@nklisch/pi-enhanced 0.2.0 → 0.2.2

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 (65) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/node_modules/@nklisch/pi-background-tasks/CHANGELOG.md +7 -0
  3. package/node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.test.ts +105 -0
  4. package/node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.ts +131 -44
  5. package/node_modules/@nklisch/pi-background-tasks/package.json +1 -1
  6. package/node_modules/@nklisch/pi-background-tasks/skills/background-tasks/SKILL.md +6 -7
  7. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  8. package/node_modules/@nklisch/pi-clearance/package.json +1 -1
  9. package/node_modules/@nklisch/pi-clearance/src/config/config-command-writer.ts +34 -17
  10. package/node_modules/@nklisch/pi-clearance/src/index.ts +34 -3
  11. package/node_modules/@nklisch/pi-clearance/src/packs/package-registration.ts +63 -36
  12. package/node_modules/@nklisch/pi-clearance/src/runtime/allow-request-message.ts +40 -1
  13. package/node_modules/@nklisch/pi-clearance/src/runtime/command-registry.ts +22 -2
  14. package/node_modules/@nklisch/pi-clearance/src/runtime/command-transforms.ts +50 -29
  15. package/node_modules/@nklisch/pi-clearance/src/runtime/config-commands/settings/native-ui.ts +233 -49
  16. package/node_modules/@nklisch/pi-clearance/src/runtime/policy-cache.ts +19 -5
  17. package/node_modules/@nklisch/pi-conveniences/extensions/context-window-footer.ts +48 -13
  18. package/node_modules/@nklisch/pi-conveniences/package.json +1 -1
  19. package/node_modules/@nklisch/pi-fff-compat/extensions/fff-compat-search.ts +49 -42
  20. package/node_modules/@nklisch/pi-fff-compat/extensions/finder-lifecycle.ts +92 -0
  21. package/node_modules/@nklisch/pi-fff-compat/package.json +2 -2
  22. package/node_modules/@nklisch/pi-model-modes/package.json +1 -1
  23. package/node_modules/@nklisch/pi-model-modes/src/command-message.ts +30 -0
  24. package/node_modules/@nklisch/pi-model-modes/src/commands.ts +31 -15
  25. package/node_modules/@nklisch/pi-model-modes/src/style-command.ts +25 -14
  26. package/node_modules/@nklisch/pi-plugins/README.md +2 -2
  27. package/node_modules/@nklisch/pi-plugins/dist/application/subagent-hook-runtime.js +4 -1
  28. package/node_modules/@nklisch/pi-plugins/dist/application/subagent-hook-runtime.js.map +1 -1
  29. package/node_modules/@nklisch/pi-plugins/dist/composition/background-update-coordinator.js +12 -3
  30. package/node_modules/@nklisch/pi-plugins/dist/composition/background-update-coordinator.js.map +1 -1
  31. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.d.ts +1 -0
  32. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.js +19 -11
  33. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.js.map +1 -1
  34. package/node_modules/@nklisch/pi-plugins/dist/pi/hooks/pi-hook-decision-adapter.js +36 -7
  35. package/node_modules/@nklisch/pi-plugins/dist/pi/hooks/pi-hook-decision-adapter.js.map +1 -1
  36. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-component.js +14 -3
  37. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-component.js.map +1 -1
  38. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-session.js +153 -54
  39. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-session.js.map +1 -1
  40. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-lifecycle.js +35 -8
  41. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-lifecycle.js.map +1 -1
  42. package/node_modules/@nklisch/pi-plugins/dist/runtime/mcp/pi-mcp-adapter-package.js +1 -1
  43. package/node_modules/@nklisch/pi-plugins/dist/runtime/mcp/pi-mcp-adapter-runtime.js +1 -1
  44. package/node_modules/@nklisch/pi-plugins/dist/runtime/subagents/pi-subagents-package.js +1 -1
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +7 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +22 -17
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/debug.ts +18 -0
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +22 -4
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +32 -8
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +8 -7
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +25 -6
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +6 -2
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +31 -16
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +70 -32
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +55 -50
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace-bracket.ts +6 -2
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/record-observer.ts +37 -34
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +15 -14
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +31 -26
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +51 -26
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +100 -46
  63. package/node_modules/@nklisch/pi-plugins/package.json +3 -3
  64. package/package.json +4 -4
  65. package/test/verify-bundle.mjs +8 -0
