@nklisch/pi-enhanced 0.2.7 → 0.3.0

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 (70) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-x64-gnu.node +0 -0
  3. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  4. package/node_modules/@nklisch/pi-plugins/README.md +4 -2
  5. package/node_modules/@nklisch/pi-plugins/dist/hooks.js +81 -8
  6. package/node_modules/@nklisch/pi-plugins/dist/hooks.js.map +1 -1
  7. package/node_modules/@nklisch/pi-plugins/dist/mcp.js +32 -1
  8. package/node_modules/@nklisch/pi-plugins/dist/mcp.js.map +1 -1
  9. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +10 -4
  10. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +144 -10
  11. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
  12. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +40 -14
  13. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  14. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +19 -0
  15. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +75 -47
  16. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/public.d.ts +171 -140
  17. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/settings.d.ts +2 -2
  18. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/FORK-MAINTENANCE.md +8 -7
  19. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/VISION.md +12 -10
  20. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +111 -104
  21. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/comparison-with-upstream.md +3 -3
  22. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0004-reconsider-ui-direction.md +5 -0
  23. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  24. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +31 -4
  25. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/default-agents.ts +1 -1
  26. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/invocation-config.ts +7 -4
  27. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/index.ts +18 -9
  28. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/layered-settings.ts +2 -2
  29. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +10 -63
  30. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +122 -76
  31. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +3 -5
  32. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +4 -3
  33. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +2 -23
  34. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +177 -252
  35. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +90 -280
  36. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-state.ts +153 -239
  37. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +569 -700
  38. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace.ts +1 -1
  39. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/notification.ts +78 -122
  40. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/renderer.ts +15 -42
  41. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +44 -95
  42. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service-adapter.ts +157 -90
  43. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service.ts +60 -68
  44. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/conversation.ts +3 -3
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query-source.ts +28 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query.ts +327 -0
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/settings.ts +1 -1
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/agent-tool.ts +171 -249
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-tool.ts +51 -94
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/helpers.ts +9 -6
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/list-tool.ts +46 -0
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/parent-tool-registry.ts +17 -0
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/query-session-tool.ts +330 -0
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/result-renderer.ts +27 -111
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/resume-tool.ts +105 -0
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/spawn-config.ts +26 -8
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/steer-tool.ts +27 -111
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/stop-tool.ts +53 -0
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/types.ts +11 -5
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +33 -27
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/display.ts +6 -3
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigation.ts +121 -10
  63. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +430 -112
  64. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/subagents-settings.ts +1 -1
  65. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/widget-renderer.ts +8 -9
  66. package/node_modules/@nklisch/pi-plugins/package.json +2 -2
  67. package/package.json +1 -1
  68. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/background-spawner.ts +0 -81
  69. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +0 -149
  70. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-report.ts +0 -77
@@ -11,7 +11,7 @@
11
11
  * synchronously at run-start. The core has no knowledge of git or worktrees.
12
12
  */
13
13
 
14
- import type { SubagentStatus } from "#src/lifecycle/subagent";
14
+ import type { SubagentStatus } from "#src/lifecycle/subagent-state";
15
15
  import type { AgentInvocation, SubagentType } from "#src/types";
16
16
 
17
17
  /** Context the core hands a provider when a child run starts. */
@@ -1,14 +1,17 @@
1
1
  import { debugLog } from "#src/debug";
2
2
  import { getLifetimeTotal } from "#src/lifecycle/usage";
3
- import type { Subagent } from "#src/types";
3
+ import type { Subagent } from "#src/lifecycle/subagent";
4
+ import type { SubagentTerminalReason } from "#src/lifecycle/subagent-state";
4
5
 
5
- /** Details attached to custom notification messages for visual rendering. */
6
6
  export interface NotificationDetails {
7
7
  id: string;
8
8
  description: string;
9
+ runId: number;
10
+ mode: string;
9
11
  modelLabel: string;
10
12
  thinkingLevel: Subagent["effectiveThinkingLevel"];
11
13
  status: string;
14
+ terminalReason?: SubagentTerminalReason;
12
15
  toolUses: number;
13
16
  turnCount: number;
14
17
  maxTurns?: number;
@@ -19,170 +22,129 @@ export interface NotificationDetails {
19
22
  resultPreview: string;
20
23
  }
21
24
 
22
- // ---- Pure helpers (exported for unit testing) ----
23
-
24
- /** Escape XML text and attribute delimiters in structured notifications. */
25
25
  export function escapeXml(s: string): string {
26
- return s
27
- .replace(/&/g, "&")
28
- .replace(/</g, "&lt;")
29
- .replace(/>/g, "&gt;")
30
- .replace(/"/g, "&quot;")
31
- .replace(/'/g, "&apos;");
26
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
32
27
  }
33
28
 
34
- /** Human-readable status label for agent completion. */
35
- export function getStatusLabel(status: string, error?: string): string {
36
- switch (status) {
37
- case "error":
38
- return `Error: ${error ?? "unknown"}`;
39
- case "aborted":
40
- return "Aborted (max turns exceeded)";
41
- case "steered":
42
- return "Wrapped up (turn limit)";
43
- case "stopped":
44
- return "Stopped";
45
- default:
46
- return "Done";
47
- }
29
+ export function getStatusLabel(status: string, reason?: SubagentTerminalReason, error?: string): string {
30
+ if (status === "error") return `Error: ${error ?? "unknown"}`;
31
+ if (reason === "turn_limit_graceful") return "Completed (turn limit)";
32
+ if (reason === "turn_limit_hard") return "Stopped (turn limit)";
33
+ if (status === "stopped") return reason ? `Stopped (${reason.replaceAll("_", " ")})` : "Stopped";
34
+ return "Done";
48
35
  }
49
36
 
50
- /** Format a structured <task-notification> XML block for the parent agent to parse. */
51
37
  export function formatTaskNotification(record: Subagent, resultMaxLen: number): string {
52
- const status = getStatusLabel(record.status, record.error);
53
- const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
54
- const totalTokens = getLifetimeTotal(record.lifetimeUsage);
55
- const contextPercent = record.getContextPercent();
56
- const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
57
- const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
58
-
59
- const resultPreview = record.stoppedWhileQueued
60
- ? "Agent was stopped while queued and never started. No work was performed."
61
- : record.result
62
- ? record.result.length > resultMaxLen
63
- ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
64
- : record.result
38
+ const status = getStatusLabel(record.status, record.stateTerminalReason, record.error);
39
+ const resultPreview = record.result
40
+ ? record.result.length > resultMaxLen ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)" : record.result
65
41
  : "No output.";
66
-
67
- const toolCallId = record.toolCallId;
68
42
  const outputFile = record.outputFile;
69
43
  return [
70
44
  "<task-notification>",
71
45
  `<task-id>${record.id}</task-id>`,
72
- toolCallId ? `<tool-use-id>${escapeXml(toolCallId)}</tool-use-id>` : null,
73
- outputFile ? `<output-file>${escapeXml(outputFile)}</output-file>` : null,
46
+ `<run-id>${record.runId}</run-id>`,
47
+ `<mode>${record.mode}</mode>`,
74
48
  `<status>${escapeXml(status)}</status>`,
49
+ record.stateTerminalReason ? `<terminal-reason>${record.stateTerminalReason}</terminal-reason>` : null,
75
50
  `<model>${escapeXml(record.modelLabel)}</model>`,
76
51
  `<thinking_level>${escapeXml(record.effectiveThinkingLevel)}</thinking_level>`,
77
- `<summary>Subagent "${escapeXml(record.description)}" ${record.status}</summary>`,
52
+ `<summary>Subagent "${escapeXml(record.description)}" ${escapeXml(record.status)}</summary>`,
78
53
  `<result>${escapeXml(resultPreview)}</result>`,
79
- `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
54
+ `<usage><total_tokens>${getLifetimeTotal(record.lifetimeUsage)}</total_tokens><tool_uses>${record.toolUses}</tool_uses><duration_ms>${record.activeRuntimeMs}</duration_ms></usage>`,
55
+ outputFile ? `<output-file>${escapeXml(outputFile)}</output-file>` : null,
80
56
  "</task-notification>",
81
- ]
82
- .filter(Boolean)
83
- .join("\n");
57
+ ].filter((line): line is string => line !== null).join("\n");
84
58
  }
85
59
 
86
- /** Build notification details for the custom message renderer. */
87
- export function buildNotificationDetails(
88
- record: Subagent,
89
- resultMaxLen: number,
90
- ): NotificationDetails {
91
- const totalTokens = getLifetimeTotal(record.lifetimeUsage);
92
-
60
+ export function buildNotificationDetails(record: Subagent, resultMaxLen: number): NotificationDetails {
93
61
  return {
94
62
  id: record.id,
95
63
  description: record.description,
64
+ runId: record.runId,
65
+ mode: record.mode,
96
66
  modelLabel: record.modelLabel,
97
67
  thinkingLevel: record.effectiveThinkingLevel,
98
68
  status: record.status,
69
+ terminalReason: record.stateTerminalReason,
99
70
  toolUses: record.toolUses,
100
71
  turnCount: record.turnCount,
101
72
  maxTurns: record.maxTurns,
102
- totalTokens,
103
- durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
73
+ totalTokens: getLifetimeTotal(record.lifetimeUsage),
74
+ durationMs: record.activeRuntimeMs,
104
75
  outputFile: record.outputFile,
105
76
  error: record.error,
106
- resultPreview: record.stoppedWhileQueued
107
- ? "Agent was stopped while queued and never started. No work was performed."
108
- : record.result
109
- ? record.result.length > resultMaxLen
110
- ? record.result.slice(0, resultMaxLen) + "…"
111
- : record.result
112
- : "No output.",
77
+ resultPreview: record.result ? record.result.slice(0, resultMaxLen) : "No output.",
113
78
  };
114
79
  }
115
80
 
116
- /** Build event data for lifecycle events from a Subagent. */
117
81
  export function buildEventData(record: Subagent) {
118
- const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
119
- const u = record.lifetimeUsage;
120
- const total = getLifetimeTotal(u);
121
- const tokens =
122
- total > 0
123
- ? { input: u.input, output: u.output, total }
124
- : undefined;
82
+ const usage = record.lifetimeUsage;
83
+ const total = getLifetimeTotal(usage);
125
84
  return {
126
85
  id: record.id,
86
+ runId: record.runId,
87
+ mode: record.mode,
127
88
  type: record.type,
128
89
  description: record.description,
129
90
  result: record.result,
130
91
  error: record.error,
131
92
  status: record.status,
93
+ terminalReason: record.stateTerminalReason,
94
+ modelLabel: record.modelLabel,
95
+ thinkingLevel: record.effectiveThinkingLevel,
132
96
  toolUses: record.toolUses,
133
- durationMs,
134
- tokens,
97
+ activeRuntimeMs: record.activeRuntimeMs,
98
+ tokens: total > 0 ? { input: usage.input, output: usage.output, total } : undefined,
135
99
  };
136
100
  }
137
101
 
138
- // ---- Notification system factory ----
139
-
140
102
  export interface NotificationSystem {
141
- sendCompletion: (record: Subagent) => void;
142
- dispose: () => void;
103
+ sendCompletion(record: Subagent): void;
104
+ dispose(): void;
143
105
  }
144
106
 
145
107
  export class NotificationManager implements NotificationSystem {
146
- // Pi cannot recall a queued followUp. Hold records while the parent run is
147
- // active so consumption can be rechecked at the actual delivery boundary.
148
- private pendingNudges = new Map<string, Subagent>();
108
+ private readonly pendingNudges = new Map<string, { record: Subagent; runId: number; content: string; details: NotificationDetails }>();
149
109
  private parentRunActive = false;
150
110
  private disposed = false;
151
111
 
152
- constructor(
153
- private sendMessage: (
154
- msg: { customType: string; content: string; display: boolean; details?: unknown },
155
- opts?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
156
- ) => void,
157
- ) {}
112
+ constructor(private readonly sendMessage: (msg: { customType: string; content: string; display: boolean; details?: unknown }, opts?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }) => void) {}
158
113
 
159
114
  sendCompletion(record: Subagent): void {
160
- // A blocking get-result request has already selected direct delivery for
161
- // this terminal outcome. Never enqueue the same payload as a follow-up,
162
- // even if Pi's parent lifecycle currently appears idle.
163
- if (this.disposed || record.consumed || record.hasPendingResultWait) return;
115
+ if (this.disposed || record.mode !== "detached" || record.consumed) return;
164
116
  if (this.parentRunActive) {
165
- this.pendingNudges.set(record.id, record);
166
- return;
167
- }
168
- this.emitIndividualNudge(record);
117
+ this.pendingNudges.set(`${record.id}:${record.runId}`, {
118
+ record,
119
+ runId: record.runId,
120
+ content: formatTaskNotification(record, 500),
121
+ details: buildNotificationDetails(record, 500),
122
+ });
123
+ } else this.emitIndividualNudge(record);
169
124
  }
170
125
 
171
- onParentAgentStart(): void {
172
- if (!this.disposed) this.parentRunActive = true;
173
- }
126
+ onParentAgentStart(): void { if (!this.disposed) this.parentRunActive = true; }
174
127
 
175
128
  onParentAgentSettled(): void {
176
129
  if (this.disposed) return;
177
130
  this.parentRunActive = false;
178
- const withheld = [...this.pendingNudges.values()];
131
+ const pending = [...this.pendingNudges.values()];
179
132
  this.pendingNudges.clear();
180
- for (const record of withheld) {
133
+ for (const nudge of pending) {
181
134
  try {
182
- this.emitIndividualNudge(record);
183
- } catch (err) {
184
- debugLog("notification render", err);
185
- }
135
+ // A resume may advance the record while the parent is running. The
136
+ // queued notification describes the old run and must not wake the
137
+ // parent after that run has been superseded (or after delivery mode
138
+ // changed to joined).
139
+ if (
140
+ nudge.runId === nudge.record.runId &&
141
+ nudge.record.mode === "detached" &&
142
+ !nudge.record.isActive() &&
143
+ !nudge.record.consumed
144
+ ) {
145
+ this.emitNudge(nudge.record, nudge.content, nudge.details);
146
+ }
147
+ } catch (error) { debugLog("notification render", error); }
186
148
  }
187
149
  }
188
150
 
@@ -192,24 +154,18 @@ export class NotificationManager implements NotificationSystem {
192
154
  }
193
155
 
194
156
  private emitIndividualNudge(record: Subagent): void {
195
- if (this.disposed || record.consumed) return;
196
-
197
- const notification = formatTaskNotification(record, 500);
198
- const outputFile = record.outputFile;
199
- const footer = !record.stoppedWhileQueued && outputFile
200
- ? `\nFull transcript available at: ${outputFile}`
201
- : "";
157
+ if (this.disposed || record.consumed || record.mode !== "detached") return;
158
+ this.emitNudge(record, formatTaskNotification(record, 500), buildNotificationDetails(record, 500));
159
+ }
202
160
 
203
- this.sendMessage(
204
- {
205
- customType: "subagent-notification",
206
- content: notification + footer,
207
- display: true,
208
- details: buildNotificationDetails(record, 500),
209
- },
210
- { deliverAs: "followUp", triggerTurn: true },
211
- );
212
- // Push delivery collected the terminal outcome just as surely as a pull.
213
- record.markConsumed();
161
+ private emitNudge(record: Subagent, notification: string, details: NotificationDetails, markConsumed = true): void {
162
+ const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : "";
163
+ this.sendMessage({
164
+ customType: "subagent-notification",
165
+ content: notification + footer,
166
+ display: true,
167
+ details,
168
+ }, { deliverAs: "followUp", triggerTurn: true });
169
+ if (markConsumed) record.markConsumed();
214
170
  }
215
171
  }
@@ -2,66 +2,39 @@ import { Text } from "@earendil-works/pi-tui";
2
2
  import type { NotificationDetails } from "#src/observation/notification";
3
3
  import { formatModelThinking, formatMs, formatTokens, formatTurns } from "#src/ui/display";
4
4
 
5
- /** Narrow theme interface — only the methods the renderer actually calls. */
6
5
  interface RendererTheme {
7
6
  fg(style: string, text: string): string;
8
7
  bold(text: string): string;
9
8
  }
9
+ interface RendererMessage { details?: NotificationDetails; }
10
+ interface RenderOptions { expanded: boolean; }
10
11
 
11
- /** Narrow message interface — only the fields the renderer reads. */
12
- interface RendererMessage {
13
- details?: NotificationDetails;
14
- }
15
-
16
- /** Narrow render options — only the fields the renderer reads. */
17
- interface RenderOptions {
18
- expanded: boolean;
19
- }
20
-
21
- /**
22
- * Create the notification renderer callback for `pi.registerMessageRenderer`.
23
- * Returns a factory so the renderer is independently testable without the Pi SDK.
24
- */
25
12
  export function createNotificationRenderer() {
26
13
  return (message: RendererMessage, { expanded }: RenderOptions, theme: RendererTheme): Text | undefined => {
27
14
  const d = message.details;
28
15
  if (!d) return undefined;
29
-
30
- const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
31
- const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
32
- const statusText = isError
33
- ? d.status
34
- : d.status === "steered"
35
- ? "completed (steered)"
36
- : "completed";
37
-
38
- // Line 1: icon + agent description + status
16
+ const stopped = d.status === "stopped" || d.status === "error";
17
+ const icon = stopped ? theme.fg("error", "") : theme.fg("success", "");
18
+ const statusText = d.status === "error"
19
+ ? `Error: ${d.error ?? "unknown"}`
20
+ : d.terminalReason === "turn_limit_graceful"
21
+ ? "completed (turn limit)"
22
+ : d.status === "stopped" && d.terminalReason
23
+ ? `stopped (${d.terminalReason})`
24
+ : d.status;
39
25
  let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
40
-
41
- // Line 2: stats
42
26
  const parts: string[] = [formatModelThinking(d.modelLabel, d.thinkingLevel)];
43
27
  if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
44
28
  if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
45
29
  if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
46
30
  if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
47
- if (parts.length) {
48
- line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
49
- }
50
-
51
- // Line 3: result preview (collapsed) or full (expanded)
31
+ line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
52
32
  if (expanded) {
53
- const lines = d.resultPreview.split("\n").slice(0, 30);
54
- for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
33
+ for (const item of d.resultPreview.split("\n").slice(0, 30)) line += "\n" + theme.fg("dim", ` ${item}`);
55
34
  } else {
56
- const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
57
- line += "\n " + theme.fg("dim", `⎿ ${preview}`);
35
+ line += "\n " + theme.fg("dim", `⎿ ${d.resultPreview.split("\n")[0]?.slice(0, 80) ?? ""}`);
58
36
  }
59
-
60
- // Line 4: output file link (if present)
61
- if (d.outputFile) {
62
- line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
63
- }
64
-
37
+ if (d.outputFile) line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
65
38
  return new Text(line, 0, 0);
66
39
  };
67
40
  }
@@ -3,106 +3,55 @@ import type { SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
3
3
  import { buildEventData, type NotificationSystem } from "#src/observation/notification";
4
4
  import type { CompactionInfo, Subagent } from "#src/types";
5
5
 
6
- /** Emit callback — a subset of `pi.events.emit`. */
7
6
  export type EventEmit = (channel: string, data: unknown) => void;
8
-
9
- /** Append callback — a subset of `pi.appendEntry`. */
10
7
  export type AppendEntry = (customType: string, data: unknown) => void;
11
8
 
12
9
  export interface SubagentEventsObserverDeps {
13
- emit: EventEmit;
14
- appendEntry: AppendEntry;
15
- notifications: NotificationSystem;
10
+ emit: EventEmit;
11
+ appendEntry: AppendEntry;
12
+ notifications: NotificationSystem;
16
13
  }
17
14
 
18
- /**
19
- * Receives agent lifecycle notifications from SubagentManager and dispatches
20
- * them to three concerns: pi.events lifecycle events, session-entry persistence,
21
- * and completion notifications.
22
- *
23
- * Constructed with narrow deps (emit, appendEntry, NotificationSystem) so all
24
- * three concerns are unit-testable without booting the extension.
25
- */
26
15
  export class SubagentEventsObserver implements SubagentManagerObserver {
27
- private readonly emit: EventEmit;
28
- private readonly appendEntry: AppendEntry;
29
- private readonly notifications: NotificationSystem;
30
-
31
- constructor(deps: SubagentEventsObserverDeps) {
32
- this.emit = deps.emit;
33
- this.appendEntry = deps.appendEntry;
34
- this.notifications = deps.notifications;
35
- }
36
-
37
- onSubagentStarted(record: Subagent): void {
38
- // Emit started event when agent transitions to running (including from queue).
39
- runSafely("subagent event emission started", () => this.emit("subagents:started", {
40
- id: record.id,
41
- type: record.type,
42
- description: record.description,
43
- }));
44
- }
45
-
46
- onSubagentCompleted(record: Subagent): void {
47
- // Emit lifecycle event based on terminal status.
48
- const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
49
- const eventData = buildEventData(record);
50
- if (isError) {
51
- runSafely("subagent event emission failed", () => this.emit("subagents:failed", eventData));
52
- } else {
53
- runSafely("subagent event emission completed", () => this.emit("subagents:completed", eventData));
54
- }
55
-
56
- // Persist final record for cross-extension history reconstruction.
57
- runSafely("subagent record append", () => this.appendEntry("subagents:record", {
58
- id: record.id,
59
- type: record.type,
60
- description: record.description,
61
- status: record.status,
62
- result: record.result,
63
- error: record.error,
64
- startedAt: record.startedAt,
65
- completedAt: record.completedAt,
66
- }));
67
-
68
- // The manager decides whether to nudge (it owns the consumed-result state).
69
- runSafely("subagent completion notification", () => this.notifications.sendCompletion(record));
70
- }
71
-
72
- onSubagentResumed(record: Subagent): void {
73
- const eventData = buildEventData(record);
74
- runSafely("subagent event emission resumed", () => this.emit("subagents:resumed", eventData));
75
- runSafely("subagent record append resumed", () => this.appendEntry("subagents:record", {
76
- id: record.id,
77
- type: record.type,
78
- description: record.description,
79
- status: record.status,
80
- result: record.result,
81
- error: record.error,
82
- startedAt: record.startedAt,
83
- completedAt: record.completedAt,
84
- }));
85
- }
86
-
87
- onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
88
- // Emit compacted event when agent's session compacts (preserves count on record).
89
- runSafely("subagent event emission compacted", () => this.emit("subagents:compacted", {
90
- id: record.id,
91
- type: record.type,
92
- description: record.description,
93
- reason: info.reason,
94
- tokensBefore: info.tokensBefore,
95
- compactionCount: record.compactionCount,
96
- }));
97
- }
98
-
99
- onSubagentCreated(record: Subagent): void {
100
- // Emit created event for background agents (before limiter admission).
101
- runSafely("subagent event emission created", () => this.emit("subagents:created", {
102
- id: record.id,
103
- type: record.type,
104
- description: record.description,
105
- isBackground: true,
106
- }));
107
- }
16
+ constructor(private readonly deps: SubagentEventsObserverDeps) {}
17
+
18
+ onSubagentStarted(record: Subagent): void {
19
+ runSafely("subagent event emission started", () => this.deps.emit("subagents:started", {
20
+ id: record.id, runId: record.runId, mode: record.mode, type: record.type, description: record.description,
21
+ }));
22
+ }
23
+
24
+ onSubagentCompleted(record: Subagent): void {
25
+ const eventData = buildEventData(record);
26
+ const channel = record.status === "error" || record.status === "stopped" ? "subagents:failed" : "subagents:completed";
27
+ runSafely("subagent event emission terminal", () => this.deps.emit(channel, eventData));
28
+ runSafely("subagent record append", () => this.deps.appendEntry("subagents:record", eventData));
29
+ runSafely("subagent completion notification", () => this.deps.notifications.sendCompletion(record));
30
+ }
31
+
32
+ onSubagentResumedStarted(record: Subagent): void {
33
+ runSafely("subagent event emission resumed-started", () => this.deps.emit("subagents:started", {
34
+ id: record.id, runId: record.runId, mode: record.mode, type: record.type, description: record.description, resumed: true,
35
+ }));
36
+ }
37
+
38
+ onSubagentResumed(record: Subagent): void {
39
+ const eventData = buildEventData(record);
40
+ runSafely("subagent event emission resumed", () => this.deps.emit("subagents:resumed", eventData));
41
+ runSafely("subagent record append resumed", () => this.deps.appendEntry("subagents:record", eventData));
42
+ runSafely("subagent resumed notification", () => this.deps.notifications.sendCompletion(record));
43
+ }
44
+
45
+ onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
46
+ runSafely("subagent event emission compacted", () => this.deps.emit("subagents:compacted", {
47
+ id: record.id, runId: record.runId, mode: record.mode, type: record.type, description: record.description,
48
+ reason: info.reason, tokensBefore: info.tokensBefore, compactionCount: record.compactionCount,
49
+ }));
50
+ }
51
+
52
+ onSubagentCreated(record: Subagent): void {
53
+ runSafely("subagent event emission created", () => this.deps.emit("subagents:created", {
54
+ id: record.id, runId: record.runId, mode: record.mode, type: record.type, description: record.description,
55
+ }));
56
+ }
108
57
  }