@gajae-code/coding-agent 0.15.5 → 0.16.0

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 (272) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  5. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  6. package/dist/types/cli/root-flags.d.ts +14 -0
  7. package/dist/types/cli/update-cli.d.ts +15 -0
  8. package/dist/types/cli.d.ts +7 -0
  9. package/dist/types/commands/auth-gateway.d.ts +3 -0
  10. package/dist/types/commands/gc.d.ts +13 -0
  11. package/dist/types/commands/launch.d.ts +10 -0
  12. package/dist/types/commands/model-presets.d.ts +21 -0
  13. package/dist/types/config/autorouting-tier-map.d.ts +120 -0
  14. package/dist/types/config/file-lock.d.ts +54 -2
  15. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  16. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  17. package/dist/types/config/keybindings.d.ts +3 -2
  18. package/dist/types/config/model-preset-registry.d.ts +368 -0
  19. package/dist/types/config/model-profile-activation.d.ts +39 -0
  20. package/dist/types/config/model-profiles.d.ts +5 -3
  21. package/dist/types/config/model-registry.d.ts +17 -5
  22. package/dist/types/config/settings-schema.d.ts +19 -1
  23. package/dist/types/config/settings.d.ts +2 -0
  24. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  25. package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
  26. package/dist/types/crash/record-loader.d.ts +15 -0
  27. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  28. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  29. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  31. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  33. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/main.d.ts +3 -1
  38. package/dist/types/master-mode/context.d.ts +21 -0
  39. package/dist/types/master-mode/first-request.d.ts +34 -0
  40. package/dist/types/master-mode/snapshot.d.ts +32 -0
  41. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  43. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  44. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  45. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  46. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  47. package/dist/types/modes/jobs-observer.d.ts +36 -0
  48. package/dist/types/modes/print-mode.d.ts +17 -0
  49. package/dist/types/modes/ui-language.d.ts +7 -1
  50. package/dist/types/sdk/broker/broker.d.ts +80 -1
  51. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  52. package/dist/types/sdk/broker/index.d.ts +3 -0
  53. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  54. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  56. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  57. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  58. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  59. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  60. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  61. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  62. package/dist/types/sdk/bus/index.d.ts +6 -0
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  65. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  66. package/dist/types/sdk/cli/index.d.ts +1 -0
  67. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  68. package/dist/types/sdk/cli/rows.d.ts +4 -3
  69. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  70. package/dist/types/sdk/client/client.d.ts +3 -2
  71. package/dist/types/sdk/client/discovery.d.ts +1 -0
  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/host.d.ts +10 -0
  77. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  78. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +67 -1
  80. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  81. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  82. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  83. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  84. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  85. package/dist/types/sdk/router/session-router.d.ts +11 -0
  86. package/dist/types/sdk/session.d.ts +16 -0
  87. package/dist/types/sdk/startup-capability.d.ts +2 -1
  88. package/dist/types/session/account-inventory.d.ts +1 -0
  89. package/dist/types/session/agent-session.d.ts +24 -7
  90. package/dist/types/session/credential-store-errors.d.ts +1 -0
  91. package/dist/types/session/fold-coordinator.d.ts +201 -0
  92. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  93. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  94. package/dist/types/session/session-manager.d.ts +65 -0
  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/telemetry/control.d.ts +11 -0
  98. package/dist/types/telemetry/events.d.ts +20 -0
  99. package/dist/types/telemetry/index.d.ts +3 -0
  100. package/dist/types/telemetry/transport.d.ts +22 -0
  101. package/dist/types/tools/bash-interactive.d.ts +23 -0
  102. package/dist/types/tools/bash.d.ts +4 -0
  103. package/dist/types/tools/gh.d.ts +8 -0
  104. package/dist/types/tools/index.d.ts +10 -5
  105. package/dist/types/web/search/providers/base.d.ts +9 -4
  106. package/dist/types/web/search/providers/xai.d.ts +2 -1
  107. package/dist/types/web/search/types.d.ts +14 -0
  108. package/package.json +10 -7
  109. package/scripts/generate-hotkeys-docs.ts +4 -0
  110. package/scripts/generate-sdk-operation-inventory.ts +9 -3
  111. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  112. package/src/async/job-manager.ts +671 -39
  113. package/src/cli/accounts-cli.ts +9 -5
  114. package/src/cli/args.ts +17 -0
  115. package/src/cli/auth-broker-cli.ts +40 -12
  116. package/src/cli/auth-gateway-cli.ts +273 -97
  117. package/src/cli/config-cli.ts +2 -2
  118. package/src/cli/fast-help.ts +4 -0
  119. package/src/cli/model-presets-cli.ts +100 -0
  120. package/src/cli/root-flags.ts +5 -0
  121. package/src/cli/update-cli.ts +117 -16
  122. package/src/cli.ts +6 -2
  123. package/src/commands/auth-gateway.ts +12 -8
  124. package/src/commands/gc.ts +19 -0
  125. package/src/commands/launch.ts +86 -18
  126. package/src/commands/model-presets.ts +24 -0
  127. package/src/commands/sdk.ts +113 -21
  128. package/src/config/autorouting-tier-map.ts +44 -0
  129. package/src/config/file-lock-gc.ts +9 -4
  130. package/src/config/file-lock.ts +890 -99
  131. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  132. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  133. package/src/config/keybindings.ts +6 -2
  134. package/src/config/model-preset-registry.ts +2795 -0
  135. package/src/config/model-profile-activation.ts +115 -28
  136. package/src/config/model-profile-contract.ts +4 -1
  137. package/src/config/model-profiles.ts +56 -14
  138. package/src/config/model-registry.ts +1044 -122
  139. package/src/config/model-resolver.ts +5 -0
  140. package/src/config/settings-schema.ts +19 -1
  141. package/src/config/settings.ts +11 -1
  142. package/src/coordinator-mcp/model-preset.ts +32 -17
  143. package/src/coordinator-mcp/projection-scan.ts +592 -0
  144. package/src/coordinator-mcp/question-state.ts +9 -11
  145. package/src/coordinator-mcp/server.ts +111 -42
  146. package/src/coordinator-mcp/session-reaper.ts +9 -3
  147. package/src/crash/index-store.ts +2 -0
  148. package/src/crash/record-loader.ts +10 -0
  149. package/src/extensibility/extensions/loader.ts +0 -1
  150. package/src/extensibility/extensions/types.ts +2 -3
  151. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  152. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  153. package/src/gjc-runtime/gc-render.ts +29 -0
  154. package/src/gjc-runtime/gc-runtime.ts +174 -11
  155. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  156. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  157. package/src/gjc-runtime/session-state-lock.ts +1321 -147
  158. package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
  159. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  160. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  161. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  162. package/src/hooks/skill-state.ts +6 -0
  163. package/src/index.ts +1 -0
  164. package/src/internal-urls/docs-index.generated.ts +14 -13
  165. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  166. package/src/lsp/config.ts +4 -3
  167. package/src/lsp/path-trust.ts +12 -6
  168. package/src/main.ts +66 -4
  169. package/src/master-mode/context.ts +50 -0
  170. package/src/master-mode/first-request.ts +65 -0
  171. package/src/master-mode/snapshot.ts +205 -0
  172. package/src/modes/acp/acp-agent.ts +616 -161
  173. package/src/modes/components/footer.ts +4 -2
  174. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  175. package/src/modes/components/jobs-overlay-model.ts +99 -16
  176. package/src/modes/components/model-selector.ts +158 -18
  177. package/src/modes/components/oauth-selector.ts +6 -1
  178. package/src/modes/components/settings-selector.ts +1 -0
  179. package/src/modes/components/status-line/model-name.ts +53 -0
  180. package/src/modes/components/status-line/priority-row.ts +107 -0
  181. package/src/modes/components/status-line/segments.ts +29 -10
  182. package/src/modes/components/tool-status-header.ts +151 -15
  183. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  184. package/src/modes/controllers/input-controller.ts +51 -6
  185. package/src/modes/controllers/selector-controller.ts +191 -96
  186. package/src/modes/interactive-mode.ts +14 -1
  187. package/src/modes/jobs-observer.ts +149 -16
  188. package/src/modes/print-mode.ts +64 -0
  189. package/src/modes/runtime-init.ts +1 -1
  190. package/src/modes/ui-language.ts +49 -2
  191. package/src/modes/utils/injected-user-submission.ts +15 -1
  192. package/src/prompts/system/master-mode.md +28 -0
  193. package/src/prompts/tools/bash.md +2 -2
  194. package/src/prompts/tools/fold-receipt.md +3 -0
  195. package/src/prompts/tools/job.md +1 -1
  196. package/src/sdk/acp/adapter.ts +17 -2
  197. package/src/sdk/broker/broker.ts +1671 -34
  198. package/src/sdk/broker/endpoint-authority.ts +86 -0
  199. package/src/sdk/broker/ensure.ts +5 -0
  200. package/src/sdk/broker/index.ts +3 -0
  201. package/src/sdk/broker/lifecycle.ts +217 -46
  202. package/src/sdk/broker/master-capability.ts +232 -0
  203. package/src/sdk/broker/session-index.ts +121 -17
  204. package/src/sdk/broker/session-scope.ts +272 -0
  205. package/src/sdk/broker/spawn-authority.ts +1031 -0
  206. package/src/sdk/broker/spawn-substrate.ts +548 -0
  207. package/src/sdk/broker/state-version.ts +26 -14
  208. package/src/sdk/broker/transport.ts +1 -0
  209. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  210. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  211. package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
  212. package/src/sdk/bus/index.ts +51 -4
  213. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  214. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  215. package/src/sdk/bus/slack-conversation.ts +5 -1
  216. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  217. package/src/sdk/cli/index.ts +1 -0
  218. package/src/sdk/cli/master-cli.ts +259 -0
  219. package/src/sdk/cli/rows.ts +11 -8
  220. package/src/sdk/cli/session-cli.ts +276 -6
  221. package/src/sdk/client/client.ts +14 -8
  222. package/src/sdk/client/discovery.ts +3 -1
  223. package/src/sdk/host/control/dispatch.ts +101 -37
  224. package/src/sdk/host/control/index.ts +2 -0
  225. package/src/sdk/host/control/operations.ts +2 -0
  226. package/src/sdk/host/control/runtime-gate.ts +203 -11
  227. package/src/sdk/host/host.ts +27 -2
  228. package/src/sdk/host/model-pin.ts +185 -25
  229. package/src/sdk/host/sdk-run-capability.ts +1 -0
  230. package/src/sdk/host/session-runtime.test.ts +581 -43
  231. package/src/sdk/host/session-runtime.ts +655 -101
  232. package/src/sdk/lifecycle/broker-client.ts +28 -0
  233. package/src/sdk/lifecycle/service.ts +279 -20
  234. package/src/sdk/lifecycle-session.ts +10 -2
  235. package/src/sdk/prompt-deadline-manager.ts +16 -1
  236. package/src/sdk/protocol/adapter-validation.ts +27 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +53 -2
  238. package/src/sdk/protocol/operation-registry.ts +17 -4
  239. package/src/sdk/router/session-router.ts +294 -154
  240. package/src/sdk/session.ts +440 -167
  241. package/src/sdk/startup-capability.ts +1 -0
  242. package/src/session/account-inventory.ts +27 -11
  243. package/src/session/agent-session.ts +1292 -180
  244. package/src/session/blob-store.ts +15 -5
  245. package/src/session/credential-store-errors.ts +2 -0
  246. package/src/session/fold-coordinator.ts +433 -0
  247. package/src/session/internal/managed-session-storage.ts +153 -78
  248. package/src/session/sdk-run-capability-internal.ts +15 -0
  249. package/src/session/sdk-run-capability.ts +2 -0
  250. package/src/session/session-manager.ts +342 -28
  251. package/src/session/yield-queue.ts +24 -10
  252. package/src/setup/frictionless-onboarding.ts +2 -0
  253. package/src/setup/provider-presets.json +1 -1
  254. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  255. package/src/task/executor.ts +19 -3
  256. package/src/telemetry/control.ts +26 -0
  257. package/src/telemetry/events.ts +1268 -0
  258. package/src/telemetry/index.ts +3 -0
  259. package/src/telemetry/transport.ts +87 -0
  260. package/src/tools/bash-interactive.ts +182 -85
  261. package/src/tools/bash.ts +1028 -109
  262. package/src/tools/gh.ts +104 -48
  263. package/src/tools/github-cache.ts +2 -2
  264. package/src/tools/index.ts +11 -5
  265. package/src/tools/tool-catalog.generated.ts +1 -1
  266. package/src/web/search/provider.ts +120 -11
  267. package/src/web/search/providers/anthropic.ts +89 -17
  268. package/src/web/search/providers/base.ts +9 -4
  269. package/src/web/search/providers/gemini.ts +87 -15
  270. package/src/web/search/providers/openai-compatible.ts +23 -8
  271. package/src/web/search/providers/xai.ts +98 -12
  272. package/src/web/search/types.ts +16 -0
