dsh-pause-session 0.1.0 → 0.2.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
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
3
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
4
+ import { applyChildComposition, foldSubagentDescriptor } from "@deepseek-ai/dsh-subagent";
5
+ import { Remote, RemoteError, TypertRemoteService, principalOf } from "@deepseek-ai/dsh-typert-protocol";
6
6
  import { DomainError, defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
7
7
  //#region lib/types/history.js
8
8
  /**
@@ -72,101 +72,16 @@ function routeNamed(events) {
72
72
  //#endregion
73
73
  //#region lib/types/shared.js
74
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.
75
+ * The identities both halves of dsh-pause-session name, the phase vocabulary
76
+ * they share, and the one spelling a host log line gives a caught value.
77
77
  *
78
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.
79
+ * so this module imports nothing. The native Remote payloads are the live
80
+ * vocabulary; there is no command encoding or slash-command surface here.
96
81
  * @module dsh-pause-session/shared
97
82
  */
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
- }
83
+ /** Result copy for a press or a release the durable registry refused to record. */
84
+ const UNRECORDED_TEXT = "pause could not write the pause registry, so it did not change the pause state.";
170
85
  /**
171
86
  * One log line's text for a caught value.
172
87
  * @param error - the caught value.
@@ -227,84 +142,6 @@ function foldTreePhases(phases) {
227
142
  safe: worst === "safe"
228
143
  };
229
144
  }
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
145
  //#endregion
309
146
  //#region lib/types/coldlog.js
310
147
  /**
@@ -316,7 +153,7 @@ function encodeTriageList(list) {
316
153
  * list only has to name where the session stopped, which tool call the log
317
154
  * never heard back from, and whether an answer is still owed — all of them
318
155
  * 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
156
+ * every time the panel opens, would make a long
320
157
  * session's cost grow with a log nobody is asking about.
321
158
  *
322
159
  * WHAT THE BOUND BUYS. A stored log is append-only, so the tail is stable: the
@@ -355,8 +192,9 @@ const TRIAGE_WINDOW_EVENTS = 2e3;
355
192
  const TRIAGE_LOG_MAX_EVENTS = 1e6;
356
193
  /**
357
194
  * 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"
195
+ * legacy command records a previous surface wrote, and the pending-input
196
+ * splices the inbox writes. They are real and durable, but "it stopped right
197
+ * after the action finished"
360
198
  * is not what "where did it stop" means.
361
199
  */
