@tintinweb/pi-subagents 0.4.3 → 0.4.5

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.5] - 2026-03-16
9
+
10
+ ### Changed
11
+ - **Widget render-once pattern** — the widget callback is now registered once via `setWidget()` and subsequent updates use `requestRender()` instead of re-registering the entire widget on every `update()` call. Eliminates layout thrashing from repeated widget teardown/setup cycles.
12
+ - **Status bar dedup** — `setStatus()` is now only called when the status text actually changes, avoiding redundant TUI updates.
13
+ - **UICtx change detection** — `setUICtx()` detects context changes and forces widget re-registration, correctly handling session switches.
14
+
15
+ ### Refactored
16
+ - Extracted `renderWidget()` private method — moves all widget content rendering out of the `update()` closure into a standalone method that reads live state on each call.
17
+ - `update()` is now a lightweight coordinator: counts agents, manages registration lifecycle, and triggers re-renders.
18
+
19
+ ## [0.4.4] - 2026-03-16
20
+
21
+ ### Fixed
22
+ - **Race condition in `get_subagent_result` with `wait: true`** — `resultConsumed` is now set before `await record.promise`, preventing a redundant follow-up notification. Previously the `onComplete` callback (attached at spawn time via `.then()`) always fired before the await resumed, seeing `resultConsumed` as false.
23
+ - **Stale agent records across sessions** — new `clearCompleted()` method removes all completed/stopped/errored agent records on `session_start` and `session_switch` events, so tasks from a prior session don't persist into a new one.
24
+ - **`steer_subagent` race on freshly launched agents** — steering an agent before its session initialized silently dropped the message. Now steers are queued on the record and flushed once `onSessionCreated` fires.
25
+
26
+ ### Changed
27
+ - Extracted `removeRecord()` private helper in `AgentManager` — deduplicates dispose+delete logic between `cleanup()` and `clearCompleted()`.
28
+
29
+ ### Added
30
+ - 8 new tests covering `resultConsumed` race condition and `clearCompleted` behavior (185 total).
31
+
8
32
  ## [0.4.3] - 2026-03-13
9
33
 
10
34
  ### Added
@@ -242,6 +266,8 @@ Initial release.
242
266
  - **Thinking level** — per-agent extended thinking control
243
267
  - **`/agent` and `/agents` commands**
244
268
 
269
+ [0.4.5]: https://github.com/tintinweb/pi-subagents/compare/v0.4.4...v0.4.5
270
+ [0.4.4]: https://github.com/tintinweb/pi-subagents/compare/v0.4.3...v0.4.4
245
271
  [0.4.3]: https://github.com/tintinweb/pi-subagents/compare/v0.4.2...v0.4.3
246
272
  [0.4.2]: https://github.com/tintinweb/pi-subagents/compare/v0.4.1...v0.4.2
247
273
  [0.4.1]: https://github.com/tintinweb/pi-subagents/compare/v0.4.0...v0.4.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -152,6 +152,13 @@ export class AgentManager {
152
152
  onTextDelta: options.onTextDelta,
153
153
  onSessionCreated: (session) => {
154
154
  record.session = session;
155
+ // Flush any steers that arrived before the session was ready
156
+ if (record.pendingSteers?.length) {
157
+ for (const msg of record.pendingSteers) {
158
+ session.steer(msg).catch(() => {});
159
+ }
160
+ record.pendingSteers = undefined;
161
+ }
155
162
  options.onSessionCreated?.(session);
156
163
  },
157
164
  })
@@ -300,18 +307,30 @@ export class AgentManager {
300
307
  return true;
301
308
  }
302
309
 
310
+ /** Dispose a record's session and remove it from the map. */
311
+ private removeRecord(id: string, record: AgentRecord): void {
312
+ record.session?.dispose?.();
313
+ record.session = undefined;
314
+ this.agents.delete(id);
315
+ }
316
+
303
317
  private cleanup() {
304
318
  const cutoff = Date.now() - 10 * 60_000;
305
319
  for (const [id, record] of this.agents) {
306
320
  if (record.status === "running" || record.status === "queued") continue;
307
321
  if ((record.completedAt ?? 0) >= cutoff) continue;
322
+ this.removeRecord(id, record);
323
+ }
324
+ }
308
325
 
