pi-subagents-lite 1.0.1 → 1.1.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
@@ -30,10 +30,14 @@ Tool names like `Agent` and `StopAgent`, and parameter names like `prompt`, `des
30
30
  - **Smart model resolution** — 6-level precedence: session → config → frontmatter → parent. Set once, forget
31
31
  - **Concurrency control** — per-model and per-provider slot limits with automatic queuing
32
32
  - **Cost tracking** — input/output/cache tokens and dollar cost per agent
33
+ - **Cost display** — toggle agent cost in stats and status bar (OFF by default)
33
34
  - **Live widget** — persistent status bar above the editor showing running/completed agents
35
+ - **Widget settings** — force compact mode, max lines, opt-in ctrl+o sync
34
36
  - **Result viewer** — fullscreen markdown viewer with stats
35
37
  - **Steer** — inject mid-execution guidance into running agents
36
38
  - **Output logs** — human-readable, `tail -f` friendly
39
+ - **Grace turns** — configurable grace turns after `max_turns` before hard abort
40
+ - **Reload safety** — warns when active agents are killed by session reload
37
41
 
38
42
  ## Install
39
43
 
@@ -278,11 +282,12 @@ The LLM never passes `model` — it's injected at call time via the `tool_call`
278
282
 
279
283
  ### `/agents`
280
284
 
281
- Management menu with four sections:
285
+ Management menu with five sections:
282
286
 
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
287
+ - **Model settings** — global default, per-type overrides, force background mode, cost display toggle, grace turns
288
+ - **Concurrency** — default limit, per-provider and per-model slots, reset to defaults
289
+ - **Running agents** — list with status and description; per-agent actions: view snapshot, view result, view error, steer, stop; bulk stop all running
290
+ - **Widget settings** — force compact mode, max lines (full/compact), ctrl+o shortcut
286
291
  - **Debug** — agent types, agent briefing (sends capabilities to the LLM)
287
292
 
288
293
  ## Interface
@@ -294,15 +299,28 @@ Persistent bar above the editor showing running and completed agents. Updates li
294
299
  - Running agents show a spinner, current tool activity, turn count, token usage (with optional context-fill percent), and elapsed time
295
300
  - Completed agents show a check mark with final stats
296
301
  - Click `tail -f` path to follow output logs in real time
302
+ - Two display modes: **full** (header + `tail -f` path + activity) and **compact** (single line, description truncated to 30 chars, activity inline)
297
303
 
298
- Format (tree structure with branch connectors):
304
+ **Full mode** (tree structure with branch connectors):
299
305
  ```
300
306
  ├─ ⠙ Explore description 3🛠 ·5≤30⟳ ·12.0k(45%)·1h 2m 3s
307
+ │ │ tail -f /tmp/pi-agent-outputs/...
301
308
  │ └ thinking…
302
309
  ```
303
310
 
311
+ **Compact mode** (single line, description truncated):
312
+ ```
313
+ ├─ ⠙ Explore description trunc… 3🛠 ·5≤30⟳ ·12.0k(45%)·1h 2m 3s thinking…
314
+ ```
315
+
304
316
  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
317
 
318
+ **Compact mode is active when:**
319
+ - **Force compact mode** is ON (in `/agents > Widget settings`), OR
320
+ - **Ctrl+o shortcut** is ON and the user has pressed ctrl+o to collapse tool expansion
321
+
322
+ Force compact always wins. When force compact is ON, ctrl+o state changes are ignored.
323
+
306
324
  ### Result Viewer
307
325
 
308
326
  Fullscreen markdown viewer for agent results. Opens automatically when viewing a completed agent's result from the `/agents` menu.
@@ -311,6 +329,8 @@ Key bindings: `↑↓` navigate · `PgUp/PgDn` · `g`/`G` top/bottom · `f` togg
311
329
 
312
330
  Stats line: ` ↑12.0k · ↓8.0k · W3.0k · $0.024 · 15 turns · 47s`
313
331
 
332
+ 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`.
333
+
314
334
  ## Configuration
315
335
 
316
336
  `~/.pi/agent/subagents-lite.json` — managed via `/agents` menu, or edit directly:
