diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md
index 0c4194b88..f50b732a9 100644
--- a/.agents/upstream-review.md
+++ b/.agents/upstream-review.md
@@ -55,6 +55,7 @@ Historical groups are indexed in the linked archive. This file migration changes
| Live activity motion parity / `6c583620ff7ad3235b135af7107c0543467eecfa` | Corrects Pylon's rendering of already-classified `c7c1dfe4df` (#9709), `ce4712d5b0` (#9799) and the #444 port of `6cf0c6ea55` (#9093) / `c3b8825bf4` (#9606) | Pylon had diverged: `cdde7f3150` gave every active row the sweeping overlay and deleted `live-tool-shine`, while upstream reserves the overlay for label-only rows (`active && shimmer`, passed only by the Thinking row) and keeps the shine for tool labels. Restores upstream's `shimmer` prop, `animated = active && !failed` guard, row-level observer ref, overlay on worktree setup and compaction, and both CSS utilities byte-identical to upstream. That same commit also deleted `@utility visible-animate-spin` as dead while `spinner.tsx` and `refresh-icon.tsx` still applied it, so every spinner and refresh glyph was static; restored. Preserved: Pylon's `workingStepLabel`, and the `document.hasFocus()` gate AGENTS.md requires and upstream lacks. Cursor unchanged. | [Live activity parity #452](https://github.com/pylon-code/pylon/pull/452); 161 focused tests, web typecheck, scoped lint/format, region diffed against `t3code-upstream/main`. |
| Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. |
| Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. |
+| First-run welcome wizard, agent setup and transcript import / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full nine-source list in [#459](https://github.com/pylon-code/pylon/pull/459), from #5362 through #10547 | Adopted all nine: the `/welcome` overlay with multi-computer selection, Claude Code and Codex readiness terminals with native installers, repository-grouped project import, server transcript scan/import with provider resume cursors, settings-hydration hardening, and the light-mode and theme follow-ups. Pylon adaptations: ProviderSessionDirectory keeps its commit guard and exact removal beside insert-ignore and imported transcript records; import invariants use Pylon's open-request scan and Pylon-only thread fields; tests use Pylon's rollback revision and provider-instance compare-and-sets; PylonMark and Pylon/Pylon Connect copy; scans skip Pylon runtime-home worktrees; imported projects keep resolving a default model until #9754 lands. Saved client settings decode per value and keep undecodable stored values (T3 Code's `confirmQuit` shape on a shared origin) instead of failing closed; desktop keeps the default quit hold when its settings file is unreadable. Duplicated sign-in: setup uses Pylon's terminal-free `ProviderSignInDialog` for Claude Code and keeps terminals for installs and `codex login`. Setup reopens from the command palette, and one transcript scanner is shared across connections. Prime and Antigravity stay in Settings → Providers with the other opt-in providers. `3faeee49ac67dfd9534369f1e1c627c0356b75ac` (#10832) is excluded and sequenced later. No migration. Cursor unchanged. | [Welcome wizard #459](https://github.com/pylon-code/pylon/pull/459); 1,703 focused tests, seven package typechecks, scoped lint/format and knip. |
## Deferred register
diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts
index 5034df44c..a07019596 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts
@@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer) => {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const settings = yield* DesktopClientSettings.DesktopClientSettings;
return {
- result: yield* settings.get,
+ result: yield* Effect.result(settings.get),
settingsPath: environment.clientSettingsPath,
records,
};
@@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => {
Effect.gen(function* () {
const result = yield* readWithLogs(FileSystem.layerNoop({}));
- assert.isTrue(Option.isNone(result.result));
+ if (result.result._tag !== "Success") return assert.fail("expected a successful read");
+ assert.isTrue(Option.isNone(result.result.success));
assert.deepEqual(result.records, []);
}),
);
- it.effect("logs non-missing filesystem failures with the settings path", () => {
+ it.effect("reports non-missing filesystem failures and logs the settings path", () => {
const permissionError = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
@@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => {
}),
);
- assert.isTrue(Option.isNone(result.result));
+ if (result.result._tag !== "Failure") return assert.fail("expected a read failure");
+ assert.instanceOf(
+ result.result.failure,
+ DesktopClientSettings.DesktopClientSettingsReadError,
+ );
+ assert.strictEqual(result.result.failure.cause, permissionError);
assert.equal(result.records.length, 1);
assert.deepEqual(result.records[0]?.message, [
"Could not read desktop client settings.",
@@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => {
});
});
- it.effect("logs malformed settings documents with the settings path", () =>
+ it.effect("reads malformed settings documents as absent and logs the settings path", () =>
Effect.gen(function* () {
const result = yield* readWithLogs(
FileSystem.layerNoop({
@@ -111,7 +117,8 @@ describe("DesktopClientSettings diagnostics", () => {
}),
);
- assert.isTrue(Option.isNone(result.result));
+ if (result.result._tag !== "Success") return assert.fail("expected a successful read");
+ assert.isTrue(Option.isNone(result.result.success));
assert.equal(result.records.length, 1);
const message = result.records[0]?.message;
if (!Array.isArray(message)) {
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 6673c4950..39c177163 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -43,6 +43,7 @@ const clientSettings: ClientSettings = {
fontSizeTerminal: 12,
fontSmoothing: true,
glassOpacity: 80,
+ onboardingCompletedAt: null,
panelAnimationDurationMs: 0,
proactivePanelsEnabled: true,
showSkillsInSlashMenu: false,
@@ -136,6 +137,59 @@ describe("DesktopClientSettings", () => {
),
);
+ for (const failure of [
+ { label: "permission", reason: "PermissionDenied" },
+ { label: "I/O", reason: "Unknown" },
+ ] as const) {
+ it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () =>
+ withClientSettings(
+ Effect.gen(function* () {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const settings = yield* DesktopClientSettings.DesktopClientSettings;
+ const savedSettings = {
+ ...clientSettings,
+ onboardingCompletedAt: "2026-09-05T12:00:00.000Z",
+ };
+ yield* settings.set(savedSettings);
+ const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath);
+ const cause = PlatformError.systemError({
+ _tag: failure.reason,
+ module: "FileSystem",
+ method: "readFileString",
+ pathOrDescriptor: environment.clientSettingsPath,
+ });
+ let failRead = true;
+ const retryableSettings = yield* DesktopClientSettings.make.pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.FileSystem.of({
+ ...fileSystem,
+ readFileString: (path) =>
+ Effect.suspend(() =>
+ failRead ? Effect.fail(cause) : fileSystem.readFileString(path),
+ ),
+ }),
+ ),
+ );
+
+ const error = yield* retryableSettings.get.pipe(Effect.flip);
+ assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
+ assert.equal(error.operation, "read-file");
+ assert.equal(error.path, environment.clientSettingsPath);
+ assert.strictEqual(error.cause, cause);
+ assert.equal(
+ yield* fileSystem.readFileString(environment.clientSettingsPath),
+ savedContents,
+ );
+
+ failRead = false;
+ assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings));
+ }),
+ ),
+ );
+ }
+
it.effect("reports the failed client settings write operation and path", () =>
withClientSettings(
Effect.gen(function* () {
@@ -222,7 +276,7 @@ describe("DesktopClientSettings", () => {
),
);
- it.effect("treats malformed client settings documents as absent", () =>
+ it.effect("reads a malformed settings file as no saved settings without rewriting it", () =>
withClientSettings(
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
@@ -232,7 +286,95 @@ describe("DesktopClientSettings", () => {
yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json");
assert.isTrue(Option.isNone(yield* settings.get));
+ assert.equal(yield* fileSystem.readFileString(environment.clientSettingsPath), "{not-json");
}),
),
);
+
+ for (const document of [
+ { label: "direct", contents: '{"fontSizeCode":"large","timestampFormat":"12-hour"}' },
+ {
+ label: "legacy",
+ contents: '{"settings":{"fontSizeCode":"large","timestampFormat":"12-hour"}}',
+ },
+ ]) {
+ it.effect(`keeps readable ${document.label} settings beside an undecodable value`, () =>
+ withClientSettings(
+ Effect.gen(function* () {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const settings = yield* DesktopClientSettings.DesktopClientSettings;
+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
+ yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents);
+
+ assert.deepEqual(
+ yield* settings.get,
+ Option.some({
+ ...(yield* decodeClientSettingsJson("{}")),
+ timestampFormat: "12-hour" as const,
+ }),
+ );
+ assert.equal(
+ yield* fileSystem.readFileString(environment.clientSettingsPath),
+ document.contents,
+ );
+ }),
+ ),
+ );
+ }
+
+ it.effect("leaves an undecodable value in the file until that setting changes", () =>
+ withClientSettings(
+ Effect.gen(function* () {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const settings = yield* DesktopClientSettings.DesktopClientSettings;
+ const readDocument = Effect.flatMap(
+ fileSystem.readFileString(environment.clientSettingsPath),
+ decodeRecordJson,
+ );
+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
+ yield* fileSystem.writeFileString(
+ environment.clientSettingsPath,
+ '{"confirmQuit":"hold","timestampFormat":"12-hour"}',
+ );
+
+ const saved = Option.getOrThrow(yield* settings.get);
+ assert.isTrue(saved.confirmQuit);
+ yield* settings.set({ ...saved, onboardingCompletedAt: "2026-09-10T12:00:00.000Z" });
+ assert.deepInclude(yield* readDocument, {
+ confirmQuit: "hold",
+ timestampFormat: "12-hour",
+ onboardingCompletedAt: "2026-09-10T12:00:00.000Z",
+ });
+
+ yield* settings.set({ ...saved, confirmQuit: false });
+ assert.deepInclude(yield* readDocument, { confirmQuit: false });
+ yield* settings.set({ ...saved, confirmQuit: false, wordWrap: false });
+ assert.deepInclude(yield* readDocument, { confirmQuit: false, wordWrap: false });
+ }),
+ ),
+ );
+
+ it.effect("keeps the default quit hold when settings cannot be read", () =>
+ Effect.gen(function* () {
+ const failing = DesktopClientSettings.DesktopClientSettings.of({
+ get: Effect.fail(
+ new DesktopClientSettings.DesktopClientSettingsReadError({
+ operation: "read-file",
+ path: "/unreadable/client-settings.json",
+ cause: new Error("permission denied"),
+ }),
+ ),
+ set: () => Effect.void,
+ });
+ const disabled = DesktopClientSettings.DesktopClientSettings.of({
+ get: Effect.succeed(Option.some({ ...clientSettings, confirmQuit: false })),
+ set: () => Effect.void,
+ });
+
+ assert.isTrue(yield* DesktopClientSettings.readConfirmQuit(failing));
+ assert.isFalse(yield* DesktopClientSettings.readConfirmQuit(disabled));
+ }),
+ );
});
diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts
index 4ff091e27..f8085edbd 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.ts
@@ -1,4 +1,11 @@
-import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
+import {
+ DEFAULT_CLIENT_SETTINGS,
+ decodeStoredClientSettings,
+ encodeStoredClientSettings,
+ retainUnreadClientSettings,
+ type ClientSettings,
+ type StoredClientSettings,
+} from "@t3tools/contracts";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
@@ -12,24 +19,23 @@ import * as Ref from "effect/Ref";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
-const ClientSettingsDocumentSchema = Schema.Struct({
- settings: ClientSettingsSchema,
-});
-
-const ClientSettingsJson = fromLenientJson(ClientSettingsSchema);
-const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema);
-const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect(
- LegacyClientSettingsDocumentJson,
+const decodeClientSettingsDocument = Schema.decodeEffect(
+ fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)),
);
-const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson);
-const decodeClientSettingsJson = (raw: string): Effect.Effect =>
- decodeLegacyClientSettingsDocumentJson(raw).pipe(
- Effect.map((document) => document.settings),
- Effect.catchTags({
- SchemaError: () => decodeClientSettingsJsonValue(raw),
- }),
- );
-const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson);
+const encodeClientSettingsDocument = Schema.encodeEffect(fromLenientJson(Schema.Unknown));
+
+export class DesktopClientSettingsReadError extends Schema.TaggedErrorClass()(
+ "DesktopClientSettingsReadError",
+ {
+ operation: Schema.Literal("read-file"),
+ path: Schema.String,
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Desktop client settings read failed during ${this.operation} at ${this.path}.`;
+ }
+}
const DesktopClientSettingsWriteOperation = Schema.Literals([
"create-temporary-file-name",
@@ -55,17 +61,23 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass>;
+ readonly get: Effect.Effect, DesktopClientSettingsReadError>;
readonly set: (
settings: ClientSettings,
) => Effect.Effect;
}
>()("@t3tools/desktop/settings/DesktopClientSettings") {}
+/**
+ * Storage failures fail the read so the renderer can retry without replacing
+ * preferences it never saw. A document that parses but holds values this build
+ * cannot decode keeps every readable setting and defaults the rest; a document
+ * that is not settings JSON at all reads as no saved settings.
+ */
const readClientSettings = (
fileSystem: FileSystem.FileSystem,
settingsPath: string,
-): Effect.Effect> =>
+): Effect.Effect, DesktopClientSettingsReadError> =>
fileSystem.readFileString(settingsPath).pipe(
Effect.map(Option.some),
Effect.catchTags({
@@ -74,20 +86,47 @@ const readClientSettings = (
? Effect.succeed(Option.none())
: Effect.logWarning("Could not read desktop client settings.", cause).pipe(
Effect.annotateLogs({ settingsPath }),
- Effect.as(Option.none()),
+ Effect.andThen(
+ Effect.fail(
+ new DesktopClientSettingsReadError({
+ operation: "read-file",
+ path: settingsPath,
+ cause,
+ }),
+ ),
+ ),
),
}),
Effect.flatMap(
Option.match({
- onNone: () => Effect.succeed(Option.none()),
+ onNone: () => Effect.succeed(Option.none()),
onSome: (raw) =>
- decodeClientSettingsJson(raw).pipe(
- Effect.map((settings) => Option.some(settings)),
+ decodeClientSettingsDocument(raw).pipe(
+ Effect.flatMap((document) => {
+ // Legacy files wrap the settings in a `settings` key.
+ const stored = decodeStoredClientSettings(
+ Object.hasOwn(document, "settings") ? document.settings : document,
+ );
+ if (stored === null) {
+ return Effect.logWarning("Could not decode desktop client settings.").pipe(
+ Effect.annotateLogs({ settingsPath }),
+ Effect.as(Option.none()),
+ );
+ }
+ const unreadSettings = Object.keys(stored.unreadValues);
+ return (
+ unreadSettings.length === 0
+ ? Effect.void
+ : Effect.logWarning(
+ "Some desktop client settings could not be decoded; using defaults for them.",
+ ).pipe(Effect.annotateLogs({ settingsPath, unreadSettings }))
+ ).pipe(Effect.as(Option.some(stored)));
+ }),
Effect.catchTags({
SchemaError: (cause) =>
Effect.logWarning("Could not decode desktop client settings.", cause).pipe(
Effect.annotateLogs({ settingsPath }),
- Effect.as(Option.none()),
+ Effect.as(Option.none()),
),
}),
),
@@ -95,16 +134,32 @@ const readClientSettings = (
),
);
+/** Reads the hold-to-quit preference, keeping the default hold when settings cannot be read. */
+export const readConfirmQuit = (
+ clientSettings: DesktopClientSettings["Service"],
+): Effect.Effect =>
+ clientSettings.get.pipe(
+ Effect.map(
+ Option.match({
+ onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit,
+ onSome: (settings) => settings.confirmQuit,
+ }),
+ ),
+ Effect.orElseSucceed(() => DEFAULT_CLIENT_SETTINGS.confirmQuit),
+ );
+
const writeClientSettings = Effect.fnUntraced(function* (input: {
readonly fileSystem: FileSystem.FileSystem;
readonly path: Path.Path;
readonly settingsPath: string;
- readonly settings: ClientSettings;
+ readonly stored: StoredClientSettings;
readonly suffix: string;
}): Effect.fn.Return {
const directory = input.path.dirname(input.settingsPath);
const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
- const encoded = yield* encodeClientSettingsJson(input.settings).pipe(
+ const encoded = yield* encodeClientSettingsDocument(
+ encodeStoredClientSettings(input.stored),
+ ).pipe(
Effect.mapError(
(cause) =>
new DesktopClientSettingsWriteError({
@@ -151,33 +206,38 @@ export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const crypto = yield* Crypto.Crypto;
+ // The last read document, so writes keep values this build could not decode.
+ const lastRead = yield* Ref.make(null);
return DesktopClientSettings.of({
get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
+ Effect.tap((stored) => Ref.set(lastRead, Option.getOrNull(stored))),
+ Effect.map(Option.map((stored) => stored.settings)),
Effect.withSpan("desktop.clientSettings.get"),
),
set: (settings) =>
- crypto.randomUUIDv4.pipe(
- Effect.map((uuid) => uuid.replace(/-/g, "")),
- Effect.mapError(
- (cause) =>
- new DesktopClientSettingsWriteError({
- operation: "create-temporary-file-name",
- path: environment.clientSettingsPath,
- cause,
- }),
- ),
- Effect.flatMap((suffix) =>
- writeClientSettings({
- fileSystem,
- path,
- settingsPath: environment.clientSettingsPath,
- settings,
- suffix,
- }),
- ),
- Effect.withSpan("desktop.clientSettings.set"),
- ),
+ Effect.gen(function* () {
+ const suffix = yield* crypto.randomUUIDv4.pipe(
+ Effect.map((uuid) => uuid.replace(/-/g, "")),
+ Effect.mapError(
+ (cause) =>
+ new DesktopClientSettingsWriteError({
+ operation: "create-temporary-file-name",
+ path: environment.clientSettingsPath,
+ cause,
+ }),
+ ),
+ );
+ const stored = retainUnreadClientSettings(settings, yield* Ref.get(lastRead));
+ yield* writeClientSettings({
+ fileSystem,
+ path,
+ settingsPath: environment.clientSettingsPath,
+ stored,
+ suffix,
+ });
+ yield* Ref.set(lastRead, stored);
+ }).pipe(Effect.withSpan("desktop.clientSettings.set")),
});
});
diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
index 980ba15ef..d0f283adf 100644
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -8,8 +8,6 @@ import * as Ref from "effect/Ref";
import * as Electron from "electron";
-import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
-
import * as DesktopAssets from "../app/DesktopAssets.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import { makeComponentLogger } from "../app/DesktopObservability.ts";
@@ -571,16 +569,7 @@ export const make = Effect.gen(function* () {
// renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL.
const quitHoldHandler = makeQuitHoldHandler({
platform: environment.platform,
- isEnabled: () =>
- runPromise(
- Effect.map(
- clientSettings.get,
- Option.match({
- onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit,
- onSome: (settings) => settings.confirmQuit,
- }),
- ),
- ),
+ isEnabled: () => runPromise(DesktopClientSettings.readConfirmQuit(clientSettings)),
notify: (state) => {
if (!window.isDestroyed()) {
window.webContents.send(QUIT_SHORTCUT_CHANNEL, state);
diff --git a/apps/server/package.json b/apps/server/package.json
index 2d2c58cc4..60b85dac6 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -35,6 +35,8 @@
"effect": "catalog:",
"msgpackr-extract": "3.0.4",
"node-pty": "^1.1.0",
+ "stream-chain": "^4.2.5",
+ "stream-json": "3.6.0",
"yaml": "catalog:",
"yauzl": "^3.4.0"
},
diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts
index fd80e2b4f..4fa695755 100644
--- a/apps/server/src/auth/RpcAuthorization.test.ts
+++ b/apps/server/src/auth/RpcAuthorization.test.ts
@@ -100,6 +100,15 @@ describe("RPC authorization scopes", () => {
);
});
+ it("requires write access to import agent session history", () => {
+ expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe(
+ AuthOrchestrationReadScope,
+ );
+ expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe(
+ AuthOrchestrationOperateScope,
+ );
+ });
+
it("reads the reviewer menu under the same scope as the pull request it belongs to", () => {
// The candidate list is a read like the detail beside it, and asking somebody for a review is
// a write like every other pull request operation.
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index 0fbf3d2d5..a6b257ac6 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -123,6 +123,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
[WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
+ [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,
+ [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope,
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
[WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
[WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
index 21356e955..7549b168b 100644
--- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
+++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
@@ -91,6 +91,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () =>
Effect.sync(() => {
getThreadCheckpointContextCalls += 1;
@@ -205,6 +206,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadRuntimeContext: () => Effect.die("unused"),
@@ -294,6 +296,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadRuntimeContext: () => Effect.die("unused"),
@@ -368,6 +371,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadRuntimeContext: () => Effect.die("unused"),
@@ -427,6 +431,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadRuntimeContext: () => Effect.die("unused"),
diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
index c85af28f5..4dab1738e 100644
--- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
@@ -1451,6 +1451,36 @@ describe("CheckpointReactor", () => {
).toBe("v1\n");
});
+ it("does not create checkpoints while importing historical user messages", async () => {
+ const harness = await createHarness({
+ hasSession: false,
+ seedFilesystemCheckpoints: false,
+ threadWorktreePath: null,
+ });
+ if (runtime === null) throw new Error("Checkpoint test runtime was not initialized.");
+
+ await runtime.runPromise(
+ harness.engine.dispatch({
+ type: "thread.history.import",
+ commandId: CommandId.make("cmd-import-history-without-checkpoint"),
+ threadId: ThreadId.make("thread-1"),
+ messages: [
+ {
+ messageId: MessageId.make("imported-user-message"),
+ role: "user",
+ text: "A message from an existing agent session",
+ createdAt: "2026-01-01T00:00:00.000Z",
+ },
+ ],
+ }),
+ );
+ await harness.drain();
+
+ expect(
+ gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)),
+ ).toBe(false);
+ });
+
it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => {
const harness = await createHarness({
hasSession: false,
diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts
index 938f8ca23..e778d7045 100644
--- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts
+++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts
@@ -753,6 +753,7 @@ export const make = Effect.gen(function* () {
) {
if (event.type === "thread.message-sent") {
if (
+ event.metadata.historyImport === true ||
event.payload.role !== "user" ||
event.payload.streaming ||
event.payload.turnId !== null
diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
index 883c77c1e..6eee5e19b 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
@@ -605,6 +605,7 @@ describe("OrchestrationEngine", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadRuntimeContext: () => Effect.die("unused"),
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
index 820018e3c..115caa033 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
@@ -200,6 +200,102 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-branch-pr-proje
},
);
+it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))(
+ "imported thread shell projection",
+ (it) => {
+ it.effect("does not mark imported user messages as queued work in thread shells", () =>
+ Effect.gen(function* () {
+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
+ const eventStore = yield* OrchestrationEventStore;
+ const sql = yield* SqlClient.SqlClient;
+ const createdAt = "2026-08-24T10:00:00.000Z";
+ const threadId = ThreadId.make("import:codex:shell-session");
+
+ yield* eventStore.append({
+ type: "thread.created",
+ eventId: EventId.make("evt-import-shell-thread"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ occurredAt: createdAt,
+ commandId: CommandId.make("cmd-import-shell-thread"),
+ causationEventId: null,
+ correlationId: CommandId.make("cmd-import-shell-thread"),
+ metadata: {},
+ payload: {
+ threadId,
+ projectId: ProjectId.make("project-import-shell"),
+ title: "Imported thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ });
+ yield* eventStore.append({
+ type: "thread.message-sent",
+ eventId: EventId.make("evt-import-shell-message"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ occurredAt: createdAt,
+ commandId: CommandId.make("cmd-import-shell-message"),
+ causationEventId: null,
+ correlationId: CommandId.make("cmd-import-shell-message"),
+ metadata: { historyImport: true },
+ payload: {
+ threadId,
+ messageId: MessageId.make("import:codex:shell-session:0"),
+ role: "user",
+ text: "Imported user prompt",
+ turnId: null,
+ streaming: false,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ });
+
+ yield* projectionPipeline.bootstrap;
+
+ const readLatestUserMessageAt = sql<{ readonly latestUserMessageAt: string | null }>`
+ SELECT latest_user_message_at AS "latestUserMessageAt"
+ FROM projection_threads
+ WHERE thread_id = ${threadId}
+ `;
+ assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]);
+
+ const sessionEvent = yield* eventStore.append({
+ type: "thread.session-set",
+ eventId: EventId.make("evt-import-shell-session"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ occurredAt: createdAt,
+ commandId: CommandId.make("cmd-import-shell-session"),
+ causationEventId: null,
+ correlationId: CommandId.make("cmd-import-shell-session"),
+ metadata: {},
+ payload: {
+ threadId,
+ session: {
+ threadId,
+ status: "ready",
+ providerName: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ runtimeMode: "full-access",
+ activeTurnId: null,
+ lastError: null,
+ updatedAt: createdAt,
+ },
+ },
+ });
+ yield* projectionPipeline.projectEvent(sessionEvent);
+ assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]);
+ }),
+ );
+ },
+);
+
it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
it.effect("bootstraps all projection states and writes projection rows", () =>
Effect.gen(function* () {
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
index 2f42126f8..7518d725e 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
@@ -1,10 +1,12 @@
import {
ApprovalRequestId,
+ isImportedAgentSessionMessageId,
type ChatAttachment,
type OrchestrationEvent,
type OrchestrationSessionStatus,
ThreadId,
} from "@t3tools/contracts";
+import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
@@ -229,7 +231,7 @@ function retainProjectionMessagesAfterRevert(
}
for (const message of messages) {
- if (message.role === "system") {
+ if (message.role === "system" || isImportedAgentSessionMessageId(message.messageId)) {
retainedMessageIds.add(message.messageId);
continue;
}
@@ -239,7 +241,10 @@ function retainProjectionMessagesAfterRevert(
}
const retainedUserCount = messages.filter(
- (message) => message.role === "user" && retainedMessageIds.has(message.messageId),
+ (message) =>
+ message.role === "user" &&
+ !isImportedAgentSessionMessageId(message.messageId) &&
+ retainedMessageIds.has(message.messageId),
).length;
const missingUserCount = Math.max(0, turnCount - retainedUserCount);
if (missingUserCount > 0) {
@@ -252,7 +257,7 @@ function retainProjectionMessagesAfterRevert(
)
.toSorted(
(left, right) =>
- left.createdAt.localeCompare(right.createdAt) ||
+ compareDateTimeStrings(left.createdAt, right.createdAt) ||
left.messageId.localeCompare(right.messageId),
)
.slice(0, missingUserCount);
@@ -262,7 +267,10 @@ function retainProjectionMessagesAfterRevert(
}
const retainedAssistantCount = messages.filter(
- (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId),
+ (message) =>
+ message.role === "assistant" &&
+ !isImportedAgentSessionMessageId(message.messageId) &&
+ retainedMessageIds.has(message.messageId),
).length;
const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
if (missingAssistantCount > 0) {
@@ -275,7 +283,7 @@ function retainProjectionMessagesAfterRevert(
)
.toSorted(
(left, right) =>
- left.createdAt.localeCompare(right.createdAt) ||
+ compareDateTimeStrings(left.createdAt, right.createdAt) ||
left.messageId.localeCompare(right.messageId),
)
.slice(0, missingAssistantCount);
@@ -896,6 +904,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
updatedAt: event.occurredAt,
latestUserMessageAt:
event.payload.role === "user" &&
+ !isImportedAgentSessionMessageId(event.payload.messageId) &&
(previousLatest === null || event.payload.createdAt > previousLatest)
? event.payload.createdAt
: previousLatest,
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
index 92bf179f9..0ded33b4d 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
@@ -1,4 +1,5 @@
import {
+ type AgentSessionImportSource,
ChatAttachment,
CheckpointRef,
EventId,
@@ -2531,7 +2532,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
//
// Straggler user message at T03.5 (turn_id NULL, not any pending_message_id)
// and a turnless activity at T03.6 — both belong to the page containing T03+.
- const seedFanOutThread = Effect.fnUntraced(function* () {
+ const seedFanOutThread = Effect.fnUntraced(function* (options?: {
+ readonly importedMessageCount?: number;
+ }) {
const sql = yield* SqlClient.SqlClient;
// Tests in this block share one in-memory database; reset before seeding.
@@ -2560,6 +2563,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL)
`;
+ if (options?.importedMessageCount) {
+ for (let index = 0; index < options.importedMessageCount; index += 1) {
+ const messageId = `import:codex:session-w:${String(index).padStart(6, "0")}`;
+ const role = index % 2 === 0 ? "user" : "assistant";
+ yield* sql`
+ INSERT INTO projection_thread_messages (
+ message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at
+ )
+ VALUES (${messageId}, 'thread-w', NULL, ${role}, ${"imported message " + index}, 0,
+ '2026-02-28T00:00:00.000Z', '2026-02-28T00:00:00.000Z')
+ `;
+ }
+ }
+
const turns: ReadonlyArray<{
turn: string;
pendingMessage: string | null;
@@ -2793,6 +2810,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
}),
);
+ it.effect("keeps imported history on the oldest page after resumed turns", () =>
+ Effect.gen(function* () {
+ yield* seedFanOutThread({ importedMessageCount: 12 });
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+
+ const completePage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 50 });
+ assert.equal(completePage._tag, "Some");
+ if (completePage._tag !== "Some") return;
+ assert.equal(
+ completePage.value.thread.messages.filter((message) => message.id.startsWith("import:"))
+ .length,
+ 12,
+ );
+ assert.equal(completePage.value.page?.hasMore, false);
+ assert.equal(completePage.value.page?.beforeCursor, null);
+
+ const recentPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 });
+ assert.equal(recentPage._tag, "Some");
+ if (recentPage._tag !== "Some") return;
+ assert.equal(
+ recentPage.value.thread.messages.some((message) => message.id.startsWith("import:")),
+ false,
+ );
+ const cursor = recentPage.value.page?.beforeCursor;
+ assert.notEqual(cursor, null);
+ assert.notEqual(cursor, undefined);
+ if (cursor === null || cursor === undefined) return;
+
+ const oldestPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {
+ turnLimit: 1,
+ beforeCursor: cursor,
+ });
+ assert.equal(oldestPage._tag, "Some");
+ if (oldestPage._tag !== "Some") return;
+
+ const importedIds = oldestPage.value.thread.messages
+ .map((message) => message.id)
+ .filter((messageId) => messageId.startsWith("import:"));
+ assert.equal(importedIds.length, 12);
+ assert.equal(new Set(importedIds).size, 12);
+ assert.equal(oldestPage.value.page?.hasMore, false);
+ assert.equal(oldestPage.value.page?.beforeCursor, null);
+ }),
+ );
+
it.effect("a cursor for a different thread degrades to the first page", () =>
Effect.gen(function* () {
yield* seedFanOutThread();
@@ -3184,3 +3246,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
}),
);
});
+
+projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => {
+ const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
+ const source: AgentSessionImportSource = {
+ provider: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex-home"),
+ providerSessionId: "native-session",
+ filePath: "/tmp/transcript.jsonl",
+ size: 128,
+ mtimeMs: 1_700_000_000_000,
+ device: 1,
+ inode: 2,
+ birthtimeMs: 1_699_000_000_000,
+ };
+
+ const seedImportedSession = Effect.fn("seedImportedSession")(function* (
+ projectId: ProjectId,
+ source: AgentSessionImportSource,
+ ) {
+ const sql = yield* SqlClient.SqlClient;
+ const threadId = ThreadId.make(
+ `import:${source.providerInstanceId}:${source.providerSessionId}`,
+ );
+ const timestamp = "2026-03-02T00:00:00.000Z";
+ yield* sql`
+ INSERT OR IGNORE INTO projection_projects (
+ project_id, title, workspace_root, scripts_json, created_at, updated_at
+ ) VALUES (${projectId}, 'Imported project', '/tmp/imported-project', '[]',
+ ${timestamp}, ${timestamp})
+ `;
+ yield* sql`
+ INSERT INTO projection_threads (
+ thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode,
+ created_at, updated_at
+ ) VALUES (${threadId}, ${projectId}, 'Imported thread',
+ ${encodeJson({ instanceId: source.providerInstanceId, model: "gpt-5-codex" })},
+ 'full-access', 'default',
+ ${timestamp}, ${timestamp})
+ `;
+ yield* sql`
+ INSERT INTO provider_session_runtime (
+ thread_id, provider_name, provider_instance_id, adapter_key, runtime_mode, status,
+ last_seen_at, resume_cursor_json, runtime_payload_json
+ ) VALUES (${threadId}, ${source.provider}, ${source.providerInstanceId},
+ ${source.provider}, 'full-access', 'stopped', ${timestamp},
+ ${encodeJson({ threadId: source.providerSessionId })},
+ ${encodeJson({ importedTranscripts: [source] })})
+ `;
+ yield* sql`
+ INSERT INTO projection_thread_messages (
+ message_id, thread_id, role, text, is_streaming, created_at, updated_at
+ ) VALUES (${`${threadId}:000000`}, ${threadId}, 'user', 'Imported history', 0,
+ ${timestamp}, ${timestamp})
+ `;
+ return { threadId, source };
+ });
+
+ it.effect("reads completed source copies without decoding message bodies", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const query = yield* ProjectionSnapshotQuery;
+ const projectId = ProjectId.make("project-import-metadata");
+ const imported = yield* seedImportedSession(projectId, source);
+ const copiedSource = {
+ ...source,
+ filePath: "/tmp/transcript-copy.jsonl",
+ mtimeMs: null,
+ inode: null,
+ birthtimeMs: null,
+ };
+ yield* sql`
+ UPDATE provider_session_runtime
+ SET runtime_payload_json = ${encodeJson({
+ cwd: "/tmp/imported-project",
+ importedTranscripts: [source, copiedSource],
+ })}
+ WHERE thread_id = ${imported.threadId}
+ `;
+ yield* sql`
+ UPDATE projection_thread_messages SET attachments_json = 'not-json'
+ WHERE thread_id = ${imported.threadId}
+ `;
+
+ const counter = makeSqlStatementCounter();
+ const sources = yield* query
+ .getImportedAgentSessionSources(projectId)
+ .pipe(Effect.withTracer(counter.tracer));
+ assert.deepEqual(sources, [imported, { threadId: imported.threadId, source: copiedSource }]);
+ assert.equal(counter.count(), 1);
+ }),
+ );
+
+ it.effect("requires active project threads, a binding, and an imported message", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const query = yield* ProjectionSnapshotQuery;
+ const projectId = ProjectId.make("project-import-completion");
+ const completed = yield* seedImportedSession(projectId, {
+ ...source,
+ providerSessionId: "completed",
+ });
+ yield* sql`
+ UPDATE projection_thread_messages SET message_id = ${`${completed.threadId}:legacy`}
+ WHERE thread_id = ${completed.threadId}
+ `;
+ const partials = yield* Effect.forEach(
+ [
+ "no-binding",
+ "no-history",
+ "no-imported-message",
+ "wrong-message-thread",
+ "archived",
+ "deleted",
+ ],
+ (providerSessionId) => seedImportedSession(projectId, { ...source, providerSessionId }),
+ );
+ const [noBinding, noHistory, noImportedMessage, wrongMessageThread, archived, deleted] =
+ partials;
+ assert.isDefined(noBinding);
+ assert.isDefined(noHistory);
+ assert.isDefined(noImportedMessage);
+ assert.isDefined(wrongMessageThread);
+ assert.isDefined(archived);
+ assert.isDefined(deleted);
+ yield* sql`DELETE FROM provider_session_runtime WHERE thread_id = ${noBinding.threadId}`;
+ yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${noHistory.threadId}`;
+ yield* sql`
+ UPDATE projection_thread_messages SET message_id = ${`normal:${noImportedMessage.threadId}`}
+ WHERE thread_id = ${noImportedMessage.threadId}
+ `;
+ yield* sql`
+ UPDATE projection_thread_messages SET thread_id = 'unrelated-thread'
+ WHERE thread_id = ${wrongMessageThread.threadId}
+ `;
+ yield* sql`
+ UPDATE projection_threads SET archived_at = '2026-03-03T00:00:00.000Z'
+ WHERE thread_id = ${archived.threadId}
+ `;
+ yield* sql`
+ UPDATE projection_threads SET deleted_at = '2026-03-03T00:00:00.000Z'
+ WHERE thread_id = ${deleted.threadId}
+ `;
+ const otherProjectId = ProjectId.make("project-import-other");
+ const otherProject = yield* seedImportedSession(otherProjectId, {
+ ...source,
+ providerSessionId: "other-project",
+ });
+ const deletedProjectId = ProjectId.make("project-import-deleted");
+ yield* seedImportedSession(deletedProjectId, {
+ ...source,
+ providerSessionId: "deleted-project",
+ });
+ yield* sql`
+ UPDATE projection_projects SET deleted_at = '2026-03-03T00:00:00.000Z'
+ WHERE project_id = ${deletedProjectId}
+ `;
+
+ assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [completed]);
+ assert.deepEqual(yield* query.getImportedAgentSessionSources(otherProjectId), [otherProject]);
+ assert.deepEqual(yield* query.getImportedAgentSessionSources(deletedProjectId), []);
+ assert.deepEqual(
+ yield* query.getImportedAgentSessionSources(ProjectId.make("project-import-missing")),
+ [],
+ );
+ }),
+ );
+
+ it.effect("keeps original sources when the current runtime provider and cursor change", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const query = yield* ProjectionSnapshotQuery;
+ const projectId = ProjectId.make("project-import-switched");
+ const imported = yield* seedImportedSession(projectId, {
+ ...source,
+ provider: "claudeAgent",
+ providerInstanceId: ProviderInstanceId.make("claude-original"),
+ providerSessionId: "original-session",
+ });
+ yield* sql`
+ UPDATE provider_session_runtime
+ SET provider_name = 'codex', provider_instance_id = 'codex-new', adapter_key = 'codex',
+ resume_cursor_json = '{"threadId":"new-session"}'
+ WHERE thread_id = ${imported.threadId}
+ `;
+
+ assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported]);
+ }),
+ );
+
+ it.effect("skips invalid source payloads and entries without dropping valid sources", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const query = yield* ProjectionSnapshotQuery;
+ const projectId = ProjectId.make("project-import-invalid");
+ const imported = yield* seedImportedSession(projectId, {
+ ...source,
+ providerSessionId: "a-invalid",
+ });
+ const valid = yield* seedImportedSession(projectId, {
+ ...source,
+ providerSessionId: "z-valid",
+ });
+ for (const payload of [null, "not-json", "null", "[]", "{}", '{"importedTranscripts":{}}']) {
+ yield* sql`
+ UPDATE provider_session_runtime SET runtime_payload_json = ${payload}
+ WHERE thread_id = ${imported.threadId}
+ `;
+ assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]);
+ }
+ yield* sql`
+ UPDATE provider_session_runtime SET runtime_payload_json = X'FF'
+ WHERE thread_id = ${imported.threadId}
+ `;
+ assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]);
+
+ yield* sql`
+ UPDATE provider_session_runtime
+ SET runtime_payload_json = ${encodeJson({
+ importedTranscripts: [
+ null,
+ {},
+ { ...imported.source, size: -1 },
+ { ...imported.source, provider: "cursor" },
+ { ...imported.source, providerInstanceId: "wrong-instance" },
+ { ...imported.source, providerSessionId: "wrong-session" },
+ imported.source,
+ ],
+ })}
+ WHERE thread_id = ${imported.threadId}
+ `;
+ assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported, valid]);
+ }),
+ );
+});
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
index 6a7ea8465..97b2e2a7b 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -1,4 +1,5 @@
import {
+ AgentSessionImportSource,
ApprovalRequestId,
ChatAttachment,
CheckpointRef,
@@ -79,6 +80,14 @@ import {
const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot);
const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread);
+const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption(
+ Schema.fromJsonString(
+ Schema.Struct({
+ importedTranscripts: Schema.Array(Schema.Unknown),
+ }),
+ ),
+);
+const decodeAgentSessionImportSource = Schema.decodeUnknownOption(AgentSessionImportSource);
// Keep detail reads consistent with the in-memory projector's retained
// activity window. Applying the limit in SQL avoids decoding an unbounded
// payload_json set before the projector can enforce that invariant.
@@ -195,6 +204,10 @@ const WorkspaceRootLookupInput = Schema.Struct({
const ProjectIdLookupInput = Schema.Struct({
projectId: ProjectId,
});
+const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({
+ threadId: ThreadId,
+ runtimePayload: Schema.Unknown,
+});
const ThreadIdLookupInput = Schema.Struct({
threadId: ThreadId,
});
@@ -1235,6 +1248,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
`,
});
+ const listImportedAgentSessionSourceRows = SqlSchema.findAll({
+ Request: ProjectIdLookupInput,
+ Result: ProjectionImportedAgentSessionSourcesRowSchema,
+ execute: ({ projectId }) =>
+ sql`
+ SELECT
+ threads.thread_id AS "threadId",
+ runtime.runtime_payload_json AS "runtimePayload"
+ FROM projection_threads AS threads
+ INNER JOIN projection_projects AS projects
+ ON projects.project_id = threads.project_id
+ INNER JOIN provider_session_runtime AS runtime
+ ON runtime.thread_id = threads.thread_id
+ WHERE threads.project_id = ${projectId}
+ AND threads.deleted_at IS NULL
+ AND threads.archived_at IS NULL
+ AND projects.deleted_at IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM projection_thread_messages AS messages
+ WHERE messages.thread_id = threads.thread_id
+ AND messages.message_id GLOB 'import:*'
+ )
+ ORDER BY threads.thread_id ASC
+ `,
+ });
+
const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({
Request: ThreadIdLookupInput,
Result: ProjectionThreadCheckpointContextThreadRowSchema,
@@ -3193,6 +3233,33 @@ pending_approval_requests AS (
Effect.map(Option.map((row) => row.threadId)),
);
+ const getImportedAgentSessionSources: ProjectionSnapshotQueryShape["getImportedAgentSessionSources"] =
+ Effect.fn("ProjectionSnapshotQuery.getImportedAgentSessionSources")(function* (projectId) {
+ const rows = yield* listImportedAgentSessionSourceRows({ projectId }).pipe(
+ Effect.mapError(
+ toPersistenceSqlOrDecodeError(
+ "ProjectionSnapshotQuery.getImportedAgentSessionSources:query",
+ "ProjectionSnapshotQuery.getImportedAgentSessionSources:decodeRows",
+ ),
+ ),
+ );
+ return rows.flatMap((row) => {
+ const payload = decodeImportedTranscriptsPayload(row.runtimePayload);
+ if (Option.isNone(payload)) return [];
+ return payload.value.importedTranscripts.flatMap((entry) => {
+ const source = decodeAgentSessionImportSource(entry);
+ if (
+ Option.isNone(source) ||
+ row.threadId !==
+ `import:${source.value.providerInstanceId}:${source.value.providerSessionId}`
+ ) {
+ return [];
+ }
+ return [{ threadId: row.threadId, source: source.value }];
+ });
+ });
+ });
+
const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = (
threadId,
) =>
@@ -3742,17 +3809,35 @@ pending_approval_requests AS (
);
const oldest = windowRows[0];
+ const hasMore =
+ oldest !== undefined &&
+ (yield* listTurnWindowRows({
+ threadId,
+ beforeAnchorAt: oldest.anchorAt,
+ beforeTurnKey: oldest.turnKey,
+ userTurnLimit: 1,
+ maxRawTurns: 1,
+ }).pipe(
+ Effect.mapError(
+ toPersistenceSqlOrDecodeError(
+ "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query",
+ "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows",
+ ),
+ ),
+ )).length > 0;
// An empty window (no turns before the cursor, or a thread with no
// turns at all) still returns thread metadata with empty collections
// for turn-linked rows; turnless rows are bounded to the same empty
// range. The first page of a turnless thread stays unwindowed so
- // pre-turn content (e.g. a just-created thread) is not hidden.
+ // pre-turn content (e.g. a just-created thread) is not hidden. Once
+ // paging reaches the oldest turn, include turnless messages before
+ // the first turn, such as history imported from a provider session.
const bounds: ThreadDetailBounds | undefined =
oldest === undefined && cursor === null
? undefined
: {
- minAnchorAt: oldest?.anchorAt ?? "",
- minTurnKey: oldest?.turnKey ?? "",
+ minAnchorAt: hasMore ? (oldest?.anchorAt ?? "") : "",
+ minTurnKey: hasMore ? (oldest?.turnKey ?? "") : "",
beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED,
beforeTurnKey: cursor?.beforeTurnId ?? "",
};
@@ -3769,23 +3854,6 @@ pending_approval_requests AS (
return Option.none();
}
- const hasMore =
- oldest !== undefined &&
- (yield* listTurnWindowRows({
- threadId,
- beforeAnchorAt: oldest.anchorAt,
- beforeTurnKey: oldest.turnKey,
- userTurnLimit: 1,
- maxRawTurns: 1,
- }).pipe(
- Effect.mapError(
- toPersistenceSqlOrDecodeError(
- "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query",
- "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows",
- ),
- ),
- )).length > 0;
-
const { snapshotSequence } = yield* getSnapshotSequence();
const watermarkRow = yield* getThreadEventWatermarkRow({
threadId,
@@ -3846,6 +3914,7 @@ pending_approval_requests AS (
getActiveProjectByWorkspaceRoot,
getProjectShellById,
getFirstActiveThreadIdByProjectId,
+ getImportedAgentSessionSources,
getThreadCheckpointContext,
getFullThreadDiffContext,
getThreadShellById,
diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
index 9f4fb50a5..8e392af31 100644
--- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
@@ -7,6 +7,7 @@
* @module ProjectionSnapshotQuery
*/
import type {
+ AgentSessionImportSource,
ApprovalRequestId,
CheckpointRef,
CommandId,
@@ -197,6 +198,15 @@ export interface ProjectionSnapshotQueryShape {
projectId: ProjectId,
) => Effect.Effect, ProjectionRepositoryError>;
+ /** Read completed import sources without loading thread history. */
+ readonly getImportedAgentSessionSources: (projectId: ProjectId) => Effect.Effect<
+ ReadonlyArray<{
+ readonly threadId: ThreadId;
+ readonly source: AgentSessionImportSource;
+ }>,
+ ProjectionRepositoryError
+ >;
+
/**
* Read the checkpoint context needed to resolve a single thread diff.
*/
diff --git a/apps/server/src/orchestration/decider.import.test.ts b/apps/server/src/orchestration/decider.import.test.ts
new file mode 100644
index 000000000..c809c7338
--- /dev/null
+++ b/apps/server/src/orchestration/decider.import.test.ts
@@ -0,0 +1,509 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { expect, it } from "@effect/vitest";
+import {
+ CommandId,
+ EventId,
+ MessageId,
+ ProjectId,
+ ProviderInstanceId,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as TestClock from "effect/testing/TestClock";
+
+import { decideOrchestrationCommand } from "./decider.ts";
+import { createEmptyReadModel, projectEvent } from "./projector.ts";
+
+it.layer(NodeServices.layer)("thread history import", (it) => {
+ it.effect("marks imported thread creation without changing live creation", () =>
+ Effect.gen(function* () {
+ const createdAt = "2026-08-24T10:00:00.000Z";
+ const projectId = ProjectId.make("project-1");
+ const readModel = yield* projectEvent(createEmptyReadModel(createdAt), {
+ sequence: 1,
+ eventId: EventId.make("event-project-created"),
+ aggregateKind: "project",
+ aggregateId: projectId,
+ type: "project.created",
+ occurredAt: createdAt,
+ commandId: CommandId.make("command-project-created"),
+ causationEventId: null,
+ correlationId: CommandId.make("command-project-created"),
+ metadata: {},
+ payload: {
+ projectId,
+ title: "Project",
+ workspaceRoot: "/tmp/project",
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt,
+ updatedAt: createdAt,
+ },
+ });
+ const makeCreateCommand = (threadId: ThreadId) => ({
+ type: "thread.create" as const,
+ commandId: CommandId.make(`command-create-${threadId}`),
+ threadId,
+ projectId,
+ title: "Imported thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
+ runtimeMode: "full-access" as const,
+ interactionMode: "default" as const,
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ });
+
+ const imported = yield* decideOrchestrationCommand({
+ command: {
+ ...makeCreateCommand(ThreadId.make("import:codex:session-1")),
+ historyImport: true,
+ },
+ readModel,
+ });
+ const live = yield* decideOrchestrationCommand({
+ command: makeCreateCommand(ThreadId.make("live-thread")),
+ readModel,
+ });
+
+ expect(imported).toMatchObject({
+ type: "thread.created",
+ metadata: { historyImport: true },
+ });
+ expect(live).toMatchObject({ type: "thread.created" });
+ expect(live).not.toMatchObject({ metadata: { historyImport: true } });
+ }),
+ );
+
+ it.effect("settles imported messages at the latest absolute timestamp", () =>
+ Effect.gen(function* () {
+ const createdAt = "2026-08-24T10:30:00.000+02:00";
+ const threadId = ThreadId.make("import:codex:session-1");
+ const readModel = yield* projectEvent(createEmptyReadModel(createdAt), {
+ sequence: 1,
+ eventId: EventId.make("event-thread-created"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.created",
+ occurredAt: createdAt,
+ commandId: CommandId.make("command-thread-created"),
+ causationEventId: null,
+ correlationId: CommandId.make("command-thread-created"),
+ metadata: {},
+ payload: {
+ threadId,
+ projectId: ProjectId.make("project-1"),
+ title: "Imported thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ });
+
+ const events = yield* decideOrchestrationCommand({
+ command: {
+ type: "thread.history.import",
+ commandId: CommandId.make("command-import-history"),
+ threadId,
+ messages: [
+ {
+ messageId: MessageId.make(`${threadId}:000000`),
+ role: "user",
+ text: "Fix the bug",
+ createdAt,
+ },
+ {
+ messageId: MessageId.make(`${threadId}:000001`),
+ role: "assistant",
+ text: "Fixed",
+ createdAt: "2026-08-24T09:00:00.000Z",
+ },
+ ],
+ },
+ readModel,
+ });
+
+ expect(events).toMatchObject([
+ {
+ type: "thread.message-sent",
+ metadata: { historyImport: true },
+ payload: { role: "user", text: "Fix the bug", turnId: null, streaming: false },
+ },
+ {
+ type: "thread.message-sent",
+ metadata: { historyImport: true },
+ payload: { role: "assistant", text: "Fixed", turnId: null, streaming: false },
+ },
+ {
+ type: "thread.settled",
+ metadata: { historyImport: true },
+ occurredAt: "2026-08-24T09:00:00.000Z",
+ payload: {
+ settledAt: "2026-08-24T09:00:00.000Z",
+ updatedAt: "2026-08-24T09:00:00.000Z",
+ },
+ },
+ ]);
+
+ let projected = readModel;
+ const plannedEvents = Array.isArray(events) ? events : [events];
+ for (const [index, event] of plannedEvents.entries()) {
+ projected = yield* projectEvent(projected, { ...event, sequence: index + 2 });
+ }
+ projected = yield* projectEvent(projected, {
+ sequence: 5,
+ eventId: EventId.make("event-import-reverted"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.reverted",
+ occurredAt: "2026-08-24T10:02:00.000Z",
+ commandId: CommandId.make("command-import-reverted"),
+ causationEventId: null,
+ correlationId: CommandId.make("command-import-reverted"),
+ metadata: {},
+ payload: { threadId, turnCount: 0 },
+ });
+ expect(projected.threads[0]?.messages.map((message) => message.text)).toEqual([
+ "Fix the bug",
+ "Fixed",
+ ]);
+ }),
+ );
+
+ it.effect("allows a thread with a newly imported user message to be settled", () =>
+ Effect.gen(function* () {
+ const createdAt = "2026-08-24T10:00:00.000Z";
+ yield* TestClock.setTime(Date.parse("2026-08-24T10:00:30.000Z"));
+ const threadId = ThreadId.make("import:codex:session-1");
+ const withThread = yield* projectEvent(createEmptyReadModel(createdAt), {
+ sequence: 1,
+ eventId: EventId.make("event-import-thread-created"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.created",
+ occurredAt: createdAt,
+ commandId: CommandId.make("command-import-thread-created"),
+ causationEventId: null,
+ correlationId: CommandId.make("command-import-thread-created"),
+ metadata: {},
+ payload: {
+ threadId,
+ projectId: ProjectId.make("project-1"),
+ title: "Imported thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ });
+ const readModel = yield* projectEvent(withThread, {
+ sequence: 2,
+ eventId: EventId.make("event-import-user-message"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.message-sent",
+ occurredAt: createdAt,
+ commandId: CommandId.make("command-import-user-message"),
+ causationEventId: null,
+ correlationId: CommandId.make("command-import-user-message"),
+ metadata: { historyImport: true },
+ payload: {
+ threadId,
+ messageId: MessageId.make("import:codex:session-1:0"),
+ role: "user",
+ text: "Existing prompt",
+ turnId: null,
+ streaming: false,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ });
+
+ const result = yield* decideOrchestrationCommand({
+ command: {
+ type: "thread.settle",
+ commandId: CommandId.make("command-settle-imported-thread"),
+ threadId,
+ },
+ readModel,
+ });
+
+ expect(result).toMatchObject({ type: "thread.settled" });
+ }),
+ );
+
+ it.effect("rejects history import after a client message reaches the thread", () =>
+ Effect.gen(function* () {
+ const createdAt = "2026-08-24T10:00:00.000Z";
+ const liveMessageAt = "2026-08-24T10:02:00.000Z";
+ const threadId = ThreadId.make("import:codex:client-race");
+ const withThread = yield* projectEvent(createEmptyReadModel(createdAt), {
+ sequence: 1,
+ eventId: EventId.make("event-client-race-thread-created"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.created",
+ occurredAt: createdAt,
+ commandId: CommandId.make("command-client-race-thread-created"),
+ causationEventId: null,
+ correlationId: CommandId.make("command-client-race-thread-created"),
+ metadata: {},
+ payload: {
+ threadId,
+ projectId: ProjectId.make("project-1"),
+ title: "Imported thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ });
+ const readModel = yield* projectEvent(withThread, {
+ sequence: 2,
+ eventId: EventId.make("event-client-race-message"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.message-sent",
+ occurredAt: liveMessageAt,
+ commandId: CommandId.make("command-client-race-message"),
+ causationEventId: null,
+ correlationId: CommandId.make("command-client-race-message"),
+ metadata: {},
+ payload: {
+ threadId,
+ messageId: MessageId.make("client-race-message"),
+ role: "user",
+ text: "Start live work",
+ turnId: null,
+ streaming: false,
+ createdAt: liveMessageAt,
+ updatedAt: liveMessageAt,
+ },
+ });
+
+ const error = yield* Effect.flip(
+ decideOrchestrationCommand({
+ command: {
+ type: "thread.history.import",
+ commandId: CommandId.make("command-client-race-import"),
+ threadId,
+ messages: [
+ {
+ messageId: MessageId.make(`${threadId}:000000`),
+ role: "user",
+ text: "Old work",
+ createdAt,
+ },
+ ],
+ },
+ readModel,
+ }),
+ );
+
+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
+ expect(error.message).toContain("must be active and empty");
+ expect(readModel.threads[0]?.updatedAt).toBe(liveMessageAt);
+ }),
+ );
+
+ for (const requestKind of ["approval.requested", "user-input.requested"] as const) {
+ it.effect(`rejects history import with an open ${requestKind} activity`, () =>
+ Effect.gen(function* () {
+ const createdAt = "2026-08-24T10:00:00.000Z";
+ const threadId = ThreadId.make(`import:codex:${requestKind}`);
+ const withThread = yield* projectEvent(createEmptyReadModel(createdAt), {
+ sequence: 1,
+ eventId: EventId.make(`event-${requestKind}-thread-created`),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.created",
+ occurredAt: createdAt,
+ commandId: CommandId.make(`command-${requestKind}-thread-created`),
+ causationEventId: null,
+ correlationId: CommandId.make(`command-${requestKind}-thread-created`),
+ metadata: {},
+ payload: {
+ threadId,
+ projectId: ProjectId.make("project-1"),
+ title: "Imported thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ });
+ const readModel = yield* projectEvent(withThread, {
+ sequence: 2,
+ eventId: EventId.make(`event-${requestKind}`),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.activity-appended",
+ occurredAt: createdAt,
+ commandId: CommandId.make(`command-${requestKind}`),
+ causationEventId: null,
+ correlationId: CommandId.make(`command-${requestKind}`),
+ metadata: {},
+ payload: {
+ threadId,
+ activity: {
+ id: EventId.make(`activity-${requestKind}`),
+ tone: "approval",
+ kind: requestKind,
+ summary: "Pending request",
+ payload: { requestId: "request-1" },
+ turnId: null,
+ createdAt,
+ },
+ },
+ });
+
+ const error = yield* Effect.flip(
+ decideOrchestrationCommand({
+ command: {
+ type: "thread.history.import",
+ commandId: CommandId.make(`command-import-${requestKind}`),
+ threadId,
+ messages: [
+ {
+ messageId: MessageId.make(`${threadId}:000000`),
+ role: "user",
+ text: "Old work",
+ createdAt,
+ },
+ ],
+ },
+ readModel,
+ }),
+ );
+
+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
+ expect(error.message).toContain("must be active and empty");
+ }),
+ );
+ }
+
+ it.effect("rejects a live user message in the imported-session namespace", () =>
+ Effect.gen(function* () {
+ const createdAt = "2026-08-24T10:00:00.000Z";
+ const threadId = ThreadId.make("thread-live-message");
+ const readModel = yield* projectEvent(createEmptyReadModel(createdAt), {
+ sequence: 1,
+ eventId: EventId.make("event-live-thread-created"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.created",
+ occurredAt: createdAt,
+ commandId: CommandId.make("command-live-thread-created"),
+ causationEventId: null,
+ correlationId: CommandId.make("command-live-thread-created"),
+ metadata: {},
+ payload: {
+ threadId,
+ projectId: ProjectId.make("project-1"),
+ title: "Live thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ });
+
+ const error = yield* Effect.flip(
+ decideOrchestrationCommand({
+ command: {
+ type: "thread.turn.start",
+ commandId: CommandId.make("command-live-import-id"),
+ threadId,
+ message: {
+ messageId: MessageId.make("import:forged-live-message"),
+ role: "user",
+ text: "Live work",
+ attachments: [],
+ },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ createdAt,
+ },
+ readModel,
+ }),
+ );
+
+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
+ expect(error.message).toContain("reserved imported-session namespace");
+ }),
+ );
+
+ it.effect("rejects live assistant messages in the imported-session namespace", () =>
+ Effect.gen(function* () {
+ const createdAt = "2026-08-24T10:00:00.000Z";
+ const threadId = ThreadId.make("thread-live-assistant-message");
+ const readModel = yield* projectEvent(createEmptyReadModel(createdAt), {
+ sequence: 1,
+ eventId: EventId.make("event-live-assistant-thread-created"),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.created",
+ occurredAt: createdAt,
+ commandId: CommandId.make("command-live-assistant-thread-created"),
+ causationEventId: null,
+ correlationId: CommandId.make("command-live-assistant-thread-created"),
+ metadata: {},
+ payload: {
+ threadId,
+ projectId: ProjectId.make("project-1"),
+ title: "Live thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ });
+
+ for (const commandType of [
+ "thread.message.assistant.delta",
+ "thread.message.assistant.complete",
+ ] as const) {
+ const command =
+ commandType === "thread.message.assistant.delta"
+ ? {
+ type: commandType,
+ commandId: CommandId.make("command-live-assistant-delta-import-id"),
+ threadId,
+ messageId: MessageId.make("import:forged-live-assistant-message"),
+ delta: "Live work",
+ createdAt,
+ }
+ : {
+ type: commandType,
+ commandId: CommandId.make("command-live-assistant-complete-import-id"),
+ threadId,
+ messageId: MessageId.make("import:forged-live-assistant-message"),
+ createdAt,
+ };
+ const error = yield* Effect.flip(decideOrchestrationCommand({ command, readModel }));
+
+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
+ expect(error.message).toContain("reserved imported-session namespace");
+ }
+ }),
+ );
+});
diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts
index ae6c0ed3c..89857b6d1 100644
--- a/apps/server/src/orchestration/decider.ts
+++ b/apps/server/src/orchestration/decider.ts
@@ -5,12 +5,14 @@ import {
ThreadLinkedPullRequest,
MessageId,
UserInputRequestedPayload,
+ isImportedAgentSessionMessageId,
type OrchestrationCommand,
type OrchestrationEvent,
type OrchestrationReadModel,
type OrchestrationThread,
type OrchestrationThreadActivity,
} from "@t3tools/contracts";
+import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
import * as DateTime from "effect/DateTime";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
@@ -114,7 +116,7 @@ function hasQueuedTurnStartForThread(
let latestUserMessageAt: string | null = null;
let latestUserMessageAtMs = Number.NEGATIVE_INFINITY;
for (const message of thread.messages) {
- if (message.role !== "user") continue;
+ if (message.role !== "user" || isImportedAgentSessionMessageId(message.id)) continue;
const messageAtMs = Date.parse(message.createdAt);
latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs);
if (messageAtMs === latestUserMessageAtMs) {
@@ -378,6 +380,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
+ ...(command.historyImport === true ? { metadata: { historyImport: true } } : {}),
})),
type: "thread.created",
payload: {
@@ -1107,6 +1110,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
}
case "thread.turn.start": {
+ if (isImportedAgentSessionMessageId(command.message.messageId)) {
+ return yield* new OrchestrationCommandInvariantError({
+ commandType: command.type,
+ detail: `Message id '${command.message.messageId}' uses the reserved imported-session namespace.`,
+ });
+ }
const targetThread = yield* requireThread({
readModel,
command,
@@ -2052,6 +2061,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
}
case "thread.message.assistant.delta": {
+ if (isImportedAgentSessionMessageId(command.messageId)) {
+ return yield* new OrchestrationCommandInvariantError({
+ commandType: command.type,
+ detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`,
+ });
+ }
yield* requireThread({
readModel,
command,
@@ -2079,6 +2094,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
}
case "thread.message.assistant.complete": {
+ if (isImportedAgentSessionMessageId(command.messageId)) {
+ return yield* new OrchestrationCommandInvariantError({
+ commandType: command.type,
+ detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`,
+ });
+ }
yield* requireThread({
readModel,
command,
@@ -2105,6 +2126,80 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
};
}
+ case "thread.history.import": {
+ const thread = yield* requireThread({
+ readModel,
+ command,
+ threadId: command.threadId,
+ });
+ if (
+ thread.deletedAt !== null ||
+ thread.archivedAt !== null ||
+ thread.messages.length > 0 ||
+ thread.latestTurn !== null ||
+ thread.session !== null ||
+ openRequests({ ...thread, activities: pendingRequestActivities ?? thread.activities })
+ .size > 0
+ ) {
+ return yield* new OrchestrationCommandInvariantError({
+ commandType: command.type,
+ detail: `Thread '${command.threadId}' must be active and empty before history can be imported.`,
+ });
+ }
+ const firstMessage = command.messages[0];
+ if (firstMessage === undefined) {
+ return yield* new OrchestrationCommandInvariantError({
+ commandType: command.type,
+ detail: "Thread history imports require at least one message.",
+ });
+ }
+
+ const events: Array = [];
+ for (const message of command.messages) {
+ events.push({
+ ...(yield* withEventBase({
+ aggregateKind: "thread",
+ aggregateId: command.threadId,
+ occurredAt: message.createdAt,
+ commandId: command.commandId,
+ metadata: { historyImport: true },
+ })),
+ type: "thread.message-sent",
+ payload: {
+ threadId: command.threadId,
+ messageId: message.messageId,
+ role: message.role,
+ text: message.text,
+ turnId: null,
+ streaming: false,
+ createdAt: message.createdAt,
+ updatedAt: message.createdAt,
+ },
+ });
+ }
+ const settledAt = command.messages.reduce(
+ (latest, message) =>
+ compareDateTimeStrings(message.createdAt, latest) > 0 ? message.createdAt : latest,
+ firstMessage.createdAt,
+ );
+ events.push({
+ ...(yield* withEventBase({
+ aggregateKind: "thread",
+ aggregateId: command.threadId,
+ occurredAt: settledAt,
+ commandId: command.commandId,
+ metadata: { historyImport: true },
+ })),
+ type: "thread.settled",
+ payload: {
+ threadId: command.threadId,
+ settledAt,
+ updatedAt: settledAt,
+ },
+ });
+ return events;
+ }
+
case "thread.proposed-plan.upsert": {
yield* requireThread({
readModel,
diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts
index 6368a6328..2cdaa47cd 100644
--- a/apps/server/src/orchestration/projector.ts
+++ b/apps/server/src/orchestration/projector.ts
@@ -1,10 +1,12 @@
import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts";
import {
+ isImportedAgentSessionMessageId,
OrchestrationCheckpointSummary,
OrchestrationMessage,
OrchestrationSession,
OrchestrationThread,
} from "@t3tools/contracts";
+import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as Predicate from "effect/Predicate";
@@ -117,7 +119,7 @@ function retainThreadMessagesAfterRevert(
): ReadonlyArray {
const retainedMessageIds = new Set();
for (const message of messages) {
- if (message.role === "system") {
+ if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) {
retainedMessageIds.add(message.id);
continue;
}
@@ -127,7 +129,10 @@ function retainThreadMessagesAfterRevert(
}
const retainedUserCount = messages.filter(
- (message) => message.role === "user" && retainedMessageIds.has(message.id),
+ (message) =>
+ message.role === "user" &&
+ !isImportedAgentSessionMessageId(message.id) &&
+ retainedMessageIds.has(message.id),
).length;
const missingUserCount = Math.max(0, turnCount - retainedUserCount);
if (missingUserCount > 0) {
@@ -140,7 +145,8 @@ function retainThreadMessagesAfterRevert(
)
.toSorted(
(left, right) =>
- left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
+ compareDateTimeStrings(left.createdAt, right.createdAt) ||
+ left.id.localeCompare(right.id),
)
.slice(0, missingUserCount);
for (const message of fallbackUserMessages) {
@@ -149,7 +155,10 @@ function retainThreadMessagesAfterRevert(
}
const retainedAssistantCount = messages.filter(
- (message) => message.role === "assistant" && retainedMessageIds.has(message.id),
+ (message) =>
+ message.role === "assistant" &&
+ !isImportedAgentSessionMessageId(message.id) &&
+ retainedMessageIds.has(message.id),
).length;
const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
if (missingAssistantCount > 0) {
@@ -162,7 +171,8 @@ function retainThreadMessagesAfterRevert(
)
.toSorted(
(left, right) =>
- left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
+ compareDateTimeStrings(left.createdAt, right.createdAt) ||
+ left.id.localeCompare(right.id),
)
.slice(0, missingAssistantCount);
for (const message of fallbackAssistantMessages) {
diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts
index 53ab4d773..12f4db91f 100644
--- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts
+++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts
@@ -12,12 +12,24 @@ const layer = it.layer(
);
layer("ProjectionThreadMessageRepository", (it) => {
- it.effect("finds the latest user-message time within one thread", () =>
+ it.effect("finds the latest live user-message time within one thread", () =>
Effect.gen(function* () {
const repository = yield* ProjectionThreadMessageRepository;
const threadId = ThreadId.make("thread-latest-user-message");
assert.isNull(yield* repository.getLatestUserMessageAt({ threadId }));
+ yield* repository.upsert({
+ messageId: MessageId.make("import:codex:latest-user-message:000000"),
+ threadId,
+ turnId: null,
+ role: "user",
+ text: "Imported prompt",
+ isStreaming: false,
+ createdAt: "2026-02-28T19:05:06.000Z",
+ updatedAt: "2026-02-28T19:05:06.000Z",
+ });
+ assert.isNull(yield* repository.getLatestUserMessageAt({ threadId }));
+
const messages = [
{ role: "user", createdAt: "2026-02-28T19:05:02.000Z" },
{ role: "user", createdAt: "2026-02-28T19:05:01.000Z" },
diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts
index c612d56f0..eae7189de 100644
--- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts
+++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts
@@ -210,6 +210,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
SELECT MAX(created_at) AS "latestUserMessageAt"
FROM projection_thread_messages
WHERE thread_id = ${threadId} AND role = 'user'
+ AND message_id NOT GLOB 'import:*'
`,
});
diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts
index 2ccdd8625..d73f56aab 100644
--- a/apps/server/src/persistence/ProviderSessionRuntime.ts
+++ b/apps/server/src/persistence/ProviderSessionRuntime.ts
@@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as SqlSchema from "effect/unstable/sql/SqlSchema";
import {
+ AgentSessionImportSource,
IsoDateTime,
ProviderInstanceId,
ProviderSessionRuntimeStatus,
@@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp
export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId });
export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type;
+export const RecordImportedTranscriptInput = Schema.Struct({
+ threadId: ThreadId,
+ source: AgentSessionImportSource,
+});
+export type RecordImportedTranscriptInput = typeof RecordImportedTranscriptInput.Type;
+
+export interface ProviderSessionRuntimeUpsertOptions {
+ readonly onConflict?: "update" | "ignore";
+}
+
/**
* ProviderSessionRuntimeRepository - Service tag for provider runtime persistence.
*/
@@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service<
/**
* Insert or replace a provider runtime row.
*
- * Upserts by canonical `threadId`, including JSON payload/cursor fields.
+ * Upserts by canonical `threadId`, retaining imported transcript records
+ * from the current database row.
*/
readonly upsert: (
runtime: ProviderSessionRuntime,
+ options?: ProviderSessionRuntimeUpsertOptions,
+ ) => Effect.Effect;
+
+ /** Record one source file without replacing the current session state. */
+ readonly recordImportedTranscript: (
+ input: RecordImportedTranscriptInput,
) => Effect.Effect;
/**
@@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({
const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;
+const RecordImportedTranscriptRequestSchema = RecordImportedTranscriptInput.mapFields(
+ Struct.assign({ source: Schema.fromJsonString(AgentSessionImportSource) }),
+);
+
function toPersistenceSqlOrDecodeError(
sqlOperation: string,
decodeOperation: string,
@@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError(
export const make = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
+ // Runtime writes can carry stale payloads. Only recordImportedTranscript may
+ // change source records, so restore that field from the row being updated.
const upsertRuntimeRow = SqlSchema.void({
Request: ProviderSessionRuntimeDbRowSchema,
execute: (runtime) =>
@@ -171,7 +195,11 @@ export const make = Effect.gen(function* () {
${runtime.status},
${runtime.lastSeenAt},
${runtime.resumeCursor},
- ${runtime.runtimePayload}
+ CASE
+ WHEN json_type(${runtime.runtimePayload}) = 'object'
+ THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts')
+ ELSE ${runtime.runtimePayload}
+ END
)
ON CONFLICT (thread_id)
DO UPDATE SET
@@ -182,7 +210,107 @@ export const make = Effect.gen(function* () {
status = excluded.status,
last_seen_at = excluded.last_seen_at,
resume_cursor_json = excluded.resume_cursor_json,
- runtime_payload_json = excluded.runtime_payload_json
+ runtime_payload_json = CASE
+ WHEN json_type(
+ CASE
+ WHEN json_valid(provider_session_runtime.runtime_payload_json)
+ THEN provider_session_runtime.runtime_payload_json
+ ELSE '{}'
+ END,
+ '$.importedTranscripts'
+ ) IS NOT NULL
+ THEN json_set(
+ CASE
+ WHEN json_type(excluded.runtime_payload_json) = 'object'
+ THEN excluded.runtime_payload_json
+ ELSE '{}'
+ END,
+ '$.importedTranscripts',
+ json_extract(provider_session_runtime.runtime_payload_json, '$.importedTranscripts')
+ )
+ ELSE excluded.runtime_payload_json
+ END
+ `,
+ });
+
+ const insertRuntimeRow = SqlSchema.void({
+ Request: ProviderSessionRuntimeDbRowSchema,
+ execute: (runtime) =>
+ sql`
+ INSERT INTO provider_session_runtime (
+ thread_id,
+ provider_name,
+ provider_instance_id,
+ adapter_key,
+ runtime_mode,
+ status,
+ last_seen_at,
+ resume_cursor_json,
+ runtime_payload_json
+ )
+ VALUES (
+ ${runtime.threadId},
+ ${runtime.providerName},
+ ${runtime.providerInstanceId},
+ ${runtime.adapterKey},
+ ${runtime.runtimeMode},
+ ${runtime.status},
+ ${runtime.lastSeenAt},
+ ${runtime.resumeCursor},
+ CASE
+ WHEN json_type(${runtime.runtimePayload}) = 'object'
+ THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts')
+ ELSE ${runtime.runtimePayload}
+ END
+ )
+ ON CONFLICT (thread_id) DO NOTHING
+ `,
+ });
+
+ const recordImportedTranscriptRow = SqlSchema.void({
+ Request: RecordImportedTranscriptRequestSchema,
+ execute: ({ threadId, source }) =>
+ sql`
+ WITH current_runtime AS (
+ SELECT CASE
+ WHEN json_valid(runtime_payload_json) THEN CASE
+ WHEN json_type(runtime_payload_json) = 'object' THEN runtime_payload_json
+ ELSE '{}'
+ END
+ ELSE '{}'
+ END AS payload
+ FROM provider_session_runtime
+ WHERE thread_id = ${threadId}
+ )
+ UPDATE provider_session_runtime
+ SET runtime_payload_json = (
+ SELECT json_set(
+ payload,
+ '$.importedTranscripts',
+ json((
+ SELECT json_group_array(json(value))
+ FROM (
+ SELECT value
+ FROM json_each(CASE
+ WHEN json_type(payload, '$.importedTranscripts') = 'array'
+ THEN json_extract(payload, '$.importedTranscripts')
+ ELSE '[]'
+ END)
+ WHERE CASE
+ WHEN type = 'object' THEN
+ json_extract(value, '$.providerInstanceId')
+ IS NOT json_extract(${source}, '$.providerInstanceId')
+ OR json_extract(value, '$.filePath') IS NOT json_extract(${source}, '$.filePath')
+ ELSE 0
+ END
+ UNION ALL
+ SELECT ${source} AS value
+ )
+ ))
+ )
+ FROM current_runtime
+ )
+ WHERE thread_id = ${threadId}
`,
});
@@ -235,8 +363,8 @@ export const make = Effect.gen(function* () {
`,
});
- const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) =>
- upsertRuntimeRow(runtime).pipe(
+ const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime, options) =>
+ (options?.onConflict === "ignore" ? insertRuntimeRow(runtime) : upsertRuntimeRow(runtime)).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProviderSessionRuntimeRepository.upsert:query",
@@ -246,6 +374,18 @@ export const make = Effect.gen(function* () {
),
);
+ const recordImportedTranscript: ProviderSessionRuntimeRepository["Service"]["recordImportedTranscript"] =
+ (input) =>
+ recordImportedTranscriptRow(input).pipe(
+ Effect.mapError(
+ toPersistenceSqlOrDecodeError(
+ "ProviderSessionRuntimeRepository.recordImportedTranscript:query",
+ "ProviderSessionRuntimeRepository.recordImportedTranscript:encodeRequest",
+ { threadId: input.threadId },
+ ),
+ ),
+ );
+
const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) =>
getRuntimeRowByThreadId(input).pipe(
Effect.mapError(
@@ -324,6 +464,7 @@ export const make = Effect.gen(function* () {
return {
upsert,
+ recordImportedTranscript,
getByThreadId,
list,
deleteByThreadId,
diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts
new file mode 100644
index 000000000..95287e786
--- /dev/null
+++ b/apps/server/src/project/AgentSessionImporter.test.ts
@@ -0,0 +1,1237 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it, vi } from "@effect/vitest";
+import {
+ AgentSessionImportProjectChangedError,
+ CheckpointRef,
+ CommandId,
+ MessageId,
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ThreadId,
+ TurnId,
+ type OrchestrationCommand,
+ type OrchestrationProjectShell,
+ type OrchestrationThread,
+ type ProviderSendTurnInput,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Deferred from "effect/Deferred";
+import * as Fiber from "effect/Fiber";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
+
+import { makeTestProviderAdapterHarness } from "../../integration/TestProviderAdapter.integration.ts";
+import { ServerConfig } from "../config.ts";
+import { GitWorkflowService } from "../git/GitWorkflowService.ts";
+import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts";
+import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts";
+import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
+import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts";
+import { OrchestrationEngineLive } from "../orchestration/Layers/OrchestrationEngine.ts";
+import { OrchestrationProjectionPipelineLive } from "../orchestration/Layers/ProjectionPipeline.ts";
+import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts";
+import { ProviderCommandReactorLive } from "../orchestration/Layers/ProviderCommandReactor.ts";
+import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts";
+import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts";
+import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts";
+import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
+import { ProviderCommandReactor } from "../orchestration/Services/ProviderCommandReactor.ts";
+import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts";
+import { makeProviderServiceLive } from "../provider/Layers/ProviderService.ts";
+import {
+ NoOpProviderEventLoggers,
+ ProviderEventLoggers,
+} from "../provider/Layers/ProviderEventLoggers.ts";
+import { ProviderSessionDirectoryPersistenceError } from "../provider/Errors.ts";
+import { ProviderAdapterRegistry } from "../provider/Services/ProviderAdapterRegistry.ts";
+import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts";
+import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts";
+import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts";
+import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";
+import { ServerSettingsService } from "../serverSettings.ts";
+import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
+import { TextGeneration } from "../textGeneration/TextGeneration.ts";
+import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts";
+import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
+import { importRecentAgentThreads } from "./AgentSessionImporter.ts";
+import * as AgentSessionScanner from "./AgentSessionScanner.ts";
+
+const PROJECT_ID = ProjectId.make("project-1");
+const WORKSPACE_ROOT = "/tmp/project-from-server";
+const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000";
+const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
+
+const makeThread = (source: "codex" | "claudeAgent"): AgentSessionScanner.AgentSessionThread => ({
+ source,
+ providerInstanceId: ProviderInstanceId.make(source),
+ providerSessionId: source === "codex" ? "codex-session" : CLAUDE_SESSION_ID,
+ title: `Imported ${source} thread`,
+ model: null,
+ createdAt: "2026-08-24T10:00:00.000Z",
+ updatedAt: "2026-08-24T10:01:00.000Z",
+ messages: [
+ { role: "user", text: "Fix the bug", createdAt: "2026-08-24T10:00:00.000Z" },
+ { role: "assistant", text: "Fixed", createdAt: "2026-08-24T10:01:00.000Z" },
+ ],
+});
+
+const makeThreadOutcome = (thread: AgentSessionScanner.AgentSessionThread) =>
+ ({
+ _tag: "Importable",
+ thread,
+ source: {
+ provider: thread.source,
+ providerInstanceId: thread.providerInstanceId,
+ providerSessionId: thread.providerSessionId,
+ filePath: `/tmp/transcripts/${thread.providerInstanceId}/${thread.providerSessionId}.jsonl`,
+ size: 0,
+ mtimeMs: 0,
+ device: 0,
+ inode: 0,
+ birthtimeMs: 0,
+ },
+ }) satisfies AgentSessionScanner.AgentSessionRecentThread;
+
+const makeProject = (): OrchestrationProjectShell => ({
+ id: PROJECT_ID,
+ title: "Project",
+ workspaceRoot: WORKSPACE_ROOT,
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: "2026-08-24T09:00:00.000Z",
+ updatedAt: "2026-08-24T09:00:00.000Z",
+});
+
+const makeProjectedThread = (input: {
+ readonly source: "codex" | "claudeAgent";
+ readonly projectId?: ProjectId;
+ readonly imported?: boolean;
+ readonly includeFollowup?: boolean;
+}): OrchestrationThread => {
+ const sourceThread = makeThread(input.source);
+ const threadId = ThreadId.make(
+ `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`,
+ );
+ return {
+ id: threadId,
+ projectId: input.projectId ?? PROJECT_ID,
+ title: sourceThread.title,
+ modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ latestTurn: null,
+ createdAt: sourceThread.createdAt,
+ updatedAt: sourceThread.updatedAt,
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ deletedAt: null,
+ messages: input.imported
+ ? [
+ {
+ id: MessageId.make(`${threadId}:000000`),
+ role: "user",
+ text: "Fix the bug",
+ turnId: null,
+ streaming: false,
+ createdAt: "2026-08-24T10:00:00.000Z",
+ updatedAt: "2026-08-24T10:00:00.000Z",
+ },
+ ...(input.includeFollowup
+ ? [
+ {
+ id: MessageId.make("user-followup"),
+ role: "user" as const,
+ text: "Keep going",
+ turnId: null,
+ streaming: false,
+ createdAt: "2026-08-24T10:02:00.000Z",
+ updatedAt: "2026-08-24T10:02:00.000Z",
+ },
+ ]
+ : []),
+ ]
+ : [],
+ proposedPlans: [],
+ activities: [],
+ checkpoints: [],
+ session: null,
+ };
+};
+
+const makeSnapshotsLayer = (input: {
+ readonly project?: OrchestrationProjectShell;
+ readonly getThread?: (threadId: ThreadId) => Option.Option;
+}) =>
+ Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({
+ getProjectShellById: () =>
+ Effect.succeed(input.project === undefined ? Option.none() : Option.some(input.project)),
+ getImportedAgentSessionSources: () => Effect.succeed([]),
+ getThreadDetailById: (threadId) => Effect.succeed(input.getThread?.(threadId) ?? Option.none()),
+ });
+
+const runImport = (input: {
+ readonly scanner: AgentSessionScanner.AgentSessionScanner["Service"];
+ readonly engine: OrchestrationEngine.OrchestrationEngineService["Service"];
+ readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"];
+ readonly snapshots: ReturnType;
+ readonly expectedWorkspaceRoot?: string;
+}) =>
+ importRecentAgentThreads({
+ projectId: PROJECT_ID,
+ ...(input.expectedWorkspaceRoot === undefined
+ ? {}
+ : { expectedWorkspaceRoot: input.expectedWorkspaceRoot }),
+ }).pipe(
+ Effect.provideService(AgentSessionScanner.AgentSessionScanner, input.scanner),
+ Effect.provideService(OrchestrationEngine.OrchestrationEngineService, input.engine),
+ Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory),
+ Effect.provide(input.snapshots),
+ );
+
+it.layer(NodeServices.layer)("AgentSessionImporter", (it) => {
+ describe("importRecentAgentThreads", () => {
+ it.effect("uses the project root and stores provider-specific resume cursors", () =>
+ Effect.gen(function* () {
+ const commands: Array = [];
+ const bindings: Array = [];
+ let scannedRoot: string | undefined;
+ const scanner = AgentSessionScanner.AgentSessionScanner.of({
+ scan: Effect.die("unused"),
+ recentThreads: (workspaceRoot) => {
+ scannedRoot = workspaceRoot;
+ return Stream.concat(
+ Stream.succeed(makeThreadOutcome(makeThread("codex"))),
+ Stream.fromEffect(
+ Effect.sync(() => {
+ expect(bindings).toHaveLength(1);
+ return makeThreadOutcome(makeThread("claudeAgent"));
+ }),
+ ),
+ );
+ },
+ });
+ const engine = OrchestrationEngine.OrchestrationEngineService.of({
+ dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })),
+ readEvents: () => Stream.empty,
+ readThreadEvents: () => Stream.empty,
+ getThreadReplayStats: () => Effect.die("unused"),
+ streamDomainEvents: Stream.empty,
+ subscribeDomainEvents: Effect.succeed(Stream.empty),
+ latestSequence: Effect.succeed(0),
+ });
+ const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({
+ upsert: (binding) => Effect.sync(() => void bindings.push(binding)),
+ getProvider: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.void,
+ getBinding: () => Effect.succeed(Option.none()),
+ removeExact: () => Effect.die("unused"),
+ listThreadIds: () => Effect.die("unused"),
+ listBindings: () => Effect.die("unused"),
+ });
+
+ const result = yield* runImport({
+ scanner,
+ engine,
+ directory,
+ snapshots: makeSnapshotsLayer({ project: makeProject() }),
+ expectedWorkspaceRoot: `${WORKSPACE_ROOT}/`,
+ });
+
+ expect(result).toEqual({ importedCount: 2, skippedCount: 0 });
+ expect(scannedRoot).toBe(WORKSPACE_ROOT);
+ expect(commands.map((command) => command.type)).toEqual([
+ "thread.create",
+ "thread.history.import",
+ "thread.create",
+ "thread.history.import",
+ ]);
+ expect(commands.filter((command) => command.type === "thread.create")).toMatchObject([
+ { historyImport: true },
+ { historyImport: true },
+ ]);
+ expect(
+ commands
+ .filter((command) => command.type === "thread.history.import")
+ .flatMap((command) => command.messages.map((message) => message.messageId)),
+ ).toEqual([
+ "import:codex:codex-session:000000",
+ "import:codex:codex-session:000001",
+ `import:claudeAgent:${CLAUDE_SESSION_ID}:000000`,
+ `import:claudeAgent:${CLAUDE_SESSION_ID}:000001`,
+ ]);
+ expect(bindings).toMatchObject([
+ {
+ provider: "codex",
+ providerInstanceId: "codex",
+ resumeCursor: { threadId: "codex-session" },
+ runtimePayload: { cwd: WORKSPACE_ROOT },
+ },
+ {
+ provider: "claudeAgent",
+ providerInstanceId: "claudeAgent",
+ resumeCursor: {
+ threadId: `import:claudeAgent:${CLAUDE_SESSION_ID}`,
+ resume: CLAUDE_SESSION_ID,
+ },
+ runtimePayload: { cwd: WORKSPACE_ROOT },
+ },
+ ]);
+ }),
+ );
+
+ it.effect("rejects a changed project root before scanning or writing", () =>
+ Effect.gen(function* () {
+ const recentThreads = vi.fn(() => Stream.empty);
+ const error = yield* importRecentAgentThreads({
+ projectId: PROJECT_ID,
+ expectedWorkspaceRoot: WORKSPACE_ROOT,
+ }).pipe(
+ Effect.provideService(
+ AgentSessionScanner.AgentSessionScanner,
+ AgentSessionScanner.AgentSessionScanner.of({
+ scan: Effect.die("must not scan a changed project"),
+ recentThreads,
+ }),
+ ),
+ Effect.provide(
+ Layer.mergeAll(
+ Layer.mock(OrchestrationEngine.OrchestrationEngineService)({}),
+ Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({}),
+ makeSnapshotsLayer({
+ project: { ...makeProject(), workspaceRoot: "/tmp/project-moved" },
+ }),
+ ),
+ ),
+ Effect.flip,
+ );
+
+ expect(error).toEqual(new AgentSessionImportProjectChangedError({ projectId: PROJECT_ID }));
+ expect(recentThreads).not.toHaveBeenCalled();
+ }),
+ );
+
+ it.effect("counts scanner skips without writing a thread or binding", () =>
+ Effect.gen(function* () {
+ const scanner = AgentSessionScanner.AgentSessionScanner.of({
+ scan: Effect.die("unused"),
+ recentThreads: () => Stream.succeed({ _tag: "Skipped" }),
+ });
+ const engine = OrchestrationEngine.OrchestrationEngineService.of({
+ dispatch: () => Effect.die("must not dispatch for a scanner skip"),
+ readEvents: () => Stream.empty,
+ readThreadEvents: () => Stream.empty,
+ getThreadReplayStats: () => Effect.die("unused"),
+ streamDomainEvents: Stream.empty,
+ subscribeDomainEvents: Effect.succeed(Stream.empty),
+ latestSequence: Effect.succeed(0),
+ });
+ const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({
+ upsert: () => Effect.die("must not bind a scanner skip"),
+ getProvider: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
+ getBinding: () => Effect.die("must not read a scanner skip binding"),
+ removeExact: () => Effect.die("unused"),
+ listThreadIds: () => Effect.die("unused"),
+ listBindings: () => Effect.die("unused"),
+ });
+
+ const result = yield* runImport({
+ scanner,
+ engine,
+ directory,
+ snapshots: makeSnapshotsLayer({ project: makeProject() }),
+ });
+
+ expect(result).toEqual({ importedCount: 0, skippedCount: 1 });
+ }),
+ );
+
+ it.effect("recovers after a rejected history receipt and a failed binding write", () =>
+ Effect.gen(function* () {
+ let threadCreated = false;
+ let historyImported = false;
+ let historyAttemptCount = 0;
+ let bindingAttemptCount = 0;
+ const rejectedCommandIds = new Set();
+ const bindings: Array = [];
+ const scanner = AgentSessionScanner.AgentSessionScanner.of({
+ scan: Effect.die("unused"),
+ recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]),
+ });
+ const engine = OrchestrationEngine.OrchestrationEngineService.of({
+ dispatch: (command) => {
+ if (rejectedCommandIds.has(command.commandId)) {
+ return Effect.fail(
+ new OrchestrationCommandInvariantError({
+ commandType: command.type,
+ detail: "Previously rejected.",
+ }),
+ );
+ }
+ if (command.type === "thread.create") threadCreated = true;
+ if (command.type === "thread.history.import") {
+ historyAttemptCount += 1;
+ if (historyAttemptCount === 1) {
+ rejectedCommandIds.add(command.commandId);
+ return Effect.fail(
+ new OrchestrationCommandInvariantError({
+ commandType: command.type,
+ detail: "Temporary history import failure.",
+ }),
+ );
+ }
+ historyImported = true;
+ }
+ return Effect.succeed({ sequence: 1 });
+ },
+ readEvents: () => Stream.empty,
+ readThreadEvents: () => Stream.empty,
+ getThreadReplayStats: () => Effect.die("unused"),
+ streamDomainEvents: Stream.empty,
+ subscribeDomainEvents: Effect.succeed(Stream.empty),
+ latestSequence: Effect.succeed(0),
+ });
+ const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({
+ upsert: (binding) => {
+ bindingAttemptCount += 1;
+ if (bindingAttemptCount === 1) {
+ return Effect.fail(
+ new ProviderSessionDirectoryPersistenceError({
+ operation: "upsert",
+ detail: "Temporary session storage failure.",
+ }),
+ );
+ }
+ bindings.push(binding);
+ return Effect.void;
+ },
+ getProvider: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.void,
+ getBinding: () =>
+ Effect.succeed(bindings[0] === undefined ? Option.none() : Option.some(bindings[0])),
+ listThreadIds: () => Effect.die("unused"),
+ removeExact: () => Effect.die("unused"),
+ listBindings: () => Effect.die("unused"),
+ });
+ const snapshots = makeSnapshotsLayer({
+ project: makeProject(),
+ getThread: () =>
+ threadCreated
+ ? Option.some(makeProjectedThread({ source: "codex", imported: historyImported }))
+ : Option.none(),
+ });
+ const importOnce = () => runImport({ scanner, engine, directory, snapshots });
+
+ expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 });
+ expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 });
+ expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 });
+ const historyAttemptsAfterCompletion = historyAttemptCount;
+ expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 });
+ expect(historyAttemptCount).toBe(historyAttemptsAfterCompletion);
+ expect(historyAttemptCount).toBe(2);
+ expect(bindings).toHaveLength(1);
+ }),
+ );
+
+ it.effect("does not replace completed history or an active binding on retry", () =>
+ Effect.gen(function* () {
+ const scanner = AgentSessionScanner.AgentSessionScanner.of({
+ scan: Effect.die("unused"),
+ recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]),
+ });
+ const runningBinding: ProviderSessionDirectory.ProviderRuntimeBinding = {
+ threadId: ThreadId.make("import:codex:codex-session"),
+ provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ status: "running",
+ resumeCursor: { threadId: "newer-codex-session" },
+ };
+ const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({
+ upsert: () => Effect.die("must not replace an active binding"),
+ getProvider: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.void,
+ getBinding: () => Effect.succeed(Option.some(runningBinding)),
+ removeExact: () => Effect.die("unused"),
+ listThreadIds: () => Effect.die("unused"),
+ listBindings: () => Effect.die("unused"),
+ });
+ const engine = OrchestrationEngine.OrchestrationEngineService.of({
+ dispatch: () => Effect.die("must not replay history or settle active work"),
+ readEvents: () => Stream.empty,
+ readThreadEvents: () => Stream.empty,
+ getThreadReplayStats: () => Effect.die("unused"),
+ streamDomainEvents: Stream.empty,
+ subscribeDomainEvents: Effect.succeed(Stream.empty),
+ latestSequence: Effect.succeed(0),
+ });
+
+ const result = yield* runImport({
+ scanner,
+ engine,
+ directory,
+ snapshots: makeSnapshotsLayer({
+ project: makeProject(),
+ getThread: () =>
+ Option.some(
+ makeProjectedThread({ source: "codex", imported: true, includeFollowup: true }),
+ ),
+ }),
+ });
+
+ expect(result).toEqual({ importedCount: 1, skippedCount: 0 });
+ }),
+ );
+
+ it.effect("skips malformed Claude ids and wrong-project thread collisions", () =>
+ Effect.gen(function* () {
+ const scanner = AgentSessionScanner.AgentSessionScanner.of({
+ scan: Effect.die("unused"),
+ recentThreads: () =>
+ Stream.fromIterable([
+ makeThreadOutcome({ ...makeThread("claudeAgent"), providerSessionId: "not-a-uuid" }),
+ makeThreadOutcome(makeThread("codex")),
+ ]),
+ });
+ const commands: Array = [];
+ const engine = OrchestrationEngine.OrchestrationEngineService.of({
+ dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })),
+ readEvents: () => Stream.empty,
+ readThreadEvents: () => Stream.empty,
+ getThreadReplayStats: () => Effect.die("unused"),
+ streamDomainEvents: Stream.empty,
+ subscribeDomainEvents: Effect.succeed(Stream.empty),
+ latestSequence: Effect.succeed(0),
+ });
+ const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({
+ upsert: () => Effect.die("must not bind malformed or wrong-project sessions"),
+ getProvider: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
+ getBinding: () => Effect.succeed(Option.none()),
+ removeExact: () => Effect.die("unused"),
+ listThreadIds: () => Effect.die("unused"),
+ listBindings: () => Effect.die("unused"),
+ });
+
+ const result = yield* runImport({
+ scanner,
+ engine,
+ directory,
+ snapshots: makeSnapshotsLayer({
+ project: makeProject(),
+ getThread: (threadId) =>
+ threadId === "import:codex:codex-session"
+ ? Option.some(
+ makeProjectedThread({
+ source: "codex",
+ projectId: ProjectId.make("project-other"),
+ }),
+ )
+ : Option.none(),
+ }),
+ });
+
+ expect(result).toEqual({ importedCount: 0, skippedCount: 2 });
+ expect(commands).toHaveLength(0);
+ }),
+ );
+ });
+});
+
+const integrationThread = {
+ ...makeThread("codex"),
+ updatedAt: "2026-08-24T10:00:00.000Z",
+ messages: Array.from({ length: 12 }, (_, index) => ({
+ role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
+ text: `Message ${index}`,
+ createdAt: "2026-08-24T10:00:00.000Z",
+ })),
+};
+const integrationScanner = AgentSessionScanner.AgentSessionScanner.of({
+ scan: Effect.die("unused"),
+ recentThreads: () => Stream.fromIterable([makeThreadOutcome(integrationThread)]),
+});
+const integrationServerConfig = ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3-agent-session-importer-test-",
+});
+const integrationRuntimeRepository = ProviderSessionRuntime.layer.pipe(
+ Layer.provide(SqlitePersistenceMemory),
+);
+const integrationLayer = Layer.mergeAll(
+ OrchestrationEngineLive.pipe(
+ Layer.provide(OrchestrationProjectionSnapshotQueryLive),
+ Layer.provide(OrchestrationProjectionPipelineLive),
+ ),
+ OrchestrationProjectionSnapshotQueryLive,
+ integrationRuntimeRepository,
+ ProviderSessionDirectoryLive.pipe(Layer.provide(integrationRuntimeRepository)),
+ Layer.succeed(AgentSessionScanner.AgentSessionScanner, integrationScanner),
+).pipe(
+ Layer.provide(ThreadBackgroundLiveness.layer),
+ Layer.provide(ThreadPlanProgress.layer),
+ Layer.provide(OrchestrationEventStoreLive),
+ Layer.provide(OrchestrationCommandReceiptRepositoryLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
+ Layer.provide(SqlitePersistenceMemory),
+ Layer.provideMerge(integrationServerConfig),
+ Layer.provideMerge(NodeServices.layer),
+);
+
+it.layer(integrationLayer)("AgentSessionImporter integration", (it) => {
+ it.effect("imports once after the real engine persists an old rejected receipt", () =>
+ Effect.gen(function* () {
+ const engine = yield* OrchestrationEngine.OrchestrationEngineService;
+ const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
+ const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
+ const threadId = ThreadId.make("import:codex:codex-session");
+
+ yield* engine.dispatch({
+ type: "project.create",
+ commandId: CommandId.make("create-import-integration-project"),
+ projectId: PROJECT_ID,
+ title: "Project",
+ workspaceRoot: WORKSPACE_ROOT,
+ defaultModelSelection: null,
+ createdAt: "2026-08-24T09:00:00.000Z",
+ });
+ const rejected = yield* Effect.result(
+ engine.dispatch({
+ type: "thread.history.import",
+ commandId: CommandId.make(`agent-session:history:${threadId}`),
+ threadId,
+ messages: [
+ {
+ messageId: MessageId.make(`${threadId}:000000`),
+ role: "user",
+ text: "Fix the bug",
+ createdAt: "2026-08-24T10:00:00.000Z",
+ },
+ ],
+ }),
+ );
+ expect(rejected._tag).toBe("Failure");
+
+ const result = yield* importRecentAgentThreads({ projectId: PROJECT_ID });
+ const importedThread = yield* snapshots.getThreadDetailById(threadId);
+ const binding = yield* directory.getBinding(threadId);
+
+ expect(result).toEqual({ importedCount: 1, skippedCount: 0 });
+ expect(Option.getOrThrow(importedThread).messages.map((message) => message.text)).toEqual(
+ integrationThread.messages.map((message) => message.text),
+ );
+ expect(Option.getOrThrow(importedThread).settledOverride).toBe("settled");
+ expect(Option.getOrThrow(importedThread).updatedAt).toBe("2026-08-24T10:00:00.000Z");
+ expect(Option.getOrThrow(binding)).toMatchObject({
+ provider: "codex",
+ providerInstanceId: "codex",
+ resumeCursor: { threadId: "codex-session" },
+ runtimePayload: { cwd: WORKSPACE_ROOT },
+ });
+
+ // Pylon's rollback completion is a revision compare-and-set, so the
+ // revert needs a completed turn after the imported history to undo.
+ yield* engine.dispatch({
+ type: "thread.turn.diff.complete",
+ commandId: CommandId.make("complete-turn-after-import"),
+ threadId,
+ turnId: TurnId.make("turn-after-import"),
+ completedAt: "2026-08-24T10:04:00.000Z",
+ checkpointRef: CheckpointRef.make("refs/t3/checkpoints/import/turn/1"),
+ status: "ready",
+ files: [],
+ checkpointTurnCount: 1,
+ createdAt: "2026-08-24T10:04:00.000Z",
+ });
+ yield* engine.dispatch({
+ type: "thread.revert.complete",
+ commandId: CommandId.make("revert-imported-thread-to-baseline"),
+ threadId,
+ operationId: "revert-imported-thread-to-baseline",
+ sourceRevision: 1,
+ targetRevision: 0,
+ turnCount: 0,
+ createdAt: "2026-08-24T10:05:00.000Z",
+ });
+ const afterRevert = yield* snapshots.getThreadDetailById(threadId);
+ expect(Option.getOrThrow(afterRevert).messages.map((message) => message.text)).toEqual(
+ integrationThread.messages.map((message) => message.text),
+ );
+ }),
+ );
+
+ it.effect(
+ "retries a bounded import after scanner restart without rereading completed transcripts",
+ () =>
+ Effect.gen(function* () {
+ const engine = yield* OrchestrationEngine.OrchestrationEngineService;
+ const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
+ const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-import-retry-",
+ });
+ const workspaceRoot = path.join(fixtureDir, "workspace");
+ const claudeHomePath = path.join(fixtureDir, "claude");
+ const codexHomePath = path.join(fixtureDir, "codex");
+ const sessionsDir = path.join(codexHomePath, "sessions", "2026", "08", "24");
+ yield* fileSystem.makeDirectory(workspaceRoot);
+ yield* fileSystem.makeDirectory(claudeHomePath);
+ yield* fileSystem.makeDirectory(sessionsDir, { recursive: true });
+
+ const projectId = ProjectId.make("project-bounded-import-retry");
+ const transcripts = Array.from({ length: 101 }, (_, index) => {
+ const providerSessionId = `bounded-session-${String(index).padStart(3, "0")}`;
+ return {
+ providerSessionId,
+ threadId: ThreadId.make(`import:codex:${providerSessionId}`),
+ filePath: path.join(sessionsDir, `rollout-${providerSessionId}.jsonl`),
+ };
+ });
+ for (const [index, transcript] of transcripts.entries()) {
+ yield* fileSystem.writeFileString(
+ transcript.filePath,
+ [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: transcript.providerSessionId, cwd: workspaceRoot },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: {
+ type: "user_message",
+ message: `Prompt ${transcript.providerSessionId}`,
+ },
+ }),
+ ].join("\n"),
+ );
+ const seconds = nowMs / 1_000 - index;
+ yield* fileSystem.utimes(transcript.filePath, seconds, seconds);
+ }
+ const legacy = transcripts[0]!;
+ const failed = transcripts[1]!;
+ const remaining = transcripts[100]!;
+ yield* engine.dispatch({
+ type: "project.create",
+ commandId: CommandId.make("create-bounded-import-project"),
+ projectId,
+ title: "Bounded import",
+ workspaceRoot,
+ defaultModelSelection: null,
+ createdAt: "2026-08-24T09:00:00.000Z",
+ });
+
+ // This completed import predates persisted transcript source metadata.
+ yield* directory.upsert({
+ threadId: legacy.threadId,
+ provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ status: "stopped",
+ resumeCursor: { threadId: "legacy-current-session" },
+ runtimePayload: { cwd: workspaceRoot },
+ });
+ yield* engine.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make("create-legacy-bounded-import"),
+ threadId: legacy.threadId,
+ projectId,
+ title: "Legacy import",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt: "2026-08-24T10:00:00.000Z",
+ historyImport: true,
+ });
+ yield* engine.dispatch({
+ type: "thread.history.import",
+ commandId: CommandId.make("import-legacy-bounded-history"),
+ threadId: legacy.threadId,
+ messages: [
+ {
+ messageId: MessageId.make(`${legacy.threadId}:000000`),
+ role: "user",
+ text: "Legacy imported history",
+ createdAt: "2026-08-24T10:00:00.000Z",
+ },
+ ],
+ });
+ expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toEqual([]);
+
+ let failHistory = true;
+ const importerEngine = OrchestrationEngine.OrchestrationEngineService.of({
+ ...engine,
+ dispatch: (command) => {
+ if (
+ failHistory &&
+ command.type === "thread.history.import" &&
+ command.threadId === failed.threadId
+ ) {
+ failHistory = false;
+ return Effect.fail(
+ new OrchestrationCommandInvariantError({
+ commandType: command.type,
+ detail: "Injected history import failure.",
+ }),
+ );
+ }
+ return engine.dispatch(command);
+ },
+ });
+ const settingsLayer = ServerSettingsService.layerTest({
+ providers: {
+ claudeAgent: { homePath: claudeHomePath },
+ codex: { homePath: codexHomePath },
+ },
+ });
+ const transcriptPaths = new Set(transcripts.map((transcript) => transcript.filePath));
+ const runAttempt = Effect.fn("runBoundedImportAttempt")(function* (
+ completedPaths: ReadonlySet,
+ ) {
+ const openCounts = new Map();
+ const fullReads: string[] = [];
+ const observedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ open: (filePath, options) =>
+ Effect.suspend(() => {
+ if (transcriptPaths.has(filePath)) {
+ const count = (openCounts.get(filePath) ?? 0) + 1;
+ openCounts.set(filePath, count);
+ // A fresh scanner first opens each file for project discovery.
+ if (count > 1) {
+ fullReads.push(filePath);
+ if (completedPaths.has(filePath)) {
+ return Effect.die(new Error(`Completed transcript reopened: ${filePath}`));
+ }
+ }
+ }
+ return fileSystem.open(filePath, options);
+ }),
+ });
+ const result = yield* importRecentAgentThreads({ projectId }).pipe(
+ Effect.provide(
+ Layer.fresh(AgentSessionScanner.layer).pipe(
+ Layer.provide(settingsLayer),
+ Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFileSystem)),
+ ),
+ ),
+ Effect.provideService(OrchestrationEngine.OrchestrationEngineService, importerEngine),
+ );
+ return { result, fullReads, openCounts };
+ });
+
+ const first = yield* runAttempt(new Set());
+ expect(first.result).toEqual({ importedCount: 99, skippedCount: 2 });
+ expect(failHistory).toBe(false);
+ expect(first.fullReads).toEqual(transcripts.slice(0, 100).map((entry) => entry.filePath));
+ expect(first.openCounts.get(remaining.filePath)).toBe(1);
+ const completedSources = yield* snapshots.getImportedAgentSessionSources(projectId);
+ expect(completedSources).toHaveLength(99);
+ expect(completedSources).toContainEqual({
+ threadId: legacy.threadId,
+ source: expect.objectContaining({ filePath: legacy.filePath }),
+ });
+ expect(
+ Option.getOrThrow(yield* snapshots.getThreadDetailById(failed.threadId)).messages,
+ ).toEqual([]);
+ expect(Option.getOrThrow(yield* directory.getBinding(failed.threadId))).toMatchObject({
+ status: "stopped",
+ resumeCursor: { threadId: failed.providerSessionId },
+ });
+ expect(Option.isNone(yield* snapshots.getThreadDetailById(remaining.threadId))).toBe(true);
+
+ const completedPaths = new Set(completedSources.map((entry) => entry.source.filePath));
+ const second = yield* runAttempt(completedPaths);
+ expect(second.result).toEqual({ importedCount: 101, skippedCount: 0 });
+ expect(second.fullReads).toEqual([failed.filePath, remaining.filePath]);
+ for (const transcript of transcripts) {
+ expect(second.openCounts.get(transcript.filePath)).toBe(
+ completedPaths.has(transcript.filePath) ? 1 : 2,
+ );
+ }
+ expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toHaveLength(101);
+ expect(
+ Option.getOrThrow(yield* snapshots.getThreadDetailById(legacy.threadId)).messages.map(
+ (message) => message.text,
+ ),
+ ).toEqual(["Legacy imported history"]);
+ expect(
+ Option.getOrThrow(yield* directory.getBinding(legacy.threadId)).resumeCursor,
+ ).toEqual({
+ threadId: "legacy-current-session",
+ });
+ for (const transcript of [failed, remaining]) {
+ expect(
+ Option.getOrThrow(
+ yield* snapshots.getThreadDetailById(transcript.threadId),
+ ).messages.map((message) => message.text),
+ ).toEqual([`Prompt ${transcript.providerSessionId}`]);
+ }
+ }),
+ );
+
+ for (const source of ["codex", "claudeAgent"] as const) {
+ it.effect(`resumes imported ${source} history only after the first prompt`, () =>
+ Effect.gen(function* () {
+ const engine = yield* OrchestrationEngine.OrchestrationEngineService;
+ const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
+ const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped();
+ const projectId = ProjectId.make(`project-import-resume-${source}`);
+ const sourceThread = {
+ ...makeThread(source),
+ providerSessionId: source === "codex" ? "codex-first-resume" : CLAUDE_SESSION_ID,
+ };
+ const threadId = ThreadId.make(
+ `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`,
+ );
+ const resumeCursor =
+ source === "codex"
+ ? { threadId: sourceThread.providerSessionId }
+ : { threadId, resume: sourceThread.providerSessionId };
+ const provider = ProviderDriverKind.make(source);
+ const harness = yield* makeTestProviderAdapterHarness({ provider });
+ const importSettled = yield* Deferred.make();
+ const turnSent = yield* Deferred.make();
+ const startSession = vi.fn(harness.adapter.startSession);
+ const sendTurn = vi.fn((input: ProviderSendTurnInput) =>
+ harness.adapter
+ .sendTurn(input)
+ .pipe(Effect.tap(() => Deferred.succeed(turnSent, undefined))),
+ );
+ const providerLayer = makeProviderServiceLive().pipe(
+ Layer.provide(
+ Layer.succeed(
+ ProviderAdapterRegistry,
+ makeAdapterRegistryMock({
+ [provider]: { ...harness.adapter, startSession, sendTurn },
+ }),
+ ),
+ ),
+ Layer.provide(
+ Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory),
+ ),
+ Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
+ Layer.provide(AnalyticsService.layerTest),
+ );
+ const reactorLayer = ProviderCommandReactorLive.pipe(
+ Layer.provideMerge(providerLayer),
+ Layer.provide(
+ Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
+ ...snapshots,
+ // Acknowledge the imported settlement before draining the reactor.
+ getThreadShellById: (requestedThreadId) =>
+ snapshots
+ .getThreadShellById(requestedThreadId)
+ .pipe(
+ Effect.tap(() =>
+ requestedThreadId === threadId
+ ? Deferred.succeed(importSettled, undefined)
+ : Effect.void,
+ ),
+ ),
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ProviderAuthService)({
+ tryHandlePromptCommand: () => Effect.succeed(false),
+ }),
+ ),
+ Layer.provide(makeProviderRegistryLayer()),
+ Layer.provide(Layer.mock(GitWorkflowService)({})),
+ Layer.provide(Layer.mock(VcsStatusBroadcaster)({})),
+ Layer.provide(Layer.mock(TextGeneration)({})),
+ Layer.provide(ServerSettingsService.layerTest()),
+ );
+
+ yield* engine.dispatch({
+ type: "project.create",
+ commandId: CommandId.make(`create-import-resume-project-${source}`),
+ projectId,
+ title: "Import resume",
+ workspaceRoot,
+ defaultModelSelection: null,
+ createdAt: "2026-08-24T09:00:00.000Z",
+ });
+ yield* harness.queueTurnResponseForNextSession({ events: [] });
+
+ yield* Effect.gen(function* () {
+ const reactor = yield* ProviderCommandReactor;
+ yield* reactor.start();
+ expect(yield* importRecentAgentThreads({ projectId })).toEqual({
+ importedCount: 1,
+ skippedCount: 0,
+ });
+ yield* Deferred.await(importSettled);
+ yield* reactor.drain;
+ expect(startSession).not.toHaveBeenCalled();
+ expect(sendTurn).not.toHaveBeenCalled();
+ const importedThread = Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId));
+ expect(importedThread.session).toBeNull();
+ expect(importedThread.latestTurn).toBeNull();
+
+ yield* engine.dispatch({
+ type: "thread.turn.start",
+ commandId: CommandId.make(`resume-imported-${source}`),
+ threadId,
+ message: {
+ messageId: MessageId.make(`resume-imported-message-${source}`),
+ role: "user",
+ text: "Continue this session",
+ attachments: [],
+ },
+ modelSelection: importedThread.modelSelection,
+ runtimeMode: importedThread.runtimeMode,
+ interactionMode: importedThread.interactionMode,
+ createdAt: "2026-08-24T10:02:00.000Z",
+ });
+ yield* Deferred.await(turnSent);
+ yield* reactor.drain;
+ expect(startSession).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({
+ threadId,
+ provider,
+ providerInstanceId: sourceThread.providerInstanceId,
+ resumeCursor,
+ cwd: workspaceRoot,
+ }),
+ );
+ expect(sendTurn).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ threadId, input: "Continue this session" }),
+ );
+ expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({
+ provider,
+ providerInstanceId: sourceThread.providerInstanceId,
+ resumeCursor,
+ });
+ }).pipe(
+ Effect.provide(reactorLayer),
+ Effect.provideService(
+ AgentSessionScanner.AgentSessionScanner,
+ AgentSessionScanner.AgentSessionScanner.of({
+ scan: Effect.die("unused"),
+ recentThreads: () => Stream.succeed(makeThreadOutcome(sourceThread)),
+ }),
+ ),
+ );
+ }),
+ );
+ }
+
+ it.effect("persists the resume cursor before publishing a new imported thread", () =>
+ Effect.gen(function* () {
+ const engine = yield* OrchestrationEngine.OrchestrationEngineService;
+ const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
+ const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
+ const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
+ const projectId = ProjectId.make("project-import-binding-race");
+ const workspaceRoot = "/tmp/project-import-binding-race";
+ const providerSessionId = "codex-binding-race";
+ const threadId = ThreadId.make(`import:codex:${providerSessionId}`);
+ const scanner = AgentSessionScanner.AgentSessionScanner.of({
+ scan: Effect.die("unused"),
+ recentThreads: () =>
+ Stream.succeed(
+ makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Binding race" }),
+ ),
+ });
+ const importerAtBindingWrite = yield* Deferred.make();
+ const releaseImporter = yield* Deferred.make();
+ const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({
+ ...repository,
+ upsert: (runtime, options) =>
+ options?.onConflict === "ignore"
+ ? Deferred.succeed(importerAtBindingWrite, undefined).pipe(
+ Effect.andThen(Deferred.await(releaseImporter)),
+ Effect.andThen(repository.upsert(runtime, options)),
+ )
+ : repository.upsert(runtime, options),
+ });
+ const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe(
+ Effect.provide(
+ Layer.fresh(ProviderSessionDirectoryLive).pipe(
+ Layer.provide(
+ Layer.succeed(
+ ProviderSessionRuntime.ProviderSessionRuntimeRepository,
+ importerRepository,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ yield* engine.dispatch({
+ type: "project.create",
+ commandId: CommandId.make("create-import-binding-race-project"),
+ projectId,
+ title: "Binding race",
+ workspaceRoot,
+ defaultModelSelection: null,
+ createdAt: "2026-08-24T09:00:00.000Z",
+ });
+
+ const importFiber = yield* importRecentAgentThreads({ projectId }).pipe(
+ Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner),
+ Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory),
+ Effect.forkChild,
+ );
+
+ yield* Effect.raceFirst(
+ Deferred.await(importerAtBindingWrite),
+ Fiber.join(importFiber).pipe(
+ Effect.flatMap((result) =>
+ Effect.die(
+ new Error(`Import completed before the binding write: ${JSON.stringify(result)}`),
+ ),
+ ),
+ ),
+ );
+ expect(Option.isNone(yield* snapshots.getThreadDetailById(threadId))).toBe(true);
+
+ yield* directory.upsert({
+ threadId,
+ provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ status: "running",
+ resumeCursor: { threadId: "active-client-session" },
+ runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" },
+ });
+ yield* Deferred.succeed(releaseImporter, undefined);
+
+ expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 1, skippedCount: 0 });
+ expect(
+ Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map(
+ (message) => message.text,
+ ),
+ ).toEqual(integrationThread.messages.map((message) => message.text));
+ expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({
+ status: "running",
+ resumeCursor: { threadId: "active-client-session" },
+ runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" },
+ });
+ }),
+ );
+
+ it.effect("does not import history over a turn started on a partial thread", () =>
+ Effect.gen(function* () {
+ const engine = yield* OrchestrationEngine.OrchestrationEngineService;
+ const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
+ const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
+ const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
+ const projectId = ProjectId.make("project-import-turn-race");
+ const workspaceRoot = "/tmp/project-import-turn-race";
+ const providerSessionId = "codex-turn-race";
+ const threadId = ThreadId.make(`import:codex:${providerSessionId}`);
+ const scanner = AgentSessionScanner.AgentSessionScanner.of({
+ scan: Effect.die("unused"),
+ recentThreads: () =>
+ Stream.succeed(
+ makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Turn race" }),
+ ),
+ });
+ const importerAtBindingWrite = yield* Deferred.make();
+ const releaseImporter = yield* Deferred.make();
+ const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({
+ ...repository,
+ upsert: (runtime, options) =>
+ options?.onConflict === "ignore"
+ ? Deferred.succeed(importerAtBindingWrite, undefined).pipe(
+ Effect.andThen(Deferred.await(releaseImporter)),
+ Effect.andThen(repository.upsert(runtime, options)),
+ )
+ : repository.upsert(runtime, options),
+ });
+ const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe(
+ Effect.provide(
+ Layer.fresh(ProviderSessionDirectoryLive).pipe(
+ Layer.provide(
+ Layer.succeed(
+ ProviderSessionRuntime.ProviderSessionRuntimeRepository,
+ importerRepository,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ yield* engine.dispatch({
+ type: "project.create",
+ commandId: CommandId.make("create-import-turn-race-project"),
+ projectId,
+ title: "Turn race",
+ workspaceRoot,
+ defaultModelSelection: null,
+ createdAt: "2026-08-24T09:00:00.000Z",
+ });
+ yield* engine.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make("create-import-turn-race-thread"),
+ threadId,
+ projectId,
+ title: "Turn race",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt: "2026-08-24T10:00:00.000Z",
+ });
+
+ const importFiber = yield* importRecentAgentThreads({ projectId }).pipe(
+ Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner),
+ Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory),
+ Effect.forkChild,
+ );
+ yield* Deferred.await(importerAtBindingWrite);
+
+ yield* directory.upsert({
+ threadId,
+ provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ status: "running",
+ resumeCursor: { threadId: "active-client-session" },
+ runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" },
+ });
+ yield* engine.dispatch({
+ type: "thread.turn.start",
+ commandId: CommandId.make("start-turn-during-import"),
+ threadId,
+ message: {
+ messageId: MessageId.make("message-during-import"),
+ role: "user",
+ text: "Continue while import waits",
+ attachments: [],
+ },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ createdAt: "2026-08-24T10:02:00.000Z",
+ });
+ yield* Deferred.succeed(releaseImporter, undefined);
+
+ expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 0, skippedCount: 1 });
+ expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({
+ status: "running",
+ resumeCursor: { threadId: "active-client-session" },
+ runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" },
+ });
+ expect(
+ Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map(
+ (message) => message.text,
+ ),
+ ).toEqual(["Continue while import waits"]);
+ }),
+ );
+});
diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts
new file mode 100644
index 000000000..ef7042ad6
--- /dev/null
+++ b/apps/server/src/project/AgentSessionImporter.ts
@@ -0,0 +1,301 @@
+import {
+ CommandId,
+ DEFAULT_MODEL,
+ DEFAULT_MODEL_BY_PROVIDER,
+ DEFAULT_PROVIDER_INTERACTION_MODE,
+ DEFAULT_RUNTIME_MODE,
+ AgentSessionImportProjectChangedError,
+ AgentSessionImportProjectNotFoundError,
+ AgentSessionSource,
+ AgentSessionScanError,
+ isImportedAgentSessionMessageId,
+ MessageId,
+ ProjectId,
+ ProviderDriverKind,
+ ThreadId,
+ type AgentSessionImportInput,
+ type AgentSessionImportResult,
+ type OrchestrationThread,
+} from "@t3tools/contracts";
+import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
+import * as Crypto from "effect/Crypto";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+
+import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
+import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts";
+import * as AgentSessionScanner from "./AgentSessionScanner.ts";
+
+const CLAUDE_SESSION_ID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+class AgentSessionUnresumableSessionError extends Schema.TaggedErrorClass()(
+ "AgentSessionUnresumableSessionError",
+ {
+ source: AgentSessionSource,
+ providerSessionId: Schema.String,
+ },
+) {
+ override get message(): string {
+ return `Session '${this.providerSessionId}' from '${this.source}' cannot be resumed.`;
+ }
+}
+
+class AgentSessionThreadProjectConflictError extends Schema.TaggedErrorClass()(
+ "AgentSessionThreadProjectConflictError",
+ {
+ threadId: ThreadId,
+ expectedProjectId: ProjectId,
+ actualProjectId: ProjectId,
+ },
+) {
+ override get message(): string {
+ return `Imported thread '${this.threadId}' belongs to project '${this.actualProjectId}', not '${this.expectedProjectId}'.`;
+ }
+}
+
+class AgentSessionThreadModifiedError extends Schema.TaggedErrorClass()(
+ "AgentSessionThreadModifiedError",
+ { threadId: ThreadId },
+) {
+ override get message(): string {
+ return `Imported thread '${this.threadId}' changed before its history import completed.`;
+ }
+}
+
+function hasImportedHistory(thread: OrchestrationThread): boolean {
+ return thread.messages.some((message) => isImportedAgentSessionMessageId(message.id));
+}
+
+function hasImportBlockingActivity(
+ thread: OrchestrationThread,
+ importedHistoryPresent: boolean,
+): boolean {
+ return (
+ thread.archivedAt !== null ||
+ thread.deletedAt !== null ||
+ thread.latestTurn !== null ||
+ thread.session !== null ||
+ thread.messages.some((message) => !isImportedAgentSessionMessageId(message.id)) ||
+ thread.proposedPlans.length > 0 ||
+ thread.activities.length > 0 ||
+ thread.checkpoints.length > 0 ||
+ thread.snoozedUntil != null ||
+ thread.snoozedAt != null ||
+ thread.pinnedAt != null ||
+ thread.pinOrderKey != null ||
+ thread.titleRegeneration != null ||
+ thread.linkedPullRequest != null ||
+ thread.branchPullRequest != null ||
+ thread.activeOrderKey != null ||
+ thread.rollbackStatus != null ||
+ thread.continuedFromThreadId != null ||
+ thread.unsettledAt != null ||
+ (importedHistoryPresent
+ ? thread.settledOverride !== "settled"
+ : thread.settledOverride !== null || thread.settledAt !== null)
+ );
+}
+
+/** Import recent transcript text and persist the cursor needed to resume its provider session. */
+export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(function* (
+ input: AgentSessionImportInput,
+) {
+ const scanner = yield* AgentSessionScanner.AgentSessionScanner;
+ const engine = yield* OrchestrationEngine.OrchestrationEngineService;
+ const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
+ const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
+ const crypto = yield* Crypto.Crypto;
+ const project = yield* snapshots.getProjectShellById(input.projectId).pipe(
+ Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })),
+ Effect.flatMap(
+ Option.match({
+ onNone: () =>
+ Effect.fail(new AgentSessionImportProjectNotFoundError({ projectId: input.projectId })),
+ onSome: Effect.succeed,
+ }),
+ ),
+ );
+ const workspaceRoot = project.workspaceRoot;
+ if (
+ input.expectedWorkspaceRoot !== undefined &&
+ normalizeProjectPathForComparison(workspaceRoot) !==
+ normalizeProjectPathForComparison(input.expectedWorkspaceRoot)
+ ) {
+ return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId });
+ }
+ const completedSources = yield* snapshots
+ .getImportedAgentSessionSources(input.projectId)
+ .pipe(
+ Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })),
+ );
+ const threads = scanner.recentThreads(
+ workspaceRoot,
+ completedSources.map((entry) => entry.source),
+ );
+ const importedThreadIds = new Set();
+ let importedCount = 0;
+ let skippedCount = 0;
+
+ yield* Stream.runForEach(threads, (outcome) =>
+ Effect.gen(function* () {
+ if (outcome._tag === "Skipped") {
+ skippedCount += 1;
+ return;
+ }
+ if (outcome._tag === "AlreadyImported" || outcome._tag === "Duplicate") {
+ const threadId = ThreadId.make(
+ `import:${outcome.source.providerInstanceId}:${outcome.source.providerSessionId}`,
+ );
+ if (outcome._tag === "AlreadyImported") {
+ importedThreadIds.add(threadId);
+ importedCount += 1;
+ } else if (importedThreadIds.has(threadId)) {
+ const recorded = yield* directory
+ .recordImportedTranscript({ threadId, source: outcome.source })
+ .pipe(Effect.result);
+ if (recorded._tag === "Failure") {
+ skippedCount += 1;
+ yield* Effect.logWarning("Could not record an imported transcript copy", {
+ threadId,
+ cause: recorded.failure,
+ });
+ }
+ }
+ return;
+ }
+ const thread = outcome.thread;
+ const threadId = ThreadId.make(
+ `import:${thread.providerInstanceId}:${thread.providerSessionId}`,
+ );
+ const imported = yield* Effect.gen(function* () {
+ const provider = ProviderDriverKind.make(thread.source);
+ const model = thread.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL;
+ const existingThread = yield* snapshots.getThreadDetailById(threadId);
+ const existingBinding = yield* directory.getBinding(threadId);
+
+ if (
+ thread.source === "claudeAgent" &&
+ !CLAUDE_SESSION_ID_PATTERN.test(thread.providerSessionId)
+ ) {
+ return yield* new AgentSessionUnresumableSessionError({
+ source: thread.source,
+ providerSessionId: thread.providerSessionId,
+ });
+ }
+
+ if (Option.isSome(existingThread) && existingThread.value.projectId !== input.projectId) {
+ return yield* new AgentSessionThreadProjectConflictError({
+ threadId,
+ expectedProjectId: input.projectId,
+ actualProjectId: existingThread.value.projectId,
+ });
+ }
+
+ const importedHistoryPresent = Option.isSome(existingThread)
+ ? hasImportedHistory(existingThread.value)
+ : false;
+ if (
+ Option.isSome(existingThread) &&
+ importedHistoryPresent &&
+ Option.isSome(existingBinding)
+ ) {
+ yield* directory.recordImportedTranscript({ threadId, source: outcome.source });
+ return true;
+ }
+
+ if (
+ Option.isSome(existingThread) &&
+ hasImportBlockingActivity(existingThread.value, importedHistoryPresent)
+ ) {
+ return yield* new AgentSessionThreadModifiedError({ threadId });
+ }
+
+ if (
+ Option.isSome(existingBinding) &&
+ (existingBinding.value.provider !== provider ||
+ existingBinding.value.providerInstanceId !== thread.providerInstanceId ||
+ existingBinding.value.status !== "stopped")
+ ) {
+ return yield* new AgentSessionThreadModifiedError({ threadId });
+ }
+
+ // Install the cursor before the thread becomes visible. A concurrent
+ // real session can replace it, while insert-ignore keeps this import
+ // from replacing that newer binding.
+ if (Option.isNone(existingBinding)) {
+ yield* directory.upsert(
+ {
+ threadId,
+ provider,
+ providerInstanceId: thread.providerInstanceId,
+ status: "stopped",
+ runtimeMode: DEFAULT_RUNTIME_MODE,
+ resumeCursor:
+ thread.source === "codex"
+ ? { threadId: thread.providerSessionId }
+ : { threadId, resume: thread.providerSessionId },
+ runtimePayload: { cwd: workspaceRoot },
+ },
+ { onConflict: "ignore" },
+ );
+ }
+
+ if (Option.isNone(existingThread)) {
+ yield* engine.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make(yield* crypto.randomUUIDv4),
+ threadId,
+ projectId: input.projectId,
+ title: thread.title,
+ modelSelection: { instanceId: thread.providerInstanceId, model },
+ runtimeMode: DEFAULT_RUNTIME_MODE,
+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
+ branch: null,
+ worktreePath: null,
+ createdAt: thread.createdAt,
+ historyImport: true,
+ });
+ }
+
+ if (!importedHistoryPresent) {
+ yield* engine.dispatch({
+ type: "thread.history.import",
+ commandId: CommandId.make(yield* crypto.randomUUIDv4),
+ threadId,
+ messages: thread.messages.map((message, index) => ({
+ messageId: MessageId.make(`${threadId}:${String(index).padStart(6, "0")}`),
+ role: message.role,
+ text: message.text,
+ createdAt: message.createdAt,
+ })),
+ });
+ }
+
+ yield* directory.recordImportedTranscript({ threadId, source: outcome.source });
+
+ return true;
+ }).pipe(
+ Effect.catch((cause) =>
+ Effect.logWarning("Could not import an agent session", {
+ provider: thread.source,
+ sessionId: thread.providerSessionId,
+ cause,
+ }).pipe(Effect.as(false)),
+ ),
+ );
+
+ if (imported) {
+ importedThreadIds.add(threadId);
+ importedCount += 1;
+ } else {
+ skippedCount += 1;
+ }
+ }),
+ );
+
+ return { importedCount, skippedCount } satisfies AgentSessionImportResult;
+});
diff --git a/apps/server/src/project/AgentSessionJson.ts b/apps/server/src/project/AgentSessionJson.ts
new file mode 100644
index 000000000..d31c47833
--- /dev/null
+++ b/apps/server/src/project/AgentSessionJson.ts
@@ -0,0 +1,155 @@
+import * as SchemaAST from "effect/SchemaAST";
+import { isMany, none, type Many } from "stream-chain/defs.js";
+import { Assembler } from "stream-json/core/assembler.js";
+import { filter } from "stream-json/core/filters/filter.js";
+import * as StreamJson from "stream-json/core/parser.js";
+import type { ParserOptions, Token } from "stream-json/core/parser.js";
+
+type JsonPath = ReadonlyArray;
+
+/** Select schema fields before assembling their values, without a second field list. */
+export function createTranscriptJsonSelector(schema: { readonly ast: SchemaAST.AST }) {
+ const ast = SchemaAST.toEncoded(schema.ast);
+ const includes = (node: SchemaAST.AST, path: JsonPath, index: number): boolean => {
+ if (index === path.length) return true;
+ switch (node._tag) {
+ case "Objects":
+ // Records have dynamic keys. Keep their values for the decoder to validate.
+ if (node.indexSignatures.length > 0) return true;
+ return node.propertySignatures.some(
+ (property) =>
+ String(property.name) === path[index] && includes(property.type, path, index + 1),
+ );
+ case "Arrays": {
+ const key = path[index];
+ if (typeof key !== "number") return true;
+ const element = node.elements[key];
+ if (element) return includes(element, path, index + 1);
+ return node.rest.length === 0 || node.rest.some((item) => includes(item, path, index + 1));
+ }
+ case "Union":
+ return node.types.some((type) => includes(type, path, index));
+ case "Suspend":
+ return includes(node.thunk(), path, index);
+ case "Unknown":
+ case "Any":
+ case "ObjectKeyword":
+ case "Declaration":
+ // Unstructured/custom schemas must reach the decoder intact. The
+ // shared budget still bounds their allocations.
+ return true;
+ default:
+ return false;
+ }
+ };
+ return (path: JsonPath) => includes(ast, path, 0);
+}
+
+export class TranscriptJsonLimitError extends Error {}
+
+/**
+ * Project a single JSONL record without materializing unselected string values.
+ * The caller supplies a shared allocation budget for the entire transcript.
+ * Budget exhaustion rejects the transcript, never a message within it.
+ */
+export function createTranscriptJsonReader(
+ reserve: (bytes: number) => void,
+ selectPath: (path: JsonPath) => boolean,
+) {
+ // The synchronous tokenizer is exported at runtime in 3.6.0, but omitted
+ // from its bundled types. Unlike parser(), it does not wrap tokens in an
+ // async generator; the file reader already supplies backpressure and UTF-8.
+ const { jsonParser } = StreamJson as typeof StreamJson & {
+ jsonParser: (
+ options: ParserOptions,
+ ) => (input: string | typeof none) => Many | typeof none;
+ };
+ const tokenize = jsonParser({ packValues: false });
+ const select = filter({ filter: selectPath, streamKeys: false }) as (
+ input: Token | typeof none,
+ ) => Token | Many | typeof none;
+ const assembler = new Assembler();
+ let key: string | null = null;
+ let value = "";
+ let depth = 0;
+ let complete = false;
+ let malformed = false;
+
+ const assemble = (token: Token) => {
+ reserve(
+ 64 + ("value" in token && typeof token.value === "string" ? token.value.length * 2 : 0),
+ );
+ switch (token.name) {
+ case "startString":
+ case "startNumber":
+ value = "";
+ break;
+ case "stringChunk":
+ case "numberChunk":
+ value += token.value;
+ break;
+ case "endString":
+ assembler.consume({ name: "stringValue", value });
+ value = "";
+ break;
+ case "endNumber":
+ assembler.consume({ name: "numberValue", value });
+ value = "";
+ break;
+ default:
+ assembler.consume(token);
+ }
+ };
+ const selectToken = (token: Token | typeof none) => {
+ const selected = select(token);
+ if (selected === none) return;
+ if (isMany(selected)) {
+ for (const item of selected.values) assemble(item);
+ } else {
+ assemble(selected);
+ }
+ };
+ const consume = (input: string | typeof none) => {
+ if (malformed) return;
+ try {
+ const tokens = tokenize(input);
+ if (tokens === none) return;
+ for (const token of tokens.values) {
+ if (token.name === "startObject" || token.name === "startArray") {
+ if (++depth > 128)
+ throw new TranscriptJsonLimitError("Transcript JSON nesting exceeds 128 levels");
+ } else if (token.name === "endObject" || token.name === "endArray") {
+ if (--depth === 0) complete = true;
+ }
+ // Charge keys before assembling them, including unknown names. Reject
+ // the transcript on exhaustion instead of silently shortening a key.
+ if (token.name === "startKey") {
+ key = "";
+ } else if (token.name === "stringChunk" && key !== null) {
+ reserve(token.value.length * 2);
+ key += token.value;
+ } else if (token.name === "endKey") {
+ selectToken({ name: "keyValue", value: key ?? "" });
+ key = null;
+ } else {
+ selectToken(token);
+ }
+ }
+ } catch (cause) {
+ if (cause instanceof Error && cause.message.startsWith("Parser ")) {
+ malformed = true;
+ } else {
+ throw cause;
+ }
+ }
+ };
+ return {
+ write: (chunk: string) => consume(chunk),
+ finish: (): unknown => {
+ consume(none);
+ if (malformed || !complete) return undefined;
+ selectToken(none);
+ return assembler.done ? assembler.current : undefined;
+ },
+ };
+}
diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts
new file mode 100644
index 000000000..20c7279e1
--- /dev/null
+++ b/apps/server/src/project/AgentSessionScanner.test.ts
@@ -0,0 +1,3221 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as NodeOS from "node:os";
+import { describe, expect, it } from "@effect/vitest";
+import {
+ type OrchestrationProjectShell,
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ServerSettings as ContractServerSettings,
+} from "@t3tools/contracts";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
+
+import * as ServerConfig from "../config.ts";
+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
+import * as ServerSettings from "../serverSettings.ts";
+import * as AgentSessionScanner from "./AgentSessionScanner.ts";
+
+const makeProjectShell = (workspaceRoot: string): OrchestrationProjectShell => ({
+ id: ProjectId.make("project-1"),
+ title: "Imported",
+ workspaceRoot,
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+});
+
+/** Only `getShellSnapshot` is exercised; the rest must not be called. */
+const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray) =>
+ Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
+ getCommandReadModel: () => Effect.die("unused"),
+ getUserInputActivity: () => Effect.die("unused"),
+ getPendingRequestActivities: () => Effect.die("unused"),
+ getTurnStartMessage: () => Effect.die("unused"),
+ getSnapshot: () => Effect.die("unused"),
+ getShellSnapshot: () =>
+ Effect.succeed({
+ snapshotSequence: 0,
+ projects: importedWorkspaceRoots.map((workspaceRoot) => makeProjectShell(workspaceRoot)),
+ threads: [],
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ }),
+ getArchivedShellSnapshot: () => Effect.die("unused"),
+ getSnapshotSequence: () => Effect.die("unused"),
+ getCounts: () => Effect.die("unused"),
+ getEventReplayStats: () => Effect.die("unused"),
+ getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
+ getProjectShellById: () => Effect.die("unused"),
+ getImportedAgentSessionSources: () => Effect.succeed([]),
+ getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
+ getThreadCheckpointContext: () => Effect.die("unused"),
+ getFullThreadDiffContext: () => Effect.die("unused"),
+ getThreadShellById: () => Effect.die("unused"),
+ getThreadRuntimeContext: () => Effect.die("unused"),
+ getThreadDetailById: () => Effect.die("unused"),
+ getThreadDetailSnapshot: () => Effect.die("unused"),
+ searchThreads: () => Effect.die("unused"),
+ });
+
+/**
+ * Run a scan against the given homes. Homes are temp dirs created inside the
+ * test, so the layer is built per run rather than shared.
+ */
+interface ScannerTestInput {
+ readonly claudeHomePath: string;
+ readonly codexHomePath: string;
+ readonly importedWorkspaceRoots?: ReadonlyArray;
+ /** Base dir for the test ServerConfig; worktreesDir derives from it. */
+ readonly configBaseDir?: string;
+ readonly providerInstances?: ContractServerSettings["providerInstances"];
+}
+
+const makeScannerTestLayer = (input: ScannerTestInput) =>
+ AgentSessionScanner.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ ServerSettings.layerTest({
+ providers: {
+ claudeAgent: { homePath: input.claudeHomePath },
+ codex: { homePath: input.codexHomePath },
+ },
+ ...(input.providerInstances === undefined
+ ? {}
+ : { providerInstances: input.providerInstances }),
+ }),
+ ServerConfig.layerTest(
+ input.claudeHomePath,
+ input.configBaseDir ?? { prefix: "t3code-scanner-config-" },
+ ),
+ makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []),
+ ),
+ ),
+ );
+
+const runScan = (input: ScannerTestInput) =>
+ Effect.gen(function* () {
+ const scanner = yield* AgentSessionScanner.AgentSessionScanner;
+ return yield* scanner.scan;
+ }).pipe(Effect.provide(makeScannerTestLayer(input)));
+
+const runRecentThreadOutcomes = (input: ScannerTestInput & { readonly workspaceRoot: string }) =>
+ Effect.gen(function* () {
+ const scanner = yield* AgentSessionScanner.AgentSessionScanner;
+ return yield* scanner.recentThreads(input.workspaceRoot).pipe(
+ Stream.runCollect,
+ Effect.map((outcomes) => Array.from(outcomes)),
+ );
+ }).pipe(Effect.provide(makeScannerTestLayer(input)));
+
+const runRecentThreads = (input: ScannerTestInput & { readonly workspaceRoot: string }) =>
+ runRecentThreadOutcomes(input).pipe(
+ Effect.map((outcomes) =>
+ outcomes.flatMap((outcome) => (outcome._tag === "Importable" ? [outcome.thread] : [])),
+ ),
+ );
+
+const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.makeTempDirectoryScoped({ prefix });
+});
+
+const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: {
+ readonly filePath: string;
+ readonly contents: string;
+ /** Epoch millis, so ordering assertions never depend on write timing. */
+ readonly mtimeMs: number;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true });
+ yield* fileSystem.writeFileString(input.filePath, input.contents);
+ // Numeric utimes arguments are seconds, not milliseconds.
+ const seconds = input.mtimeMs / 1000;
+ yield* fileSystem.utimes(input.filePath, seconds, seconds);
+});
+
+/** Claude session line: the first record carries the real `cwd`. */
+const claudeSessionLine = (cwd: string) =>
+ `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`;
+
+/** Codex rollout line: session metadata is nested under `payload`. */
+const codexRolloutLine = (cwd: string) =>
+ `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`;
+
+const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
+
+function makeRecordLimitTranscript(cwd: string, overflow: boolean): string {
+ const records =
+ [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "record-limit-session", cwd },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "First prompt" },
+ }),
+ ].join("\n") +
+ "\n" +
+ "{}\n".repeat(99_998);
+ return overflow
+ ? records +
+ "\n" +
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Overflow prompt" },
+ }) +
+ "\n"
+ : records;
+}
+
+it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
+ describe("scan", () => {
+ it.effect("reads Claude project cwds from transcripts, newest first", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const olderWorkspace = yield* makeTempDir("t3code-workspace-older-");
+ const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-");
+
+ // Slugs are intentionally lossy; the scanner must not decode them.
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"),
+ contents: claudeSessionLine(olderWorkspace),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"),
+ contents: claudeSessionLine(olderWorkspace),
+ mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"),
+ contents: claudeSessionLine(newerWorkspace),
+ mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(result.candidates).toEqual([
+ {
+ path: newerWorkspace,
+ title: path.basename(newerWorkspace),
+ sources: ["claudeAgent"],
+ threadCount: 1,
+ lastActiveAt: "2026-03-01T00:00:00.000Z",
+ alreadyImported: false,
+ git: null,
+ },
+ {
+ path: olderWorkspace,
+ title: path.basename(olderWorkspace),
+ sources: ["claudeAgent"],
+ threadCount: 2,
+ lastActiveAt: "2026-01-02T00:00:00.000Z",
+ alreadyImported: false,
+ git: null,
+ },
+ ]);
+ }),
+ );
+
+ it.effect("groups Codex rollouts by cwd across date directories", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const otherWorkspace = yield* makeTempDir("t3code-workspace-other-");
+
+ const rollout = (year: string, month: string, day: string, name: string) =>
+ path.join(codexHomePath, "sessions", year, month, day, name);
+
+ yield* writeTranscript({
+ filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"),
+ contents: codexRolloutLine(workspace),
+ mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"),
+ contents: codexRolloutLine(workspace),
+ mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"),
+ contents: codexRolloutLine(otherWorkspace),
+ mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"),
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(result.candidates).toEqual([
+ {
+ path: otherWorkspace,
+ title: path.basename(otherWorkspace),
+ sources: ["codex"],
+ threadCount: 1,
+ lastActiveAt: "2026-02-09T11:00:00.000Z",
+ alreadyImported: false,
+ git: null,
+ },
+ {
+ path: workspace,
+ title: path.basename(workspace),
+ sources: ["codex"],
+ threadCount: 2,
+ lastActiveAt: "2026-02-09T10:00:00.000Z",
+ alreadyImported: false,
+ git: null,
+ },
+ ]);
+ }),
+ );
+
+ it.effect.each(["claudeAgent", "codex"] as const)(
+ "does not open a non-file %s transcript",
+ (source) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const transcriptPath =
+ source === "claudeAgent"
+ ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl")
+ : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl");
+ yield* fileSystem.makeDirectory(transcriptPath, { recursive: true });
+
+ let transcriptOpenCount = 0;
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ open: (filePath, options) => {
+ if (filePath === transcriptPath) transcriptOpenCount += 1;
+ return fileSystem.open(filePath, options);
+ },
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
+ Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
+ );
+
+ expect(result.candidates).toEqual([]);
+ expect(transcriptOpenCount).toBe(0);
+ }),
+ );
+
+ it.effect.each(["claudeAgent", "codex"] as const)(
+ "stops %s directory reads at the discovery operation budget",
+ (source) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const discoveryRoot =
+ source === "claudeAgent"
+ ? path.join(claudeHomePath, "projects")
+ : path.join(codexHomePath, "sessions");
+ const emptyDirectories = Array.from(
+ { length: 20_001 },
+ (_, index) => `empty-${index.toString().padStart(5, "0")}`,
+ );
+ let directoryReadCount = 0;
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ readDirectory: (directory, options) => {
+ if (directory === discoveryRoot) {
+ directoryReadCount += 1;
+ return Effect.succeed(emptyDirectories);
+ }
+ if (path.dirname(directory) === discoveryRoot) {
+ directoryReadCount += 1;
+ return Effect.succeed([]);
+ }
+ return fileSystem.readDirectory(directory, options);
+ },
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
+ Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
+ );
+
+ expect(result.candidates).toEqual([]);
+ expect(directoryReadCount).toBe(20_000);
+ }),
+ );
+
+ it.effect("merges the same cwd seen by both agents and flags imported projects", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
+ contents: claudeSessionLine(workspace),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "04",
+ "01",
+ "rollout-2026-04-01T09-00-00-aaa.jsonl",
+ ),
+ contents: codexRolloutLine(workspace),
+ mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"),
+ });
+
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ importedWorkspaceRoots: [workspace],
+ });
+
+ expect(result.candidates).toEqual([
+ {
+ path: workspace,
+ title: path.basename(workspace),
+ projectId: ProjectId.make("project-1"),
+ sources: ["claudeAgent", "codex"],
+ threadCount: 2,
+ lastActiveAt: "2026-04-01T09:00:00.000Z",
+ alreadyImported: true,
+ git: null,
+ },
+ ]);
+ }),
+ );
+
+ it.effect("returns the imported project ID through a realpath alias", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const linkParent = yield* makeTempDir("t3code-scanner-links-");
+ const workspaceAlias = path.join(linkParent, "workspace-alias");
+ yield* fileSystem.symlink(workspace, workspaceAlias);
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
+ contents: claudeSessionLine(workspaceAlias),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ importedWorkspaceRoots: [workspace],
+ });
+
+ expect(result.candidates[0]).toMatchObject({
+ path: workspace,
+ projectId: ProjectId.make("project-1"),
+ alreadyImported: true,
+ git: null,
+ });
+ }),
+ );
+
+ it.effect("matches a persisted project alias to a transcript realpath", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const linkParent = yield* makeTempDir("t3code-scanner-links-");
+ const workspaceAlias = path.join(linkParent, "workspace-alias");
+ yield* fileSystem.symlink(workspace, workspaceAlias);
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
+ contents: claudeSessionLine(workspace),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ importedWorkspaceRoots: [workspaceAlias],
+ });
+
+ expect(result.candidates[0]).toMatchObject({
+ path: workspaceAlias,
+ projectId: ProjectId.make("project-1"),
+ alreadyImported: true,
+ git: null,
+ });
+ }),
+ );
+
+ it.effect("merges case aliases and preserves the persisted project path", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const workspaceAlias = path.join(
+ path.dirname(workspace),
+ path.basename(workspace).toUpperCase(),
+ );
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
+ contents: claudeSessionLine(workspaceAlias),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: path.join(codexHomePath, "sessions", "2026", "01", "02", "rollout-b.jsonl"),
+ contents: codexRolloutLine(workspace),
+ mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
+ });
+
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath),
+ });
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ importedWorkspaceRoots: [workspace],
+ }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
+
+ expect(result.candidates).toEqual([
+ {
+ path: workspace,
+ title: path.basename(workspace),
+ projectId: ProjectId.make("project-1"),
+ sources: ["claudeAgent", "codex"],
+ threadCount: 2,
+ lastActiveAt: "2026-01-02T00:00:00.000Z",
+ alreadyImported: true,
+ git: null,
+ },
+ ]);
+ }),
+ );
+
+ it.effect("keeps case variants distinct when the filesystem identities differ", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const backingUpper = yield* makeTempDir("t3code-backing-upper-");
+ const backingLower = yield* makeTempDir("t3code-backing-lower-");
+ const aliasParent = yield* makeTempDir("t3code-case-aliases-");
+ const upperWorkspace = path.join(aliasParent, "Repo");
+ const lowerWorkspace = path.join(aliasParent, "repo");
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-upper", "a.jsonl"),
+ contents: claudeSessionLine(upperWorkspace),
+ mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-lower", "b.jsonl"),
+ contents: claudeSessionLine(lowerWorkspace),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ stat: (filePath) =>
+ fileSystem.stat(
+ filePath === upperWorkspace
+ ? backingUpper
+ : filePath === lowerWorkspace
+ ? backingLower
+ : filePath,
+ ),
+ });
+ const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
+ Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
+ );
+
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([
+ upperWorkspace,
+ lowerWorkspace,
+ ]);
+ }),
+ );
+
+ it.effect("uses explicit provider instance homes instead of overridden legacy homes", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-legacy-");
+ const claudeInstanceHome = yield* makeTempDir("t3code-claude-instance-");
+ const codexInstanceHome = yield* makeTempDir("t3code-codex-instance-");
+ const legacyWorkspace = yield* makeTempDir("t3code-workspace-legacy-");
+ const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-");
+ const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-");
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-legacy", "session.jsonl"),
+ contents: claudeSessionLine(legacyWorkspace),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: path.join(claudeInstanceHome, "projects", "-actual", "session.jsonl"),
+ contents: claudeSessionLine(claudeWorkspace),
+ mtimeMs: Date.parse("2026-02-01T00:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: path.join(
+ codexInstanceHome,
+ "sessions",
+ "2026",
+ "03",
+ "01",
+ "rollout-instance.jsonl",
+ ),
+ contents: codexRolloutLine(codexWorkspace),
+ mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ providerInstances: {
+ [ProviderInstanceId.make("claudeAgent")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ config: { homePath: claudeInstanceHome },
+ },
+ [ProviderInstanceId.make("codex")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: codexInstanceHome },
+ },
+ },
+ });
+
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([
+ codexWorkspace,
+ claudeWorkspace,
+ ]);
+ }),
+ );
+
+ it.effect("scans each distinct home across multiple instances once", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const otherCodexHome = yield* makeTempDir("t3code-codex-other-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const otherWorkspace = yield* makeTempDir("t3code-workspace-other-");
+
+ for (const [home, cwd] of [
+ [codexHomePath, workspace],
+ [otherCodexHome, otherWorkspace],
+ ] as const) {
+ yield* writeTranscript({
+ filePath: path.join(home, "sessions", "2026", "01", "01", "rollout-session.jsonl"),
+ contents: codexRolloutLine(cwd),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+ }
+
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ providerInstances: {
+ [ProviderInstanceId.make("codex-personal")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: codexHomePath },
+ },
+ [ProviderInstanceId.make("codex-work")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: otherCodexHome },
+ },
+ },
+ });
+
+ expect(result.candidates).toHaveLength(2);
+ expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([1, 1]);
+ expect(result.candidates.map((candidate) => candidate.path).sort()).toEqual(
+ [workspace, otherWorkspace].sort(),
+ );
+ }),
+ );
+
+ it.effect("honors provider instance home directory environment variables", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-legacy-");
+ const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-");
+ const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-");
+ const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-");
+ const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-");
+
+ yield* writeTranscript({
+ filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"),
+ contents: claudeSessionLine(claudeWorkspace),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: path.join(
+ codexEnvironmentHome,
+ "sessions",
+ "2026",
+ "01",
+ "01",
+ "rollout-session.jsonl",
+ ),
+ contents: codexRolloutLine(codexWorkspace),
+ mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ providerInstances: {
+ [ProviderInstanceId.make("claudeAgent")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ environment: [
+ { name: "CLAUDE_CONFIG_DIR", value: claudeEnvironmentHome, sensitive: false },
+ ],
+ config: {},
+ },
+ [ProviderInstanceId.make("codex")]: {
+ driver: ProviderDriverKind.make("codex"),
+ environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }],
+ config: {},
+ },
+ },
+ });
+
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([
+ codexWorkspace,
+ claudeWorkspace,
+ ]);
+ }),
+ );
+
+ it.effect("ignores invalid provider instances while scanning the remaining providers", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"),
+ contents: claudeSessionLine(workspace),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ providerInstances: {
+ [ProviderInstanceId.make("codex")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: 123 },
+ },
+ },
+ });
+
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
+ }),
+ );
+
+ it.effect("does not scan provider instances disabled by the envelope or config", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const envelopeDisabledHome = yield* makeTempDir("t3code-codex-disabled-envelope-");
+ const configDisabledHome = yield* makeTempDir("t3code-codex-disabled-config-");
+ const envelopeWorkspace = yield* makeTempDir("t3code-workspace-disabled-envelope-");
+ const configWorkspace = yield* makeTempDir("t3code-workspace-disabled-config-");
+
+ for (const [home, workspace, session] of [
+ [envelopeDisabledHome, envelopeWorkspace, "envelope-disabled"],
+ [configDisabledHome, configWorkspace, "config-disabled"],
+ ] as const) {
+ yield* writeTranscript({
+ filePath: path.join(home, "sessions", "2026", "08", "24", `rollout-${session}.jsonl`),
+ contents: codexRolloutLine(workspace),
+ mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+ }
+
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ providerInstances: {
+ [ProviderInstanceId.make("codex-envelope-disabled")]: {
+ driver: ProviderDriverKind.make("codex"),
+ enabled: false,
+ config: { homePath: envelopeDisabledHome },
+ },
+ [ProviderInstanceId.make("codex-config-disabled")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { enabled: false, homePath: configDisabledHome },
+ },
+ },
+ });
+
+ expect(result.candidates).toEqual([]);
+ }),
+ );
+
+ it.effect("ignores relative working directories from malformed transcripts", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"),
+ contents: claudeSessionLine(path.relative(path.resolve(), workspace)),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(result.candidates).toEqual([]);
+ }),
+ );
+
+ it.effect("drops candidates whose directory no longer exists", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
+ contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(result.candidates).toEqual([]);
+ }),
+ );
+
+ it.effect("excludes the home directory, temporary root, and T3 data directory", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+
+ for (const [index, cwd] of [
+ NodeOS.homedir(),
+ NodeOS.tmpdir(),
+ configBaseDir,
+ workspace,
+ ].entries()) {
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "session.jsonl"),
+ contents: claudeSessionLine(cwd),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index,
+ });
+ }
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir });
+
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
+ }),
+ );
+
+ it.effect("excludes Pylon- and T3-managed worktree sandboxes", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const fileSystem = yield* FileSystem.FileSystem;
+
+ const worktreeCwds = [".t3", ".pylon-code", ".pylon-code-nightly"].map((homeName) =>
+ path.join(claudeHomePath, homeName, "worktrees", "pylon", "wt-1"),
+ );
+ for (const [index, worktreeCwd] of worktreeCwds.entries()) {
+ yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"),
+ contents: claudeSessionLine(worktreeCwd),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+ }
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(result.candidates).toEqual([]);
+ }),
+ );
+
+ it.effect("excludes Codex scratch directories and Downloads", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ // The exclusions key off the real home directory, so these fixtures
+ // must live there. Each run owns a uniquely named subtree and removes
+ // only that subtree, never the shared Codex or Downloads parents.
+ const home = NodeOS.homedir();
+ // Borrow a unique suffix from a scoped temp dir instead of reaching for
+ // Date.now or Math.random, which the Effect lint rejects.
+ const runId = path.basename(yield* makeTempDir("t3code-scanner-test-"));
+ const scratchRoot = path.join(home, "Documents", "Codex", runId);
+ const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation");
+ const downloads = path.join(home, "Downloads", runId);
+ const keep = yield* makeTempDir("t3code-workspace-keep-");
+ yield* fileSystem.makeDirectory(scratch, { recursive: true });
+ yield* fileSystem.makeDirectory(downloads, { recursive: true });
+ yield* Effect.addFinalizer(() =>
+ Effect.all([
+ fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore),
+ fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore),
+ ]),
+ );
+
+ for (const [index, cwd] of [scratch, downloads, keep].entries()) {
+ yield* writeTranscript({
+ filePath: path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "09",
+ "01",
+ `rollout-${index}.jsonl`,
+ ),
+ contents: codexRolloutLine(cwd),
+ mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"),
+ });
+ }
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]);
+ }),
+ );
+
+ it.effect("skips linked git worktrees and reports the origin of real checkouts", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const repo = yield* makeTempDir("t3code-workspace-repo-");
+ const worktree = yield* makeTempDir("t3code-workspace-worktree-");
+ const plain = yield* makeTempDir("t3code-workspace-plain-");
+ const noRemote = yield* makeTempDir("t3code-workspace-noremote-");
+ const submodule = yield* makeTempDir("t3code-workspace-submodule-");
+
+ yield* fileSystem.makeDirectory(path.join(repo, ".git"));
+ yield* fileSystem.writeFileString(
+ path.join(repo, ".git", "config"),
+ '[core]\n\tbare = false\n[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n',
+ );
+ yield* fileSystem.writeFileString(
+ path.join(worktree, ".git"),
+ `gitdir: ${path.join(repo, ".git", "worktrees", "wt")}\n`,
+ );
+ yield* fileSystem.makeDirectory(path.join(noRemote, ".git"));
+ yield* fileSystem.writeFileString(path.join(noRemote, ".git", "config"), "[core]\n");
+ // Submodules also use a gitdir pointer, but into `modules/`, not `worktrees/`.
+ const submoduleGitDir = path.join(repo, ".git", "modules", "vendor");
+ yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true });
+ yield* fileSystem.writeFileString(
+ path.join(submoduleGitDir, "config"),
+ '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n',
+ );
+ yield* fileSystem.writeFileString(
+ path.join(submodule, ".git"),
+ `gitdir: ${submoduleGitDir}\n`,
+ );
+
+ for (const [index, cwd] of [repo, worktree, plain, noRemote, submodule].entries()) {
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"),
+ contents: claudeSessionLine(cwd),
+ mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`),
+ });
+ }
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(
+ result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })),
+ ).toEqual([
+ {
+ path: submodule,
+ git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" },
+ },
+ { path: noRemote, git: { remoteKey: null, repository: null } },
+ { path: plain, git: null },
+ {
+ path: repo,
+ git: { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" },
+ },
+ ]);
+ }),
+ );
+
+ it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
+ const fileSystem = yield* FileSystem.FileSystem;
+
+ // worktreesDir derives as `/worktrees`, and the temp base
+ // dir contains no `.t3` segment — only the config-based prefix match
+ // can exclude this one.
+ const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2");
+ yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
+ contents: claudeSessionLine(worktreeCwd),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir });
+
+ expect(result.candidates).toEqual([]);
+ }),
+ );
+
+ it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
+ const linkParent = yield* makeTempDir("t3code-scanner-links-");
+ const fileSystem = yield* FileSystem.FileSystem;
+
+ // The recorded cwd is a symlink whose own spelling looks harmless;
+ // only its realpath reveals the managed sandbox.
+ const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3");
+ yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
+ const symlinkCwd = path.join(linkParent, "innocent-project");
+ yield* fileSystem.symlink(worktreeCwd, symlinkCwd);
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
+ contents: claudeSessionLine(symlinkCwd),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir });
+
+ expect(result.candidates).toEqual([]);
+ }),
+ );
+
+ it.effect("finds the cwd on a later line when the first records carry none", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+
+ // Claude transcripts often open with records that have no cwd.
+ const contents = `{"type":"file-history-snapshot","messageId":"m1"}\n{"type":"queue-operation","operation":"enqueue"}\n${claudeSessionLine(workspace)}`;
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
+ contents,
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
+ }),
+ );
+
+ it.effect("reads a complete transcript record at the exact chunk boundary", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const record = claudeSessionLine(workspace).split("\n")[0]!;
+ const prefix = '{"padding":"';
+ const suffix = `",${record.slice(1)}`;
+ const contents = `${prefix}${"x".repeat(32 * 1024 - prefix.length - suffix.length)}${suffix}`;
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-exact", "session.jsonl"),
+ contents,
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(contents).toHaveLength(32 * 1024);
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
+ }),
+ );
+
+ it.effect("finds session metadata after a first record larger than one chunk", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`;
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"),
+ contents: `${history}${claudeSessionLine(workspace)}`,
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
+ }),
+ );
+
+ it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-metadata-home-");
+ const secondHome = yield* makeTempDir("t3code-metadata-second-");
+ const codexHomePath = yield* makeTempDir("t3code-metadata-codex-");
+ const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-");
+ const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-");
+ const directories = [
+ path.join(claudeHomePath, "projects", "p"),
+ path.join(secondHome, "projects", "p"),
+ ];
+ const templates = directories.map((directory) => path.join(directory, "template.jsonl"));
+ for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) {
+ const record = encodeTranscriptRecord({ cwd: workspace });
+ yield* writeTranscript({
+ filePath: templates[index]!,
+ contents:
+ " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record,
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000,
+ });
+ }
+ const resolveFile = (filePath: string) => {
+ const index = directories.indexOf(path.dirname(filePath));
+ return index === -1 ? filePath : templates[index]!;
+ };
+ let reservedBytes = 0;
+ let opens = 0;
+ const requests: number[] = [];
+ const observedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ readDirectory: (directory, options) => {
+ const index = directories.indexOf(directory);
+ return index === -1
+ ? fileSystem.readDirectory(directory, options)
+ : Effect.succeed(
+ Array.from(
+ { length: index === 0 ? 32 : count - 32 },
+ (_, item) => `session-${item}.jsonl`,
+ ),
+ );
+ },
+ stat: (filePath) => fileSystem.stat(resolveFile(filePath)),
+ open: (filePath, options) => {
+ if (!directories.includes(path.dirname(filePath)))
+ return fileSystem.open(filePath, options);
+ opens += 1;
+ return fileSystem.open(resolveFile(filePath), options).pipe(
+ Effect.map((file) => ({
+ ...file,
+ stat: file.stat,
+ readAlloc: (size: FileSystem.SizeInput) => {
+ reservedBytes += Number(size);
+ requests.push(Number(size));
+ return file.readAlloc(size);
+ },
+ })),
+ );
+ },
+ });
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ providerInstances: {
+ [ProviderInstanceId.make("claude-work")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ config: { homePath: secondHome },
+ },
+ },
+ }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem));
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([
+ firstWorkspace,
+ secondWorkspace,
+ ]);
+ expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]);
+ expect(result.truncated).toBe(count === 65 ? true : undefined);
+ expect(opens).toBe(64);
+ expect(reservedBytes).toBe(64 * 1024 * 1024);
+ expect(requests[0]).toBe(8 * 1024);
+ expect(Math.max(...requests)).toBe(8 * 1024);
+ }),
+ );
+
+ it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-");
+ const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-");
+ const workspace = yield* makeTempDir("t3code-short-metadata-project-");
+ const directory = path.join(claudeHomePath, "projects", "p");
+ const template = path.join(directory, "template.jsonl");
+ const record = encodeTranscriptRecord({ cwd: workspace });
+ const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record;
+ const bytes = new TextEncoder().encode(contents);
+ yield* writeTranscript({
+ filePath: template,
+ contents,
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+ let operations = 0;
+ const observedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ readDirectory: (target, options) =>
+ target === directory
+ ? Effect.succeed(
+ Array.from({ length: count }, (_, index) => `session-${index}.jsonl`),
+ )
+ : fileSystem.readDirectory(target, options),
+ stat: (filePath) =>
+ fileSystem.stat(path.dirname(filePath) === directory ? template : filePath),
+ open: (filePath, options) => {
+ if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options);
+ operations += 1;
+ let offset = 0;
+ return fileSystem.open(template, options).pipe(
+ Effect.map((file) => ({
+ ...file,
+ stat: file.stat,
+ readAlloc: () =>
+ Effect.sync(() => {
+ operations += 1;
+ if (offset === bytes.length) return Option.none();
+ return Option.some(bytes.subarray(offset, ++offset));
+ }),
+ })),
+ );
+ },
+ });
+ const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
+ Effect.provideService(FileSystem.FileSystem, observedFileSystem),
+ );
+ expect(operations).toBe(20_000);
+ expect(result.candidates[0]?.threadCount).toBe(50);
+ expect(result.truncated).toBe(count === 51 ? true : undefined);
+ }),
+ );
+
+ it.effect("bounds malformed metadata records without excluding another account", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-");
+ const secondHome = yield* makeTempDir("t3code-record-metadata-second-");
+ const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-");
+ const workspace = yield* makeTempDir("t3code-record-metadata-project-");
+ const directory = path.join(claudeHomePath, "projects", "p");
+ const template = path.join(directory, "template.jsonl");
+ yield* writeTranscript({
+ filePath: template,
+ contents: "x\n".repeat(1_001),
+ mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: path.join(secondHome, "projects", "p", "session.jsonl"),
+ contents: encodeTranscriptRecord({ cwd: workspace }),
+ mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
+ });
+ let malformedOpens = 0;
+ const observedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ readDirectory: (target, options) =>
+ target === directory
+ ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`))
+ : fileSystem.readDirectory(target, options),
+ stat: (filePath) =>
+ fileSystem.stat(path.dirname(filePath) === directory ? template : filePath),
+ open: (filePath, options) => {
+ if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options);
+ malformedOpens += 1;
+ return fileSystem.open(template, options);
+ },
+ });
+ const result = yield* runScan({
+ claudeHomePath,
+ codexHomePath,
+ providerInstances: {
+ [ProviderInstanceId.make("claude-work")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ config: { homePath: secondHome },
+ },
+ },
+ }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem));
+ expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
+ expect(malformedOpens).toBe(100);
+ expect(result.truncated).toBe(true);
+ }),
+ );
+
+ it.effect.each([19_999, 20_000])(
+ "reports unfinished directory work for %s project directories",
+ (count) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-");
+ const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-");
+ const projectsDir = path.join(claudeHomePath, "projects");
+ let reads = 0;
+ const observedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ readDirectory: (directory, options) => {
+ if (directory === projectsDir) {
+ reads += 1;
+ return Effect.succeed(
+ Array.from({ length: count }, (_, index) => `project-${index}`),
+ );
+ }
+ if (path.dirname(directory) === projectsDir) {
+ reads += 1;
+ return Effect.succeed([]);
+ }
+ return fileSystem.readDirectory(directory, options);
+ },
+ });
+ const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
+ Effect.provideService(FileSystem.FileSystem, observedFileSystem),
+ );
+ expect(reads).toBe(20_000);
+ expect(result.candidates).toEqual([]);
+ expect(result.truncated).toBe(count === 20_000 ? true : undefined);
+ }),
+ );
+
+ it.effect("skips malformed transcripts without failing the scan", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"),
+ contents: "not json at all\n",
+ mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"),
+ });
+ // Valid JSON, but no cwd anywhere in the record.
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"),
+ contents: `{"type":"summary"}\n`,
+ mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"),
+ });
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"),
+ contents: claudeSessionLine(workspace),
+ mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"),
+ });
+
+ const result = yield* runScan({ claudeHomePath, codexHomePath });
+
+ expect(result.candidates).toEqual([
+ {
+ path: workspace,
+ title: path.basename(workspace),
+ sources: ["claudeAgent"],
+ threadCount: 1,
+ lastActiveAt: "2026-05-03T00:00:00.000Z",
+ alreadyImported: false,
+ git: null,
+ },
+ ]);
+ }),
+ );
+
+ it.effect("returns an empty result when neither home directory exists", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const root = yield* makeTempDir("t3code-missing-homes-");
+
+ const result = yield* runScan({
+ claudeHomePath: path.join(root, "no-claude"),
+ codexHomePath: path.join(root, "no-codex"),
+ });
+
+ expect(result.candidates).toEqual([]);
+ expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ }),
+ );
+ });
+
+ describe("recentThreads", () => {
+ it.effect.each([false, true])(
+ "counts terminal newlines correctly with record overflow=%s",
+ (overflow) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-");
+ const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-");
+ const workspace = yield* makeTempDir("t3code-record-limit-project-");
+ const directory = path.join(codexHomePath, "sessions", "2026", "08", "24");
+ yield* writeTranscript({
+ filePath: path.join(directory, "rollout-records.jsonl"),
+ contents: makeRecordLimitTranscript(workspace, overflow),
+ mtimeMs: nowMs,
+ });
+ yield* writeTranscript({
+ filePath: path.join(directory, "rollout-older.jsonl"),
+ contents: [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "older-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Older prompt" },
+ }),
+ ].join("\n"),
+ mtimeMs: nowMs - 1_000,
+ });
+ const outcomes = yield* runRecentThreadOutcomes({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ });
+ expect(outcomes.map((outcome) => outcome._tag)).toEqual(
+ overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"],
+ );
+ expect(
+ outcomes.flatMap((outcome) =>
+ outcome._tag === "Importable"
+ ? outcome.thread.messages.map((message) => message.text)
+ : [],
+ ),
+ ).toEqual([overflow ? "Older prompt" : "First prompt"]);
+ }),
+ );
+
+ it.effect("imports recent Claude and Codex sessions for the selected project only", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const otherWorkspace = yield* makeTempDir("t3code-workspace-other-");
+
+ const claudeTranscript = (cwd: string, sessionId: string) =>
+ `${JSON.stringify({
+ type: "user",
+ cwd,
+ sessionId,
+ timestamp: "2026-08-23T12:00:00.000Z",
+ message: { role: "user", content: "Fix the project" },
+ })}\n${JSON.stringify({
+ type: "assistant",
+ sessionId,
+ timestamp: "2026-08-23T12:01:00.000Z",
+ message: { role: "assistant", content: [{ type: "text", text: "Done" }] },
+ })}\n`;
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"),
+ contents: claudeTranscript(workspace, "claude-recent"),
+ mtimeMs: nowMs - 24 * 60 * 60 * 1000,
+ });
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"),
+ contents: claudeTranscript(workspace, "claude-old"),
+ mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000,
+ });
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"),
+ contents: claudeTranscript(otherWorkspace, "claude-other"),
+ mtimeMs: nowMs - 24 * 60 * 60 * 1000,
+ });
+ yield* writeTranscript({
+ filePath: path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ "rollout-codex-recent.jsonl",
+ ),
+ contents: [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "codex-recent", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ timestamp: "2026-08-24T10:00:00.000Z",
+ payload: { type: "user_message", message: "Review this code" },
+ }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ timestamp: "2026-08-24T10:01:00.000Z",
+ payload: {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: "Looks good" }],
+ },
+ }),
+ ].join("\n"),
+ mtimeMs: nowMs - 60 * 60 * 1000,
+ });
+
+ const threads = yield* runRecentThreads({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ });
+
+ expect(threads.map((thread) => thread.providerSessionId)).toEqual([
+ "codex-recent",
+ "claude-recent",
+ ]);
+ expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([
+ ["Review this code", "Looks good"],
+ ["Fix the project", "Done"],
+ ]);
+ }),
+ );
+
+ it.effect("imports history recorded with a case alias", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const workspaceAlias = path.join(
+ path.dirname(workspace),
+ path.basename(workspace).toUpperCase(),
+ );
+
+ yield* writeTranscript({
+ filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"),
+ contents: [
+ encodeTranscriptRecord({
+ type: "user",
+ cwd: workspaceAlias,
+ sessionId: "case-session",
+ timestamp: "2026-08-24T10:00:00.000Z",
+ message: { role: "user", content: "Import case alias history" },
+ }),
+ encodeTranscriptRecord({
+ type: "assistant",
+ sessionId: "case-session",
+ timestamp: "2026-08-24T10:01:00.000Z",
+ message: { role: "assistant", content: "Imported" },
+ }),
+ ].join("\n"),
+ mtimeMs: nowMs,
+ });
+
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath),
+ });
+ const threads = yield* runRecentThreads({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
+
+ expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]);
+ }),
+ );
+
+ it.effect("keeps the provider instance that owns a custom session home", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const customHome = yield* makeTempDir("t3code-codex-custom-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+
+ yield* writeTranscript({
+ filePath: path.join(customHome, "sessions", "2026", "08", "24", "rollout-custom.jsonl"),
+ contents: [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "custom-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Use my work account" },
+ }),
+ ].join("\n"),
+ mtimeMs: nowMs,
+ });
+
+ const threads = yield* runRecentThreads({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ providerInstances: {
+ [ProviderInstanceId.make("codex-work")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: customHome },
+ },
+ },
+ });
+
+ expect(threads[0]?.providerInstanceId).toBe("codex-work");
+ }),
+ );
+
+ it.effect("suppresses duplicate session copies without reporting a skipped import", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const contents = [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "copied-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Import this session once" },
+ }),
+ ].join("\n");
+
+ for (const [name, mtimeMs] of [
+ ["rollout-copy-a.jsonl", nowMs],
+ ["rollout-copy-b.jsonl", nowMs - 1],
+ ] as const) {
+ yield* writeTranscript({
+ filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", name),
+ contents,
+ mtimeMs,
+ });
+ }
+
+ const outcomes = yield* runRecentThreadOutcomes({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ });
+
+ expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable", "Duplicate"]);
+ expect(outcomes[0]).toMatchObject({
+ _tag: "Importable",
+ thread: { providerSessionId: "copied-session" },
+ });
+ }),
+ );
+
+ it.effect("streams large transcripts across providers without hiding projects", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-budget-claude-");
+ const codexHomePath = yield* makeTempDir("t3code-budget-codex-");
+ const workspace = yield* makeTempDir("t3code-budget-workspace-");
+ const transcriptPaths = new Set();
+ for (const [index, source] of [
+ "codex",
+ "claudeAgent",
+ "codex",
+ "claudeAgent",
+ "codex",
+ ].entries()) {
+ const sessionId = `budget-session-${index}`;
+ const filePath =
+ source === "codex"
+ ? path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ `rollout-${sessionId}.jsonl`,
+ )
+ : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`);
+ const contents =
+ source === "codex"
+ ? [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: sessionId, cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Imported prompt" },
+ }),
+ ].join("\n")
+ : encodeTranscriptRecord({
+ type: "user",
+ cwd: workspace,
+ sessionId,
+ message: { content: "Imported prompt" },
+ });
+ transcriptPaths.add(filePath);
+ yield* writeTranscript({
+ filePath,
+ contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "),
+ mtimeMs: nowMs - index * 1_000,
+ });
+ }
+
+ const opens = new Map();
+ let fullReadBytes = 0;
+ const trackedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ open: (filePath, options) => {
+ const count = (opens.get(filePath) ?? 0) + 1;
+ opens.set(filePath, count);
+ return fileSystem.open(filePath, options).pipe(
+ Effect.map((file) =>
+ !transcriptPaths.has(filePath) || count === 1
+ ? file
+ : {
+ ...file,
+ stat: file.stat,
+ readAlloc: (size: FileSystem.SizeInput) =>
+ file.readAlloc(size).pipe(
+ Effect.tap((chunk) =>
+ Effect.sync(() => {
+ if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength;
+ }),
+ ),
+ ),
+ },
+ ),
+ );
+ },
+ });
+ const outcomes = yield* Effect.gen(function* () {
+ const scanner = yield* AgentSessionScanner.AgentSessionScanner;
+ const scan = yield* scanner.scan;
+ expect(scan.candidates[0]?.threadCount).toBe(5);
+ return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect);
+ }).pipe(
+ Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })),
+ Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
+ );
+
+ expect(outcomes.map((outcome) => outcome._tag)).toEqual([
+ "Importable",
+ "Importable",
+ "Importable",
+ "Importable",
+ "Importable",
+ ]);
+ expect(fullReadBytes).toBe(80 * 1024 * 1024);
+ }),
+ );
+
+ it.effect("skips excessive records without blocking an older valid transcript", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-");
+ const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-");
+ const workspace = yield* makeTempDir("t3code-record-budget-workspace-");
+ for (const [sessionId, padding, mtimeMs] of [
+ ["excessive", "\n".repeat(100_001), nowMs],
+ ["older", "", nowMs - 1_000],
+ ] as const) {
+ yield* writeTranscript({
+ filePath: path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ `rollout-${sessionId}.jsonl`,
+ ),
+ contents:
+ [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: sessionId, cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Imported prompt" },
+ }),
+ ].join("\n") + padding,
+ mtimeMs,
+ });
+ }
+ const outcomes = yield* runRecentThreadOutcomes({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ });
+ expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]);
+ expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } });
+ }),
+ );
+
+ for (const source of ["claudeAgent", "codex"] as const) {
+ for (const replacement of [
+ "same root",
+ "other root",
+ "symlink alias",
+ "other then same",
+ ] as const) {
+ it.effect.skipIf(replacement === "symlink alias" && !symlinksSupported)(
+ `rechecks ${source} snapshot cwd after replacement with ${replacement}`,
+ () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const fixture = yield* makeTempDir("t3code-replaced-cwd-");
+ const workspace = path.join(fixture, "original");
+ const otherWorkspace = path.join(fixture, "other");
+ const alias = path.join(fixture, "alias");
+ const claudeHomePath = path.join(fixture, "claude");
+ const codexHomePath = path.join(fixture, "codex");
+ yield* fileSystem.makeDirectory(workspace);
+ yield* fileSystem.makeDirectory(otherWorkspace);
+ if (replacement === "symlink alias") yield* fileSystem.symlink(workspace, alias);
+ const filePath =
+ source === "codex"
+ ? path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ "rollout-replaced.jsonl",
+ )
+ : path.join(claudeHomePath, "projects", "p", "replaced.jsonl");
+ const makeContents = (cwd: string, text: string, laterCwd?: string) =>
+ [
+ ...(source === "codex"
+ ? [
+ { type: "session_meta", payload: { id: "replacement-session", cwd } },
+ { type: "event_msg", payload: { type: "user_message", message: text } },
+ ]
+ : [
+ {
+ type: "user",
+ cwd,
+ sessionId: "replacement-session",
+ message: { content: text },
+ },
+ ]),
+ ...(laterCwd === undefined ? [] : [{ cwd: laterCwd }]),
+ ]
+ .map((record) => encodeTranscriptRecord(record))
+ .join("\n");
+ yield* writeTranscript({
+ filePath,
+ contents: makeContents(workspace, "Original prompt"),
+ mtimeMs: nowMs,
+ });
+
+ yield* Effect.gen(function* () {
+ const scanner = yield* AgentSessionScanner.AgentSessionScanner;
+ const scan = yield* scanner.scan;
+ expect(scan.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
+ const replacementCwd =
+ replacement === "symlink alias"
+ ? alias
+ : replacement === "same root"
+ ? workspace
+ : otherWorkspace;
+ yield* fileSystem.remove(filePath);
+ yield* writeTranscript({
+ filePath,
+ contents: makeContents(
+ replacementCwd,
+ "Replacement prompt",
+ replacement === "other then same" ? workspace : undefined,
+ ),
+ mtimeMs: nowMs,
+ });
+ const outcomes = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect);
+ if (replacement === "same root" || replacement === "symlink alias") {
+ expect(outcomes).toHaveLength(1);
+ expect(outcomes[0]).toMatchObject({
+ _tag: "Importable",
+ thread: { messages: [{ text: "Replacement prompt" }] },
+ });
+ } else {
+ expect(outcomes).toEqual([{ _tag: "Skipped" }]);
+ }
+ }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })));
+ }),
+ );
+ }
+ }
+
+ it.effect("checks file identity and provider before skipping completed history", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-completed-claude-");
+ const codexHomePath = yield* makeTempDir("t3code-completed-codex-");
+ const workspace = yield* makeTempDir("t3code-completed-workspace-");
+ const filePath = path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ "rollout-replaced.jsonl",
+ );
+ const contents = (sessionId: string) =>
+ [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: sessionId, cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Imported prompt" },
+ }),
+ ].join("\n");
+ yield* writeTranscript({
+ filePath,
+ contents: contents("original-session"),
+ mtimeMs: nowMs,
+ });
+
+ yield* Effect.gen(function* () {
+ const scanner = yield* AgentSessionScanner.AgentSessionScanner;
+ const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect);
+ const imported = initial[0];
+ expect(imported?._tag).toBe("Importable");
+ if (imported?._tag !== "Importable") return;
+ const completed = yield* scanner
+ .recentThreads(workspace, [imported.source])
+ .pipe(Stream.runCollect);
+ expect(completed[0]?._tag).toBe("AlreadyImported");
+ const wrongProvider = yield* scanner
+ .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }])
+ .pipe(Stream.runCollect);
+ expect(wrongProvider[0]?._tag).toBe("Importable");
+
+ // Keep the old inode allocated while replacing the path with an equal-size file.
+ yield* fileSystem.open(filePath);
+ yield* fileSystem.remove(filePath);
+ yield* writeTranscript({
+ filePath,
+ contents: contents("replaced-session"),
+ mtimeMs: nowMs,
+ });
+ const replaced = yield* scanner
+ .recentThreads(workspace, [imported.source])
+ .pipe(Stream.runCollect);
+ expect(replaced[0]).toMatchObject({
+ _tag: "Importable",
+ thread: { providerSessionId: "replaced-session" },
+ source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs },
+ });
+ }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })));
+ }),
+ );
+
+ it.effect("imports visible history from a transcript with an oversized tool record", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const transcript = `${[
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "large-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Import this large session" },
+ }),
+ ].join("\n")}\n${encodeTranscriptRecord({ type: "tool_result", data: "" }).padEnd(
+ 16 * 1024 * 1024 + 1,
+ " ",
+ )}`;
+ yield* writeTranscript({
+ filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"),
+ contents: transcript,
+ mtimeMs: nowMs,
+ });
+
+ const outcomes = yield* runRecentThreadOutcomes({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ });
+
+ expect(outcomes).toMatchObject([
+ {
+ _tag: "Importable",
+ thread: {
+ providerSessionId: "large-session",
+ messages: [{ role: "user", text: "Import this large session" }],
+ },
+ },
+ ]);
+ }),
+ );
+
+ it.effect("reports stat, read, and parse failures as skipped", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const missingPath = path.join(codexHomePath, "missing.jsonl");
+ const transcriptPaths = {
+ stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"),
+ read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"),
+ parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"),
+ };
+ const transcriptContents = (sessionId: string) =>
+ [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: sessionId, cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Import this session" },
+ }),
+ ].join("\n");
+
+ yield* writeTranscript({
+ filePath: transcriptPaths.stat,
+ contents: transcriptContents("stat-session"),
+ mtimeMs: nowMs,
+ });
+ yield* writeTranscript({
+ filePath: transcriptPaths.read,
+ contents: transcriptContents("read-session"),
+ mtimeMs: nowMs,
+ });
+ yield* writeTranscript({
+ filePath: transcriptPaths.parse,
+ contents: encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "parse-session", cwd: workspace },
+ }),
+ mtimeMs: nowMs,
+ });
+
+ let statCount = 0;
+ let readOpenCount = 0;
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ stat: (filePath) => {
+ if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath);
+ statCount += 1;
+ return fileSystem.stat(statCount === 1 ? filePath : missingPath);
+ },
+ open: (filePath, options) => {
+ if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options);
+ readOpenCount += 1;
+ return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options);
+ },
+ });
+
+ const outcomes = yield* runRecentThreadOutcomes({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
+
+ expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]);
+ }),
+ );
+
+ it.effect("does not reopen a transcript that becomes a non-file after discovery", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const nonFilePath = yield* makeTempDir("t3code-non-file-");
+ const transcriptPath = path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ "rollout-changed.jsonl",
+ );
+ yield* writeTranscript({
+ filePath: transcriptPath,
+ contents: [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "changed-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Do not import this session" },
+ }),
+ ].join("\n"),
+ mtimeMs: nowMs,
+ });
+
+ let transcriptStatCount = 0;
+ let transcriptOpenCount = 0;
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ stat: (filePath) => {
+ if (filePath !== transcriptPath) return fileSystem.stat(filePath);
+ transcriptStatCount += 1;
+ return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath);
+ },
+ open: (filePath, options) => {
+ if (filePath === transcriptPath) transcriptOpenCount += 1;
+ return fileSystem.open(filePath, options);
+ },
+ });
+
+ const outcomes = yield* runRecentThreadOutcomes({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
+
+ expect(transcriptStatCount).toBe(2);
+ expect(transcriptOpenCount).toBe(1);
+ expect(outcomes).toEqual([{ _tag: "Skipped" }]);
+ }),
+ );
+
+ it.effect("does not import a transcript dated after the current time", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ yield* writeTranscript({
+ filePath: path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ "rollout-future.jsonl",
+ ),
+ contents: [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "future-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Future work" },
+ }),
+ ].join("\n"),
+ mtimeMs: nowMs + 1,
+ });
+
+ const outcomes = yield* runRecentThreadOutcomes({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ });
+
+ expect(outcomes).toEqual([]);
+ }),
+ );
+
+ it.effect("skips growth during reading without exceeding the reserved bytes", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const transcriptPath = path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ "rollout-growing.jsonl",
+ );
+ const contents = [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "growing-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Do not import a changing file" },
+ }),
+ ].join("\n");
+ yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs });
+ let transcriptOpenCount = 0;
+ let fullReadBytes = 0;
+ let grew = false;
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ open: (filePath, options) => {
+ if (filePath !== transcriptPath) return fileSystem.open(filePath, options);
+ transcriptOpenCount += 1;
+ if (transcriptOpenCount === 1) return fileSystem.open(filePath, options);
+ return fileSystem.open(filePath, options).pipe(
+ Effect.map((file) => ({
+ ...file,
+ stat: file.stat,
+ readAlloc: (size: FileSystem.SizeInput) =>
+ file.readAlloc(size).pipe(
+ Effect.tap((chunk) =>
+ Effect.gen(function* () {
+ if (chunk._tag === "None") return;
+ fullReadBytes += chunk.value.byteLength;
+ if (!grew) {
+ grew = true;
+ yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`);
+ }
+ }),
+ ),
+ ),
+ })),
+ );
+ },
+ });
+
+ const outcomes = yield* runRecentThreadOutcomes({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
+
+ expect(transcriptOpenCount).toBe(2);
+ expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength);
+ expect(outcomes).toEqual([{ _tag: "Skipped" }]);
+ }),
+ );
+
+ it.effect("skips a transcript that shrinks after its size check", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const transcriptPath = path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ "rollout-shrinking.jsonl",
+ );
+ const shrunkPath = path.join(codexHomePath, "shrunk.jsonl");
+ const contents = [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "shrinking-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Do not import a changing file" },
+ }),
+ ].join("\n");
+ yield* writeTranscript({
+ filePath: transcriptPath,
+ contents: `${contents}\n${"padding".repeat(100)}`,
+ mtimeMs: nowMs,
+ });
+ yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs });
+
+ let transcriptOpenCount = 0;
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ open: (filePath, options) => {
+ if (filePath !== transcriptPath) return fileSystem.open(filePath, options);
+ transcriptOpenCount += 1;
+ return fileSystem.open(
+ transcriptOpenCount === 1 ? transcriptPath : shrunkPath,
+ options,
+ );
+ },
+ });
+
+ const outcomes = yield* runRecentThreadOutcomes({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
+
+ expect(transcriptOpenCount).toBe(2);
+ expect(outcomes).toEqual([{ _tag: "Skipped" }]);
+ }),
+ );
+
+ it.effect("does not read the second transcript when the consumer takes one thread", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+ const makeCodexTranscript = (sessionId: string, text: string) =>
+ [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: sessionId, cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: text },
+ }),
+ ].join("\n");
+ const olderPath = path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "23",
+ "rollout-older.jsonl",
+ );
+ const newerPath = path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ "rollout-newer.jsonl",
+ );
+ yield* writeTranscript({
+ filePath: olderPath,
+ contents: makeCodexTranscript("older-session", "Older prompt"),
+ mtimeMs: nowMs - 1_000,
+ });
+ yield* writeTranscript({
+ filePath: newerPath,
+ contents: makeCodexTranscript("newer-session", "Newer prompt"),
+ mtimeMs: nowMs,
+ });
+
+ const openCounts = new Map();
+ const contentReads: Array = [];
+ const trackedPaths = new Set([olderPath, newerPath]);
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ open: (filePath, options) => {
+ if (trackedPaths.has(filePath)) {
+ const count = (openCounts.get(filePath) ?? 0) + 1;
+ openCounts.set(filePath, count);
+ if (count === 2) contentReads.push(filePath);
+ }
+ return fileSystem.open(filePath, options);
+ },
+ });
+
+ const threads = yield* Effect.gen(function* () {
+ const scanner = yield* AgentSessionScanner.AgentSessionScanner;
+ return yield* scanner.recentThreads(workspace).pipe(
+ Stream.take(1),
+ Stream.runCollect,
+ Effect.map((items) => Array.from(items)),
+ );
+ }).pipe(
+ Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })),
+ Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
+ );
+
+ expect(
+ threads.flatMap((outcome) =>
+ outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [],
+ ),
+ ).toEqual(["newer-session"]);
+ expect(contentReads).toEqual([newerPath]);
+ expect(openCounts.get(olderPath)).toBe(1);
+ }),
+ );
+
+ it.effect("does not import sessions from a T3-managed worktree", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
+ const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree");
+ yield* fileSystem.makeDirectory(workspace, { recursive: true });
+
+ yield* writeTranscript({
+ filePath: path.join(
+ codexHomePath,
+ "sessions",
+ "2026",
+ "08",
+ "24",
+ "rollout-managed.jsonl",
+ ),
+ contents: [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "managed-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Do not import this session" },
+ }),
+ ].join("\n"),
+ mtimeMs: nowMs,
+ });
+
+ const threads = yield* runRecentThreads({
+ claudeHomePath,
+ codexHomePath,
+ configBaseDir,
+ workspaceRoot: workspace,
+ });
+
+ expect(threads).toEqual([]);
+ }),
+ );
+
+ it.effect("uses one deterministic provider instance for a shared session home", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const sharedHome = yield* makeTempDir("t3code-codex-shared-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+
+ yield* writeTranscript({
+ filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"),
+ contents: [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "shared-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Use the shared session" },
+ }),
+ ].join("\n"),
+ mtimeMs: nowMs,
+ });
+
+ const threads = yield* runRecentThreads({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ providerInstances: {
+ [ProviderInstanceId.make("codex")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: sharedHome },
+ },
+ [ProviderInstanceId.make("codex-personal")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: sharedHome },
+ },
+ [ProviderInstanceId.make("codex-work")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: sharedHome },
+ },
+ },
+ });
+
+ expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]);
+ }),
+ );
+
+ it.effect("uses configured order when custom instances share a session home", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const sharedHome = yield* makeTempDir("t3code-codex-shared-");
+ const workspace = yield* makeTempDir("t3code-workspace-");
+
+ yield* writeTranscript({
+ filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"),
+ contents: [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "shared-session", cwd: workspace },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Use the first account" },
+ }),
+ ].join("\n"),
+ mtimeMs: nowMs,
+ });
+
+ const threads = yield* runRecentThreads({
+ claudeHomePath,
+ codexHomePath,
+ workspaceRoot: workspace,
+ providerInstances: {
+ [ProviderInstanceId.make("codex-work")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: sharedHome },
+ },
+ [ProviderInstanceId.make("codex-personal")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: sharedHome },
+ },
+ },
+ });
+
+ expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]);
+ }),
+ );
+
+ it.effect("keeps a second account when the first has 5000 newer files", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
+ yield* TestClock.setTime(nowMs);
+ const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
+ const codexHomePath = yield* makeTempDir("t3code-codex-home-");
+ const oldWorkspace = yield* makeTempDir("t3code-workspace-old-");
+ const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-");
+ const recentHome = yield* makeTempDir("t3code-claude-recent-home-");
+ const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old");
+ const oldTranscript = path.join(oldDirectory, "old.jsonl");
+ const recentDirectory = path.join(recentHome, "projects", "-zzz-recent");
+
+ yield* writeTranscript({
+ filePath: oldTranscript,
+ contents: encodeTranscriptRecord({
+ type: "user",
+ cwd: oldWorkspace,
+ sessionId: "old-session",
+ message: { role: "user", content: "Old work" },
+ }),
+ mtimeMs: nowMs,
+ });
+ yield* writeTranscript({
+ filePath: path.join(recentDirectory, "recent.jsonl"),
+ contents: encodeTranscriptRecord({
+ type: "user",
+ cwd: recentWorkspace,
+ sessionId: "recent-session",
+ message: { role: "user", content: "Recent work" },
+ }),
+ mtimeMs: nowMs - 1_000,
+ });
+
+ const simulatedOldTranscripts = Array.from(
+ { length: 5_000 },
+ (_, index) => `old-${index}.jsonl`,
+ );
+ const resolveTranscript = (filePath: string) =>
+ path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-")
+ ? oldTranscript
+ : filePath;
+ const simulatedFileSystem = FileSystem.FileSystem.of({
+ ...fileSystem,
+ readDirectory: (directory, options) =>
+ directory === oldDirectory
+ ? Effect.succeed(simulatedOldTranscripts)
+ : fileSystem.readDirectory(directory, options),
+ stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)),
+ open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options),
+ });
+
+ const input = {
+ claudeHomePath,
+ codexHomePath,
+ providerInstances: {
+ [ProviderInstanceId.make("claude-work")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ config: { homePath: recentHome },
+ },
+ },
+ };
+ const threads = yield* Effect.gen(function* () {
+ const scanner = yield* AgentSessionScanner.AgentSessionScanner;
+ const scan = yield* scanner.scan;
+ expect(scan.truncated).toBe(true);
+ return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect);
+ }).pipe(
+ Effect.provide(makeScannerTestLayer(input)),
+ Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
+ );
+
+ expect(
+ threads.flatMap((outcome) =>
+ outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [],
+ ),
+ ).toEqual(["recent-session"]);
+ }),
+ );
+ });
+});
+
+describe("parseAgentSessionTranscript", () => {
+ it.each([false, true])(
+ "handles the exact record limit and an interior blank overflow=%s",
+ (overflow) => {
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: makeRecordLimitTranscript("/project", overflow),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "unused",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+ if (overflow) expect(thread).toBeNull();
+ else expect(thread?.messages.map((message) => message.text)).toEqual(["First prompt"]);
+ },
+ );
+
+ it("keeps Claude text and titles while dropping malformed and tool records", () => {
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ "not valid json",
+ JSON.stringify({ type: "ai-title", aiTitle: "Fix authentication" }),
+ JSON.stringify({
+ type: "user",
+ sessionId: "claude-session",
+ isMeta: true,
+ message: { role: "user", content: "Injected skill instructions" },
+ }),
+ JSON.stringify({
+ type: "user",
+ sessionId: "claude-session",
+ isCompactSummary: true,
+ message: { role: "user", content: "Injected compaction summary" },
+ }),
+ JSON.stringify({
+ type: "user",
+ sessionId: "claude-session",
+ timestamp: "2026-08-24T10:00:00.000Z",
+ message: { role: "user", content: [{ type: "text", text: "Fix authentication" }] },
+ }),
+ JSON.stringify({
+ type: "user",
+ sessionId: "claude-session",
+ message: { role: "user", content: [{ type: "tool_result", text: "hidden" }] },
+ }),
+ JSON.stringify({
+ type: "assistant",
+ sessionId: "claude-session",
+ message: {
+ role: "assistant",
+ model: "claude-sonnet-5",
+ content: [{ type: "text", text: "Updated the login flow" }],
+ },
+ }),
+ JSON.stringify({
+ type: "assistant",
+ sessionId: "claude-session",
+ message: {
+ role: "assistant",
+ model: "",
+ content: [{ type: "text", text: "The provider request failed" }],
+ },
+ }),
+ ].join("\n"),
+ source: "claudeAgent",
+ providerInstanceId: ProviderInstanceId.make("claudeAgent"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+
+ expect(thread).toMatchObject({
+ providerSessionId: "claude-session",
+ title: "Fix authentication",
+ model: "claude-sonnet-5",
+ messages: [
+ { role: "user", text: "Fix authentication" },
+ { role: "assistant", text: "Updated the login flow" },
+ { role: "assistant", text: "The provider request failed" },
+ ],
+ });
+ });
+
+ it("drops injected Codex instructions while keeping the visible user event", () => {
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }),
+ JSON.stringify({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
+ content: [
+ {
+ type: "input_text",
+ text: "\nInternal setup instructions\n",
+ },
+ ],
+ },
+ }),
+ JSON.stringify({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Fix the actual bug" },
+ }),
+ JSON.stringify({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
+ content: [{ type: "input_text", text: "Fix the actual bug" }],
+ },
+ }),
+ JSON.stringify({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: "Fixed" }],
+ },
+ }),
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+
+ expect(thread?.messages.map((message) => message.text)).toEqual([
+ "Fix the actual bug",
+ "Fixed",
+ ]);
+ });
+
+ it("keeps the canonical first prompt after long Codex transcripts are capped", () => {
+ const canonicalPrompt = "\n Keep the canonical prompt \n";
+ const canonicalTimestamp = "2026-08-24T10:01:00.000Z";
+ const laterAssistantMessages = Array.from({ length: 200 }, (_, index) =>
+ encodeTranscriptRecord({
+ type: "response_item",
+ timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`,
+ payload: {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: `Assistant message ${index}` }],
+ },
+ }),
+ );
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ timestamp: "2026-08-24T10:00:00.000Z",
+ payload: {
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: "Keep the canonical prompt" }],
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ timestamp: canonicalTimestamp,
+ payload: { type: "user_message", message: canonicalPrompt },
+ }),
+ ...laterAssistantMessages,
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+
+ expect(thread?.messages).toHaveLength(200);
+ expect(thread?.messages[0]).toMatchObject({
+ role: "user",
+ text: canonicalPrompt,
+ createdAt: canonicalTimestamp,
+ });
+ });
+
+ it("restores the canonical first prompt when a later user message remains", () => {
+ const canonicalPrompt = "\n Keep the canonical prompt \n";
+ const canonicalTimestamp = "2026-08-24T10:01:00.000Z";
+ const assistantMessages = Array.from({ length: 198 }, (_, index) =>
+ encodeTranscriptRecord({
+ type: "response_item",
+ timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`,
+ payload: {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: `Assistant message ${index}` }],
+ },
+ }),
+ );
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ timestamp: "2026-08-24T10:00:00.000Z",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
+ content: [{ type: "input_text", text: "Keep the canonical prompt" }],
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ timestamp: canonicalTimestamp,
+ payload: { type: "user_message", message: canonicalPrompt },
+ }),
+ ...assistantMessages,
+ encodeTranscriptRecord({
+ type: "event_msg",
+ timestamp: "2026-08-24T11:58:30.000Z",
+ payload: { type: "user_message", message: "Keep this later prompt" },
+ }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ timestamp: "2026-08-24T11:59:00.000Z",
+ payload: {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: "Keep this latest response" }],
+ },
+ }),
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+
+ expect(thread?.messages).toHaveLength(200);
+ expect(thread?.messages[0]).toMatchObject({
+ role: "user",
+ text: canonicalPrompt,
+ createdAt: canonicalTimestamp,
+ });
+ expect(
+ thread?.messages.filter((message) => message.text.trim() === canonicalPrompt.trim()),
+ ).toHaveLength(1);
+ expect(thread?.messages.some((message) => message.text === "Keep this later prompt")).toBe(
+ true,
+ );
+ expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response");
+ });
+
+ it("keeps mixed-format response users when turn IDs repeat after an assistant", () => {
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: { turn_id: "turn-older" },
+ content: [{ type: "input_text", text: "Keep this older prompt" }],
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Keep this newer prompt" },
+ }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" },
+ content: [{ type: "input_text", text: "Keep this newer prompt" }],
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: "Ask again when needed" }],
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" },
+ content: [{ type: "input_text", text: "Keep this newer prompt" }],
+ },
+ }),
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+
+ expect(thread?.messages.map((message) => message.text)).toEqual([
+ "Keep this older prompt",
+ "Keep this newer prompt",
+ "Ask again when needed",
+ "Keep this newer prompt",
+ ]);
+ });
+
+ it("preserves response user text when Codex turn metadata is ambiguous", () => {
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: ["unexpected"],
+ content: [{ type: "input_text", text: "Keep this legacy prompt" }],
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: { turn_id: " " },
+ content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }],
+ },
+ }),
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+
+ expect(thread?.messages.map((message) => message.text)).toEqual([
+ "Keep this legacy prompt",
+ "Keep this prompt with a blank turn ID",
+ ]);
+ });
+
+ it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => {
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "fork-session", forked_from_id: "parent-session" },
+ }),
+ encodeTranscriptRecord({
+ type: "session_meta",
+ payload: { id: "parent-session" },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "Continue in the fork" },
+ }),
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+
+ expect(thread?.providerSessionId).toBe("fork-session");
+ });
+
+ it("skips Codex transcripts without a resumable session ID", () => {
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: "This transcript has no session metadata" },
+ }),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "rollout-2026-08-24T12-00-00-not-a-session-id",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+
+ expect(thread).toBeNull();
+ });
+
+ it("uses the canonical Codex event when its turn has generated response context", () => {
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
+ content: [
+ {
+ type: "input_text",
+ text: "\n/tmp/project\nzsh\n",
+ },
+ ],
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
+ content: [
+ {
+ type: "input_text",
+ text: "# AGENTS.md instructions for /tmp/project\n\n\nPrivate project rules\n",
+ },
+ ],
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: {
+ type: "user_message",
+ message: "Do something here so it looks like a real project.",
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
+ content: [
+ {
+ type: "input_text",
+ text: "Do something here so it looks like a real project.",
+ },
+ ],
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: "Created the project." }],
+ },
+ }),
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
+ });
+
+ expect(thread?.title).toBe("Do something here so it looks like a real project.");
+ expect(thread?.messages.map((message) => message.text)).toEqual([
+ "Do something here so it looks like a real project.",
+ "Created the project.",
+ ]);
+ });
+
+ it("preserves context markup in response-only Codex messages", () => {
+ const context = "\n/tmp/project\n";
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ content: [
+ {
+ type: "input_text",
+ text: context,
+ },
+ ],
+ },
+ }),
+ encodeTranscriptRecord({
+ type: "response_item",
+ payload: {
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: "Initialize Git and add a README." }],
+ },
+ }),
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
+ });
+
+ expect(thread?.title).toBe("");
+ expect(thread?.messages.map((message) => message.text)).toEqual([
+ context,
+ "Initialize Git and add a README.",
+ ]);
+ });
+
+ it("preserves a canonical Codex event that starts with context markup", () => {
+ const prompt =
+ "\n/tmp/project\n\n\nCreate a useful project.";
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: {
+ type: "user_message",
+ message: prompt,
+ },
+ }),
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
+ });
+
+ expect(thread?.title).toBe("");
+ expect(thread?.messages.map((message) => message.text)).toEqual([prompt]);
+ });
+
+ it("preserves a Codex request heading in a canonical event", () => {
+ const prompt = "\n ## My request for Codex:\n\nFix the visible bug";
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: {
+ type: "user_message",
+ message: prompt,
+ },
+ }),
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
+ });
+
+ expect(thread?.title).toBe("## My request for Codex:");
+ expect(thread?.messages.map((message) => message.text)).toEqual([prompt]);
+ });
+
+ it("keeps context markup quoted inside visible Codex user text", () => {
+ const quoted =
+ "Do not remove this example:\n\n/tmp/example\n";
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: [
+ encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
+ encodeTranscriptRecord({
+ type: "event_msg",
+ payload: { type: "user_message", message: quoted },
+ }),
+ ].join("\n"),
+ source: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
+ });
+
+ expect(thread?.messages.map((message) => message.text)).toEqual([quoted]);
+ });
+
+ it("skips sessions without a visible user message", () => {
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: JSON.stringify({
+ type: "assistant",
+ message: { role: "assistant", content: "Done" },
+ }),
+ source: "claudeAgent",
+ providerInstanceId: ProviderInstanceId.make("claudeAgent"),
+ fallbackSessionId: "claude-session",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+
+ expect(thread).toBeNull();
+ });
+
+ it("keeps the first prompt when later assistant output exceeds the message limit", () => {
+ const transcript = [
+ encodeTranscriptRecord({
+ type: "user",
+ sessionId: "claude-session",
+ message: { role: "user", content: "Keep this prompt" },
+ }),
+ ...Array.from({ length: 250 }, (_, index) =>
+ encodeTranscriptRecord({
+ type: "assistant",
+ message: { role: "assistant", content: `Assistant update ${index}` },
+ }),
+ ),
+ ].join("\n");
+
+ const thread = AgentSessionScanner.parseAgentSessionTranscript({
+ contents: transcript,
+ source: "claudeAgent",
+ providerInstanceId: ProviderInstanceId.make("claudeAgent"),
+ fallbackSessionId: "fallback",
+ lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
+ });
+
+ expect(thread?.messages).toHaveLength(200);
+ expect(thread?.messages[0]?.text).toBe("Keep this prompt");
+ expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249");
+ });
+});
diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts
new file mode 100644
index 000000000..e1e6714e2
--- /dev/null
+++ b/apps/server/src/project/AgentSessionScanner.ts
@@ -0,0 +1,1502 @@
+/**
+ * AgentSessionScanner - discovery of projects a user already works on.
+ *
+ * Claude Code and Codex both keep a per-session transcript on disk, and each
+ * transcript records the directory the session ran in. Reading those `cwd`
+ * values gives us the set of directories worth offering as projects during
+ * onboarding, without asking the user to browse the filesystem.
+ *
+ * The scan is read-only and best-effort: an unreadable home, a malformed
+ * transcript, or a directory that has since been deleted is skipped rather
+ * than failing the scan. Project creation stays with the client, which
+ * dispatches `project.create` for whichever candidates the user picks.
+ *
+ * @module project/AgentSessionScanner
+ */
+import * as NodeOS from "node:os";
+
+import {
+ AgentSessionScanError,
+ ClaudeSettings,
+ CodexSettings,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ resolveProviderInstanceEnabled,
+ type AgentSessionImportSource,
+ type AgentSessionProjectCandidate,
+ type AgentSessionProjectGit,
+ type AgentSessionScanResult,
+ type ProviderInstanceConfig,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as Semaphore from "effect/Semaphore";
+import * as Stream from "effect/Stream";
+
+import {
+ normalizeGitRemoteUrl,
+ parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
+ parseOriginUrlFromGitConfig,
+} from "@t3tools/shared/git";
+import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
+
+import * as ServerConfig from "../config.ts";
+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
+import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
+import { expandHomePath } from "../pathExpansion.ts";
+import * as ServerSettings from "../serverSettings.ts";
+import {
+ createTranscriptJsonReader,
+ createTranscriptJsonSelector,
+ TranscriptJsonLimitError,
+} from "./AgentSessionJson.ts";
+
+/** Chunk size for full transcript reads. */
+const TRANSCRIPT_PREFIX_BYTES = 32 * 1024;
+/** Small reads avoid wasting the metadata budget on long Codex instruction headers. */
+const METADATA_READ_BYTES = 8 * 1024;
+/** Prevent malformed transcripts from turning project discovery into a full file scan. */
+const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024;
+
+/**
+ * Upper bound on transcripts inspected (first line read) per source.
+ * Newest-first ordering means the cap drops only stale sessions when a home
+ * directory is unusually large.
+ */
+const MAX_TRANSCRIPTS_PER_SOURCE = 5000;
+
+/**
+ * Upper bound on discovery filesystem operations per source. Newest-first
+ * ordering needs mtimes before the read cap can be applied, so directory reads
+ * and candidate stats share a larger budget. Once it runs out the scan stops.
+ */
+const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4;
+const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024;
+const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4;
+const MAX_METADATA_RECORDS_PER_SOURCE = 100_000;
+const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000;
+const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
+/**
+ * Large tool results (especially screenshots) can make an otherwise ordinary
+ * Codex transcript several GiB. Streaming field selection avoids allocating
+ * those payloads. Raw I/O and selected history have separate budgets.
+ */
+const MAX_IMPORTED_TRANSCRIPT_BYTES = 4 * 1024 * 1024 * 1024;
+const MAX_IMPORTED_MESSAGES = 200;
+const MAX_IMPORT_HISTORY_BYTES = 32 * 1024 * 1024;
+const MAX_IMPORT_BYTES = 4 * 1024 * 1024 * 1024;
+const MAX_IMPORT_TRANSCRIPTS = 100;
+const MAX_IMPORT_RECORDS = 100_000;
+
+const TranscriptContentBlock = Schema.Struct({
+ type: Schema.optional(Schema.String),
+ text: Schema.optional(Schema.String),
+});
+
+const TranscriptMessage = Schema.Struct({
+ role: Schema.optional(Schema.String),
+ content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])),
+ model: Schema.optional(Schema.String),
+});
+
+const CodexTurnMetadata = Schema.Struct({
+ turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])),
+});
+
+const TranscriptRecord = Schema.Struct({
+ type: Schema.optional(Schema.String),
+ timestamp: Schema.optional(Schema.String),
+ cwd: Schema.optional(Schema.String),
+ sessionId: Schema.optional(Schema.String),
+ aiTitle: Schema.optional(Schema.String),
+ isSidechain: Schema.optional(Schema.Boolean),
+ isMeta: Schema.optional(Schema.Boolean),
+ isCompactSummary: Schema.optional(Schema.Boolean),
+ message: Schema.optional(TranscriptMessage),
+ payload: Schema.optional(
+ Schema.Struct({
+ id: Schema.optional(Schema.String),
+ session_id: Schema.optional(Schema.String),
+ type: Schema.optional(Schema.String),
+ role: Schema.optional(Schema.String),
+ message: Schema.optional(Schema.String),
+ model: Schema.optional(Schema.String),
+ cwd: Schema.optional(Schema.String),
+ content: Schema.optional(Schema.Array(TranscriptContentBlock)),
+ internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown),
+ }),
+ ),
+});
+
+const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);
+const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings);
+const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord));
+const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord);
+const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord);
+const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata);
+
+type DecodedTranscriptRecord = typeof TranscriptRecord.Type;
+
+interface AgentSessionTranscriptMetadata {
+ readonly source: AgentSessionSource;
+ readonly providerInstanceId: ProviderInstanceId;
+ readonly fallbackSessionId: string;
+ readonly lastActiveAtMs: number;
+}
+
+export interface AgentSessionThreadMessage {
+ readonly role: "user" | "assistant";
+ readonly text: string;
+ readonly createdAt: string;
+}
+
+export interface AgentSessionThread {
+ readonly source: AgentSessionSource;
+ readonly providerInstanceId: ProviderInstanceId;
+ readonly providerSessionId: string;
+ readonly title: string;
+ readonly model: string | null;
+ readonly createdAt: string;
+ readonly updatedAt: string;
+ readonly messages: ReadonlyArray;
+}
+
+export type AgentSessionRecentThread =
+ | {
+ readonly _tag: "Importable";
+ readonly thread: AgentSessionThread;
+ readonly source: AgentSessionImportSource;
+ }
+ | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource }
+ | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource }
+ | { readonly _tag: "Skipped" };
+
+/** Service tag for agent session discovery. */
+export class AgentSessionScanner extends Context.Service<
+ AgentSessionScanner,
+ {
+ /**
+ * Discover every directory the configured Claude and Codex homes have run
+ * a session in. Candidates are returned newest-first; the client decides
+ * which ones to import and how far back to look. Fails with the contract
+ * error directly — there is no server-local context worth wrapping.
+ */
+ readonly scan: Effect.Effect;
+ readonly recentThreads: (
+ workspaceRoot: string,
+ completedSources?: ReadonlyArray,
+ ) => Stream.Stream;
+ }
+>()("t3/project/AgentSessionScanner") {}
+
+type AgentSessionSource = AgentSessionProjectCandidate["sources"][number];
+
+/** A single directory's worth of evidence from one source. */
+interface RawCandidate {
+ readonly cwd: string;
+ readonly source: AgentSessionSource;
+ readonly providerInstanceId: ProviderInstanceId;
+ readonly threadCount: number;
+ readonly lastActiveAtMs: number | null;
+ readonly transcripts: ReadonlyArray<{
+ readonly filePath: string;
+ readonly mtimeMs: number | null;
+ }>;
+}
+
+interface TranscriptCandidate {
+ readonly filePath: string;
+ readonly mtimeMs: number;
+ readonly providerInstanceId: ProviderInstanceId;
+ readonly size: number;
+}
+
+interface MetadataReadBudget {
+ bytesRemaining: number;
+ operationsRemaining: number;
+ recordsRemaining: number;
+ truncated: boolean;
+}
+
+function selectMetadataTranscripts(transcripts: ReadonlyArray) {
+ const selected: Array = [];
+ let pending = Array.from(
+ Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(),
+ (entries) => entries.values(),
+ );
+ while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) {
+ const nextRound: typeof pending = [];
+ for (const iterator of pending) {
+ if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break;
+ const next = iterator.next();
+ if (next.done) continue;
+ selected.push(next.value);
+ nextRound.push(iterator);
+ }
+ pending = nextRound;
+ }
+ return selected;
+}
+
+function splitTranscriptRecords(contents: string, limit: number): string[] {
+ const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents;
+ return records.split("\n", limit);
+}
+
+function extractText(
+ content: string | ReadonlyArray | undefined,
+): string {
+ if (typeof content === "string") return content.trim();
+ if (content === undefined) return "";
+ return content
+ .filter(
+ (block) =>
+ block.type === "text" || block.type === "input_text" || block.type === "output_text",
+ )
+ .map((block) => block.text?.trim() ?? "")
+ .filter((text) => text.length > 0)
+ .join("\n");
+}
+
+function normalizeTimestamp(value: string | undefined, fallback: string): string {
+ if (value === undefined) return fallback;
+ const parsed = DateTime.make(value);
+ return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback;
+}
+
+function codexTurnId(metadata: unknown): string | null {
+ const decoded = decodeCodexTurnMetadata(metadata);
+ if (
+ Option.isNone(decoded) ||
+ typeof decoded.value.turn_id !== "string" ||
+ decoded.value.turn_id.trim().length === 0
+ ) {
+ return null;
+ }
+ return decoded.value.turn_id;
+}
+
+/** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */
+export function parseAgentSessionTranscript(
+ input: AgentSessionTranscriptMetadata & {
+ readonly contents: string;
+ },
+ lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1),
+): AgentSessionThread | null {
+ if (lines.length > MAX_IMPORT_RECORDS) return null;
+ const records = lines.flatMap((line) => Option.toArray(decodeTranscriptRecord(line)));
+ return parseAgentSessionRecords(input, records);
+}
+
+function parseAgentSessionRecords(
+ input: AgentSessionTranscriptMetadata,
+ records: ReadonlyArray,
+): AgentSessionThread | null {
+ const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs));
+ // Claude filenames are session IDs. Codex rollout filenames include extra
+ // timestamp text, so only transcript metadata can provide a resumable ID.
+ let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId;
+ let title: string | null = null;
+ let model: string | null = null;
+ let hasCodexSessionId = false;
+ const messages: Array = [];
+ let firstUserMessage:
+ | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean })
+ | undefined;
+ // A Codex response item can include generated setup text beside the real
+ // prompt. Suppress response-user records only when the shared turn ID and a
+ // verbatim event copy prove which prompt the user submitted.
+ const canonicalCodexResponseUserIndices = new Set();
+ let canonicalUserTextsInTurn = new Set();
+ let responseUsersInTurn: Array<{
+ readonly index: number;
+ readonly turnId: string;
+ readonly text: string;
+ }> = [];
+ const finishCodexTurn = () => {
+ const canonicalTurnIds = new Set(
+ responseUsersInTurn.flatMap((responseUser) =>
+ canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [],
+ ),
+ );
+ for (const responseUser of responseUsersInTurn) {
+ if (canonicalTurnIds.has(responseUser.turnId)) {
+ canonicalCodexResponseUserIndices.add(responseUser.index);
+ }
+ }
+ canonicalUserTextsInTurn = new Set();
+ responseUsersInTurn = [];
+ };
+ if (input.source === "codex") {
+ let recordIndex = -1;
+ for (const record of records) {
+ recordIndex += 1;
+ if (
+ record.type === "response_item" &&
+ record.payload?.type === "message" &&
+ record.payload.role === "assistant"
+ ) {
+ finishCodexTurn();
+ continue;
+ }
+ if (record.type === "event_msg" && record.payload?.type === "user_message") {
+ const text = record.payload.message?.trim() ?? "";
+ if (text.length > 0) canonicalUserTextsInTurn.add(text);
+ continue;
+ }
+ if (
+ record.type === "response_item" &&
+ record.payload?.type === "message" &&
+ record.payload.role === "user"
+ ) {
+ const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough);
+ const text = extractText(record.payload.content);
+ if (turnId !== null && text.length > 0) {
+ responseUsersInTurn.push({ index: recordIndex, turnId, text });
+ }
+ }
+ }
+ finishCodexTurn();
+ }
+
+ const retainMessage = (
+ message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean },
+ ) => {
+ if (firstUserMessage === undefined && message.role === "user") {
+ firstUserMessage = message;
+ }
+ messages.push(message);
+ if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift();
+ };
+
+ const hasMatchingCodexEventInTurn = (text: string) => {
+ const comparisonText = text.trim();
+ for (let index = messages.length - 1; index >= 0; index--) {
+ const message = messages[index];
+ if (message?.role === "assistant") return false;
+ if (
+ message?.role === "user" &&
+ !message.codexResponseUser &&
+ message.text.trim() === comparisonText
+ ) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ let recordIndex = -1;
+ for (const record of records) {
+ recordIndex += 1;
+ if (input.source === "claudeAgent") {
+ if (
+ record.isSidechain === true ||
+ record.isMeta === true ||
+ record.isCompactSummary === true
+ ) {
+ continue;
+ }
+ if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim();
+ if (record.aiTitle?.trim()) title = record.aiTitle.trim();
+ const messageModel = record.message?.model?.trim();
+ // Claude uses this sentinel for local error responses. It is not a
+ // model ID that can be selected when the imported session resumes.
+ if (messageModel && messageModel !== "") model = messageModel;
+ if (record.type !== "user" && record.type !== "assistant") {
+ continue;
+ }
+
+ const text = extractText(record.message?.content);
+ if (text.length === 0) continue;
+ retainMessage({
+ role: record.type,
+ text,
+ createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
+ codexResponseUser: false,
+ });
+ continue;
+ }
+
+ if (record.type === "session_meta") {
+ const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim();
+ if (!hasCodexSessionId && sessionId) {
+ providerSessionId = sessionId;
+ hasCodexSessionId = true;
+ }
+ continue;
+ }
+ if (record.type === "turn_context" && record.payload?.model?.trim()) {
+ model = record.payload.model.trim();
+ continue;
+ }
+ if (record.type === "event_msg" && record.payload?.type === "user_message") {
+ const text = record.payload.message ?? "";
+ if (text.trim().length === 0) continue;
+ // Codex can write the same prompt as both a response item and an event.
+ // Remove only the matching response copy so mixed-format logs keep every
+ // distinct user message.
+ for (let index = messages.length - 1; index >= 0; index--) {
+ const message = messages[index];
+ if (message?.role === "assistant") break;
+ if (message?.codexResponseUser === true && message.text.trim() === text.trim()) {
+ if (firstUserMessage === message) firstUserMessage = undefined;
+ messages.splice(index, 1);
+ break;
+ }
+ }
+ retainMessage({
+ role: "user",
+ text,
+ createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
+ codexResponseUser: false,
+ });
+ continue;
+ }
+ if (
+ record.type !== "response_item" ||
+ record.payload?.type !== "message" ||
+ (record.payload.role !== "user" && record.payload.role !== "assistant")
+ ) {
+ continue;
+ }
+
+ const extractedText = extractText(record.payload.content);
+ if (extractedText.length === 0) continue;
+ if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) {
+ continue;
+ }
+ if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) {
+ continue;
+ }
+ retainMessage({
+ role: record.payload.role,
+ text: extractedText,
+ createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
+ codexResponseUser: record.payload.role === "user",
+ });
+ }
+
+ const visibleMessages = messages.map(
+ ({ codexResponseUser: _codexResponseUser, ...message }) => message,
+ );
+ if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null;
+ const firstUserMessageRetained = messages.includes(firstUserMessage);
+ const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage;
+ const retainedMessages = firstUserMessageRetained
+ ? visibleMessages
+ : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))];
+ const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim();
+
+ return {
+ source: input.source,
+ providerInstanceId: input.providerInstanceId,
+ providerSessionId,
+ title: title ?? (derivedTitle && derivedTitle.length > 0 ? derivedTitle : "Imported thread"),
+ model,
+ createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp,
+ updatedAt: fallbackTimestamp,
+ messages: retainedMessages,
+ };
+}
+
+function extractDecodedCwd(record: DecodedTranscriptRecord): string | null {
+ const cwd = record.cwd?.trim() || record.payload?.cwd?.trim();
+ return cwd && cwd.length > 0 ? cwd : null;
+}
+
+function shouldRetainDecodedRecord(
+ source: AgentSessionSource,
+ record: DecodedTranscriptRecord,
+): boolean {
+ if (extractDecodedCwd(record) !== null) return true;
+ if (source === "claudeAgent") {
+ return (
+ record.type === "user" ||
+ record.type === "assistant" ||
+ record.sessionId !== undefined ||
+ record.aiTitle !== undefined ||
+ record.message?.model !== undefined
+ );
+ }
+ return (
+ record.type === "session_meta" ||
+ record.type === "turn_context" ||
+ (record.type === "event_msg" && record.payload?.type === "user_message") ||
+ (record.type === "response_item" &&
+ record.payload?.type === "message" &&
+ (record.payload.role === "user" || record.payload.role === "assistant"))
+ );
+}
+
+/**
+ * Pylon runs its own agent sessions inside disposable worktrees. Their
+ * transcripts look exactly like user sessions, but re-importing the app's own
+ * sandboxes as projects is never right. Matches this server's configured
+ * worktrees directory plus the conventional `.pylon-code/worktrees` layout
+ * (including channel homes such as `.pylon-code-nightly`) and T3 Code's
+ * `.t3/worktrees`, which also catches sandboxes from other homes on the same
+ * machine. Separators are normalized (and, on Windows, case folded) so the
+ * prefix match holds there too. Callers check both the recorded spelling and
+ * its realpath so a symlink into the worktrees directory cannot bypass the
+ * filter.
+ */
+const RUNTIME_HOME_WORKTREES_PATTERN = /\/\.pylon-code(?:-[a-z0-9]+)?\/worktrees\//;
+
+function normalizeForWorktreeMatch(value: string, caseFold: boolean): string {
+ const normalized = `${value.replaceAll("\\", "/")}/`;
+ return caseFold ? normalized.toLowerCase() : normalized;
+}
+
+function isT3ManagedWorktree(
+ candidatePath: string,
+ worktreesDir: string,
+ caseFold: boolean,
+): boolean {
+ const normalized = normalizeForWorktreeMatch(candidatePath, caseFold);
+ return (
+ normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) ||
+ normalized.includes("/.t3/worktrees/") ||
+ RUNTIME_HOME_WORKTREES_PATTERN.test(normalized)
+ );
+}
+
+/** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */
+function extractCwd(line: string): string | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(line);
+ } catch {
+ return null;
+ }
+ if (typeof parsed !== "object" || parsed === null) return null;
+
+ const record = parsed as Record;
+ if (typeof record.cwd === "string" && record.cwd.trim().length > 0) {
+ return record.cwd;
+ }
+ // Codex nests session metadata under `payload`.
+ const payload = record.payload;
+ if (typeof payload === "object" && payload !== null) {
+ const nested = (payload as Record).cwd;
+ if (typeof nested === "string" && nested.trim().length > 0) {
+ return nested;
+ }
+ }
+ return null;
+}
+
+function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) {
+ return {
+ filePath,
+ size: Number(stats.size),
+ mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }),
+ device: stats.dev,
+ inode: Option.getOrNull(stats.ino),
+ birthtimeMs: Option.match(stats.birthtime, {
+ onNone: () => null,
+ onSome: (date) => date.getTime(),
+ }),
+ };
+}
+
+function sameTranscriptIdentity(
+ left: ReturnType,
+ right: ReturnType,
+): boolean {
+ return (
+ left.filePath === right.filePath &&
+ left.size === right.size &&
+ left.mtimeMs === right.mtimeMs &&
+ left.device === right.device &&
+ left.inode === right.inode &&
+ left.birthtimeMs === right.birthtimeMs
+ );
+}
+
+export const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ // Different project imports can arrive concurrently from multiple clients.
+ // Only one transcript may hold its selected-history budget at a time.
+ const importReadLock = yield* Semaphore.make(1);
+ const path = yield* Path.Path;
+ const serverConfig = yield* ServerConfig.ServerConfig;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
+ const baseDir = path.resolve(serverConfig.baseDir);
+ const worktreesDir = path.resolve(serverConfig.worktreesDir);
+ // Windows filesystems are case-insensitive, so path prefix checks there
+ // must case fold.
+ const foldWorktreeCase = (yield* HostProcessPlatform) === "win32";
+ const hostEnvironment = yield* HostProcessEnvironment;
+ const homeDir = NodeOS.homedir();
+ // `/private/tmp` is what macOS reports for sessions started in `/tmp`.
+ const excludedProjectRoots = new Set(
+ [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) =>
+ normalizeProjectPathForComparison(path.resolve(directory)),
+ ),
+ );
+ // Codex creates one scratch directory per conversation under
+ // ~/Documents/Codex//. Neither those nor anything a user
+ // unpacked into Downloads is a project.
+ const excludedProjectAncestors = [
+ path.join(homeDir, "Downloads"),
+ path.join(homeDir, "Documents", "Codex"),
+ ];
+
+ const isExcludedProjectPath = (candidatePath: string) =>
+ excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) ||
+ excludedProjectAncestors.some((ancestor) =>
+ normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
+ normalizeForWorktreeMatch(ancestor, foldWorktreeCase),
+ ),
+ ) ||
+ normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
+ normalizeForWorktreeMatch(baseDir, foldWorktreeCase),
+ ) ||
+ isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase);
+
+ const listDirectory = (directory: string) =>
+ fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => []));
+
+ const statOption = (target: string) =>
+ fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none));
+
+ /** Match directory aliases without assuming the host volume is case-insensitive. */
+ const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* (
+ target: string,
+ knownStats?: FileSystem.File.Info,
+ ) {
+ const resolved = path.resolve(target);
+ const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats);
+ if (
+ Option.isSome(stats) &&
+ Option.isSome(stats.value.ino) &&
+ Number.isSafeInteger(stats.value.ino.value) &&
+ stats.value.ino.value > 0
+ ) {
+ return `inode:${stats.value.dev}:${stats.value.ino.value}`;
+ }
+ const realPath = yield* fileSystem
+ .realPath(resolved)
+ .pipe(Effect.orElseSucceed(() => resolved));
+ return `path:${normalizeProjectPathForComparison(realPath)}`;
+ });
+
+ /**
+ * Git identity of a directory, or the reason it has none. Reads `.git`
+ * directly instead of spawning git so a scan over hundreds of candidates
+ * stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a
+ * `worktrees/` directory the checkout is a linked worktree, which
+ * onboarding skips because its history belongs to the main checkout.
+ * Submodules use the same pointer shape but live under `modules/`, and
+ * are offered like any other repository.
+ */
+ const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* (
+ directory: string,
+ ): Effect.fn.Return<
+ | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null }
+ | { readonly _tag: "Worktree" }
+ | { readonly _tag: "NotGit" }
+ > {
+ const gitPath = path.join(directory, ".git");
+ const gitStats = yield* statOption(gitPath);
+ if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const;
+ let gitDir = gitPath;
+ if (gitStats.value.type !== "Directory") {
+ const pointer = yield* fileSystem
+ .readFileString(gitPath)
+ .pipe(Effect.orElseSucceed(() => ""));
+ const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
+ if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const;
+ gitDir = path.resolve(directory, target);
+ if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const;
+ }
+ const configText = yield* fileSystem
+ .readFileString(path.join(gitDir, "config"))
+ .pipe(Effect.orElseSucceed(() => ""));
+ const originUrl = parseOriginUrlFromGitConfig(configText);
+ return {
+ _tag: "Repository",
+ git: {
+ remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl),
+ repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl),
+ },
+ } as const;
+ });
+
+ // A large history snapshot can precede session metadata. Read bounded
+ // chunks until a complete record names its cwd or the safety budget ends.
+ const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* (
+ transcript: TranscriptCandidate,
+ budget: MetadataReadBudget,
+ ) {
+ if (transcript.size === 0) return null;
+ if (
+ budget.bytesRemaining === 0 ||
+ budget.operationsRemaining < 2 ||
+ budget.recordsRemaining === 0
+ ) {
+ budget.truncated = true;
+ return null;
+ }
+ budget.operationsRemaining -= 1;
+ return yield* Effect.scoped(
+ fileSystem.open(transcript.filePath, { flag: "r" }).pipe(
+ Effect.flatMap((file) =>
+ Effect.gen(function* () {
+ const decoder = new TextDecoder();
+ let remaining = "";
+ let bytesRead = 0;
+ let recordsRead = 0;
+ const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size);
+ const reserveRecord = () => {
+ if (
+ recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT ||
+ budget.recordsRemaining === 0
+ ) {
+ budget.truncated = true;
+ return false;
+ }
+ recordsRead += 1;
+ budget.recordsRemaining -= 1;
+ return true;
+ };
+ const readLastRecord = () => {
+ const record = remaining + decoder.decode();
+ return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim());
+ };
+
+ while (bytesRead < maxBytes) {
+ if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) {
+ budget.truncated = true;
+ return null;
+ }
+ const readSize = Math.min(
+ METADATA_READ_BYTES,
+ maxBytes - bytesRead,
+ budget.bytesRemaining,
+ );
+ budget.operationsRemaining -= 1;
+ budget.bytesRemaining -= readSize;
+ const next = yield* file.readAlloc(readSize);
+ if (Option.isNone(next)) {
+ return readLastRecord();
+ }
+
+ bytesRead += next.value.byteLength;
+ remaining += decoder.decode(next.value, { stream: true });
+ const lines = remaining.split("\n");
+ remaining = lines.pop() ?? "";
+
+ for (const line of lines) {
+ if (!reserveRecord()) return null;
+ const cwd = extractCwd(line.trim());
+ if (cwd !== null) return cwd;
+ }
+ }
+
+ if (bytesRead < transcript.size) {
+ budget.truncated = true;
+ return null;
+ }
+ return readLastRecord();
+ }),
+ ),
+ ),
+ ).pipe(Effect.orElseSucceed(() => null));
+ });
+
+ /**
+ * Project history fields while reading, before allocating whole JSON records.
+ * Check the file identity on both sides of the read. A selected-history budget
+ * failure rejects the entire transcript before any imported messages persist.
+ */
+ const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* (
+ filePath: string,
+ expected: ReturnType,
+ recordLimit: number,
+ source: AgentSessionSource,
+ ) {
+ if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null;
+
+ return yield* Effect.scoped(
+ fileSystem.open(filePath, { flag: "r" }).pipe(
+ Effect.flatMap((file) =>
+ Effect.gen(function* () {
+ if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) {
+ return null;
+ }
+ const records: Array = [];
+ let historyBytes = 0;
+ let recordBytes = 0;
+ let recordCount = 0;
+ let bytesRead = 0;
+ const reserve = (bytes: number) => {
+ recordBytes += bytes;
+ if (historyBytes + recordBytes > MAX_IMPORT_HISTORY_BYTES) {
+ throw new TranscriptJsonLimitError(
+ "Transcript selected history exceeds the 32 MiB memory budget",
+ );
+ }
+ };
+ let reader = createTranscriptJsonReader(reserve, selectTranscriptPath);
+ let decoder = new TextDecoder();
+ let recordStarted = false;
+
+ const finishRecord = () => {
+ reader.write(decoder.decode());
+ recordCount += 1;
+ if (recordCount > recordLimit) return false;
+ const decoded = decodeTranscriptValue(reader.finish());
+ if (Option.isSome(decoded) && shouldRetainDecodedRecord(source, decoded.value)) {
+ records.push(decoded.value);
+ historyBytes += recordBytes;
+ }
+ recordBytes = 0;
+ reader = createTranscriptJsonReader(reserve, selectTranscriptPath);
+ decoder = new TextDecoder();
+ recordStarted = false;
+ return true;
+ };
+
+ while (bytesRead < expected.size) {
+ const next = yield* file.readAlloc(
+ Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead),
+ );
+ if (Option.isNone(next)) {
+ return null;
+ }
+
+ bytesRead += next.value.byteLength;
+ const withinBudget = yield* Effect.try(() => {
+ let start = 0;
+ while (start < next.value.byteLength) {
+ const newline = next.value.indexOf(10, start);
+ const end = newline === -1 ? next.value.byteLength : newline;
+ recordStarted = true;
+ reader.write(decoder.decode(next.value.subarray(start, end), { stream: true }));
+ if (newline === -1) break;
+ if (!finishRecord()) return false;
+ start = newline + 1;
+ }
+ return true;
+ });
+ if (!withinBudget) return null;
+ }
+
+ if (recordStarted && !(yield* Effect.try(finishRecord))) return null;
+ return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))
+ ? { records, recordCount }
+ : null;
+ }),
+ ),
+ ),
+ ).pipe(
+ Effect.catch((cause) =>
+ Effect.logWarning("Could not read imported transcript", { filePath, cause }).pipe(
+ Effect.as(null),
+ ),
+ ),
+ );
+ });
+
+ /**
+ * Resolve the Claude config directory the CLI would use, matching the
+ * precedence the spawned CLI sees: the instance's `homePath` (exported as
+ * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the
+ * environment, then `~/.claude`.
+ */
+ const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => {
+ const configured = homePath.trim();
+ if (configured.length > 0) {
+ return path.resolve(expandHomePath(configured));
+ }
+ const fromEnvironment = environmentHome?.trim() ?? "";
+ if (fromEnvironment.length > 0) {
+ return path.resolve(expandHomePath(fromEnvironment));
+ }
+ return path.join(NodeOS.homedir(), ".claude");
+ };
+
+ const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")(
+ function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) {
+ const projectsDir = path.join(homePath, "projects");
+ let operationsRemaining = operationBudget;
+ let truncated = false;
+ const readDirectory = (directory: string) => {
+ if (operationsRemaining <= 0) {
+ truncated = true;
+ return Effect.succeed>([]);
+ }
+ operationsRemaining -= 1;
+ return listDirectory(directory);
+ };
+ const projectDirectories = yield* readDirectory(projectsDir);
+ const transcripts: Array = [];
+
+ for (const projectDirectory of projectDirectories) {
+ if (operationsRemaining <= 0) {
+ truncated = true;
+ break;
+ }
+ const directory = path.join(projectsDir, projectDirectory);
+ const directoryTranscripts = (yield* readDirectory(directory))
+ .filter((entry) => entry.endsWith(".jsonl"))
+ .map((entry) => path.join(directory, entry));
+
+ for (const filePath of directoryTranscripts) {
+ if (operationsRemaining <= 0) {
+ truncated = true;
+ break;
+ }
+ operationsRemaining -= 1;
+ const stats = yield* statOption(filePath);
+ if (
+ Option.isNone(stats) ||
+ stats.value.type !== "File" ||
+ Option.isNone(stats.value.mtime)
+ ) {
+ continue;
+ }
+ transcripts.push({
+ filePath,
+ mtimeMs: stats.value.mtime.value.getTime(),
+ providerInstanceId,
+ size: Number(stats.value.size),
+ });
+ }
+ }
+ return { transcripts, truncated };
+ },
+ );
+
+ const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")(
+ function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) {
+ const sessionsDir = path.join(homePath, "sessions");
+
+ const transcripts: Array = [];
+ let operationsRemaining = operationBudget;
+ let truncated = false;
+ const readDirectory = (directory: string) => {
+ if (operationsRemaining <= 0) {
+ truncated = true;
+ return Effect.succeed>([]);
+ }
+ operationsRemaining -= 1;
+ return listDirectory(directory);
+ };
+ // Date-partitioned directories sort chronologically, so walking them in
+ // reverse spends each home's share of the operation budget on recent sessions.
+ for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) {
+ if (operationsRemaining <= 0) {
+ truncated = true;
+ break;
+ }
+ for (const month of (yield* readDirectory(path.join(sessionsDir, year)))
+ .toSorted()
+ .toReversed()) {
+ if (operationsRemaining <= 0) {
+ truncated = true;
+ break;
+ }
+ for (const day of (yield* readDirectory(path.join(sessionsDir, year, month)))
+ .toSorted()
+ .toReversed()) {
+ if (operationsRemaining <= 0) {
+ truncated = true;
+ break;
+ }
+ const directory = path.join(sessionsDir, year, month, day);
+ for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) {
+ if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue;
+ if (operationsRemaining <= 0) {
+ truncated = true;
+ break;
+ }
+ const filePath = path.join(directory, entry);
+ operationsRemaining -= 1;
+ const stats = yield* statOption(filePath);
+ if (
+ Option.isSome(stats) &&
+ stats.value.type === "File" &&
+ Option.isSome(stats.value.mtime)
+ ) {
+ transcripts.push({
+ filePath,
+ mtimeMs: stats.value.mtime.value.getTime(),
+ providerInstanceId,
+ size: Number(stats.value.size),
+ });
+ }
+ }
+ }
+ }
+ }
+ return { transcripts, truncated };
+ },
+ );
+
+ const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* (
+ source: AgentSessionSource,
+ transcripts: ReadonlyArray,
+ budget: MetadataReadBudget,
+ ) {
+ const byOwnerAndCwd = new Map<
+ string,
+ {
+ cwd: string;
+ providerInstanceId: ProviderInstanceId;
+ lastActiveAtMs: number;
+ transcripts: Array<{ filePath: string; mtimeMs: number }>;
+ }
+ >();
+
+ for (const transcript of transcripts) {
+ const cwd = yield* readCwd(transcript, budget);
+ if (cwd === null) continue;
+ const key = `${transcript.providerInstanceId}\0${cwd}`;
+ const existing = byOwnerAndCwd.get(key);
+ if (existing) {
+ existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs);
+ existing.transcripts.push(transcript);
+ } else {
+ byOwnerAndCwd.set(key, {
+ cwd,
+ providerInstanceId: transcript.providerInstanceId,
+ lastActiveAtMs: transcript.mtimeMs,
+ transcripts: [transcript],
+ });
+ }
+ }
+
+ return Array.from(
+ byOwnerAndCwd.values(),
+ (group): RawCandidate => ({
+ cwd: group.cwd,
+ source,
+ providerInstanceId: group.providerInstanceId,
+ threadCount: group.transcripts.length,
+ lastActiveAtMs: group.lastActiveAtMs,
+ transcripts: group.transcripts,
+ }),
+ );
+ });
+
+ const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () {
+ const settings = yield* serverSettings.getSettings.pipe(
+ Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })),
+ );
+
+ const raw: Array = [];
+ let truncated = false;
+
+ for (const source of ["claudeAgent", "codex"] as const) {
+ const instances: Array<{
+ readonly instanceId: ProviderInstanceId;
+ readonly config: ProviderInstanceConfig;
+ }> = Object.entries(settings.providerInstances)
+ .filter(
+ ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance),
+ )
+ .map(([instanceId, config]) => ({
+ instanceId: ProviderInstanceId.make(instanceId),
+ config,
+ }));
+ if (!Object.hasOwn(settings.providerInstances, source)) {
+ const legacyInstance = {
+ instanceId: ProviderInstanceId.make(source),
+ config: {
+ driver: ProviderDriverKind.make(source),
+ config: settings.providers[source],
+ },
+ };
+ if (resolveProviderInstanceEnabled(legacyInstance.config)) {
+ instances.push(legacyInstance);
+ }
+ }
+
+ // A shared home contains one copy of each session. Prefer the built-in
+ // instance as its owner, then keep configured order for custom accounts.
+ instances.sort((left, right) => {
+ const leftDefault = left.instanceId === source ? 0 : 1;
+ const rightDefault = right.instanceId === source ? 0 : 1;
+ return leftDefault - rightDefault;
+ });
+ const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = [];
+ const seenHomes = new Set();
+ for (const { instanceId, config: instance } of instances) {
+ const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME";
+ const environmentHome =
+ instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ??
+ hostEnvironment[homeVariable];
+
+ let homePath: string;
+ if (source === "claudeAgent") {
+ const config = decodeClaudeSettings(instance.config ?? {});
+ if (Option.isNone(config)) continue;
+ homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome);
+ } else {
+ const config = decodeCodexSettings(instance.config ?? {});
+ if (Option.isNone(config)) continue;
+ const codexSettings =
+ config.value.homePath.trim().length === 0 &&
+ config.value.shadowHomePath.trim().length === 0 &&
+ environmentHome?.trim()
+ ? { ...config.value, homePath: environmentHome }
+ : config.value;
+ const layout = yield* resolveCodexHomeLayout(codexSettings).pipe(
+ Effect.provideService(Path.Path, path),
+ );
+ homePath = layout.sharedHomePath;
+ }
+
+ const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`;
+ if (seenHomes.has(homeKey)) continue;
+ seenHomes.add(homeKey);
+ homes.push({ homePath, providerInstanceId: instanceId });
+ }
+
+ const transcriptCandidates: Array = [];
+ const baseOperationBudget = Math.floor(
+ MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length),
+ );
+ const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length);
+ for (const [index, home] of homes.entries()) {
+ const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0);
+ if (operationBudget === 0) {
+ truncated = true;
+ continue;
+ }
+ const discovered = yield* source === "claudeAgent"
+ ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget)
+ : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget);
+ truncated ||= discovered.truncated;
+ transcriptCandidates.push(...discovered.transcripts);
+ }
+
+ transcriptCandidates.sort(
+ (left, right) =>
+ right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath),
+ );
+ if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) {
+ truncated = true;
+ }
+ // Give each account a turn before taking another file from the same home.
+ const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates);
+ const metadataBudget: MetadataReadBudget = {
+ bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE,
+ operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE,
+ recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE,
+ truncated: false,
+ };
+ raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget)));
+ truncated ||= metadataBudget.truncated;
+ }
+
+ return { candidates: raw, truncated };
+ });
+
+ let cachedCandidates: ReadonlyArray | null = null;
+
+ const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () {
+ const { candidates: raw, truncated } = yield* collectCandidates();
+ cachedCandidates = raw;
+
+ // Filesystem identity merges symlinks and case aliases without collapsing
+ // distinct case-sensitive directories.
+ const merged = new Map<
+ string,
+ {
+ path: string;
+ sources: Array;
+ threadCount: number;
+ lastActiveAtMs: number | null;
+ git: AgentSessionProjectGit | null;
+ }
+ >();
+ const directoryKeys = new Map();
+ const gitIdentities = new Map();
+
+ for (const candidate of raw) {
+ const expanded = expandHomePath(candidate.cwd.trim());
+ if (!path.isAbsolute(expanded)) continue;
+ const resolved = path.resolve(expanded);
+ if (isExcludedProjectPath(resolved)) continue;
+ let key = directoryKeys.get(resolved);
+ if (key === undefined) {
+ const stats = yield* statOption(resolved);
+ // Directories that no longer exist can't be imported.
+ if (Option.isNone(stats) || stats.value.type !== "Directory") {
+ directoryKeys.set(resolved, "");
+ continue;
+ }
+ const realPath = yield* fileSystem
+ .realPath(resolved)
+ .pipe(Effect.orElseSucceed(() => resolved));
+ // A symlink can point into the worktrees directory even when its own
+ // spelling doesn't; check again with links resolved.
+ if (isExcludedProjectPath(realPath)) {
+ key = "";
+ } else {
+ const gitIdentity = yield* readGitIdentity(resolved);
+ if (gitIdentity._tag === "Worktree") {
+ key = "";
+ } else {
+ key = yield* directoryIdentity(resolved, stats.value);
+ gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null);
+ }
+ }
+ directoryKeys.set(resolved, key);
+ }
+ if (key === "") continue;
+
+ const existing = merged.get(key);
+ if (!existing) {
+ merged.set(key, {
+ path: resolved,
+ sources: [candidate.source],
+ threadCount: candidate.threadCount,
+ lastActiveAtMs: candidate.lastActiveAtMs,
+ git: gitIdentities.get(key) ?? null,
+ });
+ continue;
+ }
+ if (!existing.sources.includes(candidate.source)) {
+ existing.sources.push(candidate.source);
+ }
+ existing.threadCount += candidate.threadCount;
+ existing.lastActiveAtMs =
+ existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null
+ ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs)
+ : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs);
+ }
+
+ // Resolve persisted roots too. A project and a transcript can name
+ // different symlinks to the same directory.
+ const shellSnapshot = yield* projectionSnapshotQuery
+ .getShellSnapshot()
+ .pipe(
+ Effect.mapError(
+ (cause) => new AgentSessionScanError({ operation: "read-projects", cause }),
+ ),
+ );
+ const importedProjectsByRoot = new Map();
+ for (const project of shellSnapshot.projects) {
+ const projectRoot = path.resolve(expandHomePath(project.workspaceRoot));
+ importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project);
+ importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project);
+ }
+
+ const candidates: Array = [];
+ for (const [key, entry] of merged.entries()) {
+ // Keep the path key for missing roots and use filesystem identity for
+ // aliases that resolve to the same directory.
+ const importedProject =
+ importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ??
+ importedProjectsByRoot.get(key);
+ const candidatePath = importedProject?.workspaceRoot ?? entry.path;
+ candidates.push({
+ path: candidatePath,
+ title: path.basename(candidatePath) || candidatePath,
+ ...(importedProject === undefined ? {} : { projectId: importedProject.id }),
+ sources: entry.sources,
+ threadCount: entry.threadCount,
+ lastActiveAt:
+ entry.lastActiveAtMs === null
+ ? null
+ : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)),
+ alreadyImported: importedProject !== undefined,
+ git: entry.git,
+ });
+ }
+
+ // Newest first, undated candidates last.
+ candidates.sort((left, right) => {
+ if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path);
+ if (left.lastActiveAt === null) return 1;
+ if (right.lastActiveAt === null) return -1;
+ return right.lastActiveAt.localeCompare(left.lastActiveAt);
+ });
+
+ return {
+ candidates,
+ scannedAt: DateTime.formatIso(yield* DateTime.now),
+ ...(truncated ? { truncated: true } : {}),
+ };
+ });
+
+ const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* (
+ workspaceRoot: string,
+ completedSources: ReadonlyArray,
+ ) {
+ const root = path.resolve(expandHomePath(workspaceRoot));
+ const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root));
+ if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty;
+ const rootIdentity = yield* directoryIdentity(root);
+ const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
+ const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS;
+
+ const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates;
+ cachedCandidates = candidates;
+
+ const eligibleTranscripts: Array<{
+ readonly candidate: RawCandidate;
+ readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number };
+ }> = [];
+ for (const candidate of candidates) {
+ const expanded = expandHomePath(candidate.cwd.trim());
+ if (!path.isAbsolute(expanded)) continue;
+ const resolved = path.resolve(expanded);
+ if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue;
+
+ for (const transcript of candidate.transcripts) {
+ if (
+ transcript.mtimeMs === null ||
+ transcript.mtimeMs < cutoffMs ||
+ transcript.mtimeMs > nowMs
+ ) {
+ continue;
+ }
+ eligibleTranscripts.push({
+ candidate,
+ transcript: { ...transcript, mtimeMs: transcript.mtimeMs },
+ });
+ }
+ }
+
+ eligibleTranscripts.sort((left, right) => {
+ if (left.transcript.mtimeMs !== right.transcript.mtimeMs) {
+ return right.transcript.mtimeMs - left.transcript.mtimeMs;
+ }
+ return left.transcript.filePath.localeCompare(right.transcript.filePath);
+ });
+
+ const completedByFile = Map.groupBy(
+ completedSources,
+ (source) => `${source.providerInstanceId}\0${source.filePath}`,
+ );
+ const importedSessions = new Set();
+ let bytesRemaining = MAX_IMPORT_BYTES;
+ let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS;
+ let recordsRemaining = MAX_IMPORT_RECORDS;
+ return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe(
+ Stream.mapEffect(({ candidate, transcript }) =>
+ Effect.gen(function* () {
+ const completed = completedByFile.get(
+ `${candidate.providerInstanceId}\0${transcript.filePath}`,
+ );
+ if (
+ completed === undefined &&
+ (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0)
+ ) {
+ return Option.some({ _tag: "Skipped" });
+ }
+ const stats = yield* statOption(transcript.filePath);
+ if (Option.isNone(stats) || stats.value.type !== "File") {
+ return Option.some({ _tag: "Skipped" });
+ }
+ const identity = transcriptIdentity(transcript.filePath, stats.value);
+ const completedSource = completed?.find(
+ (source) =>
+ source.provider === candidate.source && sameTranscriptIdentity(source, identity),
+ );
+ if (completedSource !== undefined) {
+ const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`;
+ if (importedSessions.has(sessionKey)) return Option.none();
+ importedSessions.add(sessionKey);
+ return Option.some({
+ _tag: "AlreadyImported",
+ source: completedSource,
+ });
+ }
+ if (
+ transcriptsRemaining === 0 ||
+ recordsRemaining === 0 ||
+ identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES ||
+ identity.size > bytesRemaining
+ ) {
+ return Option.some({ _tag: "Skipped" });
+ }
+ // Reserve the whole file even if its read or parse fails.
+ transcriptsRemaining -= 1;
+ bytesRemaining -= identity.size;
+ const snapshot = yield* readTranscript(
+ transcript.filePath,
+ identity,
+ recordsRemaining,
+ candidate.source,
+ );
+ if (snapshot === null) {
+ return Option.some({ _tag: "Skipped" });
+ }
+ recordsRemaining -= snapshot.recordCount;
+
+ // A stable replacement file can belong to a different project than the cached candidate.
+ let snapshotCwd: string | null = null;
+ for (const record of snapshot.records) {
+ snapshotCwd = extractDecodedCwd(record);
+ if (snapshotCwd !== null) break;
+ }
+ if (snapshotCwd === null) {
+ return Option.some({ _tag: "Skipped" });
+ }
+ const expandedCwd = expandHomePath(snapshotCwd.trim());
+ if (
+ !path.isAbsolute(expandedCwd) ||
+ (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity
+ ) {
+ return Option.some({ _tag: "Skipped" });
+ }
+
+ const parsedThread = parseAgentSessionRecords(
+ {
+ source: candidate.source,
+ providerInstanceId: candidate.providerInstanceId,
+ fallbackSessionId: path.basename(transcript.filePath, ".jsonl"),
+ lastActiveAtMs: transcript.mtimeMs,
+ },
+ snapshot.records,
+ );
+ if (parsedThread === null) {
+ return Option.some({ _tag: "Skipped" });
+ }
+
+ const source: AgentSessionImportSource = {
+ ...identity,
+ provider: parsedThread.source,
+ providerInstanceId: parsedThread.providerInstanceId,
+ providerSessionId: parsedThread.providerSessionId,
+ };
+ const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`;
+ if (importedSessions.has(sessionKey)) {
+ return Option.some({ _tag: "Duplicate", source });
+ }
+ importedSessions.add(sessionKey);
+ return Option.some({
+ _tag: "Importable",
+ thread: parsedThread,
+ source,
+ });
+ }).pipe(importReadLock.withPermits(1)),
+ ),
+ Stream.map(Option.toArray),
+ Stream.flattenIterable,
+ );
+ });
+
+ const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = (
+ workspaceRoot,
+ completedSources = [],
+ ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources));
+
+ return AgentSessionScanner.of({ scan, recentThreads });
+});
+
+export const layer = Layer.effect(AgentSessionScanner, make);
diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts
index 988f1fc9c..35430e9c2 100644
--- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts
+++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts
@@ -42,6 +42,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
getProjectShellById: (projectId) =>
Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.die("unused"),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadRuntimeContext: () => Effect.die("unused"),
diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts
index d3dfac17c..266146579 100644
--- a/apps/server/src/provider/Drivers/ClaudeDriver.ts
+++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts
@@ -33,6 +33,7 @@ import { retainUsageLimits } from "../providerUsageRetention.ts";
import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
import { ServerConfig } from "../../config.ts";
+import { expandHomePath } from "../../pathExpansion.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
@@ -136,7 +137,11 @@ export const ClaudeDriver: ProviderDriver = {
driverKind: DRIVER_KIND,
instanceId,
});
- const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;
+ const effectiveConfig = {
+ ...config,
+ enabled,
+ binaryPath: expandHomePath(config.binaryPath),
+ } satisfies ClaudeSettings;
const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
binaryPath: effectiveConfig.binaryPath,
diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts
index 10bd7ce66..deedcb1e0 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.ts
@@ -39,6 +39,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
import { ServerConfig } from "../../config.ts";
+import { expandHomePath } from "../../pathExpansion.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
@@ -170,6 +171,7 @@ export const CodexDriver: ProviderDriver = {
const effectiveConfig = {
...config,
enabled,
+ binaryPath: expandHomePath(config.binaryPath),
homePath: homeLayout.effectiveHomePath ?? "",
} satisfies CodexSettings;
const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts
index 4d30ba28d..24445301b 100644
--- a/apps/server/src/provider/Layers/CodexAdapter.test.ts
+++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts
@@ -226,6 +226,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea
const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
upsert: () => Effect.void,
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () =>
Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
getBinding: () => Effect.succeed(Option.none()),
diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
index 2569dc688..87cf94043 100644
--- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
+++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
@@ -521,6 +521,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
upsert: () => Effect.void,
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () =>
Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
getBinding: () => Effect.succeed(Option.none()),
diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
index 0244def64..d5e4781d5 100644
--- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
+++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
@@ -30,7 +30,6 @@ import * as NodePath from "node:path";
import { describe, expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
-import * as Path from "effect/Path";
import {
type ClaudeSettings,
type CodexSettings,
@@ -45,10 +44,11 @@ import {
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import { createModelSelection } from "@t3tools/shared/model";
-import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
import * as Stream from "effect/Stream";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";
@@ -56,6 +56,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
import type { BuiltInDriversEnv } from "../builtInDrivers.ts";
import { ServerConfig } from "../../config.ts";
+import { expandHomePath } from "../../pathExpansion.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
import { CodexDriver } from "../Drivers/CodexDriver.ts";
@@ -168,6 +169,80 @@ const makePrimeAgentConfig = (overrides: Partial): PrimeAgen
...overrides,
});
+const makeTildeProviderFixtures = Effect.fn(
+ "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures",
+)(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const homePath = expandHomePath("~");
+ const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({
+ directory: homePath,
+ prefix: ".t3-provider-path-test-",
+ });
+ const codexPath = path.join(fixtureDir, "codex");
+ const claudePath = path.join(fixtureDir, "claude");
+ const claudeHomePath = path.join(fixtureDir, "claude-home");
+ const codexScriptPath = path.join(fixtureDir, "codex-script.json");
+ const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures");
+
+ yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath);
+ yield* fileSystem.copyFile(
+ path.join(codexFixtureDir, "codexCollabMockPeer.mjs"),
+ path.join(fixtureDir, "codexCollabMockPeer.mjs"),
+ );
+ yield* fileSystem.copyFile(
+ path.join(codexFixtureDir, "codexMultiAgentWire.json"),
+ path.join(fixtureDir, "codexMultiAgentWire.json"),
+ );
+ yield* fileSystem.writeFileString(
+ codexScriptPath,
+ // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer.
+ JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }),
+ );
+ yield* fileSystem.chmod(codexPath, 0o755);
+
+ yield* fileSystem.writeFileString(
+ claudePath,
+ [
+ "#!/usr/bin/env node",
+ 'import * as NodeReadline from "node:readline";',
+ 'if (process.argv.includes("--version")) {',
+ ' process.stdout.write("claude 2.1.219\\n");',
+ " process.exit(0);",
+ "}",
+ "const lines = NodeReadline.createInterface({ input: process.stdin });",
+ 'lines.on("line", (line) => {',
+ " const message = JSON.parse(line);",
+ ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;',
+ " process.stdout.write(JSON.stringify({",
+ ' type: "control_response",',
+ " response: {",
+ ' subtype: "success",',
+ " request_id: message.request_id,",
+ " response: {",
+ " commands: [], agents: [], models: [],",
+ ' output_style: "default", available_output_styles: ["default"],',
+ ' account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },',
+ " },",
+ " },",
+ ' }) + "\\n");',
+ "});",
+ "setInterval(() => {}, 1_000);",
+ "",
+ ].join("\n"),
+ );
+ yield* fileSystem.chmod(claudePath, 0o755);
+ yield* fileSystem.makeDirectory(claudeHomePath);
+
+ const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`;
+ return {
+ codexBinaryPath: asTildePath(codexPath),
+ claudeBinaryPath: asTildePath(claudePath),
+ claudeHomePath,
+ codexScriptPath,
+ };
+});
+
describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
// `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch
// directory. `Layer.merge` just unions requirements, so we have to push
@@ -290,6 +365,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
}).pipe(Effect.provide(testLayer)),
);
+ it.live("runs Codex and Claude readiness probes from configured tilde paths", () =>
+ Effect.gen(function* () {
+ if (yield* isHostWindows) return;
+
+ const fixtures = yield* makeTildeProviderFixtures();
+
+ const codexId = ProviderInstanceId.make("codex_tilde");
+ const claudeId = ProviderInstanceId.make("claude_tilde");
+ const configMap: ProviderInstanceConfigMap = {
+ [codexId]: {
+ driver: ProviderDriverKind.make("codex"),
+ enabled: true,
+ environment: [
+ {
+ name: "T3_CODEX_COLLAB_SCRIPT",
+ value: fixtures.codexScriptPath,
+ sensitive: false,
+ },
+ ],
+ config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }),
+ },
+ [claudeId]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ enabled: true,
+ config: makeClaudeConfig({
+ enabled: true,
+ binaryPath: fixtures.claudeBinaryPath,
+ homePath: fixtures.claudeHomePath,
+ }),
+ },
+ };
+
+ const { registry } = yield* makeProviderInstanceRegistry({
+ drivers: [CodexDriver, ClaudeDriver],
+ configMap,
+ });
+ const codex = yield* registry.getInstance(codexId);
+ const claude = yield* registry.getInstance(claudeId);
+ expect(codex).toBeDefined();
+ expect(claude).toBeDefined();
+
+ const [codexSnapshot, claudeSnapshot] = yield* Effect.all(
+ [codex!.snapshot.refresh, claude!.snapshot.refresh],
+ { concurrency: "unbounded" },
+ );
+ expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" });
+ expect(claudeSnapshot).toMatchObject({
+ status: "ready",
+ installed: true,
+ version: "2.1.219",
+ });
+ }).pipe(Effect.provide(testLayer)),
+ );
+
it.live(
"shadows instances whose driver is not registered in this build without failing boot",
() =>
diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts
index 46d8778e0..4290af1c4 100644
--- a/apps/server/src/provider/Layers/ProviderService.test.ts
+++ b/apps/server/src/provider/Layers/ProviderService.test.ts
@@ -6164,6 +6164,7 @@ const getBinding = vi.fn((threadId: ThreadId) =>
const boundedListing = makeProviderServiceLayer({
directory: {
upsert: () => Effect.void,
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"),
removeExact: () => Effect.die("ProviderService.listSessions does not use removeExact"),
getBinding,
diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts
index 134424a62..f3ed6f6a8 100644
--- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts
+++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts
@@ -4,9 +4,13 @@ import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeServices from "@effect/platform-node/NodeServices";
-import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
-import { it, assert } from "@effect/vitest";
-import { assertSome } from "@effect/vitest/utils";
+import {
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ThreadId,
+ type AgentSessionImportSource,
+} from "@t3tools/contracts";
+import { assert, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
@@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim
import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
+const importedSource = {
+ provider: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ providerSessionId: "provider-session",
+ filePath: "/tmp/provider-session.jsonl",
+ size: 100,
+ mtimeMs: 1_000,
+ device: 1,
+ inode: 123,
+ birthtimeMs: 500,
+} satisfies AgentSessionImportSource;
+
function makeDirectoryLayer(persistenceLayer: Layer.Layer) {
const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer));
return Layer.mergeAll(
+ persistenceLayer,
runtimeRepositoryLayer,
ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)),
NodeServices.layer,
@@ -30,7 +47,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer {
- it("upserts and reads thread bindings", () =>
+ it.effect("upserts and reads thread bindings", () =>
Effect.gen(function* () {
const directory = yield* ProviderSessionDirectory;
const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
@@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
yield* directory.upsert({
provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
threadId: initialThreadId,
});
const provider = yield* directory.getProvider(initialThreadId);
assert.equal(provider, "codex");
const resolvedBinding = yield* directory.getBinding(initialThreadId);
- assertSome(resolvedBinding, {
+ expect(Option.getOrThrow(resolvedBinding)).toMatchObject({
threadId: initialThreadId,
provider: ProviderDriverKind.make("codex"),
});
@@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
yield* directory.upsert({
provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
threadId: nextThreadId,
});
const updatedBinding = yield* directory.getBinding(nextThreadId);
@@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
}
const threadIds = yield* directory.listThreadIds();
- assert.deepEqual(threadIds, [nextThreadId]);
- }));
+ expect(threadIds).toEqual(expect.arrayContaining([initialThreadId, nextThreadId]));
+ }),
+ );
- it("persists runtime fields and merges payload updates", () =>
+ it.effect("persists runtime fields and merges payload updates", () =>
Effect.gen(function* () {
const directory = yield* ProviderSessionDirectory;
const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
@@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
yield* directory.upsert({
provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
threadId,
status: "starting",
resumeCursor: {
@@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
yield* directory.upsert({
provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
threadId,
status: "running",
runtimePayload: {
@@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
activeTurnId: "turn-1",
});
}
- }));
+ }),
+ );
+
+ it.effect("keeps the existing binding when an insert conflicts", () =>
+ Effect.gen(function* () {
+ const directory = yield* ProviderSessionDirectory;
+ const threadId = ThreadId.make("thread-insert-conflict");
+
+ yield* directory.upsert({
+ provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ threadId,
+ status: "running",
+ resumeCursor: { threadId: "active-provider-thread" },
+ });
+
+ yield* directory.upsert(
+ {
+ provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ threadId,
+ status: "stopped",
+ resumeCursor: { threadId: "stale-provider-thread" },
+ },
+ { onConflict: "ignore" },
+ );
+
+ const binding = yield* directory.getBinding(threadId);
+ expect(Option.getOrThrow(binding)).toMatchObject({
+ threadId,
+ status: "running",
+ resumeCursor: { threadId: "active-provider-thread" },
+ });
+ }),
+ );
+
+ it.effect("records source files without replacing the current provider session", () =>
+ Effect.gen(function* () {
+ const directory = yield* ProviderSessionDirectory;
+ const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
+ const source = { ...importedSource, providerSessionId: "record-source" };
+ const threadId = ThreadId.make(
+ `import:${source.providerInstanceId}:${source.providerSessionId}`,
+ );
+ const runtimePayload = { cwd: "/tmp/project", activeTurnId: "active-turn" };
+ yield* directory.upsert({
+ threadId,
+ provider: ProviderDriverKind.make("claudeAgent"),
+ providerInstanceId: ProviderInstanceId.make("claude-current"),
+ status: "running",
+ resumeCursor: { resume: "current-native-session" },
+ runtimePayload,
+ });
+ const before = Option.getOrThrow(yield* repository.getByThreadId({ threadId }));
+
+ yield* directory.recordImportedTranscript({ threadId, source });
+ const replacement = { ...source, size: 200, mtimeMs: 2_000 };
+ yield* directory.recordImportedTranscript({ threadId, source: replacement });
+ const secondFile = { ...source, filePath: "/tmp/provider-session-copy.jsonl" };
+ yield* directory.recordImportedTranscript({ threadId, source: secondFile });
+
+ expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({
+ ...before,
+ runtimePayload: { ...runtimePayload, importedTranscripts: [replacement, secondFile] },
+ });
+ }),
+ );
+
+ it.effect("does not create a binding when recording an imported transcript", () =>
+ Effect.gen(function* () {
+ const directory = yield* ProviderSessionDirectory;
+ const threadId = ThreadId.make("import:codex:missing-source-binding");
+
+ yield* directory.recordImportedTranscript({ threadId, source: importedSource });
+
+ expect(Option.isNone(yield* directory.getBinding(threadId))).toBe(true);
+ }),
+ );
+
+ it.effect("keeps newly recorded sources when a runtime write uses a stale payload", () =>
+ Effect.gen(function* () {
+ const directory = yield* ProviderSessionDirectory;
+ const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
+ const firstSource = { ...importedSource, providerSessionId: "stale-source" };
+ const threadId = ThreadId.make(
+ `import:${firstSource.providerInstanceId}:${firstSource.providerSessionId}`,
+ );
+ yield* directory.upsert({
+ threadId,
+ provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ status: "stopped",
+ resumeCursor: { threadId: "original-native-session" },
+ runtimePayload: { cwd: "/tmp/stale-source-project" },
+ });
+ yield* directory.recordImportedTranscript({ threadId, source: firstSource });
+ const stale = Option.getOrThrow(yield* repository.getByThreadId({ threadId }));
+ const secondSource = { ...firstSource, filePath: "/tmp/stale-source-copy.jsonl" };
+ yield* directory.recordImportedTranscript({ threadId, source: secondSource });
+
+ yield* repository.upsert({
+ ...stale,
+ status: "running",
+ resumeCursor: { threadId: "new-native-session" },
+ lastSeenAt: "2026-08-24T10:00:00.000Z",
+ });
+
+ expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({
+ ...stale,
+ status: "running",
+ resumeCursor: { threadId: "new-native-session" },
+ lastSeenAt: "2026-08-24T10:00:00.000Z",
+ runtimePayload: {
+ cwd: "/tmp/stale-source-project",
+ importedTranscripts: [firstSource, secondSource],
+ },
+ });
+ }),
+ );
+
+ it.effect("reserves imported source records for the atomic recording method", () =>
+ Effect.gen(function* () {
+ const directory = yield* ProviderSessionDirectory;
+ for (const onConflict of ["update", "ignore"] as const) {
+ const source = { ...importedSource, providerSessionId: `reserved-source-${onConflict}` };
+ const threadId = ThreadId.make(
+ `import:${source.providerInstanceId}:${source.providerSessionId}`,
+ );
+ const binding = {
+ threadId,
+ provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ };
+ yield* directory.upsert(
+ { ...binding, runtimePayload: { cwd: "/tmp/project", importedTranscripts: [source] } },
+ { onConflict },
+ );
+ expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({
+ cwd: "/tmp/project",
+ });
+
+ yield* directory.recordImportedTranscript({ threadId, source });
+ yield* directory.upsert({ ...binding, runtimePayload: null });
+
+ expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({
+ importedTranscripts: [source],
+ });
+ }
+ }),
+ );
- it("lists persisted bindings with metadata in oldest-first order", () =>
+ it.effect("lists persisted bindings with metadata in oldest-first order", () =>
Effect.gen(function* () {
const directory = yield* ProviderSessionDirectory;
const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
@@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
},
});
- const bindings = yield* directory.listBindings();
+ const bindings = (yield* directory.listBindings()).filter(
+ (binding) => binding.threadId === olderThreadId || binding.threadId === newerThreadId,
+ );
assert.deepEqual(bindings, [
{
threadId: olderThreadId,
provider: ProviderDriverKind.make("claudeAgent"),
+ providerInstanceId: ProviderInstanceId.make("claudeAgent"),
adapterKey: "claudeAgent",
runtimeMode: "approval-required",
status: "starting",
@@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
{
threadId: newerThreadId,
provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
adapterKey: "codex",
runtimeMode: "full-access",
status: "running",
@@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
},
},
]);
- }));
+ }),
+ );
- it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () =>
- Effect.gen(function* () {
- const directory = yield* ProviderSessionDirectory;
- const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
- const threadId = ThreadId.make("thread-provider-change");
+ it.effect(
+ "resets adapterKey to the new provider when provider changes without an explicit adapter key",
+ () =>
+ Effect.gen(function* () {
+ const directory = yield* ProviderSessionDirectory;
+ const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
+ const threadId = ThreadId.make("thread-provider-change");
- yield* runtimeRepository.upsert({
- threadId,
- providerName: "claudeAgent",
- providerInstanceId: null,
- adapterKey: "claudeAgent",
- runtimeMode: "full-access",
- status: "running",
- lastSeenAt: "2026-01-01T00:00:00.000Z",
- resumeCursor: null,
- runtimePayload: null,
- });
+ yield* runtimeRepository.upsert({
+ threadId,
+ providerName: "claudeAgent",
+ providerInstanceId: null,
+ adapterKey: "claudeAgent",
+ runtimeMode: "full-access",
+ status: "running",
+ lastSeenAt: "2026-01-01T00:00:00.000Z",
+ resumeCursor: null,
+ runtimePayload: null,
+ });
- yield* directory.upsert({
- provider: ProviderDriverKind.make("codex"),
- threadId,
- });
+ yield* directory.upsert({
+ provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ threadId,
+ });
- const runtime = yield* runtimeRepository.getByThreadId({ threadId });
- assert.equal(Option.isSome(runtime), true);
- if (Option.isSome(runtime)) {
- assert.equal(runtime.value.providerName, "codex");
- assert.equal(runtime.value.adapterKey, "codex");
- }
- }));
+ const runtime = yield* runtimeRepository.getByThreadId({ threadId });
+ assert.equal(Option.isSome(runtime), true);
+ if (Option.isSome(runtime)) {
+ assert.equal(runtime.value.providerName, "codex");
+ assert.equal(runtime.value.adapterKey, "codex");
+ }
+ }),
+ );
- it("rehydrates persisted mappings across layer restart", () =>
+ it.effect("rehydrates persisted mappings across layer restart", () =>
Effect.gen(function* () {
const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-"));
const dbPath = NodePath.join(tempDir, "orchestration.sqlite");
@@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
const directory = yield* ProviderSessionDirectory;
yield* directory.upsert({
provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
threadId,
});
}).pipe(Effect.provide(directoryLayer));
@@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
assert.equal(provider, "codex");
const resolvedBinding = yield* directory.getBinding(threadId);
- assertSome(resolvedBinding, {
+ expect(Option.getOrThrow(resolvedBinding)).toMatchObject({
threadId,
provider: ProviderDriverKind.make("codex"),
});
@@ -267,8 +448,48 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
}).pipe(Effect.provide(directoryLayer));
NodeFS.rmSync(tempDir, { recursive: true, force: true });
- }));
- it("skips a session binding when its private commit guard retires", () =>
+ }),
+ );
+
+ it.effect("applies the commit guard before an insert-ignore binding write", () =>
+ Effect.gen(function* () {
+ const directory = yield* ProviderSessionDirectory;
+ const binding = (threadId: ThreadId, resume: string) => ({
+ provider: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ threadId,
+ status: "stopped" as const,
+ resumeCursor: { threadId: resume },
+ });
+
+ const retiredThreadId = ThreadId.make("import:codex:guard-retired");
+ yield* directory.upsert(binding(retiredThreadId, "retired-session"), {
+ commitGuard: Effect.succeed(false),
+ onConflict: "ignore",
+ });
+ assert.isTrue(Option.isNone(yield* directory.getBinding(retiredThreadId)));
+
+ const currentThreadId = ThreadId.make("import:codex:guard-current");
+ yield* directory.upsert(binding(currentThreadId, "imported-session"), {
+ commitGuard: Effect.succeed(true),
+ onConflict: "ignore",
+ });
+ expect(Option.getOrThrow(yield* directory.getBinding(currentThreadId))).toMatchObject({
+ resumeCursor: { threadId: "imported-session" },
+ });
+
+ // A passing guard still cannot replace a binding that already exists.
+ yield* directory.upsert(binding(currentThreadId, "stale-session"), {
+ commitGuard: Effect.succeed(true),
+ onConflict: "ignore",
+ });
+ expect(Option.getOrThrow(yield* directory.getBinding(currentThreadId))).toMatchObject({
+ resumeCursor: { threadId: "imported-session" },
+ });
+ }),
+ );
+
+ it.effect("skips a session binding when its private commit guard retires", () =>
Effect.gen(function* () {
const directory = yield* ProviderSessionDirectory;
const threadId = ThreadId.make("thread-retired-generation");
@@ -283,5 +504,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
);
assert.isTrue(Option.isNone(yield* directory.getBinding(threadId)));
- }));
+ }),
+ );
});
diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts
index 625e6857c..e0b707d97 100644
--- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts
+++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts
@@ -134,27 +134,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
}
if (options?.commitGuard !== undefined && !(yield* options.commitGuard)) return;
yield* repository
- .upsert({
- threadId: resolvedThreadId,
- providerName: binding.provider,
- providerInstanceId,
- adapterKey:
- binding.adapterKey ??
- (providerChanged
- ? binding.provider
- : (existingRuntime?.adapterKey ?? binding.provider)),
- runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",
- status: binding.status ?? existingRuntime?.status ?? "running",
- lastSeenAt: now,
- resumeCursor:
- binding.resumeCursor !== undefined
- ? binding.resumeCursor
- : (existingRuntime?.resumeCursor ?? null),
- runtimePayload: mergeRuntimePayload(
- existingRuntime?.runtimePayload ?? null,
- binding.runtimePayload,
- ),
- })
+ .upsert(
+ {
+ threadId: resolvedThreadId,
+ providerName: binding.provider,
+ providerInstanceId,
+ adapterKey:
+ binding.adapterKey ??
+ (providerChanged
+ ? binding.provider
+ : (existingRuntime?.adapterKey ?? binding.provider)),
+ runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",
+ status: binding.status ?? existingRuntime?.status ?? "running",
+ lastSeenAt: now,
+ resumeCursor:
+ binding.resumeCursor !== undefined
+ ? binding.resumeCursor
+ : (existingRuntime?.resumeCursor ?? null),
+ runtimePayload: mergeRuntimePayload(
+ existingRuntime?.runtimePayload ?? null,
+ binding.runtimePayload,
+ ),
+ },
+ options,
+ )
.pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));
}),
);
@@ -211,6 +214,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
),
);
+ const recordImportedTranscript: ProviderSessionDirectoryShape["recordImportedTranscript"] = (
+ input,
+ ) =>
+ repository
+ .recordImportedTranscript(input)
+ .pipe(
+ Effect.mapError(toPersistenceError("ProviderSessionDirectory.recordImportedTranscript")),
+ );
+
const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () =>
repository.list().pipe(
Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")),
@@ -231,6 +243,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
return {
upsert,
+ recordImportedTranscript,
getProvider,
getBinding,
removeExact,
diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts
index eb2e10106..012faadf0 100644
--- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts
+++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts
@@ -263,6 +263,7 @@ describe("ProviderSessionReaper", () => {
getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
getProjectShellById: () => Effect.die("unused"),
getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.die("unused"),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadRuntimeContext: () => Effect.die("unused"),
diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts
index 7ac3f2f28..7d6bbe61a 100644
--- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts
+++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts
@@ -1,8 +1,55 @@
-import { describe, expect, it } from "vite-plus/test";
+import * as NodeOS from "node:os";
+
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Path from "effect/Path";
import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts";
describe("mergeProviderInstanceEnvironment", () => {
+ it.effect.each([
+ { value: "~/.account", tail: ".account" },
+ { value: "~\\.account\\work", tail: ".account\\work" },
+ ])("expands configured provider homes set to $value", ({ value, tail }) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const baseEnv = {
+ CODEX_HOME: "~/.inherited-codex",
+ CLAUDE_CONFIG_DIR: "~/.inherited-claude",
+ };
+ const environment = mergeProviderInstanceEnvironment(
+ [
+ { name: "CODEX_HOME", value, sensitive: false },
+ { name: "CLAUDE_CONFIG_DIR", value, sensitive: false },
+ { name: "CUSTOM_VALUE", value, sensitive: false },
+ ],
+ baseEnv,
+ );
+
+ expect(environment).toEqual({
+ CODEX_HOME: path.join(NodeOS.homedir(), tail),
+ CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), tail),
+ CUSTOM_VALUE: value,
+ });
+ expect(baseEnv).toEqual({
+ CODEX_HOME: "~/.inherited-codex",
+ CLAUDE_CONFIG_DIR: "~/.inherited-claude",
+ });
+ }).pipe(Effect.provide(NodeServices.layer)),
+ );
+
+ it("leaves inherited provider homes unchanged", () => {
+ const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" };
+
+ expect(
+ mergeProviderInstanceEnvironment(
+ [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }],
+ baseEnv,
+ ),
+ ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" });
+ });
+
it("overrides inherited environment values and preserves empty strings", () => {
expect(
mergeProviderInstanceEnvironment(
diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts
index e46925360..77c0c6c2d 100644
--- a/apps/server/src/provider/ProviderInstanceEnvironment.ts
+++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts
@@ -1,5 +1,7 @@
import type { ProviderInstanceEnvironment } from "@t3tools/contracts";
+import { expandHomePath } from "../pathExpansion.ts";
+
export function mergeProviderInstanceEnvironment(
environment: ProviderInstanceEnvironment | undefined,
baseEnv: NodeJS.ProcessEnv = process.env,
@@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment(
const next: NodeJS.ProcessEnv = { ...baseEnv };
for (const variable of environment) {
- next[variable.name] = variable.value;
+ // Child processes do not apply shell expansion to environment values.
+ next[variable.name] =
+ variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR"
+ ? expandHomePath(variable.value)
+ : variable.value;
}
return next;
}
diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts
index b995a1257..807b0706a 100644
--- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts
+++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts
@@ -1,4 +1,5 @@
import type {
+ AgentSessionImportSource,
ProviderInstanceId,
ProviderDriverKind,
ProviderSessionRuntimeStatus,
@@ -41,12 +42,24 @@ export type ProviderSessionDirectoryWriteError =
| ProviderValidationError
| ProviderSessionDirectoryPersistenceError;
+export interface ProviderSessionDirectoryUpsertOptions {
+ /** Checked inside the directory's mutation permit; `false` skips the write. */
+ readonly commitGuard?: Effect.Effect | undefined;
+ readonly onConflict?: "update" | "ignore";
+}
+
export interface ProviderSessionDirectoryShape {
readonly upsert: (
binding: ProviderRuntimeBinding,
- options?: { readonly commitGuard?: Effect.Effect | undefined },
+ options?: ProviderSessionDirectoryUpsertOptions,
) => Effect.Effect;
+ /** Record an imported file without changing the current provider session. */
+ readonly recordImportedTranscript: (input: {
+ readonly threadId: ThreadId;
+ readonly source: AgentSessionImportSource;
+ }) => Effect.Effect;
+
readonly getProvider: (
threadId: ThreadId,
) => Effect.Effect;
diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts
index 62b5f000f..9a5ea2e0b 100644
--- a/apps/server/src/provider/providerMaintenance.test.ts
+++ b/apps/server/src/provider/providerMaintenance.test.ts
@@ -25,6 +25,7 @@ import {
parseHomebrewLatestVersion,
ProviderVersionCache,
resolveLatestProviderVersion,
+ resolvePackageManagedProviderMaintenance,
resolveProviderMaintenanceCapabilitiesEffect,
type ProviderMaintenanceCapabilities,
} from "./providerMaintenance.ts";
@@ -304,6 +305,42 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
).toBeNull();
});
+ // The Codex Windows installer exposes `%LOCALAPPDATA%\\Programs\\OpenAI\\Codex\\bin`
+ // as a junction into `%CODEX_HOME%\\packages\\standalone\\current\\bin`. Node's
+ // realpath follows junctions, so the real path carries the standalone marker
+ // even though the visible path does not.
+ it.effect("recognizes a Windows standalone install through its junctioned bin dir", () =>
+ Effect.gen(function* () {
+ const visiblePath =
+ "C:\\Users\\Theo\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe";
+ const realPath =
+ "C:\\Users\\Theo\\.codex\\packages\\standalone\\releases\\0.120.0-x86_64\\bin\\codex.exe";
+ const capabilities = yield* resolvePackageManagedProviderMaintenance(
+ {
+ provider: driver("codex"),
+ npmPackageName: "@openai/codex",
+ nativeUpdate: {
+ args: ["update"],
+ isCommandPath: isNativeTestCommandPath("/packages/standalone/"),
+ },
+ },
+ {
+ binaryPath: "codex",
+ resolvedCommandPath: visiblePath,
+ realCommandPath: realPath,
+ env: {},
+ platform: "win32",
+ },
+ ).pipe(Effect.provideService(HostProcessPlatform, "win32"));
+
+ expect(capabilities.update).toMatchObject({
+ executable: visiblePath,
+ args: ["update"],
+ lockKey: "codex-native",
+ });
+ }),
+ );
+
it.effect("proves Windows npm ownership from the package manifest beside the shim", () =>
Effect.gen(function* () {
const tempDir = yield* makeTempDir("t3-npm-windows-capabilities");
diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs
index 4e6d1b261..fa567d75c 100644
--- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs
+++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs
@@ -57,6 +57,14 @@ rl.on("line", (line) => {
});
return;
}
+ if (method === "account/read") {
+ write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } });
+ return;
+ }
+ if (method === "skills/list" || method === "model/list") {
+ write({ id, result: { data: [] } });
+ return;
+ }
if (method === "thread/start") {
write({ id, result: fixture.responses.threadStart });
return;
diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts
index cf369ed0d..a3c3f884b 100644
--- a/apps/server/src/relay/AgentAwarenessRelay.test.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts
@@ -138,7 +138,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId);
});
- it("does not publish start intents, streaming content, or non-awareness activity events", () => {
+ it("does not publish imported, start-intent, streaming, or non-awareness events", () => {
const now = "2026-05-25T00:00:00.000Z";
const base = {
sequence: 1,
@@ -147,6 +147,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
aggregateKind: "thread",
aggregateId: "thread-1" as ThreadId,
occurredAt: now,
+ metadata: {},
};
expect(
@@ -202,6 +203,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
},
} as unknown as OrchestrationEvent),
).toBe(false);
+ expect(
+ AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
+ ...base,
+ type: "thread.created",
+ metadata: { historyImport: true },
+ payload: { threadId: "thread-1" as ThreadId },
+ } as unknown as OrchestrationEvent),
+ ).toBe(false);
+ expect(
+ AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
+ ...base,
+ type: "thread.settled",
+ metadata: { historyImport: true },
+ payload: { threadId: "thread-1" as ThreadId },
+ } as unknown as OrchestrationEvent),
+ ).toBe(false);
+ expect(
+ AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
+ ...base,
+ type: "thread.created",
+ payload: { threadId: "thread-1" as ThreadId },
+ } as unknown as OrchestrationEvent),
+ ).toBe(true);
+ expect(
+ AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
+ ...base,
+ type: "thread.settled",
+ payload: { threadId: "thread-1" as ThreadId },
+ } as unknown as OrchestrationEvent),
+ ).toBe(true);
});
it("deduplicates awareness state updates whose only change is their event timestamp", () => {
@@ -413,17 +444,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
).rejects.toBeDefined();
});
- it.effect("keeps the orchestration listener armed until relay config is installed", () =>
+ it.effect("keeps the listener armed and skips imported thread work", () =>
Effect.scoped(
Effect.gen(function* () {
const events = yield* Queue.unbounded();
const threadShellRequested = yield* Deferred.make();
+ const releaseThreadShell = yield* Deferred.make();
+ const threadShellRequests: Array = [];
+ let fetchCallCount = 0;
const secrets = makeMemorySecretStore();
const now = "2026-05-25T00:00:00.000Z";
const projectId = "project-1" as ProjectId;
const threadId = "thread-1" as ThreadId;
+ const importedThreadId = "import:codex:session-1" as ThreadId;
const environmentId = "env-1" as EnvironmentId;
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = (() => {
+ fetchCallCount += 1;
+ return Promise.resolve(Response.json({ ok: true, deliveries: [] }));
+ }) as unknown as typeof fetch;
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ globalThis.fetch = originalFetch;
+ }),
+ );
+
const project = {
id: projectId,
title: "Pylon",
@@ -486,15 +532,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
getShellSnapshot: () =>
Effect.succeed({
snapshotSequence: 1,
- projects: [project],
- threads: [thread],
+ projects: [],
+ threads: [],
updatedAt: now,
} satisfies OrchestrationShellSnapshot),
- getThreadShellById: () =>
- Deferred.succeed(threadShellRequested, undefined).pipe(
- Effect.ignore,
- Effect.as(Option.some(thread)),
- ),
+ getThreadShellById: (requestedThreadId: ThreadId) =>
+ Effect.gen(function* () {
+ threadShellRequests.push(requestedThreadId);
+ if (requestedThreadId !== threadId) return Option.none();
+ yield* Deferred.succeed(threadShellRequested, undefined);
+ yield* Deferred.await(releaseThreadShell);
+ return Option.some(thread);
+ }),
getProjectShellById: () => Effect.succeed(Option.some(project)),
} as unknown as ProjectionSnapshotQueryShape;
@@ -524,17 +573,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
yield* Effect.gen(function* () {
const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
yield* relay.start();
- yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1");
+ yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test");
yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential");
yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true");
yield* Queue.offer(events, {
- type: "thread.activity-appended",
+ type: "thread.created",
sequence: 1,
+ eventId: "evt-import-created",
+ commandId: CommandId.make("cmd-import-created"),
+ aggregateKind: "thread",
+ aggregateId: importedThreadId,
+ metadata: { historyImport: true },
+ payload: { threadId: importedThreadId },
+ occurredAt: now,
+ } as unknown as OrchestrationEvent);
+ yield* Queue.offer(events, {
+ type: "thread.settled",
+ sequence: 2,
+ eventId: "evt-import-settled",
+ commandId: CommandId.make("cmd-import-settled"),
+ aggregateKind: "thread",
+ aggregateId: importedThreadId,
+ metadata: { historyImport: true },
+ payload: { threadId: importedThreadId },
+ occurredAt: now,
+ } as unknown as OrchestrationEvent);
+ yield* Queue.offer(events, {
+ type: "thread.activity-appended",
+ sequence: 3,
eventId: "evt-1",
commandId: CommandId.make("cmd-1"),
aggregateKind: "thread",
aggregateId: threadId,
actor: { kind: "server" },
+ metadata: {},
payload: {
threadId,
activity: {
@@ -545,6 +617,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
} as unknown as OrchestrationEvent);
yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds"));
+ expect(threadShellRequests).toEqual([threadId]);
+ expect(fetchCallCount).toBe(0);
+ yield* Deferred.succeed(releaseThreadShell, undefined);
}).pipe(
Effect.provide(
AgentAwarenessRelay.layer.pipe(
@@ -704,6 +779,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
aggregateKind: "thread",
aggregateId: threadId,
actor: { kind: "server" },
+ metadata: {},
payload: {
threadId,
activity: {
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts
index 0ce64c6f2..43c2d952c 100644
--- a/apps/server/src/relay/AgentAwarenessRelay.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.ts
@@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null {
}
export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean {
+ if (event.metadata.historyImport === true) {
+ return false;
+ }
switch (event.type) {
case "thread.message-sent":
case "thread.turn-start-requested":
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index b2d02ea5a..ee3a12be2 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -41,6 +41,7 @@ import {
UsageLimitSourceId,
ServerProviderMutationBusyError,
ResolvedKeybindingRule,
+ type ServerLifecycleStreamEvent,
ThreadId,
TurnId,
WS_METHODS,
@@ -97,6 +98,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect(
const decodeTransferShellSnapshot = Schema.decodeUnknownEffect(
Schema.fromJsonString(OrchestrationShellSnapshot),
);
+const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
import * as ServerConfig from "./config.ts";
@@ -132,6 +134,7 @@ import {
ProviderValidationError,
} from "./provider/Errors.ts";
import * as ProviderService from "./provider/Services/ProviderService.ts";
+import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
import { ProviderAdapterRequestError } from "./provider/Errors.ts";
import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
@@ -493,6 +496,9 @@ const buildAppUnderTest = (options?: {
projectSetupScriptRunner?: Partial<
ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]
>;
+ providerSessionDirectory?: Partial<
+ ProviderSessionDirectory.ProviderSessionDirectory["Service"]
+ >;
terminalManager?: Partial;
orchestrationEngine?: Partial;
threadDeletionReactor?: Partial;
@@ -762,6 +768,13 @@ const buildAppUnderTest = (options?: {
uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"),
...options?.layers?.providerService,
}),
+ Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({
+ upsert: () => Effect.void,
+ getBinding: () => Effect.succeed(Option.none()),
+ listThreadIds: () => Effect.succeed([]),
+ listBindings: () => Effect.succeed([]),
+ ...options?.layers?.providerSessionDirectory,
+ }),
),
),
Layer.provide(
@@ -975,6 +988,7 @@ const buildAppUnderTest = (options?: {
}),
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
+ getImportedAgentSessionSources: () => Effect.succeed([]),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
...options?.layers?.projectionSnapshotQuery,
}),
@@ -5574,6 +5588,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
+ it.effect("keeps agent session import project failures structured over websocket rpc", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest();
+
+ const projectId = ProjectId.make("missing-import-project");
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const error = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.agentSessionsImport]({ projectId }).pipe(Effect.flip),
+ ),
+ );
+
+ assert.equal(error._tag, "AgentSessionImportProjectNotFoundError");
+ if (error._tag === "AgentSessionImportProjectNotFoundError") {
+ assert.equal(error.projectId, projectId);
+ }
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
+ it.effect("returns scanner skip counts over websocket rpc", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const codexHome = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-agent-import-rpc-codex-",
+ });
+ const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-agent-import-rpc-workspace-",
+ });
+ const transcriptDirectory = path.join(codexHome, "sessions", "2026", "08", "31");
+ const transcriptPath = path.join(transcriptDirectory, "rollout-skipped.jsonl");
+ yield* fileSystem.makeDirectory(transcriptDirectory, { recursive: true });
+ yield* fileSystem.writeFileString(
+ transcriptPath,
+ encodeTestJson({
+ timestamp: "2026-08-31T12:00:00.000Z",
+ type: "session_meta",
+ payload: { id: "rpc-skipped-session", cwd: workspaceRoot },
+ }),
+ );
+ yield* fileSystem.utimes(transcriptPath, 0, 0);
+
+ const projectId = ProjectId.make("agent-import-rpc-project");
+ const project = {
+ id: projectId,
+ title: "Agent import RPC",
+ workspaceRoot,
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: "2026-08-31T12:00:00.000Z",
+ updatedAt: "2026-08-31T12:00:00.000Z",
+ } as const;
+ yield* buildAppUnderTest({
+ layers: {
+ serverSettings: {
+ getSettings: Effect.succeed({
+ ...DEFAULT_SERVER_SETTINGS,
+ providerInstances: {
+ [ProviderInstanceId.make("codex")]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath: codexHome },
+ },
+ [ProviderInstanceId.make("claudeAgent")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ enabled: false,
+ config: {},
+ },
+ },
+ }),
+ },
+ projectionSnapshotQuery: {
+ getProjectShellById: (requestedProjectId) =>
+ Effect.succeed(
+ requestedProjectId === projectId ? Option.some(project) : Option.none(),
+ ),
+ },
+ },
+ });
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const result = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ Effect.gen(function* () {
+ const scan = yield* client[WS_METHODS.agentSessionsScan]({});
+ assert.deepEqual(
+ scan.candidates.map((candidate) => candidate.path),
+ [workspaceRoot],
+ );
+ return yield* client[WS_METHODS.agentSessionsImport]({ projectId });
+ }),
+ ),
+ );
+
+ assert.deepEqual(result, { importedCount: 0, skippedCount: 1 });
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
it.effect("uploads Codex thread feedback through websocket rpc", () =>
Effect.gen(function* () {
const input = {
@@ -6359,6 +6470,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
+ it.effect("subscribeServerLifecycle buffers updates published during snapshot capture", () =>
+ Effect.gen(function* () {
+ const pubsub = yield* PubSub.unbounded();
+ const streamSubscribed = yield* Deferred.make();
+ const snapshotPublished = yield* Deferred.make();
+ const bootstrapProjectId = ProjectId.make("project-bootstrap");
+ const bootstrapThreadId = ThreadId.make("thread-bootstrap");
+ const snapshotEvent = {
+ version: 1 as const,
+ sequence: 1,
+ type: "welcome" as const,
+ payload: {
+ environment: testEnvironmentDescriptor,
+ cwd: "/tmp/project",
+ projectName: "project",
+ bootstrapStatus: "pending" as const,
+ },
+ };
+ const gapEvent = {
+ version: 1 as const,
+ sequence: 2,
+ type: "welcome" as const,
+ payload: {
+ environment: testEnvironmentDescriptor,
+ cwd: "/tmp/project",
+ projectName: "project",
+ bootstrapStatus: "complete" as const,
+ bootstrapProjectId,
+ bootstrapThreadId,
+ bootstrapProjectCreated: true,
+ bootstrapThreadCreated: true,
+ },
+ };
+ const sentinelEvent = {
+ version: 1 as const,
+ sequence: 3,
+ type: "ready" as const,
+ payload: { at: "2026-01-01T00:00:01.000Z", environment: testEnvironmentDescriptor },
+ };
+ const liveStream = Stream.unwrap(
+ Effect.gen(function* () {
+ const subscription = yield* PubSub.subscribe(pubsub);
+ yield* Deferred.succeed(streamSubscribed, undefined);
+ return Stream.fromSubscription(subscription);
+ }),
+ );
+
+ yield* buildAppUnderTest({
+ layers: {
+ serverLifecycleEvents: {
+ snapshot: PubSub.publish(pubsub, gapEvent).pipe(
+ Effect.andThen(Deferred.succeed(snapshotPublished, undefined)),
+ Effect.as({ sequence: 1, events: [snapshotEvent] }),
+ ),
+ stream: liveStream,
+ },
+ },
+ });
+
+ yield* Effect.gen(function* () {
+ yield* Deferred.await(snapshotPublished);
+ yield* Deferred.await(streamSubscribed);
+ yield* PubSub.publish(pubsub, sentinelEvent);
+ }).pipe(Effect.forkScoped);
+
+ const wsUrl = yield* getWsServerUrl("/ws");
+ const events = yield* Effect.scoped(
+ withWsRpcClient(wsUrl, (client) =>
+ client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect),
+ ),
+ );
+
+ const [first, second] = Array.from(events);
+ assert.equal(first?.type, "welcome");
+ assert.equal(first?.sequence, 1);
+ if (first?.type !== "welcome") {
+ assert.fail("expected the pending bootstrap event");
+ }
+ assert.equal(first.payload.bootstrapStatus, "pending");
+ assert.equal(second?.type, "welcome");
+ assert.equal(second?.sequence, 2);
+ if (second?.type !== "welcome") {
+ assert.fail("expected the bootstrap completion event");
+ }
+ assert.equal(second.payload.bootstrapStatus, "complete");
+ assert.equal(second.payload.bootstrapProjectId, bootstrapProjectId);
+ assert.equal(second.payload.bootstrapThreadId, bootstrapThreadId);
+ assert.equal(second.payload.bootstrapProjectCreated, true);
+ assert.equal(second.payload.bootstrapThreadCreated, true);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
it.effect("routes websocket rpc projects.searchEntries", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts
index 6f592e0a3..4f88c5ec5 100644
--- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts
+++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts
@@ -186,6 +186,7 @@ it.effect("marks active running sessions that have persisted resume state", () =
),
upsert: (binding) => Effect.sync(() => upserts.push(binding)),
removeExact: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
@@ -315,6 +316,7 @@ it.effect.each(
),
),
removeExact: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
@@ -448,6 +450,7 @@ it.effect("does not continue archived or deleted marked sessions", () => {
},
upsert: () => Effect.void,
removeExact: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
@@ -504,6 +507,7 @@ it.effect("retries continuation preparation before settling a persistent failure
),
upsert: () => Effect.void,
removeExact: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
@@ -576,6 +580,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio
),
upsert: (binding) => Effect.sync(() => upserts.push(binding)),
removeExact: () => Effect.succeed(false),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
@@ -656,6 +661,7 @@ it.effect(
),
upsert: () => Effect.fail(writeFailure),
removeExact: () => Effect.succeed(false),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
@@ -694,6 +700,7 @@ it.effect("retries failed projections and continues after a persistent failure",
getBinding: () => Effect.succeed(Option.none()),
upsert: () => Effect.void,
removeExact: () => Effect.succeed(false),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
@@ -752,6 +759,7 @@ it.effect("runs restart adoption before taking the orphan inventory", () => {
getBinding: () => Effect.die("recovered thread must not be orphaned"),
upsert: () => Effect.die("recovered thread must not be orphaned"),
removeExact: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.die("unused"),
@@ -790,6 +798,7 @@ it.effect("does not fail startup when the live provider session inventory cannot
getBinding: () => Effect.die("unused"),
upsert: () => Effect.die("unused"),
removeExact: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
@@ -861,6 +870,7 @@ for (const scenario of [
upserts.push(binding);
}),
removeExact: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
@@ -935,6 +945,7 @@ for (const preparedStatus of [
yield* Deferred.succeed(cleared, undefined);
}),
removeExact: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () =>
@@ -1041,6 +1052,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", (
binding = next;
}),
removeExact: () => Effect.die("unused"),
+ recordImportedTranscript: () => Effect.die("unused"),
getProvider: () => Effect.die("unused"),
listThreadIds: () => Effect.die("unused"),
listBindings: () => Effect.succeed([]),
diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts
index 3266ba452..fc66b60ad 100644
--- a/apps/server/src/serverRuntimeStartup.test.ts
+++ b/apps/server/src/serverRuntimeStartup.test.ts
@@ -156,6 +156,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
),
getProjectShellById: () => Effect.die("unused"),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadRuntimeContext: () => Effect.die("unused"),
@@ -183,6 +184,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
assert.deepStrictEqual(targets, {
bootstrapProjectId,
bootstrapThreadId,
+ bootstrapProjectCreated: false,
+ bootstrapThreadCreated: false,
});
assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []);
});
@@ -215,6 +218,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.die("unused"),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadRuntimeContext: () => Effect.die("unused"),
@@ -241,6 +245,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
assert.equal(typeof targets.bootstrapProjectId, "string");
assert.equal(typeof targets.bootstrapThreadId, "string");
+ assert.equal(targets.bootstrapProjectCreated, true);
+ assert.equal(targets.bootstrapThreadCreated, true);
const commands = yield* Ref.get(dispatchCalls);
assert.deepStrictEqual(
commands.map((command) => command.type),
@@ -254,6 +260,62 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
}),
);
+it.effect(
+ "resolveAutoBootstrapWelcomeTargets preserves a project created before thread failure",
+ () =>
+ Effect.gen(function* () {
+ const dispatchCalls = yield* Ref.make>([]);
+ const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
+ Effect.provideService(ServerConfig.ServerConfig, {
+ cwd: "/tmp/startup-project",
+ autoBootstrapProjectFromCwd: true,
+ } as never),
+ Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
+ getUserInputActivity: () => Effect.die("unused"),
+ getPendingRequestActivities: () => Effect.die("unused"),
+ getTurnStartMessage: () => Effect.die("unused"),
+ getCommandReadModel: () => Effect.die("unused"),
+ getSnapshot: () => Effect.die("unused"),
+ getShellSnapshot: () => Effect.die("unused"),
+ getArchivedShellSnapshot: () => Effect.die("unused"),
+ getSnapshotSequence: () => Effect.die("unused"),
+ getCounts: () => Effect.die("unused"),
+ getEventReplayStats: () => Effect.die("unused"),
+ getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
+ getProjectShellById: () => Effect.die("unused"),
+ getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
+ getThreadCheckpointContext: () => Effect.succeed(Option.none()),
+ getFullThreadDiffContext: () => Effect.succeed(Option.none()),
+ getThreadRuntimeContext: () => Effect.die("unused"),
+ getThreadShellById: () => Effect.die("unused"),
+ getThreadDetailById: () => Effect.die("unused"),
+ getThreadDetailSnapshot: () => Effect.die("unused"),
+ searchThreads: () => Effect.succeed({ matches: [] }),
+ }),
+ Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
+ readEvents: () => Stream.empty,
+ readThreadEvents: () => Stream.empty,
+ getThreadReplayStats: () => Effect.die("unused thread replay stats"),
+ dispatch: (command) =>
+ Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
+ Effect.as({ sequence: 1 }),
+ ),
+ streamDomainEvents: Stream.empty,
+ subscribeDomainEvents: Effect.succeed(Stream.empty),
+ latestSequence: Effect.succeed(0),
+ } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]),
+ Effect.provide(NodeServices.layer),
+ );
+
+ assert.equal(typeof targets.bootstrapProjectId, "string");
+ assert.equal(targets.bootstrapProjectCreated, true);
+ assert.equal(targets.bootstrapThreadId, undefined);
+ assert.equal(targets.bootstrapThreadCreated, undefined);
+ assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create"]);
+ }),
+);
+
it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () =>
Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
@@ -283,6 +345,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.die("unused"),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
+ getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadRuntimeContext: () => Effect.die("unused"),
@@ -315,3 +378,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []);
}).pipe(Effect.provide(NodeServices.layer)),
);
+
+it.effect("completeAutoBootstrapWelcome settles failures without bootstrap targets", () =>
+ Effect.gen(function* () {
+ const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(
+ Effect.fail("bootstrap failed"),
+ );
+
+ assert.deepStrictEqual(completion, { bootstrapStatus: "complete" });
+ }),
+);
+
+it.effect("completeAutoBootstrapWelcome settles unexpected defects", () =>
+ Effect.gen(function* () {
+ const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(
+ Effect.die("bootstrap defect"),
+ );
+
+ assert.deepStrictEqual(completion, { bootstrapStatus: "complete" });
+ }),
+);
+
+it.effect("completeAutoBootstrapWelcome settles an empty bootstrap result", () =>
+ Effect.gen(function* () {
+ const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(Effect.succeed({}));
+
+ assert.deepStrictEqual(completion, { bootstrapStatus: "complete" });
+ }),
+);
diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts
index cf0c914dd..74f5d15c1 100644
--- a/apps/server/src/serverRuntimeStartup.ts
+++ b/apps/server/src/serverRuntimeStartup.ts
@@ -196,6 +196,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
let bootstrapProjectId: ProjectId | undefined;
let bootstrapThreadId: ThreadId | undefined;
+ let bootstrapProjectCreated = false;
+ let bootstrapThreadCreated = false;
if (serverConfig.autoBootstrapProjectFromCwd) {
yield* Effect.gen(function* () {
@@ -218,45 +220,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
workspaceRoot: serverConfig.cwd,
createdAt,
});
+ bootstrapProjectId = nextProjectId;
+ bootstrapProjectCreated = true;
} else {
nextProjectId = existingProject.value.id;
+ bootstrapProjectId = nextProjectId;
nextThreadModelSelection =
existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection();
}
- const existingThreadId =
- yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId);
- if (Option.isNone(existingThreadId)) {
- const createdAt = DateTime.formatIso(yield* DateTime.now);
- const createdThreadId = ThreadId.make(yield* randomUUID);
- yield* orchestrationEngine.dispatch({
- type: "thread.create",
- commandId: CommandId.make(yield* randomUUID),
- threadId: createdThreadId,
- projectId: nextProjectId,
- title: "New thread",
- modelSelection: nextThreadModelSelection,
- interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
- runtimeMode: "full-access",
- branch: null,
- worktreePath: null,
- createdAt,
- });
- bootstrapProjectId = nextProjectId;
- bootstrapThreadId = createdThreadId;
- } else {
- bootstrapProjectId = nextProjectId;
- bootstrapThreadId = existingThreadId.value;
- }
+ yield* Effect.gen(function* () {
+ const existingThreadId =
+ yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId);
+ if (Option.isNone(existingThreadId)) {
+ const createdAt = DateTime.formatIso(yield* DateTime.now);
+ const createdThreadId = ThreadId.make(yield* randomUUID);
+ yield* orchestrationEngine.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make(yield* randomUUID),
+ threadId: createdThreadId,
+ projectId: nextProjectId,
+ title: "New thread",
+ modelSelection: nextThreadModelSelection,
+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ });
+ bootstrapThreadId = createdThreadId;
+ bootstrapThreadCreated = true;
+ } else {
+ bootstrapThreadId = existingThreadId.value;
+ }
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Cause.hasInterrupts(cause)
+ ? Effect.failCause(cause)
+ : Effect.logWarning("startup thread auto-bootstrap failed", {
+ bootstrapProjectId: nextProjectId,
+ cause,
+ }),
+ ),
+ );
});
}
return {
...(bootstrapProjectId ? { bootstrapProjectId } : {}),
...(bootstrapThreadId ? { bootstrapThreadId } : {}),
+ ...(bootstrapProjectId ? { bootstrapProjectCreated } : {}),
+ ...(bootstrapThreadId ? { bootstrapThreadCreated } : {}),
} as const;
});
+export const completeAutoBootstrapWelcome = (
+ bootstrap: Effect.Effect,
+) =>
+ bootstrap.pipe(
+ Effect.matchCauseEffect({
+ onFailure: (cause) =>
+ Cause.hasInterrupts(cause)
+ ? Effect.failCause(cause)
+ : Effect.logWarning("startup auto-bootstrap failed", { cause }).pipe(
+ Effect.as({ bootstrapStatus: "complete" as const }),
+ ),
+ onSuccess: (targets) =>
+ Effect.succeed({
+ ...targets,
+ bootstrapStatus: "complete" as const,
+ }),
+ }),
+ );
+
const resolveStartupBrowserTarget = Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
@@ -881,36 +917,31 @@ export const make = (options?: StartupOptions) =>
runStartupPhase(
"welcome.autobootstrap",
Effect.gen(function* () {
- const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe(
- Effect.provideService(Crypto.Crypto, crypto),
+ const bootstrapCompletion = yield* completeAutoBootstrapWelcome(
+ resolveAutoBootstrapWelcomeTargets.pipe(
+ Effect.provideService(Crypto.Crypto, crypto),
+ ),
+ );
+
+ yield* Effect.logDebug(
+ "startup phase: publishing completed bootstrap welcome event",
+ {
+ environmentId: environment.environmentId,
+ cwd: welcomeBase.cwd,
+ projectName: welcomeBase.projectName,
+ ...bootstrapCompletion,
+ },
);
- if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) {
- return;
- }
-
- yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", {
- environmentId: environment.environmentId,
- cwd: welcomeBase.cwd,
- projectName: welcomeBase.projectName,
- bootstrapProjectId: bootstrapTargets.bootstrapProjectId,
- bootstrapThreadId: bootstrapTargets.bootstrapThreadId,
- });
yield* lifecycleEvents.publish({
version: 1,
type: "welcome",
payload: {
environment,
...welcomeBase,
- ...bootstrapTargets,
+ ...bootstrapCompletion,
},
});
- }).pipe(
- Effect.catch((cause) =>
- Effect.logWarning("startup auto-bootstrap welcome failed", {
- cause,
- }),
- ),
- ),
+ }).pipe(Effect.ignoreCause({ log: true })),
),
);
}
@@ -956,7 +987,11 @@ export const make = (options?: StartupOptions) =>
lifecycleEvents.publish({
version: 1,
type: "welcome",
- payload: { environment, ...welcomeBase },
+ payload: {
+ environment,
+ ...welcomeBase,
+ bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete",
+ },
}),
);
yield* options?.activate ?? Effect.void;
diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts
index cfc0c76af..6139988b7 100644
--- a/apps/server/src/serverSettings.test.ts
+++ b/apps/server/src/serverSettings.test.ts
@@ -16,6 +16,7 @@ import * as Duration from "effect/Duration";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
+import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
@@ -24,6 +25,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as ServerConfig from "./config.ts";
import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
import * as ServerSettingsModule from "./serverSettings.ts";
+import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";
const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch);
const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
@@ -1493,4 +1495,44 @@ it.layer(NodeServices.layer)("server settings", (it) => {
);
}).pipe(Effect.provide(makeServerSettingsLayer())),
);
+
+ it.effect("materializes provider secrets for terminal environment resolution", () =>
+ Effect.gen(function* () {
+ const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
+ const serverConfig = yield* ServerConfig.ServerConfig;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const instanceId = ProviderInstanceId.make("codex_terminal");
+
+ const initial = yield* serverSettings.getSettings;
+ yield* serverSettings.mutateProviderInstances({
+ mutationId: ServerProviderInstancesMutationId.make("terminal-environment-secret"),
+ expectedProviderInstances: initial.providerInstances,
+ patch: {
+ providerInstances: {
+ [instanceId]: {
+ driver: ProviderDriverKind.make("codex"),
+ environment: [
+ { name: "OPENROUTER_API_KEY", value: "sk-terminal-secret", sensitive: true },
+ ],
+ config: { homePath: "~/.codex-terminal" },
+ },
+ },
+ },
+ });
+
+ const environment = yield* resolveProviderInstanceTerminalEnvironment({
+ serverSettings,
+ path,
+ rawProviderInstanceId: instanceId,
+ env: undefined,
+ });
+ const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath);
+
+ assert.equal(environment.OPENROUTER_API_KEY, "sk-terminal-secret");
+ assert.match(environment.CODEX_HOME ?? "", /[\\/][.]codex-terminal$/);
+ assert.notInclude(persisted, "sk-terminal-secret");
+ assert.include(persisted, '"valueRedacted": true');
+ }).pipe(Effect.provide(makeServerSettingsLayer())),
+ );
});
diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts
index 4845f8eab..26e81f187 100644
--- a/apps/server/src/terminal/Manager.test.ts
+++ b/apps/server/src/terminal/Manager.test.ts
@@ -7,12 +7,18 @@ import {
type TerminalMetadataStreamEvent,
type TerminalOpenInput,
type TerminalRestartInput,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ServerProviderInstancesMutationId,
+ ServerSettingsError,
+ TerminalProviderInstanceNotFoundError,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Data from "effect/Data";
import * as Clock from "effect/Clock";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
+import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
@@ -30,7 +36,11 @@ import * as TestClock from "effect/testing/TestClock";
import { ChildProcessSpawner } from "effect/unstable/process";
import { expect } from "vite-plus/test";
+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
+import * as ServerConfig from "../config.ts";
+import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
import * as ProcessRunner from "../processRunner.ts";
+import * as ServerSettings from "../serverSettings.ts";
import * as TerminalManager from "./Manager.ts";
import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";
import * as PtyAdapter from "./PtyAdapter.ts";
@@ -220,6 +230,9 @@ interface CreateManagerOptions {
maxRetainedInactiveSessions?: number;
historyByteLimit?: number;
ptyAdapter?: FakePtyAdapter;
+ resolveProviderInstanceEnvironment?: Parameters<
+ typeof TerminalManager.makeWithOptions
+ >[0]["resolveProviderInstanceEnvironment"];
}
interface ManagerFixture {
@@ -265,6 +278,9 @@ const createManager = (
...(options.maxRetainedInactiveSessions !== undefined
? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }
: {}),
+ ...(options.resolveProviderInstanceEnvironment !== undefined
+ ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment }
+ : {}),
});
const eventsRef = yield* Ref.make>([]);
const unsubscribe = yield* manager.subscribe((event) =>
@@ -1817,6 +1833,26 @@ it.layer(
}),
);
+ it.effect("expands provider home paths passed to setup terminals", () =>
+ Effect.gen(function* () {
+ const { manager, ptyAdapter } = yield* createManager(5);
+
+ yield* manager.open({
+ ...openInput(),
+ env: {
+ CODEX_HOME: "~/.codex-work",
+ CLAUDE_CONFIG_DIR: "~/.claude-work",
+ CUSTOM_ACCOUNT: "~/leave-this-value-alone",
+ },
+ });
+
+ const environment = ptyAdapter.spawnInputs[0]?.env;
+ expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/);
+ expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/);
+ expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone");
+ }),
+ );
+
it.effect("strips AppImage runtime env from terminal sessions", () =>
Effect.gen(function* () {
const appDir = "/tmp/.mount_T3Codeabc123";
@@ -1903,6 +1939,392 @@ it.layer(
}),
);
+ it.effect("resolves a provider instance environment before spawning", () =>
+ Effect.gen(function* () {
+ const providerInstanceId = ProviderInstanceId.make("codex_work");
+ const { manager, ptyAdapter } = yield* createManager(5, {
+ env: { T3CODE_SECRET: "server-only" },
+ resolveProviderInstanceEnvironment: (requestedId, env) =>
+ Effect.succeed({
+ ...env,
+ PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong",
+ CODEX_HOME: "/accounts/codex-work",
+ }),
+ });
+
+ const snapshot = yield* manager.open(
+ openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }),
+ );
+
+ expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value");
+ expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work");
+ expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1");
+ expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined();
+ expect(snapshot).not.toHaveProperty("env");
+ expect(snapshot).not.toHaveProperty("providerInstanceId");
+ }),
+ );
+
+ it.effect("fails closed when a provider instance is missing", () =>
+ Effect.gen(function* () {
+ const providerInstanceId = ProviderInstanceId.make("deleted_instance");
+ const { manager, ptyAdapter } = yield* createManager(5, {
+ resolveProviderInstanceEnvironment: (requestedId) =>
+ Effect.fail(
+ new TerminalProviderInstanceNotFoundError({
+ providerInstanceId: ProviderInstanceId.make(requestedId),
+ }),
+ ),
+ });
+
+ const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip);
+
+ assert.deepStrictEqual(
+ error,
+ new TerminalProviderInstanceNotFoundError({ providerInstanceId }),
+ );
+ expect(ptyAdapter.spawnInputs).toHaveLength(0);
+ }),
+ );
+
+ it.effect("preserves the settings failure when provider environment resolution fails", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const providerInstanceId = ProviderInstanceId.make("codex_work");
+ const settingsCause = new Error("secret store read failed");
+ const settingsError = new ServerSettingsError({
+ settingsPath: "/test/settings.json",
+ operation: "read-secret",
+ providerInstanceId,
+ environmentVariable: "OPENROUTER_API_KEY",
+ cause: settingsCause,
+ });
+ const serverSettings = ServerSettings.ServerSettingsService.of({
+ start: Effect.void,
+ ready: Effect.void,
+ getSettings: Effect.fail(settingsError),
+ updateSettings: () => Effect.fail(settingsError),
+ mutateProviderInstances: () => Effect.fail(settingsError),
+ streamChanges: Stream.empty,
+ subscribeChanges: Effect.succeed(Stream.empty),
+ });
+
+ const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
+ serverSettings,
+ path,
+ rawProviderInstanceId: providerInstanceId,
+ env: undefined,
+ }).pipe(Effect.flip);
+
+ expect(error).toMatchObject({
+ _tag: "TerminalProviderEnvironmentError",
+ providerInstanceId,
+ });
+ expect(error.cause).toBe(settingsError);
+ expect(error.message).not.toContain(settingsError.message);
+ expect(error.message).not.toContain("OPENROUTER_API_KEY");
+ }),
+ );
+
+ it.effect.each([
+ {
+ name: "Codex home",
+ driver: "codex",
+ variable: "CODEX_HOME",
+ config: { homePath: "/configured/codex" },
+ expectedHome: "/configured/codex",
+ },
+ {
+ name: "Codex shadow home",
+ driver: "codex",
+ variable: "CODEX_HOME",
+ config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" },
+ expectedHome: "/configured/codex-shadow",
+ },
+ {
+ name: "Claude home",
+ driver: "claudeAgent",
+ variable: "CLAUDE_CONFIG_DIR",
+ config: { homePath: "/configured/claude" },
+ expectedHome: "/configured/claude",
+ },
+ ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
+ serverSettings,
+ path,
+ rawProviderInstanceId: "configured_home",
+ env: undefined,
+ });
+
+ expect(environment[variable]).toBe(path.resolve(expectedHome));
+ }).pipe(
+ Effect.provide(
+ ServerSettings.layerTest({
+ providerInstances: {
+ [ProviderInstanceId.make("configured_home")]: {
+ driver: ProviderDriverKind.make(driver),
+ environment: [{ name: variable, value: "~/.environment-account", sensitive: false }],
+ config,
+ },
+ },
+ }),
+ ),
+ ),
+ );
+
+ it.effect("resolves the legacy Codex default instance", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
+ serverSettings,
+ path,
+ rawProviderInstanceId: "codex",
+ env: undefined,
+ });
+
+ expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/);
+ }).pipe(
+ Effect.provide(
+ ServerSettings.ServerSettingsService.layerTest({
+ providerInstances: {},
+ providers: { codex: { homePath: "~/.codex-legacy" } },
+ }),
+ ),
+ ),
+ );
+
+ it.effect("resolves the legacy Claude default instance", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
+ serverSettings,
+ path,
+ rawProviderInstanceId: "claudeAgent",
+ env: undefined,
+ });
+
+ expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/);
+ }).pipe(
+ Effect.provide(
+ ServerSettings.ServerSettingsService.layerTest({
+ providerInstances: {},
+ providers: { claudeAgent: { homePath: "~/.claude-legacy" } },
+ }),
+ ),
+ ),
+ );
+
+ it.effect("prefers an explicit default instance over legacy provider settings", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
+ serverSettings,
+ path,
+ rawProviderInstanceId: "codex",
+ env: undefined,
+ });
+
+ expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/);
+ }).pipe(
+ Effect.provide(
+ ServerSettings.ServerSettingsService.layerTest({
+ providers: { codex: { homePath: "~/.codex-legacy" } },
+ providerInstances: {
+ [ProviderInstanceId.make("codex")]: {
+ driver: "codex",
+ config: { homePath: "~/.codex-explicit" },
+ },
+ },
+ }),
+ ),
+ ),
+ );
+
+ it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({
+ serverSettings,
+ path,
+ rawProviderInstanceId: "codex_unknown",
+ env: undefined,
+ }).pipe(Effect.flip);
+
+ expect(error).toMatchObject({
+ _tag: "TerminalProviderInstanceNotFoundError",
+ providerInstanceId: "codex_unknown",
+ });
+ }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())),
+ );
+
+ it.effect("restarts a running terminal when the resolved provider environment changes", () =>
+ Effect.gen(function* () {
+ const providerInstanceId = ProviderInstanceId.make("codex_work");
+ let providerSecret = "first-secret";
+ const { manager, ptyAdapter } = yield* createManager(5, {
+ resolveProviderInstanceEnvironment: () =>
+ Effect.succeed({ PROVIDER_SECRET: providerSecret }),
+ });
+
+ yield* manager.open(openInput({ providerInstanceId }));
+ providerSecret = "second-secret";
+ yield* manager.open(openInput({ providerInstanceId }));
+
+ expect(ptyAdapter.processes[0]?.killed).toBe(true);
+ expect(ptyAdapter.spawnInputs).toHaveLength(2);
+ expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret");
+ }),
+ );
+
+ it.effect("restarts with current provider secrets and clears bounded history", () =>
+ Effect.gen(function* () {
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const path = yield* Path.Path;
+ const providerInstanceId = ProviderInstanceId.make("codex_restart");
+ const { manager, ptyAdapter, logsDir } = yield* createManager(2, {
+ historyByteLimit: 8,
+ resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) =>
+ TerminalManager.resolveProviderInstanceTerminalEnvironment({
+ serverSettings,
+ path,
+ rawProviderInstanceId,
+ env,
+ }),
+ });
+ const homePath = path.join(logsDir, "codex");
+ // Pylon replaces provider instances only through their compare-and-set mutation.
+ const updateSecret = (value: string) =>
+ Effect.gen(function* () {
+ const current = yield* serverSettings.getSettings;
+ yield* serverSettings.mutateProviderInstances({
+ mutationId: ServerProviderInstancesMutationId.make(`terminal-restart-${value}`),
+ expectedProviderInstances:
+ ServerSettings.redactServerSettingsForClient(current).providerInstances,
+ patch: {
+ providerInstances: {
+ [providerInstanceId]: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { homePath },
+ environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }],
+ },
+ },
+ },
+ });
+ });
+ const input = {
+ providerInstanceId,
+ env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" },
+ };
+ const outputProcessed = yield* Deferred.make();
+ const unsubscribe = yield* manager.subscribe((event) =>
+ event.type === "output"
+ ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid)
+ : Effect.void,
+ );
+ yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
+
+ yield* updateSecret("first-secret");
+ yield* manager.restart(restartInput(input));
+ const firstProcess = ptyAdapter.processes[0]!;
+ expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret");
+ firstProcess.emitData("discarded\nold-one\nold-two\n");
+ yield* Deferred.await(outputProcessed);
+ expect((yield* manager.open(openInput(input))).history).toBe("old-two\n");
+
+ yield* updateSecret("second-secret");
+ const restarted = yield* manager.restart(restartInput(input));
+
+ expect(firstProcess.killed).toBe(true);
+ expect(ptyAdapter.spawnInputs).toHaveLength(2);
+ expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({
+ PROVIDER_SECRET: "second-secret",
+ CODEX_HOME: homePath,
+ CLIENT_FLAG: "1",
+ });
+ expect(restarted.history).toBe("");
+ expect(restarted.status).toBe("running");
+ expect(restarted).not.toHaveProperty("env");
+ expect(restarted).not.toHaveProperty("providerInstanceId");
+ const logPath = yield* historyLogPath(logsDir);
+ expect(yield* readFileString(logPath)).toBe("");
+
+ ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n");
+ yield* manager.close({ threadId: "thread-1" });
+ expect(yield* readFileString(logPath)).toBe("new-two\n");
+ }).pipe(
+ Effect.provide(
+ ServerSettings.layer.pipe(
+ Layer.provide(ServerSecretStore.layer),
+ Layer.provide(SqlitePersistenceMemory),
+ Layer.provide(
+ ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ it.effect("attaches to a running provider terminal without resolving the provider again", () =>
+ Effect.gen(function* () {
+ const providerInstanceId = ProviderInstanceId.make("codex_work");
+ let providerAvailable = true;
+ const { manager, ptyAdapter } = yield* createManager(5, {
+ resolveProviderInstanceEnvironment: (requestedId) =>
+ providerAvailable
+ ? Effect.succeed({ PROVIDER_SECRET: "secret-value" })
+ : Effect.fail(
+ new TerminalProviderInstanceNotFoundError({
+ providerInstanceId: ProviderInstanceId.make(requestedId),
+ }),
+ ),
+ });
+ yield* manager.open(openInput({ providerInstanceId }));
+ providerAvailable = false;
+ const events: TerminalAttachStreamEvent[] = [];
+
+ const unsubscribe = yield* manager.attachStream(
+ { ...openInput({ providerInstanceId }), restartIfNotRunning: true },
+ (event) => Effect.sync(() => events.push(event)),
+ );
+ unsubscribe();
+
+ expect(events[0]?.type).toBe("snapshot");
+ expect(ptyAdapter.spawnInputs).toHaveLength(1);
+ expect(ptyAdapter.processes[0]?.killed).toBe(false);
+ }),
+ );
+
+ it.effect("fails closed when attaching would create a missing provider terminal", () =>
+ Effect.gen(function* () {
+ const providerInstanceId = ProviderInstanceId.make("deleted_instance");
+ const { manager, ptyAdapter } = yield* createManager(5, {
+ resolveProviderInstanceEnvironment: (requestedId) =>
+ Effect.fail(
+ new TerminalProviderInstanceNotFoundError({
+ providerInstanceId: ProviderInstanceId.make(requestedId),
+ }),
+ ),
+ });
+
+ const error = yield* manager
+ .attachStream(openInput({ providerInstanceId }), () => Effect.void)
+ .pipe(Effect.flip);
+
+ assert.deepStrictEqual(
+ error,
+ new TerminalProviderInstanceNotFoundError({ providerInstanceId }),
+ );
+ expect(ptyAdapter.spawnInputs).toHaveLength(0);
+ }),
+ );
+
it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () =>
Effect.gen(function* () {
if ((yield* HostProcessPlatform) === "win32") return;
diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts
index 4da5beedb..f67f8b6b8 100644
--- a/apps/server/src/terminal/Manager.ts
+++ b/apps/server/src/terminal/Manager.ts
@@ -15,6 +15,8 @@ import {
TerminalError,
TerminalHistoryError,
TerminalNotRunningError,
+ TerminalProviderInstanceNotFoundError,
+ TerminalProviderEnvironmentError,
TerminalResizeError,
TerminalSessionLookupError,
TerminalWriteError,
@@ -32,6 +34,9 @@ import {
type TerminalSessionStatus,
type TerminalSummary,
type TerminalWriteInput,
+ ClaudeSettings,
+ CodexSettings,
+ ProviderInstanceId,
} from "@t3tools/contracts";
import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
@@ -53,11 +58,17 @@ import * as Semaphore from "effect/Semaphore";
import * as SynchronizedRef from "effect/SynchronizedRef";
import * as ServerConfig from "../config.ts";
+import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";
+import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
+import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";
+import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts";
+import * as ServerSettings from "../serverSettings.ts";
import {
increment,
terminalRestartsTotal,
terminalSessionsTotal,
} from "../observability/Metrics.ts";
+import { expandHomePath } from "../pathExpansion.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as PortScanner from "../preview/PortScanner.ts";
import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";
@@ -71,6 +82,8 @@ export {
TerminalError,
TerminalHistoryError,
TerminalNotRunningError,
+ TerminalProviderInstanceNotFoundError,
+ TerminalProviderEnvironmentError,
TerminalResizeError,
TerminalSessionLookupError,
TerminalWriteError,
@@ -89,6 +102,8 @@ const DEFAULT_OPEN_ROWS = 30;
const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
const MAX_TERMINAL_LABEL_LENGTH = 128;
+const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);
+const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings);
class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()(
"TerminalSubprocessCheckError",
@@ -1273,7 +1288,8 @@ function createTerminalSpawnEnv(
}
if (runtimeEnv) {
for (const [key, value] of Object.entries(runtimeEnv)) {
- spawnEnv[key] = value;
+ spawnEnv[key] =
+ key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value;
}
}
// Both PTY backends feed truecolor-capable terminal clients.
@@ -1313,19 +1329,80 @@ interface TerminalManagerOptions {
readonly threadId: string;
readonly terminalId: string;
}) => Effect.Effect;
+ resolveProviderInstanceEnvironment?: (
+ providerInstanceId: string,
+ env: Record | undefined,
+ ) => Effect.Effect<
+ Record,
+ TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError
+ >;
}
+export const resolveProviderInstanceTerminalEnvironment = Effect.fn(
+ "terminal.resolveProviderInstanceTerminalEnvironment",
+)(function* (input: {
+ readonly serverSettings: ServerSettings.ServerSettingsService["Service"];
+ readonly path: Path.Path;
+ readonly rawProviderInstanceId: string;
+ readonly env: Record | undefined;
+}) {
+ const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId);
+ const settings = yield* input.serverSettings.getSettings.pipe(
+ Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })),
+ );
+ const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId];
+ if (instance === undefined) {
+ return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId });
+ }
+
+ let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {});
+ if (instance.driver === "codex") {
+ const config = decodeCodexSettings(instance.config ?? {});
+ if (Option.isSome(config)) {
+ const layout = yield* resolveCodexHomeLayout(config.value).pipe(
+ Effect.provideService(Path.Path, input.path),
+ );
+ if (layout.effectiveHomePath)
+ resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath };
+ }
+ } else if (instance.driver === "claudeAgent") {
+ const config = decodeClaudeSettings(instance.config ?? {});
+ if (Option.isSome(config)) {
+ resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe(
+ Effect.provideService(Path.Path, input.path),
+ );
+ }
+ }
+
+ return Object.fromEntries(
+ Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined),
+ );
+});
+
export const make = Effect.fn("TerminalManager.make")(function* () {
const { terminalLogsDir } = yield* ServerConfig.ServerConfig;
const ptyAdapter = yield* PtyAdapter.PtyAdapter;
const portDiscovery = yield* PortScanner.PortDiscovery;
const nativeTelemetry = yield* NativeTelemetryClient.NativeTelemetryClient;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const path = yield* Path.Path;
+ const resolveProviderInstanceEnvironment = Effect.fn(
+ "terminal.resolveProviderInstanceEnvironment",
+ )((rawProviderInstanceId: string, env: Record | undefined) =>
+ resolveProviderInstanceTerminalEnvironment({
+ serverSettings,
+ path,
+ rawProviderInstanceId,
+ env,
+ }),
+ );
return yield* makeWithOptions({
logsDir: terminalLogsDir,
ptyAdapter,
processTable: nativeTelemetry.processTable,
registerTerminalProcesses: portDiscovery.registerTerminalProcesses,
unregisterTerminal: portDiscovery.unregisterTerminal,
+ resolveProviderInstanceEnvironment,
});
});
@@ -1348,6 +1425,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
const baseEnv = options.env ?? process.env;
const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
const processRunner = yield* ProcessRunner.ProcessRunner;
+ const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")(
+ function* (
+ input: Input,
+ ): Effect.fn.Return<
+ Input,
+ TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError
+ > {
+ if (input.providerInstanceId === undefined) return input;
+ const resolver = options.resolveProviderInstanceEnvironment;
+ if (resolver === undefined) {
+ return yield* new TerminalProviderInstanceNotFoundError({
+ providerInstanceId: ProviderInstanceId.make(input.providerInstanceId),
+ });
+ }
+ const env = yield* resolver(input.providerInstanceId, input.env);
+ return { ...input, env };
+ },
+ );
// One process-table snapshot per poll tick, shared across every terminal.
// Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and
// can exhaust the PID space on hosts with many sessions (#6332).
@@ -2534,7 +2629,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
});
const open: TerminalManager["Service"]["open"] = (input) =>
- withThreadLock(input.threadId, openLocked(input));
+ withThreadLock(
+ input.threadId,
+ resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)),
+ );
const openOrAttachForStream = (input: TerminalAttachInput) =>
withThreadLock(
@@ -2551,11 +2649,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
});
}
- return yield* openLocked({
+ const resolvedInput = yield* resolveLaunchInputEnvironment({
...input,
terminalId,
cwd: input.cwd,
});
+ return yield* openLocked(resolvedInput);
}
const session = existing.value;
@@ -2563,11 +2662,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
const targetRows = input.rows ?? session.rows;
if (!session.process && input.cwd && input.restartIfNotRunning === true) {
- return yield* openLocked({
+ const resolvedInput = yield* resolveLaunchInputEnvironment({
...input,
terminalId,
cwd: input.cwd,
});
+ return yield* openLocked(resolvedInput);
}
if (
@@ -2819,84 +2919,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
}),
);
+ const restartResolved = (input: TerminalRestartInput) =>
+ Effect.gen(function* () {
+ yield* increment(terminalRestartsTotal, { scope: "thread" });
+ const terminalId = input.terminalId;
+ yield* assertValidCwd(input.cwd);
+
+ const sessionKey = toSessionKey(input.threadId, terminalId);
+ const existingSession = yield* getSession(input.threadId, terminalId);
+ let session: TerminalSessionState;
+ if (Option.isNone(existingSession)) {
+ const cols = input.cols ?? DEFAULT_OPEN_COLS;
+ const rows = input.rows ?? DEFAULT_OPEN_ROWS;
+ session = {
+ threadId: input.threadId,
+ terminalId,
+ cwd: input.cwd,
+ worktreePath: input.worktreePath ?? null,
+ status: "starting",
+ pid: null,
+ history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit),
+ pendingHistoryControlSequence: "",
+ pendingProcessEvents: [],
+ pendingProcessEventIndex: 0,
+ processEventDrainRunning: false,
+ exitCode: null,
+ exitSignal: null,
+ updatedAt: yield* nowIso,
+ eventSequence: 0,
+ cols,
+ rows,
+ process: null,
+ unsubscribeData: null,
+ unsubscribeExit: null,
+ hasRunningSubprocess: false,
+ childCommandLabel: null,
+ runtimeEnv: normalizedRuntimeEnv(input.env),
+ };
+ const createdSession = session;
+ yield* modifyManagerState((state) => {
+ const sessions = new Map(state.sessions);
+ sessions.set(sessionKey, createdSession);
+ return [undefined, { ...state, sessions }] as const;
+ });
+ yield* evictInactiveSessionsIfNeeded();
+ } else {
+ session = existingSession.value;
+ yield* stopProcess(session);
+ session.cwd = input.cwd;
+ session.worktreePath = input.worktreePath ?? null;
+ session.runtimeEnv = normalizedRuntimeEnv(input.env);
+ }
+
+ const cols = input.cols ?? session.cols;
+ const rows = input.rows ?? session.rows;
+
+ session.history.clear();
+ session.pendingHistoryControlSequence = "";
+ session.pendingProcessEvents = [];
+ session.pendingProcessEventIndex = 0;
+ session.processEventDrainRunning = false;
+ yield* persistHistory(input.threadId, terminalId, session.history);
+ yield* startSession(
+ session,
+ {
+ threadId: input.threadId,
+ terminalId,
+ cwd: input.cwd,
+ ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
+ cols,
+ rows,
+ ...(input.env ? { env: input.env } : {}),
+ },
+ "restarted",
+ );
+ return snapshot(session);
+ });
+
const restart: TerminalManager["Service"]["restart"] = (input) =>
withThreadLock(
input.threadId,
- Effect.gen(function* () {
- yield* increment(terminalRestartsTotal, { scope: "thread" });
- const terminalId = input.terminalId;
- yield* assertValidCwd(input.cwd);
-
- const sessionKey = toSessionKey(input.threadId, terminalId);
- const existingSession = yield* getSession(input.threadId, terminalId);
- let session: TerminalSessionState;
- if (Option.isNone(existingSession)) {
- const cols = input.cols ?? DEFAULT_OPEN_COLS;
- const rows = input.rows ?? DEFAULT_OPEN_ROWS;
- session = {
- threadId: input.threadId,
- terminalId,
- cwd: input.cwd,
- worktreePath: input.worktreePath ?? null,
- status: "starting",
- pid: null,
- history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit),
- pendingHistoryControlSequence: "",
- pendingProcessEvents: [],
- pendingProcessEventIndex: 0,
- processEventDrainRunning: false,
- exitCode: null,
- exitSignal: null,
- updatedAt: yield* nowIso,
- eventSequence: 0,
- cols,
- rows,
- process: null,
- unsubscribeData: null,
- unsubscribeExit: null,
- hasRunningSubprocess: false,
- childCommandLabel: null,
- runtimeEnv: normalizedRuntimeEnv(input.env),
- };
- const createdSession = session;
- yield* modifyManagerState((state) => {
- const sessions = new Map(state.sessions);
- sessions.set(sessionKey, createdSession);
- return [undefined, { ...state, sessions }] as const;
- });
- yield* evictInactiveSessionsIfNeeded();
- } else {
- session = existingSession.value;
- yield* stopProcess(session);
- session.cwd = input.cwd;
- session.worktreePath = input.worktreePath ?? null;
- session.runtimeEnv = normalizedRuntimeEnv(input.env);
- }
-
- const cols = input.cols ?? session.cols;
- const rows = input.rows ?? session.rows;
-
- session.history.clear();
- session.pendingHistoryControlSequence = "";
- session.pendingProcessEvents = [];
- session.pendingProcessEventIndex = 0;
- session.processEventDrainRunning = false;
- yield* persistHistory(input.threadId, terminalId, session.history);
- yield* startSession(
- session,
- {
- threadId: input.threadId,
- terminalId,
- cwd: input.cwd,
- ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
- cols,
- rows,
- ...(input.env ? { env: input.env } : {}),
- },
- "restarted",
- );
- return snapshot(session);
- }),
+ resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)),
);
const close: TerminalManager["Service"]["close"] = (input) =>
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index a0e6df74a..eef9162c1 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -62,6 +62,7 @@ import {
type RelayClientInstallProgressEvent,
ServerSelfUpdateError,
type ServerSelfUpdateProgressEvent,
+ type ServerLifecycleStreamEvent,
type FilesystemBrowseFailure,
FilesystemBrowseError,
AssetWorkspaceContextNotFoundError,
@@ -110,6 +111,7 @@ import {
} from "./observability/RpcInstrumentation.ts";
import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
import * as ProviderService from "./provider/Services/ProviderService.ts";
+import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
import * as PrimeManagedMaintenance from "./provider/prime/PrimeManagedMaintenance.ts";
import { toProviderMessageSessionAgentError } from "./provider/providerMessageSessionAgentRpcError.ts";
@@ -149,6 +151,8 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";
import { RollbackSagaRunner } from "./rollback/RollbackSagaRunner.ts";
import * as ReviewService from "./review/ReviewService.ts";
import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
+import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";
+import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts";
import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
@@ -523,6 +527,7 @@ const makeWsRpcLayer = (
const portDiscovery = yield* PortScanner.PortDiscovery;
const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
const providerService = yield* ProviderService.ProviderService;
+ const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
const rollbackSagaRunner = yield* RollbackSagaRunner;
const rollbackSagaRepository = yield* RollbackSagaRepository;
const sideQuestionOwnership = makeSessionSideQuestionOwnership();
@@ -575,6 +580,7 @@ const makeWsRpcLayer = (
return true;
});
const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
+ const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;
const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
const rpcClientIds = yield* Ref.make(new Set());
@@ -2657,6 +2663,31 @@ const makeWsRpcLayer = (
deletePendingAttachment(input.attachmentId),
{ "rpc.aggregate": "workspace" },
),
+ [WS_METHODS.agentSessionsScan]: () =>
+ observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, {
+ "rpc.aggregate": "workspace",
+ }),
+ [WS_METHODS.agentSessionsImport]: (input) =>
+ observeRpcEffect(
+ WS_METHODS.agentSessionsImport,
+ importRecentAgentThreads(input).pipe(
+ Effect.provideService(AgentSessionScanner.AgentSessionScanner, agentSessionScanner),
+ Effect.provideService(
+ OrchestrationEngine.OrchestrationEngineService,
+ orchestrationEngine,
+ ),
+ Effect.provideService(
+ ProjectionSnapshotQuery.ProjectionSnapshotQuery,
+ projectionSnapshotQuery,
+ ),
+ Effect.provideService(Crypto.Crypto, crypto),
+ Effect.provideService(
+ ProviderSessionDirectory.ProviderSessionDirectory,
+ providerSessionDirectory,
+ ),
+ ),
+ { "rpc.aggregate": "workspace" },
+ ),
[WS_METHODS.assetsCreateUrl]: (input) =>
observeRpcEffect(
WS_METHODS.assetsCreateUrl,
@@ -3088,11 +3119,18 @@ const makeWsRpcLayer = (
observeRpcStreamEffect(
WS_METHODS.subscribeServerLifecycle,
Effect.gen(function* () {
+ const liveBuffer = yield* Queue.unbounded();
+ yield* Effect.forkScoped(
+ lifecycleEvents.stream.pipe(
+ Stream.runForEach((event) => Queue.offer(liveBuffer, event)),
+ ),
+ { startImmediately: true },
+ );
const snapshot = yield* lifecycleEvents.snapshot;
const snapshotEvents = Array.from(snapshot.events).toSorted(
(left, right) => left.sequence - right.sequence,
);
- const liveEvents = lifecycleEvents.stream.pipe(
+ const liveEvents = Stream.fromQueue(liveBuffer).pipe(
Stream.filter((event) => event.sequence > snapshot.sequence),
);
return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents);
@@ -3185,6 +3223,9 @@ export const websocketRpcRouteLayer = Layer.unwrap(
),
});
const pullRequests = yield* PullRequestService.PullRequestService;
+ // Built once for the route so every connection shares the scanner's import
+ // read lock and its per-transcript memory budget.
+ const agentSessionScanner = yield* AgentSessionScanner.make;
return HttpRouter.add(
"GET",
"/ws",
@@ -3211,6 +3252,9 @@ export const websocketRpcRouteLayer = Layer.unwrap(
Effect.provide(
makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe(
Layer.provideMerge(RpcSerialization.layerJson),
+ Layer.provide(
+ Layer.succeed(AgentSessionScanner.AgentSessionScanner, agentSessionScanner),
+ ),
Layer.provide(ProviderMaintenanceRunner.layer),
Layer.provide(PrimeManagedMaintenance.layer),
Layer.provide(
diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts
index 1a79f729b..dfe2b51d0 100644
--- a/apps/web/src/authBootstrap.test.ts
+++ b/apps/web/src/authBootstrap.test.ts
@@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => {
expect(testApi.calls.session).toBe(2);
});
+ it("keeps manual token submission pending until the session is authenticated", async () => {
+ vi.useFakeTimers();
+ let authenticated = false;
+ let settled = false;
+ try {
+ const testApi = await installAuthApi({
+ session: () =>
+ authenticated
+ ? authenticatedSession(LOOPBACK_AUTH)
+ : unauthenticatedSession(LOOPBACK_AUTH),
+ browserSession: () => Effect.succeed(browserSession(["orchestration:read"])),
+ });
+ const { submitServerAuthCredential } = await import("./environments/primary");
+
+ const submission = submitServerAuthCredential("retry-token").finally(() => {
+ settled = true;
+ });
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]);
+ expect(testApi.calls.session).toBe(1);
+ expect(settled).toBe(false);
+
+ authenticated = true;
+ await vi.advanceTimersByTimeAsync(100);
+ await expect(submission).resolves.toBeUndefined();
+ expect(testApi.calls.session).toBe(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("fails manual token submission when the session is not established", async () => {
+ vi.useFakeTimers();
+ try {
+ const testApi = await installAuthApi({
+ session: () => unauthenticatedSession(LOOPBACK_AUTH),
+ browserSession: () => Effect.succeed(browserSession(["orchestration:read"])),
+ });
+ const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } =
+ await import("./environments/primary/auth");
+
+ const submission = submitServerAuthCredential("retry-token");
+ const failure = submission.then(
+ () => null,
+ (error: unknown) => error,
+ );
+ await vi.advanceTimersByTimeAsync(2_000);
+
+ await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError);
+ expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]);
+ expect(testApi.calls.session).toBeGreaterThan(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it("rejects a blank pairing token with a structured validation error", async () => {
const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } =
await import("./environments/primary/auth");
diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx
new file mode 100644
index 000000000..4a241befe
--- /dev/null
+++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx
@@ -0,0 +1,200 @@
+import {
+ DEFAULT_CLIENT_SETTINGS,
+ EnvironmentId,
+ FILL_PREVIEW_VIEWPORT,
+ ThreadId,
+ type ClientSettings,
+ type DesktopPreviewBridge,
+} from "@t3tools/contracts";
+import { act } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const mocks = vi.hoisted(() => ({
+ getClientSettings: vi.fn<() => Promise>(),
+ setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(),
+ createTab: vi.fn(),
+ closeTab: vi.fn(),
+ registerWebview: vi.fn(),
+ getPreviewConfig: vi.fn(),
+ activeRecordings: new Set(),
+}));
+
+vi.mock("~/localApi", () => ({
+ ensureLocalApi: () => ({ persistence: mocks }),
+}));
+
+vi.mock("~/components/preview/previewBridge", () => ({
+ previewBridge: {
+ createTab: mocks.createTab,
+ closeTab: mocks.closeTab,
+ registerWebview: mocks.registerWebview,
+ getPreviewConfig: mocks.getPreviewConfig,
+ },
+}));
+
+vi.mock("~/components/preview/usePreviewBridge", () => ({
+ usePreviewBridge: () => undefined,
+}));
+
+vi.mock("./browserRecording", () => ({
+ useActiveBrowserRecordingTabIds: () => mocks.activeRecordings,
+ stopBrowserRecording: async () => null,
+}));
+
+import {
+ __resetClientSettingsPersistenceForTests,
+ ensureClientSettingsHydrated,
+} from "~/hooks/useSettings";
+import { useBrowserSurfaceStore } from "./browserSurfaceStore";
+import * as desktopTabLifetime from "./desktopTabLifetime";
+import { HostedBrowserWebview } from "./HostedBrowserWebview";
+
+let renderer: ReactTestRenderer | undefined;
+
+function deferred() {
+ let resolve!: (value: A) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+beforeEach(() => {
+ __resetClientSettingsPersistenceForTests();
+ useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} });
+ mocks.getClientSettings.mockReset();
+ mocks.setClientSettings.mockReset().mockResolvedValue(undefined);
+ mocks.createTab.mockReset().mockResolvedValue(undefined);
+ mocks.closeTab.mockReset().mockResolvedValue(undefined);
+ mocks.registerWebview.mockReset().mockResolvedValue(undefined);
+ mocks.getPreviewConfig.mockReset().mockResolvedValue({
+ partition: "persist:t3-preview-work",
+ webPreferences: "contextIsolation=yes",
+ preloadUrl: null,
+ });
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("window", globalThis);
+ vi.stubGlobal("navigator", { platform: "Linux" });
+ vi.stubGlobal(
+ "requestAnimationFrame",
+ vi.fn(() => 0),
+ );
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+});
+
+afterEach(async () => {
+ vi.useFakeTimers();
+ await act(() => renderer?.unmount());
+ renderer = undefined;
+ await vi.advanceTimersByTimeAsync(0);
+ vi.useRealTimers();
+ __resetClientSettingsPersistenceForTests();
+ useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} });
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe("HostedBrowserWebview settings hydration", () => {
+ it("starts a retained background tab only after a settings read succeeds on retry", async () => {
+ const firstRead = deferred();
+ const retryRead = deferred();
+ const tabCreation = deferred();
+ mocks.getClientSettings
+ .mockReturnValueOnce(firstRead.promise)
+ .mockReturnValueOnce(retryRead.promise);
+ mocks.createTab.mockReturnValueOnce(tabCreation.promise);
+ const acquire = vi.spyOn(desktopTabLifetime, "acquireDesktopTab");
+ const createGuest = vi.fn((_attributes: unknown) =>
+ Object.assign(new EventTarget(), { getWebContentsId: () => 41 }),
+ );
+ const threadRef = {
+ environmentId: EnvironmentId.make("host-settings-retry"),
+ threadId: ThreadId.make("thread-settings-retry"),
+ };
+ const runtimeTabId = "retained-background-tab";
+ useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId);
+
+ await act(() => {
+ renderer = create(
+ ,
+ {
+ createNodeMock: (element) =>
+ element.type === "webview"
+ ? createGuest(element.props)
+ : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined },
+ },
+ );
+ });
+
+ expect(mocks.getClientSettings).toHaveBeenCalledOnce();
+ expect(acquire).not.toHaveBeenCalled();
+ expect(createGuest).not.toHaveBeenCalled();
+ expect(mocks.createTab).not.toHaveBeenCalled();
+
+ const failure = new Error("Saved settings are unavailable");
+ await act(async () => {
+ const hydration = ensureClientSettingsHydrated();
+ firstRead.reject(failure);
+ await expect(hydration).rejects.toBe(failure);
+ });
+ expect(acquire).not.toHaveBeenCalled();
+ expect(createGuest).not.toHaveBeenCalled();
+ expect(mocks.createTab).not.toHaveBeenCalled();
+
+ let retry!: Promise;
+ await act(() => {
+ retry = ensureClientSettingsHydrated();
+ });
+ expect(mocks.getClientSettings).toHaveBeenCalledTimes(2);
+ expect(acquire).not.toHaveBeenCalled();
+ expect(createGuest).not.toHaveBeenCalled();
+ expect(mocks.createTab).not.toHaveBeenCalled();
+
+ await act(async () => {
+ retryRead.resolve({
+ ...DEFAULT_CLIENT_SETTINGS,
+ browserDefaultZoomFactor: 1.25,
+ browserDefaultAppearance: "dark",
+ browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
+ browserDefaultProfileId: "work",
+ });
+ await retry;
+ });
+
+ expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId);
+ expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work");
+ expect(createGuest).toHaveBeenCalledOnce();
+ expect(createGuest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ partition: "persist:t3-preview-work",
+ src: "https://example.com",
+ }),
+ );
+ expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, {
+ zoomFactor: 1.25,
+ colorScheme: "dark",
+ });
+ expect(mocks.registerWebview).not.toHaveBeenCalled();
+
+ await act(async () => {
+ tabCreation.resolve();
+ await tabCreation.promise;
+ });
+ expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41);
+ expect(mocks.closeTab).not.toHaveBeenCalled();
+ expect(mocks.setClientSettings).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx
index 93d24cd74..ade36bd96 100644
--- a/apps/web/src/browser/HostedBrowserWebview.tsx
+++ b/apps/web/src/browser/HostedBrowserWebview.tsx
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { previewBridge } from "~/components/preview/previewBridge";
import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
+import { useClientSettingsHydrated } from "~/hooks/useSettings";
import { cn } from "~/lib/utils";
import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
@@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: {
zoomFactor,
profileId,
} = props;
+ const clientSettingsHydrated = useClientSettingsHydrated();
const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);
const [initialSrc] = useState(() => initialUrl ?? "about:blank");
const tabLeaseRef = useRef(null);
@@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: {
usePreviewBridge({ threadRef, tabId, runtimeTabId });
useEffect(() => {
+ if (!clientSettingsHydrated) return;
crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;
const lease = acquireDesktopTab(runtimeTabId);
tabLeaseRef.current = lease;
@@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: {
if (tabLeaseRef.current === lease) tabLeaseRef.current = null;
lease.release();
};
- }, [runtimeTabId]);
+ }, [clientSettingsHydrated, runtimeTabId]);
const [webviewGeneration, setWebviewGeneration] = useState(0);
const [recoverySrc, setRecoverySrc] = useState(initialSrc);
@@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: {
useEffect(() => {
const webview = webviewRef.current;
const bridge = previewBridge;
- if (!webview || !config || !bridge) return;
+ if (!clientSettingsHydrated || !webview || !config || !bridge) return;
let disposed = false;
let recoveryTimeout: ReturnType | null = null;
const register = () => {
@@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: {
webview.removeEventListener("dom-ready", register);
webview.removeEventListener("render-process-gone", recoverGuest);
};
- }, [config, initialSrc, runtimeTabId, webviewGeneration]);
+ }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]);
const active = presentation.visible && presentation.rect !== null;
const lastRect = presentation.rect;
@@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: {
wrapper.scrollTo({ left: 0, top: 0 });
}, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]);
- if (!config) return null;
+ if (!clientSettingsHydrated || !config) return null;
const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;
const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts
index a85a75612..ba18e86f4 100644
--- a/apps/web/src/browser/browserDefaults.test.ts
+++ b/apps/web/src/browser/browserDefaults.test.ts
@@ -1,12 +1,14 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
+import { ensureClientSettingsHydrated } from "~/hooks/useSettings";
+
const settings = vi.hoisted(() => ({ current: {} as Record }));
vi.mock("~/hooks/useSettings", () => ({
getClientSettings: () => settings.current,
useClientSettings: () => undefined,
- ensureClientSettingsHydrated: () => Promise.resolve(),
+ ensureClientSettingsHydrated: vi.fn(async () => undefined),
}));
const { resolveBrowserDefaults } = await import("./browserDefaults");
@@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => {
);
});
});
+
+describe("resolveBrowserDefaults", () => {
+ it("rejects failed reads and uses the saved profile after a successful retry", async () => {
+ await withDefaultProfile("work");
+ settings.current.browserDefaultZoomFactor = 1.25;
+ settings.current.browserDefaultAppearance = "dark";
+ const failure = new Error("Settings read failed");
+ vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure);
+
+ await expect(resolveBrowserDefaults()).rejects.toBe(failure);
+ await expect(resolveBrowserDefaults()).resolves.toMatchObject({
+ viewport: { _tag: "fill" },
+ zoomFactor: 1.25,
+ appearance: "dark",
+ autoShowFloatingPreview: true,
+ profileId: "work",
+ });
+ });
+});
diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts
index 42ea8c680..192243afd 100644
--- a/apps/web/src/browser/browserDefaults.ts
+++ b/apps/web/src/browser/browserDefaults.ts
@@ -79,6 +79,7 @@ function getBrowserDefaults(): BrowserDefaults {
* Opening a preview is asynchronous anyway, and before hydration the snapshot
* is the schema defaults rather than the user's — a tab opened in that window
* would be born at the wrong viewport, zoom and appearance and never corrected.
+ * Read failures reject so a new tab cannot use the wrong profile or viewport.
*/
export async function resolveBrowserDefaults(): Promise {
await ensureClientSettingsHydrated();
diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts
index 94f97001c..a60362c43 100644
--- a/apps/web/src/browser/browserLinkTarget.test.ts
+++ b/apps/web/src/browser/browserLinkTarget.test.ts
@@ -1,6 +1,16 @@
-import { describe, expect, it } from "vite-plus/test";
+import type { BrowserLinkTarget } from "@t3tools/contracts";
+import { describe, expect, it, vi } from "vite-plus/test";
-import { resolveLinkTarget } from "./browserLinkTarget";
+import { ensureClientSettingsHydrated } from "~/hooks/useSettings";
+
+import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget";
+
+const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget }));
+
+vi.mock("~/hooks/useSettings", () => ({
+ ensureClientSettingsHydrated: vi.fn(async () => undefined),
+ getClientSettings: () => settings,
+}));
const click = { metaKey: false, ctrlKey: false };
@@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => {
}
});
});
+
+describe("resolveBrowserLinkTargetPreference", () => {
+ it.each(["system", "app"] as const)(
+ "rejects failed reads instead of using the current %s preference",
+ async (preference) => {
+ settings.browserLinkTarget = preference;
+ const failure = new Error("Settings read failed");
+ vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure);
+
+ await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure);
+ await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference);
+ },
+ );
+});
diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts
index d03775572..7ecffb459 100644
--- a/apps/web/src/browser/browserLinkTarget.ts
+++ b/apps/web/src/browser/browserLinkTarget.ts
@@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean {
* hydration the snapshot is the schema default ("system"), so a link clicked
* in the first moments after launch would ignore a persisted "app" — opening
* is asynchronous anyway, so waiting costs nothing the user can see.
+ * Read failures reject rather than choosing a browser without the saved preference.
*/
export async function resolveBrowserLinkTargetPreference(): Promise {
await ensureClientSettingsHydrated();
diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts
index 737aae00c..b14b20d2d 100644
--- a/apps/web/src/browser/browserRecording.test.ts
+++ b/apps/web/src/browser/browserRecording.test.ts
@@ -1,6 +1,8 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import { ensureClientSettingsHydrated } from "~/hooks/useSettings";
+
const { clientSettings, events, getUserMedia, registrySet, save, startScreencast, stopScreencast } =
vi.hoisted(() => {
const events: string[] = [];
@@ -229,6 +231,37 @@ describe("browser recording", () => {
await stopBrowserRecording("recording-tab");
});
+ it("clears a failed settings read before retrying recording", async () => {
+ const tabId = "settings-read-failure-tab";
+ const error = new Error("Settings read failed");
+ vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error);
+
+ await expect(startBrowserRecording(tabId)).rejects.toBe(error);
+
+ expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
+ expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined();
+ expect(animationFrameCount).toBe(0);
+ expect(startScreencast).not.toHaveBeenCalled();
+ expect(stopScreencast).not.toHaveBeenCalled();
+ expect(getUserMedia).not.toHaveBeenCalled();
+ expect(FakeMediaRecorder.instances).toHaveLength(0);
+
+ clientSettings.browserRecordingFrameRate = 60;
+ await startBrowserRecording(tabId);
+
+ expect(getUserMedia).toHaveBeenCalledWith({
+ audio: false,
+ video: {
+ mandatory: expect.objectContaining({ maxFrameRate: 60 }),
+ },
+ });
+ await stopBrowserRecording(tabId);
+
+ expect(startScreencast).toHaveBeenCalledOnce();
+ expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
+ expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined();
+ });
+
it("stops the native stream when MediaRecorder cleanup fails", async () => {
const stopTrack = vi.fn();
getUserMedia.mockResolvedValueOnce({
diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts
index e362a358e..4364ebf06 100644
--- a/apps/web/src/browser/browserRecording.ts
+++ b/apps/web/src/browser/browserRecording.ts
@@ -419,10 +419,12 @@ export async function startBrowserRecording(
activeRecordings.set(tabId, recording);
publishActiveRecordingTabIds();
try {
- const frameRatePromise = ensureClientSettingsHydrated().then(
- () => getClientSettings().browserRecordingFrameRate,
- );
- const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]);
+ await ensureClientSettingsHydrated().catch((cause: unknown) => {
+ clearActiveRecording(recording);
+ throw cause;
+ });
+ const frameRate = getClientSettings().browserRecordingFrameRate;
+ await waitForBrowserRecordingPaint();
let source: DesktopPreviewRecordingSource;
try {
source = await bridge.recording.startScreencast(tabId);
diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts
index 80bfa0d27..c5338ecf4 100644
--- a/apps/web/src/browser/desktopTabLifetime.test.ts
+++ b/apps/web/src/browser/desktopTabLifetime.test.ts
@@ -1,6 +1,7 @@
import {
DEFAULT_PREVIEW_APPEARANCE,
DEFAULT_PREVIEW_ZOOM_FACTOR,
+ DEFAULT_CLIENT_SETTINGS,
EnvironmentId,
ThreadId,
} from "@t3tools/contracts";
@@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({
}));
import { acquireDesktopTab } from "./desktopTabLifetime";
+import * as browserDefaults from "./browserDefaults";
+import { __setClientSettingsForTests } from "~/hooks/useSettings";
-/** Client settings are unset in tests, so creation carries the schema defaults. */
+/** Tests load default settings unless they select other preferences. */
const DEFAULT_TAB_STATE = {
zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
colorScheme: DEFAULT_PREVIEW_APPEARANCE,
@@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId";
describe("desktopTabLifetime", () => {
beforeEach(() => {
+ __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
closeTab.mockClear();
createTab.mockClear();
stopBrowserRecording.mockClear();
@@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("does not create a desktop tab after a failed settings read and permits a later retry", async () => {
+ vi.useFakeTimers();
+ const failure = new Error("Settings read failed");
+ vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure);
+ const failed = acquireDesktopTab("tab_settings_retry");
+
+ await expect(failed.ready).rejects.toBe(failure);
+ expect(createTab).not.toHaveBeenCalled();
+ failed.release();
+ await vi.advanceTimersByTimeAsync(0);
+
+ __setClientSettingsForTests({
+ ...DEFAULT_CLIENT_SETTINGS,
+ browserDefaultZoomFactor: 1.25,
+ browserDefaultAppearance: "dark",
+ });
+ createTab.mockResolvedValueOnce(undefined);
+ const retry = acquireDesktopTab("tab_settings_retry");
+ await retry.ready;
+
+ expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", {
+ zoomFactor: 1.25,
+ colorScheme: "dark",
+ });
+ retry.release();
+ await vi.advanceTimersByTimeAsync(0);
});
it("shares tab creation readiness across concurrent leases", async () => {
diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts
index f506e42e7..a320e3ba3 100644
--- a/apps/web/src/browser/openFileInPreview.ts
+++ b/apps/web/src/browser/openFileInPreview.ts
@@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError(
readonly message: string;
}> {}
+export class BrowserSettingsReadError extends Data.TaggedError("BrowserSettingsReadError")<{
+ readonly cause: unknown;
+}> {
+ override get message(): string {
+ return "Saved browser settings could not be loaded.";
+ }
+}
+
export type OpenPreviewMutation = (input: {
readonly environmentId: EnvironmentId;
readonly input: PreviewOpenInput;
@@ -47,8 +55,13 @@ export async function openUrlInPreview(input: {
readonly threadRef: ScopedThreadRef;
readonly url: string;
readonly openPreview: OpenPreviewMutation;
-}): Promise> {
- const defaults = await resolveBrowserDefaults();
+}): Promise> {
+ const defaults = await resolveBrowserDefaults().catch(
+ (cause: unknown) => new BrowserSettingsReadError({ cause }),
+ );
+ if (defaults instanceof BrowserSettingsReadError) {
+ return AsyncResult.failure(Cause.fail(defaults));
+ }
const result = await input.openPreview({
environmentId: input.threadRef.environmentId,
input: {
@@ -82,7 +95,12 @@ export async function openFileInPreview(input: {
readonly input: { readonly resource: AssetResource };
}) => Promise>;
readonly openPreview: OpenPreviewMutation;
-}): Promise> {
+}): Promise<
+ AtomCommandResult<
+ void,
+ AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError
+ >
+> {
if (!isPreviewSupportedInRuntime()) {
return AsyncResult.failure(
Cause.fail(
diff --git a/apps/web/src/browser/useOpenLink.test.tsx b/apps/web/src/browser/useOpenLink.test.tsx
index a5fcf444a..73ee27ddd 100644
--- a/apps/web/src/browser/useOpenLink.test.tsx
+++ b/apps/web/src/browser/useOpenLink.test.tsx
@@ -26,8 +26,12 @@ vi.mock("~/localApi", () => ({
}));
vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => mocks.openPreview }));
vi.mock("~/state/preview", () => ({ previewEnvironment: { open: {} } }));
-vi.mock("./openFileInPreview", () => ({ openUrlInPreview: mocks.openUrl }));
+vi.mock("./openFileInPreview", async (importOriginal) => ({
+ ...(await importOriginal()),
+ openUrlInPreview: mocks.openUrl,
+}));
+import { BrowserSettingsReadError } from "./openFileInPreview";
import { useOpenLink } from "./useOpenLink";
const threadRef = { environmentId: "local", threadId: "thread-1" } as ScopedThreadRef;
@@ -101,6 +105,18 @@ describe("useOpenLink", () => {
expect(mocks.recordVisit).not.toHaveBeenCalled();
},
);
+ it.each(["failure", "rejection"])(
+ "rejects a settings read %s without opening either browser",
+ async (kind) => {
+ const error = new BrowserSettingsReadError({ cause: new Error("storage unavailable") });
+ if (kind === "failure")
+ mocks.openUrl.mockResolvedValue(AsyncResult.failure(Cause.fail(error)));
+ else mocks.openUrl.mockRejectedValue(error);
+ await expect(opener(threadRef)(url)).rejects.toBe(error);
+ expect(mocks.openExternal).not.toHaveBeenCalled();
+ expect(mocks.recordVisit).not.toHaveBeenCalled();
+ },
+ );
it("leaves interrupted opens alone", async () => {
mocks.openUrl.mockResolvedValue(AsyncResult.failure(Cause.interrupt()));
await opener(threadRef)(url);
diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts
index 66c2454fa..1f595b9cc 100644
--- a/apps/web/src/browser/useOpenLink.ts
+++ b/apps/web/src/browser/useOpenLink.ts
@@ -1,5 +1,8 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
-import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
import { useCallback } from "react";
import { recordVisitForThread } from "~/browserHistoryStore";
@@ -12,7 +15,7 @@ import {
resolveBrowserLinkTargetPreference,
resolveLinkTarget,
} from "./browserLinkTarget";
-import { openUrlInPreview } from "./openFileInPreview";
+import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview";
const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const;
@@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const;
*
* An in-app open that fails falls back to the system browser rather than
* dropping the click: the user asked for the link, and the setting only says
- * where it should go first. The returned promise rejects only when that
- * fallback fails too, the same way `shell.openExternal` does.
+ * where it should go first. Failed settings reads reject without opening a
+ * browser. The promise also rejects if the system-browser fallback fails.
*/
export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
url: string,
@@ -53,8 +56,11 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
recordVisitForThread(targetThreadRef, url);
return;
}
+ const failure = squashAtomCommandFailure(result);
+ if (failure instanceof BrowserSettingsReadError) throw failure;
console.error(result.cause);
} catch (cause) {
+ if (cause instanceof BrowserSettingsReadError) throw cause;
console.error(cause);
}
}
diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts
index db69fe96c..95a024c9d 100644
--- a/apps/web/src/clientPersistenceStorage.test.ts
+++ b/apps/web/src/clientPersistenceStorage.test.ts
@@ -52,22 +52,106 @@ describe("clientPersistenceStorage", () => {
expect(readBrowserClientSettings()).toEqual(settings);
});
- it("reports structured decode failures while preserving the fallback", async () => {
+ it.each(["not-json", "[]"])(
+ "falls back to defaults for a document that is not settings JSON: %s",
+ async (value) => {
+ const testWindow = getTestWindow();
+ testWindow.localStorage.setItem("t3code:client-settings:v1", value);
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const { readBrowserClientSettings } = await import("./clientPersistenceStorage");
+
+ expect(readBrowserClientSettings()).toBeNull();
+ expect(consoleError).toHaveBeenCalledWith(
+ "Could not read persisted client settings.",
+ ...(value === "not-json"
+ ? [
+ expect.objectContaining({
+ _tag: "LocalStorageOperationError",
+ operation: "decode",
+ storageKey: "t3code:client-settings:v1",
+ }),
+ ]
+ : []),
+ );
+ expect(testWindow.localStorage.getItem("t3code:client-settings:v1")).toBe(value);
+ },
+ );
+
+ // T3 Code served from the same origin writes `confirmQuit` as "hold" | "direct" | "double-click".
+ const t3CodeDocument = JSON.stringify({
+ confirmQuit: "hold",
+ timestampFormat: "12-hour",
+ wordWrap: false,
+ });
+
+ it("keeps readable settings when another client's value cannot be decoded", async () => {
const testWindow = getTestWindow();
- testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json");
- const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ testWindow.localStorage.setItem("t3code:client-settings:v1", t3CodeDocument);
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const write = vi.spyOn(testWindow.localStorage, "setItem");
const { readBrowserClientSettings } = await import("./clientPersistenceStorage");
- expect(readBrowserClientSettings()).toBeNull();
- expect(consoleError).toHaveBeenCalledWith(
- "Could not read persisted client settings.",
+ expect(readBrowserClientSettings()).toEqual({
+ ...DEFAULT_CLIENT_SETTINGS,
+ timestampFormat: "12-hour",
+ wordWrap: false,
+ });
+ expect(write).not.toHaveBeenCalled();
+ });
+
+ it("hydrates the app and leaves an unread value in place until that setting changes", async () => {
+ const testWindow = getTestWindow();
+ testWindow.localStorage.setItem("t3code:client-settings:v1", t3CodeDocument);
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const { ensureClientSettingsHydrated, getClientSettings, persistClientSettingsUpdate } =
+ await import("./hooks/useSettings");
+ const storedDocument = () =>
+ JSON.parse(testWindow.localStorage.getItem("t3code:client-settings:v1") ?? "null");
+
+ await ensureClientSettingsHydrated();
+ expect(getClientSettings()).toEqual({
+ ...DEFAULT_CLIENT_SETTINGS,
+ timestampFormat: "12-hour",
+ wordWrap: false,
+ });
+
+ await persistClientSettingsUpdate((current) => ({
+ ...current,
+ onboardingCompletedAt: "2026-09-10T12:00:00.000Z",
+ }));
+ expect(storedDocument()).toMatchObject({
+ confirmQuit: "hold",
+ timestampFormat: "12-hour",
+ onboardingCompletedAt: "2026-09-10T12:00:00.000Z",
+ });
+
+ await persistClientSettingsUpdate((current) => ({ ...current, confirmQuit: false }));
+ expect(storedDocument()).toMatchObject({ confirmQuit: false });
+ await persistClientSettingsUpdate((current) => ({ ...current, wordWrap: true }));
+ expect(storedDocument()).toMatchObject({ confirmQuit: false, wordWrap: true });
+ });
+
+ it("preserves saved settings across a transient read failure", async () => {
+ const testWindow = getTestWindow();
+ const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const };
+ testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings));
+ const write = vi.spyOn(testWindow.localStorage, "setItem");
+ const failure = new Error("storage unavailable");
+ vi.spyOn(testWindow.localStorage, "getItem").mockImplementationOnce(() => {
+ throw failure;
+ });
+ const { readBrowserClientSettings } = await import("./clientPersistenceStorage");
+
+ expect(() => readBrowserClientSettings()).toThrow(
expect.objectContaining({
_tag: "LocalStorageOperationError",
- operation: "decode",
+ operation: "read",
storageKey: "t3code:client-settings:v1",
- cause: expect.anything(),
+ cause: failure,
}),
);
+ expect(readBrowserClientSettings()).toEqual(settings);
+ expect(write).not.toHaveBeenCalled();
});
it("defaults word wrap on and discards obsolete wrapping preferences", async () => {
diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts
index cdbdef32b..6bb9787a3 100644
--- a/apps/web/src/clientPersistenceStorage.ts
+++ b/apps/web/src/clientPersistenceStorage.ts
@@ -1,30 +1,82 @@
-import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
+import {
+ decodeStoredClientSettings,
+ encodeStoredClientSettings,
+ retainUnreadClientSettings,
+ type ClientSettings,
+ type StoredClientSettings,
+} from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
-import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
+import {
+ getLocalStorageItem,
+ LocalStorageOperationError,
+ setLocalStorageItem,
+} from "./hooks/useLocalStorage";
const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
+const isLocalStorageOperationError = Schema.is(LocalStorageOperationError);
+
+// Pylon and T3 Code share this key when they are served from the same origin,
+// so the document can hold values in shapes this build cannot read.
+let storedClientSettings: StoredClientSettings | null = null;
function hasWindow(): boolean {
return typeof window !== "undefined";
}
+/**
+ * Reads saved client settings. Storage access failures throw so hydration can
+ * retry without replacing preferences it never saw. A document that parses but
+ * holds values this build cannot decode keeps every readable setting and falls
+ * back to defaults for the rest; a document that is not settings JSON at all
+ * falls back to defaults entirely.
+ */
export function readBrowserClientSettings(): ClientSettings | null {
if (!hasWindow()) {
return null;
}
+ let document: unknown;
try {
- return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema);
+ document = getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, Schema.Unknown);
} catch (error) {
+ if (!isLocalStorageOperationError(error) || error.operation !== "decode") {
+ throw error;
+ }
console.error("Could not read persisted client settings.", error);
+ storedClientSettings = null;
+ return null;
+ }
+ if (document === null) {
+ storedClientSettings = null;
return null;
}
+
+ storedClientSettings = decodeStoredClientSettings(document);
+ if (storedClientSettings === null) {
+ console.error("Could not read persisted client settings.");
+ return null;
+ }
+ const unreadKeys = Object.keys(storedClientSettings.unreadValues);
+ if (unreadKeys.length > 0) {
+ console.error("Some persisted client settings could not be read; using defaults for them.", {
+ settings: unreadKeys,
+ });
+ }
+ return storedClientSettings.settings;
}
+/** Writes settings, leaving unread stored values in place until their setting changes. */
export function writeBrowserClientSettings(settings: ClientSettings): void {
if (!hasWindow()) {
return;
}
- setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema);
+ const next = retainUnreadClientSettings(settings, storedClientSettings);
+ setLocalStorageItem(
+ CLIENT_SETTINGS_STORAGE_KEY,
+ encodeStoredClientSettings(next),
+ Schema.Unknown,
+ );
+ storedClientSettings = next;
}
diff --git a/apps/web/src/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx
index d0e21b390..719c01494 100644
--- a/apps/web/src/components/AnimatedHeight.tsx
+++ b/apps/web/src/components/AnimatedHeight.tsx
@@ -4,7 +4,14 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "re
const HEIGHT_TRANSITION_FALLBACK_MS = 250;
-export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
+export function AnimatedHeight({
+ children,
+ holdHeight = false,
+}: {
+ readonly children: ReactNode;
+ /** Retain the previous content height while a replacement is loading. */
+ readonly holdHeight?: boolean;
+}) {
const contentRef = useRef(null);
const [heightState, setHeightState] = useState<{
readonly height: number | null;
@@ -22,6 +29,7 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
}, [heightState.height, heightState.isClipping]);
useLayoutEffect(() => {
+ if (holdHeight) return;
const element = contentRef.current;
if (!element) return;
let firstFrameId: number | null = null;
@@ -67,7 +75,7 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
resizeObserver.disconnect();
cancelPendingFrames();
};
- }, []);
+ }, [holdHeight]);
return (
-
{children}
+
+ {children}
+
);
}
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index e80147e95..5eba1cde5 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -178,6 +178,7 @@ import {
openFileInPreview,
openUrlInPreview,
BrowserPreviewUnavailableError,
+ BrowserSettingsReadError,
} from "../browser/openFileInPreview";
import { resolveLinkTarget } from "../browser/browserLinkTarget";
import { useOpenLink } from "../browser/useOpenLink";
@@ -2441,6 +2442,18 @@ function useChatMarkdownState({
}
return openUrlInPreview({ threadRef, url, openPreview }).then((result) => {
if (result._tag === "Success") recordVisitForThread(threadRef, url);
+ else if (!isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ if (error instanceof BrowserSettingsReadError) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Unable to open link in browser",
+ description: error.message,
+ }),
+ );
+ }
+ }
return result;
});
},
diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index c02b3d6ea..f506a4d41 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -7,9 +7,13 @@ import {
ProviderInstanceId,
ThreadId,
TurnId,
+ type ServerProvider,
} from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import { resolveComposerInstanceSelection } from "../composerInstanceSelection";
+import { deriveProviderInstanceEntries } from "../providerInstances";
+
import type { Thread, ThreadShell, TurnDiffSummary } from "../types";
import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
import {
@@ -28,6 +32,7 @@ import {
buildThreadTurnInterruptInput,
createLocalDispatchSnapshot,
deriveComposerSendState,
+ deriveLockedProvider,
dismissBranchMismatchForSession,
ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
getStartedThreadModelChangeBlockReason,
@@ -812,6 +817,151 @@ describe("buildThreadTurnInterruptInput", () => {
});
});
+describe("deriveLockedProvider for imported history", () => {
+ function entry(driver: string, instanceId = driver, overrides: Partial = {}) {
+ return deriveProviderInstanceEntries([
+ {
+ driver: ProviderDriverKind.make(driver),
+ instanceId: ProviderInstanceId.make(instanceId),
+ enabled: true,
+ installed: true,
+ status: "ready",
+ auth: { status: "authenticated" },
+ version: null,
+ checkedAt: now,
+ models: [],
+ slashCommands: [],
+ skills: [],
+ ...overrides,
+ },
+ ])[0]!;
+ }
+
+ function importedThread(instanceId: ProviderInstanceId) {
+ return makeThread({
+ modelSelection: { instanceId, model: "default" },
+ messages: [
+ {
+ id: MessageId.make(`import:${instanceId}:session:000000`),
+ role: "user",
+ text: "Continue the imported conversation",
+ turnId: null,
+ createdAt: now,
+ updatedAt: now,
+ streaming: false,
+ },
+ ],
+ });
+ }
+
+ function selectComposerInstance(input: {
+ readonly entries: ReadonlyArray>;
+ readonly draftActiveProvider: ProviderInstanceId | null;
+ readonly threadInstanceId: ProviderInstanceId;
+ readonly lockedProvider: ProviderDriverKind | null;
+ }) {
+ return resolveComposerInstanceSelection({
+ entries: input.entries,
+ draftActiveProvider: input.draftActiveProvider,
+ sessionInstanceId: null,
+ threadInstanceId: input.threadInstanceId,
+ projectInstanceId: null,
+ lockedProvider: input.lockedProvider,
+ nowMs: Date.parse(now),
+ });
+ }
+
+ it.each([
+ ["claudeAgent", "claude_work"],
+ ["codex", "codex_work"],
+ ["ollama", "local_models"],
+ ])("keeps imported %s history selectable through its custom instance", (driver, instanceId) => {
+ const importedEntry = entry(driver, instanceId);
+ const entries = [entry(driver === "codex" ? "claudeAgent" : "codex"), importedEntry];
+ const thread = importedThread(importedEntry.instanceId);
+ const lockedProvider = deriveLockedProvider({
+ thread,
+ selectedProvider: entries[0]!.instanceId,
+ threadProvider: thread.modelSelection.instanceId,
+ providers: entries.map((entry) => entry.snapshot),
+ });
+
+ expect(thread.session).toBeNull();
+ expect(lockedProvider).toBe(driver);
+ expect(
+ selectComposerInstance({
+ entries,
+ draftActiveProvider: null,
+ threadInstanceId: thread.modelSelection.instanceId,
+ lockedProvider,
+ }).entry?.instanceId,
+ ).toBe(importedEntry.instanceId);
+ });
+
+ it("keeps the session driver authoritative over instance and draft selections", () => {
+ const selected = entry("claudeAgent", "claude_work");
+ const sessionEntry = entry("ollama", "local_models");
+ const thread = importedThread(selected.instanceId);
+
+ expect(
+ deriveLockedProvider({
+ thread: {
+ ...thread,
+ session: {
+ ...readySession,
+ providerName: sessionEntry.driverKind,
+ providerInstanceId: sessionEntry.instanceId,
+ },
+ },
+ selectedProvider: selected.instanceId,
+ threadProvider: thread.modelSelection.instanceId,
+ providers: [selected.snapshot, sessionEntry.snapshot],
+ }),
+ ).toBe(sessionEntry.driverKind);
+ });
+
+ it.each(["missing", "disabled"] as const)(
+ "does not move imported history to another driver when its instance is %s",
+ (state) => {
+ const imported = entry("claudeAgent", "claude_work", { enabled: false });
+ const other = entry("codex");
+ const entries = state === "missing" ? [other] : [other, imported];
+ const thread = importedThread(imported.instanceId);
+ const lockedProvider = deriveLockedProvider({
+ thread,
+ selectedProvider: other.instanceId,
+ threadProvider: thread.modelSelection.instanceId,
+ providers: entries.map((entry) => entry.snapshot),
+ });
+
+ expect(lockedProvider).not.toBeNull();
+ expect(
+ selectComposerInstance({
+ entries,
+ draftActiveProvider: other.instanceId,
+ threadInstanceId: imported.instanceId,
+ lockedProvider,
+ }).entry,
+ ).toBeUndefined();
+ },
+ );
+
+ it("leaves a new draft free to select a different driver", () => {
+ const original = entry("claudeAgent", "claude_work");
+ const selected = entry("codex", "codex_work");
+ expect(
+ deriveLockedProvider({
+ thread: makeThread({
+ modelSelection: { instanceId: original.instanceId, model: "default" },
+ }),
+ selectedProvider: selected.instanceId,
+ threadProvider: original.instanceId,
+ providers: [original.snapshot, selected.snapshot],
+ }),
+ ).toBeNull();
+ });
+});
+
describe("deriveComposerSendState", () => {
it("treats expired terminal pills as non-sendable content", () => {
const state = deriveComposerSendState({
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index 1e7bfc150..96b15e61d 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -684,22 +684,13 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean {
);
}
-// `threadProvider` is the open branded driver kind carried by the session.
-// Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe
-// rollback / fork behavior — the routing layer is the right place to surface
-// "driver not installed" errors, not the lock state.
-//
-// `selectedProvider` takes the same open-string shape because the composer
-// now tracks the picker selection as a `ProviderInstanceId` (e.g.
-// `codex_personal`). Custom instance ids that don't directly match a
-// registered driver resolve to `null` here, which matches the existing
-// "unknown driver -> unlocked" semantics. Callers that want the lock to track
-// a custom instance's underlying driver kind should resolve the instance id
-// upstream and pass the correlated kind.
+// Imported history has no session until its first prompt. Resolve its instance
+// through the environment's provider catalog before locking to a driver.
export function deriveLockedProvider(input: {
thread: Thread | null | undefined;
selectedProvider: string | null;
threadProvider: string | null;
+ providers: ReadonlyArray>;
}): ProviderDriverKind | null {
if (!threadHasStarted(input.thread)) {
return null;
@@ -708,14 +699,18 @@ export function deriveLockedProvider(input: {
if (sessionProvider && isProviderDriverKind(sessionProvider)) {
return sessionProvider;
}
+ // Preserve the existing lock while an instance is missing from the catalog;
+ // a started thread must not silently fall back to a different driver.
+ const threadProvider =
+ input.providers.find((provider) => provider.instanceId === input.threadProvider)?.driver ??
+ input.threadProvider;
+ const selectedProvider =
+ input.providers.find((provider) => provider.instanceId === input.selectedProvider)?.driver ??
+ input.selectedProvider;
const narrowedThreadProvider =
- input.threadProvider && isProviderDriverKind(input.threadProvider)
- ? input.threadProvider
- : null;
+ threadProvider && isProviderDriverKind(threadProvider) ? threadProvider : null;
const narrowedSelectedProvider =
- input.selectedProvider && isProviderDriverKind(input.selectedProvider)
- ? input.selectedProvider
- : null;
+ selectedProvider && isProviderDriverKind(selectedProvider) ? selectedProvider : null;
return narrowedThreadProvider ?? narrowedSelectedProvider ?? null;
}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 6d2c4b664..5d5da624e 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -187,6 +187,7 @@ import {
useThreadPreviewState,
} from "../previewStateStore";
import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
+import { BrowserSettingsReadError } from "../browser/openFileInPreview";
import { addBrowserSurface } from "./preview/addBrowserSurface";
import { closePreviewSession } from "./preview/closePreviewSession";
import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
@@ -2411,6 +2412,12 @@ export default function ChatView(props: ChatViewProps) {
[openOrReuseProjectDraftThread],
);
+ // Once a thread selects an environment, never substitute the primary
+ // environment's config while the selected environment is still loading.
+ const serverConfig = activeThread
+ ? (activeEnvironment?.serverConfig ?? null)
+ : (primaryEnvironment?.serverConfig ?? null);
+ const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
const selectedProviderByThreadId = composerActiveProvider ?? null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
@@ -2420,12 +2427,8 @@ export default function ChatView(props: ChatViewProps) {
thread: activeThread,
selectedProvider: selectedProviderByThreadId,
threadProvider,
+ providers: providerStatuses,
});
- // Once a thread selects an environment, never substitute the primary
- // environment's config while the selected environment is still loading.
- const serverConfig = activeThread
- ? (activeEnvironment?.serverConfig ?? null)
- : (primaryEnvironment?.serverConfig ?? null);
const pullRequestsCapabilityKnown = serverConfig !== null;
const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;
// Same fallback the load-failure view offers: a pull request stays readable on
@@ -2657,7 +2660,6 @@ export default function ChatView(props: ChatViewProps) {
versionMismatchThreadContinuation,
versionMismatchServerLabel,
]);
- const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
const threadHandoffEntries = useMemo(
() => applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings),
[providerStatuses, settings],
@@ -4083,6 +4085,18 @@ export default function ChatView(props: ChatViewProps) {
threadRef: activeThreadRef,
openPreview,
...(profileId === undefined ? {} : { profileId }),
+ }).then((result) => {
+ if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
+ const error = squashAtomCommandFailure(result);
+ if (error instanceof BrowserSettingsReadError) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Unable to open browser",
+ description: error.message,
+ }),
+ );
+ }
});
},
[activeThreadRef, openPreview],
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index 0c306571e..edb2b75c8 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -47,6 +47,7 @@ import {
FileSearchIcon,
FolderIcon,
FolderPlusIcon,
+ ImportIcon,
LinkIcon,
LibraryIcon,
MessageCircleQuestionIcon,
@@ -1768,6 +1769,28 @@ function OpenCommandPaletteDialog(props: {
});
}
+ // First-run setup has no other way back in once it is finished.
+ actionItems.push({
+ kind: "action",
+ value: "action:welcome-setup",
+ searchTerms: [
+ "setup",
+ "welcome",
+ "onboarding",
+ "import",
+ "projects",
+ "computers",
+ "agents",
+ "claude",
+ "codex",
+ ],
+ title: "Set up computers and import projects",
+ icon: ,
+ run: async () => {
+ await navigate({ to: "/welcome" });
+ },
+ });
+
actionItems.push({
kind: "action",
value: "action:theme-editor",
diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx
new file mode 100644
index 000000000..09bd92c82
--- /dev/null
+++ b/apps/web/src/components/NoProjectsHero.tsx
@@ -0,0 +1,36 @@
+import { PlusIcon } from "lucide-react";
+import { useCallback } from "react";
+
+import { openCommandPalette } from "../commandPaletteBus";
+import { Button } from "./ui/button";
+import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
+import { SidebarInset } from "./ui/sidebar";
+
+export function NoProjectsHero() {
+ const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
+
+ return (
+
+
+
+
+
+
+ What should we work on?
+
+
+ Add a project to start your first thread.
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts
index fa72d1593..26aa667ae 100644
--- a/apps/web/src/components/ThreadTerminalDrawer.test.ts
+++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts
@@ -1,12 +1,99 @@
-import { describe, expect, it } from "vite-plus/test";
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import {
resolveTerminalPathOpenTargets,
shouldClearTerminalSelectionAction,
shouldHandleTerminalExit,
+ terminalContextMenuItems,
terminalSelectionLineRange,
+ terminalSelectionMenuItems,
+ terminalThemeFromApp,
} from "./ThreadTerminalDrawer";
+describe("terminal selection menus", () => {
+ it("omits Add to chat when the terminal has no chat target", () => {
+ expect(terminalSelectionMenuItems().map(({ id }) => id)).toEqual(["add-to-chat", "copy"]);
+ expect(terminalContextMenuItems({ hasSelection: true }).map(({ id }) => id)).toEqual([
+ "add-to-chat",
+ "copy",
+ "paste",
+ ]);
+
+ expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([
+ "copy",
+ ]);
+ expect(
+ terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id),
+ ).toEqual(["copy", "paste"]);
+ });
+});
+
+describe("terminalThemeFromApp", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("uses terminal colors inherited by the mount instead of a light document theme", () => {
+ const root = { classList: { contains: () => false } };
+ const body = {};
+ const drawer = {};
+ let canvasColor = "#000";
+ const colors: Record = {
+ "#000": [0, 0, 0, 255],
+ "#fff": [255, 255, 255, 255],
+ "#ddd": [221, 221, 221, 255],
+ "#111": [17, 17, 17, 255],
+ };
+
+ vi.stubGlobal("document", {
+ documentElement: root,
+ body,
+ querySelector: () => drawer,
+ createElement: () => ({
+ width: 0,
+ height: 0,
+ getContext: () => ({
+ clearRect: () => undefined,
+ fillRect: () => undefined,
+ get fillStyle() {
+ return canvasColor;
+ },
+ set fillStyle(value: string) {
+ canvasColor = value;
+ },
+ getImageData: () => ({ data: colors[canvasColor] ?? [0, 0, 0, 0] }),
+ }),
+ }),
+ });
+ vi.stubGlobal("getComputedStyle", (element: object) => {
+ const local = element === drawer;
+ const values = local
+ ? {
+ "--terminal-background": "#000",
+ "--terminal-foreground": "#fff",
+ "--terminal-cursor": "#ddd",
+ "--terminal-selection-background": "rgba(255, 255, 255, 0.2)",
+ }
+ : {
+ "--terminal-background": "#fff",
+ "--terminal-foreground": "#111",
+ };
+ return {
+ backgroundColor: local ? "#000" : "#fff",
+ color: local ? "#fff" : "#111",
+ colorScheme: local ? "dark" : "light",
+ getPropertyValue: (name: string) => values[name as keyof typeof values] ?? "",
+ };
+ });
+
+ const theme = terminalThemeFromApp();
+
+ expect(theme.background).toEqual({ r: 0, g: 0, b: 0 });
+ expect(theme.foreground).toEqual({ r: 255, g: 255, b: 255 });
+ expect(theme.cursor).toEqual({ r: 221, g: 221, b: 221 });
+ });
+});
+
describe("terminal selection actions", () => {
it("clears a pending or currently owned menu when the selection disappears", () => {
expect(
diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx
index cc1ed66a7..a5ab721d7 100644
--- a/apps/web/src/components/ThreadTerminalDrawer.tsx
+++ b/apps/web/src/components/ThreadTerminalDrawer.tsx
@@ -21,6 +21,7 @@ import {
} from "lucide-react";
import {
type ContextMenuItem,
+ type ProviderInstanceId,
type ResolvedKeybindingsConfig,
type ScopedThreadRef,
type ThreadId,
@@ -42,6 +43,7 @@ import {
import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
import { Button } from "~/components/ui/button";
import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
+import { stackedThreadToast, toastManager } from "~/components/ui/toast";
import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { cn } from "~/lib/utils";
import { type TerminalContextSelection } from "~/lib/terminalContext";
@@ -173,16 +175,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s
}
export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme {
- const isDark = document.documentElement.classList.contains("dark");
- const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)";
- const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)";
const drawerSurface =
mountElement?.closest(".thread-terminal-drawer") ??
document.querySelector(".thread-terminal-drawer") ??
document.body;
const drawerStyles = getComputedStyle(drawerSurface);
+ const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles;
+ const colorScheme = themeStyles.colorScheme;
+ const isDark =
+ colorScheme === "dark"
+ ? true
+ : colorScheme === "light"
+ ? false
+ : document.documentElement.classList.contains("dark");
+ const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)";
+ const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)";
const bodyStyles = getComputedStyle(document.body);
- const themeStyles = getComputedStyle(document.documentElement);
+ const rootThemeStyles = getComputedStyle(document.documentElement);
const background = normalizeComputedColor(
drawerStyles.backgroundColor,
normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),
@@ -191,8 +200,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty
drawerStyles.color,
normalizeComputedColor(bodyStyles.color, fallbackForeground),
);
- const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background);
- const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground);
+ const terminalBackground = readThemeColor(
+ themeStyles,
+ "--terminal-background",
+ readThemeColor(rootThemeStyles, "--terminal-background", background),
+ );
+ const terminalForeground = readThemeColor(
+ themeStyles,
+ "--terminal-foreground",
+ readThemeColor(rootThemeStyles, "--terminal-foreground", foreground),
+ );
const terminalCursor = readThemeColor(
themeStyles,
"--terminal-cursor",
@@ -233,10 +250,14 @@ export function terminalSelectionLineRange(position: {
export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste";
-/** Post-selection popup: just the two selection actions, always enabled. */
-function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] {
+/** Post-selection popup: available selection actions, always enabled. */
+export function terminalSelectionMenuItems(options?: {
+ canAddToChat?: boolean;
+}): ContextMenuItem<"add-to-chat" | "copy">[] {
return [
- { id: "add-to-chat", label: "Add to chat" },
+ ...(options?.canAddToChat === false
+ ? []
+ : ([{ id: "add-to-chat", label: "Add to chat" }] satisfies ContextMenuItem<"add-to-chat">[])),
{ id: "copy", label: "Copy" },
];
}
@@ -247,13 +268,15 @@ function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[]
* (and Electron's default editing menu) can only paste into an editable
* element, so a canvas terminal never gets a usable entry from them.
*/
-function terminalContextMenuItems(options: {
+export function terminalContextMenuItems(options: {
hasSelection: boolean;
+ canAddToChat?: boolean;
}): ContextMenuItem[] {
+ const { hasSelection, canAddToChat = true } = options;
return [
- ...terminalSelectionMenuItems().map((item) => ({
+ ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({
...item,
- disabled: !options.hasSelection,
+ disabled: !hasSelection,
})),
{ id: "paste", label: "Paste" },
];
@@ -293,8 +316,9 @@ interface TerminalViewportProps {
cwd: string;
worktreePath?: string | null;
runtimeEnv?: Record;
+ providerInstanceId?: ProviderInstanceId;
onSessionExited: () => void;
- onAddTerminalContext: (selection: TerminalContextSelection) => void;
+ onAddTerminalContext?: (selection: TerminalContextSelection) => void;
focusRequestId: number;
autoFocus: boolean;
visible: boolean;
@@ -317,7 +341,7 @@ interface TerminalLaunchLocation {
readonly runtimeEnv?: Record;
}
-function TerminalViewport({
+export function TerminalViewport({
advancedTypography,
threadRef,
threadId,
@@ -326,6 +350,7 @@ function TerminalViewport({
cwd,
worktreePath,
runtimeEnv,
+ providerInstanceId,
onSessionExited,
onAddTerminalContext,
focusRequestId,
@@ -368,8 +393,9 @@ function TerminalViewport({
onSessionExited();
});
const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {
- onAddTerminalContext(selection);
+ onAddTerminalContext?.(selection);
});
+ const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined);
const readTerminalLabel = useEffectEvent(() => terminalLabel);
const terminalFontFamily = useClientSettings((settings) =>
resolveTerminalFontPreference({
@@ -394,6 +420,7 @@ function TerminalViewport({
cwd,
...(worktreePath !== undefined ? { worktreePath } : {}),
...(runtimeEnv ? { env: runtimeEnv } : {}),
+ ...(providerInstanceId ? { providerInstanceId } : {}),
},
});
const writeTerminal = useEffectEvent((data: string) =>
@@ -644,7 +671,10 @@ function TerminalViewport({
let clicked: TerminalContextMenuAction | null;
try {
clicked = await localApi.contextMenu.show(
- terminalContextMenuItems({ hasSelection: selectionAction !== null }),
+ terminalContextMenuItems({
+ hasSelection: selectionAction !== null,
+ canAddToChat: canAddSelectionToChat(),
+ }),
{ x: event.clientX, y: event.clientY },
);
} catch (error) {
@@ -657,7 +687,9 @@ function TerminalViewport({
}
switch (clicked) {
case "add-to-chat":
- if (selectionAction) addSelectionToChat(selectionAction.selection);
+ if (selectionAction && canAddSelectionToChat()) {
+ addSelectionToChat(selectionAction.selection);
+ }
return;
case "copy":
if (selectionAction) await copySelection(selectionAction.clipboardText, requestId);
@@ -684,7 +716,10 @@ function TerminalViewport({
const requestId = ++selectionActionRequestIdRef.current;
openSelectionMenuRequestIdRef.current = requestId;
const clicked = await localApi.contextMenu
- .show(terminalSelectionMenuItems(), nextAction.position)
+ .show(
+ terminalSelectionMenuItems({ canAddToChat: canAddSelectionToChat() }),
+ nextAction.position,
+ )
.finally(() => {
if (openSelectionMenuRequestIdRef.current === requestId) {
openSelectionMenuRequestIdRef.current = null;
@@ -695,7 +730,7 @@ function TerminalViewport({
}
switch (clicked) {
case "add-to-chat":
- addSelectionToChat(nextAction.selection);
+ if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection);
return;
case "copy":
await copySelection(nextAction.clipboardText, requestId);
@@ -777,6 +812,14 @@ function TerminalViewport({
openPreview,
fallbackToBrowser,
forceBrowser: event.metaKey || event.ctrlKey,
+ }).catch((error: unknown) => {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Unable to open link",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
});
return;
}
diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx
new file mode 100644
index 000000000..6474c2208
--- /dev/null
+++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx
@@ -0,0 +1,324 @@
+import type { Discovery } from "@t3tools/client-runtime/relay";
+import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
+import { EnvironmentId } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { AsyncResult } from "effect/unstable/reactivity";
+import { act, useState, type ButtonHTMLAttributes, type ReactNode } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"];
+
+const discovery = vi.hoisted(() => ({
+ state: null as Discovery.RelayEnvironmentDiscoveryState | null,
+ listeners: new Set<() => void>(),
+ refreshCommand: Symbol("refresh"),
+ registerCommand: Symbol("register"),
+ refresh: vi.fn<() => Promise>>(),
+ register: vi.fn(),
+ listEnvironments: vi.fn<() => Promise>(),
+}));
+
+vi.mock("~/state/relay", () => ({
+ relayEnvironmentDiscovery: { refresh: discovery.refreshCommand },
+}));
+vi.mock("~/connection/catalog", () => ({
+ environmentCatalog: { register: discovery.registerCommand },
+}));
+vi.mock("~/state/use-atom-command", () => ({
+ useAtomCommand: (command: unknown) =>
+ command === discovery.refreshCommand ? discovery.refresh : discovery.register,
+}));
+vi.mock("~/state/environments", async () => {
+ const { useSyncExternalStore } = await import("react");
+ const subscribe = (listener: () => void) => {
+ discovery.listeners.add(listener);
+ return () => discovery.listeners.delete(listener);
+ };
+ const read = () => {
+ if (discovery.state === null) throw new Error("Discovery fixture is not initialized");
+ return discovery.state;
+ };
+ return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) };
+});
+vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null }));
+vi.mock("../ui/tooltip", () => ({
+ Tooltip: ({ children }: { children: ReactNode }) => children,
+ TooltipTrigger: ({ children }: { children: ReactNode }) => {children},
+ TooltipPopup: () => null,
+}));
+vi.mock("../ui/checkbox", () => ({
+ Checkbox: (props: {
+ checked: boolean;
+ disabled: boolean;
+ onCheckedChange: (checked: boolean) => void;
+ }) => (
+ props.onCheckedChange(event.target.checked)}
+ />
+ ),
+}));
+vi.mock("../ui/button", () => ({
+ Button: ({ children, ...props }: ButtonHTMLAttributes) => (
+
+ ),
+}));
+vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
+
+import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
+
+const newMachineId = EnvironmentId.make("new-computer");
+const linkedMachines: DiscoveredEnvironments = new Map([
+ [
+ newMachineId,
+ {
+ environment: {
+ environmentId: newMachineId,
+ label: "Work laptop",
+ endpoint: {
+ httpBaseUrl: "https://relay.example.test",
+ wsBaseUrl: "wss://relay.example.test/ws",
+ providerKind: "manual",
+ },
+ linkedAt: "2026-09-05T12:00:00.000Z",
+ },
+ availability: "online",
+ status: Option.none(),
+ error: Option.none(),
+ },
+ ],
+]);
+
+let renderer: ReactTestRenderer | null;
+let page: EventTarget & { visibilityState: DocumentVisibilityState };
+let browserWindow: EventTarget;
+
+function publish(state: Discovery.RelayEnvironmentDiscoveryState) {
+ discovery.state = state;
+ for (const listener of discovery.listeners) listener();
+}
+
+async function mount(refreshWhileEmpty = true) {
+ await act(async () => {
+ renderer = create(
+ Waiting for your computer to connect.
}
+ />,
+ );
+ });
+}
+
+async function advance(milliseconds: number) {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(milliseconds);
+ });
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ page = Object.assign(new EventTarget(), { visibilityState: "visible" as const });
+ browserWindow = new EventTarget();
+ vi.stubGlobal("document", page);
+ vi.stubGlobal("window", browserWindow);
+ renderer = null;
+ discovery.listeners.clear();
+ discovery.state = {
+ environments: new Map(),
+ refreshing: false,
+ offline: false,
+ error: Option.none(),
+ };
+ discovery.listEnvironments.mockReset().mockResolvedValue(new Map());
+ discovery.register.mockReset().mockResolvedValue(AsyncResult.success(undefined));
+ discovery.refresh.mockReset().mockImplementation(async () => {
+ publish({ environments: new Map(), refreshing: true, offline: false, error: Option.none() });
+ const environments = await discovery.listEnvironments();
+ publish({ environments, refreshing: false, offline: false, error: Option.none() });
+ return AsyncResult.success(undefined);
+ });
+});
+
+afterEach(async () => {
+ await act(async () => renderer?.unmount());
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe("cloud onboarding discovery", () => {
+ it("signals that the section can expand after initial discovery settles", async () => {
+ let finishDiscovery!: (environments: DiscoveredEnvironments) => void;
+ discovery.listEnvironments.mockReturnValue(
+ new Promise((resolve) => {
+ finishDiscovery = resolve;
+ }),
+ );
+ const onDiscoveryReady = vi.fn();
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+ expect(onDiscoveryReady).not.toHaveBeenCalled();
+ await act(async () => {
+ finishDiscovery(linkedMachines);
+ });
+ expect(onDiscoveryReady).toHaveBeenCalledTimes(1);
+ expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);
+ });
+
+ it("connects and selects discovered computers by default without overwriting deselection", async () => {
+ discovery.listEnvironments.mockResolvedValue(linkedMachines);
+ const autoSelectedComputers = new Set();
+ function Setup() {
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ return (
+
+ setSelectedIds((current) => {
+ const next = new Set(current);
+ if (checked) next.add(id);
+ else next.delete(id);
+ return next;
+ }),
+ }}
+ />
+ );
+ }
+ await act(async () => {
+ renderer = create();
+ });
+
+ expect(discovery.register).toHaveBeenCalledTimes(1);
+ expect(renderer!.root.findByType("input").props.checked).toBe(true);
+ await act(async () => {
+ await renderer!.root.findByType("input").props.onChange({ target: { checked: false } });
+ });
+ await act(async () => {
+ publish({ ...discovery.state!, environments: new Map(linkedMachines) });
+ });
+ expect(renderer!.root.findByType("input").props.checked).toBe(false);
+ expect(discovery.register).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows a newly linked computer without remounting and stops polling once found", async () => {
+ discovery.listEnvironments
+ .mockResolvedValueOnce(new Map())
+ .mockResolvedValueOnce(linkedMachines);
+ await mount();
+ expect(renderer!.root.findByType("p").children).toEqual([
+ "Waiting for your computer to connect.",
+ ]);
+
+ await advance(5_000);
+
+ expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([
+ "Work laptop",
+ ]);
+ expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);
+ await advance(30_000);
+ expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
+ });
+
+ it("keeps a discovered computer visible when it is added to the browser", async () => {
+ discovery.listEnvironments.mockResolvedValue(linkedMachines);
+ await mount();
+ expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);
+ await act(async () => {
+ renderer!.update(
+ ,
+ );
+ });
+ expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([
+ "Work laptop",
+ ]);
+ expect(renderer!.root.findByType("button").children).toEqual(["Connected"]);
+ });
+
+ it("waits while hidden and refreshes immediately when visible again", async () => {
+ page.visibilityState = "hidden";
+ await mount();
+ await advance(30_000);
+ expect(discovery.listEnvironments).not.toHaveBeenCalled();
+
+ await act(async () => {
+ page.visibilityState = "visible";
+ page.dispatchEvent(new Event("visibilitychange"));
+ });
+ expect(discovery.listEnvironments).toHaveBeenCalledTimes(1);
+
+ page.visibilityState = "hidden";
+ page.dispatchEvent(new Event("visibilitychange"));
+ browserWindow.dispatchEvent(new Event("focus"));
+ await advance(30_000);
+ expect(discovery.listEnvironments).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not overlap a slow refresh or restart polling after unmount", async () => {
+ let resolveRefresh!: (environments: DiscoveredEnvironments) => void;
+ const pending = new Promise((resolve) => {
+ resolveRefresh = resolve;
+ });
+ discovery.listEnvironments.mockResolvedValueOnce(new Map()).mockReturnValueOnce(pending);
+ await mount();
+ await advance(5_000);
+ expect(renderer!.root.findByType("p").children).toEqual([
+ "Waiting for your computer to connect.",
+ ]);
+
+ browserWindow.dispatchEvent(new Event("focus"));
+ page.dispatchEvent(new Event("visibilitychange"));
+ await advance(30_000);
+ expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
+
+ await act(async () => renderer!.unmount());
+ renderer = null;
+ await act(async () => resolveRefresh(new Map()));
+ await advance(30_000);
+ expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
+ });
+
+ it("pauses while offline and resumes when discovery is online", async () => {
+ await mount();
+ await act(async () => publish({ ...discovery.state!, offline: true }));
+ await advance(30_000);
+ expect(discovery.listEnvironments).toHaveBeenCalledTimes(1);
+
+ await act(async () => publish({ ...discovery.state!, offline: false }));
+ await advance(5_000);
+ expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not add polling to other cloud lists", async () => {
+ await mount(false);
+ await advance(30_000);
+ expect(discovery.listEnvironments).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
index 63bb48090..299aa4d44 100644
--- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
+++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
@@ -11,7 +11,7 @@ import {
import type { EnvironmentId } from "@t3tools/contracts";
import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
import * as Option from "effect/Option";
-import { type ReactNode, useCallback, useEffect, useState } from "react";
+import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react";
import { environmentCatalog } from "~/connection/catalog";
import { cn } from "~/lib/utils";
@@ -20,11 +20,15 @@ import { useRelayEnvironmentDiscovery } from "~/state/environments";
import { useAtomCommand } from "~/state/use-atom-command";
import { ConnectionStatusDot } from "../ConnectionStatusDot";
import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows";
+import { Checkbox } from "../ui/checkbox";
import { Button } from "../ui/button";
import { Skeleton } from "../ui/skeleton";
import { toastManager } from "../ui/toast";
+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";
+const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000;
+
export interface SavedCloudEnvironmentConnection {
readonly environmentId: EnvironmentId;
readonly connection: EnvironmentConnectionPresentation;
@@ -55,12 +59,22 @@ export function CloudEnvironmentConnectRows({
primaryEnvironmentId,
savedEnvironments,
showSavedEnvironments = false,
+ refreshWhileEmpty = false,
empty = null,
+ selection,
+ onDiscoveryReady,
}: {
readonly primaryEnvironmentId: EnvironmentId | null;
readonly savedEnvironments: ReadonlyArray;
readonly showSavedEnvironments?: boolean;
+ readonly refreshWhileEmpty?: boolean;
readonly empty?: ReactNode;
+ readonly onDiscoveryReady?: () => void;
+ readonly selection?: {
+ readonly autoSelectedComputers?: Set;
+ readonly selectedIds: ReadonlySet;
+ readonly onChange: (environmentId: EnvironmentId, selected: boolean) => void;
+ };
}) {
const environmentsState = useRelayEnvironmentDiscovery();
const registerEnvironment = useAtomCommand(environmentCatalog.register, {
@@ -69,6 +83,10 @@ export function CloudEnvironmentConnectRows({
const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
reportFailure: false,
});
+ const refreshDiscoveryWhenIdle = useEffectEvent(async () => {
+ if (environmentsState.refreshing || environmentsState.offline) return;
+ await refreshRelayEnvironments();
+ });
const connectRelayEnvironment = useCallback(
(environment: RelayClientEnvironmentRecord) =>
registerEnvironment(
@@ -81,31 +99,43 @@ export function CloudEnvironmentConnectRows({
),
[registerEnvironment],
);
- const [connectingEnvironmentId, setConnectingEnvironmentId] = useState(
- null,
- );
+ const [connectingEnvironmentIds, setConnectingEnvironmentIds] = useState<
+ ReadonlySet
+ >(new Set());
const savedById = new Map(
savedEnvironments.map((environment) => [environment.environmentId, environment]),
);
useEffect(() => {
- void refreshRelayEnvironments();
- }, [refreshRelayEnvironments]);
+ let active = true;
+ if (onDiscoveryReady || !refreshWhileEmpty || document.visibilityState === "visible") {
+ void refreshRelayEnvironments().then(() => {
+ if (active) onDiscoveryReady?.();
+ });
+ }
+ return () => {
+ active = false;
+ };
+ }, [refreshRelayEnvironments, refreshWhileEmpty, onDiscoveryReady]);
const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {
- setConnectingEnvironmentId(environment.environmentId);
+ setConnectingEnvironmentIds((current) => new Set([...current, environment.environmentId]));
const result = await connectRelayEnvironment(environment);
- setConnectingEnvironmentId(null);
+ setConnectingEnvironmentIds((current) => {
+ const next = new Set(current);
+ next.delete(environment.environmentId);
+ return next;
+ });
if (result._tag === "Success") {
toastManager.add({
type: "success",
title: "Environment added",
description: `Connecting to ${environment.label} through Pylon Connect.`,
});
- return;
+ return true;
}
if (isAtomCommandInterrupted(result)) {
- return;
+ return false;
}
const cause = squashAtomCommandFailure(result);
const message =
@@ -125,6 +155,7 @@ export function CloudEnvironmentConnectRows({
}
: undefined,
});
+ return false;
};
const visibleEnvironments = [...environmentsState.environments.values()].filter(
@@ -132,10 +163,73 @@ export function CloudEnvironmentConnectRows({
environment.environmentId !== primaryEnvironmentId &&
(showSavedEnvironments || !savedById.has(environment.environmentId)),
);
+ const selectNewComputers = useEffectEvent(() => {
+ const seen = selection?.autoSelectedComputers;
+ if (!selection || !seen) return;
+ for (const { environment } of visibleEnvironments) {
+ const id = environment.environmentId;
+ if (seen.has(id)) continue;
+ seen.add(id);
+ selection.onChange(id, true);
+ if (!savedById.has(id)) {
+ void connectEnvironment(environment).then((connected) => {
+ if (!connected) selection.onChange(id, false);
+ });
+ }
+ }
+ });
+ useEffect(() => {
+ selectNewComputers();
+ }, [environmentsState.environments]);
+
+ // Discovery clears its list on refresh, so poll only until a machine appears.
+ const shouldRefreshWhileEmpty =
+ refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline;
+
+ useEffect(() => {
+ if (!shouldRefreshWhileEmpty) return;
+ let timer: ReturnType | undefined;
+ let disposed = false;
+ let pending = false;
+ const visible = () => document.visibilityState === "visible";
+ const schedule = () => {
+ clearTimeout(timer);
+ if (!disposed && visible()) {
+ timer = setTimeout(() => void refresh(), EMPTY_DISCOVERY_REFRESH_INTERVAL_MS);
+ }
+ };
+ const refresh = async () => {
+ if (disposed || pending || !visible()) return;
+ clearTimeout(timer);
+ pending = true;
+ try {
+ await refreshDiscoveryWhenIdle();
+ } finally {
+ pending = false;
+ schedule();
+ }
+ };
+ const onFocus = () => void refresh();
+ const onVisibilityChange = () => {
+ clearTimeout(timer);
+ if (visible()) void refresh();
+ };
+
+ schedule();
+ window.addEventListener("focus", onFocus);
+ document.addEventListener("visibilitychange", onVisibilityChange);
+ return () => {
+ disposed = true;
+ clearTimeout(timer);
+ window.removeEventListener("focus", onFocus);
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+ };
+ }, [shouldRefreshWhileEmpty]);
const standalone = showSavedEnvironments || savedEnvironments.length === 0;
if (
+ !refreshWhileEmpty &&
standalone &&
visibleEnvironments.length === 0 &&
environmentsState.refreshing &&
@@ -200,6 +294,48 @@ export function CloudEnvironmentConnectRows({
: availability === "checking"
? "Available · Checking relay status…"
: (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable");
+ if (selection) {
+ return (
+
+ );
+ }
return (