@gajae-code/coding-agent 0.16.0 → 0.16.3

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 (164) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/update-cli.d.ts +1 -0
  5. package/dist/types/commands/sdk.d.ts +2 -0
  6. package/dist/types/config/billing-path.d.ts +25 -0
  7. package/dist/types/config/config-schema-version.d.ts +13 -1
  8. package/dist/types/config/model-registry.d.ts +16 -0
  9. package/dist/types/config/settings-schema.d.ts +17 -7
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  11. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  12. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  15. package/dist/types/modes/components/model-selector.d.ts +4 -0
  16. package/dist/types/modes/components/session-selector.d.ts +1 -1
  17. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +16 -2
  19. package/dist/types/modes/theme/theme.d.ts +2 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/sdk/broker/broker.d.ts +2 -1
  22. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
  25. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  26. package/dist/types/sdk/broker/state-version.d.ts +17 -0
  27. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  28. package/dist/types/sdk/bus/index.d.ts +3 -1
  29. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  30. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  31. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  32. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  33. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  34. package/dist/types/sdk/cli/rows.d.ts +15 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +2 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  39. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  40. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  41. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  42. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  43. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  44. package/dist/types/sdk/receipt-state.d.ts +2 -0
  45. package/dist/types/sdk/router/session-router.d.ts +4 -0
  46. package/dist/types/sdk/turn-result.d.ts +6 -0
  47. package/dist/types/session/agent-session.d.ts +47 -7
  48. package/dist/types/session/fold-coordinator.d.ts +29 -2
  49. package/dist/types/session/session-manager.d.ts +14 -2
  50. package/dist/types/tools/ask.d.ts +1 -0
  51. package/dist/types/tools/bash.d.ts +12 -0
  52. package/dist/types/tools/browser/launch.d.ts +10 -0
  53. package/dist/types/tools/browser/registry.d.ts +8 -0
  54. package/dist/types/tools/index.d.ts +4 -10
  55. package/dist/types/tools/job.d.ts +3 -1
  56. package/dist/types/utils/external-editor.d.ts +12 -0
  57. package/examples/extensions/README.md +1 -1
  58. package/examples/extensions/pirate.ts +7 -4
  59. package/package.json +7 -7
  60. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  61. package/src/async/job-manager.ts +107 -9
  62. package/src/browser-backend.ts +29 -0
  63. package/src/cli/plugin-cli.ts +29 -0
  64. package/src/cli/update-cli.ts +63 -12
  65. package/src/commands/sdk.ts +22 -7
  66. package/src/config/billing-path.ts +42 -0
  67. package/src/config/config-schema-version.ts +18 -1
  68. package/src/config/model-preset-registry.ts +177 -18
  69. package/src/config/model-registry.ts +268 -42
  70. package/src/config/settings-schema.ts +19 -7
  71. package/src/config/settings.ts +46 -15
  72. package/src/coordinator-mcp/projection-scan.ts +6 -6
  73. package/src/coordinator-mcp/server.ts +21 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  75. package/src/extensibility/extensions/runner.ts +27 -0
  76. package/src/extensibility/extensions/types.ts +8 -3
  77. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  78. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  79. package/src/extensibility/skills.ts +22 -12
  80. package/src/gjc-runtime/launch-worktree.ts +285 -4
  81. package/src/gjc-runtime/session-state-lock.ts +156 -32
  82. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  83. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  84. package/src/internal-urls/docs-index.generated.ts +11 -11
  85. package/src/main.ts +4 -1
  86. package/src/modes/acp/acp-agent.ts +34 -19
  87. package/src/modes/components/model-selector.ts +76 -8
  88. package/src/modes/components/session-selector.ts +23 -15
  89. package/src/modes/components/sessions-dashboard.ts +8 -5
  90. package/src/modes/controllers/command-controller.ts +1 -1
  91. package/src/modes/controllers/event-controller.ts +30 -4
  92. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  93. package/src/modes/controllers/input-controller.ts +2 -2
  94. package/src/modes/controllers/selector-controller.ts +10 -3
  95. package/src/modes/interactive-mode.ts +36 -12
  96. package/src/modes/prompt-action-autocomplete.ts +30 -4
  97. package/src/modes/runtime-init.ts +12 -9
  98. package/src/modes/theme/theme.ts +5 -0
  99. package/src/modes/types.ts +2 -0
  100. package/src/modes/utils/ui-helpers.ts +22 -3
  101. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  102. package/src/prompts/tools/ask.md +1 -1
  103. package/src/prompts/tools/bash.md +1 -0
  104. package/src/prompts/tools/job.md +1 -1
  105. package/src/sdk/acp/adapter.ts +6 -1
  106. package/src/sdk/broker/broker.ts +10 -1
  107. package/src/sdk/broker/ensure.ts +76 -3
  108. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  109. package/src/sdk/broker/lifecycle.ts +254 -34
  110. package/src/sdk/broker/spawn-substrate.ts +45 -2
  111. package/src/sdk/broker/startup-budget.ts +144 -2
  112. package/src/sdk/broker/state-version.ts +17 -0
  113. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  114. package/src/sdk/bus/chat-daemon-runtime.ts +118 -24
  115. package/src/sdk/bus/discord-daemon.ts +50 -10
  116. package/src/sdk/bus/index.ts +33 -3
  117. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  118. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  119. package/src/sdk/bus/notification-service.ts +22 -5
  120. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  121. package/src/sdk/bus/reconciliation-store.ts +55 -31
  122. package/src/sdk/bus/slack-conversation.ts +1 -1
  123. package/src/sdk/bus/slack-daemon.ts +72 -38
  124. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  125. package/src/sdk/cli/rows.ts +75 -7
  126. package/src/sdk/cli/session-cli.ts +68 -35
  127. package/src/sdk/host/control/dispatch.ts +2 -2
  128. package/src/sdk/host/events.ts +26 -2
  129. package/src/sdk/host/host.ts +6 -1
  130. package/src/sdk/host/query/cursor.ts +52 -7
  131. package/src/sdk/host/query/handlers.ts +33 -12
  132. package/src/sdk/host/query/revision-store.ts +12 -0
  133. package/src/sdk/host/session-runtime.test.ts +497 -31
  134. package/src/sdk/host/session-runtime.ts +387 -54
  135. package/src/sdk/host/surface-policy.ts +3 -3
  136. package/src/sdk/lifecycle-session.ts +7 -2
  137. package/src/sdk/prompt-deadline-manager.ts +2 -1
  138. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  139. package/src/sdk/protocol/operation-registry.ts +1 -1
  140. package/src/sdk/receipt-state.ts +10 -0
  141. package/src/sdk/router/session-router.ts +210 -94
  142. package/src/sdk/session.ts +59 -41
  143. package/src/sdk/turn-result.ts +22 -0
  144. package/src/session/agent-session.ts +1033 -260
  145. package/src/session/fold-coordinator.ts +40 -3
  146. package/src/session/session-manager.ts +517 -77
  147. package/src/slash-commands/builtin-registry.ts +41 -0
  148. package/src/task/executor.ts +9 -4
  149. package/src/tools/ask-contract.test.ts +64 -0
  150. package/src/tools/ask-contract.ts +36 -1
  151. package/src/tools/ask.ts +10 -8
  152. package/src/tools/bash-interactive.ts +11 -2
  153. package/src/tools/bash.ts +158 -33
  154. package/src/tools/browser/launch.ts +37 -0
  155. package/src/tools/browser/registry.ts +99 -9
  156. package/src/tools/cron.ts +6 -4
  157. package/src/tools/descriptors.test.ts +15 -0
  158. package/src/tools/descriptors.ts +5 -1
  159. package/src/tools/index.ts +4 -6
  160. package/src/tools/job.ts +4 -1
  161. package/src/tools/monitor.ts +12 -9
  162. package/src/tools/resolve.ts +30 -10
  163. package/src/tools/tool-catalog.generated.ts +2 -2
  164. package/src/utils/external-editor.ts +86 -1