@@ -0,0 +1,3 @@
1
+ export * from "./control";
2
+ export * from "./events";
3
+ export * from "./transport";
@@ -0,0 +1,87 @@
1
+ import { Settings } from "../config/settings";
2
+ import { getTelemetryStatus } from "./control";
3
+ import { getTelemetryInstallId, serializeTelemetryEvent, type TelemetryEvent, type TelemetryEventName } from "./events";
4
+
5
+ export const TELEMETRY_ENDPOINT = "https://telemetry.gajae.dev/v1/events" as const;
6
+ export const TELEMETRY_MAX_IN_FLIGHT = 2 as const;
7
+ export const TELEMETRY_TIMEOUT_MS = 1500 as const;
8
+ export const TELEMETRY_FLUSH_TIMEOUT_MS = 2000 as const;
9
+
10
+ interface TelemetryTransportDependencies {
11
+ fetchImpl?: (input: string | URL, init?: RequestInit) => Promise<Response>;
12
+ endpoint?: string;
13
+ }
14
+
15
+ export type TelemetryDetails = Pick<TelemetryEvent, "channel" | "result" | "installMethod">;
16
+
17
+ let inFlight = 0;
18
+ const pendingTelemetry = new Set<Promise<void>>();
19
+
20
+ /**
21
+ * Best-effort transport. The caller only schedules work; disabled telemetry,
22
+ * queue saturation, network errors, and non-2xx responses are all ignored.
23
+ */
24
+ export function sendTelemetryEvent(
25
+ settings: Parameters<typeof getTelemetryStatus>[0],
26
+ event: TelemetryEvent,
27
+ deps: TelemetryTransportDependencies = {},
28
+ ): Promise<void> {
29
+ if (!getTelemetryStatus(settings).enabled || inFlight >= TELEMETRY_MAX_IN_FLIGHT) return Promise.resolve();
30
+ let body: string;
31
+ try {
32
+ body = serializeTelemetryEvent(event);
33
+ } catch {
34
+ return Promise.resolve();
35
+ }
36
+ inFlight++;
37
+ const fetchImpl = deps.fetchImpl ?? fetch;
38
+ const controller = new AbortController();
39
+ const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
40
+ timeout.unref?.();
41
+ const delivery = fetchImpl(deps.endpoint ?? TELEMETRY_ENDPOINT, {
42
+ method: "POST",
43
+ headers: { "content-type": "application/json" },
44
+ body,
45
+ signal: controller.signal,
46
+ redirect: "error",
47
+ })
48
+ .catch(() => undefined)
49
+ .then(() => undefined)
50
+ .finally(() => {
51
+ clearTimeout(timeout);
52
+ inFlight--;
53
+ });
54
+ pendingTelemetry.add(delivery);
55
+ void delivery.finally(() => pendingTelemetry.delete(delivery));
56
+ return delivery;
57
+ }
58
+
59
+ export async function resetTelemetryTransportForTest(): Promise<void> {
60
+ await Promise.allSettled([...pendingTelemetry]);
61
+ }
62
+
63
+ /** Schedule an event without making telemetry part of the caller's lifecycle. */
64
+ export function recordTelemetryEvent(event: TelemetryEventName, details: TelemetryDetails): Promise<void> {
65
+ const pending = (async () => {
66
+ const settings = await Settings.init();
67
+ if (!getTelemetryStatus(settings).enabled) return;
68
+ const installId = await getTelemetryInstallId();
69
+ await sendTelemetryEvent(settings, {
70
+ schemaVersion: 1,
71
+ event,
72
+ installId,
73
+ occurredAt: new Date().toISOString(),
74
+ ...details,
75
+ });
76
+ })().catch(() => undefined);
77
+ pendingTelemetry.add(pending);
78
+ void pending.finally(() => pendingTelemetry.delete(pending));
79
+ return pending;
80
+ }
81
+
82
+ /** Wait briefly for accepted events so explicit process exits do not drop them. */
83
+ export async function flushTelemetryEvents(timeoutMs = TELEMETRY_FLUSH_TIMEOUT_MS): Promise<void> {
84
+ const pending = [...pendingTelemetry];
85
+ if (pending.length === 0) return;
86
+ await Promise.race([Promise.allSettled(pending), Bun.sleep(timeoutMs)]);
87
+ }
@@ -3,6 +3,7 @@ import type { PtySession as NativePtySession, PtyRunResult } from "@gajae-code/n
3
3
  import {
4
4
  type Component,
5
5
  extractPrintableText,
6
+ getKeybindings,
6
7
  matchesKey,
7
8
  padding,
8
9
  parseKey,
@@ -10,7 +11,7 @@ import {
10
11
  truncateToWidth,
11
12
  visibleWidth,
12
13
  } from "@gajae-code/tui";
13
- import { sanitizeText } from "@gajae-code/utils";
14
+ import { logger, sanitizeText } from "@gajae-code/utils";
14
15
  import type { Terminal as XtermTerminalType } from "@xterm/headless";
15
16
  import { Settings } from "../config/settings";
16
17
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
@@ -115,6 +116,7 @@ class BashInteractiveOverlayComponent implements Component {
115
116
  #writeOffset = 0;
116
117
  #flushResolvers: Array<() => void> = [];
117
118
  #writing = false;
119
+ #onFoldKey: () => boolean = () => false;
118
120
 
119
121
  constructor(
120
122
  private readonly command: string,
@@ -131,10 +133,16 @@ class BashInteractiveOverlayComponent implements Component {
131
133
  });
132
134
  }
133
135
 
134
- setHandlers(onInput: (data: string) => void, onDismiss: () => void, onDispose: () => void): void {
136
+ setHandlers(
137
+ onInput: (data: string) => void,
138
+ onDismiss: () => void,
139
+ onDispose: () => void,
140
+ onFoldKey: () => boolean,
141
+ ): void {
135
142
  this.#onInput = onInput;
136
143
  this.#onDismiss = onDismiss;
137
144
  this.#onDispose = onDispose;
145
+ this.#onFoldKey = onFoldKey;
138
146
  }
139
147
 
140
148
  appendOutput(chunk: string): void {
@@ -199,6 +207,9 @@ class BashInteractiveOverlayComponent implements Component {
199
207
  }
200
208
 
201
209
  handleInput(data: string): void {
210
+ if (this.#state === "running" && getKeybindings().matches(data, "app.tool.backgroundFold")) {
211
+ if (this.#onFoldKey()) return;
212
+ }
202
213
  if (this.#state === "running" && (matchesKey(data, "escape") || matchesKey(data, "esc"))) {
203
214
  this.#onDismiss();
204
215
  return;
@@ -293,6 +304,25 @@ class BashInteractiveOverlayComponent implements Component {
293
304
  }
294
305
  }
295
306
 
307
+ /** Live controls handed to the caller once the PTY run owns its session. */
308
+ export interface InteractivePtyControls {
309
+ /**
310
+ * Detach the overlay and settle the foreground call with `foldResult`, leaving
311
+ * the process running and still writing into the sink. Idempotent.
312
+ */
313
+ detachObserver: (foldResult: BashInteractiveResult) => "resolved" | "already-settled";
314
+ /**
315
+ * Resolves with the run's real summary when the process actually ends,
316
+ * regardless of whether the foreground was folded. A folded run is delivered
317
+ * from this, so its result can never be silently dropped.
318
+ */
319
+ terminalCompletion: Promise<BashInteractiveResult>;
320
+ /** Kill the owned process during owner teardown; the observer never calls this. */
321
+ kill: () => void;
322
+ /** Stop forwarding the foreground abort signal after ownership transfers on fold. */
323
+ detachForegroundCancellation: () => void;
324
+ }
325
+
296
326
  export async function runInteractiveBashPty(
297
327
  ui: NonNullable<AgentToolContext["ui"]>,
298
328
  options: {
@@ -307,6 +337,11 @@ export async function runInteractiveBashPty(
307
337
  spillThreshold?: number;
308
338
  headBytes?: number;
309
339
  settings?: Settings;
340
+ /** Receives live controls once the session is running; used to register a fold. */
341
+ onControls?: (controls: InteractivePtyControls) => void;
342
+ onFoldKey?: () => boolean;
343
+ onDismiss?: () => void;
344
+ onOutput?: (chunk: string) => void;
310
345
  },
311
346
  ): Promise<BashInteractiveResult> {
312
347
  const settings = options.settings ?? (await Settings.init());
@@ -322,89 +357,151 @@ export async function runInteractiveBashPty(
322
357
  const { default: xterm } = await import("@xterm/headless");
323
358
  const XtermTerminal = xterm.Terminal;
324
359
  const PtySession = await ptySessionNative();
325
- const result = await ui.custom<BashInteractiveResult>(
326
- (tui, uiTheme, _keybindings, done) => {
327
- const session = new PtySession();
328
- const component = new BashInteractiveOverlayComponent(
329
- options.command,
330
- uiTheme,
331
- () => tui.terminal.rows,
332
- XtermTerminal,
333
- );
334
- component.setSession(session);
335
- let finished = false;
336
- const finalize = (run: PtyRunResult) => {
337
- if (finished) return;
338
- finished = true;
339
- component.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut });
340
- tui.requestRender();
341
- void (async () => {
342
- await component.flushOutput();
343
- const summary = await sink.dump();
344
- done({
345
- exitCode: run.exitCode,
346
- cancelled: run.cancelled,
347
- timedOut: run.timedOut,
348
- ...summary,
349
- });
350
- })();
360
+
361
+ // Ownership inversion: this runner owns the session and the sink from t0,
362
+ // started OUTSIDE ui.custom. The overlay is only an observer view, so
363
+ // dismissing it — or never creating it — can never kill the process.
364
+ // Seed the real host geometry so the first frame renders at the user's size;
365
+ // the overlay keeps resizing the session as it lays out. Fallbacks apply only
366
+ // when the host exposes no terminal (tests, pipes).
367
+ const initialPtySize = {
368
+ cols: Math.max(20, (process.stdout.columns ?? 80) - 2),
369
+ rows: Math.max(5, (process.stdout.rows ?? 24) - 4),
370
+ };
371
+
372
+ const session = new PtySession();
373
+ const processAbortController = new AbortController();
374
+ const forwardForegroundAbort = () => processAbortController.abort();
375
+ if (options.signal?.aborted) processAbortController.abort();
376
+ else options.signal?.addEventListener("abort", forwardForegroundAbort, { once: true });
377
+ let observer: BashInteractiveOverlayComponent | undefined;
378
+ let settleForeground: ((result: BashInteractiveResult) => void) | undefined;
379
+ let settled = false;
380
+ const foreground = Promise.withResolvers<BashInteractiveResult>();
381
+ const terminal = Promise.withResolvers<BashInteractiveResult>();
382
+
383
+ const settle = (result: BashInteractiveResult): "resolved" | "already-settled" => {
384
+ if (settled) return "already-settled";
385
+ settled = true;
386
+ // Prefer ui.custom's own `done` so the overlay tears down through its normal
387
+ // path; fall back to the outer promise when no overlay was ever attached.
388
+ if (settleForeground) settleForeground(result);
389
+ else foreground.resolve(result);
390
+ return "resolved";
391
+ };
392
+
393
+ let finished = false;
394
+ const finalize = (run: PtyRunResult): void => {
395
+ if (finished) return;
396
+ finished = true;
397
+ observer?.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut });
398
+ void (async () => {
399
+ await observer?.flushOutput();
400
+ const summary = await sink.dump();
401
+ const outcome: BashInteractiveResult = {
402
+ exitCode: run.exitCode,
403
+ cancelled: run.cancelled,
404
+ timedOut: run.timedOut,
405
+ ...summary,
351
406
  };
352
- const cols = Math.max(20, tui.terminal.columns - 2);
353
- const rows = Math.max(5, tui.terminal.rows - 4);
354
- component.setHandlers(
355
- data => {
356
- try {
357
- session.write(data);
358
- } catch {
359
- // ignore writes after command exits
360
- }
361
- },
362
- () => {
363
- try {
364
- session.kill();
365
- } catch {
366
- // ignore
367
- }
368
- },
369
- () => {
370
- try {
371
- session.kill();
372
- } catch {
373
- // ignore
374
- }
375
- },
376
- );
377
- void session
378
- .start(
379
- {
380
- command: options.command,
381
- cwd: options.cwd,
382
- timeoutMs: options.timeoutMs,
383
- env: {
384
- ...NON_INTERACTIVE_ENV,
385
- ...options.env,
386
- },
387
- signal: options.signal,
388
- cols,
389
- rows,
390
- shell: resolvedShell,
391
- },
392
- (err, chunk) => {
393
- if (finished || err || !chunk) return;
394
- component.appendOutput(chunk);
395
- const normalizedChunk = normalizeCaptureChunk(chunk);
396
- sink.push(normalizedChunk);
397
- tui.requestRender();
398
- },
399
- )
400
- .then(finalize)
401
- .catch(error => {
402
- sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`);
403
- finalize({ exitCode: undefined, cancelled: false, timedOut: false });
404
- });
405
- return component;
407
+ options.signal?.removeEventListener("abort", forwardForegroundAbort);
408
+ // Publish the real outcome BEFORE settling: a folded run's foreground is
409
+ // already gone, and this is the only path that can deliver its result.
410
+ terminal.resolve(outcome);
411
+ settle(outcome);
412
+ })();
413
+ };
414
+
415
+ // The original deadline rides on the run itself, so a folded command expires
416
+ // exactly when it would have in the foreground.
417
+ void session
418
+ .start(
419
+ {
420
+ command: options.command,
421
+ cwd: options.cwd,
422
+ timeoutMs: options.timeoutMs,
423
+ env: { ...NON_INTERACTIVE_ENV, ...options.env },
424
+ signal: processAbortController.signal,
425
+ cols: initialPtySize.cols,
426
+ rows: initialPtySize.rows,
427
+ shell: resolvedShell,
428
+ },
429
+ (err, chunk) => {
430
+ if (finished || err || !chunk) return;
431
+ // The sink is fed unconditionally: output stays continuous across a
432
+ // fold, when no overlay exists, and after the overlay is disposed.
433
+ const normalizedChunk = normalizeCaptureChunk(chunk);
434
+ sink.push(normalizedChunk);
435
+ options.onOutput?.(normalizedChunk);
436
+ observer?.appendOutput(chunk);
437
+ },
438
+ )
439
+ .then(finalize)
440
+ .catch(error => {
441
+ sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`);
442
+ finalize({ exitCode: undefined, cancelled: false, timedOut: false });
443
+ });
444
+
445
+ options.onControls?.({
446
+ terminalCompletion: terminal.promise,
447
+ kill: () => session.kill(),
448
+ detachForegroundCancellation: () => {
449
+ options.signal?.removeEventListener("abort", forwardForegroundAbort);
406
450
  },
407
- { overlay: true },
408
- );
409
- return result;
451
+ detachObserver: (foldResult: BashInteractiveResult) => {
452
+ const outcome = settle(foldResult);
453
+ if (outcome === "resolved") observer = undefined;
454
+ return outcome;
455
+ },
456
+ });
457
+
458
+ // Folded (or already finished) before any view existed: there is nothing to
459
+ // attach an observer to, so never await an overlay that will never be settled.
460
+ if (settled) return await foreground.promise;
461
+
462
+ try {
463
+ const overlayResult = await Promise.race([
464
+ foreground.promise,
465
+ ui.custom<BashInteractiveResult>(
466
+ (tui, uiTheme, _keybindings, done) => {
467
+ settleForeground = done;
468
+ const component = new BashInteractiveOverlayComponent(
469
+ options.command,
470
+ uiTheme,
471
+ () => tui.terminal.rows,
472
+ XtermTerminal,
473
+ );
474
+ component.setSession(session);
475
+ // Observer view only: stdin still reaches the process while attached,
476
+ // Escape explicitly force-kills, while disposal never kills the work.
477
+ component.setHandlers(
478
+ data => {
479
+ try {
480
+ session.write(data);
481
+ } catch {
482
+ // ignore writes after the command exits
483
+ }
484
+ },
485
+ options.onDismiss ?? (() => session.kill()),
486
+ () => {},
487
+ options.onFoldKey ?? (() => false),
488
+ );
489
+ observer = component;
490
+ return component;
491
+ },
492
+ { overlay: true },
493
+ ),
494
+ ]);
495
+ return overlayResult;
496
+ } catch (error) {
497
+ // Overlay creation or view init failed. The process is already running and
498
+ // owned here, so surface the failure without orphaning it: the run keeps
499
+ // writing to the sink and settles the foreground on its own.
500
+ logger.warn("Interactive PTY overlay unavailable; continuing without the view", {
501
+ error: error instanceof Error ? error.message : String(error),
502
+ });
503
+ observer = undefined;
504
+ settleForeground = undefined;
505
+ return await foreground.promise;
506
+ }
410
507
  }