@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.
Files changed (40) hide show
  1. package/dist/api/dto/bash-jobs.d.ts +2 -0
  2. package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
  3. package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
  4. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  5. package/dist/clients/attach/viewer.js +377 -377
  6. package/dist/commands/node/bash.js +3 -2
  7. package/dist/commands/node/lifecycle.js +2 -2
  8. package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
  9. package/dist/core/bash-jobs.d.ts +11 -0
  10. package/dist/core/bash-jobs.js +24 -1
  11. package/dist/core/canvas/types.d.ts +2 -1
  12. package/dist/core/inspector/model.d.ts +5 -0
  13. package/dist/core/inspector/model.js +5 -0
  14. package/dist/core/inspector/text.js +2 -2
  15. package/dist/core/inspector/tui.js +10 -3
  16. package/dist/core/keybindings/catalog.d.ts +1 -1
  17. package/dist/core/keybindings/catalog.js +4 -2
  18. package/dist/core/runtime/persona.js +2 -1
  19. package/dist/core/runtime/tmux-bindings.js +8 -1
  20. package/dist/core/termrender/version.d.ts +1 -1
  21. package/dist/core/termrender/version.js +1 -1
  22. package/dist/daemon/api/handlers/bash-jobs.js +1 -0
  23. package/dist/daemon/reconcilers/broker-supervision.js +7 -1
  24. package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
  25. package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
  26. package/dist/daemon/reconcilers/live-obligation.js +11 -0
  27. package/dist/pages/bundle.js +9 -9
  28. package/dist/pages/comments.d.ts +9 -0
  29. package/dist/pages/comments.js +53 -0
  30. package/dist/pages/elements/cards.js +19 -2
  31. package/dist/pages/elements/options.js +27 -2
  32. package/dist/pages/elements/table.js +31 -2
  33. package/dist/pages/elements/text.js +19 -2
  34. package/dist/pages/host.d.ts +26 -0
  35. package/dist/pages/host.js +33 -0
  36. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
  37. package/dist/pi-extensions/canvas-bash-valve.js +25 -7
  38. package/package.json +1 -1
  39. package/runtime.lock.json +2 -2
  40. 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 and wakes on inbox/human.' },
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 stays dormant, woken by inbox/human, and is never forced to submit a final. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
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 — a boot-revived settled node that
10
- // receives no new work must still traverse bounded release: DAEMON
11
- // SUPERVISION, not an agent event, owns the unattended park clock, and the
12
- // exit policy frees the fleet slot without reviving.
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('a revived settled node with no work releases on the daemon park clock and leaves no broker', { timeout: 30_000 }, async () => {
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 === 'idle' && node.intent === 'idle-release' && !isPidAlive(boot.pid);
145
- }, { timeoutMs: 10_000, intervalMs: 20, label: 'park-clock release and broker exit' });
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 idle-release broker');
150
- assert.equal(h.node(nodeId)?.intent, 'idle-release', 'the row remains durably dormant');
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 without inbox work');
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)
@@ -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
  }
@@ -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 = manager/orchestrator (stays dormant, woken by inbox). */
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.command.split('\n')[0]} · ${jobElapsed(job)}`, ` id ${job.job_id}`, ` started ${job.started_at}`, ` log ${job.log_path}`);
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
- for (const [label, text] of [['job', job.job_id], ['started', job.started_at], ['log', shortPath(job.log_path)], ['watch', `tail -f ${shortPath(job.log_path)}`]]) {
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: commandCell(job.command, Math.max(20, width - 26)), style: { bold: true } },
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 node', 'Promote the focused node to orchestrator.', ['o'], ['tmux.menu']),
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 node', 'Demote the focused node to terminal — it keeps running, owes a final up the spine, and reaps when done.', ['d'], ['tmux.menu']),
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, stays dormant, never forced to submit)
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
- 'crtr.tmux.menu.demote': { description: 'demote (finish when done)', action: { kind: 'command', run: 'node lifecycle demote --pane {pane}' } },
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.6";
1
+ export declare const TERMRENDER_VERSION = "4.12.7";
@@ -1 +1 @@
1
- export const TERMRENDER_VERSION = '4.12.6';
1
+ export const TERMRENDER_VERSION = '4.12.7';
@@ -35,6 +35,7 @@ function handleList(ctx) {
35
35
  return {
36
36
  job_id: job.jobId,
37
37
  command: job.command,
38
+ purpose: job.purpose,
38
39
  started_at: new Date(job.startedAtMs).toISOString(),
39
40
  elapsed_ms: Math.max(0, now - job.startedAtMs),
40
41
  pgid,
@@ -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
- transition(id, 'release');
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,2 @@
1
+ /** Durable work that requires a resident to remain automatically wakeable. */
2
+ export declare function hasLiveObligation(nodeId: string): boolean;
@@ -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
+ }