@gajae-code/coding-agent 0.16.0 → 0.16.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.
Files changed (163) hide show
  1. package/CHANGELOG.md +41 -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 +8 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +1 -0
  37. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  38. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  39. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  41. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  42. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  43. package/dist/types/sdk/receipt-state.d.ts +2 -0
  44. package/dist/types/sdk/router/session-router.d.ts +4 -0
  45. package/dist/types/sdk/turn-result.d.ts +6 -0
  46. package/dist/types/session/agent-session.d.ts +47 -7
  47. package/dist/types/session/fold-coordinator.d.ts +29 -2
  48. package/dist/types/session/session-manager.d.ts +14 -2
  49. package/dist/types/tools/ask.d.ts +1 -0
  50. package/dist/types/tools/bash.d.ts +12 -0
  51. package/dist/types/tools/browser/launch.d.ts +10 -0
  52. package/dist/types/tools/browser/registry.d.ts +8 -0
  53. package/dist/types/tools/index.d.ts +6 -10
  54. package/dist/types/tools/job.d.ts +3 -1
  55. package/dist/types/utils/external-editor.d.ts +7 -0
  56. package/examples/extensions/README.md +1 -1
  57. package/examples/extensions/pirate.ts +7 -4
  58. package/package.json +7 -7
  59. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  60. package/src/async/job-manager.ts +107 -9
  61. package/src/browser-backend.ts +29 -0
  62. package/src/cli/plugin-cli.ts +29 -0
  63. package/src/cli/update-cli.ts +63 -12
  64. package/src/commands/sdk.ts +22 -7
  65. package/src/config/billing-path.ts +42 -0
  66. package/src/config/config-schema-version.ts +18 -1
  67. package/src/config/model-preset-registry.ts +177 -18
  68. package/src/config/model-registry.ts +268 -42
  69. package/src/config/settings-schema.ts +19 -7
  70. package/src/config/settings.ts +46 -15
  71. package/src/coordinator-mcp/projection-scan.ts +6 -6
  72. package/src/coordinator-mcp/server.ts +21 -1
  73. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  74. package/src/extensibility/extensions/runner.ts +27 -0
  75. package/src/extensibility/extensions/types.ts +8 -3
  76. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  77. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  78. package/src/extensibility/skills.ts +22 -12
  79. package/src/gjc-runtime/launch-worktree.ts +285 -4
  80. package/src/gjc-runtime/session-state-lock.ts +154 -28
  81. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  82. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  83. package/src/internal-urls/docs-index.generated.ts +10 -10
  84. package/src/main.ts +4 -1
  85. package/src/modes/acp/acp-agent.ts +5 -5
  86. package/src/modes/components/model-selector.ts +76 -8
  87. package/src/modes/components/session-selector.ts +23 -15
  88. package/src/modes/components/sessions-dashboard.ts +8 -5
  89. package/src/modes/controllers/command-controller.ts +1 -1
  90. package/src/modes/controllers/event-controller.ts +30 -4
  91. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  92. package/src/modes/controllers/input-controller.ts +2 -2
  93. package/src/modes/controllers/selector-controller.ts +10 -3
  94. package/src/modes/interactive-mode.ts +36 -12
  95. package/src/modes/prompt-action-autocomplete.ts +30 -4
  96. package/src/modes/runtime-init.ts +12 -9
  97. package/src/modes/theme/theme.ts +5 -0
  98. package/src/modes/types.ts +2 -0
  99. package/src/modes/utils/ui-helpers.ts +22 -3
  100. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  101. package/src/prompts/tools/ask.md +1 -1
  102. package/src/prompts/tools/bash.md +1 -0
  103. package/src/prompts/tools/job.md +1 -1
  104. package/src/sdk/acp/adapter.ts +5 -0
  105. package/src/sdk/broker/broker.ts +10 -1
  106. package/src/sdk/broker/ensure.ts +76 -3
  107. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  108. package/src/sdk/broker/lifecycle.ts +254 -34
  109. package/src/sdk/broker/spawn-substrate.ts +45 -2
  110. package/src/sdk/broker/startup-budget.ts +144 -2
  111. package/src/sdk/broker/state-version.ts +17 -0
  112. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  113. package/src/sdk/bus/chat-daemon-runtime.ts +111 -24
  114. package/src/sdk/bus/discord-daemon.ts +50 -10
  115. package/src/sdk/bus/index.ts +33 -3
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  117. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  118. package/src/sdk/bus/notification-service.ts +22 -5
  119. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  120. package/src/sdk/bus/reconciliation-store.ts +55 -31
  121. package/src/sdk/bus/slack-conversation.ts +1 -1
  122. package/src/sdk/bus/slack-daemon.ts +72 -38
  123. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  124. package/src/sdk/cli/rows.ts +30 -7
  125. package/src/sdk/cli/session-cli.ts +33 -21
  126. package/src/sdk/host/control/dispatch.ts +2 -2
  127. package/src/sdk/host/events.ts +1 -0
  128. package/src/sdk/host/host.ts +2 -0
  129. package/src/sdk/host/query/cursor.ts +52 -7
  130. package/src/sdk/host/query/handlers.ts +33 -12
  131. package/src/sdk/host/query/revision-store.ts +12 -0
  132. package/src/sdk/host/session-runtime.test.ts +463 -8
  133. package/src/sdk/host/session-runtime.ts +383 -54
  134. package/src/sdk/host/surface-policy.ts +3 -3
  135. package/src/sdk/lifecycle-session.ts +7 -2
  136. package/src/sdk/prompt-deadline-manager.ts +2 -1
  137. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  138. package/src/sdk/protocol/operation-registry.ts +1 -1
  139. package/src/sdk/receipt-state.ts +10 -0
  140. package/src/sdk/router/session-router.ts +152 -59
  141. package/src/sdk/session.ts +60 -41
  142. package/src/sdk/turn-result.ts +22 -0
  143. package/src/session/agent-session.ts +1033 -260
  144. package/src/session/fold-coordinator.ts +40 -3
  145. package/src/session/session-manager.ts +517 -77
  146. package/src/slash-commands/builtin-registry.ts +41 -0
  147. package/src/task/executor.ts +9 -4
  148. package/src/tools/ask-contract.test.ts +64 -0
  149. package/src/tools/ask-contract.ts +36 -1
  150. package/src/tools/ask.ts +10 -8
  151. package/src/tools/bash-interactive.ts +11 -2
  152. package/src/tools/bash.ts +156 -33
  153. package/src/tools/browser/launch.ts +37 -0
  154. package/src/tools/browser/registry.ts +99 -9
  155. package/src/tools/cron.ts +6 -4
  156. package/src/tools/descriptors.test.ts +15 -0
  157. package/src/tools/descriptors.ts +5 -1
  158. package/src/tools/index.ts +6 -6
  159. package/src/tools/job.ts +4 -1
  160. package/src/tools/monitor.ts +12 -9
  161. package/src/tools/resolve.ts +30 -10
  162. package/src/tools/tool-catalog.generated.ts +2 -2
  163. package/src/utils/external-editor.ts +72 -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,107 @@ 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 own steer admission: `busyPromptMode=queue` never admits a
