@gajae-code/coding-agent 0.18.12 → 0.19.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 (305) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/dist/types/capability/rule.d.ts +7 -2
  3. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  4. package/dist/types/cli/args.d.ts +7 -2
  5. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  6. package/dist/types/cli/customize-doctor.d.ts +4 -3
  7. package/dist/types/cli/public-command-errors.d.ts +1 -0
  8. package/dist/types/cli/root-flags.d.ts +0 -12
  9. package/dist/types/cli/update-cli.d.ts +7 -2
  10. package/dist/types/cli-main.d.ts +0 -6
  11. package/dist/types/commands/launch.d.ts +0 -6
  12. package/dist/types/commands/migrate.d.ts +1 -1
  13. package/dist/types/commands/sdk.d.ts +8 -0
  14. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  15. package/dist/types/config/autorouting-tier-map.d.ts +23 -1
  16. package/dist/types/config/config-hot-reload.d.ts +5 -0
  17. package/dist/types/config/file-lock.d.ts +44 -2
  18. package/dist/types/config/model-profile-activation.d.ts +19 -5
  19. package/dist/types/config/model-registry.d.ts +16 -3
  20. package/dist/types/config/model-resolver.d.ts +5 -4
  21. package/dist/types/config/settings-schema.d.ts +11 -1
  22. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  23. package/dist/types/customization/import.d.ts +2 -0
  24. package/dist/types/customization/types.d.ts +1 -1
  25. package/dist/types/dap/session.d.ts +11 -2
  26. package/dist/types/discovery/helpers.d.ts +7 -2
  27. package/dist/types/edit/index.d.ts +5 -0
  28. package/dist/types/edit/modes/patch.d.ts +9 -0
  29. package/dist/types/eval/py/runtime.d.ts +9 -0
  30. package/dist/types/export/ttsr.d.ts +19 -0
  31. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  32. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  33. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  34. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  36. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  37. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  38. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  39. package/dist/types/hindsight/content.d.ts +5 -6
  40. package/dist/types/internal-urls/types.d.ts +2 -0
  41. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  42. package/dist/types/main.d.ts +23 -18
  43. package/dist/types/migrate/action-planner.d.ts +10 -1
  44. package/dist/types/migrate/adapters/codex.d.ts +1 -1
  45. package/dist/types/migrate/adapters/index.d.ts +35 -3
  46. package/dist/types/migrate/hook-module.d.ts +23 -0
  47. package/dist/types/migrate/report.d.ts +2 -1
  48. package/dist/types/migrate/types.d.ts +69 -2
  49. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +43 -0
  51. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  52. package/dist/types/modes/utils/host-status.d.ts +15 -11
  53. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  54. package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
  55. package/dist/types/sdk/broker/broker.d.ts +22 -1
  56. package/dist/types/sdk/broker/ensure.d.ts +5 -2
  57. package/dist/types/sdk/broker/hop.d.ts +9 -0
  58. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  59. package/dist/types/sdk/broker/session-index.d.ts +2 -0
  60. package/dist/types/sdk/broker/transport.d.ts +2 -0
  61. package/dist/types/sdk/bus/index.d.ts +21 -0
  62. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
  63. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  64. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  65. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  66. package/dist/types/sdk/host/events.d.ts +4 -0
  67. package/dist/types/sdk/host/host.d.ts +2 -1
  68. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  69. package/dist/types/sdk/host/session-runtime.d.ts +8 -8
  70. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  71. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  72. package/dist/types/sdk/prompt-status.d.ts +11 -4
  73. package/dist/types/session/agent-session.d.ts +50 -8
  74. package/dist/types/session/session-manager.d.ts +16 -0
  75. package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
  76. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  77. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  78. package/dist/types/system-prompt.d.ts +3 -2
  79. package/dist/types/task/role-body.d.ts +11 -0
  80. package/dist/types/tools/awaiting-input.d.ts +28 -0
  81. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
  82. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  83. package/dist/types/tools/bash.d.ts +7 -0
  84. package/dist/types/tools/descriptors.d.ts +13 -2
  85. package/dist/types/tools/fetch.d.ts +6 -0
  86. package/dist/types/tools/irc.d.ts +5 -3
  87. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  88. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +2 -0
  90. package/dist/types/utils/file-mentions.d.ts +5 -0
  91. package/dist/types/utils/image-limits.d.ts +7 -0
  92. package/dist/types/utils/image-loading.d.ts +0 -1
  93. package/dist/types/utils/open.d.ts +0 -1
  94. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  95. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  96. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  97. package/dist/types/utils/title-generator.d.ts +13 -3
  98. package/dist/types/web/search/providers/codex.d.ts +6 -7
  99. package/package.json +9 -8
  100. package/scripts/acp-conformance-agent.ts +12 -2
  101. package/scripts/generate-sdk-operation-inventory.ts +16 -0
  102. package/src/capability/fs.ts +31 -3
  103. package/src/capability/rule.ts +11 -2
  104. package/src/cli/append-system-prompt.ts +12 -0
  105. package/src/cli/args.ts +11 -7
  106. package/src/cli/customize-doctor-precedence.ts +31 -0
  107. package/src/cli/customize-doctor.ts +49 -39
  108. package/src/cli/fast-help.ts +0 -2
  109. package/src/cli/migrate-cli.ts +14 -3
  110. package/src/cli/public-command-errors.ts +2 -0
  111. package/src/cli/root-flags.ts +0 -2
  112. package/src/cli/update-cli.ts +247 -6
  113. package/src/commands/harness.ts +25 -15
  114. package/src/commands/migrate.ts +3 -3
  115. package/src/commands/sdk.ts +81 -11
  116. package/src/commit/agentic/agent.ts +6 -6
  117. package/src/commit/analysis/conventional.ts +14 -5
  118. package/src/commit/map-reduce/index.ts +7 -7
  119. package/src/commit/pipeline.ts +12 -12
  120. package/src/config/autorouting-tier-map.ts +20 -1
  121. package/src/config/config-file.ts +106 -3
  122. package/src/config/config-hot-reload.ts +8 -0
  123. package/src/config/file-lock.ts +1431 -304
  124. package/src/config/keybindings.ts +154 -7
  125. package/src/config/model-profile-activation.ts +177 -39
  126. package/src/config/model-registry.ts +380 -55
  127. package/src/config/model-resolver.ts +49 -21
  128. package/src/config/settings-schema.ts +13 -1
  129. package/src/config/settings.ts +2 -0
  130. package/src/coordinator-mcp/server.ts +37 -2
  131. package/src/crash/sanitize.ts +3 -1
  132. package/src/customization/import.ts +276 -7
  133. package/src/customization/inventory.ts +24 -8
  134. package/src/customization/types.ts +1 -0
  135. package/src/dap/client.ts +3 -2
  136. package/src/dap/session.ts +161 -36
  137. package/src/debug/crash-diagnostics.ts +87 -5
  138. package/src/debug/report-bundle.ts +71 -2
  139. package/src/discovery/builtin.ts +4 -3
  140. package/src/discovery/helpers.ts +24 -14
  141. package/src/edit/index.ts +191 -5
  142. package/src/edit/modes/patch.ts +27 -0
  143. package/src/eval/py/runtime.ts +44 -4
  144. package/src/exec/non-interactive-env.ts +0 -1
  145. package/src/export/html/index.ts +60 -20
  146. package/src/export/ttsr.ts +176 -1
  147. package/src/extensibility/extensions/loader.ts +2 -0
  148. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  149. package/src/extensibility/extensions/types.ts +5 -1
  150. package/src/extensibility/hooks/loader.ts +10 -2
  151. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  152. package/src/extensibility/slash-commands.ts +5 -0
  153. package/src/gjc-runtime/gc-runtime.ts +4 -1
  154. package/src/gjc-runtime/launch-tmux.ts +23 -13
  155. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  156. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  157. package/src/gjc-runtime/state-writer.ts +109 -5
  158. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  159. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  160. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  161. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  162. package/src/harness-control-plane/finalize.ts +11 -0
  163. package/src/harness-control-plane/receipt-spool.ts +105 -28
  164. package/src/harness-control-plane/storage.ts +124 -6
  165. package/src/hindsight/content.ts +116 -16
  166. package/src/hindsight/mental-models.ts +22 -10
  167. package/src/hindsight/state.ts +7 -4
  168. package/src/hooks/native-skill-hook.ts +3 -1
  169. package/src/internal-urls/artifact-protocol.ts +3 -1
  170. package/src/internal-urls/docs-index.generated.ts +14 -14
  171. package/src/internal-urls/local-protocol.ts +28 -3
  172. package/src/internal-urls/types.ts +2 -0
  173. package/src/lsp/client.ts +2 -1
  174. package/src/lsp/clients/biome-client.ts +2 -0
  175. package/src/lsp/clients/swiftlint-client.ts +2 -0
  176. package/src/main.ts +330 -92
  177. package/src/memories/index.ts +25 -2
  178. package/src/migrate/action-planner.ts +517 -2
  179. package/src/migrate/adapters/claude-code.ts +141 -3
  180. package/src/migrate/adapters/codex.ts +57 -5
  181. package/src/migrate/adapters/index.ts +240 -5
  182. package/src/migrate/adapters/opencode.ts +4 -0
  183. package/src/migrate/executor.ts +141 -3
  184. package/src/migrate/hook-module.ts +193 -0
  185. package/src/migrate/report.ts +45 -1
  186. package/src/migrate/types.ts +73 -3
  187. package/src/modes/acp/acp-agent.ts +10 -1
  188. package/src/modes/components/agent-dashboard.ts +20 -4
  189. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  190. package/src/modes/components/customization/import-wizard.ts +29 -3
  191. package/src/modes/components/model-selector.ts +157 -103
  192. package/src/modes/components/welcome.ts +3 -1
  193. package/src/modes/controllers/command-controller.ts +271 -16
  194. package/src/modes/controllers/event-controller.ts +1 -1
  195. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  196. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  197. package/src/modes/controllers/input-controller.ts +2 -0
  198. package/src/modes/controllers/plan-mode-controller.ts +49 -4
  199. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  200. package/src/modes/controllers/selector-controller.ts +14 -1
  201. package/src/modes/interactive-mode.ts +55 -4
  202. package/src/modes/prompt-suggestion-controller.ts +1 -1
  203. package/src/modes/runtime-init.ts +5 -1
  204. package/src/modes/utils/host-status.ts +21 -10
  205. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  206. package/src/prompts/system/irc-incoming.md +3 -1
  207. package/src/prompts/tools/irc.md +1 -1
  208. package/src/runtime-mcp/manager.ts +8 -3
  209. package/src/runtime-mcp/project-host-values.ts +12 -8
  210. package/src/runtime-mcp/tool-bridge.ts +68 -4
  211. package/src/runtime-mcp/transports/stdio.ts +2 -1
  212. package/src/sdk/broker/broker-exit.ts +2 -0
  213. package/src/sdk/broker/broker.ts +143 -4
  214. package/src/sdk/broker/ensure.ts +74 -6
  215. package/src/sdk/broker/hop.ts +13 -1
  216. package/src/sdk/broker/lifecycle-ledger.ts +3 -0
  217. package/src/sdk/broker/lifecycle.ts +102 -6
  218. package/src/sdk/broker/session-index.ts +29 -4
  219. package/src/sdk/broker/transport.ts +17 -1
  220. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  221. package/src/sdk/bus/index.ts +408 -14
  222. package/src/sdk/bus/session-control.ts +2 -0
  223. package/src/sdk/bus/slack-daemon.ts +105 -9
  224. package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
  225. package/src/sdk/bus/telegram-daemon.ts +10 -2
  226. package/src/sdk/bus/threaded-render.ts +20 -1
  227. package/src/sdk/cli/session-cli.ts +2 -0
  228. package/src/sdk/host/control/dispatch.ts +13 -1
  229. package/src/sdk/host/control/operations.ts +10 -1
  230. package/src/sdk/host/events.ts +67 -4
  231. package/src/sdk/host/host.ts +8 -2
  232. package/src/sdk/host/prompt-image-upload.ts +438 -0
  233. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  234. package/src/sdk/host/session-runtime.test.ts +1537 -24
  235. package/src/sdk/host/session-runtime.ts +332 -36
  236. package/src/sdk/host/surface-policy.ts +4 -0
  237. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  238. package/src/sdk/host/websocket-transport.ts +19 -7
  239. package/src/sdk/lifecycle/service.ts +14 -1
  240. package/src/sdk/prompt-status.ts +6 -4
  241. package/src/sdk/protocol/adapter-validation.ts +28 -1
  242. package/src/sdk/protocol/operation-inventory.generated.json +160 -0
  243. package/src/sdk/protocol/operation-registry.ts +33 -3
  244. package/src/sdk/session.ts +123 -36
  245. package/src/session/agent-session.ts +1614 -329
  246. package/src/session/history-storage.ts +182 -12
  247. package/src/session/internal/managed-session-scope.ts +47 -18
  248. package/src/session/internal/managed-session-storage.ts +71 -1
  249. package/src/session/session-manager.ts +90 -5
  250. package/src/session-import/redact.ts +53 -2
  251. package/src/skill-state/workflow-mutation-guard.ts +1498 -18
  252. package/src/slash-commands/builtin-registry.ts +6 -3
  253. package/src/slash-commands/helpers/mcp.ts +11 -3
  254. package/src/ssh/ssh-executor.ts +9 -2
  255. package/src/stt/recorder.ts +26 -9
  256. package/src/system-prompt.ts +61 -16
  257. package/src/task/executor.ts +12 -8
  258. package/src/task/role-body.ts +84 -0
  259. package/src/task/token-log-mode.test.ts +162 -0
  260. package/src/task/token-log.ts +91 -2
  261. package/src/tools/archive-reader.ts +650 -37
  262. package/src/tools/ask.ts +1 -0
  263. package/src/tools/ast-grep.ts +7 -2
  264. package/src/tools/auto-generated-guard.ts +12 -3
  265. package/src/tools/awaiting-input.ts +114 -0
  266. package/src/tools/bash-allowed-prefixes.ts +61 -1
  267. package/src/tools/bash-skill-urls.ts +58 -5
  268. package/src/tools/bash.ts +99 -26
  269. package/src/tools/browser/tab-worker.ts +39 -13
  270. package/src/tools/browser.ts +17 -7
  271. package/src/tools/cron.ts +10 -1
  272. package/src/tools/debug.ts +9 -2
  273. package/src/tools/descriptors.test.ts +4 -4
  274. package/src/tools/descriptors.ts +16 -4
  275. package/src/tools/eval.ts +15 -0
  276. package/src/tools/fetch.ts +90 -2
  277. package/src/tools/gh.ts +27 -12
  278. package/src/tools/image-gen.ts +20 -6
  279. package/src/tools/irc.ts +5 -3
  280. package/src/tools/monitor.ts +9 -1
  281. package/src/tools/output-meta.ts +51 -3
  282. package/src/tools/read.ts +9 -2
  283. package/src/tools/recipe/runner.ts +23 -12
  284. package/src/tools/render-utils.ts +2 -2
  285. package/src/tools/search.ts +3 -2
  286. package/src/tools/ssh.ts +274 -8
  287. package/src/tools/subagent.ts +10 -4
  288. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  289. package/src/tools/tool-catalog.generated.ts +1 -1
  290. package/src/tools/ultragoal-ask-guard.ts +13 -1
  291. package/src/tools/write.ts +7 -0
  292. package/src/tui/tree-list.ts +15 -1
  293. package/src/utils/commit-message-generator.ts +3 -2
  294. package/src/utils/external-editor.ts +21 -7
  295. package/src/utils/file-mentions.ts +46 -6
  296. package/src/utils/git.ts +38 -7
  297. package/src/utils/image-limits.ts +8 -0
  298. package/src/utils/image-loading.ts +1 -1
  299. package/src/utils/open.ts +73 -4
  300. package/src/utils/pasted-image-loading.ts +9 -8
  301. package/src/utils/prompt-suggestion.ts +5 -4
  302. package/src/utils/shell-snapshot.ts +9 -1
  303. package/src/utils/title-generator.ts +65 -20
  304. package/src/web/search/index.ts +2 -1
  305. package/src/web/search/providers/codex.ts +29 -32
@@ -117,7 +117,7 @@ import {
117
117
  } from "./managed-task-dag";
118
118
  import { reconcileRunningManagedVerification, verifyManagedTaskAttempt } from "./managed-task-verification";
119
119
  import { createMasterCapabilityVerifier, readEndpoint } from "./master-capability";
120
- import { isProcessIncarnation } from "./process-incarnation";
120
+ import { isProcessIncarnation, observeProcessIncarnation } from "./process-incarnation";
121
121
  import { sdkInternalRuntimeImage } from "./runtime";
122
122
  import {
123
123
  type IndexedSession,
@@ -165,6 +165,21 @@ export interface BrokerSettings {
165
165
  masterCapabilityVerifier?: MasterCapabilityVerifier;
166
166
  /** Grace before an orphaned spawn child closes; schema-bounded in production. */
167
167
  masterOrphanGraceMs?: number;
168
+ /**
169
+ * Grace before a broker nobody is left to use exits on its own. The clock only
170
+ * runs while the index proves no live session host and no open spawn authority,
171
+ * so a broker whose client died without closing exits instead of holding its
172
+ * port, its publication, and its lock for the life of the machine.
173
+ */
174
+ abandonedGraceMs?: number;
175
+ /**
176
+ * Whether this broker may end itself once nobody is left to use it. Only the
177
+ * detached autostart entry point enables this: an operator-supervised
178
+ * `gjc sdk broker run` service owns its own lifetime, and its supervisor reads an
179
+ * owned-root completion as a clean exit, so self-reaping would quietly retire a
180
+ * service that was meant to keep serving.
181
+ */
182
+ reapWhenAbandoned?: boolean;
168
183
  /** Host model resolver override for lifecycle tests and embedders. */
169
184
  resolveModelPin?: SdkHostModelResolver;
170
185
  /**
@@ -202,6 +217,8 @@ type ResolvedBrokerSettings = {
202
217
  spawnSubstrateProvider?: SpawnSubstrateProvider;
203
218
  spawnPromptLayer?: SpawnPromptLayer;
204
219
  masterOrphanGraceMs: number;
220
+ abandonedGraceMs: number;
221
+ reapWhenAbandoned: boolean;
205
222
  restartRequestId?: string;
206
223
  };
207
224
  export function resolveBrokerPackageGeneration(): string {
@@ -227,6 +244,7 @@ function modelResolutionCwd(input: Record<string, unknown>): string | undefined
227
244
 
228
245
  export type BrokerErrorCode =
229
246
  | "idempotency_conflict"
247
+ | "legacy_lifecycle_ambiguous"
230
248
  | "terminal_uncertain"
231
249
  | "broker_restarting"
232
250
  | "unavailable"
@@ -623,6 +641,7 @@ const SPAWN_HOST_REGISTRATION_TIMEOUT_MS = 10_000;
623
641
  const SPAWN_HOST_REGISTRATION_POLL_MS = 50;
624
642
  const SPAWN_PROMPT_EXCHANGE_TIMEOUT_MS = 10_000;
625
643
  const MASTER_ORPHAN_GRACE_DEFAULT_MS = 120_000;
644
+ const BROKER_ABANDONED_GRACE_DEFAULT_MS = 120_000;
626
645
  const MANAGED_CLOSE_WAIT_MS = 10_000;
627
646
 
628
647
  function settlesWithin(promise: Promise<unknown>, timeoutMs: number): Promise<boolean> {
@@ -1602,6 +1621,7 @@ const brokerLockReclaimHooksForTest = new WeakMap<
1602
1621
  { beforeQuarantine?: () => Promise<void>; onContended?: () => void }
1603
1622
  >();
1604
1623
  const livenessGraceOverridesForTest = new WeakMap<Broker, number>();
1624
+ const abandonedGraceOverridesForTest = new WeakMap<Broker, number>();
1605
1625
  const heartbeatStallOverridesForTest = new WeakMap<Broker, PromiseWithResolvers<void>>();
1606
1626
  const managedCloseWaitOverridesForTest = new WeakMap<Broker, number>();
1607
1627
 
@@ -1644,6 +1664,8 @@ export class Broker {
1644
1664
  #publishedAt: bigint | null = null;
1645
1665
  #startedAt: bigint | null = null;
1646
1666
  #watchInFlight = false;
1667
+ #abandonedAt: number | null = null;
1668
+ #abandonedReapInFlight = false;
1647
1669
  #checkpointInFlight = false;
1648
1670
  #stopping = false;
1649
1671
  #transport: BrokerTransport | null = null;
@@ -1683,6 +1705,8 @@ export class Broker {
1683
1705
  spawnSubstrateProvider: settings.spawnSubstrateProvider,
1684
1706
  spawnPromptLayer: settings.spawnPromptLayer,
1685
1707
  masterOrphanGraceMs: settings.masterOrphanGraceMs ?? MASTER_ORPHAN_GRACE_DEFAULT_MS,
1708
+ abandonedGraceMs: settings.abandonedGraceMs ?? BROKER_ABANDONED_GRACE_DEFAULT_MS,
1709
+ reapWhenAbandoned: settings.reapWhenAbandoned === true,
1686
1710
  ...(settings.restartRequestId === undefined ? {} : { restartRequestId: settings.restartRequestId }),
1687
1711
  };
1688
1712
  this.index = new SessionIndex(settings.agentDir);
@@ -3656,6 +3680,96 @@ export class Broker {
3656
3680
  await this.#reapSpawnOrphans();
3657
3681
  }
3658
3682
 
3683
+ /**
3684
+ * Grace before an idle broker exits. The clock starts only after the index has
3685
+ * already stopped proving any host live, so this grace is time on top of that
3686
+ * freshness window, never a substitute for it.
3687
+ */
3688
+ #abandonedGraceMs(): number {
3689
+ return abandonedGraceOverridesForTest.get(this) ?? this.settings.abandonedGraceMs;
3690
+ }
3691
+
3692
+ /**
3693
+ * What the index proves about the hosts using this agent directory. Liveness is
3694
+ * judged on read -- fresh checkpoint, live pid, matching incarnation -- so a host
3695
+ * that died without closing stops proving it without help. A row that is not live
3696
+ * is absence only once the OS confirms the process is gone: an inconclusive
3697
+ * reading is never death proof on any platform, and a pid that still exists but
3698
+ * cannot be matched to the recorded incarnation leaves the host undecided.
3699
+ */
3700
+ async #sessionHostLiveness(): Promise<"live" | "absent" | "unknown"> {
3701
+ try {
3702
+ await this.index.refreshIfChanged();
3703
+ } catch (error) {
3704
+ logger.warn(`sdk broker: abandonment probe could not read the session index: ${String(error)}`);
3705
+ return "unknown";
3706
+ }
3707
+ for (const row of this.index.listSessionIdentities()) {
3708
+ if (row.endpointGeneration <= 0) continue;
3709
+ if (row.live) return "live";
3710
+ // The tri-state observer keeps positive absence separate from every
3711
+ // inconclusive outcome, which is the difference between reaping a broker and
3712
+ // killing one out from under a host this platform simply cannot confirm.
3713
+ const observed = observeProcessIncarnation(row.pid);
3714
+ if (observed.status === "unknown") return "unknown";
3715
+ if (observed.status === "absent") continue;
3716
+ const recorded = row.hostIncarnation ?? row.processIncarnation;
3717
+ if (recorded === undefined) return "unknown";
3718
+ if (observed.incarnation === recorded) return "live";
3719
+ // The pid names another process now: the host that registered it is gone.
3720
+ }
3721
+ return "absent";
3722
+ }
3723
+
3724
+ /** Whether any client currently holds a transport connection to this broker. */
3725
+ #hasAttachedClient(): boolean {
3726
+ return (this.#transport?.clientCount ?? 0) > 0;
3727
+ }
3728
+
3729
+ /** Whether this broker still owns a spawn authority it must keep serving. */
3730
+ #hasOpenSpawnAuthority(): boolean {
3731
+ const store = this.#spawnAuthority;
3732
+ if (!store) return false;
3733
+ for (const claim of store.claims()) {
3734
+ const authority = store.authority(claim.lifecycleIdentity);
3735
+ if (authority && (authority.closeState === "active" || authority.closeState === "close_requested"))
3736
+ return true;
3737
+ }
3738
+ return false;
3739
+ }
3740
+
3741
+ /**
3742
+ * Ends a broker nobody is left to use. A client that dies without closing leaves
3743
+ * no live session host behind, and nothing else reaps this process: without this
3744
+ * pass it would hold its port, its publication, and its lock until the machine
3745
+ * reboots. The clock only runs while absence is confirmed and no client holds a
3746
+ * connection, and any live host, attached client, open authority, or undecided
3747
+ * reading disarms it, so a slow reattach never races the grace. Brokers an
3748
+ * operator supervises never reap themselves at all.
3749
+ */
3750
+ async #reapSelfIfAbandoned(): Promise<void> {
3751
+ if (!this.settings.reapWhenAbandoned) return;
3752
+ if (this.#stopping || this.#completionTask !== null || this.#fenceReason !== null) return;
3753
+ if (this.#abandonedReapInFlight) return;
3754
+ this.#abandonedReapInFlight = true;
3755
+ try {
3756
+ const liveness = await this.#sessionHostLiveness();
3757
+ const now = Date.now();
3758
+ if (liveness !== "absent" || this.#hasAttachedClient() || this.#hasOpenSpawnAuthority()) {
3759
+ this.#abandonedAt = null;
3760
+ return;
3761
+ }
3762
+ if (this.#abandonedAt === null) {
3763
+ this.#abandonedAt = now;
3764
+ return;
3765
+ }
3766
+ if (now - this.#abandonedAt < this.#abandonedGraceMs()) return;
3767
+ await this.#complete(this.#provenOwnedRoot() ? "owned-root" : "lost-root", "abandoned");
3768
+ } finally {
3769
+ this.#abandonedReapInFlight = false;
3770
+ }
3771
+ }
3772
+
3659
3773
  async #reapSpawnOrphans(): Promise<void> {
3660
3774
  if (this.#spawnReapInFlight || this.#stopping) return;
3661
3775
  this.#spawnReapInFlight = true;
@@ -4005,6 +4119,11 @@ export class Broker {
4005
4119
  this.#ownedLockIncarnation = undefined;
4006
4120
  this.#watchInFlight = false;
4007
4121
  this.#checkpointInFlight = false;
4122
+ // A restarted instance is a new generation: it holds none of the state the
4123
+ // previous one stopped with, so the abandonment clock it armed (and any reap
4124
+ // still unwinding) must not survive into the generation clients reconnect to.
4125
+ this.#abandonedAt = null;
4126
+ this.#abandonedReapInFlight = false;
4008
4127
  this.#throwIfStartupAborted();
4009
4128
  await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
4010
4129
  this.#throwIfStartupAborted();
@@ -4178,6 +4297,7 @@ export class Broker {
4178
4297
  this.#heartbeatTimer = setInterval(() => {
4179
4298
  void this.#watchPublication();
4180
4299
  void this.#reapSpawnOrphans();
4300
+ void this.#reapSelfIfAbandoned();
4181
4301
  }, cadenceMs);
4182
4302
  if (this.#startupPostPublicationDelayMs > 0) await Bun.sleep(this.#startupPostPublicationDelayMs);
4183
4303
  // This process is now the independently verified successor: its own
@@ -4227,6 +4347,10 @@ export class Broker {
4227
4347
  this.discovery?.ownerId === this.#owner
4228
4348
  );
4229
4349
  }
4350
+ /** Whether this broker completed its initial retained discovery publication. */
4351
+ get hasPublishedDiscovery(): boolean {
4352
+ return this.#publishedAt !== null;
4353
+ }
4230
4354
  get completion(): Promise<void> {
4231
4355
  return this.#completion;
4232
4356
  }
@@ -5348,7 +5472,7 @@ export class Broker {
5348
5472
  },
5349
5473
  )
5350
5474
  ) {
5351
- return error("idempotency_conflict", "legacy lifecycle request has an ambiguous target");
5475
+ return error("legacy_lifecycle_ambiguous", "legacy lifecycle request has an ambiguous target");
5352
5476
  }
5353
5477
  }