package/src/tools/bash.ts CHANGED
@@ -5,7 +5,7 @@ import type { Component } from "@gajae-code/tui";
5
5
  import { getKeybindings, ImageProtocol, TERMINAL, Text, visibleWidth } from "@gajae-code/tui";
6
6
  import { getProjectDir, isEnoent, logger, prompt } from "@gajae-code/utils";
7
7
  import * as z from "zod/v4";
8
- import { AsyncJobManager } from "../async";
8
+ import { AsyncJobManager, type FoldReason } from "../async";
9
9
  import { type BashArtifactSaveResult, type BashResult, executeBash } from "../exec/bash-executor";
10
10
 
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -25,6 +25,7 @@ import type {
25
25
  ClientBridgeTerminalHandle,
26
26
  ClientBridgeTerminalOutput,
27
27
  } from "../session/client-bridge";
28
+ import type { FoldAdapter } from "../session/fold-coordinator";
28
29
  import {
29
30
  DEFAULT_ARTIFACT_MAX_BYTES,
30
31
  OutputSink,
@@ -85,6 +86,18 @@ function sliceTextAfterUtf8ByteOffset(text: string, offsetBytes: number): string
85
86
  import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
86
87
 
87
88
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
89
+ /**
90
+ * A user steer folds a running foreground bash call only after it has been
91
+ * running at least this long. Shorter commands finish normally and the steer is
92
+ * consumed at the ordinary tool boundary, so a quick `git status` never turns
93
+ * into a background job plus a wake-up turn.
94
+ */
95
+ export const STEER_FOLD_GRACE_MS = 2_000;
96
+
97
+ /** Model-facing line appended to a steer-folded background-start result. */
98
+ export function steerFoldReasonLine(jobId: string): string {
99
+ return `Folded into background job ${jobId} because a user steer arrived; the command keeps running with its original timeout and its result will wake a later turn.`;
100
+ }
88
101
 
89
102
  const BASH_ERROR_MAX_BYTES = 4096;
90
103
  const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
@@ -488,6 +501,8 @@ export interface BashToolDetails {
488
501
  timeoutSeconds?: number;
489
502
  requestedTimeoutSeconds?: number;
490
503
  terminalId?: string;
504
+ /** Why the foreground wait was folded, when this result is a background-start after a fold. */
505
+ foldReason?: FoldReason;
491
506
  async?: {
492
507
  state: "running" | "completed" | "failed";
493
508
  jobId: string;
@@ -934,7 +949,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
934
949
  label: string,
935
950
  previewText: string,
936
951
  timeoutSec: number,
937
- options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
952
+ options: {
953
+ requestedTimeoutSec?: number;
954
+ notices?: readonly string[];
955
+ terminalId?: string;
956
+ foldReason?: FoldReason;
957
+ } = {},
938
958
  ): AgentToolResult<BashToolDetails> {
939
959
  const details: BashToolDetails = {
940
960
  timeoutSeconds: timeoutSec,
@@ -946,6 +966,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
946
966
  if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
947
967
  details.requestedTimeoutSeconds = options.requestedTimeoutSec;
948
968
  }
969
+ if (options.foldReason !== undefined) details.foldReason = options.foldReason;
949
970
  const lines: string[] = [];
950
971
  const trimmedPreview = previewText.trimEnd();
951
972
  if (trimmedPreview.length > 0) {
@@ -955,6 +976,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
955
976
  lines.push(...options.notices, "");
956
977
  }
957
978
  lines.push(`Background job ${jobId} started: ${label}`);
979
+ if (options.foldReason === "steer") lines.push(steerFoldReasonLine(jobId));
958
980
  lines.push("Result will be delivered automatically when complete.");
959
981
  lines.push(
960
982
  `You can use \`job\` to poll until complete, but prefer to continue with another task in the meanwhile if it's not blocking.`,
@@ -1115,24 +1137,109 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1115
1137
  return AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
1116
1138
  }
1117
1139
 
1140
+ /**
1141
+ * Whether a queued user steer may fold the running foreground wait. Mirrors
1142
+ * the loop's steer admission: `busyPromptMode=queue` never admits a steer
1143
+ * into the busy run, so it never folds. `toolInterruptPolicy` is
1144
+ * deliberately NOT a gate: `finish_tools` means "do not kill the batch to
1145
+ * deliver a steer", and a fold kills nothing. It is the one way to deliver
1146
+ * the steer now AND let the command finish, so it applies under both
1147
+ * policies; the policy only decides what the loop does with sibling tools
1148
+ * after the fold returns. The auto-background setting is likewise not
1149
+ * consulted: like the chord, a steer fold is a user action.
1150
+ *
1151
+ * Fails closed: a session that cannot report newly admitted steering or
1152
+ * accept a fold request is not steer-foldable.
1153
+ */
1154
+ #steerFoldEnabled(): boolean {
1155
+ const { waitForUserSteering, requestForegroundBashBackground } = this.session;
1156
+ if (!waitForUserSteering || !requestForegroundBashBackground) return false;
1157
+ return this.session.settings.get("busyPromptMode") === "steer";
1158
+ }
1159
+
1160
+ /**
1161
+ * Fold `adapter` on the first user steer that ARRIVES after the wait has run
1162
+ * for {@link STEER_FOLD_GRACE_MS} since `startedAt`. A steer already queued
1163
+ * when the wait starts, or one that arrives inside the grace window, never
1164
+ * folds: the command finishes normally and that steer is consumed at the
1165
+ * ordinary tool boundary. The watcher keeps observing so a later qualifying
1166
+ * steer still folds, and re-checks the gate at that moment so a
1167
+ * `busyPromptMode` change during the command is honored.
1168
+ * Returns a stop function; call it when the wait settles. No-op when steer
1169
+ * folding is gated off at start.
1170
+ */
1171
+ #watchSteerForFold(adapter: FoldAdapter, startedAt: number): () => void {
1172
+ if (!this.#steerFoldEnabled()) return () => {};
1173
+ const waitForSteer = this.session.waitForUserSteering;
1174
+ const requestFold = this.session.requestForegroundBashBackground;
1175
+ if (!waitForSteer || !requestFold) return () => {};
1176
+ const watch = new AbortController();
1177
+ const observe = async (): Promise<void> => {
1178
+ while (!watch.signal.aborted) {
1179
+ await waitForSteer(watch.signal);
1180
+ if (watch.signal.aborted) return;
1181
+ if (Date.now() - startedAt < STEER_FOLD_GRACE_MS) continue;
1182
+ if (!this.#steerFoldEnabled()) continue;
1183
+ await requestFold("steer", adapter);
1184
+ return;
1185
+ }
1186
+ };
1187
+ observe().catch((error: unknown) => {
1188
+ logger.warn("Steer-triggered fold failed", {
1189
+ jobId: adapter.jobId,
1190
+ error: error instanceof Error ? error.message : String(error),
1191
+ });
1192
+ });
1193
+ return () => watch.abort();
1194
+ }
1195
+
1196
+ /**
1197
+ * Race a managed job against the auto-background threshold, an explicit fold
1198
+ * (chord / SDK control / steer), and abort. A steer that arrives once the
1199
+ * command has run for {@link STEER_FOLD_GRACE_MS} requests a `steer` fold,
1200
+ * which settles this wait through the same `backgroundRequest` path as the
1201
+ * chord; a steer inside the grace window is ignored here and consumed at the
1202
+ * ordinary tool boundary once the command finishes.
1203
+ */
1118
1204
  async #waitForManagedBashJob(
1119
1205
  job: ManagedBashJobHandle,
1120
1206
  thresholdMs: number,
1121
1207
  signal?: AbortSignal,
1122
- backgroundRequest?: Promise<void>,
1123
- ): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }> {
1208
+ backgroundRequest?: Promise<FoldReason>,
1209
+ foldAdapter?: FoldAdapter,
1210
+ ): Promise<ManagedBashJobCompletion | { kind: "running"; reason: FoldReason } | { kind: "aborted" }> {
1124
1211
  if (signal?.aborted) {
1125
1212
  return { kind: "aborted" };
1126
1213
  }
1127
1214
 
1128
- const threshold = Promise.withResolvers<{ kind: "running" }>();
1129
- const thresholdTimer = setTimeout(() => threshold.resolve({ kind: "running" }), Math.max(0, thresholdMs));
1130
- const waiters: Array<Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }>> = [
1131
- job.completion,
1132
- threshold.promise,
1133
- ];
1215
+ const startedAt = Date.now();
1216
+ const threshold = Promise.withResolvers<{ kind: "running"; reason: FoldReason }>();
1217
+ const thresholdTimer = setTimeout(
1218
+ () => {
1219
+ const requestFold = this.session.requestForegroundBashBackground;
1220
+ if (!foldAdapter || !requestFold) {
1221
+ threshold.resolve({ kind: "running", reason: "timer" });
1222
+ return;
1223
+ }
1224
+ void requestFold("timer", foldAdapter)
1225
+ .then(folded => {
1226
+ if (!folded) threshold.resolve({ kind: "running", reason: "timer" });
1227
+ })
1228
+ .catch(error => {
1229
+ logger.warn("Timer-triggered fold failed", {
1230
+ jobId: foldAdapter.jobId,
1231
+ error: error instanceof Error ? error.message : String(error),
1232
+ });
1233
+ threshold.resolve({ kind: "running", reason: "timer" });
1234
+ });
1235
+ },
1236
+ Math.max(0, thresholdMs),
1237
+ );
1238
+ const waiters: Array<
1239
+ Promise<ManagedBashJobCompletion | { kind: "running"; reason: FoldReason } | { kind: "aborted" }>
1240
+ > = [job.completion, threshold.promise];
1134
1241
  if (backgroundRequest) {
1135
- waiters.push(backgroundRequest.then(() => ({ kind: "running" as const })));
1242
+ waiters.push(backgroundRequest.then(reason => ({ kind: "running" as const, reason })));
1136
1243
  }
1137
1244
 
1138
1245
  let onAbort: (() => void) | undefined;
@@ -1142,9 +1249,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1142
1249
  signal.addEventListener("abort", onAbort, { once: true });
1143
1250
  waiters.push(aborted.promise);
1144
1251
  }
