pi-subagents-lite 1.0.2 → 1.2.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.
package/README.md CHANGED
@@ -25,15 +25,21 @@ Tool names like `Agent` and `StopAgent`, and parameter names like `prompt`, `des
25
25
  ## Features
26
26
 
27
27
  - **Two tools** — `Agent` (spawn) and `StopAgent` (stop)
28
+ - **Manual spawn** — spawn agents from the `/agents` menu without asking the LLM. Full control over model, thinking, turns, and background mode.
28
29
  - **Foreground & background** — block or fire-and-forget with auto-delivered results
29
30
  - **Custom agent types** — define via `.md` files with YAML frontmatter (tools, model, thinking, turn limits)
30
31
  - **Smart model resolution** — 6-level precedence: session → config → frontmatter → parent. Set once, forget
31
32
  - **Concurrency control** — per-model and per-provider slot limits with automatic queuing
32
33
  - **Cost tracking** — input/output/cache tokens and dollar cost per agent
34
+ - **Cost display** — toggle agent cost in stats and status bar (OFF by default)
33
35
  - **Live widget** — persistent status bar above the editor showing running/completed agents
36
+ - **Widget settings** — force compact mode, max lines, opt-in ctrl+o sync
34
37
  - **Result viewer** — fullscreen markdown viewer with stats
35
38
  - **Steer** — inject mid-execution guidance into running agents
36
39
  - **Output logs** — human-readable, `tail -f` friendly
40
+ - **Grace turns** — configurable grace turns after `max_turns` before hard abort
41
+ - **Reload safety** — warns when active agents are killed by session reload
42
+ - **Worktree support** — `worktree_path` parameter runs agents in a git worktree with validated path, worktree agent discovery, and UI label
37
43
 
38
44
  ## Install
39
45
 
@@ -95,6 +101,7 @@ Stop a running agent at any time via /agents command
95
101
  | `description` | ✅ | Brief description for the LLM caller |
96
102
  | `agent` | | Type name — `general-purpose`, `Explore`, or any custom type you define (see [Custom Agent Types](#custom-agent-types)). The available values are **auto-populated** from `.md` files in your agent directories — drop a file, it appears in the enum. Set `hidden: true` in frontmatter to hide a type from this list (still callable by name). |
97
103
  | `run_in_background` | | Fire-and-forget; result delivered automatically when done |
104
+ | `worktree_path` | | Absolute path to a git worktree. Agent runs in that worktree's context, discovers agents from its `.pi/agents/` directory, and displays a worktree label in the widget and menus. Path is validated against the parent repo's git common dir. |
98
105
 
99
106
  > `model`, `max_turns`, and `thinking` are **not visible to the LLM** through tool introspection — the extension injects them at call time from agent config and frontmatter. `model` is resolved via the [Model Resolution](#model-resolution) chain; `max_turns`/`thinking` come from the agent's config. See [Custom Agent Types](#custom-agent-types) to set them.
100
107
 
@@ -280,9 +287,12 @@ The LLM never passes `model` — it's injected at call time via the `tool_call`
280
287
 
281
288
  Management menu with four sections:
282
289
 
283
- - **Model settings** — global default, per-type overrides, force background mode
284
- - **Concurrency** — default limit, per-provider and per-model slots
285
- - **Running agents** — list, steer, stop, view snapshot, view result
290
+ - **Running agents** — list with status and description; per-agent actions: view snapshot, view result, view error, steer, stop; bulk stop all running
291
+ - **Spawn agent** — manually spawn an agent without asking the LLM. Pick a type, enter a prompt, configure options (model, thinking, max turns, grace turns, background), and spawn. Options are pre-filled from agent config and current settings. Spawn immediately or customize first.
292
+ - **Settings** — model, concurrency, and widget settings grouped together
293
+ - **Model settings** — global default, per-type overrides, force background mode, cost display toggle, grace turns
294
+ - **Concurrency** — default limit, per-provider and per-model slots, reset to defaults
295
+ - **Widget settings** — force compact mode, max lines (full/compact), ctrl+o shortcut
286
296
  - **Debug** — agent types, agent briefing (sends capabilities to the LLM)
287
297
 
288
298
  ## Interface
@@ -294,15 +304,28 @@ Persistent bar above the editor showing running and completed agents. Updates li
294
304
  - Running agents show a spinner, current tool activity, turn count, token usage (with optional context-fill percent), and elapsed time
295
305
  - Completed agents show a check mark with final stats
296
306
  - Click `tail -f` path to follow output logs in real time
307
+ - Two display modes: **full** (header + `tail -f` path + activity) and **compact** (single line, description truncated to 30 chars, activity inline)
297
308
 
298
- Format (tree structure with branch connectors):
309
+ **Full mode** (tree structure with branch connectors):
299
310
  ```