1143
+ * steer into the busy run, and `toolInterruptPolicy=finish_tools` explicitly
1144
+ * lets the tool finish, so neither may fold. The
1145
+ * auto-background setting is deliberately not consulted: like the chord,
1146
+ * a steer fold is a user action.
1147
+ *
1148
+ * Fails closed: a session that cannot prove its tool interrupt policy or
1149
+ * report newly admitted steering is not steer-foldable.
1150
+ */
1151
+ #steerFoldEnabled(): boolean {
1152
+ const { waitForUserSteering, getToolInterruptPolicy, requestForegroundBashBackground } = this.session;
1153
+ if (!waitForUserSteering || !getToolInterruptPolicy || !requestForegroundBashBackground) return false;
1154
+ if (this.session.settings.get("busyPromptMode") !== "steer") return false;
1155
+ return getToolInterruptPolicy() === "abort_tools";
1156
+ }
1157
+
1158
+ /**
1159
+ * Fold `adapter` on the first user steer that ARRIVES after the wait has run
1160
+ * for {@link STEER_FOLD_GRACE_MS} since `startedAt`. A steer already queued
1161
+ * when the wait starts, or one that arrives inside the grace window, never
1162
+ * folds: the command finishes normally and that steer is consumed at the
1163
+ * ordinary tool boundary. The watcher keeps observing so a later qualifying
1164
+ * steer still folds, and re-checks the gates at that moment so a
1165
+ * `busyPromptMode`/`toolInterruptPolicy` change during the command is honored.
1166
+ * Returns a stop function; call it when the wait settles. No-op when steer
1167
+ * folding is gated off at start.
1168
+ */
1169
+ #watchSteerForFold(adapter: FoldAdapter, startedAt: number): () => void {
1170
+ if (!this.#steerFoldEnabled()) return () => {};
1171
+ const waitForSteer = this.session.waitForUserSteering;
1172
+ const requestFold = this.session.requestForegroundBashBackground;
1173
+ if (!waitForSteer || !requestFold) return () => {};
1174
+ const watch = new AbortController();
1175
+ const observe = async (): Promise<void> => {
1176
+ while (!watch.signal.aborted) {
1177
+ await waitForSteer(watch.signal);
1178
+ if (watch.signal.aborted) return;
1179
+ if (Date.now() - startedAt < STEER_FOLD_GRACE_MS) continue;
1180
+ if (!this.#steerFoldEnabled()) continue;
1181
+ await requestFold("steer", adapter);
1182
+ return;
1183
+ }
1184
+ };
1185
+ observe().catch((error: unknown) => {
1186
+ logger.warn("Steer-triggered fold failed", {
1187
+ jobId: adapter.jobId,
1188
+ error: error instanceof Error ? error.message : String(error),
1189
+ });
1190
+ });
1191
+ return () => watch.abort();
1192
+ }
1193
+
1194
+ /**
1195
+ * Race a managed job against the auto-background threshold, an explicit fold
1196
+ * (chord / SDK control / steer), and abort. A steer that arrives once the
1197
+ * command has run for {@link STEER_FOLD_GRACE_MS} requests a `steer` fold,
1198
+ * which settles this wait through the same `backgroundRequest` path as the
1199
+ * chord; a steer inside the grace window is ignored here and consumed at the
1200
+ * ordinary tool boundary once the command finishes.
1201
+ */
1118
1202
  async #waitForManagedBashJob(
1119
1203
  job: ManagedBashJobHandle,
1120
1204
  thresholdMs: number,
1121
1205
  signal?: AbortSignal,
1122
- backgroundRequest?: Promise<void>,
1123
- ): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }> {
1206
+ backgroundRequest?: Promise<FoldReason>,
1207
+ foldAdapter?: FoldAdapter,
1208
+ ): Promise<ManagedBashJobCompletion | { kind: "running"; reason: FoldReason } | { kind: "aborted" }> {
1124
1209
  if (signal?.aborted) {
1125
1210
  return { kind: "aborted" };
1126
1211
  }
1127
1212
 
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
- ];
1213
+ const startedAt = Date.now();
1214
+ const threshold = Promise.withResolvers<{ kind: "running"; reason: FoldReason }>();
1215
+ const thresholdTimer = setTimeout(
1216
+ () => {
1217
+ const requestFold = this.session.requestForegroundBashBackground;
1218
+ if (!foldAdapter || !requestFold) {
1219
+ threshold.resolve({ kind: "running", reason: "timer" });
1220
+ return;
1221
+ }
1222
+ void requestFold("timer", foldAdapter)
1223
+ .then(folded => {
1224
+ if (!folded) threshold.resolve({ kind: "running", reason: "timer" });
1225
+ })
1226
+ .catch(error => {
1227
+ logger.warn("Timer-triggered fold failed", {
1228
+ jobId: foldAdapter.jobId,
1229
+ error: error instanceof Error ? error.message : String(error),
1230
+ });
1231
+ threshold.resolve({ kind: "running", reason: "timer" });
1232
+ });
1233
+ },
1234
+ Math.max(0, thresholdMs),
1235
+ );
1236
+ const waiters: Array<
1237
+ Promise<ManagedBashJobCompletion | { kind: "running"; reason: FoldReason } | { kind: "aborted" }>
1238
+ > = [job.completion, threshold.promise];
1134
1239
  if (backgroundRequest) {
1135
- waiters.push(backgroundRequest.then(() => ({ kind: "running" as const })));
1240
+ waiters.push(backgroundRequest.then(reason => ({ kind: "running" as const, reason })));
1136
1241
  }
