@tintinweb/pi-subagents 0.4.9 → 0.4.11

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 (44) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +11 -11
  4. package/biome.json +26 -0
  5. package/dist/agent-manager.d.ts +18 -4
  6. package/dist/agent-manager.js +111 -9
  7. package/dist/agent-runner.d.ts +10 -6
  8. package/dist/agent-runner.js +80 -26
  9. package/dist/agent-types.d.ts +10 -0
  10. package/dist/agent-types.js +23 -1
  11. package/dist/cross-extension-rpc.d.ts +30 -0
  12. package/dist/cross-extension-rpc.js +33 -0
  13. package/dist/custom-agents.js +36 -8
  14. package/dist/index.js +335 -66
  15. package/dist/memory.d.ts +49 -0
  16. package/dist/memory.js +151 -0
  17. package/dist/output-file.d.ts +17 -0
  18. package/dist/output-file.js +66 -0
  19. package/dist/prompts.d.ts +12 -1
  20. package/dist/prompts.js +15 -3
  21. package/dist/skill-loader.d.ts +19 -0
  22. package/dist/skill-loader.js +67 -0
  23. package/dist/types.d.ts +45 -1
  24. package/dist/ui/agent-widget.d.ts +21 -0
  25. package/dist/ui/agent-widget.js +205 -127
  26. package/dist/ui/conversation-viewer.d.ts +2 -2
  27. package/dist/ui/conversation-viewer.js +2 -2
  28. package/dist/ui/conversation-viewer.test.d.ts +1 -0
  29. package/dist/ui/conversation-viewer.test.js +254 -0
  30. package/dist/worktree.d.ts +36 -0
  31. package/dist/worktree.js +139 -0
  32. package/package.json +7 -2
  33. package/src/agent-manager.ts +7 -5
  34. package/src/agent-runner.ts +24 -19
  35. package/src/agent-types.ts +5 -5
  36. package/src/custom-agents.ts +4 -4
  37. package/src/index.ts +54 -33
  38. package/src/memory.ts +2 -2
  39. package/src/output-file.ts +1 -1
  40. package/src/skill-loader.ts +1 -1
  41. package/src/types.ts +3 -1
  42. package/src/ui/agent-widget.ts +18 -2
  43. package/src/ui/conversation-viewer.ts +4 -4
  44. package/src/worktree.ts +2 -2
@@ -32,6 +32,10 @@ export function formatTokens(count) {
32
32
  return `${(count / 1_000).toFixed(1)}k token`;
33
33
  return `${count} token`;
34
34
  }
35
+ /** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
36
+ export function formatTurns(turnCount, maxTurns) {
37
+ return maxTurns != null ? `⟳${turnCount}≤${maxTurns}` : `⟳${turnCount}`;
38
+ }
35
39
  /** Format milliseconds as human-readable duration. */
36
40
  export function formatMs(ms) {
37
41
  return `${(ms / 1000).toFixed(1)}s`;
@@ -94,13 +98,26 @@ export class AgentWidget {
94
98
  finishedTurnAge = new Map();
95
99
  /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
96
100
  static ERROR_LINGER_TURNS = 2;
101
+ /** Whether the widget callback is currently registered with the TUI. */
102
+ widgetRegistered = false;
103
+ /** Cached TUI reference from widget factory callback, used for requestRender(). */
104
+ tui;
105
+ /** Last status bar text, used to avoid redundant setStatus calls. */
106
+ lastStatusText;
97
107
  constructor(manager, agentActivity) {
98
108
  this.manager = manager;
99
109
  this.agentActivity = agentActivity;
100
110
  }
101
111
  /** Set the UI context (grabbed from first tool execution). */
102
112
  setUICtx(ctx) {
103
- this.uiCtx = ctx;
113
+ if (ctx !== this.uiCtx) {
114
+ // UICtx changed — the widget registered on the old context is gone.
115
+ // Force re-registration on next update().
116
+ this.uiCtx = ctx;
117
+ this.widgetRegistered = false;
118
+ this.tui = undefined;
119
+ this.lastStatusText = undefined;
120
+ }
104
121
  }
105
122
  /**
106
123
  * Called on each new turn (tool_execution_start).
@@ -162,16 +179,20 @@ export class AgentWidget {
162
179
  statusText = theme.fg("warning", " aborted");
163
180
  }
164
181
  const parts = [];
182
+ const activity = this.agentActivity.get(a.id);
183
+ if (activity)
184
+ parts.push(formatTurns(activity.turnCount, activity.maxTurns));
165
185
  if (a.toolUses > 0)
166
186
  parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
167
187
  parts.push(duration);
168
188
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
169
189
  return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
170
190
  }
171
- /** Force an immediate widget update. */
172
- update() {
173
- if (!this.uiCtx)
174
- return;
191
+ /**
192
+ * Render the widget content. Called from the registered widget's render() callback,
193
+ * reading live state each time instead of capturing it in a closure.
194
+ */
195
+ renderWidget(tui, theme) {
175
196
  const allAgents = this.manager.listAgents();
176
197
  const running = allAgents.filter(a => a.status === "running");
177
198
  const queued = allAgents.filter(a => a.status === "queued");
@@ -179,10 +200,153 @@ export class AgentWidget {
179
200
  && this.shouldShowFinished(a.id, a.status));
180
201
  const hasActive = running.length > 0 || queued.length > 0;
181
202
  const hasFinished = finished.length > 0;
203
+ // Nothing to show — return empty (widget will be unregistered by update())
204
+ if (!hasActive && !hasFinished)
205
+ return [];
206
+ const w = tui.terminal.columns;
207
+ const truncate = (line) => truncateToWidth(line, w);
208
+ const headingColor = hasActive ? "accent" : "dim";
209
+ const headingIcon = hasActive ? "●" : "○";
210
+ const frame = SPINNER[this.widgetFrame % SPINNER.length];
211
+ // Build sections separately for overflow-aware assembly.
212
+ // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
213
+ const finishedLines = [];
214
+ for (const a of finished) {
215
+ finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
216
+ }
217
+ const runningLines = []; // each entry is [header, activity]
218
+ for (const a of running) {
219
+ const name = getDisplayName(a.type);
220
+ const modeLabel = getPromptModeLabel(a.type);
221
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
222
+ const elapsed = formatMs(Date.now() - a.startedAt);
223
+ const bg = this.agentActivity.get(a.id);
224
+ const toolUses = bg?.toolUses ?? a.toolUses;
225
+ let tokenText = "";
226
+ if (bg?.session) {
227
+ try {
228
+ tokenText = formatTokens(bg.session.getSessionStats().tokens.total);
229
+ }
230
+ catch { /* */ }
231
+ }
232
+ const parts = [];
233
+ if (bg)
234
+ parts.push(formatTurns(bg.turnCount, bg.maxTurns));
235
+ if (toolUses > 0)
236
+ parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
237
+ if (tokenText)
238
+ parts.push(tokenText);
239
+ parts.push(elapsed);
240
+ const statsText = parts.join(" · ");
241
+ const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
242
+ runningLines.push([
243
+ truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
244
+ truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
245
+ ]);
246
+ }
247
+ const queuedLine = queued.length > 0
248
+ ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
249
+ : undefined;
250
+ // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
251
+ const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
252
+ const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
253
+ const lines = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
254
+ if (totalBody <= maxBody) {
255
+ // Everything fits — add all lines and fix up connectors for the last item.
256
+ lines.push(...finishedLines);
257
+ for (const pair of runningLines)
258
+ lines.push(...pair);
259
+ if (queuedLine)
260
+ lines.push(queuedLine);
261
+ // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
262
+ if (lines.length > 1) {
263
+ const last = lines.length - 1;
264
+ lines[last] = lines[last].replace("├─", "└─");
265
+ // If last item is a running agent activity line, fix indent of that line
266
+ // and fix the header line above it.
267
+ if (runningLines.length > 0 && !queuedLine) {
268
+ // The last two lines are the last running agent's header + activity.
269
+ if (last >= 2) {
270
+ lines[last - 1] = lines[last - 1].replace("├─", "└─");
271
+ lines[last] = lines[last].replace("│ ", " ");
272
+ }
273
+ }
274
+ }
275
+ }
276
+ else {
277
+ // Overflow — prioritize: running > queued > finished.
278
+ // Reserve 1 line for overflow indicator.
279
+ let budget = maxBody - 1;
280
+ let hiddenRunning = 0;
281
+ let hiddenFinished = 0;
282
+ // 1. Running agents (2 lines each)
283
+ for (const pair of runningLines) {
284
+ if (budget >= 2) {
285
+ lines.push(...pair);
286
+ budget -= 2;
287
+ }
288
+ else {
289
+ hiddenRunning++;
290
+ }
291
+ }
292
+ // 2. Queued line
293
+ if (queuedLine && budget >= 1) {
294
+ lines.push(queuedLine);
295
+ budget--;
296
+ }
297
+ // 3. Finished agents
298
+ for (const fl of finishedLines) {
299
+ if (budget >= 1) {
300
+ lines.push(fl);
301
+ budget--;
302
+ }
303
+ else {
304
+ hiddenFinished++;
305
+ }
306
+ }
307
+ // Overflow summary
308
+ const overflowParts = [];
309
+ if (hiddenRunning > 0)
310
+ overflowParts.push(`${hiddenRunning} running`);
311
+ if (hiddenFinished > 0)
312
+ overflowParts.push(`${hiddenFinished} finished`);
313
+ const overflowText = overflowParts.join(", ");
314
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`));
315
+ }
316
+ return lines;
317
+ }
318
+ /** Force an immediate widget update. */
319
+ update() {
320
+ if (!this.uiCtx)
321
+ return;
322
+ const allAgents = this.manager.listAgents();
323
+ // Lightweight existence checks — full categorization happens in renderWidget()
324
+ let runningCount = 0;
325
+ let queuedCount = 0;
326
+ let hasFinished = false;
327
+ for (const a of allAgents) {
328
+ if (a.status === "running") {
329
+ runningCount++;
330
+ }
331
+ else if (a.status === "queued") {
332
+ queuedCount++;
333
+ }
334
+ else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) {
335
+ hasFinished = true;
336
+ }
337
+ }
338
+ const hasActive = runningCount > 0 || queuedCount > 0;
182
339
  // Nothing to show — clear widget
183
340
  if (!hasActive && !hasFinished) {
184
- this.uiCtx.setWidget("agents", undefined);
185
- this.uiCtx.setStatus("subagents", undefined);
341
+ if (this.widgetRegistered) {
342
+ this.uiCtx.setWidget("agents", undefined);
343
+ this.widgetRegistered = false;
344
+ this.tui = undefined;
345
+ }
346
+ if (this.lastStatusText !== undefined) {
347
+ this.uiCtx.setStatus("subagents", undefined);
348
+ this.lastStatusText = undefined;
349
+ }
186
350
  if (this.widgetInterval) {
187
351
  clearInterval(this.widgetInterval);
188
352
  this.widgetInterval = undefined;
@@ -194,131 +358,42 @@ export class AgentWidget {
194
358
  }
195
359
  return;
196
360
  }
197
- // Status bar
361
+ // Status bar — only call setStatus when the text actually changes
362
+ let newStatusText;
198
363
  if (hasActive) {
199
364
  const statusParts = [];
200
- if (running.length > 0)
201
- statusParts.push(`${running.length} running`);
202
- if (queued.length > 0)
203
- statusParts.push(`${queued.length} queued`);
204
- const total = running.length + queued.length;
205
- this.uiCtx.setStatus("subagents", `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`);
365
+ if (runningCount > 0)
366
+ statusParts.push(`${runningCount} running`);
367
+ if (queuedCount > 0)
368
+ statusParts.push(`${queuedCount} queued`);
369
+ const total = runningCount + queuedCount;
370
+ newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
206
371
  }
207
- else {
208
- this.uiCtx.setStatus("subagents", undefined);
372
+ if (newStatusText !== this.lastStatusText) {
373
+ this.uiCtx.setStatus("subagents", newStatusText);
374
+ this.lastStatusText = newStatusText;
209
375
  }
210
376
  this.widgetFrame++;
211
- const frame = SPINNER[this.widgetFrame % SPINNER.length];
212
- this.uiCtx.setWidget("agents", (tui, theme) => {
213
- const w = tui.terminal.columns;
214
- const truncate = (line) => truncateToWidth(line, w);
215
- const headingColor = hasActive ? "accent" : "dim";
216
- const headingIcon = hasActive ? "●" : "○";
217
- // Build sections separately for overflow-aware assembly.
218
- // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
219
- const finishedLines = [];
220
- for (const a of finished) {
221
- finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
222
- }
223
- const runningLines = []; // each entry is [header, activity]
224
- for (const a of running) {
225
- const name = getDisplayName(a.type);
226
- const modeLabel = getPromptModeLabel(a.type);
227
- const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
228
- const elapsed = formatMs(Date.now() - a.startedAt);
229
- const bg = this.agentActivity.get(a.id);
230
- const toolUses = bg?.toolUses ?? a.toolUses;
231
- let tokenText = "";
232
- if (bg?.session) {
233
- try {
234
- tokenText = formatTokens(bg.session.getSessionStats().tokens.total);
235
- }
236
- catch { /* */ }
237
- }
238
- const parts = [];
239
- if (toolUses > 0)
240
- parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
241
- if (tokenText)
242
- parts.push(tokenText);
243
- parts.push(elapsed);
244
- const statsText = parts.join(" · ");
245
- const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
246
- runningLines.push([
247
- truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
248
- truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
249
- ]);
250
- }
251
- const queuedLine = queued.length > 0
252
- ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
253
- : undefined;
254
- // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
255
- const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
256
- const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
257
- const lines = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
258
- if (totalBody <= maxBody) {
259
- // Everything fits — add all lines and fix up connectors for the last item.
260
- lines.push(...finishedLines);
261
- for (const pair of runningLines)
262
- lines.push(...pair);
263
- if (queuedLine)
264
- lines.push(queuedLine);
265
- // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
266
- if (lines.length > 1) {
267
- const last = lines.length - 1;
268
- lines[last] = lines[last].replace("├─", "└─");
269
- // If last item is a running agent activity line, fix indent of that line
270
- // and fix the header line above it.
271
- if (runningLines.length > 0 && !queuedLine) {
272
- // The last two lines are the last running agent's header + activity.
273
- if (last >= 2) {
274
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
275
- lines[last] = lines[last].replace("│ ", " ");
276
- }
277
- }
278
- }
279
- }
280
- else {
281
- // Overflow — prioritize: running > queued > finished.
282
- // Reserve 1 line for overflow indicator.
283
- let budget = maxBody - 1;
284
- let hiddenRunning = 0;
285
- let hiddenFinished = 0;
286
- // 1. Running agents (2 lines each)
287
- for (const pair of runningLines) {
288
- if (budget >= 2) {
289
- lines.push(...pair);
290
- budget -= 2;
291
- }
292
- else {
293
- hiddenRunning++;
294
- }
295
- }
296
- // 2. Queued line
297
- if (queuedLine && budget >= 1) {
298
- lines.push(queuedLine);
299
- budget--;
300
- }
301
- // 3. Finished agents
302
- for (const fl of finishedLines) {
303
- if (budget >= 1) {
304
- lines.push(fl);
305
- budget--;
306
- }
307
- else {
308
- hiddenFinished++;
309
- }
310
- }
311
- // Overflow summary
312
- const overflowParts = [];
313
- if (hiddenRunning > 0)
314
- overflowParts.push(`${hiddenRunning} running`);
315
- if (hiddenFinished > 0)
316
- overflowParts.push(`${hiddenFinished} finished`);
317
- const overflowText = overflowParts.join(", ");
318
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`));
319
- }
320
- return { render: () => lines, invalidate: () => { } };
321
- }, { placement: "aboveEditor" });
377
+ // Register widget callback once; subsequent updates use requestRender()
378
+ // which re-invokes render() without replacing the component (avoids layout thrashing).
379
+ if (!this.widgetRegistered) {
380
+ this.uiCtx.setWidget("agents", (tui, theme) => {
381
+ this.tui = tui;
382
+ return {
383
+ render: () => this.renderWidget(tui, theme),
384
+ invalidate: () => {
385
+ // Theme changed — force re-registration so factory captures fresh theme.
386
+ this.widgetRegistered = false;
387
+ this.tui = undefined;
388
+ },
389
+ };
390
+ }, { placement: "aboveEditor" });
391
+ this.widgetRegistered = true;
392
+ }
393
+ else {
394
+ // Widget already registered — just request a re-render of existing components.
395
+ this.tui?.requestRender();
396
+ }
322
397
  }
