@hicaru/pi-rlm 0.2.0 → 0.2.1

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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Session-scoped home for detached `spawn()` work in native repl() mode.
3
+ *
4
+ * A spawned sub-call may settle after the repl() call that started it has returned, when
5
+ * that turn's RlmEmitter has been shut down and its LimitGuard discarded. Both live here for
6
+ * the whole session instead. The turn that awaits a task — or simply the next turn to run —
7
+ * adopts its settled subtree into its own ReplDetails, so background spend is reported even
8
+ * when the model never collects the result.
9
+ *
10
+ * The headless engine needs none of this: its emitter and guard already outlive every
11
+ * sub-call it services (see core/engine.ts).
12
+ */
13
+
14
+ import { RlmEmitter } from "./rlm-events.ts";
15
+ import { SubcallStore, type SubcallTotals } from "./subcall-store.ts";
16
+ import type { RlmSubcall } from "./rlm-details.ts";
17
+ import { LimitGuard, type Limits } from "../core/limits.ts";
18
+ import type { Invocation } from "../bridge/subcall-handlers.ts";
19
+ import { trace, traceEnabled } from "../util/trace.ts";
20
+
21
+ /** What a drain hands to the turn that is reporting it. */
22
+ export interface BackgroundDrain {
23
+ readonly subcalls: readonly RlmSubcall[];
24
+ readonly totals: SubcallTotals;
25
+ }
26
+
27
+ export class BackgroundTasks {
28
+ /** "bg" prefix so these IDs can never collide with a turn emitter's `s1, s2, …`. */
29
+ private readonly _emitter = new RlmEmitter("bg");
30
+ private readonly store = new SubcallStore(this._emitter);
31
+ private readonly limits: LimitGuard;
32
+ private active = 0;
33
+
34
+ constructor(limits: Limits) {
35
+ this.limits = new LimitGuard(limits);
36
+ }
37
+
38
+ /** Read-only access for the progressive tracer (scope "background"). */
39
+ get emitter(): RlmEmitter {
40
+ return this._emitter;
41
+ }
42
+
43
+ /** The Invocation detached sub-calls resolve to. Stable for the session. */
44
+ get invocation(): Invocation {
45
+ return {
46
+ emitter: this._emitter,
47
+ parentId: undefined,
48
+ depth: 0,
49
+ limits: this.limits,
50
+ };
51
+ }
52
+
53
+ /** Detached sub-calls still in flight. The single pending counter for the session. */
54
+ get pending(): number {
55
+ return this.active;
56
+ }
57
+
58
+ /** Live snapshot of detached sub-calls, settled or not — progressive rendering only. */
59
+ liveSubcalls(): readonly RlmSubcall[] {
60
+ return this.store.getSubcalls();
61
+ }
62
+
63
+ /** Live totals for that same snapshot. Accounting still flows through `drain()`. */
64
+ liveTotals(): SubcallTotals {
65
+ return this.store.getTotals();
66
+ }
67
+
68
+ /** Count `run` as in-flight detached work for its duration. */
69
+ async track<T>(run: () => Promise<T>): Promise<T> {
70
+ this.active += 1;
71
+ const startedAt = Date.now();
72
+ if (traceEnabled) trace("bg.start", { pending: this.active });
73
+ try {
74
+ return await run();
75
+ } finally {
76
+ this.active -= 1;
77
+ if (traceEnabled) trace("bg.settle", { pending: this.active, durationMs: Date.now() - startedAt });
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Hand over every fully-settled subtree and forget it.
83
+ *
84
+ * Called at the end of every repl() call, not only when a task was awaited, so a spawn
85
+ * the model never collects still reaches the user's cost totals.
86
+ */
87
+ drain(): BackgroundDrain {
88
+ return this.store.takeSettledSubtrees();
89
+ }
90
+
91
+ dispose(): void {
92
+ this.store.dispose();
93
+ this._emitter.shutdown();
94
+ }
95
+ }
@@ -22,6 +22,8 @@ export interface ReplDetails {
22
22
  readonly totals: { readonly costUsd: number; readonly tokens: number };
23
23
  /** Final answer submitted through answer["ready"] without echoing it to the model. */
24
24
  readonly finalAnswer?: string;
25
+ /** Detached spawn() sub-calls still running when this call returned. Absent when none. */
26
+ readonly backgroundPending?: number;
25
27
  /** Advisory diagnostics — surfaced to the user, never a failure. */
26
28
  readonly warnings?: readonly string[];
27
29
  }
@@ -6,10 +6,13 @@
6
6
  * and collects sub-calls manually from emitter events. No RlmEventAggregator is used
7
7
  * (ReplDetails ≠ RlmDetails structural mismatch).
8
8
  *
9
- * Sandbox handlers (llm_query, rlm_query, todo, ask_user_question) are the *shared* bridges
10
- * from bridge/llm-query.ts and bridge/rlm-query.ts, bound to NativeBridgeState accessors so
11
- * the tool can swap per-invocation state (emitter, depth, limits) without recreating the
12
- * sandbox — preserving REPL variable state across calls.
9
+ * Sub-call handling itself lives in bridge/subcall-handlers.ts; this file only supplies the
10
+ * per-invocation Invocation those handlers resolve against, swapping it inside the
11
+ * serialized exec slot so a queued repl() cannot claim the running one's emitter.
12
+ *
13
+ * Work started with `spawn()` may still be running when the call returns, so it resolves to
14
+ * the session-scoped BackgroundTasks registry instead and is drained back into whichever
15
+ * turn is reporting next.
13
16
  */
14
17
 
15
18
  import { Type } from "typebox";
@@ -17,16 +20,16 @@ import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
17
20
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
18
21
  import type { Model, Usage, Api } from "@earendil-works/pi-ai";
19
22
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
20
- import { displayModelRef } from "../config/settings.ts";
21
23
  import { buildInteractiveHandlers } from "../bridge/interactive.ts";
22
24
  import { buildLibraryHandler } from "../bridge/library.ts";
23
25
  import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
24
- import { createLlmBridge } from "../bridge/llm-query.ts";
25
- import { createRlmHandlers } from "../bridge/rlm-query.ts";
26
+ import type { SubcallGates } from "../util/concurrency.ts";
26
27
  import { LimitGuard, limitsFromConfig } from "../core/limits.ts";
27
- import type { RemainingResources } from "../core/resource-limits.ts";
28
- import type { InteractiveDeps, RlmConfig, RunRlm } from "../core/types.ts";
28
+ import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
29
29
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
30
+ import type { SubcallOpts } from "../sandbox/sandbox.ts";
31
+ import { createSubcallHandlers, type Invocation } from "../bridge/subcall-handlers.ts";
32
+ import { BackgroundTasks } from "./background-tasks.ts";
30
33
  import type { ReplResult } from "../sandbox/protocol.ts";
31
34
  import { RlmEmitter } from "./rlm-events.ts";
32
35
  import { SubcallStore } from "./subcall-store.ts";
@@ -44,6 +47,7 @@ import {
44
47
  } from "./subcall-render.ts";
45
48
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
46
49
  import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
50
+ import { attachTracer, trace, traceEnabled } from "../util/trace.ts";
47
51
 
48
52
  /** Chars of code shown on the tool call line, and of stdout in the expanded view. */
49
53
  const CALL_PREVIEW_CHARS = 80;
@@ -56,29 +60,61 @@ export const ReplToolParams = Object.freeze(Type.Object({
56
60
  code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
57
61
  }));
58
62
 
63
+ /** Last non-empty line of a Python traceback — the `TypeError: …` line, not the frames. */
64
+ function lastLine(text: string): string {
65
+ const lines = text.trimEnd().split("\n");
66
+ for (let i = lines.length - 1; i >= 0; i--) {
67
+ const line = lines[i]?.trim();
68
+ if (line) return line.slice(0, 200);
69
+ }
70
+ return "";
71
+ }
72
+
59
73
  /** Model-visible text assembled from a repl() result. */
60
74
  export interface ReplResultText {
61
75
  readonly text: string;
62
76
  }
63
77
 
64
78
  /**
65
- * Assemble the model-visible text for a repl() result: cap stdout and append a
66
- * zero-subcall delegation nudge when a bulk read went undelegated.
79
+ * Assemble the model-visible text for a repl() result: cap stdout, append a zero-subcall
80
+ * delegation nudge when a bulk read went undelegated, and report tasks still running.
81
+ *
82
+ * The pending line is the model's only signal that `spawn()`ed work is outstanding — without
83
+ * it a model that spawned and moved on has no way to know it should still collect.
84
+ *
85
+ * `varNames` covers the opposite failure: a block that stores its results in `answers` and
86
+ * prints nothing reads as a bare "(no output)", so the model concludes the block did nothing
87
+ * and re-runs it — paying twice for the same sub-calls. The headless engine already answers
88
+ * this with the same hint (core/answer.ts); native mode was the only path missing it.
67
89
  */
68
90
  export function buildReplResultText(
69
91
  stdout: string,
70
92
  finalAnswer: string | undefined,
71
93
  subcalls: readonly RlmSubcall[],
94
+ backgroundPending = 0,
95
+ varNames: readonly string[] = [],
72
96
  ): ReplResultText {
73
97
  const answerSubmitted = finalAnswer !== undefined;
98
+ const noOutput = !answerSubmitted && !stdout;
99
+ const varsHint = noOutput && varNames.length > 0
100
+ ? ` — the block ran fine and these REPL vars are defined: ${varNames.join(", ")}. `
101
+ + "Do NOT re-run it; read them in the next block."
102
+ : "";
74
103
  const rawText = answerSubmitted
75
104
  ? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
76
- : stdout || "(no output)";
105
+ : stdout || `(no output)${varsHint}`;
77
106
  // Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
78
107
  const cappedText = capReplResultText(rawText) ?? rawText;
79
108
  const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
80
109
  const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
81
- return { text: cappedText + (nudge ?? "") };
110
+ const failedBg = subcalls.filter((s) => s.id.startsWith("bg") && s.status === "error").length;
111
+ const pendingLine = backgroundPending > 0
112
+ ? `\n\n[rlm] ${backgroundPending} background task(s) still running — rlm_await_all(tasks) to collect.`
113
+ : "";
114
+ const failedLine = failedBg > 0
115
+ ? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their rlm_await value is an "Error: …" string, not data.`
116
+ : "";
117
+ return { text: cappedText + (nudge ?? "") + pendingLine + failedLine };
82
118
  }
83
119
 
84
120
  /** Advisory diagnostics derived from a completed invocation's sub-calls. */
@@ -99,33 +135,41 @@ export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly s
99
135
  // ── Mutable bridge state (handler indirection) ──
100
136
 
101
137
  /**
102
- * Holds per-invocation mutable state that the shared bridges dereference through accessors.
103
- * The sandbox is created once with handlers bound to this object, so the tool can swap
104
- * emitter/depth/limits between calls without recreating the sandbox (preserving REPL state).
138
+ * Holds per-invocation state that the sandbox handlers resolve against.
139
+ *
140
+ * The sandbox is created once, so the tool swaps the current Invocation between repl()
141
+ * calls rather than rebuilding handlers (which would lose REPL variable state). Handlers
142
+ * capture the Invocation synchronously at interrupt entry and never re-read it — with
143
+ * spawn() a sub-call can outlive its exec, and a later read would attribute it to whichever
144
+ * turn happened to be current when it resumed.
145
+ *
146
+ * Detached work resolves to the session-scoped background Invocation instead, whose emitter
147
+ * and LimitGuard are not torn down at the end of a turn.
105
148
  */
106
149
  class NativeBridgeState {
107
- currentEmitter: RlmEmitter | null = null;
108
- currentParentId: string | undefined;
109
- currentDepth = 0;
110
- currentLimits: LimitGuard | null = null;
111
- currentInteractive: InteractiveDeps | null = null;
112
-
113
- swap(inv: { emitter: RlmEmitter; parentId?: string; depth: number; limits: LimitGuard; interactive: InteractiveDeps }): void {
114
- this.currentEmitter = inv.emitter;
115
- this.currentParentId = inv.parentId;
116
- this.currentDepth = inv.depth;
117
- this.currentLimits = inv.limits;
118
- this.currentInteractive = inv.interactive;
150
+ private current: Invocation | null = null;
151
+ /** Interactive callbacks for the turn in progress; child engines inherit them. */
152
+ interactive: InteractiveDeps | null = null;
153
+
154
+ constructor(private readonly background: BackgroundTasks) {}
155
+
156
+ swap(inv: Invocation, interactive: InteractiveDeps): void {
157
+ this.current = Object.freeze({ ...inv });
158
+ this.interactive = interactive;
119
159
  }
120
160
 
121
- /** Remaining budget/timeout of the invocation that currently owns the exec slot. */
122
- remainingBudget(): RemainingResources | undefined {
123
- const limits = this.currentLimits;
124
- if (!limits) return undefined;
125
- return { budgetUsd: limits.remainingBudgetUsd(), timeoutMs: limits.remainingTimeoutMs() };
161
+ /** Detached session registry; otherwise the turn that is currently executing. */
162
+ resolve(opts: SubcallOpts): Invocation | null {
163
+ return opts.detached ? this.background.invocation : this.current;
164
+ }
165
+
166
+ /** The turn emitter, for library-load reporting. Null between repl() calls. */
167
+ get currentEmitter(): RlmEmitter | null {
168
+ return this.current?.emitter ?? null;
126
169
  }
127
170
  }
128
171
 
172
+
129
173
  // ── Tool factory ──
130
174
 
131
175
  export interface ReplToolDeps {
@@ -137,6 +181,10 @@ export interface ReplToolDeps {
137
181
  readonly registry: ModelRegistry;
138
182
  /** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
139
183
  readonly getConfig: () => RlmConfig;
184
+ /** Session-wide sub-call admission, shared with every child engine this tool spawns. */
185
+ readonly gates: SubcallGates;
186
+ /** Session-scoped home for detached spawn() work. */
187
+ readonly background: BackgroundTasks;
140
188
  readonly signal?: AbortSignal;
141
189
  readonly onUsage?: (usage: Usage, role: "sub") => void;
142
190
  readonly ensureContext?: () => Promise<void>;
@@ -145,61 +193,48 @@ export interface ReplToolDeps {
145
193
  }
146
194
 
147
195
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
148
- const { sandboxManager, workerModel, registry, getConfig, signal, onUsage } = deps;
149
- const bridgeState = new NativeBridgeState();
196
+ const { sandboxManager, workerModel, registry, getConfig, signal, onUsage, background } = deps;
197
+ const bridgeState = new NativeBridgeState(background);
150
198
 
151
199
  // Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
152
200
  let sessionCwd = process.cwd();
153
201
 
154
- const rootModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
202
+ const getWorkerModel = (): Model<Api> => deps.getWorkerModel?.() ?? workerModel;
203
+ const getModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
155
204
 
156
- // Build handlers once llm/rlm/library read late-bound state so the same closures stay
157
- // correct across repl() calls; counters reset when the sandbox is discarded and re-spawned.
158
- const llmHandlers = createLlmBridge({
159
- workerModel: () => deps.getWorkerModel?.() ?? workerModel,
205
+ // Each rlm_query spawns a child RLM with its own sandbox and turn loop, not a flat
206
+ // one-shot llm_query. The engine is created per call so the child's subcalls, turn
207
+ // progress and cost deltas land on the emitter the parent invocation is using.
208
+ const runChild = (input: RlmInput, inv: Invocation): Promise<RlmResult> => createEngine({
209
+ model: getModel(),
210
+ workerModel: getWorkerModel(),
160
211
  registry,
161
- config: getConfig,
212
+ config: getConfig(),
162
213
  signal,
163
- onUsage: (usage) => { bridgeState.currentLimits?.addUsage(usage); },
164
- remainingBudget: () => bridgeState.remainingBudget(),
165
- emitter: () => bridgeState.currentEmitter ?? undefined,
166
- parentId: () => bridgeState.currentParentId,
167
- depth: () => bridgeState.currentDepth,
168
- });
169
-
170
- // Real recursive rlm_query — each call spawns a child RLM with its own sandbox and turn
171
- // loop, bound to the *current* invocation's emitter so child sub-calls, turn progress, and
172
- // cost deltas land on the live visual tree.
173
- const runChildRlm: RunRlm = (input) => {
174
- const emitter = bridgeState.currentEmitter;
175
- // Only reachable while an invocation owns the exec slot, which always swaps in an emitter.
176
- if (!emitter) throw new Error("RLM bridge not wired for this invocation");
177
- const config = getConfig();
178
- return createEngine({
179
- model: rootModel(),
180
- workerModel: deps.getWorkerModel?.() ?? workerModel,
181
- registry,
182
- config,
183
- signal,
184
- emitter,
185
- onUsage: onUsage === undefined ? undefined : (usage, role) => { if (role === "sub") onUsage(usage, role); },
186
- limits: limitsFromConfig(config),
187
- onTodo: bridgeState.currentInteractive?.onTodo,
188
- onAskUserQuestion: bridgeState.currentInteractive?.onAskUserQuestion,
189
- })(input);
190
- };
191
-
192
- const rlmHandlers = createRlmHandlers({
193
- run: runChildRlm,
194
- llm: llmHandlers,
195
- config: getConfig,
196
- modelLabel: (override) => displayModelRef(registry, override, rootModel()),
197
- emitter: () => bridgeState.currentEmitter ?? undefined,
198
- parentNodeId: () => bridgeState.currentParentId,
199
- remainingBudget: () => bridgeState.remainingBudget(),
200
- onChildUsage: (costUsd, inputTokens, outputTokens) => {
201
- bridgeState.currentLimits?.addRaw(costUsd, inputTokens, outputTokens);
202
- },
214
+ gates: deps.gates,
215
+ // Same emitter the parent subcall node lives on — see SubcallHandlerDeps.runChild.
216
+ emitter: inv.emitter,
217
+ // Everything a child engine spends is sub-work from this tool's perspective, including
218
+ // the child's own root turns — so fold both roles into "sub" rather than casting.
219
+ onUsage: onUsage === undefined ? undefined : (usage: Usage) => onUsage(usage, "sub"),
220
+ limits: limitsFromConfig(getConfig()),
221
+ onTodo: bridgeState.interactive?.onTodo,
222
+ onAskUserQuestion: bridgeState.interactive?.onAskUserQuestion,
223
+ })(input);
224
+
225
+ // Built once: the same closures stay correct across repl() calls because everything
226
+ // per-invocation is reached through bridgeState.resolve, not captured here.
227
+ const subcallHandlers = createSubcallHandlers({
228
+ resolve: (opts) => bridgeState.resolve(opts),
229
+ gates: deps.gates,
230
+ registry,
231
+ getWorkerModel,
232
+ getModel,
233
+ getConfig,
234
+ signal,
235
+ onUsage,
236
+ runChild,
237
+ trackDetached: (task) => background.track(task),
203
238
  });
204
239
 
205
240
  const libraryBundle = getConfig().libraryLoader
@@ -233,7 +268,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
233
268
  ],
234
269
  parameters: ReplToolParams,
235
270
 
236
- async execute(_toolCallId, rawParams, _execSignal, onUpdate, ctx) {
271
+ async execute(_toolCallId, rawParams, execSignal, onUpdate, ctx) {
237
272
  const validation = validateToolParams(ReplToolParams, rawParams, "REPL", (errors): ReplDetails => ({
238
273
  status: "error",
239
274
  output: "",
@@ -253,17 +288,29 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
253
288
  const startedAt = Date.now();
254
289
  const limits = new LimitGuard(limitsFromConfig(getConfig()));
255
290
 
291
+ const detachTracers = traceEnabled
292
+ ? [attachTracer(emitter, "turn"), attachTracer(background.emitter, "background")]
293
+ : [];
294
+
256
295
  // ── Progressive rendering: spinner + live sub-call tree ──
257
296
  const progress = createProgressNotifier<ReplDetails>({
258
297
  onUpdate,
259
- getDetails: () => ({
260
- status: progressStatus,
261
- output: capturedStdout,
262
- stderr: capturedStderr,
263
- executionTimeMs: Date.now() - startedAt,
264
- subcalls: store.getSubcalls(),
265
- totals: store.getTotals(),
266
- }),
298
+ getDetails: () => {
299
+ // Detached spawn() nodes live on the SESSION emitter, so without this merge the card
300
+ // stays empty for the entire time background work is running.
301
+ const live = background.liveSubcalls();
302
+ const bg = background.liveTotals();
303
+ const own = store.getTotals();
304
+ return {
305
+ status: progressStatus,
306
+ output: capturedStdout,
307
+ stderr: capturedStderr,
308
+ executionTimeMs: Date.now() - startedAt,
309
+ subcalls: live.length > 0 ? [...store.getSubcalls(), ...live] : store.getSubcalls(),
310
+ totals: { costUsd: own.costUsd + bg.costUsd, tokens: own.tokens + bg.tokens },
311
+ backgroundPending: background.pending > 0 ? background.pending : undefined,
312
+ };
313
+ },
267
314
  isRunning: (details) => details.status === "running",
268
315
  renderText: (details) => details.output.slice(0, 500) || (details.status === "running" ? `${spinnerFrame()} Running…` : "(no output)"),
269
316
  });
@@ -295,8 +342,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
295
342
 
296
343
  await deps.ensureContext?.();
297
344
  await sandboxManager.getOrCreate({
298
- ...llmHandlers,
299
- ...rlmHandlers,
345
+ ...subcallHandlers,
300
346
  askUserQuestion: interactiveHandlers.askUserQuestion,
301
347
  todo: interactiveHandlers.todo,
302
348
  ...(libraryBundle?.handlers ?? {}),
@@ -311,19 +357,46 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
311
357
  });
312
358
  }
313
359
 
360
+ if (traceEnabled) {
361
+ trace("repl.exec.start", { chars: params.code.length, code: params.code.slice(0, 400) });
362
+ }
363
+
314
364
  const start = Date.now();
315
365
  const result: ReplResult = await sandboxManager.execWithSetup(params.code, () => {
316
366
  // Wire per-invocation mutable state only after the serialized exec slot
317
367
  // is active. Swapping earlier would let queued repl() calls overwrite
318
368
  // emitter/limits for the currently running REPL execution.
319
- bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits, interactive });
320
- });
369
+ bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits }, interactive);
370
+ }, execSignal);
321
371
  const elapsed = Date.now() - start;
322
372
  capturedStdout = result.stdout;
323
373
  capturedStderr = result.stderr;
324
374
  progressStatus = "done";
325
375
 
326
- const totals = store.getTotals();
376
+ if (traceEnabled) {
377
+ trace("repl.exec.end", {
378
+ ms: elapsed,
379
+ stdout: result.stdout.length,
380
+ raised: result.raised,
381
+ pending: background.pending,
382
+ // A block that raised delegated nothing; without the exception the trace shows a
383
+ // silent turn and the reason is only in the TUI card.
384
+ error: result.raised ? lastLine(result.stderr) : undefined,
385
+ });
386
+ }
387
+
388
+ // Adopt every background subtree that has settled, whether or not this turn awaited
389
+ // it — otherwise a spawn the model never collects would never reach the user's cost
390
+ // totals. IDs are "bg"-prefixed, so they cannot collide with this turn's.
391
+ // (drain() removes what it hands over, so live view + accounted view never double-count.)
392
+ const adopted = background.drain();
393
+ const subcalls: readonly RlmSubcall[] = adopted.subcalls.length > 0
394
+ ? [...store.getSubcalls(), ...adopted.subcalls]
395
+ : store.getSubcalls();
396
+ const totals = {
397
+ costUsd: store.getTotals().costUsd + adopted.totals.costUsd,
398
+ tokens: store.getTotals().tokens + adopted.totals.tokens,
399
+ };
327
400
  const subUsage: Usage = {
328
401
  input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: totals.tokens,
329
402
  cost: { total: totals.costUsd, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -336,7 +409,9 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
336
409
  const { text: resultText } = buildReplResultText(
337
410
  result.stdout,
338
411
  finalAnswer,
339
- store.getSubcalls(),
412
+ subcalls,
413
+ background.pending,
414
+ result.varNames,
340
415
  );
341
416
 
342
417
  const details: ReplDetails = {
@@ -344,10 +419,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
344
419
  output: result.stdout,
345
420
  stderr: result.stderr,
346
421
  executionTimeMs: elapsed,
347
- subcalls: store.getSubcalls(),
348
- totals: store.getTotals(),
422
+ subcalls,
423
+ totals,
349
424
  finalAnswer,
350
- warnings: collectReplWarnings(store.getSubcalls()),
425
+ backgroundPending: background.pending > 0 ? background.pending : undefined,
426
+ warnings: collectReplWarnings(subcalls),
351
427
  };
352
428
  const progressText = finalAnswer !== undefined
353
429
  ? `ANSWER_SUBMITTED (${finalAnswer.length} chars)`
@@ -358,13 +434,20 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
358
434
  } catch (e) {
359
435
  progressStatus = "error";
360
436
  const msg = errorMessage(e);
437
+ // Drain here too: a failing turn must not swallow the cost of background work that
438
+ // settled during it, or a run that keeps erroring would never report any of it.
439
+ const adopted = background.drain();
361
440
  const details: ReplDetails = {
362
441
  status: "error",
363
442
  output: "",
364
443
  stderr: msg,
365
444
  executionTimeMs: 0,
366
- subcalls: store.getSubcalls(),
367
- totals: store.getTotals(),
445
+ subcalls: [...store.getSubcalls(), ...adopted.subcalls],
446
+ totals: {
447
+ costUsd: store.getTotals().costUsd + adopted.totals.costUsd,
448
+ tokens: store.getTotals().tokens + adopted.totals.tokens,
449
+ },
450
+ backgroundPending: background.pending > 0 ? background.pending : undefined,
368
451
  };
369
452
  onUpdate?.({ content: [{ type: "text", text: `REPL error: ${msg}` }], details });
370
453
  return {
@@ -373,6 +456,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
373
456
  };
374
457
  } finally {
375
458
  progress.stop();
459
+ for (const off of detachTracers) off();
376
460
  store.dispose();
377
461
  emitter.shutdown();
378
462
  }
@@ -401,7 +485,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
401
485
 
402
486
  function replStats(details: ReplDetails, theme: Theme): string {
403
487
  const elapsed = details.executionTimeMs > 0 ? `${details.executionTimeMs}ms` : undefined;
404
- return cardStatsLine(details.totals, theme, elapsed);
488
+ return cardStatsLine(details.totals, theme, elapsed, details.backgroundPending);
405
489
  }
406
490
 
407
491
  function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
@@ -76,18 +76,26 @@ export interface RootPromptEvent {
76
76
  /**
77
77
  * Typed wrapper around a Node.js EventEmitter for RLM lifecycle events.
78
78
  *
79
- * Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()`.
79
+ * Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()`, with a
80
+ * configurable prefix so a second emitter's IDs cannot collide with the default ones.
80
81
  * Provides typed `on*` methods that return unsubscribe functions.
81
82
  */
82
83
  export class RlmEmitter {
83
84
  private readonly ee = new EventEmitter();
84
85
  private seq = 0;
85
86
 
87
+ /**
88
+ * `idPrefix` namespaces generated IDs. The counter is per-instance, so two emitters
89
+ * would both start at `s1`; a distinct prefix is what lets one emitter's subcalls be
90
+ * merged into another's tree without colliding IDs or corrupting parentId links.
91
+ */
92
+ constructor(private readonly idPrefix = "s") {}
93
+
86
94
  // ── Emit ──
87
95
 
88
96
  /** Create a new sub-call entry. Returns the auto-generated ID. */
89
97
  emitSubcallCreated(init: Omit<SubcallCreatedEvent, "id">): string {
90
- const id = `s${++this.seq}`;
98
+ const id = `${this.idPrefix}${++this.seq}`;
91
99
  const event: SubcallCreatedEvent = { id, ...init };
92
100
  this.ee.emit("subcall:created", event);
93
101
  return id;
@@ -52,11 +52,21 @@ export function cardStatsLine(
52
52
  totals: { readonly costUsd: number; readonly tokens: number },
53
53
  theme: Theme,
54
54
  extra?: string,
55
+ backgroundPending?: number,
55
56
  ): string {
56
57
  const parts: string[] = [formatCost(totals.costUsd)];
57
58
  if (totals.tokens > 0) parts.push(`${formatTokens(totals.tokens)} tok`);
58
59
  if (extra) parts.push(extra);
59
- return theme.fg("dim", parts.join(" · "));
60
+ const line = theme.fg("dim", parts.join(" · "));
61
+ // The one thing no tree can show: spawned work that may outlive this block.
62
+ return backgroundPending !== undefined && backgroundPending > 0
63
+ ? `${line} ${theme.fg("warning", `↯${backgroundPending} bg`)}`
64
+ : line;
65
+ }
66
+
67
+ /** Detached nodes carry BackgroundTasks' "bg" id prefix (RlmEmitter("bg")). */
68
+ function backgroundTag(sc: RlmSubcall, theme: Theme): string {
69
+ return sc.id.startsWith("bg") ? ` ${theme.fg("warning", "↯bg")}` : "";
60
70
  }
61
71
 
62
72
  /** `<glyph> <TITLE> <stats>` — the first line of both tools' collapsed and expanded views. */
@@ -128,7 +138,8 @@ export function renderCollapsedSubcallTree(
128
138
  const branch = isLast ? "└─" : "├─";
129
139
  const gGlyph = subcallStatusGlyph(sc, theme);
130
140
  const gStats = subcallStatsLine(sc);
131
- lines.push(`${prefix}${branch} ${sc.label} ${gGlyph} ${gStats}`);
141
+ const gBg = backgroundTag(sc, theme);
142
+ lines.push(`${prefix}${branch} ${sc.label} ${gGlyph} ${gStats}${gBg}`);
132
143
  const childPrefix = prefix + (isLast ? " " : "│ ");
133
144
  lines.push(...walk(sc.id, childPrefix));
134
145
  }
@@ -155,7 +166,8 @@ export function renderExpandedSubcallTree(
155
166
  const sKind = theme.fg("muted", sc.label);
156
167
  const sModel = sc.model ? theme.fg("dim", ` ${sc.model}`) : "";
157
168
  const sStats = sc.endedAt ? ` ${theme.fg("dim", subcallStatsLine(sc))}` : "";
158
- let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}`;
169
+ const sBg = backgroundTag(sc, theme);
170
+ let line = `${pad}${sGlyph} ${sKind}${sModel}${sStats}${sBg}`;
159
171
 
160
172
  if (sc.args) {
161
173
  line += `\n${pad} ${theme.fg("dim", previewText(sc.args, ARGS_PREVIEW_CHARS))}`;