@@ -9,7 +9,7 @@
9
9
  import { randomUUID } from "node:crypto";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
11
  import type { AgentSessionEvent, ToolDefinition } from "@earendil-works/pi-coding-agent";
12
- import { debugLog } from "#src/debug";
12
+ import { debugLog, runDetached, runSafely } from "#src/debug";
13
13
  import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
14
14
  import {
15
15
  type LifecycleInterceptorRegistry,
@@ -264,30 +264,26 @@ export class Subagent {
264
264
  */
265
265
  async run(): Promise<void> {
266
266
  this.markRunning(Date.now());
267
- this.execution.observer?.onStarted?.(this);
268
- this.listeners.wireSignal(this.execution.signal, () => this.abort());
269
-
270
- // Guard the await so the no-provider path stays synchronous, preserving
271
- // the original run() timing: the factory is called in the same turn as
272
- // spawn() when no workspace provider is registered.
273
- let cwd: string | undefined;
274
- if (this.workspaceBracket.hasProvider()) {
275
- try {
267
+ try {
268
+ // Observer callbacks are extension-owned sinks, not part of the agent's
269
+ // work. A stale UI/context callback must not leave a background record
270
+ // running with a slot held and no terminal notification.
271
+ runSafely("subagent onStarted observer", () => this.execution.observer?.onStarted?.(this));
272
+ this.listeners.wireSignal(this.execution.signal, () => this.abort());
273
+
274
+ // Guard the await so the no-provider path stays synchronous, preserving
275
+ // the original run() timing: the factory is called in the same turn as
276
+ // spawn() when no workspace provider is registered.
277
+ let cwd: string | undefined;
278
+ if (this.workspaceBracket.hasProvider()) {
276
279
  cwd = await this.workspaceBracket.prepare({
277
280
  agentId: this.id,
278
281
  agentType: this.type,
279
282
  baseCwd: this.execution.baseCwd,
280
283
  invocation: this.invocation,
281
284
  });
282
- } catch (err) {
283
- this.markError(err);
284
- this.listeners.release();
285
- this.execution.observer?.onRunFinished?.(this);
286
- return;
287
285
  }
288
- }
289
286
 
290
- try {
291
287
  this.subagentSession = await this.execution.createSubagentSession({
292
288
  snapshot: this.execution.snapshot,
293
289
  type: this.type,
@@ -296,27 +292,24 @@ export class Subagent {
296
292
  model: this.execution.model,
297
293
  thinkingLevel: this.execution.thinkingLevel,
298
294
  });
299
- } catch (err) {
300
- // The factory disposed its own session on a post-creation failure.
301
- this.failRun(err);
302
- return;
303
- }
304
295
 
305
- // The SDK session is authoritative after creation: it has applied its
306
- // defaults and model-capability clamp. Keep the record as the one source
307
- // consumed by every operator-facing status surface.
308
- this._modelLabel = formatModelLabel(this.subagentSession.model ?? this.execution.model ?? this.execution.snapshot.model);
309
- this._effectiveThinkingLevel = this.subagentSession.thinkingLevel ?? this._effectiveThinkingLevel;
310
-
311
- this.flushPendingSteers();
312
- this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
313
- onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
314
- }));
315
- this.execution.observer?.onSessionCreated?.(this);
316
-
317
- const runConfig = this.execution.getRunConfig?.();
318
- const lifecycle = this.createTurnLifecycle("initial");
319
- try {
296
+ // The SDK session is authoritative after creation: it has applied its
297
+ // defaults and model-capability clamp. Keep the record as the one source
298
+ // consumed by every operator-facing status surface.
299
+ this._modelLabel = formatModelLabel(this.subagentSession.model ?? this.execution.model ?? this.execution.snapshot.model);
300
+ this._effectiveThinkingLevel = this.subagentSession.thinkingLevel ?? this._effectiveThinkingLevel;
301
+
302
+ this.flushPendingSteers();
303
+ this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
304
+ onCompact: (info) => runSafely(
305
+ "subagent onCompacted observer",
306
+ () => this.execution.observer?.onCompacted?.(this, info),
307
+ ),
308
+ }));
309
+ runSafely("subagent onSessionCreated observer", () => this.execution.observer?.onSessionCreated?.(this));
310
+
311
+ const runConfig = this.execution.getRunConfig?.();
312
+ const lifecycle = this.createTurnLifecycle("initial");
320
313
  const result = await this.subagentSession.runTurnLoop(this.execution.prompt, {
321
314
  maxTurns: this.execution.maxTurns,
322
315
  defaultMaxTurns: runConfig?.defaultMaxTurns,
@@ -326,6 +319,8 @@ export class Subagent {
326
319
  });
327
320
  this.completeRun(result);
328
321
  } catch (err) {
322
+ // One outer failure path guarantees terminal state, listener release,
323
+ // workspace cleanup, and the manager's completion funnel.
329
324
  this.failRun(err);
330
325
  }
331
326
  }
