@gajae-code/coding-agent 0.11.2 → 0.11.4

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 (101) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/launch-disposition.d.ts +23 -0
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +85 -10
  7. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  8. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  9. package/dist/types/main.d.ts +1 -0
  10. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  11. package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  13. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  14. package/dist/types/sdk/broker/broker.d.ts +1 -0
  15. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  16. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  17. package/dist/types/sdk/broker/internal.d.ts +3 -0
  18. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  19. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  20. package/dist/types/sdk/bus/index.d.ts +2 -0
  21. package/dist/types/sdk/session.d.ts +2 -0
  22. package/dist/types/tools/ask.d.ts +39 -12
  23. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  24. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  25. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  26. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  27. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  28. package/dist/types/tools/path-utils.d.ts +9 -14
  29. package/dist/types/tools/read.d.ts +2 -0
  30. package/dist/types/tools/render-utils.d.ts +1 -8
  31. package/dist/types/tools/resource-gc.d.ts +14 -0
  32. package/dist/types/tools/subagent.d.ts +1 -0
  33. package/dist/types/utils/file-mentions.d.ts +3 -3
  34. package/dist/types/utils/image-loading.d.ts +23 -0
  35. package/dist/types/utils/image-resize.d.ts +2 -0
  36. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  37. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  38. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  39. package/dist/types/web/scrapers/types.d.ts +1 -5
  40. package/package.json +24 -8
  41. package/scripts/run-threat-model-check.ts +60 -0
  42. package/src/cli/launch-disposition.ts +42 -0
  43. package/src/cli/worktree-cli.ts +9 -287
  44. package/src/cli.ts +10 -9
  45. package/src/commands/gc.ts +11 -1
  46. package/src/commands/sdk.ts +3 -2
  47. package/src/commands/worktree.ts +10 -53
  48. package/src/config/model-profiles.ts +15 -15
  49. package/src/config/settings-schema.ts +55 -10
  50. package/src/eval/js/shared/runtime.ts +1 -1
  51. package/src/gjc-runtime/gc-render.ts +19 -1
  52. package/src/gjc-runtime/gc-runtime.ts +68 -3
  53. package/src/gjc-runtime/state-argv.ts +292 -0
  54. package/src/gjc-runtime/state-runtime.ts +51 -212
  55. package/src/main.ts +99 -22
  56. package/src/modes/components/custom-editor.ts +103 -32
  57. package/src/modes/components/custom-provider-wizard.ts +26 -1
  58. package/src/modes/components/session-observer-overlay.ts +10 -23
  59. package/src/modes/components/tool-transcript-format.ts +72 -0
  60. package/src/modes/components/transcript-viewer-overlay.ts +42 -2
  61. package/src/modes/controllers/input-controller.ts +98 -37
  62. package/src/modes/controllers/selector-controller.ts +1 -1
  63. package/src/modes/interactive-mode.ts +13 -2
  64. package/src/modes/prompt-action-autocomplete.ts +7 -0
  65. package/src/sdk/broker/broker.ts +139 -32
  66. package/src/sdk/broker/discovery.ts +125 -0
  67. package/src/sdk/broker/ensure.ts +103 -19
  68. package/src/sdk/broker/internal.ts +10 -0
  69. package/src/sdk/broker/session-index.ts +228 -15
  70. package/src/sdk/broker/state-version.ts +5 -2
  71. package/src/sdk/bus/index.ts +4 -1
  72. package/src/sdk/bus/telegram-daemon.ts +50 -10
  73. package/src/sdk/host/host.ts +13 -1
  74. package/src/sdk/session.ts +4 -1
  75. package/src/session/internal/managed-session-scope.ts +15 -1
  76. package/src/tools/ask.ts +216 -41
  77. package/src/tools/bash-allowed-prefixes.ts +79 -29
  78. package/src/tools/browser/launch.ts +3 -1
  79. package/src/tools/browser/readable.ts +1 -1
  80. package/src/tools/browser/screenshot-format.ts +34 -0
  81. package/src/tools/browser/tab-protocol.ts +3 -0
  82. package/src/tools/browser/tab-supervisor.ts +125 -122
  83. package/src/tools/browser/tab-worker-entry.ts +33 -14
  84. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  85. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  86. package/src/tools/browser/tab-worker.ts +7 -5
  87. package/src/tools/output-meta.ts +6 -2
  88. package/src/tools/path-utils.ts +42 -35
  89. package/src/tools/read.ts +189 -64
  90. package/src/tools/render-utils.ts +1 -27
  91. package/src/tools/resource-gc.ts +123 -34
  92. package/src/tools/subagent.ts +43 -2
  93. package/src/utils/file-mentions.ts +4 -4
  94. package/src/utils/image-loading.ts +85 -34
  95. package/src/utils/image-resize.ts +16 -9
  96. package/src/utils/pasted-image-loading.ts +514 -0
  97. package/src/utils/pasted-image-path.ts +223 -98
  98. package/src/web/scrapers/html-to-markdown.ts +68 -0
  99. package/src/web/scrapers/readthedocs.ts +1 -1
  100. package/src/web/scrapers/types.ts +1 -68
  101. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -62,6 +62,7 @@ import {
62
62
  } from "./components/pet-capability";
