diff --git a/packages/cli/src/__tests__/pi-goal.test.ts b/packages/cli/src/__tests__/pi-goal.test.ts index 7b0a36e53e..83150250af 100644 --- a/packages/cli/src/__tests__/pi-goal.test.ts +++ b/packages/cli/src/__tests__/pi-goal.test.ts @@ -3,9 +3,12 @@ import { describe, test } from 'node:test'; import { GOAL_STATUSES, type GoalStatus } from '@maka/core/goal'; import type { GoalProjection } from '@maka/runtime-host/protocol'; import { formatTokenCount } from '../pi-transcript-format.js'; +import { stripAnsi } from '../tui-ansi.js'; import { formatGoalElapsed, + goalAttachedNoticeText, goalElapsedMs, + goalPausedNoticeText, goalStatusLabel, goalStatusLineText, goalSummaryLines, @@ -34,6 +37,10 @@ function goal(overrides: Partial = {}): GoalProjection { } describe('pi-goal display helpers', () => { + test('strips generic ESC character-set sequences from displayed text', () => { + assert.equal(stripAnsi('\x1b(0goal'), 'goal'); + }); + test('every declared goal status has a label and a live/terminal classification', () => { // Exhaustiveness guard: a new GoalStatus must make a deliberate choice in // both places instead of silently falling through. @@ -97,6 +104,19 @@ describe('pi-goal display helpers', () => { assert.equal(messy[0], 'Goal: Ship the feature'); assert.equal(messy.at(-1), 'Last evaluator note: line one line two'); + const hostile = goalSummaryLines( + goal({ + condition: 'Ship\x1b[2J the\x00 feature', + lastReason: 'safe\x1b]0;spoofed title\x07\nUnicode ✓', + }), + 61_000, + ); + assert.equal(hostile[0], 'Goal: Ship the feature'); + assert.equal(hostile.at(-1), 'Last evaluator note: safe Unicode ✓'); + for (const line of hostile) { + assert.doesNotMatch(line, /[\u0000-\u001f\u007f-\u009f]/u); + } + // A cleared goal keeps its terminal record; the summary must not present // the condition as if it were still armed. const cleared = goalSummaryLines(goal({ status: 'cleared' }), 61_000); @@ -126,4 +146,23 @@ describe('pi-goal display helpers', () => { test('token formatting is the shared status-line formatter', () => { assert.equal(formatTokenCount(45_200), '45k'); }); + + test('pause and attach notices name the loop and its controls', () => { + assert.equal( + goalPausedNoticeText(goal({ lastReason: 'Goal-associated turn was aborted.' })), + 'Goal paused (3/50). Goal-associated turn was aborted. /goal resume continues it, /goal clear stops it.', + ); + assert.equal( + goalPausedNoticeText(goal({ lastReason: null })), + 'Goal paused (3/50). /goal resume continues it, /goal clear stops it.', + ); + // Embedded newlines collapse so the notice stays one line. + assert.equal( + goalAttachedNoticeText(goal({ condition: 'Ship the\n feature' })), + 'Autonomous goal is running (3/50): Ship the feature — /goal shows details, /goal pause pauses it.', + ); + // Long conditions are capped with an ellipsis. + const long = goalAttachedNoticeText(goal({ condition: 'x'.repeat(200) })); + assert.ok(long.includes('…') && long.length <= 210); + }); }); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index d9af68233d..7a31ea0526 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -4987,6 +4987,273 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('/goal pause during a running turn refuses with a clear message instead of steering', async () => { + const terminal = new FakeTerminal(); + const driver = new SteeringTurnDriver(); + driver.goal = armedGoal; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('start the work'); + terminal.input('\r'); + await waitFor(() => terminal.progressStates.at(-1) === true); + + terminal.input('/goal pause'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Cannot control the goal while a turn or another action is running', + ), + ); + // Neither steered into the model nor applied behind the turn's back. + assert.deepEqual(driver.steered, []); + + terminal.input('\x1b'); + terminal.input('\x1b'); + await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('\x03'); + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + + test('/goal pause|resume|clear control the loop and print confirmations', async () => { + const terminal = new FakeTerminal(160, 24); + const driver = new SlashCommandDriver(); + driver.goal = armedGoal; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + // Attaching to a session whose goal is running announces the loop — + // recovery never resumes a token-burning loop silently. + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Autonomous goal is running (2/50)'), + ); + + terminal.input('/goal pause'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Goal paused. /goal resume continues it'), + ); + assert.deepEqual(driver.controlledGoalActions, ['pause']); + // The self-initiated pause must not also print the auto-pause notice, + // and the status line followed the pushed projection. + assert.equal(plainTerminalOutput(terminal.output()).includes('Goal paused (2/50)'), false); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('goal paused 2/50')); + + terminal.input('/goal resume'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Goal resumed.')); + + terminal.input('/goal clear'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Goal cleared.')); + assert.deepEqual(driver.controlledGoalActions, ['pause', 'resume', 'clear']); + // A cleared goal is terminal: the status-line segment disappears from + // the live screen (scrollback keeps earlier frames). + await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('goal 2/50')); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('a host-pushed pause announces itself with resume/clear guidance', async () => { + const terminal = new FakeTerminal(160, 24); + const driver = new SlashCommandDriver(); + driver.goal = armedGoal; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('goal 2/50')); + + // Ctrl+C on a goal continuation turn aborts it and the runtime + // auto-pauses the goal; the pushed projection must surface that. + driver.pushGoal({ + ...armedGoal, + status: 'paused', + revision: 4, + pausedAt: Date.now(), + lastReason: 'Goal-associated turn was aborted.', + }); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Goal paused (2/50). Goal-associated turn was aborted. /goal resume continues it, /goal clear stops it.', + ), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('a settled /goal pause does not suppress a later host-initiated pause notice', async () => { + const terminal = new FakeTerminal(160, 24); + const driver = new SlashCommandDriver(); + driver.goal = armedGoal; + // The host can answer the control RPC before the subscription push folds + // the transition; the suppression flag must settle with the command + // instead of lingering for the push handler. + driver.deferGoalControlPush = true; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('goal 2/50')); + + terminal.input('/goal pause'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Goal paused. /goal resume continues it'), + ); + + // The trailing push of the command's own pause folds onto the settled + // projection: no duplicate auto-pause notice. + driver.pushGoal({ ...armedGoal, status: 'paused', revision: 4, pausedAt: Date.now() }); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('goal paused 2/50')); + assert.equal(plainTerminalOutput(terminal.output()).includes('Goal paused (2/50).'), false); + + terminal.input('/goal resume'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Goal resumed.')); + + // A later host-initiated pause of the same goal (e.g. the Ctrl+C + // auto-pause) must announce itself. + driver.pushGoal({ + ...armedGoal, + status: 'paused', + revision: 6, + pausedAt: Date.now(), + lastReason: 'Goal-associated turn was aborted.', + }); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Goal paused (2/50). Goal-associated turn was aborted.', + ), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('resuming into a session with a live goal announces the auto-continuing loop', async () => { + const terminal = new FakeTerminal(160, 24); + const driver = new SlashCommandDriver([fakeSessionSummary('session-2', '/repo')]); + // Before the switch, the driver has no attached session — the init-time + // check sees nothing; the notice must come from the switch seam. + driver.goal = null; + driver.goalsBySessionId.set('session-2', armedGoal); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + resumeSessionId: 'session-2', + }); + + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Autonomous goal is running (2/50)'), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('/goal control pre-validates impossible transitions', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + driver.goal = armedGoal; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('goal 2/50')); + + terminal.input('/goal resume'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Cannot resume: the goal is active.'), + ); + assert.deepEqual(driver.controlledGoalActions, []); + + // The host's transition rules are mirrored client-side: pause requires + // active|waiting, clear rejects a terminal record. + driver.pushGoal({ ...armedGoal, status: 'paused', revision: 4, pausedAt: Date.now() }); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('goal paused 2/50')); + terminal.input('/goal pause'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Cannot pause: the goal is paused.'), + ); + + driver.pushGoal({ ...armedGoal, status: 'cleared', revision: 5 }); + terminal.input('/goal clear'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Cannot clear: the goal is cleared.'), + ); + assert.deepEqual(driver.controlledGoalActions, []); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('/goal with no goal armed says so, and a bad subcommand shows usage', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); @@ -5008,6 +5275,13 @@ describe('Maka Pi TUI runner', () => { terminal.input('\r'); await waitFor(() => plainTerminalOutput(terminal.output()).includes('Usage: /goal')); + terminal.input('/goal pause'); + terminal.input('\r'); + await waitFor( + () => plainTerminalOutput(terminal.output()).split('No goal set.').length - 1 === 2, + ); + assert.deepEqual(driver.controlledGoalActions, []); + exitMaka(terminal); await Promise.race([ run, @@ -6188,6 +6462,34 @@ class SlashCommandDriver implements MakaSessionDriver { for (const listener of this.goalListeners) listener(goal); } + /** Records control actions and applies them to the local goal like the host would. */ + readonly controlledGoalActions: Array<'pause' | 'resume' | 'clear'> = []; + /** + * When true, controlGoal resolves without pushing the projection first — + * the response-before-push ordering a slow subscription stream can produce. + */ + deferGoalControlPush = false; + /** Per-session goal projections applied when switchSession adopts a session. */ + readonly goalsBySessionId = new Map(); + + controlGoal(action: 'pause' | 'resume' | 'clear'): Promise { + this.controlledGoalActions.push(action); + const goal = this.goal; + if (!goal) return Promise.resolve(null); + const next: GoalProjection = + action === 'clear' + ? { ...goal, status: 'cleared', revision: goal.revision + 1 } + : action === 'pause' + ? { ...goal, status: 'paused', revision: goal.revision + 1, pausedAt: Date.now() } + : { ...goal, status: 'active', revision: goal.revision + 1, pausedAt: null }; + if (this.deferGoalControlPush) { + this.goal = next; + } else { + this.pushGoal(next); + } + return Promise.resolve(next); + } + preparePrompt( prompt: string, options: MakaPreparePromptOptions = {}, @@ -6290,6 +6592,9 @@ class SlashCommandDriver implements MakaSessionDriver { const nextSummary = summary ?? fakeSessionSummary(sessionId); this.orchestrationMode = nextSummary.orchestrationMode ?? 'default'; this.activeBoundaryDisplayMode = this.boundaryDisplayModeBySession.get(nextSummary.id); + if (this.goalsBySessionId.has(sessionId)) { + this.goal = this.goalsBySessionId.get(sessionId) ?? null; + } return switchResult(nextSummary, [...(this.sessionMessages.get(nextSummary.id) ?? [])]); } async listRewindTargets(): Promise { diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 478010da5a..7dda7d111c 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -9,7 +9,7 @@ import type { DirectRequestOperationKey, RuntimeHostSessionSubscription, } from '@maka/runtime-host/client'; -import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; +import { RuntimeHostOperationError, RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; import { SESSION_CONTINUITY_SCHEMA_VERSION, type GoalProjection, @@ -150,6 +150,127 @@ describe('Runtime Host Maka Session driver', () => { unsubscribe(); }); + test('controlGoal applies actions with the snapshot revision and retries conflicts', async () => { + const armedGoal = goalProjection({ status: 'active' }); + const subscription = new FakeSubscription( + continuitySnapshot({ goal: armedGoal }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'session-id', + }); + + // No session attached: no-op, no RPC. + assert.equal(await driver.controlGoal!('pause'), null); + assert.equal( + connection.requests.some(({ operation }) => operation === 'goal.control'), + false, + ); + + await driver.createSession({ + cwd: '/repo', + backend: 'ai-sdk', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }); + + // Clean path: one control request carrying the snapshot revision, no query. + connection.goalControlOutcomes.push( + goalProjection({ status: 'paused', revision: 2, pausedAt: 90 }), + ); + assert.equal((await driver.controlGoal!('pause'))?.status, 'paused'); + let controlRevisions = connection.requests + .filter(({ operation }) => operation === 'goal.control') + .map(({ input }) => (input as OperationInput<'goal.control'>).expectedRevision); + assert.deepEqual(controlRevisions, [1]); + assert.equal( + connection.requests.some(({ operation }) => operation === 'goal.query'), + false, + ); + + // The host broadcasts the pause; the snapshot folds it before the next action. + subscription.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: continuitySnapshot({ + goal: goalProjection({ status: 'paused', revision: 2, pausedAt: 90 }), + projectionRevision: 2, + }), + }); + await waitFor(() => driver.getGoal!()?.revision === 2); + + // Conflict path: re-query for the fresh revision and retry against it. + connection.goalControlOutcomes.push( + new RuntimeHostOperationError('goal.control', 'operation_conflict', 'revision conflict'), + goalProjection({ status: 'active', revision: 4 }), + ); + connection.goalQueryResults.push( + goalProjection({ status: 'paused', revision: 3, pausedAt: 95 }), + ); + assert.equal((await driver.controlGoal!('resume'))?.revision, 4); + controlRevisions = connection.requests + .filter(({ operation }) => operation === 'goal.control') + .map(({ input }) => (input as OperationInput<'goal.control'>).expectedRevision); + assert.deepEqual(controlRevisions, [1, 2, 3]); + assert.equal( + connection.requests.filter(({ operation }) => operation === 'goal.query').length, + 1, + ); + + // Conflict where a concurrent controller removed the goal mid-flight: null + // (for clear, that is the desired end state). + connection.goalControlOutcomes.push( + new RuntimeHostOperationError('goal.control', 'operation_conflict', 'revision conflict'), + ); + connection.goalQueryResults.push(null); + assert.equal(await driver.controlGoal!('clear'), null); + + // Status conflict (invalid transition): the re-query returns the SAME + // revision — every accepted transition bumps it — proving a refusal, not + // a race. The host's reason is rethrown, not a misleading retry-exhaustion + // error, and the loop stops instead of burning the remaining attempts. + connection.goalControlOutcomes.push( + new RuntimeHostOperationError( + 'goal.control', + 'operation_conflict', + 'Goal cannot pause from status paused', + ), + ); + connection.goalQueryResults.push( + goalProjection({ status: 'paused', revision: 2, pausedAt: 90 }), + ); + await assert.rejects(driver.controlGoal!('pause'), /Goal cannot pause from status paused/); + const attempts = connection.requests.filter( + ({ operation }) => operation === 'goal.control', + ).length; + assert.equal(attempts, 5); // 1 clean + 2 raced + 1 raced-then-gone + 1 refused — no futile retries + + // A third conflict has no retry left to serve, so preserve that final Host + // reason instead of replacing it with a generic retry-exhaustion message. + connection.goalControlOutcomes.push( + new RuntimeHostOperationError('goal.control', 'operation_conflict', 'revision conflict 1'), + new RuntimeHostOperationError('goal.control', 'operation_conflict', 'revision conflict 2'), + new RuntimeHostOperationError( + 'goal.control', + 'operation_conflict', + 'Goal cannot resume from status active', + ), + ); + connection.goalQueryResults.push( + goalProjection({ status: 'paused', revision: 3, pausedAt: 95 }), + goalProjection({ status: 'paused', revision: 4, pausedAt: 95 }), + ); + await assert.rejects(driver.controlGoal!('resume'), /Goal cannot resume from status active/); + }); + test('honors explicit Project intent before inheriting the current workspace', async () => { const cases = [ { cwd: '/repo', projectId: null, expected: { kind: 'host_path', path: '/repo' } }, @@ -1310,6 +1431,10 @@ class FakeConnection { interactionQuery: unknown; executionBoundary: unknown = { kind: 'managed', access: 'read_write', revision: 1 }; skillStartBlocked = false; + /** Scripted outcomes for goal.control: return the result goal, or throw (e.g. operation_conflict). */ + readonly goalControlOutcomes: Array = []; + /** Scripted goal.query results, shifted per call; defaults to null (no goal). */ + readonly goalQueryResults: Array = []; readonly value: RuntimeHostMakaSessionDriverInput['connection']; constructor( @@ -1357,6 +1482,21 @@ class FakeConnection { }, }) as OperationOutput; } + if (operation === 'goal.control') { + const outcome = this.goalControlOutcomes.shift(); + if (outcome === undefined) throw new Error('Unexpected goal.control request'); + if (outcome instanceof Error) throw outcome; + return { + sessionId: (input as OperationInput<'goal.control'>).sessionId, + goal: outcome, + } as OperationOutput; + } + if (operation === 'goal.query') { + return { + sessionId: (input as OperationInput<'goal.query'>).sessionId, + goal: this.goalQueryResults.shift() ?? null, + } as OperationOutput; + } if (operation === 'session.configuration.update') { const update = input as OperationInput<'session.configuration.update'>; return { diff --git a/packages/cli/src/pi-goal.ts b/packages/cli/src/pi-goal.ts index 7559f0b94f..8d47d9e556 100644 --- a/packages/cli/src/pi-goal.ts +++ b/packages/cli/src/pi-goal.ts @@ -9,6 +9,7 @@ import type { GoalStatus } from '@maka/core/goal'; import type { GoalProjection } from '@maka/runtime-host/protocol'; import { formatTokenCount } from './pi-transcript-format.js'; +import { stripAnsi } from './tui-ansi.js'; /** * Statuses a watching user still cares about. Terminal goals are hidden from @@ -94,11 +95,40 @@ export function goalStatusLineText( return `goal ${goalStatusLabel(goal.status)} ${counter}`; } +/** Conditions and evaluator notes may legally embed newlines; collapse whitespace so notices stay one line per field. */ +function inlineGoalText(value: string): string { + return stripAnsi(value) + .replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * One-line notice when a goal transitions into paused while attached — + * typically the runtime's abort auto-pause after Ctrl+C on a goal + * continuation turn. Says the loop still exists and how to continue/stop it. + */ +export function goalPausedNoticeText( + goal: Pick, +): string { + const reason = goal.lastReason ? ` ${inlineGoalText(goal.lastReason)}` : ''; + return `Goal paused (${goal.iterations}/${goal.maxIterations}).${reason} /goal resume continues it, /goal clear stops it.`; +} + +/** + * One-line notice when attaching to a session whose durable goal is + * auto-continuing after recovery — a token-burning loop never resumes silently. + */ +export function goalAttachedNoticeText( + goal: Pick, +): string { + const condition = inlineGoalText(goal.condition); + const short = condition.length > 120 ? `${condition.slice(0, 119)}…` : condition; + return `Autonomous goal is running (${goal.iterations}/${goal.maxIterations}): ${short} — /goal shows details, /goal pause pauses it.`; +} + /** Full `/goal` summary. Terminal goals are as welcome here as live ones. */ export function goalSummaryLines(goal: GoalProjection, now: number): string[] { - // Conditions and evaluator notes may legally embed newlines; collapse - // whitespace so each field stays on one notice line. - const inline = (value: string): string => value.replace(/\s+/g, ' ').trim(); const status = `Status: ${goalStatusLabel(goal.status)} · ${goal.iterations}/${goal.maxIterations} iterations`; // Terminal verdicts other than `achieved` carry no freeze timestamp, so a // wall-clock elapsed would keep growing for a loop that already ended. @@ -108,8 +138,8 @@ export function goalSummaryLines(goal: GoalProjection, now: number): string[] { // A cleared goal keeps its terminal record, so say "cleared" up front // instead of presenting the condition as if it were still armed. goal.status === 'cleared' - ? `Cleared goal: ${inline(goal.condition)}` - : `Goal: ${inline(goal.condition)}`, + ? `Cleared goal: ${inlineGoalText(goal.condition)}` + : `Goal: ${inlineGoalText(goal.condition)}`, elapsedMeaningful ? `${status} · ${formatGoalElapsed(goalElapsedMs(goal, now))}` : status, ]; if (goal.tokenBudget !== null) { @@ -119,6 +149,6 @@ export function goalSummaryLines(goal: GoalProjection, now: number): string[] { } else if (goal.tokensSpent > 0) { lines.push(`Tokens: ${formatTokenCount(goal.tokensSpent)}`); } - if (goal.lastReason) lines.push(`Last evaluator note: ${inline(goal.lastReason)}`); + if (goal.lastReason) lines.push(`Last evaluator note: ${inlineGoalText(goal.lastReason)}`); return lines; } diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index f50b97393d..cd89b8a566 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -117,8 +117,15 @@ import { type MakaSlashCommand, } from './pi-tui-pickers.js'; import { formatMakaResumeCommand } from './cli-invocation.js'; -import { goalSummaryLines } from './pi-goal.js'; +import { + goalAttachedNoticeText, + goalPausedNoticeText, + goalStatusLabel, + goalSummaryLines, + isLiveGoalStatus, +} from './pi-goal.js'; import { getTuiPrimaryGuidance } from './tui-primary-guidance.js'; +import type { GoalControlAction, GoalProjection } from '@maka/runtime-host/protocol'; export interface MakaPiTuiInput { /** Launcher command used in resume and recovery instructions. */ @@ -343,13 +350,50 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { rejectClosed = reject; }); - // The driver is the single read authority for goal state: metadata() reads - // the live projection on every render, and the subscription exists only to - // re-render when a host-pushed transition lands. No cached copy, so the - // status line and `/goal` can never drift from the driver's snapshot. - const unsubscribeGoalChanges = input.driver.subscribeGoalChanges?.(() => { + // Rendering reads the driver's live projection directly (metadata()); this + // cache exists only to detect transitions on the push stream — notably the + // abort auto-pause — and to suppress the notice for a pause we initiated. + let currentGoal: GoalProjection | null = input.driver.getGoal?.() ?? null; + // goalId of a `/goal pause` we initiated: its paused projection must not + // re-announce itself — the command prints its own confirmation. + let selfInitiatedPauseGoalId: string | null = null; + const unsubscribeGoalChanges = input.driver.subscribeGoalChanges?.((goal) => { + const previous = currentGoal; + currentGoal = goal; + if ( + goal !== null && + goal.status === 'paused' && + previous?.goalId === goal.goalId && + previous.status !== 'paused' + ) { + if (selfInitiatedPauseGoalId === goal.goalId) { + selfInitiatedPauseGoalId = null; + } else { + // Typically the runtime's abort auto-pause (Ctrl+C on a goal + // continuation turn): the loop still exists and can be resumed. + state.entries.push({ + kind: 'notice', + level: 'info', + text: goalPausedNoticeText(goal), + }); + } + } requestRender(); }); + // Attaching to a session whose durable goal auto-continues after recovery + // must never resume a token-burning loop silently. This covers a driver + // that is already attached at startup; a resumeSessionId attach happens + // later, so switchSession repeats the check after adopting the session. + if ( + currentGoal !== null && + (currentGoal.status === 'active' || currentGoal.status === 'waiting') + ) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: goalAttachedNoticeText(currentGoal), + }); + } const metadata = (): MakaPiTranscriptMetadata => ({ title: input.title, @@ -1395,6 +1439,23 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { relocateCwd === undefined ? undefined : { relocateCwd }, ); await applySwitchResult(result); + // Sync the transition cache to the adopted session's goal, then announce a + // live durable goal: the init-time check ran before the driver attached + // the resumed session, and the goal subscription only announces pause + // transitions. Emitting here — after the transcript replacement that + // would erase a notice from adoption time — keeps an auto-continuing + // token-burning loop from resuming silently. + currentGoal = input.driver.getGoal?.() ?? null; + if ( + currentGoal !== null && + (currentGoal.status === 'active' || currentGoal.status === 'waiting') + ) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: goalAttachedNoticeText(currentGoal), + }); + } if (result.relocation?.changed) { const warning = result.relocation.oldCwdDirty === true @@ -2494,6 +2555,67 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); }; + const controlGoalCommand = async (action: GoalControlAction): Promise => { + const notice = (text: string): void => { + state.entries.push({ kind: 'notice', level: 'info', text }); + requestRender(); + }; + const goal = input.driver.getGoal?.() ?? null; + if (!goal) { + notice('No goal set.'); + return; + } + if (!input.driver.controlGoal) { + notice('Goal control is unavailable on this runtime.'); + return; + } + // Pre-validate against the live projection so an invalid transition gets + // a plain message instead of the host's operation error. These mirror the + // host's rules exactly: pause requires active|waiting, resume requires + // paused, clear rejects a terminal record. + if (action === 'pause' && goal.status !== 'active' && goal.status !== 'waiting') { + notice(`Cannot pause: the goal is ${goalStatusLabel(goal.status)}.`); + return; + } + if (action === 'resume' && goal.status !== 'paused') { + notice(`Cannot resume: the goal is ${goalStatusLabel(goal.status)}.`); + return; + } + if (action === 'clear' && !isLiveGoalStatus(goal.status)) { + notice(`Cannot clear: the goal is ${goalStatusLabel(goal.status)}.`); + return; + } + if (action === 'pause') selfInitiatedPauseGoalId = goal.goalId; + let result: GoalProjection | null; + try { + result = await input.driver.controlGoal(action); + } catch (error) { + selfInitiatedPauseGoalId = null; + throw error; // runControl's reportError surfaces it + } + if (result === null) { + // The goal disappeared to a concurrent controller mid-flight. + selfInitiatedPauseGoalId = null; + notice(action === 'clear' ? 'Goal cleared.' : 'The goal no longer exists.'); + return; + } + // Keep the transition cache on the authoritative response: a trailing push + // of this same transition then folds onto an identical previous state and + // is not mistaken for a fresh one. + currentGoal = result; + if (action === 'pause') { + // Settle the suppression flag: the command's own confirmation has told + // the user, and a lingering flag would suppress a later host-initiated + // pause of this goal (e.g. the Ctrl+C auto-pause). + selfInitiatedPauseGoalId = null; + notice('Goal paused. /goal resume continues it, /goal clear stops it.'); + } else if (action === 'resume') { + notice('Goal resumed.'); + } else { + notice('Goal cleared.'); + } + }; + const slashCommandHandlers = { context: { description: primaryGuidance.commands.context, @@ -2544,19 +2666,39 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { goal: { description: primaryGuidance.commands.goal, run: (parts: string[]) => { - if (parts.length !== 1) { + if (parts.length === 1) { + // Read-only, so no runControl busy gate: an autonomous loop keeps + // the session busy almost by definition, and that is exactly when + // the user wants to inspect it. + showGoalSummary(); + return; + } + const action = parts[1]; + if ( + parts.length !== 2 || + (action !== 'pause' && action !== 'resume' && action !== 'clear') + ) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Usage: /goal [pause|resume|clear]', + }); + requestRender(); + return; + } + // Goal control mutates the durable loop, so it takes the runControl + // write gate — but say so instead of silently swallowing the command + // when a turn or another control action owns the session. + if (busy) { state.entries.push({ kind: 'notice', level: 'error', - text: 'Usage: /goal', + text: 'Cannot control the goal while a turn or another action is running — interrupt it (Esc) or wait for it to finish.', }); requestRender(); return; } - // Read-only, so no runControl busy gate: an autonomous loop keeps the - // session busy almost by definition, and that is exactly when the user - // wants to inspect it. - showGoalSummary(); + void runControl(() => controlGoalCommand(action)); }, }, help: { diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index bae23de17e..e568ea2a0d 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -26,7 +26,11 @@ import { type RuntimeHostTerminalTurn as TerminalTurnSnapshot, } from '@maka/runtime-host/adapter'; import type { DirectRequestOperationKey, RuntimeHostConnection } from '@maka/runtime-host/client'; -import { readRuntimeHostResources, readRuntimeHostSessions } from '@maka/runtime-host/client'; +import { + readRuntimeHostResources, + readRuntimeHostSessions, + RuntimeHostOperationError, +} from '@maka/runtime-host/client'; import { InteractionPendingSnapshot, OperationInput, @@ -36,6 +40,7 @@ import { SessionUpdateResult, SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, WorkspaceTarget, + type GoalControlAction, type GoalProjection, } from '@maka/runtime-host/protocol'; import { @@ -65,6 +70,9 @@ import { } from './session-driver-policy.js'; const MAX_CATALOG_ATTEMPTS = 3; +/** Optimistic-control retries for goal pause/resume/clear (mirrors the desktop client). */ +const GOAL_CONTROL_MAX_ATTEMPTS = 3; + export interface RuntimeHostMakaSessionDriverInput { connection: RuntimeHostSessionDriverConnection; cwd: string; @@ -634,6 +642,46 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return () => this.#goalListeners.delete(listener); } + async controlGoal(action: GoalControlAction): Promise { + const sessionId = this.#sessionId; + if (!sessionId) return null; + let goal = this.getGoal(); + if (!goal) return null; + // Optimistic concurrency with the same shape as the desktop client's + // clearGoal: expectedRevision guards against a concurrent controller, and + // an operation_conflict retries against a freshly queried projection — + // the pushed snapshot may lag the conflicting mutation by a frame. + const goalId = goal.goalId; + for (let attempt = 0; attempt < GOAL_CONTROL_MAX_ATTEMPTS; attempt += 1) { + try { + const result = await this.#request('goal.control', { + sessionId, + goalId, + expectedRevision: goal.revision, + action, + }); + return result.goal; + } catch (error) { + if (!(error instanceof RuntimeHostOperationError) || error.code !== 'operation_conflict') { + throw error; + } + if (attempt === GOAL_CONTROL_MAX_ATTEMPTS - 1) throw error; + const current = (await this.#request('goal.query', { sessionId })).goal; + if (!current || current.goalId !== goalId) return null; + if (current.revision === goal.revision) { + // The host folds invalid transitions into operation_conflict too + // ("Goal cannot pause from status paused"). Every accepted transition + // bumps the revision, so a conflict at an unchanged revision is a + // status refusal, not a race — retrying is futile. Surface the host's + // reason instead of a misleading "revision conflict" exhaustion error. + throw error; + } + goal = current; + } + } + throw new Error(`Goal ${action} failed without a result`); + } + async getContextDiagnostics(): Promise { if (!this.#sessionId) return { status: 'unavailable', reason: 'no_completed_request' }; const diagnostics = await this.#request('context.diagnostics.query', { diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 3af0817fc8..ecb49cd7bb 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -9,7 +9,7 @@ import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; -import type { GoalProjection } from '@maka/runtime-host/protocol'; +import type { GoalControlAction, GoalProjection } from '@maka/runtime-host/protocol'; export interface MakaSessionMoveResult { previousCwd: string; @@ -123,6 +123,14 @@ export interface MakaSessionDriver { * resumed, cleared, or when the attached session changes. */ subscribeGoalChanges?(listener: (goal: GoalProjection | null) => void): () => void; + /** + * Applies a goal control action (pause/resume/clear) with optimistic + * revision retry, mirroring the desktop client. Resolves with the resulting + * projection, or null when no goal is armed (or the goal disappeared to a + * concurrent control action mid-flight — for clear that is the desired end + * state). Optional: drivers without a goal authority reject goal control. + */ + controlGoal?(action: GoalControlAction): Promise; getContextDiagnostics?(): Promise; getOrchestrationMode?(): OrchestrationMode; getPermissionMode?(): PermissionMode; diff --git a/packages/cli/src/tui-ansi.ts b/packages/cli/src/tui-ansi.ts index 79f9b3b01d..fd96fa6170 100644 --- a/packages/cli/src/tui-ansi.ts +++ b/packages/cli/src/tui-ansi.ts @@ -32,7 +32,10 @@ export function disc(tone: DiscTone): string { } export function stripAnsi(text: string): string { - return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ''); + return text.replace( + /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[PX^_][^\x1b]*(?:\x1b\\)|[ -/]*[0-~])/gu, + '', + ); } export function editorTheme(): EditorTheme {