1252
+
1253
+ const stopSteerWatch =
1254
+ backgroundRequest && foldAdapter ? this.#watchSteerForFold(foldAdapter, startedAt) : () => {};
1145
1255
  try {
1146
1256
  return await Promise.race(waiters);
1147
1257
  } finally {
1258
+ stopSteerWatch();
1148
1259
  clearTimeout(thresholdTimer);
1149
1260
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
1150
1261
  }
@@ -1657,7 +1768,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1657
1768
  });
1658
1769
  const jobGeneration = asyncManager.getJob(job.jobId)?.generation ?? job.jobId;
1659
1770
  job.setBackgrounded(true);
1660
- asyncManager.markBackgrounded(job.jobId, jobGeneration);
1771
+ asyncManager.markStartedInBackground(job.jobId, jobGeneration);
1661
1772
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1662
1773
  requestedTimeoutSec,
1663
1774
  notices: pendingNotices,
@@ -1711,13 +1822,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1711
1822
  const jobGeneration = ownedManager.getJob(job.jobId)?.generation ?? job.jobId;
1712
1823
  if (startBackgrounded) {
1713
1824
  job.setBackgrounded(true);
1714
- ownedManager.markBackgrounded(job.jobId, jobGeneration);
1825
+ ownedManager.markStartedInBackground(job.jobId, jobGeneration);
1715
1826
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1716
1827
  requestedTimeoutSec,
1717
1828
  notices: pendingNotices,
1718
1829
  });
