@zergai/ztc 0.2.7-dev.36236183737 → 0.3.1-dev.36465749722

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
Binary file
Binary file
Binary file
package/dist/App.js CHANGED
@@ -527,7 +527,21 @@ export const App = () => {
527
527
  return;
528
528
  try {
529
529
  streamedResponse.current = false;
530
- const result = await runAgent.run(requestMessages, signal);
530
+ const priorIds = new Set(requestMessages.map(message => message.id));
531
+ const stopCheckpoints = runAgent.on(event => {
532
+ if (event.type !== 'history_checkpoint')
533
+ return;
534
+ const ordered = event.messages;
535
+ setMessages(prev => [...prev.filter(message => priorIds.has(message.id)), ...ordered,
536
+ ...prev.filter(message => !priorIds.has(message.id) && (message.role === 'system' || message.role === 'tool'))]);
537
+ });
538
+ let result;
539
+ try {
540
+ result = await runAgent.run(requestMessages, signal);
541
+ }
542
+ finally {
543
+ stopCheckpoints();
544
+ }
531
545
  if (isActive && !isActive())
532
546
  return;
533
547
  if (!streamedResponse.current) {
@@ -539,7 +553,7 @@ export const App = () => {
539
553
  addMessage({
540
554
  role: 'assistant',
541
555
  content: result.content,
542
- toolCalls: result.toolCalls.length > 0 ? result.toolCalls : undefined,
556
+ toolCalls: result.turnMessages?.length ? undefined : result.toolCalls.length > 0 ? result.toolCalls : undefined,
543
557
  // Keep the provider's reasoning trace alongside usage: reasoning
544
558
  // models expect the assistant message replayed with its
545
559
  // reasoning_content, and dropping it here strips every later turn.
@@ -2,6 +2,8 @@ import { Message, ToolCall, AgentEvent } from '../types.js';
2
2
  import { AgentBackend, BackendRequest, TokenUsage } from './backends/index.js';
3
3
  import type { ClientToolCallResult } from './backends/types.js';
4
4
  import type { ApiRetrySettings } from '../config/types.js';
5
+ import { type ExecutionSettings } from '../config/execution.js';
6
+ import { type ExecutionContext } from './runtime/execution_context.js';
5
7
  import { Policy } from './runtime/policy.js';
6
8
  import { Tracer } from './runtime/tracing.js';
7
9
  import type { Tool } from './tools/types.js';
@@ -26,6 +28,14 @@ export interface AgentConfig {
26
28
  allow_fallbacks: boolean;
27
29
  };
28
30
  maxIterations?: number;
31
+ execution?: ExecutionSettings;
32
+ sessionId?: string;
33
+ runtimeFacts?: {
34
+ mode?: string;
35
+ mux?: boolean;
36
+ configScope?: string;
37
+ outputLimitSource?: string;
38
+ };
29
39
  /**
30
40
  * Retry/timeout policy for LLM requests. Renderer hosts normally leave this
31
41
  * undefined so the config-file setting applies; an explicit value wins.
@@ -54,8 +64,10 @@ export interface AgentConfig {
54
64
  */
55
65
  maxConsecutiveAutoContinues?: number;
56
66
  /**
57
- * Pin the first real user message into request.system for long single-task
58
- * sessions. Defaults to true. Hosted multi-turn chat should set false.
67
+ * Local task sessions: append current execution facts and any explicitly
68
+ * pinned goal to request.system. User messages are never automatically
69
+ * pinned. Hosted multi-turn chat should set false, which leaves
70
+ * request.system exactly as configured.
59
71
  */
60
72
  pinTaskHeader?: boolean;
61
73
  /**
@@ -105,7 +117,7 @@ export interface AgentRunResult {
105
117
  usage?: TokenUsage;
106
118
  stopReason: 'complete' | 'interactive' | 'max_iterations';
107
119
  reasoning?: string;
108
- /** Ordered model-facing messages produced during a turn that accepted steering. */
120
+ /** Ordered model-facing messages produced during every turn. */
109
121
  turnMessages?: Message[];
110
122
  }
111
123
  export interface HarnessToolCallResult {
@@ -240,11 +252,9 @@ export declare class Agent {
240
252
  */
241
253
  private _taskHeader;
242
254
  /**
243
- * Drop the pinned task so the NEXT conversation re-captures its own first user
244
- * message. Wired into the /clear-only path (clearTransientState), never into
255
+ * Drop the explicitly pinned task for the next conversation. Wired into the /clear-only path (clearTransientState), never into
245
256
  * /compact — the pin must survive compaction (that is the whole point of R7).
246
- * Null (not '') so systemWithTaskHeader recaptures rather than treating it as
247
- * "no task found."
257
+ * Null represents no explicitly pinned goal.
248
258
  */
249
259
  clearPinnedTask(): void;
250
260
  /**
@@ -257,10 +267,11 @@ export declare class Agent {
257
267
  * for the whole run. A driver that starts or switches a goal calls this so
258
268
  * request.system and the goal's per-turn re-injection name the same objective.
259
269
  *
260
- * A blank value falls back to recapturing, matching clearPinnedTask.
270
+ * A blank value clears the pin, matching clearPinnedTask.
261
271
  */
262
272
  setPinnedTask(task: string): void;
263
273
  /** Append the pinned task header (R7) to the static system prompt. */
274
+ getExecutionContext(): ExecutionContext;
264
275
  private systemWithTaskHeader;
265
276
  /**
266
277
  * Execute one terminal turn. A host binding makes the remote conversation the
@@ -268,6 +279,7 @@ export declare class Agent {
268
279
  * the same core loop used by unhosted ZTC.
269
280
  */
270
281
  run(messages: Message[], signal?: AbortSignal, takeSteering?: () => Message[]): Promise<AgentRunResult>;
282
+ private runBound;
271
283
  private runCore;
272
284
  /**
273
285
  * Release the backend. The backend lives as long as the Agent — the run loop
@@ -7,6 +7,11 @@ import { mergeTokenUsage } from './backends/token_usage.js';
7
7
  import { isTransportCodeError, isTransportMessageError } from './backends/transport_errors.js';
8
8
  import { inferDefaultProvider, getDefaultApiKeyForProvider, getDefaultModelForProvider } from '../config/defaults.js';
9
9
  import { resolveApiRetrySettings } from '../config/api_retry.js';
10
+ import { resolveExecutionSettings } from '../config/execution.js';
11
+ import { createRecoveryGuard } from './runtime/recovery.js';
12
+ import { listCommandJobs, interruptCommandJobs } from './runtime/command_jobs.js';
13
+ import { describeExecutionContext } from './runtime/execution_context.js';
14
+ import { scheduleToolBatch, commandStillRunning } from './runtime/tool_scheduler.js';
10
15
  import { AllowAllPolicy } from './runtime/policy.js';
11
16
  import { NoopTracer } from './runtime/tracing.js';
12
17
  import { defaultTools as baseDefaultTools } from './tools/registry.js';
@@ -208,6 +213,9 @@ export class Agent {
208
213
  maxTokens: config.maxTokens,
209
214
  routePlan: config.routePlan,
210
215
  maxIterations: config.maxIterations || 10,
216
+ execution: resolveExecutionSettings(config.execution),
217
+ sessionId: config.sessionId ?? randomUUID(),
218
+ runtimeFacts: config.runtimeFacts ?? {},
211
219
  apiRetry: resolveApiRetrySettings({ apiRetry: config.apiRetry }),
212
220
  llmRequestTimeoutMs: resolveLlmRequestTimeoutMs(config.llmRequestTimeoutMs, config.apiRetry?.requestTimeoutMs),
213
221
  tools: config.tools || (ambientDiscovery ? defaultTools : []),
@@ -574,6 +582,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
574
582
  .map(item => ({
575
583
  tool_use_id: item.tool_use_id,
576
584
  content: typeof item.content === 'string' ? item.content : String(item.content ?? ''),
585
+ is_error: item.is_error,
577
586
  }));
578
587
  }
579
588
  catch {
@@ -668,7 +677,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
668
677
  content = content.slice(0, SYNTH_RESULT_CAP) + `\n[truncated — ${content.length} chars total]`;
669
678
  }
670
679
  emittedToolResultIds.add(tc.id);
671
- return { tool_use_id: tc.id, content };
680
+ return { tool_use_id: tc.id, content, is_error: Boolean(tc.error) || tc.result == null };
672
681
  });
673
682
  if (toolResults.length === 0)
674
683
  continue;
@@ -687,12 +696,30 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
687
696
  }
688
697
  buildRequest(messages, availableTools = this.config.tools) {
689
698
  // Repair any orphaned tool_use blocks before building request
699
+ if (this.config.pinTaskHeader) {
700
+ const facts = describeExecutionContext({ ...this.getExecutionContext(), tools: availableTools.map(tool => tool.definition.name) });
701
+ const latestFacts = [...messages].reverse().find(message => typeof message.metadata?.executionContext === 'string')?.metadata?.executionContext;
702
+ if (facts !== latestFacts) {
703
+ const latestUser = [...messages].reverse().find(message => message.role === 'user');
704
+ if (latestUser)
705
+ latestUser.metadata = { ...latestUser.metadata, executionContext: facts };
706
+ }
707
+ }
690
708
  const repairedMessages = this.repairConversationHistory(messages);
709
+ // Facts are attached to the user/tool-result turn that observed them. Keep
710
+ // earlier snapshots immutable so provider threads can reuse their prefix.
711
+ const contextMessages = this.config.pinTaskHeader ? repairedMessages.flatMap(message => {
712
+ const facts = message.metadata?.executionContext;
713
+ return typeof facts === 'string' ? [message, {
714
+ id: `${message.id}_execution`, role: 'user', content: facts, timestamp: message.timestamp,
715
+ metadata: { synthetic: true },
716
+ }] : [message];
717
+ }) : repairedMessages;
691
718
  return {
692
719
  model: this.config.model,
693
720
  maxTokens: this.config.maxTokens,
694
721
  system: this.systemWithTaskHeader(messages),
695
- messages: this.formatMessages(repairedMessages),
722
+ messages: this.formatMessages(contextMessages),
696
723
  tools: getRequestToolDefinitions(availableTools, this.discoveredTools, this.config.deferToolSchemas !== false, this.alwaysLoadedToolNames),
697
724
  // A container pin identifies a cached prompt prefix, so the transport has to
698
725
  // know when this conversation is no longer sending the prefix its container
@@ -918,11 +945,9 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
918
945
  */
919
946
  _taskHeader = null;
920
947
  /**
921
- * Drop the pinned task so the NEXT conversation re-captures its own first user
922
- * message. Wired into the /clear-only path (clearTransientState), never into
948
+ * Drop the explicitly pinned task for the next conversation. Wired into the /clear-only path (clearTransientState), never into
923
949
  * /compact — the pin must survive compaction (that is the whole point of R7).
924
- * Null (not '') so systemWithTaskHeader recaptures rather than treating it as
925
- * "no task found."
950
+ * Null represents no explicitly pinned goal.
926
951
  */
927
952
  clearPinnedTask() {
928
953
  this._taskHeader = null;
@@ -937,23 +962,27 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
937
962
  * for the whole run. A driver that starts or switches a goal calls this so
938
963
  * request.system and the goal's per-turn re-injection name the same objective.
939
964
  *
940
- * A blank value falls back to recapturing, matching clearPinnedTask.
965
+ * A blank value clears the pin, matching clearPinnedTask.
941
966
  */
942
967
  setPinnedTask(task) {
943
968
  const trimmed = task.trim();
944
969
  this._taskHeader = trimmed.length > 0 ? trimmed : null;
945
970
  }
946
971
  /** Append the pinned task header (R7) to the static system prompt. */
947
- systemWithTaskHeader(messages) {
948
- if (!this.config.pinTaskHeader) {
972
+ getExecutionContext() {
973
+ return {
974
+ mode: this.config.runtimeFacts.mode ?? 'normal', cwd: this._cwd,
975
+ tools: this.getConfiguredToolNames(), mux: this.config.runtimeFacts.mux ?? false,
976
+ configScope: this.config.runtimeFacts.configScope ?? 'host configuration',
977
+ maxTokens: this.config.maxTokens,
978
+ outputLimitSource: this.config.runtimeFacts.outputLimitSource ?? (this.config.maxTokens === undefined ? 'provider default' : 'explicit configuration'),
979
+ settings: resolveExecutionSettings(this.config.execution), jobs: listCommandJobs(this.config.sessionId),
980
+ };
981
+ }
982
+ systemWithTaskHeader(_messages) {
983
+ // Hosted chat owns its system prompt: no local execution facts and no pin.
984
+ if (!this.config.pinTaskHeader)
949
985
  return this.config.systemPrompt;
950
- }
951
- if (this._taskHeader === null) {
952
- const firstUser = messages.find(m => m.role === 'user' && !m.metadata?.synthetic && !m.metadata?.toolResult && typeof m.content === 'string' && m.content.trim().length > 0);
953
- this._taskHeader = firstUser ? firstUser.content.trim().slice(0, 2000) : '';
954
- }
955
- // Shared loop_policy formatting (identical to SubAgent.run); returns the
956
- // base prompt unchanged when there is no captured task.
957
986
  return pinnedTaskSystem(this.config.systemPrompt, this._taskHeader);
958
987
  }
959
988
  /**
@@ -962,6 +991,17 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
962
991
  * the same core loop used by unhosted ZTC.
963
992
  */
964
993
  async run(messages, signal, takeSteering) {
994
+ const onInterrupt = () => { if (this.config.execution.interruptBackgroundJobs)
995
+ void interruptCommandJobs(this.config.sessionId); };
996
+ signal?.addEventListener('abort', onInterrupt, { once: true });
997
+ try {
998
+ return await this.runBound(messages, signal, takeSteering);
999
+ }
1000
+ finally {
1001
+ signal?.removeEventListener('abort', onInterrupt);
1002
+ }
1003
+ }
1004
+ async runBound(messages, signal, takeSteering) {
965
1005
  const binding = this.#conversationBinding;
966
1006
  if (!binding)
967
1007
  return this.runCore(messages, signal, takeSteering);
@@ -1048,7 +1088,6 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1048
1088
  }
1049
1089
  const allToolCalls = [];
1050
1090
  const turnMessages = [];
1051
- let acceptedSteering = false;
1052
1091
  let iterations = 0;
1053
1092
  // Each turn gets its own auto-continue budget; a long-running Agent must
1054
1093
  // not inherit one spent by the previous turn.
@@ -1072,6 +1111,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1072
1111
  // reproduced a byte-identical result as non-progressing (F8 churn / F1
1073
1112
  // re-fire). Result-equality (not raw repeat count) spares legitimate polling.
1074
1113
  const applyRepeatGuard = createRepeatGuard();
1114
+ const recovery = createRecoveryGuard(this.config.execution);
1075
1115
  try {
1076
1116
  while (iterations < this.config.maxIterations) {
1077
1117
  if (signal?.aborted) {
@@ -1189,10 +1229,6 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1189
1229
  stopReason = 'complete';
1190
1230
  break;
1191
1231
  }
1192
- // Claude requests multiple tool_use blocks in a single response only
1193
- // when they are independent, so running them concurrently is safe and
1194
- // significantly faster for multi-file reads, searches, etc.
1195
- //
1196
1232
  // Per-batch dedup for `recall_tool_response`: two recalls of the same
1197
1233
  // inner tool_use_id in one assistant turn share a single execute. The
1198
1234
  // outer toolBlock.id pairing is preserved for each block so the
@@ -1210,7 +1246,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1210
1246
  data: { tool: toolBlock.name },
1211
1247
  });
1212
1248
  const guarded = applyRepeatGuard(toolBlock.name, toolBlock.input, content);
1213
- return { toolCall, toolResult: { tool_use_id: toolBlock.id, content: guarded } };
1249
+ return { toolCall, toolResult: { tool_use_id: toolBlock.id, content: guarded, is_error: false } };
1214
1250
  };
1215
1251
  const recordToolError = (toolBlock, toolCall, error, effectiveArgs) => {
1216
1252
  toolCall.completedAt = new Date();
@@ -1229,6 +1265,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1229
1265
  toolResult: {
1230
1266
  tool_use_id: toolBlock.id,
1231
1267
  content: applyRepeatGuard(toolBlock.name, toolBlock.input, `Error: ${error}`),
1268
+ is_error: true,
1232
1269
  },
1233
1270
  };
1234
1271
  };
@@ -1245,7 +1282,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1245
1282
  // It must be the only executable call in this provider response; the
1246
1283
  // current agent is rebuilt before any subsequent iteration.
1247
1284
  const exclusiveTransitionId = toolUseBlocks.find((toolBlock) => toolBlock.name === 'plan_mode')?.id ?? null;
1248
- const toolPromises = toolUseBlocks.map(async (toolBlock) => {
1285
+ const combined = await scheduleToolBatch(toolUseBlocks, this.config.tools, this.config.execution, async (toolBlock, skipReason) => {
1249
1286
  const toolCall = {
1250
1287
  id: toolBlock.id,
1251
1288
  name: toolBlock.name,
@@ -1259,6 +1296,8 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1259
1296
  data: { tool: toolBlock.name }
1260
1297
  });
1261
1298
  this.emit({ type: 'tool_start', tool: toolBlock.name, args: toolBlock.input, toolCallId: toolBlock.id });
1299
+ if (skipReason)
1300
+ return recordToolError(toolBlock, toolCall, skipReason);
1262
1301
  if (exclusiveTransitionId !== null
1263
1302
  && toolBlock.id !== exclusiveTransitionId) {
1264
1303
  return recordToolError(toolBlock, toolCall, 'Skipped because plan_mode is an exclusive safety transition; retry after the plan agent is active.');
@@ -1298,6 +1337,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1298
1337
  context: {
1299
1338
  cwd: this._cwd,
1300
1339
  toolCallId: toolBlock.id,
1340
+ sessionId: this.config.sessionId,
1301
1341
  signal,
1302
1342
  toolResponseStore: this.toolResponseStore,
1303
1343
  discoveredTools: this.discoveredTools,
@@ -1321,6 +1361,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1321
1361
  context: {
1322
1362
  cwd: this._cwd,
1323
1363
  toolCallId: toolBlock.id,
1364
+ sessionId: this.config.sessionId,
1324
1365
  signal,
1325
1366
  toolResponseStore: this.toolResponseStore,
1326
1367
  discoveredTools: this.discoveredTools,
@@ -1344,21 +1385,17 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1344
1385
  const error = err.message;
1345
1386
  return recordToolError(toolBlock, toolCall, error);
1346
1387
  }
1347
- });
1348
- const combined = await Promise.all(toolPromises);
1388
+ }, result => result.toolCall.status === 'error' || commandStillRunning(result.toolResult.content), signal, exclusiveTransitionId);
1349
1389
  allToolCalls.push(...combined.map(r => r.toolCall));
1350
1390
  const partial = Boolean(signal?.aborted);
1351
1391
  const toolResults = combined.map(r => {
1352
1392
  const { rendered } = this.toolResponseStore.put(r.toolResult.tool_use_id, r.toolCall.name, r.toolResult.content, partial, {
1353
1393
  sourcePath: extractSourcePath(r.toolCall.name, r.toolCall.args),
1354
1394
  dedupable: r.toolCall.status === 'complete',
1395
+ isError: r.toolResult.is_error,
1355
1396
  });
1356
- return { tool_use_id: r.toolResult.tool_use_id, content: rendered };
1397
+ return { tool_use_id: r.toolResult.tool_use_id, content: rendered, is_error: r.toolResult.is_error };
1357
1398
  });
1358
- // Check abort after tool execution so cancel takes effect immediately
1359
- if (signal?.aborted) {
1360
- throw new AgentError('Cancelled', 'CANCELLED');
1361
- }
1362
1399
  // Real work happened, so the turn has demonstrably not stalled on
1363
1400
  // narration: give it a fresh continuation budget.
1364
1401
  this._consecutiveAutoContinues = 0;
@@ -1367,14 +1404,6 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1367
1404
  .filter(this.isTextBlock)
1368
1405
  .map(b => b.text)
1369
1406
  .join('\n');
1370
- // Stream any text content that came with tool calls (important for user feedback!)
1371
- if (textContent && textContent.trim()) {
1372
- if (!this._lastResponseStreamed)
1373
- await this.emitStreamText(textContent, signal);
1374
- // Last non-empty text wins — empty iterations preserve the prior value
1375
- // so maxIterations exhaustion still has something to return.
1376
- lastToolTurnText = textContent;
1377
- }
1378
1407
  const toolUseMessage = {
1379
1408
  id: `assistant_${Date.now()}`,
1380
1409
  role: 'assistant',
@@ -1394,6 +1423,29 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1394
1423
  };
1395
1424
  messages.push(toolResultMessage);
1396
1425
  turnMessages.push(toolResultMessage);
1426
+ this.emit({ type: 'history_checkpoint', messages: [...turnMessages] });
1427
+ if (signal?.aborted)
1428
+ throw new AgentError('Cancelled', 'CANCELLED');
1429
+ // Stream any text content that came with tool calls (important for user feedback!)
1430
+ if (textContent && textContent.trim() && !signal?.aborted) {
1431
+ if (!this._lastResponseStreamed)
1432
+ await this.emitStreamText(textContent, signal);
1433
+ // Last non-empty text wins — empty iterations preserve the prior value
1434
+ // so maxIterations exhaustion still has something to return.
1435
+ lastToolTurnText = textContent;
1436
+ }
1437
+ const recoveryAction = recovery(combined.map(result => ({ name: result.toolCall.name,
1438
+ content: result.toolResult.content, isError: result.toolResult.is_error })));
1439
+ if (recoveryAction === 'stop')
1440
+ throw new AgentError('Automatic recovery budget exhausted after repeated tool failures. Inspect the retained results and current capabilities before retrying.', 'RECOVERY_EXHAUSTED');
1441
+ if (recoveryAction === 'recover') {
1442
+ const recoveryMessage = { id: `recovery_${randomUUID()}`, role: 'user',
1443
+ content: '[Recovery] Repeated failures share the same cause. Stop that sequence. Inspect current capabilities and active jobs, then make one changed recovery attempt. Use file/stdin tools for scripts and raw Ctrl+C for stuck shell input, then read the pane in the same response or immediately afterward. A silent running-job poll allows one further inspection before stopping; do not repeat empty polls. Continue authorized work; use ask_user only for a concrete missing prerequisite. Do not repeat an acknowledgement as proof of success.',
1444
+ metadata: { synthetic: true }, timestamp: new Date() };
1445
+ messages.push(recoveryMessage);
1446
+ turnMessages.push(recoveryMessage);
1447
+ this.emit({ type: 'history_checkpoint', messages: [...turnMessages] });
1448
+ }
1397
1449
  // An explicit terminal tool may supply the answer after its bookkeeping
1398
1450
  // succeeds. Keep the tool-use/result pair above for conversation replay.
1399
1451
  if (combined.length === 1 && combined[0].toolCall.status === 'complete') {
@@ -1432,7 +1484,6 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1432
1484
  if (steering.length > 0) {
1433
1485
  messages.push(...steering);
1434
1486
  turnMessages.push(...steering);
1435
- acceptedSteering = true;
1436
1487
  }
1437
1488
  }
1438
1489
  }
@@ -1488,7 +1539,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1488
1539
  usage: totalUsage || undefined,
1489
1540
  stopReason,
1490
1541
  ...(finalReasoning ? { reasoning: finalReasoning } : {}),
1491
- ...(acceptedSteering ? { turnMessages } : {}),
1542
+ turnMessages,
1492
1543
  };
1493
1544
  }
1494
1545
  /**
@@ -1526,6 +1577,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1526
1577
  context: {
1527
1578
  cwd: this._cwd,
1528
1579
  toolCallId,
1580
+ sessionId: this.config.sessionId,
1529
1581
  signal: options.signal,
1530
1582
  toolResponseStore: this.toolResponseStore,
1531
1583
  discoveredTools: this.discoveredTools,
@@ -22,7 +22,7 @@ export class GeminiBackend extends BaseBackend {
22
22
  const resultText = typeof block.content === 'string'
23
23
  ? block.content
24
24
  : block.content.map(b => b.type === 'text' ? b.text : '[image]').join('\n');
25
- return { text: `Function result: ${resultText}` };
25
+ return { text: `Function ${block.tool_use_id} ${block.is_error ? 'error' : 'result'}: ${resultText}` };
26
26
  }
27
27
  if (block.type === 'tool_use') {
28
28
  // Convert tool_use to function call format for Gemini
@@ -36,7 +36,7 @@ export function buildOpenAIMessages(request) {
36
36
  toolUses.push({ id: block.id, name: block.name, input: block.input });
37
37
  }
38
38
  else if (block.type === 'tool_result') {
39
- toolResults.push({ tool_use_id: block.tool_use_id, content: block.content });
39
+ toolResults.push({ tool_use_id: block.tool_use_id, content: block.content, is_error: block.is_error });
40
40
  }
41
41
  else if (block.type === 'image') {
42
42
  mediaParts.push({
@@ -80,7 +80,7 @@ export function buildOpenAIMessages(request) {
80
80
  out.push({
81
81
  role: 'tool',
82
82
  tool_call_id: tr.tool_use_id,
83
- content: resultText,
83
+ content: tr.is_error && !/^error\b/i.test(resultText) ? `Error: ${resultText}` : resultText,
84
84
  });
85
85
  }
86
86
  // A tool_result merged with a later real user message: OpenAI still
@@ -179,7 +179,7 @@ export function buildResponsesInput(messages) {
179
179
  input.push({
180
180
  type: 'function_call_output',
181
181
  call_id: block.tool_use_id,
182
- output: resultText,
182
+ output: block.is_error && !/^error\b/i.test(resultText) ? `Error: ${resultText}` : resultText,
183
183
  });
184
184
  break;
185
185
  }
@@ -29,6 +29,7 @@ export interface ToolResultBlock {
29
29
  type: 'tool_result';
30
30
  tool_use_id: string;
31
31
  content: string | Array<RequestTextBlock | ToolResultImageBlock>;
32
+ is_error?: boolean;
32
33
  }
33
34
  export interface ToolUseRequestBlock {
34
35
  type: 'tool_use';
@@ -116,7 +116,7 @@ const STATUS_LINE_USAGE = [
116
116
  export const configCommand = {
117
117
  name: 'config',
118
118
  description: 'Manage configuration',
119
- usage: '[show|reset|scope|local|global|key|provider|endpoint|openai-compat|auth-mode|model|effort|display|spinner|statusline|goal-max-turns|manifests-dir|context-progress-bar|workflows] [value]',
119
+ usage: '[show|reset|scope|local|global|key|provider|endpoint|openai-compat|auth-mode|model|max-tokens|effort|display|spinner|statusline|goal-max-turns|manifests-dir|context-progress-bar|workflows] [value]',
120
120
  agentRunnable: true,
121
121
  category: 'config',
122
122
  handler: async (args, ctx) => {
@@ -139,6 +139,20 @@ export const configCommand = {
139
139
  return;
140
140
  }
141
141
  switch (subCmd) {
142
+ case 'max-tokens': {
143
+ const input = value.trim().toLowerCase();
144
+ if (input && input !== 'default' && (!/^[1-9]\d*$/.test(input) || !Number.isSafeInteger(Number(input)))) {
145
+ ctx.addMessage({ role: 'system', content: 'Max tokens must be a positive whole number, or default.' });
146
+ return;
147
+ }
148
+ if (input) {
149
+ ctx.config.set('maxTokens', input === 'default' ? undefined : Number(input));
150
+ await ctx.config.save();
151
+ ctx.reloadAgent();
152
+ }
153
+ ctx.addMessage({ role: 'system', content: `Output cap: ${ctx.config.get().maxTokens ?? 'provider default'}; source: ${ctx.config.getMaxTokensSource?.() ?? 'host configuration'}; config write scope: ${ctx.config.getConfigScope?.() ?? 'global'}. Use /config max-tokens <number|default>.` });
154
+ return;
155
+ }
142
156
  case 'reset': {
143
157
  if (rest.length > 0) {
144
158
  ctx.addMessage({ role: 'system', content: 'Usage: /config reset' });
@@ -1,3 +1,4 @@
1
+ import { resolveExecutionSettings } from '../../config/execution.js';
1
2
  import { accountsCommand } from './accounts.js';
2
3
  import { authCommand } from './auth.js';
3
4
  import { activeProfile, builtinProfileEmulation, listConfigProfiles } from '../../config/profiles.js';
@@ -209,6 +210,16 @@ export async function buildSettingsCategories(ctx) {
209
210
  },
210
211
  ],
211
212
  },
213
+ {
214
+ name: 'Execution',
215
+ settings: Object.entries(resolveExecutionSettings(config.execution)).map(([key, value]) => ({
216
+ name: `execution_${key}`, label: { shellPanePolicy: 'Shell pane creation', readConcurrency: 'Concurrent reads',
217
+ maxCallsPerResponse: 'Calls per response', recovery: 'Failure recovery', repeatedFailureLimit: 'Repeated failure threshold',
218
+ recoveryAttempts: 'Recovery attempts', interruptBackgroundJobs: 'Cancel background jobs on interrupt', maxBackgroundJobs: 'Background command limit' }[key],
219
+ value: String(value), type: typeof value === 'number' ? 'text' : 'select',
220
+ options: (key === 'shellPanePolicy' ? ['on_request', 'auto'] : key === 'recovery' ? ['automatic', 'pause'] : typeof value === 'boolean' ? ['false', 'true'] : []).map(option => ({ value: option, label: option })),
221
+ })),
222
+ },
212
223
  {
213
224
  name: 'Accounts',
214
225
  settings: [{
@@ -1025,6 +1036,18 @@ function openCustomPaneManager(ctx, reopen) {
1025
1036
  showList();
1026
1037
  }
1027
1038
  function applySettingChange(ctx, setting, value) {
1039
+ if (setting.startsWith('execution_')) {
1040
+ const key = setting.slice('execution_'.length);
1041
+ const current = resolveExecutionSettings(ctx.config.get().execution);
1042
+ if (!(key in current))
1043
+ throw new Error(`Unknown execution setting: ${key}`);
1044
+ const parsed = typeof current[key] === 'number' ? Number(value) : typeof current[key] === 'boolean' ? value === 'true' : value;
1045
+ const next = { ...current, [key]: parsed };
1046
+ if (String(resolveExecutionSettings(next)[key]) !== value)
1047
+ throw new Error(`Invalid value for ${key}: ${value}`);
1048
+ ctx.config.set('execution', next);
1049
+ return;
1050
+ }
1028
1051
  if (setting.startsWith('toolPermission_')) {
1029
1052
  const capability = setting.slice('toolPermission_'.length);
1030
1053
  if (!['file_read', 'file_write', 'shell_exec', 'network', 'screen_capture', 'computer_control', 'ml_inference'].includes(capability)) {
@@ -1,3 +1,4 @@
1
+ import { describeExecutionContext } from '../runtime/execution_context.js';
1
2
  import { resolveAuthMode } from '../../config.js';
2
3
  import { formatRuntimeSummary, getRuntimeInfo } from '../../runtime_info.js';
3
4
  import { getWorkflowRegistry } from '../../workflows/registry_handle.js';
@@ -44,10 +45,12 @@ export const statusCommand = {
44
45
  catch (error) {
45
46
  routeError = error instanceof Error ? error.message : String(error);
46
47
  }
48
+ const execution = ctx.getExecutionContext?.();
47
49
  ctx.addMessage({
48
50
  role: 'system',
49
51
  content: [
50
52
  'Status:',
53
+ ...(execution ? [describeExecutionContext(execution)] : []),
51
54
  ` API Key: ${hasKey ? '✓ configured' : '✗ not set (use /config key <key>)'}`,
52
55
  ` Platform Auth: ${isAuthenticated ? '✓ configured' : '✗ not set'}`,
53
56
  ` Provider: ${provider}`,
@@ -14,6 +14,7 @@ export interface ConfigController {
14
14
  set: <K extends keyof ZTCConfig>(key: K, value: ZTCConfig[K]) => void;
15
15
  setGlobal?: <K extends keyof ZTCConfig>(key: K, value: ZTCConfig[K]) => void;
16
16
  getConfigScope?: () => ZTCConfigScope;
17
+ getMaxTokensSource?: () => string;
17
18
  setConfigScope?: (scope: ZTCConfigScope) => void;
18
19
  clearSessionOverrides?: () => void;
19
20
  withConfigScope?: <T>(scope: ZTCConfigScope, fn: () => T | Promise<T>) => Promise<T>;
@@ -304,6 +305,7 @@ export interface CommandContext {
304
305
  exit: () => void;
305
306
  /** Current session ID. Used by /rename and other session-scoped commands. */
306
307
  getSessionId?: () => string;
308
+ getExecutionContext?: () => import('../runtime/execution_context.js').ExecutionContext | undefined;
307
309
  /** Current user-set session name (live, post-rename). Undefined if not named. */
308
310
  getSessionName?: () => string | undefined;
309
311
  /**
@@ -20,10 +20,10 @@ export function incompleteModelResponseMessage(stopReason, maxTokens, outputToke
20
20
  if (limited || exhausted) {
21
21
  if (maxTokens === undefined) {
22
22
  return 'Model reached the provider\'s output limit (including reasoning). '
23
- + 'Set maxTokens within the model\'s limit or lower reasoning effort, then retry; the incomplete response was not accepted.';
23
+ + 'Use /config max-tokens <number> within the model\'s limit or lower reasoning effort, then retry; completed tool work is retained and the incomplete response was not accepted.';
24
24
  }
25
25
  return `Model reached the output limit (maxTokens: ${maxTokens}, including reasoning). `
26
- + 'Increase maxTokens and retry; the incomplete response was not accepted.';
26
+ + 'Set /config max-tokens <number> or /config max-tokens default, then retry. Completed tool work is retained; the incomplete response was not accepted.';
27
27
  }
28
28
  return INCOMPLETE_MODEL_RESPONSE_MESSAGE;
29
29
  }
@@ -677,7 +677,7 @@ export function createAgentFromConfig(options) {
677
677
  emulationMappingPrompt,
678
678
  skillPrompt: effectiveSkillPrompt,
679
679
  modePrompt,
680
- muxEnabled: paneToolsEnabled,
680
+ muxEnabled: filteredToolNames.has('create_terminal_pane'),
681
681
  agentsContext: projectContext,
682
682
  runtimeContext,
683
683
  askUserAvailable: filteredToolNames.has('ask_user'),
@@ -715,6 +715,12 @@ export function createAgentFromConfig(options) {
715
715
  // Hosted runtimes never pin the task header; other callers may opt in.
716
716
  pinTaskHeader: hostRuntime ? false : options.pinTaskHeader,
717
717
  tools: filteredTools,
718
+ execution: options.config.execution,
719
+ sessionId: options.sessionId,
720
+ runtimeFacts: { mode, mux: Boolean(options.muxMode || effectivePaneMode),
721
+ configScope: configStore.getConfigScope(),
722
+ outputLimitSource: options.config.maxTokens === configStore.get().maxTokens ? configStore.getMaxTokensSource() : 'host configuration',
723
+ },
718
724
  // Agent-domain tools (ZTC_AGENT_DOMAIN) are the host's curated surface — ship
719
725
  // their schemas every turn instead of hiding them behind the deferred-tool
720
726
  // search corpus (which the mode allowlist can also strip).