@north-light/crouter 0.3.169 → 0.3.170

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.
@@ -8,12 +8,12 @@ import { InputError } from '../../core/io.js';
8
8
  import { cliClient } from '../api-client.js';
9
9
  import { nodeDir } from '../../core/canvas/paths.js';
10
10
  import { mkdirSync, existsSync } from 'node:fs';
11
- import { isAbsolute, join, resolve } from 'node:path';
11
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
12
12
  import { randomBytes, randomUUID } from 'node:crypto';
13
13
  import { tmuxServerReachable } from '../../core/spawn.js';
14
14
  import { BrokerClient, BrokerUnavailableError } from '../../core/broker-client/index.js';
15
15
  import { markStopSignal } from '../../core/runtime/stop-signals.js';
16
- import { validateDeck, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
16
+ import { validateDeck, resolveDeckBodyPaths, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
17
17
  import { BODY_PATH_CONTRACT, DECK_SCHEMA_HINT, registerCrouterRoot, resolveMaxPanes } from './shared.js';
18
18
  import { inboxOpenInstruction, inboxPopupHint } from '../../core/keybindings/index.js';
19
19
  /** The asking node's id, or null when run from a bare shell (no parent to route to). */
@@ -54,14 +54,22 @@ function requireSubtitle(subtitle, field = 'subtitle') {
54
54
  next: 'Write one plain-English sentence stating the decision, recommendation or status, and stakes.',
55
55
  });
56
56
  }
