@north-light/crouter 0.3.252 → 0.3.253

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.
@@ -109,15 +109,15 @@ const nodeClose = defineLeaf({
109
109
  return `Closed ${r['node_id']} and its exclusive subtree — ${r['count']} node(s) closed${spared > 0 ? `, ${spared} spared (still managed from outside the subtree)` : ''}.`;
110
110
  },
111
111
  });
112
- // node config — reconfigure a node's settings headlessly (model/lifecycle/kind/mode/name)
112
+ // node config — reconfigure a node's settings headlessly (model/lifecycle/kind/mode/name/cwd repair)
113
113
  const NODE_CONFIG_MODEL_EFFECT = 'Model change first: a live broker switches immediately over its socket; a dormant node persists the durable model override and launch recipe for its next revive.';
114
114
  export const nodeConfig = defineLeaf({
115
115
  name: 'config',
116
- description: 'reconfigure a node\'s settings headlessly — model, lifecycle (terminal/resident), kind, mode, name',
117
- whenToUse: 'you want to reconfigure a node\'s settings headlessly after spawn — swap its model/provider, flip it terminal↔resident (interactable), respecialize its kind, switch base↔orchestrator mode, or rename it — without opening its viewer. The durable attribute setter; reuses the same recipes a revive reads. Use `node promote` for the opinionated self-promotion path, and `node lifecycle` for teardown actions (recycle/close/demote).',
116
+ description: 'reconfigure a node\'s settings headlessly — model, lifecycle (terminal/resident), kind, mode, name; repair a missing cwd',
117
+ whenToUse: 'you want to reconfigure a node\'s settings headlessly after spawn — swap its model/provider, flip it terminal↔resident (interactable), respecialize its kind, switch base↔orchestrator mode, or rename it — without opening its viewer. It also repairs a dormant node whose recorded cwd is gone, starting its next revive in a fresh conversation. The durable attribute setter; reuses the same recipes a revive reads. Use `node promote` for the opinionated self-promotion path, and `node lifecycle` for teardown actions (recycle/close/demote).',
118
118
  help: {
119
119
  name: 'node config',
120
- summary: 'reconfigure a node\'s settings headlessly — model, lifecycle, kind, mode, name',
120
+ summary: 'reconfigure a node\'s settings headlessly — model, lifecycle, kind, mode, name; repair a missing cwd',
121
121
  params: [
122
122
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Target node id. Defaults to the node in --pane, else CRTR_NODE_ID.' },
123
123
  { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to resolve the node from. Defaults to $TMUX_PANE.' },
@@ -140,20 +140,23 @@ export const nodeConfig = defineLeaf({
140
140
  { 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
141
  { 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
142
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Rename the node; if the node has a live window, also rename that viewer window.' },
143
+ { 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.' },
143
144
  ],
144
145
  output: [
145
- { name: 'node_id', type: 'string', required: true, constraint: 'The reconfigured node.' },
146
+ { name: 'node_id', type: 'string', required: false, constraint: 'The reconfigured node, present for ordinary config changes.' },
146
147
  { name: 'model', type: 'string', required: false, constraint: 'The resolved model, present only when --model changed it.' },
147
148
  { name: 'lifecycle', type: 'string', required: false, constraint: 'The new lifecycle, present only when --lifecycle changed it.' },
148
149
  { name: 'kind', type: 'string', required: false, constraint: 'The new kind, present only when --kind changed it.' },
149
150
  { name: 'mode', type: 'string', required: false, constraint: 'The new mode, present only when --mode changed it.' },
150
151
  { name: 'name', type: 'string', required: false, constraint: 'The new name, present only when --name changed it.' },
152
+ { name: 'cwd', type: 'string', required: false, constraint: 'The lexically resolved replacement directory, present only after a cwd repair.' },
151
153
  ],
152
154
  outputKind: 'object',
153
155
  effects: [
154
156
  NODE_CONFIG_MODEL_EFFECT,
155
157
  'Kind, mode, and lifecycle changes rebuild the launch spec from the fresh node meta; setting mode to orchestrator seeds a roadmap scaffold if absent.',
156
158
  'Name changes update the row and, when the node already has a live window, rename that viewer window to the new full name.',
159
+ 'A cwd repair writes only the lexically resolved cwd and clears saved Pi session pointers; the old transcript and durable node work remain untouched.',
157
160
  ],
158
161
  dynamicState: () => kindsStateBlock(),
159
162
  },
@@ -165,6 +168,10 @@ export const nodeConfig = defineLeaf({
165
168
  // kind is validated against the caller's installed personas here for the
166
169
  // clean listing error, and re-validated server-side against the TARGET
167
170
  // node's scope as a backstop.
171
+ const cwdSpec = input['cwd'];
172
+ if (cwdSpec !== undefined && ['model', 'kind', 'lifecycle', 'mode', 'name'].some((flag) => input[flag] !== undefined)) {
173
+ throw new InputError({ error: 'cwd_not_exclusive', message: 'cwd repair cannot be combined with another config flag', field: 'cwd', next: 'Pass --cwd alone.' });
174
+ }
168
175
  const modelSpec = input['model']?.trim();
169
176
  if (modelSpec === '') {
170
177
  throw new InputError({ error: 'empty_spec', message: 'a model spec is required', field: 'model', next: 'Run `crtr node config --model -h` and read the focused contract before retrying.' });
@@ -203,12 +210,18 @@ export const nodeConfig = defineLeaf({
203
210
  patch.name = nameSpec;
204
211
  applied.push('name');
205
212
  }
213
+ if (cwdSpec !== undefined) {
214
+ patch.cwd = cwdSpec;
215
+ applied.push('cwd');
216
+ }
206
217
  if (applied.length === 0) {
207
- throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name.' });
218
+ throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name, --cwd.' });
208
219
  }
209
220
  const detail = await cliClient()
210
221
  .patchConfig(nodeId, patch)
211
222
  .catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
223
+ if (cwdSpec !== undefined)
224
+ return { cwd: detail.cwd };
212
225
  const result = { node_id: nodeId };
213
226
  if (applied.includes('model'))
214
227
  result.model = detail.model_override ?? modelSpec;
@@ -223,6 +236,8 @@ export const nodeConfig = defineLeaf({
223
236
  return result;
224
237
  },
225
238
  render: (r) => {
239
+ if (r['cwd'] !== undefined)
240
+ return `Repaired cwd: ${r['cwd']}.`;
226
241
  const bits = [];
227
242
  if (r['model'] !== undefined)
228
243
  bits.push(`model=${r['model']}`);
@@ -230,10 +230,15 @@ function launchRevive(nodeId, meta, opts) {
230
230
  // fail deep inside the crash/doctrine-wake catch below, where it would
231
231
  // surface as an opaque `internal error` (toErrorBody masks every 5xx).
232
232
  if (!existsSync(meta.cwd)) {
233
+ const next = meta.fork_from != null
234
+ ? `Restore ${meta.cwd}, then revive the fork normally with \`crtr node lifecycle revive ${nodeId}\`.`
235
+ : meta.managed_worktree?.state === 'open'
236
+ ? 'Restore or reconcile the recorded checkout, then use the managed-worktree close or abandon recovery.'
237
+ : `Recreate ${meta.cwd}, or repair it with \`crtr node config --cwd <path> --node ${nodeId}\`; durable node work resumes in a fresh conversation. Then retry \`crtr node lifecycle revive ${nodeId}\`.`;
233
238
  throw new InputError({
234
239
  error: 'cwd_missing',
235
240
  message: `${nodeId} cannot be revived — its recorded cwd no longer exists: ${meta.cwd}`,
236
- next: `Recreate ${meta.cwd}, or update the node's cwd, then retry \`crtr node lifecycle revive ${nodeId}\`.`,
241
+ next,
237
242
  });
238
243
  }
239
244
  // Lazy host_kind coerce (§C): every launch now uses the broker host. Persist
@@ -7,8 +7,8 @@
7
7
  //
8
8
  // `{id}` is the target node.
9
9
  import { envProfileId } from '../../../shared/env.js';
10
- import { readdirSync } from 'node:fs';
11
- import { basename } from 'node:path';
10
+ import { existsSync, readdirSync, statSync } from 'node:fs';
11
+ import { basename, isAbsolute, resolve } from 'node:path';
12
12
  import { appendSituationalContext, formatSituationalProse } from '../../../core/runtime/situational-context.js';
13
13
  import { closeNode } from '../../../core/runtime/close.js';
14
14
  import { reviveNode } from '../../../core/runtime/revive.js';
@@ -35,6 +35,7 @@ import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js'
35
35
  import { readFault } from '../../../core/runtime/fault.js';
36
36
  import { activeFaultForDisplay } from '../../../core/canvas/render-source.js';
37
37
  import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
38
+ import { boundFleet } from '../../../core/runtime/fleet.js';
38
39
  import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
39
40
  import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
40
41
  import { resolveLaunchTarget } from '../../../core/runtime/launch-target.js';
@@ -45,6 +46,7 @@ import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
45
46
  import { notFound, usage } from '../../../core/errors.js';
46
47
  import { toCloseResultDTO, toNodeDetailDTO, toNodeSummaryDTO, toReviveResultDTO } from '../map.js';
47
48
  import { ApiError } from '../../../api/index.js';
49
+ import { InputError } from '../../../core/io.js';
48
50
  // Shared helpers
49
51
  function requireMeta(id) {
50
52
  const meta = getNode(id);
@@ -622,11 +624,129 @@ function handleReviveAll() {
622
624
  };
623
625
  return { status: 200, body };
624
626
  }
625
- // PATCH /v1/nodes/{id}/config — model / situational-context (spec §6.2)
627
+ // PATCH /v1/nodes/{id}/config — durable config and repair-only cwd (spec §6.2)
626
628
  export async function handleConfig(ctx, deps = {}) {
627
629
  const id = ctx.params['id'];
630
+ const body = ctx.body;
631
+ let patch;
632
+ if (body === undefined) {
633
+ patch = {};
634
+ }
635
+ else if (typeof body === 'object' && body !== null && !Array.isArray(body)) {
636
+ patch = body;
637
+ }
638
+ else {
639
+ throw new InputError({
640
+ error: 'cwd_invalid',
641
+ message: 'cwd repair requires a non-null object with a non-empty cwd string',
642
+ received: body,
643
+ field: 'cwd',
644
+ next: 'Send a config patch with a non-empty absolute cwd string.',
645
+ });
646
+ }
647
+ if (Object.prototype.hasOwnProperty.call(patch, 'cwd')) {
648
+ const cwd = patch.cwd;
649
+ if (typeof cwd !== 'string' || cwd === '') {
650
+ throw new InputError({
651
+ error: 'cwd_invalid',
652
+ message: 'cwd must be a non-empty string',
653
+ received: cwd,
654
+ field: 'cwd',
655
+ next: 'Send a non-empty absolute cwd string.',
656
+ });
657
+ }
658
+ if (Object.keys(patch).some((key) => key !== 'cwd' && patch[key] !== undefined)) {
659
+ throw new InputError({
660
+ error: 'cwd_not_exclusive',
661
+ message: 'cwd repair cannot be combined with another config field',
662
+ received: patch,
663
+ field: 'cwd',
664
+ next: 'Send cwd as the only config field.',
665
+ });
666
+ }
667
+ const meta = requireMeta(id);
668
+ if (boundFleet().has(id)) {
669
+ const message = `${id} cannot repair its cwd while its broker is live`;
670
+ throw new ApiError(409, 'cwd_node_live', message, {
671
+ error: 'cwd_node_live',
672
+ message,
673
+ received: cwd,
674
+ field: 'cwd',
675
+ next: 'Let the node become dormant, then retry the cwd repair.',
676
+ });
677
+ }
678
+ if (meta.fork_from != null) {
679
+ throw new InputError({
680
+ error: 'cwd_node_forked',
681
+ message: `${id} is a fork and cannot repair its cwd; recorded cwd: ${meta.cwd}`,
682
+ received: cwd,
683
+ field: 'cwd',
684
+ next: `Restore ${meta.cwd}, then revive the fork normally.`,
685
+ });
686
+ }
687
+ if (meta.managed_worktree?.state === 'open') {
688
+ throw new InputError({
689
+ error: 'cwd_node_managed_worktree_open',
690
+ message: `${id} owns an open managed worktree and cannot repair its cwd`,
691
+ received: cwd,
692
+ field: 'cwd',
693
+ next: 'Restore or reconcile the recorded checkout, then use the managed-worktree close or abandon flow.',
694
+ });
695
+ }
696
+ if (existsSync(meta.cwd)) {
697
+ throw new InputError({
698
+ error: 'cwd_still_present',
699
+ message: `${id} recorded cwd still exists: ${meta.cwd}; cwd repair is not a relocation command`,
700
+ received: cwd,
701
+ field: 'cwd',
702
+ next: 'This command only repairs a node whose recorded cwd is gone.',
703
+ });
704
+ }
705
+ if (!isAbsolute(cwd)) {
706
+ throw new InputError({
707
+ error: 'cwd_not_absolute',
708
+ message: `cwd must be an absolute path: ${cwd}`,
709
+ received: cwd,
710
+ field: 'cwd',
711
+ next: 'Send an absolute path.',
712
+ });
713
+ }
714
+ const resolved = resolve(cwd);
715
+ if (!existsSync(resolved)) {
716
+ throw new InputError({
717
+ error: 'cwd_missing',
718
+ message: `cwd does not exist: ${resolved}`,
719
+ received: cwd,
720
+ field: 'cwd',
721
+ next: 'Create the replacement directory or send an existing absolute directory.',
722
+ });
723
+ }
724
+ try {
725
+ if (!statSync(resolved).isDirectory()) {
726
+ throw new InputError({
727
+ error: 'cwd_not_a_directory',
728
+ message: `cwd is not a directory: ${resolved}`,
729
+ received: cwd,
730
+ field: 'cwd',
731
+ next: 'Send an existing absolute directory.',
732
+ });
733
+ }
734
+ }
735
+ catch (error) {
736
+ if (error instanceof InputError)
737
+ throw error;
738
+ throw new InputError({
739
+ error: 'cwd_missing',
740
+ message: `cwd does not exist: ${resolved}`,
741
+ received: cwd,
742
+ field: 'cwd',
743
+ next: 'Create the replacement directory or send an existing absolute directory.',
744
+ });
745
+ }
746
+ updateNode(id, { cwd: resolved, pi_session_id: null, pi_session_file: null });
747
+ return { status: 200, body: detailOf(id) };
748
+ }
628
749
  let meta = requireMeta(id);
629
- const patch = ctx.body ?? {};
630
750
  if (patch.situational_context !== undefined) {
631
751
  appendSituationalContext(id, formatSituationalProse(patch.situational_context));
632
752
  }
@@ -112,7 +112,7 @@ export declare function errorToStatus(thrown: unknown): {
112
112
  };
113
113
  /** Build the uniform JSON error body (spec §8) for a thrown value, using
114
114
  * `errorToStatus` for the status/code. Structured `details` are surfaced for a
115
- * CrtrError (they let the client reconstruct the error verbatim). A 500 still
115
+ * CrtrError or ApiError (they let the client reconstruct the error verbatim). A 500 still
116
116
  * omits `details` for hygiene, but NO LONGER strips the message to a bare
117
117
  * `internal error`: it surfaces the bounded cause (`boundedCause`) so a caller
118
118
  * is never left with a causeless internal, while the router logs the full
@@ -17,6 +17,7 @@ import { NodeIdConflictError } from '../../core/runtime/nodes.js';
17
17
  import { readContextTokens } from '../../core/canvas/telemetry.js';
18
18
  import { CrtrError } from '../../core/errors.js';
19
19
  import { WorktreeError } from '../../core/worktree.js';
20
+ import { ApiError } from '../../api/errors.js';
20
21
  import { ExitCode } from '../../types.js';
21
22
  import { sinkDisplay } from '../cron-sink.js';
22
23
  import { responsePath } from '../../core/human/convention.js';
@@ -563,7 +564,7 @@ function boundedCause(err) {
563
564
  }
564
565
  /** Build the uniform JSON error body (spec §8) for a thrown value, using
565
566
  * `errorToStatus` for the status/code. Structured `details` are surfaced for a
566
- * CrtrError (they let the client reconstruct the error verbatim). A 500 still
567
+ * CrtrError or ApiError (they let the client reconstruct the error verbatim). A 500 still
567
568
  * omits `details` for hygiene, but NO LONGER strips the message to a bare
568
569
  * `internal error`: it surfaces the bounded cause (`boundedCause`) so a caller
569
570
  * is never left with a causeless internal, while the router logs the full
@@ -577,7 +578,7 @@ export function toErrorBody(thrown) {
577
578
  const message = err instanceof Error ? err.message : String(err);
578
579
  const details = err instanceof ReviewOperationErrorClass
579
580
  ? reviewErrorDetails(err)
580
- : err instanceof CrtrError && err.details !== undefined
581
+ : (err instanceof CrtrError || err instanceof ApiError) && err.details !== undefined
581
582
  ? err.details
582
583
  : undefined;
583
584
  const body = { error: { code, message } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.252",
3
+ "version": "0.3.253",
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.252",
3
+ "version": "0.3.253",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.252",
9
+ "version": "0.3.253",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {