@gajae-code/coding-agent 0.15.6 → 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 (270) hide show
  1. package/CHANGELOG.md +99 -0
  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/args.d.ts +2 -0
  5. package/dist/types/cli/root-flags.d.ts +14 -0
  6. package/dist/types/cli/update-cli.d.ts +14 -0
  7. package/dist/types/cli.d.ts +7 -0
  8. package/dist/types/commands/launch.d.ts +10 -0
  9. package/dist/types/commands/sdk.d.ts +2 -0
  10. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  11. package/dist/types/config/billing-path.d.ts +25 -0
  12. package/dist/types/config/config-schema-version.d.ts +13 -1
  13. package/dist/types/config/file-lock.d.ts +5 -0
  14. package/dist/types/config/model-registry.d.ts +22 -0
  15. package/dist/types/config/settings-schema.d.ts +27 -8
  16. package/dist/types/config/settings.d.ts +2 -0
  17. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  18. package/dist/types/crash/record-loader.d.ts +15 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  20. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  21. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  22. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  23. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  24. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  26. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  27. package/dist/types/main.d.ts +1 -0
  28. package/dist/types/master-mode/context.d.ts +21 -0
  29. package/dist/types/master-mode/first-request.d.ts +34 -0
  30. package/dist/types/master-mode/snapshot.d.ts +32 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  32. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  33. package/dist/types/modes/components/model-selector.d.ts +4 -0
  34. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  35. package/dist/types/modes/components/session-selector.d.ts +1 -1
  36. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  37. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  38. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  39. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  40. package/dist/types/modes/interactive-mode.d.ts +16 -2
  41. package/dist/types/modes/print-mode.d.ts +17 -0
  42. package/dist/types/modes/theme/theme.d.ts +2 -1
  43. package/dist/types/modes/types.d.ts +2 -0
  44. package/dist/types/modes/ui-language.d.ts +7 -1
  45. package/dist/types/sdk/broker/broker.d.ts +79 -2
  46. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  47. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  48. package/dist/types/sdk/broker/index.d.ts +3 -0
  49. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  50. package/dist/types/sdk/broker/lifecycle.d.ts +40 -0
  51. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  52. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  53. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  54. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  55. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  56. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  57. package/dist/types/sdk/broker/state-version.d.ts +21 -1
  58. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  59. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  60. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  61. package/dist/types/sdk/bus/index.d.ts +9 -1
  62. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  63. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  64. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  65. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  66. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  67. package/dist/types/sdk/cli/index.d.ts +1 -0
  68. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  69. package/dist/types/sdk/cli/rows.d.ts +12 -5
  70. package/dist/types/sdk/cli/session-cli.d.ts +20 -3
  71. package/dist/types/sdk/client/client.d.ts +3 -2
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/events.d.ts +1 -0
  77. package/dist/types/sdk/host/host.d.ts +10 -0
  78. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  79. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  80. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  81. package/dist/types/sdk/host/session-runtime.d.ts +70 -17
  82. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  83. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  84. package/dist/types/sdk/lifecycle-session.d.ts +3 -1
  85. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  86. package/dist/types/sdk/receipt-state.d.ts +2 -0
  87. package/dist/types/sdk/router/session-router.d.ts +6 -0
  88. package/dist/types/sdk/session.d.ts +16 -0
  89. package/dist/types/sdk/startup-capability.d.ts +2 -1
  90. package/dist/types/sdk/turn-result.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +52 -7
  92. package/dist/types/session/credential-store-errors.d.ts +1 -0
  93. package/dist/types/session/fold-coordinator.d.ts +29 -2
  94. package/dist/types/session/session-manager.d.ts +79 -2
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/tools/ask.d.ts +1 -0
  98. package/dist/types/tools/bash.d.ts +16 -0
  99. package/dist/types/tools/browser/launch.d.ts +10 -0
  100. package/dist/types/tools/browser/registry.d.ts +8 -0
  101. package/dist/types/tools/gh.d.ts +8 -0
  102. package/dist/types/tools/index.d.ts +10 -10
  103. package/dist/types/tools/job.d.ts +3 -1
  104. package/dist/types/utils/external-editor.d.ts +7 -0
  105. package/examples/extensions/README.md +1 -1
  106. package/examples/extensions/pirate.ts +7 -4
  107. package/package.json +7 -7
  108. package/scripts/generate-sdk-operation-inventory.ts +13 -1
  109. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  110. package/src/async/job-manager.ts +107 -9
  111. package/src/browser-backend.ts +29 -0
  112. package/src/cli/accounts-cli.ts +9 -5
  113. package/src/cli/args.ts +17 -0
  114. package/src/cli/fast-help.ts +1 -0
  115. package/src/cli/plugin-cli.ts +29 -0
  116. package/src/cli/root-flags.ts +5 -0
  117. package/src/cli/update-cli.ts +140 -23
  118. package/src/commands/launch.ts +86 -18
  119. package/src/commands/sdk.ts +135 -28
  120. package/src/config/autorouting-tier-map.ts +12 -0
  121. package/src/config/billing-path.ts +42 -0
  122. package/src/config/config-schema-version.ts +18 -1
  123. package/src/config/file-lock.ts +158 -83
  124. package/src/config/model-preset-registry.ts +178 -23
  125. package/src/config/model-profiles.ts +5 -5
  126. package/src/config/model-registry.ts +291 -42
  127. package/src/config/model-resolver.ts +5 -0
  128. package/src/config/settings-schema.ts +27 -8
  129. package/src/config/settings.ts +51 -15
  130. package/src/coordinator-mcp/projection-scan.ts +27 -6
  131. package/src/coordinator-mcp/question-state.ts +9 -11
  132. package/src/coordinator-mcp/server.ts +115 -21
  133. package/src/crash/index-store.ts +2 -0
  134. package/src/crash/record-loader.ts +10 -0
  135. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  136. package/src/extensibility/extensions/runner.ts +27 -0
  137. package/src/extensibility/extensions/types.ts +8 -3
  138. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  139. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  140. package/src/extensibility/skills.ts +22 -12
  141. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  142. package/src/gjc-runtime/launch-worktree.ts +285 -4
  143. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  144. package/src/gjc-runtime/session-state-lock.ts +213 -14
  145. package/src/gjc-runtime/session-state-sidecar.ts +938 -39
  146. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  147. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -3
  148. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  149. package/src/hooks/skill-state.ts +6 -0
  150. package/src/internal-urls/docs-index.generated.ts +15 -15
  151. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  152. package/src/main.ts +61 -3
  153. package/src/master-mode/context.ts +50 -0
  154. package/src/master-mode/first-request.ts +65 -0
  155. package/src/master-mode/snapshot.ts +205 -0
  156. package/src/modes/acp/acp-agent.ts +610 -161
  157. package/src/modes/components/footer.ts +4 -2
  158. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  159. package/src/modes/components/model-selector.ts +76 -8
  160. package/src/modes/components/oauth-selector.ts +6 -1
  161. package/src/modes/components/session-selector.ts +23 -15
  162. package/src/modes/components/sessions-dashboard.ts +8 -5
  163. package/src/modes/components/settings-selector.ts +1 -0
  164. package/src/modes/components/status-line/model-name.ts +53 -0
  165. package/src/modes/components/status-line/priority-row.ts +107 -0
  166. package/src/modes/components/status-line/segments.ts +23 -9
  167. package/src/modes/components/tool-status-header.ts +151 -15
  168. package/src/modes/controllers/command-controller.ts +1 -1
  169. package/src/modes/controllers/event-controller.ts +30 -4
  170. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  171. package/src/modes/controllers/input-controller.ts +30 -2
  172. package/src/modes/controllers/selector-controller.ts +200 -98
  173. package/src/modes/interactive-mode.ts +36 -12
  174. package/src/modes/print-mode.ts +64 -0
  175. package/src/modes/prompt-action-autocomplete.ts +30 -4
  176. package/src/modes/runtime-init.ts +12 -9
  177. package/src/modes/theme/theme.ts +5 -0
  178. package/src/modes/types.ts +2 -0
  179. package/src/modes/ui-language.ts +49 -2
  180. package/src/modes/utils/ui-helpers.ts +22 -3
  181. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  182. package/src/prompts/system/master-mode.md +28 -0
  183. package/src/prompts/tools/ask.md +1 -1
  184. package/src/prompts/tools/bash.md +1 -0
  185. package/src/prompts/tools/job.md +1 -1
  186. package/src/sdk/acp/adapter.ts +5 -0
  187. package/src/sdk/broker/broker.ts +1646 -27
  188. package/src/sdk/broker/endpoint-authority.ts +86 -0
  189. package/src/sdk/broker/ensure.ts +81 -3
  190. package/src/sdk/broker/index.ts +3 -0
  191. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  192. package/src/sdk/broker/lifecycle.ts +440 -75
  193. package/src/sdk/broker/master-capability.ts +232 -0
  194. package/src/sdk/broker/session-index.ts +121 -17
  195. package/src/sdk/broker/session-scope.ts +272 -0
  196. package/src/sdk/broker/spawn-authority.ts +1031 -0
  197. package/src/sdk/broker/spawn-substrate.ts +591 -0
  198. package/src/sdk/broker/startup-budget.ts +144 -2
  199. package/src/sdk/broker/state-version.ts +43 -14
  200. package/src/sdk/broker/transport.ts +1 -0
  201. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  202. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  203. package/src/sdk/bus/chat-daemon-runtime.ts +136 -18
  204. package/src/sdk/bus/discord-daemon.ts +50 -10
  205. package/src/sdk/bus/index.ts +84 -7
  206. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  207. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  208. package/src/sdk/bus/notification-service.ts +22 -5
  209. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  210. package/src/sdk/bus/reconciliation-store.ts +55 -31
  211. package/src/sdk/bus/slack-conversation.ts +1 -1
  212. package/src/sdk/bus/slack-daemon.ts +72 -38
  213. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  214. package/src/sdk/cli/index.ts +1 -0
  215. package/src/sdk/cli/master-cli.ts +259 -0
  216. package/src/sdk/cli/rows.ts +41 -15
  217. package/src/sdk/cli/session-cli.ts +297 -26
  218. package/src/sdk/client/client.ts +14 -8
  219. package/src/sdk/host/control/dispatch.ts +101 -40
  220. package/src/sdk/host/control/index.ts +2 -0
  221. package/src/sdk/host/control/operations.ts +2 -0
  222. package/src/sdk/host/control/runtime-gate.ts +203 -11
  223. package/src/sdk/host/events.ts +1 -0
  224. package/src/sdk/host/host.ts +29 -2
  225. package/src/sdk/host/query/cursor.ts +52 -7
  226. package/src/sdk/host/query/handlers.ts +33 -12
  227. package/src/sdk/host/query/revision-store.ts +12 -0
  228. package/src/sdk/host/session-runtime.test.ts +673 -14
  229. package/src/sdk/host/session-runtime.ts +674 -103
  230. package/src/sdk/host/surface-policy.ts +3 -3
  231. package/src/sdk/lifecycle/broker-client.ts +28 -0
  232. package/src/sdk/lifecycle/service.ts +279 -20
  233. package/src/sdk/lifecycle-session.ts +17 -4
  234. package/src/sdk/prompt-deadline-manager.ts +2 -1
  235. package/src/sdk/protocol/operation-inventory.generated.json +105 -3
  236. package/src/sdk/protocol/operation-registry.ts +14 -4
  237. package/src/sdk/receipt-state.ts +10 -0
  238. package/src/sdk/router/session-router.ts +219 -118
  239. package/src/sdk/session.ts +416 -195
  240. package/src/sdk/startup-capability.ts +1 -0
  241. package/src/sdk/turn-result.ts +22 -0
  242. package/src/session/agent-session.ts +1628 -318
  243. package/src/session/blob-store.ts +15 -5
  244. package/src/session/credential-store-errors.ts +2 -0
  245. package/src/session/fold-coordinator.ts +40 -3
  246. package/src/session/internal/managed-session-storage.ts +153 -78
  247. package/src/session/session-manager.ts +850 -97
  248. package/src/session/yield-queue.ts +24 -10
  249. package/src/setup/frictionless-onboarding.ts +2 -0
  250. package/src/slash-commands/builtin-registry.ts +41 -0
  251. package/src/task/executor.ts +9 -4
  252. package/src/telemetry/events.ts +1138 -12
  253. package/src/tools/ask-contract.test.ts +64 -0
  254. package/src/tools/ask-contract.ts +36 -1
  255. package/src/tools/ask.ts +10 -8
  256. package/src/tools/bash-interactive.ts +11 -2
  257. package/src/tools/bash.ts +412 -75
  258. package/src/tools/browser/launch.ts +37 -0
  259. package/src/tools/browser/registry.ts +99 -9
  260. package/src/tools/cron.ts +6 -4
  261. package/src/tools/descriptors.test.ts +15 -0
  262. package/src/tools/descriptors.ts +5 -1
  263. package/src/tools/gh.ts +104 -48
  264. package/src/tools/github-cache.ts +2 -2
  265. package/src/tools/index.ts +10 -6
  266. package/src/tools/job.ts +4 -1
  267. package/src/tools/monitor.ts +12 -9
  268. package/src/tools/resolve.ts +30 -10
  269. package/src/tools/tool-catalog.generated.ts +2 -2
  270. package/src/utils/external-editor.ts +72 -1
