@crouton-kit/crouter 0.3.19 → 0.3.20

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 (51) hide show
  1. package/dist/builtin-personas/design/PERSONA.md +1 -0
  2. package/dist/builtin-personas/design/orchestrator.md +1 -0
  3. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  4. package/dist/builtin-personas/developer/orchestrator.md +1 -0
  5. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  6. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  7. package/dist/builtin-personas/general/PERSONA.md +1 -0
  8. package/dist/builtin-personas/general/orchestrator.md +4 -0
  9. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  10. package/dist/builtin-personas/plan/orchestrator.md +1 -0
  11. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  12. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  13. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  14. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  15. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  16. package/dist/builtin-personas/review/PERSONA.md +1 -0
  17. package/dist/builtin-personas/review/orchestrator.md +4 -0
  18. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  19. package/dist/builtin-personas/spec/orchestrator.md +1 -0
  20. package/dist/commands/human/queue.js +11 -0
  21. package/dist/commands/node.js +4 -1
  22. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  23. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  24. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  25. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  26. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  27. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  28. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  29. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  30. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  31. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  32. package/dist/core/__tests__/broker-lifecycle.test.js +14 -604
  33. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  34. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  35. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  36. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  37. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  38. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  39. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  40. package/dist/core/__tests__/live-mutation.test.js +4 -147
  41. package/dist/core/canvas/canvas.js +1 -1
  42. package/dist/core/canvas/types.d.ts +6 -0
  43. package/dist/core/runtime/launch.d.ts +9 -2
  44. package/dist/core/runtime/launch.js +20 -3
  45. package/dist/core/runtime/nodes.d.ts +4 -0
  46. package/dist/core/runtime/nodes.js +1 -0
  47. package/dist/core/runtime/promote.js +3 -0
  48. package/dist/core/runtime/reset.js +3 -2
  49. package/dist/core/runtime/spawn.d.ts +4 -0
  50. package/dist/core/runtime/spawn.js +2 -1
  51. package/package.json +1 -1
@@ -24,6 +24,10 @@
24
24
  // (idle-release vs dormant; persona-ack recompose; the A4 boundary) changes
25
25
  // accordingly. None of the above drives `crtr node lifecycle` on a live node,
26
26
  // nor asserts persona_ack mutation across a live promote, nor the A4 loss site.
27
+ //
28
+ // Part 2 of this axis — the demote/recycle verb split and the A4 promote-then-
29
+ // yield boundary — lives in live-mutation-verbs.test.ts (split for node:test
30
+ // file-level parallelism; each test holds its own isolated harness).
27
31
  import { test } from 'node:test';
28
32
  import assert from 'node:assert/strict';
29
33
  import { spawnSync } from 'node:child_process';
@@ -45,15 +49,6 @@ function orchestrationSteers(inj) {
45
49
  function persona(m) {
46
50
  return { mode: m.mode, lifecycle: m.lifecycle };
47
51
  }
48
- /** The first %pane_id of a tmux window. The spawn path records window+session
49
- * but NOT pane (spawn.ts: pane is null until a reconcile/focus), so a node's
50
- * live pane must be resolved from its window here. */
51
- function firstPaneOf(window) {
52
- const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
53
- if (r.status !== 0)
54
- return null;
55
- return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
56
- }
57
52
  // ===========================================================================
58
53
  // (a) LIFECYCLE FLIP — `crtr node lifecycle` on a LIVE node, both directions,
59
54
  // observing the idle-release behavior change. A round-trip on ONE live
@@ -218,141 +213,3 @@ test('live mode flip: promote base→orchestrator recomposes persona_ack at turn
218
213
  assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
219
214
  }
220
215
  });
