diff --git a/packages/cli/src/__tests__/pi-goal.test.ts b/packages/cli/src/__tests__/pi-goal.test.ts new file mode 100644 index 0000000000..7b0a36e53e --- /dev/null +++ b/packages/cli/src/__tests__/pi-goal.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +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 { + formatGoalElapsed, + goalElapsedMs, + goalStatusLabel, + goalStatusLineText, + goalSummaryLines, + isLiveGoalStatus, +} from '../pi-goal.js'; + +function goal(overrides: Partial = {}): GoalProjection { + return { + goalId: 'goal-1', + revision: 1, + sessionId: 'session-1', + condition: 'Ship the feature', + status: 'active', + setAt: 1_000, + iterations: 3, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokenBudget: null, + tokensSpent: 0, + lastReason: null, + achievedAt: null, + pausedAt: null, + ...overrides, + }; +} + +describe('pi-goal display helpers', () => { + 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. + for (const status of GOAL_STATUSES) { + assert.equal(typeof goalStatusLabel(status), 'string', status); + assert.notEqual(goalStatusLabel(status), '', status); + assert.equal(typeof isLiveGoalStatus(status), 'boolean', status); + } + assert.deepEqual( + GOAL_STATUSES.filter((status) => isLiveGoalStatus(status)), + ['active', 'waiting', 'paused'], + ); + }); + + test('elapsed freezes at pausedAt for a paused goal and never goes negative', () => { + const paused = goal({ status: 'paused', setAt: 1_000, pausedAt: 61_000 }); + assert.equal(goalElapsedMs(paused, 600_000), 60_000); + assert.equal(goalElapsedMs(goal({ setAt: 10_000 }), 5_000), 0); + }); + + test('formatGoalElapsed is compact', () => { + assert.equal(formatGoalElapsed(0), '0s'); + assert.equal(formatGoalElapsed(59_000), '59s'); + assert.equal(formatGoalElapsed(60_000), '1m'); + assert.equal(formatGoalElapsed(90 * 60_000), '1h 30m'); + assert.equal(formatGoalElapsed(2 * 3_600_000), '2h'); + assert.equal(formatGoalElapsed(26 * 3_600_000), '1d 2h'); + }); + + test('status-line text: active shows elapsed, waiting and paused show the state name', () => { + const now = 61_000; + assert.equal(goalStatusLineText(goal({ setAt: 1_000 }), now), 'goal 3/50 1m'); + assert.equal(goalStatusLineText(goal({ status: 'waiting' }), now), 'goal waiting 3/50'); + assert.equal( + goalStatusLineText(goal({ status: 'paused', pausedAt: 31_000 }), now), + 'goal paused 3/50', + ); + }); + + test('summary lines include budget only when set and the evaluator note only when present', () => { + const plain = goalSummaryLines(goal(), 61_000); + assert.equal(plain.length, 2); + assert.match(plain[0]!, /^Goal: Ship the feature$/); + assert.match(plain[1]!, /active · 3\/50 iterations · 1m$/); + + const detailed = goalSummaryLines( + goal({ tokenBudget: 100_000, tokensSpent: 45_200, lastReason: 'tests still failing' }), + 61_000, + ); + assert.deepEqual(detailed.slice(2), [ + 'Tokens: 45k / 100k', + 'Last evaluator note: tests still failing', + ]); + }); + + test('summary collapses embedded whitespace and labels a cleared goal as cleared', () => { + const messy = goalSummaryLines( + goal({ condition: 'Ship the\n feature', lastReason: 'line one\nline two' }), + 61_000, + ); + assert.equal(messy[0], 'Goal: Ship the feature'); + assert.equal(messy.at(-1), 'Last evaluator note: line one line two'); + + // 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); + assert.equal(cleared[0], 'Cleared goal: Ship the feature'); + assert.match(cleared[1]!, /^Status: cleared /); + }); + + test('summary hides elapsed for terminal verdicts that carry no freeze timestamp', () => { + const terminal = [ + 'cleared', + 'impossible', + 'stalled', + 'budget_limited', + 'max_iterations', + ] as const; + for (const status of terminal) { + const lines = goalSummaryLines(goal({ status }), 61_000); + assert.equal(lines[1], `Status: ${goalStatusLabel(status)} · 3/50 iterations`, status); + } + }); + + test('summary keeps the frozen elapsed for an achieved goal', () => { + const lines = goalSummaryLines(goal({ status: 'achieved', achievedAt: 61_000 }), 600_000); + assert.match(lines[1]!, /achieved · 3\/50 iterations · 1m$/); + }); + + test('token formatting is the shared status-line formatter', () => { + assert.equal(formatTokenCount(45_200), '45k'); + }); +}); diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index d4c994a110..f7537e0b70 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -12,6 +12,7 @@ import { applyShellRunUpdateToTranscript, createMakaPiTranscriptState, renderMakaPiActivityStrip, + renderMakaPiStatusLine, renderMakaPiTranscript, reconcileToolsWithStoredMessages, replaceTranscriptWithStoredMessages, @@ -58,6 +59,65 @@ describe('Maka Pi TUI transcript', () => { assert.match(chinese, /\/session\s+切换或恢复会话/); }); + test('renders goal-origin prompts as autonomous provenance, not as user prompts', () => { + const state = createMakaPiTranscriptState(); + replaceTranscriptWithStoredMessages(state, [ + { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 1, + text: '[Goal continuation] The goal is not yet met.', + origin: { kind: 'goal', goalId: 'goal-1' }, + }, + ]); + + assert.deepEqual(state.entries, [ + { kind: 'goal_continuation', text: '[Goal continuation] The goal is not yet met.' }, + ]); + assert.match( + renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'), + /Goal continuation \(autonomous\).*Goal continuation\] The goal is not yet met/s, + ); + }); + + test('status line shows a live goal and hides terminal or absent goals', () => { + const base = { + goalId: 'goal-1', + revision: 1, + sessionId: 'session-1', + condition: 'Ship it', + setAt: Date.now() - 60_000, + iterations: 3, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokenBudget: null, + tokensSpent: 0, + lastReason: null, + achievedAt: null, + pausedAt: null, + } as const; + const active = stripAnsi( + renderMakaPiStatusLine({ ...meta(), goal: { ...base, status: 'active' as const } }, 120), + ); + assert.match(active, /goal 3\/50 1m/); + + const paused = stripAnsi( + renderMakaPiStatusLine( + { ...meta(), goal: { ...base, status: 'paused' as const, pausedAt: Date.now() - 30_000 } }, + 120, + ), + ); + assert.match(paused, /goal paused 3\/50/); + + const achieved = stripAnsi( + renderMakaPiStatusLine({ ...meta(), goal: { ...base, status: 'achieved' as const } }, 120), + ); + assert.doesNotMatch(achieved, /goal/); + assert.doesNotMatch(stripAnsi(renderMakaPiStatusLine({ ...meta(), goal: null }, 120)), /goal/); + }); + test('keeps assistant text after a tool call visible after the tool block', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'inspect the package'); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index efd24ffd38..50d5d67c0b 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -22,6 +22,7 @@ import { type UserQuestionResponse } from '@maka/core/user-question'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; import { type ContextDiagnostics } from '@maka/runtime/context-diagnostics'; +import type { GoalProjection } from '@maka/runtime-host/protocol'; import type { MakaPreparePromptOptions, MakaPreparedSessionTurn, @@ -4801,6 +4802,143 @@ describe('Maka Pi TUI runner', () => { }); }); + describe('/goal command', () => { + const armedGoal: GoalProjection = { + goalId: 'goal-1', + revision: 3, + sessionId: 'session-1', + condition: 'Ship the feature', + status: 'active', + setAt: Date.now() - 60_000, + iterations: 2, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokenBudget: 100_000, + tokensSpent: 12_000, + lastReason: 'tests still failing', + achievedAt: null, + pausedAt: null, + }; + + test('/goal prints the live goal summary and the status line carries the indicator', 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, + }); + + // The startup read must pick the goal up before any turn runs, and the + // status line must show the loop while it burns tokens. + await waitFor(() => plainTerminalOutput(terminal.output()).includes('goal 2/50')); + + terminal.input('/goal'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Goal: Ship the feature'), + ); + const output = plainTerminalOutput(terminal.output()); + assert.match(output, /Status: active · 2\/50 iterations/); + assert.match(output, /Tokens: 12k \/ 100k/); + assert.match(output, /Last evaluator note: tests still failing/); + // No model turn was burned to answer a status question. + assert.equal(driver.prompts.length, 0); + + // A host-pushed transition (e.g. the abort auto-pause after Ctrl+C) + // reaches the status line without any user action. + driver.pushGoal({ + ...armedGoal, + status: 'paused', + revision: 4, + pausedAt: Date.now(), + }); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('goal paused 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 during a running turn answers locally instead of steering into the model', 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); + + // The primary use case: inspect the loop while it is burning tokens. + // Steering "/goal" into the model would confuse it and spend a turn on + // a status question. + terminal.input('/goal'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Goal: Ship the feature'), + ); + assert.deepEqual(driver.steered, []); + + terminal.input('\x1b'); + terminal.input('\x1b'); + await waitFor(() => terminal.progressStates.at(-1) === false); + // Interrupt refills the editor with the cleared queue; clear it before /exit. + terminal.input('\x03'); + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + + test('/goal with no goal armed says so, and a bad subcommand shows usage', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/goal'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('No goal set.')); + + terminal.input('/goal later'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Usage: /goal')); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + }); + test('"quit now" and "请 exit" are sent as ordinary prompts, not the exit word', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); @@ -5272,6 +5410,7 @@ class InterruptibleTurnDriver implements MakaSessionDriver { // be exercised end-to-end without a real runtime. class SteeringTurnDriver implements MakaSessionDriver { stopCalls = 0; + goal: GoalProjection | null = null; readonly steered: string[] = []; readonly queuedMessages: string[] = []; readonly turnOrchestrations: Array = []; @@ -5302,6 +5441,10 @@ class SteeringTurnDriver implements MakaSessionDriver { async *compactSession(): AsyncIterable {} + getGoal(): GoalProjection | null { + return this.goal; + } + // Queue contents travel on ONE path, exactly like the runtime: enqueues // emit a `queue_update` through the parked turn stream; the outcome only // says `queued`. @@ -5921,6 +6064,8 @@ class SlashCommandDriver implements MakaSessionDriver { startNewSessionCalls = 0; resumeCalls = 0; contextDiagnosticsRequests = 0; + goal: GoalProjection | null = null; + readonly goalListeners = new Set<(goal: GoalProjection | null) => void>(); contextDiagnostics: ContextDiagnostics = { status: 'unavailable', reason: 'no_completed_request', @@ -5949,6 +6094,21 @@ class SlashCommandDriver implements MakaSessionDriver { return this.contextDiagnostics; } + getGoal(): GoalProjection | null { + return this.goal; + } + + subscribeGoalChanges(listener: (goal: GoalProjection | null) => void): () => void { + this.goalListeners.add(listener); + return () => this.goalListeners.delete(listener); + } + + /** Simulates a host-pushed goal projection change. */ + pushGoal(goal: GoalProjection | null): void { + this.goal = goal; + for (const listener of this.goalListeners) listener(goal); + } + preparePrompt( prompt: string, options: MakaPreparePromptOptions = {}, 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 c35f0267b0..478010da5a 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -12,6 +12,7 @@ import type { import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; import { SESSION_CONTINUITY_SCHEMA_VERSION, + type GoalProjection, type InteractionPendingSnapshot, type OperationInput, type OperationOutput, @@ -66,6 +67,89 @@ describe('Runtime Host Maka Session driver', () => { ); }); + test('exposes the session goal from the pushed continuity snapshot', 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 yet: no channel, no goal. + assert.equal(driver.getGoal!(), null); + + const observations: Array = []; + const unsubscribe = driver.subscribeGoalChanges!((goal) => + observations.push(goal === null ? null : `${goal.status}@${goal.revision}`), + ); + + await driver.createSession({ + cwd: '/repo', + backend: 'ai-sdk', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }); + + // Channel adoption publishes the snapshot's goal without any RPC. + assert.equal(driver.getGoal!()?.goalId, 'goal-1'); + assert.deepEqual(observations, ['active@1']); + assert.equal( + connection.requests.some(({ operation }) => operation === 'goal.query'), + false, + ); + + // A pushed projection frame with a bumped revision updates the read and + // notifies listeners — this is how an abort auto-pause reaches the TUI. + const pausedGoal = goalProjection({ status: 'paused', revision: 2, pausedAt: 90 }); + subscription.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: continuitySnapshot({ goal: pausedGoal, projectionRevision: 2 }), + }); + await waitFor(() => driver.getGoal!()?.status === 'paused'); + assert.deepEqual(observations, ['active@1', 'paused@2']); + + // An unchanged goal in a later frame must not re-notify. Proven by the + // exact sequence: if it had notified, a duplicate 'paused@2' would appear + // before the 'cleared@3' below. + subscription.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: continuitySnapshot({ goal: pausedGoal, projectionRevision: 3 }), + }); + subscription.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 3, + snapshot: continuitySnapshot({ + goal: goalProjection({ status: 'cleared', revision: 3 }), + projectionRevision: 4, + }), + }); + await waitFor(() => observations.length === 3); + assert.deepEqual(observations, ['active@1', 'paused@2', 'cleared@3']); + + // startNewSession drops the channel: goal reads null and listeners hear it. + driver.startNewSession(); + assert.equal(driver.getGoal!(), null); + assert.deepEqual(observations, ['active@1', 'paused@2', 'cleared@3', null]); + + unsubscribe(); + }); + test('honors explicit Project intent before inheriting the current workspace', async () => { const cases = [ { cwd: '/repo', projectId: null, expected: { kind: 'host_path', path: '/repo' } }, @@ -1444,6 +1528,27 @@ function continuitySnapshot( }; } +function goalProjection(overrides: Partial = {}): GoalProjection { + return { + goalId: 'goal-1', + revision: 1, + sessionId: 'session-id', + condition: 'Ship the feature', + status: 'active', + setAt: 1, + iterations: 2, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokenBudget: 100_000, + tokensSpent: 12_000, + lastReason: null, + achievedAt: null, + pausedAt: null, + ...overrides, + }; +} + function runningTurn(turnId: string, runId: string) { return { sessionId: 'session-1', turnId, runId, status: 'running' as const }; } diff --git a/packages/cli/src/pi-goal.ts b/packages/cli/src/pi-goal.ts new file mode 100644 index 0000000000..7559f0b94f --- /dev/null +++ b/packages/cli/src/pi-goal.ts @@ -0,0 +1,124 @@ +/** + * Goal display helpers for the TUI — the status-line segment and the `/goal` + * summary share this formatting. Pure functions over the host's Goal + * projection; the session driver owns fetching. + * + * Codex parallel: codex-rs/tui/src/goal_display.rs. + */ + +import type { GoalStatus } from '@maka/core/goal'; +import type { GoalProjection } from '@maka/runtime-host/protocol'; +import { formatTokenCount } from './pi-transcript-format.js'; + +/** + * Statuses a watching user still cares about. Terminal goals are hidden from + * the status line (matching the desktop chip) but remain visible to `/goal`. + */ +export function isLiveGoalStatus(status: GoalStatus): boolean { + return status === 'active' || status === 'waiting' || status === 'paused'; +} + +export function goalStatusLabel(status: GoalStatus): string { + switch (status) { + case 'active': + return 'active'; + case 'waiting': + return 'waiting'; + case 'paused': + return 'paused'; + case 'achieved': + return 'achieved'; + case 'impossible': + return 'impossible'; + case 'cleared': + return 'cleared'; + case 'stalled': + return 'stalled'; + case 'budget_limited': + return 'budget limited'; + case 'max_iterations': + return 'iteration limit'; + } +} + +/** + * Wall-clock milliseconds since the goal was armed: frozen at `pausedAt` + * while paused and at `achievedAt` once achieved. Not accumulated active + * time: a pause/resume cycle keeps the paused window in the total. Honest + * and cheap; per-goal active-time accounting would require runtime changes + * (Codex tracks it server-side). + */ +export function goalElapsedMs( + goal: Pick, + now: number, +): number { + const end = + goal.status === 'paused' && goal.pausedAt !== null + ? goal.pausedAt + : goal.status === 'achieved' && goal.achievedAt !== null + ? goal.achievedAt + : now; + return Math.max(0, end - goal.setAt); +} + +export function formatGoalElapsed(elapsedMs: number): string { + const seconds = Math.max(0, Math.floor(elapsedMs / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const restMinutes = minutes % 60; + if (hours < 24) return restMinutes === 0 ? `${hours}h` : `${hours}h ${restMinutes}m`; + return `${Math.floor(hours / 24)}d ${hours % 24}h`; +} + +/** + * One compact status-line segment for a live goal: + * active → `goal 3/50 12m` + * waiting → `goal waiting 3/50` + * paused → `goal paused 3/50` + * Elapsed is omitted while waiting/paused: waiting bounces between turns and + * paused freezes the clock, so the number adds noise without saying "alive". + */ +export function goalStatusLineText( + goal: Pick< + GoalProjection, + 'status' | 'iterations' | 'maxIterations' | 'setAt' | 'pausedAt' | 'achievedAt' + >, + now: number, +): string { + const counter = `${goal.iterations}/${goal.maxIterations}`; + if (goal.status === 'active') { + return `goal ${counter} ${formatGoalElapsed(goalElapsedMs(goal, now))}`; + } + return `goal ${goalStatusLabel(goal.status)} ${counter}`; +} + +/** 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. + const elapsedMeaningful = + isLiveGoalStatus(goal.status) || (goal.status === 'achieved' && goal.achievedAt !== null); + const lines = [ + // 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)}`, + elapsedMeaningful ? `${status} · ${formatGoalElapsed(goalElapsedMs(goal, now))}` : status, + ]; + if (goal.tokenBudget !== null) { + lines.push( + `Tokens: ${formatTokenCount(goal.tokensSpent)} / ${formatTokenCount(goal.tokenBudget)}`, + ); + } else if (goal.tokensSpent > 0) { + lines.push(`Tokens: ${formatTokenCount(goal.tokensSpent)}`); + } + if (goal.lastReason) lines.push(`Last evaluator note: ${inline(goal.lastReason)}`); + return lines; +} diff --git a/packages/cli/src/pi-transcript-format.ts b/packages/cli/src/pi-transcript-format.ts index e64c7cb161..73919e475f 100644 --- a/packages/cli/src/pi-transcript-format.ts +++ b/packages/cli/src/pi-transcript-format.ts @@ -151,6 +151,12 @@ export function formatUnknownInline(value: unknown): string { } /** Fold line breaks into spaces so a summary can never split a one-line slot. */ +export function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; + return String(tokens); +} + export function collapseToSingleLine(text: string): string { return text.replace(/\s*\n\s*/g, ' '); } diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 7363ff192a..10be3973c5 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -30,14 +30,17 @@ import { BoundedChunkBuffer } from './bounded-chunk-buffer.js'; import { ansi } from './tui-ansi.js'; import { fitLine, + formatTokenCount, formatToolResultContent, formatUnknown, limitText, markdownTheme, renderIndented, } from './pi-transcript-format.js'; +import { goalStatusLineText, isLiveGoalStatus } from './pi-goal.js'; import { renderToolBlock } from './pi-transcript-tools.js'; import { getTuiPrimaryGuidance } from './tui-primary-guidance.js'; +import type { GoalProjection } from '@maka/runtime-host/protocol'; export interface MakaPiUsageSummary { /** Cumulative cost in USD across the session. */ @@ -143,6 +146,7 @@ const LIVE_TOOL_BUFFER_MAX_CHUNKS = 512; export type MakaPiTranscriptEntry = | { kind: 'user'; text: string } | { kind: 'legacy_automation'; text: string } + | { kind: 'goal_continuation'; text: string } | { kind: 'assistant'; messageId: string; text: string } | { kind: 'thinking'; messageId: string; text: string; expanded: boolean } | { @@ -194,6 +198,12 @@ export interface MakaPiTranscriptMetadata { providerRetry?: ProviderRetryEvent; /** Resolved locale for primary TUI guidance. Defaults to English for direct embeddings. */ uiLocale?: UiLocale; + /** + * Latest known goal projection for the session, or null when no goal is + * set. The status line shows live goals only (active/waiting/paused); + * terminal goals leave no segment, matching the desktop chip. + */ + goal?: GoalProjection | null; } export function createMakaPiTranscriptState(): MakaPiTranscriptState { @@ -809,7 +819,12 @@ function chatItemToTranscriptEntries(item: ChatItem): MakaPiTranscriptEntry[] { case 'user': return [ { - kind: item.message.origin?.kind === 'legacy_automation' ? 'legacy_automation' : 'user', + kind: + item.message.origin?.kind === 'legacy_automation' + ? 'legacy_automation' + : item.message.origin?.kind === 'goal' + ? 'goal_continuation' + : 'user', text: item.message.displayText ?? item.message.text, }, ]; @@ -1243,6 +1258,8 @@ function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number) return renderUserBlock(entry.text, width); case 'legacy_automation': return renderLegacyAutomationBlock(entry.text, width); + case 'goal_continuation': + return renderGoalContinuationBlock(entry.text, width); case 'assistant': return renderAssistantBlock(entry.text, width); case 'thinking': @@ -1261,6 +1278,8 @@ function transcriptEntrySignature(entry: MakaPiTranscriptEntry, width: number): return `user|${width}|${entry.text.length}`; case 'legacy_automation': return `legacy_automation|${width}|${entry.text}`; + case 'goal_continuation': + return `goal_continuation|${width}|${entry.text}`; case 'assistant': // text_complete authoritatively replaces streamed text, including with a // same-length final, so the full value must participate in the cache key. @@ -1324,6 +1343,21 @@ export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width } else if (metadata.orchestrationMode === 'graph') { parts.push(ansi.accent('graph')); } + // An autonomous goal burns tokens between prompts; it must never be + // invisible. Terminal goals show nothing (the desktop chip hides them too). + if (metadata.goal && isLiveGoalStatus(metadata.goal.status)) { + const text = goalStatusLineText(metadata.goal, Date.now()); + // paused gets warning salience: the loop stopped burning but stays armed + // and resumable, which the user must not miss. waiting is a normal + // transient between turns, so it stays dim like the other chrome. + parts.push( + metadata.goal.status === 'active' + ? ansi.accent(text) + : metadata.goal.status === 'paused' + ? ansi.yellow(text) + : ansi.dim(text), + ); + } const usage = metadata.usage; if (usage) { // ctx segment: only show when contextRemaining is available, since @@ -1462,12 +1496,6 @@ function shortenCwd(cwd: string, homeDir?: string): string { return cwd; } -function formatTokenCount(tokens: number): string { - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; - if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; - return String(tokens); -} - function formatCost(costUsd: number): string { if (costUsd < 0.01) return '<0.01'; return costUsd.toFixed(2); @@ -1663,14 +1691,29 @@ function renderUserBlock(text: string, width: number): string[] { return renderIndented(text, width, 2).map((line) => fitLine(`${prefix} ${line.slice(2)}`, width)); } -function renderLegacyAutomationBlock(text: string, width: number): string[] { +/** Provenance header + indented body for non-human-authored prompts. */ +function renderProvenanceBlock( + label: string, + accent: boolean, + text: string, + width: number, +): string[] { if (!text.trim()) return []; + const styled = accent ? ansi.accent(label) : ansi.dim(label); return [ - fitLine(ansi.dim('Legacy Automation (history only)'), width), + fitLine(styled, width), ...renderIndented(text, width, 2).map((line) => fitLine(line, width)), ]; } +function renderLegacyAutomationBlock(text: string, width: number): string[] { + return renderProvenanceBlock('Legacy Automation (history only)', false, text, width); +} + +function renderGoalContinuationBlock(text: string, width: number): string[] { + return renderProvenanceBlock('Goal continuation (autonomous)', true, text, width); +} + /** An assistant turn: bare markdown prose, no speaker label or indent. */ function renderAssistantBlock(text: string, width: number): string[] { if (!text.trim()) return []; diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index eed52e6441..2de7752471 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -115,6 +115,7 @@ import { type MakaSlashCommand, } from './pi-tui-pickers.js'; import { formatMakaResumeCommand } from './cli-invocation.js'; +import { goalSummaryLines } from './pi-goal.js'; import { getTuiPrimaryGuidance } from './tui-primary-guidance.js'; export interface MakaPiTuiInput { @@ -335,6 +336,14 @@ 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?.(() => { + requestRender(); + }); + const metadata = (): MakaPiTranscriptMetadata => ({ title: input.title, cwd, @@ -351,6 +360,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { turnElapsedMs: turnStartedAt !== undefined ? Date.now() - turnStartedAt : undefined, providerRetry: state.providerRetry, uiLocale: locale, + goal: input.driver.getGoal?.() ?? null, }); const transcript = new MakaTranscriptComponent(state, metadata); @@ -607,6 +617,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const restoreTerminal = () => { removeProcessHandlers(); unsubscribeSessionTitleChanges(); + unsubscribeGoalChanges?.(); unsubscribeStartedTurns(); unsubscribeResolvedInteractions(); unsubscribeTranscriptReplacements(); @@ -1022,6 +1033,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } return; } + // Read-only status commands answer locally even mid-turn instead of + // steering into the model as prompt text: an autonomous goal loop keeps + // a turn running almost by definition, and that is exactly when the + // user reaches for `/goal` (review finding on turnRunning routing). + if (prompt.trim().split(/\s+/, 1)[0] === '/goal') { + editor.addToHistory(prompt); + handleSlashCommand(prompt, 0); + return; + } steerRunningTurn(prompt); return; } @@ -2387,6 +2407,27 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { type TuiSlashCommandId = SlashCommandIdForSurface<'tui'>; type TuiSlashCommandHandler = Omit; + const showGoalSummary = () => { + // Read the live projection at request time: /goal is the one place the + // user asks for the state *right now*. + if (!input.driver.getGoal) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: 'Goal status is unavailable on this runtime.', + }); + requestRender(); + return; + } + const goal = input.driver.getGoal(); + state.entries.push({ + kind: 'notice', + level: 'info', + text: goal ? goalSummaryLines(goal, Date.now()).join('\n') : 'No goal set.', + }); + requestRender(); + }; + const slashCommandHandlers = { context: { description: primaryGuidance.commands.context, @@ -2434,6 +2475,24 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { beginGracefulClose(); }, }, + goal: { + description: primaryGuidance.commands.goal, + run: (parts: string[]) => { + if (parts.length !== 1) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Usage: /goal', + }); + 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(); + }, + }, help: { description: primaryGuidance.commands.help, run: () => { diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 6ce3fe35b5..da6f80c64d 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -19,6 +19,7 @@ import { SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, SessionContinuitySnapshot, SubscriptionFrame, + type GoalProjection, } from '@maka/runtime-host/protocol'; import type { MakaPreparedSessionTurn } from './session-driver.js'; @@ -42,6 +43,12 @@ export interface RuntimeHostSessionChannelOptions { onInteractionResolved: (pending: InteractionPendingSnapshot) => void; onTurnTerminal: (turn: TerminalTurnSnapshot) => void; onTranscriptReplaced: (turnId: string, messages: readonly StoredMessage[]) => void; + /** + * Fired when the folded session projection's goal changes (set / settle / + * pause / resume / clear). The projection stream is the authoritative push + * channel for goal state — the same one the desktop observer diffs. + */ + onGoalChanged: (goal: GoalProjection | null) => void; onRecovered: () => void; } @@ -58,6 +65,7 @@ export class RuntimeHostSessionChannel { readonly #onInteractionResolved: (pending: InteractionPendingSnapshot) => void; readonly #onTurnTerminal: (turn: TerminalTurnSnapshot) => void; readonly #onTranscriptReplaced: (turnId: string, messages: readonly StoredMessage[]) => void; + readonly #onGoalChanged: (goal: GoalProjection | null) => void; readonly #onRecovered: () => void; readonly #turns = new Map(); readonly #pendingFrames: SubscriptionFrame[] = []; @@ -92,6 +100,7 @@ export class RuntimeHostSessionChannel { this.#onInteractionResolved = options.onInteractionResolved; this.#onTurnTerminal = options.onTurnTerminal; this.#onTranscriptReplaced = options.onTranscriptReplaced; + this.#onGoalChanged = options.onGoalChanged; this.#onRecovered = options.onRecovered; } @@ -355,6 +364,9 @@ export class RuntimeHostSessionChannel { ...messages.map((message) => structuredClone(message)), ); this.snapshot = nextSnapshot; + if (!sameGoalProjection(previousSnapshot.goal, nextSnapshot.goal)) { + this.#onGoalChanged(nextSnapshot.goal === null ? null : structuredClone(nextSnapshot.goal)); + } this.#projector = new RuntimeHostSessionProjector( nextSnapshot, createRuntimeHostSessionProjectionSeed(this.messages, nextSnapshot), @@ -468,9 +480,16 @@ export class RuntimeHostSessionChannel { const previousPendingIds = new Set( this.snapshot.interactions.pending.map((interaction) => interaction.interactionId), ); + const previousGoal = this.snapshot.goal; const update = this.#projector?.accept(frame); if (!update || !this.#projector) return; this.snapshot = this.#projector.snapshot; + if (!sameGoalProjection(previousGoal, this.snapshot.goal)) { + // Clone like the canonical-replacement path above: listeners receive + // their own copy, so a mutating listener cannot corrupt the live + // snapshot regardless of which path delivered the change. + this.#onGoalChanged(this.snapshot.goal === null ? null : structuredClone(this.snapshot.goal)); + } for (const interaction of this.snapshot.interactions.pending) { if (previousPendingIds.has(interaction.interactionId)) continue; const pending = structuredClone(interaction); @@ -596,3 +615,13 @@ function sameTerminalTurn( previous.terminalEventId === next.terminalEventId ); } + +/** + * Goal identity + revision: GoalManager.commit bumps the revision on every + * accepted transition, so this pair detects every set/settle/pause/resume/ + * clear without a field-by-field compare. + */ +function sameGoalProjection(a: GoalProjection | null, b: GoalProjection | null): boolean { + if (a === null || b === null) return a === b; + return a.goalId === b.goalId && a.revision === b.revision; +} diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index d4b550ff78..bae23de17e 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -36,6 +36,7 @@ import { SessionUpdateResult, SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, WorkspaceTarget, + type GoalProjection, } from '@maka/runtime-host/protocol'; import { RuntimeHostSessionChannel, @@ -136,6 +137,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { #sessionGeneration = 0; #channelGeneration = 0; readonly #startedTurnListeners = new Set<(turn: MakaAttachedSessionTurn) => void>(); + readonly #goalListeners = new Set<(goal: GoalProjection | null) => void>(); readonly #pendingInteractionListeners = new Set<(pending: InteractionPendingSnapshot) => void>(); readonly #claimedTurnIds = new Set(); readonly #shellRunListeners = new Set<(update: ShellRunUpdate) => void>(); @@ -620,6 +622,18 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return this.#sessionId; } + getGoal(): GoalProjection | null { + // The session subscription's continuity snapshot carries the goal + // projection and is folded on every pushed frame, so this read is as + // fresh as the host's last broadcast — no RPC, no staleness window. + return this.#channel?.snapshot.goal ?? null; + } + + subscribeGoalChanges(listener: (goal: GoalProjection | null) => void): () => void { + this.#goalListeners.add(listener); + return () => this.#goalListeners.delete(listener); + } + async getContextDiagnostics(): Promise { if (!this.#sessionId) return { status: 'unavailable', reason: 'no_completed_request' }; const diagnostics = await this.#request('context.diagnostics.query', { @@ -729,6 +743,8 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { async #replaceChannel(next: RuntimeHostSessionChannel | undefined): Promise { const previous = this.#channel; this.#channel = next; + const goal = next?.snapshot.goal ?? null; + for (const listener of this.#goalListeners) listener(goal); await previous?.close().catch(() => undefined); } @@ -914,6 +930,12 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { onTurnTerminal: (turn) => this.#refreshTerminalTranscript(turn), onTranscriptReplaced: (turnId, messages) => this.#publishTranscriptReplacement(sessionId, turnId, messages, 'reconnect'), + onGoalChanged: (goal) => { + // A closing channel from a previous session can still be draining a + // frame when the swap happens; only the live session may publish. + if (this.#sessionId !== sessionId || this.#sessionGeneration !== sessionGeneration) return; + for (const listener of this.#goalListeners) listener(goal); + }, onRecovered: () => this.#refreshRuntimeResources(sessionId), }); } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 1fbc25ed46..3af0817fc8 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -9,6 +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'; export interface MakaSessionMoveResult { previousCwd: string; @@ -110,6 +111,18 @@ export interface MakaSessionDriver { startNewSession(): void; stop(): Promise; getSessionId(): string | null; + /** + * The current session's goal projection, or null when no goal is set. + * Read from the live session projection (push-updated); subscribe to + * subscribeGoalChanges for updates. Optional: drivers without a goal + * authority leave goal UI hidden. + */ + getGoal?(): GoalProjection | null; + /** + * Fires when the session's goal projection changes — set, settled, paused, + * resumed, cleared, or when the attached session changes. + */ + subscribeGoalChanges?(listener: (goal: GoalProjection | null) => void): () => void; getContextDiagnostics?(): Promise; getOrchestrationMode?(): OrchestrationMode; getPermissionMode?(): PermissionMode; diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index f9bc1aea66..f014aa9185 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -32,6 +32,7 @@ const TUI_PRIMARY_GUIDANCE = { compact: '压缩会话上下文', context: '查看最近一次请求的上下文用量', exit: '退出 Maka', + goal: '查看自主目标状态', graph: '查看、启用、停用 Graph 模式,或执行一次 Graph 任务', help: '查看命令和快捷键', model: '选择模型', @@ -77,6 +78,7 @@ const TUI_PRIMARY_GUIDANCE = { compact: 'Compact session context', context: 'Show latest request context usage', exit: 'Exit Maka', + goal: 'Show autonomous goal status', graph: 'Show, enable, disable, or run one Graph turn', help: 'Show commands and keybindings', model: 'Select model', diff --git a/packages/core/src/slash-command-catalog.ts b/packages/core/src/slash-command-catalog.ts index 3c3a443904..dd564132b2 100644 --- a/packages/core/src/slash-command-catalog.ts +++ b/packages/core/src/slash-command-catalog.ts @@ -12,6 +12,7 @@ export const SLASH_COMMAND_CATALOG = [ { id: 'compact', session: 'required', surfaces: ['desktop', 'tui'] }, { id: 'context', session: 'required', surfaces: ['tui'] }, { id: 'exit', aliases: ['quit'], session: 'none', surfaces: ['tui'] }, + { id: 'goal', session: 'required', surfaces: ['tui'] }, { id: 'graph', session: 'none', surfaces: ['desktop', 'tui'] }, { id: 'help', session: 'none', surfaces: ['tui'] }, { id: 'model', session: 'required', surfaces: ['tui'] },