@aragon-agent/cli 0.6.38 → 0.6.39

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 (156) hide show
  1. package/CHANGELOG.md +2523 -2326
  2. package/README.md +3614 -3173
  3. package/dist/agent/controller.d.ts +142 -1
  4. package/dist/agent/controller.js +171 -3
  5. package/dist/agent/reducer.d.ts +41 -1
  6. package/dist/agent/reducer.js +25 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +5 -0
  9. package/dist/cli.js +85 -27
  10. package/dist/commands/builtins.js +13 -1
  11. package/dist/commands/keys-command.d.ts +23 -0
  12. package/dist/commands/keys-command.js +38 -0
  13. package/dist/commands/registry.d.ts +39 -0
  14. package/dist/commands/task-command.d.ts +39 -0
  15. package/dist/commands/task-command.js +102 -0
  16. package/dist/config/cli-commands.d.ts +23 -0
  17. package/dist/config/cli-commands.js +36 -0
  18. package/dist/config/env.js +17 -4
  19. package/dist/config/load.js +6 -1
  20. package/dist/config/schema.d.ts +43 -4
  21. package/dist/config/schema.js +31 -5
  22. package/dist/config/store.js +17 -2
  23. package/dist/diagnostics/doctor.d.ts +9 -0
  24. package/dist/diagnostics/doctor.js +34 -0
  25. package/dist/diagnostics/info.d.ts +71 -0
  26. package/dist/diagnostics/info.js +91 -5
  27. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  28. package/dist/exec/bridge/caller-tools.js +213 -0
  29. package/dist/exec/bridge/decisions.d.ts +78 -0
  30. package/dist/exec/bridge/decisions.js +116 -0
  31. package/dist/exec/bridge/human-answers.d.ts +38 -0
  32. package/dist/exec/bridge/human-answers.js +164 -0
  33. package/dist/exec/bridge/limits.d.ts +68 -0
  34. package/dist/exec/bridge/limits.js +68 -0
  35. package/dist/exec/bridge/ndjson.d.ts +17 -0
  36. package/dist/exec/bridge/ndjson.js +83 -0
  37. package/dist/exec/bridge/pending.d.ts +70 -0
  38. package/dist/exec/bridge/pending.js +132 -0
  39. package/dist/exec/bridge/protocol.d.ts +346 -0
  40. package/dist/exec/bridge/protocol.js +256 -0
  41. package/dist/exec/bridge/server.d.ts +142 -0
  42. package/dist/exec/bridge/server.js +502 -0
  43. package/dist/exec/bridge/source.d.ts +58 -0
  44. package/dist/exec/bridge/source.js +145 -0
  45. package/dist/exec/bridge/writer.d.ts +26 -0
  46. package/dist/exec/bridge/writer.js +156 -0
  47. package/dist/exec/cli-commands.js +30 -2
  48. package/dist/exec/emitter.d.ts +16 -2
  49. package/dist/exec/emitter.js +26 -6
  50. package/dist/exec/events.d.ts +465 -10
  51. package/dist/exec/events.js +47 -1
  52. package/dist/exec/hooks/dispatch.d.ts +76 -0
  53. package/dist/exec/hooks/dispatch.js +169 -0
  54. package/dist/exec/hooks/index.d.ts +83 -0
  55. package/dist/exec/hooks/index.js +205 -0
  56. package/dist/exec/hooks/limits.d.ts +56 -0
  57. package/dist/exec/hooks/limits.js +49 -0
  58. package/dist/exec/hooks/registry.d.ts +49 -0
  59. package/dist/exec/hooks/registry.js +91 -0
  60. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  61. package/dist/exec/hooks/tool-wrapper.js +154 -0
  62. package/dist/exec/hooks/turn.d.ts +61 -0
  63. package/dist/exec/hooks/turn.js +59 -0
  64. package/dist/exec/hooks/validate.d.ts +85 -0
  65. package/dist/exec/hooks/validate.js +310 -0
  66. package/dist/exec/index.d.ts +130 -4
  67. package/dist/exec/index.js +501 -10
  68. package/dist/exec/options.d.ts +124 -2
  69. package/dist/exec/options.js +413 -6
  70. package/dist/exec/permission.d.ts +9 -1
  71. package/dist/exec/permission.js +7 -1
  72. package/dist/exec/request/limits.d.ts +51 -0
  73. package/dist/exec/request/limits.js +49 -0
  74. package/dist/exec/request/scope.d.ts +106 -0
  75. package/dist/exec/request/scope.js +149 -0
  76. package/dist/exec/result/limits.d.ts +74 -0
  77. package/dist/exec/result/limits.js +72 -0
  78. package/dist/exec/result/prompt.d.ts +28 -0
  79. package/dist/exec/result/prompt.js +74 -0
  80. package/dist/exec/result/schema.d.ts +99 -0
  81. package/dist/exec/result/schema.js +464 -0
  82. package/dist/exec/result/sink.d.ts +56 -0
  83. package/dist/exec/result/sink.js +50 -0
  84. package/dist/exec/result/tool.d.ts +37 -0
  85. package/dist/exec/result/tool.js +118 -0
  86. package/dist/exec/runner.d.ts +395 -1
  87. package/dist/exec/runner.js +860 -8
  88. package/dist/exec/threads/aggregate.d.ts +34 -0
  89. package/dist/exec/threads/aggregate.js +30 -0
  90. package/dist/exec/threads/capacity.d.ts +23 -0
  91. package/dist/exec/threads/capacity.js +38 -0
  92. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  93. package/dist/exec/threads/dispatch-source.js +106 -0
  94. package/dist/exec/threads/execute-threads.d.ts +53 -0
  95. package/dist/exec/threads/execute-threads.js +234 -0
  96. package/dist/exec/threads/gate.d.ts +42 -0
  97. package/dist/exec/threads/gate.js +81 -0
  98. package/dist/exec/threads/identity.d.ts +36 -0
  99. package/dist/exec/threads/identity.js +67 -0
  100. package/dist/exec/threads/interrupt.d.ts +29 -0
  101. package/dist/exec/threads/interrupt.js +79 -0
  102. package/dist/exec/threads/limits.d.ts +58 -0
  103. package/dist/exec/threads/limits.js +35 -0
  104. package/dist/exec/threads/outcome.d.ts +47 -0
  105. package/dist/exec/threads/outcome.js +26 -0
  106. package/dist/exec/threads/registry.d.ts +81 -0
  107. package/dist/exec/threads/registry.js +427 -0
  108. package/dist/exec/threads/runtime.d.ts +107 -0
  109. package/dist/exec/threads/runtime.js +523 -0
  110. package/dist/exec/threads/thread-source.d.ts +20 -0
  111. package/dist/exec/threads/thread-source.js +134 -0
  112. package/dist/fast/limits.d.ts +54 -0
  113. package/dist/fast/limits.js +54 -0
  114. package/dist/fast/summary-call.d.ts +72 -0
  115. package/dist/fast/summary-call.js +133 -0
  116. package/dist/fast/summary-prompt.d.ts +106 -0
  117. package/dist/fast/summary-prompt.js +320 -0
  118. package/dist/fast/task-summary-types.d.ts +66 -0
  119. package/dist/fast/task-summary-types.js +17 -0
  120. package/dist/logging/logger.d.ts +1 -1
  121. package/dist/proc/limits.d.ts +7 -0
  122. package/dist/proc/limits.js +7 -0
  123. package/dist/proc/output-streams.d.ts +50 -0
  124. package/dist/proc/output-streams.js +54 -0
  125. package/dist/team/runtime.d.ts +9 -0
  126. package/dist/team/runtime.js +8 -0
  127. package/dist/team/subagent.d.ts +15 -0
  128. package/dist/team/subagent.js +45 -1
  129. package/dist/tools/bash-tool.d.ts +10 -0
  130. package/dist/tools/bash-tool.js +71 -6
  131. package/dist/tools/index.d.ts +93 -1
  132. package/dist/tools/index.js +112 -3
  133. package/dist/tools/tool-gate.d.ts +100 -0
  134. package/dist/tools/tool-gate.js +87 -0
  135. package/dist/ui/App.js +224 -11
  136. package/dist/ui/Header.d.ts +28 -16
  137. package/dist/ui/Header.js +21 -19
  138. package/dist/ui/interaction-hints.js +6 -0
  139. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  140. package/dist/ui/layout/OverlayFrame.js +19 -4
  141. package/dist/ui/layout/header-hints.d.ts +55 -62
  142. package/dist/ui/layout/header-hints.js +59 -166
  143. package/dist/ui/layout/header-left.d.ts +115 -0
  144. package/dist/ui/layout/header-left.js +206 -0
  145. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  146. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  147. package/dist/ui/layout/status-detail-layout.js +5 -1
  148. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  149. package/dist/ui/layout/task-summary-cell.js +65 -0
  150. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  151. package/dist/ui/overlays/HelpOverlay.js +29 -2
  152. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  153. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  154. package/dist/ui/shortcuts.d.ts +81 -0
  155. package/dist/ui/shortcuts.js +221 -0
  156. package/package.json +2 -2