5354
5478
  }
@@ -5424,6 +5548,9 @@ export class Broker {
5424
5548
  const begun = await this.ledger.begin(identity, requestHash, metadata);
5425
5549
  if (begun.kind === "replay") {
5426
5550
  const replay = begun.entry.response as BrokerResponse;
5551
+ // Proven errors are immutable even when their response carries cleanup
5552
+ // metadata. Only unproven cleanup outcomes may execute again.
5553
+ if (begun.entry.state === "terminal_error") return replay;
5427
5554
  const cleanup = cleanupFromResponse(replay) ?? reconstructedDeleteCleanup;
5428
5555
  if (!cleanup) {
5429
5556
  if (
@@ -5468,7 +5595,7 @@ export class Broker {
5468
5595
  const outcome = await executeLifecycle(this, operation, input, identity, cleanup);
5469
5596
  const response = outcome.response;
5470
5597
  const storedResponse = credentialFreeLifecycleResponse(response) as BrokerResponse;
5471
- await this.ledger.transition(identity, lifecycleResponseState(response), {
5598
+ const recorded = await this.ledger.transition(identity, lifecycleResponseState(response), {
5472
5599
  ...(operation === "session.delete" &&
5473
5600
  typeof input.sessionId === "string" &&
5474
5601
  lifecycleResponseState(response) === "terminal_uncertain" &&
@@ -5480,6 +5607,10 @@ export class Broker {
5480
5607
  ...(outcome.durableEffects ? { durableEffects: outcome.durableEffects } : {}),
5481
5608
  ...(outcome.startupFailure ? { startupFailure: outcome.startupFailure } : {}),
5482
5609
  });
5610
+ // A late transition may have proven a terminal result already. The ledger
5611
+ // preserves that row; return it instead of the freshly computed response.
5612
+ if (recorded.state === "terminal_error" && recorded.response !== undefined)
5613
+ return recorded.response as BrokerResponse;
5483
5614
  return response;
5484
5615
  }
5485
5616
  if (begun.kind === "idempotency_conflict")
@@ -5492,7 +5623,7 @@ export class Broker {
5492
5623
  const outcome = await executeLifecycle(this, operation, input, identity, cleanup);
5493
5624
  const response = outcome.response;
5494
5625
  const storedResponse = credentialFreeLifecycleResponse(response) as BrokerResponse;
5495
- await this.ledger.transition(identity, lifecycleResponseState(response), {
5626
+ const recorded = await this.ledger.transition(identity, lifecycleResponseState(response), {
5496
5627
  ...(pendingCleanupSessionId(response) ? { intendedSessionId: pendingCleanupSessionId(response) } : {}),
5497
5628
  ...(operation === "session.delete" &&
5498
5629
  typeof input.sessionId === "string" &&
@@ -5505,6 +5636,8 @@ export class Broker {
5505
5636
  ...(outcome.durableEffects ? { durableEffects: outcome.durableEffects } : {}),
5506
5637
  ...(outcome.startupFailure ? { startupFailure: outcome.startupFailure } : {}),
5507
5638
  });
5639
+ if (recorded.state === "terminal_error" && recorded.response !== undefined)
5640
+ return recorded.response as BrokerResponse;
5508
5641
  return response;
5509
5642
  }
5510
5643
  if (begun.kind === "in_progress") return error("broker_restarting", "lifecycle operation is in progress");
@@ -5601,6 +5734,12 @@ export function setBrokerLockReclaimHooksForTest(
5601
5734
  else brokerLockReclaimHooksForTest.set(broker, hooks);
5602
5735
  }
5603
5736
 
5737
+ /** Test-only hook for shortening the bounded abandonment grace. */
5738
+ export function setAbandonedGraceForTest(broker: Broker, graceMs: number | undefined): void {
5739
+ if (graceMs === undefined) abandonedGraceOverridesForTest.delete(broker);
5740
+ else abandonedGraceOverridesForTest.set(broker, graceMs);
5741
+ }
5742
+
5604
5743
  /** Test-only hook for shortening the bounded liveness deadline. */
5605
5744
  export function setLivenessGraceForTest(broker: Broker, graceMs: number | undefined): void {
5606
5745
  if (graceMs === undefined) livenessGraceOverridesForTest.delete(broker);
@@ -4,6 +4,7 @@ import type { FileHandle } from "node:fs/promises";
4
4
  import * as fs from "node:fs/promises";
5
5
  import * as os from "node:os";
6
6
  import path from "node:path";
7
+ import { type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
7
8
  import { nativeProcessBindings } from "@gajae-code/utils/native-process";
8
9
  import packageJson from "../../../package.json" with { type: "json" };
9
10
  import {
@@ -12,6 +13,7 @@ import {
12
13
  type FileLockOptions,
13
14
  withFileLock,
14
15
  } from "../../config/file-lock";
16
+ import { trustedCoordinatorEnv } from "../../gjc-runtime/sidecar-bootstrap-env";
15
17
  import { SdkClient, SdkClientError } from "../client/client";
16
18
  import { type BrokerStartupExitRecord, clearBrokerStartupExitRecord, readBrokerStartupExitRecord } from "./broker-exit";
17
19
  import {
@@ -726,13 +728,64 @@ export interface BrokerStartupLockOptions {
726
728
  onContended?: () => void;
727
729
  }
728
730
 
731
+ const BROKER_TEST_SIGNAL_DIR_ENV = "GJC_SDK_TEST_BROKER_SIGNAL_DIR";
732
+ /** Bound the project dotenv read. A FIFO or a larger file is incomplete and rejected. */
733
+ const BROKER_SIGNAL_DOTENV_MAX_BYTES = 64 * 1024;
734
+
735
+ /**
736
+ * Dotenv from the cwd that first loaded this module. `launch --worktree` changes
737
+ * cwd after Bun has already copied that file into `process.env`.
738
+ */
739
+ const startupBrokerSignalSnapshot = projectEnvSnapshot(process.cwd(), {
740
+ maxFileBytes: BROKER_SIGNAL_DOTENV_MAX_BYTES,
741
+ });
742
+
743
+ function brokerSignalSnapshots(override?: readonly ProjectEnvSnapshot[]): readonly ProjectEnvSnapshot[] {
744
+ if (override) return override;
745
+ return [
746
+ projectEnvSnapshot(process.cwd(), { maxFileBytes: BROKER_SIGNAL_DOTENV_MAX_BYTES }),
747
+ startupBrokerSignalSnapshot,
748
+ ];
749
+ }
750
+
751
+ /**
752
+ * Operator environment only. A directory declared by the project dotenv is
753
+ * ignored so startup cannot create a path the repository chose.
754
+ * No signal value means no later dotenv read, so an unused seam cannot stall startup.
755
+ * An incomplete startup or cwd snapshot rejects the value.
756
+ */
757
+ function trustedBrokerTestSignalDirectory(
758
+ env: NodeJS.ProcessEnv,
759
+ snapshots?: readonly ProjectEnvSnapshot[],
760
+ ): string | undefined {
761
+ const raw = env[BROKER_TEST_SIGNAL_DIR_ENV];
762
+ if (!raw) return undefined;
763
+ let accepted: string | undefined;
764
+ for (const snapshot of brokerSignalSnapshots(snapshots)) {
765
+ if (snapshot.incomplete) return undefined;
766
+ const trusted = trustedCoordinatorEnv(BROKER_TEST_SIGNAL_DIR_ENV, env, snapshot);
767
+ if (trusted === undefined) return undefined;
768
+ accepted = trusted;
769
+ }
770
+ return accepted;
771
+ }
772
+
773
+ /** Drop a project-dotenv signal directory. Keep a trusted directory as the value that was accepted. */
774
+ function scrubUntrustedBrokerTestSignalDirectory(environment: NodeJS.ProcessEnv): void {
775
+ const trusted = trustedBrokerTestSignalDirectory(environment);
776
+ for (const name of Object.keys(environment)) {
777
+ if (name.toUpperCase() === BROKER_TEST_SIGNAL_DIR_ENV) delete environment[name];
778
+ }
779
+ if (trusted !== undefined) environment[BROKER_TEST_SIGNAL_DIR_ENV] = trusted;
780
+ }
781
+
729
782
  /**
730
783
  * Test-only phase observation for detached broker startup regressions. The
731
- * signal is opt-in through the child environment and never affects broker
732
- * ownership or error handling when the test seam is absent.
784
+ * directory is opt-in through a trusted operator environment and never affects
785
+ * broker ownership or error handling when the seam is absent.
733
786
  */
734
787
  export async function emitBrokerStartupTestSignal(signal: string): Promise<void> {
735
- const directory = process.env.GJC_SDK_TEST_BROKER_SIGNAL_DIR;
788
+ const directory = trustedBrokerTestSignalDirectory(process.env);
736
789
  if (!directory || !/^[a-z0-9-]+$/.test(signal)) return;
737
790
  try {
738
791
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
@@ -1203,11 +1256,11 @@ function registerBrokerOwner(
1203
1256
  handoff = undefined;
1204
1257
  } else if (publishedDiscovery) {
1205
1258
  try {
1206
- await shutdownPublishedBrokerViaRpc(
1207
- agentDir,
1259
+ await shutdownBrokerViaRpc(
1208
1260
  publishedDiscovery,
1209
1261
  Date.now() +
1210
1262
  Math.max(BROKER_DISCOVERY_BUDGET.staleRetirementMs, timing.gracefulMs + timing.killVerifyMs),
1263
+ agentDir,
1211
1264
  );
1212
1265
  } catch (error) {
1213
1266
  if (process.platform === "darwin" && expectedBrokerPid !== child.pid) throw error;
@@ -1270,6 +1323,7 @@ function brokerSpawnEnvironment(command: SdkInternalSpawnCommand, override?: Nod
1270
1323
  delete environment.PI_COMPILED;
1271
1324
  delete environment.GJC_COMPILED;
1272
1325
  }
1326
+ scrubUntrustedBrokerTestSignalDirectory(environment);
1273
1327
  return environment;
1274
1328
  }
1275
1329
 
@@ -1343,9 +1397,15 @@ async function shutdownPublishedBrokerViaRpc(
1343
1397
  else await waitForBrokerIdentityGone(discovery, deadline);
1344
1398
  return;
1345
1399
  }
1400
+ await shutdownBrokerViaRpc(current, deadline, agentDir);
1401
+ }
1402
+
1403
+ /** Stop a broker whose authenticated discovery was retained by its local owner. */
1404
+ async function shutdownBrokerViaRpc(discovery: BrokerDiscovery, deadline: number, agentDir: string): Promise<void> {
1405
+ if (brokerIdentityIsGone(discovery)) return;
1346
1406
  const remainingMs = deadline - ensureBrokerTiming.now();
1347
1407
  if (remainingMs <= 0) throw new Error("SDK broker shutdown deadline elapsed before RPC connection.");
1348
- const client = await SdkClient.connect(current.url, current.token, {
1408
+ const client = await SdkClient.connect(discovery.url, discovery.token, {
1349
1409
  timeoutMs: Math.max(1, Math.min(2_000, remainingMs)),
1350
1410
  });
1351
1411
  try {
@@ -1954,6 +2014,14 @@ export function signalDetachedBrokerProcessForTest(
1954
2014
  ): boolean {
1955
2015
  return signalDetachedBrokerProcess(pid, brokerIncarnation, signal, platform);
1956
2016
  }
2017
+ /** Test hook: the signal directory accepted from this environment and snapshot. */
2018
+ export function trustedBrokerSignalDirectoryForTest(
2019
+ env: NodeJS.ProcessEnv,
2020
+ snapshot: ProjectEnvSnapshot | readonly ProjectEnvSnapshot[],
2021
+ ): string | undefined {
2022
+ const snapshots = Array.isArray(snapshot) ? snapshot : [snapshot];
2023
+ return trustedBrokerTestSignalDirectory(env, snapshots);
2024
+ }
1957
2025
  /** Test hook: resolves the complete broker environment without spawning. */
1958
2026
  export function brokerSpawnEnvironmentForTest(
1959
2027
  command: SdkInternalSpawnCommand,
@@ -7,9 +7,18 @@ import type { Process } from "@gajae-code/natives";
7
7
  import { nativeProcessBindings } from "@gajae-code/utils/native-process";
8
8
  import type { BrokerHopMessage } from "./ensure";
9
9
 
10
+ /** Handoff protocol frames written to the trampoline's stdin. */
10
11
  export const BROKER_HANDOFF_ACKNOWLEDGEMENT = "GJC_BROKER_HANDOFF_ACK\n";
11
12
  export const BROKER_HANDOFF_COMMIT = "GJC_BROKER_HANDOFF_COMMIT\n";
12
13
  export const BROKER_HANDOFF_ABORT = "GJC_BROKER_HANDOFF_ABORT\n";
14
+ /**
15
+ * Backstop for a retained trampoline whose parent never sends a decision. It must
16
+ * outlive the parent's own broker discovery deadline (which is derived from the
17
+ * publication budget and can double across a guarded retry), otherwise the
18
+ * trampoline reaps the broker first and the parent misreports its own discovery
19
+ * timeout as an "exited before discovery" startup failure.
20
+ */
21
+ export const BROKER_HANDOFF_DECISION_TIMEOUT_MS = 120_000;
13
22
 
14
23
  /**
15
24
  * Windows broker hop: spawns the real broker with detached:true and reports its pid.
@@ -165,7 +174,10 @@ export function waitForBrokerHandoffAcknowledgement(input: stream.Readable): Pro
165
174
  }
166
175
 
167
176
  /** Wait for a validated identity ACK followed by the parent's publication decision. */
168
- export function waitForBrokerHandoffDecision(input: stream.Readable, timeoutMs = 30_000): Promise<"commit" | "abort"> {
177
+ export function waitForBrokerHandoffDecision(
178
+ input: stream.Readable,
179
+ timeoutMs = BROKER_HANDOFF_DECISION_TIMEOUT_MS,
180
+ ): Promise<"commit" | "abort"> {
169
181
  const decision = Promise.withResolvers<"commit" | "abort">();
170
182
  let received = "";
171
183
  let settled = false;
@@ -968,6 +968,9 @@ export class LifecycleLedger {
968
968
  return this.#mutate(async () => {
969
969
  const previous = this.#byIdentity.get(identity);
970
970
  if (!previous) throw new Error("Unknown lifecycle identity");
971
+ // Match replay validation: proven terminal records admit no successor.
972
+ // A late failure must not append a row that will be quarantined on reopen.
973
+ if (terminal(previous.state)) return previous;
971
974
  const next = {
972
975
  ...previous,
973
976
  ...fields,
@@ -951,6 +951,14 @@ type SessionLaunch = {
951
951
  modelId?: string;
952
952
  modelPreset?: string;
953
953
  mcpServers?: SessionLifecycleMcpServer[];
954
+ /**
955
+ * Per-session environment for the hosted session (#6735). Applied to the
956
+ * session host's spawn environment (and therefore inherited by its Bash
957
+ * executor) on top of the broker's own environment, after `unsetEnv` has
958
+ * removed inherited names. Broker-reserved `GJC_*` names are rejected.
959
+ */
960
+ env?: Record<string, string>;
961
+ unsetEnv?: string[];
954
962
  /** Coordinator namespace dir; broker computes the state file path from launch.id (#2549). */
955
963
  coordinatorStateDir?: string;
956
964
  coordinatorSessionId?: string;
@@ -963,6 +971,85 @@ type SessionLaunch = {
963
971
  };
964
972
 
965
973
  const CREDENTIAL_NAME_PATTERN = /(?:token|secret|password|credential|api[_-]?key|auth|cookie)/iu;
974
+ const SESSION_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
975
+ const SESSION_ENV_MAX_ENTRIES = 256;
976
+ const SESSION_ENV_MAX_VALUE_LENGTH = 131_072;
977
+
978
+ function isSessionEnvName(name: unknown): name is string {
979
+ return (
980
+ typeof name === "string" &&
981
+ name.length <= 256 &&
982
+ SESSION_ENV_NAME_PATTERN.test(name) &&
983
+ // GJC_* carries broker-issued launch authority (request, effect marker,
984
+ // state root, coordinator correlation); a caller must not shadow it.
985
+ !name.toUpperCase().startsWith("GJC_")
986
+ );
987
+ }
988
+
989
+ const sessionEnvKey = (name: string): string => (process.platform === "win32" ? name.toUpperCase() : name);
990
+
991
+ /**
992
+ * Validates the optional per-session `env`/`unsetEnv` pair (#6735). Returns
993
+ * null when either is malformed so the caller fails the launch closed.
994
+ */
995
+ export function lifecycleSessionEnv(input: {
996
+ env?: unknown;
997
+ unsetEnv?: unknown;
998
+ }): { env?: Record<string, string>; unsetEnv?: string[] } | null {
999
+ let env: Record<string, string> | undefined;
1000
+ if (input.env !== undefined) {
1001
+ if (input.env === null || typeof input.env !== "object" || Array.isArray(input.env)) return null;
1002
+ const entries = Object.entries(input.env as Record<string, unknown>);
1003
+ if (entries.length > SESSION_ENV_MAX_ENTRIES) return null;
1004
+ const seen = new Set<string>();
1005
+ for (const [name, value] of entries) {
1006
+ if (
1007
+ !isSessionEnvName(name) ||
1008
+ typeof value !== "string" ||
1009
+ value.length > SESSION_ENV_MAX_VALUE_LENGTH ||
1010
+ value.includes("\0") ||
1011
+ seen.has(sessionEnvKey(name))
1012
+ )
1013
+ return null;
1014
+ seen.add(sessionEnvKey(name));
1015
+ }
1016
+ env = Object.fromEntries(entries) as Record<string, string>;
1017
+ }
1018
+ let unsetEnv: string[] | undefined;
1019
+ if (input.unsetEnv !== undefined) {
1020
+ if (!Array.isArray(input.unsetEnv) || input.unsetEnv.length > SESSION_ENV_MAX_ENTRIES) return null;
1021
+ const keys = new Set<string>();
1022
+ for (const name of input.unsetEnv) {
1023
+ if (!isSessionEnvName(name) || keys.has(sessionEnvKey(name))) return null;
1024
+ keys.add(sessionEnvKey(name));
1025
+ }
1026
+ // Setting and unsetting the same name is contradictory, not a precedence question.
1027
+ if (env && Object.keys(env).some(name => keys.has(sessionEnvKey(name)))) return null;
1028
+ unsetEnv = [...(input.unsetEnv as string[])];
1029
+ }
1030
+ return { ...(env ? { env } : {}), ...(unsetEnv ? { unsetEnv } : {}) };
1031
+ }
1032
+
1033
+ /**
1034
+ * Composes the inherited part of a lifecycle session host's environment:
1035
+ * drops the process-local master capability, every `unsetEnv` name, and any
1036
+ * inherited name an explicit per-session `env` entry replaces (case-folded on
1037
+ * Windows so `Path` and `PATH` never both reach the child), then layers the
1038
+ * per-session `env` on top. Broker-issued `GJC_*` keys are applied after this.
1039
+ */
1040
+ export function lifecycleSessionHostBaseEnv(
1041
+ inherited: NodeJS.ProcessEnv | Readonly<Record<string, string | undefined>>,
1042
+ launch: { env?: Readonly<Record<string, string>>; unsetEnv?: readonly string[] },
1043
+ ): Record<string, string> {
1044
+ const removed = new Set([...(launch.unsetEnv ?? []), ...Object.keys(launch.env ?? {})].map(sessionEnvKey));
1045
+ // Valid names like `__proto__` must remain own environment entries.
1046
+ const base = Object.create(null) as Record<string, string>;
1047
+ for (const [name, value] of Object.entries(inherited)) {
1048
+ if (name === "GJC_MASTER_CAPABILITY" || typeof value !== "string" || removed.has(sessionEnvKey(name))) continue;
1049
+ base[name] = value;
1050
+ }
1051
+ return { ...base, ...launch.env };
1052
+ }
966
1053
 
967
1054
  function lifecycleLaunchKnownSecrets(launch: SessionLaunch): string[] {
968
1055
  const secrets = [
@@ -985,6 +1072,9 @@ function lifecycleLaunchKnownSecrets(launch: SessionLaunch): string[] {
985
1072
  .filter(([name, value]) => value && CREDENTIAL_NAME_PATTERN.test(name))
986
1073
  .map(([, value]) => value);
987
1074
  }),
1075
+ ...Object.entries(launch.env ?? {})
1076
+ .filter(([name, value]) => value && CREDENTIAL_NAME_PATTERN.test(name))
1077
+ .map(([, value]) => value),
988
1078
  ];
989
1079
  return [...new Set(secrets.filter(secret => typeof secret === "string" && secret.length > 0))];
990
1080
  }
@@ -5998,6 +6088,12 @@ async function launchInput(
5998
6088
  if (input.mcpServers !== undefined && !isSessionLifecycleMcpServers(input.mcpServers))
5999
6089
  return fail("invalid_input", "mcpServers must contain unique valid stdio, HTTP, or SSE server definitions.");
6000
6090
  const mcpServers = input.mcpServers as SessionLifecycleMcpServer[] | undefined;
6091
+ const sessionEnv = lifecycleSessionEnv(input);
6092
+ if (!sessionEnv)
6093
+ return fail(
6094
+ "invalid_input",
6095
+ "env must map valid non-GJC_ variable names to strings, and unsetEnv must list distinct valid names not also set in env.",
6096
+ );
6001
6097
 
6002
6098
  /**
6003
6099
  * Prepared readiness is an explicit creation-only intent. Only the two exact
@@ -6047,6 +6143,7 @@ async function launchInput(
6047
6143
  modelId,
6048
6144
  modelPreset,
6049
6145
  mcpServers,
6146
+ ...sessionEnv,
6050
6147
  worktree,
6051
6148
  worktreePlan,
6052
6149
  ...(readiness ? { readiness } : {}),
@@ -6070,6 +6167,7 @@ async function launchInput(
6070
6167
  sessionIdentity: saved.identity,
6071
6168
  modelPreset,
6072
6169
  mcpServers,
6170
+ ...sessionEnv,
6073
6171
  worktree,
6074
6172
  worktreePlan,
6075
6173
  ...(coordinatorStateDir ? { coordinatorStateDir } : {}),
@@ -6104,6 +6202,7 @@ async function launchInput(
6104
6202
  modelId,
6105
6203
  modelPreset,
6106
6204
  mcpServers,
6205
+ ...sessionEnv,
6107
6206
  worktree,
6108
6207
  worktreePlan,
6109
6208
  ...(coordinatorStateDir ? { coordinatorStateDir } : {}),
@@ -7160,12 +7259,9 @@ async function executeLifecycleResponse(
7160
7259
  stdio: ["ignore", "ignore", childStderrSink?.drainer.stdin ?? "ignore"],
7161
7260
  env: {
7162
7261
  // Master capability is process-local to direct Bash children and must
7163
- // never cross a broker lifecycle launch boundary.
7164
- ...Object.fromEntries(
7165
- Object.entries("kind" in cmd ? cmd.env : process.env).filter(
7166
- ([key]) => key !== "GJC_MASTER_CAPABILITY",
7167
- ),
7168
- ),
7262
+ // never cross a broker lifecycle launch boundary. Per-session env
7263
+ // (#6735) is layered here, below the broker-issued GJC_* keys.
7264
+ ...lifecycleSessionHostBaseEnv("kind" in cmd ? cmd.env : process.env, launch),
7169
7265
  GJC_AGENT_DIR: broker.settings.agentDir,
7170
7266
  GJC_CODING_AGENT_DIR: broker.settings.agentDir,
7171
7267
  GJC_SESSION_ID: launch.id,