@gajae-code/coding-agent 0.12.19 → 0.12.20

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 (97) hide show
  1. package/CHANGELOG.md +20 -9
  2. package/dist/types/commands/gc.d.ts +4 -0
  3. package/dist/types/commands/sdk.d.ts +66 -0
  4. package/dist/types/config/settings-schema.d.ts +34 -0
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
  6. package/dist/types/modes/acp/acp-agent.d.ts +33 -1
  7. package/dist/types/modes/components/bash-execution.d.ts +7 -0
  8. package/dist/types/modes/components/eval-execution.d.ts +7 -0
  9. package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +9 -17
  11. package/dist/types/sdk/acp/index.d.ts +1 -1
  12. package/dist/types/sdk/broker/broker.d.ts +93 -1
  13. package/dist/types/sdk/broker/ensure.d.ts +17 -0
  14. package/dist/types/sdk/broker/index.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
  16. package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
  17. package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
  18. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  19. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
  20. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +20 -9
  22. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
  23. package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
  24. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  25. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  26. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  27. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  28. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  29. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  30. package/dist/types/sdk/startup-capability.d.ts +1 -1
  31. package/dist/types/session/agent-session.d.ts +14 -4
  32. package/dist/types/session/blob-store.d.ts +32 -0
  33. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  34. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  35. package/dist/types/session/session-manager.d.ts +301 -27
  36. package/dist/types/session/session-storage.d.ts +170 -0
  37. package/dist/types/task/executor.d.ts +2 -1
  38. package/dist/types/tools/bash.d.ts +1 -0
  39. package/dist/types/tui/output-block.d.ts +6 -0
  40. package/package.json +8 -8
  41. package/src/cli/args.ts +75 -34
  42. package/src/commands/acp.ts +7 -1
  43. package/src/commands/gc.ts +31 -2
  44. package/src/commands/sdk.ts +141 -7
  45. package/src/config/settings-schema.ts +43 -0
  46. package/src/coordinator-mcp/server.ts +6 -1
  47. package/src/export/html/index.ts +83 -39
  48. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  49. package/src/internal-urls/docs-index.generated.ts +1 -1
  50. package/src/internal-urls/local-protocol.ts +14 -0
  51. package/src/main.ts +40 -9
  52. package/src/modes/acp/acp-agent.ts +564 -37
  53. package/src/modes/acp/terminal-auth.ts +2 -2
  54. package/src/modes/components/bash-execution.ts +14 -0
  55. package/src/modes/components/eval-execution.ts +14 -0
  56. package/src/modes/controllers/command-controller.ts +63 -8
  57. package/src/modes/utils/ui-helpers.ts +81 -11
  58. package/src/sdk/acp/adapter.ts +46 -45
  59. package/src/sdk/acp/index.ts +1 -1
  60. package/src/sdk/broker/broker.ts +392 -11
  61. package/src/sdk/broker/ensure.ts +131 -61
  62. package/src/sdk/broker/index.ts +1 -0
  63. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  64. package/src/sdk/broker/lifecycle.ts +278 -63
  65. package/src/sdk/broker/startup-budget.ts +103 -0
  66. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  67. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  68. package/src/sdk/bus/conversation-store.ts +1 -1
  69. package/src/sdk/bus/discord-daemon.ts +2 -1
  70. package/src/sdk/bus/discord-live-provider.ts +34 -6
  71. package/src/sdk/bus/index.ts +118 -20
  72. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  73. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  74. package/src/sdk/bus/slack-daemon.ts +91 -18
  75. package/src/sdk/cli/session-cli.ts +8 -1
  76. package/src/sdk/host/query/handlers.ts +1 -1
  77. package/src/sdk/host/query/revision-store.ts +48 -12
  78. package/src/sdk/host/session-runtime.test.ts +50 -0
  79. package/src/sdk/host/session-runtime.ts +12 -2
  80. package/src/sdk/lifecycle-session.ts +26 -3
  81. package/src/sdk/mcp/server.ts +6 -1
  82. package/src/sdk/prompt-failure.ts +42 -0
  83. package/src/sdk/prompt-watchdog.ts +162 -0
  84. package/src/sdk/session-reconnect.ts +43 -0
  85. package/src/sdk/startup-capability.ts +13 -4
  86. package/src/session/agent-session.ts +525 -222
  87. package/src/session/blob-store.ts +89 -0
  88. package/src/session/internal/managed-session-scope.ts +36 -26
  89. package/src/session/internal/managed-session-storage.ts +303 -32
  90. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  91. package/src/session/session-manager.ts +7027 -394
  92. package/src/session/session-storage.ts +1134 -11
  93. package/src/task/executor.ts +17 -4
  94. package/src/tools/bash.ts +109 -7
  95. package/src/tools/find.ts +55 -26
  96. package/src/tools/todo-contract.ts +40 -8
  97. package/src/tui/output-block.ts +11 -1
@@ -1,10 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { logger } from "@gajae-code/utils";
4
5
  import { type IndexedSession, SessionIndex } from "../broker/session-index";
6
+ import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
5
7
  import { SdkClient, SdkClientError } from "../client/client";
6
8
  import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
7
9
  import { SESSION_PREPARED_EVENT } from "../host/host";
10
+ import { ACP_SESSION_RECONNECT } from "../session-reconnect";
8
11
 
9
12
  import { createDiscordAdapter, createSlackAdapter } from "./chat-adapters";
10
13
  import { type ChatTransport, projectChatCommandOutcome, sendAuthorizedChatOperation } from "./chat-command-policy";
