@north-light/crouter 0.3.258 → 0.3.259

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/api/dto/attach.d.ts +6 -0
  2. package/dist/api/dto/nodes.d.ts +15 -0
  3. package/dist/clients/attach/command.js +3 -1
  4. package/dist/clients/attach/input/capabilities.d.ts +3 -0
  5. package/dist/clients/attach/input/capabilities.js +7 -3
  6. package/dist/clients/attach/input/controller.d.ts +1 -1
  7. package/dist/clients/attach/input/controller.js +31 -17
  8. package/dist/clients/attach/session/connection.d.ts +12 -3
  9. package/dist/clients/attach/session/connection.js +40 -25
  10. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  11. package/dist/clients/attach/session/input-wiring.js +6 -2
  12. package/dist/clients/attach/session/reconnect.d.ts +7 -3
  13. package/dist/clients/attach/session/reconnect.js +13 -2
  14. package/dist/clients/attach/slash/dispatch.js +1 -1
  15. package/dist/clients/attach/viewer.d.ts +1 -1
  16. package/dist/clients/attach/viewer.js +689 -689
  17. package/dist/commands/surface/node/cycle.js +2 -2
  18. package/dist/commands/surface/node/focus.js +11 -11
  19. package/dist/commands/surface/node/placement.js +14 -5
  20. package/dist/core/broker-client/client.d.ts +4 -5
  21. package/dist/core/broker-client/client.js +9 -5
  22. package/dist/core/canvas/browse/model.d.ts +3 -3
  23. package/dist/core/canvas/browse/model.js +3 -3
  24. package/dist/core/canvas/browse/render.js +3 -3
  25. package/dist/core/runtime/revive.d.ts +19 -6
  26. package/dist/core/runtime/revive.js +34 -9
  27. package/dist/daemon/api/handlers/attach.js +24 -20
  28. package/dist/daemon/api/handlers/nodes.js +7 -0
  29. package/package.json +1 -1
  30. package/runtime.lock.json +2 -2
@@ -55,7 +55,7 @@ export const surfaceNodeCycleLeaf = defineLeaf({
55
55
  { name: 'from', type: 'string', required: false, constraint: 'The node you cycled away from.' },
56
56
  ],
57
57
  outputKind: 'object',
58
- effects: ['Brings the neighbor\'s viewer forefront beside the caller (like `surface node focus`); the viewer you were watching drops to the background.', 'Relaunches the neighbor\'s broker first if it was dormant.'],
58
+ effects: ['Brings the neighbor\'s viewer forefront beside the caller (like `surface node focus`); the viewer you were watching drops to the background.', 'Opens a parked resident from its saved snapshot without waking it; submitted input wakes it through the normal message path.'],
59
59
  },
60
60
  run: async (input) => {
61
61
  const pane = input['pane'] ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane ?? undefined;
@@ -73,7 +73,7 @@ export const surfaceNodeCycleLeaf = defineLeaf({
73
73
  if (target === null)
74
74
  return { focused: false, from: fromId };
75
75
  // Placement brings the neighbor's viewer forefront beside the caller; the
76
- // server (ensureAttach, inside focusViewer) relaunches its broker if dormant.
76
+ // server keeps an exact parked resident offline until submitted input.
77
77
  const res = await focusViewer(targetId, { cwd: target.cwd, name: target.name, pane });
78
78
  return { focused: res.focused, node_id: targetId, name: target.name, from: fromId };
79
79
  },
@@ -6,11 +6,11 @@ import { cliClient, rethrowAsCliError } from '../../api-client.js';
6
6
  import { currentTmux, displayPopup, focusViewer, shellQuote } from './placement.js';
7
7
  export const surfaceNodeFocusLeaf = defineLeaf({
8
8
  name: 'focus',
9
- description: 'bring a node into view without accidentally reviving finished work',
10
- whenToUse: 'you want to bring a specific node into view — a live node, or a resident agent that merely stopped, opens (resuming its saved conversation in place) beside your current pane; FINISHED work — a terminal worker, or any node that pushed a final report — opens its saved transcript instead of being put back to work. Use `node lifecycle revive` to deliberately relaunch finished work anyway. Use `surface node cycle` to walk neighbors, and `node message send` to steer without leaving where you are',
9
+ description: 'bring a node into view without waking parked or finished work',
10
+ whenToUse: 'you want to bring a specific node into view — a live node opens beside your pane, while a parked resident opens its saved conversation without waking until input is submitted. FINISHED work — a terminal worker, or any node that pushed a final report — opens its saved transcript instead of being put back to work. Use `node lifecycle revive` to deliberately relaunch finished work anyway. Use `surface node cycle` to walk neighbors, and `node message send` to steer without leaving where you are',
11
11
  help: {
12
12
  name: 'surface node focus',
13
- summary: 'bring a node into view — attach to a live broker, resume a resident agent that stopped, or open a saved transcript for finished work without reviving it',
13
+ summary: 'bring a node into view — attach live, open a parked resident without waking it, or inspect finished work as a saved transcript',
14
14
  params: [
15
15
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
16
16
  { kind: 'flag', name: 'new-pane', type: 'bool', required: false, constraint: 'Always open a NEW viewer SIDE-BY-SIDE with your current pane instead of moving/reusing the node\'s existing viewer. Two agents on screen at once (F4).' },
@@ -20,11 +20,11 @@ export const surfaceNodeFocusLeaf = defineLeaf({
20
20
  output: [
21
21
  { name: 'focused', type: 'boolean', required: true, constraint: 'True when the node\'s viewer was brought into view.' },
22
22
  { name: 'session', type: 'string', required: false, constraint: 'The tmux session the viewer lives in.' },
23
- { name: 'revived', type: 'boolean', required: true, constraint: 'True when focusing had to relaunch the node\'s broker (a live node whose engine was gone, or a stopped resident agent resumed in place); false when its viewer was already attached, and false for a saved-transcript inspection.' },
23
+ { name: 'revived', type: 'boolean', required: true, constraint: 'True when focusing had to recover a non-parked node\'s broker; false for an already attached viewer, an offline parked resident, or saved-transcript inspection.' },
24
24
  { name: 'in_place', type: 'boolean', required: true, constraint: 'True when an existing viewer was navigated to (no new pane); false when a fresh viewer pane was opened or moved beside you.' },
25
25
  ],
26
26
  outputKind: 'object',
27
- effects: ['For active/idle nodes: opens/moves the one surface-attach viewer beside the caller, or navigates to its existing viewer.', 'For a resident node that stopped without finalizing: resumes its saved conversation in place (status returns to active) and opens its viewer — the same path as a live node.', 'For finished work (a terminal node, or any node with a final report): opens a read-only saved transcript popup. It does not launch a broker or change node status.', 'With --new-pane/--in-place on a live node: controls whether the viewer splits beside or replaces the caller pane.'],
27
+ effects: ['For active/idle nodes: opens/moves the one surface-attach viewer beside the caller, or navigates to its existing viewer.', 'For an unfinalized parked resident: opens the normal conversation viewer from its saved snapshot without launching a broker or changing lifecycle; submitting input sends the durable human message that wakes it.', 'For finished work (a terminal node, or any node with a final report): opens a read-only saved transcript popup. It does not launch a broker or change node status.', 'With --new-pane/--in-place on a conversation viewer: controls whether the viewer splits beside or replaces the caller pane.'],
28
28
  },
29
29
  run: async (input) => {
30
30
  const id = input['node'];
@@ -59,16 +59,16 @@ export const surfaceNodeFocusLeaf = defineLeaf({
59
59
  // any node that pushed a final report) is archive: show its persisted
60
60
  // transcript, because reviving it would set completed work back in motion, and
61
61
  // `node lifecycle revive` remains the explicit way to do that on purpose.
62
- // A RESIDENT node with no final report never finished — its `done` is just a
63
- // clean broker exit — so it falls through to the live path below, where
64
- // ensureAttach's true resume replays the conversation exactly as it stood.
62
+ // A RESIDENT node with no final report never finished — its parked `done`
63
+ // falls through to the conversation path, where focus can seat its saved
64
+ // snapshot without launching an engine.
65
65
  if (!isLiveStatus(node.status) && isFinishedWork({ lifecycle: node.lifecycle, finalized: node.final_report != null })) {
66
66
  const focused = displayPopup(pane, node.cwd, `crtr node inspect transcript ${shellQuote(id)}`);
67
67
  return { focused, session: null, revived: false, in_place: false };
68
68
  }
69
- // Local viewer tail: open/move the broker-backed viewer beside the caller.
70
- // The server (ensureAttach) owns the revive + view.sock wait; pure tmux ops
71
- // own the pane placement.
69
+ // Local viewer tail: open/move the normal conversation viewer beside the
70
+ // caller. The server owns the serialized live-vs-parked decision; pure tmux
71
+ // operations own pane placement.
72
72
  const res = await focusViewer(id, {
73
73
  cwd: node.cwd,
74
74
  name: node.name,
@@ -7,6 +7,11 @@ export function viewerEnv() {
7
7
  const crtrHome = envHomeOverride();
8
8
  return crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
9
9
  }
10
+ /** Internal startup policy for viewers opened by focus/navigation. Direct
11
+ * `surface attach` has no marker and keeps its activating default. */
12
+ function focusViewerEnv() {
13
+ return { ...viewerEnv(), CRTR_ATTACH_WAKE_PARKED: '0' };
14
+ }
10
15
  /** The live viewer pane for `nodeId` (the pane a surface-attach viewer tagged
11
16
  * `@crtr_node`), or undefined. Pure tag scan over live panes. */
12
17
  export function viewerPaneOf(nodeId) {
@@ -22,7 +27,7 @@ export function viewerPaneOf(nodeId) {
22
27
  /** Open a fresh surface-attach viewer SPLIT beside `callerPane` and rebalance
23
28
  * its window to even side-by-side widths. Pure tmux. */
24
29
  function openBesideCaller(callerPane, nodeId, cwd, callerSession, revived) {
25
- const opened = splitWindow(callerPane, { cwd, env: viewerEnv(), command: `crtr surface attach to ${nodeId}` });
30
+ const opened = splitWindow(callerPane, { cwd, env: focusViewerEnv(), command: `crtr surface attach to ${nodeId}` });
26
31
  if (opened === null)
27
32
  return { focused: false, session: null, inPlace: false, revived };
28
33
  const loc = paneLocation(opened);
@@ -40,8 +45,12 @@ export async function focusViewer(nodeId, opts) {
40
45
  if (callerPane === undefined || callerPane === '') {
41
46
  return { focused: false, session: null, inPlace: false, revived: false };
42
47
  }
43
- // (a) Server ensures the broker engine is alive + its view.sock accepts.
44
- const attach = await cliClient().ensureAttach(nodeId).catch(rethrowAsCliError);
48
+ // (a) Server ensures a live broker is attachable, but leaves an exact parked
49
+ // resident untouched. This policy is unconditional so an active→parked race
50
+ // is decided at the daemon's synchronous fleet/launch boundary.
51
+ const attach = await cliClient()
52
+ .ensureAttach(nodeId, { resume: true, revive: true, wakeParked: false })
53
+ .catch(rethrowAsCliError);
45
54
  const revived = attach.revived;
46
55
  const callerSession = paneLocation(callerPane)?.session ?? null;
47
56
  // (b0) --in-place → the caller pane BECOMES this node's viewer, replacing what
@@ -57,8 +66,8 @@ export async function focusViewer(nodeId, opts) {
57
66
  catch { /* best-effort */ }
58
67
  const window = windowOfPane(callerPane);
59
68
  const env = priorNodeId !== undefined && priorNodeId !== ''
60
- ? { ...viewerEnv(), CRTR_ATTACH_ROSTER: priorNodeId }
61
- : viewerEnv();
69
+ ? { ...focusViewerEnv(), CRTR_ATTACH_ROSTER: priorNodeId }
70
+ : focusViewerEnv();
62
71
  const ok = respawnPaneSync({ pane: callerPane, cwd: opts.cwd, env, command: `crtr surface attach to ${nodeId}` });
63
72
  if (ok && window !== null)
64
73
  renameWindow(window, opts.name);
@@ -22,11 +22,10 @@ export interface BrokerClientOptions {
22
22
  * re-dialing the same target while the node is still alive — a yield
23
23
  * leaves `status='active'` (intent='refresh') and the daemon revives a fresh
24
24
  * broker on the same path. It gives up only when the node is genuinely gone:
25
- * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
26
- * terminal — an idle-release node revives on its next inbox wake, so keep
27
- * trying (the supervisor waits at a relaxed cadence for as long as the row
28
- * stays live). Local canvas rows only — a remote target has no local row, so
29
- * callers driving a remote attach must not consult this (see viewer.ts). */
25
+ * a terminal status (done/dead/canceled) or a reaped row (null). `idle` and an
26
+ * exact unfinalized parked resident are NOT terminal — either can wake while
27
+ * the viewer remains seated. Local canvas rows only — a remote target has no
28
+ * local row, so callers driving a remote attach must not consult this. */
30
29
  export declare function reconnectShouldGiveUp(row: NodeRow | null): boolean;
31
30
  /** The plan-fixed interface (Wave 3): `connect()`, `on('frame', …)`,
32
31
  * `send(frame)`, `on('close', …)`, plus `connect`/`error` events. */
@@ -41,14 +41,18 @@ const REQUEST_TIMEOUT_MS = 10_000;
41
41
  * re-dialing the same target while the node is still alive — a yield
42
42
  * leaves `status='active'` (intent='refresh') and the daemon revives a fresh
43
43
  * broker on the same path. It gives up only when the node is genuinely gone:
44
- * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
45
- * terminal — an idle-release node revives on its next inbox wake, so keep
46
- * trying (the supervisor waits at a relaxed cadence for as long as the row
47
- * stays live). Local canvas rows only — a remote target has no local row, so
48
- * callers driving a remote attach must not consult this (see viewer.ts). */
44
+ * a terminal status (done/dead/canceled) or a reaped row (null). `idle` and an
45
+ * exact unfinalized parked resident are NOT terminal — either can wake while
46
+ * the viewer remains seated. Local canvas rows only — a remote target has no
47
+ * local row, so callers driving a remote attach must not consult this. */
49
48
  export function reconnectShouldGiveUp(row) {
50
49
  if (row === null)
51
50
  return true;
51
+ if (row.lifecycle === 'resident'
52
+ && row.status === 'done'
53
+ && row.intent === 'parked'
54
+ && row.final_report == null)
55
+ return false;
52
56
  return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
53
57
  }
54
58
  export class BrokerClient extends EventEmitter {
@@ -12,11 +12,11 @@ export declare const SORTS: readonly SortMode[];
12
12
  /** Does a node belong to this tab's slice?
13
13
  * All — every node.
14
14
  * Live — what you can pick up right now: active | idle, PLUS a resident
15
- * agent that stopped without finishing (its broker exited, but
16
- * Enter resumes the conversation in place — see `isFinishedWork`).
15
+ * agent that stopped without finishing (Enter opens its saved
16
+ * conversation; submitted input wakes it — see `isFinishedWork`).
17
17
  * Finished work never appears here.
18
18
  * Dormant — done | dead | canceled: literally "no engine running". A stopped
19
- * resident agent is deliberately on BOTH tabs — it is resumable
19
+ * resident agent is deliberately on BOTH tabs — it is openable
20
20
  * (Live) and it is not running (Dormant); both are true and each
21
21
  * tab is the one a different search starts from.
22
22
  * Attention — needs a human: has pending asks OR is parked on a fault.
@@ -11,11 +11,11 @@ export const SORTS = ['tree', 'attention', 'relevance', 'recency'];
11
11
  /** Does a node belong to this tab's slice?
12
12
  * All — every node.
13
13
  * Live — what you can pick up right now: active | idle, PLUS a resident
14
- * agent that stopped without finishing (its broker exited, but
15
- * Enter resumes the conversation in place — see `isFinishedWork`).
14
+ * agent that stopped without finishing (Enter opens its saved
15
+ * conversation; submitted input wakes it — see `isFinishedWork`).
16
16
  * Finished work never appears here.
17
17
  * Dormant — done | dead | canceled: literally "no engine running". A stopped
18
- * resident agent is deliberately on BOTH tabs — it is resumable
18
+ * resident agent is deliberately on BOTH tabs — it is openable
19
19
  * (Live) and it is not running (Dormant); both are true and each
20
20
  * tab is the one a different search starts from.
21
21
  * Attention — needs a human: has pending asks OR is parked on a fault.
@@ -435,8 +435,8 @@ const HELP_ROWS = [
435
435
  ['Tabs', 'Tab / 1-5', 'All · Live · Dormant · Attention · Pinned'],
436
436
  ['Find', '/', 'fuzzy search (name · kind · id · prompts)'],
437
437
  ['Pin', 'f', 'star / unstar — floats to top, Pinned tab'],
438
- ['Open', '⏎ o', 'resume the node (closes navigator)'],
439
- ['', 'O', 'resume, keep browsing (open in background)'],
438
+ ['Open', '⏎ o', 'open conversation (closes navigator)'],
439
+ ['', 'O', 'open, keep browsing (background)'],
440
440
  ['Act', 'y', 'copy the node id to the clipboard'],
441
441
  ['', 'm', 'message the node (no resume)'],
442
442
  ['', 'R', 'rename the node'],
@@ -557,7 +557,7 @@ export function renderFrame(state, size, caps = { color: false, color256: false
557
557
  ? '⏎ commit Esc cancel ⌫ delete'
558
558
  : state.readOnly === true
559
559
  ? '↑↓ move →/← tree / search f pin Tab tabs ? more q quit — read-only remote canvas'
560
- : `${hangingHint}↑↓ move ⏎ resume O bg / search f pin m msg x close R rename ? more q quit`;
560
+ : `${hangingHint}↑↓ move ⏎ open O bg / search f pin m msg x close R rename ? more q quit`;
561
561
  lines.push(`${DIM}${clip(footer, width)}${RESET}`);
562
562
  }
563
563
  // Assemble: home, each line cleared to EOL, then clear below.
@@ -57,6 +57,18 @@ export interface ReviveResult {
57
57
  outcome: ReviveOutcome;
58
58
  }
59
59
  export type ReviveOutcome = 'launched' | 'already-live' | 'frozen';
60
+ export type AttachReviveDecision = {
61
+ parked: true;
62
+ } | {
63
+ parked: false;
64
+ result: ReviveResult;
65
+ };
66
+ interface ReviveOptions {
67
+ resume: boolean;
68
+ capacity: ReviveCapacityPolicy;
69
+ wakeReason?: ReviveWakeReason;
70
+ recovery?: boolean;
71
+ }
60
72
  /** What a caller wants when the broker cap is full. `freeze` suits a caller
61
73
  * whose reason to run survives waiting (durable mail, a cron sink, a recovery
62
74
  * respawn): the row is marked frozen and the tick relaunches it when a slot
@@ -75,9 +87,10 @@ export type ReviveCapacityPolicy = 'freeze' | 'refuse';
75
87
  * Throws if the node does not exist. All other failures propagate as-is —
76
88
  * callers (daemon, command) decide how to handle.
77
89
  */
78
- export declare function reviveNode(nodeId: string, opts: {
79
- resume: boolean;
80
- capacity: ReviveCapacityPolicy;
81
- wakeReason?: ReviveWakeReason;
82
- recovery?: boolean;
83
- }): ReviveResult;
90
+ export declare function reviveNode(nodeId: string, opts: ReviveOptions): ReviveResult;
91
+ /** The attach-specific entry to the revive boundary. A focus viewer may decline
92
+ * to wake an exact parked resident while preserving the ordinary activating
93
+ * attach default. The fleet check and parked decision are synchronous with the
94
+ * launch below, so an already-running broker wins before dormancy is observed. */
95
+ export declare function decideAttachRevive(nodeId: string, opts: ReviveOptions, wakeParked: boolean): AttachReviveDecision;
96
+ export {};
@@ -140,6 +140,19 @@ export function hasFreshGroundState(nodeId) {
140
140
  * callers (daemon, command) decide how to handle.
141
141
  */
142
142
  export function reviveNode(nodeId, opts) {
143
+ const decision = decideRevive(nodeId, opts, true);
144
+ if (decision.parked)
145
+ throw new Error(`reviveNode: internal parked decision for activating revive ${nodeId}`);
146
+ return decision.result;
147
+ }
148
+ /** The attach-specific entry to the revive boundary. A focus viewer may decline
149
+ * to wake an exact parked resident while preserving the ordinary activating
150
+ * attach default. The fleet check and parked decision are synchronous with the
151
+ * launch below, so an already-running broker wins before dormancy is observed. */
152
+ export function decideAttachRevive(nodeId, opts, wakeParked) {
153
+ return decideRevive(nodeId, opts, wakeParked);
154
+ }
155
+ function decideRevive(nodeId, opts, wakeParked) {
143
156
  let meta = getNode(nodeId);
144
157
  if (meta === null) {
145
158
  throw new Error(`reviveNode: unknown node ${nodeId}`);
@@ -170,12 +183,21 @@ export function reviveNode(nodeId, opts) {
170
183
  // so this is a no-op for a row that is already active.
171
184
  transition(nodeId, meta.intent === 'refresh' ? 'refresh-launch' : 'revive');
172
185
  return {
173
- window: null,
174
- session: meta.tmux_session ?? null,
175
- resumed: false,
176
- outcome: 'already-live',
186
+ parked: false,
187
+ result: {
188
+ window: null,
189
+ session: meta.tmux_session ?? null,
190
+ resumed: false,
191
+ outcome: 'already-live',
192
+ },
177
193
  };
178
194
  }
195
+ // Focus may establish a viewport without establishing an engine. This exact
196
+ // row has no final latch and no fleet owner; returning before reservation is
197
+ // what keeps lifecycle, cycle count, pid, inbox cursor, and session untouched.
198
+ if (!wakeParked && meta.lifecycle === 'resident' && isParked(meta)) {
199
+ return { parked: true };
200
+ }
179
201
  // The cap, consulted BEFORE any mutation below: a node that cannot get a
180
202
  // broker must not have its cycle counter bumped, its session identity
181
203
  // cleared, or its bearings drained on the way to being turned away.
@@ -203,14 +225,17 @@ export function reviveNode(nodeId, opts) {
203
225
  });
204
226
  }
205
227
  return {
206
- window: null,
207
- session: meta.tmux_session ?? null,
208
- resumed: false,
209
- outcome: 'frozen',
228
+ parked: false,
229
+ result: {
230
+ window: null,
231
+ session: meta.tmux_session ?? null,
232
+ resumed: false,
233
+ outcome: 'frozen',
234
+ },
210
235
  };
211
236
  }
212
237
  try {
213
- return launchRevive(nodeId, meta, opts);
238
+ return { parked: false, result: launchRevive(nodeId, meta, opts) };
214
239
  }
215
240
  finally {
216
241
  // The slot is either consumed by the fleet registration inside `launch`
@@ -6,53 +6,57 @@
6
6
  //
7
7
  // `{id}` is the target node being attached to.
8
8
  import { getNode } from '../../../core/canvas/canvas.js';
9
- import { reviveNode } from '../../../core/runtime/revive.js';
9
+ import { decideAttachRevive } from '../../../core/runtime/revive.js';
10
10
  import { viewSocketPath } from '../../../core/canvas/paths.js';
11
11
  import { waitForBrokerViewSocket } from '../../../core/runtime/placement-tmux.js';
12
12
  import { notFound, usage } from '../../../core/errors.js';
13
- /** Validate + narrow the attach-ensure body (both fields optional, default true). */
13
+ /** Validate + narrow the attach-ensure body (all fields optional, default true). */
14
14
  function parseAttachBody(body) {
15
15
  if (body === undefined || body === null)
16
- return { resume: true, revive: true };
16
+ return { resume: true, revive: true, wakeParked: true };
17
17
  if (typeof body !== 'object') {
18
- throw usage('attach body must be a JSON object { resume?, revive? }');
18
+ throw usage('attach body must be a JSON object { resume?, revive?, wakeParked? }');
19
19
  }
20
20
  const resume = body.resume;
21
21
  const revive = body.revive;
22
+ const wakeParked = body.wakeParked;
22
23
  if (resume !== undefined && typeof resume !== 'boolean')
23
24
  throw usage('resume must be a boolean');
24
25
  if (revive !== undefined && typeof revive !== 'boolean')
25
26
  throw usage('revive must be a boolean');
26
- return { resume: resume ?? true, revive: revive ?? true };
27
+ if (wakeParked !== undefined && typeof wakeParked !== 'boolean')
28
+ throw usage('wakeParked must be a boolean');
29
+ return { resume: resume ?? true, revive: revive ?? true, wakeParked: wakeParked ?? true };
27
30
  }
28
31
  async function handleAttach(ctx) {
29
32
  const id = ctx.params['id'];
30
33
  if (getNode(id) === null)
31
34
  throw notFound(`unknown node: ${id}`, { received: id });
32
- const { resume, revive } = parseAttachBody(ctx.body);
35
+ const { resume, revive, wakeParked } = parseAttachBody(ctx.body);
33
36
  let revived = false;
34
37
  let resumed = false;
38
+ let parked = false;
35
39
  if (revive) {
36
- // reviveNode is the ONLY sanctioned broker launcher; a double-revive of a
37
- // live node is an idempotent no-op (no `launch` handle) — `revived` reflects
38
- // whether a fresh engine actually started.
39
- // An attach has a viewer waiting on a socket that only a live broker
40
- // publishes, so there is nothing to wait for it with: refuse at the cap and
41
- // let the caller see why, rather than freezing and returning a `revived`
42
- // that never becomes true.
43
- const result = reviveNode(id, { resume, capacity: 'refuse' });
44
- revived = result.launch !== undefined;
45
- resumed = result.resumed;
46
- // If we launched, let the broker publish its view socket so the local
47
- // viewer's immediate connect finds it (bounded wait; best-effort).
48
- if (revived)
49
- await waitForBrokerViewSocket(id);
40
+ // This decision and reviveNode's fleet guard share one synchronous launch
41
+ // boundary. Focus may stop at an exact parked resident; ordinary/direct
42
+ // attach keeps wakeParked=true and preserves attach-implies-revive.
43
+ const decision = decideAttachRevive(id, { resume, capacity: 'refuse' }, wakeParked);
44
+ parked = decision.parked;
45
+ if (!decision.parked) {
46
+ revived = decision.result.launch !== undefined;
47
+ resumed = decision.result.resumed;
48
+ // If we launched, let the broker publish its view socket so the local
49
+ // viewer's immediate connect finds it (bounded wait; best-effort).
50
+ if (revived)
51
+ await waitForBrokerViewSocket(id);
52
+ }
50
53
  }
51
54
  const body = {
52
55
  node_id: id,
53
56
  socket_path: viewSocketPath(id),
54
57
  revived,
55
58
  resumed,
59
+ parked,
56
60
  };
57
61
  return { status: 200, body };
58
62
  }
@@ -366,8 +366,15 @@ async function handleSnapshot(ctx) {
366
366
  node_id: id,
367
367
  snapshot: {
368
368
  messages: read.snapshot.messages,
369
+ ...(read.snapshot.messageIds === undefined ? {} : { messageIds: read.snapshot.messageIds }),
369
370
  stats: read.snapshot.stats,
370
371
  state: read.snapshot.state,
372
+ display: read.snapshot.display,
373
+ ...(read.snapshot.queued === undefined ? {} : { queued: read.snapshot.queued }),
374
+ ...(read.snapshot.toolGroupSummaries === undefined
375
+ ? {}
376
+ : { toolGroupSummaries: read.snapshot.toolGroupSummaries }),
377
+ ...(read.snapshot.workingActivity === undefined ? {} : { workingActivity: read.snapshot.workingActivity }),
371
378
  },
372
379
  commands: read.commands.map((c) => ({ name: c.name, description: c.description, source: c.source })),
373
380
  captured_at: nowIso(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.258",
3
+ "version": "0.3.259",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.258",
3
+ "version": "0.3.259",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.258",
9
+ "version": "0.3.259",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {