@north-light/crouter 0.3.193 → 0.3.195
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/bash-jobs.d.ts +2 -0
- package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
- package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
- package/dist/clients/attach/viewer.js +377 -377
- package/dist/commands/node/bash.js +3 -2
- package/dist/commands/node/lifecycle.js +2 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
- package/dist/core/bash-jobs.d.ts +11 -0
- package/dist/core/bash-jobs.js +24 -1
- package/dist/core/canvas/types.d.ts +2 -1
- package/dist/core/inspector/model.d.ts +5 -0
- package/dist/core/inspector/model.js +5 -0
- package/dist/core/inspector/text.js +2 -2
- package/dist/core/inspector/tui.js +10 -3
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +4 -2
- package/dist/core/runtime/persona.js +2 -1
- package/dist/core/runtime/tmux-bindings.js +8 -1
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/daemon/api/handlers/bash-jobs.js +1 -0
- package/dist/daemon/reconcilers/broker-supervision.js +7 -1
- package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
- package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
- package/dist/daemon/reconcilers/live-obligation.js +11 -0
- package/dist/pages/bundle.js +9 -9
- package/dist/pages/comments.d.ts +9 -0
- package/dist/pages/comments.js +53 -0
- package/dist/pages/elements/cards.js +19 -2
- package/dist/pages/elements/options.js +27 -2
- package/dist/pages/elements/table.js +31 -2
- package/dist/pages/elements/text.js +19 -2
- package/dist/pages/host.d.ts +26 -0
- package/dist/pages/host.js +33 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
- package/dist/pi-extensions/canvas-bash-valve.js +25 -7
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/install-runtime.mjs +172 -5
|
@@ -78,7 +78,7 @@ const nodeBashList = defineLeaf({
|
|
|
78
78
|
],
|
|
79
79
|
output: [
|
|
80
80
|
{ name: 'node_id', type: 'string', required: true, constraint: 'The target node.' },
|
|
81
|
-
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job.' },
|
|
81
|
+
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, purpose, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job; purpose is a human-readable label or null.' },
|
|
82
82
|
],
|
|
83
83
|
outputKind: 'object',
|
|
84
84
|
effects: ['Reads the node context jobs directory. Changes nothing.'],
|
|
@@ -95,6 +95,7 @@ const nodeBashList = defineLeaf({
|
|
|
95
95
|
const jobs = activeBackgroundBashJobs(dir).map((job) => ({
|
|
96
96
|
job_id: job.jobId,
|
|
97
97
|
command: job.command,
|
|
98
|
+
purpose: job.purpose,
|
|
98
99
|
started_at: new Date(job.startedAtMs).toISOString(),
|
|
99
100
|
elapsed_ms: now - job.startedAtMs,
|
|
100
101
|
log_path: job.logPath,
|
|
@@ -107,7 +108,7 @@ const nodeBashList = defineLeaf({
|
|
|
107
108
|
const jobs = result['jobs'];
|
|
108
109
|
if (jobs.length === 0)
|
|
109
110
|
return `No background bash jobs for ${result['node_id']}.`;
|
|
110
|
-
return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}\n log: ${job.log_path}`).join('\n');
|
|
111
|
+
return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}${job.purpose === null ? '' : `\n purpose: ${job.purpose}`}\n log: ${job.log_path}`).join('\n');
|
|
111
112
|
},
|
|
112
113
|
});
|
|
113
114
|
const nodeBashKill = defineLeaf({
|
|
@@ -130,7 +130,7 @@ export const nodeConfig = defineLeaf({
|
|
|
130
130
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Target node id. Defaults to the node in --pane, else CRTR_NODE_ID.' },
|
|
131
131
|
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to resolve the node from. Defaults to $TMUX_PANE.' },
|
|
132
132
|
{ kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Model spec: exact provider/id, capability tier (ultra|strong|medium|light), family alias (opus|sonnet|haiku), or a free-text substring when the broker is live. Dormant nodes require a provider/id, tier, or alias.' },
|
|
133
|
-
{ kind: 'flag', name: 'lifecycle', type: 'enum', choices: ['terminal', 'resident'], required: false, constraint: 'Set lifecycle headlessly without closing the viewer. terminal owes a final and reaps when done; resident stays interactable
|
|
133
|
+
{ kind: 'flag', name: 'lifecycle', type: 'enum', choices: ['terminal', 'resident'], required: false, constraint: 'Set lifecycle headlessly without closing the viewer. terminal owes a final and reaps when done; resident stays interactable while attached or awaiting something live.' },
|
|
134
134
|
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Persona kind. The <kinds> list below names every top-level installable kind and when to use each; a sub-kind (e.g. plan/reviewers/security) is valid too by exact path but not listed here.' },
|
|
135
135
|
{ kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, constraint: 'Set persona mode headlessly. base is hands-on; orchestrator holds a roadmap and delegates. orchestrator seeds a roadmap scaffold if absent.' },
|
|
136
136
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Rename the node; if the node has a live window, also rename that viewer window.' },
|
|
@@ -242,7 +242,7 @@ export const nodePromote = defineLeaf({
|
|
|
242
242
|
summary: 'promote yourself to an orchestrator when worthwhile parallel delegation should make coordination your primary job; task duration, sequential phases, or one helper do not qualify. Mode only — lifecycle stays as-is unless you pass --resident',
|
|
243
243
|
params: [
|
|
244
244
|
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator. The <kinds> list below names every top-level installable kind and when to use each; a sub-kind (e.g. plan/reviewers/security) is valid too by exact path but not listed here. For an exact composed persona list, use `crtr sys prompt-review --list --kind <kind>` with the target kind. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
|
|
245
|
-
{ kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it
|
|
245
|
+
{ kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it is never forced to submit a final, stays wakeable while attached or awaiting something live, and completes after the unattended grace otherwise. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
|
|
246
246
|
{ kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong'], required: false, constraint: 'Change the model you run on, by capability tier: `ultra` (frontier — reserve for high-taste judgment or enormous work: speccing, planning something large, e2e-testing something hard to test) or `strong` (opus — regular dev work). Omit to keep your current model. An orchestrator steering work is never weaker than opus, so these are the only two choices; the change is durable across future revives.' },
|
|
247
247
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
|
|
248
248
|
],
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// (1) Issue #222/#353 — a real idle-release cycle must settle the inbox watcher
|
|
7
7
|
// before the broker exits, and the daemon may only revive for a row strictly
|
|
8
8
|
// after the committed cursor.
|
|
9
|
-
// (2) The observed 57-broker / ~5.4GB leak —
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// (2) The observed 57-broker / ~5.4GB leak — DAEMON SUPERVISION, not an agent
|
|
10
|
+
// event, owns the unattended clock. A resident with no live obligation
|
|
11
|
+
// completes into prunable history; one awaiting a child remains wakeable.
|
|
12
|
+
// Both release their broker and fleet slot.
|
|
13
13
|
//
|
|
14
14
|
// Both drive a real broker process through the real watcher/supervision paths, so
|
|
15
15
|
// they belong to the compiled seam tier; neither needs tmux.
|
|
@@ -110,7 +110,20 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
|
|
|
110
110
|
await h.tick();
|
|
111
111
|
assert.equal(h.node(manager).cycles ?? 0, cyclesAfterRelease + 1, 'the settled later row does not cause a duplicate revival');
|
|
112
112
|
});
|
|
113
|
-
test('
|
|
113
|
+
test('an already idle resident with no live obligation reconciles into completed history', async () => {
|
|
114
|
+
const nodeId = h.fabricateBrokerNode({
|
|
115
|
+
id: 'idle-resident-without-obligation',
|
|
116
|
+
parent: root,
|
|
117
|
+
lifecycle: 'resident',
|
|
118
|
+
status: 'idle',
|
|
119
|
+
intent: 'idle-release',
|
|
120
|
+
pi_session_id: 'parked-session',
|
|
121
|
+
});
|
|
122
|
+
await h.tick();
|
|
123
|
+
assert.equal(h.node(nodeId)?.status, 'done', 'an older idle row becomes pruning-eligible without needing a broker revive');
|
|
124
|
+
assert.equal(h.node(nodeId)?.intent, 'done', 'the reconciled row records clean completion');
|
|
125
|
+
});
|
|
126
|
+
test('an unattended resident with no live obligation completes on the daemon clock', { timeout: 30_000 }, async () => {
|
|
114
127
|
// The park clock is read per supervision pass (unattendedParkMsForDaemon), so
|
|
115
128
|
// it is armed for THIS test only — a file-global 1500ms park clock would also
|
|
116
129
|
// apply to the idle-release manager above and race its own release path.
|
|
@@ -120,6 +133,7 @@ test('a revived settled node with no work releases on the daemon park clock and
|
|
|
120
133
|
const nodeId = h.fabricateBrokerNode({
|
|
121
134
|
id: 'settled-no-work',
|
|
122
135
|
parent: root,
|
|
136
|
+
lifecycle: 'resident',
|
|
123
137
|
status: 'idle',
|
|
124
138
|
intent: 'idle-release',
|
|
125
139
|
});
|
|
@@ -141,15 +155,55 @@ test('a revived settled node with no work releases on the daemon park clock and
|
|
|
141
155
|
await h.tick(seenAt + 1500);
|
|
142
156
|
await h.waitFor(() => {
|
|
143
157
|
const node = h.node(nodeId);
|
|
144
|
-
return node?.status === '
|
|
145
|
-
}, { timeoutMs: 10_000, intervalMs: 20, label: '
|
|
158
|
+
return node?.status === 'done' && node.intent === 'done' && !isPidAlive(boot.pid);
|
|
159
|
+
}, { timeoutMs: 10_000, intervalMs: 20, label: 'unattended completion and broker exit' });
|
|
146
160
|
// The pid vanishing is not the fleet learning about it: wait for the child
|
|
147
161
|
// 'exit' delivery + its policy job, which is what actually frees the slot.
|
|
148
162
|
await h.awaitFleetExit(nodeId);
|
|
149
|
-
assert.equal(h.fleet.has(nodeId), false, 'exit policy forgets the
|
|
150
|
-
assert.equal(h.node(nodeId)?.
|
|
163
|
+
assert.equal(h.fleet.has(nodeId), false, 'exit policy forgets the completed broker');
|
|
164
|
+
assert.equal(h.node(nodeId)?.status, 'done', 'the row is terminal and eligible for history pruning');
|
|
151
165
|
await h.tick();
|
|
152
|
-
assert.equal(h.fleet.has(nodeId), false, 'a supervision tick does not revive
|
|
166
|
+
assert.equal(h.fleet.has(nodeId), false, 'a supervision tick does not revive completed history');
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
if (priorParkMs === undefined)
|
|
170
|
+
delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
171
|
+
else
|
|
172
|
+
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
test('an unattended resident awaiting a live child remains wakeable', { timeout: 30_000 }, async () => {
|
|
176
|
+
const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
177
|
+
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
|
|
178
|
+
try {
|
|
179
|
+
const nodeId = h.fabricateBrokerNode({
|
|
180
|
+
id: 'settled-awaiting-child',
|
|
181
|
+
parent: root,
|
|
182
|
+
lifecycle: 'resident',
|
|
183
|
+
status: 'idle',
|
|
184
|
+
intent: 'idle-release',
|
|
185
|
+
});
|
|
186
|
+
const child = h.fabricateBrokerNode({ parent: nodeId, status: 'idle', intent: 'idle-release' });
|
|
187
|
+
subscribe(nodeId, child, true);
|
|
188
|
+
updateNode(nodeId, {
|
|
189
|
+
launch: {
|
|
190
|
+
extensions: [],
|
|
191
|
+
tools: [],
|
|
192
|
+
systemPrompt: '',
|
|
193
|
+
env: {},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
reviveNode(nodeId, { resume: true });
|
|
197
|
+
const boot = await h.awaitBoot(nodeId);
|
|
198
|
+
const seenAt = 20_000;
|
|
199
|
+
await h.tick(seenAt);
|
|
200
|
+
await h.tick(seenAt + 1500);
|
|
201
|
+
await h.waitFor(() => {
|
|
202
|
+
const node = h.node(nodeId);
|
|
203
|
+
return node?.status === 'idle' && node.intent === 'idle-release' && !isPidAlive(boot.pid);
|
|
204
|
+
}, { timeoutMs: 10_000, intervalMs: 20, label: 'awaiting resident release and broker exit' });
|
|
205
|
+
await h.awaitFleetExit(nodeId);
|
|
206
|
+
assert.equal(h.node(nodeId)?.status, 'idle', 'a live child keeps the resident eligible for an automatic wake');
|
|
153
207
|
}
|
|
154
208
|
finally {
|
|
155
209
|
if (priorParkMs === undefined)
|
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -12,7 +12,13 @@ export interface BashJobPaths {
|
|
|
12
12
|
* Inspector's cancel) — without it on disk, only the agent's own handoff
|
|
13
13
|
* notice knows the group to signal. */
|
|
14
14
|
jobPgid: string;
|
|
15
|
+
/** Human-readable label supplied with the bash call, when any. */
|
|
16
|
+
jobPurpose: string;
|
|
15
17
|
}
|
|
18
|
+
/** A purpose is display text, never shell input. Keep the file-backed control
|
|
19
|
+
* plane to one bounded, visible line so old or hand-written job directories
|
|
20
|
+
* cannot inject control content into terminal or JSON presenters. */
|
|
21
|
+
export declare function normalizeBashJobPurpose(value: unknown): string | null;
|
|
16
22
|
export declare function bashJobsDir(contextDir: string): string;
|
|
17
23
|
/** Whether a job id can safely be used as one path segment. Generated ids are
|
|
18
24
|
* numeric epoch prefixes plus a hex suffix; this broader safe alphabet keeps
|
|
@@ -22,6 +28,9 @@ export declare function bashJobPaths(contextDir: string, jobId: string): BashJob
|
|
|
22
28
|
/** The job's supervisor process group, or undefined for a job started before
|
|
23
29
|
* pgid was persisted (or a partially-written dir). */
|
|
24
30
|
export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
|
|
31
|
+
/** The optional human-readable job label. Jobs created before labels existed
|
|
32
|
+
* have no file and deliberately project null. */
|
|
33
|
+
export declare function readJobPurpose(contextDir: string, jobId: string): string | null;
|
|
25
34
|
/** Whether a persisted job supervisor process group is still present. EPERM
|
|
26
35
|
* means the group exists but cannot be signaled by this process; it is still
|
|
27
36
|
* live for the status surface. */
|
|
@@ -72,6 +81,8 @@ export interface BashJobStatus {
|
|
|
72
81
|
jobId: string;
|
|
73
82
|
/** Full text of cmd.sh. */
|
|
74
83
|
command: string;
|
|
84
|
+
/** Human-readable label, or null for an unlabeled or legacy job. */
|
|
85
|
+
purpose: string | null;
|
|
75
86
|
startedAtMs: number;
|
|
76
87
|
logPath: string;
|
|
77
88
|
}
|
package/dist/core/bash-jobs.js
CHANGED
|
@@ -5,6 +5,18 @@
|
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
6
|
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
+
const MAX_BASH_JOB_PURPOSE_BYTES = 280;
|
|
9
|
+
/** A purpose is display text, never shell input. Keep the file-backed control
|
|
10
|
+
* plane to one bounded, visible line so old or hand-written job directories
|
|
11
|
+
* cannot inject control content into terminal or JSON presenters. */
|
|
12
|
+
export function normalizeBashJobPurpose(value) {
|
|
13
|
+
if (typeof value !== 'string' || /[\p{Cc}\p{Cf}]/u.test(value))
|
|
14
|
+
return null;
|
|
15
|
+
const purpose = value.trim();
|
|
16
|
+
if (purpose === '')
|
|
17
|
+
return null;
|
|
18
|
+
return Buffer.byteLength(purpose, 'utf8') <= MAX_BASH_JOB_PURPOSE_BYTES ? purpose : null;
|
|
19
|
+
}
|
|
8
20
|
export function bashJobsDir(contextDir) {
|
|
9
21
|
return join(contextDir, 'jobs');
|
|
10
22
|
}
|
|
@@ -29,6 +41,7 @@ export function bashJobPaths(contextDir, jobId) {
|
|
|
29
41
|
jobBg: join(dir, 'job.bg'),
|
|
30
42
|
jobDone: join(dir, 'job.done'),
|
|
31
43
|
jobPgid: join(dir, 'job.pgid'),
|
|
44
|
+
jobPurpose: join(dir, 'purpose'),
|
|
32
45
|
};
|
|
33
46
|
}
|
|
34
47
|
/** The job's supervisor process group, or undefined for a job started before
|
|
@@ -42,6 +55,16 @@ export function readJobPgid(contextDir, jobId) {
|
|
|
42
55
|
return undefined;
|
|
43
56
|
}
|
|
44
57
|
}
|
|
58
|
+
/** The optional human-readable job label. Jobs created before labels existed
|
|
59
|
+
* have no file and deliberately project null. */
|
|
60
|
+
export function readJobPurpose(contextDir, jobId) {
|
|
61
|
+
try {
|
|
62
|
+
return normalizeBashJobPurpose(readFileSync(bashJobPaths(contextDir, jobId).jobPurpose, 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
45
68
|
/** Whether a persisted job supervisor process group is still present. EPERM
|
|
46
69
|
* means the group exists but cannot be signaled by this process; it is still
|
|
47
70
|
* live for the status surface. */
|
|
@@ -193,7 +216,7 @@ export function activeBackgroundBashJobs(contextDir) {
|
|
|
193
216
|
catch {
|
|
194
217
|
continue;
|
|
195
218
|
}
|
|
196
|
-
jobs.push({ jobId, command, startedAtMs, logPath: paths.jobLog });
|
|
219
|
+
jobs.push({ jobId, command, purpose: readJobPurpose(contextDir, jobId), startedAtMs, logPath: paths.jobLog });
|
|
197
220
|
}
|
|
198
221
|
return jobs.sort((a, b) => a.startedAtMs - b.startedAtMs);
|
|
199
222
|
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* only `dead` is a fault. */
|
|
12
12
|
export type NodeStatus = 'active' | 'idle' | 'done' | 'dead' | 'canceled';
|
|
13
13
|
/** Does stopping finalize the node? terminal = worker (finalizes on push --final);
|
|
14
|
-
* resident =
|
|
14
|
+
* resident = interactable while attached or holding a live obligation. An
|
|
15
|
+
* unattended obligation-free resident completes after the daemon grace. */
|
|
15
16
|
export type Lifecycle = 'terminal' | 'resident';
|
|
16
17
|
/** base = hands-on worker; orchestrator = delegating manager. Bespoke per kind. */
|
|
17
18
|
export type Mode = 'base' | 'orchestrator';
|
|
@@ -4,6 +4,8 @@ export type InspectorSection = 'overview' | 'schedule' | 'jobs' | 'raw';
|
|
|
4
4
|
export interface BashJobRow {
|
|
5
5
|
job_id: string;
|
|
6
6
|
command: string;
|
|
7
|
+
/** Human-readable label supplied with the bash call, or null when absent. */
|
|
8
|
+
purpose: string | null;
|
|
7
9
|
started_at: string;
|
|
8
10
|
elapsed_ms: number;
|
|
9
11
|
log_path: string;
|
|
@@ -67,6 +69,9 @@ export declare function jobElapsedMs(job: BashJobRow, now?: number): number;
|
|
|
67
69
|
export declare function jobElapsed(job: BashJobRow, now?: number): string;
|
|
68
70
|
/** First line of a command, whitespace-collapsed and capped for a one-row cell. */
|
|
69
71
|
export declare function commandCell(command: string, cap?: number): string;
|
|
72
|
+
/** A person-readable purpose leads when present; legacy jobs retain their
|
|
73
|
+
* command label. */
|
|
74
|
+
export declare function jobLabel(job: BashJobRow, cap?: number): string;
|
|
70
75
|
export declare function relativeTime(iso: string | null, now?: number): string;
|
|
71
76
|
/** How long ago an ISO timestamp was, e.g. `3d ago`. `—` when absent. */
|
|
72
77
|
export declare function ago(iso: unknown, now?: number): string;
|
|
@@ -81,6 +81,11 @@ export function commandCell(command, cap = 60) {
|
|
|
81
81
|
const oneLine = (command.split('\n')[0] ?? '').replace(/\s+/g, ' ').trim();
|
|
82
82
|
return oneLine.length > cap ? `${oneLine.slice(0, cap - 1)}…` : oneLine;
|
|
83
83
|
}
|
|
84
|
+
/** A person-readable purpose leads when present; legacy jobs retain their
|
|
85
|
+
* command label. */
|
|
86
|
+
export function jobLabel(job, cap = 60) {
|
|
87
|
+
return commandCell(job.purpose ?? job.command, cap);
|
|
88
|
+
}
|
|
84
89
|
export function relativeTime(iso, now = Date.now()) {
|
|
85
90
|
if (iso === null)
|
|
86
91
|
return '—';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jobElapsed, overviewGroups, relativeTime, scheduledItems, value } from './model.js';
|
|
1
|
+
import { jobElapsed, jobLabel, overviewGroups, relativeTime, scheduledItems, value } from './model.js';
|
|
2
2
|
/** The piped path: same information and grouping as the TUI, minus the glyphs
|
|
3
3
|
* and hue a pipe cannot carry. */
|
|
4
4
|
export const inspectorText = {
|
|
@@ -36,7 +36,7 @@ export const inspectorText = {
|
|
|
36
36
|
if (jobs.length === 0)
|
|
37
37
|
lines.push(' nothing running');
|
|
38
38
|
for (const job of jobs) {
|
|
39
|
-
lines.push('', ` ${job
|
|
39
|
+
lines.push('', ` ${jobLabel(job)} · ${jobElapsed(job)}`, ` id ${job.job_id}`, ...(job.purpose === null ? [] : [` command ${job.command.split('\n')[0]}`]), ` started ${job.started_at}`, ` log ${job.log_path}`);
|
|
40
40
|
for (const line of job.log_tail.slice(-5))
|
|
41
41
|
lines.push(` | ${line}`);
|
|
42
42
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ICON, commandCell, jobElapsed, jobElapsedMs, jobTone, overviewGroups, relativeTime, scheduledItems, shortPath, value, } from './model.js';
|
|
1
|
+
import { ICON, commandCell, jobElapsed, jobElapsedMs, jobLabel, jobTone, overviewGroups, relativeTime, scheduledItems, shortPath, value, } from './model.js';
|
|
2
2
|
// ── Visual grammar ───────────────────────────────────────────────────────────
|
|
3
3
|
//
|
|
4
4
|
// Three primitives carry the whole screen: a TAB STRIP (where am I / where can
|
|
@@ -162,7 +162,14 @@ function jobItem(job, expanded, width) {
|
|
|
162
162
|
lines.push([{ text: `${INDENT}${INDENT}${last === undefined ? 'no output yet' : logCell(last, width - 6)}`, style: { dim: true } }]);
|
|
163
163
|
}
|
|
164
164
|
else {
|
|
165
|
-
|
|
165
|
+
const details = [
|
|
166
|
+
['job', job.job_id],
|
|
167
|
+
...(job.purpose === null ? [] : [['command', commandCell(job.command, Math.max(20, width - 26))]]),
|
|
168
|
+
['started', job.started_at],
|
|
169
|
+
['log', shortPath(job.log_path)],
|
|
170
|
+
['watch', `tail -f ${shortPath(job.log_path)}`],
|
|
171
|
+
];
|
|
172
|
+
for (const [label, text] of details) {
|
|
166
173
|
lines.push([{ text: `${INDENT}${INDENT}${label.padEnd(LABEL_COL)}`, style: LABEL_STYLE }, { text, style: label === 'watch' ? { fg: '36' } : undefined }]);
|
|
167
174
|
}
|
|
168
175
|
lines.push([]);
|
|
@@ -176,7 +183,7 @@ function jobItem(job, expanded, width) {
|
|
|
176
183
|
return {
|
|
177
184
|
spans: [
|
|
178
185
|
{ text: ` ${ICON.job} `, style: tone },
|
|
179
|
-
{ text:
|
|
186
|
+
{ text: jobLabel(job, Math.max(20, width - 26)), style: { bold: true } },
|
|
180
187
|
],
|
|
181
188
|
right: [{ text: `${jobElapsed(job)} `, style: tone }],
|
|
182
189
|
lines,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BindingDefinition, SafetyRequirement } from './types.js';
|
|
2
2
|
import type { AttachBindingId, AttachMenuBindingId } from './attach-control.js';
|
|
3
|
-
type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'fold-tools' | 'metadata' | 'promote' | 'resume' | 'fork' | 'demote' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'providers' | 'plugins' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
|
|
3
|
+
type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'fold-tools' | 'metadata' | 'promote' | 'demote-mode' | 'resume' | 'fork' | 'demote' | 'make-resident' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'providers' | 'plugins' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
|
|
4
4
|
type TmuxMenuEntryAction = Exclude<TmuxMenuAction, 'open' | 'attach'> | `issue.${'idea' | 'bug'}`;
|
|
5
5
|
type GraphAction = 'down' | 'up' | 'first' | 'last' | 'collapse-or-manager' | 'expand-or-child' | 'focus' | 'revive' | 'focus-manager' | 'close-subtree' | 'dismiss';
|
|
6
6
|
type BrowseAction = 'quit' | 'down' | 'up' | 'first' | 'last' | 'expand-or-child' | 'collapse-or-parent' | 'toggle-fold' | 'tab-next' | 'tab-previous' | `tab.${1 | 2 | 3 | 4 | 5}` | 'close-subtree' | 'confirm-close' | 'sort' | 'scope' | 'profile' | 'residents' | 'preview' | 'search' | 'revive-now' | 'focus' | 'focus-background';
|
|
@@ -38,10 +38,12 @@ const SETUP_TAB_CONTEXTS = ['setup.login', 'setup.list', 'setup.keybindings', 's
|
|
|
38
38
|
export const BINDING_CATALOG = Object.freeze([
|
|
39
39
|
b('crtr.tmux.menu.open', 'Global tmux', 'Open crouter menu', 'Open the crouter action menu.', ['alt+c'], 'host-global', ['tmux.root']),
|
|
40
40
|
b('crtr.tmux.menu.attach', 'Global tmux', 'Attach actions', 'Open the attach-viewer action submenu.', ['a'], 'tmux-menu-selector', ['tmux.menu']),
|
|
41
|
-
...menuEntry('promote', 'Promote
|
|
41
|
+
...menuEntry('promote', 'Promote to orchestrator', 'Promote the focused node to orchestrator — it holds a roadmap and delegates.', ['o'], ['tmux.menu']),
|
|
42
|
+
...menuEntry('demote-mode', 'Demote to base node', 'Demote the focused orchestrator back to a base node — hands-on again in its own window, no roadmap to drive.', ['shift+o'], ['tmux.menu']),
|
|
42
43
|
...menuEntry('resume', 'Find node', 'Open the canvas navigator and go to the chosen node.', ['/'], ['tmux.menu']),
|
|
43
44
|
...menuEntry('fork', 'Fork node', 'Fork the focused node into an independent idle root.', ['f'], ['tmux.menu']),
|
|
44
|
-
...menuEntry('demote', 'Demote
|
|
45
|
+
...menuEntry('demote', 'Demote to terminal', 'Demote the focused node to terminal — it keeps running, owes a final up the spine, and reaps when done.', ['d'], ['tmux.menu']),
|
|
46
|
+
...menuEntry('make-resident', 'Make resident', 'Flip the focused node to resident — interactable, never forced to submit a final, and wakeable while attached or awaiting something live.', ['shift+r'], ['tmux.menu']),
|
|
45
47
|
...menuEntry('detach', 'Demote and close viewer', 'Demote the focused node and close its viewer; its broker keeps running off-screen.', ['shift+d'], ['tmux.menu']),
|
|
46
48
|
...menuEntry('close-subtree', 'Close subtree', 'Close the focused node and its subtree.', ['x'], ['tmux.menu']),
|
|
47
49
|
...menuEntry('background-bash', 'Background running bash', 'Hand the focused node’s running bash command to its background job system.', ['b'], ['tmux.menu']),
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// • mode — base (hands-on; yields mode-preservingly when another window is needed) ↔ orchestrator
|
|
5
5
|
// (delegates, holds a roadmap, survives refresh cycles + yields)
|
|
6
6
|
// • lifecycle — terminal (owes a final up the spine, reaps when done) ↔
|
|
7
|
-
// resident (interactable,
|
|
7
|
+
// resident (interactable, never forced to submit; unattended
|
|
8
|
+
// obligation-free conversations complete after the daemon grace)
|
|
8
9
|
//
|
|
9
10
|
// Whenever EITHER axis changes from the value the node was last GIVEN guidance
|
|
10
11
|
// for, the node must be prompt-injected with guidance for its new state —
|
|
@@ -19,7 +19,12 @@ const LEAF_MENU_ACTIONS = {
|
|
|
19
19
|
'crtr.tmux.menu.promote': { description: 'promote to orchestrator', action: { kind: 'keys', keys: '/promote' } },
|
|
20
20
|
'crtr.tmux.menu.resume': { description: 'find node', action: { kind: 'keys', keys: '/resume-node' } },
|
|
21
21
|
'crtr.tmux.menu.fork': { description: 'fork into idle root', action: { kind: 'direct', run: 'node fork --pane {pane}' } },
|
|
22
|
-
|
|
22
|
+
// Mode demote is the mirror of `/promote`: `node config` is the durable
|
|
23
|
+
// attribute setter, and the persona injector delivers the hands-on-again
|
|
24
|
+
// guidance at the node's next turn boundary.
|
|
25
|
+
'crtr.tmux.menu.demote-mode': { description: 'demote to base node', action: { kind: 'command', run: 'node config --mode base --pane {pane}' } },
|
|
26
|
+
'crtr.tmux.menu.demote': { description: 'demote to terminal (finish when done)', action: { kind: 'command', run: 'node lifecycle demote --pane {pane}' } },
|
|
27
|
+
'crtr.tmux.menu.make-resident': { description: 'make resident (interactable)', action: { kind: 'command', run: 'node config --lifecycle resident --pane {pane}' } },
|
|
23
28
|
'crtr.tmux.menu.detach': { description: 'demote + close viewer', action: { kind: 'command', run: 'node lifecycle demote --pane {pane} --detach' } },
|
|
24
29
|
'crtr.tmux.menu.close-subtree': { description: 'close agent + subtree', action: { kind: 'command', run: 'node lifecycle close --pane {pane}' } },
|
|
25
30
|
'crtr.tmux.menu.background-bash': { description: 'background running bash', action: { kind: 'direct', run: 'node bash background --pane {pane}' } },
|
|
@@ -296,7 +301,9 @@ function menuItems(bindings, entries) {
|
|
|
296
301
|
addGroup('Lifecycle', boundItems([
|
|
297
302
|
'crtr.tmux.menu.fork',
|
|
298
303
|
'crtr.tmux.menu.promote',
|
|
304
|
+
'crtr.tmux.menu.demote-mode',
|
|
299
305
|
'crtr.tmux.menu.demote',
|
|
306
|
+
'crtr.tmux.menu.make-resident',
|
|
300
307
|
'crtr.tmux.menu.detach',
|
|
301
308
|
'crtr.tmux.menu.close-subtree',
|
|
302
309
|
]));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const TERMRENDER_VERSION = "4.12.
|
|
1
|
+
export declare const TERMRENDER_VERSION = "4.12.7";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const TERMRENDER_VERSION = '4.12.
|
|
1
|
+
export const TERMRENDER_VERSION = '4.12.7';
|
|
@@ -20,6 +20,7 @@ import { operationIdContext } from '../../core/events/operation-id.js';
|
|
|
20
20
|
import { readCursor, readInboxSince } from '../../core/feed/inbox.js';
|
|
21
21
|
import { activeBackgroundBashJobs } from '../../core/bash-jobs.js';
|
|
22
22
|
import { focusOf } from '../../core/runtime/placement.js';
|
|
23
|
+
import { hasLiveObligation } from './live-obligation.js';
|
|
23
24
|
const execFileAsync = promisify(execFile);
|
|
24
25
|
function configuredPositiveInteger(name, fallback) {
|
|
25
26
|
const raw = process.env[name];
|
|
@@ -205,7 +206,12 @@ export class BrokerSupervisionReconciler {
|
|
|
205
206
|
if (now - since < unattendedParkMsForDaemon())
|
|
206
207
|
return;
|
|
207
208
|
this.unattendedSince.delete(id);
|
|
208
|
-
|
|
209
|
+
const liveObligation = hasLiveObligation(id);
|
|
210
|
+
// A resident with nothing left to wake it has completed its conversation:
|
|
211
|
+
// terminalize it so history retention can eventually prune it. A resident
|
|
212
|
+
// still awaiting a child/controller/human/deadline/output stays wakeable, as does
|
|
213
|
+
// every terminal node (whose own stop guard owns semantic completion).
|
|
214
|
+
transition(id, row.lifecycle === 'resident' && !liveObligation ? 'finish' : 'release');
|
|
209
215
|
headlessBrokerHost.teardown(id);
|
|
210
216
|
}
|
|
211
217
|
async warnOverCapInTmux(count, warning, max) {
|
|
@@ -7,6 +7,7 @@ import { appendControllerDeathNotice, readCursor, readInboxSince, } from '../../
|
|
|
7
7
|
import { fanDoctrineWake } from '../../core/runtime/close.js';
|
|
8
8
|
import { transition } from '../../core/runtime/lifecycle.js';
|
|
9
9
|
import { reviveNode } from '../../core/runtime/revive.js';
|
|
10
|
+
import { hasLiveObligation } from './live-obligation.js';
|
|
10
11
|
// Idle-release/inbox retry cap (2026-07-10 diagnosis, round 2): the SAME
|
|
11
12
|
// livelock shape as the stranded-relaunch case, but on the separate pass that
|
|
12
13
|
// resumes a dormant idle-released node when its inbox gains an unseen entry.
|
|
@@ -78,6 +79,13 @@ export class DormantInboxReconciler {
|
|
|
78
79
|
// set neither revives the node nor starts a stuck-relaunch episode.
|
|
79
80
|
if (!entries.some((entry) => entry.tier !== 'deferred')) {
|
|
80
81
|
this.relaunchState.delete(id);
|
|
82
|
+
const meta = getNode(id);
|
|
83
|
+
// An idle resident with no wake-worthy inbox entry still needs
|
|
84
|
+
// lifecycle settlement. Once nothing live remains, this is completed
|
|
85
|
+
// history rather than a permanently protected idle row.
|
|
86
|
+
if (meta?.lifecycle === 'resident' && !hasLiveObligation(id)) {
|
|
87
|
+
transition(id, 'finish');
|
|
88
|
+
}
|
|
81
89
|
return;
|
|
82
90
|
}
|
|
83
91
|
const prior = this.relaunchState.get(id);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCancelOnWakeCron, } from '../../core/canvas/index.js';
|
|
2
|
+
import { countTickets } from '../../core/canvas/attention.js';
|
|
3
|
+
import { readOutputRequest } from '../../core/runtime/structured-output.js';
|
|
4
|
+
/** Durable work that requires a resident to remain automatically wakeable. */
|
|
5
|
+
export function hasLiveObligation(nodeId) {
|
|
6
|
+
return hasActiveLiveSubscription(nodeId)
|
|
7
|
+
|| hasLiveMessageWait(nodeId)
|
|
8
|
+
|| hasPendingCancelOnWakeCron(nodeId)
|
|
9
|
+
|| countTickets(nodeId) > 0
|
|
10
|
+
|| readOutputRequest(nodeId).state !== 'absent';
|
|
11
|
+
}
|