300
311
  ├─ ⠙ Explore description 3🛠 ·5≤30⟳ ·12.0k(45%)·1h 2m 3s
312
+ │ │ tail -f /tmp/pi-agent-outputs/...
301
313
  │ └ thinking…
302
314
  ```
303
315
 
316
+ **Compact mode** (single line, description truncated):
317
+ ```
318
+ ├─ ⠙ Explore description trunc… 3🛠 ·5≤30⟳ ·12.0k(45%)·1h 2m 3s thinking…
319
+ ```
320
+
304
321
  Turn format uses `≤` and `⟳` glyphs (`5≤30⟳` = 5 of 30 turns). Token count uses compact notation (`12.0k`) with optional context-fill percent in parentheses. No "tokens" label — the glyphs are self-explanatory.
305
322
 
323
+ **Compact mode is active when:**
324
+ - **Force compact mode** is ON (in `/agents > Widget settings`), OR
325
+ - **Ctrl+o shortcut** is ON and the user has pressed ctrl+o to collapse tool expansion
326
+
327
+ Force compact always wins. When force compact is ON, ctrl+o state changes are ignored.
328
+
306
329
  ### Result Viewer
307
330
 
308
331
  Fullscreen markdown viewer for agent results. Opens automatically when viewing a completed agent's result from the `/agents` menu.
@@ -311,6 +334,8 @@ Key bindings: `↑↓` navigate · `PgUp/PgDn` · `g`/`G` top/bottom · `f` togg
311
334
 
312
335
  Stats line: ` ↑12.0k · ↓8.0k · W3.0k · $0.024 · 15 turns · 47s`
313
336
 
337
+ When **Cost display** is enabled (ON), agent stats show dollar cost: `✓ Builder·2🛠 ·5⟳ ·12.3k·$0.008·10s`. The status bar shows total agent cost: `agents: $0.008` or `2 agents: $0.008`.
338
+
314
339
  ## Configuration
315
340
 
316
341
  `~/.pi/agent/subagents-lite.json` — managed via `/agents` menu, or edit directly:
@@ -320,6 +345,12 @@ Stats line: ` ↑12.0k · ↓8.0k · W3.0k · $0.024 · 15 turns · 47s`
320
345
  "agent": {
321
346
  "default": null,
322
347
  "forceBackground": false,
348
+ "showCost": true,
349
+ "graceTurns": 6,
350
+ "widgetMaxLines": 12,
351
+ "widgetMaxLinesCompact": 6,
352
+ "widgetCompact": false,
353
+ "widgetShortcut": false,
323
354
  "Explore": "anthropic/claude-haiku-4-5-20251001"
324
355
  },
325
356
  "concurrency": {
@@ -332,7 +363,18 @@ Stats line: ` ↑12.0k · ↓8.0k · W3.0k · $0.024 · 15 turns · 47s`
332
363
  }
333
364
  ```
334
365
 
