@hicaru/pi-rlm 0.3.8 → 0.3.9

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 (44) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/emitting.ts +5 -23
  4. package/src/bridge/handlers/index.ts +1 -1
  5. package/src/bridge/handlers/llm-query.ts +21 -22
  6. package/src/bridge/handlers/rlm-query.ts +9 -31
  7. package/src/commands/pins.ts +51 -0
  8. package/src/commands/rlm-config.ts +4 -88
  9. package/src/commands/rlm-llm.ts +59 -0
  10. package/src/commands/rlm-rlm.ts +58 -0
  11. package/src/commands/rlm.ts +2 -2
  12. package/src/config/settings.ts +13 -3
  13. package/src/core/engine.ts +16 -2
  14. package/src/core/iteration.ts +5 -0
  15. package/src/core/ledger.ts +14 -122
  16. package/src/core/memory.ts +12 -0
  17. package/src/index.ts +47 -3
  18. package/src/mode/rlm-mode.ts +11 -1
  19. package/src/prompts/glossary.ts +11 -3
  20. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  21. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  22. package/src/tool/repl-render.ts +4 -10
  23. package/src/tool/repl-tool.ts +30 -17
  24. package/src/tool/rlm-aggregator.ts +16 -3
  25. package/src/tool/rlm-details.ts +7 -0
  26. package/src/tool/rlm-events.ts +17 -1
  27. package/src/tool/rlm-tool.ts +25 -14
  28. package/src/tool/subcall-render.ts +14 -129
  29. package/src/tool/subcall-store.ts +11 -1
  30. package/src/ui/intro.ts +13 -4
  31. package/src/ui/modal/agent-modal.ts +104 -0
  32. package/src/ui/modal/modal-view.ts +132 -0
  33. package/src/ui/modal/timeline-store.ts +85 -0
  34. package/src/ui/model-picker/drilldown.ts +173 -0
  35. package/src/ui/model-picker/grouping.ts +81 -0
  36. package/src/ui/model-picker/levels.ts +63 -0
  37. package/src/ui/model-picker.ts +7 -197
  38. package/src/ui/panel/run-registry.ts +135 -0
  39. package/src/ui/panel/tree-panel.ts +46 -0
  40. package/src/ui/status.ts +26 -13
  41. package/src/ui/theme.ts +0 -4
  42. package/src/ui/tree/tree-model.ts +221 -0
  43. package/src/ui/tree/tree-rows.ts +73 -0
  44. package/src/ui/tree/tree-widget.ts +186 -0
@@ -139,111 +139,10 @@ export function taskKey(
139
139
  }
140
140
 
141
141
  export const ECHO_STUB: string = Object.freeze(
142
- "[ledger echo] this task restates an ancestor goal the parent run already covers it. " +
143
- "Do not spawn a duplicate; answer from what you already know or await the existing task.",
142
+ "[ledger: ancestor echo rejected. You are already doing this task. " +
143
+ "Use context / answers / the files already in scope. Do not rlm_query the parent task.]",
144
144
  );
145
145
 
