@sayknow-cli/coding-agent 0.3.12 → 0.3.15

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 (248) hide show
  1. package/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
  2. package/dist/types/autoresearch/tools/log-experiment.d.ts +2 -2
  3. package/dist/types/cli/classify-install-target.d.ts +0 -10
  4. package/dist/types/cli/daemon-cli.d.ts +2 -0
  5. package/dist/types/cli/initial-message.d.ts +1 -1
  6. package/dist/types/cli/malloc-env-guard.d.ts +65 -0
  7. package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
  8. package/dist/types/cli/notify-cli.d.ts +3 -1
  9. package/dist/types/cli/session-picker.d.ts +4 -3
  10. package/dist/types/cli/update-cli.d.ts +22 -3
  11. package/dist/types/cli.d.ts +5 -0
  12. package/dist/types/commands/daemon.d.ts +6 -2
  13. package/dist/types/commands/launch.d.ts +1 -0
  14. package/dist/types/commands/notify.d.ts +6 -0
  15. package/dist/types/commands/web-search.d.ts +1 -1
  16. package/dist/types/commit/agentic/tools/propose-changelog.d.ts +4 -4
  17. package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -9
  18. package/dist/types/commit/agentic/tools/schemas.d.ts +9 -9
  19. package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -9
  20. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  21. package/dist/types/commit/analysis/summary.d.ts +1 -1
  22. package/dist/types/commit/changelog/generate.d.ts +3 -3
  23. package/dist/types/commit/changelog/index.d.ts +2 -2
  24. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  25. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  26. package/dist/types/commit/shared-llm.d.ts +9 -9
  27. package/dist/types/config/keybindings.d.ts +134 -129
  28. package/dist/types/config/model-profile-activation.d.ts +1 -0
  29. package/dist/types/config/model-registry.d.ts +35 -35
  30. package/dist/types/config/models-config-schema.d.ts +111 -111
  31. package/dist/types/config/settings-schema.d.ts +56 -16
  32. package/dist/types/config/settings.d.ts +2 -0
  33. package/dist/types/coordinator/contract.d.ts +1 -1
  34. package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
  35. package/dist/types/coordinator-mcp/policy.d.ts +3 -0
  36. package/dist/types/coordinator-mcp/server.d.ts +79 -6
  37. package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
  38. package/dist/types/daemon/control-types.d.ts +10 -0
  39. package/dist/types/daemon/operator-contract.d.ts +47 -0
  40. package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  41. package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/shared/errors.d.ts +12 -12
  42. package/dist/types/discovery/helpers.d.ts +1 -1
  43. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  44. package/dist/types/edit/modes/patch.d.ts +2 -2
  45. package/dist/types/extensibility/extensions/types.d.ts +3 -1
  46. package/dist/types/goals/tools/goal-tool.d.ts +6 -6
  47. package/dist/types/i18n/messages/en.d.ts +8 -8
  48. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  49. package/dist/types/lsp/index.d.ts +9 -9
  50. package/dist/types/lsp/types.d.ts +9 -9
  51. package/dist/types/main.d.ts +66 -2
  52. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  53. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  54. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  55. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  56. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  57. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  58. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  59. package/dist/types/modes/components/sayknow-pet-widget.d.ts +59 -0
  60. package/dist/types/modes/components/session-selector.d.ts +22 -9
  61. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  62. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  63. package/dist/types/modes/components/status-line.d.ts +0 -10
  64. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  65. package/dist/types/modes/components/user-message.d.ts +2 -1
  66. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  67. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  68. package/dist/types/modes/interactive-mode.d.ts +24 -2
  69. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  70. package/dist/types/modes/print-mode.d.ts +3 -4
  71. package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
  72. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
  73. package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
  74. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  75. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  76. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  77. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  78. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  79. package/dist/types/modes/types.d.ts +28 -2
  80. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  81. package/dist/types/modes/utils/context-usage.d.ts +4 -19
  82. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  83. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  84. package/dist/types/notifications/config.d.ts +34 -0
  85. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  86. package/dist/types/notifications/index.d.ts +27 -0
  87. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  88. package/dist/types/notifications/notification-service.d.ts +144 -0
  89. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  90. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  91. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  92. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  93. package/dist/types/notifications/topic-registry.d.ts +24 -4
  94. package/dist/types/registry/agent-registry.d.ts +2 -0
  95. package/dist/types/rlm/index.d.ts +1 -1
  96. package/dist/types/sdk.d.ts +2 -0
  97. package/dist/types/secrets/obfuscator.d.ts +1 -1
  98. package/dist/types/session/agent-session.d.ts +40 -3
  99. package/dist/types/session/cache-economics.d.ts +79 -0
  100. package/dist/types/session/context-estimation.d.ts +31 -0
  101. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  102. package/dist/types/session/session-manager.d.ts +124 -4
  103. package/dist/types/session/session-storage.d.ts +155 -13
  104. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  105. package/dist/types/skc-runtime/session-state-sidecar.d.ts +34 -3
  106. package/dist/types/skc-runtime/state-schema.d.ts +19 -19
  107. package/dist/types/skc-runtime/tmux-common.d.ts +6 -0
  108. package/dist/types/skc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  109. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +309 -0
  110. package/dist/types/skc-runtime/tmux-sessions.d.ts +49 -17
  111. package/dist/types/task/agents.d.ts +2 -2
  112. package/dist/types/task/executor.d.ts +3 -0
  113. package/dist/types/task/receipt.d.ts +3 -1
  114. package/dist/types/task/types.d.ts +35 -27
  115. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  116. package/dist/types/tools/bash.d.ts +1 -1
  117. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  118. package/dist/types/tools/browser.d.ts +28 -28
  119. package/dist/types/tools/computer.d.ts +20 -20
  120. package/dist/types/tools/cron.d.ts +2 -2
  121. package/dist/types/tools/debug.d.ts +40 -40
  122. package/dist/types/tools/gh.d.ts +10 -10
  123. package/dist/types/tools/image-gen.d.ts +2 -2
  124. package/dist/types/tools/index.d.ts +58 -6
  125. package/dist/types/tools/monitor.d.ts +6 -6
  126. package/dist/types/tools/skill-discovery.d.ts +4 -4
  127. package/dist/types/tools/subagent.d.ts +12 -12
  128. package/dist/types/tools/telegram-send.d.ts +7 -0
  129. package/dist/types/tools/todo-write.d.ts +8 -8
  130. package/dist/types/utils/external-editor.d.ts +2 -0
  131. package/dist/types/web/insane/bridge.d.ts +9 -9
  132. package/dist/types/web/search/index.d.ts +4 -4
  133. package/package.json +8 -8
  134. package/scripts/g004-tmux-smoke.ts +196 -36
  135. package/src/cli/daemon-cli.ts +19 -29
  136. package/src/cli/malloc-env-guard.test.ts +104 -0
  137. package/src/cli/malloc-env-guard.ts +149 -0
  138. package/src/cli/notify-cli.ts +78 -14
  139. package/src/cli/session-picker.ts +23 -40
  140. package/src/cli/update-cli.ts +56 -37
  141. package/src/cli.ts +51 -10
  142. package/src/commands/daemon.ts +19 -5
  143. package/src/commands/harness.ts +515 -47
  144. package/src/commands/launch.ts +11 -3
  145. package/src/commands/notify.ts +5 -1
  146. package/src/commands/session.ts +6 -1
  147. package/src/config/keybindings.ts +5 -0
  148. package/src/config/model-profile-activation.ts +1 -1
  149. package/src/config/model-profiles.ts +14 -14
  150. package/src/config/settings-schema.ts +41 -1
  151. package/src/config/settings.ts +96 -45
  152. package/src/coordinator/contract.ts +1 -0
  153. package/src/coordinator-mcp/model-preset.ts +100 -0
  154. package/src/coordinator-mcp/policy.ts +30 -0
  155. package/src/coordinator-mcp/server.ts +2958 -430
  156. package/src/coordinator-mcp/session-reaper.ts +129 -0
  157. package/src/daemon/control-types.ts +11 -0
  158. package/src/daemon/operator-contract.ts +121 -0
  159. package/src/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  160. package/src/defaults/skc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  161. package/src/defaults/skc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  162. package/src/extensibility/extensions/runner.ts +1 -1
  163. package/src/extensibility/extensions/types.ts +3 -1
  164. package/src/internal-urls/docs-index.generated.ts +10 -9
  165. package/src/internal-urls/local-protocol.ts +29 -10
  166. package/src/main.ts +301 -80
  167. package/src/modes/acp/acp-agent.ts +730 -100
  168. package/src/modes/acp/acp-mode.ts +29 -2
  169. package/src/modes/components/assistant-message.ts +32 -3
  170. package/src/modes/components/bash-execution.ts +43 -13
  171. package/src/modes/components/custom-editor.ts +5 -0
  172. package/src/modes/components/footer.ts +3 -3
  173. package/src/modes/components/hook-selector.ts +12 -0
  174. package/src/modes/components/irc-sidebar.ts +206 -0
  175. package/src/modes/components/pet-selector.ts +55 -0
  176. package/src/modes/components/sayknow-pet-widget.ts +360 -0
  177. package/src/modes/components/session-selector.ts +222 -221
  178. package/src/modes/components/settings-selector.ts +10 -0
  179. package/src/modes/components/status-line/segments.ts +14 -7
  180. package/src/modes/components/status-line/types.ts +1 -1
  181. package/src/modes/components/status-line.ts +4 -171
  182. package/src/modes/components/tool-execution.ts +47 -6
  183. package/src/modes/components/user-message.ts +26 -9
  184. package/src/modes/controllers/command-controller.ts +28 -2
  185. package/src/modes/controllers/event-controller.ts +159 -36
  186. package/src/modes/controllers/extension-ui-controller.ts +37 -18
  187. package/src/modes/controllers/input-controller.ts +8 -2
  188. package/src/modes/controllers/selector-controller.ts +62 -15
  189. package/src/modes/interactive-mode.ts +199 -7
  190. package/src/modes/irc-observation-ledger.ts +127 -0
  191. package/src/modes/print-mode.ts +228 -92
  192. package/src/modes/rpc/rpc-client.ts +58 -1
  193. package/src/modes/rpc/rpc-mode.ts +286 -85
  194. package/src/modes/rpc/rpc-types.ts +15 -1
  195. package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
  196. package/src/modes/shared/agent-wire/command-validation.ts +10 -0
  197. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  198. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  199. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  200. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  201. package/src/modes/types.ts +34 -2
  202. package/src/modes/utils/context-usage.ts +56 -92
  203. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  204. package/src/modes/utils/irc-message.ts +138 -0
  205. package/src/modes/utils/ui-helpers.ts +216 -50
  206. package/src/notifications/config.ts +44 -0
  207. package/src/notifications/daemon-paths.ts +8 -0
  208. package/src/notifications/index.ts +535 -75
  209. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  210. package/src/notifications/notification-service.ts +710 -0
  211. package/src/notifications/rate-limit-pool.ts +46 -12
  212. package/src/notifications/telegram-daemon-control.ts +27 -2
  213. package/src/notifications/telegram-daemon.ts +635 -94
  214. package/src/notifications/telegram-reference.ts +73 -13
  215. package/src/notifications/topic-registry.ts +86 -9
  216. package/src/prompts/system/irc-peer-roster.md +1 -0
  217. package/src/prompts/system/subagent-system-prompt.md +1 -0
  218. package/src/prompts/system/system-prompt.md +1 -0
  219. package/src/prompts/tools/irc.md +1 -1
  220. package/src/registry/agent-registry.ts +3 -0
  221. package/src/sdk.ts +52 -1
  222. package/src/secrets/obfuscator.ts +4 -2
  223. package/src/session/agent-session.ts +670 -217
  224. package/src/session/cache-economics.ts +275 -0
  225. package/src/session/context-estimation.ts +91 -0
  226. package/src/session/provider-safety-stop.ts +9 -0
  227. package/src/session/session-dump-format.ts +6 -0
  228. package/src/session/session-manager.ts +809 -39
  229. package/src/session/session-storage.ts +582 -38
  230. package/src/skc-runtime/session-state-sidecar.ts +756 -135
  231. package/src/skc-runtime/tmux-common.ts +26 -7
  232. package/src/skc-runtime/tmux-gc.ts +52 -9
  233. package/src/skc-runtime/tmux-owner-isolation-cli.ts +185 -0
  234. package/src/skc-runtime/tmux-owner-isolation.ts +2060 -0
  235. package/src/skc-runtime/tmux-sessions.ts +871 -68
  236. package/src/slash-commands/builtin-registry.ts +153 -9
  237. package/src/slash-commands/helpers/context-report.ts +26 -4
  238. package/src/task/executor.ts +12 -3
  239. package/src/task/index.ts +8 -0
  240. package/src/task/receipt.ts +7 -2
  241. package/src/task/types.ts +26 -0
  242. package/src/tools/ask-answer-registry.ts +25 -0
  243. package/src/tools/browser/tab-supervisor.ts +63 -6
  244. package/src/tools/browser.ts +8 -3
  245. package/src/tools/index.ts +81 -6
  246. package/src/tools/sqlite-reader.ts +8 -4
  247. package/src/tui/output-block.ts +18 -6
  248. package/src/utils/external-editor.ts +6 -1