362
200
  const BOOKKEEPING_EVENTS = /* @__PURE__ */ new Set([
@@ -530,7 +368,7 @@ async function eventCount(handle, signal) {
530
368
  if (await reaches(handle, mid, signal)) low = mid;
531
369
  else high = mid;
532
370
  }
533
- return low;
371
+ return await reaches(handle, 0, signal) ? low + 1 : 0;
534
372
  }
535
373
  /**
536
374
  * Read one session's stored log, bounded to the events the list's facts need.
@@ -579,155 +417,6 @@ async function coldRead(ctx, sessionId, windowEvents = TRIAGE_WINDOW_EVENTS, sig
579
417
  }
580
418
  }
581
419
  //#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
420
  //#region lib/types/tree.js
732
421
  /**
733
422
  * One session's subagent tree, read from the parent catalogs.
@@ -830,11 +519,16 @@ async function walkDescendants(ctx, rootSessionId, signal) {
830
519
  */
831
520
  const PRESS_ROUNDS = 4;
832
521
  /** Fold one shared tree walk into the two doubts this report publishes. */
833
- function readingOf(walk) {
522
+ function readingOf(walk, watch) {
834
523
  return {
835
524
  members: walk.members.map((member) => member.id),
836
525
  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")
526
+ unread: walk.unreadWhole || walk.unread.some((branch) => branch.reason !== "unsupported"),
527
+ running: walk.members.some((member) => {
528
+ if (member.mode === "continuable") return false;
529
+ const activity = watch.activity(member.id);
530
+ return activity.turnOpen || activity.runningTools.length > 0;
531
+ })
838
532
  };
839
533
  }
840
534
  /**
@@ -853,6 +547,7 @@ function mountGlobalPause(ctx, watch, registry, deps) {
853
547
  * instead of quietly leaving them out of a park it then calls safe.
854
548
  */
855
549
  const unrecorded = /* @__PURE__ */ new Set();
550
+ const notResumable = /* @__PURE__ */ new Set();
856
551
  /** Record or clear one session's "pressed and the registry refused" state. */
857
552
  const markUnrecorded = (sessionId, flag) => {
858
553
  if (flag) unrecorded.add(sessionId);
@@ -879,15 +574,18 @@ function mountGlobalPause(ctx, watch, registry, deps) {
879
574
  * answer; the cache keeps that read from repeating a catalog round-trip it
880
575
  * has already paid for.
881
576
  */
882
- const walkTree = async (sessionId, signal) => {
577
+ const walkTree = async (sessionId, signal, shared) => {
883
578
  signal?.throwIfAborted();
579
+ const provided = shared?.get(sessionId);
580
+ if (provided !== void 0) return readingOf(provided, watch);
884
581
  const cached = walks.get(sessionId);
885
582
  if (cached !== void 0) return cached;
886
583
  const reading = ctx.get("subagents") === void 0 ? {
887
584
  members: [],
888
585
  unresumable: false,
889
- unread: true
890
- } : readingOf(await walkDescendants(ctx, sessionId, signal));
586
+ unread: true,
587
+ running: false
588
+ } : readingOf(await walkDescendants(ctx, sessionId, signal), watch);
891
589
  walks.set(sessionId, reading);
892
590
  return reading;
893
591
  };
@@ -934,7 +632,7 @@ function mountGlobalPause(ctx, watch, registry, deps) {
934
632
  * The park also names the session it belongs to, so a client can address its
935
633
  * reads there rather than at whatever session it is showing.
936
634
  */
937
- const status = async (signal) => {
635
+ const status = async (signal, sharedWalks) => {
938
636
  const phases = /* @__PURE__ */ new Map();
939
637
  for (const [sessionId, record] of registry.parked) phases.set(sessionId, {
940
638
  phase: record.phase,
@@ -968,7 +666,7 @@ function mountGlobalPause(ctx, watch, registry, deps) {
968
666
  const unreadTrees = [];
969
667
  const owners = [...phases.keys()];
970
668
  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)]));
669
+ const trees = await Promise.all(owners.map(async (sessionId) => [sessionId, await walkTree(sessionId, signal, sharedWalks)]));
972
670
  for (const [sessionId, tree] of trees) {
973
671
  if (tree.unresumable) unresumable.push(String(sessionId));
974
672
  if (tree.unread) unreadTrees.push(String(sessionId));
@@ -978,14 +676,14 @@ function mountGlobalPause(ctx, watch, registry, deps) {
978
676
  if (ctx.get("jobs") === void 0) blind.push("jobs");
979
677
  const registered = phases.size;
980
678
  return {
981
- verdict: registered === 0 ? "idle" : safe === registered && unreadTrees.length === 0 ? "safe" : "waiting",
679
+ verdict: registered === 0 ? "idle" : safe === registered && unreadTrees.length === 0 && !trees.some(([, tree]) => tree.running) ? "safe" : "waiting",
982
680
  registered,
983
681
  safe,
984
682
  ...parkedAt === void 0 ? {} : { parkedAt: String(parkedAt) },
985
683
  draining: draining.sort(),
986
684
  parking,
987
685
  stalled: stalled.sort(),
988
- unresumable: unresumable.sort(),
686
+ unresumable: [...new Set(unresumable)].sort(),
989
687
  unreadTrees: unreadTrees.sort(),
990
688
  jobs: liveJobs(owners),
991
689
  blind
@@ -1000,104 +698,261 @@ function mountGlobalPause(ctx, watch, registry, deps) {
1000
698
  signal?.throwIfAborted();
1001
699
  switch (await deps.press(sessionId, signal)) {
1002
700
  case "registered":
701
+ notResumable.delete(sessionId);
1003
702
  markUnrecorded(sessionId, false);
1004
703
  deps.look(sessionId);
1005
704
  break;
1006
705
  case "unrecordable":
1007
706
  markUnrecorded(sessionId, true);
1008
707
  break;
708
+ case "not-resumable":
709
+ notResumable.add(sessionId);
710
+ break;
1009
711
  default: break;
1010
712
  }
1011
713
  }
1012
714
  }
1013
- const answer = encodeGlobalStatus(await status(signal));
1014
- return unrecorded.size === 0 ? {
715
+ const aggregate = await status(signal);
716
+ const answer = aggregate.verdict === "safe" ? "Every session is paused." : aggregate.verdict === "idle" ? "Nothing is running." : "Pausing is in progress.";
717
+ const blocked = notResumable.size;
718
+ return unrecorded.size === 0 && blocked === 0 ? {
1015
719
  kind: "success",
1016
720
  text: answer
1017
721
  } : {
1018
722
  kind: "error",
1019
- text: answer
723
+ text: `${answer} ${String(blocked)} session${blocked === 1 ? "" : "s"} could not be paused because its route is not resumable.`
1020
724
  };
1021
725
  },
1022
- readStatus: async (signal) => ({
1023
- kind: "success",
1024
- text: encodeGlobalStatus(await status(signal))
1025
- }),
726
+ readState: async (signal, walks) => await status(signal, walks),
1026
727
  markUnrecorded
1027
728
  };
1028
729
  }
1029
730
  //#endregion
1030
- //#region lib/types/resume.js
731
+ //#region lib/types/release-order.js
1031
732
  /**
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.
733
+ * The order a tree release walks: parent before child, shallowest first.
734
+ * @module dsh-pause-session/release-order
735
+ */
736
+ /**
737
+ * Sequence a batch release parent before child.
738
+ * @param ctx - host context carrying the optional subagent catalog.
739
+ * @param sessionIds - every recorded session, in registry order.
740
+ * @param signal - cancellation for each catalog read.
741
+ * @returns the parent of each child, and one list per depth level.
742
+ */
743
+ async function releaseOrder(ctx, sessionIds, signal) {
744
+ const owned = new Set(sessionIds);
745
+ const parentOf = /* @__PURE__ */ new Map();
746
+ const subagents = ctx.get("subagents");
747
+ if (subagents !== void 0) for (const sessionId of sessionIds) {
748
+ signal?.throwIfAborted();
749
+ try {
750
+ for (const child of await subagents.listChildren(sessionId, signal)) if (owned.has(child.id)) parentOf.set(child.id, sessionId);
751
+ } catch (error) {
752
+ if (signal?.aborted === true) throw error;
753
+ ctx.logger.warn(`pause: could not read the children of "${sessionId}": ${describeError(error)}`);
754
+ }
755
+ }
756
+ const depthOf = (sessionId) => {
757
+ let steps = 0;
758
+ const seen = /* @__PURE__ */ new Set([sessionId]);
759
+ for (let at = parentOf.get(sessionId); at !== void 0; at = parentOf.get(at)) {
760
+ if (seen.has(at)) break;
761
+ seen.add(at);
762
+ steps += 1;
763
+ }
764
+ return steps;
765
+ };
766
+ const levels = /* @__PURE__ */ new Map();
767
+ for (const sessionId of sessionIds) {
768
+ const level = depthOf(sessionId);
769
+ const bucket = levels.get(level);
770
+ if (bucket === void 0) levels.set(level, [sessionId]);
771
+ else bucket.push(sessionId);
772
+ }
773
+ return {
774
+ parents: parentOf,
775
+ levels: [...levels.keys()].sort((left, right) => left - right).map((level) => levels.get(level) ?? [])
776
+ };
777
+ }
778
+ //#endregion
779
+ //#region lib/types/composition.js
780
+ /**
781
+ * Rebuild the scoped Agent composition a parked Session ran under.
1041
782
  *
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.
783
+ * Activation is not only a persisted Session: the Agent preset supplies the
784
+ * scoped tools, prompt sections, skills, and — for a subagent child — the
785
+ * delegation context, persona, and tool restriction that were in force before
786
+ * the park. A resume that loads the log without that setup starts a different
787
+ * Agent on the same history.
1049
788
  *
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.
789
+ * The preset a Session runs is the `agentPreset` projection, never the
790
+ * creation-time header alone: a blank Session may select another preset before
791
+ * its first turn, and the projection records the selection that actually ran.
792
+ * A child with no independent selection joins its live parent's preset
793
+ * generation; one that selected independently mounts the projected preset.
794
+ * The durable child descriptor supplies the persona, tool filter, and route
795
+ * that are reapplied only on cold resume.
1054
796
  *
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
797
+ * Both services are optional. A profile without `agentPresets` has no scoped
798
+ * preset composition to restore, and this module leaves the resume unchanged.
799
+ * A profile that mounts one but cannot read the Session projection cannot
800
+ * prove what composition the Session ran, so it fails the resume instead of
801
+ * silently starting a different Agent.
802
+ * @module dsh-pause-session/composition
1063
803
  */
1064
804
  /**
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.
805
+ * A stored subagent route that no resume may reactivate.
806
+ *
807
+ * A one-shot child is terminal by contract; activating it as a root Agent
808
+ * would turn a finished delegation into an ordinary conversation that happens
809
+ * to share its Session id.
1068
810
  */
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.
811
+ var UnresumableSessionError = class extends Error {
812
+ constructor(sessionId) {
813
+ super(`subagent session "${String(sessionId)}" has a one-shot route and cannot be resumed`);
814
+ this.name = "UnresumableSessionError";
815
+ }
816
+ };
817
+ /**
818
+ * Whether the child's own log selected a preset before its descriptor.
819
+ *
820
+ * The creation-time selection precedes the first `subagent/descriptor`; a
821
+ * selection after the descriptor belongs to the child's own later history and
822
+ * does not decide which composition cold resume joins. This mirrors the
823
+ * subagent runtime's own cold-resume rule.
824
+ * @param events - the child's own log suffix, without the fork-inherited prefix.
825
+ * @returns whether the child carries an independent preset selection.
826
+ */
827
+ function hasIndependentPresetSelection(events) {
828
+ for (const event of events) {
829
+ if (event.type === "subagent/descriptor") return false;
830
+ if (event.type === "agent-preset/selected") return true;
831
+ }
832
+ return false;
833
+ }
834
+ /**
835
+ * Whether a local Session may be parked under a promise of a later resume.
836
+ *
837
+ * A normal root Session has no child descriptor and is resumable. A local
838
+ * subagent child is resumable only when its own durable descriptor is
839
+ * `continuable`; a historical `one-shot` child is terminal and must never be
840
+ * registered as a park the morning path would later have to refuse. External
841
+ * executions have no local Session, so callers never reach this check.
842
+ *
843
+ * A profile without the optional Session-query service cannot prove the
844
+ * descriptor's mode; it keeps the previous permissive behavior rather than
845
+ * dropping every subagent. The web profile mounts the query service.
846
+ * @param ctx - host context carrying the optional Session-query service.
847
+ * @param sessionId - the local Session to inspect.
848
+ * @param signal - cancellation for the observation read.
849
+ * @returns whether a park of this Session may be resumed.
850
+ * @throws the cancellation reason when `signal` aborts the read.
851
+ */
852
+ async function isResumableSession(ctx, sessionId, signal) {
853
+ const query = ctx.get("sessionQuery");
854
+ if (query === void 0) return true;
855
+ const observation = await query.observeSession(sessionId, { ...signal === void 0 ? {} : { signal } });
856
+ try {
857
+ return foldSubagentDescriptor(observation.events.slice(observation.inheritedEventCount))?.mode !== "one-shot";
858
+ } finally {
859
+ observation[Symbol.dispose]();
860
+ }
861
+ }
862
+ /**
863
+ * Read one parked Session's stored composition and build the resume options.
864
+ *
865
+ * The returned `setup` belongs to the unpublished Agent scope, so all
866
+ * registrations exist before `agent/created`. For a root Session the setup
867
+ * mounts the Session's projected preset, or the profile's default when the
868
+ * Session never selected one. For a child under a live parent it applies the
869
+ * child composition: an independent preset mounts separately, while an
870
+ * inherited composition joins the parent's current generation; the historical
871
+ * descriptor's persona and tool filter are reapplied in both cases.
872
+ * @param ctx - host context carrying the optional preset and Session-query services.
873
+ * @param sessionId - the parked Session being resumed.
874
+ * @param parent - the live direct parent, when a child is resumed under it.
875
+ * @param signal - cancellation for the observation read.
876
+ * @returns the Agent options and setup to pass to `agents.resume`.
877
+ * @throws {UnresumableSessionError} when the stored child route is one-shot.
878
+ * @throws when a mounted preset roster cannot read the Session projection, or
879
+ * a recorded preset cannot be restored.
880
+ */
881
+ async function restoreComposition(ctx, sessionId, parent, signal) {
882
+ const presets = ctx.get("agentPresets");
883
+ const query = ctx.get("sessionQuery");
884
+ if (query === void 0) {
885
+ if (presets !== void 0) throw new Error("resuming a composed Agent requires the session-query service");
886
+ return {};
887
+ }
888
+ const observation = await query.observeSession(sessionId, { ...signal === void 0 ? {} : { signal } });
889
+ try {
890
+ const projections = observation.projections;
891
+ const ownEvents = observation.events.slice(observation.inheritedEventCount);
892
+ const descriptor = foldSubagentDescriptor(ownEvents);
893
+ if (descriptor?.mode === "one-shot") throw new UnresumableSessionError(sessionId);
894
+ if (presets === void 0 || projections === void 0) {
895
+ if (presets !== void 0) throw new Error("resuming a composed Agent requires projected Session state");
896
+ return {};
897
+ }
898
+ if (parent === void 0) {
899
+ const resolved = await presets.resolve(projections.values.agentPreset ?? void 0);
900
+ return { setup: async (agentCtx) => {
901
+ await presets.mount(agentCtx, resolved.id);
902
+ } };
903
+ }
904
+ const independentlySelected = hasIndependentPresetSelection(ownEvents);
905
+ const presetId = independentlySelected ? projections.values.agentPreset : void 0;
906
+ if (independentlySelected && typeof presetId !== "string") throw new Error(`subagent "${String(sessionId)}" has no projected agent preset to restore`);
907
+ const composition = {
908
+ ...typeof presetId === "string" ? { agentPreset: presetId } : {},
909
+ ...descriptor?.mode === "continuable" && descriptor.persona !== void 0 ? { persona: descriptor.persona } : {},
910
+ ...descriptor?.mode === "continuable" && descriptor.toolFilter !== void 0 ? { toolFilter: descriptor.toolFilter } : {}
911
+ };
912
+ const agentOptions = descriptor?.mode === "continuable" ? {
913
+ ...descriptor.agentProvider === void 0 ? {} : { provider: descriptor.agentProvider },
914
+ ...descriptor.agentModel === void 0 ? {} : { model: descriptor.agentModel },
915
+ ...descriptor.agentReasoningEffort === void 0 ? {} : { reasoningEffort: descriptor.agentReasoningEffort }
916
+ } : void 0;
917
+ return {
918
+ ...agentOptions === void 0 ? {} : { agentOptions },
919
+ setup: async (agentCtx) => {
920
+ await applyChildComposition(agentCtx, parent, composition);
921
+ }
922
+ };
923
+ } finally {
924
+ observation[Symbol.dispose]();
925
+ }
926
+ }
927
+ //#endregion
928
+ //#region lib/types/resume-text.js
929
+ /**
930
+ * User-facing copy and result shapes for the clean resume and tree release.
931
+ * @module dsh-pause-session/resume-text
932
+ */
933
+ /**
934
+ * The one wake content a route that keeps an empty user message is given.
1075
935
  */
936
+ const WAKE_MARKER = "[pause: resumed from a pause]";
937
+ /** Result copy for a resumed session; the model never sees it. */
938
+ const RESUMED_TEXT = "This session resumed from where it paused.";
939
+ /** Result copy for a registered session whose park did not finish cleanly. */
1076
940
  const UNCLEAN_TEXT = "pause did not resume this session: its pause did not finish cleanly, so it needs your decision.";
1077
941
  /** Result copy when nothing is parked under that session. */
1078
942
  const NOT_PARKED_TEXT = "pause has no pause recorded for this session.";
1079
943
  /** Result copy for a session that is parked but could not be loaded back. */
1080
944
  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. */
945
+ /** Result copy for a one-shot subagent route. */
1082
946
  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
- */
947
+ /** Result copy for the review decision. */
1089
948
  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
949
  /** Copy for one goal opt-out change. */
1093
950
  const REARM_GOAL_ON_TEXT = "pause will re-open this session's goal when it resumes it.";
1094
951
  const REARM_GOAL_OFF_TEXT = "pause will leave this session's goal closed when it resumes it.";
1095
952
  /**
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.
953
+ * How one tree-wide release answers the person who pressed it.
1099
954
  * @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`.
955
+ * @returns one sentence about what came back and what did not.
1101
956
  */
1102
957
  function summarizeTreeRelease(released) {
1103
958
  const resumed = released.filter((attempt) => attempt.resumed).length;
@@ -1107,30 +962,23 @@ function summarizeTreeRelease(released) {
1107
962
  const named = refused.filter((attempt) => attempt.refusal === "not-clean").map((attempt) => `"${String(attempt.sessionId)}"`);
1108
963
  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
964
  }
965
+ //#endregion
966
+ //#region lib/types/resume-activation.js
1110
967
  /**
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.
968
+ * Reactivate one parked session: restore its composition, give it the route its
969
+ * log recorded, deliver the wake it owes, and only then drop the gate and record.
970
+ * @module dsh-pause-session/resume-activation
971
+ */
972
+ /**
973
+ * Build the activation closure.
974
+ * @param ctx - host context carrying the agent store and goal service.
975
+ * @param gate - the pause gate released after activation.
976
+ * @param registry - the durable list the release forgets from.
1115
977
  * @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.
978
+ * @returns the activation methods the resume path uses.
1118
979
  */
1119
- function installResumePath(ctx, gate, registry, options, deps) {
1120
- /** The route the session's next request will use, as its own log recorded it. */
980
+ function createActivator(ctx, gate, registry, options) {
1121
981
  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
982
  const ensureRoute = (agent) => {
1135
983
  if (agent.model.current() !== void 0) return;
1136
984
  const config = routeOf(agent.session);
@@ -1141,22 +989,10 @@ function installResumePath(ctx, gate, registry, options, deps) {
1141
989
  ...config.reasoningEffort === void 0 ? {} : { reasoningEffort: config.reasoningEffort }
1142
990
  });
1143
991
  };
1144
- /** What one wake input carries for the route in use. */
1145
992
  const wakeContent = (provider) => options.silentWakeRoutes.includes(provider) ? [] : [{
1146
993
  type: "text",
1147
994
  text: WAKE_MARKER
1148
995
  }];
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
996
  const wake = (agent, request) => {
1161
997
  if (request.kind === "none") return;
1162
998
  if (gate.isHeld(agent.id) || agent.status !== "idle") return;
@@ -1172,16 +1008,6 @@ function installResumePath(ctx, gate, registry, options, deps) {
1172
1008
  source: { kind: "pause" }
1173
1009
  }));
1174
1010
  };
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
1011
  const rearmGoal = (agent) => {
1186
1012
  const goals = ctx.get("goals");
1187
1013
  if (goals === void 0) return;
@@ -1197,23 +1023,6 @@ function installResumePath(ctx, gate, registry, options, deps) {
1197
1023
  ctx.logger.warn(`pause: could not re-open the goal of "${agent.id}": ${describeError(error)}`);
1198
1024
  }
1199
1025
  };
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
1026
  const activate = async (sessionId, request, rearm, parentId, signal) => {
1218
1027
  const parent = parentId === void 0 ? void 0 : ctx.agents.get(parentId);
1219
1028
  let agent;
@@ -1258,7 +1067,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
1258
1067
  refusal: "unrecorded",
1259
1068
  answer: {
1260
1069
  kind: "error",
1261
- text: UNRECORDED_TEXT$1
1070
+ text: UNRECORDED_TEXT
1262
1071
  }
1263
1072
  };
1264
1073
  }
@@ -1273,6 +1082,60 @@ function installResumePath(ctx, gate, registry, options, deps) {
1273
1082
  }
1274
1083
  };
1275
1084
  };
1085
+ return {
1086
+ activate,
1087
+ ensureRoute,
1088
+ wake,
1089
+ rearmGoal
1090
+ };
1091
+ }
1092
+ //#endregion
1093
+ //#region lib/types/resume.js
1094
+ /**
1095
+ * The clean resume path: bring back a session a previous process parked, and
1096
+ * let it answer the input that was already pending when it stopped.
1097
+ *
1098
+ * A resume invents no turn. At the park point the loop had claimed the
1099
+ * session's pending input onto the surface and built the request the gate then
1100
+ * held; after a restart that input is still the last thing the session says and
1101
+ * the model still owes it an answer. What is missing is only a reason for the
1102
+ * loop to build a request again, and the documented way to ask for one is a
1103
+ * wake input — a user message the loop claims.
1104
+ *
1105
+ * The wake is invisible only on the routes the composition declares in
1106
+ * `silentWakeRoutes`, whose conversion drops an empty user message. On every
1107
+ * other route the loop's request carries one visible marker instead, and the
1108
+ * marker reaches the model; that is an accepted cost of resuming at all.
1109
+ * Whether an empty user message survives is a property of the route's
1110
+ * conversion rather than of the loop, so the route is resolved per session —
1111
+ * from the request header the session itself persisted.
1112
+ *
1113
+ * The wake message is attributed to this plugin, never to the user: it is text
1114
+ * this module synthesized on the session's behalf, in the session's own log and
1115
+ * in front of the model, and a downstream consumer has to be able to tell it
1116
+ * apart from something the human typed.
1117
+ *
1118
+ * Only a cleanly parked session takes this path. A record that never reached
1119
+ * the park point, or whose flush did not finish, describes work whose durable
1120
+ * state nobody knows; waking it would spend its owner's money on a guess, and
1121
+ * deciding those sessions is the morning list's job, not this path's. That
1122
+ * holds whether or not the recorded session happens to be live in this process:
1123
+ * liveness is not the park outcome, and a session can be live without ever
1124
+ * having stopped.
1125
+ * @module dsh-pause-session/resume
1126
+ */
1127
+ /**
1128
+ * Build the clean resume path.
1129
+ * @param ctx - host context carrying the agent store, the session store, and the goal service when one is mounted.
1130
+ * @param gate - the pause gate the resume releases.
1131
+ * @param registry - the durable list this path reads and forgets from.
1132
+ * @param options - the route knowledge the wake content depends on.
1133
+ * @param deps - the control-owned release order and the adopted-pause fact.
1134
+ * @returns the path the session control exposes.
1135
+ */
1136
+ function installResumePath(ctx, gate, registry, options, deps) {
1137
+ /** The route the session's next request will use, as its own log recorded it. */
1138
+ const { activate, ensureRoute, wake, rearmGoal } = createActivator(ctx, gate, registry, options);
1276
1139
  /**
1277
1140
  * Load one unclean session back from its stored log.
1278
1141
  *
@@ -1332,7 +1195,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
1332
1195
  refusal: "unrecorded",
1333
1196
  answer: {
1334
1197
  kind: "error",
1335
- text: UNRECORDED_TEXT$1
1198
+ text: UNRECORDED_TEXT
1336
1199
  }
1337
1200
  };
1338
1201
  }
@@ -1412,7 +1275,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
1412
1275
  refusal: "unrecorded",
1413
1276
  answer: {
1414
1277
  kind: "error",
1415
- text: UNRECORDED_TEXT$1
1278
+ text: UNRECORDED_TEXT
1416
1279
  }
1417
1280
  };
1418
1281
  }
@@ -1486,41 +1349,6 @@ function installResumePath(ctx, gate, registry, options, deps) {
1486
1349
  * @param signal - cancellation for each catalog read.
1487
1350
  * @returns the parent of each child, and one list per depth level, shallowest first.
1488
1351
  */
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
1352
  return {
1525
1353
  resumeSession,
1526
1354
  decideUnclean,
@@ -1535,7 +1363,10 @@ function installResumePath(ctx, gate, registry, options, deps) {
1535
1363
  text: NOT_PARKED_TEXT
1536
1364
  }
1537
1365
  };
1538
- const cancelling = registered.every((member) => registry.get(member)?.phase !== "safe");
1366
+ const cancelling = registered.every((member) => {
1367
+ const record = registry.get(member);
1368
+ return record !== void 0 && record.phase !== "safe" && !deps.adopted(member);
1369
+ });
1539
1370
  const released = [];
1540
1371
  if (cancelling) for (const member of registered) {
1541
1372
  signal?.throwIfAborted();
@@ -1552,7 +1383,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
1552
1383
  });
1553
1384
  }
1554
1385
  else {
1555
- const release = await releaseOrder(registered, signal);
1386
+ const release = await releaseOrder(ctx, registered, signal);
1556
1387
  for (const level of release.levels) {
1557
1388
  signal?.throwIfAborted();
1558
1389
  for (const member of level) {
@@ -1592,7 +1423,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
1592
1423
  };
1593
1424
  },
1594
1425
  resumeParked: async (signal) => {
1595
- const release = await releaseOrder(registry.list().map((entry) => entry.sessionId), signal);
1426
+ const release = await releaseOrder(ctx, registry.list().map((entry) => entry.sessionId), signal);
1596
1427
  const attempts = [];
1597
1428
  for (const level of release.levels) {
1598
1429
  signal?.throwIfAborted();
@@ -1607,7 +1438,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
1607
1438
  ctx.logger.warn(`pause: could not record the goal opt-out of "${sessionId}": ${describeError(error)}`);
1608
1439
  return {
1609
1440
  kind: "error",
1610
- text: UNRECORDED_TEXT$1
1441
+ text: UNRECORDED_TEXT
1611
1442
  };
1612
1443
  }
1613
1444
  return {
@@ -1632,8 +1463,8 @@ function installResumePath(ctx, gate, registry, options, deps) {
1632
1463
  * stored log through a read handle that claims no ownership and writes nothing,
1633
1464
  * so asking what happened cannot itself change what happened.
1634
1465
  *
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
1466
+ * WHAT ONE READ COSTS. The list is read when the panel opens and when a native
1467
+ * action changes the park, and what it publishes is about the end of a log that
1637
1468
  * only grows, so `./coldlog.ts` bounds each row's read to that end. A row whose
1638
1469
  * facts the bound cannot hold reports itself unreadable rather than publishing
1639
1470
  * a fragment.
@@ -1656,10 +1487,10 @@ function installResumePath(ctx, gate, registry, options, deps) {
1656
1487
  * reported; a withheld promise costs a user nothing, a broken one spends their
1657
1488
  * money.
1658
1489
  *
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.
1490
+ * WHO MAY DECIDE. A decision carries the calling session and its target as wire
1491
+ * input, so this list acts only on rows its most recent read published to the
1492
+ * calling session: the caller's own read is what makes a target actionable.
1493
+ * Anything else is refused, not looked up.
1663
1494
  *
1664
1495
  * TWO OF THE FIVE VERBS PUT THE SESSION BACK ON THE CLEAN PATH. Continue and
1665
1496
  * continue-with-the-note go through `ResumePath.decideUnclean`, which activates
@@ -1677,6 +1508,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
1677
1508
  * parked session.
1678
1509
  * @module dsh-pause-session/triage
1679
1510
  */
1511
+ /** Result copy for a decision the list itself could not carry out. */
1680
1512
  /** Result copy for leaving one listed session alone. */
1681
1513
  const TRIAGE_LEFT_TEXT = "pause left this session paused; it is still on the list.";
1682
1514
  /** Result copy for a decision aimed at a session that is not on the list. */
@@ -1686,7 +1518,7 @@ const TRIAGE_NOT_LISTED_TEXT = "pause has no unfinished pause for this session."
1686
1518
  * The list is the only thing that legitimizes a target, so a target nobody read
1687
1519
  * is refused rather than acted on.
1688
1520
  */
1689
- const TRIAGE_UNSHOWN_TEXT = "pause did not act: this Session has not been shown that row. Read the list first (/pause-triage).";
1521
+ const TRIAGE_UNSHOWN_TEXT = "pause did not act: this Session has not been shown that row. Read the decision list first.";
1690
1522
  /**
1691
1523
  * Model-facing text that goes with a wake when the user chooses to continue
1692
1524
  * with a note.
@@ -1743,7 +1575,7 @@ function rowOf(ctx, entry, events, complete) {
1743
1575
  function mountTriageList(ctx, registry, resumePath, windowEvents = TRIAGE_WINDOW_EVENTS) {
1744
1576
  /**
1745
1577
  * 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
1578
+ * names its target as wire input, and the channel carries no
1747
1579
  * origin of its own, so this is what ties a target to the list the user read:
1748
1580
  * only a row this caller has been shown is acted on.
1749
1581
  */
@@ -1806,201 +1638,105 @@ function mountTriageList(ctx, registry, resumePath, windowEvents = TRIAGE_WINDOW
1806
1638
  };
1807
1639
  }
1808
1640
  //#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
- */
1641
+ //#region lib/types/snapshot.js
1865
1642
  /**
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.
1643
+ * Pure projection of the durable park and each live tree into the native
1644
+ * snapshot. This module owns the phase fold and the goal opt-out; it does not
1645
+ * press, release, or decide anything.
1646
+ * @module dsh-pause-session/snapshot
1872
1647
  */
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.`;
1648
+ /** The phase a durable record holds for one session, live or adopted. */
1649
+ function recordedPhase(deps, sessionId) {
1650
+ return deps.marks.get(sessionId)?.phase ?? deps.registry.get(sessionId)?.phase;
1651
+ }
1652
+ /** Fold one session's mark and its registered descendants into the header reading. */
1653
+ async function treeStatus(deps, sessionId, mark, signal, knownWalk) {
1654
+ const walk = knownWalk ?? await walkDescendants(deps.ctx, sessionId, signal);
1655
+ const phases = [mark.phase];
1656
+ const extraRunning = [];
1657
+ for (const member of walk.members) {
1658
+ const phase = recordedPhase(deps, member.id);
1659
+ if (phase !== void 0) {
1660
+ phases.push(phase);
1661
+ continue;
1662
+ }
1663
+ if (member.mode !== "continuable") {
1664
+ const activity = deps.watch.activity(member.id);
1665
+ if (activity.turnOpen || activity.runningTools.length > 0) {
1666
+ phases.push("draining");
1667
+ extraRunning.push(...activity.runningTools);
1668
+ }
1669
+ }
1670
+ }
1671
+ for (let at = 0; at < walk.unread.length + (walk.unreadWhole ? 1 : 0); at += 1) phases.push("parking");
1672
+ const folded = foldTreePhases(phases);
1673
+ const runningTools = [
1674
+ ...mark.phase === "draining" ? deps.watch.activity(sessionId).runningTools : [],
1675
+ ...walk.members.flatMap((member) => recordedPhase(deps, member.id) === "draining" ? deps.watch.activity(member.id).runningTools : []),
1676
+ ...extraRunning
1677
+ ];
1678
+ const record = deps.registry.get(sessionId);
1679
+ return {
1680
+ phase: folded.phase,
1681
+ safe: folded.safe,
1682
+ runningTools,
1683
+ ...record === void 0 ? {} : { rearmGoal: record.rearmGoal }
1684
+ };
1875
1685
  }
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
1686
  /**
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.
1687
+ * Read every live mark and the whole-app aggregate in one pass.
1688
+ * @param deps - the control-owned marks, watch, registry, and aggregate reader.
1689
+ * @param signal - cancellation for the tree walks and aggregate.
1690
+ * @returns the native snapshot.
1892
1691
  */
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.`;
1692
+ async function readSnapshot(deps, signal) {
1693
+ const sessions = {};
1694
+ const walks = /* @__PURE__ */ new Map();
1695
+ await Promise.all([...deps.marks].map(async ([sessionId, mark]) => {
1696
+ const walk = await walkDescendants(deps.ctx, sessionId, signal);
1697
+ if (deps.ctx.get("subagents") !== void 0) walks.set(sessionId, walk);
1698
+ sessions[String(sessionId)] = await treeStatus(deps, sessionId, mark, signal, walk);
1699
+ }));
1700
+ return {
1701
+ global: await deps.readGlobal(signal, walks),
1702
+ sessions
1703
+ };
1895
1704
  }
1705
+ //#endregion
1706
+ //#region lib/types/park-state.js
1896
1707
  /**
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.
1708
+ * One session's park episode: the durable phase writes, the activity walk that
1709
+ * settles them, and the press/release lifecycle. The control composes this with
1710
+ * the aggregate, the resume path, and the decision list.
1711
+ * @module dsh-pause-session/park-state
1901
1712
  */
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
1713
  /**
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.
1714
+ * Mount the park episode state machine over the gate, registry, and activity fold.
1715
+ * @param ctx - host context carrying the session store and logger.
1716
+ * @param gate - the pause gate this state drives.
1717
+ * @param registry - the durable pause list every phase write goes to.
1718
+ * @param watch - the activity fold the park predicate reads.
1719
+ * @returns the park lifecycle the control composes.
1928
1720
  */
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. */
1721
+ function installParkState(ctx, gate, registry, watch) {
1962
1722
  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
- */
1723
+ const unrecorded = /* @__PURE__ */ new Set();
1971
1724
  const unheld = /* @__PURE__ */ new Set();
1972
- /** Bumped by every mark and every cancel; a stale generation publishes nothing. */
1725
+ const listeners = /* @__PURE__ */ new Set();
1726
+ let notification;
1973
1727
  let generation = 0;
1728
+ /** One snapshot per burst of host changes, not one per phase write. */
1729
+ const notify = () => {
1730
+ if (notification !== void 0) return;
1731
+ notification = setTimeout(() => {
1732
+ notification = void 0;
1733
+ for (const listener of [...listeners]) listener();
1734
+ }, 20);
1735
+ };
1974
1736
  const drop = (sessionId) => {
1975
1737
  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
- });
1738
+ notify();
1982
1739
  };
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
1740
  const commit = async (sessionId, mark, phase) => {
2005
1741
  try {
2006
1742
  await registry.record(sessionId, phase);
@@ -2010,124 +1746,14 @@ function installSessionControl(ctx, gate, watch, registry, options) {
2010
1746
  }
2011
1747
  const changed = mark.phase !== phase;
2012
1748
  mark.phase = phase;
2013
- if (changed) markers.get(sessionId)?.announce();
1749
+ if (changed) notify();
2014
1750
  return true;
2015
1751
  };
2016
- /** Whether this session is standing still at the park point. */
2017
1752
  const parked = (sessionId) => {
2018
1753
  const activity = watch.activity(sessionId);
2019
1754
  if (activity.runningTools.length > 0) return false;
2020
1755
  return gate.isHeld(sessionId) || !activity.turnOpen;
2021
1756
  };
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
1757
  const advance = (sessionId) => {
2132
1758
  const mark = marks.get(sessionId);
2133
1759
  if (mark === void 0 || mark.phase !== "draining" || mark.advancing) return false;
@@ -2136,20 +1762,9 @@ function installSessionControl(ctx, gate, watch, registry, options) {
2136
1762
  mark.advancing = true;
2137
1763
  return true;
2138
1764
  };
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
1765
  const settle = async (sessionId, attempt) => {
2150
1766
  const mark = marks.get(sessionId);
2151
1767
  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
1768
  const owned = () => marks.get(sessionId) === mark && mark.generation === attempt;
2154
1769
  const session = ctx.sessions.get(sessionId);
2155
1770
  try {
@@ -2160,7 +1775,10 @@ function installSessionControl(ctx, gate, watch, registry, options) {
2160
1775
  await commit(sessionId, mark, "parking");
2161
1776
  if (!owned()) return;
2162
1777
  try {
2163
- await ctx.sessions.flush(session);
1778
+ if (!await ctx.sessions.flush(session)) {
1779
+ await commit(sessionId, mark, "flush-failed");
1780
+ return;
1781
+ }
2164
1782
  } catch (error) {
2165
1783
  if (!owned()) return;
2166
1784
  ctx.logger.warn(`pause: flush failed for "${sessionId}": ${describeError(error)}`);
@@ -2173,33 +1791,12 @@ function installSessionControl(ctx, gate, watch, registry, options) {
2173
1791
  if (marks.get(sessionId) === mark && mark.phase === "draining") mark.advancing = false;
2174
1792
  }
2175
1793
  };
2176
- /** Look at one marked session and act on what it is doing. */
2177
1794
  const look = (sessionId) => {
2178
- publish(sessionId);
1795
+ if (marks.has(sessionId)) notify();
2179
1796
  if (!advance(sessionId)) return;
2180
1797
  const attempt = marks.get(sessionId)?.generation;
2181
1798
  if (attempt !== void 0) settle(sessionId, attempt);
2182
1799
  };
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
1800
  const press = async (sessionId, signal, knownResumable = false) => {
2204
1801
  if (ctx.sessions.get(sessionId) === void 0) return "not-live";
2205
1802
  if (!knownResumable && !await isResumableSession(ctx, sessionId, signal)) return "not-resumable";
@@ -2213,12 +1810,132 @@ function installSessionControl(ctx, gate, watch, registry, options) {
2213
1810
  marks.set(sessionId, mark);
2214
1811
  if (await commit(sessionId, mark, "draining")) {
2215
1812
  unheld.delete(sessionId);
1813
+ notify();
2216
1814
  return "registered";
2217
1815
  }
2218
1816
  drop(sessionId);
2219
1817
  gate.resume(sessionId);
2220
1818
  return "unrecordable";
2221
1819
  };
1820
+ const release = async (sessionId, text) => {
1821
+ if (!gate.isPaused(sessionId)) return {
1822
+ kind: "success",
1823
+ text
1824
+ };
1825
+ try {
1826
+ await registry.forget(sessionId);
1827
+ } catch (error) {
1828
+ ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
1829
+ return {
1830
+ kind: "error",
1831
+ text: UNRECORDED_TEXT
1832
+ };
1833
+ }
1834
+ gate.resume(sessionId);
1835
+ notify();
1836
+ return {
1837
+ kind: "success",
1838
+ text
1839
+ };
1840
+ };
1841
+ const sync = (sessionId) => {
1842
+ if (!gate.isPaused(sessionId)) {
1843
+ drop(sessionId);
1844
+ return;
1845
+ }
1846
+ };
1847
+ return {
1848
+ marks,
1849
+ unrecorded,
1850
+ unheld,
1851
+ subscribe: (listener) => {
1852
+ listeners.add(listener);
1853
+ return () => {
1854
+ listeners.delete(listener);
1855
+ };
1856
+ },
1857
+ notify,
1858
+ press,
1859
+ adopt: (sessionId, phase) => {
1860
+ marks.set(sessionId, {
1861
+ phase,
1862
+ generation: ++generation,
1863
+ origin: "registry",
1864
+ advancing: false
1865
+ });
1866
+ },
1867
+ look,
1868
+ drop,
1869
+ sync,
1870
+ release,
1871
+ markUnrecorded: (sessionId, blocked) => {
1872
+ if (blocked) unrecorded.add(sessionId);
1873
+ else unrecorded.delete(sessionId);
1874
+ },
1875
+ live: () => new Map([...marks].map(([sessionId, mark]) => [sessionId, {
1876
+ phase: mark.phase,
1877
+ moving: mark.origin === "press" && mark.phase !== "flush-failed" && ctx.sessions.get(sessionId) !== void 0
1878
+ }])),
1879
+ cleanup: () => {
1880
+ if (notification !== void 0) clearTimeout(notification);
1881
+ marks.clear();
1882
+ listeners.clear();
1883
+ }
1884
+ };
1885
+ }
1886
+ //#endregion
1887
+ //#region lib/types/control.js
1888
+ /**
1889
+ * The session control the browser half drives: the pause verb, the park state
1890
+ * machine behind it, and the live state channel that accompanies both.
1891
+ *
1892
+ * The host exposes this control through the native Typert Remote namespace:
1893
+ * `observe` streams snapshots and `act` / `triage` / `decide` drive the UI. No
1894
+ * slash command is registered. Phase changes wake the Remote stream through a
1895
+ * local listener set; the durable registry remains the source of truth for what
1896
+ * the snapshot reports.
1897
+ * @module dsh-pause-session/control
1898
+ */
1899
+ /**
1900
+ * Whether this host can read one recorded session at all — a live session, or a
1901
+ * stored log the persistence backend can observe.
1902
+ * @param ctx - host context carrying the session store and the optional persistence service.
1903
+ * @param sessionId - the recorded session to look for.
1904
+ * @returns whether the session can be acted on here.
1905
+ */
1906
+ async function sessionReadable(ctx, sessionId) {
1907
+ if (ctx.sessions.get(sessionId) !== void 0) return true;
1908
+ const persistence = ctx.get("sessionPersistence");
1909
+ if (persistence === void 0) return false;
1910
+ try {
1911
+ return await persistence.stat(sessionId) !== void 0;
1912
+ } catch (error) {
1913
+ ctx.logger.warn(`pause: could not look for the stored log of "${sessionId}": ${describeError(error)}`);
1914
+ return false;
1915
+ }
1916
+ }
1917
+ /** How resuming all parked sessions answers the caller. */
1918
+ function summarizeResume(attempts) {
1919
+ const resumed = attempts.filter((attempt) => attempt.resumed).length;
1920
+ const refused = attempts.length - resumed;
1921
+ 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`;
1922
+ if (refused === 0) return `${head}.`;
1923
+ 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`}.`;
1924
+ }
1925
+ /**
1926
+ * Contribute the session control: the pause verb, the per-session status read,
1927
+ * the global resume verb, and — for every paused session that has a live agent
1928
+ * — the resume marker.
1929
+ * @param ctx - host context carrying the agent store, the
1930
+ * session store, and the projection registry.
1931
+ * @param gate - the pause gate this control drives and reflects.
1932
+ * @param watch - the per-session activity fold the park predicate reads.
1933
+ * @param registry - the durable pause list every phase change is written to.
1934
+ * @param options - route knowledge the clean resume path needs, and the decision list's read bound.
1935
+ * @returns the mount-time adoption step and the morning resume path.
1936
+ */
1937
+ function installSessionControl(ctx, gate, watch, registry, options) {
1938
+ const park = installParkState(ctx, gate, registry, watch);
2222
1939
  /**
2223
1940
  * Press one session's pause and register its whole tree behind it.
2224
1941
  *
@@ -2242,29 +1959,35 @@ function installSessionControl(ctx, gate, watch, registry, options) {
2242
1959
  registered: 0,
2243
1960
  rootRegistered: false,
2244
1961
  unrecorded: [],
2245
- unread: false
1962
+ unread: false,
1963
+ unresumable: [],
1964
+ unreadBranches: []
2246
1965
  };
2247
1966
  const walk = await walkDescendants(ctx, sessionId, signal);
2248
1967
  if (walk.unreadWhole) {
2249
- const pressed = await press(sessionId, signal);
2250
- if (pressed === "registered") look(sessionId);
1968
+ const pressed = await park.press(sessionId, signal);
1969
+ if (pressed === "registered") park.look(sessionId);
2251
1970
  globalPause.markUnrecorded(sessionId, pressed === "unrecordable");
2252
1971
  return {
2253
1972
  members: pressed === "registered" ? 1 : 0,
2254
1973
  registered: pressed === "registered" ? 1 : 0,
2255
1974
  rootRegistered: pressed === "registered",
2256
1975
  unrecorded: pressed === "unrecordable" ? [sessionId] : [],
2257
- unread: true
1976
+ unread: true,
1977
+ unresumable: [],
1978
+ unreadBranches: []
2258
1979
  };
2259
1980
  }
2260
1981
  const registered = [];
2261
1982
  const unrecorded = [];
1983
+ const unresumable = walk.members.filter((member) => member.mode !== "continuable").map((member) => member.id);
1984
+ const unreadBranches = walk.unread.map((branch) => branch.id);
2262
1985
  const members = walk.members.filter((member) => member.mode === "continuable");
2263
1986
  for (const id of [sessionId, ...members.map((member) => member.id)]) {
2264
- const pressed = await press(id, signal, id !== sessionId);
1987
+ const pressed = await park.press(id, signal, id !== sessionId);
2265
1988
  if (pressed === "registered") {
2266
1989
  registered.push(id);
2267
- look(id);
1990
+ park.look(id);
2268
1991
  } else if (pressed === "unrecordable") unrecorded.push(id);
2269
1992
  globalPause.markUnrecorded(id, pressed === "unrecordable");
2270
1993
  }
@@ -2273,214 +1996,115 @@ function installSessionControl(ctx, gate, watch, registry, options) {
2273
1996
  registered: registered.length,
2274
1997
  rootRegistered: registered.includes(sessionId),
2275
1998
  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
1999
+ unread: unreadBranches.length > 0,
2000
+ unresumable,
2001
+ unreadBranches
2310
2002
  };
2311
2003
  };
2312
2004
  const resumePath = installResumePath(ctx, gate, registry, options, {
2313
- release,
2314
- adopted: (sessionId) => marks.get(sessionId)?.origin === "registry"
2005
+ release: park.release,
2006
+ adopted: (sessionId) => park.marks.get(sessionId)?.origin === "registry"
2315
2007
  });
2316
2008
  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
2009
  const globalPause = mountGlobalPause(ctx, watch, registry, {
2337
2010
  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
2011
+ press: park.press,
2012
+ look: park.look,
2013
+ live: park.live,
2014
+ unheld: () => park.unheld
2345
2015
  });
2016
+ const readSnapshot$1 = (signal) => readSnapshot({
2017
+ ctx,
2018
+ marks: park.marks,
2019
+ watch,
2020
+ registry,
2021
+ readGlobal: globalPause.readState
2022
+ }, signal);
2346
2023
  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
2024
  yield gate.onChange((sessionId) => {
2444
- sync(sessionId);
2025
+ park.sync(sessionId);
2445
2026
  });
2446
2027
  yield gate.onHeld((sessionId) => {
2447
2028
  queueMicrotask(() => {
2448
- look(sessionId);
2029
+ park.look(sessionId);
2449
2030
  });
2450
2031
  });
2451
2032
  yield watch.onChange((sessionId) => {
2452
- look(sessionId);
2033
+ park.look(sessionId);
2453
2034
  });
2454
2035
  yield ctx.on("agent/created", ({ agent }) => {
2455
- sync(agent.id);
2456
- });
2457
- yield ctx.on("agent/disposed", ({ agent }) => {
2458
- markers.delete(agent.id);
2036
+ park.sync(agent.id);
2459
2037
  });
2460
2038
  yield async () => {
2461
- const pending = [...markers.values()].map((marker) => marker.fiber.dispose());
2462
- markers.clear();
2463
- marks.clear();
2464
- await Promise.all(pending);
2039
+ park.cleanup();
2465
2040
  };
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;
2041
+ }, "pause: session events");
2042
+ return {
2043
+ readSnapshot: readSnapshot$1,
2044
+ subscribe: park.subscribe,
2045
+ pressTree: async (sessionId, signal) => await pressTree(sessionId, signal),
2046
+ releaseTree: async (sessionId, signal) => await resumePath.releaseTree(sessionId, signal),
2047
+ pauseAll: async (signal) => {
2048
+ const answer = await globalPause.pressAll(signal);
2049
+ park.notify();
2050
+ return answer;
2051
+ },
2052
+ cancelAll: async (signal) => {
2053
+ const outcomes = [];
2054
+ for (let round = 0; round < 3; round += 1) {
2055
+ const before = registry.parked.size;
2056
+ for (const sessionId of [...registry.parked.keys()]) {
2057
+ signal?.throwIfAborted();
2058
+ outcomes.push((await resumePath.releaseTree(sessionId, signal)).answer);
2059
+ }
2060
+ if (registry.parked.size === before) break;
2473
2061
  }
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);
2062
+ park.notify();
2063
+ const failed = outcomes.filter((answer) => answer.kind === "error");
2064
+ return failed.length === 0 ? {
2065
+ kind: "success",
2066
+ text: "Every pause was released."
2067
+ } : {
2068
+ kind: "error",
2069
+ text: failed.map((answer) => answer.text).join(" ")
2070
+ };
2071
+ },
2072
+ resumeAll: async (signal) => {
2073
+ const answer = {
2074
+ kind: "success",
2075
+ text: summarizeResume(await resumePath.resumeParked(signal))
2076
+ };
2077
+ park.notify();
2078
+ return answer;
2079
+ },
2080
+ toggleGoal: async (sessionId) => {
2081
+ const record = registry.get(sessionId);
2082
+ if (record === void 0) return {
2083
+ kind: "error",
2084
+ text: NOT_PARKED_TEXT
2085
+ };
2086
+ const answer = await resumePath.setRearmGoal(sessionId, !record.rearmGoal);
2087
+ park.notify();
2088
+ return answer;
2089
+ },
2090
+ readTriage: async (caller, signal) => await triage.read(caller, signal),
2091
+ decideTriage: async (caller, target, action, signal) => {
2092
+ const answer = await triage.decide(caller, target, action, signal);
2093
+ park.notify();
2094
+ return answer;
2095
+ },
2096
+ adopt: async () => {
2097
+ for (const entry of registry.list()) {
2098
+ if (!await sessionReadable(ctx, entry.sessionId)) {
2099
+ park.unheld.add(entry.sessionId);
2100
+ ctx.logger.warn(`pause: the pause recorded for "${entry.sessionId}" is not held: this host cannot read that session`);
2101
+ continue;
2102
+ }
2103
+ if (gate.pause(entry.sessionId)) park.adopt(entry.sessionId, entry.phase);
2104
+ }
2105
+ for (const agent of ctx.agents.list()) park.sync(agent.id);
2481
2106
  }
2482
- for (const agent of ctx.agents.list()) sync(agent.id);
2483
- } };
2107
+ };
2484
2108
  }
2485
2109
  //#endregion
2486
2110
  //#region lib/types/gate.js
@@ -2784,6 +2408,399 @@ async function mountParkWatch(ctx) {
2784
2408
  }
2785
2409
  };
2786
2410
  }
2411
+ //#endregion
2412
+ //#region lib/types/remote.js
2413
+ /**
2414
+ * Native Remote transport for the pause controls.
2415
+ *
2416
+ * This module is deliberately self-contained: it imports only Cordis and the
2417
+ * Typert decorators. The Typert generator analyzes this file alone so a
2418
+ * third-party package can emit its Host reflection and Client contribution
2419
+ * without pulling the plugin's DSH-typed modules into the generation face.
2420
+ *
2421
+ * The UI uses this namespace on Web and Desktop alike. No slash command is
2422
+ * registered; the stream is the live status channel and the methods below are
2423
+ * the button actions.
2424
+ * @module dsh-pause-session/remote
2425
+ */
2426
+ var __runInitializers = function(thisArg, initializers, value) {
2427
+ var useValue = arguments.length > 2;
2428
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
2429
+ return useValue ? value : void 0;
2430
+ };
2431
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
2432
+ function accept(f) {
2433
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
2434
+ return f;
2435
+ }
2436
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
2437
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
2438
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
2439
+ var _, done = false;
2440
+ for (var i = decorators.length - 1; i >= 0; i--) {
2441
+ var context = {};
2442
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
2443
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
2444
+ context.addInitializer = function(f) {
2445
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
2446
+ extraInitializers.push(accept(f || null));
2447
+ };
2448
+ var result = (0, decorators[i])(kind === "accessor" ? {
2449
+ get: descriptor.get,
2450
+ set: descriptor.set
2451
+ } : descriptor[key], context);
2452
+ if (kind === "accessor") {
2453
+ if (result === void 0) continue;
2454
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
2455
+ if (_ = accept(result.get)) descriptor.get = _;
2456
+ if (_ = accept(result.set)) descriptor.set = _;
2457
+ if (_ = accept(result.init)) initializers.unshift(_);
2458
+ } else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
2459
+ else descriptor[key] = _;
2460
+ }
2461
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
2462
+ done = true;
2463
+ };
2464
+ /** Host callbacks the Remote service delegates to. */
2465
+ /**
2466
+ * Refuse a Remote call made by a principal other than the owner. In-process
2467
+ * callers (tests, the Host itself) have no invocation and are trusted.
2468
+ * @param ctx - the service Context.
2469
+ */
2470
+ function ownerOnly(ctx) {
2471
+ const invocation = ctx.invocation;
2472
+ if (invocation === void 0) return;
2473
+ if (invocation.peer.principal?.kind !== "owner") throw new RemoteError("pause/owner-only", "pause controls are available only to the owner", {});
2474
+ }
2475
+ /**
2476
+ * Record one state-changing Remote action in the host log. The principal is
2477
+ * the owning user for owner calls and trusted in-process invocations; no wire
2478
+ * field can forge it.
2479
+ * @param ctx - the service Context.
2480
+ * @param action - the action pressed.
2481
+ * @param sessionId - the addressed Session.
2482
+ */
2483
+ function logAction(ctx, action, sessionId) {
2484
+ const invocation = ctx.invocation;
2485
+ const principal = principalOf(invocation);
2486
+ ctx.logger.info(`pause: ${action} "${sessionId}"`, {
2487
+ action,
2488
+ sessionId,
2489
+ principal: principal?.kind ?? "owner"
2490
+ });
2491
+ }
2492
+ /** Host service exposed to the browser as the `pauseSession` Remote namespace. */
2493
+ let PauseRemote = (() => {
2494
+ let _classSuper = TypertRemoteService;
2495
+ let _instanceExtraInitializers = [];
2496
+ let _snapshot_decorators;
2497
+ let _observe_decorators;
2498
+ let _act_decorators;
2499
+ let _triage_decorators;
2500
+ let _decide_decorators;
2501
+ return class PauseRemote extends _classSuper {
2502
+ static {
2503
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
2504
+ _snapshot_decorators = [Remote("snapshot")];
2505
+ _observe_decorators = [Remote({ mode: "stream" })];
2506
+ _act_decorators = [Remote("act")];
2507
+ _triage_decorators = [Remote("triage")];
2508
+ _decide_decorators = [Remote("decide")];
2509
+ __esDecorate(this, null, _snapshot_decorators, {
2510
+ kind: "method",
2511
+ name: "snapshot",
2512
+ static: false,
2513
+ private: false,
2514
+ access: {
2515
+ has: (obj) => "snapshot" in obj,
2516
+ get: (obj) => obj.snapshot
2517
+ },
2518
+ metadata: _metadata
2519
+ }, null, _instanceExtraInitializers);
2520
+ __esDecorate(this, null, _observe_decorators, {
2521
+ kind: "method",
2522
+ name: "observe",
2523
+ static: false,
2524
+ private: false,
2525
+ access: {
2526
+ has: (obj) => "observe" in obj,
2527
+ get: (obj) => obj.observe
2528
+ },
2529
+ metadata: _metadata
2530
+ }, null, _instanceExtraInitializers);
2531
+ __esDecorate(this, null, _act_decorators, {
2532
+ kind: "method",
2533
+ name: "act",
2534
+ static: false,
2535
+ private: false,
2536
+ access: {
2537
+ has: (obj) => "act" in obj,
2538
+ get: (obj) => obj.act
2539
+ },
2540
+ metadata: _metadata
2541
+ }, null, _instanceExtraInitializers);
2542
+ __esDecorate(this, null, _triage_decorators, {
2543
+ kind: "method",
2544
+ name: "triage",
2545
+ static: false,
2546
+ private: false,
2547
+ access: {
2548
+ has: (obj) => "triage" in obj,
2549
+ get: (obj) => obj.triage
2550
+ },
2551
+ metadata: _metadata
2552
+ }, null, _instanceExtraInitializers);
2553
+ __esDecorate(this, null, _decide_decorators, {
2554
+ kind: "method",
2555
+ name: "decide",
2556
+ static: false,
2557
+ private: false,
2558
+ access: {
2559
+ has: (obj) => "decide" in obj,
2560
+ get: (obj) => obj.decide
2561
+ },
2562
+ metadata: _metadata
2563
+ }, null, _instanceExtraInitializers);
2564
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
2565
+ enumerable: true,
2566
+ configurable: true,
2567
+ writable: true,
2568
+ value: _metadata
2569
+ });
2570
+ }
2571
+ api = __runInitializers(this, _instanceExtraInitializers);
2572
+ /** One snapshot per host version, shared by every connected observer. */
2573
+ snapshotCache;
2574
+ /** Bumped by every host notification; each observer compares it to its own cursor. */
2575
+ version = 0;
2576
+ /** Observers waiting for the next version. */
2577
+ waiters = /* @__PURE__ */ new Set();
2578
+ unsubscribe;
2579
+ /**
2580
+ * @param ctx - host context owning the service.
2581
+ * @param api - the pause engine behind the wire methods.
2582
+ */
2583
+ constructor(ctx, api) {
2584
+ super(ctx, "pauseSession", { namespace: "pauseSession" });
2585
+ this.api = api;
2586
+ this.unsubscribe = this.api.subscribe(() => {
2587
+ this.version += 1;
2588
+ this.snapshotCache = void 0;
2589
+ for (const wake of [...this.waiters]) wake();
2590
+ this.waiters.clear();
2591
+ });
2592
+ ctx.effect(() => this.unsubscribe, "pause: remote updates");
2593
+ }
2594
+ /**
2595
+ * One exact snapshot, used by tests and by a reconnect that cannot wait for
2596
+ * the first stream item.
2597
+ * @param signal - carrier cancellation.
2598
+ * @returns the current aggregate and session states.
2599
+ */
2600
+ async snapshot(signal) {
2601
+ ownerOnly(this.ctx);
2602
+ return await this.api.snapshot(signal);
2603
+ }
2604
+ /**
2605
+ * The snapshot for one host version, computed once even when several clients
2606
+ * are watching. A new notification discards the cached promise.
2607
+ * @param version - the host version this snapshot answers.
2608
+ * @param signal - stream cancellation.
2609
+ * @returns the shared snapshot promise.
2610
+ */
2611
+ snapshotFor(version, signal) {
2612
+ if (this.snapshotCache?.version === version) return this.snapshotCache.value;
2613
+ const value = this.api.snapshot(signal);
2614
+ this.snapshotCache = {
2615
+ version,
2616
+ value
2617
+ };
2618
+ return value;
2619
+ }
2620
+ /**
2621
+ * Live status: the opening snapshot, then one new snapshot whenever the host
2622
+ * reports a change.
2623
+ * @param signal - stream cancellation.
2624
+ * @returns the snapshot stream.
2625
+ */
2626
+ async *observe(signal) {
2627
+ ownerOnly(this.ctx);
2628
+ let seen = -1;
2629
+ for (;;) {
2630
+ signal?.throwIfAborted();
2631
+ if (this.version !== seen) {
2632
+ seen = this.version;
2633
+ yield await this.snapshotFor(seen, signal);
2634
+ continue;
2635
+ }
2636
+ await new Promise((resolve, reject) => {
2637
+ const finish = () => {
2638
+ signal?.removeEventListener("abort", onAbort);
2639
+ this.waiters.delete(finish);
2640
+ resolve();
2641
+ };
2642
+ const onAbort = () => {
2643
+ this.waiters.delete(finish);
2644
+ signal?.removeEventListener("abort", onAbort);
2645
+ reject(signal?.reason);
2646
+ };
2647
+ this.waiters.add(finish);
2648
+ signal?.addEventListener("abort", onAbort, { once: true });
2649
+ if (this.version !== seen) finish();
2650
+ });
2651
+ }
2652
+ }
2653
+ /**
2654
+ * Pause, resume, press every tree, resume every clean park, or flip one
2655
+ * Session's goal re-arm.
2656
+ * @param request - the button action.
2657
+ * @param signal - carrier cancellation.
2658
+ * @returns the user-facing outcome.
2659
+ */
2660
+ async act(request, signal) {
2661
+ ownerOnly(this.ctx);
2662
+ logAction(this.ctx, request.action, request.sessionId);
2663
+ return await this.api.act(request, signal);
2664
+ }
2665
+ /**
2666
+ * Read the morning decision list.
2667
+ * @param request - the calling Session whose list is read.
2668
+ * @param signal - read cancellation.
2669
+ * @returns the listed rows.
2670
+ */
2671
+ async triage(request, signal) {
2672
+ ownerOnly(this.ctx);
2673
+ return await this.api.triage(request, signal);
2674
+ }
2675
+ /**
2676
+ * Take one decision on one listed Session.
2677
+ * @param request - the caller, target, and decision.
2678
+ * @param signal - load cancellation.
2679
+ * @returns the user-facing outcome.
2680
+ */
2681
+ async decide(request, signal) {
2682
+ ownerOnly(this.ctx);
2683
+ logAction(this.ctx, request.decision, request.target);
2684
+ return await this.api.decide(request, signal);
2685
+ }
2686
+ };
2687
+ })();
2688
+ //#endregion
2689
+ //#region lib/types/remote-adapter.js
2690
+ /**
2691
+ * Wire adapter between the pause engine and the generated Remote namespace.
2692
+ *
2693
+ * The engine speaks in branded Session ids and user-facing answer copy; the Remote
2694
+ * contract is plain JSON. Mapping lives here so neither side depends on the
2695
+ * other's types.
2696
+ * @module dsh-pause-session/remote-adapter
2697
+ */
2698
+ /** Map the engine's aggregate status to the wire shape. */
2699
+ function globalOf(status) {
2700
+ return {
2701
+ verdict: status.verdict,
2702
+ registered: status.registered,
2703
+ safe: status.safe,
2704
+ ...status.parkedAt === void 0 ? {} : { parkedAt: String(status.parkedAt) },
2705
+ draining: status.draining.map(String),
2706
+ parking: status.parking,
2707
+ stalled: status.stalled.map(String),
2708
+ unresumable: status.unresumable.map(String),
2709
+ unreadTrees: status.unreadTrees.map(String),
2710
+ jobs: status.jobs.map(String),
2711
+ blind: [...status.blind]
2712
+ };
2713
+ }
2714
+ /** Map one engine status to the wire shape. */
2715
+ function sessionOf(status) {
2716
+ return {
2717
+ phase: status.phase,
2718
+ runningTools: [...status.runningTools],
2719
+ ...status.rearmGoal === void 0 ? {} : { rearmGoal: status.rearmGoal }
2720
+ };
2721
+ }
2722
+ /** Map one engine triage row to the wire shape. */
2723
+ function triageRowOf(row) {
2724
+ return {
2725
+ sessionId: row.sessionId,
2726
+ phase: row.phase,
2727
+ updatedAt: row.updatedAt,
2728
+ reading: row.reading,
2729
+ ...row.lastEvent === void 0 ? {} : { lastEvent: row.lastEvent },
2730
+ ...row.turn === void 0 ? {} : { turn: row.turn },
2731
+ ...row.step === void 0 ? {} : { step: row.step },
2732
+ traces: row.traces.map((trace) => ({
2733
+ tool: trace.tool,
2734
+ callId: trace.callId
2735
+ })),
2736
+ owed: row.owed,
2737
+ wakeable: row.wakeable,
2738
+ rearmGoal: row.rearmGoal
2739
+ };
2740
+ }
2741
+ /** Map the engine's snapshot to the wire shape. */
2742
+ function snapshotOf(snapshot) {
2743
+ return {
2744
+ global: globalOf(snapshot.global),
2745
+ sessions: Object.fromEntries(Object.entries(snapshot.sessions).map(([id, status]) => [id, sessionOf(status)]))
2746
+ };
2747
+ }
2748
+ /** Convert one engine answer to the wire outcome. */
2749
+ function outcomeOf(answer) {
2750
+ return {
2751
+ ok: answer.kind === "success",
2752
+ message: answer.text ?? (answer.kind === "success" ? "Done." : "Pause action failed.")
2753
+ };
2754
+ }
2755
+ /** Convert a press outcome to the wire outcome. */
2756
+ function pressOutcomeOf(outcome) {
2757
+ const names = (ids) => ids.map((id) => `"${String(id)}"`).join(", ");
2758
+ if (outcome.unrecorded.length > 0) return {
2759
+ ok: false,
2760
+ message: `pause could not record ${names(outcome.unrecorded)}.`
2761
+ };
2762
+ if (!outcome.rootRegistered) return {
2763
+ ok: false,
2764
+ message: "pause did not register this session."
2765
+ };
2766
+ const caveats = [];
2767
+ if (outcome.unresumable.length > 0) caveats.push(`these subagents cannot be resumed: ${names(outcome.unresumable)}.`);
2768
+ if (outcome.unreadBranches.length > 0) caveats.push(`these subagent branches could not be read: ${names(outcome.unreadBranches)}.`);
2769
+ if (outcome.unread && outcome.unreadBranches.length === 0) caveats.push("The subagent tree could not be fully read.");
2770
+ return {
2771
+ ok: true,
2772
+ message: `Paused ${String(outcome.registered)} session${outcome.registered === 1 ? "" : "s"}.` + (caveats.length === 0 ? "" : ` ${caveats.join(" ")}`)
2773
+ };
2774
+ }
2775
+ /**
2776
+ * Build the Remote callbacks over the mounted pause control.
2777
+ * @param control - the engine's host control surface.
2778
+ * @returns the wire API.
2779
+ */
2780
+ function remoteApiOf(control) {
2781
+ return {
2782
+ snapshot: async (signal) => snapshotOf(await control.readSnapshot(signal)),
2783
+ subscribe: (listener) => control.subscribe(listener),
2784
+ act: async (request, signal) => {
2785
+ const sessionId = SessionId(request.sessionId);
2786
+ switch (request.action) {
2787
+ case "pause": return pressOutcomeOf(await control.pressTree(sessionId, signal));
2788
+ case "resume": return outcomeOf((await control.releaseTree(sessionId, signal)).answer);
2789
+ case "pause-all": return outcomeOf(await control.pauseAll(signal));
2790
+ case "resume-all": return outcomeOf(await control.resumeAll(signal));
2791
+ case "cancel-all": return outcomeOf(await control.cancelAll(signal));
2792
+ case "goal": return outcomeOf(await control.toggleGoal(sessionId, signal));
2793
+ }
2794
+ },
2795
+ triage: async (request, signal) => {
2796
+ return (await control.readTriage(SessionId(request.caller), signal)).rows.map(triageRowOf);
2797
+ },
2798
+ decide: async (request, signal) => {
2799
+ const action = request.decision;
2800
+ return outcomeOf(await control.decideTriage(SessionId(request.caller), SessionId(request.target), action, signal));
2801
+ }
2802
+ };
2803
+ }
2787
2804
  /**
2788
2805
  * The registry declaration. `version: 1` and no `compatibleVersions`: the
2789
2806
  * domain layer rejects a stored unit stamped with any other version rather than
@@ -2847,7 +2864,10 @@ async function openParkRegistry(ctx) {
2847
2864
  list: entriesNow,
2848
2865
  get parked() {
2849
2866
  const entries = entriesNow();
2850
- if (cached !== void 0 && entries.length === cachedFrom.length && entries.every((entry, at) => entry === cachedFrom[at])) return cached;
2867
+ if (cached !== void 0 && entries.length === cachedFrom.length && entries.every((entry, at) => {
2868
+ const previous = cachedFrom[at];
2869
+ return previous !== void 0 && entry.sessionId === previous.sessionId && entry.phase === previous.phase && entry.updatedAt === previous.updatedAt && entry.rearmGoal === previous.rearmGoal;
2870
+ })) return cached;
2851
2871
  cachedFrom = entries;
2852
2872
  cached = new Map(entries.map((entry) => [entry.sessionId, entry]));
2853
2873
  return cached;
@@ -2908,7 +2928,6 @@ const name = "dsh-pause-session";
2908
2928
  */
2909
2929
  const inject = [
2910
2930
  "llm",
2911
- "commands",
2912
2931
  "agents",
2913
2932
  "sessions",
2914
2933
  "sessionProjections",
@@ -2964,12 +2983,14 @@ async function apply(ctx, config = Config.parse({})) {
2964
2983
  }, "pause: lifetime");
2965
2984
  const gate = new PauseGate({ lifetime: lifetime.signal });
2966
2985
  ctx.effect(() => ctx.on("llm/stream", (request, next) => gate.stream(request, next)), "pause: pause gate");
2967
- await installSessionControl(ctx, gate, await mountParkWatch(ctx), registry, {
2986
+ const control = installSessionControl(ctx, gate, await mountParkWatch(ctx), registry, {
2968
2987
  silentWakeRoutes: config.silentWakeRoutes,
2969
2988
  maxListedLogEvents: config.maxListedLogEvents
2970
- }).adopt();
2989
+ });
2990
+ new PauseRemote(ctx, remoteApiOf(control));
2991
+ await control.adopt();
2971
2992
  }
2972
2993
  //#endregion
2973
- export { Config, DEFAULT_SILENT_WAKE_ROUTES, apply, inject, name };
2994
+ export { Config, DEFAULT_SILENT_WAKE_ROUTES, PauseRemote, apply, inject, name };
2974
2995
 
2975
2996
  //# sourceMappingURL=index.js.map