@gajae-code/coding-agent 0.12.6 → 0.12.8

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 (150) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/dist/types/async/job-manager.d.ts +36 -1
  3. package/dist/types/commands/notify.d.ts +4 -0
  4. package/dist/types/config/file-lock.d.ts +3 -0
  5. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  6. package/dist/types/config/model-registry.d.ts +2 -0
  7. package/dist/types/config/resolve-config-value.d.ts +2 -1
  8. package/dist/types/config/settings-schema.d.ts +17 -1
  9. package/dist/types/eval/backend.d.ts +2 -2
  10. package/dist/types/exec/bash-executor.d.ts +8 -10
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -36
  12. package/dist/types/internal-urls/registry-helpers.d.ts +0 -18
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  15. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  16. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  17. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  18. package/dist/types/sdk/broker/broker.d.ts +5 -1
  19. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -3
  21. package/dist/types/sdk/bus/config.d.ts +75 -113
  22. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  23. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  24. package/dist/types/sdk/bus/index.d.ts +1 -1
  25. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  26. package/dist/types/sdk/bus/notification-service.d.ts +31 -4
  27. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  28. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  29. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
  31. package/dist/types/sdk/client/discovery.d.ts +2 -1
  32. package/dist/types/sdk/host/host.d.ts +2 -23
  33. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  34. package/dist/types/sdk/host/reverse-leases.d.ts +4 -7
  35. package/dist/types/sdk/index.d.ts +1 -0
  36. package/dist/types/sdk/providers.d.ts +22 -0
  37. package/dist/types/sdk/session-directory.d.ts +1 -1
  38. package/dist/types/session/artifacts.d.ts +5 -8
  39. package/dist/types/session/client-bridge.d.ts +0 -14
  40. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  41. package/dist/types/session/internal/managed-session-storage.d.ts +11 -4
  42. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -0
  43. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  44. package/dist/types/session/messages.d.ts +1 -4
  45. package/dist/types/session/session-manager.d.ts +7 -6
  46. package/dist/types/session/session-storage.d.ts +2 -0
  47. package/dist/types/session/streaming-output.d.ts +0 -32
  48. package/dist/types/task/receipt.d.ts +1 -2
  49. package/dist/types/task/types.d.ts +43 -178
  50. package/dist/types/tools/bash.d.ts +1 -1
  51. package/dist/types/tools/fetch.d.ts +0 -3
  52. package/dist/types/tools/output-meta.d.ts +5 -37
  53. package/dist/types/tools/subagent.d.ts +15 -0
  54. package/dist/types/tools/todo-write.d.ts +3 -0
  55. package/dist/types/tools/tool-result.d.ts +0 -1
  56. package/package.json +10 -8
  57. package/scripts/build-sdk-package-smoke.ts +51 -0
  58. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  59. package/src/async/job-manager.ts +430 -46
  60. package/src/cli/notify-cli.ts +204 -94
  61. package/src/commands/notify.ts +19 -8
  62. package/src/config/file-lock.ts +6 -0
  63. package/src/config/model-discovery-manager.ts +50 -8
  64. package/src/config/model-registry.ts +862 -75
  65. package/src/config/resolve-config-value.ts +10 -8
  66. package/src/config/settings-schema.ts +25 -15
  67. package/src/config/settings.ts +15 -15
  68. package/src/cursor.ts +13 -20
  69. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -3
  70. package/src/eval/backend.ts +2 -2
  71. package/src/exec/bash-executor.ts +151 -425
  72. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  73. package/src/gjc-runtime/ultragoal-runtime.ts +3 -280
  74. package/src/gjc-runtime/workflow-manifest.generated.json +1 -40
  75. package/src/gjc-runtime/workflow-manifest.ts +0 -12
  76. package/src/internal-urls/agent-protocol.ts +38 -98
  77. package/src/internal-urls/artifact-protocol.ts +17 -28
  78. package/src/internal-urls/docs-index.generated.ts +7 -7
  79. package/src/internal-urls/registry-helpers.ts +0 -62
  80. package/src/main.ts +43 -7
  81. package/src/modes/DESIGN.md +11 -11
  82. package/src/modes/acp/acp-agent.ts +0 -24
  83. package/src/modes/acp/acp-client-bridge.ts +11 -154
  84. package/src/modes/components/hook-selector.ts +155 -44
  85. package/src/modes/components/model-selector.ts +242 -49
  86. package/src/modes/components/notifications-settings-editor.ts +631 -85
  87. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  88. package/src/modes/controllers/selector-controller.ts +464 -50
  89. package/src/prompts/tools/bash.md +2 -2
  90. package/src/sdk/acp/adapter.ts +9 -200
  91. package/src/sdk/broker/broker.ts +54 -10
  92. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  93. package/src/sdk/broker/lifecycle.ts +47 -50
  94. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  95. package/src/sdk/bus/chat-daemon-control.ts +35 -8
  96. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  97. package/src/sdk/bus/config.ts +534 -286
  98. package/src/sdk/bus/discord-live-provider.ts +57 -2
  99. package/src/sdk/bus/discord-provider.ts +21 -0
  100. package/src/sdk/bus/index.ts +105 -359
  101. package/src/sdk/bus/notification-orchestration.ts +371 -68
  102. package/src/sdk/bus/notification-service.ts +431 -130
  103. package/src/sdk/bus/session-control.ts +119 -44
  104. package/src/sdk/bus/slack-live-provider.ts +78 -2
  105. package/src/sdk/bus/slack-provider.ts +24 -0
  106. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  107. package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
  108. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  109. package/src/sdk/bus/telegram-daemon.ts +23 -15
  110. package/src/sdk/client/discovery.ts +10 -4
  111. package/src/sdk/host/host.ts +9 -187
  112. package/src/sdk/host/query/handlers.ts +20 -1
  113. package/src/sdk/host/reverse-leases.ts +42 -249
  114. package/src/sdk/index.ts +1 -0
  115. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  116. package/src/sdk/protocol/operation-registry.ts +5 -2
  117. package/src/sdk/providers.ts +51 -0
  118. package/src/sdk/session-directory.ts +1 -0
  119. package/src/sdk/session.ts +10 -7
  120. package/src/session/agent-session.ts +59 -202
  121. package/src/session/artifacts.ts +74 -211
  122. package/src/session/client-bridge.ts +0 -15
  123. package/src/session/internal/managed-session-scope.ts +981 -450
  124. package/src/session/internal/managed-session-storage.ts +362 -124
  125. package/src/session/internal/native-publish-outcome.ts +3 -1
  126. package/src/session/internal/session-open-errors.ts +11 -0
  127. package/src/session/messages.ts +7 -32
  128. package/src/session/session-manager.ts +48 -15
  129. package/src/session/session-storage.ts +72 -5
  130. package/src/session/streaming-output.ts +40 -198
  131. package/src/slash-commands/builtin-registry.ts +82 -4
  132. package/src/task/index.ts +237 -65
  133. package/src/task/receipt.ts +2 -4
  134. package/src/task/render.ts +0 -30
  135. package/src/task/types.ts +10 -34
  136. package/src/tools/ask.ts +1 -1
  137. package/src/tools/bash.ts +239 -818
  138. package/src/tools/eval.ts +4 -99
  139. package/src/tools/fetch.ts +15 -77
  140. package/src/tools/find.ts +3 -7
  141. package/src/tools/output-meta.ts +114 -696
  142. package/src/tools/search.ts +4 -9
  143. package/src/tools/subagent.ts +97 -75
  144. package/src/tools/telegram-send.ts +4 -2
  145. package/src/tools/todo-write.ts +47 -0
  146. package/src/tools/tool-result.ts +0 -1
  147. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +0 -83
  148. package/dist/types/utils/artifact-id.d.ts +0 -2
  149. package/src/gjc-runtime/ultragoal-review-source.ts +0 -378
  150. package/src/utils/artifact-id.ts +0 -6
