@north-light/crouter 0.3.214 → 0.3.216
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/nodes.d.ts +2 -1
- package/dist/api/dto/profiles.d.ts +13 -1
- package/dist/builtin-memory/00-runtime-base.md +12 -11
- package/dist/builtin-memory/01-spine/00-has-manager.md +1 -11
- package/dist/builtin-memory/02-lifecycle/00-terminal.md +4 -10
- package/dist/builtin-memory/04-orchestration-kernel.md +9 -35
- package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +1 -1
- package/dist/builtin-memory/05-kinds/design/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +1 -1
- package/dist/builtin-memory/05-kinds/developer/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/general/00-base.md +2 -0
- package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/{00-base.md → lens-contract.md} +1 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
- package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
- package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
- package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
- package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
- package/dist/clients/attach/__tests__/context-message.test.js +28 -1
- package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
- package/dist/clients/attach/chrome/canvas-panels.js +5 -7
- package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
- package/dist/clients/attach/chrome/inbox-strip.js +187 -0
- package/dist/clients/attach/chrome/roster.d.ts +6 -2
- package/dist/clients/attach/chrome/roster.js +13 -52
- package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
- package/dist/clients/attach/chrome/ticket-panel.js +236 -0
- package/dist/clients/attach/render/card-presentation.d.ts +8 -3
- package/dist/clients/attach/render/card-presentation.js +48 -10
- package/dist/clients/attach/render/context-message.d.ts +5 -0
- package/dist/clients/attach/render/context-message.js +20 -14
- package/dist/clients/attach/session/context.d.ts +3 -0
- package/dist/clients/attach/session/frame.d.ts +4 -0
- package/dist/clients/attach/session/frame.js +8 -3
- package/dist/clients/attach/session/keys.d.ts +7 -0
- package/dist/clients/attach/session/keys.js +7 -0
- package/dist/clients/attach/session/layout.js +6 -3
- package/dist/clients/attach/session/pane-focus.d.ts +8 -0
- package/dist/clients/attach/session/pane-focus.js +42 -0
- package/dist/clients/attach/viewer.js +793 -789
- package/dist/clients/inbox/controller.d.ts +9 -0
- package/dist/clients/inbox/controller.js +57 -2
- package/dist/clients/inbox/surface.d.ts +2 -0
- package/dist/clients/inbox/surface.js +18 -3
- package/dist/clients/inbox/tui.d.ts +2 -0
- package/dist/clients/inbox/tui.js +11 -1
- package/dist/commands/node/create.js +3 -3
- package/dist/commands/profile/kind.d.ts +1 -0
- package/dist/commands/profile/kind.js +39 -0
- package/dist/commands/profile/list.js +5 -1
- package/dist/commands/profile/meta.d.ts +4 -0
- package/dist/commands/profile/meta.js +69 -0
- package/dist/commands/profile/new.js +34 -4
- package/dist/commands/profile/pause.d.ts +2 -0
- package/dist/commands/profile/pause.js +60 -0
- package/dist/commands/profile/show.js +10 -2
- package/dist/commands/profile.js +5 -8
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
- package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
- package/dist/core/command.js +23 -23
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +15 -0
- package/dist/core/help.d.ts +1 -1
- package/dist/core/keybindings/attach-control.d.ts +3 -0
- package/dist/core/keybindings/attach-control.js +1 -0
- package/dist/core/keybindings/catalog.js +1 -0
- package/dist/core/profiles/deletion-reservation.d.ts +7 -3
- package/dist/core/profiles/deletion-reservation.js +9 -5
- package/dist/core/profiles/manifest.d.ts +39 -1
- package/dist/core/profiles/manifest.js +171 -4
- package/dist/core/runtime/boot-root.js +3 -3
- package/dist/core/runtime/close.js +10 -5
- package/dist/core/runtime/revive.js +2 -0
- package/dist/core/runtime/spawn-env.d.ts +9 -1
- package/dist/core/runtime/spawn-env.js +17 -1
- package/dist/core/termrender/termrender.d.ts +7 -2
- package/dist/core/termrender/termrender.js +11 -6
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
- package/dist/daemon/api/handlers/messages.js +9 -2
- package/dist/daemon/api/handlers/nodes.d.ts +2 -0
- package/dist/daemon/api/handlers/nodes.js +15 -8
- package/dist/daemon/api/handlers/profiles.js +12 -2
- package/dist/daemon/api/map.js +4 -0
- package/dist/daemon/fleet.js +1 -6
- package/dist/daemon/reconcilers/broker-supervision.js +15 -25
- package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
- package/dist/daemon/reconcilers/live-obligation.d.ts +5 -1
- package/dist/daemon/reconcilers/live-obligation.js +11 -19
- package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
- package/dist/types.d.ts +9 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
- package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
|
@@ -14,6 +14,7 @@ import { appendInbox, readCursor } from '../feed/inbox.js';
|
|
|
14
14
|
import { drainBearings } from '../runtime/kickoff.js';
|
|
15
15
|
import { appendSituationalContext } from '../runtime/situational-context.js';
|
|
16
16
|
import { createNode, getNode, recordPid, setIntent } from '../canvas/canvas.js';
|
|
17
|
+
import { createProfile, pauseProfile, resumeProfile } from '../profiles/manifest.js';
|
|
17
18
|
import { closeDb } from '../canvas/db.js';
|
|
18
19
|
import { createApiServer } from '../../daemon/api/server.js';
|
|
19
20
|
import { CrtrClient } from '../../api/client.js';
|
|
@@ -257,6 +258,45 @@ describe('canvas inbox watcher — hold + idle delivery', () => {
|
|
|
257
258
|
assert.equal(pi.injected.length, 1);
|
|
258
259
|
assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
|
|
259
260
|
});
|
|
261
|
+
// Pause makes the profile inert while its broker is still alive: the entry
|
|
262
|
+
// must be RETAINED, not consumed. A critical entry is the sharpest case —
|
|
263
|
+
// its route aborts the live turn, exactly the punch-through pause forbids.
|
|
264
|
+
test('a paused profile holds queued entries in a live broker until resume', async () => {
|
|
265
|
+
freshNode('node-paused-profile');
|
|
266
|
+
closeDb();
|
|
267
|
+
const origUserHome = process.env.HOME;
|
|
268
|
+
const origProfile = process.env['CRTR_PROFILE_ID'];
|
|
269
|
+
const userHome = join(tmpdir(), `crtr-w-home-${Math.random().toString(36).slice(2, 8)}`);
|
|
270
|
+
mkdirSync(userHome, { recursive: true });
|
|
271
|
+
homes.push(userHome);
|
|
272
|
+
process.env.HOME = userHome;
|
|
273
|
+
try {
|
|
274
|
+
const profile = createProfile('paused watcher', ['/tmp']);
|
|
275
|
+
pauseProfile(profile.profileId);
|
|
276
|
+
process.env['CRTR_PROFILE_ID'] = profile.profileId;
|
|
277
|
+
const pi = makeFakePi();
|
|
278
|
+
disposers.push(registerWatcher(pi));
|
|
279
|
+
await wait(TICK_MS + 100);
|
|
280
|
+
appendInbox('node-paused-profile', { from: 'child-1', tier: 'critical', kind: 'update', label: 'sent while paused' });
|
|
281
|
+
await wait(SETTLE_MS);
|
|
282
|
+
assert.equal(pi.injected.length, 0, 'a paused profile delivers nothing to its live broker');
|
|
283
|
+
assert.equal(readCursor('node-paused-profile'), undefined, 'the held entry stays unconsumed');
|
|
284
|
+
resumeProfile(profile.profileId);
|
|
285
|
+
await wait(SETTLE_MS);
|
|
286
|
+
assert.equal(pi.injected.length, 1, 'resume delivers the entry that waited');
|
|
287
|
+
assert.match(pi.injected[0].content, /sent while paused/);
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
if (origUserHome === undefined)
|
|
291
|
+
delete process.env.HOME;
|
|
292
|
+
else
|
|
293
|
+
process.env.HOME = origUserHome;
|
|
294
|
+
if (origProfile === undefined)
|
|
295
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
296
|
+
else
|
|
297
|
+
process.env['CRTR_PROFILE_ID'] = origProfile;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
260
300
|
});
|
|
261
301
|
// Lifecycle-ordering regression (the live inbox-delivery startup loop): pi
|
|
262
302
|
// deliberately makes action methods (sendUserMessage/sendMessage) throw until
|
|
@@ -9,11 +9,39 @@
|
|
|
9
9
|
import { test } from 'node:test';
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
11
|
import { parseArgv } from '../command.js';
|
|
12
|
+
// Companion lock: `crtr node yield "note"`, `crtr push "done"` and 11 sibling
|
|
13
|
+
// leaves declare a `stdin` body and NO positional param, and take that body as
|
|
14
|
+
// one bare token. A parser refactor dropped the branch that accepts it while
|
|
15
|
+
// leaving the resolution code below it intact, so every one of them exited 2
|
|
16
|
+
// with empty stderr — caught only indirectly, two suites away.
|
|
17
|
+
const YIELD_SHAPED_PARAMS = [
|
|
18
|
+
{ kind: 'flag', name: 'promote', type: 'bool', required: false, constraint: 'promote too' },
|
|
19
|
+
{ kind: 'stdin', name: 'message', required: true, constraint: 'note to your future self' },
|
|
20
|
+
];
|
|
12
21
|
const PROFILE_SET_SHAPED_PARAMS = [
|
|
13
22
|
{ kind: 'positional', name: 'profile', required: true, constraint: 'profile id' },
|
|
14
23
|
{ kind: 'flag', name: 'name', type: 'string', required: true, constraint: 'var name' },
|
|
15
24
|
{ kind: 'stdin', name: 'value', required: true, constraint: 'value piped on stdin' },
|
|
16
25
|
];
|
|
26
|
+
test('a lone positional supplies the stdin body on a leaf declaring no positional param', async () => {
|
|
27
|
+
const result = await parseArgv(YIELD_SHAPED_PARAMS, ['refresh against the original goal']);
|
|
28
|
+
assert.equal(result['message'], 'refresh against the original goal');
|
|
29
|
+
});
|
|
30
|
+
test('flags parse alongside the positional that supplies the stdin body', async () => {
|
|
31
|
+
const result = await parseArgv(YIELD_SHAPED_PARAMS, ['--promote', 'coordinate the fan-out']);
|
|
32
|
+
assert.equal(result['message'], 'coordinate the fan-out');
|
|
33
|
+
assert.equal(result['promote'], true);
|
|
34
|
+
});
|
|
35
|
+
test('allowPositional:false keeps a stdin-only leaf rejecting positionals by arity', async () => {
|
|
36
|
+
const params = [
|
|
37
|
+
{ kind: 'stdin', name: 'document', required: false, allowPositional: false, constraint: 'page source on stdin' },
|
|
38
|
+
];
|
|
39
|
+
await assert.rejects(() => parseArgv(params, ['page.tsx']), (err) => {
|
|
40
|
+
assert.equal(err.code, 'bad_invocation');
|
|
41
|
+
assert.match(err.message, /takes no positional arguments/);
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
});
|
|
17
45
|
test('an extra positional on a stdin-bearing leaf is rejected without echoing it', async () => {
|
|
18
46
|
const secret = 'SUPERSECRET_ARGV';
|
|
19
47
|
await assert.rejects(() => parseArgv(PROFILE_SET_SHAPED_PARAMS, ['some-profile', secret, '--name', 'REVIEW_TOKEN']), (err) => {
|
|
@@ -127,9 +127,12 @@ test('an already idle resident with no live obligation reconciles into completed
|
|
|
127
127
|
intent: 'idle-release',
|
|
128
128
|
pi_session_id: 'parked-session',
|
|
129
129
|
});
|
|
130
|
+
subscribe(root, nodeId, true);
|
|
130
131
|
await h.tick();
|
|
131
132
|
assert.equal(h.node(nodeId)?.status, 'done', 'an older idle row becomes pruning-eligible without needing a broker revive');
|
|
132
133
|
assert.equal(h.node(nodeId)?.intent, 'done', 'the reconciled row records clean completion');
|
|
134
|
+
const wake = readInboxSince(root).find((entry) => entry.from === nodeId && entry.data?.['reason'] === 'child-auto-done');
|
|
135
|
+
assert.ok(wake, 'the subscribing root learns that the parked resident completed without a final report');
|
|
133
136
|
});
|
|
134
137
|
test('an unattended resident with no live obligation completes on the daemon clock', { timeout: 30_000 }, async () => {
|
|
135
138
|
// The park clock is read per supervision pass (unattendedParkMsForDaemon), so
|
|
@@ -153,6 +156,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
|
|
|
153
156
|
env: {},
|
|
154
157
|
},
|
|
155
158
|
});
|
|
159
|
+
subscribe(root, nodeId, true);
|
|
156
160
|
reviveNode(nodeId, { resume: true });
|
|
157
161
|
const boot = await h.awaitBoot(nodeId);
|
|
158
162
|
assert.equal(h.fleet.has(nodeId), true, 'the revived broker is fleet-owned');
|
|
@@ -170,6 +174,9 @@ test('an unattended resident with no live obligation completes on the daemon clo
|
|
|
170
174
|
await h.awaitFleetExit(nodeId);
|
|
171
175
|
assert.equal(h.fleet.has(nodeId), false, 'exit policy forgets the completed broker');
|
|
172
176
|
assert.equal(h.node(nodeId)?.status, 'done', 'the row is terminal and eligible for history pruning');
|
|
177
|
+
const wake = readInboxSince(root).find((entry) => entry.from === nodeId && entry.data?.['reason'] === 'child-auto-done');
|
|
178
|
+
assert.ok(wake, 'the subscribing root is woken when the unattended clock completes the resident');
|
|
179
|
+
assert.match(wake.label, /sent no final report/, 'the wake distinguishes automatic completion from a final report');
|
|
173
180
|
await h.tick();
|
|
174
181
|
assert.equal(h.fleet.has(nodeId), false, 'a supervision tick does not revive completed history');
|
|
175
182
|
}
|
package/dist/core/command.js
CHANGED
|
@@ -301,7 +301,7 @@ export async function parseArgv(params, tokens, options) {
|
|
|
301
301
|
const provided = new Set(); // Track explicitly-provided parameter names
|
|
302
302
|
const contextFilePaths = new Map();
|
|
303
303
|
// Index params by kind for quick lookup
|
|
304
|
-
const
|
|
304
|
+
const positionalParams = params.filter((p) => p.kind === 'positional');
|
|
305
305
|
const stdinParam = params.find((p) => p.kind === 'stdin');
|
|
306
306
|
const contextFileParam = params.find((p) => p.kind === 'context-file');
|
|
307
307
|
const flagParams = params.filter((p) => p.kind === 'flag');
|
|
@@ -316,7 +316,7 @@ export async function parseArgv(params, tokens, options) {
|
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
318
|
const positionalValues = [];
|
|
319
|
-
|
|
319
|
+
let positionalIndex = 0;
|
|
320
320
|
let positionalWasSupplied = false; // Track if positional came from argv (not stdin)
|
|
321
321
|
let forcedPositional = false; // true after bare --
|
|
322
322
|
let i = 0;
|
|
@@ -419,46 +419,50 @@ export async function parseArgv(params, tokens, options) {
|
|
|
419
419
|
continue;
|
|
420
420
|
}
|
|
421
421
|
// Positional (or token after --)
|
|
422
|
-
|
|
422
|
+
const positionalParam = positionalParams[positionalIndex];
|
|
423
|
+
if (positionalParam !== undefined) {
|
|
424
|
+
const key = flagNameToKey(positionalParam.name);
|
|
425
|
+
if (positionalParam.repeatable === true) {
|
|
426
|
+
const values = result[key] ?? [];
|
|
427
|
+
values.push(token);
|
|
428
|
+
result[key] = values;
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
result[key] = token;
|
|
432
|
+
positionalIndex++;
|
|
433
|
+
}
|
|
423
434
|
positionalValues.push(token);
|
|
424
435
|
positionalWasSupplied = true;
|
|
436
|
+
provided.add(key);
|
|
425
437
|
i++;
|
|
426
438
|
continue;
|
|
427
439
|
}
|
|
428
440
|
if (positionalValues.length > 0) {
|
|
429
441
|
// A leaf that also declares a `stdin` param treats its stdin value as
|
|
430
442
|
// write-only/sensitive by convention (`profile env set`'s value, etc.).
|
|
431
|
-
// An unexpected extra positional
|
|
432
|
-
//
|
|
433
|
-
// of piping it — so never echo the token or the full argv back; a
|
|
434
|
-
// sensitive value must not be disclosed by the very error that rejects
|
|
435
|
-
// the mistake.
|
|
443
|
+
// An unexpected extra positional is the likely secret-as-argv mistake, so
|
|
444
|
+
// never echo it back in the rejection.
|
|
436
445
|
if (stdinParam !== undefined) {
|
|
437
446
|
throw parseArgvError('bad_invocation', `unexpected extra positional argument (value withheld: this leaf reads "${stdinParam.name}" from stdin, never argv)`, undefined, undefined, `Pipe it on stdin instead of passing it as a positional argument.`);
|
|
438
447
|
}
|
|
439
|
-
throw parseArgvError('bad_invocation', `unexpected extra positional argument: ${token}`, tokens.join(' '), undefined, 'Use
|
|
448
|
+
throw parseArgvError('bad_invocation', `unexpected extra positional argument: ${token}`, tokens.join(' '), undefined, 'Use declared flags or positional parameters. Run -h for the schema.');
|
|
440
449
|
}
|
|
441
|
-
// A
|
|
442
|
-
//
|
|
443
|
-
|
|
450
|
+
// A leaf declaring no positional param may still take one bare token as its
|
|
451
|
+
// stdin body (`crtr node yield "note"`); the resolution below turns it into
|
|
452
|
+
// the stdin value. `allowPositional: false` opts out.
|
|
453
|
+
if (positionalParams.length === 0 && (stdinParam === undefined || stdinParam.allowPositional === false)) {
|
|
444
454
|
throw parseArgvError('bad_invocation', `this leaf takes no positional arguments: ${token}`, token, undefined, 'Use --flag for parameters. Run -h for the schema.');
|
|
445
455
|
}
|
|
446
456
|
positionalValues.push(token);
|
|
447
457
|
positionalWasSupplied = true;
|
|
448
458
|
i++;
|
|
449
459
|
}
|
|
450
|
-
// Assign positional (already tracked in provided set above)
|
|
451
|
-
if (positionalValues.length > 0 && positionalParam !== undefined) {
|
|
452
|
-
result[flagNameToKey(positionalParam.name)] = repeatablePositional
|
|
453
|
-
? positionalValues
|
|
454
|
-
: positionalValues[0];
|
|
455
|
-
}
|
|
456
460
|
// Resolve stdin if declared. Some leaves permit one positional token to
|
|
457
461
|
// supply the stdin body; `-` then means read stdin. A positional alongside
|
|
458
462
|
// genuinely piped, non-empty stdin is ambiguous and errors loudly.
|
|
459
463
|
let stdinWasSupplied = false;
|
|
460
464
|
if (stdinParam !== undefined) {
|
|
461
|
-
if (positionalValues.length > 0 &&
|
|
465
|
+
if (positionalValues.length > 0 && positionalParams.length === 0 && stdinParam.allowPositional !== false) {
|
|
462
466
|
const positionalValue = positionalValues[0];
|
|
463
467
|
if (positionalValue === '-') {
|
|
464
468
|
const raw = await readStdinRaw();
|
|
@@ -505,10 +509,6 @@ export async function parseArgv(params, tokens, options) {
|
|
|
505
509
|
stdinWasSupplied = !stdinIsTTY || positionalWasSupplied;
|
|
506
510
|
}
|
|
507
511
|
}
|
|
508
|
-
// Track provided positional param
|
|
509
|
-
if (positionalWasSupplied && positionalParam !== undefined) {
|
|
510
|
-
provided.add(flagNameToKey(positionalParam.name));
|
|
511
|
-
}
|
|
512
512
|
// Track provided stdin param
|
|
513
513
|
if (stdinWasSupplied && stdinParam !== undefined) {
|
|
514
514
|
provided.add(flagNameToKey(stdinParam.name));
|
package/dist/core/config.d.ts
CHANGED
|
@@ -141,6 +141,11 @@ export declare function readMergedLaunchConfig(targetCwd?: string, targetProfile
|
|
|
141
141
|
* existence/launch-menu enumeration is a caller concern); this only
|
|
142
142
|
* resolves the config for a kind the caller already knows about. */
|
|
143
143
|
export declare function resolveKindConfig(kind: string): KindConfig | undefined;
|
|
144
|
+
/** Reject a kind no scope registers. The one validation gate for every
|
|
145
|
+
* surface that PERSISTS a kind (`profile new/kind`, the profile-ensure API
|
|
146
|
+
* route) — a stored unknown kind would poison future omitted-kind creates
|
|
147
|
+
* through `default_kind` resolution. */
|
|
148
|
+
export declare function assertInstalledKind(kind: string): void;
|
|
144
149
|
/** The sub-kinds available to spawn FROM a given top-level kind — every
|
|
145
150
|
* registered sub-kind (full path contains `/`) whose `availableTo` (default:
|
|
146
151
|
* its own top-level ancestor, e.g. `plan/reviewers/security` defaults to
|
package/dist/core/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { dirname, join } from 'node:path';
|
|
|
2
2
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
3
|
import { CONFIG_FILE, STATE_FILE, CONDENSED_HISTORY_MODES, MOUSE_MODE_DEFAULTS, WHIP_MESSAGE_MODES, DEFAULT_WHIP_MESSAGES, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
|
|
4
4
|
import { BINDING_CATALOG, BINDING_IDS, isAttachPaneBinding } from './keybindings/catalog.js';
|
|
5
|
+
import { usage } from './errors.js';
|
|
5
6
|
import { emitEvent } from './events/emit.js';
|
|
6
7
|
import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
|
|
7
8
|
import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
|
|
@@ -753,6 +754,20 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
|
|
|
753
754
|
export function resolveKindConfig(kind) {
|
|
754
755
|
return readMergedLaunchConfig().kinds[kind];
|
|
755
756
|
}
|
|
757
|
+
/** Reject a kind no scope registers. The one validation gate for every
|
|
758
|
+
* surface that PERSISTS a kind (`profile new/kind`, the profile-ensure API
|
|
759
|
+
* route) — a stored unknown kind would poison future omitted-kind creates
|
|
760
|
+
* through `default_kind` resolution. */
|
|
761
|
+
export function assertInstalledKind(kind) {
|
|
762
|
+
const kinds = Object.keys(readMergedLaunchConfig().kinds).sort();
|
|
763
|
+
if (!kinds.includes(kind)) {
|
|
764
|
+
throw usage(`unknown kind: ${kind}`, {
|
|
765
|
+
received: kind,
|
|
766
|
+
field: 'kind',
|
|
767
|
+
next: `Valid kinds: ${kinds.join(', ')}.`,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
756
771
|
/** The sub-kinds available to spawn FROM a given top-level kind — every
|
|
757
772
|
* registered sub-kind (full path contains `/`) whose `availableTo` (default:
|
|
758
773
|
* its own top-level ancestor, e.g. `plan/reviewers/security` defaults to
|
package/dist/core/help.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export interface Field {
|
|
|
6
6
|
* token caps. Lives here, never in a separate Preconditions section. */
|
|
7
7
|
constraint: string;
|
|
8
8
|
}
|
|
9
|
-
/** Positional argument
|
|
9
|
+
/** Positional argument. A repeatable positional consumes every remaining positional token. */
|
|
10
10
|
export interface PositionalParam {
|
|
11
11
|
kind: 'positional';
|
|
12
12
|
name: string;
|
|
@@ -13,6 +13,9 @@ export declare const ATTACH_CONTROL_BINDINGS: readonly [{
|
|
|
13
13
|
}, {
|
|
14
14
|
readonly menuId: "crtr.tmux.menu.attach.inbox.toggle";
|
|
15
15
|
readonly actionId: "crtr.attach.inbox.toggle";
|
|
16
|
+
}, {
|
|
17
|
+
readonly menuId: "crtr.tmux.menu.attach.inbox-strip";
|
|
18
|
+
readonly actionId: "crtr.attach.inbox-strip";
|
|
16
19
|
}, {
|
|
17
20
|
readonly menuId: "crtr.tmux.menu.attach.model-ladder.next";
|
|
18
21
|
readonly actionId: "crtr.attach.model-ladder.next";
|
|
@@ -4,6 +4,7 @@ export const ATTACH_CONTROL_BINDINGS = [
|
|
|
4
4
|
{ menuId: 'crtr.tmux.menu.attach.graph.toggle', actionId: 'crtr.attach.graph.toggle' },
|
|
5
5
|
{ menuId: 'crtr.tmux.menu.attach.help.toggle', actionId: 'crtr.attach.help.toggle' },
|
|
6
6
|
{ menuId: 'crtr.tmux.menu.attach.inbox.toggle', actionId: 'crtr.attach.inbox.toggle' },
|
|
7
|
+
{ menuId: 'crtr.tmux.menu.attach.inbox-strip', actionId: 'crtr.attach.inbox-strip' },
|
|
7
8
|
{ menuId: 'crtr.tmux.menu.attach.model-ladder.next', actionId: 'crtr.attach.model-ladder.next' },
|
|
8
9
|
{ menuId: 'crtr.tmux.menu.attach.model-ladder.previous', actionId: 'crtr.attach.model-ladder.previous' },
|
|
9
10
|
{ menuId: 'crtr.tmux.menu.attach.command.inspect', actionId: 'crtr.attach.command.inspect' },
|
|
@@ -72,6 +72,7 @@ export const BINDING_CATALOG = Object.freeze([
|
|
|
72
72
|
...attachEntry('crtr.tmux.menu.attach.graph.toggle', 'crtr.attach.graph.toggle', 'Toggle graph', 'Open or close the graph overlay.', ['g'], ['alt+g']),
|
|
73
73
|
...attachEntry('crtr.tmux.menu.attach.help.toggle', 'crtr.attach.help.toggle', 'Help', 'Open the attach help overlay.', ['k'], ['alt+shift+k']),
|
|
74
74
|
...attachEntry('crtr.tmux.menu.attach.inbox.toggle', 'crtr.attach.inbox.toggle', 'Toggle inbox', 'Open or close the human inbox surface in this viewer.', ['i'], []),
|
|
75
|
+
...attachEntry('crtr.tmux.menu.attach.inbox-strip', 'crtr.attach.inbox-strip', 'Pending requests', 'Jump to the pending human-request strip at the top of the pane.', ['u'], ['alt+u']),
|
|
75
76
|
...attachEntry('crtr.tmux.menu.attach.model-ladder.next', 'crtr.attach.model-ladder.next', 'Next model', 'Move to the next model ladder rung.', [']'], ['alt+m']),
|
|
76
77
|
...attachEntry('crtr.tmux.menu.attach.model-ladder.previous', 'crtr.attach.model-ladder.previous', 'Previous model', 'Move to the previous model ladder rung.', ['['], ['alt+shift+m']),
|
|
77
78
|
...attachEntry('crtr.tmux.menu.attach.command.inspect', 'crtr.attach.command.inspect', 'Inspect loaded command', 'Open the loaded slash command’s expanded prompt.', ['h'], ['alt+shift+h']),
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* The caller must release the returned lease after completion or failure. */
|
|
3
3
|
export declare function reserveProfileDeletion(profileId: string): () => void;
|
|
4
4
|
/** The synchronous birth gate. spawnNode calls this immediately before the
|
|
5
|
-
* canvas row is persisted
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* canvas row is persisted, so EVERY birth passes here — the API create, forks,
|
|
6
|
+
* cron spawns, warm-pool mints, `/new` relaunch, and recycle alike. A
|
|
7
|
+
* reservation rejects a birth that reaches the gate during deletion; the exact
|
|
8
|
+
* manifest read rejects one whose async preflight started before a fast hard
|
|
9
|
+
* deletion and resumed after its lease was released; a paused profile is inert
|
|
10
|
+
* and creates nothing. `POST /v1/nodes` gates earlier only to phrase the paused
|
|
11
|
+
* refusal against the operand the caller actually typed. */
|
|
8
12
|
export declare function assertProfileAvailableForBirth(profileId: string | null): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { usage } from '../errors.js';
|
|
2
|
-
import { loadExactProfileManifest } from './manifest.js';
|
|
2
|
+
import { assertProfileEntryActive, loadExactProfileManifest } from './manifest.js';
|
|
3
3
|
const deletingProfileIds = new Set();
|
|
4
4
|
/** Reserve one profile identity for its complete daemon-owned deletion lifecycle.
|
|
5
5
|
* The caller must release the returned lease after completion or failure. */
|
|
@@ -21,9 +21,13 @@ export function reserveProfileDeletion(profileId) {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
/** The synchronous birth gate. spawnNode calls this immediately before the
|
|
24
|
-
* canvas row is persisted
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* canvas row is persisted, so EVERY birth passes here — the API create, forks,
|
|
25
|
+
* cron spawns, warm-pool mints, `/new` relaunch, and recycle alike. A
|
|
26
|
+
* reservation rejects a birth that reaches the gate during deletion; the exact
|
|
27
|
+
* manifest read rejects one whose async preflight started before a fast hard
|
|
28
|
+
* deletion and resumed after its lease was released; a paused profile is inert
|
|
29
|
+
* and creates nothing. `POST /v1/nodes` gates earlier only to phrase the paused
|
|
30
|
+
* refusal against the operand the caller actually typed. */
|
|
27
31
|
export function assertProfileAvailableForBirth(profileId) {
|
|
28
32
|
if (profileId === null)
|
|
29
33
|
return;
|
|
@@ -34,5 +38,5 @@ export function assertProfileAvailableForBirth(profileId) {
|
|
|
34
38
|
next: 'Choose another profile or wait for this deletion to finish.',
|
|
35
39
|
});
|
|
36
40
|
}
|
|
37
|
-
loadExactProfileManifest(profileId);
|
|
41
|
+
assertProfileEntryActive(loadExactProfileManifest(profileId));
|
|
38
42
|
}
|
|
@@ -39,8 +39,46 @@ export declare function loadProfileManifest(profileIdOrName: string): ProfileEnt
|
|
|
39
39
|
* below run inside this. */
|
|
40
40
|
export declare function withProfileManifestLock<T>(profileId: string, fn: () => T): T;
|
|
41
41
|
export declare function ensureRootProfile(): ProfileEntry;
|
|
42
|
-
|
|
42
|
+
/** The env name a metadata entry surfaces as (spawn-env source F). Also the
|
|
43
|
+
* uniqueness domain for keys: the fold is lossy (`a-b`, `a_b`, `A_B` →
|
|
44
|
+
* `CRTR_PROFILE_META_A_B`), so two stored keys may not share a fold. */
|
|
45
|
+
export declare function metadataEnvName(key: string): string;
|
|
46
|
+
/** Reject anything a valid metadata map may not carry. Takes `unknown`
|
|
47
|
+
* because the API route hands over a caller-supplied body slice — the
|
|
48
|
+
* TypeScript type on the DTO proves nothing at runtime. */
|
|
49
|
+
export declare function assertProfileMetadata(entries: unknown): asserts entries is Record<string, string>;
|
|
50
|
+
/** Drop entries no valid mutation could have written — a manifest is
|
|
51
|
+
* hand-editable, so a projection or merge base must never trust it raw.
|
|
52
|
+
* Never throws; on an env-name collision the first entry wins (matching
|
|
53
|
+
* injection, where a deterministic winner beats insertion-order luck). */
|
|
54
|
+
export declare function sanitizeProfileMetadata(stored: unknown): Record<string, string>;
|
|
55
|
+
export declare function createProfile(name: string, projects?: string[], opts?: {
|
|
56
|
+
defaultKind?: string;
|
|
57
|
+
metadata?: Record<string, string>;
|
|
58
|
+
}): ProfileEntry;
|
|
43
59
|
export declare function updateProfileLastUsed(profileId: string): ProfileEntry;
|
|
60
|
+
/** Paused check for an ALREADY-RESOLVED durable id (a canvas row's `profile_id`,
|
|
61
|
+
* `CRTR_PROFILE_ID`) — exact-id only, no name matching, so it costs one small
|
|
62
|
+
* read on the hot delivery paths that call it every poll. Fails OPEN: an id
|
|
63
|
+
* whose manifest is missing or corrupt is not paused, because treating it as
|
|
64
|
+
* paused would strand that node's queued inbox forever. */
|
|
65
|
+
export declare function isProfilePaused(profileId: string | null | undefined): boolean;
|
|
66
|
+
/** Refuse a launch under a paused profile, naming the resume command. Shared by
|
|
67
|
+
* every gate that already holds a loaded manifest. */
|
|
68
|
+
export declare function assertProfileEntryActive(entry: ProfileEntry): void;
|
|
69
|
+
export declare function assertProfileActive(profileId: string | null | undefined): ProfileEntry | null;
|
|
70
|
+
export declare function pauseProfile(profileId: string): ProfileEntry;
|
|
71
|
+
export declare function resumeProfile(profileId: string): ProfileEntry;
|
|
72
|
+
export declare function setProfileDefaultKind(profileId: string, defaultKind: string): ProfileEntry;
|
|
73
|
+
/** Merge `set` entries over the stored map and drop `unset` keys; the
|
|
74
|
+
* `metadata` field is omitted entirely when the result is empty. */
|
|
75
|
+
export declare function updateProfileMetadata(profileId: string, set: Record<string, string>, unset?: string[]): ProfileEntry;
|
|
76
|
+
/** A profile's stored metadata for broker-env injection — sanitized entry by
|
|
77
|
+
* entry (a manifest is hand-editable, so a bad key/value is dropped rather
|
|
78
|
+
* than trusted) and never throwing: a stale/invalid/absent profile id is a
|
|
79
|
+
* hot-path no-op, because broker-env resolution must never fail a launch
|
|
80
|
+
* over a bad profile id (mirrors `readProfileEnvVars`). */
|
|
81
|
+
export declare function readProfileMetadata(profileId: string | null): Record<string, string>;
|
|
44
82
|
export declare function renameProfile(profileId: string, name: string): ProfileEntry;
|
|
45
83
|
export declare function addProfileProject(profileId: string, dir: string, opts?: {
|
|
46
84
|
home?: boolean;
|
|
@@ -84,9 +84,9 @@ export function profileMemoryDir(profileId) {
|
|
|
84
84
|
* the user started them, not in whatever dir was last added to its purview. */
|
|
85
85
|
function normalizeHome(profileId, manifest) {
|
|
86
86
|
const stored = typeof manifest.home === 'string' && manifest.home !== '' ? manifest.home : null;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return { ...manifest, home:
|
|
87
|
+
const home = profileId === ROOT_PROFILE_ID ? stored : stored ?? manifest.projects[0] ?? null;
|
|
88
|
+
const pausedAt = typeof manifest.paused_at === 'string' && manifest.paused_at !== '' ? manifest.paused_at : null;
|
|
89
|
+
return { ...manifest, home, paused_at: pausedAt };
|
|
90
90
|
}
|
|
91
91
|
function readManifestFile(path, profileId) {
|
|
92
92
|
if (!existsSync(path))
|
|
@@ -282,6 +282,7 @@ export function ensureRootProfile() {
|
|
|
282
282
|
name: ROOT_PROFILE_NAME,
|
|
283
283
|
projects: [],
|
|
284
284
|
home: null,
|
|
285
|
+
paused_at: null,
|
|
285
286
|
created_at: nowIso(),
|
|
286
287
|
last_used_at: nowIso(),
|
|
287
288
|
};
|
|
@@ -290,10 +291,89 @@ export function ensureRootProfile() {
|
|
|
290
291
|
return { profileId: ROOT_PROFILE_ID, manifest: next };
|
|
291
292
|
});
|
|
292
293
|
}
|
|
293
|
-
|
|
294
|
+
/** Key shape every stored metadata entry must satisfy — kept env-mappable
|
|
295
|
+
* (`CRTR_PROFILE_META_<KEY>`, see `core/runtime/spawn-env.ts`) by
|
|
296
|
+
* construction. */
|
|
297
|
+
const METADATA_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
298
|
+
/** The env name a metadata entry surfaces as (spawn-env source F). Also the
|
|
299
|
+
* uniqueness domain for keys: the fold is lossy (`a-b`, `a_b`, `A_B` →
|
|
300
|
+
* `CRTR_PROFILE_META_A_B`), so two stored keys may not share a fold. */
|
|
301
|
+
export function metadataEnvName(key) {
|
|
302
|
+
return `CRTR_PROFILE_META_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
|
|
303
|
+
}
|
|
304
|
+
/** Reject anything a valid metadata map may not carry. Takes `unknown`
|
|
305
|
+
* because the API route hands over a caller-supplied body slice — the
|
|
306
|
+
* TypeScript type on the DTO proves nothing at runtime. */
|
|
307
|
+
export function assertProfileMetadata(entries) {
|
|
308
|
+
if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) {
|
|
309
|
+
throw usage('metadata must be a map of string keys to string values', {
|
|
310
|
+
field: 'metadata',
|
|
311
|
+
next: 'Pass an object, e.g. {"role": "support"}.',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const claimed = new Map();
|
|
315
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
316
|
+
if (!METADATA_KEY_SHAPE.test(key)) {
|
|
317
|
+
throw usage(`invalid metadata key: ${JSON.stringify(key)}`, {
|
|
318
|
+
received: key,
|
|
319
|
+
field: 'metadata',
|
|
320
|
+
next: 'Keys start with a letter or digit, followed by letters, digits, `_`, or `-`.',
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (typeof value !== 'string') {
|
|
324
|
+
throw usage(`metadata value for ${key} must be a string`, {
|
|
325
|
+
received: typeof value,
|
|
326
|
+
field: 'metadata',
|
|
327
|
+
next: 'Every metadata value is an env-var value — pass strings only.',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (value.includes('\0')) {
|
|
331
|
+
throw usage(`metadata value for ${key} contains a NUL byte, which no OS accepts in an environment variable`, {
|
|
332
|
+
field: 'metadata',
|
|
333
|
+
next: 'Remove NUL bytes from the value and retry.',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const envName = metadataEnvName(key);
|
|
337
|
+
const prior = claimed.get(envName);
|
|
338
|
+
if (prior !== undefined) {
|
|
339
|
+
throw usage(`metadata keys ${JSON.stringify(prior)} and ${JSON.stringify(key)} both surface as ${envName}`, {
|
|
340
|
+
field: 'metadata',
|
|
341
|
+
next: 'Rename or drop one — keys must stay distinct after uppercasing and `_`-folding.',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
claimed.set(envName, key);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** Drop entries no valid mutation could have written — a manifest is
|
|
348
|
+
* hand-editable, so a projection or merge base must never trust it raw.
|
|
349
|
+
* Never throws; on an env-name collision the first entry wins (matching
|
|
350
|
+
* injection, where a deterministic winner beats insertion-order luck). */
|
|
351
|
+
export function sanitizeProfileMetadata(stored) {
|
|
352
|
+
if (typeof stored !== 'object' || stored === null || Array.isArray(stored))
|
|
353
|
+
return {};
|
|
354
|
+
const out = {};
|
|
355
|
+
const claimed = new Set();
|
|
356
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
357
|
+
if (typeof value !== 'string')
|
|
358
|
+
continue;
|
|
359
|
+
if (!METADATA_KEY_SHAPE.test(key))
|
|
360
|
+
continue;
|
|
361
|
+
if (value.includes('\0'))
|
|
362
|
+
continue;
|
|
363
|
+
const envName = metadataEnvName(key);
|
|
364
|
+
if (claimed.has(envName))
|
|
365
|
+
continue;
|
|
366
|
+
claimed.add(envName);
|
|
367
|
+
out[key] = value;
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
export function createProfile(name, projects = [], opts = {}) {
|
|
294
372
|
const trimmed = name.trim();
|
|
295
373
|
if (trimmed === '')
|
|
296
374
|
throw usage('profile name must not be empty');
|
|
375
|
+
if (opts.metadata !== undefined)
|
|
376
|
+
assertProfileMetadata(opts.metadata);
|
|
297
377
|
const resolvedProjects = dedupeOrdered(projects.map(resolveExistingProjectDir));
|
|
298
378
|
const profileId = generateProfileId(trimmed);
|
|
299
379
|
return withProfileManifestLock(profileId, () => {
|
|
@@ -302,6 +382,9 @@ export function createProfile(name, projects = []) {
|
|
|
302
382
|
name: trimmed,
|
|
303
383
|
projects: resolvedProjects,
|
|
304
384
|
home: resolvedProjects[0] ?? null,
|
|
385
|
+
paused_at: null,
|
|
386
|
+
...(opts.defaultKind !== undefined ? { default_kind: opts.defaultKind } : {}),
|
|
387
|
+
...(opts.metadata !== undefined && Object.keys(opts.metadata).length > 0 ? { metadata: { ...opts.metadata } } : {}),
|
|
305
388
|
created_at: nowIso(),
|
|
306
389
|
last_used_at: null,
|
|
307
390
|
};
|
|
@@ -335,6 +418,90 @@ function mutateManifest(profileId, mutate) {
|
|
|
335
418
|
export function updateProfileLastUsed(profileId) {
|
|
336
419
|
return mutateManifest(profileId, (m) => ({ ...m, last_used_at: nowIso() }));
|
|
337
420
|
}
|
|
421
|
+
/** Resolve an existing profile for a launch gate without making stale durable
|
|
422
|
+
* profile ids a new failure mode on a hot path. */
|
|
423
|
+
function loadProfileManifestOrNull(profileId) {
|
|
424
|
+
if (profileId === null || profileId === undefined || profileId === '')
|
|
425
|
+
return null;
|
|
426
|
+
try {
|
|
427
|
+
return loadProfileManifest(profileId);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/** Paused check for an ALREADY-RESOLVED durable id (a canvas row's `profile_id`,
|
|
434
|
+
* `CRTR_PROFILE_ID`) — exact-id only, no name matching, so it costs one small
|
|
435
|
+
* read on the hot delivery paths that call it every poll. Fails OPEN: an id
|
|
436
|
+
* whose manifest is missing or corrupt is not paused, because treating it as
|
|
437
|
+
* paused would strand that node's queued inbox forever. */
|
|
438
|
+
export function isProfilePaused(profileId) {
|
|
439
|
+
if (profileId === null || profileId === undefined || profileId === '')
|
|
440
|
+
return false;
|
|
441
|
+
try {
|
|
442
|
+
return loadExactProfileManifest(profileId).manifest.paused_at !== null;
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** Refuse a launch under a paused profile, naming the resume command. Shared by
|
|
449
|
+
* every gate that already holds a loaded manifest. */
|
|
450
|
+
export function assertProfileEntryActive(entry) {
|
|
451
|
+
if (entry.manifest.paused_at === null)
|
|
452
|
+
return;
|
|
453
|
+
throw usage(`profile "${entry.manifest.name}" is paused and cannot start nodes`, {
|
|
454
|
+
received: entry.profileId,
|
|
455
|
+
next: `Resume it with \`crtr profile resume ${entry.profileId}\`.`,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
export function assertProfileActive(profileId) {
|
|
459
|
+
const entry = loadProfileManifestOrNull(profileId);
|
|
460
|
+
if (entry === null)
|
|
461
|
+
return null;
|
|
462
|
+
assertProfileEntryActive(entry);
|
|
463
|
+
return entry;
|
|
464
|
+
}
|
|
465
|
+
export function pauseProfile(profileId) {
|
|
466
|
+
return mutateManifest(profileId, (m) => ({ ...m, paused_at: m.paused_at ?? nowIso() }));
|
|
467
|
+
}
|
|
468
|
+
export function resumeProfile(profileId) {
|
|
469
|
+
return mutateManifest(profileId, (m) => ({ ...m, paused_at: null }));
|
|
470
|
+
}
|
|
471
|
+
export function setProfileDefaultKind(profileId, defaultKind) {
|
|
472
|
+
return mutateManifest(profileId, (m) => ({ ...m, default_kind: defaultKind }));
|
|
473
|
+
}
|
|
474
|
+
/** Merge `set` entries over the stored map and drop `unset` keys; the
|
|
475
|
+
* `metadata` field is omitted entirely when the result is empty. */
|
|
476
|
+
export function updateProfileMetadata(profileId, set, unset = []) {
|
|
477
|
+
assertProfileMetadata(set);
|
|
478
|
+
return mutateManifest(profileId, (m) => {
|
|
479
|
+
const next = { ...sanitizeProfileMetadata(m.metadata), ...set };
|
|
480
|
+
for (const key of unset)
|
|
481
|
+
delete next[key];
|
|
482
|
+
// Re-assert the merged result: a set key may collide with a STORED key in
|
|
483
|
+
// the env-name domain (`a-b` over stored `a_b`), which per-argument
|
|
484
|
+
// validation cannot see.
|
|
485
|
+
assertProfileMetadata(next);
|
|
486
|
+
const { metadata: _dropped, ...rest } = m;
|
|
487
|
+
return { ...rest, ...(Object.keys(next).length > 0 ? { metadata: next } : {}) };
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
/** A profile's stored metadata for broker-env injection — sanitized entry by
|
|
491
|
+
* entry (a manifest is hand-editable, so a bad key/value is dropped rather
|
|
492
|
+
* than trusted) and never throwing: a stale/invalid/absent profile id is a
|
|
493
|
+
* hot-path no-op, because broker-env resolution must never fail a launch
|
|
494
|
+
* over a bad profile id (mirrors `readProfileEnvVars`). */
|
|
495
|
+
export function readProfileMetadata(profileId) {
|
|
496
|
+
if (profileId === null || profileId === '')
|
|
497
|
+
return {};
|
|
498
|
+
try {
|
|
499
|
+
return sanitizeProfileMetadata(loadExactProfileManifest(profileId).manifest.metadata);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
return {};
|
|
503
|
+
}
|
|
504
|
+
}
|
|
338
505
|
export function renameProfile(profileId, name) {
|
|
339
506
|
const trimmed = name.trim();
|
|
340
507
|
if (trimmed === '')
|
|
@@ -30,7 +30,7 @@ export async function bootRoot(opts) {
|
|
|
30
30
|
if (!inTmux()) {
|
|
31
31
|
throw new Error('crtr must be started from inside a tmux session — start tmux first (e.g. `tmux new -s work`), then run `crtr` there.');
|
|
32
32
|
}
|
|
33
|
-
const kind = opts.kind
|
|
33
|
+
const kind = opts.kind;
|
|
34
34
|
// The front door's only source of profile identity — a root has no spawner to
|
|
35
35
|
// inherit from. Runs BEFORE the create call: explicit --profile > MRU profile
|
|
36
36
|
// covering cwd > a synchronous create-or-root-profile prompt (the stable root
|
|
@@ -47,12 +47,12 @@ export async function bootRoot(opts) {
|
|
|
47
47
|
// viewer paints the crouton banner the instant the engine attaches (~1s),
|
|
48
48
|
// which is the real "we're up" signal.
|
|
49
49
|
const detail = await cliClient().createNode({
|
|
50
|
-
kind,
|
|
50
|
+
...(kind !== undefined ? { kind } : {}),
|
|
51
51
|
mode: 'base',
|
|
52
52
|
prompt: opts.prompt,
|
|
53
53
|
profile: profileId,
|
|
54
54
|
cwd: opts.cwd,
|
|
55
|
-
name: opts.name
|
|
55
|
+
...(opts.name !== undefined ? { name: opts.name } : kind !== undefined ? { name: kind } : {}),
|
|
56
56
|
root: true,
|
|
57
57
|
// The front door is exactly the wait a warm spare exists to remove: a bare
|
|
58
58
|
// resident root with no kickoff. A miss costs nothing (cold spawn) and
|