221
- // ===========================================================================
222
- // (b) THE demote / recycle SPLIT — two DISTINCT verbs after the rename:
223
- // • `node demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
224
- // its pane, its MODE, and its parentage, keeps running, is NOT finalized; it
225
- // now merely owes a final up the spine (vision F5). It is NOT an
226
- // orchestrator→base mode flip — MODE is untouched (so persona.ts
227
- // `baseModeGuidance` stays unreachable via live mutation, as before).
228
- // • `node recycle` is FINISH+RECYCLE — push final → done, then recycle the
229
- // pane into a FRESH general/base/resident root (a DIFFERENT node). The
230
- // recycled node keeps mode=orchestrator (it is merely `done`).
231
- // This test drives BOTH real verbs on one live node and pins each behavior.
232
- // ===========================================================================
233
- test('node demote flips lifecycle→terminal IN PLACE; node recycle is FINISH+RECYCLE', { skip: SKIP, timeout: 120_000 }, async () => {
234
- const h = await createHarness({ sessionPrefix: 'crtr-live-demote' });
235
- try {
236
- const A = h.spawnRoot('resident root');
237
- const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
238
- // Make B resident + orchestrator so the demote's flip→terminal is visible
239
- // and we can prove MODE/parentage survive it.
240
- assert.equal(h.cli(B, ['node', 'lifecycle', 'resident', '--node', B]).code, 0, 'B → resident');
241
- assert.equal(h.cli(B, ['node', 'promote', '--kind', 'developer']).code, 0, 'promote B');
242
- {
243
- const b = h.node(B);
244
- assert.equal(b.lifecycle, 'resident', 'B resident before demote');
245
- assert.equal(b.mode, 'orchestrator', 'B orchestrator before demote');
246
- }
247
- const bParent = h.node(B).parent;
248
- // --- node demote: flip-to-terminal IN PLACE. Keeps B alive, MODE/parentage
249
- // untouched, NOT finalized.
250
- const dem = h.cli(B, ['node', 'demote', '--node', B]);
251
- assert.equal(dem.code, 0, `node demote exit 0\n${dem.stderr}`);
252
- assert.match(dem.stdout, /<demoted /, `demote rendered\n${dem.stdout}`);
253
- {
254
- const b = h.node(B);
255
- assert.equal(b.lifecycle, 'terminal', 'demote flips lifecycle→terminal IN PLACE');
256
- assert.equal(b.mode, 'orchestrator', 'demote leaves MODE untouched (not an orchestrator→base flip)');
257
- assert.equal(b.parent, bParent, 'demote leaves parentage unchanged');
258
- assert.equal(b.status, 'active', 'demote does NOT finish B — it keeps running in place');
259
- assert.notEqual(b.intent ?? null, 'done', 'demote does NOT finalize B');
260
- }
261
- // --- node recycle: FINISH + RECYCLE the SAME pane into a fresh root.
262
- // Resolve B's live %pane_id from its window (the row's `pane` is null
263
- // until a reconcile; the spawn path records only window+session).
264
- const pane = firstPaneOf(h.node(B).window);
265
- assert.ok(typeof pane === 'string' && pane !== '', 'B has a live pane to recycle');
266
- // RECYCLE via the real verb (TMUX_PANE is scrubbed from child env → pass --pane).
267
- const res = h.cli(B, ['node', 'recycle', '--node', B, '--pane', pane]);
268
- assert.equal(res.code, 0, `recycle exit 0\n${res.stderr}`);
269
- // The leaf renders `<recycled ... finalized=".." new_root=".."/>` (not JSON).
270
- assert.match(res.stdout, /<recycled /, `recycle recycled the pane\n${res.stdout}`);
271
- const newRoot = /new_root="([^"]+)"/.exec(res.stdout)?.[1];
272
- const finalized = /finalized="true"/.test(res.stdout);
273
- // The recycled node is FINISHED, not mode-flipped.
274
- {
275
- const b = h.node(B);
276
- assert.equal(b.status, 'done', 'recycled node → done (finished), NOT re-roled');
277
- assert.equal(b.intent, 'done', 'intent=done (finalize), per the push-final path');
278
- assert.equal(b.mode, 'orchestrator', 'recycled node KEEPS mode=orchestrator — recycle is NOT a mode flip');
279
- assert.ok(finalized, 'recycle pushed a final for the node');
280
- }
281
- // The fresh root is a DIFFERENT, BASE×RESIDENT node.
282
- assert.ok(typeof newRoot === 'string' && newRoot !== B, 'a fresh root (≠ B) was minted');
283
- {
284
- const fresh = h.node(newRoot);
285
- assert.deepEqual(persona(fresh), { mode: 'base', lifecycle: 'resident' }, 'recycled root is born base×resident (general)');
286
- assert.deepEqual(fresh.persona_ack, { mode: 'base', lifecycle: 'resident' }, 'fresh root born acked base×resident');
287
- }
288
- }
289
- finally {
290
- const session = h.session;
291
- await h.dispose();
292
- assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
293
- }
294
- });
295
- // ===========================================================================
296
- // (b) A4 BOUNDARY — promote-then-yield emits a steer that is discarded. The
297
- // oracle/flagship boundary: the base→orchestrator guidance lands as a STEER
298
- // only if a turn_end fires while the drift is pending. A `node yield` on a
299
- // base node auto-promotes (mode→orchestrator, ack NOT committed) and its
300
- // agent_end goes STRAIGHT to reviveInPlace (b') with NO preceding turn_end —
301
- // so the only steer-delivery site (turn_end) is BYPASSED. Two deterministic
302
- // facts pin the boundary, both confirmed by direct observation:
303
- // (1) NO orchestration STEER is ever delivered (the LOSS).
304
- // (2) The ack is silently advanced base→orchestrator at the refresh DRAIN
305
- // (reviveInPlace→drainBearings→commitPersonaAck), NOT via a steer — so
306
- // neither this turn nor (per A4) the fresh revive re-offers it as a
307
- // steer; the guidance survives only in the kickoff PROMPT it built.
308
- // ⚑ FLAGGED (not fixed): the in-place refresh of this LARGE pending-drift
309
- // kickoff prompt did NOT complete a fresh fake-pi boot in the harness (it
310
- // stayed at 1 boot, intent=refresh, ack=orchestrator, pane alive) — a
311
- // base→orchestrator yield's giant <persona-transition> kickoff pushed
312
- // through respawn-pane did not bring up the fresh vehicle. Whether a real
313
- // edge (oversized argv through respawn-pane) or a harness artifact, it is
314
- // out of scope to fix; this test asserts only the deterministic boundary.
315
- // ===========================================================================
316
- test('A4: a base→orchestrator yield with no preceding turn_end loses the orchestration STEER (ack advances silently at the refresh drain)', { skip: SKIP, timeout: 120_000 }, async () => {
317
- const h = await createHarness({ sessionPrefix: 'crtr-live-a4' });
318
- try {
319
- const A = h.spawnRoot('resident root');
320
- const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
321
- assert.deepEqual(persona(h.node(B)), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
322
- const injBefore = h.injected(B).length;
323
- // `crtr node yield` (base → auto-promote → intent=refresh). INTERMEDIATE
324
- // state, BEFORE any agent_end: mode flipped, ack NOT yet committed (the
325
- // turn_end injector has not run), intent=refresh, drift PENDING.
326
- const y = h.cli(B, ['node', 'yield', 'refresh against the roadmap']);
327
- assert.equal(y.code, 0, `node yield exit 0\n${y.stderr}`);
328
- {
329
- const b = h.node(B);
330
- assert.equal(b.mode, 'orchestrator', 'yield auto-promoted base→orchestrator');
331
- assert.equal(b.intent, 'refresh', 'intent=refresh set by the yield');
332
- assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'ack STILL base — promote/yield never commits it; only an injector does');
333
- }
334
- // Fire the stop: agent_end sees intent=refresh → (b') reviveInPlace, whose
335
- // drainBearings commits the ack synchronously BEFORE the respawn. NO
336
- // turn_end fires this turn, so the turn_end steer site is bypassed.
337
- await h.stop(B);
338
- // (2) The ack is silently advanced to orchestrator at the refresh DRAIN —
339
- // not by any steer. (waitFor: the agent_end handler runs after h.stop
340
- // observes the recorded event.)
341
- await h.waitFor(() => {
342
- const a = h.node(B)?.persona_ack;
343
- return a?.mode === 'orchestrator' && a?.lifecycle === 'terminal';
344
- }, { timeoutMs: 20_000, label: 'persona_ack advanced at the refresh drain (not via a steer)' });
345
- assert.deepEqual(h.node(B).persona_ack, { mode: 'orchestrator', lifecycle: 'terminal' }, 'ack committed base→orchestrator by drainBearings during reviveInPlace');
346
- // (1) ⚑ LOSS site: across the whole yield→refresh, NO orchestration
347
- // guidance was ever delivered as a turn-boundary STEER (the only steer
348
- // site, turn_end, never ran). The ack moved without the agent ever being
349
- // steered with the new-role guidance — it survives only in the kickoff
350
- // prompt drainBearings built for the (here, non-booting) fresh vehicle.
351
- assert.equal(orchestrationSteers(h.injected(B).slice(injBefore)).length, 0, '⚑ A4: no orchestration STEER delivered — the turn_end injector was bypassed by the yield');
352
- }
353
- finally {
354
- const session = h.session;
355
- await h.dispose();
356
- assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
357
- }
358
- });
@@ -22,7 +22,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, } from '.
22
22
  const IDENTITY_KEYS = [
23
23
  'node_id', 'name', 'description', 'cycles', 'created', 'cwd', 'host_kind', 'kind', 'mode',
24
24
  'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'passive_default',
25
- 'home_session', 'pi_session_id', 'pi_session_file', 'launch',
25
+ 'home_session', 'pi_session_id', 'pi_session_file', 'model_override', 'launch',
26
26
  ];
