@gajae-code/coding-agent 0.9.5 → 0.10.0

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 (194) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/cli/classify-install-target.d.ts +0 -10
  3. package/dist/types/cli/daemon-cli.d.ts +2 -0
  4. package/dist/types/cli/initial-message.d.ts +1 -1
  5. package/dist/types/cli/malloc-env-guard.d.ts +65 -0
  6. package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
  7. package/dist/types/cli/notify-cli.d.ts +3 -1
  8. package/dist/types/cli/session-picker.d.ts +4 -3
  9. package/dist/types/cli/update-cli.d.ts +22 -3
  10. package/dist/types/cli.d.ts +4 -0
  11. package/dist/types/commands/daemon.d.ts +6 -2
  12. package/dist/types/commands/launch.d.ts +1 -0
  13. package/dist/types/commands/notify.d.ts +6 -0
  14. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  15. package/dist/types/commit/analysis/summary.d.ts +1 -1
  16. package/dist/types/commit/changelog/generate.d.ts +1 -1
  17. package/dist/types/commit/changelog/index.d.ts +2 -2
  18. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  19. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  20. package/dist/types/config/keybindings.d.ts +5 -0
  21. package/dist/types/config/model-profile-activation.d.ts +1 -0
  22. package/dist/types/config/settings-schema.d.ts +47 -7
  23. package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
  24. package/dist/types/coordinator-mcp/server.d.ts +67 -6
  25. package/dist/types/daemon/control-types.d.ts +10 -0
  26. package/dist/types/daemon/operator-contract.d.ts +47 -0
  27. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  28. package/dist/types/discovery/helpers.d.ts +1 -1
  29. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  30. package/dist/types/gjc-runtime/launch-tmux.d.ts +26 -7
  31. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +34 -3
  32. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  33. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +309 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +49 -17
  36. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  37. package/dist/types/main.d.ts +66 -2
  38. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  39. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  40. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  42. package/dist/types/modes/components/gajae-pet-widget.d.ts +59 -0
  43. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  44. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  45. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  46. package/dist/types/modes/components/session-selector.d.ts +22 -9
  47. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  48. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  49. package/dist/types/modes/components/user-message.d.ts +2 -1
  50. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  51. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  52. package/dist/types/modes/interactive-mode.d.ts +24 -2
  53. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  54. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  55. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  56. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  57. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  58. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  59. package/dist/types/modes/types.d.ts +28 -2
  60. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  61. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  62. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  63. package/dist/types/notifications/config.d.ts +34 -0
  64. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  65. package/dist/types/notifications/index.d.ts +27 -0
  66. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  67. package/dist/types/notifications/notification-service.d.ts +144 -0
  68. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  69. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  70. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  71. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  72. package/dist/types/notifications/topic-registry.d.ts +24 -4
  73. package/dist/types/registry/agent-registry.d.ts +2 -0
  74. package/dist/types/rlm/index.d.ts +1 -1
  75. package/dist/types/sdk.d.ts +2 -0
  76. package/dist/types/secrets/obfuscator.d.ts +1 -1
  77. package/dist/types/session/agent-session.d.ts +26 -2
  78. package/dist/types/session/cache-economics.d.ts +79 -0
  79. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  80. package/dist/types/session/session-manager.d.ts +124 -4
  81. package/dist/types/session/session-storage.d.ts +155 -13
  82. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  83. package/dist/types/task/executor.d.ts +3 -0
  84. package/dist/types/task/receipt.d.ts +3 -1
  85. package/dist/types/task/types.d.ts +8 -0
  86. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  87. package/dist/types/tools/ask.d.ts +25 -1
  88. package/dist/types/tools/bash.d.ts +1 -1
  89. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  90. package/dist/types/tools/index.d.ts +58 -6
  91. package/dist/types/tools/telegram-send.d.ts +7 -0
  92. package/dist/types/utils/external-editor.d.ts +2 -0
  93. package/package.json +9 -9
  94. package/scripts/compile-args.ts +8 -2
  95. package/scripts/g004-tmux-smoke.ts +196 -36
  96. package/src/cli/daemon-cli.ts +19 -29
  97. package/src/cli/malloc-env-guard.test.ts +104 -0
  98. package/src/cli/malloc-env-guard.ts +149 -0
  99. package/src/cli/notify-cli.ts +78 -14
  100. package/src/cli/session-picker.ts +23 -40
  101. package/src/cli/update-cli.ts +56 -37
  102. package/src/cli.ts +50 -10
  103. package/src/commands/daemon.ts +19 -5
  104. package/src/commands/harness.ts +515 -47
  105. package/src/commands/launch.ts +11 -3
  106. package/src/commands/notify.ts +5 -1
  107. package/src/commands/session.ts +6 -1
  108. package/src/config/keybindings.ts +5 -0
  109. package/src/config/model-profile-activation.ts +1 -1
  110. package/src/config/model-profiles.ts +23 -23
  111. package/src/config/settings-schema.ts +41 -1
  112. package/src/coordinator-mcp/model-preset.ts +100 -0
  113. package/src/coordinator-mcp/server.ts +2653 -425
  114. package/src/daemon/control-types.ts +11 -0
  115. package/src/daemon/operator-contract.ts +121 -0
  116. package/src/deep-interview/render-middleware.ts +67 -2
  117. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  118. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  119. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  120. package/src/gjc-runtime/launch-tmux.ts +702 -135
  121. package/src/gjc-runtime/session-state-sidecar.ts +756 -135
  122. package/src/gjc-runtime/team-runtime.ts +6 -0
  123. package/src/gjc-runtime/tmux-common.ts +26 -7
  124. package/src/gjc-runtime/tmux-gc.ts +52 -9
  125. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +185 -0
  126. package/src/gjc-runtime/tmux-owner-isolation.ts +2060 -0
  127. package/src/gjc-runtime/tmux-sessions.ts +871 -68
  128. package/src/internal-urls/docs-index.generated.ts +9 -8
  129. package/src/internal-urls/local-protocol.ts +29 -10
  130. package/src/main.ts +298 -75
  131. package/src/modes/acp/acp-agent.ts +728 -98
  132. package/src/modes/acp/acp-mode.ts +29 -2
  133. package/src/modes/components/assistant-message.ts +32 -3
  134. package/src/modes/components/bash-execution.ts +43 -13
  135. package/src/modes/components/custom-editor.ts +5 -0
  136. package/src/modes/components/gajae-pet-widget.ts +360 -0
  137. package/src/modes/components/hook-selector.ts +12 -0
  138. package/src/modes/components/irc-sidebar.ts +206 -0
  139. package/src/modes/components/pet-selector.ts +55 -0
  140. package/src/modes/components/session-selector.ts +222 -221
  141. package/src/modes/components/settings-selector.ts +10 -0
  142. package/src/modes/components/tool-execution.ts +47 -6
  143. package/src/modes/components/user-message.ts +26 -9
  144. package/src/modes/controllers/command-controller.ts +28 -2
  145. package/src/modes/controllers/event-controller.ts +159 -36
  146. package/src/modes/controllers/extension-ui-controller.ts +36 -17
  147. package/src/modes/controllers/input-controller.ts +8 -2
  148. package/src/modes/controllers/selector-controller.ts +62 -15
  149. package/src/modes/interactive-mode.ts +183 -7
  150. package/src/modes/irc-observation-ledger.ts +127 -0
  151. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  152. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  153. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  154. package/src/modes/types.ts +34 -2
  155. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  156. package/src/modes/utils/irc-message.ts +138 -0
  157. package/src/modes/utils/ui-helpers.ts +216 -50
  158. package/src/notifications/config.ts +44 -0
  159. package/src/notifications/daemon-paths.ts +8 -0
  160. package/src/notifications/index.ts +535 -75
  161. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  162. package/src/notifications/notification-service.ts +710 -0
  163. package/src/notifications/rate-limit-pool.ts +46 -12
  164. package/src/notifications/telegram-daemon-cli.ts +2 -0
  165. package/src/notifications/telegram-daemon-control.ts +27 -2
  166. package/src/notifications/telegram-daemon.ts +635 -94
  167. package/src/notifications/telegram-reference.ts +73 -13
  168. package/src/notifications/topic-registry.ts +86 -9
  169. package/src/prompts/system/irc-peer-roster.md +1 -0
  170. package/src/prompts/system/subagent-system-prompt.md +1 -0
  171. package/src/prompts/system/system-prompt.md +1 -0
  172. package/src/prompts/tools/irc.md +1 -1
  173. package/src/registry/agent-registry.ts +3 -0
  174. package/src/sdk.ts +52 -1
  175. package/src/secrets/obfuscator.ts +4 -2
  176. package/src/session/agent-session.ts +396 -145
  177. package/src/session/cache-economics.ts +275 -0
  178. package/src/session/provider-safety-stop.ts +9 -0
  179. package/src/session/session-dump-format.ts +6 -0
  180. package/src/session/session-manager.ts +809 -39
  181. package/src/session/session-storage.ts +582 -38
  182. package/src/slash-commands/builtin-registry.ts +143 -9
  183. package/src/task/executor.ts +12 -3
  184. package/src/task/index.ts +8 -0
  185. package/src/task/receipt.ts +7 -2
  186. package/src/task/types.ts +26 -0
  187. package/src/tools/ask-answer-registry.ts +25 -0
  188. package/src/tools/ask.ts +355 -33
  189. package/src/tools/browser/tab-supervisor.ts +63 -6
  190. package/src/tools/browser.ts +8 -3
  191. package/src/tools/index.ts +81 -6
  192. package/src/tools/sqlite-reader.ts +8 -4
  193. package/src/tui/output-block.ts +18 -6
  194. package/src/utils/external-editor.ts +6 -1