@@ -389,12 +384,15 @@ export class Subagent {
389
384
  const executionSignal = signal
390
385
  ? AbortSignal.any([this.abortController.signal, signal])
391
386
  : this.abortController.signal;
392
- this.listeners.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
393
- onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
394
- }));
395
-
396
- const lifecycle = this.createTurnLifecycle("resume", executionSignal);
397
387
  try {
388
+ this.listeners.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
389
+ onCompact: (info) => runSafely(
390
+ "subagent onCompacted observer",
391
+ () => this.execution.observer?.onCompacted?.(this, info),
392
+ ),
393
+ }));
394
+
395
+ const lifecycle = this.createTurnLifecycle("resume", executionSignal);
398
396
  if (lifecycle) {
399
397
  const result = await subagentSession.resumeLifecycleTurnLoop(
400
398
  prompt,
@@ -415,7 +413,7 @@ export class Subagent {
415
413
  this.markError(err);
416
414
  } finally {
417
415
  this.listeners.release();
418
- this.execution.observer?.onResumedFinished?.(this);
416
+ runSafely("subagent onResumedFinished observer", () => this.execution.observer?.onResumedFinished?.(this));
419
417
  }
420
418
  }
421
419
 
@@ -527,7 +525,7 @@ export class Subagent {
527
525
  /** Stop a queued agent through the same terminal observer funnel as a run. */
528
526
  stopQueued(): void {
529
527
  this.state.stopQueued();
530
- this.execution.observer?.onRunFinished?.(this);
528
+ runSafely("subagent onRunFinished observer", () => this.execution.observer?.onRunFinished?.(this));
531
529
  }
532
530
 
533
531
  /**
@@ -557,7 +555,7 @@ export class Subagent {
557
555
  */
558
556
  private flushPendingSteers(): void {
559
557
  for (const msg of this._pendingSteers) {
560
- this.subagentSession?.steer(msg).catch(() => {});
558
+ runDetached("subagent buffered steer", () => this.subagentSession?.steer(msg));
561
559
  }
562
560
  this._pendingSteers = [];
563
561
  }
@@ -580,16 +578,23 @@ export class Subagent {
580
578
  : result.steered
581
579
  ? "steered"
582
580
  : "completed";
583
- const finalResult =
584
- result.responseText +
585
- this.workspaceBracket.dispose({ status: finalStatus, description: this.description });
581
+ let finalResult = result.responseText;
582
+ try {
583
+ finalResult += this.workspaceBracket.dispose({ status: finalStatus, description: this.description });
584
+ } catch (error) {
585
+ // Workspace teardown belongs to the run's terminal boundary. If it
586
+ // fails, report an error once rather than re-entering disposal from the
587
+ // outer catch path.
588
+ this.failRun(error);
589
+ return;
590
+ }
586
591
 
587
592
  if (result.failure) this.markFailed(result.failure, finalResult);
588
593
  else if (result.aborted) this.markAborted(finalResult);
589
594
  else if (result.steered) this.markSteered(finalResult);
590
595
  else this.markCompleted(finalResult);
591
596
 
592
- this.execution.observer?.onRunFinished?.(this);
597
+ runSafely("subagent onRunFinished observer", () => this.execution.observer?.onRunFinished?.(this));
593
598
  }
594
599
 
595
600
  /** Dispose the wrapped session, firing the `disposed` lifecycle event. */
@@ -616,7 +621,7 @@ export class Subagent {
616
621
  this.workspaceBracket.dispose({ status: "error", description: this.description });
617
622
  } catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
618
623
 
619
- this.execution.observer?.onRunFinished?.(this);
624
+ runSafely("subagent onRunFinished observer", () => this.execution.observer?.onRunFinished?.(this));
620
625
  }
621
626
  }
622
627
 
@@ -53,7 +53,11 @@ export class WorkspaceBracket {
53
53
  * disposal is desired (e.g. failRun).
54
54
  */
55
55
  dispose(outcome: WorkspaceDisposeOutcome): string {
56
- if (!this.prepared) return "";
57
- return this.prepared.dispose(outcome)?.resultAddendum ?? "";
56
+ const prepared = this.prepared;
57
+ // Clear the owned resource before calling user code. If disposal throws,
58
+ // an outer failure path must not invoke the provider a second time.
59
+ this.prepared = undefined;
60
+ if (!prepared) return "";
61
+ return prepared.dispose(outcome)?.resultAddendum ?? "";
58
62
  }
59
63
  }
@@ -7,6 +7,7 @@
7
7
  * the caller forwards itself to its own lifecycle observer via onCompact.
8
8
  */
9
9
 
10
+ import { runSafely } from "#src/debug";
10
11
  import type { SubagentState } from "#src/lifecycle/subagent-state";
11
12
  import type { CompactionInfo, SubscribableSession } from "#src/types";
12
13
 
@@ -34,45 +35,47 @@ export function subscribeSubagentObserver(
34
35
  options?: SubagentObserverOptions,
35
36
  ): () => void {
36
37
  return session.subscribe((event) => {
37
- if (event.type === "tool_execution_start") {
38
- state.addActiveTool(event.toolName);
39
- }
38
+ runSafely("subagent session observer", () => {
39
+ if (event.type === "tool_execution_start") {
40
+ state.addActiveTool(event.toolName);
41
+ }
40
42
 
41
- if (event.type === "tool_execution_end") {
42
- state.removeActiveTool(event.toolName);
43
- state.incrementToolUses();
44
- }
43
+ if (event.type === "tool_execution_end") {
44
+ state.removeActiveTool(event.toolName);
45
+ state.incrementToolUses();
46
+ }
45
47
 
46
- if (event.type === "message_start") {
47
- state.resetResponseText();
48
- }
48
+ if (event.type === "message_start") {
49
+ state.resetResponseText();
50
+ }
49
51
 
50
- if (
51
- event.type === "message_update" &&
52
- event.assistantMessageEvent.type === "text_delta"
53
- ) {
54
- state.appendResponseText(event.assistantMessageEvent.delta);
55
- }
52
+ if (
53
+ event.type === "message_update" &&
54
+ event.assistantMessageEvent.type === "text_delta"
55
+ ) {
56
+ state.appendResponseText(event.assistantMessageEvent.delta);
57
+ }
56
58
 
57
- if (event.type === "turn_end") {
58
- state.incrementTurnCount();
59
- }
59
+ if (event.type === "turn_end") {
60
+ state.incrementTurnCount();
61
+ }
60
62
 
61
- if (event.type === "message_end" && event.message.role === "assistant") {
62
- const u = event.message.usage;
63
- state.addUsage({
64
- input: u.input,
65
- output: u.output,
66
- cacheWrite: u.cacheWrite,
67
- });
68
- }
63
+ if (event.type === "message_end" && event.message.role === "assistant") {
64
+ const u = event.message.usage;
65
+ state.addUsage({
66
+ input: u.input,
67
+ output: u.output,
68
+ cacheWrite: u.cacheWrite,
69
+ });
70
+ }
69
71
 
70
- if (event.type === "compaction_end" && !event.aborted && event.result) {
71
- state.incrementCompactions();
72
- options?.onCompact?.({
73
- reason: event.reason,
74
- tokensBefore: event.result.tokensBefore,
75
- });
76
- }
72
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
73
+ state.incrementCompactions();
74
+ options?.onCompact?.({
75
+ reason: event.reason,
76
+ tokensBefore: event.result.tokensBefore,
77
+ });
78
+ }
79
+ });
77
80
  });
