@gajae-code/coding-agent 0.18.1 → 0.18.6

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 (128) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/dist/types/cli/stats-cli.d.ts +0 -5
  3. package/dist/types/cli-commands.d.ts +10 -0
  4. package/dist/types/cli-ordinary.d.ts +13 -0
  5. package/dist/types/cli.d.ts +12 -3
  6. package/dist/types/commands/contribution-prep.d.ts +0 -3
  7. package/dist/types/commands/sdk.d.ts +3 -0
  8. package/dist/types/commands/update.d.ts +1 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +29 -2
  10. package/dist/types/config/model-profile-activation.d.ts +19 -0
  11. package/dist/types/config/model-registry.d.ts +16 -1
  12. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  13. package/dist/types/edit/modes/replace.d.ts +3 -3
  14. package/dist/types/edit/streaming.d.ts +2 -2
  15. package/dist/types/exa/mcp-client.d.ts +1 -5
  16. package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
  17. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  18. package/dist/types/hashline/parser.d.ts +28 -5
  19. package/dist/types/modes/acp/acp-agent.d.ts +6 -1
  20. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  21. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  22. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  23. package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
  24. package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
  25. package/dist/types/sdk/broker/runtime.d.ts +21 -1
  26. package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
  27. package/dist/types/sdk/bus/index.d.ts +1 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
  30. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  31. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  32. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  33. package/dist/types/sdk/host/session-runtime.d.ts +7 -0
  34. package/dist/types/sdk/index.d.ts +1 -0
  35. package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
  36. package/dist/types/sdk/router/session-router.d.ts +2 -0
  37. package/dist/types/sdk/session.d.ts +13 -0
  38. package/dist/types/session/agent-session.d.ts +5 -1
  39. package/dist/types/tools/ask-contract.d.ts +75 -254
  40. package/dist/types/tools/bash.d.ts +1 -0
  41. package/package.json +7 -7
  42. package/scripts/build-sdk-package-smoke.ts +5 -0
  43. package/src/cli/public-command-entry.ts +5 -0
  44. package/src/cli/ssh-cli.ts +6 -3
  45. package/src/cli/stats-cli.ts +4 -36
  46. package/src/cli/web-search-cli.ts +9 -3
  47. package/src/cli-commands.ts +14 -0
  48. package/src/cli-main.ts +4 -8
  49. package/src/cli-ordinary.ts +197 -0
  50. package/src/cli.ts +25 -186
  51. package/src/commands/commit.ts +2 -2
  52. package/src/commands/contribution-prep.ts +1 -3
  53. package/src/commands/sdk.ts +60 -2
  54. package/src/commands/update.ts +15 -1
  55. package/src/config/autorouting-tier-map.ts +20 -2
  56. package/src/config/file-lock.ts +91 -2
  57. package/src/config/model-preset-registry.ts +17 -2
  58. package/src/config/model-profile-activation.ts +73 -5
  59. package/src/config/model-profile-contract.ts +4 -1
  60. package/src/config/model-profiles.ts +29 -29
  61. package/src/config/model-registry.ts +45 -17
  62. package/src/config/model-resolver.ts +9 -1
  63. package/src/coordinator-mcp/event-webhook.ts +19 -14
  64. package/src/coordinator-mcp/policy.ts +14 -4
  65. package/src/coordinator-mcp/server.ts +142 -7
  66. package/src/coordinator-mcp/session-reaper.ts +9 -1
  67. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  68. package/src/edit/modes/replace.ts +6 -6
  69. package/src/edit/streaming.ts +15 -15
  70. package/src/exa/mcp-client.ts +41 -8
  71. package/src/exec/bash-shell-guardian.ts +184 -34
  72. package/src/exec/bash-shell-supervisor.ts +60 -3
  73. package/src/hashline/execute.ts +51 -3
  74. package/src/hashline/parser.ts +63 -16
  75. package/src/internal-urls/docs-index.generated.ts +29 -28
  76. package/src/memories/index.ts +8 -2
  77. package/src/modes/acp/acp-agent.ts +688 -371
  78. package/src/modes/components/tool-execution.ts +1 -1
  79. package/src/modes/controllers/ssh-command-controller.ts +3 -1
  80. package/src/prompts/tools/ask.md +2 -0
  81. package/src/sdk/broker/broker.ts +46 -0
  82. package/src/sdk/broker/daemon-entry.ts +67 -6
  83. package/src/sdk/broker/discovery.ts +8 -0
  84. package/src/sdk/broker/ensure.ts +156 -20
  85. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  86. package/src/sdk/broker/lifecycle.ts +108 -34
  87. package/src/sdk/broker/recovery-backoff.ts +135 -0
  88. package/src/sdk/broker/runtime.ts +88 -1
  89. package/src/sdk/broker/startup-budget.ts +6 -1
  90. package/src/sdk/broker/transport.ts +5 -1
  91. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  92. package/src/sdk/bus/index.ts +305 -19
  93. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  94. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  95. package/src/sdk/bus/telegram-daemon.ts +34 -1
  96. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  97. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  98. package/src/sdk/host/control/dispatch.ts +52 -4
  99. package/src/sdk/host/control/operations.ts +1 -1
  100. package/src/sdk/host/session-runtime.test.ts +40 -2
  101. package/src/sdk/host/session-runtime.ts +112 -8
  102. package/src/sdk/index.ts +1 -0
  103. package/src/sdk/lifecycle-session.ts +1 -0
  104. package/src/sdk/prompt-watchdog.ts +17 -4
  105. package/src/sdk/protocol/operation-registry.ts +1 -1
  106. package/src/sdk/router/session-router.ts +7 -1
  107. package/src/sdk/session.ts +37 -8
  108. package/src/session/agent-session.ts +128 -41
  109. package/src/session/blob-store.ts +78 -33
  110. package/src/session/internal/managed-session-storage.ts +119 -3
  111. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  112. package/src/setup/hermes-setup.ts +4 -1
  113. package/src/slash-commands/builtin-registry.ts +8 -5
  114. package/src/tools/ask-contract.test.ts +76 -1
  115. package/src/tools/ask-contract.ts +57 -6
  116. package/src/tools/ask.ts +16 -7
  117. package/src/tools/bash.ts +77 -29
  118. package/src/tools/descriptors.test.ts +29 -0
  119. package/src/tools/tool-catalog.generated.ts +7 -4
  120. package/src/web/search/index.ts +2 -2
  121. package/dist/types/cli/grep-cli.d.ts +0 -17
  122. package/dist/types/cli/shell-cli.d.ts +0 -8
  123. package/dist/types/commands/grep.d.ts +0 -42
  124. package/dist/types/commands/shell.d.ts +0 -21
  125. package/src/cli/grep-cli.ts +0 -160
  126. package/src/cli/shell-cli.ts +0 -176
  127. package/src/commands/grep.ts +0 -48
  128. package/src/commands/shell.ts +0 -29
