diff --git a/apps/macos/Sources/OpenClaw/CockpitData.swift b/apps/macos/Sources/OpenClaw/CockpitData.swift index 50dc049f8..07f1fd703 100644 --- a/apps/macos/Sources/OpenClaw/CockpitData.swift +++ b/apps/macos/Sources/OpenClaw/CockpitData.swift @@ -187,6 +187,18 @@ struct CockpitLaneSummary: Codable, Identifiable, Sendable { } } +struct CockpitTerminalLane: Codable, Identifiable, Sendable { + let id: String + let repoRoot: String + let worktreePath: String? + let backendProfile: String? + let workerId: String? + let status: String + let title: String? + let createdAt: String + let updatedAt: String +} + struct CockpitWorkerLogs: Codable, Sendable { let workerId: String let latestRun: CockpitRunSummary? @@ -214,6 +226,7 @@ struct CockpitWorkspaceSummary: Codable, Sendable { let pendingReviews: [CockpitReviewSummary] let recentRuns: [CockpitRunSummary] let activeLanes: [CockpitLaneSummary] + let terminalLanes: [CockpitTerminalLane] } extension CockpitGatewayStatus { @@ -418,6 +431,18 @@ extension CockpitWorkspaceSummary { terminationReason: "paused", updatedAt: "2026-03-19T12:50:00.000Z"), pendingReview: nil), + ], + terminalLanes: [ + CockpitTerminalLane( + id: "tl_shell", + repoRoot: "/Users/tessaro/openclaw", + worktreePath: "/Users/tessaro/openclaw/.worktrees/code/shell-lane", + backendProfile: "codex-cli", + workerId: "worker_shell", + status: "open", + title: "shell-lane", + createdAt: "2026-03-19T12:56:00.000Z", + updatedAt: "2026-03-19T12:58:00.000Z"), ]) } diff --git a/src/cli/code-remote-timeout.test.ts b/src/cli/code-remote-timeout.test.ts index bce7fe769..978ed9656 100644 --- a/src/cli/code-remote-timeout.test.ts +++ b/src/cli/code-remote-timeout.test.ts @@ -68,6 +68,7 @@ describe("code remote gateway timeout", () => { retryBackoffCount: 0, recentRuns: [], activeLanes: [], + terminalLanes: [], generatedAt: "2026-03-19T00:00:00.000Z", }); diff --git a/src/code-cockpit/gateway-handlers.test.ts b/src/code-cockpit/gateway-handlers.test.ts index 6f6c0c60d..a8735c3a2 100644 --- a/src/code-cockpit/gateway-handlers.test.ts +++ b/src/code-cockpit/gateway-handlers.test.ts @@ -130,6 +130,7 @@ const runtimeMethods = vi.hoisted(() => ({ retryBackoffCount: 0, recentRuns: [], activeLanes: [], + terminalLanes: [], })), }, getCodeCockpitRuntime: vi.fn(), diff --git a/src/code-cockpit/runtime.ts b/src/code-cockpit/runtime.ts index d1288aed8..ae2b0232b 100644 --- a/src/code-cockpit/runtime.ts +++ b/src/code-cockpit/runtime.ts @@ -18,15 +18,23 @@ import type { ProcessSupervisor, RunExit, SpawnInput } from "../process/supervis import { type CreateCodeReviewRequestInput, type CreateCodeTaskInput, + type CreateCodeTerminalLaneInput, + type UpdateCodeTerminalLaneInput, createCodeTask, createCodeReviewRequest, createCodeRun, + createCodeTerminalLane, createCodeWorkerSession, getCodeTask, getCodeCockpitWorkspaceSummary, getCodeRun, + getCodeTerminalLane, getCodeWorkerSession, + listCodeTerminalLanes, loadCodeCockpitStore, + removeCodeTerminalLane, + updateCodeTerminalLane, + type CodeTerminalLane, type CodeWorkerAuthHealth, type CodeWorkerEngineId, type CodePullRequestState, @@ -1744,6 +1752,35 @@ class CodeCockpitRuntime { await this.ensureInitialized(); return await getCodeCockpitWorkspaceSummary(); } + + async addTerminalLane(input: CreateCodeTerminalLaneInput): Promise { + await this.ensureInitialized(); + return await createCodeTerminalLane(input); + } + + async updateTerminalLane(params: { + laneId: string; + patch: UpdateCodeTerminalLaneInput; + }): Promise { + await this.ensureInitialized(); + return await updateCodeTerminalLane(params.laneId, params.patch); + } + + async listTerminalLanes(): Promise<{ terminalLanes: CodeTerminalLane[] }> { + await this.ensureInitialized(); + return { terminalLanes: await listCodeTerminalLanes() }; + } + + async showTerminalLane(params: { laneId: string }): Promise { + await this.ensureInitialized(); + return await getCodeTerminalLane(params.laneId); + } + + async removeTerminalLane(params: { laneId: string }): Promise<{ removed: true }> { + await this.ensureInitialized(); + await removeCodeTerminalLane(params.laneId); + return { removed: true }; + } } let singleton: CodeCockpitRuntime | null = null; diff --git a/src/code-cockpit/store.test.ts b/src/code-cockpit/store.test.ts index 08f91d285..7f4859641 100644 --- a/src/code-cockpit/store.test.ts +++ b/src/code-cockpit/store.test.ts @@ -415,4 +415,137 @@ describe("code cockpit store", () => { ]), ); }); + + it("creates and persists terminal lanes bound to worktrees", async () => { + const storeModule = await importStoreModule(); + const lane = await storeModule.createCodeTerminalLane({ + repoRoot: "/tmp/openclaw", + worktreePath: "/tmp/openclaw/.worktrees/code/feature-a", + backendProfile: "codex-cli", + title: "feature-a", + }); + + expect(lane).toMatchObject({ + repoRoot: "/tmp/openclaw", + worktreePath: "/tmp/openclaw/.worktrees/code/feature-a", + backendProfile: "codex-cli", + status: "open", + title: "feature-a", + }); + expect(lane.id).toMatch(/^tl_/); + + const store = await storeModule.loadCodeCockpitStore(); + expect(store.terminalLanes).toEqual( + expect.arrayContaining([expect.objectContaining({ id: lane.id })]), + ); + }); + + it("links a terminal lane to an existing worker", async () => { + const storeModule = await importStoreModule(); + const task = await storeModule.createCodeTask({ + title: "Worker-linked lane", + repoRoot: "/tmp/openclaw", + }); + const worker = await storeModule.createCodeWorkerSession({ + taskId: task.id, + name: "lane-worker", + repoRoot: "/tmp/openclaw", + worktreePath: "/tmp/openclaw/.worktrees/code/lane-worker", + }); + const lane = await storeModule.createCodeTerminalLane({ + repoRoot: "/tmp/openclaw", + worktreePath: worker.worktreePath, + workerId: worker.id, + }); + + expect(lane.workerId).toBe(worker.id); + expect(lane.worktreePath).toBe(worker.worktreePath); + }); + + it("rejects terminal lane creation with invalid worker id", async () => { + const storeModule = await importStoreModule(); + await expect( + storeModule.createCodeTerminalLane({ + repoRoot: "/tmp/openclaw", + workerId: "worker_nonexistent", + }), + ).rejects.toThrow(/not found/); + }); + + it("updates terminal lane fields including worktree rebinding", async () => { + const storeModule = await importStoreModule(); + const lane = await storeModule.createCodeTerminalLane({ + repoRoot: "/tmp/openclaw", + worktreePath: "/tmp/openclaw/.worktrees/code/old-wt", + title: "old-title", + }); + + const updated = await storeModule.updateCodeTerminalLane(lane.id, { + worktreePath: "/tmp/openclaw/.worktrees/code/new-wt", + backendProfile: "claude-cli", + title: "new-title", + }); + + expect(updated.worktreePath).toBe("/tmp/openclaw/.worktrees/code/new-wt"); + expect(updated.backendProfile).toBe("claude-cli"); + expect(updated.title).toBe("new-title"); + }); + + it("closes a terminal lane via status update", async () => { + const storeModule = await importStoreModule(); + const lane = await storeModule.createCodeTerminalLane({ + repoRoot: "/tmp/openclaw", + }); + expect(lane.status).toBe("open"); + + const closed = await storeModule.updateCodeTerminalLane(lane.id, { status: "closed" }); + expect(closed.status).toBe("closed"); + }); + + it("lists terminal lanes sorted by most recently updated", async () => { + const storeModule = await importStoreModule(); + const older = await storeModule.createCodeTerminalLane({ + repoRoot: "/tmp/openclaw", + title: "older", + }); + const newer = await storeModule.createCodeTerminalLane({ + repoRoot: "/tmp/openclaw", + title: "newer", + }); + + const lanes = await storeModule.listCodeTerminalLanes(); + expect(lanes.length).toBe(2); + expect(lanes[0].id).toBe(newer.id); + expect(lanes[1].id).toBe(older.id); + }); + + it("removes a terminal lane from the store", async () => { + const storeModule = await importStoreModule(); + const lane = await storeModule.createCodeTerminalLane({ + repoRoot: "/tmp/openclaw", + }); + + await storeModule.removeCodeTerminalLane(lane.id); + + const lanes = await storeModule.listCodeTerminalLanes(); + expect(lanes).toHaveLength(0); + }); + + it("includes open terminal lanes in the workspace summary", async () => { + const storeModule = await importStoreModule(); + const open = await storeModule.createCodeTerminalLane({ + repoRoot: "/tmp/openclaw", + worktreePath: "/tmp/openclaw/.worktrees/code/a", + title: "open-lane", + }); + const closed = await storeModule.createCodeTerminalLane({ + repoRoot: "/tmp/openclaw", + title: "closed-lane", + }); + await storeModule.updateCodeTerminalLane(closed.id, { status: "closed" }); + + const summary = await storeModule.getCodeCockpitWorkspaceSummary(); + expect(summary.terminalLanes).toHaveLength(1); + expect(summary.terminalLanes[0].id).toBe(open.id); + }); }); diff --git a/src/code-cockpit/store.ts b/src/code-cockpit/store.ts index 90f8d744f..05c2bdcf7 100644 --- a/src/code-cockpit/store.ts +++ b/src/code-cockpit/store.ts @@ -50,6 +50,8 @@ export const CODE_REVIEW_STATUSES = [ "dismissed", ] as const; +export const CODE_TERMINAL_LANE_STATUSES = ["open", "closed"] as const; + export const CODE_CONTEXT_SNAPSHOT_KINDS = ["repo", "obsidian", "brief", "handoff"] as const; export const CODE_RUN_STATUSES = ["queued", "running", "succeeded", "failed", "cancelled"] as const; @@ -62,6 +64,7 @@ export type CodeWorkerEngineId = (typeof CODE_WORKER_ENGINE_IDS)[number]; export type CodeWorkerAuthHealth = (typeof CODE_WORKER_AUTH_HEALTHS)[number]; export type CodePullRequestState = (typeof CODE_PULL_REQUEST_STATES)[number]; export type CodeReviewStatus = (typeof CODE_REVIEW_STATUSES)[number]; +export type CodeTerminalLaneStatus = (typeof CODE_TERMINAL_LANE_STATUSES)[number]; export type CodeContextSnapshotKind = (typeof CODE_CONTEXT_SNAPSHOT_KINDS)[number]; export type CodeRunStatus = (typeof CODE_RUN_STATUSES)[number]; @@ -170,6 +173,18 @@ export type CodeRun = { updatedAt: string; }; +export type CodeTerminalLane = { + id: string; + repoRoot: string; + worktreePath?: string; + backendProfile?: string; + workerId?: string; + status: CodeTerminalLaneStatus; + title?: string; + createdAt: string; + updatedAt: string; +}; + export type CodeCockpitStore = { version: number; updatedAt: string; @@ -179,6 +194,7 @@ export type CodeCockpitStore = { decisions: CodeDecisionLog[]; contextSnapshots: CodeContextSnapshot[]; runs: CodeRun[]; + terminalLanes: CodeTerminalLane[]; }; export type CodeCockpitSummary = { @@ -227,6 +243,7 @@ export type CodeCockpitWorkspaceSummary = CodeCockpitSummary & { retryBackoffCount: number; recentRuns: CodeRun[]; activeLanes: CodeCockpitLaneSummary[]; + terminalLanes: CodeTerminalLane[]; }; export type CodeResolvedReviewResult = { @@ -367,6 +384,22 @@ export type UpdateCodeRunInput = { stderrLogPath?: string | null; }; +export type CreateCodeTerminalLaneInput = { + repoRoot: string; + worktreePath?: string; + backendProfile?: string; + workerId?: string; + title?: string; +}; + +export type UpdateCodeTerminalLaneInput = { + worktreePath?: string | null; + backendProfile?: string | null; + workerId?: string | null; + title?: string | null; + status?: CodeTerminalLaneStatus; +}; + const TASK_TRANSITIONS: Record = { queued: ["planning", "in_progress", "blocked", "cancelled"], planning: ["queued", "in_progress", "blocked", "cancelled"], @@ -415,6 +448,7 @@ function createEmptyStore(updatedAt: string): CodeCockpitStore { decisions: [], contextSnapshots: [], runs: [], + terminalLanes: [], }; } @@ -469,6 +503,7 @@ function normalizeStore( decisions: Array.isArray(candidate.decisions) ? candidate.decisions : [], contextSnapshots: Array.isArray(candidate.contextSnapshots) ? candidate.contextSnapshots : [], runs: Array.isArray(candidate.runs) ? candidate.runs : [], + terminalLanes: Array.isArray(candidate.terminalLanes) ? candidate.terminalLanes : [], }; } @@ -598,6 +633,23 @@ function assertRunStatus(value: string): CodeRunStatus { ); } +function assertTerminalLaneStatus(value: string): CodeTerminalLaneStatus { + if ((CODE_TERMINAL_LANE_STATUSES as readonly string[]).includes(value)) { + return value as CodeTerminalLaneStatus; + } + throw new Error( + `Invalid terminal lane status "${value}". Expected one of: ${CODE_TERMINAL_LANE_STATUSES.join(", ")}`, + ); +} + +function findTerminalLane(store: CodeCockpitStore, laneId: string): CodeTerminalLane { + const lane = store.terminalLanes.find((entry) => entry.id === laneId); + if (!lane) { + throw new Error(`Terminal lane "${laneId}" not found`); + } + return lane; +} + function assertTransition( entityName: string, transitions: Record, @@ -1215,6 +1267,94 @@ export async function updateCodeRun( }); } +export async function createCodeTerminalLane( + input: CreateCodeTerminalLaneInput, + options?: CodeCockpitStoreOptions, +): Promise { + if (!normalizeString(input.repoRoot)) { + throw new Error("Terminal lane repoRoot is required"); + } + return await mutateStore(options, (store, updatedAt) => { + if (input.workerId) { + findWorker(store, input.workerId); + } + const lane: CodeTerminalLane = { + id: createId("tl"), + repoRoot: normalizeString(input.repoRoot)!, + worktreePath: normalizeString(input.worktreePath), + backendProfile: normalizeString(input.backendProfile), + workerId: normalizeString(input.workerId), + status: "open", + title: normalizeString(input.title), + createdAt: updatedAt, + updatedAt, + }; + store.terminalLanes.push(lane); + return lane; + }); +} + +export async function updateCodeTerminalLane( + laneId: string, + patch: UpdateCodeTerminalLaneInput, + options?: CodeCockpitStoreOptions, +): Promise { + return await mutateStore(options, (store, updatedAt) => { + const lane = findTerminalLane(store, laneId); + if (patch.status !== undefined) { + lane.status = assertTerminalLaneStatus(patch.status); + } + const worktreePath = normalizePatchString(patch.worktreePath); + if (worktreePath !== undefined) { + lane.worktreePath = worktreePath ?? undefined; + } + const backendProfile = normalizePatchString(patch.backendProfile); + if (backendProfile !== undefined) { + lane.backendProfile = backendProfile ?? undefined; + } + if (patch.workerId !== undefined) { + if (patch.workerId) { + findWorker(store, patch.workerId); + } + lane.workerId = patch.workerId ?? undefined; + } + const title = normalizePatchString(patch.title); + if (title !== undefined) { + lane.title = title ?? undefined; + } + lane.updatedAt = updatedAt; + return lane; + }); +} + +export async function listCodeTerminalLanes( + options?: CodeCockpitStoreOptions, +): Promise { + const store = await loadCodeCockpitStore(options); + return sortByUpdatedAt(store.terminalLanes); +} + +export async function getCodeTerminalLane( + laneId: string, + options?: CodeCockpitStoreOptions, +): Promise { + const store = await loadCodeCockpitStore(options); + return findTerminalLane(store, laneId); +} + +export async function removeCodeTerminalLane( + laneId: string, + options?: CodeCockpitStoreOptions, +): Promise { + await mutateStore(options, (store) => { + const index = store.terminalLanes.findIndex((entry) => entry.id === laneId); + if (index === -1) { + throw new Error(`Terminal lane "${laneId}" not found`); + } + store.terminalLanes.splice(index, 1); + }); +} + export async function getCodeTask( taskId: string, options?: CodeCockpitStoreOptions, @@ -1334,5 +1474,6 @@ export async function getCodeCockpitWorkspaceSummary( retryBackoffCount: store.tasks.filter((task) => isTaskInRetryBackoff(task, now)).length, recentRuns: sortByUpdatedAt(store.runs).slice(0, 8), activeLanes, + terminalLanes: sortByUpdatedAt(store.terminalLanes).filter((tl) => tl.status === "open"), }; } diff --git a/src/code-cockpit/tui.test.ts b/src/code-cockpit/tui.test.ts index 0d761c78d..3e87ff337 100644 --- a/src/code-cockpit/tui.test.ts +++ b/src/code-cockpit/tui.test.ts @@ -105,6 +105,7 @@ function makeSummary(): CodeCockpitWorkspaceSummary { }, ], activeLanes: [], + terminalLanes: [], }; } diff --git a/src/gateway/server-methods/code-cockpit.ts b/src/gateway/server-methods/code-cockpit.ts index 73f9f1e1f..373229069 100644 --- a/src/gateway/server-methods/code-cockpit.ts +++ b/src/gateway/server-methods/code-cockpit.ts @@ -253,4 +253,60 @@ export const codeCockpitHandlers: GatewayRequestHandlers = { }), ); }, + "code.terminal-lane.add": async ({ params, respond }) => { + await withRuntimeResult( + respond, + async () => + await getCodeCockpitRuntime().addTerminalLane({ + repoRoot: requireTitle(params.repoRoot, "repoRoot"), + worktreePath: optionalString(params.worktreePath), + backendProfile: optionalString(params.backendProfile), + workerId: optionalString(params.workerId), + title: optionalString(params.title), + }), + ); + }, + "code.terminal-lane.list": async ({ respond }) => { + await withRuntimeResult(respond, async () => await getCodeCockpitRuntime().listTerminalLanes()); + }, + "code.terminal-lane.show": async ({ params, respond }) => { + await withRuntimeResult( + respond, + async () => + await getCodeCockpitRuntime().showTerminalLane({ + laneId: requireTitle(params.laneId, "laneId"), + }), + ); + }, + "code.terminal-lane.update": async ({ params, respond }) => { + await withRuntimeResult(respond, async () => { + const laneId = requireTitle(params.laneId, "laneId"); + const patch: Record = {}; + if (params.worktreePath !== undefined) { + patch.worktreePath = params.worktreePath; + } + if (params.backendProfile !== undefined) { + patch.backendProfile = params.backendProfile; + } + if (params.workerId !== undefined) { + patch.workerId = params.workerId; + } + if (params.title !== undefined) { + patch.title = params.title; + } + if (params.status !== undefined) { + patch.status = params.status; + } + return await getCodeCockpitRuntime().updateTerminalLane({ laneId, patch }); + }); + }, + "code.terminal-lane.remove": async ({ params, respond }) => { + await withRuntimeResult( + respond, + async () => + await getCodeCockpitRuntime().removeTerminalLane({ + laneId: requireTitle(params.laneId, "laneId"), + }), + ); + }, };