1719
1830
  }
1720
- const backgroundRequest = Promise.withResolvers<void>();
1831
+ const backgroundRequest = Promise.withResolvers<FoldReason>();
1721
1832
  // Exactly one party may settle the foreground caller. detachObserver (the
1722
1833
  // fold won) and resolveForegroundObserver (a completion was handed back
1723
1834
  // after a failed fold) share this flag, so a race cannot double-settle.
@@ -1727,7 +1838,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1727
1838
  foregroundSettled = true;
1728
1839
  return "resolved";
1729
1840
  };
1730
- const unregisterBackgroundRequest = this.session.registerForegroundFoldParticipant?.({
1841
+ const managedFoldAdapter: FoldAdapter = {
1731
1842
  kind: "bash-managed",
1732
1843
  jobId: job.jobId,
1733
1844
  jobGeneration,
@@ -1746,24 +1857,26 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1746
1857
  const current = ownedManager.getJob(job.jobId);
1747
1858
  return current?.generation === jobGeneration ? current : undefined;
1748
1859
  },
1749
- detachObserver: () => {
1860
+ detachObserver: receipt => {
1750
1861
  const outcome = settleForeground();
1751
1862
  if (outcome === "resolved") {
1752
1863
  job.setBackgrounded(true);
1753
- ownedManager.markBackgrounded(job.jobId, jobGeneration);
1754
- backgroundRequest.resolve();
1864
+ ownedManager.markBackgrounded(job.jobId, jobGeneration, receipt.reason);
1865
+ backgroundRequest.resolve(receipt.reason);
1755
1866
  }
1756
1867
  return outcome;
1757
1868
  },
1758
1869
  resolveForegroundObserver: () => settleForeground(),
1759
- });
1760
- let waitResult: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
1870
+ };
1871
+ const unregisterBackgroundRequest = this.session.registerForegroundFoldParticipant?.(managedFoldAdapter);
1872
+ let waitResult: ManagedBashJobCompletion | { kind: "running"; reason: FoldReason } | { kind: "aborted" };
1761
1873
  try {
1762
1874
  waitResult = await this.#waitForManagedBashJob(
1763
1875
  job,
1764
1876
  autoBackgroundWaitMs,
1765
1877
  signal,
1766
1878
  backgroundRequest.promise,
1879
+ managedFoldAdapter,
1767
1880
  );
