@hicaru/pi-rlm 0.3.8 → 0.3.13

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 (55) hide show
  1. package/README.md +3 -4
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/completion.ts +5 -0
  4. package/src/bridge/handlers/emitting.ts +33 -23
  5. package/src/bridge/handlers/index.ts +1 -1
  6. package/src/bridge/handlers/llm-query.ts +23 -24
  7. package/src/bridge/handlers/rlm-query.ts +10 -32
  8. package/src/bridge/handlers/types.ts +8 -1
  9. package/src/bridge/model.ts +33 -15
  10. package/src/commands/pins.ts +51 -0
  11. package/src/commands/rlm-config.ts +4 -88
  12. package/src/commands/rlm-llm.ts +59 -0
  13. package/src/commands/rlm-rlm.ts +58 -0
  14. package/src/commands/rlm.ts +2 -2
  15. package/src/config/defaults.ts +14 -4
  16. package/src/config/settings.ts +26 -3
  17. package/src/core/budget.ts +1 -1
  18. package/src/core/compaction.ts +4 -0
  19. package/src/core/engine.ts +21 -4
  20. package/src/core/iteration.ts +12 -0
  21. package/src/core/ledger.ts +15 -123
  22. package/src/core/memory.ts +13 -1
  23. package/src/core/model-registry.ts +1 -1
  24. package/src/core/types.ts +14 -0
  25. package/src/index.ts +53 -4
  26. package/src/mode/rlm-mode.ts +11 -1
  27. package/src/prompts/glossary.ts +11 -3
  28. package/src/prompts/native.ts +1 -1
  29. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  31. package/src/tool/repl-render.ts +4 -10
  32. package/src/tool/repl-tool.ts +30 -17
  33. package/src/tool/rlm-aggregator.ts +16 -3
  34. package/src/tool/rlm-details.ts +8 -0
  35. package/src/tool/rlm-events.ts +17 -1
  36. package/src/tool/rlm-tool.ts +25 -14
  37. package/src/tool/subcall-render.ts +14 -129
  38. package/src/tool/subcall-store.ts +11 -1
  39. package/src/ui/intro.ts +13 -4
  40. package/src/ui/modal/agent-modal.ts +104 -0
  41. package/src/ui/modal/modal-view.ts +132 -0
  42. package/src/ui/modal/timeline-store.ts +85 -0
  43. package/src/ui/model-picker/drilldown.ts +173 -0
  44. package/src/ui/model-picker/grouping.ts +81 -0
  45. package/src/ui/model-picker/levels.ts +63 -0
  46. package/src/ui/model-picker.ts +7 -197
  47. package/src/ui/panel/run-registry.ts +135 -0
  48. package/src/ui/panel/tree-panel.ts +46 -0
  49. package/src/ui/status.ts +26 -13
  50. package/src/ui/theme.ts +0 -4
  51. package/src/ui/tree/tree-model.ts +226 -0
  52. package/src/ui/tree/tree-rows.ts +74 -0
  53. package/src/ui/tree/tree-widget.ts +186 -0
  54. package/src/util/retry.ts +180 -0
  55. package/src/util/throttle.ts +90 -0
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";
@@ -19,6 +23,7 @@ import { buildSessionGates, type SubcallGates } from "./util/concurrency.ts";
19
23
  import { BackgroundTasks } from "./tool/background-tasks.ts";
20
24
  import { MemoryStore } from "./core/memory.ts";
21
25
  import { modelComplete } from "./bridge/model.ts";
26
+ import { retryPolicy } from "./util/retry.ts";
22
27
  import { resolve } from "node:path";
23
28
  import { resolveSource } from "./context/resolve.ts";
24
29
  import { formatContextListing } from "./context/listing.ts";
@@ -98,6 +103,19 @@ export default function rlmExtension(pi: ExtensionAPI): void {
98
103
  maxTokens: config.maxTokens,
99
104
  maxErrors: config.maxErrors,
100
105
  });
106
+ // Session tree panel: every repl cell / rlm run / detached bg task registers here;
107
+ // the below-editor widget + agent modal read from it. Background is persistent
108
+ // and hides itself while idle.
109
+ const runRegistry = new RunRegistry();
110
+ runRegistry.register({
111
+ runId: "background",
112
+ label: "background tasks",
113
+ emitter: background.emitter,
114
+ subcalls: () => background.liveSubcalls(),
115
+ totals: () => background.liveTotals(),
116
+ hideWhenEmpty: true,
117
+ });
118
+ let treePanelInstalled = false;
101
119
  // A detached child works in its OWN sandbox, so this one sees no frames and its request
102
120
  // watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
103
121
  // with it. Keep it alive while detached work is genuinely in flight.
@@ -161,9 +179,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
161
179
  // ── Commands ──
162
180
  registerRlmCommand(pi, controller);
163
181
  registerRlmConfigCommand(pi, controller);
182
+ registerRlmLlmCommand(pi, controller);
183
+ registerRlmRlmCommand(pi, controller);
164
184
 
165
185
  // ── Tool registration ──
166
- pi.registerTool(createRlmTool(controller));
186
+ pi.registerTool(createRlmTool(controller, runRegistry));
167
187
  let guidePosted = false;
168
188
 