323
398
  dispose() {
324
399
  if (this.widgetInterval) {
@@ -329,5 +404,8 @@ export class AgentWidget {
329
404
  this.uiCtx.setWidget("agents", undefined);
330
405
  this.uiCtx.setStatus("subagents", undefined);
331
406
  }
407
+ this.widgetRegistered = false;
408
+ this.tui = undefined;
409
+ this.lastStatusText = undefined;
332
410
  }
333
411
  }
@@ -4,11 +4,11 @@
4
4
  * Displays a scrollable, live-updating view of an agent's conversation.
5
5
  * Subscribes to session events for real-time streaming updates.
6
6
  */
7
- import { type Component, type TUI } from "@mariozechner/pi-tui";
8
7
  import type { AgentSession } from "@mariozechner/pi-coding-agent";
8
+ import { type Component, type TUI } from "@mariozechner/pi-tui";
9
+ import type { AgentRecord } from "../types.js";
9
10
  import type { Theme } from "./agent-widget.js";
10
11
  import { type AgentActivity } from "./agent-widget.js";
11
- import type { AgentRecord } from "../types.js";
12
12
  export declare class ConversationViewer implements Component {
13
13
  private tui;
14
14
  private session;
@@ -5,8 +5,8 @@
5
5
  * Subscribes to session events for real-time streaming updates.
6
6
  */
7
7
  import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
8
- import { formatTokens, formatDuration, getDisplayName, getPromptModeLabel, describeActivity } from "./agent-widget.js";
9
8
  import { extractText } from "../context.js";
9
+ import { describeActivity, formatDuration, formatTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
10
10
  /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
11
11
  const CHROME_LINES = 6;
12
12
  const MIN_VIEWPORT = 3;
@@ -231,6 +231,6 @@ export class ConversationViewer {
231
231
  lines.push("");
232
232
  lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
233
233
  }
234
- return lines;
234
+ return lines.map(l => truncateToWidth(l, width));
235
235
  }
236
236
  }
@@ -0,0 +1 @@
1
+ export {};