@gajae-code/coding-agent 0.18.5 → 0.18.7

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 (133) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  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/model-profile-activation.d.ts +19 -0
  10. package/dist/types/config/settings.d.ts +4 -0
  11. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  12. package/dist/types/discovery/helpers.d.ts +2 -2
  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/exec/bash-shell-supervisor.d.ts +12 -0
  16. package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
  17. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  18. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  19. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  20. package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
  21. package/dist/types/sdk/broker/runtime.d.ts +1 -1
  22. package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
  23. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  24. package/dist/types/sdk/bus/telegram-daemon.d.ts +4 -2
  25. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  26. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  27. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  28. package/dist/types/sdk/host/host.d.ts +6 -0
  29. package/dist/types/sdk/index.d.ts +1 -0
  30. package/dist/types/sdk/router/session-router.d.ts +9 -0
  31. package/dist/types/sdk/session.d.ts +13 -0
  32. package/dist/types/session/agent-session.d.ts +5 -1
  33. package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
  34. package/dist/types/session/internal/task-artifact-owner-access.d.ts +17 -0
  35. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +3 -0
  36. package/dist/types/session/session-retirement.d.ts +47 -0
  37. package/dist/types/session/session-storage.d.ts +0 -46
  38. package/dist/types/session/session-transcript-header.d.ts +2 -0
  39. package/dist/types/session/task-artifact-owner-codec.d.ts +107 -0
  40. package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
  41. package/dist/types/session/task-artifact-owner.d.ts +10 -0
  42. package/dist/types/tools/ask-contract.d.ts +75 -254
  43. package/dist/types/tools/bash.d.ts +1 -1
  44. package/package.json +7 -7
  45. package/scripts/build-sdk-package-smoke.ts +5 -0
  46. package/scripts/resident-memory-bench.ts +174 -20
  47. package/src/capability/index.ts +1 -0
  48. package/src/capability/types.ts +4 -0
  49. package/src/cli/config-cli.ts +8 -18
  50. package/src/cli/public-command-entry.ts +5 -0
  51. package/src/cli/ssh-cli.ts +6 -3
  52. package/src/cli/web-search-cli.ts +9 -3
  53. package/src/cli-commands.ts +14 -0
  54. package/src/cli-main.ts +7 -8
  55. package/src/cli-ordinary.ts +197 -0
  56. package/src/cli.ts +25 -182
  57. package/src/commands/contribution-prep.ts +1 -3
  58. package/src/commands/sdk.ts +171 -21
  59. package/src/commands/update.ts +15 -1
  60. package/src/config/file-lock.ts +91 -2
  61. package/src/config/model-profile-activation.ts +70 -4
  62. package/src/config/model-profile-contract.ts +4 -1
  63. package/src/config/model-profiles.ts +23 -31
  64. package/src/config/model-resolver.ts +9 -1
  65. package/src/config/settings.ts +44 -4
  66. package/src/coordinator-mcp/event-webhook.ts +19 -14
  67. package/src/coordinator-mcp/policy.ts +14 -4
  68. package/src/coordinator-mcp/server.ts +142 -7
  69. package/src/coordinator-mcp/session-reaper.ts +9 -1
  70. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  71. package/src/discovery/helpers.ts +9 -3
  72. package/src/discovery/ssh.ts +8 -3
  73. package/src/edit/modes/replace.ts +6 -6
  74. package/src/edit/streaming.ts +15 -15
  75. package/src/exec/bash-shell-supervisor.ts +60 -3
  76. package/src/gjc-runtime/gc-runtime.ts +2 -1
  77. package/src/hindsight/config.ts +9 -3
  78. package/src/internal-urls/docs-index.generated.ts +11 -10
  79. package/src/memories/index.ts +8 -2
  80. package/src/modes/acp/acp-agent.ts +724 -370
  81. package/src/modes/components/tool-execution.ts +1 -1
  82. package/src/prompts/tools/ask.md +2 -0
  83. package/src/runtime-mcp/client.ts +13 -4
  84. package/src/runtime-mcp/elicitation.ts +142 -0
  85. package/src/sdk/broker/broker.ts +54 -1
  86. package/src/sdk/broker/daemon-entry.ts +67 -6
  87. package/src/sdk/broker/discovery.ts +8 -0
  88. package/src/sdk/broker/ensure.ts +156 -20
  89. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  90. package/src/sdk/broker/lifecycle.ts +334 -348
  91. package/src/sdk/broker/runtime.ts +5 -1
  92. package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
  93. package/src/sdk/broker/transport.ts +5 -1
  94. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  95. package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
  96. package/src/sdk/bus/index.ts +3 -3
  97. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  98. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  99. package/src/sdk/bus/telegram-daemon.ts +38 -3
  100. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  101. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  102. package/src/sdk/host/control/dispatch.ts +52 -4
  103. package/src/sdk/host/control/operations.ts +1 -1
  104. package/src/sdk/host/host.ts +6 -0
  105. package/src/sdk/host/session-runtime.test.ts +83 -4
  106. package/src/sdk/host/session-runtime.ts +15 -7
  107. package/src/sdk/index.ts +1 -0
  108. package/src/sdk/lifecycle-session.ts +1 -0
  109. package/src/sdk/mcp/server.ts +15 -3
  110. package/src/sdk/protocol/operation-registry.ts +1 -1
  111. package/src/sdk/router/session-router.ts +26 -3
  112. package/src/sdk/session.ts +51 -8
  113. package/src/session/agent-session.ts +169 -43
  114. package/src/session/blob-store.ts +78 -33
  115. package/src/session/internal/managed-session-storage.ts +203 -4
  116. package/src/session/internal/task-artifact-owner-access.ts +229 -0
  117. package/src/session/internal/task-artifact-owner-transcript.ts +68 -0
  118. package/src/session/session-retirement.ts +155 -0
  119. package/src/session/session-storage.ts +1 -160
  120. package/src/session/session-transcript-header.ts +17 -0
  121. package/src/session/task-artifact-owner-codec.ts +578 -0
  122. package/src/session/task-artifact-owner-retirement.ts +475 -0
  123. package/src/session/task-artifact-owner.ts +289 -0
  124. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  125. package/src/setup/hermes-setup.ts +4 -1
  126. package/src/slash-commands/builtin-registry.ts +8 -5
  127. package/src/tools/ask-contract.test.ts +76 -1
  128. package/src/tools/ask-contract.ts +57 -6
  129. package/src/tools/ask.ts +16 -7
  130. package/src/tools/bash.ts +38 -29
  131. package/src/tools/descriptors.test.ts +29 -0
  132. package/src/tools/tool-catalog.generated.ts +7 -4
  133. package/src/web/search/index.ts +2 -2
