Skip to content
Merged
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ Observability guide: [docs/observability.md](./docs/observability.md)

## If you REALLY want to contribute still.... read this first

Before local development, prepare the environment and install dependencies:

```bash
# Optional: only needed if you use mise for dev tool management.
mise install
bun install .
```

Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.

Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ describe("ProviderCommandReactor", () => {
(input.runtimeMode === "approval-required" || input.runtimeMode === "full-access")
? input.runtimeMode
: "full-access",
...(typeof input === "object" &&
input !== null &&
"cwd" in input &&
typeof input.cwd === "string"
? { cwd: input.cwd }
: {}),
...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}),
threadId,
resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },
Expand Down Expand Up @@ -900,6 +906,84 @@ describe("ProviderCommandReactor", () => {
expect(harness.stopSession.mock.calls.length).toBe(0);
});

it("restarts the provider session when the thread workspace changes", async () => {
const harness = await createHarness({
threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
});
// MarCode auto-archives threads whose worktree path is missing on disk
// (ProviderCommandReactor.ts:302). Create a real temp dir so the workspace
// change triggers a session restart instead of an auto-archive.
const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), "marcode-reactor-worktree-"));
const now = new Date().toISOString();

try {
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-workspace-1"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-workspace-1"),
role: "user",
text: "first in project root",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.startSession.mock.calls.length === 1);
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
cwd: "/tmp/provider-project",
});

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-worktree-change"),
threadId: ThreadId.make("thread-1"),
worktreePath,
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-workspace-2"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-workspace-2"),
role: "user",
text: "second in worktree",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.startSession.mock.calls.length === 2);
await waitFor(() => harness.sendTurn.mock.calls.length === 2);
expect(harness.stopSession.mock.calls.length).toBe(0);
expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
threadId: ThreadId.make("thread-1"),
cwd: worktreePath,
resumeCursor: { opaque: "resume-1" },
modelSelection: {
provider: "claudeAgent",
model: "claude-sonnet-4-6",
},
runtimeMode: "approval-required",
});
} finally {
fs.rmSync(worktreePath, { recursive: true, force: true });
}
});

