diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b57e43775c..1b23374262 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,155 @@ 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?.callStatus, 'completed'); + assert.equal(tool?.expanded, true); + 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, + ); + }); + + 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, { + 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('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 84d559537f..6931d7310f 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'; @@ -232,6 +233,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 +245,216 @@ 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('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 命令')); + assert.deepEqual(driver.commands, []); + assert.deepEqual(driver.prompts, []); + + terminal.input('p'); + await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('输入 shell 命令')); + + 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(); + 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('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(); + 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(); + 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( @@ -6442,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'; } @@ -6542,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'; } @@ -6604,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'; } @@ -6661,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'; } @@ -6798,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'; } @@ -6977,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'; } @@ -7070,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'; } @@ -7141,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'; } @@ -7513,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; @@ -7529,6 +7757,94 @@ 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 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 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(); @@ -7957,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'; } @@ -8016,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'; } @@ -8100,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 a6fb92e2db..6aa49d36a4 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, @@ -189,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]); @@ -371,6 +372,401 @@ 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, + 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, + 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('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, + 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, + 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('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, + 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({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 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('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, + 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('awaits the user-command stop before clearing identity on /new (#3210)', 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(); + + await driver.startNewSession(); + + 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('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 @@ -413,7 +809,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. @@ -1589,12 +1985,35 @@ 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; + /** 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). */ 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( @@ -1666,6 +2085,31 @@ class FakeConnection { }), } as OperationOutput; } + if (operation === 'runtime.resource.start') { + await this.onRuntimeResourceStart?.(); + return { resource: this.userCommandResource } as OperationOutput; + } + if (operation === 'runtime.resource.stop') { + if (this.runtimeResourceStopFailure) throw this.runtimeResourceStopFailure; + 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'); + } + 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 35de5bbd80..9d2f116955 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,8 @@ 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; } | { kind: 'notice'; level: 'info' | 'error'; text: string }; @@ -290,12 +293,18 @@ 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 ( !tool || - tool.toolName !== 'Bash' || + !isShellRunToolCard(tool) || tool.result?.kind !== 'shell_run' || tool.result.ref !== update.result.ref || tool.result.revision !== update.result.revision || @@ -319,16 +328,79 @@ 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, + resultVersion: 1, + progress: createProgressBuffer(), + outputDeltas: createOutputBuffer(), + callStatus: toolResultActivityStatus( + input.result.status === 'failed' || input.result.status === 'timed_out', + input.result, + ), + expanded: true, + userOwned: true, + }); +} + 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, + ) + : []; + 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 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; + const rebuilt = storedMessagesToTranscriptEntries(messages, timestamps); + for (let index = 0; index < rebuilt.length; index += 1) { + while ( + cardIndex < cards.length && + userCommandCardStartedAt(cards[cardIndex]!) <= + (timestamps[index] ?? Number.POSITIVE_INFINITY) + ) { + interleaved.push(cards[cardIndex]!); + cardIndex += 1; + } + interleaved.push(rebuilt[index]!); + } + 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; @@ -350,6 +422,17 @@ export function replaceTranscriptWithStoredMessages( } } +export function hasRunningUserCommand(state: MakaPiTranscriptState): boolean { + return state.entries.some( + (entry) => entry.kind === 'tool' && entry.userOwned === true && isLiveShellRunCard(entry), + ); +} + +/** 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; +} + /** * Fill durable tool details that are intentionally absent from Runtime Host * live events without applying session-switch reset semantics. @@ -446,7 +529,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; @@ -776,6 +859,7 @@ export function applyMakaSessionEventToTranscript( function storedMessagesToTranscriptEntries( messages: readonly StoredMessage[], + entryTimestamps?: number[], ): MakaPiTranscriptEntry[] { const entries: MakaPiTranscriptEntry[] = []; const resultsByToolUseId = new Map( @@ -791,6 +875,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({ @@ -807,28 +895,32 @@ function storedMessagesToTranscriptEntries( // Stored thinking happened before the reply text, so it resumes above it. const thinking = message.thinking?.text; if (thinking?.trim()) { - entries.push({ - kind: 'thinking', - messageId: message.id, - text: thinking, - expanded: false, - }); + 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': @@ -1610,6 +1702,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..3ba7b0d858 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -87,9 +87,12 @@ import { } from './session-driver.js'; import { appendTurnFailureToTranscript, + appendUserCommandToTranscript, appendUserPrompt, applyMakaSessionEventToTranscript, + applyShellRunUpdateToTranscript, createMakaPiTranscriptState, + hasRunningUserCommand, activeSandboxBoundaryRequest, activeUserQuestionRequest, completePendingInteraction, @@ -281,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; @@ -368,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 @@ -375,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 }; @@ -468,6 +485,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( @@ -535,7 +553,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; @@ -671,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; @@ -866,6 +889,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 @@ -1111,6 +1148,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { handleSlashCommand(prompt, 0); return; } + 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); if (swarmCommand) { editor.addToHistory(prompt); @@ -1532,6 +1579,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). @@ -2347,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 @@ -2365,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 @@ -2384,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), @@ -2408,15 +2485,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', @@ -3012,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: { @@ -3388,6 +3471,24 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { else requestTurnInterrupt(); return { consume: true }; } + if ( + !turnRunning && + matchesKey(data, Key.ctrl('c')) && + !userCommandStopRejected && + hasRunningUserCommand(state) && + input.driver.stopUserCommands + ) { + lastIdleCtrlCAt = 0; + 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 // boundary branch so Escape keeps meaning "deny" while a prompt is // pending, and it only arms while a prompt turn is actually running. @@ -3709,6 +3810,9 @@ function isExitPrompt(prompt: string): boolean { return trimmed === 'quit' || trimmed === 'exit' || trimmed === '/quit' || trimmed === '/exit'; } -// 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; +/** 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(); +} diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 2eeebf0e8c..5046ed7034 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 { isActiveShellRunStatus } from '@maka/core/shell-run'; import { executionBoundaryDisplayMode } from '@maka/core/sandbox-boundary'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -184,6 +187,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 >(); @@ -309,6 +320,92 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { } } + async runUserCommand(command: string): Promise<{ + commandId: string; + result: ShellRunSnapshotResult; + takeRacedUpdate(): ShellRunUpdate['result'] | undefined; + }> { + 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, + command, + }); + 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, + 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) { + 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); @@ -489,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); @@ -598,7 +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. 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; @@ -655,12 +767,28 @@ 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, - }); + // 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', { + 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 { @@ -1058,7 +1186,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); } @@ -1121,12 +1249,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/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 42e179efab..da6a917181 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,10 @@ 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; + /** Stops every live user-owned command started by this driver. */ + stopUserCommands?(): Promise; compactSession(): AsyncIterable; resumeLatest?(): AsyncIterable; steer?(text: string): Promise; @@ -127,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/cli/src/skill-highlight-editor.ts b/packages/cli/src/skill-highlight-editor.ts index e27f6dfafe..058b2981d5 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,34 @@ 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; + + // 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; + + 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 28c1621a99..dead56997c 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -34,9 +34,13 @@ export interface TuiPrimaryGuidanceCopy { readonly commands: Readonly>; readonly help: { readonly commandsHeading: string; + readonly userCommand: string; readonly keybindingsHeading: string; readonly keybindings: readonly string[]; }; + readonly editor: { + readonly userCommandHint: string; + }; } const TUI_PRIMARY_GUIDANCE = { @@ -72,6 +76,7 @@ const TUI_PRIMARY_GUIDANCE = { }, help: { commandsHeading: '命令', + userCommand: ' ! — 执行一次仅用户可见的 shell 命令', keybindingsHeading: '快捷键', keybindings: [ ' Ctrl+O — 展开或折叠所有工具输出', @@ -86,6 +91,9 @@ const TUI_PRIMARY_GUIDANCE = { ' Ctrl+D — 输入为空时退出', ], }, + editor: { + userCommandHint: '输入 shell 命令', + }, }, en: { welcome: { @@ -119,6 +127,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', @@ -133,6 +142,9 @@ const TUI_PRIMARY_GUIDANCE = { ' Ctrl+D — exit when input is empty', ], }, + editor: { + userCommandHint: 'type a shell command', + }, }, } satisfies UiCatalog; 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..d307f9ceb7 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,9 @@ describe('Host Runtime Resource coordinator', () => { sessionId: SESSION_ID, sourceTurnId: 'desktop-launch-1', sourceToolCallId: 'desktop-launch-1', + // The interactive login shell carries no `command`, so it keeps its + // prior model-visible visibility (#3210). + visibility: undefined, cwd: '/workspace', pty: true, }, @@ -315,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, @@ -420,6 +441,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 +614,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', @@ -571,6 +625,7 @@ function createHarness(options: Pick currentSnapshot, @@ -638,7 +695,10 @@ function createHarness(options: Pick structuredClone(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/__tests__/runtime-resource-protocol.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts index 9bcce899c0..0b8a1a06b8 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, @@ -30,8 +32,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 +48,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', @@ -70,7 +96,34 @@ 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 > 49); + 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({ + 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/index.ts b/packages/runtime-host/src/protocol/index.ts index a42993bfec..cabe08879a 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,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 = 49 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 50 as const; +// 50: `runtime.resource.start` accepts an optional one-shot `command`, and the +// durable Shell Run record carries a `visibility` field. An epoch-49 Host +// decodes the start input with exact keys and rejects `command` as unknown; +// an epoch-49 binary rejects the widened record on read. Peers must agree on +// both before either is exercised. // 49: WorkHub resolves one durable Coordination Session per Runtime Host. // Older peers do not know the operation or the hidden Session role. // 48: Session branch creation accepts an explicit Side Conversation intent. 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..c2dd05d170 100644 --- a/packages/runtime-host/src/server/runtime-resource-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-resource-coordinator.ts @@ -360,50 +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: string; - 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 launched = await this.runBackgroundBash({ - sessionId: input.sessionId, - sourceTurnId: input.launchId, - sourceToolCallId: input.launchId, - cwd: header.cwd, - command, - env, - pty: true, - 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', { @@ -539,6 +587,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 +677,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,