@gajae-code/coding-agent 0.13.0 → 0.13.2

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 (119) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/config/file-lock.d.ts +2 -0
  3. package/dist/types/config/keybindings.d.ts +2 -2
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/models-config-schema.d.ts +6 -0
  6. package/dist/types/config/settings-schema.d.ts +17 -5
  7. package/dist/types/config/settings.d.ts +10 -1
  8. package/dist/types/edit/streaming.d.ts +5 -5
  9. package/dist/types/eval/py/kernel.d.ts +2 -2
  10. package/dist/types/eval/py/runtime.d.ts +5 -1
  11. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  12. package/dist/types/gjc-runtime/tmux-sessions.d.ts +3 -1
  13. package/dist/types/modes/components/model-selector.d.ts +3 -2
  14. package/dist/types/runtime/process-lifecycle.d.ts +12 -31
  15. package/dist/types/sdk/broker/identity.d.ts +2 -1
  16. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +17 -1
  17. package/dist/types/sdk/broker/lifecycle.d.ts +11 -6
  18. package/dist/types/sdk/broker/startup-failure.d.ts +32 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  20. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +4 -0
  22. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +10 -1
  23. package/dist/types/sdk/bus/reconciliation-store.d.ts +2 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +17 -1
  26. package/dist/types/sdk/host/query/cursor.d.ts +4 -0
  27. package/dist/types/sdk/host/query/handlers.d.ts +6 -0
  28. package/dist/types/sdk/host/session-runtime.d.ts +7 -6
  29. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  30. package/dist/types/sdk/index.d.ts +1 -0
  31. package/dist/types/sdk/turn-result.d.ts +37 -0
  32. package/dist/types/session/agent-session.d.ts +8 -11
  33. package/dist/types/session/blob-store.d.ts +6 -3
  34. package/dist/types/session/internal/managed-session-storage.d.ts +13 -0
  35. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +12 -3
  36. package/dist/types/task/executor.d.ts +4 -7
  37. package/dist/types/tools/index.d.ts +1 -1
  38. package/dist/types/tools/read.d.ts +1 -1
  39. package/dist/types/tools/search.d.ts +1 -1
  40. package/dist/types/utils/edit-mode.d.ts +69 -0
  41. package/package.json +8 -8
  42. package/scripts/generate-hotkeys-docs.ts +8 -6
  43. package/src/commands/sdk.ts +23 -2
  44. package/src/config/file-lock.ts +25 -5
  45. package/src/config/keybindings.ts +4 -2
  46. package/src/config/model-registry.ts +20 -0
  47. package/src/config/models-config-schema.ts +1 -0
  48. package/src/config/settings-schema.ts +29 -22
  49. package/src/config/settings.ts +42 -10
  50. package/src/coordinator-mcp/server.ts +87 -106
  51. package/src/edit/index.ts +5 -19
  52. package/src/edit/renderer.ts +3 -2
  53. package/src/edit/streaming.ts +28 -15
  54. package/src/eval/py/executor.ts +108 -10
  55. package/src/eval/py/kernel.ts +86 -12
  56. package/src/eval/py/runtime.ts +200 -16
  57. package/src/exec/bash-executor.ts +36 -37
  58. package/src/gjc-runtime/gc-runtime.ts +25 -3
  59. package/src/gjc-runtime/managed-owner-supervisor.ts +27 -1
  60. package/src/gjc-runtime/session-state-sidecar.ts +110 -4
  61. package/src/gjc-runtime/tmux-sessions.ts +34 -5
  62. package/src/internal-urls/docs-index.generated.ts +9 -9
  63. package/src/modes/acp/acp-agent.ts +10 -2
  64. package/src/modes/components/model-selector.ts +28 -8
  65. package/src/modes/controllers/plan-mode-controller.ts +3 -3
  66. package/src/modes/controllers/selector-controller.ts +4 -2
  67. package/src/modes/interactive-mode.ts +4 -0
  68. package/src/runtime/process-lifecycle.ts +126 -27
  69. package/src/runtime-mcp/transports/stdio.ts +11 -6
  70. package/src/sdk/acp/adapter.ts +18 -10
  71. package/src/sdk/broker/broker.ts +55 -4
  72. package/src/sdk/broker/ensure.ts +10 -7
  73. package/src/sdk/broker/identity.ts +10 -0
  74. package/src/sdk/broker/lifecycle-ledger.ts +56 -6
  75. package/src/sdk/broker/lifecycle.ts +53 -10
  76. package/src/sdk/broker/process-incarnation.ts +6 -1
  77. package/src/sdk/broker/session-index.ts +28 -11
  78. package/src/sdk/broker/startup-failure.ts +101 -0
  79. package/src/sdk/broker/transport.ts +1 -0
  80. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  81. package/src/sdk/bus/chat-daemon-runtime.ts +8 -1
  82. package/src/sdk/bus/conversation-store.ts +171 -50
  83. package/src/sdk/bus/index.ts +101 -47
  84. package/src/sdk/bus/kind-aware-reconciliation.ts +52 -2
  85. package/src/sdk/bus/notification-service.ts +3 -3
  86. package/src/sdk/bus/reconciliation-store.ts +16 -0
  87. package/src/sdk/bus/telegram-daemon-contract.ts +13 -2
  88. package/src/sdk/bus/telegram-daemon.ts +151 -41
  89. package/src/sdk/bus/topic-registry.ts +38 -9
  90. package/src/sdk/cli/session-cli.ts +20 -7
  91. package/src/sdk/client/discovery.ts +23 -2
  92. package/src/sdk/host/query/cursor.ts +4 -0
  93. package/src/sdk/host/query/handlers.ts +99 -100
  94. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  95. package/src/sdk/host/session-runtime.test.ts +98 -1
  96. package/src/sdk/host/session-runtime.ts +57 -15
  97. package/src/sdk/host/websocket-transport.lifecycle.test.ts +192 -2
  98. package/src/sdk/host/websocket-transport.ts +54 -30
  99. package/src/sdk/index.ts +1 -0
  100. package/src/sdk/mcp/server.ts +19 -6
  101. package/src/sdk/protocol/operation-inventory.generated.json +1 -1
  102. package/src/sdk/protocol/operation-registry.ts +8 -12
  103. package/src/sdk/session.ts +5 -14
  104. package/src/sdk/turn-result.ts +53 -0
  105. package/src/session/agent-session.ts +112 -69
  106. package/src/session/blob-store.ts +91 -21
  107. package/src/session/internal/managed-session-storage.ts +136 -21
  108. package/src/session/session-manager.ts +408 -81
  109. package/src/session/session-storage.ts +68 -10
  110. package/src/slash-commands/builtin-registry.ts +43 -5
  111. package/src/slash-commands/helpers/fast-status-report.ts +24 -8
  112. package/src/task/executor.ts +25 -8
  113. package/src/task/index.ts +5 -4
  114. package/src/tools/bash.ts +11 -9
  115. package/src/tools/index.ts +1 -1
  116. package/src/tools/read.ts +12 -11
  117. package/src/tools/search.ts +7 -7
  118. package/src/utils/edit-mode.ts +180 -9
  119. package/src/web/search/providers/insane.ts +55 -1