1768
1881
  } finally {
1769
1882
  unregisterBackgroundRequest?.();
@@ -1791,10 +1904,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1791
1904
  throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, job.getLatestText()));
1792
1905
  }
1793
1906
  job.setBackgrounded(true);
1794
- ownedManager.markBackgrounded(job.jobId, jobGeneration);
1907
+ ownedManager.markBackgrounded(job.jobId, jobGeneration, waitResult.reason);
1795
1908
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
1796
1909
  requestedTimeoutSec,
1797
1910
  notices: pendingNotices,
1911
+ foldReason: waitResult.reason,
1798
1912
  });
1799
1913
  }
1800
1914
 
@@ -2323,9 +2437,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2323
2437
  void bridgeHandle.completion.finally(unregisterOwnerCleanup);
2324
2438
  registerOwnedIfLineaged(bridgeManager, toolCallId, bridgeJobId, bridgeEndpointId);
2325
2439
 
2326
- const bridgeFoldRequest = Promise.withResolvers<void>();
2440
+ const bridgeFoldRequest = Promise.withResolvers<FoldReason>();
2327
2441
  const bridgeOriginatingTurn = ctx?.attemptScope !== undefined;
2328
- const unregisterBridgeFold = this.session.registerForegroundFoldParticipant?.({
2442
+ const bridgeFoldAdapter: FoldAdapter = {
2329
2443
  kind: "client-terminal",
2330
2444
  jobId: bridgeJobId,
2331
2445
  jobGeneration: bridgeGeneration,
@@ -2342,25 +2456,27 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2342
2456
  const current = bridgeManager.getJob(bridgeJobId);
2343
2457
  return current?.generation === bridgeGeneration ? current : undefined;
2344
2458
  },
2345
- detachObserver: () => {
2459
+ detachObserver: receipt => {
2346
2460
  const outcome = settleBridgeForeground();
2347
2461
  if (outcome === "resolved") {
2348
2462
  bridgeHandle.setBackgrounded(true);
2349
- bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
2350
- bridgeFoldRequest.resolve();
2463
+ bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration, receipt.reason);
2464
+ bridgeFoldRequest.resolve(receipt.reason);
2351
2465
  }
2352
2466
  return outcome;
2353
2467
  },
2354
2468
  resolveForegroundObserver: () => settleBridgeForeground(),
2355
- });
2469
+ };
2470
+ const unregisterBridgeFold = this.session.registerForegroundFoldParticipant?.(bridgeFoldAdapter);
2356
2471
 
2357
- let bridgeWait: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
2472
+ let bridgeWait: ManagedBashJobCompletion | { kind: "running"; reason: FoldReason } | { kind: "aborted" };
2358
2473
  try {
2359
2474
  bridgeWait = await this.#waitForManagedBashJob(
2360
2475
  bridgeHandle,
2361
2476
  this.#autoBackgroundEnabled ? this.#resolveAutoBackgroundWaitMs(timeoutMs) : timeoutMs + 1_000,
2362
2477
  signal,
2363
2478
  bridgeFoldRequest.promise,
2479
+ bridgeFoldAdapter,
2364
2480
  );
2365
2481
  } finally {
2366
2482
  unregisterBridgeFold?.();
@@ -2395,11 +2511,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2395
2511
  // Folded (or auto-backgrounded): the remote terminal keeps running and its
2396
2512
  // result arrives as a background completion.
2397
2513
  bridgeHandle.setBackgrounded(true);
2398
- bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
2514
+ bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration, bridgeWait.reason);
2399
2515
  return this.#buildBackgroundStartResult(bridgeJobId, bridgeLabel, bridgeHandle.getLatestText(), timeoutSec, {
2400
2516
  requestedTimeoutSec,
2401
2517
  notices: pendingNotices,
2402
2518
  terminalId: handle.terminalId,
2519
+ foldReason: bridgeWait.reason,
2403
2520
  });
2404
2521
  }
2405
2522
 
@@ -2521,7 +2638,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2521
2638
  },
2522
2639
  );
2523
2640
  void controls.terminalCompletion.finally(unregisterPtyOwnerCleanup);
