@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,58 @@
1
+ /** The declared state model: what a session is, what caused it, and what is still open. */
2
+ export type {
3
+ ActivityKind,
4
+ AgedEntry,
5
+ CauseEvent,
6
+ CauseKind,
7
+ ControlEventName,
8
+ EntryLane,
9
+ HookEventName,
10
+ MessageEventName,
11
+ OpenEntry,
12
+ ProcessEventName,
13
+ SessionActivity,
14
+ SessionSnapshot,
15
+ SessionState,
16
+ SessionTransition,
17
+ TimeoutEventName,
18
+ TransitionCause,
19
+ TransitionWhere,
20
+ } from './model.js';
21
+ export {
22
+ ACTIVITY_KINDS,
23
+ CAUSE_KINDS,
24
+ CONTROL_EVENTS,
25
+ HOOK_EVENTS,
26
+ HOST_ACTIVITY_KINDS,
27
+ MESSAGE_EVENTS,
28
+ PROCESS_EVENTS,
29
+ SDK_ACTIVITY_KINDS,
30
+ SESSION_STATES,
31
+ TIMEOUT_EVENTS,
32
+ formatActivity,
33
+ isCauseEvent,
34
+ isCauseKind,
35
+ sameActivity,
36
+ } from './model.js';
37
+
38
+ export type {
39
+ EntryOp,
40
+ RejectedTransition,
41
+ RejectionListener,
42
+ StateMachineOptions,
43
+ TransitionListener,
44
+ TransitionRequest,
45
+ } from './machine.js';
46
+ export { SessionStateMachine } from './machine.js';
47
+
48
+ export type { TransitionStoreOptions } from './store.js';
49
+ export { TransitionStore } from './store.js';
50
+
51
+ export { SessionObserver } from './observer.js';
52
+
53
+ export { SessionStateReporter } from './reporter.js';
54
+
55
+ // The coverage table. Exported because it is the contract: a later layer checks its own event
56
+ // wiring against these rows rather than rediscovering which events exist.
57
+ export type { CoverageHandling, CoverageRow } from './coverage.js';
58
+ export { HOOK_COVERAGE, MESSAGE_COVERAGE, coverageTally } from './coverage.js';
@@ -0,0 +1,359 @@
1
+ /**
2
+ * The state machine for one session. It records; it never decides what a session means.
3
+ *
4
+ * The state is the record, and that is the whole design.
5
+ * `state`, `activity` and `sessionId` are all derived from the last recorded transition and the
6
+ * open-entry set. There is no separate field to assign, so a code path that skips the record also
7
+ * fails to change the state — suppression is self-defeating instead of silent.
8
+ *
9
+ * That inversion is the point. With a separate state field, one early return can skip a publish
10
+ * and a state stamp together: the control concern wins, and the observability loss is invisible —
11
+ * no error, no null, just a column that stays `running` forever while every reader infers. Here
12
+ * the same edit would leave the session stuck in its previous state, visibly, in the next thing
13
+ * anyone read.
14
+ *
15
+ * What is deliberately not in scope here, so the suppressing branch cannot be written.
16
+ * This module imports the pure core and its own model, and nothing else. It cannot see an observer
17
+ * count, a controller link, a registry, or how a session was created — so there is no session-class
18
+ * fact available to condition an emission on, whatever a later editor intends. Pinned by
19
+ * pins/state-record.test.ts.
20
+ *
21
+ * It never throws. A transition that cannot name its cause is refused, counted, and reported on
22
+ * `onRejected` — but not thrown, because these calls run inside hook handlers that are wrapped in
23
+ * `try/catch` by contract, and a thrown rejection would be swallowed there. Silent loss is the one
24
+ * failure this module exists to make impossible, so "loud" here means observable and counted.
25
+ */
26
+ import type { Clock, Ticker } from '../core/time.js';
27
+ import type { Refusal } from '../core/refusal.js';
28
+ import type { Result } from '../core/result.js';
29
+ import { ok, refuse } from '../core/result.js';
30
+ import type {
31
+ AgedEntry,
32
+ OpenEntry,
33
+ SessionActivity,
34
+ SessionSnapshot,
35
+ SessionState,
36
+ SessionTransition,
37
+ TransitionCause,
38
+ TransitionWhere,
39
+ } from './model.js';
40
+ import { isCauseEvent, isCauseKind } from './model.js';
41
+
42
+ /** Opens, closes or backgrounds one entry. `null` on a request means the entry set is untouched. */
43
+ export type EntryOp =
44
+ | {
45
+ readonly op: 'open';
46
+ readonly entryId: string;
47
+ readonly activity: SessionActivity;
48
+ /** Set when the entry belongs to a subagent rather than the main thread. */
49
+ readonly agentId?: string | null;
50
+ }
51
+ | { readonly op: 'close'; readonly entryId: string }
52
+ | { readonly op: 'background'; readonly entryId: string }
53
+ /** Mark every still-open entry abandoned. Marks — never erases. */
54
+ | { readonly op: 'abandon-open'; readonly reason: string };
55
+
56
+ export interface TransitionRequest {
57
+ readonly to: SessionState;
58
+ readonly cause: TransitionCause;
59
+ readonly entry?: EntryOp | null;
60
+ /** Supplied once, when the agent reports itself. Carried forward automatically after that. */
61
+ readonly sessionId?: string | null;
62
+ /**
63
+ * A new `where`, when this transition is what changed it.
64
+ *
65
+ * The agent can change its own working directory mid-session, and every later transition has to
66
+ * carry the new one — a trace whose `where` silently describes a directory the session left is
67
+ * worse than one with no `where` at all. Carried forward automatically when absent, so it is
68
+ * derived from the record like everything else rather than living in a field of its own.
69
+ */
70
+ readonly where?: TransitionWhere | null;
71
+ /** Overrides the clock. For a caller that already stamped the originating event. */
72
+ readonly at?: string | null;
73
+ }
74
+
75
+ export interface RejectedTransition {
76
+ readonly refusal: Refusal;
77
+ readonly at: string;
78
+ readonly attempted: TransitionRequest;
79
+ }
80
+
81
+ export type TransitionListener = (transition: SessionTransition) => void;
82
+ export type RejectionListener = (rejected: RejectedTransition) => void;
83
+ export type Unsubscribe = () => void;
84
+
85
+ export interface StateMachineOptions {
86
+ readonly where: TransitionWhere;
87
+ readonly clock: Clock;
88
+ readonly ticker: Ticker;
89
+ /** Opaque and never interpreted. See SessionTransition.correlationId. */
90
+ readonly correlationId?: string | null;
91
+ }
92
+
93
+ /**
94
+ * The state a machine holds before anything has been recorded.
95
+ *
96
+ * A machine is constructed at the moment a process is asked for, and the first record is the
97
+ * `spawning` one. Until then `from` has to be something, and `spawning` is the honest answer: the
98
+ * caller has already decided to start a session.
99
+ */
100
+ const INITIAL_STATE: SessionState = 'spawning';
101
+
102
+ export class SessionStateMachine {
103
+ /** Only until the first record. After that `where` is derived like everything else. */
104
+ readonly #initialWhere: TransitionWhere;
105
+ readonly #clock: Clock;
106
+ readonly #ticker: Ticker;
107
+ readonly #correlationId: string | null;
108
+
109
+ readonly #entries = new Map<string, OpenEntry>();
110
+ readonly #listeners = new Set<TransitionListener>();
111
+ readonly #rejectionListeners = new Set<RejectionListener>();
112
+
113
+ /** The last recorded transition. The only mutable state, assigned in exactly one place. */
114
+ #current: SessionTransition | null = null;
115
+ #seq = 0;
116
+ #rejectedCount = 0;
117
+
118
+ constructor(options: StateMachineOptions) {
119
+ this.#initialWhere = options.where;
120
+ this.#clock = options.clock;
121
+ this.#ticker = options.ticker;
122
+ this.#correlationId = options.correlationId ?? null;
123
+ }
124
+
125
+ /** Derived from the last transition. There is no field to set. */
126
+ get state(): SessionState {
127
+ return this.#current?.to ?? INITIAL_STATE;
128
+ }
129
+
130
+ /** Derived from the open-entry set. There is no field to set. */
131
+ get activity(): SessionActivity | null {
132
+ return foregroundActivity(this.#entries);
133
+ }
134
+
135
+ /** Null until the agent reports itself; carried forward after. There is no field to set. */
136
+ get sessionId(): string | null {
137
+ return this.#current?.sessionId ?? null;
138
+ }
139
+
140
+ get correlationId(): string | null {
141
+ return this.#correlationId;
142
+ }
143
+
144
+ /** Derived from the last transition. There is no field to set. */
145
+ get where(): TransitionWhere {
146
+ return this.#current?.where ?? this.#initialWhere;
147
+ }
148
+
149
+ get transitionCount(): number {
150
+ return this.#seq;
151
+ }
152
+
153
+ /** How many transitions were refused for an unnameable cause. Never silently zero. */
154
+ get rejectedCount(): number {
155
+ return this.#rejectedCount;
156
+ }
157
+
158
+ /** Every entry that has not exited, with the number a human wants: how long it has been open. */
159
+ openEntries(): AgedEntry[] {
160
+ const now = this.#ticker();
161
+ return [...this.#entries.values()].map((entry) => ({
162
+ ...entry,
163
+ ageMs: Math.max(0, now - Date.parse(entry.openedAt)),
164
+ }));
165
+ }
166
+
167
+ onTransition(listener: TransitionListener): Unsubscribe {
168
+ this.#listeners.add(listener);
169
+ return () => this.#listeners.delete(listener);
170
+ }
171
+
172
+ onRejected(listener: RejectionListener): Unsubscribe {
173
+ this.#rejectionListeners.add(listener);
174
+ return () => this.#rejectionListeners.delete(listener);
175
+ }
176
+
177
+ /**
178
+ * Record a transition. The only way a session's state changes.
179
+ *
180
+ * The only thing this branches on is whether the cause can be named. Nothing about which
181
+ * session this is, how it was created, or whether anyone is listening is reachable from here —
182
+ * see this file's header. `#commit` below is straight-line by construction.
183
+ */
184
+ record(request: TransitionRequest): Result<SessionTransition> {
185
+ const named = nameable(request.cause);
186
+ if (named !== null) return this.#reject(named, request);
187
+ return ok(this.#commit(request));
188
+ }
189
+
190
+ /** What this host can say about this session. Raw material for a controller — not a roster. */
191
+ snapshot(): SessionSnapshot {
192
+ return {
193
+ sessionId: this.sessionId,
194
+ correlationId: this.#correlationId,
195
+ state: this.state,
196
+ activity: this.activity,
197
+ where: this.where,
198
+ openEntries: this.openEntries(),
199
+ lastTransitionAt: this.#current?.at ?? null,
200
+ transitionCount: this.#seq,
201
+ };
202
+ }
203
+
204
+ // -------------------------------------------------------------------------
205
+
206
+ /**
207
+ * The single mutation point. Straight-line: every call that reaches here records.
208
+ *
209
+ * Do not add a guard to this method. A condition here is a session whose state changed without
210
+ * saying so, which is the defect this whole module exists to prevent.
211
+ */
212
+ #commit(request: TransitionRequest): SessionTransition {
213
+ const carried = carryForward(request, this.#current, this.#initialWhere, this.#clock);
214
+ const entryId = applyEntryOp(this.#entries, carried.entry, carried.at, request.cause);
215
+
216
+ this.#seq += 1;
217
+ const transition: SessionTransition = {
218
+ sessionId: carried.sessionId,
219
+ seq: this.#seq,
220
+ at: carried.at,
221
+ from: carried.from,
222
+ to: request.to,
223
+ activity: foregroundActivity(this.#entries),
224
+ entryId,
225
+ cause: request.cause,
226
+ where: carried.where,
227
+ correlationId: this.#correlationId,
228
+ };
229
+ this.#current = transition;
230
+
231
+ for (const listener of this.#listeners) listener(transition);
232
+ return transition;
233
+ }
234
+
235
+ #reject(detail: string, attempted: TransitionRequest): Result<SessionTransition> {
236
+ this.#rejectedCount += 1;
237
+ const refusal: Refusal = { reason: 'transition-cause-unnamed', detail };
238
+ const rejected: RejectedTransition = { refusal, at: this.#clock(), attempted };
239
+ for (const listener of this.#rejectionListeners) listener(rejected);
240
+ return refuse<SessionTransition>('transition-cause-unnamed', detail);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * The four values a transition inherits from the one before it, resolved in one place.
246
+ *
247
+ * They are lifted out of `#commit` deliberately. Each is a defaulting expression — "use what was
248
+ * given, else what the last transition carried" — and a defaulting expression is still a branch in
249
+ * the shape of one. Keeping them here leaves the commit path with no conditional of any kind, so
250
+ * "the record cannot be skipped" is a claim a reader can check by looking rather than by trusting.
251
+ */
252
+ function carryForward(
253
+ request: TransitionRequest,
254
+ current: SessionTransition | null,
255
+ initialWhere: TransitionWhere,
256
+ clock: Clock,
257
+ ): {
258
+ at: string;
259
+ from: SessionState;
260
+ sessionId: string | null;
261
+ where: TransitionWhere;
262
+ entry: EntryOp | null;
263
+ } {
264
+ return {
265
+ at: request.at ?? clock(),
266
+ from: current?.to ?? INITIAL_STATE,
267
+ sessionId: request.sessionId ?? current?.sessionId ?? null,
268
+ where: request.where ?? current?.where ?? initialWhere,
269
+ entry: request.entry ?? null,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Null when the cause is nameable, otherwise why it is not.
275
+ *
276
+ * The compiler already closes `CauseEvent`, so this only fires for a value that never met the
277
+ * compiler — a cause rebuilt from a decoded frame, or a caller in plain JavaScript.
278
+ */
279
+ function nameable(cause: TransitionCause | undefined | null): string | null {
280
+ if (cause === undefined || cause === null) return 'a transition carried no cause at all';
281
+ if (!isCauseKind(cause.kind)) return `cause.kind "${String(cause.kind)}" is not a declared cause kind`;
282
+ if (typeof cause.event !== 'string' || cause.event.length === 0) return 'cause.event is empty';
283
+ if (!isCauseEvent(cause.event)) return `cause.event "${String(cause.event)}" is not a declared cause event`;
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * What holds the session right now: the most recently opened foreground entry.
289
+ *
290
+ * Background entries are excluded by construction — that is the whole point of the lane. Abandoned
291
+ * entries are excluded too: they are still recorded and still ageing, but a session is not blocked
292
+ * on work that has been marked as never coming back.
293
+ *
294
+ * Most-recent rather than oldest, because `activity` answers "what is it doing now". "What has it
295
+ * been stuck on longest" is a different question, and `openEntries()` with its ages answers it
296
+ * without this field having to mean two things.
297
+ */
298
+ function foregroundActivity(entries: ReadonlyMap<string, OpenEntry>): SessionActivity | null {
299
+ let held: OpenEntry | null = null;
300
+ for (const entry of entries.values()) {
301
+ if (entry.lane !== 'foreground') continue;
302
+ if (entry.abandonedAt !== null) continue;
303
+ held = entry;
304
+ }
305
+ return held?.activity ?? null;
306
+ }
307
+
308
+ /** Applies one entry operation and returns the entry it touched, or null. Pure over the map. */
309
+ function applyEntryOp(
310
+ entries: Map<string, OpenEntry>,
311
+ op: EntryOp | null,
312
+ at: string,
313
+ cause: TransitionCause,
314
+ ): string | null {
315
+ if (op === null) return null;
316
+
317
+ if (op.op === 'open') {
318
+ // Re-opening the same entry keeps the original openedAt. Two sources can report the same
319
+ // condition (the compaction hook and the status message both say "compacting"), and taking the
320
+ // later timestamp would silently reset the age — which is the one number an unpaired entry
321
+ // exists to carry. The transition still records either way; only the clock is protected.
322
+ const existing = entries.get(op.entryId);
323
+ const sameThing = existing !== undefined && existing.activity.kind === op.activity.kind;
324
+ entries.set(op.entryId, {
325
+ entryId: op.entryId,
326
+ activity: op.activity,
327
+ lane: 'foreground',
328
+ openedAt: sameThing ? existing.openedAt : at,
329
+ backgroundedAt: null,
330
+ abandonedAt: null,
331
+ abandonReason: null,
332
+ cause,
333
+ agentId: op.agentId ?? null,
334
+ });
335
+ return op.entryId;
336
+ }
337
+
338
+ if (op.op === 'close') {
339
+ entries.delete(op.entryId);
340
+ return op.entryId;
341
+ }
342
+
343
+ if (op.op === 'background') {
344
+ const open = entries.get(op.entryId);
345
+ // A background report for an entry this host never saw open is not an error and not a state
346
+ // change: the transition still records, naming the id, and the entry set is simply unchanged.
347
+ if (open === undefined) return op.entryId;
348
+ entries.set(op.entryId, { ...open, lane: 'background', backgroundedAt: at });
349
+ return op.entryId;
350
+ }
351
+
352
+ // abandon-open: mark, never erase. An entry that never exited is the most useful thing this
353
+ // model can report, and a cleanup that deleted it would destroy exactly that signal.
354
+ for (const [entryId, entry] of entries) {
355
+ if (entry.abandonedAt !== null) continue;
356
+ entries.set(entryId, { ...entry, abandonedAt: at, abandonReason: op.reason });
357
+ }
358
+ return null;
359
+ }