309
- // Dispose and clear session so memory can be reclaimed
310
- if (record.session) {
311
- record.session.dispose();
312
- record.session = undefined;
313
- }
314
- this.agents.delete(id);
326
+ /**
327
+ * Remove all completed/stopped/errored records immediately.
328
+ * Called on session start/switch so tasks from a prior session don't persist.
329
+ */
330
+ clearCompleted(): void {
331
+ for (const [id, record] of this.agents) {
332
+ if (record.status === "running" || record.status === "queued") continue;
333
+ this.removeRecord(id, record);
315
334
  }
316
335
  }
317
336
 
package/src/index.ts CHANGED
@@ -284,6 +284,10 @@ export default function (pi: ExtensionAPI) {
284
284
  getRecord: (id: string) => manager.getRecord(id),
285
285
  };
286
286
 
287
+ // Clear completed tasks when a new session starts (e.g. /new) so stale records don't persist
288
+ pi.on("session_start", () => { manager.clearCompleted(); });
289
+ pi.on("session_switch", () => { manager.clearCompleted(); });
290
+
287
291
  // Wait for all subagents on shutdown, then dispose the manager
288
292
  pi.on("session_shutdown", async () => {
289
293
  delete (globalThis as any)[MANAGER_KEY];
@@ -812,8 +816,12 @@ Guidelines:
812
816
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
813
817
  }
814
818
 
815
- // Wait for completion if requested
819
+ // Wait for completion if requested.
820
+ // Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
821
+ // (attached earlier at spawn time) and always runs before this await resumes.
822
+ // Setting the flag here prevents a redundant follow-up notification.
816
823
  if (params.wait && record.status === "running" && record.promise) {
824
+ record.resultConsumed = true;
817
825
  await record.promise;
818
826
  }
819
827
 
@@ -877,7 +885,10 @@ Guidelines:
877
885
  return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
878
886
  }
879
887
  if (!record.session) {
880
- return textResult(`Agent "${params.agent_id}" has no active session yet. It may still be initializing.`);
888
+ // Session not ready yet queue the steer for delivery once initialized
889
+ (record.pendingSteers ??= []).push(params.message);
890
+ pi.events.emit("subagents:steered", { id: record.id, message: params.message });
891
+ return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
881
892
  }
882
893
 
