@north-light/crouter 0.3.299 → 0.3.301

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 (29) hide show
  1. package/dist/clients/attach/session/profile-files.d.ts +6 -3
  2. package/dist/clients/attach/session/profile-files.js +39 -10
  3. package/dist/clients/attach/viewer.js +663 -663
  4. package/dist/commands/__tests__/node-message.test.js +90 -5
  5. package/dist/commands/node/create.js +1 -1
  6. package/dist/commands/node/lifecycle.js +29 -2
  7. package/dist/commands/node/message.js +20 -9
  8. package/dist/core/__tests__/human-action-delivery.test.js +13 -11
  9. package/dist/core/__tests__/node-outcome.test.js +1 -6
  10. package/dist/core/bash-jobs.d.ts +22 -0
  11. package/dist/core/bash-jobs.js +38 -1
  12. package/dist/core/canvas/delivery-contract.d.ts +19 -0
  13. package/dist/core/canvas/delivery-contract.js +8 -0
  14. package/dist/core/canvas/human-deliveries.d.ts +3 -17
  15. package/dist/core/canvas/node-outcome-deliveries.d.ts +3 -19
  16. package/dist/core/canvas/node-outcome-deliveries.js +0 -4
  17. package/dist/core/command.js +2 -1
  18. package/dist/core/help.d.ts +7 -0
  19. package/dist/core/keybindings/catalog.js +1 -1
  20. package/dist/daemon/__tests__/node-outcome-delivery.test.js +3 -2
  21. package/dist/daemon/delivery/run-delivery.d.ts +25 -0
  22. package/dist/daemon/delivery/run-delivery.js +164 -0
  23. package/dist/daemon/human/deliver-action.d.ts +1 -14
  24. package/dist/daemon/human/deliver-action.js +5 -165
  25. package/dist/daemon/node-outcome/deliver-outcome.d.ts +1 -12
  26. package/dist/daemon/node-outcome/deliver-outcome.js +6 -152
  27. package/dist/pi-extensions/canvas-bash-valve.js +7 -10
  28. package/package.json +1 -1
  29. package/runtime.lock.json +5 -5
@@ -1,14 +1,99 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { renderLeafArgv } from '../../core/help.js';
3
+ import { PassThrough } from 'node:stream';
4
+ import { parseArgv } from '../../core/command.js';
5
+ import { renderBranch, renderLeafArgv } from '../../core/help.js';
4
6
  import { nodeMessage } from '../node/message.js';