@@ -36,7 +39,15 @@ export interface ChatDaemonRuntimeConfig {
36
39
 
37
40
  export interface ChatDaemonSdkClient {
38
41
  onFrame(handler: (frame: Record<string, unknown>) => void): () => void;
39
- request(frame: Record<string, unknown>): Promise<Record<string, unknown>>;
42
+ /**
43
+ * Announces that a replacement socket is live. Optional because command-scoped
44
+ * clients (the broker client, one-shot doubles) never outlive one socket, while
45
+ * every long-lived attachment's client carries it.
46
+ */
47
+ onReconnect?(handler: () => void): () => void;
48
+ /** Re-dials a retired socket; a no-op on a live one. */
49
+ connect?(): Promise<void>;
50
+ request(frame: Record<string, unknown>, options?: { timeoutMs?: number }): Promise<Record<string, unknown>>;
40
51
  close(): Promise<void>;
41
52
  send(frame: Record<string, unknown>): void;
42
53
  }
@@ -77,15 +88,107 @@ export interface ChatDaemonRuntimeDeps {
77
88
  clearInterval?: typeof clearInterval;
78
89
  }
79
90
 
91
+ /** One live frame held behind a replay barrier, kept with the sequence that orders it. */
92
+ type HeldFrame = Readonly<{ seq: number; frame: Record<string, unknown> }>;
93
+
94
+ /**
95
+ * Which producer handed a frame to delivery.
96
+ *
97
+ * A reconnect has two at once — the replacement socket, whose subscription never
98
+ * stopped, and the replay answer — and only the socket's frames may be held: the
99
+ * barrier drains by re-entering delivery with the frames it holds, and holding those
100
+ * a second time would never publish anything.
101
+ */
102
+ type FrameOrigin = "live" | "ordered";
103
+
104
+ /**
105
+ * Ingress fence one attachment applies while its replay is outstanding.
106
+ *
107
+ * `held` is non-undefined exactly while a replay round owns this attachment's live
108
+ * ingress, and the array identity is that round's ticket: a later round or a disposal
109
+ * installs or revokes its own, so an earlier round can tell it no longer owns the
110
+ * buffer instead of stranding — or publishing — someone else's frames.
111
+ */
112
+ type ReplayBarrier = {
113
+ held: HeldFrame[] | undefined;
114
+ detached: boolean;
115
+ /**
116
+ * Set when a round could not close the gap it fenced — the replay went
117
+ * unanswered, or the hold buffer overflowed. The attachment stops delivering
118
+ * from that point on, because lifting the fence instead would let the next live
119
+ * frame drag the cursor over sequences nobody ever delivered.
120
+ */
121
+ failed: boolean;
122
+ };
123
+
124
+ /**
125
+ * How many live frames one attachment holds while its replay is outstanding.
126
+ *
127
+ * The window is a single round trip on an open socket, so the bound is only there
128
+ * to keep a flooding session from growing the buffer without limit. Overflow is
129
+ * barrier failure rather than an eviction: the oldest held frame is the one the
130
+ * cursor needs next, and dropping the newest still leaves a hole no drain can
131
+ * close, so the attachment is rebuilt from its cursor and its replay re-fetches
132
+ * the whole gap instead.
133
+ */
134
+ const REPLAY_BARRIER_LIMIT = 1_024;
135
+
136
+ /**
137
+ * How many times one round re-issues a refused replay, and the backoff it doubles
138
+ * from.
139
+ *
140
+ * A socket that never dropped sends no hello, so nothing but this round can
141
+ * re-issue the replay it owes — `client.onReconnect` fires only for a new
142
+ * connection id. The retry therefore rides the same live socket, and the budget is
143
+ * bounded so a host that never serves the gap ends in a rebuild rather than a
144
+ * barrier that fences ingress forever.
145
+ */
146
+ const REPLAY_RETRY_ATTEMPTS = 3;
147
+ const REPLAY_RETRY_BACKOFF_MS = 100;
148
+
149
+ /**
150
+ * How many rounds re-serve one sequence whose publication failed before it is conceded.
151
+ *
152
+ * A refused publication holds the cursor below its frame, and the rebuild that follows
153
+ * re-serves exactly that frame. A surface that is down for good would repeat that
154
+ * forever and deliver nothing else, so the rounds are bounded: past this count the frame
155
+ * is conceded the way a retention gap is, and the stream resumes above it.
156
+ */
157
+ const DELIVERY_ATTEMPT_LIMIT = 3;
158
+
80
159
  type AttachedSession = Readonly<{
81
160
  id: string;
82
161
  sessionId: string;
83
162
  endpoint: SdkSessionEndpoint;
84
163
  generation: number;
85
164
  client: ChatDaemonSdkClient;
165
+ /**
166
+ * How far this attachment has consumed its session's event stream. The record is
167
+ * frozen; this cursor is the one thing that must keep moving with delivery,
168
+ * because a reconnect resumes from exactly here.
169
+ */
170
+ cursor: { seq: number };
171
+ /**
172
+ * Ordering fence between the two producers a reconnect creates. Frozen with the
173
+ * record like the cursor; only its own fields move.
174
+ */
175
+ barrier: ReplayBarrier;
86
176
  dispose: () => void;
87
177
  }>;
88
178
 
179
+ /**
180
+ * Default transport for one attached session.
181
+ *
182
+ * The attachment is long-lived — its frame subscription survives until an
183
+ * explicit detach or endpoint roll — and it sits under the host's liveness
184
+ * reaper, which drops any session that has not ponged within HEARTBEAT_TTL_MS.
185
+ * The transport's one-shot defaults give up after 175ms, so every stall the host
186
+ * reaps would be unrecoverable; the shared session budget outlives that TTL.
187
+ */
188
+ async function connectAttachedSession(endpoint: SdkSessionEndpoint): Promise<ChatDaemonSdkClient> {
189
+ return await SdkClient.connect(endpoint.url, endpoint.token, { ...ACP_SESSION_RECONNECT });
190
+ }
191
+
89
192
  /**
90
193
  * The lifecycle signals that decide whether a chat root exists at all. They are
91
194
  * the only event names that close, resume, or withhold a session's publication,
@@ -143,6 +246,38 @@ function readGeneration(value: unknown): number | undefined {
143
246
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
144
247
  }
145
248
 
249
+ function readSequence(value: unknown): number | undefined {
250
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
251
+ }
252
+
253
+ /**
254
+ * What one `event_replay_result` states about the range it could not answer.
255
+ *
256
+ * A gap is the host's own admission, not an inference this side draws:
257
+ * `sequence_gap` names sequences its ring has already evicted, and
258
+ * `generation_reset` says the stream this cursor belongs to no longer exists.
259
+ * Reading it whole — rather than treating every truthy `gap` alike — is what
260
+ * keeps a bounded, nameable loss apart from an answer nothing can be concluded
261
+ * from, and the two owe opposite responses.
262
+ */
263
+ type ReplayGap =
264
+ | Readonly<{ kind: "generation_reset"; toGeneration: number }>
265
+ | Readonly<{ kind: "sequence_gap"; fromSeq: number; toSeq: number }>;
266
+
267
+ function readReplayGap(value: unknown): ReplayGap | undefined {
268
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
269
+ const gap = value as Record<string, unknown>;
270
+ if (gap.kind === "generation_reset") {
271
+ const toGeneration = readGeneration(gap.toGeneration);
272
+ return toGeneration === undefined ? undefined : { kind: "generation_reset", toGeneration };
273
+ }
274
+ if (gap.kind !== "sequence_gap") return undefined;
275
+ const fromSeq = readSequence(gap.fromSeq);
276
+ const toSeq = readSequence(gap.toSeq);
277
+ if (fromSeq === undefined || toSeq === undefined || toSeq < fromSeq) return undefined;
278
+ return { kind: "sequence_gap", fromSeq, toSeq };
279
+ }
280
+
146
281
  /**
147
282
  * What one representation of a frame states about an identity it owns.
148
283
  *
@@ -279,6 +414,19 @@ export class ChatDaemonRuntime {
279
414
  #stopTimer: (() => void) | undefined;
280
415
  readonly #pending = new Set<Promise<void>>();
281
416
  readonly #frameTails = new Map<string, Promise<void>>();
417
+ /**
418
+ * The frame an attachment failed to publish, with how many rounds have tried it.
419
+ *
420
+ * Keyed by session rather than by attachment because the retire-and-rebuild a refused
421
+ * publication triggers replaces the attachment: the count has to outlive it, or a
422
+ * surface that is down for good would rebuild from the same sequence forever. It does
423
+ * not outlive the stream, though: a sequence names a frame only within one endpoint
424
+ * generation, so a rolled endpoint reopens a sequence space whose first frame nothing
425
+ * has ever been offered. Charging it for what an earlier generation's frame cost would
426
+ * concede it after a single refusal.
427
+ */
428
+ readonly #undelivered = new Map<string, { generation: number; seq: number; attempts: number }>();
429
+ readonly #reviving = new Set<string>();
282
430
  #reconcileTail: Promise<void> = Promise.resolve();
283
431
 
284
432
  #discord: DiscordNotificationDaemon | undefined;
@@ -460,24 +608,46 @@ export class ChatDaemonRuntime {
460
608
  const endpointStat = await fs.stat(endpoint.path).catch(() => undefined);
461
609
  if (!endpointStat || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return;
462
610
  const existing = this.#sessions.get(indexed.sessionId);
463
- if (
464
- existing &&
611
+ const resumable =
612
+ existing !== undefined &&
465
613
  existing.endpoint.url === endpoint.url &&
466
614
  existing.endpoint.token === endpoint.token &&
467
- existing.generation === indexed.endpointGeneration
468
- )
615
+ existing.generation === indexed.endpointGeneration;
616
+ if (existing && resumable && !existing.barrier.failed) {
617
+ // The attachment is current, so reconcile's remaining job is keeping its socket
618
+ // dialed: `SdkClient` retires a closed socket and re-dials only on the next
619
+ // request, and a passive subscription issues none.
620
+ this.#reviveTransport(existing);
469
621
  return;
622
+ }
623
+ // A failed barrier is rebuilt rather than revived, and its cursor travels into the
624
+ // replacement: same endpoint, same generation, same stream, so the replay this
625
+ // attach issues asks for exactly the gap the failed round left open, and every
626
+ // sequence already published stays below the cursor that drops it. A rolled
627
+ // generation is a different stream, so that fence resets the cursor to zero.
628
+ const resumeSeq = existing && resumable ? existing.cursor.seq : 0;
470
629
  if (existing) {
471
630
  this.#sessions.delete(indexed.sessionId);
472
631
  existing.dispose();
473
632
  await existing.client.close();
474
633
  }
475
- const client = await (this.deps.createClient ?? (async value => await SdkClient.connect(value.url, value.token)))(
476
- endpoint,
477
- );
634
+ const client = await (this.deps.createClient ?? connectAttachedSession)(endpoint);
478
635
  let attached: AttachedSession | undefined;
479
- const dispose = client.onFrame(frame => {
480
- if (attached) this.schedule(this.enqueueFrame(attached, frame));
636
+ const barrier: ReplayBarrier = { held: undefined, detached: false, failed: false };
637
+ const disposeFrames = client.onFrame(frame => {
638
+ if (attached) this.schedule(this.enqueueFrame(attached, frame, "live"));
639
+ });
640
+ // The frame subscription is client-scoped and survives the socket, but a
641
+ // replacement socket resumes delivery where the stream stands then. Only a replay
642
+ // from this attachment's cursor closes the gap the drop left behind, and that cursor
643
+ // is read here — synchronously with the hello — so the replay asks for exactly the
644
+ // gap the barrier it opens is holding the replacement socket's frames ahead of.
645
+ //
646
+ // Deliberately outside `#pending`, like `#reviveTransport`: a replay that never
647
+ // answers must not make `stop()` wait for it. Disposal revokes the barrier and
648
+ // closing the client settles the request, so the round ends without publishing.
649
+ const disposeReconnect = client.onReconnect?.(() => {
650
+ if (attached) void this.#replayAttachment(attached, attached.cursor.seq).catch(() => undefined);
481
651
  });
482
652
  attached = Object.freeze({
483
653
  id: randomUUID(),
@@ -485,7 +655,16 @@ export class ChatDaemonRuntime {
485
655
  endpoint,
486
656
  generation: indexed.endpointGeneration,
487
657
  client,
488
- dispose,
658
+ cursor: { seq: resumeSeq },
659
+ barrier,
660
+ dispose: () => {
661
+ disposeFrames();
662
+ disposeReconnect?.();
663
+ // A detached attachment's outstanding replay is dead work: revoking the
664
+ // barrier here is what keeps its held frames off the newer attachment.
665
+ barrier.detached = true;
666
+ barrier.held = undefined;
667
+ },
489
668
  });
490
669
  this.#sessions.set(indexed.sessionId, attached);
491
670
  this.#presentation?.connectSession(indexed.sessionId, {
@@ -495,15 +674,283 @@ export class ChatDaemonRuntime {
495
674
  attached.client.send({ type: "reply", id: route.actionId, answer: route.answer });
496
675
  },
497
676
  });
498
- const replay = await client.request({
499
- type: "event_replay",
500
- sinceGeneration: indexed.endpointGeneration,
501
- sinceSeq: 0,
502
- });
503
- if (Array.isArray(replay.events))
504
- for (const event of replay.events)
505
- if (event && typeof event === "object" && !Array.isArray(event))
506
- await this.enqueueFrame(attached, event as Record<string, unknown>);
677
+ await this.#replayAttachment(attached, resumeSeq);
678
+ }
679
+
680
+ /**
681
+ * Keep an established attachment's socket dialed.
682
+ *
683
+ * `SdkClient` never opens a replacement socket on its own: it retires the closed
684
+ * incarnation and re-dials on the next `connect`/`request`. A chat attachment is
685
+ * purely passive, so without this probe a transient drop would silently end
686
+ * delivery for good. `connect()` resolves immediately on a live socket, spends the
687
+ * session reconnect budget on a dead one, and — because reconcile only reaches
688
+ * here for an endpoint it has just re-read as current at this generation — never
689
+ * revives an attachment the index has moved on from.
690
+ */
691
+ #reviveTransport(attached: AttachedSession): void {
692
+ const connect = attached.client.connect?.bind(attached.client);
693
+ if (!connect || this.#reviving.has(attached.id)) return;
694
+ this.#reviving.add(attached.id);
695
+ // Deliberately outside `#pending`: the reconnect budget outlives the heartbeat
696
+ // TTL, and `stop()` must not wait for it. Closing the client aborts it instead.
697
+ void connect()
698
+ .catch(() => undefined)
699
+ .finally(() => this.#reviving.delete(attached.id));
700
+ }
701
+
702
+ /**
703
+ * Issue one replay for `attached` and publish its answer ahead of the live frames
704
+ * that raced it.
705
+ *
706
+ * A reconnect leaves two producers writing one stream: the replacement socket, whose
707
+ * subscription never stopped, and the replay answer. Ingress is therefore fenced for
708
+ * the whole round trip — frames the socket delivers after the hello are held, the
709
+ * replayed events are published first, and the held frames are drained afterwards in
710
+ * sequence order — so delivery follows sequence rather than arrival. Both paths
711
+ * publish through the same cursor, so an event both producers carried is published
712
+ * exactly once. Frames carrying no sequence of this attachment's own cannot be
713
+ * ordered against a replay at all, so they are never held and never delayed.
714
+ *
715
+ * The replay is fenced exactly like `attach()`: it is issued for the attachment's
716
+ * own endpoint generation and from its own cursor, and it is dropped whole if the
717
+ * runtime has since detached, rolled, or superseded this attachment — so a stale
718
+ * incarnation can never resurrect its events onto a newer one's root.
719
+ *
720
+ * A refused replay loses nothing either: the cursor stays where the request was
721
+ * issued from, the held frames stay held, and the round re-issues on the socket
722
+ * it already has. When that budget runs out the barrier fails whole and the
723
+ * attachment is rebuilt from its cursor. A replay that reports a retention gap
724
+ * is not a round to retry: the sequences it names are evicted at the host, so no
725
+ * rebuild can re-fetch them. That round concedes them instead — loudly — carries
726
+ * the cursor over them, and publishes the suffix the host did keep, so the gap
727
+ * costs exactly the sequences the host lost and nothing that follows them. The
728
+ * concession is what moves the cursor, not the suffix: a host that kept every
729
+ * sequence above the gap can still answer with none of them, and a cursor left
730
+ * below the lost range would concede it again on every later replay. A gap is
731
+ * conceded only where it answers this round's own request: one that opens above
732
+ * the cursor, or that the same answer returns retained events from, states no
733
+ * bounded loss and is fenced like any other answer nothing can be concluded from.
734
+ * What the host evicted is not what delivery lost: a sequence in the conceded
735
+ * range that the replacement socket already carried is held by this round's
736
+ * barrier, so it is published before the cursor steps over the rest.
737
+ */
738
+ async #replayAttachment(attached: AttachedSession, sinceSeq: number): Promise<void> {
739
+ if (!this.#attachmentLive(attached)) return;
740
+ const held: HeldFrame[] = [];
741
+ attached.barrier.held = held;
742
+ try {
743
+ let replay: Record<string, unknown>;
744
+ for (let attempt = 0; ; attempt++) {
745
+ try {
746
+ replay = await attached.client.request({
747
+ type: "event_replay",
748
+ sinceGeneration: attached.generation,
749
+ sinceSeq,
750
+ });
751
+ break;
752
+ } catch {
753
+ // An unanswered replay is a transport failure, not a delivery decision, and
754
+ // the gap it owed is still owed: the cursor stays at `sinceSeq` and the held
755
+ // frames stay held, so the retry asks for exactly the same gap. It is issued
756
+ // on this attachment's own live socket, which is the only producer that can
757
+ // close the gap when no hello is coming.
758
+ if (attempt >= REPLAY_RETRY_ATTEMPTS) {
759
+ this.#failBarrier(attached, "replay went unanswered");
760
+ return;
761
+ }
762
+ await Bun.sleep(REPLAY_RETRY_BACKOFF_MS * 2 ** attempt);
763
+ // A later round or a disposal may have taken this attachment's ingress over
764
+ // while the retry waited. That round owes the same gap from the same cursor,
765
+ // so this one stands down instead of asking for it twice.
766
+ if (attached.barrier.held !== held) return;
767
+ if (!this.#attachmentLive(attached)) return;
768
+ }
769
+ }
770
+ if (attached.barrier.held !== held) return;
771
+ if (!this.#attachmentLive(attached)) return;
772
+ // The answer rides the same socket as the live frames that preceded it, but
773
+ // ingress is a queue: a frame this socket already delivered can still be in
774
+ // flight when the answer resolves, and the hold buffer does not carry it until
775
+ // it lands. What live delivery already carried is therefore only readable once
776
+ // that queue has caught up, so the round joins it before reading its own
777
+ // answer — otherwise a conceded range would step the cursor over a sequence
778
+ // still on its way into the buffer, and that frame would be dropped as already
779
+ // delivered by a cursor no producer ever published it past.
780
+ await this.#frameTails.get(attached.sessionId)?.catch(() => undefined);
781
+ if (attached.barrier.held !== held) return;
782
+ if (!this.#attachmentLive(attached)) return;
783
+ // The answer's own events are read before anything is concluded from its gap: a
784
+ // concession is only readable against the suffix it arrived with.
785
+ const events = Array.isArray(replay.events)
786
+ ? replay.events.filter(
787
+ (event): event is Record<string, unknown> =>
788
+ !!event && typeof event === "object" && !Array.isArray(event),
789
+ )
790
+ : [];
791
+ if (replay.gap !== undefined) {
792
+ // The host answered, and its answer says part of what this cursor asked for
793
+ // is gone for good. Neither a retry nor a rebuild can recover an evicted
794
+ // sequence, so refusing to publish would trade a bounded loss for a total
795
+ // outage on a healthy socket, and the 2-second reconcile would rebuild into
796
+ // the same gap forever. Concede it instead — loudly — and publish the suffix
797
+ // the host did keep, so the gap costs exactly the sequences the host lost and
798
+ // nothing that follows them.
799
+ const gap = readReplayGap(replay.gap);
800
+ if (!gap) {
801
+ this.#failBarrier(attached, "replay reported a gap it did not state");
802
+ return;
803
+ }
804
+ if (gap.kind === "generation_reset") {
805
+ // A reset stream shares no sequence space with this cursor, so nothing in
806
+ // the answer can be ordered against it. Reconcile rebuilds against the
807
+ // generation the index publishes now, which is a different fence entirely.
808
+ this.#failBarrier(attached, `replay reported a generation reset to ${gap.toGeneration}`);
809
+ return;
810
+ }
811
+ // A gap is a statement about this round's own request, so it is conceded only
812
+ // where it answers one: it must open at the first sequence this round asked
813
+ // for, and the suffix the host kept must sit entirely above it. A range this
814
+ // cursor never asked about, or one the same answer returns events from, states
815
+ // no bounded loss at all, and conceding it would fence off sequences the host
816
+ // still holds — the one thing a retention gap must never cost.
817
+ if (gap.fromSeq !== sinceSeq + 1) {
818
+ this.#failBarrier(
819
+ attached,
820
+ `replay conceded sequences ${gap.fromSeq}-${gap.toSeq} for a request that resumed from seq ${sinceSeq}`,
821
+ );
822
+ return;
823
+ }
824
+ const retained = events
825
+ .map(event => readSequence(event.seq))
826
+ .find(seq => seq !== undefined && seq <= gap.toSeq);
827
+ if (retained !== undefined) {
828
+ this.#failBarrier(
829
+ attached,
830
+ `replay conceded sequences ${gap.fromSeq}-${gap.toSeq} while returning seq ${retained}`,
831
+ );
832
+ return;
833
+ }
834
+ // A sequence the host lost from its ring can still have arrived over the
835
+ // replacement socket and be sitting in this round's hold buffer: the two
836
+ // producers fail independently, so eviction at the host says nothing about
837
+ // what live delivery already carried. Those frames are published here — in
838
+ // sequence order, ahead of the retained suffix that sits above them — and
839
+ // leave the buffer, so the concession costs only the sequences neither
840
+ // producer holds and the drain cannot re-offer one the cursor just passed.
841
+ const recovered = held.filter(entry => entry.seq <= gap.toSeq).sort((left, right) => left.seq - right.seq);
842
+ const carried = held.filter(entry => entry.seq > gap.toSeq);
843
+ held.splice(0, held.length, ...carried);
844
+ const recoveredNote =
845
+ recovered.length > 0 ? `, ${recovered.length} of them recovered from live delivery` : "";
846
+ logger.warn(
847
+ `chat daemon replay conceded a retention gap (sequences ${gap.fromSeq}-${gap.toSeq} are gone from the host${recoveredNote}); session ${attached.sessionId} generation ${attached.generation} resumes at seq ${gap.toSeq + 1}.`,
848
+ );
849
+ for (const entry of recovered) await this.enqueueFrame(attached, entry.frame, "ordered");
850
+ // The concession itself carries the cursor over the rest of the lost range,
851
+ // because the retained suffix cannot be trusted to do it: `SessionSdkHost`
852
+ // drops capability-gated kinds from every replay answer, so a host that still
853
+ // holds every sequence above the gap can legitimately answer with none of
854
+ // them. A cursor left below a range no host can re-serve would resume the
855
+ // next replay from the same evicted sequence and concede the same permanent
856
+ // loss on every reconnect, forever.
857
+ if (gap.toSeq > attached.cursor.seq) attached.cursor.seq = gap.toSeq;
858
+ }
859
+ for (const event of events) await this.enqueueFrame(attached, event, "ordered");
860
+ await this.#drainHeldFrames(attached, held);
861
+ } finally {
862
+ // Only this round's own buffer is revoked: a later round or a disposal may have
863
+ // installed its own, and clearing that one would strand the frames it holds.
864
+ if (attached.barrier.held === held) attached.barrier.held = undefined;
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Whether `attached` is still the incarnation this runtime publishes through.
870
+ *
871
+ * Detachment, supersession, and a barrier that could not close its gap all retire
872
+ * an attachment the same way: whatever it still carries is dead work, and
873
+ * publishing any of it would either resurrect a stale incarnation or step the
874
+ * cursor over a sequence nobody delivered.
875
+ */
876
+ #attachmentLive(attached: AttachedSession): boolean {
877
+ return (
878
+ !attached.barrier.detached && !attached.barrier.failed && this.#sessions.get(attached.sessionId) === attached
879
+ );
880
+ }
881
+
882
+ /**
883
+ * Retire one attachment whose barrier could not close the gap it fenced.
884
+ *
885
+ * The cursor is left at the last contiguously published sequence and delivery
886
+ * stops there, so nothing can carry it over the gap. Reconcile then rebuilds the
887
+ * attachment against the same endpoint generation and hands that cursor to the
888
+ * replacement, whose replay re-fetches exactly what this round could not close —
889
+ * held frames are dropped only because that replay re-issues every one of them.
890
+ * Only an inconclusive round retires this way: a host that names what it lost is
891
+ * conceded where the answer is read, because no rebuild could recover it.
892
+ *
893
+ * Loud, because a barrier that cannot close is a transport fault rather than a
894
+ * delivery decision.
895
+ */
896
+ #failBarrier(attached: AttachedSession, reason: string): void {
897
+ if (attached.barrier.detached || attached.barrier.failed) return;
898
+ attached.barrier.failed = true;
899
+ attached.barrier.held = undefined;
900
+ logger.warn(
901
+ `chat daemon replay barrier failed (${reason}); rebuilding session ${attached.sessionId} at generation ${attached.generation} from seq ${attached.cursor.seq}.`,
902
+ );
903
+ }
904
+
905
+ /**
906
+ * Retire one attachment whose frame reached a surface without landing on it.
907
+ *
908
+ * The cursor is delivery's record, so a frame no surface took must stay above it. The
909
+ * attachment is retired exactly like one whose barrier failed, and the rebuild replays
910
+ * from a cursor that still sits below the frame. Later frames cannot drag the cursor
911
+ * over it either, because a retired attachment publishes nothing.
912
+ *
913
+ * The rounds are bounded, because a surface that is down for good would re-serve that
914
+ * same sequence forever and never deliver another. On the last one the frame is
915
+ * conceded — loudly, like a retention gap — and the cursor steps over it, since a
916
+ * stream that never advances again loses every later frame instead of just this one.
917
+ */
918
+ #failDelivery(attached: AttachedSession, seq: number, error: unknown): void {
919
+ const previous = this.#undelivered.get(attached.sessionId);
920
+ const attempts = previous?.generation === attached.generation && previous.seq === seq ? previous.attempts + 1 : 1;
921
+ const reason = error instanceof Error ? error.message : String(error);
922
+ if (attempts >= DELIVERY_ATTEMPT_LIMIT) {
923
+ this.#undelivered.delete(attached.sessionId);
924
+ attached.cursor.seq = seq;
925
+ logger.warn(
926
+ `chat daemon conceded seq ${seq} of session ${attached.sessionId} at generation ${attached.generation} after ${attempts} refused publications (${reason}); delivery resumes above it.`,
927
+ );
928
+ return;
929
+ }
930
+ this.#undelivered.set(attached.sessionId, { generation: attached.generation, seq, attempts });
931
+ this.#failBarrier(attached, `publication failed at seq ${seq} (${reason})`);
932
+ }
933
+
934
+ /**
935
+ * Publish what this round held, lowest sequence first, then reopen live ingress.
936
+ *
937
+ * Frames keep arriving while the drain runs, so the barrier is only lifted by a pass
938
+ * that finds nothing left to publish: lifting it earlier would let a frame arriving
939
+ * mid-drain overtake one still held. That lift is synchronous with the emptiness
940
+ * check, because anything queued behind an await could otherwise land in a buffer
941
+ * nobody owns any more.
942
+ */
943
+ async #drainHeldFrames(attached: AttachedSession, held: HeldFrame[]): Promise<void> {
944
+ for (;;) {
945
+ if (attached.barrier.held !== held) return;
946
+ if (!this.#attachmentLive(attached)) return;
947
+ if (held.length === 0) {
948
+ attached.barrier.held = undefined;
949
+ return;
950
+ }
951
+ const batch = held.splice(0, held.length).sort((left, right) => left.seq - right.seq);
952
+ for (const entry of batch) await this.enqueueFrame(attached, entry.frame, "ordered");
953
+ }
507
954
  }
508
955
 
509
956
  private async resolveEndpoint(sessionId: string): Promise<SlackEndpoint | null> {
@@ -578,9 +1025,9 @@ export class ChatDaemonRuntime {
578
1025
  () => this.#pending.delete(task),
579
1026
  );
580
1027
  }
581
- private enqueueFrame(attached: AttachedSession, frame: Record<string, unknown>): Promise<void> {
1028
+ private enqueueFrame(attached: AttachedSession, frame: Record<string, unknown>, origin: FrameOrigin): Promise<void> {
582
1029
  const previous = this.#frameTails.get(attached.sessionId) ?? Promise.resolve();
583
- const current = previous.catch(() => undefined).then(async () => await this.handleFrame(attached, frame));
1030
+ const current = previous.catch(() => undefined).then(async () => await this.handleFrame(attached, frame, origin));
584
1031
  this.#frameTails.set(attached.sessionId, current);
585
1032
  void current.then(
586
1033
  () => {
@@ -592,14 +1039,86 @@ export class ChatDaemonRuntime {
592
1039
  );
593
1040
  return current;
594
1041
  }
595
- private async handleFrame(attached: AttachedSession, frame: Record<string, unknown>): Promise<void> {
596
- if (this.#sessions.get(attached.sessionId) !== attached) return;
1042
+ private async handleFrame(
1043
+ attached: AttachedSession,
1044
+ frame: Record<string, unknown>,
1045
+ origin: FrameOrigin,
1046
+ ): Promise<void> {
1047
+ if (!this.#attachmentLive(attached)) return;
597
1048
  // Correlate before anything is acted on. A frame whose envelope and payload
598
1049
  // disagree on session, lifecycle identity, or lifecycle generation is not a
599
1050
  // usable event, so it is dropped whole — no close, resume, notify, resolve,
600
1051
  // root, or mapping mutation — on the replay path and the live path alike.
601
1052
  const correlated = correlateFrame(frame);
602
1053
  if (!correlated) return;
1054
+ // One event delivered under this attachment's identity is one sequence, and a
1055
+ // reconnect leaves two producers carrying it: the replacement socket, whose
1056
+ // subscription never stopped, and the replay answer. So the cursor both orders
1057
+ // delivery and settles it. A sequence at or below the cursor was already
1058
+ // published and is rejected outright rather than merely declining to advance,
1059
+ // or the frame both producers carried is published twice; and every sequence
1060
+ // above it advances the cursor, including the ones the filters below drop,
1061
+ // because a cursor left behind by dropped-but-delivered frames would re-deliver
1062
+ // every notification interleaved with them. The fences are the two `attach()`
1063
+ // already applies — this session, this endpoint generation — so a foreign or
1064
+ // superseded frame neither moves the cursor nor enters the barrier.
1065
+ const seq = typeof frame.seq === "number" && Number.isSafeInteger(frame.seq) ? frame.seq : undefined;
1066
+ const ownsSequence =
1067
+ correlated.generation === attached.generation &&
1068
+ (correlated.sessionId === undefined || correlated.sessionId === attached.sessionId);
1069
+ if (seq !== undefined && ownsSequence) {
1070
+ if (seq <= attached.cursor.seq) return;
1071
+ const held = attached.barrier.held;
1072
+ if (held && origin === "live") {
1073
+ // A replay is outstanding, so where this frame sits in the stream is not
1074
+ // settled yet: hold it, and let the drain place it after the answer.
1075
+ if (held.length >= REPLAY_BARRIER_LIMIT) {
1076
+ // Overflow is not a choice between frames: evicting the oldest silently
1077
+ // skips the sequence the cursor needs next, and evicting the newest leaves
1078
+ // a hole no drain can close. So the barrier fails whole instead, and the
1079
+ // rebuild re-fetches the gap from this attachment's own cursor — the host
1080
+ // answers with whatever of it survived, and names the rest as lost.
1081
+ this.#failBarrier(attached, `hold buffer overflowed at ${REPLAY_BARRIER_LIMIT} frames`);
1082
+ return;
1083
+ }
1084
+ held.push({ seq, frame });
1085
+ return;
1086
+ }
1087
+ }
1088
+ // Publication is the delivery boundary. The cursor records what was delivered, so
1089
+ // it may pass this sequence only once every configured surface has taken the
1090
+ // frame: moving it first turns one refused publication into permanent loss,
1091
+ // because the next replay would resume above a frame nobody ever published.
1092
+ // Rebuilds and replays retain this stream identity, so an acknowledgement that
1093
+ // disappears after provider acceptance is reconciled as the same publication.
1094
+ const publicationId =
1095
+ seq !== undefined && ownsSequence ? `${attached.sessionId}:${attached.generation}:${seq}` : undefined;
1096
+ try {
1097
+ await this.#publishFrame(attached, correlated, publicationId);
1098
+ } catch (error) {
1099
+ // An unsequenced frame has no cursor to hold back, so its failure stays a
1100
+ // rejection for the frame queue to absorb.
1101
+ if (seq === undefined || !ownsSequence) throw error;
1102
+ this.#failDelivery(attached, seq, error);
1103
+ return;
1104
+ }
1105
+ if (seq !== undefined && ownsSequence) {
1106
+ this.#undelivered.delete(attached.sessionId);
1107
+ // The publish was awaited, so a concession may have carried the cursor past this
1108
+ // sequence in the meantime. Delivery records a sequence; it never rewinds one.
1109
+ if (seq > attached.cursor.seq) attached.cursor.seq = seq;
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * Publish one correlated frame to every surface this runtime fans out to.
1115
+ *
1116
+ * Split from sequencing so the two outcomes stay distinguishable: returning means the
1117
+ * frame is delivered — published, or deliberately dropped by a filter that leaves
1118
+ * nothing to publish — and throwing means no surface took it. Only the caller moves
1119
+ * the cursor, and only on the first.
1120
+ */
1121
+ async #publishFrame(attached: AttachedSession, correlated: CorrelatedFrame, publicationId?: string): Promise<void> {
603
1122
  const normalizedFrame = correlated.body;
604
1123
  // The SDK's own request/response traffic arrives on this same observer:
605
1124
  // `SdkClient` settles a pending request and still forwards that frame to
@@ -626,7 +1145,7 @@ export class ChatDaemonRuntime {
626
1145
  if (name === SESSION_PREPARED_EVENT || bodyType === SESSION_PREPARED_EVENT) return;
627
1146
  if (name === "session_ready") {
628
1147
  if (correlated.generation !== attached.generation) return;
629
- await this.resume(sessionId, attached.generation, "GJC session ready.");
1148
+ await this.resume(sessionId, attached.generation, "GJC session ready.", publicationId);
630
1149
  return;
631
1150
  }
632
1151
  const notification = this.#notificationEvent(sessionId, normalizedFrame);
@@ -652,6 +1171,7 @@ export class ChatDaemonRuntime {
652
1171
  sessionId,
653
1172
  endpointGeneration: attached.generation,
654
1173
  content,
1174
+ ...(publicationId === undefined ? {} : { publicationId }),
655
1175
  ...(notification.type === "action_needed"
656
1176
  ? { actionId: notification.id, options: notification.options }
657
1177
  : {}),
@@ -662,20 +1182,32 @@ export class ChatDaemonRuntime {
662
1182
  content,
663
1183
  notification.type === "action_needed" ? notification.id : undefined,
664
1184
  attached.generation,
1185
+ publicationId,
665
1186
  );
666
1187
  }
667
1188
 
668
1189
  private async close(sessionId: string): Promise<void> {
1190
+ this.#undelivered.delete(sessionId);
669
1191
  await this.#discord?.close(sessionId);
670
1192
  await this.#slack?.close(sessionId);
671
1193
  }
672
1194
 
673
- private async resume(sessionId: string, generation: number, content: string): Promise<void> {
1195
+ // Each surface takes the identity on the call that actually publishes, so a retry
1196
+ // reconciles the attempt it retries instead of adding a second message. Discord's
1197
+ // resume only unarchives the thread — the publication, and the nonce reconciled
1198
+ // against it, belong to the notify below. Slack's resume rolls the root over and
1199
+ // posts the body itself, so it carries the identity directly.
1200
+ private async resume(sessionId: string, generation: number, content: string, publicationId?: string): Promise<void> {
674
1201
  if (this.#discord) {
675
1202
  await this.#discord.resume(sessionId, generation);
676
- await this.#discord.notify({ sessionId, endpointGeneration: generation, content });
1203
+ await this.#discord.notify({
1204
+ sessionId,
1205
+ endpointGeneration: generation,
1206
+ content,
1207
+ ...(publicationId === undefined ? {} : { publicationId }),
1208
+ });
677
1209
  }
678
- if (this.#slack) await this.#slack.resume(sessionId, content, generation);
1210
+ if (this.#slack) await this.#slack.resume(sessionId, content, generation, publicationId);
679
1211
  }
680
1212
  async #runChatCommand(
681
1213
  transport: ChatTransport,
@@ -741,7 +1273,11 @@ export class ChatDaemonRuntime {
741
1273
  throw new ChatDeliveryError("pre_send");
742
1274
  }
743
1275
  try {
744
- return await client.request({ type: "broker_request", operation, input, idempotencyKey });
1276
+ const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
1277
+ return await client.request(
1278
+ { type: "broker_request", operation, input, idempotencyKey },
1279
+ timeoutMs === undefined ? undefined : { timeoutMs },
1280
+ );
745
1281
  } finally {
746
1282
  await client.close();
747
1283
  }