78
81
  }
@@ -1,3 +1,4 @@
1
+ import { runSafely } from "#src/debug";
1
2
  import type { SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
2
3
  import { buildEventData, type NotificationSystem } from "#src/observation/notification";
3
4
  import type { CompactionInfo, Subagent } from "#src/types";
@@ -35,11 +36,11 @@ export class SubagentEventsObserver implements SubagentManagerObserver {
35
36
 
36
37
  onSubagentStarted(record: Subagent): void {
37
38
  // Emit started event when agent transitions to running (including from queue).
38
- this.emit("subagents:started", {
39
+ runSafely("subagent event emission started", () => this.emit("subagents:started", {
39
40
  id: record.id,
40
41
  type: record.type,
41
42
  description: record.description,
42
- });
43
+ }));
43
44
  }
44
45
 
45
46
  onSubagentCompleted(record: Subagent): void {
@@ -47,13 +48,13 @@ export class SubagentEventsObserver implements SubagentManagerObserver {
47
48
  const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
48
49
  const eventData = buildEventData(record);
49
50
  if (isError) {
50
- this.emit("subagents:failed", eventData);
51
+ runSafely("subagent event emission failed", () => this.emit("subagents:failed", eventData));
51
52
  } else {
52
- this.emit("subagents:completed", eventData);
53
+ runSafely("subagent event emission completed", () => this.emit("subagents:completed", eventData));
53
54
  }
54
55
 
55
56
  // Persist final record for cross-extension history reconstruction.
56
- this.appendEntry("subagents:record", {
57
+ runSafely("subagent record append", () => this.appendEntry("subagents:record", {
57
58
  id: record.id,
58
59
  type: record.type,
59
60
  description: record.description,
@@ -62,16 +63,16 @@ export class SubagentEventsObserver implements SubagentManagerObserver {
62
63
  error: record.error,
63
64
  startedAt: record.startedAt,
64
65
  completedAt: record.completedAt,
65
- });
66
+ }));
66
67
 
67
68
  // The manager decides whether to nudge (it owns the consumed-result state).
68
- this.notifications.sendCompletion(record);
69
+ runSafely("subagent completion notification", () => this.notifications.sendCompletion(record));
69
70
  }
70
71
 
71
72
  onSubagentResumed(record: Subagent): void {
72
73
  const eventData = buildEventData(record);
73
- this.emit("subagents:resumed", eventData);
74
- this.appendEntry("subagents:record", {
74
+ runSafely("subagent event emission resumed", () => this.emit("subagents:resumed", eventData));
75
+ runSafely("subagent record append resumed", () => this.appendEntry("subagents:record", {
75
76
  id: record.id,
76
77
  type: record.type,
77
78
  description: record.description,
@@ -80,28 +81,28 @@ export class SubagentEventsObserver implements SubagentManagerObserver {
80
81
  error: record.error,
81
82
  startedAt: record.startedAt,
82
83
  completedAt: record.completedAt,
83
- });
84
+ }));
84
85
  }
85
86
 
86
87
  onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
87
88
  // Emit compacted event when agent's session compacts (preserves count on record).
88
- this.emit("subagents:compacted", {
89
+ runSafely("subagent event emission compacted", () => this.emit("subagents:compacted", {
89
90
  id: record.id,
90
91
  type: record.type,
91
92
  description: record.description,
92
93
  reason: info.reason,
93
94
  tokensBefore: info.tokensBefore,
94
95
  compactionCount: record.compactionCount,
95
- });
96
+ }));
96
97
  }
97
98
 
98
99
  onSubagentCreated(record: Subagent): void {
99
100
  // Emit created event for background agents (before limiter admission).
100
- this.emit("subagents:created", {
101
+ runSafely("subagent event emission created", () => this.emit("subagents:created", {
101
102
  id: record.id,
102
103
  type: record.type,
103
104
  description: record.description,
104
105
  isBackground: true,
105
- });
106
+ }));
106
107
  }
107
108
  }
@@ -1,4 +1,5 @@
1
1
  import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { runSafely } from "#src/debug";
2
3
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
3
4
  import type { AgentSpawnConfig } from "#src/lifecycle/subagent-manager";
4
5
  import {
@@ -50,27 +51,29 @@ export async function runForeground(
50
51
 
51
52
  let recordRef: Subagent | undefined;
52
53
 
53
- const streamUpdate = () => {
54
- const toolUses = recordRef?.toolUses ?? 0;
55
- const details: AgentDetails = {
56
- ...presentation.detailBase,
57
- toolUses,
58
- tokens: recordRef ? formatLifetimeTokens(recordRef) : "",
59
- // Read activity off the record; fall back to safe defaults before onSessionCreated fires
60
- turnCount: recordRef?.turnCount ?? 1,
61
- maxTurns: recordRef?.maxTurns ?? execution.effectiveMaxTurns,
62
- durationMs: Date.now() - startedAt,
63
- status: "running",
64
- activity: describeActivity(
65
- recordRef?.activeTools ?? new Map(),
66
- recordRef?.responseText ?? "",
67
- ),
68
- spinnerFrame: spinnerFrame % SPINNER.length,
69
- };
70
- onUpdate?.({
71
- content: [{ type: "text", text: `${toolUses} tool uses...` }],
72
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Pi SDK ToolCallUpdate details type is not exported
73
- details: details as any,
54
+ const streamUpdate = (): void => {
55
+ runSafely("foreground progress update", () => {
56
+ const toolUses = recordRef?.toolUses ?? 0;
57
+ const details: AgentDetails = {
58
+ ...presentation.detailBase,
59
+ toolUses,
60
+ tokens: recordRef ? formatLifetimeTokens(recordRef) : "",
61
+ // Read activity off the record; fall back to safe defaults before onSessionCreated fires
62
+ turnCount: recordRef?.turnCount ?? 1,
63
+ maxTurns: recordRef?.maxTurns ?? execution.effectiveMaxTurns,
64
+ durationMs: Date.now() - startedAt,
65
+ status: "running",
66
+ activity: describeActivity(
67
+ recordRef?.activeTools ?? new Map(),
68
+ recordRef?.responseText ?? "",
69
+ ),
70
+ spinnerFrame: spinnerFrame % SPINNER.length,
71
+ };
72
+ onUpdate?.({
73
+ content: [{ type: "text", text: `${toolUses} tool uses...` }],
74
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Pi SDK ToolCallUpdate details type is not exported
75
+ details: details as any,
76
+ });
74
77
  });
75
78
  };
76
79
 
@@ -81,10 +84,10 @@ export async function runForeground(
81
84
  streamUpdate();
82
85
  }, 500);
83
86
 
84
- streamUpdate();
85
-
86
87
  let record: Subagent;
87
88
  try {
89
+ streamUpdate();
90
+
88
91
  record = await manager.spawnAndWait(
89
92
  params.snapshot,
90
93
  identity.subagentType,
@@ -107,12 +110,14 @@ export async function runForeground(
107
110
  },
108
111
  );
109
112
  } catch (err) {
110
- clearInterval(spinnerInterval);
113
+ // Only the spawn boundary converts to an error-text result. Post-spawn
114
+ // formatting is internal code; its defects stay thrown tool errors so Pi
115
+ // reports isError rather than a success-shaped result.
111
116
  return textResult(err instanceof Error ? err.message : String(err));
117
+ } finally {
118
+ clearInterval(spinnerInterval);
112
119
  }
113
120
 
114
- clearInterval(spinnerInterval);
115
-
116
121
  // Foreground delivery returns the complete outcome inline, so retention can
117
122
  // use the shorter consumed-session window.
118
123
  record.markConsumed();
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { AgentTypeRegistry } from "#src/config/agent-types";
10
+ import { debugLog, runSafely } from "#src/debug";
10
11
  import type { Subagent } from "#src/lifecycle/subagent";
11
12
  import type { SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
12
13
  import type { CompactionInfo } from "#src/types";
@@ -121,7 +122,7 @@ export class AgentWidget implements SubagentManagerObserver {
121
122
  // Lifecycle events can arrive before the first tool gives us a UI context.
122
123
  // Refresh when that context becomes available, but let update() decide
123
124
  // whether any active record actually needs an animation timer.
124
- if (contextChanged && this.uiCtx && this.backgroundAgents.size > 0) this.update();
125
+ if (contextChanged && this.uiCtx && this.backgroundAgents.size > 0) this.refresh();
125
126
  }
126
127
 
127
128
  /**
@@ -141,7 +142,7 @@ export class AgentWidget implements SubagentManagerObserver {
141
142
  this.finishedTurnAge.set(id, nextAge);
142
143
  }
143
144
  }
144
- this.update();
145
+ this.refresh();
145
146
  }
146
147
 
147
148
  // ---- SubagentManagerObserver: react to lifecycle, self-drive the timer ----
@@ -164,7 +165,7 @@ export class AgentWidget implements SubagentManagerObserver {
164
165
  onSubagentCompleted(record: Subagent) {
165
166
  if (!this.trackBackground(record)) return;
166
167
  this.finishedTurnAge.set(record.id, 0);
167
- this.update();
168
+ this.refresh();
168
169
  }
169
170
 
170
171
  /** A resumed subagent started — ensure the update loop is live and render. */
@@ -178,38 +179,38 @@ export class AgentWidget implements SubagentManagerObserver {
178
179
  onSubagentResumed(record: Subagent) {
179
180
  if (!this.trackBackground(record)) return;
180
181
  this.finishedTurnAge.set(record.id, 0);
181
- this.update();
182
+ this.refresh();
182
183
  }
183
184
 
184
185
  /** A subagent's session compacted — render to refresh the compaction count. */
185
186
  onSubagentCompacted(record: Subagent, _info: CompactionInfo) {
186
- if (this.trackBackground(record)) this.update();
187
+ if (this.trackBackground(record)) this.refresh();
187
188
  }
188
189
 
189
190
  /** Remove terminal state when the manager clears a parent session. */
190
191
  onSubagentCleared(record: Subagent) {
191
192
  if (!this.backgroundAgents.delete(record.id)) return;
192
193
  this.finishedTurnAge.delete(record.id);
193
- this.update();
194
+ this.refresh();
194
195
  }
195
196
 
196
197
  /** Refresh immediately; update() owns the timer lifecycle. */
197
198
  private startLoop() {
198
199
  if (!this.uiCtx) return;
199
- this.update();
200
+ this.refresh();
200
201
  }
201
202
 
202
203
  /** Ensure the widget update timer is running while active records animate. */
203
204
  private ensureTimer() {
204
205
  this.widgetInterval ??= setInterval(
205
- () => this.update(),
206
+ () => this.refresh(),
206
207
  AgentWidget.STATUS_REFRESH_INTERVAL_MS,
207
208
  );
208
209
  }
209
210
 
210
211
  /** Stop animated refreshes while leaving any static finished widget intact. */
211
212
  private stopTimer() {
212
- if (this.widgetInterval) {
213
+ if (this.widgetInterval !== undefined) {
213
214
  clearInterval(this.widgetInterval);
214
215
  this.widgetInterval = undefined;
215
216
  }
@@ -285,12 +286,12 @@ export class AgentWidget implements SubagentManagerObserver {
285
286
  */
286
287
  private clearWidget(): void {
287
288
  if (this.widgetRegistered) {
288
- this.uiCtx!.setWidget("agents", undefined);
289
+ runSafely("agent widget clear widget", () => this.uiCtx!.setWidget("agents", undefined));
289
290
  this.widgetRegistered = false;
290
291
  this.tui = undefined;
291
292
  }
292
293
  if (this.lastStatusText !== undefined) {
293
- this.uiCtx!.setStatus("subagents", undefined);
294
+ runSafely("agent widget clear status", () => this.uiCtx!.setStatus("subagents", undefined));
294
295
  this.lastStatusText = undefined;
295
296
  }
296
297
  this.stopTimer();
@@ -334,21 +335,45 @@ export class AgentWidget implements SubagentManagerObserver {
334
335
  // Register widget callback once; subsequent updates use requestRender()
335
336
  // which re-invokes render() without replacing the component (avoids layout thrashing).
336
337
  if (!this.widgetRegistered) {
337
- this.uiCtx.setWidget("agents", (tui, theme) => {
338
- this.tui = tui;
339
- return {
340
- render: () => this.renderWidget(tui, theme),
341
- invalidate: () => {
342
- // Theme changed force re-registration so factory captures fresh theme.
343
- this.widgetRegistered = false;
344
- this.tui = undefined;
345
- },
346
- };
347
- }, { placement: "aboveEditor" });
348
- this.widgetRegistered = true;
338
+ try {
339
+ this.uiCtx!.setWidget("agents", (tui, theme) => {
340
+ this.tui = tui;
341
+ return {
342
+ // TUI render callbacks run after the lifecycle callback that
343
+ // registered them; contain SDK/theme failures at this boundary too.
344
+ render: () => {
345
+ try {
346
+ return this.renderWidget(tui, theme);
347
+ } catch (error) {
348
+ debugLog("agent widget render", error);
349
+ return [];
350
+ }
351
+ },
352
+ invalidate: () => {
353
+ // Theme changed — force re-registration so factory captures fresh theme.
354
+ this.widgetRegistered = false;
355
+ this.tui = undefined;
356
+ },
357
+ };
358
+ }, { placement: "aboveEditor" });
359
+ this.widgetRegistered = true;
360
+ } catch (error) {
361
+ debugLog("agent widget register", error);
362
+ this.stopTimer();
363
+ }
349
364
  } else {
350
365
  // Widget already registered — just request a re-render of existing components.
351
- this.tui?.requestRender();
366
+ runSafely("agent widget request render", () => this.tui?.requestRender());
367
+ }
368
+ }
369
+
370
+ /** Contain UI refresh failures and stop a timer that can no longer make progress. */
371
+ private refresh(): void {
372
+ try {
373
+ this.update();
374
+ } catch (error) {
375
+ debugLog("agent widget update", error);
376
+ this.stopTimer();
352
377
  }
353
378
  }
354
379
 
@@ -356,8 +381,8 @@ export class AgentWidget implements SubagentManagerObserver {
356
381
  dispose() {
357
382
  this.stopTimer();
358
383
  if (this.uiCtx) {
359
- this.uiCtx.setWidget("agents", undefined);
360
- this.uiCtx.setStatus("subagents", undefined);
384
+ runSafely("agent widget dispose widget", () => this.uiCtx!.setWidget("agents", undefined));
385
+ runSafely("agent widget dispose status", () => this.uiCtx!.setStatus("subagents", undefined));
361
386
  }
362
387
  this.widgetRegistered = false;
363
388
  this.tui = undefined;