@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.
Files changed (188) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -405
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /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, or fresh (against the context dir)
7
- // for a refresh-yield.
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 ?? kindModel;
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: fresh again (no resume) the node re-reads its roadmap. */
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('pi', nameArgs(body), { encoding: 'utf8', timeout: NAME_TIMEOUT_MS }, (err, stdout) => {
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('pi', piTextArgs(TITLE_SYSTEM_PROMPT, titleUserPrompt(body)), { encoding: 'utf8', timeout: NAME_TIMEOUT_MS }, (err, stdout) => {
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,2 @@
1
+ export declare function resolveBundledPiCliPath(): string;
2
+ export declare function bundledPiCommandForShell(quote: (value: string) => string): string;
@@ -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
- 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';
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('pi', recapArgs(body), { encoding: 'utf8', timeout: RECAP_TIMEOUT_MS }, (err, stdout) => {
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 resumes by the
5
- * absolute session-file path (immune to cwd; pi opens it directly) — but ONLY
6
- * when that file actually exists on disk. A node's `pi_session_file` can be
7
- * recorded in meta with nothing ever written there (e.g. its first-boot engine
8
- * died before session_start ever ran), and `headlessBrokerHost.launch`'s
9
- * preflight (host.ts) fatally rejects handing pi a `--session` for a missing
10
- * path OR a bare id either would crash the revive and strand the node dead
11
- * with nothing to recover it. So when there is no real `.jsonl` to resume,
12
- * this selects NEITHER source: the caller then falls through to a fresh
13
- * (`--fresh`-equivalent) launch, which is always safe. resume=false (a
14
- * refresh-yield) also selects neither the node re-reads its roadmap fresh.
15
- * Pure so the decision is unit-testable without tmux or a real filesystem
16
- * fixture beyond a temp file. */
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 when pi was instructed to resume its saved conversation by its
28
- * absolute session-file path (`--session <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 → fresh pi invocation the node re-reads its roadmap/context dir.
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 resumes by the
34
- * absolute session-file path (immune to cwd; pi opens it directly) — but ONLY
35
- * when that file actually exists on disk. A node's `pi_session_file` can be
36
- * recorded in meta with nothing ever written there (e.g. its first-boot engine
37
- * died before session_start ever ran), and `headlessBrokerHost.launch`'s
38
- * preflight (host.ts) fatally rejects handing pi a `--session` for a missing
39
- * path OR a bare id either would crash the revive and strand the node dead
40
- * with nothing to recover it. So when there is no real `.jsonl` to resume,
41
- * this selects NEITHER source: the caller then falls through to a fresh
42
- * (`--fresh`-equivalent) launch, which is always safe. resume=false (a
43
- * refresh-yield) also selects neither the node re-reads its roadmap fresh.
44
- * Pure so the decision is unit-testable without tmux or a real filesystem
45
- * fixture beyond a temp file. */
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 != null && path !== '' && existsSync(path)) {
51
- return { resumeSessionPath: path };
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 or start fresh. resumeArgs
103
- // resumes ONLY by the absolute session-file path (cwd-immune) and ONLY when
104
- // that .jsonl actually exists; there is no bare-id fallback (the broker
105
- // preflight rejects a bare `--session`). Anything else is a fresh launch.
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
- const resuming = resume.resumeSessionPath !== undefined;
108
- // A fresh (non-resuming) launch starts a NEW pi session the old session
109
- // identity is dead. Clear it from meta NOW, before launch, so the recorded
110
- // state matches what we're about to do. This is load-bearing for boot-failure
111
- // classification: if this fresh broker dies BEFORE session_start (recording
112
- // no new pid and no new session), the daemon's pid-null branch keys on
113
- // pi_session_id a leftover stale id would be misread as a "stranded
114
- // relaunch" and retried forever instead of surfaced via transition('crash') +
115
- // surfaceBootFailure. Clearing it makes the failure show as pid=NULL +
116
- // session=NULL boot failure, the correct terminal outcome.
117
- if (!resuming && (meta.pi_session_id != null || meta.pi_session_file != null)) {
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 fresh revive (no resume) gets a kickoff prompt so it re-reads its roadmap
123
- // and continues; resuming a saved conversation needs none. drainBearings is the
124
- // one-shot consuming step (yield note + feed cursor + persona ack); the builder
125
- // is then pure.
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
- // A fresh revive: drain the one-shot bearings, then build the kickoff. When
132
- // a scheduled bare self-alarm drove this revive, opts.wakeReason carries the
133
- // wake provenance so the kickoff leads with a <crtr-wake> block ("a timer
134
- // woke you"); every other reviveNode caller passes nothing → no block.
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, { prompt: buildReviveKickoff(meta, bearings, opts.wakeReason) });
137
- // Fresh (no-resume) revive starts a NEW transcript — reset the on-read doc
138
- // dedup so the new conversation surfaces docs from scratch (a resume below
139
- // would instead KEEP the persisted set, continuing the same transcript).
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
- // Clear the stale pid so the daemon won't re-fire on it during the new
182
- // broker's boot. The fresh broker re-records its pid during extension bind; if
183
- // it never boots, the daemon's boot-grace + surfaceBootFailure own that case.
184
- clearPid(nodeId);
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
+ }