2524
- ptyFoldUnregister = this.session.registerForegroundFoldParticipant?.({
2641
+ const ptyStartedAt = Date.now();
2642
+ const ptyFoldAdapter: FoldAdapter = {
2525
2643
  kind: "bash-pty",
2526
2644
  jobId: ptyJobId,
2527
2645
  jobGeneration: ptyGeneration,
@@ -2538,12 +2656,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2538
2656
  const current = ptyManager.getJob(ptyJobId);
2539
2657
  return current?.generation === ptyGeneration ? current : undefined;
2540
2658
  },
2541
- detachObserver: () => {
2659
+ detachObserver: receipt => {
2542
2660
  // Output-only continuation: stdin forwarding ends here and the
2543
2661
  // process is never killed or restarted by folding.
2544
2662
  const started = this.#buildBackgroundStartResult(ptyJobId, ptyLabel, "", timeoutSec, {
2545
2663
  requestedTimeoutSec,
2546
2664
  notices: pendingNotices,
2665
+ foldReason: receipt.reason,
2547
2666
  });
2548
2667
  const outcome = controls.detachObserver(
2549
2668
  interactiveResultFromText(this.#extractTextResult(started)),
@@ -2551,13 +2670,19 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2551
2670
  if (outcome === "resolved") {
2552
2671
  controls.detachForegroundCancellation();
2553
2672
  ptyBackgrounded = true;
2554
- ptyManager.markBackgrounded(ptyJobId, ptyGeneration);
2673
+ ptyManager.markBackgrounded(ptyJobId, ptyGeneration, receipt.reason);
2555
2674
  ptyFoldResult = started;
2556
2675
  }
2557
2676
  return outcome;
2558
2677
  },
2559
2678
  resolveForegroundObserver: () => "already-settled",
2560
- });
2679
+ };
2680
+ const unregisterPtyFold = this.session.registerForegroundFoldParticipant?.(ptyFoldAdapter);
2681
+ const stopPtySteerWatch = this.#watchSteerForFold(ptyFoldAdapter, ptyStartedAt);
2682
+ ptyFoldUnregister = () => {
2683
+ stopPtySteerWatch();
2684
+ unregisterPtyFold?.();
2685
+ };
2561
2686
  },
2562
2687
  onFoldKey: () => {
2563
2688
  const now = Date.now();
@@ -293,6 +293,43 @@ function resolveSystemChromium(): string | undefined {
293
293
  return found;
294
294
  }
295
295
 
296
+ export function resolveSystemChromiumForTest(): string | undefined {
297
+ return resolveSystemChromium();
298
+ }
299
+
300
+ export function resetSystemChromiumForTest(): void {
301
+ resolvedChromium = undefined;
302
+ }
303
+
304
+ export function headlessExecutableForProfileReuse(): string | undefined {
305
+ return resolveSystemChromium() ?? trustedBrowserEnv("PUPPETEER_EXECUTABLE_PATH");
306
+ }
307
+
308
+ /**
309
+ * On macOS the Cookies DB is encrypted with a Keychain key whose service name is
310
+ * brand-specific ("Chrome Safe Storage" vs "Chromium Safe Storage"). A Chrome profile
311
+ * copied into a bundled Chromium therefore decrypts to garbage with no warning.
312
+ * Return true only when the headless executable can actually decrypt the discovered profile.
313
+ */
314
+ export function canDecryptProfileCookies(
315
+ executablePath: string | undefined,
316
+ discoveredUserDataDir: string | undefined,
317
+ platform: NodeJS.Platform = process.platform,
318
+ ): boolean {
319
+ if (platform !== "darwin") return true;
320
+ if (!discoveredUserDataDir) return true;
321
+ if (!executablePath) return false;
322
+ const lowerExe = executablePath.toLowerCase();
323
+ const lowerDir = discoveredUserDataDir.toLowerCase();
324
+ const isChromiumExe = lowerExe.includes("chromium");
325
+ const isChromeExe = lowerExe.includes("google chrome") || lowerExe.includes("/google/chrome");
326
+ const isChromiumProfile = lowerDir.includes("chromium");
327
+ const isChromeProfile = lowerDir.includes("google") && lowerDir.includes("chrome");
328
+ if (isChromeProfile && isChromiumExe && !isChromeExe) return false;
329
+ if (isChromiumProfile && isChromeExe) return false;
330
+ return true;
331
+ }
332
+
296
333
  /** Edge is Chromium-based but keeps its own profile format under its own user data root. */
297
334
  const EDGE_EXECUTABLE_PATTERN =
298
335
  /(?:^|[/\\])(?:msedge(?:\.exe)?|microsoft-edge(?:-(?:stable|beta|dev|canary))?|com\.microsoft\.Edge|Microsoft Edge(?: Beta| Dev| Canary)?)$/i;
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import { logger } from "@gajae-code/utils";
4
5
  import type { Subprocess } from "bun";
@@ -12,7 +13,14 @@ import {
12
13
  killExistingByPath,
13
14
  waitForCdp,
14
15
  } from "./attach";
15
- import { BROWSER_PROTOCOL_TIMEOUT_MS, launchHeadlessBrowser, loadPuppeteer, type UserAgentOverride } from "./launch";
16
+ import {
17
+ BROWSER_PROTOCOL_TIMEOUT_MS,
18
+ canDecryptProfileCookies,
19
+ headlessExecutableForProfileReuse,
20
+ launchHeadlessBrowser,
21
+ loadPuppeteer,
22
+ type UserAgentOverride,
23
+ } from "./launch";
16
24
  import { defaultDiscoveryEnv } from "./profile-discovery";
17
25
  import type { ProfileReusePosture } from "./profile-posture";
18
26
  import { resolveProfileReuse } from "./profile-reuse";
@@ -55,6 +63,17 @@ type SpawnedChromeProfileKind = Extract<BrowserKind, { kind: "chrome-profile" }>
55
63
  const browsers = new Map<string, BrowserHandle>();
56
64
  const browserOpenPromises = new Map<string, Promise<BrowserHandle>>();
57
65
 
66
+ /**
67
+ * Chrome profile warm-up dirs this module created, keyed by the handle that owns one.
68
+ *
69
+ * Ownership is a registry-internal fact, not a property of the handle: `BrowserHandle`
70
+ * is exported and mutable, so a field would let any caller point recursive deletion at
71
+ * an arbitrary path. Recording the dir here — only on the launch path that created it —
72
+ * means disposal can delete exactly what this module made and nothing else. A path
73
+ * prefix check would not prove ownership; an entry in this map does.
74
+ */
75
+ const ownedWarmupDirs = new WeakMap<BrowserHandle, string>();
76
+
58
77
  /**
59
78
  * Upper bound on the CDP `browser.close()` round-trip during a forced (signal-path)
60
79
  * teardown before we fall back to killing the Chrome process tree. Only applies when
@@ -110,6 +129,19 @@ export function browserKeyForTest(
110
129
  return browserKey(kind, geo, opts.profileReuse);
111
130
  }
112
131
 
132
+ /**
133
+ * Create and register a warm-up dir as registry-owned for `handle`.
134
+ *
135
+ * Exists so tests can exercise disposal without spawning Chrome. The caller cannot
136
+ * supply the deletion target: this module creates the directory itself before recording
137
+ * ownership, preserving the same invariant as the production launch path.
138
+ */
139
+ export function createOwnedWarmupDirForTest(handle: BrowserHandle): string {
140
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-profile-warmup-"));
141
+ ownedWarmupDirs.set(handle, dir);
142
+ return dir;
143
+ }
144
+
113
145
  export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOptions): Promise<BrowserHandle> {
114
146
  const geo = kind.kind === "headless" ? normalizeGeo(opts.geo) : undefined;
115
147
  const key = browserKey(kind, geo, opts.profileReuse);
@@ -148,17 +180,38 @@ async function openBrowserHandle(
148
180
  discoveryEnv: defaultDiscoveryEnv(fs.existsSync),
149
181
  });
