@north-light/crouter 0.3.240 → 0.3.242

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 (76) hide show
  1. package/dist/api/dto/broker-ops.d.ts +8 -3
  2. package/dist/api/dto/broker.d.ts +4 -2
  3. package/dist/api/dto/inbox.d.ts +1 -0
  4. package/dist/api/dto/nodes.d.ts +4 -0
  5. package/dist/api/dto/review-comments.d.ts +1 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/clients/attach/session/profile-files.js +4 -1
  8. package/dist/clients/attach/viewer.js +366 -366
  9. package/dist/commands/node/create.js +8 -3
  10. package/dist/commands/node/inspect.js +1 -1
  11. package/dist/commands/sys/context/admin/detail-panel.d.ts +3 -0
  12. package/dist/commands/sys/context/admin/detail-panel.js +32 -3
  13. package/dist/commands/sys/context/admin/docs-panel.d.ts +23 -13
  14. package/dist/commands/sys/context/admin/docs-panel.js +101 -103
  15. package/dist/commands/sys/context/admin/list-view.d.ts +53 -0
  16. package/dist/commands/sys/context/admin/list-view.js +125 -0
  17. package/dist/commands/sys/context/admin/model.d.ts +11 -0
  18. package/dist/commands/sys/context/admin/model.js +28 -1
  19. package/dist/commands/sys/context/admin/rail-panel.d.ts +46 -0
  20. package/dist/commands/sys/context/admin/{axes-panel.js → rail-panel.js} +64 -21
  21. package/dist/commands/sys/context/admin/shell.d.ts +19 -2
  22. package/dist/commands/sys/context/admin/shell.js +142 -46
  23. package/dist/commands/sys/daemon.js +1 -1
  24. package/dist/core/__tests__/child-death-wake.test.js +0 -48
  25. package/dist/core/__tests__/daemon-boot.test.js +0 -8
  26. package/dist/core/__tests__/dead-node-policy-table.test.js +1 -2
  27. package/dist/core/__tests__/integration/deferred-no-wake.test.js +14 -2
  28. package/dist/core/__tests__/lifecycle.test.js +5 -5
  29. package/dist/core/__tests__/relaunch-root.test.js +91 -1
  30. package/dist/core/__tests__/revive-capacity.test.js +36 -1
  31. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +73 -0
  32. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -2
  33. package/dist/core/canvas/browse/model.d.ts +0 -9
  34. package/dist/core/canvas/browse/model.js +1 -37
  35. package/dist/core/canvas/browse/render.js +2 -1
  36. package/dist/core/canvas/canvas.d.ts +6 -5
  37. package/dist/core/canvas/canvas.js +8 -7
  38. package/dist/core/canvas/types.d.ts +6 -0
  39. package/dist/core/review/realize.js +1 -1
  40. package/dist/core/runtime/broker/node-named.d.ts +1 -0
  41. package/dist/core/runtime/host.js +114 -103
  42. package/dist/core/runtime/lifecycle.js +3 -2
  43. package/dist/core/runtime/naming.d.ts +26 -8
  44. package/dist/core/runtime/naming.js +75 -43
  45. package/dist/core/runtime/reset.js +12 -5
  46. package/dist/core/runtime/revive-all.d.ts +4 -11
  47. package/dist/core/runtime/revive-all.js +6 -13
  48. package/dist/core/runtime/tmux-bindings.js +4 -3
  49. package/dist/core/runtime/warm-pool.js +4 -4
  50. package/dist/core/tui/fuzzy.d.ts +9 -0
  51. package/dist/core/tui/fuzzy.js +38 -0
  52. package/dist/daemon/api/__tests__/node-create-description.test.js +1 -0
  53. package/dist/daemon/api/handlers/broker-ops.js +14 -7
  54. package/dist/daemon/api/handlers/feedback-comments.js +1 -1
  55. package/dist/daemon/api/map.js +2 -0
  56. package/dist/daemon/crtrd.js +17 -20
  57. package/dist/daemon/messaging/node-message.d.ts +1 -0
  58. package/dist/daemon/messaging/node-message.js +6 -2
  59. package/dist/daemon/reconcilers/live-obligation.d.ts +1 -2
  60. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +0 -5
  61. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -13
  62. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +0 -6
  63. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +0 -2
  64. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +0 -10
  65. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +2 -2
  66. package/dist/daemon/reconcilers/node-lifecycle/tick.js +4 -2
  67. package/dist/daemon/reconcilers/storage-maintenance.js +2 -2
  68. package/dist/daemon/review/comment-notify.d.ts +1 -0
  69. package/dist/daemon/review/comment-notify.js +1 -0
  70. package/dist/pi-extensions/broker-local.d.ts +1 -0
  71. package/dist/pi-extensions/broker-local.js +12 -7
  72. package/dist/pi-extensions/canvas-recap.js +1 -0
  73. package/dist/pi-extensions/canvas-stophook.js +2 -0
  74. package/package.json +1 -1
  75. package/runtime.lock.json +2 -2
  76. package/dist/commands/sys/context/admin/axes-panel.d.ts +0 -21
@@ -5,7 +5,7 @@ import { resolve as resolvePath } from 'node:path';
5
5
  import { theme } from '../../../../core/tui/panel.js';