package/src/tools/bash.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
3
4
  import type { Component } from "@gajae-code/tui";
4
5
  import { getKeybindings, ImageProtocol, TERMINAL, Text, visibleWidth } from "@gajae-code/tui";
5
6
  import { getProjectDir, isEnoent, logger, prompt } from "@gajae-code/utils";
6
7
  import * as z from "zod/v4";
7
- import { AsyncJobManager } from "../async";
8
+ import { AsyncJobManager, type FoldReason } from "../async";
8
9
  import { type BashArtifactSaveResult, type BashResult, executeBash } from "../exec/bash-executor";
9
10
 
10
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -17,12 +18,14 @@ import { InternalUrlRouter } from "../internal-urls";
17
18
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
18
19
  import { highlightCode, type Theme } from "../modes/theme/theme";
19
20
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
21
+ import { renderSpawnTable, runSdkSpawn, type SdkSpawnArgs } from "../sdk/cli/master-cli";
20
22
  import type { ArtifactManager } from "../session/artifacts";
21
23
  import type {
22
24
  ClientBridgeTerminalExitStatus,
23
25
  ClientBridgeTerminalHandle,
24
26
  ClientBridgeTerminalOutput,
25
27
  } from "../session/client-bridge";
28
+ import type { FoldAdapter } from "../session/fold-coordinator";
26
29
  import {
27
30
  DEFAULT_ARTIFACT_MAX_BYTES,
28
31
  OutputSink,
@@ -83,10 +86,23 @@ function sliceTextAfterUtf8ByteOffset(text: string, offsetBytes: number): string
83
86
  import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
84
87
 
85
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
+ }
86
101
 
87
102
  const BASH_ERROR_MAX_BYTES = 4096;
88
103
  const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
89
104
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
105
+ const MASTER_CAPABILITY_ENV = "GJC_MASTER_CAPABILITY";
90
106
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
91
107
  const ACP_RELEASE_TIMEOUT_MS = 1_000;
92
108
  const READ_ONLY_BASH_ENV: Record<string, string> = {
@@ -485,6 +501,8 @@ export interface BashToolDetails {
485
501
  timeoutSeconds?: number;
486
502
  requestedTimeoutSeconds?: number;
487
503
  terminalId?: string;
504
+ /** Why the foreground wait was folded, when this result is a background-start after a fold. */
505
+ foldReason?: FoldReason;
488
506
  async?: {
489
507
  state: "running" | "completed" | "failed";
490
508
  jobId: string;
@@ -601,6 +619,107 @@ function normalizeBashEnv(env: Record<string, string> | undefined): Record<strin
601
619
  return normalized;
602
620
  }
603
621
 
622
+ /**
623
+ * Return true only for a direct, shell-syntax-free `gjc sdk spawn` command.
624
+ *
625
+ * Master authority is process-local and must not become ambient state for an
626
+ * arbitrary shell pipeline. Keep a tiny lexer here instead of a prefix check:
627
+ * shell quoting is allowed for ordinary argument values, while operators,
628
+ * expansions, escapes, globbing, and line breaks all refuse the privileged
629
+ * environment injection path.
630
+ */
631
+ function strictDirectSdkSpawnTokens(command: string): string[] | undefined {
632
+ if (command.length === 0) return undefined;
633
+ const tokens: string[] = [];
634
+ let token = "";
635
+ let tokenStarted = false;
636
+ let quote: "'" | '"' | undefined;
637
+ for (const character of command) {
638
+ if (quote !== undefined) {
639
+ if (character === quote) {
640
+ quote = undefined;
641
+ tokenStarted = true;
642
+ } else {
643
+ token += character;
644
+ tokenStarted = true;
645
+ }
646
+ continue;
647
+ }
648
+ if (";&|<>()$`\\*?[]{}#!\n\r".includes(character)) return undefined;
649
+ if (character === "'" || character === '"') {
650
+ quote = character;
651
+ tokenStarted = true;
652
+ continue;
653
+ }
654
+ if (/\s/u.test(character)) {
655
+ if (tokenStarted) {
656
+ tokens.push(token);
657
+ token = "";
658
+ tokenStarted = false;
659
+ }
660
+ continue;
661
+ }
662
+ token += character;
663
+ tokenStarted = true;
664
+ }
665
+ if (quote !== undefined) return undefined;
666
+ if (tokenStarted) tokens.push(token);
667
+ return tokens.length >= 3 && tokens[0] === "gjc" && tokens[1] === "sdk" && tokens[2] === "spawn"
668
+ ? tokens
669
+ : undefined;
670
+ }
671
+
672
+ export function isStrictDirectSdkSpawnCommand(command: string): boolean {
673
+ return strictDirectSdkSpawnTokens(command) !== undefined;
674
+ }
675
+
676
+ export function parseDirectSdkSpawnArgs(command: string): SdkSpawnArgs {
677
+ const tokens = strictDirectSdkSpawnTokens(command);
678
+ if (tokens === undefined) throw new ToolError("Master spawn command is not a direct shell-safe invocation.");
679
+ const args: SdkSpawnArgs = {};
680
+ for (let index = 3; index < tokens.length; index++) {
681
+ const token = tokens[index]!;
682
+ if (token === "--json") {
683
+ args.json = true;
684
+ continue;
685
+ }
686
+ const equals = token.indexOf("=");
687
+ const name = equals < 0 ? token : token.slice(0, equals);
688
+ const value = equals < 0 ? tokens[++index] : token.slice(equals + 1);
689
+ if (value === undefined || value.length === 0) throw new ToolError(`Master spawn flag ${name} requires a value.`);
690
+ switch (name) {
691
+ case "--cwd":
692
+ args.cwd = value;
693
+ break;
694
+ case "--prompt":
695
+ args.prompt = value;
696
+ break;
697
+ case "--model":
698
+ args.model = value;
699
+ break;
700
+ case "--profile":
701
+ args.profile = value;
702
+ break;
703
+ case "--idempotency-key":
704
+ args.idempotencyKey = value;
705
+ break;
706
+ case "--agent-dir":
707
+ throw new ToolError("Master spawn cannot override the session agent directory.");
708
+ default:
709
+ throw new ToolError(`Unsupported master spawn flag: ${name}`);
710
+ }
711
+ }
712
+ return args;
713
+ }
714
+
715
+ export function masterCommandEnvOverrides(
716
+ env: Record<string, string> | undefined,
717
+ directMasterSpawn: boolean,
718
+ ): Record<string, string> {
719
+ if (directMasterSpawn) return {};
720
+ return Object.fromEntries(Object.entries(env ?? {}).filter(([key]) => key !== MASTER_CAPABILITY_ENV));
721
+ }
722
+
604
723
  function escapeBashEnvValueForDisplay(value: string): string {
605
724
  return value
606
725
  .replaceAll("\\", "\\\\")
@@ -830,7 +949,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
830
949
  label: string,
831
950
  previewText: string,
832
951
  timeoutSec: number,
833
- options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
952
+ options: {
953
+ requestedTimeoutSec?: number;
954
+ notices?: readonly string[];
955
+ terminalId?: string;
956
+ foldReason?: FoldReason;
957
+ } = {},
834
958
  ): AgentToolResult<BashToolDetails> {
835
959
  const details: BashToolDetails = {
836
960
  timeoutSeconds: timeoutSec,
@@ -842,6 +966,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
842
966
  if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
843
967
  details.requestedTimeoutSeconds = options.requestedTimeoutSec;
844
968
  }
969
+ if (options.foldReason !== undefined) details.foldReason = options.foldReason;
845
970
  const lines: string[] = [];
846
971
  const trimmedPreview = previewText.trimEnd();
847
972
  if (trimmedPreview.length > 0) {
@@ -851,6 +976,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
851
976
  lines.push(...options.notices, "");
852
977
  }
853
978
  lines.push(`Background job ${jobId} started: ${label}`);
979
+ if (options.foldReason === "steer") lines.push(steerFoldReasonLine(jobId));
854
980
  lines.push("Result will be delivered automatically when complete.");
855
981
  lines.push(
856
982
  `You can use \`job\` to poll until complete, but prefer to continue with another task in the meanwhile if it's not blocking.`,
@@ -874,6 +1000,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
874
1000
  notices?: readonly string[];
875
1001
 
876
1002
  resolvedEnv?: Record<string, string>;
1003
+ directMasterSpawn: boolean;
877
1004
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
878
1005
  startBackgrounded: boolean;
879
1006
  onCompletion?: () => void;
@@ -885,7 +1012,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
885
1012
  throw new ToolError("Background job manager unavailable for this session.");
886
1013
  }
887
1014
 
888
- const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
1015
+ const label = options.directMasterSpawn
1016
+ ? "gjc sdk spawn"
1017
+ : options.command.length > 120
1018
+ ? `${options.command.slice(0, 117)}...`
1019
+ : options.command;
889
1020
  let latestText = "";
890
1021
  let backgrounded = options.startBackgrounded;
891
1022
  const runningDetails = (jobId: string): Record<string, unknown> | undefined =>
@@ -908,37 +1039,37 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
908
1039
 
909
1040
  let executionResult: BashResult | BashInteractiveResult | undefined;
910
1041
  try {
911
- const result = await executeBash(options.command, {
912
- cwd: options.commandCwd,
913
- settings: this.session.settings,
914
- sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
915
- timeout: options.timeoutMs,
916
- signal: runSignal,
917
- env: options.resolvedEnv,
918
- artifactPath,
919
- artifactId,
920
- artifactPublisher,
921
- spillThreshold,
922
- headBytes,
923
- oneShot: true,
924
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
925
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
926
- onChunk: chunk => {
927
- tailBuffer.append(chunk);
928
- latestText = tailBuffer.text();
929
- void reportProgress(latestText, runningDetails(jobId));
930
- },
931
- onRawChunk: chunk => {
932
- // Forward the unthrottled sanitized chunk to the async-job
933
- // substrate so the Monitor tool can read the complete process
934
- // stream by byte offset, independent of the throttled preview
935
- // path above.
936
- manager.appendOutput(jobId, chunk);
937
- },
938
- onMinimizedSave: async originalText => {
939
- return saveBashOriginalArtifact(this.session, originalText);
940
- },
941
- });
1042
+ const result = options.directMasterSpawn
1043
+ ? await this.#runDirectMasterSpawn(
1044
+ options.command,
1045
+ options.commandCwd,
1046
+ options.timeoutMs,
1047
+ runSignal,
1048
+ options.resolvedEnv,
1049
+ )
1050
+ : await executeBash(options.command, {
1051
+ cwd: options.commandCwd,
1052
+ settings: this.session.settings,
1053
+ sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
1054
+ timeout: options.timeoutMs,
1055
+ signal: runSignal,
1056
+ env: options.resolvedEnv,
1057
+ artifactPath,
1058
+ artifactId,
1059
+ artifactPublisher,
1060
+ spillThreshold,
1061
+ headBytes,
1062
+ oneShot: true,
1063
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1064
+ disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1065
+ onChunk: chunk => {
1066
+ tailBuffer.append(chunk);
1067
+ latestText = tailBuffer.text();
1068
+ void reportProgress(latestText, runningDetails(jobId));
1069
+ },
1070
+ onRawChunk: chunk => manager.appendOutput(jobId, chunk),
1071
+ onMinimizedSave: async originalText => saveBashOriginalArtifact(this.session, originalText),
1072
+ });
942
1073
  executionResult = result;
943
1074
  const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
944
1075
  requestedTimeoutSec: options.requestedTimeoutSec,
@@ -1006,24 +1137,107 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1006
1137
  return AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
1007
1138
  }
1008
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
+ */
1009
1202
  async #waitForManagedBashJob(
1010
1203
  job: ManagedBashJobHandle,
1011
1204
  thresholdMs: number,
1012
1205
  signal?: AbortSignal,
1013
- backgroundRequest?: Promise<void>,
1014
- ): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }> {
1206
+ backgroundRequest?: Promise<FoldReason>,
1207
+ foldAdapter?: FoldAdapter,
1208
+ ): Promise<ManagedBashJobCompletion | { kind: "running"; reason: FoldReason } | { kind: "aborted" }> {
1015
1209
  if (signal?.aborted) {
1016
1210
  return { kind: "aborted" };
1017
1211
  }
1018
1212
 
1019
- const threshold = Promise.withResolvers<{ kind: "running" }>();
1020
- const thresholdTimer = setTimeout(() => threshold.resolve({ kind: "running" }), Math.max(0, thresholdMs));
1021
- const waiters: Array<Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }>> = [
1022
- job.completion,
1023
- threshold.promise,
1024
- ];
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];
1025
1239
  if (backgroundRequest) {
1026
- waiters.push(backgroundRequest.then(() => ({ kind: "running" as const })));
1240
+ waiters.push(backgroundRequest.then(reason => ({ kind: "running" as const, reason })));
1027
1241
  }
1028
1242
 
1029
1243
  let onAbort: (() => void) | undefined;
@@ -1033,9 +1247,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1033
1247
  signal.addEventListener("abort", onAbort, { once: true });
1034
1248
  waiters.push(aborted.promise);
1035
1249
  }
1250
+
1251
+ const stopSteerWatch =
1252
+ backgroundRequest && foldAdapter ? this.#watchSteerForFold(foldAdapter, startedAt) : () => {};
1036
1253
  try {
1037
1254
  return await Promise.race(waiters);
1038
1255
  } finally {
1256
+ stopSteerWatch();
1039
1257
  clearTimeout(thresholdTimer);
1040
1258
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
1041
1259
  }
@@ -1061,6 +1279,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1061
1279
  command: string;
1062
1280
  commandCwd: string;
1063
1281
  resolvedEnv: Record<string, string>;
1282
+ directMasterSpawn: boolean;
1064
1283
  requestedTimeoutSec: number;
1065
1284
  timeoutSec: number;
1066
1285
  timeoutMs: number;
@@ -1206,14 +1425,26 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1206
1425
  // silently ignore the caller's override.
1207
1426
  const explicitAgentDirOverride =
1208
1427
  expandedEnv?.GJC_CODING_AGENT_DIR !== undefined || expandedEnv?.PI_CODING_AGENT_DIR !== undefined;
