@naswerks/periscope 1.0.0 → 1.0.1

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 (187) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
  4. package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
  5. package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
  6. package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
  7. package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
  8. package/contracts/wire-vectors/bulk_request.full.json +21 -0
  9. package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
  10. package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
  11. package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
  12. package/contracts/wire-vectors/link_ack.cursors.json +25 -0
  13. package/contracts/wire-vectors/link_ack.empty.json +16 -0
  14. package/contracts/wire-vectors/link_bye.cause.json +16 -0
  15. package/contracts/wire-vectors/link_hello.empty.json +33 -0
  16. package/contracts/wire-vectors/link_hello.full.json +44 -0
  17. package/contracts/wire-vectors/link_ping.nonce.json +16 -0
  18. package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
  19. package/contracts/wire-vectors/link_pong.nonce.json +16 -0
  20. package/contracts/wire-vectors/link_welcome.empty.json +18 -0
  21. package/contracts/wire-vectors/link_welcome.full.json +25 -0
  22. package/contracts/wire-vectors/refused.empty.json +10 -0
  23. package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
  24. package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
  25. package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
  26. package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
  27. package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
  28. package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
  29. package/contracts/wire-vectors/refused.missing-member.json +10 -0
  30. package/contracts/wire-vectors/refused.not-json.json +10 -0
  31. package/contracts/wire-vectors/refused.over-limit.json +10 -0
  32. package/contracts/wire-vectors/refused.seq-zero.json +10 -0
  33. package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
  34. package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
  35. package/contracts/wire-vectors/repository_list.root.json +19 -0
  36. package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
  37. package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
  38. package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
  39. package/contracts/wire-vectors/repository_read.head.json +20 -0
  40. package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
  41. package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
  42. package/contracts/wire-vectors/session_cancel.bare.json +17 -0
  43. package/contracts/wire-vectors/session_configure.full.json +22 -0
  44. package/contracts/wire-vectors/session_configure.minimal.json +20 -0
  45. package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
  46. package/contracts/wire-vectors/session_list.request.json +18 -0
  47. package/contracts/wire-vectors/session_list_result.empty.json +21 -0
  48. package/contracts/wire-vectors/session_list_result.full.json +36 -0
  49. package/contracts/wire-vectors/session_new.full.json +72 -0
  50. package/contracts/wire-vectors/session_new.minimal.json +22 -0
  51. package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
  52. package/contracts/wire-vectors/session_prompt.empty.json +18 -0
  53. package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
  54. package/contracts/wire-vectors/session_prompt.text.json +18 -0
  55. package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
  56. package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
  57. package/contracts/wire-vectors/session_update.agent-message.json +26 -0
  58. package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
  59. package/contracts/wire-vectors/session_update.state-transition.json +41 -0
  60. package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
  61. package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
  62. package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
  63. package/contracts/wire-vectors/transcript_list.paged.json +19 -0
  64. package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
  65. package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
  66. package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
  67. package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
  68. package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
  69. package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
  70. package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
  71. package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
  72. package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
  73. package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
  74. package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
  75. package/contracts/wire-vectors/workspace_release.request.json +22 -0
  76. package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
  77. package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
  78. package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
  79. package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
  80. package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
  81. package/package.json +6 -1
  82. package/src/bin/command.ts +198 -0
  83. package/src/bin/config.ts +104 -0
  84. package/src/bin/login.ts +149 -0
  85. package/src/bin/main.ts +204 -0
  86. package/src/bin/pair.ts +246 -0
  87. package/src/bin/periscope.ts +3 -0
  88. package/src/bin/posture.ts +232 -0
  89. package/src/bin/reconfigure.ts +266 -0
  90. package/src/bin/serve.ts +615 -0
  91. package/src/bin/status.ts +61 -0
  92. package/src/bin/workspaces.ts +280 -0
  93. package/src/control/backoff.ts +40 -0
  94. package/src/control/codec.ts +557 -0
  95. package/src/control/credential.ts +33 -0
  96. package/src/control/frames.ts +1586 -0
  97. package/src/control/link-state.ts +67 -0
  98. package/src/control/link.ts +894 -0
  99. package/src/control/queue.ts +248 -0
  100. package/src/control/seq.ts +76 -0
  101. package/src/control/stream-routing.ts +216 -0
  102. package/src/control/stream.ts +149 -0
  103. package/src/core/async-queue.ts +63 -0
  104. package/src/core/failure.ts +61 -0
  105. package/src/core/index.ts +22 -0
  106. package/src/core/keyed-turns.ts +47 -0
  107. package/src/core/paths.ts +84 -0
  108. package/src/core/refusal.ts +430 -0
  109. package/src/core/result.ts +27 -0
  110. package/src/core/time.ts +34 -0
  111. package/src/core/vocab.ts +44 -0
  112. package/src/core/workspace-id.ts +11 -0
  113. package/src/gate/command.ts +617 -0
  114. package/src/gate/decision.ts +173 -0
  115. package/src/gate/escalate.ts +164 -0
  116. package/src/gate/gate.ts +635 -0
  117. package/src/gate/index.ts +53 -0
  118. package/src/gate/jail.ts +210 -0
  119. package/src/gate/local.ts +115 -0
  120. package/src/gate/outcome.ts +164 -0
  121. package/src/gate/shell.ts +295 -0
  122. package/src/host/agent-process.ts +694 -0
  123. package/src/host/bulk-post.ts +166 -0
  124. package/src/host/claude-transcripts.ts +404 -0
  125. package/src/host/config-file.ts +246 -0
  126. package/src/host/git-facts.ts +119 -0
  127. package/src/host/hooks.ts +102 -0
  128. package/src/host/host.ts +1901 -0
  129. package/src/host/index.ts +92 -0
  130. package/src/host/link-state-file.ts +79 -0
  131. package/src/host/loopback.ts +177 -0
  132. package/src/host/machine.ts +27 -0
  133. package/src/host/mcp-server.ts +66 -0
  134. package/src/host/package-facts.ts +25 -0
  135. package/src/host/paired-credential-store.ts +140 -0
  136. package/src/host/paths.ts +175 -0
  137. package/src/host/repository-read.ts +245 -0
  138. package/src/host/session-store.ts +133 -0
  139. package/src/host/sign-in.ts +352 -0
  140. package/src/host/telemetry.ts +105 -0
  141. package/src/host/token-cache.ts +214 -0
  142. package/src/host/transcript-fs.ts +110 -0
  143. package/src/host/wire-request.ts +285 -0
  144. package/src/host/workspace-fs.ts +90 -0
  145. package/src/host/workspace-trust.ts +92 -0
  146. package/src/identity/authorize.ts +127 -0
  147. package/src/identity/config.ts +227 -0
  148. package/src/identity/credential.ts +176 -0
  149. package/src/identity/device-code.ts +217 -0
  150. package/src/identity/index.ts +84 -0
  151. package/src/identity/mode.ts +161 -0
  152. package/src/identity/paired-credential.ts +89 -0
  153. package/src/identity/pkce.ts +113 -0
  154. package/src/identity/store.ts +98 -0
  155. package/src/identity/token.ts +203 -0
  156. package/src/index.ts +140 -0
  157. package/src/mcp/descriptor.ts +75 -0
  158. package/src/mcp/index.ts +23 -0
  159. package/src/mcp/schema.ts +214 -0
  160. package/src/mcp/server.ts +180 -0
  161. package/src/persistence/entry.ts +142 -0
  162. package/src/persistence/index.ts +47 -0
  163. package/src/persistence/key.ts +77 -0
  164. package/src/persistence/mirror.ts +118 -0
  165. package/src/persistence/receipt.ts +182 -0
  166. package/src/persistence/retention.ts +118 -0
  167. package/src/persistence/store.ts +183 -0
  168. package/src/persistence/transition-log.ts +176 -0
  169. package/src/protocol.ts +215 -0
  170. package/src/sessions/index.ts +27 -0
  171. package/src/sessions/registry.ts +392 -0
  172. package/src/sessions/session.ts +440 -0
  173. package/src/sessions/spawn-env.ts +318 -0
  174. package/src/state/coverage.ts +360 -0
  175. package/src/state/index.ts +58 -0
  176. package/src/state/machine.ts +359 -0
  177. package/src/state/model.ts +417 -0
  178. package/src/state/observer.ts +499 -0
  179. package/src/state/reporter.ts +60 -0
  180. package/src/state/store.ts +157 -0
  181. package/src/telemetry/index.ts +10 -0
  182. package/src/telemetry/usage.ts +117 -0
  183. package/src/workspace/git-worktree.ts +551 -0
  184. package/src/workspace/index.ts +17 -0
  185. package/src/workspace/plain-dir.ts +132 -0
  186. package/src/workspace/provider.ts +159 -0
  187. package/src/workspace/worktree-porcelain.ts +118 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The retention buffer between producers and the wire.
