@crouton-kit/crouter 0.3.40 → 0.3.41
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.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
- package/dist/clients/attach/attach-cmd.js +706 -733
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/web/server.js +5 -28
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
- package/dist/commands/canvas-rebuild-index.js +5 -5
- package/dist/commands/node-snapshot.js +9 -1
- package/dist/commands/node.js +37 -17
- package/dist/commands/push.js +8 -0
- package/dist/commands/revive.d.ts +10 -0
- package/dist/commands/revive.js +28 -10
- package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/setup-core.js +3 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/commands/worktree.d.ts +2 -0
- package/dist/commands/worktree.js +94 -0
- package/dist/core/__tests__/boot.test.js +4 -4
- package/dist/core/__tests__/canvas.test.js +19 -7
- package/dist/core/__tests__/child-followup.test.js +15 -5
- package/dist/core/__tests__/daemon-boot.test.js +6 -1
- package/dist/core/__tests__/daemon-wedge.test.js +18 -1
- package/dist/core/__tests__/fault-classifier.test.js +30 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
- package/dist/core/__tests__/full/spike-harness.test.js +8 -5
- package/dist/core/__tests__/grace-clock.test.js +18 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
- package/dist/core/__tests__/prune-to-limit.test.js +14 -0
- package/dist/core/__tests__/review-model-floor.test.js +32 -0
- package/dist/core/__tests__/revive.test.js +18 -4
- package/dist/core/__tests__/session-cycles.test.js +77 -0
- package/dist/core/__tests__/worktree.test.js +85 -0
- package/dist/core/canvas/boot.js +12 -7
- package/dist/core/canvas/canvas.d.ts +27 -8
- package/dist/core/canvas/canvas.js +54 -26
- package/dist/core/canvas/db.js +14 -0
- package/dist/core/canvas/history.js +1 -0
- package/dist/core/canvas/paths.d.ts +10 -9
- package/dist/core/canvas/paths.js +10 -9
- package/dist/core/canvas/pid.d.ts +155 -1
- package/dist/core/canvas/pid.js +306 -1
- package/dist/core/canvas/status-glyph.d.ts +7 -0
- package/dist/core/canvas/status-glyph.js +10 -1
- package/dist/core/canvas/types.d.ts +34 -0
- package/dist/core/fault-classifier.js +5 -1
- package/dist/core/runtime/bearings.js +4 -0
- package/dist/core/runtime/branded-host.d.ts +7 -0
- package/dist/core/runtime/branded-host.js +44 -17
- package/dist/core/runtime/broker-sdk.js +28 -68
- package/dist/core/runtime/broker.js +35 -4
- package/dist/core/runtime/host.d.ts +3 -3
- package/dist/core/runtime/host.js +148 -33
- package/dist/core/runtime/launch.d.ts +15 -15
- package/dist/core/runtime/launch.js +58 -4
- package/dist/core/runtime/naming.js +3 -2
- package/dist/core/runtime/nodes.d.ts +3 -1
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/pi-cli.d.ts +2 -0
- package/dist/core/runtime/pi-cli.js +51 -0
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +5 -2
- package/dist/core/runtime/recap.js +2 -1
- package/dist/core/runtime/revive.d.ts +21 -15
- package/dist/core/runtime/revive.js +87 -52
- package/dist/core/runtime/session-cycles.d.ts +30 -0
- package/dist/core/runtime/session-cycles.js +77 -0
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +152 -100
- package/dist/core/runtime/tmux.d.ts +7 -10
- package/dist/core/runtime/tmux.js +9 -11
- package/dist/core/worktree.d.ts +35 -0
- package/dist/core/worktree.js +158 -0
- package/dist/daemon/crtrd.d.ts +17 -8
- package/dist/daemon/crtrd.js +191 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
- package/dist/pi-extensions/canvas-context-intro.js +34 -23
- package/dist/pi-extensions/canvas-stophook.js +11 -5
- package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
- package/dist/web-client/assets/index-DrkcvANq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/docs/compat/hearth-crtr-v1.md +191 -0
- package/docs/public-api.md +75 -0
- package/package.json +3 -4
- package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
- package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
- package/dist/core/hearth/config.d.ts +0 -3
- package/dist/core/hearth/config.js +0 -108
- package/dist/core/hearth/guest-env.d.ts +0 -9
- package/dist/core/hearth/guest-env.js +0 -27
- package/dist/core/hearth/index.d.ts +0 -4
- package/dist/core/hearth/index.js +0 -4
- package/dist/core/hearth/model-auth-guest.d.ts +0 -8
- package/dist/core/hearth/model-auth-guest.js +0 -430
- package/dist/core/hearth/provider.d.ts +0 -36
- package/dist/core/hearth/provider.js +0 -10
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
- package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
- package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
- package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
- package/dist/core/hearth/providers/blaxel-home.js +0 -405
- package/dist/core/hearth/providers/blaxel.d.ts +0 -36
- package/dist/core/hearth/providers/blaxel.js +0 -364
- package/dist/core/hearth/providers/types.d.ts +0 -93
- package/dist/core/hearth/types.d.ts +0 -155
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
- package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
- package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
- package/dist/hearth/control-plane/config.d.ts +0 -21
- package/dist/hearth/control-plane/config.js +0 -77
- package/dist/hearth/control-plane/db.d.ts +0 -30
- package/dist/hearth/control-plane/db.js +0 -561
- package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
- package/dist/hearth/control-plane/hearth-target.js +0 -68
- package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
- package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
- package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
- package/dist/hearth/control-plane/ingress/route-store.js +0 -61
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
- package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
- package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
- package/dist/hearth/control-plane/main.d.ts +0 -1
- package/dist/hearth/control-plane/main.js +0 -88
- package/dist/hearth/control-plane/node-message.d.ts +0 -31
- package/dist/hearth/control-plane/node-message.js +0 -98
- package/dist/hearth/control-plane/register.d.ts +0 -15
- package/dist/hearth/control-plane/register.js +0 -34
- package/dist/hearth/control-plane/registry.d.ts +0 -22
- package/dist/hearth/control-plane/registry.js +0 -168
- package/dist/hearth/control-plane/relay.d.ts +0 -44
- package/dist/hearth/control-plane/relay.js +0 -711
- package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
- package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
- package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
- package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
- package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
- package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
- package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
- package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
- package/dist/hearth/control-plane/secrets.d.ts +0 -31
- package/dist/hearth/control-plane/secrets.js +0 -134
- package/dist/hearth/control-plane/server.d.ts +0 -27
- package/dist/hearth/control-plane/server.js +0 -482
- package/dist/hearth/control-plane/serving.d.ts +0 -15
- package/dist/hearth/control-plane/serving.js +0 -106
- package/dist/hearth/control-plane/session.d.ts +0 -68
- package/dist/hearth/control-plane/session.js +0 -273
- package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
- package/dist/hearth/control-plane/triggers/acl.js +0 -52
- package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
- package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
- package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
- package/dist/hearth/control-plane/triggers/deliver.js +0 -76
- package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
- package/dist/hearth/control-plane/triggers/envelope.js +0 -38
- package/dist/hearth/control-plane/types.d.ts +0 -86
- package/dist/hearth/control-plane/types.js +0 -1
- package/dist/hearth/control-plane/wake.d.ts +0 -86
- package/dist/hearth/control-plane/wake.js +0 -550
- package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
- /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
- /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// pi-only. No claude branch — we are a super-opinionated system. A node's
|
|
4
4
|
// LaunchSpec (persisted in meta.json) is the canonical recipe the daemon
|
|
5
5
|
// replays to revive it faithfully: `--session <path>` (the saved conversation's
|
|
6
|
-
// absolute .jsonl) to wake a done/idle node
|
|
7
|
-
//
|
|
6
|
+
// absolute .jsonl) to wake a done/idle node — a refresh-yield reuses the SAME
|
|
7
|
+
// path plus `--crtr-new-cycle`, resetting the session tree's leaf and rooting a
|
|
8
|
+
// fresh cycle as a new branch rather than starting a new file.
|
|
8
9
|
// The spec is rewritten on every polymorph (base→orchestrator) so a node
|
|
9
10
|
// always comes back as its *current* self.
|
|
10
11
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
@@ -102,6 +103,7 @@ const STRENGTH_ALIASES = {
|
|
|
102
103
|
};
|
|
103
104
|
const MODEL_PROVIDER_KEYS = ['anthropic', 'openai'];
|
|
104
105
|
const MODEL_STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
|
|
106
|
+
const MODEL_STRENGTH_RANK = { light: 0, medium: 1, strong: 2, ultra: 3 };
|
|
105
107
|
const THINKING_SUFFIXES = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
106
108
|
function stripThinkingSuffix(spec) {
|
|
107
109
|
const i = spec.lastIndexOf(':');
|
|
@@ -141,6 +143,49 @@ export function equivalentOtherProviderModel(currentModel, failedProviders = new
|
|
|
141
143
|
* 3. Bare family alias (opus/sonnet/haiku) → the Anthropic ladder's
|
|
142
144
|
* strong/medium/light, ALWAYS anthropic (ignores the env/config default).
|
|
143
145
|
* 4. Anything else → pass through unchanged. */
|
|
146
|
+
function modelTokenStrength(model) {
|
|
147
|
+
const rest = model.includes('/') ? model.slice(model.indexOf('/') + 1) : model;
|
|
148
|
+
const strength = STRENGTH_ALIASES[rest];
|
|
149
|
+
if (strength !== undefined)
|
|
150
|
+
return strength;
|
|
151
|
+
if (rest === 'opus')
|
|
152
|
+
return 'strong';
|
|
153
|
+
if (rest === 'sonnet')
|
|
154
|
+
return 'medium';
|
|
155
|
+
if (rest === 'haiku')
|
|
156
|
+
return 'light';
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
function resolvedModelStrength(model) {
|
|
160
|
+
const tokenStrength = modelTokenStrength(model);
|
|
161
|
+
if (tokenStrength !== null)
|
|
162
|
+
return tokenStrength;
|
|
163
|
+
const ladders = modelLadders();
|
|
164
|
+
const comparable = stripThinkingSuffix(normalizeModel(model));
|
|
165
|
+
for (const provider of MODEL_PROVIDER_KEYS) {
|
|
166
|
+
for (const strength of MODEL_STRENGTHS) {
|
|
167
|
+
if (stripThinkingSuffix(ladders[provider][strength]) === comparable)
|
|
168
|
+
return strength;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
function isReviewQualityKind(kind) {
|
|
174
|
+
return kind === 'review' || kind.startsWith('plan/reviewers/');
|
|
175
|
+
}
|
|
176
|
+
function floorReviewModel(kind, requestedModel, kindModel) {
|
|
177
|
+
if (requestedModel === undefined)
|
|
178
|
+
return kindModel;
|
|
179
|
+
if (!isReviewQualityKind(kind) || kindModel === undefined)
|
|
180
|
+
return requestedModel;
|
|
181
|
+
const requestedStrength = resolvedModelStrength(requestedModel);
|
|
182
|
+
const floorStrength = resolvedModelStrength(kindModel);
|
|
183
|
+
if (requestedStrength === null || floorStrength === null)
|
|
184
|
+
return requestedModel;
|
|
185
|
+
return MODEL_STRENGTH_RANK[requestedStrength] < MODEL_STRENGTH_RANK[floorStrength]
|
|
186
|
+
? kindModel
|
|
187
|
+
: requestedModel;
|
|
188
|
+
}
|
|
144
189
|
export function normalizeModel(model) {
|
|
145
190
|
const ladders = modelLadders();
|
|
146
191
|
// 1. Qualified provider/rest.
|
|
@@ -201,7 +246,7 @@ export const LAUNCH_SYSTEM_PROMPT = 'You are a node in the crtr canvas — an ag
|
|
|
201
246
|
export function buildLaunchSpec(kind, mode, opts) {
|
|
202
247
|
const kindConfig = resolveKindConfig(kind);
|
|
203
248
|
const kindModel = mode === 'orchestrator' ? kindConfig?.orchestratorModel ?? kindConfig?.model : kindConfig?.model;
|
|
204
|
-
const chosenModel = opts.model
|
|
249
|
+
const chosenModel = floorReviewModel(kind, opts.model, kindModel);
|
|
205
250
|
const launch = {
|
|
206
251
|
model: chosenModel !== undefined ? normalizeModel(chosenModel) : undefined,
|
|
207
252
|
tools: kindConfig?.tools ?? [],
|
|
@@ -243,7 +288,11 @@ function writeSystemPromptFile(nodeId, prompt) {
|
|
|
243
288
|
* `headlessBrokerHost`'s preflight (host.ts) fatally rejects a bare
|
|
244
289
|
* `--session` id before ever spawning the broker, so a real revive only
|
|
245
290
|
* ever resumes by path (or falls through to fresh when no path exists).
|
|
246
|
-
* - refresh-yield:
|
|
291
|
+
* - refresh-yield: same `.jsonl` (`resumeSessionPath`) PLUS `newCycle` —
|
|
292
|
+
* emits the valueless `--crtr-new-cycle` token alongside `--session`. The
|
|
293
|
+
* broker resets the session-tree leaf and roots the new cycle as a fresh
|
|
294
|
+
* sibling branch instead of starting a new file; the node re-reads its
|
|
295
|
+
* roadmap exactly as a fresh launch would. */
|
|
247
296
|
export function buildPiArgv(meta, opts = {}) {
|
|
248
297
|
const spec = meta.launch;
|
|
249
298
|
const argv = [];
|
|
@@ -272,6 +321,8 @@ export function buildPiArgv(meta, opts = {}) {
|
|
|
272
321
|
const resumeArg = opts.resumeSessionPath ?? opts.resumeSessionId;
|
|
273
322
|
if (resumeArg !== undefined)
|
|
274
323
|
argv.push('--session', resumeArg);
|
|
324
|
+
if (opts.newCycle === true)
|
|
325
|
+
argv.push('--crtr-new-cycle');
|
|
275
326
|
}
|
|
276
327
|
if (spec?.model !== undefined)
|
|
277
328
|
argv.push('--model', spec.model);
|
|
@@ -341,6 +392,9 @@ export function piInvocationToSdkConfig(inv) {
|
|
|
341
392
|
case '--append-system-prompt':
|
|
342
393
|
cfg.appendSystemPromptPath = argv[++i];
|
|
343
394
|
break;
|
|
395
|
+
case '--crtr-new-cycle':
|
|
396
|
+
cfg.newCycle = true;
|
|
397
|
+
break;
|
|
344
398
|
default:
|
|
345
399
|
// buildPiArgv consumes every flag's value via the cases above, so any
|
|
346
400
|
// token reaching here is the single trailing positional (the prompt).
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// prompt, so a node always gets a sane name.
|
|
18
18
|
import { execFile } from 'node:child_process';
|
|
19
19
|
import { getNode, updateNode } from '../canvas/index.js';
|
|
20
|
+
import { resolveBundledPiCliPath } from './pi-cli.js';
|
|
20
21
|
/** Cap on prompt text fed to the namer — a name needs only the gist. */
|
|
21
22
|
const PROMPT_CAP = 2000;
|
|
22
23
|
/** Wall-clock budget for the headless pi call before we fall back to a slug. */
|
|
@@ -106,7 +107,7 @@ function nameArgs(prompt) {
|
|
|
106
107
|
function headlessName(body) {
|
|
107
108
|
return new Promise((resolve) => {
|
|
108
109
|
try {
|
|
109
|
-
const child = execFile(
|
|
110
|
+
const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...nameArgs(body)], { encoding: 'utf8', timeout: NAME_TIMEOUT_MS }, (err, stdout) => {
|
|
110
111
|
if (err || typeof stdout !== 'string')
|
|
111
112
|
return resolve('');
|
|
112
113
|
resolve(sanitizeSessionName(stdout));
|
|
@@ -187,7 +188,7 @@ export function generateIssueTitle(note) {
|
|
|
187
188
|
return Promise.resolve('untitled');
|
|
188
189
|
return new Promise((resolve) => {
|
|
189
190
|
try {
|
|
190
|
-
const child = execFile(
|
|
191
|
+
const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...piTextArgs(TITLE_SYSTEM_PROMPT, titleUserPrompt(body))], { encoding: 'utf8', timeout: NAME_TIMEOUT_MS }, (err, stdout) => {
|
|
191
192
|
if (err || typeof stdout !== 'string')
|
|
192
193
|
return resolve(firstLineTitle(body));
|
|
193
194
|
const t = stdout.trim().split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type NodeMeta, type Mode, type Lifecycle, type LaunchSpec } from '../canvas/index.js';
|
|
1
|
+
import { type NodeMeta, type Mode, type Lifecycle, type LaunchSpec, type ManagedWorktree } from '../canvas/index.js';
|
|
2
2
|
/** Generate a node id in the same shape as job ids (time-sortable + random). */
|
|
3
3
|
export declare function newNodeId(): string;
|
|
4
4
|
/** The root of a node's spine: walk the `parent` column up to `parent == null`.
|
|
@@ -54,6 +54,8 @@ export interface SpawnNodeOpts {
|
|
|
54
54
|
* resolve/validate the operand (via `loadProfileManifest`) before it reaches
|
|
55
55
|
* here; this layer just persists the already-resolved id. */
|
|
56
56
|
profile_id?: string | null;
|
|
57
|
+
/** Managed worktree already created for this node, if any. */
|
|
58
|
+
managedWorktree?: ManagedWorktree;
|
|
57
59
|
/** Override the generated id (e.g. when a caller pre-allocates one). */
|
|
58
60
|
nodeId?: string;
|
|
59
61
|
}
|
|
@@ -124,6 +124,7 @@ export function spawnNode(opts) {
|
|
|
124
124
|
fork_from: opts.forkFrom,
|
|
125
125
|
model_override: opts.modelOverride ?? null,
|
|
126
126
|
passive_default: opts.passiveDefault ?? false,
|
|
127
|
+
...(opts.managedWorktree !== undefined ? { managed_worktree: opts.managedWorktree } : {}),
|
|
127
128
|
intent: null,
|
|
128
129
|
pi_session_id: null,
|
|
129
130
|
pi_session_file: null,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, parse } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
function findPackageRootFromEntry(entryPath, expectedName) {
|
|
5
|
+
let dir = dirname(entryPath);
|
|
6
|
+
const root = parse(dir).root;
|
|
7
|
+
while (true) {
|
|
8
|
+
const pkgPath = join(dir, 'package.json');
|
|
9
|
+
if (existsSync(pkgPath)) {
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
12
|
+
if (pkg.name === expectedName)
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (dir === root)
|
|
20
|
+
return null;
|
|
21
|
+
dir = dirname(dir);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function resolveBundledPiCliPath() {
|
|
25
|
+
const packageName = '@earendil-works/pi-coding-agent';
|
|
26
|
+
let entryPath;
|
|
27
|
+
try {
|
|
28
|
+
entryPath = fileURLToPath(import.meta.resolve(packageName));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
throw new Error(`crouter install is missing bundled ${packageName}; reinstall @crouton-kit/crouter. ${error.message}`);
|
|
32
|
+
}
|
|
33
|
+
const packageRoot = findPackageRootFromEntry(entryPath, packageName);
|
|
34
|
+
if (packageRoot === null) {
|
|
35
|
+
throw new Error(`could not locate bundled ${packageName} package root from ${entryPath}`);
|
|
36
|
+
}
|
|
37
|
+
const pkgPath = join(packageRoot, 'package.json');
|
|
38
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
39
|
+
const bin = pkg.bin && typeof pkg.bin === 'object' ? pkg.bin.pi : undefined;
|
|
40
|
+
if (typeof bin !== 'string' || bin.trim() === '') {
|
|
41
|
+
throw new Error(`bundled ${packageName} does not declare a pi binary in ${pkgPath}`);
|
|
42
|
+
}
|
|
43
|
+
const cliPath = join(packageRoot, bin);
|
|
44
|
+
if (!existsSync(cliPath)) {
|
|
45
|
+
throw new Error(`bundled pi binary is missing at ${cliPath}`);
|
|
46
|
+
}
|
|
47
|
+
return cliPath;
|
|
48
|
+
}
|
|
49
|
+
export function bundledPiCommandForShell(quote) {
|
|
50
|
+
return `${quote(process.execPath)} ${quote(resolveBundledPiCliPath())}`;
|
|
51
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type FocusRow } from '../canvas/index.js';
|
|
2
2
|
export type { FocusRow };
|
|
3
|
-
export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, installMenuBinding, setPaneOption, getPaneOption, piCommand, respawnPaneSync, closePane, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux.js';
|
|
3
|
+
export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, installMenuBinding, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux.js';
|
|
4
4
|
export type { RespawnPaneOpts } from './tmux.js';
|
|
5
5
|
/** The viewer a node has on screen, or null. UNIQUE(node_id) ⇒ at most one. */
|
|
6
6
|
export declare function focusOf(nodeId: string): FocusRow | null;
|
|
@@ -44,8 +44,11 @@ import { isPidAlive } from '../canvas/pid.js';
|
|
|
44
44
|
// (view-cycle.ts / view-run.ts) builds its pane commands with them, and the §5.1
|
|
45
45
|
// import-lint forbids those commands importing tmux.ts directly — they must reach
|
|
46
46
|
// the driver through placement. (They are NOT engine-in-pane; the design's
|
|
47
|
-
// "nothing keeps" classification missed the view consumers.)
|
|
48
|
-
|
|
47
|
+
// "nothing keeps" classification missed the view consumers.) `shellQuote` is
|
|
48
|
+
// re-exported for the same reason — callers building the seam's expected
|
|
49
|
+
// bundled-pi command string (e.g. the M1 seam test) need the identical quoting
|
|
50
|
+
// piCommand uses internally, without importing tmux.ts directly.
|
|
51
|
+
export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, installMenuBinding, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux.js';
|
|
49
52
|
// ---------------------------------------------------------------------------
|
|
50
53
|
// Viewer registry reads — COMPOSE over the canvas focuses table (§2.3/§4).
|
|
51
54
|
//
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// Plain TS-with-types — no imports from @earendil-works/* so it compiles inside
|
|
16
16
|
// crouter's own tsc build without a dep on the pi packages.
|
|
17
17
|
import { execFile } from 'node:child_process';
|
|
18
|
+
import { resolveBundledPiCliPath } from './pi-cli.js';
|
|
18
19
|
/** Wall-clock budget for the headless pi call before we give up (no recap). */
|
|
19
20
|
const RECAP_TIMEOUT_MS = 25_000;
|
|
20
21
|
/** Default recap model — Haiku. A recap is a one-shot summarization, so we pin
|
|
@@ -88,7 +89,7 @@ export function generateRecap(conversation, onRecap) {
|
|
|
88
89
|
if (body === '')
|
|
89
90
|
return;
|
|
90
91
|
try {
|
|
91
|
-
const child = execFile(
|
|
92
|
+
const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...recapArgs(body)], { encoding: 'utf8', timeout: RECAP_TIMEOUT_MS }, (err, stdout) => {
|
|
92
93
|
if (err || typeof stdout !== 'string')
|
|
93
94
|
return; // silent — no recap
|
|
94
95
|
const lines = parseRecap(stdout);
|
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
import { type NodeMeta } from '../canvas/index.js';
|
|
2
2
|
import type { WakeOrigin } from './bearings.js';
|
|
3
3
|
import { headlessBrokerHost } from './host.js';
|
|
4
|
-
/** Pick the `--session` source for a revive. resume=true
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
4
|
+
/** Pick the `--session` source for a revive. Both resume=true (a true resume)
|
|
5
|
+
* and resume=false (a refresh-yield) resume by the absolute session-file path
|
|
6
|
+
* (immune to cwd; pi opens it directly) — but ONLY when that file actually
|
|
7
|
+
* exists on disk. A node's `pi_session_file` can be recorded in meta with
|
|
8
|
+
* nothing ever written there (e.g. its first-boot engine died before
|
|
9
|
+
* session_start ever ran), and `headlessBrokerHost.launch`'s preflight
|
|
10
|
+
* (host.ts) fatally rejects handing pi a `--session` for a missing path OR a
|
|
11
|
+
* bare id — either would crash the revive and strand the node dead with
|
|
12
|
+
* nothing to recover it. So when there is no real `.jsonl` to resume, this
|
|
13
|
+
* selects NEITHER source regardless of `resume`: the caller then falls
|
|
14
|
+
* through to a truly fresh (`--fresh`-equivalent) launch, which is always
|
|
15
|
+
* safe. The only difference between the two `resume` values (when the file
|
|
16
|
+
* DOES exist) is `newCycle`: resume=false additionally roots a fresh cycle
|
|
17
|
+
* branch (`--crtr-new-cycle`) rather than continuing the existing leaf. Pure
|
|
18
|
+
* so the decision is unit-testable without tmux or a real filesystem fixture
|
|
19
|
+
* beyond a temp file. */
|
|
17
20
|
export declare function resumeArgs(meta: NodeMeta, resume: boolean): {
|
|
18
21
|
resumeSessionPath?: string;
|
|
22
|
+
newCycle?: boolean;
|
|
19
23
|
};
|
|
20
24
|
export interface ReviveResult {
|
|
21
25
|
/** Always null — the broker engine is never placed in a tmux window. Kept on
|
|
@@ -24,8 +28,10 @@ export interface ReviveResult {
|
|
|
24
28
|
/** The node's last live LOCATION session, or null — the broker engine has no
|
|
25
29
|
* tmux session of its own (it opens no viewer). Kept for caller back-compat. */
|
|
26
30
|
session: string | null;
|
|
27
|
-
/** True
|
|
28
|
-
* absolute session-file path
|
|
31
|
+
/** True only for a TRUE resume — pi replays the saved conversation history
|
|
32
|
+
* from its absolute session-file path. False for a fresh launch AND for an
|
|
33
|
+
* in-place cycle (which also passes `--session <path>`, but to open a new
|
|
34
|
+
* branch rather than replay the old one). */
|
|
29
35
|
resumed: boolean;
|
|
30
36
|
/** Launch handle for the detached broker, so callers can fail fast on its real
|
|
31
37
|
* exit before the socket appears. */
|
|
@@ -13,11 +13,17 @@
|
|
|
13
13
|
// absolute session-file path (cwd-immune), but only when that
|
|
14
14
|
// file actually exists on disk; otherwise falls through to a
|
|
15
15
|
// fresh launch (see resumeArgs).
|
|
16
|
-
// resume=false →
|
|
16
|
+
// resume=false → a refresh-yield. When the node's `.jsonl` already exists,
|
|
17
|
+
// this REUSES it — `pi --session <path> --crtr-new-cycle` —
|
|
18
|
+
// resetting the session-tree leaf and rooting the new cycle
|
|
19
|
+
// as a fresh sibling branch, so the node re-reads its
|
|
20
|
+
// roadmap/context dir against a clean LLM context while every
|
|
21
|
+
// cycle still lives in one session file. Only a node with no
|
|
22
|
+
// `.jsonl` yet gets a truly fresh pi invocation.
|
|
17
23
|
//
|
|
18
24
|
// reviveNode remains the ONLY sanctioned launcher of the node engine.
|
|
19
25
|
import { existsSync } from 'node:fs';
|
|
20
|
-
import { getNode, updateNode, clearPid, fullName, cancelDeadlinesFor, subscribersOf, } from '../canvas/index.js';
|
|
26
|
+
import { getNode, updateNode, clearPid, recordPid, fullName, cancelDeadlinesFor, subscribersOf, } from '../canvas/index.js';
|
|
21
27
|
import { transition } from './lifecycle.js';
|
|
22
28
|
import { fanDoctrineWake } from './close.js';
|
|
23
29
|
import { buildPiArgv } from './launch.js';
|
|
@@ -30,27 +36,27 @@ import { clearInjectedDocs } from '../substrate/injected-store.js';
|
|
|
30
36
|
// ---------------------------------------------------------------------------
|
|
31
37
|
// resumeArgs — which session source a revive resumes from
|
|
32
38
|
// ---------------------------------------------------------------------------
|
|
33
|
-
/** Pick the `--session` source for a revive. resume=true
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
39
|
+
/** Pick the `--session` source for a revive. Both resume=true (a true resume)
|
|
40
|
+
* and resume=false (a refresh-yield) resume by the absolute session-file path
|
|
41
|
+
* (immune to cwd; pi opens it directly) — but ONLY when that file actually
|
|
42
|
+
* exists on disk. A node's `pi_session_file` can be recorded in meta with
|
|
43
|
+
* nothing ever written there (e.g. its first-boot engine died before
|
|
44
|
+
* session_start ever ran), and `headlessBrokerHost.launch`'s preflight
|
|
45
|
+
* (host.ts) fatally rejects handing pi a `--session` for a missing path OR a
|
|
46
|
+
* bare id — either would crash the revive and strand the node dead with
|
|
47
|
+
* nothing to recover it. So when there is no real `.jsonl` to resume, this
|
|
48
|
+
* selects NEITHER source regardless of `resume`: the caller then falls
|
|
49
|
+
* through to a truly fresh (`--fresh`-equivalent) launch, which is always
|
|
50
|
+
* safe. The only difference between the two `resume` values (when the file
|
|
51
|
+
* DOES exist) is `newCycle`: resume=false additionally roots a fresh cycle
|
|
52
|
+
* branch (`--crtr-new-cycle`) rather than continuing the existing leaf. Pure
|
|
53
|
+
* so the decision is unit-testable without tmux or a real filesystem fixture
|
|
54
|
+
* beyond a temp file. */
|
|
46
55
|
export function resumeArgs(meta, resume) {
|
|
47
|
-
if (!resume)
|
|
48
|
-
return {};
|
|
49
56
|
const path = meta.pi_session_file;
|
|
50
|
-
if (path
|
|
51
|
-
return {
|
|
52
|
-
}
|
|
53
|
-
return {};
|
|
57
|
+
if (path == null || path === '' || !existsSync(path))
|
|
58
|
+
return {};
|
|
59
|
+
return resume ? { resumeSessionPath: path } : { resumeSessionPath: path, newCycle: true };
|
|
54
60
|
}
|
|
55
61
|
// ---------------------------------------------------------------------------
|
|
56
62
|
// reviveNode
|
|
@@ -99,44 +105,71 @@ export function reviveNode(nodeId, opts) {
|
|
|
99
105
|
// launches with the incremented count.
|
|
100
106
|
meta.cycles = (meta.cycles ?? 0) + 1;
|
|
101
107
|
updateNode(nodeId, { cycles: meta.cycles });
|
|
102
|
-
// Decide whether to wake the saved pi conversation
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
108
|
+
// Decide whether to wake the saved pi conversation, cycle it in place (a
|
|
109
|
+
// refresh-yield reusing the same `.jsonl`), or start fresh. resumeArgs
|
|
110
|
+
// resumes/cycles ONLY by the absolute session-file path (cwd-immune) and ONLY
|
|
111
|
+
// when that .jsonl actually exists; there is no bare-id fallback (the broker
|
|
112
|
+
// preflight rejects a bare `--session`). No file on disk yet ⇒ a fresh launch.
|
|
106
113
|
const resume = resumeArgs(meta, opts.resume);
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
114
|
+
// hasSessionPath: there is a real `.jsonl` to hand pi via `--session`, whether
|
|
115
|
+
// as a true resume OR a cycling refresh-yield (resumeArgs sets `newCycle` in
|
|
116
|
+
// the latter case). cycling and resuming are mutually exclusive; `resuming`
|
|
117
|
+
// keeps the STRICT true-resume-only meaning `ReviveResult.resumed` and the
|
|
118
|
+
// launch-placement hint below have always carried.
|
|
119
|
+
const hasSessionPath = resume.resumeSessionPath !== undefined;
|
|
120
|
+
const cycling = hasSessionPath && resume.newCycle === true;
|
|
121
|
+
const resuming = hasSessionPath && !cycling;
|
|
122
|
+
// Mirror THIS launch attempt's cycling-ness durably, BEFORE launching, so a
|
|
123
|
+
// pre-session_start crash-retry (crtrd.ts) can tell "was the attempt I'm
|
|
124
|
+
// retrying a cycle" apart from an ordinary resume/crash. Cleared by
|
|
125
|
+
// canvas-stophook's session_start handler the instant this boot is confirmed.
|
|
126
|
+
// Written unconditionally (true for cycling, false otherwise) so it never
|
|
127
|
+
// goes stale across launch kinds.
|
|
128
|
+
updateNode(nodeId, { cycle_pending: cycling });
|
|
129
|
+
meta.cycle_pending = cycling;
|
|
130
|
+
// A truly fresh launch — no `.jsonl` to resume OR cycle from — starts a NEW
|
|
131
|
+
// pi session, so the old session identity is dead. Clear it from meta NOW,
|
|
132
|
+
// before launch, so the recorded state matches what we're about to do. This
|
|
133
|
+
// is load-bearing for boot-failure classification: if this fresh broker dies
|
|
134
|
+
// BEFORE session_start (recording no new pid and no new session), the
|
|
135
|
+
// daemon's pid-null branch keys on pi_session_id — a leftover stale id would
|
|
136
|
+
// be misread as a "stranded relaunch" and retried forever instead of surfaced
|
|
137
|
+
// via transition('crash') + surfaceBootFailure. Clearing it makes the failure
|
|
138
|
+
// show as pid=NULL + session=NULL → boot failure, the correct terminal
|
|
139
|
+
// outcome. A cycling refresh keeps BOTH ids — the `.jsonl` and its recorded
|
|
140
|
+
// session id are exactly what's being reused — so a cycling broker that dies
|
|
141
|
+
// pre-session_start reads as a "stranded relaunch" and is retried, same class
|
|
142
|
+
// as a resume revive today.
|
|
143
|
+
if (!hasSessionPath && (meta.pi_session_id != null || meta.pi_session_file != null)) {
|
|
118
144
|
updateNode(nodeId, { pi_session_id: null, pi_session_file: null });
|
|
119
145
|
meta.pi_session_id = null;
|
|
120
146
|
meta.pi_session_file = null;
|
|
121
147
|
}
|
|
122
|
-
// A
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
148
|
+
// A true resume needs no kickoff — the saved conversation just continues. A
|
|
149
|
+
// fresh revive OR a cycling refresh-yield both get one so the (re-rooted)
|
|
150
|
+
// window re-reads its roadmap and continues: drainBearings is the one-shot
|
|
151
|
+
// consuming step (yield note + feed cursor + persona ack); the builder is
|
|
152
|
+
// then pure. `resume` carries `resumeSessionPath`/`newCycle` through
|
|
153
|
+
// unchanged (empty for true-fresh, both set for cycling) so buildPiArgv emits
|
|
154
|
+
// the right argv either way.
|
|
126
155
|
let inv;
|
|
127
156
|
if (resuming) {
|
|
128
157
|
inv = buildPiArgv(meta, resume);
|
|
129
158
|
}
|
|
130
159
|
else {
|
|
131
|
-
//
|
|
132
|
-
// a scheduled bare self-alarm drove this revive, opts.wakeReason
|
|
133
|
-
// wake provenance so the kickoff leads with a <crtr-wake> block
|
|
134
|
-
// woke you"); every other reviveNode caller passes nothing → no
|
|
160
|
+
// Fresh or cycling: drain the one-shot bearings, then build the kickoff.
|
|
161
|
+
// When a scheduled bare self-alarm drove this revive, opts.wakeReason
|
|
162
|
+
// carries the wake provenance so the kickoff leads with a <crtr-wake> block
|
|
163
|
+
// ("a timer woke you"); every other reviveNode caller passes nothing → no
|
|
164
|
+
// block.
|
|
135
165
|
const bearings = drainBearings(meta);
|
|
136
|
-
inv = buildPiArgv(meta, {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
166
|
+
inv = buildPiArgv(meta, {
|
|
167
|
+
...resume,
|
|
168
|
+
prompt: buildReviveKickoff(meta, bearings, opts.wakeReason),
|
|
169
|
+
});
|
|
170
|
+
// Fresh or cycling revive starts a NEW LLM context — reset the on-read doc
|
|
171
|
+
// dedup so it surfaces docs from scratch (a true resume above instead KEEPS
|
|
172
|
+
// the persisted set, continuing the same transcript).
|
|
140
173
|
clearInjectedDocs(nodeId);
|
|
141
174
|
}
|
|
142
175
|
// The broker host launches the detached engine. reviveNode keeps
|
|
@@ -178,9 +211,11 @@ export function reviveNode(nodeId, opts) {
|
|
|
178
211
|
clearPid(nodeId);
|
|
179
212
|
throw new Error(`[broker] refusing to revive ${nodeId}: ${error.message}`);
|
|
180
213
|
}
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
214
|
+
// Record the real broker pid immediately. session_start will re-record the
|
|
215
|
+
// same pid after extension bind, but the daemon must supervise the process
|
|
216
|
+
// during the cold-boot window too; otherwise a slow-but-alive broker reads as
|
|
217
|
+
// pid=null+session=null and can be falsely crashed by the boot-grace path.
|
|
218
|
+
if (launched.pid !== null)
|
|
219
|
+
recordPid(nodeId, launched.pid);
|
|
185
220
|
return { window: null, session: meta.tmux_session ?? null, resumed: resuming, launch: launched };
|
|
186
221
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { BrokerSnapshot } from './broker-protocol.js';
|
|
3
|
+
/** customType of the persisted tree entry that ROOTS each post-yield cycle.
|
|
4
|
+
* Data payload: `{ cycle, fromLeaf }` — `cycle` is the node's cycle counter at
|
|
5
|
+
* relaunch, `fromLeaf` the previous cycle's leaf entry id. */
|
|
6
|
+
export declare const CRTR_CYCLE_CUSTOM_TYPE = "crtr-cycle";
|
|
7
|
+
/** customType of the SYNTHETIC divider message spliced between cycles in
|
|
8
|
+
* snapshot rendering. Never persisted to the session file. */
|
|
9
|
+
export declare const CRTR_CYCLE_DIVIDER_CUSTOM_TYPE = "crtr-cycle-divider";
|
|
10
|
+
type SnapshotMessage = BrokerSnapshot['messages'][number];
|
|
11
|
+
/** The structural slice of pi's SessionManager cycle rendering needs. The
|
|
12
|
+
* broker's fake-engine test fixture implements only `buildSessionContext`, so
|
|
13
|
+
* the tree accessors are optional — absent ⇒ single-cycle behavior. */
|
|
14
|
+
export interface CycleSessionManagerLike {
|
|
15
|
+
buildSessionContext(): {
|
|
16
|
+
messages: SnapshotMessage[];
|
|
17
|
+
};
|
|
18
|
+
getEntries?(): SessionEntry[];
|
|
19
|
+
/** Walk from `fromId` (default: the current leaf) to its root; returns the
|
|
20
|
+
* path root-first. */
|
|
21
|
+
getBranch?(fromId?: string): SessionEntry[];
|
|
22
|
+
}
|
|
23
|
+
/** The full multi-cycle message history for display: prior cycles (oldest
|
|
24
|
+
* first, each closed by a divider), then the live cycle's context. Falls back
|
|
25
|
+
* to plain `buildSessionContext()` when the session has no cycle markers, when
|
|
26
|
+
* the manager lacks tree accessors (fake engine), or when the current leaf was
|
|
27
|
+
* tree-navigated back into a pre-yield branch (pi's active-branch semantics
|
|
28
|
+
* then apply unchanged). */
|
|
29
|
+
export declare function cycleAwareMessages(sm: CycleSessionManagerLike): SnapshotMessage[];
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Cycle-aware session history — one node, one `.jsonl`, many cycles.
|
|
2
|
+
//
|
|
3
|
+
// A refresh-yield does not start a new session file: reviveNode reuses the
|
|
4
|
+
// node's existing `.jsonl` (buildPiArgv's `--crtr-new-cycle`), and the broker
|
|
5
|
+
// resets the session-tree leaf, appending a `crtr-cycle` custom entry as the
|
|
6
|
+
// new cycle's ROOT. Custom entries never participate in LLM context, so the
|
|
7
|
+
// refreshed window is exactly as clean as a brand-new session — but every
|
|
8
|
+
// cycle lives in the same tree as a sibling root branch, so the /tree
|
|
9
|
+
// navigator shows previous cycles natively.
|
|
10
|
+
//
|
|
11
|
+
// `cycleAwareMessages()` is the display-side counterpart: it rebuilds the FULL
|
|
12
|
+
// node history — each prior cycle's messages (via pi's own `buildSessionContext`,
|
|
13
|
+
// so a compacted prior cycle renders correctly), a synthetic divider message per
|
|
14
|
+
// boundary, then the live cycle's context — so the broker welcome snapshot
|
|
15
|
+
// (broker.ts) and the dormant snapshot (node-snapshot.ts) both render one
|
|
16
|
+
// continuous conversation.
|
|
17
|
+
import { buildSessionContext } from '@earendil-works/pi-coding-agent';
|
|
18
|
+
/** customType of the persisted tree entry that ROOTS each post-yield cycle.
|
|
19
|
+
* Data payload: `{ cycle, fromLeaf }` — `cycle` is the node's cycle counter at
|
|
20
|
+
* relaunch, `fromLeaf` the previous cycle's leaf entry id. */
|
|
21
|
+
export const CRTR_CYCLE_CUSTOM_TYPE = 'crtr-cycle';
|
|
22
|
+
/** customType of the SYNTHETIC divider message spliced between cycles in
|
|
23
|
+
* snapshot rendering. Never persisted to the session file. */
|
|
24
|
+
export const CRTR_CYCLE_DIVIDER_CUSTOM_TYPE = 'crtr-cycle-divider';
|
|
25
|
+
function cycleDivider(cycle, timestamp) {
|
|
26
|
+
const label = cycle !== undefined ? `cycle ${cycle}` : 'new cycle';
|
|
27
|
+
return {
|
|
28
|
+
role: 'custom',
|
|
29
|
+
customType: CRTR_CYCLE_DIVIDER_CUSTOM_TYPE,
|
|
30
|
+
content: `yielded — ${label} begins with a fresh context window`,
|
|
31
|
+
display: true,
|
|
32
|
+
timestamp: new Date(timestamp).getTime(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** The full multi-cycle message history for display: prior cycles (oldest
|
|
36
|
+
* first, each closed by a divider), then the live cycle's context. Falls back
|
|
37
|
+
* to plain `buildSessionContext()` when the session has no cycle markers, when
|
|
38
|
+
* the manager lacks tree accessors (fake engine), or when the current leaf was
|
|
39
|
+
* tree-navigated back into a pre-yield branch (pi's active-branch semantics
|
|
40
|
+
* then apply unchanged). */
|
|
41
|
+
export function cycleAwareMessages(sm) {
|
|
42
|
+
const current = sm.buildSessionContext().messages;
|
|
43
|
+
if (typeof sm.getEntries !== 'function' || typeof sm.getBranch !== 'function')
|
|
44
|
+
return current;
|
|
45
|
+
// File order = append order = chronological cycle order.
|
|
46
|
+
const entries = sm.getEntries();
|
|
47
|
+
const markers = entries.filter((e) => e.type === 'custom' && e.customType === CRTR_CYCLE_CUSTOM_TYPE);
|
|
48
|
+
if (markers.length === 0)
|
|
49
|
+
return current;
|
|
50
|
+
// The current cycle's root: first entry on the leaf→root path. When it is
|
|
51
|
+
// not a cycle marker, the leaf sits in the ORIGINAL (pre-yield) branch —
|
|
52
|
+
// render just that branch. Otherwise replay every cycle up to and including
|
|
53
|
+
// the one the marker closes.
|
|
54
|
+
const currentRootId = sm.getBranch()[0]?.id;
|
|
55
|
+
const rootMarkerIndex = markers.findIndex((m) => m.id === currentRootId);
|
|
56
|
+
if (rootMarkerIndex === -1)
|
|
57
|
+
return current;
|
|
58
|
+
// Shared byId map — buildSessionContext would otherwise rebuild one from
|
|
59
|
+
// `entries` on every call; one pass here amortizes it across every replayed
|
|
60
|
+
// marker.
|
|
61
|
+
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
62
|
+
const out = [];
|
|
63
|
+
for (let i = 0; i <= rootMarkerIndex; i++) {
|
|
64
|
+
const marker = markers[i];
|
|
65
|
+
const fromLeaf = marker.data?.fromLeaf;
|
|
66
|
+
if (typeof fromLeaf === 'string' && fromLeaf !== '') {
|
|
67
|
+
// Replay this prior cycle's own branch through pi's canonical context
|
|
68
|
+
// builder — preserving compaction semantics (one compactionSummary +
|
|
69
|
+
// kept + post-compaction messages only) exactly like the live cycle
|
|
70
|
+
// below.
|
|
71
|
+
out.push(...buildSessionContext(entries, fromLeaf, byId).messages);
|
|
72
|
+
}
|
|
73
|
+
out.push(cycleDivider(marker.data?.cycle, marker.timestamp));
|
|
74
|
+
}
|
|
75
|
+
out.push(...current);
|
|
76
|
+
return out;
|
|
77
|
+
}
|