@zergai/ztc 0.3.0-beta.1 → 0.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/bin/zmux-darwin-arm64 +0 -0
  2. package/bin/ztc-tui-darwin-arm64 +0 -0
  3. package/bin/zterm-darwin-arm64 +0 -0
  4. package/dist/App.js +16 -2
  5. package/dist/agent/agent.d.ts +20 -8
  6. package/dist/agent/agent.js +92 -40
  7. package/dist/agent/backends/gemini.js +1 -1
  8. package/dist/agent/backends/openai_format.js +2 -2
  9. package/dist/agent/backends/openai_responses.js +1 -1
  10. package/dist/agent/backends/types.d.ts +1 -0
  11. package/dist/agent/commands/config.js +15 -1
  12. package/dist/agent/commands/settings.js +23 -0
  13. package/dist/agent/commands/status.js +3 -0
  14. package/dist/agent/commands/types.d.ts +2 -0
  15. package/dist/agent/completion_state.js +2 -2
  16. package/dist/agent/factory.js +7 -1
  17. package/dist/agent/request_encoder.js +3 -0
  18. package/dist/agent/runtime/command_jobs.d.ts +47 -0
  19. package/dist/agent/runtime/command_jobs.js +268 -0
  20. package/dist/agent/runtime/execution_context.d.ts +16 -0
  21. package/dist/agent/runtime/execution_context.js +20 -0
  22. package/dist/agent/runtime/recovery.d.ts +9 -0
  23. package/dist/agent/runtime/recovery.js +71 -0
  24. package/dist/agent/runtime/tool_runtime.js +3 -1
  25. package/dist/agent/runtime/tool_scheduler.d.ts +6 -0
  26. package/dist/agent/runtime/tool_scheduler.js +57 -0
  27. package/dist/agent/subagent.js +30 -12
  28. package/dist/agent/tool_response_store.d.ts +2 -0
  29. package/dist/agent/tool_response_store.js +1 -0
  30. package/dist/agent/tools/deferred.js +2 -0
  31. package/dist/agent/tools/file.js +2 -0
  32. package/dist/agent/tools/pane.js +139 -42
  33. package/dist/agent/tools/recall_tool_response.js +1 -0
  34. package/dist/agent/tools/registry.js +3 -1
  35. package/dist/agent/tools/search.js +1 -0
  36. package/dist/agent/tools/shell.d.ts +2 -0
  37. package/dist/agent/tools/shell.js +57 -2
  38. package/dist/agent/tools/types.d.ts +4 -0
  39. package/dist/app/agent_runner.js +1 -1
  40. package/dist/app/ordered_transcript.d.ts +13 -0
  41. package/dist/app/ordered_transcript.js +55 -0
  42. package/dist/cli_ratatui.d.ts +2 -7
  43. package/dist/cli_ratatui.js +53 -34
  44. package/dist/config/execution.d.ts +12 -0
  45. package/dist/config/execution.js +13 -0
  46. package/dist/config/types.d.ts +1 -0
  47. package/dist/config.d.ts +1 -0
  48. package/dist/config.js +5 -0
  49. package/dist/emulation/tool_adapters.js +5 -2
  50. package/dist/headless.d.ts +1 -18
  51. package/dist/headless.js +15 -0
  52. package/dist/plugin/runtime.d.ts +1 -1
  53. package/dist/types.d.ts +3 -0
  54. package/dist/ui/ratatui/headless_pane_manager.d.ts +2 -19
  55. package/dist/ui/ratatui/headless_pane_manager.js +41 -34
  56. package/dist/ui/ratatui/pane_input.d.ts +4 -0
  57. package/dist/ui/ratatui/pane_input.js +15 -0
  58. package/dist/ui/ratatui/pane_relay.d.ts +3 -0
  59. package/dist/ui/ratatui/pane_relay.js +20 -2
  60. package/dist/ui/ratatui/protocol.d.ts +17 -0
  61. package/dist/ui/ratatui/protocol.js +1 -0
  62. package/dist/utils/shell.d.ts +2 -0
  63. package/dist/utils/shell.js +54 -11
  64. package/dist/web/mirror_stream.d.ts +2 -1
  65. package/dist/web/mirror_stream.js +7 -0
  66. package/dist/web/pane_registry.js +8 -2
  67. package/dist/web/view_plugin.d.ts +1 -0
  68. package/dist/zlm-embed.mjs +384 -118
  69. package/package.json +2 -2
