dsh-pause-session 0.0.0-stage → 0.1.0

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.
package/lib/index.js ADDED
@@ -0,0 +1,2975 @@
1
+ import { z } from "zod";
2
+ import { SessionId, deriveEventMessage, foldRequestHeader, foldSurface, interruptedTurnClosers } from "@deepseek-ai/dsh-session";
3
+ import { CommandDefinitionId } from "@deepseek-ai/dsh-commands/brand";
4
+ import { applyChildComposition, foldSubagentDescriptor } from "@deepseek-ai/dsh-subagent";
5
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
6
+ import { DomainError, defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
7
+ //#region lib/types/history.js
8
+ /**
9
+ * The message history a session's log derives to, and the one rule that reads
10
+ * whether it still owes the model an answer.
11
+ *
12
+ * That question is asked twice and must have one answer: the clean wake asks it
13
+ * before it hands a session an input (an input nobody owes an answer to would
14
+ * open a turn of its own), and the decision list publishes it as the row's
15
+ * `wake` field. A published promise that the wake then declines to keep is the
16
+ * report lying, so both read the same derivation and the same rule.
17
+ *
18
+ * The derivation is the harness's own: the surface fold the loop reads its
19
+ * history through, with the same message projections this process has
20
+ * registered, and an interrupted tail closed exactly as the harness closes it
21
+ * on the next read. `resume.ts` reads the live form of it
22
+ * (`Session.deriveMessages()`); {@link derivedHistory} is the cold form over a
23
+ * stored log, which is all the decision list may claim — it opens a log to read
24
+ * it and never activates a session.
25
+ * @module dsh-pause-session/history
26
+ */
27
+ /**
28
+ * Whether one derived history ends owing the model an answer: the last thing
29
+ * said on the model-visible surface is not an assistant message.
30
+ * @param messages - a derived history, in model-visible order.
31
+ * @returns whether a model still owes that history a reply.
32
+ */
33
+ function owesReply(messages) {
34
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
35
+ const message = messages[index];
36
+ if (message === void 0 || message.role === "system" || message.role === "developer") continue;
37
+ return message.role !== "assistant";
38
+ }
39
+ return false;
40
+ }
41
+ /**
42
+ * The messages one stored log derives to, as the harness would read them on the
43
+ * session's next activation: the open tail turn is closed with the harness's
44
+ * own synthetic results first, then the log is folded into its surface.
45
+ * @param events - the stored events, exactly as they are stored and in sequence order.
46
+ * @param projections - this process's registered message projections.
47
+ * @returns the derived history, in model-visible order.
48
+ * @throws when the log cannot be folded — an event this build has no interpreter
49
+ * for, or surface metadata that does not apply to this history.
50
+ */
51
+ function derivedHistory(events, projections) {
52
+ const stored = [...events, ...interruptedTurnClosers(events)];
53
+ const fold = foldSurface(stored, projections);
54
+ return fold.nodes.flatMap((seq) => {
55
+ const event = stored[seq];
56
+ if (event === void 0) return [];
57
+ const message = deriveEventMessage(event, fold.projectedMessages);
58
+ return message === null ? [] : [message];
59
+ });
60
+ }
61
+ /**
62
+ * Whether one stored log names the route a wake would be addressed to. The wake
63
+ * needs a provider and a model to build a request at all, and the request
64
+ * header the session itself persisted is the only place that names them.
65
+ * @param events - the stored events, in sequence order.
66
+ * @returns whether the log's last request header carries both route fields.
67
+ */
68
+ function routeNamed(events) {
69
+ const config = foldRequestHeader(events)?.config;
70
+ return config !== void 0 && config.provider.length > 0 && config.model.length > 0;
71
+ }
72
+ //#endregion
73
+ //#region lib/types/shared.js
74
+ /**
75
+ * The identities both halves of dsh-pause-session name, the status encodings they
76
+ * share, and the one spelling a host log line gives a caught value.
77
+ *
78
+ * The browser bundle may value-import only the shell's baseline module table,
79
+ * so this module imports nothing: the client half reads command names and the
80
+ * decode functions from it directly, and the host half derives the branded
81
+ * identities from the same strings.
82
+ *
83
+ * A third-party plugin's only live channel to the browser is the command
84
+ * registry — it can add neither a `ctx.remote` namespace nor a forwarded event,
85
+ * both of which are compiled into the harness's own API assembly. A command
86
+ * descriptor's only free-form field is its `description`, and a command
87
+ * result's only free-form field is its `text`, so the park state travels as a
88
+ * prefix plus `;`-separated fields on one of those. The text stays
89
+ * human-readable: the same words are what the slash-command menu shows.
90
+ *
91
+ * Every value inside a field is escaped, so a decoded name is the exact name
92
+ * that was encoded. Session ids, tool names, and call ids are the host's own
93
+ * data and may contain the `;`, `,`, `/`, or newline a field is separated by; a
94
+ * folded spelling would decode as a different name — a different row, looked up
95
+ * again.
96
+ * @module dsh-pause-session/shared
97
+ */
98
+ /** Command that pauses the receiving session before its next model request. */
99
+ const PAUSE_COMMAND = "pause";
100
+ /** Command that resumes the receiving session; registered only while that session is paused. */
101
+ const RESUME_COMMAND = "pause-resume";
102
+ /** Command that resumes every parked session the registry remembers. */
103
+ const RESUME_ALL_COMMAND = "pause-resume-all";
104
+ /** Command that registers and parks every running session tree in one action. */
105
+ const PAUSE_ALL_COMMAND = "pause-all";
106
+ /** Command that reports the whole-app park state; registered globally, always. */
107
+ const STATE_ALL_COMMAND = "pause-state-all";
108
+ /** Command that reports the receiving session's park state; registered only while it is paused. */
109
+ const STATUS_COMMAND = "pause-state";
110
+ /** Command that turns the receiving session's own goal re-arm on or off; registered only while it is paused. */
111
+ const GOAL_COMMAND = "pause-goal";
112
+ /** Command that reports every session that did not stop cleanly. */
113
+ const TRIAGE_COMMAND = "pause-triage";
114
+ /** Command that resumes one session from the decision list exactly as the clean path would. */
115
+ const TRIAGE_CONTINUE_COMMAND = "pause-triage-continue";
116
+ /** Command that resumes one session from the decision list with the unknown-outcome note. */
117
+ const TRIAGE_NOTE_COMMAND = "pause-triage-note";
118
+ /** Command that opens one session from the decision list without spending a request. */
119
+ const TRIAGE_REVIEW_COMMAND = "pause-triage-review";
120
+ /** Command that leaves one session from the decision list exactly as it is. */
121
+ const TRIAGE_LEAVE_COMMAND = "pause-triage-leave";
122
+ /** Command that turns one listed session's goal re-arm on or off. */
123
+ const TRIAGE_GOAL_COMMAND = "pause-triage-goal";
124
+ /** Stable definition id of the pause command, matched by identity rather than by copy. */
125
+ const PAUSE_DEFINITION_ID = "dsh-pause-session/pause";
126
+ /** Stable definition id of the resume command: the host's own "this session is paused" fact. */
127
+ const RESUME_DEFINITION_ID = "dsh-pause-session/resume";
128
+ /** Stable definition id of the global resume command, matched by identity rather than by copy. */
129
+ const RESUME_ALL_DEFINITION_ID = "dsh-pause-session/resume-all";
130
+ /** Stable definition id of the whole-app pause command. */
131
+ const PAUSE_ALL_DEFINITION_ID = "dsh-pause-session/pause-all";
132
+ /** Stable definition id of the whole-app status read. */
133
+ const STATE_ALL_DEFINITION_ID = "dsh-pause-session/state-all";
134
+ /** Stable definition id of the status read, matched by identity rather than by copy. */
135
+ const STATUS_DEFINITION_ID = "dsh-pause-session/status";
136
+ /** Stable definition id of the paused session's own goal re-arm toggle. */
137
+ const GOAL_DEFINITION_ID = "dsh-pause-session/goal";
138
+ /** Stable definition id of the decision-list read. */
139
+ const TRIAGE_DEFINITION_ID = "dsh-pause-session/triage";
140
+ /** Stable definition id of the decision-list "continue" verb. */
141
+ const TRIAGE_CONTINUE_DEFINITION_ID = "dsh-pause-session/triage-continue";
142
+ /** Stable definition id of the decision-list "continue with the unknown-outcome note" verb. */
143
+ const TRIAGE_NOTE_DEFINITION_ID = "dsh-pause-session/triage-note";
144
+ /** Stable definition id of the decision-list "open without activating" verb. */
145
+ const TRIAGE_REVIEW_DEFINITION_ID = "dsh-pause-session/triage-review";
146
+ /** Stable definition id of the decision-list "leave it alone today" verb. */
147
+ const TRIAGE_LEAVE_DEFINITION_ID = "dsh-pause-session/triage-leave";
148
+ /** Stable definition id of the decision-list goal re-arm toggle. */
149
+ const TRIAGE_GOAL_DEFINITION_ID = "dsh-pause-session/triage-goal";
150
+ /** First token of every encoded status, so a descriptor is never parsed by accident. */
151
+ const STATUS_PREFIX = "pause: ";
152
+ /**
153
+ * One list field's text when the list is empty. A value that would itself spell
154
+ * this marker is escaped by {@link escapeField}, so an empty list and a list
155
+ * holding the marker stay different texts.
156
+ */
157
+ const EMPTY_FIELD = "-";
158
+ /**
159
+ * One field or item value, ready to sit inside an encoded text: every separator
160
+ * (`;`, `,`, `/`, newline) and the escape character itself are escaped, and a
161
+ * value that would spell {@link EMPTY_FIELD} is escaped so it is not read as an
162
+ * empty list.
163
+ * @param text - the value to encode, as the host holds it.
164
+ * @returns the value as it appears inside an encoded field.
165
+ */
166
+ function escapeField(text) {
167
+ const escaped = text.replace(/[\\;,\n/]/g, (character) => character === "\n" ? "\\n" : `\\${character}`);
168
+ return escaped.startsWith(EMPTY_FIELD) ? `\\${escaped}` : escaped;
169
+ }
170
+ /**
171
+ * One log line's text for a caught value.
172
+ * @param error - the caught value.
173
+ * @returns the error's message, or the value's own spelling.
174
+ */
175
+ function describeError(error) {
176
+ return error instanceof Error ? error.message : String(error);
177
+ }
178
+ /**
179
+ * The four park outcomes, listed in the order a park walks them — and the one
180
+ * vocabulary the live status, the durable registry, and the published words all
181
+ * derive from, so they cannot drift apart:
182
+ *
183
+ * - `draining`: paused, and the session is still working — a tool is running, or
184
+ * a turn is between steps.
185
+ * - `parking`: standing still at the park point; the session-level flush has
186
+ * not resolved yet.
187
+ * - `safe`: parked and flushed, so it is safe to walk away. The only clean one.
188
+ * - `flush-failed`: parked, but the flush failed — the durable promise is not
189
+ * kept, and it must never be read as clean.
190
+ */
191
+ const PARK_PHASES = [
192
+ "draining",
193
+ "parking",
194
+ "safe",
195
+ "flush-failed"
196
+ ];
197
+ /**
198
+ * How far along a phase is, worst first. The direction is not the order a park
199
+ * walks: it orders how much of the promise each outcome keeps, which is what a
200
+ * fold over several sessions has to compare. `safe` keeps all of it, `draining`
201
+ * and `parking` are on their way, `flush-failed` has stopped trying — a park
202
+ * that failed its flush is further from clean than one that is still working at
203
+ * it, so it must never be the outcome a report rounds down to.
204
+ */
205
+ const PHASE_SEVERITY = {
206
+ safe: 0,
207
+ draining: 1,
208
+ parking: 2,
209
+ "flush-failed": 3
210
+ };
211
+ /**
212
+ * Fold several records into the one verdict that speaks for all of them: the
213
+ * worst phase any of them reached, and whether every one of them is clean.
214
+ *
215
+ * This is how a session's control answers for its tree. "Safe to close" is a
216
+ * promise about everything the press registered, so it may be said only when
217
+ * every member reached the flushed park point; anything else is reported as the
218
+ * least-clean state in the set, never rounded up to the session's own.
219
+ * @param phases - the durable phases of the records being spoken for.
220
+ * @returns the worst phase, and whether that verdict is the clean one.
221
+ */
222
+ function foldTreePhases(phases) {
223
+ let worst = "safe";
224
+ for (const phase of phases) if (PHASE_SEVERITY[phase] > PHASE_SEVERITY[worst]) worst = phase;
225
+ return {
226
+ phase: worst,
227
+ safe: worst === "safe"
228
+ };
229
+ }
230
+ /** The one word each phase is published as, and the whole decoding vocabulary. */
231
+ const PHASE_WORD = {
232
+ draining: "pausing",
233
+ parking: "saving",
234
+ safe: "paused",
235
+ "flush-failed": "save failed"
236
+ };
237
+ /**
238
+ * The two states of one record's goal re-arm field, in the one spelling both
239
+ * the per-session status and the decision list publish it with.
240
+ */
241
+ const GOAL_ON = "on";
242
+ const GOAL_OFF = "off";
243
+ /**
244
+ * Encode one status for a command descriptor.
245
+ * @param status - the state to publish.
246
+ * @returns the descriptor text, e.g. `pause: draining; tools: edit, bash; goal: on`.
247
+ */
248
+ function encodeParkStatus(status) {
249
+ return `${`${STATUS_PREFIX}${PHASE_WORD[status.phase]}`}${status.runningTools.length === 0 ? "" : `; tools: ${status.runningTools.map(escapeField).join(", ")}`}${status.rearmGoal === void 0 ? "" : `; goal: ${status.rearmGoal ? GOAL_ON : GOAL_OFF}`}`;
250
+ }
251
+ /**
252
+ * First token of every encoded whole-app status. A different prefix from the
253
+ * per-session status on purpose: one decoder must never read the other's text.
254
+ */
255
+ const GLOBAL_STATUS_PREFIX = "pause all: ";
256
+ /** Encode one list field. */
257
+ function encodeNames(names) {
258
+ return names.length === 0 ? EMPTY_FIELD : names.map(escapeField).join(", ");
259
+ }
260
+ /**
261
+ * Encode one optional session id: the empty marker when the park names none.
262
+ * @param id - the session id, as the host holds it.
263
+ * @returns the id as it appears inside an encoded field.
264
+ */
265
+ function encodeId(id) {
266
+ return id === void 0 ? EMPTY_FIELD : escapeField(id);
267
+ }
268
+ /**
269
+ * Encode one whole-app status for a command result.
270
+ * @param status - the state to publish.
271
+ * @returns the result text, e.g.
272
+ * `pause all: waiting; registered: 3; safe: 1; parked-at: overnight; draining: overnight; parking: 1; stalled: -; unresumable: -; unread-trees: -; jobs: bash-1; blind: -`.
273
+ */
274
+ function encodeGlobalStatus(status) {
275
+ return `${GLOBAL_STATUS_PREFIX}${status.verdict}; registered: ${status.registered}; safe: ${status.safe}; parked-at: ${encodeId(status.parkedAt)}; draining: ${encodeNames(status.draining)}; parking: ${status.parking}; stalled: ${encodeNames(status.stalled)}; unresumable: ${encodeNames(status.unresumable)}; unread-trees: ${encodeNames(status.unreadTrees)}; jobs: ${encodeNames(status.jobs)}; blind: ${encodeNames(status.blind)}`;
276
+ }
277
+ /**
278
+ * First token of every encoded decision list. Separate from the per-session and
279
+ * whole-app status prefixes on purpose, for the same reason those two are
280
+ * separate: one decoder must never read another's text.
281
+ */
282
+ const TRIAGE_PREFIX = "pause triage: ";
283
+ /** How one row is spelled in the published list; the wake field's state when the host can hand over the owed request. */
284
+ const WAKE_YES = "yes";
285
+ /** One row's traces, as a list of `tool/callId` items. */
286
+ function encodeTraces(traces) {
287
+ if (traces.length === 0) return EMPTY_FIELD;
288
+ return traces.map((trace) => `${escapeField(trace.tool)}/${escapeField(trace.callId)}`).join(", ");
289
+ }
290
+ /** One row's stop position, encoded as `<last event>` plus the step it belongs to. */
291
+ function encodeStop(row) {
292
+ if (row.lastEvent === void 0) return EMPTY_FIELD;
293
+ const position = row.turn === void 0 ? "" : `/turn ${String(row.turn)}${row.step === void 0 ? "" : ` step ${String(row.step)}`}`;
294
+ return `${escapeField(row.lastEvent)}${position}`;
295
+ }
296
+ /**
297
+ * Encode one decision list for a command result.
298
+ * @param list - the rows to publish.
299
+ * @returns the result text, e.g. `pause triage: 1; row: overnight; phase: draining; stopped: tool/call/turn 1 step 1; tools: gated; wake: yes`.
300
+ */
301
+ function encodeTriageList(list) {
302
+ const head = `${TRIAGE_PREFIX}${String(list.rows.length)}`;
303
+ return list.rows.reduce((text, row) => {
304
+ const stopped = row.reading === "unreadable" ? "unreadable" : encodeStop(row);
305
+ return `${text}; row: ${escapeField(row.sessionId)}; phase: ${row.phase}; stopped: ${stopped}; traces: ${row.reading === "unreadable" ? EMPTY_FIELD : encodeTraces(row.traces)}; tools: ${row.reading === "unreadable" || row.traces.length === 0 ? EMPTY_FIELD : encodeNames(row.traces.map((trace) => trace.tool))}; owed: ${row.owed ? WAKE_YES : "no"}; wake: ${row.wakeable ? WAKE_YES : "no"}; goal: ${row.rearmGoal ? GOAL_ON : GOAL_OFF}`;
306
+ }, head);
307
+ }
308
+ //#endregion
309
+ //#region lib/types/coldlog.js
310
+ /**
311
+ * Reading one parked session's stored log through a bound, and the facts the
312
+ * morning list may claim from what it read.
313
+ *
314
+ * The decision list reads the same log the wake reads, but for the opposite
315
+ * reason: the wake has to reproduce the session, so it reads everything. The
316
+ * list only has to name where the session stopped, which tool call the log
317
+ * never heard back from, and whether an answer is still owed — all of them
318
+ * facts about the log's END. Reading from seq 0 to the end for every row, on
319
+ * every command-registry change while the panel is open, would make a long
320
+ * session's cost grow with a log nobody is asking about.
321
+ *
322
+ * WHAT THE BOUND BUYS. A stored log is append-only, so the tail is stable: the
323
+ * read takes the last {@link TRIAGE_WINDOW_EVENTS} events, or the whole log when
324
+ * it is shorter, and the answer is derived from that. The window is complete
325
+ * whenever the log fits inside it, which is the common case; the fold the rest
326
+ * of this plugin reads history through is then the harness's own, over exactly
327
+ * the events the harness would fold.
328
+ *
329
+ * WHAT THE TAIL ALONE PROVES. A truncated window still answers the list's
330
+ * questions, for one reason the harness guarantees: a dangling tool call exists
331
+ * only inside the step that is still open, because a step's end is written
332
+ * after its results. So an unanswered call is always inside the final open
333
+ * span, and the tail that contains that span contains every one of them. What
334
+ * the window cannot prove — a log a replacement event rewrote from further back
335
+ * — is not guessed at: {@link logFacts} reports `undefined` and the row says
336
+ * `unreadable`, because a list that invents a stop position is worse than a
337
+ * list that says it could not read one.
338
+ * @module dsh-pause-session/coldlog
339
+ */
340
+ /**
341
+ * Events the read looks at before it gives up. Sized to hold a whole final
342
+ * step — the model exchange, its tool calls, and their results — with room to
343
+ * spare, at a cost that does not grow with the session.
344
+ */
345
+ const TRIAGE_WINDOW_EVENTS = 2e3;
346
+ /**
347
+ * The longest log the read will walk while locating where its tail starts.
348
+ *
349
+ * The window's start is found by probing for the log's length, so a log longer
350
+ * than this cannot be located from its end at a cost worth paying for a panel
351
+ * left open. Such a log — a million stored events — is reported unreadable
352
+ * rather than read from the front, which is the read this module exists to
353
+ * avoid.
354
+ */
355
+ const TRIAGE_LOG_MAX_EVENTS = 1e6;
356
+ /**
357
+ * Events that are not the session's own story: the bookkeeping of the pause
358
+ * command the user pressed, and the pending-input splices the inbox writes.
359
+ * They are real and durable, but "it stopped right after `/pause` finished"
360
+ * is not what "where did it stop" means.
361
+ */
362
+ const BOOKKEEPING_EVENTS = /* @__PURE__ */ new Set([
363
+ "command/run",
364
+ "command/done",
365
+ "agent/inbox/spliced"
366
+ ]);
367
+ /**
368
+ * The turn and step an event belongs to, when it carries them.
369
+ * @param event - one stored event.
370
+ * @returns the position fields the event has, and nothing for the fields it does not.
371
+ */
372
+ function positionOf(event) {
373
+ const data = event.data;
374
+ if (typeof data !== "object" || data === null) return {};
375
+ const turn = "turn" in data && typeof data.turn === "number" ? data.turn : void 0;
376
+ const step = "step" in data && typeof data.step === "number" ? data.step : void 0;
377
+ return {
378
+ ...turn === void 0 ? {} : { turn },
379
+ ...step === void 0 ? {} : { step }
380
+ };
381
+ }
382
+ /**
383
+ * Every tool call the STORED events record and no stored result answers.
384
+ *
385
+ * Deliberately the stored prefix rather than the repaired view: what a row
386
+ * reports is the state of the session's own log, and the harness's synthetic
387
+ * unknown-outcome results are exactly what that log is missing. Taking the
388
+ * repaired view here would subtract the very traces the row exists to show.
389
+ *
390
+ * A call is answered only by a result the SAME window holds, which is what the
391
+ * harness's own repair rule reads too: an unanswered call lives in the step
392
+ * that is still open, so it is inside this window or it is answered.
393
+ * @param events - the stored events the read returned, in sequence order.
394
+ * @returns one trace per call with no result, in call order.
395
+ */
396
+ function unansweredCalls(events) {
397
+ const names = /* @__PURE__ */ new Map();
398
+ const answered = /* @__PURE__ */ new Set();
399
+ for (const event of events) {
400
+ if (event.type === "tool/call") {
401
+ names.set(String(event.data.callId), event.data.name);
402
+ continue;
403
+ }
404
+ if (event.type !== "tool/result") continue;
405
+ answered.add(String(event.data.message.toolCallId));
406
+ }
407
+ return [...names].flatMap(([callId, tool]) => answered.has(callId) ? [] : [{
408
+ tool,
409
+ callId
410
+ }]);
411
+ }
412
+ /**
413
+ * Whether a log ends owing the model an answer, read from the tail alone.
414
+ *
415
+ * This is the harness's own rule — the last thing on the model-visible surface
416
+ * is not an assistant message — read the way the harness reads it: the surface
417
+ * fold over the events at hand, then the message the last node derives to. The
418
+ * fold is handed a window rather than the whole log, so it refuses rather than
419
+ * guesses when it meets a replacement it cannot resolve; a log it cannot fold
420
+ * falls back to the last event that produces a message at all, which is the
421
+ * same answer by a narrower route.
422
+ * @param events - the stored events the read returned, in sequence order.
423
+ * @param traces - the unanswered calls, as the row reports them.
424
+ * @returns whether the session still owes the model a reply.
425
+ */
426
+ function tailOwesReply(events, traces) {
427
+ if (traces.length > 0) return true;
428
+ const bySequence = new Map(events.map((event) => [event.seq, event]));
429
+ const lastNode = () => {
430
+ for (const seq of [...foldSurface(events).nodes].reverse()) {
431
+ const event = bySequence.get(seq);
432
+ if (event !== void 0) return event;
433
+ }
434
+ };
435
+ let anchor;
436
+ try {
437
+ anchor = lastNode();
438
+ } catch {
439
+ for (let at = events.length - 1; at >= 0; at -= 1) {
440
+ const event = events[at];
441
+ if (event === void 0 || deriveEventMessage(event) === null) continue;
442
+ anchor = event;
443
+ break;
444
+ }
445
+ }
446
+ if (anchor === void 0) return false;
447
+ const message = deriveEventMessage(anchor);
448
+ if (message === null) return false;
449
+ if (message.role === "system" || message.role === "developer") return false;
450
+ return message.role !== "assistant";
451
+ }
452
+ /**
453
+ * The facts one bounded read of a stored log supports.
454
+ *
455
+ * A complete read is folded by the harness's own derivation, so the list and
456
+ * the wake answer the "does it still owe a reply" question through one rule.
457
+ * A truncated read answers from the tail, and only where the tail is proof: a
458
+ * replacement event that rewrote a surface range from outside the window is
459
+ * not visible here, so a log whose tail a later event could have rewritten is
460
+ * reported unreadable rather than guessed at.
461
+ * @param events - the stored events the read returned, in sequence order.
462
+ * @param complete - whether those events are the whole stored log.
463
+ * @param projections - this process's registered message projections.
464
+ * @returns the row's facts, or `undefined` when this read cannot support them.
465
+ */
466
+ function logFacts(events, complete, projections) {
467
+ let last;
468
+ for (let at = events.length - 1; at >= 0; at -= 1) {
469
+ const event = events[at];
470
+ if (event === void 0 || BOOKKEEPING_EVENTS.has(event.type)) continue;
471
+ last = event;
472
+ break;
473
+ }
474
+ const traces = unansweredCalls(events);
475
+ const wakeableRoute = routeNamed(events);
476
+ if (!complete) {
477
+ if (last === void 0) return void 0;
478
+ const owed = tailOwesReply(events, traces);
479
+ return {
480
+ last,
481
+ traces,
482
+ owed,
483
+ wakeable: owed && wakeableRoute
484
+ };
485
+ }
486
+ let owed;
487
+ try {
488
+ owed = owesReply(derivedHistory(events, projections));
489
+ } catch {
490
+ return;
491
+ }
492
+ return {
493
+ last,
494
+ traces,
495
+ owed,
496
+ wakeable: owed && wakeableRoute
497
+ };
498
+ }
499
+ /**
500
+ * Whether one handle holds at least `count` events, asked without materializing
501
+ * more than one of them.
502
+ * @param handle - an open read handle onto the log.
503
+ * @param count - the event count to test for.
504
+ * @param signal - cancellation for the probe.
505
+ * @returns whether the log reaches that far.
506
+ */
507
+ async function reaches(handle, count, signal) {
508
+ return (await handle.read(count, 1, { ...signal === void 0 ? {} : { signal } })).events.length > 0;
509
+ }
510
+ /**
511
+ * The stored log's own length, found by probing rather than by reading it.
512
+ *
513
+ * The handle's read seam is the only way to ask a backend anything, and it
514
+ * takes an offset — so the count has to be probed for. The probe doubles until
515
+ * it passes the end and then bisects, so a log of any length costs a handful
516
+ * of one-event reads, and none of them materializes the prefix it skips.
517
+ * @param handle - an open read handle onto the log.
518
+ * @param signal - cancellation for the probe.
519
+ * @returns the event count, or `undefined` when the log is longer than this read will walk.
520
+ */
521
+ async function eventCount(handle, signal) {
522
+ let high = 1;
523
+ while (await reaches(handle, high, signal)) {
524
+ if (high >= 1e6) return void 0;
525
+ high = Math.min(high * 2, TRIAGE_LOG_MAX_EVENTS);
526
+ }
527
+ let low = 0;
528
+ while (high - low > 1) {
529
+ const mid = low + Math.floor((high - low) / 2);
530
+ if (await reaches(handle, mid, signal)) low = mid;
531
+ else high = mid;
532
+ }
533
+ return low;
534
+ }
535
+ /**
536
+ * Read one session's stored log, bounded to the events the list's facts need.
537
+ *
538
+ * The window is the last `windowEvents` events, found by probing for the log's
539
+ * length and then reading exactly that slice. A short log is returned whole and
540
+ * marked complete; a log longer than {@link TRIAGE_LOG_MAX_EVENTS} is reported
541
+ * unreadable without being read at all.
542
+ * @param ctx - host context carrying the optional persistence service.
543
+ * @param sessionId - the stored session to read.
544
+ * @param windowEvents - how many events of the log's end one row may fold.
545
+ * @param signal - cancellation for the open, probe, and read.
546
+ * @returns the bounded read, or `unreadable` when no read seam exists, it refused, or the log is too long to walk.
547
+ * @throws the cancellation reason when `signal` aborts the read.
548
+ */
549
+ async function coldRead(ctx, sessionId, windowEvents = TRIAGE_WINDOW_EVENTS, signal) {
550
+ const persistence = ctx.get("sessionPersistence");
551
+ if (persistence === void 0) return { kind: "unreadable" };
552
+ let handle;
553
+ try {
554
+ handle = await persistence.open(sessionId, "read", { ...signal === void 0 ? {} : { signal } });
555
+ } catch (error) {
556
+ if (signal?.aborted === true) throw error;
557
+ ctx.logger.warn(`pause: could not open the stored log of "${sessionId}": ${describeError(error)}`);
558
+ return { kind: "unreadable" };
559
+ }
560
+ try {
561
+ const window = Math.max(1, Math.floor(windowEvents));
562
+ const count = await eventCount(handle, signal);
563
+ if (count === void 0) {
564
+ ctx.logger.warn(`pause: the stored log of "${sessionId}" is longer than this read will walk`);
565
+ return { kind: "unreadable" };
566
+ }
567
+ const start = Math.max(0, count - window);
568
+ return {
569
+ kind: "read",
570
+ events: (await handle.read(start, window, { ...signal === void 0 ? {} : { signal } })).events,
571
+ complete: start === 0
572
+ };
573
+ } catch (error) {
574
+ if (signal?.aborted === true) throw error;
575
+ ctx.logger.warn(`pause: could not read the stored log of "${sessionId}": ${describeError(error)}`);
576
+ return { kind: "unreadable" };
577
+ } finally {
578
+ await handle.close();
579
+ }
580
+ }
581
+ //#endregion
582
+ //#region lib/types/composition.js
583
+ /**
584
+ * Rebuild the scoped Agent composition a parked Session ran under.
585
+ *
586
+ * Activation is not only a persisted Session: the Agent preset supplies the
587
+ * scoped tools, prompt sections, skills, and — for a subagent child — the
588
+ * delegation context, persona, and tool restriction that were in force before
589
+ * the park. A resume that loads the log without that setup starts a different
590
+ * Agent on the same history.
591
+ *
592
+ * The preset a Session runs is the `agentPreset` projection, never the
593
+ * creation-time header alone: a blank Session may select another preset before
594
+ * its first turn, and the projection records the selection that actually ran.
595
+ * A child with no independent selection joins its live parent's preset
596
+ * generation; one that selected independently mounts the projected preset.
597
+ * The durable child descriptor supplies the persona, tool filter, and route
598
+ * that are reapplied only on cold resume.
599
+ *
600
+ * Both services are optional. A profile without `agentPresets` has no scoped
601
+ * preset composition to restore, and this module leaves the resume unchanged.
602
+ * A profile that mounts one but cannot read the Session projection cannot
603
+ * prove what composition the Session ran, so it fails the resume instead of
604
+ * silently starting a different Agent.
605
+ * @module dsh-pause-session/composition
606
+ */
607
+ /**
608
+ * A stored subagent route that no resume may reactivate.
609
+ *
610
+ * A one-shot child is terminal by contract; activating it as a root Agent
611
+ * would turn a finished delegation into an ordinary conversation that happens
612
+ * to share its Session id.
613
+ */
614
+ var UnresumableSessionError = class extends Error {
615
+ constructor(sessionId) {
616
+ super(`subagent session "${String(sessionId)}" has a one-shot route and cannot be resumed`);
617
+ this.name = "UnresumableSessionError";
618
+ }
619
+ };
620
+ /**
621
+ * Whether the child's own log selected a preset before its descriptor.
622
+ *
623
+ * The creation-time selection precedes the first `subagent/descriptor`; a
624
+ * selection after the descriptor belongs to the child's own later history and
625
+ * does not decide which composition cold resume joins. This mirrors the
626
+ * subagent runtime's own cold-resume rule.
627
+ * @param events - the child's own log suffix, without the fork-inherited prefix.
628
+ * @returns whether the child carries an independent preset selection.
629
+ */
630
+ function hasIndependentPresetSelection(events) {
631
+ for (const event of events) {
632
+ if (event.type === "subagent/descriptor") return false;
633
+ if (event.type === "agent-preset/selected") return true;
634
+ }
635
+ return false;
636
+ }
637
+ /**
638
+ * Whether a local Session may be parked under a promise of a later resume.
639
+ *
640
+ * A normal root Session has no child descriptor and is resumable. A local
641
+ * subagent child is resumable only when its own durable descriptor is
642
+ * `continuable`; a historical `one-shot` child is terminal and must never be
643
+ * registered as a park the morning path would later have to refuse. External
644
+ * executions have no local Session, so callers never reach this check.
645
+ *
646
+ * A profile without the optional Session-query service cannot prove the
647
+ * descriptor's mode; it keeps the previous permissive behavior rather than
648
+ * dropping every subagent. The web profile mounts the query service.
649
+ * @param ctx - host context carrying the optional Session-query service.
650
+ * @param sessionId - the local Session to inspect.
651
+ * @param signal - cancellation for the observation read.
652
+ * @returns whether a park of this Session may be resumed.
653
+ * @throws the cancellation reason when `signal` aborts the read.
654
+ */
655
+ async function isResumableSession(ctx, sessionId, signal) {
656
+ const query = ctx.get("sessionQuery");
657
+ if (query === void 0) return true;
658
+ const observation = await query.observeSession(sessionId, { ...signal === void 0 ? {} : { signal } });
659
+ try {
660
+ return foldSubagentDescriptor(observation.events.slice(observation.inheritedEventCount))?.mode !== "one-shot";
661
+ } finally {
662
+ observation[Symbol.dispose]();
663
+ }
664
+ }
665
+ /**
666
+ * Read one parked Session's stored composition and build the resume options.
667
+ *
668
+ * The returned `setup` belongs to the unpublished Agent scope, so all
669
+ * registrations exist before `agent/created`. For a root Session the setup
670
+ * mounts the Session's projected preset, or the profile's default when the
671
+ * Session never selected one. For a child under a live parent it applies the
672
+ * child composition: an independent preset mounts separately, while an
673
+ * inherited composition joins the parent's current generation; the historical
674
+ * descriptor's persona and tool filter are reapplied in both cases.
675
+ * @param ctx - host context carrying the optional preset and Session-query services.
676
+ * @param sessionId - the parked Session being resumed.
677
+ * @param parent - the live direct parent, when a child is resumed under it.
678
+ * @param signal - cancellation for the observation read.
679
+ * @returns the Agent options and setup to pass to `agents.resume`.
680
+ * @throws {UnresumableSessionError} when the stored child route is one-shot.
681
+ * @throws when a mounted preset roster cannot read the Session projection, or
682
+ * a recorded preset cannot be restored.
683
+ */
684
+ async function restoreComposition(ctx, sessionId, parent, signal) {
685
+ const presets = ctx.get("agentPresets");
686
+ const query = ctx.get("sessionQuery");
687
+ if (query === void 0) {
688
+ if (presets !== void 0) throw new Error("resuming a composed Agent requires the session-query service");
689
+ return {};
690
+ }
691
+ const observation = await query.observeSession(sessionId, { ...signal === void 0 ? {} : { signal } });
692
+ try {
693
+ const projections = observation.projections;
694
+ const ownEvents = observation.events.slice(observation.inheritedEventCount);
695
+ const descriptor = foldSubagentDescriptor(ownEvents);
696
+ if (descriptor?.mode === "one-shot") throw new UnresumableSessionError(sessionId);
697
+ if (presets === void 0 || projections === void 0) {
698
+ if (presets !== void 0) throw new Error("resuming a composed Agent requires projected Session state");
699
+ return {};
700
+ }
701
+ if (parent === void 0) {
702
+ const resolved = await presets.resolve(projections.values.agentPreset ?? void 0);
703
+ return { setup: async (agentCtx) => {
704
+ await presets.mount(agentCtx, resolved.id);
705
+ } };
706
+ }
707
+ const independentlySelected = hasIndependentPresetSelection(ownEvents);
708
+ const presetId = independentlySelected ? projections.values.agentPreset : void 0;
709
+ if (independentlySelected && typeof presetId !== "string") throw new Error(`subagent "${String(sessionId)}" has no projected agent preset to restore`);
710
+ const composition = {
711
+ ...typeof presetId === "string" ? { agentPreset: presetId } : {},
712
+ ...descriptor?.mode === "continuable" && descriptor.persona !== void 0 ? { persona: descriptor.persona } : {},
713
+ ...descriptor?.mode === "continuable" && descriptor.toolFilter !== void 0 ? { toolFilter: descriptor.toolFilter } : {}
714
+ };
715
+ const agentOptions = descriptor?.mode === "continuable" ? {
716
+ ...descriptor.agentProvider === void 0 ? {} : { provider: descriptor.agentProvider },
717
+ ...descriptor.agentModel === void 0 ? {} : { model: descriptor.agentModel },
718
+ ...descriptor.agentReasoningEffort === void 0 ? {} : { reasoningEffort: descriptor.agentReasoningEffort }
719
+ } : void 0;
720
+ return {
721
+ ...agentOptions === void 0 ? {} : { agentOptions },
722
+ setup: async (agentCtx) => {
723
+ await applyChildComposition(agentCtx, parent, composition);
724
+ }
725
+ };
726
+ } finally {
727
+ observation[Symbol.dispose]();
728
+ }
729
+ }
730
+ //#endregion
731
+ //#region lib/types/tree.js
732
+ /**
733
+ * One session's subagent tree, read from the parent catalogs.
734
+ *
735
+ * "This session's work" includes the subagents it delegated to, nested ones
736
+ * included, so the session-header press and the status the header shows both
737
+ * need the same answer to "which sessions hang under this one".
738
+ * `listDescendants` is the harness's own recursive walk of the parent-owned
739
+ * catalogs, and it is the only source: the tree is read from the parents that
740
+ * record it, never guessed from session ids.
741
+ *
742
+ * A walk is read, not remembered. The tree is a fact that grows — a delegation
743
+ * in flight publishes its child after the parent's catalog was read — so a
744
+ * press walks the tree at press time and a status read walks it at read time.
745
+ * `@module dsh-pause-session/tree`
746
+ */
747
+ /**
748
+ * Read the subagent tree under one session.
749
+ *
750
+ * A failure to read a branch is reported as unread rather than as an empty
751
+ * tree: the difference is what keeps a press from claiming it registered a tree
752
+ * it never saw, and keeps a status answer from calling itself safe behind an
753
+ * unknown branch.
754
+ * @param ctx - host context carrying the optional subagent service.
755
+ * @param rootSessionId - the session whose descendants are requested.
756
+ * @param signal - cancellation for the catalog walk.
757
+ * @returns the descendants found and what of the walk could not be read.
758
+ * @throws the cancellation reason when `signal` aborts the walk.
759
+ */
760
+ async function walkDescendants(ctx, rootSessionId, signal) {
761
+ const subagents = ctx.get("subagents");
762
+ if (subagents === void 0) return {
763
+ members: [],
764
+ unread: [],
765
+ unreadWhole: false
766
+ };
767
+ try {
768
+ const rows = await subagents.listDescendants(rootSessionId, signal);
769
+ const members = [];
770
+ const unread = [];
771
+ for (const row of rows) {
772
+ if (row.kind === "child") {
773
+ members.push({
774
+ id: row.id,
775
+ mode: row.mode
776
+ });
777
+ continue;
778
+ }
779
+ unread.push({
780
+ id: row.id,
781
+ reason: row.reason
782
+ });
783
+ }
784
+ return {
785
+ members,
786
+ unread,
787
+ unreadWhole: false
788
+ };
789
+ } catch (error) {
790
+ if (signal?.aborted === true) throw error;
791
+ ctx.logger.warn(`pause: could not read the subagent tree of "${rootSessionId}": ${describeError(error)}`);
792
+ return {
793
+ members: [],
794
+ unread: [],
795
+ unreadWhole: true
796
+ };
797
+ }
798
+ }
799
+ //#endregion
800
+ //#region lib/types/global.js
801
+ /**
802
+ * The whole-app park: one action that registers every running session tree,
803
+ * and the aggregate answer to "can I close the lid?".
804
+ *
805
+ * The per-session control owns one session's park; this module owns the set.
806
+ * It reads three real sources and never infers what they can say themselves:
807
+ *
808
+ * - `ctx.agents.list()` plus the park activity fold decide which sessions are
809
+ * running. A session with no live agent is not running, and a session with
810
+ * nothing unfinished — no tool in flight, no turn open, no delivery the loop
811
+ * has not claimed — is left alone.
812
+ * - The subagent runtime's descendant rows decide the tree and the one-shot
813
+ * fact. A row's `mode` is the harness's own durable record of whether that
814
+ * child can be resumed after a restart; a branch the walk could not read is
815
+ * reported as unread rather than assumed clean.
816
+ * - The job registry decides which background jobs belong to the registered
817
+ * trees. Those run outside the agent loop: this plugin cannot stop them, so
818
+ * it names them.
819
+ *
820
+ * The aggregate is derived in one place so the host's answer and the browser's
821
+ * reading of it cannot disagree, and it is a promise about the durable
822
+ * registry: a session is `safe` only when its recorded phase says so.
823
+ * @module dsh-pause-session/global
824
+ */
825
+ /**
826
+ * How many times one press walks the trees again. A tree can grow while the
827
+ * walk runs — a delegation already in flight publishes its child after the
828
+ * parent's catalog was read — so the walk repeats until it stops finding a
829
+ * running session this action has not pressed yet.
830
+ */
831
+ const PRESS_ROUNDS = 4;
832
+ /** Fold one shared tree walk into the two doubts this report publishes. */
833
+ function readingOf(walk) {
834
+ return {
835
+ members: walk.members.map((member) => member.id),
836
+ unresumable: walk.members.some((member) => member.mode !== "continuable") || walk.unread.some((branch) => branch.reason === "unsupported"),
837
+ unread: walk.unreadWhole || walk.unread.some((branch) => branch.reason !== "unsupported")
838
+ };
839
+ }
840
+ /**
841
+ * Mount the whole-app park.
842
+ * @param ctx - host context carrying the agent store and the optional subagent
843
+ * and job services.
844
+ * @param watch - the activity fold that says whether a session has unfinished work.
845
+ * @param registry - the durable list this aggregate is a promise about.
846
+ * @param deps - the per-session press and the live phase of every mark.
847
+ * @returns the one-press action and the aggregate read.
848
+ */
849
+ function mountGlobalPause(ctx, watch, registry, deps) {
850
+ /**
851
+ * Sessions this action tried to press and could not record. They are NOT
852
+ * parked and they keep running, so the aggregate counts them and names them
853
+ * instead of quietly leaving them out of a park it then calls safe.
854
+ */
855
+ const unrecorded = /* @__PURE__ */ new Set();
856
+ /** Record or clear one session's "pressed and the registry refused" state. */
857
+ const markUnrecorded = (sessionId, flag) => {
858
+ if (flag) unrecorded.add(sessionId);
859
+ else unrecorded.delete(sessionId);
860
+ };
861
+ /** Per-episode tree walks; an entry lives exactly as long as the park owns it. */
862
+ const walks = /* @__PURE__ */ new Map();
863
+ /**
864
+ * Whether a session has unfinished work right now. Three real answers, any
865
+ * of which is enough: the loop says it is driving the session, the activity
866
+ * fold sees a tool in flight or a turn open, or the inbox holds a delivery
867
+ * the loop has not claimed yet.
868
+ */
869
+ const running = (agent) => {
870
+ if (agent.status === "running") return true;
871
+ const activity = watch.activity(agent.id);
872
+ if (activity.turnOpen || activity.runningTools.length > 0) return true;
873
+ return agent.inbox.nextTurn.length + agent.inbox.nextStep.length > 0;
874
+ };
875
+ /**
876
+ * Read one session's subagent tree through the shared walker and remember the
877
+ * reading for this park episode. The tree is a fact about the episode, not
878
+ * about the moment it was asked, and the aggregate reads several sessions per
879
+ * answer; the cache keeps that read from repeating a catalog round-trip it
880
+ * has already paid for.
881
+ */
882
+ const walkTree = async (sessionId, signal) => {
883
+ signal?.throwIfAborted();
884
+ const cached = walks.get(sessionId);
885
+ if (cached !== void 0) return cached;
886
+ const reading = ctx.get("subagents") === void 0 ? {
887
+ members: [],
888
+ unresumable: false,
889
+ unread: true
890
+ } : readingOf(await walkDescendants(ctx, sessionId, signal));
891
+ walks.set(sessionId, reading);
892
+ return reading;
893
+ };
894
+ /**
895
+ * Every running session and every descendant of one, minus the sessions this
896
+ * pause already owns. Descendants are walked whether or not they are live
897
+ * right now: the tree is the unit the user asked to stop. A member this host
898
+ * has no live session for has nothing to hold and nothing to flush, and the
899
+ * press leaves it alone rather than record a park that could never finish
900
+ * (see `./control.ts`).
901
+ */
902
+ const runningTargets = async (signal) => {
903
+ const found = /* @__PURE__ */ new Set();
904
+ for (const agent of ctx.agents.list()) if (running(agent)) found.add(agent.id);
905
+ for (const root of [...found]) for (const member of (await walkTree(root, signal)).members) found.add(member);
906
+ return [...found].filter((sessionId) => !deps.isPaused(sessionId));
907
+ };
908
+ /**
909
+ * Live jobs of the registered trees, plus the unowned ones every caller sees.
910
+ *
911
+ * One registry walk per owner is the whole answer the jobs contract allows:
912
+ * `list()` with no caller answers with unowned jobs only, and `list(owner)`
913
+ * answers for exactly one owner, because the registry preflights ownership
914
+ * instead of letting a reader enumerate another session's work.
915
+ */
916
+ const liveJobs = (owners) => {
917
+ const jobs = ctx.get("jobs");
918
+ if (jobs === void 0) return [];
919
+ const seen = /* @__PURE__ */ new Set();
920
+ const collect = (owner) => {
921
+ for (const view of owner === void 0 ? jobs.list() : jobs.list(owner)) {
922
+ if (view.status !== "running" && view.status !== "stopping") continue;
923
+ seen.add(String(view.id));
924
+ }
925
+ };
926
+ collect();
927
+ for (const owner of owners) collect(owner);
928
+ return [...seen];
929
+ };
930
+ /**
931
+ * The aggregate. Every registered session is accounted for: the ones on
932
+ * their way, the ones that will not get there in this process, the trees
933
+ * that hold work a restart cannot resume, and the jobs nothing here can stop.
934
+ * The park also names the session it belongs to, so a client can address its
935
+ * reads there rather than at whatever session it is showing.
936
+ */
937
+ const status = async (signal) => {
938
+ const phases = /* @__PURE__ */ new Map();
939
+ for (const [sessionId, record] of registry.parked) phases.set(sessionId, {
940
+ phase: record.phase,
941
+ moving: false
942
+ });
943
+ const live = deps.live();
944
+ for (const [sessionId, park] of live) phases.set(sessionId, park);
945
+ for (const sessionId of unrecorded) phases.set(sessionId, {
946
+ phase: "draining",
947
+ moving: false
948
+ });
949
+ const unheld = deps.unheld();
950
+ for (const sessionId of [...walks.keys()]) if (!phases.has(sessionId)) walks.delete(sessionId);
951
+ let safe = 0;
952
+ let parking = 0;
953
+ const draining = [];
954
+ const stalled = [];
955
+ for (const [sessionId, park] of phases) {
956
+ if (unheld.has(sessionId)) {
957
+ stalled.push(String(sessionId));
958
+ continue;
959
+ }
960
+ if (park.phase === "safe") {
961
+ safe += 1;
962
+ continue;
963
+ }
964
+ (park.moving ? draining : stalled).push(String(sessionId));
965
+ if (park.moving && park.phase === "parking") parking += 1;
966
+ }
967
+ const unresumable = [];
968
+ const unreadTrees = [];
969
+ const owners = [...phases.keys()];
970
+ const parkedAt = owners.find((sessionId) => !unheld.has(sessionId)) ?? owners[0];
971
+ const trees = await Promise.all(owners.map(async (sessionId) => [sessionId, await walkTree(sessionId, signal)]));
972
+ for (const [sessionId, tree] of trees) {
973
+ if (tree.unresumable) unresumable.push(String(sessionId));
974
+ if (tree.unread) unreadTrees.push(String(sessionId));
975
+ }
976
+ const blind = [];
977
+ if (ctx.get("subagents") === void 0) blind.push("trees");
978
+ if (ctx.get("jobs") === void 0) blind.push("jobs");
979
+ const registered = phases.size;
980
+ return {
981
+ verdict: registered === 0 ? "idle" : safe === registered && unreadTrees.length === 0 ? "safe" : "waiting",
982
+ registered,
983
+ safe,
984
+ ...parkedAt === void 0 ? {} : { parkedAt: String(parkedAt) },
985
+ draining: draining.sort(),
986
+ parking,
987
+ stalled: stalled.sort(),
988
+ unresumable: unresumable.sort(),
989
+ unreadTrees: unreadTrees.sort(),
990
+ jobs: liveJobs(owners),
991
+ blind
992
+ };
993
+ };
994
+ return {
995
+ pressAll: async (signal) => {
996
+ for (let round = 0; round < PRESS_ROUNDS; round += 1) {
997
+ const targets = await runningTargets(signal);
998
+ if (targets.length === 0) break;
999
+ for (const sessionId of targets) {
1000
+ signal?.throwIfAborted();
1001
+ switch (await deps.press(sessionId, signal)) {
1002
+ case "registered":
1003
+ markUnrecorded(sessionId, false);
1004
+ deps.look(sessionId);
1005
+ break;
1006
+ case "unrecordable":
1007
+ markUnrecorded(sessionId, true);
1008
+ break;
1009
+ default: break;
1010
+ }
1011
+ }
1012
+ }
1013
+ const answer = encodeGlobalStatus(await status(signal));
1014
+ return unrecorded.size === 0 ? {
1015
+ kind: "success",
1016
+ text: answer
1017
+ } : {
1018
+ kind: "error",
1019
+ text: answer
1020
+ };
1021
+ },
1022
+ readStatus: async (signal) => ({
1023
+ kind: "success",
1024
+ text: encodeGlobalStatus(await status(signal))
1025
+ }),
1026
+ markUnrecorded
1027
+ };
1028
+ }
1029
+ //#endregion
1030
+ //#region lib/types/resume.js
1031
+ /**
1032
+ * The clean resume path: bring back a session a previous process parked, and
1033
+ * let it answer the input that was already pending when it stopped.
1034
+ *
1035
+ * A resume invents no turn. At the park point the loop had claimed the
1036
+ * session's pending input onto the surface and built the request the gate then
1037
+ * held; after a restart that input is still the last thing the session says and
1038
+ * the model still owes it an answer. What is missing is only a reason for the
1039
+ * loop to build a request again, and the documented way to ask for one is a
1040
+ * wake input — a user message the loop claims.
1041
+ *
1042
+ * The wake is invisible only on the routes the composition declares in
1043
+ * `silentWakeRoutes`, whose conversion drops an empty user message. On every
1044
+ * other route the loop's request carries one visible marker instead, and the
1045
+ * marker reaches the model; that is an accepted cost of resuming at all.
1046
+ * Whether an empty user message survives is a property of the route's
1047
+ * conversion rather than of the loop, so the route is resolved per session —
1048
+ * from the request header the session itself persisted.
1049
+ *
1050
+ * The wake message is attributed to this plugin, never to the user: it is text
1051
+ * this module synthesized on the session's behalf, in the session's own log and
1052
+ * in front of the model, and a downstream consumer has to be able to tell it
1053
+ * apart from something the human typed.
1054
+ *
1055
+ * Only a cleanly parked session takes this path. A record that never reached
1056
+ * the park point, or whose flush did not finish, describes work whose durable
1057
+ * state nobody knows; waking it would spend its owner's money on a guess, and
1058
+ * deciding those sessions is the morning list's job, not this path's. That
1059
+ * holds whether or not the recorded session happens to be live in this process:
1060
+ * liveness is not the park outcome, and a session can be live without ever
1061
+ * having stopped.
1062
+ * @module dsh-pause-session/resume
1063
+ */
1064
+ /**
1065
+ * The one wake content a route that keeps an empty user message is given. It
1066
+ * names its producer, because it lands in the same user turn as the input the
1067
+ * model owes an answer to and must not read as part of it.
1068
+ */
1069
+ const WAKE_MARKER = "[pause: resumed from a pause]";
1070
+ /** Result copy for a resumed session; the model never sees it. */
1071
+ const RESUMED_TEXT = "This session resumed from where it paused.";
1072
+ /**
1073
+ * Result copy for a registered session this path refuses: its durable state is
1074
+ * not the outcome of a completed park, so waking it would be a guess.
1075
+ */
1076
+ const UNCLEAN_TEXT = "pause did not resume this session: its pause did not finish cleanly, so it needs your decision.";
1077
+ /** Result copy when nothing is parked under that session. */
1078
+ const NOT_PARKED_TEXT = "pause has no pause recorded for this session.";
1079
+ /** Result copy for a session that is parked but could not be loaded back. */
1080
+ const ACTIVATION_FAILED_TEXT = "pause could not reopen this session, so it stayed paused.";
1081
+ /** Result copy for a one-shot subagent route: its Session exists, but no resume may reactivate it. */
1082
+ const UNRESUMABLE_TEXT = "pause did not resume this session: it is a one-shot subagent route and cannot be resumed.";
1083
+ /**
1084
+ * Result copy for the review decision. It is the one answer that promises
1085
+ * nothing happened, because opening a session to read it is the user's own
1086
+ * action in their session list — and activating its agent here would start a
1087
+ * session they asked only to look at.
1088
+ */
1089
+ const REVIEW_TEXT = "pause did not activate this session: it is still parked, and nothing was sent to a model. Open it from your session list when you want to read it.";
1090
+ /** Result copy for a change the durable registry refused to record. */
1091
+ const UNRECORDED_TEXT$1 = "pause could not write the pause registry, so it did not change the pause state.";
1092
+ /** Copy for one goal opt-out change. */
1093
+ const REARM_GOAL_ON_TEXT = "pause will re-open this session's goal when it resumes it.";
1094
+ const REARM_GOAL_OFF_TEXT = "pause will leave this session's goal closed when it resumes it.";
1095
+ /**
1096
+ * How one tree-wide release answers the person who pressed it. One release, one
1097
+ * sentence about all of it: what came back, and — by name, because those rows
1098
+ * are what the morning list is for — what did not.
1099
+ * @param released - the sessions this release covered, in release order.
1100
+ * @returns the result text, e.g. `pause resumed 2 sessions from where they stopped; "child" did not pause completely`.
1101
+ */
1102
+ function summarizeTreeRelease(released) {
1103
+ const resumed = released.filter((attempt) => attempt.resumed).length;
1104
+ const head = resumed === 0 ? "pause resumed nothing from this tree" : resumed === 1 ? "pause resumed 1 session from where it stopped" : `pause resumed ${resumed} sessions from where they stopped`;
1105
+ const refused = released.filter((attempt) => !attempt.resumed);
1106
+ if (refused.length === 0) return `${head}.`;
1107
+ const named = refused.filter((attempt) => attempt.refusal === "not-clean").map((attempt) => `"${String(attempt.sessionId)}"`);
1108
+ return `${head}; ${named.length === 0 ? `${String(refused.length)} could not be resumed` : refused.length === named.length ? `${named.join(", ")} did not pause completely` : `${named.join(", ")} did not pause completely, and ${String(refused.length - named.length)} more could not be resumed`}.`;
1109
+ }
1110
+ /**
1111
+ * Build the clean resume path.
1112
+ * @param ctx - host context carrying the agent store, the session store, and the goal service when one is mounted.
1113
+ * @param gate - the pause gate the resume releases.
1114
+ * @param registry - the durable list this path reads and forgets from.
1115
+ * @param options - the route knowledge the wake content depends on.
1116
+ * @param deps - the control-owned release order and the adopted-pause fact.
1117
+ * @returns the path the session control exposes.
1118
+ */
1119
+ function installResumePath(ctx, gate, registry, options, deps) {
1120
+ /** The route the session's next request will use, as its own log recorded it. */
1121
+ const routeOf = (session) => session.requestHeader()?.config;
1122
+ /**
1123
+ * Give an agent that was loaded back without one the route its own log
1124
+ * recorded. The loop refuses to build a request without a provider and model,
1125
+ * and the persisted header is what the app's own activation path uses too, so
1126
+ * this resumes onto the route the session stopped on.
1127
+ *
1128
+ * 0.2.2 replaced the installed route selection with the durable model-selection
1129
+ * fold: `agent.model.current()` already returns a route replayed from the
1130
+ * Session's own request headers, and `select()` records the header's route when
1131
+ * that fold and the Agent options are both empty. The old per-agent
1132
+ * `installModelSelection(agentCtx, selection)` no longer accepts a selection.
1133
+ */
1134
+ const ensureRoute = (agent) => {
1135
+ if (agent.model.current() !== void 0) return;
1136
+ const config = routeOf(agent.session);
1137
+ if (config === void 0) return;
1138
+ agent.model.select({
1139
+ provider: config.provider,
1140
+ model: config.model,
1141
+ ...config.reasoningEffort === void 0 ? {} : { reasoningEffort: config.reasoningEffort }
1142
+ });
1143
+ };
1144
+ /** What one wake input carries for the route in use. */
1145
+ const wakeContent = (provider) => options.silentWakeRoutes.includes(provider) ? [] : [{
1146
+ type: "text",
1147
+ text: WAKE_MARKER
1148
+ }];
1149
+ /**
1150
+ * Ask the loop for the request it owes this session. Nothing is woken when a
1151
+ * request is already standing at the gate or the loop is already running: the
1152
+ * continuation is in flight, and a second wake would duplicate it. The content
1153
+ * is the route's business only for the silent wake; a note is the user's
1154
+ * decision and goes on the wire as written, on every route. Either way the
1155
+ * message is attributed to this plugin: the wake is this path speaking on the
1156
+ * session's behalf, not the user's own input.
1157
+ * @param agent - the live agent to wake.
1158
+ * @param request - which wake this resume delivers.
1159
+ */
1160
+ const wake = (agent, request) => {
1161
+ if (request.kind === "none") return;
1162
+ if (gate.isHeld(agent.id) || agent.status !== "idle") return;
1163
+ if (!owesReply(agent.session.deriveMessages())) return;
1164
+ const provider = routeOf(agent.session)?.provider;
1165
+ if (provider === void 0) return;
1166
+ const content = request.kind === "message" ? [{
1167
+ type: "text",
1168
+ text: request.text
1169
+ }] : wakeContent(provider);
1170
+ agent.followup(createUserMessage({
1171
+ content,
1172
+ source: { kind: "pause" }
1173
+ }));
1174
+ };
1175
+ /**
1176
+ * Re-open one session's goal. Activation is process-local and is not part of
1177
+ * the durable goal, so a restart leaves a healthy goal disarmed; only that
1178
+ * case is repaired here. A goal the owner paused, blocked, or completed is a
1179
+ * decision, and this path does not take decisions back. The wake (when there
1180
+ * is one) runs first on purpose: the goal round driver queues the next round
1181
+ * as soon as the agent is idle, and that round must not join the request that
1182
+ * is answering the input already pending.
1183
+ * @param agent - the agent whose goal may be re-armed.
1184
+ */
1185
+ const rearmGoal = (agent) => {
1186
+ const goals = ctx.get("goals");
1187
+ if (goals === void 0) return;
1188
+ try {
1189
+ const goal = goals.get(agent);
1190
+ if (goal === void 0 || goal.phase !== "active" || goal.activation !== "disarmed") return;
1191
+ if (goal.roundsStarted >= goal.maxGoalRounds) return;
1192
+ goals.resume(agent, {
1193
+ id: goal.id,
1194
+ revision: goal.revision
1195
+ });
1196
+ } catch (error) {
1197
+ ctx.logger.warn(`pause: could not re-open the goal of "${agent.id}": ${describeError(error)}`);
1198
+ }
1199
+ };
1200
+ /**
1201
+ * Load one parked session back from its stored log and hand it the request it
1202
+ * stopped in front of. The pause is not released until the durable record is
1203
+ * gone, so a record that cannot be dropped leaves the session parked rather
1204
+ * than silently running.
1205
+ *
1206
+ * Activation is what makes a session run again, so nothing but a resume does
1207
+ * it: the review answer never reaches this function.
1208
+ * @param sessionId - the parked session to bring back.
1209
+ * @param request - which wake the decision delivers; see {@link WakeRequest}.
1210
+ * @param rearm - whether the record's goal opt-out allows re-opening its goal.
1211
+ * @param parentId - the direct parent a batch release already brought back,
1212
+ * when there is one. A subagent child resumed under its live parent is the
1213
+ * shape the harness's own cold resume uses; with no live parent the session
1214
+ * comes back on its own, which is all that can be done with it.
1215
+ * @param signal - cancellation for the stored composition read and load.
1216
+ */
1217
+ const activate = async (sessionId, request, rearm, parentId, signal) => {
1218
+ const parent = parentId === void 0 ? void 0 : ctx.agents.get(parentId);
1219
+ let agent;
1220
+ try {
1221
+ const composition = await restoreComposition(ctx, sessionId, parent, signal);
1222
+ agent = (await ctx.root.agents.resume({
1223
+ resumeSessionId: sessionId,
1224
+ ...parent === void 0 ? {} : { parentAgent: parent },
1225
+ ...composition,
1226
+ ...signal === void 0 ? {} : { signal }
1227
+ })).agent;
1228
+ } catch (error) {
1229
+ if (error instanceof UnresumableSessionError) return {
1230
+ sessionId,
1231
+ resumed: false,
1232
+ refusal: "unresumable",
1233
+ answer: {
1234
+ kind: "error",
1235
+ text: UNRESUMABLE_TEXT
1236
+ }
1237
+ };
1238
+ ctx.logger.warn(`pause: could not open "${sessionId}" from its stored log: ${describeError(error)}`);
1239
+ return {
1240
+ sessionId,
1241
+ resumed: false,
1242
+ refusal: "activation-failed",
1243
+ answer: {
1244
+ kind: "error",
1245
+ text: ACTIVATION_FAILED_TEXT
1246
+ }
1247
+ };
1248
+ }
1249
+ ensureRoute(agent);
1250
+ wake(agent, request);
1251
+ try {
1252
+ await registry.forget(sessionId);
1253
+ } catch (error) {
1254
+ ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
1255
+ return {
1256
+ sessionId,
1257
+ resumed: false,
1258
+ refusal: "unrecorded",
1259
+ answer: {
1260
+ kind: "error",
1261
+ text: UNRECORDED_TEXT$1
1262
+ }
1263
+ };
1264
+ }
1265
+ gate.resume(sessionId);
1266
+ if (rearm) rearmGoal(agent);
1267
+ return {
1268
+ sessionId,
1269
+ resumed: true,
1270
+ answer: {
1271
+ kind: "success",
1272
+ text: RESUMED_TEXT
1273
+ }
1274
+ };
1275
+ };
1276
+ /**
1277
+ * Load one unclean session back from its stored log.
1278
+ *
1279
+ * This is the morning list's own entry point, and it exists precisely because
1280
+ * the clean path must keep refusing these records: the refusal is the clean
1281
+ * path's guarantee, not a guard this entry can borrow. A user who has read the
1282
+ * row and chosen one of the decisions that resume it has made the decision the
1283
+ * clean path is not allowed to make for them. The release order is the same
1284
+ * one: the durable record goes first, then the gate — so a record that cannot
1285
+ * be dropped leaves the session parked.
1286
+ *
1287
+ * A record this process holds no gate for is still this list's to clear: the
1288
+ * session is live, so the decision's own wake is what asks for its request,
1289
+ * and dropping the record is what stops a later process from re-holding a
1290
+ * session the user has already decided.
1291
+ * @param sessionId - the listed session to bring back.
1292
+ * @param request - which wake the decision delivers; see {@link WakeRequest}.
1293
+ * @param finalize - whether the decision continues the session (`'release'`)
1294
+ * or only answers it (`'review'`); a review activates nothing and writes
1295
+ * nothing.
1296
+ * @param parentId - the direct parent a batch release already brought back, when there is one.
1297
+ * @param signal - cancellation for the cold load and setup.
1298
+ * @returns what happened, and the copy for whoever asked.
1299
+ */
1300
+ const decideUnclean = async (sessionId, request, finalize, parentId, signal) => {
1301
+ const record = registry.get(sessionId);
1302
+ if (record === void 0) return {
1303
+ sessionId,
1304
+ resumed: false,
1305
+ refusal: "not-parked",
1306
+ answer: {
1307
+ kind: "success",
1308
+ text: NOT_PARKED_TEXT
1309
+ }
1310
+ };
1311
+ if (finalize === "review") return {
1312
+ sessionId,
1313
+ resumed: false,
1314
+ refusal: "not-activated",
1315
+ answer: {
1316
+ kind: "success",
1317
+ text: REVIEW_TEXT
1318
+ }
1319
+ };
1320
+ const live = ctx.agents.get(sessionId);
1321
+ if (live === void 0) return await activate(sessionId, request, record.rearmGoal, parentId, signal);
1322
+ if (!gate.isPaused(sessionId)) {
1323
+ ensureRoute(live);
1324
+ wake(live, request);
1325
+ try {
1326
+ await registry.forget(sessionId);
1327
+ } catch (error) {
1328
+ ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
1329
+ return {
1330
+ sessionId,
1331
+ resumed: false,
1332
+ refusal: "unrecorded",
1333
+ answer: {
1334
+ kind: "error",
1335
+ text: UNRECORDED_TEXT$1
1336
+ }
1337
+ };
1338
+ }
1339
+ if (record.rearmGoal) rearmGoal(live);
1340
+ return {
1341
+ sessionId,
1342
+ resumed: true,
1343
+ answer: {
1344
+ kind: "success",
1345
+ text: RESUMED_TEXT
1346
+ }
1347
+ };
1348
+ }
1349
+ const adopted = deps.adopted(sessionId);
1350
+ if (adopted) {
1351
+ ensureRoute(live);
1352
+ wake(live, request);
1353
+ }
1354
+ const released = await deps.release(sessionId, RESUMED_TEXT);
1355
+ if (released.kind === "error") return {
1356
+ sessionId,
1357
+ resumed: false,
1358
+ refusal: "unrecorded",
1359
+ answer: released
1360
+ };
1361
+ if (adopted && record.rearmGoal) rearmGoal(live);
1362
+ return {
1363
+ sessionId,
1364
+ resumed: true,
1365
+ answer: released
1366
+ };
1367
+ };
1368
+ /**
1369
+ * Resume one recorded session: the clean path's only entry point.
1370
+ *
1371
+ * The clean-only rule holds on every branch, live or not: a record that is
1372
+ * not `safe` is not a park that finished, so this path neither wakes it nor
1373
+ * drops it — whoever holds it stays held, and the row reaches the morning
1374
+ * list. A pause this process pressed itself is not a record at all: taking it
1375
+ * back is the press's own undo, and it delivers no wake.
1376
+ * @param sessionId - the recorded session to resume.
1377
+ * @param request - which wake this resume delivers; see {@link WakeRequest}.
1378
+ * @param parentId - the direct parent a batch release already brought back, when there is one.
1379
+ * @param signal - cancellation for the cold load and setup.
1380
+ * @returns what happened, and the copy for whoever asked.
1381
+ */
1382
+ const resumeSession = async (sessionId, request, parentId, signal) => {
1383
+ const record = registry.get(sessionId);
1384
+ const live = ctx.agents.get(sessionId);
1385
+ if (live !== void 0) {
1386
+ if (!gate.isPaused(sessionId)) {
1387
+ if (record !== void 0 && record.phase !== "safe") return {
1388
+ sessionId,
1389
+ resumed: false,
1390
+ refusal: "not-clean",
1391
+ answer: {
1392
+ kind: "error",
1393
+ text: UNCLEAN_TEXT
1394
+ }
1395
+ };
1396
+ if (record === void 0) return {
1397
+ sessionId,
1398
+ resumed: false,
1399
+ refusal: "not-parked",
1400
+ answer: {
1401
+ kind: "success",
1402
+ text: NOT_PARKED_TEXT
1403
+ }
1404
+ };
1405
+ try {
1406
+ await registry.forget(sessionId);
1407
+ } catch (error) {
1408
+ ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
1409
+ return {
1410
+ sessionId,
1411
+ resumed: false,
1412
+ refusal: "unrecorded",
1413
+ answer: {
1414
+ kind: "error",
1415
+ text: UNRECORDED_TEXT$1
1416
+ }
1417
+ };
1418
+ }
1419
+ ensureRoute(live);
1420
+ wake(live, request);
1421
+ if (record.rearmGoal) rearmGoal(live);
1422
+ return {
1423
+ sessionId,
1424
+ resumed: true,
1425
+ answer: {
1426
+ kind: "success",
1427
+ text: RESUMED_TEXT
1428
+ }
1429
+ };
1430
+ }
1431
+ const adopted = deps.adopted(sessionId);
1432
+ if (adopted && record !== void 0 && record.phase !== "safe") return {
1433
+ sessionId,
1434
+ resumed: false,
1435
+ refusal: "not-clean",
1436
+ answer: {
1437
+ kind: "error",
1438
+ text: UNCLEAN_TEXT
1439
+ }
1440
+ };
1441
+ if (adopted) {
1442
+ ensureRoute(live);
1443
+ wake(live, request);
1444
+ }
1445
+ const released = await deps.release(sessionId, RESUMED_TEXT);
1446
+ if (released.kind === "error") return {
1447
+ sessionId,
1448
+ resumed: false,
1449
+ refusal: "unrecorded",
1450
+ answer: released
1451
+ };
1452
+ if (adopted && (record?.rearmGoal ?? true)) rearmGoal(live);
1453
+ return {
1454
+ sessionId,
1455
+ resumed: true,
1456
+ answer: released
1457
+ };
1458
+ }
1459
+ if (record === void 0) return {
1460
+ sessionId,
1461
+ resumed: false,
1462
+ refusal: "not-parked",
1463
+ answer: {
1464
+ kind: "success",
1465
+ text: NOT_PARKED_TEXT
1466
+ }
1467
+ };
1468
+ if (record.phase !== "safe") return {
1469
+ sessionId,
1470
+ resumed: false,
1471
+ refusal: "not-clean",
1472
+ answer: {
1473
+ kind: "error",
1474
+ text: UNCLEAN_TEXT
1475
+ }
1476
+ };
1477
+ return await activate(sessionId, request, record.rearmGoal, parentId, signal);
1478
+ };
1479
+ /**
1480
+ * Order the recorded sessions so a session is released after the parent it
1481
+ * hangs from: waking a child needs its direct parent to be live first. The
1482
+ * relation is read from the parent catalogs themselves (which children this
1483
+ * session actually has), never guessed from ids; a branch whose catalog
1484
+ * cannot be read simply has no known parent and is released with the roots.
1485
+ * @param sessionIds - every recorded session, in registry order.
1486
+ * @param signal - cancellation for each catalog read.
1487
+ * @returns the parent of each child, and one list per depth level, shallowest first.
1488
+ */
1489
+ const releaseOrder = async (sessionIds, signal) => {
1490
+ const owned = new Set(sessionIds);
1491
+ const parentOf = /* @__PURE__ */ new Map();
1492
+ const subagents = ctx.get("subagents");
1493
+ if (subagents !== void 0) for (const sessionId of sessionIds) {
1494
+ signal?.throwIfAborted();
1495
+ try {
1496
+ for (const child of await subagents.listChildren(sessionId, signal)) if (owned.has(child.id)) parentOf.set(child.id, sessionId);
1497
+ } catch (error) {
1498
+ if (signal?.aborted === true) throw error;
1499
+ ctx.logger.warn(`pause: could not read the children of "${sessionId}": ${describeError(error)}`);
1500
+ }
1501
+ }
1502
+ const depthOf = (sessionId) => {
1503
+ let steps = 0;
1504
+ const seen = /* @__PURE__ */ new Set([sessionId]);
1505
+ for (let at = parentOf.get(sessionId); at !== void 0; at = parentOf.get(at)) {
1506
+ if (seen.has(at)) break;
1507
+ seen.add(at);
1508
+ steps += 1;
1509
+ }
1510
+ return steps;
1511
+ };
1512
+ const levels = /* @__PURE__ */ new Map();
1513
+ for (const sessionId of sessionIds) {
1514
+ const level = depthOf(sessionId);
1515
+ const bucket = levels.get(level);
1516
+ if (bucket === void 0) levels.set(level, [sessionId]);
1517
+ else bucket.push(sessionId);
1518
+ }
1519
+ return {
1520
+ parents: parentOf,
1521
+ levels: [...levels.keys()].sort((left, right) => left - right).map((level) => levels.get(level) ?? [])
1522
+ };
1523
+ };
1524
+ return {
1525
+ resumeSession,
1526
+ decideUnclean,
1527
+ releaseTree: async (sessionId, signal) => {
1528
+ const registered = [sessionId, ...(await walkDescendants(ctx, sessionId, signal)).members.map((member) => member.id)].filter((member) => registry.get(member) !== void 0);
1529
+ if (registered.length === 0) return {
1530
+ members: 0,
1531
+ resumed: 0,
1532
+ refusals: [],
1533
+ answer: {
1534
+ kind: "success",
1535
+ text: NOT_PARKED_TEXT
1536
+ }
1537
+ };
1538
+ const cancelling = registered.every((member) => registry.get(member)?.phase !== "safe");
1539
+ const released = [];
1540
+ if (cancelling) for (const member of registered) {
1541
+ signal?.throwIfAborted();
1542
+ const answer = await deps.release(member, RESUMED_TEXT);
1543
+ released.push(answer.kind === "error" ? {
1544
+ sessionId: member,
1545
+ resumed: false,
1546
+ refusal: "unrecorded",
1547
+ answer
1548
+ } : {
1549
+ sessionId: member,
1550
+ resumed: true,
1551
+ answer
1552
+ });
1553
+ }
1554
+ else {
1555
+ const release = await releaseOrder(registered, signal);
1556
+ for (const level of release.levels) {
1557
+ signal?.throwIfAborted();
1558
+ for (const member of level) {
1559
+ const record = registry.get(member);
1560
+ if (record !== void 0 && record.phase !== "safe") {
1561
+ released.push({
1562
+ sessionId: member,
1563
+ resumed: false,
1564
+ refusal: "not-clean",
1565
+ answer: {
1566
+ kind: "error",
1567
+ text: UNCLEAN_TEXT
1568
+ }
1569
+ });
1570
+ continue;
1571
+ }
1572
+ released.push(await resumeSession(member, { kind: "silent" }, release.parents.get(member), signal));
1573
+ }
1574
+ }
1575
+ }
1576
+ const root = released.find((attempt) => attempt.sessionId === sessionId);
1577
+ const refusals = released.filter((attempt) => !attempt.resumed && attempt.refusal !== void 0).map((attempt) => ({
1578
+ sessionId: attempt.sessionId,
1579
+ refusal: attempt.refusal
1580
+ }));
1581
+ return {
1582
+ members: released.length,
1583
+ resumed: released.filter((attempt) => attempt.resumed).length,
1584
+ refusals,
1585
+ answer: root === void 0 ? {
1586
+ kind: "success",
1587
+ text: NOT_PARKED_TEXT
1588
+ } : {
1589
+ kind: refusals.length === 0 && root.resumed ? "success" : "error",
1590
+ text: summarizeTreeRelease(released)
1591
+ }
1592
+ };
1593
+ },
1594
+ resumeParked: async (signal) => {
1595
+ const release = await releaseOrder(registry.list().map((entry) => entry.sessionId), signal);
1596
+ const attempts = [];
1597
+ for (const level of release.levels) {
1598
+ signal?.throwIfAborted();
1599
+ attempts.push(...await Promise.all(level.map((sessionId) => resumeSession(sessionId, { kind: "silent" }, release.parents.get(sessionId), signal))));
1600
+ }
1601
+ return attempts;
1602
+ },
1603
+ setRearmGoal: async (sessionId, rearmGoal) => {
1604
+ try {
1605
+ await registry.setRearmGoal(sessionId, rearmGoal);
1606
+ } catch (error) {
1607
+ ctx.logger.warn(`pause: could not record the goal opt-out of "${sessionId}": ${describeError(error)}`);
1608
+ return {
1609
+ kind: "error",
1610
+ text: UNRECORDED_TEXT$1
1611
+ };
1612
+ }
1613
+ return {
1614
+ kind: "success",
1615
+ text: rearmGoal ? REARM_GOAL_ON_TEXT : REARM_GOAL_OFF_TEXT
1616
+ };
1617
+ }
1618
+ };
1619
+ }
1620
+ //#endregion
1621
+ //#region lib/types/triage.js
1622
+ /**
1623
+ * The morning decision list: every session that did not stop cleanly, the facts
1624
+ * its own log carries about where it stopped, and the listed verbs the user can
1625
+ * take on it.
1626
+ *
1627
+ * A session gets here by NOT being on the clean path. A record whose phase is
1628
+ * `safe` is woken silently by `./resume.ts` and never appears here; the other
1629
+ * three phases describe work nobody can vouch for, and guessing on them would
1630
+ * spend the owner's money on a guess. This module is the other half of that
1631
+ * rule, not a second copy of it: the reading is taken from the session's own
1632
+ * stored log through a read handle that claims no ownership and writes nothing,
1633
+ * so asking what happened cannot itself change what happened.
1634
+ *
1635
+ * WHAT ONE READ COSTS. The list is read again on every command-registry change
1636
+ * while the panel is open, and what it publishes is about the end of a log that
1637
+ * only grows, so `./coldlog.ts` bounds each row's read to that end. A row whose
1638
+ * facts the bound cannot hold reports itself unreadable rather than publishing
1639
+ * a fragment.
1640
+ *
1641
+ * WHAT A ROW REPORTS. "Where it stopped" is the last event of the stored log
1642
+ * with the turn and step it belongs to. "Which tool was in flight" is the
1643
+ * `tool/call` events that no durable `tool/result` answers — a call event is
1644
+ * only written once the loop reached the dispatch, so an unanswered one is
1645
+ * exactly a tool the real world ran and the log never heard back from. The
1646
+ * harness turns each of those into a synthetic `TOOL_OUTCOME_UNKNOWN` error
1647
+ * result when it next reads the log; this module derives the same answer from
1648
+ * the stored events instead of writing it back. Nothing here is inferred from
1649
+ * the fact that the session is listed.
1650
+ *
1651
+ * WHAT THE ROW PROMISES. `wake` says the host can hand this session the request
1652
+ * it owes, and the wake that keeps that promise is `./resume.ts`'s. Both read
1653
+ * one rule from `./history.ts` over the same derivation the loop reads, so a
1654
+ * promise this list publishes is a promise that wake keeps. When the log cannot
1655
+ * be folded into that derivation, the promise is withheld and the reading is
1656
+ * reported; a withheld promise costs a user nothing, a broken one spends their
1657
+ * money.
1658
+ *
1659
+ * WHO MAY DECIDE. A decision names its target as command input, and the command
1660
+ * channel carries no origin of its own, so this list acts only on rows its most
1661
+ * recent read published to the calling session: the caller's own read is what
1662
+ * makes a target actionable. Anything else is refused, not looked up.
1663
+ *
1664
+ * TWO OF THE FIVE VERBS PUT THE SESSION BACK ON THE CLEAN PATH. Continue and
1665
+ * continue-with-the-note go through `ResumePath.decideUnclean`, which activates
1666
+ * the session, wakes it and drops the durable record before the gate opens.
1667
+ *
1668
+ * THE OTHER THREE TOUCH NOTHING. Leave writes nothing at all: the record stays,
1669
+ * the session stays held, and the row is still on tomorrow's list. Review
1670
+ * answers without activating anything or writing anything — opening a session
1671
+ * to read it is the user's own action in their session history, and starting an
1672
+ * agent on their behalf is precisely what they declined. Goal flips the row's
1673
+ * own re-arm opt-out: it is the user path for the one thing a resume can spend
1674
+ * without the user watching it, and it takes effect on whatever resume comes
1675
+ * next. All five carry the same binding — the row must be one this caller was
1676
+ * shown — because each changes, or declines to change, durable state of a
1677
+ * parked session.
1678
+ * @module dsh-pause-session/triage
1679
+ */
1680
+ /** Result copy for leaving one listed session alone. */
1681
+ const TRIAGE_LEFT_TEXT = "pause left this session paused; it is still on the list.";
1682
+ /** Result copy for a decision aimed at a session that is not on the list. */
1683
+ const TRIAGE_NOT_LISTED_TEXT = "pause has no unfinished pause for this session.";
1684
+ /**
1685
+ * Result copy for a decision aimed at a row the calling session was not shown.
1686
+ * The list is the only thing that legitimizes a target, so a target nobody read
1687
+ * is refused rather than acted on.
1688
+ */
1689
+ const TRIAGE_UNSHOWN_TEXT = "pause did not act: this Session has not been shown that row. Read the list first (/pause-triage).";
1690
+ /**
1691
+ * Model-facing text that goes with a wake when the user chooses to continue
1692
+ * with a note.
1693
+ *
1694
+ * It is written the way the harness writes its own recovery text: the same two
1695
+ * facts the synthetic tool result states (the call's outcome was never recorded;
1696
+ * verify before retrying anything with side effects), in the same voice, with no
1697
+ * mention of the stop mechanism. The harness's own version lives in the tool
1698
+ * result for that call, which is why this one does not repeat the call's name —
1699
+ * it warns about the step as a whole, and the specific result sits directly
1700
+ * above it in the history.
1701
+ */
1702
+ const UNKNOWN_OUTCOME_NOTE = "A tool call from this step was interrupted: its call was recorded but no result was durably recorded, so its outcome is unknown. If it had side effects, verify the external state before doing anything else, and do not retry it blindly. Then continue with what I asked for.";
1703
+ /**
1704
+ * One row's facts, as the session's own log reports them.
1705
+ * @param ctx - host context carrying the registered message projections.
1706
+ * @param entry - the durable park record this row is built from.
1707
+ * @param events - the events the bounded read returned, in sequence order.
1708
+ * @param complete - whether those events are the whole stored log.
1709
+ * @returns the row, ready to publish, or `undefined` when the read cannot support it.
1710
+ */
1711
+ function rowOf(ctx, entry, events, complete) {
1712
+ const { sessionId, phase, updatedAt, rearmGoal } = entry;
1713
+ const facts = logFacts(events, complete, ctx.sessions.messageProjections);
1714
+ if (facts === void 0) return void 0;
1715
+ const { last, traces, owed, wakeable } = facts;
1716
+ return {
1717
+ sessionId: String(sessionId),
1718
+ phase,
1719
+ updatedAt,
1720
+ reading: "read",
1721
+ ...last === void 0 ? {} : {
1722
+ lastEvent: last.type,
1723
+ ...positionOf(last)
1724
+ },
1725
+ traces,
1726
+ owed,
1727
+ rearmGoal,
1728
+ wakeable
1729
+ };
1730
+ }
1731
+ /**
1732
+ * Mount the decision list over the durable registry and the clean resume path.
1733
+ *
1734
+ * The list is the registry's own complement: it asks for every record whose
1735
+ * phase is not `safe`, so a session that cleaned up is off the list by the same
1736
+ * fact that puts it on the silent path, and nothing has to be reconciled.
1737
+ * @param ctx - host context carrying the optional persistence service.
1738
+ * @param registry - the durable list of park outcomes.
1739
+ * @param resumePath - the path all three resuming decisions go through.
1740
+ * @param windowEvents - how many events of each stored log one read may fold.
1741
+ * @returns the list read and the decision verbs.
1742
+ */
1743
+ function mountTriageList(ctx, registry, resumePath, windowEvents = TRIAGE_WINDOW_EVENTS) {
1744
+ /**
1745
+ * The rows each calling session was last shown by its own read. A decision
1746
+ * names its target as command input, and the command channel carries no
1747
+ * origin of its own, so this is what ties a target to the list the user read:
1748
+ * only a row this caller has been shown is acted on.
1749
+ */
1750
+ const shown = /* @__PURE__ */ new Map();
1751
+ const unreadable = (entry) => ({
1752
+ sessionId: String(entry.sessionId),
1753
+ phase: entry.phase,
1754
+ updatedAt: entry.updatedAt,
1755
+ reading: "unreadable",
1756
+ traces: [],
1757
+ owed: false,
1758
+ wakeable: false,
1759
+ rearmGoal: entry.rearmGoal
1760
+ });
1761
+ const readable = async (entry, signal) => {
1762
+ const read = await coldRead(ctx, entry.sessionId, windowEvents, signal);
1763
+ if (read.kind === "unreadable") return unreadable(entry);
1764
+ return rowOf(ctx, entry, read.events, read.complete) ?? unreadable(entry);
1765
+ };
1766
+ /**
1767
+ * One decision, as the resume path's own verbs plus the two local verbs that
1768
+ * change nothing but the record. Review is the list's own "do not run it"
1769
+ * answer: it activates nothing and writes nothing, so the park is exactly as
1770
+ * it was. The goal toggle is the user path for the one thing a resume can
1771
+ * spend unasked.
1772
+ */
1773
+ const decide = async (caller, sessionId, action, signal) => {
1774
+ if (!(shown.get(caller)?.has(String(sessionId)) ?? false)) return {
1775
+ kind: "error",
1776
+ text: TRIAGE_UNSHOWN_TEXT
1777
+ };
1778
+ const record = registry.get(sessionId);
1779
+ if (record === void 0 || record.phase === "safe") return {
1780
+ kind: "error",
1781
+ text: TRIAGE_NOT_LISTED_TEXT
1782
+ };
1783
+ if (action === "leave") return {
1784
+ kind: "success",
1785
+ text: TRIAGE_LEFT_TEXT
1786
+ };
1787
+ if (action === "goal") return await resumePath.setRearmGoal(sessionId, !record.rearmGoal);
1788
+ if (action === "review") return (await resumePath.decideUnclean(sessionId, { kind: "none" }, "review", void 0, signal)).answer;
1789
+ const wake = action === "continue" ? { kind: "silent" } : {
1790
+ kind: "message",
1791
+ text: UNKNOWN_OUTCOME_NOTE
1792
+ };
1793
+ return (await resumePath.decideUnclean(sessionId, wake, "release", void 0, signal)).answer;
1794
+ };
1795
+ return {
1796
+ read: async (caller, signal) => {
1797
+ signal?.throwIfAborted();
1798
+ const rows = await Promise.all([...registry.parked.values()].flatMap((entry) => entry.phase === "safe" ? [] : [readable({
1799
+ ...entry,
1800
+ phase: entry.phase
1801
+ }, signal)]));
1802
+ shown.set(caller, new Set(rows.map((row) => row.sessionId)));
1803
+ return { rows };
1804
+ },
1805
+ decide
1806
+ };
1807
+ }
1808
+ //#endregion
1809
+ //#region lib/types/control.js
1810
+ /**
1811
+ * The session control the browser half drives: the pause verb, the park state
1812
+ * machine behind it, and the live state channel that accompanies both.
1813
+ *
1814
+ * A third-party plugin can add neither a `ctx.remote` namespace nor a forwarded
1815
+ * event: the Client's Remote namespaces and the forwarded-event allowlist are
1816
+ * compiled into the harness's own API assembly. The documented Client-callable
1817
+ * Host entry point is therefore a session command run through
1818
+ * `ctx.remote.commands.execute`, and the one live channel such a plugin can
1819
+ * publish through is the command registry itself — `commands/change` is a
1820
+ * forwarded event, and `commands.list(sessionId)` is a Client-callable read.
1821
+ *
1822
+ * That same registry is how a phase change leaves this process: a transition
1823
+ * re-registers the status verb, which emits the change a client needs to read
1824
+ * the new phase. It is the only way a third-party plugin can announce anything,
1825
+ * and it is what makes the flush that resolves `parking` into `safe` observable
1826
+ * at all — that step changes no registration of its own.
1827
+ *
1828
+ * So the resume definition IS the paused marker: it is registered on a paused
1829
+ * session's own agent context and removed on resume, and the browser half reads
1830
+ * "paused" from that registration rather than inventing it. A descriptor cannot
1831
+ * change after registration, so the park state rides a companion status
1832
+ * definition (registered beside the marker) whose answer is derived per call
1833
+ * rather than frozen into a descriptor — see `./shared.ts`. That one read
1834
+ * carries the record's goal opt-out too, so the session-header control can show
1835
+ * and switch it while the session is parked and clean — the state in which a
1836
+ * silent morning resume would otherwise spend the user's rounds unasked. The
1837
+ * switch is addressed to the session it changes, so it needs no listed row.
1838
+ *
1839
+ * The header control is the user's button for ONE session's work, and that work
1840
+ * includes the subagents under it: the press registers the session and its
1841
+ * descendant tree, the release takes the same tree back, and the status the
1842
+ * header shows is the worst phase among the members the press registered. The
1843
+ * whole-app control covers every running tree and answers for all of them; this
1844
+ * one answers for the tree it owns.
1845
+ *
1846
+ * A mark is not a park. A session is parked only once nothing is left to drain:
1847
+ * no call in flight AND either its next model request is held at the gate or no
1848
+ * turn is open at all (there will be no next request to hold). Only then is the
1849
+ * session flushed, at the session level — `SessionHandle.flush()` does not
1850
+ * drain the routed live buffer, so it would record a park that a crash erases.
1851
+ * The "safe to close" phase appears only after that flush resolves.
1852
+ *
1853
+ * Every phase a pause reaches is written to the durable registry before it is
1854
+ * published — including the move into `parking`, which the status command may
1855
+ * therefore never serve ahead of the record. A restart re-holds exactly the
1856
+ * sessions the last process left parked, and a phase that could not be recorded
1857
+ * is never shown as cleaner than the record says.
1858
+ *
1859
+ * A park verb changes state, so it acts only on a person's command: a command
1860
+ * dispatched from inside a model turn is refused. The listed verbs add the
1861
+ * one binding their target needs — the row must be one the calling session was
1862
+ * shown — and that rule lives with the list (`./triage.ts`).
1863
+ * @module dsh-pause-session/control
1864
+ */
1865
+ /**
1866
+ * Result copy for a session that just reached the pause point; the model never
1867
+ * sees it. The count is the point: the header control is the user's button for
1868
+ * one session's work, and that work includes the subagents under it, so the
1869
+ * press must not read as having stopped a single thing.
1870
+ * @param agents - how many subagents this press registered alongside the session.
1871
+ * @returns the copy for the person who pressed.
1872
+ */
1873
+ function pausedText(agents) {
1874
+ return `Paused ${agents === 0 ? "this session" : agents === 1 ? "this session and 1 subagent" : `this session and ${String(agents)} subagents`}. A running tool will finish first; no new model request will be sent. Press again to resume.`;
1875
+ }
1876
+ /** Result copy for a press or a release the durable registry refused to record. */
1877
+ const UNRECORDED_TEXT = "pause could not write the pause registry, so it did not change the pause state.";
1878
+ /**
1879
+ * What a tree-wide press reports when it could not read the tree. The session
1880
+ * itself is paused and says so; its subagents are neither registered nor
1881
+ * vouched for, and the answer names that limit rather than letting the session's
1882
+ * own park stand for a tree nobody read.
1883
+ */
1884
+ const TREE_UNREAD_TEXT = "pause could not read this session's subagent list, so it did not pause its subagents.";
1885
+ /** Result copy for a session whose own subagent route is terminal. */
1886
+ const NOT_RESUMABLE_PARK_TEXT = "pause did not register this session: its subagent route is one-shot and cannot be resumed.";
1887
+ /**
1888
+ * Result copy for a tree-wide press that could not record part of the tree.
1889
+ * The members it names are still running; the ones it does not are paused.
1890
+ * @param names - the members whose park record could not be written.
1891
+ * @returns the copy for the person who pressed.
1892
+ */
1893
+ function unrecordedTreeText(names) {
1894
+ return `pause could not save the pause for ${names.map((name) => `"${String(name)}"`).join(", ")}; they are still running. Everything else in the tree is paused.`;
1895
+ }
1896
+ /**
1897
+ * Result copy for a state-changing park verb issued from inside a model turn.
1898
+ * The invocation carries no origin of its own, so this refusal is the plugin's
1899
+ * own reading of the one signal that separates a person's command from a
1900
+ * model's.
1901
+ */
1902
+ const UNATTENDED_TEXT = "pause did not act: this command came from inside a model turn, and the park verbs act only on a person's command.";
1903
+ /**
1904
+ * Whether a command is being dispatched from inside a model turn.
1905
+ *
1906
+ * A person's slash command reaches the registry through the Client's command
1907
+ * Remote with no model turn around it; the loop runs every model-driven step —
1908
+ * tool calls included — under its own initiator boundary. The invocation itself
1909
+ * carries no origin: `command/run` records a constant `{ kind: 'user' }` source
1910
+ * for every executor caller. This is therefore the one signal available, and a
1911
+ * state-changing verb refuses when it says a model is driving.
1912
+ * @param ctx - host context carrying the agent store.
1913
+ * @returns whether a model turn is the initiator of this call.
1914
+ */
1915
+ function modelIssued(ctx) {
1916
+ try {
1917
+ return ctx.agents.currentInitiator() !== void 0;
1918
+ } catch {
1919
+ return true;
1920
+ }
1921
+ }
1922
+ /**
1923
+ * Whether this host can read one recorded session at all — a live session, or a
1924
+ * stored log the persistence backend can observe.
1925
+ * @param ctx - host context carrying the session store and the optional persistence service.
1926
+ * @param sessionId - the recorded session to look for.
1927
+ * @returns whether the session can be acted on here.
1928
+ */
1929
+ async function sessionReadable(ctx, sessionId) {
1930
+ if (ctx.sessions.get(sessionId) !== void 0) return true;
1931
+ const persistence = ctx.get("sessionPersistence");
1932
+ if (persistence === void 0) return false;
1933
+ try {
1934
+ return await persistence.stat(sessionId) !== void 0;
1935
+ } catch (error) {
1936
+ ctx.logger.warn(`pause: could not look for the stored log of "${sessionId}": ${describeError(error)}`);
1937
+ return true;
1938
+ }
1939
+ }
1940
+ /** How resuming all parked sessions answers the command that asked for it. */
1941
+ function summarizeResume(attempts) {
1942
+ const resumed = attempts.filter((attempt) => attempt.resumed).length;
1943
+ const refused = attempts.length - resumed;
1944
+ const head = resumed === 0 ? "pause resumed no session" : resumed === 1 ? "pause resumed 1 session from where it stopped" : `pause resumed ${resumed} sessions from where they stopped`;
1945
+ if (refused === 0) return `${head}.`;
1946
+ return `${head}; ${attempts.filter((attempt) => attempt.refusal === "not-clean").length === refused ? `${refused} did not pause completely and need your decision` : `${refused} could not be resumed`}.`;
1947
+ }
1948
+ /**
1949
+ * Contribute the session control: the pause verb, the per-session status read,
1950
+ * the global resume verb, and — for every paused session that has a live agent
1951
+ * — the resume marker.
1952
+ * @param ctx - host context carrying the command registry, the agent store, the
1953
+ * session store, and the projection registry.
1954
+ * @param gate - the pause gate this control drives and reflects.
1955
+ * @param watch - the per-session activity fold the park predicate reads.
1956
+ * @param registry - the durable pause list every phase change is written to.
1957
+ * @param options - route knowledge the clean resume path needs, and the decision list's read bound.
1958
+ * @returns the mount-time adoption step and the morning resume path.
1959
+ */
1960
+ function installSessionControl(ctx, gate, watch, registry, options) {
1961
+ /** Marks per paused session; presence here mirrors the gate's own membership. */
1962
+ const marks = /* @__PURE__ */ new Map();
1963
+ /** Published marker sets per paused session; disposing one removes its three commands. */
1964
+ const markers = /* @__PURE__ */ new Map();
1965
+ /**
1966
+ * Records this process did not hold again because the session behind them
1967
+ * cannot be read here. Nothing can be parked, released, or decided for them
1968
+ * from this process, so they are not marks — and the aggregate names them
1969
+ * instead of counting them among the sessions this park owns.
1970
+ */
1971
+ const unheld = /* @__PURE__ */ new Set();
1972
+ /** Bumped by every mark and every cancel; a stale generation publishes nothing. */
1973
+ let generation = 0;
1974
+ const drop = (sessionId) => {
1975
+ marks.delete(sessionId);
1976
+ const marker = markers.get(sessionId);
1977
+ if (marker === void 0) return;
1978
+ markers.delete(sessionId);
1979
+ Promise.resolve(marker.fiber.dispose()).catch((error) => {
1980
+ ctx.logger.warn(`pause: session control cleanup failed: ${describeError(error)}`);
1981
+ });
1982
+ };
1983
+ /**
1984
+ * Write one phase to the durable registry, then publish it on the mark and
1985
+ * announce it on the command channel.
1986
+ *
1987
+ * The order is the whole point: a phase is published only once a later
1988
+ * process would read it back. A failed write leaves the mark on its previous
1989
+ * phase, which is never cleaner than what the registry holds — the direction
1990
+ * that matters, because "safe to close" is a promise about surviving the
1991
+ * process, not about this process's memory.
1992
+ *
1993
+ * A phase that actually changed is also the moment every client is owed a
1994
+ * fresh read: the announcement is what carries `parking` → `safe` out of this
1995
+ * process, since the flush that resolves it changes no registration by
1996
+ * itself. Announcing only on a change is also what keeps the channel quiet —
1997
+ * a write that repeats the phase a mark already holds tells a reader nothing,
1998
+ * so it must not cost one.
1999
+ * @param sessionId - the paused session.
2000
+ * @param mark - its live mark, updated in place on success.
2001
+ * @param phase - the phase to record and publish.
2002
+ * @returns whether the phase became durable.
2003
+ */
2004
+ const commit = async (sessionId, mark, phase) => {
2005
+ try {
2006
+ await registry.record(sessionId, phase);
2007
+ } catch (error) {
2008
+ ctx.logger.warn(`pause: could not record the park state of "${sessionId}" as ${phase}: ${describeError(error)}`);
2009
+ return false;
2010
+ }
2011
+ const changed = mark.phase !== phase;
2012
+ mark.phase = phase;
2013
+ if (changed) markers.get(sessionId)?.announce();
2014
+ return true;
2015
+ };
2016
+ /** Whether this session is standing still at the park point. */
2017
+ const parked = (sessionId) => {
2018
+ const activity = watch.activity(sessionId);
2019
+ if (activity.runningTools.length > 0) return false;
2020
+ return gate.isHeld(sessionId) || !activity.turnOpen;
2021
+ };
2022
+ /**
2023
+ * The phase a durable record holds for one session right now, whether or not
2024
+ * this process holds it. A member whose session this host cannot read still
2025
+ * has a record, and that record is the only thing anyone knows about it.
2026
+ */
2027
+ const recordedPhase = (sessionId) => marks.get(sessionId)?.phase ?? registry.get(sessionId)?.phase;
2028
+ /**
2029
+ * The state this session's header reports: the worst phase among the members
2030
+ * the session's own press registered, folded with the session's own.
2031
+ *
2032
+ * The control speaks for a tree, so it may not say "safe to close" while a
2033
+ * member of that tree is still draining, stalled, or not readable — the user
2034
+ * would close the lid on work this press started. A branch the walk could not
2035
+ * read may hold a member nobody can see, so an unread branch counts as
2036
+ * unfinished rather than as empty.
2037
+ * @param mark - the session's own mark, whose phase the fold starts from.
2038
+ * @returns the phase to publish and the goal opt-out that rides beside it.
2039
+ */
2040
+ const treeStatus = async (sessionId, mark, signal) => {
2041
+ const walk = await walkDescendants(ctx, sessionId, signal);
2042
+ const phases = [mark.phase];
2043
+ for (const member of walk.members) {
2044
+ const phase = recordedPhase(member.id);
2045
+ if (phase !== void 0) phases.push(phase);
2046
+ }
2047
+ for (let at = 0; at < walk.unread.length + (walk.unreadWhole ? 1 : 0); at += 1) phases.push("parking");
2048
+ const folded = foldTreePhases(phases);
2049
+ const record = registry.get(sessionId);
2050
+ return {
2051
+ phase: folded.phase,
2052
+ safe: folded.safe,
2053
+ runningTools: mark.phase === "draining" ? watch.activity(sessionId).runningTools : [],
2054
+ ...record === void 0 ? {} : { rearmGoal: record.rearmGoal }
2055
+ };
2056
+ };
2057
+ /**
2058
+ * Ensure one paused session has its three verbs registered. Registration is
2059
+ * once per park: the status is answered by the command, not frozen into the
2060
+ * descriptor, so a phase change needs no re-registration to be readable — the
2061
+ * one {@link ParkMarkers.announce} performs is the announcement itself and
2062
+ * nothing more.
2063
+ */
2064
+ const publish = (sessionId) => {
2065
+ const mark = marks.get(sessionId);
2066
+ if (mark === void 0 || markers.has(sessionId)) return;
2067
+ const agent = ctx.agents.get(sessionId);
2068
+ if (agent === void 0) return;
2069
+ const status = async (signal) => encodeParkStatus(await treeStatus(sessionId, mark, signal));
2070
+ const definition = () => ({
2071
+ definitionId: CommandDefinitionId(STATUS_DEFINITION_ID),
2072
+ name: STATUS_COMMAND,
2073
+ description: "Report this session's pause state",
2074
+ handler: async (invocation) => ({
2075
+ kind: "success",
2076
+ text: await status(invocation.signal)
2077
+ })
2078
+ });
2079
+ /** Re-register the status verb, which is the announcement every phase change rides. */
2080
+ let announce;
2081
+ const fiber = agent.ctx.inject(["commands"], (scope) => {
2082
+ scope.commands.register({
2083
+ definitionId: CommandDefinitionId(RESUME_DEFINITION_ID),
2084
+ name: RESUME_COMMAND,
2085
+ description: "Resume this paused session and its subagents",
2086
+ handler: async (invocation) => modelIssued(ctx) ? {
2087
+ kind: "error",
2088
+ text: UNATTENDED_TEXT
2089
+ } : (await resumePath.releaseTree(invocation.agent.id, invocation.signal)).answer
2090
+ });
2091
+ /** The status verb's current registration; {@link announce} replaces it. */
2092
+ let registered = scope.commands.register(definition());
2093
+ announce = () => {
2094
+ registered();
2095
+ registered = scope.commands.register(definition());
2096
+ };
2097
+ scope.commands.register({
2098
+ definitionId: CommandDefinitionId(GOAL_DEFINITION_ID),
2099
+ name: GOAL_COMMAND,
2100
+ description: "Turn this paused Session's goal re-arm on or off",
2101
+ handler: async () => {
2102
+ if (modelIssued(ctx)) return {
2103
+ kind: "error",
2104
+ text: UNATTENDED_TEXT
2105
+ };
2106
+ const record = registry.get(sessionId);
2107
+ if (record === void 0) return {
2108
+ kind: "error",
2109
+ text: NOT_PARKED_TEXT
2110
+ };
2111
+ return await resumePath.setRearmGoal(sessionId, !record.rearmGoal);
2112
+ }
2113
+ });
2114
+ });
2115
+ markers.set(sessionId, {
2116
+ fiber,
2117
+ announce: () => {
2118
+ announce?.();
2119
+ }
2120
+ });
2121
+ };
2122
+ /**
2123
+ * Walk one session's mark as far forward as its current activity allows.
2124
+ *
2125
+ * The move is published by `settle`, once the write that makes `parking`
2126
+ * durable has resolved; until then the mark keeps answering with `draining`,
2127
+ * which is less than the truth rather than more than the record can back.
2128
+ * @param sessionId - the marked session to re-derive.
2129
+ * @returns whether this call moved the mark out of `draining` (and so owes a flush).
2130
+ */
2131
+ const advance = (sessionId) => {
2132
+ const mark = marks.get(sessionId);
2133
+ if (mark === void 0 || mark.phase !== "draining" || mark.advancing) return false;
2134
+ if (mark.origin === "registry") return false;
2135
+ if (!parked(sessionId)) return false;
2136
+ mark.advancing = true;
2137
+ return true;
2138
+ };
2139
+ /**
2140
+ * Flush a parked session at the session level and publish what actually
2141
+ * happened.
2142
+ *
2143
+ * The flush is the promise that survives a crash: a handle's own flush would
2144
+ * leave the routed live buffer — the pending input — in memory. `safe` is
2145
+ * therefore reserved for a flush this process ran and that resolved; a
2146
+ * session that is no longer in the store cannot be flushed at all, so its
2147
+ * park stops at the phase that says the flush never happened.
2148
+ */
2149
+ const settle = async (sessionId, attempt) => {
2150
+ const mark = marks.get(sessionId);
2151
+ if (mark === void 0 || mark.generation !== attempt) return;
2152
+ /** Whether this attempt still owns the mark: a cancel or a newer press replaces or drops it. */
2153
+ const owned = () => marks.get(sessionId) === mark && mark.generation === attempt;
2154
+ const session = ctx.sessions.get(sessionId);
2155
+ try {
2156
+ if (session === void 0) {
2157
+ await commit(sessionId, mark, "parking");
2158
+ return;
2159
+ }
2160
+ await commit(sessionId, mark, "parking");
2161
+ if (!owned()) return;
2162
+ try {
2163
+ await ctx.sessions.flush(session);
2164
+ } catch (error) {
2165
+ if (!owned()) return;
2166
+ ctx.logger.warn(`pause: flush failed for "${sessionId}": ${describeError(error)}`);
2167
+ await commit(sessionId, mark, "flush-failed");
2168
+ return;
2169
+ }
2170
+ if (!owned()) return;
2171
+ await commit(sessionId, mark, "safe");
2172
+ } finally {
2173
+ if (marks.get(sessionId) === mark && mark.phase === "draining") mark.advancing = false;
2174
+ }
2175
+ };
2176
+ /** Look at one marked session and act on what it is doing. */
2177
+ const look = (sessionId) => {
2178
+ publish(sessionId);
2179
+ if (!advance(sessionId)) return;
2180
+ const attempt = marks.get(sessionId)?.generation;
2181
+ if (attempt !== void 0) settle(sessionId, attempt);
2182
+ };
2183
+ /**
2184
+ * Press one session's pause and make it durable.
2185
+ *
2186
+ * The press IS a durable promise: a pause that cannot be recorded is taken
2187
+ * back here rather than left as one a restart silently forgets. A session a
2188
+ * pause already owns is left exactly where it is — including one adopted
2189
+ * from an earlier process, whose outcome is not this process's to re-derive.
2190
+ *
2191
+ * A session with nothing live is left alone entirely. It has no request to
2192
+ * hold and no session-level flush to run, so a record for it could only be
2193
+ * one of two lies: `safe`, a park that never happened but that the clean path
2194
+ * would silently resume, or a phase that never settles, which would keep
2195
+ * "safe to close" from ever appearing. Registering the running tree therefore
2196
+ * means registering the members a pause can actually hold and flush.
2197
+ * @param sessionId - the session to pause.
2198
+ * @param signal - cancellation for the resumability read and registry write.
2199
+ * @param knownResumable - whether the caller already proved the local route is
2200
+ * `continuable` from the parent catalog, avoiding a second Session read.
2201
+ * @returns what the press did; only `registered` created a new park.
2202
+ */
2203
+ const press = async (sessionId, signal, knownResumable = false) => {
2204
+ if (ctx.sessions.get(sessionId) === void 0) return "not-live";
2205
+ if (!knownResumable && !await isResumableSession(ctx, sessionId, signal)) return "not-resumable";
2206
+ if (!gate.pause(sessionId)) return "already-paused";
2207
+ const mark = {
2208
+ phase: "draining",
2209
+ generation: ++generation,
2210
+ origin: "press",
2211
+ advancing: false
2212
+ };
2213
+ marks.set(sessionId, mark);
2214
+ if (await commit(sessionId, mark, "draining")) {
2215
+ unheld.delete(sessionId);
2216
+ return "registered";
2217
+ }
2218
+ drop(sessionId);
2219
+ gate.resume(sessionId);
2220
+ return "unrecordable";
2221
+ };
2222
+ /**
2223
+ * Press one session's pause and register its whole tree behind it.
2224
+ *
2225
+ * "One session's work" includes the subagents it delegated to, nested ones
2226
+ * included, and the user does not pause them one by one. So the press walks
2227
+ * the session's own catalog tree and gives every member the same durable
2228
+ * treatment the session gets: {@link press} registers what it can hold, and a
2229
+ * member whose record cannot be written is named here rather than counted as
2230
+ * paused.
2231
+ *
2232
+ * A tree the walk could not read is the one case where the press does less
2233
+ * than the tree: the session itself is still registered — the user pressed a
2234
+ * session, and its park is real — and the answer says the subagents were not
2235
+ * covered rather than counting a tree nobody read.
2236
+ * @param sessionId - the session whose tree the user pressed.
2237
+ * @returns which members are registered and which could not be.
2238
+ */
2239
+ const pressTree = async (sessionId, signal) => {
2240
+ if (ctx.sessions.get(sessionId) === void 0) return {
2241
+ members: 0,
2242
+ registered: 0,
2243
+ rootRegistered: false,
2244
+ unrecorded: [],
2245
+ unread: false
2246
+ };
2247
+ const walk = await walkDescendants(ctx, sessionId, signal);
2248
+ if (walk.unreadWhole) {
2249
+ const pressed = await press(sessionId, signal);
2250
+ if (pressed === "registered") look(sessionId);
2251
+ globalPause.markUnrecorded(sessionId, pressed === "unrecordable");
2252
+ return {
2253
+ members: pressed === "registered" ? 1 : 0,
2254
+ registered: pressed === "registered" ? 1 : 0,
2255
+ rootRegistered: pressed === "registered",
2256
+ unrecorded: pressed === "unrecordable" ? [sessionId] : [],
2257
+ unread: true
2258
+ };
2259
+ }
2260
+ const registered = [];
2261
+ const unrecorded = [];
2262
+ const members = walk.members.filter((member) => member.mode === "continuable");
2263
+ for (const id of [sessionId, ...members.map((member) => member.id)]) {
2264
+ const pressed = await press(id, signal, id !== sessionId);
2265
+ if (pressed === "registered") {
2266
+ registered.push(id);
2267
+ look(id);
2268
+ } else if (pressed === "unrecordable") unrecorded.push(id);
2269
+ globalPause.markUnrecorded(id, pressed === "unrecordable");
2270
+ }
2271
+ return {
2272
+ members: registered.length + unrecorded.length,
2273
+ registered: registered.length,
2274
+ rootRegistered: registered.includes(sessionId),
2275
+ unrecorded,
2276
+ unread: false
2277
+ };
2278
+ };
2279
+ const sync = (sessionId) => {
2280
+ if (!gate.isPaused(sessionId)) {
2281
+ drop(sessionId);
2282
+ return;
2283
+ }
2284
+ publish(sessionId);
2285
+ };
2286
+ /**
2287
+ * Take the pause back: the durable record goes first, so a process that dies
2288
+ * in between does not re-hold a session the user has already released. A
2289
+ * record that cannot be dropped leaves the session paused, which is the state
2290
+ * the registry still describes.
2291
+ */
2292
+ const release = async (sessionId, text) => {
2293
+ if (!gate.isPaused(sessionId)) return {
2294
+ kind: "success",
2295
+ text
2296
+ };
2297
+ try {
2298
+ await registry.forget(sessionId);
2299
+ } catch (error) {
2300
+ ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
2301
+ return {
2302
+ kind: "error",
2303
+ text: UNRECORDED_TEXT
2304
+ };
2305
+ }
2306
+ gate.resume(sessionId);
2307
+ return {
2308
+ kind: "success",
2309
+ text
2310
+ };
2311
+ };
2312
+ const resumePath = installResumePath(ctx, gate, registry, options, {
2313
+ release,
2314
+ adopted: (sessionId) => marks.get(sessionId)?.origin === "registry"
2315
+ });
2316
+ const triage = mountTriageList(ctx, registry, resumePath, options.maxListedLogEvents);
2317
+ /**
2318
+ * The one handler the four decision verbs share. The list is addressed by
2319
+ * session like every other command on this channel, so the row the user
2320
+ * decided on travels as the command's own input. The calling session is what
2321
+ * makes that row a target: the list acts only on rows it published to this
2322
+ * caller, and never on a model's command.
2323
+ */
2324
+ const decide = (action) => async (invocation) => {
2325
+ if (modelIssued(ctx)) return {
2326
+ kind: "error",
2327
+ text: UNATTENDED_TEXT
2328
+ };
2329
+ const target = invocation.rawInput.trim();
2330
+ if (target.length === 0) return {
2331
+ kind: "error",
2332
+ text: TRIAGE_NOT_LISTED_TEXT
2333
+ };
2334
+ return await triage.decide(invocation.agent.id, SessionId(target), action, invocation.signal);
2335
+ };
2336
+ const globalPause = mountGlobalPause(ctx, watch, registry, {
2337
+ isPaused: (sessionId) => gate.isPaused(sessionId),
2338
+ press,
2339
+ look,
2340
+ live: () => new Map([...marks].map(([sessionId, mark]) => [sessionId, {
2341
+ phase: mark.phase,
2342
+ moving: mark.origin === "press" && mark.phase !== "flush-failed" && ctx.sessions.get(sessionId) !== void 0
2343
+ }])),
2344
+ unheld: () => unheld
2345
+ });
2346
+ ctx.effect(function* () {
2347
+ yield ctx.commands.register({
2348
+ definitionId: CommandDefinitionId(PAUSE_DEFINITION_ID),
2349
+ name: PAUSE_COMMAND,
2350
+ description: "Pause this Session and its subagents before their next model request",
2351
+ handler: async (invocation) => {
2352
+ if (modelIssued(ctx)) return {
2353
+ kind: "error",
2354
+ text: UNATTENDED_TEXT
2355
+ };
2356
+ const sessionId = invocation.agent.id;
2357
+ const pressed = await pressTree(sessionId, invocation.signal);
2358
+ if (pressed.unrecorded.length > 0) return {
2359
+ kind: "error",
2360
+ text: unrecordedTreeText(pressed.unrecorded)
2361
+ };
2362
+ if (!pressed.rootRegistered) return {
2363
+ kind: "error",
2364
+ text: NOT_RESUMABLE_PARK_TEXT
2365
+ };
2366
+ const agents = pressed.rootRegistered ? pressed.registered - 1 : pressed.registered;
2367
+ if (pressed.unread) return {
2368
+ kind: "success",
2369
+ text: `${pausedText(agents)} ${TREE_UNREAD_TEXT}`
2370
+ };
2371
+ return {
2372
+ kind: "success",
2373
+ text: pausedText(agents)
2374
+ };
2375
+ }
2376
+ });
2377
+ yield ctx.commands.register({
2378
+ definitionId: CommandDefinitionId(RESUME_ALL_DEFINITION_ID),
2379
+ name: RESUME_ALL_COMMAND,
2380
+ description: "Resume every paused session",
2381
+ handler: async (invocation) => modelIssued(ctx) ? {
2382
+ kind: "error",
2383
+ text: UNATTENDED_TEXT
2384
+ } : {
2385
+ kind: "success",
2386
+ text: summarizeResume(await resumePath.resumeParked(invocation.signal))
2387
+ }
2388
+ });
2389
+ yield ctx.commands.register({
2390
+ definitionId: CommandDefinitionId(PAUSE_ALL_DEFINITION_ID),
2391
+ name: PAUSE_ALL_COMMAND,
2392
+ description: "Pause every running Session and its subagents",
2393
+ handler: async (invocation) => modelIssued(ctx) ? {
2394
+ kind: "error",
2395
+ text: UNATTENDED_TEXT
2396
+ } : globalPause.pressAll(invocation.signal)
2397
+ });
2398
+ yield ctx.commands.register({
2399
+ definitionId: CommandDefinitionId(STATE_ALL_DEFINITION_ID),
2400
+ name: STATE_ALL_COMMAND,
2401
+ description: "Report whether every session is paused",
2402
+ handler: async (invocation) => globalPause.readStatus(invocation.signal)
2403
+ });
2404
+ yield ctx.commands.register({
2405
+ definitionId: CommandDefinitionId(TRIAGE_DEFINITION_ID),
2406
+ name: TRIAGE_COMMAND,
2407
+ description: "List sessions that did not pause completely",
2408
+ handler: async (invocation) => ({
2409
+ kind: "success",
2410
+ text: encodeTriageList(await triage.read(invocation.agent.id, invocation.signal))
2411
+ })
2412
+ });
2413
+ yield ctx.commands.register({
2414
+ definitionId: CommandDefinitionId(TRIAGE_CONTINUE_DEFINITION_ID),
2415
+ name: TRIAGE_CONTINUE_COMMAND,
2416
+ description: "Resume a listed session: <session id>",
2417
+ handler: decide("continue")
2418
+ });
2419
+ yield ctx.commands.register({
2420
+ definitionId: CommandDefinitionId(TRIAGE_NOTE_DEFINITION_ID),
2421
+ name: TRIAGE_NOTE_COMMAND,
2422
+ description: "Resume a listed session with a note that its tool result is unknown: <session id>",
2423
+ handler: decide("note")
2424
+ });
2425
+ yield ctx.commands.register({
2426
+ definitionId: CommandDefinitionId(TRIAGE_REVIEW_DEFINITION_ID),
2427
+ name: TRIAGE_REVIEW_COMMAND,
2428
+ description: "Open a listed session without running it: <session id>",
2429
+ handler: decide("review")
2430
+ });
2431
+ yield ctx.commands.register({
2432
+ definitionId: CommandDefinitionId(TRIAGE_LEAVE_DEFINITION_ID),
2433
+ name: TRIAGE_LEAVE_COMMAND,
2434
+ description: "Keep a listed session paused: <session id>",
2435
+ handler: decide("leave")
2436
+ });
2437
+ yield ctx.commands.register({
2438
+ definitionId: CommandDefinitionId(TRIAGE_GOAL_DEFINITION_ID),
2439
+ name: TRIAGE_GOAL_COMMAND,
2440
+ description: "Turn a listed Session's goal re-arm on or off: <session id>",
2441
+ handler: decide("goal")
2442
+ });
2443
+ yield gate.onChange((sessionId) => {
2444
+ sync(sessionId);
2445
+ });
2446
+ yield gate.onHeld((sessionId) => {
2447
+ queueMicrotask(() => {
2448
+ look(sessionId);
2449
+ });
2450
+ });
2451
+ yield watch.onChange((sessionId) => {
2452
+ look(sessionId);
2453
+ });
2454
+ yield ctx.on("agent/created", ({ agent }) => {
2455
+ sync(agent.id);
2456
+ });
2457
+ yield ctx.on("agent/disposed", ({ agent }) => {
2458
+ markers.delete(agent.id);
2459
+ });
2460
+ yield async () => {
2461
+ const pending = [...markers.values()].map((marker) => marker.fiber.dispose());
2462
+ markers.clear();
2463
+ marks.clear();
2464
+ await Promise.all(pending);
2465
+ };
2466
+ }, "pause: session control");
2467
+ return { adopt: async () => {
2468
+ for (const entry of registry.list()) {
2469
+ if (!await sessionReadable(ctx, entry.sessionId)) {
2470
+ unheld.add(entry.sessionId);
2471
+ ctx.logger.warn(`pause: the pause recorded for "${entry.sessionId}" is not held: this host cannot read that session`);
2472
+ continue;
2473
+ }
2474
+ if (gate.pause(entry.sessionId)) marks.set(entry.sessionId, {
2475
+ phase: entry.phase,
2476
+ generation: ++generation,
2477
+ origin: "registry",
2478
+ advancing: false
2479
+ });
2480
+ publish(entry.sessionId);
2481
+ }
2482
+ for (const agent of ctx.agents.list()) sync(agent.id);
2483
+ } };
2484
+ }
2485
+ //#endregion
2486
+ //#region lib/types/gate.js
2487
+ /**
2488
+ * The pause gate: one session's hold at the model-request waterfall.
2489
+ *
2490
+ * The hold sits on the `llm/stream` waterfall, which is the only point that
2491
+ * satisfies all four conditions at once: the previous step's tool calls have
2492
+ * drained, the request is already built, the session's pending input is
2493
+ * committed, and — because nothing pulls the adapter — no timer is armed
2494
+ * anywhere. Machine sleep, network loss, and the adapter's stream-idle
2495
+ * watchdog therefore cannot touch a parked request.
2496
+ * @module dsh-pause-session/gate
2497
+ */
2498
+ /** Message of the failure a held request ends with when its plugin unloads first. */
2499
+ const PLUGIN_UNLOADED = "pause: plugin lifetime ended while a request was held";
2500
+ /** The abort signal's own error, or a stable one when the abort carried no reason. */
2501
+ function abortError(signal) {
2502
+ return signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("pause: request aborted while held");
2503
+ }
2504
+ /** A latch without `Promise.withResolvers`, which this project's ES2022 lib predates. */
2505
+ function latch() {
2506
+ let open = () => {};
2507
+ return {
2508
+ promise: new Promise((resolve) => {
2509
+ open = resolve;
2510
+ }),
2511
+ open
2512
+ };
2513
+ }
2514
+ /**
2515
+ * Resolve once the session is released or any of `signals` aborts. No timer is
2516
+ * armed: the wait ends only by release or by an abort, so a parked request
2517
+ * cannot be woken by the clock. Listeners are removed once the wait settles.
2518
+ */
2519
+ function raceRelease(released, signals) {
2520
+ const wake = latch();
2521
+ const onAbort = () => {
2522
+ wake.open();
2523
+ };
2524
+ const watching = signals.filter((signal) => !signal.aborted);
2525
+ if (watching.length !== signals.length) wake.open();
2526
+ for (const signal of watching) signal.addEventListener("abort", onAbort, { once: true });
2527
+ return Promise.race([released, wake.promise]).finally(() => {
2528
+ for (const signal of watching) signal.removeEventListener("abort", onAbort);
2529
+ });
2530
+ }
2531
+ /** Per-session pause state and the waterfall listener that enforces it. */
2532
+ var PauseGate = class {
2533
+ options;
2534
+ /** Paused sessions; membership IS the pause state. */
2535
+ paused = /* @__PURE__ */ new Map();
2536
+ listeners = /* @__PURE__ */ new Set();
2537
+ heldListeners = /* @__PURE__ */ new Set();
2538
+ /**
2539
+ * @param options - the plugin-lifetime signal every held request races.
2540
+ */
2541
+ constructor(options) {
2542
+ this.options = options;
2543
+ }
2544
+ /**
2545
+ * Subscribe to pause-state changes.
2546
+ * @param listener - called with the session whose state changed and its new state.
2547
+ * @returns the unsubscribe function.
2548
+ */
2549
+ onChange(listener) {
2550
+ this.listeners.add(listener);
2551
+ return () => {
2552
+ this.listeners.delete(listener);
2553
+ };
2554
+ }
2555
+ /**
2556
+ * Subscribe to "a request arrived at / left the gate" for a paused session.
2557
+ *
2558
+ * This is the moment a session can become parked, so it is also the moment a
2559
+ * park observer has to look. Listeners run synchronously inside the request
2560
+ * path: they must only record that a look is owed.
2561
+ * @param listener - called with the session and whether it now holds a request.
2562
+ * @returns the unsubscribe function.
2563
+ */
2564
+ onHeld(listener) {
2565
+ this.heldListeners.add(listener);
2566
+ return () => {
2567
+ this.heldListeners.delete(listener);
2568
+ };
2569
+ }
2570
+ /**
2571
+ * @param sessionId - session to inspect.
2572
+ * @returns whether the session is currently paused.
2573
+ */
2574
+ isPaused(sessionId) {
2575
+ return this.paused.has(sessionId);
2576
+ }
2577
+ /**
2578
+ * @param sessionId - session to inspect.
2579
+ * @returns whether a model request of that session is waiting at the gate
2580
+ * right now. False for a session that is paused but whose loop has not built
2581
+ * its next request yet — a running tool, for instance, is still draining.
2582
+ */
2583
+ isHeld(sessionId) {
2584
+ return (this.paused.get(sessionId)?.holding ?? 0) > 0;
2585
+ }
2586
+ /**
2587
+ * Enter one request that is about to wait. The count rises synchronously with
2588
+ * {@link hold}, so an observer notified at that moment reads `isHeld` as true.
2589
+ * @param pause - the session's pause record.
2590
+ * @returns the exit that lowers the count again.
2591
+ */
2592
+ enter(pause) {
2593
+ pause.holding += 1;
2594
+ this.notifyHeld(pause.sessionId, true);
2595
+ return () => {
2596
+ pause.holding -= 1;
2597
+ if (pause.holding === 0) this.notifyHeld(pause.sessionId, false);
2598
+ };
2599
+ }
2600
+ /**
2601
+ * Pause one session: its next model request is held until {@link resume}. A
2602
+ * request already past the waterfall keeps running — pausing never
2603
+ * interrupts a model call or a tool call that has started.
2604
+ * @param sessionId - session to pause.
2605
+ * @returns whether this call changed the state.
2606
+ */
2607
+ pause(sessionId) {
2608
+ if (this.paused.has(sessionId)) return false;
2609
+ const release = latch();
2610
+ this.paused.set(sessionId, {
2611
+ sessionId,
2612
+ released: release.promise,
2613
+ release: release.open,
2614
+ holding: 0
2615
+ });
2616
+ this.notify(sessionId, true);
2617
+ return true;
2618
+ }
2619
+ /**
2620
+ * Resume one session: release everything it holds and stop holding what
2621
+ * follows. The requests sent from then on are the ones built before the
2622
+ * hold, so a resumed session continues from the point it stopped.
2623
+ * @param sessionId - session to resume.
2624
+ * @returns whether this call changed the state.
2625
+ */
2626
+ resume(sessionId) {
2627
+ const pause = this.paused.get(sessionId);
2628
+ if (pause === void 0) return false;
2629
+ this.paused.delete(sessionId);
2630
+ pause.release();
2631
+ this.notify(sessionId, false);
2632
+ return true;
2633
+ }
2634
+ /**
2635
+ * The wait one model request must finish before it may delegate, and the exit
2636
+ * that reports the request as no longer waiting.
2637
+ * @param request - the request reaching the waterfall.
2638
+ * @returns the wait plus its release, or `undefined` when this gate does not
2639
+ * hold that request.
2640
+ */
2641
+ hold(request) {
2642
+ if (request.purpose !== void 0) return void 0;
2643
+ const sessionId = request.sessionId;
2644
+ if (sessionId === void 0) return void 0;
2645
+ const pause = this.paused.get(sessionId);
2646
+ if (pause === void 0) return void 0;
2647
+ const left = this.enter(pause);
2648
+ return {
2649
+ wait: raceRelease(pause.released, request.signal === void 0 ? [this.options.lifetime] : [request.signal, this.options.lifetime]),
2650
+ left
2651
+ };
2652
+ }
2653
+ /**
2654
+ * The `llm/stream` listener.
2655
+ *
2656
+ * `ctx.waterfall` composes listeners synchronously, so this returns an async
2657
+ * iterator synchronously — an `async` listener would hand the loop a promise
2658
+ * where it iterates a stream. One generator body reaches `next()` at most
2659
+ * once, which is what makes delegation single-shot per HELD request; a flag
2660
+ * shared across requests would also short-circuit everything issued after
2661
+ * the release.
2662
+ * @param request - the full request, already built and frozen by the loop.
2663
+ * @param next - the downstream waterfall chain.
2664
+ * @returns the stream, delayed until this request may delegate.
2665
+ */
2666
+ stream = (request, next) => {
2667
+ const gate = this;
2668
+ return (async function* gated() {
2669
+ const held = gate.hold(request);
2670
+ try {
2671
+ if (held !== void 0) await held.wait;
2672
+ if (request.signal?.aborted === true) throw abortError(request.signal);
2673
+ if (gate.options.lifetime.aborted) throw new Error(PLUGIN_UNLOADED);
2674
+ yield* next();
2675
+ } finally {
2676
+ held?.left();
2677
+ }
2678
+ })();
2679
+ };
2680
+ notify(sessionId, paused) {
2681
+ for (const listener of [...this.listeners]) listener(sessionId, paused);
2682
+ }
2683
+ notifyHeld(sessionId, held) {
2684
+ for (const listener of [...this.heldListeners]) listener(sessionId, held);
2685
+ }
2686
+ };
2687
+ //#endregion
2688
+ //#region lib/types/park.js
2689
+ /**
2690
+ * What a session is doing between "pause was asked for" and "its next model
2691
+ * request is standing still at the gate".
2692
+ *
2693
+ * Two facts decide that, and each has its own owner:
2694
+ *
2695
+ * - Whether a turn is open is derived state the harness already maintains:
2696
+ * the agent registry owns the `turnBoundary` session projection, which folds
2697
+ * `turn/start`, `turn/end`, `step/start`, and `step/end` into
2698
+ * `openTurnStartSeq`. Reading it through `ctx.agents` is a synchronous
2699
+ * projection read, not a scan of the event log (synchronous history reads are
2700
+ * prohibited for new code).
2701
+ * - Which tool is in flight is a log-derived per-session fact, so it is a
2702
+ * session projection too. The projection folds each committed
2703
+ * `tool/call` → `tool/result` pairing and keeps the calls in order; the
2704
+ * framework owns replay, caching, and disposal. This module listens only to
2705
+ * committed events so it can wake the control, never to keep a second copy
2706
+ * of the state itself.
2707
+ * @module dsh-pause-session/park
2708
+ */
2709
+ /** One tool call the session has committed but not yet answered. */
2710
+ const openCallSchema = z.object({
2711
+ /** The provider-issued id pairing the call with the result that has not arrived. */
2712
+ callId: z.string(),
2713
+ /** The tool's own name, as the model called it. */
2714
+ name: z.string()
2715
+ });
2716
+ /**
2717
+ * The host-only fold behind {@link ParkWatch}. It is registered on the
2718
+ * plugin's fiber; unload removes it with every other registration.
2719
+ */
2720
+ const parkActivityProjection = {
2721
+ key: "dsh-pause-session/activity",
2722
+ stateSchema: z.array(openCallSchema),
2723
+ init: () => [],
2724
+ apply: (state, event) => {
2725
+ if (event.type === "tool/call") return [...state, {
2726
+ callId: String(event.data.callId),
2727
+ name: event.data.name
2728
+ }];
2729
+ if (event.type === "tool/result") {
2730
+ const callId = String(event.data.message.toolCallId);
2731
+ const at = state.findIndex((call) => call.callId === callId);
2732
+ if (at < 0) return state;
2733
+ return [...state.slice(0, at), ...state.slice(at + 1)];
2734
+ }
2735
+ return state;
2736
+ },
2737
+ stateVersion: 1
2738
+ };
2739
+ /** Whether the agent registry's `turnBoundary` fold reports an open turn. */
2740
+ function turnOpen(ctx, session) {
2741
+ return ctx.agents.turnBoundaryOf(session).openTurnStartSeq !== null;
2742
+ }
2743
+ /**
2744
+ * Mount the per-session activity projection and its notification edge.
2745
+ * @param ctx - host context carrying the session store and the projection registry.
2746
+ * @returns the watch the park state is derived from.
2747
+ */
2748
+ async function mountParkWatch(ctx) {
2749
+ const activity = await ctx.sessionProjections.register(parkActivityProjection);
2750
+ const listeners = /* @__PURE__ */ new Set();
2751
+ const notify = (sessionId) => {
2752
+ for (const listener of [...listeners]) listener(sessionId);
2753
+ };
2754
+ ctx.effect(() => ctx.on("session/event", (session, event) => {
2755
+ switch (event.type) {
2756
+ case "tool/call":
2757
+ case "tool/result":
2758
+ notify(session.id);
2759
+ return;
2760
+ case "turn/start":
2761
+ case "turn/end":
2762
+ notify(session.id);
2763
+ return;
2764
+ default: return;
2765
+ }
2766
+ }), "pause: session activity changes");
2767
+ return {
2768
+ activity: (sessionId) => {
2769
+ const session = ctx.sessions.get(sessionId);
2770
+ if (session === void 0) return {
2771
+ turnOpen: false,
2772
+ runningTools: []
2773
+ };
2774
+ return {
2775
+ turnOpen: turnOpen(ctx, session),
2776
+ runningTools: activity.stateOf(session).map((call) => call.name)
2777
+ };
2778
+ },
2779
+ onChange: (listener) => {
2780
+ listeners.add(listener);
2781
+ return () => {
2782
+ listeners.delete(listener);
2783
+ };
2784
+ }
2785
+ };
2786
+ }
2787
+ /**
2788
+ * The registry declaration. `version: 1` and no `compatibleVersions`: the
2789
+ * domain layer rejects a stored unit stamped with any other version rather than
2790
+ * migrating it, so an unrecognized registry fails loud at open. Validation is
2791
+ * the rejecting default for the same reason — a record this build cannot read
2792
+ * must stop the mount, not disappear from the list.
2793
+ */
2794
+ const parkRegistryDomain = defineDomain({
2795
+ name: "pause_registry",
2796
+ version: 1,
2797
+ tables: { sessions: domainTable(z.object({
2798
+ /** How far that session got before its process ended; only `safe` is clean. */
2799
+ phase: z.enum(PARK_PHASES),
2800
+ /** When this record was last written — a phase change, or a goal opt-out flip. */
2801
+ updatedAt: z.number().int().nonnegative(),
2802
+ /**
2803
+ * Whether resuming this session may re-arm its goal. Re-arming is opt-OUT
2804
+ * because a re-armed goal continues on its own and spends the owner's money;
2805
+ * a record written before this field existed reads as `true`, which is the
2806
+ * product default (a goal session comes back running its rounds).
2807
+ *
2808
+ * Every phase write of one park episode folds through the stored record, so
2809
+ * the opt-out a user sets while the session is still draining survives the
2810
+ * `parking` and `safe` writes that follow it.
2811
+ */
2812
+ rearmGoal: z.boolean().default(true)
2813
+ })) }
2814
+ });
2815
+ /**
2816
+ * Open the registry over the mounted storage. The caller owns the handle's
2817
+ * lifetime; this registers the close as an effect on `ctx`, so the unit is
2818
+ * released with the plugin.
2819
+ * @param ctx - plugin context carrying `storageDomain`.
2820
+ * @returns the open registry.
2821
+ * @throws whatever `open` rejects with — a `StorageError` for an unreadable
2822
+ * unit, a `DomainError('invalid-record')` for a record this build cannot read.
2823
+ */
2824
+ async function openParkRegistry(ctx) {
2825
+ const domain = await ctx.storageDomain.open(parkRegistryDomain);
2826
+ ctx.effect(() => () => domain.close(), "pause: registry");
2827
+ const sessions = domain.table("sessions");
2828
+ /** The entries the cached view was built from; replaced whenever they differ. */
2829
+ let cachedFrom = [];
2830
+ let cached;
2831
+ const entryOf = (sessionId) => {
2832
+ const record = sessions.get(sessionId);
2833
+ return record === void 0 ? void 0 : {
2834
+ sessionId,
2835
+ phase: record.phase,
2836
+ updatedAt: record.updatedAt,
2837
+ rearmGoal: record.rearmGoal
2838
+ };
2839
+ };
2840
+ const entriesNow = () => [...sessions.entries()].map(([sessionId, record]) => ({
2841
+ sessionId,
2842
+ phase: record.phase,
2843
+ updatedAt: record.updatedAt,
2844
+ rearmGoal: record.rearmGoal
2845
+ }));
2846
+ return {
2847
+ list: entriesNow,
2848
+ get parked() {
2849
+ const entries = entriesNow();
2850
+ if (cached !== void 0 && entries.length === cachedFrom.length && entries.every((entry, at) => entry === cachedFrom[at])) return cached;
2851
+ cachedFrom = entries;
2852
+ cached = new Map(entries.map((entry) => [entry.sessionId, entry]));
2853
+ return cached;
2854
+ },
2855
+ get: entryOf,
2856
+ record: async (sessionId, phase) => {
2857
+ const updatedAt = Date.now();
2858
+ if (sessions.get(sessionId) !== void 0) try {
2859
+ await sessions.update(sessionId, (current) => ({
2860
+ ...current,
2861
+ phase,
2862
+ updatedAt
2863
+ }));
2864
+ return;
2865
+ } catch (error) {
2866
+ if (!(error instanceof DomainError) || error.code !== "missing-key") throw error;
2867
+ }
2868
+ await sessions.put(sessionId, {
2869
+ phase,
2870
+ updatedAt,
2871
+ rearmGoal: true
2872
+ });
2873
+ },
2874
+ setRearmGoal: async (sessionId, rearmGoal) => {
2875
+ await sessions.update(sessionId, (current) => ({
2876
+ ...current,
2877
+ rearmGoal,
2878
+ updatedAt: Date.now()
2879
+ }));
2880
+ },
2881
+ forget: async (sessionId) => {
2882
+ await sessions.delete(sessionId);
2883
+ }
2884
+ };
2885
+ }
2886
+ //#endregion
2887
+ //#region lib/types/index.js
2888
+ /**
2889
+ * Host half of dsh-pause-session.
2890
+ *
2891
+ * Mounts the pause gate — the per-session hold at the model-request waterfall —
2892
+ * the activity fold the park predicate reads, the durable park registry, the
2893
+ * session control the browser half drives, and the clean resume path that
2894
+ * answers the input a parked session left pending.
2895
+ *
2896
+ * The registry is read before anything is gated, so a session a previous
2897
+ * process left parked is held again by this one without any user action; a
2898
+ * registry this build cannot read fails the mount loudly rather than silently
2899
+ * resuming every session it named.
2900
+ * @module dsh-pause-session
2901
+ */
2902
+ /** Plugin name; the Loader row and the bundle patch both use it. */
2903
+ const name = "dsh-pause-session";
2904
+ /**
2905
+ * Required host services: the model runtime to gate, the stores the control
2906
+ * needs, the projection registry the park predicate reads turn state from, and
2907
+ * the profile's storage, which holds the pause registry.
2908
+ */
2909
+ const inject = [
2910
+ "llm",
2911
+ "commands",
2912
+ "agents",
2913
+ "sessions",
2914
+ "sessionProjections",
2915
+ "storageDomain"
2916
+ ];
2917
+ /**
2918
+ * Provider routes whose request conversion drops an empty user message, so the
2919
+ * clean resume path's wake input never reaches the model on them.
2920
+ *
2921
+ * The list is deployment knowledge, not a constant this plugin may assume:
2922
+ * whether an empty user message survives is decided by the adapter (and, for
2923
+ * the pi-ai family, by the provider API that adapter is configured for), and a
2924
+ * plugin cannot introspect an adapter. The default names the routes the shipped
2925
+ * DeepSeek adapter registers; a profile that routes a session elsewhere gets
2926
+ * the visible marker instead of a silent empty turn until it declares that
2927
+ * route here.
2928
+ */
2929
+ const DEFAULT_SILENT_WAKE_ROUTES = ["deepseek-official", "deepseek-account"];
2930
+ /** Validated plugin configuration. */
2931
+ const ConfigShape = z.object({
2932
+ /** Provider routes known to drop an empty user message; see {@link DEFAULT_SILENT_WAKE_ROUTES}. */
2933
+ silentWakeRoutes: z.array(z.string()).default([...DEFAULT_SILENT_WAKE_ROUTES]),
2934
+ /**
2935
+ * How many events of a parked session's stored log the morning list folds per
2936
+ * row, counted back from the end. The list reads a session's stop position
2937
+ * and its unknown tool outcomes, both facts about the log's end, so this
2938
+ * bounds what a panel left open costs without changing what a row says about
2939
+ * a log that fits inside it. A row whose log needs more than this is reported
2940
+ * unreadable rather than guessed at; see `./coldlog.ts`.
2941
+ */
2942
+ maxListedLogEvents: z.number().int().min(1).max(TRIAGE_LOG_MAX_EVENTS).default(TRIAGE_WINDOW_EVENTS)
2943
+ });
2944
+ /**
2945
+ * The Loader hands a row's config through this schema, and an empty row config
2946
+ * arrives as `undefined`; folding it to `{}` first is what lets the field
2947
+ * defaults above apply instead of failing the mount.
2948
+ */
2949
+ const Config = z.preprocess((value) => value ?? {}, ConfigShape);
2950
+ /**
2951
+ * Mount the durable pause registry, the pause gate, the activity fold, their
2952
+ * session control, and the clean resume path.
2953
+ *
2954
+ * The registry opens first on purpose: if it cannot be read, nothing else is
2955
+ * registered, so a failed mount leaves no half-installed control behind.
2956
+ * @param ctx - the plugin's host context.
2957
+ * @param config - validated route knowledge for the resume path's wake content.
2958
+ */
2959
+ async function apply(ctx, config = Config.parse({})) {
2960
+ const registry = await openParkRegistry(ctx);
2961
+ const lifetime = new AbortController();
2962
+ ctx.effect(() => () => {
2963
+ lifetime.abort(/* @__PURE__ */ new Error("pause: plugin unloaded"));
2964
+ }, "pause: lifetime");
2965
+ const gate = new PauseGate({ lifetime: lifetime.signal });
2966
+ ctx.effect(() => ctx.on("llm/stream", (request, next) => gate.stream(request, next)), "pause: pause gate");
2967
+ await installSessionControl(ctx, gate, await mountParkWatch(ctx), registry, {
2968
+ silentWakeRoutes: config.silentWakeRoutes,
2969
+ maxListedLogEvents: config.maxListedLogEvents
2970
+ }).adopt();
2971
+ }
2972
+ //#endregion
2973
+ export { Config, DEFAULT_SILENT_WAKE_ROUTES, apply, inject, name };
2974
+
2975
+ //# sourceMappingURL=index.js.map