1428
+ // The master capability is a one-shot authority for the direct spawn CLI,
1429
+ // never ambient state for ordinary Bash or a chained/pipelined descendant.
1430
+ // Check both spellings so cwd normalization or URL expansion cannot turn a
1431
+ // command that contained shell syntax into a privileged one.
1432
+ const directMasterSpawn = isStrictDirectSdkSpawnCommand(rawCommand) && isStrictDirectSdkSpawnCommand(command);
1433
+ const masterCapability = directMasterSpawn ? this.session.getMasterBashCapability?.() : undefined;
1434
+ const masterOwnerSessionId = directMasterSpawn ? this.session.getMasterOwnerSessionId?.() : undefined;
1435
+ const commandEnvOverrides = masterCommandEnvOverrides(expandedEnv, directMasterSpawn);
1209
1436
  const resolvedEnv = {
1210
1437
  ...buildGjcRuntimeSessionEnv({
1211
1438
  sessionFile: null,
1212
1439
  sessionId: this.session.getSessionId?.(),
1213
1440
  cwd: this.session.cwd,
1214
1441
  }),
1215
- ...(sessionAgentDir && !explicitAgentDirOverride ? { GJC_CODING_AGENT_DIR: sessionAgentDir } : {}),
1216
- ...expandedEnv,
1442
+ ...(sessionAgentDir && (!explicitAgentDirOverride || directMasterSpawn)
1443
+ ? { GJC_CODING_AGENT_DIR: sessionAgentDir }
1444
+ : {}),
1445
+ ...commandEnvOverrides,
1446
+ ...(masterCapability ? { [MASTER_CAPABILITY_ENV]: masterCapability } : {}),
1447
+ ...(masterOwnerSessionId ? { GJC_MASTER_OWNER_SESSION_ID: masterOwnerSessionId } : {}),
1217
1448
  ...(this.session.bashRestrictionProfile === "read-only" ? READ_ONLY_BASH_ENV : {}),
1218
1449
  ...(allowedPrefixes && allowedPrefixes.length > 0 ? { [GJC_RESTRICTED_ROLE_AGENT_BASH_ENV]: "1" } : {}),
1219
1450
  };