@@ -269,6 +269,7 @@ export class ToolResponseStore {
269
269
  timestamp: new Date().toISOString(),
270
270
  turn: this.turnCounter++,
271
271
  partial,
272
+ ...(context?.isError === undefined ? {} : { is_error: context.isError }),
272
273
  preview,
273
274
  };
274
275
  const sourcePath = context?.sourcePath ?? null;
@@ -22,6 +22,8 @@ export const ALWAYS_LOADED_TOOL_NAMES = new Set([
22
22
  'list_directory',
23
23
  'search',
24
24
  'run_command',
25
+ 'read_command',
26
+ 'stop_command',
25
27
  'apply_patch',
26
28
  // Emulation compatibility aliases. They only appear in the request when
27
29
  // the active emulation adds matching tools to the runtime tool list.
@@ -104,6 +104,7 @@ async function assertTemporaryWriteAllowed(path, context) {
104
104
  }
105
105
  // --- File Tools ---
106
106
  export const readFileTool = {
107
+ parallelSafe: true,
107
108
  capabilities: [ToolCapability.FILE_READ],
108
109
  definition: {
109
110
  name: 'read_file',
@@ -503,6 +504,7 @@ export const editFileTool = {
503
504
  },
504
505
  };
505
506
  export const listDirectoryTool = {
507
+ parallelSafe: true,
506
508
  capabilities: [ToolCapability.FILE_READ],
507
509
  definition: {
508
510
  name: 'list_directory',
@@ -5,9 +5,12 @@
5
5
  * and list terminal panes when running inside zmux.
6
6
  * They communicate with the zmux Rust binary via the IPC socket.
7
7
  */
8
+ import { randomUUID } from 'node:crypto';
9
+ import { ToolCapability } from '../runtime/capabilities.js';
8
10
  import { existsSync } from 'fs';
9
11
  import { basename, isAbsolute, resolve } from 'path';
10
12
  import { isRatatuiBinaryAvailable } from '../../ui/ratatui/process.js';
13
+ import { CAPABILITY_PANE_DELIVERY_ACK } from '../../ui/ratatui/protocol.js';
11
14
  import { generateSessionId } from '../../session.js';
12
15
  import { isPaneProvider, resolveModelAndProvider } from '../../panes/model_catalog.js';
13
16
  import { buildZtcPaneLaunchCommand, createZtcLaunchSpec } from '../../panes/ztc_launch.js';
@@ -139,19 +142,30 @@ export function clearPaneToolsTuiProcess() {
139
142
  /**
140
143
  * Wait for a specific event type from the TUI process with a timeout.
141
144
  */
142
- function waitForEvent(tui, predicate, timeoutMs = 10000) {
145
+ const PANE_WAIT_CANCELLED = 'Cancelled while waiting for pane response; inspect the pane before retrying because input may already have arrived.';
146
+ function waitForEvent(tui, predicate, timeoutMs = 10000, signal) {
143
147
  return new Promise((resolve, reject) => {
144
- const timeout = setTimeout(() => {
148
+ if (signal?.aborted) {
149
+ reject(new Error(PANE_WAIT_CANCELLED));
150
+ return;
151
+ }
152
+ const cleanup = () => {
153
+ clearTimeout(timeout);
145
154
  unsubscribe();
146
- reject(new Error('Timed out waiting for pane response'));
155
+ signal?.removeEventListener('abort', abort);
156
+ };
157
+ const abort = () => { cleanup(); reject(new Error(PANE_WAIT_CANCELLED)); };
158
+ const timeout = setTimeout(() => {
159
+ cleanup();
160
+ reject(new Error('Timed out waiting for pane response; inspect the pane before retrying.'));
147
161
  }, timeoutMs);
148
162
  const unsubscribe = tui.on((event) => {
149
163
  if (predicate(event)) {
150
- clearTimeout(timeout);
151
- unsubscribe();
164
+ cleanup();
152
165
  resolve(event);
153
166
  }
154
167
  });
168
+ signal?.addEventListener('abort', abort, { once: true });
155
169
  });
156
170
  }
157
171
  const SPLIT_DIRECTIONS = ['horizontal', 'vertical'];
@@ -757,68 +771,122 @@ export const createZtcPaneTool = {
757
771
  }
758
772
  },
759
773
  };
774
+ // Legacy renderers omit request IDs. Only one tool read per pane may be in
775
+ // flight; viewport events from mirror consumers are filtered separately.
776
+ const legacyPaneReads = new WeakMap();
777
+ async function serializeLegacyPaneRead(transport, paneId, signal, read) {
778
+ let panes = legacyPaneReads.get(transport);
779
+ if (!panes) {
780
+ panes = new Map();
781
+ legacyPaneReads.set(transport, panes);
782
+ }
783
+ const previous = panes.get(paneId) ?? Promise.resolve();
784
+ const result = previous.then(() => {
785
+ if (signal.aborted)
786
+ throw new Error(PANE_WAIT_CANCELLED);
787
+ return read();
788
+ });
789
+ const settled = result.then(() => { }, () => { });
790
+ panes.set(paneId, settled);
791
+ void settled.then(() => { if (panes.get(paneId) === settled)
792
+ panes.delete(paneId); });
793
+ // Cancellation while waiting for a slot is immediate, but does not unlock
794
+ // the preceding request or send a command for this cancelled read.
795
+ let onAbort;
796
+ const aborted = new Promise((_, reject) => {
797
+ onAbort = () => reject(new Error(PANE_WAIT_CANCELLED));
798
+ signal.addEventListener('abort', onAbort, { once: true });
799
+ if (signal.aborted)
800
+ onAbort();
801
+ });
802
+ try {
803
+ return await Promise.race([result, aborted]);
804
+ }
805
+ finally {
806
+ signal.removeEventListener('abort', onAbort);
807
+ }
808
+ }
760
809
  export const readTerminalPaneTool = {
810
+ capabilities: [ToolCapability.FILE_READ],
761
811
  definition: {
762
812
  name: 'read_terminal_pane',
763
813
  description: `Read a pane's text output. Use viewport=true for exactly what is visible, ansi=true when terminal color/control state matters, or lines for recent scrollback.`,
764
814
  parameters: {
765
815
  type: 'object',
766
816
  properties: {
817
+ viewport: { type: 'boolean', description: 'Read visible viewport only.' },
818
+ ansi: { type: 'boolean', description: 'Keep ANSI color/control sequences.' },
767
819
  pane_id: {
768
820
  type: 'number',
769
821
  description: 'The ID of the pane to read (from create_terminal_pane or list_terminal_panes)',
770
822
  },
771
823
  lines: {
772
824
  type: 'number',
773
- description: 'Number of lines to read from the bottom of the buffer. Defaults to full screen.',
825
+ description: 'Number of lines to read from the bottom of the buffer. Defaults to 200 lines; maximum 2000.',
774
826
  },
775
827
  },
776
828
  required: ['pane_id'],
777
829
  },
778
830
  },
779
- execute: async (args) => {
831
+ execute: async (args, context) => {
832
+ const waiting = new AbortController();
833
+ const signal = context?.signal ? AbortSignal.any([context.signal, waiting.signal]) : waiting.signal;
834
+ if (signal.aborted)
835
+ throw new Error(PANE_WAIT_CANCELLED);
780
836
  if (!tuiProcessRef || !tuiProcessRef.isMuxMode) {
781
- return 'Error: Terminal panes are only available in zmux mode (--mux flag).';
837
+ throw new Error('Terminal panes require zmux (--mux). Use run_command for shell execution when available.');
782
838
  }
783
839
  const paneId = Number(args.pane_id);
784
- if (isNaN(paneId)) {
785
- return 'Error: pane_id must be a number.';
786
- }
787
- const lines = args.lines ? Number(args.lines) : undefined;
788
- const eventPromise = waitForEvent(tuiProcessRef, (e) => e.type === 'pane_read' && e.pane_id === paneId && !e.viewport, 10000);
789
- const sent = tuiProcessRef.send({
790
- type: 'read_pane',
791
- pane_id: paneId,
792
- lines,
793
- viewport: false,
794
- });
795
- if (!sent) {
796
- eventPromise.catch(() => { }); // prevent unhandled rejection from dangling timeout
797
- return 'Error: Failed to send command to zmux.';
798
- }
799
- try {
840
+ if (!Number.isSafeInteger(paneId) || paneId < 0) {
841
+ throw new Error('pane_id must be a non-negative integer.');
842
+ }
843
+ const lines = args.lines == null ? 200 : Number(args.lines);
844
+ if (!Number.isSafeInteger(lines) || lines < 1 || lines > 2000)
845
+ throw new Error('lines must be between 1 and 2000');
846
+ const viewport = args.viewport === true;
847
+ const requestId = randomUUID();
848
+ // Capture this transport and its protocol version for the entire request.
849
+ const transport = tuiProcessRef;
850
+ const correlated = transport.hasCapability?.(CAPABILITY_PANE_DELIVERY_ACK) === true;
851
+ const read = async () => {
852
+ const eventPromise = waitForEvent(transport, (e) => e.type === 'pane_read' && e.pane_id === paneId
853
+ && (correlated ? e.request_id === requestId
854
+ : (!e.request_id || e.request_id === requestId) && (e.viewport === true) === viewport), 10000, signal);
855
+ const sent = transport.send({
856
+ type: 'read_pane',
857
+ request_id: requestId,
858
+ pane_id: paneId,
859
+ lines,
860
+ viewport,
861
+ ansi: args.ansi === true,
862
+ });
863
+ if (!sent) {
864
+ eventPromise.catch(() => { });
865
+ waiting.abort();
866
+ throw new Error('Failed to send command to zmux.');
867
+ }
800
868
  const event = await eventPromise;
801
869
  if (event.type === 'pane_read') {
802
- if (!event.content || event.content.trim() === '') {
870
+ if (event.error)
871
+ throw new Error(event.error);
872
+ if (!event.content || event.content.trim() === '')
803
873
  return '(Terminal buffer is empty)';
804
- }
805
874
  return event.content;
806
875
  }
807
- return 'Error: Unexpected response from zmux.';
808
- }
809
- catch (err) {
810
- return `Error: ${err.message}`;
811
- }
876
+ throw new Error('Unexpected response from zmux.');
877
+ };
878
+ return correlated ? read() : serializeLegacyPaneRead(transport, paneId, context?.signal ?? new AbortController().signal, read);
812
879
  },
813
880
  };
814
881
  export const writeTerminalPaneTool = {
882
+ capabilities: [ToolCapability.SHELL_EXEC],
815
883
  definition: {
816
884
  name: 'write_terminal_pane',
817
885
  description: `Write input to a pane's running process. For shells and editors this sends terminal input; for ZTC child panes prefer send_message or ask_ztc_pane unless you specifically need keystroke-level control.
818
886
 
819
887
  The input is automatically submitted (Enter/Return is appended). You do NOT need to add \\n yourself — just provide the text you want to send.
820
888
 
821
- If you need to send raw keystrokes WITHOUT auto-submitting (rare), set submit=false.`,
889
+ For raw keystrokes set submit=false; input bytes are preserved and Enter is \\r (U+000D). To recover a stuck heredoc send the Ctrl+C character (U+0003) with submit=false. Delivery acknowledges terminal input only; inspect output to determine command success.`,
822
890
  parameters: {
823
891
  type: 'object',
824
892
  properties: {
@@ -838,17 +906,21 @@ If you need to send raw keystrokes WITHOUT auto-submitting (rare), set submit=fa
838
906
  required: ['pane_id', 'input'],
839
907
  },
840
908
  },
841
- execute: async (args) => {
909
+ execute: async (args, context) => {
910
+ const waiting = new AbortController();
911
+ const signal = context?.signal ? AbortSignal.any([context.signal, waiting.signal]) : waiting.signal;
912
+ if (signal.aborted)
913
+ throw new Error(PANE_WAIT_CANCELLED);
842
914
  if (!tuiProcessRef || !tuiProcessRef.isMuxMode) {
843
- return 'Error: Terminal panes are only available in zmux mode (--mux flag).';
915
+ throw new Error('Terminal panes require zmux (--mux). Use run_command for shell execution when available.');
844
916
  }
845
917
  const paneId = Number(args.pane_id);
846
- if (isNaN(paneId)) {
847
- return 'Error: pane_id must be a number.';
918
+ if (!Number.isSafeInteger(paneId) || paneId < 0) {
919
+ throw new Error('pane_id must be a non-negative integer.');
848
920
  }
849
921
  let input = String(args.input || '');
850
922
  if (!input) {
851
- return 'Error: input is required.';
923
+ throw new Error('input is required.');
852
924
  }
853
925
  const shouldSubmit = args.submit !== false && args.submit !== 'false';
854
926
  // Normalize line endings to LF and PRESERVE interior newlines so multi-line
@@ -857,21 +929,41 @@ If you need to send raw keystrokes WITHOUT auto-submitting (rare), set submit=fa
857
929
  // prompt into one line. The headless paneChild receiver delivers this as a
858
930
  // single JSON-line message (see HeadlessPaneManager.handleWritePane), so
859
931
  // interior newlines no longer fragment into premature submissions.
860
- input = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
861
932
  if (shouldSubmit) {
933
+ input = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
862
934
  // Strip trailing newline(s); `submit` alone controls the final Enter.
863
935
  input = input.replace(/\n+$/, '');
864
936
  }
937
+ const requestId = randomUUID();
938
+ const confirmed = tuiProcessRef.hasCapability?.(CAPABILITY_PANE_DELIVERY_ACK) === true;
939
+ const eventPromise = confirmed ? waitForEvent(tuiProcessRef, event => event.type === 'pane_write_result' && event.request_id === requestId && event.pane_id === paneId, 10000, signal) : null;
865
940
  const sent = tuiProcessRef.send({
866
941
  type: 'write_pane',
942
+ request_id: requestId,
867
943
  pane_id: paneId,
868
944
  input,
869
945
  submit: shouldSubmit,
870
946
  });
871
947
  if (!sent) {
872
- return 'Error: Failed to send command to zmux.';
873
- }
874
- return `Sent input to pane ${paneId}${shouldSubmit ? ' (submitted)' : ' (no submit)'}.`;
948
+ eventPromise?.catch(() => { });
949
+ waiting.abort();
950
+ throw new Error('Failed to send command to zmux.');
951
+ }
952
+ if (!eventPromise)
953
+ return `Input sent to pane ${paneId}, not confirmed: this renderer lacks delivery acknowledgements. Inspect the pane before retrying; the command may already have run.`;
954
+ const event = await eventPromise;
955
+ if (event.type !== 'pane_write_result')
956
+ throw new Error('Unexpected pane acknowledgement');
957
+ if (event.queued) {
958
+ const bytes = !event.encoding_pending && event.delivered_bytes !== undefined && event.total_bytes !== undefined
959
+ ? ` ${event.delivered_bytes} of ${event.total_bytes} bytes already delivered; the rest is queued.` : '';
960
+ return `Input queued for pane ${paneId}, not confirmed.${bytes} Do not resend blindly; read the pane to inspect progress.`;
961
+ }
962
+ if (!event.delivered) {
963
+ const bytes = event.partial && !event.cancelled ? `${event.delivered_bytes ?? 'Some'} of ${event.total_bytes ?? 'unknown'} bytes already delivered — do not resend blindly. ` : '';
964
+ throw new Error((event.error?.startsWith(bytes) ? '' : bytes) + (event.error || 'Pane input was not delivered'));
965
+ }
966
+ return `Input delivered to pane ${paneId}${shouldSubmit ? ' (submitted)' : ' (raw)'}. Delivery is not command completion; read the pane to inspect output.`;
875
967
  },
876
968
  };
877
969
  export const closeTerminalPaneTool = {
@@ -1630,4 +1722,9 @@ export const paneTools = [
1630
1722
  restartPaneTool,
1631
1723
  closeTerminalPaneTool,
1632
1724
  listTerminalPanesTool,
1633
- ];
1725
+ ].map(tool => ({ ...tool,
1726
+ // read_terminal_pane returns program output, which may itself begin with "Error:";
1727
+ // its own failures are thrown, so only tool-authored messages are classified.
1728
+ isErrorResult: tool === readTerminalPaneTool ? undefined : (content) => /^Error[: ]/i.test(content),
1729
+ capabilities: tool.capabilities ?? ([createTerminalPaneTool, createZtcPaneTool, restartPaneTool, closeTerminalPaneTool].includes(tool)
1730
+ ? [ToolCapability.SHELL_EXEC] : [ToolCapability.FILE_READ]), }));
@@ -67,6 +67,7 @@ export const recallToolResponseTool = {
67
67
  ok: true,
68
68
  tool_use_id: id,
69
69
  rehydrated: true,
70
+ meta: store.getMeta(id),
70
71
  message: `Full content for tool_use_id=${id} is now visible in the original tool_result above. Read it from there.`,
71
72
  });
72
73
  }
@@ -4,7 +4,7 @@
4
4
  * Splitting this out of index.ts avoids circular imports.
5
5
  */
6
6
  import { readFileTool, writeFileTool, editFileTool, listDirectoryTool } from './file.js';
7
- import { runCommandTool } from './shell.js';
7
+ import { runCommandTool, readCommandTool, stopCommandTool } from './shell.js';
8
8
  import { zergQueryTool, zergTaskStatusTool, zergListTool } from './zerg.js';
9
9
  import { zergWatchTool } from './zerg_watch.js';
10
10
  import { searchTool } from './search.js';
@@ -31,6 +31,8 @@ export const defaultTools = [
31
31
  listSkillsTool,
32
32
  invokeSkillTool,
33
33
  runCommandTool,
34
+ readCommandTool,
35
+ stopCommandTool,
34
36
  zergQueryTool,
35
37
  zergTaskStatusTool,
36
38
  zergListTool,
@@ -93,6 +93,7 @@ function normalizePermissionErrors(result) {
93
93
  }
94
94
  }
95
95
  export const searchTool = {
96
+ parallelSafe: true,
96
97
  capabilities: [ToolCapability.FILE_READ],
97
98
  definition: {
98
99
  name: 'search',
@@ -13,3 +13,5 @@ export declare function summarizeCommandFailure(output: string, limit?: number):
13
13
  */
14
14
  export declare function shellErrorHint(exitCode: number, stderr: string, hasStdout: boolean): string | null;
15
15
  export declare const runCommandTool: Tool;
16
+ export declare const readCommandTool: Tool;
17
+ export declare const stopCommandTool: Tool;
@@ -1,6 +1,8 @@
1
1
  import { resolve } from 'path';
2
2
  import { ToolCapability } from '../runtime/capabilities.js';
3
3
  import { runShellCommand } from '../../utils/shell.js';
4
+ import { startCommandJob, readCommandJob, stopCommandJob } from '../runtime/command_jobs.js';
5
+ import { resolveExecutionSettings } from '../../config/execution.js';
4
6
  import { configStore } from '../../config.js';
5
7
  import { assertTemporaryRootSafe, isPathWithinRoot } from '../../utils/temporary_write_root.js';
6
8
  export const FAILURE_OUTPUT_LIMIT = 2000;
@@ -46,8 +48,17 @@ export function shellErrorHint(exitCode, stderr, hasStdout) {
46
48
  }
47
49
  return null;
48
50
  }
51
+ function failedCommandReceipt(content) {
52
+ try {
53
+ return ['failed', 'cancelled', 'timed_out', 'interrupted'].includes(JSON.parse(content).status);
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
49
59
  // --- Shell Tool ---
50
60
  export const runCommandTool = {
61
+ isErrorResult: failedCommandReceipt,
51
62
  capabilities: [ToolCapability.SHELL_EXEC],
52
63
  definition: {
53
64
  name: 'run_command',
@@ -68,6 +79,8 @@ export const runCommandTool = {
68
79
  type: 'string',
69
80
  description: 'Timeout in milliseconds (default: 30000)'
70
81
  },
82
+ background: { type: 'boolean', description: 'Start a managed background job. Use read_command and stop_command with its job_id; survives a turn interrupt by default.' },
83
+ yield_time_ms: { type: 'number', description: 'Initial background wait, 0–60000ms (default 1000).' },
71
84
  stdin: {
72
85
  type: 'string',
73
86
  description: 'Optional text written to the command stdin. Prefer this over heredocs or nested quoting for multi-line payloads (e.g. git commit -F -).'
@@ -132,7 +145,7 @@ export const runCommandTool = {
132
145
  const stdin = typeof args.stdin === 'string'
133
146
  ? args.stdin
134
147
  : undefined;
135
- const result = await runShellCommand(command, cwd, context?.signal, timeoutMs, {
148
+ const shellOptions = {
136
149
  profileId,
137
150
  profile,
138
151
  sandbox,
@@ -140,7 +153,22 @@ export const runCommandTool = {
140
153
  temporaryWriteRoot,
141
154
  stdin,
142
155
  env: context?.subprocessEnv,
143
- });
156
+ };
157
+ if (args.background === true) {
158
+ if (temporaryWriteRoot)
159
+ throw new Error('Background commands are unavailable in temporary write-root workers. Run synchronously.');
160
+ if (!context?.sessionId)
161
+ throw new Error('Managed background commands require a session ID from the host.');
162
+ const wait = args.yield_time_ms === undefined ? 1000 : Number(args.yield_time_ms);
163
+ if (!Number.isSafeInteger(wait) || wait < 0 || wait > 60000)
164
+ throw new Error('yield_time_ms must be between 0 and 60000');
165
+ const settings = resolveExecutionSettings(config.execution);
166
+ const receipt = await startCommandJob({ sessionId: context.sessionId, command, cwd, timeoutMs,
167
+ shell: shellOptions, maxJobs: settings.maxBackgroundJobs,
168
+ signal: settings.interruptBackgroundJobs ? context.signal : undefined });
169
+ return JSON.stringify(await readCommandJob(context.sessionId, receipt.job_id, 0, wait, context.signal));
170
+ }
171
+ const result = await runShellCommand(command, cwd, context?.signal, timeoutMs, shellOptions);
144
172
  // Handle non-zero exit codes
145
173
  if (result.exitCode !== 0) {
146
174
  const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
@@ -160,3 +188,30 @@ export const runCommandTool = {
160
188
  });
161
189
  }
162
190
  };
191
+ export const readCommandTool = {
192
+ isErrorResult: failedCommandReceipt,
193
+ capabilities: [ToolCapability.FILE_READ],
194
+ definition: {
195
+ name: 'read_command', description: 'Read a managed command receipt and up to 16KB of incremental output. Cursor is a byte offset returned by the previous receipt. Wait is bounded; status running does not mean success.',
196
+ parameters: { type: 'object', properties: {
197
+ job_id: { type: 'string', description: 'Session-owned job ID from run_command.' }, cursor: { type: 'number', description: 'Byte cursor from the previous read (default 0).' }, wait_ms: { type: 'number', description: '0–60000ms; waits for completion or deadline.' },
198
+ }, required: ['job_id'] },
199
+ },
200
+ execute: async (args, context) => {
201
+ if (!context?.sessionId)
202
+ throw new Error('Managed commands require a session ID.');
203
+ return JSON.stringify(await readCommandJob(context.sessionId, String(args.job_id), Number(args.cursor ?? 0), Number(args.wait_ms ?? 0), context.signal));
204
+ },
205
+ };
206
+ export const stopCommandTool = {
207
+ capabilities: [ToolCapability.SHELL_EXEC],
208
+ definition: {
209
+ name: 'stop_command', description: 'Stop an owned local command process group and return its final receipt. Remote jobs must provide their own cleanup.',
210
+ parameters: { type: 'object', properties: { job_id: { type: 'string', description: 'Session-owned job ID from run_command.' } }, required: ['job_id'] },
211
+ },
212
+ execute: async (args, context) => {
213
+ if (!context?.sessionId)
214
+ throw new Error('Managed commands require a session ID.');
215
+ return JSON.stringify(await stopCommandJob(context.sessionId, String(args.job_id)));
216
+ },
217
+ };
@@ -6,6 +6,7 @@ export interface ToolContext {
6
6
  cwd: string;
7
7
  /** The model-provided tool-use id that originated this execution. */
8
8
  toolCallId?: string;
9
+ sessionId?: string;
9
10
  signal?: AbortSignal;
10
11
  temporaryWriteRoot?: string;
11
12
  /** Exact environment for shell-tool subprocesses; undefined inherits legacy ambient env. */
@@ -17,6 +18,9 @@ export interface ToolContext {
17
18
  }
18
19
  export interface Tool {
19
20
  definition: ToolDefinition;
21
+ /** Explicit scheduling opt-in; only read effects may run concurrently. */
22
+ parallelSafe?: boolean;
23
+ isErrorResult?: (content: string) => boolean;
20
24
  capabilities?: ToolCapability[];
21
25
  /** Host scheduling boundary. When omitted, capabilities are mapped
22
26
  * conservatively by `resolveToolEffect`. */
@@ -151,7 +151,7 @@ export class AgentRunLifecycle {
151
151
  this.abortController = controller;
152
152
  const isActive = () => this.activeRunId === runId;
153
153
  const cleanup = agent.on((event) => {
154
- if (!isActive())
154
+ if (!isActive() && (event.type !== 'history_checkpoint' || this.activeRunId !== null))
155
155
  return;
156
156
  onEvent(event);
157
157
  });
@@ -0,0 +1,13 @@
1
+ import type { Message } from '../types.js';
2
+ export interface TranscriptIteration {
3
+ start: number;
4
+ toolStart?: number;
5
+ }
6
+ /** Reconcile model history with the live display, keeping each iteration's arrival order. */
7
+ export declare function reconcileOrderedTranscript(options: {
8
+ live: readonly Message[];
9
+ ordered: readonly Message[];
10
+ iterations: readonly TranscriptIteration[];
11
+ toolIterations: ReadonlyMap<string, number>;
12
+ finalMessage?: Message;
13
+ }): Message[];
@@ -0,0 +1,55 @@
1
+ import { isPersistableAssistantMessage } from '../utils/assistant_message.js';
2
+ /** Reconcile model history with the live display, keeping each iteration's arrival order. */
3
+ export function reconcileOrderedTranscript(options) {
4
+ const { live, ordered, iterations, toolIterations, finalMessage } = options;
5
+ if (!iterations.length)
6
+ return [...live];
7
+ const byIteration = iterations.map(() => []);
8
+ let current = 0;
9
+ for (const message of ordered) {
10
+ if (message.toolCalls?.length)
11
+ current = toolIterations.get(message.toolCalls[0].id) ?? current;
12
+ byIteration[current].push(message);
13
+ }
14
+ const consumed = new Set(ordered.filter(message => message.role === 'user').map(message => message.id));
15
+ const output = live.slice(0, iterations[0].start);
16
+ for (let index = 0; index < iterations.length; index++) {
17
+ const frame = iterations[index];
18
+ const end = iterations[index + 1]?.start ?? live.length;
19
+ const segment = live.slice(frame.start, end).filter(message => !consumed.has(message.id));
20
+ const canonical = byIteration[index].find(message => message.toolCalls?.length);
21
+ if (canonical) {
22
+ const calls = canonical.toolCalls.map(({ result: _result, error: _error, ...call }) => call);
23
+ const beforeTools = live.slice(frame.start, frame.toolStart ?? end);
24
+ const anchorId = beforeTools.filter(isPersistableAssistantMessage).at(-1)?.id;
25
+ const anchor = segment.findIndex(message => message.id === anchorId);
26
+ if (anchor >= 0) {
27
+ segment[anchor] = { ...segment[anchor], toolCalls: calls,
28
+ metadata: { ...segment[anchor].metadata, ...canonical.metadata, toolCallsInTranscript: true } };
29
+ }
30
+ else {
31
+ // A tool-only response has no answer bubble to carry its API tool_use.
32
+ // Keep this record before the live trace without moving Thinking blocks.
33
+ const trace = segment.findIndex(message => message.role === 'tool');
34
+ const alreadyDisplayed = segment.filter(isPersistableAssistantMessage).map(message => message.content).join('') === canonical.content;
35
+ segment.splice(trace < 0 ? segment.length : trace, 0, { ...canonical,
36
+ content: alreadyDisplayed ? '[Using tools...]' : canonical.content,
37
+ toolCalls: calls, metadata: { ...canonical.metadata, toolCallsInTranscript: true } });
38
+ }
39
+ }
40
+ output.push(...segment, ...byIteration[index].filter(message => message !== canonical));
41
+ }
42
+ if (finalMessage) {
43
+ const finalFrame = iterations.length - 1;
44
+ const finalLive = live.slice(iterations[finalFrame].start).filter(isPersistableAssistantMessage);
45
+ const anchorId = finalLive.at(-1)?.id;
46
+ const anchor = output.findIndex(message => message.id === anchorId);
47
+ if (anchor >= 0) {
48
+ output[anchor] = { ...output[anchor], metadata: { ...output[anchor].metadata, ...finalMessage.metadata } };
49
+ }
50
+ else if (!byIteration[finalFrame].some(message => message.role === 'assistant' && message.content === finalMessage.content)) {
51
+ output.push(finalMessage);
52
+ }
53
+ }
54
+ return output;
55
+ }
@@ -1,10 +1,3 @@
1
- /**
2
- * RatatuiApp — the interactive TUI application class.
3
- *
4
- * Uses the Rust/Ratatui renderer for terminal rendering with proper
5
- * differential rendering. The CLI entry point that parses arguments
6
- * and launches this class lives in cli_main.ts.
7
- */
8
1
  import { TuiProcess, NullTuiProcess, HeadlessPaneManager } from './ui/ratatui/index.js';
9
2
  import { getMirrorCommandUsages, isRichSlashUsage } from './agent/commands/mirror_usages.js';
10
3
  import { type Session } from './session.js';
@@ -909,6 +902,8 @@ export declare class RatatuiApp {
909
902
  * its restricted tool surface must take precedence over that pane default.
910
903
  */
911
904
  private getEffectiveAgentMode;
905
+ private commandJobsUnsubscribe;
906
+ private commandJobStates;
912
907
  private createAgent;
913
908
  private awaitAgentBind;
914
909
  private bindAgentStore;