1137
1242
 
1138
1243
  let onAbort: (() => void) | undefined;
@@ -1142,9 +1247,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1142
1247
  signal.addEventListener("abort", onAbort, { once: true });
1143
1248
  waiters.push(aborted.promise);
1144
1249
  }
1250
+
1251
+ const stopSteerWatch =
1252
+ backgroundRequest && foldAdapter ? this.#watchSteerForFold(foldAdapter, startedAt) : () => {};
1145
1253
  try {
1146
1254
  return await Promise.race(waiters);
1147
1255
  } finally {
1256
+ stopSteerWatch();
1148
1257
  clearTimeout(thresholdTimer);
1149
1258
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
1150
1259
  }
@@ -1657,7 +1766,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1657
1766
  });
1658
1767
  const jobGeneration = asyncManager.getJob(job.jobId)?.generation ?? job.jobId;
1659
1768
  job.setBackgrounded(true);
1660
- asyncManager.markBackgrounded(job.jobId, jobGeneration);
1769
+ asyncManager.markStartedInBackground(job.jobId, jobGeneration);
1661
1770
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1662
1771
  requestedTimeoutSec,
1663
1772
  notices: pendingNotices,
@@ -1711,13 +1820,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1711
1820
  const jobGeneration = ownedManager.getJob(job.jobId)?.generation ?? job.jobId;