150
182
  if (reuse.warning) logger.warn(reuse.warning);
151
- if (reuse.mode === "real" && reuse.warmupDir) {
183
+ if (reuse.mode === "real" && reuse.warmupDir && reuse.discovered) {
184
+ const executablePath = headlessExecutableForProfileReuse();
185
+ if (!canDecryptProfileCookies(executablePath, reuse.discovered.userDataDir)) {
186
+ try {
187
+ fs.rmSync(reuse.warmupDir, { recursive: true, force: true });
188
+ } catch {}
189
+ const hint = executablePath
190
+ ? `Real Chrome profile at ${reuse.discovered.userDataDir} cannot be decrypted by ${executablePath} on macOS (Chrome Safe Storage vs Chromium Safe Storage Keychain mismatch). Using synthetic browser state instead. Set PUPPETEER_EXECUTABLE_PATH to the matching Chrome/Chromium binary.`
191
+ : `Real Chrome profile at ${reuse.discovered.userDataDir} found but no compatible browser executable is available on macOS (bundled Chromium cannot decrypt Chrome's cookies — Chrome Safe Storage vs Chromium Safe Storage). Using synthetic browser state instead. Set PUPPETEER_EXECUTABLE_PATH to /Applications/Google Chrome.app/Contents/MacOS/Google Chrome or install Chrome.`;
192
+ logger.warn(hint);
193
+ } else {
194
+ profileWarmupDir = reuse.warmupDir;
195
+ }
196
+ } else if (reuse.mode === "real" && reuse.warmupDir) {
152
197
  profileWarmupDir = reuse.warmupDir;
153
198
  }
154
199
  }
155
- const browser = await launchHeadlessBrowser({
156
- headless: kind.headless,
157
- viewport: opts.viewport,
158
- profileWarmupDir,
159
- ...(geo ? { geo } : {}),
160
- });
161
- return {
200
+ let browser: Browser;
201
+ try {
202
+ browser = await launchHeadlessBrowser({
203
+ headless: kind.headless,
204
+ viewport: opts.viewport,
205
+ profileWarmupDir,
206
+ ...(geo ? { geo } : {}),
207
+ });
208
+ } catch (error) {
209
+ // Launch failed after the warm-up copy landed: drop the temp dir here,
210
+ // because no handle will exist to carry it into releaseBrowser().
211
+ removeWarmupDir(profileWarmupDir);
212
+ throw error;
213
+ }
214
+ const handle: BrowserHandle = {
162
215
  key,
163
216
 
164
217
  kind,
@@ -168,6 +221,11 @@ async function openBrowserHandle(
168
221
  refCount: 0,
169
222
  stealth: { browserSession: null, override: null },
170
223
  };
224
+ // Record ownership only here, where this module created the dir. Disposal
225
+ // deletes solely what this map holds, so a caller-supplied path is never
226
+ // a deletion target.
227
+ if (profileWarmupDir) ownedWarmupDirs.set(handle, profileWarmupDir);
228
+ return handle;
171
229
  }
172
230
  if (kind.kind === "connected") {
173
231
  const cdpUrl = kind.cdpUrl.replace(/\/+$/, "");
@@ -418,6 +476,35 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea
418
476
  }
419
477
  }
420
478
 
