From fa1c2cf3610489f08715fcaab576df49c3545ede Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 22:08:36 +0800 Subject: [PATCH 01/14] feat(cli): add one-shot user commands Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 33 +++- .../cli/src/__tests__/pi-tui-runner.test.ts | 51 ++++++ .../runtime-host-session-driver.test.ts | 147 ++++++++++++++++++ packages/cli/src/pi-transcript.ts | 40 ++++- packages/cli/src/pi-tui-runner.ts | 53 ++++++- .../cli/src/runtime-host-session-driver.ts | 46 ++++++ packages/cli/src/session-driver.ts | 16 +- packages/cli/src/tui-primary-guidance.ts | 3 + packages/core/src/shell-run.ts | 14 ++ .../runtime-resource-coordinator.test.ts | 39 ++++- .../runtime-resource-protocol.test.ts | 31 ++++ .../src/protocol/runtime-resource.ts | 26 +++- .../server/runtime-resource-coordinator.ts | 17 +- .../src/__tests__/shell-run-manager.test.ts | 51 ++++++ packages/runtime/src/shell-run-contract.ts | 5 + packages/runtime/src/shell-run-manager.ts | 27 +++- 16 files changed, 576 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b57e43775c..1d5ba7c31f 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -22,9 +22,10 @@ import { describe, test } from 'node:test'; import { visibleWidth } from '@earendil-works/pi-tui'; import type { PipeShellOutput, PtyShellOutput } from '@maka/core/shell-run'; import type { ShellRunToolResult } from '@maka/core/shell-run-result'; -import type { SessionEvent, ToolResultContent } from '@maka/core/events'; +import type { SessionEvent, ShellRunSnapshotResult, ToolResultContent } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; import { + appendUserCommandToTranscript, appendUserPrompt, applyShellRunViewUpdateToTranscript, applyMakaSessionEventToTranscript, @@ -2538,6 +2539,36 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('updates a local user command card from its Runtime Resource', () => { + const state = createMakaPiTranscriptState(); + const ref = 'maka://runtime/background-tasks/user-command-1'; + appendUserCommandToTranscript(state, { + commandId: 'user-command-1', + command: 'pwd', + result: shellRun({ ref, status: 'running', stdout: '' }) as ShellRunSnapshotResult, + }); + + const applied = applyShellRunViewUpdateToTranscript(state, { + sessionId: 'session-1', + ownership: { kind: 'local' }, + sourceTurnId: 'user-command-1', + sourceToolCallId: 'user-command-1', + result: shellRun({ + ref, + status: 'completed', + stdout: '/repo\n', + completedAt: 2_000, + exitCode: 0, + }), + }); + + assert.equal(applied, true); + const tool = state.entries.find((entry) => entry.kind === 'tool'); + assert.equal(tool?.toolName, 'User command'); + assert.equal(tool?.status, 'done'); + assert.match(tool?.output ?? '', /\/repo/); + }); + test('notifies a settle exactly once across a folded poll and the live update', () => { const state = createMakaPiTranscriptState(); const ref = 'maka://runtime/background-tasks/bg-1'; diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 84d559537f..481ba1b1da 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -232,6 +232,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => plainTerminalOutput(terminal.output()).includes('快捷键')); const output = plainTerminalOutput(terminal.output()); assert.match(output, /\/compact\s+— 压缩会话上下文/); + assert.match(output, /! — 执行一次仅用户可见的 shell 命令/); assert.match(output, /Ctrl\+D — 输入为空时退出/); exitMaka(terminal); @@ -243,6 +244,30 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('! runs once without opening an agent turn', async () => { + const terminal = new FakeTerminal(); + const driver = new UserCommandDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('!pwd'); + terminal.input('\r'); + await waitFor(() => driver.commands.includes('pwd')); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('User command')); + assert.deepEqual(driver.prompts, []); + + exitMaka(terminal); + await run; + }); + test('disables taskbar progress on Windows and Windows Terminal by default', () => { assert.equal(resolveTaskbarProgress(undefined, { platform: 'win32' }), false); assert.equal( @@ -7529,6 +7554,32 @@ class SlashCommandDriver implements MakaSessionDriver { } } +class UserCommandDriver extends SlashCommandDriver { + readonly commands: string[] = []; + + async runUserCommand(command: string) { + this.commands.push(command); + return { + commandId: `user-command-${this.commands.length}`, + result: { + kind: 'shell_run' as const, + ref: `maka://runtime/background-tasks/user-command-${this.commands.length}`, + mode: 'pipes' as const, + status: 'completed' as const, + cwd: '/repo', + cmd: command, + startedAt: 1, + updatedAt: 2, + completedAt: 2, + exitCode: 0, + revision: 1, + output: pipeOutput(command), + }, + takeRacedUpdate: () => undefined, + }; + } +} + class HostSkillDriver extends SlashCommandDriver { constructor(private readonly skillInvocation: SkillInvocationResult) { super(); 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 1b9dc7abb1..19209ba29a 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -24,6 +24,7 @@ import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { describe, test } from 'node:test'; import type { StoredMessage } from '@maka/core/session'; +import type { ShellRunUpdate } from '@maka/core/events'; import type { DirectRequestOperationKey, RuntimeHostSessionSubscription, @@ -371,6 +372,121 @@ describe('Runtime Host Maka Session driver', () => { } }); + test('starts one user command without opening an agent turn', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: (() => { + let id = 0; + return () => `id-${++id}`; + })(), + }); + const command = await driver.runUserCommand!('pwd'); + + assert.equal(command.commandId, 'user-command-id-2'); + assert.equal(command.result.mode, 'pipes'); + assert.deepEqual( + connection.requests.map((request) => request.operation), + ['session.create', 'runtime.resource.start'], + ); + assert.deepEqual(connection.requests[1]?.input, { + sessionId: 'id-1', + launchId: 'user-command-id-2', + command: 'pwd', + }); + assert.equal(command.takeRacedUpdate(), undefined); + }); + + test('retains a terminal user-command update that arrives before its card is created', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: (() => { + let id = 0; + return () => `id-${++id}`; + })(), + }); + connection.onRuntimeResourceStart = async () => { + const startRequest = connection.requests.at(-1); + if (!startRequest) throw new Error('Expected Runtime Resource start request'); + const launchId = (startRequest.input as { launchId: string }).launchId; + connection.runtimeResourceQuery = { + kind: 'resource', + sessionId: 'id-1', + revision: `sha256:${'a'.repeat(64)}`, + resource: { + sessionId: 'id-1', + ownership: { kind: 'local' }, + sourceTurnId: launchId, + sourceToolCallId: launchId, + result: { + ...connection.userCommandResource, + status: 'completed', + output: { ...connection.userCommandResource.output, stdout: 'done\n' }, + updatedAt: 2, + completedAt: 2, + exitCode: 0, + revision: 2, + }, + } satisfies ShellRunUpdate, + }; + subscription.push({ + kind: 'subscription.session_domain_changed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'id-1', + domain: 'runtime_resource', + resources: [{ sourceSessionId: 'id-1', ref: connection.userCommandResource.ref }], + }); + await waitFor(() => + connection.requests.some((request) => request.operation === 'runtime.resource.query'), + ); + await delay(0); + }; + + const command = await driver.runUserCommand!('printf done'); + const raced = command.takeRacedUpdate(); + + assert.equal(raced?.status, 'completed'); + assert.equal(raced?.output?.mode, 'pipes'); + assert.equal(raced?.output?.mode === 'pipes' && raced.output.stdout, 'done\n'); + }); + test('drops a per-session Full access elevation when a fresh Session starts (#3020)', async () => { // The TUI flow behind /new: session A is elevated to bypass, then the // driver is asked to start over. The next prompt lazily creates session B @@ -1589,12 +1705,33 @@ class FakeConnection { readonly sessionQueries: Array> = []; openedSubscriptions = 0; interactionQuery: unknown; + runtimeResourceQuery: unknown; + onRuntimeResourceStart: (() => Promise) | undefined; 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 userCommandResource = { + kind: 'shell_run' as const, + ref: 'maka://runtime/background-tasks/user-command', + mode: 'pipes' as const, + status: 'running' as const, + cwd: '/repo', + cmd: 'pwd', + startedAt: 1, + updatedAt: 1, + revision: 1, + output: { + mode: 'pipes' as const, + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }; readonly value: RuntimeHostMakaSessionDriverInput['connection']; constructor( @@ -1667,6 +1804,16 @@ class FakeConnection { }), } as OperationOutput; } + if (operation === 'runtime.resource.start') { + await this.onRuntimeResourceStart?.(); + return { resource: this.userCommandResource } as OperationOutput; + } + if (operation === 'runtime.resource.query') { + if (this.runtimeResourceQuery === undefined) { + throw new Error('Unexpected Runtime Resource query'); + } + return this.runtimeResourceQuery as OperationOutput; + } const turnInput = input as { sessionId?: string; turnId?: string; diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 35de5bbd80..d3400d10c3 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -23,6 +23,7 @@ import type { SandboxBoundaryRequestEvent, UserQuestionRequestEvent, SessionEvent, + ShellRunSnapshotResult, ToolOutputStream, ToolResultContent, } from '@maka/core/events'; @@ -177,6 +178,15 @@ export type MakaPiTranscriptEntry = expanded: boolean; /** An internal shell-run poll retained for correlation but not displayed. */ suppressed?: boolean; + /** Local-only Runtime Resource started by `!`, never a model tool call. */ + userOwned?: boolean; + /** + * Set when a successful shell-run poll is folded into its parent while + * off-screen: the entry cannot be spliced (that would shift line numbers + * and clear scrollback), but it must not render as an independent card + * on a future full redraw. A hidden entry contributes zero lines. + */ + hidden?: boolean; } | { kind: 'notice'; level: 'info' | 'error'; text: string }; @@ -295,7 +305,7 @@ export function applyShellRunViewUpdateToTranscript( } if ( !tool || - tool.toolName !== 'Bash' || + !isShellRunToolCard(tool) || tool.result?.kind !== 'shell_run' || tool.result.ref !== update.result.ref || tool.result.revision !== update.result.revision || @@ -319,11 +329,33 @@ export function applyShellRunUpdateToTranscript( update: Extract, ): boolean { const tool = findToolEntry(state, sourceToolCallId); - if (!tool || tool.toolName !== 'Bash') return false; + if (!tool || !isShellRunToolCard(tool)) return false; if (tool.result?.kind === 'shell_run' && tool.result.ref !== update.ref) return false; return applyShellRunResult(tool, update); } +/** Adds a local-only card for a `!` resource without creating a model turn. */ +export function appendUserCommandToTranscript( + state: MakaPiTranscriptState, + input: { commandId: string; command: string; result: ShellRunSnapshotResult }, +): void { + state.entries.push({ + kind: 'tool', + toolUseId: input.commandId, + toolName: 'User command', + title: 'User command', + input: { command: input.command }, + result: input.result, + output: formatToolResultContent(input.result), + resultVersion: 1, + progress: createProgressBuffer(), + outputDeltas: createOutputBuffer(), + status: shellRunTranscriptStatus(input.result.status), + expanded: state.expandAllTools, + userOwned: true, + }); +} + export function replaceTranscriptWithStoredMessages( state: MakaPiTranscriptState, messages: readonly StoredMessage[], @@ -1610,6 +1642,10 @@ function unsuppressToolAtTail(state: MakaPiTranscriptState, tool: MakaPiToolEntr state.entries.push(tool); } +function isShellRunToolCard(tool: MakaPiToolEntry): boolean { + return tool.toolName === 'Bash' || tool.userOwned === true; +} + function createProgressBuffer(): BoundedChunkBuffer { return new BoundedChunkBuffer({ maxChars: LIVE_TOOL_BUFFER_MAX_CHARS, diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 61d335b547..02ef4418ea 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -87,8 +87,10 @@ import { } from './session-driver.js'; import { appendTurnFailureToTranscript, + appendUserCommandToTranscript, appendUserPrompt, applyMakaSessionEventToTranscript, + applyShellRunUpdateToTranscript, createMakaPiTranscriptState, activeSandboxBoundaryRequest, activeUserQuestionRequest, @@ -866,6 +868,20 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const idleMs = Date.now() - lastActivityAt; editor.addToHistory(prompt); if (handleSlashCommand(prompt, idleMs)) return; + const userCommand = parseUserCommand(prompt); + if (userCommand !== undefined) { + if (!userCommand) { + state.entries.push({ kind: 'notice', level: 'error', text: 'Usage: !' }); + requestRender(); + return; + } + if (input.firstRun) { + void showSetupWizard(); + return; + } + void runControl(() => runUserCommand(userCommand)); + return; + } // First-run has no connection, so the wizard is the only surface. This is // the single choke point for idle submits (Enter, Alt+Enter, steer // fallback): reopen the wizard instead of opening a turn against a @@ -1109,6 +1125,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (prompt.trim().split(/\s+/, 1)[0] === '/transcript') { editor.addToHistory(prompt); handleSlashCommand(prompt, 0); + if (parseUserCommand(prompt) !== undefined) { + editor.addToHistory(prompt); + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Cannot run a user command while a turn is running.', + }); + requestRender(); return; } const swarmCommand = parseSwarmCommand(prompt); @@ -1532,6 +1556,19 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); }; + const runUserCommand = async (command: string): Promise => { + if (!input.driver.runUserCommand) { + throw new Error('User commands are unavailable on this session driver.'); + } + const started = await input.driver.runUserCommand(command); + appendUserCommandToTranscript(state, { command, ...started }); + const racedUpdate = started.takeRacedUpdate(); + if (racedUpdate) { + applyShellRunUpdateToTranscript(state, started.commandId, racedUpdate); + } + requestRender(); + }; + // Adopt a switch/rewind result: the active session is now `summary` with // `messages`. Shared by switchSession and rewindToTurn so both land the same // runner state (model/connection/thinking/transcript/scroll). @@ -2408,15 +2445,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const showHelp = () => { // Derive the command list from the registry so /help never drifts from the // real commands. Keybindings are not commands, so they are listed by hand. - const commands = slashCommands - .map((command) => { + const commands = [ + ...slashCommands.map((command) => { const aliasSuffix = command.aliases && command.aliases.length > 0 ? ` (${command.aliases.map((alias) => `/${alias}`).join(', ')})` : ''; return ` /${command.name}${aliasSuffix} — ${command.description}`; - }) - .join('\n'); + }), + primaryGuidance.help.userCommand, + ].join('\n'); const keybindings = primaryGuidance.help.keybindings.join('\n'); state.entries.push({ kind: 'notice', @@ -3709,6 +3747,13 @@ function isExitPrompt(prompt: string): boolean { return trimmed === 'quit' || trimmed === 'exit' || trimmed === '/quit' || trimmed === '/exit'; } +/** Only a leading bang opts into a local user command; ordinary prose remains a prompt. */ +function parseUserCommand(prompt: string): string | undefined { + const trimmed = prompt.trim(); + if (!trimmed.startsWith('!')) return undefined; + return trimmed.slice(1).trim(); +} + // Two Escapes this close together read as one deliberate "stop the turn". const DOUBLE_ESCAPE_INTERRUPT_WINDOW_MS = 600; const DOUBLE_CTRL_C_EXIT_WINDOW_MS = 1_000; diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 6b7cf0f2a8..d2e1798a2e 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -31,12 +31,15 @@ import { type ActiveInteractionRequestEvent, type QueueEnqueueOutcome, type SessionEvent, + type ShellRunSnapshotResult, type ShellRunUpdate, } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; +import { mergeShellRunUpdate } from '@maka/core/shell-run-result'; +import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { executionBoundaryDisplayMode } from '@maka/core/sandbox-boundary'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -309,6 +312,49 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { } } + async runUserCommand(command: string): Promise<{ + commandId: string; + result: ShellRunSnapshotResult; + takeRacedUpdate(): ShellRunUpdate['result'] | undefined; + }> { + const sessionId = await this.#ensureSession(); + await this.#ensureChannel(sessionId); + const commandId = `user-command-${this.#newId()}`; + let latest: ShellRunUpdate | undefined; + const capture = (update: ShellRunUpdate) => { + if (update.sessionId === sessionId && update.sourceToolCallId === commandId) { + latest = mergeShellRunUpdate(latest, update, 'cli.user-command-start').update; + } + }; + this.#shellRunListeners.add(capture); + try { + const started = await this.#request('runtime.resource.start', { + sessionId, + launchId: commandId, + command, + }); + if (started.resource.mode !== 'pipes') { + throw new Error('Runtime Host did not start a one-shot user command'); + } + let activated = false; + return { + commandId, + result: started.resource, + takeRacedUpdate: () => { + if (activated) return undefined; + activated = true; + this.#shellRunListeners.delete(capture); + return latest && latest.result.revision > started.resource.revision + ? latest.result + : undefined; + }, + }; + } catch (error) { + this.#shellRunListeners.delete(capture); + throw error; + } + } + async *compactSession(): AsyncIterable { const sessionId = this.#requireSession('compact'); const channel = await this.#ensureChannel(sessionId); diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 42e179efab..a029491435 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -18,7 +18,12 @@ */ import { realpath } from 'node:fs/promises'; -import type { QueueEnqueueOutcome, SessionEvent } from '@maka/core/events'; +import type { + QueueEnqueueOutcome, + SessionEvent, + ShellRunSnapshotResult, + ShellRunUpdate, +} from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -81,6 +86,13 @@ export interface MakaPreparePromptOptions { maxSteps?: number; } +export interface MakaUserCommand { + readonly commandId: string; + readonly result: ShellRunSnapshotResult; + /** Returns the newest update that raced the initial card into the transcript. */ + takeRacedUpdate(): ShellRunUpdate['result'] | undefined; +} + export class SkillInvocationBlockedError extends Error { constructor(readonly skillInvocation: SkillInvocationResult) { super('Explicit Skill invocation could not be resolved'); @@ -95,6 +107,8 @@ export interface MakaSessionDriver { prompt: string, options?: MakaPreparePromptOptions, ): Promise; + /** Runs one user-owned command. Its input/output never becomes model prompt history. */ + runUserCommand?(command: string): Promise; compactSession(): AsyncIterable; resumeLatest?(): AsyncIterable; steer?(text: string): Promise; diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index 28c1621a99..6f0553a8d1 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -34,6 +34,7 @@ export interface TuiPrimaryGuidanceCopy { readonly commands: Readonly>; readonly help: { readonly commandsHeading: string; + readonly userCommand: string; readonly keybindingsHeading: string; readonly keybindings: readonly string[]; }; @@ -72,6 +73,7 @@ const TUI_PRIMARY_GUIDANCE = { }, help: { commandsHeading: '命令', + userCommand: ' ! — 执行一次仅用户可见的 shell 命令', keybindingsHeading: '快捷键', keybindings: [ ' Ctrl+O — 展开或折叠所有工具输出', @@ -119,6 +121,7 @@ const TUI_PRIMARY_GUIDANCE = { }, help: { commandsHeading: 'Commands', + userCommand: ' ! — run one shell command visible only to you', keybindingsHeading: 'Keybindings', keybindings: [ ' Ctrl+O — expand or collapse all tool output', diff --git a/packages/core/src/shell-run.ts b/packages/core/src/shell-run.ts index 0a45cb3712..7c5ae2da33 100644 --- a/packages/core/src/shell-run.ts +++ b/packages/core/src/shell-run.ts @@ -70,6 +70,13 @@ export type ShellRunTerminalStatus = (typeof SHELL_RUN_TERMINAL_STATUSES)[number export type ShellRunActiveStatus = (typeof SHELL_RUN_ACTIVE_STATUSES)[number]; export type ShellMode = 'pipes' | 'pty'; +/** + * Determines whether a runtime shell resource may be summarized to the model. + * User-owned interactive terminals remain observable to their attached Client, + * but their command stream and output are not part of an agent turn. + */ +export type ShellRunVisibility = 'model' | 'user'; + export interface PipeShellOutput { mode: 'pipes'; stdout: string; @@ -125,6 +132,8 @@ export interface ShellRunRecord { sourceRunId?: string; sourceTurnId: string; sourceToolCallId: string; + /** Defaults to `model` for model-initiated Bash runs. */ + visibility?: ShellRunVisibility; cwd: string; command: string; status: ShellRunStatus; @@ -312,6 +321,7 @@ const SHELL_RUN_RECORD_KEYS: ReadonlySet = new Set([ 'sourceRunId', 'sourceTurnId', 'sourceToolCallId', + 'visibility', 'cwd', 'command', 'status', @@ -364,6 +374,9 @@ export function normalizeShellRunRecord( hasOnlyKeys(record, SHELL_RUN_RECORD_KEYS) && requiredStrings.every((item) => typeof item === 'string') && isShellRunSourceToolCallId(record.sourceToolCallId) && + (record.visibility === undefined || + record.visibility === 'model' || + record.visibility === 'user') && record.sessionId === sessionId && record.shellRunId === shellRunId && isShellRunStatus(record.status) && @@ -507,6 +520,7 @@ function canonicalShellRunRecord(record: ShellRunRecord): ShellRunRecord { ...(record.sourceRunId !== undefined ? { sourceRunId: record.sourceRunId } : {}), sourceTurnId: record.sourceTurnId, sourceToolCallId: record.sourceToolCallId, + ...(record.visibility !== undefined ? { visibility: record.visibility } : {}), cwd: record.cwd, command: record.command, status: record.status, diff --git a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts index a450c4a598..8b2644f6ce 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts @@ -280,6 +280,7 @@ describe('Host Runtime Resource coordinator', () => { sessionId: harness.lastBackgroundInput.sessionId, sourceTurnId: harness.lastBackgroundInput.sourceTurnId, sourceToolCallId: harness.lastBackgroundInput.sourceToolCallId, + visibility: harness.lastBackgroundInput.visibility, cwd: harness.lastBackgroundInput.cwd, pty: harness.lastBackgroundInput.pty, }, @@ -287,6 +288,7 @@ describe('Host Runtime Resource coordinator', () => { sessionId: SESSION_ID, sourceTurnId: 'desktop-launch-1', sourceToolCallId: 'desktop-launch-1', + visibility: 'user', cwd: '/workspace', pty: true, }, @@ -420,6 +422,38 @@ describe('Host Runtime Resource coordinator', () => { assert.equal(harness.lastForegroundInput, undefined); }); + test('starts a one-shot user command in pipes without exposing it to the model', async () => { + const harness = createHarness(); + const started = await harness.coordinator.handlers['runtime.resource.start']( + { sessionId: SESSION_ID, launchId: 'user-command-1', command: 'printf user-command' }, + connection('connection-1'), + ); + + assert.equal(started.ok, true); + assert.equal(started.ok && started.result.resource.mode, 'pipes'); + assert.deepEqual( + harness.lastBackgroundInput && { + sessionId: harness.lastBackgroundInput.sessionId, + sourceTurnId: harness.lastBackgroundInput.sourceTurnId, + sourceToolCallId: harness.lastBackgroundInput.sourceToolCallId, + visibility: harness.lastBackgroundInput.visibility, + cwd: harness.lastBackgroundInput.cwd, + command: harness.lastBackgroundInput.command, + pty: harness.lastBackgroundInput.pty, + }, + { + sessionId: SESSION_ID, + sourceTurnId: 'user-command-1', + sourceToolCallId: 'user-command-1', + visibility: 'user', + cwd: '/workspace', + command: 'printf user-command', + pty: false, + }, + ); + harness.finishBackground({ successful: true }); + }); + test('lets stop bypass the controller, releases terminal ownership, and keeps control replay safe', async () => { const harness = createHarness(); const firstConnection = connection('connection-1'); @@ -561,6 +595,7 @@ describe('Host Runtime Resource coordinator', () => { function createHarness(options: Pick = {}) { let backgroundCompletion: ShellRunBashInput['onCompletion']; let currentSnapshot = ptySnapshot(); + let lastStartedSnapshot: ShellRunSnapshotResult | undefined; const state = { updates: [resourceUpdate(0)], sessionState: 'active' as 'active' | 'archived' | 'missing', @@ -587,9 +622,11 @@ function createHarness(options: Pick currentSnapshot, @@ -638,7 +675,7 @@ function createHarness(options: Pick structuredClone(currentSnapshot), + inspectResource: async () => structuredClone(lastStartedSnapshot ?? currentSnapshot), getLivePtySnapshot: (sessionId, ref) => ({ sessionId, ref, diff --git a/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts index 9bcce899c0..748c9d3932 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts @@ -30,8 +30,10 @@ import { decodeRuntimeResourceControllerControlInput, decodeRuntimeResourceQueryInput, decodeRuntimeResourceQueryResult, + decodeRuntimeResourceStartInput, decodeRuntimeResourceStopResult, RUNTIME_RESOURCE_CONTROL_INPUT_MAX_BYTES, + RUNTIME_RESOURCE_COMMAND_MAX_BYTES, RUNTIME_RESOURCE_MAX_CONTROL_SEQUENCE, RUNTIME_RESOURCE_CURSOR_MAX_BYTES, RUNTIME_RESOURCE_PAGE_MAX_ITEMS, @@ -44,6 +46,28 @@ type PipeShellSnapshot = Extract; describe('Runtime Resource protocol', () => { test('rejects unknown fields and non-canonical snapshots', () => { + assert.deepEqual( + decodeRuntimeResourceStartInput({ + sessionId: 'session-1', + launchId: 'user-command-1', + command: 'pwd', + }), + { sessionId: 'session-1', launchId: 'user-command-1', command: 'pwd' }, + ); + assertInvalid(() => + decodeRuntimeResourceStartInput({ + sessionId: 'session-1', + launchId: 'user-command-1', + command: '', + }), + ); + assertInvalid(() => + decodeRuntimeResourceStartInput({ + sessionId: 'session-1', + launchId: 'user-command-1', + command: ' ', + }), + ); assertInvalid(() => decodeRuntimeResourceQueryInput({ kind: 'get', @@ -71,6 +95,13 @@ describe('Runtime Resource protocol', () => { }); test('enforces cursor, sequence, PTY control, item, and encoded result bounds', () => { + assertInvalid(() => + decodeRuntimeResourceStartInput({ + sessionId: 'session-1', + launchId: 'user-command-1', + command: '界'.repeat(Math.floor(RUNTIME_RESOURCE_COMMAND_MAX_BYTES / 3) + 1), + }), + ); const maximumToolCallId = '😀'.repeat(SHELL_RUN_SOURCE_TOOL_CALL_ID_MAX_BYTES / 4); assert.equal( Buffer.byteLength(maximumToolCallId, 'utf8'), diff --git a/packages/runtime-host/src/protocol/runtime-resource.ts b/packages/runtime-host/src/protocol/runtime-resource.ts index f848f6cc16..704803e982 100644 --- a/packages/runtime-host/src/protocol/runtime-resource.ts +++ b/packages/runtime-host/src/protocol/runtime-resource.ts @@ -31,6 +31,7 @@ import { requireExactRecord, requireId, requireRecord, + requireShapedRecord, requireUtf8String, } from './codec.js'; import { invalidProtocolFrame } from './errors.js'; @@ -42,6 +43,7 @@ export const RUNTIME_RESOURCE_PAGE_MAX_ITEMS = 64; export const RUNTIME_RESOURCE_CURSOR_MAX_BYTES = 32; export const RUNTIME_RESOURCE_REF_MAX_BYTES = 256; export const RUNTIME_RESOURCE_CONTROL_INPUT_MAX_BYTES = 32 * 1024; +export const RUNTIME_RESOURCE_COMMAND_MAX_BYTES = 32 * 1024; export const RUNTIME_RESOURCE_MAX_CONTROL_SEQUENCE = Number.MAX_SAFE_INTEGER - 1; export const RUNTIME_RESOURCE_MIN_PTY_COLS = 2; export const RUNTIME_RESOURCE_MAX_PTY_COLS = 240; @@ -162,6 +164,8 @@ export interface RuntimeResourceStopInput { export interface RuntimeResourceStartInput { readonly sessionId: string; readonly launchId: string; + /** Omitted only for the Desktop-owned interactive terminal resource. */ + readonly command?: string; } export interface RuntimeResourceStartResult { @@ -242,13 +246,27 @@ export const RUNTIME_RESOURCE_OPERATION_SPECS = { } as const; export function decodeRuntimeResourceStartInput(value: unknown): RuntimeResourceStartInput { - const input = requireExactRecord(value, 'Runtime Resource start input', [ - 'sessionId', - 'launchId', - ]); + const input = requireShapedRecord( + value, + 'Runtime Resource start input', + ['sessionId', 'launchId'], + ['command'], + ); + const command = + input.command === undefined + ? undefined + : requireUtf8String( + input.command, + 'Runtime Resource command', + RUNTIME_RESOURCE_COMMAND_MAX_BYTES, + ); + if (command !== undefined && !command.trim()) { + throw invalidProtocolFrame('Invalid Runtime Resource command'); + } return { sessionId: requireEntityId(input.sessionId, 'sessionId'), launchId: requireId(input.launchId, 'launchId'), + ...(command === undefined ? {} : { command }), }; } diff --git a/packages/runtime-host/src/server/runtime-resource-coordinator.ts b/packages/runtime-host/src/server/runtime-resource-coordinator.ts index 18602c6ea5..4e31d3bd69 100644 --- a/packages/runtime-host/src/server/runtime-resource-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-resource-coordinator.ts @@ -363,25 +363,25 @@ export class HostRuntimeResourceCoordinator const header = await this.#sessionHeaders.readHeader(input.sessionId); const shell = await this.#resolveShell(); const env = { ...process.env }; - let command: string; - if (shell.kind === 'git-bash') { + let command = input.command; + if (command === undefined && shell.kind === 'git-bash') { env.SHELL = shell.exe; env.CHERE_INVOKING = '1'; env.DISABLE_AUTO_UPDATE = 'true'; env.DISABLE_UPDATE_PROMPT = 'true'; command = 'exec "$SHELL" -l'; - } else if (shell.kind === 'legacy-wsl-bash') { + } else if (command === undefined && shell.kind === 'legacy-wsl-bash') { env.DISABLE_AUTO_UPDATE = 'true'; env.DISABLE_UPDATE_PROMPT = 'true'; command = 'exec bash -l'; - } else if (shell.kind === 'posix') { + } else if (command === undefined && shell.kind === 'posix') { env.SHELL ||= userInfo().shell || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh'); env.DISABLE_AUTO_UPDATE = 'true'; env.DISABLE_UPDATE_PROMPT = 'true'; command = 'exec "$SHELL" -l'; - } else if (shell.kind === 'cmd') { + } else if (command === undefined && shell.kind === 'cmd') { command = '%ComSpec% /d /q'; - } else { + } else if (command === undefined) { const executable = (shell.exe ?? shell.displayName).replace(/'/g, "''"); command = `& '${executable}' -NoLogo`; } @@ -389,10 +389,11 @@ export class HostRuntimeResourceCoordinator sessionId: input.sessionId, sourceTurnId: input.launchId, sourceToolCallId: input.launchId, + visibility: 'user', cwd: header.cwd, command, env, - pty: true, + pty: input.command === undefined, emitOutput: () => undefined, shell, }); @@ -539,6 +540,7 @@ export class HostRuntimeResourceCoordinator const controlled = await this.#manager.writeStdin({ sessionId: input.sessionId, ref: input.ref, + caller: 'client', ...controlWrite(input.control), }); const result = decodeRuntimeResourceControllerControlResult({ @@ -628,6 +630,7 @@ export class HostRuntimeResourceCoordinator input.sessionId, input.ref, new AbortController().signal, + 'client', ); this.#releaseControllerIfTerminal(input.sessionId, input.ref, result); return { diff --git a/packages/runtime/src/__tests__/shell-run-manager.test.ts b/packages/runtime/src/__tests__/shell-run-manager.test.ts index 36dce2207a..f3014886e0 100644 --- a/packages/runtime/src/__tests__/shell-run-manager.test.ts +++ b/packages/runtime/src/__tests__/shell-run-manager.test.ts @@ -59,6 +59,57 @@ after(async () => { }); describe('ShellRunProcessManager', () => { + test('keeps user-owned terminals out of the model background-task summary', async () => { + const store = createSqliteShellRunStore(await workspace()); + await store.createShellRun({ + ...record({ shellRunId: 'user-shell', status: 'running' }), + visibility: 'user', + command: 'user-private-command', + }); + await store.createShellRun({ + ...record({ shellRunId: 'model-shell', status: 'running' }), + command: 'model-background-command', + }); + + const summary = await createManager(store).buildContextSummary('session-1'); + + assert.match(summary ?? '', /model-background-command/u); + assert.doesNotMatch(summary ?? '', /user-private-command/u); + }); + + test('rejects a model Read of a user-owned resource while preserving client inspection', async () => { + const store = createSqliteShellRunStore(await workspace()); + await store.createShellRun({ + ...record({ shellRunId: 'user-command', status: 'completed' }), + visibility: 'user', + command: 'printf private-output', + output: { + mode: 'pipes', + stdout: 'private-output\n', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + completedAt: 2, + exitCode: 0, + }); + const manager = createManager(store); + const ref = 'maka://runtime/background-tasks/user-command'; + + await assert.rejects( + () => manager.readRuntimeResource('session-1', ref, NO_ABORT), + (error: unknown) => + error instanceof Error && + (error as NodeJS.ErrnoException).code === 'ENOENT' && + error.message === 'Runtime background task not found in this session', + ); + + const inspected = await manager.inspectResource('session-1', ref); + assert.equal(inspected.output.mode, 'pipes'); + assert.equal(inspected.output.stdout, 'private-output\n'); + }); + test('rejects unprojectable provider tool-call identities before durable admission', async () => { const cwd = await workspace(); const store = sqliteShellRunStore(cwd); diff --git a/packages/runtime/src/shell-run-contract.ts b/packages/runtime/src/shell-run-contract.ts index c59709f7ba..6b92f9d24e 100644 --- a/packages/runtime/src/shell-run-contract.ts +++ b/packages/runtime/src/shell-run-contract.ts @@ -90,6 +90,8 @@ export interface ShellRunBashInput { sourceRunId?: string; sourceTurnId: string; sourceToolCallId: string; + /** User-owned terminals stay outside model context summaries. */ + visibility?: 'model' | 'user'; cwd: string; command: string; /** Final executable argv. When present, bypasses host-shell parsing. */ @@ -115,6 +117,8 @@ export interface ShellRunWriteInput { actions?: readonly TerminalInputAction[]; size?: { cols: number; rows: number }; abortSignal?: AbortSignal; + /** Client control may reach user-owned resources; model tools may not. */ + caller?: 'model' | 'client'; } export interface ShellRunPtyDataEvent { @@ -145,6 +149,7 @@ export interface BackgroundTaskStopper { sessionId: string, ref: string, abortSignal: AbortSignal, + caller?: 'model' | 'client', ): Promise; } diff --git a/packages/runtime/src/shell-run-manager.ts b/packages/runtime/src/shell-run-manager.ts index 791c7456fb..ec60d30464 100644 --- a/packages/runtime/src/shell-run-manager.ts +++ b/packages/runtime/src/shell-run-manager.ts @@ -126,6 +126,15 @@ function backgroundTaskRefError(ref: string): Error { cause: new Error(`Unsupported runtime background task ref: ${ref}`), }); } + +function assertShellRunCaller(record: ShellRunRecord, caller: 'model' | 'client' = 'model'): void { + if (caller === 'client' || record.visibility !== 'user') return; + const notFound = new Error( + 'Runtime background task not found in this session', + ) as NodeJS.ErrnoException; + notFound.code = 'ENOENT'; + throw notFound; +} type DriverExit = | { mode: 'pipes'; value: PipeProcessExit } | { mode: 'pty'; value: PtyProcessExit }; @@ -358,6 +367,7 @@ export class ShellRunProcessManager if (!target) throw backgroundTaskRefError(input.ref); const live = this.liveResource(input.sessionId, target.shellRunId); if (!live) return this.writeStdinWithoutLive(input, target.shellRunId); + assertShellRunCaller(live.record, input.caller); if (live.mode !== 'pty') throw new Error('WriteStdin requires a PTY background task ref'); if (live.driverExit) { const record = await this.markObserved(await live.finished.join()); @@ -502,7 +512,7 @@ export class ShellRunProcessManager ref: string, abortSignal: AbortSignal, ): Promise { - return this.resourceDetail(sessionId, ref, true, abortSignal); + return this.resourceDetail(sessionId, ref, true, abortSignal, true); } async inspectResource(sessionId: string, ref: string): Promise { @@ -518,11 +528,13 @@ export class ShellRunProcessManager sessionId: string, ref: string, abortSignal: AbortSignal, + caller: 'model' | 'client' = 'model', ): Promise { const target = parseShellRunResourceRef(ref); if (!target) throw backgroundTaskRefError(ref); const live = this.liveResource(sessionId, target.shellRunId); - if (!live) return this.stopWithoutLive(sessionId, target.shellRunId, abortSignal); + if (!live) return this.stopWithoutLive(sessionId, target.shellRunId, abortSignal, caller); + assertShellRunCaller(live.record, caller); if (live.driverExit) { const record = await this.markObserved(await live.finished.join()); return shellRunContent(record, { kind: 'stop', applied: false }); @@ -562,7 +574,9 @@ export class ShellRunProcessManager } async buildContextSummary(sessionId: string): Promise { - const records = await this.actionableRecords(sessionId); + const records = (await this.actionableRecords(sessionId)).filter( + (record) => record.visibility !== 'user', + ); if (records.length === 0) return undefined; const visible = records.slice(0, SHELL_RUN_CONTEXT_SUMMARY_LIMIT); const lines = [ @@ -964,6 +978,7 @@ export class ShellRunProcessManager ...(input.sourceRunId ? { sourceRunId: input.sourceRunId } : {}), sourceTurnId: input.sourceTurnId, sourceToolCallId: input.sourceToolCallId, + ...(input.visibility === undefined ? {} : { visibility: input.visibility }), cwd: input.cwd, command: redactSecrets(input.command), status: 'starting', @@ -1645,12 +1660,14 @@ export class ShellRunProcessManager ref: string, markObserved: boolean, abortSignal: AbortSignal, + modelOnly = false, ): Promise { const target = parseShellRunResourceRef(ref); if (!target) throw backgroundTaskRefError(ref); const live = this.liveResource(sessionId, target.shellRunId); let record: ShellRunRecord; if (live) { + if (modelOnly) assertShellRunCaller(live.record, 'model'); if (live.integrityFailure || live.driverExit) { record = await live.finished.join(); } else { @@ -1662,6 +1679,7 @@ export class ShellRunProcessManager if (abortSignal.aborted) throw abortError('Read aborted before the durable runtime snapshot was read'); record = await this.readDurableRecord(sessionId, target.shellRunId); + if (modelOnly) assertShellRunCaller(record, 'model'); if (isActiveShellRunStatus(record.status)) { record = await this.markOrphaned( record, @@ -1689,6 +1707,7 @@ export class ShellRunProcessManager throw abortError('WriteStdin aborted before the terminal state was observed'); } let record = await this.readDurableRecord(input.sessionId, shellRunId); + assertShellRunCaller(record, input.caller); if (record.output.mode !== 'pty') throw new Error('WriteStdin requires a PTY background task ref'); if (isActiveShellRunStatus(record.status)) { @@ -1715,11 +1734,13 @@ export class ShellRunProcessManager sessionId: string, shellRunId: string, abortSignal?: AbortSignal, + caller: 'model' | 'client' = 'model', ): Promise { if (abortSignal?.aborted) { throw abortError('StopBackgroundTask aborted before the terminal state was observed'); } let record = await this.readDurableRecord(sessionId, shellRunId); + assertShellRunCaller(record, caller); if (isActiveShellRunStatus(record.status)) { record = await this.markOrphaned( record, From e41468b5d252a81507fb52a517480d0dc183ed99 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 22:01:14 +0800 Subject: [PATCH 02/14] fix(cli): own user command teardown Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 29 ++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 136 ++++++++++++++++++ .../runtime-host-session-driver.test.ts | 90 ++++++++++++ packages/cli/src/pi-transcript.ts | 25 +++- packages/cli/src/pi-tui-runner.ts | 22 ++- .../cli/src/runtime-host-session-driver.ts | 131 ++++++++++++++--- packages/cli/src/session-driver.ts | 2 + 7 files changed, 410 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 1d5ba7c31f..29e15ead7c 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -2567,6 +2567,35 @@ describe('Maka Pi TUI transcript', () => { assert.equal(tool?.toolName, 'User command'); assert.equal(tool?.status, 'done'); assert.match(tool?.output ?? '', /\/repo/); + assert.equal( + state.entries.some((entry) => entry.kind === 'notice'), + false, + ); + }); + + test('preserves local user-command cards only for same-session reconnect replacement', () => { + const state = createMakaPiTranscriptState(); + appendUserCommandToTranscript(state, { + commandId: 'user-command-1', + command: 'sleep 60', + result: shellRun({ + ref: 'maka://runtime/background-tasks/user-command-1', + status: 'running', + stdout: '', + }) as ShellRunSnapshotResult, + }); + + replaceTranscriptWithStoredMessages(state, [], { preserveUserCommands: true }); + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.userOwned === true), + true, + ); + + replaceTranscriptWithStoredMessages(state, []); + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.userOwned === true), + false, + ); }); test('notifies a settle exactly once across a folded poll and the live update', () => { diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 481ba1b1da..958d1a6969 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -52,6 +52,7 @@ import type { MakaSessionRewindResult, MakaSessionSwitchOptions, MakaSessionSwitchResult, + MakaTranscriptReplacementReason, RewindTarget, SessionResumeAvailability, } from '../session-driver.js'; @@ -268,6 +269,86 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('Ctrl-C stops a running user command without exiting the TUI', async () => { + const terminal = new FakeTerminal(); + const driver = new RunningUserCommandDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('!sleep 3600'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('User command')); + + terminal.input('\x03'); + await waitFor(() => driver.stopUserCommandCalls === 1); + assert.equal(terminal.stopCalls, 0); + + exitMaka(terminal); + await run; + }); + + test('same-session reconnect keeps a user-command card for its terminal update', async () => { + const terminal = new FakeTerminal(); + const driver = new RunningUserCommandDriver(); + let publishShellRun: ((update: ShellRunUpdate) => void) | undefined; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + subscribeShellRunUpdates: (listener) => { + publishShellRun = listener; + return () => { + publishShellRun = undefined; + }; + }, + }); + + await waitForTuiPaint(terminal); + terminal.input('!printf done'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('User command')); + + driver.publishReconnect(); + publishShellRun?.({ + sessionId: 'session-1', + ownership: { kind: 'local' }, + sourceTurnId: 'user-command-1', + sourceToolCallId: 'user-command-1', + result: { + kind: 'shell_run', + ref: 'maka://runtime/background-tasks/user-command-1', + mode: 'pipes', + status: 'completed', + cwd: '/repo', + cmd: 'printf done', + startedAt: 1, + updatedAt: 2, + completedAt: 2, + exitCode: 0, + revision: 2, + output: pipeOutput('done\n'), + }, + }); + + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('done')); + assert.match(plainTerminalOutput(terminal.screenOutput()), /User command/); + + exitMaka(terminal); + await run; + }); + test('disables taskbar progress on Windows and Windows Terminal by default', () => { assert.equal(resolveTaskbarProgress(undefined, { platform: 'win32' }), false); assert.equal( @@ -7580,6 +7661,61 @@ class UserCommandDriver extends SlashCommandDriver { } } +class RunningUserCommandDriver extends SlashCommandDriver { + readonly commands: string[] = []; + stopUserCommandCalls = 0; + readonly #transcriptListeners = new Set< + ( + sessionId: string, + turnId: string, + messages: StoredMessage[], + reason: MakaTranscriptReplacementReason, + ) => void + >(); + + async runUserCommand(command: string) { + this.commands.push(command); + return { + commandId: `user-command-${this.commands.length}`, + result: { + kind: 'shell_run' as const, + ref: `maka://runtime/background-tasks/user-command-${this.commands.length}`, + mode: 'pipes' as const, + status: 'running' as const, + cwd: '/repo', + cmd: command, + startedAt: 1, + updatedAt: 1, + revision: 1, + output: pipeOutput(''), + }, + takeRacedUpdate: () => undefined, + }; + } + + async stopUserCommands(): Promise { + this.stopUserCommandCalls += 1; + } + + subscribeTranscriptReplacements( + listener: ( + sessionId: string, + turnId: string, + messages: StoredMessage[], + reason: MakaTranscriptReplacementReason, + ) => void, + ): () => void { + this.#transcriptListeners.add(listener); + return () => this.#transcriptListeners.delete(listener); + } + + publishReconnect(): void { + for (const listener of this.#transcriptListeners) { + listener('session-1', 'turn-1', [], 'reconnect'); + } + } +} + class HostSkillDriver extends SlashCommandDriver { constructor(private readonly skillInvocation: SkillInvocationResult) { super(); 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 19209ba29a..4e71c11b31 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -487,6 +487,85 @@ describe('Runtime Host Maka Session driver', () => { assert.equal(raced?.output?.mode === 'pipes' && raced.output.stdout, 'done\n'); }); + test('stops an already-running user command when the driver closes', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + await driver.stop(); + + const stop = connection.requests.find( + (request) => request.operation === 'runtime.resource.stop', + ); + assert.deepEqual(stop?.input, { + sessionId: 'id-1', + ref: connection.userCommandResource.ref, + }); + }); + + test('stops a user command whose start races driver close', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const releaseStart = deferred(); + connection.onRuntimeResourceStart = () => releaseStart.promise; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + + const starting = driver.runUserCommand!('sleep 3600'); + await waitFor(() => + connection.requests.some((request) => request.operation === 'runtime.resource.start'), + ); + const stopping = driver.stop(); + releaseStart.resolve(); + const command = await starting; + command.takeRacedUpdate(); + await stopping; + + assert.equal( + connection.requests.filter((request) => request.operation === 'runtime.resource.stop').length, + 1, + ); + }); + test('drops a per-session Full access elevation when a fresh Session starts (#3020)', async () => { // The TUI flow behind /new: session A is elevated to bypass, then the // driver is asked to start over. The next prompt lazily creates session B @@ -1808,6 +1887,17 @@ class FakeConnection { await this.onRuntimeResourceStart?.(); return { resource: this.userCommandResource } as OperationOutput; } + if (operation === 'runtime.resource.stop') { + return { + resource: { + ...this.userCommandResource, + status: 'cancelled', + updatedAt: 2, + completedAt: 2, + revision: 2, + }, + } as OperationOutput; + } if (operation === 'runtime.resource.query') { if (this.runtimeResourceQuery === undefined) { throw new Error('Unexpected Runtime Resource query'); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index d3400d10c3..34e9c82048 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -300,7 +300,13 @@ export function applyShellRunViewUpdateToTranscript( const tool = findToolEntry(state, update.sourceToolCallId); const wasLive = isLiveShellRunCard(tool); const applied = applyShellRunUpdateToTranscript(state, update.sourceToolCallId, update.result); - if (tool && wasLive && isSettledShellRunCard(tool) && options?.announceSettle !== false) { + if ( + tool && + tool.userOwned !== true && + wasLive && + isSettledShellRunCard(tool) && + options?.announceSettle !== false + ) { pushShellRunSettledNotice(state, tool); } if ( @@ -359,8 +365,17 @@ export function appendUserCommandToTranscript( export function replaceTranscriptWithStoredMessages( state: MakaPiTranscriptState, messages: readonly StoredMessage[], + options: { readonly preserveUserCommands?: boolean } = {}, ): void { - state.entries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); + const userCommands = options.preserveUserCommands + ? state.entries.filter( + (entry): entry is MakaPiToolEntry => entry.kind === 'tool' && entry.userOwned === true, + ) + : []; + state.entries = [ + ...foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)), + ...userCommands, + ]; clearPendingInteractions(state); state.expandAllTools = false; state.expandAllThinking = false; @@ -382,6 +397,12 @@ export function replaceTranscriptWithStoredMessages( } } +export function hasRunningUserCommand(state: MakaPiTranscriptState): boolean { + return state.entries.some( + (entry) => entry.kind === 'tool' && entry.userOwned === true && isLiveShellRunCard(entry), + ); +} + /** * Fill durable tool details that are intentionally absent from Runtime Host * live events without applying session-switch reset semantics. diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 02ef4418ea..52ba28fd64 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -92,6 +92,7 @@ import { applyMakaSessionEventToTranscript, applyShellRunUpdateToTranscript, createMakaPiTranscriptState, + hasRunningUserCommand, activeSandboxBoundaryRequest, activeUserQuestionRequest, completePendingInteraction, @@ -283,12 +284,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const tui = new TuiMainScreen(terminal); const state = createMakaPiTranscriptState(); let transcriptLastUsedModel: string | undefined; + let transcriptMessages: readonly StoredMessage[] = []; const rememberTranscriptModel = (messages: readonly StoredMessage[]): void => { transcriptLastUsedModel = latestAssistantModelId(messages); }; - const replaceTranscript = (messages: readonly StoredMessage[]): void => { + const replaceTranscript = ( + messages: readonly StoredMessage[], + options: { readonly preserveUserCommands?: boolean } = {}, + ): void => { rememberTranscriptModel(messages); - replaceTranscriptWithStoredMessages(state, messages); + transcriptMessages = messages; + replaceTranscriptWithStoredMessages(state, messages, options); }; let cwd = input.cwd; let model = input.model; @@ -537,7 +543,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { input.driver.subscribeTranscriptReplacements?.((sessionId, turnId, messages, reason) => { if (closed || input.driver.getSessionId() !== sessionId) return; if (reason === 'reconnect') { - replaceTranscript(messages); + replaceTranscript(messages, { preserveUserCommands: true }); shellRunElapsedTicker.sync(); requestRender(); return; @@ -3426,6 +3432,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { else requestTurnInterrupt(); return { consume: true }; } + if ( + !turnRunning && + matchesKey(data, Key.ctrl('c')) && + hasRunningUserCommand(state) && + input.driver.stopUserCommands + ) { + lastIdleCtrlCAt = 0; + void runControl(() => input.driver.stopUserCommands!()); + return { consume: true }; + } // Double Escape interrupts the running turn. This must sit below the // boundary branch so Escape keeps meaning "deny" while a prompt is // pending, and it only arms while a prompt turn is actually running. diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index d2e1798a2e..70366558f4 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -39,6 +39,7 @@ import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; import { mergeShellRunUpdate } from '@maka/core/shell-run-result'; +import { isActiveShellRunStatus } from '@maka/core/shell-run'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { executionBoundaryDisplayMode } from '@maka/core/sandbox-boundary'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -187,6 +188,14 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { readonly #pendingInteractionListeners = new Set<(pending: InteractionPendingSnapshot) => void>(); readonly #claimedTurnIds = new Set(); readonly #shellRunListeners = new Set<(update: ShellRunUpdate) => void>(); + readonly #activeUserCommands = new Map< + string, + { readonly sessionId: string; readonly commandId: string } + >(); + readonly #userCommandStartBarriers = new Set>(); + #userCommandStopGeneration = 0; + #userCommandStopsPending = 0; + #userCommandStopTail = Promise.resolve(); readonly #resolvedInteractionListeners = new Set< (sessionId: string, requestId: string) => void >(); @@ -317,17 +326,25 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { result: ShellRunSnapshotResult; takeRacedUpdate(): ShellRunUpdate['result'] | undefined; }> { - const sessionId = await this.#ensureSession(); - await this.#ensureChannel(sessionId); - const commandId = `user-command-${this.#newId()}`; - let latest: ShellRunUpdate | undefined; - const capture = (update: ShellRunUpdate) => { - if (update.sessionId === sessionId && update.sourceToolCallId === commandId) { - latest = mergeShellRunUpdate(latest, update, 'cli.user-command-start').update; - } - }; - this.#shellRunListeners.add(capture); + const stopGeneration = this.#userCommandStopGeneration; + const stopAlreadyPending = this.#userCommandStopsPending > 0; + let releaseStartBarrier: (() => void) | undefined; + const startBarrier = new Promise((resolve) => { + releaseStartBarrier = resolve; + }); + this.#userCommandStartBarriers.add(startBarrier); + let capture: ((update: ShellRunUpdate) => void) | undefined; try { + const sessionId = await this.#ensureSession(); + await this.#ensureChannel(sessionId); + const commandId = `user-command-${this.#newId()}`; + let latest: ShellRunUpdate | undefined; + capture = (update: ShellRunUpdate) => { + if (update.sessionId === sessionId && update.sourceToolCallId === commandId) { + latest = mergeShellRunUpdate(latest, update, 'cli.user-command-start').update; + } + }; + this.#shellRunListeners.add(capture); const started = await this.#request('runtime.resource.start', { sessionId, launchId: commandId, @@ -336,6 +353,21 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { if (started.resource.mode !== 'pipes') { throw new Error('Runtime Host did not start a one-shot user command'); } + const newestResult = + latest && latest.result.revision > started.resource.revision + ? latest.result + : started.resource; + if (isActiveShellRunStatus(newestResult.status)) { + const owner = { sessionId, commandId }; + this.#activeUserCommands.set(newestResult.ref, owner); + if ( + stopAlreadyPending || + this.#userCommandStopGeneration !== stopGeneration || + this.#userCommandStopsPending > 0 + ) { + await this.#stopUserCommand(newestResult.ref, owner); + } + } let activated = false; return { commandId, @@ -343,18 +375,38 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { takeRacedUpdate: () => { if (activated) return undefined; activated = true; - this.#shellRunListeners.delete(capture); + this.#shellRunListeners.delete(capture!); return latest && latest.result.revision > started.resource.revision ? latest.result : undefined; }, }; } catch (error) { - this.#shellRunListeners.delete(capture); + if (capture) this.#shellRunListeners.delete(capture); throw error; + } finally { + releaseStartBarrier?.(); + this.#userCommandStartBarriers.delete(startBarrier); } } + stopUserCommands(): Promise { + this.#userCommandStopGeneration += 1; + this.#userCommandStopsPending += 1; + const stop = this.#userCommandStopTail.then(async () => { + try { + await Promise.all([...this.#userCommandStartBarriers]); + await Promise.all( + [...this.#activeUserCommands].map(([ref, owner]) => this.#stopUserCommand(ref, owner)), + ); + } finally { + this.#userCommandStopsPending -= 1; + } + }); + this.#userCommandStopTail = stop.catch(() => undefined); + return stop; + } + async *compactSession(): AsyncIterable { const sessionId = this.#requireSession('compact'); const channel = await this.#ensureChannel(sessionId); @@ -701,12 +753,21 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { async stop(): Promise { const turn = this.#channel?.snapshot.rootTurn; - if (!turn || isTerminalTurn(turn)) return; - await this.#request('turn.stop', { - sessionId: turn.sessionId, - turnId: turn.turnId, - runId: turn.runId, - }); + const stops: Promise[] = [this.stopUserCommands()]; + if (turn && !isTerminalTurn(turn)) { + stops.push( + this.#request('turn.stop', { + sessionId: turn.sessionId, + turnId: turn.turnId, + runId: turn.runId, + }), + ); + } + const results = await Promise.allSettled(stops); + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + if (failed) throw failed.reason; } getSessionId(): string | null { @@ -1104,7 +1165,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { }) .then((result) => { if (result.kind !== 'resource' || !result.resource) return; - for (const listener of this.#shellRunListeners) listener(result.resource); + this.#publishShellRunUpdate(result.resource); }) .catch(() => undefined); } @@ -1167,12 +1228,42 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { .then((resources) => { if (this.#sessionId !== sessionId) return; for (const resource of resources) { - for (const listener of this.#shellRunListeners) listener(resource); + this.#publishShellRunUpdate(resource); } }) .catch(() => undefined); } + async #stopUserCommand( + ref: string, + owner: { readonly sessionId: string; readonly commandId: string }, + ): Promise { + if (this.#activeUserCommands.get(ref) !== owner) return; + const stopped = await this.#request('runtime.resource.stop', { + sessionId: owner.sessionId, + ref, + }); + this.#publishShellRunUpdate({ + sessionId: owner.sessionId, + ownership: { kind: 'local' }, + sourceTurnId: owner.commandId, + sourceToolCallId: owner.commandId, + result: stopped.resource, + }); + } + + #publishShellRunUpdate(update: ShellRunUpdate): void { + const owner = this.#activeUserCommands.get(update.result.ref); + if ( + owner?.sessionId === update.sessionId && + owner.commandId === update.sourceToolCallId && + !isActiveShellRunStatus(update.result.status) + ) { + this.#activeUserCommands.delete(update.result.ref); + } + for (const listener of this.#shellRunListeners) listener(update); + } + #request( operation: K, input: OperationInput, diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index a029491435..233f3c8241 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -109,6 +109,8 @@ export interface MakaSessionDriver { ): Promise; /** Runs one user-owned command. Its input/output never becomes model prompt history. */ runUserCommand?(command: string): Promise; + /** Stops every live user-owned command started by this driver. */ + stopUserCommands?(): Promise; compactSession(): AsyncIterable; resumeLatest?(): AsyncIterable; steer?(text: string): Promise; From c711a3c68c05b2a2e51a38d21f9dd8d7db83eb1f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 20 Aug 2026 00:09:54 +0800 Subject: [PATCH 03/14] fix(cli): hint bare user commands Show localized privacy and Ctrl+O guidance while the editor contains only the user-command prefix. Generated-by: Codex --- .../cli/src/__tests__/pi-tui-runner.test.ts | 36 +++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 1 + packages/cli/src/skill-highlight-editor.ts | 24 +++++++++++-- packages/cli/src/tui-primary-guidance.ts | 9 +++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 958d1a6969..532ddc38df 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -269,6 +269,42 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('a bare ! shows localized user-command guidance without starting a turn', async () => { + const terminal = new FakeTerminal(); + const driver = new UserCommandDriver(); + const run = runMakaPiTui({ + title: 'Maka', + locale: 'zh', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('!'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes( + 'shell 命令(仅你可见)· Ctrl+O 展开输出', + ), + ); + assert.deepEqual(driver.commands, []); + assert.deepEqual(driver.prompts, []); + + terminal.input('p'); + await waitFor( + () => + !plainTerminalOutput(terminal.screenOutput()).includes( + 'shell 命令(仅你可见)· Ctrl+O 展开输出', + ), + ); + + exitMaka(terminal); + await run; + }); + test('Ctrl-C stops a running user command without exiting the TUI', async () => { const terminal = new FakeTerminal(); const driver = new RunningUserCommandDriver(); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 52ba28fd64..a9e2577383 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -476,6 +476,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { paddingX: 0, autocompleteMaxVisible: EDITOR_AUTOCOMPLETE_MAX_VISIBLE, }); + editor.setUserCommandHint(primaryGuidance.editor.userCommandHint); let refreshEditorCwd: ((cwd: string) => void) | undefined; const editorSurface = new MakaAutocompleteAboveEditorComponent(editor); const layout = new MakaPiLayoutComponent( diff --git a/packages/cli/src/skill-highlight-editor.ts b/packages/cli/src/skill-highlight-editor.ts index e27f6dfafe..5898d0cc00 100644 --- a/packages/cli/src/skill-highlight-editor.ts +++ b/packages/cli/src/skill-highlight-editor.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Editor } from '@earendil-works/pi-tui'; +import { Editor, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { ansi } from './tui-ansi.js'; @@ -44,6 +44,12 @@ const MID_MESSAGE_SLASH_TOKEN = /(?:\s)\/\S*$/; */ export class MakaSkillHighlightEditor extends Editor { private isInvocable: (name: string) => boolean = () => false; + private userCommandHint = ''; + + setUserCommandHint(hint: string): void { + this.userCommandHint = hint; + this.invalidate(); + } /** * Swap the validator used by the render pass. Must be synchronous and @@ -57,13 +63,27 @@ export class MakaSkillHighlightEditor extends Editor { override render(width: number): string[] { const pattern = new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'); - return super + const lines = super .render(width) .map((line) => line.replace(pattern, (whole, name: string) => this.isInvocable(name) ? ansi.accent(whole) : whole, ), ); + if (this.getText() !== '!' || !this.userCommandHint) return lines; + + const cursor = '\x1b[7m \x1b[0m'; + const contentLine = lines.findIndex((line) => line.includes(cursor)); + if (contentLine === -1) return lines; + + const line = lines[contentLine] ?? ''; + const cursorEnd = line.indexOf(cursor) + cursor.length; + const available = Math.max(0, width - visibleWidth(line.slice(0, cursorEnd))); + const hint = truncateToWidth(` ${this.userCommandHint}`, available, ''); + lines[contentLine] = `${line.slice(0, cursorEnd)}${ansi.dim(hint)}${' '.repeat( + Math.max(0, available - visibleWidth(hint)), + )}`; + return lines; } override handleInput(data: string): void { diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index 6f0553a8d1..2ddfb1067b 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -38,6 +38,9 @@ export interface TuiPrimaryGuidanceCopy { readonly keybindingsHeading: string; readonly keybindings: readonly string[]; }; + readonly editor: { + readonly userCommandHint: string; + }; } const TUI_PRIMARY_GUIDANCE = { @@ -88,6 +91,9 @@ const TUI_PRIMARY_GUIDANCE = { ' Ctrl+D — 输入为空时退出', ], }, + editor: { + userCommandHint: 'shell 命令(仅你可见)· Ctrl+O 展开输出', + }, }, en: { welcome: { @@ -136,6 +142,9 @@ const TUI_PRIMARY_GUIDANCE = { ' Ctrl+D — exit when input is empty', ], }, + editor: { + userCommandHint: 'shell command (visible only to you) · Ctrl+O expands output', + }, }, } satisfies UiCatalog; From f3376bc6ab201d806aeac291042b2a0483d3d746 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 20 Aug 2026 00:13:59 +0800 Subject: [PATCH 04/14] fix(cli): clarify user command hint Keep the inline guidance concise while separating input, privacy, and output expansion. Generated-by: Codex --- packages/cli/src/__tests__/pi-tui-runner.test.ts | 4 ++-- packages/cli/src/tui-primary-guidance.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 532ddc38df..5128bea1b4 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -287,7 +287,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('!'); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes( - 'shell 命令(仅你可见)· Ctrl+O 展开输出', + '输入 shell 命令 · 仅你可见 · Ctrl+O 展开输出', ), ); assert.deepEqual(driver.commands, []); @@ -297,7 +297,7 @@ describe('Maka Pi TUI runner', () => { await waitFor( () => !plainTerminalOutput(terminal.screenOutput()).includes( - 'shell 命令(仅你可见)· Ctrl+O 展开输出', + '输入 shell 命令 · 仅你可见 · Ctrl+O 展开输出', ), ); diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index 2ddfb1067b..1bc3a75f47 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -92,7 +92,7 @@ const TUI_PRIMARY_GUIDANCE = { ], }, editor: { - userCommandHint: 'shell 命令(仅你可见)· Ctrl+O 展开输出', + userCommandHint: '输入 shell 命令 · 仅你可见 · Ctrl+O 展开输出', }, }, en: { @@ -143,7 +143,7 @@ const TUI_PRIMARY_GUIDANCE = { ], }, editor: { - userCommandHint: 'shell command (visible only to you) · Ctrl+O expands output', + userCommandHint: 'type a shell command · visible only to you · Ctrl+O expands output', }, }, } satisfies UiCatalog; From 48ab966283a34db5baec2471bccc31e687f8ebe3 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 20 Aug 2026 00:14:48 +0800 Subject: [PATCH 05/14] fix(cli): streamline user command hint Keep privacy details in help and show only immediate editor actions inline. Generated-by: Codex --- packages/cli/src/__tests__/pi-tui-runner.test.ts | 8 ++------ packages/cli/src/tui-primary-guidance.ts | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 5128bea1b4..308b93a49a 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -286,9 +286,7 @@ describe('Maka Pi TUI runner', () => { await waitForTuiPaint(terminal); terminal.input('!'); await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes( - '输入 shell 命令 · 仅你可见 · Ctrl+O 展开输出', - ), + plainTerminalOutput(terminal.screenOutput()).includes('输入 shell 命令 · Ctrl+O 展开输出'), ); assert.deepEqual(driver.commands, []); assert.deepEqual(driver.prompts, []); @@ -296,9 +294,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('p'); await waitFor( () => - !plainTerminalOutput(terminal.screenOutput()).includes( - '输入 shell 命令 · 仅你可见 · Ctrl+O 展开输出', - ), + !plainTerminalOutput(terminal.screenOutput()).includes('输入 shell 命令 · Ctrl+O 展开输出'), ); exitMaka(terminal); diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index 1bc3a75f47..c06d9eeca9 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -92,7 +92,7 @@ const TUI_PRIMARY_GUIDANCE = { ], }, editor: { - userCommandHint: '输入 shell 命令 · 仅你可见 · Ctrl+O 展开输出', + userCommandHint: '输入 shell 命令 · Ctrl+O 展开输出', }, }, en: { @@ -143,7 +143,7 @@ const TUI_PRIMARY_GUIDANCE = { ], }, editor: { - userCommandHint: 'type a shell command · visible only to you · Ctrl+O expands output', + userCommandHint: 'type a shell command · Ctrl+O expands output', }, }, } satisfies UiCatalog; From 5843a94e571bf7e12211c6a3202eb73990ccef1f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 20 Aug 2026 00:16:33 +0800 Subject: [PATCH 06/14] fix(cli): keep user command output expanded Reserve Ctrl+O for model tool cards and show user-invoked command output by default. Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 38 +++++++++++++++++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 9 +---- packages/cli/src/pi-transcript.ts | 4 +- packages/cli/src/tui-primary-guidance.ts | 4 +- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 29e15ead7c..2329c60b50 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -2566,6 +2566,7 @@ describe('Maka Pi TUI transcript', () => { const tool = state.entries.find((entry) => entry.kind === 'tool'); assert.equal(tool?.toolName, 'User command'); assert.equal(tool?.status, 'done'); + assert.equal(tool?.expanded, true); assert.match(tool?.output ?? '', /\/repo/); assert.equal( state.entries.some((entry) => entry.kind === 'notice'), @@ -2573,6 +2574,43 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('keeps user commands expanded and outside Ctrl+O model-tool toggles', () => { + const state = createMakaPiTranscriptState(); + appendUserCommandToTranscript(state, { + commandId: 'user-command-1', + command: 'printf done', + result: shellRun({ + ref: 'maka://runtime/background-tasks/user-command-1', + status: 'completed', + stdout: 'done\n', + completedAt: 2_000, + exitCode: 0, + }) as ShellRunSnapshotResult, + }); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'model-tool-1', + toolName: 'Bash', + args: { command: 'printf model' }, + }), + ); + const tools = state.entries.filter((entry) => entry.kind === 'tool'); + const userCommand = tools.find((entry) => entry.userOwned === true); + const modelTool = tools.find((entry) => entry.userOwned !== true); + assert.ok(userCommand && modelTool); + assert.equal(userCommand.expanded, true); + assert.equal(modelTool.expanded, false); + + assert.equal(toggleAllToolExpansion(state), true); + assert.equal(userCommand.expanded, true); + assert.equal(modelTool.expanded, true); + assert.equal(toggleAllToolExpansion(state), true); + assert.equal(userCommand.expanded, true); + assert.equal(modelTool.expanded, false); + }); + test('preserves local user-command cards only for same-session reconnect replacement', () => { const state = createMakaPiTranscriptState(); appendUserCommandToTranscript(state, { diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 308b93a49a..ea346faf89 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -285,17 +285,12 @@ describe('Maka Pi TUI runner', () => { await waitForTuiPaint(terminal); terminal.input('!'); - await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('输入 shell 命令 · Ctrl+O 展开输出'), - ); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('输入 shell 命令')); assert.deepEqual(driver.commands, []); assert.deepEqual(driver.prompts, []); terminal.input('p'); - await waitFor( - () => - !plainTerminalOutput(terminal.screenOutput()).includes('输入 shell 命令 · Ctrl+O 展开输出'), - ); + await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('输入 shell 命令')); exitMaka(terminal); await run; diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 34e9c82048..60a7def077 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -357,7 +357,7 @@ export function appendUserCommandToTranscript( progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), status: shellRunTranscriptStatus(input.result.status), - expanded: state.expandAllTools, + expanded: true, userOwned: true, }); } @@ -499,7 +499,7 @@ function togglesInert(state: MakaPiTranscriptState): boolean { export function toggleAllToolExpansion(state: MakaPiTranscriptState): boolean { if (togglesInert(state)) return false; const candidates = state.entries.filter( - (entry): entry is MakaPiToolEntry => entry.kind === 'tool', + (entry): entry is MakaPiToolEntry => entry.kind === 'tool' && entry.userOwned !== true, ); if (candidates.length === 0) return false; state.expandAllTools = !state.expandAllTools; diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index c06d9eeca9..dead56997c 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -92,7 +92,7 @@ const TUI_PRIMARY_GUIDANCE = { ], }, editor: { - userCommandHint: '输入 shell 命令 · Ctrl+O 展开输出', + userCommandHint: '输入 shell 命令', }, }, en: { @@ -143,7 +143,7 @@ const TUI_PRIMARY_GUIDANCE = { ], }, editor: { - userCommandHint: 'type a shell command · Ctrl+O expands output', + userCommandHint: 'type a shell command', }, }, } satisfies UiCatalog; From 9ba0b5911b4d9eae2ed313c64931c2b1debfd7a5 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 20 Aug 2026 18:47:47 +0800 Subject: [PATCH 07/14] fix(cli): stop user commands before leaving their Session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session switches (/session, rewind) and /new replace the transcript, which dropped the only projection of a running user-owned command and disabled its Ctrl+C stop affordance while the command kept running invisibly. Await the start-barrier-aware stop path in switchSession before changing Session identity, and trigger the same synchronous generation bump in startNewSession so in-flight starts self-stop. Add running-command → switch and running-command → /new regressions. Generated-by: Codex --- .../runtime-host-session-driver.test.ts | 84 +++++++++++++++++++ .../cli/src/runtime-host-session-driver.ts | 13 +++ 2 files changed, 97 insertions(+) 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 4e71c11b31..d67cba981c 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -566,6 +566,90 @@ describe('Runtime Host Maka Session driver', () => { ); }); + test('stops a running user command before switching Sessions (#3210)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const switchSubscription = new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription, switchSubscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + await driver.switchSession('session-1'); + + const stopIndex = connection.requests.findIndex( + (request) => request.operation === 'runtime.resource.stop', + ); + assert.notEqual(stopIndex, -1); + assert.deepEqual(connection.requests[stopIndex]?.input, { + sessionId: 'id-1', + ref: connection.userCommandResource.ref, + }); + assert.equal(driver.getSessionId(), 'session-1'); + }); + + test('stops a running user command before a fresh Session (#3210)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + driver.startNewSession(); + + await waitFor(() => + connection.requests.some((request) => request.operation === 'runtime.resource.stop'), + ); + const stop = connection.requests.find( + (request) => request.operation === 'runtime.resource.stop', + ); + assert.deepEqual(stop?.input, { + sessionId: 'id-1', + ref: connection.userCommandResource.ref, + }); + assert.equal(driver.getSessionId(), null); + }); + test('drops a per-session Full access elevation when a fresh Session starts (#3020)', async () => { // The TUI flow behind /new: session A is elevated to bypass, then the // driver is asked to start over. The next prompt lazily creates session B diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 70366558f4..2534db1161 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -606,6 +606,12 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { summary = projectSessionCatalogSummary(session); await assertSessionResumeAvailable(summary, this.#executionLocation); } + // Leaving the current Session must not orphan its live user commands: + // the switch replaces the transcript, so their cards and the Ctrl+C stop + // affordance would disappear while the commands keep running. Await the + // start-barrier-aware stop path before changing Session identity so an + // in-flight start cannot land after the switch (#3210). + await this.stopUserCommands(); const expectedChannelGeneration = this.#channelGeneration; const nextSessionGeneration = this.#sessionGeneration + 1; const opened = await this.#openSessionChannel(sessionId, nextSessionGeneration); @@ -697,6 +703,13 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { } startNewSession(): void { + // `/new` replaces the transcript without preserving user-command cards, + // so a still-running command would lose both its projection and its + // Ctrl+C stop affordance. Stop tracked commands before the identity + // change; the generation/pending bump is synchronous, so an in-flight + // start self-stops when it resolves even though this method stays sync + // (#3210). + void this.stopUserCommands().catch(() => undefined); this.#sessionGeneration += 1; this.#channelGeneration += 1; this.#sessionId = null; From c7c6fdda018ccfa218e17c5b9a3ea0930045d04a Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 20 Aug 2026 20:06:36 +0800 Subject: [PATCH 08/14] fix(cli): isolate user-command stop failures and gate the widened start input - Bump RUNTIME_HOST_COMPATIBILITY_EPOCH to 29: runtime.resource.start now accepts an optional one-shot command and the durable Shell Run record carries visibility, so a pre-widening peer must be refused at admission instead of failing on the first ! command. - switchSession awaits stopUserCommands before the durable cwd relocation, so a rejecting stop aborts the switch with nothing committed rather than stranding a half-switched Session. - driver.stop() stops user commands best-effort so a rejecting runtime.resource.stop is never reported as a failed turn interrupt. - A rejected Ctrl+C user-command stop disarms the capture so the next press returns to the exit chord instead of being swallowed forever. - Same-session reconnect re-inserts preserved user-command cards at their chronological position instead of the transcript tail. - Scope visibility: "user" to command-carrying starts; the Desktop interactive login shell keeps its prior model-visible behavior. Generated-by: Maka --- .../cli/src/__tests__/pi-transcript.test.ts | 46 +++++++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 48 ++++++++++ .../runtime-host-session-driver.test.ts | 96 +++++++++++++++++++ packages/cli/src/pi-transcript.ts | 45 ++++++++- packages/cli/src/pi-tui-runner.ts | 24 ++++- .../cli/src/runtime-host-session-driver.ts | 24 +++-- packages/cli/src/skill-highlight-editor.ts | 7 ++ .../runtime-resource-coordinator.test.ts | 4 +- .../runtime-resource-protocol.test.ts | 22 +++++ packages/runtime-host/src/protocol/index.ts | 7 +- .../server/runtime-resource-coordinator.ts | 5 +- 11 files changed, 309 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 2329c60b50..10b3da13f3 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -2636,6 +2636,52 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('reconnect re-inserts preserved user-command cards at their chronological position (#3210)', () => { + const state = createMakaPiTranscriptState(); + // The command ran before the model turns that followed it. + appendUserCommandToTranscript(state, { + commandId: 'user-command-1', + command: 'pwd', + result: shellRun({ + ref: 'maka://runtime/background-tasks/user-command-1', + status: 'completed', + stdout: '/repo\n', + startedAt: 1_000, + }) as ShellRunSnapshotResult, + }); + + replaceTranscriptWithStoredMessages( + state, + [ + { type: 'user', id: 'message-1', turnId: 'turn-1', ts: 2_000, text: 'later prompt' }, + { + type: 'assistant', + id: 'message-2', + turnId: 'turn-1', + ts: 3_000, + text: 'later answer', + modelId: 'model-1', + }, + ], + { preserveUserCommands: true }, + ); + + const cardIndex = state.entries.findIndex( + (entry) => entry.kind === 'tool' && entry.userOwned === true, + ); + const promptIndex = state.entries.findIndex((entry) => + JSON.stringify(entry).includes('later prompt'), + ); + const answerIndex = state.entries.findIndex((entry) => + JSON.stringify(entry).includes('later answer'), + ); + assert.notEqual(cardIndex, -1); + assert.notEqual(promptIndex, -1); + assert.notEqual(answerIndex, -1); + assert.ok(cardIndex < promptIndex, 'card must stay ahead of the later turn'); + assert.ok(promptIndex < answerIndex); + }); + test('notifies a settle exactly once across a folded poll and the live update', () => { const state = createMakaPiTranscriptState(); const ref = 'maka://runtime/background-tasks/bg-1'; diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index ea346faf89..ed3d76b256 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -322,6 +322,47 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('a rejected user-command stop hands Ctrl-C back to the exit chord (#3210)', async () => { + const terminal = new FakeTerminal(); + const driver = new RejectingUserCommandStopDriver(); + const processExitCodes: number[] = []; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + onProcessExit: (exitCode) => processExitCodes.push(exitCode), + }); + + await waitForTuiPaint(terminal); + terminal.input('!sleep 3600'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('User command')); + + // The first Ctrl+C is captured to stop the command, but the stop rejects: + // no terminal update is published, so the card still reads running. + terminal.input('\x03'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('host_draining')); + assert.equal(driver.stopUserCommandCalls, 1); + assert.equal(terminal.stopCalls, 0); + + // The capture must disarm: the next press shows the exit prompt and the + // one after exits. + terminal.input('\x03'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Press Ctrl+C again to exit.'), + ); + assert.equal(driver.stopUserCommandCalls, 1); + assert.equal(terminal.stopCalls, 0); + + terminal.input('\x03'); + await run; + assert.deepEqual(processExitCodes, [0]); + }); + test('same-session reconnect keeps a user-command card for its terminal update', async () => { const terminal = new FakeTerminal(); const driver = new RunningUserCommandDriver(); @@ -7743,6 +7784,13 @@ class RunningUserCommandDriver extends SlashCommandDriver { } } +class RejectingUserCommandStopDriver extends RunningUserCommandDriver { + override async stopUserCommands(): Promise { + this.stopUserCommandCalls += 1; + throw new Error('host_draining'); + } +} + class HostSkillDriver extends SlashCommandDriver { constructor(private readonly skillInvocation: SkillInvocationResult) { super(); 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 d67cba981c..fbf30e2ca1 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -566,6 +566,41 @@ describe('Runtime Host Maka Session driver', () => { ); }); + test('a rejecting user-command stop does not fail the turn interrupt (#3210)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: runningTurn('turn-1', 'run-1'), + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + connection.runtimeResourceStopFailure = new Error('host_draining'); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + // turn.stop succeeds while the user-command stop rejects: the interrupt + // itself must still report success. + await driver.stop(); + + assert.ok(connection.requests.some((request) => request.operation === 'turn.stop')); + assert.ok(connection.requests.some((request) => request.operation === 'runtime.resource.stop')); + }); + test('stops a running user command before switching Sessions (#3210)', async () => { const subscription = new FakeSubscription( continuitySnapshot({ @@ -609,6 +644,61 @@ describe('Runtime Host Maka Session driver', () => { assert.equal(driver.getSessionId(), 'session-1'); }); + test('a rejecting user-command stop aborts the switch before any durable relocation commits (#3210)', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tui-switch-stop-failure-')); + const target = join(root, 'new-worktree'); + await mkdir(target); + try { + const oldCwd = join(root, 'old-worktree'); + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + connection.sessionQueries.push( + sessionProjection({ + workspace: { target: { kind: 'host_path', path: oldCwd }, hostCwd: oldCwd }, + }), + ); + connection.runtimeResourceStopFailure = new Error('host_draining'); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: root, + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + inspectCwdChanges: async () => undefined, + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + await assert.rejects( + driver.switchSession('session-1', { relocateCwd: './new-worktree' }), + /host_draining/, + ); + + // The switch aborted before anything durable: no relocation was + // committed and the driver still owns the original Session. + assert.equal( + connection.requests.some(({ operation }) => operation === 'session.workspace.relocate'), + false, + ); + assert.equal(driver.getSessionId(), 'id-1'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test('stops a running user command before a fresh Session (#3210)', async () => { const subscription = new FakeSubscription( continuitySnapshot({ @@ -1872,6 +1962,8 @@ class FakeConnection { onRuntimeResourceStart: (() => Promise) | undefined; executionBoundary: unknown = { kind: 'managed', access: 'read_write', revision: 1 }; skillStartBlocked = false; + /** When set, runtime.resource.stop rejects with this error (e.g. a draining Host). */ + runtimeResourceStopFailure: Error | undefined; /** 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). */ @@ -1972,6 +2064,7 @@ class FakeConnection { return { resource: this.userCommandResource } as OperationOutput; } if (operation === 'runtime.resource.stop') { + if (this.runtimeResourceStopFailure) throw this.runtimeResourceStopFailure; return { resource: { ...this.userCommandResource, @@ -1988,6 +2081,9 @@ class FakeConnection { } return this.runtimeResourceQuery as OperationOutput; } + if (operation === 'turn.stop') { + return {} as OperationOutput; + } const turnInput = input as { sessionId?: string; turnId?: string; diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 60a7def077..dfff73490e 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -372,10 +372,37 @@ export function replaceTranscriptWithStoredMessages( (entry): entry is MakaPiToolEntry => entry.kind === 'tool' && entry.userOwned === true, ) : []; - state.entries = [ - ...foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)), - ...userCommands, - ]; + if (userCommands.length === 0) { + state.entries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); + } else { + // Re-insert each preserved card at its chronological position: comparing + // the shell run's startedAt against item timestamps keeps a `!` command + // that ran before later model turns ahead of them, so a same-session + // reconnect does not reorder the transcript (#3210). + const view = materializeSession(messages); + const cards = [...userCommands].sort( + (a, b) => userCommandCardStartedAt(a) - userCommandCardStartedAt(b), + ); + const interleaved: MakaPiTranscriptEntry[] = []; + let cardIndex = 0; + for (const item of view.items) { + while ( + cardIndex < cards.length && + userCommandCardStartedAt(cards[cardIndex]!) <= chatItemTimestamp(item) + ) { + interleaved.push(cards[cardIndex]!); + cardIndex += 1; + } + interleaved.push(...chatItemToTranscriptEntries(item)); + } + while (cardIndex < cards.length) { + interleaved.push(cards[cardIndex]!); + cardIndex += 1; + } + // Cards survive the fold untouched: folding only merges a stored shell-run + // child into a Bash parent sharing its ref, which a user command never is. + state.entries = foldStoredShellRunChildren(interleaved); + } clearPendingInteractions(state); state.expandAllTools = false; state.expandAllThinking = false; @@ -403,6 +430,16 @@ export function hasRunningUserCommand(state: MakaPiTranscriptState): boolean { ); } +/** Chronological key for a preserved user-command card; unknown times sort last. */ +function userCommandCardStartedAt(entry: MakaPiToolEntry): number { + return entry.result?.kind === 'shell_run' ? entry.result.startedAt : Number.POSITIVE_INFINITY; +} + +/** Chronological key for a materialized view item. */ +function chatItemTimestamp(item: ChatItem): number { + return item.kind === 'tool' ? item.item.ts : item.message.ts; +} + /** * Fill durable tool details that are intentionally absent from Runtime Host * live events without applying session-switch reset semantics. diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index a9e2577383..6f1cd89d32 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -376,6 +376,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let lastTurnEscapeAt = 0; let lastIdleEscapeAt = 0; let lastIdleCtrlCAt = 0; + // Two Escapes this close together read as one deliberate "stop the turn". + const DOUBLE_ESCAPE_INTERRUPT_WINDOW_MS = 600; + const DOUBLE_CTRL_C_EXIT_WINDOW_MS = 1_000; // Mirrors the editor's bracketed-paste buffering at the input seam: between a // paste start marker and its end marker the editor holds incoming bytes in an // internal buffer and getText() stays empty, so "editor is empty" must not @@ -383,6 +386,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // matching deliberately mirrors the editor's own per-chunk includes() checks, // so this flag agrees with what the editor will buffer. let editorPastePending = false; + // Set once a user-command stop rejects: a rejected stop publishes no + // terminal update, so the card would read running for the rest of the + // session and the branch below would capture every later Ctrl+C, hiding + // the exit chord. After a failure the capture disarms and Ctrl+C falls + // through to the normal idle handling (#3210). + let userCommandStopRejected = false; type AttachedTurnContext = | { readonly kind: 'adopted'; readonly turn: MakaPreparedSessionTurn } | { readonly kind: 'external'; readonly turn: MakaAttachedSessionTurn }; @@ -1132,6 +1141,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (prompt.trim().split(/\s+/, 1)[0] === '/transcript') { editor.addToHistory(prompt); handleSlashCommand(prompt, 0); + return; + } if (parseUserCommand(prompt) !== undefined) { editor.addToHistory(prompt); state.entries.push({ @@ -3436,11 +3447,19 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if ( !turnRunning && matchesKey(data, Key.ctrl('c')) && + !userCommandStopRejected && hasRunningUserCommand(state) && input.driver.stopUserCommands ) { lastIdleCtrlCAt = 0; - void runControl(() => input.driver.stopUserCommands!()); + void runControl(async () => { + try { + await input.driver.stopUserCommands!(); + } catch (error) { + userCommandStopRejected = true; + reportError(error); + } + }); return { consume: true }; } // Double Escape interrupts the running turn. This must sit below the @@ -3771,6 +3790,3 @@ function parseUserCommand(prompt: string): string | undefined { return trimmed.slice(1).trim(); } -// Two Escapes this close together read as one deliberate "stop the turn". -const DOUBLE_ESCAPE_INTERRUPT_WINDOW_MS = 600; -const DOUBLE_CTRL_C_EXIT_WINDOW_MS = 1_000; diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 2534db1161..069707a13d 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -40,7 +40,6 @@ import type { PermissionMode } from '@maka/core/permission'; import { mergeShellRunUpdate } from '@maka/core/shell-run-result'; import { isActiveShellRunStatus } from '@maka/core/shell-run'; -import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { executionBoundaryDisplayMode } from '@maka/core/sandbox-boundary'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -587,6 +586,14 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { `Cannot resume externally isolated session ${sessionId} outside its owning harness.`, ); } + // Leaving the current Session must not orphan its live user commands: + // the switch replaces the transcript, so their cards and the Ctrl+C stop + // affordance would disappear while the commands keep running. Await the + // start-barrier-aware stop path before changing Session identity so an + // in-flight start cannot land after the switch (#3210). This runs before + // the durable cwd relocation below: if a stop rejects, the switch aborts + // with nothing committed rather than stranding a half-switched Session. + await this.stopUserCommands(); let relocation: MakaSessionMoveResult | undefined; if (options.relocateCwd !== undefined) { const nextCwd = await resolveMoveCwd(options.relocateCwd, this.#workspace.hostCwd); @@ -606,12 +613,6 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { summary = projectSessionCatalogSummary(session); await assertSessionResumeAvailable(summary, this.#executionLocation); } - // Leaving the current Session must not orphan its live user commands: - // the switch replaces the transcript, so their cards and the Ctrl+C stop - // affordance would disappear while the commands keep running. Await the - // start-barrier-aware stop path before changing Session identity so an - // in-flight start cannot land after the switch (#3210). - await this.stopUserCommands(); const expectedChannelGeneration = this.#channelGeneration; const nextSessionGeneration = this.#sessionGeneration + 1; const opened = await this.#openSessionChannel(sessionId, nextSessionGeneration); @@ -766,7 +767,14 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { async stop(): Promise { const turn = this.#channel?.snapshot.rootTurn; - const stops: Promise[] = [this.stopUserCommands()]; + // A user command is not part of the turn, so its stop must never be + // reported as a failed turn interrupt: a rejecting runtime.resource.stop + // (host draining, transport failure) would otherwise reset the caller's + // interrupt affordance even though turn.stop succeeded. Stop the commands + // best-effort here — this is also the close authority — while the callers + // that own their lifecycle (Ctrl+C, Session switch) await + // stopUserCommands() directly and surface its errors themselves (#3210). + const stops: Promise[] = [this.stopUserCommands().catch(() => undefined)]; if (turn && !isTerminalTurn(turn)) { stops.push( this.#request('turn.stop', { diff --git a/packages/cli/src/skill-highlight-editor.ts b/packages/cli/src/skill-highlight-editor.ts index 5898d0cc00..058b2981d5 100644 --- a/packages/cli/src/skill-highlight-editor.ts +++ b/packages/cli/src/skill-highlight-editor.ts @@ -72,6 +72,13 @@ export class MakaSkillHighlightEditor extends Editor { ); if (this.getText() !== '!' || !this.userCommandHint) return lines; + // NOTE: this cursor glyph is a private rendering detail of + // @earendil-works/pi-tui — `Editor.render` emits `\x1b[7m \x1b[0m` (reverse + // space) only when the cursor sits at end-of-line, which a bare `!` + // always produces (read from pi-tui 0.83.0, components/editor.js:442). + // A dependency bump that changes the glyph makes findIndex return -1 and + // the hint silently disappears; the covering test asserts the rendered + // string, so such a bump fails loudly there. const cursor = '\x1b[7m \x1b[0m'; const contentLine = lines.findIndex((line) => line.includes(cursor)); if (contentLine === -1) return lines; diff --git a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts index 8b2644f6ce..34c62fb40c 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts @@ -288,7 +288,9 @@ describe('Host Runtime Resource coordinator', () => { sessionId: SESSION_ID, sourceTurnId: 'desktop-launch-1', sourceToolCallId: 'desktop-launch-1', - visibility: 'user', + // The interactive login shell carries no `command`, so it keeps its + // prior model-visible visibility (#3210). + visibility: undefined, cwd: '/workspace', pty: true, }, diff --git a/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts index 748c9d3932..cba5554117 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts @@ -22,6 +22,8 @@ import { describe, test } from 'node:test'; import { SHELL_RUN_SOURCE_TOOL_CALL_ID_MAX_BYTES } from '@maka/core/shell-run'; import { type ShellRunSnapshotResult, type ShellRunUpdate } from '@maka/core/events'; import { RuntimeHostProtocolError } from '../protocol/errors.js'; +import { requireExactRecord } from '../protocol/codec.js'; +import { RUNTIME_HOST_COMPATIBILITY_EPOCH } from '../protocol/index.js'; import { decodeSubscriptionFrame, SESSION_RUNTIME_RESOURCE_CHANGES_MAX, @@ -94,6 +96,26 @@ describe('Runtime Resource protocol', () => { } }); + test('the current epoch gates the widened runtime.resource.start input (#3210)', () => { + // The one-shot `command` field widens the start input at epoch 29. A + // pre-widening Host decodes it with exact keys and rejects `command` as + // unknown, so the epoch — not the decoder — is what keeps a pre-widening + // peer from being admitted and then failing on the first `!` command. + // Pinned relative so the next epoch advance does not silently pass. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH >= 29); + assertInvalid(() => + requireExactRecord( + { sessionId: 'session-1', launchId: 'user-command-1', command: 'pwd' }, + 'Runtime Resource start input', + ['sessionId', 'launchId'], + ), + ); + assert.deepEqual( + decodeRuntimeResourceStartInput({ sessionId: 'session-1', launchId: 'launch-1' }), + { sessionId: 'session-1', launchId: 'launch-1' }, + ); + }); + test('enforces cursor, sequence, PTY control, item, and encoded result bounds', () => { assertInvalid(() => decodeRuntimeResourceStartInput({ diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 19d5dd1f5b..ee90ebb69e 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,12 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 48 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 49 as const; +// 49: `runtime.resource.start` accepts an optional one-shot `command`, and the +// durable Shell Run record carries a `visibility` field. An epoch-48 Host +// decodes the start input with exact keys and rejects `command` as unknown; +// an epoch-48 binary rejects the widened record on read. Peers must agree on +// both before either is exercised. // 48: Session branch creation accepts an explicit Side Conversation intent. // Older peers reject the strict input shape or cannot apply its snapshot semantics. // 47: Project registration can carry an explicit location preference. Epoch-46 diff --git a/packages/runtime-host/src/server/runtime-resource-coordinator.ts b/packages/runtime-host/src/server/runtime-resource-coordinator.ts index 4e31d3bd69..9d5ef81d51 100644 --- a/packages/runtime-host/src/server/runtime-resource-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-resource-coordinator.ts @@ -389,7 +389,10 @@ export class HostRuntimeResourceCoordinator sessionId: input.sessionId, sourceTurnId: input.launchId, sourceToolCallId: input.launchId, - visibility: 'user', + // Only the one-shot `!` resources this Client owns are hidden + // from the model; the Desktop interactive login shell (no `command`) + // keeps its prior model-visible visibility (#3210). + ...(input.command === undefined ? {} : { visibility: 'user' as const }), cwd: header.cwd, command, env, From e2052a754e22eba1519926bd4f2395765874bfda Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 24 Aug 2026 10:41:57 +0800 Subject: [PATCH 09/14] fix(cli): reconcile user command cards with main's transcript authority Adapt the preserved-card feature to main's stored-message transcript builder: chronological interleaving keys off each rebuilt entry's source timestamp via an optional collector (no behavior change for other callers), card status uses the shared activity-status model, and main's born-suppressed poll lifecycle supersedes the retired hidden flag. Compatibility epoch advances to 45 for the widened start input. Generated-by: maka --- .../cli/src/__tests__/pi-transcript.test.ts | 10 +++- packages/cli/src/pi-transcript.ts | 57 +++++++++---------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 10b3da13f3..1b23374262 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -2565,9 +2565,15 @@ describe('Maka Pi TUI transcript', () => { assert.equal(applied, true); const tool = state.entries.find((entry) => entry.kind === 'tool'); assert.equal(tool?.toolName, 'User command'); - assert.equal(tool?.status, 'done'); + assert.equal(tool?.callStatus, 'completed'); assert.equal(tool?.expanded, true); - assert.match(tool?.output ?? '', /\/repo/); + const shellResult = tool?.result; + assert.equal( + shellResult?.kind === 'shell_run' && shellResult.mode === 'pipes' + ? shellResult.output?.stdout + : '', + '/repo\n', + ); assert.equal( state.entries.some((entry) => entry.kind === 'notice'), false, diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index dfff73490e..ee41b77696 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -180,13 +180,6 @@ export type MakaPiTranscriptEntry = suppressed?: boolean; /** Local-only Runtime Resource started by `!`, never a model tool call. */ userOwned?: boolean; - /** - * Set when a successful shell-run poll is folded into its parent while - * off-screen: the entry cannot be spliced (that would shift line numbers - * and clear scrollback), but it must not render as an independent card - * on a future full redraw. A hidden entry contributes zero lines. - */ - hidden?: boolean; } | { kind: 'notice'; level: 'info' | 'error'; text: string }; @@ -352,11 +345,13 @@ export function appendUserCommandToTranscript( title: 'User command', input: { command: input.command }, result: input.result, - output: formatToolResultContent(input.result), resultVersion: 1, progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), - status: shellRunTranscriptStatus(input.result.status), + callStatus: toolResultActivityStatus( + input.result.status === 'failed' || input.result.status === 'timed_out', + input.result, + ), expanded: true, userOwned: true, }); @@ -376,24 +371,26 @@ export function replaceTranscriptWithStoredMessages( state.entries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); } else { // Re-insert each preserved card at its chronological position: comparing - // the shell run's startedAt against item timestamps keeps a `!` command - // that ran before later model turns ahead of them, so a same-session - // reconnect does not reorder the transcript (#3210). - const view = materializeSession(messages); + // the shell run's startedAt against each rebuilt entry's source-message + // timestamp keeps a `!` command that ran before later model turns ahead of + // them, so a same-session reconnect does not reorder the transcript + // (#3210). + const timestamps: number[] = []; const cards = [...userCommands].sort( (a, b) => userCommandCardStartedAt(a) - userCommandCardStartedAt(b), ); const interleaved: MakaPiTranscriptEntry[] = []; let cardIndex = 0; - for (const item of view.items) { + const rebuilt = storedMessagesToTranscriptEntries(messages, timestamps); + for (let index = 0; index < rebuilt.length; index += 1) { while ( cardIndex < cards.length && - userCommandCardStartedAt(cards[cardIndex]!) <= chatItemTimestamp(item) + userCommandCardStartedAt(cards[cardIndex]!) <= (timestamps[index] ?? Number.POSITIVE_INFINITY) ) { interleaved.push(cards[cardIndex]!); cardIndex += 1; } - interleaved.push(...chatItemToTranscriptEntries(item)); + interleaved.push(rebuilt[index]!); } while (cardIndex < cards.length) { interleaved.push(cards[cardIndex]!); @@ -435,11 +432,6 @@ function userCommandCardStartedAt(entry: MakaPiToolEntry): number { return entry.result?.kind === 'shell_run' ? entry.result.startedAt : Number.POSITIVE_INFINITY; } -/** Chronological key for a materialized view item. */ -function chatItemTimestamp(item: ChatItem): number { - return item.kind === 'tool' ? item.item.ts : item.message.ts; -} - /** * Fill durable tool details that are intentionally absent from Runtime Host * live events without applying session-switch reset semantics. @@ -866,6 +858,7 @@ export function applyMakaSessionEventToTranscript( function storedMessagesToTranscriptEntries( messages: readonly StoredMessage[], + entryTimestamps?: number[], ): MakaPiTranscriptEntry[] { const entries: MakaPiTranscriptEntry[] = []; const resultsByToolUseId = new Map( @@ -881,6 +874,10 @@ function storedMessagesToTranscriptEntries( ); for (const message of messages) { + const record = (entry: MakaPiTranscriptEntry): MakaPiTranscriptEntry => { + entryTimestamps?.push(message.ts); + return entry; + }; switch (message.type) { case 'user': entries.push({ @@ -897,28 +894,30 @@ function storedMessagesToTranscriptEntries( // Stored thinking happened before the reply text, so it resumes above it. const thinking = message.thinking?.text; if (thinking?.trim()) { - entries.push({ + entries.push(record({ kind: 'thinking', messageId: message.id, text: thinking, expanded: false, - }); + })); } - entries.push({ kind: 'assistant', messageId: message.id, text: message.text }); + entries.push(record({ kind: 'assistant', messageId: message.id, text: message.text })); break; } case 'tool_call': entries.push( - storedToolToTranscriptEntry( - message, - resultsByToolUseId.get(message.id), - turnStatusById.get(message.turnId), + record( + storedToolToTranscriptEntry( + message, + resultsByToolUseId.get(message.id), + turnStatusById.get(message.turnId), + ), ), ); break; case 'system_note': { const entry = systemNoteToTranscriptEntry(message); - if (entry) entries.push(entry); + if (entry) entries.push(record(entry)); break; } case 'tool_result': From b5d18be545f1f0c20c687cfd13d42a068f81e559 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 24 Aug 2026 11:46:17 +0800 Subject: [PATCH 10/14] fix(cli,runtime-host): make /new abortable and one-shot starts self-cleaning Three review findings on #3210: - startNewSession is now awaited end-to-end: the driver runs the barrier-aware user-command stop before any identity change and rejects on failure, so /new aborts with nothing committed instead of stranding a running command without its card or Ctrl+C affordance. The runner surfaces the reason and leaves transcript/state intact; its control gate lets /new through while a user command runs (that stop is the command's first step), fenced by the existing generation guards. - The one-shot resource start performs header read, shell resolution, launch, and the initial snapshot inside one Session admission section, closing the relocate-vs-cwd gap; when the post-launch snapshot fails, the launched process is stopped so a client retry cannot double-execute. Generated-by: maka --- .../cli/src/__tests__/pi-tui-runner.test.ts | 84 +++++++++-- .../runtime-host-session-driver.test.ts | 50 ++++++- packages/cli/src/pi-tui-runner.ts | 39 ++++- .../cli/src/runtime-host-session-driver.ts | 12 +- packages/cli/src/runtime-host-tui-command.ts | 2 +- packages/cli/src/session-driver.ts | 7 +- .../runtime-resource-coordinator.test.ts | 23 ++- .../server/runtime-resource-coordinator.ts | 138 ++++++++++++------ 8 files changed, 273 insertions(+), 82 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index ed3d76b256..6931d7310f 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -322,6 +322,44 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('a rejected startNewSession aborts /new with identity and transcript intact (#3210 review)', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + let attempted = 0; + driver.startNewSession = async () => { + attempted += 1; + // Mirrors the real driver: the barrier-aware user-command stop rejected, + // so the identity swap must not commit. + throw new Error('host_draining'); + }; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + listShellRunUpdates: async () => [], + }); + + await waitForTuiPaint(terminal); + const transcriptBefore = plainTerminalOutput(terminal.screenOutput()); + terminal.input('/new'); + terminal.input('\r'); + await waitFor(() => attempted === 1); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('host_draining')); + + // Identity untouched… + assert.equal(driver.getSessionId(), 'session-1'); + // …and the transcript was not wiped by the aborted /new. + assert.match(plainTerminalOutput(terminal.screenOutput()), /host_draining/); + assert.ok(transcriptBefore.length > 0); + + exitMaka(terminal); + await run; + }); + test('a rejected user-command stop hands Ctrl-C back to the exit chord (#3210)', async () => { const terminal = new FakeTerminal(); const driver = new RejectingUserCommandStopDriver(); @@ -6616,7 +6654,9 @@ class RejectingStopDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } @@ -6716,7 +6756,9 @@ class SandboxBoundaryPromptDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } @@ -6778,7 +6820,9 @@ class UserQuestionPromptDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } @@ -6835,7 +6879,9 @@ class InterruptibleTurnDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } @@ -6972,7 +7018,9 @@ class SteeringTurnDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } @@ -7151,7 +7199,9 @@ class FallbackSteeringDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } @@ -7244,7 +7294,9 @@ class SlowStopDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } @@ -7315,7 +7367,9 @@ class ToolOutputDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } @@ -7687,7 +7741,7 @@ class SlashCommandDriver implements MakaSessionDriver { async rewindToTurn(_turnId: string): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void { + async startNewSession(): Promise { this.startNewSessionCalls += 1; this.sessionId = 'session-new'; this.activeBoundaryDisplayMode = undefined; @@ -8219,7 +8273,9 @@ class DeferredControlDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } @@ -8278,7 +8334,9 @@ class RejectingSandboxBoundaryDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } @@ -8362,7 +8420,9 @@ class SandboxBoundaryThenErrorDriver implements MakaSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string { return 'session-1'; } 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 fbf30e2ca1..93cad37ac3 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -190,7 +190,7 @@ describe('Runtime Host Maka Session driver', () => { assert.deepEqual(observations, ['active@1', 'paused@2', 'cleared@3']); // startNewSession drops the channel: goal reads null and listeners hear it. - driver.startNewSession(); + await driver.startNewSession(); assert.equal(driver.getGoal!(), null); assert.deepEqual(observations, ['active@1', 'paused@2', 'cleared@3', null]); @@ -699,7 +699,7 @@ describe('Runtime Host Maka Session driver', () => { } }); - test('stops a running user command before a fresh Session (#3210)', async () => { + test('awaits the user-command stop before clearing identity on /new (#3210)', async () => { const subscription = new FakeSubscription( continuitySnapshot({ rootTurn: null, @@ -708,7 +708,6 @@ describe('Runtime Host Maka Session driver', () => { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, }), @@ -725,11 +724,8 @@ describe('Runtime Host Maka Session driver', () => { const command = await driver.runUserCommand!('sleep 3600'); command.takeRacedUpdate(); - driver.startNewSession(); + await driver.startNewSession(); - await waitFor(() => - connection.requests.some((request) => request.operation === 'runtime.resource.stop'), - ); const stop = connection.requests.find( (request) => request.operation === 'runtime.resource.stop', ); @@ -740,6 +736,44 @@ describe('Runtime Host Maka Session driver', () => { assert.equal(driver.getSessionId(), null); }); + test('a rejected user-command stop aborts /new without clearing identity (#3210 review)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + connection.runtimeResourceStopFailure = new Error('host_draining'); + await assert.rejects(() => driver.startNewSession(), /host_draining/); + + // Nothing committed: the previous Session is still owned, so its card and + // Ctrl+C affordance remain live. + assert.equal(driver.getSessionId(), 'id-1'); + + // Once the Host recovers, /new proceeds normally. + connection.runtimeResourceStopFailure = undefined; + await driver.startNewSession(); + assert.equal(driver.getSessionId(), null); + }); + test('drops a per-session Full access elevation when a fresh Session starts (#3020)', async () => { // The TUI flow behind /new: session A is elevated to bypass, then the // driver is asked to start over. The next prompt lazily creates session B @@ -782,7 +816,7 @@ describe('Runtime Host Maka Session driver', () => { await driver.setPermissionMode('bypass'); assert.equal(driver.getPermissionMode?.(), 'bypass'); - driver.startNewSession(); + await driver.startNewSession(); assert.equal(driver.getPermissionMode?.(), 'ask'); // The fresh Session's boundary is managed again once it exists. diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 6f1cd89d32..f73a8dd8c4 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -689,10 +689,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Control commands (model/session/permission switches) mutate session state. // Run them through a single serial lock so a prompt submitted mid-switch can // not race the switch and land on the old session/model/permission mode. - const runControl = async (action: () => Promise): Promise => { + const runControl = async ( + action: () => Promise, + options: { readonly allowWhileBusy?: boolean } = {}, + ): Promise => { // Refuse nested control actions: an overlay onSelect bypasses editor.onSubmit, // so without this guard a switch could start while a prompt is still running. - if (busy) return; + // `/new` is the deliberate exception: stopping live user-owned commands IS + // its first step, so it must stay reachable while one runs (#3210 review). + if (busy && !options.allowWhileBusy) return; busy = true; const activity = beginActivity(); editor.disableSubmit = true; @@ -2402,8 +2407,24 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ); }; - const newSession = () => { - input.driver.startNewSession(); + const newSession = async (): Promise => { + try { + await input.driver.startNewSession(); + } catch (error) { + // The identity swap was aborted driver-side: the previous Session, its + // transcript, and every user-command card stay exactly as they were. + // Surface why instead of silently stranding the running commands. + state.entries.push({ + kind: 'notice', + level: 'error', + text: + error instanceof Error && error.message.length > 0 + ? error.message + : '无法开始新会话:停止本地命令失败,请稍后重试。', + }); + requestRender(); + return false; + } // A fresh session is not bound by the previous one's boundary. Falling back // to the *current* label would keep the previous Session's mode, including // Auto while a changed Host default creates with full access; the launch @@ -2420,6 +2441,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { replaceTranscript([]); shellRunElapsedTicker.sync(); requestRender(); + return true; }; // Import a foreign (Claude Code / Codex) session: read its digest, open a @@ -2439,7 +2461,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { try { const digest = await input.foreignSessions.readDigest(summary); if (closed) return; - newSession(); + if (!(await newSession())) return; void runAgentTurn({ kind: 'external', prompt: foreignSessionHandoffDisplayText(digest), @@ -3068,7 +3090,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { description: primaryGuidance.commands.new, midTurn: 'refuse', run: () => { - void runControl(async () => newSession()); + void runControl( + async () => { + await newSession(); + }, + { allowWhileBusy: true }, + ); }, }, skill: { diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 069707a13d..11841f6daa 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -703,14 +703,14 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { throw new Error(`Session kept changing while rewinding: ${sourceSessionId}`); } - startNewSession(): void { + async startNewSession(): Promise { // `/new` replaces the transcript without preserving user-command cards, // so a still-running command would lose both its projection and its - // Ctrl+C stop affordance. Stop tracked commands before the identity - // change; the generation/pending bump is synchronous, so an in-flight - // start self-stops when it resolves even though this method stays sync - // (#3210). - void this.stopUserCommands().catch(() => undefined); + // Ctrl+C stop affordance. Await the barrier-aware stop path before any + // identity change: if a stop rejects, `/new` aborts with nothing + // committed rather than stranding a running command without its card or + // stop affordance (#3210 review). + await this.stopUserCommands(); this.#sessionGeneration += 1; this.#channelGeneration += 1; this.#sessionId = null; diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index 52293a35db..fe2cf0668d 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -199,7 +199,7 @@ function createFirstRunSessionDriver(): MakaSessionDriver { switchSession: unavailable, listRewindTargets: async () => [], rewindToTurn: unavailable, - startNewSession: () => {}, + startNewSession: () => Promise.resolve(), stop: async () => {}, }; } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 233f3c8241..da6a917181 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -143,7 +143,12 @@ export interface MakaSessionDriver { reason: MakaTranscriptReplacementReason, ) => void, ): () => void; - startNewSession(): void; + /** + * Prepares a fresh Session: stops every live user-owned command first so + * their cards and Ctrl+C affordance never outlive the identity swap. + * Rejects without changing Session identity when a stop fails (#3210). + */ + startNewSession(): Promise; stop(): Promise; getSessionId(): string | null; /** diff --git a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts index 34c62fb40c..d307f9ceb7 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts @@ -319,6 +319,23 @@ describe('Host Runtime Resource coordinator', () => { harness.finishBackground({ successful: true }); }); + test('stops a launched one-shot command when the initial inspection fails', async () => { + // The command is live once runBackgroundBash returns; if the post-launch + // snapshot then fails, the operation must report failure AND stop the + // process, so a client retry cannot double-execute (#3210 review). + const harness = createHarness(); + harness.inspectFailure = new Error('snapshot encode failed'); + const started = await harness.coordinator.handlers['runtime.resource.start']( + { sessionId: SESSION_ID, launchId: 'user-command-1', command: 'sleep 3600' }, + connection('connection-1'), + ); + + assert.equal(started.ok, false); + assert.ok(harness.lastBackgroundInput); + assert.equal(harness.stopCount, 1); + harness.finishBackground({ successful: false }); + }); + test('starts the legacy WSL shim with a Linux-visible login shell', async () => { const shell = { kind: 'legacy-wsl-bash' as const, @@ -608,6 +625,7 @@ function createHarness(options: Pick structuredClone(lastStartedSnapshot ?? currentSnapshot), + inspectResource: async () => { + if (state.inspectFailure) throw state.inspectFailure; + return structuredClone(lastStartedSnapshot ?? currentSnapshot); + }, getLivePtySnapshot: (sessionId, ref) => ({ sessionId, ref, diff --git a/packages/runtime-host/src/server/runtime-resource-coordinator.ts b/packages/runtime-host/src/server/runtime-resource-coordinator.ts index 9d5ef81d51..1845cebe61 100644 --- a/packages/runtime-host/src/server/runtime-resource-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-resource-coordinator.ts @@ -360,54 +360,98 @@ export class HostRuntimeResourceCoordinator const unavailable = await this.#mutableSessionFailure(input.sessionId); if (unavailable) return mutationFailure('runtime.resource.start', unavailable); try { - const header = await this.#sessionHeaders.readHeader(input.sessionId); - const shell = await this.#resolveShell(); - const env = { ...process.env }; - let command = input.command; - if (command === undefined && shell.kind === 'git-bash') { - env.SHELL = shell.exe; - env.CHERE_INVOKING = '1'; - env.DISABLE_AUTO_UPDATE = 'true'; - env.DISABLE_UPDATE_PROMPT = 'true'; - command = 'exec "$SHELL" -l'; - } else if (command === undefined && shell.kind === 'legacy-wsl-bash') { - env.DISABLE_AUTO_UPDATE = 'true'; - env.DISABLE_UPDATE_PROMPT = 'true'; - command = 'exec bash -l'; - } else if (command === undefined && shell.kind === 'posix') { - env.SHELL ||= userInfo().shell || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh'); - env.DISABLE_AUTO_UPDATE = 'true'; - env.DISABLE_UPDATE_PROMPT = 'true'; - command = 'exec "$SHELL" -l'; - } else if (command === undefined && shell.kind === 'cmd') { - command = '%ComSpec% /d /q'; - } else if (command === undefined) { - const executable = (shell.exe ?? shell.displayName).replace(/'/g, "''"); - command = `& '${executable}' -NoLogo`; - } - const launched = await this.runBackgroundBash({ - sessionId: input.sessionId, - sourceTurnId: input.launchId, - sourceToolCallId: input.launchId, - // Only the one-shot `!` resources this Client owns are hidden - // from the model; the Desktop interactive login shell (no `command`) - // keeps its prior model-visible visibility (#3210). - ...(input.command === undefined ? {} : { visibility: 'user' as const }), - cwd: header.cwd, - command, - env, - pty: input.command === undefined, - emitOutput: () => undefined, - shell, + // Header read, shell resolution, launch, and the initial snapshot all + // share ONE admission section: a concurrent `session.workspace.relocate` + // can otherwise commit between reading `header.cwd` and the admitted + // launch, admitting the command with a stale cwd (#3210 review). + return await this.#sessionAdmission.run(input.sessionId, async () => { + if (this.#draining) throw new Error('Runtime resources are draining'); + const header = await this.#sessionHeaders.readHeader(input.sessionId); + if (this.#draining) throw new Error('Runtime resources are draining'); + const shell = await this.#resolveShell(); + if (this.#draining) throw new Error('Runtime resources are draining'); + const env = { ...process.env }; + let command: string; + if (input.command !== undefined) { + command = input.command; + } else if (shell.kind === 'git-bash') { + env.SHELL = shell.exe; + env.CHERE_INVOKING = '1'; + env.DISABLE_AUTO_UPDATE = 'true'; + env.DISABLE_UPDATE_PROMPT = 'true'; + command = 'exec "$SHELL" -l'; + } else if (shell.kind === 'legacy-wsl-bash') { + env.DISABLE_AUTO_UPDATE = 'true'; + env.DISABLE_UPDATE_PROMPT = 'true'; + command = 'exec bash -l'; + } else if (shell.kind === 'posix') { + env.SHELL ||= userInfo().shell || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh'); + env.DISABLE_AUTO_UPDATE = 'true'; + env.DISABLE_UPDATE_PROMPT = 'true'; + command = 'exec "$SHELL" -l'; + } else if (shell.kind === 'cmd') { + command = '%ComSpec% /d /q'; + } else { + const executable = (shell.exe ?? shell.displayName).replace(/'/g, "''"); + command = `& '${executable}' -NoLogo`; + } + const residency = this.#acquireResidency(); + let completed = false; + const complete = (): void => { + if (completed) return; + completed = true; + residency.release(); + }; + let launched: Awaited>; + try { + launched = await this.#manager.runBackgroundBash({ + sessionId: input.sessionId, + sourceTurnId: input.launchId, + sourceToolCallId: input.launchId, + // Only the one-shot `!` resources this Client owns are + // hidden from the model; the Desktop interactive login shell (no + // `command`) keeps its prior model-visible visibility (#3210). + ...(input.command === undefined ? {} : { visibility: 'user' as const }), + cwd: header.cwd, + command, + env, + pty: input.command === undefined, + emitOutput: () => undefined, + shell, + onCompletion: complete, + }); + } catch (launchError) { + complete(); + throw launchError; + } + try { + return { + ok: true as const, + result: decodeRuntimeResourceStartResult({ + resource: boundedRuntimeResourceSnapshot( + await this.#manager.inspectResource(input.sessionId, launched.ref), + ), + }), + }; + } catch (inspectError) { + // The command is already live but the operation must not report a + // success it cannot honor: stop it so a client retry cannot + // double-execute (#3210 review). Best-effort — the surfaced error + // stays the inspection failure. + try { + await this.#manager.stopBackgroundTask( + input.sessionId, + launched.ref, + new AbortController().signal, + 'client', + ); + } catch { + /* keep the inspection failure as the surfaced cause */ + } + throw inspectError; + } }); - return { - ok: true, - result: decodeRuntimeResourceStartResult({ - resource: boundedRuntimeResourceSnapshot( - await this.#manager.inspectResource(input.sessionId, launched.ref), - ), - }), - }; + } catch (error) { if (error instanceof ShellPreferenceError) { return mutationFailure('runtime.resource.start', { From 41188178cf441215794561f176c6318b6cd02203 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 15:24:57 +0800 Subject: [PATCH 11/14] style: format pi-transcript Generated-by: maka --- packages/cli/src/pi-transcript.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index ee41b77696..9d2f116955 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -385,7 +385,8 @@ export function replaceTranscriptWithStoredMessages( for (let index = 0; index < rebuilt.length; index += 1) { while ( cardIndex < cards.length && - userCommandCardStartedAt(cards[cardIndex]!) <= (timestamps[index] ?? Number.POSITIVE_INFINITY) + userCommandCardStartedAt(cards[cardIndex]!) <= + (timestamps[index] ?? Number.POSITIVE_INFINITY) ) { interleaved.push(cards[cardIndex]!); cardIndex += 1; @@ -894,12 +895,14 @@ function storedMessagesToTranscriptEntries( // Stored thinking happened before the reply text, so it resumes above it. const thinking = message.thinking?.text; if (thinking?.trim()) { - entries.push(record({ - kind: 'thinking', - messageId: message.id, - text: thinking, - expanded: false, - })); + entries.push( + record({ + kind: 'thinking', + messageId: message.id, + text: thinking, + expanded: false, + }), + ); } entries.push(record({ kind: 'assistant', messageId: message.id, text: message.text })); break; From a176d13592460d682f2791e05a1ad912b7542818 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 15:50:29 +0800 Subject: [PATCH 12/14] style: fix formatting Generated-by: maka --- packages/cli/src/pi-tui-runner.ts | 1 - .../runtime-host/src/server/runtime-resource-coordinator.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index f73a8dd8c4..3ba7b0d858 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -3816,4 +3816,3 @@ function parseUserCommand(prompt: string): string | undefined { if (!trimmed.startsWith('!')) return undefined; return trimmed.slice(1).trim(); } - diff --git a/packages/runtime-host/src/server/runtime-resource-coordinator.ts b/packages/runtime-host/src/server/runtime-resource-coordinator.ts index 1845cebe61..c2dd05d170 100644 --- a/packages/runtime-host/src/server/runtime-resource-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-resource-coordinator.ts @@ -385,7 +385,8 @@ export class HostRuntimeResourceCoordinator env.DISABLE_UPDATE_PROMPT = 'true'; command = 'exec bash -l'; } else if (shell.kind === 'posix') { - env.SHELL ||= userInfo().shell || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh'); + env.SHELL ||= + userInfo().shell || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh'); env.DISABLE_AUTO_UPDATE = 'true'; env.DISABLE_UPDATE_PROMPT = 'true'; command = 'exec "$SHELL" -l'; @@ -451,7 +452,6 @@ export class HostRuntimeResourceCoordinator throw inspectError; } }); - } catch (error) { if (error instanceof ShellPreferenceError) { return mutationFailure('runtime.resource.start', { From 2e5d9449fa679e0e99a866e700a0e3324226bd33 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 15:56:41 +0800 Subject: [PATCH 13/14] fix(cli): drop retired lastUsedAt from driver tests Generated-by: maka --- .../cli/src/__tests__/runtime-host-session-driver.test.ts | 7 ------- 1 file changed, 7 deletions(-) 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 93cad37ac3..d5150cdc99 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -381,7 +381,6 @@ describe('Runtime Host Maka Session driver', () => { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, }), @@ -423,7 +422,6 @@ describe('Runtime Host Maka Session driver', () => { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, }), @@ -496,7 +494,6 @@ describe('Runtime Host Maka Session driver', () => { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, }), @@ -533,7 +530,6 @@ describe('Runtime Host Maka Session driver', () => { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, }), @@ -575,7 +571,6 @@ describe('Runtime Host Maka Session driver', () => { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, }), @@ -610,7 +605,6 @@ describe('Runtime Host Maka Session driver', () => { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, }), @@ -658,7 +652,6 @@ describe('Runtime Host Maka Session driver', () => { metadataRevision: 1, status: 'running', createdAt: 1, - lastUsedAt: 1, isArchived: false, }, }), From f6139efc15ce735e8878e2313d704db7060b8153 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 16:49:13 +0800 Subject: [PATCH 14/14] chore: retrigger CI Generated-by: maka