@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
@@ -0,0 +1,654 @@
1
+ import { loadDiagnosticNativeReadOnly } from "@gajae-code/natives/diagnostic-loader";
2
+ import { SdkClient } from "../client/client";
3
+
4
+ /**
5
+ * Observation-only broker diagnostics facade (SPEC "Result contract").
6
+ *
7
+ * The publication is read ONLY through the approved read-only native lease
8
+ * (`loadDiagnosticNativeReadOnly` -> `openDiagnosticSnapshot` -> bounded read ->
9
+ * revalidate -> close), so every ancestor ACL/owner/mode and filesystem check of the
10
+ * earlier slices governs this route too. Ordinary discovery is deliberately not used: it
11
+ * would bypass that gate. An unsupported runtime tuple or artifact resolves to
12
+ * `unsupported` BEFORE any publication access. The facade then opens exactly ONE client
13
+ * connection with no reconnect attempts, asks the broker for its published diagnostics
14
+ * and returns a detached frozen snapshot. It never ensures, starts, recovers, retires or spawns
15
+ * anything, never touches the session index, ledger or evidence store, and never hands
16
+ * the caller a token, socket coordinate, path, pid or environment value.
17
+ */
18
+
19
+ /** Allowed unavailability reasons; messages are fixed literals keyed by the enum. */
20
+ export type BrokerObservationUnavailableReason =
21
+ | "absent"
22
+ | "stale"
23
+ | "incompatible"
24
+ | "authentication_failed"
25
+ | "unsupported"
26
+ | "generation_mismatch"
27
+ | "transport_unavailable"
28
+ | "timeout"
29
+ | "invalid_response"
30
+ | "unsafe_discovery";
31
+
32
+ const UNAVAILABLE_MESSAGES: Record<BrokerObservationUnavailableReason, string> = {
33
+ absent: "No broker publication was found for the requested agent directory.",
34
+ stale: "The broker publication is stale.",
35
+ incompatible: "The broker publication is not compatible with this client.",
36
+ authentication_failed: "The broker refused the observation credentials.",
37
+ unsupported: "The broker does not support diagnostic observation.",
38
+ generation_mismatch: "The broker generation does not match the expected generation.",
39
+ transport_unavailable: "The broker transport is unavailable.",
40
+ timeout: "The broker observation timed out.",
41
+ invalid_response: "The broker returned an invalid diagnostic response.",
42
+ unsafe_discovery: "The broker publication namespace is not trusted.",
43
+ };
44
+
45
+ export interface BrokerObservationBuild {
46
+ readonly packageVersion: string;
47
+ readonly buildId: string | null;
48
+ }
49
+
50
+ export interface BrokerObservationSuccess {
51
+ readonly schema: "gjc.broker-observation";
52
+ readonly version: 1;
53
+ readonly ok: true;
54
+ readonly observedAt: string;
55
+ readonly broker: {
56
+ readonly generation: string;
57
+ readonly build: BrokerObservationBuild;
58
+ readonly diagnosticProtocol: 1;
59
+ };
60
+ }
61
+
62
+ export interface BrokerObservationUnavailable {
63
+ readonly schema: "gjc.broker-observation";
64
+ readonly version: 1;
65
+ readonly ok: false;
66
+ readonly observedAt: string;
67
+ readonly unavailable: {
68
+ readonly reason: BrokerObservationUnavailableReason;
69
+ readonly message: string;
70
+ };
71
+ }
72
+
73
+ export type BrokerObservation = BrokerObservationSuccess | BrokerObservationUnavailable;
74
+
75
+ export interface ObserveExistingBrokerOptions {
76
+ readonly agentDir: string;
77
+ readonly expectedGeneration?: string;
78
+ readonly timeoutMs?: number;
79
+ }
80
+
81
+ /** The broker's published diagnostics operation. */
82
+ export const BROKER_DIAGNOSTICS_OPERATION = "broker.diagnostics";
83
+
84
+ /** The only diagnostic protocol this client understands. */
85
+ export const BROKER_DIAGNOSTIC_PROTOCOL = 1;
86
+
87
+ /** Contract timeout: default 2000 ms, accepted range 1..10000 ms. */
88
+ export const OBSERVATION_TIMEOUT_DEFAULT_MS = 2_000;
89
+ export const OBSERVATION_TIMEOUT_MIN_MS = 1;
90
+ export const OBSERVATION_TIMEOUT_MAX_MS = 10_000;
91
+
92
+ const MAX_AGENT_DIR_BYTES = 4_096;
93
+ const MAX_AGENT_DIR_COMPONENTS = 128;
94
+ /** A broker generation is 16 random bytes rendered as lowercase hex. */
95
+ const GENERATION_PATTERN = /^[0-9a-f]{32}$/;
96
+
97
+ /**
98
+ * Monotonic nanosecond clock for the single observation budget.
99
+ *
100
+ * Production always reads `Bun.nanoseconds()`. Tests may install a deterministic clock so
101
+ * the deadline's behaviour -- no socket after expiry, and a late answer discarded -- can be
102
+ * proven instead of argued. The seam replaces only the time source: no branch, capability
103
+ * or output changes with it.
104
+ */
105
+ let observationClock: () => number = () => Bun.nanoseconds();
106
+
107
+ export function setObservationClockForTest(clock: (() => number) | null): void {
108
+ observationClock = clock ?? (() => Bun.nanoseconds());
109
+ }
110
+
111
+ /**
112
+ * Test-only observers for the native stage boundary. Production installs none, and the
113
+ * hooks never change a branch, a budget or an outcome: they only report what the
114
+ * observation did (`onNativeOpen`) and give a test a point to disturb the environment
115
+ * between the first publication read and the connection (`afterInitialRead`), which is how
116
+ * the pre-socket identity recheck is proven instead of argued.
117
+ */
118
+ export interface ObservationTestHooks {
119
+ onNativeOpen?: (info: { agentDir: string; budgetMs: number }) => void;
120
+ afterInitialRead?: () => void;
121
+ /** Reports that the retained lease was released, on every exit path. */
122
+ onLeaseClosed?: () => void;
123
+ /**
124
+ * Decorates the retained lease so a test can identify the real operation phases and make
125
+ * a chosen phase refuse or throw. Production installs none.
126
+ */
127
+ wrapLease?: (lease: DiagnosticSnapshotLease) => DiagnosticSnapshotLease;
128
+ /** Replaces the loader result so loader-stage outcomes can be observed deterministically. */
129
+ loaderOverride?: () => DiagnosticNativeLoad;
130
+ }
131
+
132
+ /** Minimal shapes this facade relies on from the approved read-only loader. */
133
+ interface DiagnosticSnapshotRead {
134
+ readonly ok: boolean;
135
+ readonly reason?: string | null;
136
+ readonly bytes?: Uint8Array | null;
137
+ }
138
+
139
+ interface DiagnosticSnapshotStatus {
140
+ readonly ok: boolean;
141
+ readonly reason?: string | null;
142
+ }
143
+
144
+ interface DiagnosticSnapshotLease {
145
+ readonly ok: boolean;
146
+ readonly reason?: string | null;
147
+ read(): DiagnosticSnapshotRead;
148
+ revalidate(): DiagnosticSnapshotStatus;
149
+ close(): void;
150
+ }
151
+
152
+ type DiagnosticNativeLoad =
153
+ | { ok: true; value: { openDiagnosticSnapshot(agentDir: string, budgetMs: number): DiagnosticSnapshotLease } }
154
+ | { ok: false; reason: string };
155
+
156
+ let observationTestHooks: ObservationTestHooks = {};
157
+
158
+ export function setObservationTestHooksForTest(hooks: ObservationTestHooks | null): void {
159
+ observationTestHooks = hooks ?? {};
160
+ }
161
+
162
+ /** Malformed arguments are a typed caller error, never an observed unavailability. */
163
+ export class DiagnosticOptionsError extends Error {
164
+ readonly code = "invalid_arguments" as const;
165
+ readonly field: string;
166
+
167
+ constructor(field: string) {
168
+ super(`invalid observation argument: ${field}`);
169
+ this.name = "DiagnosticOptionsError";
170
+ this.field = field;
171
+ }
172
+ }
173
+
174
+ export type ObservationOptionsVerdict =
175
+ | { ok: true; value: { agentDir: string; expectedGeneration?: string; timeoutMs: number } }
176
+ | { ok: false; error: { code: "invalid_arguments"; field: string } };
177
+
178
+ const KNOWN_OPTION_FIELDS = new Set(["agentDir", "expectedGeneration", "timeoutMs"]);
179
+
180
+ /** Validate the caller's options against the exact contract. Pure and total. */
181
+ export function validateObservationOptions(options: unknown): ObservationOptionsVerdict {
182
+ const invalid = (field: string): ObservationOptionsVerdict => ({
183
+ ok: false,
184
+ error: { code: "invalid_arguments", field },
185
+ });
186
+ if (typeof options !== "object" || options === null) return invalid("options");
187
+ const record = options as Record<string, unknown>;
188
+ for (const field of Object.keys(record)) {
189
+ // An unknown key is reported as the fixed identifier `options`: a caller's raw key
190
+ // could carry a path, a token, newlines or an unbounded string, and the public error
191
+ // contract reflects no caller input.
192
+ if (!KNOWN_OPTION_FIELDS.has(field)) return invalid("options");
193
+ }
194
+ const agentDir = record.agentDir;
195
+ if (typeof agentDir !== "string" || agentDir.length === 0) return invalid("agentDir");
196
+ if (!agentDir.startsWith("/")) return invalid("agentDir");
197
+ if (agentDir.includes("\u0000")) return invalid("agentDir");
198
+ if (Buffer.byteLength(agentDir, "utf8") > MAX_AGENT_DIR_BYTES) return invalid("agentDir");
199
+ if (agentDir.split("/").filter(part => part.length > 0).length > MAX_AGENT_DIR_COMPONENTS) {
200
+ return invalid("agentDir");
201
+ }
202
+ let expectedGeneration: string | undefined;
203
+ if (record.expectedGeneration !== undefined) {
204
+ if (typeof record.expectedGeneration !== "string") return invalid("expectedGeneration");
205
+ if (!GENERATION_PATTERN.test(record.expectedGeneration)) return invalid("expectedGeneration");
206
+ expectedGeneration = record.expectedGeneration;
207
+ }
208
+ let timeoutMs = OBSERVATION_TIMEOUT_DEFAULT_MS;
209
+ if (record.timeoutMs !== undefined) {
210
+ if (typeof record.timeoutMs !== "number" || !Number.isInteger(record.timeoutMs)) return invalid("timeoutMs");
211
+ if (record.timeoutMs < OBSERVATION_TIMEOUT_MIN_MS || record.timeoutMs > OBSERVATION_TIMEOUT_MAX_MS) {
212
+ return invalid("timeoutMs");
213
+ }
214
+ timeoutMs = record.timeoutMs;
215
+ }
216
+ return {
217
+ ok: true,
218
+ value: { agentDir, ...(expectedGeneration === undefined ? {} : { expectedGeneration }), timeoutMs },
219
+ };
220
+ }
221
+
222
+ function unavailable(reason: BrokerObservationUnavailableReason): BrokerObservationUnavailable {
223
+ return Object.freeze({
224
+ schema: "gjc.broker-observation",
225
+ version: 1,
226
+ ok: false,
227
+ observedAt: new Date().toISOString(),
228
+ unavailable: Object.freeze({ reason, message: UNAVAILABLE_MESSAGES[reason] }),
229
+ }) as BrokerObservationUnavailable;
230
+ }
231
+
232
+ function success(generation: string, build: BrokerObservationBuild): BrokerObservationSuccess {
233
+ return Object.freeze({
234
+ schema: "gjc.broker-observation",
235
+ version: 1,
236
+ ok: true,
237
+ observedAt: new Date().toISOString(),
238
+ broker: Object.freeze({
239
+ generation,
240
+ build: Object.freeze({ packageVersion: build.packageVersion, buildId: build.buildId }),
241
+ diagnosticProtocol: BROKER_DIAGNOSTIC_PROTOCOL,
242
+ }),
243
+ }) as BrokerObservationSuccess;
244
+ }
245
+
246
+ /** Bounds that keep every rendered field inside the public output budget. */
247
+ const MAX_PUBLICATION_BYTES = 64 * 1024;
248
+ const MAX_VERSION_LENGTH = 64;
249
+ const MAX_BUILD_ID_LENGTH = 64;
250
+ const MAX_OWNER_LENGTH = 64;
251
+ const VERSION_PATTERN = /^[0-9A-Za-z.+-]{1,64}$/;
252
+ const BUILD_ID_PATTERN = /^[0-9A-Za-z._-]{1,64}$/;
253
+ const OWNER_PATTERN = /^[0-9a-f]{1,64}$/;
254
+ /**
255
+ * Canonical publication endpoint: the owned loopback address, a decimal port with no
256
+ * leading zero, and at most the root path. Anything else -- another host, scheme, path,
257
+ * query or userinfo -- would let the observation leave the endpoint the publisher owns.
258
+ *
259
+ * DESIGN writes the canonical form with a trailing `/`, while the actual publisher emits
260
+ * `ws://127.0.0.1:<port>` without one. The trailing slash is therefore accepted as
261
+ * OPTIONAL: that is a compatibility choice for the existing publisher, not a change to
262
+ * publisher semantics, which this slice deliberately leaves alone.
263
+ */
264
+ const LOOPBACK_WS_PATTERN = /^ws:\/\/127\.0\.0\.1:([1-9]\d{0,4})\/?$/;
265
+ const MAX_TCP_PORT = 65_535;
266
+
267
+ /** Whether the endpoint is the canonical owned loopback endpoint with an in-range port. */
268
+ export function canonicalLoopbackEndpoint(url: string): boolean {
269
+ const match = LOOPBACK_WS_PATTERN.exec(url);
270
+ if (match === null) return false;
271
+ const port = Number(match[1]);
272
+ return Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;
273
+ }
274
+ const TOKEN_PATTERN = /^[0-9a-f]{32,128}$/;
275
+
276
+ /**
277
+ * Parse verdicts for the publication bytes. A publication that is well formed but
278
+ * carries no diagnostic capability is NOT malformed: an old broker like that is
279
+ * `unsupported`, never `invalid_response` and never `absent`.
280
+ */
281
+ export type PublicationVerdict =
282
+ | { kind: "ok"; value: ObservedPublication }
283
+ | { kind: "no-capability" }
284
+ | { kind: "malformed" };
285
+
286
+ /** The publication fields this route reads; every other field is ignored, never trusted. */
287
+ export interface ObservedPublication {
288
+ readonly url: string;
289
+ readonly token: string;
290
+ readonly ownerId: string;
291
+ readonly pid: number;
292
+ readonly incarnation: string | null;
293
+ readonly agentRoot: string | null;
294
+ readonly generation: string;
295
+ readonly diagnosticProtocol: number;
296
+ }
297
+
298
+ /** Parse the publication bytes the native lease returned. Strict, bounded, total. */
299
+ export function parsePublicationVerdict(bytes: Uint8Array): PublicationVerdict {
300
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_PUBLICATION_BYTES) return { kind: "malformed" };
301
+ let value: unknown;
302
+ try {
303
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
304
+ } catch {
305
+ return { kind: "malformed" };
306
+ }
307
+ if (typeof value !== "object" || value === null) return { kind: "malformed" };
308
+ const record = value as Record<string, unknown>;
309
+ const url = record.url;
310
+ const token = record.token;
311
+ const ownerId = record.ownerId;
312
+ const pid = record.pid;
313
+ const generation = record.diagnosticGeneration;
314
+ const protocol = record.diagnosticProtocol;
315
+ if (typeof url !== "string" || !canonicalLoopbackEndpoint(url)) return { kind: "malformed" };
316
+ if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) return { kind: "malformed" };
317
+ if (typeof ownerId !== "string" || !OWNER_PATTERN.test(ownerId)) return { kind: "malformed" };
318
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return { kind: "malformed" };
319
+ // An old broker omits both capability fields entirely: that is `unsupported`, decided
320
+ // before any socket is opened, and never reported as malformed or absent.
321
+ if (generation === undefined && protocol === undefined) return { kind: "no-capability" };
322
+ // Present but out of contract is a malformed publication, not a missing capability.
323
+ if (typeof generation !== "string" || !GENERATION_PATTERN.test(generation)) return { kind: "malformed" };
324
+ if (typeof protocol !== "number" || !Number.isInteger(protocol)) return { kind: "malformed" };
325
+ const incarnation =
326
+ typeof record.incarnation === "string" && record.incarnation.length <= MAX_OWNER_LENGTH
327
+ ? record.incarnation
328
+ : null;
329
+ const agentRoot =
330
+ typeof record.agentRoot === "string" && record.agentRoot.length <= MAX_AGENT_DIR_BYTES ? record.agentRoot : null;
331
+ return {
332
+ kind: "ok",
333
+ value: { url, token, ownerId, pid, incarnation, agentRoot, generation, diagnosticProtocol: protocol },
334
+ };
335
+ }
336
+
337
+ /** Convenience wrapper for callers that only need the accepted shape. */
338
+ export function parsePublication(bytes: Uint8Array): ObservedPublication | null {
339
+ const verdict = parsePublicationVerdict(bytes);
340
+ return verdict.kind === "ok" ? verdict.value : null;
341
+ }
342
+
343
+ /** The exact answered payload this client accepts; unknown fields are refused. */
344
+ interface AnsweredDiagnostics {
345
+ readonly generation: string;
346
+ readonly build: BrokerObservationBuild;
347
+ readonly identity: { readonly ownerId: string; readonly pid: number; readonly agentRoot: string };
348
+ }
349
+
350
+ const ANSWER_FIELDS = new Set(["diagnosticProtocol", "generation", "build", "identity"]);
351
+ const ANSWER_BUILD_FIELDS = new Set(["packageVersion", "buildId"]);
352
+ const ANSWER_IDENTITY_FIELDS = new Set(["ownerId", "pid", "agentRoot"]);
353
+
354
+ /** Strictly validate the broker's answer, mapping every refusal onto the reason enum. */
355
+ export function parseAnsweredDiagnostics(
356
+ response: unknown,
357
+ ): { kind: "ok"; value: AnsweredDiagnostics } | { kind: "unavailable"; reason: BrokerObservationUnavailableReason } {
358
+ const refuse = (reason: BrokerObservationUnavailableReason) => ({ kind: "unavailable" as const, reason });
359
+ if (typeof response !== "object" || response === null) return refuse("invalid_response");
360
+ const envelope = response as Record<string, unknown>;
361
+ if (envelope.ok !== true) {
362
+ const error = envelope.error;
363
+ const code =
364
+ typeof error === "object" && error !== null ? (error as Record<string, unknown>).code : envelope.code;
365
+ if (code === "unauthorized" || code === "forbidden") return refuse("authentication_failed");
366
+ if (code === "unknown_operation" || code === "unsupported_operation") return refuse("unsupported");
367
+ if (code === "timeout" || code === "deadline_exceeded") return refuse("timeout");
368
+ return refuse("transport_unavailable");
369
+ }
370
+ const result = envelope.result;
371
+ if (typeof result !== "object" || result === null) return refuse("invalid_response");
372
+ const record = result as Record<string, unknown>;
373
+ for (const field of Object.keys(record)) {
374
+ if (!ANSWER_FIELDS.has(field)) return refuse("invalid_response");
375
+ }
376
+ if (typeof record.diagnosticProtocol !== "number" || !Number.isInteger(record.diagnosticProtocol)) {
377
+ return refuse("invalid_response");
378
+ }
379
+ if (record.diagnosticProtocol !== BROKER_DIAGNOSTIC_PROTOCOL) return refuse("incompatible");
380
+ if (typeof record.generation !== "string" || !GENERATION_PATTERN.test(record.generation)) {
381
+ return refuse("invalid_response");
382
+ }
383
+ const build = record.build;
384
+ if (typeof build !== "object" || build === null) return refuse("invalid_response");
385
+ const buildRecord = build as Record<string, unknown>;
386
+ for (const field of Object.keys(buildRecord)) {
387
+ if (!ANSWER_BUILD_FIELDS.has(field)) return refuse("invalid_response");
388
+ }
389
+ if (typeof buildRecord.packageVersion !== "string" || !VERSION_PATTERN.test(buildRecord.packageVersion)) {
390
+ return refuse("invalid_response");
391
+ }
392
+ if (buildRecord.packageVersion.length > MAX_VERSION_LENGTH) return refuse("invalid_response");
393
+ let buildId: string | null = null;
394
+ if (buildRecord.buildId !== null) {
395
+ if (typeof buildRecord.buildId !== "string" || !BUILD_ID_PATTERN.test(buildRecord.buildId)) {
396
+ return refuse("invalid_response");
397
+ }
398
+ if (buildRecord.buildId.length > MAX_BUILD_ID_LENGTH) return refuse("invalid_response");
399
+ buildId = buildRecord.buildId;
400
+ }
401
+ const identity = record.identity;
402
+ if (typeof identity !== "object" || identity === null) return refuse("invalid_response");
403
+ const identityRecord = identity as Record<string, unknown>;
404
+ for (const field of Object.keys(identityRecord)) {
405
+ if (!ANSWER_IDENTITY_FIELDS.has(field)) return refuse("invalid_response");
406
+ }
407
+ if (typeof identityRecord.ownerId !== "string" || !OWNER_PATTERN.test(identityRecord.ownerId)) {
408
+ return refuse("invalid_response");
409
+ }
410
+ if (typeof identityRecord.pid !== "number" || !Number.isInteger(identityRecord.pid) || identityRecord.pid <= 0) {
411
+ return refuse("invalid_response");
412
+ }
413
+ if (typeof identityRecord.agentRoot !== "string" || identityRecord.agentRoot.length === 0) {
414
+ return refuse("invalid_response");
415
+ }
416
+ return {
417
+ kind: "ok",
418
+ value: {
419
+ generation: record.generation,
420
+ build: { packageVersion: buildRecord.packageVersion, buildId },
421
+ identity: {
422
+ ownerId: identityRecord.ownerId,
423
+ pid: identityRecord.pid,
424
+ agentRoot: identityRecord.agentRoot,
425
+ },
426
+ },
427
+ };
428
+ }
429
+
430
+ /** Two publication observations describe the same authority. */
431
+ export function sameAuthority(first: ObservedPublication, second: ObservedPublication): boolean {
432
+ return (
433
+ first.generation === second.generation &&
434
+ first.ownerId === second.ownerId &&
435
+ first.pid === second.pid &&
436
+ first.incarnation === second.incarnation &&
437
+ first.agentRoot === second.agentRoot
438
+ );
439
+ }
440
+
441
+ /** The broker's answer agrees with the publication on generation and identity. */
442
+ export function answerMatchesAuthority(answer: AnsweredDiagnostics, publication: ObservedPublication): boolean {
443
+ if (answer.generation !== publication.generation) return false;
444
+ if (answer.identity.ownerId !== publication.ownerId) return false;
445
+ if (answer.identity.pid !== publication.pid) return false;
446
+ if (publication.agentRoot !== null && answer.identity.agentRoot !== publication.agentRoot) return false;
447
+ return true;
448
+ }
449
+
450
+ /**
451
+ * Observe an already running broker. Never starts, ensures, recovers or retires one.
452
+ */
453
+ export async function observeExistingBroker(
454
+ options: ObserveExistingBrokerOptions | unknown,
455
+ ): Promise<BrokerObservation> {
456
+ const verdict = validateObservationOptions(options);
457
+ if (!verdict.ok) throw new DiagnosticOptionsError(verdict.error.field);
458
+ const { agentDir, expectedGeneration, timeoutMs } = verdict.value;
459
+
460
+ // ONE monotonic budget starts before any loader or native work and governs every later
461
+ // step: the lease read, the socket and the response.
462
+ const clock = observationClock;
463
+ const startedAt = clock();
464
+ const elapsedMs = () => Math.max(0, Math.floor((clock() - startedAt) / 1_000_000));
465
+ const expired = () => elapsedMs() >= timeoutMs;
466
+ const remainingMs = () => Math.max(1, timeoutMs - elapsedMs());
467
+
468
+ // The runtime tuple and the trusted artifact decide BEFORE any publication access, so
469
+ // an unsupported platform or a missing/mismatched artifact never reaches the
470
+ // filesystem through this route.
471
+ let native: DiagnosticNativeLoad;
472
+ try {
473
+ native = observationTestHooks.loaderOverride?.() ?? loadDiagnosticNativeReadOnly();
474
+ } catch {
475
+ // A loader exception is a low-level native failure on this route, never an escaping
476
+ // error; an expired budget outranks it.
477
+ return unavailable(expired() ? "timeout" : "unsupported");
478
+ }
479
+ // DESIGN:157 keeps the two apart: a live-budget loader failure is `unsupported`, while a
480
+ // failure observed after the budget is spent is the timeout.
481
+ if (!native.ok) return unavailable(expired() ? "timeout" : "unsupported");
482
+ // The loader itself consumes budget: expiry is decided BEFORE the native stage, so an
483
+ // exhausted budget never opens a publication snapshot.
484
+ if (expired()) return unavailable("timeout");
485
+ const nativeBudgetMs = remainingMs();
486
+ // The remaining-budget read itself can cross the deadline: nothing native is entered
487
+ // with a budget that is already spent.
488
+ if (expired()) return unavailable("timeout");
489
+
490
+ let lease: DiagnosticSnapshotLease | null = null;
491
+ let client: SdkClient | null = null;
492
+ // Set while the native stage owns the work, so an exception raised there is classified as
493
+ // a native failure instead of a transport failure.
494
+ let nativeStageActive = false;
495
+ try {
496
+ observationTestHooks.onNativeOpen?.({ agentDir, budgetMs: nativeBudgetMs });
497
+ nativeStageActive = true;
498
+ const openedLease = native.value.openDiagnosticSnapshot(agentDir, nativeBudgetMs);
499
+ lease = observationTestHooks.wrapLease?.(openedLease) ?? openedLease;
500
+ // Timeout outranks a native refusal that only describes what a spent budget could no
501
+ // longer observe.
502
+ if (!lease.ok) return unavailable(expired() ? "timeout" : leaseReason(lease.reason));
503
+ // After the native call returns, the budget decides again before the next native step.
504
+ if (expired()) return unavailable("timeout");
505
+ const read = lease.read();
506
+ if (!read.ok || read.bytes === undefined || read.bytes === null) {
507
+ return unavailable(expired() ? "timeout" : leaseReason(read.reason));
508
+ }
509
+ // Timeout outranks a parse verdict that only describes bytes a spent budget could no
510
+ // longer confirm.
511
+ if (expired()) return unavailable("timeout");
512
+ const initial = parsePublicationVerdict(read.bytes);
513
+ if (initial.kind === "no-capability") return unavailable(expired() ? "timeout" : "unsupported");
514
+ if (initial.kind === "malformed") return unavailable(expired() ? "timeout" : "invalid_response");
515
+ const publication = initial.value;
516
+ observationTestHooks.afterInitialRead?.();
517
+ // A capability the client does not speak is unsupported before any socket.
518
+ if (publication.diagnosticProtocol !== BROKER_DIAGNOSTIC_PROTOCOL) return unavailable("unsupported");
519
+ if (expectedGeneration !== undefined && expectedGeneration !== publication.generation) {
520
+ return unavailable("generation_mismatch");
521
+ }
522
+ // All synchronous native work is done.
523
+ if (expired()) return unavailable("timeout");
524
+
525
+ // The publication is revalidated immediately BEFORE anything is connected, and the
526
+ // budget is re-checked AFTER that step: the revalidation itself can consume the rest
527
+ // of the budget, and a spent budget must never construct or open a connection.
528
+ const beforeSocket = lease.revalidate();
529
+ if (!beforeSocket.ok) return unavailable(expired() ? "timeout" : leaseReason(beforeSocket.reason));
530
+ // Descriptor identity is not publication identity: re-read the bounded bytes through
531
+ // the SAME retained lease (no reopen, no new trust path) and compare the semantic
532
+ // identity with the first observation. A replacement published over the same inode is
533
+ // refused here, before any socket exists.
534
+ if (expired()) return unavailable("timeout");
535
+ const preSocketRead = lease.read();
536
+ if (!preSocketRead.ok || preSocketRead.bytes === undefined || preSocketRead.bytes === null) {
537
+ return unavailable(expired() ? "timeout" : leaseReason(preSocketRead.reason));
538
+ }
539
+ if (expired()) return unavailable("timeout");
540
+ const preSocket = parsePublicationVerdict(preSocketRead.bytes);
541
+ if (preSocket.kind === "no-capability") return unavailable(expired() ? "timeout" : "unsupported");
542
+ if (preSocket.kind === "malformed") return unavailable(expired() ? "timeout" : "invalid_response");
543
+ if (!sameAuthority(publication, preSocket.value)) {
544
+ return unavailable(expired() ? "timeout" : "generation_mismatch");
545
+ }
546
+ if (expired()) return unavailable("timeout");
547
+
548
+ // The remaining budget is read first and the budget is re-checked immediately before
549
+ // the client exists, so no connection is ever constructed with a spent budget.
550
+ const connectBudgetMs = remainingMs();
551
+ if (expired()) return unavailable("timeout");
552
+ client = new SdkClient(publication.url, publication.token, {
553
+ // One connection: no reconnect, no late connect to a replacement publication.
554
+ reconnectAttempts: 0,
555
+ timeoutMs: connectBudgetMs,
556
+ // The client stage inherits the observation's ONE absolute budget, so a socket that
557
+ // opens late cannot buy the handshake a fresh relative timeout on top of it.
558
+ deadline: Date.now() + connectBudgetMs,
559
+ });
560
+ nativeStageActive = false;
561
+ await client.connect();
562
+ if (expired()) return unavailable("timeout");
563
+
564
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
565
+ const response = await Promise.race([
566
+ client.request({ type: "broker_request", operation: BROKER_DIAGNOSTICS_OPERATION, input: {} }),
567
+ new Promise<"deadline">(resolve => {
568
+ deadlineTimer = setTimeout(() => resolve("deadline"), remainingMs());
569
+ }),
570
+ ]).finally(() => {
571
+ // The budget timer is always cleared: no pending handle outlives the call.
572
+ if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
573
+ });
574
+ // A result that arrives after the budget is discarded, not rendered.
575
+ if (response === "deadline" || expired()) return unavailable("timeout");
576
+
577
+ const answered = parseAnsweredDiagnostics(response);
578
+ if (answered.kind === "unavailable") return unavailable(answered.reason);
579
+
580
+ // After the response, the publication must still be the same authority, and the
581
+ // answer must agree with it on generation and the internal owner/process/root
582
+ // identity.
583
+ if (expired()) return unavailable("timeout");
584
+ nativeStageActive = true;
585
+ const afterResponse = lease.revalidate();
586
+ if (!afterResponse.ok) return unavailable(expired() ? "timeout" : leaseReason(afterResponse.reason));
587
+ if (expired()) return unavailable("timeout");
588
+ const finalRead = lease.read();
589
+ if (!finalRead.ok || finalRead.bytes === undefined || finalRead.bytes === null) {
590
+ return unavailable(expired() ? "timeout" : leaseReason(finalRead.reason));
591
+ }
592
+ if (expired()) return unavailable("timeout");
593
+ const finalVerdict = parsePublicationVerdict(finalRead.bytes);
594
+ if (finalVerdict.kind === "no-capability") return unavailable(expired() ? "timeout" : "unsupported");
595
+ if (finalVerdict.kind === "malformed") return unavailable(expired() ? "timeout" : "invalid_response");
596
+ const finalPublication = finalVerdict.value;
597
+ if (expired()) return unavailable("timeout");
598
+ if (!sameAuthority(publication, finalPublication)) return unavailable("generation_mismatch");
599
+ if (!answerMatchesAuthority(answered.value, finalPublication)) return unavailable("generation_mismatch");
600
+ if (expectedGeneration !== undefined && expectedGeneration !== answered.value.generation) {
601
+ return unavailable("generation_mismatch");
602
+ }
603
+ return success(answered.value.generation, answered.value.build);
604
+ } catch (error) {
605
+ if (error instanceof DiagnosticOptionsError) throw error;
606
+ // An expired budget outranks a low-level failure that only describes what a spent
607
+ // budget could no longer complete.
608
+ if (expired()) return unavailable("timeout");
609
+ // A native/lease exception raised while the budget was still live is a low-level
610
+ // native failure (DESIGN:157), distinct from a transport failure of the client stage.
611
+ if (nativeStageActive) return unavailable("unsupported");
612
+ return unavailable(clientFailureReason(error));
613
+ } finally {
614
+ // Every exit path releases the single connection and the lease.
615
+ try {
616
+ await client?.close();
617
+ } catch {
618
+ // The connection is already gone; nothing else to release.
619
+ }
620
+ try {
621
+ lease?.close();
622
+ if (lease !== null) observationTestHooks.onLeaseClosed?.();
623
+ } catch {
624
+ // The lease is already closed; nothing else to release.
625
+ }
626
+ }
627
+ }
628
+
629
+ /** Map a native lease refusal onto the public reason enum. */
630
+ function leaseReason(reason: string | null | undefined): BrokerObservationUnavailableReason {
631
+ switch (reason) {
632
+ case "absent":
633
+ return "absent";
634
+ case "stale":
635
+ return "stale";
636
+ case "unsafe_discovery":
637
+ return "unsafe_discovery";
638
+ case "unsupported":
639
+ return "unsupported";
640
+ case "timeout":
641
+ return "timeout";
642
+ default:
643
+ return "unsafe_discovery";
644
+ }
645
+ }
646
+
647
+ /** Map an SdkClient failure onto the public reason enum. */
648
+ function clientFailureReason(error: unknown): BrokerObservationUnavailableReason {
649
+ const code = (error as { code?: unknown } | null)?.code;
650
+ if (code === "unauthorized" || code === "forbidden") return "authentication_failed";
651
+ if (code === "timeout" || code === "deadline_exceeded") return "timeout";
652
+ if (code === "unknown_operation" || code === "unsupported_operation") return "unsupported";
653
+ return "transport_unavailable";
654
+ }