@north-light/crouter 0.3.257 → 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.
- package/dist/api/dto/attach.d.ts +6 -0
- package/dist/api/dto/nodes.d.ts +15 -0
- package/dist/clients/attach/command.js +3 -1
- package/dist/clients/attach/input/capabilities.d.ts +3 -0
- package/dist/clients/attach/input/capabilities.js +7 -3
- package/dist/clients/attach/input/controller.d.ts +1 -1
- package/dist/clients/attach/input/controller.js +31 -17
- package/dist/clients/attach/session/connection.d.ts +12 -3
- package/dist/clients/attach/session/connection.js +40 -25
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +6 -2
- package/dist/clients/attach/session/reconnect.d.ts +7 -3
- package/dist/clients/attach/session/reconnect.js +13 -2
- package/dist/clients/attach/slash/dispatch.js +1 -1
- package/dist/clients/attach/viewer.d.ts +1 -1
- package/dist/clients/attach/viewer.js +689 -689
- package/dist/commands/surface/node/cycle.js +2 -2
- package/dist/commands/surface/node/focus.js +11 -11
- package/dist/commands/surface/node/placement.js +14 -5
- package/dist/core/broker-client/client.d.ts +4 -5
- package/dist/core/broker-client/client.js +9 -5
- package/dist/core/canvas/browse/model.d.ts +3 -3
- package/dist/core/canvas/browse/model.js +3 -3
- package/dist/core/canvas/browse/render.js +3 -3
- package/dist/core/runtime/revive.d.ts +19 -6
- package/dist/core/runtime/revive.js +34 -9
- package/dist/daemon/api/handlers/attach.js +24 -20
- package/dist/daemon/api/handlers/nodes.js +7 -0
- package/package.json +1 -1
- 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.', '
|
|
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
|
|
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
|
|
10
|
-
whenToUse: 'you want to bring a specific node into view — a live node
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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
|
|
70
|
-
// The server
|
|
71
|
-
// own
|
|
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:
|
|
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
|
|
44
|
-
|
|
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
|
-
? { ...
|
|
61
|
-
:
|
|
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`
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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`
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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 (
|
|
16
|
-
*
|
|
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
|
|
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 (
|
|
15
|
-
*
|
|
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
|
|
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', '
|
|
439
|
-
['', 'O', '
|
|
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 ⏎
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
9
|
+
"version": "0.3.259",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|