it("restarts claude sessions when claude effort changes", async () => {
const harness = await createHarness({
threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ const make = Effect.gen(function* () {
thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null;
if (existingSessionThreadId) {
const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
const cwdChanged = effectiveCwd !== activeSession?.cwd;
const sessionModelSwitch =
currentProvider === undefined
? "in-session"
Expand All @@ -383,6 +384,7 @@ const make = Effect.gen(function* () {

if (
!runtimeModeChanged &&
!cwdChanged &&
!shouldRestartForModelChange &&
!shouldRestartForModelSelectionChange &&
!shouldRestartForAdditionalDirs
Expand All @@ -401,6 +403,9 @@ const make = Effect.gen(function* () {
currentRuntimeMode: thread.session?.runtimeMode,
desiredRuntimeMode: thread.runtimeMode,
runtimeModeChanged,
previousCwd: activeSession?.cwd,
desiredCwd: effectiveCwd,
cwdChanged,
modelChanged,
shouldRestartForModelChange,
shouldRestartForModelSelectionChange,
Expand All @@ -415,6 +420,7 @@ const make = Effect.gen(function* () {
restartedSessionThreadId: restartedSession.threadId,
provider: restartedSession.provider,
runtimeMode: restartedSession.runtimeMode,
cwd: restartedSession.cwd,
});
yield* bindSessionToThread(restartedSession);
threadSessionStartDirectories.set(threadId, [...effectiveAdditionalDirs]);
Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/processRunner.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { runProcess } from "./processRunner.ts";
import { isWindowsCommandNotFound, runProcess } from "./processRunner.ts";

describe("runProcess", () => {
it("fails when output exceeds max buffer in default mode", async () => {
Expand All @@ -21,3 +21,21 @@ describe("runProcess", () => {
expect(result.stderrTruncated).toBe(false);
});
});

describe("isWindowsCommandNotFound", () => {
it("matches the localized German cmd.exe error text", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "win32", configurable: true });

try {
expect(
isWindowsCommandNotFound(
1,
"wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt",
),
).toBe(true);
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
}
});
});
15 changes: 14 additions & 1 deletion apps/server/src/processRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,23 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un
return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`);
}

const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [
/is not recognized as an internal or external command/i,
/n.o . reconhecido como um comando interno/i,
/non . riconosciuto come comando interno o esterno/i,
/n.est pas reconnu en tant que commande interne/i,
/no se reconoce como un comando interno o externo/i,
/wird nicht als interner oder externer befehl/i,
] as const;

function hasWindowsCommandNotFoundMessage(output: string): boolean {
return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output));
}

export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean {
if (process.platform !== "win32") return false;
if (code === 9009) return true;
return /is not recognized as an internal or external command/i.test(stderr);
return hasWindowsCommandNotFoundMessage(stderr);
}

function normalizeExitError(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/project/Layers/ProjectFaviconResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const FAVICON_CANDIDATES = [
"assets/icon.png",
"assets/logo.svg",
"assets/logo.png",
".idea/icon.svg",
] as const;

// Files that may contain a <link rel="icon"> or icon metadata declaration.
Expand Down
90 changes: 90 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2914,6 +2914,96 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("preserves durable resume ids across Claude resume hooks", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const durableSessionId = "550e8400-e29b-41d4-a716-446655440000";
const transientHookSessionId = "7368d0c7-40a3-4d8a-bcc1-ac80c49f2719";

const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe(
Stream.runCollect,
Effect.forkChild,
);

yield* adapter.startSession({
threadId: RESUME_THREAD_ID,
provider: "claudeAgent",
resumeCursor: {
threadId: RESUME_THREAD_ID,
resume: durableSessionId,
resumeSessionAt: "assistant-99",
turnCount: 3,
},
runtimeMode: "full-access",
});

harness.query.emit({
type: "system",
subtype: "hook_started",
hook_id: "resume-hook-1",
hook_name: "SessionStart:resume",
hook_event: "SessionStart",
session_id: transientHookSessionId,
uuid: "resume-hook-started",
} as unknown as SDKMessage);

harness.query.emit({
type: "system",
subtype: "hook_response",
hook_id: "resume-hook-1",
hook_name: "SessionStart:resume",
hook_event: "SessionStart",
output: "",
stdout: "",
stderr: "",
outcome: "success",
session_id: transientHookSessionId,
uuid: "resume-hook-response",
} as unknown as SDKMessage);

harness.query.emit({
type: "system",
subtype: "init",
apiKeySource: "none",
claude_code_version: "test",
cwd: "/tmp/claude-adapter-test",
tools: [],
mcp_servers: [],
model: "claude-sonnet-4-5",
permissionMode: "bypassPermissions",
slash_commands: [],
output_style: "default",
skills: [],
plugins: [],
session_id: durableSessionId,
uuid: "resume-init",
} as unknown as SDKMessage);

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
const threadStartedEvents = runtimeEvents.filter((event) => event.type === "thread.started");
assert.equal(threadStartedEvents.length, 1);
const threadStarted = threadStartedEvents[0];
assert.equal(threadStarted?.type, "thread.started");
if (threadStarted?.type === "thread.started") {
assert.deepEqual(threadStarted.payload, {
providerThreadId: durableSessionId,
});
}

const activeSessions = yield* adapter.listSessions();
const resumeCursor = activeSessions[0]?.resumeCursor as
| {
readonly resume?: string;
}
| undefined;
assert.equal(resumeCursor?.resume, durableSessionId);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("uses an app-generated Claude session id for fresh sessions", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@ function isSyntheticClaudeThreadId(value: string): boolean {
return value.startsWith("claude-thread-");
}

function hasDurableClaudeSessionId(message: SDKMessage): boolean {
if (message.type !== "system") {
return true;
}

return (
message.subtype !== "hook_started" &&
message.subtype !== "hook_progress" &&
message.subtype !== "hook_response"
);
}

function toMessage(cause: unknown, fallback: string): string {
if (cause instanceof Error && cause.message.length > 0) {
return cause.message;
Expand Down Expand Up @@ -1356,6 +1368,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
if (typeof message.session_id !== "string" || message.session_id.length === 0) {
return;
}
if (!hasDurableClaudeSessionId(message)) {
return;
}
const nextThreadId = message.session_id;
context.resumeSessionId = message.session_id;
yield* updateResumeCursor(context);
Expand Down Expand Up @@ -3086,6 +3101,31 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
};

yield* Effect.annotateCurrentSpan({
"provider.kind": PROVIDER,
"provider.thread_id": threadId,
"provider.runtime_mode": input.runtimeMode,
"claude.resume.source":
existingResumeSessionId !== undefined ? "resume-session" : "generated-session",
"claude.resume.thread_id": resumeState?.threadId ?? "",
"claude.resume.session_id": existingResumeSessionId ?? "",
"claude.resume.session_at": resumeState?.resumeSessionAt ?? "",
"claude.resume.turn_count": resumeState?.turnCount ?? -1,
"claude.query.cwd": input.cwd ?? "",
"claude.query.model": apiModelId ?? "",
"claude.query.effort": effectiveEffort ?? "",
"claude.query.permission_mode": permissionMode ?? "",
"claude.query.allow_dangerously_skip_permissions": permissionMode === "bypassPermissions",
"claude.query.resume": existingResumeSessionId ?? "",
"claude.query.session_id": newSessionId ?? "",
"claude.query.include_partial_messages": true,
"claude.query.additional_directories": input.cwd ? [input.cwd] : [],
"claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES],
"claude.query.settings_json": JSON.stringify(settings),
"claude.query.extra_args_json": JSON.stringify(extraArgs),
"claude.query.path_to_executable": claudeBinaryPath,
});

const queryRuntime = yield* Effect.try({
try: () =>
createQuery({
Expand Down
Loading
Loading