From 3277eb1259c2aa7745f9c6136acf0f4a5fe176c1 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 28 Aug 2026 12:30:02 +0800 Subject: [PATCH] feat(cli): add Runtime Host-backed user commands Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 152 +++++- .../cli/src/__tests__/pi-tui-runner.test.ts | 490 +++++++++++++++++- .../runtime-host-session-driver.test.ts | 448 +++++++++++++++- packages/cli/src/pi-transcript.ts | 52 +- packages/cli/src/pi-tui-runner.ts | 121 ++++- .../cli/src/runtime-host-session-driver.ts | 176 ++++++- packages/cli/src/runtime-host-tui-command.ts | 2 +- packages/cli/src/session-driver.ts | 20 +- packages/cli/src/tui-primary-guidance.ts | 3 + packages/core/src/shell-run.ts | 14 + .../runtime-resource-coordinator.test.ts | 89 +++- .../runtime-resource-protocol.test.ts | 51 ++ packages/runtime-host/src/protocol/index.ts | 6 +- .../src/protocol/runtime-resource.ts | 26 +- .../server/runtime-resource-coordinator.ts | 140 +++-- .../src/__tests__/shell-run-manager.test.ts | 74 +++ packages/runtime/src/shell-run-contract.ts | 5 + packages/runtime/src/shell-run-manager.ts | 27 +- 18 files changed, 1813 insertions(+), 83 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b165c1cfb3..bb7f0c2ffe 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, @@ -2956,6 +2957,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, [], { preserveClientLocalEntries: 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', + }, + ], + { preserveClientLocalEntries: 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 a3b5239894..77b63f1467 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -53,6 +53,7 @@ import type { MakaSessionSwitchOptions, MakaSessionSwitchResult, MakaSubmitMessageOptions, + MakaTranscriptReplacementReason, RewindTarget, SessionResumeAvailability, } from '../session-driver.js'; @@ -233,6 +234,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); @@ -244,6 +246,268 @@ 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 second leading bang remains part of the shell command', 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')); + assert.deepEqual(driver.prompts, []); + + exitMaka(terminal); + await run; + }); + + test('a submitted bare ! shows usage 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('!'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Usage: !')); + assert.deepEqual(driver.commands, []); + assert.deepEqual(driver.prompts, []); + + 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('a new user command re-arms Ctrl-C after an earlier stop rejection', async () => { + const terminal = new FakeTerminal(); + const driver = new RejectingUserCommandStopDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('!first'); + terminal.input('\r'); + await waitFor(() => driver.commands.length === 1); + terminal.input('\x03'); + await waitFor(() => driver.stopUserCommandCalls === 1); + + terminal.input('!second'); + terminal.input('\r'); + await waitFor(() => driver.commands.length === 2); + terminal.input('\x03'); + await waitFor(() => driver.stopUserCommandCalls === 2); + assert.equal(terminal.stopCalls, 0); + + exitMaka(terminal); + await run; + }); + + test('same-session reconnect keeps a user-command card for its terminal update', async () => { + const terminal = new FakeTerminal(); + const driver = new RunningUserCommandDriver(); + let publishShellRun: ((update: ShellRunUpdate) => void) | undefined; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + subscribeShellRunUpdates: (listener) => { + publishShellRun = listener; + return () => { + publishShellRun = undefined; + }; + }, + }); + + await waitForTuiPaint(terminal); + terminal.input('!printf done'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('User command')); + + driver.publishReconnect(); + publishShellRun?.({ + sessionId: 'session-1', + ownership: { kind: 'local' }, + sourceTurnId: 'user-command-1', + sourceToolCallId: 'user-command-1', + result: { + kind: 'shell_run', + ref: 'maka://runtime/background-tasks/user-command-1', + mode: 'pipes', + status: 'completed', + cwd: '/repo', + cmd: 'printf done', + startedAt: 1, + updatedAt: 2, + completedAt: 2, + exitCode: 0, + revision: 2, + output: pipeOutput('done\n'), + }, + }); + + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('done')); + assert.match(plainTerminalOutput(terminal.screenOutput()), /User command/); + + exitMaka(terminal); + await run; + }); + test('disables taskbar progress on Windows and Windows Terminal by default', () => { assert.equal(resolveTaskbarProgress(undefined, { platform: 'win32' }), false); assert.equal( @@ -6748,7 +7012,9 @@ abstract class FakeSessionDriver implements MakaSessionDriver { throw new Error('rewind not supported in this fake'); } - startNewSession(): void {} + startNewSession(): Promise { + return Promise.resolve(); + } getSessionId(): string | null { return this.sessionId; @@ -6864,6 +7130,26 @@ class SandboxBoundaryPromptDriver extends FakeSessionDriver { this.boundaryResponseWaiter = null; waiter?.(); } + async renameSession(): Promise {} + async setModel(): Promise {} + async setPermissionMode(): Promise {} + async setThinkingLevel(): Promise {} + async switchSession(sessionId: string): Promise { + return switchResult(fakeSessionSummary(sessionId)); + } + + async listRewindTargets(): Promise { + return []; + } + async rewindToTurn(): Promise { + throw new Error('rewind not supported in this fake'); + } + startNewSession(): Promise { + return Promise.resolve(); + } + getSessionId(): string { + return 'session-1'; + } } class UserQuestionPromptDriver extends FakeSessionDriver { @@ -6907,6 +7193,12 @@ class UserQuestionPromptDriver extends FakeSessionDriver { async rewindToTurn(): Promise { throw new Error('rewind not supported'); } + startNewSession(): Promise { + return Promise.resolve(); + } + getSessionId(): string { + return 'session-1'; + } } class InterruptibleTurnDriver extends FakeSessionDriver { @@ -6942,6 +7234,28 @@ class InterruptibleTurnDriver extends FakeSessionDriver { this.releaseTurn?.(); this.releaseTurn = null; } + + async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} + async renameSession(): Promise {} + async setModel(): Promise {} + async setPermissionMode(): Promise {} + async setThinkingLevel(): Promise {} + async switchSession(sessionId: string): Promise { + return switchResult(fakeSessionSummary(sessionId)); + } + + async listRewindTargets(): Promise { + return []; + } + async rewindToTurn(): Promise { + throw new Error('rewind not supported in this fake'); + } + startNewSession(): Promise { + return Promise.resolve(); + } + getSessionId(): string { + return 'session-1'; + } } // A parking turn plus an in-memory steering/followup mirror, so the runner's @@ -7175,6 +7489,28 @@ class ToolOutputDriver extends FakeSessionDriver { stopReason: 'end_turn', }; } + + async stop(): Promise {} + async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} + async renameSession(): Promise {} + async setModel(): Promise {} + async setPermissionMode(): Promise {} + async setThinkingLevel(): Promise {} + async switchSession(sessionId: string): Promise { + return switchResult(fakeSessionSummary(sessionId)); + } + async listRewindTargets(): Promise { + return []; + } + async rewindToTurn(): Promise { + throw new Error('rewind not supported in this fake'); + } + startNewSession(): Promise { + return Promise.resolve(); + } + getSessionId(): string { + return 'session-1'; + } } class BackgroundShellRunDriver extends ToolOutputDriver { @@ -7544,7 +7880,7 @@ class SlashCommandDriver extends FakeSessionDriver { 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; @@ -7560,6 +7896,94 @@ class SlashCommandDriver extends FakeSessionDriver { } } +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; + if (this.stopUserCommandCalls === 1) throw new Error('host_draining'); + } +} + class HostSkillDriver extends SlashCommandDriver { constructor(private readonly skillInvocation: SkillInvocationResult) { super(); @@ -8107,6 +8531,26 @@ class DeferredControlDriver extends FakeSessionDriver { this.resolveSetModel?.(); this.resolveSetModel = null; } + + async renameSession(): Promise {} + async setPermissionMode(): Promise {} + async setThinkingLevel(): Promise {} + async switchSession(sessionId: string): Promise { + return switchResult(fakeSessionSummary(sessionId)); + } + + async listRewindTargets(): Promise { + return []; + } + async rewindToTurn(): Promise { + throw new Error('rewind not supported in this fake'); + } + startNewSession(): Promise { + return Promise.resolve(); + } + getSessionId(): string { + return 'session-1'; + } } class RejectingSandboxBoundaryDriver extends FakeSessionDriver { @@ -8139,6 +8583,27 @@ class RejectingSandboxBoundaryDriver extends FakeSessionDriver { this.responses.push(response); throw new Error('sandbox boundary response rejected'); } + + async renameSession(): Promise {} + async setModel(): Promise {} + async setPermissionMode(): Promise {} + async setThinkingLevel(): Promise {} + async switchSession(sessionId: string): Promise { + return switchResult(fakeSessionSummary(sessionId)); + } + + async listRewindTargets(): Promise { + return []; + } + async rewindToTurn(): Promise { + throw new Error('rewind not supported in this fake'); + } + startNewSession(): Promise { + return Promise.resolve(); + } + getSessionId(): string { + return 'session-1'; + } } class DeferredListSessionsDriver extends SlashCommandDriver { @@ -8196,6 +8661,27 @@ class SandboxBoundaryThenErrorDriver extends FakeSessionDriver { async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise { this.respondCalls += 1; } + + async renameSession(): Promise {} + async setModel(): Promise {} + async setPermissionMode(): Promise {} + async setThinkingLevel(): Promise {} + async switchSession(sessionId: string): Promise { + return switchResult(fakeSessionSummary(sessionId)); + } + + async listRewindTargets(): Promise { + return []; + } + async rewindToTurn(): Promise { + throw new Error('rewind not supported in this fake'); + } + startNewSession(): Promise { + return Promise.resolve(); + } + getSessionId(): string { + return 'session-1'; + } } class RewindDriver extends SlashCommandDriver { 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 9bd706bd3f..64db3be16c 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, @@ -196,7 +197,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]); @@ -378,6 +379,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 @@ -420,7 +816,7 @@ describe('Runtime Host Maka Session driver', () => { await driver.setPermissionMode('bypass'); assert.equal(driver.getPermissionMode?.(), 'bypass'); - driver.startNewSession(); + await driver.startNewSession(); assert.equal(driver.getPermissionMode?.(), 'ask'); // The fresh Session's boundary is managed again once it exists. @@ -2010,8 +2406,12 @@ 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). */ @@ -2023,6 +2423,25 @@ class FakeConnection { * with a second call while the first is still in flight. */ readonly heldOperations = new Map>(); + 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( @@ -2115,6 +2534,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 bf71bd2af9..b7bacffed1 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'; @@ -171,6 +172,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 }; @@ -316,12 +319,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 || @@ -345,11 +354,35 @@ 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[], @@ -364,6 +397,7 @@ export function replaceTranscriptWithStoredMessages( // that dropped them would erase what the client just told the user. const isClientLocal = (entry: MakaPiTranscriptEntry): boolean => entry.kind === 'notice' || + (entry.kind === 'tool' && entry.userOwned === true) || (entry.kind === 'user' && entry.transient === true && !durableMessageIds.has(entry.messageId)); // A preserved entry keeps its place relative to the durable entry it // followed. With no durable entry ahead of it it stays at the head, unless @@ -428,6 +462,12 @@ function transcriptEntryId(entry: MakaPiTranscriptEntry): string | undefined { } } +export function hasRunningUserCommand(state: MakaPiTranscriptState): boolean { + return state.entries.some( + (entry) => entry.kind === 'tool' && entry.userOwned === true && isLiveShellRunCard(entry), + ); +} + /** * Fill durable tool details that are intentionally absent from Runtime Host * live events without applying session-switch reset semantics. @@ -524,7 +564,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; @@ -1843,6 +1883,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 51f2ff0630..cef63ff090 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -89,9 +89,12 @@ import { } from './session-driver.js'; import { appendTurnFailureToTranscript, + appendUserCommandToTranscript, appendUserPrompt, applyMakaSessionEventToTranscript, + applyShellRunUpdateToTranscript, createMakaPiTranscriptState, + hasRunningUserCommand, activeSandboxBoundaryRequest, activeUserQuestionRequest, completePendingInteraction, @@ -395,6 +398,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 }; @@ -719,10 +728,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; @@ -922,6 +936,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 @@ -1082,6 +1110,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); @@ -1435,6 +1473,22 @@ 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 }); + // A previous stop failure only disarms capture for the stranded command. + // A newly admitted command gets a fresh Ctrl+C stop attempt. + userCommandStopRejected = false; + 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). @@ -2427,8 +2481,24 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ); }; - const newSession = async (): Promise => { - 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 @@ -2446,6 +2516,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { shellRunElapsedTicker.sync(); await discardCurrentSidePair(); requestRender(); + return true; }; // Import a foreign (Claude Code / Codex) session: read its digest, open a @@ -2465,7 +2536,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { try { const digest = await input.foreignSessions.readDigest(summary); if (closed) return; - await newSession(); + if (!(await newSession())) return; submitMessage(foreignSessionHandoffDisplayText(digest), 'current_turn', { modelText: buildForeignSessionHandoffMessage(digest), }); @@ -2486,15 +2557,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', @@ -3116,7 +3188,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: { @@ -3516,6 +3593,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. @@ -3845,3 +3940,9 @@ function matchesSideConversationToggle(data: string): boolean { // 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 2ab945eb41..9228d5645e 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -32,6 +32,7 @@ import { markPersisted } from '@maka/core/persisted-value'; import { type ActiveInteractionRequestEvent, type SessionEvent, + type ShellRunSnapshotResult, type ShellRunUpdate, } from '@maka/core/events'; import { isSideConversationSession } from '@maka/core/side-conversation'; @@ -44,6 +45,8 @@ import type { ProcessLifetimeOwner } from '@maka/storage/process-lifetime-owner' 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'; @@ -209,6 +212,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 >(); @@ -351,6 +362,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); @@ -585,6 +682,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); @@ -833,7 +938,14 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { await this.#requireSessionCopyCleanup().abandonOwner('tui-side'); } - 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; @@ -891,12 +1003,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 { @@ -1385,7 +1513,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); } @@ -1449,12 +1577,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 fd7a9bc05b..f6f64a4af7 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -245,7 +245,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 ed7534e21d..fafa03c2bb 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -18,7 +18,7 @@ */ import { realpath } from 'node:fs/promises'; -import type { SessionEvent } from '@maka/core/events'; +import type { 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'; @@ -133,6 +133,13 @@ export function skillInvocationBlockedMessage(skillInvocation: SkillInvocationRe : 'Explicit Skill invocation could not be resolved'; } +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 interface MakaSessionDriver { listSessions(): Promise; getSessionResumeAvailability?(session: SessionSummary): Promise; @@ -149,6 +156,10 @@ export interface MakaSessionDriver { options: MakaSubmitMessageOptions, ): Promise; queryCancelledMessages(messageIds: readonly string[]): 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; retractQueued?(): Promise; @@ -188,7 +199,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/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index 408d1c9235..6c0ddd634c 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -34,6 +34,7 @@ export interface TuiPrimaryGuidanceCopy { readonly commands: Readonly>; readonly help: { readonly commandsHeading: string; + readonly userCommand: string; readonly keybindingsHeading: string; readonly keybindings: readonly string[]; }; @@ -74,6 +75,7 @@ const TUI_PRIMARY_GUIDANCE = { }, help: { commandsHeading: '命令', + userCommand: ' ! — 执行一次仅用户可见的 shell 命令', keybindingsHeading: '快捷键', keybindings: [ ' Ctrl+O — 展开或折叠所有工具输出', @@ -123,6 +125,7 @@ const TUI_PRIMARY_GUIDANCE = { }, help: { commandsHeading: 'Commands', + userCommand: ' ! — run one shell command visible only to you', keybindingsHeading: 'Keybindings', keybindings: [ ' Ctrl+O — expand or collapse all tool output', diff --git a/packages/core/src/shell-run.ts b/packages/core/src/shell-run.ts index 0a45cb3712..7c5ae2da33 100644 --- a/packages/core/src/shell-run.ts +++ b/packages/core/src/shell-run.ts @@ -70,6 +70,13 @@ export type ShellRunTerminalStatus = (typeof SHELL_RUN_TERMINAL_STATUSES)[number export type ShellRunActiveStatus = (typeof SHELL_RUN_ACTIVE_STATUSES)[number]; export type ShellMode = 'pipes' | 'pty'; +/** + * Determines whether a runtime shell resource may be summarized to the model. + * User-owned interactive terminals remain observable to their attached Client, + * but their command stream and output are not part of an agent turn. + */ +export type ShellRunVisibility = 'model' | 'user'; + export interface PipeShellOutput { mode: 'pipes'; stdout: string; @@ -125,6 +132,8 @@ export interface ShellRunRecord { sourceRunId?: string; sourceTurnId: string; sourceToolCallId: string; + /** Defaults to `model` for model-initiated Bash runs. */ + visibility?: ShellRunVisibility; cwd: string; command: string; status: ShellRunStatus; @@ -312,6 +321,7 @@ const SHELL_RUN_RECORD_KEYS: ReadonlySet = new Set([ 'sourceRunId', 'sourceTurnId', 'sourceToolCallId', + 'visibility', 'cwd', 'command', 'status', @@ -364,6 +374,9 @@ export function normalizeShellRunRecord( hasOnlyKeys(record, SHELL_RUN_RECORD_KEYS) && requiredStrings.every((item) => typeof item === 'string') && isShellRunSourceToolCallId(record.sourceToolCallId) && + (record.visibility === undefined || + record.visibility === 'model' || + record.visibility === 'user') && record.sessionId === sessionId && record.shellRunId === shellRunId && isShellRunStatus(record.status) && @@ -507,6 +520,7 @@ function canonicalShellRunRecord(record: ShellRunRecord): ShellRunRecord { ...(record.sourceRunId !== undefined ? { sourceRunId: record.sourceRunId } : {}), sourceTurnId: record.sourceTurnId, sourceToolCallId: record.sourceToolCallId, + ...(record.visibility !== undefined ? { visibility: record.visibility } : {}), cwd: record.cwd, command: record.command, status: record.status, diff --git a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts index a450c4a598..4716466df1 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,65 @@ 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('rechecks Session activity after queued start admission', async () => { + const harness = createHarness(); + let releaseAdmission!: () => void; + const blocker = harness.sessionAdmission.run( + SESSION_ID, + () => + new Promise((resolve) => { + releaseAdmission = resolve; + }), + ); + await new Promise((resolve) => setImmediate(resolve)); + + const starting = harness.coordinator.handlers['runtime.resource.start']( + { sessionId: SESSION_ID, launchId: 'user-command-race', command: 'pwd' }, + connection('connection-1'), + ); + await new Promise((resolve) => setImmediate(resolve)); + harness.sessionState = 'archived'; + releaseAdmission(); + await blocker; + + const result = await starting; + assert.equal(result.ok, false); + assert.equal(!result.ok && result.error.code, 'session_archived'); + assert.equal(harness.lastBackgroundInput, undefined); + }); + 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 +641,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 +652,7 @@ function createHarness(options: Pick currentSnapshot, @@ -638,7 +722,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..9612775a2e 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,32 @@ describe('Runtime Resource protocol', () => { } }); + test('the current epoch gates the widened runtime.resource.start input (#3210)', () => { + // Epoch 56 peers decode the input with exact keys and reject `command` as + // unknown. The compatibility cut, not an opaque first-command failure, + // must reject that mixed pair before domain admission. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 56); + 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 45adfa103a..1494d52522 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -93,7 +93,11 @@ 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 = 56 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 57 as const; +// 57: `runtime.resource.start` accepts an optional one-shot `command`, and the +// durable Shell Run record carries a `visibility` field. An epoch-56 Host +// rejects the widened closed input, while an epoch-56 binary cannot safely +// interpret the widened durable record. // 56: Failed Turn snapshots preserve the structured context-budget exhaustion // detail. Epoch-55 peers reject the optional field on the closed snapshot shape. // 55: Local owners can atomically revoke every credential for one access 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..6dd125c54a 100644 --- a/packages/runtime-host/src/server/runtime-resource-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-resource-coordinator.ts @@ -360,50 +360,102 @@ 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 admittedUnavailable = await this.#mutableSessionFailure(input.sessionId); + if (admittedUnavailable) { + return mutationFailure('runtime.resource.start', admittedUnavailable); + } + 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 +591,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 +681,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..a7130a5374 100644 --- a/packages/runtime/src/__tests__/shell-run-manager.test.ts +++ b/packages/runtime/src/__tests__/shell-run-manager.test.ts @@ -59,6 +59,80 @@ 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', + ); + await assert.rejects( + () => manager.stopBackgroundTask('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', + ); + await assert.rejects( + () => + manager.writeStdin({ + sessionId: 'session-1', + ref, + input: 'private-input', + abortSignal: 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'); + const stopped = await manager.stopBackgroundTask('session-1', ref, NO_ABORT, 'client'); + assert.equal(stopped.kind, 'shell_run'); + assert.equal(stopped.operation?.kind, 'stop'); + }); + 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,