479
+ /**
480
+ * Delete the warm-up dir this module created for `handle`, if any.
481
+ *
482
+ * The path comes from `ownedWarmupDirs`, never from the caller, so recursive deletion
483
+ * can only ever target a dir this registry made. The entry is consumed first, making a
484
+ * second call a no-op even if disposal runs twice.
485
+ *
486
+ * Best-effort: a leftover temp dir is preferable to a teardown that throws.
487
+ */
488
+ function removeOwnedWarmupDir(handle: BrowserHandle): void {
489
+ const dir = ownedWarmupDirs.get(handle);
490
+ if (dir === undefined) return;
491
+ ownedWarmupDirs.delete(handle);
492
+ removeWarmupDir(dir);
493
+ }
494
+
495
+ /** Delete a dir this module created. Callers must have proven ownership first. */
496
+ function removeWarmupDir(dir: string | undefined): void {
497
+ if (!dir) return;
498
+ try {
499
+ fs.rmSync(dir, { recursive: true, force: true });
500
+ } catch (err) {
501
+ logger.debug("Failed to remove Chrome profile warm-up dir", {
502
+ dir,
503
+ error: (err as Error).message,
504
+ });
505
+ }
506
+ }
507
+
421
508
  async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
422
509
  if (handle.kind.kind === "headless") {
423
510
  // Capture the launched Chrome process before close() so a forced (signal-path)
@@ -436,6 +523,9 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
436
523
  }
437
524
  }
438
525
  if (opts.kill && proc?.pid !== undefined) await gracefulKillTreeOnce(proc.pid);
526
+ // Chrome owns files under the warm-up dir until the process is gone, so this
527
+ // runs after close()/kill rather than alongside them.
528
+ removeOwnedWarmupDir(handle);
439
529
  return;
440
530
  }
441
531
  if (handle.kind.kind === "connected") {
package/src/tools/cron.ts CHANGED
@@ -453,13 +453,15 @@ function deliverCronFire(record: CronScheduleRecord): Promise<void> | undefined
453
453
  cron_expression: record.snapshot.cron_expression,
454
454
  recurring: record.snapshot.recurring,
455
455
  };
456
- const sendPromise = record.session.sendCustomMessage?.(
456
+ if (!record.session.sendCustomMessage) {
457
+ // The custom-message router is the only delivery path; treating its absence
458
+ // as a successful fire would silently retire a one-shot schedule.
459
+ return Promise.reject(new Error("Cron delivery requires a session custom-message router."));
460
+ }
461
+ return record.session.sendCustomMessage(
457
462
  { customType: "cron-fire", content, display: false, attribution: "agent", details },
458
463
  { triggerTurn: true, deliverAs: "followUp" },
459
464
  );
460
- if (sendPromise) return sendPromise;
461
- record.session.steer?.({ customType: "cron-fire", content, details });
462
- return undefined;
463
465
  }
464
466
 
465
467
  function scheduleRecord(ownerId: string | undefined, state: OwnerScheduleState, record: CronScheduleRecord): void {
@@ -39,6 +39,7 @@ function makeSession(overrides: Record<string, unknown> = {}): any {
39
39
  "calc.enabled": true,
40
40
  "skill.enabled": true,
41
41
  "browser.enabled": true,
42
+ "browser.backend": "native",
42
43
  "computer.enabled": true,
43
44
  "checkpoint.enabled": true,
44
45
  "irc.enabled": true,
@@ -371,6 +372,20 @@ describe("tool descriptor compatibility gate", () => {
371
372
  }
372
373
  });
373
374
 
375
+ test("hides the built-in browser tool when Aside is the selected backend", () => {
376
+ const context = availabilityContext();
377
+
378
+ expect(BUILTIN_TOOL_DESCRIPTORS.browser.isAvailable(makeSession({ "browser.backend": "aside" }), context)).toBe(
379
+ false,
380
+ );
381
+ expect(BUILTIN_TOOL_DESCRIPTORS.browser.isAvailable(makeSession({ "browser.backend": "native" }), context)).toBe(
382
+ true,
383
+ );
384
+ expect(BUILTIN_TOOL_DESCRIPTORS.browser.isAvailable(makeSession({ "browser.backend": undefined }), context)).toBe(
385
+ true,
386
+ );
387
+ });
388
+
374
389
  test("descriptor creation is side-effect free; materialization registers cleanup exactly once", () => {
375
390
  const cleanupCalls: Array<() => void> = [];
376
391
  let constructed = 0;
@@ -1,6 +1,7 @@
1
1
  import type { AgentTool } from "@gajae-code/agent-core";
2
2
  import type { RawArgumentValidationResult, TSchema } from "@gajae-code/ai/types";
3
3
  import { $which } from "@gajae-code/utils";
4
+ import { resolveBrowserBackend } from "../browser-backend";
4
5
  import type { ToolFactory, ToolSession } from ".";
5
6
  import { selectAskParameters } from "./ask-contract";
6
7
  import { isComputerCallable, isComputerLoadablePlatform } from "./computer-policy";
@@ -260,7 +261,10 @@ function availableFor(name: string, session: ToolSession, context = defaultAvail
260
261
  if (name === "search_tool_bm25") return context.discoveryActive ?? resolveDiscoveryActive(session);
261
262
  if (name === "calc") return Boolean(session.settings.get("calc.enabled"));
262
263
  if (name === "skill" || name === "skill_discovery") return Boolean(session.settings.get("skill.enabled"));
263
- if (name === "browser") return Boolean(session.settings.get("browser.enabled"));
264
+ if (name === "browser")
265
+ return (
266
+ Boolean(session.settings.get("browser.enabled")) && resolveBrowserBackend(session.settings).exposesBuiltinTool
267
+ );
264
268
  if (name === "computer") return isComputerCallable(session);
265
269
  if (name === "checkpoint" || name === "rewind")
266
270
  return Boolean(session.settings.get("checkpoint.enabled")) && (session.taskDepth ?? 0) === 0;