1712
1821
  if (startBackgrounded) {
1713
1822
  job.setBackgrounded(true);
1714
- ownedManager.markBackgrounded(job.jobId, jobGeneration);
1823
+ ownedManager.markStartedInBackground(job.jobId, jobGeneration);
1715
1824
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1716
1825
  requestedTimeoutSec,
1717
1826
  notices: pendingNotices,
1718
1827
  });
1719
1828
  }
1720
- const backgroundRequest = Promise.withResolvers<void>();
1829
+ const backgroundRequest = Promise.withResolvers<FoldReason>();
1721
1830
  // Exactly one party may settle the foreground caller. detachObserver (the
1722
1831
  // fold won) and resolveForegroundObserver (a completion was handed back
1723
1832
  // after a failed fold) share this flag, so a race cannot double-settle.
@@ -1727,7 +1836,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1727
1836
  foregroundSettled = true;
1728
1837
  return "resolved";
1729
1838
  };
1730
- const unregisterBackgroundRequest = this.session.registerForegroundFoldParticipant?.({
1839
+ const managedFoldAdapter: FoldAdapter = {
1731
1840
  kind: "bash-managed",
1732
1841
  jobId: job.jobId,
1733
1842
  jobGeneration,
@@ -1746,24 +1855,26 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1746
1855
  const current = ownedManager.getJob(job.jobId);
1747
1856
  return current?.generation === jobGeneration ? current : undefined;
1748
1857
  },
1749
- detachObserver: () => {
1858
+ detachObserver: receipt => {
1750
1859
  const outcome = settleForeground();
1751
1860
  if (outcome === "resolved") {
1752
1861
  job.setBackgrounded(true);
1753
- ownedManager.markBackgrounded(job.jobId, jobGeneration);
1754
- backgroundRequest.resolve();
1862
+ ownedManager.markBackgrounded(job.jobId, jobGeneration, receipt.reason);
1863
+ backgroundRequest.resolve(receipt.reason);
1755
1864
  }
1756
1865
  return outcome;
1757
1866
  },
1758
1867
  resolveForegroundObserver: () => settleForeground(),
1759
- });
1760
- let waitResult: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
1868
+ };
1869
+ const unregisterBackgroundRequest = this.session.registerForegroundFoldParticipant?.(managedFoldAdapter);
1870
+ let waitResult: ManagedBashJobCompletion | { kind: "running"; reason: FoldReason } | { kind: "aborted" };
1761
1871
  try {
1762
1872
  waitResult = await this.#waitForManagedBashJob(
1763
1873
  job,
1764
1874
  autoBackgroundWaitMs,
1765
1875
  signal,
1766
1876
  backgroundRequest.promise,
1877
+ managedFoldAdapter,
1767
1878
  );