335
- > **Note:** `agent.default` (global fallback), `agent.forceBackground` (flag), and per-type overrides like `"Explore"` are peers in the same object. Agent type names become dynamic keys alongside the special fields.
366
+ > **Note:** `agent.default` (global fallback), `agent.forceBackground` (flag), `agent.showCost` (toggle cost display), `agent.graceTurns` (grace turns after `max_turns` before hard abort), widget settings (`widgetMaxLines`, `widgetMaxLinesCompact`, `widgetCompact`, `widgetShortcut`), and per-type overrides like `"Explore"` are peers in the same object. Agent type names become dynamic keys alongside the special fields.
367
+
368
+ ### Widget settings
369
+
370
+ | Field | Default | Description |
371
+ |---|---|---|
372
+ | `widgetMaxLines` | `12` | Maximum body lines in full mode (excluding the heading). |
373
+ | `widgetMaxLinesCompact` | half of `widgetMaxLines` | Maximum body lines in compact mode. |
374
+ | `widgetCompact` | `false` | Force compact mode regardless of ctrl+o state. |
375
+ | `widgetShortcut` | `false` | Opt-in: when ON, ctrl+o (tool expansion toggle) syncs with widget compact mode. When OFF, compact mode is manual-only via `widgetCompact`. |
376
+
377
+ > **Reload safety:** if a session reload (e.g. `/reload` or extension reload) kills running agents, the UI notifies you with the count of lost agents. Output logs and completed results are preserved on disk.
336
378
 
337
379
  ## StopAgent Tool
338
380
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents-lite",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Lightweight sub-agents for pi — spawn specialized agents with isolated sessions, tools, and models.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -12,12 +12,13 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
12
12
  import {
13
13
  type AgentInvocation,
14
14
  type AgentRecord,
15
+ type AgentStatus,
15
16
  type CompactionInfo,
16
17
  SHORT_ID_LENGTH,
17
18
  type SubagentType,
18
19
  type ThinkingLevel,
19
20
  } from "./types.js";
20
- import { addUsage, getLifetimeTotal, type LifetimeUsage } from "./usage.js";
21
+ import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
21
22
  import { errorMessage } from "./utils.js";
22
23
 
23
24
  /** How often to check for expired agent records (milliseconds). */
@@ -47,16 +48,16 @@ function createOutputCleanup(
47
48
  const outputStats = { turnCount: 0, toolUseCount: 0, totalTokens: 0, cost: 0 };
48
49
  const cleanup = streamToOutputFile(session, path, outputStats);
49
50
  return () => {
50
- outputStats.turnCount = record.turnCount ?? 0;
51
- outputStats.toolUseCount = record.toolUses;
52
- outputStats.totalTokens = getLifetimeTotal(record.lifetimeUsage);
53
- outputStats.cost = record.lifetimeUsage.cost;
51
+ outputStats.turnCount = record.stats.turnCount ?? 0;
52
+ outputStats.toolUseCount = record.stats.toolUses;
53
+ outputStats.totalTokens = getLifetimeTotal(record.stats.lifetimeUsage);
54
+ outputStats.cost = record.stats.lifetimeUsage.cost;
54
55
  cleanup();
55
56
  };
56
57
  }
57
58
 
58
59
  /** Whether the agent status is terminal (no longer running or queued). */