883
894
  try {
package/src/types.ts CHANGED
@@ -73,6 +73,8 @@ export interface AgentRecord {
73
73
  joinMode?: JoinMode;
74
74
  /** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
75
75
  resultConsumed?: boolean;
76
+ /** Steering messages queued before the session was ready. */
77
+ pendingSteers?: string[];
76
78
  /** Worktree info if the agent is running in an isolated worktree. */
77
79
  worktree?: { path: string; branch: string };
78
80
  /** Worktree cleanup result after agent completion. */
@@ -155,6 +155,13 @@ export class AgentWidget {
155
155
  /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
156
156
  private static readonly ERROR_LINGER_TURNS = 2;
157
157
 
158
+ /** Whether the widget callback is currently registered with the TUI. */
159
+ private widgetRegistered = false;
160
+ /** Cached TUI reference from widget factory callback, used for requestRender(). */
161
+ private tui: any | undefined;
162
+ /** Last status bar text, used to avoid redundant setStatus calls. */
163
+ private lastStatusText: string | undefined;
164
+
158
165
  constructor(
159
166
  private manager: AgentManager,
160
167
  private agentActivity: Map<string, AgentActivity>,
@@ -162,7 +169,14 @@ export class AgentWidget {
162
169
 
163
170
  /** Set the UI context (grabbed from first tool execution). */
164
171
  setUICtx(ctx: UICtx) {
165
- this.uiCtx = ctx;
172
+ if (ctx !== this.uiCtx) {
173
+ // UICtx changed — the widget registered on the old context is gone.
174
+ // Force re-registration on next update().
175
+ this.uiCtx = ctx;
176
+ this.widgetRegistered = false;
177
+ this.tui = undefined;
178
+ this.lastStatusText = undefined;
179
+ }
166
180
  }
167
181
 
168
182
  /**
@@ -234,9 +248,11 @@ export class AgentWidget {
234
248
  return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
235
249
  }
236
250
 
237
- /** Force an immediate widget update. */
238
- update() {
239
- if (!this.uiCtx) return;
251
+ /**
252
+ * Render the widget content. Called from the registered widget's render() callback,
253
+ * reading live state each time instead of capturing it in a closure.
254
+ */
255
+ private renderWidget(tui: any, theme: Theme): string[] {
240
256
  const allAgents = this.manager.listAgents();
241
257
  const running = allAgents.filter(a => a.status === "running");
242
258
  const queued = allAgents.filter(a => a.status === "queued");
@@ -248,148 +264,196 @@ export class AgentWidget {
248
264
  const hasActive = running.length > 0 || queued.length > 0;
249
265
  const hasFinished = finished.length > 0;
250
266
 
251
- // Nothing to show — clear widget
252
- if (!hasActive && !hasFinished) {
253
- this.uiCtx.setWidget("agents", undefined);
254
- this.uiCtx.setStatus("subagents", undefined);
255
- if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
256
- // Clean up stale entries
257
- for (const [id] of this.finishedTurnAge) {
258
- if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
259
- }
260
- return;
261
- }
267
+ // Nothing to show — return empty (widget will be unregistered by update())
268
+ if (!hasActive && !hasFinished) return [];
262
269
 
263
- // Status bar
264
- if (hasActive) {
265
- const statusParts: string[] = [];
266
- if (running.length > 0) statusParts.push(`${running.length} running`);
267
- if (queued.length > 0) statusParts.push(`${queued.length} queued`);
268
- const total = running.length + queued.length;
269
- this.uiCtx.setStatus("subagents", `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`);
270
- } else {
271
- this.uiCtx.setStatus("subagents", undefined);
272
- }
273
-
274
- this.widgetFrame++;
270
+ const w = tui.terminal.columns;
271
+ const truncate = (line: string) => truncateToWidth(line, w);
272
+ const headingColor = hasActive ? "accent" : "dim";
273
+ const headingIcon = hasActive ? "●" : "○";
275
274
  const frame = SPINNER[this.widgetFrame % SPINNER.length];
276
275
 
277
- this.uiCtx.setWidget("agents", (tui, theme) => {
278
- const w = tui.terminal.columns;
279
- const truncate = (line: string) => truncateToWidth(line, w);
280
- const headingColor = hasActive ? "accent" : "dim";
281
- const headingIcon = hasActive ? "●" : "○";
276
+ // Build sections separately for overflow-aware assembly.
277
+ // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
282
278
 
283
- // Build sections separately for overflow-aware assembly.
284
- // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
279
+ const finishedLines: string[] = [];
280
+ for (const a of finished) {
281
+ finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
282
+ }
285
283
 
286
- const finishedLines: string[] = [];
287
- for (const a of finished) {
288
- finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
284
+ const runningLines: string[][] = []; // each entry is [header, activity]
285
+ for (const a of running) {
286
+ const name = getDisplayName(a.type);
287
+ const modeLabel = getPromptModeLabel(a.type);
288
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
289
+ const elapsed = formatMs(Date.now() - a.startedAt);
290
+
291
+ const bg = this.agentActivity.get(a.id);
292
+ const toolUses = bg?.toolUses ?? a.toolUses;
293
+ let tokenText = "";
294
+ if (bg?.session) {
295
+ try { tokenText = formatTokens(bg.session.getSessionStats().tokens.total); } catch { /* */ }
289
296
  }
290
297
 
291
- const runningLines: string[][] = []; // each entry is [header, activity]
292
- for (const a of running) {
293
- const name = getDisplayName(a.type);
294
- const modeLabel = getPromptModeLabel(a.type);
295
- const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
296
- const elapsed = formatMs(Date.now() - a.startedAt);
297
-
298
- const bg = this.agentActivity.get(a.id);
299
- const toolUses = bg?.toolUses ?? a.toolUses;
300
- let tokenText = "";
301
- if (bg?.session) {
302
- try { tokenText = formatTokens(bg.session.getSessionStats().tokens.total); } catch { /* */ }
303
- }
298
+ const parts: string[] = [];
299
+ if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
300
+ if (tokenText) parts.push(tokenText);
301
+ parts.push(elapsed);
302
+ const statsText = parts.join(" · ");
304
303
 
305
- const parts: string[] = [];
306
- if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
307
- if (tokenText) parts.push(tokenText);
308
- parts.push(elapsed);
309
- const statsText = parts.join(" · ");
304
+ const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
310
305
 
311
- const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
312
-
313
- runningLines.push([
314
- truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
315
- truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
316
- ]);
317
- }
306
+ runningLines.push([
307
+ truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
308
+ truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
309
+ ]);
310
+ }
318
311
 
319
- const queuedLine = queued.length > 0
320
- ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
321
- : undefined;
322
-
323
- // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
324
- const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
325
- const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
326
-
327
- const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
328
-
329
- if (totalBody <= maxBody) {
330
- // Everything fits — add all lines and fix up connectors for the last item.
331
- lines.push(...finishedLines);
332
- for (const pair of runningLines) lines.push(...pair);
333
- if (queuedLine) lines.push(queuedLine);
334
-
335
- // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
336
- if (lines.length > 1) {
337
- const last = lines.length - 1;
338
- lines[last] = lines[last].replace("├─", "└─");
339
- // If last item is a running agent activity line, fix indent of that line
340
- // and fix the header line above it.
341
- if (runningLines.length > 0 && !queuedLine) {
342
- // The last two lines are the last running agent's header + activity.
343
- if (last >= 2) {
344
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
345
- lines[last] = lines[last].replace("│ ", " ");
346
- }
312
+ const queuedLine = queued.length > 0
313
+ ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
314
+ : undefined;
315
+
316
+ // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
317
+ const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
318
+ const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
319
+
320
+ const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
321
+
322
+ if (totalBody <= maxBody) {
323
+ // Everything fits — add all lines and fix up connectors for the last item.
324
+ lines.push(...finishedLines);
325
+ for (const pair of runningLines) lines.push(...pair);
326
+ if (queuedLine) lines.push(queuedLine);
327
+
328
+ // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
329
+ if (lines.length > 1) {
330
+ const last = lines.length - 1;
331
+ lines[last] = lines[last].replace("├─", "└─");
332
+ // If last item is a running agent activity line, fix indent of that line
333
+ // and fix the header line above it.
334
+ if (runningLines.length > 0 && !queuedLine) {
335
+ // The last two lines are the last running agent's header + activity.
336
+ if (last >= 2) {
337
+ lines[last - 1] = lines[last - 1].replace("├─", "└─");
338
+ lines[last] = lines[last].replace("│ ", " ");
347
339
  }
348
340
  }
349
- } else {
350
- // Overflow — prioritize: running > queued > finished.
351
- // Reserve 1 line for overflow indicator.
352
- let budget = maxBody - 1;
353
- let hiddenRunning = 0;
354
- let hiddenFinished = 0;
355
-
356
- // 1. Running agents (2 lines each)
357
- for (const pair of runningLines) {
358
- if (budget >= 2) {
359
- lines.push(...pair);
360
- budget -= 2;
361
- } else {
362
- hiddenRunning++;
363
- }
341
+ }
342
+ } else {
343
+ // Overflow prioritize: running > queued > finished.
344
+ // Reserve 1 line for overflow indicator.
345
+ let budget = maxBody - 1;
346
+ let hiddenRunning = 0;
347
+ let hiddenFinished = 0;
348
+
349
+ // 1. Running agents (2 lines each)
350
+ for (const pair of runningLines) {
351
+ if (budget >= 2) {
352
+ lines.push(...pair);
353
+ budget -= 2;
354
+ } else {
355
+ hiddenRunning++;
364
356
  }
357
+ }
365
358
 
366
- // 2. Queued line
367
- if (queuedLine && budget >= 1) {
368
- lines.push(queuedLine);
359
+ // 2. Queued line
360
+ if (queuedLine && budget >= 1) {
361
+ lines.push(queuedLine);
362
+ budget--;
363
+ }
364
+
365
+ // 3. Finished agents
366
+ for (const fl of finishedLines) {
367
+ if (budget >= 1) {
368
+ lines.push(fl);
369
369
  budget--;
370
+ } else {
371
+ hiddenFinished++;
370
372
  }
373
+ }
371
374
 
372
- // 3. Finished agents
373
- for (const fl of finishedLines) {
374
- if (budget >= 1) {
375
- lines.push(fl);
376
- budget--;
377
- } else {
378
- hiddenFinished++;
379
- }
380
- }
375
+ // Overflow summary
376
+ const overflowParts: string[] = [];
377
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
378
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
379
+ const overflowText = overflowParts.join(", ");
380
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
381
+ );
382
+ }
383
+
384
+ return lines;
385
+ }
381
386
 
382
- // Overflow summary
383
- const overflowParts: string[] = [];
384
- if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
385
- if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
386
- const overflowText = overflowParts.join(", ");
387
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
388
- );
387
+ /** Force an immediate widget update. */
388
+ update() {
389
+ if (!this.uiCtx) return;
390
+ const allAgents = this.manager.listAgents();
391
+
392
+ // Lightweight existence checks full categorization happens in renderWidget()
393
+ let runningCount = 0;
394
+ let queuedCount = 0;
395
+ let hasFinished = false;
396
+ for (const a of allAgents) {
397
+ if (a.status === "running") { runningCount++; }
398
+ else if (a.status === "queued") { queuedCount++; }
399
+ else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
400
+ }
401
+ const hasActive = runningCount > 0 || queuedCount > 0;
402
+
403
+ // Nothing to show — clear widget
404
+ if (!hasActive && !hasFinished) {
405
+ if (this.widgetRegistered) {
406
+ this.uiCtx.setWidget("agents", undefined);
407
+ this.widgetRegistered = false;
408
+ this.tui = undefined;
389
409
  }
410
+ if (this.lastStatusText !== undefined) {
411
+ this.uiCtx.setStatus("subagents", undefined);
412
+ this.lastStatusText = undefined;
413
+ }
414
+ if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
415
+ // Clean up stale entries
416
+ for (const [id] of this.finishedTurnAge) {
417
+ if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
418
+ }
419
+ return;
420
+ }
390
421
 
391
- return { render: () => lines, invalidate: () => {} };
392
- }, { placement: "aboveEditor" });
422
+ // Status bar only call setStatus when the text actually changes
423
+ let newStatusText: string | undefined;
424
+ if (hasActive) {
425
+ const statusParts: string[] = [];
426
+ if (runningCount > 0) statusParts.push(`${runningCount} running`);
427
+ if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
428
+ const total = runningCount + queuedCount;
429
+ newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
430
+ }
431
+ if (newStatusText !== this.lastStatusText) {
432
+ this.uiCtx.setStatus("subagents", newStatusText);
433
+ this.lastStatusText = newStatusText;
434
+ }
435
+
436
+ this.widgetFrame++;
437
+
438
+ // Register widget callback once; subsequent updates use requestRender()
439
+ // which re-invokes render() without replacing the component (avoids layout thrashing).
440
+ if (!this.widgetRegistered) {
441
+ this.uiCtx.setWidget("agents", (tui, theme) => {
442
+ this.tui = tui;
443
+ return {
444
+ render: () => this.renderWidget(tui, theme),
445
+ invalidate: () => {
446
+ // Theme changed — force re-registration so factory captures fresh theme.
447
+ this.widgetRegistered = false;
448
+ this.tui = undefined;
449
+ },
450
+ };
451
+ }, { placement: "aboveEditor" });
452
+ this.widgetRegistered = true;
453
+ } else {
454
+ // Widget already registered — just request a re-render of existing components.
455
+ this.tui?.requestRender();
456
+ }
393
457
  }
394
458
 
395
459
  dispose() {
@@ -401,5 +465,8 @@ export class AgentWidget {
401
465
  this.uiCtx.setWidget("agents", undefined);
402
466
  this.uiCtx.setStatus("subagents", undefined);
403
467
  }
468
+ this.widgetRegistered = false;
469
+ this.tui = undefined;
470
+ this.lastStatusText = undefined;
404
471
  }
405
472
  }