@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.
- package/dist/api/dto/config.d.ts +2 -0
- package/dist/builtin-memory/04-base-worker-exploring.md +13 -0
- package/dist/builtin-memory/04-base-worker.md +1 -4
- package/dist/builtin-memory/05-kinds/explore/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +4 -2
- package/dist/builtin-memory/explore/exploration-doc.md +27 -0
- package/dist/clients/attach/viewer.js +366 -366
- package/dist/commands/node/lifecycle.js +21 -6
- package/dist/core/runtime/revive.js +6 -1
- package/dist/daemon/api/handlers/nodes.js +124 -4
- package/dist/daemon/api/map.d.ts +1 -1
- package/dist/daemon/api/map.js +3 -2
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -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:
|
|
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
|
|
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 —
|
|
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
|
}
|
package/dist/daemon/api/map.d.ts
CHANGED
|
@@ -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
|
package/dist/daemon/api/map.js
CHANGED
|
@@ -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
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
9
|
+
"version": "0.3.253",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|