3
+ *
4
+ * Bounded, because an unbounded buffer is an out-of-memory kill in a process whose whole job is to
5
+ * still be running when the controller comes back. Bounded means something gets dropped, and which
6
+ * thing is a decision rather than an accident: see DROPPABLE_KINDS in frames.ts.
7
+ *
8
+ * Entries live in one of two states, and the split is what keeps the wire's numbering dense:
9
+ * - pending: accepted for delivery, not yet written. It has no `seq` and never had one, so
10
+ * discarding it costs the content and nothing else.
11
+ * - written: stamped with its `seq` at the moment of its first socket write, and held until the
12
+ * controller acknowledges it. A written frame is never discarded here: it may already be at the
13
+ * receiver, so forgetting it would break replay's completeness. Only an ack (`pruneUpTo`) or
14
+ * session end (`forget`) releases it.
15
+ */
16
+ import type { Result } from '../core/result.js';
17
+ import { ok, refuse } from '../core/result.js';
18
+ import type { SessionFrame, SessionPayload, SessionPayloadKind } from './frames.js';
19
+ import { isDroppable } from './frames.js';
20
+
21
+ export interface QueueStats {
22
+ readonly depth: number;
23
+ /** Of `depth`, how many entries are still waiting for their first write. */
24
+ readonly pendingDepth: number;
25
+ readonly capacity: number;
26
+ /** Deltas discarded under pressure. Expected under load; worth watching, not alarming. */
27
+ readonly droppedDroppable: number;
28
+ /** Frames that could not be held and could not be dropped. Always a real problem. */
29
+ readonly refusedUndroppable: number;
30
+ }
31
+
32
+ /** What an admission displaced, when it displaced anything. Named so the caller can say it out loud. */
33
+ export interface EvictedPending {
34
+ readonly sessionId: string;
35
+ readonly kind: SessionPayloadKind;
36
+ }
37
+
38
+ export interface QueueAdmission {
39
+ /** The pending droppable discarded to make room, or null when nothing was displaced. */
40
+ readonly evicted: EvictedPending | null;
41
+ }
42
+
43
+ /** Builds the frame for a pending entry at the moment it is written. The caller supplies the seq. */
44
+ export type StampFrame = (sessionId: string, at: string, payload: SessionPayload) => SessionFrame;
45
+
46
+ interface PendingEntry {
47
+ readonly written: false;
48
+ readonly sessionId: string;
49
+ readonly at: string;
50
+ readonly payload: SessionPayload;
51
+ }
52
+
53
+ interface WrittenEntry {
54
+ readonly written: true;
55
+ readonly frame: SessionFrame;
56
+ }
57
+
58
+ type HeldEntry = PendingEntry | WrittenEntry;
59
+
60
+ export class BoundedFrameQueue {
61
+ /**
62
+ * Written entries are a prefix and pending entries the suffix: a push appends, a stamp converts the
63
+ * first pending entry in place, and every removal keeps the two runs contiguous. `#writtenCount` is
64
+ * the boundary, so the next entry to stamp is found without a scan and a prune reads only the
65
+ * written run.
66
+ */
67
+ readonly #entries: HeldEntry[] = [];
68
+ #writtenCount = 0;
69
+ readonly #capacity: number;
70
+ #droppedDroppable = 0;
71
+ #refusedUndroppable = 0;
72
+
73
+ constructor(capacity: number) {
74
+ if (!Number.isInteger(capacity) || capacity < 1) {
75
+ throw new RangeError(`queue capacity must be a positive integer, got ${capacity}`);
76
+ }
77
+ this.#capacity = capacity;
78
+ }
79
+
80
+ /**
81
+ * Admit a payload for delivery. It enters pending, with no `seq`.
82
+ *
83
+ * At capacity the order of preference is: discard the incoming payload if it is droppable; else
84
+ * discard the oldest pending droppable to make room; else refuse, loudly. Refusing is the honest
85
+ * end of the ladder, because the alternative is discarding a transition or a receipt and
86
+ * reporting success, which is a lie about what the session did. Written frames are never
87
+ * candidates: a frame that has touched the wire may already be at the receiver, and the numbering
88
+ * stays dense only because nothing numbered is ever quietly withdrawn.
89
+ */
90
+ push(sessionId: string, at: string, payload: SessionPayload): Result<QueueAdmission> {
91
+ if (this.#entries.length < this.#capacity) {
92
+ this.#entries.push({ written: false, sessionId, at, payload });
93
+ return ok({ evicted: null });
94
+ }
95
+
96
+ if (isDroppable(payload.kind)) {
97
+ this.#droppedDroppable += 1;
98
+ return refuse(
99
+ 'queue-dropped-droppable',
100
+ `queue full at ${this.#capacity}; discarded an incoming ${payload.kind}`,
101
+ );
102
+ }
103
+
104
+ let victim = -1;
105
+ for (let index = this.#writtenCount; index < this.#entries.length; index += 1) {
106
+ const entry = this.#entries[index] as PendingEntry;
107
+ if (isDroppable(entry.payload.kind)) {
108
+ victim = index;
109
+ break;
110
+ }
111
+ }
112
+ if (victim >= 0) {
113
+ const evicted = this.#entries[victim] as PendingEntry;
114
+ this.#entries.splice(victim, 1);
115
+ this.#droppedDroppable += 1;
116
+ this.#entries.push({ written: false, sessionId, at, payload });
117
+ return ok({ evicted: { sessionId: evicted.sessionId, kind: evicted.payload.kind } });
118
+ }
119
+
120
+ this.#refusedUndroppable += 1;
121
+ return refuse(
122
+ 'queue-overflow-undroppable',
123
+ `queue full at ${this.#capacity} with nothing droppable to discard; cannot hold ` +
124
+ `${payload.kind} for session ${sessionId}`,
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Stamp the oldest pending entry and hand back its frame for the write.
130
+ *
131
+ * Stamping and writing are one act: the entry becomes written before the caller touches the
132
+ * socket, so however the write itself goes, a stamped frame is retained and replayable — a seq,
133
+ * once minted, is always accounted for. Returns null when nothing is pending.
134
+ */
135
+ stampNext(stamp: StampFrame): SessionFrame | null {
136
+ const index = this.#writtenCount;
137
+ const pending = this.#entries[index];
138
+ if (pending === undefined || pending.written) return null;
139
+ const frame = stamp(pending.sessionId, pending.at, pending.payload);
140
+ this.#entries[index] = { written: true, frame };
141
+ this.#writtenCount += 1;
142
+ return frame;
143
+ }
144
+
145
+ /** Every written frame still held, oldest first — what replay re-sends after a reconnect. */
146
+ writtenFrames(): readonly SessionFrame[] {
147
+ return this.#entries.filter((entry): entry is WrittenEntry => entry.written).map((entry) => entry.frame);
148
+ }
149
+
150
+ get hasPending(): boolean {
151
+ return this.#writtenCount < this.#entries.length;
152
+ }
153
+
154
+ /**
155
+ * Forget frames the controller has confirmed, per session.
156
+ *
157
+ * This is what bounds the retention window. Frames are held after being written, because a frame
158
+ * in flight when the socket dies is exactly the one replay has to produce — so "sent" is not
159
+ * "safe to forget"; only an ack is.
160
+ */
161
+ pruneUpTo(sessionId: string, seq: number): number {
162
+ return this.#removeWritten((frame) => frame.sessionId === sessionId && frame.seq <= seq);
163
+ }
164
+
165
+ /** One pass over the written run, keeping what the predicate does not name; the pending run is untouched. */
166
+ #removeWritten(remove: (frame: SessionFrame) => boolean): number {
167
+ const kept: HeldEntry[] = [];
168
+ for (let index = 0; index < this.#writtenCount; index += 1) {
169
+ const held = this.#entries[index] as WrittenEntry;
170
+ if (!remove(held.frame)) kept.push(held);
171
+ }
172
+ const removed = this.#writtenCount - kept.length;
173
+ if (removed > 0) {
174
+ this.#entries.splice(0, this.#writtenCount, ...kept);
175
+ this.#writtenCount = kept.length;
176
+ }
177
+ return removed;
178
+ }
179
+
180
+ /** One pass over the pending run, keeping what the predicate does not name; the written run is untouched. */
181
+ #removePending(remove: (entry: PendingEntry) => boolean): number {
182
+ const kept: HeldEntry[] = [];
183
+ for (let index = this.#writtenCount; index < this.#entries.length; index += 1) {
184
+ const held = this.#entries[index] as PendingEntry;
185
+ if (!remove(held)) kept.push(held);
186
+ }
187
+ const removed = this.#entries.length - this.#writtenCount - kept.length;
188
+ if (removed > 0)
189
+ this.#entries.splice(this.#writtenCount, this.#entries.length - this.#writtenCount, ...kept);
190
+ return removed;
191
+ }
192
+
193
+ /**
194
+ * Drop a session's pending entries at session end. Written-and-unacked frames are kept.
195
+ *
196
+ * Two rules collide here and this is which one wins. "Nothing keyed by a session outlives it"
197
+ * bounds a long-running host; "a transition is never dropped" is what makes the trace true. At
198
+ * session end they meet, because the last frames a session produces (its `-> ended` transition,
199
+ * the result that preceded it) are exactly the ones still unacked if the link happens to be
200
+ * down. Dropping them would lose the record of how a session ended, silently, in precisely the
201
+ * case somebody is going to ask about. So the never-drop rule wins for anything stamped.
202
+ *
203
+ * This does not reopen the unbounded problem: a written frame is retained only until the
204
+ * controller acks it, and an ended session produces no more frames — so what is kept is a fixed,
205
+ * already-bounded set that the next `link_ack` prunes to nothing. Pending entries are dropped
206
+ * because they never touched the wire and never had a seq: nothing on the other side is waiting
207
+ * for them, and there is no hole for them to leave.
208
+ *
209
+ * Returns how many entries were dropped.
210
+ */
211
+ forget(sessionId: string): number {
212
+ return this.#removePending((entry) => entry.sessionId === sessionId);
213
+ }
214
+
215
+ /**
216
+ * Drop everything this session holds, written frames included. Returns how many were written.
217
+ *
218
+ * This is the one method here that discards a stamped frame, and it exists for a bound rather
219
+ * than for convenience. `forget` deliberately keeps written-and-unacked frames because a
220
+ * session's last frames are the ones replay has to produce; `pruneUpTo` releases them on an ack.
221
+ * Neither has a bound, and nothing in the protocol obliges a peer to ack, so an ended session
222
+ * nobody acks is held forever, and written frames are never eviction candidates, so those holds
223
+ * accumulate until the queue refuses live traffic.
224
+ *
225
+ * Call this only for an ended session past its retention bound, and never as a tidy-up: for a
226
+ * live session this reintroduces the permanent-hole defect the whole retention window exists to
227
+ * prevent. `link.ts`'s sweep is the only caller, and it names what it dropped on the refusal lane.
228
+ */
229
+ releaseSession(sessionId: string): number {
230
+ this.#removePending((entry) => entry.sessionId === sessionId);
231
+ return this.#removeWritten((frame) => frame.sessionId === sessionId);
232
+ }
233
+
234
+ /** How many written-but-unacked frames this session still holds. Zero once the controller acks. */
235
+ retainedFor(sessionId: string): number {
236
+ return this.#entries.filter((entry) => entry.written && entry.frame.sessionId === sessionId).length;
237
+ }
238
+
239
+ get stats(): QueueStats {
240
+ return {
241
+ depth: this.#entries.length,
242
+ pendingDepth: this.#entries.length - this.#writtenCount,
243
+ capacity: this.#capacity,
244
+ droppedDroppable: this.#droppedDroppable,
245
+ refusedUndroppable: this.#refusedUndroppable,
246
+ };
247
+ }
248
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `seq` accounting, used identically on both ends and in both directions.
3
+ *
4
+ * Outbound it hands out numbers; inbound it judges them. Symmetric on purpose: the controller's
5
+ * commands get the same duplicate-suppression and gap-detection the host's events do, from one
6
+ * implementation rather than two that can disagree.
7
+ */
8
+ import type { SessionCursor } from './frames.js';
9
+
10
+ export type InboundCheck =
11
+ /** In order. Deliver it. */
12
+ | { readonly disposition: 'accept' }
13
+ /** Already seen. Replay after a reconnect is expected, so this is normal and silent. */
14
+ | { readonly disposition: 'duplicate'; readonly seq: number }
15
+ /** Something was lost between the two ends. Loud: this is the condition replay exists to prevent. */
16
+ | { readonly disposition: 'gap'; readonly expected: number; readonly received: number };
17
+
18
+ export class SeqTracker {
19
+ readonly #lastBySession = new Map<string, number>();
20
+
21
+ /** The next number to stamp on an outbound frame for this session. First is 1. */
22
+ next(sessionId: string): number {
23
+ const next = (this.#lastBySession.get(sessionId) ?? 0) + 1;
24
+ this.#lastBySession.set(sessionId, next);
25
+ return next;
26
+ }
27
+
28
+ /** The highest number issued or accepted so far, or 0 when the session is new. */
29
+ last(sessionId: string): number {
30
+ return this.#lastBySession.get(sessionId) ?? 0;
31
+ }
32
+
33
+ /**
34
+ * Judge an inbound frame. Dense sequencing is what makes this arithmetic rather than a guess:
35
+ * expected is always `last + 1`, so a gap needs no timestamps and no heuristics.
36
+ */
37
+ accept(sessionId: string, seq: number): InboundCheck {
38
+ const last = this.last(sessionId);
39
+ const expected = last + 1;
40
+
41
+ if (seq === expected) {
42
+ this.#lastBySession.set(sessionId, seq);
43
+ return { disposition: 'accept' };
44
+ }
45
+ if (seq <= last) {
46
+ return { disposition: 'duplicate', seq };
47
+ }
48
+ return { disposition: 'gap', expected, received: seq };
49
+ }
50
+
51
+ /** What this side holds, for the hello handshake and for resume after a drop. */
52
+ cursors(): SessionCursor[] {
53
+ return [...this.#lastBySession].map(([sessionId, seq]) => ({ sessionId, seq }));
54
+ }
55
+
56
+ /** Adopt a peer's cursors on reconnect, so replay starts from what it actually has. */
57
+ adopt(cursors: readonly SessionCursor[]): void {
58
+ for (const cursor of cursors) {
59
+ this.#lastBySession.set(cursor.sessionId, cursor.seq);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Drop a session's counter at session end.
65
+ *
66
+ * Nothing keyed by session may outlive the session — an unbounded map is how a long-running host
67
+ * dies of something nobody can attribute to any one session.
68
+ */
69
+ forget(sessionId: string): void {
70
+ this.#lastBySession.delete(sessionId);
71
+ }
72
+
73
+ get trackedSessions(): number {
74
+ return this.#lastBySession.size;
75
+ }
76
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The stream routing table. Every message the agent can emit rides a declared lane, in writing.
3
+ *
4
+ * An absent row is a gap, not a default, and it cannot happen, because this is declared
5
+ * `satisfies Record<MessageDiscriminator, RoutingRow>` against the discriminator set derived from
6
+ * the SDK's own union. A message added by an SDK upgrade breaks the build rather than silently
7
+ * falling through to whichever lane a branch happened to end on.
8
+ *
9
+ * This is not `state/coverage.ts` and must not be merged with it. They answer different
10
+ * questions and disagree in both directions:
11
+ * - `coverage.ts`: does this message move the state machine?
12
+ * - this table: does this message go on the wire, and on which lane?
13
+ * `assistant` is declined there (it changes no state) and forwarded here (it is the turn's text).
14
+ * `system/session_state_changed` is wired there and declined here (the transition it causes already
15
+ * carries the fact, and two names for one fact is how a vocabulary drifts apart). A single table
16
+ * would have to pick one question and would silently answer the other one wrongly.
17
+ *
18
+ * The three lanes, and the rule that assigns them.
19
+ *
20
+ * `delta` Superseded by something that settles the same fact, and high-frequency with it.
21
+ * Losing one costs a repaint, never a fact — which is exactly what makes it the one
22
+ * droppable kind. ~20 per turn, so putting these in a bounded replay ring would evict
23
+ * real events with token fragments.
24
+ *
25
+ * `update` A fact of the turn that nothing later restates. Durable: retained until acked,
26
+ * replayed after a reconnect.
27
+ *
28
+ * `declined` Only where the fact demonstrably reaches the wire another way, or where
29
+ * forwarding it would echo the host's own decisions back at the controller that made
30
+ * them. Never "judged uninteresting": this host is deliberately ignorant, and
31
+ * `state/reporter.ts` states the reason: a host that decides what matters is a host
32
+ * that must be rebuilt for the next product. So `update` is the default, and a
33
+ * declined row owes a reason a stranger can check.
34
+ */
35
+ import type { MessageDiscriminator, SDKMessage } from '../host/agent-process.js';
36
+ import { discriminatorOf } from '../host/agent-process.js';
37
+
38
+ export type StreamLane = 'delta' | 'update' | 'declined';
39
+
40
+ export interface RoutingRow {
41
+ readonly lane: StreamLane;
42
+ /** Why this lane. Required on every row, including the declined ones. */
43
+ readonly note: string;
44
+ }
45
+
46
+ export const MESSAGE_ROUTING = {
47
+ // --- delta: superseded, high-frequency, broadcast-only ---------------------
48
+ stream_event: {
49
+ lane: 'delta',
50
+ note: 'the incremental lane — text_delta, thinking_delta, input_json_delta, and the block start/stop markers around them. Every fragment is superseded by the assistant message that settles it, so a lost one costs a repaint. Exists at all only under includePartialMessages.',
51
+ },
52
+ 'system/thinking_tokens': {
53
+ lane: 'delta',
54
+ note: 'a running thinking-token estimate, emitted per thinking delta. Its own docs call it approximate progress for spinners rather than the billed count, and the next one supersedes it.',
55
+ },
56
+ 'system/status': {
57
+ lane: 'delta',
58
+ note: 'the requesting/compacting ticker. Each value supersedes the last and the transition it causes is the durable record; this lane exists so a view can show the spinner without waiting for one.',
59
+ },
60
+ tool_progress: {
61
+ lane: 'delta',
62
+ note: 'elapsed seconds for a running tool call, re-emitted while it runs. Superseded by the next tick and finally by the tool closing.',
63
+ },
64
+ 'system/task_progress': {
65
+ lane: 'delta',
66
+ note: 'progress for a background task, re-emitted while it runs. Same shape as tool_progress and superseded the same way.',
67
+ },
68
+ 'system/hook_progress': {
69
+ lane: 'delta',
70
+ note: 'progress while a hook handler runs — a ticker, superseded by the hook resolving.',
71
+ },
72
+
73
+ // --- update: facts of the turn that nothing later restates -----------------
74
+ 'system/init': {
75
+ lane: 'update',
76
+ note: 'the per-spawn receipt — session id, CLI version, model, tool/skill/plugin inventory, apiKeySource. It arrives once and nothing restates it.',
77
+ },
78
+ assistant: {
79
+ lane: 'update',
80
+ note: 'the settled assistant message. The fact the deltas were fragments of; a consumer with no delta lane still renders a turn from these.',
81
+ },
82
+ user: {
83
+ lane: 'update',
84
+ note: "tool results — that is what this row carries in practice. Observed on a live session: the queued prompt does not come back on the output stream for this host's input lane, so no SDKUserMessageReplay arrives to deduplicate against. This host still synthesizes no user echo of its own, because the controller queued the prompt and already holds the operator's turn — a synthesized event would be a second name for it.",
85
+ },
86
+ result: {
87
+ lane: 'update',
88
+ note: "the turn's outcome, and the telemetry that arrives with it: total_cost_usd, modelUsage[].costUSD per model, permission_denials, ttft_ms, user_message_uuid and terminal_reason (19 values). Forwarded whole so the layer that owns cost consumes these rather than re-deriving them.",
89
+ },
90
+ rate_limit_event: {
91
+ lane: 'update',
92
+ note: 'rate-limit state with resetsAt and utilization, in-stream. Losing one loses a reset time nothing else carries.',
93
+ },
94
+ 'system/compact_boundary': {
95
+ lane: 'update',
96
+ note: 'compaction happened and this is where. A durable fact about the transcript, and the boundary anything reading history has to know about.',
97
+ },
98
+ 'system/api_retry': {
99
+ lane: 'update',
100
+ note: 'a request failed and is being retried. Nothing else carries it, and an unattended run that is quietly retrying looks identical to one that is thinking.',
101
+ },
102
+ 'system/model_refusal_fallback': {
103
+ lane: 'update',
104
+ note: 'the model refused and a fallback ran. The counterpart of model_refusal_no_fallback, which moves the machine; this one does not, so forwarding is the only way the fact travels.',
105
+ },
106
+ 'system/model_refusal_no_fallback': {
107
+ lane: 'update',
108
+ note: 'the model refused and nothing ran. It also causes a transition, but the message names the original model, which the transition does not.',
109
+ },
110
+ 'system/local_command_output': {
111
+ lane: 'update',
112
+ note: 'output of a local command, which is content a consumer renders. Not restated anywhere.',
113
+ },
114
+ 'system/session_state_changed': {
115
+ lane: 'declined',
116
+ note: 'its entire content is state: idle|running|requires_action, and the transition it causes carries exactly that in `to`. Forwarding it too would put two names for one fact on the wire — the reason session_started and session_ended were removed from the payload kinds.',
117
+ },
118
+ 'system/task_started': {
119
+ lane: 'update',
120
+ note: 'the only message carrying task_id AND tool_use_id together — the join a consumer needs to attribute later task reports. The transition lane deliberately records nothing here.',
121
+ },
122
+ 'system/task_updated': {
123
+ lane: 'update',
124
+ note: 'a task changed status or was backgrounded. It causes a transition, and it also carries the patch itself, which the transition does not.',
125
+ },
126
+ 'system/task_notification': {
127
+ lane: 'update',
128
+ note: 'a background task finished and says how. Causes a transition; the message carries the status text.',
129
+ },
130
+ 'system/background_tasks_changed': {
131
+ lane: 'update',
132
+ note: 'the set of background tasks changed. Nothing else enumerates them, and a session whose work moved to the background is the case this whole split exists for.',
133
+ },
134
+ 'system/worker_shutting_down': {
135
+ lane: 'update',
136
+ note: 'the worker is going away, with its reason. Causes a transition; the reason is worth carrying verbatim.',
137
+ },
138
+ 'system/permission_denied': {
139
+ lane: 'update',
140
+ note: "the SDK's own auto-deny short-circuit — a different path from this host's gate, which the SDK's docs say explicitly does not produce this message. Nothing this package emits restates it.",
141
+ },
142
+ 'system/elicitation_complete': {
143
+ lane: 'update',
144
+ note: 'an MCP elicitation resolved, with what it resolved to. The transition records the exit; the answer rides here.',
145
+ },
146
+ 'system/plugin_install': {
147
+ lane: 'update',
148
+ note: 'a plugin was installed mid-session, so the inventory init reported is now stale. Nothing else says so.',
149
+ },
150
+ 'system/commands_changed': {
151
+ lane: 'update',
152
+ note: 'the available command set changed. init carries the opening inventory and this carries the change; a consumer showing commands has no other source.',
153
+ },
154
+ 'system/notification': {
155
+ lane: 'update',
156
+ note: 'a notification aimed at whoever is watching. Withholding it would be this host deciding what a controller finds worth showing.',
157
+ },
158
+ 'system/memory_recall': {
159
+ lane: 'update',
160
+ note: 'what the agent recalled and used. Content, and part of explaining a turn a reader is trying to understand.',
161
+ },
162
+ 'system/mirror_error': {
163
+ lane: 'update',
164
+ note: 'a mirror write failed. A degrade is a named outcome and never a silent pass, so it goes on the wire even though this host takes no action on it.',
165
+ },
166
+ 'system/informational': {
167
+ lane: 'update',
168
+ note: "an informational message from the agent. Forwarded for the same reason as notification: the judgement is the controller's.",
169
+ },
170
+ tool_use_summary: {
171
+ lane: 'update',
172
+ note: 'a settled summary of a tool call — what a cold reader sees instead of raw arguments.',
173
+ },
174
+ auth_status: {
175
+ lane: 'update',
176
+ note: 'the credential state changed under a running session. An unattended run whose auth lapsed must not look like one that went quiet.',
177
+ },
178
+ prompt_suggestion: {
179
+ lane: 'update',
180
+ note: "a suggested next turn. Product surface, and whether to show it is the controller's call rather than this host's.",
181
+ },
182
+ conversation_reset: {
183
+ lane: 'update',
184
+ note: 'the conversation was reset, so everything a consumer has accumulated for this session is now history rather than context.',
185
+ },
186
+
187
+ // --- declined: the fact reaches the wire another way, or it is the host's own ---
188
+ 'system/control_request_progress': {
189
+ lane: 'declined',
190
+ note: "progress on the SDK's internal control protocol between this host and its own CLI subprocess. It describes the host's transport, not the session, and a controller can do nothing with it.",
191
+ },
192
+ 'system/hook_started': {
193
+ lane: 'declined',
194
+ note: "reports one of this host's own hooks starting. Forwarding it would echo the observer and the gate back at the controller that configured them. The gate's outcomes are recorded as transitions in the machine, and forwardSession's machine subscription carries every recorded transition to the wire whatever caused it — so this message would arrive as a second copy of a fact the transition already states.",
195
+ },
196
+ 'system/hook_response': {
197
+ lane: 'declined',
198
+ note: "the answer this host's own hook just returned. Same echo reason as hook_started — and the decision's transition already rides the wire via the machine subscription, so re-reporting the answer here would invite a consumer to count one decision twice.",
199
+ },
200
+ 'system/files_persisted': {
201
+ lane: 'declined',
202
+ note: 'a checkpointing receipt. It belongs to the layer that owns the receipt read path, which reads local records rather than the live stream.',
203
+ },
204
+ } as const satisfies Record<MessageDiscriminator, RoutingRow>;
205
+
206
+ /** The lane one message rides. Total by construction — every discriminator has a row. */
207
+ export function laneFor(message: SDKMessage): StreamLane {
208
+ return MESSAGE_ROUTING[discriminatorOf(message)].lane;
209
+ }
210
+
211
+ /** Every discriminator on one lane, in declaration order. The subject of the routing pin. */
212
+ export function discriminatorsOn(lane: StreamLane): MessageDiscriminator[] {
213
+ return (Object.keys(MESSAGE_ROUTING) as MessageDiscriminator[]).filter(
214
+ (key) => MESSAGE_ROUTING[key].lane === lane,
215
+ );
216
+ }