@crouton-kit/crouter 0.3.14 → 0.3.15
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/build-root.d.ts +8 -0
- package/dist/build-root.js +30 -0
- package/dist/builtin-personas/design/base.md +3 -7
- package/dist/builtin-personas/design/orchestrator.md +4 -3
- package/dist/builtin-personas/developer/base.md +3 -7
- package/dist/builtin-personas/developer/orchestrator.md +5 -4
- package/dist/builtin-personas/explore/base.md +3 -7
- package/dist/builtin-personas/explore/orchestrator.md +1 -5
- package/dist/builtin-personas/general/base.md +2 -4
- package/dist/builtin-personas/general/orchestrator.md +2 -4
- package/dist/builtin-personas/lifecycle/resident.md +2 -0
- package/dist/builtin-personas/lifecycle/terminal.md +6 -0
- package/dist/builtin-personas/orchestration-kernel.md +42 -3
- package/dist/builtin-personas/plan/base.md +3 -5
- package/dist/builtin-personas/plan/orchestrator.md +5 -4
- package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
- package/dist/builtin-personas/review/base.md +3 -5
- package/dist/builtin-personas/review/orchestrator.md +2 -6
- package/dist/builtin-personas/runtime-base.md +3 -19
- package/dist/builtin-personas/spec/base.md +3 -5
- package/dist/builtin-personas/spec/orchestrator.md +4 -3
- package/dist/builtin-personas/spine/has-manager.md +10 -0
- package/dist/builtin-personas/spine/no-manager.md +2 -0
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
- package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
- package/dist/cli.js +6 -29
- package/dist/commands/attention.js +76 -7
- package/dist/commands/canvas-prune.d.ts +2 -0
- package/dist/commands/canvas-prune.js +66 -0
- package/dist/commands/canvas.js +5 -8
- package/dist/commands/chord.d.ts +2 -0
- package/dist/commands/chord.js +143 -0
- package/dist/commands/daemon.js +8 -5
- package/dist/commands/dashboard.js +2 -0
- package/dist/commands/human/prompts.js +28 -27
- package/dist/commands/human/queue.js +30 -14
- package/dist/commands/human/shared.d.ts +26 -21
- package/dist/commands/human/shared.js +44 -66
- package/dist/commands/human.js +4 -14
- package/dist/commands/node.d.ts +11 -0
- package/dist/commands/node.js +221 -98
- package/dist/commands/pkg/market-inspect.js +6 -4
- package/dist/commands/pkg/market-manage.js +10 -6
- package/dist/commands/pkg/market.js +2 -4
- package/dist/commands/pkg/plugin-inspect.js +6 -4
- package/dist/commands/pkg/plugin-manage.js +12 -7
- package/dist/commands/pkg/plugin.js +2 -4
- package/dist/commands/pkg.js +0 -4
- package/dist/commands/push.js +178 -15
- package/dist/commands/revive.js +5 -3
- package/dist/commands/skill/author.js +6 -4
- package/dist/commands/skill/find.js +8 -5
- package/dist/commands/skill/read.js +2 -0
- package/dist/commands/skill/state.js +6 -4
- package/dist/commands/skill.js +0 -6
- package/dist/commands/sys/config.js +21 -7
- package/dist/commands/sys/doctor.js +2 -0
- package/dist/commands/sys/update.js +4 -0
- package/dist/commands/sys.js +0 -6
- package/dist/commands/tmux-spread.d.ts +2 -0
- package/dist/commands/tmux-spread.js +130 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
- package/dist/core/__tests__/child-followup.test.d.ts +1 -0
- package/dist/core/__tests__/child-followup.test.js +83 -0
- package/dist/core/__tests__/close.test.d.ts +1 -0
- package/dist/core/__tests__/close.test.js +148 -0
- package/dist/core/__tests__/context-intro.test.d.ts +1 -0
- package/dist/core/__tests__/context-intro.test.js +196 -0
- package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-boot.test.js +93 -0
- package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-liveness.test.js +223 -0
- package/dist/core/__tests__/focuses.test.d.ts +1 -0
- package/dist/core/__tests__/focuses.test.js +259 -0
- package/dist/core/__tests__/fork.test.d.ts +1 -0
- package/dist/core/__tests__/fork.test.js +91 -0
- package/dist/core/__tests__/home-session.test.d.ts +1 -0
- package/dist/core/__tests__/home-session.test.js +153 -0
- package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
- package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
- package/dist/core/__tests__/keystone.test.d.ts +1 -0
- package/dist/core/__tests__/keystone.test.js +185 -0
- package/dist/core/__tests__/kickoff.test.d.ts +1 -0
- package/dist/core/__tests__/kickoff.test.js +89 -0
- package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/lifecycle.test.js +178 -0
- package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
- package/dist/core/__tests__/listing-completeness.test.js +31 -0
- package/dist/core/__tests__/memory.test.d.ts +1 -0
- package/dist/core/__tests__/memory.test.js +152 -0
- package/dist/core/__tests__/migration.test.d.ts +1 -0
- package/dist/core/__tests__/migration.test.js +238 -0
- package/dist/core/__tests__/pane-column.test.d.ts +1 -0
- package/dist/core/__tests__/pane-column.test.js +153 -0
- package/dist/core/__tests__/passive-subscription.test.js +24 -1
- package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
- package/dist/core/__tests__/persona-compose.test.js +53 -0
- package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
- package/dist/core/__tests__/persona-subkind.test.js +62 -0
- package/dist/core/__tests__/persona.test.d.ts +1 -0
- package/dist/core/__tests__/persona.test.js +107 -0
- package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
- package/dist/core/__tests__/placement-focus.test.js +244 -0
- package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
- package/dist/core/__tests__/placement-reconcile.test.js +212 -0
- package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
- package/dist/core/__tests__/placement-revive.test.js +238 -0
- package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/placement-teardown.test.js +183 -0
- package/dist/core/__tests__/prune.test.d.ts +1 -0
- package/dist/core/__tests__/prune.test.js +116 -0
- package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
- package/dist/core/__tests__/push-final-guard.test.js +71 -0
- package/dist/core/__tests__/relaunch.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch.test.js +328 -0
- package/dist/core/__tests__/reset.test.js +26 -7
- package/dist/core/__tests__/revive.test.d.ts +1 -0
- package/dist/core/__tests__/revive.test.js +217 -0
- package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
- package/dist/core/__tests__/spawn-root.test.js +73 -0
- package/dist/core/__tests__/steer-note.test.d.ts +1 -0
- package/dist/core/__tests__/steer-note.test.js +39 -0
- package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
- package/dist/core/__tests__/stop-guard.test.js +82 -0
- package/dist/core/__tests__/subcommand-tier.test.js +35 -33
- package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
- package/dist/core/__tests__/tmux-surface.test.js +106 -0
- package/dist/core/__tests__/unknown-path.test.js +8 -2
- package/dist/core/canvas/attention.d.ts +10 -0
- package/dist/core/canvas/attention.js +40 -0
- package/dist/core/canvas/canvas.d.ts +66 -7
- package/dist/core/canvas/canvas.js +209 -21
- package/dist/core/canvas/db.d.ts +8 -0
- package/dist/core/canvas/db.js +206 -4
- package/dist/core/canvas/focuses.d.ts +22 -0
- package/dist/core/canvas/focuses.js +80 -0
- package/dist/core/canvas/index.d.ts +3 -0
- package/dist/core/canvas/index.js +3 -0
- package/dist/core/canvas/labels.d.ts +27 -0
- package/dist/core/canvas/labels.js +36 -0
- package/dist/core/canvas/render.js +25 -10
- package/dist/core/canvas/telemetry.d.ts +14 -0
- package/dist/core/canvas/telemetry.js +35 -0
- package/dist/core/canvas/types.d.ts +115 -12
- package/dist/core/command.d.ts +25 -1
- package/dist/core/command.js +23 -15
- package/dist/core/config.js +36 -2
- package/dist/core/feed/feed.js +3 -3
- package/dist/core/feed/inbox.d.ts +3 -1
- package/dist/core/feed/inbox.js +45 -5
- package/dist/core/feed/passive.js +24 -11
- package/dist/core/help.d.ts +26 -13
- package/dist/core/help.js +44 -37
- package/dist/core/personas/index.d.ts +1 -1
- package/dist/core/personas/index.js +1 -1
- package/dist/core/personas/loader.d.ts +40 -1
- package/dist/core/personas/loader.js +63 -1
- package/dist/core/personas/resolve.d.ts +13 -6
- package/dist/core/personas/resolve.js +46 -34
- package/dist/core/runtime/bearings.d.ts +20 -0
- package/dist/core/runtime/bearings.js +92 -0
- package/dist/core/runtime/close.d.ts +14 -0
- package/dist/core/runtime/close.js +151 -0
- package/dist/core/runtime/demote.js +27 -10
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/kickoff.d.ts +23 -6
- package/dist/core/runtime/kickoff.js +92 -36
- package/dist/core/runtime/launch.d.ts +24 -12
- package/dist/core/runtime/launch.js +75 -19
- package/dist/core/runtime/lifecycle.d.ts +13 -0
- package/dist/core/runtime/lifecycle.js +86 -0
- package/dist/core/runtime/memory.d.ts +43 -0
- package/dist/core/runtime/memory.js +165 -0
- package/dist/core/runtime/naming.d.ts +22 -0
- package/dist/core/runtime/naming.js +166 -0
- package/dist/core/runtime/nodes.d.ts +32 -1
- package/dist/core/runtime/nodes.js +60 -10
- package/dist/core/runtime/persona.d.ts +25 -0
- package/dist/core/runtime/persona.js +139 -0
- package/dist/core/runtime/placement.d.ts +287 -0
- package/dist/core/runtime/placement.js +663 -0
- package/dist/core/runtime/presence.d.ts +7 -15
- package/dist/core/runtime/presence.js +90 -66
- package/dist/core/runtime/promote.d.ts +14 -7
- package/dist/core/runtime/promote.js +57 -67
- package/dist/core/runtime/reset.d.ts +47 -4
- package/dist/core/runtime/reset.js +223 -52
- package/dist/core/runtime/revive.d.ts +26 -2
- package/dist/core/runtime/revive.js +166 -39
- package/dist/core/runtime/spawn.d.ts +20 -5
- package/dist/core/runtime/spawn.js +163 -43
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +18 -8
- package/dist/core/runtime/tmux.d.ts +100 -14
- package/dist/core/runtime/tmux.js +201 -28
- package/dist/core/spawn.js +15 -0
- package/dist/daemon/crtrd.d.ts +12 -1
- package/dist/daemon/crtrd.js +152 -34
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
- package/dist/pi-extensions/canvas-commands.js +16 -13
- package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
- package/dist/pi-extensions/canvas-context-intro.js +164 -0
- package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
- package/dist/pi-extensions/canvas-goal-capture.js +15 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
- package/dist/pi-extensions/canvas-nav.d.ts +12 -4
- package/dist/pi-extensions/canvas-nav.js +586 -262
- package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
- package/dist/pi-extensions/canvas-stophook.js +344 -228
- package/dist/types.d.ts +28 -0
- package/dist/types.js +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/pi-extensions/__tests__/canvas-stophook-agentend.test.ts
|
|
2
|
+
//
|
|
3
|
+
// The stophook's agent_end routing no longer auto-pushes anything: a node
|
|
4
|
+
// reaches its subscribers ONLY through its own explicit `crtr push` calls.
|
|
5
|
+
// These tests pin that on the three stop outcomes:
|
|
6
|
+
// • natural stop while awaiting a live worker → idle-release, NO push
|
|
7
|
+
// • refresh-yield (intent='refresh') → re-exec/shutdown, NO push
|
|
8
|
+
// • stalled leaf (nothing live, no final) → reprompt still fires
|
|
9
|
+
// Every assertion is on DB / disk effects (report files, inbox pointers) plus
|
|
10
|
+
// the captured sendUserMessage — tmux is unavailable here, so the focus/respawn
|
|
11
|
+
// helpers no-op (TMUX_PANE is cleared) and we drive a clean shutdown path.
|
|
12
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import registerCanvasStophook from '../canvas-stophook.js';
|
|
18
|
+
import { createNode, subscribe, getNode, setStatus } from '../../core/canvas/canvas.js';
|
|
19
|
+
import { openFocusRow, getFocusByNode, getFocusById, listFocuses } from '../../core/canvas/focuses.js';
|
|
20
|
+
import { closeDb } from '../../core/canvas/db.js';
|
|
21
|
+
import { reportsDir } from '../../core/canvas/paths.js';
|
|
22
|
+
import { readInboxSince } from '../../core/feed/inbox.js';
|
|
23
|
+
import { STALL_REPROMPT } from '../../core/runtime/stop-guard.js';
|
|
24
|
+
let home;
|
|
25
|
+
let origNode;
|
|
26
|
+
let origPane;
|
|
27
|
+
function node(id, over = {}) {
|
|
28
|
+
return {
|
|
29
|
+
node_id: id,
|
|
30
|
+
name: id,
|
|
31
|
+
created: new Date().toISOString(),
|
|
32
|
+
cwd: '/tmp/work',
|
|
33
|
+
kind: 'general',
|
|
34
|
+
mode: 'base',
|
|
35
|
+
lifecycle: 'terminal',
|
|
36
|
+
status: 'active',
|
|
37
|
+
...over,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function makeFakePi() {
|
|
41
|
+
const handlers = {};
|
|
42
|
+
return {
|
|
43
|
+
injected: [],
|
|
44
|
+
on(e, h) { handlers[e] = h; },
|
|
45
|
+
sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
|
|
46
|
+
fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** A natural-stop agent_end event carrying one assistant text block. */
|
|
50
|
+
function stopEvent(text) {
|
|
51
|
+
return { messages: [{ role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] }] };
|
|
52
|
+
}
|
|
53
|
+
/** Count of report files written under a node's reports/ dir (0 when none). */
|
|
54
|
+
function reportCount(id) {
|
|
55
|
+
const dir = reportsDir(id);
|
|
56
|
+
return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')).length : 0;
|
|
57
|
+
}
|
|
58
|
+
before(() => {
|
|
59
|
+
origNode = process.env['CRTR_NODE_ID'];
|
|
60
|
+
origPane = process.env['TMUX_PANE'];
|
|
61
|
+
});
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
closeDb();
|
|
64
|
+
if (home)
|
|
65
|
+
rmSync(home, { recursive: true, force: true });
|
|
66
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-stophook-end-'));
|
|
67
|
+
process.env['CRTR_HOME'] = home;
|
|
68
|
+
// Force the clean-shutdown path (no in-place respawn) so the refresh test is
|
|
69
|
+
// deterministic even when the suite runs inside a tmux pane.
|
|
70
|
+
delete process.env['TMUX_PANE'];
|
|
71
|
+
});
|
|
72
|
+
after(() => {
|
|
73
|
+
closeDb();
|
|
74
|
+
if (home)
|
|
75
|
+
rmSync(home, { recursive: true, force: true });
|
|
76
|
+
delete process.env['CRTR_HOME'];
|
|
77
|
+
if (origNode === undefined)
|
|
78
|
+
delete process.env['CRTR_NODE_ID'];
|
|
79
|
+
else
|
|
80
|
+
process.env['CRTR_NODE_ID'] = origNode;
|
|
81
|
+
if (origPane === undefined)
|
|
82
|
+
delete process.env['TMUX_PANE'];
|
|
83
|
+
else
|
|
84
|
+
process.env['TMUX_PANE'] = origPane;
|
|
85
|
+
});
|
|
86
|
+
test('natural stop while awaiting a live worker → idle-release with NO push (no report, no inbox pointer)', () => {
|
|
87
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
88
|
+
createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
|
|
89
|
+
createNode(node('worker', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
|
|
90
|
+
subscribe('root', 'mgr', true); // root would receive any push mgr emits
|
|
91
|
+
subscribe('mgr', 'worker', true); // mgr holds an active live subscription → "awaiting"
|
|
92
|
+
process.env['CRTR_NODE_ID'] = 'mgr';
|
|
93
|
+
const pi = makeFakePi();
|
|
94
|
+
registerCanvasStophook(pi);
|
|
95
|
+
let shutdown = false;
|
|
96
|
+
pi.fire('agent_end', stopEvent('still waiting on the worker'), { shutdown: () => { shutdown = true; } });
|
|
97
|
+
const m = getNode('mgr');
|
|
98
|
+
assert.equal(m?.intent, 'idle-release', 'mgr idle-released');
|
|
99
|
+
assert.equal(m?.status, 'idle', 'mgr marked idle');
|
|
100
|
+
assert.equal(shutdown, true, 'pi shut down');
|
|
101
|
+
assert.equal(reportCount('mgr'), 0, 'NO report file written');
|
|
102
|
+
assert.equal(readInboxSince('root').length, 0, 'NO inbox pointer fanned to subscriber');
|
|
103
|
+
assert.equal(pi.injected.length, 0, 'no reprompt on a legitimate idle-release');
|
|
104
|
+
// §5.1 case 6 (awaiting + UNFOCUSED → idle-release, no focus): the awaiting
|
|
105
|
+
// branch must never create/touch a focus row. Non-vacuous: an impl that ran
|
|
106
|
+
// the done-branch handoff/openFocus on an idle-release would leave a row here.
|
|
107
|
+
assert.equal(listFocuses().length, 0, 'awaiting+unfocused leaves the focuses table empty');
|
|
108
|
+
});
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// §5.1 — the §1.7 agent_end branch map on the focuses table. Every assertion is
|
|
111
|
+
// on the canvas focuses/runtime rows after firing agent_end (TMUX_PANE is
|
|
112
|
+
// cleared in beforeEach, so the focus helpers in the handler are pure DB and the
|
|
113
|
+
// '%pane' ids below are never read by tmux). status='done' is reached by setting
|
|
114
|
+
// the runtime row directly (the branch reads getNode(nodeId).status).
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
test('§5.1.1 truly-done + focused + manager-not-focused → MANAGER TAKEOVER of the focus row', () => {
|
|
117
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
118
|
+
createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
|
|
119
|
+
// M starts WITH a recorded LOCATION so the MINOR presence-null is observable.
|
|
120
|
+
createNode(node('M', { parent: 'mgr', lifecycle: 'terminal', pane: '%m', tmux_session: 'Suser', window: '@wm' }));
|
|
121
|
+
subscribe('mgr', 'M', true);
|
|
122
|
+
openFocusRow('fM', '%m', 'Suser', 'M');
|
|
123
|
+
process.env['CRTR_NODE_ID'] = 'M';
|
|
124
|
+
setStatus('M', 'done'); // pushed final this turn
|
|
125
|
+
const pi = makeFakePi();
|
|
126
|
+
registerCanvasStophook(pi);
|
|
127
|
+
let shutdown = false;
|
|
128
|
+
pi.fire('agent_end', stopEvent('done — pushed final'), { shutdown: () => { shutdown = true; } });
|
|
129
|
+
// managerId = M.parent = 'mgr' (not focused elsewhere, no live pane here → the
|
|
130
|
+
// DORMANT-takeover path) → handFocusToManager repoints fM's occupant M→mgr. The
|
|
131
|
+
// daemon later revives mgr INTO M's frozen focus pane. Non-vacuous: a no-op (no
|
|
132
|
+
// handoff) impl leaves M as occupant, so getFocusByNode('mgr') is null AND
|
|
133
|
+
// getFocusByNode('M') still names fM — both asserts fail.
|
|
134
|
+
assert.equal(getFocusByNode('mgr')?.focus_id, 'fM', 'focus row taken over by the manager');
|
|
135
|
+
assert.equal(getFocusByNode('M'), null, 'the finished node no longer occupies any focus');
|
|
136
|
+
assert.equal(shutdown, true, 'pi shut down after the handoff');
|
|
137
|
+
// MINOR: after a successful takeover M (done) owns no pane (Invariant P) — its
|
|
138
|
+
// own presence is nulled so two rows never reference %m. Non-vacuous: an impl
|
|
139
|
+
// that skips the done-path setPresence-null leaves getNode('M').pane === '%m'.
|
|
140
|
+
assert.equal(getNode('M')?.pane ?? null, null, 'the finished node\'s own LOCATION pane is nulled');
|
|
141
|
+
assert.equal(getNode('M')?.window ?? null, null, 'the finished node\'s window presence is nulled too');
|
|
142
|
+
});
|
|
143
|
+
test('§5.1.2 truly-done + focused + NO manager (root) → focus row CLOSED (Q1)', () => {
|
|
144
|
+
// R carries a LOCATION so the close-path presence-null is observable.
|
|
145
|
+
createNode(node('R', { parent: null, lifecycle: 'terminal', pane: '%r', tmux_session: 'Suser', window: '@wr' }));
|
|
146
|
+
openFocusRow('fR', '%r', 'Suser', 'R');
|
|
147
|
+
process.env['CRTR_NODE_ID'] = 'R';
|
|
148
|
+
setStatus('R', 'done');
|
|
149
|
+
const pi = makeFakePi();
|
|
150
|
+
registerCanvasStophook(pi);
|
|
151
|
+
pi.fire('agent_end', stopEvent('root done'), { shutdown: () => { } });
|
|
152
|
+
// managerId = R.parent(null) ?? subscribersOf(R)[0](none) = null →
|
|
153
|
+
// handFocusToManager returns false → the close path: closeFocusRow(fR) +
|
|
154
|
+
// setRemainOnExit(%r's window, false) (return-to-shell) + null R's presence.
|
|
155
|
+
// Non-vacuous: a takeover-instead-of-close impl would leave the row present;
|
|
156
|
+
// an impl that skips the MINOR presence-null leaves getNode('R').pane === '%r'.
|
|
157
|
+
assert.equal(getFocusById('fR'), null, 'a manager-less finished focus is closed, not handed off');
|
|
158
|
+
assert.equal(listFocuses().length, 0, 'no focus rows survive');
|
|
159
|
+
assert.equal(getNode('R')?.pane ?? null, null, 'the finished root\'s own LOCATION pane is nulled (close path reaps)');
|
|
160
|
+
});
|
|
161
|
+
test('§5.1.3 truly-done + focused + manager ALREADY focused elsewhere → focus CLOSED, manager UNMOVED', () => {
|
|
162
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
163
|
+
createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
|
|
164
|
+
createNode(node('M', { parent: 'mgr', lifecycle: 'terminal', pane: '%m', tmux_session: 'Sa', window: '@wm' }));
|
|
165
|
+
subscribe('mgr', 'M', true);
|
|
166
|
+
openFocusRow('fOther', '%o', 'Sb', 'mgr'); // mgr already on its OWN viewport
|
|
167
|
+
openFocusRow('fM', '%m', 'Sa', 'M');
|
|
168
|
+
process.env['CRTR_NODE_ID'] = 'M';
|
|
169
|
+
setStatus('M', 'done');
|
|
170
|
+
const pi = makeFakePi();
|
|
171
|
+
registerCanvasStophook(pi);
|
|
172
|
+
pi.fire('agent_end', stopEvent('M done'), { shutdown: () => { } });
|
|
173
|
+
// handFocusToManager sees getFocusByNode('mgr') != null → returns false →
|
|
174
|
+
// closeFocusRow(fM). Non-vacuous: moving mgr would either repoint its focus_id
|
|
175
|
+
// to fM (and a wrong impl that didn't close fM would leave it present) or throw
|
|
176
|
+
// UNIQUE(node_id); this pins mgr's OTHER focus untouched and M's focus gone.
|
|
177
|
+
assert.equal(getFocusById('fM'), null, "M's focus is closed");
|
|
178
|
+
assert.equal(getFocusByNode('mgr')?.focus_id, 'fOther', "the manager's other viewport is NOT stolen");
|
|
179
|
+
// MINOR: M (done) is reaped on the close path — its own presence nulled.
|
|
180
|
+
// Non-vacuous: an impl that skips the done-path setPresence-null leaves
|
|
181
|
+
// getNode('M').pane === '%m'.
|
|
182
|
+
assert.equal(getNode('M')?.pane ?? null, null, "the finished node's own LOCATION pane is nulled");
|
|
183
|
+
});
|
|
184
|
+
test('§5.1.4 truly-done + UNFOCUSED → no focus row created/touched, shuts down (Invariant P)', () => {
|
|
185
|
+
createNode(node('mgr', { parent: null, lifecycle: 'terminal', mode: 'orchestrator' }));
|
|
186
|
+
createNode(node('M', { parent: 'mgr', lifecycle: 'terminal' }));
|
|
187
|
+
subscribe('mgr', 'M', true);
|
|
188
|
+
process.env['CRTR_NODE_ID'] = 'M';
|
|
189
|
+
setStatus('M', 'done');
|
|
190
|
+
const pi = makeFakePi();
|
|
191
|
+
registerCanvasStophook(pi);
|
|
192
|
+
let shutdown = false;
|
|
193
|
+
pi.fire('agent_end', stopEvent('done, never had a viewport'), { shutdown: () => { shutdown = true; } });
|
|
194
|
+
// focusOf(M) is null → the focus block is skipped entirely → just shutdown.
|
|
195
|
+
// Non-vacuous: an impl that created or handed off a focus row would leave
|
|
196
|
+
// listFocuses non-empty.
|
|
197
|
+
assert.equal(shutdown, true, 'an unfocused done node shuts down');
|
|
198
|
+
assert.equal(listFocuses().length, 0, 'no focus row was created or touched');
|
|
199
|
+
});
|
|
200
|
+
test('§5.1.5 awaiting + focused → idle-release FREEZE: the focus row SURVIVES untouched (F3)', () => {
|
|
201
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
202
|
+
createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
|
|
203
|
+
createNode(node('worker', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
|
|
204
|
+
subscribe('root', 'mgr', true);
|
|
205
|
+
subscribe('mgr', 'worker', true); // mgr awaits a live worker → idle-release
|
|
206
|
+
openFocusRow('fMgr', '%g', 'Suser', 'mgr');
|
|
207
|
+
process.env['CRTR_NODE_ID'] = 'mgr';
|
|
208
|
+
const pi = makeFakePi();
|
|
209
|
+
registerCanvasStophook(pi);
|
|
210
|
+
pi.fire('agent_end', stopEvent('still waiting on the worker'), { shutdown: () => { } });
|
|
211
|
+
// The awaiting branch only transition('release')s — it must NOT close or
|
|
212
|
+
// repoint the focus (that is the done branch). Non-vacuous: a wrong impl that
|
|
213
|
+
// routed an idle-release through the done-branch handoff/close would change or
|
|
214
|
+
// remove fMgr.
|
|
215
|
+
assert.equal(getNode('mgr')?.intent, 'idle-release', 'mgr idle-released (frozen)');
|
|
216
|
+
assert.equal(getFocusByNode('mgr')?.focus_id, 'fMgr', 'the focus row is UNCHANGED — not closed, not handed off');
|
|
217
|
+
});
|
|
218
|
+
test('§5.1.7 resident attended (no live subs) → nothing happens; focus + status survive', () => {
|
|
219
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
220
|
+
openFocusRow('fR', '%r', 'Suser', 'root');
|
|
221
|
+
process.env['CRTR_NODE_ID'] = 'root';
|
|
222
|
+
const pi = makeFakePi();
|
|
223
|
+
registerCanvasStophook(pi);
|
|
224
|
+
let shutdown = false;
|
|
225
|
+
pi.fire('agent_end', stopEvent('I have wrapped up'), { shutdown: () => { shutdown = true; } });
|
|
226
|
+
// evaluateStop on a resident → reason 'dormant' (NOT 'awaiting'), so the
|
|
227
|
+
// awaiting branch is skipped and the handler does nothing: no release, no
|
|
228
|
+
// shutdown, no focus touch. Non-vacuous: an impl that idle-released a resident
|
|
229
|
+
// would flip status→idle / intent→idle-release; one that touched focus would
|
|
230
|
+
// change/remove fR.
|
|
231
|
+
assert.equal(getNode('root')?.status, 'active', 'a resident is never forced dormant');
|
|
232
|
+
assert.equal(getNode('root')?.intent ?? null, null, 'no idle-release intent on a resident');
|
|
233
|
+
assert.equal(getFocusByNode('root')?.focus_id, 'fR', 'focus row survives untouched');
|
|
234
|
+
assert.equal(shutdown, false, 'a resident attended node is not shut down');
|
|
235
|
+
});
|
|
236
|
+
test('refresh-yield (intent=refresh) writes NO push — silent to subscribers', () => {
|
|
237
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
238
|
+
createNode(node('orch', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator', intent: 'refresh' }));
|
|
239
|
+
subscribe('root', 'orch', true);
|
|
240
|
+
process.env['CRTR_NODE_ID'] = 'orch';
|
|
241
|
+
const pi = makeFakePi();
|
|
242
|
+
registerCanvasStophook(pi);
|
|
243
|
+
let shutdown = false;
|
|
244
|
+
pi.fire('agent_end', stopEvent('checkpoint before refreshing'), { shutdown: () => { shutdown = true; } });
|
|
245
|
+
assert.equal(shutdown, true, 'pi shut down (no tmux pane → clean shutdown)');
|
|
246
|
+
assert.equal(reportCount('orch'), 0, 'a yield is silent: NO report file');
|
|
247
|
+
assert.equal(readInboxSince('root').length, 0, 'a yield is silent: NO inbox pointer');
|
|
248
|
+
assert.equal(pi.injected.length, 0, 'no reprompt on a refresh-yield');
|
|
249
|
+
});
|
|
250
|
+
test('stalled leaf (nothing live to await, no final) is still reprompted', () => {
|
|
251
|
+
createNode(node('mgr', { parent: null, lifecycle: 'terminal', mode: 'orchestrator' }));
|
|
252
|
+
createNode(node('leaf', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
|
|
253
|
+
subscribe('mgr', 'leaf', true); // mgr subscribes to leaf; leaf itself awaits nothing
|
|
254
|
+
process.env['CRTR_NODE_ID'] = 'leaf';
|
|
255
|
+
const pi = makeFakePi();
|
|
256
|
+
registerCanvasStophook(pi);
|
|
257
|
+
let shutdown = false;
|
|
258
|
+
pi.fire('agent_end', stopEvent('I think I am basically done here'), { shutdown: () => { shutdown = true; } });
|
|
259
|
+
assert.equal(pi.injected.length, 1, 'the stall reprompt fired');
|
|
260
|
+
assert.equal(pi.injected[0].content, STALL_REPROMPT, 'reprompt carries the stall nudge to push final / ask');
|
|
261
|
+
assert.equal(pi.injected[0].deliverAs, 'followUp', 'reprompt delivered as a followUp');
|
|
262
|
+
assert.equal(shutdown, false, 'a stalled leaf is NOT shut down — it is re-prompted to finish');
|
|
263
|
+
assert.notEqual(getNode('leaf')?.intent, 'idle-release', 'a stalled leaf does not idle-release');
|
|
264
|
+
assert.equal(reportCount('leaf'), 0, 'NO report file written on a stall');
|
|
265
|
+
assert.equal(readInboxSince('mgr').length, 0, 'NO inbox pointer fanned on a stall');
|
|
266
|
+
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// canvas-commands.ts — pi extension registering canvas slash-commands on nodes.
|
|
2
2
|
//
|
|
3
|
-
// /promote [kind] — promote THIS node to
|
|
3
|
+
// /promote [kind] — promote THIS node to an orchestrator. Runs
|
|
4
4
|
// `crtr node promote --json` for CRTR_NODE_ID (optionally specializing its
|
|
5
|
-
// kind), then
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// kind), then triggers a turn. The orchestration guidance is injected
|
|
6
|
+
// CENTRALLY by the persona injector (canvas-stophook turn_end) at the turn
|
|
7
|
+
// boundary — the same path the node gets by running the command itself by
|
|
8
|
+
// hand — surfaced as a one-keystroke affordance.
|
|
9
9
|
//
|
|
10
10
|
// The Alt+C tmux action menu's "promote to orchestrator" item (key `o`) simply
|
|
11
11
|
// send-keys `/promote` into the active pane, so the menu and the slash command
|
|
@@ -46,7 +46,7 @@ export function registerCanvasCommands(pi) {
|
|
|
46
46
|
if (nodeId === undefined || nodeId.trim() === '')
|
|
47
47
|
return; // not a canvas node
|
|
48
48
|
pi.registerCommand('promote', {
|
|
49
|
-
description: 'Promote this node to
|
|
49
|
+
description: 'Promote this node to an orchestrator — /promote, or /promote <kind> to specialize',
|
|
50
50
|
getArgumentCompletions: (prefix) => {
|
|
51
51
|
const items = kinds()
|
|
52
52
|
.filter((k) => k.startsWith(prefix))
|
|
@@ -87,13 +87,16 @@ export function registerCanvasCommands(pi) {
|
|
|
87
87
|
}
|
|
88
88
|
const rmPath = (result.roadmap_path ?? '').trim();
|
|
89
89
|
ctx.ui.notify(`Promoted to ${result.kind ?? 'orchestrator'} orchestrator — authoring roadmap${rmPath !== '' ? ` (${rmPath})` : ''}.`, 'info');
|
|
90
|
-
// The guidance is
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
// The orchestration guidance is no longer returned by the command — the
|
|
91
|
+
// persona injector (canvas-stophook turn_end) is the single source and
|
|
92
|
+
// steers it in at the next turn boundary. Trigger a turn so the node wakes
|
|
93
|
+
// and the injector fires, exactly as when the node runs `crtr node
|
|
94
|
+
// promote` itself by hand.
|
|
95
|
+
pi.sendMessage({
|
|
96
|
+
customType: 'crtr-promote',
|
|
97
|
+
content: 'You have just been promoted to an orchestrator. Your new-role guidance is arriving — read it, author your roadmap, and start delegating.',
|
|
98
|
+
display: false,
|
|
99
|
+
}, { triggerTurn: true });
|
|
97
100
|
},
|
|
98
101
|
});
|
|
99
102
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** The `customType` stamped on the injected session message. Used both to write
|
|
2
|
+
* the entry and to detect it on resume (the idempotency guard). */
|
|
3
|
+
export declare const CONTEXT_INTRO_CUSTOM_TYPE = "crtr-context";
|
|
4
|
+
interface SessionEntryLike {
|
|
5
|
+
type: string;
|
|
6
|
+
customType?: string;
|
|
7
|
+
}
|
|
8
|
+
interface SessionStartCtxLike {
|
|
9
|
+
sessionManager: {
|
|
10
|
+
getEntries: () => SessionEntryLike[];
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
interface CustomMessageLike {
|
|
14
|
+
customType: string;
|
|
15
|
+
content: string;
|
|
16
|
+
display?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** The message handed to a message renderer. `content` is normally the string we
|
|
19
|
+
* sent, but pi types it as string-or-blocks, so we handle both. */
|
|
20
|
+
interface RenderedMessageLike {
|
|
21
|
+
customType: string;
|
|
22
|
+
content: string | Array<{
|
|
23
|
+
type: string;
|
|
24
|
+
text?: string;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
/** Minimal structural match for pi-tui's `Component` (render + invalidate). A
|
|
28
|
+
* plain object of this shape is a valid child for pi's Container. */
|
|
29
|
+
interface ComponentLike {
|
|
30
|
+
render: (width: number) => string[];
|
|
31
|
+
invalidate: () => void;
|
|
32
|
+
}
|
|
33
|
+
/** Subset of pi's `Theme` we touch — `fg(color, text)` wraps text in ANSI. Used
|
|
34
|
+
* defensively (falls back to plain text if absent). */
|
|
35
|
+
interface ThemeLike {
|
|
36
|
+
fg?: (color: string, text: string) => string;
|
|
37
|
+
}
|
|
38
|
+
interface PiLike {
|
|
39
|
+
on: (event: 'session_start', handler: (event: unknown, ctx: SessionStartCtxLike) => void | Promise<void>) => void;
|
|
40
|
+
sendMessage: (message: CustomMessageLike, options?: {
|
|
41
|
+
deliverAs?: string;
|
|
42
|
+
triggerTurn?: boolean;
|
|
43
|
+
}) => void;
|
|
44
|
+
registerMessageRenderer: (customType: string, renderer: (message: RenderedMessageLike, options: {
|
|
45
|
+
expanded?: boolean;
|
|
46
|
+
}, theme: ThemeLike) => ComponentLike | undefined) => void;
|
|
47
|
+
}
|
|
48
|
+
/** Build the <crtr-context> bearings block for `nodeId`. Thin wrapper over the
|
|
49
|
+
* shared builder in core/runtime/bearings.ts (the single source of truth, also
|
|
50
|
+
* used by the promotion guidance dump). Exported for testing. */
|
|
51
|
+
export declare function buildContextIntro(nodeId: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Renderer for `crtr-context` messages. Collapsed (default) shows a one-line
|
|
54
|
+
* stub; expanded (Ctrl+O) shows the label + full body. Returns a plain object
|
|
55
|
+
* matching pi's structural `Component` interface — no pi-tui import. Exported for
|
|
56
|
+
* testing.
|
|
57
|
+
*/
|
|
58
|
+
export declare function renderContextMessage(message: RenderedMessageLike, options: {
|
|
59
|
+
expanded?: boolean;
|
|
60
|
+
}, theme: ThemeLike): ComponentLike;
|
|
61
|
+
/**
|
|
62
|
+
* Register the context-intro preamble on `pi`.
|
|
63
|
+
*
|
|
64
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent. On `session_start`
|
|
65
|
+
* it injects the <crtr-context> block as the first message of a brand-new chat
|
|
66
|
+
* — but only when the session does not already carry it, so a `--session <id>`
|
|
67
|
+
* relaunch (which restores the conversation) never duplicates the block.
|
|
68
|
+
*/
|
|
69
|
+
export declare function registerCanvasContextIntro(pi: PiLike): void;
|
|
70
|
+
export default registerCanvasContextIntro;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// canvas-context-intro.ts — pi extension for pi-native canvas agent nodes.
|
|
2
|
+
//
|
|
3
|
+
// Loaded into every canvas node's pi process via the node's launch.extensions
|
|
4
|
+
// list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
|
|
5
|
+
//
|
|
6
|
+
// The bearings preamble. On `session_start` — which fires BEFORE the node's
|
|
7
|
+
// first user message enters the session — this injects ONE <crtr-context>
|
|
8
|
+
// message via `pi.sendMessage` (no delivery options, so at the idle start it is
|
|
9
|
+
// pushed straight onto the message list and persisted). Because the session is
|
|
10
|
+
// still empty at that point, the bearings land as the FIRST entry, ahead of the
|
|
11
|
+
// node's first prompt — the orienting frame, not a trailing afterthought.
|
|
12
|
+
// (before_agent_start / deliverAs:"nextTurn" both append AFTER the user
|
|
13
|
+
// message — see agent-session's submit path — which is why we use
|
|
14
|
+
// session_start instead.)
|
|
15
|
+
//
|
|
16
|
+
// The block carries: the path to the node's own context dir and the framing for
|
|
17
|
+
// what belongs there (a shared document store for the other nodes). Resident
|
|
18
|
+
// orchestrators additionally get the across-refresh-cycles framing + a <memory>
|
|
19
|
+
// block merging the indexes of their three scoped memory stores (user-global,
|
|
20
|
+
// project, node-local), each labeled with its absolute dir + index path. The
|
|
21
|
+
// prose lives in core/runtime/bearings.ts (shared with the promotion guidance
|
|
22
|
+
// dump), which gates the memory block on the node having a node-local store — so
|
|
23
|
+
// a terminal worker gets no memory framing at all.
|
|
24
|
+
//
|
|
25
|
+
// IDEMPOTENT across resumes: a `--session` relaunch restores the conversation,
|
|
26
|
+
// so the block is already in history; the session_start handler sees it via
|
|
27
|
+
// `sessionManager.getEntries()` and skips, so it never accumulates.
|
|
28
|
+
//
|
|
29
|
+
// COLLAPSED BY DEFAULT: a `registerMessageRenderer` keyed to our customType
|
|
30
|
+
// renders the block as a single one-line stub; the full body only appears when
|
|
31
|
+
// the user expands tool output (Ctrl+O / `app.tools.expand`). pi drives this via
|
|
32
|
+
// `CustomMessageComponent.setExpanded(toolOutputExpanded)`, so the same toggle
|
|
33
|
+
// that expands tool results expands the bearings. The renderer returns a plain
|
|
34
|
+
// object satisfying pi's structural `Component` interface ({ render, invalidate })
|
|
35
|
+
// — no pi-tui class needed. The LLM always sees the full `content` regardless of
|
|
36
|
+
// how it renders; the renderer is display-only.
|
|
37
|
+
//
|
|
38
|
+
// Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
|
|
39
|
+
// crouter's own tsc build without a dep on the pi packages.
|
|
40
|
+
import { buildContextBearings } from '../core/runtime/bearings.js';
|
|
41
|
+
/** The `customType` stamped on the injected session message. Used both to write
|
|
42
|
+
* the entry and to detect it on resume (the idempotency guard). */
|
|
43
|
+
export const CONTEXT_INTRO_CUSTOM_TYPE = 'crtr-context';
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Block builder
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
/** Build the <crtr-context> bearings block for `nodeId`. Thin wrapper over the
|
|
48
|
+
* shared builder in core/runtime/bearings.ts (the single source of truth, also
|
|
49
|
+
* used by the promotion guidance dump). Exported for testing. */
|
|
50
|
+
export function buildContextIntro(nodeId) {
|
|
51
|
+
return buildContextBearings(nodeId);
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Collapsed-by-default rendering
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
/** Pull the plain text out of a custom message's content (string or blocks). */
|
|
57
|
+
function messageText(message) {
|
|
58
|
+
if (typeof message.content === 'string')
|
|
59
|
+
return message.content;
|
|
60
|
+
return message.content
|
|
61
|
+
.filter((c) => c.type === 'text' && typeof c.text === 'string')
|
|
62
|
+
.map((c) => c.text)
|
|
63
|
+
.join('\n');
|
|
64
|
+
}
|
|
65
|
+
/** Hard-wrap a single logical line to `width` columns (content carries no ANSI).
|
|
66
|
+
* Code-point aware so wide-string slicing never splits a surrogate pair; the
|
|
67
|
+
* bearings prose is plain text, so code-point count == visible columns. */
|
|
68
|
+
function wrapLine(line, width) {
|
|
69
|
+
if (width <= 0)
|
|
70
|
+
return [''];
|
|
71
|
+
const chars = Array.from(line);
|
|
72
|
+
if (chars.length <= width)
|
|
73
|
+
return [line];
|
|
74
|
+
const out = [];
|
|
75
|
+
for (let i = 0; i < chars.length; i += width)
|
|
76
|
+
out.push(chars.slice(i, i + width).join(''));
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Truncate plain text to at most `width` columns, appending an ellipsis when it
|
|
80
|
+
* would overflow. Content here is ANSI-free plain text (label + prose), so a
|
|
81
|
+
* code-point count stands in for visible width. The renderer MUST keep every
|
|
82
|
+
* emitted line within the terminal width or pi's TUI aborts the whole render. */
|
|
83
|
+
function truncateToWidth(text, width) {
|
|
84
|
+
if (width <= 0)
|
|
85
|
+
return '';
|
|
86
|
+
const chars = Array.from(text);
|
|
87
|
+
if (chars.length <= width)
|
|
88
|
+
return text;
|
|
89
|
+
if (width === 1)
|
|
90
|
+
return '…';
|
|
91
|
+
return chars.slice(0, width - 1).join('') + '…';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Renderer for `crtr-context` messages. Collapsed (default) shows a one-line
|
|
95
|
+
* stub; expanded (Ctrl+O) shows the label + full body. Returns a plain object
|
|
96
|
+
* matching pi's structural `Component` interface — no pi-tui import. Exported for
|
|
97
|
+
* testing.
|
|
98
|
+
*/
|
|
99
|
+
export function renderContextMessage(message, options, theme) {
|
|
100
|
+
const expanded = options?.expanded === true;
|
|
101
|
+
const paint = (color, text) => typeof theme?.fg === 'function' ? theme.fg(color, text) : text;
|
|
102
|
+
return {
|
|
103
|
+
render(width) {
|
|
104
|
+
const w = typeof width === 'number' && width > 0 ? width : 80;
|
|
105
|
+
if (!expanded) {
|
|
106
|
+
// Truncate BEFORE painting so the ANSI wrapper never inflates the
|
|
107
|
+
// measured width; an over-wide line aborts pi's entire TUI render.
|
|
108
|
+
const stub = `[${CONTEXT_INTRO_CUSTOM_TYPE}] orienting bearings — ctrl+o to expand`;
|
|
109
|
+
return [paint('dim', truncateToWidth(stub, w))];
|
|
110
|
+
}
|
|
111
|
+
const lines = [paint('customMessageLabel', truncateToWidth(`[${CONTEXT_INTRO_CUSTOM_TYPE}]`, w)), ''];
|
|
112
|
+
for (const raw of messageText(message).split('\n')) {
|
|
113
|
+
for (const wrapped of wrapLine(raw, w))
|
|
114
|
+
lines.push(paint('customMessageText', wrapped));
|
|
115
|
+
}
|
|
116
|
+
return lines;
|
|
117
|
+
},
|
|
118
|
+
invalidate() {
|
|
119
|
+
/* stateless — nothing to clear */
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Extension
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
/**
|
|
127
|
+
* Register the context-intro preamble on `pi`.
|
|
128
|
+
*
|
|
129
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent. On `session_start`
|
|
130
|
+
* it injects the <crtr-context> block as the first message of a brand-new chat
|
|
131
|
+
* — but only when the session does not already carry it, so a `--session <id>`
|
|
132
|
+
* relaunch (which restores the conversation) never duplicates the block.
|
|
133
|
+
*/
|
|
134
|
+
export function registerCanvasContextIntro(pi) {
|
|
135
|
+
// Collapse the block to a one-liner until the user expands tool output (Ctrl+O).
|
|
136
|
+
// Harmless to register outside TUI mode (it's only consulted while rendering).
|
|
137
|
+
pi.registerMessageRenderer(CONTEXT_INTRO_CUSTOM_TYPE, renderContextMessage);
|
|
138
|
+
pi.on('session_start', (_event, ctx) => {
|
|
139
|
+
try {
|
|
140
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
141
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
142
|
+
return; // not a canvas node
|
|
143
|
+
// Idempotent: a restored/reloaded session already carries the block.
|
|
144
|
+
const present = ctx.sessionManager
|
|
145
|
+
.getEntries()
|
|
146
|
+
.some((e) => e.type === 'custom_message' && e.customType === CONTEXT_INTRO_CUSTOM_TYPE);
|
|
147
|
+
if (present)
|
|
148
|
+
return;
|
|
149
|
+
// No delivery options: at the idle start of a session this is pushed onto
|
|
150
|
+
// the (still empty) message list and persisted immediately, so it precedes
|
|
151
|
+
// the node's first prompt.
|
|
152
|
+
pi.sendMessage({
|
|
153
|
+
customType: CONTEXT_INTRO_CUSTOM_TYPE,
|
|
154
|
+
content: buildContextIntro(nodeId),
|
|
155
|
+
display: true,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Best-effort: a failure here must never break session startup.
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
export default registerCanvasContextIntro;
|
|
@@ -6,6 +6,9 @@ interface InputEventLike {
|
|
|
6
6
|
}
|
|
7
7
|
interface PiLike {
|
|
8
8
|
on: (event: 'input', handler: (event: InputEventLike, ctx: any) => void) => void;
|
|
9
|
+
/** Update the live session display name (pi's editor label). Present in
|
|
10
|
+
* interactive mode; optional so the extension stays inert where it's not. */
|
|
11
|
+
setSessionName?: (name: string) => void;
|
|
9
12
|
}
|
|
10
13
|
/**
|
|
11
14
|
* Register the goal-capture handler on `pi`.
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
// Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
|
|
20
20
|
// crouter's own tsc build without a dep on the pi packages.
|
|
21
21
|
import { captureGoalIfAbsent, REVIVE_KICKOFF_SENTINEL } from '../core/runtime/kickoff.js';
|
|
22
|
+
import { generateAndPersistName } from '../core/runtime/naming.js';
|
|
23
|
+
import { editorLabel } from '../core/canvas/index.js';
|
|
22
24
|
/**
|
|
23
25
|
* Register the goal-capture handler on `pi`.
|
|
24
26
|
*
|
|
@@ -43,7 +45,19 @@ export function registerCanvasGoalCapture(pi) {
|
|
|
43
45
|
// masquerade as the user's first mandate.
|
|
44
46
|
if (text.startsWith(REVIVE_KICKOFF_SENTINEL))
|
|
45
47
|
return;
|
|
46
|
-
|
|
48
|
+
// First mandate for a bare root: persist it as the goal, and ask pi
|
|
49
|
+
// (async, non-blocking) to name the session from it. The name lands on
|
|
50
|
+
// meta.description; the onNamed callback pushes the new editor label into
|
|
51
|
+
// THIS live session via setSessionName, so it updates immediately instead
|
|
52
|
+
// of only on the next cycle.
|
|
53
|
+
if (captureGoalIfAbsent(nodeId, text)) {
|
|
54
|
+
generateAndPersistName(nodeId, text, (meta) => {
|
|
55
|
+
try {
|
|
56
|
+
pi.setSessionName?.(editorLabel(meta));
|
|
57
|
+
}
|
|
58
|
+
catch { /* best-effort */ }
|
|
59
|
+
});
|
|
60
|
+
}
|
|
47
61
|
}
|
|
48
62
|
catch {
|
|
49
63
|
// Best-effort: a capture failure must never drop or alter the message.
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
// Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
|
|
33
33
|
// crouter's own tsc build without a dep on the pi packages.
|
|
34
34
|
import { readInboxSince, readCursor, writeCursor, coalesce, } from '../core/feed/inbox.js';
|
|
35
|
+
import { getNode } from '../core/canvas/index.js';
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
36
37
|
// Module-level timer — prevents stacking on /reload (the double-notify bug).
|
|
37
38
|
//
|
|
@@ -179,6 +180,16 @@ export function registerCanvasInboxWatcher(pi) {
|
|
|
179
180
|
seeded = true;
|
|
180
181
|
}
|
|
181
182
|
const newEntries = readInboxSince(nodeId, cursor);
|
|
183
|
+
// Refresh-yield in flight: the node ran `crtr node yield` and is about to be
|
|
184
|
+
// torn down and revived fresh. Hold everything — don't consume the cursor
|
|
185
|
+
// (advancing it past these entries would drop them on tear-down) and don't
|
|
186
|
+
// deliver (steering a child's `final` into the yielding turn hijacks the
|
|
187
|
+
// clean stop the refresh path depends on, which is how a yield got derailed
|
|
188
|
+
// mid-flight). The fresh pi re-reads the feed on boot. getNode only when
|
|
189
|
+
// there's actual work pending, so idle ticks stay cheap.
|
|
190
|
+
if ((newEntries.length > 0 || buffer.length > 0) && getNode(nodeId)?.intent === 'refresh') {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
182
193
|
if (newEntries.length > 0) {
|
|
183
194
|
// Advance and persist the cursor BEFORE buffering, so a crash after this
|
|
184
195
|
// point loses at most one coalesced message rather than re-injecting
|