@@ -1245,7 +1476,80 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1245
1476
  const sleepAdvisory = longSleepAdvisory(command, timeoutSec);
1246
1477
  if (sleepAdvisory) notices.push(sleepAdvisory);
1247
1478
 
1248
- return { command, commandCwd, resolvedEnv, requestedTimeoutSec, timeoutSec, timeoutMs, notices };
1479
+ return {
1480
+ command,
1481
+ commandCwd,
1482
+ resolvedEnv,
1483
+ directMasterSpawn,
1484
+ requestedTimeoutSec,
1485
+ timeoutSec,
1486
+ timeoutMs,
1487
+ notices,
1488
+ };
1489
+ }
1490
+
1491
+ async #runDirectMasterSpawn(
1492
+ command: string,
1493
+ commandCwd: string,
1494
+ timeoutMs: number,
1495
+ signal: AbortSignal | undefined,
1496
+ resolvedEnv: Record<string, string> | undefined,
1497
+ ): Promise<BashResult> {
1498
+ const masterCapability = resolvedEnv?.[MASTER_CAPABILITY_ENV];
1499
+ const sessionAgentDir = resolvedEnv?.GJC_CODING_AGENT_DIR;
1500
+ if (masterCapability === undefined || sessionAgentDir === undefined)
1501
+ throw new ToolError("Master spawn authority context is unavailable.");
1502
+ const args = parseDirectSdkSpawnArgs(command);
1503
+ args.agentDir = sessionAgentDir;
1504
+ if (args.cwd !== undefined && !path.isAbsolute(args.cwd)) args.cwd = path.resolve(commandCwd, args.cwd);
1505
+ const request = runSdkSpawn(args, {
1506
+ env: {
1507
+ ...process.env,
1508
+ GJC_MASTER_CAPABILITY: masterCapability,
1509
+ GJC_SESSION_ID: resolvedEnv?.GJC_MASTER_OWNER_SESSION_ID ?? this.session.getSessionId?.() ?? undefined,
1510
+ GJC_CODING_AGENT_DIR: sessionAgentDir,
1511
+ },
1512
+ });
1513
+ const timeout = Bun.sleep(timeoutMs).then(() => {
1514
+ throw new ToolError(`Command timed out after ${Math.ceil(timeoutMs / 1000)} seconds`);
1515
+ });
1516
+ const aborted = Promise.withResolvers<never>();
1517
+ const onAbort = (): void => aborted.reject(new ToolAbortError("Command aborted"));
1518
+ if (signal?.aborted) onAbort();
1519
+ else signal?.addEventListener("abort", onAbort, { once: true });
1520
+ let result: Awaited<typeof request>;
1521
+ try {
1522
+ result = await Promise.race([request, timeout, aborted.promise]);
1523
+ } finally {
1524
+ signal?.removeEventListener("abort", onAbort);
1525
+ }
1526
+ const output = args.json ? JSON.stringify(result.rendered) : renderSpawnTable(result.rendered);
1527
+ const outputBytes = Buffer.byteLength(output, "utf8");
1528
+ const outputLines = output.split("\n").length;
1529
+ return {
1530
+ output,
1531
+ exitCode: result.exitCode,
1532
+ cancelled: false,
1533
+ truncated: false,
1534
+ totalLines: outputLines,
1535
+ totalBytes: outputBytes,
1536
+ outputLines,
1537
+ outputBytes,
1538
+ };
1539
+ }
1540
+
1541
+ async #executeDirectMasterSpawn(
1542
+ command: string,
1543
+ commandCwd: string,
1544
+ timeoutSec: number,
1545
+ timeoutMs: number,
1546
+ requestedTimeoutSec: number,
1547
+ notices: readonly string[],
1548
+ signal: AbortSignal | undefined,
1549
+ resolvedEnv: Record<string, string>,
1550
+ ): Promise<AgentToolResult<BashToolDetails>> {
1551
+ const result = await this.#runDirectMasterSpawn(command, commandCwd, timeoutMs, signal, resolvedEnv);
1552
+ return this.#buildCompletedResult(result, timeoutSec, { requestedTimeoutSec, notices });
1249
1553
  }
1250
1554
 