@@ -65,5 +65,5 @@ Use it only for sanctioned GJC workflow CLI persistence or state read/write/cont
65
65
  # Output minimizer
66
66
 
67
67
  - Bash stdout/stderr may be rewritten before you see it: long output keeps only the last 1 KiB by default to reduce noise and input-token use. Explicit `tools.artifactTailBytes` / `tools.artifactHeadBytes` settings can set the tail budget or retain both ends. Prefer focused commands and dedicated `search`/`find` tools over producing broad output. Test/lint runners (e.g. `bun test`, `cargo test`, ESLint) are also passed through heuristic filters that drop noise and keep failures.
68
- - When the local minimizer changes visible text, successful artifact storage appends a footer containing an `artifact://<id>` reference. Artifact allocation is best-effort on direct, async, and monitor paths, and only decimal session artifact ids are accepted. Native callback delivery bounds both the core-to-native relay and N-API queue with backpressure and preserves a 64 KiB terminal tail after its core stream budget, emitting at most one typed aggregate loss marker. Complete artifacts are labeled as full output; hard-capped artifacts, streams that lost bytes before Bash capture, reader/client captures that cannot prove completeness, and loss counters beyond JavaScript's exact integer range report incompleteness instead. If artifact allocation/storage is unavailable before a writer/save operation is attempted, truncation may have no reference or diagnostic. If an artifact writer/save operation is attempted and fails, a bounded diagnostic is emitted without inventing an artifact URI.
69
- - ACP/client-terminal output can arrive already truncated from the beginning. Terminal creation, kill, output-recovery, and release RPCs are settlement-bounded; stalled or failed exit/kill/output operations report source-capture uncertainty instead of hanging or claiming completeness. Treat any truncation notice or metadata as evidence that the visible tail is incomplete. Recover omitted output only when an `artifact://` footer or metadata reference is present; truncation evidence without a reference means the visible output is incomplete and no artifact is recoverable. Output with neither truncation evidence nor an artifact reference is the complete emitted output.
68
+ - When the local minimizer changes visible text, successful artifact storage appends a footer containing an `artifact://<id>` reference. Complete artifacts are labeled as full output; hard-capped artifacts report omitted bytes instead. If artifact allocation/storage is unavailable before a writer/save operation is attempted, truncation may have no reference or diagnostic. If an artifact writer/save operation is attempted and fails, a bounded diagnostic is emitted without inventing an artifact URI.
69
+ - ACP/client-terminal output can arrive already truncated from the beginning. Treat any truncation notice or metadata as evidence that the visible tail is incomplete. Recover omitted output only when an `artifact://` footer or metadata reference is present; truncation evidence without a reference means the visible output is incomplete and no artifact is recoverable. Output with neither truncation evidence nor an artifact reference is the complete emitted output.
@@ -42,9 +42,6 @@ export type AcpReconnectFailedHandler = (error: SdkClientError) => void;
42
42
  export type AcpFrameHandler = (frame: SdkFrame) => void;
43
43
  type ReverseRequest = {
44
44
  state: "pending" | "cancelled";
45
- connectionId?: string;
46
- capability?: string;
47
- leaseId?: string;
48
45
  controller?: AbortController;
49
46
  cancelTimer?: NodeJS.Timeout;
50
47
  };
