pi-repl-py 0.6.13 → 0.7.0

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.
@@ -114,12 +114,7 @@ function marker(state: ExecuteRenderState, deps: RenderDeps): string {
114
114
  }
115
115
  }
116
116
 
117
- /**
118
- * highlight.js python emits no scope for plain identifiers, so they arrive as raw (uncolored)
119
- * text mixed with SGR-colored tokens and bare punctuation. Re-color only whole identifier runs
120
- * that sit outside any colored span, leaving keywords, strings, numbers, and other already
121
- * colored tokens untouched.
122
- */
117
+ /** highlight.js emits no scope for bare Python identifiers — re-color only raw runs outside colored spans. */
123
118
  function colorBareIdentifiers(line: string, paint: (id: string) => string): string {
124
119
  if (!line.includes("\x1b") && !/[a-zA-Z_]/.test(line)) return line;
125
120
  const out: string[] = [];
@@ -160,7 +155,6 @@ function colorBareIdentifiers(line: string, paint: (id: string) => string): stri
160
155
  i = end;
161
156
  continue;
162
157
  }
163
- // only raw (uncolored) text may be repainted; colored tokens pass through untouched
164
158
  if (colored) out.push(line[i]);
165
159
  else pending += line[i];
166
160
  i++;
@@ -242,9 +236,7 @@ function sanitizeTuiOutput(text: string): string {
242
236
  .replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x9f]/g, "�");
243
237
  }
244
238
 
