@crouton-kit/crouter 0.3.14 → 0.3.16
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 +45 -67
- package/dist/commands/human.js +4 -14
- package/dist/commands/node.d.ts +11 -0
- package/dist/commands/node.js +221 -99
- 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 +129 -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 +196 -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 +266 -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 +178 -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 +334 -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 +105 -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 +205 -4
- package/dist/core/canvas/focuses.d.ts +22 -0
- package/dist/core/canvas/focuses.js +81 -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 +24 -12
- 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 +26 -12
- package/dist/core/runtime/launch.js +78 -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 +39 -1
- package/dist/core/runtime/nodes.js +69 -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 +299 -0
- package/dist/core/runtime/placement.js +688 -0
- 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-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +113 -20
- package/dist/core/runtime/tmux.js +221 -39
- 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 +594 -262
- package/dist/pi-extensions/canvas-resume.d.ts +22 -0
- package/dist/pi-extensions/canvas-resume.js +173 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
- package/dist/pi-extensions/canvas-stophook.js +340 -228
- package/dist/types.d.ts +28 -0
- package/dist/types.js +16 -0
- package/package.json +2 -2
- package/dist/core/runtime/presence.d.ts +0 -38
- package/dist/core/runtime/presence.js +0 -154
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
// (a) stopReason is 'aborted' or 'error' → stay alive for re-steering; return.
|
|
17
17
|
// (b) node.status is already 'done' (agent called `crtr push --final` this
|
|
18
18
|
// turn, which sets status synchronously) → shut down; work is complete.
|
|
19
|
-
// (c) Natural stop ('stop' | 'length') —
|
|
20
|
-
//
|
|
19
|
+
// (c) Natural stop ('stop' | 'length') — run the stop-guard (the node is
|
|
20
|
+
// NEVER auto-pushed; it reports only via its own explicit `crtr push`):
|
|
21
21
|
// • 'reprompt' → pi.sendUserMessage so the node finishes or escalates.
|
|
22
22
|
// • 'allow' (awaiting) → idle-release: free the tmux window and shut
|
|
23
23
|
// down; the daemon watches the inbox and revives it
|
|
@@ -28,18 +28,20 @@
|
|
|
28
28
|
// crouter's own tsc build without a dep on the pi packages.
|
|
29
29
|
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
30
30
|
import { join } from 'node:path';
|
|
31
|
-
import { getNode, jobDir, updateNode, subscribersOf } from '../core/canvas/index.js';
|
|
32
|
-
import {
|
|
31
|
+
import { getNode, jobDir, updateNode, recordPid, subscribersOf, setPresence } from '../core/canvas/index.js';
|
|
32
|
+
import { transition } from '../core/runtime/lifecycle.js';
|
|
33
33
|
import { evaluateStop } from '../core/runtime/stop-guard.js';
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
34
|
+
import { personaDrift, commitPersonaAck } from '../core/runtime/persona.js';
|
|
35
|
+
import { reviveInPlace } from '../core/runtime/revive.js';
|
|
36
|
+
import { handleNewSession, markCleanExitDone } from '../core/runtime/reset.js';
|
|
37
|
+
import { focusOf, handFocusToManager, tearDownNode, closeFocusToShell } from '../core/runtime/placement.js';
|
|
38
38
|
/**
|
|
39
39
|
* Merge accumulated token counts into nodes/<nodeId>/job/telemetry.json.
|
|
40
40
|
* Creates the directory when it doesn't yet exist. Best-effort; never throws.
|
|
41
|
+
* `contextTokens` is the live window gauge for THIS turn; when null (pi can't
|
|
42
|
+
* size the window yet) the last recorded value is preserved.
|
|
41
43
|
*/
|
|
42
|
-
function flushTelemetry(jobDirPath, tokensIn, tokensOut, model) {
|
|
44
|
+
function flushTelemetry(jobDirPath, tokensIn, tokensOut, model, contextTokens) {
|
|
43
45
|
try {
|
|
44
46
|
if (!existsSync(jobDirPath))
|
|
45
47
|
mkdirSync(jobDirPath, { recursive: true });
|
|
@@ -58,6 +60,7 @@ function flushTelemetry(jobDirPath, tokensIn, tokensOut, model) {
|
|
|
58
60
|
const record = {
|
|
59
61
|
tokens_in: tokensIn,
|
|
60
62
|
tokens_out: tokensOut,
|
|
63
|
+
context_tokens: contextTokens ?? existing.context_tokens,
|
|
61
64
|
model: model !== '' ? model : (existing.model ?? ''),
|
|
62
65
|
updated_at: new Date().toISOString(),
|
|
63
66
|
};
|
|
@@ -79,105 +82,103 @@ function lastAssistantMessage(messages) {
|
|
|
79
82
|
}
|
|
80
83
|
return undefined;
|
|
81
84
|
}
|
|
82
|
-
/** When a FOCUSED node is about to shut down (final or idle-release), bring its
|
|
83
|
-
* manager into the visible pane it currently occupies so the view travels UP
|
|
84
|
-
* the spine — instead of the visible window collapsing when this node's pi
|
|
85
|
-
* exits in it. A no-op unless this node is the one the user is looking at.
|
|
86
|
-
*
|
|
87
|
-
* This is the swap-back guard the one-window-per-node model dropped: in-place
|
|
88
|
-
* focus (swap-pane) reintroduced shared pane slots, so a focused leaf that
|
|
89
|
-
* exits must hand its slot back to its manager rather than take it down.
|
|
90
|
-
* Best-effort throughout — never throws out of agent_end. */
|
|
91
|
-
function restoreFocusToManager(nodeId) {
|
|
92
|
-
try {
|
|
93
|
-
if (getFocus() !== nodeId)
|
|
94
|
-
return; // not in view — nothing to restore
|
|
95
|
-
const meta = getNode(nodeId);
|
|
96
|
-
if (meta === null)
|
|
97
|
-
return;
|
|
98
|
-
const managerId = meta.parent ?? subscribersOf(nodeId)[0]?.node_id ?? null;
|
|
99
|
-
if (managerId === null || managerId === nodeId)
|
|
100
|
-
return;
|
|
101
|
-
const manager = getNode(managerId);
|
|
102
|
-
if (manager === null)
|
|
103
|
-
return;
|
|
104
|
-
// Revive a dormant manager so there is a live pane to swap into view (it is
|
|
105
|
-
// about to be woken by this node's push anyway).
|
|
106
|
-
if (!windowAlive(manager.tmux_session, manager.window)) {
|
|
107
|
-
try {
|
|
108
|
-
reviveNode(managerId, { resume: true });
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
// Swap the manager into THIS (focused, exiting) node's pane slot. focus reads
|
|
115
|
-
// the caller pane from $TMUX_PANE — this stophook runs inside the exiting
|
|
116
|
-
// node's pi, so that is the visible pane. When this node's pi then exits, its
|
|
117
|
-
// pane lives on in the manager's old (background) window and closes there.
|
|
118
|
-
focusNodeInPlace(managerId);
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
/* best-effort; never throw out of agent_end */
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
/** Concatenate all {type:'text'} content blocks from an assistant message. */
|
|
125
|
-
function extractText(msg) {
|
|
126
|
-
if (!msg || !Array.isArray(msg.content))
|
|
127
|
-
return '';
|
|
128
|
-
return msg.content
|
|
129
|
-
.filter((c) => c != null && c.type === 'text' && typeof c.text === 'string')
|
|
130
|
-
.map((c) => c.text)
|
|
131
|
-
.join('\n')
|
|
132
|
-
.trim();
|
|
133
|
-
}
|
|
134
85
|
// ---------------------------------------------------------------------------
|
|
135
|
-
// Context-size steering bands —
|
|
136
|
-
// as input context grows. The first band is a gentle
|
|
137
|
-
// band turns firm.
|
|
138
|
-
//
|
|
86
|
+
// Context-size steering bands — a single shared schedule of thresholds that
|
|
87
|
+
// ESCALATE in tone as input context grows. The first band is a gentle
|
|
88
|
+
// "consider it"; a later band turns firm. The schedule TIGHTENS as it climbs:
|
|
89
|
+
// 130k, 150k, 170k, 185k, 200k, then every 10k (210k, 220k, …) so a long-lived
|
|
90
|
+
// node keeps getting reminded, more often the deeper it goes.
|
|
91
|
+
//
|
|
92
|
+
// The band schedule is shared across all node shapes; only the MESSAGE differs
|
|
93
|
+
// (steerNote), keyed on MODE first, then LIFECYCLE — three reachable personas:
|
|
94
|
+
// orchestrator (terminal OR resident): HAS a roadmap (promotion / orchestrator
|
|
95
|
+
// birth seeds context/roadmap.md), so it is steered to checkpoint +
|
|
96
|
+
// yield — 130k gentle (consider yielding) → 150k firm (do it now) →
|
|
97
|
+
// 185k+ pushy. Keyed on mode, NOT lifecycle: a terminal/orchestrator
|
|
98
|
+
// yields against its roadmap exactly like a resident one (the
|
|
99
|
+
// daemon's refresh-revive keys on intent='refresh', not lifecycle).
|
|
100
|
+
// resident/base (a root conversation): never promoted ⇒ NO roadmap on disk,
|
|
101
|
+
// so it is NOT told to checkpoint/yield against one. Instead: if the
|
|
102
|
+
// chat is outgrowing one window into a multi-phase job, promote;
|
|
103
|
+
// otherwise wrap up or start fresh. 130k gentle → 150k firm → 185k+
|
|
104
|
+
// pushy.
|
|
105
|
+
// terminal/base (a worker): 130k/150k suggest promote → 170k suggest promote
|
|
106
|
+
// (+ "ignore if nearly done") → 185k+ pushy.
|
|
139
107
|
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
108
|
+
// The promote/push-final guidance only makes sense for a terminal BASE worker. A
|
|
109
|
+
// resident node finishes by yielding or being closed, not `push final`, so it is
|
|
110
|
+
// never told to push final; only an ORCHESTRATOR (either lifecycle) has a roadmap
|
|
111
|
+
// to yield against, so only it is steered at the roadmap.
|
|
143
112
|
// ---------------------------------------------------------------------------
|
|
144
|
-
const STEER_STEP =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
*
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const first =
|
|
153
|
-
const last =
|
|
113
|
+
const STEER_STEP = 10_000;
|
|
114
|
+
// Shared escalation schedule (both lifecycles). Tightens as it climbs:
|
|
115
|
+
// 130k, 150k, 170k, 185k, 200k, then every 10k (210k, 220k, …).
|
|
116
|
+
const BANDS = [130_000, 150_000, 170_000, 185_000, 200_000];
|
|
117
|
+
/** The highest band threshold at or below `tokens`. Below the first band →
|
|
118
|
+
* null. At/past the last listed band, bands continue every STEER_STEP (so the
|
|
119
|
+
* firmest nudge keeps recurring). */
|
|
120
|
+
function steerBand(tokens) {
|
|
121
|
+
const first = BANDS[0];
|
|
122
|
+
const last = BANDS[BANDS.length - 1];
|
|
154
123
|
if (tokens < first)
|
|
155
124
|
return null;
|
|
156
125
|
if (tokens >= last)
|
|
157
126
|
return last + Math.floor((tokens - last) / STEER_STEP) * STEER_STEP;
|
|
158
127
|
let chosen = first;
|
|
159
|
-
for (const b of
|
|
128
|
+
for (const b of BANDS)
|
|
160
129
|
if (tokens >= b)
|
|
161
130
|
chosen = b;
|
|
162
131
|
return chosen;
|
|
163
132
|
}
|
|
164
|
-
/** The nudge text for a crossed band, specialized to the node's mode
|
|
165
|
-
* along the escalation it is.
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
|
|
133
|
+
/** The nudge text for a crossed band, specialized to the node's (mode,
|
|
134
|
+
* lifecycle) persona + how far along the escalation it is.
|
|
135
|
+
*
|
|
136
|
+
* - orchestrator (terminal OR resident): checkpoint its roadmap and yield
|
|
137
|
+
* (gently → firmly → pushy). It has a context/roadmap.md to yield against.
|
|
138
|
+
* - resident/base (root conversation): never promoted, so NO roadmap exists —
|
|
139
|
+
* steer it to PROMOTE if the chat is growing into a multi-phase job (which
|
|
140
|
+
* seeds a roadmap), else wrap up / start fresh. Never points at roadmap.md or
|
|
141
|
+
* a bare `node yield`, which for a roadmap-less root just drops context.
|
|
142
|
+
* - terminal/base (worker): PROMOTE itself — become an orchestrator — when
|
|
143
|
+
* work remains, with an "ignore if nearly done, finish with push final" once
|
|
144
|
+
* it's deeper in.
|
|
145
|
+
*
|
|
146
|
+
* At/past 185k every persona goes PUSHY: the context is long enough that
|
|
147
|
+
* drifting further risks an overflow. */
|
|
148
|
+
export function steerNote(at, lifecycle, mode) {
|
|
170
149
|
const k = Math.round(at / 1000);
|
|
150
|
+
const pushy = at >= 185_000;
|
|
151
|
+
// Keyed on MODE first: any orchestrator (terminal or resident) has a roadmap
|
|
152
|
+
// to checkpoint + yield against.
|
|
171
153
|
if (mode === 'orchestrator') {
|
|
172
154
|
if (at < 150_000) {
|
|
173
155
|
return `Context ~${k}k and growing. When you reach a good stopping point, consider updating context/roadmap.md and running \`crtr node yield\` to refresh against it — no rush yet.`;
|
|
174
156
|
}
|
|
175
|
-
|
|
157
|
+
if (!pushy) {
|
|
158
|
+
return `Context ~${k}k. Update context/roadmap.md so a fresh revive can continue, delegate any outstanding work, then \`crtr node yield\` to refresh.`;
|
|
159
|
+
}
|
|
160
|
+
return `Context ~${k}k — this is getting long. Stop taking on new work now: checkpoint context/roadmap.md, hand off anything outstanding, and \`crtr node yield\` immediately to refresh before this context overflows.`;
|
|
176
161
|
}
|
|
177
|
-
|
|
178
|
-
|
|
162
|
+
if (lifecycle === 'resident') {
|
|
163
|
+
// resident/base — a root conversation. It has no roadmap (only promotion
|
|
164
|
+
// seeds one), so steer it toward promote-or-wrap-up, never at roadmap.md.
|
|
165
|
+
const grow = `If this is turning into a multi-phase job, \`crtr node promote\` to become a resident orchestrator (seeds a roadmap so you can delegate and \`crtr node yield\` to refresh).`;
|
|
166
|
+
if (at < 150_000) {
|
|
167
|
+
return `Context ~${k}k and growing. ${grow} Otherwise no rush — wrap up when you reach a good stopping point.`;
|
|
168
|
+
}
|
|
169
|
+
if (!pushy) {
|
|
170
|
+
return `Context ~${k}k. ${grow} If you're near done, just finish here; if there's more open-ended work, start a fresh \`crtr\` rather than letting this context grow.`;
|
|
171
|
+
}
|
|
172
|
+
return `Context ~${k}k — this is getting long. Wrap up now before this context overflows: finish what's in hand, or \`crtr node promote\` immediately if substantial work remains, otherwise continue in a fresh \`crtr\`.`;
|
|
173
|
+
}
|
|
174
|
+
// terminal — a worker.
|
|
175
|
+
const suggest = `If much more work remains than this context can finish, consider \`crtr node promote\` to become an orchestrator (seeds a roadmap, lets you delegate and \`crtr node yield\` to refresh).`;
|
|
176
|
+
if (at < 170_000)
|
|
179
177
|
return `Context ~${k}k. ${suggest}`;
|
|
180
|
-
|
|
178
|
+
if (!pushy) {
|
|
179
|
+
return `Context ~${k}k. ${suggest} If you're nearly done, ignore this suggestion and finish with \`crtr push final\`.`;
|
|
180
|
+
}
|
|
181
|
+
return `Context ~${k}k — this is getting long. Wrap up now: \`crtr push final\` if you're close, otherwise \`crtr node promote\` immediately to continue as an orchestrator instead of overflowing this context.`;
|
|
181
182
|
}
|
|
182
183
|
// ---------------------------------------------------------------------------
|
|
183
184
|
// Extension
|
|
@@ -195,60 +196,115 @@ export function registerCanvasStophook(pi) {
|
|
|
195
196
|
if (nodeId === undefined || nodeId.trim() === '')
|
|
196
197
|
return; // not a canvas node
|
|
197
198
|
const jobDirPath = jobDir(nodeId);
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// fires agent_end without a preceding
|
|
199
|
+
// Cumulative throughput across all turns in this pi session, for telemetry
|
|
200
|
+
// (job/telemetry.json) only — both turn_end and agent_end accumulate so tokens
|
|
201
|
+
// emitted in the final partial turn (if pi fires agent_end without a preceding
|
|
202
|
+
// turn_end for it) are captured. NOT used for context-size steering: that is a
|
|
203
|
+
// per-turn gauge (see contextTokens), not a running sum.
|
|
201
204
|
let totalIn = 0;
|
|
202
205
|
let totalOut = 0;
|
|
203
206
|
let model = '';
|
|
204
207
|
// Context-size steering. As input context grows we nudge the node once per
|
|
205
|
-
// band on an escalating,
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
+
// band on an escalating, persona-specific schedule (see steerBand/steerNote).
|
|
209
|
+
// The node's (lifecycle, mode) persona is read at fire time, since a terminal
|
|
210
|
+
// worker can promote mid-session: a resident orchestrator is steered to
|
|
211
|
+
// checkpoint roadmap + yield; a resident base (root chat) to promote-or-wrap-
|
|
212
|
+
// up (it has no roadmap); a terminal worker to promote / push final.
|
|
208
213
|
const firedBands = new Set();
|
|
209
214
|
// ---------------------------------------------------------------------------
|
|
210
215
|
// session_start — capture pi's session id, and detect `/new`.
|
|
211
216
|
//
|
|
212
|
-
// pi
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
217
|
+
// pi tags each session_start with a `reason`: 'startup' on boot (a fresh
|
|
218
|
+
// launch or a daemon revive — both new processes), 'new' when the user runs
|
|
219
|
+
// `/new` (a brand-new conversation in the SAME process), and 'resume' /
|
|
220
|
+
// 'reload' / 'fork' for the in-place conversation swaps that keep the same
|
|
221
|
+
// node. We branch on that reason — NOT on a remembered session id.
|
|
222
|
+
//
|
|
223
|
+
// Why reason, not a closure flag: pi RE-ACTIVATES extensions on every session
|
|
224
|
+
// swap, so any id we stash on boot is reset to its initial value before the
|
|
225
|
+
// next session_start fires and can never observe the change — a `/new` then
|
|
226
|
+
// looks identical to a boot. (That is exactly the bug this replaced: `/new`
|
|
227
|
+
// silently fell back to an in-place reset instead of relaunching, so the node
|
|
228
|
+
// id + context dir never changed.) The event reason is delivered fresh on
|
|
229
|
+
// every fire and is immune to the re-activation.
|
|
230
|
+
//
|
|
231
|
+
// For a root, reason 'new' means a brand-new graph: relaunch (park the old
|
|
232
|
+
// root + boot a fresh node in this pane) or, with no pane, an in-place reset.
|
|
220
233
|
// ---------------------------------------------------------------------------
|
|
221
|
-
|
|
222
|
-
pi.on('session_start', (_event, ctx) => {
|
|
234
|
+
pi.on('session_start', (event, ctx) => {
|
|
223
235
|
try {
|
|
224
236
|
const id = ctx?.sessionManager?.getSessionId?.();
|
|
225
237
|
if (typeof id !== 'string' || id === '')
|
|
226
238
|
return;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
239
|
+
// The absolute path to this session's .jsonl, captured alongside the id.
|
|
240
|
+
// Resuming by path is immune to a cwd discrepancy (pi opens it directly),
|
|
241
|
+
// whereas a bare id is resolved cwd-relative and forks across projects.
|
|
242
|
+
const filed = ctx?.sessionManager?.getSessionFile?.();
|
|
243
|
+
const sessionFile = typeof filed === 'string' && filed !== '' ? filed : null;
|
|
244
|
+
// `/new` — a brand-new conversation in the same process. Route it: a
|
|
245
|
+
// non-root child refreshes its session id; a ROOT in a tmux pane RELAUNCHES
|
|
246
|
+
// (parks the old root + boots a fresh node in this pane via respawn-pane
|
|
247
|
+
// -k, which tears down THIS pi); a root with no pane falls back to an
|
|
248
|
+
// in-place reset. The relaunch's detached respawn may kill this pi before
|
|
249
|
+
// the lines after the call run — that's fine; do not rely on anything
|
|
250
|
+
// after handleNewSession.
|
|
251
|
+
if (event?.reason === 'new') {
|
|
252
|
+
try {
|
|
253
|
+
handleNewSession(nodeId, id, process.env['TMUX_PANE'], {}, sessionFile);
|
|
254
|
+
}
|
|
255
|
+
catch { /* best-effort */ }
|
|
256
|
+
// Clear in-memory context-steering so the fresh conversation starts clean.
|
|
257
|
+
totalIn = 0;
|
|
258
|
+
totalOut = 0;
|
|
259
|
+
firedBands.clear();
|
|
233
260
|
return;
|
|
234
261
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
262
|
+
// Boot / startup / resume / reload / fork → (re)bind this process to its
|
|
263
|
+
// session id, record our OS pid (the daemon's liveness signal for inline
|
|
264
|
+
// roots whose window outlives pi), and CONFIRM any pending refresh-yield.
|
|
265
|
+
// Reaching session_start proves a fresh pi actually booted, so it is now
|
|
266
|
+
// safe to clear intent='refresh'. reviveInPlace deliberately leaves intent
|
|
267
|
+
// set: the detached respawn it dispatches can't confirm itself (it kills
|
|
268
|
+
// the caller mid-flight), so a real boot is the only thing allowed to clear
|
|
269
|
+
// it — otherwise a failed respawn would look identical to a successful one.
|
|
270
|
+
const existing = getNode(nodeId);
|
|
271
|
+
// Identity (session id/file) → meta; runtime (pid, intent) → atomic row setters.
|
|
272
|
+
updateNode(nodeId, {
|
|
273
|
+
pi_session_id: id,
|
|
274
|
+
pi_session_file: sessionFile,
|
|
275
|
+
});
|
|
276
|
+
recordPid(nodeId, process.pid);
|
|
277
|
+
if (existing?.intent === 'refresh')
|
|
278
|
+
transition(nodeId, 'revive');
|
|
247
279
|
}
|
|
248
280
|
catch {
|
|
249
281
|
/* best-effort; never surface from an extension handler */
|
|
250
282
|
}
|
|
251
283
|
});
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// session_shutdown — clean exit → done.
|
|
286
|
+
//
|
|
287
|
+
// pi hands us a reason as a session tears down. Only 'quit' is a node-ending
|
|
288
|
+
// event we record (markCleanExitDone guards against clobbering a node
|
|
289
|
+
// agent_end already routed to done/refresh/idle-release). 'new' is owned by
|
|
290
|
+
// the session_start trigger above; reload/resume/fork keep the SAME node id on
|
|
291
|
+
// a swapped conversation. A true crash fires NO session_shutdown and falls
|
|
292
|
+
// through to the daemon's window-gone 'dead'.
|
|
293
|
+
//
|
|
294
|
+
// MUST stay synchronous (no await): the synchronous DatabaseSync write then
|
|
295
|
+
// lands within pi's awaited shutdown emit, before pi exits.
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
pi.on('session_shutdown', (event, _ctx) => {
|
|
298
|
+
try {
|
|
299
|
+
// Clean /quit (reason='quit') resolves the node to done; if it held the
|
|
300
|
+
// user's viewport, Q1-close it (tearDownNode kills the frozen focus pane +
|
|
301
|
+
// closes the focus row → returns the user to a shell, §1.5/flow (e)). pi is
|
|
302
|
+
// already exiting here, so killing its own pane is not a self-saw.
|
|
303
|
+
if (markCleanExitDone(nodeId, event?.reason))
|
|
304
|
+
tearDownNode(nodeId);
|
|
305
|
+
}
|
|
306
|
+
catch { /* best-effort; never throw out of an extension handler */ }
|
|
307
|
+
});
|
|
252
308
|
/** Absorb usage + model from any assistant message (turn or final batch). */
|
|
253
309
|
const accumulate = (msg) => {
|
|
254
310
|
if (msg?.role !== 'assistant' || msg.usage == null)
|
|
@@ -262,135 +318,191 @@ export function registerCanvasStophook(pi) {
|
|
|
262
318
|
// turn_end — live telemetry refresh.
|
|
263
319
|
// event shape: { message: AssistantMessage, ... }
|
|
264
320
|
// ---------------------------------------------------------------------------
|
|
265
|
-
pi.on('turn_end', (event) => {
|
|
321
|
+
pi.on('turn_end', (event, ctx) => {
|
|
266
322
|
accumulate(event?.message);
|
|
323
|
+
// The CURRENT context size via ctx.getContextUsage() — the exact figure pi's
|
|
324
|
+
// footer shows. Captured once here for two consumers: the telemetry flush
|
|
325
|
+
// (so out-of-process readers like `crtr node new` can size this node) and
|
|
326
|
+
// the context-size steering below.
|
|
327
|
+
// .tokens is null/undefined only when pi can't know the size yet (no model,
|
|
328
|
+
// or right after a compaction before the next reply) — telemetry then keeps
|
|
329
|
+
// its last value and steering is skipped for the turn.
|
|
330
|
+
let contextTokens = null;
|
|
331
|
+
try {
|
|
332
|
+
const t = ctx.getContextUsage()?.tokens;
|
|
333
|
+
if (typeof t === 'number')
|
|
334
|
+
contextTokens = t;
|
|
335
|
+
}
|
|
336
|
+
catch { /* gauge unavailable this turn */ }
|
|
267
337
|
// Fire-and-forget: flushTelemetry uses synchronous fs writes and never throws.
|
|
268
|
-
flushTelemetry(jobDirPath, totalIn, totalOut, model);
|
|
269
|
-
// Context-size steering: fire the current band once, with
|
|
270
|
-
// guidance (
|
|
338
|
+
flushTelemetry(jobDirPath, totalIn, totalOut, model, contextTokens);
|
|
339
|
+
// Context-size steering: fire the current band once, with lifecycle-specific
|
|
340
|
+
// guidance (lifecycle is read live — a terminal worker may have promoted to
|
|
341
|
+
// resident since launch).
|
|
342
|
+
// Delivered as a STEER, not a followUp: guidance to become an orchestrator /
|
|
343
|
+
// delegate / yield must redirect the node at the turn boundary, not queue
|
|
344
|
+
// behind whatever it does next (where it rides along, easy to ignore).
|
|
345
|
+
// Never the cumulative totalIn: under prompt caching that never grows (input
|
|
346
|
+
// is a ~2-token uncached delta each turn), so the bands were unreachable and
|
|
347
|
+
// the nudge never fired.
|
|
271
348
|
try {
|
|
272
|
-
const
|
|
273
|
-
const
|
|
349
|
+
const node = getNode(nodeId);
|
|
350
|
+
const lifecycle = node?.lifecycle ?? 'terminal';
|
|
351
|
+
const mode = node?.mode ?? 'base';
|
|
352
|
+
const at = contextTokens !== null ? steerBand(contextTokens) : null;
|
|
274
353
|
if (at !== null && !firedBands.has(at)) {
|
|
275
354
|
firedBands.add(at);
|
|
276
|
-
pi.sendUserMessage(`[crtr] ${steerNote(at, mode)}`, { deliverAs: '
|
|
355
|
+
pi.sendUserMessage(`[crtr] ${steerNote(at, lifecycle, mode)}`, { deliverAs: 'steer' });
|
|
277
356
|
}
|
|
278
357
|
}
|
|
279
358
|
catch {
|
|
280
359
|
/* steering is best-effort */
|
|
281
360
|
}
|
|
361
|
+
// Persona-transition steering. When this node's mode or lifecycle changed
|
|
362
|
+
// since it was last GIVEN guidance (it ran `crtr node promote` / `node
|
|
363
|
+
// lifecycle` this turn, or a sibling/human flipped it while the node was
|
|
364
|
+
// active), inject the guidance for its NEW persona once, then commit the
|
|
365
|
+
// ack so the next turn sees no drift. This is the single delivery site for
|
|
366
|
+
// in-session transitions — state-changing commands never hand-emit guidance.
|
|
367
|
+
// Delivered as a STEER (like the context nudge): a persona change must
|
|
368
|
+
// redirect the node at the turn boundary, not queue behind its next action.
|
|
369
|
+
try {
|
|
370
|
+
const drift = personaDrift(nodeId);
|
|
371
|
+
if (drift !== null) {
|
|
372
|
+
pi.sendUserMessage(drift.guidance, { deliverAs: 'steer' });
|
|
373
|
+
commitPersonaAck(nodeId, drift.to);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
/* persona steering is best-effort */
|
|
378
|
+
}
|
|
282
379
|
});
|
|
283
380
|
// ---------------------------------------------------------------------------
|
|
284
381
|
// agent_end — routing decision when the node's pi stops.
|
|
285
382
|
// event shape: { messages: AgentMessage[] }
|
|
286
383
|
// ---------------------------------------------------------------------------
|
|
287
384
|
pi.on('agent_end', (event, ctx) => {
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
const yieldText = extractText(last);
|
|
332
|
-
const body = yieldText !== ''
|
|
333
|
-
? `↻ Refreshing context (yield) — still working toward my goal.\n\n${yieldText}`
|
|
334
|
-
: '↻ Refreshing context (yield) — still working toward my goal.';
|
|
335
|
-
await push(nodeId, { kind: 'update', body });
|
|
336
|
-
}
|
|
337
|
-
catch { /* notify is best-effort */ }
|
|
338
|
-
const pane = process.env['TMUX_PANE'];
|
|
339
|
-
if (pane !== undefined && pane.trim() !== '') {
|
|
340
|
-
try {
|
|
341
|
-
reviveInPlace(nodeId, pane);
|
|
342
|
-
return; // respawn-pane -k tears down this pi and starts the fresh one
|
|
343
|
-
}
|
|
344
|
-
catch { /* fall through to plain shutdown */ }
|
|
345
|
-
}
|
|
346
|
-
try {
|
|
347
|
-
ctx?.shutdown?.();
|
|
385
|
+
// All routing here is synchronous fs (status writes, telemetry, idle-release,
|
|
386
|
+
// steering). The stop/yield auto-pushes that needed `await push(...)` were
|
|
387
|
+
// removed, so the handler no longer needs to be async — the node reaches its
|
|
388
|
+
// subscribers ONLY through its own explicit `crtr push` calls.
|
|
389
|
+
try {
|
|
390
|
+
const messages = Array.isArray(event?.messages) ? event.messages : [];
|
|
391
|
+
// Accumulate tokens from the final batch (edge case: a turn that fired
|
|
392
|
+
// agent_end without a preceding turn_end for the same turn).
|
|
393
|
+
for (const m of messages)
|
|
394
|
+
accumulate(m);
|
|
395
|
+
const last = lastAssistantMessage(messages);
|
|
396
|
+
const stopReason = last?.stopReason ?? '';
|
|
397
|
+
// (a) Interrupted or errored — stay alive so the user can re-steer.
|
|
398
|
+
if (stopReason !== 'stop' && stopReason !== 'length')
|
|
399
|
+
return;
|
|
400
|
+
// (b) Already done: `crtr push --final` was called this turn, which
|
|
401
|
+
// transitions node.status → 'done' synchronously. Shut down cleanly.
|
|
402
|
+
const node = getNode(nodeId);
|
|
403
|
+
if (node?.status === 'done') {
|
|
404
|
+
// TRULY-DONE (pushed `final` this turn). If this node owns the user's
|
|
405
|
+
// viewport, its lifecycle successor takes the focus (§1.6):
|
|
406
|
+
// handFocusToManager hands the focus row to the manager (the node up
|
|
407
|
+
// the subscribes_to spine it reports to) and, when that manager's pi is
|
|
408
|
+
// LIVE in the backstage, synchronously swaps it into this now-frozen
|
|
409
|
+
// focus pane; a DORMANT manager is revived into the pane by the daemon
|
|
410
|
+
// on the `final` it just pushed — either way no new window, no taint.
|
|
411
|
+
// No manager (a root) or a manager already focused elsewhere → Q1-close
|
|
412
|
+
// this focus AND flip remain-on-exit OFF on %m's window so the pane
|
|
413
|
+
// closes when this pi exits (return-to-shell) instead of freezing into
|
|
414
|
+
// an orphan. We CANNOT closePane(%m) from inside %m (self-saw), but the
|
|
415
|
+
// pi is still alive mid-shutdown, so remain-on-exit-off is safe and
|
|
416
|
+
// makes tmux reap the pane on exit. An unfocused done node just shuts
|
|
417
|
+
// down (no pane anywhere, Invariant P). M is done → it owns no pane
|
|
418
|
+
// (Invariant P), so null its own presence in BOTH sub-branches before
|
|
419
|
+
// shutdown.
|
|
420
|
+
const f = focusOf(nodeId);
|
|
421
|
+
if (f !== null) {
|
|
422
|
+
const managerId = node.parent ?? subscribersOf(nodeId)[0]?.node_id ?? null;
|
|
423
|
+
if (!handFocusToManager(f.focus_id, managerId)) {
|
|
424
|
+
// Q1 return-to-shell, self-saw-safe: close the focus row + disarm the
|
|
425
|
+
// pane's freeze so it reaps on exit (we can't closePane our own pane).
|
|
426
|
+
closeFocusToShell(f.focus_id, nodeId);
|
|
348
427
|
}
|
|
349
|
-
catch { /* ignore */ }
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
// (c) Natural stop — decide FIRST, then act. Running the stop-guard
|
|
353
|
-
// before any auto-push is what prevents duplicate reporting: a
|
|
354
|
-
// stalled terminal worker that narrates "done" without calling
|
|
355
|
-
// `push final` must NOT have that prose pushed as an `update`,
|
|
356
|
-
// because the reprompt below makes it emit a `final` next turn —
|
|
357
|
-
// two feed entries for one completion. Only genuinely dormant
|
|
358
|
-
// nodes ('allow') get a routine checkpoint update.
|
|
359
|
-
const decision = evaluateStop(nodeId, { pushedFinal: false, askedHuman: false });
|
|
360
|
-
if (decision.action === 'reprompt') {
|
|
361
|
-
// Stalled — re-prompt so the node finishes or escalates. Its `final`
|
|
362
|
-
// (or escalation) carries the real result, so we deliberately skip
|
|
363
|
-
// the auto-update here. Deliver as a followUp: the turn just ended
|
|
364
|
-
// but pi may still be flushing, so an unqualified sendUserMessage
|
|
365
|
-
// races with 'already processing'.
|
|
366
|
-
pi.sendUserMessage(decision.message, { deliverAs: 'followUp' });
|
|
367
|
-
return;
|
|
368
428
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (text !== '') {
|
|
373
|
-
await push(nodeId, { kind: 'update', body: text });
|
|
429
|
+
setPresence(nodeId, { pane: null, tmux_session: null, window: null }); // M done → owns no pane
|
|
430
|
+
try {
|
|
431
|
+
ctx?.shutdown?.();
|
|
374
432
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
433
|
+
catch { /* ignore */ }
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// (b') Refresh-yield: the node ran `crtr node yield` this turn, setting
|
|
437
|
+
// intent='refresh'. Re-exec a FRESH pi IN PLACE in this same tmux
|
|
438
|
+
// pane (respawn-pane -k) so the node re-reads its roadmap without
|
|
439
|
+
// churning its window — critically, an interactive/foreground root
|
|
440
|
+
// is never dropped to a shell, and no daemon round-trip is needed
|
|
441
|
+
// (the old window-death detection silently failed whenever pi
|
|
442
|
+
// exited into a persistent shell pane). Falls back to a clean
|
|
443
|
+
// shutdown (daemon revives in a new window) only when we're not in
|
|
444
|
+
// a tmux pane.
|
|
445
|
+
if (node?.intent === 'refresh') {
|
|
446
|
+
// A yield is SILENT to subscribers: the node keeps its identity and
|
|
447
|
+
// subscription edges across the revive and reports only through its
|
|
448
|
+
// own explicit `crtr push` calls, so there is no checkpoint push here
|
|
449
|
+
// — just re-exec a fresh pi in place against the roadmap.
|
|
450
|
+
const pane = process.env['TMUX_PANE'];
|
|
451
|
+
if (pane !== undefined && pane.trim() !== '') {
|
|
383
452
|
try {
|
|
384
|
-
|
|
453
|
+
reviveInPlace(nodeId, pane);
|
|
454
|
+
return; // respawn-pane -k tears down this pi and starts the fresh one
|
|
385
455
|
}
|
|
386
|
-
catch { /*
|
|
387
|
-
return;
|
|
456
|
+
catch { /* fall through to plain shutdown */ }
|
|
388
457
|
}
|
|
458
|
+
try {
|
|
459
|
+
ctx?.shutdown?.();
|
|
460
|
+
}
|
|
461
|
+
catch { /* ignore */ }
|
|
462
|
+
return;
|
|
389
463
|
}
|
|
390
|
-
|
|
391
|
-
|
|
464
|
+
// (c) Natural stop — run the stop-guard to classify this stop. Nothing
|
|
465
|
+
// is auto-pushed: the node reaches its subscribers only through its
|
|
466
|
+
// own explicit `crtr push` calls this turn. The guard decides
|
|
467
|
+
// whether the stop is a legitimate dormancy (idle-release, or an
|
|
468
|
+
// attended root staying live) or a stall to reprompt.
|
|
469
|
+
const decision = evaluateStop(nodeId, { pushedFinal: false, askedHuman: false });
|
|
470
|
+
if (decision.action === 'reprompt') {
|
|
471
|
+
// Stalled — re-prompt so the node finishes or escalates with an
|
|
472
|
+
// explicit `crtr push final` (or `crtr human ask`). Deliver as a
|
|
473
|
+
// followUp: the turn just ended but pi may still be flushing, so an
|
|
474
|
+
// unqualified sendUserMessage races with 'already processing'.
|
|
475
|
+
pi.sendUserMessage(decision.message, { deliverAs: 'followUp' });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
// 'allow' — the node legitimately stopped. Nothing is pushed here; any
|
|
479
|
+
// report it owed its subscribers was sent by an explicit `crtr push`
|
|
480
|
+
// during the turn.
|
|
481
|
+
//
|
|
482
|
+
// Idle-release: a node awaiting its workers (reason 'awaiting') is holding
|
|
483
|
+
// a tmux window for nothing. Free it — mark it idle-released and shut pi
|
|
484
|
+
// down; the daemon watches its inbox and revives it (resume) the moment a
|
|
485
|
+
// subscribed worker delivers. An 'attended' root never releases: the human
|
|
486
|
+
// is its wake source, so we keep its window live and dormant.
|
|
487
|
+
if (decision.reason === 'awaiting') {
|
|
488
|
+
// AWAITING = F3. transition('release') marks it idle-released. If this
|
|
489
|
+
// node is FOCUSED its pane FREEZES in place (remain-on-exit, armed at
|
|
490
|
+
// focus time) so the daemon can respawn-pane -k it back into the SAME
|
|
491
|
+
// focus pane when a worker pushes; if UNFOCUSED its backstage pane
|
|
492
|
+
// closes (dormant) and the daemon revives it into the backstage on the
|
|
493
|
+
// inbox. Both are the same release — tmux's per-window remain-on-exit
|
|
494
|
+
// decides freeze vs close. NO manager-takeover (awaiting ≠ done).
|
|
495
|
+
transition(nodeId, 'release');
|
|
496
|
+
try {
|
|
497
|
+
ctx?.shutdown?.();
|
|
498
|
+
}
|
|
499
|
+
catch { /* ignore */ }
|
|
500
|
+
return;
|
|
392
501
|
}
|
|
393
|
-
}
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
/* agent_end handler must never throw out of the extension */
|
|
505
|
+
}
|
|
394
506
|
});
|
|
395
507
|
}
|
|
396
508
|
export default registerCanvasStophook;
|