@@ -3,7 +3,15 @@ import * as path from "node:path";
3
3
  import { ThinkingLevel } from "@sayknow-cli/agent-core";
4
4
  import { type Model, modelsAreEqual } from "@sayknow-cli/ai";
5
5
  import { getOAuthProviders } from "@sayknow-cli/ai/utils/oauth";
6
- import { Spacer, Text } from "@sayknow-cli/tui";
6
+ import {
7
+ isPetMode,
8
+ isUnderTerminalMultiplexer,
9
+ PET_MODE_IDS,
10
+ PET_SKIN_IDS,
11
+ PET_SKINS,
12
+ Spacer,
13
+ Text,
14
+ } from "@sayknow-cli/tui";
7
15
  import { setProjectDir } from "@sayknow-cli/utils";
8
16
  import { jobElapsedMs } from "../async";
9
17
  import { materializeActiveModelProfileAssignments } from "../config/model-profile-activation";
@@ -23,8 +31,20 @@ import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../
23
31
  import { td } from "../i18n";
24
32
  import { resolveMemoryBackend } from "../memory-backend";
25
33
  import { DynamicBorder } from "../modes/components/dynamic-border";
34
+ import { SayknowPetWidget } from "../modes/components/sayknow-pet-widget";
26
35
  import { theme } from "../modes/theme/theme";