63
63
  import type { ToolExecutionHandle } from "./components/tool-execution";
64
64
  import { StatusLineComponent } from "./components/tool-status-header";
65
+ import { composeToolText } from "./components/tool-transcript-format";
65
66
  import {
66
67
  WelcomeComponent,
67
68
  type WelcomeLogoMode,
@@ -1817,17 +1818,27 @@ export class InteractiveMode implements InteractiveModeContext {
1817
1818
  result?.content
1818
1819
  .filter(part => part.type === "text")
1819
1820
  .map(part => part.text)
1820
- .join("\n") ?? "";
1821
+ .join("\n")
1822
+ .trim() ?? "";
1821
1823
  items.push({
1822
1824
  kind: "tool",
1823
1825
  source: { toolCallId: content.id, content, result },
1824
1826
  getPayload: () => ({
1825
- text: resultText || JSON.stringify(content.arguments, null, 2),
1827
+ text: composeToolText({
1828
+ name: content.name,
1829
+ args: content.arguments,
1830
+ intent: content.intent,
1831
+ resultText,
1832
+ isError: result?.isError ?? false,
1833
+ hasResult: toolResults.has(content.id),
1834
+ }),
1826
1835
  metadata: {
1827
1836
  name: content.name,
1828
1837
  arguments: content.arguments,
1829
1838
  intent: content.intent,
1830
1839
  isError: result?.isError ?? false,
1840
+ resultText,
1841
+ hasResult: toolResults.has(content.id),
1831
1842
  },
1832
1843
  source: { content, result },
1833
1844
  }),
@@ -248,6 +248,13 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
248
248
 
249
249
  return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol);
250
250
  }
251
+ getForceFileSuggestions(
252
+ lines: string[],
253
+ cursorLine: number,
254
+ cursorCol: number,
255
+ ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
256
+ return this.#baseProvider.getForceFileSuggestions(lines, cursorLine, cursorCol);
257
+ }
251
258
 
252
259
  applyCompletion(
253
260
  lines: string[],
@@ -12,12 +12,14 @@ import {
12
12
  type BrokerDiscovery,
13
13
  brokerDiscoveryPath,
14
14
  brokerProcessIncarnation,
15
+ heartbeatBrokerDiscoveryRetained,
15
16
  isPidAlive,
16
17
  newBrokerToken,
18
+ publishBrokerDiscovery,
17
19
  type RedactedBrokerDiscovery,
20
+ type RetainedBrokerDiscovery,
18
21
  readBrokerDiscovery,
19
22
  redactBrokerDiscovery,
20
- writeBrokerDiscovery,
21
23
  } from "./discovery";
22
24
  import { deriveIdempotencyIdentity } from "./identity";
23
25
  import { executeLifecycle, isCanonicalSessionId } from "./lifecycle";
@@ -362,6 +364,17 @@ type BrokerLockSnapshot = {
362
364
  identity: string;
363
365
  };
364
366
 
367
+ const BROKER_PUBLICATION_CADENCE_MS = 5_000;
368
+ const BROKER_PUBLICATION_GRACE_MS = 15_000;
369
+ const BROKER_SETTLEMENT_MS = 2_000;
370
+ type BrokerPublicationState =
371
+ | "healthy-owned"
372
+ | "suspect-unpublished"
373
+ | "observation-ambiguous"
374
+ | "heartbeat-ambiguous"
375
+ | "stopping";
376
+ type BrokerStopMode = "owned-root" | "lost-root";
377
+
365
378
  const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
366
379
 
367
380
  export class Broker {
@@ -372,10 +385,17 @@ export class Broker {
372
385
  #lock: string;
373
386
  #owner = randomBytes(12).toString("hex");
374
387
  #chains = new Map<string, Promise<void>>();
388
+ #admitted = new Set<Promise<void>>();
389
+ #publication: RetainedBrokerDiscovery | null = null;
390
+ #publicationState: BrokerPublicationState = "healthy-owned";
391
+ #lossAt: bigint | null = null;
375
392
  #stopping = false;
376
393
  #transport: BrokerTransport | null = null;
377
394
  #heartbeatTimer: NodeJS.Timeout | null = null;
378
- #heartbeatWrite: Promise<void> = Promise.resolve();
395
+ #completionTask: Promise<void> | null = null;
396
+ #completion!: Promise<void>;
397
+ #resolveCompletion!: () => void;
398
+ #rejectCompletion!: (error: unknown) => void;
379
399
  constructor(settings: BrokerSettings) {
380
400
  this.settings = {
381
401
  agentDir: settings.agentDir,
@@ -387,6 +407,10 @@ export class Broker {
387
407
  this.index = new SessionIndex(settings.agentDir);
388
408
  this.ledger = new LifecycleLedger(settings.agentDir);
389
409
  this.#lock = path.join(settings.agentDir, "sdk", "broker.lock");
410
+ const completion = Promise.withResolvers<void>();
411
+ this.#completion = completion.promise;
412
+ this.#resolveCompletion = completion.resolve;
413
+ this.#rejectCompletion = completion.reject;
390
414
  }
391
415
  #lockRecordPath(): string {
392
416
  return path.join(this.#lock, BROKER_LOCK_RECORD);
@@ -490,7 +514,17 @@ export class Broker {
490
514
  }
491
515
 
492
516
  async start(): Promise<BrokerDiscovery> {
517
+ if (this.#completionTask) {
518
+ await this.#completionTask;
519
+ const completion = Promise.withResolvers<void>();
520
+ this.#completion = completion.promise;
521
+ this.#resolveCompletion = completion.resolve;
522
+ this.#rejectCompletion = completion.reject;
523
+ this.#completionTask = null;
524
+ }
493
525
  this.#stopping = false;
526
+ this.#publicationState = "healthy-owned";
527
+ this.#lossAt = null;
494
528
  await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
495
529
  await fs.mkdir(path.dirname(this.#lock), { recursive: true, mode: 0o700 });
496
530
  for (;;) {
@@ -544,15 +578,19 @@ export class Broker {
544
578
  startedAt: now,
545
579
  heartbeatAt: now,
546
580
  };
547
- await writeBrokerDiscovery(this.settings.agentDir, this.discovery);
548
- this.#heartbeatTimer = setInterval(
549
- () => void this.heartbeat(),
550
- Math.max(1, Math.floor(this.settings.heartbeatTtlMs / 3)),
581
+ this.#publication = await publishBrokerDiscovery(this.settings.agentDir, this.discovery);
582
+ this.#publicationState = "healthy-owned";
583
+ const cadenceMs = Math.max(
584
+ 10,
585
+ Math.min(BROKER_PUBLICATION_CADENCE_MS, Math.floor(this.settings.heartbeatTtlMs / 3)),
551
586
  );
587
+ this.#heartbeatTimer = setInterval(() => void this.#watchPublication(), cadenceMs);
552
588
  return this.discovery;
553
589
  } catch (error) {
554
590
  await this.#transport?.stop();
555
591
  this.#transport = null;
592
+ this.#publication?.close();
593
+ this.#publication = null;
556
594
  this.discovery = null;
557
595
  await this.#releaseOwnedLock();
558
596
  throw error;
@@ -561,38 +599,96 @@ export class Broker {
561
599
  get ownsDiscovery(): boolean {
562
600
  return this.discovery?.ownerId === this.#owner;
563
601
  }
602
+ get completion(): Promise<void> {
603
+ return this.#completion;
604
+ }
564
605
  status(): RedactedBrokerDiscovery | null {
565
606
  return this.discovery ? redactBrokerDiscovery(this.discovery) : null;
566
607
  }
567
- async heartbeat(): Promise<void> {
568
- if (!this.discovery || this.discovery.ownerId !== this.#owner) return;
569
- this.discovery = { ...this.discovery, heartbeatAt: Date.now() };
570
- const discovery = this.discovery;
571
- this.#heartbeatWrite = this.#heartbeatWrite.then(() => writeBrokerDiscovery(this.settings.agentDir, discovery));
572
- await this.#heartbeatWrite;
608
+ #fence(kind: "suspect-unpublished" | "observation-ambiguous" | "heartbeat-ambiguous"): void {
609
+ if (this.#publicationState === "stopping") return;
610
+ this.#publicationState = kind;
611
+ if (kind === "suspect-unpublished") this.#lossAt ??= process.hrtime.bigint();
612
+ else this.#lossAt = null;
573
613
  }
574
- async stop(): Promise<void> {
575
- this.#stopping = true;
576
- if (this.#heartbeatTimer) {
577
- clearInterval(this.#heartbeatTimer);
578
- this.#heartbeatTimer = null;
614
+ async #watchPublication(writeHeartbeat = true): Promise<void> {
615
+ if (!this.#publication || this.#publicationState === "stopping") return;
616
+ let observation: ReturnType<RetainedBrokerDiscovery["observe"]>;
617
+ try {
618
+ observation = this.#publication.observe();
619
+ } catch {
620
+ this.#fence("observation-ambiguous");
621
+ return;
579
622
  }
580
- await this.#heartbeatWrite;
581
- await Promise.allSettled(this.#chains.values());
582
- await this.#transport?.stop();
583
- this.#transport = null;
584
- if (this.discovery?.ownerId === this.#owner) {
585
- try {
586
- const disk = JSON.parse(await fs.readFile(brokerDiscoveryPath(this.settings.agentDir), "utf8")) as {
587
- ownerId?: string;
588
- };
589
- if (disk.ownerId === this.#owner) await fs.unlink(brokerDiscoveryPath(this.settings.agentDir));
590
- } catch (e) {
591
- if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
623
+ if (observation === "owned") {
624
+ if (this.#publicationState === "heartbeat-ambiguous") {
625
+ if (writeHeartbeat) await this.#writeHeartbeat();
626
+ return;
592
627
  }
593
- await this.#releaseOwnedLock();
628
+ this.#publicationState = "healthy-owned";
629
+ this.#lossAt = null;
630
+ if (writeHeartbeat) await this.#writeHeartbeat();
631
+ return;
632
+ }
633
+ this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
634
+ if (
635
+ this.#lossAt !== null &&
636
+ process.hrtime.bigint() - this.#lossAt >= BigInt(BROKER_PUBLICATION_GRACE_MS) * 1_000_000n
637
+ )
638
+ void this.#complete("lost-root");
639
+ }
640
+ async #writeHeartbeat(): Promise<void> {
641
+ if (!this.discovery || !this.#publication || this.#publicationState === "stopping") return;
642
+ const heartbeatAt = Date.now();
643
+ try {
644
+ if (!(await heartbeatBrokerDiscoveryRetained(this.#publication, heartbeatAt))) {
645
+ this.#fence("heartbeat-ambiguous");
646
+ return;
647
+ }
648
+ } catch {
649
+ this.#fence("heartbeat-ambiguous");
650
+ return;
594
651
  }
595
- this.discovery = null;
652
+ this.#publicationState = "healthy-owned";
653
+ this.#lossAt = null;
654
+ this.discovery = { ...this.discovery, heartbeatAt };
655
+ }
656
+ async heartbeat(): Promise<void> {
657
+ if (this.#publicationState !== "healthy-owned") return;
658
+ await this.#writeHeartbeat();
659
+ }
660
+ async #complete(mode: BrokerStopMode): Promise<void> {
661
+ if (this.#completionTask) return this.#completionTask;
662
+ this.#stopping = true;
663
+ this.#publicationState = "stopping";
664
+ if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
665
+ this.#heartbeatTimer = null;
666
+ this.#completionTask = (async () => {
667
+ await this.#transport?.stop();
668
+ this.#transport = null;
669
+ if (mode === "lost-root")
670
+ await Promise.race([Promise.allSettled(this.#admitted), Bun.sleep(BROKER_SETTLEMENT_MS)]);
671
+ else await Promise.allSettled(this.#admitted);
672
+ this.#publication?.close();
673
+ this.#publication = null;
674
+ if (mode === "owned-root" && this.discovery?.ownerId === this.#owner) {
675
+ try {
676
+ const disk = JSON.parse(await fs.readFile(brokerDiscoveryPath(this.settings.agentDir), "utf8")) as {
677
+ ownerId?: string;
678
+ };
679
+ if (disk.ownerId === this.#owner) await fs.unlink(brokerDiscoveryPath(this.settings.agentDir));
680
+ } catch (e) {
681
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
682
+ }
683
+ await this.#releaseOwnedLock();
684
+ }
685
+ this.discovery = null;
686
+ })();
687
+ void this.#completionTask.then(this.#resolveCompletion, this.#rejectCompletion);
688
+ return this.#completionTask;
689
+ }
690
+ async stop(): Promise<void> {
691
+ await this.#complete("owned-root");
596
692
  }
597
693
  async #endpoint(input: Record<string, unknown>): Promise<BrokerResponse> {
598
694
  const sessionId = input.sessionId;
@@ -634,7 +730,18 @@ export class Broker {
634
730
  throw e;
635
731
  }
636
732
  }
637
- async handleRequest(
733
+ handleRequest(operation: string, input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
734
+ if (this.#stopping || (this.#publication !== null && this.#publicationState !== "healthy-owned"))
735
+ return Promise.resolve(error("unavailable", "broker publication is unavailable"));
736
+ let release!: () => void;
737
+ const admission = new Promise<void>(resolve => (release = resolve));
738
+ this.#admitted.add(admission);
739
+ return this.#handleRequest(operation, input, idempotencyKey).finally(() => {
740
+ release();
741
+ this.#admitted.delete(admission);
742
+ });
743
+ }
744
+ async #handleRequest(
638
745
  operation: string,
639
746
  input: Record<string, unknown>,
640
747
  idempotencyKey?: string,
@@ -1,9 +1,97 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { type NativeRetainedBrokerPublication, retainBrokerPublication } from "@gajae-code/natives";
4
5
  import { processIncarnation } from "./process-incarnation";
5
6
  import { assertSupportedStateVersion, SDK_STATE_VERSION } from "./state-version";
6
7
 
8
+ export type BrokerPublicationObservation = "owned" | "absent" | "replaced" | "ambiguous";
9
+ export interface RetainedBrokerDiscovery {
10
+ observe(): BrokerPublicationObservation;
11
+ heartbeat(heartbeatAt: number): Promise<boolean>;
12
+ close(): void;
13
+ }
14
+
15
+ function requireRetainedBrokerPublication(agentDir: string): NativeRetainedBrokerPublication {
16
+ if (typeof retainBrokerPublication !== "function") {
17
+ throw new Error("Loaded native bindings do not expose retained broker publication authority.");
18
+ }
19
+ return retainBrokerPublication(agentDir);
20
+ }
21
+
22
+ async function rollbackPublishedBrokerDiscovery(agentDir: string, discovery: BrokerDiscovery): Promise<void> {
23
+ const file = brokerDiscoveryPath(agentDir);
24
+ try {
25
+ const raw: unknown = JSON.parse(await fs.readFile(file, "utf8"));
26
+ if (
27
+ !raw ||
28
+ typeof raw !== "object" ||
29
+ (raw as BrokerDiscovery).ownerId !== discovery.ownerId ||
30
+ (raw as BrokerDiscovery).pid !== discovery.pid ||
31
+ (raw as BrokerDiscovery).incarnation !== discovery.incarnation ||
32
+ (raw as BrokerDiscovery).token !== discovery.token ||
33
+ (raw as BrokerDiscovery).startedAt !== discovery.startedAt ||
34
+ (raw as BrokerDiscovery).heartbeatAt !== discovery.heartbeatAt
35
+ )
36
+ return;
37
+ await fs.unlink(file);
38
+ await syncDirectory(path.dirname(file));
39
+ } catch (error) {
40
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
41
+ }
42
+ }
43
+
44
+ class NativeRetainedBrokerDiscovery implements RetainedBrokerDiscovery {
45
+ #closed = false;
46
+ #publication: NativeRetainedBrokerPublication;
47
+ constructor(publication: NativeRetainedBrokerPublication) {
48
+ this.#publication = publication;
49
+ }
50
+ observe(): BrokerPublicationObservation {
51
+ if (this.#closed) return "ambiguous";
52
+ const kind = this.#publication.observe().kind;
53
+ return kind === "owned" || kind === "absent" || kind === "replaced" || kind === "ambiguous" ? kind : "ambiguous";
54
+ }
55
+ async heartbeat(heartbeatAt: number): Promise<boolean> {
56
+ if (this.#closed || !isFixedWidthHeartbeat(heartbeatAt)) return false;
57
+ const write = this.#publication.heartbeat(String(heartbeatAt));
58
+ if (write.kind !== "written") return false;
59
+ return this.#publication.sync().kind === "synced";
60
+ }
61
+ close(): void {
62
+ if (this.#closed) return;
63
+ this.#closed = true;
64
+ this.#publication.close();
65
+ }
66
+ }
67
+
68
+ class LegacyWindowsBrokerDiscovery implements RetainedBrokerDiscovery {
69
+ #closed = false;
70
+ #discovery: BrokerDiscovery;
71
+ #agentDir: string;
72
+ constructor(agentDir: string, discovery: BrokerDiscovery) {
73
+ this.#agentDir = agentDir;
74
+ this.#discovery = discovery;
75
+ }
76
+ observe(): BrokerPublicationObservation {
77
+ return this.#closed ? "ambiguous" : "owned";
78
+ }
79
+ async heartbeat(heartbeatAt: number): Promise<boolean> {
80
+ if (this.#closed || !isFixedWidthHeartbeat(heartbeatAt)) return false;
81
+ const next = { ...this.#discovery, heartbeatAt };
82
+ await writeBrokerDiscovery(this.#agentDir, next);
83
+ this.#discovery = next;
84
+ return true;
85
+ }
86
+ close(): void {
87
+ this.#closed = true;
88
+ }
89
+ }
90
+
91
+ function isFixedWidthHeartbeat(value: number): boolean {
92
+ return Number.isSafeInteger(value) && value >= 1_000_000_000_000 && value <= 9_999_999_999_999;
93
+ }
94
+
7
95
  export const BROKER_HEARTBEAT_TTL_MS = 15_000;
8
96
  export interface BrokerDiscovery {
9
97
  version: typeof SDK_STATE_VERSION;
@@ -63,6 +151,9 @@ export async function writeBrokerDiscovery(agentDir: string, discovery: BrokerDi
63
151
  const incarnation = discovery.incarnation ?? brokerProcessIncarnation(discovery.pid);
64
152
  if (!incarnation) throw new Error(`Broker process incarnation is unavailable for pid ${discovery.pid}.`);
65
153
  const record: BrokerDiscovery = { ...discovery, incarnation };
154
+ if (!isFixedWidthHeartbeat(discovery.heartbeatAt)) {
155
+ throw new Error("Broker heartbeatAt must be a fixed-width 13-digit millisecond timestamp.");
156
+ }
66
157
  const file = brokerDiscoveryPath(agentDir);
67
158
  const dir = path.dirname(file);
68
159
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
@@ -78,6 +169,40 @@ export async function writeBrokerDiscovery(agentDir: string, discovery: BrokerDi
78
169
  await fs.rm(temp, { force: true });
79
170
  }
80
171
  }
172
+
173
+ /** Publish once by name, then retain no-follow native authority for recurring heartbeats. */
174
+ export async function publishBrokerDiscovery(
175
+ agentDir: string,
176
+ discovery: BrokerDiscoveryWrite,
177
+ platform: NodeJS.Platform = process.platform,
178
+ ): Promise<RetainedBrokerDiscovery> {
179
+ const incarnation = discovery.incarnation ?? brokerProcessIncarnation(discovery.pid);
180
+ if (!incarnation) throw new Error(`Broker process incarnation is unavailable for pid ${discovery.pid}.`);
181
+ const published: BrokerDiscovery = { ...discovery, incarnation };
182
+ await writeBrokerDiscovery(agentDir, published);
183
+ if (platform === "win32") return new LegacyWindowsBrokerDiscovery(agentDir, published);
184
+ try {
185
+ return new NativeRetainedBrokerDiscovery(requireRetainedBrokerPublication(agentDir));
186
+ } catch (error) {
187
+ try {
188
+ await rollbackPublishedBrokerDiscovery(agentDir, published);
189
+ } catch (rollbackError) {
190
+ throw new AggregateError(
191
+ [error, rollbackError],
192
+ "Broker publication authority acquisition and exact rollback both failed.",
193
+ );
194
+ }
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ /** Heartbeat through retained authority, or the explicit Windows compatibility path. */
200
+ export function heartbeatBrokerDiscoveryRetained(
201
+ publication: RetainedBrokerDiscovery,
202
+ heartbeatAt: number,
203
+ ): Promise<boolean> {
204
+ return publication.heartbeat(heartbeatAt);
205
+ }
81
206
  export async function readBrokerDiscovery(
82
207
  agentDir: string,
83
208
  ttlMs = BROKER_HEARTBEAT_TTL_MS,
@@ -19,12 +19,32 @@ const FIXTURE_DISCOVERY_TIMEOUT_MS = 30_000;
19
19
  const REAP_GRACEFUL_MS = 2_000;
20
20
  const REAP_SIGKILL_CAP_MS = 2_000;
21
21
  export interface FixtureBrokerLease {
22
+ /** Backward-compatible fixture cleanup alias for exact child termination. */
22
23
  close(): Promise<void>;
23
24
  }
24
25
 
26
+ export interface ExactFixtureBrokerLease extends FixtureBrokerLease {
27
+ /** Observes the retained child only; it never signals a process. */
28
+ waitForExit(timeoutMs: number): Promise<boolean>;
29
+ /** Signals only the retained ChildProcess, never a discovery-derived PID. */
30
+ terminateExactChild(): Promise<void>;
31
+ }
32
+
33
+ export interface FixtureBrokerCommand {
34
+ file: string;
35
+ args: readonly string[];
36
+ cwd?: string;
37
+ env?: NodeJS.ProcessEnv;
38
+ }
39
+
40
+ export interface StartedFixtureBrokerCommand {
41
+ lease: ExactFixtureBrokerLease;
42
+ control: NodeJS.WritableStream;
43
+ }
44
+
25
45
  export interface StartedFixtureBroker {
26
46
  discovery: BrokerDiscovery;
27
- lease: FixtureBrokerLease;
47
+ lease: ExactFixtureBrokerLease;
28
48
  }
29
49
 
30
50
  interface BrokerOwner {
@@ -37,7 +57,7 @@ type EnsureOutcome =
37
57
  | { kind: "external-discovery"; discovery: BrokerDiscovery }
38
58
  | { kind: "prior-local-owner"; discovery: BrokerDiscovery; owner: BrokerOwner }
39
59
  | { kind: "local-started-discovery"; discovery: BrokerDiscovery }
40
- | { kind: "local-started-fixture"; discovery: BrokerDiscovery; owner: BrokerOwner };
60
+ | { kind: "local-started-fixture"; discovery: BrokerDiscovery; owner: BrokerOwner; child: ChildProcess };
41
61
  interface EnsureInFlight {
42
62
  initiator: EnsureInitiator;
43
63
  promise: Promise<EnsureOutcome>;
@@ -167,27 +187,48 @@ function fixtureLeaseUnavailable(): Error {
167
187
  return new Error("fixture_broker_lease_unavailable");
168
188
  }
169
189
 
170
- function createFixtureLease(owner: BrokerOwner): FixtureBrokerLease {
171
- let closeAttempt: Promise<void> | undefined;
172
- let closed = false;
190
+ function createFixtureLeaseFromChild(child: ChildProcess, terminate: () => Promise<void>): ExactFixtureBrokerLease {
191
+ let termination: Promise<void> | undefined;
192
+ const hasExited = (): boolean => child.exitCode !== null || child.signalCode !== null || child.pid === undefined;
193
+ const waitForExit = (timeoutMs: number): Promise<boolean> => {
194
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0)
195
+ return Promise.reject(new Error("Invalid fixture broker exit timeout."));
196
+ if (hasExited()) return Promise.resolve(true);
197
+ return new Promise(resolve => {
198
+ let settled = false;
199
+ let timer: ReturnType<typeof setTimeout> | undefined;
200
+ const onExit = (): void => finish(true);
201
+ const finish = (exited: boolean): void => {
202
+ if (settled) return;
203
+ settled = true;
204
+ if (timer) clearTimeout(timer);
205
+ child.off("exit", onExit);
206
+ child.off("close", onExit);
207
+ resolve(exited && hasExited());
208
+ };
209
+ timer = setTimeout(() => finish(false), timeoutMs);
210
+ child.once("exit", onExit);
211
+ child.once("close", onExit);
212
+ if (hasExited()) finish(true);
213
+ });
214
+ };
173
215
  return {
216
+ waitForExit,
217
+ terminateExactChild(): Promise<void> {
218
+ if (!termination) termination = terminate();
219
+ return termination;
220
+ },
174
221
  close(): Promise<void> {
175
- if (closed) return Promise.resolve();
176
- if (closeAttempt) return closeAttempt;
177
- closeAttempt = owner.stop().then(
178
- () => {
179
- closed = true;
180
- },
181
- (error: unknown) => {
182
- closeAttempt = undefined;
183
- throw error;
184
- },
185
- );
186
- return closeAttempt;
222
+ if (!termination) termination = terminate();
223
+ return termination;
187
224
  },
188
225
  };
189
226
  }
190
227
 
228
+ function createFixtureLease(owner: BrokerOwner, child: ChildProcess): ExactFixtureBrokerLease {
229
+ return createFixtureLeaseFromChild(child, () => owner.stop());
230
+ }
231
+
191
232
  async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: EnsureInitiator): Promise<EnsureOutcome> {
192
233
  const priorOwner = owners.get(settings.agentDir);
193
234
  const existing = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
@@ -226,7 +267,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
226
267
  if (discovered) {
227
268
  if (owner.markReady(discovered)) {
228
269
  return initiator === "fixture-lease"
229
- ? { kind: "local-started-fixture", discovery: discovered, owner }
270
+ ? { kind: "local-started-fixture", discovery: discovered, owner, child }
230
271
  : { kind: "local-started-discovery", discovery: discovered };
231
272
  }
232
273
  await owner.stop();
@@ -280,10 +321,53 @@ export function startFixtureBrokerWithLeaseForTest(settings: EnsureBrokerSetting
280
321
  const inFlight = startEnsure(settings, "fixture-lease");
281
322
  return inFlight.promise.then(outcome => {
282
323
  if (outcome.kind !== "local-started-fixture") throw fixtureLeaseUnavailable();
283
- return { discovery: outcome.discovery, lease: createFixtureLease(outcome.owner) };
324
+ return { discovery: outcome.discovery, lease: createFixtureLease(outcome.owner, outcome.child) };
284
325
  });
285
326
  }
286
327
 
328
+ /**
329
+ * Test-only launch surface for topology fixtures. It accepts an already-resolved
330
+ * command and retains the exact spawned child; no production selection path
331
+ * reaches this function.
332
+ */
333
+ export function startFixtureBrokerCommandWithLeaseForTest(command: FixtureBrokerCommand): StartedFixtureBrokerCommand {
334
+ if (!command.file || !Array.isArray(command.args)) throw new Error("Invalid fixture broker command.");
335
+ const child = spawn(command.file, [...command.args], {
336
+ cwd: command.cwd,
337
+ detached: true,
338
+ stdio: ["ignore", "ignore", "ignore", "pipe"],
339
+ env: command.env,
340
+ });
341
+ child.unref();
342
+ let spawnError: Error | undefined;
343
+ child.once("error", error => {
344
+ spawnError = error;
345
+ });
346
+ const control = child.stdio[3];
347
+ if (!control || typeof (control as NodeJS.WritableStream).write !== "function") {
348
+ try {
349
+ if (!child.kill("SIGKILL"))
350
+ throw new Error(
351
+ "Fixture broker fd 3 is unavailable and the exact child could not be synchronously terminated.",
352
+ );
353
+ } catch (reapError) {
354
+ throw new AggregateError(
355
+ [reapError],
356
+ "Fixture broker fd 3 is unavailable and the exact child could not be synchronously terminated.",
357
+ );
358
+ }
359
+ if (spawnError) throw new Error(`Failed to spawn fixture broker: ${spawnError.message}`);
360
+ throw new Error("Fixture broker fd 3 is unavailable.");
361
+ }
362
+ return {
363
+ lease: createFixtureLeaseFromChild(child, async () => {
364
+ await reapSpawnedBroker(child);
365
+ if (spawnError) throw new Error(`Failed to spawn fixture broker: ${spawnError.message}`);
366
+ }),
367
+ control: control as NodeJS.WritableStream,
368
+ };
369
+ }
370
+
287
371
  /** Test hook: returns a stop handle for the detached broker this process spawned. */
288
372
  export function brokerOwnerForTest(agentDir: string): BrokerOwner | undefined {
289
373
  return owners.get(agentDir);
@@ -0,0 +1,10 @@
1
+ import type { Broker } from "./broker";
2
+
3
+ /** Wait for broker-local completion, then terminate only the current broker process. */
4
+ export async function completeBrokerProcess(
5
+ broker: Broker,
6
+ exit: (code: number) => never = code => process.exit(code),
7
+ ): Promise<never> {
8
+ await broker.completion;
9
+ return exit(0);
10
+ }