27
27
  /** Project any node object down to its durable-identity subset. */
28
28
  function toIdentity(m) {
@@ -104,6 +104,12 @@ export interface NodeIdentity {
104
104
  * PATH is opened directly — immune to any cwd discrepancy. Null for older
105
105
  * nodes booted before this field existed → revive falls back to the bare id. */
106
106
  pi_session_file?: string | null;
107
+ /** A caller-pinned model TIER (ultra/strong/medium/light) that overrides the
108
+ * persona's declared default. Durable so it survives a polymorph: every
109
+ * spec-rebuild path (promote/demote/lifecycle/reset) re-passes it to
110
+ * buildLaunchSpec, while the persona default is recomputed fresh for the
111
+ * (possibly new) kind. Null/undefined ⇒ use the persona default. */
112
+ model_override?: string | null;
107
113
  /** Full pi launch recipe; rewritten on every polymorph. */
108
114
  launch?: LaunchSpec;
109
115
  }
@@ -22,8 +22,14 @@ export declare const CANVAS_VIEW_PATH: string;
22
22
  * All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
23
23
  * reads the raw user text. */
24
24
  export declare const CANVAS_EXTENSIONS: string[];
25
- /** Bare model aliases resolve to the anthropic provider under pi (avoids the
26
- * bedrock default). Anything with a `/` or an unknown name passes through. */
25
+ /** The named capability tiers a caller picks with `--model`, in descending
26
+ * strength. Each maps to a concrete pi model spec. `strong`/`medium`/`light`
27
+ * resolve to the latest opus/sonnet/haiku; `ultra` to the frontier model. */
28
+ export declare const MODEL_TIERS: Record<string, string>;
29
+ /** Resolve a model token to the spec pi gets via `--model`. A named tier
30
+ * (ultra/strong/medium/light) maps to its concrete spec; a bare alias
31
+ * (sonnet/opus/haiku) resolves to the anthropic provider under pi (avoids the
32
+ * bedrock default); anything with a `/` or an unknown name passes through. */
27
33
  export declare function normalizeModel(model: string): string;
28
34
  /** Compose a node's full pi launch recipe from its persona. The system prompt
29
35
  * is composed from FOUR inputs: kind×mode (the persona body) plus lifecycle
@@ -36,6 +42,7 @@ export declare function buildLaunchSpec(kind: string, mode: Mode, opts: {
36
42
  lifecycle: Lifecycle;
37
43
  hasManager: boolean;
38
44
  extraEnv?: Record<string, string>;
45
+ model?: string;
39
46
  }): {
40
47
  launch: LaunchSpec;
41
48
  lifecycle: 'terminal' | 'resident';
@@ -60,9 +60,22 @@ export const CANVAS_EXTENSIONS = [
60
60
  CANVAS_RESUME_PATH,
61
61
  CANVAS_VIEW_PATH,
62
62
  ];
63
- /** Bare model aliases resolve to the anthropic provider under pi (avoids the
64
- * bedrock default). Anything with a `/` or an unknown name passes through. */
63
+ /** The named capability tiers a caller picks with `--model`, in descending
64
+ * strength. Each maps to a concrete pi model spec. `strong`/`medium`/`light`
65
+ * resolve to the latest opus/sonnet/haiku; `ultra` to the frontier model. */
66
+ export const MODEL_TIERS = {
67
+ ultra: 'anthropic/claude-fable-5',
68
+ strong: 'anthropic/opus',
69
+ medium: 'anthropic/sonnet',
70
+ light: 'anthropic/haiku',
71
+ };
72
+ /** Resolve a model token to the spec pi gets via `--model`. A named tier
73
+ * (ultra/strong/medium/light) maps to its concrete spec; a bare alias
74
+ * (sonnet/opus/haiku) resolves to the anthropic provider under pi (avoids the
75
+ * bedrock default); anything with a `/` or an unknown name passes through. */
65
76
  export function normalizeModel(model) {
77
+ if (model in MODEL_TIERS)
78
+ return MODEL_TIERS[model];
66
79
  const bare = new Set(['sonnet', 'opus', 'haiku']);
67
80
  if (bare.has(model))
68
81
  return `anthropic/${model}`;
@@ -80,8 +93,12 @@ export function normalizeModel(model) {
80
93
  * declared extensions follow. */
81
94
  export function buildLaunchSpec(kind, mode, opts) {
82
95
  const p = resolvePersona(kind, mode, { lifecycle: opts.lifecycle, hasManager: opts.hasManager });
96
+ // A caller-supplied override (durable on `meta.model_override`, re-passed on
97
+ // every polymorph) wins over the persona's declared default; absent both, the
98
+ // model is left unset and the node inherits pi's default.
99
+ const chosenModel = opts.model ?? p.model;
83
100
  const launch = {
84
- model: p.model !== undefined ? normalizeModel(p.model) : undefined,
101
+ model: chosenModel !== undefined ? normalizeModel(chosenModel) : undefined,
85
102
  tools: p.tools,
86
103
  extensions: [...CANVAS_EXTENSIONS, ...p.extensions],
87
104
  systemPrompt: p.systemPrompt,
@@ -98,6 +98,10 @@ export interface SpawnNodeOpts {
98
98
  passiveDefault?: boolean;
99
99
  /** Resolved pi launch recipe (from resolve(kind,mode)). */
100
100
  launch?: LaunchSpec;
101
+ /** Caller-pinned model tier (ultra/strong/medium/light) that overrides the
102
+ * persona default. Persisted to `meta.model_override` so polymorphs preserve
103
+ * it. Omit to use the persona default. */
104
+ modelOverride?: string | null;
101
105
  /** Override the generated id (e.g. when a caller pre-allocates one). */
102
106
  nodeId?: string;
103
107
  }
@@ -179,6 +179,7 @@ export function spawnNode(opts) {
179
179
  parent,
180
180
  spawned_by: spawnedBy,
181
181
  fork_from: opts.forkFrom,
182
+ model_override: opts.modelOverride ?? null,
182
183
  passive_default: opts.passiveDefault ?? false,
183
184
  intent: null,
184
185
  pi_session_id: null,
@@ -46,6 +46,9 @@ export function promote(nodeId, opts = {}) {
46
46
  const { launch } = buildLaunchSpec(targetKind, 'orchestrator', {
47
47
  lifecycle: opts.resident === true ? 'resident' : node.lifecycle,
48
48
  hasManager: node.parent !== null,
49
+ // Preserve a caller-pinned model tier across the polymorph (the persona
50
+ // default is recomputed fresh for targetKind).
51
+ model: node.model_override ?? undefined,
49
52
  });
50
53
  // Seed a barebones roadmap scaffold if absent so the file exists for a
51
54
  // refresh. Pre-fill its Goal from the node's goal doc when present (set at
@@ -112,7 +112,7 @@ export function resetRoot(nodeId, newSessionId, newSessionFile) {
112
112
  // Re-seed persona_ack to the fresh persona so the pristine `/new`
113
113
  // conversation never gets a spurious mode/lifecycle transition steer (the
114
114
  // persona injector compares against this ack).
115
- const { launch } = buildLaunchSpec(meta.kind, 'base', { lifecycle: 'resident', hasManager: false });
115
+ const { launch } = buildLaunchSpec(meta.kind, 'base', { lifecycle: 'resident', hasManager: false, model: meta.model_override ?? undefined });
116
116
  updateNode(nodeId, {
117
117
  mode: 'base',
118
118
  lifecycle: 'resident',
@@ -180,7 +180,7 @@ export function relaunchRoot(oldId, pane, deps = {}) {
180
180
  window: oldMeta.window ?? null,
181
181
  };
182
182
  const newId = newNodeId();
183
- const { launch } = buildLaunchSpec(oldMeta.kind, 'base', { lifecycle: 'resident', hasManager: false });
183
+ const { launch } = buildLaunchSpec(oldMeta.kind, 'base', { lifecycle: 'resident', hasManager: false, model: oldMeta.model_override ?? undefined });
184
184
  // Park-old + mint-new is the single most fragile spot in the runtime, so it is
185
185
  // ONE atomic unit: every ROW write below runs inside a sqlite transaction. A
186
186
  // failure anywhere — including the respawn DISPATCH — rolls the whole thing
@@ -206,6 +206,7 @@ export function relaunchRoot(oldId, pane, deps = {}) {
206
206
  parent: null,
207
207
  spawnedBy: oldId, // audit-only successor link; does NOT touch the spine
208
208
  nodeId: newId,
209
+ modelOverride: oldMeta.model_override,
209
210
  launch,
210
211
  });
211
212
  transition(newId, 'yield'); // active (from spawn) + intent=refresh safety net
@@ -39,6 +39,10 @@ export interface SpawnChildOpts {
39
39
  * headless broker. Persisted as `host_kind` at birth (resolved from
40
40
  * `--headless` / the `headless` config default by the caller). */
41
41
  hostKind?: 'tmux' | 'broker';
42
+ /** Pin the node to a model TIER (ultra/strong/medium/light), overriding the
43
+ * persona's declared default. Persisted to `meta.model_override` so it
44
+ * survives polymorphs. Omit to use the persona default. */
45
+ model?: string;
42
46
  }
43
47
  /** Resolve a `--fork-from` value to the source pi gets as `--fork <path|id>`.
44
48
  * A live node id resolves to its captured session FILE (absolute, cwd-immune),
@@ -161,7 +161,7 @@ export function spawnChild(opts) {
161
161
  // Spine: a managed child reports up to its spawner (has a manager); an
162
162
  // independent root sits top-of-spine with nobody to push to. Mirrors the
163
163
  // `parent` set below (root ? null : spawner), so hasManager === parent!==null.
164
- const { launch } = buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root });
164
+ const { launch } = buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model });
165
165
  // Name the worker from its task now, so its first editor label carries it.
166
166
  const meta = spawnNode({
167
167
  kind: opts.kind,
@@ -180,6 +180,7 @@ export function spawnChild(opts) {
180
180
  // identity over the source's copied-in conversation.
181
181
  forkFrom: opts.forkFrom,
182
182
  hostKind: opts.hostKind,
183
+ modelOverride: opts.model,
183
184
  launch,
184
185
  });
185
186
  // Persist the task as the child's goal for a fresh revive to re-read.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",