6
6
  import { realpathOrSelf } from '../../../../core/fs-utils.js';
7
7
  import { planDelivery, } from '../../../../core/substrate/plan.js';
8
- import { RUNGS, SURFACE_EVENTS, rungRank, } from '../../../../core/substrate/schema.js';
8
+ import { RUNGS, SURFACE_EVENTS, previewLine, rungRank, } from '../../../../core/substrate/schema.js';
9
9
  import { scopeForCwd, profileNameFor } from '../../../../core/substrate/subject-fields.js';
10
10
  import { resolveMemoryDocForTarget } from '../../../../core/memory-resolver.js';
11
11
  export function subjectOf(snapshot) {
@@ -207,3 +207,30 @@ export function describeSurfaceEntry(entry) {
207
207
  export function plural(count, one, many) {
208
208
  return `${count} ${count === 1 ? one : many}`;
209
209
  }
210
+ export function rungCostsOf(record) {
211
+ const entry = record.name.length;
212
+ const routing = previewLine(record.doc).length;
213
+ return {
214
+ none: 0,
215
+ name: tokens(entry),
216
+ preview: tokens(entry + routing),
217
+ content: tokens(record.doc.body.length),
218
+ };
219
+ }
220
+ /** What this document actually spends in the event the record belongs to. */
221
+ export function deliveredCost(record) {
222
+ return record === undefined ? 0 : rungCostsOf(record)[record.finalRung];
223
+ }
224
+ function tokens(characters) {
225
+ return Math.ceil(characters / 4);
226
+ }
227
+ /** Approximate tokens in at most six columns, always marked as an estimate. */
228
+ export function formatCost(count) {
229
+ if (count <= 0)
230
+ return '';
231
+ if (count < 1000)
232
+ return `~${count}`;
233
+ if (count < 10_000)
234
+ return `~${(count / 1000).toFixed(1)}k`;
235
+ return `~${Math.round(count / 1000)}k`;
236
+ }
@@ -0,0 +1,46 @@
1
+ import { type Snapshot } from './model.js';
2
+ import type { Filters } from './list-view.js';
3
+ export type AxisId = 'event' | 'payload' | 'kind' | 'mode' | 'lifecycle' | 'manager' | 'depth' | 'profile' | 'cwd';
4
+ export interface Axis {
5
+ id: AxisId;
6
+ label: string;
7
+ value: string;
8
+ /** Free-text axes open a field; the rest cycle in place. */
9
+ typed: boolean;
10
+ }
11
+ export type FacetId = 'rung' | 'origin' | 'kind' | 'state' | 'query';
12
+ export interface Facet {
13
+ id: FacetId;
14
+ label: string;
15
+ value: string;
16
+ /** The query is typed; every other facet cycles through a closed set. */
17
+ typed: boolean;
18
+ }
19
+ /** The FILTERS rows for one filter value. `doc kind` is spelled out because the
20
+ * snapshot above it dials the NODE's kind. */
21
+ export declare function facetsOf(filters: Filters): Facet[];
22
+ export declare function cycleFacet(filters: Filters, id: FacetId, delta: number): Filters;
23
+ /** The rail's rows for a snapshot. The payload row appears only for the events
24
+ * that read one, because on boot there is nothing it could change. */
25
+ export declare function axesOf(snapshot: Snapshot, profileName: string): Axis[];
26
+ /** Cycle one enum axis, returning the changed snapshot. A typed axis is never
27
+ * cycled — the shell opens a field for it instead. */
28
+ export declare function cycleAxis(snapshot: Snapshot, id: AxisId, delta: number): Snapshot;
29
+ /** Apply a typed axis's submitted value. */
30
+ export declare function setTypedAxis(snapshot: Snapshot, id: AxisId, value: string): Snapshot;
31
+ /** The value a typed axis's field is seeded with. */
32
+ export declare function typedAxisValue(snapshot: Snapshot, id: AxisId): string;
33
+ /** Both sections as one column. `index` runs over the axes and then the facets
34
+ * — the order the shell routes a cycle in — while the section headings and the
35
+ * blank rows between them are drawn but never selectable. */
36
+ export declare function renderRail(opts: {
37
+ axes: Axis[];
38
+ facets: Facet[];
39
+ index: number;
40
+ width: number;
41
+ height: number;
42
+ focused: boolean;
43
+ /** Documents the filters select, out of the whole corpus. */
44
+ matched: number;
45
+ total: number;
46
+ }): string[];
@@ -1,11 +1,45 @@
1
- // The snapshot rail: the configuration axes the page plans against. Every row
2
- // is a live control — an enum cycles in place, a free-text axis opens a field —
3
- // and every change re-plans the whole surface.
1
+ // The rail: two sections in one row grammar. SNAPSHOT holds the configuration
2
+ // axes the page plans against — changing one re-plans every event. FILTERS holds
3
+ // the question asked of that plan — changing one only changes what is shown.
4
+ // An enum cycles in place; a free-text row opens a field.
4
5
  import { truncateToWidth } from '@earendil-works/pi-tui';
5
- import { padAnsi, theme, visibleRange } from '../../../../core/tui/panel.js';
6
- import { SURFACE_EVENTS } from '../../../../core/substrate/schema.js';
6
+ import { padAnsi, theme } from '../../../../core/tui/panel.js';
7
+ import { KINDS, RUNGS, SURFACE_EVENTS } from '../../../../core/substrate/schema.js';
7
8
  import { listProfiles } from '../../../../core/profiles/manifest.js';
8
9
  import { payloadAxisFor } from './model.js';
10
+ const RUNG_FACETS = ['any', ...RUNGS.filter((rung) => rung !== 'none'), 'silent'];
11
+ const ORIGIN_FACETS = ['any', 'project', 'user', 'profile', 'builtin', 'node'];
12
+ const KIND_FACETS = ['any', ...KINDS];
13
+ const STATE_FACETS = ['any', 'delivering', 'gated', 'shadowed', 'no-entry'];
14
+ /** The FILTERS rows for one filter value. `doc kind` is spelled out because the
15
+ * snapshot above it dials the NODE's kind. */
16
+ export function facetsOf(filters) {
17
+ return [
18
+ { id: 'rung', label: 'rung', value: filters.rung, typed: false },
19
+ { id: 'origin', label: 'origin', value: filters.origin, typed: false },
20
+ { id: 'kind', label: 'doc kind', value: filters.kind, typed: false },
21
+ { id: 'state', label: 'state', value: filters.state, typed: false },
22
+ { id: 'query', label: 'search', value: filters.query === '' ? 'any' : filters.query, typed: true },
23
+ ];
24
+ }
25
+ export function cycleFacet(filters, id, delta) {
26
+ const step = (values, current) => {
27
+ const at = values.indexOf(current);
28
+ return values[(Math.max(0, at) + delta + values.length) % values.length];
29
+ };
30
+ switch (id) {
31
+ case 'rung':
32
+ return { ...filters, rung: step(RUNG_FACETS, filters.rung) };
33
+ case 'origin':
34
+ return { ...filters, origin: step(ORIGIN_FACETS, filters.origin) };
35
+ case 'kind':
36
+ return { ...filters, kind: step(KIND_FACETS, filters.kind) };
37
+ case 'state':
38
+ return { ...filters, state: step(STATE_FACETS, filters.state) };
39
+ case 'query':
40
+ return filters;
41
+ }
42
+ }
9
43
  function tildify(path) {
10
44
  const home = process.env['HOME'];
11
45
  return home !== undefined && home !== '' && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
@@ -92,26 +126,35 @@ export function typedAxisValue(snapshot, id) {
92
126
  return axis === 'file' ? snapshot.file : axis === 'docName' ? snapshot.docName : axis === 'command' ? snapshot.command : '';
93
127
  }
94
128
  const LABEL_WIDTH = 10;
95
- export function renderAxes(axes, index, width, height, focused) {
96
- const out = [theme.fg('dim', ' SNAPSHOT'), ''];
97
- const headerRows = out.length;
98
- const rows = [];
99
- for (const [at, axis] of axes.entries()) {
129
+ /** Both sections as one column. `index` runs over the axes and then the facets
130
+ * — the order the shell routes a cycle in — while the section headings and the
131
+ * blank rows between them are drawn but never selectable. */
132
+ export function renderRail(opts) {
133
+ const { axes, facets, index, width, height, focused, matched, total } = opts;
134
+ const lines = [];
135
+ let cursorLine = 0;
136
+ const push = (at, label, value) => {
100
137
  const room = Math.max(1, width - LABEL_WIDTH - 2);
101
- const label = padAnsi(truncateToWidth(axis.label, LABEL_WIDTH), LABEL_WIDTH);
102
- const value = truncateToWidth(axis.value, room);
103
- const body = ` ${label} ${value}`;
138
+ const padded = padAnsi(truncateToWidth(label, LABEL_WIDTH), LABEL_WIDTH);
139
+ const shown = truncateToWidth(value, room);
140
+ const body = ` ${padded} ${shown}`;
141
+ if (at === index)
142
+ cursorLine = lines.length;
104
143
  if (at === index && focused)
105
- rows.push(theme.bg('selectedBg', padAnsi(body, width)));
144
+ lines.push(theme.bg('selectedBg', padAnsi(body, width)));
106
145
  else if (at === index)
107
- rows.push(`${theme.fg('accent', '▌')}${padAnsi(theme.fg('accent', body.slice(1)), width - 1)}`);
146
+ lines.push(`${theme.fg('accent', '▌')}${padAnsi(theme.fg('accent', body.slice(1)), width - 1)}`);
108
147
  else
109
- rows.push(padAnsi(` ${theme.fg('dim', label)} ${value}`, width));
110
- }
111
- const capacity = Math.max(1, height - headerRows);
112
- const range = visibleRange(rows.length, index, capacity);
113
- out.push(...rows.slice(range.start, range.start + capacity));
148
+ lines.push(padAnsi(` ${theme.fg('dim', padded)} ${shown}`, width));
149
+ };
150
+ lines.push(theme.fg('dim', ' SNAPSHOT'), '');
151
+ axes.forEach((axis, at) => push(at, axis.label, axis.value));
152
+ lines.push('', theme.fg('dim', ` FILTERS · ${matched} of ${total}`), '');
153
+ facets.forEach((facet, at) => push(axes.length + at, facet.label, facet.value));
154
+ const maxStart = Math.max(0, lines.length - height);
155
+ const start = Math.min(Math.max(0, cursorLine - Math.floor(height / 2)), maxStart);
156
+ const out = lines.slice(start, start + height);
114
157
  while (out.length < height)
115
158
  out.push('');
116
- return out.slice(0, height);
159
+ return out;
117
160
  }
@@ -14,7 +14,11 @@ export declare class ContextAdminShell implements Component, Focusable {
14
14
  private snapshot;
15
15
  private plans;
16
16
  private zone;
17
- private axisIndex;
17
+ private railIndex;
18
+ private filters;
19
+ private rows;
20
+ /** The search field owns the keyboard while it is open. */
21
+ private searching;
18
22
  private readonly docs;
19
23
  private readonly detail;
20
24
  private modal;
@@ -35,6 +39,9 @@ export declare class ContextAdminShell implements Component, Focusable {
35
39
  /** Recompute the focused event's plan, refresh the panels from it, and let
36
40
  * the remaining events fill in behind it. */
37
41
  private rebuild;
42
+ /** A filter never re-plans: it is a question about the plan already computed,
43
+ * which is what keeps a facet change instant on a corpus this size. */
44
+ private setFilters;
38
45
  private syncDetail;
39
46
  private setSnapshot;
40
47
  private matches;
@@ -47,8 +54,14 @@ export declare class ContextAdminShell implements Component, Focusable {
47
54
  * drawn against, so it earns the page-level gesture. */
48
55
  private cyclesEvent;
49
56
  handleInput(data: string): void;
50
- private handleAxes;
57
+ /** The rail's rows are the snapshot axes followed by the filter facets, so
58
+ * one cursor and one gesture drive both sections. */
59
+ private handleRail;
51
60
  private handleDocs;
61
+ private openSearch;
62
+ /** Typing filters live, so the field is the list's own heading rather than a
63
+ * modal over the rows it is selecting. */
64
+ private handleSearch;
52
65
  private handleDetail;
53
66
  /** What the focused dossier row does when opened — the same write its letter
54
67
  * key starts, so a user who never learns the letters can still edit. */
@@ -78,5 +91,9 @@ export declare class ContextAdminShell implements Component, Focusable {
78
91
  private renderModal;
79
92
  private footer;
80
93
  render(width: number): string[];
94
+ /** What the delivering documents spend on this event. An estimate, and a
95
+ * floor on the rendered prompt: boot wraps these documents in its own intro
96
+ * prose and catalog tree. */
97
+ private costMeta;
81
98
  }
82
99
  export declare function runContextAdmin(seed: Snapshot): Promise<AdminResult | undefined>;
@@ -1,7 +1,7 @@
1
- // The `crtr sys context admin` page: a snapshot rail, the document matrix, and
2
- // the dossier that edits one document's routing. The rail decides WHAT is being
3
- // planned, the matrix shows WHERE every document lands, and the dossier is the
4
- // only place a write starts.
1
+ // The `crtr sys context admin` page: a rail, the document matrix, and the
2
+ // dossier that edits one document's routing. The rail decides WHAT is being
3
+ // planned and WHICH slice of it is shown, the matrix shows WHERE every document
4
+ // lands, and the dossier is the only place a write starts.
5
5
  import { Input, matchesKey, truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
6
6
  import { formatGesture, matchesPiTuiInput, } from '../../../../core/keybindings/index.js';
7
7
  import { runTuiPage } from '../../../../core/tui/page-host.js';
@@ -10,14 +10,26 @@ import { RUNGS, SURFACE_EVENTS, SURFACE_RUNGS, } from '../../../../core/substrat
10
10
  import { profileNameFor } from '../../../../core/substrate/subject-fields.js';
11
11
  import { editBufferInEditor } from '../../../../clients/inbox/review/roundtrip.js';
12
12
  import { capOwnerFor, moveDoc, readBody, readOnlyReason, toggleFlag, writeBody, writeCap, writeGate, writeRoutingLine, writeShortForm, writeSurfaces, } from './actions.js';
13
- import { axesOf, cycleAxis, setTypedAxis, renderAxes, typedAxisValue } from './axes-panel.js';
13
+ import { axesOf, cycleAxis, cycleFacet, facetsOf, renderRail, setTypedAxis, typedAxisValue, } from './rail-panel.js';
14
14
  import { ContextDetailPanel } from './detail-panel.js';
15
15
  import { ContextDocsPanel, buildRows } from './docs-panel.js';
16
- import { PlanSet, plural } from './model.js';
16
+ import { NO_FILTERS, costTotals } from './list-view.js';
17
+ import { PlanSet, formatCost, plural } from './model.js';
17
18
  const RAIL_WIDTH = 26;
18
- const DOCS_WIDTH = 38;
19
19
  const RAIL_MIN_TOTAL_WIDTH = 86;
20
20
  const THREE_COLUMN_MIN_WIDTH = 112;
21
+ /** Columns the two dividers between the three zones spend. */
22
+ const DIVIDER_WIDTH = 6;
23
+ /** Below the floor a row cannot hold a name beside the matrix; above the
24
+ * ceiling extra width stops buying legibility and the dossier wants it. */
25
+ const DOCS_MIN_WIDTH = 48;
26
+ const DOCS_MAX_WIDTH = 100;
27
+ const DOCS_SHARE = 0.45;
28
+ /** The list's share of what is left after the rail and the dividers. */
29
+ function docsWidthFor(width) {
30
+ const room = Math.max(1, width - RAIL_WIDTH - DIVIDER_WIDTH);
31
+ return Math.max(DOCS_MIN_WIDTH, Math.min(DOCS_MAX_WIDTH, Math.floor(room * DOCS_SHARE)));
32
+ }
21
33
  export class ContextAdminShell {
22
34
  tui;
23
35
  terminalRows;
@@ -25,8 +37,12 @@ export class ContextAdminShell {
25
37
  suspendForChild;
26
38
  snapshot;
27
39
  plans;
28
- zone = 'axes';
29
- axisIndex = 0;
40
+ zone = 'rail';
41
+ railIndex = 0;
42
+ filters = NO_FILTERS;
43
+ rows = [];
44
+ /** The search field owns the keyboard while it is open. */
45
+ searching = false;
30
46
  docs;
31
47
  detail = new ContextDetailPanel();
32
48
  modal;
@@ -64,14 +80,23 @@ export class ContextAdminShell {
64
80
  * the remaining events fill in behind it. */
65
81
  rebuild(selectedKey) {
66
82
  this.plans.plan(this.snapshot.event);
67
- this.docs.setRows(buildRows(this.plans, this.snapshot.event), selectedKey ?? this.docs.selected?.key);
83
+ this.rows = buildRows(this.plans, this.snapshot.event);
84
+ this.docs.setRows(this.rows, this.snapshot.event, selectedKey ?? this.docs.selected?.key);
68
85
  this.syncDetail();
69
86
  this.plans.warm(() => {
70
- this.docs.setRows(buildRows(this.plans, this.snapshot.event), this.docs.selected?.key);
87
+ this.rows = buildRows(this.plans, this.snapshot.event);
88
+ this.docs.setRows(this.rows, this.snapshot.event, this.docs.selected?.key);
71
89
  this.syncDetail();
72
90
  this.tui.requestRender();
73
91
  });
74
92
  }
93
+ /** A filter never re-plans: it is a question about the plan already computed,
94
+ * which is what keeps a facet change instant on a corpus this size. */
95
+ setFilters(next) {
96
+ this.filters = next;
97
+ this.docs.setFilters(next);
98
+ this.syncDetail();
99
+ }
75
100
  syncDetail() {
76
101
  const row = this.docs.selected;
77
102
  const capOwner = row === undefined ? null : capOwnerFor(row.anchor, this.snapshot.profileId);
@@ -124,8 +149,8 @@ export class ContextAdminShell {
124
149
  this.handleModal(data);
125
150
  return;
126
151
  }
127
- if (this.docs.ownsKeyboard && this.docs.handleInput(data)) {
128
- this.syncDetail();
152
+ if (this.searching) {
153
+ this.handleSearch(data);
129
154
  return;
130
155
  }
131
156
  const step = this.cyclesEvent(data);
@@ -137,32 +162,48 @@ export class ContextAdminShell {
137
162
  this.close();
138
163
  return;
139
164
  }
140
- if (this.zone === 'axes')
141
- this.handleAxes(data);
165
+ if (this.zone === 'rail')
166
+ this.handleRail(data);
142
167
  else if (this.zone === 'docs')
143
168
  this.handleDocs(data);
144
169
  else
145
170
  this.handleDetail(data);
146
171
  }
147
- handleAxes(data) {
172
+ /** The rail's rows are the snapshot axes followed by the filter facets, so
173
+ * one cursor and one gesture drive both sections. */
174
+ handleRail(data) {
148
175
  const axes = axesOf(this.snapshot, profileNameFor(this.snapshot.profileId) ?? 'none');
149
- this.axisIndex = Math.max(0, Math.min(this.axisIndex, axes.length - 1));
150
- const axis = axes[this.axisIndex];
176
+ const facets = facetsOf(this.filters);
177
+ const total = axes.length + facets.length;
178
+ this.railIndex = Math.max(0, Math.min(this.railIndex, total - 1));
179
+ const axis = axes[this.railIndex];
180
+ const facet = facets[this.railIndex - axes.length];
181
+ const change = (delta) => {
182
+ if (axis !== undefined) {
183
+ if (axis.typed)
184
+ this.openAxisField(axis.id, axis.label);
185
+ else
186
+ this.setSnapshot(cycleAxis(this.snapshot, axis.id, delta));
187
+ }
188
+ else if (facet !== undefined) {
189
+ if (facet.typed)
190
+ this.openSearch();
191
+ else
192
+ this.setFilters(cycleFacet(this.filters, facet.id, delta));
193
+ }
194
+ };
151
195
  if (this.isDown(data)) {
152
- this.axisIndex = Math.min(axes.length - 1, this.axisIndex + 1);
196
+ this.railIndex = Math.min(total - 1, this.railIndex + 1);
153
197
  }
154
198
  else if (this.isUp(data)) {
155
- this.axisIndex = Math.max(0, this.axisIndex - 1);
199
+ this.railIndex = Math.max(0, this.railIndex - 1);
156
200
  }
157
201
  else if (matchesKey(data, 'space') || this.matches('crtr.setup.toggle', data)) {
158
- if (axis.typed)
159
- this.openAxisField(axis.id, axis.label);
160
- else
161
- this.setSnapshot(cycleAxis(this.snapshot, axis.id, 1));
202
+ change(1);
162
203
  }
163
204
  else if (this.isDescend(data)) {
164
- if (matchesKey(data, 'enter') && axis.typed)
165
- this.openAxisField(axis.id, axis.label);
205
+ if (matchesKey(data, 'enter') && (axis?.typed === true || facet?.typed === true))
206
+ change(1);
166
207
  else
167
208
  this.zone = 'docs';
168
209
  }
@@ -171,11 +212,10 @@ export class ContextAdminShell {
171
212
  }
172
213
  }
173
214
  handleDocs(data) {
174
- if (this.docs.handleInput(data)) {
175
- this.syncDetail();
176
- return;
215
+ if (data === '/' || matchesKey(data, '/')) {
216
+ this.openSearch();
177
217
  }
178
- if (this.isDown(data)) {
218
+ else if (this.isDown(data)) {
179
219
  this.docs.move(1);
180
220
  this.syncDetail();
181
221
  }
@@ -183,18 +223,48 @@ export class ContextAdminShell {
183
223
  this.docs.move(-1);
184
224
  this.syncDetail();
185
225
  }
186
- else if (matchesKey(data, 'd')) {
187
- this.docs.toggleDeliveringOnly();
226
+ else if (matchesKey(data, 'space') || this.matches('crtr.setup.toggle', data)) {
227
+ this.docs.toggleGroup();
188
228
  this.syncDetail();
189
229
  }
190
230
  else if (this.isDescend(data)) {
191
- if (this.docs.selected !== undefined)
231
+ if (this.docs.onGroup) {
232
+ this.docs.toggleGroup();
233
+ this.syncDetail();
234
+ }
235
+ else if (this.docs.selected !== undefined)
192
236
  this.zone = 'detail';
193
237
  }
238
+ else if (matchesKey(data, 'escape') && this.filters.query !== '') {
239
+ this.setFilters({ ...this.filters, query: '' });
240
+ }
194
241
  else if (this.isAscend(data) || matchesKey(data, 'escape')) {
195
- this.zone = 'axes';
242
+ this.zone = 'rail';
196
243
  }
197
244
  }
245
+ openSearch() {
246
+ this.searching = true;
247
+ this.zone = 'docs';
248
+ this.tui.requestRender();
249
+ }
250
+ /** Typing filters live, so the field is the list's own heading rather than a
251
+ * modal over the rows it is selecting. */
252
+ handleSearch(data) {
253
+ if (matchesKey(data, 'escape')) {
254
+ this.searching = false;
255
+ this.setFilters({ ...this.filters, query: '' });
256
+ }
257
+ else if (matchesKey(data, 'enter')) {
258
+ this.searching = false;
259
+ }
260
+ else if (matchesKey(data, 'backspace')) {
261
+ this.setFilters({ ...this.filters, query: this.filters.query.slice(0, -1) });
262
+ }
263
+ else if (data.length === 1 && data.charCodeAt(0) >= 32) {
264
+ this.setFilters({ ...this.filters, query: this.filters.query + data });
265
+ }
266
+ this.tui.requestRender();
267
+ }
198
268
  handleDetail(data) {
199
269
  if (this.isDown(data))
200
270
  this.detail.move(1);
@@ -570,15 +640,15 @@ export class ContextAdminShell {
570
640
  return theme.fg('dim', 'y/enter confirm · n/esc cancel');
571
641
  if (this.modal?.kind === 'field')
572
642
  return theme.fg('dim', 'type · enter apply · esc cancel');
573
- if (this.docs.ownsKeyboard)
574
- return theme.fg('dim', 'type to filter · enter keep · esc clear');
643
+ if (this.searching)
644
+ return theme.fg('dim', 'type to search names · enter keep · esc clear');
575
645
  if (this.notice !== undefined)
576
646
  return theme.fg(this.notice.ok ? 'success' : 'warning', truncateToWidth(this.notice.text, width));
577
647
  const move = `${this.label('crtr.setup.list-down', 'j')}/${this.label('crtr.setup.list-up', 'k')}`;
578
- const left = this.zone === 'axes'
579
- ? `${move} axis · space change · enter documents`
648
+ const left = this.zone === 'rail'
649
+ ? `${move} row · space change · enter documents`
580
650
  : this.zone === 'docs'
581
- ? `${move} doc · / filter · d ${this.docs.filteringDelivering ? 'all' : 'delivering'} · enter dossier`
651
+ ? `${move} row · space ${this.docs.onGroup ? 'open group' : 'group'} · / search · enter dossier`
582
652
  : `${move} field · enter edit · e add · x remove · g gate · b body · u unlisted · s slash · m cap · r rename`;
583
653
  const right = `${this.label('crtr.setup.tab-next', 'tab')} event · ${this.label('crtr.setup.cancel', 'q')} close`;
584
654
  const gap = width - visibleWidth(left) - visibleWidth(right);
@@ -591,14 +661,26 @@ export class ContextAdminShell {
591
661
  const bodyHeight = Math.max(1, height - PAGE_CHROME_ROWS);
592
662
  const railVisible = width >= RAIL_MIN_TOTAL_WIDTH;
593
663
  const axes = axesOf(this.snapshot, profileNameFor(this.snapshot.profileId) ?? 'none');
594
- this.axisIndex = Math.max(0, Math.min(this.axisIndex, axes.length - 1));
664
+ const facets = facetsOf(this.filters);
665
+ this.railIndex = Math.max(0, Math.min(this.railIndex, axes.length + facets.length - 1));
666
+ const rail = (railWidth) => renderRail({
667
+ axes,
668
+ facets,
669
+ index: this.railIndex,
670
+ width: railWidth,
671
+ height: bodyHeight,
672
+ focused: this.zone === 'rail',
673
+ matched: this.docs.visibleCount,
674
+ total: this.rows.length,
675
+ });
595
676
  let body;
596
677
  if (width >= THREE_COLUMN_MIN_WIDTH) {
597
- const detailWidth = Math.max(1, width - RAIL_WIDTH - DOCS_WIDTH - 6);
598
- const middle = columns(this.docs.render(DOCS_WIDTH, bodyHeight, this.zone === 'docs', this.plans), DOCS_WIDTH, this.modal === undefined
678
+ const docsWidth = docsWidthFor(width);
679
+ const detailWidth = Math.max(1, width - RAIL_WIDTH - docsWidth - DIVIDER_WIDTH);
680
+ const middle = columns(this.docs.render(docsWidth, bodyHeight, this.zone === 'docs', this.plans), docsWidth, this.modal === undefined
599
681
  ? this.detail.render(detailWidth, bodyHeight, this.zone === 'detail')
600
682
  : this.renderModal(detailWidth, bodyHeight), detailWidth);
601
- body = columns(renderAxes(axes, this.axisIndex, RAIL_WIDTH, bodyHeight, this.zone === 'axes'), RAIL_WIDTH, middle, width - RAIL_WIDTH - 3);
683
+ body = columns(rail(RAIL_WIDTH), RAIL_WIDTH, middle, width - RAIL_WIDTH - 3);
602
684
  }
603
685
  else if (railVisible) {
604
686
  const panelWidth = width - RAIL_WIDTH - 3;
@@ -607,13 +689,13 @@ export class ContextAdminShell {
607
689
  : this.zone === 'detail'
608
690
  ? this.detail.render(panelWidth, bodyHeight, true)
609
691
  : this.docs.render(panelWidth, bodyHeight, this.zone === 'docs', this.plans);
610
- body = columns(renderAxes(axes, this.axisIndex, RAIL_WIDTH, bodyHeight, this.zone === 'axes'), RAIL_WIDTH, panel, panelWidth);
692
+ body = columns(rail(RAIL_WIDTH), RAIL_WIDTH, panel, panelWidth);
611
693
  }
612
694
  else if (this.modal !== undefined) {
613
695
  body = this.renderModal(width, bodyHeight);
614
696
  }
615
- else if (this.zone === 'axes') {
616
- body = renderAxes(axes, this.axisIndex, width, bodyHeight, true);
697
+ else if (this.zone === 'rail') {
698
+ body = rail(width);
617
699
  }
618
700
  else if (this.zone === 'detail') {
619
701
  body = this.detail.render(width, bodyHeight, true);
@@ -624,6 +706,7 @@ export class ContextAdminShell {
624
706
  const pending = SURFACE_EVENTS.filter((event) => this.plans.peek(event) === undefined).length;
625
707
  const meta = [
626
708
  this.snapshot.event,
709
+ this.costMeta(),
627
710
  pending > 0 ? `${plural(pending, 'event', 'events')} planning` : 'all events planned',
628
711
  this.edited.size > 0 ? `${plural(this.edited.size, 'doc', 'docs')} edited` : '',
629
712
  ]
@@ -631,6 +714,19 @@ export class ContextAdminShell {
631
714
  .join(' · ');
632
715
  return pageFrame({ width, height, title: 'crouter context', meta, body, footer: this.footer(width) });
633
716
  }
717
+ /** What the delivering documents spend on this event. An estimate, and a
718
+ * floor on the rendered prompt: boot wraps these documents in its own intro
719
+ * prose and catalog tree. */
720
+ costMeta() {
721
+ const { total, byRung } = costTotals(this.rows, this.snapshot.event);
722
+ if (total === 0)
723
+ return 'no documents delivered';
724
+ const split = ['content', 'preview', 'name']
725
+ .filter((rung) => byRung[rung] > 0)
726
+ .map((rung) => `${formatCost(byRung[rung]).replace('~', '')} ${rung}`)
727
+ .join(' · ');
728
+ return `docs load ${formatCost(total)} — ${split}`;
729
+ }
634
730
  }
635
731
  /** Every field here is an edit-or-accept, never a blank to retype, so the
636
732
  * cursor lands at the end (`\x05`) — `setValue` alone leaves it at 0, where
@@ -80,7 +80,7 @@ const daemonRestart = defineLeaf({
80
80
  effects: [
81
81
  'The daemon acknowledges immediately, then after a short grace tears its whole broker fleet down, releases its claim, spawns a successor on the runtime generation currently selected, and exits.',
82
82
  'Each torn-down broker hands its foreground bash commands to the file-backed background job system; they keep running and report completion by urgent inbox message.',
83
- 'Every node it tore down — including the caller — is resumed by the successor through the ordinary startup recovery sweep.',
83
+ 'Every node it tore down — including the caller — is resolved by the successor through the ordinary row-driven supervision tick.',
84
84
  'The calling process is killed as part of that teardown, AFTER this result is returned.',
85
85
  ],
86
86
  },
@@ -1,30 +1,4 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/child-death-wake.test.ts
2
- //
3
- // DOCTRINE WAKE — "a parent that delegated and went dormant is still woken when
4
- // its child reaches a genuine terminal outcome, and is NEVER spuriously woken on
5
- // healthy dormancy." Drives the REAL daemon decision pass (superviseTick) + the
6
- // REAL closeNode against canvas rows fabricated DIRECTLY in an isolated home — NO
7
- // real tmux session, NO broker boot.
8
- //
9
- // BROKER CUT (this worktree): the daemon's OLD parent-wake mechanism is GONE.
10
- // U7 deleted surfaceChildDeath and the entire pane-gone reaping block (the
11
- // crash/finalize/release routing that used to mark a pane-gone child dead/done/
12
- // idle and fan a system inbox entry to its subscribers). Under exit-event
13
- // authority a viewer pane/window closing is NOT a node death, and the tick runs
14
- // no liveness pass at all — a booted child's death is observed only through its
15
- // broker's real exit event (fleet exit-policy job), which recovery-revives it
16
- // on its saved session rather than surfacing it as dead. The doctrine wake
17
- // therefore RELOCATED off the daemon's tick:
18
- // • child finishes → `crtr push final` wakes subscribers via the PUSH itself;
19
- // • child crashes → its exit event drives a recovery revive (it comes back
20
- // and pushes later);
21
- // • child never boots → the exit event terminalizes it and surfaceBootFailure
22
- // pushes urgent (daemon-boot.test);
23
- // • child `node close`d → close.ts fans the child-closed wake (tested below).
24
- // So the daemon now fans NO liveness wake at all, which makes the old CRUX
25
- // (healthy dormancy must not wake the parent) hold by construction. This file
26
- // locks in (a) the new pid-only non-reaping + no-daemon-wake contract, and (b)
27
- // the one genuine-terminal wake that still lives in core: close.ts.
28
2
  import { test, before, after, beforeEach } from 'node:test';
29
3
  import assert from 'node:assert/strict';
30
4
  import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
@@ -112,15 +86,6 @@ after(() => {
112
86
  delete process.env['CRTR_HOME'];
113
87
  resetFleetForTests();
114
88
  });
115
- // BROKER CUT — exit-event authority REPLACES daemon pane-gone reaping. A booted
116
- // child whose engine pid is dead (and whose viewer pane is gone) is RECOVERED,
117
- // not reaped, and the daemon's tick fans NO wake to the dormant parent. This
118
- // single case subsumes the four deleted scenarios (crash / quiet-finalize /
119
- // awaiting a live grandchild / pending self-wake): with pane state ignored and
120
- // surfaceChildDeath gone, the daemon treats them all identically — no reap, no
121
- // liveness wake — so the old CRUX (no spurious wake on healthy dormancy) holds
122
- // by construction. The node-lifecycle tick relaunches the row through the same
123
- // policy table its exit event would have used (D-12); that recovery is silent.
124
89
  test('exit-event authority: a dead-engine booted child is recovered, NOT reaped, and the tick fans NO wake to its dormant parent', async () => {
125
90
  const originalLaunch = headlessBrokerHost.launch;
126
91
  const launches = [];
@@ -139,9 +104,6 @@ test('exit-event authority: a dead-engine booted child is recovered, NOT reaped,
139
104
  // its saved session, never reaped to dead/done/idle.
140
105
  assert.deepEqual(launches, ['CHILD'], 'the tick recovers the dead engine through the policy table');
141
106
  assert.equal(getNode('CHILD').status, 'active', 'CHILD is recovered, NOT reaped');
142
- // surfaceChildDeath is deleted: the daemon raises no liveness wake. The
143
- // doctrine wake now rides the child's own `push final` (or close.ts), so a
144
- // purely-inbox-waiting parent is never spuriously woken on dormancy/crash.
145
107
  assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — no daemon liveness wake');
146
108
  }
147
109
  finally {
@@ -163,13 +125,3 @@ test('node close of a child wakes its SURVIVING manager (the parent outside the
163
125
  assert.ok(wake, 'node close fanned a child-closed entry to the surviving manager (D-1: previously none)');
164
126
  assert.match(wake.label, /closed/i, 'the entry names the closed child');
165
127
  });
166
- // NOTE (broker cut): the two NEGATIVE "CRUX" tests that used to live here — a
167
- // child dormant while awaiting a live grandchild, and a child dormant on a
168
- // pending self-wake — asserted the daemon's pane-gone routing chose idle-release
169
- // (status 'idle', intent 'idle-release') over finalize, so it would not spuriously
170
- // wake the parent. U7 DELETED that routing entirely: pane state is ignored, the
171
- // daemon never finalizes/releases on liveness, and surfaceChildDeath is gone, so
172
- // the daemon raises no liveness wake at all. The no-spurious-wake invariant they
173
- // protected now holds by construction and is covered by the pid-only test above
174
- // (PARENT inbox stays empty). Removed rather than adapted because the
175
- // idle-release-vs-finalize distinction they asserted no longer exists.