diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index f7537e0b70..c4bebec44d 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -3,9 +3,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, @@ -1774,6 +1775,149 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('updates a local user command card from its Runtime Resource', () => { + const state = createMakaPiTranscriptState(); + const ref = 'maka://runtime/background-tasks/user-command-1'; + appendUserCommandToTranscript(state, { + commandId: 'user-command-1', + command: 'pwd', + result: shellRun({ ref, status: 'running', stdout: '' }) as ShellRunSnapshotResult, + }); + + const applied = applyShellRunViewUpdateToTranscript(state, { + sessionId: 'session-1', + ownership: { kind: 'local' }, + sourceTurnId: 'user-command-1', + sourceToolCallId: 'user-command-1', + result: shellRun({ + ref, + status: 'completed', + stdout: '/repo\n', + completedAt: 2_000, + exitCode: 0, + }), + }); + + assert.equal(applied, true); + const tool = state.entries.find((entry) => entry.kind === 'tool'); + assert.equal(tool?.toolName, 'User command'); + assert.equal(tool?.status, 'done'); + assert.equal(tool?.expanded, true); + assert.match(tool?.output ?? '', /\/repo/); + assert.equal( + state.entries.some((entry) => entry.kind === 'notice'), + false, + ); + }); + + test('keeps user commands expanded and outside Ctrl+O model-tool toggles', () => { + const state = createMakaPiTranscriptState(); + appendUserCommandToTranscript(state, { + commandId: 'user-command-1', + command: 'printf done', + result: shellRun({ + ref: 'maka://runtime/background-tasks/user-command-1', + status: 'completed', + stdout: 'done\n', + completedAt: 2_000, + exitCode: 0, + }) as ShellRunSnapshotResult, + }); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'model-tool-1', + toolName: 'Bash', + args: { command: 'printf model' }, + }), + ); + const tools = state.entries.filter((entry) => entry.kind === 'tool'); + const userCommand = tools.find((entry) => entry.userOwned === true); + const modelTool = tools.find((entry) => entry.userOwned !== true); + assert.ok(userCommand && modelTool); + assert.equal(userCommand.expanded, true); + assert.equal(modelTool.expanded, false); + + assert.equal(toggleAllToolExpansion(state), true); + assert.equal(userCommand.expanded, true); + assert.equal(modelTool.expanded, true); + assert.equal(toggleAllToolExpansion(state), true); + assert.equal(userCommand.expanded, true); + assert.equal(modelTool.expanded, false); + }); + + test('preserves local user-command cards only for same-session reconnect replacement', () => { + const state = createMakaPiTranscriptState(); + appendUserCommandToTranscript(state, { + commandId: 'user-command-1', + command: 'sleep 60', + result: shellRun({ + ref: 'maka://runtime/background-tasks/user-command-1', + status: 'running', + stdout: '', + }) as ShellRunSnapshotResult, + }); + + replaceTranscriptWithStoredMessages(state, [], { preserveUserCommands: true }); + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.userOwned === true), + true, + ); + + replaceTranscriptWithStoredMessages(state, []); + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.userOwned === true), + false, + ); + }); + + test('reconnect re-inserts preserved user-command cards at their chronological position (#3210)', () => { + const state = createMakaPiTranscriptState(); + // The command ran before the model turns that followed it. + appendUserCommandToTranscript(state, { + commandId: 'user-command-1', + command: 'pwd', + result: shellRun({ + ref: 'maka://runtime/background-tasks/user-command-1', + status: 'completed', + stdout: '/repo\n', + startedAt: 1_000, + }) as ShellRunSnapshotResult, + }); + + replaceTranscriptWithStoredMessages( + state, + [ + { type: 'user', id: 'message-1', turnId: 'turn-1', ts: 2_000, text: 'later prompt' }, + { + type: 'assistant', + id: 'message-2', + turnId: 'turn-1', + ts: 3_000, + text: 'later answer', + modelId: 'model-1', + }, + ], + { preserveUserCommands: true }, + ); + + const cardIndex = state.entries.findIndex( + (entry) => entry.kind === 'tool' && entry.userOwned === true, + ); + const promptIndex = state.entries.findIndex((entry) => + JSON.stringify(entry).includes('later prompt'), + ); + const answerIndex = state.entries.findIndex((entry) => + JSON.stringify(entry).includes('later answer'), + ); + assert.notEqual(cardIndex, -1); + assert.notEqual(promptIndex, -1); + assert.notEqual(answerIndex, -1); + assert.ok(cardIndex < promptIndex, 'card must stay ahead of the later turn'); + assert.ok(promptIndex < answerIndex); + }); + test('notifies a settle exactly once across a folded poll and the live update', () => { const state = createMakaPiTranscriptState(); const ref = 'maka://runtime/background-tasks/bg-1'; diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 0414e7c6e9..754aefad84 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -33,6 +33,7 @@ import type { MakaSessionRewindResult, MakaSessionSwitchOptions, MakaSessionSwitchResult, + MakaTranscriptReplacementReason, RewindTarget, SessionResumeAvailability, } from '../session-driver.js'; @@ -211,6 +212,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); @@ -222,6 +224,178 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('! runs once without opening an agent turn', async () => { + const terminal = new FakeTerminal(); + const driver = new UserCommandDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('!pwd'); + terminal.input('\r'); + await waitFor(() => driver.commands.includes('pwd')); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('User command')); + assert.deepEqual(driver.prompts, []); + + exitMaka(terminal); + await run; + }); + + test('a bare ! shows localized user-command guidance without starting a turn', async () => { + const terminal = new FakeTerminal(); + const driver = new UserCommandDriver(); + const run = runMakaPiTui({ + title: 'Maka', + locale: 'zh', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('!'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('输入 shell 命令')); + assert.deepEqual(driver.commands, []); + assert.deepEqual(driver.prompts, []); + + terminal.input('p'); + await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('输入 shell 命令')); + + exitMaka(terminal); + await run; + }); + + test('Ctrl-C stops a running user command without exiting the TUI', async () => { + const terminal = new FakeTerminal(); + const driver = new RunningUserCommandDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('!sleep 3600'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('User command')); + + terminal.input('\x03'); + await waitFor(() => driver.stopUserCommandCalls === 1); + assert.equal(terminal.stopCalls, 0); + + exitMaka(terminal); + await run; + }); + + test('a rejected user-command stop hands Ctrl-C back to the exit chord (#3210)', async () => { + const terminal = new FakeTerminal(); + const driver = new RejectingUserCommandStopDriver(); + const processExitCodes: number[] = []; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + onProcessExit: (exitCode) => processExitCodes.push(exitCode), + }); + + await waitForTuiPaint(terminal); + terminal.input('!sleep 3600'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('User command')); + + // The first Ctrl+C is captured to stop the command, but the stop rejects: + // no terminal update is published, so the card still reads running. + terminal.input('\x03'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('host_draining')); + assert.equal(driver.stopUserCommandCalls, 1); + assert.equal(terminal.stopCalls, 0); + + // The capture must disarm: the next press shows the exit prompt and the + // one after exits. + terminal.input('\x03'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Press Ctrl+C again to exit.'), + ); + assert.equal(driver.stopUserCommandCalls, 1); + assert.equal(terminal.stopCalls, 0); + + terminal.input('\x03'); + await run; + assert.deepEqual(processExitCodes, [0]); + }); + + test('same-session reconnect keeps a user-command card for its terminal update', async () => { + const terminal = new FakeTerminal(); + const driver = new RunningUserCommandDriver(); + let publishShellRun: ((update: ShellRunUpdate) => void) | undefined; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + subscribeShellRunUpdates: (listener) => { + publishShellRun = listener; + return () => { + publishShellRun = undefined; + }; + }, + }); + + await waitForTuiPaint(terminal); + terminal.input('!printf done'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('User command')); + + driver.publishReconnect(); + publishShellRun?.({ + sessionId: 'session-1', + ownership: { kind: 'local' }, + sourceTurnId: 'user-command-1', + sourceToolCallId: 'user-command-1', + result: { + kind: 'shell_run', + ref: 'maka://runtime/background-tasks/user-command-1', + mode: 'pipes', + status: 'completed', + cwd: '/repo', + cmd: 'printf done', + startedAt: 1, + updatedAt: 2, + completedAt: 2, + exitCode: 0, + revision: 2, + output: pipeOutput('done\n'), + }, + }); + + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('done')); + assert.match(plainTerminalOutput(terminal.screenOutput()), /User command/); + + exitMaka(terminal); + await run; + }); + test('disables taskbar progress on Windows and Windows Terminal by default', () => { assert.equal(resolveTaskbarProgress(undefined, { platform: 'win32' }), false); assert.equal( @@ -6822,6 +6996,94 @@ class SlashCommandDriver implements MakaSessionDriver { } } +class UserCommandDriver extends SlashCommandDriver { + readonly commands: string[] = []; + + async runUserCommand(command: string) { + this.commands.push(command); + return { + commandId: `user-command-${this.commands.length}`, + result: { + kind: 'shell_run' as const, + ref: `maka://runtime/background-tasks/user-command-${this.commands.length}`, + mode: 'pipes' as const, + status: 'completed' as const, + cwd: '/repo', + cmd: command, + startedAt: 1, + updatedAt: 2, + completedAt: 2, + exitCode: 0, + revision: 1, + output: pipeOutput(command), + }, + takeRacedUpdate: () => undefined, + }; + } +} + +class RunningUserCommandDriver extends SlashCommandDriver { + readonly commands: string[] = []; + stopUserCommandCalls = 0; + readonly #transcriptListeners = new Set< + ( + sessionId: string, + turnId: string, + messages: StoredMessage[], + reason: MakaTranscriptReplacementReason, + ) => void + >(); + + async runUserCommand(command: string) { + this.commands.push(command); + return { + commandId: `user-command-${this.commands.length}`, + result: { + kind: 'shell_run' as const, + ref: `maka://runtime/background-tasks/user-command-${this.commands.length}`, + mode: 'pipes' as const, + status: 'running' as const, + cwd: '/repo', + cmd: command, + startedAt: 1, + updatedAt: 1, + revision: 1, + output: pipeOutput(''), + }, + takeRacedUpdate: () => undefined, + }; + } + + async stopUserCommands(): Promise { + this.stopUserCommandCalls += 1; + } + + subscribeTranscriptReplacements( + listener: ( + sessionId: string, + turnId: string, + messages: StoredMessage[], + reason: MakaTranscriptReplacementReason, + ) => void, + ): () => void { + this.#transcriptListeners.add(listener); + return () => this.#transcriptListeners.delete(listener); + } + + publishReconnect(): void { + for (const listener of this.#transcriptListeners) { + listener('session-1', 'turn-1', [], 'reconnect'); + } + } +} + +class RejectingUserCommandStopDriver extends RunningUserCommandDriver { + override async stopUserCommands(): Promise { + this.stopUserCommandCalls += 1; + throw new Error('host_draining'); + } +} + class HostSkillDriver extends SlashCommandDriver { constructor(private readonly skillInvocation: SkillInvocationResult) { super(); 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 371a2b2416..f7823de21e 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -5,6 +5,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, @@ -323,6 +324,374 @@ describe('Runtime Host Maka Session driver', () => { } }); + test('starts one user command without opening an agent turn', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: (() => { + let id = 0; + return () => `id-${++id}`; + })(), + }); + const command = await driver.runUserCommand!('pwd'); + + assert.equal(command.commandId, 'user-command-id-2'); + assert.equal(command.result.mode, 'pipes'); + assert.deepEqual( + connection.requests.map((request) => request.operation), + ['session.create', 'runtime.resource.start'], + ); + assert.deepEqual(connection.requests[1]?.input, { + sessionId: 'id-1', + launchId: 'user-command-id-2', + command: 'pwd', + }); + assert.equal(command.takeRacedUpdate(), undefined); + }); + + test('retains a terminal user-command update that arrives before its card is created', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: (() => { + let id = 0; + return () => `id-${++id}`; + })(), + }); + connection.onRuntimeResourceStart = async () => { + const startRequest = connection.requests.at(-1); + if (!startRequest) throw new Error('Expected Runtime Resource start request'); + const launchId = (startRequest.input as { launchId: string }).launchId; + connection.runtimeResourceQuery = { + kind: 'resource', + sessionId: 'id-1', + revision: `sha256:${'a'.repeat(64)}`, + resource: { + sessionId: 'id-1', + ownership: { kind: 'local' }, + sourceTurnId: launchId, + sourceToolCallId: launchId, + result: { + ...connection.userCommandResource, + status: 'completed', + output: { ...connection.userCommandResource.output, stdout: 'done\n' }, + updatedAt: 2, + completedAt: 2, + exitCode: 0, + revision: 2, + }, + } satisfies ShellRunUpdate, + }; + subscription.push({ + kind: 'subscription.session_domain_changed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'id-1', + domain: 'runtime_resource', + resources: [{ sourceSessionId: 'id-1', ref: connection.userCommandResource.ref }], + }); + await waitFor(() => + connection.requests.some((request) => request.operation === 'runtime.resource.query'), + ); + await delay(0); + }; + + const command = await driver.runUserCommand!('printf done'); + const raced = command.takeRacedUpdate(); + + assert.equal(raced?.status, 'completed'); + assert.equal(raced?.output?.mode, 'pipes'); + assert.equal(raced?.output?.mode === 'pipes' && raced.output.stdout, 'done\n'); + }); + + test('stops an already-running user command when the driver closes', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + await driver.stop(); + + const stop = connection.requests.find( + (request) => request.operation === 'runtime.resource.stop', + ); + assert.deepEqual(stop?.input, { + sessionId: 'id-1', + ref: connection.userCommandResource.ref, + }); + }); + + test('stops a user command whose start races driver close', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const releaseStart = deferred(); + connection.onRuntimeResourceStart = () => releaseStart.promise; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + + const starting = driver.runUserCommand!('sleep 3600'); + await waitFor(() => + connection.requests.some((request) => request.operation === 'runtime.resource.start'), + ); + const stopping = driver.stop(); + releaseStart.resolve(); + const command = await starting; + command.takeRacedUpdate(); + await stopping; + + assert.equal( + connection.requests.filter((request) => request.operation === 'runtime.resource.stop').length, + 1, + ); + }); + + test('a rejecting user-command stop does not fail the turn interrupt (#3210)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: runningTurn('turn-1', 'run-1'), + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + connection.runtimeResourceStopFailure = new Error('host_draining'); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + // turn.stop succeeds while the user-command stop rejects: the interrupt + // itself must still report success. + await driver.stop(); + + assert.ok(connection.requests.some((request) => request.operation === 'turn.stop')); + assert.ok(connection.requests.some((request) => request.operation === 'runtime.resource.stop')); + }); + + test('stops a running user command before switching Sessions (#3210)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const switchSubscription = new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription, switchSubscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + await driver.switchSession('session-1'); + + const stopIndex = connection.requests.findIndex( + (request) => request.operation === 'runtime.resource.stop', + ); + assert.notEqual(stopIndex, -1); + assert.deepEqual(connection.requests[stopIndex]?.input, { + sessionId: 'id-1', + ref: connection.userCommandResource.ref, + }); + assert.equal(driver.getSessionId(), 'session-1'); + }); + + test('a rejecting user-command stop aborts the switch before any durable relocation commits (#3210)', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tui-switch-stop-failure-')); + const target = join(root, 'new-worktree'); + await mkdir(target); + try { + const oldCwd = join(root, 'old-worktree'); + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + connection.sessionQueries.push( + sessionProjection({ + workspace: { target: { kind: 'host_path', path: oldCwd }, hostCwd: oldCwd }, + }), + ); + connection.runtimeResourceStopFailure = new Error('host_draining'); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: root, + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + inspectCwdChanges: async () => undefined, + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + await assert.rejects( + driver.switchSession('session-1', { relocateCwd: './new-worktree' }), + /host_draining/, + ); + + // The switch aborted before anything durable: no relocation was + // committed and the driver still owns the original Session. + assert.equal( + connection.requests.some(({ operation }) => operation === 'session.workspace.relocate'), + false, + ); + assert.equal(driver.getSessionId(), 'id-1'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('stops a running user command before a fresh Session (#3210)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + driver.startNewSession(); + + await waitFor(() => + connection.requests.some((request) => request.operation === 'runtime.resource.stop'), + ); + const stop = connection.requests.find( + (request) => request.operation === 'runtime.resource.stop', + ); + assert.deepEqual(stop?.input, { + sessionId: 'id-1', + ref: connection.userCommandResource.ref, + }); + assert.equal(driver.getSessionId(), null); + }); + test('drops a per-session Full access elevation when a fresh Session starts (#3020)', async () => { // The TUI flow behind /new: session A is elevated to bypass, then the // driver is asked to start over. The next prompt lazily creates session B @@ -1424,12 +1793,35 @@ class FakeConnection { readonly sessionQueries: Array> = []; openedSubscriptions = 0; interactionQuery: unknown; + runtimeResourceQuery: unknown; + onRuntimeResourceStart: (() => Promise) | undefined; executionBoundary: unknown = { kind: 'managed', access: 'read_write', revision: 1 }; skillStartBlocked = false; + /** When set, runtime.resource.stop rejects with this error (e.g. a draining Host). */ + runtimeResourceStopFailure: Error | undefined; /** Scripted outcomes for goal.control: return the result goal, or throw (e.g. operation_conflict). */ readonly goalControlOutcomes: Array = []; /** Scripted goal.query results, shifted per call; defaults to null (no goal). */ readonly goalQueryResults: Array = []; + readonly userCommandResource = { + kind: 'shell_run' as const, + ref: 'maka://runtime/background-tasks/user-command', + mode: 'pipes' as const, + status: 'running' as const, + cwd: '/repo', + cmd: 'pwd', + startedAt: 1, + updatedAt: 1, + revision: 1, + output: { + mode: 'pipes' as const, + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }; readonly value: RuntimeHostMakaSessionDriverInput['connection']; constructor( @@ -1502,6 +1894,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 10be3973c5..f768abec4d 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -4,6 +4,7 @@ import type { SandboxBoundaryRequestEvent, UserQuestionRequestEvent, SessionEvent, + ShellRunSnapshotResult, ToolOutputStream, ToolResultContent, } from '@maka/core/events'; @@ -167,8 +168,10 @@ export type MakaPiTranscriptEntry = outputDeltas: BoundedChunkBuffer; durationMs?: number; status: 'running' | 'done' | 'error' | 'failed' | 'aborted' | 'detached' | 'unavailable'; - /** Expanded card view; stamped from expandAllTools, retargeted by Ctrl+O. */ + /** Expanded card view; model tools follow expandAllTools and Ctrl+O. */ expanded: boolean; + /** Local-only Runtime Resource started by `!`, never a model tool call. */ + userOwned?: boolean; /** * Set when a successful shell-run poll is folded into its parent while * off-screen: the entry cannot be spliced (that would shift line numbers @@ -286,12 +289,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 || !isActiveShellRunStatus(tool.result.status) @@ -314,17 +323,74 @@ export function applyShellRunUpdateToTranscript( update: Extract, ): boolean { const tool = findToolEntry(state, sourceToolCallId); - if (!tool || tool.toolName !== 'Bash') return false; + if (!tool || !isShellRunToolCard(tool)) return false; if (tool.result?.kind === 'shell_run' && tool.result.ref !== update.ref) return false; return applyShellRunResult(tool, update); } +/** Adds a local-only card for a `!` resource without creating a model turn. */ +export function appendUserCommandToTranscript( + state: MakaPiTranscriptState, + input: { commandId: string; command: string; result: ShellRunSnapshotResult }, +): void { + state.entries.push({ + kind: 'tool', + toolUseId: input.commandId, + toolName: 'User command', + title: 'User command', + input: { command: input.command }, + result: input.result, + output: formatToolResultContent(input.result), + resultVersion: 1, + progress: createProgressBuffer(), + outputDeltas: createOutputBuffer(), + status: shellRunTranscriptStatus(input.result.status), + expanded: true, + userOwned: true, + }); +} + export function replaceTranscriptWithStoredMessages( state: MakaPiTranscriptState, messages: readonly StoredMessage[], + options: { readonly preserveUserCommands?: boolean } = {}, ): void { + const userCommands = options.preserveUserCommands + ? state.entries.filter( + (entry): entry is MakaPiToolEntry => entry.kind === 'tool' && entry.userOwned === true, + ) + : []; const view = materializeSession(messages); - state.entries = foldStoredShellRunChildren(view.items.flatMap(chatItemToTranscriptEntries)); + if (userCommands.length === 0) { + state.entries = foldStoredShellRunChildren(view.items.flatMap(chatItemToTranscriptEntries)); + } else { + // Re-insert each preserved card at its chronological position: comparing + // the shell run's startedAt against item timestamps keeps a `!` command + // that ran before later model turns ahead of them, so a same-session + // reconnect does not reorder the transcript (#3210). + const cards = [...userCommands].sort( + (a, b) => userCommandCardStartedAt(a) - userCommandCardStartedAt(b), + ); + const interleaved: MakaPiTranscriptEntry[] = []; + let cardIndex = 0; + for (const item of view.items) { + while ( + cardIndex < cards.length && + userCommandCardStartedAt(cards[cardIndex]!) <= chatItemTimestamp(item) + ) { + interleaved.push(cards[cardIndex]!); + cardIndex += 1; + } + interleaved.push(...chatItemToTranscriptEntries(item)); + } + while (cardIndex < cards.length) { + interleaved.push(cards[cardIndex]!); + cardIndex += 1; + } + // Cards survive the fold untouched: folding only merges a stored shell-run + // child into a Bash parent sharing its ref, which a user command never is. + state.entries = foldStoredShellRunChildren(interleaved); + } clearPendingInteractions(state); state.pendingShellRunPolls.clear(); state.expandAllTools = false; @@ -347,6 +413,22 @@ export function replaceTranscriptWithStoredMessages( } } +export function hasRunningUserCommand(state: MakaPiTranscriptState): boolean { + return state.entries.some( + (entry) => entry.kind === 'tool' && entry.userOwned === true && isLiveShellRunCard(entry), + ); +} + +/** Chronological key for a preserved user-command card; unknown times sort last. */ +function userCommandCardStartedAt(entry: MakaPiToolEntry): number { + return entry.result?.kind === 'shell_run' ? entry.result.startedAt : Number.POSITIVE_INFINITY; +} + +/** Chronological key for a materialized view item. */ +function chatItemTimestamp(item: ChatItem): number { + return item.kind === 'tool' ? item.item.ts : item.message.ts; +} + /** * Fill durable tool details that are intentionally absent from Runtime Host * live events without applying session-switch reset semantics. @@ -443,7 +525,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; @@ -1574,6 +1656,10 @@ function findToolEntry( ); } +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 b042f0b7ef..d1e64a0ac4 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -68,9 +68,12 @@ import { } from './session-driver.js'; import { appendTurnFailureToTranscript, + appendUserCommandToTranscript, appendUserPrompt, applyMakaSessionEventToTranscript, + applyShellRunUpdateToTranscript, createMakaPiTranscriptState, + hasRunningUserCommand, activeSandboxBoundaryRequest, activeUserQuestionRequest, completePendingInteraction, @@ -260,9 +263,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const tui = new TUI(terminal); const state = createMakaPiTranscriptState(); let transcriptMessages: readonly StoredMessage[] = []; - const replaceTranscript = (messages: readonly StoredMessage[]): void => { + const replaceTranscript = ( + messages: readonly StoredMessage[], + options: { readonly preserveUserCommands?: boolean } = {}, + ): void => { transcriptMessages = messages; - replaceTranscriptWithStoredMessages(state, messages); + replaceTranscriptWithStoredMessages(state, messages, options); }; let cwd = input.cwd; let model = input.model; @@ -331,6 +337,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let lastTurnEscapeAt = 0; let lastIdleEscapeAt = 0; let lastIdleCtrlCAt = 0; + // 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 }; @@ -424,6 +436,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { paddingX: 1, autocompleteMaxVisible: EDITOR_AUTOCOMPLETE_MAX_VISIBLE, }); + editor.setUserCommandHint(primaryGuidance.editor.userCommandHint); let refreshEditorCwd: ((cwd: string) => void) | undefined; const editorSurface = new MakaAutocompleteAboveEditorComponent(editor); const layout = new MakaPiLayoutComponent( @@ -491,7 +504,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { input.driver.subscribeTranscriptReplacements?.((sessionId, turnId, messages, reason) => { if (closed || input.driver.getSessionId() !== sessionId) return; if (reason === 'reconnect') { - replaceTranscript(messages); + replaceTranscript(messages, { preserveUserCommands: true }); shellRunElapsedTicker.sync(); requestRender(); return; @@ -819,6 +832,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 @@ -1054,6 +1081,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { beginGracefulClose(); 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); @@ -1432,6 +1469,19 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); }; + const runUserCommand = async (command: string): Promise => { + if (!input.driver.runUserCommand) { + throw new Error('User commands are unavailable on this session driver.'); + } + const started = await input.driver.runUserCommand(command); + appendUserCommandToTranscript(state, { command, ...started }); + const racedUpdate = started.takeRacedUpdate(); + if (racedUpdate) { + applyShellRunUpdateToTranscript(state, started.commandId, racedUpdate); + } + requestRender(); + }; + // Adopt a switch/rewind result: the active session is now `summary` with // `messages`. Shared by switchSession and rewindToTurn so both land the same // runner state (model/connection/thinking/transcript/scroll). @@ -2156,15 +2206,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', @@ -3057,6 +3108,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. @@ -3375,6 +3444,13 @@ function isExitPrompt(prompt: string): boolean { return trimmed === 'quit' || trimmed === 'exit' || trimmed === '/quit' || trimmed === '/exit'; } +/** Only a leading bang opts into a local user command; ordinary prose remains a prompt. */ +function parseUserCommand(prompt: string): string | undefined { + const trimmed = prompt.trim(); + if (!trimmed.startsWith('!')) return undefined; + return trimmed.slice(1).trim(); +} + // Two Escapes this close together read as one deliberate "stop the turn". const DOUBLE_ESCAPE_INTERRUPT_WINDOW_MS = 600; const DOUBLE_CTRL_C_EXIT_WINDOW_MS = 1_000; diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index e568ea2a0d..b9534935e4 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -10,10 +10,13 @@ import { type ActiveInteractionRequestEvent, type QueueEnqueueOutcome, type SessionEvent, + type ShellRunSnapshotResult, type ShellRunUpdate, } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; +import { mergeShellRunUpdate } from '@maka/core/shell-run-result'; +import { isActiveShellRunStatus } from '@maka/core/shell-run'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { executionBoundaryDisplayMode } from '@maka/core/sandbox-boundary'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -149,6 +152,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 >(); @@ -272,6 +283,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); @@ -452,6 +549,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); @@ -563,6 +668,13 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { } startNewSession(): void { + // `/new` replaces the transcript without preserving user-command cards, + // so a still-running command would lose both its projection and its + // Ctrl+C stop affordance. Stop tracked commands before the identity + // change; the generation/pending bump is synchronous, so an in-flight + // start self-stops when it resolves even though this method stays sync + // (#3210). + void this.stopUserCommands().catch(() => undefined); this.#sessionGeneration += 1; this.#channelGeneration += 1; this.#sessionId = null; @@ -618,12 +730,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 { @@ -996,7 +1124,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); } @@ -1049,12 +1177,42 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { .then((resources) => { if (this.#sessionId !== sessionId) return; for (const resource of resources) { - for (const listener of this.#shellRunListeners) listener(resource); + this.#publishShellRunUpdate(resource); } }) .catch(() => undefined); } + async #stopUserCommand( + ref: string, + owner: { readonly sessionId: string; readonly commandId: string }, + ): Promise { + if (this.#activeUserCommands.get(ref) !== owner) return; + const stopped = await this.#request('runtime.resource.stop', { + sessionId: owner.sessionId, + ref, + }); + this.#publishShellRunUpdate({ + sessionId: owner.sessionId, + ownership: { kind: 'local' }, + sourceTurnId: owner.commandId, + sourceToolCallId: owner.commandId, + result: stopped.resource, + }); + } + + #publishShellRunUpdate(update: ShellRunUpdate): void { + const owner = this.#activeUserCommands.get(update.result.ref); + if ( + owner?.sessionId === update.sessionId && + owner.commandId === update.sourceToolCallId && + !isActiveShellRunStatus(update.result.status) + ) { + this.#activeUserCommands.delete(update.result.ref); + } + for (const listener of this.#shellRunListeners) listener(update); + } + #request( operation: K, input: OperationInput, diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index ecb49cd7bb..b8e10d8626 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -1,5 +1,10 @@ import { realpath } from 'node:fs/promises'; -import type { QueueEnqueueOutcome, SessionEvent } from '@maka/core/events'; +import type { + QueueEnqueueOutcome, + SessionEvent, + ShellRunSnapshotResult, + ShellRunUpdate, +} from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -62,6 +67,13 @@ export interface MakaPreparePromptOptions { maxSteps?: number; } +export interface MakaUserCommand { + readonly commandId: string; + readonly result: ShellRunSnapshotResult; + /** Returns the newest update that raced the initial card into the transcript. */ + takeRacedUpdate(): ShellRunUpdate['result'] | undefined; +} + export class SkillInvocationBlockedError extends Error { constructor(readonly skillInvocation: SkillInvocationResult) { super('Explicit Skill invocation could not be resolved'); @@ -76,6 +88,10 @@ export interface MakaSessionDriver { prompt: string, options?: MakaPreparePromptOptions, ): Promise; + /** Runs one user-owned command. Its input/output never becomes model prompt history. */ + runUserCommand?(command: string): Promise; + /** Stops every live user-owned command started by this driver. */ + stopUserCommands?(): Promise; compactSession(): AsyncIterable; resumeLatest?(): AsyncIterable; steer?(text: string): Promise; diff --git a/packages/cli/src/skill-highlight-editor.ts b/packages/cli/src/skill-highlight-editor.ts index b414093d43..cdc829c10a 100644 --- a/packages/cli/src/skill-highlight-editor.ts +++ b/packages/cli/src/skill-highlight-editor.ts @@ -1,4 +1,4 @@ -import { Editor } from '@earendil-works/pi-tui'; +import { Editor, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { ansi } from './tui-ansi.js'; @@ -25,6 +25,12 @@ const MID_MESSAGE_SLASH_TOKEN = /(?:\s)\/\S*$/; */ export class MakaSkillHighlightEditor extends Editor { private isInvocable: (name: string) => boolean = () => false; + private userCommandHint = ''; + + setUserCommandHint(hint: string): void { + this.userCommandHint = hint; + this.invalidate(); + } /** * Swap the validator used by the render pass. Must be synchronous and @@ -38,13 +44,34 @@ export class MakaSkillHighlightEditor extends Editor { override render(width: number): string[] { const pattern = new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'); - return super + const lines = super .render(width) .map((line) => line.replace(pattern, (whole, name: string) => this.isInvocable(name) ? ansi.accent(whole) : whole, ), ); + if (this.getText() !== '!' || !this.userCommandHint) return lines; + + // NOTE: this cursor glyph is a private rendering detail of + // @earendil-works/pi-tui — `Editor.render` emits `\x1b[7m \x1b[0m` (reverse + // space) only when the cursor sits at end-of-line, which a bare `!` + // always produces (read from pi-tui 0.83.0, components/editor.js:442). + // A dependency bump that changes the glyph makes findIndex return -1 and + // the hint silently disappears; the covering test asserts the rendered + // string, so such a bump fails loudly there. + const cursor = '\x1b[7m \x1b[0m'; + const contentLine = lines.findIndex((line) => line.includes(cursor)); + if (contentLine === -1) return lines; + + const line = lines[contentLine] ?? ''; + const cursorEnd = line.indexOf(cursor) + cursor.length; + const available = Math.max(0, width - visibleWidth(line.slice(0, cursorEnd))); + const hint = truncateToWidth(` ${this.userCommandHint}`, available, ''); + lines[contentLine] = `${line.slice(0, cursorEnd)}${ansi.dim(hint)}${' '.repeat( + Math.max(0, available - visibleWidth(hint)), + )}`; + return lines; } override handleInput(data: string): void { diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index f014aa9185..fbae251eb7 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -14,9 +14,13 @@ export interface TuiPrimaryGuidanceCopy { readonly commands: Readonly>; readonly help: { readonly commandsHeading: string; + readonly userCommand: string; readonly keybindingsHeading: string; readonly keybindings: readonly string[]; }; + readonly editor: { + readonly userCommandHint: string; + }; } const TUI_PRIMARY_GUIDANCE = { @@ -51,6 +55,7 @@ const TUI_PRIMARY_GUIDANCE = { }, help: { commandsHeading: '命令', + userCommand: ' ! — 执行一次仅用户可见的 shell 命令', keybindingsHeading: '快捷键', keybindings: [ ' Ctrl+O — 展开或折叠所有工具输出', @@ -65,6 +70,9 @@ const TUI_PRIMARY_GUIDANCE = { ' Ctrl+D — 输入为空时退出', ], }, + editor: { + userCommandHint: '输入 shell 命令', + }, }, en: { welcome: { @@ -97,6 +105,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', @@ -111,6 +120,9 @@ const TUI_PRIMARY_GUIDANCE = { ' Ctrl+D — exit when input is empty', ], }, + editor: { + userCommandHint: 'type a shell command', + }, }, } satisfies UiCatalog; diff --git a/packages/core/src/shell-run.ts b/packages/core/src/shell-run.ts index 3c753018bb..9b82a12096 100644 --- a/packages/core/src/shell-run.ts +++ b/packages/core/src/shell-run.ts @@ -51,6 +51,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; @@ -106,6 +113,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; @@ -293,6 +302,7 @@ const SHELL_RUN_RECORD_KEYS: ReadonlySet = new Set([ 'sourceRunId', 'sourceTurnId', 'sourceToolCallId', + 'visibility', 'cwd', 'command', 'status', @@ -345,6 +355,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) && @@ -488,6 +501,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 cea8652db7..024885fabb 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts @@ -261,6 +261,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, }, @@ -268,6 +269,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, }, @@ -401,6 +405,38 @@ describe('Host Runtime Resource coordinator', () => { assert.equal(harness.lastForegroundInput, undefined); }); + test('starts a one-shot user command in pipes without exposing it to the model', async () => { + const harness = createHarness(); + const started = await harness.coordinator.handlers['runtime.resource.start']( + { sessionId: SESSION_ID, launchId: 'user-command-1', command: 'printf user-command' }, + connection('connection-1'), + ); + + assert.equal(started.ok, true); + assert.equal(started.ok && started.result.resource.mode, 'pipes'); + assert.deepEqual( + harness.lastBackgroundInput && { + sessionId: harness.lastBackgroundInput.sessionId, + sourceTurnId: harness.lastBackgroundInput.sourceTurnId, + sourceToolCallId: harness.lastBackgroundInput.sourceToolCallId, + visibility: harness.lastBackgroundInput.visibility, + cwd: harness.lastBackgroundInput.cwd, + command: harness.lastBackgroundInput.command, + pty: harness.lastBackgroundInput.pty, + }, + { + sessionId: SESSION_ID, + sourceTurnId: 'user-command-1', + sourceToolCallId: 'user-command-1', + visibility: 'user', + cwd: '/workspace', + command: 'printf user-command', + pty: false, + }, + ); + harness.finishBackground({ successful: true }); + }); + test('lets stop bypass the controller, releases terminal ownership, and keeps control replay safe', async () => { const harness = createHarness(); const firstConnection = connection('connection-1'); @@ -542,6 +578,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', @@ -568,9 +605,11 @@ function createHarness(options: Pick currentSnapshot, @@ -619,7 +658,7 @@ function createHarness(options: Pick structuredClone(currentSnapshot), + inspectResource: async () => structuredClone(lastStartedSnapshot ?? currentSnapshot), getLivePtySnapshot: (sessionId, ref) => ({ sessionId, ref, diff --git a/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts index 3a71c43e52..d7823714d0 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-protocol.test.ts @@ -3,6 +3,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, @@ -11,8 +13,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, @@ -25,6 +29,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', @@ -51,7 +77,34 @@ describe('Runtime Resource protocol', () => { } }); + test('the current epoch gates the widened runtime.resource.start input (#3210)', () => { + // The one-shot `command` field widens the start input at epoch 29. A + // pre-widening Host decodes it with exact keys and rejects `command` as + // unknown, so the epoch — not the decoder — is what keeps a pre-widening + // peer from being admitted and then failing on the first `!` command. + // Pinned relative so the next epoch advance does not silently pass. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH >= 29); + assertInvalid(() => + requireExactRecord( + { sessionId: 'session-1', launchId: 'user-command-1', command: 'pwd' }, + 'Runtime Resource start input', + ['sessionId', 'launchId'], + ), + ); + assert.deepEqual( + decodeRuntimeResourceStartInput({ sessionId: 'session-1', launchId: 'launch-1' }), + { sessionId: 'session-1', launchId: 'launch-1' }, + ); + }); + test('enforces cursor, sequence, PTY control, item, and encoded result bounds', () => { + assertInvalid(() => + decodeRuntimeResourceStartInput({ + 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 b18b69ab14..118a12be37 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -72,11 +72,16 @@ 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 = 28 as const; -// 28: Relay model profiles carry the Fast service-tier declaration. Older -// peers cannot safely preserve that Runtime Policy field. +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 29 as const; // 27: Runtime Policy carries the Host-owned shell preference used by tool, // PTY, and prompt composition. Older peers cannot safely preserve that field. +// 28: Relay model profiles carry the Fast service-tier declaration. Older +// peers cannot safely preserve that Runtime Policy field. +// 29: `runtime.resource.start` accepts an optional one-shot `command`, and the +// durable Shell Run record carries a `visibility` field. An epoch-28 Host +// decodes the start input with exact keys and rejects `command` as unknown; +// an epoch-28 binary rejects the widened record on read. Peers must agree on +// both before either is exercised. // Transcript pages amortize storage and network round trips with a 512 KiB raw // payload. Base64 expansion plus the bounded fragment envelope must still fit in // one transport message; narrower domains retain their own encoded limits. diff --git a/packages/runtime-host/src/protocol/runtime-resource.ts b/packages/runtime-host/src/protocol/runtime-resource.ts index a8a88c341d..a1d8419be7 100644 --- a/packages/runtime-host/src/protocol/runtime-resource.ts +++ b/packages/runtime-host/src/protocol/runtime-resource.ts @@ -12,6 +12,7 @@ import { requireExactRecord, requireId, requireRecord, + requireShapedRecord, requireUtf8String, } from './codec.js'; import { invalidProtocolFrame } from './errors.js'; @@ -23,6 +24,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; @@ -143,6 +145,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 { @@ -223,13 +227,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 3dcd8ae4d6..95fb12d762 100644 --- a/packages/runtime-host/src/server/runtime-resource-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-resource-coordinator.ts @@ -344,25 +344,25 @@ export class HostRuntimeResourceCoordinator const header = await this.#sessionHeaders.readHeader(input.sessionId); const shell = await this.#resolveShell(); const env = { ...process.env }; - let command: string; - if (shell.kind === 'git-bash') { + let command = input.command; + if (command === undefined && shell.kind === 'git-bash') { env.SHELL = shell.exe; env.CHERE_INVOKING = '1'; env.DISABLE_AUTO_UPDATE = 'true'; env.DISABLE_UPDATE_PROMPT = 'true'; command = 'exec "$SHELL" -l'; - } else if (shell.kind === 'legacy-wsl-bash') { + } else if (command === undefined && shell.kind === 'legacy-wsl-bash') { env.DISABLE_AUTO_UPDATE = 'true'; env.DISABLE_UPDATE_PROMPT = 'true'; command = 'exec bash -l'; - } else if (shell.kind === 'posix') { + } else if (command === undefined && shell.kind === 'posix') { env.SHELL ||= userInfo().shell || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh'); env.DISABLE_AUTO_UPDATE = 'true'; env.DISABLE_UPDATE_PROMPT = 'true'; command = 'exec "$SHELL" -l'; - } else if (shell.kind === 'cmd') { + } else if (command === undefined && shell.kind === 'cmd') { command = '%ComSpec% /d /q'; - } else { + } else if (command === undefined) { const executable = (shell.exe ?? shell.displayName).replace(/'/g, "''"); command = `& '${executable}' -NoLogo`; } @@ -370,10 +370,14 @@ export class HostRuntimeResourceCoordinator 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: true, + pty: input.command === undefined, emitOutput: () => undefined, shell, }); @@ -520,6 +524,7 @@ export class HostRuntimeResourceCoordinator const controlled = await this.#manager.writeStdin({ sessionId: input.sessionId, ref: input.ref, + caller: 'client', ...controlWrite(input.control), }); const result = decodeRuntimeResourceControllerControlResult({ @@ -609,6 +614,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 3fe3e3c5b5..5c22f8dfa9 100644 --- a/packages/runtime/src/__tests__/shell-run-manager.test.ts +++ b/packages/runtime/src/__tests__/shell-run-manager.test.ts @@ -38,6 +38,57 @@ after(async () => { }); describe('ShellRunProcessManager', () => { + test('keeps user-owned terminals out of the model background-task summary', async () => { + const store = createSqliteShellRunStore(await workspace()); + await store.createShellRun({ + ...record({ shellRunId: 'user-shell', status: 'running' }), + visibility: 'user', + command: 'user-private-command', + }); + await store.createShellRun({ + ...record({ shellRunId: 'model-shell', status: 'running' }), + command: 'model-background-command', + }); + + const summary = await createManager(store).buildContextSummary('session-1'); + + assert.match(summary ?? '', /model-background-command/u); + assert.doesNotMatch(summary ?? '', /user-private-command/u); + }); + + test('rejects a model Read of a user-owned resource while preserving client inspection', async () => { + const store = createSqliteShellRunStore(await workspace()); + await store.createShellRun({ + ...record({ shellRunId: 'user-command', status: 'completed' }), + visibility: 'user', + command: 'printf private-output', + output: { + mode: 'pipes', + stdout: 'private-output\n', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + completedAt: 2, + exitCode: 0, + }); + const manager = createManager(store); + const ref = 'maka://runtime/background-tasks/user-command'; + + await assert.rejects( + () => manager.readRuntimeResource('session-1', ref, NO_ABORT), + (error: unknown) => + error instanceof Error && + (error as NodeJS.ErrnoException).code === 'ENOENT' && + error.message === 'Runtime background task not found in this session', + ); + + const inspected = await manager.inspectResource('session-1', ref); + assert.equal(inspected.output.mode, 'pipes'); + assert.equal(inspected.output.stdout, 'private-output\n'); + }); + test('rejects unprojectable provider tool-call identities before durable admission', async () => { const cwd = await workspace(); const store = createSqliteShellRunStore(cwd); diff --git a/packages/runtime/src/shell-run-contract.ts b/packages/runtime/src/shell-run-contract.ts index 8c9d128611..814399018e 100644 --- a/packages/runtime/src/shell-run-contract.ts +++ b/packages/runtime/src/shell-run-contract.ts @@ -71,6 +71,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. */ @@ -96,6 +98,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 { @@ -126,6 +130,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 b22a581c3e..ac3d513c44 100644 --- a/packages/runtime/src/shell-run-manager.ts +++ b/packages/runtime/src/shell-run-manager.ts @@ -107,6 +107,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 }; @@ -339,6 +348,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()); @@ -483,7 +493,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 { @@ -499,11 +509,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 }); @@ -543,7 +555,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 = [ @@ -945,6 +959,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', @@ -1626,12 +1641,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 { @@ -1643,6 +1660,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, @@ -1670,6 +1688,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)) { @@ -1696,11 +1715,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,