@north-light/crouter 0.3.263 → 0.3.270
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/client.d.ts +5 -1
- package/dist/api/client.js +6 -0
- package/dist/api/dto/broker.d.ts +11 -2
- package/dist/api/dto/broker.js +2 -2
- package/dist/api/dto/canvas.d.ts +53 -2
- package/dist/api/dto/nodes.d.ts +10 -2
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
- package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
- package/dist/clients/attach/session/whip-achievements.js +145 -0
- package/dist/clients/attach/slash/dispatch.js +5 -3
- package/dist/clients/attach/viewer.js +607 -606
- package/dist/commands/canvas-history/grep.js +3 -22
- package/dist/commands/canvas-history/read.js +5 -3
- package/dist/commands/canvas-history/search.js +3 -22
- package/dist/commands/canvas-history/shared.d.ts +8 -0
- package/dist/commands/canvas-history/shared.js +64 -1
- package/dist/commands/canvas-history/stats.d.ts +1 -0
- package/dist/commands/canvas-history/stats.js +68 -0
- package/dist/commands/canvas-history.js +4 -3
- package/dist/commands/node/create.js +2 -2
- package/dist/commands/pkg/plugin-inspect.js +2 -2
- package/dist/commands/pkg/plugin-manage.js +3 -3
- package/dist/commands/sys/config.js +19 -2
- package/dist/commands/sys/doctor.js +4 -3
- package/dist/commands/sys/panels/models-panel.js +3 -1
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
- package/dist/core/__tests__/integration/revive.test.js +19 -17
- package/dist/core/__tests__/model-pin-durability.test.js +25 -1
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/relaunch-root.test.js +17 -0
- package/dist/core/__tests__/revive-capacity.test.js +20 -20
- package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
- package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
- package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
- package/dist/core/broker-client/transport-relay.js +5 -1
- package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +1 -1
- package/dist/core/canvas/browse/app.js +13 -10
- package/dist/core/canvas/browse/model.js +16 -1
- package/dist/core/canvas/browse/render.d.ts +23 -3
- package/dist/core/canvas/browse/render.js +429 -64
- package/dist/core/canvas/canvas.js +12 -8
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/canvas/history.d.ts +36 -1
- package/dist/core/canvas/history.js +328 -7
- package/dist/core/canvas/labels.d.ts +2 -2
- package/dist/core/canvas/migrations.js +32 -0
- package/dist/core/canvas/node-recap.d.ts +23 -0
- package/dist/core/canvas/node-recap.js +67 -0
- package/dist/core/canvas/render-source.d.ts +33 -1
- package/dist/core/canvas/render-source.js +80 -22
- package/dist/core/canvas/types.d.ts +9 -0
- package/dist/core/command-hooks/discovery.d.ts +27 -1
- package/dist/core/command-hooks/discovery.js +52 -0
- package/dist/core/command-hooks/index.d.ts +4 -3
- package/dist/core/command-hooks/index.js +2 -1
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
- package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
- package/dist/core/command-hooks/report.d.ts +11 -2
- package/dist/core/command-hooks/report.js +10 -1
- package/dist/core/command-hooks/schema.d.ts +16 -1
- package/dist/core/command-hooks/schema.js +128 -40
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
- package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
- package/dist/core/config.js +13 -5
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/model-routes.js +1 -1
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/auth-reload.js +4 -1
- package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
- package/dist/core/runtime/broker/engine-drive.js +29 -1
- package/dist/core/runtime/broker/fault-retry.js +7 -2
- package/dist/core/runtime/broker/frame-client.js +3 -0
- package/dist/core/runtime/broker/frame-dispatch.js +7 -0
- package/dist/core/runtime/broker/inbox.d.ts +12 -2
- package/dist/core/runtime/broker/inbox.js +68 -38
- package/dist/core/runtime/broker/rebind.js +7 -0
- package/dist/core/runtime/broker-protocol.d.ts +16 -1
- package/dist/core/runtime/broker.d.ts +1 -1
- package/dist/core/runtime/broker.js +27 -3
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/interactive-deliver.d.ts +8 -2
- package/dist/core/runtime/interactive-deliver.js +12 -3
- package/dist/core/runtime/launch-target.js +1 -1
- package/dist/core/runtime/launch.d.ts +12 -6
- package/dist/core/runtime/launch.js +19 -17
- package/dist/core/runtime/model-swap.d.ts +5 -4
- package/dist/core/runtime/model-swap.js +7 -5
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +18 -9
- package/dist/core/runtime/promote.js +2 -1
- package/dist/core/runtime/reset.js +4 -2
- package/dist/core/runtime/revive-all.d.ts +1 -1
- package/dist/core/runtime/revive-all.js +2 -2
- package/dist/core/runtime/revive.d.ts +2 -2
- package/dist/core/runtime/revive.js +71 -13
- package/dist/core/runtime/session-visibility.d.ts +10 -14
- package/dist/core/runtime/session-visibility.js +43 -30
- package/dist/core/runtime/spawn.d.ts +3 -3
- package/dist/core/runtime/spawn.js +20 -3
- package/dist/core/runtime/stamp/channel.d.ts +22 -0
- package/dist/core/runtime/stamp/channel.js +83 -0
- package/dist/core/runtime/stamp/protocol.d.ts +90 -0
- package/dist/core/runtime/stamp/protocol.js +113 -0
- package/dist/core/runtime/tmux-driver.d.ts +11 -1
- package/dist/core/runtime/tmux-driver.js +27 -11
- package/dist/core/runtime/turn-visibility.d.ts +10 -0
- package/dist/core/runtime/turn-visibility.js +31 -0
- package/dist/core/self-update.js +2 -5
- package/dist/core/tui/draw.d.ts +1 -0
- package/dist/core/tui/draw.js +5 -1
- package/dist/core/tui/markdown.d.ts +17 -0
- package/dist/core/tui/markdown.js +407 -0
- package/dist/core/user-settings.d.ts +4 -0
- package/dist/core/user-settings.js +1 -0
- package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/bridge.d.ts +3 -2
- package/dist/daemon/api/bridge.js +34 -9
- package/dist/daemon/api/handlers/attach.js +4 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/canvas.js +62 -16
- package/dist/daemon/api/handlers/messages.js +14 -3
- package/dist/daemon/api/handlers/nodes.js +17 -7
- package/dist/daemon/api/map.js +2 -0
- package/dist/daemon/cron/sinks.js +3 -3
- package/dist/daemon/fleet.js +1 -1
- package/dist/daemon/messaging/node-message.js +16 -2
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/broker-supervision.js +26 -9
- package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
- package/dist/daemon/reconcilers/live-obligation.js +17 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
- package/dist/hook-authoring.d.ts +31 -1
- package/dist/hook-authoring.js +42 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
- package/dist/pi-extensions/canvas-recap.js +4 -0
- package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
- package/dist/pi-extensions/canvas-stamp.js +77 -0
- package/dist/pi-extensions/canvas-stophook.js +3 -0
- package/dist/shared/__tests__/env-boundary.test.js +4 -2
- package/dist/shared/crtr-version.d.ts +2 -0
- package/dist/shared/crtr-version.js +18 -0
- package/dist/shared/env.d.ts +10 -0
- package/dist/shared/env.js +19 -4
- package/dist/shared/generated-context.d.ts +1 -2
- package/dist/shared/generated-context.js +3 -4
- package/dist/types.d.ts +10 -0
- package/dist/types.js +4 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -337,7 +337,13 @@ test("picker and Alt+M choices each clear logical routing, and Alt+M / Shift+Tab
|
|
|
337
337
|
const parent = h.spawnRoot('model durability parent');
|
|
338
338
|
seedPool('anthropic', false);
|
|
339
339
|
seedPool('openai-codex', false);
|
|
340
|
-
const
|
|
340
|
+
const userConfigDir = join(h.agentHome, '.crouter');
|
|
341
|
+
const userConfigPath = join(userConfigDir, 'config.json');
|
|
342
|
+
mkdirSync(userConfigDir, { recursive: true });
|
|
343
|
+
writeFileSync(userConfigPath, JSON.stringify({
|
|
344
|
+
kinds: { 'test/logical-openai': { whenToUse: 'Logical OpenAI routing test.', model: 'openai/strong' } },
|
|
345
|
+
}));
|
|
346
|
+
const pickerNode = await h.spawnHeadlessChild(parent, 'picker durability child', { kind: 'test/logical-openai' });
|
|
341
347
|
const pickerBefore = h.node(pickerNode);
|
|
342
348
|
assert.deepEqual(pickerBefore.launch?.modelIntent, { family: 'openai', strength: 'strong' }, 'picker fixture starts from a logical launch preference');
|
|
343
349
|
assert.equal(pickerBefore.launch?.env.CRTR_MODEL_INTENT, 'openai/strong');
|
|
@@ -358,7 +364,7 @@ test("picker and Alt+M choices each clear logical routing, and Alt+M / Shift+Tab
|
|
|
358
364
|
// A separate logical launch reaches Alt+M with BOTH intent representations
|
|
359
365
|
// still populated. This is the regression path: a user-selected ladder rung
|
|
360
366
|
// must clear them before a restart can resolve the stale route over it.
|
|
361
|
-
const ladderNode = await h.spawnHeadlessChild(parent, 'ladder durability child', {
|
|
367
|
+
const ladderNode = await h.spawnHeadlessChild(parent, 'ladder durability child', { kind: 'test/logical-openai' });
|
|
362
368
|
const ladderBefore = h.node(ladderNode);
|
|
363
369
|
assert.deepEqual(ladderBefore.launch?.modelIntent, { family: 'openai', strength: 'strong' }, 'Alt+M starts with durable logical modelIntent present');
|
|
364
370
|
assert.equal(ladderBefore.launch?.env.CRTR_MODEL_INTENT, 'openai/strong', 'Alt+M starts with the launch environment intent present');
|
|
@@ -388,6 +394,7 @@ test("picker and Alt+M choices each clear logical routing, and Alt+M / Shift+Tab
|
|
|
388
394
|
assert.equal(replay.argv[modelArg + 1], expected, 'the replayed model argument carries both choices');
|
|
389
395
|
assert.equal(replay.env.CRTR_MODEL_INTENT, undefined, 'the stale logical intent is absent from the restarted broker environment');
|
|
390
396
|
ladderCtrl.close();
|
|
397
|
+
rmSync(userConfigPath, { force: true });
|
|
391
398
|
});
|
|
392
399
|
test('reload_auth restores a newly authenticated personal route before the next idle prompt', { timeout: 30_000 }, async () => {
|
|
393
400
|
const personal = { provider: 'personal-anthropic', id: 'claude-personal' };
|
|
@@ -398,6 +405,7 @@ test('reload_auth restores a newly authenticated personal route before the next
|
|
|
398
405
|
const unauthenticatedProvider = join(h.home, 'fake-pi.unauthenticated-provider');
|
|
399
406
|
mkdirSync(userConfigDir, { recursive: true });
|
|
400
407
|
writeFileSync(userConfigPath, JSON.stringify({
|
|
408
|
+
kinds: { general: { whenToUse: 'General work.', model: 'anthropic/medium' } },
|
|
401
409
|
modelRoutes: {
|
|
402
410
|
personal: { family: 'anthropic', credentialSource: 'user', providerId: personal.provider, models: { ultra: personalSpec, strong: personalSpec, medium: personalSpec, light: personalSpec } },
|
|
403
411
|
funded: { family: 'anthropic', credentialSource: 'northlight', providerId: funded.provider, models: { ultra: `${funded.provider}/${funded.id}:medium`, strong: `${funded.provider}/${funded.id}:medium`, medium: `${funded.provider}/${funded.id}:medium`, light: `${funded.provider}/${funded.id}:medium` } },
|
|
@@ -407,7 +415,7 @@ test('reload_auth restores a newly authenticated personal route before the next
|
|
|
407
415
|
writeFileSync(unauthenticatedProvider, personal.provider);
|
|
408
416
|
try {
|
|
409
417
|
const parent = h.spawnRoot('auth route reconciliation parent');
|
|
410
|
-
const child = await h.spawnHeadlessChild(parent, 'auth route reconciliation child'
|
|
418
|
+
const child = await h.spawnHeadlessChild(parent, 'auth route reconciliation child');
|
|
411
419
|
assert.deepEqual(model(child), funded, 'the logical request starts on the funded route while personal auth is absent');
|
|
412
420
|
const controller = await kit.connectRaw(child, { read: true });
|
|
413
421
|
controller.send({ type: 'hello', role: 'controller', client_id: 'auth-route-reconciliation' });
|
|
@@ -470,10 +478,16 @@ test('reload_auth restores a newly authenticated personal route before the next
|
|
|
470
478
|
// subscription-pool occupancy (broker-sdk-wiring's scope).
|
|
471
479
|
test('login re-drive → list_models self-heals (0a) and reload_auth resolves a model + fires the deferred kickoff', { timeout: 30_000 }, async () => {
|
|
472
480
|
const noAuthSentinel = join(h.home, 'fake-pi.no-auth');
|
|
481
|
+
const userConfigDir = join(h.agentHome, '.crouter');
|
|
482
|
+
const userConfigPath = join(userConfigDir, 'config.json');
|
|
483
|
+
mkdirSync(userConfigDir, { recursive: true });
|
|
484
|
+
writeFileSync(userConfigPath, JSON.stringify({
|
|
485
|
+
kinds: { general: { whenToUse: 'General work.', model: 'anthropic/medium' } },
|
|
486
|
+
}));
|
|
473
487
|
writeFileSync(noAuthSentinel, '1');
|
|
474
488
|
try {
|
|
475
489
|
const parent = h.spawnRoot('login re-drive parent');
|
|
476
|
-
const child = await h.spawnHeadlessChild(parent, 'login re-drive'
|
|
490
|
+
const child = await h.spawnHeadlessChild(parent, 'login re-drive');
|
|
477
491
|
assert.equal(model(child), null, 'boots model-less under zero-auth');
|
|
478
492
|
assert.equal(agentStartCount(child), 0, 'kickoff deferred at boot');
|
|
479
493
|
const c = await attachUntil(child, 'controller', 'redrive-ctrl', (a) => a.welcome.role === 'controller', 'redrive controller admitted');
|
|
@@ -498,5 +512,6 @@ test('login re-drive → list_models self-heals (0a) and reload_auth resolves a
|
|
|
498
512
|
}
|
|
499
513
|
finally {
|
|
500
514
|
rmSync(noAuthSentinel, { force: true });
|
|
515
|
+
rmSync(userConfigPath, { force: true });
|
|
501
516
|
}
|
|
502
517
|
});
|
|
@@ -79,7 +79,7 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
|
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
81
|
});
|
|
82
|
-
reviveNode(manager, { resume: true, capacity: 'freeze' });
|
|
82
|
+
await reviveNode(manager, { resume: true, capacity: 'freeze' });
|
|
83
83
|
await h.awaitBoot(manager);
|
|
84
84
|
// A live managed child makes this unfocused terminal manager legitimately
|
|
85
85
|
// awaiting, so its real stophook takes the idle-release branch.
|
|
@@ -172,7 +172,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
|
|
|
172
172
|
},
|
|
173
173
|
});
|
|
174
174
|
subscribe(root, nodeId, true);
|
|
175
|
-
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
175
|
+
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
176
176
|
const boot = await h.awaitBoot(nodeId);
|
|
177
177
|
assert.equal(h.fleet.has(nodeId), true, 'the revived broker is fleet-owned');
|
|
178
178
|
const seenAt = 10_000;
|
|
@@ -223,7 +223,7 @@ test('a non-attending observer does not prevent unattended resident parking', {
|
|
|
223
223
|
intent: 'idle-release',
|
|
224
224
|
});
|
|
225
225
|
updateNode(nodeId, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
226
|
-
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
226
|
+
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
227
227
|
const boot = await h.awaitBoot(nodeId);
|
|
228
228
|
await attach.attach(nodeId, 'observer', 'system-observer-test', { attends: false });
|
|
229
229
|
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');
|
|
@@ -268,7 +268,7 @@ test('a connected broker viewer keeps an obligation-free resident live', { timeo
|
|
|
268
268
|
env: {},
|
|
269
269
|
},
|
|
270
270
|
});
|
|
271
|
-
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
271
|
+
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
272
272
|
const boot = await h.awaitBoot(nodeId);
|
|
273
273
|
// awaitBoot returns on the boot proof, which is written BEFORE the broker's
|
|
274
274
|
// own `persistAttachState()` lays down the truthful zero state. Writing our
|
|
@@ -311,7 +311,7 @@ test('the unattended park clock cancels an obligation-free terminal node and spa
|
|
|
311
311
|
try {
|
|
312
312
|
const boot = async (id) => {
|
|
313
313
|
updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
314
|
-
reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
314
|
+
await reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
315
315
|
return (await h.awaitBoot(id)).pid;
|
|
316
316
|
};
|
|
317
317
|
const settled = h.fabricateBrokerNode({ id: 'terminal-no-work', parent: root, status: 'idle', intent: 'idle-release' });
|
|
@@ -378,7 +378,7 @@ test('a resident managed child parks without finalizing while a terminal child i
|
|
|
378
378
|
subscribe(terminalManager, terminal, true);
|
|
379
379
|
const boot = async (id) => {
|
|
380
380
|
updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
381
|
-
reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
381
|
+
await reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
382
382
|
return (await h.awaitBoot(id)).pid;
|
|
383
383
|
};
|
|
384
384
|
const residentPid = await boot(resident);
|
|
@@ -439,7 +439,7 @@ test('an unattended resident awaiting a live child remains wakeable', { timeout:
|
|
|
439
439
|
env: {},
|
|
440
440
|
},
|
|
441
441
|
});
|
|
442
|
-
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
442
|
+
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
443
443
|
const boot = await h.awaitBoot(nodeId);
|
|
444
444
|
const seenAt = 20_000;
|
|
445
445
|
await h.tick(seenAt);
|
|
@@ -42,7 +42,7 @@ test('a Jiti-loaded watcher prepends its held deferred batch to a native broker
|
|
|
42
42
|
},
|
|
43
43
|
},
|
|
44
44
|
});
|
|
45
|
-
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
45
|
+
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
46
46
|
await h.awaitBoot(nodeId);
|
|
47
47
|
const entry = appendInbox(nodeId, {
|
|
48
48
|
from: 'child',
|
|
@@ -115,11 +115,28 @@ test('node-relative memory slash commands omit preview metadata and register inv
|
|
|
115
115
|
assert.equal(loadedCommandPreview('/acceptance-independent first evidence', live.commands), undefined);
|
|
116
116
|
assert.deepEqual(live.notifications, []);
|
|
117
117
|
const sentContent = Object.fromEntries(live.sent.map((send) => [send.name, send.message.content]));
|
|
118
|
-
|
|
118
|
+
const invokedBody = {
|
|
119
119
|
[COMMAND_NAMES[0]]: `Store evidence in ${join(home, 'nodes', nodeId, 'context', 'result.md')}.\n`,
|
|
120
120
|
[COMMAND_NAMES[1]]: `Node ${nodeId} received evidence.\n`,
|
|
121
121
|
[COMMAND_NAMES[2]]: 'Independent payload: evidence.\n',
|
|
122
|
-
}
|
|
122
|
+
};
|
|
123
|
+
// An invoked document is content delivery, so the FIRST invocation carries the
|
|
124
|
+
// same companions `crtr memory read` attaches: the listings that place the doc,
|
|
125
|
+
// plus every corpus doc its memory-read routes match. The doc being delivered is
|
|
126
|
+
// absent from its own listing because its exposure registers before they render.
|
|
127
|
+
const firstSent = sentContent[COMMAND_NAMES[0]];
|
|
128
|
+
assert.ok(firstSent.endsWith(invokedBody[COMMAND_NAMES[0]]), 'the expanded body ends the delivery');
|
|
129
|
+
const companions = firstSent.slice(0, firstSent.length - invokedBody[COMMAND_NAMES[0]].length);
|
|
130
|
+
assert.match(companions, /^<auto-loaded-context>\n[\s\S]*<\/auto-loaded-context>\n\n$/);
|
|
131
|
+
assert.ok(companions.includes('<memory-listing dir="workspace/workspace">\n'
|
|
132
|
+
+ `[[workspace/workspace/${COMMAND_LOCALS[2]}]]: Acceptance fixture.\n`
|
|
133
|
+
+ `[[workspace/workspace/${COMMAND_LOCALS[1]}]]: Acceptance fixture.\n`
|
|
134
|
+
+ '</memory-listing>'), 'the invoked doc\'s own directory listing is attached as a block');
|
|
135
|
+
assert.ok(companions.includes('<memory-listing dir="workspace">\n[[workspace/workspace]]\n</memory-listing>'));
|
|
136
|
+
assert.match(companions, /<memory kind="knowledge" name="memory-read-orientation">/);
|
|
137
|
+
// The later invocations attach nothing: the first delivery already exposed them.
|
|
138
|
+
assert.equal(sentContent[COMMAND_NAMES[1]], invokedBody[COMMAND_NAMES[1]]);
|
|
139
|
+
assert.equal(sentContent[COMMAND_NAMES[2]], invokedBody[COMMAND_NAMES[2]]);
|
|
123
140
|
for (const send of live.sent) {
|
|
124
141
|
assert.deepEqual(send.message, {
|
|
125
142
|
customType: 'memory-slash-command',
|
|
@@ -73,11 +73,11 @@ test('yield-message survives one pre-session_start retry and clears on session_s
|
|
|
73
73
|
// the retry still roots a fresh cycle carrying the note, never reopening the
|
|
74
74
|
// old session. Driving reviveNode directly is exactly what focus's revive path
|
|
75
75
|
// calls, minus a tmux pane this contract does not involve.
|
|
76
|
-
const focusRevive = reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
76
|
+
const focusRevive = await reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
77
77
|
const focusPid = focusRevive.launch?.pid;
|
|
78
78
|
assert.ok(focusPid != null, 'the competing focus invocation launches the replacement broker');
|
|
79
79
|
assert.equal(focusRevive.resumed, false, 'refresh intent overrides this invocation\'s strict-resume request');
|
|
80
|
-
assert.equal(h.fleet.get(id)?.pid, focusPid, 'the focus invocation
|
|
80
|
+
assert.equal(h.fleet.get(id)?.pid, focusPid, 'the focus invocation owns the authoritative fleet slot');
|
|
81
81
|
// Registering the manual launch cancels the exit policy's deferred recovery.
|
|
82
82
|
// Its pid must therefore be the successful observed boot, proving these
|
|
83
83
|
// assertions belong to the contender above rather than an already-queued boot.
|
|
@@ -61,7 +61,9 @@ test('RelayTransport dials crtrd attach with Authorization: Bearer <token>', asy
|
|
|
61
61
|
const relay = await startMockRelay();
|
|
62
62
|
const transport = new RelayTransport('node-auth', relay.target);
|
|
63
63
|
await connectTransport(transport);
|
|
64
|
-
|
|
64
|
+
// `wakeParked=1` is part of the dial contract: a person attached deliberately,
|
|
65
|
+
// so the bridge wakes an exact parked resident for this caller.
|
|
66
|
+
assert.equal(relay.requestUrls[0], '/v1/nodes/node-auth/attach?wakeParked=1');
|
|
65
67
|
assert.equal(relay.authHeaders[0], 'Bearer test-token');
|
|
66
68
|
transport.destroy();
|
|
67
69
|
await relay.close();
|
|
@@ -14,7 +14,11 @@ function relayUrl(target, nodeId) {
|
|
|
14
14
|
const url = new URL(target.previewEndpoint);
|
|
15
15
|
url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:';
|
|
16
16
|
url.pathname = `/v1/nodes/${encodeURIComponent(nodeId)}/attach`;
|
|
17
|
-
|
|
17
|
+
// A person typed `crtr surface attach` at this node, so an exact parked
|
|
18
|
+
// resident is woken for it — the same thing the LOCAL attach ensure does for
|
|
19
|
+
// a direct attach. The bridge leaves a parked node parked without this, which
|
|
20
|
+
// is what a product page reconnecting its viewport wants.
|
|
21
|
+
url.search = '?wakeParked=1';
|
|
18
22
|
url.hash = '';
|
|
19
23
|
return url.toString();
|
|
20
24
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// history-transcript.test.ts — transcript artifacts carry the origin their
|
|
2
|
+
// stamp recorded.
|
|
3
|
+
//
|
|
4
|
+
// The join is the fragile part: a stamp is physically written BEFORE the
|
|
5
|
+
// message it describes, so correlation runs on the message's own epoch-ms
|
|
6
|
+
// `timestamp` and never on tree position. Getting that wrong yields a corpus
|
|
7
|
+
// where every origin silently reads `unknown` — which looks like a stamping
|
|
8
|
+
// regression rather than a reader bug, so it is worth pinning.
|
|
9
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { createNode } from '../canvas.js';
|
|
15
|
+
import { closeDb } from '../db.js';
|
|
16
|
+
import { nodeDir, sessionPtrPath } from '../paths.js';
|
|
17
|
+
import { nodeArtifacts, groupCorpus } from '../history.js';
|
|
18
|
+
const NODE = 'test-transcript-node';
|
|
19
|
+
function meta(id, cwd) {
|
|
20
|
+
return {
|
|
21
|
+
node_id: id,
|
|
22
|
+
name: id,
|
|
23
|
+
created: new Date().toISOString(),
|
|
24
|
+
cwd,
|
|
25
|
+
kind: 'general',
|
|
26
|
+
mode: 'base',
|
|
27
|
+
lifecycle: 'terminal',
|
|
28
|
+
status: 'active',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function messageEntry(role, text, msgTs) {
|
|
32
|
+
return JSON.stringify({
|
|
33
|
+
type: 'message',
|
|
34
|
+
timestamp: new Date(msgTs).toISOString(),
|
|
35
|
+
message: { role, content: [{ type: 'text', text }], timestamp: msgTs },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function stampEntry(msgTs, origin) {
|
|
39
|
+
return JSON.stringify({
|
|
40
|
+
type: 'custom',
|
|
41
|
+
customType: 'crtr-stamp',
|
|
42
|
+
data: { v: 1, kind: 'message', payload: { msgTs, origin } },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** A session file in the real on-disk shape: stamps precede their messages,
|
|
46
|
+
* one inbound turn is unstamped, and an assistant turn sits between them. */
|
|
47
|
+
function seedSession(home) {
|
|
48
|
+
const dir = nodeDir(NODE);
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
const file = join(home, 'session.jsonl');
|
|
51
|
+
writeFileSync(file, [
|
|
52
|
+
JSON.stringify({ type: 'custom', customType: 'crtr-stamp', data: { v: 1, kind: 'session', payload: { node: NODE, crtr: '0.0.0' } } }),
|
|
53
|
+
stampEntry(1000, { type: 'runtime', kind: 'wake', wake: { kind: 'schedule_revive', ownerId: null, armedAt: 'x' } }),
|
|
54
|
+
messageEntry('user', 'woken by cron', 1000),
|
|
55
|
+
JSON.stringify({ type: 'message', timestamp: new Date(1500).toISOString(), message: { role: 'assistant', content: [{ type: 'thinking', text: 'private' }, { type: 'toolCall', name: 'Bash' }], timestamp: 1500 } }),
|
|
56
|
+
stampEntry(2000, { type: 'runtime', kind: 'inbox', entries: [{ from: 'n1', fromName: 'child-node', kind: 'report', tier: 'normal' }] }),
|
|
57
|
+
messageEntry('user', 'a delivery', 2000),
|
|
58
|
+
messageEntry('user', 'no stamp for this one', 3000),
|
|
59
|
+
].join('\n'), 'utf8');
|
|
60
|
+
writeFileSync(sessionPtrPath(NODE), file, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
let home;
|
|
63
|
+
let cwd;
|
|
64
|
+
before(() => {
|
|
65
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-history-transcript-'));
|
|
66
|
+
cwd = mkdtempSync(join(tmpdir(), 'crtr-history-transcript-cwd-'));
|
|
67
|
+
process.env['CRTR_HOME'] = home;
|
|
68
|
+
});
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
closeDb();
|
|
71
|
+
});
|
|
72
|
+
after(() => {
|
|
73
|
+
closeDb();
|
|
74
|
+
for (const d of [home, cwd])
|
|
75
|
+
rmSync(d, { recursive: true, force: true });
|
|
76
|
+
delete process.env['CRTR_HOME'];
|
|
77
|
+
});
|
|
78
|
+
test('a stamped message carries its origin; an unstamped one reads unknown', () => {
|
|
79
|
+
createNode(meta(NODE, cwd));
|
|
80
|
+
seedSession(home);
|
|
81
|
+
const arts = nodeArtifacts(NODE, ['transcript']);
|
|
82
|
+
assert.equal(arts.length, 4, 'every message entry becomes one artifact');
|
|
83
|
+
const origins = arts.map((a) => a.origin);
|
|
84
|
+
assert.deepEqual(origins, ['wake', undefined, 'inbox', 'unknown']);
|
|
85
|
+
// Origin is a property of ARRIVAL — a generated turn must not acquire one,
|
|
86
|
+
// which is what lets `--origin` exclude assistant turns without a role filter.
|
|
87
|
+
const assistant = arts.find((a) => a.role === 'assistant');
|
|
88
|
+
assert.equal(assistant?.origin, undefined);
|
|
89
|
+
// Sender attribution rides the inbox stamp's delivered entries.
|
|
90
|
+
assert.equal(arts[2]?.from, 'child-node');
|
|
91
|
+
// Private model reasoning stays out of the searchable body, matching every
|
|
92
|
+
// other human-facing transcript surface.
|
|
93
|
+
assert.ok(!assistant?.loadBody().includes('private'));
|
|
94
|
+
assert.ok(assistant?.loadBody().includes('Tool: Bash'));
|
|
95
|
+
});
|
|
96
|
+
test('grouping by origin counts arrivals only, and unknown is its own bucket', () => {
|
|
97
|
+
createNode(meta(NODE, cwd));
|
|
98
|
+
seedSession(home);
|
|
99
|
+
const groups = groupCorpus(nodeArtifacts(NODE, ['transcript']), 'origin');
|
|
100
|
+
assert.deepEqual(groups.map((g) => g.key).sort(), ['inbox', 'unknown', 'wake'], 'the assistant turn contributes no bucket');
|
|
101
|
+
assert.equal(groups.reduce((n, g) => n + g.count, 0), 3);
|
|
102
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CanvasSource } from '../source.js';
|
|
2
2
|
import { type BindingId, type BindingResolution, type RawTerminalInput } from '../../keybindings/index.js';
|
|
3
|
-
declare const BROWSE_NAV_ACTIONS: readonly ["crtr.browse.quit", "crtr.browse.down", "crtr.browse.up", "crtr.browse.first", "crtr.browse.last", "crtr.browse.expand-or-child", "crtr.browse.collapse-or-parent", "crtr.browse.toggle-fold", "crtr.browse.tab-next", "crtr.browse.tab-previous", "crtr.browse.tab.1", "crtr.browse.tab.2", "crtr.browse.tab.3", "crtr.browse.tab.4", "crtr.browse.tab.5", "crtr.browse.close-subtree", "crtr.browse.sort", "crtr.browse.scope", "crtr.browse.profile", "crtr.browse.residents", "crtr.browse.preview", "crtr.browse.search", "crtr.browse.revive-now", "crtr.browse.focus", "crtr.browse.focus-background", "crtr.browse.yank-id"];
|
|
3
|
+
declare const BROWSE_NAV_ACTIONS: readonly ["crtr.browse.quit", "crtr.browse.down", "crtr.browse.up", "crtr.browse.first", "crtr.browse.last", "crtr.browse.expand-or-child", "crtr.browse.collapse-or-parent", "crtr.browse.toggle-fold", "crtr.browse.tab-next", "crtr.browse.tab-previous", "crtr.browse.tab.1", "crtr.browse.tab.2", "crtr.browse.tab.3", "crtr.browse.tab.4", "crtr.browse.tab.5", "crtr.browse.close-subtree", "crtr.browse.sort", "crtr.browse.scope", "crtr.browse.profile", "crtr.browse.residents", "crtr.browse.preview", "crtr.browse.pin", "crtr.browse.search", "crtr.browse.revive-now", "crtr.browse.focus", "crtr.browse.focus-background", "crtr.browse.yank-id"];
|
|
4
4
|
declare const BROWSE_SEARCH_ACTIONS: readonly ["crtr.browse.search.cancel", "crtr.browse.search.commit", "crtr.browse.search.delete", "crtr.browse.search.quit"];
|
|
5
5
|
export declare function resolveBrowseNavAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof BROWSE_NAV_ACTIONS)[number] | null;
|
|
6
6
|
export declare function resolveBrowseSearchAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof BROWSE_SEARCH_ACTIONS)[number] | null;
|
|
@@ -21,7 +21,8 @@ const BROWSE_NAV_ACTIONS = [
|
|
|
21
21
|
'crtr.browse.expand-or-child', 'crtr.browse.collapse-or-parent', 'crtr.browse.toggle-fold',
|
|
22
22
|
'crtr.browse.tab-next', 'crtr.browse.tab-previous', 'crtr.browse.tab.1', 'crtr.browse.tab.2',
|
|
23
23
|
'crtr.browse.tab.3', 'crtr.browse.tab.4', 'crtr.browse.tab.5', 'crtr.browse.close-subtree', 'crtr.browse.sort',
|
|
24
|
-
'crtr.browse.scope', 'crtr.browse.profile', 'crtr.browse.residents', 'crtr.browse.preview',
|
|
24
|
+
'crtr.browse.scope', 'crtr.browse.profile', 'crtr.browse.residents', 'crtr.browse.preview',
|
|
25
|
+
'crtr.browse.pin', 'crtr.browse.search',
|
|
25
26
|
'crtr.browse.revive-now', 'crtr.browse.focus', 'crtr.browse.focus-background', 'crtr.browse.yank-id',
|
|
26
27
|
];
|
|
27
28
|
const BROWSE_SEARCH_ACTIONS = [
|
|
@@ -40,14 +41,16 @@ export function resolveBrowseSearchAction(bindings, raw) {
|
|
|
40
41
|
// Sort cycle for the `s` key. Starts on the default `attention` ordering; one `s`
|
|
41
42
|
// press restores the structural `tree` view, then relevance/recency, then back.
|
|
42
43
|
const SORT_CYCLE = ['attention', 'tree', 'relevance', 'recency'];
|
|
43
|
-
import { renderFrame, detectColorCaps, headerHeight, PREVIEW_HEIGHT } from './render.js';
|
|
44
|
+
import { renderFrame, detectColorCaps, headerHeight, previewLayout, PREVIEW_HEIGHT } from './render.js';
|
|
44
45
|
import { writeClipboardTextEverywhere } from '../../clipboard-text.js';
|
|
45
46
|
/** Viewport (body) height = total rows minus the header renderFrame draws (see
|
|
46
|
-
* render.ts headerHeight), the footer, and the preview panel when
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
* render.ts headerHeight), the footer, and the BOTTOM preview panel when that is
|
|
48
|
+
* the layout in play. The side preview column costs columns, not rows, so it does
|
|
49
|
+
* not shrink the viewport. Kept in lockstep with render.ts via the shared
|
|
50
|
+
* headerHeight/previewLayout/PREVIEW_HEIGHT. */
|
|
51
|
+
function viewportHeight(size, inputLine, previewOn) {
|
|
52
|
+
const rows = Math.max(8, size.rows);
|
|
53
|
+
const previewH = previewLayout(size.cols, previewOn).mode === 'bottom' ? PREVIEW_HEIGHT : 0;
|
|
51
54
|
return Math.max(1, rows - headerHeight(inputLine) - 1 /* footer */ - previewH);
|
|
52
55
|
}
|
|
53
56
|
export async function runBrowse(opts) {
|
|
@@ -117,7 +120,7 @@ export async function runBrowse(opts) {
|
|
|
117
120
|
cwdScope: null, // default: All dirs
|
|
118
121
|
profileScope: launchProfileId,
|
|
119
122
|
sort: 'attention', // default: attention ordering (attached/streaming/live first)
|
|
120
|
-
preview:
|
|
123
|
+
preview: false, // default OFF (decision) — `v` opens it for the hovered row
|
|
121
124
|
residentsOnly: true, // default ON: hide one-shot workers (decision)
|
|
122
125
|
pendingClose: null,
|
|
123
126
|
flash: null,
|
|
@@ -218,7 +221,7 @@ export async function runBrowse(opts) {
|
|
|
218
221
|
const size = getTerminalSize();
|
|
219
222
|
const previewOn = state.preview && visible.length > 0;
|
|
220
223
|
const inputMode = state.search || state.rename || state.message;
|
|
221
|
-
const viewport = viewportHeight(size
|
|
224
|
+
const viewport = viewportHeight(size, inputMode, previewOn);
|
|
222
225
|
// Keep the cursor inside the viewport window.
|
|
223
226
|
if (state.cursor < state.scrollOffset)
|
|
224
227
|
state.scrollOffset = state.cursor;
|
|
@@ -708,7 +711,7 @@ export async function runBrowse(opts) {
|
|
|
708
711
|
return;
|
|
709
712
|
}
|
|
710
713
|
// Pin or unpin in the local, view-only pin set.
|
|
711
|
-
if (
|
|
714
|
+
if (action === 'crtr.browse.pin') {
|
|
712
715
|
if (row !== undefined)
|
|
713
716
|
togglePin(row.id);
|
|
714
717
|
scheduleFlush();
|
|
@@ -201,13 +201,28 @@ export function previewSnippet(query, text, width, maxLines) {
|
|
|
201
201
|
// 2. Recompute highlight indices on the FINAL snippet string (no drift), then
|
|
202
202
|
// word-wrap with offset tracking and split the highlight into per-line cols.
|
|
203
203
|
const hi = highlightIndices(query, snippet);
|
|
204
|
-
|
|
204
|
+
const wrapped = wrapTracked(snippet, width, maxLines);
|
|
205
|
+
const lines = wrapped.map(({ text: lt, start }) => {
|
|
205
206
|
const lhi = new Set();
|
|
206
207
|
for (const idx of hi)
|
|
207
208
|
if (idx >= start && idx < start + lt.length)
|
|
208
209
|
lhi.add(idx - start);
|
|
209
210
|
return { text: lt, hi: lhi };
|
|
210
211
|
});
|
|
212
|
+
// 3. Mark a truncation. Without this the last line reads as the end of the text,
|
|
213
|
+
// which is exactly the wrong impression for "where did this node leave off?".
|
|
214
|
+
const last = wrapped[wrapped.length - 1];
|
|
215
|
+
const lastLine = lines[lines.length - 1];
|
|
216
|
+
if (last !== undefined && lastLine !== undefined && last.start + last.text.length < snippet.length) {
|
|
217
|
+
const keep = Math.max(0, Math.min(lastLine.text.length, width - 1));
|
|
218
|
+
const text = lastLine.text.slice(0, keep);
|
|
219
|
+
const lhi = new Set();
|
|
220
|
+
for (const idx of lastLine.hi)
|
|
221
|
+
if (idx < keep)
|
|
222
|
+
lhi.add(idx);
|
|
223
|
+
lines[lines.length - 1] = { text: `${text}…`, hi: lhi };
|
|
224
|
+
}
|
|
225
|
+
return lines;
|
|
211
226
|
}
|
|
212
227
|
function shortId(id) {
|
|
213
228
|
return id.slice(0, 8);
|
|
@@ -2,10 +2,21 @@ import type { Tab, Tree, VisibleRow, SortMode } from './model.js';
|
|
|
2
2
|
import { detectColorCaps, type ColorCaps } from '../../tui/draw.js';
|
|
3
3
|
export { detectColorCaps };
|
|
4
4
|
export type { ColorCaps };
|
|
5
|
-
export declare const
|
|
6
|
-
export declare const
|
|
7
|
-
export declare const PREVIEW_BODY
|
|
5
|
+
export declare const RECAP_LINES = 2;
|
|
6
|
+
export declare const REPORT_LINES = 2;
|
|
7
|
+
export declare const PREVIEW_BODY = 8;
|
|
8
8
|
export declare const PREVIEW_HEIGHT: number;
|
|
9
|
+
export type PreviewMode = 'none' | 'bottom' | 'side';
|
|
10
|
+
/** Where the preview goes at this terminal width, and how the body splits.
|
|
11
|
+
* `rowsW` is the width the row table draws into (full width unless `side`).
|
|
12
|
+
* Exported so app.ts's viewport math can subtract preview ROWS only in `bottom`
|
|
13
|
+
* mode — the side column costs columns, not rows. */
|
|
14
|
+
export interface PreviewLayout {
|
|
15
|
+
mode: PreviewMode;
|
|
16
|
+
panelW: number;
|
|
17
|
+
rowsW: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function previewLayout(cols: number, previewOn: boolean): PreviewLayout;
|
|
9
20
|
export declare function headerHeight(inputLine: boolean): number;
|
|
10
21
|
export interface RenderState {
|
|
11
22
|
tree: Tree;
|
|
@@ -52,6 +63,15 @@ export interface RenderState {
|
|
|
52
63
|
* dead action. undefined/false = local, byte-identical footer. */
|
|
53
64
|
readOnly?: boolean;
|
|
54
65
|
}
|
|
66
|
+
/** How the fixed PREVIEW_BODY budget is divided for one row: the optional blocks
|
|
67
|
+
* take their two lines only when the node actually has them, and the remainder is
|
|
68
|
+
* split between the ask and the reply (ask keeps the odd line). */
|
|
69
|
+
export declare function previewAllocation(hasRecap: boolean, hasReport: boolean): {
|
|
70
|
+
recap: number;
|
|
71
|
+
ask: number;
|
|
72
|
+
reply: number;
|
|
73
|
+
report: number;
|
|
74
|
+
};
|
|
55
75
|
/**
|
|
56
76
|
* Render the whole frame. Returns a single string that, written as-is, repaints
|
|
57
77
|
* the screen in place. `caps` gates all hue (defaults to a no-color frame so
|