@@ -14,7 +14,20 @@
14
14
  * half-written event would corrupt every consumer. The caller writes the message
15
15
  * to stderr and exits.
16
16
  */
17
+ import { type HookTimeoutPolicy } from './hooks/limits.js';
17
18
  import type { ExecInputFormat, ExecOutputFormat, ExecPermissionMode } from './events.js';
19
+ /**
20
+ * What `resolveExecOptions` needs from the resolved config.
21
+ *
22
+ * THE ONLY FACT IT NEEDS IS `confirmTools`, and the reason it is a parameter
23
+ * rather than a second `loadConfig()` call is that this module is PURE by
24
+ * contract (see the file header): it decides about flags, never about the world.
25
+ * The default is `false` so every existing caller - and every test that passes one
26
+ * argument - keeps the exact behaviour it had.
27
+ */
28
+ export interface ExecOptionsContext {
29
+ confirmTools?: boolean;
30
+ }
18
31
  /** Raw commander output. Every field is whatever the user typed, or absent. */
19
32
  export interface RawExecOptions {
20
33
  outputFormat?: string;
@@ -32,6 +45,36 @@ export interface RawExecOptions {
32
45
  /** The hidden `--timeout` alias of `--max-duration` (P2-1 / D-16). */
33
46
  timeout?: string;
34
47
  permissionMode?: string;
48
+ /** `--decision-timeout <ms>`; bridge only. */
49
+ decisionTimeout?: string;
50
+ /** `--on-decision-timeout deny|error`; bridge only. */
51
+ onDecisionTimeout?: string;
52
+ /** `--caller-tool-timeout <ms>`; bridge only. */
53
+ callerToolTimeout?: string;
54
+ /**
55
+ * Path to the caller's JSON Schema (headless-request-scope-and-typed-result
56
+ * section 4.1). Not readable from here: this module resolves flags, never the
57
+ * world, so `exec/index.ts` opens the file and reports `result_schema_unreadable`.
58
+ */
59
+ resultSchema?: string;
60
+ /** `--result-repair-turns 0..2`. Needs `--result-schema`. */
61
+ resultRepairTurns?: string;
62
+ /** `--on-result-unsatisfied error|continue`. Needs `--result-schema`. */
63
+ onResultUnsatisfied?: string;
64
+ /** `--request-max-turns <n>`; bridge only. */
65
+ requestMaxTurns?: string;
66
+ /** `--request-max-duration <ms>`; bridge only. */
67
+ requestMaxDuration?: string;
68
+ /** `--threads`. Requires bridge input AND stream-json output. */
69
+ threads?: boolean;
70
+ /** `--max-open-threads <n>`; thread mode only. */
71
+ maxOpenThreads?: string;
72
+ /** `--thread-concurrency <n>`; thread mode only. */
73
+ threadConcurrency?: string;
74
+ /** `--hook-timeout <ms>`; bridge only. */
75
+ hookTimeout?: string;
76
+ /** `--on-hook-timeout deny|allow|error`; bridge only. */
77
+ onHookTimeout?: string;
35
78
  allowTool?: string[];
36
79
  denyTool?: string[];
37
80
  appendSystemPrompt?: string;
@@ -53,8 +96,52 @@ export interface ExecOptions {
53
96
  maxTurns?: number;
54
97
  maxDurationMs?: number;
55
98
  permissionMode: ExecPermissionMode;
99
+ /**
100
+ * The three bridge budgets, ALWAYS RESOLVED and only ever used under
101
+ * `--input-format bridge`. Defaulted here rather than left optional so no
102
+ * consumer has to repeat `?? 300000` and get a different number than the
103
+ * `BRIDGE_LIMITS` one.
104
+ */
105
+ decisionTimeoutMs: number;
106
+ onDecisionTimeout: ExecDecisionTimeout;
107
+ callerToolTimeoutMs: number;
56
108
  allowTools: string[];
57
109
  denyTools: string[];
110
+ /**
111
+ * The structured-result contract for this run (headless-request-scope-and-typed-
112
+ * result section 4.1). All three are absent unless `--result-schema` was given,
113
+ * which is what keeps the three output faces byte-identical without it (I-C1).
114
+ *
115
+ * `resultRepairTurns` and `onResultUnsatisfied` are RESOLVED HERE rather than left
116
+ * optional, for the reason the bridge budgets are: a consumer must not repeat
117
+ * `?? 1` and get a different number than `RESULT_LIMITS` says.
118
+ */
119
+ resultSchemaPath?: string;
120
+ resultRepairTurns?: number;
121
+ onResultUnsatisfied?: ExecResultUnsatisfiedMode;
122
+ /**
123
+ * The per-request budgets. Bridge only, and NEVER defaulted - "no ceiling" is the
124
+ * honest default for a long-running process, and a default ceiling would silently
125
+ * cut off a legitimate long request (section 9.7).
126
+ */
127
+ requestMaxTurns?: number;
128
+ requestMaxDurationMs?: number;
129
+ /**
130
+ * Thread mode (headless-threads-and-lifecycle-hooks section 4.1).
131
+ *
132
+ * `threads` is a BOOLEAN because the mode has no tuning of its own: the two ceilings below
133
+ * are per-run numbers with defaults that come from `THREAD_LIMITS`, resolved here for the
134
+ * same reason the bridge budgets are - no consumer may restate a default.
135
+ */
136
+ threads: boolean;
137
+ maxOpenThreads: number;
138
+ threadConcurrency: number;
139
+ /**
140
+ * `--hook-timeout`, ALWAYS RESOLVED and only consulted by a bridge run that registered a
141
+ * hook. Clamped to 30 s under `--on-hook-timeout allow` (section 2.4.8, review P2-1).
142
+ */
143
+ hookTimeoutMs: number;
144
+ onHookTimeout: HookTimeoutPolicy;
58
145
  appendSystemPrompt?: string;
59
146
  appendSystemPromptFile?: string;
60
147
  quiet: boolean;
@@ -64,7 +151,31 @@ export interface ExecOptions {
64
151
  */
65
152
  warnings: string[];
66
153
  }
67
- export type ExecUsageCode = 'invalid_output_format' | 'invalid_input_format' | 'invalid_permission_mode' | 'invalid_max_turns' | 'invalid_max_duration' | 'invalid_session_id' | 'unknown_tool' | 'unsupported_in_text_mode' | 'input_format_requires_stream_json' | 'stream_only_flag' | 'session_flag_conflict' | 'print_not_supported';
154
+ export type ExecUsageCode = 'invalid_output_format' | 'invalid_input_format' | 'invalid_permission_mode' | 'invalid_max_turns' | 'invalid_max_duration' | 'invalid_session_id' | 'unknown_tool' | 'unsupported_in_text_mode' | 'input_format_requires_stream_json' | 'stream_only_flag' | 'session_flag_conflict' | 'print_not_supported' | 'bridge_flag_requires_bridge' | 'ask_confirm_conflict' | 'invalid_decision_timeout' | 'invalid_caller_tool_timeout' | 'invalid_on_decision_timeout'
155
+ /**
156
+ * The structured-result vocabulary (section 4.1). The first five are decided HERE,
157
+ * from flags alone. The last three come from `exec/result/schema.ts` preflight and
158
+ * are reported by `exec/index.ts`, which is the module allowed to touch the disk -
159
+ * one union so a caller switching on the code has one list to read.
160
+ */
161
+ | 'result_flag_requires_schema' | 'invalid_result_repair_turns' | 'invalid_on_result_unsatisfied' | 'invalid_request_max_turns' | 'invalid_request_max_duration'
162
+ /**
163
+ * THE PREFLIGHT VOCABULARY (section 2.3.3 / 4.1), and it is eight members rather
164
+ * than three on purpose: `exec/result/schema.ts` decides these from the file's
165
+ * CONTENT, `exec/index.ts` decides `result_schema_unreadable` from the filesystem,
166
+ * and a caller switching on the code needs ONE list - including the five that only
167
+ * the parser can produce.
168
+ */
169
+ | 'result_schema_unreadable' | 'result_schema_too_large' | 'result_schema_invalid_json' | 'result_schema_not_object' | 'result_schema_too_deep' | 'result_schema_unsupported_ref' | 'result_schema_wrapper_conflict' | 'result_schema_composite_root'
170
+ /**
171
+ * The thread and hook vocabulary (headless-threads-and-lifecycle-hooks section 4.1).
172
+ *
173
+ * SEVEN MEMBERS, and the last two are the ones a caller meets first: a thread flag without
174
+ * `--threads` and a hook flag without bridge are the same class of mistake as
175
+ * `--decision-timeout` without a bridge, and this package answers that class with a usage
176
+ * error rather than a silent no-op.
177
+ */
178
+ | 'threads_require_bridge' | 'threads_require_stream_json' | 'threads_flag_conflict' | 'threads_flag_requires_threads' | 'invalid_thread_limit' | 'invalid_hook_timeout' | 'invalid_on_hook_timeout';
68
179
  export type ExecOptionsResult = {
69
180
  ok: true;
70
181
  options: ExecOptions;
@@ -73,4 +184,15 @@ export type ExecOptionsResult = {
73
184
  code: ExecUsageCode;
74
185
  message: string;
75
186
  };
76
- export declare function resolveExecOptions(raw: RawExecOptions): ExecOptionsResult;
187
+ /** `--on-decision-timeout`. There is deliberately no `allow` member. */
188
+ export type ExecDecisionTimeout = 'deny' | 'error';
189
+ /**
190
+ * `--on-result-unsatisfied error|continue` (section 2.3.7, rule 3).
191
+ *
192
+ * `error` IS THE DEFAULT BECAUSE A SINGLE-CALL EMBEDDER MUST NOT BE TOLD "SUCCESS"
193
+ * FOR AN ANSWER IT DID NOT GET, and it is also the ONLY value that ends the process.
194
+ * `continue` keeps the exit code at 0 but never makes `result.isError` false: the two
195
+ * signals a wrapper branches on must not be able to disagree.
196
+ */
197
+ export type ExecResultUnsatisfiedMode = 'error' | 'continue';
198
+ export declare function resolveExecOptions(raw: RawExecOptions, context?: ExecOptionsContext): ExecOptionsResult;
@@ -16,9 +16,25 @@
16
16
  */
17
17
  import { HOST_TOOL_NAMES } from '../tools/index.js';
18
18
  import { isValidSessionId } from '../session/store.js';
19
+ import { BRIDGE_LIMITS } from './bridge/limits.js';
20
+ import { RESULT_LIMITS } from './result/limits.js';
21
+ import { REQUEST_LIMITS } from './request/limits.js';
22
+ import { THREAD_LIMITS } from './threads/limits.js';
23
+ import { HOOK_LIMITS } from './hooks/limits.js';
19
24
  const OUTPUT_FORMATS = ['text', 'json', 'stream-json'];
20
- const INPUT_FORMATS = ['text', 'stream-json'];
21
- const PERMISSION_MODES = ['auto', 'plan', 'strict'];
25
+ const INPUT_FORMATS = ['text', 'stream-json', 'bridge'];
26
+ const PERMISSION_MODES = ['auto', 'plan', 'strict', 'ask'];
27
+ const DECISION_TIMEOUT_MODES = ['deny', 'error'];
28
+ const RESULT_UNSATISFIED_MODES = ['error', 'continue'];
29
+ /**
30
+ * `--on-hook-timeout deny|allow|error` (section 2.4.8).
31
+ *
32
+ * `deny` IS THE DEFAULT AND IT POINTS THE SAFE WAY: an unanswered hook is an unevaluatable
33
+ * policy, and letting the tool run would silently disable the redactor. `allow` exists
34
+ * because a purely observational hook that blocks a tool on timeout would be absurd - which
35
+ * is also why it is the one mode whose timeout is clamped.
36
+ */
37
+ const ON_HOOK_TIMEOUT_MODES = ['deny', 'allow', 'error'];
22
38
  /**
23
39
  * Registered nowhere in a headless session, yet real names (P2-2).
24
40
  *
@@ -47,6 +63,10 @@ const TEXT_MODE_REFUSED = [
47
63
  { flag: '--input-format stream-json', test: (o) => o.inputFormat === 'stream-json' },
48
64
  { flag: '--partial-messages', test: (o) => o.partialMessages === true },
49
65
  { flag: '--include-thinking', test: (o) => o.includeThinking === true },
66
+ // Text mode delegates to `runHeadless` VERBATIM, and `runHeadless` has never heard
67
+ // of `submit_result`: accepting this flag there would promise a contract nothing
68
+ // honours. Refused for the reason the four above are, not because it is new.
69
+ { flag: '--result-schema', test: (o) => o.resultSchema !== undefined },
50
70
  ];
51
71
  function usage(code, message) {
52
72
  return { ok: false, code, message };
@@ -71,7 +91,7 @@ function parsePositiveInt(raw) {
71
91
  const n = Number.parseInt(raw.trim(), 10);
72
92
  return Number.isSafeInteger(n) ? n : null;
73
93
  }
74
- export function resolveExecOptions(raw) {
94
+ export function resolveExecOptions(raw, context = {}) {
75
95
  if (raw.print) {
76
96
  return usage('print_not_supported', '`aragon exec` is already the headless face; `-p/--print` is not accepted here. ' +
77
97
  'Use `aragon exec --output-format text` for the same stdout as `aragon -p`.');
@@ -84,8 +104,12 @@ export function resolveExecOptions(raw) {
84
104
  if (!INPUT_FORMATS.includes(inputFormat)) {
85
105
  return usage('invalid_input_format', `Unknown --input-format "${inputFormat}". Choose: ${INPUT_FORMATS.join(', ')}.`);
86
106
  }
87
- if (inputFormat === 'stream-json' && outputFormat !== 'stream-json') {
88
- return usage('input_format_requires_stream_json', '--input-format stream-json requires --output-format stream-json.');
107
+ const genericInputFormat = inputFormat === 'stream-json' || inputFormat === 'bridge';
108
+ if (genericInputFormat && outputFormat !== 'stream-json') {
109
+ // ONE RULE, NAMED PER SPELLING. `bridge` needs the per-event writer for `seq`
110
+ // and for the pre-`init` queue, which is the same structural reason
111
+ // `stream-json` needs it - so the message quotes the flag actually typed.
112
+ return usage('input_format_requires_stream_json', `--input-format ${inputFormat} requires --output-format stream-json.`);
89
113
  }
90
114
  const formatError = checkFormatCompatibility(raw, outputFormat);
91
115
  if (formatError)
@@ -94,6 +118,70 @@ export function resolveExecOptions(raw) {
94
118
  if (!PERMISSION_MODES.includes(permissionMode)) {
95
119
  return usage('invalid_permission_mode', `Unknown --permission-mode "${permissionMode}". Choose: ${PERMISSION_MODES.join(', ')}.`);
96
120
  }
121
+ const bridge = inputFormat === 'bridge';
122
+ const bridgeFlagError = checkBridgeFlags(raw, bridge);
123
+ if (bridgeFlagError)
124
+ return bridgeFlagError;
125
+ // THE STRUCTURED-RESULT FLAGS, resolved before anything else reads them. Two of the
126
+ // five are meaningful only WITH `--result-schema`, and "you passed --on-result-
127
+ // unsatisfied without a schema" is a usage error rather than a silent no-op: a
128
+ // caller that meant to protect its output must not get a run that quietly cannot.
129
+ const structured = resolveStructuredResult(raw);
130
+ if (!structured.ok)
131
+ return structured.error;
132
+ const requestBudgets = resolveRequestBudgets(raw);
133
+ if (!requestBudgets.ok)
134
+ return requestBudgets.error;
135
+ // THREADS AND HOOKS, IN THIS ORDER. Hook state is legal WITHOUT threads - "one
136
+ // conversation with one redactor" is the commonest embedding shape - so the two are
137
+ // resolved independently and neither implies the other (section 9.9).
138
+ const policy = resolveHookPolicy(raw);
139
+ if (!policy.ok)
140
+ return policy.error;
141
+ const threads = resolveThreads(raw, bridge, outputFormat);
142
+ if (!threads.ok)
143
+ return threads.error;
144
+ const hooks = resolveHooks(raw, policy.value);
145
+ if (!hooks.ok)
146
+ return hooks.error;
147
+ // THE FOURTH BASELINE NEEDS SOMEWHERE TO ASK. Without bridge there is no
148
+ // decision channel at all, so `ask` would silently degrade to `auto` - the
149
+ // worst of the three possible outcomes (run unasked, refuse everything, or say
150
+ // the invocation is wrong). `exec/options.ts:11-15` settles the choice.
151
+ if (permissionMode === 'ask' && !bridge) {
152
+ return usage('bridge_flag_requires_bridge', '--permission-mode ask requires --input-format bridge: there is nobody to answer.');
153
+ }
154
+ // TWO APPROVAL LAYERS WOULD ASK TWICE, which a user experiences as "no
155
+ // approval at all". They cover the same three mutating tools; `skill_create` is
156
+ // reachable only through the bridge gate, so nothing is left unguarded.
157
+ if (permissionMode === 'ask' && context.confirmTools === true) {
158
+ return usage('ask_confirm_conflict', '--permission-mode ask and --confirm both gate the same three tools. Pick one.');
159
+ }
160
+ const decisionTimeout = resolveMilliseconds({
161
+ raw: raw.decisionTimeout,
162
+ flag: '--decision-timeout',
163
+ min: 1_000,
164
+ max: BRIDGE_LIMITS.maxDecisionTimeoutMs,
165
+ fallback: BRIDGE_LIMITS.defaultDecisionTimeoutMs,
166
+ code: 'invalid_decision_timeout',
167
+ });
168
+ if (!decisionTimeout.ok)
169
+ return decisionTimeout.error;
170
+ const callerToolTimeout = resolveMilliseconds({
171
+ raw: raw.callerToolTimeout,
172
+ flag: '--caller-tool-timeout',
173
+ min: 1_000,
174
+ max: BRIDGE_LIMITS.maxCallerToolTimeoutMs,
175
+ fallback: BRIDGE_LIMITS.defaultCallerToolTimeoutMs,
176
+ code: 'invalid_caller_tool_timeout',
177
+ });
178
+ if (!callerToolTimeout.ok)
179
+ return callerToolTimeout.error;
180
+ const onDecisionTimeout = (raw.onDecisionTimeout ?? 'deny').trim();
181
+ if (!DECISION_TIMEOUT_MODES.includes(onDecisionTimeout)) {
182
+ return usage('invalid_on_decision_timeout', `Unknown --on-decision-timeout "${onDecisionTimeout}". ` +
183
+ `Choose: ${DECISION_TIMEOUT_MODES.join(', ')}.`);
184
+ }
97
185
  const budgets = resolveBudgets(raw);
98
186
  if (!budgets.ok)
99
187
  return budgets.error;
@@ -106,11 +194,34 @@ export function resolveExecOptions(raw) {
106
194
  if (toolError)
107
195
  return toolError;
108
196
  const warnings = [];
109
- for (const name of allowTools) {
197
+ for (const name of bridge ? [] : allowTools) {
110
198
  if (NEVER_REGISTERED_HEADLESS.includes(name)) {
111
199
  warnings.push(`--allow-tool ${name}: that tool is never registered in a headless run, so allowing it has no effect.`);
112
200
  }
113
201
  }
202
+ // THE THREE INERT-OR-MISLEADING COMBINATIONS (section 4.1, W-1..W-3). All three are
203
+ // warnings and none changes the exit code: each one describes a run that may still
204
+ // do exactly what the caller wanted, and refusing it would be overreach.
205
+ if (structured.resultSchemaPath !== undefined) {
206
+ if (budgets.maxTurns === 1) {
207
+ // W-1. One turn CAN be enough (the model may submit on it), so this is not an
208
+ // error - but the number is almost certainly a mistake.
209
+ warnings.push('--max-turns 1 with --result-schema: a structured result needs at least 2 turns ' +
210
+ '(submitting is itself a turn).');
211
+ }
212
+ if (requestBudgets.requestMaxTurns === 1) {
213
+ // W-2. Names the exact shape of the resulting `request_end` (P1-9), so the
214
+ // "two fields saying two different things" outcome is announced up front
215
+ // rather than discovered in a log.
216
+ warnings.push('--request-max-turns 1 with --result-schema: this request will end with ' +
217
+ "stopReason 'max_turns' and error.code 'result_schema_unsatisfied'.");
218
+ }
219
+ }
220
+ if (structured.resultSchemaPath === undefined && denyTools.includes('submit_result')) {
221
+ // W-3. `submit_result` is registered ONLY by `--result-schema`, so denying it here
222
+ // denies a name that will never exist. Same argument as NEVER_REGISTERED_HEADLESS.
223
+ warnings.push('--deny-tool submit_result: that tool is registered only under --result-schema, so this deny has no effect.');
224
+ }
114
225
  return {
115
226
  ok: true,
116
227
  options: {
@@ -128,6 +239,27 @@ export function resolveExecOptions(raw) {
128
239
  ...(budgets.maxTurns !== undefined ? { maxTurns: budgets.maxTurns } : {}),
129
240
  ...(budgets.maxDurationMs !== undefined ? { maxDurationMs: budgets.maxDurationMs } : {}),
130
241
  permissionMode: permissionMode,
242
+ decisionTimeoutMs: decisionTimeout.value,
243
+ onDecisionTimeout: onDecisionTimeout,
244
+ callerToolTimeoutMs: callerToolTimeout.value,
245
+ ...(structured.resultSchemaPath !== undefined
246
+ ? {
247
+ resultSchemaPath: structured.resultSchemaPath,
248
+ resultRepairTurns: structured.resultRepairTurns,
249
+ onResultUnsatisfied: structured.onResultUnsatisfied,
250
+ }
251
+ : {}),
252
+ ...(requestBudgets.requestMaxTurns !== undefined
253
+ ? { requestMaxTurns: requestBudgets.requestMaxTurns }
254
+ : {}),
255
+ ...(requestBudgets.requestMaxDurationMs !== undefined
256
+ ? { requestMaxDurationMs: requestBudgets.requestMaxDurationMs }
257
+ : {}),
258
+ threads: threads.threads,
259
+ maxOpenThreads: threads.maxOpenThreads,
260
+ threadConcurrency: threads.threadConcurrency,
261
+ hookTimeoutMs: hooks.hookTimeoutMs,
262
+ onHookTimeout: policy.value,
131
263
  allowTools,
132
264
  denyTools,
133
265
  ...(raw.appendSystemPrompt ? { appendSystemPrompt: raw.appendSystemPrompt } : {}),
@@ -189,6 +321,281 @@ function resolveBudgets(raw) {
189
321
  ...(maxDurationMs !== undefined ? { maxDurationMs } : {}),
190
322
  };
191
323
  }
324
+ /**
325
+ * A bridge-only flag without `--input-format bridge` is a USAGE ERROR.
326
+ *
327
+ * NOT A WARNING, and not a silent no-op: these three flags tune a channel that
328
+ * does not exist in any other mode, so accepting them would promise a ceiling
329
+ * that is never consulted. This project's rule for that case is the same one
330
+ * `checkFormatCompatibility` applies to `--partial-messages` in `json` mode.
331
+ */
332
+ function checkBridgeFlags(raw, bridge) {
333
+ if (bridge)
334
+ return null;
335
+ const present = [
336
+ raw.decisionTimeout !== undefined ? '--decision-timeout' : null,
337
+ raw.onDecisionTimeout !== undefined ? '--on-decision-timeout' : null,
338
+ raw.callerToolTimeout !== undefined ? '--caller-tool-timeout' : null,
339
+ // The two REQUEST-LEVEL budgets join the same rule for the same reason: they tune
340
+ // a per-request channel that does not exist outside bridge mode, and accepting
341
+ // them elsewhere would promise a ceiling nothing consults.
342
+ raw.requestMaxTurns !== undefined ? '--request-max-turns' : null,
343
+ raw.requestMaxDuration !== undefined ? '--request-max-duration' : null,
344
+ // The two HOOK flags join it too: `hook_register` is a bridge frame, so a timeout for a
345
+ // hook that can never be registered is the same inert promise.
346
+ raw.hookTimeout !== undefined ? '--hook-timeout' : null,
347
+ raw.onHookTimeout !== undefined ? '--on-hook-timeout' : null,
348
+ ].filter((flag) => flag !== null);
349
+ if (present.length === 0)
350
+ return null;
351
+ const subject = present.join(' and ');
352
+ const verb = present.length === 1 ? 'requires' : 'require';
353
+ return usage('bridge_flag_requires_bridge', `${subject} ${verb} --input-format bridge.`);
354
+ }
355
+ /**
356
+ * Resolve `--result-schema`, `--result-repair-turns` and `--on-result-unsatisfied`.
357
+ *
358
+ * THE FILE IS NOT OPENED HERE. Its contents are the world, and this module is the
359
+ * flag parser: `exec/index.ts` reads it and reports `result_schema_unreadable`, which
360
+ * is the same split `--prompt-file` already has.
361
+ */
362
+ function resolveStructuredResult(raw) {
363
+ const hasSchema = raw.resultSchema !== undefined;
364
+ if (!hasSchema) {
365
+ const orphan = [
366
+ raw.resultRepairTurns !== undefined ? '--result-repair-turns' : null,
367
+ raw.onResultUnsatisfied !== undefined ? '--on-result-unsatisfied' : null,
368
+ ].filter((flag) => flag !== null);
369
+ if (orphan.length > 0) {
370
+ // A USAGE ERROR, NOT A WARNING: these two exist only to shape an answer that
371
+ // will never be requested, so "the run ignores you" would be the whole outcome.
372
+ return {
373
+ ok: false,
374
+ error: usage('result_flag_requires_schema', `${orphan.join(' and ')} ${orphan.length === 1 ? 'requires' : 'require'} --result-schema.`),
375
+ };
376
+ }
377
+ return {
378
+ ok: true,
379
+ resultRepairTurns: RESULT_LIMITS.defaultRepairTurns,
380
+ onResultUnsatisfied: 'error',
381
+ };
382
+ }
383
+ let repairTurns = RESULT_LIMITS.defaultRepairTurns;
384
+ if (raw.resultRepairTurns !== undefined) {
385
+ const parsed = parsePositiveInt(raw.resultRepairTurns);
386
+ if (parsed === null || parsed > RESULT_LIMITS.maxRepairTurns) {
387
+ return {
388
+ ok: false,
389
+ error: usage('invalid_result_repair_turns', `--result-repair-turns must be an integer between 0 and ${RESULT_LIMITS.maxRepairTurns}.`),
390
+ };
391
+ }
392
+ repairTurns = parsed;
393
+ }
394
+ const mode = (raw.onResultUnsatisfied ?? 'error').trim();
395
+ if (!RESULT_UNSATISFIED_MODES.includes(mode)) {
396
+ return {
397
+ ok: false,
398
+ error: usage('invalid_on_result_unsatisfied', `Unknown --on-result-unsatisfied "${mode}". Choose: ${RESULT_UNSATISFIED_MODES.join(', ')}.`),
399
+ };
400
+ }
401
+ return {
402
+ ok: true,
403
+ resultSchemaPath: raw.resultSchema,
404
+ resultRepairTurns: repairTurns,
405
+ onResultUnsatisfied: mode,
406
+ };
407
+ }
408
+ /** The two per-request ceilings (section 4.1). Bridge-only, checked one level up. */
409
+ function resolveRequestBudgets(raw) {
410
+ let requestMaxTurns;
411
+ if (raw.requestMaxTurns !== undefined) {
412
+ const parsed = parsePositiveInt(raw.requestMaxTurns);
413
+ if (parsed === null || parsed < 1 || parsed > REQUEST_LIMITS.maxRequestTurns) {
414
+ return {
415
+ ok: false,
416
+ error: usage('invalid_request_max_turns', `--request-max-turns must be an integer between 1 and ${REQUEST_LIMITS.maxRequestTurns}.`),
417
+ };
418
+ }
419
+ requestMaxTurns = parsed;
420
+ }
421
+ let requestMaxDurationMs;
422
+ if (raw.requestMaxDuration !== undefined) {
423
+ const parsed = parsePositiveInt(raw.requestMaxDuration);
424
+ if (parsed === null ||
425
+ parsed < REQUEST_LIMITS.minRequestTimeoutMs ||
426
+ parsed > REQUEST_LIMITS.maxRequestTimeoutMs) {
427
+ return {
428
+ ok: false,
429
+ error: usage('invalid_request_max_duration', `--request-max-duration must be an integer between ` +
430
+ `${REQUEST_LIMITS.minRequestTimeoutMs} and ${REQUEST_LIMITS.maxRequestTimeoutMs} ` +
431
+ '(milliseconds).'),
432
+ };
433
+ }
434
+ requestMaxDurationMs = parsed;
435
+ }
436
+ return {
437
+ ok: true,
438
+ ...(requestMaxTurns !== undefined ? { requestMaxTurns } : {}),
439
+ ...(requestMaxDurationMs !== undefined ? { requestMaxDurationMs } : {}),
440
+ };
441
+ }
442
+ /**
443
+ * Parse a bounded millisecond flag, or fall back to the protocol default.
444
+ *
445
+ * BOTH ENDS ARE CHECKED (review P2-5). A lower bound alone would let
446
+ * `--decision-timeout 999999999` through, and I-6 ("every ceiling has a number")
447
+ * would then be false for the one ceiling a caller can set - the number would be
448
+ * a literal in a flag parser rather than a member of `BRIDGE_LIMITS`.
449
+ */
450
+ function resolveMilliseconds(options) {
451
+ if (options.raw === undefined)
452
+ return { ok: true, value: options.fallback };
453
+ const parsed = parsePositiveInt(options.raw);
454
+ if (parsed === null || parsed < options.min || parsed > options.max) {
455
+ return {
456
+ ok: false,
457
+ error: usage(options.code, `${options.flag} must be an integer between ${options.min} and ${options.max} ` +
458
+ '(milliseconds).'),
459
+ };
460
+ }
461
+ return { ok: true, value: parsed };
462
+ }
463
+ /**
464
+ * `--threads` and its two ceilings (section 4.1).
465
+ *
466
+ * THREE CROSS-CHECKS, and each one names BOTH offenders: `--threads` without bridge has no
467
+ * frame channel, without `stream-json` has no per-event writer (so no `seq` and no
468
+ * pre-`init` queue), and with `--max-turns` would apply a process-wide turn budget to a
469
+ * conversation it cannot describe - the honest advice there is `--request-max-turns`.
470
+ */
471
+ function resolveThreads(raw, bridge, outputFormat) {
472
+ const maxOpenThreads = resolveThreadLimit({
473
+ raw: raw.maxOpenThreads,
474
+ flag: '--max-open-threads',
475
+ fallback: THREAD_LIMITS.defaultMaxOpenThreads,
476
+ min: 1,
477
+ max: THREAD_LIMITS.maxOpenThreads,
478
+ });
479
+ if (!maxOpenThreads.ok)
480
+ return { ok: false, error: maxOpenThreads.error };
481
+ const threadConcurrency = resolveThreadLimit({
482
+ raw: raw.threadConcurrency,
483
+ flag: '--thread-concurrency',
484
+ fallback: THREAD_LIMITS.defaultConcurrency,
485
+ min: 1,
486
+ max: THREAD_LIMITS.maxOpenThreads,
487
+ });
488
+ if (!threadConcurrency.ok)
489
+ return { ok: false, error: threadConcurrency.error };
490
+ if (threadConcurrency.value > maxOpenThreads.value) {
491
+ return {
492
+ ok: false,
493
+ error: usage('invalid_thread_limit', `--thread-concurrency (${threadConcurrency.value}) cannot exceed --max-open-threads ` +
494
+ `(${maxOpenThreads.value}): there would be more slots than threads.`),
495
+ };
496
+ }
497
+ const ceilingGiven = raw.maxOpenThreads !== undefined || raw.threadConcurrency !== undefined;
498
+ if (ceilingGiven && raw.threads !== true) {
499
+ // A CEILING FOR A MODE THAT IS OFF IS THE SAME INERT PROMISE as a bridge flag without a
500
+ // bridge, and this package answers that class with a usage error rather than a no-op.
501
+ return {
502
+ ok: false,
503
+ error: usage('threads_flag_requires_threads', `${raw.maxOpenThreads !== undefined ? '--max-open-threads' : '--thread-concurrency'} ` +
504
+ 'only applies with --threads.'),
505
+ };
506
+ }
507
+ if (raw.threads !== true) {
508
+ return {
509
+ ok: true,
510
+ threads: false,
511
+ maxOpenThreads: maxOpenThreads.value,
512
+ threadConcurrency: threadConcurrency.value,
513
+ };
514
+ }
515
+ if (!bridge) {
516
+ return {
517
+ ok: false,
518
+ error: usage('threads_require_bridge', '--threads requires --input-format bridge: a thread is opened, prompted and closed ' +
519
+ 'through the frame channel, which no other input format has.'),
520
+ };
521
+ }
522
+ if (outputFormat !== 'stream-json') {
523
+ return {
524
+ ok: false,
525
+ error: usage('threads_require_stream_json', '--threads requires --output-format stream-json: thread events and their `threadId` ' +
526
+ 'need the per-event writer.'),
527
+ };
528
+ }
529
+ if (raw.maxTurns !== undefined) {
530
+ return {
531
+ ok: false,
532
+ error: usage('threads_flag_conflict', '--max-turns cannot be combined with --threads: one process-wide turn budget has no ' +
533
+ 'honest meaning across N conversations. Use --request-max-turns instead.'),
534
+ };
535
+ }
536
+ return {
537
+ ok: true,
538
+ threads: true,
539
+ maxOpenThreads: maxOpenThreads.value,
540
+ threadConcurrency: threadConcurrency.value,
541
+ };
542
+ }
543
+ function resolveThreadLimit(options) {
544
+ if (options.raw === undefined)
545
+ return { ok: true, value: options.fallback };
546
+ const parsed = parsePositiveInt(options.raw);
547
+ if (parsed === null || parsed < options.min || parsed > options.max) {
548
+ return {
549
+ ok: false,
550
+ error: usage('invalid_thread_limit', `${options.flag} must be an integer between ${options.min} and ${options.max}.`),
551
+ };
552
+ }
553
+ return { ok: true, value: parsed };
554
+ }
555
+ /**
556
+ * `--on-hook-timeout`, alone in its own resolver.
557
+ *
558
+ * A HOOK POLICY IS NOT A THREAD CEILING, and the first version of this file parsed it inside
559
+ * `resolveThreads` because the mode has to be known before the timeout is clamped. That put a
560
+ * hook rule in a thread function and made `--on-hook-timeout` unreadable to anyone looking for
561
+ * it under "hooks". The dependency is the other way round: the policy is resolved first and
562
+ * handed to `resolveHooks`.
563
+ */
564
+ function resolveHookPolicy(raw) {
565
+ const mode = (raw.onHookTimeout ?? 'deny').trim();
566
+ if (!ON_HOOK_TIMEOUT_MODES.includes(mode)) {
567
+ return {
568
+ ok: false,
569
+ error: usage('invalid_on_hook_timeout', `Unknown --on-hook-timeout "${mode}". Choose: ${ON_HOOK_TIMEOUT_MODES.join(', ')}.`),
570
+ };
571
+ }
572
+ return { ok: true, value: mode };
573
+ }
574
+ /**
575
+ * `--hook-timeout` (section 2.4.8).
576
+ *
577
+ * THE `allow` CLAMP IS A RULE, NOT A CONVENIENCE (review P2-1): a tool can be asked twice
578
+ * (`tool_pre` + `tool_post`) and `ToolExecutor`'s own budget is 120 s, so two round trips
579
+ * that may each consume the full 60 s could starve a legitimate tool call. Under `deny` and
580
+ * `error` the ceiling stays at 60 s, because those stop the tool at the deadline instead of
581
+ * waiting longer.
582
+ */
583
+ function resolveHooks(raw, proposed) {
584
+ const max = proposed === 'allow' ? HOOK_LIMITS.allowModeMaxTimeoutMs : HOOK_LIMITS.maxTimeoutMs;
585
+ if (raw.hookTimeout === undefined) {
586
+ const fallback = Math.min(HOOK_LIMITS.defaultTimeoutMs, max);
587
+ return { ok: true, hookTimeoutMs: fallback };
588
+ }
589
+ const parsed = parsePositiveInt(raw.hookTimeout);
590
+ if (parsed === null || parsed < HOOK_LIMITS.minTimeoutMs || parsed > max) {
591
+ return {
592
+ ok: false,
593
+ error: usage('invalid_hook_timeout', `--hook-timeout must be an integer between ${HOOK_LIMITS.minTimeoutMs} and ${max}` +
594
+ (proposed === 'allow' ? ' under --on-hook-timeout allow.' : '.')),
595
+ };
596
+ }
597
+ return { ok: true, hookTimeoutMs: parsed };
598
+ }
192
599
  function checkSessionFlags(raw) {
193
600
  if (raw.sessionId !== undefined) {
194
601
  const id = raw.sessionId.trim();
@@ -25,7 +25,15 @@
25
25
  * plan mode the point is that the model KNOWS the write exists and is being
26
26
  * deferred; for a deny list the point is the opposite (D-4).
27
27
  */
28
- export type PermissionMode = 'auto' | 'plan' | 'strict';
28
+ /**
29
+ * `ask` is the fourth baseline and it CHANGES NOTHING HERE (headless-host-bridge
30
+ * section 2.3.2). It filters no tool - an `ask` run registers exactly what an `auto` run
31
+ * registers - because what it changes is the CALL: `withToolApproval` makes the
32
+ * mutating tools wait for the caller first. Keeping the filter identical is what
33
+ * makes `--permission-mode ask --deny-tool bash` mean "not registered, not asked"
34
+ * rather than "asked and then filtered".
35
+ */
36
+ export type PermissionMode = 'auto' | 'plan' | 'strict' | 'ask';
29
37
  export interface ToolPermission {
30
38
  mode: PermissionMode;
31
39
  allow: ReadonlySet<string>;
@@ -82,7 +82,13 @@ export function resolveToolAllowList(registered, mode, allow, deny) {
82
82
  * alias of `-p --plan`.
83
83
  */
84
84
  export function resolvePermission(input) {
85
- if (input.mode !== 'strict' && input.allow.length === 0 && input.deny.length === 0) {
85
+ // `ask` ALWAYS PRODUCES AN OBJECT, even with both lists empty, for the reason
86
+ // `strict` does: an empty allow list under `ask` is a posture ("ask me about
87
+ // everything in `CALLER_GATED_TOOLS`"), not the absence of one. It still filters
88
+ // nothing - `decide()` returns `true` for every name under `ask` apart from the
89
+ // deny list - so the array it hands back is the array `auto` would have built.
90
+ const alwaysPresent = input.mode === 'strict' || input.mode === 'ask';
91
+ if (!alwaysPresent && input.allow.length === 0 && input.deny.length === 0) {
86
92
  return undefined;
87
93
  }
88
94
  const allow = new Set(input.allow);