@gajae-code/coding-agent 0.18.5 → 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 (91) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/dist/types/cli-commands.d.ts +10 -0
  3. package/dist/types/cli-ordinary.d.ts +13 -0
  4. package/dist/types/cli.d.ts +12 -3
  5. package/dist/types/commands/contribution-prep.d.ts +0 -3
  6. package/dist/types/commands/sdk.d.ts +3 -0
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-profile-activation.d.ts +19 -0
  9. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  10. package/dist/types/edit/modes/replace.d.ts +3 -3
  11. package/dist/types/edit/streaming.d.ts +2 -2
  12. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  13. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  14. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  16. package/dist/types/sdk/broker/runtime.d.ts +1 -1
  17. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  18. package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
  19. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  20. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  21. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  22. package/dist/types/sdk/index.d.ts +1 -0
  23. package/dist/types/sdk/router/session-router.d.ts +2 -0
  24. package/dist/types/sdk/session.d.ts +13 -0
  25. package/dist/types/session/agent-session.d.ts +5 -1
  26. package/dist/types/tools/ask-contract.d.ts +75 -254
  27. package/dist/types/tools/bash.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/scripts/build-sdk-package-smoke.ts +5 -0
  30. package/src/cli/public-command-entry.ts +5 -0
  31. package/src/cli/ssh-cli.ts +6 -3
  32. package/src/cli/web-search-cli.ts +9 -3
  33. package/src/cli-commands.ts +14 -0
  34. package/src/cli-ordinary.ts +197 -0
  35. package/src/cli.ts +25 -182
  36. package/src/commands/contribution-prep.ts +1 -3
  37. package/src/commands/sdk.ts +59 -0
  38. package/src/commands/update.ts +15 -1
  39. package/src/config/file-lock.ts +91 -2
  40. package/src/config/model-profile-activation.ts +70 -4
  41. package/src/config/model-profile-contract.ts +4 -1
  42. package/src/config/model-profiles.ts +23 -31
  43. package/src/config/model-resolver.ts +9 -1
  44. package/src/coordinator-mcp/event-webhook.ts +19 -14
  45. package/src/coordinator-mcp/policy.ts +14 -4
  46. package/src/coordinator-mcp/server.ts +142 -7
  47. package/src/coordinator-mcp/session-reaper.ts +9 -1
  48. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  49. package/src/edit/modes/replace.ts +6 -6
  50. package/src/edit/streaming.ts +15 -15
  51. package/src/exec/bash-shell-supervisor.ts +60 -3
  52. package/src/internal-urls/docs-index.generated.ts +10 -9
  53. package/src/memories/index.ts +8 -2
  54. package/src/modes/acp/acp-agent.ts +649 -367
  55. package/src/modes/components/tool-execution.ts +1 -1
  56. package/src/prompts/tools/ask.md +2 -0
  57. package/src/sdk/broker/broker.ts +46 -0
  58. package/src/sdk/broker/daemon-entry.ts +67 -6
  59. package/src/sdk/broker/discovery.ts +8 -0
  60. package/src/sdk/broker/ensure.ts +156 -20
  61. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  62. package/src/sdk/broker/lifecycle.ts +225 -341
  63. package/src/sdk/broker/runtime.ts +5 -1
  64. package/src/sdk/broker/transport.ts +5 -1
  65. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  66. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  67. package/src/sdk/bus/telegram-daemon.ts +34 -1
  68. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  69. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  70. package/src/sdk/host/control/dispatch.ts +52 -4
  71. package/src/sdk/host/control/operations.ts +1 -1
  72. package/src/sdk/host/session-runtime.test.ts +40 -2
  73. package/src/sdk/host/session-runtime.ts +13 -5
  74. package/src/sdk/index.ts +1 -0
  75. package/src/sdk/lifecycle-session.ts +1 -0
  76. package/src/sdk/protocol/operation-registry.ts +1 -1
  77. package/src/sdk/router/session-router.ts +7 -1
  78. package/src/sdk/session.ts +37 -8
  79. package/src/session/agent-session.ts +114 -39
  80. package/src/session/blob-store.ts +78 -33
  81. package/src/session/internal/managed-session-storage.ts +96 -3
  82. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  83. package/src/setup/hermes-setup.ts +4 -1
  84. package/src/slash-commands/builtin-registry.ts +8 -5
  85. package/src/tools/ask-contract.test.ts +76 -1
  86. package/src/tools/ask-contract.ts +57 -6
  87. package/src/tools/ask.ts +16 -7
  88. package/src/tools/bash.ts +38 -29
  89. package/src/tools/descriptors.test.ts +29 -0
  90. package/src/tools/tool-catalog.generated.ts +7 -4
  91. package/src/web/search/index.ts +2 -2