@@ -68,15 +65,6 @@ function isLifecycleOperation(operation: string): boolean {
68
65
  );
69
66
  }
70
67
 
71
- const MAX_PENDING_REVERSE_PROVIDER_CALLS = 64;
72
-
73
- type ProviderCallToken = {
74
- method: string;
75
- promise?: Promise<unknown>;
76
- };
77
-
78
- const PROVIDER_CLOSE_SETTLEMENT_MS = 1_000;
79
-
80
68
  /**
81
69
  * Pure ACP-to-SDK adapter. It deliberately owns neither an AgentSession nor an
82
70
  * ACP bridge: all session work is performed through authenticated v3 frames.
@@ -95,13 +83,10 @@ export class AcpSdkAdapter {
95
83
  #reclaiming?: Promise<void>;
96
84
  #reverseRequests = new Map<string, ReverseRequest>();
97
85
  #reconnectFailedHandlers = new Set<AcpReconnectFailedHandler>();
98
- #pendingReverseProviderCalls = new Set<ProviderCallToken>();
99
86
  #frameHandlers = new Set<AcpFrameHandler>();
100
87
 
101
88
  #reverseCancelTtlMs: number;
102
89
  #closed = false;
103
- #closePromise?: Promise<void>;
104
- #terminalCleanupUncertain = false;
105
90
 
106
91
  constructor(options: AcpSdkAdapterOptions) {
107
92
  this.#client = options.client ?? new SdkClient(options.url, options.token);
@@ -155,16 +140,10 @@ export class AcpSdkAdapter {
155
140
  }
156
141
 
157
142
  async close(): Promise<void> {
158
- this.#closePromise ??= this.#closeInternal();
159
- await this.#closePromise;
160
- }
161
-
162
- async #closeInternal(): Promise<void> {
163
143
  if (this.#closed) return;
164
144
  this.#closed = true;
165
145
  if (this.#heartbeat) clearInterval(this.#heartbeat);
166
- for (const [id, request] of this.#reverseRequests) {
167
- if (request.state === "pending") this.#sendReverseCancellation(id, request);
146
+ for (const request of this.#reverseRequests.values()) {
168
147
  request.controller?.abort();
169
148
  if (request.cancelTimer) clearTimeout(request.cancelTimer);
170
149
  }
@@ -172,21 +151,7 @@ export class AcpSdkAdapter {
172
151
  this.#unsubscribe?.();
173
152
  this.#unsubscribeReconnect?.();
174
153
  this.#unsubscribeReconnectFailed?.();
175
-
176
- const terminalSettled = await this.#awaitTerminalProviderSettlement();
177
- let clientError: unknown;
178
- try {
179
- await this.#client.close();
180
- } catch (error) {
181
- clientError = error;
182
- }
183
- if (!terminalSettled) {
184
- throw new AcpSdkAdapterError(
185
- "ownership_uncertain",
186
- "ACP terminal provider cleanup did not settle before adapter close.",
187
- );
188
- }
189
- if (clientError) throw clientError;
154
+ await this.#client.close();
190
155
  }
191
156
 
192
157
  async prompt(params: JsonObject | string): Promise<unknown> {
@@ -390,7 +355,7 @@ export class AcpSdkAdapter {
390
355
  )
391
356
  return;
392
357
  const controller = new AbortController();
393
- const active: ReverseRequest = { state: "pending", connectionId, capability, leaseId, controller };
358
+ const active: ReverseRequest = { state: "pending", controller };
394
359
  this.#reverseRequests.set(id, active);
395
360
  try {
396
361
  const request = frame.payload as JsonObject | undefined;
@@ -458,175 +423,19 @@ export class AcpSdkAdapter {
458
423
 
459
424
  #abortActiveReverseRequests(): void {
460
425
  for (const [id, request] of this.#reverseRequests) {
461
- if (request.state === "pending") this.#sendReverseCancellation(id, request);
462
426
  request.state = "cancelled";
463
427
  request.controller?.abort();
464
428
  this.#finishReverse(id, request);
465
429
  }
466
430
  }
467
431
 
468
- #sendReverseCancellation(id: string, request: ReverseRequest): void {
469
- if (!request.connectionId || !request.leaseId) return;
470
- try {
471
- const cancellation = this.#client.send({
472
- type: "reverse_cancel",
473
- id,
474
- connectionId: request.connectionId,
475
- leaseId: request.leaseId,
476
- });
477
- void Promise.resolve(cancellation).catch(() => {});
478
- } catch {
479
- // Cancellation is best effort while local retirement remains authoritative.
480
- }
481
- }
482
-
483
- async #awaitTerminalProviderSettlement(): Promise<boolean> {
484
- const deadline = Date.now() + PROVIDER_CLOSE_SETTLEMENT_MS;
485
- for (;;) {
486
- const pending = [...this.#pendingReverseProviderCalls].filter(token =>
487
- ["terminal.create", "terminal.kill", "terminal.release"].includes(token.method),
488
- );
489
- if (pending.length === 0) return !this.#terminalCleanupUncertain;
490
- const remaining = deadline - Date.now();
491
- if (remaining <= 0) return false;
492
- const promises = pending.flatMap(token => (token.promise ? [token.promise] : []));
493
- if (promises.length === 0) {
494
- await Bun.sleep(Math.min(remaining, 1));
495
- continue;
496
- }
497
- await Promise.race([Promise.allSettled(promises), Bun.sleep(remaining)]);
498
- }
499
- }
500
-
501
- #reserveProviderCalls(method: string): { call: ProviderCallToken; cleanup: ProviderCallToken[] } {
502
- const cleanup =
503
- method === "terminal.create"
504
- ? ([{ method: "terminal.kill" }, { method: "terminal.release" }] satisfies ProviderCallToken[])
505
- : [];
506
- const required = 1 + cleanup.length;
507
- if (this.#pendingReverseProviderCalls.size + required > MAX_PENDING_REVERSE_PROVIDER_CALLS)
508
- throw new AcpSdkAdapterError("acp_reverse_capacity", "ACP reverse provider call capacity reached.");
509
- const call: ProviderCallToken = { method };
510
- this.#pendingReverseProviderCalls.add(call);
511
- for (const token of cleanup) this.#pendingReverseProviderCalls.add(token);
512
- return { call, cleanup };
513
- }
514
-
515
- #releaseReservedProviderCalls(tokens: readonly ProviderCallToken[]): void {
516
- for (const token of tokens) {
517
- if (!token.promise) this.#pendingReverseProviderCalls.delete(token);
518
- }
519
- }
520
-
521
- #startProviderCall(
522
- method: string,
523
- payload: JsonObject,
524
- signal: AbortSignal | undefined,
525
- token: ProviderCallToken,
526
- ): Promise<unknown> {
527
- let providerCall: Promise<unknown>;
528
- try {
529
- if (!this.#connection) throw new AcpSdkAdapterError("acp_reverse_unavailable");
530
- if (this.#connection.request)
531
- providerCall = Promise.resolve(this.#connection.request(method, payload, { cancellationSignal: signal }));
532
- else {
533
- const target = this.#connection[method];
534
- if (typeof target !== "function")
535
- throw new AcpSdkAdapterError("acp_reverse_unsupported", `ACP client does not support ${method}.`);
536
- providerCall = Promise.resolve((target as (params: JsonObject) => Promise<unknown>)(payload));
537
- }
538
- } catch (error) {
539
- if (token.method === "terminal.kill" || token.method === "terminal.release")
540
- this.#terminalCleanupUncertain = true;
541
- this.#pendingReverseProviderCalls.delete(token);
542
- return Promise.reject(error);
543
- }
544
- token.promise = providerCall;
545
- void providerCall.then(
546
- () => this.#pendingReverseProviderCalls.delete(token),
547
- () => {
548
- if (token.method === "terminal.kill" || token.method === "terminal.release")
549
- this.#terminalCleanupUncertain = true;
550
- this.#pendingReverseProviderCalls.delete(token);
551
- },
552
- );
553
- return providerCall;
554
- }
555
-
556
432
  async #forwardReverse(method: string, payload: JsonObject, signal: AbortSignal): Promise<unknown> {
557
433
  if (!method || !this.#connection) throw new AcpSdkAdapterError("acp_reverse_unavailable");
558
- if (signal.aborted) throw new AcpSdkAdapterError("request_cancelled", "Reverse request was cancelled.");
559
- const reservation = this.#reserveProviderCalls(method);
560
- const providerCall = this.#startProviderCall(method, payload, signal, reservation.call);
561
- const cancelled = Promise.withResolvers<{ status: "cancelled" }>();
562
- const onAbort = () => cancelled.resolve({ status: "cancelled" });
563
- signal.addEventListener("abort", onAbort, { once: true });
564
- if (signal.aborted) cancelled.resolve({ status: "cancelled" });
565
- let outcome:
566
- | { status: "resolved"; result: unknown }
567
- | { status: "rejected"; error: unknown }
568
- | { status: "cancelled" };
569
- try {
570
- outcome = await Promise.race([
571
- providerCall.then(
572
- result => ({ status: "resolved" as const, result }),
573
- error => ({ status: "rejected" as const, error }),
574
- ),
575
- cancelled.promise,
576
- ]);
577
- } finally {
578
- signal.removeEventListener("abort", onAbort);
579
- }
580
- if (outcome.status === "cancelled" || signal.aborted) {
581
- if (method === "terminal.create") {
582
- void providerCall
583
- .then(
584
- result => this.#cleanupCancelledTerminalCreate(result, reservation.cleanup),
585
- () => {
586
- this.#releaseReservedProviderCalls(reservation.cleanup);
587
- },
588
- )
589
- .catch(() => {
590
- this.#terminalCleanupUncertain = true;
591
- this.#releaseReservedProviderCalls(reservation.cleanup);
592
- });
593
- } else this.#releaseReservedProviderCalls(reservation.cleanup);
594
- throw new AcpSdkAdapterError("request_cancelled", "Reverse request was cancelled.");
595
- }
596
- this.#releaseReservedProviderCalls(reservation.cleanup);
597
- if (outcome.status === "rejected") throw outcome.error;
598
- return outcome.result;
599
- }
600
-
601
- async #cleanupCancelledTerminalCreate(result: unknown, cleanupTokens: readonly ProviderCallToken[]): Promise<void> {
602
- const terminalId =
603
- result && typeof result === "object" && typeof (result as { terminalId?: unknown }).terminalId === "string"
604
- ? (result as { terminalId: string }).terminalId
605
- : undefined;
606
- if (!terminalId || !this.#connection) {
607
- this.#terminalCleanupUncertain = true;
608
- this.#releaseReservedProviderCalls(cleanupTokens);
609
- return;
610
- }
611
- for (const [index, method] of (["terminal.kill", "terminal.release"] as const).entries()) {
612
- const token = cleanupTokens[index];
613
- if (!token) continue;
614
- let providerCall: Promise<unknown>;
615
- try {
616
- providerCall = this.#startProviderCall(method, { terminalId }, AbortSignal.timeout(500), token);
617
- } catch {
618
- this.#terminalCleanupUncertain = true;
619
- this.#releaseReservedProviderCalls([token]);
620
- continue;
621
- }
622
- const cleanupOutcome = await Promise.race([
623
- providerCall.then(
624
- () => "settled" as const,
625
- () => "failed" as const,
626
- ),
627
- Bun.sleep(500).then(() => "timeout" as const),
628
- ]);
629
- if (cleanupOutcome === "failed") this.#terminalCleanupUncertain = true;
630
- }
434
+ if (this.#connection.request)
435
+ return await this.#connection.request(method, payload, { cancellationSignal: signal });
436
+ const target = this.#connection[method];
437
+ if (typeof target !== "function")
438
+ throw new AcpSdkAdapterError("acp_reverse_unsupported", `ACP client does not support ${method}.`);
439
+ return await (target as (params: JsonObject) => Promise<unknown>)(payload);
631
440
  }
632
441
  }
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  BROKER_HEARTBEAT_TTL_MS,
14
14
  type BrokerDiscovery,
15
+ type BrokerPublicationObservation,
15
16
  brokerDiscoveryPath,
16
17
  brokerProcessIncarnation,
17
18
  heartbeatBrokerDiscoveryRetained,
@@ -396,6 +397,14 @@ type BrokerLockSnapshot = {
396
397
 
397
398
  const BROKER_PUBLICATION_CADENCE_MS = 5_000;
398
399
  const BROKER_PUBLICATION_GRACE_MS = 15_000;
400
+ // A broker that cannot observe its own publication is not provably the root, but
401
+ // ambiguity is also not proof of replacement, so it must not be treated as a
402
+ // `lost-root` immediately. It must still be bounded: an indefinitely ambiguous
403
+ // broker stops heartbeating, so peers discover it as stale and spawn replacements
404
+ // while it keeps its port and memory forever. Ambiguity therefore accrues against
405
+ // its own deadline, generous enough to absorb transient filesystem faults and far
406
+ // longer than the loss grace.
407
+ const BROKER_AMBIGUITY_GRACE_MS = 120_000;
399
408
  const BROKER_SETTLEMENT_MS = 2_000;
400
409
  type BrokerPublicationState =
401
410
  | "healthy-owned"
@@ -406,6 +415,8 @@ type BrokerPublicationState =
406
415
  type BrokerStopMode = "owned-root" | "lost-root";
407
416
 
408
417
  const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
418
+ const ambiguityGraceOverridesForTest = new WeakMap<Broker, number>();
419
+ const publicationObservationOverridesForTest = new WeakMap<Broker, BrokerPublicationObservation>();
409
420
 
410
421
  export class Broker {
411
422
  readonly settings: ResolvedBrokerSettings;
@@ -419,6 +430,7 @@ export class Broker {
419
430
  #publication: RetainedBrokerDiscovery | null = null;
420
431
  #publicationState: BrokerPublicationState = "healthy-owned";
421
432
  #lossAt: bigint | null = null;
433
+ #ambiguousAt: bigint | null = null;
422
434
  #stopping = false;
423
435
  #transport: BrokerTransport | null = null;
424
436
  #heartbeatTimer: NodeJS.Timeout | null = null;
@@ -565,6 +577,7 @@ export class Broker {
565
577
  this.#stopping = false;
566
578
  this.#publicationState = "healthy-owned";
567
579
  this.#lossAt = null;
580
+ this.#ambiguousAt = null;
568
581
  await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
569
582
  await fs.mkdir(path.dirname(this.#lock), { recursive: true, mode: 0o700 });
570
583
  for (;;) {
@@ -648,16 +661,35 @@ export class Broker {
648
661
  #fence(kind: "suspect-unpublished" | "observation-ambiguous" | "heartbeat-ambiguous"): void {
649
662
  if (this.#publicationState === "stopping") return;
650
663
  this.#publicationState = kind;
651
- if (kind === "suspect-unpublished") this.#lossAt ??= process.hrtime.bigint();
652
- else this.#lossAt = null;
664
+ if (kind === "suspect-unpublished") {
665
+ this.#lossAt ??= process.hrtime.bigint();
666
+ this.#ambiguousAt = null;
667
+ } else {
668
+ this.#lossAt = null;
669
+ this.#ambiguousAt ??= process.hrtime.bigint();
670
+ }
671
+ }
672
+ /**
673
+ * Whether this broker has been unable to confirm it is the published root for
674
+ * longer than the deadline for its current fence. Replacement is proven quickly
675
+ * and ambiguity slowly, but neither may persist indefinitely: a permanently
676
+ * fenced broker never heartbeats, so it is unreachable through discovery while
677
+ * still holding its port and memory.
678
+ */
679
+ #fencedBeyondDeadline(): boolean {
680
+ const now = process.hrtime.bigint();
681
+ if (this.#lossAt !== null && now - this.#lossAt >= BigInt(BROKER_PUBLICATION_GRACE_MS) * 1_000_000n) return true;
682
+ const ambiguityGraceMs = ambiguityGraceOverridesForTest.get(this) ?? BROKER_AMBIGUITY_GRACE_MS;
683
+ return this.#ambiguousAt !== null && now - this.#ambiguousAt >= BigInt(ambiguityGraceMs) * 1_000_000n;
653
684
  }