27
36
  import type { InteractiveModeContext } from "../modes/types";
37
+ import {
38
+ buildNotificationStatusReport,
39
+ checkNotificationHealth,
40
+ formatNotificationHealthReport,
41
+ formatNotificationRecoveryReport,
42
+ formatNotificationStatusReport,
43
+ formatNotificationTestResult,
44
+ recoverNotifications,
45
+ sendNotificationTest,
46
+ } from "../notifications/notification-service";
47
+ import { computeCacheMissCostSummary, formatCacheMissSummaryLines } from "../session/cache-economics";
28
48
  import { formatModelOnboardingGuidance } from "../setup/model-onboarding-guidance";
29
49
  import {
30
50
  addApiCompatibleProvider,
@@ -37,7 +57,7 @@ import { getDisplayChangelogEntries } from "../utils/changelog";
37
57
  import { buildContextReportText } from "./helpers/context-report";
38
58
  import { buildFastStatusReport } from "./helpers/fast-status-report";
39
59
  import { formatDuration } from "./helpers/format";
40
- import { commandConsumed, errorMessage, parseSlashCommand, usage } from "./helpers/parse";
60
+ import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse";
41
61
  import { handleSshAcp } from "./helpers/ssh";
42
62
  import { buildUsageReportText } from "./helpers/usage-report";
43
63
  import type {
@@ -471,6 +491,66 @@ const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashComma
471
491
  };
472
492
 
473
493
  const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
494
+ {
495
+ name: "notify",
496
+ priority: 30,
497
+ description: "Notification status, health, test, recovery, and session on/off",
498
+ acpDescription: "Notification status, health, test, recovery, and session on/off",
499
+ subcommands: [
500
+ { name: "on", description: "Enable notifications for this session" },
501
+ { name: "off", description: "Disable notifications for this session" },
502
+ { name: "status", description: "Show notification configuration (no secrets)" },
503
+ { name: "health", description: "Config, daemon-ownership and endpoint health" },
504
+ { name: "test", description: "Send a test notification", usage: "[message]" },
505
+ { name: "recovery", description: "Clear dead-owner locks and stale endpoint files" },
506
+ { name: "setup", description: "How to pair a Telegram bot (run in a terminal)" },
507
+ ],
508
+ inlineHint: "[on|off|status|health|test|recovery|setup]",
509
+ acpInputHint: "[on|off|status|health|test|recovery|setup]",
510
+ allowArgs: true,
511
+ handle: async (command, runtime) => {
512
+ const { verb, rest } = parseSubcommand(command.args);
513
+ const action = verb || "status";
514
+ // `on`/`off` are session-local runtime controls owned by the
515
+ // notifications extension command (`api.registerCommand("notify")`),
516
+ // which holds the live per-session server/disable state. Pass them
517
+ // through untouched — never consume them — so this builtin cannot
518
+ // shadow that control. Everything below is config/service diagnostics
519
+ // the extension does not implement, so the builtin owns them exclusively
520
+ // (and the extension therefore never consumes them).
521
+ if (action === "on" || action === "off") {
522
+ return { prompt: command.text };
523
+ }
524
+ const stateRoot = path.join(runtime.cwd, ".skc", "state");
525
+ switch (action) {
526
+ case "status":
527
+ await runtime.output(formatNotificationStatusReport(buildNotificationStatusReport(runtime.settings)));
528
+ return commandConsumed();
529
+ case "health": {
530
+ const report = await checkNotificationHealth({ settings: runtime.settings, stateRoot });
531
+ await runtime.output(formatNotificationHealthReport(report));
532
+ return commandConsumed();
533
+ }
534
+ case "test": {
535
+ const result = await sendNotificationTest({ settings: runtime.settings, text: rest || undefined });
536
+ await runtime.output(formatNotificationTestResult(result));
537
+ return commandConsumed();
538
+ }
539
+ case "recovery": {
540
+ const report = await recoverNotifications({ settings: runtime.settings, stateRoot });
541
+ await runtime.output(formatNotificationRecoveryReport(report));
542
+ return commandConsumed();
543
+ }
544
+ case "setup":
545
+ return usage(
546
+ "Run `skc notify setup` in a terminal to pair a Telegram bot token with a private chat (interactive; requires a TTY).",
547
+ runtime,
548
+ );
549
+ default:
550
+ return usage(`Usage: /notify [on|off|status|health|test|recovery|setup] (got "${action}")`, runtime);
551
+ }
552
+ },
553
+ },
474
554
  {
475
555
  name: "settings",
476
556
  priority: 40,
@@ -488,6 +568,41 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
488
568
  runtime.ctx.editor.setText("");
489
569
  },
490
570
  },