7
+ function messageLeaf(name) {
8
+ const leaf = nodeMessage.children.find((child) => child.kind === 'leaf' && child.name === name);
9
+ if (leaf === undefined || leaf.kind !== 'leaf')
10
+ assert.fail(`node message ${name} leaf is missing`);
11
+ return leaf;
12
+ }
13
+ async function withFakeStdin(fake, fn) {
14
+ const real = process.stdin;
15
+ Object.defineProperty(process, 'stdin', { value: fake, configurable: true });
16
+ try {
17
+ return await fn();
18
+ }
19
+ finally {
20
+ Object.defineProperty(process, 'stdin', { value: real, configurable: true });
21
+ }
22
+ }
23
+ function pipedStdin(body) {
24
+ const stdin = new PassThrough();
25
+ stdin.end(body);
26
+ return stdin;
27
+ }
28
+ test('node message help names the target flag in the family and both child rubrics', () => {
29
+ const rendered = renderBranch(nodeMessage.help);
30
+ assert.match(rendered, /targeted with `--to <node-id>` or `--self`/);
31
+ assert.match(rendered, /send[\s\S]*?whenToUse="a target chosen with `--to <node-id>` or `--self` should have information/);
32
+ assert.match(rendered, /request[\s\S]*?whenToUse="a target chosen with `--to <node-id>` or `--self` must hand back one answer/);
33
+ });
5
34
  test('node message send help documents the reopen re-task semantics', () => {
6
- const send = nodeMessage.children.find((child) => child.kind === 'leaf' && child.name === 'send');
7
- if (send === undefined || send.kind !== 'leaf')
8
- assert.fail('node message send leaf is missing');
9
- const rendered = renderLeafArgv(send.help);
35
+ const rendered = renderLeafArgv(messageLeaf('send').help);
10
36
  assert.match(rendered, /--reopen/);
11
37
  assert.match(rendered, /Re-task the target: commits it resident and clears any finalization latch before delivery/);
12
38
  assert.match(rendered, /valid for live, parked, and finalized targets/);
13
39
  assert.match(rendered, /Without it, a finalized target rejects delivery before an inbox entry is appended or a revive is attempted/);
14
40
  });
41
+ test('node message send explains a positional node id without a target flag', async () => {
42
+ const send = messageLeaf('send');
43
+ const nodeId = '3zl47w7d-mtqb57ts-74ea25fe';
44
+ await assert.rejects(() => send.run({ body: nodeId }), (error) => {
45
+ assert.equal(error.code, 'bad_target');
46
+ assert.equal(error.message, 'no target given — the message body is not a node id');
47
+ assert.equal(error.details?.field, 'to');
48
+ assert.equal(error.details?.next, 'Pass the node id with --to <node-id> and provide the message body as a positional argument or on stdin, or use --self.');
49
+ return true;
50
+ });
51
+ const params = send.help.params;
52
+ if (params === undefined)
53
+ assert.fail('node message send input schema is missing');
54
+ await withFakeStdin(pipedStdin('A long message body.'), () => assert.rejects(() => parseArgv(params, [nodeId]), (error) => {
55
+ assert.equal(error.code, 'bad_invocation');
56
+ assert.equal(error.message, 'no target given — the positional argument is the message body, not a node id');
57
+ assert.equal(error.details?.field, 'to');
58
+ assert.equal(error.details?.next, 'Pass the node id with --to <node-id> and pipe the message body on stdin, or use --self.');
59
+ return true;
60
+ }));
61
+ });
62
+ test('node message request gives the same no-target guidance', async () => {
63
+ const request = messageLeaf('request');
64
+ const nodeId = '3zl47w7d-mtqb57ts-74ea25fe';
65
+ await assert.rejects(() => request.run({ body: nodeId, outputSchema: '{}' }), (error) => {
66
+ assert.equal(error.code, 'bad_target');
67
+ assert.equal(error.message, 'no target given — the message body is not a node id');
68
+ assert.equal(error.details?.field, 'to');
69
+ assert.equal(error.details?.next, 'Pass the node id with --to <node-id> and provide the message body as a positional argument or on stdin, or use --self.');
70
+ return true;
71
+ });
72
+ const params = request.help.params;
73
+ if (params === undefined)
74
+ assert.fail('node message request input schema is missing');
75
+ await withFakeStdin(pipedStdin('A long message body.'), () => assert.rejects(() => parseArgv(params, [nodeId]), (error) => {
76
+ assert.equal(error.code, 'bad_invocation');
77
+ assert.equal(error.message, 'no target given — the positional argument is the message body, not a node id');
78
+ assert.equal(error.details?.field, 'to');
79
+ assert.equal(error.details?.next, 'Pass the node id with --to <node-id> and pipe the message body on stdin, or use --self.');
80
+ return true;
81
+ }));
82
+ });
83
+ test('a targeted node message keeps positional-body parsing and generic stdin conflicts', async () => {
84
+ const send = messageLeaf('send');
85
+ const params = send.help.params;
86
+ if (params === undefined)
87
+ assert.fail('node message send input schema is missing');
88
+ const parsed = await withFakeStdin(pipedStdin(''), () => parseArgv(params, ['message body', '--to', 'target-node']));
89
+ assert.equal(parsed['body'], 'message body');
90
+ assert.equal(parsed['to'], 'target-node');
91
+ const genericParams = [{ kind: 'stdin', name: 'body', required: false, constraint: 'body' }];
92
+ await withFakeStdin(pipedStdin('A long message body.'), () => assert.rejects(() => parseArgv(genericParams, ['message body']), (error) => {
93
+ assert.equal(error.code, 'bad_invocation');
94
+ assert.equal(error.message, 'both a positional argument ("message body") and piped stdin were provided for body');
95
+ assert.equal(error.details?.field, 'body');
96
+ assert.equal(error.details?.next, 'Pass exactly one: either the positional argument or piped stdin, not both.');
97
+ return true;
98
+ }));
99
+ });
@@ -83,7 +83,7 @@ function nodeNewParams() {
83
83
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
84
84
  { kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
85
85
  { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an independent resident root with no parent or subscription. Mutually exclusive with --worktree.' },
86
- { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'Birth the node as resident: it parks and stays wakeable without being forced to submit a final. Applies to managed children and roots; roots default to resident.' },
86
+ { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'Birth the node as resident: it parks and stays wakeable without being forced to submit a final. Pass it ONLY when the user will open this node’s viewer pane and work with it back and forth; a child that only other nodes message stays terminal. Applies to managed children and roots; roots default to resident.' },
87
87
  { kind: 'flag', name: 'worktree', type: 'string', required: false, constraint: 'Local base branch for a crouter-managed worktree for this child. Mutually exclusive with --root.' },
88
88
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'Fork the new node from an existing node id, absolute session path, or partial pi session uuid.' },
89
89
  {
@@ -3,6 +3,8 @@ import { envNodeId } from '../../shared/env.js';
3
3
  import { defineBranch, defineLeaf } from '../../core/command.js';
4
4
  import { InputError } from '../../core/io.js';
5
5
  import { cliClient, getNodeOrNull, rethrowAsCliError } from '../api-client.js';
6
+ import { contextDir } from '../../core/canvas/paths.js';
7
+ import { DEFAULT_BASH_NICENESS, MAX_BASH_NICENESS, writeBashNiceness } from '../../core/bash-jobs.js';
6
8
  import { nodeReviveLeaf } from '../node-lifecycle-revive.js';
7
9
  import { assertKind, kindsStateBlock } from './create.js';
8
10
  import { MODEL_SPEC_FORMS } from '../../core/runtime/model-selection.js';
@@ -140,6 +142,7 @@ export const nodeConfig = defineLeaf({
140
142
  { 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 registered sub-kind is valid too by exact path but not listed here.' },
141
143
  { 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.' },
142
144
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Rename the node; if the node has a live window, also rename that viewer window.' },
145
+ { kind: 'flag', name: 'nice', type: 'int', required: false, constraint: `Scheduler niceness every command this node's bash tool starts at, 0–${MAX_BASH_NICENESS} (default ${DEFAULT_BASH_NICENESS}). ${DEFAULT_BASH_NICENESS} keeps the node's builds and test suites below the daemon, the brokers, the viewers, and the person's own shell; 0 lets them compete on equal terms with everything else on the host; ${MAX_BASH_NICENESS} makes them yield to it. Applies to the node's very next command, foreground and background alike.` },
143
146
  { kind: 'flag', name: 'cwd', type: 'string', required: false, constraint: 'Repair-only replacement launch directory. Must be a non-empty absolute existing directory and used alone; the daemon accepts it only for a dormant, non-forked node with no open managed worktree whose recorded cwd is gone. Clears the saved Pi session, so the next revive starts fresh while durable node work remains.' },
144
147
  ],
145
148
  output: [
@@ -149,6 +152,7 @@ export const nodeConfig = defineLeaf({
149
152
  { name: 'kind', type: 'string', required: false, constraint: 'The new kind, present only when --kind changed it.' },
150
153
  { name: 'mode', type: 'string', required: false, constraint: 'The new mode, present only when --mode changed it.' },
151
154
  { name: 'name', type: 'string', required: false, constraint: 'The new name, present only when --name changed it.' },
155
+ { name: 'nice', type: 'number', required: false, constraint: 'The new bash niceness, present only when --nice changed it.' },
152
156
  { name: 'cwd', type: 'string', required: false, constraint: 'The lexically resolved replacement directory, present only after a cwd repair.' },
153
157
  ],
154
158
  outputKind: 'object',
@@ -157,6 +161,7 @@ export const nodeConfig = defineLeaf({
157
161
  'Kind, mode, and lifecycle changes rebuild the launch spec from the fresh node meta; setting mode to orchestrator seeds a roadmap scaffold if absent.',
158
162
  'Name changes update the row and, when the node already has a live window, rename that viewer window to the new full name.',
159
163
  'A cwd repair writes only the lexically resolved cwd and clears saved Pi session pointers; the old transcript and durable node work remain untouched.',
164
+ 'A niceness change is stored with the node\'s own bash-job state, so it takes effect on the next command without a revive and survives one.',
160
165
  ],
161
166
  dynamicState: () => kindsStateBlock(),
162
167
  },
@@ -169,7 +174,7 @@ export const nodeConfig = defineLeaf({
169
174
  // clean listing error, and re-validated server-side against the TARGET
170
175
  // node's scope as a backstop.
171
176
  const cwdSpec = input['cwd'];
172
- if (cwdSpec !== undefined && ['model', 'kind', 'lifecycle', 'mode', 'name'].some((flag) => input[flag] !== undefined)) {
177
+ if (cwdSpec !== undefined && ['model', 'kind', 'lifecycle', 'mode', 'name', 'nice'].some((flag) => input[flag] !== undefined)) {
173
178
  throw new InputError({ error: 'cwd_not_exclusive', message: 'cwd repair cannot be combined with another config flag', field: 'cwd', next: 'Pass --cwd alone.' });
174
179
  }
175
180
  const modelSpec = input['model']?.trim();
@@ -180,6 +185,15 @@ export const nodeConfig = defineLeaf({
180
185
  const lifecycleSpec = input['lifecycle']?.trim();
181
186
  const modeSpec = input['mode']?.trim();
182
187
  const nameSpec = input['name'];
188
+ const niceSpec = input['nice'];
189
+ if (niceSpec !== undefined && (niceSpec < 0 || niceSpec > MAX_BASH_NICENESS)) {
190
+ throw new InputError({
191
+ error: 'bad_nice',
192
+ message: `niceness must be between 0 and ${MAX_BASH_NICENESS}: ${niceSpec}`,
193
+ field: 'nice',
194
+ next: `A negative niceness needs root, so pass 0–${MAX_BASH_NICENESS}.`,
195
+ });
196
+ }
183
197
  if (kindSpec !== undefined)
184
198
  assertKind(kindSpec);
185
199
  if (lifecycleSpec !== undefined && lifecycleSpec.toLowerCase() !== 'terminal' && lifecycleSpec.toLowerCase() !== 'resident') {
@@ -214,8 +228,17 @@ export const nodeConfig = defineLeaf({
214
228
  patch.cwd = cwdSpec;
215
229
  applied.push('cwd');
216
230
  }
231
+ // Niceness is node-local bash-lane state, not canvas state: it is stored
232
+ // beside this node's job dirs so the valve reads it when it starts the next
233
+ // command, rather than waiting for a launch recipe to be rebuilt.
234
+ if (niceSpec !== undefined) {
235
+ writeBashNiceness(contextDir(nodeId), niceSpec);
236
+ applied.push('nice');
237
+ if (applied.length === 1)
238
+ return { node_id: nodeId, nice: niceSpec };
239
+ }
217
240
  if (applied.length === 0) {
218
- throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name, --cwd.' });
241
+ throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name, --nice, --cwd.' });
219
242
  }
220
243
  const detail = await cliClient()
221
244
  .patchConfig(nodeId, patch)
@@ -233,6 +256,8 @@ export const nodeConfig = defineLeaf({
233
256
  result.mode = detail.mode;
234
257
  if (applied.includes('name'))
235
258
  result.name = detail.name;
259
+ if (applied.includes('nice'))
260
+ result.nice = niceSpec;
236
261
  return result;
237
262
  },
238
263
  render: (r) => {
@@ -249,6 +274,8 @@ export const nodeConfig = defineLeaf({
249
274
  bits.push(`mode=${r['mode']}`);
250
275
  if (r['name'] !== undefined)
251
276
  bits.push(`name=${r['name']}`);
277
+ if (r['nice'] !== undefined)
278
+ bits.push(`nice=${r['nice']}`);
252
279
  return `Configured ${r['node_id']} — ${bits.join(', ')}`;
253
280
  },
254
281
  });
@@ -11,15 +11,16 @@ function requireNonBlankDiscriminant(input, name) {
11
11
  throw new InputError({ error: 'empty_discriminant', message: `--${flag} must contain non-whitespace text`, received: typeof value === 'string' ? value : String(value), field: flag, next: `Pass a non-empty --${flag} value, or omit the flag.` });
12
12
  }
13
13
  }
14
- async function resolveMsgTarget(input) {
14
+ async function resolveMsgTarget(input, hasBody) {
15
15
  const to = input['to']?.trim();
16
16
  const self = input['self'] === true;
17
17
  const hasTo = to !== undefined && to !== '';
18
18
  if (hasTo === self) {
19
19
  throw new InputError({
20
20
  error: 'bad_target',
21
- message: hasTo && self ? '--to and --self are mutually exclusive' : 'exactly one of --to or --self is required',
22
- next: 'Pass --to <node-id> or --self.',
21
+ message: hasTo && self ? '--to and --self are mutually exclusive' : hasBody ? 'no target given — the message body is not a node id' : 'exactly one of --to or --self is required',
22
+ field: hasTo ? undefined : 'to',
23
+ next: hasTo ? 'Pass --to <node-id> or --self.' : hasBody ? 'Pass the node id with --to <node-id> and provide the message body as a positional argument or on stdin, or use --self.' : 'Pass --to <node-id> or --self.',
23
24
  });
24
25
  }
25
26
  if (self) {
@@ -41,9 +42,19 @@ function messageOutput() {
41
42
  { name: 'guidance', type: 'string', required: true, constraint: 'Immediate action confirmation.' },
42
43
  ];
43
44
  }
45
+ function missingMessageTargetForBody(input) {
46
+ const to = input['to']?.trim();
47
+ if ((to !== undefined && to !== '') || input['self'] === true)
48
+ return undefined;
49
+ return {
50
+ message: 'no target given — the positional argument is the message body, not a node id',
51
+ field: 'to',
52
+ next: 'Pass the node id with --to <node-id> and pipe the message body on stdin, or use --self.',
53
+ };
54
+ }
44
55
  function messageSendParams() {
45
56
  return [
46
- { kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. Required unless --situational-context is supplied.' },
57
+ { kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. Required unless --situational-context is supplied.', positionalStdinConflict: missingMessageTargetForBody },
47
58
  { kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to or --self is required.' },
48
59
  { kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node. Exactly one of --to or --self is required.' },
49
60
  { kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, default: 'normal', constraint: 'Delivery urgency. Deferred never wakes an idle node, except a terminal one, which has no later cycle to read it on and is raised to urgent.' },
@@ -53,7 +64,7 @@ function messageSendParams() {
53
64
  }
54
65
  function messageRequestParams() {
55
66
  return [
56
- { kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. The schema alone is a complete request.' },
67
+ { kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. The schema alone is a complete request.', positionalStdinConflict: missingMessageTargetForBody },
57
68
  { kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to or --self is required.' },
58
69
  { kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node. Exactly one of --to or --self is required.' },
59
70
  { kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal'], required: false, default: 'normal', constraint: 'Delivery urgency. Every accepted tier may revive a dormant target, because the request must be answered; deferred is rejected for that reason.' },
@@ -68,10 +79,10 @@ async function runMessageEngine(input) {
68
79
  if (situationalContextSupplied && situationalContextRaw === '') {
69
80
  throw new InputError({ error: 'empty_situational_context', message: '--situational-context must contain non-whitespace text', field: 'situational-context', next: 'Pass non-whitespace text, or omit --situational-context.' });
70
81
  }
71
- const { targetId } = await resolveMsgTarget(input);
72
82
  const bodyRaw = input['body'];
73
83
  const body = bodyRaw !== undefined ? bodyRaw.trim() : undefined;
74
84
  const hasBody = body !== undefined && body !== '';
85
+ const { targetId } = await resolveMsgTarget(input, hasBody);
75
86
  const tierRaw = input['tier'];
76
87
  const hasTier = tierRaw !== undefined && tierRaw !== '';
77
88
  const outputSchemaParsed = parseOutputSchemaValue(input['outputSchema']);
@@ -107,7 +118,7 @@ async function runMessageEngine(input) {
107
118
  const nodeMessageSend = defineLeaf({
108
119
  name: 'send',
109
120
  description: 'deliver an inbox message to an existing node immediately',
110
- whenToUse: 'a node that already exists should have information or new direction now — steering a worker mid-task, handing over a finding it needs, correcting a wrong turn, or waking a dormant node with fresh work. The target reads it and decides what to do with it. Use `node message request` instead when you need an answer back in a shape you specify, and `node subscription add` when the target should keep receiving this node’s pushes rather than this one delivery',
121
+ whenToUse: 'a target chosen with `--to <node-id>` or `--self` should have information or new direction now — steering a worker mid-task, handing over a finding it needs, correcting a wrong turn, or waking a dormant node with fresh work. The target reads it and decides what to do with it. Use `node message request` instead when you need an answer back in a shape you specify, and `node subscription add` when the target should keep receiving this node’s pushes rather than this one delivery',
111
122
  help: {
112
123
  name: 'node message send',
113
124
  summary: 'deliver an inbox message to an existing node immediately',
@@ -122,7 +133,7 @@ const nodeMessageSend = defineLeaf({
122
133
  const nodeMessageRequest = defineLeaf({
123
134
  name: 'request',
124
135
  description: 'deliver an immediate typed-output request to an existing node',
125
- whenToUse: 'a node that already exists must hand back one answer in a shape you fix — a choice among options, a structured extraction, a verdict your own logic will branch on — or must be held to answering at all before it goes quiet. Use `node message send` instead when prose in the target’s own judgment is enough, and `node new --output-schema` when no node holds the context yet and the work needs a fresh agent',
136
+ whenToUse: 'a target chosen with `--to <node-id>` or `--self` must hand back one answer in a shape you fix — a choice among options, a structured extraction, a verdict your own logic will branch on — or must be held to answering at all before it goes quiet. Use `node message send` instead when prose in the target’s own judgment is enough, and `node new --output-schema` when no node holds the context yet and the work needs a fresh agent',
126
137
  help: {
127
138
  name: 'node message request',
128
139
  summary: 'deliver an immediate typed-output request to an existing node',
@@ -144,7 +155,7 @@ export const nodeMessage = defineBranch({
144
155
  help: {
145
156
  name: 'node message',
146
157
  summary: 'deliver one immediate inbox entry to an existing node',
147
- model: 'Both children append one inbox entry to a node that already exists, targeted by id or --self, and may revive it if it is dormant. They differ in what the delivery obligates. An ordinary message obligates nothing: the target reads it and acts however it judges best. A request installs a one-off output schema and holds the target awake and owing until it answers; the answer is validated against the schema, written to the target’s result file, and pushed as an update report to the TARGET’S subscribers, so subscribe to it first if the answer has to reach you. Neither child creates that standing relationship — a durable delivery edge is `node subscription`, and work that needs a fresh agent is `node new`.',
158
+ model: 'Both children append one inbox entry to a node that already exists, targeted with `--to <node-id>` or `--self`, and may revive it if it is dormant. They differ in what the delivery obligates. An ordinary message obligates nothing: the target reads it and acts however it judges best. A request installs a one-off output schema and holds the target awake and owing until it answers; the answer is validated against the schema, written to the target’s result file, and pushed as an update report to the TARGET’S subscribers, so subscribe to it first if the answer has to reach you. Neither child creates that standing relationship — a durable delivery edge is `node subscription`, and work that needs a fresh agent is `node new`.',
148
159
  },
149
160
  children: [nodeMessageSend, nodeMessageRequest],
150
161
  });
@@ -5,7 +5,9 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { closeDb } from '../canvas/db.js';
7
7
  import { claimActionDelivery, enqueueActionDelivery, readActionDelivery, } from '../canvas/human-deliveries.js';
8
- import { actionDeliveryBackoffMs, actionDeliverySettlementForOutcome, deliverAction, } from '../../daemon/human/deliver-action.js';
8
+ import { deliveryBackoffMs } from '../canvas/delivery-contract.js';
9
+ import { deliverySettlementForOutcome } from '../../daemon/delivery/run-delivery.js';
10
+ import { deliverAction } from '../../daemon/human/deliver-action.js';
9
11
  let home;
10
12
  const priorHome = process.env['CRTR_HOME'];
11
13
  beforeEach(() => {
@@ -25,14 +27,14 @@ after(() => {
25
27
  else
26
28
  process.env['CRTR_HOME'] = priorHome;
27
29
  });
28
- test('action delivery retries follow the durable backoff and exit boundary', () => {
29
- assert.equal(actionDeliveryBackoffMs(1), 5_000);
30
- assert.equal(actionDeliveryBackoffMs(2), 10_000);
31
- assert.equal(actionDeliveryBackoffMs(3), 20_000);
32
- assert.equal(actionDeliveryBackoffMs(11), 3_600_000);
33
- assert.equal(actionDeliveryBackoffMs(100), 3_600_000);
30
+ test('delivery retries follow the durable backoff and exit boundary', () => {
31
+ assert.equal(deliveryBackoffMs(1), 5_000);
32
+ assert.equal(deliveryBackoffMs(2), 10_000);
33
+ assert.equal(deliveryBackoffMs(3), 20_000);
34
+ assert.equal(deliveryBackoffMs(11), 3_600_000);
35
+ assert.equal(deliveryBackoffMs(100), 3_600_000);
34
36
  const now = 1_000_000;
35
- const outcome = (exitCode, signal = null) => actionDeliverySettlementForOutcome({
37
+ const outcome = (exitCode, signal = null) => deliverySettlementForOutcome({
36
38
  exitCode,
37
39
  signal,
38
40
  timedOut: false,
@@ -50,14 +52,14 @@ test('action delivery retries follow the durable backoff and exit boundary', ()
50
52
  });
51
53
  assert.equal(outcome(79).state, 'pending');
52
54
  assert.equal(outcome(null, 'SIGTERM').state, 'pending');
53
- assert.equal(actionDeliverySettlementForOutcome({
55
+ assert.equal(deliverySettlementForOutcome({
54
56
  exitCode: null,
55
57
  signal: null,
56
58
  timedOut: true,
57
59
  stderr: '',
58
60
  }, 1, now).state, 'pending');
59
61
  // The kill escalation's signal is the only way to tell the graceful term from the SIGKILL.
60
- assert.deepEqual(actionDeliverySettlementForOutcome({
62
+ assert.deepEqual(deliverySettlementForOutcome({
61
63
  exitCode: null,
62
64
  signal: 'SIGKILL',
63
65
  timedOut: true,
@@ -67,7 +69,7 @@ test('action delivery retries follow the durable backoff and exit boundary', ()
67
69
  nextAttemptAt: now + 5_000,
68
70
  failure: { kind: 'timeout', signal: 'SIGKILL', stderr: 'stalled' },
69
71
  });
70
- assert.equal(actionDeliverySettlementForOutcome({
72
+ assert.equal(deliverySettlementForOutcome({
71
73
  exitCode: null,
72
74
  signal: null,
73
75
  timedOut: false,
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { closeDb, MIGRATIONS } from '../canvas/db.js';
8
8
  import { createNode, getRow } from '../canvas/canvas.js';
9
- import { armOutcomeDelivery, claimOutcomeDelivery, outcomeDeliveryBackoffMs, readOutcomeDelivery, recoverRunningOutcomeDeliveries, settleOutcomeDelivery, } from '../canvas/node-outcome-deliveries.js';
9
+ import { armOutcomeDelivery, claimOutcomeDelivery, readOutcomeDelivery, recoverRunningOutcomeDeliveries, settleOutcomeDelivery, } from '../canvas/node-outcome-deliveries.js';
10
10
  import { transition } from '../runtime/lifecycle.js';
11
11
  import { composeNodeOutcomeDocument } from '../runtime/outcome-document.js';
12
12
  import { pushFinal } from '../feed/feed.js';
@@ -153,11 +153,6 @@ test('outcome delivery state is fenced, recoverable, and follows the durable bac
153
153
  nextAttemptAt: readOutcomeDelivery('recover').nextAttemptAt,
154
154
  claimOwner: readOutcomeDelivery('recover').claimOwner,
155
155
  }, { state: 'pending', nextAttemptAt: recoveredAt, claimOwner: null });
156
- assert.equal(outcomeDeliveryBackoffMs(1), 5_000);
157
- assert.equal(outcomeDeliveryBackoffMs(2), 10_000);
158
- assert.equal(outcomeDeliveryBackoffMs(3), 20_000);
159
- assert.equal(outcomeDeliveryBackoffMs(11), 3_600_000);
160
- assert.equal(outcomeDeliveryBackoffMs(100), 3_600_000);
161
156
  });
162
157
  test('v39 migrates a v38 database and backfills finalized and terminal rows', () => {
163
158
  const dbPath = join(home, 'v38.db');
@@ -36,6 +36,28 @@ export declare function readJobPgid(contextDir: string, jobId: string): number |
36
36
  /** The optional human-readable job label. Jobs created before labels existed
37
37
  * have no file and deliberately project null. */
38
38
  export declare function readJobPurpose(contextDir: string, jobId: string): string | null;
39
+ /** Scheduler niceness every command a node runs is started with. Agent work —
40
+ * builds, test suites, typechecks — sits below the daemon, the brokers, the
41
+ * viewers, and the person's own shell, so a fan-out of release builds cannot
42
+ * starve them: the scheduler only honors this under contention and it costs
43
+ * nothing on an idle host. */
44
+ export declare const DEFAULT_BASH_NICENESS = 10;
45
+ /** The politest priority the scheduler accepts. A NEGATIVE niceness (more
46
+ * priority than the broker itself) needs root: `nice` prints a permission error
47
+ * into the command's own log and runs it at the inherited priority anyway, so
48
+ * the override range stops at 0. */
49
+ export declare const MAX_BASH_NICENESS = 19;
50
+ /** Per-node override of the bash niceness, written by `crtr node config --nice`
51
+ * and read by the valve when it starts each command. Node-local like the job
52
+ * dirs beside it, so a change applies to the node's very next command without
53
+ * waiting for a revive. */
54
+ export declare function bashNicenessPath(contextDir: string): string;
55
+ /** The niceness this node's commands run at. Any missing, malformed, or
56
+ * out-of-range file reads as the default rather than an odd priority. */
57
+ export declare function readBashNiceness(contextDir: string): number;
58
+ /** Set the node's bash niceness. The caller owns validation; the store keeps
59
+ * one integer so the valve's read stays a single file read per command. */
60
+ export declare function writeBashNiceness(contextDir: string, niceness: number): void;
39
61
  /** The auto-background cancel deadline (epoch ms), or undefined for a job with
40
62
  * no deadline (a deliberate handoff, or a legacy job dir). A non-finite or
41
63
  * malformed file reads as no deadline rather than an instant expiry. */
@@ -3,7 +3,7 @@
3
3
  // job.bg, while foreground completion renames it to job.done. The detached
4
4
  // supervisor can therefore outlive pi and still decide whether to report exit.
5
5
  import { randomBytes } from 'node:crypto';
6
- import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
6
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  const MAX_BASH_JOB_PURPOSE_BYTES = 280;
9
9
  /** A purpose is display text, never shell input. Keep the file-backed control
@@ -66,6 +66,43 @@ export function readJobPurpose(contextDir, jobId) {
66
66
  return null;
67
67
  }
68
68
  }
69
+ /** Scheduler niceness every command a node runs is started with. Agent work —
70
+ * builds, test suites, typechecks — sits below the daemon, the brokers, the
71
+ * viewers, and the person's own shell, so a fan-out of release builds cannot
72
+ * starve them: the scheduler only honors this under contention and it costs
73
+ * nothing on an idle host. */
74
+ export const DEFAULT_BASH_NICENESS = 10;
75
+ /** The politest priority the scheduler accepts. A NEGATIVE niceness (more
76
+ * priority than the broker itself) needs root: `nice` prints a permission error
77
+ * into the command's own log and runs it at the inherited priority anyway, so
78
+ * the override range stops at 0. */
79
+ export const MAX_BASH_NICENESS = 19;
80
+ /** Per-node override of the bash niceness, written by `crtr node config --nice`
81
+ * and read by the valve when it starts each command. Node-local like the job
82
+ * dirs beside it, so a change applies to the node's very next command without
83
+ * waiting for a revive. */
84
+ export function bashNicenessPath(contextDir) {
85
+ return join(bashJobsDir(contextDir), 'niceness');
86
+ }
87
+ /** The niceness this node's commands run at. Any missing, malformed, or
88
+ * out-of-range file reads as the default rather than an odd priority. */
89
+ export function readBashNiceness(contextDir) {
90
+ try {
91
+ const n = Number(readFileSync(bashNicenessPath(contextDir), 'utf8').trim());
92
+ if (!Number.isInteger(n) || n < 0 || n > MAX_BASH_NICENESS)
93
+ return DEFAULT_BASH_NICENESS;
94
+ return n;
95
+ }
96
+ catch {
97
+ return DEFAULT_BASH_NICENESS;
98
+ }
99
+ }
100
+ /** Set the node's bash niceness. The caller owns validation; the store keeps
101
+ * one integer so the valve's read stays a single file read per command. */
102
+ export function writeBashNiceness(contextDir, niceness) {
103
+ mkdirSync(bashJobsDir(contextDir), { recursive: true });
104
+ writeFileSync(bashNicenessPath(contextDir), `${Math.trunc(niceness)}\n`);
105
+ }
69
106
  /** The auto-background cancel deadline (epoch ms), or undefined for a job with
70
107
  * no deadline (a deliberate handoff, or a legacy job dir). A non-finite or
71
108
  * malformed file reads as no deadline rather than an instant expiry. */
@@ -0,0 +1,19 @@
1
+ export interface DeliveryFailure {
2
+ kind: 'exit' | 'signal' | 'timeout' | 'spawn_error';
3
+ exitCode?: number;
4
+ signal?: string;
5
+ message?: string;
6
+ stderr?: string;
7
+ }
8
+ export type DeliverySettlement = {
9
+ state: 'accepted';
10
+ } | {
11
+ state: 'permanent_failed';
12
+ failure: DeliveryFailure;
13
+ } | {
14
+ state: 'pending';
15
+ nextAttemptAt: number;
16
+ failure: DeliveryFailure;
17
+ };
18
+ /** Durable exponential retry delay for the attempt that just failed. */
19
+ export declare function deliveryBackoffMs(attempt: number): number;
@@ -0,0 +1,8 @@
1
+ // The durable contract shared by every retried delivery: what a failed attempt
2
+ // records, how an attempt settles, and when the next attempt becomes due. The
3
+ // canvas modules own the rows; `daemon/delivery/run-delivery.ts` owns the
4
+ // process that produces these settlements.
5
+ /** Durable exponential retry delay for the attempt that just failed. */
6
+ export function deliveryBackoffMs(attempt) {
7
+ return Math.min(5_000 * 2 ** (attempt - 1), 3_600_000);
8
+ }
@@ -1,11 +1,6 @@
1
+ import type { DeliveryFailure, DeliverySettlement } from './delivery-contract.js';
1
2
  export type ActionDeliveryState = 'pending' | 'running' | 'accepted' | 'permanent_failed';
2
- export interface ActionDeliveryFailure {
3
- kind: 'exit' | 'signal' | 'timeout' | 'spawn_error';
4
- exitCode?: number;
5
- signal?: string;
6
- message?: string;
7
- stderr?: string;
8
- }
3
+ export type ActionDeliveryFailure = DeliveryFailure;
9
4
  export interface ActionDeliveryRecord {
10
5
  requestId: string;
11
6
  state: ActionDeliveryState;
@@ -37,16 +32,7 @@ export declare function readActionDelivery(requestId: string): ActionDeliveryRec
37
32
  export declare function dueActionDeliveries(now: number): ActionDeliveryRecord[];
38
33
  /** Atomically start one due delivery attempt. A null result lost the claim. */
39
34
  export declare function claimActionDelivery(requestId: string, now: number, claimOwner: string): ActionDeliveryRecord | null;
40
- export type ActionDeliverySettlement = {
41
- state: 'accepted';
42
- } | {
43
- state: 'permanent_failed';
44
- failure: ActionDeliveryFailure;
45
- } | {
46
- state: 'pending';
47
- nextAttemptAt: number;
48
- failure: ActionDeliveryFailure;
49
- };
35
+ export type ActionDeliverySettlement = DeliverySettlement;
50
36
  /** Settle only the still-owned running attempt, so a stale process cannot overwrite a newer claim. */
51
37
  export declare function settleActionDelivery(requestId: string, attempt: number, claimOwner: string, settlement: ActionDeliverySettlement, now: number): boolean;
52
38
  /** A new daemon deliberately redelivers interrupted completions at least once. */
@@ -1,14 +1,7 @@
1
+ import type { DeliveryFailure, DeliverySettlement } from './delivery-contract.js';
1
2
  import { type NodeOutcomeKind, type TerminalReason } from './types.js';
2
3
  export type OutcomeDeliveryState = 'armed' | 'pending' | 'running' | 'accepted' | 'permanent_failed';
3
- export interface OutcomeDeliveryFailure {
4
- kind: 'exit' | 'signal' | 'timeout' | 'spawn_error';
5
- exitCode?: number;
6
- signal?: string;
7
- message?: string;
8
- stderr?: string;
9
- }
10
- /** Durable exponential retry delay for the attempt that just failed. */
11
- export declare function outcomeDeliveryBackoffMs(attempt: number): number;
4
+ export type OutcomeDeliveryFailure = DeliveryFailure;
12
5
  export interface OutcomeDeliveryRecord {
13
6
  nodeId: string;
14
7
  state: OutcomeDeliveryState;
@@ -61,16 +54,7 @@ export declare function armOutcomeDelivery(row: {
61
54
  export declare function readOutcomeDelivery(nodeId: string): OutcomeDeliveryRecord | null;
62
55
  export declare function dueOutcomeDeliveries(now: number): OutcomeDeliveryRecord[];
63
56
  export declare function claimOutcomeDelivery(nodeId: string, now: number, claimOwner: string): OutcomeDeliveryRecord | null;
64
- export type OutcomeDeliverySettlement = {
65
- state: 'accepted';
66
- } | {
67
- state: 'permanent_failed';
68
- failure: OutcomeDeliveryFailure;
69
- } | {
70
- state: 'pending';
71
- nextAttemptAt: number;
72
- failure: OutcomeDeliveryFailure;
73
- };
57
+ export type OutcomeDeliverySettlement = DeliverySettlement;
74
58
  /** Settle only the still-owned running attempt, fencing stale delivery processes. */
75
59
  export declare function settleOutcomeDelivery(nodeId: string, attempt: number, claimOwner: string, settlement: OutcomeDeliverySettlement, now: number): boolean;
76
60
  export declare function recoverRunningOutcomeDeliveries(now: number): number;
@@ -3,10 +3,6 @@ import { openDb, withCanvasWrite } from './db.js';
3
3
  import { getNode } from './canvas.js';
4
4
  import { OUTCOME_PAYLOAD_MAX_BYTES } from './types.js';
5
5
  import { composeNodeOutcomeDocument } from '../runtime/outcome-document.js';
6
- /** Durable exponential retry delay for the attempt that just failed. */
7
- export function outcomeDeliveryBackoffMs(attempt) {
8
- return Math.min(5_000 * 2 ** (attempt - 1), 3_600_000);
9
- }
10
6
  function serializedPayload(payload) {
11
7
  if (payload === undefined)
12
8
  return null;
@@ -503,7 +503,8 @@ export async function parseArgv(params, tokens, options) {
503
503
  // whole invocation waiting for a close that will never come.
504
504
  const piped = await peekStdinRaw();
505
505
  if (piped.trim() !== '') {
506
- throw parseArgvError('bad_invocation', `both a positional argument ("${positionalValue}") and piped stdin were provided for ${stdinParam.name}`, positionalValue, stdinParam.name, `Pass exactly one: either the positional argument or piped stdin, not both.`);
506
+ const conflict = stdinParam.positionalStdinConflict?.(result);
507
+ throw parseArgvError('bad_invocation', conflict?.message ?? `both a positional argument ("${positionalValue}") and piped stdin were provided for ${stdinParam.name}`, positionalValue, conflict?.field ?? stdinParam.name, conflict?.next ?? `Pass exactly one: either the positional argument or piped stdin, not both.`);
507
508
  }
508
509
  result[flagNameToKey(stdinParam.name)] = positionalValue;
509
510
  stdinWasSupplied = true; // positional supplying stdin counts as supplied
@@ -87,6 +87,13 @@ export interface StdinParam {
87
87
  /** Whether one positional token may supply stdin instead of a pipe. Defaults
88
88
  * to true for existing stdin-body leaves; false requires actual stdin. */
89
89
  allowPositional?: boolean;
90
+ /** Optional leaf-specific recovery when a positional stdin body collides with
91
+ * piped stdin. Return undefined to keep the generic collision error. */
92
+ positionalStdinConflict?: (input: Readonly<Record<string, unknown>>) => {
93
+ message: string;
94
+ field?: string;
95
+ next: string;
96
+ } | undefined;
90
97
  }
91
98
  /** --context-file PATH: reads and JSON-parses the file at PATH. */
92
99
  export interface ContextFileParam {
@@ -84,7 +84,7 @@ export const BINDING_CATALOG = Object.freeze([
84
84
  ...attachEntry('crtr.tmux.menu.attach.model-ladder.previous', 'crtr.attach.model-ladder.previous', 'Previous model', 'Move to the previous model ladder rung.', ['['], ['alt+shift+m']),
85
85
  ...attachEntry('crtr.tmux.menu.attach.command.inspect', 'crtr.attach.command.inspect', 'Inspect loaded command', 'Open the loaded slash command’s expanded prompt.', ['h'], ['alt+shift+h']),
86
86
  ...attachEntry('crtr.tmux.menu.attach.file-review', 'crtr.attach.file-review', 'Review a file', 'Open a line-anchored review of a file named in the transcript.', [], ['alt+shift+r']),
87
- ...attachEntry('crtr.tmux.menu.attach.profile-files', 'crtr.attach.profile-files', 'Search profile files', 'Fuzzy-match path segments across the node family’s working directories, profile projects and memory, and ancestor crouter memory stores; Ctrl+Y copies a selected path.', ['f'], ['ctrl+f']),
87
+ ...attachEntry('crtr.tmux.menu.attach.profile-files', 'crtr.attach.profile-files', 'Search profile files', 'Fuzzy-match path segments across the node family’s working directories, their context and reports directories, profile projects and memory, and ancestor crouter memory stores; Ctrl+Y copies a selected path.', ['f'], ['ctrl+f']),
88
88
  ...attachEntry('crtr.tmux.menu.attach.search', 'crtr.attach.search', 'Search transcript', 'Search the transcript as it is currently displayed — folded tool output is not searched until it is unfolded.', ['/'], ['alt+/']),
89
89
  ...attachEntry('crtr.tmux.menu.attach.whip', 'crtr.attach.whip', 'Whip agent', 'Interrupt the agent and send a playful command.', ['w'], []),
90
90
  b('crtr.attach.scroll.up', 'Attach', 'Scroll up', 'Scroll the transcript one line up.', ['shift+up'], 'terminal', ATTACH_CONTEXTS),