@tintinweb/pi-subagents 0.6.3 → 0.7.1

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/dist/types.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
5
5
  import type { AgentSession } from "@mariozechner/pi-coding-agent";
6
+ import type { LifetimeUsage } from "./usage.js";
6
7
  export type { ThinkingLevel };
7
8
  /** Agent type: any string name (built-in defaults or user-defined). */
8
9
  export type SubagentType = string;
@@ -82,6 +83,14 @@ export interface AgentRecord {
82
83
  outputFile?: string;
83
84
  /** Cleanup function for the output file stream subscription. */
84
85
  outputCleanup?: () => void;
86
+ /**
87
+ * Lifetime usage breakdown, accumulated via `message_end` events. Survives
88
+ * compaction. Total = input + output + cacheWrite (cacheRead deliberately
89
+ * excluded — see issue #38). Initialized to zeros at spawn.
90
+ */
91
+ lifetimeUsage: LifetimeUsage;
92
+ /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
93
+ compactionCount: number;
85
94
  }
86
95
  /** Details attached to custom notification messages for visual rendering. */
87
96
  export interface NotificationDetails {
@@ -104,3 +113,40 @@ export interface EnvInfo {
104
113
  branch: string;
105
114
  platform: string;
106
115
  }
116
+ /**
117
+ * A subagent spawn registered to fire on a schedule.
118
+ *
119
+ * Stored at `<cwd>/.pi/subagent-schedules/<sessionId>.json`. Session-scoped:
120
+ * survives `/resume` but resets on `/new`, mirroring pi-chonky-tasks.
121
+ */
122
+ export interface ScheduledSubagent {
123
+ id: string;
124
+ /** Unique within store. Defaults to `description`. */
125
+ name: string;
126
+ description: string;
127
+ /** Raw user input — cron expr | "+10m" | ISO | "5m". */
128
+ schedule: string;
129
+ scheduleType: "cron" | "once" | "interval";
130
+ /** Computed at create time for interval/once. */
131
+ intervalMs?: number;
132
+ subagent_type: SubagentType;
133
+ prompt: string;
134
+ model?: string;
135
+ thinking?: ThinkingLevel;
136
+ max_turns?: number;
137
+ isolated?: boolean;
138
+ isolation?: IsolationMode;
139
+ enabled: boolean;
140
+ /** ISO timestamp. */
141
+ createdAt: string;
142
+ lastRun?: string;
143
+ lastStatus?: "success" | "error" | "running";
144
+ /** Refreshed on every fire and on store load. */
145
+ nextRun?: string;
146
+ runCount: number;
147
+ }
148
+ export interface ScheduleStoreData {
149
+ /** For future migrations. */
150
+ version: 1;
151
+ jobs: ScheduledSubagent[];
152
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { AgentManager } from "../agent-manager.js";
8
8
  import type { SubagentType } from "../types.js";
9
+ import { type LifetimeUsage, type SessionLike } from "../usage.js";
9
10
  /** Braille spinner frames for animated running indicator. */
10
11
  export declare const SPINNER: string[];
11
12
  /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
@@ -27,19 +28,14 @@ export type UICtx = {
27
28
  export interface AgentActivity {
28
29
  activeTools: Map<string, string>;
29
30
  toolUses: number;
30
- tokens: string;
31
31
  responseText: string;
32
- session?: {
33
- getSessionStats(): {
34
- tokens: {
35
- total: number;
36
- };
37
- };
38
- };
32
+ session?: SessionLike;
39
33
  /** Current turn count. */
40
34
  turnCount: number;
41
35
  /** Effective max turns for this agent (undefined = unlimited). */
42
36
  maxTurns?: number;
37
+ /** Lifetime usage breakdown — see LifetimeUsage docs. */
38
+ lifetimeUsage: LifetimeUsage;
43
39
  }
44
40
  /** Metadata attached to Agent tool results for custom rendering. */
45
41
  export interface AgentDetails {
@@ -67,6 +63,17 @@ export interface AgentDetails {
67
63
  }
68
64
  /** Format a token count compactly: "33.8k token", "1.2M token". */
69
65
  export declare function formatTokens(count: number): string;
66
+ /**
67
+ * Token count with optional context-fill % and compaction-count annotations.
68
+ * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
69
+ * Compaction count rendered as `↻N` in dim.
70
+ *
71
+ * "12.3k token" — no annotations
72
+ * "12.3k token (45%)" — percent only
73
+ * "12.3k token (↻2)" — compactions only (e.g. right after compact)
74
+ * "12.3k token (45% · ↻2)" — both
75
+ */
76
+ export declare function formatSessionTokens(tokens: number, percent: number | null, theme: Theme, compactions?: number): string;
70
77
  /** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
71
78
  export declare function formatTurns(turnCount: number, maxTurns?: number | null): string;
72
79
  /** Format milliseconds as human-readable duration. */
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { truncateToWidth } from "@mariozechner/pi-tui";
8
8
  import { getConfig } from "../agent-types.js";
9
+ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
9
10
  // ---- Constants ----
10
11
  /** Maximum number of rendered lines before overflow collapse kicks in. */
11
12
  const MAX_WIDGET_LINES = 12;
@@ -32,6 +33,30 @@ export function formatTokens(count) {
32
33
  return `${(count / 1_000).toFixed(1)}k token`;
33
34
  return `${count} token`;
34
35
  }
36
+ /**
37
+ * Token count with optional context-fill % and compaction-count annotations.
38
+ * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
39
+ * Compaction count rendered as `↻N` in dim.
40
+ *
41
+ * "12.3k token" — no annotations
42
+ * "12.3k token (45%)" — percent only
43
+ * "12.3k token (↻2)" — compactions only (e.g. right after compact)
44
+ * "12.3k token (45% · ↻2)" — both
45
+ */
46
+ export function formatSessionTokens(tokens, percent, theme, compactions = 0) {
47
+ const tokenStr = formatTokens(tokens);
48
+ const annot = [];
49
+ if (percent !== null) {
50
+ const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
51
+ annot.push(theme.fg(color, `${Math.round(percent)}%`));
52
+ }
53
+ if (compactions > 0) {
54
+ annot.push(theme.fg("dim", `↻${compactions}`));
55
+ }
56
+ if (annot.length === 0)
57
+ return tokenStr;
58
+ return `${tokenStr} (${annot.join(" · ")})`;
59
+ }
35
60
  /** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
36
61
  export function formatTurns(turnCount, maxTurns) {
37
62
  return maxTurns != null ? `⟳${turnCount}≤${maxTurns}` : `⟳${turnCount}`;
@@ -222,13 +247,9 @@ export class AgentWidget {
222
247
  const elapsed = formatMs(Date.now() - a.startedAt);
223
248
  const bg = this.agentActivity.get(a.id);
224
249
  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
- }
250
+ const tokens = getLifetimeTotal(bg?.lifetimeUsage);
251
+ const contextPercent = getSessionContextPercent(bg?.session);
252
+ const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
232
253
  const parts = [];
233
254
  if (bg)
234
255
  parts.push(formatTurns(bg.turnCount, bg.maxTurns));
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
8
8
  import { extractText } from "../context.js";
9
- import { describeActivity, formatDuration, formatTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
9
+ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
10
+ import { describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
10
11
  /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
11
12
  const CHROME_LINES = 6;
12
13
  const MIN_VIEWPORT = 3;
@@ -101,13 +102,10 @@ export class ConversationViewer {
101
102
  const toolUses = this.activity?.toolUses ?? this.record.toolUses;
102
103
  if (toolUses > 0)
103
104
  headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
104
- if (this.activity?.session) {
105
- try {
106
- const tokens = this.activity.session.getSessionStats().tokens.total;
107
- if (tokens > 0)
108
- headerParts.push(formatTokens(tokens));
109
- }
110
- catch { /* */ }
105
+ const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
106
+ if (tokens > 0) {
107
+ const percent = getSessionContextPercent(this.activity?.session);
108
+ headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
111
109
  }
112
110
  lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`));
113
111
  lines.push(hrMid);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * schedule-menu.ts — `/agents → Scheduled jobs` submenu.
3
+ *
4
+ * Minimal v1 surface: list scheduled jobs, select one to inspect details +
5
+ * confirm cancellation. No create wizard (the `Agent` tool's `schedule` param
6
+ * is the canonical creation path), no toggle/cleanup (cancel is enough for
7
+ * "I scheduled something dumb, get rid of it"). Add management surfaces here
8
+ * if real demand emerges.
9
+ */
10
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
11
+ import type { SubagentScheduler } from "../schedule.js";
12
+ /**
13
+ * List scheduled jobs; selecting one opens a cancel-confirm with details.
14
+ * Returns when the user backs out or after a cancellation.
15
+ */
16
+ export declare function showSchedulesMenu(ctx: ExtensionCommandContext, scheduler: SubagentScheduler): Promise<void>;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * schedule-menu.ts — `/agents → Scheduled jobs` submenu.
3
+ *
4
+ * Minimal v1 surface: list scheduled jobs, select one to inspect details +
5
+ * confirm cancellation. No create wizard (the `Agent` tool's `schedule` param
6
+ * is the canonical creation path), no toggle/cleanup (cancel is enough for
7
+ * "I scheduled something dumb, get rid of it"). Add management surfaces here
8
+ * if real demand emerges.
9
+ */
10
+ /** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
11
+ function relTime(iso, now = Date.now()) {
12
+ if (!iso)
13
+ return "—";
14
+ const t = new Date(iso).getTime();
15
+ if (Number.isNaN(t))
16
+ return "—";
17
+ const diff = t - now;
18
+ const abs = Math.abs(diff);
19
+ const future = diff > 0;
20
+ if (abs < 60_000)
21
+ return future ? "in <1m" : "<1m ago";
22
+ const m = Math.round(abs / 60_000);
23
+ if (m < 60)
24
+ return future ? `in ${m}m` : `${m}m ago`;
25
+ const h = Math.round(abs / 3_600_000);
26
+ if (h < 24)
27
+ return future ? `in ${h}h` : `${h}h ago`;
28
+ const d = Math.round(abs / 86_400_000);
29
+ return future ? `in ${d}d` : `${d}d ago`;
30
+ }
31
+ /** One-line status icon. */
32
+ function statusIcon(j) {
33
+ if (!j.enabled)
34
+ return "✗";
35
+ if (j.lastStatus === "error")
36
+ return "!";
37
+ if (j.lastStatus === "running")
38
+ return "⋯";
39
+ return "✓";
40
+ }
41
+ /** Compact selectable row — name, schedule, agent type, next/last run, count. */
42
+ function formatJob(j, scheduler) {
43
+ const next = scheduler.getNextRun(j.id);
44
+ return [
45
+ statusIcon(j),
46
+ j.name.padEnd(18).slice(0, 18),
47
+ j.schedule.padEnd(14).slice(0, 14),
48
+ `[${j.subagent_type}]`,
49
+ `next ${relTime(next)}`,
50
+ `last ${relTime(j.lastRun)}`,
51
+ `runs ${j.runCount}`,
52
+ ].join(" ");
53
+ }
54
+ /** Multi-line details block for the cancel confirm. */
55
+ function formatDetails(j, scheduler) {
56
+ const next = scheduler.getNextRun(j.id) ?? "—";
57
+ return [
58
+ `name: ${j.name}`,
59
+ `schedule: ${j.schedule} (${j.scheduleType})`,
60
+ `agent: ${j.subagent_type}`,
61
+ `prompt: ${j.prompt.slice(0, 200)}${j.prompt.length > 200 ? "…" : ""}`,
62
+ `created: ${j.createdAt}`,
63
+ `last run: ${j.lastRun ?? "—"} (${j.lastStatus ?? "—"})`,
64
+ `next run: ${next}`,
65
+ `runs: ${j.runCount}`,
66
+ ].join("\n");
67
+ }
68
+ /**
69
+ * List scheduled jobs; selecting one opens a cancel-confirm with details.
70
+ * Returns when the user backs out or after a cancellation.
71
+ */
72
+ export async function showSchedulesMenu(ctx, scheduler) {
73
+ if (!scheduler.isActive()) {
74
+ ctx.ui.notify("Scheduler is not active in this session.", "warning");
75
+ return;
76
+ }
77
+ const jobs = scheduler.list();
78
+ if (jobs.length === 0) {
79
+ ctx.ui.notify("No scheduled jobs.", "info");
80
+ return;
81
+ }
82
+ const labels = jobs.map(j => formatJob(j, scheduler));
83
+ const choice = await ctx.ui.select(`Scheduled jobs (${jobs.length}) — select to cancel`, labels);
84
+ if (!choice)
85
+ return;
86
+ const idx = labels.indexOf(choice);
87
+ if (idx < 0)
88
+ return;
89
+ const job = jobs[idx];
90
+ const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
91
+ if (!ok)
92
+ return;
93
+ scheduler.removeJob(job.id);
94
+ ctx.ui.notify(`Cancelled "${job.name}".`, "info");
95
+ }
@@ -0,0 +1,50 @@
1
+ /** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
2
+ /**
3
+ * Lifetime usage components, accumulated via `message_end` events. Survives
4
+ * compaction (which replaces session.state.messages and would reset any
5
+ * stats-derived sum). cacheRead is excluded because each turn's cacheRead is
6
+ * the cumulative cached prefix re-read on that one call — summing across
7
+ * turns counts the prefix N times. See issue #38.
8
+ */
9
+ export type LifetimeUsage = {
10
+ input: number;
11
+ output: number;
12
+ cacheWrite: number;
13
+ };
14
+ /** Sum of lifetime usage components, or 0 if undefined. */
15
+ export declare function getLifetimeTotal(u?: LifetimeUsage): number;
16
+ /** Add a usage delta into a target accumulator (mutates target). */
17
+ export declare function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void;
18
+ /** Minimal shape we read from upstream `getSessionStats()`. */
19
+ export type SessionStatsLike = {
20
+ tokens: {
21
+ input: number;
22
+ output: number;
23
+ cacheWrite: number;
24
+ };
25
+ contextUsage?: {
26
+ percent: number | null;
27
+ };
28
+ };
29
+ export type SessionLike = {
30
+ getSessionStats(): SessionStatsLike;
31
+ };
32
+ /**
33
+ * Session-scoped token count: input + output + cacheWrite as reported by
34
+ * upstream `getSessionStats().tokens` for the *current* session window.
35
+ *
36
+ * RESETS at compaction — upstream replaces `session.state.messages` and the
37
+ * stats are derived from that array. For a lifetime total that survives
38
+ * compaction, use `getLifetimeTotal(lifetimeUsage)` instead, which reads
39
+ * from an independent accumulator fed by `message_end` events.
40
+ *
41
+ * Avoids upstream's `tokens.total` field, which sums per-turn `cacheRead`
42
+ * and so counts the cumulative cached prefix N times across N turns
43
+ * (issue #38).
44
+ */
45
+ export declare function getSessionTokens(session: SessionLike | undefined): number;
46
+ /**
47
+ * Context-window utilization (0–100), or null when unavailable
48
+ * (no model contextWindow, or post-compaction before the next response).
49
+ */
50
+ export declare function getSessionContextPercent(session: SessionLike | undefined): number | null;
package/dist/usage.js ADDED
@@ -0,0 +1,49 @@
1
+ /** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
2
+ /** Sum of lifetime usage components, or 0 if undefined. */
3
+ export function getLifetimeTotal(u) {
4
+ return u ? u.input + u.output + u.cacheWrite : 0;
5
+ }
6
+ /** Add a usage delta into a target accumulator (mutates target). */
7
+ export function addUsage(into, delta) {
8
+ into.input += delta.input;
9
+ into.output += delta.output;
10
+ into.cacheWrite += delta.cacheWrite;
11
+ }
12
+ /**
13
+ * Session-scoped token count: input + output + cacheWrite as reported by
14
+ * upstream `getSessionStats().tokens` for the *current* session window.
15
+ *
16
+ * RESETS at compaction — upstream replaces `session.state.messages` and the
17
+ * stats are derived from that array. For a lifetime total that survives
18
+ * compaction, use `getLifetimeTotal(lifetimeUsage)` instead, which reads
19
+ * from an independent accumulator fed by `message_end` events.
20
+ *
21
+ * Avoids upstream's `tokens.total` field, which sums per-turn `cacheRead`
22
+ * and so counts the cumulative cached prefix N times across N turns
23
+ * (issue #38).
24
+ */
25
+ export function getSessionTokens(session) {
26
+ if (!session)
27
+ return 0;
28
+ try {
29
+ const t = session.getSessionStats().tokens;
30
+ return t.input + t.output + t.cacheWrite;
31
+ }
32
+ catch {
33
+ return 0;
34
+ }
35
+ }
36
+ /**
37
+ * Context-window utilization (0–100), or null when unavailable
38
+ * (no model contextWindow, or post-compaction before the next response).
39
+ */
40
+ export function getSessionContextPercent(session) {
41
+ if (!session)
42
+ return null;
43
+ try {
44
+ return session.getSessionStats().contextUsage?.percent ?? null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -20,11 +20,15 @@
20
20
  "agent",
21
21
  "autonomous"
22
22
  ],
23
+ "peerDependencies": {
24
+ "@mariozechner/pi-ai": ">=0.70.5",
25
+ "@mariozechner/pi-coding-agent": ">=0.70.5",
26
+ "@mariozechner/pi-tui": ">=0.70.5"
27
+ },
23
28
  "dependencies": {
24
- "@mariozechner/pi-ai": "^0.70.5",
25
- "@mariozechner/pi-coding-agent": "^0.70.5",
26
- "@mariozechner/pi-tui": "^0.70.5",
27
- "@sinclair/typebox": "latest"
29
+ "@sinclair/typebox": "^0.34.49",
30
+ "croner": "^10.0.1",
31
+ "nanoid": "^5.0.0"
28
32
  },
29
33
  "scripts": {
30
34
  "build": "tsc",
@@ -36,7 +40,7 @@
36
40
  "lint:fix": "biome check --fix src/ test/"
37
41
  },
38
42
  "devDependencies": {
39
- "@biomejs/biome": "^2.3.5",
43
+ "@biomejs/biome": "^2.4.14",
40
44
  "@types/node": "^25.5.0",
41
45
  "typescript": "^6.0.0",
42
46
  "vitest": "^4.0.18"
@@ -11,10 +11,13 @@ import type { Model } from "@mariozechner/pi-ai";
11
11
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
12
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
13
13
  import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
14
+ import { addUsage } from "./usage.js";
14
15
  import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
15
16
 
16
17
  export type OnAgentComplete = (record: AgentRecord) => void;
17
18
  export type OnAgentStart = (record: AgentRecord) => void;
19
+ export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
20
+ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
18
21
 
19
22
  /** Default max concurrent background agents. */
20
23
  const DEFAULT_MAX_CONCURRENT = 4;
@@ -35,8 +38,16 @@ interface SpawnOptions {
35
38
  inheritContext?: boolean;
36
39
  thinkingLevel?: ThinkingLevel;
37
40
  isBackground?: boolean;
41
+ /**
42
+ * Skip the maxConcurrent queue check for this spawn — start immediately even
43
+ * if the configured concurrency limit would otherwise queue it. Used by the
44
+ * scheduler so a fired job can't be deferred past its trigger window.
45
+ */
46
+ bypassQueue?: boolean;
38
47
  /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
39
48
  isolation?: IsolationMode;
49
+ /** Parent abort signal — when aborted, the subagent is also stopped. */
50
+ signal?: AbortSignal;
40
51
  /** Called on tool start/end with activity info (for streaming progress to UI). */
41
52
  onToolActivity?: (activity: ToolActivity) => void;
42
53
  /** Called on streaming text deltas from the assistant response. */
@@ -45,6 +56,10 @@ interface SpawnOptions {
45
56
  onSessionCreated?: (session: AgentSession) => void;
46
57
  /** Called at the end of each agentic turn with the cumulative count. */
47
58
  onTurnEnd?: (turnCount: number) => void;
59
+ /** Called once per assistant message_end with that message's usage delta. */
60
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
61
+ /** Called when the session successfully compacts. */
62
+ onCompaction?: (info: CompactionInfo) => void;
48
63
  }
49
64
 
50
65
  export class AgentManager {
@@ -52,6 +67,7 @@ export class AgentManager {
52
67
  private cleanupInterval: ReturnType<typeof setInterval>;
53
68
  private onComplete?: OnAgentComplete;
54
69
  private onStart?: OnAgentStart;
70
+ private onCompact?: OnAgentCompact;
55
71
  private maxConcurrent: number;
56
72
 
57
73
  /** Queue of background agents waiting to start. */
@@ -59,12 +75,19 @@ export class AgentManager {
59
75
  /** Number of currently running background agents. */
60
76
  private runningBackground = 0;
61
77
 
62
- constructor(onComplete?: OnAgentComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart?: OnAgentStart) {
78
+ constructor(
79
+ onComplete?: OnAgentComplete,
80
+ maxConcurrent = DEFAULT_MAX_CONCURRENT,
81
+ onStart?: OnAgentStart,
82
+ onCompact?: OnAgentCompact,
83
+ ) {
63
84
  this.onComplete = onComplete;
64
85
  this.onStart = onStart;
86
+ this.onCompact = onCompact;
65
87
  this.maxConcurrent = maxConcurrent;
66
88
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
67
89
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
90
+ this.cleanupInterval.unref();
68
91
  }
69
92
 
70
93
  /** Update the max concurrent background agents limit. */
@@ -99,45 +122,63 @@ export class AgentManager {
99
122
  toolUses: 0,
100
123
  startedAt: Date.now(),
101
124
  abortController,
125
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
126
+ compactionCount: 0,
102
127
  };
103
128
  this.agents.set(id, record);
104
129
 
105
130
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
106
131
 
107
- if (options.isBackground && this.runningBackground >= this.maxConcurrent) {
132
+ if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
108
133
  // Queue it — will be started when a running agent completes
109
134
  this.queue.push({ id, args });
110
135
  return id;
111
136
  }
112
137
 
113
- this.startAgent(id, record, args);
138
+ // startAgent can throw (e.g. strict worktree-isolation failure) — clean
139
+ // up the record so callers don't see an orphan in `listAgents()`.
140
+ try {
141
+ this.startAgent(id, record, args);
142
+ } catch (err) {
143
+ this.agents.delete(id);
144
+ throw err;
145
+ }
114
146
  return id;
115
147
  }
116
148
 
117
149
  /** Actually start an agent (called immediately or from queue drain). */
118
150
  private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
119
- record.status = "running";
120
- record.startedAt = Date.now();
121
- if (options.isBackground) this.runningBackground++;
122
- this.onStart?.(record);
123
-
124
- // Worktree isolation: create a temporary git worktree if requested
151
+ // Worktree isolation: try to create a temporary git worktree. Strict
152
+ // fail loud if not possible (no silent fallback to main tree). Done
153
+ // BEFORE state mutation so a throw doesn't leave the record half-running.
125
154
  let worktreeCwd: string | undefined;
126
- let worktreeWarning = "";
127
155
  if (options.isolation === "worktree") {
128
156
  const wt = createWorktree(ctx.cwd, id);
129
- if (wt) {
130
- record.worktree = wt;
131
- worktreeCwd = wt.path;
132
- } else {
133
- worktreeWarning = "\n\n[WARNING: Worktree isolation was requested but failed (not a git repo, or no commits yet). Running in the main working directory instead.]";
157
+ if (!wt) {
158
+ throw new Error(
159
+ 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
160
+ 'Initialize git and commit at least once, or omit `isolation`.',
161
+ );
134
162
  }
163
+ record.worktree = wt;
164
+ worktreeCwd = wt.path;
135
165
  }
136
166
 
137
- // Prepend worktree warning to prompt if isolation failed
138
- const effectivePrompt = worktreeWarning ? worktreeWarning + "\n\n" + prompt : prompt;
167
+ record.status = "running";
168
+ record.startedAt = Date.now();
169
+ if (options.isBackground) this.runningBackground++;
170
+ this.onStart?.(record);
171
+
172
+ // Wire parent abort signal to stop the subagent when the parent is interrupted
173
+ let detachParentSignal: (() => void) | undefined;
174
+ if (options.signal) {
175
+ const onParentAbort = () => this.abort(id);
176
+ options.signal.addEventListener("abort", onParentAbort, { once: true });
177
+ detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
178
+ }
179
+ const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
139
180
 
140
- const promise = runAgent(ctx, type, effectivePrompt, {
181
+ const promise = runAgent(ctx, type, prompt, {
141
182
  pi,
142
183
  model: options.model,
143
184
  maxTurns: options.maxTurns,
@@ -152,6 +193,15 @@ export class AgentManager {
152
193
  },
153
194
  onTurnEnd: options.onTurnEnd,
154
195
  onTextDelta: options.onTextDelta,
196
+ onAssistantUsage: (usage) => {
197
+ addUsage(record.lifetimeUsage, usage);
198
+ options.onAssistantUsage?.(usage);
199
+ },
200
+ onCompaction: (info) => {
201
+ record.compactionCount++;
202
+ this.onCompact?.(record, info);
203
+ options.onCompaction?.(info);
204
+ },
155
205
  onSessionCreated: (session) => {
156
206
  record.session = session;
157
207
  // Flush any steers that arrived before the session was ready
@@ -173,6 +223,8 @@ export class AgentManager {
173
223
  record.session = session;
174
224
  record.completedAt ??= Date.now();
175
225
 
226
+ detach();
227
+
176
228
  // Final flush of streaming output file
177
229
  if (record.outputCleanup) {
178
230
  try { record.outputCleanup(); } catch { /* ignore */ }
@@ -191,7 +243,7 @@ export class AgentManager {
191
243
 
192
244
  if (options.isBackground) {
193
245
  this.runningBackground--;
194
- this.onComplete?.(record);
246
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
195
247
  this.drainQueue();
196
248
  }
197
249
  return responseText;
@@ -204,6 +256,8 @@ export class AgentManager {
204
256
  record.error = err instanceof Error ? err.message : String(err);
205
257
  record.completedAt ??= Date.now();
206
258
 
259
+ detach();
260
+
207
261
  // Final flush of streaming output file on error
208
262
  if (record.outputCleanup) {
209
263
  try { record.outputCleanup(); } catch { /* ignore */ }
@@ -235,7 +289,16 @@ export class AgentManager {
235
289
  const next = this.queue.shift()!;
236
290
  const record = this.agents.get(next.id);
237
291
  if (!record || record.status !== "queued") continue;
238
- this.startAgent(next.id, record, next.args);
292
+ try {
293
+ this.startAgent(next.id, record, next.args);
294
+ } catch (err) {
295
+ // Late failure (e.g. strict worktree-isolation) — surface on the record
296
+ // so the user/agent can see it via /agents, then keep draining.
297
+ record.status = "error";
298
+ record.error = err instanceof Error ? err.message : String(err);
299
+ record.completedAt = Date.now();
300
+ this.onComplete?.(record);
301
+ }
239
302
  }
240
303
  }
241
304
 
@@ -278,6 +341,13 @@ export class AgentManager {
278
341
  onToolActivity: (activity) => {
279
342
  if (activity.type === "end") record.toolUses++;
280
343
  },
344
+ onAssistantUsage: (usage) => {
345
+ addUsage(record.lifetimeUsage, usage);
346
+ },
347
+ onCompaction: (info) => {
348
+ record.compactionCount++;
349
+ this.onCompact?.(record, info);
350
+ },
281
351
  signal,
282
352
  });
283
353
  record.status = "completed";