@@ -320,6 +340,12 @@ Stats line: ` ↑12.0k · ↓8.0k · W3.0k · $0.024 · 15 turns · 47s`
320
340
  "agent": {
321
341
  "default": null,
322
342
  "forceBackground": false,
343
+ "showCost": true,
344
+ "graceTurns": 6,
345
+ "widgetMaxLines": 12,
346
+ "widgetMaxLinesCompact": 6,
347
+ "widgetCompact": false,
348
+ "widgetShortcut": false,
323
349
  "Explore": "anthropic/claude-haiku-4-5-20251001"
324
350
  },
325
351
  "concurrency": {
@@ -332,7 +358,18 @@ Stats line: ` ↑12.0k · ↓8.0k · W3.0k · $0.024 · 15 turns · 47s`
332
358
  }
333
359
  ```
334
360
 
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.
361
+ > **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.
362
+
363
+ ### Widget settings
364
+
365
+ | Field | Default | Description |
366
+ |---|---|---|
367
+ | `widgetMaxLines` | `12` | Maximum body lines in full mode (excluding the heading). |
368
+ | `widgetMaxLinesCompact` | half of `widgetMaxLines` | Maximum body lines in compact mode. |
369
+ | `widgetCompact` | `false` | Force compact mode regardless of ctrl+o state. |
370
+ | `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`. |
371
+
372
+ > **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
373
 
337
374
  ## StopAgent Tool
338
375
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents-lite",
3
- "version": "1.0.1",
3
+ "version": "1.1.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
 
@@ -125,6 +126,9 @@ export class AgentManager {
125
126
  private onComplete?: OnAgentComplete;
126
127
  private onStart?: OnAgentStart;
127
128
 
129
+ /** Session-level cumulative agent cost. Survives agent eviction. */
130
+ private totalAgentCost = 0;
131
+
128
132
  /** Per-model concurrency slots keyed by "provider/modelId". */
129
133
  private concurrencySlots = new Map<string, ConcurrencySlot>();
130
134
 
@@ -248,16 +252,24 @@ export class AgentManager {
248
252
 
249
253
  const record: AgentRecord = {
250
254
  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,
255
+ lifecycle: {
256
+ status: queued ? "queued" : "running",
257
+ startedAt: Date.now(),
258
+ },
259
+ display: {
260
+ type,
261
+ description: options.description,
262
+ invocation: options.invocation,
263
+ },
264
+ execution: {
265
+ abortController,
266
+ },
267
+ stats: {
268
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
269
+ toolUses: 0,
270
+ compactionCount: 0,
271
+ maxTurns: options.maxTurns,
272
+ },
261
273
  };
262
274
  this.agents.set(id, record);
263
275
 
@@ -286,12 +298,12 @@ export class AgentManager {
286
298
  ) {
287
299
  if (concurrencySlot) concurrencySlot.running++;
288
300
 
289
- record.status = "running";
290
- record.startedAt = Date.now();
301
+ record.lifecycle.status = "running";
302
+ record.lifecycle.startedAt = Date.now();
291
303
 
292
304
  // Create output file for this agent
293
- record.outputFile = createOutputFilePath(id);
294
- writeInitialEntry(record.outputFile, prompt);
305
+ record.display.outputFile = createOutputFilePath(id);
306
+ writeInitialEntry(record.display.outputFile, prompt);
295
307
 
296
308
  this.onStart?.(record);
297
309
 
@@ -307,29 +319,29 @@ export class AgentManager {
307
319
  maxTurns: options.maxTurns,
308
320
  thinkingLevel: options.thinkingLevel,
309
321
  graceTurns: options.graceTurns,
310
- signal: record.abortController!.signal,
322
+ signal: record.execution.abortController!.signal,
311
323
  ...this.createRecordCallbacks(record, options),
312
324
  onTurnEnd: (turnCount) => {
313
- record.turnCount = turnCount;
325
+ record.stats.turnCount = turnCount;
314
326
  options.onTurnEnd?.(turnCount);
315
327
  },
316
328
  onTextDelta: options.onTextDelta,
317
329
  onSessionCreated: (session) => {
318
- record.session = session;
330
+ record.execution.session = session;
319
331
  // Flush any steers that arrived before the session was ready
320
- if (record.pendingSteers?.length) {
321
- for (const msg of record.pendingSteers) {
332
+ if (record.execution.pendingSteers?.length) {
333
+ for (const msg of record.execution.pendingSteers) {
322
334
  session.steer(msg).catch(() => {
323
335
  // Steer is advisory — a failure here (e.g. session already aborting)
324
336
  // is fine; the user can re-send if needed.
325
337
  });
326
338
  }
327
- record.pendingSteers = undefined;
339
+ record.execution.pendingSteers = undefined;
328
340
  }
329
341
  // Stream session events to the output file
330
- if (record.outputFile) {
331
- record.outputCleanup = createOutputCleanup(
332
- session, record.outputFile, record,
342
+ if (record.display.outputFile) {
343
+ record.execution.outputCleanup = createOutputCleanup(
344
+ session, record.display.outputFile, record,
333
345
  );
334
346
  }
335
347
  options.onSessionCreated?.(session);
@@ -337,28 +349,29 @@ export class AgentManager {
337
349
  })
338
350
  .then(({ responseText, session, aborted, steered }) => {
339
351
  // Don't overwrite status if externally stopped via abort()
340
- if (record.status !== "stopped") {
341
- record.status = aborted ? "aborted" : steered ? "steered" : "completed";
352
+ if (record.lifecycle.status !== "stopped") {
353
+ record.lifecycle.status = aborted ? "aborted" : steered ? "steered" : "completed";
342
354
  }
343
355
  record.result = responseText;
344
- record.session = session;
345
- record.completedAt ??= Date.now();
356
+ record.execution.session = session;
357
+ record.stats.contextPercent = getSessionContextPercent(session);
358
+ record.lifecycle.completedAt ??= Date.now();
346
359
  return responseText;
347
360
  })
348
361
  .catch((err) => {
349
362
  // Don't overwrite status if externally stopped via abort()
350
- if (record.status !== "stopped") {
351
- record.status = "error";
363
+ if (record.lifecycle.status !== "stopped") {
364
+ record.lifecycle.status = "error";
352
365
  }
353
366
  record.error = errorMessage(err);
354
- record.completedAt ??= Date.now();
367
+ record.lifecycle.completedAt ??= Date.now();
355
368
  return "";
356
369
  })
357
370
  .finally(() => {
358
371
  // Final flush of streaming output file
359
- if (record.outputCleanup) {
360
- try { record.outputCleanup(); } catch { /* ignore */ }
361
- record.outputCleanup = undefined;
372
+ if (record.execution.outputCleanup) {
373
+ try { record.execution.outputCleanup(); } catch { /* ignore */ }
374
+ record.execution.outputCleanup = undefined;
362
375
  }
363
376
 
364
377
  // Decrement per-model concurrency count
@@ -368,14 +381,20 @@ export class AgentManager {
368
381
  this.drainQueue();
369
382
  });
370
383
 
371
- record.promise = promise;
384
+ record.execution.promise = promise;
372
385
  }
373
386
 
374
387
  /** Notify completion callback, ignoring any errors. */
375
388
  private safeNotifyComplete(record: AgentRecord): void {
389
+ this.totalAgentCost += record.stats.lifetimeUsage.cost;
376
390
  try { this.onComplete?.(record); } catch { /* ignore */ }
377
391
  }
378
392
 
393
+ /** Get the session-level cumulative agent cost. Survives agent eviction. */
394
+ getTotalAgentCost(): number {
395
+ return this.totalAgentCost;
396
+ }
397
+
379
398
  /**
380
399
  * Build common record-tracking callbacks shared by startAgent.
381
400
  * Updates the record's toolUses, lifetimeUsage, and compactionCount.
@@ -391,15 +410,15 @@ export class AgentManager {
391
410
  } {
392
411
  return {
393
412
  onToolActivity: (activity) => {
394
- if (activity.type === "end") record.toolUses++;
413
+ if (activity.type === "end") record.stats.toolUses++;
395
414
  options?.onToolActivity?.(activity);
396
415
  },
397
416
  onAssistantUsage: (usage) => {
398
- addUsage(record.lifetimeUsage, usage);
417
+ addUsage(record.stats.lifetimeUsage, usage);
399
418
  options?.onAssistantUsage?.(usage);
400
419
  },
401
420
  onCompaction: (info) => {
402
- record.compactionCount++;
421
+ record.stats.compactionCount++;
403
422
  options?.onCompaction?.(info);
404
423
  },
405
424
  };
@@ -410,7 +429,7 @@ export class AgentManager {
410
429
  const started = new Set<string>();
411
430
  for (const entry of this.queue) {
412
431
  const record = this.agents.get(entry.id);
413
- if (!record || record.status !== "queued") continue;
432
+ if (!record || record.lifecycle.status !== "queued") continue;
414
433
 
415
434
  const slot = this.getSlot(entry.modelKey);
416
435
  if (slot.running >= slot.limit) continue;
@@ -420,9 +439,9 @@ export class AgentManager {
420
439
  started.add(entry.id);
421
440
  } catch (err) {
422
441
  // Late failure — surface on the record so the user can see it
423
- record.status = "error";
442
+ record.lifecycle.status = "error";
424
443
  record.error = errorMessage(err);
425
- record.completedAt = Date.now();
444
+ record.lifecycle.completedAt = Date.now();
426
445
  started.add(entry.id);
427
446
  this.safeNotifyComplete(record);
428
447
  }
@@ -439,17 +458,17 @@ export class AgentManager {
439
458
  const record = this.agents.get(id);
440
459
  if (!record) return false;
441
460
 
442
- if (record.status !== "running") return false;
461
+ if (record.lifecycle.status !== "running") return false;
443
462
 
444
- if (!record.session) {
463
+ if (!record.execution.session) {
445
464
  // Session not yet created — queue the steer
446
- if (!record.pendingSteers) record.pendingSteers = [];
447
- record.pendingSteers.push(message);
465
+ if (!record.execution.pendingSteers) record.execution.pendingSteers = [];
466
+ record.execution.pendingSteers.push(message);
448
467
  return true;
449
468
  }
450
469
 
451
470
  try {
452
- await record.session.steer(message);
471
+ await record.execution.session.steer(message);
453
472
  return true;
454
473
  } catch {
455
474
  // steer failures are surfaced to the caller via the boolean return value
@@ -463,7 +482,7 @@ export class AgentManager {
463
482
 
464
483
  listAgents(): AgentRecord[] {
465
484
  return [...this.agents.values()].sort(
466
- (a, b) => b.startedAt - a.startedAt,
485
+ (a, b) => b.lifecycle.startedAt - a.lifecycle.startedAt,
467
486
  );
468
487
  }
469
488
 
@@ -479,30 +498,30 @@ export class AgentManager {
479
498
  * Returns true if the agent was stopped, false if it wasn't running/queued.
480
499
  */
481
500
  private stopAgent(record: AgentRecord): boolean {
482
- if (record.status === "queued") {
501
+ if (record.lifecycle.status === "queued") {
483
502
  this.queue = this.queue.filter(q => q.id !== record.id);
484
- } else if (record.status !== "running") {
503
+ } else if (record.lifecycle.status !== "running") {
485
504
  return false;
486
505
  } else {
487
- record.abortController?.abort();
506
+ record.execution.abortController?.abort();
488
507
  }
489
- record.status = "stopped";
490
- record.completedAt = Date.now();
508
+ record.lifecycle.status = "stopped";
509
+ record.lifecycle.completedAt = Date.now();
491
510
  return true;
492
511
  }
493
512
 
494
513
  /** Dispose a record's session and remove it from the map. */
495
514
  private removeRecord(id: string, record: AgentRecord): void {
496
- record.session?.dispose();
497
- record.session = undefined;
515
+ record.execution.session?.dispose();
516
+ record.execution.session = undefined;
498
517
  this.agents.delete(id);
499
518
  }
500
519
 
501
520
  private cleanup() {
502
521
  const cutoff = Date.now() - CLEANUP_AGE_CUTOFF_MS;
503
522
  for (const [id, record] of this.agents) {
504
- if (!isTerminalStatus(record.status)) continue;
505
- if ((record.completedAt ?? 0) >= cutoff) continue;
523
+ if (!isTerminalStatus(record.lifecycle.status)) continue;
524
+ if ((record.lifecycle.completedAt ?? 0) >= cutoff) continue;
506
525
  this.removeRecord(id, record);
507
526
  }
508
527
  }
@@ -511,7 +530,7 @@ export class AgentManager {
511
530
  clearInterval(this.cleanupInterval);
512
531
  this.queue = [];
513
532
  for (const record of this.agents.values()) {
514
- record.session?.dispose();
533
+ record.execution.session?.dispose();
515
534
  }
516
535
  this.agents.clear();
517
536
  }