@@ -965,7 +965,7 @@ export class ToolExecutionComponent extends Container {
965
965
  if (this.#expanded && !previews?.some(preview => preview.diff)) {
966
966
  const editMode = this.#editMode;
967
967
  const strategy = editMode ? EDIT_MODE_STRATEGIES[editMode] : undefined;
968
- const fallback = strategy?.renderStreamingFallback(this.#args, theme);
968
+ const fallback = strategy?.renderStreamingPlaceholder(this.#args, theme);
969
969
  if (fallback) context.editStreamingFallback = fallback;
970
970
  }
971
971
  context.renderDiff = renderDiff;
@@ -134,7 +134,9 @@ export class SSHCommandController {
134
134
  this.ctx.showError("Missing value for --port.");
135
135
  return;
136
136
  }
137
- const parsed = Number.parseInt(value, 10);
137
+ // Same rule as the ACP `/ssh add` parser: `Number.parseInt` accepts
138
+ // trailing garbage ("22oops" -> 22), which would save a typo as a port.
139
+ const parsed = /^\d+$/.test(value) ? Number.parseInt(value, 10) : Number.NaN;
138
140
  if (Number.isNaN(parsed) || parsed < 1 || parsed > 65535) {
139
141
  this.ctx.showError("Invalid --port value. Must be an integer between 1 and 65535.");
140
142
  return;
@@ -8,6 +8,8 @@ Asks user when you need clarification or input during task execution.
8
8
  - Use `recommended: <index>` to mark default (0-indexed); " (Recommended)" added automatically
9
9
  - Use `questions` for multiple related questions instead of asking one at a time
10
10
  - Set `multi: true` on question to allow multiple selections
11
+ - Every question body must contain meaningful, non-whitespace text. Never add empty `metadata`/`ignore` questions.
12
+ - While deep-interview is in the `interviewing` phase, submit exactly one question with the required `deepInterview` metadata on that question. Round 0 needs `round: 0`, `component: "review-topology"`, `dimension: "topology"`, `ambiguity`, and `intent_contract` containing the displayed items and affirmative labels. Later questions need a positive round, component, dimension, and ambiguity. Question prose and `workflowGate` alone cannot record an interview answer. After final-spec persistence transitions to `handoff`, next-workflow/approval choices are ordinary asks without interview round metadata.
11
13
  </instruction>
12
14
 
13
15
  <caution>
@@ -1495,6 +1495,16 @@ export class Broker {
1495
1495
  discovery: BrokerDiscovery | null = null;
1496
1496
  #lock: string;
1497
1497
  #owner = randomBytes(12).toString("hex");
1498
+ /** Public publication-incarnation id, fixed at startup; never the endpoint generation. */
1499
+ #diagnosticGeneration = randomBytes(16).toString("hex");
1500
+ /** Trusted build snapshot captured at startup; unknown buildId stays null. */
1501
+ #startupBuild: { packageVersion: string; buildId: string | null } = {
1502
+ packageVersion: packageJson.version,
1503
+ buildId:
1504
+ typeof process.env.GJC_BUILD_ID === "string" && process.env.GJC_BUILD_ID.length > 0
1505
+ ? process.env.GJC_BUILD_ID
1506
+ : null,
1507
+ };
1498
1508
  #sessionListCursors = new Map<string, SessionListCursor>();
1499
1509
  #chains = new Map<string, Promise<void>>();
1500
1510
  #spawnInFlight = new Map<string, SpawnInFlight>();
@@ -3868,6 +3878,10 @@ export class Broker {
3868
3878
  ownerId: this.#owner,
3869
3879
  pid: process.pid,
3870
3880
  incarnation,
3881
+ // Observation capability: the startup-fixed generation and the protocol the
3882
+ // broker answers. An old broker simply omits both.
3883
+ diagnosticGeneration: this.#diagnosticGeneration,
3884
+ diagnosticProtocol: 1,
3871
3885
  host: "127.0.0.1",
3872
3886
  port,
3873
3887
  url: `ws://127.0.0.1:${port}`,
@@ -4728,6 +4742,32 @@ export class Broker {
4728
4742
  ? entry.response
4729
4743
  : error("terminal_uncertain", "lifecycle outcome has no recorded response");
4730
4744
  }
4745
+ /**
4746
+ * Published diagnostics for observation clients (SPEC "Result contract").
4747
+ *
4748
+ * Everything here is captured once at startup and never recomputed, so a later source
4749
+ * or package change cannot alter what a running broker reports. No token, socket
4750
+ * coordinate, argv or environment value is included; the identity fields are the
4751
+ * internal owner/process/root binding the observer already knows from discovery.
4752
+ */
4753
+ #publishedDiagnostics(): Promise<BrokerResponse> {
4754
+ // Observation is answered ONLY from an already-owned healthy retained publication.
4755
+ // A broker that owns no publication refuses here and recovers nothing: it does not
4756
+ // publish, ensure, restart or touch the authority to answer.
4757
+ if (this.#publication === null || this.#publicationState !== "healthy-owned") {
4758
+ return Promise.resolve(error("unavailable", "broker publication authority is not owned"));
4759
+ }
4760
+ return Promise.resolve({
4761
+ ok: true,
4762
+ result: {
4763
+ diagnosticProtocol: 1,
4764
+ generation: this.#diagnosticGeneration,
4765
+ build: this.#startupBuild,
4766
+ identity: { ownerId: this.#owner, pid: process.pid, agentRoot: this.settings.agentDir },
4767
+ },
4768
+ });
4769
+ }
4770
+
4731
4771
  // Wire requests are readiness-gated by BrokerTransport before reaching this dispatcher.
4732
4772
  handleRequest(operation: string, input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
4733
4773
  if (operation === "broker.status") return Promise.resolve({ ok: true, result: this.status() });
@@ -4768,6 +4808,7 @@ export class Broker {
4768
4808
  const spawnClose = await this.#maybeCloseSpawnChild(input);
4769
4809
  if (spawnClose) return spawnClose;
4770
4810
  }
4811
+ if (operation === "broker.diagnostics") return this.#publishedDiagnostics();
4771
4812
  if (operation === "session.control") return this.#sessionControl(input, idempotencyKey);
4772
4813
  if (operation === "session.lookup") {
4773
4814
  const lookup = publicLifecycleLookupInput(input);
@@ -4937,6 +4978,11 @@ export class Broker {
4937
4978
  ...(operation === "session.close" && typeof input.sessionId === "string"
4938
4979
  ? { intendedSessionId: input.sessionId }
4939
4980
  : {}),
4981
+ ...(operation === "session.close" &&
4982
+ typeof input.endpointGeneration === "number" &&
4983
+ typeof input.endpointIncarnation === "string"
4984
+ ? { closeAuthorityBound: true }
4985
+ : {}),
4940
4986
  };
4941
4987
  if (!this.ledger.get(identity)) {
4942
4988
  const matchingOperation = this.ledger.findAnyByOperationKey(operationKey);
@@ -1,7 +1,7 @@
1
1
  import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { type BrokerDiscovery, readBrokerDiscovery, readBrokerRestartIntent } from "./discovery";
3
3
  import { withBrokerStartupLock } from "./ensure";
4
- import { observeProcessIncarnation } from "./process-incarnation";
4
+ import { isProcessIncarnation, observeProcessIncarnation } from "./process-incarnation";
5
5
  import { resolveSdkInternalSpawnCommand } from "./runtime";
6
6
 
7
7
  export interface AuthorizedBrokerSuccessorOptions {
@@ -61,12 +61,15 @@ export async function launchAuthorizedBrokerSuccessor(
61
61
  existing.restartRequestId === options.requestId
62
62
  )
63
63
  return { kind: "adopted" as const, discovery: existing };
64
- const command = resolveSdkInternalSpawnCommand("broker-internal");
64
+ const command = resolveSdkInternalSpawnCommand(
65
+ process.platform === "win32" ? "broker-internal" : "broker-trampoline-internal",
66
+ );
65
67
  let child: ChildProcess;
68
+ const isTrampoline = process.platform !== "win32";
66
69
  try {
67
70
  child = spawn(command.file, [...command.args, "--agent-dir", options.agentDir], {
68
71
  detached: true,
69
- stdio: "ignore",
72
+ stdio: isTrampoline ? ["ignore", "pipe", "ignore"] : "ignore",
70
73
  env: { ...command.env, GJC_BROKER_RESTART_REQUEST: options.requestId },
71
74
  ...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
72
75
  });
@@ -78,14 +81,44 @@ export async function launchAuthorizedBrokerSuccessor(
78
81
  };
79
82
  }
80
83
  let spawnError: Error | undefined;
84
+ let trampolineOutput = "";
85
+ let trampolineOutputEnded = !isTrampoline;
86
+ if (child.stdout)
87
+ child.stdout
88
+ .setEncoding("utf8")
89
+ .on("data", chunk => (trampolineOutput += chunk))
90
+ .once("end", () => (trampolineOutputEnded = true));
81
91
  child.once("error", childError => {
82
92
  spawnError = childError;
83
93
  });
84
94
  child.unref();
85
- return { kind: "spawned" as const, child, spawnError: () => spawnError };
95
+ return {
96
+ kind: "spawned" as const,
97
+ child,
98
+ isTrampoline,
99
+ trampolineOutput: () => trampolineOutput,
100
+ trampolineOutputEnded: () => trampolineOutputEnded,
101
+ spawnError: () => spawnError,
102
+ };
86
103
  });
87
104
  if (spawnOutcome.kind !== "spawned") return spawnOutcome;
88
105
  const { child } = spawnOutcome;
106
+ let successorPid: number | undefined;
107
+ let successorIncarnation: string | undefined;
108
+ let trampolineExited = !spawnOutcome.isTrampoline;
109
+ let trampolineExitCode: number | null = null;
110
+ let trampolineSignal: NodeJS.Signals | null = null;
111
+ if (spawnOutcome.isTrampoline)
112
+ child.once("exit", (code, signal) => {
113
+ trampolineExitCode = code;
114
+ trampolineSignal = signal;
115
+ trampolineExited = true;
116
+ });
117
+ if (spawnOutcome.isTrampoline && (child.exitCode !== null || child.signalCode !== null)) {
118
+ trampolineExitCode = child.exitCode;
119
+ trampolineSignal = child.signalCode;
120
+ trampolineExited = true;
121
+ }
89
122
  const until = Math.min(Date.now() + Math.max(1, options.deadlineAt - Date.now()), options.deadlineAt);
90
123
  for (;;) {
91
124
  if (spawnOutcome.spawnError())
@@ -94,7 +127,34 @@ export async function launchAuthorizedBrokerSuccessor(
94
127
  reason: "spawn_failed",
95
128
  detail: spawnOutcome.spawnError()?.message,
96
129
  };
97
- if (child.exitCode !== null || child.signalCode !== null)
130
+ if (!spawnOutcome.isTrampoline && (child.exitCode !== null || child.signalCode !== null))
131
+ return { kind: "refused", reason: "spawn_exited_before_publication" };
132
+ if (spawnOutcome.isTrampoline && !successorPid) {
133
+ const report = spawnOutcome.trampolineOutput().trim();
134
+ const match = /^(\d+)\t([^\r\n]+)$/.exec(report);
135
+ if (match) {
136
+ const pid = Number(match[1]);
137
+ const incarnation = match[2];
138
+ if (Number.isSafeInteger(pid) && pid > 0 && isProcessIncarnation(incarnation)) {
139
+ const observed = observeProcessIncarnation(pid);
140
+ if (observed.status === "present" && observed.incarnation === incarnation) {
141
+ successorPid = pid;
142
+ successorIncarnation = incarnation;
143
+ }
144
+ }
145
+ }
146
+ }
147
+ if (successorPid) {
148
+ const observed = observeProcessIncarnation(successorPid);
149
+ if (
150
+ observed.status === "absent" ||
151
+ (observed.status === "present" && observed.incarnation !== successorIncarnation)
152
+ )
153
+ return { kind: "refused", reason: "spawn_exited_before_publication" };
154
+ }
155
+ if (spawnOutcome.isTrampoline && trampolineExited && spawnOutcome.trampolineOutputEnded() && !successorPid)
156
+ return { kind: "refused", reason: "spawn_exited_before_publication" };
157
+ if (spawnOutcome.isTrampoline && trampolineExited && (trampolineSignal !== null || trampolineExitCode !== 0))
98
158
  return { kind: "refused", reason: "spawn_exited_before_publication" };
99
159
  const discovered = await readBrokerDiscovery(options.agentDir);
100
160
  if (
@@ -102,7 +162,8 @@ export async function launchAuthorizedBrokerSuccessor(
102
162
  discovered.packageGeneration === options.packageGeneration &&
103
163
  discovered.restartRequestId === options.requestId
104
164
  )
105
- return { kind: "spawned", discovery: discovered };
165
+ if (!spawnOutcome.isTrampoline || (trampolineExited && spawnOutcome.trampolineOutputEnded()))
166
+ return { kind: "spawned", discovery: discovered };
106
167
  if (Date.now() >= until) return { kind: "refused", reason: "publication_timeout" };
107
168
  await Bun.sleep(SUCCESSOR_SPAWN_POLL_MS);
108
169
  }
@@ -386,6 +386,14 @@ export interface BrokerDiscovery {
386
386
  ownerId: string;
387
387
  pid: number;
388
388
  incarnation: string;
389
+ /**
390
+ * Public publication-incarnation id for diagnostic observation, fixed at startup.
391
+ * Absent on publications written before the capability existed; an observer then
392
+ * reports `unsupported` instead of synthesizing compatibility.
393
+ */
394
+ diagnosticGeneration?: string;
395
+ /** Diagnostic protocol the publisher answers; absent means no capability. */
396
+ diagnosticProtocol?: number;
389
397
  host: "127.0.0.1";
390
398
  port: number;
391
399
  url: string;
@@ -14,6 +14,7 @@ import {
14
14
  readBrokerDiscovery,
15
15
  readBrokerRestartIntent,
16
16
  } from "./discovery";
17
+ import { isProcessIncarnation, observeProcessIncarnation } from "./process-incarnation";
17
18
  import {
18
19
  isSdkInternalRuntimeImagePresent,
19
20
  resolveSdkInternalSpawnCommand,
@@ -369,6 +370,7 @@ interface BrokerOwner {
369
370
  stop(): Promise<void>;
370
371
  canReuse(discovery: BrokerDiscovery | null): boolean;
371
372
  markReady(discovery: BrokerDiscovery): boolean;
373
+ setTarget(pid: number, incarnation: string): void;
372
374
  }
373
375
  type EnsureInitiator = "discovery" | "fixture-lease";
374
376
  type EnsureOutcome =
@@ -453,25 +455,60 @@ async function reapSpawnedBroker(child: ChildProcess, timing: ReapTiming = DEFAU
453
455
  throw new Error(`Detached SDK broker (pid ${child.pid}) did not exit after SIGKILL during reap.`);
454
456
  }
455
457
 
458
+ async function reapSpawnedBrokerIdentity(
459
+ pid: number,
460
+ incarnation: string,
461
+ timing: ReapTiming = DEFAULT_REAP_TIMING,
462
+ ): Promise<void> {
463
+ const matches = (): boolean => {
464
+ const observed = observeProcessIncarnation(pid);
465
+ return observed.status === "present" && observed.incarnation === incarnation;
466
+ };
467
+ const gone = (): boolean => observeProcessIncarnation(pid).status === "absent";
468
+ const signal = (sig: NodeJS.Signals): void => {
469
+ if (!matches()) return;
470
+ try {
471
+ process.kill(pid, sig);
472
+ } catch {
473
+ // already exited between identity check and signal delivery
474
+ }
475
+ };
476
+ if (gone() || !matches()) return;
477
+ // A reported trampoline has already detached this broker from the retained
478
+ // child handle, so graceful shutdown cannot be observed or reaped reliably.
479
+ // Force termination keeps discovery-timeout cleanup within its caller's
480
+ // bounded deadline while the identity check still prevents PID reuse.
481
+ signal("SIGKILL");
482
+ await Promise.race([waitForProcessAbsence(pid), sleep(timing.killVerifyMs)]);
483
+ if (!gone()) throw new Error(`Detached SDK broker (pid ${pid}) did not exit after SIGKILL during reap.`);
484
+ }
485
+
486
+ async function waitForProcessAbsence(pid: number): Promise<void> {
487
+ while (observeProcessIncarnation(pid).status !== "absent") await Bun.sleep(10);
488
+ }
489
+
456
490
  function registerBrokerOwner(
457
491
  agentDir: string,
458
492
  child: ChildProcess,
459
493
  timing: ReapTiming = DEFAULT_REAP_TIMING,
460
494
  ): BrokerOwner {
461
- const incarnation = child.pid === undefined ? undefined : brokerProcessIncarnation(child.pid);
495
+ let targetPid = child.pid;
496
+ let targetIncarnation = child.pid === undefined ? undefined : brokerProcessIncarnation(child.pid);
462
497
  let state: "starting" | "ready" | "cleanup-unverified" = "starting";
463
498
  const matches = (discovery: BrokerDiscovery | null): boolean =>
464
499
  Boolean(
465
500
  discovery &&
466
- child.pid !== undefined &&
467
- incarnation &&
468
- discovery.pid === child.pid &&
469
- discovery.incarnation === incarnation,
501
+ targetPid !== undefined &&
502
+ targetIncarnation !== undefined &&
503
+ discovery.pid === targetPid &&
504
+ discovery.incarnation === targetIncarnation,
470
505
  );
471
506
  const owner: BrokerOwner = {
472
507
  async stop(): Promise<void> {
473
508
  try {
474
- await reapSpawnedBroker(child, timing);
509
+ if (targetPid !== undefined && targetIncarnation !== undefined && targetPid !== child.pid)
510
+ await reapSpawnedBrokerIdentity(targetPid, targetIncarnation, timing);
511
+ else await reapSpawnedBroker(child, timing);
475
512
  } catch (error) {
476
513
  state = "cleanup-unverified";
477
514
  throw error;
@@ -486,6 +523,10 @@ function registerBrokerOwner(
486
523
  state = "ready";
487
524
  return true;
488
525
  },
526
+ setTarget(pid, incarnation): void {
527
+ targetPid = pid;
528
+ targetIncarnation = incarnation;
529
+ },
489
530
  };
490
531
  owners.set(agentDir, owner);
491
532
  return owner;
@@ -688,7 +729,11 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
688
729
  // that finished between our first read and the lock acquisition.
689
730
  const discoveredUnderLock = await reconcileBrokerGenerationForStartup(settings, deadline);
690
731
  if (discoveredUnderLock) return { kind: "external-discovery", discovery: discoveredUnderLock };
691
- const command = resolveSdkInternalSpawnCommand("broker-internal");
732
+ const command = resolveSdkInternalSpawnCommand(
733
+ process.platform === "win32" || initiator === "fixture-lease"
734
+ ? "broker-internal"
735
+ : "broker-trampoline-internal",
736
+ );
692
737
  spawnLog = await openBrokerSpawnLog(settings.agentDir);
693
738
  // A stale marker must never be misattributed to this spawn; clear it first.
694
739
  await clearBrokerStartupExitRecord(settings.agentDir);
@@ -696,23 +741,83 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
696
741
  const childSpawnedAt = Date.now();
697
742
  const child = spawn(command.file, [...command.args, "--agent-dir", settings.agentDir], {
698
743
  detached: true,
699
- stdio: ["ignore", "ignore", spawnLog ? spawnLog.handle.fd : "ignore"],
744
+ stdio: ["ignore", process.platform === "win32" ? "ignore" : "pipe", spawnLog ? spawnLog.handle.fd : "ignore"],
700
745
  env: brokerSpawnEnvironment(command, settings.env),
701
746
  ...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
702
747
  });
748
+ const isTrampoline = process.platform !== "win32" && initiator !== "fixture-lease";
703
749
  let spawnError: Error | undefined;
750
+ let childExited = false;
751
+ let childExitCode: number | null = null;
752
+ let childSignalCode: NodeJS.Signals | null = null;
753
+ let brokerDeathObserved = false;
704
754
  child.once("error", error => {
705
755
  spawnError = error;
706
756
  });
707
- const childIncarnation = child.pid === undefined ? undefined : brokerProcessIncarnation(child.pid);
757
+ child.once("close", (code, signal) => {
758
+ childExited = true;
759
+ childExitCode = code;
760
+ childSignalCode = signal;
761
+ });
762
+ let trampolineReported = !isTrampoline;
763
+ let spawnedBrokerPid = isTrampoline ? undefined : child.pid;
764
+ let spawnedBrokerIncarnation =
765
+ isTrampoline || child.pid === undefined ? undefined : brokerProcessIncarnation(child.pid);
708
766
  const owner = registerBrokerOwner(settings.agentDir, child);
767
+ if (isTrampoline && child.stdout) {
768
+ let output = "";
769
+ child.stdout.on("data", chunk => {
770
+ output += Buffer.from(chunk).toString("utf8");
771
+ const newline = output.indexOf("\n");
772
+ if (newline < 0) return;
773
+ const line = output.slice(0, newline).trim();
774
+ const [rawPid, rawIncarnation] = line?.split("\t") ?? [];
775
+ const pid = rawPid ? Number(rawPid) : NaN;
776
+ if (!Number.isSafeInteger(pid) || pid <= 0) return;
777
+ if (isProcessIncarnation(rawIncarnation)) {
778
+ const incarnation = rawIncarnation;
779
+ trampolineReported = true;
780
+ spawnedBrokerPid = pid;
781
+ spawnedBrokerIncarnation = incarnation;
782
+ owner.setTarget(pid, incarnation);
783
+ }
784
+ });
785
+ }
709
786
  child.unref();
710
787
  // The child holds its own duplicate of the descriptor. Failure to close the
711
788
  // parent's diagnostic handle must not discard exact ownership of a live child.
712
789
  await spawnLog?.handle.close().catch(() => undefined);
713
790
  let discoveryError: unknown;
714
791
  while (ensureBrokerTiming.now() < deadline) {
715
- if (spawnError || child.exitCode !== null || child.signalCode !== null) break;
792
+ if (
793
+ spawnError ||
794
+ (isTrampoline && childExited && (!trampolineReported || childExitCode !== 0 || childSignalCode !== null)) ||
795
+ (!isTrampoline && process.platform === "win32" && (child.exitCode !== null || child.signalCode !== null))
796
+ )
797
+ break;
798
+ if (
799
+ isTrampoline &&
800
+ trampolineReported &&
801
+ spawnedBrokerPid !== undefined &&
802
+ spawnedBrokerIncarnation !== undefined
803
+ ) {
804
+ const observed = observeProcessIncarnation(spawnedBrokerPid);
805
+ if (observed.status === "present" && observed.incarnation !== spawnedBrokerIncarnation) {
806
+ brokerDeathObserved = true;
807
+ break;
808
+ }
809
+ if (observed.status === "absent") {
810
+ brokerDeathObserved = true;
811
+ break;
812
+ }
813
+ }
814
+ // The trampoline's stdout line identifies the real broker. Do not read
815
+ // discovery until that line has been parsed, or a marker from the
816
+ // trampoline could be trusted as the broker's own startup evidence.
817
+ if (isTrampoline && !trampolineReported) {
818
+ await ensureBrokerTiming.sleep(50);
819
+ continue;
820
+ }
716
821
  try {
717
822
  const discovered = await readBrokerDiscoveryBeforeDeadline(
718
823
  settings.agentDir,
@@ -724,6 +829,20 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
724
829
  await ensureBrokerTiming.sleep(50);
725
830
  continue;
726
831
  }
832
+ if (isTrampoline && !childExited) {
833
+ await ensureBrokerTiming.sleep(50);
834
+ continue;
835
+ }
836
+ if (isTrampoline && spawnedBrokerPid !== undefined && spawnedBrokerIncarnation !== undefined) {
837
+ const observed = observeProcessIncarnation(spawnedBrokerPid);
838
+ if (
839
+ observed.status === "absent" ||
840
+ (observed.status === "present" && observed.incarnation !== spawnedBrokerIncarnation)
841
+ ) {
842
+ brokerDeathObserved = true;
843
+ break;
844
+ }
845
+ }
727
846
  if (owner.markReady(discovered)) {
728
847
  return initiator === "fixture-lease"
729
848
  ? { kind: "local-started-fixture", discovery: discovered, owner, child }
@@ -737,8 +856,13 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
737
856
  }
738
857
  await ensureBrokerTiming.sleep(50);
739
858
  }
740
- const exitedBeforeDiscovery = child.exitCode !== null || child.signalCode !== null;
741
- if (exitedBeforeDiscovery && child.exitCode === 0) {
859
+ const exitedBeforeDiscovery =
860
+ (isTrampoline &&
861
+ (!trampolineReported ||
862
+ (childExited && (childExitCode !== 0 || childSignalCode !== null)) ||
863
+ brokerDeathObserved)) ||
864
+ (!isTrampoline && process.platform === "win32" && (child.exitCode !== null || child.signalCode !== null));
865
+ if (!isTrampoline && exitedBeforeDiscovery && childExitCode === 0) {
742
866
  // A clean exit means another broker won the ownership lock (two ACP
743
867
  // processes racing a cold broker state, e.g. a provider probe and an
744
868
  // agent launch). The winner may publish its discovery right after our
@@ -765,16 +889,16 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
765
889
  const startupExitRecord = await readBrokerStartupExitRecord(settings.agentDir);
766
890
  const trustedMarker =
767
891
  marker &&
768
- child.pid !== undefined &&
769
- childIncarnation !== undefined &&
770
- marker.pid === child.pid &&
771
- marker.incarnation === childIncarnation
892
+ spawnedBrokerPid !== undefined &&
893
+ spawnedBrokerIncarnation !== undefined &&
894
+ marker.pid === spawnedBrokerPid &&
895
+ marker.incarnation === spawnedBrokerIncarnation
772
896
  ? marker
773
897
  : undefined;
774
898
  const trustedStartupExitRecord =
775
899
  startupExitRecord &&
776
- child.pid !== undefined &&
777
- startupExitRecord.pid === child.pid &&
900
+ spawnedBrokerPid !== undefined &&
901
+ startupExitRecord.pid === spawnedBrokerPid &&
778
902
  startupExitRecord.writtenAt >= childSpawnedAt
779
903
  ? startupExitRecord
780
904
  : undefined;
@@ -828,8 +952,20 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
828
952
  ? new Error(`Failed to spawn detached SDK broker: ${spawnError.message}`)
829
953
  : exitedBeforeDiscovery
830
954
  ? new BrokerStartupError({
831
- exitCode: child.exitCode ?? trustedStartupExitRecord?.exitCode ?? null,
832
- signal: child.signalCode ?? trustedStartupExitRecord?.signal ?? null,
955
+ exitCode: isTrampoline
956
+ ? (trustedStartupExitRecord?.exitCode ??
957
+ trustedMarker?.exitCode ??
958
+ childExitCode ??
959
+ child.exitCode ??
960
+ null)
961
+ : (childExitCode ?? child.exitCode ?? trustedStartupExitRecord?.exitCode ?? null),
962
+ signal: isTrampoline
963
+ ? (trustedStartupExitRecord?.signal ??
964
+ trustedMarker?.signal ??
965
+ childSignalCode ??
966
+ child.signalCode ??
967
+ null)
968
+ : (childSignalCode ?? child.signalCode ?? trustedStartupExitRecord?.signal ?? null),
833
969
  reason:
834
970
  brokerStartupExitReason(trustedStartupExitRecord) ?? brokerStartupFailureReason(trustedMarker),
835
971
  stderrExcerpt: spawnLogTail.length > 0 ? spawnLogTail : undefined,