@north-light/crouter 0.3.218 → 0.3.220

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.
@@ -82,7 +82,7 @@ export class BrokerLimitsPanel {
82
82
  }
83
83
  render(width) {
84
84
  const out = [
85
- truncateToWidth(`${theme.fg("accent", theme.bold("Broker limits"))}${theme.fg("dim", " user scope · defaults 128 / 256")}`, width),
85
+ truncateToWidth(`${theme.fg("accent", theme.bold("Broker limits"))}${theme.fg("dim", " user scope · defaults 16 / 32")}`, width),
86
86
  truncateToWidth(theme.fg("dim", "These bounds govern daemon automatic revive; explicit node revives remain available."), width),
87
87
  "",
88
88
  ];
@@ -91,8 +91,8 @@ export class BrokerLimitsPanel {
91
91
  out.push(truncateToWidth(`${selected ? theme.fg("accent", "▶") : " "} ${padAnsi(selected ? theme.fg("accent", label) : label, 24)} ${input.render(Math.max(8, width - 28))[0] ?? ""}`, width));
92
92
  out.push(truncateToWidth(theme.fg("dim", ` ${description}`), width));
93
93
  };
94
- line(0, "Warning threshold", "Warn when live brokers reach this count. Default: 128.", this.warningInput);
95
- line(1, "Automatic-revive hard cap", "Defer automatic revives at this count. Default: 256.", this.hardCapInput);
94
+ line(0, "Warning threshold", "Warn when live brokers reach this count. Default: 16.", this.warningInput);
95
+ line(1, "Automatic-revive hard cap", "Defer automatic revives at this count. Default: 32.", this.hardCapInput);
96
96
  out.push("");
97
97
  for (const text of wrapText(`Configured limits apply next tick. ${BROKER_LIMIT_OVERRIDE_PRECEDENCE}`, width))
98
98
  out.push(theme.fg("dim", text));
@@ -27,6 +27,7 @@ export interface AttachKit {
27
27
  /** Connect a BrokerClient to a node's running broker, hello, await welcome. */
28
28
  attach(id: string, role: ClientRole, clientId: string, opts?: {
29
29
  snapshotTail?: number;
30
+ attends?: boolean;
30
31
  }): Promise<Attached>;
31
32
  /** Attach (as `role`) and retry until the welcome satisfies `pred`. */
32
33
  attachUntil(id: string, role: ClientRole, clientId: string, pred: (a: Attached) => boolean, label: string): Promise<Attached>;
@@ -70,6 +70,7 @@ export function createAttachKit(getH) {
70
70
  role,
71
71
  client_id: clientId,
72
72
  ...(opts.snapshotTail === undefined ? {} : { snapshot_tail: opts.snapshotTail }),
73
+ ...(opts.attends === undefined ? {} : { attends: opts.attends }),
73
74
  });
74
75
  let welcome;
75
76
  try {
@@ -19,20 +19,23 @@
19
19
  // they belong to the compiled seam tier; neither needs tmux.
20
20
  import { after, before, test } from 'node:test';
21
21
  import assert from 'node:assert/strict';
22
- import { writeFileSync } from 'node:fs';
22
+ import { readFileSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
25
25
  import { registerWarmSpare, subscribe, updateNode } from '../../canvas/index.js';
26
26
  import { isPidAlive } from '../../canvas/pid.js';
27
27
  import { reviveNode } from '../../runtime/revive.js';
28
28
  import { createHarness } from '../helpers/harness.js';
29
+ import { createAttachKit } from '../helpers/broker-clients.js';
29
30
  let h;
30
31
  let root;
32
+ const attach = createAttachKit(() => h);
31
33
  before(async () => {
32
34
  h = await createHarness({ headless: true, sessionPrefix: 'crtr-dormancy' });
33
35
  root = h.spawnRoot('dormancy-release suite root');
34
36
  });
35
37
  after(async () => {
38
+ attach.closeAll();
36
39
  if (h !== undefined)
37
40
  await h.dispose();
38
41
  });
@@ -187,6 +190,39 @@ test('an unattended resident with no live obligation completes on the daemon clo
187
190
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
188
191
  }
189
192
  });
193
+ test('a non-attending observer does not prevent unattended resident parking', { timeout: 30_000 }, async () => {
194
+ const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
195
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
196
+ const nodeId = h.fabricateBrokerNode({
197
+ id: 'settled-with-system-observer',
198
+ parent: root,
199
+ lifecycle: 'resident',
200
+ status: 'idle',
201
+ intent: 'idle-release',
202
+ });
203
+ updateNode(nodeId, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
204
+ reviveNode(nodeId, { resume: true });
205
+ const boot = await h.awaitBoot(nodeId);
206
+ await attach.attach(nodeId, 'observer', 'system-observer-test', { attends: false });
207
+ assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), 'utf8')).viewers, 0, 'a non-attending hello is not persisted as human attendance');
208
+ try {
209
+ const seenAt = 12_000;
210
+ await h.tick(seenAt);
211
+ await h.tick(seenAt + 1500);
212
+ await h.waitFor(() => h.node(nodeId)?.status === 'done' && !isPidAlive(boot.pid), {
213
+ timeoutMs: 10_000,
214
+ intervalMs: 20,
215
+ label: 'system-observed unattended completion and broker exit',
216
+ });
217
+ await h.awaitFleetExit(nodeId);
218
+ }
219
+ finally {
220
+ if (priorParkMs === undefined)
221
+ delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
222
+ else
223
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
224
+ }
225
+ });
190
226
  test('a connected broker viewer keeps an obligation-free resident live', { timeout: 30_000 }, async () => {
191
227
  const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
192
228
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
@@ -1,8 +1,9 @@
1
1
  import type { Socket } from 'node:net';
2
2
  import { FrameDecoder, type BrokerSnapshot, type BrokerToClient, type ClientRole } from '../broker-protocol.js';
3
- /** True when the live broker has at least one helloed terminal or web viewer.
4
- * Callers establish broker liveness separately because a crash can leave the
5
- * last persisted count behind. */
3
+ /** True when the live broker has at least one client that declared itself to
4
+ * be attending the conversation. A system observer may read the same stream
5
+ * without keeping an otherwise idle broker alive. Callers establish broker
6
+ * liveness separately because a crash can leave the last persisted count behind. */
6
7
  export declare function hasAttachedBrokerViewers(nodeId: string): boolean;
7
8
  export interface BrokerClient {
8
9
  id: string;
@@ -10,6 +11,8 @@ export interface BrokerClient {
10
11
  socket: Socket;
11
12
  decoder: FrameDecoder;
12
13
  helloed: boolean;
14
+ /** Fixed by the first hello; absent `attends` remains backward-compatible true. */
15
+ attends: boolean;
13
16
  snapshotTail: number | undefined;
14
17
  pendingBytes: number;
15
18
  queuedFrames: number;
@@ -5,9 +5,10 @@ import { encodeFrame, FrameDecoder, BROKER_READ_CAPS, } from '../broker-protocol
5
5
  /** Per-viewer outbound high-water mark. Slow viewers are shed rather than allowed to backpressure the shared engine indefinitely. */
6
6
  const MAX_QUEUED_FRAMES = 1000;
7
7
  const MAX_PENDING_BYTES = 32 * 1024 * 1024;
8
- /** True when the live broker has at least one helloed terminal or web viewer.
9
- * Callers establish broker liveness separately because a crash can leave the
10
- * last persisted count behind. */
8
+ /** True when the live broker has at least one client that declared itself to
9
+ * be attending the conversation. A system observer may read the same stream
10
+ * without keeping an otherwise idle broker alive. Callers establish broker
11
+ * liveness separately because a crash can leave the last persisted count behind. */
11
12
  export function hasAttachedBrokerViewers(nodeId) {
12
13
  try {
13
14
  const record = JSON.parse(readFileSync(`${jobDir(nodeId)}/attach.json`, 'utf8'));
@@ -36,6 +37,7 @@ export class BrokerClientRegistry {
36
37
  socket,
37
38
  decoder: new FrameDecoder(BROKER_READ_CAPS),
38
39
  helloed: false,
40
+ attends: true,
39
41
  snapshotTail: undefined,
40
42
  pendingBytes: 0,
41
43
  queuedFrames: 0,
@@ -58,7 +60,7 @@ export class BrokerClientRegistry {
58
60
  try {
59
61
  let viewers = 0;
60
62
  for (const client of this.clients)
61
- if (client.helloed)
63
+ if (client.helloed && client.attends)
62
64
  viewers += 1;
63
65
  const dir = jobDir(this.nodeId);
64
66
  if (!existsSync(dir))
@@ -757,6 +757,7 @@ export function createFrameDispatchContext(deps) {
757
757
  if (firstHello) {
758
758
  client.helloed = true;
759
759
  client.role = frame.role === 'controller' ? 'controller' : 'observer';
760
+ client.attends = frame.attends !== false;
760
761
  }
761
762
  sendWelcome(client);
762
763
  if (firstHello)
@@ -82,6 +82,10 @@ export interface HelloFrame {
82
82
  type: 'hello';
83
83
  role: ClientRole;
84
84
  client_id: string;
85
+ /** Whether this socket represents a person attending the conversation. Defaults
86
+ * true for backward compatibility; system observers set false without changing
87
+ * their read-only `role`. */
88
+ attends?: boolean;
85
89
  /** Per-attach welcome transcript bound; absent or invalid keeps the full history. */
86
90
  snapshot_tail?: number;
87
91
  /** Terminal geometry of a tmux-pane viewer; absent for a headless client. */
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ // Runs the synchronous configuration resolver outside crtrd's process. A
2
+ // network filesystem can block a native read indefinitely; unlike a worker
3
+ // thread, this subprocess can be killed at the launch-config timeout.
4
+ import { readMergedLaunchConfig } from '../config.js';
5
+ process.once('message', (message) => {
6
+ try {
7
+ process.send?.({ config: readMergedLaunchConfig(message.cwd, message.profileId) });
8
+ }
9
+ catch (error) {
10
+ process.send?.({ error: error instanceof Error ? error.message : String(error) });
11
+ }
12
+ });
@@ -1,3 +1,4 @@
1
+ import { type MergedLaunchConfig } from '../config.js';
1
2
  import type { PiInvocation } from './invocation.js';
2
3
  export { CANVAS_EXTENSIONS, CANVAS_STOPHOOK_PATH, CANVAS_INBOX_WATCHER_PATH, CANVAS_RECAP_PATH, CANVAS_GOAL_CAPTURE_PATH, CANVAS_PASSIVE_CONTEXT_PATH, CANVAS_CONTEXT_INTRO_PATH, CANVAS_TOOL_GUIDE_PATH, CANVAS_DOC_SUBSTRATE_PATH, CANVAS_STRUCTURED_OUTPUT_PATH, CANVAS_BASH_VALVE_PATH, } from './canvas-extensions.js';
3
4
  import type { NodeMeta, LaunchSpec, Mode, Lifecycle } from '../canvas/index.js';
@@ -59,7 +60,7 @@ export declare function nextLadderModel(currentSpec: string, direction?: 1 | -1,
59
60
  * a polymorph/flip rebuilds the recipe faithfully; `lifecycle` is returned
60
61
  * as-given, never derived from persona frontmatter. The two canvas
61
62
  * extensions are always first; kind-declared extensions follow. */
62
- export declare function buildLaunchSpec(kind: string, mode: Mode, opts: {
63
+ export interface LaunchBuildOptions {
63
64
  lifecycle: Lifecycle;
64
65
  hasManager: boolean;
65
66
  extraEnv?: Record<string, string>;
@@ -101,10 +102,23 @@ export declare function buildLaunchSpec(kind: string, mode: Mode, opts: {
101
102
  * broker's launch-time/turn-time resolution paths must bypass every
102
103
  * fallback for and fail closed on unavailability. */
103
104
  modelExact?: boolean;
104
- }): {
105
+ }
106
+ /** Build a launch recipe for callers that are not on crtrd's event loop. */
107
+ export declare function buildLaunchSpec(kind: string, mode: Mode, opts: LaunchBuildOptions): {
105
108
  launch: LaunchSpec;
106
109
  lifecycle: 'terminal' | 'resident';
107
110
  };
111
+ /**
112
+ * Resolve launch config in a bounded subprocess before building a recipe.
113
+ * crtrd is single-threaded: project/profile roots can live on a network
114
+ * filesystem, so even an ordinary synchronous config read must never run on
115
+ * its event loop.
116
+ */
117
+ export declare function buildLaunchSpecAsync(kind: string, mode: Mode, opts: LaunchBuildOptions): Promise<{
118
+ launch: LaunchSpec;
119
+ lifecycle: 'terminal' | 'resident';
120
+ }>;
121
+ export declare function readMergedLaunchConfigAsync(cwd: string, profileId: string | null): Promise<MergedLaunchConfig>;
108
122
  export type { PiInvocation } from './invocation.js';
109
123
  /** Construct the pi invocation for a node.
110
124
  * - fresh start: pass `prompt` (the node's first user message), no resume.
@@ -11,6 +11,8 @@
11
11
  import { mkdirSync, writeFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { readMergedLaunchConfig } from '../config.js';
14
+ import { fork } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
14
16
  import { LAUNCH_SYSTEM_PROMPT } from './launch-prompt.js';
15
17
  import { nodeEnv } from './nodes.js';
16
18
  import { editorLabel } from '../canvas/index.js';
@@ -232,27 +234,7 @@ export function nextLadderModel(currentSpec, direction = 1, providers) {
232
234
  return order[0];
233
235
  return order[(idx + direction + order.length) % order.length];
234
236
  }
235
- // ---------------------------------------------------------------------------
236
- // Build the launch spec from {kind, mode}
237
- // ---------------------------------------------------------------------------
238
- /** Compose a node's full pi launch recipe from the kind registry. The system
239
- * prompt is now a minimal static scaffold (`LAUNCH_SYSTEM_PROMPT`) — runtime/
240
- * spine/lifecycle/kind prose is spliced per-turn from gated builtin memory docs,
241
- * not baked in here. Launch KNOBS (model/tools/extensions) still come from the
242
- * kind registry (`resolveKindConfig`), keyed on the FULL kind string (top-level
243
- * or sub-kind). Model precedence, strongest to weakest:
244
- * 1. Caller-supplied `opts.model` (durable on `meta.model_override`, re-passed
245
- * on every polymorph).
246
- * 2. The kind's registered model — mode-aware: orchestrator mode prefers
247
- * `orchestratorModel`, falling back to `model`; base mode always uses
248
- * `model`.
249
- * 3. Unset → pi default.
250
- * Callers pass the authoritative lifecycle + hasManager (`parent !== null`) so
251
- * a polymorph/flip rebuilds the recipe faithfully; `lifecycle` is returned
252
- * as-given, never derived from persona frontmatter. The two canvas
253
- * extensions are always first; kind-declared extensions follow. */
254
- export function buildLaunchSpec(kind, mode, opts) {
255
- const merged = readMergedLaunchConfig(opts.cwd ?? process.cwd(), opts.profileId !== undefined ? opts.profileId : (process.env['CRTR_PROFILE_ID'] || null));
237
+ function buildLaunchSpecFromMerged(kind, mode, opts, merged) {
256
238
  const kindConfig = merged.kinds[kind];
257
239
  const ladders = merged.modelLadders;
258
240
  const kindModel = mode === 'orchestrator' ? kindConfig?.orchestratorModel ?? kindConfig?.model : kindConfig?.model;
@@ -274,6 +256,58 @@ export function buildLaunchSpec(kind, mode, opts) {
274
256
  };
275
257
  return { launch, lifecycle: opts.lifecycle };
276
258
  }
259
+ /** Build a launch recipe for callers that are not on crtrd's event loop. */
260
+ export function buildLaunchSpec(kind, mode, opts) {
261
+ return buildLaunchSpecFromMerged(kind, mode, opts, readMergedLaunchConfig(opts.cwd ?? process.cwd(), opts.profileId !== undefined ? opts.profileId : (process.env['CRTR_PROFILE_ID'] || null)));
262
+ }
263
+ /**
264
+ * Resolve launch config in a bounded subprocess before building a recipe.
265
+ * crtrd is single-threaded: project/profile roots can live on a network
266
+ * filesystem, so even an ordinary synchronous config read must never run on
267
+ * its event loop.
268
+ */
269
+ export async function buildLaunchSpecAsync(kind, mode, opts) {
270
+ const cwd = opts.cwd ?? process.cwd();
271
+ const profileId = opts.profileId !== undefined ? opts.profileId : (process.env['CRTR_PROFILE_ID'] || null);
272
+ const merged = await readMergedLaunchConfigAsync(cwd, profileId);
273
+ return buildLaunchSpecFromMerged(kind, mode, opts, merged);
274
+ }
275
+ const LAUNCH_CONFIG_TIMEOUT_MS = 15_000;
276
+ export function readMergedLaunchConfigAsync(cwd, profileId) {
277
+ return new Promise((resolve, reject) => {
278
+ const child = fork(fileURLToPath(new URL('./launch-config-worker.js', import.meta.url)), [], {
279
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
280
+ serialization: 'advanced',
281
+ });
282
+ child.send({ cwd, profileId });
283
+ let settled = false;
284
+ const finish = (fn) => {
285
+ if (settled)
286
+ return;
287
+ settled = true;
288
+ clearTimeout(timeout);
289
+ if (child.connected)
290
+ child.disconnect();
291
+ if (!child.killed)
292
+ child.kill('SIGKILL');
293
+ fn();
294
+ };
295
+ const timeout = setTimeout(() => finish(() => reject(new Error(`timed out after ${LAUNCH_CONFIG_TIMEOUT_MS}ms reading launch config for ${cwd}`))), LAUNCH_CONFIG_TIMEOUT_MS);
296
+ child.once('message', (message) => {
297
+ if (message.error !== undefined)
298
+ finish(() => reject(new Error(message.error)));
299
+ else if (message.config !== undefined)
300
+ finish(() => resolve(message.config));
301
+ else
302
+ finish(() => reject(new Error('launch config subprocess returned no result')));
303
+ });
304
+ child.once('error', (error) => finish(() => reject(error)));
305
+ child.once('exit', (code, signal) => {
306
+ if (code !== 0 && signal !== 'SIGKILL')
307
+ finish(() => reject(new Error(`launch config subprocess exited with code ${code}`)));
308
+ });
309
+ });
310
+ }
277
311
  // ---------------------------------------------------------------------------
278
312
  // Build the pi argv to launch / revive a node
279
313
  // ---------------------------------------------------------------------------
@@ -14,13 +14,17 @@ export interface PromoteResult {
14
14
  * is what a plain `crtr node yield --kind/--model` reshape rides, with no
15
15
  * mode flip and no roadmap seeding. `promote()` below is a thin wrapper that
16
16
  * also forces mode→orchestrator and seeds the roadmap. */
17
- export declare function reshapeNode(nodeId: string, opts?: {
17
+ type ReshapeOptions = {
18
18
  kind?: string;
19
19
  model?: string;
20
20
  mode?: Mode;
21
21
  lifecycle?: Lifecycle;
22
22
  profileId?: string | null;
23
- }, beforePersist?: () => void): NodeMeta;
23
+ };
24
+ /** Synchronous reshape for CLI callers outside the daemon. */
25
+ export declare function reshapeNode(nodeId: string, opts?: ReshapeOptions, beforePersist?: () => void): NodeMeta;
26
+ /** Daemon-safe reshape: network-backed config resolution happens off its event loop. */
27
+ export declare function reshapeNodeAsync(nodeId: string, opts?: ReshapeOptions, beforePersist?: () => void): Promise<NodeMeta>;
24
28
  /** Promote a node to an orchestrator (mode→orchestrator), optionally
25
29
  * specializing its kind (e.g. a `general` worker becoming a
26
30
  * `developer.orchestrator`) and optionally also making it resident. Idempotent:
@@ -33,6 +37,12 @@ export declare function promote(nodeId: string, opts?: {
33
37
  resident?: boolean;
34
38
  model?: string;
35
39
  }): PromoteResult;
40
+ /** Daemon-safe promotion variant; preserves the same validation/seed ordering. */
41
+ export declare function promoteAsync(nodeId: string, opts?: {
42
+ kind?: string;
43
+ resident?: boolean;
44
+ model?: string;
45
+ }): Promise<PromoteResult>;
36
46
  export interface YieldResult {
37
47
  meta: NodeMeta;
38
48
  /** True iff `promote:true` was passed — a plain yield never flips mode. */
@@ -49,6 +59,13 @@ export interface YieldResult {
49
59
  * same path `crtr node promote` would — mode flips to orchestrator and a
50
60
  * roadmap scaffold is seeded. Sets intent='refresh'; the stophook shuts the
51
61
  * process down on the next stop and the daemon revives it fresh. */
62
+ export declare function requestYieldAsync(nodeId: string, opts?: {
63
+ kind?: string;
64
+ model?: string;
65
+ promote?: boolean;
66
+ }, deps?: {
67
+ ensure?: () => void;
68
+ }): Promise<YieldResult>;
52
69
  export declare function requestYield(nodeId: string, opts?: {
53
70
  kind?: string;
54
71
  model?: string;
@@ -56,3 +73,4 @@ export declare function requestYield(nodeId: string, opts?: {
56
73
  }, deps?: {
57
74
  ensure?: () => void;
58
75
  }): YieldResult;
76
+ export {};
@@ -21,75 +21,48 @@
21
21
  import { getNode, updateNode } from '../canvas/index.js';
22
22
  import { transition } from './lifecycle.js';
23
23
  import { ensureDaemon } from '../../daemon/manage.js';
24
- import { buildLaunchSpec } from './launch.js';
24
+ import { buildLaunchSpec, buildLaunchSpecAsync } from './launch.js';
25
25
  import { hasRoadmap, seedRoadmap, roadmapPath } from './roadmap.js';
26
26
  import { readGoal, goalPath } from './kickoff.js';
27
- /** Shared reshape machinery: rewrite kind and/or model tier — and, when the
28
- * caller supplies them, mode/lifecycle — by rebuilding the launch spec so the
29
- * change is durable across a future revive. MODE-PRESERVING by default (omit
30
- * `mode`/`lifecycle` and the node's own current values carry through) — this
31
- * is what a plain `crtr node yield --kind/--model` reshape rides, with no
32
- * mode flip and no roadmap seeding. `promote()` below is a thin wrapper that
33
- * also forces mode→orchestrator and seeds the roadmap. */
34
- export function reshapeNode(nodeId, opts = {}, beforePersist) {
27
+ function reshapeInputs(nodeId, opts) {
35
28
  const node = getNode(nodeId);
36
29
  if (node === null)
37
30
  throw new Error(`unknown node: ${nodeId}`);
38
- // The node may specialize as it reshapes; default to its current kind.
39
- const targetKind = opts.kind ?? node.kind;
40
- // ...and may raise/change its model tier; default to its current pin (so a
41
- // reshape with no --model preserves whatever it was running on).
42
- const targetModel = opts.model ?? node.model_override ?? undefined;
43
- const targetMode = opts.mode ?? node.mode;
44
- const targetLifecycle = opts.lifecycle ?? node.lifecycle;
45
- const targetProfileId = opts.profileId === undefined ? node.profile_id : opts.profileId;
46
- // Rewrite the launch spec to the target kind/mode's persona so the *next*
47
- // revive comes back as this exact shape (polymorph stage 2). nodeEnv reads
48
- // meta.{kind,mode}, so CRTR_KIND/CRTR_MODE flip immediately for the live
49
- // process's children too.
50
- const { launch } = buildLaunchSpec(targetKind, targetMode, {
51
- lifecycle: targetLifecycle,
52
- hasManager: node.parent !== null,
53
- // A model tier chosen on this call (opts.model) overrides the persona
54
- // default and is persisted below; absent one, the existing pin carries
55
- // across the polymorph (the persona default is recomputed fresh for
56
- // targetKind).
57
- model: targetModel,
58
- // opts.model given — an explicit new pick — lets buildLaunchSpec re-decide
59
- // the pin from ITS raw shape (undefined here). No opts.model — targetModel
60
- // just re-supplies the current value for resolution, not a new user
61
- // choice — so carry the node's existing pin verbatim instead.
62
- modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
63
- modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
64
- // Exact-model human-work forks must stay fail-closed after a polymorph.
65
- // Dropping this bit produces a recipe reviveNode refuses to relaunch.
66
- modelExact: opts.model === undefined ? node.launch?.modelExact : undefined,
67
- // Resolve kind/ladder config against the NODE's own cwd + profile, not
68
- // this process's ambient scope.
69
- cwd: node.cwd,
70
- profileId: targetProfileId,
71
- });
72
- // Promotion seeds after its target launch has been built but before the
73
- // mutation is persisted, preserving the original promote() failure order.
31
+ const kind = opts.kind ?? node.kind;
32
+ const mode = opts.mode ?? node.mode;
33
+ const lifecycle = opts.lifecycle ?? node.lifecycle;
34
+ const profileId = opts.profileId === undefined ? node.profile_id ?? null : opts.profileId;
35
+ return {
36
+ node, kind, mode, lifecycle, profileId,
37
+ launchOpts: {
38
+ lifecycle, hasManager: node.parent !== null, model: opts.model ?? node.model_override ?? undefined,
39
+ modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
40
+ modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
41
+ modelExact: opts.model === undefined ? node.launch?.modelExact : undefined,
42
+ cwd: node.cwd, profileId,
43
+ },
44
+ };
45
+ }
46
+ function persistReshape(nodeId, opts, input, launch, beforePersist) {
74
47
  beforePersist?.();
75
- // If the handle is still the spawn-time kind default (name === old kind),
76
- // carry it to the new kind so the "name === kind ⇒ default, drop it" invariant
77
- // in fullName() holds across a kind change. Otherwise a node spawned `general`
78
- // (name="general") that reshapes to `plan` would surface a stale "general"
79
- // handle in front of its real description.
80
- const carriesDefaultHandle = node.name === node.kind && targetKind !== node.kind;
48
+ const carriesDefaultHandle = input.node.name === input.node.kind && input.kind !== input.node.kind;
81
49
  return updateNode(nodeId, {
82
- kind: targetKind,
83
- mode: targetMode,
84
- lifecycle: targetLifecycle,
85
- profile_id: targetProfileId,
86
- launch,
87
- ...(carriesDefaultHandle ? { name: targetKind } : {}),
88
- // Persist a newly-chosen tier so it is durable across future revives; omit
89
- // when unchanged so the existing pin (or persona default) stands.
50
+ kind: input.kind, mode: input.mode, lifecycle: input.lifecycle, profile_id: input.profileId, launch,
51
+ ...(carriesDefaultHandle ? { name: input.kind } : {}),
90
52
  ...(opts.model !== undefined ? { model_override: opts.model } : {}),
91
53
  });
92
54
  }
55
+ /** Synchronous reshape for CLI callers outside the daemon. */
56
+ export function reshapeNode(nodeId, opts = {}, beforePersist) {
57
+ const input = reshapeInputs(nodeId, opts);
58
+ return persistReshape(nodeId, opts, input, buildLaunchSpec(input.kind, input.mode, input.launchOpts).launch, beforePersist);
59
+ }
60
+ /** Daemon-safe reshape: network-backed config resolution happens off its event loop. */
61
+ export async function reshapeNodeAsync(nodeId, opts = {}, beforePersist) {
62
+ const input = reshapeInputs(nodeId, opts);
63
+ const { launch } = await buildLaunchSpecAsync(input.kind, input.mode, input.launchOpts);
64
+ return persistReshape(nodeId, opts, input, launch, beforePersist);
65
+ }
93
66
  /** Promote a node to an orchestrator (mode→orchestrator), optionally
94
67
  * specializing its kind (e.g. a `general` worker becoming a
95
68
  * `developer.orchestrator`) and optionally also making it resident. Idempotent:
@@ -129,6 +102,25 @@ export function promote(nodeId, opts = {}) {
129
102
  goalPath: goalPath(nodeId),
130
103
  };
131
104
  }
105
+ /** Daemon-safe promotion variant; preserves the same validation/seed ordering. */
106
+ export async function promoteAsync(nodeId, opts = {}) {
107
+ const node = getNode(nodeId);
108
+ if (node === null)
109
+ throw new Error(`unknown node: ${nodeId}`);
110
+ let roadmapWritten = false;
111
+ const meta = await reshapeNodeAsync(nodeId, {
112
+ ...(opts.kind !== undefined ? { kind: opts.kind } : {}),
113
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
114
+ mode: 'orchestrator', lifecycle: opts.resident === true ? 'resident' : node.lifecycle,
115
+ }, () => {
116
+ if (!hasRoadmap(nodeId)) {
117
+ const goal = readGoal(nodeId);
118
+ seedRoadmap(nodeId, goal !== null && goal.trim() !== '' ? { goal: goal.trim() } : {});
119
+ roadmapWritten = true;
120
+ }
121
+ });
122
+ return { meta, roadmapWritten, roadmapPath: roadmapPath(nodeId), goalPath: goalPath(nodeId) };
123
+ }
132
124
  /** Request a refresh-yield: discard in-memory context and revive fresh, mode
133
125
  * and lifecycle UNCHANGED, against the roadmap if one exists else the
134
126
  * original goal (`buildReviveKickoff` falls back to it). A plain yield is
@@ -138,6 +130,18 @@ export function promote(nodeId, opts = {}) {
138
130
  * same path `crtr node promote` would — mode flips to orchestrator and a
139
131
  * roadmap scaffold is seeded. Sets intent='refresh'; the stophook shuts the
140
132
  * process down on the next stop and the daemon revives it fresh. */
133
+ export async function requestYieldAsync(nodeId, opts = {}, deps = {}) {
134
+ const node = getNode(nodeId);
135
+ if (node === null)
136
+ throw new Error(`unknown node: ${nodeId}`);
137
+ if (opts.promote === true) {
138
+ await promoteAsync(nodeId, { ...(opts.kind !== undefined ? { kind: opts.kind } : {}), ...(opts.model !== undefined ? { model: opts.model } : {}) });
139
+ }
140
+ else if (opts.kind !== undefined || opts.model !== undefined) {
141
+ await reshapeNodeAsync(nodeId, { ...(opts.kind !== undefined ? { kind: opts.kind } : {}), ...(opts.model !== undefined ? { model: opts.model } : {}) });
142
+ }
143
+ return { ...requestYield(nodeId, {}, deps), promoted: opts.promote === true };
144
+ }
141
145
  export function requestYield(nodeId, opts = {}, deps = {}) {
142
146
  const ensure = deps.ensure ?? ensureDaemon;
143
147
  const node = getNode(nodeId);
@@ -21,7 +21,7 @@ import { getNode, setPresence, setFocusOccupant, fullName } from '../canvas/inde
21
21
  import { reportsDir } from '../canvas/paths.js';
22
22
  import { pushFinal } from '../feed/feed.js';
23
23
  import { spawnNode, rootOfSpine } from './nodes.js';
24
- import { buildLaunchSpec, buildPiArgv } from './launch.js';
24
+ import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
25
25
  import { focusOf, respawnPaneSync, setPaneOption } from './placement.js';
26
26
  import { waitForBrokerViewSocket, viewerSplitEnv } from './placement-tmux.js';
27
27
  import { headlessBrokerHost } from './host.js';
@@ -102,7 +102,7 @@ export async function recycleNode(nodeId, callerPane) {
102
102
  // ambient scope) and carry the profile onto the fresh root — it is the same
103
103
  // person's seat, so profile-scope defaults (e.g. a persisted /model default)
104
104
  // must keep applying.
105
- const { launch } = buildLaunchSpec('general', 'base', { lifecycle: 'resident', hasManager: false, cwd: meta.cwd, profileId: meta.profile_id });
105
+ const { launch } = await buildLaunchSpecAsync('general', 'base', { lifecycle: 'resident', hasManager: false, cwd: meta.cwd, profileId: meta.profile_id });
106
106
  const root = spawnNode({
107
107
  kind: 'general',
108
108
  mode: 'base',
@@ -25,7 +25,7 @@ import { transition } from './lifecycle.js';
25
25
  import { headlessBrokerHost } from './host.js';
26
26
  import { tearDownNode, focusOf, registerViewerFocus, respawnPaneSync, windowOfPane, renameWindow, } from './placement.js';
27
27
  import { viewerSplitEnv, waitForBrokerViewSocket } from './placement-tmux.js';
28
- import { buildLaunchSpec, buildPiArgv } from './launch.js';
28
+ import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
29
29
  import { spawnNode, rootOfSpine } from './nodes.js';
30
30
  // ---------------------------------------------------------------------------
31
31
  // reapDescendants — tear down a root's descendant sub-DAG (shared helper)
@@ -83,7 +83,7 @@ export async function relaunchRoot(oldId, deps = {}) {
83
83
  // --- mint + boot the NEW broker FIRST (a failure here leaves the old root
84
84
  // untouched) ---
85
85
  // A relaunched root is a fresh resident base, exactly like the front door.
86
- const { launch } = buildLaunchSpec(old.kind, 'base', {
86
+ const { launch } = await buildLaunchSpecAsync(old.kind, 'base', {
87
87
  lifecycle: 'resident',
88
88
  hasManager: false,
89
89
  model: old.model_override ?? undefined,
@@ -14,7 +14,7 @@ import { isAbsolute, resolve, join } from 'node:path';
14
14
  import { spawnNode, currentNodeContext, rootOfSpine, newNodeId, preflightNodeId } from './nodes.js';
15
15
  import { loadProfileManifest } from '../profiles/manifest.js';
16
16
  import { selectProfileForCwd } from '../profiles/select.js';
17
- import { buildLaunchSpec, buildPiArgv } from './launch.js';
17
+ import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
18
18
  import { createManagedWorktree, rollbackManagedWorktree } from '../worktree.js';
19
19
  import { usage, brokerLaunchFailed } from '../errors.js';
20
20
  import { writeGoal } from './kickoff.js';
@@ -237,7 +237,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
237
237
  if (opts.nodeId !== undefined)
238
238
  preflightNodeId(opts.nodeId);
239
239
  const profileId = await resolveProfileId(opts.profile, spawner, opts.cwd);
240
- const preflightLaunch = buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: opts.cwd, profileId }).launch;
240
+ const preflightLaunch = (await buildLaunchSpecAsync(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: opts.cwd, profileId })).launch;
241
241
  await assertLaunchModelRegistered(requestFromLaunch(preflightLaunch), { cwd: opts.cwd, profileId });
242
242
  const forkFrom = opts.forkFrom !== undefined ? resolveForkSource(opts.forkFrom) : undefined;
243
243
  let managedWorktree;
@@ -255,7 +255,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
255
255
  // this process's ambient scope (a front-door shell has no CRTR_PROFILE_ID,
256
256
  // which silently hid every profile-scope kind override).
257
257
  const launch = wantsWorktree
258
- ? buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: spawnCwd, profileId }).launch
258
+ ? (await buildLaunchSpecAsync(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: spawnCwd, profileId })).launch
259
259
  : preflightLaunch;
260
260
  if (wantsWorktree) {
261
261
  await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd: spawnCwd, profileId });