@@ -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;
@@ -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>
@@ -451,6 +451,9 @@ function isModernMethodNotFound(error: unknown): boolean {
451
451
  * The original request is retried with `inputResponses` and a verbatim
452
452
  * `requestState` echo under a fresh JSON-RPC id, at most MAX_MRTR_RETRIES times;
453
453
  * the retry is fenced to the originating exchange by a stable correlation id.
454
+ * `roots/list` input requests are answered locally; every other input request
455
+ * requires `options.inputHandler`, and its absence fails the exchange before any
456
+ * retry is sent (no write replay, no implicit accept).
454
457
  */
455
458
  async function requestWithInputHandling<T>(
456
459
  connection: MCPServerConnection,
@@ -474,17 +477,20 @@ async function requestWithInputHandling<T>(
474
477
  if (!inputRequired) return stripResultType(initial);
475
478
 
476
479
  const handler = options?.inputHandler;
477
- if (!handler) {
478
- throw new MCPExpectedFailure(
480
+ const correlationId = crypto.randomUUID();
481
+
482
+ const missingHandler = () =>
483
+ new MCPExpectedFailure(
479
484
  new Error(
480
485
  `MCP server "${connection.name}" requested additional input (input_required) for ${method}, but no interactive input handler is available`,
481
486
  ),
482
487
  );
483
- }
484
- const correlationId = crypto.randomUUID();
485
488
 
486
489
  for (let attempt = 0; attempt < MAX_MRTR_RETRIES; attempt++) {
487
490
  const inputResponses: Record<string, unknown> = {};
491
+ // A requestState-only round has no local answer to give; without an
492
+ // interactive handler it is never replayed unattended.
493
+ if (!handler && Object.keys(inputRequired.inputRequests).length === 0) throw missingHandler();
488
494
  for (const [key, request] of Object.entries(inputRequired.inputRequests)) {
489
495
  if (options?.signal?.aborted) {
490
496
  throw options.signal.reason instanceof Error ? options.signal.reason : new Error("Aborted");
@@ -494,6 +500,9 @@ async function requestWithInputHandling<T>(
494
500
  inputResponses[key] = await defaultRequestHandler("roots/list", undefined);
495
501
  continue;
496
502
  }
503
+ // Everything else (elicitation, sampling, ...) needs a real interactive
504
+ // handler. It is never answered implicitly; fail before any retry is sent.
505
+ if (!handler) throw missingHandler();
497
506
  const outcome = await handler(key, request, {
498
507
  serverName: connection.name,
499
508
  originMethod: method,
@@ -0,0 +1,142 @@
1
+ import { untilAborted } from "@gajae-code/utils";
2
+ import type { ExtensionUIContext } from "../extensibility/extensions/types";
3
+ import type { AskAnswerSource, AskRemoteReceipt } from "../tools";
4
+ import type { MCPInputRequestHandler } from "./types";
5
+
6
+ export interface MCPFormInputDependencies {
7
+ getUi(): { ui?: Pick<ExtensionUIContext, "select" | "input">; hasUI: boolean };
8
+ getAskAnswerSource(): AskAnswerSource | undefined;
9
+ }
10
+
11
+ interface FormField {
12
+ key: string;
13
+ label: string;
14
+ type: "string" | "boolean";
15
+ choices?: string[];
16
+ }
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+
22
+ function displayText(value: string): string {
23
+ return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ").slice(0, 4000);
24
+ }
25
+
26
+ /** Unsupported constraints are rejected, never silently discarded. */
27
+ function decodeField(schema: unknown): FormField | undefined {
28
+ if (!isRecord(schema) || schema.type !== "object" || !isRecord(schema.properties)) return undefined;
29
+ const allowed = new Set(["type", "properties", "required", "additionalProperties", "title", "description"]);
30
+ if (Object.keys(schema).some(key => !allowed.has(key))) return undefined;
31
+ if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") return undefined;
32
+ const entries = Object.entries(schema.properties);
33
+ if (entries.length !== 1) return undefined;
34
+ const [key, property] = entries[0]!;
35
+ if (!isRecord(property) || (property.type !== "string" && property.type !== "boolean")) return undefined;
36
+ if (
37
+ schema.required !== undefined &&
38
+ (!Array.isArray(schema.required) || schema.required.some(value => value !== key))
39
+ )
40
+ return undefined;
41
+ const propertyKeys = new Set(["type", "title", "description", "enum"]);
42
+ if (Object.keys(property).some(name => !propertyKeys.has(name))) return undefined;
43
+ if (property.title !== undefined && typeof property.title !== "string") return undefined;
44
+ if (property.description !== undefined && typeof property.description !== "string") return undefined;
45
+ let choices: string[] | undefined;
46
+ if (property.enum !== undefined) {
47
+ if (
48
+ property.type !== "string" ||
49
+ !Array.isArray(property.enum) ||
50
+ property.enum.length === 0 ||
51
+ !property.enum.every(value => typeof value === "string") ||
52
+ new Set(property.enum).size !== property.enum.length ||
53
+ property.enum.some(value => displayText(value) !== value)
54
+ )
55
+ return undefined;
56
+ choices = property.enum;
57
+ }
58
+ const description = typeof property.description === "string" ? ` — ${property.description}` : "";
59
+ return { key, label: displayText(`${property.title ?? key}${description}`), type: property.type, choices };
60
+ }
61
+
62
+ /** Register on an owned manager; dependencies are resolved per call for late UI registration. */
63
+ export function createMCPFormInputHandler(deps: MCPFormInputDependencies): MCPInputRequestHandler {
64
+ return async (_key, request, context) => {
65
+ if (context.signal?.aborted) return { kind: "failed", reason: "cancelled" };
66
+ if (request.method !== "elicitation/create") return { kind: "failed", reason: "unavailable" };
67
+ const params = request.params;
68
+ if (!params || typeof params.message !== "string" || (params.mode !== undefined && params.mode !== "form"))
69
+ return { kind: "failed", reason: "unavailable", message: "Unsupported MCP form request" };
70
+ const field = decodeField(params.requestedSchema);
71
+ if (!field) return { kind: "failed", reason: "unavailable", message: "Unsupported MCP form schema" };
72
+ const local = deps.getUi();
73
+ const ui = local.hasUI ? local.ui : undefined;
74
+ const source = ui ? undefined : deps.getAskAnswerSource();
75
+ if (!ui && !source)
76
+ return { kind: "failed", reason: "unavailable", message: "No user input surface is available" };
77
+ const signal = context.signal;
78
+ const ask = async (question: string, choices?: string[]): Promise<string | undefined> => {
79
+ return untilAborted(signal, async () => {
80
+ if (ui)
81
+ return choices ? ui.select(question, choices, { signal }) : ui.input(question, undefined, { signal });
82
+ if (!source) return undefined;
83
+ const answer = source.awaitAnswerRequest
84
+ ? await source.awaitAnswerRequest(
85
+ {
86
+ question,
87
+ options: choices ?? [],
88
+ interaction: choices ? "selector" : "custom_editor",
89
+ controls: [],
90
+ },
91
+ signal,
92
+ )
93
+ : await source.awaitAnswer(question, choices ?? [], signal);
94
+ let receipt: AskRemoteReceipt | undefined;
95
+ let value: string | undefined;
96
+ if (typeof answer === "string") value = answer;
97
+ else if (answer) {
98
+ receipt = answer;
99
+ if (answer.interaction.kind === "value") value = answer.interaction.value;
100
+ }
101
+ if (signal?.aborted) {
102
+ await receipt?.settle({ kind: "resolve_without_commit", reason: "aborted" });
103
+ return undefined;
104
+ }
105
+ if (receipt?.interaction.kind === "control") {
106
+ await receipt.settle({ kind: "invalid", reason: "invalid_control" });
107
+ throw new Error("Invalid MCP form control");
108
+ }
109
+ if (value !== undefined && choices && !choices.includes(value)) {
110
+ await receipt?.settle({ kind: "invalid", reason: "invalid_option" });
111
+ throw new Error("Invalid MCP form answer");
112
+ }
113
+ const settlement = await receipt?.settle(
114
+ value === undefined ? { kind: "resolve_without_commit", reason: "cancelled" } : { kind: "commit" },
115
+ );
116
+ if (receipt && value !== undefined && settlement?.kind !== "committed") return undefined;
117
+ return value;
118
+ });
119
+ };
120
+ try {
121
+ const title = `MCP server ${displayText(context.serverName)} requests input (untrusted server text): ${displayText(params.message)}`;
122
+ const action = await ask(title, ["Accept", "Decline"]);
123
+ if (signal?.aborted) return { kind: "failed", reason: "cancelled" };
124
+ if (action === undefined) return { kind: "result", result: { action: "cancel" } };
125
+ if (action === "Decline") return { kind: "result", result: { action: "decline" } };
126
+ if (action !== "Accept") return { kind: "failed", reason: "error", message: "Invalid MCP form action" };
127
+ const choices = field.type === "boolean" ? ["Yes", "No"] : field.choices;
128
+ const answer = await ask(
129
+ `MCP server ${displayText(context.serverName)} (untrusted server text): ${field.label}`,
130
+ choices,
131
+ );
132
+ if (signal?.aborted) return { kind: "failed", reason: "cancelled" };
133
+ if (answer === undefined) return { kind: "result", result: { action: "cancel" } };
134
+ if (choices && !choices.includes(answer))
135
+ return { kind: "failed", reason: "error", message: "Invalid MCP form answer" };
136
+ const value = field.type === "boolean" ? answer === "Yes" : answer;
137
+ return { kind: "result", result: { action: "accept", content: { [field.key]: value } } };
138
+ } catch {
139
+ return { kind: "failed", reason: signal?.aborted ? "cancelled" : "error" };
140
+ }
141
+ };
142
+ }
@@ -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>();
@@ -1515,6 +1525,7 @@ export class Broker {
1515
1525
  #publishedAt: bigint | null = null;
1516
1526
  #startedAt: bigint | null = null;
1517
1527
  #watchInFlight = false;
1528
+ #checkpointInFlight = false;
1518
1529
  #stopping = false;
1519
1530
  #transport: BrokerTransport | null = null;
1520
1531
  #heartbeatTimer: NodeJS.Timeout | null = null;
@@ -3776,6 +3787,7 @@ export class Broker {
3776
3787
  this.#publishedAt = null;
3777
3788
  this.#startedAt = process.hrtime.bigint();
3778
3789
  this.#watchInFlight = false;
3790
+ this.#checkpointInFlight = false;
3779
3791
  this.#throwIfStartupAborted();
3780
3792
  await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
3781
3793
  this.#throwIfStartupAborted();
@@ -3868,6 +3880,10 @@ export class Broker {
3868
3880
  ownerId: this.#owner,
3869
3881
  pid: process.pid,
3870
3882
  incarnation,
3883
+ // Observation capability: the startup-fixed generation and the protocol the
3884
+ // broker answers. An old broker simply omits both.
3885
+ diagnosticGeneration: this.#diagnosticGeneration,
3886
+ diagnosticProtocol: 1,
3871
3887
  host: "127.0.0.1",
3872
3888
  port,
3873
3889
  url: `ws://127.0.0.1:${port}`,
@@ -4178,7 +4194,7 @@ export class Broker {
4178
4194
  return;
4179
4195
  }
4180
4196
  this.#startupAdmissions.reopen();
4181
- if (this.#publicationState === "healthy-owned") await this.#checkpointSessionHeartbeats();
4197
+ if (this.#publicationState === "healthy-owned") void this.#checkpointSessionHeartbeats();
4182
4198
  return;
4183
4199
  }
4184
4200
  this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
@@ -4219,10 +4235,14 @@ export class Broker {
4219
4235
  return await this.index.checkpointLiveHeartbeats(now);
4220
4236
  }
4221
4237
  async #checkpointSessionHeartbeats(): Promise<void> {
4238
+ if (this.#checkpointInFlight || this.#stopping) return;
4239
+ this.#checkpointInFlight = true;
4222
4240
  try {
4223
4241
  await this.heartbeatSessions();
4224
4242
  } catch (error) {
4225
4243
  logger.warn(`sdk broker: session heartbeat checkpoint failed: ${String(error)}`);
4244
+ } finally {
4245
+ this.#checkpointInFlight = false;
4226
4246
  }
4227
4247
  }
4228
4248
  async #complete(
@@ -4246,6 +4266,7 @@ export class Broker {
4246
4266
  };
4247
4267
  (mode === "lost-root" ? logger.warn : logger.info)("sdk broker: exiting", exitRecord);
4248
4268
  this.#stopping = true;
4269
+ this.#checkpointInFlight = false;
4249
4270
  this.#publicationState = "stopping";
4250
4271
  // A lost-root broker has been fenced: it no longer owns the published root, and
4251
4272
  // its settlement is bounded, so any startup still queued behind it would be
@@ -4728,6 +4749,32 @@ export class Broker {
4728
4749
  ? entry.response
4729
4750
  : error("terminal_uncertain", "lifecycle outcome has no recorded response");
4730
4751
  }
4752
+ /**
4753
+ * Published diagnostics for observation clients (SPEC "Result contract").
4754
+ *
4755
+ * Everything here is captured once at startup and never recomputed, so a later source
4756
+ * or package change cannot alter what a running broker reports. No token, socket
4757
+ * coordinate, argv or environment value is included; the identity fields are the
4758
+ * internal owner/process/root binding the observer already knows from discovery.
4759
+ */
4760
+ #publishedDiagnostics(): Promise<BrokerResponse> {
4761
+ // Observation is answered ONLY from an already-owned healthy retained publication.
4762
+ // A broker that owns no publication refuses here and recovers nothing: it does not
4763
+ // publish, ensure, restart or touch the authority to answer.
4764
+ if (this.#publication === null || this.#publicationState !== "healthy-owned") {
4765
+ return Promise.resolve(error("unavailable", "broker publication authority is not owned"));
4766
+ }
4767
+ return Promise.resolve({
4768
+ ok: true,
4769
+ result: {
4770
+ diagnosticProtocol: 1,
4771
+ generation: this.#diagnosticGeneration,
4772
+ build: this.#startupBuild,
4773
+ identity: { ownerId: this.#owner, pid: process.pid, agentRoot: this.settings.agentDir },
4774
+ },
4775
+ });
4776
+ }
4777
+
4731
4778
  // Wire requests are readiness-gated by BrokerTransport before reaching this dispatcher.
4732
4779
  handleRequest(operation: string, input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
4733
4780
  if (operation === "broker.status") return Promise.resolve({ ok: true, result: this.status() });
@@ -4768,6 +4815,7 @@ export class Broker {
4768
4815
  const spawnClose = await this.#maybeCloseSpawnChild(input);
4769
4816
  if (spawnClose) return spawnClose;
4770
4817
  }
4818
+ if (operation === "broker.diagnostics") return this.#publishedDiagnostics();
4771
4819
  if (operation === "session.control") return this.#sessionControl(input, idempotencyKey);
4772
4820
  if (operation === "session.lookup") {
4773
4821
  const lookup = publicLifecycleLookupInput(input);
@@ -4937,6 +4985,11 @@ export class Broker {
4937
4985
  ...(operation === "session.close" && typeof input.sessionId === "string"
4938
4986
  ? { intendedSessionId: input.sessionId }
4939
4987
  : {}),
4988
+ ...(operation === "session.close" &&
4989
+ typeof input.endpointGeneration === "number" &&
4990
+ typeof input.endpointIncarnation === "string"
4991
+ ? { closeAuthorityBound: true }
4992
+ : {}),
4940
4993
  };
4941
4994
  if (!this.ledger.get(identity)) {
4942
4995
  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;