@hicaru/pi-rlm 0.3.6 → 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 (63) 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 +84 -29
  6. package/src/bridge/handlers/rlm-query.ts +133 -33
  7. package/src/bridge/handlers/types.ts +12 -0
  8. package/src/commands/pins.ts +51 -0
  9. package/src/commands/rlm-config.ts +4 -88
  10. package/src/commands/rlm-llm.ts +59 -0
  11. package/src/commands/rlm-rlm.ts +58 -0
  12. package/src/commands/rlm.ts +2 -2
  13. package/src/config/defaults.ts +17 -0
  14. package/src/config/settings.ts +58 -5
  15. package/src/core/answer.ts +7 -10
  16. package/src/core/budget.ts +182 -0
  17. package/src/core/compaction.ts +46 -0
  18. package/src/core/engine.ts +185 -5
  19. package/src/core/iteration.ts +5 -0
  20. package/src/core/ledger.ts +343 -0
  21. package/src/core/memory.ts +589 -0
  22. package/src/core/model-registry.ts +88 -0
  23. package/src/core/types.ts +44 -3
  24. package/src/index.ts +107 -12
  25. package/src/mode/rlm-mode.ts +58 -10
  26. package/src/prompts/glossary.ts +147 -57
  27. package/src/prompts/native.ts +12 -7
  28. package/src/prompts/system.ts +22 -7
  29. package/src/prompts/user.ts +6 -3
  30. package/src/sandbox/interrupts.ts +24 -0
  31. package/src/sandbox/protocol.ts +69 -5
  32. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  34. package/src/sandbox/py/guards.py +11 -6
  35. package/src/sandbox/py/scaffold.py +615 -0
  36. package/src/sandbox/py/worker.py +53 -506
  37. package/src/sandbox/sandbox.ts +21 -3
  38. package/src/text/repl-output.ts +15 -0
  39. package/src/tool/repl-render.ts +4 -10
  40. package/src/tool/repl-result.ts +54 -10
  41. package/src/tool/repl-tool.ts +50 -3
  42. package/src/tool/rlm-aggregator.ts +16 -3
  43. package/src/tool/rlm-details.ts +7 -0
  44. package/src/tool/rlm-events.ts +17 -1
  45. package/src/tool/rlm-tool.ts +25 -14
  46. package/src/tool/subcall-render.ts +14 -129
  47. package/src/tool/subcall-store.ts +11 -1
  48. package/src/ui/intro.ts +13 -4
  49. package/src/ui/modal/agent-modal.ts +104 -0
  50. package/src/ui/modal/modal-view.ts +132 -0
  51. package/src/ui/modal/timeline-store.ts +85 -0
  52. package/src/ui/model-picker/drilldown.ts +173 -0
  53. package/src/ui/model-picker/grouping.ts +81 -0
  54. package/src/ui/model-picker/levels.ts +63 -0
  55. package/src/ui/model-picker.ts +7 -197
  56. package/src/ui/panel/run-registry.ts +135 -0
  57. package/src/ui/panel/tree-panel.ts +46 -0
  58. package/src/ui/status.ts +26 -10
  59. package/src/ui/theme.ts +0 -4
  60. package/src/ui/tree/tree-model.ts +221 -0
  61. package/src/ui/tree/tree-rows.ts +73 -0
  62. package/src/ui/tree/tree-widget.ts +186 -0
  63. package/src/util/concurrency.ts +47 -0
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  import {
15
15
  isInterrupt,
16
16
  isWorkerMessage,
17
+ parsePendingTasks,
17
18
  type ParentMessage,
18
19
  type ReplResult,
19
20
  type WorkerMessage,
@@ -29,6 +30,9 @@ export type { AddContextResult, SubcallOpts, SubLlmHandlers } from "./interrupts
29
30
  export interface SandboxOptions {
30
31
  /** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
31
32
  readonly depth?: number;
33
+ /** v5 role separation: "child" sandboxes install the delegation-only scaffold (no
34
+ * search/grep_context/outline/add_context — retrieval belongs to the root). */
35
+ readonly surface?: "root" | "child";
32
36
  /** Per-`repl`-block wall-clock timeout inside the worker (seconds). */
33
37
  readonly execTimeoutS?: number;
34
38
  /** Parent-side watchdog per request (ms); on breach the worker is SIGKILLed. */
@@ -52,6 +56,8 @@ export interface SandboxOptions {
52
56
 
53
57
  const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "py", "worker.py");
54
58
  const STDERR_TAIL_CHARS = 8_192;
59
+ /** How often to refresh the parent request watchdog (and ping the worker) during silent host work. */
60
+ export const SANDBOX_WATCHDOG_HEARTBEAT_MS = 30_000;
55
61
  /** How long dispose() waits for a clean worker exit before escalating to SIGKILL. */
56
62
  const SHUTDOWN_GRACE_MS = 50;
57
63
 
@@ -118,6 +124,7 @@ export class PythonSandbox {
118
124
  "-X", "utf8=1",
119
125
  "-u", WORKER_PATH,
120
126
  "--depth", String(opts.depth ?? 1),
127
+ "--surface", opts.surface === "child" ? "child" : "root",
121
128
  "--timeout", String(opts.execTimeoutS ?? 600),
122
129
  ];
123
130
  if (opts.maxPromptChars !== undefined) {
@@ -224,6 +231,7 @@ export class PythonSandbox {
224
231
  raised: res.raised ?? false,
225
232
  executionTimeMs: Math.round((res.execution_time ?? 0) * 1000),
226
233
  varNames: res.var_names ?? [],
234
+ pendingTasks: parsePendingTasks(res.pending_tasks),
227
235
  };
228
236
  }
229
237
 
@@ -312,12 +320,22 @@ export class PythonSandbox {
312
320
  }
313
321
 
314
322
  /**
315
- * Refresh the parent-side request watchdog for every pending request.
316
- * Used during long mid-exec work that does not
317
- * produce additional worker interrupts on this sandbox.
323
+ * Refresh the parent-side request watchdog and ping the worker.
324
+ * Used during long mid-exec work that does not produce additional worker
325
+ * interrupts on this sandbox. The heartbeat rearms the worker's stall alarm
326
+ * so a healthy long sub-call is not reported as `_StallTimeout`.
318
327
  */
319
328
  refreshWatchdog(): void {
320
329
  this.touchPending();
330
+ if (this.hasPendingRequest()) this.send({ type: "heartbeat" });
331
+ }
332
+
333
+ /** True when an exec/load_context/shutdown (not the init handshake) is in flight. */
334
+ private hasPendingRequest(): boolean {
335
+ for (const id of this.pending.keys()) {
336
+ if (id !== "_init") return true;
337
+ }
338
+ return false;
321
339
  }
322
340
 
323
341
  private send(msg: ParentMessage): void {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared REPL output formatting used by headless history and native repl() tool results.
3
+ * Lives in text/ so tool/ does not import from core/.
4
+ */
5
+
6
+ import { truncateOutput } from "./parsing.ts";
7
+
8
+ /** Max stderr kept in model-visible REPL output (headless history and native tool_result). */
9
+ export const STDERR_LIMIT = 8_000;
10
+
11
+ /** Prefix stderr so the model can tell prints from exceptions. Empty when stderr is blank. */
12
+ export function formatReplStderr(stderr: string, limit = STDERR_LIMIT): string {
13
+ const err = stderr.trim();
14
+ return err ? `\n[stderr]\n${truncateOutput(err, limit)}` : "";
15
+ }
@@ -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
  }
@@ -5,7 +5,9 @@
5
5
  */
6
6
 
7
7
  import type { RlmSubcall } from "./rlm-details.ts";
8
+ import type { PendingTaskInfo } from "../sandbox/protocol.ts";
8
9
  import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
10
+ import { formatReplStderr } from "../text/repl-output.ts";
9
11
 
10
12
  /** Model-visible text assembled from a repl() result. */
11
13
  export interface ReplResultText {
@@ -30,23 +32,20 @@ export function buildReplResultText(
30
32
  subcalls: readonly RlmSubcall[],
31
33
  backgroundPending = 0,
32
34
  varNames: readonly string[] = [],
35
+ stderr = "",
36
+ raised = false,
37
+ pendingTasks: readonly PendingTaskInfo[] = [],
33
38
  ): ReplResultText {
34
39
  const answerSubmitted = finalAnswer !== undefined;
35
- const noOutput = !answerSubmitted && !stdout;
36
- const varsHint = noOutput && varNames.length > 0
37
- ? ` — the block ran fine and these REPL vars are defined: ${varNames.join(", ")}. `
38
- + "Do NOT re-run it; read them in the next block."
39
- : "";
40
- const rawText = answerSubmitted
41
- ? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
42
- : stdout || `(no output)${varsHint}`;
40
+ const stderrBlock = formatReplStderr(stderr);
41
+ const rawText = assembleReplBody(stdout, finalAnswer, varNames, stderrBlock, raised);
43
42
  // Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
44
43
  const cappedText = capReplResultText(rawText) ?? rawText;
45
44
  const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
46
- const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
45
+ const nudge = answerSubmitted || raised ? undefined : replDelegationNudge(rawText.length, delegated);
47
46
  const failedBg = subcalls.filter((s) => s.id.startsWith("bg") && s.status === "error").length;
48
47
  const pendingLine = backgroundPending > 0
49
- ? `\n\n[rlm] ${backgroundPending} background task(s) still running — await_task(tasks) to collect.`
48
+ ? `\n\n[rlm] ${backgroundPending} background task(s) still running — ${pendingCollectHint(pendingTasks)}.`
50
49
  : "";
51
50
  const failedLine = failedBg > 0
52
51
  ? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their await_task value is an "Error: …" string, not data.`
@@ -54,6 +53,51 @@ export function buildReplResultText(
54
53
  return { text: cappedText + (nudge ?? "") + pendingLine + failedLine };
55
54
  }
56
55
 
56
+ function assembleReplBody(
57
+ stdout: string,
58
+ finalAnswer: string | undefined,
59
+ varNames: readonly string[],
60
+ stderrBlock: string,
61
+ raised: boolean,
62
+ ): string {
63
+ if (finalAnswer !== undefined) {
64
+ return `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`;
65
+ }
66
+ if (raised) {
67
+ const body = [stdout.trimEnd(), stderrBlock.trimStart()].filter((s) => s.length > 0).join("\n");
68
+ return body.length > 0 ? body : "(raised — see [stderr])";
69
+ }
70
+ const noOutput = !stdout;
71
+ const varsHint = noOutput && varNames.length > 0
72
+ ? ` — the block ran fine and these REPL vars are defined: ${varNames.join(", ")}. `
73
+ + "Do NOT re-run it; read them in the next block."
74
+ : "";
75
+ return stdout || `(no output)${varsHint}`;
76
+ }
77
+
78
+ /** Name the Python handle when we know it; otherwise point at await_task() / list_tasks(). */
79
+ function pendingCollectHint(pendingTasks: readonly PendingTaskInfo[]): string {
80
+ if (pendingTasks.length === 0) {
81
+ return "collect with await_task(<Task var>) or await_task(); answers[] is for results AFTER await_task";
82
+ }
83
+ const names = new Array<string>(pendingTasks.length);
84
+ for (let i = 0; i < pendingTasks.length; i++) {
85
+ const t = pendingTasks[i];
86
+ const kind = t?.kind ?? "task";
87
+ const label = t?.label ?? "";
88
+ names[i] = t?.var ?? `<${kind}${label.length > 0 ? ` ${label}` : ""}>`;
89
+ }
90
+ if (pendingTasks.length === 1) {
91
+ const only = names[0] ?? "";
92
+ const first = pendingTasks[0];
93
+ const detail = first !== undefined && first.label.length > 0 ? ` (${first.kind} ${first.label})` : "";
94
+ return only.startsWith("<")
95
+ ? `collect with await_task()${detail}; answers[] is for results AFTER await_task`
96
+ : `collect with await_task(${only})${detail}; answers[] is for results AFTER await_task`;
97
+ }
98
+ return `collect with await_task() (${names.join(", ")}); answers[] is for results AFTER await_task`;
99
+ }
100
+
57
101
  /** Advisory diagnostics derived from a completed invocation's sub-calls. */
58
102
  export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly string[] | undefined {
59
103
  let failed = 0;
@@ -28,13 +28,17 @@ import type { RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
28
28
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
29
29
  import type { SubcallOpts } from "../sandbox/sandbox.ts";
30
30
  import { createSubcallHandlers, type Invocation } from "../bridge/handlers/index.ts";
31
+ import { TaskLedger } from "../core/ledger.ts";
32
+ import type { MemoryStore } from "../core/memory.ts";
31
33
  import { BackgroundTasks } from "./background-tasks.ts";
32
34
  import type { ReplResult } from "../sandbox/protocol.ts";
33
35
  import { RlmEmitter } from "./rlm-events.ts";
36
+ import type { RunRegistry } from "../ui/panel/run-registry.ts";
34
37
  import { SubcallStore } from "./subcall-store.ts";
35
38
  import type { ReplDetails } from "./repl-details.ts";
36
39
  import type { RlmSubcall } from "./rlm-details.ts";
37
40
  import { createEngine } from "../core/engine.ts";
41
+ import { modelRef } from "../config/settings.ts";
38
42
  import { spinnerFrame } from "../ui/theme.ts";
39
43
  import { previewText } from "../text/preview.ts";
40
44
  import { errorMessage } from "../util/errors.ts";
@@ -110,8 +114,15 @@ export interface ReplToolDeps {
110
114
  readonly getConfig: () => RlmConfig;
111
115
  /** Session-wide sub-call admission, shared with every child engine this tool spawns. */
112
116
  readonly gates: SubcallGates;
117
+ /** v5: live re-resolution of the session gates (provider caps change via /rlm-config without
118
+ * a restart). Falls back to `gates` when omitted. Read lazily per sub-call. */
119
+ readonly resolveGates?: () => SubcallGates;
113
120
  /** Session-scoped home for detached spawn() work. */
114
121
  readonly background: BackgroundTasks;
122
+ /** Session tree panel index; omitted → runs don't appear in the widget. */
123
+ readonly runRegistry?: RunRegistry;
124
+ /** v5 durable memory (session-wide `.rlm` store); omitted → memory off for this tool. */
125
+ readonly memory?: MemoryStore;
115
126
  readonly signal?: AbortSignal;
116
127
  readonly onUsage?: (usage: Usage, role: "sub") => void;
117
128
  readonly ensureContext?: () => Promise<void>;
@@ -127,12 +138,17 @@ export interface ReplToolDeps {
127
138
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
128
139
  const { sandboxManager, llmModel, registry, getConfig, signal, onUsage, background } = deps;
129
140
  const bridgeState = new NativeBridgeState(background);
141
+ // v5: one session-wide blackboard for the native repl() path — the same claim/coalesce/
142
+ // demote logic the engine gets per run, shared by every turn and every child it spawns.
143
+ const sessionLedger = new TaskLedger();
130
144
 
131
145
  // Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
132
146
  let sessionCwd = process.cwd();
133
147
 
134
148
  const getLlmModel = (): Model<Api> => deps.getLlmModel?.() ?? llmModel;
135
149
  const getModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
150
+ // v5 (audit C6): resolve lazily per call so provider-cap edits via /rlm-config apply live.
151
+ const currentGates = (): SubcallGates => deps.resolveGates?.() ?? deps.gates;
136
152
 
137
153
  // Each rlm_query spawns a child RLM with its own sandbox and turn loop, not a flat
138
154
  // one-shot llm_query. The engine is created per call so the child's subcalls, turn
@@ -143,7 +159,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
143
159
  registry,
144
160
  config: getConfig(),
145
161
  signal,
146
- gates: deps.gates,
162
+ memory: deps.memory,
163
+ gates: currentGates(),
147
164
  // Same emitter the parent subcall node lives on — see SubcallHandlerDeps.runChild.
148
165
  emitter: inv.emitter,
149
166
  // Everything a child engine spends is sub-work from this tool's perspective, including
@@ -156,7 +173,10 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
156
173
  // per-invocation is reached through bridgeState.resolve, not captured here.
157
174
  const subcallHandlers = createSubcallHandlers({
158
175
  resolve: (opts) => bridgeState.resolve(opts),
159
- gates: deps.gates,
176
+ // Getter, not a captured value: sub-call deps read gates lazily per call.
177
+ get gates(): SubcallGates {
178
+ return currentGates();
179
+ },
160
180
  registry,
161
181
  getLlmModel,
162
182
  getModel,
@@ -168,6 +188,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
168
188
  // earlier repl() reaches a child spawned in a later one. Populated before any interrupt can
169
189
  // fire: execute() awaits ensureContext() before getOrCreate().
170
190
  getChildContext: () => sandboxManager.contextPayload ?? undefined,
191
+ ledger: sessionLedger,
192
+ memory: deps.memory,
171
193
  trackDetached: (task) => background.track(task),
172
194
  });
173
195
 
@@ -213,7 +235,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
213
235
  "repl: free search/outline; fire rlm_batch|map_files|llm_batch as Task (BG); await_task for results.",
214
236
  promptGuidelines: [
215
237
  "Multi-area analysis: rlm_batch([...]) or map_files(paths, q); free search; await_task — not serial native read.",
216
- "Always-spawn tools return Task; only await_task has content. Fire-all independent work before await.",
238
+ "Always-spawn tools return Task; only await_task has content. Fire-all then await; await_task() collects every still-running Task.",
217
239
  "llm_query/llm_batch have no disk — never 'Read path/to/file.ts'; use map_files or rlm_* (see context).",
218
240
  ],
219
241
  parameters: ReplToolParams,
@@ -232,6 +254,20 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
232
254
 
233
255
  const emitter = new RlmEmitter();
234
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
+ });
235
271
  let capturedStdout = "";
236
272
  let capturedStderr = "";
237
273
  let progressStatus: ReplDetails["status"] = "running";
@@ -283,6 +319,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
283
319
  await sandboxManager.getOrCreate({
284
320
  ...subcallHandlers,
285
321
  ...(contextBundle?.handlers ?? {}),
322
+ ledgerClaims: () => Promise.resolve(sessionLedger.listClaims()),
323
+ memoryOp: (op, args) => Promise.resolve(deps.memory?.serviceOp(op, args) ?? "memory off"),
286
324
  });
287
325
 
288
326
  // Detect queue contention AFTER sandbox init (initPromise settled, isExecuting now accurate)
@@ -299,6 +337,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
299
337
  }
300
338
 
301
339
  const start = Date.now();
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.
302
345
  const result: ReplResult = await sandboxManager.execWithSetup(params.code, () => {
303
346
  // Wire per-invocation mutable state only after the serialized exec slot
304
347
  // is active. Swapping earlier would let queued repl() calls overwrite
@@ -349,6 +392,9 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
349
392
  subcalls,
350
393
  background.pending,
351
394
  result.varNames,
395
+ result.stderr,
396
+ result.raised,
397
+ result.pendingTasks,
352
398
  );
353
399
 
354
400
  const details: ReplDetails = {
@@ -393,6 +439,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
393
439
  };
394
440
  } finally {
395
441
  progress.stop();
442
+ unregisterRun?.();
396
443
  for (const off of detachTracers) off();
397
444
  store.dispose();
398
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); };
@@ -9,19 +9,16 @@ import { type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent
9
9
  import { Container, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
10
10
  import { Type } from "typebox";
11
11
  import type { RlmController, StartInput } from "../mode/rlm-mode.ts";
12
+ import { modelRef } from "../config/settings.ts";
12
13
  import { spinnerFrame } from "../ui/theme.ts";
14
+ import type { RunRegistry } from "../ui/panel/run-registry.ts";
13
15
  import { markdownTheme } from "../ui/theme-adapter.ts";
14
16
  import { previewText } from "../text/preview.ts";
15
17
  import { errorMessage } from "../util/errors.ts";
16
18
  import { type RlmDetails } from "./rlm-details.ts";
17
19
  import { RlmEmitter } from "./rlm-events.ts";
18
20
  import { RlmEventAggregator } from "./rlm-aggregator.ts";
19
- import {
20
- cardHeader,
21
- cardStatsLine,
22
- renderCollapsedCard,
23
- renderExpandedSubcallTree,
24
- } from "./subcall-render.ts";
21
+ import { cardHeader, cardStatsLine, renderCollapsedCard } from "./subcall-render.ts";
25
22
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
26
23
 
27
24
  /** Chars of the prompt shown on the tool call line. */
@@ -43,7 +40,7 @@ function rootStats(details: RlmDetails, theme: Theme): string {
43
40
 
44
41
  // ── Tool definition ──
45
42
 
46
- export function createRlmTool(controller: RlmController): ToolDefinition<typeof RlmToolParams, RlmDetails> {
43
+ export function createRlmTool(controller: RlmController, runRegistry?: RunRegistry): ToolDefinition<typeof RlmToolParams, RlmDetails> {
47
44
  return {
48
45
  name: "rlm",
49
46
  label: "RLM",
@@ -65,6 +62,25 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
65
62
  const aggregator = new RlmEventAggregator(emitter, onUpdate ?? (() => {}));
66
63
  emitter.emitRootPrompt(params.prompt);
67
64
 
65
+ // Surface the run in the tree panel for its whole lifetime (unregistered in finally).
66
+ const unregisterRun = runRegistry?.register({
67
+ runId: `rlm-${_toolCallId}`,
68
+ label: previewText(params.prompt, 48),
69
+ emitter,
70
+ subcalls: () => aggregator.getState().subcalls,
71
+ totals: () => aggregator.getState().totals,
72
+ rootStatus: () => aggregator.getState().status,
73
+ rootPhase: () => aggregator.getState().rootPhase,
74
+ turns: () => aggregator.getState().turns,
75
+ // Reuses controller.resolveModels — the ONE model-resolution path (DRY); lazy so
76
+ // the pin resolution inside start() is reflected, and it shows own spend only.
77
+ rootModel: () => {
78
+ const m = controller.resolveModels(ctx)?.model;
79
+ return m === undefined ? undefined : modelRef(m) ?? m.id;
80
+ },
81
+ rootTokens: () => aggregator.getRootUsage().tokens,
82
+ });
83
+
68
84
  // Wire abort signal to controller
69
85
  if (signal) {
70
86
  signal.addEventListener("abort", () => controller.abort(), { once: true });
@@ -102,6 +118,7 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
102
118
  };
103
119
  } finally {
104
120
  progress.stop();
121
+ unregisterRun?.();
105
122
  aggregator.dispose();
106
123
  emitter.shutdown();
107
124
  }
@@ -134,12 +151,6 @@ function renderExpanded(details: RlmDetails, theme: Theme): Component {
134
151
  const container = new Container();
135
152
  container.addChild(new Text(cardHeader("RLM", details.status, rootStats(details, theme), theme), 0, 0));
136
153
 
137
- if (details.subcalls.length > 0) {
138
- container.addChild(new Spacer(1));
139
- container.addChild(new Text(theme.fg("muted", "─── Sub-calls ───"), 0, 0));
140
- container.addChild(renderExpandedSubcallTree(details.subcalls, theme));
141
- }
142
-
143
154
  if (details.answer) {
144
155
  container.addChild(new Spacer(1));
145
156
  container.addChild(new Text(theme.fg("muted", "─── Answer ───"), 0, 0));
@@ -152,5 +163,5 @@ function renderExpanded(details: RlmDetails, theme: Theme): Component {
152
163
  // ── Collapsed view ──
153
164
 
154
165
  function renderCollapsed(details: RlmDetails, theme: Theme): Text {
155
- return renderCollapsedCard("RLM", details.status, rootStats(details, theme), details.subcalls, theme);
166
+ return renderCollapsedCard("RLM", details.status, rootStats(details, theme), theme);
156
167
  }