package/src/tools/ask.ts CHANGED
@@ -36,11 +36,24 @@ import {
36
36
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
37
37
  import { appendOrMergeDeepInterviewRound, syncDeepInterviewRecorderHud } from "../gjc-runtime/deep-interview-recorder";
38
38
  import { deepInterviewStatePath } from "../gjc-runtime/deep-interview-runtime";
39
- import { gateAnswerToResult, questionToGate } from "../modes/shared/agent-wire/deep-interview-gate";
39
+ import {
40
+ type AskGateQuestion,
41
+ gateAnswerToResult,
42
+ questionToGate,
43
+ } from "../modes/shared/agent-wire/deep-interview-gate";
40
44
  import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
41
45
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
42
46
  import { renderStatusLine } from "../tui";
43
- import type { ToolSession } from ".";
47
+ import type {
48
+ AskAnswerRequest,
49
+ AskRemoteControl,
50
+ AskRemoteInteraction,
51
+ AskRemoteReceipt,
52
+ AskSettlement,
53
+ AskSettlementResult,
54
+ ToolSession,
55
+ } from ".";
56
+
44
57
  import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
45
58
  import { ToolAbortError } from "./tool-errors";
46
59
  import { assertUltragoalAskAllowed } from "./ultragoal-ask-guard";
@@ -112,6 +125,7 @@ export interface AskToolDetails {
112
125
  const OTHER_OPTION = "Other (type your own)";
113
126
  const ASK_CLARIFICATION_OPTION = "Ask about these choices";
114
127
  const RECOMMENDED_SUFFIX = " (Recommended)";
128
+ const REMOTE_NAVIGATION_FORWARD = "\u0000ask-navigation-forward";
115
129
  const DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS = Number.MAX_SAFE_INTEGER;
116
130
  const DEEP_INTERVIEW_RECORDER_AWAIT_TIMEOUT_MS = 250;
117
131
 
@@ -179,6 +193,79 @@ function numberOptionLabels(labels: string[]): string[] {
179
193
  return labels.map(formatNumberedOptionLabel);
180
194
  }
181
195
 
196
+ /** The only remote navigation control; labels are presentation, never protocol. */
197
+ export function askRemoteControls(input: {
198
+ multi: boolean;
199
+ questionIndex: number;
200
+ questionCount: number;
201
+ selectedCount: number;
202
+ hasNonWhitespaceCustom: boolean;
203
+ }): readonly AskRemoteControl[] {
204
+ if (!input.multi) return [];
205
+ const final = input.questionIndex === input.questionCount - 1;
206
+ return [
207
+ {
208
+ id: "navigation_forward",
209
+ kind: "navigation",
210
+ label: input.questionCount > 1 && !final ? "Next" : "Done",
211
+ enabled: input.questionCount > 1 || input.selectedCount > 0 || input.hasNonWhitespaceCustom,
212
+ },
213
+ ];
214
+ }
215
+
216
+ /** Classify remote input without looking at option labels used for controls. */
217
+ export function classifyAskRemoteInteraction(input: {
218
+ interaction: AskRemoteInteraction;
219
+ options: readonly string[];
220
+ controls: readonly AskRemoteControl[];
221
+ multi: boolean;
222
+ selectedCount: number;
223
+ customInput?: string;
224
+ clarification?: boolean;
225
+ }): AskSettlement {
226
+ const interaction = input.interaction;
227
+ if (interaction.kind === "control") {
228
+ const control = input.controls.find(candidate => candidate.id === interaction.controlId);
229
+ if (!control?.enabled || !input.multi) return { kind: "invalid", reason: "invalid_control" };
230
+ return input.selectedCount > 0 || (input.customInput?.trim().length ?? 0) > 0
231
+ ? { kind: "commit" }
232
+ : { kind: "resolve_without_commit", reason: "empty_navigation" };
233
+ }
234
+ if (input.clarification) {
235
+ return interaction.value.trim().length > 0
236
+ ? { kind: "resolve_without_commit", reason: "clarification_submitted" }
237
+ : { kind: "invalid", reason: "empty_clarification" };
238
+ }
239
+ if (input.options.includes(interaction.value))
240
+ return input.multi ? { kind: "resolve_without_commit", reason: "toggle" } : { kind: "commit" };
241
+ return interaction.value.trim().length > 0 ? { kind: "commit" } : { kind: "invalid", reason: "empty_custom" };
242
+ }
243
+
244
+ /** A one-shot local receipt used to normalize legacy string answer sources. */
245
+ export function legacyAskReceipt(value: string): {
246
+ source: "remote";
247
+ interaction: AskRemoteInteraction;
248
+ settle(settlement: AskSettlement): Promise<AskSettlementResult>;
249
+ } {
250
+ let settled: Promise<AskSettlementResult> | undefined;
251
+ return {
252
+ interaction: { kind: "value", value },
253
+ source: "remote",
254
+ settle(settlement) {
255
+ if (!settled) {
256
+ settled = Promise.resolve(
257
+ settlement.kind === "commit"
258
+ ? { kind: "committed", ack: { status: "failed", reason: "unsupported" } }
259
+ : settlement.kind === "invalid"
260
+ ? { kind: "invalid_closed" }
261
+ : { kind: "resolved_without_commit" },
262
+ );
263
+ }
264
+ return settled;
265
+ },
266
+ };
267
+ }
268
+
182
269
  // =============================================================================
183
270
  // Question Selection Logic
184
271
  // =============================================================================
@@ -206,6 +293,11 @@ interface AskSingleQuestionOptions {
206
293
  scrollTitleRows?: number;
207
294
  otherOptionLabel?: string;
208
295
  clarificationOptionLabel?: string;
296
+ onRemoteState?: (state: {
297
+ interaction: "selector" | "custom_editor" | "clarification_editor";
298
+ selectedCount: number;
299
+ hasNonWhitespaceCustom: boolean;
300
+ }) => void;
209
301
  }
210
302
 
211
303
  interface UIContext {
@@ -329,14 +421,27 @@ async function askSingleQuestion(
329
421
  // resolve the "Other" label without invoking customInput.onSubmit).
330
422
  const promptForCustomInput = async (): Promise<{ input: string | undefined }> => {
331
423
  const dialogOptions = signal ? { signal } : undefined;
332
- const showCustomInput = () => ui.editor("Enter your response:", undefined, dialogOptions, { promptStyle: true });
424
+ const showCustomInput = () => {
425
+ options.onRemoteState?.({
426
+ interaction: "custom_editor",
427
+ selectedCount: selectedOptions.length,
428
+ hasNonWhitespaceCustom: (customInput?.trim().length ?? 0) > 0,
429
+ });
430
+ return ui.editor("Enter your response:", undefined, dialogOptions, { promptStyle: true });
431
+ };
333
432
  const input = signal ? await untilAborted(signal, showCustomInput) : await showCustomInput();
334
433
  return { input };
335
434
  };
336
435
  const promptForClarificationInput = async (): Promise<{ input: string | undefined }> => {
337
436
  const dialogOptions = signal ? { signal } : undefined;
338
- const showClarificationInput = () =>
339
- ui.editor("Ask a clarification question:", undefined, dialogOptions, { promptStyle: true });
437
+ const showClarificationInput = () => {
438
+ options.onRemoteState?.({
439
+ interaction: "clarification_editor",
440
+ selectedCount: selectedOptions.length,
441
+ hasNonWhitespaceCustom: false,
442
+ });
443
+ return ui.editor("Ask a clarification question:", undefined, dialogOptions, { promptStyle: true });
444
+ };
340
445
  const input = signal ? await untilAborted(signal, showClarificationInput) : await showClarificationInput();
341
446
  return { input: input !== undefined && input.trim() === "" ? undefined : input };
342
447
  };
@@ -366,6 +471,11 @@ async function askSingleQuestion(
366
471
  opts.push(clarificationOptionLabel);
367
472
  }
368
473
 
474
+ options.onRemoteState?.({
475
+ interaction: "selector",
476
+ selectedCount: selected.size,
477
+ hasNonWhitespaceCustom: (customInput?.trim().length ?? 0) > 0,
478
+ });
369
479
  const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
370
480
  const {
371
481
  choice,
@@ -385,6 +495,10 @@ async function askSingleQuestion(
385
495
  }
386
496
  return { selectedOptions: Array.from(selected), customInput, timedOut, cancelled: true };
387
497
  }
498
+ if (choice === REMOTE_NAVIGATION_FORWARD) {
499
+ return { selectedOptions: Array.from(selected), customInput, timedOut, navigation: "forward" };
500
+ }
501
+
388
502
  if (choice === doneLabel) break;
389
503
 
390
504
  if (choice === otherOptionLabel) {
@@ -432,6 +546,10 @@ async function askSingleQuestion(
432
546
  selected.add(opt);
433
547
  }
434
548
  }
549
+ if (!opt && choice.trim().length > 0) {
550
+ customInput = choice;
551
+ break;
552
+ }
435
553
 
436
554
  if (selectTimedOut) {
437
555
  timedOut = true;
@@ -458,6 +576,11 @@ async function askSingleQuestion(
458
576
  initialIndex = Math.max(0, Math.min(initialIndex, maxIndex));
459
577
  }
460
578
 
579
+ options.onRemoteState?.({
580
+ interaction: "selector",
581
+ selectedCount: selectedOptions.length,
582
+ hasNonWhitespaceCustom: (customInput?.trim().length ?? 0) > 0,
583
+ });
461
584
  const {
462
585
  choice,
463
586
  timedOut: selectTimedOut,
@@ -470,6 +593,9 @@ async function askSingleQuestion(
470
593
  if (arrowNavigation) {
471
594
  return { selectedOptions, customInput, timedOut, navigation: arrowNavigation };
472
595
  }
596
+ if (choice === REMOTE_NAVIGATION_FORWARD) {
597
+ return { selectedOptions, customInput, timedOut, navigation: "forward" };
598
+ }
473
599
  if (choice === undefined) {
474
600
  if (!timedOut) {
475
601
  return { selectedOptions, customInput, timedOut, cancelled: true };
@@ -617,6 +743,20 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
617
743
  activeSkillState: this.session.getActiveSkillState?.(),
618
744
  sessionId: this.session.getSessionId?.() ?? null,
619
745
  });
746
+ let activeRemoteReceipt: AskRemoteReceipt | undefined;
747
+ let activeRemoteRequest: AskAnswerRequest | undefined;
748
+ let remoteGeneration = 0;
749
+ type RemoteRaceResult = {
750
+ winner: "remote";
751
+ value: string;
752
+ receipt: AskRemoteReceipt;
753
+ settlement?: AskSettlement;
754
+ };
755
+ const settleActiveRemote = async (settlement: AskSettlement): Promise<void> => {
756
+ const receipt = activeRemoteReceipt;
757
+ activeRemoteReceipt = undefined;
758
+ if (receipt) await receipt.settle(settlement);
759
+ };
620
760
  const gateEmitter = this.session.getWorkflowGateEmitter?.();
621
761
  const canUseWorkflowGate = gateEmitter?.isUnattended() === true;
622
762
 
@@ -633,40 +773,113 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
633
773
  const source = this.session.getAskAnswerSource?.();
634
774
  if (!source) return extensionUi.select(prompt, options, dialogOptions);
635
775
  // Race the local UI against a remote answer (e.g. a Telegram reply via the
636
- // notifications SDK) so asks can be answered without RPC mode. When the
637
- // local UI wins, abort the remote source so it stops waiting and marks the
638
- // action resolved-locally. First valid answer wins.
639
- // Race the local UI against a remote answer (e.g. a Telegram reply via the
640
776
  // notifications SDK) so asks can be answered without RPC mode. First valid
641
777
  // answer wins; the loser is aborted so neither side is left hanging:
642
778
  // - local wins -> abort the remote source (marks the action resolved-locally)
643
779
  // - remote wins -> abort the local selector so the TUI dialog actually closes
644
780
  const remoteController = new AbortController();
645
781
  const localController = new AbortController();
646
- // Propagate an external cancel (the tool's signal) to the local selector too.
782
+ const generation = ++remoteGeneration;
783
+ // Propagate external cancellation to both race legs and invalidate late replies.
647
784
  const toolSignal = dialogOptions?.signal;
785
+ const abortRace = () => {
786
+ if (generation === remoteGeneration) remoteGeneration++;
787
+ localController.abort();
788
+ remoteController.abort();
789
+ };
648
790
  if (toolSignal) {
649
- if (toolSignal.aborted) localController.abort();
650
- else toolSignal.addEventListener("abort", () => localController.abort(), { once: true });
791
+ if (toolSignal.aborted) abortRace();
792
+ else toolSignal.addEventListener("abort", abortRace, { once: true });
651
793
  }
652
- const remote = source.awaitAnswer(prompt, options, remoteController.signal).then(answer => {
653
- // undefined is not a valid remote answer (registration failed, or the local
654
- // UI already won and aborted us): never settle the race, let the local
655
- // selector decide instead of cancelling the ask.
656
- if (answer === undefined) return new Promise<string | undefined>(() => {});
657
- localController.abort();
658
- return answer;
794
+ const remote = (
795
+ source.awaitAnswerRequest
796
+ ? source.awaitAnswerRequest(
797
+ activeRemoteRequest ?? { question: prompt, options, interaction: "selector", controls: [] },
798
+ remoteController.signal,
799
+ )
800
+ : source.awaitAnswer(prompt, options, remoteController.signal)
801
+ ).then((answer): RemoteRaceResult | Promise<RemoteRaceResult> => {
802
+ if (answer === undefined) return new Promise<never>(() => {});
803
+ const receipt = typeof answer === "string" ? legacyAskReceipt(answer) : answer;
804
+ if (generation !== remoteGeneration) {
805
+ return receipt
806
+ .settle({ kind: "resolve_without_commit", reason: "aborted" })
807
+ .then(() => new Promise<never>(() => {}));
808
+ }
809
+ const remoteValue = receipt.interaction.kind === "value" ? receipt.interaction.value : undefined;
810
+ const value = remoteValue ?? REMOTE_NAVIGATION_FORWARD;
811
+ const selectedValue =
812
+ remoteValue === undefined
813
+ ? value
814
+ : (options.find(
815
+ option =>
816
+ option === remoteValue ||
817
+ option === `${theme.checkbox.checked} ${remoteValue}` ||
818
+ option === `${theme.checkbox.unchecked} ${remoteValue}`,
819
+ ) ?? value);
820
+ const normalizedRemoteValue = remoteValue?.startsWith(`${theme.checkbox.checked} `)
821
+ ? remoteValue.slice(`${theme.checkbox.checked} `.length)
822
+ : remoteValue?.startsWith(`${theme.checkbox.unchecked} `)
823
+ ? remoteValue.slice(`${theme.checkbox.unchecked} `.length)
824
+ : remoteValue;
825
+ const semanticRemoteValue = normalizedRemoteValue?.replace(/^\s*\d+[.)]\s+/, "");
826
+ const transitionReason =
827
+ semanticRemoteValue === OTHER_OPTION
828
+ ? "other_transition"
829
+ : semanticRemoteValue === ASK_CLARIFICATION_OPTION
830
+ ? "clarification_transition"
831
+ : undefined;
832
+ if (transitionReason) {
833
+ return {
834
+ winner: "remote" as const,
835
+ value: selectedValue,
836
+ receipt,
837
+ settlement: { kind: "resolve_without_commit", reason: transitionReason },
838
+ };
839
+ }
840
+ if (
841
+ remoteValue !== undefined &&
842
+ activeRemoteRequest?.interaction === "selector" &&
843
+ activeRemoteRequest.controls.length > 0 &&
844
+ activeRemoteRequest.options.includes(remoteValue)
845
+ ) {
846
+ return {
847
+ winner: "remote" as const,
848
+ value: selectedValue,
849
+ receipt,
850
+ settlement: { kind: "resolve_without_commit", reason: "toggle" },
851
+ };
852
+ }
853
+ return { winner: "remote" as const, value: selectedValue, receipt };
659
854
  });
855
+
660
856
  const local = extensionUi
661
857
  .select(prompt, options, { ...dialogOptions, signal: localController.signal })
662
858
  .then(answer => {
859
+ if (generation === remoteGeneration) remoteGeneration++;
663
860
  remoteController.abort();
664
- return answer;
861
+ return { winner: "local" as const, value: answer };
862
+ })
863
+ .catch(error => {
864
+ if (generation === remoteGeneration) remoteGeneration++;
865
+ remoteController.abort();
866
+ throw error;
665
867
  });
666
868
  // The losing selector may reject when aborted after the race already settled;
667
869
  // swallow that so it is not an unhandled rejection (the race result is unaffected).
668
870
  void local.catch(() => undefined);
669
- return Promise.race([local, remote]);
871
+ return Promise.race([local, remote]).then(async result => {
872
+ if (result.winner === "remote") {
873
+ localController.abort();
874
+ if (result.settlement) await result.receipt.settle(result.settlement);
875
+ else activeRemoteReceipt = result.receipt;
876
+ } else {
877
+ void remote.then(remoteResult =>
878
+ remoteResult.receipt.settle({ kind: "resolve_without_commit", reason: "aborted" }),
879
+ );
880
+ }
881
+ return result.value;
882
+ });
670
883
  },
671
884
  editor: (title, prefill, dialogOptions, editorOptions) => {
672
885
  if (!extensionUi) throw new ToolAbortError("Ask tool requires interactive mode");
@@ -677,24 +890,65 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
677
890
  // reply) instead of blocking on the local-only editor. Mirrors `select`.
678
891
  const remoteController = new AbortController();
679
892
  const localController = new AbortController();
893
+ const generation = ++remoteGeneration;
680
894
  const toolSignal = dialogOptions?.signal;
895
+ const abortRace = () => {
896
+ if (generation === remoteGeneration) remoteGeneration++;
897
+ localController.abort();
898
+ remoteController.abort();
899
+ };
681
900
  if (toolSignal) {
682
- if (toolSignal.aborted) localController.abort();
683
- else toolSignal.addEventListener("abort", () => localController.abort(), { once: true });
901
+ if (toolSignal.aborted) abortRace();
902
+ else toolSignal.addEventListener("abort", abortRace, { once: true });
684
903
  }
685
- const remote = source.awaitAnswer(title, [], remoteController.signal).then(answer => {
686
- if (answer === undefined) return new Promise<string | undefined>(() => {});
687
- localController.abort();
688
- return answer;
904
+ const remote = (
905
+ source.awaitAnswerRequest
906
+ ? source.awaitAnswerRequest(
907
+ activeRemoteRequest ?? {
908
+ question: title,
909
+ options: [],
910
+ interaction: "custom_editor",
911
+ controls: [],
912
+ },
913
+ remoteController.signal,
914
+ )
915
+ : source.awaitAnswer(title, [], remoteController.signal)
916
+ ).then((answer): RemoteRaceResult | Promise<RemoteRaceResult> => {
917
+ if (answer === undefined) return new Promise<never>(() => {});
918
+ const receipt = typeof answer === "string" ? legacyAskReceipt(answer) : answer;
919
+ if (generation !== remoteGeneration) {
920
+ return receipt
921
+ .settle({ kind: "resolve_without_commit", reason: "aborted" })
922
+ .then(() => new Promise<never>(() => {}));
923
+ }
924
+ const value =
925
+ receipt.interaction.kind === "control" ? REMOTE_NAVIGATION_FORWARD : receipt.interaction.value;
926
+ return { winner: "remote" as const, value, receipt };
689
927
  });
690
928
  const local = extensionUi
691
929
  .editor(title, prefill, { ...(dialogOptions ?? {}), signal: localController.signal }, editorOptions)
692
930
  .then(answer => {
931
+ if (generation === remoteGeneration) remoteGeneration++;
932
+ remoteController.abort();
933
+ return { winner: "local" as const, value: answer };
934
+ })
935
+ .catch(error => {
936
+ if (generation === remoteGeneration) remoteGeneration++;
693
937
  remoteController.abort();
694
- return answer;
938
+ throw error;
695
939
  });
696
940
  void local.catch(() => undefined);
697
- return Promise.race([local, remote]);
941
+ return Promise.race([local, remote]).then(result => {
942
+ if (result.winner === "remote") {
943
+ activeRemoteReceipt = result.receipt;
944
+ localController.abort();
945
+ } else {
946
+ void remote.then(remoteResult =>
947
+ remoteResult.receipt.settle({ kind: "resolve_without_commit", reason: "aborted" }),
948
+ );
949
+ }
950
+ return result.value;
951
+ });
698
952
  },
699
953
  };
700
954
 
@@ -720,10 +974,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
720
974
  options?: { previous?: QuestionResult; navigation?: NavigationControls },
721
975
  ) => {
722
976
  const rawOptionLabels = q.options.map(o => o.label);
977
+ const questionIndex = params.questions.indexOf(q);
723
978
  // Unattended (#316/#323/G011): route the question through the workflow-gate
724
979
  // emitter instead of the interactive UI; the external agent answers over RPC.
725
980
  if (gateEmitter && canUseWorkflowGate) {
726
- const gateQuestion = {
981
+ const gateQuestion: AskGateQuestion = {
727
982
  id: q.id,
728
983
  question: q.question,
729
984
  options: q.options,
@@ -731,6 +986,8 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
731
986
  recommended: q.recommended,
732
987
  deepInterview: q.deepInterview,
733
988
  workflowGate: q.workflowGate,
989
+ allowEmpty: q.multi === true && params.questions.length > 1,
990
+ navigationLabel: questionIndex === params.questions.length - 1 ? "Done" : "Next",
734
991
  };
735
992
  const answer = await gateEmitter.emitGate(questionToGate(gateQuestion));
736
993
  const decoded = gateAnswerToResult(gateQuestion, answer);
@@ -753,6 +1010,14 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
753
1010
  const clarificationOptionLabel = shouldNumberOptions
754
1011
  ? formatNumberedOptionLabel(ASK_CLARIFICATION_OPTION, optionLabels.length + 1)
755
1012
  : undefined;
1013
+ const otherOptionLabel = shouldNumberOptions
1014
+ ? formatNumberedOptionLabel(OTHER_OPTION, optionLabels.length)
1015
+ : OTHER_OPTION;
1016
+ const remoteSelectorOptions = [
1017
+ ...optionLabels,
1018
+ otherOptionLabel,
1019
+ ...(clarificationOptionLabel ? [clarificationOptionLabel] : []),
1020
+ ];
756
1021
  const initialSelection =
757
1022
  shouldNumberOptions && options?.previous
758
1023
  ? {
@@ -763,6 +1028,19 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
763
1028
  }),
764
1029
  }
765
1030
  : options?.previous;
1031
+ activeRemoteRequest = {
1032
+ question: displayQuestion,
1033
+ options: remoteSelectorOptions,
1034
+ interaction: "selector",
1035
+ controls: askRemoteControls({
1036
+ multi: q.multi === true,
1037
+ questionIndex,
1038
+ questionCount: params.questions.length,
1039
+ selectedCount: initialSelection?.selectedOptions.length ?? 0,
1040
+ hasNonWhitespaceCustom: (initialSelection?.customInput?.trim().length ?? 0) > 0,
1041
+ }),
1042
+ };
1043
+
766
1044
  const {
767
1045
  selectedOptions: displaySelectedOptions,
768
1046
  customInput,
@@ -777,10 +1055,25 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
777
1055
  initialSelection,
778
1056
  navigation: options?.navigation,
779
1057
  scrollTitleRows: isDeepInterviewQuestion ? DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS : undefined,
780
- otherOptionLabel: shouldNumberOptions
781
- ? formatNumberedOptionLabel(OTHER_OPTION, optionLabels.length)
782
- : undefined,
1058
+ otherOptionLabel,
783
1059
  clarificationOptionLabel,
1060
+ onRemoteState: state => {
1061
+ activeRemoteRequest = {
1062
+ question: displayQuestion,
1063
+ options: state.interaction === "selector" ? remoteSelectorOptions : [],
1064
+ interaction: state.interaction,
1065
+ controls:
1066
+ state.interaction === "selector"
1067
+ ? askRemoteControls({
1068
+ multi: q.multi === true,
1069
+ questionIndex,
1070
+ questionCount: params.questions.length,
1071
+ selectedCount: state.selectedCount,
1072
+ hasNonWhitespaceCustom: state.hasNonWhitespaceCustom,
1073
+ })
1074
+ : [],
1075
+ };
1076
+ },
784
1077
  });
785
1078
  const selectedOptions = shouldNumberOptions
786
1079
  ? displaySelectedOptions.map(selected => {
@@ -788,6 +1081,30 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
788
1081
  return displayIndex >= 0 ? (rawOptionLabels[displayIndex] ?? selected) : selected;
789
1082
  })
790
1083
  : displaySelectedOptions;
1084
+ if (activeRemoteReceipt) {
1085
+ const settlement: AskSettlement =
1086
+ clarificationQuestion !== undefined
1087
+ ? { kind: "resolve_without_commit", reason: "clarification_submitted" }
1088
+ : customInput !== undefined && customInput.trim().length === 0
1089
+ ? { kind: "invalid", reason: "empty_custom" }
1090
+ : cancelled
1091
+ ? { kind: "resolve_without_commit", reason: "cancelled" }
1092
+ : timedOut
1093
+ ? { kind: "resolve_without_commit", reason: "timed_out" }
1094
+ : navigation === "back"
1095
+ ? { kind: "resolve_without_commit", reason: "back_navigation" }
1096
+ : navigation === "forward" &&
1097
+ selectedOptions.length === 0 &&
1098
+ (customInput === undefined || customInput.trim().length === 0)
1099
+ ? { kind: "resolve_without_commit", reason: "empty_navigation" }
1100
+ : selectedOptions.length > 0 || (customInput?.trim().length ?? 0) > 0
1101
+ ? { kind: "commit" }
1102
+ : { kind: "resolve_without_commit", reason: "cancelled" };
1103
+ await settleActiveRemote(settlement);
1104
+ activeRemoteRequest = undefined;
1105
+ if (settlement.kind === "invalid") return askQuestion(q, options);
1106
+ }
1107
+ activeRemoteRequest = undefined;
791
1108
  return {
792
1109
  optionLabels: rawOptionLabels,
793
1110
  selectedOptions,
@@ -798,6 +1115,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
798
1115
  timedOut,
799
1116
  };
800
1117
  } catch (error) {
1118
+ await settleActiveRemote({
1119
+ kind: "resolve_without_commit",
1120
+ reason: error instanceof Error && error.name === "AbortError" ? "aborted" : "exception",
1121
+ });
1122
+ activeRemoteRequest = undefined;
801
1123
  if (error instanceof Error && error.name === "AbortError") {
802
1124
  throw new ToolAbortError("Ask input was cancelled");
803
1125
  }
@@ -89,11 +89,57 @@ export interface RunInTabOptions {
89
89
 
90
90
  export interface ReleaseTabOptions {
91
91
  kill?: boolean;
92
+ /**
93
+ * Absolute end-to-end deadline (`Date.now()`-based, ms) for the whole teardown chain.
94
+ * Shared across a `releaseAllTabs` loop so close/close-all honors ONE aggregate budget.
95
+ * Omitted (the GC/session-teardown callers) keeps the original unbounded behavior.
96
+ */
97
+ deadlineAt?: number;
92
98
  }
93
99
 
94
100
  const tabs = new Map<string, TabSession>();
95
101
  const GRACE_MS = 750;
96
102
 
103
+ /**
104
+ * Remaining time (ms) until an absolute teardown deadline, or +Infinity when no deadline
105
+ * is set (the GC / session-teardown callers). A non-positive result means the shared
106
+ * close budget is already spent.
107
+ */
108
+ function remainingBudget(deadlineAt: number | undefined): number {
109
+ if (deadlineAt === undefined) return Number.POSITIVE_INFINITY;
110
+ return deadlineAt - Date.now();
111
+ }
112
+
113
+ /**
114
+ * Await `op` but never longer than `remainingMs` (#2027). On timeout — or when the budget
115
+ * is already exhausted — the operation is detached and kept alive best-effort with its
116
+ * rejection swallowed, so a dying CDP target cannot wedge the close teardown or leak an
117
+ * unhandled rejection. Returns true when `op` settled within budget, false when detached.
118
+ */
119
+ async function awaitWithinBudget(op: Promise<unknown>, remainingMs: number, label: string): Promise<boolean> {
120
+ const settled = Promise.resolve(op).then(
121
+ () => true,
122
+ () => true,
123
+ );
124
+ if (remainingMs === Number.POSITIVE_INFINITY) return await settled;
125
+ if (remainingMs <= 0) {
126
+ void settled;
127
+ logger.debug("close teardown budget already spent; detaching step", { label });
128
+ return false;
129
+ }
130
+ let timer: ReturnType<typeof setTimeout> | undefined;
131
+ const timedOut = new Promise<boolean>(resolve => {
132
+ timer = setTimeout(() => resolve(false), remainingMs);
133
+ });
134
+ try {
135
+ const ok = await Promise.race([settled, timedOut]);
136
+ if (!ok) logger.debug("close teardown step exceeded deadline; detached", { label });
137
+ return ok;
138
+ } finally {
139
+ if (timer) clearTimeout(timer);
140
+ }
141
+ }
142
+
97
143
  export function getTab(name: string): TabSession | undefined {
98
144
  return tabs.get(name);
99
145
  }
@@ -316,17 +362,24 @@ export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Pr
316
362
  }
317
363
  tab.pending.clear();
318
364
  let forced = false;
365
+ const deadlineAt = opts.deadlineAt;
319
366
  if (wasAlive) {
320
367
  try {
321
368
  tab.worker.send({ type: "close" });
322
- await waitForClosed(tab);
369
+ await waitForClosed(tab, remainingBudget(deadlineAt));
323
370
  } catch {
324
371
  forced = true;
325
372
  }
326
373
  }
327
- await tab.worker.terminate().catch(() => undefined);
328
- if (forced && tab.kindTag === "headless") await closeOrphanTarget(tab);
329
- await releaseBrowser(tab.browser, { kill: opts.kill ?? false });
374
+ await awaitWithinBudget(tab.worker.terminate(), remainingBudget(deadlineAt), "worker.terminate");
375
+ if (forced && tab.kindTag === "headless") {
376
+ await awaitWithinBudget(closeOrphanTarget(tab), remainingBudget(deadlineAt), "closeOrphanTarget");
377
+ }
378
+ await awaitWithinBudget(
379
+ releaseBrowser(tab.browser, { kill: opts.kill ?? false }),
380
+ remainingBudget(deadlineAt),
381
+ "releaseBrowser",
382
+ );
330
383
  // Only delete if the map still holds THIS tab: a same-name reacquire during our async
331
384
  // teardown may have installed a fresh tab that we must not evict.
332
385
  if (tabs.get(name) === tab) tabs.delete(name);
@@ -495,13 +548,17 @@ async function closeOrphanTarget(tab: TabSession): Promise<void> {
495
548
  }
496
549
  }
497
550
 
498
- async function waitForClosed(tab: TabSession): Promise<void> {
551
+ async function waitForClosed(tab: TabSession, remainingMs: number = Number.POSITIVE_INFINITY): Promise<void> {
499
552
  const { promise, resolve } = Promise.withResolvers<void>();
500
553
  const unsubscribe = tab.worker.onMessage(msg => {
501
554
  if (msg.type === "closed") resolve();
502
555
  });
503
556
  try {
504
- await raceWithTimeout(promise, GRACE_MS, "Timed out closing browser tab worker");
557
+ await raceWithTimeout(
558
+ promise,
559
+ Math.max(0, Math.min(GRACE_MS, remainingMs)),
560
+ "Timed out closing browser tab worker",
561
+ );
505
562
  } finally {
506
563
  unsubscribe();
507
564
  }