@gajae-code/coding-agent 0.9.6 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/dist/types/cli/classify-install-target.d.ts +0 -10
- package/dist/types/cli/daemon-cli.d.ts +2 -0
- package/dist/types/cli/initial-message.d.ts +1 -1
- package/dist/types/cli/malloc-env-guard.d.ts +65 -0
- package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
- package/dist/types/cli/notify-cli.d.ts +3 -1
- package/dist/types/cli/session-picker.d.ts +4 -3
- package/dist/types/cli/update-cli.d.ts +22 -3
- package/dist/types/cli.d.ts +5 -0
- package/dist/types/commands/daemon.d.ts +6 -2
- package/dist/types/commands/launch.d.ts +1 -0
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/commit/analysis/conventional.d.ts +1 -1
- package/dist/types/commit/analysis/summary.d.ts +1 -1
- package/dist/types/commit/changelog/generate.d.ts +1 -1
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/settings-schema.d.ts +47 -7
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
- package/dist/types/coordinator-mcp/policy.d.ts +3 -0
- package/dist/types/coordinator-mcp/server.d.ts +79 -6
- package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
- package/dist/types/daemon/control-types.d.ts +10 -0
- package/dist/types/daemon/operator-contract.d.ts +47 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
- package/dist/types/discovery/helpers.d.ts +1 -1
- package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
- package/dist/types/extensibility/extensions/types.d.ts +3 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +26 -7
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +34 -3
- package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +309 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +49 -17
- package/dist/types/internal-urls/local-protocol.d.ts +13 -9
- package/dist/types/main.d.ts +66 -2
- package/dist/types/modes/acp/acp-agent.d.ts +19 -2
- package/dist/types/modes/acp/acp-mode.d.ts +12 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +2 -0
- package/dist/types/modes/components/gajae-pet-widget.d.ts +59 -0
- package/dist/types/modes/components/hook-selector.d.ts +5 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
- package/dist/types/modes/components/pet-selector.d.ts +11 -0
- package/dist/types/modes/components/session-selector.d.ts +22 -9
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +0 -10
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +2 -1
- package/dist/types/modes/controllers/event-controller.d.ts +4 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +24 -2
- package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
- package/dist/types/modes/print-mode.d.ts +3 -4
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
- package/dist/types/modes/theme/defaults/index.d.ts +568 -568
- package/dist/types/modes/types.d.ts +28 -2
- package/dist/types/modes/utils/abort-message.d.ts +1 -1
- package/dist/types/modes/utils/context-usage.d.ts +4 -19
- package/dist/types/modes/utils/irc-message.d.ts +38 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
- package/dist/types/notifications/config.d.ts +34 -0
- package/dist/types/notifications/daemon-paths.d.ts +7 -0
- package/dist/types/notifications/index.d.ts +27 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
- package/dist/types/notifications/notification-service.d.ts +144 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
- package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
- package/dist/types/notifications/telegram-daemon.d.ts +130 -3
- package/dist/types/notifications/telegram-reference.d.ts +19 -2
- package/dist/types/notifications/topic-registry.d.ts +24 -4
- package/dist/types/registry/agent-registry.d.ts +2 -0
- package/dist/types/rlm/index.d.ts +1 -1
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +40 -3
- package/dist/types/session/cache-economics.d.ts +79 -0
- package/dist/types/session/context-estimation.d.ts +31 -0
- package/dist/types/session/provider-safety-stop.d.ts +5 -0
- package/dist/types/session/session-manager.d.ts +124 -4
- package/dist/types/session/session-storage.d.ts +155 -13
- package/dist/types/setup/credential-auto-import.d.ts +2 -2
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/receipt.d.ts +3 -1
- package/dist/types/task/types.d.ts +8 -0
- package/dist/types/tools/ask-answer-registry.d.ts +5 -0
- package/dist/types/tools/ask.d.ts +25 -1
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
- package/dist/types/tools/index.d.ts +58 -6
- package/dist/types/tools/telegram-send.d.ts +7 -0
- package/dist/types/utils/external-editor.d.ts +2 -0
- package/package.json +9 -9
- package/scripts/g004-tmux-smoke.ts +196 -36
- package/src/cli/daemon-cli.ts +19 -29
- package/src/cli/malloc-env-guard.test.ts +104 -0
- package/src/cli/malloc-env-guard.ts +149 -0
- package/src/cli/notify-cli.ts +78 -14
- package/src/cli/session-picker.ts +23 -40
- package/src/cli/update-cli.ts +56 -37
- package/src/cli.ts +51 -10
- package/src/commands/daemon.ts +19 -5
- package/src/commands/harness.ts +515 -47
- package/src/commands/launch.ts +11 -3
- package/src/commands/notify.ts +5 -1
- package/src/commands/session.ts +6 -1
- package/src/config/keybindings.ts +5 -0
- package/src/config/model-profile-activation.ts +1 -1
- package/src/config/model-profiles.ts +14 -14
- package/src/config/settings-schema.ts +41 -1
- package/src/config/settings.ts +96 -45
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/model-preset.ts +100 -0
- package/src/coordinator-mcp/policy.ts +30 -0
- package/src/coordinator-mcp/server.ts +2881 -425
- package/src/coordinator-mcp/session-reaper.ts +129 -0
- package/src/daemon/control-types.ts +11 -0
- package/src/daemon/operator-contract.ts +121 -0
- package/src/deep-interview/render-middleware.ts +67 -2
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -1
- package/src/gjc-runtime/launch-tmux.ts +702 -135
- package/src/gjc-runtime/session-state-sidecar.ts +756 -135
- package/src/gjc-runtime/team-runtime.ts +6 -0
- package/src/gjc-runtime/tmux-common.ts +26 -7
- package/src/gjc-runtime/tmux-gc.ts +52 -9
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +185 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +2060 -0
- package/src/gjc-runtime/tmux-sessions.ts +871 -68
- package/src/internal-urls/docs-index.generated.ts +12 -11
- package/src/internal-urls/local-protocol.ts +29 -10
- package/src/main.ts +301 -80
- package/src/modes/acp/acp-agent.ts +730 -100
- package/src/modes/acp/acp-mode.ts +29 -2
- package/src/modes/components/assistant-message.ts +32 -3
- package/src/modes/components/bash-execution.ts +43 -13
- package/src/modes/components/custom-editor.ts +5 -0
- package/src/modes/components/footer.ts +3 -3
- package/src/modes/components/gajae-pet-widget.ts +360 -0
- package/src/modes/components/hook-selector.ts +12 -0
- package/src/modes/components/irc-sidebar.ts +206 -0
- package/src/modes/components/pet-selector.ts +55 -0
- package/src/modes/components/session-selector.ts +222 -221
- package/src/modes/components/settings-selector.ts +10 -0
- package/src/modes/components/status-line/segments.ts +13 -6
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +4 -171
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/components/user-message.ts +26 -9
- package/src/modes/controllers/command-controller.ts +28 -2
- package/src/modes/controllers/event-controller.ts +159 -36
- package/src/modes/controllers/extension-ui-controller.ts +37 -18
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/controllers/selector-controller.ts +62 -15
- package/src/modes/interactive-mode.ts +199 -7
- package/src/modes/irc-observation-ledger.ts +127 -0
- package/src/modes/print-mode.ts +228 -92
- package/src/modes/rpc/rpc-client.ts +58 -1
- package/src/modes/rpc/rpc-mode.ts +286 -85
- package/src/modes/rpc/rpc-types.ts +15 -1
- package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
- package/src/modes/shared/agent-wire/command-validation.ts +10 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
- package/src/modes/types.ts +34 -2
- package/src/modes/utils/context-usage.ts +56 -92
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/irc-message.ts +138 -0
- package/src/modes/utils/ui-helpers.ts +216 -50
- package/src/notifications/config.ts +44 -0
- package/src/notifications/daemon-paths.ts +8 -0
- package/src/notifications/index.ts +535 -75
- package/src/notifications/lifecycle-control-runtime.ts +930 -77
- package/src/notifications/notification-service.ts +710 -0
- package/src/notifications/rate-limit-pool.ts +46 -12
- package/src/notifications/telegram-daemon-cli.ts +2 -0
- package/src/notifications/telegram-daemon-control.ts +27 -2
- package/src/notifications/telegram-daemon.ts +635 -94
- package/src/notifications/telegram-reference.ts +73 -13
- package/src/notifications/topic-registry.ts +86 -9
- package/src/prompts/system/irc-peer-roster.md +1 -0
- package/src/prompts/system/subagent-system-prompt.md +1 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/irc.md +1 -1
- package/src/registry/agent-registry.ts +3 -0
- package/src/sdk.ts +52 -1
- package/src/secrets/obfuscator.ts +4 -2
- package/src/session/agent-session.ts +670 -217
- package/src/session/cache-economics.ts +275 -0
- package/src/session/context-estimation.ts +91 -0
- package/src/session/provider-safety-stop.ts +9 -0
- package/src/session/session-dump-format.ts +6 -0
- package/src/session/session-manager.ts +809 -39
- package/src/session/session-storage.ts +582 -38
- package/src/slash-commands/builtin-registry.ts +153 -9
- package/src/slash-commands/helpers/context-report.ts +26 -4
- package/src/task/executor.ts +12 -3
- package/src/task/index.ts +8 -0
- package/src/task/receipt.ts +7 -2
- package/src/task/types.ts +26 -0
- package/src/tools/ask-answer-registry.ts +25 -0
- package/src/tools/ask.ts +355 -33
- package/src/tools/browser/tab-supervisor.ts +63 -6
- package/src/tools/browser.ts +8 -3
- package/src/tools/index.ts +81 -6
- package/src/tools/sqlite-reader.ts +8 -4
- package/src/tui/output-block.ts +18 -6
- 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 {
|
|
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 {
|
|
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 = () =>
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
650
|
-
else toolSignal.addEventListener("abort",
|
|
791
|
+
if (toolSignal.aborted) abortRace();
|
|
792
|
+
else toolSignal.addEventListener("abort", abortRace, { once: true });
|
|
651
793
|
}
|
|
652
|
-
const remote =
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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)
|
|
683
|
-
else toolSignal.addEventListener("abort",
|
|
901
|
+
if (toolSignal.aborted) abortRace();
|
|
902
|
+
else toolSignal.addEventListener("abort", abortRace, { once: true });
|
|
684
903
|
}
|
|
685
|
-
const remote =
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
328
|
-
if (forced && tab.kindTag === "headless")
|
|
329
|
-
|
|
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(
|
|
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
|
}
|