571
+ {
572
+ name: "pet",
573
+ description: "Sayknow pet living beside the composer",
574
+ subcommands: [
575
+ { name: "off", description: "Hide the pet" },
576
+ ...PET_SKIN_IDS.map(id => ({ name: id, description: PET_SKINS[id].description })),
577
+ ],
578
+ inlineHint: `[${PET_MODE_IDS.join("|")}]`,
579
+ allowArgs: true,
580
+ handleTui: (command, runtime) => {
581
+ const ctx = runtime.ctx;
582
+ const raw = command.args?.trim().toLowerCase() ?? "";
583
+ const arg = raw === "on" ? "red" : raw;
584
+ if (!arg) {
585
+ ctx.showPetSelector();
586
+ ctx.editor.setText("");
587
+ return;
588
+ }
589
+ if (arg !== "off" && !SayknowPetWidget.pixelProtocol()) {
590
+ ctx.showStatus(
591
+ isUnderTerminalMultiplexer()
592
+ ? "Sayknow pet: graphics are suppressed inside tmux/screen/zellij — escapes are not forwarded end-to-end. Run skc outside the multiplexer in a sixel/kitty-graphics terminal, or set PI_FORCE_IMAGE_PROTOCOL=sixel when your multiplexer+client chain renders sixel."
593
+ : "Sayknow pet needs a sixel/kitty-graphics terminal (Windows Terminal 1.22+, kitty, Ghostty, WezTerm)",
594
+ { dim: true },
595
+ );
596
+ } else if (isPetMode(arg)) {
597
+ ctx.setPetMode(arg);
598
+ const name = arg === "off" ? "Sayknow pet hidden" : `${PET_SKINS[arg].label} is here`;
599
+ ctx.showStatus(name);
600
+ } else {
601
+ ctx.showStatus(`Usage: /pet [${PET_MODE_IDS.join("|")}]`, { dim: true });
602
+ }
603
+ ctx.editor.setText("");
604
+ },
605
+ },
491
606
  {
492
607
  name: "goal",
493
608
  priority: 84,
@@ -821,13 +936,42 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
821
936
  allowArgs: true,
822
937
  handle: async (command, runtime) => {
823
938
  if (!command.args || command.args === "info") {
824
- await runtime.output(
825
- [
826
- `Session: ${runtime.session.sessionId}`,
827
- `Title: ${runtime.session.sessionName}`,
828
- `CWD: ${runtime.cwd}`,
829
- ].join("\n"),
830
- );
939
+ const stats = runtime.session.getSessionStats();
940
+ const lines = [
941
+ `Session: ${runtime.session.sessionId}`,
942
+ `Title: ${runtime.session.sessionName}`,
943
+ `CWD: ${runtime.cwd}`,
944
+ "",
945
+ "Tokens",
946
+ `Input: ${stats.tokens.input.toLocaleString()}`,
947
+ `Output: ${stats.tokens.output.toLocaleString()}`,
948
+ ];
949
+ if (stats.tokens.cacheRead > 0) {
950
+ lines.push(`Cache Read: ${stats.tokens.cacheRead.toLocaleString()}`);
951
+ }
952
+ if (stats.tokens.cacheWrite > 0) {
953
+ lines.push(`Cache Write: ${stats.tokens.cacheWrite.toLocaleString()}`);
954
+ }
955
+ lines.push(`Total: ${stats.tokens.total.toLocaleString()}`);
956
+ if (stats.cost > 0 || stats.premiumRequests > 0) {
957
+ lines.push("", "Cost");
958
+ if (stats.cost > 0) {
959
+ lines.push(`Total: ${stats.cost.toFixed(4)}`);
960
+ }
961
+ if (stats.premiumRequests > 0) {
962
+ lines.push(`Premium Requests: ${stats.premiumRequests.toLocaleString()}`);
963
+ }
964
+ }
965
+ const cacheMissSummary = stats.costBreakdown
966
+ ? computeCacheMissCostSummary(stats.tokens, {
967
+ kind: "persisted-aggregate",
968
+ costBreakdown: stats.costBreakdown,
969
+ })
970
+ : undefined;
971
+ if (cacheMissSummary) {
972
+ lines.push("", "Cache Miss Cost", ...formatCacheMissSummaryLines(cacheMissSummary));
973
+ }
974
+ await runtime.output(lines.join("\n"));
831
975
  return commandConsumed();
832
976
  }
833
977
  if (command.args === "delete") {
@@ -83,6 +83,12 @@ function formatReserveText(runtime: SlashCommandRuntime, contextWindow: number,
83
83
  return "unknown";
84
84
  }
85
85
 
86
+ function contextUsageSourceLabel(source: "provider_anchor" | "heuristic" | "unknown"): string {
87
+ if (source === "provider_anchor") return "provider-reported";
88
+ if (source === "unknown") return "estimated; exact count unknown until next response";
89
+ return "estimated";
90
+ }
91
+
86
92
  /**
87
93
  * Build the `/context` ACP-mode text. Tries the rich breakdown first
88
94
  * (categories + auto-compact buffer + free slack) and falls back to the
@@ -96,11 +102,21 @@ export function buildContextReportText(runtime: SlashCommandRuntime): string {
96
102
  return "Context usage is unavailable: no model is selected for this session.";
97
103
  }
98
104
  const promptUsage = findLastAssistantUsage(history.activeMessages);
99
- const usedPct = (breakdown.usedTokens / breakdown.contextWindow) * 100;
105
+ const activeContext =
106
+ breakdown.usedTokens === null
107
+ ? `Active context: unknown / ${breakdown.contextWindow.toLocaleString()} tokens (exact count unknown until next response) (${contextUsageSourceLabel(breakdown.source)})`
108
+ : `Active context: ${breakdown.usedTokens.toLocaleString()} / ${breakdown.contextWindow.toLocaleString()} tokens (${((breakdown.usedTokens / breakdown.contextWindow) * 100).toFixed(1)}% used) (${contextUsageSourceLabel(breakdown.source)})`;
100
109
  const lines = [
101
110
  "Context usage",
102
111
  `Model: ${breakdown.model?.provider ?? "unknown"}/${breakdown.model?.id ?? "unknown"}`,
103
- `Active context: ${breakdown.usedTokens.toLocaleString()} / ${breakdown.contextWindow.toLocaleString()} tokens (${usedPct.toFixed(1)}% used)`,
112
+ activeContext,
113
+ ...(breakdown.source !== "unknown" &&
114
+ breakdown.usedTokens !== null &&
115
+ breakdown.estimatedCategoryTotal !== breakdown.usedTokens
116
+ ? [
117
+ `Estimated category total: ${breakdown.estimatedCategoryTotal.toLocaleString()} tokens (composition below is estimated)`,
118
+ ]
119
+ : []),
104
120
  `Reserve: ${formatReserveText(runtime, breakdown.contextWindow, breakdown.autoCompactBufferTokens)}`,
105
121
  "",
106
122
  "Active context breakdown (estimated)",
@@ -111,7 +127,13 @@ export function buildContextReportText(runtime: SlashCommandRuntime): string {
111
127
  if (breakdown.autoCompactBufferTokens > 0) {
112
128
  lines.push(formatTokenLine("Reserve", breakdown.autoCompactBufferTokens, breakdown.contextWindow));
113
129
  }
114
- lines.push(formatTokenLine("Free", breakdown.freeTokens, breakdown.contextWindow));
130
+ lines.push(
131
+ formatTokenLine(
132
+ breakdown.usedTokens === null ? "Free (estimated)" : "Free",
133
+ breakdown.freeTokens,
134
+ breakdown.contextWindow,
135
+ ),
136
+ );
115
137
  lines.push(
116
138
  "",
117
139
  "History",
@@ -141,7 +163,7 @@ export function buildContextReportText(runtime: SlashCommandRuntime): string {
141
163
  if (!fallback) return "Context usage is unavailable.";
142
164
  return [
143
165
  "Context usage",
144
- `Active context: ${fallback.tokens === null || fallback.tokens === undefined ? "unknown" : fallback.tokens.toLocaleString()}`,
166
+ `Active context: ${fallback.tokens === null ? "unknown" : fallback.tokens.toLocaleString()} (${contextUsageSourceLabel(fallback.source)})`,
145
167
  `Context window: ${fallback.contextWindow.toLocaleString()}`,
146
168
  "Breakdown: unknown",
147
169
  ].join("\n");
@@ -47,6 +47,7 @@ import { persistTaskTokenLog, taskTokenLogFromUsage } from "./token-log";
47
47
  import {
48
48
  type AgentDefinition,
49
49
  type AgentProgress,
50
+ hasCompleteUsageCostBreakdown,
50
51
  MAX_OUTPUT_BYTES,
51
52
  MAX_OUTPUT_LINES,
52
53
  type ModelSubstitutionWarning,
@@ -86,8 +87,8 @@ function normalizeModelPatterns(value: string | string[] | undefined): string[]
86
87
  .filter(Boolean);
87
88
  }
88
89
 
89
- function renderIrcPeerRoster(selfId: string): string {
90
- const peers = AgentRegistry.global()
90
+ function renderIrcPeerRoster(agentRegistry: AgentRegistry, selfId: string): string {
91
+ const peers = agentRegistry
91
92
  .list()
92
93
  .filter(ref => ref.id !== selfId && (ref.status === "running" || ref.status === "idle"));
93
94
  if (peers.length === 0) return "- (no other live agents)";
@@ -151,6 +152,8 @@ export interface ExecutorOptions {
151
152
  authStorage?: AuthStorage;
152
153
  modelRegistry?: ModelRegistry;
153
154
  settings?: Settings;
155
+ /** Parent session's registry; shared by child sessions for IRC routing and roster visibility. */
156
+ agentRegistry?: AgentRegistry;
154
157
  /**
155
158
  * Live service-tier intent of the parent session (`AgentSession.serviceTier`),
156
159
  * used as the inherited tier when `task.serviceTier === "inherit"`. Passing the
@@ -705,6 +708,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
705
708
  : agent.spawns.join(",");
706
709
 
707
710
  const lspEnabled = enableLsp ?? true;
711
+ const agentRegistry = options.agentRegistry ?? AgentRegistry.global();
708
712
  const ircEnabled = options.ircAvailable === true;
709
713
  const contextFileForPrompt = ircEnabled ? undefined : options.contextFile;
710
714
  const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
@@ -743,6 +747,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
743
747
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
744
748
  };
745
749
  let hasUsage = false;
750
+ let usageCostBreakdownComplete = true;
746
751
 
747
752
  const requestAbort = (reason: AbortReason) => {
748
753
  if (reason === "timeout") {
@@ -1109,6 +1114,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1109
1114
  const usageRecord = messageUsage as Record<string, unknown>;
1110
1115
  const costRecord = (messageUsage as { cost?: Record<string, unknown> }).cost;
1111
1116
  hasUsage = true;
1117
+ usageCostBreakdownComplete &&= hasCompleteUsageCostBreakdown(usageRecord);
1112
1118
  accumulatedUsage.input += getNumberField(usageRecord, "input") ?? 0;
1113
1119
  accumulatedUsage.output += getNumberField(usageRecord, "output") ?? 0;
1114
1120
  accumulatedUsage.cacheRead += getNumberField(usageRecord, "cacheRead") ?? 0;
@@ -1382,7 +1388,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1382
1388
  worktree: worktree ?? "",
1383
1389
  outputSchema: normalizedOutputSchema,
1384
1390
  contextFile: contextFileForPrompt,
1385
- ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
1391
+ ircPeers: ircEnabled ? renderIrcPeerRoster(agentRegistry, id) : "",
1386
1392
  ircSelfId: ircEnabled ? id : "",
1387
1393
  forkContext: forkContextNotice,
1388
1394
  });
@@ -1411,6 +1417,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1411
1417
  parentTaskPrefix: id,
1412
1418
  agentId: id,
1413
1419
  agentDisplayName: agent.name,
1420
+ agentRosterLabel: options.description,
1414
1421
  bashAllowedPrefixes: agent.bashAllowedPrefixes,
1415
1422
  enableLsp: lspEnabled,
1416
1423
  skipPythonPreflight,
@@ -1418,6 +1425,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1418
1425
  localProtocolOptions: options.localProtocolOptions,
1419
1426
  telemetry: subagentTelemetry,
1420
1427
  forkContextSeed: options.forkContextSeed,
1428
+ agentRegistry,
1421
1429
  shouldPause: () => pauseRequested,
1422
1430
  }),
1423
1431
  );
@@ -1866,6 +1874,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1866
1874
  abortReason: finalAbortReason,
1867
1875
  paused,
1868
1876
  usage: hasUsage ? accumulatedUsage : undefined,
1877
+ usageCostBreakdownComplete: hasUsage && usageCostBreakdownComplete ? true : undefined,
1869
1878
  outputPath,
1870
1879
  extractedToolData: progress.extractedToolData,
1871
1880
  retryFailure: progress.retryFailure,
package/src/task/index.ts CHANGED
@@ -34,12 +34,14 @@ import {
34
34
  type ForkContextMode,
35
35
  type ForkContextPolicy,
36
36
  getTaskSchema,
37
+ hasCompleteAggregateUsageCostBreakdown,
37
38
  type SingleResult,
38
39
  type TaskItem,
39
40
  type TaskParams,
40
41
  type TaskToolDetails,
41
42
  type TaskToolSchemaInstance,
42
43
  } from "./types";
44
+
43
45
  // Import review tools for side effects (registers subagent tool handlers)
44
46
  import "../tools/review";
45
47
  import type { LocalProtocolOptions } from "../internal-urls";
@@ -53,6 +55,7 @@ import { getTaskIdValidationError, validateAllocatedTaskId } from "./id";
53
55
  import { AgentOutputManager } from "./output-manager";
54
56
  import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
55
57
  import { assertNoRawTaskFields, buildTaskReceipt, buildTaskRoiSummary } from "./receipt";
58
+
56
59
  import { renderResult, renderCall as renderTaskCall } from "./render";
57
60
  import { reconcileSpawnRoi } from "./roi-reconciliation";
58
61
  import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
@@ -1316,6 +1319,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1316
1319
  },
1317
1320
  authStorage: this.session.authStorage,
1318
1321
  modelRegistry: this.session.modelRegistry,
1322
+ agentRegistry: this.session.agentRegistry,
1319
1323
  settings: this.session.settings,
1320
1324
  inheritedServiceTier: this.session.serviceTier,
1321
1325
  contextFiles,
@@ -1379,6 +1383,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1379
1383
  },
1380
1384
  authStorage: this.session.authStorage,
1381
1385
  modelRegistry: this.session.modelRegistry,
1386
+ agentRegistry: this.session.agentRegistry,
1382
1387
  settings: this.session.settings,
1383
1388
  inheritedServiceTier: this.session.serviceTier,
1384
1389
  contextFiles,
@@ -1529,6 +1534,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1529
1534
  // Aggregate usage from executor results (already accumulated incrementally)
1530
1535
  const aggregatedUsage = createUsageTotals();
1531
1536
  let hasAggregatedUsage = false;
1537
+
1532
1538
  for (const result of results) {
1533
1539
  if (result.usage) {
1534
1540
  addUsageTotals(aggregatedUsage, result.usage);
@@ -1734,6 +1740,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1734
1740
  results: receipts,
1735
1741
  totalDurationMs: totalDuration,
1736
1742
  usage: hasAggregatedUsage ? aggregatedUsage : undefined,
1743
+ usageCostBreakdownComplete:
1744
+ hasAggregatedUsage && hasCompleteAggregateUsageCostBreakdown(results) ? true : undefined,
1737
1745
  forkContextClonedTokens: forkContextClonedTokens > 0 ? forkContextClonedTokens : undefined,
1738
1746
  roiSummary,
1739
1747
  roiReconciliation,
@@ -1,5 +1,4 @@
1
- import type { SingleResult, TaskToolDetails } from "./types";
2
-
1
+ import { hasCompleteUsageCostBreakdown, type SingleResult, type TaskToolDetails } from "./types";
3
2
  export interface TaskRoi {
4
3
  tokens: number;
5
4
  contextTokens?: number;
@@ -32,6 +31,7 @@ export interface TaskResultReceipt {
32
31
  modelSubstitutionWarning?: SingleResult["modelSubstitutionWarning"];
33
32
  usage?: SingleResult["usage"];
34
33
  cost?: number;
34
+ usageCostBreakdownComplete?: true;
35
35
  branchName?: string;
36
36
  retryFailure?: { attempt: number; errorSummary: string };
37
37
  errorSummary?: string;
@@ -246,6 +246,8 @@ export function buildTaskReceipt(raw: SingleResult): TaskResultReceipt {
246
246
  modelSubstitutionWarning: raw.modelSubstitutionWarning,
247
247
  usage: raw.usage,
248
248
  cost: raw.usage?.cost.total,
249
+ usageCostBreakdownComplete:
250
+ raw.usageCostBreakdownComplete === true && hasCompleteUsageCostBreakdown(raw.usage) ? true : undefined,
249
251
  branchName: raw.branchName,
250
252
  retryFailure: raw.retryFailure
251
253
  ? { attempt: raw.retryFailure.attempt, errorSummary: "Retry failure recorded." }
@@ -274,6 +276,7 @@ export interface RawTaskToolDetails {
274
276
  results: SingleResult[];
275
277
  totalDurationMs: number;
276
278
  usage?: TaskToolDetails["usage"];
279
+ usageCostBreakdownComplete?: TaskToolDetails["usageCostBreakdownComplete"];
277
280
  async?: TaskToolDetails["async"];
278
281
  forkContextClonedTokens?: number;
279
282
  roiSummary?: TaskToolDetails["roiSummary"];
@@ -286,6 +289,8 @@ export function sanitizeTaskToolDetails(raw: RawTaskToolDetails): TaskToolDetail
286
289
  results: raw.results.map(buildTaskReceipt),
287
290
  totalDurationMs: raw.totalDurationMs,
288
291
  usage: raw.usage,
292
+ usageCostBreakdownComplete:
293
+ raw.usageCostBreakdownComplete === true && hasCompleteUsageCostBreakdown(raw.usage) ? true : undefined,
289
294
  forkContextClonedTokens: raw.forkContextClonedTokens,
290
295
  roiSummary: raw.roiSummary ?? buildTaskRoiSummary(raw.results.map(buildTaskReceipt)),
291
296
  async: raw.async,
package/src/task/types.ts CHANGED
@@ -320,6 +320,8 @@ export interface SingleResult {
320
320
  paused?: boolean;
321
321
  /** Aggregated usage from the subprocess, accumulated incrementally from message_end events. */
322
322
  usage?: Usage;
323
+ /** True only when every usage-contributing assistant supplied a complete, non-negative raw cost breakdown. */
324
+ usageCostBreakdownComplete?: true;
323
325
  /** Output path for the task result */
324
326
  outputPath?: string;
325
327
  /** Patch path for isolated worktree output */
@@ -353,6 +355,28 @@ export interface SingleResult {
353
355
  forkContextAdvisory?: { recommendedMode: ForkContextMode; reasons: string[] };
354
356
  }
355
357
 
358
+ /** True only for complete, factual five-bucket cost accounting. */
359
+ export function hasCompleteUsageCostBreakdown(usage: unknown): boolean {
360
+ if (!usage || typeof usage !== "object") return false;
361
+ const cost = (usage as { cost?: unknown }).cost;
362
+ if (!cost || typeof cost !== "object") return false;
363
+ return ["input", "output", "cacheRead", "cacheWrite", "total"].every(key => {
364
+ const value = (cost as Record<string, unknown>)[key];
365
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
366
+ });
367
+ }
368
+
369
+ /** True only when every usage-contributing result has explicit, complete cost provenance. */
370
+ export function hasCompleteAggregateUsageCostBreakdown(
371
+ results: readonly Pick<SingleResult, "usage" | "usageCostBreakdownComplete">[],
372
+ ): boolean {
373
+ return results.every(
374
+ result =>
375
+ result.usage === undefined ||
376
+ (result.usageCostBreakdownComplete === true && hasCompleteUsageCostBreakdown(result.usage)),
377
+ );
378
+ }
379
+
356
380
  /** Tool details for TUI rendering */
357
381
  export interface TaskToolDetails {
358
382
  projectAgentsDir: string | null;
@@ -360,6 +384,8 @@ export interface TaskToolDetails {
360
384
  totalDurationMs: number;
361
385
  /** Aggregated usage across all subagents. */
362
386
  usage?: Usage;
387
+ /** True only when every usage-contributing subagent supplied a complete raw cost breakdown. */
388
+ usageCostBreakdownComplete?: true;
363
389
  /** Aggregate cloned tokens copied into fork-context seeds across subagents. */
364
390
  forkContextClonedTokens?: number;
365
391
  roiSummary?: {
@@ -7,9 +7,12 @@
7
7
  * source; registering returns a disposer.
8
8
  */
9
9
 
10
+ import type { WorkflowGateEmitter } from "../modes/shared/agent-wire/unattended-session";
10
11
  import type { AskAnswerSource } from "./index";
11
12
 
12
13
  const sources = new Map<string, AskAnswerSource>();
14
+ const workflowGateEmitters = new Map<string, WorkflowGateEmitter>();
15
+ const workflowGateListeners = new Map<string, Set<(emitter: WorkflowGateEmitter | undefined) => void>>();
13
16
 
14
17
  /** Register `source` for `sessionId`. Returns a disposer that clears it. */
15
18
  export function registerAskAnswerSource(sessionId: string, source: AskAnswerSource): () => void {
@@ -23,3 +26,25 @@ export function registerAskAnswerSource(sessionId: string, source: AskAnswerSour
23
26
  export function getAskAnswerSource(sessionId: string): AskAnswerSource | undefined {
24
27
  return sources.get(sessionId);
25
28
  }
29
+
30
+ /** Publish a session's current workflow-gate emitter after mode initialization. */
31
+ export function notifyWorkflowGateEmitterChanged(sessionId: string, emitter: WorkflowGateEmitter | undefined): void {
32
+ if (emitter) workflowGateEmitters.set(sessionId, emitter);
33
+ else workflowGateEmitters.delete(sessionId);
34
+ for (const listener of workflowGateListeners.get(sessionId) ?? []) listener(emitter);
35
+ }
36
+
37
+ /** Observe workflow-gate emitter installation even when it occurs after session_start. */
38
+ export function registerWorkflowGateEmitterListener(
39
+ sessionId: string,
40
+ listener: (emitter: WorkflowGateEmitter | undefined) => void,
41
+ ): () => void {
42
+ const listeners = workflowGateListeners.get(sessionId) ?? new Set();
43
+ listeners.add(listener);
44
+ workflowGateListeners.set(sessionId, listeners);
45
+ listener(workflowGateEmitters.get(sessionId));
46
+ return () => {
47
+ listeners.delete(listener);
48
+ if (listeners.size === 0) workflowGateListeners.delete(sessionId);
49
+ };
50
+ }
@@ -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
  }
@@ -187,7 +187,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
187
187
  case "open":
188
188
  return await this.#open(name, params, details, timeoutMs, signal);
189
189
  case "close":
190
- return await this.#close(name, params, details, signal);
190
+ return await this.#close(name, params, details, timeoutMs, signal);
191
191
  case "run":
192
192
  return await this.#run(name, params, details, timeoutMs, signal);
193
193
  case "act":
@@ -274,15 +274,20 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
274
274
  name: string,
275
275
  params: BrowserParams,
276
276
  details: BrowserToolDetails,
277
+ timeoutMs: number,
277
278
  signal?: AbortSignal,
278
279
  ): Promise<AgentToolResult<BrowserToolDetails>> {
279
280
  const kill = !!params.kill;
281
+ // One end-to-end budget across the whole close/close-all teardown chain (#2027):
282
+ // releaseAllTabs → waitForClosed → worker.terminate → releaseBrowser stay bounded so
283
+ // a CDP target dying mid-teardown cannot hang the tool call or the agent turn.
284
+ const deadlineAt = Date.now() + timeoutMs;
280
285
  if (params.all) {
281
- const count = await untilAborted(signal, () => releaseAllTabs({ kill }));
286
+ const count = await untilAborted(signal, () => releaseAllTabs({ kill, deadlineAt }));
282
287
  details.result = `Closed ${count} tab(s)`;
283
288
  return toolResult(details).text(details.result).done();
284
289
  }
285
- const closed = await untilAborted(signal, () => releaseTab(name, { kill }));
290
+ const closed = await untilAborted(signal, () => releaseTab(name, { kill, deadlineAt }));
286
291
  details.result = closed ? `Closed tab ${JSON.stringify(name)}` : `No tab named ${JSON.stringify(name)}`;
287
292
  return toolResult(details).text(details.result).done();
288
293
  }