1251
1555
  /**
@@ -1343,8 +1647,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1343
1647
  spillThreshold,
1344
1648
  headBytes,
1345
1649
  oneShot: true,
1346
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1347
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1650
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only" || prepared.directMasterSpawn,
1651
+ disableShellSnapshot:
1652
+ this.session.bashRestrictionProfile === "read-only" || prepared.directMasterSpawn,
1348
1653
  onChunk: chunk => {
1349
1654
  tailBuffer.append(chunk);
1350
1655
  void reportProgress(tailBuffer.text(), {
@@ -1416,11 +1721,25 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1416
1721
  command,
1417
1722
  commandCwd,
1418
1723
  resolvedEnv,
1724
+ directMasterSpawn,
1419
1725
  requestedTimeoutSec,
1420
1726
  timeoutSec,
1421
1727
  timeoutMs,
1422
1728
  notices: pendingNotices,
1423
1729
  } = prepared;
1730
+ const masterCapability = directMasterSpawn ? resolvedEnv[MASTER_CAPABILITY_ENV] : undefined;
1731
+ if (directMasterSpawn && masterCapability !== undefined && !asyncRequested) {
1732
+ return await this.#executeDirectMasterSpawn(
1733
+ command,
1734
+ commandCwd,
1735
+ timeoutSec,
1736
+ timeoutMs,
1737
+ requestedTimeoutSec,
1738
+ pendingNotices,
1739
+ signal,
1740
+ resolvedEnv,
1741
+ );
1742
+ }
1424
1743
 
1425
1744
  if (asyncRequested) {
1426
1745
  // Availability is endpoint-first: a concurrent top-level session
@@ -1440,13 +1759,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1440
1759
  notices: pendingNotices,
1441
1760
 
1442
1761
  resolvedEnv,
1762
+ directMasterSpawn,
1443
1763
  onUpdate,
1444
1764
  startBackgrounded: true,
1445
1765
  toolCallId,
1446
1766
  });
1447
1767
  const jobGeneration = asyncManager.getJob(job.jobId)?.generation ?? job.jobId;
1448
1768
  job.setBackgrounded(true);
1449
- asyncManager.markBackgrounded(job.jobId, jobGeneration);
1769
+ asyncManager.markStartedInBackground(job.jobId, jobGeneration);
1450
1770
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1451
1771
  requestedTimeoutSec,
1452
1772
  notices: pendingNotices,
@@ -1457,7 +1777,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1457
1777
  // Skip when pty=true (PTY needs the local terminal UI).
1458
1778
  const clientBridge =
1459
1779
  this.session.bashRestrictionProfile === "read-only" ? undefined : this.session.getClientBridge?.();
1460
- const clientTerminalActive = Boolean(clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty);
1780
+ const clientTerminalActive = Boolean(
1781
+ !directMasterSpawn && clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty,
1782
+ );
1461
1783
 
1462
1784
  // Run non-PTY bash through the managed job path so Ctrl+B-twice fold-on-demand works
1463
1785
  // even when auto-background is disabled. When a client terminal will handle the
@@ -1487,6 +1809,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1487
1809
  notices: pendingNotices,
1488
1810
 
1489
1811
  resolvedEnv,
1812
+ directMasterSpawn,
1490
1813
  onUpdate,
1491
1814
  startBackgrounded,
1492
1815
  onCompletion: () => {
@@ -1497,13 +1820,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1497
1820
  const jobGeneration = ownedManager.getJob(job.jobId)?.generation ?? job.jobId;
1498
1821
  if (startBackgrounded) {
1499
1822
  job.setBackgrounded(true);
1500
- ownedManager.markBackgrounded(job.jobId, jobGeneration);
1823
+ ownedManager.markStartedInBackground(job.jobId, jobGeneration);
1501
1824
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1502
1825
  requestedTimeoutSec,
1503
1826
  notices: pendingNotices,
1504
1827
  });
1505
1828
  }
1506
- const backgroundRequest = Promise.withResolvers<void>();
1829
+ const backgroundRequest = Promise.withResolvers<FoldReason>();
1507
1830
  // Exactly one party may settle the foreground caller. detachObserver (the
1508
1831
  // fold won) and resolveForegroundObserver (a completion was handed back
1509
1832
  // after a failed fold) share this flag, so a race cannot double-settle.
@@ -1513,7 +1836,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1513
1836
  foregroundSettled = true;
1514
1837
  return "resolved";
1515
1838
  };
1516
- const unregisterBackgroundRequest = this.session.registerForegroundFoldParticipant?.({
1839
+ const managedFoldAdapter: FoldAdapter = {
1517
1840
  kind: "bash-managed",
1518
1841
  jobId: job.jobId,
1519
1842
  jobGeneration,
@@ -1532,24 +1855,26 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1532
1855
  const current = ownedManager.getJob(job.jobId);
1533
1856
  return current?.generation === jobGeneration ? current : undefined;
1534
1857
  },
1535
- detachObserver: () => {
1858
+ detachObserver: receipt => {
1536
1859
  const outcome = settleForeground();
1537
1860
  if (outcome === "resolved") {
1538
1861
  job.setBackgrounded(true);
1539
- ownedManager.markBackgrounded(job.jobId, jobGeneration);
1540
- backgroundRequest.resolve();
1862
+ ownedManager.markBackgrounded(job.jobId, jobGeneration, receipt.reason);
1863
+ backgroundRequest.resolve(receipt.reason);
1541
1864
  }
1542
1865
  return outcome;
1543
1866
  },
1544
1867
  resolveForegroundObserver: () => settleForeground(),
1545
- });
1546
- 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" };
1547
1871
  try {
1548
1872
  waitResult = await this.#waitForManagedBashJob(
1549
1873
  job,
1550
1874
  autoBackgroundWaitMs,
1551
1875
  signal,
1552
1876
  backgroundRequest.promise,
1877
+ managedFoldAdapter,
1553
1878
  );
1554
1879
  } finally {
1555
1880
  unregisterBackgroundRequest?.();
@@ -1577,10 +1902,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1577
1902
  throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, job.getLatestText()));
1578
1903
  }
1579
1904
  job.setBackgrounded(true);
1580
- ownedManager.markBackgrounded(job.jobId, jobGeneration);
1905
+ ownedManager.markBackgrounded(job.jobId, jobGeneration, waitResult.reason);
1581
1906
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
1582
1907
  requestedTimeoutSec,
1583
1908
  notices: pendingNotices,
1909
+ foldReason: waitResult.reason,
1584
1910
  });
1585
1911
  }
1586
1912
 
@@ -2109,9 +2435,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2109
2435
  void bridgeHandle.completion.finally(unregisterOwnerCleanup);
2110
2436
  registerOwnedIfLineaged(bridgeManager, toolCallId, bridgeJobId, bridgeEndpointId);
2111
2437
 
2112
- const bridgeFoldRequest = Promise.withResolvers<void>();
2438
+ const bridgeFoldRequest = Promise.withResolvers<FoldReason>();
2113
2439
  const bridgeOriginatingTurn = ctx?.attemptScope !== undefined;
2114
- const unregisterBridgeFold = this.session.registerForegroundFoldParticipant?.({
2440
+ const bridgeFoldAdapter: FoldAdapter = {
2115
2441
  kind: "client-terminal",
2116
2442
  jobId: bridgeJobId,
2117
2443
  jobGeneration: bridgeGeneration,
@@ -2128,25 +2454,27 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2128
2454
  const current = bridgeManager.getJob(bridgeJobId);
2129
2455
  return current?.generation === bridgeGeneration ? current : undefined;
2130
2456
  },
2131
- detachObserver: () => {
2457
+ detachObserver: receipt => {
2132
2458
  const outcome = settleBridgeForeground();
2133
2459
  if (outcome === "resolved") {
2134
2460
  bridgeHandle.setBackgrounded(true);
2135
- bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
2136
- bridgeFoldRequest.resolve();
2461
+ bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration, receipt.reason);
2462
+ bridgeFoldRequest.resolve(receipt.reason);
2137
2463
  }
2138
2464
  return outcome;
2139
2465
  },
2140
2466
  resolveForegroundObserver: () => settleBridgeForeground(),
2141
- });
2467
+ };
2468
+ const unregisterBridgeFold = this.session.registerForegroundFoldParticipant?.(bridgeFoldAdapter);
2142
2469
 
2143
- let bridgeWait: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
2470
+ let bridgeWait: ManagedBashJobCompletion | { kind: "running"; reason: FoldReason } | { kind: "aborted" };
2144
2471
  try {
2145
2472
  bridgeWait = await this.#waitForManagedBashJob(
2146
2473
  bridgeHandle,
2147
2474
  this.#autoBackgroundEnabled ? this.#resolveAutoBackgroundWaitMs(timeoutMs) : timeoutMs + 1_000,
2148
2475
  signal,
2149
2476
  bridgeFoldRequest.promise,
2477
+ bridgeFoldAdapter,
2150
2478
  );
2151
2479
  } finally {
2152
2480
  unregisterBridgeFold?.();
@@ -2181,11 +2509,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2181
2509
  // Folded (or auto-backgrounded): the remote terminal keeps running and its
2182
2510
  // result arrives as a background completion.
2183
2511
  bridgeHandle.setBackgrounded(true);
2184
- bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
2512
+ bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration, bridgeWait.reason);
2185
2513
  return this.#buildBackgroundStartResult(bridgeJobId, bridgeLabel, bridgeHandle.getLatestText(), timeoutSec, {
2186
2514
  requestedTimeoutSec,
2187
2515
  notices: pendingNotices,
2188
2516
  terminalId: handle.terminalId,
2517
+ foldReason: bridgeWait.reason,
2189
2518
  });
2190
2519
  }
2191
2520
 
@@ -2200,7 +2529,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2200
2529
  const artifactPublisher = createBashArtifactPublisher(this.session);
2201
2530
 
2202
2531
  const interactiveUi =
2203
- this.session.bashRestrictionProfile === "read-only"
2532
+ this.session.bashRestrictionProfile === "read-only" || directMasterSpawn
2204
2533
  ? undefined
2205
2534
  : canUseInteractiveBashPty(pty, ctx)
2206
2535
  ? ctx?.ui
@@ -2307,7 +2636,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2307
2636
  },
2308
2637
  );
2309
2638
  void controls.terminalCompletion.finally(unregisterPtyOwnerCleanup);
2310
- ptyFoldUnregister = this.session.registerForegroundFoldParticipant?.({
2639
+ const ptyStartedAt = Date.now();
2640
+ const ptyFoldAdapter: FoldAdapter = {
2311
2641
  kind: "bash-pty",
2312
2642
  jobId: ptyJobId,
2313
2643
  jobGeneration: ptyGeneration,
@@ -2324,12 +2654,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2324
2654
  const current = ptyManager.getJob(ptyJobId);
2325
2655
  return current?.generation === ptyGeneration ? current : undefined;
2326
2656
  },
2327
- detachObserver: () => {
2657
+ detachObserver: receipt => {
2328
2658
  // Output-only continuation: stdin forwarding ends here and the
2329
2659
  // process is never killed or restarted by folding.
2330
2660
  const started = this.#buildBackgroundStartResult(ptyJobId, ptyLabel, "", timeoutSec, {
2331
2661
  requestedTimeoutSec,
2332
2662
  notices: pendingNotices,
2663
+ foldReason: receipt.reason,
2333
2664
  });
2334
2665
  const outcome = controls.detachObserver(
2335
2666
  interactiveResultFromText(this.#extractTextResult(started)),
@@ -2337,13 +2668,19 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2337
2668
  if (outcome === "resolved") {
2338
2669
  controls.detachForegroundCancellation();
2339
2670
  ptyBackgrounded = true;
2340
- ptyManager.markBackgrounded(ptyJobId, ptyGeneration);
2671
+ ptyManager.markBackgrounded(ptyJobId, ptyGeneration, receipt.reason);
2341
2672
  ptyFoldResult = started;
2342
2673
  }
2343
2674
  return outcome;
2344
2675
  },
2345
2676
  resolveForegroundObserver: () => "already-settled",
2346
- });
2677
+ };
2678
+ const unregisterPtyFold = this.session.registerForegroundFoldParticipant?.(ptyFoldAdapter);
2679
+ const stopPtySteerWatch = this.#watchSteerForFold(ptyFoldAdapter, ptyStartedAt);
2680
+ ptyFoldUnregister = () => {
2681
+ stopPtySteerWatch();
2682
+ unregisterPtyFold?.();
2683
+ };
2347
2684
  },
2348
2685
  onFoldKey: () => {
2349
2686
  const now = Date.now();
@@ -2368,7 +2705,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2368
2705
  cwd: commandCwd,
2369
2706
  settings: this.session.settings,
2370
2707
  sessionKey: this.session.getSessionId?.() ?? undefined,
2371
- oneShot: this.session.bashRestrictionProfile === "read-only",
2708
+ oneShot: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
2372
2709
  timeout: timeoutMs,
2373
2710
  signal,
2374
2711
  env: resolvedEnv,
@@ -2379,8 +2716,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2379
2716
  headBytes,
2380
2717
  onChunk: streamTailUpdates(tailBuffer, onUpdate),
2381
2718
  onMinimizedSave: async originalText => saveBashOriginalArtifact(this.session, originalText),
2382
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
2383
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
2719
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
2720
+ disableShellSnapshot: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
2384
2721
  });
2385
2722
  ptyFoldUnregister?.();
2386
2723
  // Folded: the foreground was settled by the fold, so return the background