59
- function isTerminalStatus(status: AgentRecord["status"]): boolean {
60
+ function isTerminalStatus(status: AgentStatus): boolean {
60
61
  return status !== "running" && status !== "queued";
61
62
  }
62
63
 
@@ -93,6 +94,10 @@ export interface SpawnOptions {
93
94
  maxTurns?: number;
94
95
  thinkingLevel?: ThinkingLevel;
95
96
  isBackground?: boolean;
97
+ /** Resolved worktree path — forwarded as cwd to runAgent. */
98
+ worktreePath?: string;
99
+ /** Short display label for the worktree (set on record display after spawn). */
100
+ worktreeLabel?: string;
96
101
  /**
97
102
  * Model key for concurrency pool lookup (e.g. "llamacpp/4b_small").
98
103
  * When set, the agent is counted against that model's concurrency limit.
@@ -125,6 +130,9 @@ export class AgentManager {
125
130
  private onComplete?: OnAgentComplete;
126
131
  private onStart?: OnAgentStart;
127
132
 
133
+ /** Session-level cumulative agent cost. Survives agent eviction. */
134
+ private totalAgentCost = 0;
135
+
128
136
  /** Per-model concurrency slots keyed by "provider/modelId". */
129
137
  private concurrencySlots = new Map<string, ConcurrencySlot>();
130
138
 
@@ -248,16 +256,26 @@ export class AgentManager {
248
256
 
249
257
  const record: AgentRecord = {
250
258
  id,
251
- type,
252
- description: options.description,
253
- status: queued ? "queued" : "running",
254
- toolUses: 0,
255
- startedAt: Date.now(),
256
- abortController,
257
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
258
- compactionCount: 0,
259
- invocation: options.invocation,
260
- maxTurns: options.maxTurns,
259
+ lifecycle: {
260
+ status: queued ? "queued" : "running",
261
+ startedAt: Date.now(),
262
+ },
263
+ display: {
264
+ type,
265
+ description: options.description,
266
+ invocation: options.invocation,
267
+ worktreePath: options.worktreePath,
268
+ worktreeLabel: options.worktreeLabel,
269
+ },
270
+ execution: {
271
+ abortController,
272
+ },
273
+ stats: {
274
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
275
+ toolUses: 0,
276
+ compactionCount: 0,
277
+ maxTurns: options.maxTurns,
278
+ },
261
279
  };
262
280
  this.agents.set(id, record);
263
281
 
@@ -286,12 +304,12 @@ export class AgentManager {
286
304
  ) {
287
305
  if (concurrencySlot) concurrencySlot.running++;
288
306
 
289
- record.status = "running";
290
- record.startedAt = Date.now();
307
+ record.lifecycle.status = "running";
308
+ record.lifecycle.startedAt = Date.now();
291
309
 
292
310
  // Create output file for this agent
293
- record.outputFile = createOutputFilePath(id);
294
- writeInitialEntry(record.outputFile, prompt);
311
+ record.display.outputFile = createOutputFilePath(id);
312
+ writeInitialEntry(record.display.outputFile, prompt);
295
313
 
296
314
  this.onStart?.(record);
297
315
 
@@ -306,30 +324,31 @@ export class AgentManager {
306
324
  model: options.model,
307
325
  maxTurns: options.maxTurns,
308
326
  thinkingLevel: options.thinkingLevel,
327
+ cwd: options.worktreePath,
309
328
  graceTurns: options.graceTurns,
310
- signal: record.abortController!.signal,
329
+ signal: record.execution.abortController!.signal,
311
330
  ...this.createRecordCallbacks(record, options),
312
331
  onTurnEnd: (turnCount) => {
313
- record.turnCount = turnCount;
332
+ record.stats.turnCount = turnCount;
314
333
  options.onTurnEnd?.(turnCount);
315
334
  },
316
335
  onTextDelta: options.onTextDelta,
317
336
  onSessionCreated: (session) => {
318
- record.session = session;
337
+ record.execution.session = session;
319
338
  // Flush any steers that arrived before the session was ready
320
- if (record.pendingSteers?.length) {
321
- for (const msg of record.pendingSteers) {
339
+ if (record.execution.pendingSteers?.length) {
340
+ for (const msg of record.execution.pendingSteers) {
322
341
  session.steer(msg).catch(() => {
323
342
  // Steer is advisory — a failure here (e.g. session already aborting)
324
343
  // is fine; the user can re-send if needed.
325
344
  });
326
345
  }
327
- record.pendingSteers = undefined;
346
+ record.execution.pendingSteers = undefined;
328
347
  }
329
348
  // Stream session events to the output file
330
- if (record.outputFile) {
331
- record.outputCleanup = createOutputCleanup(
332
- session, record.outputFile, record,
349
+ if (record.display.outputFile) {
350
+ record.execution.outputCleanup = createOutputCleanup(
351
+ session, record.display.outputFile, record,
333
352
  );
334
353
  }
335
354
  options.onSessionCreated?.(session);
@@ -337,28 +356,29 @@ export class AgentManager {
337
356
  })
338
357
  .then(({ responseText, session, aborted, steered }) => {
339
358
  // Don't overwrite status if externally stopped via abort()
340
- if (record.status !== "stopped") {
341
- record.status = aborted ? "aborted" : steered ? "steered" : "completed";
359
+ if (record.lifecycle.status !== "stopped") {
360
+ record.lifecycle.status = aborted ? "aborted" : steered ? "steered" : "completed";
342
361
  }
343
362
  record.result = responseText;
344
- record.session = session;
345
- record.completedAt ??= Date.now();
363
+ record.execution.session = session;
364
+ record.stats.contextPercent = getSessionContextPercent(session);
365
+ record.lifecycle.completedAt ??= Date.now();
346
366
  return responseText;
347
367
  })
348
368
  .catch((err) => {
349
369
  // Don't overwrite status if externally stopped via abort()
350
- if (record.status !== "stopped") {
351
- record.status = "error";
370
+ if (record.lifecycle.status !== "stopped") {
371
+ record.lifecycle.status = "error";
352
372
  }
353
373
  record.error = errorMessage(err);
354
- record.completedAt ??= Date.now();
374
+ record.lifecycle.completedAt ??= Date.now();
355
375
  return "";
356
376
  })
357
377
  .finally(() => {
358
378
  // Final flush of streaming output file
359
- if (record.outputCleanup) {
360
- try { record.outputCleanup(); } catch { /* ignore */ }
361
- record.outputCleanup = undefined;
379
+ if (record.execution.outputCleanup) {
380
+ try { record.execution.outputCleanup(); } catch { /* ignore */ }
381
+ record.execution.outputCleanup = undefined;
362
382
  }
363
383
 
364
384
  // Decrement per-model concurrency count
@@ -368,14 +388,20 @@ export class AgentManager {
368
388
  this.drainQueue();
369
389
  });
370
390
 
371
- record.promise = promise;
391
+ record.execution.promise = promise;
372
392
  }
373
393
 
374
394
  /** Notify completion callback, ignoring any errors. */
375
395
  private safeNotifyComplete(record: AgentRecord): void {
396
+ this.totalAgentCost += record.stats.lifetimeUsage.cost;
376
397
  try { this.onComplete?.(record); } catch { /* ignore */ }
377
398
  }
378
399
 
400
+ /** Get the session-level cumulative agent cost. Survives agent eviction. */
401
+ getTotalAgentCost(): number {
402
+ return this.totalAgentCost;
403
+ }
404
+
379
405
  /**
380
406
  * Build common record-tracking callbacks shared by startAgent.
381
407
  * Updates the record's toolUses, lifetimeUsage, and compactionCount.
@@ -391,15 +417,15 @@ export class AgentManager {
391
417
  } {
392
418
  return {
393
419
  onToolActivity: (activity) => {
394
- if (activity.type === "end") record.toolUses++;
420
+ if (activity.type === "end") record.stats.toolUses++;
395
421
  options?.onToolActivity?.(activity);
396
422
  },
397
423
  onAssistantUsage: (usage) => {
398
- addUsage(record.lifetimeUsage, usage);
424
+ addUsage(record.stats.lifetimeUsage, usage);
399
425
  options?.onAssistantUsage?.(usage);
400
426
  },
401
427
  onCompaction: (info) => {
402
- record.compactionCount++;
428
+ record.stats.compactionCount++;
403
429
  options?.onCompaction?.(info);
404
430
  },
405
431
  };
@@ -410,7 +436,7 @@ export class AgentManager {
410
436
  const started = new Set<string>();
411
437
  for (const entry of this.queue) {
412
438
  const record = this.agents.get(entry.id);
413
- if (!record || record.status !== "queued") continue;
439
+ if (!record || record.lifecycle.status !== "queued") continue;
414
440
 
415
441
  const slot = this.getSlot(entry.modelKey);
416
442
  if (slot.running >= slot.limit) continue;
@@ -420,9 +446,9 @@ export class AgentManager {
420
446
  started.add(entry.id);
421
447
  } catch (err) {
422
448
  // Late failure — surface on the record so the user can see it
423
- record.status = "error";
449
+ record.lifecycle.status = "error";
424
450
  record.error = errorMessage(err);
425
- record.completedAt = Date.now();
451
+ record.lifecycle.completedAt = Date.now();
426
452
  started.add(entry.id);
427
453
  this.safeNotifyComplete(record);
428
454
  }
@@ -439,17 +465,17 @@ export class AgentManager {
439
465
  const record = this.agents.get(id);
440
466
  if (!record) return false;
441
467
 
442
- if (record.status !== "running") return false;
468
+ if (record.lifecycle.status !== "running") return false;
443
469
 
444
- if (!record.session) {
470
+ if (!record.execution.session) {
445
471
  // Session not yet created — queue the steer
446
- if (!record.pendingSteers) record.pendingSteers = [];
447
- record.pendingSteers.push(message);
472
+ if (!record.execution.pendingSteers) record.execution.pendingSteers = [];
473
+ record.execution.pendingSteers.push(message);
448
474
  return true;
449
475
  }
450
476
 
451
477
  try {
452
- await record.session.steer(message);
478
+ await record.execution.session.steer(message);
453
479
  return true;
454
480
  } catch {
455
481
  // steer failures are surfaced to the caller via the boolean return value
@@ -463,7 +489,7 @@ export class AgentManager {
463
489
 
464
490
  listAgents(): AgentRecord[] {
465
491
  return [...this.agents.values()].sort(
466
- (a, b) => b.startedAt - a.startedAt,
492
+ (a, b) => b.lifecycle.startedAt - a.lifecycle.startedAt,
467
493
  );
468
494
  }
469
495
 
@@ -479,30 +505,30 @@ export class AgentManager {
479
505
  * Returns true if the agent was stopped, false if it wasn't running/queued.
480
506
  */
481
507
  private stopAgent(record: AgentRecord): boolean {
482
- if (record.status === "queued") {
508
+ if (record.lifecycle.status === "queued") {
483
509
  this.queue = this.queue.filter(q => q.id !== record.id);
484
- } else if (record.status !== "running") {
510
+ } else if (record.lifecycle.status !== "running") {
485
511
  return false;
486
512
  } else {
487
- record.abortController?.abort();
513
+ record.execution.abortController?.abort();
488
514
  }
489
- record.status = "stopped";
490
- record.completedAt = Date.now();
515
+ record.lifecycle.status = "stopped";
516
+ record.lifecycle.completedAt = Date.now();
491
517
  return true;
492
518
  }
493
519
 
494
520
  /** Dispose a record's session and remove it from the map. */
495
521
  private removeRecord(id: string, record: AgentRecord): void {
496
- record.session?.dispose();
497
- record.session = undefined;
522
+ record.execution.session?.dispose();
523
+ record.execution.session = undefined;
498
524
  this.agents.delete(id);
499
525
  }
500
526
 
501
527
  private cleanup() {
502
528
  const cutoff = Date.now() - CLEANUP_AGE_CUTOFF_MS;
503
529
  for (const [id, record] of this.agents) {
504
- if (!isTerminalStatus(record.status)) continue;
505
- if ((record.completedAt ?? 0) >= cutoff) continue;
530
+ if (!isTerminalStatus(record.lifecycle.status)) continue;
531
+ if ((record.lifecycle.completedAt ?? 0) >= cutoff) continue;
506
532
  this.removeRecord(id, record);
507
533
  }
508
534
  }
@@ -511,7 +537,7 @@ export class AgentManager {
511
537
  clearInterval(this.cleanupInterval);
512
538
  this.queue = [];
513
539
  for (const record of this.agents.values()) {
514
- record.session?.dispose();
540
+ record.execution.session?.dispose();
515
541
  }
516
542
  this.agents.clear();
517
543
  }