169
189
  pi.on("session_start", async (_event, ctx) => {
@@ -172,6 +192,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
172
192
  const persisted = await loadSettings();
173
193
  controller.config = mergeConfig(persisted.config);
174
194
  controller.savedLlmRef = persisted.llm ?? undefined;
195
+ controller.savedRlmRef = persisted.rlm ?? undefined;
175
196
 
176
197
  // An explicit --rlm flag wins over the persisted setting for this session.
177
198
  const flag = pi.getFlag("rlm");
@@ -188,6 +209,25 @@ export default function rlmExtension(pi: ExtensionAPI): void {
188
209
  console.warn(`[rlm] model registry refresh failed: ${errorMessage(err)}`);
189
210
  }
190
211
 
212
+ if (controller.savedRlmRef) {
213
+ const resolvedRlm = resolveModelId(ctx.modelRegistry, controller.savedRlmRef);
214
+ if (resolvedRlm) {
215
+ controller.rlmModel = resolvedRlm;
216
+ } else {
217
+ console.warn(
218
+ `[rlm] pinned rlm model ${controller.savedRlmRef} not in registry; following session model until it reappears`,
219
+ );
220
+ try {
221
+ ctx.ui.notify(
222
+ `RLM: pinned rlm=${controller.savedRlmRef} unavailable — following session model until it is`,
223
+ "warning",
224
+ );
225
+ } catch {
226
+ // Some hosts have no UI at session_start.
227
+ }
228
+ }
229
+ }
230
+
191
231
  if (controller.savedLlmRef) {
192
232
  const resolved = resolveModelId(ctx.modelRegistry, controller.savedLlmRef);
193
233
  if (resolved) {
@@ -217,7 +257,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
217
257
  // the workspace root is only known once the session starts.
218
258
  const consolidateModel = llmModel;
219
259
  memory.setLlm((prompt) =>
220
- modelComplete([{ role: "user", content: prompt }], { model: consolidateModel, registry: ctx.modelRegistry })
260
+ modelComplete([{ role: "user", content: prompt }], {
261
+ model: consolidateModel,
262
+ registry: ctx.modelRegistry,
263
+ retry: retryPolicy(controller.config),
264
+ })
221
265
  .then((r) => r.text));
222
266
  memory.setRoot(ctx.cwd ?? process.cwd());
223
267
  // v5 provider caps (audit C1/C6): ONE resolver shared by both composition roots — the
@@ -259,6 +303,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
259
303
  gates: resolveSessionGates(),
260
304
  resolveGates: resolveSessionGates,
261
305
  background,
306
+ runRegistry,
262
307
  memory,
263
308
  registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
264
309
  registerContextBundle: (bundle) => {
@@ -282,7 +327,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
282
327
  }
283
328
  }
284
329
 
285
- setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
330
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
331
+ if (!treePanelInstalled) {
332
+ treePanelInstalled = true;
333
+ installTreePanel(ctx, runRegistry);
334
+ }
286
335
  if (!guidePosted && controller.enabled) {
287
336
  guidePosted = true;
288
337
  postRlmGuide(pi, controller);
@@ -291,7 +340,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
291
340
 
292
341
  // ── Keep the footer's context reading live (RLM exists to shrink this number) ──
293
342
  pi.on("turn_end", async (_event, ctx) => {
294
- setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
343
+ setRlmModeStatus(ctx, controller, ctx.getContextUsage());
295
344
  });
296
345
 
297
346
  /** 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,6 +1,6 @@
1
1
  /** Native-mode prompts — the main Pi agent drives the sandbox through the `repl` tool.
2
2
  *
3
- * Structure mirrors rlm_test api_v5_anthropic (best bake-off arm): role → contract → routing →
3
+ * Structure mirrors api_v5_anthropic (best bake-off arm): role → contract → routing →
4
4
  * few-shots → anti-patterns → REPL surface. Goal: multi-area work fires rlm_batch / rlm_query
5
5
  * as Task (BG), not serial repl+native read.
6
6
  */
@@ -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,10 @@
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
+ * "queued" = parked on the rate-limit cooldown (util/throttle.ts), not in flight. */
14
+ export type SubcallPhase = "thinking" | "texting" | "repl" | "waiting" | "spawning" | "queued";
11
15
  export type RlmRunStatus = "running" | "done" | "error" | "aborted";
12
16
 
13
17
  export interface RlmSubcall {
@@ -20,6 +24,8 @@ export interface RlmSubcall {
20
24
  readonly label: string;
21
25
  readonly model?: string;
22
26
  readonly status: SubcallStatus;
27
+ /** Current activity while running (undefined = not reported). */
28
+ readonly phase?: SubcallPhase;
23
29
  readonly detail?: string;
24
30
  readonly args?: string;
25
31
  readonly resultPreview?: string;
@@ -35,6 +41,8 @@ export interface RlmSubcall {
35
41
 
36
42
  export interface RlmDetails {
37
43
  readonly status: RlmRunStatus;
44
+ /** Root node's live activity phase (root has no subcall entry). */
45
+ readonly rootPhase?: SubcallPhase;
38
46
  readonly rootPrompt: string;
39
47
  readonly turns: { readonly current: number; readonly max: number };
40
48
  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
  }