@@ -162,6 +162,7 @@ type BrokerSession = {
162
162
  sessionId: string;
163
163
  locator?: { repo?: string };
164
164
  live?: boolean;
165
+ terminal?: boolean;
165
166
  endpointGeneration?: number;
166
167
  endpointMtimeMs?: number;
167
168
  title?: string;
@@ -252,7 +253,11 @@ export function paginateAcpSessions(
252
253
  (value): value is BrokerSession & { locator: { repo: string } } =>
253
254
  typeof value?.sessionId === "string" && typeof value.locator?.repo === "string",
254
255
  )
255
- .filter(value => !cwd || value.locator.repo === cwd);
256
+ .filter(value => !cwd || value.locator.repo === cwd)
257
+ // DR-1 retains closed/unregistered rows in the broker index for inspect
258
+ // and offline tail; ACP's session/list advertises only loadable sessions,
259
+ // so terminal rows are excluded here (matching the pre-DR-1 wire shape).
260
+ .filter(value => value.terminal !== true);
256
261
  const sessions = filtered.slice(offset, offset + SESSION_PAGE_SIZE).map(value => {
257
262
  const metadata = sessionMetadata.get(value.sessionId);
258
263
  const updatedAt =
@@ -1245,7 +1250,10 @@ export class AcpAgent implements Agent {
1245
1250
  if (
1246
1251
  typeof candidate?.sessionId !== "string" ||
1247
1252
  typeof candidate.locator?.repo !== "string" ||
1248
- path.resolve(candidate.locator.repo) !== path.resolve(params.cwd)
1253
+ path.resolve(candidate.locator.repo) !== path.resolve(params.cwd) ||
1254
+ // A DR-1 terminal row is not a loadable session: adopting its cwd
1255
+ // here would re-arm teardown authority for a host that is gone.
1256
+ candidate.terminal === true
1249
1257
  )
1250
1258
  continue;
1251
1259
  if (discovered.has(candidate.sessionId))
@@ -1,5 +1,5 @@
1
1
  import { ThinkingLevel } from "@gajae-code/agent-core";
2
- import { getSupportedEfforts, type Model, modelsAreEqual } from "@gajae-code/ai/core";
2
+ import { getSupportedEfforts, type Model, modelSupportsServiceTier, modelsAreEqual } from "@gajae-code/ai/core";
3
3
  import {
4
4
  Container,
5
5
  fuzzyFilter,
@@ -365,8 +365,8 @@ export class ModelSelectorComponent extends Container {
365
365
  #currentThinkingLevel?: ThinkingLevel;
366
366
  #activeModelProfile?: string;
367
367
  #configuredDefaultChain?: readonly string[];
368
- #isFastForProvider: (provider?: string) => boolean = () => false;
369
- #isFastForSubagentProvider: (provider?: string) => boolean = () => false;
368
+ #isFastForProvider: (provider?: string, supportsServiceTier?: boolean) => boolean = () => false;
369
+ #isFastForSubagentProvider: (provider?: string, supportsServiceTier?: boolean) => boolean = () => false;
370
370
  #isCurrentModelFastModeActive: () => boolean = () => false;
371
371
  #pendingActionItem?: ModelItem | CanonicalModelItem;
372
372
  #selectedActionIndex: number = 0;
@@ -374,6 +374,8 @@ export class ModelSelectorComponent extends Container {
374
374
  #selectedThinkingIndex: number = 0;
375
375
  #assignmentState: "idle" | "assigning" = "idle";
376
376
  #closeAfterAssignment = false;
377
+ #unsubscribeCatalogChanged: () => void = () => {};
378
+ #disposed = false;
377
379
 
378
380
  // Preset landing state
379
381
  #viewMode: ModelSelectorViewMode = "presets";
@@ -405,8 +407,8 @@ export class ModelSelectorComponent extends Container {
405
407
  temporaryOnly?: boolean;
406
408
  initialSearchInput?: string;
407
409
  sessionId?: string;
408
- isFastForProvider?: (provider?: string) => boolean;
409
- isFastForSubagentProvider?: (provider?: string) => boolean;
410
+ isFastForProvider?: (provider?: string, supportsServiceTier?: boolean) => boolean;
411
+ isFastForSubagentProvider?: (provider?: string, supportsServiceTier?: boolean) => boolean;
410
412
  isCurrentModelFastModeActive?: () => boolean;
411
413
  currentThinkingLevel?: ThinkingLevel;
412
414
  activeModelProfile?: string;
@@ -434,7 +436,10 @@ export class ModelSelectorComponent extends Container {
434
436
  // session's effective predicate so an auto-disabled provider shows no glyph.
435
437
  this.#isCurrentModelFastModeActive =
436
438
  options?.isCurrentModelFastModeActive ??
437
- (() => (this.#currentModel ? this.#isFastForProvider(this.#currentModel.provider) : false));
439
+ (() =>
440
+ this.#currentModel
441
+ ? this.#isFastForProvider(this.#currentModel.provider, modelSupportsServiceTier(this.#currentModel))
442
+ : false);
438
443
  const initialSearchInput = options?.initialSearchInput;
439
444
  this.#viewMode = this.#temporaryOnly || initialSearchInput || scopedModels.length > 0 ? "models" : "presets";
440
445
 
@@ -479,6 +484,13 @@ export class ModelSelectorComponent extends Container {
479
484
  // Add bottom border
480
485
  this.addChild(new DynamicBorder());
481
486
 
487
+ if (typeof this.#modelRegistry.onCatalogChanged === "function") {
488
+ this.#unsubscribeCatalogChanged = this.#modelRegistry.onCatalogChanged(() => {
489
+ if (this.#disposed) return;
490
+ if (this.#refreshCatalogView()) this.#tui.requestRender();
491
+ });
492
+ }
493
+
482
494
  // Load models and do initial render
483
495
  this.#loadModels().then(() => {
484
496
  this.#buildProviderTabs();
@@ -504,6 +516,13 @@ export class ModelSelectorComponent extends Container {
504
516
  });
505
517
  }
506
518
 
519
+ override dispose(): void {
520
+ if (this.#disposed) return;
521
+ this.#disposed = true;
522
+ this.#unsubscribeCatalogChanged();
523
+ super.dispose();
524
+ }
525
+
507
526
  #isActiveDefaultFallback(): boolean {
508
527
  if (!this.#currentModel) return false;
509
528
  const configuredChain =
@@ -1705,11 +1724,12 @@ export class ModelSelectorComponent extends Container {
1705
1724
  // other modelRoles rows show pure intent.
1706
1725
  const isSubagentRole = roleInfo.settingsPath === "task.agentModelOverrides";
1707
1726
  const isCurrentRow = this.#currentModel !== undefined && modelsAreEqual(this.#currentModel, item.model);
1727
+ const supportsServiceTier = modelSupportsServiceTier(assigned.model);
1708
1728
  const roleFast = isSubagentRole
1709
- ? this.#isFastForSubagentProvider(assigned.model.provider)
1729
+ ? this.#isFastForSubagentProvider(assigned.model.provider, supportsServiceTier)
1710
1730
  : isCurrentRow
1711
1731
  ? this.#isCurrentModelFastModeActive()
1712
- : this.#isFastForProvider(assigned.model.provider);
1732
+ : this.#isFastForProvider(assigned.model.provider, supportsServiceTier);
1713
1733
  if (roleFast && isCurrentRow && !isSubagentRole) {
1714
1734
  currentModelEffectiveGlyphRendered = true;
1715
1735
  }
@@ -145,7 +145,7 @@ export class PlanModeController {
145
145
  await this.ctx.session.abort({ timeoutMs: ABORT_TIMEOUT_MS });
146
146
  if (this.#previousTools?.length) await this.ctx.session.setActiveToolsByName(this.#previousTools);
147
147
  if (this.#providerSessionScope && !this.ctx.session.isStreaming) {
148
- if (this.ctx.session.restoreTemporaryProviderSessionScope(this.#providerSessionScope))
148
+ if (await this.ctx.session.restoreTemporaryProviderSessionScope(this.#providerSessionScope))
149
149
  this.#providerSessionScope = undefined;
150
150
  } else if (this.#previousModelState) {
151
151
  const previous = this.#previousModelState;
@@ -187,7 +187,7 @@ export class PlanModeController {
187
187
  });
188
188
  } catch (error) {
189
189
  if (this.#providerSessionScope)
190
- this.ctx.session.restoreTemporaryProviderSessionScope(this.#providerSessionScope);
190
+ await this.ctx.session.restoreTemporaryProviderSessionScope(this.#providerSessionScope);
191
191
  this.#providerSessionScope = undefined;
192
192
  this.ctx.showWarning(
193
193
  `Failed to switch model after streaming: ${error instanceof Error ? error.message : String(error)}`,
@@ -284,7 +284,7 @@ export class PlanModeController {
284
284
  });
285
285
  } catch (error) {
286
286
  if (this.#providerSessionScope)
287
- this.ctx.session.restoreTemporaryProviderSessionScope(this.#providerSessionScope);
287
+ await this.ctx.session.restoreTemporaryProviderSessionScope(this.#providerSessionScope);
288
288
  this.#providerSessionScope = undefined;
289
289
  this.ctx.showWarning(
290
290
  `Failed to switch to plan model for plan mode: ${error instanceof Error ? error.message : String(error)}`,
@@ -2476,8 +2476,10 @@ export class SelectorController {
2476
2476
  currentThinkingLevel: this.ctx.session.thinkingLevel,
2477
2477
  activeModelProfile:
2478
2478
  this.ctx.session.getActiveModelProfile?.() ?? this.ctx.settings.get("modelProfile.default"),
2479
- isFastForProvider: provider => this.ctx.session.isFastForProvider(provider),
2480
- isFastForSubagentProvider: provider => this.ctx.session.isFastForSubagentProvider(provider),
2479
+ isFastForProvider: (provider, supportsServiceTier) =>
2480
+ this.ctx.session.isFastForProvider(provider, supportsServiceTier),
2481
+ isFastForSubagentProvider: (provider, supportsServiceTier) =>
2482
+ this.ctx.session.isFastForSubagentProvider(provider, supportsServiceTier),
2481
2483
  isCurrentModelFastModeActive: () => this.ctx.session.isFastModeActive(),
2482
2484
  },
2483
2485
  );
@@ -1345,7 +1345,11 @@ export class InteractiveMode implements InteractiveModeContext {
1345
1345
  this.editorContainer.clear();
1346
1346
  this.editorContainer.addChild(this.editor);
1347
1347
  }
1348
+ // Re-mounting after a cancelled action must invalidate the editor so the
1349
+ // inline prompt suggestion is rendered again on the restored composer.
1350
+ this.editor.invalidate();
1348
1351
  this.ui.setFocus(this.editor);
1352
+ this.ui.requestRender();
1349
1353
  }
1350
1354
 
1351
1355
  #createPetWidget(editor: CustomEditor): GajaePetWidget {
@@ -28,7 +28,9 @@
28
28
  * drain it, so a chatty child whose stdout is never read can still block on a
29
29
  * full pipe. That draining is the adopter's responsibility.
30
30
  */
31
+ import * as fs from "node:fs";
31
32
  import { logger, postmortem, ptree } from "@gajae-code/utils";
33
+ import { type LinuxProcPidProbeResult, probeLinuxProcPidSync } from "../gjc-runtime/linux-proc";
32
34
 
33
35
  const DEFAULT_GRACEFUL_MS = 2_000;
34
36
  // Hard cap for how long `dispose()` waits after SIGKILL before giving up so a
@@ -69,6 +71,63 @@ function groupAlive(pgid: number): boolean {
69
71
  }
70
72
  }
71
73
 
74
+ /** Whether an unreadable `/proc` entry might still describe a running process. */
75
+ export function procEntryMayStillBeRunning(error: unknown): boolean {
76
+ const code = (error as NodeJS.ErrnoException).code;
77
+ return code !== "ENOENT" && code !== "ESRCH";
78
+ }
79
+
80
+ /** Whether a process-group leader still has the spawn-time identity we own. */
81
+ export function groupLeaderIdentityMatches(
82
+ expectedStartTime: string | undefined,
83
+ leader: LinuxProcPidProbeResult,
84
+ ): boolean {
85
+ // An absent leader cannot be a recycled leader. Its group may still contain
86
+ // descendants that we own, including when the root exits before we can read
87
+ // its start time immediately after spawn.
88
+ if (leader.kind === "absent") return true;
89
+ return expectedStartTime !== undefined && leader.kind === "live" && leader.startTime === expectedStartTime;
90
+ }
91
+
92
+ /**
93
+ * Whether a POSIX process group still has a *running* member. Zombies are
94
+ * inert: they execute no code and can only be reaped by their parent, which
95
+ * the owned process cannot control. Teardown must not wait on an external
96
+ * reaper, so disposal considers a group terminated once every member is dead
97
+ * or a zombie. On Linux this inspects `/proc`; elsewhere it conservatively
98
+ * falls back to {@link groupAlive} (zombies count as alive), so teardown there
99
+ * relies on the bounded SIGTERM/SIGKILL windows rather than zombie detection.
100
+ */
101
+ function groupHasRunningMembers(pgid: number): boolean {
102
+ if (!groupAlive(pgid)) return false;
103
+ if (process.platform !== "linux") return true;
104
+ try {
105
+ for (const entry of fs.readdirSync("/proc")) {
106
+ if (entry.length === 0 || entry.charCodeAt(0) < 0x30 || entry.charCodeAt(0) > 0x39) continue;
107
+ let stat: string;
108
+ try {
109
+ stat = fs.readFileSync(`/proc/${entry}/stat`, "utf8");
110
+ } catch (error) {
111
+ // Entries disappearing during enumeration are expected. Every other
112
+ // failure leaves the member's state unknowable, so fail closed.
113
+ if (!procEntryMayStillBeRunning(error)) continue;
114
+ return true;
115
+ }
116
+ // Format: pid (comm) state ppid pgrp session tty ...; comm may contain
117
+ // spaces or parens, so parse everything after the last ')'.
118
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
119
+ const state = fields[0] ?? "";
120
+ if (Number(fields[2]) === pgid && state !== "Z" && state !== "X") {
121
+ return true;
122
+ }
123
+ }
124
+ return false;
125
+ } catch {
126
+ // /proc unavailable or unreadable: keep the conservative behavior.
127
+ return true;
128
+ }
129
+ }
130
+
72
131
  /** Options for {@link spawnOwnedProcess}. */
73
132
  export interface SpawnOwnedOptions {
74
133
  cwd?: string;
@@ -97,6 +156,14 @@ export interface AwaitExitResult {
97
156
  code: number | null;
98
157
  }
99
158
 
159
+ /** The observed outcome of an owned-process teardown attempt. */
160
+ export type OwnedProcessTeardownStatus = "terminated" | "still_running" | "identity_unverified";
161
+
162
+ /** Result of {@link OwnedProcess.dispose}. */
163
+ export interface OwnedProcessTeardownResult {
164
+ status: OwnedProcessTeardownStatus;
165
+ }
166
+
100
167
  /** A spawned child process owned by the runtime with guaranteed teardown. */
101
168
  export interface OwnedProcess {
102
169
  readonly child: ptree.ChildProcess;
@@ -116,7 +183,7 @@ export interface OwnedProcess {
116
183
  * abort listener and deregisters from the live-owner set only after teardown
117
184
  * has completed. Repeated/concurrent calls return the same in-flight promise.
118
185
  */
119
- dispose(): Promise<void>;
186
+ dispose(): Promise<OwnedProcessTeardownResult>;
120
187
  }
121
188
 
122
189
  const liveOwners = new Set<OwnedProcess>();
@@ -155,9 +222,10 @@ export function spawnOwnedProcess(cmd: string[], opts: SpawnOwnedOptions = {}):
155
222
  // On POSIX with `detached`, the child is its own process-group leader, so the
156
223
  // group id equals its pid. `undefined` => single-process (Windows/opt-out).
157
224
  const pgid = useGroup ? child.pid : undefined;
158
-
225
+ const groupLeader = pgid === undefined ? undefined : probeLinuxProcPidSync(pgid);
226
+ const groupLeaderStartTime = groupLeader?.kind === "live" ? groupLeader.startTime : undefined;
159
227
  let disposed = false;
160
- let disposePromise: Promise<void> | undefined;
228
+ let disposePromise: Promise<OwnedProcessTeardownResult> | undefined;
161
229
  let deregistered = false;
162
230
  // Terminal once teardown/reconciliation has confirmed the group is gone. A
163
231
  // late dispose() must then be a true no-op and never re-probe a pgid the OS
@@ -172,14 +240,28 @@ export function spawnOwnedProcess(cmd: string[], opts: SpawnOwnedOptions = {}):
172
240
  }
173
241
  };
174
242
 
175
- const deregister = (): void => {
243
+ const deregister = (terminal = true): void => {
176
244
  if (deregistered) return;
177
245
  deregistered = true;
178
- terminated = true;
246
+ if (terminal) terminated = true;
179
247
  liveOwners.delete(owner);
180
248
  removeAbort();
181
249
  };
182
250
 
251
+ // Identity verification exists to stop a recycled pgid from being signalled.
252
+ // It is only decidable where the leader's spawn-time identity is readable
253
+ // (`/proc`). Elsewhere the group id is still ours for the lifetime of the
254
+ // owned child, so an unverifiable probe must not be reported as a mismatch —
255
+ // doing so skips SIGTERM/SIGKILL entirely and leaks the whole child tree.
256
+ const groupIdentityMatches = (): boolean => {
257
+ if (pgid === undefined) return false;
258
+ if (process.platform !== "linux") return true;
259
+ const leader = probeLinuxProcPidSync(pgid);
260
+ // Once the original leader has exited, its group can still own descendants.
261
+ // A replacement group has a leader at this pid, whose start time must match.
262
+ return groupLeaderIdentityMatches(groupLeaderStartTime, leader);
263
+ };
264
+
183
265
  const signalTree = (signal: NodeJS.Signals): void => {
184
266
  const pid = child.pid;
185
267
  if (pid === undefined) return;
@@ -232,51 +314,68 @@ export function spawnOwnedProcess(cmd: string[], opts: SpawnOwnedOptions = {}):
232
314
  if (timer) clearTimeout(timer);
233
315
  }
234
316
  },
235
- dispose(): Promise<void> {
317
+ dispose(): Promise<OwnedProcessTeardownResult> {
236
318
  // Already terminal (e.g. clean drain reconciled and deregistered):
237
319
  // never re-probe the pgid; treat dispose as a settled no-op.
238
320
  if (terminated) {
239
321
  disposed = true;
240
- if (!disposePromise) disposePromise = Promise.resolve();
322
+ if (!disposePromise) disposePromise = Promise.resolve({ status: "terminated" });
241
323
  return disposePromise;
242
324
  }
243
325
  if (disposePromise) return disposePromise;
244
326
  disposed = true;
245
327
  removeAbort();
246
- disposePromise = (async () => {
328
+ const result = (status: OwnedProcessTeardownStatus): OwnedProcessTeardownResult => ({ status });
329
+ disposePromise = (async (): Promise<OwnedProcessTeardownResult> => {
247
330
  try {
248
331
  if (pgid !== undefined) {
249
- // Group ownership: reap until the whole group is gone, even if
250
- // the root has already exited (it may have backgrounded children).
251
- if (!groupAlive(pgid)) return;
332
+ // Group ownership: terminate every *running* member, even if the
333
+ // root has already exited (it may have backgrounded children).
334
+ // Liveness is measured by running members, not raw group
335
+ // existence: SIGTERM'd descendants become zombies that only their
336
+ // parent (often PID 1) can reap, and teardown must not burn its
337
+ // grace window waiting on an external reaper.
338
+ if (!groupAlive(pgid)) return result("terminated");
339
+ if (!groupIdentityMatches()) return result("identity_unverified");
252
340
  signalTree("SIGTERM");
253
- if (await pollUntil(() => !groupAlive(pgid), gracefulMs)) return;
341
+ if (await pollUntil(() => !groupHasRunningMembers(pgid), gracefulMs)) return result("terminated");
342
+ if (!groupIdentityMatches()) return result("identity_unverified");
254
343
  signalTree("SIGKILL");
255
- if (!(await pollUntil(() => !groupAlive(pgid), SIGKILL_REAP_CAP_MS))) {
256
- logger.warn("owned process group still alive after SIGKILL", {
257
- name: opts.name,
258
- pgid,
259
- });
260
- }
261
- return;
344
+ if (await pollUntil(() => !groupHasRunningMembers(pgid), SIGKILL_REAP_CAP_MS))
345
+ return result("terminated");
346
+ logger.warn("owned process group still running after SIGKILL", {
347
+ name: opts.name,
348
+ pgid,
349
+ });
350
+ return result("still_running");
262
351
  }
263
352
  // Single-process fallback (Windows / processGroup:false).
264
- if (child.exitCode !== null) return;
353
+ if (child.exitCode !== null) return result("terminated");
265
354
  signalTree("SIGTERM");
266
- if ((await owner.awaitExit({ timeoutMs: gracefulMs })).exited) return;
355
+ if ((await owner.awaitExit({ timeoutMs: gracefulMs })).exited) return result("terminated");
267
356
  signalTree("SIGKILL");
268
- await owner.awaitExit({ timeoutMs: SIGKILL_REAP_CAP_MS });
357
+ return (await owner.awaitExit({ timeoutMs: SIGKILL_REAP_CAP_MS })).exited
358
+ ? result("terminated")
359
+ : result("still_running");
269
360
  } catch (err) {
270
361
  logger.warn("owned process dispose failed", {
271
362
  name: opts.name,
272
363
  error: err instanceof Error ? err.message : String(err),
273
364
  });
274
- } finally {
275
- // FIX: deregister only after teardown has completed so a postmortem
276
- // firing mid-grace still sees the owner and awaits this dispose.
277
- deregister();
365
+ return result("still_running");
278
366
  }
279
- })();
367
+ })().then(res => {
368
+ // `identity_unverified` and `still_running` leave the owner registered
369
+ // so a later postmortem or caller retry can make another bounded attempt.
370
+ // Only a confirmed `terminated` closes the book.
371
+ if (res.status === "terminated") {
372
+ deregister(true);
373
+ } else {
374
+ disposePromise = undefined;
375
+ disposed = false;
376
+ }
377
+ return res;
378
+ });
280
379
  return disposePromise;
281
380
  },
282
381
  };
@@ -89,10 +89,10 @@ export class StdioTransport implements MCPTransport {
89
89
  * Start the subprocess and begin reading.
90
90
  */
91
91
  async connect(): Promise<void> {
92
- if (this.#closePromise) {
93
- throw new MCPExpectedFailure();
94
- }
95
92
  if (this.#connected) return;
93
+ if (this.#closePromise || this.#process) {
94
+ throw new MCPExpectedFailure(new Error("MCP stdio child teardown is incomplete"));
95
+ }
96
96
 
97
97
  const args = this.config.args ?? [];
98
98
  const env = this.config.noInheritEnv
@@ -244,7 +244,9 @@ export class StdioTransport implements MCPTransport {
244
244
  }
245
245
 
246
246
  #handleClose(failure?: MCPExpectedFailure): void {
247
- void this.#closeInternal(true, failure);
247
+ void this.#closeInternal(true, failure).catch(error => {
248
+ this.onError?.(error instanceof Error ? error : new Error(String(error)));
249
+ });
248
250
  }
249
251
 
250
252
  async request<T = unknown>(
@@ -374,11 +376,14 @@ export class StdioTransport implements MCPTransport {
374
376
 
375
377
  const stdin = this.#getStdin();
376
378
  const process = this.#process;
377
- this.#process = null;
378
379
  if (process) {
379
380
  stdin?.end();
380
- await process.dispose().catch(() => {});
381
+ const teardown = await process.dispose();
381
382
  await process.awaitExit({ timeoutMs: CLOSE_WAIT_MS }).catch(() => ({ exited: false, code: null }));
383
+ if (teardown.status !== "terminated") {
384
+ throw new MCPExpectedFailure(new Error(`stdio child teardown ${teardown.status}`));
385
+ }
386
+ this.#process = null;
382
387
  }
383
388
 
384
389
  if (!fromReadLoop && this.#readLoop) {
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
3
- import { SdkClient, SdkClientError, type SdkFrame } from "../client";
3
+ import { SdkClient, SdkClientError, type SdkFrame, type SdkSentRecord } from "../client";
4
4
  import { assertReverseResponseFrame, ReverseLeaseError } from "../host/reverse-leases";
5
5
  import { validateAdapterControl, validateAdapterSecretFields } from "../protocol/adapter-validation";
6
6
  import { OPERATIONS } from "../protocol/operation-registry";
@@ -224,16 +224,24 @@ export class AcpSdkAdapter {
224
224
  if (isLifecycleOperation(operation) && !idempotencyKey)
225
225
  throw new AcpSdkAdapterError("invalid_input", "idempotencyKey is required for lifecycle operations.");
226
226
  // The broker may hold a startup in its admission queue before the readiness
227
- // clock even starts, so the caller deadline covers the queue wait too; sizing
228
- // it on readiness alone times out requests the broker is still running. A
229
- // request that named no readiness budget is queued for the default one, so it
230
- // needs the same extension rather than the client's generic request deadline.
227
+ // clock starts, so the caller deadline covers the queue wait too.
231
228
  const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
232
- const response = await this.#client.global(operation, input, {
233
- idempotencyKey,
234
- ...(timeoutMs === undefined ? {} : { timeoutMs }),
235
- });
236
- return response;
229
+ try {
230
+ return await this.#client.global(operation, input, {
231
+ idempotencyKey,
232
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
233
+ });
234
+ } catch (error) {
235
+ if (
236
+ isLifecycleOperation(operation) &&
237
+ error instanceof SdkClientError &&
238
+ error.code === "uncertain_after_send" &&
239
+ error.details &&
240
+ typeof error.details === "object"
241
+ )
242
+ return await this.#client.lookupLifecycle(error.details as SdkSentRecord);
243
+ throw error;
244
+ }
237
245
  }
238
246
 
239
247
  async sdkControl(params: { operation: string; input?: JsonObject }): Promise<unknown> {
@@ -45,7 +45,7 @@ import {
45
45
  readBrokerDiscovery,
46
46
  redactBrokerDiscovery,
47
47
  } from "./discovery";
48
- import { deriveIdempotencyIdentity } from "./identity";
48
+ import { deriveIdempotencyIdentity, deriveLegacyTargetIdentity } from "./identity";
49
49
  import { canonicalDeleteLocatorPath, executeLifecycle, isCanonicalSessionId } from "./lifecycle";
50
50
  import {
51
51
  type LifecycleDurableEffectsReceipt,
@@ -237,6 +237,14 @@ function lifecycleResponseState(response: BrokerResponse): LifecycleState {
237
237
  return response.error.code === "terminal_uncertain" ? "terminal_uncertain" : "terminal_error";
238
238
  }
239
239
 
240
+ const LIFECYCLE_OPERATIONS = new Set([
241
+ "session.create",
242
+ "session.fork",
243
+ "session.resume",
244
+ "session.close",
245
+ "session.delete",
246
+ ]);
247
+
240
248
  type InputNormalization = { input: Record<string, unknown> } | BrokerResponse;
241
249
 
242
250
  type SessionListCursor = {
@@ -457,7 +465,7 @@ function lifecycleTarget(operation: string, input: Record<string, unknown>): unk
457
465
  case "session.resume":
458
466
  case "session.close":
459
467
  case "session.delete":
460
- return { sessionId: id };
468
+ return { root, sessionId: id };
461
469
  default:
462
470
  return { operation, root, sessionId: id };
463
471
  }
@@ -1411,6 +1419,7 @@ export class Broker {
1411
1419
  elevationRequestId?: string,
1412
1420
  ): Promise<BrokerResponse> {
1413
1421
  if (this.#stopping) return error("broker_restarting", "broker is stopping");
1422
+ const fingerprint = JSON.stringify({ operation, input: JSON.parse(JSON.stringify(input)) });
1414
1423
  const normalization = normalizeBrokerInput(operation, input);
1415
1424
  if (isBrokerResponse(normalization)) return normalization;
1416
1425
  const approvedInput = input;
@@ -1454,6 +1463,32 @@ export class Broker {
1454
1463
  if (operation === "elevation.issue") return this.#elevationIssueRequest(input);
1455
1464
  if (operation === "elevation.status") return this.#elevationStatusRequest(input);
1456
1465
  if (operation === "session.control") return this.#sessionControlRequest(input);
1466
+ if (operation === "broker.lookup_lifecycle") {
1467
+ const requestedOperation = typeof input.operation === "string" ? input.operation : undefined;
1468
+ const fingerprint = typeof input.fingerprint === "string" ? input.fingerprint : undefined;
1469
+ if (!idempotencyKey || !requestedOperation || !fingerprint)
1470
+ return error("invalid_input", "operation, idempotencyKey, and fingerprint are required");
1471
+ if (!LIFECYCLE_OPERATIONS.has(requestedOperation))
1472
+ return error("not_found", "lifecycle operation was not found");
1473
+ const identity = await deriveIdempotencyIdentity(this.settings.agentDir, requestedOperation, idempotencyKey);
1474
+ const entry = this.ledger.get(identity);
1475
+ if (!entry) return error("not_found", "lifecycle operation was not found");
1476
+ if (entry.fingerprint !== fingerprint)
1477
+ return error("idempotency_conflict", "lifecycle request fingerprint differs");
1478
+ if (entry.state === "terminal_uncertain")
1479
+ return {
1480
+ ok: false,
1481
+ error: { code: "terminal_uncertain", message: "lifecycle outcome is still uncertain" },
1482
+ };
1483
+ // Reconcile to the terminal original response contract: callers expect
1484
+ // the BrokerResponse they would have received, not a lookup envelope.
1485
+ return (
1486
+ (entry.response as BrokerResponse) ?? {
1487
+ ok: false,
1488
+ error: { code: "terminal_uncertain", message: "lifecycle outcome has no recorded response" },
1489
+ }
1490
+ );
1491
+ }
1457
1492
  if (!idempotencyKey) return error("invalid_input", "idempotencyKey is required for lifecycle operations");
1458
1493
  // Elevation gate (F1.3): allowlisted raw operations execute only behind a
1459
1494
  // broker-owned single-use grant. Validation is fail-closed and happens
@@ -1504,7 +1539,20 @@ export class Broker {
1504
1539
  const target = createHash("sha256")
1505
1540
  .update(canonicalJson(lifecycleTarget(operation, input)))
1506
1541
  .digest("hex");
1507
- const identity = await deriveIdempotencyIdentity(this.settings.agentDir, operation, idempotencyKey, target);
1542
+ const identity = await deriveIdempotencyIdentity(this.settings.agentDir, operation, idempotencyKey);
1543
+ const operationKey = `${operation}\0${idempotencyKey}`;
1544
+ const legacyIdentity = await deriveLegacyTargetIdentity(
1545
+ this.settings.agentDir,
1546
+ operation,
1547
+ idempotencyKey,
1548
+ target,
1549
+ );
1550
+ if (!this.ledger.get(identity)) {
1551
+ if (await this.ledger.migrateIdentity(legacyIdentity, identity, { operationKey, fingerprint })) {
1552
+ // Exact target-derived legacy identity migrated without granting new authority.
1553
+ } else if (this.ledger.findByOperationKey(operationKey) || this.ledger.hasLegacyIdentity())
1554
+ return error("idempotency_conflict", "legacy lifecycle request has an ambiguous target");
1555
+ }
1508
1556
  let reconstructedDeleteCleanup: BrokerCleanupEvidence | undefined;
1509
1557
  if (operation === "session.delete" && input.cwd === undefined && input.sessionPath === undefined) {
1510
1558
  const entry = this.ledger.get(identity);
@@ -1570,7 +1618,10 @@ export class Broker {
1570
1618
  await prev;
1571
1619
  try {
1572
1620
  const beforeBegin = this.ledger.get(identity);
1573
- const begun = await this.ledger.begin(identity, requestHash);
1621
+ const begun = await this.ledger.begin(identity, requestHash, {
1622
+ operationKey,
1623
+ fingerprint,
1624
+ });
1574
1625
  if (begun.kind === "replay") {
1575
1626
  const replay = begun.entry.response as BrokerResponse;
1576
1627
  const cleanup = cleanupFromResponse(replay) ?? reconstructedDeleteCleanup;
@@ -5,6 +5,7 @@ import * as fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { type BrokerDiscovery, brokerProcessIncarnation, readBrokerDiscovery } from "./discovery";
7
7
  import { resolveSdkInternalSpawnCommand, type SdkInternalSpawnCommand } from "./runtime";
8
+ import { BrokerStartupError, clearBrokerStartupFailureMarker, readBrokerStartupFailureMarker } from "./startup-failure";
8
9
  export interface EnsureBrokerSettings {
9
10
  agentDir: string;
10
11
  heartbeatTtlMs?: number;
@@ -301,6 +302,8 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
301
302
 
302
303
  const command = resolveSdkInternalSpawnCommand("broker-internal");
303
304
  const spawnLog = await openBrokerSpawnLog(settings.agentDir);
305
+ // A stale marker must never be misattributed to this spawn; clear it first.
306
+ await clearBrokerStartupFailureMarker(settings.agentDir);
304
307
  try {
305
308
  const child = spawn(command.file, [...command.args, "--agent-dir", settings.agentDir], {
306
309
  detached: true,
@@ -358,16 +361,16 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
358
361
  }
359
362
  }
360
363
  const spawnLogTail = exitedBeforeDiscovery && spawnLog ? await readBrokerSpawnLogTail(spawnLog.path) : "";
364
+ const marker = await readBrokerStartupFailureMarker(settings.agentDir);
361
365
  const failure = spawnError
362
366
  ? new Error(`Failed to spawn detached SDK broker: ${spawnError.message}`)
363
367
  : exitedBeforeDiscovery
364
- ? new Error(
365
- `Detached SDK broker exited before discovery (code=${child.exitCode}, signal=${child.signalCode}).${
366
- spawnLogTail
367
- ? ` Broker stderr: ${spawnLogTail}`
368
- : " The broker wrote no stderr; see the gjc log for its lock-contention diagnostic."
369
- }`,
370
- )
368
+ ? new BrokerStartupError({
369
+ exitCode: child.exitCode,
370
+ signal: child.signalCode,
371
+ reason: marker?.reason ?? "Detached SDK broker exited before publishing discovery.",
372
+ stderrExcerpt: spawnLogTail.length > 0 ? spawnLogTail : undefined,
373
+ })
371
374
  : discoveryError
372
375
  ? discoveryError
373
376
  : new Error("Timed out waiting for detached SDK broker discovery.");
@@ -40,6 +40,16 @@ export async function getBrokerIdentityKey(agentDir: string): Promise<string> {
40
40
  }
41
41
 
42
42
  export async function deriveIdempotencyIdentity(
43
+ agentDir: string,
44
+ operation: string,
45
+ callerKey: string,
46
+ _protocolVersionOrLegacyTargetHash?: string,
47
+ ): Promise<string> {
48
+ const key = await getBrokerIdentityKey(agentDir);
49
+ return createHmac("sha256", Buffer.from(key, "hex")).update(`3|${operation}|${callerKey}`).digest("hex");
50
+ }
51
+
52
+ export async function deriveLegacyTargetIdentity(
43
53
  agentDir: string,
44
54
  operation: string,
45
55
  callerKey: string,