146
- const RLM_CALL_OPEN = /\brlm_(?:query|batch)\s*\(/g;
147
-
148
- /**
149
- * Native `repl()` cells are Python, not a task (audit R1). Pull quoted
150
- * `rlm_query` / `rlm_batch` arguments so `beginRun` has a task-shaped ancestor
151
- * instead of `print` / `await_task` tokens. Falls back to the raw cell when no
152
- * such call is present. `paths=` keyword args are not tasks.
153
- */
154
- export function nativeRunAncestors(code: string): readonly string[] {
155
- const found = extractRlmTaskPrompts(code);
156
- return Object.freeze(found.length > 0 ? found : [code]);
157
- }
158
-
159
- function extractRlmTaskPrompts(code: string): readonly string[] {
160
- const out: string[] = [];
161
- RLM_CALL_OPEN.lastIndex = 0;
162
- for (const m of code.matchAll(RLM_CALL_OPEN)) {
163
- const start = (m.index ?? 0) + m[0].length;
164
- const body = sliceCallBody(code, start);
165
- const pathSplit = body.split(/\bpaths\s*=/);
166
- const taskPart = pathSplit[0] ?? body;
167
- const strings = quotedStrings(taskPart);
168
- for (let i = 0; i < strings.length; i++) {
169
- const s = strings[i];
170
- if (s !== undefined && s.trim() !== "") out.push(s);
171
- }
172
- }
173
- return out;
174
- }
175
-
176
- function sliceCallBody(src: string, start: number): string {
177
- let depth = 1;
178
- let i = start;
179
- while (i < src.length && depth > 0) {
180
- const c = src[i];
181
- if (c === "'" || c === '"') {
182
- i = skipPyString(src, i);
183
- continue;
184
- }
185
- if (c === "#") {
186
- const nl = src.indexOf("\n", i);
187
- i = nl === -1 ? src.length : nl + 1;
188
- continue;
189
- }
190
- if (c === "(") depth++;
191
- else if (c === ")") depth--;
192
- i++;
193
- }
194
- return src.slice(start, depth === 0 ? i - 1 : i);
195
- }
196
-
197
- function quotedStrings(src: string): readonly string[] {
198
- const out: string[] = [];
199
- let i = 0;
200
- while (i < src.length) {
201
- const c = src[i];
202
- if (c === "'" || c === '"') {
203
- const parsed = readPyString(src, i);
204
- if (parsed.keep) out.push(parsed.value);
205
- i = parsed.end;
206
- continue;
207
- }
208
- i++;
209
- }
210
- return out;
211
- }
212
-
213
- function skipPyString(src: string, quoteAt: number): number {
214
- return readPyString(src, quoteAt).end;
215
- }
216
-
217
- function readPyString(
218
- src: string,
219
- quoteAt: number,
220
- ): { readonly value: string; readonly end: number; readonly keep: boolean } {
221
- const quote = src[quoteAt] ?? '"';
222
- const prefix = quoteAt > 0 ? src[quoteAt - 1] : "";
223
- const keep = prefix !== "f" && prefix !== "F";
224
- const triple = src.startsWith(quote + quote + quote, quoteAt);
225
- const delimLen = triple ? 3 : 1;
226
- const from = quoteAt + delimLen;
227
- if (triple) {
228
- const close = src.indexOf(quote + quote + quote, from);
229
- if (close === -1) return { value: src.slice(from), end: src.length, keep };
230
- return { value: src.slice(from, close), end: close + 3, keep };
231
- }
232
- const parts: string[] = [];
233
- let j = from;
234
- while (j < src.length) {
235
- const ch = src[j];
236
- if (ch === "\\") {
237
- parts.push(src[j + 1] ?? "");
238
- j += 2;
239
- continue;
240
- }
241
- if (ch === quote) return { value: parts.join(""), end: j + 1, keep };
242
- parts.push(ch ?? "");
243
- j++;
244
- }
245
- return { value: parts.join(""), end: src.length, keep };
246
- }
247
146
 
248
147
  export class TaskLedger {
249
148
  private readonly claims = new Map<string, Claim>();
@@ -252,7 +151,11 @@ export class TaskLedger {
252
151
  private readonly hitCounts = { exact: 0, echo: 0, near: 0 };
253
152
  private rlmRuns = 0;
254
153
 
255
- /** Engine marks the active run's root prompt — the ancestor chain for echo detection. */
154
+ /** Engine marks the active run's root prompt — the ancestor chain for echo detection.
155
+ * v5 `begin_run` parity: the ONLY producer. `endRun` pops in the engine's finally, so a
156
+ * stack entry exists exactly while that engine is RUNNING — v5's `status in (pending,
157
+ * running)` filter is structural here. Native `repl()` cells never push ancestors: their
158
+ * spawns claim against an empty stack, so an originator can never echo against itself. */
256
159
  beginRun(rootPrompt: string): void {
257
160
  this.stack.push(normalizePrompt(rootPrompt));
258
161
  }
@@ -261,20 +164,6 @@ export class TaskLedger {
261
164
  this.stack.pop();
262
165
  }
263
166
 
264
- /** Native `repl()` path (audit R1): push task-shaped ancestors extracted from the cell. */
265
- beginNativeCell(code: string): number {
266
- const ancestors = nativeRunAncestors(code);
267
- for (let i = 0; i < ancestors.length; i++) {
268
- const a = ancestors[i];
269
- if (a !== undefined) this.beginRun(a);
270
- }
271
- return ancestors.length;
272
- }
273
-
274
- endNativeCell(n: number): void {
275
- for (let i = 0; i < n; i++) this.endRun();
276
- }
277
-
278
167
  /** A child prompt echoing any ancestor (exact or ≥ 0.8 Jaccard) is rejected as a stub. */
279
168
  detectEcho(prompt: string): boolean {
280
169
  const np = normalizePrompt(prompt);
@@ -410,11 +299,13 @@ export class TaskLedger {
410
299
  return Object.freeze({ ...this.hitCounts });
411
300
  }
412
301
 
413
- /** Compact table for the sandbox's `list_claims()` REPL call. */
302
+ /** Compact table for the sandbox's `list_claims()` REPL call. Echo hits are appended —
303
+ * a suppressed spawn must be visible from inside the session (audit BUG-1). */
414
304
  listClaims(): string {
415
- if (this.claims.size === 0) return "ledger: no claims";
305
+ const echoNote = this.hitCounts.echo > 0 ? ` (echo_rejected=${this.hitCounts.echo})` : "";
306
+ if (this.claims.size === 0) return `ledger: no claims${echoNote}`;
416
307
  const lines: string[] = new Array<string>(this.claims.size + 1);
417
- lines[0] = "ledger claims:";
308
+ lines[0] = `ledger claims:${echoNote}`;
418
309
  let n = 1;
419
310
  for (const c of this.claims.values()) {
420
311
  lines[n++] = ` ${c.key.slice(0, 8)} ${c.kind} ${c.status} depth=${c.depth} paths=${pathSig(c.paths) || "-"} '${c.prompt.slice(0, PROMPT_PREVIEW)}'`;
@@ -435,7 +326,8 @@ export class TaskLedger {
435
326
  if (inflight.length === 0 && done.length === 0 && stackN <= 1) return "";
436
327
  const lines: string[] = [
437
328
  "[ledger]",
438
- ` depth_stack=${stackN} inflight=${inflight.length} done=${done.length}`,
329
+ ` depth_stack=${stackN} inflight=${inflight.length} done=${done.length}` +
330
+ (this.hitCounts.echo > 0 ? ` echo_rejected=${this.hitCounts.echo}` : ""),
439
331
  " rlm_query only for a disjoint goal. ancestor echo is rejected.",
440
332
  ];
441
333
  if (inflight.length > 0) {
@@ -15,6 +15,7 @@ import { createHash } from "node:crypto";
15
15
  import { closeSync, openSync, readSync } from "node:fs";
16
16
  import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
17
17
  import { dirname, join, resolve, sep } from "node:path";
18
+ import { formatError } from "../util/errors.ts";
18
19
 
19
20
  const TOK = /[a-z0-9]{2,}/g;
20
21
  const EPISODE_CAP = 4_000;
@@ -123,6 +124,10 @@ export function rootContextPaths(context: unknown, max: number): readonly string
123
124
  return Object.freeze(out);
124
125
  }
125
126
 
127
+ /** Who is calling serviceOp — delegation children read durable notes but never write them
128
+ * (their findings persist via recordEpisode in the rlm_query handler, one per run). */
129
+ export type MemoryScope = "root" | "child";
130
+
126
131
  export class MemoryStore {
127
132
  readonly enabled: boolean;
128
133
  private dir: string | undefined;
@@ -462,9 +467,16 @@ export class MemoryStore {
462
467
  serviceOp(
463
468
  op: "query" | "add" | "stats",
464
469
  args: { readonly query?: string; readonly k?: number; readonly content?: string; readonly paths?: readonly string[]; readonly tags?: readonly string[] },
470
+ scope: MemoryScope = "root",
465
471
  ): string {
466
472
  if (!this.enabled) return "memory disabled";
467
473
  if (op === "stats") return JSON.stringify(this.stats());
474
+ if (op === "add" && scope === "child") {
475
+ return formatError(
476
+ "memory.add is root-only — query durable notes with memory.query; " +
477
+ "your final answer is recorded as an episode automatically",
478
+ );
479
+ }
468
480
  if (op === "add") {
469
481
  const n = this.addNote({ content: args.content ?? "", paths: args.paths ?? [], tags: args.tags ?? [] });
470
482
  return n === undefined ? "add skipped (empty content)" : `ok note ${n.id}`;
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { Markdown } from "@earendil-works/pi-tui";
5
5
  import { registerRlmCommand } from "./commands/rlm.ts";
6
6
  import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
7
+ import { registerRlmLlmCommand } from "./commands/rlm-llm.ts";
8
+ import { registerRlmRlmCommand } from "./commands/rlm-rlm.ts";
7
9
  import type { RlmConfig } from "./core/types.ts";
8
10
  import { createRlmTool } from "./tool/rlm-tool.ts";
9
11
  import { createReplTool } from "./tool/repl-tool.ts";
@@ -12,6 +14,8 @@ import { RlmController } from "./mode/rlm-mode.ts";
12
14
  import { cheapestModel } from "./mode/llm-model.ts";
13
15
  import { postRlmGuide } from "./ui/intro.ts";
14
16
  import { setRlmModeStatus } from "./ui/status.ts";
17
+ import { RunRegistry } from "./ui/panel/run-registry.ts";
18
+ import { installTreePanel } from "./ui/panel/tree-panel.ts";
15
19
  import { markdownTheme } from "./ui/theme-adapter.ts";
16
20
  import { SANDBOX_WATCHDOG_HEARTBEAT_MS } from "./sandbox/sandbox.ts";
17
21
  import { SandboxManager } from "./sandbox/sandbox-manager.ts";
@@ -98,6 +102,19 @@ export default function rlmExtension(pi: ExtensionAPI): void {
98
102
  maxTokens: config.maxTokens,
99
103
  maxErrors: config.maxErrors,
100
104
  });
105
+ // Session tree panel: every repl cell / rlm run / detached bg task registers here;
106
+ // the below-editor widget + agent modal read from it. Background is persistent
107
+ // and hides itself while idle.
108
+ const runRegistry = new RunRegistry();
109
+ runRegistry.register({
110
+ runId: "background",
111
+ label: "background tasks",
112
+ emitter: background.emitter,
113
+ subcalls: () => background.liveSubcalls(),
114
+ totals: () => background.liveTotals(),
115
+ hideWhenEmpty: true,
116
+ });
117
+ let treePanelInstalled = false;
101
118
  // A detached child works in its OWN sandbox, so this one sees no frames and its request
102
119
  // watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
103
120
  // with it. Keep it alive while detached work is genuinely in flight.
@@ -161,9 +178,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
161
178
  // ── Commands ──
162
179
  registerRlmCommand(pi, controller);
163
180
  registerRlmConfigCommand(pi, controller);
181
+ registerRlmLlmCommand(pi, controller);
182
+ registerRlmRlmCommand(pi, controller);
164
183
 
165
184
  // ── Tool registration ──
166
- pi.registerTool(createRlmTool(controller));
185
+ pi.registerTool(createRlmTool(controller, runRegistry));
167
186
  let guidePosted = false;
168
187
 
169
188
  pi.on("session_start", async (_event, ctx) => {
@@ -172,6 +191,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
172
191
  const persisted = await loadSettings();
173
192
  controller.config = mergeConfig(persisted.config);
174
193
  controller.savedLlmRef = persisted.llm ?? undefined;
194
+ controller.savedRlmRef = persisted.rlm ?? undefined;
175
195
 
176
196
  // An explicit --rlm flag wins over the persisted setting for this session.
177
197
  const flag = pi.getFlag("rlm");
@@ -188,6 +208,25 @@ export default function rlmExtension(pi: ExtensionAPI): void {
188
208
  console.warn(`[rlm] model registry refresh failed: ${errorMessage(err)}`);
189
209
  }
190
210
 
211
+ if (controller.savedRlmRef) {
212
+ const resolvedRlm = resolveModelId(ctx.modelRegistry, controller.savedRlmRef);
213
+ if (resolvedRlm) {
214
+ controller.rlmModel = resolvedRlm;
215
+ } else {
216
+ console.warn(
217
+ `[rlm] pinned rlm model ${controller.savedRlmRef} not in registry; following session model until it reappears`,
218
+ );
219
+ try {
220
+ ctx.ui.notify(
221
+ `RLM: pinned rlm=${controller.savedRlmRef} unavailable — following session model until it is`,
222
+ "warning",
223
+ );
224
+ } catch {
225
+ // Some hosts have no UI at session_start.
226
+ }
227
+ }
228
+ }
229
+
191
230
  if (controller.savedLlmRef) {
192
231
  const resolved = resolveModelId(ctx.modelRegistry, controller.savedLlmRef);
193
232
  if (resolved) {
@@ -259,6 +298,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
259
298
  gates: resolveSessionGates(),
260
299
  resolveGates: resolveSessionGates,
261
300
  background,
301
+ runRegistry,
262
302
  memory,
263
303
  registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
264
304
  registerContextBundle: (bundle) => {
@@ -282,7 +322,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
282
322
  }
283
323
  }
284
324
 
285
- setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
325
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
326
+ if (!treePanelInstalled) {
327
+ treePanelInstalled = true;
328
+ installTreePanel(ctx, runRegistry);
329
+ }
286
330
  if (!guidePosted && controller.enabled) {
287
331
  guidePosted = true;
288
332
  postRlmGuide(pi, controller);
@@ -291,7 +335,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
291
335
 
292
336
  // ── Keep the footer's context reading live (RLM exists to shrink this number) ──
293
337
  pi.on("turn_end", async (_event, ctx) => {
294
- setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
338
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
295
339
  });
296
340
 
297
341
  /** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
@@ -35,6 +35,11 @@ export class RlmController {
35
35
  savedLlmRef: string | undefined;
36
36
  /** Set by applyLlmSelection when the user explicitly picks "cheapest (auto)". */
37
37
  explicitClearPin = false;
38
+ /** Pinned rlm root/worker model — when unset, child engines follow pi's session model. */
39
+ rlmModel: Model<Api> | undefined;
40
+ savedRlmRef: string | undefined;
41
+ /** Set by applyRlmSelection when the user explicitly picks "(follow session model)". */
42
+ explicitClearRlmPin = false;
38
43
  private active: AbortController | null = null;
39
44
  /** v5: session admission gates (provider-capped), shared with the repl() tool — set at
40
45
  * session_start so BOTH composition roots admit through one pool (audit C1). */
@@ -78,6 +83,9 @@ export class RlmController {
78
83
  llm: this.explicitClearPin
79
84
  ? null
80
85
  : (modelRef(this.llmModel) ?? this.savedLlmRef),
86
+ rlm: this.explicitClearRlmPin
87
+ ? null
88
+ : (modelRef(this.rlmModel) ?? this.savedRlmRef),
81
89
  });
82
90
  }
83
91
 
@@ -91,7 +99,9 @@ export class RlmController {
91
99
 
92
100
  resolveModels(ctx: ExtensionContext): { model: Model<Api>; llm: Model<Api> } | undefined {
93
101
  if (!this.llmModel && this.savedLlmRef) this.llmModel = resolveModelId(ctx.modelRegistry, this.savedLlmRef);
94
- const model = ctx.model ?? cheapestModel(ctx.modelRegistry);
102
+ if (!this.rlmModel && this.savedRlmRef) this.rlmModel = resolveModelId(ctx.modelRegistry, this.savedRlmRef);
103
+ // The rlm pin wins over the session model; unset → follow pi's active model.
104
+ const model = this.rlmModel ?? ctx.model ?? cheapestModel(ctx.modelRegistry);
95
105
  if (!model) return undefined;
96
106
  const llm = this.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
97
107
  return { model, llm };
@@ -357,9 +357,17 @@ export function replGlossary(
357
357
  " verified result in `answers` — see the decomposition doctrine below.",
358
358
  "- `SHOW_VARS() -> str`: list every variable currently in the REPL (Task handles show as `<Task …>`).",
359
359
  "- `list_tasks()`: every Task this REPL created — [{kind, label, done, var}].",
360
- "- `memory.query(q) -> str` / `memory.add(text, paths=…, tags=…)`: durable notes under `.rlm/`",
361
- " that survive across sessions. Query before re-studying a known area; add concise findings",
362
- " (facts, locations, decisions) never secrets or API keys (notes persist on disk).",
360
+ ...(delegation
361
+ ? [
362
+ "- `memory.query(q) -> str`: durable notes under `.rlm/` that survive across sessions.",
363
+ " **READ-ONLY here** — `memory.add` is root-only. Query before re-studying a known area;",
364
+ " your own final answer is recorded as an episode automatically.",
365
+ ]
366
+ : [
367
+ "- `memory.query(q) -> str` / `memory.add(text, paths=…, tags=…)`: durable notes under `.rlm/`",
368
+ " that survive across sessions. Query before re-studying a known area; add concise findings",
369
+ " (facts, locations, decisions) — never secrets or API keys (notes persist on disk).",
370
+ ]),
363
371
  "- `list_claims()`: the live `[ledger]` table of inflight/done agent work.",
364
372
  '- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
365
373
  ' set `answer["content"]` to the answer text and `answer["ready"] = True`.',
@@ -1,9 +1,10 @@
1
- /** repl() tool TUI views — collapsed one-liner card and the expanded output/sub-call tree. */
1
+ /** repl() tool TUI views — the single-line card and the expanded output view.
2
+ * Sub-call trees are not rendered here; the live tree widget owns agent visualization. */
2
3
 
3
4
  import type { Theme } from "@earendil-works/pi-coding-agent";
4
5
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
5
6
  import type { ReplDetails } from "./repl-details.ts";
6
- import { cardHeader, cardStatsLine, renderCollapsedCard, renderExpandedSubcallTree } from "./subcall-render.ts";
7
+ import { cardHeader, cardStatsLine, renderCollapsedCard } from "./subcall-render.ts";
7
8
 
8
9
  /** Chars of stdout/stderr shown in the expanded view. */
9
10
  const EXPANDED_STDOUT_CHARS = 2_000;
@@ -17,7 +18,7 @@ export function replStats(details: ReplDetails, theme: Theme): string {
17
18
  }
18
19
 
19
20
  export function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
20
- return renderCollapsedCard("REPL", details.status, replStats(details, theme), details.subcalls, theme);
21
+ return renderCollapsedCard("REPL", details.status, replStats(details, theme), theme);
21
22
  }
22
23
 
23
24
  // ── Expanded view ──
@@ -47,12 +48,5 @@ export function renderReplExpanded(details: ReplDetails, theme: Theme): Containe
47
48
  container.addChild(new Text(theme.fg("error", details.stderr.slice(0, EXPANDED_STDERR_CHARS)), 0, 0));
48
49
  }
49
50
 
50
- // Sub-call tree
51
- if (details.subcalls.length > 0) {
52
- container.addChild(new Spacer(1));
53
- container.addChild(new Text(theme.fg("muted", "─── Sub-calls ───"), 0, 0));
54
- container.addChild(renderExpandedSubcallTree(details.subcalls, theme));
55
- }
56
-
57
51
  return container;
58
52
  }
@@ -33,10 +33,12 @@ import type { MemoryStore } from "../core/memory.ts";
33
33
  import { BackgroundTasks } from "./background-tasks.ts";
34
34
  import type { ReplResult } from "../sandbox/protocol.ts";
35
35
  import { RlmEmitter } from "./rlm-events.ts";
36
+ import type { RunRegistry } from "../ui/panel/run-registry.ts";
36
37
  import { SubcallStore } from "./subcall-store.ts";
37
38
  import type { ReplDetails } from "./repl-details.ts";
38
39
  import type { RlmSubcall } from "./rlm-details.ts";
39
40
  import { createEngine } from "../core/engine.ts";
41
+ import { modelRef } from "../config/settings.ts";
40
42
  import { spinnerFrame } from "../ui/theme.ts";
41
43
  import { previewText } from "../text/preview.ts";
42
44
  import { errorMessage } from "../util/errors.ts";
@@ -117,6 +119,8 @@ export interface ReplToolDeps {
117
119
  readonly resolveGates?: () => SubcallGates;
118
120
  /** Session-scoped home for detached spawn() work. */
119
121
  readonly background: BackgroundTasks;
122
+ /** Session tree panel index; omitted → runs don't appear in the widget. */
123
+ readonly runRegistry?: RunRegistry;
120
124
  /** v5 durable memory (session-wide `.rlm` store); omitted → memory off for this tool. */
121
125
  readonly memory?: MemoryStore;
122
126
  readonly signal?: AbortSignal;
@@ -250,6 +254,20 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
250
254
 
251
255
  const emitter = new RlmEmitter();
252
256
  const store = new SubcallStore(emitter);
257
+ // Surface the cell in the tree panel for its whole lifetime (unregistered in finally).
258
+ const unregisterRun = deps.runRegistry?.register({
259
+ runId: `repl-${_toolCallId}`,
260
+ label: `repl: ${previewText(params.code, 40)}`,
261
+ emitter,
262
+ subcalls: () => store.getSubcalls(),
263
+ totals: () => store.getTotals(),
264
+ // Root = the session's default (pi) model driving this repl cell, own spend only.
265
+ rootModel: () => {
266
+ const m = getModel();
267
+ return modelRef(m) ?? m.id;
268
+ },
269
+ rootTokens: () => store.getRootUsage().tokens,
270
+ });
253
271
  let capturedStdout = "";
254
272
  let capturedStderr = "";
255
273
  let progressStatus: ReplDetails["status"] = "running";
@@ -319,23 +337,17 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
319
337
  }
320
338
 
321
339
  const start = Date.now();
322
- // v5 blackboard (audit C3 / R1): ancestors are the rlm_query/rlm_batch task
323
- // strings inside this cell, not the Python soup (`print`, `await_task`). A
324
- // child restating the user's goal then echoes. Popped in finally: detached
325
- // work spawned by this cell already claimed at spawn time, inside this window.
326
- const ledgerActive = getConfig().enableLedger;
327
- const ancestorN = ledgerActive ? sessionLedger.beginNativeCell(params.code) : 0;
328
- let result: ReplResult;
329
- try {
330
- result = await sandboxManager.execWithSetup(params.code, () => {
331
- // Wire per-invocation mutable state only after the serialized exec slot
332
- // is active. Swapping earlier would let queued repl() calls overwrite
333
- // emitter/limits for the currently running REPL execution.
334
- bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
335
- }, execSignal);
336
- } finally {
337
- if (ledgerActive) sessionLedger.endNativeCell(ancestorN);
338
- }
340
+ // v5 blackboard (audit C3 / BUG-1): ancestors are RUNNING engines only — engine.ts
341
+ // brackets each child run with beginRun/endRun. A native cell pushes NOTHING: its
342
+ // spawns claim against an empty stack, so an originator can never echo against
343
+ // itself. Duplicates are caught by the ledger's claim store (exact/near
344
+ // coalescing + rlmBudget demotion), never by silent suppression.
345
+ const result: ReplResult = await sandboxManager.execWithSetup(params.code, () => {
346
+ // Wire per-invocation mutable state only after the serialized exec slot
347
+ // is active. Swapping earlier would let queued repl() calls overwrite
348
+ // emitter/limits for the currently running REPL execution.
349
+ bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
350
+ }, execSignal);
339
351
  const elapsed = Date.now() - start;
340
352
  capturedStdout = result.stdout;
341
353
  capturedStderr = result.stderr;
@@ -427,6 +439,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
427
439
  };
428
440
  } finally {
429
441
  progress.stop();
442
+ unregisterRun?.();
430
443
  for (const off of detachTracers) off();
431
444
  store.dispose();
432
445
  emitter.shutdown();
@@ -13,16 +13,17 @@
13
13
  */
14
14
 
15
15
  import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
16
- import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, StatusEvent, RootPromptEvent } from "./rlm-events.ts";
17
- import type { RlmDetails, RlmRunStatus } from "./rlm-details.ts";
16
+ import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, StatusEvent, RootPromptEvent, RootPhaseEvent } from "./rlm-events.ts";
17
+ import type { RlmDetails, RlmRunStatus, SubcallPhase } from "./rlm-details.ts";
18
18
  import { EmitterListener } from "./emitter-listener.ts";
19
- import { SubcallStore } from "./subcall-store.ts";
19
+ import { SubcallStore, type SubcallTotals } from "./subcall-store.ts";
20
20
 
21
21
  export class RlmEventAggregator extends EmitterListener {
22
22
  private readonly store: SubcallStore;
23
23
 
24
24
  // Root-level state
25
25
  private rootStatus: RlmRunStatus = "running";
26
+ private rootPhase?: SubcallPhase;
26
27
  private rootPrompt = "";
27
28
  private turnCurrent = 0;
28
29
  private turnMax = 0;
@@ -41,6 +42,7 @@ export class RlmEventAggregator extends EmitterListener {
41
42
  emitter.onAnswer((e) => this.handleAnswer(e)),
42
43
  emitter.onStatus((e) => this.handleStatus(e)),
43
44
  emitter.onRootPrompt((e) => this.handleRootPrompt(e)),
45
+ emitter.onRootPhase((e) => this.handleRootPhase(e)),
44
46
  ]);
45
47
  }
46
48
 
@@ -72,12 +74,18 @@ export class RlmEventAggregator extends EmitterListener {
72
74
  // No notify — root prompt is set before listeners exist; no TUI re-render needed
73
75
  }
74
76
 
77
+ private handleRootPhase(event: RootPhaseEvent): void {
78
+ this.rootPhase = event.phase;
79
+ this.notify();
80
+ }
81
+
75
82
  // ── Read ──
76
83
 
77
84
  /** Snapshot the current accumulated state. O(1). */
78
85
  getState(): RlmDetails {
79
86
  return {
80
87
  status: this.rootStatus,
88
+ rootPhase: this.rootPhase,
81
89
  rootPrompt: this.rootPrompt,
82
90
  turns: { current: this.turnCurrent, max: this.turnMax },
83
91
  subcalls: this.store.getSubcalls(),
@@ -86,6 +94,11 @@ export class RlmEventAggregator extends EmitterListener {
86
94
  };
87
95
  }
88
96
 
97
+ /** Root engine's OWN spend (driver-model turns) — never blends sub-call models. */
98
+ getRootUsage(): SubcallTotals {
99
+ return this.store.getRootUsage();
100
+ }
101
+
89
102
  // ── Lifecycle ──
90
103
 
91
104
  /** Detach all emitter listeners. Call after the run completes. */
@@ -8,6 +8,9 @@
8
8
 
9
9
  export type SubcallKind = "root" | "rlm" | "llm" | "batch" | "tool";
10
10
  export type SubcallStatus = "running" | "done" | "error";
11
+
12
+ /** Live activity of a node while status is "running" — powers the tree/modal UI. */
13
+ export type SubcallPhase = "thinking" | "texting" | "repl" | "waiting" | "spawning";
11
14
  export type RlmRunStatus = "running" | "done" | "error" | "aborted";
12
15
 
13
16
  export interface RlmSubcall {
@@ -20,6 +23,8 @@ export interface RlmSubcall {
20
23
  readonly label: string;
21
24
  readonly model?: string;
22
25
  readonly status: SubcallStatus;
26
+ /** Current activity while running (undefined = not reported). */
27
+ readonly phase?: SubcallPhase;
23
28
  readonly detail?: string;
24
29
  readonly args?: string;
25
30
  readonly resultPreview?: string;
@@ -35,6 +40,8 @@ export interface RlmSubcall {
35
40
 
36
41
  export interface RlmDetails {
37
42
  readonly status: RlmRunStatus;
43
+ /** Root node's live activity phase (root has no subcall entry). */
44
+ readonly rootPhase?: SubcallPhase;
38
45
  readonly rootPrompt: string;
39
46
  readonly turns: { readonly current: number; readonly max: number };
40
47
  readonly subcalls: readonly RlmSubcall[];
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { EventEmitter } from "node:events";
14
- import type { SubcallKind, SubcallStatus, RlmRunStatus } from "./rlm-details.ts";
14
+ import type { SubcallKind, SubcallStatus, RlmRunStatus, SubcallPhase } from "./rlm-details.ts";
15
15
 
16
16
  // ── Event payloads ──
17
17
 
@@ -32,6 +32,8 @@ export interface SubcallCreatedEvent {
32
32
  export interface SubcallUpdatedEvent {
33
33
  readonly id: string;
34
34
  readonly status?: SubcallStatus;
35
+ /** Live activity while running (thinking/repl/waiting…). */
36
+ readonly phase?: SubcallPhase;
35
37
  readonly detail?: string;
36
38
  readonly args?: string;
37
39
  readonly resultPreview?: string;
@@ -67,6 +69,10 @@ export interface RootPromptEvent {
67
69
  readonly text: string;
68
70
  }
69
71
 
72
+ export interface RootPhaseEvent {
73
+ readonly phase: SubcallPhase;
74
+ }
75
+
70
76
  // ── RlmEmitter ──
71
77
 
72
78
  /**
@@ -127,6 +133,11 @@ export class RlmEmitter {
127
133
  this.ee.emit("root-prompt", { text } satisfies RootPromptEvent);
128
134
  }
129
135
 
136
+ /** Set the root node's live activity phase (root-only; children use subcall updates). */
137
+ emitRootPhase(phase: SubcallPhase): void {
138
+ this.ee.emit("root-phase", { phase } satisfies RootPhaseEvent);
139
+ }
140
+
130
141
  // ── Subscribe (returns unsubscribe function) ──
131
142
 
132
143
  onSubcallCreated(handler: (event: SubcallCreatedEvent) => void): () => void {
@@ -149,6 +160,11 @@ export class RlmEmitter {
149
160
  return () => { this.ee.off("root-usage", handler); };
150
161
  }
151
162
 
163
+ onRootPhase(handler: (event: RootPhaseEvent) => void): () => void {
164
+ this.ee.on("root-phase", handler);
165
+ return () => { this.ee.off("root-phase", handler); };
166
+ }
167
+
152
168
  onAnswer(handler: (event: AnswerEvent) => void): () => void {
153
169
  this.ee.on("answer", handler);
154
170
  return () => { this.ee.off("answer", handler); };