diff --git a/README.md b/README.md
index dcb6452a29ee..5893a4987305 100644
--- a/README.md
+++ b/README.md
@@ -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).
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
index 4781964d765f..8c97dcfbb060 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
@@ -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}` },
@@ -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" },
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
index b1993b0b82bc..48399a5fe16c 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -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"
@@ -383,6 +384,7 @@ const make = Effect.gen(function* () {
if (
!runtimeModeChanged &&
+ !cwdChanged &&
!shouldRestartForModelChange &&
!shouldRestartForModelSelectionChange &&
!shouldRestartForAdditionalDirs
@@ -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,
@@ -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]);
diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts
index 15ad4daf09bb..af4ff528ee00 100644
--- a/apps/server/src/processRunner.test.ts
+++ b/apps/server/src/processRunner.test.ts
@@ -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 () => {
@@ -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 });
+ }
+ });
+});
diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts
index 5402612887d5..03b164fc241f 100644
--- a/apps/server/src/processRunner.ts
+++ b/apps/server/src/processRunner.ts
@@ -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(
diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts
index 3004a7a45cfd..f2d44a76096a 100644
--- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts
+++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts
@@ -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 or icon metadata declaration.
diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
index 95741d564d6d..06524176b08a 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
@@ -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* () {
diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts
index e1cc19e66f77..ebbd29581d4d 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts
@@ -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;
@@ -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);
@@ -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({
diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts
index f61f09bb7d34..cb030adbf411 100644
--- a/apps/server/src/provider/Layers/ProviderService.test.ts
+++ b/apps/server/src/provider/Layers/ProviderService.test.ts
@@ -964,9 +964,10 @@ routing.layer("ProviderServiceLive routing", (it) => {
const provider = yield* ProviderService;
const runtimeRepository = yield* ProviderSessionRuntimeRepository;
- const session = yield* provider.startSession(asThreadId("thread-1"), {
+ const threadId = asThreadId("thread-runtime-status");
+ const session = yield* provider.startSession(threadId, {
provider: "codex",
- threadId: asThreadId("thread-1"),
+ threadId,
runtimeMode: "full-access",
});
yield* provider.sendTurn({
@@ -992,7 +993,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
lastError: string | null;
lastRuntimeEvent: string | null;
};
- assert.equal(runtimePayload.cwd, process.cwd());
+ assert.equal(runtimePayload.cwd, session.cwd);
assert.equal(runtimePayload.model, null);
assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`);
assert.equal(runtimePayload.lastError, null);
@@ -1091,6 +1092,94 @@ routing.layer("ProviderServiceLive routing", (it) => {
fs.rmSync(tempDir, { recursive: true, force: true });
}).pipe(Effect.provide(NodeServices.layer)),
);
+
+ it.effect(
+ "reuses persisted cwd when startSession resumes a claude session without cwd input",
+ () =>
+ Effect.gen(function* () {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cwd-"));
+ const dbPath = path.join(tempDir, "orchestration.sqlite");
+ const persistenceLayer = makeSqlitePersistenceLive(dbPath);
+ const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
+ Layer.provide(persistenceLayer),
+ );
+
+ const firstClaude = makeFakeCodexAdapter("claudeAgent");
+ const firstRegistry: typeof ProviderAdapterRegistry.Service = {
+ getByProvider: (provider) =>
+ provider === "claudeAgent"
+ ? Effect.succeed(firstClaude.adapter)
+ : Effect.fail(new ProviderUnsupportedError({ provider })),
+ listProviders: () => Effect.succeed(["claudeAgent"]),
+ };
+ const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(
+ Layer.provide(runtimeRepositoryLayer),
+ );
+ const firstProviderLayer = makeProviderServiceLive().pipe(
+ Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
+ Layer.provide(firstDirectoryLayer),
+ Layer.provide(defaultServerSettingsLayer),
+ Layer.provide(AnalyticsServiceNoopLive),
+ );
+
+ const initial = yield* Effect.gen(function* () {
+ const provider = yield* ProviderService;
+ return yield* provider.startSession(asThreadId("thread-claude-cwd"), {
+ provider: "claudeAgent",
+ threadId: asThreadId("thread-claude-cwd"),
+ cwd: "/tmp/project-claude-cwd",
+ runtimeMode: "full-access",
+ });
+ }).pipe(Effect.provide(firstProviderLayer));
+
+ const secondClaude = makeFakeCodexAdapter("claudeAgent");
+ const secondRegistry: typeof ProviderAdapterRegistry.Service = {
+ getByProvider: (provider) =>
+ provider === "claudeAgent"
+ ? Effect.succeed(secondClaude.adapter)
+ : Effect.fail(new ProviderUnsupportedError({ provider })),
+ listProviders: () => Effect.succeed(["claudeAgent"]),
+ };
+ const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(
+ Layer.provide(runtimeRepositoryLayer),
+ );
+ const secondProviderLayer = makeProviderServiceLive().pipe(
+ Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
+ Layer.provide(secondDirectoryLayer),
+ Layer.provide(defaultServerSettingsLayer),
+ Layer.provide(AnalyticsServiceNoopLive),
+ );
+
+ secondClaude.startSession.mockClear();
+
+ yield* Effect.gen(function* () {
+ const provider = yield* ProviderService;
+ yield* provider.startSession(initial.threadId, {
+ provider: "claudeAgent",
+ threadId: initial.threadId,
+ runtimeMode: "full-access",
+ });
+ }).pipe(Effect.provide(secondProviderLayer));
+
+ assert.equal(secondClaude.startSession.mock.calls.length, 1);
+ const resumedStartInput = secondClaude.startSession.mock.calls[0]?.[0];
+ assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true);
+ if (resumedStartInput && typeof resumedStartInput === "object") {
+ const startPayload = resumedStartInput as {
+ provider?: string;
+ cwd?: string;
+ resumeCursor?: unknown;
+ threadId?: string;
+ };
+ assert.equal(startPayload.provider, "claudeAgent");
+ assert.equal(startPayload.cwd, "/tmp/project-claude-cwd");
+ assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
+ assert.equal(startPayload.threadId, initial.threadId);
+ }
+
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }).pipe(Effect.provide(NodeServices.layer)),
+ );
});
const fanout = makeProviderServiceLayer();
diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts
index 03885e354d74..ad89bcb699c7 100644
--- a/apps/server/src/provider/Layers/ProviderService.ts
+++ b/apps/server/src/provider/Layers/ProviderService.ts
@@ -363,9 +363,31 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
(persistedBinding?.provider === input.provider
? persistedBinding.resumeCursor
: undefined);
+ const effectiveCwd =
+ input.cwd ??
+ (persistedBinding?.provider === input.provider
+ ? readPersistedCwd(persistedBinding.runtimePayload)
+ : undefined);
+ yield* Effect.annotateCurrentSpan({
+ "provider.resume_cursor.source":
+ input.resumeCursor !== undefined
+ ? "request"
+ : effectiveResumeCursor !== undefined && persistedBinding?.provider === input.provider
+ ? "persisted"
+ : "none",
+ "provider.resume_cursor.present": effectiveResumeCursor !== undefined,
+ "provider.cwd.source":
+ input.cwd !== undefined
+ ? "request"
+ : effectiveCwd !== undefined && persistedBinding?.provider === input.provider
+ ? "persisted"
+ : "none",
+ "provider.cwd.effective": effectiveCwd ?? "",
+ });
const adapter = yield* registry.getByProvider(input.provider);
const session = yield* adapter.startSession({
...input,
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}),
});
diff --git a/apps/web/src/environments/runtime/service.test.ts b/apps/web/src/environments/runtime/service.test.ts
index 7a4af4049808..40ec455adc13 100644
--- a/apps/web/src/environments/runtime/service.test.ts
+++ b/apps/web/src/environments/runtime/service.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
-import { shouldApplyTerminalEvent } from "./service";
+import {
+ shouldApplyProjectionEvent,
+ shouldApplyProjectionSnapshot,
+ shouldApplyTerminalEvent,
+} from "./service";
describe("shouldApplyTerminalEvent", () => {
it("applies terminal events for draft-only threads", () => {
@@ -39,3 +43,106 @@ describe("shouldApplyTerminalEvent", () => {
).toBe(true);
});
});
+
+describe("shouldApplyProjectionSnapshot", () => {
+ it("accepts the first snapshot for an environment", () => {
+ expect(
+ shouldApplyProjectionSnapshot({
+ current: null,
+ next: {
+ snapshotSequence: 1,
+ updatedAt: "2026-04-22T10:00:00.000Z",
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it("drops snapshots with an older sequence", () => {
+ expect(
+ shouldApplyProjectionSnapshot({
+ current: {
+ sequence: 5,
+ updatedAt: "2026-04-22T10:05:00.000Z",
+ },
+ next: {
+ snapshotSequence: 4,
+ updatedAt: "2026-04-22T10:06:00.000Z",
+ },
+ }),
+ ).toBe(false);
+ });
+
+ it("drops snapshots with the same sequence and older timestamp", () => {
+ expect(
+ shouldApplyProjectionSnapshot({
+ current: {
+ sequence: 5,
+ updatedAt: "2026-04-22T10:05:00.000Z",
+ },
+ next: {
+ snapshotSequence: 5,
+ updatedAt: "2026-04-22T10:04:59.000Z",
+ },
+ }),
+ ).toBe(false);
+ });
+
+ it("accepts snapshots with the same sequence and a newer timestamp", () => {
+ expect(
+ shouldApplyProjectionSnapshot({
+ current: {
+ sequence: 5,
+ updatedAt: "2026-04-22T10:05:00.000Z",
+ },
+ next: {
+ snapshotSequence: 5,
+ updatedAt: "2026-04-22T10:05:01.000Z",
+ },
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("shouldApplyProjectionEvent", () => {
+ it("accepts the first event for an environment", () => {
+ expect(
+ shouldApplyProjectionEvent({
+ current: null,
+ sequence: 1,
+ }),
+ ).toBe(true);
+ });
+
+ it("drops stale or duplicate events", () => {
+ expect(
+ shouldApplyProjectionEvent({
+ current: {
+ sequence: 5,
+ updatedAt: "2026-04-22T10:05:00.000Z",
+ },
+ sequence: 5,
+ }),
+ ).toBe(false);
+ expect(
+ shouldApplyProjectionEvent({
+ current: {
+ sequence: 5,
+ updatedAt: "2026-04-22T10:05:00.000Z",
+ },
+ sequence: 4,
+ }),
+ ).toBe(false);
+ });
+
+ it("accepts newer events", () => {
+ expect(
+ shouldApplyProjectionEvent({
+ current: {
+ sequence: 5,
+ updatedAt: "2026-04-22T10:05:00.000Z",
+ },
+ sequence: 6,
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts
index 385a87947177..2dfa4b96a4b3 100644
--- a/apps/web/src/environments/runtime/service.ts
+++ b/apps/web/src/environments/runtime/service.ts
@@ -95,6 +95,13 @@ type ThreadDetailSubscriptionEntry = {
const environmentConnections = new Map();
const environmentConnectionListeners = new Set<() => void>();
const threadDetailSubscriptions = new Map();
+const lastAppliedProjectionVersionByEnvironment = new Map<
+ EnvironmentId,
+ {
+ readonly sequence: number;
+ readonly updatedAt: string | null;
+ }
+>();
let activeService: EnvironmentServiceState | null = null;
let needsProviderInvalidation = false;
@@ -110,6 +117,98 @@ const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 2 * 60 * 1000;
const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32;
const NOOP = () => undefined;
+function compareAppliedProjectionVersion(
+ left: { readonly sequence: number; readonly updatedAt: string | null },
+ right: { readonly sequence: number; readonly updatedAt: string | null },
+): number {
+ if (left.sequence !== right.sequence) {
+ return left.sequence - right.sequence;
+ }
+
+ const leftUpdatedAt = left.updatedAt ?? "";
+ const rightUpdatedAt = right.updatedAt ?? "";
+ if (leftUpdatedAt === rightUpdatedAt) {
+ return 0;
+ }
+
+ return leftUpdatedAt < rightUpdatedAt ? -1 : 1;
+}
+
+function toAppliedProjectionVersion(
+ snapshot: Pick,
+): {
+ readonly sequence: number;
+ readonly updatedAt: string;
+} {
+ return {
+ sequence: snapshot.snapshotSequence,
+ updatedAt: snapshot.updatedAt,
+ };
+}
+
+export function shouldApplyProjectionSnapshot(input: {
+ readonly current: {
+ readonly sequence: number;
+ readonly updatedAt: string | null;
+ } | null;
+ readonly next: Pick;
+}): boolean {
+ if (input.current === null) {
+ return true;
+ }
+
+ return compareAppliedProjectionVersion(input.current, toAppliedProjectionVersion(input.next)) < 0;
+}
+
+export function shouldApplyProjectionEvent(input: {
+ readonly current: {
+ readonly sequence: number;
+ readonly updatedAt: string | null;
+ } | null;
+ readonly sequence: number;
+}): boolean {
+ if (input.current === null) {
+ return true;
+ }
+
+ return input.sequence > input.current.sequence;
+}
+
+function readLastAppliedProjectionVersion(environmentId: EnvironmentId): {
+ readonly sequence: number;
+ readonly updatedAt: string | null;
+} | null {
+ return lastAppliedProjectionVersionByEnvironment.get(environmentId) ?? null;
+}
+
+function markAppliedProjectionSnapshot(
+ environmentId: EnvironmentId,
+ snapshot: Pick,
+): void {
+ const nextVersion = toAppliedProjectionVersion(snapshot);
+ const currentVersion = readLastAppliedProjectionVersion(environmentId);
+ if (
+ currentVersion !== null &&
+ compareAppliedProjectionVersion(currentVersion, nextVersion) >= 0
+ ) {
+ return;
+ }
+
+ lastAppliedProjectionVersionByEnvironment.set(environmentId, nextVersion);
+}
+
+function markAppliedProjectionEvent(environmentId: EnvironmentId, sequence: number): void {
+ const currentVersion = readLastAppliedProjectionVersion(environmentId);
+ if (currentVersion !== null && sequence <= currentVersion.sequence) {
+ return;
+ }
+
+ lastAppliedProjectionVersionByEnvironment.set(environmentId, {
+ sequence,
+ updatedAt: currentVersion?.updatedAt ?? null,
+ });
+}
+
function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string {
return scopedThreadKey(scopeThreadRef(environmentId, threadId));
}
@@ -618,6 +717,15 @@ export function applyEnvironmentThreadDetailEvent(
}
function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) {
+ if (
+ !shouldApplyProjectionEvent({
+ current: readLastAppliedProjectionVersion(environmentId),
+ sequence: event.sequence,
+ })
+ ) {
+ return;
+ }
+
const threadId =
event.kind === "thread-upserted"
? event.thread.id
@@ -628,6 +736,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En
const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined;
useStore.getState().applyShellEvent(event, environmentId);
+ markAppliedProjectionEvent(environmentId, event.sequence);
switch (event.kind) {
case "project-upserted":
@@ -661,7 +770,17 @@ function createEnvironmentConnectionHandlers() {
return {
applyShellEvent,
syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => {
+ if (
+ !shouldApplyProjectionSnapshot({
+ current: readLastAppliedProjectionVersion(environmentId),
+ next: snapshot,
+ })
+ ) {
+ return;
+ }
+
useStore.getState().syncServerShellSnapshot(snapshot, environmentId);
+ markAppliedProjectionSnapshot(environmentId, snapshot);
reconcileThreadDetailSubscriptionsForEnvironment(
environmentId,
snapshot.threads.map((thread) => thread.id),
@@ -776,6 +895,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise
}
disposeThreadDetailSubscriptionsForEnvironment(environmentId);
+ lastAppliedProjectionVersionByEnvironment.delete(environmentId);
environmentConnections.delete(environmentId);
emitEnvironmentConnectionRegistryChange();
await connection.dispose();
@@ -1104,6 +1224,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
export async function resetEnvironmentServiceForTests(): Promise {
stopActiveService();
+ lastAppliedProjectionVersionByEnvironment.clear();
for (const key of Array.from(threadDetailSubscriptions.keys())) {
disposeThreadDetailSubscriptionByKey(key);
}
diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts
index d21e37b5298d..ecc3b88275ce 100644
--- a/apps/web/src/rpc/requestLatencyState.ts
+++ b/apps/web/src/rpc/requestLatencyState.ts
@@ -36,7 +36,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray {
}
function shouldTrackRpcAck(tag: string): boolean {
- return !tag.startsWith("subscribe");
+ return !tag.includes("subscribe");
}
export function getSlowRpcAckRequests(): ReadonlyArray {
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index d75da30ea938..d5dc70fa1103 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -213,6 +213,7 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ
switch (requestType) {
case "command_execution_approval":
case "exec_command_approval":
+ case "dynamic_tool_call":
return "command";
case "file_read_approval":
return "file-read";