654
685
  async #watchPublication(writeHeartbeat = true): Promise<void> {
655
686
  if (!this.#publication || this.#publicationState === "stopping") return;
656
687
  let observation: ReturnType<RetainedBrokerDiscovery["observe"]>;
657
688
  try {
658
- observation = this.#publication.observe();
689
+ observation = publicationObservationOverridesForTest.get(this) ?? this.#publication.observe();
659
690
  } catch {
660
691
  this.#fence("observation-ambiguous");
692
+ if (this.#fencedBeyondDeadline()) void this.#complete("lost-root");
661
693
  return;
662
694
  }
663
695
  if (observation === "owned") {
@@ -667,15 +699,12 @@ export class Broker {
667
699
  }
668
700
  this.#publicationState = "healthy-owned";
669
701
  this.#lossAt = null;
702
+ this.#ambiguousAt = null;
670
703
  if (writeHeartbeat) await this.#writeHeartbeat();
671
704
  return;
672
705
  }
673
706
  this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
674
- if (
675
- this.#lossAt !== null &&
676
- process.hrtime.bigint() - this.#lossAt >= BigInt(BROKER_PUBLICATION_GRACE_MS) * 1_000_000n
677
- )
678
- void this.#complete("lost-root");
707
+ if (this.#fencedBeyondDeadline()) void this.#complete("lost-root");
679
708
  }
680
709
  async #writeHeartbeat(): Promise<void> {
681
710
  if (!this.discovery || !this.#publication || this.#publicationState === "stopping") return;
@@ -691,6 +720,7 @@ export class Broker {
691
720
  }
692
721
  this.#publicationState = "healthy-owned";
693
722
  this.#lossAt = null;
723
+ this.#ambiguousAt = null;
694
724
  this.discovery = { ...this.discovery, heartbeatAt };
695
725
  }
696
726
  async heartbeat(): Promise<void> {
@@ -938,9 +968,8 @@ export class Broker {
938
968
  });
939
969
  if (isCleanupPending(response)) return response;
940
970
  const persisted = await this.ledger.readTerminal(identity, requestHash);
941
- const expectedResponseDigest = createHash("sha256").update(canonicalJson(response)).digest("hex");
942
971
  const persistenceVerified =
943
- persisted?.responseDigest === expectedResponseDigest &&
972
+ persisted !== undefined &&
944
973
  canonicalJson(persisted.response) === canonicalJson(response) &&
945
974
  canonicalJson(persisted.durableEffects) === canonicalJson(outcome.durableEffects) &&
946
975
  canonicalJson(persisted.startupFailure) === canonicalJson(outcome.startupFailure);
@@ -972,3 +1001,18 @@ export function setTerminalPersistenceHookForTest(broker: Broker, hook: (() => v
972
1001
  if (hook) terminalPersistenceHooksForTest.set(broker, hook);
973
1002
  else terminalPersistenceHooksForTest.delete(broker);
974
1003
  }
1004
+
1005
+ /** Test-only hook for shortening the bounded ambiguity deadline. */
1006
+ export function setAmbiguityGraceForTest(broker: Broker, graceMs: number | undefined): void {
1007
+ if (graceMs === undefined) ambiguityGraceOverridesForTest.delete(broker);
1008
+ else ambiguityGraceOverridesForTest.set(broker, graceMs);
1009
+ }
1010
+
1011
+ /** Test-only hook for forcing the observation the publication watchdog sees. */
1012
+ export function setPublicationObservationForTest(
1013
+ broker: Broker,
1014
+ observation: BrokerPublicationObservation | undefined,
1015
+ ): void {
1016
+ if (observation === undefined) publicationObservationOverridesForTest.delete(broker);
1017
+ else publicationObservationOverridesForTest.set(broker, observation);
1018
+ }
@@ -666,7 +666,13 @@ export class LifecycleLedger {
666
666
  return this.#mutate(async () => {
667
667
  const previous = this.#byIdentity.get(identity);
668
668
  if (!previous) throw new Error("Unknown lifecycle identity");
669
- const next = { ...previous, ...fields, state, ts: Date.now() };
669
+ const next = {
670
+ ...previous,
671
+ ...fields,
672
+ state,
673
+ ts: Date.now(),
674
+ ...(fields.response !== undefined ? { response: fields.response } : {}),
675
+ };
670
676
  if (this.#isCleanupPending(next)) {
671
677
  next.unresolvedCleanupResponse = undefined;
672
678
  next.unresolvedCleanupResponseDigest = undefined;
@@ -796,4 +802,31 @@ export class LifecycleLedger {
796
802
  get(identity: string): LifecycleLedgerEntry | undefined {
797
803
  return this.#byIdentity.get(identity);
798
804
  }
805
+
806
+ findPendingCleanupByTarget(
807
+ sessionId: string,
808
+ cwd: string,
809
+ transcriptPath: string,
810
+ ): LifecycleLedgerEntry | undefined {
811
+ const expectedCwd = path.resolve(cwd);
812
+ const expectedTranscript = path.resolve(transcriptPath);
813
+ let latest: LifecycleLedgerEntry | undefined;
814
+ for (const entry of this.#byIdentity.values()) {
815
+ if (entry.state !== "effect_started" || !this.#isCleanupPending(entry)) continue;
816
+ const response = entry.response as {
817
+ error?: { cleanup?: { sessionId?: unknown; cwd?: unknown; transcriptPath?: unknown } };
818
+ };
819
+ const cleanup = response.error?.cleanup;
820
+ if (
821
+ cleanup?.sessionId !== sessionId ||
822
+ typeof cleanup.cwd !== "string" ||
823
+ typeof cleanup.transcriptPath !== "string" ||
824
+ path.resolve(cleanup.cwd) !== expectedCwd ||
825
+ path.resolve(cleanup.transcriptPath) !== expectedTranscript
826
+ )
827
+ continue;
828
+ if (!latest || entry.ts > latest.ts) latest = entry;
829
+ }
830
+ return latest;
831
+ }
799
832
  }
@@ -865,7 +865,7 @@ export async function writeSessionLifecycleReady(root: string, id: string, effec
865
865
  const incarnation = processIncarnation(process.pid);
866
866
  if (!incarnation) throw new Error("Lifecycle child has no readable OS incarnation.");
867
867
  await fs.mkdir(path.join(root, "sdk"), { recursive: true, mode: 0o700 });
868
- await fs.writeFile(lifecycleReadyPath(root, id), JSON.stringify({ pid: process.pid, effectMarker, incarnation }), {
868
+ await fs.writeFile(lifecycleReadyPath(root, id), canonicalJson({ pid: process.pid, effectMarker, incarnation }), {
869
869
  mode: 0o600,
870
870
  });
871
871
  }
@@ -1365,15 +1365,6 @@ function lifecycleMetadataReplayFiles(cleanup: CleanupEvidence): LifecycleCleanu
1365
1365
  return cleanup.lifecycleFiles?.length ? cleanup.lifecycleFiles : undefined;
1366
1366
  }
1367
1367
 
1368
- function absentLifecyclePath(file: string): boolean {
1369
- try {
1370
- fsSync.lstatSync(file);
1371
- return false;
1372
- } catch (error) {
1373
- return (error as NodeJS.ErrnoException).code === "ENOENT";
1374
- }
1375
- }
1376
-
1377
1368
  function isLifecycleCleanupResponse(value: LifecycleFileCapture | BrokerResponse | undefined): value is BrokerResponse {
1378
1369
  return typeof value === "object" && value !== null && "ok" in value;
1379
1370
  }
@@ -1423,6 +1414,19 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
1423
1414
  try {
1424
1415
  current = captureLifecycleFile(candidate, true, true);
1425
1416
  } catch {
1417
+ if (
1418
+ file.completed &&
1419
+ [file.detachedPath, file.plannedPath].some(
1420
+ bound => bound && path.resolve(candidate) === path.resolve(bound),
1421
+ )
1422
+ ) {
1423
+ try {
1424
+ const stat = fsSync.lstatSync(candidate);
1425
+ if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size === 0) continue;
1426
+ } catch (error) {
1427
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1428
+ }
1429
+ }
1426
1430
  return fail("terminal_uncertain", "Lifecycle metadata candidate could not be safely inspected.");
1427
1431
  }
1428
1432
  if (!current) continue;
@@ -1457,7 +1461,7 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
1457
1461
  if (!markerEntry)
1458
1462
  return fail("terminal_uncertain", "Lifecycle readiness metadata lacks canonical marker authority.");
1459
1463
  if (!marker) {
1460
- if (createHash("sha256").update(canonicalJson(readyMarker)).digest("hex") !== markerEntry.identity.sha256)
1464
+ if (createHash("sha256").update(ready.bytes).digest("hex") !== markerEntry.identity.sha256)
1461
1465
  return fail(
1462
1466
  "terminal_uncertain",
1463
1467
  "Lifecycle readiness metadata is not bound to the completed marker authority.",
@@ -1473,30 +1477,6 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
1473
1477
  return undefined;
1474
1478
  }
1475
1479
 
1476
- function lifecycleMetadataReplayAbsent(cleanup: CleanupEvidence): boolean {
1477
- const files = lifecycleMetadataReplayFiles(cleanup);
1478
- if (!files) return true;
1479
- const root = path.resolve(cleanup.metadataRoot!);
1480
- const id = cleanup.sessionId!;
1481
- const required = new Set<string>([lifecycleMarkerPath(root, id), lifecycleReadyPath(root, id)]);
1482
- for (const file of files) for (const candidate of lifecycleCleanupCandidates(file)) required.add(candidate);
1483
- for (const candidate of required) {
1484
- if (absentLifecyclePath(candidate)) continue;
1485
- // A completed file's recorded retained quarantine is durable evidence, not a
1486
- // survivor — accept it only at its receipt-bound path and identity.
1487
- const owner = files.find(
1488
- file =>
1489
- file.completed === true &&
1490
- file.detachedPath !== undefined &&
1491
- path.resolve(file.detachedPath) === path.resolve(candidate),
1492
- );
1493
- if (!owner) return false;
1494
- const current = captureLifecycleFile(candidate, true, true);
1495
- if (!current || !sameLifecycleCleanupIdentity(current.identity, owner.identity)) return false;
1496
- }
1497
- return true;
1498
- }
1499
-
1500
1480
  /**
1501
1481
  * Base dev persisted metadata cleanup one file at a time. Accept only its
1502
1482
  * identity-bound marker receipt and translate it into the current replay plan.
@@ -1612,7 +1592,7 @@ function legacyMetadataCleanupPlan(cleanup: CleanupEvidence): CleanupEvidence |
1612
1592
  return undefined;
1613
1593
  }
1614
1594
  if (marker && !sameEffectMarker(marker, readyMarker)) return undefined;
1615
- if (!marker && createHash("sha256").update(canonicalJson(readyMarker)).digest("hex") !== persistedIdentity.sha256)
1595
+ if (!marker && createHash("sha256").update(ready.capture.bytes).digest("hex") !== persistedIdentity.sha256)
1616
1596
  return undefined;
1617
1597
  }
1618
1598
 
@@ -1785,11 +1765,11 @@ async function reconcileLifecycleCleanup(
1785
1765
  file.detachedPath &&
1786
1766
  path.resolve(candidate) === path.resolve(file.detachedPath) &&
1787
1767
  stat.isFile() &&
1788
- !stat.isSymbolicLink()
1789
- ) {
1790
- const current = captureLifecycleFile(candidate, true, true);
1791
- if (current && sameLifecycleCleanupIdentity(current.identity, file.identity)) continue;
1792
- }
1768
+ !stat.isSymbolicLink() &&
1769
+ stat.nlink === 1 &&
1770
+ stat.size === 0
1771
+ )
1772
+ continue;
1793
1773
  return fail(
1794
1774
  "terminal_uncertain",
1795
1775
  "Lifecycle cleanup receipt marks a target complete while an authorized candidate remains.",
@@ -1892,7 +1872,7 @@ async function reconcileLifecycleCleanup(
1892
1872
  });
1893
1873
  }
1894
1874
  const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
1895
- candidateIndex === index ? { ...candidate, completed: true as const } : candidate,
1875
+ candidateIndex === index ? { ...candidate, detachedPath: undefined, completed: true as const } : candidate,
1896
1876
  );
1897
1877
  activeCleanup = { ...activeCleanup, lifecycleFiles };
1898
1878
  await broker.ledger.transition(identity, "effect_started", {
@@ -1900,8 +1880,6 @@ async function reconcileLifecycleCleanup(
1900
1880
  });
1901
1881
  lifecycleCleanupHooksForTest.get(broker)?.();
1902
1882
  }
1903
- if (!lifecycleMetadataReplayAbsent(activeCleanup))
1904
- return fail("terminal_uncertain", "Lifecycle metadata replay left an authorized sibling behind.");
1905
1883
  await syncDirectory(path.join(activeCleanup.metadataRoot!, "sdk"));
1906
1884
  return completion;
1907
1885
  }
@@ -3463,6 +3441,21 @@ async function executeLifecycleResponse(
3463
3441
  return (error as NodeJS.ErrnoException).code === "ENOENT";
3464
3442
  }
3465
3443
  };
3444
+ try {
3445
+ const currentTranscript = fsSync.lstatSync(cleanupTarget.transcriptPath, { bigint: true });
3446
+ if (
3447
+ currentTranscript.isFile() &&
3448
+ !currentTranscript.isSymbolicLink() &&
3449
+ currentTranscript.dev === cleanupTarget.transcriptIdentity.dev &&
3450
+ currentTranscript.ino === cleanupTarget.transcriptIdentity.ino
3451
+ )
3452
+ cleanupTarget.transcriptIdentity = {
3453
+ ...cleanupTarget.transcriptIdentity,
3454
+ nlink: currentTranscript.nlink,
3455
+ };
3456
+ } catch (error) {
3457
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
3458
+ }
3466
3459
  const transcriptParentIdentity = cleanup?.transcriptParentIdentity ?? validated.transcriptParentIdentity;
3467
3460
  const durableArtifactsPlan =
3468
3461
  cleanup?.artifactTree?.plannedPath ?? cleanup?.plannedArtifactsPath ?? cleanupTarget.plannedArtifactsPath;
@@ -3687,11 +3680,17 @@ async function executeLifecycleResponse(
3687
3680
  const retainedTranscriptReplayHasNoSideAuthority = retainedTranscriptSidePaths.every(
3688
3681
  candidate => candidate === undefined,
3689
3682
  );
3690
- const retainedTranscriptSideAuthorityIsProvenAbsent =
3691
- retainedTranscriptSidePaths.every(pathIsAbsent) &&
3692
- (cleanupTarget.detachedTranscriptPath ? true : retainedTranscriptIdentityIsAbsentFromParent());
3693
3683
  if (cleanup && !retainedTranscriptReplayHasNoSideAuthority) {
3694
- if (!retainedTranscriptSideAuthorityIsProvenAbsent) return await publishRetainedTranscriptSideAuthority();
3684
+ const successorOrUnknownRemains = [
3685
+ cleanupTarget.retainedTranscriptSuccessorPath,
3686
+ cleanupTarget.retainedTranscriptUnknownPath,
3687
+ ].some(candidate => candidate !== undefined && !pathIsAbsent(candidate));
3688
+ if (
3689
+ successorOrUnknownRemains ||
3690
+ !pathIsAbsent(cleanupTarget.retainedTranscriptPlaceholderPath) ||
3691
+ (!cleanupTarget.detachedTranscriptPath && !retainedTranscriptIdentityIsAbsentFromParent())
3692
+ )
3693
+ return await publishRetainedTranscriptSideAuthority();
3695
3694
  cleanupTarget.retainedTranscriptSuccessorPath = undefined;
3696
3695
  cleanupTarget.retainedTranscriptPlaceholderPath = undefined;
3697
3696
  cleanupTarget.retainedTranscriptUnknownPath = undefined;
@@ -3861,8 +3860,6 @@ async function executeLifecycleResponse(
3861
3860
  preauthorizedCleanup,
3862
3861
  "Saved session cleanup is pending in artifacts: a planned quarantine alias remains before terminal completion.",
3863
3862
  );
3864
- if (deleted.kind === "deleted" && !retainedTranscriptIdentityIsAbsentFromParent())
3865
- return await publishRetainedTranscriptSideAuthority();
3866
3863
  const retainedRootArtifactsPlan = durableArtifactsPlan;
3867
3864
  if (deleted.kind === "cleanup_pending")
3868
3865
  return fail(