From 8acf75654f9cdbf70505aa1fdad26b7105f038b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:54:12 +0000 Subject: [PATCH 01/51] feat(teleport): add the import/export engine without native formats Contracts, orchestration, and the teleport service land first. Native CLI formats register later, so list/import/export stay empty until a provider adapter is added. Co-authored-by: aanishbhirud --- apps/server/src/auth/RpcAuthorization.ts | 3 + .../Layers/ProjectionPipeline.ts | 60 ++ .../Layers/ProjectionSnapshotQuery.test.ts | 58 ++ .../Layers/ProjectionSnapshotQuery.ts | 25 +- apps/server/src/orchestration/Schemas.ts | 4 + .../orchestration/decider.teleport.test.ts | 139 ++++ apps/server/src/orchestration/decider.ts | 50 ++ apps/server/src/orchestration/projector.ts | 47 ++ .../persistence/Layers/ProjectionThreads.ts | 20 +- apps/server/src/persistence/Migrations.ts | 2 + .../041_ProjectionThreadsTeleport.ts | 63 ++ .../persistence/Services/ProjectionThreads.ts | 2 + apps/server/src/server.test.ts | 34 +- apps/server/src/server.ts | 6 +- .../src/teleport/Layers/TeleportService.ts | 765 ++++++++++++++++++ .../src/teleport/Services/TeleportService.ts | 31 + apps/server/src/teleport/cwd.test.ts | 139 ++++ apps/server/src/teleport/cwd.ts | 152 ++++ apps/server/src/teleport/discovery.test.ts | 31 + apps/server/src/teleport/discovery.ts | 84 ++ apps/server/src/teleport/fileLock.ts | 68 ++ apps/server/src/teleport/formats/adapter.ts | 51 ++ apps/server/src/teleport/formats/register.ts | 2 + apps/server/src/teleport/formats/registry.ts | 21 + apps/server/src/teleport/homes.ts | 43 + apps/server/src/teleport/json.ts | 95 +++ apps/server/src/teleport/nativeWrite.ts | 183 +++++ .../server/src/teleport/resumeCursors.test.ts | 41 + apps/server/src/teleport/resumeCursors.ts | 89 ++ apps/server/src/teleport/sessionFile.ts | 32 + apps/server/src/teleport/testFixtures.ts | 36 + apps/server/src/teleport/types.ts | 74 ++ apps/server/src/ws.ts | 22 +- .../client-runtime/src/state/entities.test.ts | 58 ++ .../client-runtime/src/state/threadDetail.ts | 4 + .../src/state/threadReducer.test.ts | 132 +++ .../client-runtime/src/state/threadReducer.ts | 26 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.ts | 50 ++ packages/contracts/src/rpc.ts | 37 + packages/contracts/src/teleport.test.ts | 59 ++ packages/contracts/src/teleport.ts | 231 ++++++ 42 files changed, 3049 insertions(+), 21 deletions(-) create mode 100644 apps/server/src/orchestration/decider.teleport.test.ts create mode 100644 apps/server/src/persistence/Migrations/041_ProjectionThreadsTeleport.ts create mode 100644 apps/server/src/teleport/Layers/TeleportService.ts create mode 100644 apps/server/src/teleport/Services/TeleportService.ts create mode 100644 apps/server/src/teleport/cwd.test.ts create mode 100644 apps/server/src/teleport/cwd.ts create mode 100644 apps/server/src/teleport/discovery.test.ts create mode 100644 apps/server/src/teleport/discovery.ts create mode 100644 apps/server/src/teleport/fileLock.ts create mode 100644 apps/server/src/teleport/formats/adapter.ts create mode 100644 apps/server/src/teleport/formats/register.ts create mode 100644 apps/server/src/teleport/formats/registry.ts create mode 100644 apps/server/src/teleport/homes.ts create mode 100644 apps/server/src/teleport/json.ts create mode 100644 apps/server/src/teleport/nativeWrite.ts create mode 100644 apps/server/src/teleport/resumeCursors.test.ts create mode 100644 apps/server/src/teleport/resumeCursors.ts create mode 100644 apps/server/src/teleport/sessionFile.ts create mode 100644 apps/server/src/teleport/testFixtures.ts create mode 100644 apps/server/src/teleport/types.ts create mode 100644 packages/contracts/src/teleport.test.ts create mode 100644 packages/contracts/src/teleport.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 36f348d6370a..b475b4334874 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -123,6 +123,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, [WS_METHODS.subscribeBackgroundPolicy]: AuthOrchestrationReadScope, + [WS_METHODS.teleportListSessions]: AuthOrchestrationReadScope, + [WS_METHODS.teleportImportSessions]: AuthOrchestrationOperateScope, + [WS_METHODS.teleportExportSession]: AuthOrchestrationOperateScope, } as const satisfies Readonly>; export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..246384ce3021 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -628,6 +628,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pendingUserInputCount: 0, hasActionableProposedPlan: 0, deletedAt: null, + teleport: null, }); return; @@ -850,9 +851,25 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.teleported": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + teleport: event.payload.teleport, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.message-sent": case "thread.proposed-plan-upserted": case "thread.activity-appended": + case "thread.history-replaced": case "thread.approval-response-requested": case "thread.user-input-response-requested": { const existingRow = yield* projectionThreadRepository.getById({ @@ -1019,6 +1036,31 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.history-replaced": { + yield* projectionThreadMessageRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + yield* Effect.forEach( + event.payload.messages, + (message) => + projectionThreadMessageRepository.upsert({ + messageId: message.id, + threadId: event.payload.threadId, + turnId: message.turnId, + role: message.role, + text: message.text, + ...(message.attachments !== undefined + ? { attachments: [...message.attachments] } + : {}), + isStreaming: message.streaming, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + return; + } + default: return; } @@ -1041,6 +1083,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; + case "thread.history-replaced": + yield* projectionThreadProposedPlanRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.reverted": { const existingRows = yield* projectionThreadProposedPlanRepository.listByThreadId({ threadId: event.payload.threadId, @@ -1095,6 +1143,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; + case "thread.history-replaced": + yield* projectionThreadActivityRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.reverted": { const existingRows = yield* projectionThreadActivityRepository.listByThreadId({ threadId: event.payload.threadId, @@ -1448,6 +1502,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.history-replaced": + yield* projectionTurnRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.reverted": { const existingTurns = yield* projectionTurnRepository.listByThreadId({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index be596b36b850..ebfcc98e9a90 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2335,4 +2335,62 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = } }), ); + + it.effect("hydrates teleport presence onto thread detail and shell snapshots", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at + ) + VALUES ( + 'project-teleport', 'Teleport', '/tmp/project-teleport', '[]', + '2026-08-14T00:00:00.000Z', '2026-08-14T00:00:00.000Z', NULL + ) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + created_at, updated_at, deleted_at, teleport_json + ) + VALUES ( + 'thread-teleport', 'project-teleport', 'Native thread', + '{"provider":"grok","model":"grok-4"}', 'full-access', 'default', + 0, 0, 0, '2026-08-14T00:00:00.000Z', '2026-08-14T00:00:00.000Z', NULL, + '{"presence":"native","provider":"grok","externalSessionId":"session-1","nativePath":"/tmp/native","lastSyncedAt":"2026-08-14T00:00:01.000Z"}' + ) + `; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 1, '2026-08-14T00:00:01.000Z') + `; + } + + const expectedTeleport = { + presence: "native", + provider: "grok", + externalSessionId: "session-1", + nativePath: "/tmp/native", + lastSyncedAt: "2026-08-14T00:00:01.000Z", + }; + const detail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-teleport")); + assert.equal(detail._tag, "Some"); + if (detail._tag === "Some") { + assert.deepEqual(detail.value.teleport, expectedTeleport); + } + const shell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-teleport")); + assert.equal(shell._tag, "Some"); + if (shell._tag === "Some") { + assert.deepEqual(shell.value.teleport, expectedTeleport); + } + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3e77f9cf875a..02280616ef72 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -24,6 +24,7 @@ import { type OrchestrationThreadShell, ModelSelection, ProjectId, + TeleportThreadState, ThreadId, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; @@ -85,6 +86,9 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + teleport: Schema.NullOr(Schema.fromJsonString(TeleportThreadState)).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -434,7 +438,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads ORDER BY created_at ASC, thread_id ASC `, @@ -470,7 +475,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NULL @@ -508,7 +514,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NOT NULL @@ -950,7 +957,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads WHERE thread_id = ${threadId} AND deleted_at IS NULL @@ -1129,7 +1137,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { 'thread.activity-appended', 'thread.turn-diff-completed', 'thread.reverted', - 'thread.session-set' + 'thread.session-set', + 'thread.history-replaced', + 'thread.teleported' ) `, }); @@ -1584,6 +1594,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { activities: activitiesByThread.get(row.threadId) ?? [], checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionsByThread.get(row.threadId) ?? null, + ...(row.teleport == null ? {} : { teleport: row.teleport }), })); const snapshot = { @@ -1791,6 +1802,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { activities: [], checkpoints: [], session: sessionByThread.get(row.threadId) ?? null, + ...(row.teleport == null ? {} : { teleport: row.teleport }), }); } @@ -1930,6 +1942,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { row.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + ...(row.teleport == null ? {} : { teleport: row.teleport }), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -2354,6 +2367,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threadRow.value.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId), + ...(threadRow.value.teleport == null ? {} : { teleport: threadRow.value.teleport }), } satisfies OrchestrationThreadShell); }); @@ -2508,6 +2522,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { completedAt: row.completedAt, })), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + ...(threadRow.value.teleport == null ? {} : { teleport: threadRow.value.teleport }), }; return Option.some( diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89592..cd6ac08a68eb 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -22,6 +22,8 @@ import { ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema, ThreadActivityAppendedPayload as ContractsThreadActivityAppendedPayloadSchema, + ThreadHistoryReplacedPayload as ContractsThreadHistoryReplacedPayloadSchema, + ThreadTeleportedPayload as ContractsThreadTeleportedPayloadSchema, ThreadTurnStartRequestedPayload as ContractsThreadTurnStartRequestedPayloadSchema, ThreadTurnInterruptRequestedPayload as ContractsThreadTurnInterruptRequestedPayloadSchema, ThreadApprovalResponseRequestedPayload as ContractsThreadApprovalResponseRequestedPayloadSchema, @@ -55,6 +57,8 @@ export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema; +export const ThreadHistoryReplacedPayload = ContractsThreadHistoryReplacedPayloadSchema; +export const ThreadTeleportedPayload = ContractsThreadTeleportedPayloadSchema; export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema; export const ThreadTurnInterruptRequestedPayload = diff --git a/apps/server/src/orchestration/decider.teleport.test.ts b/apps/server/src/orchestration/decider.teleport.test.ts new file mode 100644 index 000000000000..6fe94940c93e --- /dev/null +++ b/apps/server/src/orchestration/decider.teleport.test.ts @@ -0,0 +1,139 @@ +import { + CommandId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationThread, + type TeleportThreadState, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +const NATIVE_TELEPORT: TeleportThreadState = { + presence: "native", + provider: "codex", + externalSessionId: "01a00270-6f96-7ce3-9244-ab159194e668", + nativePath: "/home/ubuntu/.codex/sessions/session.jsonl", + lastSyncedAt: NOW, +}; + +function makeReadModel(input: { + readonly teleport?: OrchestrationThread["teleport"]; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...(input.teleport !== undefined ? { teleport: input.teleport } : {}), + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("teleport thread decider", (it) => { + it.effect("sets teleport presence on a thread", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.teleport.set", + commandId: CommandId.make("cmd-teleport-set"), + threadId: ThreadId.make("thread-1"), + teleport: NATIVE_TELEPORT, + createdAt: NOW, + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.teleported"); + if (events[0]?.type === "thread.teleported") { + expect(events[0].payload.teleport).toEqual(NATIVE_TELEPORT); + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("rejects turn start while the thread is in the native CLI", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "hello", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel({ teleport: NATIVE_TELEPORT }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + if (error._tag === "OrchestrationCommandInvariantError") { + expect(error.detail).toContain("native CLI"); + } + }), + ); + + it.effect("allows turn start while the thread is owned by T3", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "hello", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel({ + teleport: { + ...NATIVE_TELEPORT, + presence: "t3", + }, + }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toContain("thread.turn-start-requested"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a48bb29e154b..ff37bcf61fd0 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -941,6 +941,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: `Proposed plan '${sourceProposedPlan?.planId}' belongs to thread '${sourceThread.id}' in a different project.`, }); } + if (targetThread.teleport?.presence === "native") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "This thread is in the native CLI. Import it before sending messages from T3.", + }); + } const userMessageEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", @@ -1390,6 +1396,50 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" return [unsettledEvent, activityAppendedEvent]; } + case "thread.history.replace": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.history-replaced", + payload: { + threadId: command.threadId, + messages: command.messages, + replacedAt: command.createdAt, + }, + }; + } + + case "thread.teleport.set": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.teleported", + payload: { + threadId: command.threadId, + teleport: command.teleport, + updatedAt: command.createdAt, + }, + }; + } + default: { command satisfies never; const fallback = command as never as { type: string }; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..3fd943cc450d 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -33,6 +33,8 @@ import { ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, + ThreadHistoryReplacedPayload, + ThreadTeleportedPayload, } from "./Schemas.ts"; type ThreadPatch = Partial>; @@ -800,6 +802,51 @@ export function projectEvent( }), ); + case "thread.history-replaced": + return decodeForEvent( + ThreadHistoryReplacedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + messages: payload.messages.slice(-MAX_THREAD_MESSAGES), + proposedPlans: [], + activities: [], + checkpoints: [], + latestTurn: null, + updatedAt: payload.replacedAt, + }), + }; + }), + ); + + case "thread.teleported": + return decodeForEvent(ThreadTeleportedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + teleport: payload.teleport, + updatedAt: payload.updatedAt, + }), + }; + }), + ); + default: return Effect.succeed(nextBase); } diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index b7d8ae137473..16e64cfc292c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,14 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, TeleportThreadState } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + teleport: Schema.NullOr(Schema.fromJsonString(TeleportThreadState)).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -55,7 +58,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, - deleted_at + deleted_at, + teleport_json ) VALUES ( ${row.threadId}, @@ -82,7 +86,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, ${row.hasActionableProposedPlan}, - ${row.deletedAt} + ${row.deletedAt}, + ${row.teleport == null ? null : JSON.stringify(row.teleport)} ) ON CONFLICT (thread_id) DO UPDATE SET @@ -109,7 +114,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, - deleted_at = excluded.deleted_at + deleted_at = excluded.deleted_at, + teleport_json = excluded.teleport_json `, }); @@ -143,7 +149,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads WHERE thread_id = ${threadId} `, @@ -179,7 +186,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads WHERE project_id = ${projectId} ORDER BY created_at ASC, thread_id ASC diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..c52efe5829e7 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_ProjectionThreadsTeleport.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionThreadsTeleport", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionThreadsTeleport.ts b/apps/server/src/persistence/Migrations/041_ProjectionThreadsTeleport.ts new file mode 100644 index 000000000000..d112b7b70238 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionThreadsTeleport.ts @@ -0,0 +1,63 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "teleport_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN teleport_json TEXT + `; + } + + yield* sql` + UPDATE projection_threads + SET + teleport_json = ( + SELECT + json_object( + 'presence', + CASE + WHEN json_extract(runtime.runtime_payload_json, '$.teleport.presence') IS NOT NULL THEN json_extract( + runtime.runtime_payload_json, + '$.teleport.presence' + ) + WHEN json_extract(runtime.runtime_payload_json, '$.teleport.lastSyncDirection') = 'export' THEN 'native' + ELSE 't3' + END, + 'provider', + runtime.provider_name, + 'externalSessionId', + json_extract(runtime.runtime_payload_json, '$.teleport.externalSessionId'), + 'nativePath', + json_extract(runtime.runtime_payload_json, '$.teleport.nativePath'), + 'lastSyncedAt', + json_extract(runtime.runtime_payload_json, '$.teleport.lastSyncedAt') + ) + FROM provider_session_runtime AS runtime + WHERE + runtime.thread_id = projection_threads.thread_id + AND runtime.provider_name IN ('codex', 'claudeAgent', 'opencode', 'grok') + AND json_extract(runtime.runtime_payload_json, '$.teleport.externalSessionId') IS NOT NULL + AND json_extract(runtime.runtime_payload_json, '$.teleport.nativePath') IS NOT NULL + AND json_extract(runtime.runtime_payload_json, '$.teleport.lastSyncedAt') IS NOT NULL + ) + WHERE + teleport_json IS NULL + AND EXISTS ( + SELECT + 1 + FROM provider_session_runtime AS runtime + WHERE + runtime.thread_id = projection_threads.thread_id + AND runtime.provider_name IN ('codex', 'claudeAgent', 'opencode', 'grok') + AND json_extract(runtime.runtime_payload_json, '$.teleport.externalSessionId') IS NOT NULL + AND json_extract(runtime.runtime_payload_json, '$.teleport.nativePath') IS NOT NULL + AND json_extract(runtime.runtime_payload_json, '$.teleport.lastSyncedAt') IS NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index c572e1d11ccd..5a3c176fdc03 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,6 +14,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + TeleportThreadState, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -50,6 +51,7 @@ export const ProjectionThread = Schema.Struct({ pendingUserInputCount: NonNegativeInt, hasActionableProposedPlan: NonNegativeInt, deletedAt: Schema.NullOr(IsoDateTime), + teleport: Schema.optional(Schema.NullOr(TeleportThreadState)), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3f63eb4dbef7..8f436cbaddee 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -29,6 +29,7 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + TELEPORT_SCHEMA_VERSION, WS_METHODS, WsRpcGroup, EditorId, @@ -119,6 +120,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import { TeleportService } from "./teleport/Services/TeleportService.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; @@ -419,6 +421,7 @@ const buildAppUnderTest = (options?: { desktopTelemetryReceiver?: Partial< DesktopTelemetryReceiver.DesktopTelemetryReceiver["Service"] >; + teleportService?: Partial; }; }) => Effect.gen(function* () { @@ -764,13 +767,30 @@ const buildAppUnderTest = (options?: { ), ), Layer.provide( - Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ - readEvents: () => Stream.empty, - dispatch: () => Effect.succeed({ sequence: 0 }), - streamDomainEvents: Stream.empty, - latestSequence: Effect.succeed(0), - ...options?.layers?.orchestrationEngine, - }), + Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 0 }), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + ...options?.layers?.orchestrationEngine, + }), + Layer.mock(TeleportService)({ + listSessions: () => + Effect.succeed({ + schemaVersion: TELEPORT_SCHEMA_VERSION, + sessions: [], + }), + importSessions: () => + Effect.succeed({ + schemaVersion: TELEPORT_SCHEMA_VERSION, + imported: [], + }), + exportSession: () => + Effect.die("TeleportService.exportSession not stubbed in this test"), + ...options?.layers?.teleportService, + }), + ), ), Layer.provide( Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 32bcaaa8b96b..588adaf64eb2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -42,6 +42,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { TeleportServiceLive } from "./teleport/Layers/TeleportService.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -412,7 +413,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ), ); -const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( +const RuntimeDependenciesLive = Layer.mergeAll( + RuntimeCoreDependenciesLive, + TeleportServiceLive.pipe(Layer.provide(RuntimeCoreDependenciesLive)), +).pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), diff --git a/apps/server/src/teleport/Layers/TeleportService.ts b/apps/server/src/teleport/Layers/TeleportService.ts new file mode 100644 index 000000000000..ddbac1b60ef9 --- /dev/null +++ b/apps/server/src/teleport/Layers/TeleportService.ts @@ -0,0 +1,765 @@ +import { + CommandId, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + MessageId, + ProviderDriverKind, + TELEPORT_NATIVE_FORMAT_VERSION, + TELEPORT_SCHEMA_VERSION, + TeleportDiscoveryError, + TeleportInvalidInputError, + TeleportProjectResolutionError, + TeleportIdentityConflictError, + TeleportNativeWriteError, + TeleportUnsupportedProviderError, + ThreadId, + defaultInstanceIdForDriver, + isTeleportProvider, + resolveTeleportPresence, + type ModelSelection, + type OrchestrationMessage, + type TeleportExportSessionInput, + type TeleportImportedSession, + type TeleportImportSessionsInput, + type TeleportListSessionsInput, + type TeleportProvider, + type TeleportRuntimePayload, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +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 PlatformError from "effect/PlatformError"; + +import * as OrchestrationEngine from "../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import { resolveTeleportCwdPath, teleportCwdsEquivalent } from "../cwd.ts"; +import { discoverTeleportSessions, loadTeleportSession } from "../discovery.ts"; +import { getTeleportFormat } from "../formats/registry.ts"; +import "../formats/register.ts"; +import { resolveTeleportHomes, type TeleportHomes } from "../homes.ts"; +import { + buildTeleportResumeCursor, + readTeleportExternalSessionId, + readTeleportRuntimePayload, + teleportThreadStateFromPayload, + toTeleportProvider, +} from "../resumeCursors.ts"; +import { firstUserTitle, truncateTitle } from "../json.ts"; +import { + MAX_TELEPORT_MESSAGE_CHARS, + MAX_TELEPORT_MESSAGES, + nativeTextMessage, + type NativeTextMessage, + type ParsedNativeSession, +} from "../types.ts"; +import { TeleportService } from "../Services/TeleportService.ts"; + +function modelSelectionForProvider(provider: TeleportProvider): ModelSelection { + const driver = ProviderDriverKind.make(provider); + return { + instanceId: defaultInstanceIdForDriver(driver), + model: DEFAULT_MODEL_BY_PROVIDER[driver] ?? DEFAULT_MODEL, + }; +} + +function capMessages(messages: ReadonlyArray): NativeTextMessage[] { + return messages.slice(-MAX_TELEPORT_MESSAGES).map((message) => + message.text.length > MAX_TELEPORT_MESSAGE_CHARS + ? { + ...message, + text: `${message.text.slice(0, MAX_TELEPORT_MESSAGE_CHARS)}\n\n[truncated]`, + } + : message, + ); +} + +function nativeMessagesToOrchestration( + messages: ReadonlyArray, + ids: ReadonlyArray, + now: string, +): OrchestrationMessage[] { + return messages.map((message, index) => ({ + id: MessageId.make(message.id ?? ids[index] ?? `${index}`), + role: message.role, + text: message.text, + turnId: null, + streaming: false, + createdAt: message.createdAt ?? now, + updatedAt: message.createdAt ?? now, + })); +} + +function orchestrationToNative(messages: ReadonlyArray): NativeTextMessage[] { + return messages.flatMap((message) => { + if (message.role !== "user" && message.role !== "assistant") { + return []; + } + const text = message.text.trim(); + if (text.length === 0) { + return []; + } + return [ + nativeTextMessage({ + role: message.role, + text: message.text, + createdAt: message.createdAt, + id: message.id, + }), + ]; + }); +} + +function isBusySessionStatus(status: string | undefined): boolean { + return status === "starting" || status === "running"; +} + +function isIgnorableProviderStopError(error: { readonly _tag: string }): boolean { + switch (error._tag) { + case "ProviderValidationError": + case "ProviderSessionNotFoundError": + case "ProviderAdapterSessionNotFoundError": + return true; + default: + return false; + } +} + +export const TeleportServiceLive = Layer.effect( + TeleportService, + Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory; + const instanceRegistry = yield* ProviderInstanceRegistry; + const providerService = yield* ProviderService; + const crypto = yield* Crypto.Crypto; + const nativeContext = yield* Effect.context(); + const provideNative = ( + effect: Effect.Effect, + ): Effect.Effect => effect.pipe(Effect.provideContext(nativeContext)); + + const nextId = () => crypto.randomUUIDv4; + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const inFlight = new Set(); + const alreadyInFlightError = () => + new TeleportInvalidInputError({ + message: "Teleport already in progress for this session.", + }); + const withInFlight = ( + keys: string[], + effect: Effect.Effect, + ): Effect.Effect => + Effect.suspend((): Effect.Effect => { + for (const key of keys) { + if (inFlight.has(key)) { + return alreadyInFlightError(); + } + } + for (const key of keys) { + inFlight.add(key); + } + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + for (const key of keys) { + inFlight.delete(key); + } + }), + ), + ); + }); + const claimExtraInFlight = (keys: string[], extra: string) => + Effect.suspend((): Effect.Effect => { + if (keys.includes(extra)) { + return Effect.void; + } + if (inFlight.has(extra)) { + return alreadyInFlightError(); + } + inFlight.add(extra); + keys.push(extra); + return Effect.void; + }); + + const mapDispatchError = (message: string) => (cause: unknown) => + new TeleportInvalidInputError({ + message, + cause, + }); + + const requireParsedSessionUnlocked = (parsed: ParsedNativeSession, homes: TeleportHomes) => { + const adapter = getTeleportFormat(parsed.provider); + if (!adapter) { + return new TeleportUnsupportedProviderError({ + provider: ProviderDriverKind.make(parsed.provider), + message: `Teleport does not support provider '${parsed.provider}'.`, + }); + } + return adapter.requireUnlocked({ + homes, + nativePath: parsed.nativePath, + }); + }; + + const listSessions = (input: TeleportListSessionsInput) => + provideNative( + Effect.gen(function* () { + const cwd = yield* resolveTeleportCwdPath(input.cwd); + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + message: "Server settings could not be read for teleport discovery.", + cause, + }), + ), + ); + const homes = yield* resolveTeleportHomes(settings); + return yield* discoverTeleportSessions({ + homes, + cwd, + ...(input.providers ? { providers: input.providers } : {}), + }); + }), + ); + + const importSessions = (input: TeleportImportSessionsInput) => { + const inFlightKeys = input.sessions.map( + (session) => `session:${session.provider}:${session.externalSessionId}`, + ); + return withInFlight( + inFlightKeys, + provideNative( + Effect.gen(function* () { + const project = yield* snapshotQuery.getProjectShellById(input.projectId).pipe( + Effect.mapError( + (cause) => + new TeleportProjectResolutionError({ + message: "Failed to load the target project.", + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new TeleportProjectResolutionError({ + message: `Project '${input.projectId}' was not found.`, + }); + } + const cwd = yield* resolveTeleportCwdPath(input.cwd); + if (!(yield* teleportCwdsEquivalent(project.value.workspaceRoot, cwd))) { + return yield* new TeleportInvalidInputError({ + message: "Import cwd must match the selected project's workspace root.", + }); + } + const seenRefs = new Set(); + for (const ref of input.sessions) { + const key = `${ref.provider}:${ref.externalSessionId}`; + if (seenRefs.has(key)) { + return yield* new TeleportInvalidInputError({ + message: `Duplicate session '${ref.externalSessionId}' in the import batch.`, + }); + } + seenRefs.add(key); + } + + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + message: "Server settings could not be read for teleport import.", + cause, + }), + ), + ); + const homes = yield* resolveTeleportHomes(settings); + const bindings = yield* directory.listBindings().pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + message: "Failed to read provider session bindings.", + cause, + }), + ), + ); + + const parsedSessions: ParsedNativeSession[] = []; + for (const ref of input.sessions) { + const parsed = yield* loadTeleportSession({ + homes, + provider: ref.provider, + externalSessionId: ref.externalSessionId, + cwd, + }); + yield* requireParsedSessionUnlocked(parsed, homes); + parsedSessions.push({ + ...parsed, + messages: capMessages(parsed.messages), + }); + } + + const imported: TeleportImportedSession[] = []; + const now = yield* nowIso; + + for (const parsed of parsedSessions) { + const driver = ProviderDriverKind.make(parsed.provider); + let existingThreadId: ThreadId | undefined; + let existingProjectId = input.projectId; + let existingStatus: (typeof bindings)[number]["status"]; + for (const binding of bindings) { + if (binding.provider !== driver) { + continue; + } + const externalSessionId = readTeleportExternalSessionId({ + provider: binding.provider, + resumeCursor: binding.resumeCursor, + runtimePayload: binding.runtimePayload, + }); + if (externalSessionId !== parsed.externalSessionId) { + continue; + } + const shell = yield* snapshotQuery.getThreadShellById(binding.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + message: "Failed to load an existing teleport thread.", + cause, + }), + ), + ); + if (Option.isNone(shell) || shell.value.archivedAt !== null) { + continue; + } + if (shell.value.projectId !== input.projectId) { + return yield* new TeleportIdentityConflictError({ + provider: parsed.provider, + externalSessionId: parsed.externalSessionId, + existingThreadId: binding.threadId, + existingProjectId: shell.value.projectId, + message: `Session '${parsed.externalSessionId}' is already bound to another T3 project.`, + }); + } + existingThreadId = binding.threadId; + existingProjectId = shell.value.projectId; + existingStatus = binding.status; + if (isBusySessionStatus(shell.value.session?.status)) { + return yield* new TeleportInvalidInputError({ + message: `Cannot import while T3 session '${parsed.externalSessionId}' is running.`, + }); + } + break; + } + + const messageIds = yield* Effect.forEach(parsed.messages, () => nextId(), { + concurrency: 1, + }); + const messages = nativeMessagesToOrchestration(parsed.messages, messageIds, now); + const title = + truncateTitle( + parsed.title ?? firstUserTitle(parsed.messages) ?? "Imported session", + ) || "Imported session"; + let threadId = existingThreadId; + let updatedInPlace = false; + + if (threadId) { + yield* claimExtraInFlight(inFlightKeys, `thread:${threadId}`); + const latest = yield* snapshotQuery.getThreadDetailById(threadId).pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + message: "Failed to load the existing thread for in-place import.", + cause, + }), + ), + ); + if (Option.isNone(latest)) { + return yield* new TeleportDiscoveryError({ + message: `Thread '${threadId}' was not found for in-place import.`, + }); + } + if (isBusySessionStatus(latest.value.session?.status)) { + return yield* new TeleportInvalidInputError({ + message: `Cannot import while T3 session '${parsed.externalSessionId}' is running.`, + }); + } + if ( + parsed.messages.length === 0 && + orchestrationToNative(latest.value.messages).length > 0 + ) { + return yield* new TeleportInvalidInputError({ + message: "Native session has no messages; refusing to wipe this thread.", + }); + } + updatedInPlace = true; + const replaceCommandId = CommandId.make(yield* nextId()); + yield* engine + .dispatch({ + type: "thread.history.replace", + commandId: replaceCommandId, + threadId, + messages, + createdAt: now, + }) + .pipe(Effect.mapError(mapDispatchError("Failed to replace thread history."))); + yield* engine + .dispatch({ + type: "thread.meta.update", + commandId: CommandId.make(yield* nextId()), + threadId, + title, + }) + .pipe( + Effect.mapError(mapDispatchError("Failed to update imported thread title.")), + ); + } else { + threadId = ThreadId.make(yield* nextId()); + yield* engine + .dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* nextId()), + threadId, + projectId: input.projectId, + title, + modelSelection: modelSelectionForProvider(parsed.provider), + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: now, + }) + .pipe(Effect.mapError(mapDispatchError("Failed to create an imported thread."))); + yield* engine + .dispatch({ + type: "thread.history.replace", + commandId: CommandId.make(yield* nextId()), + threadId, + messages, + createdAt: now, + }) + .pipe( + Effect.mapError(mapDispatchError("Failed to write imported thread history.")), + ); + } + + const teleportPayload: TeleportRuntimePayload = { + schemaVersion: TELEPORT_SCHEMA_VERSION, + externalSessionId: parsed.externalSessionId, + nativePath: parsed.nativePath, + lastSyncDirection: "import", + lastSyncedAt: now, + nativeFormatVersion: parsed.nativeFormatVersion, + presence: "t3", + }; + yield* directory + .upsert({ + threadId, + provider: driver, + providerInstanceId: defaultInstanceIdForDriver(driver), + status: existingStatus ?? "stopped", + resumeCursor: buildTeleportResumeCursor({ + provider: parsed.provider, + externalSessionId: parsed.externalSessionId, + }), + runtimePayload: { teleport: teleportPayload }, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + message: "Failed to bind the imported native session.", + cause, + }), + ), + ); + yield* engine + .dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make(yield* nextId()), + threadId, + teleport: teleportThreadStateFromPayload({ + provider: parsed.provider, + payload: teleportPayload, + }), + createdAt: now, + }) + .pipe(Effect.mapError(mapDispatchError("Failed to persist teleport presence."))); + + imported.push({ + threadId, + projectId: existingProjectId, + provider: parsed.provider, + providerInstanceId: defaultInstanceIdForDriver(driver), + externalSessionId: parsed.externalSessionId, + updatedInPlace, + }); + } + + return { + schemaVersion: TELEPORT_SCHEMA_VERSION, + imported, + }; + }), + ).pipe( + Effect.catchTag( + "PlatformError", + (cause: PlatformError.PlatformError) => + new TeleportDiscoveryError({ + message: "Native filesystem error during teleport import.", + cause, + }), + ), + ), + ); + }; + + const exportSession = (input: TeleportExportSessionInput) => { + const inFlightKeys = [`thread:${input.threadId}`]; + return withInFlight( + inFlightKeys, + provideNative( + Effect.gen(function* () { + const thread = yield* snapshotQuery.getThreadDetailById(input.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to load the thread for export.", + cause, + }), + ), + ); + if (Option.isNone(thread)) { + return yield* new TeleportInvalidInputError({ + message: `Thread '${input.threadId}' was not found.`, + }); + } + if (isBusySessionStatus(thread.value.session?.status)) { + return yield* new TeleportInvalidInputError({ + message: "Cannot export while this T3 session is running.", + }); + } + + const project = yield* snapshotQuery.getProjectShellById(thread.value.projectId).pipe( + Effect.mapError( + (cause) => + new TeleportProjectResolutionError({ + message: "Failed to load the thread's project.", + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new TeleportProjectResolutionError({ + message: "The thread's project was not found.", + }); + } + + const binding = yield* directory.getBinding(input.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to read the thread's provider binding.", + cause, + }), + ), + ); + const instance = yield* instanceRegistry.getInstance( + thread.value.modelSelection.instanceId, + ); + const driverKind = + Option.getOrUndefined(binding)?.provider ?? + instance?.driverKind ?? + (isTeleportProvider(thread.value.modelSelection.instanceId) + ? ProviderDriverKind.make(thread.value.modelSelection.instanceId) + : undefined); + if (!driverKind) { + return yield* new TeleportInvalidInputError({ + message: "Teleport export could not resolve a supported provider for this thread.", + }); + } + const provider = yield* toTeleportProvider(driverKind); + const existingPayload = Option.isSome(binding) + ? readTeleportRuntimePayload(binding.value.runtimePayload) + : undefined; + if (resolveTeleportPresence(existingPayload) === "native") { + return yield* new TeleportInvalidInputError({ + message: + "This thread is already in the native CLI. Import it before exporting again.", + }); + } + + yield* providerService.stopSession({ threadId: input.threadId }).pipe( + Effect.catchIf(isIgnorableProviderStopError, (error) => + Effect.logDebug("teleport.export.stop-session-skipped", { + threadId: input.threadId, + reason: error._tag, + }), + ), + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to stop the T3 provider session before export.", + cause, + }), + ), + ); + const now = yield* nowIso; + yield* engine + .dispatch({ + type: "thread.session.stop", + commandId: CommandId.make(yield* nextId()), + threadId: input.threadId, + createdAt: now, + }) + .pipe( + Effect.catch(() => + Effect.logDebug("teleport.export.session-stop-dispatch-skipped", { + threadId: input.threadId, + }), + ), + ); + + const cwd = yield* resolveTeleportCwdPath(project.value.workspaceRoot); + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: cwd, + message: "Server settings could not be read for teleport export.", + cause, + }), + ), + ); + const homes = yield* resolveTeleportHomes(settings); + const existingExternalId = Option.isSome(binding) + ? readTeleportExternalSessionId({ + provider: driverKind, + resumeCursor: binding.value.resumeCursor, + runtimePayload: binding.value.runtimePayload, + }) + : undefined; + const externalSessionId = existingExternalId ?? (yield* nextId()); + yield* claimExtraInFlight(inFlightKeys, `session:${provider}:${externalSessionId}`); + const latest = yield* snapshotQuery.getThreadDetailById(input.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to re-check the thread before export.", + cause, + }), + ), + ); + if (Option.isNone(latest)) { + return yield* new TeleportInvalidInputError({ + message: `Thread '${input.threadId}' was not found.`, + }); + } + const messages = capMessages(orchestrationToNative(latest.value.messages)); + const nativeSession: ParsedNativeSession = { + provider, + externalSessionId, + cwd, + nativePath: "", + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + title: thread.value.title, + createdAt: thread.value.createdAt, + updatedAt: now, + messages, + }; + + const existingNativePath = existingPayload?.nativePath; + const adapter = getTeleportFormat(provider); + if (!adapter) { + return yield* new TeleportUnsupportedProviderError({ + provider: driverKind, + message: `Teleport does not support provider '${provider}'.`, + }); + } + const nativePath = yield* adapter.write({ + homes, + session: nativeSession, + ...(existingNativePath !== undefined ? { existingNativePath } : {}), + }); + + const teleportPayload: TeleportRuntimePayload = { + schemaVersion: TELEPORT_SCHEMA_VERSION, + externalSessionId, + nativePath, + lastSyncDirection: "export", + lastSyncedAt: now, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + presence: "native", + }; + yield* directory + .upsert({ + threadId: input.threadId, + provider: driverKind, + providerInstanceId: + Option.getOrUndefined(binding)?.providerInstanceId ?? + defaultInstanceIdForDriver(driverKind), + status: "stopped", + resumeCursor: buildTeleportResumeCursor({ provider, externalSessionId }), + runtimePayload: { teleport: teleportPayload }, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath, + message: "Failed to bind the exported native session.", + cause, + }), + ), + ); + yield* engine + .dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make(yield* nextId()), + threadId: input.threadId, + teleport: teleportThreadStateFromPayload({ + provider, + payload: teleportPayload, + }), + createdAt: now, + }) + .pipe(Effect.mapError(mapDispatchError("Failed to persist teleport presence."))); + + return { + schemaVersion: TELEPORT_SCHEMA_VERSION, + provider, + providerInstanceId: defaultInstanceIdForDriver(driverKind), + externalSessionId, + nativePath, + cwd, + }; + }), + ).pipe( + Effect.catchTag( + "PlatformError", + (cause: PlatformError.PlatformError) => + new TeleportNativeWriteError({ + nativePath: "native session", + message: "Native filesystem error during teleport export.", + cause, + }), + ), + ), + ); + }; + + return TeleportService.of({ + listSessions, + importSessions, + exportSession, + }); + }), +); diff --git a/apps/server/src/teleport/Services/TeleportService.ts b/apps/server/src/teleport/Services/TeleportService.ts new file mode 100644 index 000000000000..9a9824c9a2f8 --- /dev/null +++ b/apps/server/src/teleport/Services/TeleportService.ts @@ -0,0 +1,31 @@ +import type { + TeleportExportError, + TeleportExportSessionInput, + TeleportExportSessionResult, + TeleportImportError, + TeleportImportSessionsInput, + TeleportImportSessionsResult, + TeleportListSessionsError, + TeleportListSessionsInput, + TeleportListSessionsResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface TeleportServiceShape { + readonly listSessions: ( + input: TeleportListSessionsInput, + ) => Effect.Effect; + + readonly importSessions: ( + input: TeleportImportSessionsInput, + ) => Effect.Effect; + + readonly exportSession: ( + input: TeleportExportSessionInput, + ) => Effect.Effect; +} + +export class TeleportService extends Context.Service()( + "t3/teleport/Services/TeleportService", +) {} diff --git a/apps/server/src/teleport/cwd.test.ts b/apps/server/src/teleport/cwd.test.ts new file mode 100644 index 000000000000..5bd8a3d5084f --- /dev/null +++ b/apps/server/src/teleport/cwd.test.ts @@ -0,0 +1,139 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { + isForeignOpenCodeProjectFolder, + isGenericTeleportCwd, + isTeleportCwdWithin, + opencodeSessionMatchesProjectCwd, + resolveTeleportCwdPath, + teleportCwdsEquivalent, + teleportCwdsMatch, +} from "./cwd.ts"; + +describe("teleport cwd matching", () => { + it("treats trailing slashes as the same project", () => { + assert.equal(teleportCwdsMatch("/workspace", "/workspace/"), true); + assert.equal(teleportCwdsMatch("/workspace", "/other"), false); + }); + + it("treats a project folder as inside its OpenCode launch cwd", () => { + assert.equal( + isTeleportCwdWithin( + "/home/ubuntu/baanish-testing/native/opencode", + "/home/ubuntu/baanish-testing/native", + ), + true, + ); + assert.equal(isTeleportCwdWithin("/tmp/oc-wire-test", "/tmp"), true); + assert.equal(isTeleportCwdWithin("/foobar", "/foo"), false); + }); + + it("rejects home and temp roots as OpenCode launch directories", () => { + assert.equal(isGenericTeleportCwd("/"), true); + assert.equal(isGenericTeleportCwd("/tmp"), true); + assert.equal(isGenericTeleportCwd("/home/ubuntu"), true); + assert.equal(isGenericTeleportCwd("/home/ubuntu/baanish-testing/native"), false); + assert.equal(isGenericTeleportCwd("C:\\Users\\Foo"), true); + assert.equal(isGenericTeleportCwd("C:\\Users\\Foo\\proj"), false); + }); + + it.effect("matches an OpenCode session whose cwd is a parent of the project", () => + opencodeSessionMatchesProjectCwd( + "/home/ubuntu/baanish-testing/native", + "/home/ubuntu/baanish-testing/native/opencode", + ).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, true); + }), + ), + ); + + it.effect("does not match an OpenCode session launched from /tmp", () => + opencodeSessionMatchesProjectCwd("/tmp", "/tmp/oc-wire-test").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, false); + }), + ), + ); + + it("does not treat sibling harness folders as OpenCode parent matches", () => { + assert.equal(isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/codex"), true); + assert.equal( + isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/claude"), + true, + ); + assert.equal( + isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/opencode"), + false, + ); + }); + + it.effect("does not list a parent OpenCode session under a Codex project folder", () => + opencodeSessionMatchesProjectCwd( + "/home/ubuntu/baanish-testing/native", + "/home/ubuntu/baanish-testing/native/codex", + ).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, false); + }), + ), + ); + + it.effect("treats a symlink cwd as the same project", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-cwd-" }); + const real = path.join(root, "real-project"); + const link = path.join(root, "link-project"); + yield* fs.makeDirectory(real, { recursive: true }); + yield* fs.symlink(real, link); + assert.equal(yield* teleportCwdsEquivalent(real, link), true); + assert.equal(yield* teleportCwdsEquivalent(real, path.join(root, "other")), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("treats macOS cwd spellings as the same when they differ only by case", () => + teleportCwdsEquivalent("/Users/Alex/proj", "/Users/alex/proj").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, true); + }), + ), + ); + + it.effect("does not case-fold missing Unix paths on Linux", () => + teleportCwdsEquivalent("/Users/Alex/proj", "/Users/alex/proj").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, false); + }), + ), + ); + + it.effect("resolves a persist cwd without lowercasing Unix paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-persist-cwd-" }); + const project = path.join(root, "MixedCase"); + yield* fs.makeDirectory(project, { recursive: true }); + const resolved = yield* resolveTeleportCwdPath(`${project}/`); + assert.equal(resolved, yield* fs.realPath(project)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/teleport/cwd.ts b/apps/server/src/teleport/cwd.ts new file mode 100644 index 000000000000..99564062ee4e --- /dev/null +++ b/apps/server/src/teleport/cwd.ts @@ -0,0 +1,152 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + normalizeProjectPathForComparison, + normalizeProjectPathForDispatch, +} from "@t3tools/shared/path"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +export function normalizeTeleportCwd(value: string): string { + return normalizeProjectPathForComparison(value); +} + +/** + * On-disk cwd spelling for native session files and folder names. + * Comparison-normalize lowercases Windows paths; the native CLIs look up + * the persisted case and separators instead. + */ +export const resolveTeleportCwdPath = Effect.fn("resolveTeleportCwdPath")(function* ( + value: string, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = path.resolve(normalizeProjectPathForDispatch(value)); + return yield* fs.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)); +}); + +export const canonicalizeTeleportCwd = Effect.fn("canonicalizeTeleportCwd")(function* ( + value: string, +): Effect.fn.Return { + return normalizeTeleportCwd(yield* resolveTeleportCwdPath(value)); +}); + +export function teleportCwdsMatch(left: string, right: string): boolean { + return normalizeTeleportCwd(left) === normalizeTeleportCwd(right); +} + +export function isTeleportCwdWithin(inner: string, outer: string): boolean { + const innerCwd = normalizeTeleportCwd(inner); + const outerCwd = normalizeTeleportCwd(outer); + if (innerCwd === outerCwd) { + return true; + } + const separator = innerCwd.includes("\\") || outerCwd.includes("\\") ? "\\" : "/"; + const prefix = outerCwd.endsWith(separator) ? outerCwd : `${outerCwd}${separator}`; + return innerCwd.startsWith(prefix); +} + +/** + * Home folders, temp roots, and drive roots. OpenCode often records the + * process cwd, which can be a parent of the T3 project — but matching `/tmp` + * or `/home/ubuntu` would pull in unrelated sessions. + */ +export function isGenericTeleportCwd(cwd: string): boolean { + const posix = normalizeTeleportCwd(cwd).replaceAll("\\", "/").replace(/\/+$/u, ""); + const lowered = posix.toLowerCase(); + if (lowered === "" || lowered === "/") { + return true; + } + if ( + lowered === "/tmp" || + lowered === "/private/tmp" || + lowered === "/var" || + lowered === "/var/tmp" || + lowered === "/home" || + lowered === "/users" || + lowered === "/root" + ) { + return true; + } + const parts = lowered.split("/").filter((part) => part.length > 0); + const first = parts[0]; + if (first === undefined) { + return true; + } + if (first === "home" || first === "users" || first === "root") { + return parts.length <= 2; + } + if (/^[a-z]:$/u.test(first)) { + if (parts.length <= 1) { + return true; + } + if (parts[1] === "users") { + return parts.length <= 3; + } + if (parts[1] === "windows") { + return parts.length <= 2; + } + } + return false; +} + +/** + * Same location, including macOS `/tmp` → `/private/tmp` and case-insensitive + * default volumes. Lexical equality short-circuits; otherwise both sides go + * through `realpath` so a native CLI cwd and a T3 project root still match. + */ +export const teleportCwdsEquivalent = Effect.fn("teleportCwdsEquivalent")(function* ( + left: string, + right: string, +) { + if (teleportCwdsMatch(left, right)) { + return true; + } + const leftCanon = yield* canonicalizeTeleportCwd(left); + const rightCanon = yield* canonicalizeTeleportCwd(right); + if (leftCanon === rightCanon) { + return true; + } + const platform = yield* HostProcessPlatform; + // Default macOS volumes are case-insensitive. realpath keeps on-disk case + // when the path exists; missing paths fall back to the input spelling. + if (platform === "darwin") { + return leftCanon.toLowerCase() === rightCanon.toLowerCase(); + } + return false; +}); + +const FOREIGN_OPENCODE_PROJECT_FOLDERS = new Set([ + "codex", + "claude", + "grok", + "claude-code", + "claudeagent", +]); + +export function isForeignOpenCodeProjectFolder(projectCwd: string): boolean { + const posix = normalizeTeleportCwd(projectCwd).replaceAll("\\", "/"); + const lastSlash = posix.lastIndexOf("/"); + const base = (lastSlash === -1 ? posix : posix.slice(lastSlash + 1)).toLowerCase(); + return base.length > 0 && FOREIGN_OPENCODE_PROJECT_FOLDERS.has(base); +} + +/** + * OpenCode stores the process cwd, which is often a parent of the T3 project + * folder. Exact match still wins; otherwise the project may sit under the + * session directory unless that directory is a generic root or a sibling + * harness folder such as `codex` / `claude` / `grok`. + */ +export const opencodeSessionMatchesProjectCwd = Effect.fn("opencodeSessionMatchesProjectCwd")( + function* (sessionCwd: string, projectCwd: string) { + if (yield* teleportCwdsEquivalent(sessionCwd, projectCwd)) { + return true; + } + const sessionCanon = yield* canonicalizeTeleportCwd(sessionCwd); + const projectCanon = yield* canonicalizeTeleportCwd(projectCwd); + if (isGenericTeleportCwd(sessionCanon) || isForeignOpenCodeProjectFolder(projectCanon)) { + return false; + } + return isTeleportCwdWithin(projectCanon, sessionCanon); + }, +); diff --git a/apps/server/src/teleport/discovery.test.ts b/apps/server/src/teleport/discovery.test.ts new file mode 100644 index 000000000000..a4d1f07a3a72 --- /dev/null +++ b/apps/server/src/teleport/discovery.test.ts @@ -0,0 +1,31 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverTeleportSessions } from "./discovery.ts"; +import { resetTeleportFormats } from "./formats/registry.ts"; +import type { TeleportHomes } from "./homes.ts"; + +describe("teleport discovery", () => { + it.effect("lists nothing when no native formats are registered", () => + Effect.gen(function* () { + resetTeleportFormats(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-empty-registry-" }); + const homes: TeleportHomes = { + codexSessionsRoot: path.join(root, "codex", "sessions"), + claudeProjectsRoot: path.join(root, "claude", "projects"), + opencodeRoot: path.join(root, "opencode"), + grokSessionsRoot: path.join(root, "grok", "sessions"), + }; + const listed = yield* discoverTeleportSessions({ + homes, + cwd: "/workspace", + }); + assert.deepStrictEqual(listed.sessions, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/teleport/discovery.ts b/apps/server/src/teleport/discovery.ts new file mode 100644 index 000000000000..4ef826bf55ca --- /dev/null +++ b/apps/server/src/teleport/discovery.ts @@ -0,0 +1,84 @@ +import { + TELEPORT_SCHEMA_VERSION, + TeleportDiscoveryError, + TeleportSchemaVersionError, + type TeleportListSessionsResult, + type TeleportProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { getTeleportFormat, listRegisteredTeleportProviders } from "./formats/registry.ts"; +import type { TeleportHomes } from "./homes.ts"; +import type { ParsedNativeSession } from "./types.ts"; + +export const discoverTeleportSessions = Effect.fn("discoverTeleportSessions")(function* (input: { + readonly homes: TeleportHomes; + readonly cwd: string; + readonly providers?: ReadonlyArray; +}): Effect.fn.Return< + TeleportListSessionsResult, + TeleportSchemaVersionError | TeleportDiscoveryError, + FileSystem.FileSystem | Path.Path +> { + const providers = input.providers ?? listRegisteredTeleportProviders(); + const sessions = []; + + for (const provider of providers) { + const adapter = getTeleportFormat(provider); + if (!adapter) { + continue; + } + sessions.push(...(yield* adapter.list({ homes: input.homes, cwd: input.cwd }))); + } + + return { + schemaVersion: TELEPORT_SCHEMA_VERSION, + sessions: sessions.toSorted((left, right) => { + const leftAt = left.updatedAt ?? left.createdAt ?? ""; + const rightAt = right.updatedAt ?? right.createdAt ?? ""; + return rightAt.localeCompare(leftAt); + }), + }; +}); + +export const loadTeleportSession = Effect.fn("loadTeleportSession")(function* (input: { + readonly homes: TeleportHomes; + readonly provider: TeleportProvider; + readonly externalSessionId: string; + readonly cwd: string; +}): Effect.fn.Return< + ParsedNativeSession, + TeleportSchemaVersionError | TeleportDiscoveryError, + FileSystem.FileSystem | Path.Path +> { + const adapter = getTeleportFormat(input.provider); + if (!adapter) { + return yield* new TeleportDiscoveryError({ + message: `Native ${input.provider} session support is not registered.`, + }); + } + + const listed = yield* discoverTeleportSessions({ + homes: input.homes, + cwd: input.cwd, + providers: [input.provider], + }); + const candidate = listed.sessions.find( + (session) => + session.provider === input.provider && session.externalSessionId === input.externalSessionId, + ); + if (!candidate) { + return yield* new TeleportDiscoveryError({ + message: `Native ${input.provider} session '${input.externalSessionId}' was not found for this project.`, + }); + } + + return yield* adapter.load({ + homes: input.homes, + cwd: input.cwd, + externalSessionId: input.externalSessionId, + nativePath: candidate.nativePath, + }); +}); diff --git a/apps/server/src/teleport/fileLock.ts b/apps/server/src/teleport/fileLock.ts new file mode 100644 index 000000000000..99240b348daf --- /dev/null +++ b/apps/server/src/teleport/fileLock.ts @@ -0,0 +1,68 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeUtil from "node:util"; + +import { TeleportFileLockedError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +function nodeErrorCode(error: unknown): string | undefined { + if (typeof error === "object" && error !== null && "code" in error) { + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; + } + return undefined; +} + +const isWindowsPathInUse = Effect.fn("isWindowsPathInUse")(function* (nativePath: string) { + // Succeed with a boolean. `Effect.tryPromise` `catch` is the error channel + // and would leak `false` into export/import failures. + return yield* Effect.promise(async () => { + try { + const handle = await NodeFSP.open(nativePath, "r+"); + await handle.close(); + return false; + } catch (error) { + const code = nodeErrorCode(error); + if (code === "ENOENT" || code === "EISDIR") { + return false; + } + return code === "EBUSY" || code === "EPERM" || code === "EACCES"; + } + }); +}); + +export const isNativePathLocked = Effect.fn("isNativePathLocked")(function* (nativePath: string) { + const platform = yield* HostProcessPlatform; + if (platform === "win32") { + // `lsof` is not a Windows tool. Exclusive locks from a native CLI show up + // as EBUSY/EPERM/EACCES on a write-open instead. + return yield* isWindowsPathInUse(nativePath); + } + return yield* Effect.tryPromise({ + try: () => execFile("lsof", ["-t", nativePath], { timeout: 2_000 }), + catch: () => + new TeleportFileLockedError({ + nativePath, + message: `Failed to check whether ${nativePath} is locked.`, + }), + }).pipe( + Effect.map((result) => result.stdout.trim().length > 0), + Effect.orElseSucceed(() => false), + ); +}); + +export const requireNativePathUnlocked = Effect.fn("requireNativePathUnlocked")(function* ( + nativePath: string, +) { + const locked = yield* isNativePathLocked(nativePath); + if (locked) { + return yield* new TeleportFileLockedError({ + nativePath, + message: `Native session file is locked: ${nativePath}`, + }); + } +}); diff --git a/apps/server/src/teleport/formats/adapter.ts b/apps/server/src/teleport/formats/adapter.ts new file mode 100644 index 000000000000..6dff4c50c21a --- /dev/null +++ b/apps/server/src/teleport/formats/adapter.ts @@ -0,0 +1,51 @@ +import { + TeleportDiscoveryError, + TeleportFileLockedError, + TeleportNativeWriteError, + TeleportSchemaVersionError, + type TeleportProvider, + type TeleportSessionCandidate, +} from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; + +import type { TeleportHomes } from "../homes.ts"; +import type { ParsedNativeSession } from "../types.ts"; + +export interface TeleportFormatAdapter { + readonly provider: TeleportProvider; + readonly list: (input: { + readonly homes: TeleportHomes; + readonly cwd: string; + }) => Effect.Effect< + ReadonlyArray, + TeleportSchemaVersionError | TeleportDiscoveryError, + FileSystem.FileSystem | Path.Path + >; + readonly load: (input: { + readonly homes: TeleportHomes; + readonly cwd: string; + readonly externalSessionId: string; + readonly nativePath: string; + }) => Effect.Effect< + ParsedNativeSession, + TeleportSchemaVersionError | TeleportDiscoveryError, + FileSystem.FileSystem | Path.Path + >; + readonly write: (input: { + readonly homes: TeleportHomes; + readonly session: ParsedNativeSession; + readonly existingNativePath?: string; + }) => Effect.Effect< + string, + TeleportNativeWriteError | TeleportFileLockedError | TeleportSchemaVersionError, + FileSystem.FileSystem | Path.Path + >; + readonly requireUnlocked: (input: { + readonly homes: TeleportHomes; + readonly nativePath: string; + }) => Effect.Effect; + readonly resumeCursor: (externalSessionId: string) => unknown; + readonly readExternalSessionId: (resumeCursor: unknown) => string | undefined; +} diff --git a/apps/server/src/teleport/formats/register.ts b/apps/server/src/teleport/formats/register.ts new file mode 100644 index 000000000000..02f9707edf95 --- /dev/null +++ b/apps/server/src/teleport/formats/register.ts @@ -0,0 +1,2 @@ +// Native format adapters register themselves by importing their modules here. +export function loadTeleportFormats(): void {} diff --git a/apps/server/src/teleport/formats/registry.ts b/apps/server/src/teleport/formats/registry.ts new file mode 100644 index 000000000000..bc86b11b2af6 --- /dev/null +++ b/apps/server/src/teleport/formats/registry.ts @@ -0,0 +1,21 @@ +import type { TeleportProvider } from "@t3tools/contracts"; + +import type { TeleportFormatAdapter } from "./adapter.ts"; + +const adapters = new Map(); + +export function registerTeleportFormat(adapter: TeleportFormatAdapter): void { + adapters.set(adapter.provider, adapter); +} + +export function getTeleportFormat(provider: TeleportProvider): TeleportFormatAdapter | undefined { + return adapters.get(provider); +} + +export function listRegisteredTeleportProviders(): ReadonlyArray { + return [...adapters.keys()]; +} + +export function resetTeleportFormats(): void { + adapters.clear(); +} diff --git a/apps/server/src/teleport/homes.ts b/apps/server/src/teleport/homes.ts new file mode 100644 index 000000000000..40cd11bdf36e --- /dev/null +++ b/apps/server/src/teleport/homes.ts @@ -0,0 +1,43 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; + +import type { ServerSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { expandHomePath } from "../pathExpansion.ts"; +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; + +export interface TeleportHomes { + readonly codexSessionsRoot: string; + readonly claudeProjectsRoot: string; + readonly opencodeRoot: string; + readonly grokSessionsRoot: string; +} + +export const resolveTeleportHomes = Effect.fn("resolveTeleportHomes")(function* ( + settings: ServerSettings, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); + const nestedClaude = path.join(claudeHome, ".claude", "projects"); + const nestedExists = yield* fs.exists(nestedClaude).pipe(Effect.orElseSucceed(() => false)); + const claudeProjectsRoot = nestedExists ? nestedClaude : path.join(claudeHome, "projects"); + // OpenCode stores sessions at ~/.local/share/opencode on macOS/Linux and + // %USERPROFILE%\.local\share\opencode on Windows. XDG_DATA_HOME still wins. + const xdgData = + process.env.XDG_DATA_HOME && process.env.XDG_DATA_HOME.trim().length > 0 + ? expandHomePath(process.env.XDG_DATA_HOME) + : path.join(NodeOS.homedir(), ".local", "share"); + + return { + codexSessionsRoot: path.join(codexLayout.sharedHomePath, "sessions"), + claudeProjectsRoot, + opencodeRoot: path.join(xdgData, "opencode"), + grokSessionsRoot: path.join(NodeOS.homedir(), ".grok", "sessions"), + }; +}); diff --git a/apps/server/src/teleport/json.ts b/apps/server/src/teleport/json.ts new file mode 100644 index 000000000000..1f889c809b21 --- /dev/null +++ b/apps/server/src/teleport/json.ts @@ -0,0 +1,95 @@ +export function definedField( + key: K, + value: V | undefined, +): Record | { readonly [P in K]: V } { + if (value === undefined) { + return {}; + } + return { [key]: value } as { readonly [P in K]: V }; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function parseJsonObject(raw: string): Record | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + return isRecord(parsed) ? parsed : undefined; +} + +export function collectTextParts(content: unknown): string | undefined { + if (typeof content === "string") { + return nonEmptyString(content); + } + if (!Array.isArray(content)) { + return undefined; + } + const parts: string[] = []; + for (const part of content) { + if (!isRecord(part)) { + continue; + } + const text = nonEmptyString(part.text); + if (text) { + parts.push(text); + } + } + return nonEmptyString(parts.join("\n")); +} + +export function truncateTitle(input: string): string { + const normalized = input.replace(/\s+/gu, " ").trim(); + if (normalized.length <= 80) { + return normalized; + } + return `${normalized.slice(0, 77).trimEnd()}...`; +} + +export function isSyntheticNativeUserText(text: string): boolean { + const trimmed = text.trim(); + return ( + trimmed.startsWith("") || + trimmed.startsWith("") || + trimmed.startsWith("") + ); +} + +export function firstUserTitle( + messages: ReadonlyArray<{ role: string; text: string }>, +): string | undefined { + for (const message of messages) { + if (message.role !== "user") { + continue; + } + if (isSyntheticNativeUserText(message.text)) { + continue; + } + const line = message.text + .split("\n") + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0); + if (line) { + return truncateTitle(line); + } + } + return undefined; +} + +export const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/iu; + +export function uuidFromPath(filePath: string): string | undefined { + return filePath.match(UUID_RE)?.[0]?.toLowerCase(); +} diff --git a/apps/server/src/teleport/nativeWrite.ts b/apps/server/src/teleport/nativeWrite.ts new file mode 100644 index 000000000000..ac8cfda2003c --- /dev/null +++ b/apps/server/src/teleport/nativeWrite.ts @@ -0,0 +1,183 @@ +import { TeleportFileLockedError, TeleportNativeWriteError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; + +import { requireNativePathUnlocked } from "./fileLock.ts"; + +function platformErrorTag(cause: unknown): string | undefined { + if (typeof cause !== "object" || cause === null) { + return undefined; + } + if ("reason" in cause) { + const reason = (cause as { reason?: { _tag?: unknown } }).reason; + if (reason && typeof reason._tag === "string") { + return reason._tag; + } + } + if ("_tag" in cause && typeof (cause as { _tag?: unknown })._tag === "string") { + return (cause as { _tag: string })._tag; + } + return undefined; +} + +function nodeErrorCode(cause: unknown): string | undefined { + if (typeof cause !== "object" || cause === null) { + return undefined; + } + if ("code" in cause && typeof (cause as { code?: unknown }).code === "string") { + return (cause as { code: string }).code; + } + if ("reason" in cause) { + const nested = (cause as { reason?: { cause?: unknown } }).reason?.cause; + return nodeErrorCode(nested); + } + return undefined; +} + +export function isNativeFileBusy(cause: unknown): boolean { + const tag = platformErrorTag(cause); + if (tag === "Busy") { + return true; + } + const code = nodeErrorCode(cause); + return code === "EBUSY"; +} + +function isReplaceConflict(cause: unknown): boolean { + const tag = platformErrorTag(cause); + if (tag === "AlreadyExists" || tag === "PermissionDenied") { + return true; + } + const code = nodeErrorCode(cause); + return code === "EEXIST" || code === "EPERM" || code === "EACCES"; +} + +/** + * Rename `from` onto `to`. Unix rename replaces an existing file; Windows + * throws if the destination exists, so we remove then rename. A locked + * destination becomes `TeleportFileLockedError`. + */ +export const replaceNativeFile = Effect.fn("replaceNativeFile")(function* (input: { + readonly from: string; + readonly to: string; +}) { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const locked = (cause: unknown) => + new TeleportFileLockedError({ + nativePath: input.to, + message: `Native session file is locked: ${input.to}`, + cause, + }); + const failed = (cause: unknown) => + new TeleportNativeWriteError({ + nativePath: input.to, + message: `Failed to replace ${input.to}.`, + cause, + }); + + const asReplaceError = (cause: unknown): TeleportFileLockedError | TeleportNativeWriteError => + isNativeFileBusy(cause) ? locked(cause) : failed(cause); + + const rename = fs.rename(input.from, input.to); + return yield* rename.pipe( + Effect.catch( + ( + cause: PlatformError.PlatformError, + ): Effect.Effect => { + if (platform === "win32" && isReplaceConflict(cause) && !isNativeFileBusy(cause)) { + return fs.remove(input.to).pipe( + Effect.mapError((removeCause): TeleportFileLockedError | TeleportNativeWriteError => + isNativeFileBusy(removeCause) || isReplaceConflict(removeCause) + ? locked(removeCause) + : failed(removeCause), + ), + Effect.andThen(rename.pipe(Effect.mapError(asReplaceError))), + ); + } + return Effect.fail(asReplaceError(cause)); + }, + ), + ); +}); + +export const writeNativeSessionAtomically = Effect.fn("writeNativeSessionAtomically")( + function* (input: { + readonly filePath: string; + readonly contents: string; + readonly verify: (contents: string) => Effect.Effect; + }): Effect.fn.Return< + void, + TeleportNativeWriteError | TeleportFileLockedError, + FileSystem.FileSystem | Path.Path + > { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetDirectory = path.dirname(input.filePath); + + yield* requireNativePathUnlocked(input.filePath); + + const exists = yield* fs.exists(input.filePath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + yield* requireNativePathUnlocked(input.filePath); + } + + yield* fs.makeDirectory(targetDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: input.filePath, + message: `Failed to create directory for ${input.filePath}.`, + cause, + }), + ), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const tempDirectory = yield* fs + .makeTempDirectoryScoped({ + directory: targetDirectory, + prefix: `${path.basename(input.filePath)}.`, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: input.filePath, + message: `Failed to create a temp file for ${input.filePath}.`, + cause, + }), + ), + ); + const tempPath = path.join(tempDirectory, "contents.tmp"); + yield* fs.writeFileString(tempPath, input.contents).pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: input.filePath, + message: `Failed to write temp session file for ${input.filePath}.`, + cause, + }), + ), + ); + const written = yield* fs.readFileString(tempPath).pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: input.filePath, + message: `Failed to re-read temp session file for ${input.filePath}.`, + cause, + }), + ), + ); + yield* input.verify(written); + yield* requireNativePathUnlocked(input.filePath); + yield* replaceNativeFile({ from: tempPath, to: input.filePath }); + }), + ); + }, +); diff --git a/apps/server/src/teleport/resumeCursors.test.ts b/apps/server/src/teleport/resumeCursors.test.ts new file mode 100644 index 000000000000..d4343d6e1732 --- /dev/null +++ b/apps/server/src/teleport/resumeCursors.test.ts @@ -0,0 +1,41 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind } from "@t3tools/contracts"; + +import { resetTeleportFormats } from "./formats/registry.ts"; +import { buildTeleportResumeCursor, readTeleportExternalSessionId } from "./resumeCursors.ts"; +import { TELEPORT_TEST_SESSION_ID } from "./testFixtures.ts"; + +describe("teleport resume cursors", () => { + it("prefers the teleport payload over a resume cursor", () => { + resetTeleportFormats(); + assert.equal( + readTeleportExternalSessionId({ + provider: ProviderDriverKind.make("grok"), + resumeCursor: { schemaVersion: 1, sessionId: "other" }, + runtimePayload: { + teleport: { externalSessionId: TELEPORT_TEST_SESSION_ID }, + }, + }), + TELEPORT_TEST_SESSION_ID, + ); + }); + + it("falls back to a generic session id when no format is registered", () => { + resetTeleportFormats(); + assert.deepStrictEqual( + buildTeleportResumeCursor({ + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + }), + { sessionId: TELEPORT_TEST_SESSION_ID }, + ); + assert.equal( + readTeleportExternalSessionId({ + provider: ProviderDriverKind.make("codex"), + resumeCursor: { threadId: TELEPORT_TEST_SESSION_ID }, + runtimePayload: null, + }), + undefined, + ); + }); +}); diff --git a/apps/server/src/teleport/resumeCursors.ts b/apps/server/src/teleport/resumeCursors.ts new file mode 100644 index 000000000000..3e65b8e2817c --- /dev/null +++ b/apps/server/src/teleport/resumeCursors.ts @@ -0,0 +1,89 @@ +import { + isTeleportProvider, + ProviderDriverKind, + resolveTeleportPresence, + TeleportRuntimePayload, + TeleportUnsupportedProviderError, + type TeleportProvider, + type TeleportThreadState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { getTeleportFormat } from "./formats/registry.ts"; +import { isRecord, nonEmptyString } from "./json.ts"; + +const decodeTeleportRuntimePayload = Schema.decodeUnknownOption(TeleportRuntimePayload); + +export function toTeleportProvider( + provider: ProviderDriverKind, +): Effect.Effect { + if (isTeleportProvider(provider)) { + return Effect.succeed(provider); + } + return Effect.fail( + new TeleportUnsupportedProviderError({ + provider, + message: `Teleport does not support provider '${provider}'.`, + }), + ); +} + +export function buildTeleportResumeCursor(input: { + readonly provider: TeleportProvider; + readonly externalSessionId: string; +}): unknown { + return ( + getTeleportFormat(input.provider)?.resumeCursor(input.externalSessionId) ?? { + sessionId: input.externalSessionId, + } + ); +} + +export function readTeleportRuntimePayload( + runtimePayload: unknown, +): TeleportRuntimePayload | undefined { + if (!isRecord(runtimePayload)) { + return undefined; + } + return Option.getOrUndefined(decodeTeleportRuntimePayload(runtimePayload.teleport)); +} + +export function teleportThreadStateFromPayload(input: { + readonly provider: TeleportProvider; + readonly payload: TeleportRuntimePayload; +}): TeleportThreadState { + return { + presence: resolveTeleportPresence(input.payload), + provider: input.provider, + externalSessionId: input.payload.externalSessionId, + nativePath: input.payload.nativePath, + lastSyncedAt: input.payload.lastSyncedAt, + }; +} + +export function readTeleportExternalSessionId(input: { + readonly provider: ProviderDriverKind; + readonly resumeCursor: unknown; + readonly runtimePayload: unknown; +}): string | undefined { + if (isRecord(input.runtimePayload)) { + const teleport = input.runtimePayload.teleport; + if (isRecord(teleport)) { + const externalSessionId = nonEmptyString(teleport.externalSessionId); + if (externalSessionId) { + return externalSessionId; + } + } + } + + if (!isRecord(input.resumeCursor)) { + return undefined; + } + + if (!isTeleportProvider(input.provider)) { + return undefined; + } + return getTeleportFormat(input.provider)?.readExternalSessionId(input.resumeCursor); +} diff --git a/apps/server/src/teleport/sessionFile.ts b/apps/server/src/teleport/sessionFile.ts new file mode 100644 index 000000000000..dd3d338acb9d --- /dev/null +++ b/apps/server/src/teleport/sessionFile.ts @@ -0,0 +1,32 @@ +import { TeleportSchemaVersionError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; + +import { MAX_TELEPORT_SESSION_BYTES, type ParsedNativeSession } from "./types.ts"; + +export const readNativeSessionFile = Effect.fn("readNativeSessionFile")(function* (input: { + readonly nativePath: string; + readonly parse: (args: { + readonly contents: string; + readonly nativePath: string; + }) => Effect.Effect, TeleportSchemaVersionError>; +}): Effect.fn.Return< + Option.Option, + TeleportSchemaVersionError, + FileSystem.FileSystem +> { + const fs = yield* FileSystem.FileSystem; + const stat = yield* fs.stat(input.nativePath).pipe(Effect.orElseSucceed(() => null)); + if (stat === null || stat.type !== "File") { + return Option.none(); + } + if (typeof stat.size === "number" && stat.size > MAX_TELEPORT_SESSION_BYTES) { + return Option.none(); + } + const contents = yield* fs.readFileString(input.nativePath).pipe(Effect.orElseSucceed(() => "")); + if (contents.length === 0) { + return Option.none(); + } + return yield* input.parse({ contents, nativePath: input.nativePath }); +}); diff --git a/apps/server/src/teleport/testFixtures.ts b/apps/server/src/teleport/testFixtures.ts new file mode 100644 index 000000000000..6b85290b09fc --- /dev/null +++ b/apps/server/src/teleport/testFixtures.ts @@ -0,0 +1,36 @@ +import { TELEPORT_NATIVE_FORMAT_VERSION } from "@t3tools/contracts"; + +import type { ParsedNativeSession } from "./types.ts"; + +export const TELEPORT_TEST_SESSION_ID = "11111111-1111-4111-8111-111111111111"; +export const TELEPORT_TEST_CREATED_AT = "2026-08-14T06:00:00.000Z"; + +export function sampleTeleportSession( + provider: ParsedNativeSession["provider"], + cwd = "/workspace", +): ParsedNativeSession { + return { + provider, + externalSessionId: TELEPORT_TEST_SESSION_ID, + cwd, + nativePath: `/tmp/${provider}.session`, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + title: "Fix the flaky matcher", + createdAt: TELEPORT_TEST_CREATED_AT, + updatedAt: "2026-08-14T06:01:00.000Z", + messages: [ + { + role: "user", + text: "Fix the flaky matcher", + createdAt: TELEPORT_TEST_CREATED_AT, + id: "user-1", + }, + { + role: "assistant", + text: "I'll tighten the path comparison and add a realpath fallback.", + createdAt: "2026-08-14T06:01:00.000Z", + id: "assistant-1", + }, + ], + }; +} diff --git a/apps/server/src/teleport/types.ts b/apps/server/src/teleport/types.ts new file mode 100644 index 000000000000..992f1a723131 --- /dev/null +++ b/apps/server/src/teleport/types.ts @@ -0,0 +1,74 @@ +import type { TeleportProvider, TeleportSessionCandidate } from "@t3tools/contracts"; + +import { definedField } from "./json.ts"; + +export interface NativeTextMessage { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt?: string; + readonly id?: string; +} + +export interface ParsedNativeSession { + readonly provider: TeleportProvider; + readonly externalSessionId: string; + readonly cwd: string; + readonly nativePath: string; + readonly nativeFormatVersion: number; + readonly title?: string; + readonly createdAt?: string; + readonly updatedAt?: string; + readonly messages: ReadonlyArray; +} + +export function nativeTextMessage(input: { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string | undefined; + readonly id: string | undefined; +}): NativeTextMessage { + return { + role: input.role, + text: input.text, + ...definedField("createdAt", input.createdAt), + ...definedField("id", input.id), + }; +} + +export function parsedNativeSession(input: { + readonly provider: TeleportProvider; + readonly externalSessionId: string; + readonly cwd: string; + readonly nativePath: string; + readonly nativeFormatVersion: number; + readonly title: string | undefined; + readonly createdAt: string | undefined; + readonly updatedAt: string | undefined; + readonly messages: ReadonlyArray; +}): ParsedNativeSession { + return { + provider: input.provider, + externalSessionId: input.externalSessionId, + cwd: input.cwd, + nativePath: input.nativePath, + nativeFormatVersion: input.nativeFormatVersion, + ...definedField("title", input.title), + ...definedField("createdAt", input.createdAt), + ...definedField("updatedAt", input.updatedAt), + messages: input.messages, + }; +} + +export function teleportCandidateFields( + session: ParsedNativeSession, +): Pick { + return { + ...definedField("title", session.title), + ...definedField("createdAt", session.createdAt), + ...definedField("updatedAt", session.updatedAt), + }; +} + +export const MAX_TELEPORT_MESSAGES = 2_000; +export const MAX_TELEPORT_MESSAGE_CHARS = 100_000; +export const MAX_TELEPORT_SESSION_BYTES = 20 * 1024 * 1024; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 173c89ecabff..c58778ea7419 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -123,6 +123,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { TeleportService } from "./teleport/Services/TeleportService.ts"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -279,7 +280,9 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" - | "thread.session-set"; + | "thread.session-set" + | "thread.history-replaced" + | "thread.teleported"; } > { return ( @@ -288,7 +291,9 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || - event.type === "thread.session-set" + event.type === "thread.session-set" || + event.type === "thread.history-replaced" || + event.type === "thread.teleported" ); } @@ -417,6 +422,7 @@ const makeWsRpcLayer = ( const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; const relayClient = yield* RelayClient.RelayClient; + const teleport = yield* TeleportService; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -2271,6 +2277,18 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + [WS_METHODS.teleportListSessions]: (input) => + observeRpcEffect(WS_METHODS.teleportListSessions, teleport.listSessions(input), { + "rpc.aggregate": "teleport", + }), + [WS_METHODS.teleportImportSessions]: (input) => + observeRpcEffect(WS_METHODS.teleportImportSessions, teleport.importSessions(input), { + "rpc.aggregate": "teleport", + }), + [WS_METHODS.teleportExportSession]: (input) => + observeRpcEffect(WS_METHODS.teleportExportSession, teleport.exportSession(input), { + "rpc.aggregate": "teleport", + }), }); }), ); diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index d3bb6680208a..c3d751eeae33 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -229,6 +229,64 @@ describe("environment entity projections", () => { expect(merged?.messages).toBe(messages); }); + it("fills missing detail teleport presence from the shell snapshot", () => { + const nativeTeleport = { + presence: "native" as const, + provider: "grok" as const, + externalSessionId: "session-1", + nativePath: "/tmp/native", + lastSyncedAt: "2026-08-14T23:00:00.000Z", + }; + const detail = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + } satisfies OrchestrationThread & { readonly environmentId: EnvironmentId }; + const shell = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + teleport: nativeTeleport, + }; + + expect(mergeEnvironmentThread(detail, shell)?.teleport).toEqual(nativeTeleport); + }); + + it("keeps live detail teleport presence over a stale shell snapshot", () => { + const nativeTeleport = { + presence: "native" as const, + provider: "codex" as const, + externalSessionId: "session-1", + nativePath: "/tmp/native", + lastSyncedAt: "2026-08-14T23:00:00.000Z", + }; + const t3Teleport = { + ...nativeTeleport, + presence: "t3" as const, + lastSyncedAt: "2026-08-14T23:05:00.000Z", + }; + const detail = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + teleport: t3Teleport, + } satisfies OrchestrationThread & { readonly environmentId: EnvironmentId }; + const shell = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + teleport: nativeTeleport, + }; + + expect(mergeEnvironmentThread(detail, shell)?.teleport).toEqual(t3Teleport); + }); + it("preserves untouched project and thread identities across unrelated shell updates", () => { const harness = makeHarness(); const projectRefsAtom = harness.projects.environmentProjectRefsAtom(ENVIRONMENT_ID); diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 5a2ffa442e04..e6b5c1c5606a 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -64,6 +64,10 @@ export function mergeEnvironmentThread( pinnedAt: shell.pinnedAt, pinOrderKey: shell.pinOrderKey, session: shell.session, + // Detail can resume from a cached snapshot and skip HTTP, so a backfilled + // `teleport_json` never arrives as `thread.teleported`. The shell snapshot + // is refetched per session; use it when detail has no presence yet. + teleport: detail.teleport ?? shell.teleport ?? null, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 8b2479c7a349..913227e7d59a 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -882,6 +882,138 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.history-replaced", () => { + it("replaces messages and clears turn-derived state", () => { + const threadWithHistory: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("old-1"), + role: "user", + text: "old", + turnId: TurnId.make("turn-1"), + streaming: false, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + ], + proposedPlans: [ + { + id: "plan-1", + turnId: TurnId.make("turn-1"), + planMarkdown: "do things", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + ], + activities: [ + { + id: EventId.make("act-1"), + turnId: TurnId.make("turn-1"), + tone: "tool", + kind: "file-edit", + summary: "old activity", + payload: {}, + createdAt: "2026-04-01T01:00:00.000Z", + }, + ], + checkpoints: [ + { + turnId: TurnId.make("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("old-2"), + completedAt: "2026-04-01T01:00:00.000Z", + }, + ], + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-01T01:00:00.000Z", + startedAt: "2026-04-01T01:00:00.000Z", + completedAt: "2026-04-01T01:00:00.000Z", + assistantMessageId: MessageId.make("old-2"), + }, + }; + + const messages = [ + { + id: MessageId.make("new-1"), + role: "user" as const, + text: "imported", + turnId: null, + streaming: false, + createdAt: "2026-08-14T06:00:00.000Z", + updatedAt: "2026-08-14T06:00:00.000Z", + }, + ]; + + const result = applyThreadDetailEvent(threadWithHistory, { + ...baseEventFields, + sequence: 20, + occurredAt: "2026-08-14T06:02:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.history-replaced", + payload: { + threadId: ThreadId.make("thread-1"), + messages, + replacedAt: "2026-08-14T06:02:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages).toEqual(messages); + expect(result.thread.proposedPlans).toEqual([]); + expect(result.thread.activities).toEqual([]); + expect(result.thread.checkpoints).toEqual([]); + expect(result.thread.latestTurn).toBeNull(); + expect(result.thread.updatedAt).toBe("2026-08-14T06:02:00.000Z"); + } + }); + }); + + describe("thread.teleported", () => { + it("stores teleport presence on the thread", () => { + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-08-14T22:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.teleported", + payload: { + threadId: ThreadId.make("thread-1"), + teleport: { + presence: "native", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + }, + updatedAt: "2026-08-14T22:00:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.teleport).toEqual({ + presence: "native", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + }); + expect(result.thread.updatedAt).toBe("2026-08-14T22:00:00.000Z"); + } + }); + }); + describe("no-op events", () => { it("returns unchanged for approval-response-requested", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 970fd94b1a16..748e1b2881d6 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -559,6 +559,32 @@ export function applyThreadDetailEvent( }; } + case "thread.history-replaced": { + return { + kind: "updated", + thread: { + ...thread, + messages: event.payload.messages, + proposedPlans: [], + activities: [], + checkpoints: [], + latestTurn: null, + updatedAt: event.payload.replacedAt, + }, + }; + } + + case "thread.teleported": { + return { + kind: "updated", + thread: { + ...thread, + teleport: event.payload.teleport, + updatedAt: event.payload.updatedAt, + }, + }; + } + // ── Activities ────────────────────────────────────────────────── case "thread.activity-appended": { const activity = event.payload.activity; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..6793c9f8dda2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -30,4 +30,5 @@ export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; +export * from "./teleport.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 35fef721efa7..1607373215bb 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -22,6 +22,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { TeleportThreadState } from "./teleport.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -405,6 +406,8 @@ export const OrchestrationThread = Schema.Struct({ activities: Schema.Array(OrchestrationThreadActivity), checkpoints: Schema.Array(OrchestrationCheckpointSummary), session: Schema.NullOr(OrchestrationSession), + // Optional so payloads from pre-teleport servers still decode. + teleport: Schema.optional(Schema.NullOr(TeleportThreadState)), }); export type OrchestrationThread = typeof OrchestrationThread.Type; @@ -480,6 +483,11 @@ export const OrchestrationThreadShell = Schema.Struct({ }), ), ), + // Thread-level native/T3 presence. Optional so payloads from pre-teleport + // servers still decode. Lives on the shell because the environment snapshot + // is refreshed on every new session, unlike cached thread detail which can + // resume from events and miss a backfilled projection column. + teleport: Schema.optional(Schema.NullOr(TeleportThreadState)), }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; @@ -1023,6 +1031,22 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), }); +const ThreadHistoryReplaceCommand = Schema.Struct({ + type: Schema.Literal("thread.history.replace"), + commandId: CommandId, + threadId: ThreadId, + messages: Schema.Array(OrchestrationMessage), + createdAt: IsoDateTime, +}); + +const ThreadTeleportSetCommand = Schema.Struct({ + type: Schema.Literal("thread.teleport.set"), + commandId: CommandId, + threadId: ThreadId, + teleport: TeleportThreadState, + createdAt: IsoDateTime, +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, @@ -1032,6 +1056,8 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadActivityAppendCommand, ThreadRevertCompleteCommand, ThreadTitleRegenerationCompleteCommand, + ThreadHistoryReplaceCommand, + ThreadTeleportSetCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1071,6 +1097,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.proposed-plan-upserted", "thread.turn-diff-completed", "thread.activity-appended", + "thread.history-replaced", + "thread.teleported", ]); export type OrchestrationEventType = typeof OrchestrationEventType.Type; @@ -1305,6 +1333,18 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ activity: OrchestrationThreadActivity, }); +export const ThreadHistoryReplacedPayload = Schema.Struct({ + threadId: ThreadId, + messages: Schema.Array(OrchestrationMessage), + replacedAt: IsoDateTime, +}); + +export const ThreadTeleportedPayload = Schema.Struct({ + threadId: ThreadId, + teleport: TeleportThreadState, + updatedAt: IsoDateTime, +}); + export const OrchestrationEventMetadata = Schema.Struct({ providerTurnId: Schema.optional(TrimmedNonEmptyString), providerItemId: Schema.optional(ProviderItemId), @@ -1472,6 +1512,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.activity-appended"), payload: ThreadActivityAppendedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.history-replaced"), + payload: ThreadHistoryReplacedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.teleported"), + payload: ThreadTeleportedPayload, + }), ]); export type OrchestrationEvent = typeof OrchestrationEvent.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b5bd91cad59c..a18333cb3824 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -189,6 +189,17 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; +import { + TeleportExportError, + TeleportExportSessionInput, + TeleportExportSessionResult, + TeleportImportError, + TeleportImportSessionsInput, + TeleportImportSessionsResult, + TeleportListSessionsError, + TeleportListSessionsInput, + TeleportListSessionsResult, +} from "./teleport.ts"; export const WS_METHODS = { // Project registry methods @@ -298,6 +309,11 @@ export const WS_METHODS = { sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", + // Teleport session sync + teleportListSessions: "teleport.listSessions", + teleportImportSessions: "teleport.importSessions", + teleportExportSession: "teleport.exportSession", + // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", @@ -970,6 +986,24 @@ export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeReso stream: true, }); +export const WsTeleportListSessionsRpc = Rpc.make(WS_METHODS.teleportListSessions, { + payload: TeleportListSessionsInput, + success: TeleportListSessionsResult, + error: Schema.Union([TeleportListSessionsError, EnvironmentAuthorizationError]), +}); + +export const WsTeleportImportSessionsRpc = Rpc.make(WS_METHODS.teleportImportSessions, { + payload: TeleportImportSessionsInput, + success: TeleportImportSessionsResult, + error: Schema.Union([TeleportImportError, EnvironmentAuthorizationError]), +}); + +export const WsTeleportExportSessionRpc = Rpc.make(WS_METHODS.teleportExportSession, { + payload: TeleportExportSessionInput, + success: TeleportExportSessionResult, + error: Schema.Union([TeleportExportError, EnvironmentAuthorizationError]), +}); + export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, @@ -1061,6 +1095,9 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, + WsTeleportListSessionsRpc, + WsTeleportImportSessionsRpc, + WsTeleportExportSessionRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetWorkflowScriptRpc, WsOrchestrationGetTurnDiffRpc, diff --git a/packages/contracts/src/teleport.test.ts b/packages/contracts/src/teleport.test.ts new file mode 100644 index 000000000000..6bd7aeaeca07 --- /dev/null +++ b/packages/contracts/src/teleport.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { + resolveTeleportPresence, + TeleportRuntimePayload, + TeleportThreadState, +} from "./teleport.ts"; + +const decodeTeleportThreadState = Schema.decodeUnknownSync(TeleportThreadState); +const decodeTeleportRuntimePayload = Schema.decodeUnknownSync(TeleportRuntimePayload); + +describe("teleport presence", () => { + it("decodes a complete thread teleport state", () => { + const parsed = decodeTeleportThreadState({ + presence: "native", + provider: "grok", + externalSessionId: "session-1", + nativePath: "/home/ubuntu/.grok/sessions/session-1", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + }); + expect(parsed.presence).toBe("native"); + expect(parsed.provider).toBe("grok"); + }); + + it("uses an explicit presence on the runtime payload", () => { + const parsed = decodeTeleportRuntimePayload({ + schemaVersion: 1, + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncDirection: "export", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + nativeFormatVersion: 1, + presence: "t3", + }); + expect(resolveTeleportPresence(parsed)).toBe("t3"); + }); + + it("treats a legacy export as native presence", () => { + const parsed = decodeTeleportRuntimePayload({ + schemaVersion: 1, + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncDirection: "export", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + nativeFormatVersion: 1, + }); + expect(parsed.presence).toBeUndefined(); + expect(resolveTeleportPresence(parsed)).toBe("native"); + }); + + it("treats a legacy import as t3 presence", () => { + expect( + resolveTeleportPresence({ + lastSyncDirection: "import", + }), + ).toBe("t3"); + }); +}); diff --git a/packages/contracts/src/teleport.ts b/packages/contracts/src/teleport.ts new file mode 100644 index 000000000000..509eef323da8 --- /dev/null +++ b/packages/contracts/src/teleport.ts @@ -0,0 +1,231 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { IsoDateTime, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +export const TELEPORT_SCHEMA_VERSION = 1 as const; +export const TELEPORT_NATIVE_FORMAT_VERSION = 1 as const; + +export const TeleportProvider = Schema.Literals(["codex", "claudeAgent", "opencode", "grok"]); +export type TeleportProvider = typeof TeleportProvider.Type; + +export const TeleportSyncDirection = Schema.Literals(["import", "export"]); +export type TeleportSyncDirection = typeof TeleportSyncDirection.Type; + +export const TeleportPresence = Schema.Literals(["t3", "native"]); +export type TeleportPresence = typeof TeleportPresence.Type; + +export const TeleportThreadState = Schema.Struct({ + presence: TeleportPresence, + provider: TeleportProvider, + externalSessionId: TrimmedNonEmptyString, + nativePath: TrimmedNonEmptyString, + lastSyncedAt: IsoDateTime, +}); +export type TeleportThreadState = typeof TeleportThreadState.Type; + +export const TeleportSessionRef = Schema.Struct({ + provider: TeleportProvider, + externalSessionId: TrimmedNonEmptyString, +}); +export type TeleportSessionRef = typeof TeleportSessionRef.Type; + +export const TeleportListSessionsInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + providers: Schema.optional(Schema.Array(TeleportProvider)), +}); +export type TeleportListSessionsInput = typeof TeleportListSessionsInput.Type; + +export const TeleportSessionCandidate = Schema.Struct({ + provider: TeleportProvider, + providerInstanceId: ProviderInstanceId, + externalSessionId: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyString, + nativePath: TrimmedNonEmptyString, + nativeFormatVersion: Schema.Int, + title: Schema.optional(TrimmedNonEmptyString), + createdAt: Schema.optional(IsoDateTime), + updatedAt: Schema.optional(IsoDateTime), +}); +export type TeleportSessionCandidate = typeof TeleportSessionCandidate.Type; + +export const TeleportListSessionsResult = Schema.Struct({ + schemaVersion: Schema.Literal(TELEPORT_SCHEMA_VERSION).pipe( + Schema.withDecodingDefault(Effect.succeed(TELEPORT_SCHEMA_VERSION)), + ), + sessions: Schema.Array(TeleportSessionCandidate), +}); +export type TeleportListSessionsResult = typeof TeleportListSessionsResult.Type; + +export const TeleportImportSessionsInput = Schema.Struct({ + projectId: ProjectId, + cwd: TrimmedNonEmptyString, + sessions: Schema.Array(TeleportSessionRef).check(Schema.isMinLength(1)), +}); +export type TeleportImportSessionsInput = typeof TeleportImportSessionsInput.Type; + +export const TeleportImportedSession = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + provider: TeleportProvider, + providerInstanceId: ProviderInstanceId, + externalSessionId: TrimmedNonEmptyString, + updatedInPlace: Schema.Boolean, +}); +export type TeleportImportedSession = typeof TeleportImportedSession.Type; + +export const TeleportImportSessionsResult = Schema.Struct({ + schemaVersion: Schema.Literal(TELEPORT_SCHEMA_VERSION).pipe( + Schema.withDecodingDefault(Effect.succeed(TELEPORT_SCHEMA_VERSION)), + ), + imported: Schema.Array(TeleportImportedSession), +}); +export type TeleportImportSessionsResult = typeof TeleportImportSessionsResult.Type; + +export const TeleportExportSessionInput = Schema.Struct({ + threadId: ThreadId, +}); +export type TeleportExportSessionInput = typeof TeleportExportSessionInput.Type; + +export const TeleportExportSessionResult = Schema.Struct({ + schemaVersion: Schema.Literal(TELEPORT_SCHEMA_VERSION).pipe( + Schema.withDecodingDefault(Effect.succeed(TELEPORT_SCHEMA_VERSION)), + ), + provider: TeleportProvider, + providerInstanceId: ProviderInstanceId, + externalSessionId: TrimmedNonEmptyString, + nativePath: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyString, +}); +export type TeleportExportSessionResult = typeof TeleportExportSessionResult.Type; + +export const TeleportRuntimePayload = Schema.Struct({ + schemaVersion: Schema.Literal(TELEPORT_SCHEMA_VERSION), + externalSessionId: TrimmedNonEmptyString, + nativePath: TrimmedNonEmptyString, + lastSyncDirection: TeleportSyncDirection, + lastSyncedAt: IsoDateTime, + nativeFormatVersion: Schema.Int, + presence: Schema.optional(TeleportPresence), +}); +export type TeleportRuntimePayload = typeof TeleportRuntimePayload.Type; + +export function isTeleportProvider(value: string): value is TeleportProvider { + return value === "codex" || value === "claudeAgent" || value === "opencode" || value === "grok"; +} + +export function resolveTeleportPresence( + payload: Pick | null | undefined, +): TeleportPresence { + if (payload?.presence) { + return payload.presence; + } + return payload?.lastSyncDirection === "export" ? "native" : "t3"; +} + +export function isTeleportedOut(teleport: TeleportThreadState | null | undefined): boolean { + return teleport?.presence === "native"; +} + +export class TeleportInvalidInputError extends Schema.TaggedErrorClass()( + "TeleportInvalidInputError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class TeleportUnsupportedProviderError extends Schema.TaggedErrorClass()( + "TeleportUnsupportedProviderError", + { + provider: ProviderDriverKind, + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class TeleportSchemaVersionError extends Schema.TaggedErrorClass()( + "TeleportSchemaVersionError", + { + provider: Schema.optional(TeleportProvider), + nativePath: Schema.optional(TrimmedNonEmptyString), + foundVersion: Schema.optional(Schema.Int), + supportedVersion: Schema.Int, + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class TeleportFileLockedError extends Schema.TaggedErrorClass()( + "TeleportFileLockedError", + { + nativePath: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class TeleportIdentityConflictError extends Schema.TaggedErrorClass()( + "TeleportIdentityConflictError", + { + provider: TeleportProvider, + externalSessionId: TrimmedNonEmptyString, + existingThreadId: ThreadId, + existingProjectId: ProjectId, + message: TrimmedNonEmptyString, + }, +) {} + +export class TeleportProjectResolutionError extends Schema.TaggedErrorClass()( + "TeleportProjectResolutionError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class TeleportDiscoveryError extends Schema.TaggedErrorClass()( + "TeleportDiscoveryError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class TeleportNativeWriteError extends Schema.TaggedErrorClass()( + "TeleportNativeWriteError", + { + nativePath: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export const TeleportListSessionsError = Schema.Union([ + TeleportInvalidInputError, + TeleportDiscoveryError, + TeleportSchemaVersionError, +]); +export type TeleportListSessionsError = typeof TeleportListSessionsError.Type; + +export const TeleportImportError = Schema.Union([ + TeleportInvalidInputError, + TeleportUnsupportedProviderError, + TeleportSchemaVersionError, + TeleportFileLockedError, + TeleportIdentityConflictError, + TeleportProjectResolutionError, + TeleportDiscoveryError, +]); +export type TeleportImportError = typeof TeleportImportError.Type; + +export const TeleportExportError = Schema.Union([ + TeleportInvalidInputError, + TeleportUnsupportedProviderError, + TeleportSchemaVersionError, + TeleportFileLockedError, + TeleportProjectResolutionError, + TeleportNativeWriteError, +]); +export type TeleportExportError = typeof TeleportExportError.Type; From 75a3793234be3c4921ff1fff9ac16bd9e77f7e06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:54:13 +0000 Subject: [PATCH 02/51] feat(teleport): add the import picker and export button The UI can list, import, and export native sessions. Until a format adapter is registered, the picker stays empty and export fails closed. Co-authored-by: aanishbhirud --- apps/web/src/commandPaletteBus.ts | 6 +- .../web/src/components/ChatView.logic.test.ts | 40 ++ apps/web/src/components/ChatView.tsx | 20 +- .../components/CommandPalette.logic.test.ts | 20 + .../src/components/CommandPalette.logic.ts | 32 +- apps/web/src/components/CommandPalette.tsx | 444 +++++++++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 37 +- apps/web/src/components/chat/ChatHeader.tsx | 7 + .../src/components/chat/DraftHeroHeadline.tsx | 41 +- .../components/teleport/TeleportOutButton.tsx | 170 +++++++ apps/web/src/lib/teleport.ts | 39 ++ apps/web/src/state/teleport.ts | 5 + packages/client-runtime/package.json | 4 + packages/client-runtime/src/state/teleport.ts | 24 + 14 files changed, 847 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/components/teleport/TeleportOutButton.tsx create mode 100644 apps/web/src/lib/teleport.ts create mode 100644 apps/web/src/state/teleport.ts create mode 100644 packages/client-runtime/src/state/teleport.ts diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992c..ba8ff2cfeaf4 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -1,9 +1,13 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + // Tiny event bus allowing components to programmatically open the command palette // without owning its React state. const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; export interface CommandPaletteOpenDetail { - readonly open?: "add-project" | "new-thread-in"; + readonly open?: "add-project" | "new-thread-in" | "import-sessions"; + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a138..1623ad8b61b7 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -168,6 +168,46 @@ describe("buildLoadingThreadFromShell", () => { checkpoints: [], }); }); + + it("preserves shell teleport presence while detail is still loading", () => { + const teleport = { + presence: "native" as const, + provider: "grok" as const, + externalSessionId: "session-1", + nativePath: "/tmp/native", + lastSyncedAt: now, + }; + const shell = { + environmentId, + id: threadId, + projectId, + title: "Loading thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + session: null, + latestUserMessageAt: now, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + teleport, + } satisfies ThreadShell; + + expect(buildLoadingThreadFromShell(shell).teleport).toEqual(teleport); + }); }); describe("resolveThreadMetadataUpdateForNextTurn", () => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1f00c177c307..1306d5ecece6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,6 +21,7 @@ import { ProviderDriverKind, RuntimeMode, TerminalOpenInput, + isTeleportedOut, } from "@t3tools/contracts"; import { connectionStatusTitle, @@ -166,6 +167,7 @@ import { WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; +import { TELEPORTED_OUT_SEND_DISABLED_REASON } from "~/lib/teleport"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -4894,6 +4896,16 @@ function ChatViewContent(props: ChatViewProps) { notifyDirectAnnotationAttached(); return; } + if (isTeleportedOut(activeThread.teleport)) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Thread is in the native CLI", + description: TELEPORTED_OUT_SEND_DISABLED_REASON, + }), + ); + return; + } if (activeEnvironmentUnavailable) { toastManager.add( stackedThreadToast({ @@ -6349,7 +6361,13 @@ function ChatViewContent(props: ChatViewProps) { phase={phase} isConnecting={isConnecting} isSendBusy={isSendBusy} - sendDisabledReason={threadDetailLoading ? "Messages loading" : null} + sendDisabledReason={ + threadDetailLoading + ? "Messages loading" + : isTeleportedOut(activeThread?.teleport) + ? TELEPORTED_OUT_SEND_DISABLED_REASON + : null + } isPreparingWorktree={isPreparingWorktree} environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 06dabc5e8490..e7f7371f3678 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -60,6 +60,26 @@ describe("reduceCommandPaletteUiState", () => { mode: "command", openIntent: { kind: "new-thread-in" }, }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenImportSessions" })).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "import-sessions" }, + }); + expect( + reduceCommandPaletteUiState(filesOpen, { + _tag: "OpenImportSessions", + environmentId: EnvironmentId.make("environment-local"), + projectId: ProjectId.make("project-1"), + }), + ).toEqual({ + open: true, + mode: "command", + openIntent: { + kind: "import-sessions", + environmentId: EnvironmentId.make("environment-local"), + projectId: ProjectId.make("project-1"), + }, + }); }); it("resets to command mode for dialog-driven opens and closes", () => { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 07e0e520d84e..67fa7ed49b7a 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,6 +1,8 @@ import { + type EnvironmentId, type FilesystemBrowseEntry, type KeybindingCommand, + type ProjectId, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; @@ -23,9 +25,14 @@ export const ADDON_ICON_CLASS = "size-4"; */ export type SearchOverlayMode = "command" | "files" | "content"; -export interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} +export type CommandPaletteOpenIntent = + | { readonly kind: "add-project" } + | { readonly kind: "new-thread-in" } + | { + readonly kind: "import-sessions"; + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; + }; export interface CommandPaletteUiState { readonly open: boolean; @@ -38,6 +45,11 @@ export type CommandPaletteUiAction = | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } + | { + readonly _tag: "OpenImportSessions"; + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; + } | { readonly _tag: "ClearOpenIntent" }; export function reduceCommandPaletteUiState( @@ -59,8 +71,22 @@ export function reduceCommandPaletteUiState( return { open: true, mode: "command", openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; + case "OpenImportSessions": + return { + open: true, + mode: "command", + openIntent: { + kind: "import-sessions", + ...(action.environmentId === undefined ? {} : { environmentId: action.environmentId }), + ...(action.projectId === undefined ? {} : { projectId: action.projectId }), + }, + }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; + default: { + const _exhaustive: never = action; + return _exhaustive; + } } } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 48471accb995..521a387b23e1 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -33,7 +33,9 @@ import { FileSearchIcon, FolderIcon, FolderPlusIcon, + ImportIcon, LinkIcon, + LoaderIcon, MessageSquareIcon, PaletteIcon, SettingsIcon, @@ -83,6 +85,7 @@ import { resolveProjectPathForDispatch, } from "../lib/projectPaths"; import { onOpenCommandPalette } from "../commandPaletteBus"; +import { formatRelativeTimeLabel } from "../timestampFormat"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; @@ -115,6 +118,73 @@ import { reduceCommandPaletteUiState, type SearchOverlayMode, } from "./CommandPalette.logic"; + +function nativeSessionsPaletteView( + projectTitle: string, + items: CommandPaletteActionItem[], +): CommandPaletteView { + return { + addonIcon: , + groups: [ + { + value: "native-sessions", + label: `Sessions in ${projectTitle}`, + items, + }, + ], + }; +} + +function importSessionsStatusItem(input: { + readonly value: string; + readonly title: string; + readonly description?: string; + readonly icon: ReactNode; +}): CommandPaletteActionItem { + return { + kind: "action", + value: input.value, + searchTerms: [], + title: input.title, + ...(input.description === undefined ? {} : { description: input.description }), + icon: input.icon, + disabled: true, + run: async () => undefined, + }; +} + +function importSessionsLoadingView(projectTitle: string): CommandPaletteView { + return nativeSessionsPaletteView(projectTitle, [ + importSessionsStatusItem({ + value: "import-sessions-loading", + title: "Looking for native sessions…", + icon: , + }), + ]); +} + +function importIntoProjectPaletteView( + items: ReadonlyArray, +): CommandPaletteView { + return { + addonIcon: , + groups: [ + { + value: "projects", + label: "Import into project", + items: enumerateCommandPaletteItems(items), + }, + ], + }; +} + +function toInstalledPaletteView(view: CommandPaletteView): CommandPaletteView { + return { + addonIcon: view.addonIcon, + groups: view.groups, + ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), + }; +} import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; @@ -126,7 +196,9 @@ import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog" import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; +import { isTeleportedOut, teleportFailureMessage, teleportProviderLabel } from "../lib/teleport"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; +import { teleportEnvironment } from "../state/teleport"; import { deriveProviderInstanceEntries, resolveDefaultProviderModelSelection, @@ -393,6 +465,15 @@ export function CommandPalette({ children }: { children: ReactNode }) { ); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); + const openImportSessions = useCallback( + (input?: { readonly environmentId?: EnvironmentId; readonly projectId?: ProjectId }) => + dispatch({ + _tag: "OpenImportSessions", + ...(input?.environmentId === undefined ? {} : { environmentId: input.environmentId }), + ...(input?.projectId === undefined ? {} : { projectId: input.projectId }), + }), + [], + ); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { theme, themeHalves, resolvedTheme } = useTheme(); @@ -463,15 +544,33 @@ export function CommandPalette({ children }: { children: ReactNode }) { useEffect( () => onOpenCommandPalette((detail) => { - if (detail.open === "new-thread-in") { - openNewThreadIn(); - } else if (detail.open === "add-project") { - openAddProject(); - } else { + const open = detail.open; + if (open === undefined) { setOpen(true); + return; + } + switch (open) { + case "new-thread-in": + openNewThreadIn(); + return; + case "add-project": + openAddProject(); + return; + case "import-sessions": + openImportSessions({ + ...(detail.environmentId === undefined + ? {} + : { environmentId: detail.environmentId }), + ...(detail.projectId === undefined ? {} : { projectId: detail.projectId }), + }); + return; + default: { + const _exhaustive: never = open; + return _exhaustive; + } } }), - [openAddProject, openNewThreadIn, setOpen], + [openAddProject, openImportSessions, openNewThreadIn, setOpen], ); return ( @@ -578,6 +677,12 @@ function OpenCommandPaletteDialog(props: { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); + const listTeleportSessions = useAtomCommand(teleportEnvironment.listSessions, { + reportFailure: false, + }); + const importTeleportSessions = useAtomCommand(teleportEnvironment.importSessions, { + reportFailure: false, + }); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -631,6 +736,8 @@ function OpenCommandPaletteDialog(props: { browseNavigationRef.current = createBrowseNavigationCoordinator(); } const browseNavigation = browseNavigationRef.current; + const teleportImportPendingRef = useRef(false); + const importListGenerationRef = useRef(0); const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( null, ); @@ -1084,14 +1191,31 @@ function OpenCommandPaletteDialog(props: { const pushPaletteView = useCallback( (view: CommandPaletteView): void => { browseNavigation.invalidate(); - setViewStack((previousViews) => [ - ...previousViews, - { - addonIcon: view.addonIcon, - groups: view.groups, - ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), - }, - ]); + setViewStack((previousViews) => [...previousViews, toInstalledPaletteView(view)]); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + }, + [browseNavigation], + ); + + const replacePaletteView = useCallback( + (view: CommandPaletteView): void => { + browseNavigation.invalidate(); + setAddProjectCloneFlow(null); + setViewStack([toInstalledPaletteView(view)]); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + }, + [browseNavigation], + ); + + const replaceTopPaletteView = useCallback( + (view: CommandPaletteView): void => { + browseNavigation.invalidate(); + setViewStack((previousViews) => { + const nextView = toInstalledPaletteView(view); + return previousViews.length === 0 ? [nextView] : [...previousViews.slice(0, -1), nextView]; + }); setHighlightedItemValue(null); setQuery(view.initialQuery ?? ""); }, @@ -1126,6 +1250,186 @@ function OpenCommandPaletteDialog(props: { } } + const importNativeSession = useCallback( + async ( + project: Project, + session: { + readonly provider: "codex" | "claudeAgent" | "opencode" | "grok"; + readonly externalSessionId: string; + }, + ): Promise => { + if (teleportImportPendingRef.current) { + return; + } + teleportImportPendingRef.current = true; + try { + const result = await importTeleportSessions({ + environmentId: project.environmentId, + input: { + projectId: project.id, + cwd: project.workspaceRoot, + sessions: [ + { + provider: session.provider, + externalSessionId: session.externalSessionId, + }, + ], + }, + }); + if (result._tag === "Success") { + const imported = result.value.imported[0]; + if (imported) { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(project.environmentId, imported.threadId), + ), + }); + toastManager.add({ + type: "success", + title: imported.updatedInPlace + ? "Updated thread from native session" + : "Imported native session", + }); + } + setOpen(false); + return; + } + if (isAtomCommandInterrupted(result)) { + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not import session", + description: teleportFailureMessage(squashAtomCommandFailure(result)), + }), + ); + } finally { + teleportImportPendingRef.current = false; + } + }, + [importTeleportSessions, navigate, setOpen], + ); + + const loadNativeSessionsIntoView = useCallback( + async (project: Project): Promise => { + const generation = (importListGenerationRef.current += 1); + const result = await listTeleportSessions({ + environmentId: project.environmentId, + input: { cwd: project.workspaceRoot }, + }); + if (generation !== importListGenerationRef.current) { + return; + } + if (result._tag !== "Success") { + if (isAtomCommandInterrupted(result)) { + return; + } + replaceTopPaletteView( + nativeSessionsPaletteView(project.title, [ + importSessionsStatusItem({ + value: "import-sessions-error", + title: "Could not list native sessions", + description: teleportFailureMessage(squashAtomCommandFailure(result)), + icon: , + }), + ]), + ); + return; + } + if (result.value.sessions.length === 0) { + replaceTopPaletteView( + nativeSessionsPaletteView(project.title, [ + importSessionsStatusItem({ + value: "import-sessions-empty", + title: "No native sessions in this project", + description: project.workspaceRoot, + icon: , + }), + ]), + ); + return; + } + replaceTopPaletteView( + nativeSessionsPaletteView( + project.title, + result.value.sessions.map((session) => ({ + kind: "action" as const, + value: `import-session:${session.provider}:${session.externalSessionId}`, + searchTerms: [ + session.title ?? "", + session.externalSessionId, + teleportProviderLabel(session.provider), + session.cwd, + ], + title: session.title ?? session.externalSessionId, + description: [ + teleportProviderLabel(session.provider), + session.updatedAt ? formatRelativeTimeLabel(session.updatedAt) : null, + ] + .filter((part): part is string => part !== null) + .join(" · "), + icon: , + run: async () => { + await importNativeSession(project, session); + }, + })), + ), + ); + }, + [importNativeSession, listTeleportSessions, replaceTopPaletteView], + ); + + const openImportSessionsForProject = useCallback( + (project: Project): void => { + pushPaletteView(importSessionsLoadingView(project.title)); + void loadNativeSessionsIntoView(project); + }, + [loadNativeSessionsIntoView, pushPaletteView], + ); + + const importProjectItems = useMemo( + () => + pickerProjects.map((project) => ({ + kind: "action" as const, + value: `import-sessions:${project.environmentId}:${project.id}`, + searchTerms: [project.title, project.workspaceRoot, "import", "teleport"], + title: project.title, + description: project.workspaceRoot, + icon: projectFavicon(project), + keepOpen: true, + run: async () => { + openImportSessionsForProject(project); + }, + })), + [openImportSessionsForProject, pickerProjects], + ); + + const openImportSessionsFlow = useCallback(() => { + if (importProjectItems.length === 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "No projects available", + description: "Add a project before importing native sessions.", + }), + ); + return; + } + const currentPrefix = + currentProjectEnvironmentId && currentProjectId + ? `import-sessions:${currentProjectEnvironmentId}:${currentProjectId}` + : null; + const prioritized = currentPrefix + ? [ + ...importProjectItems.filter((item) => item.value === currentPrefix), + ...importProjectItems.filter((item) => item.value !== currentPrefix), + ] + : importProjectItems; + pushPaletteView(importIntoProjectPaletteView(prioritized)); + }, [currentProjectEnvironmentId, currentProjectId, importProjectItems, pushPaletteView]); + const startAddProjectBrowse = useCallback( async (environmentId: EnvironmentId): Promise => { const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId); @@ -1414,6 +1718,64 @@ function OpenCommandPaletteDialog(props: { pushPaletteView, ]); + useLayoutEffect(() => { + if (openIntent?.kind !== "import-sessions") { + return; + } + const environmentId = openIntent.environmentId; + const projectId = openIntent.projectId; + clearOpenIntent(); + browseNavigation.invalidate(); + setAddProjectCloneFlow(null); + setQuery(""); + if (environmentId !== undefined && projectId !== undefined) { + const project = pickerProjects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + if (project) { + replacePaletteView(importSessionsLoadingView(project.title)); + void loadNativeSessionsIntoView(project); + return; + } + } + if (importProjectItems.length === 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "No projects available", + description: "Add a project before importing native sessions.", + }), + ); + setOpen(false); + return; + } + const currentPrefix = + currentProjectEnvironmentId && currentProjectId + ? `import-sessions:${currentProjectEnvironmentId}:${currentProjectId}` + : null; + replacePaletteView( + importIntoProjectPaletteView( + currentPrefix + ? [ + ...importProjectItems.filter((item) => item.value === currentPrefix), + ...importProjectItems.filter((item) => item.value !== currentPrefix), + ] + : importProjectItems, + ), + ); + }, [ + browseNavigation, + clearOpenIntent, + currentProjectEnvironmentId, + currentProjectId, + importProjectItems, + loadNativeSessionsIntoView, + openIntent, + pickerProjects, + replacePaletteView, + setOpen, + ]); + const actionItems: Array = []; if (projects.length > 0) { @@ -1453,6 +1815,60 @@ function OpenCommandPaletteDialog(props: { addonIcon: , groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); + + const boundTeleport = isTeleportedOut(activeThread?.teleport) + ? (activeThread?.teleport ?? null) + : null; + const boundImportProject = + boundTeleport && activeThread + ? (pickerProjects.find( + (project) => + project.id === activeThread.projectId && + project.environmentId === activeThread.environmentId, + ) ?? null) + : null; + if (boundTeleport && boundImportProject) { + actionItems.push({ + kind: "action", + value: "action:import-this-thread", + searchTerms: [ + "import this thread", + "teleport in", + "native", + "cli", + teleportProviderLabel(boundTeleport.provider), + ], + title: "Import this thread from native CLI", + icon: , + run: async () => { + await importNativeSession(boundImportProject, { + provider: boundTeleport.provider, + externalSessionId: boundTeleport.externalSessionId, + }); + }, + }); + } + + actionItems.push({ + kind: "action", + value: "action:import-sessions", + searchTerms: [ + "import sessions", + "teleport", + "codex", + "claude", + "opencode", + "grok", + "native", + "cli", + ], + title: "Import sessions...", + icon: , + keepOpen: true, + run: async () => { + openImportSessionsFlow(); + }, + }); } actionItems.push({ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e918e7758688..7b65e95190c9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -105,6 +105,7 @@ import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; +import { TELEPORTED_OUT_SEND_DISABLED_REASON } from "~/lib/teleport"; import { Separator } from "../ui/separator"; type ComposerCommandMenuPosition = { @@ -3039,21 +3040,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onCommandKeyDown={onComposerCommandKey} onPaste={onComposerPaste} placeholder={ - isComposerApprovalState - ? (activePendingApproval?.detail ?? "Resolve this approval request to continue") - : activePendingProgress - ? "Type your own answer, or leave this blank to use the selected option" - : showPlanFollowUpPrompt && activeProposedPlan - ? "Add feedback to refine the plan, or leave this blank to implement it" - : projectSelectionRequired - ? "Choose a project above to start a thread" - : noProviderAvailable - ? "Enable a provider in Settings to send a message" - : phase === "disconnected" - ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, $use skills, or / for commands" + sendDisabledReason === TELEPORTED_OUT_SEND_DISABLED_REASON + ? TELEPORTED_OUT_SEND_DISABLED_REASON + : isComposerApprovalState + ? (activePendingApproval?.detail ?? + "Resolve this approval request to continue") + : activePendingProgress + ? "Type your own answer, or leave this blank to use the selected option" + : showPlanFollowUpPrompt && activeProposedPlan + ? "Add feedback to refine the plan, or leave this blank to implement it" + : projectSelectionRequired + ? "Choose a project above to start a thread" + : noProviderAvailable + ? "Enable a provider in Settings to send a message" + : phase === "disconnected" + ? "Ask for follow-up changes or attach images" + : "Ask anything, @tag files/folders, $use skills, or / for commands" + } + disabled={ + isConnecting || + isComposerApprovalState || + projectSelectionRequired || + sendDisabledReason === TELEPORTED_OUT_SEND_DISABLED_REASON } - disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> {showMobilePendingAnswerActions ? (
)} +
); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 9377dfa229a1..e603a03ecd7e 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,6 +1,6 @@ import type { ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { FolderPlusIcon } from "lucide-react"; +import { FolderPlusIcon, ImportIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; @@ -41,6 +41,17 @@ export function DraftHeroHeadline({ const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); const handleNewThread = useNewThreadHandler(); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + const openImportSessions = useCallback(() => { + if (activeProjectRef === null) { + openCommandPalette({ open: "import-sessions" }); + return; + } + openCommandPalette({ + open: "import-sessions", + environmentId: activeProjectRef.environmentId, + projectId: activeProjectRef.projectId, + }); + }, [activeProjectRef]); const environmentLabelById = useMemo( () => @@ -151,14 +162,26 @@ export function DraftHeroHeadline({ ); return ( -

+
+

+ {hasResolvedProject ? ( + <>What should we build in {projectSelector}? + ) : canChooseProject ? ( + <>{projectSelector} to start + ) : ( + <>Add a project to start + )} +

{hasResolvedProject ? ( - <>What should we build in {projectSelector}? - ) : canChooseProject ? ( - <>{projectSelector} to start - ) : ( - <>Add a project to start - )} -

+ + ) : null} + ); } diff --git a/apps/web/src/components/teleport/TeleportOutButton.tsx b/apps/web/src/components/teleport/TeleportOutButton.tsx new file mode 100644 index 000000000000..9ea090fc599a --- /dev/null +++ b/apps/web/src/components/teleport/TeleportOutButton.tsx @@ -0,0 +1,170 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { isTeleportProvider, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { LogOutIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { buildLoadingThreadFromShell } from "../ChatView.logic"; +import { isTeleportedOut, teleportFailureMessage } from "../../lib/teleport"; +import { useThread, useThreadShell } from "../../state/entities"; +import { teleportEnvironment } from "../../state/teleport"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +interface TeleportOutButtonProps { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly isServerThread: boolean; + readonly cwd: string | null; +} + +export function TeleportOutButton({ + environmentId, + threadId, + isServerThread, + cwd, +}: TeleportOutButtonProps) { + const threadRef = scopeThreadRef(environmentId, threadId); + const detail = useThread(threadRef); + const shell = useThreadShell(threadRef); + const exportSession = useAtomCommand(teleportEnvironment.exportSession, { + reportFailure: false, + }); + const importSessions = useAtomCommand(teleportEnvironment.importSessions, { + reportFailure: false, + }); + const pendingRef = useRef(false); + const [pending, setPending] = useState(false); + const thread = detail ?? (shell === null ? null : buildLoadingThreadFromShell(shell)); + if (!isServerThread || thread === null) { + return null; + } + + const teleport = thread.teleport ?? null; + const teleportedOut = isTeleportedOut(teleport); + const providerName = thread.session?.providerName; + const supported = + teleportedOut || + (typeof providerName === "string" && isTeleportProvider(providerName)) || + isTeleportProvider(thread.modelSelection.instanceId); + if (!supported) { + return null; + } + + const sessionBusy = thread.session?.status === "starting" || thread.session?.status === "running"; + const importNeedsCwd = teleportedOut && (cwd === null || cwd.length === 0); + const busy = pending || sessionBusy || importNeedsCwd; + + return ( + + { + if (pendingRef.current || busy) { + return; + } + pendingRef.current = true; + setPending(true); + void (async () => { + try { + if (teleportedOut) { + if (teleport === null || cwd === null || cwd.length === 0) { + return; + } + const result = await importSessions({ + environmentId, + input: { + projectId: thread.projectId, + cwd, + sessions: [ + { + provider: teleport.provider, + externalSessionId: teleport.externalSessionId, + }, + ], + }, + }); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Imported from native session", + description: teleport.nativePath, + }); + return; + } + if (isAtomCommandInterrupted(result)) { + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not teleport in", + description: teleportFailureMessage(squashAtomCommandFailure(result)), + }), + ); + return; + } + + const result = await exportSession({ + environmentId, + input: { threadId }, + }); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Teleported to native session", + description: result.value.nativePath, + }); + return; + } + if (isAtomCommandInterrupted(result)) { + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not teleport out", + description: teleportFailureMessage(squashAtomCommandFailure(result)), + }), + ); + } finally { + pendingRef.current = false; + setPending(false); + } + })(); + }} + > + + + } + /> + + {teleportedOut + ? importNeedsCwd + ? "Import needs the project workspace path" + : sessionBusy + ? "Import is available when this provider thread is idle" + : "Read this thread back from the native CLI session" + : sessionBusy + ? "Teleport out is available when this provider thread is idle" + : "Write this idle thread to the native CLI session"} + + + ); +} diff --git a/apps/web/src/lib/teleport.ts b/apps/web/src/lib/teleport.ts new file mode 100644 index 000000000000..09548c3730c2 --- /dev/null +++ b/apps/web/src/lib/teleport.ts @@ -0,0 +1,39 @@ +import { isTeleportedOut, type TeleportProvider } from "@t3tools/contracts"; + +export { isTeleportedOut }; + +export const TELEPORTED_OUT_SEND_DISABLED_REASON = + "This thread is in the native CLI. Import it to keep chatting here."; + +export function teleportProviderLabel(provider: TeleportProvider): string { + switch (provider) { + case "codex": + return "Codex"; + case "claudeAgent": + return "Claude"; + case "opencode": + return "OpenCode"; + case "grok": + return "Grok"; + default: { + const _exhaustive: never = provider; + return _exhaustive; + } + } +} + +export function teleportFailureMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" && + error.message.trim().length > 0 + ) { + return error.message; + } + return "Teleport failed."; +} diff --git a/apps/web/src/state/teleport.ts b/apps/web/src/state/teleport.ts new file mode 100644 index 000000000000..2d0802de3306 --- /dev/null +++ b/apps/web/src/state/teleport.ts @@ -0,0 +1,5 @@ +import { createTeleportEnvironmentAtoms } from "@t3tools/client-runtime/state/teleport"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const teleportEnvironment = createTeleportEnvironmentAtoms(connectionAtomRuntime); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 01600af4699f..36a07d563518 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -119,6 +119,10 @@ "types": "./src/state/sourceControl.ts", "default": "./src/state/sourceControl.ts" }, + "./state/teleport": { + "types": "./src/state/teleport.ts", + "default": "./src/state/teleport.ts" + }, "./state/terminal": { "types": "./src/state/terminal.ts", "default": "./src/state/terminal.ts" diff --git a/packages/client-runtime/src/state/teleport.ts b/packages/client-runtime/src/state/teleport.ts new file mode 100644 index 000000000000..9588a665a49b --- /dev/null +++ b/packages/client-runtime/src/state/teleport.ts @@ -0,0 +1,24 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcCommand } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createTeleportEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + listSessions: createEnvironmentRpcCommand(runtime, { + label: "environment-data:teleport:list-sessions", + tag: WS_METHODS.teleportListSessions, + }), + importSessions: createEnvironmentRpcCommand(runtime, { + label: "environment-data:teleport:import-sessions", + tag: WS_METHODS.teleportImportSessions, + }), + exportSession: createEnvironmentRpcCommand(runtime, { + label: "environment-data:teleport:export-session", + tag: WS_METHODS.teleportExportSession, + }), + }; +} From 1700d19cfa83897a410bfd7ecbe4efcbe3a407c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:54:13 +0000 Subject: [PATCH 03/51] feat(teleport): add Codex native session support Register the Codex jsonl adapter so T3 can list, import, and export rollouts the CLI can resume. Co-authored-by: aanishbhirud --- .../server/src/teleport/formats/codex.test.ts | 191 ++++++++ apps/server/src/teleport/formats/codex.ts | 411 ++++++++++++++++++ apps/server/src/teleport/formats/register.ts | 1 + 3 files changed, 603 insertions(+) create mode 100644 apps/server/src/teleport/formats/codex.test.ts create mode 100644 apps/server/src/teleport/formats/codex.ts diff --git a/apps/server/src/teleport/formats/codex.test.ts b/apps/server/src/teleport/formats/codex.test.ts new file mode 100644 index 000000000000..aeaf4ef0c660 --- /dev/null +++ b/apps/server/src/teleport/formats/codex.test.ts @@ -0,0 +1,191 @@ +// Native session fixtures are JSON/JSONL records, not Effect schemas. +// @effect-diagnostics preferSchemaOverJson:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind, TELEPORT_NATIVE_FORMAT_VERSION } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { discoverTeleportSessions } from "../discovery.ts"; +import type { TeleportHomes } from "../homes.ts"; +import { buildTeleportResumeCursor, readTeleportExternalSessionId } from "../resumeCursors.ts"; +import { + sampleTeleportSession, + TELEPORT_TEST_CREATED_AT, + TELEPORT_TEST_SESSION_ID, +} from "../testFixtures.ts"; +import { parseCodexSessionContents, serializeCodexSession } from "./codex.ts"; + +describe("teleport Codex format", () => { + it.effect("roundtrips Codex jsonl", () => + Effect.gen(function* () { + const session = sampleTeleportSession("codex"); + const parsed = yield* parseCodexSessionContents({ + contents: serializeCodexSession(session), + nativePath: session.nativePath, + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.externalSessionId, TELEPORT_TEST_SESSION_ID); + assert.equal(parsed.value.cwd, "/workspace"); + assert.equal(parsed.value.messages.length, 2); + assert.equal(parsed.value.messages[0]?.text, "Fix the flaky matcher"); + assert.equal( + parsed.value.messages[1]?.text, + "I'll tighten the path comparison and add a realpath fallback.", + ); + } + }), + ); + + it("writes Codex session_meta the CLI can resume", () => { + const contents = serializeCodexSession(sampleTeleportSession("codex")); + const firstLine = contents.split("\n")[0] ?? ""; + const event = JSON.parse(firstLine) as { + type?: unknown; + ordinal?: unknown; + nativeFormatVersion?: unknown; + payload?: { + id?: unknown; + session_id?: unknown; + cwd?: unknown; + originator?: unknown; + cli_version?: unknown; + source?: unknown; + }; + }; + assert.equal(event.type, "session_meta"); + assert.equal(event.ordinal, 0); + assert.equal(event.nativeFormatVersion, undefined); + assert.equal(event.payload?.id, TELEPORT_TEST_SESSION_ID); + assert.equal(event.payload?.session_id, TELEPORT_TEST_SESSION_ID); + assert.equal(event.payload?.cwd, "/workspace"); + assert.equal(typeof event.payload?.cli_version, "string"); + assert.notEqual(event.payload?.cli_version, ""); + assert.equal(event.payload?.originator, "t3-teleport"); + assert.equal(event.payload?.source, "cli"); + }); + + it.effect("skips Codex environment_context user wrappers", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "session_meta", + payload: { id: TELEPORT_TEST_SESSION_ID, cwd: "/workspace" }, + })}\n${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "\n /workspace\n" }, + ], + }, + })}\n${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "KEEP_NAT_CODEX_U1_CEDAR: add a --json flag" }], + }, + })}\n`; + const parsed = yield* parseCodexSessionContents({ + contents, + nativePath: "/tmp/codex-env.jsonl", + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.messages.length, 1); + assert.equal(parsed.value.messages[0]?.text, "KEEP_NAT_CODEX_U1_CEDAR: add a --json flag"); + assert.equal(parsed.value.title, "KEEP_NAT_CODEX_U1_CEDAR: add a --json flag"); + } + }), + ); + + it.effect("skips forked Codex sessions", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "session_meta", + payload: { + id: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + forked_from_id: "parent-session", + }, + })}\n`; + const parsed = yield* parseCodexSessionContents({ + contents, + nativePath: "/tmp/fork.jsonl", + }); + assert.equal(Option.isNone(parsed), true); + }), + ); + + it.effect("fails closed on a newer Codex format version", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "session_meta", + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION + 1, + payload: { id: TELEPORT_TEST_SESSION_ID, cwd: "/workspace" }, + })}\n`; + const result = yield* parseCodexSessionContents({ + contents, + nativePath: "/tmp/new-codex.jsonl", + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "TeleportSchemaVersionError"); + } + }), + ); + + it("roundtrips Codex resume cursors", () => { + assert.equal( + readTeleportExternalSessionId({ + provider: ProviderDriverKind.make("codex"), + resumeCursor: buildTeleportResumeCursor({ + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + }), + runtimePayload: null, + }), + TELEPORT_TEST_SESSION_ID, + ); + }); + + it.effect("lists matching Codex sessions and skips unreadable files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-codex-homes-" }); + const homes: TeleportHomes = { + codexSessionsRoot: path.join(root, "codex", "sessions"), + claudeProjectsRoot: path.join(root, "claude", "projects"), + opencodeRoot: path.join(root, "opencode"), + grokSessionsRoot: path.join(root, "grok", "sessions"), + }; + const codexPath = path.join( + homes.codexSessionsRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + yield* fs.makeDirectory(path.dirname(codexPath), { recursive: true }); + yield* fs.writeFileString(codexPath, serializeCodexSession(sampleTeleportSession("codex"))); + yield* fs.writeFileString(path.join(path.dirname(codexPath), "garbage.jsonl"), "not-json\n"); + const listed = yield* discoverTeleportSessions({ + homes, + cwd: "/workspace", + }); + assert.equal(listed.sessions.length, 1); + assert.equal(listed.sessions[0]?.provider, "codex"); + assert.equal(listed.sessions[0]?.externalSessionId, TELEPORT_TEST_SESSION_ID); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/teleport/formats/codex.ts b/apps/server/src/teleport/formats/codex.ts new file mode 100644 index 000000000000..bcb1e830511c --- /dev/null +++ b/apps/server/src/teleport/formats/codex.ts @@ -0,0 +1,411 @@ +// Native Codex files store wall-clock ISO timestamps and JSONL event records. +// @effect-diagnostics globalDate:off preferSchemaOverJson:off +import { + TELEPORT_NATIVE_FORMAT_VERSION, + TeleportDiscoveryError, + TeleportNativeWriteError, + TeleportSchemaVersionError, + defaultInstanceIdForDriver, + ProviderDriverKind, + type TeleportSessionCandidate, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { teleportCwdsEquivalent } from "../cwd.ts"; +import { readNativeSessionFile } from "../sessionFile.ts"; +import { requireNativePathUnlocked } from "../fileLock.ts"; +import { + firstUserTitle, + isRecord, + isSyntheticNativeUserText, + nonEmptyString, + parseJsonObject, + uuidFromPath, +} from "../json.ts"; +import { writeNativeSessionAtomically } from "../nativeWrite.ts"; +import { registerTeleportFormat } from "./registry.ts"; +import { + nativeTextMessage, + parsedNativeSession, + teleportCandidateFields, + type NativeTextMessage, + type ParsedNativeSession, +} from "../types.ts"; + +const CODEX = ProviderDriverKind.make("codex"); + +function isForkedSessionMeta(payload: Record): boolean { + if (typeof payload.forked_from_id === "string") { + return true; + } + const source = payload.source; + if (!isRecord(source)) { + return false; + } + const subagent = source.subagent; + if (!isRecord(subagent)) { + return false; + } + const spawn = subagent.thread_spawn; + if (!isRecord(spawn)) { + return false; + } + return typeof spawn.parent_thread_id === "string"; +} + +function extractMessage(event: Record): NativeTextMessage | undefined { + if (event.type !== "response_item") { + return undefined; + } + const payload = isRecord(event.payload) ? event.payload : undefined; + if (payload?.type !== "message") { + return undefined; + } + const role = payload.role === "user" || payload.role === "assistant" ? payload.role : undefined; + if (!role) { + return undefined; + } + const text = collectCodexText(payload.content); + if (!text) { + return undefined; + } + if (isSyntheticNativeUserText(text)) { + return undefined; + } + return nativeTextMessage({ + role, + text, + createdAt: nonEmptyString(event.timestamp), + id: undefined, + }); +} + +function collectCodexText(content: unknown): string | undefined { + if (typeof content === "string") { + return nonEmptyString(content); + } + if (!Array.isArray(content)) { + return undefined; + } + const parts: string[] = []; + for (const part of content) { + if (!isRecord(part)) { + continue; + } + const text = nonEmptyString(part.text); + if (text) { + parts.push(text); + } + } + return nonEmptyString(parts.join("\n")); +} + +export function parseCodexSessionContents(input: { + readonly contents: string; + readonly nativePath: string; +}): Effect.Effect, TeleportSchemaVersionError> { + const lines = input.contents.split(/\r?\n/u).filter((line) => line.trim().length > 0); + if (lines.length === 0) { + return Effect.succeed(Option.none()); + } + + let sessionId: string | undefined; + let cwd: string | undefined; + let createdAt: string | undefined; + let title: string | undefined; + let nativeFormatVersion: number = TELEPORT_NATIVE_FORMAT_VERSION; + let forked = false; + const messages: NativeTextMessage[] = []; + + for (const line of lines) { + const event = parseJsonObject(line); + if (!event) { + continue; + } + const declaredVersion = event.nativeFormatVersion; + if (typeof declaredVersion === "number" && Number.isInteger(declaredVersion)) { + nativeFormatVersion = declaredVersion; + } + if (nativeFormatVersion > TELEPORT_NATIVE_FORMAT_VERSION) { + return Effect.fail( + new TeleportSchemaVersionError({ + provider: "codex", + nativePath: input.nativePath, + foundVersion: nativeFormatVersion, + supportedVersion: TELEPORT_NATIVE_FORMAT_VERSION, + message: `Unsupported Codex session format version ${nativeFormatVersion} in ${input.nativePath}.`, + }), + ); + } + + if (event.type === "session_meta") { + const payload = isRecord(event.payload) ? event.payload : undefined; + if (!payload) { + continue; + } + if (isForkedSessionMeta(payload)) { + forked = true; + continue; + } + sessionId = nonEmptyString(payload.id) ?? nonEmptyString(payload.session_id) ?? sessionId; + cwd = nonEmptyString(payload.cwd) ?? cwd; + createdAt = nonEmptyString(event.timestamp) ?? nonEmptyString(payload.timestamp) ?? createdAt; + title = nonEmptyString(payload.title) ?? title; + continue; + } + + if (event.type === "turn_context") { + const payload = isRecord(event.payload) ? event.payload : undefined; + cwd = nonEmptyString(payload?.cwd) ?? cwd; + continue; + } + + const message = extractMessage(event); + if (message) { + messages.push(message); + } + } + + if (forked) { + return Effect.succeed(Option.none()); + } + + const externalSessionId = sessionId ?? uuidFromPath(input.nativePath); + if (!externalSessionId || !cwd) { + return Effect.succeed(Option.none()); + } + + const updatedAt = messages.at(-1)?.createdAt ?? createdAt; + return Effect.succeed( + Option.some( + parsedNativeSession({ + provider: "codex", + externalSessionId, + cwd, + nativePath: input.nativePath, + nativeFormatVersion, + title: title ?? firstUserTitle(messages), + createdAt, + updatedAt, + messages, + }), + ), + ); +} + +const CODEX_EXPORT_ORIGINATOR = "t3-teleport"; +const CODEX_EXPORT_CLI_VERSION = "0.0.0"; + +/** + * Codex TUI bootstrap requires the first parseable rollout line to be + * `session_meta`. Extra top-level fields (including our format version) make + * that line fail serde, so resume then treats the first `response_item` as + * the header and errors with "does not start with session metadata". + * `cli_version` is required on SessionMeta. + */ +export function serializeCodexSession(session: ParsedNativeSession): string { + const timestamp = session.createdAt ?? new Date().toISOString(); + let ordinal = 0; + const lines: string[] = [ + JSON.stringify({ + timestamp, + ordinal: ordinal++, + type: "session_meta", + payload: { + id: session.externalSessionId, + session_id: session.externalSessionId, + timestamp, + cwd: session.cwd, + originator: CODEX_EXPORT_ORIGINATOR, + cli_version: CODEX_EXPORT_CLI_VERSION, + source: "cli", + thread_source: "user", + }, + }), + JSON.stringify({ + timestamp, + ordinal: ordinal++, + type: "turn_context", + payload: { + cwd: session.cwd, + }, + }), + ]; + + for (const message of session.messages) { + const at = message.createdAt ?? timestamp; + lines.push( + JSON.stringify({ + timestamp: at, + ordinal: ordinal++, + type: "response_item", + payload: { + type: "message", + role: message.role, + content: [ + { + type: message.role === "user" ? "input_text" : "output_text", + text: message.text, + }, + ], + }, + }), + ); + } + + return `${lines.join("\n")}\n`; +} + +export function allocateCodexSessionPath(input: { + readonly sessionsRoot: string; + readonly sessionId: string; + readonly createdAt: string; + readonly join: (left: string, ...rest: string[]) => string; +}): string { + const created = new Date(input.createdAt); + const safeDate = Number.isNaN(created.getTime()) ? new Date() : created; + const year = String(safeDate.getUTCFullYear()); + const month = String(safeDate.getUTCMonth() + 1).padStart(2, "0"); + const day = String(safeDate.getUTCDate()).padStart(2, "0"); + const stamp = safeDate.toISOString().replaceAll(":", "-"); + return input.join( + input.sessionsRoot, + year, + month, + day, + `rollout-${stamp}-${input.sessionId}.jsonl`, + ); +} + +export const listCodexJsonlFiles = Effect.fn("listCodexJsonlFiles")(function* ( + sessionsRoot: string, +) { + return yield* walkFiles(sessionsRoot, (filePath) => filePath.endsWith(".jsonl")); +}); + +export function toCodexCandidate(session: ParsedNativeSession): TeleportSessionCandidate { + return { + provider: "codex", + providerInstanceId: defaultInstanceIdForDriver(CODEX), + externalSessionId: session.externalSessionId, + cwd: session.cwd, + nativePath: session.nativePath, + nativeFormatVersion: session.nativeFormatVersion, + ...teleportCandidateFields(session), + }; +} + +const walkFiles = Effect.fn("walkFiles")(function* ( + root: string, + predicate: (filePath: string) => boolean, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exists = yield* fs.exists(root).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return []; + } + + const files: string[] = []; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + const entries = yield* fs.readDirectory(current).pipe(Effect.orElseSucceed(() => [])); + for (const name of entries) { + const entryPath = path.join(current, name); + const stat = yield* fs.stat(entryPath).pipe(Effect.orElseSucceed(() => null)); + if (stat === null) { + continue; + } + if (stat.type === "Directory") { + stack.push(entryPath); + } else if (stat.type === "File" && predicate(entryPath)) { + files.push(entryPath); + } + } + } + return files; +}); + +registerTeleportFormat({ + provider: "codex", + list: Effect.fn("listCodexSessions")(function* (input) { + const files = yield* listCodexJsonlFiles(input.homes.codexSessionsRoot); + const sessions = []; + for (const nativePath of files) { + const parsed = yield* readNativeSessionFile({ + nativePath, + parse: parseCodexSessionContents, + }); + if (Option.isNone(parsed)) { + continue; + } + if (!(yield* teleportCwdsEquivalent(parsed.value.cwd, input.cwd))) { + continue; + } + sessions.push(toCodexCandidate(parsed.value)); + } + return sessions; + }), + load: Effect.fn("loadCodexSession")(function* (input) { + const parsed = yield* readNativeSessionFile({ + nativePath: input.nativePath, + parse: parseCodexSessionContents, + }); + if (Option.isNone(parsed)) { + return yield* new TeleportDiscoveryError({ + message: `Native Codex session '${input.externalSessionId}' could not be parsed.`, + }); + } + return parsed.value; + }), + write: Effect.fn("writeCodexSession")(function* (input) { + const path = yield* Path.Path; + const now = yield* DateTime.now; + const nativePath = + input.existingNativePath ?? + allocateCodexSessionPath({ + sessionsRoot: input.homes.codexSessionsRoot, + sessionId: input.session.externalSessionId, + createdAt: input.session.createdAt ?? input.session.updatedAt ?? DateTime.formatIso(now), + join: path.join, + }); + const contents = serializeCodexSession({ ...input.session, nativePath }); + yield* writeNativeSessionAtomically({ + filePath: nativePath, + contents, + verify: (written) => + parseCodexSessionContents({ contents: written, nativePath }).pipe( + Effect.flatMap((parsed) => + Option.isSome(parsed) + ? Effect.void + : new TeleportNativeWriteError({ + nativePath, + message: `Exported Codex session failed verification: ${nativePath}`, + }), + ), + Effect.mapError((error) => + error._tag === "TeleportSchemaVersionError" + ? new TeleportNativeWriteError({ + nativePath, + message: error.message, + cause: error, + }) + : error, + ), + ), + }); + return nativePath; + }), + requireUnlocked: (input) => requireNativePathUnlocked(input.nativePath), + resumeCursor: (externalSessionId) => ({ threadId: externalSessionId }), + readExternalSessionId: (resumeCursor) => + isRecord(resumeCursor) ? nonEmptyString(resumeCursor.threadId) : undefined, +}); diff --git a/apps/server/src/teleport/formats/register.ts b/apps/server/src/teleport/formats/register.ts index 02f9707edf95..83b4d55384f8 100644 --- a/apps/server/src/teleport/formats/register.ts +++ b/apps/server/src/teleport/formats/register.ts @@ -1,2 +1,3 @@ // Native format adapters register themselves by importing their modules here. +import "./codex.ts"; export function loadTeleportFormats(): void {} From dbc84524372ee6d374fdfa78560f1bb30aa2cecd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:54:14 +0000 Subject: [PATCH 04/51] feat(teleport): add OpenCode native session support Register the OpenCode adapter so T3 can list, import, and export text turns from opencode.db or JSON storage without treating the live db as a foreign lock. Co-authored-by: aanishbhirud --- .../src/teleport/formats/opencode.test.ts | 263 ++++++++ apps/server/src/teleport/formats/opencode.ts | 635 ++++++++++++++++++ apps/server/src/teleport/formats/register.ts | 1 + 3 files changed, 899 insertions(+) create mode 100644 apps/server/src/teleport/formats/opencode.test.ts create mode 100644 apps/server/src/teleport/formats/opencode.ts diff --git a/apps/server/src/teleport/formats/opencode.test.ts b/apps/server/src/teleport/formats/opencode.test.ts new file mode 100644 index 000000000000..b1a81f7513f9 --- /dev/null +++ b/apps/server/src/teleport/formats/opencode.test.ts @@ -0,0 +1,263 @@ +// Native session fixtures are JSON/JSONL records, not Effect schemas. +// @effect-diagnostics preferSchemaOverJson:off nodeBuiltinImport:off +import * as NodeSqlite from "node:sqlite"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { buildTeleportResumeCursor, readTeleportExternalSessionId } from "../resumeCursors.ts"; +import { sampleTeleportSession, TELEPORT_TEST_SESSION_ID } from "../testFixtures.ts"; +import { + isOpenCodeSharedStorePath, + listOpenCodeSessions, + readOpenCodeSessionById, + requireOpenCodeSessionUnlocked, + writeOpenCodeSession, +} from "./opencode.ts"; + +function writeOpenCodeSqliteFixture(input: { + readonly dbPath: string; + readonly sessions: ReadonlyArray<{ + readonly id: string; + readonly directory: string; + readonly title: string; + readonly messages: ReadonlyArray<{ + readonly id: string; + readonly role: "user" | "assistant"; + readonly parts: ReadonlyArray; + }>; + }>; +}): void { + const db = new NodeSqlite.DatabaseSync(input.dbPath); + try { + db.exec(` + CREATE TABLE session ( + id TEXT, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER, + time_archived INTEGER + ); + CREATE TABLE message ( + id TEXT, + session_id TEXT, + time_created INTEGER, + data TEXT + ); + CREATE TABLE part ( + id TEXT, + message_id TEXT, + time_created INTEGER, + data TEXT + ); + `); + let time = 1; + for (const session of input.sessions) { + db.prepare( + ` + INSERT INTO session (id, directory, title, time_created, time_updated, time_archived) + VALUES (?, ?, ?, ?, ?, NULL) + `, + ).run(session.id, session.directory, session.title, time, time); + for (const message of session.messages) { + time += 1; + db.prepare( + `INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`, + ).run(message.id, session.id, time, JSON.stringify({ role: message.role })); + for (const [index, part] of message.parts.entries()) { + time += 1; + db.prepare( + `INSERT INTO part (id, message_id, time_created, data) VALUES (?, ?, ?, ?)`, + ).run(`${message.id}-p${index}`, message.id, time, JSON.stringify(part)); + } + } + } + } finally { + db.close(); + } +} + +describe("teleport OpenCode format", () => { + it.effect("roundtrips OpenCode json storage", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-opencode-" }); + const session = sampleTeleportSession("opencode", "/workspace"); + const nativePath = yield* writeOpenCodeSession({ + opencodeRoot: root, + session, + }); + const parsed = yield* readOpenCodeSessionById({ + opencodeRoot: root, + sessionId: TELEPORT_TEST_SESSION_ID, + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.cwd, "/workspace"); + assert.equal(parsed.value.messages.length, 2); + assert.equal(path.basename(nativePath), `${TELEPORT_TEST_SESSION_ID}.json`); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("imports OpenCode sqlite text parts and skips step-finish telemetry", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-opencode-sqlite-" }); + writeOpenCodeSqliteFixture({ + dbPath: path.join(root, "opencode.db"), + sessions: [ + { + id: "ses_meal", + directory: "/home/ubuntu/baanish-testing/native", + title: "Weekly meal planning webapp", + messages: [ + { + id: "msg_user", + role: "user", + parts: [{ type: "text", text: "Make light mode pastel themed" }], + }, + { + id: "msg_assistant", + role: "assistant", + parts: [ + { type: "step-start" }, + { type: "reasoning", text: "I will restyle the page." }, + { type: "text", text: "Updated the palette to pastels." }, + { type: "tool", tool: "edit", state: { status: "completed" } }, + { + type: "step-finish", + reason: "stop", + tokens: { total: 12, input: 8, output: 4 }, + cost: 0, + }, + ], + }, + ], + }, + ], + }); + const parsed = yield* readOpenCodeSessionById({ + opencodeRoot: root, + sessionId: "ses_meal", + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.deepStrictEqual( + parsed.value.messages.map((message) => ({ role: message.role, text: message.text })), + [ + { role: "user", text: "Make light mode pastel themed" }, + { role: "assistant", text: "Updated the palette to pastels." }, + ], + ); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("lists OpenCode sessions launched from a parent project cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-opencode-parent-" }); + writeOpenCodeSqliteFixture({ + dbPath: path.join(root, "opencode.db"), + sessions: [ + { + id: "ses_parent", + directory: "/home/ubuntu/baanish-testing/native", + title: "Weekly meal planning webapp", + messages: [ + { + id: "msg_user", + role: "user", + parts: [{ type: "text", text: "make a simple webapp" }], + }, + ], + }, + { + id: "ses_tmp", + directory: "/tmp", + title: "tmp session", + messages: [ + { + id: "msg_tmp", + role: "user", + parts: [{ type: "text", text: "should not match a tmp child project" }], + }, + ], + }, + ], + }); + const listed = yield* listOpenCodeSessions({ + opencodeRoot: root, + cwd: "/home/ubuntu/baanish-testing/native/opencode", + }); + assert.deepStrictEqual( + listed.map((session) => session.externalSessionId), + ["ses_parent"], + ); + const tmpListed = yield* listOpenCodeSessions({ + opencodeRoot: root, + cwd: "/tmp/oc-wire-test", + }); + assert.deepStrictEqual( + tmpListed.map((session) => session.externalSessionId), + [], + ); + const codexListed = yield* listOpenCodeSessions({ + opencodeRoot: root, + cwd: "/home/ubuntu/baanish-testing/native/codex", + }); + assert.deepStrictEqual( + codexListed.map((session) => session.externalSessionId), + [], + ); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(NodeServices.layer), + ), + ); + + it("treats opencode.db as the shared live store, not a session file", () => { + assert.equal(isOpenCodeSharedStorePath("/home/ubuntu/.local/share/opencode/opencode.db"), true); + assert.equal( + isOpenCodeSharedStorePath("/home/ubuntu/.local/share/opencode/storage/session/t3/abc.json"), + false, + ); + }); + + it.effect("does not refuse import when T3 already has opencode.db open", () => + requireOpenCodeSessionUnlocked({ + nativePath: "/home/ubuntu/.local/share/opencode/opencode.db", + opencodeRoot: "/home/ubuntu/.local/share/opencode", + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(NodeServices.layer), + ), + ); + + it("roundtrips OpenCode resume cursors", () => { + assert.equal( + readTeleportExternalSessionId({ + provider: ProviderDriverKind.make("opencode"), + resumeCursor: buildTeleportResumeCursor({ + provider: "opencode", + externalSessionId: TELEPORT_TEST_SESSION_ID, + }), + runtimePayload: null, + }), + TELEPORT_TEST_SESSION_ID, + ); + }); +}); diff --git a/apps/server/src/teleport/formats/opencode.ts b/apps/server/src/teleport/formats/opencode.ts new file mode 100644 index 000000000000..1d12f56f75b2 --- /dev/null +++ b/apps/server/src/teleport/formats/opencode.ts @@ -0,0 +1,635 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off preferSchemaOverJson:off +import * as NodeSqlite from "node:sqlite"; + +import { + TELEPORT_NATIVE_FORMAT_VERSION, + TeleportDiscoveryError, + TeleportNativeWriteError, + TeleportSchemaVersionError, + defaultInstanceIdForDriver, + ProviderDriverKind, + type TeleportSessionCandidate, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { opencodeSessionMatchesProjectCwd, teleportCwdsMatch } from "../cwd.ts"; +import { requireNativePathUnlocked } from "../fileLock.ts"; +import { firstUserTitle, isRecord, nonEmptyString, parseJsonObject } from "../json.ts"; +import { registerTeleportFormat } from "./registry.ts"; +import { + nativeTextMessage, + parsedNativeSession, + teleportCandidateFields, + type NativeTextMessage, + type ParsedNativeSession, +} from "../types.ts"; + +const OPENCODE = ProviderDriverKind.make("opencode"); + +function dateFromMillis(value: unknown): string | undefined { + return typeof value === "number" && Number.isFinite(value) + ? new Date(value).toISOString() + : undefined; +} + +function millisFromIso(value: string | undefined): number { + if (!value) { + return Date.now(); + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Date.now(); +} + +function normalizeOpenCodeText(text: string): string { + try { + const parsed: unknown = JSON.parse(text); + if (typeof parsed === "string") { + return parsed; + } + } catch { + // OpenCode sometimes stores plain text. + } + return text; +} + +function textFromOpenCodePartRecord(record: Record): string | undefined { + if (record.type !== undefined && record.type !== "text") { + return undefined; + } + const text = nonEmptyString(record.text); + return text === undefined ? undefined : normalizeOpenCodeText(text); +} + +function textFromPartData(data: unknown): string | undefined { + if (isRecord(data)) { + return textFromOpenCodePartRecord(data); + } + if (typeof data !== "string") { + return undefined; + } + try { + const parsed: unknown = JSON.parse(data); + if (isRecord(parsed)) { + return textFromOpenCodePartRecord(parsed); + } + if (typeof parsed === "string") { + return nonEmptyString(parsed); + } + return undefined; + } catch { + return nonEmptyString(data); + } +} + +export function opencodeDbPath( + opencodeRoot: string, + join: (left: string, ...rest: string[]) => string, +): string { + return join(opencodeRoot, "opencode.db"); +} + +export function isOpenCodeSharedStorePath(nativePath: string): boolean { + const normalized = nativePath.replaceAll("\\", "/"); + return normalized.endsWith("/opencode.db"); +} + +export const requireOpenCodeSessionUnlocked = Effect.fn("requireOpenCodeSessionUnlocked")( + function* (input: { readonly nativePath: string; readonly opencodeRoot: string }) { + // T3's idle OpenCode process keeps opencode.db open. Treat that as the live + // store, not a foreign lock. Only refuse when the session JSON itself is held. + const path = yield* Path.Path; + if ( + isOpenCodeSharedStorePath(input.nativePath) || + teleportCwdsMatch(input.nativePath, opencodeDbPath(input.opencodeRoot, path.join)) + ) { + return; + } + yield* requireNativePathUnlocked(input.nativePath); + }, +); + +export const listOpenCodeSessions = Effect.fn("listOpenCodeSessions")(function* (input: { + readonly opencodeRoot: string; + readonly cwd: string; +}) { + const sqliteSessions = yield* readOpenCodeSqliteSessions({ + opencodeRoot: input.opencodeRoot, + cwd: input.cwd, + pathsMatch: () => true, + }); + const candidates = + sqliteSessions.length > 0 + ? sqliteSessions + : yield* readOpenCodeJsonSessions(input.opencodeRoot); + const sessions: ParsedNativeSession[] = []; + for (const session of candidates) { + if (yield* opencodeSessionMatchesProjectCwd(session.cwd, input.cwd)) { + sessions.push(session); + } + } + return sessions; +}); + +export const readOpenCodeSessionById = Effect.fn("readOpenCodeSessionById")(function* (input: { + readonly opencodeRoot: string; + readonly sessionId: string; +}): Effect.fn.Return< + Option.Option, + TeleportSchemaVersionError, + FileSystem.FileSystem | Path.Path +> { + const sqlite = yield* readOpenCodeSqliteSessions({ + opencodeRoot: input.opencodeRoot, + cwd: "", + pathsMatch: () => true, + }); + const fromSqlite = sqlite.find((session) => session.externalSessionId === input.sessionId); + if (fromSqlite) { + return Option.some(fromSqlite); + } + const jsonSessions = yield* readOpenCodeJsonSessions(input.opencodeRoot); + const fromJson = jsonSessions.find((session) => session.externalSessionId === input.sessionId); + return fromJson ? Option.some(fromJson) : Option.none(); +}); + +const readOpenCodeSqliteSessions = Effect.fn("readOpenCodeSqliteSessions")(function* (input: { + readonly opencodeRoot: string; + readonly cwd: string; + readonly pathsMatch: (left: string, right: string) => boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dbPath = opencodeDbPath(input.opencodeRoot, path.join); + const exists = yield* fs.exists(dbPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return [] as ParsedNativeSession[]; + } + + return yield* Effect.sync(() => { + try { + const db = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); + try { + const rows = db + .prepare( + ` + SELECT + s.id, + s.directory, + s.title, + s.time_created, + s.time_updated + FROM session s + WHERE s.time_archived IS NULL + ORDER BY s.time_updated DESC + LIMIT 1000 + `, + ) + .all() as ReadonlyArray>; + + const sessions: ParsedNativeSession[] = []; + for (const row of rows) { + const id = nonEmptyString(row.id); + const cwd = nonEmptyString(row.directory); + if (!id || !cwd) { + continue; + } + const messageRows = db + .prepare( + ` + SELECT m.id, m.data, m.time_created, p.data AS part_data + FROM message m + JOIN part p ON p.message_id = m.id + WHERE m.session_id = ? + ORDER BY m.time_created ASC, p.time_created ASC + `, + ) + .all(id) as ReadonlyArray>; + const messages: NativeTextMessage[] = []; + const messageIndexById = new Map(); + for (const messageRow of messageRows) { + const data = + parseJsonObject(typeof messageRow.data === "string" ? messageRow.data : "") ?? + (isRecord(messageRow.data) ? messageRow.data : undefined); + const role = + data?.role === "user" || data?.role === "assistant" ? data.role : undefined; + const text = textFromPartData(messageRow.part_data); + if (!role || !text) { + continue; + } + const id = nonEmptyString(messageRow.id); + const existingIndex = id === undefined ? undefined : messageIndexById.get(id); + if (existingIndex !== undefined) { + const existing = messages[existingIndex]; + if (existing) { + messages[existingIndex] = nativeTextMessage({ + role: existing.role, + text: `${existing.text}\n${text}`, + createdAt: existing.createdAt, + id: existing.id, + }); + } + continue; + } + if (id !== undefined) { + messageIndexById.set(id, messages.length); + } + messages.push( + nativeTextMessage({ + role, + text, + createdAt: dateFromMillis(messageRow.time_created), + id, + }), + ); + } + sessions.push( + parsedNativeSession({ + provider: "opencode", + externalSessionId: id, + cwd, + nativePath: dbPath, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + title: nonEmptyString(row.title) ?? firstUserTitle(messages), + createdAt: dateFromMillis(row.time_created), + updatedAt: dateFromMillis(row.time_updated), + messages, + }), + ); + } + return sessions; + } finally { + db.close(); + } + } catch { + return [] as ParsedNativeSession[]; + } + }); +}); + +const readOpenCodeJsonSessions = Effect.fn("readOpenCodeJsonSessions")(function* ( + opencodeRoot: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sessionRoot = path.join(opencodeRoot, "storage", "session"); + const files = yield* walkJson(sessionRoot); + const sessions: ParsedNativeSession[] = []; + + for (const filePath of files) { + const contents = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + const parsed = parseJsonObject(contents); + if (!parsed) { + continue; + } + const declaredVersion = parsed.nativeFormatVersion; + if (typeof declaredVersion === "number" && declaredVersion > TELEPORT_NATIVE_FORMAT_VERSION) { + return yield* new TeleportSchemaVersionError({ + provider: "opencode", + nativePath: filePath, + foundVersion: declaredVersion, + supportedVersion: TELEPORT_NATIVE_FORMAT_VERSION, + message: `Unsupported OpenCode session format version ${declaredVersion} in ${filePath}.`, + }); + } + const id = nonEmptyString(parsed.id); + const cwd = nonEmptyString(parsed.directory) ?? nonEmptyString(parsed.cwd); + if (!id || !cwd) { + continue; + } + const messages = yield* readOpenCodeJsonMessages(opencodeRoot, id); + const createdAt = + dateFromMillis(isRecord(parsed.time) ? parsed.time.created : parsed.time_created) ?? + nonEmptyString(parsed.createdAt); + const updatedAt = + dateFromMillis(isRecord(parsed.time) ? parsed.time.updated : parsed.time_updated) ?? + nonEmptyString(parsed.updatedAt); + sessions.push( + parsedNativeSession({ + provider: "opencode", + externalSessionId: id, + cwd, + nativePath: filePath, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + title: nonEmptyString(parsed.title) ?? firstUserTitle(messages), + createdAt, + updatedAt, + messages, + }), + ); + } + + return sessions; +}); + +const readOpenCodeJsonMessages = Effect.fn("readOpenCodeJsonMessages")(function* ( + opencodeRoot: string, + sessionId: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const messageRoot = path.join(opencodeRoot, "storage", "message", sessionId); + const files = yield* walkJson(messageRoot); + const messages: NativeTextMessage[] = []; + for (const filePath of files) { + const contents = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + const parsed = parseJsonObject(contents); + if (!parsed) { + continue; + } + const role = parsed.role === "user" || parsed.role === "assistant" ? parsed.role : undefined; + const id = nonEmptyString(parsed.id); + if (!role || !id) { + continue; + } + const text = yield* readOpenCodePartText(opencodeRoot, id); + if (!text) { + continue; + } + const createdAt = dateFromMillis(isRecord(parsed.time) ? parsed.time.created : undefined); + messages.push( + nativeTextMessage({ + role, + text, + createdAt, + id, + }), + ); + } + return messages.toSorted((left, right) => + (left.createdAt ?? "").localeCompare(right.createdAt ?? ""), + ); +}); + +const readOpenCodePartText = Effect.fn("readOpenCodePartText")(function* ( + opencodeRoot: string, + messageId: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const partRoot = path.join(opencodeRoot, "storage", "part", messageId); + const files = yield* walkJson(partRoot); + const texts: string[] = []; + for (const filePath of files) { + const contents = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + const parsed = parseJsonObject(contents); + const text = textFromPartData(parsed ?? contents); + if (text) { + texts.push(text); + } + } + return nonEmptyString(texts.join("\n")); +}); + +export const writeOpenCodeSession = Effect.fn("writeOpenCodeSession")(function* (input: { + readonly opencodeRoot: string; + readonly session: ParsedNativeSession; +}): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sessionDir = path.join(input.opencodeRoot, "storage", "session", "t3"); + const sessionPath = path.join(sessionDir, `${input.session.externalSessionId}.json`); + const created = millisFromIso(input.session.createdAt); + const updated = millisFromIso(input.session.updatedAt ?? input.session.createdAt); + const mapWriteError = (message: string) => (cause: unknown) => + new TeleportNativeWriteError({ + nativePath: sessionPath, + message, + cause, + }); + yield* requireOpenCodeSessionUnlocked({ + nativePath: sessionPath, + opencodeRoot: input.opencodeRoot, + }).pipe(Effect.mapError(mapWriteError(`Native OpenCode session is locked: ${sessionPath}`))); + yield* fs + .makeDirectory(sessionDir, { recursive: true }) + .pipe( + Effect.mapError( + mapWriteError(`Failed to create OpenCode session directory for ${sessionPath}.`), + ), + ); + yield* fs + .writeFileString( + sessionPath, + `${JSON.stringify({ + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + id: input.session.externalSessionId, + directory: input.session.cwd, + cwd: input.session.cwd, + title: + input.session.title ?? + firstUserTitle(input.session.messages) ?? + input.session.externalSessionId, + time: { created, updated }, + })}\n`, + ) + .pipe(Effect.mapError(mapWriteError(`Failed to write OpenCode session ${sessionPath}.`))); + + const messageRoot = path.join( + input.opencodeRoot, + "storage", + "message", + input.session.externalSessionId, + ); + yield* fs + .makeDirectory(messageRoot, { recursive: true }) + .pipe( + Effect.mapError( + mapWriteError(`Failed to create OpenCode message directory for ${sessionPath}.`), + ), + ); + for (const [index, message] of input.session.messages.entries()) { + const messageId = message.id ?? `${input.session.externalSessionId}-m${index}`; + yield* fs + .writeFileString( + path.join(messageRoot, `${messageId}.json`), + `${JSON.stringify({ + id: messageId, + sessionID: input.session.externalSessionId, + role: message.role, + time: { created: millisFromIso(message.createdAt) }, + path: { cwd: input.session.cwd }, + })}\n`, + ) + .pipe(Effect.mapError(mapWriteError(`Failed to write OpenCode message ${messageId}.`))); + const partRoot = path.join(input.opencodeRoot, "storage", "part", messageId); + yield* fs + .makeDirectory(partRoot, { recursive: true }) + .pipe( + Effect.mapError( + mapWriteError(`Failed to create OpenCode part directory for ${messageId}.`), + ), + ); + yield* fs + .writeFileString( + path.join(partRoot, `${messageId}-text.json`), + `${JSON.stringify({ type: "text", text: message.text })}\n`, + ) + .pipe(Effect.mapError(mapWriteError(`Failed to write OpenCode part for ${messageId}.`))); + } + + yield* writeOpenCodeSqliteSession({ + opencodeRoot: input.opencodeRoot, + session: input.session, + created, + updated, + }).pipe(Effect.orElseSucceed(() => undefined)); + + return sessionPath; +}); + +const writeOpenCodeSqliteSession = Effect.fn("writeOpenCodeSqliteSession")(function* (input: { + readonly opencodeRoot: string; + readonly session: ParsedNativeSession; + readonly created: number; + readonly updated: number; +}) { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const dbPath = opencodeDbPath(input.opencodeRoot, path.join); + const exists = yield* fs.exists(dbPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return; + } + yield* Effect.sync(() => { + try { + const db = new NodeSqlite.DatabaseSync(dbPath); + try { + db.exec("BEGIN"); + db.prepare( + ` + INSERT OR REPLACE INTO session (id, directory, title, time_created, time_updated) + VALUES (?, ?, ?, ?, ?) + `, + ).run( + input.session.externalSessionId, + input.session.cwd, + input.session.title ?? + firstUserTitle(input.session.messages) ?? + input.session.externalSessionId, + input.created, + input.updated, + ); + for (const [index, message] of input.session.messages.entries()) { + const messageId = message.id ?? `${input.session.externalSessionId}-m${index}`; + const created = millisFromIso(message.createdAt); + db.prepare( + ` + INSERT OR REPLACE INTO message (id, session_id, time_created, data) + VALUES (?, ?, ?, ?) + `, + ).run( + messageId, + input.session.externalSessionId, + created, + JSON.stringify({ + role: message.role, + time: { created }, + path: { cwd: input.session.cwd }, + }), + ); + db.prepare( + ` + INSERT OR REPLACE INTO part (id, message_id, time_created, data) + VALUES (?, ?, ?, ?) + `, + ).run( + `${messageId}-text`, + messageId, + created, + JSON.stringify({ type: "text", text: message.text }), + ); + } + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } finally { + db.close(); + } + } catch { + // JSON files are the export source of truth; SQLite is best-effort. + } + }); +}); + +export function toOpenCodeCandidate(session: ParsedNativeSession): TeleportSessionCandidate { + return { + provider: "opencode", + providerInstanceId: defaultInstanceIdForDriver(OPENCODE), + externalSessionId: session.externalSessionId, + cwd: session.cwd, + nativePath: session.nativePath, + nativeFormatVersion: session.nativeFormatVersion, + ...teleportCandidateFields(session), + }; +} + +const walkJson = Effect.fn("walkOpenCodeJson")(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exists = yield* fs.exists(root).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return [] as string[]; + } + const files: string[] = []; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + const entries = yield* fs.readDirectory(current).pipe(Effect.orElseSucceed(() => [])); + for (const name of entries) { + const entryPath = path.join(current, name); + const stat = yield* fs.stat(entryPath).pipe(Effect.orElseSucceed(() => null)); + if (stat === null) { + continue; + } + if (stat.type === "Directory") { + stack.push(entryPath); + } else if (stat.type === "File" && entryPath.endsWith(".json")) { + files.push(entryPath); + } + } + } + return files; +}); + +registerTeleportFormat({ + provider: "opencode", + list: Effect.fn("listOpenCodeTeleportSessions")(function* (input) { + const parsedSessions = yield* listOpenCodeSessions({ + opencodeRoot: input.homes.opencodeRoot, + cwd: input.cwd, + }); + return parsedSessions.map(toOpenCodeCandidate); + }), + load: Effect.fn("loadOpenCodeSession")(function* (input) { + const parsed = yield* readOpenCodeSessionById({ + opencodeRoot: input.homes.opencodeRoot, + sessionId: input.externalSessionId, + }); + if (Option.isNone(parsed)) { + return yield* new TeleportDiscoveryError({ + message: `Native OpenCode session '${input.externalSessionId}' could not be read.`, + }); + } + return parsed.value; + }), + write: (input) => + writeOpenCodeSession({ + opencodeRoot: input.homes.opencodeRoot, + session: input.session, + }), + requireUnlocked: (input) => + requireOpenCodeSessionUnlocked({ + nativePath: input.nativePath, + opencodeRoot: input.homes.opencodeRoot, + }), + resumeCursor: (externalSessionId) => ({ schemaVersion: 1, sessionId: externalSessionId }), + readExternalSessionId: (resumeCursor) => + isRecord(resumeCursor) ? nonEmptyString(resumeCursor.sessionId) : undefined, +}); diff --git a/apps/server/src/teleport/formats/register.ts b/apps/server/src/teleport/formats/register.ts index ee84e6d6c83e..16642eab30b2 100644 --- a/apps/server/src/teleport/formats/register.ts +++ b/apps/server/src/teleport/formats/register.ts @@ -2,4 +2,5 @@ import "./claude.ts"; import "./codex.ts"; import "./grok.ts"; +import "./opencode.ts"; export function loadTeleportFormats(): void {} From 2048109c33e8ef0377df23eb92678651a9016416 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:54:14 +0000 Subject: [PATCH 05/51] feat(teleport): add Claude native session support Register the Claude jsonl adapter so T3 can list, import, and export sessions from the Claude projects folder. Co-authored-by: aanishbhirud --- .../src/teleport/formats/claude.test.ts | 169 +++++++++ apps/server/src/teleport/formats/claude.ts | 348 ++++++++++++++++++ apps/server/src/teleport/formats/register.ts | 1 + 3 files changed, 518 insertions(+) create mode 100644 apps/server/src/teleport/formats/claude.test.ts create mode 100644 apps/server/src/teleport/formats/claude.ts diff --git a/apps/server/src/teleport/formats/claude.test.ts b/apps/server/src/teleport/formats/claude.test.ts new file mode 100644 index 000000000000..7d5a328d7b79 --- /dev/null +++ b/apps/server/src/teleport/formats/claude.test.ts @@ -0,0 +1,169 @@ +// Native session fixtures are JSON/JSONL records, not Effect schemas. +// @effect-diagnostics preferSchemaOverJson:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind, TELEPORT_NATIVE_FORMAT_VERSION } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { buildTeleportResumeCursor, readTeleportExternalSessionId } from "../resumeCursors.ts"; +import { + sampleTeleportSession, + TELEPORT_TEST_CREATED_AT, + TELEPORT_TEST_SESSION_ID, +} from "../testFixtures.ts"; +import { + encodeClaudeProjectPath, + listClaudeJsonlFiles, + parseClaudeSessionContents, + serializeClaudeSession, +} from "./claude.ts"; + +describe("teleport Claude format", () => { + it.effect("skips Claude tool_result-only user records", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + type: "user", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { role: "user", content: "KEEP_NAT_CLAUDE_U1_PINE: create receipts/.gitkeep" }, + })}\n${JSON.stringify({ + type: "user", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call-1", content: "ok" }], + }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { + role: "assistant", + content: [{ type: "text", text: "Created the gitkeep file." }], + }, + })}\n`; + const parsed = yield* parseClaudeSessionContents({ + contents, + nativePath: "/tmp/claude-tools.jsonl", + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.messages.length, 2); + assert.equal( + parsed.value.messages[0]?.text, + "KEEP_NAT_CLAUDE_U1_PINE: create receipts/.gitkeep", + ); + assert.equal(parsed.value.messages[1]?.role, "assistant"); + } + }), + ); + + it.effect("roundtrips Claude jsonl", () => + Effect.gen(function* () { + const session = sampleTeleportSession("claudeAgent"); + const parsed = yield* parseClaudeSessionContents({ + contents: serializeClaudeSession(session), + nativePath: session.nativePath, + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.externalSessionId, TELEPORT_TEST_SESSION_ID); + assert.equal(parsed.value.messages.length, 2); + } + }), + ); + + it.effect("fails closed on a newer Claude format version", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + type: "user", + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION + 1, + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + })}\n`; + const result = yield* parseClaudeSessionContents({ + contents, + nativePath: "/tmp/new-claude.jsonl", + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }), + ); + + it("encodes Claude project folders without a trailing separator", () => { + assert.equal(encodeClaudeProjectPath("/workspace/"), encodeClaudeProjectPath("/workspace")); + assert.equal( + encodeClaudeProjectPath("C:\\Users\\Foo\\proj\\"), + encodeClaudeProjectPath("C:\\Users\\Foo\\proj"), + ); + }); + + it.effect("lists Claude sessions from the realpath-encoded folder", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-claude-cwd-" }); + const real = path.join(root, "real-project"); + const link = path.join(root, "link-project"); + const projectsRoot = path.join(root, "projects"); + yield* fs.makeDirectory(real, { recursive: true }); + yield* fs.symlink(real, link); + const lexicalDir = path.join(projectsRoot, encodeClaudeProjectPath(link)); + const realDir = path.join(projectsRoot, encodeClaudeProjectPath(real)); + yield* fs.makeDirectory(lexicalDir, { recursive: true }); + yield* fs.makeDirectory(realDir, { recursive: true }); + const nativePath = path.join(realDir, `${TELEPORT_TEST_SESSION_ID}.jsonl`); + yield* fs.writeFileString( + nativePath, + `${JSON.stringify({ + type: "user", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: real, + timestamp: TELEPORT_TEST_CREATED_AT, + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + })}\n`, + ); + const files = yield* listClaudeJsonlFiles(projectsRoot, link); + assert.equal(files.includes(nativePath), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reads a Claude session id from a Windows native path", () => + Effect.gen(function* () { + const parsed = yield* parseClaudeSessionContents({ + nativePath: "C:\\Users\\Foo\\.claude\\projects\\proj\\abc-session.jsonl", + contents: `${JSON.stringify({ + type: "user", + cwd: "C:\\Users\\Foo\\proj", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + })}\n`, + }); + assert.equal(parsed._tag, "Some"); + if (parsed._tag === "Some") { + assert.equal(parsed.value.externalSessionId, "abc-session"); + } + }), + ); + + it("roundtrips Claude resume cursors", () => { + assert.equal( + readTeleportExternalSessionId({ + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: buildTeleportResumeCursor({ + provider: "claudeAgent", + externalSessionId: TELEPORT_TEST_SESSION_ID, + }), + runtimePayload: null, + }), + TELEPORT_TEST_SESSION_ID, + ); + }); +}); diff --git a/apps/server/src/teleport/formats/claude.ts b/apps/server/src/teleport/formats/claude.ts new file mode 100644 index 000000000000..15a0f16bff5e --- /dev/null +++ b/apps/server/src/teleport/formats/claude.ts @@ -0,0 +1,348 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off preferSchemaOverJson:off +import * as NodeCrypto from "node:crypto"; + +import { + TELEPORT_NATIVE_FORMAT_VERSION, + TeleportDiscoveryError, + TeleportNativeWriteError, + TeleportSchemaVersionError, + defaultInstanceIdForDriver, + ProviderDriverKind, + type TeleportSessionCandidate, +} from "@t3tools/contracts"; +import { normalizeProjectPathForDispatch } from "@t3tools/shared/path"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { resolveTeleportCwdPath, teleportCwdsEquivalent } from "../cwd.ts"; +import { requireNativePathUnlocked } from "../fileLock.ts"; +import { writeNativeSessionAtomically } from "../nativeWrite.ts"; +import { readNativeSessionFile } from "../sessionFile.ts"; +import { registerTeleportFormat } from "./registry.ts"; +import { + collectTextParts, + firstUserTitle, + isRecord, + nonEmptyString, + parseJsonObject, +} from "../json.ts"; +import { + nativeTextMessage, + parsedNativeSession, + teleportCandidateFields, + type NativeTextMessage, + type ParsedNativeSession, +} from "../types.ts"; + +const CLAUDE = ProviderDriverKind.make("claudeAgent"); + +function isToolResultOnlyContent(content: unknown): boolean { + if (!Array.isArray(content) || content.length === 0) { + return false; + } + return content.every( + (part) => isRecord(part) && (part.type === "tool_result" || part.type === "tool_use"), + ); +} + +export function encodeClaudeProjectPath(cwd: string): string { + const encoded = normalizeProjectPathForDispatch(cwd).replace(/[^a-zA-Z0-9]/gu, "-"); + if (encoded.length <= 200) { + return encoded; + } + const digest = NodeCrypto.createHash("sha256").update(cwd).digest("hex").slice(0, 16); + return `${encoded.slice(0, 200)}${digest}`; +} + +function extractClaudeMessage(record: Record): NativeTextMessage | undefined { + if (record.isSidechain === true) { + return undefined; + } + const type = record.type; + if (type !== "user" && type !== "assistant") { + return undefined; + } + const message = isRecord(record.message) ? record.message : undefined; + const role = + message?.role === "user" || message?.role === "assistant" + ? message.role + : type === "user" || type === "assistant" + ? type + : undefined; + if (!role) { + return undefined; + } + const text = collectTextParts(message?.content) ?? collectTextParts(record.content); + if (!text) { + return undefined; + } + if (role === "user" && isToolResultOnlyContent(message?.content ?? record.content)) { + return undefined; + } + return nativeTextMessage({ + role, + text, + createdAt: nonEmptyString(record.timestamp), + id: nonEmptyString(record.uuid), + }); +} + +export function parseClaudeSessionContents(input: { + readonly contents: string; + readonly nativePath: string; +}): Effect.Effect, TeleportSchemaVersionError> { + const lines = input.contents.split(/\r?\n/u).filter((line) => line.trim().length > 0); + if (lines.length === 0) { + return Effect.succeed(Option.none()); + } + + let sessionId: string | undefined; + let cwd: string | undefined; + let createdAt: string | undefined; + let nativeFormatVersion: number = TELEPORT_NATIVE_FORMAT_VERSION; + const messages: NativeTextMessage[] = []; + + for (const line of lines) { + const record = parseJsonObject(line); + if (!record) { + continue; + } + const declaredVersion = record.nativeFormatVersion; + if (typeof declaredVersion === "number" && Number.isInteger(declaredVersion)) { + nativeFormatVersion = declaredVersion; + } + if (nativeFormatVersion > TELEPORT_NATIVE_FORMAT_VERSION) { + return Effect.fail( + new TeleportSchemaVersionError({ + provider: "claudeAgent", + nativePath: input.nativePath, + foundVersion: nativeFormatVersion, + supportedVersion: TELEPORT_NATIVE_FORMAT_VERSION, + message: `Unsupported Claude session format version ${nativeFormatVersion} in ${input.nativePath}.`, + }), + ); + } + sessionId = nonEmptyString(record.sessionId) ?? sessionId; + cwd = nonEmptyString(record.cwd) ?? cwd; + createdAt = createdAt ?? nonEmptyString(record.timestamp); + const message = extractClaudeMessage(record); + if (message) { + messages.push(message); + } + } + + const externalSessionId = sessionId ?? fileStem(input.nativePath); + if (!externalSessionId || !cwd) { + return Effect.succeed(Option.none()); + } + + const updatedAt = messages.at(-1)?.createdAt ?? createdAt; + return Effect.succeed( + Option.some( + parsedNativeSession({ + provider: "claudeAgent", + externalSessionId, + cwd, + nativePath: input.nativePath, + nativeFormatVersion, + title: firstUserTitle(messages), + createdAt, + updatedAt, + messages, + }), + ), + ); +} + +export function serializeClaudeSession(session: ParsedNativeSession): string { + const lines: string[] = []; + let parentUuid: string | null = null; + for (const [index, message] of session.messages.entries()) { + const uuid = message.id ?? `${session.externalSessionId}-${index}`; + const timestamp = message.createdAt ?? session.createdAt ?? new Date().toISOString(); + lines.push( + JSON.stringify({ + type: message.role, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + uuid, + parentUuid, + sessionId: session.externalSessionId, + cwd: session.cwd, + timestamp, + message: { + role: message.role, + content: [{ type: "text", text: message.text }], + }, + }), + ); + parentUuid = uuid; + } + return `${lines.join("\n")}\n`; +} + +export function allocateClaudeSessionPath(input: { + readonly projectsRoot: string; + readonly cwd: string; + readonly sessionId: string; + readonly join: (left: string, ...rest: string[]) => string; +}): string { + return input.join( + input.projectsRoot, + encodeClaudeProjectPath(input.cwd), + `${input.sessionId}.jsonl`, + ); +} + +export const listClaudeJsonlFiles = Effect.fn("listClaudeJsonlFiles")(function* ( + projectsRoot: string, + cwd: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = yield* resolveTeleportCwdPath(cwd); + const encodedNames = [ + ...new Set([encodeClaudeProjectPath(cwd), encodeClaudeProjectPath(resolved)]), + ]; + const roots: string[] = []; + for (const encoded of encodedNames) { + const encodedDir = path.join(projectsRoot, encoded); + if (yield* fs.exists(encodedDir).pipe(Effect.orElseSucceed(() => false))) { + roots.push(encodedDir); + } + } + if (roots.length === 0) { + roots.push(projectsRoot); + } + const files: string[] = []; + for (const root of roots) { + files.push(...(yield* walkJsonl(root))); + } + return files; +}); + +export function toClaudeCandidate(session: ParsedNativeSession): TeleportSessionCandidate { + return { + provider: "claudeAgent", + providerInstanceId: defaultInstanceIdForDriver(CLAUDE), + externalSessionId: session.externalSessionId, + cwd: session.cwd, + nativePath: session.nativePath, + nativeFormatVersion: session.nativeFormatVersion, + ...teleportCandidateFields(session), + }; +} + +function fileStem(filePath: string): string | undefined { + const base = filePath.replaceAll("\\", "/").split("/").at(-1) ?? filePath; + return nonEmptyString(base.replace(/\.jsonl$/u, "")); +} + +const walkJsonl = Effect.fn("walkClaudeJsonl")(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exists = yield* fs.exists(root).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return [] as string[]; + } + const files: string[] = []; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + const entries = yield* fs.readDirectory(current).pipe(Effect.orElseSucceed(() => [])); + for (const name of entries) { + const entryPath = path.join(current, name); + const stat = yield* fs.stat(entryPath).pipe(Effect.orElseSucceed(() => null)); + if (stat === null) { + continue; + } + if (stat.type === "Directory") { + stack.push(entryPath); + } else if (stat.type === "File" && entryPath.endsWith(".jsonl")) { + files.push(entryPath); + } + } + } + return files; +}); + +registerTeleportFormat({ + provider: "claudeAgent", + list: Effect.fn("listClaudeSessions")(function* (input) { + const files = yield* listClaudeJsonlFiles(input.homes.claudeProjectsRoot, input.cwd); + const sessions = []; + for (const nativePath of files) { + const parsed = yield* readNativeSessionFile({ + nativePath, + parse: parseClaudeSessionContents, + }); + if (Option.isNone(parsed)) { + continue; + } + if (!(yield* teleportCwdsEquivalent(parsed.value.cwd, input.cwd))) { + continue; + } + sessions.push(toClaudeCandidate(parsed.value)); + } + return sessions; + }), + load: Effect.fn("loadClaudeSession")(function* (input) { + const parsed = yield* readNativeSessionFile({ + nativePath: input.nativePath, + parse: parseClaudeSessionContents, + }); + if (Option.isNone(parsed)) { + return yield* new TeleportDiscoveryError({ + message: `Native Claude session '${input.externalSessionId}' could not be parsed.`, + }); + } + return parsed.value; + }), + write: Effect.fn("writeClaudeSession")(function* (input) { + const path = yield* Path.Path; + const nativePath = + input.existingNativePath ?? + allocateClaudeSessionPath({ + projectsRoot: input.homes.claudeProjectsRoot, + cwd: input.session.cwd, + sessionId: input.session.externalSessionId, + join: path.join, + }); + const contents = serializeClaudeSession({ ...input.session, nativePath }); + yield* writeNativeSessionAtomically({ + filePath: nativePath, + contents, + verify: (written) => + parseClaudeSessionContents({ contents: written, nativePath }).pipe( + Effect.flatMap((parsed) => + Option.isSome(parsed) + ? Effect.void + : new TeleportNativeWriteError({ + nativePath, + message: `Exported Claude session failed verification: ${nativePath}`, + }), + ), + Effect.mapError((error) => + error._tag === "TeleportSchemaVersionError" + ? new TeleportNativeWriteError({ + nativePath, + message: error.message, + cause: error, + }) + : error, + ), + ), + }); + return nativePath; + }), + requireUnlocked: (input) => requireNativePathUnlocked(input.nativePath), + resumeCursor: (externalSessionId) => ({ resume: externalSessionId }), + readExternalSessionId: (resumeCursor) => + isRecord(resumeCursor) + ? (nonEmptyString(resumeCursor.resume) ?? nonEmptyString(resumeCursor.threadId)) + : undefined, +}); diff --git a/apps/server/src/teleport/formats/register.ts b/apps/server/src/teleport/formats/register.ts index 83b4d55384f8..55c3a6b78973 100644 --- a/apps/server/src/teleport/formats/register.ts +++ b/apps/server/src/teleport/formats/register.ts @@ -1,3 +1,4 @@ // Native format adapters register themselves by importing their modules here. +import "./claude.ts"; import "./codex.ts"; export function loadTeleportFormats(): void {} From 38eac2f34af721668516d2c3839891e46148ff41 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:54:14 +0000 Subject: [PATCH 06/51] feat(teleport): add Grok native session support Register the Grok session-directory adapter so T3 can list, import, and export native Grok Build chats. Co-authored-by: aanishbhirud --- apps/server/src/teleport/formats/grok.test.ts | 233 +++++++ apps/server/src/teleport/formats/grok.ts | 583 ++++++++++++++++++ apps/server/src/teleport/formats/register.ts | 1 + 3 files changed, 817 insertions(+) create mode 100644 apps/server/src/teleport/formats/grok.test.ts create mode 100644 apps/server/src/teleport/formats/grok.ts diff --git a/apps/server/src/teleport/formats/grok.test.ts b/apps/server/src/teleport/formats/grok.test.ts new file mode 100644 index 000000000000..298fe722af98 --- /dev/null +++ b/apps/server/src/teleport/formats/grok.test.ts @@ -0,0 +1,233 @@ +// Native session fixtures are JSON/JSONL records, not Effect schemas. +// @effect-diagnostics preferSchemaOverJson:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind, TELEPORT_NATIVE_FORMAT_VERSION } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { discoverTeleportSessions } from "../discovery.ts"; +import type { TeleportHomes } from "../homes.ts"; +import { buildTeleportResumeCursor, readTeleportExternalSessionId } from "../resumeCursors.ts"; +import { sampleTeleportSession, TELEPORT_TEST_SESSION_ID } from "../testFixtures.ts"; +import { + encodeGrokCwdGroup, + parseGrokSessionDirectory, + requireGrokSessionUnlocked, + writeGrokSession, +} from "./grok.ts"; + +describe("teleport Grok format", () => { + it.effect("roundtrips Grok session directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-grok-" }); + const session = sampleTeleportSession("grok", "/workspace"); + const nativePath = yield* writeGrokSession({ + sessionsRoot: root, + session, + }); + const summary = yield* fs.readFileString(path.join(nativePath, "summary.json")); + const updates = yield* fs.readFileString(path.join(nativePath, "updates.jsonl")); + const parsed = yield* parseGrokSessionDirectory({ + summaryContents: summary, + updatesContents: updates, + nativePath, + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.externalSessionId, TELEPORT_TEST_SESSION_ID); + assert.equal(parsed.value.cwd, "/workspace"); + assert.equal(parsed.value.messages.length, 2); + assert.equal(parsed.value.messages[0]?.text, "Fix the flaky matcher"); + assert.equal( + parsed.value.messages[1]?.text, + "I'll tighten the path comparison and add a realpath fallback.", + ); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("concatenates consecutive Grok message chunks", () => + Effect.gen(function* () { + const parsed = yield* parseGrokSessionDirectory({ + nativePath: "/tmp/grok-session", + summaryContents: JSON.stringify({ + info: { id: TELEPORT_TEST_SESSION_ID, cwd: "/workspace" }, + chat_format_version: 1, + }), + updatesContents: `${JSON.stringify({ + timestamp: 1_782_000_000, + method: "session/update", + params: { + sessionId: TELEPORT_TEST_SESSION_ID, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Hello " }, + }, + }, + })}\n${JSON.stringify({ + timestamp: 1_782_000_001, + method: "session/update", + params: { + sessionId: TELEPORT_TEST_SESSION_ID, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "world" }, + }, + }, + })}\n${JSON.stringify({ + timestamp: 1_782_000_002, + method: "session/update", + params: { + sessionId: TELEPORT_TEST_SESSION_ID, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hi" }, + }, + }, + })}\n`, + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.messages.length, 2); + assert.equal(parsed.value.messages[0]?.text, "Hello world"); + assert.equal(parsed.value.messages[1]?.text, "Hi"); + } + }), + ); + + it.effect("fails closed on a newer Grok format version", () => + Effect.gen(function* () { + const result = yield* parseGrokSessionDirectory({ + summaryContents: JSON.stringify({ + info: { id: TELEPORT_TEST_SESSION_ID, cwd: "/workspace" }, + chat_format_version: 2, + }), + updatesContents: "", + nativePath: "/tmp/new-grok", + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "TeleportSchemaVersionError"); + } + }), + ); + + it.effect("parses an empty Grok transcript as zero messages", () => + Effect.gen(function* () { + const parsed = yield* parseGrokSessionDirectory({ + summaryContents: JSON.stringify({ + info: { id: TELEPORT_TEST_SESSION_ID, cwd: "/workspace" }, + chat_format_version: 1, + }), + updatesContents: "", + nativePath: "/tmp/empty-grok", + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.messages.length, 0); + } + }), + ); + + it.effect("treats unlocked Grok session files as writable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-grok-unlocked-" }); + const nativePath = yield* writeGrokSession({ + sessionsRoot: root, + session: sampleTeleportSession("grok"), + }); + yield* requireGrokSessionUnlocked(nativePath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it("encodes Grok cwd groups from the persisted spelling, not the comparison form", () => { + assert.equal(encodeGrokCwdGroup("/workspace/"), encodeURIComponent("/workspace")); + assert.equal( + encodeGrokCwdGroup("C:\\Users\\Foo\\proj"), + encodeURIComponent("C:\\Users\\Foo\\proj"), + ); + assert.notEqual( + encodeGrokCwdGroup("C:\\Users\\Foo\\proj"), + encodeURIComponent("c:\\users\\foo\\proj"), + ); + }); + + it("roundtrips Grok resume cursors", () => { + assert.equal( + readTeleportExternalSessionId({ + provider: ProviderDriverKind.make("grok"), + resumeCursor: buildTeleportResumeCursor({ + provider: "grok", + externalSessionId: TELEPORT_TEST_SESSION_ID, + }), + runtimePayload: null, + }), + TELEPORT_TEST_SESSION_ID, + ); + }); + + it.effect("lists matching Grok sessions and skips other project cwds", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-grok-homes-" }); + const homes: TeleportHomes = { + codexSessionsRoot: path.join(root, "codex", "sessions"), + claudeProjectsRoot: path.join(root, "claude", "projects"), + opencodeRoot: path.join(root, "opencode"), + grokSessionsRoot: path.join(root, "grok", "sessions"), + }; + yield* writeGrokSession({ + sessionsRoot: homes.grokSessionsRoot, + session: sampleTeleportSession("grok"), + }); + yield* writeGrokSession({ + sessionsRoot: homes.grokSessionsRoot, + session: sampleTeleportSession("grok", "/other-project"), + }); + const listed = yield* discoverTeleportSessions({ + homes, + cwd: "/workspace", + providers: ["grok"], + }); + assert.equal(listed.sessions.length, 1); + assert.equal(listed.sessions[0]?.provider, "grok"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("fails the whole list when a Grok session is a newer schema", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-grok-newer-" }); + const homes: TeleportHomes = { + codexSessionsRoot: path.join(root, "codex", "sessions"), + claudeProjectsRoot: path.join(root, "claude", "projects"), + opencodeRoot: path.join(root, "opencode"), + grokSessionsRoot: path.join(root, "grok", "sessions"), + }; + const grokPath = path.join(homes.grokSessionsRoot, "%2Fworkspace", TELEPORT_TEST_SESSION_ID); + yield* fs.makeDirectory(grokPath, { recursive: true }); + yield* fs.writeFileString( + path.join(grokPath, "summary.json"), + JSON.stringify({ + info: { id: TELEPORT_TEST_SESSION_ID, cwd: "/workspace" }, + chat_format_version: TELEPORT_NATIVE_FORMAT_VERSION + 1, + }), + ); + yield* fs.writeFileString(path.join(grokPath, "updates.jsonl"), ""); + const result = yield* discoverTeleportSessions({ + homes, + cwd: "/workspace", + providers: ["grok"], + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/teleport/formats/grok.ts b/apps/server/src/teleport/formats/grok.ts new file mode 100644 index 000000000000..25b84f57372d --- /dev/null +++ b/apps/server/src/teleport/formats/grok.ts @@ -0,0 +1,583 @@ +// Native Grok Build sessions are directories of JSON/JSONL ACP records. +// @effect-diagnostics globalDate:off preferSchemaOverJson:off +import { + TELEPORT_NATIVE_FORMAT_VERSION, + TeleportDiscoveryError, + TeleportNativeWriteError, + TeleportSchemaVersionError, + defaultInstanceIdForDriver, + ProviderDriverKind, + type TeleportSessionCandidate, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { normalizeProjectPathForDispatch } from "@t3tools/shared/path"; + +import { teleportCwdsEquivalent } from "../cwd.ts"; +import { requireNativePathUnlocked } from "../fileLock.ts"; +import { registerTeleportFormat } from "./registry.ts"; +import { replaceNativeFile } from "../nativeWrite.ts"; +import { firstUserTitle, isRecord, nonEmptyString, parseJsonObject } from "../json.ts"; +import { + MAX_TELEPORT_SESSION_BYTES, + nativeTextMessage, + parsedNativeSession, + teleportCandidateFields, + type NativeTextMessage, + type ParsedNativeSession, +} from "../types.ts"; + +const GROK = ProviderDriverKind.make("grok"); +const GROK_CHAT_FORMAT_VERSION = 1; +const SUMMARY_FILE = "summary.json"; +const UPDATES_FILE = "updates.jsonl"; +const CHAT_HISTORY_FILE = "chat_history.jsonl"; + +export const requireGrokSessionUnlocked = Effect.fn("requireGrokSessionUnlocked")(function* ( + nativePath: string, +) { + const path = yield* Path.Path; + yield* requireNativePathUnlocked(nativePath); + yield* requireNativePathUnlocked(path.join(nativePath, SUMMARY_FILE)); + yield* requireNativePathUnlocked(path.join(nativePath, UPDATES_FILE)); +}); + +export function encodeGrokCwdGroup(cwd: string): string { + // Encode the cwd spelling we persist, not the comparison form. Comparison + // lowercases Windows paths and forces backslashes, which would write a + // different folder than the native Grok CLI looks up. + return encodeURIComponent(normalizeProjectPathForDispatch(cwd)); +} + +export function allocateGrokSessionPath(input: { + readonly sessionsRoot: string; + readonly cwd: string; + readonly sessionId: string; + readonly join: (left: string, ...rest: string[]) => string; +}): string { + return input.join(input.sessionsRoot, encodeGrokCwdGroup(input.cwd), input.sessionId); +} + +export const listGrokSessionDirs = Effect.fn("listGrokSessionDirs")(function* ( + sessionsRoot: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exists = yield* fs.exists(sessionsRoot).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return [] as string[]; + } + const dirs: string[] = []; + const groups = yield* fs.readDirectory(sessionsRoot).pipe(Effect.orElseSucceed(() => [])); + for (const groupName of groups) { + const groupPath = path.join(sessionsRoot, groupName); + const groupStat = yield* fs.stat(groupPath).pipe(Effect.orElseSucceed(() => null)); + if (groupStat?.type !== "Directory") { + continue; + } + if (yield* isGrokSessionDirectory(groupPath)) { + dirs.push(groupPath); + continue; + } + const children = yield* fs.readDirectory(groupPath).pipe(Effect.orElseSucceed(() => [])); + for (const childName of children) { + const childPath = path.join(groupPath, childName); + if (yield* isGrokSessionDirectory(childPath)) { + dirs.push(childPath); + } + } + } + return dirs; +}); + +export const readGrokSessionFromDir = Effect.fn("readGrokSessionFromDir")(function* ( + nativePath: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const summaryPath = path.join(nativePath, SUMMARY_FILE); + const updatesPath = path.join(nativePath, UPDATES_FILE); + const summaryStat = yield* fs.stat(summaryPath).pipe(Effect.orElseSucceed(() => null)); + const updatesStat = yield* fs.stat(updatesPath).pipe(Effect.orElseSucceed(() => null)); + if (summaryStat?.type !== "File") { + return Option.none(); + } + if (typeof summaryStat.size === "number" && summaryStat.size > MAX_TELEPORT_SESSION_BYTES) { + return Option.none(); + } + if ( + updatesStat !== null && + typeof updatesStat.size === "number" && + updatesStat.size > MAX_TELEPORT_SESSION_BYTES + ) { + return Option.none(); + } + const summaryContents = yield* fs + .readFileString(summaryPath) + .pipe(Effect.orElseSucceed(() => "")); + const updatesContents = yield* fs + .readFileString(updatesPath) + .pipe(Effect.orElseSucceed(() => "")); + return yield* parseGrokSessionDirectory({ + summaryContents, + updatesContents, + nativePath, + }); +}); + +export const writeGrokSession = Effect.fn("writeGrokSession")(function* (input: { + readonly sessionsRoot: string; + readonly session: ParsedNativeSession; + readonly existingNativePath?: string; +}): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nativePath = yield* resolveGrokWritePath({ + sessionsRoot: input.sessionsRoot, + session: input.session, + existingNativePath: input.existingNativePath, + }); + const mapWriteError = (message: string) => (cause: unknown) => + new TeleportNativeWriteError({ + nativePath, + message, + cause, + }); + + const summaryContents = serializeGrokSummary(input.session); + const updatesContents = serializeGrokUpdates(input.session); + const chatHistoryContents = serializeGrokChatHistory(input.session); + + yield* requireGrokSessionUnlocked(nativePath).pipe( + Effect.mapError(mapWriteError(`Native Grok session is locked: ${nativePath}`)), + ); + + yield* fs + .makeDirectory(path.dirname(nativePath), { recursive: true }) + .pipe( + Effect.mapError(mapWriteError(`Failed to create Grok session parent for ${nativePath}.`)), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const tempDirectory = yield* fs + .makeTempDirectoryScoped({ + directory: path.dirname(nativePath), + prefix: `${path.basename(nativePath)}.`, + }) + .pipe( + Effect.mapError(mapWriteError(`Failed to create a temp directory for ${nativePath}.`)), + ); + yield* fs + .writeFileString(path.join(tempDirectory, SUMMARY_FILE), summaryContents) + .pipe( + Effect.mapError(mapWriteError(`Failed to write temp Grok summary for ${nativePath}.`)), + ); + yield* fs + .writeFileString(path.join(tempDirectory, UPDATES_FILE), updatesContents) + .pipe( + Effect.mapError(mapWriteError(`Failed to write temp Grok updates for ${nativePath}.`)), + ); + yield* fs + .writeFileString(path.join(tempDirectory, CHAT_HISTORY_FILE), chatHistoryContents) + .pipe( + Effect.mapError( + mapWriteError(`Failed to write temp Grok chat history for ${nativePath}.`), + ), + ); + const verified = yield* readGrokSessionFromDir(tempDirectory).pipe( + Effect.mapError((error) => + error._tag === "TeleportSchemaVersionError" + ? new TeleportNativeWriteError({ + nativePath, + message: error.message, + cause: error, + }) + : new TeleportNativeWriteError({ + nativePath, + message: `Exported Grok session failed verification: ${nativePath}`, + cause: error, + }), + ), + ); + if (Option.isNone(verified)) { + return yield* new TeleportNativeWriteError({ + nativePath, + message: `Exported Grok session failed verification: ${nativePath}`, + }); + } + yield* requireGrokSessionUnlocked(nativePath).pipe( + Effect.mapError(mapWriteError(`Native Grok session is locked: ${nativePath}`)), + ); + const destStat = yield* fs.stat(nativePath).pipe(Effect.orElseSucceed(() => null)); + if (destStat?.type === "File") { + yield* fs + .remove(nativePath, { force: true }) + .pipe(Effect.mapError(mapWriteError(`Failed to replace ${nativePath}.`))); + } + yield* fs + .makeDirectory(nativePath, { recursive: true }) + .pipe( + Effect.mapError(mapWriteError(`Failed to create Grok session directory ${nativePath}.`)), + ); + for (const name of [SUMMARY_FILE, UPDATES_FILE, CHAT_HISTORY_FILE] as const) { + yield* replaceNativeFile({ + from: path.join(tempDirectory, name), + to: path.join(nativePath, name), + }).pipe( + Effect.mapError(mapWriteError(`Failed to replace ${path.join(nativePath, name)}.`)), + ); + } + }), + ); + + return nativePath; +}); + +export function parseGrokSessionDirectory(input: { + readonly summaryContents: string; + readonly updatesContents: string; + readonly nativePath: string; +}): Effect.Effect, TeleportSchemaVersionError> { + const summary = parseJsonObject(input.summaryContents); + if (!summary) { + return Effect.succeed(Option.none()); + } + + const chatFormatVersion = + typeof summary.chat_format_version === "number" && Number.isInteger(summary.chat_format_version) + ? summary.chat_format_version + : GROK_CHAT_FORMAT_VERSION; + const nativeFormatVersion = + typeof summary.nativeFormatVersion === "number" && Number.isInteger(summary.nativeFormatVersion) + ? summary.nativeFormatVersion + : TELEPORT_NATIVE_FORMAT_VERSION; + if ( + chatFormatVersion > GROK_CHAT_FORMAT_VERSION || + nativeFormatVersion > TELEPORT_NATIVE_FORMAT_VERSION + ) { + const foundVersion = Math.max(chatFormatVersion, nativeFormatVersion); + return Effect.fail( + new TeleportSchemaVersionError({ + provider: "grok", + nativePath: input.nativePath, + foundVersion, + supportedVersion: TELEPORT_NATIVE_FORMAT_VERSION, + message: `Unsupported Grok session format version ${foundVersion} in ${input.nativePath}.`, + }), + ); + } + + if (isSkippedGrokSession(summary)) { + return Effect.succeed(Option.none()); + } + + const info = isRecord(summary.info) ? summary.info : undefined; + const externalSessionId = + nonEmptyString(info?.id) ?? nonEmptyString(basenamePath(input.nativePath)); + const cwd = nonEmptyString(info?.cwd) ?? nonEmptyString(summary.cwd); + if (!externalSessionId || !cwd) { + return Effect.succeed(Option.none()); + } + + const messages = parseGrokUpdates(input.updatesContents); + const createdAt = + nonEmptyString(summary.created_at) ?? messages[0]?.createdAt ?? isoFromUnixSeconds(undefined); + const updatedAt = + nonEmptyString(summary.updated_at) ?? + nonEmptyString(summary.last_active_at) ?? + messages.at(-1)?.createdAt ?? + createdAt; + + return Effect.succeed( + Option.some( + parsedNativeSession({ + provider: "grok", + externalSessionId, + cwd, + nativePath: input.nativePath, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + title: + nonEmptyString(summary.generated_title) ?? + nonEmptyString(summary.session_summary) ?? + firstUserTitle(messages), + createdAt, + updatedAt, + messages, + }), + ), + ); +} + +export function toGrokCandidate(session: ParsedNativeSession): TeleportSessionCandidate { + return { + provider: "grok", + providerInstanceId: defaultInstanceIdForDriver(GROK), + externalSessionId: session.externalSessionId, + cwd: session.cwd, + nativePath: session.nativePath, + nativeFormatVersion: session.nativeFormatVersion, + ...teleportCandidateFields(session), + }; +} + +export function serializeGrokSummary(session: ParsedNativeSession): string { + const createdAt = session.createdAt ?? new Date().toISOString(); + const updatedAt = session.updatedAt ?? createdAt; + const title = session.title ?? firstUserTitle(session.messages) ?? session.externalSessionId; + return `${JSON.stringify( + { + info: { + id: session.externalSessionId, + cwd: session.cwd, + }, + session_summary: title, + generated_title: title, + created_at: createdAt, + updated_at: updatedAt, + num_messages: session.messages.length, + num_chat_messages: session.messages.length, + current_model_id: "deepseek", + chat_format_version: GROK_CHAT_FORMAT_VERSION, + }, + null, + 2, + )}\n`; +} + +export function serializeGrokUpdates(session: ParsedNativeSession): string { + const lines: string[] = []; + let promptIndex = 0; + for (const message of session.messages) { + const timestamp = unixSeconds(message.createdAt ?? session.createdAt); + const sessionUpdate = message.role === "user" ? "user_message_chunk" : "agent_message_chunk"; + const meta = + message.role === "user" + ? { promptIndex, modelId: "deepseek" } + : { promptIndex, modelId: "deepseek", updateType: "AgentMessageChunk" }; + if (message.role === "user") { + promptIndex += 1; + } + lines.push( + JSON.stringify({ + timestamp, + method: "session/update", + params: { + sessionId: session.externalSessionId, + update: { + sessionUpdate, + content: { type: "text", text: message.text }, + _meta: meta, + }, + }, + }), + ); + } + return lines.length > 0 ? `${lines.join("\n")}\n` : ""; +} + +export function serializeGrokChatHistory(session: ParsedNativeSession): string { + const lines: string[] = []; + let promptIndex = 0; + for (const message of session.messages) { + if (message.role === "user") { + lines.push( + JSON.stringify({ + type: "user", + content: [{ type: "text", text: `\n${message.text}\n` }], + prompt_index: promptIndex, + }), + ); + promptIndex += 1; + continue; + } + lines.push( + JSON.stringify({ + type: "assistant", + content: message.text, + model_id: "deepseek", + }), + ); + } + return lines.length > 0 ? `${lines.join("\n")}\n` : ""; +} + +function parseGrokUpdates(contents: string): NativeTextMessage[] { + const messages: NativeTextMessage[] = []; + let current: { role: "user" | "assistant"; parts: string[]; createdAt?: string } | undefined; + const flush = (): void => { + if (!current) { + return; + } + const text = current.parts.join(""); + if (text.length > 0) { + messages.push( + nativeTextMessage({ + role: current.role, + text, + createdAt: current.createdAt, + id: undefined, + }), + ); + } + current = undefined; + }; + + for (const rawLine of contents.split("\n")) { + const line = rawLine.trim(); + if (line.length === 0) { + continue; + } + const parsed = parseJsonObject(line); + if (!parsed) { + continue; + } + const params = isRecord(parsed.params) ? parsed.params : undefined; + const update = isRecord(params?.update) ? params.update : undefined; + if (!update) { + continue; + } + const sessionUpdate = nonEmptyString(update.sessionUpdate); + const role = + sessionUpdate === "user_message_chunk" + ? "user" + : sessionUpdate === "agent_message_chunk" + ? "assistant" + : undefined; + if (!role) { + continue; + } + const text = textFromGrokContent(update.content); + if (!text) { + continue; + } + const createdAt = isoFromUnixSeconds(parsed.timestamp); + if (current && current.role === role) { + current.parts.push(text); + continue; + } + flush(); + current = { role, parts: [text], ...definedCreatedAt(createdAt) }; + } + flush(); + return messages; +} + +function textFromGrokContent(content: unknown): string | undefined { + if (typeof content === "string") { + return content.length > 0 ? content : undefined; + } + if (!isRecord(content) || typeof content.text !== "string") { + return undefined; + } + return content.text.length > 0 ? content.text : undefined; +} + +function isSkippedGrokSession(summary: Record): boolean { + if (nonEmptyString(summary.parent_session_id)) { + return true; + } + const kind = nonEmptyString(summary.session_kind); + return kind === "fork" || kind === "subagent" || kind === "subagent_fork"; +} + +const isGrokSessionDirectory = Effect.fn("isGrokSessionDirectory")(function* (nativePath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stat = yield* fs.stat(nativePath).pipe(Effect.orElseSucceed(() => null)); + if (stat?.type !== "Directory") { + return false; + } + const summaryPath = path.join(nativePath, SUMMARY_FILE); + const summaryStat = yield* fs.stat(summaryPath).pipe(Effect.orElseSucceed(() => null)); + return summaryStat?.type === "File"; +}); + +const resolveGrokWritePath = Effect.fn("resolveGrokWritePath")(function* (input: { + readonly sessionsRoot: string; + readonly session: ParsedNativeSession; + readonly existingNativePath: string | undefined; +}) { + const path = yield* Path.Path; + const allocated = allocateGrokSessionPath({ + sessionsRoot: input.sessionsRoot, + cwd: input.session.cwd, + sessionId: input.session.externalSessionId, + join: path.join, + }); + if (!input.existingNativePath) { + return allocated; + } + if (yield* isGrokSessionDirectory(input.existingNativePath)) { + return input.existingNativePath; + } + return allocated; +}); + +function unixSeconds(iso: string | undefined): number { + if (!iso) { + return Math.floor(Date.now() / 1000); + } + const parsed = Date.parse(iso); + return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : Math.floor(Date.now() / 1000); +} + +function isoFromUnixSeconds(value: unknown): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + const millis = value > 1_000_000_000_000 ? value : value * 1000; + return new Date(millis).toISOString(); +} + +function definedCreatedAt(createdAt: string | undefined): { readonly createdAt?: string } { + return createdAt ? { createdAt } : {}; +} + +function basenamePath(filePath: string): string { + const parts = filePath.split(/[/\\]/u).filter((part) => part.length > 0); + return parts.at(-1) ?? filePath; +} + +registerTeleportFormat({ + provider: "grok", + list: Effect.fn("listGrokSessions")(function* (input) { + const dirs = yield* listGrokSessionDirs(input.homes.grokSessionsRoot); + const sessions = []; + for (const nativePath of dirs) { + const parsed = yield* readGrokSessionFromDir(nativePath); + if (Option.isNone(parsed)) { + continue; + } + if (!(yield* teleportCwdsEquivalent(parsed.value.cwd, input.cwd))) { + continue; + } + sessions.push(toGrokCandidate(parsed.value)); + } + return sessions; + }), + load: Effect.fn("loadGrokSession")(function* (input) { + const parsed = yield* readGrokSessionFromDir(input.nativePath); + if (Option.isNone(parsed)) { + return yield* new TeleportDiscoveryError({ + message: `Native Grok session '${input.externalSessionId}' could not be read.`, + }); + } + return parsed.value; + }), + write: (input) => + writeGrokSession({ + sessionsRoot: input.homes.grokSessionsRoot, + session: input.session, + ...(input.existingNativePath !== undefined + ? { existingNativePath: input.existingNativePath } + : {}), + }), + requireUnlocked: (input) => requireGrokSessionUnlocked(input.nativePath), + resumeCursor: (externalSessionId) => ({ schemaVersion: 1, sessionId: externalSessionId }), + readExternalSessionId: (resumeCursor) => + isRecord(resumeCursor) ? nonEmptyString(resumeCursor.sessionId) : undefined, +}); diff --git a/apps/server/src/teleport/formats/register.ts b/apps/server/src/teleport/formats/register.ts index 55c3a6b78973..ee84e6d6c83e 100644 --- a/apps/server/src/teleport/formats/register.ts +++ b/apps/server/src/teleport/formats/register.ts @@ -1,4 +1,5 @@ // Native format adapters register themselves by importing their modules here. import "./claude.ts"; import "./codex.ts"; +import "./grok.ts"; export function loadTeleportFormats(): void {} From 5062f81599990199fde3e517c3ed2a9bfb1de95b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 06:07:38 +0000 Subject: [PATCH 07/51] fix(opencode): settle the session when a turn is aborted Stop marked the turn interrupted but left the session running, so the thread stayed Working after abort hung or only emitted turn.aborted. Co-authored-by: aanishbhirud --- .../Layers/ProviderRuntimeIngestion.test.ts | 39 ++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 12 ++++-- .../provider/Layers/OpenCodeAdapter.test.ts | 40 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 11 +++-- 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 258aa010e3e6..0c9cf5e43b7a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -364,6 +364,45 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); + it("maps turn.aborted into a ready session with no active turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-before-abort"), + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-1"), + createdAt: now, + turnId: asTurnId("turn-abort"), + }); + + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && thread.session?.activeTurnId === "turn-abort", + ); + + harness.emit({ + type: "turn.aborted", + eventId: asEventId("evt-turn-aborted"), + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:05.000Z", + turnId: asTurnId("turn-abort"), + payload: { + reason: "Interrupted by user.", + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => entry.session?.status === "ready" && entry.session?.activeTurnId === null, + ); + expect(thread.session?.status).toBe("ready"); + expect(thread.session?.activeTurnId).toBeNull(); + }); + it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 03253797242e..a7ed1a2e3247 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1525,6 +1525,7 @@ const make = Effect.gen(function* () { case "turn.started": return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; case "turn.completed": + case "turn.aborted": if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } @@ -1555,7 +1556,8 @@ const make = Effect.gen(function* () { event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" + event.type === "turn.completed" || + event.type === "turn.aborted" ) { const status = (() => { switch (event.type) { @@ -1571,6 +1573,8 @@ const make = Effect.gen(function* () { return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" : "ready"; + case "turn.aborted": + return "ready"; case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an @@ -1581,7 +1585,9 @@ const make = Effect.gen(function* () { const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" + : event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "session.exited" ? null : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn( @@ -1822,7 +1828,7 @@ const make = Effect.gen(function* () { }); } - if (event.type === "turn.completed") { + if (event.type === "turn.completed" || event.type === "turn.aborted") { const detailedThread = yield* getLoadedThreadDetail(); const messages = detailedThread?.messages ?? []; const proposedPlans = detailedThread?.proposedPlans ?? []; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1385ccbaabec..a244f4d38dfa 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -809,6 +809,46 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("emits turn.aborted and returns the session to ready on interrupt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-interrupt"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.aborted"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "keep going", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }); + + yield* adapter.interruptTurn(threadId, turn.turnId); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.equal(events[0]?.type, "turn.aborted"); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + NodeAssert.equal( + runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), + true, + ); + }), + ); + it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 73c23b77e686..284a4a847394 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1540,14 +1540,19 @@ export function makeOpenCodeAdapter( const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = yield* ensureSessionContext(sessions, threadId); + const abortedTurnId = turnId ?? context.activeTurnId; + // Settle local state first. `session.abort` can hang on a stuck + // OpenCode turn; waiting on it left T3 "Working" after Stop. + context.activeTurnId = undefined; + yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); yield* runOpenCodeSdk("session.abort", () => context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); - if (turnId ?? context.activeTurnId) { + ).pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); + if (abortedTurnId) { yield* emit({ ...(yield* buildEventBase({ threadId, - turnId: turnId ?? context.activeTurnId, + turnId: abortedTurnId, })), type: "turn.aborted", payload: { From 9de48da17a679125631b88a69089b72307981758 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 06:25:20 +0000 Subject: [PATCH 08/51] fix(opencode): unstick Working when Stop has no live session A projected running OpenCode thread stayed Working after Stop if the in-memory provider session was already gone. Interrupt no longer recovers a session just to abort it, and Stop settles the projection when there is nothing live to interrupt. Co-authored-by: aanishbhirud --- .../Layers/ProviderCommandReactor.test.ts | 116 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 59 ++++++++- .../provider/Layers/OpenCodeAdapter.test.ts | 25 ++++ .../src/provider/Layers/OpenCodeAdapter.ts | 23 ++-- .../provider/Layers/ProviderService.test.ts | 22 ++++ .../src/provider/Layers/ProviderService.ts | 2 +- 6 files changed, 234 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..cdc3a0a01fa9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -153,6 +153,9 @@ describe("ProviderCommandReactor", () => { readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; + readonly interruptTurnEffect?: ( + input: unknown, + ) => Effect.Effect; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -235,7 +238,9 @@ describe("ProviderCommandReactor", () => { turnId: asTurnId("turn-1"), }), ); - const interruptTurn = vi.fn((_: unknown) => Effect.void); + const interruptTurn = vi.fn( + (payload: unknown) => input?.interruptTurnEffect?.(payload) ?? Effect.void, + ); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); const stopSession = vi.fn((input: unknown) => @@ -2469,6 +2474,16 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); + harness.runtimeSessions.push({ + provider: ProviderDriverKind.make("codex"), + status: "running", + runtimeMode: "approval-required", + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project", + resumeCursor: { opaque: "resume-live-interrupt" }, + createdAt: now, + updatedAt: now, + }); await Effect.runPromise( harness.engine.dispatch({ @@ -2486,6 +2501,105 @@ describe("ProviderCommandReactor", () => { }); }); + it("settles a projected running session when no live provider session exists", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-zombie"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "opencode", + runtimeMode: "full-access", + activeTurnId: asTurnId("turn-zombie"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-zombie"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-zombie"), + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return thread?.session?.status === "ready" && thread.session.activeTurnId === null; + }); + expect(harness.interruptTurn.mock.calls).toEqual([]); + }); + + it("settles the projected session when provider interrupt fails", async () => { + const harness = await createHarness({ + interruptTurnEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "turn/interrupt", + detail: "provider interrupt hung", + }), + ), + }); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-fail"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-fail"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + harness.runtimeSessions.push({ + provider: ProviderDriverKind.make("codex"), + status: "running", + runtimeMode: "approval-required", + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project", + resumeCursor: { opaque: "resume-interrupt-fail" }, + createdAt: now, + updatedAt: now, + }); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-fail"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-fail"), + createdAt: now, + }), + ); + + await waitFor(() => harness.interruptTurn.mock.calls.length === 1); + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return thread?.session?.status === "ready" && thread.session.activeTurnId === null; + }); + }); + it("starts a fresh session when only projected session state exists", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179f..6c2660dde2af 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -7,6 +7,7 @@ import { ProviderDriverKind, type ProjectId, type OrchestrationSession, + type OrchestrationThread, ThreadId, type ProviderSession, type RuntimeMode, @@ -1184,6 +1185,32 @@ const make = Effect.gen(function* () { .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); }); + const settleInterruptedThreadSession = (input: { + readonly thread: OrchestrationThread; + readonly createdAt: string; + }) => { + const session = input.thread.session; + if (!session || session.status === "stopped") { + return Effect.void; + } + return setThreadSession({ + threadId: input.thread.id, + session: { + threadId: input.thread.id, + status: "ready", + providerName: session.providerName, + ...(session.providerInstanceId !== undefined + ? { providerInstanceId: session.providerInstanceId } + : {}), + runtimeMode: session.runtimeMode, + activeTurnId: null, + lastError: session.lastError, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }; + const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( event: Extract, ) { @@ -1203,8 +1230,36 @@ const make = Effect.gen(function* () { }); } - // Orchestration turn ids are not provider turn ids, so interrupt by session. - yield* providerService.interruptTurn({ threadId: event.payload.threadId }); + const liveSession = (yield* providerService.listSessions()).find( + (session) => session.threadId === thread.id, + ); + if (!liveSession) { + // Projection can stay "running" after the in-memory provider session + // is gone (restart, earlier stop). Do not recover just to interrupt. + return yield* settleInterruptedThreadSession({ + thread, + createdAt: event.payload.createdAt, + }); + } + + // Orchestration turn ids are not provider turn ids, so interrupt by + // session. Passing the orchestration id would make Codex interrupt the + // wrong turn and make Grok ignore the request. + yield* providerService.interruptTurn({ threadId: event.payload.threadId }).pipe( + Effect.catchCause((cause) => + settleInterruptedThreadSession({ + thread, + createdAt: event.payload.createdAt, + }).pipe( + Effect.andThen( + Effect.logWarning("provider turn interrupt failed; settled projected session", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + ), + ); }); const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index a244f4d38dfa..4e4d642ac44e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -20,6 +20,7 @@ import { ProviderDriverKind, ProviderInstanceId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; @@ -849,6 +850,30 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect( + "emits turn.aborted without starting a session when interrupt has no live context", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-zombie-interrupt"); + const turnId = TurnId.make("opencode-turn-zombie"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.aborted"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.interruptTurn(threadId, turnId); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.equal(events[0]?.type, "turn.aborted"); + NodeAssert.equal(String(events[0]?.turnId), String(turnId)); + NodeAssert.equal(runtimeMock.state.startCalls.length, 0); + NodeAssert.equal(runtimeMock.state.abortCalls.length, 0); + }), + ); + it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 284a4a847394..2a39d621ba99 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1539,15 +1539,20 @@ export function makeOpenCodeAdapter( const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { - const context = yield* ensureSessionContext(sessions, threadId); - const abortedTurnId = turnId ?? context.activeTurnId; - // Settle local state first. `session.abort` can hang on a stuck - // OpenCode turn; waiting on it left T3 "Working" after Stop. - context.activeTurnId = undefined; - yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); + // Do not start or recover a session just to stop it. A vanished + // in-memory context is a zombie projection; emit turn.aborted so + // ingestion can leave "Working". + const context = sessions.get(threadId); + const abortedTurnId = turnId ?? context?.activeTurnId; + if (context && !(yield* Ref.get(context.stopped))) { + // Settle local state first. `session.abort` can hang on a stuck + // OpenCode turn; waiting on it left T3 "Working" after Stop. + context.activeTurnId = undefined; + yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); + } if (abortedTurnId) { yield* emit({ ...(yield* buildEventBase({ diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 7334cd019725..2c77c1b6b402 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -988,6 +988,28 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("does not recover a stale session just to interrupt a turn", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + const initial = yield* provider.startSession(asThreadId("thread-interrupt-no-recover"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-interrupt-no-recover"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(initial.threadId); + routing.codex.startSession.mockClear(); + routing.codex.interruptTurn.mockClear(); + + yield* provider.interruptTurn({ threadId: initial.threadId }); + + assert.equal(routing.codex.startSession.mock.calls.length, 0); + assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[initial.threadId, undefined]]); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2ac00873df99..bdcfa8d92a4d 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -786,7 +786,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.interruptTurn", - allowRecovery: true, + allowRecovery: false, }); metricProvider = routed.adapter.provider; yield* Effect.annotateCurrentSpan({ From 3620294d185dc17c40698d5b815af4f697829f68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 09:06:19 +0000 Subject: [PATCH 09/51] test(teleport): use generic fixture paths Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud --- .../orchestration/decider.teleport.test.ts | 2 +- apps/server/src/teleport/cwd.test.ts | 22 +++++++++---------- apps/server/src/teleport/cwd.ts | 2 +- packages/contracts/src/teleport.test.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/decider.teleport.test.ts b/apps/server/src/orchestration/decider.teleport.test.ts index 6fe94940c93e..e13878820efd 100644 --- a/apps/server/src/orchestration/decider.teleport.test.ts +++ b/apps/server/src/orchestration/decider.teleport.test.ts @@ -20,7 +20,7 @@ const NATIVE_TELEPORT: TeleportThreadState = { presence: "native", provider: "codex", externalSessionId: "01a00270-6f96-7ce3-9244-ab159194e668", - nativePath: "/home/ubuntu/.codex/sessions/session.jsonl", + nativePath: "/home/user/.codex/sessions/session.jsonl", lastSyncedAt: NOW, }; diff --git a/apps/server/src/teleport/cwd.test.ts b/apps/server/src/teleport/cwd.test.ts index 5bd8a3d5084f..e76ee711cfdf 100644 --- a/apps/server/src/teleport/cwd.test.ts +++ b/apps/server/src/teleport/cwd.test.ts @@ -25,8 +25,8 @@ describe("teleport cwd matching", () => { it("treats a project folder as inside its OpenCode launch cwd", () => { assert.equal( isTeleportCwdWithin( - "/home/ubuntu/baanish-testing/native/opencode", - "/home/ubuntu/baanish-testing/native", + "/home/user/projects/native/opencode", + "/home/user/projects/native", ), true, ); @@ -37,16 +37,16 @@ describe("teleport cwd matching", () => { it("rejects home and temp roots as OpenCode launch directories", () => { assert.equal(isGenericTeleportCwd("/"), true); assert.equal(isGenericTeleportCwd("/tmp"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu/baanish-testing/native"), false); + assert.equal(isGenericTeleportCwd("/home/user"), true); + assert.equal(isGenericTeleportCwd("/home/user/projects/native"), false); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo"), true); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo\\proj"), false); }); it.effect("matches an OpenCode session whose cwd is a parent of the project", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/opencode", + "/home/user/projects/native", + "/home/user/projects/native/opencode", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), @@ -67,21 +67,21 @@ describe("teleport cwd matching", () => { ); it("does not treat sibling harness folders as OpenCode parent matches", () => { - assert.equal(isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/codex"), true); + assert.equal(isForeignOpenCodeProjectFolder("/home/user/projects/native/codex"), true); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/claude"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/claude"), true, ); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/opencode"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/opencode"), false, ); }); it.effect("does not list a parent OpenCode session under a Codex project folder", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/codex", + "/home/user/projects/native", + "/home/user/projects/native/codex", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), diff --git a/apps/server/src/teleport/cwd.ts b/apps/server/src/teleport/cwd.ts index 99564062ee4e..90070e8cd41b 100644 --- a/apps/server/src/teleport/cwd.ts +++ b/apps/server/src/teleport/cwd.ts @@ -49,7 +49,7 @@ export function isTeleportCwdWithin(inner: string, outer: string): boolean { /** * Home folders, temp roots, and drive roots. OpenCode often records the * process cwd, which can be a parent of the T3 project — but matching `/tmp` - * or `/home/ubuntu` would pull in unrelated sessions. + * or `/home/user` would pull in unrelated sessions. */ export function isGenericTeleportCwd(cwd: string): boolean { const posix = normalizeTeleportCwd(cwd).replaceAll("\\", "/").replace(/\/+$/u, ""); diff --git a/packages/contracts/src/teleport.test.ts b/packages/contracts/src/teleport.test.ts index 6bd7aeaeca07..f5307ffa140b 100644 --- a/packages/contracts/src/teleport.test.ts +++ b/packages/contracts/src/teleport.test.ts @@ -16,7 +16,7 @@ describe("teleport presence", () => { presence: "native", provider: "grok", externalSessionId: "session-1", - nativePath: "/home/ubuntu/.grok/sessions/session-1", + nativePath: "/home/user/.grok/sessions/session-1", lastSyncedAt: "2026-08-14T22:00:00.000Z", }); expect(parsed.presence).toBe("native"); From 6a51d212cf91663de6639f679166c25dc1e6fa00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 09:06:19 +0000 Subject: [PATCH 10/51] test(teleport): use generic fixture paths Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud --- .../orchestration/decider.teleport.test.ts | 2 +- apps/server/src/teleport/cwd.test.ts | 22 +++++++++---------- apps/server/src/teleport/cwd.ts | 2 +- packages/contracts/src/teleport.test.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/decider.teleport.test.ts b/apps/server/src/orchestration/decider.teleport.test.ts index 6fe94940c93e..e13878820efd 100644 --- a/apps/server/src/orchestration/decider.teleport.test.ts +++ b/apps/server/src/orchestration/decider.teleport.test.ts @@ -20,7 +20,7 @@ const NATIVE_TELEPORT: TeleportThreadState = { presence: "native", provider: "codex", externalSessionId: "01a00270-6f96-7ce3-9244-ab159194e668", - nativePath: "/home/ubuntu/.codex/sessions/session.jsonl", + nativePath: "/home/user/.codex/sessions/session.jsonl", lastSyncedAt: NOW, }; diff --git a/apps/server/src/teleport/cwd.test.ts b/apps/server/src/teleport/cwd.test.ts index 5bd8a3d5084f..e76ee711cfdf 100644 --- a/apps/server/src/teleport/cwd.test.ts +++ b/apps/server/src/teleport/cwd.test.ts @@ -25,8 +25,8 @@ describe("teleport cwd matching", () => { it("treats a project folder as inside its OpenCode launch cwd", () => { assert.equal( isTeleportCwdWithin( - "/home/ubuntu/baanish-testing/native/opencode", - "/home/ubuntu/baanish-testing/native", + "/home/user/projects/native/opencode", + "/home/user/projects/native", ), true, ); @@ -37,16 +37,16 @@ describe("teleport cwd matching", () => { it("rejects home and temp roots as OpenCode launch directories", () => { assert.equal(isGenericTeleportCwd("/"), true); assert.equal(isGenericTeleportCwd("/tmp"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu/baanish-testing/native"), false); + assert.equal(isGenericTeleportCwd("/home/user"), true); + assert.equal(isGenericTeleportCwd("/home/user/projects/native"), false); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo"), true); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo\\proj"), false); }); it.effect("matches an OpenCode session whose cwd is a parent of the project", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/opencode", + "/home/user/projects/native", + "/home/user/projects/native/opencode", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), @@ -67,21 +67,21 @@ describe("teleport cwd matching", () => { ); it("does not treat sibling harness folders as OpenCode parent matches", () => { - assert.equal(isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/codex"), true); + assert.equal(isForeignOpenCodeProjectFolder("/home/user/projects/native/codex"), true); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/claude"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/claude"), true, ); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/opencode"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/opencode"), false, ); }); it.effect("does not list a parent OpenCode session under a Codex project folder", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/codex", + "/home/user/projects/native", + "/home/user/projects/native/codex", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), diff --git a/apps/server/src/teleport/cwd.ts b/apps/server/src/teleport/cwd.ts index 99564062ee4e..90070e8cd41b 100644 --- a/apps/server/src/teleport/cwd.ts +++ b/apps/server/src/teleport/cwd.ts @@ -49,7 +49,7 @@ export function isTeleportCwdWithin(inner: string, outer: string): boolean { /** * Home folders, temp roots, and drive roots. OpenCode often records the * process cwd, which can be a parent of the T3 project — but matching `/tmp` - * or `/home/ubuntu` would pull in unrelated sessions. + * or `/home/user` would pull in unrelated sessions. */ export function isGenericTeleportCwd(cwd: string): boolean { const posix = normalizeTeleportCwd(cwd).replaceAll("\\", "/").replace(/\/+$/u, ""); diff --git a/packages/contracts/src/teleport.test.ts b/packages/contracts/src/teleport.test.ts index 6bd7aeaeca07..f5307ffa140b 100644 --- a/packages/contracts/src/teleport.test.ts +++ b/packages/contracts/src/teleport.test.ts @@ -16,7 +16,7 @@ describe("teleport presence", () => { presence: "native", provider: "grok", externalSessionId: "session-1", - nativePath: "/home/ubuntu/.grok/sessions/session-1", + nativePath: "/home/user/.grok/sessions/session-1", lastSyncedAt: "2026-08-14T22:00:00.000Z", }); expect(parsed.presence).toBe("native"); From f86f532874854fe688ca2aea97a37744a84a3e08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 09:06:19 +0000 Subject: [PATCH 11/51] test(teleport): use generic fixture paths Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud --- .../orchestration/decider.teleport.test.ts | 2 +- apps/server/src/teleport/cwd.test.ts | 22 +++++++++---------- apps/server/src/teleport/cwd.ts | 2 +- packages/contracts/src/teleport.test.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/decider.teleport.test.ts b/apps/server/src/orchestration/decider.teleport.test.ts index 6fe94940c93e..e13878820efd 100644 --- a/apps/server/src/orchestration/decider.teleport.test.ts +++ b/apps/server/src/orchestration/decider.teleport.test.ts @@ -20,7 +20,7 @@ const NATIVE_TELEPORT: TeleportThreadState = { presence: "native", provider: "codex", externalSessionId: "01a00270-6f96-7ce3-9244-ab159194e668", - nativePath: "/home/ubuntu/.codex/sessions/session.jsonl", + nativePath: "/home/user/.codex/sessions/session.jsonl", lastSyncedAt: NOW, }; diff --git a/apps/server/src/teleport/cwd.test.ts b/apps/server/src/teleport/cwd.test.ts index 5bd8a3d5084f..e76ee711cfdf 100644 --- a/apps/server/src/teleport/cwd.test.ts +++ b/apps/server/src/teleport/cwd.test.ts @@ -25,8 +25,8 @@ describe("teleport cwd matching", () => { it("treats a project folder as inside its OpenCode launch cwd", () => { assert.equal( isTeleportCwdWithin( - "/home/ubuntu/baanish-testing/native/opencode", - "/home/ubuntu/baanish-testing/native", + "/home/user/projects/native/opencode", + "/home/user/projects/native", ), true, ); @@ -37,16 +37,16 @@ describe("teleport cwd matching", () => { it("rejects home and temp roots as OpenCode launch directories", () => { assert.equal(isGenericTeleportCwd("/"), true); assert.equal(isGenericTeleportCwd("/tmp"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu/baanish-testing/native"), false); + assert.equal(isGenericTeleportCwd("/home/user"), true); + assert.equal(isGenericTeleportCwd("/home/user/projects/native"), false); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo"), true); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo\\proj"), false); }); it.effect("matches an OpenCode session whose cwd is a parent of the project", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/opencode", + "/home/user/projects/native", + "/home/user/projects/native/opencode", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), @@ -67,21 +67,21 @@ describe("teleport cwd matching", () => { ); it("does not treat sibling harness folders as OpenCode parent matches", () => { - assert.equal(isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/codex"), true); + assert.equal(isForeignOpenCodeProjectFolder("/home/user/projects/native/codex"), true); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/claude"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/claude"), true, ); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/opencode"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/opencode"), false, ); }); it.effect("does not list a parent OpenCode session under a Codex project folder", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/codex", + "/home/user/projects/native", + "/home/user/projects/native/codex", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), diff --git a/apps/server/src/teleport/cwd.ts b/apps/server/src/teleport/cwd.ts index 99564062ee4e..90070e8cd41b 100644 --- a/apps/server/src/teleport/cwd.ts +++ b/apps/server/src/teleport/cwd.ts @@ -49,7 +49,7 @@ export function isTeleportCwdWithin(inner: string, outer: string): boolean { /** * Home folders, temp roots, and drive roots. OpenCode often records the * process cwd, which can be a parent of the T3 project — but matching `/tmp` - * or `/home/ubuntu` would pull in unrelated sessions. + * or `/home/user` would pull in unrelated sessions. */ export function isGenericTeleportCwd(cwd: string): boolean { const posix = normalizeTeleportCwd(cwd).replaceAll("\\", "/").replace(/\/+$/u, ""); diff --git a/packages/contracts/src/teleport.test.ts b/packages/contracts/src/teleport.test.ts index 6bd7aeaeca07..f5307ffa140b 100644 --- a/packages/contracts/src/teleport.test.ts +++ b/packages/contracts/src/teleport.test.ts @@ -16,7 +16,7 @@ describe("teleport presence", () => { presence: "native", provider: "grok", externalSessionId: "session-1", - nativePath: "/home/ubuntu/.grok/sessions/session-1", + nativePath: "/home/user/.grok/sessions/session-1", lastSyncedAt: "2026-08-14T22:00:00.000Z", }); expect(parsed.presence).toBe("native"); From 959ef153e5fa5323d8c6e3d655707cab1ad4e9b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 09:06:19 +0000 Subject: [PATCH 12/51] test(teleport): use generic fixture paths Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud --- .../orchestration/decider.teleport.test.ts | 2 +- apps/server/src/teleport/cwd.test.ts | 22 +++++++++---------- apps/server/src/teleport/cwd.ts | 2 +- packages/contracts/src/teleport.test.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/decider.teleport.test.ts b/apps/server/src/orchestration/decider.teleport.test.ts index 6fe94940c93e..e13878820efd 100644 --- a/apps/server/src/orchestration/decider.teleport.test.ts +++ b/apps/server/src/orchestration/decider.teleport.test.ts @@ -20,7 +20,7 @@ const NATIVE_TELEPORT: TeleportThreadState = { presence: "native", provider: "codex", externalSessionId: "01a00270-6f96-7ce3-9244-ab159194e668", - nativePath: "/home/ubuntu/.codex/sessions/session.jsonl", + nativePath: "/home/user/.codex/sessions/session.jsonl", lastSyncedAt: NOW, }; diff --git a/apps/server/src/teleport/cwd.test.ts b/apps/server/src/teleport/cwd.test.ts index 5bd8a3d5084f..e76ee711cfdf 100644 --- a/apps/server/src/teleport/cwd.test.ts +++ b/apps/server/src/teleport/cwd.test.ts @@ -25,8 +25,8 @@ describe("teleport cwd matching", () => { it("treats a project folder as inside its OpenCode launch cwd", () => { assert.equal( isTeleportCwdWithin( - "/home/ubuntu/baanish-testing/native/opencode", - "/home/ubuntu/baanish-testing/native", + "/home/user/projects/native/opencode", + "/home/user/projects/native", ), true, ); @@ -37,16 +37,16 @@ describe("teleport cwd matching", () => { it("rejects home and temp roots as OpenCode launch directories", () => { assert.equal(isGenericTeleportCwd("/"), true); assert.equal(isGenericTeleportCwd("/tmp"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu/baanish-testing/native"), false); + assert.equal(isGenericTeleportCwd("/home/user"), true); + assert.equal(isGenericTeleportCwd("/home/user/projects/native"), false); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo"), true); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo\\proj"), false); }); it.effect("matches an OpenCode session whose cwd is a parent of the project", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/opencode", + "/home/user/projects/native", + "/home/user/projects/native/opencode", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), @@ -67,21 +67,21 @@ describe("teleport cwd matching", () => { ); it("does not treat sibling harness folders as OpenCode parent matches", () => { - assert.equal(isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/codex"), true); + assert.equal(isForeignOpenCodeProjectFolder("/home/user/projects/native/codex"), true); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/claude"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/claude"), true, ); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/opencode"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/opencode"), false, ); }); it.effect("does not list a parent OpenCode session under a Codex project folder", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/codex", + "/home/user/projects/native", + "/home/user/projects/native/codex", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), diff --git a/apps/server/src/teleport/cwd.ts b/apps/server/src/teleport/cwd.ts index 99564062ee4e..90070e8cd41b 100644 --- a/apps/server/src/teleport/cwd.ts +++ b/apps/server/src/teleport/cwd.ts @@ -49,7 +49,7 @@ export function isTeleportCwdWithin(inner: string, outer: string): boolean { /** * Home folders, temp roots, and drive roots. OpenCode often records the * process cwd, which can be a parent of the T3 project — but matching `/tmp` - * or `/home/ubuntu` would pull in unrelated sessions. + * or `/home/user` would pull in unrelated sessions. */ export function isGenericTeleportCwd(cwd: string): boolean { const posix = normalizeTeleportCwd(cwd).replaceAll("\\", "/").replace(/\/+$/u, ""); diff --git a/packages/contracts/src/teleport.test.ts b/packages/contracts/src/teleport.test.ts index 6bd7aeaeca07..f5307ffa140b 100644 --- a/packages/contracts/src/teleport.test.ts +++ b/packages/contracts/src/teleport.test.ts @@ -16,7 +16,7 @@ describe("teleport presence", () => { presence: "native", provider: "grok", externalSessionId: "session-1", - nativePath: "/home/ubuntu/.grok/sessions/session-1", + nativePath: "/home/user/.grok/sessions/session-1", lastSyncedAt: "2026-08-14T22:00:00.000Z", }); expect(parsed.presence).toBe("native"); From d24730ca9ecc2520eac0517ea0137b29582a2333 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 09:06:19 +0000 Subject: [PATCH 13/51] test(teleport): use generic fixture paths Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud --- .../orchestration/decider.teleport.test.ts | 2 +- apps/server/src/teleport/cwd.test.ts | 22 +++++++++---------- apps/server/src/teleport/cwd.ts | 2 +- packages/contracts/src/teleport.test.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/decider.teleport.test.ts b/apps/server/src/orchestration/decider.teleport.test.ts index 6fe94940c93e..e13878820efd 100644 --- a/apps/server/src/orchestration/decider.teleport.test.ts +++ b/apps/server/src/orchestration/decider.teleport.test.ts @@ -20,7 +20,7 @@ const NATIVE_TELEPORT: TeleportThreadState = { presence: "native", provider: "codex", externalSessionId: "01a00270-6f96-7ce3-9244-ab159194e668", - nativePath: "/home/ubuntu/.codex/sessions/session.jsonl", + nativePath: "/home/user/.codex/sessions/session.jsonl", lastSyncedAt: NOW, }; diff --git a/apps/server/src/teleport/cwd.test.ts b/apps/server/src/teleport/cwd.test.ts index 5bd8a3d5084f..e76ee711cfdf 100644 --- a/apps/server/src/teleport/cwd.test.ts +++ b/apps/server/src/teleport/cwd.test.ts @@ -25,8 +25,8 @@ describe("teleport cwd matching", () => { it("treats a project folder as inside its OpenCode launch cwd", () => { assert.equal( isTeleportCwdWithin( - "/home/ubuntu/baanish-testing/native/opencode", - "/home/ubuntu/baanish-testing/native", + "/home/user/projects/native/opencode", + "/home/user/projects/native", ), true, ); @@ -37,16 +37,16 @@ describe("teleport cwd matching", () => { it("rejects home and temp roots as OpenCode launch directories", () => { assert.equal(isGenericTeleportCwd("/"), true); assert.equal(isGenericTeleportCwd("/tmp"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu/baanish-testing/native"), false); + assert.equal(isGenericTeleportCwd("/home/user"), true); + assert.equal(isGenericTeleportCwd("/home/user/projects/native"), false); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo"), true); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo\\proj"), false); }); it.effect("matches an OpenCode session whose cwd is a parent of the project", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/opencode", + "/home/user/projects/native", + "/home/user/projects/native/opencode", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), @@ -67,21 +67,21 @@ describe("teleport cwd matching", () => { ); it("does not treat sibling harness folders as OpenCode parent matches", () => { - assert.equal(isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/codex"), true); + assert.equal(isForeignOpenCodeProjectFolder("/home/user/projects/native/codex"), true); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/claude"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/claude"), true, ); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/opencode"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/opencode"), false, ); }); it.effect("does not list a parent OpenCode session under a Codex project folder", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/codex", + "/home/user/projects/native", + "/home/user/projects/native/codex", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), diff --git a/apps/server/src/teleport/cwd.ts b/apps/server/src/teleport/cwd.ts index 99564062ee4e..90070e8cd41b 100644 --- a/apps/server/src/teleport/cwd.ts +++ b/apps/server/src/teleport/cwd.ts @@ -49,7 +49,7 @@ export function isTeleportCwdWithin(inner: string, outer: string): boolean { /** * Home folders, temp roots, and drive roots. OpenCode often records the * process cwd, which can be a parent of the T3 project — but matching `/tmp` - * or `/home/ubuntu` would pull in unrelated sessions. + * or `/home/user` would pull in unrelated sessions. */ export function isGenericTeleportCwd(cwd: string): boolean { const posix = normalizeTeleportCwd(cwd).replaceAll("\\", "/").replace(/\/+$/u, ""); diff --git a/packages/contracts/src/teleport.test.ts b/packages/contracts/src/teleport.test.ts index 6bd7aeaeca07..f5307ffa140b 100644 --- a/packages/contracts/src/teleport.test.ts +++ b/packages/contracts/src/teleport.test.ts @@ -16,7 +16,7 @@ describe("teleport presence", () => { presence: "native", provider: "grok", externalSessionId: "session-1", - nativePath: "/home/ubuntu/.grok/sessions/session-1", + nativePath: "/home/user/.grok/sessions/session-1", lastSyncedAt: "2026-08-14T22:00:00.000Z", }); expect(parsed.presence).toBe("native"); From 3ac60c2e90dab72380fe848bcf6d17855d246521 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 09:06:19 +0000 Subject: [PATCH 14/51] test(teleport): use generic fixture paths Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud --- .../orchestration/decider.teleport.test.ts | 2 +- apps/server/src/teleport/cwd.test.ts | 22 +++++++++---------- apps/server/src/teleport/cwd.ts | 2 +- packages/contracts/src/teleport.test.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/decider.teleport.test.ts b/apps/server/src/orchestration/decider.teleport.test.ts index 6fe94940c93e..e13878820efd 100644 --- a/apps/server/src/orchestration/decider.teleport.test.ts +++ b/apps/server/src/orchestration/decider.teleport.test.ts @@ -20,7 +20,7 @@ const NATIVE_TELEPORT: TeleportThreadState = { presence: "native", provider: "codex", externalSessionId: "01a00270-6f96-7ce3-9244-ab159194e668", - nativePath: "/home/ubuntu/.codex/sessions/session.jsonl", + nativePath: "/home/user/.codex/sessions/session.jsonl", lastSyncedAt: NOW, }; diff --git a/apps/server/src/teleport/cwd.test.ts b/apps/server/src/teleport/cwd.test.ts index 5bd8a3d5084f..e76ee711cfdf 100644 --- a/apps/server/src/teleport/cwd.test.ts +++ b/apps/server/src/teleport/cwd.test.ts @@ -25,8 +25,8 @@ describe("teleport cwd matching", () => { it("treats a project folder as inside its OpenCode launch cwd", () => { assert.equal( isTeleportCwdWithin( - "/home/ubuntu/baanish-testing/native/opencode", - "/home/ubuntu/baanish-testing/native", + "/home/user/projects/native/opencode", + "/home/user/projects/native", ), true, ); @@ -37,16 +37,16 @@ describe("teleport cwd matching", () => { it("rejects home and temp roots as OpenCode launch directories", () => { assert.equal(isGenericTeleportCwd("/"), true); assert.equal(isGenericTeleportCwd("/tmp"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu"), true); - assert.equal(isGenericTeleportCwd("/home/ubuntu/baanish-testing/native"), false); + assert.equal(isGenericTeleportCwd("/home/user"), true); + assert.equal(isGenericTeleportCwd("/home/user/projects/native"), false); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo"), true); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo\\proj"), false); }); it.effect("matches an OpenCode session whose cwd is a parent of the project", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/opencode", + "/home/user/projects/native", + "/home/user/projects/native/opencode", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), @@ -67,21 +67,21 @@ describe("teleport cwd matching", () => { ); it("does not treat sibling harness folders as OpenCode parent matches", () => { - assert.equal(isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/codex"), true); + assert.equal(isForeignOpenCodeProjectFolder("/home/user/projects/native/codex"), true); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/claude"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/claude"), true, ); assert.equal( - isForeignOpenCodeProjectFolder("/home/ubuntu/baanish-testing/native/opencode"), + isForeignOpenCodeProjectFolder("/home/user/projects/native/opencode"), false, ); }); it.effect("does not list a parent OpenCode session under a Codex project folder", () => opencodeSessionMatchesProjectCwd( - "/home/ubuntu/baanish-testing/native", - "/home/ubuntu/baanish-testing/native/codex", + "/home/user/projects/native", + "/home/user/projects/native/codex", ).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), diff --git a/apps/server/src/teleport/cwd.ts b/apps/server/src/teleport/cwd.ts index 99564062ee4e..90070e8cd41b 100644 --- a/apps/server/src/teleport/cwd.ts +++ b/apps/server/src/teleport/cwd.ts @@ -49,7 +49,7 @@ export function isTeleportCwdWithin(inner: string, outer: string): boolean { /** * Home folders, temp roots, and drive roots. OpenCode often records the * process cwd, which can be a parent of the T3 project — but matching `/tmp` - * or `/home/ubuntu` would pull in unrelated sessions. + * or `/home/user` would pull in unrelated sessions. */ export function isGenericTeleportCwd(cwd: string): boolean { const posix = normalizeTeleportCwd(cwd).replaceAll("\\", "/").replace(/\/+$/u, ""); diff --git a/packages/contracts/src/teleport.test.ts b/packages/contracts/src/teleport.test.ts index 6bd7aeaeca07..f5307ffa140b 100644 --- a/packages/contracts/src/teleport.test.ts +++ b/packages/contracts/src/teleport.test.ts @@ -16,7 +16,7 @@ describe("teleport presence", () => { presence: "native", provider: "grok", externalSessionId: "session-1", - nativePath: "/home/ubuntu/.grok/sessions/session-1", + nativePath: "/home/user/.grok/sessions/session-1", lastSyncedAt: "2026-08-14T22:00:00.000Z", }); expect(parsed.presence).toBe("native"); From 9594bd8ca05423011baebae8ad49e2badfed3f12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 09:06:27 +0000 Subject: [PATCH 15/51] test(teleport): use generic OpenCode fixture copy Keep live-session titles and personal directories out of the public diff. Co-authored-by: aanishbhirud --- .../src/teleport/formats/opencode.test.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/server/src/teleport/formats/opencode.test.ts b/apps/server/src/teleport/formats/opencode.test.ts index b1a81f7513f9..b3bfafa4fdf5 100644 --- a/apps/server/src/teleport/formats/opencode.test.ts +++ b/apps/server/src/teleport/formats/opencode.test.ts @@ -118,14 +118,14 @@ describe("teleport OpenCode format", () => { dbPath: path.join(root, "opencode.db"), sessions: [ { - id: "ses_meal", - directory: "/home/ubuntu/baanish-testing/native", - title: "Weekly meal planning webapp", + id: "ses_sample", + directory: "/home/user/projects/native", + title: "Sample webapp", messages: [ { id: "msg_user", role: "user", - parts: [{ type: "text", text: "Make light mode pastel themed" }], + parts: [{ type: "text", text: "Use a light theme" }], }, { id: "msg_assistant", @@ -133,7 +133,7 @@ describe("teleport OpenCode format", () => { parts: [ { type: "step-start" }, { type: "reasoning", text: "I will restyle the page." }, - { type: "text", text: "Updated the palette to pastels." }, + { type: "text", text: "Updated the palette." }, { type: "tool", tool: "edit", state: { status: "completed" } }, { type: "step-finish", @@ -149,15 +149,15 @@ describe("teleport OpenCode format", () => { }); const parsed = yield* readOpenCodeSessionById({ opencodeRoot: root, - sessionId: "ses_meal", + sessionId: "ses_sample", }); assert.equal(Option.isSome(parsed), true); if (Option.isSome(parsed)) { assert.deepStrictEqual( parsed.value.messages.map((message) => ({ role: message.role, text: message.text })), [ - { role: "user", text: "Make light mode pastel themed" }, - { role: "assistant", text: "Updated the palette to pastels." }, + { role: "user", text: "Use a light theme" }, + { role: "assistant", text: "Updated the palette." }, ], ); } @@ -174,8 +174,8 @@ describe("teleport OpenCode format", () => { sessions: [ { id: "ses_parent", - directory: "/home/ubuntu/baanish-testing/native", - title: "Weekly meal planning webapp", + directory: "/home/user/projects/native", + title: "Sample webapp", messages: [ { id: "msg_user", @@ -200,7 +200,7 @@ describe("teleport OpenCode format", () => { }); const listed = yield* listOpenCodeSessions({ opencodeRoot: root, - cwd: "/home/ubuntu/baanish-testing/native/opencode", + cwd: "/home/user/projects/native/opencode", }); assert.deepStrictEqual( listed.map((session) => session.externalSessionId), @@ -216,7 +216,7 @@ describe("teleport OpenCode format", () => { ); const codexListed = yield* listOpenCodeSessions({ opencodeRoot: root, - cwd: "/home/ubuntu/baanish-testing/native/codex", + cwd: "/home/user/projects/native/codex", }); assert.deepStrictEqual( codexListed.map((session) => session.externalSessionId), @@ -230,17 +230,17 @@ describe("teleport OpenCode format", () => { ); it("treats opencode.db as the shared live store, not a session file", () => { - assert.equal(isOpenCodeSharedStorePath("/home/ubuntu/.local/share/opencode/opencode.db"), true); + assert.equal(isOpenCodeSharedStorePath("/home/user/.local/share/opencode/opencode.db"), true); assert.equal( - isOpenCodeSharedStorePath("/home/ubuntu/.local/share/opencode/storage/session/t3/abc.json"), + isOpenCodeSharedStorePath("/home/user/.local/share/opencode/storage/session/t3/abc.json"), false, ); }); it.effect("does not refuse import when T3 already has opencode.db open", () => requireOpenCodeSessionUnlocked({ - nativePath: "/home/ubuntu/.local/share/opencode/opencode.db", - opencodeRoot: "/home/ubuntu/.local/share/opencode", + nativePath: "/home/user/.local/share/opencode/opencode.db", + opencodeRoot: "/home/user/.local/share/opencode", }).pipe( Effect.provideService(HostProcessPlatform, "linux"), Effect.provide(NodeServices.layer), From b3ac6a89d249f652d154e683a45c64fa6c52962d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 04:57:02 +0000 Subject: [PATCH 16/51] fix(web): match teleport controls to existing header primitives The import CTA lacked cursor-pointer, and the header export control used the text xs size instead of the square icon-xs used by neighboring actions. Co-authored-by: aanishbhirud --- apps/web/src/components/chat/DraftHeroHeadline.tsx | 2 +- apps/web/src/components/teleport/TeleportOutButton.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index e603a03ecd7e..8718522bebcf 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -176,7 +176,7 @@ export function DraftHeroHeadline({ } /> From 468bc93dc58474e2fd5c4e377b0b4ca58bb0b049 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 04:57:02 +0000 Subject: [PATCH 17/51] fix(teleport): close lock, path, and projection holes from review Reject unsafe session ids on native reads/writes, fail closed when lock checks cannot run, keep Codex whitespace, preserve in-place provider instances, and clear stale history/approvals when replacing a thread. Co-authored-by: aanishbhirud --- .../Layers/ProjectionPipeline.ts | 32 +- .../Layers/ProjectionSnapshotQuery.test.ts | 9 + .../Layers/ProjectionSnapshotQuery.ts | 1 + .../orchestration/decider.teleport.test.ts | 36 +- apps/server/src/orchestration/decider.ts | 18 +- .../src/teleport/Layers/TeleportService.ts | 1138 +++++++++-------- .../src/teleport/Services/TeleportService.ts | 29 +- apps/server/src/teleport/cwd.test.ts | 20 +- apps/server/src/teleport/cwd.ts | 10 +- apps/server/src/teleport/fileLock.ts | 75 +- .../src/teleport/formats/claude.test.ts | 17 + apps/server/src/teleport/formats/claude.ts | 28 +- .../server/src/teleport/formats/codex.test.ts | 26 + apps/server/src/teleport/formats/codex.ts | 16 +- apps/server/src/teleport/formats/grok.ts | 17 +- .../src/teleport/formats/opencode.test.ts | 41 + apps/server/src/teleport/formats/opencode.ts | 106 +- apps/server/src/teleport/json.test.ts | 20 + apps/server/src/teleport/json.ts | 22 + 19 files changed, 1011 insertions(+), 650 deletions(-) create mode 100644 apps/server/src/teleport/json.test.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 246384ce3021..9bd9c2e07d4d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -869,7 +869,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.message-sent": case "thread.proposed-plan-upserted": case "thread.activity-appended": - case "thread.history-replaced": case "thread.approval-response-requested": case "thread.user-input-response-requested": { const existingRow = yield* projectionThreadRepository.getById({ @@ -886,6 +885,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.history-replaced": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + latestTurnId: null, + updatedAt: event.occurredAt, + }); + yield* refreshThreadShellSummary(event.payload.threadId); + return; + } + case "thread.session-set": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, @@ -1661,6 +1676,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.history-replaced": { + const pendingRows = yield* projectionPendingApprovalRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + yield* Effect.forEach( + pendingRows, + (row) => + projectionPendingApprovalRepository.deleteByRequestId({ + requestId: row.requestId, + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + return; + } + default: return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 87f40f5b6c5c..9561eb5e4fa8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2515,6 +2515,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = if (shell._tag === "Some") { assert.deepEqual(shell.value.teleport, expectedTeleport); } + + yield* sql` + UPDATE projection_threads + SET archived_at = '2026-08-14T00:00:02.000Z' + WHERE thread_id = 'thread-teleport' + `; + const archived = yield* snapshotQuery.getArchivedShellSnapshot(); + assert.equal(archived.threads[0]?.id, ThreadId.make("thread-teleport")); + assert.deepEqual(archived.threads[0]?.teleport, expectedTeleport); }), ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 0a38a65d11a8..413ae1090612 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -2215,6 +2215,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { row.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + ...(row.teleport == null ? {} : { teleport: row.teleport }), }), ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", diff --git a/apps/server/src/orchestration/decider.teleport.test.ts b/apps/server/src/orchestration/decider.teleport.test.ts index e13878820efd..c6e49bec107e 100644 --- a/apps/server/src/orchestration/decider.teleport.test.ts +++ b/apps/server/src/orchestration/decider.teleport.test.ts @@ -26,6 +26,8 @@ const NATIVE_TELEPORT: TeleportThreadState = { function makeReadModel(input: { readonly teleport?: OrchestrationThread["teleport"]; + readonly session?: OrchestrationThread["session"]; + readonly messages?: OrchestrationThread["messages"]; }): OrchestrationReadModel { return { snapshotSequence: 0, @@ -47,11 +49,11 @@ function makeReadModel(input: { settledOverride: null, settledAt: null, deletedAt: null, - messages: [], + messages: input.messages ?? [], proposedPlans: [], activities: [], checkpoints: [], - session: null, + session: input.session ?? null, ...(input.teleport !== undefined ? { teleport: input.teleport } : {}), }, ], @@ -136,4 +138,34 @@ it.layer(NodeServices.layer)("teleport thread decider", (it) => { expect(events.map((entry) => entry.type)).toContain("thread.turn-start-requested"); }), ); + + it.effect("rejects native teleport while the T3 session is running", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.teleport.set", + commandId: CommandId.make("cmd-teleport-busy"), + threadId: ThreadId.make("thread-1"), + teleport: NATIVE_TELEPORT, + createdAt: NOW, + }, + readModel: makeReadModel({ + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + if (error._tag === "OrchestrationCommandInvariantError") { + expect(error.detail).toContain("starting or running"); + } + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 6cd656d9fa61..efa34b920fd1 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1431,11 +1431,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.teleport.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + if (command.teleport.presence === "native") { + const status = thread.session?.status; + if (status === "starting" || status === "running") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: + "Cannot teleport a thread to the native CLI while its T3 session is starting or running.", + }); + } + if (threadHasQueuedTurnStart(thread, command.createdAt)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Cannot teleport a thread to the native CLI while a turn start is queued.", + }); + } + } return { ...(yield* withEventBase({ aggregateKind: "thread", diff --git a/apps/server/src/teleport/Layers/TeleportService.ts b/apps/server/src/teleport/Layers/TeleportService.ts index ddbac1b60ef9..8b22132ef88f 100644 --- a/apps/server/src/teleport/Layers/TeleportService.ts +++ b/apps/server/src/teleport/Layers/TeleportService.ts @@ -123,599 +123,349 @@ function isBusySessionStatus(status: string | undefined): boolean { return status === "starting" || status === "running"; } -function isIgnorableProviderStopError(error: { readonly _tag: string }): boolean { - switch (error._tag) { - case "ProviderValidationError": - case "ProviderSessionNotFoundError": - case "ProviderAdapterSessionNotFoundError": - return true; - default: - return false; - } -} - -export const TeleportServiceLive = Layer.effect( - TeleportService, - Effect.gen(function* () { - const settingsService = yield* ServerSettings.ServerSettingsService; - const engine = yield* OrchestrationEngine.OrchestrationEngineService; - const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; - const directory = yield* ProviderSessionDirectory; - const instanceRegistry = yield* ProviderInstanceRegistry; - const providerService = yield* ProviderService; - const crypto = yield* Crypto.Crypto; - const nativeContext = yield* Effect.context(); - const provideNative = ( - effect: Effect.Effect, - ): Effect.Effect => effect.pipe(Effect.provideContext(nativeContext)); +export const make = Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory; + const instanceRegistry = yield* ProviderInstanceRegistry; + const providerService = yield* ProviderService; + const crypto = yield* Crypto.Crypto; + const nativeContext = yield* Effect.context(); + const provideNative = ( + effect: Effect.Effect, + ): Effect.Effect => effect.pipe(Effect.provideContext(nativeContext)); - const nextId = () => crypto.randomUUIDv4; - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const inFlight = new Set(); - const alreadyInFlightError = () => - new TeleportInvalidInputError({ - message: "Teleport already in progress for this session.", - }); - const withInFlight = ( - keys: string[], - effect: Effect.Effect, - ): Effect.Effect => - Effect.suspend((): Effect.Effect => { - for (const key of keys) { - if (inFlight.has(key)) { - return alreadyInFlightError(); - } - } - for (const key of keys) { - inFlight.add(key); - } - return effect.pipe( - Effect.ensuring( - Effect.sync(() => { - for (const key of keys) { - inFlight.delete(key); - } - }), - ), - ); - }); - const claimExtraInFlight = (keys: string[], extra: string) => - Effect.suspend((): Effect.Effect => { - if (keys.includes(extra)) { - return Effect.void; - } - if (inFlight.has(extra)) { + const nextId = () => crypto.randomUUIDv4; + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const inFlight = new Set(); + const alreadyInFlightError = () => + new TeleportInvalidInputError({ + message: "Teleport already in progress for this session.", + }); + const withInFlight = ( + keys: string[], + effect: Effect.Effect, + ): Effect.Effect => + Effect.suspend((): Effect.Effect => { + for (const key of keys) { + if (inFlight.has(key)) { return alreadyInFlightError(); } - inFlight.add(extra); - keys.push(extra); + } + for (const key of keys) { + inFlight.add(key); + } + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + for (const key of keys) { + inFlight.delete(key); + } + }), + ), + ); + }); + const claimExtraInFlight = (keys: string[], extra: string) => + Effect.suspend((): Effect.Effect => { + if (keys.includes(extra)) { return Effect.void; - }); + } + if (inFlight.has(extra)) { + return alreadyInFlightError(); + } + inFlight.add(extra); + keys.push(extra); + return Effect.void; + }); + + const mapDispatchError = (message: string) => (cause: unknown) => + new TeleportInvalidInputError({ + message, + cause, + }); - const mapDispatchError = (message: string) => (cause: unknown) => - new TeleportInvalidInputError({ - message, - cause, + const requireParsedSessionUnlocked = (parsed: ParsedNativeSession, homes: TeleportHomes) => { + const adapter = getTeleportFormat(parsed.provider); + if (!adapter) { + return new TeleportUnsupportedProviderError({ + provider: ProviderDriverKind.make(parsed.provider), + message: `Teleport does not support provider '${parsed.provider}'.`, }); + } + return adapter.requireUnlocked({ + homes, + nativePath: parsed.nativePath, + }); + }; - const requireParsedSessionUnlocked = (parsed: ParsedNativeSession, homes: TeleportHomes) => { - const adapter = getTeleportFormat(parsed.provider); - if (!adapter) { - return new TeleportUnsupportedProviderError({ - provider: ProviderDriverKind.make(parsed.provider), - message: `Teleport does not support provider '${parsed.provider}'.`, + const listSessions = (input: TeleportListSessionsInput) => + provideNative( + Effect.gen(function* () { + const cwd = yield* resolveTeleportCwdPath(input.cwd); + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + message: "Server settings could not be read for teleport discovery.", + cause, + }), + ), + ); + const homes = yield* resolveTeleportHomes(settings); + return yield* discoverTeleportSessions({ + homes, + cwd, + ...(input.providers ? { providers: input.providers } : {}), }); - } - return adapter.requireUnlocked({ - homes, - nativePath: parsed.nativePath, - }); - }; + }), + ); - const listSessions = (input: TeleportListSessionsInput) => + const importSessions = (input: TeleportImportSessionsInput) => { + const inFlightKeys = input.sessions.map( + (session) => `session:${session.provider}:${session.externalSessionId}`, + ); + return withInFlight( + inFlightKeys, provideNative( Effect.gen(function* () { + const project = yield* snapshotQuery.getProjectShellById(input.projectId).pipe( + Effect.mapError( + (cause) => + new TeleportProjectResolutionError({ + message: "Failed to load the target project.", + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new TeleportProjectResolutionError({ + message: `Project '${input.projectId}' was not found.`, + }); + } const cwd = yield* resolveTeleportCwdPath(input.cwd); + if (!(yield* teleportCwdsEquivalent(project.value.workspaceRoot, cwd))) { + return yield* new TeleportInvalidInputError({ + message: "Import cwd must match the selected project's workspace root.", + }); + } + const seenRefs = new Set(); + for (const ref of input.sessions) { + const key = `${ref.provider}:${ref.externalSessionId}`; + if (seenRefs.has(key)) { + return yield* new TeleportInvalidInputError({ + message: `Duplicate session '${ref.externalSessionId}' in the import batch.`, + }); + } + seenRefs.add(key); + } + const settings = yield* settingsService.getSettings.pipe( Effect.mapError( (cause) => new TeleportDiscoveryError({ - message: "Server settings could not be read for teleport discovery.", + message: "Server settings could not be read for teleport import.", cause, }), ), ); const homes = yield* resolveTeleportHomes(settings); - return yield* discoverTeleportSessions({ - homes, - cwd, - ...(input.providers ? { providers: input.providers } : {}), - }); - }), - ); + const bindings = yield* directory.listBindings().pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + message: "Failed to read provider session bindings.", + cause, + }), + ), + ); - const importSessions = (input: TeleportImportSessionsInput) => { - const inFlightKeys = input.sessions.map( - (session) => `session:${session.provider}:${session.externalSessionId}`, - ); - return withInFlight( - inFlightKeys, - provideNative( - Effect.gen(function* () { - const project = yield* snapshotQuery.getProjectShellById(input.projectId).pipe( - Effect.mapError( - (cause) => - new TeleportProjectResolutionError({ - message: "Failed to load the target project.", - cause, - }), - ), - ); - if (Option.isNone(project)) { - return yield* new TeleportProjectResolutionError({ - message: `Project '${input.projectId}' was not found.`, - }); - } - const cwd = yield* resolveTeleportCwdPath(input.cwd); - if (!(yield* teleportCwdsEquivalent(project.value.workspaceRoot, cwd))) { - return yield* new TeleportInvalidInputError({ - message: "Import cwd must match the selected project's workspace root.", + const parsedSessions: ParsedNativeSession[] = []; + for (const ref of input.sessions) { + const parsed = yield* loadTeleportSession({ + homes, + provider: ref.provider, + externalSessionId: ref.externalSessionId, + cwd, + }); + yield* requireParsedSessionUnlocked(parsed, homes); + parsedSessions.push({ + ...parsed, + messages: capMessages(parsed.messages), + }); + } + + const imported: TeleportImportedSession[] = []; + const now = yield* nowIso; + + for (const parsed of parsedSessions) { + const driver = ProviderDriverKind.make(parsed.provider); + let existingThreadId: ThreadId | undefined; + let existingProjectId = input.projectId; + let existingStatus: (typeof bindings)[number]["status"]; + let existingProviderInstanceId: (typeof bindings)[number]["providerInstanceId"]; + for (const binding of bindings) { + if (binding.provider !== driver) { + continue; + } + const externalSessionId = readTeleportExternalSessionId({ + provider: binding.provider, + resumeCursor: binding.resumeCursor, + runtimePayload: binding.runtimePayload, }); - } - const seenRefs = new Set(); - for (const ref of input.sessions) { - const key = `${ref.provider}:${ref.externalSessionId}`; - if (seenRefs.has(key)) { + if (externalSessionId !== parsed.externalSessionId) { + continue; + } + const shell = yield* snapshotQuery.getThreadShellById(binding.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + message: "Failed to load an existing teleport thread.", + cause, + }), + ), + ); + if (Option.isNone(shell) || shell.value.archivedAt !== null) { + continue; + } + if (shell.value.projectId !== input.projectId) { + return yield* new TeleportIdentityConflictError({ + provider: parsed.provider, + externalSessionId: parsed.externalSessionId, + existingThreadId: binding.threadId, + existingProjectId: shell.value.projectId, + message: `Session '${parsed.externalSessionId}' is already bound to another T3 project.`, + }); + } + existingThreadId = binding.threadId; + existingProjectId = shell.value.projectId; + existingStatus = binding.status; + existingProviderInstanceId = binding.providerInstanceId; + if (isBusySessionStatus(shell.value.session?.status)) { return yield* new TeleportInvalidInputError({ - message: `Duplicate session '${ref.externalSessionId}' in the import batch.`, + message: `Cannot import while T3 session '${parsed.externalSessionId}' is running.`, }); } - seenRefs.add(key); + break; } - const settings = yield* settingsService.getSettings.pipe( - Effect.mapError( - (cause) => - new TeleportDiscoveryError({ - message: "Server settings could not be read for teleport import.", - cause, - }), - ), - ); - const homes = yield* resolveTeleportHomes(settings); - const bindings = yield* directory.listBindings().pipe( - Effect.mapError( - (cause) => - new TeleportDiscoveryError({ - message: "Failed to read provider session bindings.", - cause, - }), - ), - ); - - const parsedSessions: ParsedNativeSession[] = []; - for (const ref of input.sessions) { - const parsed = yield* loadTeleportSession({ - homes, - provider: ref.provider, - externalSessionId: ref.externalSessionId, - cwd, - }); - yield* requireParsedSessionUnlocked(parsed, homes); - parsedSessions.push({ - ...parsed, - messages: capMessages(parsed.messages), - }); - } - - const imported: TeleportImportedSession[] = []; - const now = yield* nowIso; + const messageIds = yield* Effect.forEach(parsed.messages, () => nextId(), { + concurrency: 1, + }); + const messages = nativeMessagesToOrchestration(parsed.messages, messageIds, now); + const title = + truncateTitle( + parsed.title ?? firstUserTitle(parsed.messages) ?? "Imported session", + ) || "Imported session"; + let threadId = existingThreadId; + let updatedInPlace = false; - for (const parsed of parsedSessions) { - const driver = ProviderDriverKind.make(parsed.provider); - let existingThreadId: ThreadId | undefined; - let existingProjectId = input.projectId; - let existingStatus: (typeof bindings)[number]["status"]; - for (const binding of bindings) { - if (binding.provider !== driver) { - continue; - } - const externalSessionId = readTeleportExternalSessionId({ - provider: binding.provider, - resumeCursor: binding.resumeCursor, - runtimePayload: binding.runtimePayload, + if (threadId) { + yield* claimExtraInFlight(inFlightKeys, `thread:${threadId}`); + const latest = yield* snapshotQuery.getThreadDetailById(threadId).pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + message: "Failed to load the existing thread for in-place import.", + cause, + }), + ), + ); + if (Option.isNone(latest)) { + return yield* new TeleportDiscoveryError({ + message: `Thread '${threadId}' was not found for in-place import.`, }); - if (externalSessionId !== parsed.externalSessionId) { - continue; - } - const shell = yield* snapshotQuery.getThreadShellById(binding.threadId).pipe( - Effect.mapError( - (cause) => - new TeleportDiscoveryError({ - message: "Failed to load an existing teleport thread.", - cause, - }), - ), - ); - if (Option.isNone(shell) || shell.value.archivedAt !== null) { - continue; - } - if (shell.value.projectId !== input.projectId) { - return yield* new TeleportIdentityConflictError({ - provider: parsed.provider, - externalSessionId: parsed.externalSessionId, - existingThreadId: binding.threadId, - existingProjectId: shell.value.projectId, - message: `Session '${parsed.externalSessionId}' is already bound to another T3 project.`, - }); - } - existingThreadId = binding.threadId; - existingProjectId = shell.value.projectId; - existingStatus = binding.status; - if (isBusySessionStatus(shell.value.session?.status)) { - return yield* new TeleportInvalidInputError({ - message: `Cannot import while T3 session '${parsed.externalSessionId}' is running.`, - }); - } - break; } - - const messageIds = yield* Effect.forEach(parsed.messages, () => nextId(), { - concurrency: 1, - }); - const messages = nativeMessagesToOrchestration(parsed.messages, messageIds, now); - const title = - truncateTitle( - parsed.title ?? firstUserTitle(parsed.messages) ?? "Imported session", - ) || "Imported session"; - let threadId = existingThreadId; - let updatedInPlace = false; - - if (threadId) { - yield* claimExtraInFlight(inFlightKeys, `thread:${threadId}`); - const latest = yield* snapshotQuery.getThreadDetailById(threadId).pipe( - Effect.mapError( - (cause) => - new TeleportDiscoveryError({ - message: "Failed to load the existing thread for in-place import.", - cause, - }), - ), - ); - if (Option.isNone(latest)) { - return yield* new TeleportDiscoveryError({ - message: `Thread '${threadId}' was not found for in-place import.`, - }); - } - if (isBusySessionStatus(latest.value.session?.status)) { - return yield* new TeleportInvalidInputError({ - message: `Cannot import while T3 session '${parsed.externalSessionId}' is running.`, - }); - } - if ( - parsed.messages.length === 0 && - orchestrationToNative(latest.value.messages).length > 0 - ) { - return yield* new TeleportInvalidInputError({ - message: "Native session has no messages; refusing to wipe this thread.", - }); - } - updatedInPlace = true; - const replaceCommandId = CommandId.make(yield* nextId()); - yield* engine - .dispatch({ - type: "thread.history.replace", - commandId: replaceCommandId, - threadId, - messages, - createdAt: now, - }) - .pipe(Effect.mapError(mapDispatchError("Failed to replace thread history."))); - yield* engine - .dispatch({ - type: "thread.meta.update", - commandId: CommandId.make(yield* nextId()), - threadId, - title, - }) - .pipe( - Effect.mapError(mapDispatchError("Failed to update imported thread title.")), - ); - } else { - threadId = ThreadId.make(yield* nextId()); - yield* engine - .dispatch({ - type: "thread.create", - commandId: CommandId.make(yield* nextId()), - threadId, - projectId: input.projectId, - title, - modelSelection: modelSelectionForProvider(parsed.provider), - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - branch: null, - worktreePath: null, - createdAt: now, - }) - .pipe(Effect.mapError(mapDispatchError("Failed to create an imported thread."))); - yield* engine - .dispatch({ - type: "thread.history.replace", - commandId: CommandId.make(yield* nextId()), - threadId, - messages, - createdAt: now, - }) - .pipe( - Effect.mapError(mapDispatchError("Failed to write imported thread history.")), - ); + if (isBusySessionStatus(latest.value.session?.status)) { + return yield* new TeleportInvalidInputError({ + message: `Cannot import while T3 session '${parsed.externalSessionId}' is running.`, + }); } - - const teleportPayload: TeleportRuntimePayload = { - schemaVersion: TELEPORT_SCHEMA_VERSION, - externalSessionId: parsed.externalSessionId, - nativePath: parsed.nativePath, - lastSyncDirection: "import", - lastSyncedAt: now, - nativeFormatVersion: parsed.nativeFormatVersion, - presence: "t3", - }; - yield* directory - .upsert({ + if ( + parsed.messages.length === 0 && + orchestrationToNative(latest.value.messages).length > 0 + ) { + return yield* new TeleportInvalidInputError({ + message: "Native session has no messages; refusing to wipe this thread.", + }); + } + updatedInPlace = true; + const replaceCommandId = CommandId.make(yield* nextId()); + yield* engine + .dispatch({ + type: "thread.history.replace", + commandId: replaceCommandId, threadId, - provider: driver, - providerInstanceId: defaultInstanceIdForDriver(driver), - status: existingStatus ?? "stopped", - resumeCursor: buildTeleportResumeCursor({ - provider: parsed.provider, - externalSessionId: parsed.externalSessionId, - }), - runtimePayload: { teleport: teleportPayload }, + messages, + createdAt: now, }) - .pipe( - Effect.mapError( - (cause) => - new TeleportDiscoveryError({ - message: "Failed to bind the imported native session.", - cause, - }), - ), - ); + .pipe(Effect.mapError(mapDispatchError("Failed to replace thread history."))); yield* engine .dispatch({ - type: "thread.teleport.set", + type: "thread.meta.update", commandId: CommandId.make(yield* nextId()), threadId, - teleport: teleportThreadStateFromPayload({ - provider: parsed.provider, - payload: teleportPayload, - }), + title, + }) + .pipe(Effect.mapError(mapDispatchError("Failed to update imported thread title."))); + } else { + threadId = ThreadId.make(yield* nextId()); + yield* engine + .dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* nextId()), + threadId, + projectId: input.projectId, + title, + modelSelection: modelSelectionForProvider(parsed.provider), + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, createdAt: now, }) - .pipe(Effect.mapError(mapDispatchError("Failed to persist teleport presence."))); - - imported.push({ - threadId, - projectId: existingProjectId, - provider: parsed.provider, - providerInstanceId: defaultInstanceIdForDriver(driver), - externalSessionId: parsed.externalSessionId, - updatedInPlace, - }); - } - - return { - schemaVersion: TELEPORT_SCHEMA_VERSION, - imported, - }; - }), - ).pipe( - Effect.catchTag( - "PlatformError", - (cause: PlatformError.PlatformError) => - new TeleportDiscoveryError({ - message: "Native filesystem error during teleport import.", - cause, - }), - ), - ), - ); - }; - - const exportSession = (input: TeleportExportSessionInput) => { - const inFlightKeys = [`thread:${input.threadId}`]; - return withInFlight( - inFlightKeys, - provideNative( - Effect.gen(function* () { - const thread = yield* snapshotQuery.getThreadDetailById(input.threadId).pipe( - Effect.mapError( - (cause) => - new TeleportInvalidInputError({ - message: "Failed to load the thread for export.", - cause, - }), - ), - ); - if (Option.isNone(thread)) { - return yield* new TeleportInvalidInputError({ - message: `Thread '${input.threadId}' was not found.`, - }); - } - if (isBusySessionStatus(thread.value.session?.status)) { - return yield* new TeleportInvalidInputError({ - message: "Cannot export while this T3 session is running.", - }); - } - - const project = yield* snapshotQuery.getProjectShellById(thread.value.projectId).pipe( - Effect.mapError( - (cause) => - new TeleportProjectResolutionError({ - message: "Failed to load the thread's project.", - cause, - }), - ), - ); - if (Option.isNone(project)) { - return yield* new TeleportProjectResolutionError({ - message: "The thread's project was not found.", - }); - } - - const binding = yield* directory.getBinding(input.threadId).pipe( - Effect.mapError( - (cause) => - new TeleportInvalidInputError({ - message: "Failed to read the thread's provider binding.", - cause, - }), - ), - ); - const instance = yield* instanceRegistry.getInstance( - thread.value.modelSelection.instanceId, - ); - const driverKind = - Option.getOrUndefined(binding)?.provider ?? - instance?.driverKind ?? - (isTeleportProvider(thread.value.modelSelection.instanceId) - ? ProviderDriverKind.make(thread.value.modelSelection.instanceId) - : undefined); - if (!driverKind) { - return yield* new TeleportInvalidInputError({ - message: "Teleport export could not resolve a supported provider for this thread.", - }); - } - const provider = yield* toTeleportProvider(driverKind); - const existingPayload = Option.isSome(binding) - ? readTeleportRuntimePayload(binding.value.runtimePayload) - : undefined; - if (resolveTeleportPresence(existingPayload) === "native") { - return yield* new TeleportInvalidInputError({ - message: - "This thread is already in the native CLI. Import it before exporting again.", - }); - } - - yield* providerService.stopSession({ threadId: input.threadId }).pipe( - Effect.catchIf(isIgnorableProviderStopError, (error) => - Effect.logDebug("teleport.export.stop-session-skipped", { - threadId: input.threadId, - reason: error._tag, - }), - ), - Effect.mapError( - (cause) => - new TeleportInvalidInputError({ - message: "Failed to stop the T3 provider session before export.", - cause, - }), - ), - ); - const now = yield* nowIso; - yield* engine - .dispatch({ - type: "thread.session.stop", - commandId: CommandId.make(yield* nextId()), - threadId: input.threadId, - createdAt: now, - }) - .pipe( - Effect.catch(() => - Effect.logDebug("teleport.export.session-stop-dispatch-skipped", { - threadId: input.threadId, - }), - ), - ); - - const cwd = yield* resolveTeleportCwdPath(project.value.workspaceRoot); - const settings = yield* settingsService.getSettings.pipe( - Effect.mapError( - (cause) => - new TeleportNativeWriteError({ - nativePath: cwd, - message: "Server settings could not be read for teleport export.", - cause, - }), - ), - ); - const homes = yield* resolveTeleportHomes(settings); - const existingExternalId = Option.isSome(binding) - ? readTeleportExternalSessionId({ - provider: driverKind, - resumeCursor: binding.value.resumeCursor, - runtimePayload: binding.value.runtimePayload, + .pipe(Effect.mapError(mapDispatchError("Failed to create an imported thread."))); + yield* engine + .dispatch({ + type: "thread.history.replace", + commandId: CommandId.make(yield* nextId()), + threadId, + messages, + createdAt: now, }) - : undefined; - const externalSessionId = existingExternalId ?? (yield* nextId()); - yield* claimExtraInFlight(inFlightKeys, `session:${provider}:${externalSessionId}`); - const latest = yield* snapshotQuery.getThreadDetailById(input.threadId).pipe( - Effect.mapError( - (cause) => - new TeleportInvalidInputError({ - message: "Failed to re-check the thread before export.", - cause, - }), - ), - ); - if (Option.isNone(latest)) { - return yield* new TeleportInvalidInputError({ - message: `Thread '${input.threadId}' was not found.`, - }); - } - const messages = capMessages(orchestrationToNative(latest.value.messages)); - const nativeSession: ParsedNativeSession = { - provider, - externalSessionId, - cwd, - nativePath: "", - nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, - title: thread.value.title, - createdAt: thread.value.createdAt, - updatedAt: now, - messages, - }; - - const existingNativePath = existingPayload?.nativePath; - const adapter = getTeleportFormat(provider); - if (!adapter) { - return yield* new TeleportUnsupportedProviderError({ - provider: driverKind, - message: `Teleport does not support provider '${provider}'.`, - }); + .pipe( + Effect.mapError(mapDispatchError("Failed to write imported thread history.")), + ); } - const nativePath = yield* adapter.write({ - homes, - session: nativeSession, - ...(existingNativePath !== undefined ? { existingNativePath } : {}), - }); + const providerInstanceId = + existingProviderInstanceId ?? defaultInstanceIdForDriver(driver); const teleportPayload: TeleportRuntimePayload = { schemaVersion: TELEPORT_SCHEMA_VERSION, - externalSessionId, - nativePath, - lastSyncDirection: "export", + externalSessionId: parsed.externalSessionId, + nativePath: parsed.nativePath, + lastSyncDirection: "import", lastSyncedAt: now, - nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, - presence: "native", + nativeFormatVersion: parsed.nativeFormatVersion, + presence: "t3", }; yield* directory .upsert({ - threadId: input.threadId, - provider: driverKind, - providerInstanceId: - Option.getOrUndefined(binding)?.providerInstanceId ?? - defaultInstanceIdForDriver(driverKind), - status: "stopped", - resumeCursor: buildTeleportResumeCursor({ provider, externalSessionId }), + threadId, + provider: driver, + providerInstanceId, + status: existingStatus ?? "stopped", + resumeCursor: buildTeleportResumeCursor({ + provider: parsed.provider, + externalSessionId: parsed.externalSessionId, + }), runtimePayload: { teleport: teleportPayload }, }) .pipe( Effect.mapError( (cause) => - new TeleportNativeWriteError({ - nativePath, - message: "Failed to bind the exported native session.", + new TeleportDiscoveryError({ + message: "Failed to bind the imported native session.", cause, }), ), @@ -724,42 +474,294 @@ export const TeleportServiceLive = Layer.effect( .dispatch({ type: "thread.teleport.set", commandId: CommandId.make(yield* nextId()), - threadId: input.threadId, + threadId, teleport: teleportThreadStateFromPayload({ - provider, + provider: parsed.provider, payload: teleportPayload, }), createdAt: now, }) .pipe(Effect.mapError(mapDispatchError("Failed to persist teleport presence."))); - return { - schemaVersion: TELEPORT_SCHEMA_VERSION, - provider, - providerInstanceId: defaultInstanceIdForDriver(driverKind), - externalSessionId, - nativePath, - cwd, - }; - }), - ).pipe( - Effect.catchTag( - "PlatformError", - (cause: PlatformError.PlatformError) => - new TeleportNativeWriteError({ - nativePath: "native session", - message: "Native filesystem error during teleport export.", - cause, + imported.push({ + threadId, + projectId: existingProjectId, + provider: parsed.provider, + providerInstanceId, + externalSessionId: parsed.externalSessionId, + updatedInPlace, + }); + } + + return { + schemaVersion: TELEPORT_SCHEMA_VERSION, + imported, + }; + }), + ).pipe( + Effect.catchTag( + "PlatformError", + (cause: PlatformError.PlatformError) => + new TeleportDiscoveryError({ + message: "Native filesystem error during teleport import.", + cause, + }), + ), + ), + ); + }; + + const exportSession = (input: TeleportExportSessionInput) => { + const inFlightKeys = [`thread:${input.threadId}`]; + return withInFlight( + inFlightKeys, + provideNative( + Effect.gen(function* () { + const thread = yield* snapshotQuery.getThreadDetailById(input.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to load the thread for export.", + cause, + }), + ), + ); + if (Option.isNone(thread)) { + return yield* new TeleportInvalidInputError({ + message: `Thread '${input.threadId}' was not found.`, + }); + } + if (isBusySessionStatus(thread.value.session?.status)) { + return yield* new TeleportInvalidInputError({ + message: "Cannot export while this T3 session is running.", + }); + } + + const project = yield* snapshotQuery.getProjectShellById(thread.value.projectId).pipe( + Effect.mapError( + (cause) => + new TeleportProjectResolutionError({ + message: "Failed to load the thread's project.", + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new TeleportProjectResolutionError({ + message: "The thread's project was not found.", + }); + } + + const binding = yield* directory.getBinding(input.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to read the thread's provider binding.", + cause, + }), + ), + ); + const instance = yield* instanceRegistry.getInstance( + thread.value.modelSelection.instanceId, + ); + const driverKind = + Option.getOrUndefined(binding)?.provider ?? + instance?.driverKind ?? + (isTeleportProvider(thread.value.modelSelection.instanceId) + ? ProviderDriverKind.make(thread.value.modelSelection.instanceId) + : undefined); + if (!driverKind) { + return yield* new TeleportInvalidInputError({ + message: "Teleport export could not resolve a supported provider for this thread.", + }); + } + const provider = yield* toTeleportProvider(driverKind); + const existingPayload = Option.isSome(binding) + ? readTeleportRuntimePayload(binding.value.runtimePayload) + : undefined; + if (resolveTeleportPresence(existingPayload) === "native") { + return yield* new TeleportInvalidInputError({ + message: + "This thread is already in the native CLI. Import it before exporting again.", + }); + } + + yield* providerService.stopSession({ threadId: input.threadId }).pipe( + Effect.catchTags({ + ProviderValidationError: (error) => + Effect.logDebug("teleport.export.stop-session-skipped", { + threadId: input.threadId, + reason: error._tag, + }), + ProviderSessionNotFoundError: (error) => + Effect.logDebug("teleport.export.stop-session-skipped", { + threadId: input.threadId, + reason: error._tag, + }), + ProviderAdapterSessionNotFoundError: (error) => + Effect.logDebug("teleport.export.stop-session-skipped", { + threadId: input.threadId, + reason: error._tag, + }), + }), + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to stop the T3 provider session before export.", + cause, + }), + ), + ); + const now = yield* nowIso; + yield* engine + .dispatch({ + type: "thread.session.stop", + commandId: CommandId.make(yield* nextId()), + threadId: input.threadId, + createdAt: now, + }) + .pipe( + Effect.catch(() => + Effect.logDebug("teleport.export.session-stop-dispatch-skipped", { + threadId: input.threadId, + }), + ), + ); + + const cwd = yield* resolveTeleportCwdPath(project.value.workspaceRoot); + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: cwd, + message: "Server settings could not be read for teleport export.", + cause, + }), + ), + ); + const homes = yield* resolveTeleportHomes(settings); + const existingExternalId = Option.isSome(binding) + ? readTeleportExternalSessionId({ + provider: driverKind, + resumeCursor: binding.value.resumeCursor, + runtimePayload: binding.value.runtimePayload, + }) + : undefined; + const externalSessionId = existingExternalId ?? (yield* nextId()); + yield* claimExtraInFlight(inFlightKeys, `session:${provider}:${externalSessionId}`); + const latest = yield* snapshotQuery.getThreadDetailById(input.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to re-check the thread before export.", + cause, + }), + ), + ); + if (Option.isNone(latest)) { + return yield* new TeleportInvalidInputError({ + message: `Thread '${input.threadId}' was not found.`, + }); + } + const messages = capMessages(orchestrationToNative(latest.value.messages)); + const nativeSession: ParsedNativeSession = { + provider, + externalSessionId, + cwd, + nativePath: "", + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + title: thread.value.title, + createdAt: thread.value.createdAt, + updatedAt: now, + messages, + }; + + const existingNativePath = existingPayload?.nativePath; + const adapter = getTeleportFormat(provider); + if (!adapter) { + return yield* new TeleportUnsupportedProviderError({ + provider: driverKind, + message: `Teleport does not support provider '${provider}'.`, + }); + } + const nativePath = yield* adapter.write({ + homes, + session: nativeSession, + ...(existingNativePath !== undefined ? { existingNativePath } : {}), + }); + + const teleportPayload: TeleportRuntimePayload = { + schemaVersion: TELEPORT_SCHEMA_VERSION, + externalSessionId, + nativePath, + lastSyncDirection: "export", + lastSyncedAt: now, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + presence: "native", + }; + yield* directory + .upsert({ + threadId: input.threadId, + provider: driverKind, + providerInstanceId: + Option.getOrUndefined(binding)?.providerInstanceId ?? + defaultInstanceIdForDriver(driverKind), + status: "stopped", + resumeCursor: buildTeleportResumeCursor({ provider, externalSessionId }), + runtimePayload: { teleport: teleportPayload }, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath, + message: "Failed to bind the exported native session.", + cause, + }), + ), + ); + yield* engine + .dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make(yield* nextId()), + threadId: input.threadId, + teleport: teleportThreadStateFromPayload({ + provider, + payload: teleportPayload, }), - ), + createdAt: now, + }) + .pipe(Effect.mapError(mapDispatchError("Failed to persist teleport presence."))); + + return { + schemaVersion: TELEPORT_SCHEMA_VERSION, + provider, + providerInstanceId: defaultInstanceIdForDriver(driverKind), + externalSessionId, + nativePath, + cwd, + }; + }), + ).pipe( + Effect.catchTag( + "PlatformError", + (cause: PlatformError.PlatformError) => + new TeleportNativeWriteError({ + nativePath: "native session", + message: "Native filesystem error during teleport export.", + cause, + }), ), - ); - }; + ), + ); + }; - return TeleportService.of({ - listSessions, - importSessions, - exportSession, - }); - }), -); + return TeleportService.of({ + listSessions, + importSessions, + exportSession, + }); +}); + +export const layer = Layer.effect(TeleportService, make); +export const TeleportServiceLive = layer; diff --git a/apps/server/src/teleport/Services/TeleportService.ts b/apps/server/src/teleport/Services/TeleportService.ts index 9a9824c9a2f8..34f674c732fd 100644 --- a/apps/server/src/teleport/Services/TeleportService.ts +++ b/apps/server/src/teleport/Services/TeleportService.ts @@ -12,20 +12,19 @@ import type { import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -export interface TeleportServiceShape { - readonly listSessions: ( - input: TeleportListSessionsInput, - ) => Effect.Effect; +export class TeleportService extends Context.Service< + TeleportService, + { + readonly listSessions: ( + input: TeleportListSessionsInput, + ) => Effect.Effect; - readonly importSessions: ( - input: TeleportImportSessionsInput, - ) => Effect.Effect; + readonly importSessions: ( + input: TeleportImportSessionsInput, + ) => Effect.Effect; - readonly exportSession: ( - input: TeleportExportSessionInput, - ) => Effect.Effect; -} - -export class TeleportService extends Context.Service()( - "t3/teleport/Services/TeleportService", -) {} + readonly exportSession: ( + input: TeleportExportSessionInput, + ) => Effect.Effect; + } +>()("t3/teleport/Services/TeleportService") {} diff --git a/apps/server/src/teleport/cwd.test.ts b/apps/server/src/teleport/cwd.test.ts index e76ee711cfdf..3bc79fc398bb 100644 --- a/apps/server/src/teleport/cwd.test.ts +++ b/apps/server/src/teleport/cwd.test.ts @@ -24,10 +24,7 @@ describe("teleport cwd matching", () => { it("treats a project folder as inside its OpenCode launch cwd", () => { assert.equal( - isTeleportCwdWithin( - "/home/user/projects/native/opencode", - "/home/user/projects/native", - ), + isTeleportCwdWithin("/home/user/projects/native/opencode", "/home/user/projects/native"), true, ); assert.equal(isTeleportCwdWithin("/tmp/oc-wire-test", "/tmp"), true); @@ -37,7 +34,12 @@ describe("teleport cwd matching", () => { it("rejects home and temp roots as OpenCode launch directories", () => { assert.equal(isGenericTeleportCwd("/"), true); assert.equal(isGenericTeleportCwd("/tmp"), true); + assert.equal(isGenericTeleportCwd("/private/var"), true); + assert.equal(isGenericTeleportCwd("/private/var/tmp"), true); + assert.equal(isGenericTeleportCwd("//server/share"), true); assert.equal(isGenericTeleportCwd("/home/user"), true); + assert.equal(isGenericTeleportCwd("/root"), true); + assert.equal(isGenericTeleportCwd("/root/workspace"), false); assert.equal(isGenericTeleportCwd("/home/user/projects/native"), false); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo"), true); assert.equal(isGenericTeleportCwd("C:\\Users\\Foo\\proj"), false); @@ -68,14 +70,8 @@ describe("teleport cwd matching", () => { it("does not treat sibling harness folders as OpenCode parent matches", () => { assert.equal(isForeignOpenCodeProjectFolder("/home/user/projects/native/codex"), true); - assert.equal( - isForeignOpenCodeProjectFolder("/home/user/projects/native/claude"), - true, - ); - assert.equal( - isForeignOpenCodeProjectFolder("/home/user/projects/native/opencode"), - false, - ); + assert.equal(isForeignOpenCodeProjectFolder("/home/user/projects/native/claude"), true); + assert.equal(isForeignOpenCodeProjectFolder("/home/user/projects/native/opencode"), false); }); it.effect("does not list a parent OpenCode session under a Codex project folder", () => diff --git a/apps/server/src/teleport/cwd.ts b/apps/server/src/teleport/cwd.ts index 90070e8cd41b..ec38aa0f5069 100644 --- a/apps/server/src/teleport/cwd.ts +++ b/apps/server/src/teleport/cwd.ts @@ -62,6 +62,8 @@ export function isGenericTeleportCwd(cwd: string): boolean { lowered === "/private/tmp" || lowered === "/var" || lowered === "/var/tmp" || + lowered === "/private/var" || + lowered === "/private/var/tmp" || lowered === "/home" || lowered === "/users" || lowered === "/root" @@ -69,13 +71,19 @@ export function isGenericTeleportCwd(cwd: string): boolean { return true; } const parts = lowered.split("/").filter((part) => part.length > 0); + if (posix.startsWith("//") && parts.length <= 2) { + return true; + } const first = parts[0]; if (first === undefined) { return true; } - if (first === "home" || first === "users" || first === "root") { + if (first === "home" || first === "users") { return parts.length <= 2; } + if (first === "root") { + return parts.length <= 1; + } if (/^[a-z]:$/u.test(first)) { if (parts.length <= 1) { return true; diff --git a/apps/server/src/teleport/fileLock.ts b/apps/server/src/teleport/fileLock.ts index 99240b348daf..44c3ccae116f 100644 --- a/apps/server/src/teleport/fileLock.ts +++ b/apps/server/src/teleport/fileLock.ts @@ -17,21 +17,51 @@ function nodeErrorCode(error: unknown): string | undefined { return undefined; } +function execExitStatus(error: unknown): number | undefined { + if (typeof error !== "object" || error === null) { + return undefined; + } + if ("status" in error && typeof (error as { status?: unknown }).status === "number") { + return (error as { status: number }).status; + } + if ("code" in error && typeof (error as { code?: unknown }).code === "number") { + return (error as { code: number }).code; + } + return undefined; +} + +function execStdout(error: unknown): string { + if (typeof error === "object" && error !== null && "stdout" in error) { + const stdout = (error as { stdout?: unknown }).stdout; + return typeof stdout === "string" ? stdout : ""; + } + return ""; +} + const isWindowsPathInUse = Effect.fn("isWindowsPathInUse")(function* (nativePath: string) { - // Succeed with a boolean. `Effect.tryPromise` `catch` is the error channel - // and would leak `false` into export/import failures. - return yield* Effect.promise(async () => { - try { - const handle = await NodeFSP.open(nativePath, "r+"); - await handle.close(); - return false; - } catch (error) { - const code = nodeErrorCode(error); - if (code === "ENOENT" || code === "EISDIR") { + return yield* Effect.tryPromise({ + try: async () => { + try { + const handle = await NodeFSP.open(nativePath, "r+"); + await handle.close(); return false; + } catch (error) { + const code = nodeErrorCode(error); + if (code === "ENOENT" || code === "EISDIR") { + return false; + } + if (code === "EBUSY" || code === "EPERM" || code === "EACCES") { + return true; + } + throw error; } - return code === "EBUSY" || code === "EPERM" || code === "EACCES"; - } + }, + catch: (cause) => + new TeleportFileLockedError({ + nativePath, + message: `Failed to check whether ${nativePath} is locked.`, + cause, + }), }); }); @@ -43,16 +73,25 @@ export const isNativePathLocked = Effect.fn("isNativePathLocked")(function* (nat return yield* isWindowsPathInUse(nativePath); } return yield* Effect.tryPromise({ - try: () => execFile("lsof", ["-t", nativePath], { timeout: 2_000 }), - catch: () => + try: async () => { + try { + const result = await execFile("lsof", ["-t", nativePath], { timeout: 2_000 }); + return result.stdout.trim().length > 0; + } catch (error) { + // lsof exits 1 when no process has the file open. + if (execExitStatus(error) === 1) { + return execStdout(error).trim().length > 0; + } + throw error; + } + }, + catch: (cause) => new TeleportFileLockedError({ nativePath, message: `Failed to check whether ${nativePath} is locked.`, + cause, }), - }).pipe( - Effect.map((result) => result.stdout.trim().length > 0), - Effect.orElseSucceed(() => false), - ); + }); }); export const requireNativePathUnlocked = Effect.fn("requireNativePathUnlocked")(function* ( diff --git a/apps/server/src/teleport/formats/claude.test.ts b/apps/server/src/teleport/formats/claude.test.ts index 7d5a328d7b79..9a38d0c2a839 100644 --- a/apps/server/src/teleport/formats/claude.test.ts +++ b/apps/server/src/teleport/formats/claude.test.ts @@ -80,6 +80,23 @@ describe("teleport Claude format", () => { }), ); + it.effect("serializes an empty Claude session with metadata the parser can resume", () => + Effect.gen(function* () { + const session = sampleTeleportSession("claudeAgent"); + const empty = { ...session, messages: [] }; + const parsed = yield* parseClaudeSessionContents({ + contents: serializeClaudeSession(empty), + nativePath: session.nativePath, + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.externalSessionId, TELEPORT_TEST_SESSION_ID); + assert.equal(parsed.value.cwd, "/workspace"); + assert.equal(parsed.value.messages.length, 0); + } + }), + ); + it.effect("fails closed on a newer Claude format version", () => Effect.gen(function* () { const contents = `${JSON.stringify({ diff --git a/apps/server/src/teleport/formats/claude.ts b/apps/server/src/teleport/formats/claude.ts index 15a0f16bff5e..48054547a774 100644 --- a/apps/server/src/teleport/formats/claude.ts +++ b/apps/server/src/teleport/formats/claude.ts @@ -25,6 +25,7 @@ import { collectTextParts, firstUserTitle, isRecord, + isSafeTeleportSessionId, nonEmptyString, parseJsonObject, } from "../json.ts"; @@ -158,10 +159,25 @@ export function parseClaudeSessionContents(input: { export function serializeClaudeSession(session: ParsedNativeSession): string { const lines: string[] = []; + const timestamp = session.createdAt ?? new Date().toISOString(); + if (session.messages.length === 0) { + lines.push( + JSON.stringify({ + type: "session", + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + uuid: session.externalSessionId, + parentUuid: null, + sessionId: session.externalSessionId, + cwd: session.cwd, + timestamp, + }), + ); + return `${lines.join("\n")}\n`; + } let parentUuid: string | null = null; for (const [index, message] of session.messages.entries()) { const uuid = message.id ?? `${session.externalSessionId}-${index}`; - const timestamp = message.createdAt ?? session.createdAt ?? new Date().toISOString(); + const at = message.createdAt ?? timestamp; lines.push( JSON.stringify({ type: message.role, @@ -170,7 +186,7 @@ export function serializeClaudeSession(session: ParsedNativeSession): string { parentUuid, sessionId: session.externalSessionId, cwd: session.cwd, - timestamp, + timestamp: at, message: { role: message.role, content: [{ type: "text", text: message.text }], @@ -280,7 +296,7 @@ registerTeleportFormat({ nativePath, parse: parseClaudeSessionContents, }); - if (Option.isNone(parsed)) { + if (Option.isNone(parsed) || !isSafeTeleportSessionId(parsed.value.externalSessionId)) { continue; } if (!(yield* teleportCwdsEquivalent(parsed.value.cwd, input.cwd))) { @@ -304,6 +320,12 @@ registerTeleportFormat({ }), write: Effect.fn("writeClaudeSession")(function* (input) { const path = yield* Path.Path; + if (!isSafeTeleportSessionId(input.session.externalSessionId)) { + return yield* new TeleportNativeWriteError({ + nativePath: input.homes.claudeProjectsRoot, + message: `Refusing to write a Claude session with an unsafe id '${input.session.externalSessionId}'.`, + }); + } const nativePath = input.existingNativePath ?? allocateClaudeSessionPath({ diff --git a/apps/server/src/teleport/formats/codex.test.ts b/apps/server/src/teleport/formats/codex.test.ts index aeaf4ef0c660..625a6463d4d0 100644 --- a/apps/server/src/teleport/formats/codex.test.ts +++ b/apps/server/src/teleport/formats/codex.test.ts @@ -106,6 +106,32 @@ describe("teleport Codex format", () => { }), ); + it.effect("preserves leading and trailing whitespace in Codex message text", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "session_meta", + payload: { id: TELEPORT_TEST_SESSION_ID, cwd: "/workspace" }, + })}\n${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: " keep indent \n" }], + }, + })}\n`; + const parsed = yield* parseCodexSessionContents({ + contents, + nativePath: "/tmp/codex-whitespace.jsonl", + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.messages[0]?.text, " keep indent \n"); + } + }), + ); + it.effect("skips forked Codex sessions", () => Effect.gen(function* () { const contents = `${JSON.stringify({ diff --git a/apps/server/src/teleport/formats/codex.ts b/apps/server/src/teleport/formats/codex.ts index bcb1e830511c..cd2bd98f4c70 100644 --- a/apps/server/src/teleport/formats/codex.ts +++ b/apps/server/src/teleport/formats/codex.ts @@ -21,7 +21,9 @@ import { requireNativePathUnlocked } from "../fileLock.ts"; import { firstUserTitle, isRecord, + isSafeTeleportSessionId, isSyntheticNativeUserText, + nativeSessionText, nonEmptyString, parseJsonObject, uuidFromPath, @@ -86,7 +88,7 @@ function extractMessage(event: Record): NativeTextMessage | und function collectCodexText(content: unknown): string | undefined { if (typeof content === "string") { - return nonEmptyString(content); + return nativeSessionText(content); } if (!Array.isArray(content)) { return undefined; @@ -96,12 +98,12 @@ function collectCodexText(content: unknown): string | undefined { if (!isRecord(part)) { continue; } - const text = nonEmptyString(part.text); + const text = nativeSessionText(part.text); if (text) { parts.push(text); } } - return nonEmptyString(parts.join("\n")); + return parts.length > 0 ? parts.join("\n") : undefined; } export function parseCodexSessionContents(input: { @@ -344,7 +346,7 @@ registerTeleportFormat({ nativePath, parse: parseCodexSessionContents, }); - if (Option.isNone(parsed)) { + if (Option.isNone(parsed) || !isSafeTeleportSessionId(parsed.value.externalSessionId)) { continue; } if (!(yield* teleportCwdsEquivalent(parsed.value.cwd, input.cwd))) { @@ -368,6 +370,12 @@ registerTeleportFormat({ }), write: Effect.fn("writeCodexSession")(function* (input) { const path = yield* Path.Path; + if (!isSafeTeleportSessionId(input.session.externalSessionId)) { + return yield* new TeleportNativeWriteError({ + nativePath: input.homes.codexSessionsRoot, + message: `Refusing to write a Codex session with an unsafe id '${input.session.externalSessionId}'.`, + }); + } const now = yield* DateTime.now; const nativePath = input.existingNativePath ?? diff --git a/apps/server/src/teleport/formats/grok.ts b/apps/server/src/teleport/formats/grok.ts index 25b84f57372d..611d485984ff 100644 --- a/apps/server/src/teleport/formats/grok.ts +++ b/apps/server/src/teleport/formats/grok.ts @@ -20,7 +20,13 @@ import { teleportCwdsEquivalent } from "../cwd.ts"; import { requireNativePathUnlocked } from "../fileLock.ts"; import { registerTeleportFormat } from "./registry.ts"; import { replaceNativeFile } from "../nativeWrite.ts"; -import { firstUserTitle, isRecord, nonEmptyString, parseJsonObject } from "../json.ts"; +import { + firstUserTitle, + isRecord, + isSafeTeleportSessionId, + nonEmptyString, + parseJsonObject, +} from "../json.ts"; import { MAX_TELEPORT_SESSION_BYTES, nativeTextMessage, @@ -43,6 +49,7 @@ export const requireGrokSessionUnlocked = Effect.fn("requireGrokSessionUnlocked" yield* requireNativePathUnlocked(nativePath); yield* requireNativePathUnlocked(path.join(nativePath, SUMMARY_FILE)); yield* requireNativePathUnlocked(path.join(nativePath, UPDATES_FILE)); + yield* requireNativePathUnlocked(path.join(nativePath, CHAT_HISTORY_FILE)); }); export function encodeGrokCwdGroup(cwd: string): string { @@ -133,6 +140,12 @@ export const writeGrokSession = Effect.fn("writeGrokSession")(function* (input: readonly session: ParsedNativeSession; readonly existingNativePath?: string; }): Effect.fn.Return { + if (!isSafeTeleportSessionId(input.session.externalSessionId)) { + return yield* new TeleportNativeWriteError({ + nativePath: input.sessionsRoot, + message: `Refusing to write a Grok session with an unsafe id '${input.session.externalSessionId}'.`, + }); + } const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const nativePath = yield* resolveGrokWritePath({ @@ -549,7 +562,7 @@ registerTeleportFormat({ const sessions = []; for (const nativePath of dirs) { const parsed = yield* readGrokSessionFromDir(nativePath); - if (Option.isNone(parsed)) { + if (Option.isNone(parsed) || !isSafeTeleportSessionId(parsed.value.externalSessionId)) { continue; } if (!(yield* teleportCwdsEquivalent(parsed.value.cwd, input.cwd))) { diff --git a/apps/server/src/teleport/formats/opencode.test.ts b/apps/server/src/teleport/formats/opencode.test.ts index b3bfafa4fdf5..60506cf53291 100644 --- a/apps/server/src/teleport/formats/opencode.test.ts +++ b/apps/server/src/teleport/formats/opencode.test.ts @@ -247,6 +247,47 @@ describe("teleport OpenCode format", () => { ), ); + it.effect("refuses to load a session id that would traverse out of the OpenCode root", () => + readOpenCodeSessionById({ + opencodeRoot: "/tmp/opencode-root", + sessionId: "../../.codex/sessions", + }).pipe( + Effect.provide(NodeServices.layer), + Effect.map((parsed) => { + assert.equal(Option.isNone(parsed), true); + }), + ), + ); + + it.effect("replaces OpenCode json messages instead of leaving stale turns", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-opencode-replace-" }); + const first = sampleTeleportSession("opencode", "/workspace"); + yield* writeOpenCodeSession({ + opencodeRoot: root, + session: first, + }); + const shortened = { + ...first, + messages: first.messages.slice(0, 1), + }; + yield* writeOpenCodeSession({ + opencodeRoot: root, + session: shortened, + }); + const parsed = yield* readOpenCodeSessionById({ + opencodeRoot: root, + sessionId: TELEPORT_TEST_SESSION_ID, + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.messages.length, 1); + assert.equal(parsed.value.messages[0]?.text, first.messages[0]?.text); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it("roundtrips OpenCode resume cursors", () => { assert.equal( readTeleportExternalSessionId({ diff --git a/apps/server/src/teleport/formats/opencode.ts b/apps/server/src/teleport/formats/opencode.ts index 1d12f56f75b2..80954532e48a 100644 --- a/apps/server/src/teleport/formats/opencode.ts +++ b/apps/server/src/teleport/formats/opencode.ts @@ -17,7 +17,14 @@ import * as Path from "effect/Path"; import { opencodeSessionMatchesProjectCwd, teleportCwdsMatch } from "../cwd.ts"; import { requireNativePathUnlocked } from "../fileLock.ts"; -import { firstUserTitle, isRecord, nonEmptyString, parseJsonObject } from "../json.ts"; +import { + firstUserTitle, + isRecord, + isSafeTeleportSessionId, + nativeSessionText, + nonEmptyString, + parseJsonObject, +} from "../json.ts"; import { registerTeleportFormat } from "./registry.ts"; import { nativeTextMessage, @@ -141,10 +148,14 @@ export const readOpenCodeSessionById = Effect.fn("readOpenCodeSessionById")(func TeleportSchemaVersionError, FileSystem.FileSystem | Path.Path > { + if (!isSafeTeleportSessionId(input.sessionId)) { + return Option.none(); + } const sqlite = yield* readOpenCodeSqliteSessions({ opencodeRoot: input.opencodeRoot, cwd: "", pathsMatch: () => true, + sessionId: input.sessionId, }); const fromSqlite = sqlite.find((session) => session.externalSessionId === input.sessionId); if (fromSqlite) { @@ -159,6 +170,7 @@ const readOpenCodeSqliteSessions = Effect.fn("readOpenCodeSqliteSessions")(funct readonly opencodeRoot: string; readonly cwd: string; readonly pathsMatch: (left: string, right: string) => boolean; + readonly sessionId?: string; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -172,28 +184,46 @@ const readOpenCodeSqliteSessions = Effect.fn("readOpenCodeSqliteSessions")(funct try { const db = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); try { - const rows = db - .prepare( - ` - SELECT - s.id, - s.directory, - s.title, - s.time_created, - s.time_updated - FROM session s - WHERE s.time_archived IS NULL - ORDER BY s.time_updated DESC - LIMIT 1000 - `, - ) - .all() as ReadonlyArray>; + const rows = ( + input.sessionId === undefined + ? db + .prepare( + ` + SELECT + s.id, + s.directory, + s.title, + s.time_created, + s.time_updated + FROM session s + WHERE s.time_archived IS NULL + ORDER BY s.time_updated DESC + `, + ) + .all() + : db + .prepare( + ` + SELECT + s.id, + s.directory, + s.title, + s.time_created, + s.time_updated + FROM session s + WHERE s.time_archived IS NULL + AND s.id = ? + ORDER BY s.time_updated DESC + `, + ) + .all(input.sessionId) + ) as ReadonlyArray>; const sessions: ParsedNativeSession[] = []; for (const row of rows) { const id = nonEmptyString(row.id); const cwd = nonEmptyString(row.directory); - if (!id || !cwd) { + if (!id || !cwd || !isSafeTeleportSessionId(id)) { continue; } const messageRows = db @@ -296,7 +326,7 @@ const readOpenCodeJsonSessions = Effect.fn("readOpenCodeJsonSessions")(function* } const id = nonEmptyString(parsed.id); const cwd = nonEmptyString(parsed.directory) ?? nonEmptyString(parsed.cwd); - if (!id || !cwd) { + if (!id || !cwd || !isSafeTeleportSessionId(id)) { continue; } const messages = yield* readOpenCodeJsonMessages(opencodeRoot, id); @@ -328,6 +358,9 @@ const readOpenCodeJsonMessages = Effect.fn("readOpenCodeJsonMessages")(function* opencodeRoot: string, sessionId: string, ) { + if (!isSafeTeleportSessionId(sessionId)) { + return [] as NativeTextMessage[]; + } const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const messageRoot = path.join(opencodeRoot, "storage", "message", sessionId); @@ -371,22 +404,33 @@ const readOpenCodePartText = Effect.fn("readOpenCodePartText")(function* ( const path = yield* Path.Path; const partRoot = path.join(opencodeRoot, "storage", "part", messageId); const files = yield* walkJson(partRoot); - const texts: string[] = []; + const parts: Array<{ readonly created: number; readonly name: string; readonly text: string }> = + []; for (const filePath of files) { const contents = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); const parsed = parseJsonObject(contents); const text = textFromPartData(parsed ?? contents); - if (text) { - texts.push(text); + if (!text) { + continue; } + const createdRaw = isRecord(parsed?.time) ? parsed.time.created : parsed?.time_created; + const created = typeof createdRaw === "number" && Number.isFinite(createdRaw) ? createdRaw : 0; + parts.push({ created, name: filePath, text }); } - return nonEmptyString(texts.join("\n")); + parts.sort((left, right) => left.created - right.created || left.name.localeCompare(right.name)); + return nativeSessionText(parts.map((part) => part.text).join("\n")); }); export const writeOpenCodeSession = Effect.fn("writeOpenCodeSession")(function* (input: { readonly opencodeRoot: string; readonly session: ParsedNativeSession; }): Effect.fn.Return { + if (!isSafeTeleportSessionId(input.session.externalSessionId)) { + return yield* new TeleportNativeWriteError({ + nativePath: input.opencodeRoot, + message: `Refusing to write an OpenCode session with an unsafe id '${input.session.externalSessionId}'.`, + }); + } const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const sessionDir = path.join(input.opencodeRoot, "storage", "session", "t3"); @@ -433,6 +477,18 @@ export const writeOpenCodeSession = Effect.fn("writeOpenCodeSession")(function* "message", input.session.externalSessionId, ); + const existingMessageFiles = yield* walkJson(messageRoot); + for (const filePath of existingMessageFiles) { + const contents = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + const parsed = parseJsonObject(contents); + const messageId = parsed ? nonEmptyString(parsed.id) : undefined; + if (messageId && isSafeTeleportSessionId(messageId)) { + yield* fs + .remove(path.join(input.opencodeRoot, "storage", "part", messageId), { recursive: true }) + .pipe(Effect.orElseSucceed(() => undefined)); + } + } + yield* fs.remove(messageRoot, { recursive: true }).pipe(Effect.orElseSucceed(() => undefined)); yield* fs .makeDirectory(messageRoot, { recursive: true }) .pipe( @@ -498,6 +554,10 @@ const writeOpenCodeSqliteSession = Effect.fn("writeOpenCodeSqliteSession")(funct const db = new NodeSqlite.DatabaseSync(dbPath); try { db.exec("BEGIN"); + db.prepare( + `DELETE FROM part WHERE message_id IN (SELECT id FROM message WHERE session_id = ?)`, + ).run(input.session.externalSessionId); + db.prepare(`DELETE FROM message WHERE session_id = ?`).run(input.session.externalSessionId); db.prepare( ` INSERT OR REPLACE INTO session (id, directory, title, time_created, time_updated) diff --git a/apps/server/src/teleport/json.test.ts b/apps/server/src/teleport/json.test.ts new file mode 100644 index 000000000000..52af07283a86 --- /dev/null +++ b/apps/server/src/teleport/json.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { isSafeTeleportSessionId, nativeSessionText } from "./json.ts"; + +describe("teleport json helpers", () => { + it("keeps leading and trailing whitespace on native session text", () => { + expect(nativeSessionText(" keep this \n")).toBe(" keep this \n"); + expect(nativeSessionText(" ")).toBeUndefined(); + expect(nativeSessionText(1)).toBeUndefined(); + }); + + it("rejects session ids that can traverse out of the native root", () => { + expect(isSafeTeleportSessionId("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee")).toBe(true); + expect(isSafeTeleportSessionId("ses_sample")).toBe(true); + expect(isSafeTeleportSessionId("../.codex/sessions")).toBe(false); + expect(isSafeTeleportSessionId("..\\..\\.codex")).toBe(false); + expect(isSafeTeleportSessionId("..")).toBe(false); + expect(isSafeTeleportSessionId(".")).toBe(false); + }); +}); diff --git a/apps/server/src/teleport/json.ts b/apps/server/src/teleport/json.ts index 1f889c809b21..2077f2a7f41b 100644 --- a/apps/server/src/teleport/json.ts +++ b/apps/server/src/teleport/json.ts @@ -20,6 +20,28 @@ export function nonEmptyString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +/** + * Message text from a native session. Empty-after-trim is skipped, but + * leading/trailing whitespace on real content is preserved so export can + * round-trip the original transcript. + */ +export function nativeSessionText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + return value.trim().length > 0 ? value : undefined; +} + +export function isSafeTeleportSessionId(value: string): boolean { + if (value.length === 0 || value.length > 200) { + return false; + } + if (value.includes("\0") || value.includes("/") || value.includes("\\")) { + return false; + } + return value !== "." && value !== ".."; +} + export function parseJsonObject(raw: string): Record | undefined { let parsed: unknown; try { From 77fbd25d6b621342eaf76dccbb82c424b03cd1ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 05:04:36 +0000 Subject: [PATCH 18/51] refactor(teleport): collocate TeleportService in one module Macroscope expects new Effect services as a single canonical module with the tag, make, and layer together. Inline dispatch-error construction at the failure boundary instead of a curried helper. Co-authored-by: aanishbhirud --- apps/server/src/server.test.ts | 2 +- apps/server/src/server.ts | 4 +- .../src/teleport/Services/TeleportService.ts | 30 ----- .../teleport/{Layers => }/TeleportService.ts | 118 +++++++++++++----- apps/server/src/ws.ts | 2 +- 5 files changed, 94 insertions(+), 62 deletions(-) delete mode 100644 apps/server/src/teleport/Services/TeleportService.ts rename apps/server/src/teleport/{Layers => }/TeleportService.ts (88%) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 84a15c88b509..89d32f4ba11f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -121,7 +121,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; -import { TeleportService } from "./teleport/Services/TeleportService.ts"; +import { TeleportService } from "./teleport/TeleportService.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b6a5c870c7e5..d559a5d12445 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -43,7 +43,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; -import { TeleportServiceLive } from "./teleport/Layers/TeleportService.ts"; +import * as TeleportService from "./teleport/TeleportService.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -417,7 +417,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( const RuntimeDependenciesLive = Layer.mergeAll( RuntimeCoreDependenciesLive, - TeleportServiceLive.pipe(Layer.provide(RuntimeCoreDependenciesLive)), + TeleportService.layer.pipe(Layer.provide(RuntimeCoreDependenciesLive)), ).pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), diff --git a/apps/server/src/teleport/Services/TeleportService.ts b/apps/server/src/teleport/Services/TeleportService.ts deleted file mode 100644 index 34f674c732fd..000000000000 --- a/apps/server/src/teleport/Services/TeleportService.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { - TeleportExportError, - TeleportExportSessionInput, - TeleportExportSessionResult, - TeleportImportError, - TeleportImportSessionsInput, - TeleportImportSessionsResult, - TeleportListSessionsError, - TeleportListSessionsInput, - TeleportListSessionsResult, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export class TeleportService extends Context.Service< - TeleportService, - { - readonly listSessions: ( - input: TeleportListSessionsInput, - ) => Effect.Effect; - - readonly importSessions: ( - input: TeleportImportSessionsInput, - ) => Effect.Effect; - - readonly exportSession: ( - input: TeleportExportSessionInput, - ) => Effect.Effect; - } ->()("t3/teleport/Services/TeleportService") {} diff --git a/apps/server/src/teleport/Layers/TeleportService.ts b/apps/server/src/teleport/TeleportService.ts similarity index 88% rename from apps/server/src/teleport/Layers/TeleportService.ts rename to apps/server/src/teleport/TeleportService.ts index 8b22132ef88f..4c8163d867f1 100644 --- a/apps/server/src/teleport/Layers/TeleportService.ts +++ b/apps/server/src/teleport/TeleportService.ts @@ -20,13 +20,20 @@ import { resolveTeleportPresence, type ModelSelection, type OrchestrationMessage, + type TeleportExportError, type TeleportExportSessionInput, + type TeleportExportSessionResult, type TeleportImportedSession, + type TeleportImportError, type TeleportImportSessionsInput, + type TeleportImportSessionsResult, + type TeleportListSessionsError, type TeleportListSessionsInput, + type TeleportListSessionsResult, type TeleportProvider, type TeleportRuntimePayload, } from "@t3tools/contracts"; +import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -36,33 +43,49 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import * as OrchestrationEngine from "../../orchestration/Services/OrchestrationEngine.ts"; -import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; -import { ProviderService } from "../../provider/Services/ProviderService.ts"; -import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; -import * as ServerSettings from "../../serverSettings.ts"; -import { resolveTeleportCwdPath, teleportCwdsEquivalent } from "../cwd.ts"; -import { discoverTeleportSessions, loadTeleportSession } from "../discovery.ts"; -import { getTeleportFormat } from "../formats/registry.ts"; -import "../formats/register.ts"; -import { resolveTeleportHomes, type TeleportHomes } from "../homes.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; +import { ProviderService } from "../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { resolveTeleportCwdPath, teleportCwdsEquivalent } from "./cwd.ts"; +import { discoverTeleportSessions, loadTeleportSession } from "./discovery.ts"; +import { getTeleportFormat } from "./formats/registry.ts"; +import "./formats/register.ts"; +import { resolveTeleportHomes, type TeleportHomes } from "./homes.ts"; import { buildTeleportResumeCursor, readTeleportExternalSessionId, readTeleportRuntimePayload, teleportThreadStateFromPayload, toTeleportProvider, -} from "../resumeCursors.ts"; -import { firstUserTitle, truncateTitle } from "../json.ts"; +} from "./resumeCursors.ts"; +import { firstUserTitle, truncateTitle } from "./json.ts"; import { MAX_TELEPORT_MESSAGE_CHARS, MAX_TELEPORT_MESSAGES, nativeTextMessage, type NativeTextMessage, type ParsedNativeSession, -} from "../types.ts"; -import { TeleportService } from "../Services/TeleportService.ts"; +} from "./types.ts"; + +export class TeleportService extends Context.Service< + TeleportService, + { + readonly listSessions: ( + input: TeleportListSessionsInput, + ) => Effect.Effect; + + readonly importSessions: ( + input: TeleportImportSessionsInput, + ) => Effect.Effect; + + readonly exportSession: ( + input: TeleportExportSessionInput, + ) => Effect.Effect; + } +>()("t3/teleport/TeleportService") {} function modelSelectionForProvider(provider: TeleportProvider): ModelSelection { const driver = ProviderDriverKind.make(provider); @@ -179,12 +202,6 @@ export const make = Effect.gen(function* () { return Effect.void; }); - const mapDispatchError = (message: string) => (cause: unknown) => - new TeleportInvalidInputError({ - message, - cause, - }); - const requireParsedSessionUnlocked = (parsed: ParsedNativeSession, homes: TeleportHomes) => { const adapter = getTeleportFormat(parsed.provider); if (!adapter) { @@ -399,7 +416,15 @@ export const make = Effect.gen(function* () { messages, createdAt: now, }) - .pipe(Effect.mapError(mapDispatchError("Failed to replace thread history."))); + .pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to replace thread history.", + cause, + }), + ), + ); yield* engine .dispatch({ type: "thread.meta.update", @@ -407,7 +432,15 @@ export const make = Effect.gen(function* () { threadId, title, }) - .pipe(Effect.mapError(mapDispatchError("Failed to update imported thread title."))); + .pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to update imported thread title.", + cause, + }), + ), + ); } else { threadId = ThreadId.make(yield* nextId()); yield* engine @@ -424,7 +457,15 @@ export const make = Effect.gen(function* () { worktreePath: null, createdAt: now, }) - .pipe(Effect.mapError(mapDispatchError("Failed to create an imported thread."))); + .pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to create an imported thread.", + cause, + }), + ), + ); yield* engine .dispatch({ type: "thread.history.replace", @@ -434,7 +475,13 @@ export const make = Effect.gen(function* () { createdAt: now, }) .pipe( - Effect.mapError(mapDispatchError("Failed to write imported thread history.")), + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to write imported thread history.", + cause, + }), + ), ); } @@ -481,7 +528,15 @@ export const make = Effect.gen(function* () { }), createdAt: now, }) - .pipe(Effect.mapError(mapDispatchError("Failed to persist teleport presence."))); + .pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to persist teleport presence.", + cause, + }), + ), + ); imported.push({ threadId, @@ -731,7 +786,15 @@ export const make = Effect.gen(function* () { }), createdAt: now, }) - .pipe(Effect.mapError(mapDispatchError("Failed to persist teleport presence."))); + .pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + message: "Failed to persist teleport presence.", + cause, + }), + ), + ); return { schemaVersion: TELEPORT_SCHEMA_VERSION, @@ -764,4 +827,3 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(TeleportService, make); -export const TeleportServiceLive = layer; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a2005c784f80..b3fe76dc501a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -124,7 +124,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { TeleportService } from "./teleport/Services/TeleportService.ts"; +import { TeleportService } from "./teleport/TeleportService.ts"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); From 9e479d0265b16f7f070fe8de9544db95fea0b4cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 05:09:30 +0000 Subject: [PATCH 19/51] fix(web): hide teleport when the server lacks the capability Older remotes do not implement teleport RPCs. Advertise a teleport capability from current servers and keep the web entry points hidden when it is absent, including the React Native client until it opts in. Co-authored-by: aanishbhirud --- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + apps/web/src/components/CommandPalette.tsx | 131 +++++++++++------- .../src/components/chat/DraftHeroHeadline.tsx | 8 +- .../components/teleport/TeleportOutButton.tsx | 15 +- apps/web/src/lib/teleport.test.ts | 25 ++++ apps/web/src/lib/teleport.ts | 12 +- packages/contracts/src/environment.ts | 5 + 8 files changed, 144 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/lib/teleport.test.ts diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index ee30d987591d..27e05f32b33e 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.teleport).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..32d8680569b8 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -152,6 +152,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + teleport: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2b2b89e422f3..c9ad3c4dd370 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -203,7 +203,12 @@ import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog" import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { isTeleportedOut, teleportFailureMessage, teleportProviderLabel } from "../lib/teleport"; +import { + environmentSupportsTeleport, + isTeleportedOut, + teleportFailureMessage, + teleportProviderLabel, +} from "../lib/teleport"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { teleportEnvironment } from "../state/teleport"; import { @@ -1416,19 +1421,26 @@ function OpenCommandPaletteDialog(props: { const importProjectItems = useMemo( () => - pickerProjects.map((project) => ({ - kind: "action" as const, - value: `import-sessions:${project.environmentId}:${project.id}`, - searchTerms: [project.title, project.workspaceRoot, "import", "teleport"], - title: project.title, - description: project.workspaceRoot, - icon: projectFavicon(project), - keepOpen: true, - run: async () => { - openImportSessionsForProject(project); - }, - })), - [openImportSessionsForProject, pickerProjects], + pickerProjects + .filter((project) => + environmentSupportsTeleport( + environments.find((environment) => environment.environmentId === project.environmentId) + ?.serverConfig?.environment.capabilities, + ), + ) + .map((project) => ({ + kind: "action" as const, + value: `import-sessions:${project.environmentId}:${project.id}`, + searchTerms: [project.title, project.workspaceRoot, "import", "teleport"], + title: project.title, + description: project.workspaceRoot, + icon: projectFavicon(project), + keepOpen: true, + run: async () => { + openImportSessionsForProject(project); + }, + })), + [environments, openImportSessionsForProject, pickerProjects], ); const openImportSessionsFlow = useCallback(() => { @@ -1436,8 +1448,11 @@ function OpenCommandPaletteDialog(props: { toastManager.add( stackedThreadToast({ type: "error", - title: "No projects available", - description: "Add a project before importing native sessions.", + title: pickerProjects.length > 0 ? "Teleport is not available" : "No projects available", + description: + pickerProjects.length > 0 + ? "This server does not support importing native CLI sessions." + : "Add a project before importing native sessions.", }), ); return; @@ -1453,7 +1468,13 @@ function OpenCommandPaletteDialog(props: { ] : importProjectItems; pushPaletteView(importIntoProjectPaletteView(prioritized)); - }, [currentProjectEnvironmentId, currentProjectId, importProjectItems, pushPaletteView]); + }, [ + currentProjectEnvironmentId, + currentProjectId, + importProjectItems, + pickerProjects.length, + pushPaletteView, + ]); const startAddProjectBrowse = useCallback( async (environmentId: EnvironmentId): Promise => { @@ -1767,8 +1788,11 @@ function OpenCommandPaletteDialog(props: { toastManager.add( stackedThreadToast({ type: "error", - title: "No projects available", - description: "Add a project before importing native sessions.", + title: pickerProjects.length > 0 ? "Teleport is not available" : "No projects available", + description: + pickerProjects.length > 0 + ? "This server does not support importing native CLI sessions." + : "Add a project before importing native sessions.", }), ); setOpen(false); @@ -1853,47 +1877,56 @@ function OpenCommandPaletteDialog(props: { ) ?? null) : null; if (boundTeleport && boundImportProject) { + const boundSupportsTeleport = environmentSupportsTeleport( + environments.find( + (environment) => environment.environmentId === boundImportProject.environmentId, + )?.serverConfig?.environment.capabilities, + ); + if (boundSupportsTeleport) { + actionItems.push({ + kind: "action", + value: "action:import-this-thread", + searchTerms: [ + "import this thread", + "teleport in", + "native", + "cli", + teleportProviderLabel(boundTeleport.provider), + ], + title: "Import this thread from native CLI", + icon: , + run: async () => { + await importNativeSession(boundImportProject, { + provider: boundTeleport.provider, + externalSessionId: boundTeleport.externalSessionId, + }); + }, + }); + } + } + + if (importProjectItems.length > 0) { actionItems.push({ kind: "action", - value: "action:import-this-thread", + value: "action:import-sessions", searchTerms: [ - "import this thread", - "teleport in", + "import sessions", + "teleport", + "codex", + "claude", + "opencode", + "grok", "native", "cli", - teleportProviderLabel(boundTeleport.provider), ], - title: "Import this thread from native CLI", + title: "Import sessions...", icon: , + keepOpen: true, run: async () => { - await importNativeSession(boundImportProject, { - provider: boundTeleport.provider, - externalSessionId: boundTeleport.externalSessionId, - }); + openImportSessionsFlow(); }, }); } - - actionItems.push({ - kind: "action", - value: "action:import-sessions", - searchTerms: [ - "import sessions", - "teleport", - "codex", - "claude", - "opencode", - "grok", - "native", - "cli", - ], - title: "Import sessions...", - icon: , - keepOpen: true, - run: async () => { - openImportSessionsFlow(); - }, - }); } actionItems.push({ diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 8718522bebcf..ff5e83da926a 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -4,6 +4,7 @@ import { FolderPlusIcon, ImportIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; +import { environmentSupportsTeleport } from "~/lib/teleport"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useClientSettings } from "~/hooks/useSettings"; import { selectProjectGroupingSettings } from "~/logicalProject"; @@ -107,6 +108,11 @@ export function DraftHeroHeadline({ const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; + const teleportEnvironmentId = activeProjectRef?.environmentId ?? primaryEnvironmentId; + const supportsTeleport = environmentSupportsTeleport( + environments.find((environment) => environment.environmentId === teleportEnvironmentId) + ?.serverConfig?.environment.capabilities, + ); const projectSelector = shouldShowProjectMenu ? ( @@ -172,7 +178,7 @@ export function DraftHeroHeadline({ <>Add a project to start )} - {hasResolvedProject ? ( + {hasResolvedProject && supportsTeleport ? (