1768
1879
  } finally {
1769
1880
  unregisterBackgroundRequest?.();
@@ -1791,10 +1902,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1791
1902
  throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, job.getLatestText()));
1792
1903
  }
1793
1904
  job.setBackgrounded(true);
1794
- ownedManager.markBackgrounded(job.jobId, jobGeneration);
1905
+ ownedManager.markBackgrounded(job.jobId, jobGeneration, waitResult.reason);
1795
1906
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
1796
1907
  requestedTimeoutSec,
1797
1908
  notices: pendingNotices,
1909
+ foldReason: waitResult.reason,
1798
1910
  });
1799
1911
  }
1800
1912
 
@@ -2323,9 +2435,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2323
2435
  void bridgeHandle.completion.finally(unregisterOwnerCleanup);
2324
2436
  registerOwnedIfLineaged(bridgeManager, toolCallId, bridgeJobId, bridgeEndpointId);
2325
2437
 
2326
- const bridgeFoldRequest = Promise.withResolvers<void>();
2438
+ const bridgeFoldRequest = Promise.withResolvers<FoldReason>();
2327
2439
  const bridgeOriginatingTurn = ctx?.attemptScope !== undefined;
2328
- const unregisterBridgeFold = this.session.registerForegroundFoldParticipant?.({
2440
+ const bridgeFoldAdapter: FoldAdapter = {
2329
2441
  kind: "client-terminal",
2330
2442
  jobId: bridgeJobId,
2331
2443
  jobGeneration: bridgeGeneration,
@@ -2342,25 +2454,27 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2342
2454
  const current = bridgeManager.getJob(bridgeJobId);
2343
2455
  return current?.generation === bridgeGeneration ? current : undefined;
2344
2456
  },
2345
- detachObserver: () => {
2457
+ detachObserver: receipt => {
2346
2458
  const outcome = settleBridgeForeground();
2347
2459
  if (outcome === "resolved") {
2348
2460
  bridgeHandle.setBackgrounded(true);
2349
- bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
2350
- bridgeFoldRequest.resolve();
2461
+ bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration, receipt.reason);
2462
+ bridgeFoldRequest.resolve(receipt.reason);
2351
2463
  }
2352
2464
  return outcome;
2353
2465
  },
2354
2466
  resolveForegroundObserver: () => settleBridgeForeground(),
2355
- });
2467
+ };
2468
+ const unregisterBridgeFold = this.session.registerForegroundFoldParticipant?.(bridgeFoldAdapter);
2356
2469
 
