From 637326dfab7b6b92259017ad5da7222d7bfd1cc6 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 12:57:35 -0600 Subject: [PATCH 01/17] docs(agents): add note about not replacing the opencode string --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1787c21..ade1578 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ Guide for coding agents working on `opencode-mission-control` in ULW mode. +> NOTE! Never replace the string opencode with claude or claude code unless explicitly asked to. + ## Mission - Keep changes small, safe, and shippable. From 5134240803576383975cc20350ec3c9234d471ed Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 13:00:00 -0600 Subject: [PATCH 02/17] chore: add @opencode-ai/sdk dependency for serve-based orchestration --- bun.lock | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index 3781f42..e186d85 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "opencode-mission-control", "dependencies": { "@opencode-ai/plugin": "^1.0.85", + "@opencode-ai/sdk": "^1.1.53", "zod": "^4.3.6", }, "devDependencies": { diff --git a/package.json b/package.json index d5ccb6c..e8553e4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@opencode-ai/plugin": "^1.0.85", + "@opencode-ai/sdk": "^1.1.53", "zod": "^4.3.6" }, "devDependencies": { From 9578fd9120404a8422e82e33a855c208aa33f303 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 13:09:25 -0600 Subject: [PATCH 03/17] fix: route notifications to launching session instead of active session (#74) --- src/hooks/notifications.ts | 2 +- src/index.ts | 4 +- src/lib/job-state.ts | 23 ++++- src/lib/orchestrator.ts | 11 +- src/lib/plan-types.ts | 1 + src/lib/schemas.ts | 4 +- src/tools/launch.ts | 1 + src/tools/plan.ts | 1 + tests/lib/job-state.test.ts | 197 ++++++++++++++++++++++++++++++++++-- 9 files changed, 227 insertions(+), 17 deletions(-) diff --git a/src/hooks/notifications.ts b/src/hooks/notifications.ts index 9ec3073..bf076b1 100644 --- a/src/hooks/notifications.ts +++ b/src/hooks/notifications.ts @@ -58,7 +58,7 @@ export function setupNotifications(options: SetupNotificationsOptions): void { // If detection fails, continue sending (safer default) } - const sessionID = await getActiveSessionID(); + const sessionID = job.launchSessionID ?? await getActiveSessionID(); if (!sessionID || !sessionID.startsWith('ses')) { return; } diff --git a/src/index.ts b/src/index.ts index b79f031..8939d62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -175,9 +175,9 @@ export const MissionControl: Plugin = async ({ client }) => { let notifyPending: Promise = Promise.resolve(); if (!isJobAgent) { - setSharedNotifyCallback((message: string) => { + setSharedNotifyCallback((message: string, targetSessionID?: string) => { notifyPending = notifyPending.then(async () => { - const sessionID = await getActiveSessionID(); + const sessionID = targetSessionID ?? await getActiveSessionID(); if (!sessionID || !sessionID.startsWith('ses')) return; await client.session.prompt({ path: { id: sessionID }, diff --git a/src/lib/job-state.ts b/src/lib/job-state.ts index 2ec90a3..bd30f9d 100644 --- a/src/lib/job-state.ts +++ b/src/lib/job-state.ts @@ -10,6 +10,16 @@ import { atomicWrite } from './utils'; export type Job = z.infer; export type JobState = z.infer; +export function migrateV2ToV3(state: Record): JobState { + const jobs = (state.jobs as Job[]) ?? []; + const migrated = { + version: 3 as const, + jobs: jobs.map((job) => ({ ...job, launchSessionID: job.launchSessionID ?? undefined })), + updatedAt: (state.updatedAt as string) ?? new Date().toISOString(), + }; + return JobStateSchema.parse(migrated); +} + export function migrateJobState(state: Record): JobState { const version = (state.version as number) ?? 1; @@ -20,7 +30,12 @@ export function migrateJobState(state: Record): JobState { jobs: jobs.map((job) => ({ ...job, planId: job.planId ?? undefined })), updatedAt: (state.updatedAt as string) ?? new Date().toISOString(), }; - return JobStateSchema.parse(migrated); + // Continue to v3 migration + return migrateV2ToV3(migrated as unknown as Record); + } + + if (version < 3) { + return migrateV2ToV3(state); } return JobStateSchema.parse(state); @@ -61,7 +76,7 @@ export async function loadJobState(): Promise { if (!exists) { return { - version: 2, + version: 3, jobs: [], updatedAt: new Date().toISOString(), }; @@ -70,7 +85,7 @@ export async function loadJobState(): Promise { try { const content = await file.text(); const parsed = JSON.parse(content); - if (!parsed.version || parsed.version < 2) { + if (!parsed.version || parsed.version < 3) { return migrateJobState(parsed); } return JobStateSchema.parse(parsed); @@ -86,7 +101,7 @@ export async function saveJobState(state: JobState): Promise { const filePath = await getStateFilePath(); const updatedState: JobState = { ...state, - version: 2, + version: 3, updatedAt: new Date().toISOString(), }; diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index 7978d1b..5f65341 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -36,7 +36,7 @@ export type ToastCallback = ( variant: ToastVariant, duration: number, ) => void; -export type NotifyCallback = (message: string) => void; +export type NotifyCallback = (message: string, targetSessionID?: string) => void; const TERMINAL_PLAN_STATUSES: PlanStatus[] = ['completed', 'failed', 'canceled']; @@ -165,6 +165,7 @@ export class Orchestrator { private reconcilePending = false; private activePlanId: string | null = null; private planModelSnapshot: string | undefined; + private planLaunchSessionID: string | undefined; private planPlacement: 'session' | 'window' | null = null; private subscriptionsActive = false; private checkpoint: CheckpointType | null = null; @@ -223,9 +224,9 @@ export class Orchestrator { this.toastCallback(title, message, variant, durations[variant]); } - private notify(message: string): void { + private notify(message: string, targetSessionID?: string): void { if (!this.notifyCallback) return; - this.notifyCallback(message); + this.notifyCallback(message, targetSessionID ?? this.planLaunchSessionID); } private formatTestReportSummary(testReport?: MergeTestReport): string | null { @@ -356,6 +357,7 @@ export class Orchestrator { this.activePlanId = plan.id; this.planPlacement = spec.placement ?? null; + this.planLaunchSessionID = spec.launchSessionID; this.mergeTrain = new MergeTrain(plan.integrationWorktree, this.getMergeTrainConfig()); this.subscribeToMonitorEvents(); this.startReconciler(); @@ -878,6 +880,7 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ mode, createdAt: new Date().toISOString(), planId, + launchSessionID: this.planLaunchSessionID, }); await updatePlanJob(planId, job.name, { @@ -1068,6 +1071,7 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ mode, createdAt: new Date().toISOString(), planId: plan.id, + launchSessionID: this.planLaunchSessionID, }); await updatePlanJob(plan.id, job.name, { @@ -1196,6 +1200,7 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ this.activePlanId = plan.id; this.planPlacement = plan.placement ?? null; + this.planLaunchSessionID = plan.launchSessionID; this.mergeTrain = new MergeTrain(plan.integrationWorktree!, this.getMergeTrainConfig()); const runningJobs = (await getRunningJobs()).filter((job) => job.planId === plan.id); diff --git a/src/lib/plan-types.ts b/src/lib/plan-types.ts index bdd0499..6dd19ff 100644 --- a/src/lib/plan-types.ts +++ b/src/lib/plan-types.ts @@ -49,6 +49,7 @@ export interface PlanSpec { prUrl?: string; checkpoint?: CheckpointType | null; checkpointContext?: CheckpointContext | null; + launchSessionID?: string; } export interface JobSpec { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index b756f30..d8e227c 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -16,10 +16,11 @@ export const JobSchema = z.object({ completedAt: z.string().optional(), exitCode: z.number().optional(), planId: z.string().optional(), + launchSessionID: z.string().optional(), }); export const JobStateSchema = z.object({ - version: z.union([z.literal(1), z.literal(2)]), + version: z.union([z.literal(1), z.literal(2), z.literal(3)]), jobs: z.array(JobSchema), updatedAt: z.string(), }); @@ -97,6 +98,7 @@ export const PlanSpecSchema = z.object({ checkpoint: CheckpointTypeSchema.nullable().optional(), checkpointContext: CheckpointContextSchema.nullable().optional(), ghAuthenticated: z.boolean().optional(), + launchSessionID: z.string().optional(), }); export const WorktreeSetupSchema = z.object({ diff --git a/src/tools/launch.ts b/src/tools/launch.ts index 5c36906..e067333 100644 --- a/src/tools/launch.ts +++ b/src/tools/launch.ts @@ -307,6 +307,7 @@ export const mc_launch: ToolDefinition = tool({ mode, planFile: args.planFile, createdAt: new Date().toISOString(), + launchSessionID: context?.sessionID, }; await addJob(job); diff --git a/src/tools/plan.ts b/src/tools/plan.ts index b9af2b7..5315c2a 100644 --- a/src/tools/plan.ts +++ b/src/tools/plan.ts @@ -124,6 +124,7 @@ export const mc_plan: ToolDefinition = tool({ integrationBranch: `mc/integration-${planId}`, baseCommit, createdAt: new Date().toISOString(), + launchSessionID: context?.sessionID, }; // 8. Handle mode diff --git a/tests/lib/job-state.test.ts b/tests/lib/job-state.test.ts index f4a5e77..e366830 100644 --- a/tests/lib/job-state.test.ts +++ b/tests/lib/job-state.test.ts @@ -11,6 +11,8 @@ import { getJob, getJobByName, getRunningJobs, + migrateJobState, + migrateV2ToV3, type Job, type JobState, } from '../../src/lib/job-state'; @@ -43,14 +45,14 @@ describe('job-state', () => { describe('loadJobState', () => { it('should return default state when file does not exist', async () => { const state = await loadJobState(); - expect(state.version).toBe(2); + expect(state.version).toBe(3); expect(state.jobs).toEqual([]); expect(state.updatedAt).toBeDefined(); }); it('should load existing state from file', async () => { const testState: JobState = { - version: 2, + version: 3, jobs: [ { id: 'test-1', @@ -71,7 +73,7 @@ describe('job-state', () => { await Bun.write(getTestStateFile(), JSON.stringify(testState)); const loaded = await loadJobState(); - expect(loaded.version).toBe(2); + expect(loaded.version).toBe(3); expect(loaded.jobs).toHaveLength(1); expect(loaded.jobs[0].id).toBe('test-1'); }); @@ -80,7 +82,7 @@ describe('job-state', () => { describe('saveJobState', () => { it('should save state to file with updated timestamp', async () => { const state: JobState = { - version: 2, + version: 3, jobs: [], updatedAt: '2024-01-01T00:00:00Z', }; @@ -88,13 +90,13 @@ describe('job-state', () => { await saveJobState(state); const loaded = await loadJobState(); - expect(loaded.version).toBe(2); + expect(loaded.version).toBe(3); expect(loaded.updatedAt).not.toBe('2024-01-01T00:00:00Z'); }); it('should use atomic write pattern', async () => { const state: JobState = { - version: 2, + version: 3, jobs: [], updatedAt: new Date().toISOString(), }; @@ -368,4 +370,187 @@ describe('job-state', () => { expect(running).toHaveLength(0); }); }); + + describe('migrateV2ToV3', () => { + it('should migrate v2 state to v3 adding launchSessionID', () => { + const v2State = { + version: 2, + jobs: [ + { + id: 'job-1', + name: 'Test Job', + worktreePath: '/path/to/worktree', + branch: 'main', + tmuxTarget: 'mc-test', + placement: 'session', + status: 'running', + prompt: 'Test prompt', + mode: 'vanilla', + createdAt: '2024-01-01T00:00:00Z', + planId: 'plan-1', + }, + ], + updatedAt: '2024-01-01T00:00:00Z', + }; + + const migrated = migrateV2ToV3(v2State); + + expect(migrated.version).toBe(3); + expect(migrated.jobs).toHaveLength(1); + expect(migrated.jobs[0].launchSessionID).toBeUndefined(); + expect(migrated.jobs[0].id).toBe('job-1'); + expect(migrated.jobs[0].planId).toBe('plan-1'); + }); + + it('should preserve existing launchSessionID if present', () => { + const v2State = { + version: 2, + jobs: [ + { + id: 'job-1', + name: 'Test Job', + worktreePath: '/path/to/worktree', + branch: 'main', + tmuxTarget: 'mc-test', + placement: 'session', + status: 'running', + prompt: 'Test prompt', + mode: 'vanilla', + createdAt: '2024-01-01T00:00:00Z', + launchSessionID: 'ses_existing', + }, + ], + updatedAt: '2024-01-01T00:00:00Z', + }; + + const migrated = migrateV2ToV3(v2State); + + expect(migrated.version).toBe(3); + expect(migrated.jobs[0].launchSessionID).toBe('ses_existing'); + }); + + it('should handle empty jobs array', () => { + const v2State = { + version: 2, + jobs: [], + updatedAt: '2024-01-01T00:00:00Z', + }; + + const migrated = migrateV2ToV3(v2State); + + expect(migrated.version).toBe(3); + expect(migrated.jobs).toHaveLength(0); + }); + }); + + describe('migrateJobState', () => { + it('should migrate v1 state through v2 to v3', () => { + const v1State = { + jobs: [ + { + id: 'job-1', + name: 'Test Job', + worktreePath: '/path/to/worktree', + branch: 'main', + tmuxTarget: 'mc-test', + placement: 'session', + status: 'running', + prompt: 'Test prompt', + mode: 'vanilla', + createdAt: '2024-01-01T00:00:00Z', + }, + ], + updatedAt: '2024-01-01T00:00:00Z', + }; + + const migrated = migrateJobState(v1State); + + expect(migrated.version).toBe(3); + expect(migrated.jobs[0].planId).toBeUndefined(); + expect(migrated.jobs[0].launchSessionID).toBeUndefined(); + }); + + it('should migrate v2 state to v3', () => { + const v2State = { + version: 2, + jobs: [ + { + id: 'job-1', + name: 'Test Job', + worktreePath: '/path/to/worktree', + branch: 'main', + tmuxTarget: 'mc-test', + placement: 'session', + status: 'running', + prompt: 'Test prompt', + mode: 'vanilla', + createdAt: '2024-01-01T00:00:00Z', + }, + ], + updatedAt: '2024-01-01T00:00:00Z', + }; + + const migrated = migrateJobState(v2State); + + expect(migrated.version).toBe(3); + expect(migrated.jobs[0].launchSessionID).toBeUndefined(); + }); + + it('should pass through v3 state unchanged', () => { + const v3State = { + version: 3, + jobs: [ + { + id: 'job-1', + name: 'Test Job', + worktreePath: '/path/to/worktree', + branch: 'main', + tmuxTarget: 'mc-test', + placement: 'session', + status: 'running', + prompt: 'Test prompt', + mode: 'vanilla', + createdAt: '2024-01-01T00:00:00Z', + launchSessionID: 'ses_abc123', + }, + ], + updatedAt: '2024-01-01T00:00:00Z', + }; + + const migrated = migrateJobState(v3State); + + expect(migrated.version).toBe(3); + expect(migrated.jobs[0].launchSessionID).toBe('ses_abc123'); + }); + }); + + describe('loadJobState with v2 file on disk', () => { + it('should auto-migrate v2 state file to v3', async () => { + const v2State = { + version: 2, + jobs: [ + { + id: 'job-1', + name: 'Legacy Job', + worktreePath: '/path/to/worktree', + branch: 'main', + tmuxTarget: 'mc-legacy', + placement: 'session', + status: 'running', + prompt: 'Legacy prompt', + mode: 'vanilla', + createdAt: '2024-01-01T00:00:00Z', + }, + ], + updatedAt: '2024-01-01T00:00:00Z', + }; + + await Bun.write(getTestStateFile(), JSON.stringify(v2State)); + const loaded = await loadJobState(); + + expect(loaded.version).toBe(3); + expect(loaded.jobs).toHaveLength(1); + expect(loaded.jobs[0].launchSessionID).toBeUndefined(); + }); + }); }); From bfffbc306a39a94e98634dcaeb05352dee27dbb0 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 13:30:42 -0600 Subject: [PATCH 04/17] test: add session routing and v3 migration test coverage (#74) --- tests/hooks/notifications.test.ts | 101 ++++++++++++++++++++++++++++++ tests/lib/plan-types.test.ts | 16 ++--- 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/tests/hooks/notifications.test.ts b/tests/hooks/notifications.test.ts index 945f934..a4a413b 100644 --- a/tests/hooks/notifications.test.ts +++ b/tests/hooks/notifications.test.ts @@ -259,4 +259,105 @@ describe('notifications hook', () => { const callArgs = mockClient.session.prompt.mock.calls[0][0]; expect(callArgs.body.parts[0].text).toContain("👀 Job 'review-task' needs review"); }); + + it('should route notification to launchSessionID when present', async () => { + setupNotifications({ + client: mockClient as any, + monitor: mockMonitor as any, + getActiveSessionID: mockGetActiveSessionID as any, + isSubagent: mockIsSubagent as any, + }); + + const job: Job = { + id: 'job-5', + name: 'routed-job', + worktreePath: '/path/to/worktree', + branch: 'mc/routed-job', + tmuxTarget: 'mc-routed-job', + placement: 'session', + status: 'completed', + prompt: 'Some task', + mode: 'vanilla', + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + launchSessionID: 'ses_launch_abc', + }; + + const handlers = mockMonitor.handlers.get('complete')!; + handlers[0](job); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockClient.session.prompt).toHaveBeenCalled(); + const callArgs = mockClient.session.prompt.mock.calls[0][0]; + expect(callArgs.path.id).toBe('ses_launch_abc'); + expect(callArgs.body.parts[0].text).toContain("🟢 Job 'routed-job' completed"); + }); + + it('should fallback to activeSessionID when launchSessionID is undefined', async () => { + mockGetActiveSessionID.mockResolvedValue('ses_active_456'); + + setupNotifications({ + client: mockClient as any, + monitor: mockMonitor as any, + getActiveSessionID: mockGetActiveSessionID as any, + isSubagent: mockIsSubagent as any, + }); + + const job: Job = { + id: 'job-6', + name: 'fallback-job', + worktreePath: '/path/to/worktree', + branch: 'mc/fallback-job', + tmuxTarget: 'mc-fallback-job', + placement: 'session', + status: 'completed', + prompt: 'Some task', + mode: 'vanilla', + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }; + + const handlers = mockMonitor.handlers.get('complete')!; + handlers[0](job); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockClient.session.prompt).toHaveBeenCalled(); + const callArgs = mockClient.session.prompt.mock.calls[0][0]; + expect(callArgs.path.id).toBe('ses_active_456'); + }); + + it('should not send notification when launchSessionID is invalid', async () => { + mockGetActiveSessionID.mockResolvedValue(undefined); + + setupNotifications({ + client: mockClient as any, + monitor: mockMonitor as any, + getActiveSessionID: mockGetActiveSessionID as any, + isSubagent: mockIsSubagent as any, + }); + + const job: Job = { + id: 'job-7', + name: 'invalid-session-job', + worktreePath: '/path/to/worktree', + branch: 'mc/invalid-session-job', + tmuxTarget: 'mc-invalid-session-job', + placement: 'session', + status: 'completed', + prompt: 'Some task', + mode: 'vanilla', + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + launchSessionID: 'not-a-valid-session', + }; + + const handlers = mockMonitor.handlers.get('complete')!; + handlers[0](job); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockClient.session.prompt).not.toHaveBeenCalled(); + }); }); diff --git a/tests/lib/plan-types.test.ts b/tests/lib/plan-types.test.ts index dcd509c..c9f8844 100644 --- a/tests/lib/plan-types.test.ts +++ b/tests/lib/plan-types.test.ts @@ -216,7 +216,7 @@ describe('plan-types', () => { }); describe('migrateJobState', () => { - it('should migrate v1 state to v2', () => { + it('should migrate v1 state to v3', () => { const v1State = { version: 1, jobs: [ @@ -238,9 +238,10 @@ describe('plan-types', () => { const migrated = migrateJobState(v1State); - expect(migrated.version).toBe(2); + expect(migrated.version).toBe(3); expect(migrated.jobs).toHaveLength(1); expect(migrated.jobs[0].planId).toBeUndefined(); + expect(migrated.jobs[0].launchSessionID).toBeUndefined(); expect(migrated.jobs[0].id).toBe('job-1'); }); @@ -265,11 +266,11 @@ describe('plan-types', () => { const migrated = migrateJobState(noVersionState); - expect(migrated.version).toBe(2); + expect(migrated.version).toBe(3); expect(migrated.jobs).toHaveLength(1); }); - it('should pass through v2 state unchanged', () => { + it('should migrate v2 state to v3', () => { const v2State = { version: 2, jobs: [ @@ -292,15 +293,16 @@ describe('plan-types', () => { const migrated = migrateJobState(v2State); - expect(migrated.version).toBe(2); + expect(migrated.version).toBe(3); expect(migrated.jobs[0].planId).toBe('plan-1'); + expect(migrated.jobs[0].launchSessionID).toBeUndefined(); }); it('should handle empty jobs array', () => { const emptyState = { version: 1, jobs: [], updatedAt: '2024-01-01T00:00:00Z' }; const migrated = migrateJobState(emptyState); - expect(migrated.version).toBe(2); + expect(migrated.version).toBe(3); expect(migrated.jobs).toEqual([]); }); @@ -308,7 +310,7 @@ describe('plan-types', () => { const noTimestamp = { jobs: [] }; const migrated = migrateJobState(noTimestamp); - expect(migrated.version).toBe(2); + expect(migrated.version).toBe(3); expect(migrated.updatedAt).toBeDefined(); }); }); From dd58446038b6664e4b430b2512d595ff4cc589e6 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 13:41:40 -0600 Subject: [PATCH 05/17] feat: annotate session titles when jobs need attention (#75) --- src/hooks/notifications.ts | 96 +++++++++++ src/index.ts | 8 +- tests/hooks/notifications.test.ts | 268 +++++++++++++++++++++++++++++- 3 files changed, 370 insertions(+), 2 deletions(-) diff --git a/src/hooks/notifications.ts b/src/hooks/notifications.ts index bf076b1..55628b4 100644 --- a/src/hooks/notifications.ts +++ b/src/hooks/notifications.ts @@ -17,7 +17,93 @@ interface SetupNotificationsOptions { isSubagent: () => Promise; } +interface SessionTitleState { + originalTitle: string; + annotations: Map; // jobName → status text ("done", "failed", "needs input") +} + +const titleState = new Map(); + +function extractSessionTitle(response: unknown): string | undefined { + if (!response || typeof response !== 'object') return undefined; + const obj = response as Record; + + // SDK may wrap in { data: { ... } } or return flat + if (obj.data && typeof obj.data === 'object') { + const data = obj.data as Record; + if (typeof data.title === 'string') return data.title; + } + + if (typeof obj.title === 'string') return obj.title; + + return undefined; +} +function buildAnnotatedTitle(state: SessionTitleState): string { + if (state.annotations.size === 0) return state.originalTitle; + if (state.annotations.size === 1) { + const [[jobName, statusText]] = [...state.annotations.entries()]; + return `${jobName} ${statusText}`; + } + return `${state.annotations.size} jobs need attention`; +} + +export async function annotateSessionTitle( + client: Client, + sessionID: string, + jobName: string, + statusText: string, +): Promise { + if (!sessionID || !sessionID.startsWith('ses')) return; + + try { + if (!titleState.has(sessionID)) { + const session = await client.session.get({ path: { id: sessionID } }); + const originalTitle = extractSessionTitle(session) ?? ''; + titleState.set(sessionID, { + originalTitle, + annotations: new Map(), + }); + } + + const state = titleState.get(sessionID)!; + state.annotations.set(jobName, statusText); + const annotatedTitle = buildAnnotatedTitle(state); + + await client.session.update({ + path: { id: sessionID }, + body: { title: annotatedTitle }, + }); + } catch { + // Fire-and-forget: don't block on title update failures + } +} + +export async function resetSessionTitle(client: Client, sessionID: string): Promise { + const state = titleState.get(sessionID); + if (!state) return; + + const originalTitle = state.originalTitle; + titleState.delete(sessionID); + + try { + await client.session.update({ + path: { id: sessionID }, + body: { title: originalTitle }, + }); + } catch { + // Fire-and-forget: don't block on title reset failures + } +} + +export function hasAnnotation(sessionID: string): boolean { + return titleState.has(sessionID); +} + +// Exposed for testing only +export function _getTitleStateForTesting(): Map { + return titleState; +} async function sendMessage(client: Client, sessionID: string, text: string): Promise { await client.session.prompt({ @@ -82,6 +168,16 @@ export function setupNotifications(options: SetupNotificationsOptions): void { await sendMessage(client, sessionID, message); sent.add(dedupKey); + + const titleAnnotationMap: Partial> = { + complete: 'done', + failed: 'failed', + awaiting_input: 'needs input', + }; + const statusText = titleAnnotationMap[event]; + if (statusText) { + await annotateSessionTitle(client, sessionID, job.name, statusText); + } }; const enqueue = (event: NotificationEvent, job: Job): void => { diff --git a/src/index.ts b/src/index.ts index 8939d62..f85b6c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import type { Plugin } from '@opencode-ai/plugin'; import { getSharedMonitor, setSharedNotifyCallback, getSharedNotifyCallback, setSharedOrchestrator } from './lib/orchestrator-singleton'; import { getCompactionContext, getJobCompactionContext } from './hooks/compaction'; import { shouldShowAutoStatus, getAutoStatusMessage } from './hooks/auto-status'; -import { setupNotifications } from './hooks/notifications'; +import { setupNotifications, hasAnnotation, resetSessionTitle } from './hooks/notifications'; import { registerCommands, createCommandHandler } from './commands'; import { isTmuxAvailable } from './lib/tmux'; import { loadPlan } from './lib/plan-state'; @@ -221,12 +221,18 @@ export const MissionControl: Plugin = async ({ client }) => { 'command.execute.before': (input: { command: string; sessionID: string; arguments: string }, output: { parts: unknown[] }) => { if (isValidSessionID(input.sessionID)) { activeSessionID = input.sessionID; + if (hasAnnotation(input.sessionID)) { + resetSessionTitle(client, input.sessionID).catch(() => {}); + } } return createCommandHandler(client)(input, output); }, 'tool.execute.before': async (input: { sessionID?: string; [key: string]: unknown }) => { if (input.sessionID && isValidSessionID(input.sessionID)) { activeSessionID = input.sessionID; + if (hasAnnotation(input.sessionID)) { + resetSessionTitle(client, input.sessionID).catch(() => {}); + } } }, 'chat.message': async (input) => { diff --git a/tests/hooks/notifications.test.ts b/tests/hooks/notifications.test.ts index a4a413b..3656735 100644 --- a/tests/hooks/notifications.test.ts +++ b/tests/hooks/notifications.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { setupNotifications } from '../../src/hooks/notifications'; +import { + setupNotifications, + annotateSessionTitle, + resetSessionTitle, + hasAnnotation, + _getTitleStateForTesting, +} from '../../src/hooks/notifications'; import type { Job } from '../../src/lib/job-state'; vi.mock('../../src/lib/reports', () => ({ @@ -14,12 +20,16 @@ describe('notifications hook', () => { let mockClient: { session: { prompt: ReturnType; + get: ReturnType; + update: ReturnType; }; }; let mockGetActiveSessionID: ReturnType; let mockIsSubagent: ReturnType; beforeEach(() => { + _getTitleStateForTesting().clear(); + mockMonitor = { handlers: new Map(), on(event: string, handler: (job: Job) => void) { @@ -33,6 +43,8 @@ describe('notifications hook', () => { mockClient = { session: { prompt: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue({ data: { title: 'Original Title' } }), + update: vi.fn().mockResolvedValue(undefined), }, }; @@ -359,5 +371,259 @@ describe('notifications hook', () => { await new Promise((resolve) => setTimeout(resolve, 50)); expect(mockClient.session.prompt).not.toHaveBeenCalled(); + + }); + + describe('title annotations', () => { + it('should annotate session title on job completion', async () => { + setupNotifications({ + client: mockClient as any, + monitor: mockMonitor as any, + getActiveSessionID: mockGetActiveSessionID as any, + isSubagent: mockIsSubagent as any, + }); + + const job: Job = { + id: 'job-t1', + name: 'feature-auth', + worktreePath: '/path/to/worktree', + branch: 'mc/feature-auth', + tmuxTarget: 'mc-feature-auth', + placement: 'session', + status: 'completed', + prompt: 'Add OAuth', + mode: 'vanilla', + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + launchSessionID: 'ses_launcher', + }; + + mockMonitor.handlers.get('complete')![0](job); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockClient.session.update).toHaveBeenCalledWith({ + path: { id: 'ses_launcher' }, + body: { title: 'feature-auth done' }, + }); + }); + + it('should annotate session title on job failure', async () => { + setupNotifications({ + client: mockClient as any, + monitor: mockMonitor as any, + getActiveSessionID: mockGetActiveSessionID as any, + isSubagent: mockIsSubagent as any, + }); + + const job: Job = { + id: 'job-t2', + name: 'fix-bug', + worktreePath: '/path/to/worktree', + branch: 'mc/fix-bug', + tmuxTarget: 'mc-fix-bug', + placement: 'session', + status: 'failed', + prompt: 'Fix bug', + mode: 'vanilla', + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + launchSessionID: 'ses_launcher', + }; + + mockMonitor.handlers.get('failed')![0](job); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockClient.session.update).toHaveBeenCalledWith({ + path: { id: 'ses_launcher' }, + body: { title: 'fix-bug failed' }, + }); + }); + + it('should annotate session title on awaiting_input', async () => { + setupNotifications({ + client: mockClient as any, + monitor: mockMonitor as any, + getActiveSessionID: mockGetActiveSessionID as any, + isSubagent: mockIsSubagent as any, + }); + + const job: Job = { + id: 'job-t3', + name: 'setup-db', + worktreePath: '/path/to/worktree', + branch: 'mc/setup-db', + tmuxTarget: 'mc-setup-db', + placement: 'session', + status: 'running', + prompt: 'Setup DB', + mode: 'vanilla', + createdAt: new Date().toISOString(), + launchSessionID: 'ses_launcher', + }; + + mockMonitor.handlers.get('awaiting_input')![0](job); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockClient.session.update).toHaveBeenCalledWith({ + path: { id: 'ses_launcher' }, + body: { title: 'setup-db needs input' }, + }); + }); + + it('should aggregate multiple annotations as "N jobs need attention"', async () => { + setupNotifications({ + client: mockClient as any, + monitor: mockMonitor as any, + getActiveSessionID: mockGetActiveSessionID as any, + isSubagent: mockIsSubagent as any, + }); + + const job1: Job = { + id: 'job-t4', + name: 'job-a', + worktreePath: '/path/a', + branch: 'mc/job-a', + tmuxTarget: 'mc-job-a', + placement: 'session', + status: 'completed', + prompt: 'Task A', + mode: 'vanilla', + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + launchSessionID: 'ses_launcher', + }; + + const job2: Job = { + id: 'job-t5', + name: 'job-b', + worktreePath: '/path/b', + branch: 'mc/job-b', + tmuxTarget: 'mc-job-b', + placement: 'session', + status: 'failed', + prompt: 'Task B', + mode: 'vanilla', + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + launchSessionID: 'ses_launcher', + }; + + mockMonitor.handlers.get('complete')![0](job1); + await new Promise((resolve) => setTimeout(resolve, 50)); + + mockMonitor.handlers.get('failed')![0](job2); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const calls = mockClient.session.update.mock.calls; + const lastCall = calls[calls.length - 1][0]; + expect(lastCall).toEqual({ + path: { id: 'ses_launcher' }, + body: { title: '2 jobs need attention' }, + }); + }); + + it('should not annotate title for blocked events', async () => { + setupNotifications({ + client: mockClient as any, + monitor: mockMonitor as any, + getActiveSessionID: mockGetActiveSessionID as any, + isSubagent: mockIsSubagent as any, + }); + + const job: Job = { + id: 'job-t6', + name: 'blocked-job', + worktreePath: '/path/to/worktree', + branch: 'mc/blocked-job', + tmuxTarget: 'mc-blocked-job', + placement: 'session', + status: 'completed', + prompt: 'Some task', + mode: 'vanilla', + createdAt: new Date().toISOString(), + launchSessionID: 'ses_launcher', + }; + + mockMonitor.handlers.get('blocked')![0](job); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockClient.session.update).not.toHaveBeenCalled(); + }); + + it('should not annotate title when sessionID is undefined', async () => { + mockGetActiveSessionID.mockResolvedValue(undefined); + + setupNotifications({ + client: mockClient as any, + monitor: mockMonitor as any, + getActiveSessionID: mockGetActiveSessionID as any, + isSubagent: mockIsSubagent as any, + }); + + const job: Job = { + id: 'job-t7', + name: 'no-session-job', + worktreePath: '/path/to/worktree', + branch: 'mc/no-session-job', + tmuxTarget: 'mc-no-session-job', + placement: 'session', + status: 'completed', + prompt: 'Task', + mode: 'vanilla', + createdAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }; + + mockMonitor.handlers.get('complete')![0](job); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockClient.session.update).not.toHaveBeenCalled(); + }); + + it('should fetch original title only once per session', async () => { + await annotateSessionTitle(mockClient as any, 'ses_test', 'job-a', 'done'); + await annotateSessionTitle(mockClient as any, 'ses_test', 'job-b', 'failed'); + + expect(mockClient.session.get).toHaveBeenCalledTimes(1); + expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: 'ses_test' } }); + }); + }); + + describe('title reset', () => { + it('should restore original title on reset', async () => { + await annotateSessionTitle(mockClient as any, 'ses_reset', 'my-job', 'done'); + expect(hasAnnotation('ses_reset')).toBe(true); + + await resetSessionTitle(mockClient as any, 'ses_reset'); + + expect(mockClient.session.update).toHaveBeenLastCalledWith({ + path: { id: 'ses_reset' }, + body: { title: 'Original Title' }, + }); + expect(hasAnnotation('ses_reset')).toBe(false); + }); + + it('should be a no-op when session has no annotation', async () => { + await resetSessionTitle(mockClient as any, 'ses_unknown'); + expect(mockClient.session.update).not.toHaveBeenCalled(); + }); + + it('should clear all annotations for the session', async () => { + await annotateSessionTitle(mockClient as any, 'ses_multi', 'job-x', 'done'); + await annotateSessionTitle(mockClient as any, 'ses_multi', 'job-y', 'failed'); + expect(_getTitleStateForTesting().get('ses_multi')!.annotations.size).toBe(2); + + await resetSessionTitle(mockClient as any, 'ses_multi'); + expect(hasAnnotation('ses_multi')).toBe(false); + expect(_getTitleStateForTesting().has('ses_multi')).toBe(false); + }); + + it('should not throw when session.update fails during reset', async () => { + mockClient.session.update.mockRejectedValueOnce(new Error('network error')); + + await annotateSessionTitle(mockClient as any, 'ses_err', 'my-job', 'done'); + await expect(resetSessionTitle(mockClient as any, 'ses_err')).resolves.toBeUndefined(); + expect(hasAnnotation('ses_err')).toBe(false); + }); }); }); From 4e323a928572d0eb3f6ace0574b320219b7c7fd8 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 13:41:45 -0600 Subject: [PATCH 06/17] feat: add serve-based launch with port management and SDK client (#65) --- src/lib/config.ts | 3 + src/lib/port-allocator.ts | 97 +++++++++ src/lib/prompt-file.ts | 15 ++ src/lib/schemas.ts | 8 + src/lib/sdk-client.ts | 105 +++++++++ src/tools/cleanup.ts | 5 + src/tools/kill.ts | 12 +- src/tools/launch.ts | 299 +++++++++++++++++++------- tests/integration/z-workflows.test.ts | 1 + tests/lib/config.test.ts | 3 + tests/lib/port-allocator.test.ts | 149 +++++++++++++ tests/lib/sdk-client.test.ts | 183 ++++++++++++++++ tests/tools/launch.test.ts | 217 +++++++++++++++++++ 13 files changed, 1014 insertions(+), 83 deletions(-) create mode 100644 src/lib/port-allocator.ts create mode 100644 src/lib/sdk-client.ts create mode 100644 tests/lib/port-allocator.test.ts create mode 100644 tests/lib/sdk-client.test.ts diff --git a/src/lib/config.ts b/src/lib/config.ts index 2cfd6a9..0bade4c 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -22,6 +22,9 @@ const DEFAULT_CONFIG: MCConfig = { autoCommit: true, testTimeout: 600000, mergeStrategy: 'squash', + useServeMode: true, + portRangeStart: 14100, + portRangeEnd: 14199, omo: { enabled: false, defaultMode: 'vanilla', diff --git a/src/lib/port-allocator.ts b/src/lib/port-allocator.ts new file mode 100644 index 0000000..c98bb62 --- /dev/null +++ b/src/lib/port-allocator.ts @@ -0,0 +1,97 @@ +import { join } from 'path'; +import { getDataDir } from './paths'; +import { GitMutex } from './git-mutex'; +import { atomicWrite } from './utils'; +import type { MCConfig } from './config'; +import type { Job } from './job-state'; + +const LOCK_FILE = 'port.lock'; + +/** + * In-process mutex for serializing port allocation operations. + * Same pattern as job-state.ts — protects read-modify-write cycles + * within a single process. + */ +const portMutex = new GitMutex(); + +async function getLockFilePath(): Promise { + const dataDir = await getDataDir(); + return join(dataDir, LOCK_FILE); +} + +async function readLockedPorts(): Promise { + const filePath = await getLockFilePath(); + const file = Bun.file(filePath); + const exists = await file.exists(); + + if (!exists) { + return []; + } + + try { + const content = await file.text(); + const parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + return parsed.filter((p): p is number => typeof p === 'number'); + } + return []; + } catch { + // Corrupted lock file — treat as empty + return []; + } +} + +async function writeLockedPorts(ports: number[]): Promise { + const filePath = await getLockFilePath(); + await atomicWrite(filePath, JSON.stringify(ports)); +} + +/** + * Allocate the next available port from the configured range. + * Scans active jobs for used ports and checks the lock file to prevent races. + */ +export async function allocatePort( + config: MCConfig, + activeJobs: Job[], +): Promise { + return portMutex.withLock(async () => { + const rangeStart = config.portRangeStart ?? 14100; + const rangeEnd = config.portRangeEnd ?? 14199; + + const jobPorts = new Set( + activeJobs + .map((j) => j.port) + .filter((p): p is number => p !== undefined), + ); + const lockedPorts = await readLockedPorts(); + const usedPorts = new Set([...jobPorts, ...lockedPorts]); + + for (let port = rangeStart; port <= rangeEnd; port++) { + if (!usedPorts.has(port)) { + lockedPorts.push(port); + await writeLockedPorts(lockedPorts); + return port; + } + } + + throw new Error( + `No available ports in range ${rangeStart}-${rangeEnd}. ` + + `${usedPorts.size} ports in use. Use mc_cleanup to free ports from completed jobs.`, + ); + }); +} + +/** + * Release a port back to the available pool. + * Idempotent — no-op if port is not in the lock file. + */ +export async function releasePort(port: number): Promise { + await portMutex.withLock(async () => { + const lockedPorts = await readLockedPorts(); + const filtered = lockedPorts.filter((p) => p !== port); + + if (filtered.length !== lockedPorts.length) { + await writeLockedPorts(filtered); + } + }); +} diff --git a/src/lib/prompt-file.ts b/src/lib/prompt-file.ts index da062e0..4824895 100644 --- a/src/lib/prompt-file.ts +++ b/src/lib/prompt-file.ts @@ -49,3 +49,18 @@ export function cleanupLauncherScript(worktreePath: string, delayMs = 5000): voi unlink(launcherPath).catch(() => {}); }, delayMs); } + +export async function writeServeLauncherScript( + worktreePath: string, + port: number, + password?: string, +): Promise { + const launcherPath = join(worktreePath, LAUNCHER_FILENAME); + const opencodeBin = resolveOpencodePath(); + const envLine = password + ? `export OPENCODE_SERVER_PASSWORD="${password}"\n` + : ''; + const script = `#!/bin/bash\n${envLine}exec ${opencodeBin} serve --port ${port} --hostname 127.0.0.1\n`; + await writeFile(launcherPath, script, { mode: 0o755 }); + return launcherPath; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index d8e227c..3b4ae6e 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -17,6 +17,8 @@ export const JobSchema = z.object({ exitCode: z.number().optional(), planId: z.string().optional(), launchSessionID: z.string().optional(), + port: z.number().optional(), + serverUrl: z.string().optional(), }); export const JobStateSchema = z.object({ @@ -71,6 +73,8 @@ export const JobSpecSchema = z.object({ symlinkDirs: z.array(z.string()).optional(), commands: z.array(z.string()).optional(), mode: z.enum(['vanilla', 'plan', 'ralph', 'ulw']).optional(), + port: z.number().optional(), + serverUrl: z.string().optional(), }); export const FailureKindSchema = z.enum(['touchset', 'merge_conflict', 'test_failure', 'job_failed']); @@ -124,6 +128,10 @@ export const MCConfigSchema = z.object({ mergeStrategy: z.enum(['squash', 'ff-only', 'merge']).optional(), worktreeSetup: WorktreeSetupSchema.optional(), allowUnsafeCommands: z.boolean().optional(), + useServeMode: z.boolean().optional(), + portRangeStart: z.number().optional(), + portRangeEnd: z.number().optional(), + serverPassword: z.string().optional(), omo: OmoConfigSchema, }); diff --git a/src/lib/sdk-client.ts b/src/lib/sdk-client.ts new file mode 100644 index 0000000..fc19ad5 --- /dev/null +++ b/src/lib/sdk-client.ts @@ -0,0 +1,105 @@ +import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk'; + +export function createJobClient( + port: number, + password?: string, +): OpencodeClient { + const baseUrl = `http://127.0.0.1:${port}`; + const headers: Record = {}; + + if (password) { + headers['Authorization'] = + 'Basic ' + Buffer.from('opencode:' + password).toString('base64'); + } + + return createOpencodeClient({ + baseUrl, + headers, + }); +} + +export interface WaitForServerOptions { + timeoutMs?: number; + password?: string; +} + +/** + * Wait for an opencode serve instance to become ready. + * Polls with exponential backoff until the server responds. + */ +export async function waitForServer( + port: number, + options?: WaitForServerOptions, +): Promise { + const timeoutMs = options?.timeoutMs ?? 60_000; + const client = createJobClient(port, options?.password); + + const startTime = Date.now(); + let delay = 100; + const maxDelay = 5_000; + const backoffFactor = 1.5; + + while (Date.now() - startTime < timeoutMs) { + try { + await client.session.list(); + return client; + } catch { + const remaining = timeoutMs - (Date.now() - startTime); + const waitTime = Math.min(delay, remaining, maxDelay); + if (waitTime <= 0) break; + + await new Promise((resolve) => setTimeout(resolve, waitTime)); + delay = Math.min(delay * backoffFactor, maxDelay); + } + } + + throw new Error( + `Server on port ${port} did not become ready within ${timeoutMs}ms`, + ); +} + +/** + * Send a prompt to a session via the SDK. + * Uses promptAsync to return immediately without waiting for completion. + */ +export async function sendPrompt( + client: OpencodeClient, + sessionId: string, + prompt: string, + agent?: string, + model?: { providerID: string; modelID: string }, +): Promise { + try { + await client.session.promptAsync({ + path: { id: sessionId }, + body: { + parts: [{ type: 'text', text: prompt }], + ...(agent ? { agent } : {}), + ...(model ? { model } : {}), + }, + }); + } catch (error) { + throw new Error( + `Failed to send prompt to session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export async function createSessionAndPrompt( + client: OpencodeClient, + prompt: string, + agent?: string, + model?: { providerID: string; modelID: string }, +): Promise { + const result = await client.session.create(); + + if (!result.data) { + throw new Error( + `Failed to create session: ${result.error ? JSON.stringify(result.error) : 'unknown error'}`, + ); + } + + const sessionId = result.data.id; + await sendPrompt(client, sessionId, prompt, agent, model); + return sessionId; +} diff --git a/src/tools/cleanup.ts b/src/tools/cleanup.ts index 9c00e57..d37bf4c 100644 --- a/src/tools/cleanup.ts +++ b/src/tools/cleanup.ts @@ -4,6 +4,7 @@ import { removeWorktree } from '../lib/worktree'; import { killSession, sessionExists } from '../lib/tmux'; import { gitCommand } from '../lib/git'; import { removeReport } from '../lib/reports'; +import { releasePort } from '../lib/port-allocator'; async function deleteBranch(branchName: string): Promise { const result = await gitCommand(['branch', '-D', branchName]); @@ -58,6 +59,10 @@ async function cleanupJobs( try { await killSession(job.tmuxTarget); } catch {} } + if (job.port) { + try { await releasePort(job.port); } catch {} + } + await removeWorktreeWithFallback(job.worktreePath); if (shouldDeleteBranch) { await deleteBranchWithFallback(job.branch); diff --git a/src/tools/kill.ts b/src/tools/kill.ts index 3a19875..457be4b 100644 --- a/src/tools/kill.ts +++ b/src/tools/kill.ts @@ -1,6 +1,7 @@ import { tool, type ToolDefinition } from '@opencode-ai/plugin'; import { getJobByName, updateJob } from '../lib/job-state'; import { killSession, killWindow } from '../lib/tmux'; +import { releasePort } from '../lib/port-allocator'; export const mc_kill: ToolDefinition = tool({ description: 'Stop a running job', @@ -44,7 +45,16 @@ export const mc_kill: ToolDefinition = tool({ ); } - // 4. Update job status to 'stopped' + // 4. Release port if allocated + if (job.port) { + try { + await releasePort(job.port); + } catch { + // Non-fatal: port will be reclaimed on next allocation scan + } + } + + // 5. Update job status to 'stopped' try { await updateJob(job.id, { status: 'stopped', diff --git a/src/tools/launch.ts b/src/tools/launch.ts index e067333..5faea2b 100644 --- a/src/tools/launch.ts +++ b/src/tools/launch.ts @@ -1,10 +1,12 @@ import { tool, type ToolDefinition } from '@opencode-ai/plugin'; import { randomUUID } from 'crypto'; -import { getJobByName, addJob, type Job } from '../lib/job-state'; +import { getJobByName, addJob, getRunningJobs, type Job } from '../lib/job-state'; import { createWorktree, removeWorktree } from '../lib/worktree'; import { createSession, createWindow, + killSession, + killWindow, setPaneDiedHook, sendKeys, getCurrentSession, @@ -15,8 +17,10 @@ import { loadConfig } from '../lib/config'; import { detectOMO } from '../lib/omo'; import { copyPlansToWorktree } from '../lib/plan-copier'; import { resolvePostCreateHook } from '../lib/worktree-setup'; -import { writePromptFile, cleanupPromptFile, writeLauncherScript, cleanupLauncherScript } from '../lib/prompt-file'; +import { writePromptFile, cleanupPromptFile, writeLauncherScript, cleanupLauncherScript, writeServeLauncherScript } from '../lib/prompt-file'; import { getCurrentModel } from '../lib/model-tracker'; +import { allocatePort, releasePort } from '../lib/port-allocator'; +import { waitForServer, createSessionAndPrompt } from '../lib/sdk-client'; /** * Sleep for a given number of milliseconds @@ -201,99 +205,218 @@ export const mc_launch: ToolDefinition = tool({ } } - // 6. Write launcher script before creating tmux session - let promptFilePath: string | undefined; - let launcherPath: string | undefined; - try { - const fullPrompt = buildFullPrompt({ - prompt: args.prompt, - mode, - planFile: args.planFile, - autoCommit: config.autoCommit, - }); - promptFilePath = await writePromptFile(worktreePath, fullPrompt); - const model = getCurrentModel(context?.sessionID); - launcherPath = await writeLauncherScript(worktreePath, promptFilePath, model); - } catch (error) { - if (promptFilePath) { - cleanupPromptFile(promptFilePath, 0); + // 6. Branch: serve mode vs TUI mode + const useServeMode = config.useServeMode === true; + let allocatedPort: number | undefined; + let serverUrl: string | undefined; + + if (useServeMode) { + // === SERVE MODE === + // Allocate port + try { + const activeJobs = await getRunningJobs(); + allocatedPort = await allocatePort(config, activeJobs); + } catch (error) { + try { + await removeWorktree(worktreePath, true); + } catch { + // Best-effort cleanup + } + throw new Error( + `Failed to allocate port: ${error instanceof Error ? error.message : String(error)}`, + ); } + + serverUrl = `http://127.0.0.1:${allocatedPort}`; + + // Write serve launcher script + let launcherPath: string; try { - await removeWorktree(worktreePath, true); - } catch { - // Best-effort cleanup + launcherPath = await writeServeLauncherScript( + worktreePath, + allocatedPort, + config.serverPassword, + ); + } catch (error) { + await releasePort(allocatedPort).catch(() => {}); + try { + await removeWorktree(worktreePath, true); + } catch { + // Best-effort cleanup + } + throw new Error( + `Failed to write serve launcher: ${error instanceof Error ? error.message : String(error)}`, + ); } - throw new Error( - `Failed to write launch files: ${error instanceof Error ? error.message : String(error)}`, - ); - } - // 7. Create tmux session/window with launcher as initial command - const initialCommand = `bash '${launcherPath}'`; - try { - if (placement === 'session') { - await createSession({ - name: tmuxSessionName, - workdir: worktreePath, - command: initialCommand, - }); - } else { - const currentSession = getCurrentSession()!; - await createWindow({ - session: currentSession, - name: sanitizedName, - workdir: worktreePath, - command: initialCommand, - }); + // Create tmux session/window + const initialCommand = `bash '${launcherPath}'`; + try { + if (placement === 'session') { + await createSession({ + name: tmuxSessionName, + workdir: worktreePath, + command: initialCommand, + }); + } else { + const currentSession = getCurrentSession()!; + await createWindow({ + session: currentSession, + name: sanitizedName, + workdir: worktreePath, + command: initialCommand, + }); + } + } catch (error) { + await releasePort(allocatedPort).catch(() => {}); + cleanupLauncherScript(worktreePath, 0); + try { + await removeWorktree(worktreePath, true); + } catch { + // Best-effort cleanup + } + throw new Error( + `Failed to create tmux ${placement}: ${error instanceof Error ? error.message : String(error)}`, + ); } - } catch (error) { - cleanupPromptFile(promptFilePath!, 0); - cleanupLauncherScript(worktreePath, 0); + + // Set up pane-died hook try { - await removeWorktree(worktreePath, true); + const hookCommand = `run-shell "echo '${jobId}' >> .mission-control/completed-jobs.log"`; + await setPaneDiedHook(tmuxTarget, hookCommand); } catch { - // Best-effort cleanup + // Non-fatal: pane-died hook is supplementary; polling is primary } - throw new Error( - `Failed to create tmux ${placement}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - // 8. Set up pane-died hook for completion detection - try { - const hookCommand = `run-shell "echo '${jobId}' >> .mission-control/completed-jobs.log"`; - await setPaneDiedHook(tmuxTarget, hookCommand); - } catch { - // Non-fatal: pane-died hook is supplementary; polling is primary - } + cleanupLauncherScript(worktreePath); - cleanupPromptFile(promptFilePath!); - cleanupLauncherScript(worktreePath); + // Wait for server and send prompt via SDK + try { + const client = await waitForServer(allocatedPort, { + password: config.serverPassword, + }); + const fullPrompt = buildFullPrompt({ + prompt: args.prompt, + mode, + planFile: args.planFile, + autoCommit: config.autoCommit, + }); + await createSessionAndPrompt(client, fullPrompt); + } catch (error) { + await releasePort(allocatedPort).catch(() => {}); + try { + if (placement === 'session') { + await killSession(tmuxTarget); + } else { + const [session, window] = tmuxTarget.split(':'); + if (session && window) { + await killWindow(session, window); + } + } + } catch { + // Best-effort tmux cleanup + } + try { + await removeWorktree(worktreePath, true); + } catch { + // Best-effort cleanup + } + throw new Error( + `Failed to start serve session: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { + // === TUI MODE (existing behavior) === + let promptFilePath: string | undefined; + let launcherPath: string | undefined; + try { + const fullPrompt = buildFullPrompt({ + prompt: args.prompt, + mode, + planFile: args.planFile, + autoCommit: config.autoCommit, + }); + promptFilePath = await writePromptFile(worktreePath, fullPrompt); + const model = getCurrentModel(context?.sessionID); + launcherPath = await writeLauncherScript(worktreePath, promptFilePath, model); + } catch (error) { + if (promptFilePath) { + cleanupPromptFile(promptFilePath, 0); + } + try { + await removeWorktree(worktreePath, true); + } catch { + // Best-effort cleanup + } + throw new Error( + `Failed to write launch files: ${error instanceof Error ? error.message : String(error)}`, + ); + } - // 9. For OMO modes, send follow-up commands after opencode starts - if (mode !== 'vanilla') { + const initialCommand = `bash '${launcherPath}'`; try { - await sleep(2000); - switch (mode) { - case 'plan': - await sendKeys(tmuxTarget, '/start-work'); - await sendKeys(tmuxTarget, 'Enter'); - break; - case 'ralph': - await sendKeys(tmuxTarget, '/ralph-loop'); - await sendKeys(tmuxTarget, 'Enter'); - break; - case 'ulw': - await sendKeys(tmuxTarget, '/ulw-loop'); - await sendKeys(tmuxTarget, 'Enter'); - break; + if (placement === 'session') { + await createSession({ + name: tmuxSessionName, + workdir: worktreePath, + command: initialCommand, + }); + } else { + const currentSession = getCurrentSession()!; + await createWindow({ + session: currentSession, + name: sanitizedName, + workdir: worktreePath, + command: initialCommand, + }); + } + } catch (error) { + cleanupPromptFile(promptFilePath!, 0); + cleanupLauncherScript(worktreePath, 0); + try { + await removeWorktree(worktreePath, true); + } catch { + // Best-effort cleanup } + throw new Error( + `Failed to create tmux ${placement}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + const hookCommand = `run-shell "echo '${jobId}' >> .mission-control/completed-jobs.log"`; + await setPaneDiedHook(tmuxTarget, hookCommand); } catch { - // Non-fatal: OMO command delivery is best-effort + // Non-fatal: pane-died hook is supplementary; polling is primary + } + + cleanupPromptFile(promptFilePath!); + cleanupLauncherScript(worktreePath); + + if (mode !== 'vanilla') { + try { + await sleep(2000); + switch (mode) { + case 'plan': + await sendKeys(tmuxTarget, '/start-work'); + await sendKeys(tmuxTarget, 'Enter'); + break; + case 'ralph': + await sendKeys(tmuxTarget, '/ralph-loop'); + await sendKeys(tmuxTarget, 'Enter'); + break; + case 'ulw': + await sendKeys(tmuxTarget, '/ulw-loop'); + await sendKeys(tmuxTarget, 'Enter'); + break; + } + } catch { + // Non-fatal: OMO command delivery is best-effort + } } } - // 9. Create and persist job + // 7. Create and persist job const job: Job = { id: jobId, name: args.name, @@ -308,12 +431,14 @@ export const mc_launch: ToolDefinition = tool({ planFile: args.planFile, createdAt: new Date().toISOString(), launchSessionID: context?.sessionID, + port: allocatedPort, + serverUrl, }; await addJob(job); - // 10. Return job info - return [ + // 8. Return job info + const infoLines = [ `Job "${args.name}" launched successfully.`, '', ` ID: ${jobId}`, @@ -323,10 +448,20 @@ export const mc_launch: ToolDefinition = tool({ ` tmux: ${tmuxTarget}`, ` Placement: ${placement}`, ` Mode: ${mode}`, - '', + ]; + + if (allocatedPort) { + infoLines.push(` Port: ${allocatedPort}`); + infoLines.push(` Server: ${serverUrl}`); + } + + infoLines.push(''); + infoLines.push( placement === 'session' ? `Attach with: tmux attach -t ${tmuxSessionName}` : `Switch with: tmux select-window -t ${tmuxTarget}`, - ].join('\n'); + ); + + return infoLines.join('\n'); }, }); diff --git a/tests/integration/z-workflows.test.ts b/tests/integration/z-workflows.test.ts index b0fe45c..2eb980e 100644 --- a/tests/integration/z-workflows.test.ts +++ b/tests/integration/z-workflows.test.ts @@ -136,6 +136,7 @@ mock.module('../../src/lib/prompt-file', () => ({ writePromptFile: vi.fn(async (worktreePath: string, _prompt: string) => `${worktreePath}/.mc-prompt.txt`), cleanupPromptFile: vi.fn(() => {}), writeLauncherScript: vi.fn(async (worktreePath: string) => `${worktreePath}/.mc-launch.sh`), + writeServeLauncherScript: vi.fn(async (worktreePath: string) => `${worktreePath}/.mc-launch.sh`), cleanupLauncherScript: vi.fn(() => {}), })); diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts index bef0826..da22aa0 100644 --- a/tests/lib/config.test.ts +++ b/tests/lib/config.test.ts @@ -186,6 +186,9 @@ describe('config', () => { autoCommit: true, testTimeout: 600000, mergeStrategy: 'squash', + useServeMode: true, + portRangeStart: 14100, + portRangeEnd: 14199, omo: { enabled: true, defaultMode: 'ulw', diff --git a/tests/lib/port-allocator.test.ts b/tests/lib/port-allocator.test.ts new file mode 100644 index 0000000..83d2f1f --- /dev/null +++ b/tests/lib/port-allocator.test.ts @@ -0,0 +1,149 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import type { Job } from '../../src/lib/job-state'; +import type { MCConfig } from '../../src/lib/config'; + +vi.mock('../../src/lib/paths', () => ({ + getDataDir: vi.fn(), +})); + +const paths = await import('../../src/lib/paths'); +const { allocatePort, releasePort } = await import('../../src/lib/port-allocator'); + +let testDataDir: string; + +function makeConfig(overrides?: Partial): MCConfig { + return { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp/mc-worktrees', + useServeMode: true, + portRangeStart: 14100, + portRangeEnd: 14110, + omo: { enabled: false, defaultMode: 'vanilla' }, + ...overrides, + } as MCConfig; +} + +function makeJob(overrides?: Partial): Job { + return { + id: 'job-1', + name: 'test-job', + worktreePath: '/tmp/worktree', + branch: 'mc/test', + tmuxTarget: 'mc-test', + placement: 'session', + status: 'running', + prompt: 'test', + mode: 'vanilla', + createdAt: new Date().toISOString(), + ...overrides, + } as Job; +} + +describe('port-allocator', () => { + beforeEach(async () => { + vi.clearAllMocks(); + const fs = await import('fs'); + testDataDir = fs.mkdtempSync(join(tmpdir(), 'mc-port-test-')); + (paths.getDataDir as ReturnType).mockResolvedValue(testDataDir); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + const fs = await import('fs'); + if (fs.existsSync(testDataDir)) { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } + }); + + describe('allocatePort', () => { + it('should allocate first port in range when no active jobs', async () => { + const port = await allocatePort(makeConfig(), []); + expect(port).toBe(14100); + }); + + it('should skip ports used by active jobs', async () => { + const jobs = [ + makeJob({ port: 14100 }), + makeJob({ port: 14101 }), + ]; + const port = await allocatePort(makeConfig(), jobs); + expect(port).toBe(14102); + }); + + it('should skip ports in lock file from previous allocations', async () => { + await allocatePort(makeConfig(), []); + const secondPort = await allocatePort(makeConfig(), []); + expect(secondPort).toBe(14101); + }); + + it('should use custom port range from config', async () => { + const config = makeConfig({ portRangeStart: 15000, portRangeEnd: 15010 }); + const port = await allocatePort(config, []); + expect(port).toBe(15000); + }); + + it('should throw when all ports in range are exhausted', async () => { + const config = makeConfig({ portRangeStart: 14100, portRangeEnd: 14101 }); + await allocatePort(config, []); + await allocatePort(config, []); + + await expect(allocatePort(config, [])).rejects.toThrow( + 'No available ports in range 14100-14101', + ); + }); + + it('should consider both job ports and lock file ports', async () => { + const config = makeConfig({ portRangeStart: 14100, portRangeEnd: 14103 }); + await allocatePort(config, []); + const jobs = [makeJob({ port: 14101 })]; + const port = await allocatePort(config, jobs); + expect(port).toBe(14102); + }); + + it('should handle corrupted lock file gracefully', async () => { + const fs = await import('fs'); + fs.writeFileSync(join(testDataDir, 'port.lock'), 'invalid json{{{'); + const port = await allocatePort(makeConfig(), []); + expect(port).toBe(14100); + }); + + it('should handle jobs without ports', async () => { + const jobs = [makeJob({ port: undefined })]; + const port = await allocatePort(makeConfig(), jobs); + expect(port).toBe(14100); + }); + }); + + describe('releasePort', () => { + it('should remove port from lock file', async () => { + const config = makeConfig(); + await allocatePort(config, []); + await releasePort(14100); + const port = await allocatePort(config, []); + expect(port).toBe(14100); + }); + + it('should be idempotent for unknown ports', async () => { + await expect(releasePort(99999)).resolves.toBeUndefined(); + }); + + it('should only remove the specified port', async () => { + const config = makeConfig(); + await allocatePort(config, []); + await allocatePort(config, []); + + await releasePort(14100); + + const port = await allocatePort(config, []); + expect(port).toBe(14100); + }); + + it('should handle missing lock file gracefully', async () => { + await expect(releasePort(14100)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/lib/sdk-client.test.ts b/tests/lib/sdk-client.test.ts new file mode 100644 index 0000000..efd5e4b --- /dev/null +++ b/tests/lib/sdk-client.test.ts @@ -0,0 +1,183 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +const mockSessionList = vi.fn(); +const mockSessionCreate = vi.fn(); +const mockSessionPromptAsync = vi.fn(); + +const mockClient = { + session: { + list: mockSessionList, + create: mockSessionCreate, + promptAsync: mockSessionPromptAsync, + }, +}; + +vi.mock('@opencode-ai/sdk', () => ({ + createOpencodeClient: vi.fn(() => mockClient), +})); + +const sdk = await import('@opencode-ai/sdk'); +const { createJobClient, waitForServer, sendPrompt, createSessionAndPrompt } = await import('../../src/lib/sdk-client'); + +describe('sdk-client', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createJobClient', () => { + it('should create client with correct baseUrl', () => { + createJobClient(14100); + + expect(sdk.createOpencodeClient).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'http://127.0.0.1:14100', + }), + ); + }); + + it('should add auth header when password provided', () => { + createJobClient(14100, 'secret'); + + const expectedAuth = 'Basic ' + Buffer.from('opencode:secret').toString('base64'); + expect(sdk.createOpencodeClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { Authorization: expectedAuth }, + }), + ); + }); + + it('should not include auth header when no password', () => { + createJobClient(14100); + + expect(sdk.createOpencodeClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: {}, + }), + ); + }); + }); + + describe('waitForServer', () => { + it('should return client when server responds immediately', async () => { + mockSessionList.mockResolvedValue({ data: [] }); + + const client = await waitForServer(14100); + expect(client).toBeDefined(); + expect(client.session).toBeDefined(); + }); + + it('should retry until server responds', async () => { + mockSessionList + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockResolvedValueOnce({ data: [] }); + + const client = await waitForServer(14100); + expect(client).toBeDefined(); + expect(mockSessionList).toHaveBeenCalledTimes(3); + }); + + it('should throw on timeout', async () => { + mockSessionList.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect( + waitForServer(14100, { timeoutMs: 500 }), + ).rejects.toThrow('did not become ready within 500ms'); + }); + + it('should pass password to client', async () => { + mockSessionList.mockResolvedValue({ data: [] }); + + await waitForServer(14100, { password: 'secret' }); + + const expectedAuth = 'Basic ' + Buffer.from('opencode:secret').toString('base64'); + expect(sdk.createOpencodeClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { Authorization: expectedAuth }, + }), + ); + }); + }); + + describe('sendPrompt', () => { + it('should call promptAsync with correct parameters', async () => { + mockSessionPromptAsync.mockResolvedValue({ data: {} }); + + await sendPrompt(mockClient as any, 'session-1', 'Hello'); + + expect(mockSessionPromptAsync).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + body: { + parts: [{ type: 'text', text: 'Hello' }], + }, + }); + }); + + it('should include agent when provided', async () => { + mockSessionPromptAsync.mockResolvedValue({ data: {} }); + + await sendPrompt(mockClient as any, 'session-1', 'Hello', 'build'); + + expect(mockSessionPromptAsync).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + body: { + parts: [{ type: 'text', text: 'Hello' }], + agent: 'build', + }, + }); + }); + + it('should include model when provided', async () => { + mockSessionPromptAsync.mockResolvedValue({ data: {} }); + + const model = { providerID: 'anthropic', modelID: 'claude-sonnet-4-20250514' }; + await sendPrompt(mockClient as any, 'session-1', 'Hello', undefined, model); + + expect(mockSessionPromptAsync).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + body: { + parts: [{ type: 'text', text: 'Hello' }], + model, + }, + }); + }); + + it('should wrap errors with context', async () => { + mockSessionPromptAsync.mockRejectedValue(new Error('network error')); + + await expect( + sendPrompt(mockClient as any, 'session-1', 'Hello'), + ).rejects.toThrow('Failed to send prompt to session session-1'); + }); + }); + + describe('createSessionAndPrompt', () => { + it('should create session and send prompt', async () => { + mockSessionCreate.mockResolvedValue({ + data: { id: 'new-session-id', title: 'test' }, + }); + mockSessionPromptAsync.mockResolvedValue({ data: {} }); + + const sessionId = await createSessionAndPrompt(mockClient as any, 'Hello'); + + expect(sessionId).toBe('new-session-id'); + expect(mockSessionCreate).toHaveBeenCalled(); + expect(mockSessionPromptAsync).toHaveBeenCalledWith( + expect.objectContaining({ + path: { id: 'new-session-id' }, + }), + ); + }); + + it('should throw when session creation fails', async () => { + mockSessionCreate.mockResolvedValue({ + data: undefined, + error: { message: 'server error' }, + }); + + await expect( + createSessionAndPrompt(mockClient as any, 'Hello'), + ).rejects.toThrow('Failed to create session'); + }); + }); +}); diff --git a/tests/tools/launch.test.ts b/tests/tools/launch.test.ts index 010824b..214bc86 100644 --- a/tests/tools/launch.test.ts +++ b/tests/tools/launch.test.ts @@ -9,6 +9,8 @@ import * as worktreeSetup from '../../src/lib/worktree-setup'; import * as omo from '../../src/lib/omo'; import * as planCopier from '../../src/lib/plan-copier'; import * as modelTracker from '../../src/lib/model-tracker'; +import * as portAllocator from '../../src/lib/port-allocator'; +import * as sdkClient from '../../src/lib/sdk-client'; vi.mock('crypto', () => ({ randomUUID: vi.fn(() => 'test-uuid-1234'), @@ -33,6 +35,12 @@ let mockWriteLauncherScript: any; let mockDetectOMO: any; let mockCopyPlansToWorktree: any; let mockResolvePostCreateHook: any; +let mockAllocatePort: any; +let mockReleasePort: any; +let mockWaitForServer: any; +let mockCreateSessionAndPrompt: any; +let mockWriteServeLauncherScript: any; +let mockGetRunningJobs: any; const mockContext = { sessionID: 'test-session', @@ -94,6 +102,14 @@ describe('mc_launch', () => { mockCopyPlansToWorktree = vi.spyOn(planCopier, 'copyPlansToWorktree').mockImplementation(() => Promise.resolve(undefined) as any); mockResolvePostCreateHook = vi.spyOn(worktreeSetup, 'resolvePostCreateHook').mockImplementation(() => ({ symlinkDirs: ['.opencode'] } as any)); vi.spyOn(modelTracker, 'getCurrentModel').mockReturnValue(undefined); + mockAllocatePort = vi.spyOn(portAllocator, 'allocatePort').mockImplementation(() => Promise.resolve(14100) as any); + mockReleasePort = vi.spyOn(portAllocator, 'releasePort').mockImplementation(() => Promise.resolve(undefined) as any); + mockWaitForServer = vi.spyOn(sdkClient, 'waitForServer').mockImplementation(() => Promise.resolve({ session: {} } as any)); + mockCreateSessionAndPrompt = vi.spyOn(sdkClient, 'createSessionAndPrompt').mockImplementation(() => Promise.resolve('sdk-session-1') as any); + mockWriteServeLauncherScript = vi.spyOn(promptFile, 'writeServeLauncherScript').mockImplementation(() => Promise.resolve('/tmp/mc-worktrees/test-job/.mc-launch.sh') as any); + mockGetRunningJobs = vi.spyOn(jobState, 'getRunningJobs').mockImplementation(() => Promise.resolve([]) as any); + vi.spyOn(tmux, 'killSession').mockImplementation(() => Promise.resolve(undefined) as any); + vi.spyOn(tmux, 'killWindow').mockImplementation(() => Promise.resolve(undefined) as any); setupDefaultMocks(); }); @@ -577,4 +593,205 @@ describe('mc_launch', () => { ); }); }); + + describe('serve mode', () => { + beforeEach(() => { + mockLoadConfig.mockResolvedValue({ + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp/mc-worktrees', + useServeMode: true, + portRangeStart: 14100, + portRangeEnd: 14199, + omo: { enabled: false, defaultMode: 'vanilla' }, + }); + }); + + it('should allocate port and use serve launcher', async () => { + await mc_launch.execute( + { name: 'serve-job', prompt: 'Do task' }, + mockContext, + ); + + expect(mockAllocatePort).toHaveBeenCalled(); + expect(mockWriteServeLauncherScript).toHaveBeenCalledWith( + '/tmp/mc-worktrees/test-job', + 14100, + undefined, + ); + }); + + it('should not call TUI writeLauncherScript or writePromptFile', async () => { + await mc_launch.execute( + { name: 'serve-job', prompt: 'Do task' }, + mockContext, + ); + + expect(mockWritePromptFile).not.toHaveBeenCalled(); + expect(mockWriteLauncherScript).not.toHaveBeenCalled(); + }); + + it('should wait for server and send prompt via SDK', async () => { + await mc_launch.execute( + { name: 'serve-job', prompt: 'Do task' }, + mockContext, + ); + + expect(mockWaitForServer).toHaveBeenCalledWith(14100, { + password: undefined, + }); + expect(mockCreateSessionAndPrompt).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Do task'), + ); + }); + + it('should store port and serverUrl on job record', async () => { + await mc_launch.execute( + { name: 'serve-job', prompt: 'Do task' }, + mockContext, + ); + + expect(mockAddJob).toHaveBeenCalledWith( + expect.objectContaining({ + port: 14100, + serverUrl: 'http://127.0.0.1:14100', + }), + ); + }); + + it('should include port info in output', async () => { + const result = await mc_launch.execute( + { name: 'serve-job', prompt: 'Do task' }, + mockContext, + ); + + expect(result).toContain('Port: 14100'); + expect(result).toContain('Server: http://127.0.0.1:14100'); + }); + + it('should pass serverPassword to serve launcher and waitForServer', async () => { + mockLoadConfig.mockResolvedValue({ + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp/mc-worktrees', + useServeMode: true, + portRangeStart: 14100, + portRangeEnd: 14199, + serverPassword: 'my-secret', + omo: { enabled: false, defaultMode: 'vanilla' }, + }); + + await mc_launch.execute( + { name: 'serve-job', prompt: 'Do task' }, + mockContext, + ); + + expect(mockWriteServeLauncherScript).toHaveBeenCalledWith( + '/tmp/mc-worktrees/test-job', + 14100, + 'my-secret', + ); + expect(mockWaitForServer).toHaveBeenCalledWith(14100, { + password: 'my-secret', + }); + }); + + it('should not use sendKeys for OMO modes in serve mode', async () => { + mockLoadConfig.mockResolvedValue({ + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp/mc-worktrees', + useServeMode: true, + portRangeStart: 14100, + portRangeEnd: 14199, + omo: { enabled: true, defaultMode: 'ulw' }, + }); + mockDetectOMO.mockResolvedValue({ detected: true, configSource: 'local', sisyphusPath: './.sisyphus' }); + + await mc_launch.execute( + { name: 'serve-job', prompt: 'Do task', mode: 'ulw' }, + mockContext, + ); + + expect(mockSendKeys).not.toHaveBeenCalled(); + }); + + it('should cleanup on waitForServer failure', async () => { + mockWaitForServer.mockRejectedValue(new Error('Server timeout')); + + await expect( + mc_launch.execute( + { name: 'serve-job', prompt: 'Do task' }, + mockContext, + ), + ).rejects.toThrow('Failed to start serve session'); + + expect(mockReleasePort).toHaveBeenCalledWith(14100); + expect(mockRemoveWorktree).toHaveBeenCalled(); + }); + + it('should cleanup on port allocation failure', async () => { + mockAllocatePort.mockRejectedValue(new Error('No ports')); + + await expect( + mc_launch.execute( + { name: 'serve-job', prompt: 'Do task' }, + mockContext, + ), + ).rejects.toThrow('Failed to allocate port'); + + expect(mockRemoveWorktree).toHaveBeenCalled(); + }); + }); + + describe('TUI mode fallback', () => { + it('should use TUI path when useServeMode is false', async () => { + mockLoadConfig.mockResolvedValue({ + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp/mc-worktrees', + useServeMode: false, + omo: { enabled: false, defaultMode: 'vanilla' }, + }); + + await mc_launch.execute( + { name: 'tui-job', prompt: 'Do task' }, + mockContext, + ); + + expect(mockAllocatePort).not.toHaveBeenCalled(); + expect(mockWriteServeLauncherScript).not.toHaveBeenCalled(); + expect(mockWaitForServer).not.toHaveBeenCalled(); + expect(mockWritePromptFile).toHaveBeenCalled(); + expect(mockWriteLauncherScript).toHaveBeenCalled(); + }); + + it('should not store port on job when in TUI mode', async () => { + mockLoadConfig.mockResolvedValue({ + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp/mc-worktrees', + useServeMode: false, + omo: { enabled: false, defaultMode: 'vanilla' }, + }); + + await mc_launch.execute( + { name: 'tui-job', prompt: 'Do task' }, + mockContext, + ); + + expect(mockAddJob).toHaveBeenCalledWith( + expect.objectContaining({ + port: undefined, + serverUrl: undefined, + }), + ); + }); + }); }); From 41a7cbe3ed607254b1222b9108ad068216913c8e Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 13:51:36 -0600 Subject: [PATCH 07/17] feat: enhance mc_attach with smart tmux window for serve-mode jobs (#65) --- src/tools/attach.ts | 31 ++++++++++++ tests/tools/attach.test.ts | 98 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/src/tools/attach.ts b/src/tools/attach.ts index 7a3a085..292798c 100644 --- a/src/tools/attach.ts +++ b/src/tools/attach.ts @@ -1,5 +1,6 @@ import { tool, type ToolDefinition } from '@opencode-ai/plugin'; import { getJobByName } from '../lib/job-state'; +import { isInsideTmux, createWindow, getCurrentSession } from '../lib/tmux'; export const mc_attach: ToolDefinition = tool({ description: 'Get instructions for attaching to a job\'s terminal', @@ -13,6 +14,36 @@ export const mc_attach: ToolDefinition = tool({ throw new Error(`Job "${args.name}" not found`); } + // Check if this is a serve-mode job (has port/serverUrl) + const isServeMode = job.port !== undefined && job.serverUrl !== undefined; + + if (isServeMode) { + // Serve-mode job: open TUI in new tmux window if inside tmux, otherwise return command + if (isInsideTmux()) { + // Create new tmux window with opencode attach command + const windowName = `mc-${job.name}`; + const attachCommand = `opencode attach ${job.serverUrl} --dir ${job.worktreePath}`; + const currentSession = getCurrentSession(); + + if (!currentSession) { + return `Run: opencode attach ${job.serverUrl}`; + } + + await createWindow({ + session: currentSession, + name: windowName, + workdir: job.worktreePath, + command: attachCommand, + }); + + return `Opened TUI for job '${job.name}' in new tmux window`; + } else { + // Not inside tmux, return the command to run + return `Run: opencode attach ${job.serverUrl}`; + } + } + + // TUI-mode job: preserve existing behavior (return tmux attach/select command) const lines: string[] = [ `To attach to job "${args.name}":`, '', diff --git a/tests/tools/attach.test.ts b/tests/tools/attach.test.ts index e41002d..7d48777 100644 --- a/tests/tools/attach.test.ts +++ b/tests/tools/attach.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; import type { Job } from '../../src/lib/job-state'; import * as jobState from '../../src/lib/job-state'; +import * as tmux from '../../src/lib/tmux'; const { mc_attach } = await import('../../src/tools/attach'); let mockGetJobByName: Mock; +let mockCreateWindow: Mock; +let mockIsInsideTmux: Mock; +let mockGetCurrentSession: Mock; const mockContext = { sessionID: 'test-session', @@ -21,6 +25,9 @@ describe('mc_attach', () => { beforeEach(() => { vi.clearAllMocks(); mockGetJobByName = vi.spyOn(jobState, 'getJobByName').mockImplementation(() => undefined as any); + mockCreateWindow = vi.spyOn(tmux, 'createWindow').mockImplementation(() => undefined as any); + mockIsInsideTmux = vi.spyOn(tmux, 'isInsideTmux').mockReturnValue(false); + mockGetCurrentSession = vi.spyOn(tmux, 'getCurrentSession').mockReturnValue('main-session'); }); describe('tool definition', () => { @@ -134,4 +141,95 @@ describe('mc_attach', () => { expect(resultWindow).toContain('# To detach: Ctrl+B, D'); }); }); + + describe('serve-mode job inside tmux', () => { + it('should create new tmux window and return success message', async () => { + mockIsInsideTmux.mockReturnValue(true); + mockCreateWindow.mockResolvedValue(undefined); + + const job: Job = { + id: 'job-serve', + name: 'serve-job', + worktreePath: '/tmp/mc-worktrees/serve-job', + branch: 'mc/serve-job', + tmuxTarget: 'mc-serve-job', + placement: 'session', + status: 'running', + prompt: 'Run server', + mode: 'vanilla', + createdAt: new Date().toISOString(), + port: 8080, + serverUrl: 'http://localhost:8080', + }; + + mockGetJobByName.mockResolvedValue(job); + + const result = await mc_attach.execute({ name: 'serve-job' }, mockContext); + + expect(mockCreateWindow).toHaveBeenCalledWith({ + session: 'main-session', + name: 'mc-serve-job', + workdir: '/tmp/mc-worktrees/serve-job', + command: 'opencode attach http://localhost:8080 --dir /tmp/mc-worktrees/serve-job', + }); + expect(result).toBe("Opened TUI for job 'serve-job' in new tmux window"); + }); + }); + + describe('serve-mode job inside tmux but no current session', () => { + it('should return command when current session cannot be determined', async () => { + mockIsInsideTmux.mockReturnValue(true); + mockGetCurrentSession.mockReturnValue(undefined); + + const job: Job = { + id: 'job-serve', + name: 'serve-job', + worktreePath: '/tmp/mc-worktrees/serve-job', + branch: 'mc/serve-job', + tmuxTarget: 'mc-serve-job', + placement: 'session', + status: 'running', + prompt: 'Run server', + mode: 'vanilla', + createdAt: new Date().toISOString(), + port: 8080, + serverUrl: 'http://localhost:8080', + }; + + mockGetJobByName.mockResolvedValue(job); + + const result = await mc_attach.execute({ name: 'serve-job' }, mockContext); + + expect(mockCreateWindow).not.toHaveBeenCalled(); + expect(result).toBe('Run: opencode attach http://localhost:8080'); + }); + }); + + describe('serve-mode job outside tmux', () => { + it('should return command to run when not inside tmux', async () => { + mockIsInsideTmux.mockReturnValue(false); + + const job: Job = { + id: 'job-serve', + name: 'serve-job', + worktreePath: '/tmp/mc-worktrees/serve-job', + branch: 'mc/serve-job', + tmuxTarget: 'mc-serve-job', + placement: 'session', + status: 'running', + prompt: 'Run server', + mode: 'vanilla', + createdAt: new Date().toISOString(), + port: 8080, + serverUrl: 'http://localhost:8080', + }; + + mockGetJobByName.mockResolvedValue(job); + + const result = await mc_attach.execute({ name: 'serve-job' }, mockContext); + + expect(mockCreateWindow).not.toHaveBeenCalled(); + expect(result).toBe('Run: opencode attach http://localhost:8080'); + }); + }); }); From 6843ae728d4ecf59ba97d3efab0f63943929c3f9 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 13:57:40 -0600 Subject: [PATCH 08/17] feat: add SDK-based monitoring with question relay and permission handling (#65) --- src/lib/monitor.ts | 300 ++++++++++++++++++++--- src/lib/question-relay.ts | 396 +++++++++++++++++++++++++++++++ tests/lib/monitor.test.ts | 150 +++++++++++- tests/lib/question-relay.test.ts | 333 ++++++++++++++++++++++++++ 4 files changed, 1137 insertions(+), 42 deletions(-) create mode 100644 src/lib/question-relay.ts create mode 100644 tests/lib/question-relay.test.ts diff --git a/src/lib/monitor.ts b/src/lib/monitor.ts index d5e60b5..1699aee 100644 --- a/src/lib/monitor.ts +++ b/src/lib/monitor.ts @@ -3,6 +3,9 @@ import { getRunningJobs, updateJob, type Job } from './job-state.js'; import { isPaneRunning, capturePane, captureExitStatus } from './tmux.js'; import { loadConfig } from './config.js'; import { readReport, type AgentReport } from './reports.js'; +import { createJobClient } from './sdk-client.js'; +import { QuestionRelay, type PermissionRequest } from './question-relay.js'; +import type { OpencodeClient } from '@opencode-ai/sdk'; type JobEventType = 'complete' | 'failed' | 'blocked' | 'needs_review' | 'awaiting_input' | 'agent_report'; type JobEventHandler = (job: Job) => void; @@ -12,6 +15,25 @@ interface IdleTracker { lastChangedAt: number; } +interface EventAccumulator { + filesEdited: string[]; + currentTool?: string; + lastActivityAt: number; + eventCount: number; + currentFile?: string; +} + +interface SSESubscription { + abortController: AbortController; + client: OpencodeClient; + reconnectAttempts: number; +} + +const MAX_EVENTS_PER_JOB = 100; +const SSE_INITIAL_BACKOFF_MS = 100; +const SSE_MAX_BACKOFF_MS = 30000; +const SSE_BACKOFF_FACTOR = 2; + export interface JobMonitorOptions { pollInterval?: number; idleThreshold?: number; @@ -50,6 +72,9 @@ export class JobMonitor extends EventEmitter { private isRunning = false; private idleTrackers: Map = new Map(); private awaitingInputNotified: Set = new Set(); + private eventAccumulators: Map = new Map(); + private sseSubscriptions: Map = new Map(); + private questionRelay: QuestionRelay; private explicitIdleThreshold: boolean; @@ -58,6 +83,7 @@ export class JobMonitor extends EventEmitter { this.pollInterval = options.pollInterval ?? 10000; this.idleThreshold = options.idleThreshold ?? 300000; this.explicitIdleThreshold = options.idleThreshold !== undefined; + this.questionRelay = new QuestionRelay(); const isTest = process.env.NODE_ENV === 'test' || process.env.VITEST === 'true'; if (this.pollInterval < 10000 && !isTest) { @@ -97,6 +123,25 @@ export class JobMonitor extends EventEmitter { } this.idleTrackers.clear(); this.awaitingInputNotified.clear(); + this.cleanupSSESubscriptions(); + this.questionRelay.dispose(); + } + + private cleanupSSESubscriptions(): void { + for (const [jobId, subscription] of this.sseSubscriptions) { + subscription.abortController.abort(); + this.sseSubscriptions.delete(jobId); + this.questionRelay.cleanup(jobId); + } + } + + private cleanupSSEForJob(jobId: string): void { + const subscription = this.sseSubscriptions.get(jobId); + if (subscription) { + subscription.abortController.abort(); + this.sseSubscriptions.delete(jobId); + this.questionRelay.cleanup(jobId); + } } on(event: JobEventType, handler: JobEventHandler): this { @@ -110,6 +155,188 @@ export class JobMonitor extends EventEmitter { } catch {} } + private getOrCreateEventAccumulator(jobId: string): EventAccumulator { + if (!this.eventAccumulators.has(jobId)) { + this.eventAccumulators.set(jobId, { + filesEdited: [], + lastActivityAt: Date.now(), + eventCount: 0, + }); + } + return this.eventAccumulators.get(jobId)!; + } + + private updateEventAccumulator( + jobId: string, + updates: Partial>, + ): void { + const accumulator = this.getOrCreateEventAccumulator(jobId); + accumulator.eventCount++; + accumulator.lastActivityAt = Date.now(); + + if (updates.currentTool !== undefined) { + accumulator.currentTool = updates.currentTool; + } + if (updates.currentFile !== undefined) { + accumulator.currentFile = updates.currentFile; + } + if (updates.filesEdited !== undefined) { + for (const file of updates.filesEdited) { + if (!accumulator.filesEdited.includes(file)) { + accumulator.filesEdited.push(file); + if (accumulator.filesEdited.length > MAX_EVENTS_PER_JOB) { + accumulator.filesEdited.shift(); + } + } + } + } + } + + private async subscribeToSSE(job: Job): Promise { + if (!job.port) { + return; + } + + if (this.sseSubscriptions.has(job.id)) { + return; + } + + const abortController = new AbortController(); + const client = createJobClient(job.port); + + this.sseSubscriptions.set(job.id, { + abortController, + client, + reconnectAttempts: 0, + }); + + this.processSSEStream(job, client, abortController).catch((error) => { + console.error(`[Monitor] SSE stream error for job ${job.name}:`, error); + }); + } + + private async processSSEStream( + job: Job, + client: OpencodeClient, + abortController: AbortController, + ): Promise { + while (this.isRunning && !abortController.signal.aborted) { + try { + const subscription = this.sseSubscriptions.get(job.id); + if (subscription) { + subscription.reconnectAttempts = 0; + } + + const events = await client.event.subscribe(); + + for await (const event of events.stream) { + if (abortController.signal.aborted) { + break; + } + + await this.handleSSEEvent(job, event); + } + } catch (error) { + if (abortController.signal.aborted) { + break; + } + + const subscription = this.sseSubscriptions.get(job.id); + const reconnectAttempts = subscription?.reconnectAttempts ?? 0; + const backoffMs = Math.min( + SSE_INITIAL_BACKOFF_MS * Math.pow(SSE_BACKOFF_FACTOR, reconnectAttempts), + SSE_MAX_BACKOFF_MS, + ); + + if (subscription) { + subscription.reconnectAttempts = reconnectAttempts + 1; + } + + console.warn( + `[Monitor] SSE connection lost for job ${job.name}, reconnecting in ${backoffMs}ms (attempt ${reconnectAttempts + 1})`, + ); + + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + } + + private async handleSSEEvent(job: Job, event: any): Promise { + const eventType = event.type || event.event; + + switch (eventType) { + case 'session.status': + case 'session.idle': { + const now = new Date().toISOString(); + this.cleanupSSEForJob(job.id); + await updateJob(job.id, { status: 'completed', completedAt: now }); + this.emit('complete', { ...job, status: 'completed', completedAt: now }); + break; + } + + case 'session.error': { + const now = new Date().toISOString(); + this.cleanupSSEForJob(job.id); + await updateJob(job.id, { status: 'failed', completedAt: now }); + this.emit('failed', { ...job, status: 'failed', completedAt: now }); + break; + } + + case 'message.part.updated': { + this.updateEventAccumulator(job.id, { currentTool: 'streaming' }); + break; + } + + case 'file.edited': { + const filePath = event.properties?.path || event.path; + if (filePath) { + this.updateEventAccumulator(job.id, { + filesEdited: [filePath], + currentFile: filePath, + }); + } + break; + } + + case 'permission.updated': { + const permission: PermissionRequest = { + id: event.properties?.id || event.id || 'unknown', + type: this.inferPermissionType(event), + path: event.properties?.path || event.path, + description: event.properties?.description || event.description || 'Unknown permission request', + }; + + const accumulator = this.getOrCreateEventAccumulator(job.id); + await this.questionRelay.handlePermissionRequest(job, permission, accumulator.currentFile); + break; + } + } + } + + private inferPermissionType(event: any): PermissionRequest['type'] { + const eventData = event.properties || event; + const typeHint = eventData.type || eventData.permissionType || ''; + + if (typeHint.includes('file') || typeHint.includes('write') || typeHint.includes('edit')) { + return 'file_operation'; + } + if (typeHint.includes('shell') || typeHint.includes('command') || typeHint.includes('exec')) { + return 'shell_command'; + } + if (typeHint.includes('network') || typeHint.includes('http') || typeHint.includes('fetch')) { + return 'network'; + } + if (typeHint.includes('mcp') || typeHint.includes('tool')) { + return 'mcp'; + } + + return 'other'; + } + + private isServeModeJob(job: Job): boolean { + return job.port !== undefined && job.port > 0; + } + private async checkAgentReport(job: Job): Promise { try { const report = await readReport(job.id); @@ -155,50 +382,53 @@ export class JobMonitor extends EventEmitter { } } - for (const job of jobs) { - try { - let isRunning: boolean; - try { - isRunning = await isPaneRunning(job.tmuxTarget); - } catch (paneError) { - console.warn( - `tmux error checking job ${job.id}, skipping this poll cycle:`, - paneError instanceof Error ? paneError.message : paneError, - ); - continue; - } + for (const job of jobs) { + try { + if (this.isServeModeJob(job)) { + await this.subscribeToSSE(job); + continue; + } + + let isRunning: boolean; + try { + isRunning = await isPaneRunning(job.tmuxTarget); + } catch (paneError) { + console.warn( + `tmux error checking job ${job.id}, skipping this poll cycle:`, + paneError instanceof Error ? paneError.message : paneError, + ); + continue; + } if (!isRunning) { - this.idleTrackers.delete(job.id); - this.awaitingInputNotified.delete(job.id); - const now = new Date().toISOString(); - - // Check exit status to determine success vs failure - const exitCode = await captureExitStatus(job.tmuxTarget); - const isFailed = exitCode !== undefined && exitCode !== 0; - - if (isFailed) { - await updateJob(job.id, { status: 'failed', completedAt: now }); - this.emit('failed', { ...job, status: 'failed', completedAt: now }); - } else { - await updateJob(job.id, { status: 'completed', completedAt: now }); - this.emit('complete', { ...job, status: 'completed', completedAt: now }); - } - continue; - } - - // Check agent reports first — agent completion signal takes priority + this.idleTrackers.delete(job.id); + this.awaitingInputNotified.delete(job.id); + const now = new Date().toISOString(); + + const exitCode = await captureExitStatus(job.tmuxTarget); + const isFailed = exitCode !== undefined && exitCode !== 0; + + if (isFailed) { + await updateJob(job.id, { status: 'failed', completedAt: now }); + this.emit('failed', { ...job, status: 'failed', completedAt: now }); + } else { + await updateJob(job.id, { status: 'completed', completedAt: now }); + this.emit('complete', { ...job, status: 'completed', completedAt: now }); + } + continue; + } + const reportHandled = await this.checkAgentReport(job); if (reportHandled) continue; const output = await capturePane(job.tmuxTarget, 50); const state = detectSessionState(output); - + if (state === 'awaiting_input' && !this.awaitingInputNotified.has(job.id)) { this.awaitingInputNotified.add(job.id); this.emit('awaiting_input', job); } - + const currentHash = hashOutput(output); const now = Date.now(); const tracker = this.idleTrackers.get(job.id); @@ -226,4 +456,8 @@ export class JobMonitor extends EventEmitter { } } } + + getEventAccumulator(jobId: string): EventAccumulator | undefined { + return this.eventAccumulators.get(jobId); + } } diff --git a/src/lib/question-relay.ts b/src/lib/question-relay.ts new file mode 100644 index 0000000..9b5007e --- /dev/null +++ b/src/lib/question-relay.ts @@ -0,0 +1,396 @@ +import type { OpencodeClient } from '@opencode-ai/sdk'; +import type { Job } from './job-state.js'; +import { createJobClient } from './sdk-client.js'; + +/** + * Permission request from an SSE event + */ +export interface PermissionRequest { + id: string; + type: 'file_operation' | 'shell_command' | 'network' | 'mcp' | 'other'; + path?: string; + description: string; +} + +/** + * Context for relaying a question to the launching session + */ +export interface QuestionContext { + jobName: string; + taskSummary: string; + currentFile?: string; + permissionRequest?: PermissionRequest; +} + +/** + * Result of handling a permission request + */ +export interface PermissionResult { + approved: boolean; + autoApproved: boolean; + reason: string; +} + +/** + * Configuration for question relay behavior + */ +export interface QuestionRelayConfig { + autoResponseTimeoutMs: number; + defaultResponse: string; +} + +const DEFAULT_CONFIG: QuestionRelayConfig = { + autoResponseTimeoutMs: 120_000, // 2 minutes + defaultResponse: 'use your best judgment', +}; + +/** + * Build a task summary from job prompt (first ~120 chars) + */ +export function buildTaskSummary(prompt: string): string { + const maxLen = 120; + if (prompt.length <= maxLen) { + return prompt; + } + return prompt.slice(0, maxLen - 3) + '...'; +} + +/** + * Check if a path is inside the worktree + */ +export function isPathInsideWorktree(path: string, worktreePath: string): boolean { + const normalizedPath = path.replace(/\\/g, '/'); + const normalizedWorktree = worktreePath.replace(/\\/g, '/'); + return normalizedPath.startsWith(normalizedWorktree); +} + +/** + * Determine if a permission request should be auto-approved + */ +export function shouldAutoApprove( + permission: PermissionRequest, + worktreePath: string, +): PermissionResult { + // MCP tool execution: auto-approve + if (permission.type === 'mcp') { + return { + approved: true, + autoApproved: true, + reason: 'MCP tool execution is auto-approved', + }; + } + + // File operations: check if inside worktree + if (permission.type === 'file_operation' && permission.path) { + if (isPathInsideWorktree(permission.path, worktreePath)) { + return { + approved: true, + autoApproved: true, + reason: 'File operation inside worktree is auto-approved', + }; + } + return { + approved: false, + autoApproved: false, + reason: 'File operation outside worktree requires user approval', + }; + } + + // Shell commands: check if inside worktree context + if (permission.type === 'shell_command') { + // Shell commands are generally safe inside worktree, but check if path is specified + if (!permission.path || isPathInsideWorktree(permission.path, worktreePath)) { + return { + approved: true, + autoApproved: true, + reason: 'Shell command inside worktree is auto-approved', + }; + } + return { + approved: false, + autoApproved: false, + reason: 'Shell command outside worktree requires user approval', + }; + } + + // Network/dangerous operations: always relay to user + if (permission.type === 'network') { + return { + approved: false, + autoApproved: false, + reason: 'Network operation requires user approval', + }; + } + + // Unknown/other types: relay to user + return { + approved: false, + autoApproved: false, + reason: 'Unknown permission type requires user approval', + }; +} + +/** + * Build the question prompt to send to the launching session + */ +export function buildQuestionPrompt(context: QuestionContext): string { + const parts: string[] = [ + `🔔 Job "${context.jobName}" needs your attention:`, + '', + `Task: ${context.taskSummary}`, + ]; + + if (context.currentFile) { + parts.push(`Current file: ${context.currentFile}`); + } + + if (context.permissionRequest) { + parts.push(''); + parts.push('Permission request:'); + parts.push(` Type: ${context.permissionRequest.type}`); + parts.push(` Description: ${context.permissionRequest.description}`); + if (context.permissionRequest.path) { + parts.push(` Path: ${context.permissionRequest.path}`); + } + } + + parts.push(''); + parts.push('Options:'); + parts.push(' - Reply "yes" to approve'); + parts.push(' - Reply "no" to deny'); + parts.push(' - Reply with specific instructions'); + parts.push(' - Let it decide: use its best judgment (auto-response after timeout)'); + + return parts.join('\n'); +} + +/** + * Question relay handler for managing permission requests + */ +export class QuestionRelay { + private config: QuestionRelayConfig; + private pendingResponses: Map = new Map(); + private clients: Map = new Map(); + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Get or create a client for a job's port + */ + private getClient(port: number, password?: string): OpencodeClient { + const key = port; + if (!this.clients.has(key)) { + this.clients.set(key, createJobClient(port, password)); + } + return this.clients.get(key)!; + } + + /** + * Handle a permission request from an SSE event + * Returns true if auto-approved, false if relayed to user + */ + async handlePermissionRequest( + job: Job, + permission: PermissionRequest, + currentFile?: string, + ): Promise { + const result = shouldAutoApprove(permission, job.worktreePath); + + if (result.autoApproved) { + // Auto-approve the permission + if (job.port) { + try { + const client = this.getClient(job.port); + await this.replyToPermission(client, permission.id, true); + } catch (error) { + console.error(`[QuestionRelay] Failed to auto-approve permission for job ${job.name}:`, error); + } + } + return result; + } + + // Relay to launching session + if (job.launchSessionID && job.port) { + await this.relayToLaunchingSession(job, permission, currentFile); + } + + return result; + } + + /** + * Relay a question to the launching session + */ + private async relayToLaunchingSession( + job: Job, + permission: PermissionRequest, + currentFile?: string, + ): Promise { + if (!job.launchSessionID || !job.port) { + console.warn(`[QuestionRelay] Cannot relay: missing launchSessionID or port for job ${job.name}`); + return; + } + + const context: QuestionContext = { + jobName: job.name, + taskSummary: buildTaskSummary(job.prompt), + currentFile, + permissionRequest: permission, + }; + + const prompt = buildQuestionPrompt(context); + + try { + const client = this.getClient(job.port); + await client.session.prompt({ + path: { id: job.launchSessionID }, + body: { + parts: [{ type: 'text', text: prompt }], + noReply: false, + }, + }); + + // Set up auto-response timeout + this.setupAutoResponse(job.id, permission.id, client); + } catch (error) { + console.error(`[QuestionRelay] Failed to relay question for job ${job.name}:`, error); + } + } + + /** + * Set up automatic response after timeout + */ + private setupAutoResponse( + jobId: string, + permissionId: string, + client: OpencodeClient, + ): void { + // Clear any existing timeout for this permission + const existingKey = `${jobId}:${permissionId}`; + if (this.pendingResponses.has(existingKey)) { + clearTimeout(this.pendingResponses.get(existingKey)!); + } + + // Set new timeout + const timeoutId = setTimeout(async () => { + try { + await this.replyToPermission(client, permissionId, true, this.config.defaultResponse); + console.log(`[QuestionRelay] Auto-responded to permission ${permissionId} for job ${jobId}`); + } catch (error) { + console.error(`[QuestionRelay] Failed to auto-respond for job ${jobId}:`, error); + } finally { + this.pendingResponses.delete(existingKey); + } + }, this.config.autoResponseTimeoutMs); + + this.pendingResponses.set(existingKey, timeoutId); + } + + /** + * Reply to a permission request + */ + private async replyToPermission( + client: OpencodeClient, + permissionId: string, + approved: boolean, + message?: string, + ): Promise { + // Note: The actual SDK endpoint for replying to permissions may vary + // This is a placeholder based on typical SDK patterns + // The SDK might have client.permission.reply or similar + try { + // Using a generic approach - actual implementation depends on SDK + const response = await fetch(`${(client as any).baseUrl}/permission/${permissionId}/reply`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...((client as any).headers || {}), + }, + body: JSON.stringify({ + approved, + message: message || (approved ? 'Approved' : 'Denied'), + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${await response.text()}`); + } + } catch (error) { + console.error(`[QuestionRelay] Failed to reply to permission ${permissionId}:`, error); + throw error; + } + } + + /** + * Handle user response to a relayed question + * Called when the user replies in the launching session + */ + async handleUserResponse( + job: Job, + permissionId: string, + response: string, + ): Promise { + // Clear the auto-response timeout + const key = `${job.id}:${permissionId}`; + if (this.pendingResponses.has(key)) { + clearTimeout(this.pendingResponses.get(key)!); + this.pendingResponses.delete(key); + } + + if (!job.port) { + return; + } + + // Parse user response + const normalizedResponse = response.toLowerCase().trim(); + let approved: boolean; + let message: string; + + if (normalizedResponse === 'yes' || normalizedResponse === 'y' || normalizedResponse === 'approve') { + approved = true; + message = 'Approved by user'; + } else if (normalizedResponse === 'no' || normalizedResponse === 'n' || normalizedResponse === 'deny') { + approved = false; + message = 'Denied by user'; + } else if (normalizedResponse.includes('let it decide') || normalizedResponse.includes('best judgment')) { + approved = true; + message = 'Let agent use best judgment'; + } else { + // Treat as specific instructions - approve with the message + approved = true; + message = response; + } + + try { + const client = this.getClient(job.port); + await this.replyToPermission(client, permissionId, approved, message); + } catch (error) { + console.error(`[QuestionRelay] Failed to send user response for job ${job.name}:`, error); + } + } + + /** + * Clean up resources for a job + */ + cleanup(jobId: string): void { + // Clear all pending timeouts for this job + for (const [key, timeoutId] of this.pendingResponses) { + if (key.startsWith(`${jobId}:`)) { + clearTimeout(timeoutId); + this.pendingResponses.delete(key); + } + } + } + + /** + * Clean up all resources + */ + dispose(): void { + for (const timeoutId of this.pendingResponses.values()) { + clearTimeout(timeoutId); + } + this.pendingResponses.clear(); + this.clients.clear(); + } +} diff --git a/tests/lib/monitor.test.ts b/tests/lib/monitor.test.ts index 792f45a..840e05c 100644 --- a/tests/lib/monitor.test.ts +++ b/tests/lib/monitor.test.ts @@ -538,13 +538,46 @@ describe('JobMonitor', () => { monitor.stop(); }); - it('should not emit events if pane is still running', async () => { + it('should not emit events if pane is still running', async () => { + const mockJob: Job = { + id: 'job-1', + name: 'Test Job', + worktreePath: '/path/to/worktree', + branch: 'main', + tmuxTarget: 'mc-test', + placement: 'session', + status: 'running', + prompt: 'Test prompt', + mode: 'vanilla', + createdAt: new Date().toISOString(), + }; + + mockGetRunningJobs.mockResolvedValue([mockJob]); + mockIsPaneRunning.mockResolvedValue(true); + mockCapturePane.mockResolvedValue(STREAMING_OUTPUT); + mockCaptureExitStatus.mockResolvedValue(undefined); + + const monitor = new JobMonitor(); + const completeHandler = mock(); + monitor.on('complete', completeHandler); + + monitor.start(); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(completeHandler).not.toHaveBeenCalled(); + + monitor.stop(); + }); + }); + + describe('dual-mode monitoring', () => { + it('should use tmux monitoring for TUI-mode jobs (no port)', async () => { const mockJob: Job = { - id: 'job-1', - name: 'Test Job', + id: 'job-tui', + name: 'TUI Job', worktreePath: '/path/to/worktree', branch: 'main', - tmuxTarget: 'mc-test', + tmuxTarget: 'mc-tui', placement: 'session', status: 'running', prompt: 'Test prompt', @@ -553,20 +586,119 @@ describe('JobMonitor', () => { }; mockGetRunningJobs.mockResolvedValue([mockJob]); + mockIsPaneRunning.mockResolvedValue(false); + mockCaptureExitStatus.mockResolvedValue(0); + mockUpdateJob.mockResolvedValue(undefined); + + const monitor = new JobMonitor(); + monitor.start(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(mockIsPaneRunning).toHaveBeenCalledWith('mc-tui'); + expect(mockUpdateJob).toHaveBeenCalledWith('job-tui', { + status: 'completed', + completedAt: expect.any(String), + }); + + monitor.stop(); + }); + + it('should skip tmux monitoring for serve-mode jobs (with port)', async () => { + const mockJob: Job = { + id: 'job-serve', + name: 'Serve Job', + worktreePath: '/path/to/worktree', + branch: 'main', + tmuxTarget: 'mc-serve', + placement: 'session', + status: 'running', + prompt: 'Test prompt', + mode: 'vanilla', + createdAt: new Date().toISOString(), + port: 8080, + }; + + mockGetRunningJobs.mockResolvedValue([mockJob]); + + const monitor = new JobMonitor(); + monitor.start(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(mockIsPaneRunning).not.toHaveBeenCalled(); + + monitor.stop(); + }); + + it('should handle mixed TUI and serve-mode jobs', async () => { + const tuiJob: Job = { + id: 'job-tui', + name: 'TUI Job', + worktreePath: '/path/tui', + branch: 'main', + tmuxTarget: 'mc-tui', + placement: 'session', + status: 'running', + prompt: 'TUI prompt', + mode: 'vanilla', + createdAt: new Date().toISOString(), + }; + + const serveJob: Job = { + id: 'job-serve', + name: 'Serve Job', + worktreePath: '/path/serve', + branch: 'main', + tmuxTarget: 'mc-serve', + placement: 'session', + status: 'running', + prompt: 'Serve prompt', + mode: 'vanilla', + createdAt: new Date().toISOString(), + port: 8080, + }; + + mockGetRunningJobs.mockResolvedValue([tuiJob, serveJob]); mockIsPaneRunning.mockResolvedValue(true); mockCapturePane.mockResolvedValue(STREAMING_OUTPUT); - mockCaptureExitStatus.mockResolvedValue(undefined); const monitor = new JobMonitor(); - const completeHandler = mock(); - monitor.on('complete', completeHandler); + monitor.start(); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(mockIsPaneRunning).toHaveBeenCalledWith('mc-tui'); + expect(mockIsPaneRunning).not.toHaveBeenCalledWith('mc-serve'); + + monitor.stop(); + }); + + it('should detect serve-mode job when port is defined', async () => { + const serveJob: Job = { + id: 'job-serve', + name: 'Serve Job', + worktreePath: '/path/to/worktree', + branch: 'main', + tmuxTarget: 'mc-serve', + placement: 'session', + status: 'running', + prompt: 'Test prompt', + mode: 'vanilla', + createdAt: new Date().toISOString(), + port: 8080, + }; + + mockGetRunningJobs.mockResolvedValue([serveJob]); + + const monitor = new JobMonitor(); monitor.start(); + await new Promise(resolve => setTimeout(resolve, 50)); - expect(completeHandler).not.toHaveBeenCalled(); + expect(mockIsPaneRunning).not.toHaveBeenCalled(); monitor.stop(); }); - }); + }); }); diff --git a/tests/lib/question-relay.test.ts b/tests/lib/question-relay.test.ts new file mode 100644 index 0000000..e743725 --- /dev/null +++ b/tests/lib/question-relay.test.ts @@ -0,0 +1,333 @@ +import { mock, describe, it, expect, beforeEach } from 'bun:test'; +import { + buildTaskSummary, + isPathInsideWorktree, + shouldAutoApprove, + buildQuestionPrompt, + QuestionRelay, + type PermissionRequest, +} from '../../src/lib/question-relay'; +import type { Job } from '../../src/lib/job-state'; + +describe('buildTaskSummary', () => { + it('should return the full prompt if under 120 chars', () => { + const prompt = 'Fix the login bug'; + expect(buildTaskSummary(prompt)).toBe(prompt); + }); + + it('should truncate and add ellipsis if over 120 chars', () => { + const prompt = 'A'.repeat(150); + const result = buildTaskSummary(prompt); + expect(result.length).toBe(120); + expect(result.endsWith('...')).toBe(true); + }); + + it('should handle exactly 120 chars without truncation', () => { + const prompt = 'A'.repeat(120); + expect(buildTaskSummary(prompt)).toBe(prompt); + }); +}); + +describe('isPathInsideWorktree', () => { + it('should return true for paths inside worktree', () => { + expect(isPathInsideWorktree('/home/user/project/src/file.ts', '/home/user/project')).toBe(true); + }); + + it('should return false for paths outside worktree', () => { + expect(isPathInsideWorktree('/etc/passwd', '/home/user/project')).toBe(false); + }); + + it('should handle paths with trailing slashes', () => { + expect(isPathInsideWorktree('/home/user/project/src/file.ts', '/home/user/project/')).toBe(true); + }); + + it('should handle Windows-style paths', () => { + expect(isPathInsideWorktree('C:\\Users\\project\\src\\file.ts', 'C:\\Users\\project')).toBe(true); + }); + + it('should return false for sibling directories', () => { + expect(isPathInsideWorktree('/home/user/other/file.ts', '/home/user/project')).toBe(false); + }); +}); + +describe('shouldAutoApprove', () => { + const worktreePath = '/home/user/project'; + + it('should auto-approve MCP tool execution', () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'mcp', + description: 'Execute MCP tool', + }; + + const result = shouldAutoApprove(permission, worktreePath); + + expect(result.approved).toBe(true); + expect(result.autoApproved).toBe(true); + }); + + it('should auto-approve file operations inside worktree', () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'file_operation', + path: '/home/user/project/src/file.ts', + description: 'Edit file', + }; + + const result = shouldAutoApprove(permission, worktreePath); + + expect(result.approved).toBe(true); + expect(result.autoApproved).toBe(true); + }); + + it('should NOT auto-approve file operations outside worktree', () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'file_operation', + path: '/etc/passwd', + description: 'Edit system file', + }; + + const result = shouldAutoApprove(permission, worktreePath); + + expect(result.approved).toBe(false); + expect(result.autoApproved).toBe(false); + }); + + it('should auto-approve shell commands inside worktree', () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'shell_command', + path: '/home/user/project', + description: 'Run npm install', + }; + + const result = shouldAutoApprove(permission, worktreePath); + + expect(result.approved).toBe(true); + expect(result.autoApproved).toBe(true); + }); + + it('should auto-approve shell commands without specified path', () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'shell_command', + description: 'Run ls', + }; + + const result = shouldAutoApprove(permission, worktreePath); + + expect(result.approved).toBe(true); + expect(result.autoApproved).toBe(true); + }); + + it('should NOT auto-approve shell commands outside worktree', () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'shell_command', + path: '/etc', + description: 'Run system command', + }; + + const result = shouldAutoApprove(permission, worktreePath); + + expect(result.approved).toBe(false); + expect(result.autoApproved).toBe(false); + }); + + it('should NOT auto-approve network operations', () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'network', + description: 'Make HTTP request', + }; + + const result = shouldAutoApprove(permission, worktreePath); + + expect(result.approved).toBe(false); + expect(result.autoApproved).toBe(false); + }); + + it('should NOT auto-approve unknown permission types', () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'other', + description: 'Unknown operation', + }; + + const result = shouldAutoApprove(permission, worktreePath); + + expect(result.approved).toBe(false); + expect(result.autoApproved).toBe(false); + }); +}); + +describe('buildQuestionPrompt', () => { + it('should include job name and task summary', () => { + const context = { + jobName: 'fix-auth', + taskSummary: 'Fix the authentication bug', + }; + + const prompt = buildQuestionPrompt(context); + + expect(prompt).toContain('fix-auth'); + expect(prompt).toContain('Fix the authentication bug'); + }); + + it('should include current file when provided', () => { + const context = { + jobName: 'fix-auth', + taskSummary: 'Fix the authentication bug', + currentFile: 'src/auth.ts', + }; + + const prompt = buildQuestionPrompt(context); + + expect(prompt).toContain('src/auth.ts'); + }); + + it('should include permission request details', () => { + const context = { + jobName: 'fix-auth', + taskSummary: 'Fix the authentication bug', + permissionRequest: { + id: 'perm-1', + type: 'file_operation' as const, + path: '/etc/config', + description: 'Edit system config', + }, + }; + + const prompt = buildQuestionPrompt(context); + + expect(prompt).toContain('file_operation'); + expect(prompt).toContain('/etc/config'); + expect(prompt).toContain('Edit system config'); + }); + + it('should include all options', () => { + const context = { + jobName: 'fix-auth', + taskSummary: 'Fix the authentication bug', + }; + + const prompt = buildQuestionPrompt(context); + + expect(prompt).toContain('yes'); + expect(prompt).toContain('no'); + expect(prompt).toContain('best judgment'); + }); +}); + +describe('QuestionRelay', () => { + let relay: QuestionRelay; + + beforeEach(() => { + relay = new QuestionRelay(); + }); + + const mockJob: Job = { + id: 'job-1', + name: 'Test Job', + worktreePath: '/home/user/project', + branch: 'main', + tmuxTarget: 'mc-test', + placement: 'session', + status: 'running', + prompt: 'Fix the authentication bug', + mode: 'vanilla', + createdAt: new Date().toISOString(), + port: 8080, + launchSessionID: 'session-123', + }; + + describe('handlePermissionRequest', () => { + it('should auto-approve MCP permissions without relaying', async () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'mcp', + description: 'Execute MCP tool', + }; + + const result = await relay.handlePermissionRequest(mockJob, permission); + + expect(result.autoApproved).toBe(true); + }); + + it('should auto-approve file operations inside worktree', async () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'file_operation', + path: '/home/user/project/src/file.ts', + description: 'Edit file', + }; + + const result = await relay.handlePermissionRequest(mockJob, permission); + + expect(result.autoApproved).toBe(true); + }); + + it('should not auto-approve operations outside worktree', async () => { + const permission: PermissionRequest = { + id: 'perm-1', + type: 'file_operation', + path: '/etc/passwd', + description: 'Edit system file', + }; + + const result = await relay.handlePermissionRequest(mockJob, permission); + + expect(result.autoApproved).toBe(false); + expect(result.approved).toBe(false); + }); + + it('should handle jobs without port gracefully', async () => { + const jobWithoutPort = { ...mockJob, port: undefined }; + const permission: PermissionRequest = { + id: 'perm-1', + type: 'mcp', + description: 'Execute MCP tool', + }; + + const result = await relay.handlePermissionRequest(jobWithoutPort, permission); + + expect(result.autoApproved).toBe(true); + }); + + it('should handle jobs without launchSessionID gracefully', async () => { + const jobWithoutSession = { ...mockJob, launchSessionID: undefined }; + const permission: PermissionRequest = { + id: 'perm-1', + type: 'file_operation', + path: '/etc/passwd', + description: 'Edit system file', + }; + + const result = await relay.handlePermissionRequest(jobWithoutSession, permission); + + expect(result.autoApproved).toBe(false); + }); + }); + + describe('configuration', () => { + it('should accept custom timeout configuration', () => { + const customRelay = new QuestionRelay({ + autoResponseTimeoutMs: 60000, + defaultResponse: 'custom response', + }); + + expect(customRelay).toBeDefined(); + }); + }); + + describe('cleanup', () => { + it('should clean up without errors', () => { + relay.cleanup('job-1'); + }); + + it('should dispose without errors', () => { + relay.dispose(); + }); + }); +}); From b5fc7d14a18f7dc5a2bacd0df3a098a35de68c76 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 14:08:12 -0600 Subject: [PATCH 09/17] feat: add rich job observability with structured telemetry and event capture (#66) --- src/tools/capture.ts | 65 +++++- src/tools/overview.ts | 33 ++- src/tools/status.ts | 31 ++- tests/tools/capture.test.ts | 203 ++++++++++++++++- tests/tools/overview.test.ts | 420 +++++++++++++++++++++++++++++++++++ tests/tools/status.test.ts | 100 ++++++++- 6 files changed, 837 insertions(+), 15 deletions(-) create mode 100644 tests/tools/overview.test.ts diff --git a/src/tools/capture.ts b/src/tools/capture.ts index 288cea7..9366e6c 100644 --- a/src/tools/capture.ts +++ b/src/tools/capture.ts @@ -1,25 +1,78 @@ import { tool, type ToolDefinition } from '@opencode-ai/plugin'; import { getJobByName } from '../lib/job-state'; import { capturePane } from '../lib/tmux'; +import { getSharedMonitor } from '../lib/orchestrator-singleton'; export const mc_capture: ToolDefinition = tool({ - description: 'Capture current terminal output from a job', + description: 'Capture current terminal output or structured events from a job', args: { name: tool.schema.string().describe('Job name'), - lines: tool.schema.number().optional().describe('Number of lines (default: 100)'), + lines: tool.schema.number().optional().describe('Number of lines for TUI mode (default: 100)'), + filter: tool.schema.enum(['file.edited', 'tool', 'error', 'all']).optional().describe('Event filter for serve-mode jobs (default: all)'), }, async execute(args) { - // 1. Find job by name const job = await getJobByName(args.name); if (!job) { throw new Error(`Job "${args.name}" not found`); } - // 2. Use capturePane to get output + const isServeMode = job.port !== undefined && job.port > 0; + + if (isServeMode) { + const monitor = getSharedMonitor(); + const accumulator = monitor.getEventAccumulator(job.id); + const filter = args.filter ?? 'all'; + + if (!accumulator) { + return JSON.stringify({ + job: job.name, + mode: 'serve', + status: job.status, + events: [], + message: 'No events accumulated yet', + }, null, 2); + } + + const events: Array<{ type: string; timestamp: string; payload: unknown }> = []; + + if (filter === 'all' || filter === 'file.edited') { + for (const file of accumulator.filesEdited) { + events.push({ + type: 'file.edited', + timestamp: new Date(accumulator.lastActivityAt).toISOString(), + payload: { path: file }, + }); + } + } + + if ((filter === 'all' || filter === 'tool') && accumulator.currentTool) { + events.push({ + type: 'tool', + timestamp: new Date(accumulator.lastActivityAt).toISOString(), + payload: { tool: accumulator.currentTool, file: accumulator.currentFile }, + }); + } + + const result = { + job: job.name, + mode: 'serve', + status: job.status, + filter, + summary: { + totalEvents: accumulator.eventCount, + filesEdited: accumulator.filesEdited.length, + currentTool: accumulator.currentTool, + currentFile: accumulator.currentFile, + lastActivityAt: new Date(accumulator.lastActivityAt).toISOString(), + }, + events, + }; + + return JSON.stringify(result, null, 2); + } + const lineCount = args.lines ?? 100; const output = await capturePane(job.tmuxTarget, lineCount); - - // 3. Return text content return output; }, }); diff --git a/src/tools/overview.ts b/src/tools/overview.ts index 58bc6e0..398cb34 100644 --- a/src/tools/overview.ts +++ b/src/tools/overview.ts @@ -3,6 +3,7 @@ import { loadJobState, getRunningJobs, type Job } from '../lib/job-state'; import { loadPlan } from '../lib/plan-state'; import { readAllReports, type AgentReport } from '../lib/reports'; import { formatTimeAgo } from '../lib/utils'; +import { getSharedMonitor } from '../lib/orchestrator-singleton'; @@ -40,6 +41,29 @@ function getReportForJob( return reportsByJob.get(`id:${job.id}`) ?? reportsByJob.get(`name:${job.name}`); } +function getJobActivityState(job: Job): { state: string; lastActivity: string } { + const isServeMode = job.port !== undefined && job.port > 0; + if (!isServeMode) { + return { state: 'tmux', lastActivity: formatTimeAgo(job.createdAt) }; + } + + const monitor = getSharedMonitor(); + const accumulator = monitor.getEventAccumulator(job.id); + if (!accumulator) { + return { state: 'idle', lastActivity: formatTimeAgo(job.createdAt) }; + } + + const lastActivityTime = new Date(accumulator.lastActivityAt).toISOString(); + const lastActivity = formatTimeAgo(lastActivityTime); + + let state = 'idle'; + if (accumulator.currentTool) { + state = accumulator.currentTool; + } + + return { state, lastActivity }; +} + function formatRecentCompletions(jobs: Job[]): string[] { const completed = jobs .filter((job) => job.status === 'completed' && job.completedAt) @@ -215,10 +239,17 @@ export const mc_overview: ToolDefinition = tool({ ) .map((job) => { const lastReport = getReportForJob(job, reportsByJob); + const activity = getJobActivityState(job); + const isServeMode = job.port !== undefined && job.port > 0; + + if (isServeMode) { + return `- ${job.name} | ${activity.state} | ${activity.lastActivity} | ${job.branch}`; + } + const reportText = lastReport ? `${lastReport.status}: ${truncate(lastReport.message, 60)}` : 'none'; - return `- ${job.name} | ${formatTimeAgo(job.createdAt)} | ${job.branch} | last report: ${reportText}`; + return `- ${job.name} | ${activity.lastActivity} | ${job.branch} | last report: ${reportText}`; }); lines.push(...runningLines); } diff --git a/src/tools/status.ts b/src/tools/status.ts index dff530e..f9c196f 100644 --- a/src/tools/status.ts +++ b/src/tools/status.ts @@ -4,7 +4,8 @@ import { capturePane } from '../lib/tmux'; import { isInManagedWorktree } from '../lib/worktree'; import { gitCommand } from '../lib/git'; import { readReport } from '../lib/reports'; -import { formatDurationMs } from '../lib/utils'; +import { formatDurationMs, formatTimeAgo } from '../lib/utils'; +import { getSharedMonitor } from '../lib/orchestrator-singleton'; async function getGitStatus( worktreePath: string, @@ -92,7 +93,30 @@ export const mc_status: ToolDefinition = tool({ duration = formatDurationMs(durationMs); } - // 7. Format output + // 7. Get serve-mode telemetry if available (job.port indicates serve-mode) + const isServeMode = job.port !== undefined && job.port > 0; + let serveModeTelemetry: string[] = []; + if (isServeMode) { + const monitor = getSharedMonitor(); + const accumulator = monitor.getEventAccumulator(job.id); + if (accumulator) { + const activityTime = formatTimeAgo(new Date(accumulator.lastActivityAt).toISOString()); + serveModeTelemetry = [ + 'Serve Mode Telemetry:', + ` Session State: ${accumulator.currentTool || 'idle'}`, + ` Current File: ${accumulator.currentFile || '(none)'}`, + ` Files Edited: ${accumulator.filesEdited.length}`, + ...(accumulator.filesEdited.length > 0 + ? accumulator.filesEdited.map((f) => ` - ${f}`) + : []), + ` Last Activity: ${activityTime}`, + ` Events Accumulated: ${accumulator.eventCount}`, + '', + ]; + } + } + + // 8. Format output const lines: string[] = [ `Job: ${job.name}`, `Status: ${job.status}`, @@ -106,6 +130,8 @@ export const mc_status: ToolDefinition = tool({ ...(job.completedAt ? [` Completed: ${new Date(job.completedAt).toISOString()}`] : []), ...(duration ? [` Duration: ${duration}`] : []), ...(job.exitCode !== undefined ? [` Exit Code: ${job.exitCode}`] : []), + ...(isServeMode && job.port ? [` Port: ${job.port}`] : []), + ...(job.serverUrl ? [` Server URL: ${job.serverUrl}`] : []), '', 'Paths:', ` Worktree: ${job.worktreePath}`, @@ -119,6 +145,7 @@ export const mc_status: ToolDefinition = tool({ ` Ahead: ${gitStatus.ahead}`, ` Behind: ${gitStatus.behind}`, '', + ...(serveModeTelemetry.length > 0 ? serveModeTelemetry : []), ...(report ? [ 'Agent Report:', diff --git a/tests/tools/capture.test.ts b/tests/tools/capture.test.ts index 62e69cb..8c0cab1 100644 --- a/tests/tools/capture.test.ts +++ b/tests/tools/capture.test.ts @@ -2,11 +2,13 @@ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; import type { Job } from '../../src/lib/job-state'; import * as jobState from '../../src/lib/job-state'; import * as tmux from '../../src/lib/tmux'; +import * as orchestratorSingleton from '../../src/lib/orchestrator-singleton'; const { mc_capture } = await import('../../src/tools/capture'); let mockGetJobByName: Mock; let mockCapturePane: Mock; +let mockGetSharedMonitor: Mock; function setupDefaultMocks() { mockGetJobByName.mockResolvedValue({ @@ -21,14 +23,17 @@ function setupDefaultMocks() { describe('mc_capture', () => { beforeEach(() => { vi.clearAllMocks(); - mockGetJobByName = vi.spyOn(jobState, 'getJobByName').mockImplementation(() => undefined as any); - mockCapturePane = vi.spyOn(tmux, 'capturePane').mockImplementation(() => '' as any); + mockGetJobByName = vi.spyOn(jobState, 'getJobByName').mockImplementation(() => undefined as any) as unknown as Mock; + mockCapturePane = vi.spyOn(tmux, 'capturePane').mockImplementation(() => '' as any) as unknown as Mock; + mockGetSharedMonitor = vi.spyOn(orchestratorSingleton, 'getSharedMonitor').mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue(undefined), + } as any) as unknown as Mock; setupDefaultMocks(); }); describe('tool definition', () => { it('should have correct description', () => { - expect(mc_capture.description).toBe('Capture current terminal output from a job'); + expect(mc_capture.description).toBe('Capture current terminal output or structured events from a job'); }); it('should have required arg: name', () => { @@ -108,4 +113,196 @@ describe('mc_capture', () => { expect(mockCapturePane).toHaveBeenCalledWith('mc-test-job', 10000); }); }); + + describe('serve-mode structured capture', () => { + it('should return structured JSON for serve-mode jobs', async () => { + mockGetJobByName.mockResolvedValue({ + id: 'serve-job-id', + name: 'serve-job', + tmuxTarget: 'mc-serve-job', + status: 'running', + port: 8080, + } as Job); + + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue({ + filesEdited: ['src/index.ts', 'src/utils.ts'], + currentTool: 'streaming', + currentFile: 'src/index.ts', + lastActivityAt: Date.now(), + eventCount: 42, + }), + }); + + const result = await mc_capture.execute({ name: 'serve-job' }); + const parsed = JSON.parse(result); + + expect(parsed.job).toBe('serve-job'); + expect(parsed.mode).toBe('serve'); + expect(parsed.status).toBe('running'); + expect(parsed.filter).toBe('all'); + expect(parsed.summary.filesEdited).toBe(2); + expect(parsed.summary.totalEvents).toBe(42); + expect(parsed.summary.currentTool).toBe('streaming'); + expect(parsed.events).toHaveLength(3); + }); + + it('should filter events with filter="file.edited"', async () => { + mockGetJobByName.mockResolvedValue({ + id: 'serve-job-id', + name: 'serve-job', + tmuxTarget: 'mc-serve-job', + status: 'running', + port: 8080, + } as Job); + + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue({ + filesEdited: ['src/index.ts', 'src/utils.ts'], + currentTool: 'streaming', + currentFile: 'src/index.ts', + lastActivityAt: Date.now(), + eventCount: 42, + }), + }); + + const result = await mc_capture.execute({ name: 'serve-job', filter: 'file.edited' }); + const parsed = JSON.parse(result); + + expect(parsed.filter).toBe('file.edited'); + expect(parsed.events).toHaveLength(2); + expect(parsed.events.every((e: any) => e.type === 'file.edited')).toBe(true); + }); + + it('should filter events with filter="tool"', async () => { + mockGetJobByName.mockResolvedValue({ + id: 'serve-job-id', + name: 'serve-job', + tmuxTarget: 'mc-serve-job', + status: 'running', + port: 8080, + } as Job); + + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue({ + filesEdited: ['src/index.ts'], + currentTool: 'streaming', + currentFile: 'src/index.ts', + lastActivityAt: Date.now(), + eventCount: 5, + }), + }); + + const result = await mc_capture.execute({ name: 'serve-job', filter: 'tool' }); + const parsed = JSON.parse(result); + + expect(parsed.filter).toBe('tool'); + expect(parsed.events).toHaveLength(1); + expect(parsed.events[0].type).toBe('tool'); + expect(parsed.events[0].payload.tool).toBe('streaming'); + }); + + it('should return empty events when filter does not match', async () => { + mockGetJobByName.mockResolvedValue({ + id: 'serve-job-id', + name: 'serve-job', + tmuxTarget: 'mc-serve-job', + status: 'running', + port: 8080, + } as Job); + + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue({ + filesEdited: [], + currentTool: 'streaming', + lastActivityAt: Date.now(), + eventCount: 5, + }), + }); + + const result = await mc_capture.execute({ name: 'serve-job', filter: 'file.edited' }); + const parsed = JSON.parse(result); + + expect(parsed.filter).toBe('file.edited'); + expect(parsed.events).toHaveLength(0); + }); + + it('should handle serve-mode job with no accumulator data', async () => { + mockGetJobByName.mockResolvedValue({ + id: 'serve-job-id', + name: 'serve-job', + tmuxTarget: 'mc-serve-job', + status: 'running', + port: 8080, + } as Job); + + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue(undefined), + }); + + const result = await mc_capture.execute({ name: 'serve-job' }); + const parsed = JSON.parse(result); + + expect(parsed.job).toBe('serve-job'); + expect(parsed.mode).toBe('serve'); + expect(parsed.events).toEqual([]); + expect(parsed.message).toBe('No events accumulated yet'); + }); + + it('should return raw terminal output for TUI-mode jobs', async () => { + mockGetJobByName.mockResolvedValue({ + id: 'tui-job-id', + name: 'tui-job', + tmuxTarget: 'mc-tui-job', + status: 'running', + } as Job); + + const result = await mc_capture.execute({ name: 'tui-job' }); + + expect(result).toBe('test output\nline 2\nline 3'); + expect(mockCapturePane).toHaveBeenCalledWith('mc-tui-job', 100); + }); + + it('should ignore filter parameter for TUI-mode jobs', async () => { + mockGetJobByName.mockResolvedValue({ + id: 'tui-job-id', + name: 'tui-job', + tmuxTarget: 'mc-tui-job', + status: 'running', + } as Job); + + const result = await mc_capture.execute({ name: 'tui-job', filter: 'file.edited' }); + + expect(result).toBe('test output\nline 2\nline 3'); + expect(mockCapturePane).toHaveBeenCalledWith('mc-tui-job', 100); + }); + + it('should have filter arg in tool schema', () => { + expect(mc_capture.args.filter).toBeDefined(); + }); + + it('should use default filter "all" when not specified', async () => { + mockGetJobByName.mockResolvedValue({ + id: 'serve-job-id', + name: 'serve-job', + tmuxTarget: 'mc-serve-job', + status: 'running', + port: 8080, + } as Job); + + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue({ + filesEdited: ['src/index.ts'], + currentTool: 'streaming', + lastActivityAt: Date.now(), + eventCount: 2, + }), + }); + + const result = await mc_capture.execute({ name: 'serve-job' }); + const parsed = JSON.parse(result); + + expect(parsed.filter).toBe('all'); + }); + }); }); diff --git a/tests/tools/overview.test.ts b/tests/tools/overview.test.ts new file mode 100644 index 0000000..b8867ee --- /dev/null +++ b/tests/tools/overview.test.ts @@ -0,0 +1,420 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import * as jobState from '../../src/lib/job-state'; +import * as planState from '../../src/lib/plan-state'; +import * as reports from '../../src/lib/reports'; +import * as orchestratorSingleton from '../../src/lib/orchestrator-singleton'; + +const { mc_overview } = await import('../../src/tools/overview'); + +let mockLoadJobState: Mock; +let mockGetRunningJobs: Mock; +let mockLoadPlan: Mock; +let mockReadAllReports: Mock; +let mockGetSharedMonitor: Mock; + +function createMockJob(overrides?: Partial): jobState.Job { + return { + id: 'test-job-id', + name: 'test-job', + worktreePath: '/tmp/mc-worktrees/test-job', + branch: 'mc/test-job', + tmuxTarget: 'mc-test-job', + placement: 'session', + status: 'running', + prompt: 'Test prompt', + mode: 'vanilla', + createdAt: new Date(Date.now() - 3600000).toISOString(), + ...overrides, + }; +} + +describe('mc_overview', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockLoadJobState = vi.spyOn(jobState, 'loadJobState').mockResolvedValue({ + version: 3, + jobs: [], + updatedAt: new Date().toISOString(), + } as any) as unknown as Mock; + + mockGetRunningJobs = vi.spyOn(jobState, 'getRunningJobs').mockResolvedValue([]) as unknown as Mock; + + mockLoadPlan = vi.spyOn(planState, 'loadPlan').mockResolvedValue(null) as unknown as Mock; + + mockReadAllReports = vi.spyOn(reports, 'readAllReports').mockResolvedValue([]) as unknown as Mock; + + mockGetSharedMonitor = vi.spyOn(orchestratorSingleton, 'getSharedMonitor').mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue(undefined), + } as any) as unknown as Mock; + }); + + describe('tool definition', () => { + it('should have correct description', () => { + expect(mc_overview.description).toContain('Get a complete overview'); + }); + + it('should have no required args', () => { + expect(Object.keys(mc_overview.args)).toHaveLength(0); + }); + }); + + describe('empty state', () => { + it('should show dashboard header with timestamp', async () => { + const result = await mc_overview.execute({}); + + expect(result).toContain('Mission Control Dashboard'); + expect(result).toContain('Timestamp:'); + }); + + it('should show no active plan', async () => { + const result = await mc_overview.execute({}); + + expect(result).toContain('Active Plan'); + expect(result).toContain('- None'); + }); + + it('should show zero jobs summary', async () => { + const result = await mc_overview.execute({}); + + expect(result).toContain('Jobs Summary'); + expect(result).toContain('0 running, 0 completed, 0 failed'); + }); + + it('should show no running jobs', async () => { + const result = await mc_overview.execute({}); + + expect(result).toContain('Running Jobs'); + expect(result).toContain('- None'); + }); + }); + + describe('running jobs with activity indicators', () => { + it('should show serve-mode jobs with activity state', async () => { + mockGetRunningJobs.mockResolvedValue([ + createMockJob({ + id: 'job-1', + name: 'serve-job', + port: 8080, + status: 'running', + }), + ]); + + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue({ + filesEdited: ['src/index.ts'], + currentTool: 'streaming', + currentFile: 'src/index.ts', + lastActivityAt: Date.now(), + eventCount: 5, + }), + }); + + const result = await mc_overview.execute({}); + + expect(result).toContain('serve-job'); + expect(result).toContain('streaming'); + }); + + it('should show serve-mode jobs as idle when no current tool', async () => { + mockGetRunningJobs.mockResolvedValue([ + createMockJob({ + id: 'job-1', + name: 'idle-job', + port: 8080, + status: 'running', + }), + ]); + + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue({ + filesEdited: [], + lastActivityAt: Date.now(), + eventCount: 0, + }), + }); + + const result = await mc_overview.execute({}); + + expect(result).toContain('idle-job'); + expect(result).toContain('idle'); + }); + + it('should show TUI-mode jobs with report info', async () => { + mockGetRunningJobs.mockResolvedValue([ + createMockJob({ + id: 'job-1', + name: 'tui-job', + status: 'running', + }), + ]); + + mockReadAllReports.mockResolvedValue([ + { + jobId: 'job-1', + jobName: 'tui-job', + status: 'working', + message: 'Processing files', + timestamp: new Date().toISOString(), + }, + ]); + + const result = await mc_overview.execute({}); + + expect(result).toContain('tui-job'); + expect(result).toContain('working: Processing files'); + }); + + it('should show activity timestamp for serve-mode jobs', async () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + mockGetRunningJobs.mockResolvedValue([ + createMockJob({ + id: 'job-1', + name: 'active-job', + port: 8080, + status: 'running', + }), + ]); + + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue({ + filesEdited: ['src/test.ts'], + currentTool: 'streaming', + lastActivityAt: fiveMinutesAgo, + eventCount: 10, + }), + }); + + const result = await mc_overview.execute({}); + + expect(result).toContain('active-job'); + expect(result).toContain('streaming'); + }); + + it('should handle multiple running jobs with mixed modes', async () => { + mockGetRunningJobs.mockResolvedValue([ + createMockJob({ + id: 'job-1', + name: 'serve-job', + port: 8080, + status: 'running', + }), + createMockJob({ + id: 'job-2', + name: 'tui-job', + status: 'running', + }), + ]); + + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockImplementation((jobId: string) => { + if (jobId === 'job-1') { + return { + filesEdited: ['src/index.ts'], + currentTool: 'streaming', + lastActivityAt: Date.now(), + eventCount: 5, + }; + } + return undefined; + }), + }); + + mockReadAllReports.mockResolvedValue([ + { + jobId: 'job-2', + jobName: 'tui-job', + status: 'working', + message: 'Working hard', + timestamp: new Date().toISOString(), + }, + ]); + + const result = await mc_overview.execute({}); + + expect(result).toContain('serve-job'); + expect(result).toContain('streaming'); + expect(result).toContain('tui-job'); + expect(result).toContain('working: Working hard'); + }); + }); + + describe('plan status', () => { + it('should show active plan with progress', async () => { + mockLoadPlan.mockResolvedValue({ + id: 'plan-1', + name: 'test-plan', + status: 'running', + mode: 'autopilot', + jobs: [ + { status: 'merged' }, + { status: 'running' }, + { status: 'queued' }, + ], + integrationBranch: 'mc/integration-plan-1', + baseCommit: 'abc123', + createdAt: new Date().toISOString(), + }); + + const result = await mc_overview.execute({}); + + expect(result).toContain('Active Plan'); + expect(result).toContain('Name: test-plan'); + expect(result).toContain('Status: running'); + expect(result).toContain('Progress: 1/3 merged'); + expect(result).toContain('Mode: autopilot'); + }); + + it('should show copilot mode pending plan', async () => { + mockLoadPlan.mockResolvedValue({ + id: 'plan-1', + name: 'pending-plan', + status: 'pending', + mode: 'copilot', + jobs: [], + integrationBranch: 'mc/integration-plan-1', + baseCommit: 'abc123', + createdAt: new Date().toISOString(), + }); + + const result = await mc_overview.execute({}); + + expect(result).toContain('Mode: copilot'); + expect(result).toContain('Plan is pending approval'); + }); + + it('should show supervisor checkpoint', async () => { + mockLoadPlan.mockResolvedValue({ + id: 'plan-1', + name: 'supervised-plan', + status: 'paused', + mode: 'supervisor', + jobs: [], + integrationBranch: 'mc/integration-plan-1', + baseCommit: 'abc123', + createdAt: new Date().toISOString(), + checkpoint: 'pre_merge', + }); + + const result = await mc_overview.execute({}); + + expect(result).toContain('Plan paused at pre_merge'); + }); + }); + + describe('alerts and suggested actions', () => { + it('should show blocked job alerts', async () => { + mockReadAllReports.mockResolvedValue([ + { + jobId: 'job-1', + jobName: 'blocked-job', + status: 'blocked', + message: 'Waiting for user input on database migration', + timestamp: new Date().toISOString(), + }, + ]); + + const result = await mc_overview.execute({}); + + expect(result).toContain('Alerts'); + expect(result).toContain('blocked-job [blocked]:'); + expect(result).toContain('Waiting for user input'); + }); + + it('should show needs_review alerts', async () => { + mockReadAllReports.mockResolvedValue([ + { + jobId: 'job-1', + jobName: 'review-job', + status: 'needs_review', + message: 'Implementation complete, needs review', + timestamp: new Date().toISOString(), + }, + ]); + + const result = await mc_overview.execute({}); + + expect(result).toContain('review-job [needs_review]:'); + }); + + it('should suggest actions based on job states', async () => { + mockGetRunningJobs.mockResolvedValue([ + createMockJob({ id: 'job-1', name: 'running-job', status: 'running' }), + ]); + + mockLoadJobState.mockResolvedValue({ + version: 3, + jobs: [ + createMockJob({ id: 'job-1', name: 'running-job', status: 'running' }), + createMockJob({ id: 'job-2', name: 'completed-job', status: 'completed' }), + createMockJob({ id: 'job-3', name: 'failed-job', status: 'failed' }), + ], + updatedAt: new Date().toISOString(), + }); + + mockReadAllReports.mockResolvedValue([ + { + jobId: 'job-4', + jobName: 'blocked-job', + status: 'blocked', + message: 'Blocked on question', + timestamp: new Date().toISOString(), + }, + ]); + + const result = await mc_overview.execute({}); + + expect(result).toContain('Suggested Actions'); + expect(result).toContain('blocked'); + expect(result).toContain('completed'); + expect(result).toContain('failed'); + expect(result).toContain('running'); + }); + }); + + describe('recent completions and failures', () => { + it('should show recent completed jobs', async () => { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 3600000); + + mockLoadJobState.mockResolvedValue({ + version: 3, + jobs: [ + createMockJob({ + id: 'job-1', + name: 'completed-job', + status: 'completed', + completedAt: now.toISOString(), + createdAt: oneHourAgo.toISOString(), + }), + ], + updatedAt: now.toISOString(), + }); + + const result = await mc_overview.execute({}); + + expect(result).toContain('Recent Completions'); + expect(result).toContain('completed-job'); + }); + + it('should show recent failed jobs', async () => { + mockLoadJobState.mockResolvedValue({ + version: 3, + jobs: [ + createMockJob({ + id: 'job-1', + name: 'failed-job', + status: 'failed', + completedAt: new Date().toISOString(), + }), + ], + updatedAt: new Date().toISOString(), + }); + + const result = await mc_overview.execute({}); + + expect(result).toContain('Recent Failures'); + expect(result).toContain('failed-job'); + }); + }); +}); diff --git a/tests/tools/status.test.ts b/tests/tools/status.test.ts index f5b04a5..b4f0ecc 100644 --- a/tests/tools/status.test.ts +++ b/tests/tools/status.test.ts @@ -3,12 +3,14 @@ import type { Job } from '../../src/lib/job-state'; import * as jobState from '../../src/lib/job-state'; import * as tmux from '../../src/lib/tmux'; import * as worktree from '../../src/lib/worktree'; +import * as orchestratorSingleton from '../../src/lib/orchestrator-singleton'; const { mc_status } = await import('../../src/tools/status'); let mockGetJobByName: Mock; let mockCapturePane: Mock; let mockIsInManagedWorktree: Mock; +let mockGetSharedMonitor: Mock; const mockContext = { sessionID: 'test-session', @@ -40,9 +42,12 @@ function createMockJob(overrides?: Partial): Job { describe('mc_status', () => { beforeEach(() => { vi.clearAllMocks(); - mockGetJobByName = vi.spyOn(jobState, 'getJobByName').mockImplementation(() => undefined as any); - mockCapturePane = vi.spyOn(tmux, 'capturePane').mockImplementation(() => '' as any); - mockIsInManagedWorktree = vi.spyOn(worktree, 'isInManagedWorktree').mockImplementation(() => false as any); + mockGetJobByName = vi.spyOn(jobState, 'getJobByName').mockImplementation(() => undefined as any) as unknown as Mock; + mockCapturePane = vi.spyOn(tmux, 'capturePane').mockImplementation(() => '' as any) as unknown as Mock; + mockIsInManagedWorktree = vi.spyOn(worktree, 'isInManagedWorktree').mockImplementation(() => false as any) as unknown as Mock; + mockGetSharedMonitor = vi.spyOn(orchestratorSingleton, 'getSharedMonitor').mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue(undefined), + } as any) as unknown as Mock; }); describe('tool definition', () => { @@ -287,4 +292,93 @@ describe('mc_status', () => { expect(result).toContain('Exit Code: 1'); }); }); + + describe('serve-mode telemetry', () => { + it('should include serve-mode telemetry section for jobs with port', async () => { + const serveModeJob = createMockJob({ + port: 8080, + serverUrl: 'http://localhost:8080', + }); + + mockGetJobByName.mockResolvedValue(serveModeJob); + mockCapturePane.mockResolvedValue(''); + mockIsInManagedWorktree.mockResolvedValue({ isManaged: true }); + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue({ + filesEdited: ['src/index.ts', 'src/utils.ts'], + currentTool: 'streaming', + currentFile: 'src/index.ts', + lastActivityAt: Date.now(), + eventCount: 42, + }), + }); + + const result = await mc_status.execute({ name: 'test-job' }, mockContext); + + expect(result).toContain('Serve Mode Telemetry:'); + expect(result).toContain('Session State: streaming'); + expect(result).toContain('Current File: src/index.ts'); + expect(result).toContain('Files Edited: 2'); + expect(result).toContain('src/index.ts'); + expect(result).toContain('src/utils.ts'); + expect(result).toContain('Events Accumulated: 42'); + expect(result).toContain('Port: 8080'); + expect(result).toContain('Server URL: http://localhost:8080'); + }); + + it('should handle serve-mode job with no accumulated events', async () => { + const serveModeJob = createMockJob({ + port: 8080, + }); + + mockGetJobByName.mockResolvedValue(serveModeJob); + mockCapturePane.mockResolvedValue(''); + mockIsInManagedWorktree.mockResolvedValue({ isManaged: true }); + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue(undefined), + }); + + const result = await mc_status.execute({ name: 'test-job' }, mockContext); + + expect(result).toContain('Port: 8080'); + expect(result).not.toContain('Serve Mode Telemetry:'); + }); + + it('should preserve TUI-mode output format for jobs without port', async () => { + const tuiJob = createMockJob(); + + mockGetJobByName.mockResolvedValue(tuiJob); + mockCapturePane.mockResolvedValue('TUI output'); + mockIsInManagedWorktree.mockResolvedValue({ isManaged: true }); + + const result = await mc_status.execute({ name: 'test-job' }, mockContext); + + expect(result).not.toContain('Serve Mode Telemetry:'); + expect(result).not.toContain('Port:'); + expect(result).toContain('Recent Output (last 10 lines):'); + expect(result).toContain('TUI output'); + }); + + it('should show idle state when no current tool', async () => { + const serveModeJob = createMockJob({ + port: 8080, + }); + + mockGetJobByName.mockResolvedValue(serveModeJob); + mockCapturePane.mockResolvedValue(''); + mockIsInManagedWorktree.mockResolvedValue({ isManaged: true }); + mockGetSharedMonitor.mockReturnValue({ + getEventAccumulator: vi.fn().mockReturnValue({ + filesEdited: [], + lastActivityAt: Date.now(), + eventCount: 0, + }), + }); + + const result = await mc_status.execute({ name: 'test-job' }, mockContext); + + expect(result).toContain('Session State: idle'); + expect(result).toContain('Files Edited: 0'); + }); + }); }); From cd53752af395a59c105f109530d814274ba74251 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 14:11:51 -0600 Subject: [PATCH 10/17] fix: add captureExitStatus to z-workflows tmux mock --- tests/integration/z-workflows.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/z-workflows.test.ts b/tests/integration/z-workflows.test.ts index 2eb980e..e7eb8ea 100644 --- a/tests/integration/z-workflows.test.ts +++ b/tests/integration/z-workflows.test.ts @@ -106,6 +106,7 @@ mock.module('../../src/lib/tmux', () => ({ sessionExists: vi.fn(async () => true), windowExists: vi.fn(async () => true), getPanePid: vi.fn(async () => 12345), + captureExitStatus: vi.fn(async () => undefined), })); mock.module('../../src/lib/config', () => ({ From 91cb148db6ffc176ac60669371975ca0dd51c37a Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 14:12:32 -0600 Subject: [PATCH 11/17] feat: add dynamic orchestration with replanning, inter-job comms, session forking, and fix-before-rollback (#68) --- src/lib/config.ts | 1 + src/lib/job-comms.ts | 143 +++++++++++++++++++++++++++ src/lib/merge-train.ts | 65 ++++++++++++ src/lib/orchestrator.ts | 214 +++++++++++++++++++++++++++++++++++++++- src/lib/plan-state.ts | 1 + src/lib/plan-types.ts | 17 +++- src/lib/schemas.ts | 14 +++ src/lib/sdk-client.ts | 40 ++++++++ 8 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 src/lib/job-comms.ts diff --git a/src/lib/config.ts b/src/lib/config.ts index 0bade4c..59096dd 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -25,6 +25,7 @@ const DEFAULT_CONFIG: MCConfig = { useServeMode: true, portRangeStart: 14100, portRangeEnd: 14199, + fixBeforeRollbackTimeout: 120000, omo: { enabled: false, defaultMode: 'vanilla', diff --git a/src/lib/job-comms.ts b/src/lib/job-comms.ts new file mode 100644 index 0000000..055fe79 --- /dev/null +++ b/src/lib/job-comms.ts @@ -0,0 +1,143 @@ +import type { JobSpec } from './plan-types'; +import { sendPrompt, waitForServer } from './sdk-client'; + +export interface RelayContext { + finding: string; + filePath?: string; + lineNumber?: number; + severity?: 'info' | 'warning' | 'error'; +} + +export interface RelayMessage { + from: string; + to: string; + context: RelayContext; + timestamp: string; +} + +export class JobComms { + private messageBus: Map = new Map(); + private relayPatterns: Map = new Map(); + private relayPatternSources: Map = new Map(); + + registerJob(job: JobSpec): void { + if (job.relayPatterns && job.relayPatterns.length > 0) { + const patterns: Bun.Glob[] = []; + const sources: string[] = []; + for (const pattern of job.relayPatterns) { + const normalized = pattern.endsWith('/') ? `${pattern}**` : pattern; + patterns.push(new Bun.Glob(normalized)); + sources.push(pattern); + } + this.relayPatterns.set(job.name, patterns); + this.relayPatternSources.set(job.name, sources); + } + if (!this.messageBus.has(job.name)) { + this.messageBus.set(job.name, []); + } + } + + unregisterJob(jobName: string): void { + this.relayPatterns.delete(jobName); + this.relayPatternSources.delete(jobName); + this.messageBus.delete(jobName); + } + + relayFinding(from: string, to: string, context: RelayContext): void { + const message: RelayMessage = { + from, + to, + context, + timestamp: new Date().toISOString(), + }; + + const messages = this.messageBus.get(to) ?? []; + messages.push(message); + this.messageBus.set(to, messages); + } + + getMessagesForJob(jobName: string): RelayMessage[] { + return this.messageBus.get(jobName) ?? []; + } + + clearMessagesForJob(jobName: string): void { + this.messageBus.set(jobName, []); + } + + shouldRelayForFile(jobName: string, filePath: string): boolean { + const patterns = this.relayPatterns.get(jobName); + if (!patterns || patterns.length === 0) { + return false; + } + return patterns.some((pattern) => pattern.match(filePath)); + } + + async deliverMessages( + job: JobSpec, + options?: { filterFrom?: string[] }, + ): Promise { + const messages = this.getMessagesForJob(job.name); + if (messages.length === 0) { + return 0; + } + + const filtered = options?.filterFrom + ? messages.filter((m) => options.filterFrom!.includes(m.from)) + : messages; + + if (filtered.length === 0) { + return 0; + } + + if (!job.port) { + return 0; + } + + try { + const client = await waitForServer(job.port, { timeoutMs: 5000 }); + + for (const message of filtered) { + const prompt = this.formatRelayPrompt(message); + await sendPrompt(client, job.launchSessionID ?? '', prompt); + } + + this.clearMessagesForJob(job.name); + return filtered.length; + } catch { + return 0; + } + } + + private formatRelayPrompt(message: RelayMessage): string { + const { from, context } = message; + const { finding, filePath, lineNumber, severity } = context; + + const parts: string[] = [`[Inter-Job Communication from ${from}]`]; + + if (severity) { + parts.push(`Severity: ${severity.toUpperCase()}`); + } + + parts.push(`Finding: ${finding}`); + + if (filePath) { + parts.push(`File: ${filePath}`); + } + + if (lineNumber) { + parts.push(`Line: ${lineNumber}`); + } + + parts.push('\nConsider how this finding may affect your current work.'); + + return parts.join('\n'); + } + + getAllRegisteredJobs(): string[] { + return Array.from(this.messageBus.keys()); + } + + getRelayPatternsForJob(jobName: string): string[] | undefined { + return this.relayPatternSources.get(jobName); + } +} diff --git a/src/lib/merge-train.ts b/src/lib/merge-train.ts index e193280..8e2f972 100644 --- a/src/lib/merge-train.ts +++ b/src/lib/merge-train.ts @@ -4,6 +4,7 @@ import type { JobSpec } from './plan-types'; import { gitCommand } from './git'; import { getIntegrationWorktree } from './integration'; import { extractConflicts } from './utils'; +import { createJobClient, sendPrompt, waitForServer } from './sdk-client'; export type MergeResult = | { success: true; mergedAt: string; testReport: MergeTestReport } @@ -35,9 +36,11 @@ type MergeTrainConfig = { testTimeout?: number; mergeStrategy?: 'squash' | 'ff-only' | 'merge'; setupCommands?: string[]; + fixBeforeRollbackTimeout?: number; }; const DEFAULT_TEST_TIMEOUT_MS = 600000; +const DEFAULT_FIX_BEFORE_ROLLBACK_TIMEOUT_MS = 120000; const INSTALL_COMMAND_BY_LOCKFILE = [ { file: 'bun.lockb', command: 'bun install --frozen-lockfile' }, @@ -564,6 +567,37 @@ export class MergeTrain { } if (!testResult.success) { + const fixTimeout = this.config?.fixBeforeRollbackTimeout ?? DEFAULT_FIX_BEFORE_ROLLBACK_TIMEOUT_MS; + const isServeMode = job.port !== undefined; + + if (isServeMode && fixTimeout > 0) { + const fixPrompt = this.buildFixPrompt(job.name, testCommand, testResult.output); + const fixPrompted = await this.promptAgentForFix(job, fixPrompt); + + if (fixPrompted) { + await this.sleep(fixTimeout); + + const retestResult = await runTestCommand(this.integrationWorktree, testCommand, timeoutMs); + + if (retestResult.success) { + return { + success: true, + mergedAt: new Date().toISOString(), + testReport: { + status: 'passed', + command: testCommand, + output: retestResult.output || undefined, + setup: { + status: dependencySetupResult.status, + commands: dependencySetupResult.commands, + output: dependencySetupResult.output || undefined, + }, + }, + }; + } + } + } + await rollbackMergeToHead(this.integrationWorktree, headBeforeStr); return { success: false, @@ -609,4 +643,35 @@ export class MergeTrain { return results; } + + private buildFixPrompt(jobName: string, testCommand: string, testOutput: string): string { + return `[AUTO-GENERATED] Test Failure in Merge Train + +Job "${jobName}" failed during merge train testing. + +Test Command: ${testCommand} + +Test Output: +${testOutput.slice(0, 2000)} + +Please fix the failing tests. You have a limited time window to apply fixes before the merge is rolled back. Focus on the most critical failures first.`; + } + + private async promptAgentForFix(job: JobSpec, prompt: string): Promise { + if (!job.port || !job.launchSessionID) { + return false; + } + + try { + const client = await waitForServer(job.port, { timeoutMs: 10000 }); + await sendPrompt(client, job.launchSessionID, prompt); + return true; + } catch { + return false; + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } } diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index 5f65341..66b85c1 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto'; import type { MCConfig } from './config'; -import type { PlanSpec, JobSpec, PlanStatus, CheckpointType, CheckpointContext } from './plan-types'; +import type { PlanSpec, JobSpec, PlanStatus, CheckpointType, CheckpointContext, AuditLogEntry } from './plan-types'; import { loadPlan, savePlan, updatePlanJob, updatePlanFields, clearPlan, validateGhAuth } from './plan-state'; import { getDefaultBranch } from './git'; import { createIntegrationBranch, deleteIntegrationBranch } from './integration'; @@ -26,6 +26,8 @@ import { sendKeys, setPaneDiedHook, } from './tmux'; +import { JobComms, type RelayContext } from './job-comms'; +import { forkJobSession, waitForServer } from './sdk-client'; type PlanSpecWithAuth = PlanSpec & { ghAuthenticated?: boolean }; @@ -174,17 +176,21 @@ export class Orchestrator { private jobsLaunchedCount = 0; private approvedForMerge = new Set(); private firstJobCompleted = false; + private jobComms: JobComms = new JobComms(); + private pendingReplanActions: Map Promise> = new Map(); private getMergeTrainConfig(): { testCommand?: string; testTimeout?: number; mergeStrategy?: 'squash' | 'ff-only' | 'merge'; setupCommands?: string[]; + fixBeforeRollbackTimeout?: number; } { const config = this.config as MCConfig & { testCommand?: string; testTimeout?: number; mergeStrategy?: 'squash' | 'ff-only' | 'merge'; + fixBeforeRollbackTimeout?: number; worktreeSetup?: { commands?: string[]; }; @@ -194,6 +200,7 @@ export class Orchestrator { testTimeout: config.testTimeout, mergeStrategy: config.mergeStrategy, setupCommands: config.worktreeSetup?.commands, + fixBeforeRollbackTimeout: config.fixBeforeRollbackTimeout, }; } @@ -1183,6 +1190,211 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ this.unsubscribeFromMonitorEvents(); } + async skipJob(jobName: string, reason?: string): Promise { + const plan = await loadPlan(); + if (!plan) { + throw new Error('No active plan'); + } + + const jobIndex = plan.jobs.findIndex((j) => j.name === jobName); + if (jobIndex === -1) { + throw new Error(`Job "${jobName}" not found in plan`); + } + + const job = plan.jobs[jobIndex]; + const requiresApproval = plan.mode === 'supervisor' || plan.mode === 'copilot'; + + if (requiresApproval && !this.pendingReplanActions.has(`skip-${jobName}`)) { + this.pendingReplanActions.set(`skip-${jobName}`, async () => { + await this.executeSkipJob(plan, job, reason); + this.pendingReplanActions.delete(`skip-${jobName}`); + }); + this.showToast('Mission Control', `Approval required to skip job "${jobName}"`, 'warning'); + return; + } + + await this.executeSkipJob(plan, job, reason); + } + + private async executeSkipJob(plan: PlanSpec, job: JobSpec, reason?: string): Promise { + await updatePlanJob(plan.id, job.name, { status: 'canceled' }); + this.jobComms.unregisterJob(job.name); + + const auditEntry: AuditLogEntry = { + timestamp: new Date().toISOString(), + action: 'skip_job', + jobName: job.name, + details: { reason: reason ?? 'No reason provided' }, + userApproved: plan.mode !== 'autopilot', + }; + + await this.appendAuditLog(plan.id, auditEntry); + this.showToast('Mission Control', `Job "${job.name}" skipped`, 'info'); + await this.reconcile(); + } + + async addJob(jobSpec: Omit, options?: { forkFrom?: string }): Promise { + const plan = await loadPlan(); + if (!plan) { + throw new Error('No active plan'); + } + + const requiresApproval = plan.mode === 'supervisor' || plan.mode === 'copilot'; + + if (requiresApproval && !this.pendingReplanActions.has(`add-${jobSpec.name}`)) { + this.pendingReplanActions.set(`add-${jobSpec.name}`, async () => { + await this.executeAddJob(plan, jobSpec, options); + this.pendingReplanActions.delete(`add-${jobSpec.name}`); + }); + this.showToast('Mission Control', `Approval required to add job "${jobSpec.name}"`, 'warning'); + return; + } + + await this.executeAddJob(plan, jobSpec, options); + } + + private async executeAddJob( + plan: PlanSpec, + jobSpec: Omit, + options?: { forkFrom?: string }, + ): Promise { + const newJob: JobSpec = { + ...jobSpec, + id: randomUUID(), + status: 'queued', + }; + + if (options?.forkFrom) { + const sourceJob = plan.jobs.find((j) => j.name === options.forkFrom); + if (sourceJob?.port && sourceJob?.launchSessionID) { + try { + const client = await waitForServer(sourceJob.port, { timeoutMs: 10000 }); + await forkJobSession(client, sourceJob.launchSessionID, { + sourceJobName: sourceJob.name, + newJobName: newJob.name, + additionalPrompt: newJob.prompt, + }); + newJob.port = sourceJob.port; + newJob.launchSessionID = sourceJob.launchSessionID; + } catch { + } + } + } + + plan.jobs.push(newJob); + await savePlan(plan); + this.jobComms.registerJob(newJob); + + const auditEntry: AuditLogEntry = { + timestamp: new Date().toISOString(), + action: 'add_job', + jobName: newJob.name, + details: { forkFrom: options?.forkFrom }, + userApproved: plan.mode !== 'autopilot', + }; + + await this.appendAuditLog(plan.id, auditEntry); + this.showToast('Mission Control', `Job "${newJob.name}" added to plan`, 'info'); + await this.reconcile(); + } + + async reorderJobs(newOrder: string[]): Promise { + const plan = await loadPlan(); + if (!plan) { + throw new Error('No active plan'); + } + + if (newOrder.length !== plan.jobs.length) { + throw new Error('New order must contain all jobs'); + } + + const currentNames = new Set(plan.jobs.map((j) => j.name)); + const newNames = new Set(newOrder); + if (![...currentNames].every((name) => newNames.has(name))) { + throw new Error('New order contains unknown job names'); + } + + const requiresApproval = plan.mode === 'supervisor' || plan.mode === 'copilot'; + + if (requiresApproval && !this.pendingReplanActions.has('reorder')) { + this.pendingReplanActions.set('reorder', async () => { + await this.executeReorderJobs(plan, newOrder); + this.pendingReplanActions.delete('reorder'); + }); + this.showToast('Mission Control', 'Approval required to reorder jobs', 'warning'); + return; + } + + await this.executeReorderJobs(plan, newOrder); + } + + private async executeReorderJobs(plan: PlanSpec, newOrder: string[]): Promise { + const jobMap = new Map(plan.jobs.map((j) => [j.name, j])); + const reorderedJobs = newOrder.map((name) => jobMap.get(name)!); + + for (let i = 0; i < reorderedJobs.length; i++) { + reorderedJobs[i].mergeOrder = i; + } + + plan.jobs = reorderedJobs; + await savePlan(plan); + + const auditEntry: AuditLogEntry = { + timestamp: new Date().toISOString(), + action: 'reorder_jobs', + details: { newOrder }, + userApproved: plan.mode !== 'autopilot', + }; + + await this.appendAuditLog(plan.id, auditEntry); + this.showToast('Mission Control', 'Jobs reordered', 'info'); + await this.reconcile(); + } + + private async appendAuditLog(planId: string, entry: AuditLogEntry): Promise { + const plan = await loadPlan(); + if (!plan || plan.id !== planId) { + return; + } + + const auditLog = plan.auditLog ?? []; + auditLog.push(entry); + await updatePlanFields(planId, { auditLog }); + } + + relayFinding(fromJob: string, toJob: string, context: RelayContext): void { + this.jobComms.relayFinding(fromJob, toJob, context); + this.showToast('Mission Control', `Finding relayed from "${fromJob}" to "${toJob}"`, 'info'); + } + + async approveReplanAction(actionId: string): Promise { + const action = this.pendingReplanActions.get(actionId); + if (!action) { + throw new Error(`No pending replan action with ID "${actionId}"`); + } + await action(); + } + + getPendingReplanActions(): Array<{ id: string; description: string }> { + return Array.from(this.pendingReplanActions.entries()).map(([id, _]) => ({ + id, + description: this.getReplanDescription(id), + })); + } + + private getReplanDescription(actionId: string): string { + if (actionId.startsWith('skip-')) { + return `Skip job "${actionId.slice(5)}"`; + } + if (actionId.startsWith('add-')) { + return `Add job "${actionId.slice(4)}"`; + } + if (actionId === 'reorder') { + return 'Reorder jobs'; + } + return actionId; + } + async resumePlan(): Promise { const plan = await loadPlan(); if (!plan || (plan.status !== 'running' && plan.status !== 'paused')) { diff --git a/src/lib/plan-state.ts b/src/lib/plan-state.ts index f68661e..feea703 100644 --- a/src/lib/plan-state.ts +++ b/src/lib/plan-state.ts @@ -120,6 +120,7 @@ export interface PlanFieldUpdates { checkpointContext?: CheckpointContext | null; completedAt?: string; prUrl?: string; + auditLog?: PlanSpec['auditLog']; } /** diff --git a/src/lib/plan-types.ts b/src/lib/plan-types.ts index 6dd19ff..18b841f 100644 --- a/src/lib/plan-types.ts +++ b/src/lib/plan-types.ts @@ -19,6 +19,16 @@ export interface CheckpointContext { touchSetPatterns?: string[]; } +export type AuditAction = 'skip_job' | 'add_job' | 'reorder_jobs' | 'fork_session' | 'relay_finding' | 'fix_prompted'; + +export interface AuditLogEntry { + timestamp: string; + action: AuditAction; + jobName?: string; + details: Record; + userApproved?: boolean; +} + export type JobStatus = | 'queued' | 'waiting_deps' @@ -43,13 +53,14 @@ export interface PlanSpec { jobs: JobSpec[]; integrationBranch: string; integrationWorktree?: string; - baseCommit: string; // SHA of main when plan started + baseCommit: string; createdAt: string; completedAt?: string; prUrl?: string; checkpoint?: CheckpointType | null; checkpointContext?: CheckpointContext | null; launchSessionID?: string; + auditLog?: AuditLogEntry[]; } export interface JobSpec { @@ -70,6 +81,10 @@ export interface JobSpec { symlinkDirs?: string[]; commands?: string[]; mode?: 'vanilla' | 'plan' | 'ralph' | 'ulw'; + relayPatterns?: string[]; + port?: number; + serverUrl?: string; + launchSessionID?: string; } export const VALID_PLAN_TRANSITIONS: Record = { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 3b4ae6e..b7b8eb6 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -55,6 +55,16 @@ export const JobStatusSchema = z.enum([ 'canceled', ]); +export const AuditActionSchema = z.enum(['skip_job', 'add_job', 'reorder_jobs', 'fork_session', 'relay_finding', 'fix_prompted']); + +export const AuditLogEntrySchema = z.object({ + timestamp: z.string(), + action: AuditActionSchema, + jobName: z.string().optional(), + details: z.record(z.string(), z.unknown()), + userApproved: z.boolean().optional(), +}); + export const JobSpecSchema = z.object({ id: z.string(), name: z.string(), @@ -75,6 +85,8 @@ export const JobSpecSchema = z.object({ mode: z.enum(['vanilla', 'plan', 'ralph', 'ulw']).optional(), port: z.number().optional(), serverUrl: z.string().optional(), + relayPatterns: z.array(z.string()).optional(), + launchSessionID: z.string().optional(), }); export const FailureKindSchema = z.enum(['touchset', 'merge_conflict', 'test_failure', 'job_failed']); @@ -103,6 +115,7 @@ export const PlanSpecSchema = z.object({ checkpointContext: CheckpointContextSchema.nullable().optional(), ghAuthenticated: z.boolean().optional(), launchSessionID: z.string().optional(), + auditLog: z.array(AuditLogEntrySchema).optional(), }); export const WorktreeSetupSchema = z.object({ @@ -132,6 +145,7 @@ export const MCConfigSchema = z.object({ portRangeStart: z.number().optional(), portRangeEnd: z.number().optional(), serverPassword: z.string().optional(), + fixBeforeRollbackTimeout: z.number().optional(), omo: OmoConfigSchema, }); diff --git a/src/lib/sdk-client.ts b/src/lib/sdk-client.ts index fc19ad5..7fb6b3a 100644 --- a/src/lib/sdk-client.ts +++ b/src/lib/sdk-client.ts @@ -103,3 +103,43 @@ export async function createSessionAndPrompt( await sendPrompt(client, sessionId, prompt, agent, model); return sessionId; } + +export interface ForkSessionOptions { + sourceJobName: string; + newJobName: string; + additionalPrompt?: string; + agent?: string; + model?: { providerID: string; modelID: string }; +} + +export async function forkJobSession( + client: OpencodeClient, + sourceSessionId: string, + options: ForkSessionOptions, +): Promise { + const forkResult = await client.session.fork({ + path: { id: sourceSessionId }, + body: {}, + }); + + if (!forkResult.data) { + throw new Error( + `Failed to fork session: ${forkResult.error ? JSON.stringify(forkResult.error) : 'unknown error'}`, + ); + } + + const newSessionId = forkResult.data.id; + + if (options.additionalPrompt) { + const contextPrompt = `Forked from job "${options.sourceJobName}" as "${options.newJobName}".\n\nAdditional context: ${options.additionalPrompt}`; + await sendPrompt( + client, + newSessionId, + contextPrompt, + options.agent, + options.model, + ); + } + + return newSessionId; +} From 36294ba82098909533b1f0c52e4f2fe7e3c9324f Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 14:14:56 -0600 Subject: [PATCH 12/17] fix: add fixBeforeRollbackTimeout to config round-trip test --- tests/lib/config.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts index da22aa0..2926764 100644 --- a/tests/lib/config.test.ts +++ b/tests/lib/config.test.ts @@ -189,6 +189,7 @@ describe('config', () => { useServeMode: true, portRangeStart: 14100, portRangeEnd: 14199, + fixBeforeRollbackTimeout: 120000, omo: { enabled: true, defaultMode: 'ulw', From eb2506fa4d349cf4cd24a629fe65529a600a6ba5 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 14:41:51 -0600 Subject: [PATCH 13/17] feat: add permission policy engine with configurable per-job/per-plan rules (#69) --- src/lib/config.ts | 2 + src/lib/monitor.ts | 125 +++++++++++++--- src/lib/permission-policy.ts | 213 ++++++++++++++++++++++++++++ src/lib/plan-types.ts | 4 + src/lib/question-relay.ts | 17 +++ src/lib/schemas.ts | 20 +++ tests/lib/config.test.ts | 6 +- tests/lib/permission-policy.test.ts | 191 +++++++++++++++++++++++++ 8 files changed, 558 insertions(+), 20 deletions(-) create mode 100644 src/lib/permission-policy.ts create mode 100644 tests/lib/permission-policy.test.ts diff --git a/src/lib/config.ts b/src/lib/config.ts index 59096dd..4e8646e 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,6 +3,7 @@ import { homedir } from 'os'; import { z } from 'zod'; import { getDataDir } from './paths'; import { MCConfigSchema, PartialMCConfigSchema } from './schemas'; +import { PermissionPolicy } from './permission-policy'; import { atomicWrite } from './utils'; export type WorktreeSetup = { @@ -26,6 +27,7 @@ const DEFAULT_CONFIG: MCConfig = { portRangeStart: 14100, portRangeEnd: 14199, fixBeforeRollbackTimeout: 120000, + defaultPermissionPolicy: PermissionPolicy.getDefaultPolicy(), omo: { enabled: false, defaultMode: 'vanilla', diff --git a/src/lib/monitor.ts b/src/lib/monitor.ts index 1699aee..ecd5fb7 100644 --- a/src/lib/monitor.ts +++ b/src/lib/monitor.ts @@ -2,9 +2,11 @@ import { EventEmitter } from 'events'; import { getRunningJobs, updateJob, type Job } from './job-state.js'; import { isPaneRunning, capturePane, captureExitStatus } from './tmux.js'; import { loadConfig } from './config.js'; -import { readReport, type AgentReport } from './reports.js'; +import { readReport } from './reports.js'; import { createJobClient } from './sdk-client.js'; import { QuestionRelay, type PermissionRequest } from './question-relay.js'; +import { loadPlan } from './plan-state.js'; +import { PermissionPolicy, type PermissionPolicyConfig } from './permission-policy.js'; import type { OpencodeClient } from '@opencode-ai/sdk'; type JobEventType = 'complete' | 'failed' | 'blocked' | 'needs_review' | 'awaiting_input' | 'agent_report'; @@ -299,15 +301,7 @@ export class JobMonitor extends EventEmitter { } case 'permission.updated': { - const permission: PermissionRequest = { - id: event.properties?.id || event.id || 'unknown', - type: this.inferPermissionType(event), - path: event.properties?.path || event.path, - description: event.properties?.description || event.description || 'Unknown permission request', - }; - - const accumulator = this.getOrCreateEventAccumulator(job.id); - await this.questionRelay.handlePermissionRequest(job, permission, accumulator.currentFile); + void this.handlePermissionUpdate(job, event); break; } } @@ -315,24 +309,117 @@ export class JobMonitor extends EventEmitter { private inferPermissionType(event: any): PermissionRequest['type'] { const eventData = event.properties || event; - const typeHint = eventData.type || eventData.permissionType || ''; + const metadata = eventData.metadata ?? {}; + const typeHint = String(eventData.type || eventData.permissionType || metadata.type || '').toLowerCase(); - if (typeHint.includes('file') || typeHint.includes('write') || typeHint.includes('edit')) { - return 'file_operation'; - } - if (typeHint.includes('shell') || typeHint.includes('command') || typeHint.includes('exec')) { - return 'shell_command'; + if (typeHint.includes('mcp') || typeHint.includes('tool')) { + return 'mcp'; } - if (typeHint.includes('network') || typeHint.includes('http') || typeHint.includes('fetch')) { + if (typeHint.includes('network') || typeHint.includes('http') || typeHint.includes('fetch') || typeHint.includes('web')) { return 'network'; } - if (typeHint.includes('mcp') || typeHint.includes('tool')) { - return 'mcp'; + if (typeHint.includes('shell') || typeHint.includes('command') || typeHint.includes('exec') || typeHint.includes('bash')) { + return 'shell_command'; + } + if (typeHint.includes('file') || typeHint.includes('write') || typeHint.includes('edit') || typeHint === 'read') { + return 'file_operation'; } return 'other'; } + private extractString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; + } + + private extractPermissionPath(event: any): string | undefined { + const eventData = event.properties || event; + const metadata = eventData.metadata; + + const directPath = this.extractString(eventData.path) + ?? this.extractString(eventData.file) + ?? this.extractString(event.path); + if (directPath) { + return directPath; + } + + if (metadata && typeof metadata === 'object') { + const metadataPath = (metadata as Record).path; + return this.extractString(metadataPath); + } + + return undefined; + } + + private buildPermissionRequest(event: any): PermissionRequest { + const eventData = event.properties || event; + const rawType = this.extractString(eventData.type) || this.extractString(eventData.permissionType) || 'unknown'; + const path = this.extractPermissionPath(event); + const description = this.extractString(eventData.description) + || this.extractString(eventData.title) + || this.extractString(event.description) + || 'Unknown permission request'; + + return { + id: this.extractString(eventData.id) || this.extractString(event.id) || 'unknown', + type: this.inferPermissionType(event), + path, + target: path, + action: this.extractString(eventData.title) || rawType, + rawType, + description, + }; + } + + private async resolvePolicyForJob(job: Job): Promise { + const config = await loadConfig(); + const globalPolicy = config.defaultPermissionPolicy; + + let planPolicy: PermissionPolicyConfig | undefined; + let jobPolicy: PermissionPolicyConfig | undefined; + + if (job.planId) { + const plan = await loadPlan(); + if (plan && plan.id === job.planId) { + planPolicy = plan.permissionPolicy; + jobPolicy = plan.jobs.find((planJob) => planJob.name === job.name)?.permissionPolicy; + } + } + + return PermissionPolicy.resolvePolicy({ + jobPolicy, + planPolicy, + globalPolicy, + }); + } + + private async handlePermissionUpdate(job: Job, event: any): Promise { + try { + const permission = this.buildPermissionRequest(event); + const accumulator = this.getOrCreateEventAccumulator(job.id); + const policy = await this.resolvePolicyForJob(job); + const decision = policy.evaluate(permission, { worktreePath: job.worktreePath }); + const decisionLog = policy.getDecisionLog(); + const lastLog = decisionLog[decisionLog.length - 1]; + const reason = lastLog?.reason ?? 'Permission decision from policy'; + + if (decision === 'auto-approve') { + await this.questionRelay.respondToPermission(job, permission.id, true, `Auto-approved by permission policy: ${reason}`); + return; + } + + if (decision === 'deny') { + await this.questionRelay.respondToPermission(job, permission.id, false, `Denied by permission policy: ${reason}`); + console.warn(`[Monitor] Permission denied by policy for job ${job.name}: ${permission.description}`); + return; + } + + await this.questionRelay.handlePermissionRequest(job, permission, accumulator.currentFile); + } catch (error) { + console.error(`[Monitor] Failed to process permission update for job ${job.name}:`, error); + } + } + private isServeModeJob(job: Job): boolean { return job.port !== undefined && job.port > 0; } diff --git a/src/lib/permission-policy.ts b/src/lib/permission-policy.ts new file mode 100644 index 0000000..e6f334f --- /dev/null +++ b/src/lib/permission-policy.ts @@ -0,0 +1,213 @@ +import { resolve } from 'path'; + +export type PermissionPolicyDecision = 'auto-approve' | 'deny' | 'ask-user'; + +export type PolicyScopedRule = { + insideWorktree: PermissionPolicyDecision; + outsideWorktree: PermissionPolicyDecision; +}; + +export type PermissionPolicyConfig = { + permissions: { + fileEdit: PolicyScopedRule; + shellCommand: PolicyScopedRule; + networkAccess: PermissionPolicyDecision; + installPackages: PermissionPolicyDecision; + mcpTools: PermissionPolicyDecision; + }; +}; + +export type PermissionPolicyRequest = { + type: 'file_operation' | 'shell_command' | 'network' | 'mcp' | 'other'; + path?: string; + description?: string; + action?: string; + target?: string; +}; + +export type PermissionJobContext = { + worktreePath: string; +}; + +export type PermissionPolicyLogEntry = { + timestamp: string; + permissionType: keyof PermissionPolicyConfig['permissions'] | 'unknown'; + action: string; + target: string; + policyDecision: PermissionPolicyDecision; + reason: string; +}; + +export type PermissionPolicySources = { + jobPolicy?: PermissionPolicyConfig; + planPolicy?: PermissionPolicyConfig; + globalPolicy?: PermissionPolicyConfig; +}; + +function normalizePathForComparison(path: string): string { + return resolve(path).replace(/\\/g, '/'); +} + +function isInsideWorktree(path: string, worktreePath: string): boolean { + const normalizedPath = normalizePathForComparison(path); + const normalizedWorktree = normalizePathForComparison(worktreePath); + + if (normalizedPath === normalizedWorktree) { + return true; + } + + const suffix = normalizedWorktree.endsWith('/') ? '' : '/'; + return normalizedPath.startsWith(`${normalizedWorktree}${suffix}`); +} + +function looksLikePackageInstall(input?: string): boolean { + if (!input) { + return false; + } + + const normalized = input.toLowerCase(); + const installSignals = ['npm install', 'pnpm add', 'yarn add', 'bun add', 'pip install', 'apt install']; + return installSignals.some((signal) => normalized.includes(signal)); +} + +export class PermissionPolicy { + private readonly policy: PermissionPolicyConfig; + private readonly decisionLog: PermissionPolicyLogEntry[] = []; + + constructor(policy: PermissionPolicyConfig = PermissionPolicy.getDefaultPolicy()) { + this.policy = policy; + } + + static loadPolicy(config?: PermissionPolicyConfig | null): PermissionPolicy { + if (!config) { + return new PermissionPolicy(PermissionPolicy.getDefaultPolicy()); + } + return new PermissionPolicy(config); + } + + static resolvePolicy(sources: PermissionPolicySources = {}): PermissionPolicy { + return PermissionPolicy.loadPolicy( + sources.jobPolicy + ?? sources.planPolicy + ?? sources.globalPolicy + ?? PermissionPolicy.getDefaultPolicy(), + ); + } + + static getDefaultPolicy(): PermissionPolicyConfig { + return { + permissions: { + fileEdit: { insideWorktree: 'auto-approve', outsideWorktree: 'deny' }, + shellCommand: { insideWorktree: 'auto-approve', outsideWorktree: 'ask-user' }, + networkAccess: 'deny', + installPackages: 'ask-user', + mcpTools: 'auto-approve', + }, + }; + } + + evaluate( + permissionRequest: PermissionPolicyRequest, + jobContext: PermissionJobContext, + ): PermissionPolicyDecision { + const permissionType = this.resolvePermissionType(permissionRequest); + + let policyDecision: PermissionPolicyDecision; + let reason: string; + + switch (permissionType) { + case 'fileEdit': { + const targetPath = permissionRequest.path; + if (!targetPath) { + policyDecision = 'ask-user'; + reason = 'File edit request is missing path context'; + break; + } + + const inWorktree = isInsideWorktree(targetPath, jobContext.worktreePath); + policyDecision = inWorktree + ? this.policy.permissions.fileEdit.insideWorktree + : this.policy.permissions.fileEdit.outsideWorktree; + reason = inWorktree + ? 'File edit target is inside worktree' + : 'File edit target is outside worktree'; + break; + } + + case 'shellCommand': { + const targetPath = permissionRequest.path ?? jobContext.worktreePath; + const inWorktree = isInsideWorktree(targetPath, jobContext.worktreePath); + policyDecision = inWorktree + ? this.policy.permissions.shellCommand.insideWorktree + : this.policy.permissions.shellCommand.outsideWorktree; + reason = inWorktree + ? 'Shell command target is inside worktree' + : 'Shell command target is outside worktree'; + break; + } + + case 'installPackages': + policyDecision = this.policy.permissions.installPackages; + reason = 'Package installation request'; + break; + + case 'networkAccess': + policyDecision = this.policy.permissions.networkAccess; + reason = 'Network access request'; + break; + + case 'mcpTools': + policyDecision = this.policy.permissions.mcpTools; + reason = 'MCP tool request'; + break; + + default: + policyDecision = 'ask-user'; + reason = 'Unknown permission type'; + break; + } + + const action = permissionRequest.action ?? permissionRequest.description ?? permissionRequest.type; + const target = permissionRequest.target ?? permissionRequest.path ?? '(unknown target)'; + + this.decisionLog.push({ + timestamp: new Date().toISOString(), + permissionType, + action, + target, + policyDecision, + reason, + }); + + return policyDecision; + } + + getDecisionLog(): readonly PermissionPolicyLogEntry[] { + return this.decisionLog; + } + + private resolvePermissionType( + permissionRequest: PermissionPolicyRequest, + ): keyof PermissionPolicyConfig['permissions'] | 'unknown' { + if (permissionRequest.type === 'mcp') { + return 'mcpTools'; + } + + if (permissionRequest.type === 'network') { + return 'networkAccess'; + } + + if (permissionRequest.type === 'file_operation') { + return 'fileEdit'; + } + + if (permissionRequest.type === 'shell_command') { + const packageInstall = + looksLikePackageInstall(permissionRequest.description) || + looksLikePackageInstall(permissionRequest.action); + return packageInstall ? 'installPackages' : 'shellCommand'; + } + + return 'unknown'; + } +} diff --git a/src/lib/plan-types.ts b/src/lib/plan-types.ts index 18b841f..e37f0c6 100644 --- a/src/lib/plan-types.ts +++ b/src/lib/plan-types.ts @@ -1,3 +1,5 @@ +import type { PermissionPolicyConfig } from './permission-policy'; + export type PlanStatus = | 'pending' | 'running' @@ -61,6 +63,7 @@ export interface PlanSpec { checkpointContext?: CheckpointContext | null; launchSessionID?: string; auditLog?: AuditLogEntry[]; + permissionPolicy?: PermissionPolicyConfig; } export interface JobSpec { @@ -85,6 +88,7 @@ export interface JobSpec { port?: number; serverUrl?: string; launchSessionID?: string; + permissionPolicy?: PermissionPolicyConfig; } export const VALID_PLAN_TRANSITIONS: Record = { diff --git a/src/lib/question-relay.ts b/src/lib/question-relay.ts index 9b5007e..9d92d03 100644 --- a/src/lib/question-relay.ts +++ b/src/lib/question-relay.ts @@ -10,6 +10,9 @@ export interface PermissionRequest { type: 'file_operation' | 'shell_command' | 'network' | 'mcp' | 'other'; path?: string; description: string; + action?: string; + target?: string; + rawType?: string; } /** @@ -219,6 +222,20 @@ export class QuestionRelay { return result; } + async respondToPermission( + job: Job, + permissionId: string, + approved: boolean, + message?: string, + ): Promise { + if (!job.port) { + return; + } + + const client = this.getClient(job.port); + await this.replyToPermission(client, permissionId, approved, message); + } + /** * Relay a question to the launching session */ diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index b7b8eb6..132bda5 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -65,6 +65,23 @@ export const AuditLogEntrySchema = z.object({ userApproved: z.boolean().optional(), }); +export const PermissionPolicyDecisionSchema = z.enum(['auto-approve', 'deny', 'ask-user']); + +export const PermissionPolicyScopeSchema = z.object({ + insideWorktree: PermissionPolicyDecisionSchema, + outsideWorktree: PermissionPolicyDecisionSchema, +}); + +export const PermissionPolicySchema = z.object({ + permissions: z.object({ + fileEdit: PermissionPolicyScopeSchema, + shellCommand: PermissionPolicyScopeSchema, + networkAccess: PermissionPolicyDecisionSchema, + installPackages: PermissionPolicyDecisionSchema, + mcpTools: PermissionPolicyDecisionSchema, + }), +}); + export const JobSpecSchema = z.object({ id: z.string(), name: z.string(), @@ -87,6 +104,7 @@ export const JobSpecSchema = z.object({ serverUrl: z.string().optional(), relayPatterns: z.array(z.string()).optional(), launchSessionID: z.string().optional(), + permissionPolicy: PermissionPolicySchema.optional(), }); export const FailureKindSchema = z.enum(['touchset', 'merge_conflict', 'test_failure', 'job_failed']); @@ -116,6 +134,7 @@ export const PlanSpecSchema = z.object({ ghAuthenticated: z.boolean().optional(), launchSessionID: z.string().optional(), auditLog: z.array(AuditLogEntrySchema).optional(), + permissionPolicy: PermissionPolicySchema.optional(), }); export const WorktreeSetupSchema = z.object({ @@ -146,6 +165,7 @@ export const MCConfigSchema = z.object({ portRangeEnd: z.number().optional(), serverPassword: z.string().optional(), fixBeforeRollbackTimeout: z.number().optional(), + defaultPermissionPolicy: PermissionPolicySchema.optional(), omo: OmoConfigSchema, }); diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts index 2926764..1db88e7 100644 --- a/tests/lib/config.test.ts +++ b/tests/lib/config.test.ts @@ -3,6 +3,7 @@ import { join } from 'path'; import { homedir } from 'os'; import { tmpdir } from 'os'; import type { MCConfig } from '../../src/lib/config'; +import { PermissionPolicy } from '../../src/lib/permission-policy'; vi.mock('../../src/lib/paths', () => ({ getDataDir: vi.fn(), @@ -199,7 +200,10 @@ describe('config', () => { await saveConfig(originalConfig); const loaded = await loadConfig(); - expect(loaded).toEqual(originalConfig); + expect(loaded).toEqual({ + ...originalConfig, + defaultPermissionPolicy: PermissionPolicy.getDefaultPolicy(), + }); }); it('should update config on subsequent saves', async () => { diff --git a/tests/lib/permission-policy.test.ts b/tests/lib/permission-policy.test.ts new file mode 100644 index 0000000..fb897c1 --- /dev/null +++ b/tests/lib/permission-policy.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'bun:test'; +import { + PermissionPolicy, + type PermissionPolicyConfig, +} from '../../src/lib/permission-policy'; + +const WORKTREE = '/tmp/project'; + +describe('PermissionPolicy', () => { + it('default policy auto-approves worktree operations', () => { + const policy = PermissionPolicy.loadPolicy(PermissionPolicy.getDefaultPolicy()); + + const fileDecision = policy.evaluate( + { + type: 'file_operation', + path: '/tmp/project/src/index.ts', + description: 'edit file in worktree', + }, + { worktreePath: WORKTREE }, + ); + + const shellDecision = policy.evaluate( + { + type: 'shell_command', + path: '/tmp/project', + description: 'run build in worktree', + }, + { worktreePath: WORKTREE }, + ); + + expect(fileDecision).toBe('auto-approve'); + expect(shellDecision).toBe('auto-approve'); + }); + + it('default policy denies outside worktree file operations', () => { + const policy = PermissionPolicy.loadPolicy(PermissionPolicy.getDefaultPolicy()); + + const decision = policy.evaluate( + { + type: 'file_operation', + path: '/etc/passwd', + description: 'edit outside worktree', + }, + { worktreePath: WORKTREE }, + ); + + expect(decision).toBe('deny'); + }); + + it('per-job policy overrides global default', () => { + const globalPolicy: PermissionPolicyConfig = { + permissions: { + fileEdit: { insideWorktree: 'auto-approve', outsideWorktree: 'deny' }, + shellCommand: { insideWorktree: 'auto-approve', outsideWorktree: 'ask-user' }, + networkAccess: 'deny', + installPackages: 'ask-user', + mcpTools: 'auto-approve', + }, + }; + + const perJobPolicy: PermissionPolicyConfig = { + permissions: { + fileEdit: { insideWorktree: 'auto-approve', outsideWorktree: 'deny' }, + shellCommand: { insideWorktree: 'deny', outsideWorktree: 'deny' }, + networkAccess: 'deny', + installPackages: 'deny', + mcpTools: 'auto-approve', + }, + }; + + const policy = PermissionPolicy.resolvePolicy({ + jobPolicy: perJobPolicy, + globalPolicy, + }); + + const decision = policy.evaluate( + { + type: 'shell_command', + path: '/tmp/project', + description: 'run command in worktree', + }, + { worktreePath: WORKTREE }, + ); + + expect(decision).toBe('deny'); + }); + + it('policy cascade applies job > plan > global > default priority', () => { + const globalPolicy: PermissionPolicyConfig = { + permissions: { + fileEdit: { insideWorktree: 'deny', outsideWorktree: 'deny' }, + shellCommand: { insideWorktree: 'deny', outsideWorktree: 'deny' }, + networkAccess: 'deny', + installPackages: 'deny', + mcpTools: 'deny', + }, + }; + + const planPolicy: PermissionPolicyConfig = { + permissions: { + fileEdit: { insideWorktree: 'ask-user', outsideWorktree: 'deny' }, + shellCommand: { insideWorktree: 'ask-user', outsideWorktree: 'deny' }, + networkAccess: 'ask-user', + installPackages: 'ask-user', + mcpTools: 'ask-user', + }, + }; + + const jobPolicy: PermissionPolicyConfig = { + permissions: { + fileEdit: { insideWorktree: 'auto-approve', outsideWorktree: 'deny' }, + shellCommand: { insideWorktree: 'auto-approve', outsideWorktree: 'ask-user' }, + networkAccess: 'deny', + installPackages: 'ask-user', + mcpTools: 'auto-approve', + }, + }; + + const fromJob = PermissionPolicy.resolvePolicy({ + jobPolicy, + planPolicy, + globalPolicy, + }); + const fromPlan = PermissionPolicy.resolvePolicy({ + planPolicy, + globalPolicy, + }); + const fromGlobal = PermissionPolicy.resolvePolicy({ globalPolicy }); + const fromDefault = PermissionPolicy.resolvePolicy({}); + + expect( + fromJob.evaluate( + { type: 'mcp', description: 'execute mcp tool' }, + { worktreePath: WORKTREE }, + ), + ).toBe('auto-approve'); + + expect( + fromPlan.evaluate( + { type: 'mcp', description: 'execute mcp tool' }, + { worktreePath: WORKTREE }, + ), + ).toBe('ask-user'); + + expect( + fromGlobal.evaluate( + { type: 'mcp', description: 'execute mcp tool' }, + { worktreePath: WORKTREE }, + ), + ).toBe('deny'); + + expect( + fromDefault.evaluate( + { type: 'mcp', description: 'execute mcp tool' }, + { worktreePath: WORKTREE }, + ), + ).toBe('auto-approve'); + }); + + it('logs all policy decisions', () => { + const policy = PermissionPolicy.resolvePolicy({}); + + policy.evaluate( + { + type: 'file_operation', + path: '/tmp/project/src/a.ts', + description: 'edit a file', + action: 'edit', + }, + { worktreePath: WORKTREE }, + ); + + policy.evaluate( + { + type: 'file_operation', + path: '/etc/passwd', + description: 'edit outside file', + action: 'edit', + }, + { worktreePath: WORKTREE }, + ); + + const log = policy.getDecisionLog(); + expect(log.length).toBe(2); + expect(typeof log[0].timestamp).toBe('string'); + expect(log[0].permissionType).toBe('fileEdit'); + expect(log[0].policyDecision).toBe('auto-approve'); + expect(log[1].policyDecision).toBe('deny'); + expect(log[1].reason.length).toBeGreaterThan(0); + }); +}); From 2c32e5ed29ac7a49c30ed2c5042192c3de9133de Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 14:41:58 -0600 Subject: [PATCH 14/17] test: add comprehensive coverage for dynamic orchestration features (#68) --- tests/lib/job-comms.test.ts | 279 +++++++++++++++++++ tests/lib/merge-train.test.ts | 207 +++++++++++++- tests/lib/orchestrator.test.ts | 492 +++++++++++++++++++++++++++++++++ tests/lib/sdk-client.test.ts | 125 ++++++++- 4 files changed, 1101 insertions(+), 2 deletions(-) create mode 100644 tests/lib/job-comms.test.ts diff --git a/tests/lib/job-comms.test.ts b/tests/lib/job-comms.test.ts new file mode 100644 index 0000000..83da571 --- /dev/null +++ b/tests/lib/job-comms.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import type { JobSpec } from '../../src/lib/plan-types'; +import { JobComms, type RelayContext } from '../../src/lib/job-comms'; +import * as sdkClientMod from '../../src/lib/sdk-client'; + +function makeJob(name: string, overrides: Partial = {}): JobSpec { + return { + id: `${name}-id`, + name, + prompt: `do ${name}`, + status: 'running', + ...overrides, + }; +} + +describe('JobComms', () => { + let comms: JobComms; + + beforeEach(() => { + comms = new JobComms(); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('registerJob / unregisterJob', () => { + it('registers a job and makes it visible in getAllRegisteredJobs', () => { + comms.registerJob(makeJob('alpha')); + + expect(comms.getAllRegisteredJobs()).toContain('alpha'); + }); + + it('unregisters a job and removes it from all lookups', () => { + comms.registerJob(makeJob('alpha', { relayPatterns: ['src/**'] })); + comms.unregisterJob('alpha'); + + expect(comms.getAllRegisteredJobs()).not.toContain('alpha'); + expect(comms.getRelayPatternsForJob('alpha')).toBeUndefined(); + }); + + it('registers relay patterns from job spec', () => { + comms.registerJob(makeJob('alpha', { relayPatterns: ['src/**', 'tests/'] })); + + expect(comms.getRelayPatternsForJob('alpha')).toEqual(['src/**', 'tests/']); + }); + + it('does not register patterns when relayPatterns is empty', () => { + comms.registerJob(makeJob('alpha', { relayPatterns: [] })); + + expect(comms.getRelayPatternsForJob('alpha')).toBeUndefined(); + }); + }); + + describe('relayFinding', () => { + it('stores a message in the target job message bus', () => { + comms.registerJob(makeJob('sender')); + comms.registerJob(makeJob('receiver')); + + const context: RelayContext = { + finding: 'API signature changed', + filePath: 'src/api.ts', + lineNumber: 42, + severity: 'warning', + }; + comms.relayFinding('sender', 'receiver', context); + + const messages = comms.getMessagesForJob('receiver'); + expect(messages).toHaveLength(1); + expect(messages[0].from).toBe('sender'); + expect(messages[0].to).toBe('receiver'); + expect(messages[0].context.finding).toBe('API signature changed'); + expect(messages[0].context.filePath).toBe('src/api.ts'); + expect(messages[0].context.lineNumber).toBe(42); + expect(messages[0].context.severity).toBe('warning'); + expect(messages[0].timestamp).toBeTruthy(); + }); + + it('accumulates multiple messages for the same target', () => { + comms.registerJob(makeJob('a')); + comms.registerJob(makeJob('b')); + + comms.relayFinding('a', 'b', { finding: 'first' }); + comms.relayFinding('a', 'b', { finding: 'second' }); + + expect(comms.getMessagesForJob('b')).toHaveLength(2); + }); + + it('stores messages even for unregistered targets', () => { + comms.relayFinding('unknown-sender', 'unknown-receiver', { finding: 'orphan' }); + + const messages = comms.getMessagesForJob('unknown-receiver'); + expect(messages).toHaveLength(1); + expect(messages[0].context.finding).toBe('orphan'); + }); + }); + + describe('getMessagesForJob / clearMessagesForJob', () => { + it('returns empty array when no messages exist', () => { + expect(comms.getMessagesForJob('nonexistent')).toEqual([]); + }); + + it('clears messages for a job', () => { + comms.registerJob(makeJob('target')); + comms.relayFinding('source', 'target', { finding: 'msg' }); + + comms.clearMessagesForJob('target'); + + expect(comms.getMessagesForJob('target')).toHaveLength(0); + }); + }); + + describe('shouldRelayForFile', () => { + it('returns true when file matches a relay pattern', () => { + comms.registerJob(makeJob('alpha', { relayPatterns: ['src/**'] })); + + expect(comms.shouldRelayForFile('alpha', 'src/lib/foo.ts')).toBe(true); + }); + + it('returns false when file does not match any pattern', () => { + comms.registerJob(makeJob('alpha', { relayPatterns: ['src/**'] })); + + expect(comms.shouldRelayForFile('alpha', 'tests/foo.test.ts')).toBe(false); + }); + + it('returns false for unregistered job', () => { + expect(comms.shouldRelayForFile('nonexistent', 'src/foo.ts')).toBe(false); + }); + + it('handles trailing slash pattern (directory prefix)', () => { + comms.registerJob(makeJob('alpha', { relayPatterns: ['docs/'] })); + + expect(comms.shouldRelayForFile('alpha', 'docs/guide.md')).toBe(true); + expect(comms.shouldRelayForFile('alpha', 'src/app.ts')).toBe(false); + }); + + it('returns false when job has no relay patterns', () => { + comms.registerJob(makeJob('alpha')); + + expect(comms.shouldRelayForFile('alpha', 'src/foo.ts')).toBe(false); + }); + }); + + describe('deliverMessages', () => { + it('delivers messages to job via SDK and clears queue', async () => { + const mockClient = { session: { promptAsync: async () => ({}) } }; + const waitSpy = spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any); + const sendSpy = spyOn(sdkClientMod, 'sendPrompt').mockResolvedValue(); + + comms.registerJob(makeJob('target')); + comms.relayFinding('source', 'target', { + finding: 'Schema change detected', + filePath: 'src/schema.ts', + severity: 'error', + }); + + const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' }); + const delivered = await comms.deliverMessages(job); + + expect(delivered).toBe(1); + expect(waitSpy).toHaveBeenCalledWith(14100, { timeoutMs: 5000 }); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith( + mockClient, + 'session-1', + expect.stringContaining('Schema change detected'), + ); + expect(comms.getMessagesForJob('target')).toHaveLength(0); + }); + + it('returns 0 when no messages exist for job', async () => { + const job = makeJob('empty', { port: 14100, launchSessionID: 'session-1' }); + const delivered = await comms.deliverMessages(job); + expect(delivered).toBe(0); + }); + + it('returns 0 when job has no port', async () => { + comms.registerJob(makeJob('target')); + comms.relayFinding('source', 'target', { finding: 'msg' }); + + const job = makeJob('target'); + const delivered = await comms.deliverMessages(job); + expect(delivered).toBe(0); + }); + + it('returns 0 when server connection fails', async () => { + spyOn(sdkClientMod, 'waitForServer').mockRejectedValue(new Error('connection refused')); + + comms.registerJob(makeJob('target')); + comms.relayFinding('source', 'target', { finding: 'msg' }); + + const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' }); + const delivered = await comms.deliverMessages(job); + expect(delivered).toBe(0); + }); + + it('filters messages by source when filterFrom is specified', async () => { + const mockClient = { session: { promptAsync: async () => ({}) } }; + spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any); + const sendSpy = spyOn(sdkClientMod, 'sendPrompt').mockResolvedValue(); + + comms.registerJob(makeJob('target')); + comms.relayFinding('job-a', 'target', { finding: 'from A' }); + comms.relayFinding('job-b', 'target', { finding: 'from B' }); + comms.relayFinding('job-c', 'target', { finding: 'from C' }); + + const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' }); + const delivered = await comms.deliverMessages(job, { filterFrom: ['job-a', 'job-c'] }); + + expect(delivered).toBe(2); + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + + it('returns 0 when filterFrom matches no messages', async () => { + comms.registerJob(makeJob('target')); + comms.relayFinding('job-a', 'target', { finding: 'from A' }); + + const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' }); + const delivered = await comms.deliverMessages(job, { filterFrom: ['nonexistent'] }); + expect(delivered).toBe(0); + }); + + it('formats relay prompt with all context fields', async () => { + const mockClient = { session: { promptAsync: async () => ({}) } }; + spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any); + const sendSpy = spyOn(sdkClientMod, 'sendPrompt').mockResolvedValue(); + + comms.registerJob(makeJob('target')); + comms.relayFinding('api-job', 'target', { + finding: 'Endpoint removed', + filePath: 'src/routes.ts', + lineNumber: 55, + severity: 'error', + }); + + const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' }); + await comms.deliverMessages(job); + + const promptArg = sendSpy.mock.calls[0][2]; + expect(promptArg).toContain('[Inter-Job Communication from api-job]'); + expect(promptArg).toContain('Severity: ERROR'); + expect(promptArg).toContain('Finding: Endpoint removed'); + expect(promptArg).toContain('File: src/routes.ts'); + expect(promptArg).toContain('Line: 55'); + }); + + it('formats relay prompt without optional fields', async () => { + const mockClient = { session: { promptAsync: async () => ({}) } }; + spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any); + const sendSpy = spyOn(sdkClientMod, 'sendPrompt').mockResolvedValue(); + + comms.registerJob(makeJob('target')); + comms.relayFinding('source', 'target', { finding: 'Simple message' }); + + const job = makeJob('target', { port: 14100, launchSessionID: 'session-1' }); + await comms.deliverMessages(job); + + const promptArg = sendSpy.mock.calls[0][2]; + expect(promptArg).toContain('Finding: Simple message'); + expect(promptArg).not.toContain('Severity:'); + expect(promptArg).not.toContain('File:'); + expect(promptArg).not.toContain('Line:'); + }); + }); + + describe('getAllRegisteredJobs', () => { + it('returns all registered job names', () => { + comms.registerJob(makeJob('a')); + comms.registerJob(makeJob('b')); + comms.registerJob(makeJob('c')); + + const jobs = comms.getAllRegisteredJobs(); + expect(jobs).toHaveLength(3); + expect(jobs).toContain('a'); + expect(jobs).toContain('b'); + expect(jobs).toContain('c'); + }); + }); +}); diff --git a/tests/lib/merge-train.test.ts b/tests/lib/merge-train.test.ts index 489b1d7..eae0256 100644 --- a/tests/lib/merge-train.test.ts +++ b/tests/lib/merge-train.test.ts @@ -1,9 +1,10 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import { join } from 'path'; import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import type { JobSpec } from '../../src/lib/plan-types'; import { MergeTrain, checkMergeability, detectInstallCommand, detectTestCommand, validateTouchSet } from '../../src/lib/merge-train'; +import * as sdkClientMod from '../../src/lib/sdk-client'; type TestRepo = { rootDir: string; @@ -520,6 +521,210 @@ describe('validateTouchSet', () => { }); }); +describe('fix-before-rollback', () => { + let testRepo: TestRepo; + + beforeEach(async () => { + testRepo = await setupRepo(); + }); + + afterEach(() => { + rmSync(testRepo.rootDir, { recursive: true, force: true }); + mock.restore(); + }); + + it('serve-mode job: prompts agent on test failure and retests', async () => { + await createBranchCommit(testRepo.repoDir, 'feature-fix', 'fix.txt', 'fix\n'); + + const marker = join(testRepo.integrationWorktree, '.retest-pass'); + const testScript = `test -f '${marker}'`; + + const mockClient = { session: {} }; + spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any); + spyOn(sdkClientMod, 'sendPrompt').mockImplementation(async () => { + writeFileSync(marker, 'fixed'); + }); + + const train = new MergeTrain(testRepo.integrationWorktree, { + testCommand: testScript, + testTimeout: 10000, + fixBeforeRollbackTimeout: 10, + }); + const job = makeJob('feature-fix'); + job.port = 14100; + job.launchSessionID = 'session-abc'; + train.enqueue(job); + + const result = await train.processNext(); + + expect(result.success).toBe(true); + expect(sdkClientMod.waitForServer).toHaveBeenCalledWith(14100, { timeoutMs: 10000 }); + expect(sdkClientMod.sendPrompt).toHaveBeenCalledWith( + mockClient, + 'session-abc', + expect.stringContaining('Test Failure in Merge Train'), + ); + }); + + it('serve-mode job: rolls back when retest also fails', async () => { + await createBranchCommit(testRepo.repoDir, 'feature-still-broken', 'broken.txt', 'broken\n'); + + const headBefore = await mustExec( + ['git', 'rev-parse', 'HEAD'], + testRepo.integrationWorktree, + ); + + const mockClient = { session: {} }; + spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any); + spyOn(sdkClientMod, 'sendPrompt').mockResolvedValue(); + + const train = new MergeTrain(testRepo.integrationWorktree, { + testCommand: 'false', + testTimeout: 10000, + fixBeforeRollbackTimeout: 10, + }); + const job = makeJob('feature-still-broken'); + job.port = 14100; + job.launchSessionID = 'session-abc'; + train.enqueue(job); + + const result = await train.processNext(); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.type).toBe('test_failure'); + } + + const headAfter = await mustExec( + ['git', 'rev-parse', 'HEAD'], + testRepo.integrationWorktree, + ); + expect(headAfter).toBe(headBefore); + }); + + it('TUI-mode job (no port): immediately rolls back without prompting', async () => { + await createBranchCommit(testRepo.repoDir, 'feature-tui-fail', 'tui.txt', 'tui\n'); + + const headBefore = await mustExec( + ['git', 'rev-parse', 'HEAD'], + testRepo.integrationWorktree, + ); + + const waitSpy = spyOn(sdkClientMod, 'waitForServer'); + + const train = new MergeTrain(testRepo.integrationWorktree, { + testCommand: 'false', + testTimeout: 10000, + fixBeforeRollbackTimeout: 10, + }); + train.enqueue(makeJob('feature-tui-fail')); + + const result = await train.processNext(); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.type).toBe('test_failure'); + } + expect(waitSpy).not.toHaveBeenCalled(); + + const headAfter = await mustExec( + ['git', 'rev-parse', 'HEAD'], + testRepo.integrationWorktree, + ); + expect(headAfter).toBe(headBefore); + }); + + it('serve-mode job: falls through to rollback when SDK connection fails', async () => { + await createBranchCommit(testRepo.repoDir, 'feature-sdk-fail', 'sdk.txt', 'sdk\n'); + + const headBefore = await mustExec( + ['git', 'rev-parse', 'HEAD'], + testRepo.integrationWorktree, + ); + + spyOn(sdkClientMod, 'waitForServer').mockRejectedValue(new Error('connection refused')); + + const train = new MergeTrain(testRepo.integrationWorktree, { + testCommand: 'false', + testTimeout: 10000, + fixBeforeRollbackTimeout: 10, + }); + const job = makeJob('feature-sdk-fail'); + job.port = 14100; + job.launchSessionID = 'session-abc'; + train.enqueue(job); + + const result = await train.processNext(); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.type).toBe('test_failure'); + } + + const headAfter = await mustExec( + ['git', 'rev-parse', 'HEAD'], + testRepo.integrationWorktree, + ); + expect(headAfter).toBe(headBefore); + }); + + it('serve-mode job: prompt includes job name and test output', async () => { + await createBranchCommit(testRepo.repoDir, 'feature-prompt-check', 'check.txt', 'check\n'); + + const mockClient = { session: {} }; + spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any); + const sendSpy = spyOn(sdkClientMod, 'sendPrompt').mockResolvedValue(); + + const train = new MergeTrain(testRepo.integrationWorktree, { + testCommand: 'echo "assertion failed: expected 3 got 4" && false', + testTimeout: 10000, + fixBeforeRollbackTimeout: 10, + }); + const job = makeJob('feature-prompt-check'); + job.port = 14100; + job.launchSessionID = 'session-abc'; + train.enqueue(job); + + await train.processNext(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + const promptArg = sendSpy.mock.calls[0][2]; + expect(promptArg).toContain('feature-prompt-check'); + expect(promptArg).toContain('assertion failed'); + }); + + it('serve-mode job: does not prompt when launchSessionID is missing', async () => { + await createBranchCommit(testRepo.repoDir, 'feature-no-session', 'ns.txt', 'ns\n'); + + const headBefore = await mustExec( + ['git', 'rev-parse', 'HEAD'], + testRepo.integrationWorktree, + ); + + const waitSpy = spyOn(sdkClientMod, 'waitForServer'); + + const train = new MergeTrain(testRepo.integrationWorktree, { + testCommand: 'false', + testTimeout: 10000, + fixBeforeRollbackTimeout: 10, + }); + const job = makeJob('feature-no-session'); + job.port = 14100; + train.enqueue(job); + + const result = await train.processNext(); + + expect(result.success).toBe(false); + expect(waitSpy).not.toHaveBeenCalled(); + + const headAfter = await mustExec( + ['git', 'rev-parse', 'HEAD'], + testRepo.integrationWorktree, + ); + expect(headAfter).toBe(headBefore); + }); +}); + describe('checkMergeability', () => { let testRepo: TestRepo; diff --git a/tests/lib/orchestrator.test.ts b/tests/lib/orchestrator.test.ts index f69b5af..b808a09 100644 --- a/tests/lib/orchestrator.test.ts +++ b/tests/lib/orchestrator.test.ts @@ -12,6 +12,7 @@ import * as worktreeMod from '../../src/lib/worktree'; import * as promptFileMod from '../../src/lib/prompt-file'; import * as reportsMod from '../../src/lib/reports'; import * as modelTrackerMod from '../../src/lib/model-tracker'; +import * as sdkClientMod from '../../src/lib/sdk-client'; function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; @@ -1336,3 +1337,494 @@ describe('plan-scoped branch naming', () => { ); }); }); + +describe('orchestrator dynamic replanning', () => { + let planState: PlanSpec | null; + let runningJobs: Job[]; + let monitor: FakeMonitor; + + function makeConfig() { + return { + defaultPlacement: 'session' as const, + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + maxParallel: 3, + }; + } + + beforeEach(() => { + planState = null; + runningJobs = []; + monitor = new FakeMonitor(); + + spyOn(planStateMod, 'loadPlan').mockImplementation(async () => clone(planState)); + spyOn(planStateMod, 'savePlan').mockImplementation(async (plan: PlanSpec) => { + planState = clone(plan); + }); + spyOn(planStateMod, 'updatePlanJob').mockImplementation( + async (planId: string, jobName: string, updates: Partial) => { + if (!planState || planState.id !== planId) { + return; + } + planState.jobs = planState.jobs.map((job) => + job.name === jobName ? { ...job, ...updates } : job, + ); + }, + ); + spyOn(planStateMod, 'updatePlanFields').mockImplementation( + async (planId: string, updates: Partial) => { + if (!planState || planState.id !== planId) { + return; + } + if (updates.status !== undefined) planState.status = updates.status; + if (updates.checkpoint !== undefined) planState.checkpoint = updates.checkpoint; + if (updates.checkpointContext !== undefined) planState.checkpointContext = updates.checkpointContext; + if (updates.completedAt !== undefined) planState.completedAt = updates.completedAt; + if (updates.prUrl !== undefined) planState.prUrl = updates.prUrl; + if (updates.auditLog !== undefined) planState.auditLog = updates.auditLog; + }, + ); + spyOn(planStateMod, 'clearPlan').mockImplementation(async () => { + planState = null; + }); + spyOn(planStateMod, 'validateGhAuth').mockResolvedValue(true); + + spyOn(integrationMod, 'createIntegrationBranch').mockResolvedValue({ + branch: 'mc/integration-plan-1', + worktreePath: '/tmp/integration-plan-1', + }); + spyOn(integrationMod, 'deleteIntegrationBranch').mockResolvedValue(); + + spyOn(jobStateMod, 'getRunningJobs').mockImplementation(async () => clone(runningJobs)); + spyOn(jobStateMod, 'addJob').mockResolvedValue(); + spyOn(jobStateMod, 'updateJob').mockResolvedValue(); + spyOn(jobStateMod, 'removeJob').mockResolvedValue(); + spyOn(jobStateMod, 'loadJobState').mockImplementation(async () => ({ + version: 2, + jobs: runningJobs, + updatedAt: new Date().toISOString(), + })); + + spyOn(worktreeMod, 'createWorktree').mockResolvedValue('/tmp/wt/job-a'); + spyOn(worktreeMod, 'removeWorktree').mockResolvedValue(); + + spyOn(tmuxMod, 'createSession').mockResolvedValue(); + spyOn(tmuxMod, 'createWindow').mockResolvedValue(); + spyOn(tmuxMod, 'getCurrentSession').mockReturnValue('main'); + spyOn(tmuxMod, 'isInsideTmux').mockReturnValue(true); + spyOn(tmuxMod, 'isPaneRunning').mockResolvedValue(true); + spyOn(tmuxMod, 'killSession').mockResolvedValue(); + spyOn(tmuxMod, 'killWindow').mockResolvedValue(); + spyOn(tmuxMod, 'sendKeys').mockResolvedValue(); + spyOn(tmuxMod, 'setPaneDiedHook').mockResolvedValue(); + + spyOn(mergeTrainMod, 'checkMergeability').mockResolvedValue({ canMerge: true }); + + spyOn(promptFileMod, 'writePromptFile').mockResolvedValue('/tmp/wt/job-a/.mc-prompt'); + spyOn(promptFileMod, 'cleanupPromptFile').mockImplementation(() => {}); + spyOn(promptFileMod, 'writeLauncherScript').mockResolvedValue('/tmp/wt/job-a/.mc-launcher.sh'); + spyOn(promptFileMod, 'cleanupLauncherScript').mockImplementation(() => {}); + + spyOn(reportsMod, 'removeReport').mockResolvedValue(); + spyOn(modelTrackerMod, 'getCurrentModel').mockReturnValue('test-model'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('skipJob', () => { + it('marks job as canceled in autopilot mode', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [ + makeJob('skip-me', { status: 'queued' }), + makeJob('keep-me', { status: 'queued' }), + ], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.skipJob('skip-me', 'no longer needed'); + + expect(planStateMod.updatePlanJob).toHaveBeenCalledWith('plan-1', 'skip-me', { status: 'canceled' }); + }); + + it('records skip in audit log with reason', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [makeJob('skip-me', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.skipJob('skip-me', 'scope reduced'); + + expect(planState?.auditLog).toBeDefined(); + expect(planState!.auditLog).toHaveLength(1); + expect(planState!.auditLog![0].action).toBe('skip_job'); + expect(planState!.auditLog![0].jobName).toBe('skip-me'); + expect(planState!.auditLog![0].details).toEqual({ reason: 'scope reduced' }); + }); + + it('provides default reason when none given', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [makeJob('skip-me', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.skipJob('skip-me'); + + expect(planState!.auditLog![0].details).toEqual({ reason: 'No reason provided' }); + }); + + it('throws when no active plan exists', async () => { + planState = null; + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + + await expect(orchestrator.skipJob('any')).rejects.toThrow('No active plan'); + }); + + it('throws when job not found in plan', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [makeJob('exists')], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + (orchestrator as any).activePlanId = 'plan-1'; + + await expect(orchestrator.skipJob('nonexistent')).rejects.toThrow('not found in plan'); + }); + + it('requires approval in supervisor mode', async () => { + planState = makePlan({ + status: 'running', + mode: 'supervisor', + jobs: [makeJob('skip-me', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.skipJob('skip-me'); + + const pending = orchestrator.getPendingReplanActions(); + expect(pending).toHaveLength(1); + expect(pending[0].id).toBe('skip-skip-me'); + expect(planStateMod.updatePlanJob).not.toHaveBeenCalledWith('plan-1', 'skip-me', { status: 'canceled' }); + }); + }); + + describe('addJob', () => { + it('inserts new job into plan and saves', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [makeJob('existing', { status: 'running' })], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.addJob({ + name: 'new-job', + prompt: 'do new things', + }); + + expect(planState?.jobs).toHaveLength(2); + const addedJob = planState?.jobs.find((j) => j.name === 'new-job'); + expect(addedJob).toBeDefined(); + expect(addedJob?.status).toBe('queued'); + expect(addedJob?.prompt).toBe('do new things'); + expect(addedJob?.id).toBeTruthy(); + }); + + it('records add_job in audit log', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [makeJob('existing', { status: 'running' })], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.addJob({ name: 'new-job', prompt: 'do it' }); + + expect(planState?.auditLog).toHaveLength(1); + expect(planState!.auditLog![0].action).toBe('add_job'); + expect(planState!.auditLog![0].jobName).toBe('new-job'); + }); + + it('attempts session fork when forkFrom is specified', async () => { + const mockClient = { session: { fork: async () => ({ data: { id: 'forked-session' } }) } }; + spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any); + spyOn(sdkClientMod, 'forkJobSession').mockResolvedValue('forked-session'); + + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [makeJob('source-job', { status: 'running', port: 14100, launchSessionID: 'src-session' })], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.addJob( + { name: 'forked-job', prompt: 'continue work' }, + { forkFrom: 'source-job' }, + ); + + expect(sdkClientMod.forkJobSession).toHaveBeenCalledWith( + mockClient, + 'src-session', + expect.objectContaining({ + sourceJobName: 'source-job', + newJobName: 'forked-job', + additionalPrompt: 'continue work', + }), + ); + }); + + it('records forkFrom in audit log details', async () => { + const mockClient = { session: {} }; + spyOn(sdkClientMod, 'waitForServer').mockResolvedValue(mockClient as any); + spyOn(sdkClientMod, 'forkJobSession').mockResolvedValue('forked-session'); + + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [makeJob('source', { status: 'running', port: 14100, launchSessionID: 'sess-1' })], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.addJob({ name: 'new', prompt: 'go' }, { forkFrom: 'source' }); + + expect(planState!.auditLog![0].details).toEqual( + expect.objectContaining({ forkFrom: 'source' }), + ); + }); + + it('throws when no active plan', async () => { + planState = null; + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + + await expect( + orchestrator.addJob({ name: 'x', prompt: 'y' }), + ).rejects.toThrow('No active plan'); + }); + + it('requires approval in copilot mode', async () => { + planState = makePlan({ + status: 'running', + mode: 'copilot', + jobs: [makeJob('existing')], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.addJob({ name: 'pending-add', prompt: 'do it' }); + + const pending = orchestrator.getPendingReplanActions(); + expect(pending).toHaveLength(1); + expect(pending[0].id).toBe('add-pending-add'); + expect(planState?.jobs).toHaveLength(1); + }); + }); + + describe('reorderJobs', () => { + it('updates mergeOrder of all jobs', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [ + makeJob('a', { status: 'queued', mergeOrder: 0 }), + makeJob('b', { status: 'queued', mergeOrder: 1 }), + makeJob('c', { status: 'queued', mergeOrder: 2 }), + ], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.reorderJobs(['c', 'a', 'b']); + + expect(planState?.jobs[0].name).toBe('c'); + expect(planState?.jobs[0].mergeOrder).toBe(0); + expect(planState?.jobs[1].name).toBe('a'); + expect(planState?.jobs[1].mergeOrder).toBe(1); + expect(planState?.jobs[2].name).toBe('b'); + expect(planState?.jobs[2].mergeOrder).toBe(2); + }); + + it('records reorder in audit log', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [ + makeJob('a', { status: 'queued', mergeOrder: 0 }), + makeJob('b', { status: 'queued', mergeOrder: 1 }), + ], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.reorderJobs(['b', 'a']); + + expect(planState?.auditLog).toHaveLength(1); + expect(planState!.auditLog![0].action).toBe('reorder_jobs'); + expect(planState!.auditLog![0].details).toEqual({ newOrder: ['b', 'a'] }); + }); + + it('throws when job count does not match', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [makeJob('a'), makeJob('b')], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + (orchestrator as any).activePlanId = 'plan-1'; + + await expect(orchestrator.reorderJobs(['a'])).rejects.toThrow('must contain all jobs'); + }); + + it('throws when unknown job names present', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [makeJob('a'), makeJob('b')], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + (orchestrator as any).activePlanId = 'plan-1'; + + await expect(orchestrator.reorderJobs(['a', 'z'])).rejects.toThrow('unknown job names'); + }); + + it('throws when no active plan', async () => { + planState = null; + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + + await expect(orchestrator.reorderJobs(['a'])).rejects.toThrow('No active plan'); + }); + }); + + describe('audit log', () => { + it('accumulates entries across multiple operations', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [ + makeJob('a', { status: 'queued', mergeOrder: 0 }), + makeJob('b', { status: 'queued', mergeOrder: 1 }), + ], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.skipJob('a', 'not needed'); + await orchestrator.addJob({ name: 'c', prompt: 'do c' }); + + expect(planState?.auditLog).toHaveLength(2); + expect(planState!.auditLog![0].action).toBe('skip_job'); + expect(planState!.auditLog![1].action).toBe('add_job'); + }); + + it('marks userApproved false in autopilot', async () => { + planState = makePlan({ + status: 'running', + mode: 'autopilot', + jobs: [makeJob('x', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.skipJob('x'); + + expect(planState!.auditLog![0].userApproved).toBe(false); + }); + }); + + describe('approveReplanAction', () => { + it('executes pending action on approval', async () => { + planState = makePlan({ + status: 'running', + mode: 'supervisor', + jobs: [makeJob('skip-target', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + spyOn(orchestrator as any, 'reconcile').mockResolvedValue(undefined); + (orchestrator as any).activePlanId = 'plan-1'; + + await orchestrator.skipJob('skip-target'); + expect(orchestrator.getPendingReplanActions()).toHaveLength(1); + + await orchestrator.approveReplanAction('skip-skip-target'); + + expect(planStateMod.updatePlanJob).toHaveBeenCalledWith('plan-1', 'skip-target', { status: 'canceled' }); + expect(orchestrator.getPendingReplanActions()).toHaveLength(0); + }); + + it('throws when action ID does not exist', async () => { + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any); + + await expect( + orchestrator.approveReplanAction('nonexistent'), + ).rejects.toThrow('No pending replan action'); + }); + }); + + describe('relayFinding', () => { + it('delegates to internal jobComms and shows toast', async () => { + const toastFn = mock(() => {}); + + const orchestrator = new Orchestrator(monitor as any, makeConfig() as any, toastFn as any); + + orchestrator.relayFinding('job-a', 'job-b', { + finding: 'Type mismatch', + severity: 'error', + }); + + expect(toastFn).toHaveBeenCalledWith( + 'Mission Control', + expect.stringContaining('job-a'), + 'info', + expect.any(Number), + ); + }); + }); +}); diff --git a/tests/lib/sdk-client.test.ts b/tests/lib/sdk-client.test.ts index efd5e4b..489562f 100644 --- a/tests/lib/sdk-client.test.ts +++ b/tests/lib/sdk-client.test.ts @@ -3,12 +3,14 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; const mockSessionList = vi.fn(); const mockSessionCreate = vi.fn(); const mockSessionPromptAsync = vi.fn(); +const mockSessionFork = vi.fn(); const mockClient = { session: { list: mockSessionList, create: mockSessionCreate, promptAsync: mockSessionPromptAsync, + fork: mockSessionFork, }, }; @@ -17,7 +19,7 @@ vi.mock('@opencode-ai/sdk', () => ({ })); const sdk = await import('@opencode-ai/sdk'); -const { createJobClient, waitForServer, sendPrompt, createSessionAndPrompt } = await import('../../src/lib/sdk-client'); +const { createJobClient, waitForServer, sendPrompt, createSessionAndPrompt, forkJobSession } = await import('../../src/lib/sdk-client'); describe('sdk-client', () => { beforeEach(() => { @@ -180,4 +182,125 @@ describe('sdk-client', () => { ).rejects.toThrow('Failed to create session'); }); }); + + describe('forkJobSession', () => { + it('should call session.fork with source session ID', async () => { + mockSessionFork.mockResolvedValue({ + data: { id: 'forked-session-id' }, + }); + mockSessionPromptAsync.mockResolvedValue({ data: {} }); + + const newId = await forkJobSession(mockClient as any, 'source-session', { + sourceJobName: 'api-job', + newJobName: 'api-job-v2', + additionalPrompt: 'Continue the API work', + }); + + expect(newId).toBe('forked-session-id'); + expect(mockSessionFork).toHaveBeenCalledWith({ + path: { id: 'source-session' }, + body: {}, + }); + }); + + it('should send context prompt to forked session when additionalPrompt provided', async () => { + mockSessionFork.mockResolvedValue({ + data: { id: 'forked-session-id' }, + }); + mockSessionPromptAsync.mockResolvedValue({ data: {} }); + + await forkJobSession(mockClient as any, 'source-session', { + sourceJobName: 'db-schema', + newJobName: 'db-schema-v2', + additionalPrompt: 'Add indexes', + }); + + expect(mockSessionPromptAsync).toHaveBeenCalledWith({ + path: { id: 'forked-session-id' }, + body: { + parts: [{ + type: 'text', + text: expect.stringContaining('Forked from job "db-schema" as "db-schema-v2"'), + }], + }, + }); + }); + + it('should include additional prompt in context message', async () => { + mockSessionFork.mockResolvedValue({ + data: { id: 'forked-session-id' }, + }); + mockSessionPromptAsync.mockResolvedValue({ data: {} }); + + await forkJobSession(mockClient as any, 'source-session', { + sourceJobName: 'src', + newJobName: 'dst', + additionalPrompt: 'Focus on error handling', + }); + + expect(mockSessionPromptAsync).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + parts: [{ + type: 'text', + text: expect.stringContaining('Focus on error handling'), + }], + }, + }), + ); + }); + + it('should not send prompt when additionalPrompt is not provided', async () => { + mockSessionFork.mockResolvedValue({ + data: { id: 'forked-session-id' }, + }); + + const newId = await forkJobSession(mockClient as any, 'source-session', { + sourceJobName: 'src', + newJobName: 'dst', + }); + + expect(newId).toBe('forked-session-id'); + expect(mockSessionPromptAsync).not.toHaveBeenCalled(); + }); + + it('should pass agent and model to sendPrompt when provided', async () => { + mockSessionFork.mockResolvedValue({ + data: { id: 'forked-session-id' }, + }); + mockSessionPromptAsync.mockResolvedValue({ data: {} }); + + const model = { providerID: 'anthropic', modelID: 'claude-sonnet-4-20250514' }; + await forkJobSession(mockClient as any, 'source-session', { + sourceJobName: 'src', + newJobName: 'dst', + additionalPrompt: 'Continue', + agent: 'build', + model, + }); + + expect(mockSessionPromptAsync).toHaveBeenCalledWith({ + path: { id: 'forked-session-id' }, + body: { + parts: [{ type: 'text', text: expect.any(String) }], + agent: 'build', + model, + }, + }); + }); + + it('should throw when fork call fails', async () => { + mockSessionFork.mockResolvedValue({ + data: undefined, + error: { message: 'fork not supported' }, + }); + + await expect( + forkJobSession(mockClient as any, 'source-session', { + sourceJobName: 'src', + newJobName: 'dst', + }), + ).rejects.toThrow('Failed to fork session'); + }); + }); }); From 17e6a4ca1d95548833bc6407a46da57f64c5a9eb Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Sun, 15 Feb 2026 15:11:09 -0600 Subject: [PATCH 15/17] docs: add v1.5 serve mode, observability, notifications, permissions, and dynamic orchestration test phases --- MANUAL_TEST_PLAN.md | 386 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 364 insertions(+), 22 deletions(-) diff --git a/MANUAL_TEST_PLAN.md b/MANUAL_TEST_PLAN.md index 87f51cb..f9ec92b 100644 --- a/MANUAL_TEST_PLAN.md +++ b/MANUAL_TEST_PLAN.md @@ -3,6 +3,9 @@ > **Context**: We are dogfooding this plugin from within the plugin's own repo. > All `mc_*` tool calls are made by the AI agent (us) in this session. > This plan covers **all 17 MCP tools**, **all 12 job states**, and **all 8 plan states**. +> +> **v1.5 additions**: Phases 13–18 cover serve-mode launch, enhanced `mc_attach`, structured +> observability, session-aware notifications, permission policies, and dynamic orchestration. > **Dynamic Path Convention**: All paths use `$(basename $(git rev-parse --show-toplevel))` instead of > hardcoded project names. This makes the plan portable across repos and forks. @@ -77,6 +80,16 @@ git worktree prune 2>/dev/null || true echo "Cleaning jobs state..." rm -f "$STATE_DIR/state/jobs.json" 2>/dev/null || true +# 12. Clean port allocation (v1.5 serve mode) +echo "Cleaning port allocation..." +rm -f "$STATE_DIR/port.lock" 2>/dev/null || true + +# 13. Kill leaked serve-mode server processes (ports 14100-14199) +echo "Killing leaked serve processes..." +for p in $(lsof -ti:14100-14199 2>/dev/null); do + kill "$p" 2>/dev/null || true +done + echo "=== Nuclear Cleanup Complete ===" ``` @@ -115,6 +128,9 @@ ls -la dist/index.js 9. **Cancel before completion**: Plans must be cancelled before all jobs reach `merged` state. If all jobs merge, the plan auto-pushes to remote and enters `creating_pr` state. 10. **Dynamic paths only**: Never hardcode project names in paths. Always use `$(basename $(git rev-parse --show-toplevel))` or the `$PROJECT_NAME` variable. 11. **Agent timing**: Simple prompts (echo, file creation) complete in 10-25 seconds. If you need the agent to be in `running` state when you check, either check within 5-10 seconds of launch, use a longer-running prompt like "Read every file in src/ and summarize each one", or kill the agent immediately after launch. +12. **Serve mode default (v1.5)**: `useServeMode` defaults to `true`. Jobs launch via `opencode serve` + SDK. To test TUI-mode behavior, temporarily set `"useServeMode": false` in `config.json` and restore afterwards. +13. **Port range (v1.5)**: Serve mode allocates ports from 14100–14199 using `port.lock`. Cleanup releases ports. If stuck, remove `port.lock` manually from the state directory. +14. **Serve mode startup time**: Serve-mode jobs need 5–10 seconds to start (server boot + SDK session creation), compared to 3–5 seconds for TUI mode. Adjust wait times accordingly. --- @@ -134,9 +150,12 @@ Run these tests for basic validation after a code change. References use test ID | 8 | Phase 9 | 9.4 (overview with jobs) | Dashboard with data | | 9 | Phase 5G | 5.76 (retry + relaunch mutual exclusion) | TouchSet param validation | | 10 | Phase 9 | 9.14 (overview after cleanup) | Dashboard cleanup | -| 11 | Phase 12 | Nuclear Cleanup | Clean exit | +| 11 | Phase 13 | 13.1-13.7 (serve mode launch → status → verify port) | Serve mode basics | +| 12 | Phase 14 | 14.3 (mc_attach opens tmux window for serve job) | Enhanced attach | +| 13 | Phase 15 | 15.5 (mc_capture returns JSON for serve job) | Structured capture | +| 14 | Phase 12 | Nuclear Cleanup | Clean exit | -**Pass criteria**: All 11 steps succeed. If any fail, run the full test plan for that phase. +**Pass criteria**: All 14 steps succeed. If any fail, run the full test plan for that phase. --- @@ -146,23 +165,23 @@ All 17 tools must be exercised during this plan. Check off as tested: | Tool | Phase(s) | Notes | |------|----------|-------| -| `mc_launch` | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 | Core lifecycle | -| `mc_jobs` | 1, 2, 3, 4, 5, 6, 9 | List/filter jobs | -| `mc_status` | 1, 2, 9, 10 | Detailed job info | -| `mc_capture` | 1, 2, 7, 8, 10 | Terminal output | -| `mc_attach` | 1, 2 | Tmux attach command | +| `mc_launch` | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 13 | Core lifecycle + serve mode | +| `mc_jobs` | 1, 2, 3, 4, 5, 6, 9, 13 | List/filter jobs | +| `mc_status` | 1, 2, 9, 10, 13, 15 | Detailed job info + serve telemetry | +| `mc_capture` | 1, 2, 7, 8, 10, 15 | Terminal output + structured events | +| `mc_attach` | 1, 2, 14 | Tmux attach / serve TUI window | | `mc_diff` | 1, 2, 4 | Branch comparison | -| `mc_kill` | 1, 2, 3, 4, 6, 7, 8 | Stop running jobs | -| `mc_cleanup` | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12 | Remove artifacts | +| `mc_kill` | 1, 2, 3, 4, 6, 7, 8, 13 | Stop running jobs | +| `mc_cleanup` | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13 | Remove artifacts + port release | | `mc_sync` | 4 | Rebase/merge sync | | `mc_merge` | 4, 6 | Merge to main | | `mc_pr` | — | **NOT tested** (pushes to remote). Structural mention only. | -| `mc_plan` | 5, 6 | Create orchestrated plans | -| `mc_plan_status` | 5, 6 | Plan progress | -| `mc_plan_cancel` | 5, 6 | Cancel active plan | +| `mc_plan` | 5, 6, 18 | Create orchestrated plans + dynamic features | +| `mc_plan_status` | 5, 6, 18 | Plan progress | +| `mc_plan_cancel` | 5, 6, 18 | Cancel active plan | | `mc_plan_approve` | 5, 5G | Approve copilot/supervisor, accept/relaunch/retry touchSet violations | | `mc_report` | 8 | Agent status reporting (filesystem verification) | -| `mc_overview` | 9 | Dashboard summary | +| `mc_overview` | 9, 15 | Dashboard summary + activity indicators | --- @@ -978,6 +997,8 @@ These cannot be directly invoked but can be observed during testing. ## Phase 12 — Final Verification & Nuclear Cleanup +> **Run this LAST** — after all feature phases (including Phases 13–18). + This ensures we leave the repo in **exactly** the same state we found it. ### 12.1 — Run Nuclear Cleanup @@ -1043,6 +1064,14 @@ rm -f "$STATE_DIR/state/plan.json" 2>/dev/null || true # Clean jobs state rm -f "$STATE_DIR/state/jobs.json" 2>/dev/null || true + +# Clean port allocation (v1.5) +rm -f "$STATE_DIR/port.lock" 2>/dev/null || true + +# Kill leaked serve-mode server processes (v1.5) +for p in $(lsof -ti:14100-14199 2>/dev/null); do + kill "$p" 2>/dev/null || true +done ``` ### 12.7 — Verify against pre-test snapshot @@ -1064,6 +1093,301 @@ rm -f "$STATE_DIR/state/jobs.json" 2>/dev/null || true --- +## Phase 13 — Serve Mode Launch & Port Management (#65) + +> **Context**: v1.5 defaults to `useServeMode: true`. Jobs launch via `opencode serve` on an +> allocated port (range 14100–14199), with prompts delivered via the SDK instead of +> `opencode --prompt`. This phase validates the serve-mode launch path, port allocation, +> and port release on cleanup. +> +> **Prerequisites**: `useServeMode` must be `true` in config (this is the default). If you +> previously overrode it, restore: edit `config.json` or remove the `useServeMode` key. +> +> **Timing Note**: Serve-mode jobs need 5–10 seconds to start (server boot + SDK session +> creation). Adjust wait times vs TUI mode (3–5 seconds). + +### 13A: Basic Serve Mode Launch + +| # | Test | Action | Expected | +|---|------|--------|----------| +| 13.1 | Launch serve mode job | `mc_launch` name=tmc-serve, prompt="Create a file test-serve.txt with 'hello from serve'" | Success message includes Port and Server URL | +| 13.2 | **Wait 5-10 seconds** | Allow server startup + SDK session creation | — | +| 13.3 | Verify port in output | Check launch response | Contains `Port: 14100` (or next available) and `Server: http://127.0.0.1:` | +| 13.4 | Verify status includes serve fields | `mc_status` name=tmc-serve | Shows `Port: ` and `Server URL: http://...` under Metadata | +| 13.5 | Verify tmux session exists | `tmux list-sessions \| grep mc-tmc-serve` | Session present (serves `opencode serve`) | +| 13.6 | Verify port lock file | `cat $STATE_DIR/port.lock` | JSON array containing the allocated port number | +| 13.7 | Verify job state persisted | `mc_jobs` | tmc-serve shown as `running` | +| 13.8 | Verify launchSessionID captured | Read `$STATE_DIR/state/jobs.json` | Job entry has `launchSessionID` field matching your current session | + +### 13B: Port Allocation Uniqueness + +| # | Test | Action | Expected | +|---|------|--------|----------| +| 13.9 | Launch second serve job | `mc_launch` name=tmc-serve-2, prompt="echo hello" | Success, different port | +| 13.10 | **Wait 5-10 seconds** | — | — | +| 13.11 | Verify unique ports | Compare `mc_status` tmc-serve vs `mc_status` tmc-serve-2 | Different port numbers (e.g., 14100 and 14101) | +| 13.12 | Both in port lock | Read port.lock | Contains both allocated ports | + +### 13C: Port Release on Cleanup + +| # | Test | Action | Expected | +|---|------|--------|----------| +| 13.13 | Kill first job | `mc_kill` name=tmc-serve | Stopped | +| 13.14 | Cleanup first job | `mc_cleanup` name=tmc-serve, deleteBranch=true | Cleaned | +| 13.15 | Verify port released | Read port.lock | First job's port no longer listed | +| 13.16 | Kill and cleanup second | `mc_kill` name=tmc-serve-2; `mc_cleanup` name=tmc-serve-2, deleteBranch=true | Cleaned | +| 13.17 | Verify all ports released | Read port.lock | Empty array `[]` or file absent | +| 13.18 | Verify clean state | `mc_jobs` | "No jobs found." | + +### 13D: TUI Mode Fallback + +| # | Test | Action | Expected | +|---|------|--------|----------| +| 13.19 | Set useServeMode=false | Edit `$STATE_DIR/config.json`: set `"useServeMode": false` | Config saved | +| 13.20 | Launch TUI mode job | `mc_launch` name=tmc-tui-fallback, prompt="echo hello" | Success — **no** Port or Server URL in output | +| 13.21 | **Wait 3-5 seconds** | — | — | +| 13.22 | Verify no port allocated | `mc_status` name=tmc-tui-fallback | No Port or Server URL fields | +| 13.23 | Cleanup | `mc_kill` name=tmc-tui-fallback; `mc_cleanup` name=tmc-tui-fallback, deleteBranch=true | Cleaned | +| 13.24 | **Restore useServeMode** | Edit `config.json`: set `"useServeMode": true` or remove the key | Config restored to serve-mode default | + +**Phase 13 Gate**: All ports must be released and config restored before proceeding. + +--- + +## Phase 14 — Enhanced mc_attach (#65) + +> **Context**: In serve mode, `mc_attach` opens an `opencode attach ` TUI in a new +> tmux window (when running inside tmux) instead of returning a `tmux attach -t` command. +> This phase validates both serve-mode and TUI-mode attach behavior. + +### 14A: Serve Mode Attach (Inside tmux) + +| # | Test | Action | Expected | +|---|------|--------|----------| +| 14.1 | Launch serve mode job | `mc_launch` name=tmc-attach-s, prompt="echo attach test" | Success with port | +| 14.2 | **Wait 5-10 seconds** | — | — | +| 14.3 | Attach opens tmux window | `mc_attach` name=tmc-attach-s | Returns "Opened TUI for job 'tmc-attach-s' in new tmux window" | +| 14.4 | Verify tmux window created | `tmux list-windows` (in current session) | Window named `mc-tmc-attach-s` exists | +| 14.5 | Clean up attach window | Kill the window: `tmux kill-window -t :mc-tmc-attach-s` | Window removed | + +### 14B: TUI Mode Attach (Backward Compatibility) + +| # | Test | Action | Expected | +|---|------|--------|----------| +| 14.6 | Kill serve job | `mc_kill` name=tmc-attach-s | Stopped | +| 14.7 | Cleanup serve job | `mc_cleanup` name=tmc-attach-s, deleteBranch=true | Cleaned | +| 14.8 | Set useServeMode=false | Edit config | Done | +| 14.9 | Launch TUI job | `mc_launch` name=tmc-attach-t, prompt="echo tui attach" | Success, no port | +| 14.10 | **Wait 3-5 seconds** | — | — | +| 14.11 | Attach returns tmux command | `mc_attach` name=tmc-attach-t | Returns `tmux attach -t mc-tmc-attach-t` (session mode) | +| 14.12 | Cleanup | `mc_kill` name=tmc-attach-t; `mc_cleanup` name=tmc-attach-t, deleteBranch=true | Cleaned | +| 14.13 | Restore useServeMode | Edit config back to `true` or remove key | Done | + +--- + +## Phase 15 — Serve Mode Observability (#66) + +> **Context**: v1.5 enriches `mc_status`, `mc_capture`, and `mc_overview` with structured +> telemetry for serve-mode jobs. `mc_capture` returns JSON events (with `filter` parameter) +> instead of raw terminal text. `mc_status` adds a "Serve Mode Telemetry" section. +> `mc_overview` shows per-job activity indicators (current tool, last activity time). + +### 15A: Enriched mc_status + +| # | Test | Action | Expected | +|---|------|--------|----------| +| 15.1 | Launch serve job | `mc_launch` name=tmc-obs, prompt="Create file observe.txt with 'testing observability'" | Success with port | +| 15.2 | **Wait 10-15 seconds** | Allow SDK events to accumulate | — | +| 15.3 | Status shows telemetry section | `mc_status` name=tmc-obs | Contains "Serve Mode Telemetry:" with fields: Session State, Current File, Files Edited, Last Activity, Events Accumulated | +| 15.4 | Status shows port and server | `mc_status` name=tmc-obs | Metadata section includes `Port: ` and `Server URL: http://...` | + +### 15B: Structured mc_capture (Serve Mode) + +| # | Test | Action | Expected | +|---|------|--------|----------| +| 15.5 | Capture all events | `mc_capture` name=tmc-obs | Returns **JSON** (not raw text) with fields: `job`, `mode: "serve"`, `status`, `filter: "all"`, `summary`, `events` | +| 15.6 | Verify summary structure | Parse JSON from 15.5 | `summary` has: `totalEvents`, `filesEdited`, `currentTool`, `currentFile`, `lastActivityAt` | +| 15.7 | Capture with file.edited filter | `mc_capture` name=tmc-obs, filter="file.edited" | JSON with `filter: "file.edited"`, events only contain `type: "file.edited"` entries | +| 15.8 | Capture with tool filter | `mc_capture` name=tmc-obs, filter="tool" | JSON with `filter: "tool"`, events only contain `type: "tool"` entries | +| 15.9 | Capture with error filter | `mc_capture` name=tmc-obs, filter="error" | JSON with `filter: "error"`, events array (likely empty for successful work) | + +### 15C: Enriched mc_overview + +| # | Test | Action | Expected | +|---|------|--------|----------| +| 15.10 | Overview with serve job | `mc_overview` | Running Jobs section shows activity indicator format | +| 15.11 | Verify activity format | Check running job line for tmc-obs | Format: `- tmc-obs | |