@@ -6,7 +6,11 @@ import { currentExecutablePath } from "@gajae-code/natives";
6
6
 
7
7
  import internalSourceMarker from "./internal-source-marker-2178.txt" with { type: "file" };
8
8
 
9
- export type SdkInternalAction = "broker-internal" | "session-host-internal" | "stderr-drain-internal";
9
+ export type SdkInternalAction =
10
+ | "broker-internal"
11
+ | "broker-trampoline-internal"
12
+ | "session-host-internal"
13
+ | "stderr-drain-internal";
10
14
 
11
15
  export type SdkInternalSpawnCommand =
12
16
  | {
@@ -1,4 +1,5 @@
1
1
  import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { logger } from "@gajae-code/utils";
2
3
  import type { ServerWebSocket } from "bun";
3
4
  import type { Broker } from "./broker";
4
5
 
@@ -7,6 +8,8 @@ const PROTOCOL_VERSION = 3;
7
8
  const MAX_BROKER_JSON_FRAME_BYTES = 4 * 1024 * 1024;
8
9
 
9
10
  const BROKER_OPERATIONS = new Set([
11
+ // Observation-only published diagnostics; it starts, ensures and recovers nothing.
12
+ "broker.diagnostics",
10
13
  "session.list",
11
14
  "session.get_endpoint",
12
15
  "session.create",
@@ -160,7 +163,8 @@ export class BrokerTransport {
160
163
  try {
161
164
  const result = await this.#broker.handleRequest(frame.operation, frame.input, frame.idempotencyKey);
162
165
  send(socket, { type: "broker_response", id: frame.id, ...result });
163
- } catch {
166
+ } catch (error) {
167
+ logger.warn(`sdk broker request failed: ${error instanceof Error ? error.message : String(error)}`);
164
168
  sendError(socket, frame.id, "unavailable", "broker request failed");
165
169
  }
166
170
  }
@@ -297,7 +297,7 @@ export const SDK_LIFECYCLE_ROUTER_PROTOCOL_VERSION = 1;
297
297
  * Generation 193 gates streamed content on negotiated observer capabilities,
298
298
  * so existing owners are replaced before exposing the new observer path.
299
299
  */
300
- export const DAEMON_GENERATION = 199;
300
+ export const DAEMON_GENERATION = 200;
301
301
 
302
302
  /**
303
303
  * Serving-compatibility boundary for daemon lifecycle requests. Epoch 7
@@ -53,6 +53,16 @@ const nodeFs: TelegramDaemonFs = {
53
53
  const DEFAULT_GRACEFUL_TIMEOUT_MS = 8_000;
54
54
  const DEFAULT_KILL_TIMEOUT_MS = 3_000;
55
55
  const DEFAULT_WAIT_STEP_MS = 25;
56
+ // Windows process handles cannot deliver SIGTERM cooperatively. The request
57
+ // file is therefore the sole notification channel, so a captured owner gets
58
+ // enough time to finish its long poll, network slack, and shutdown joins before
59
+ // an explicit force request may escalate. Short explicit timeouts are clamped
60
+ // to this floor only for hard-termination authorities; POSIX keeps 8 seconds.
61
+ const HARD_TERMINATION_LONG_POLL_MS = 25_000;
62
+ const HARD_TERMINATION_NETWORK_SLACK_MS = 5_000;
63
+ const HARD_TERMINATION_SHUTDOWN_JOINS_MS = 10_000;
64
+ const HARD_TERMINATION_GRACE_MS =
65
+ HARD_TERMINATION_LONG_POLL_MS + HARD_TERMINATION_NETWORK_SLACK_MS + HARD_TERMINATION_SHUTDOWN_JOINS_MS;
56
66
 
57
67
  export interface TelegramDaemonControlRequest {
58
68
  version: 1;
@@ -641,17 +651,6 @@ export class TelegramDaemonController implements BuiltInDaemonController {
641
651
  const signalResult = attestedLegacyOwner
642
652
  ? await this.#signalAttestedLegacyOwner(attestedLegacyOwner, fp, chatId, "SIGTERM")
643
653
  : await this.signalCapturedOwner(modernCapturedOwner!, fp, chatId, "SIGTERM");
644
- if (signalResult === "hard_termination") {
645
- await this.clearOwnRequest(requestId);
646
- return this.result(
647
- action,
648
- false,
649
- "telegram daemon has hard process authority; refusing cooperative SIGTERM",
650
- before,
651
- await this.status(),
652
- warnings,
653
- );
654
- }
655
654
  if (signalResult === "ownership_changed") {
656
655
  await this.clearOwnRequest(requestId);
657
656
  return this.result(
@@ -667,7 +666,11 @@ export class TelegramDaemonController implements BuiltInDaemonController {
667
666
  // death through the normal path — waitForPidDeath returns immediately for a
668
667
  // dead pid, so stop succeeds and reload proceeds to spawn the replacement.
669
668
 
670
- let dead = await this.waitForPidDeath(oldPid, gracefulTimeoutMs);
669
+ const cooperativeGraceMs =
670
+ signalResult === "hard_termination"
671
+ ? Math.max(gracefulTimeoutMs, HARD_TERMINATION_GRACE_MS)
672
+ : gracefulTimeoutMs;
673
+ let dead = await this.waitForPidDeath(oldPid, cooperativeGraceMs);
671
674
  if (!dead) {
672
675
  // Old pid still alive after the cooperative SIGTERM. Inspect current ownership.
673
676
  const currentSnapshot = await readOwnerFreshnessSnapshot({ settings: this.settings, fs: this.fsImpl });
@@ -362,6 +362,7 @@ const BOT_API_RETRY_ATTEMPTS = 3;
362
362
  // Backoff after a failed getUpdates long-poll so a persistent outage does not
363
363
  // busy-loop the daemon.
364
364
  const POLL_BACKOFF_MS = 1_000;
365
+ const CONTROL_STOP_WATCH_INTERVAL_MS = 750;
365
366
  const AUTOMATIC_RELOAD_COOLDOWN_MS = 10 * 60 * 1_000;
366
367
  // Default freshness-poll window a cooldown contender waits for a sibling's
367
368
  // replacement daemon to publish a fresh ready owner before reloading itself.
@@ -4567,6 +4568,7 @@ export class TelegramNotificationDaemon {
4567
4568
  private stopRequested = false;
4568
4569
  /** First observed stop cause wins, including an explicit stop. */
4569
4570
  #stopCause: TelegramDaemonStopCause | undefined;
4571
+ #controlStopCheck: Promise<boolean> | undefined;
4570
4572
  /** Signal cause resolution is asynchronous because control requests are file-backed. */
4571
4573
  #stopCauseResolution: Promise<void> | undefined;
4572
4574
 
@@ -12875,6 +12877,9 @@ export class TelegramNotificationDaemon {
12875
12877
  throw new Error("validation forum destination must be the exact supergroup forum returned by getChat");
12876
12878
  }
12877
12879
  let ownershipProved = false;
12880
+ let controlWatchTimer: Timer | NodeJS.Timeout | undefined;
12881
+ let controlWatchActive = true;
12882
+ const clearTimeoutImpl = this.opts.clearTimeoutImpl ?? clearTimeout;
12878
12883
  try {
12879
12884
  const renewed = await renewDaemonHeartbeat({
12880
12885
  settings: this.opts.settings,
@@ -12935,6 +12940,19 @@ export class TelegramNotificationDaemon {
12935
12940
  }
12936
12941
  await this.#attachmentRouter.start();
12937
12942
  let idleSince = this.runtime.now();
12943
+ const setTimeoutImpl = this.opts.setTimeoutImpl ?? setTimeout;
12944
+ const watchControl = () => {
12945
+ if (!controlWatchActive || !this.running || !this.opts.control) return;
12946
+ controlWatchTimer = setTimeoutImpl(() => {
12947
+ controlWatchTimer = undefined;
12948
+ void this.controlStopRequested()
12949
+ .catch(() => false)
12950
+ .finally(() => {
12951
+ if (controlWatchActive && this.running) watchControl();
12952
+ });
12953
+ }, CONTROL_STOP_WATCH_INTERVAL_MS);
12954
+ };
12955
+ if (this.opts.control) watchControl();
12938
12956
  while (this.running) {
12939
12957
  if (await this.controlStopRequested()) break;
12940
12958
  // A thrown renewal (e.g. a transient Windows EPERM/EACCES/EBUSY while
@@ -13003,6 +13021,8 @@ export class TelegramNotificationDaemon {
13003
13021
  this.requestStop("unexpected_exception");
13004
13022
  throw error;
13005
13023
  } finally {
13024
+ controlWatchActive = false;
13025
+ if (controlWatchTimer !== undefined) clearTimeoutImpl(controlWatchTimer);
13006
13026
  this.requestStop();
13007
13027
  await this.#stopCauseResolution;
13008
13028
  this.running = false;
@@ -13089,8 +13109,21 @@ export class TelegramNotificationDaemon {
13089
13109
  private async controlStopRequested(): Promise<boolean> {
13090
13110
  if (this.runtime.stopRequested) return true;
13091
13111
  if (!this.opts.control) return false;
13112
+ if (this.#controlStopCheck) return this.#controlStopCheck;
13113
+ const check = this.checkControlStopRequested();
13114
+ this.#controlStopCheck = check;
13115
+ try {
13116
+ return await check;
13117
+ } finally {
13118
+ if (this.#controlStopCheck === check) this.#controlStopCheck = undefined;
13119
+ }
13120
+ }
13121
+
13122
+ private async checkControlStopRequested(): Promise<boolean> {
13092
13123
  try {
13093
- const requested = await this.opts.control.shouldStop(this.opts.ownerId);
13124
+ const control = this.opts.control;
13125
+ if (!control) return false;
13126
+ const requested = await control.shouldStop(this.opts.ownerId);
13094
13127
  if (!requested) return false;
13095
13128
  this.#resolveControlStopCause("stop");
13096
13129
  this.requestStop();
@@ -0,0 +1,247 @@
1
+ /** Isolated `gjc sdk diagnostics` parser and renderer.
2
+ *
3
+ * Reached from the inert entry before any effectful branch, so this module must
4
+ * stay a pure leaf: no command registry, session CLI, runtime globals, managed
5
+ * owner admission, malloc guard, evidence publisher, broker client, directory
6
+ * resolver or logger import. Help and usage never resolve a root or open a file,
7
+ * and every outcome is a fixed bounded string with a fixed exit code.
8
+ *
9
+ * Observation itself must never start, ensure, retire, restart or recover a
10
+ * broker. The read-only observation facade (DESIGN §B3) is wired: `observe()`
11
+ * below routes to it and to nothing else, so this command can report only what a
12
+ * supporting broker publishes to the read-only reader. An unsupported runtime
13
+ * tuple, a missing or mismatched native artifact and an old broker without a
14
+ * published generation all still fail closed with the fixed `unsupported`
15
+ * outcome.
16
+ */
17
+
18
+ const USAGE_FAMILY = `usage: gjc sdk diagnostics <command>
19
+
20
+ Read-only broker observation. Never starts, ensures, retires, restarts or
21
+ recovers a broker, and never spawns a host.
22
+
23
+ commands:
24
+ broker Observe an already running broker publication.
25
+
26
+ flags:
27
+ -h, --help Show this help.
28
+ `;
29
+
30
+ const USAGE_BROKER = `usage: gjc sdk diagnostics broker [--agent-dir <dir>] [--expected-generation <id>] [--timeout-ms <ms>] [--json]
31
+
32
+ Observe an already running broker publication without starting, ensuring,
33
+ retiring, restarting or recovering it.
34
+
35
+ flags:
36
+ --agent-dir <dir> Absolute agent directory holding the broker publication.
37
+ --expected-generation <id> Require this exact publication generation (32 hex characters).
38
+ --timeout-ms <ms> Overall observation deadline, 1-10000 (default 2000).
39
+ --json Emit the observation document as JSON.
40
+ -h, --help Show this help.
41
+ `;
42
+
43
+ /** Fixed messages keyed by reason; never exception text, never a path. */
44
+ const UNAVAILABLE_MESSAGES = {
45
+ absent: "No broker publication was found for the selected agent directory.",
46
+ stale: "The broker publication is stale.",
47
+ incompatible: "The broker uses an unsupported diagnostic protocol.",
48
+ authentication_failed: "The broker refused the diagnostic authentication.",
49
+ unsupported: "Read-only broker observation is not supported by this runtime or build.",
50
+ generation_mismatch: "The observed broker publication is a different incarnation.",
51
+ transport_unavailable: "The broker diagnostic transport is unavailable.",
52
+ timeout: "The broker observation deadline expired.",
53
+ invalid_response: "The broker returned an invalid diagnostic response.",
54
+ unsafe_discovery: "The broker publication path failed read-only safety validation.",
55
+ } as const;
56
+
57
+ type UnavailableReason = keyof typeof UNAVAILABLE_MESSAGES;
58
+
59
+ type BrokerObservation =
60
+ | {
61
+ schema: "gjc.broker-observation";
62
+ version: 1;
63
+ ok: false;
64
+ observedAt: string;
65
+ unavailable: { reason: UnavailableReason; message: string };
66
+ }
67
+ | {
68
+ schema: "gjc.broker-observation";
69
+ version: 1;
70
+ ok: true;
71
+ observedAt: string;
72
+ broker: {
73
+ generation: string;
74
+ build: { packageVersion: string; buildId: string | null };
75
+ diagnosticProtocol: 1;
76
+ };
77
+ };
78
+
79
+ const MAX_ARGV_BYTES = 8192;
80
+ const MAX_PATH_BYTES = 4096;
81
+ const MAX_PATH_COMPONENTS = 128;
82
+ const DEFAULT_TIMEOUT_MS = 2000;
83
+ const MIN_TIMEOUT_MS = 1;
84
+ const MAX_TIMEOUT_MS = 10000;
85
+
86
+ type Parsed =
87
+ | { kind: "help"; text: string }
88
+ | { kind: "usage" }
89
+ | {
90
+ kind: "observe";
91
+ agentDir: string | undefined;
92
+ expectedGeneration: string | undefined;
93
+ timeoutMs: number;
94
+ json: boolean;
95
+ };
96
+
97
+ function isHelpToken(token: string): boolean {
98
+ return token === "--help" || token === "-h";
99
+ }
100
+
101
+ function validAgentDir(value: string): boolean {
102
+ if (value.length === 0 || value.includes("\0")) return false;
103
+ if (!value.startsWith("/")) return false;
104
+ if (Buffer.byteLength(value, "utf8") > MAX_PATH_BYTES) return false;
105
+ return value.split("/").length <= MAX_PATH_COMPONENTS;
106
+ }
107
+
108
+ function validGeneration(value: string): boolean {
109
+ return /^[0-9a-f]{32}$/.test(value);
110
+ }
111
+
112
+ function parseTimeout(value: string): number | undefined {
113
+ if (!/^[0-9]{1,5}$/.test(value)) return undefined;
114
+ const parsed = Number.parseInt(value, 10);
115
+ if (parsed < MIN_TIMEOUT_MS || parsed > MAX_TIMEOUT_MS) return undefined;
116
+ return parsed;
117
+ }
118
+
119
+ function parse(argv: readonly string[]): Parsed {
120
+ let argvBytes = 0;
121
+ for (const token of argv) argvBytes += Buffer.byteLength(token, "utf8") + 1;
122
+ if (argvBytes > MAX_ARGV_BYTES) return { kind: "usage" };
123
+ if (argv.length === 0) return { kind: "usage" };
124
+ if (isHelpToken(argv[0]!)) {
125
+ // Family help is the whole invocation or nothing: a trailing operand, a second
126
+ // help token or a subcommand after it is a usage error, never a rendered help.
127
+ return argv.length === 1 ? { kind: "help", text: USAGE_FAMILY } : { kind: "usage" };
128
+ }
129
+ if (argv[0] !== "broker") return { kind: "usage" };
130
+
131
+ let agentDir: string | undefined;
132
+ let expectedGeneration: string | undefined;
133
+ let timeoutMs: number | undefined;
134
+ let json = false;
135
+ // A help request never short-circuits validation: the remaining argv is checked
136
+ // first, so an unknown flag, a private marker, a trailing operand or a duplicate
137
+ // selector next to `--help` still terminates with the fixed usage error.
138
+ let helpRequested = false;
139
+ for (let index = 1; index < argv.length; index += 1) {
140
+ const token = argv[index]!;
141
+ if (isHelpToken(token)) {
142
+ if (helpRequested) return { kind: "usage" };
143
+ helpRequested = true;
144
+ continue;
145
+ }
146
+ if (token === "--json") {
147
+ if (json) return { kind: "usage" };
148
+ json = true;
149
+ continue;
150
+ }
151
+ if (token === "--agent-dir" || token === "--expected-generation" || token === "--timeout-ms") {
152
+ const value = argv[index + 1];
153
+ if (value === undefined) return { kind: "usage" };
154
+ index += 1;
155
+ if (token === "--agent-dir") {
156
+ if (agentDir !== undefined || !validAgentDir(value)) return { kind: "usage" };
157
+ agentDir = value;
158
+ continue;
159
+ }
160
+ if (token === "--expected-generation") {
161
+ if (expectedGeneration !== undefined || !validGeneration(value)) return { kind: "usage" };
162
+ expectedGeneration = value;
163
+ continue;
164
+ }
165
+ if (timeoutMs !== undefined) return { kind: "usage" };
166
+ const parsed = parseTimeout(value);
167
+ if (parsed === undefined) return { kind: "usage" };
168
+ timeoutMs = parsed;
169
+ continue;
170
+ }
171
+ return { kind: "usage" };
172
+ }
173
+ if (helpRequested) return { kind: "help", text: USAGE_BROKER };
174
+ return { kind: "observe", agentDir, expectedGeneration, timeoutMs: timeoutMs ?? DEFAULT_TIMEOUT_MS, json };
175
+ }
176
+
177
+ function unavailable(reason: UnavailableReason): BrokerObservation {
178
+ return {
179
+ schema: "gjc.broker-observation",
180
+ version: 1,
181
+ ok: false,
182
+ observedAt: new Date().toISOString(),
183
+ unavailable: { reason, message: UNAVAILABLE_MESSAGES[reason] },
184
+ };
185
+ }
186
+
187
+ function renderText(observation: BrokerObservation): string {
188
+ if (observation.ok) {
189
+ return [
190
+ "broker observation: ok",
191
+ `generation: ${observation.broker.generation}`,
192
+ `package version: ${observation.broker.build.packageVersion}`,
193
+ `build id: ${observation.broker.build.buildId ?? "unknown"}`,
194
+ `diagnostic protocol: ${observation.broker.diagnosticProtocol}`,
195
+ "",
196
+ ].join("\n");
197
+ }
198
+ return `broker observation: unavailable (${observation.unavailable.reason})\n${observation.unavailable.message}\n`;
199
+ }
200
+
201
+ /**
202
+ * Observe an already running broker.
203
+ *
204
+ * The read-only observation reader (narrow native snapshot adapter + read-only
205
+ * loader) is part of this build, so a valid explicit agent directory reaches the
206
+ * facade. A missing `--agent-dir` still returns the fixed `absent` outcome
207
+ * without opening a path, creating a socket or writing any state: exact
208
+ * selection never authorizes an alternative-root scan.
209
+ */
210
+ async function observe(options: {
211
+ agentDir: string | undefined;
212
+ expectedGeneration: string | undefined;
213
+ timeoutMs: number;
214
+ }): Promise<BrokerObservation> {
215
+ if (options.agentDir === undefined) return unavailable("absent");
216
+ // Exactly one route: the observation facade. No other broker operation is reachable
217
+ // from here, and nothing on this path ensures, starts, recovers or retires a broker.
218
+ const { observeExistingBroker } = await import("../diagnostics/observe-broker");
219
+ return (await observeExistingBroker({
220
+ agentDir: options.agentDir,
221
+ expectedGeneration: options.expectedGeneration,
222
+ timeoutMs: options.timeoutMs,
223
+ })) as BrokerObservation;
224
+ }
225
+
226
+ /** Run `gjc sdk diagnostics <argv…>`; argv excludes the `sdk diagnostics` prefix. */
227
+ export async function runDiagnosticsCli(argv: readonly string[]): Promise<void> {
228
+ let parsed: Parsed;
229
+ try {
230
+ parsed = parse(argv);
231
+ } catch {
232
+ parsed = { kind: "usage" };
233
+ }
234
+ if (parsed.kind === "help") {
235
+ process.stdout.write(parsed.text);
236
+ return;
237
+ }
238
+ if (parsed.kind === "usage") {
239
+ process.stderr.write(USAGE_FAMILY);
240
+ process.exitCode = 2;
241
+ return;
242
+ }
243
+ const observation = await observe(parsed);
244
+ process.stdout.write(parsed.json ? `${JSON.stringify(observation)}\n` : renderText(observation));
245
+ // An observed result exits 0; a typed unavailability exits 1.
246
+ process.exitCode = observation.ok ? 0 : 1;
247
+ }