@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/CHANGELOG.md +37 -0
- package/README.md +55 -11
- package/dist/agent-manager.d.ts +23 -1
- package/dist/agent-manager.js +71 -20
- package/dist/agent-runner.d.ts +27 -0
- package/dist/agent-runner.js +28 -4
- package/dist/index.js +236 -72
- package/dist/schedule-store.d.ts +36 -0
- package/dist/schedule-store.js +144 -0
- package/dist/schedule.d.ts +109 -0
- package/dist/schedule.js +338 -0
- package/dist/settings.d.ts +10 -0
- package/dist/settings.js +5 -0
- package/dist/types.d.ts +46 -0
- package/dist/ui/agent-widget.d.ts +15 -8
- package/dist/ui/agent-widget.js +28 -7
- package/dist/ui/conversation-viewer.js +6 -8
- package/dist/ui/schedule-menu.d.ts +16 -0
- package/dist/ui/schedule-menu.js +95 -0
- package/dist/usage.d.ts +50 -0
- package/dist/usage.js +49 -0
- package/package.json +10 -6
- package/src/agent-manager.ts +90 -20
- package/src/agent-runner.ts +43 -5
- package/src/index.ts +239 -63
- package/src/schedule-store.ts +143 -0
- package/src/schedule.ts +365 -0
- package/src/settings.ts +14 -0
- package/src/types.ts +52 -0
- package/src/ui/agent-widget.ts +36 -6
- package/src/ui/conversation-viewer.ts +6 -6
- package/src/ui/schedule-menu.ts +104 -0
- package/src/usage.ts +60 -0
- package/.github/workflows/ci.yml +0 -21
- package/biome.json +0 -26
- package/dist/ui/conversation-viewer.test.d.ts +0 -1
- package/dist/ui/conversation-viewer.test.js +0 -254
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. */
|
package/dist/ui/agent-widget.js
CHANGED
|
@@ -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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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 {
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
}
|
package/dist/usage.d.ts
ADDED
|
@@ -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.
|
|
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
|
-
"@
|
|
25
|
-
"
|
|
26
|
-
"
|
|
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.
|
|
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"
|
package/src/agent-manager.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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,
|
|
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
|
-
|
|
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";
|