245
- /** Wrap a ready-colored span into `lines`: first row takes the prefix, wrapped
246
- * continuations take indent (and an optional extra indent), each row is
247
- * truncated to pane width and closed if it ends on an open SGR color. */
239
+ /** Wrap a colored span: first row takes the prefix, continuations the indent; truncate to width and close open SGR colors. */
248
240
  function pushWrappedLines(
249
241
  lines: string[],
250
242
  prefix: string,
@@ -306,12 +298,7 @@ function renderCode(state: ExecuteRenderState, lines: string[], width: number, d
306
298
  return true;
307
299
  }
308
300
 
309
- /**
310
- * Streaming output is append-only, so a wrapped blob only changes at its tail.
311
- * The cache lives on the (persistent) state object via a WeakMap, letting an
312
- * updated body re-wrap just the appended delta: O(chunk) instead of O(output).
313
- * Fresh states (unit tests, first render) fall back to a full wrap.
314
- */
301
+ /** Output is append-only: the cached wrap re-wraps just the tail — O(chunk) instead of O(output). */
315
302
  interface BlobWrapEntry {
316
303
  text: string;
317
304
  color: string;
@@ -321,20 +308,14 @@ interface BlobWrapEntry {
321
308
  }
322
309
 
323
310
  const blobWrapCache = new WeakMap<ExecuteRenderState, Map<number, BlobWrapEntry>>();
324
- /** Wrapped+highlighted rows rebuild only when their inputs (text/width) change; the
325
- * TUI re-renders bodies on every frame, so these caches turn per-frame work into
326
- * one build per change. They live on the persistent per-call state, which the host
327
- * keeps for the session, so a small bound per state keeps resize churn bounded. */
311
+ /** Row caches keyed by text/width turn per-frame renders into one build per change; bounded per state against resize churn. */
328
312
  interface CodeEntry {
329
313
  code: string;
330
314
  lines: string[];
331
315
  }
332
316
  const codeWrapCache = new WeakMap<ExecuteRenderState, Map<number, CodeEntry>>();
333
317
 
334
- /** Window resizes add a per-width entry per cell; keep a small bound so a long
335
- * session with resize churn cannot grow the wrap caches without limit. Map
336
- * iteration order is insertion order, so evicting the first key drops the oldest
337
- * width rather than the one in use. */
318
+ /** Bound per-width cache entries; insertion order evicts the oldest width first. */
338
319
  const MAX_CACHED_WIDTHS_PER_STATE = 3;
339
320
  function boundWidthCache(perWidth: Map<number, unknown>): void {
340
321
  while (perWidth.size >= MAX_CACHED_WIDTHS_PER_STATE) {
@@ -352,9 +333,7 @@ function widthOf(ch: string): number {
352
333
  return code < 0x80 ? 1 : CJK_WIDE_RE.test(ch) ? 2 : 1;
353
334
  }
354
335
 
355
- /** Fast single-pass word wrap for ANSI-free text: pi-tui's ANSI-aware wrap is
356
- * ~45ms on a 45K blob; sanitized output needs no ANSI handling, so this splits
357
- * at spaces with an O(width) scan per row and hard-breaks overlong words. */
336
+ /** Fast word wrap for sanitized text: pi-tui's ANSI-aware wrap costs ~45ms on a 45K blob. */
358
337
  function wrapPlainText(text: string, width: number): string[] {
359
338
  if (width <= 0 || text.length <= width) return [text];
360
339
  // --- fast path: pure ASCII, break by char index ---
@@ -430,8 +409,7 @@ function wrapPlainText(text: string, width: number): string[] {
430
409
  return rows;
431
410
  }
432
411
 
433
- /** Wrap a raw output line exactly the way the section loops did: sanitize, wrap (fast),
434
- * then colorize row-by-row so the color span never bleeds across rows. */
412
+ /** Sanitize fast wrap colorize rows, so an SGR span never bleeds across rows. */
435
413
  function wrapRawOutputLine(lines: string[], raw: string, color: string, width: number, deps: RenderDeps): string[] {
436
414
  const before = lines.length;
437
415
  const safe = sanitizeTuiOutput(raw || " ");
@@ -517,8 +495,7 @@ function renderOutput(
517
495
  }
518
496
 
519
497
  if (details?.errorStack && details.errorStack.length > 0) {
520
- // --- a traceback IS output: without this flag a pure-traceback error cell (no
521
- // --- stdout/stderr/result, the common error shape) also rendered "no output" below it ---
498
+ // --- a traceback IS output: without this, a pure-traceback error cell also rendered "no output" below it ---
522
499
  renderedText = true;
523
500
  output.push(` ${OUTPUT_INDENT}${deps.fg("dim", "traceback:")}`);
524
501
  for (const line of details.errorStack) {
@@ -532,10 +509,7 @@ function renderOutput(
532
509
  addWrapped(output, OUTPUT_INDENT, deps.fg("muted", message), width, deps, { sanitize: false });
533
510
  }
534
511
 
535
- // --- bottom cushion: streams end with a newline, so blobs already render a trailing blank
536
- // --- row; traceback and the placeholder have no such newline and would sit flush against
537
- // --- the panel's bottom edge. Normalize: the panel always ends with one blank painted row.
538
- // --- (SGR stripped before the blank test; the colored rows themselves stay untouched.) ---
512
+ // --- the panel always ends with one blank row: streams end with a newline, tracebacks and placeholders don't ---
539
513
  const lastRow = output[output.length - 1];
540
514
  if (lastRow !== undefined && lastRow.replace(SGR_PATTERN, "").trim() !== "") output.push("");
541
515
 
@@ -544,7 +518,6 @@ function renderOutput(
544
518
  lines.push(...output);
545
519
  }
546
520
 
547
- /** Paint the status-matched panel background across the row, surviving inner SGR resets. */
548
521
  export function paintBackground(line: string, width: number, kind: StatusKind, deps: RenderDeps): string {
549
522
  const bgAnsi = deps.getBgAnsi(backgroundFor(kind));
550
523
  const padded = line + " ".repeat(Math.max(0, width - deps.visibleWidth(line)));
@@ -30,10 +30,7 @@ function makeDeps(theme: Theme): RenderDeps {
30
30
  };
31
31
  }
32
32
 
33
- /** O(1) key: a host-bumped dirty counter plus mode state. `withSpinner` folds
34
- * the animation frame so the header alone animates while running; the body key
35
- * excludes it, so a running cell only redraws when output actually changes
36
- * instead of re-wrapping the whole body every 120ms. */
33
+ /** O(1) body key excludes the spinner frame, so a running cell redraws only when output changes, not every 120ms. */
37
34
  function renderVersion(state: ExecuteRenderState, withSpinner: boolean): string {
38
35
  const spinner = withSpinner && statusKind(state) === "running" ? Math.floor(Date.now() / 120) % 4 : -1;
39
36
  return `${state.version ?? 0}|${state.expanded}|${spinner}`;
@@ -1,6 +1,6 @@
1
- // revival is part of create() so a session that gets teardown without a session_start reload still revives
1
+ // Lifecycle: boot, session binding, reset announcements. Recovery is a background quiet-gap job; the notice lands on the first cell after the restore.
2
2
 
3
- import type { RestoreResult } from "../engine/index.js";
3
+ import type { HelperLoadResult, RestoreResult } from "../engine/index.js";
4
4
 
5
5
  /** Show enough names to orient, then count the rest (a revive can carry hundreds). */
6
6
  function summarizeNames(names: readonly string[], limit: number): string {
@@ -10,33 +10,32 @@ function summarizeNames(names: readonly string[], limit: number): string {
10
10
 
11
11
  /** The part of EngineManager this lifecycle needs; narrowed so tests can fake it. */
12
12
  export interface RevivableEngine {
13
- /** Boot the engine and revive the snapshot; `skip` boots fresh, deliberately not reviving. */
14
- restoreState(skip?: boolean): Promise<RestoreResult | null>;
13
+ /** Boot the kernel (and preload helpers), independent of snapshot recovery. */
14
+ start(skipRestore?: boolean): Promise<void>;
15
+ /** Recovery outcome: revived names, or null when nothing to revive / skipped / failed. Never rejects. */
16
+ restoreResult(): Promise<RestoreResult | null>;
17
+ /** True when recovery was deliberately skipped (a prior revive wedged). */
18
+ restoreWasSkipped(): boolean;
15
19
  /** True when this conversation's state dir already exists, so the engine was resumed. */
16
20
  hasSnapshotHistory(): boolean;
17
21
  }
18
22
 
19
23
  export interface EngineLifecycleDeps<E extends RevivableEngine> {
20
- /** Builds a fresh engine. Called at most once per lifecycle generation. */
21
- create(): E;
24
+ /** Fresh engine; skipRestore is the retry after a wedged boot, so a poisoned snapshot can't wedge twice. */
25
+ create(skipRestore?: boolean): E;
22
26
  /** Tears the current engine down, flushing its final snapshot. */
23
27
  dispose(engine: E): Promise<void>;
24
28
  /** Kill-then-rebuild when a wedged engine cannot serve the snapshot flush. */
25
29
  discard?(engine: E): Promise<void>;
26
- /** Boot deadline in ms; a boot (kernel start, helpers preload, snapshot restore) that
27
- * outlives it is killed and retried fresh. Default 90s. */
30
+ /** Boot deadline (default 90s): an overlong boot is killed and retried. Recovery is outside it — background, bounded by the restore-cell watchdog. */
28
31
  bootTimeoutMs?: number;
29
32
  }
30
33
 
31
- /** Sentinel: the boot outlived its deadline. Distinct from null, which means "booted, nothing revived". */
32
- const BOOT_WEDGED = Symbol("boot wedged");
33
-
34
- /** `startup` restores then announces on the first cell when the conversation has a saved past; `cell` means an engine was rebuilt mid-session and announces immediately. */
34
+ /** startup: announce when the conversation has a saved past; cell: a mid-session rebuild announces immediately. */
35
35
  export type AcquireOrigin = "startup" | "cell";
36
36
 
37
37
  const DEFAULT_BOOT_TIMEOUT_MS = 90_000;
38
38
 
39
- /** Model-facing body for a boot whose snapshot revive wedged and was skipped. */
40
39
  function revivedNoticeBody(origin: AcquireOrigin): string {
41
40
  const resumed = origin === "startup";
42
41
  return resumed
@@ -44,27 +43,19 @@ function revivedNoticeBody(origin: AcquireOrigin): string {
44
43
  : "The evaluator wedged while reviving its saved namespace, so the snapshot was skipped and the namespace is empty.";
45
44
  }
46
45
 
47
- // --- Terse TUI toast for the human, separate from the model-facing cell marker: the user
48
- // --- asked for the classic subtle notification instead of a showy in-cell message. Counts
49
- // --- come from the same restore the marker describes, so the two never disagree. ---
50
- export function formatResetToast(origin: AcquireOrigin, restore: RestoreResult | null): string {
46
+ // --- human toast, separate from the model marker; counts come from the same restore, so the two never disagree ---
47
+ export function formatResetToast(origin: AcquireOrigin, restore: RestoreResult | null, wedged = false): string {
51
48
  const resumed = origin === "startup";
49
+ const verb = resumed ? "repl session resumed" : "repl kernel rebuilt";
50
+ if (wedged) return `${verb}, snapshot revive skipped (wedged)`;
52
51
  const revived = restore?.restored.length ?? 0;
53
52
  const lost = restore?.failed.length ?? 0;
54
53
  if (restore && revived > 0) {
55
54
  const counts = lost > 0 ? `, ${lost} lost` : "";
56
55
  const noun = revived === 1 ? "name" : "names";
57
- return resumed
58
- ? `repl session resumed, ${revived} ${noun} revived${counts}`
59
- : `repl kernel rebuilt, ${revived} ${noun} revived${counts}`;
56
+ return `${verb}, ${revived} ${noun} revived${counts}`;
60
57
  }
61
- return resumed
62
- ? restore === null
63
- ? "repl session resumed, nothing saved to revive"
64
- : "repl session resumed, nothing could be revived"
65
- : restore === null
66
- ? "repl kernel rebuilt, nothing saved to revive"
67
- : "repl kernel rebuilt, nothing could be revived";
58
+ return restore === null ? `${verb}, nothing saved to revive` : `${verb}, nothing could be revived`;
68
59
  }
69
60
 
70
61
  function formatEngineResetNotice(restore: RestoreResult | null, origin: AcquireOrigin): string {
@@ -118,88 +109,113 @@ function formatEngineResetNotice(restore: RestoreResult | null, origin: AcquireO
118
109
  return lines.join("\n");
119
110
  }
120
111
 
112
+ /** Human toast when a /fork'd conversation inherited its parent's namespace — same counts as the reset marker, named as a fork so it can't pass for a plain resume. */
113
+ export function formatForkToast(restore: RestoreResult | null): string {
114
+ const revived = restore?.restored.length ?? 0;
115
+ if (revived > 0) {
116
+ const noun = revived === 1 ? "name" : "names";
117
+ return `repl fork started — ${revived} ${noun} inherited from the parent session`;
118
+ }
119
+ return restore === null
120
+ ? "repl fork started — nothing to inherit"
121
+ : "repl fork started — parent snapshot revived nothing";
122
+ }
123
+
124
+ // --- split audience: toast for the human whenever a boot preloaded anything; a marker for the model ONLY when a helper failed (all-good boots are silent) ---
125
+
126
+ export function formatHelperToast(report: readonly HelperLoadResult[]): string {
127
+ const loaded = report.filter((h) => h.ok).map((h) => h.name);
128
+ const failed = report.filter((h) => !h.ok);
129
+ const loadedPart = loaded.length > 0 ? `helpers loaded: ${loaded.join(", ")}` : "no helpers loaded";
130
+ const failedPart =
131
+ failed.length > 0 ? ` · failed: ${failed.map((f) => `${f.name} (${f.error ?? "failed to load"})`).join(", ")}` : "";
132
+ return `repl ${loadedPart}${failedPart}`;
133
+ }
134
+
135
+ export function formatHelperFailuresLine(report: readonly HelperLoadResult[] | null): string | undefined {
136
+ if (!report) return undefined;
137
+ const failed = report.filter((h) => !h.ok);
138
+ if (failed.length === 0) return undefined;
139
+ return `<repl_helpers_failed: ${failed.map((f) => `${f.name} (${f.error ?? "failed to load"})`).join(", ")}>`;
140
+ }
141
+
121
142
  export class EngineLifecycle<E extends RevivableEngine> {
122
143
  private engine?: E;
123
- private revival?: Promise<RestoreResult | null>;
124
144
  private pendingNotice?: string;
125
- private pendingReset?: { origin: AcquireOrigin; restore: RestoreResult | null };
145
+ private pendingReset?: { origin: AcquireOrigin; restore: RestoreResult | null; wedged: boolean };
126
146
  private teardown?: Promise<void>;
127
- /** First-build in progress. */
128
147
  private acquiring?: Promise<{ engine: E; restore: RestoreResult | null; created: boolean }>;
148
+ /** The conversation this engine was built for; a different key on acquire tears it down. */
149
+ private boundKey?: string;
129
150
 
130
151
  constructor(private readonly deps: EngineLifecycleDeps<E>) {}
131
152
 
132
- /** Race one boot attempt against the deadline. The losing attempt is abandoned (and the
133
- * engine killed by the caller): its promise gets a no-op catch so killing the kernel
134
- * cannot surface an unhandled rejection later. */
135
- private bootOnce(
136
- engine: E,
137
- deadlineMs: number,
138
- skipRestore: boolean,
139
- ): Promise<RestoreResult | null | typeof BOOT_WEDGED> {
140
- const work = engine.restoreState(skipRestore).catch(() => null);
153
+ /** Race one boot (kernel start + helpers preload) against the deadline; a failed start is soft — the first cell observes it and rebuilds. */
154
+ private bootOnce(engine: E, deadlineMs: number): Promise<boolean> {
155
+ const work = Promise.resolve()
156
+ .then(() => engine.start(false))
157
+ .catch(() => {});
141
158
  let timer: ReturnType<typeof setTimeout> | undefined;
142
- const guard = new Promise<typeof BOOT_WEDGED>((resolve) => {
143
- timer = setTimeout(() => resolve(BOOT_WEDGED), deadlineMs);
159
+ const guard = new Promise<boolean>((resolve) => {
160
+ timer = setTimeout(() => resolve(false), deadlineMs);
144
161
  timer.unref?.();
145
162
  });
146
- return Promise.race([work, guard]).finally(() => clearTimeout(timer));
163
+ return Promise.race([work.then(() => true), guard]).finally(() => clearTimeout(timer));
147
164
  }
148
165
 
149
- /** Built and revived on demand; awaited so callers never see an un-revived namespace. */
150
- async acquire(origin: AcquireOrigin): Promise<{ engine: E; restore: RestoreResult | null; created: boolean }> {
166
+ /** Built on demand; acquire() never waits on the background restore (announced on the first cell after it completes). sessionKey: a different conversation's acquire tears the bound engine down, so sessions can't bleed into each other. */
167
+ async acquire(
168
+ origin: AcquireOrigin,
169
+ sessionKey?: string,
170
+ ): Promise<{ engine: E; restore: RestoreResult | null; created: boolean }> {
171
+ if (sessionKey !== undefined && this.boundKey !== undefined && sessionKey !== this.boundKey) {
172
+ await this.teardownWith((engine) => this.deps.dispose(engine));
173
+ }
151
174
  if (this.engine) {
152
- return { engine: this.engine, restore: await this.revival!, created: false };
175
+ return { engine: this.engine, restore: null, created: false };
153
176
  }
154
177
  if (this.acquiring) return this.acquiring;
155
178
  const build = (async () => {
156
179
  while (this.teardown) await this.teardown;
157
180
  if (this.engine) {
158
181
  const held: E = this.engine;
159
- return { engine: held, restore: await this.revival!, created: false };
182
+ return { engine: held, restore: null, created: false };
160
183
  }
184
+ this.boundKey = sessionKey;
185
+ const deadline = this.deps.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS;
161
186
  let engine = this.deps.create();
162
187
  this.engine = engine;
163
- // --- the boot is bounded: kernel start, helpers preload, and snapshot restore all
164
- // --- run as kernel cells with no deadline of their own, and acquire() dedupes, so
165
- // --- one wedged boot (venv swapped mid-update, a poisoned pickle) would otherwise
166
- // --- hang the first cell and every cell after it. ---
167
- const deadline = this.deps.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS;
168
- let restore = await this.bootOnce(engine, deadline, false);
169
- if (restore === BOOT_WEDGED) {
170
- // --- the kernel is alive but stuck; only a kill frees it. Retry once WITHOUT the
171
- // --- snapshot, so a poisoned pickle cannot wedge the session twice in a row. ---
188
+ let booted = await this.bootOnce(engine, deadline);
189
+ if (!booted) {
190
+ // --- the kernel is stuck, not dead; kill and retry once WITHOUT the snapshot so a poisoned one can't wedge twice ---
172
191
  await (this.deps.discard ?? this.deps.dispose)(engine);
173
- engine = this.deps.create();
192
+ engine = this.deps.create(true);
174
193
  this.engine = engine;
175
- restore = await this.bootOnce(engine, deadline, true);
176
- if (restore === BOOT_WEDGED) {
194
+ booted = await this.bootOnce(engine, deadline);
195
+ if (!booted) {
177
196
  await (this.deps.discard ?? this.deps.dispose)(engine);
178
197
  this.engine = undefined;
198
+ this.boundKey = undefined;
179
199
  throw new Error("evaluator boot timed out twice (kernel/helpers wedged); no session was started");
180
200
  }
181
- // --- the snapshot existed but reviving it is what wedged: say exactly that, not
182
- // --- "no snapshot available", so the model doesn't go hunting for a missing file ---
183
- if (origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory())) {
184
- this.pendingNotice = [
185
- "<repl_engine_reset>",
186
- revivedNoticeBody(origin),
187
- "Re-verify a variable before reusing it, especially inside shell interpolation.",
188
- "</repl_engine_reset>",
189
- ].join("\n");
190
- this.pendingReset = { origin, restore: null };
191
- }
192
- this.revival = Promise.resolve(null);
193
- return { engine, restore: null, created: true };
194
- }
195
- this.revival = Promise.resolve(restore);
196
- // --- mid-session rebuilds always announce; startup announces only when the
197
- // --- conversation has a saved past, so a first-ever session stays quiet ---
198
- if (origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory())) {
199
- this.pendingNotice = formatEngineResetNotice(restore, origin);
200
- this.pendingReset = { origin, restore };
201
201
  }
202
- return { engine, restore, created: true };
202
+ // --- the notice lands on the first cell AFTER the restore completes; announce mid-session rebuilds and resumes with a saved past, never first sessions ---
203
+ const announce = origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory());
204
+ void engine.restoreResult().then((restore) => {
205
+ if (this.engine !== engine) return; // a replacement engine took over; no stale notice
206
+ if (!announce) return;
207
+ const wedged = engine.restoreWasSkipped();
208
+ this.pendingNotice = wedged
209
+ ? [
210
+ "<repl_engine_reset>",
211
+ revivedNoticeBody(origin),
212
+ "Re-verify a variable before reusing it, especially inside shell interpolation.",
213
+ "</repl_engine_reset>",
214
+ ].join("\n")
215
+ : formatEngineResetNotice(restore, origin);
216
+ this.pendingReset = { origin, restore, wedged };
217
+ });
218
+ return { engine, restore: null, created: true };
203
219
  })();
204
220
  this.acquiring = build;
205
221
  try {
@@ -209,14 +225,16 @@ export class EngineLifecycle<E extends RevivableEngine> {
209
225
  }
210
226
  }
211
227
 
212
- /** Returns the pending reset notice exactly once (alongside its origin and restore result), then clears it. */
213
- takeResetNotice(): { notice: string; origin: AcquireOrigin; restore: RestoreResult | null } | undefined {
228
+ /** The pending reset notice, taken exactly once. */
229
+ takeResetNotice():
230
+ | { notice: string; origin: AcquireOrigin; restore: RestoreResult | null; wedged: boolean }
231
+ | undefined {
214
232
  const reset = this.pendingReset;
215
233
  const notice = this.pendingNotice;
216
234
  this.pendingNotice = undefined;
217
235
  this.pendingReset = undefined;
218
236
  if (!notice || !reset) return undefined;
219
- return { notice, origin: reset.origin, restore: reset.restore };
237
+ return { notice, origin: reset.origin, restore: reset.restore, wedged: reset.wedged };
220
238
  }
221
239
 
222
240
  async shutdown(): Promise<void> {
@@ -231,7 +249,7 @@ export class EngineLifecycle<E extends RevivableEngine> {
231
249
  private async teardownWith(run: (engine: E) => Promise<void>): Promise<void> {
232
250
  const engine = this.engine;
233
251
  this.engine = undefined;
234
- this.revival = undefined;
252
+ this.boundKey = undefined;
235
253
  this.pendingNotice = undefined;
236
254
  if (!engine) return;
237
255
  const teardown = run(engine).finally(() => {
@@ -1,5 +1,4 @@
1
- // --- skills cannot reach the prompt in --repl: pi gates <available_skills> on the read tool
2
- // (hasRead), which repl doesn't have. Re-emit them with pi's own formatter in pi's slot. ---
1
+ // --- pi gates skills on the read tool (absent in repl); re-emit them with pi's own formatter in pi's slot ---
3
2
  import { formatSkillsForPrompt, type Skill } from "@mariozechner/pi-coding-agent";
4
3
 
5
4
  const CWD_MARKER = "\nCurrent working directory:"; // skills sit just before this, pi's last line
@@ -0,0 +1,58 @@
1
+ // --- state lives at ~/.pi/agent/pi-repl/state/<slug>__<conv>: legacy bare-name dirs migrate on the owning conversation's next start, and the sweep knows both formats, so nothing live is swept and a deleted conversation loses its snapshots ---
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+
5
+ export function conversationName(sessionFile: string): string {
6
+ return basename(sessionFile).replace(/\.jsonl$/, "");
7
+ }
8
+
9
+ /** Slug-keyed dir name: conversations whose files share a basename can never share a snapshot. */
10
+ export function sessionStateDirName(sessionFile: string): string {
11
+ return `${basename(dirname(sessionFile))}__${conversationName(sessionFile)}`;
12
+ }
13
+
14
+ function legacyStateDirName(sessionFile: string): string {
15
+ return conversationName(sessionFile);
16
+ }
17
+
18
+ /** Resolve the state dir, migrating a legacy bare-name dir on first start — whichever conversation starts first owns the legacy dir; the other starts empty. */
19
+ export function resolveStateDir(stateRoot: string, sessionFile: string): { dir: string; snapshotPath: string } {
20
+ const dir = join(stateRoot, sessionStateDirName(sessionFile));
21
+ const legacy = join(stateRoot, legacyStateDirName(sessionFile));
22
+ const dirSnap = join(dir, "namespace.snapshot");
23
+ const legacySnap = join(legacy, "namespace.snapshot");
24
+ if (legacy !== dir && !existsSync(dirSnap) && existsSync(legacySnap)) {
25
+ try {
26
+ renameSync(legacy, dir); // migrate; a racing rename (another conversation) just falls through
27
+ } catch {
28
+ // the slug dir already exists or the rename raced: keep it; legacy stays until the sweep sees it live or orphaned
29
+ }
30
+ }
31
+ return { dir, snapshotPath: join(dir, "namespace.snapshot") };
32
+ }
33
+
34
+ /** The /fork header's parentSession -> the parent's own snapshot file, when the fork has no history yet. */
35
+ export function forkParentSnapshot(stateRoot: string, sessionFile: string, snapshotPath: string): string | undefined {
36
+ if (existsSync(snapshotPath)) return undefined; // the fork already built its own history
37
+ let header: { parentSession?: unknown };
38
+ try {
39
+ const first = readFileSync(sessionFile, "utf8").split("\n", 1)[0] ?? "";
40
+ header = JSON.parse(first) as { parentSession?: unknown };
41
+ } catch {
42
+ return undefined; // unreadable / not a session file — not a fork
43
+ }
44
+ if (typeof header.parentSession !== "string" || header.parentSession === "") return undefined;
45
+ const parent = resolveStateDir(stateRoot, header.parentSession);
46
+ return existsSync(parent.snapshotPath) ? parent.snapshotPath : undefined;
47
+ }
48
+
49
+ /** Copy the parent's last snapshot into the fork's own key once (true when something was inherited); the fork then restores like any resume, and the parent is never touched. */
50
+ export function inheritForkSnapshot(stateRoot: string, sessionFile: string, snapshotPath: string): boolean {
51
+ const parentSnap = forkParentSnapshot(stateRoot, sessionFile, snapshotPath);
52
+ if (parentSnap === undefined) return false;
53
+ mkdirSync(dirname(snapshotPath), { recursive: true });
54
+ const tmp = `${snapshotPath}.tmp`;
55
+ copyFileSync(parentSnap, tmp);
56
+ renameSync(tmp, snapshotPath);
57
+ return true;
58
+ }
@@ -1,15 +1,8 @@
1
- // --- tool-meta: thin surface assembling the execute tool's prompt from pure modules ---
2
- // --- the model contract lives in prompt.ts; only the helpers wiring stays here ---
3
-
4
- import { buildHelpersMap, buildHelpersMapForCwd } from "./helpers.js";
5
1
  import { buildPromptGuidelines, executePromptSnippet, executeToolDescription } from "./prompt.js";
6
2
 
7
3
  export const EXECUTE_DESCRIPTION = executeToolDescription;
8
4
  export const EXECUTE_PROMPT_SNIPPET = executePromptSnippet;
9
5
 
10
- // --- build the guidelines from project + global helper dirs ---
11
- export function buildExecutePromptGuidelines(cwd?: string): string[] {
12
- const map = cwd ? buildHelpersMapForCwd(cwd) : buildHelpersMap();
13
- const preloaded = map.length > 0 ? map : [];
14
- return buildPromptGuidelines(preloaded);
6
+ export function buildExecutePromptGuidelines(): string[] {
7
+ return buildPromptGuidelines([]);
15
8
  }