2357
- let bridgeWait: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
2470
+ let bridgeWait: ManagedBashJobCompletion | { kind: "running"; reason: FoldReason } | { kind: "aborted" };
2358
2471
  try {
2359
2472
  bridgeWait = await this.#waitForManagedBashJob(
2360
2473
  bridgeHandle,
2361
2474
  this.#autoBackgroundEnabled ? this.#resolveAutoBackgroundWaitMs(timeoutMs) : timeoutMs + 1_000,
2362
2475
  signal,
2363
2476
  bridgeFoldRequest.promise,
2477
+ bridgeFoldAdapter,
2364
2478
  );
2365
2479
  } finally {
2366
2480
  unregisterBridgeFold?.();
@@ -2395,11 +2509,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2395
2509
  // Folded (or auto-backgrounded): the remote terminal keeps running and its
2396
2510
  // result arrives as a background completion.
2397
2511
  bridgeHandle.setBackgrounded(true);
2398
- bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
2512
+ bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration, bridgeWait.reason);
2399
2513
  return this.#buildBackgroundStartResult(bridgeJobId, bridgeLabel, bridgeHandle.getLatestText(), timeoutSec, {
2400
2514
  requestedTimeoutSec,
2401
2515
  notices: pendingNotices,
2402
2516
  terminalId: handle.terminalId,
2517
+ foldReason: bridgeWait.reason,
2403
2518
  });
2404
2519
  }
2405
2520
 
@@ -2521,7 +2636,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2521
2636
  },
2522
2637
  );
2523
2638
  void controls.terminalCompletion.finally(unregisterPtyOwnerCleanup);
2524
- ptyFoldUnregister = this.session.registerForegroundFoldParticipant?.({
2639
+ const ptyStartedAt = Date.now();
2640
+ const ptyFoldAdapter: FoldAdapter = {
2525
2641
  kind: "bash-pty",
2526
2642
  jobId: ptyJobId,
2527
2643
  jobGeneration: ptyGeneration,
@@ -2538,12 +2654,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2538
2654
  const current = ptyManager.getJob(ptyJobId);
2539
2655
  return current?.generation === ptyGeneration ? current : undefined;
2540
2656
  },
2541
- detachObserver: () => {
2657
+ detachObserver: receipt => {
2542
2658
  // Output-only continuation: stdin forwarding ends here and the
2543
2659
  // process is never killed or restarted by folding.
2544
2660
  const started = this.#buildBackgroundStartResult(ptyJobId, ptyLabel, "", timeoutSec, {
2545
2661
  requestedTimeoutSec,
2546
2662
  notices: pendingNotices,
2663
+ foldReason: receipt.reason,
2547
2664
  });
2548
2665
  const outcome = controls.detachObserver(
2549
2666
  interactiveResultFromText(this.#extractTextResult(started)),
@@ -2551,13 +2668,19 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2551
2668
  if (outcome === "resolved") {
2552
2669
  controls.detachForegroundCancellation();
2553
2670
  ptyBackgrounded = true;
2554
- ptyManager.markBackgrounded(ptyJobId, ptyGeneration);
2671
+ ptyManager.markBackgrounded(ptyJobId, ptyGeneration, receipt.reason);
2555
2672
  ptyFoldResult = started;
2556
2673
  }
2557
2674
  return outcome;
2558
2675
  },
2559
2676
  resolveForegroundObserver: () => "already-settled",
2560
- });
2677
+ };
2678
+ const unregisterPtyFold = this.session.registerForegroundFoldParticipant?.(ptyFoldAdapter);
2679
+ const stopPtySteerWatch = this.#watchSteerForFold(ptyFoldAdapter, ptyStartedAt);
2680
+ ptyFoldUnregister = () => {
2681
+ stopPtySteerWatch();
2682
+ unregisterPtyFold?.();
2683
+ };
2561
2684
  },
2562
2685
  onFoldKey: () => {
2563
2686
  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;