57
- function validateTitledDeck(input) {
57
+ function validateTitledDeck(input, deckDirectory) {
58
58
  const title = typeof input === 'object' && input !== null
59
59
  ? input.title
60
60
  : undefined;
61
61
  if (typeof title !== 'string' || title.trim() === '') {
62
62
  throw new Error('deck title is required');
63
63
  }
64
- return validateDeck(input);
64
+ const deck = validateDeck(input);
65
+ if (deckDirectory === undefined)
66
+ return deck;
67
+ for (const interaction of deck.interactions) {
68
+ if (interaction.bodyPath !== undefined && isAbsolute(interaction.bodyPath)) {
69
+ throw new Error('bodyPath must be relative to the deck JSON; absolute paths are unsupported');
70
+ }
71
+ }
72
+ return resolveDeckBodyPaths(deck, deckDirectory);
65
73
  }
66
74
  function buildHumanAskDeck(title, subtitle, question) {
67
75
  return validateTitledDeck({
@@ -172,7 +180,7 @@ export const humanAsk = defineLeaf({
172
180
  `Returns immediately; nothing opens on screen. The human answers from the inbox popup (${inboxPopupHint()}) on their own time.`,
173
181
  ],
174
182
  },
175
- run: async (input) => {
183
+ run: async (input, context) => {
176
184
  // Optional stdin reaches the leaf as an empty string when omitted, including
177
185
  // when --context-file is supplied. Keep the raw non-empty bytes untouched.
178
186
  const titleRaw = input['title'];
@@ -182,6 +190,7 @@ export const humanAsk = defineLeaf({
182
190
  const subtitleRaw = input['subtitle'];
183
191
  const subtitle = typeof subtitleRaw === 'string' && subtitleRaw.trim() !== '' ? subtitleRaw.trim() : undefined;
184
192
  const deckInput = input['deck'];
193
+ const deckPath = context?.contextFilePaths.get('deck');
185
194
  if (deckInput !== undefined && (question !== undefined || titleRaw !== undefined || subtitleRaw !== undefined)) {
186
195
  throw new InputError({
187
196
  error: 'bad_invocation',
@@ -205,7 +214,7 @@ export const humanAsk = defineLeaf({
205
214
  deck = buildHumanAskDeck(simpleAsk.title, simpleAsk.subtitle, simpleAsk.question);
206
215
  }
207
216
  else {
208
- deck = validateTitledDeck(deckInput);
217
+ deck = validateTitledDeck(deckInput, deckPath === undefined ? undefined : dirname(deckPath));
209
218
  }
210
219
  deck = canonicalizeAskKinds(deck);
211
220
  }
@@ -16,17 +16,20 @@ import { test, describe, before, after, beforeEach, afterEach } from 'node:test'
16
16
  import assert from 'node:assert/strict';
17
17
  import { mkdtempSync, mkdirSync, writeFileSync, realpathSync, readFileSync, existsSync, symlinkSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
- import { dirname, join, basename } from 'node:path';
19
+ import { dirname, join, resolve, basename } from 'node:path';
20
+ import { Readable } from 'node:stream';
20
21
  import { registerInboxRoot, submitDeck, validateDeck, claimTicket, completeDeck, cancelTicket, readTicketResult, atomicWriteJson, } from '@crouton-kit/humanloop';
21
22
  import { spawnNode } from '../runtime/nodes.js';
22
23
  import { getNode, listNodes, closeDb } from '../canvas/index.js';
23
24
  import { readInboxSince } from '../feed/inbox.js';
24
25
  import { humanAsk, humanReview, humanNotify } from '../../commands/human/prompts.js';
26
+ import { runCli } from '../command.js';
25
27
  import { resolveRef } from '../canvas/history.js';
26
28
  import { humanDeliver } from '../../commands/human/queue.js';
27
29
  import { createApiServer } from '../../daemon/api/server.js';
28
30
  import { trustedCrouterVisualHandler } from '../../daemon/api/handlers/human.js';
29
- import { apiSocketPath } from '../canvas/paths.js';
31
+ import { apiSocketPath, nodeDir } from '../canvas/paths.js';
32
+ import { resolveRoot } from '../../build-root.js';
30
33
  // ---------------------------------------------------------------------------
31
34
  // Isolated environment
32
35
  // ---------------------------------------------------------------------------
@@ -57,6 +60,28 @@ const ISO = '2026-07-10T12:00:00.000Z';
57
60
  function benignHandler() {
58
61
  return { command: process.execPath, args: ['-e', ''] };
59
62
  }
63
+ async function withPipedStdin(body, fn) {
64
+ const original = process.stdin;
65
+ const stdin = Readable.from([Buffer.from(body)]);
66
+ Object.defineProperty(stdin, 'isTTY', { value: false, configurable: true });
67
+ Object.defineProperty(process, 'stdin', { value: stdin, configurable: true });
68
+ try {
69
+ return await fn();
70
+ }
71
+ finally {
72
+ Object.defineProperty(process, 'stdin', { value: original, configurable: true });
73
+ }
74
+ }
75
+ async function withMutedStdout(fn) {
76
+ const originalWrite = process.stdout.write;
77
+ process.stdout.write = (() => true);
78
+ try {
79
+ return await fn();
80
+ }
81
+ finally {
82
+ process.stdout.write = originalWrite;
83
+ }
84
+ }
60
85
  /** Register `dir` (created fresh) as an owner-'crouter' root and return its
61
86
  * canonical path. */
62
87
  function registerRoot(dir) {
@@ -172,6 +197,41 @@ describe('enqueue is producer-only', () => {
172
197
  }));
173
198
  const structuredDeck = JSON.parse(readFileSync(join(structured['dir'], 'deck.json'), 'utf8'));
174
199
  assert.deepEqual(structuredDeck.interactions.map((interaction) => interaction.kind), ['decision', 'context']);
200
+ const deckDir = mkdtempSync(join(tmpdir(), 'crtr-context-file-deck-'));
201
+ assert.notEqual(resolve(process.cwd()), deckDir, 'the CLI caller cwd must differ from the deck directory');
202
+ const deckPath = join(deckDir, 'deck.json');
203
+ writeFileSync(join(deckDir, 'body.md'), 'The body came from beside the deck JSON.');
204
+ writeFileSync(deckPath, JSON.stringify({
205
+ title: 'Context file body',
206
+ interactions: [{
207
+ id: 'decision',
208
+ title: 'Use the deck',
209
+ subtitle: 'Use the deck body so the person can decide with its context.',
210
+ bodyPath: 'body.md',
211
+ options: [{ id: 'yes', label: 'Yes' }],
212
+ }],
213
+ }));
214
+ const nodesBeforeContextFile = new Set(listNodes().map((node) => node.node_id));
215
+ await withMutedStdout(() => withPipedStdin('', async () => {
216
+ await runCli(await resolveRoot('human'), ['node', 'crtr', 'human', 'ask', '--context-file', deckPath]);
217
+ }));
218
+ const contextFileBridge = listNodes().find((node) => !nodesBeforeContextFile.has(node.node_id));
219
+ assert.ok(contextFileBridge, 'the real CLI invocation created a human bridge');
220
+ const contextFileDeck = JSON.parse(readFileSync(join(nodeDir(contextFileBridge.node_id), 'deck.json'), 'utf8'));
221
+ assert.equal(contextFileDeck.interactions[0]?.body, 'The body came from beside the deck JSON.');
222
+ assert.equal(contextFileDeck.interactions[0]?.bodyPath, undefined);
223
+ await assert.rejects(() => humanAsk.run({
224
+ deck: {
225
+ title: 'Absolute path',
226
+ interactions: [{
227
+ id: 'decision',
228
+ title: 'Reject absolute path',
229
+ subtitle: 'Reject the invalid path so the documented deck boundary remains intact.',
230
+ bodyPath: join(deckDir, 'body.md'),
231
+ options: [{ id: 'yes', label: 'Yes' }],
232
+ }],
233
+ },
234
+ }, { providedParams: new Set(['deck']), contextFilePaths: new Map([['deck', deckPath]]) }), /absolute paths are unsupported/);
175
235
  const rc = JSON.parse(readFileSync(join(r['dir'], 'run.json'), 'utf8'));
176
236
  assert.equal(rc['mode'], 'ask');
177
237
  assert.equal(rc['job_id'], r['job_id']);
@@ -12,6 +12,11 @@ export interface LeafRunContext {
12
12
  * as a positional argument, or explicitly passed; absence means TTY with no
13
13
  * pipe (truly absent). */
14
14
  readonly providedParams: ReadonlySet<string>;
15
+ /** Absolute source paths for JSON values supplied through `--context-file`,
16
+ * keyed by their camel-cased parameter name. The parsed JSON remains the
17
+ * leaf input; this metadata preserves its source directory for values that
18
+ * reference adjacent files. */
19
+ readonly contextFilePaths: ReadonlyMap<string, string>;
15
20
  }
16
21
  /** Opt-in flag that surfaces a node as an editor slash command (a pi prompt
17
22
  * template / Claude Code command). When set, the bootstrap auto-writes a
@@ -149,6 +154,9 @@ export interface ParseArgvOptions {
149
154
  * Called once with a ReadonlySet of parameter names that were supplied by
150
155
  * the caller (distinct from defaulted or missing values). */
151
156
  onProvidedParams?: (provided: ReadonlySet<string>) => void;
157
+ /** Optional collector for absolute source paths of JSON values supplied via
158
+ * `--context-file`, keyed by their camel-cased parameter name. */
159
+ onContextFilePaths?: (paths: ReadonlyMap<string, string>) => void;
152
160
  /** The walked command path (e.g. `['push','final']`), supplied by the
153
161
  * dispatcher. Used only to name the exact leaf in a schema-class error's
154
162
  * `Next:` line, which mandates reading that leaf's `-h`. */
@@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs';
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import { constants as osConstants } from 'node:os';
16
16
  import { isatty } from 'node:tty';
17
+ import { resolve } from 'node:path';
17
18
  // ---------------------------------------------------------------------------
18
19
  // Factory functions
19
20
  // ---------------------------------------------------------------------------
@@ -268,6 +269,7 @@ function flagNameToKey(name) {
268
269
  export async function parseArgv(params, tokens, options) {
269
270
  const result = {};
270
271
  const provided = new Set(); // Track explicitly-provided parameter names
272
+ const contextFilePaths = new Map();
271
273
  // Index params by kind for quick lookup
272
274
  const positionalParam = params.find((p) => p.kind === 'positional');
273
275
  const stdinParam = params.find((p) => p.kind === 'stdin');
@@ -332,6 +334,7 @@ export async function parseArgv(params, tokens, options) {
332
334
  }
333
335
  const contextFileKey = flagNameToKey(contextFileParam.name);
334
336
  result[contextFileKey] = parsed;
337
+ contextFilePaths.set(contextFileKey, resolve(pathVal));
335
338
  provided.add(contextFileKey);
336
339
  i++;
337
340
  continue;
@@ -539,6 +542,9 @@ export async function parseArgv(params, tokens, options) {
539
542
  if (options?.onProvidedParams !== undefined) {
540
543
  options.onProvidedParams(provided);
541
544
  }
545
+ if (options?.onContextFilePaths !== undefined) {
546
+ options.onContextFilePaths(contextFilePaths);
547
+ }
542
548
  return result;
543
549
  }
544
550
  export async function runCli(root, argv, options) {
@@ -618,13 +624,17 @@ export async function runCli(root, argv, options) {
618
624
  const leaf = node;
619
625
  const params = leaf.help.params !== undefined ? leaf.help.params : [];
620
626
  let providedParams = new Set();
627
+ let contextFilePaths = new Map();
621
628
  const input = await parseArgv(params, remaining, {
622
629
  leafPath: path,
623
630
  onProvidedParams: (provided) => {
624
631
  providedParams = provided;
625
632
  },
633
+ onContextFilePaths: (paths) => {
634
+ contextFilePaths = paths;
635
+ },
626
636
  });
627
- const context = { providedParams };
637
+ const context = { providedParams, contextFilePaths };
628
638
  const result = await operationIdContext.fresh(() => leaf.run(input, context));
629
639
  if (result !== undefined && result !== null) {
630
640
  if (isJsonOutput()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.169",
3
+ "version": "0.3.170",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.169",
3
+ "version": "0.3.170",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.169",
9
+ "version": "0.3.170",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {