@gotgenes/pi-subagents 1.0.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/.markdownlint-cli2.yaml +19 -0
- package/.prettierignore +5 -0
- package/.release-please-manifest.json +3 -0
- package/AGENTS.md +85 -0
- package/CHANGELOG.md +495 -0
- package/LICENSE +21 -0
- package/README.md +528 -0
- package/dist/agent-manager.d.ts +108 -0
- package/dist/agent-manager.js +390 -0
- package/dist/agent-runner.d.ts +93 -0
- package/dist/agent-runner.js +428 -0
- package/dist/agent-types.d.ts +48 -0
- package/dist/agent-types.js +136 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +56 -0
- package/dist/cross-extension-rpc.d.ts +46 -0
- package/dist/cross-extension-rpc.js +54 -0
- package/dist/custom-agents.d.ts +14 -0
- package/dist/custom-agents.js +127 -0
- package/dist/default-agents.d.ts +7 -0
- package/dist/default-agents.js +119 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +28 -0
- package/dist/group-join.d.ts +32 -0
- package/dist/group-join.js +116 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1731 -0
- package/dist/invocation-config.d.ts +22 -0
- package/dist/invocation-config.js +15 -0
- package/dist/memory.d.ts +49 -0
- package/dist/memory.js +151 -0
- package/dist/model-resolver.d.ts +19 -0
- package/dist/model-resolver.js +62 -0
- package/dist/output-file.d.ts +24 -0
- package/dist/output-file.js +86 -0
- package/dist/prompts.d.ts +29 -0
- package/dist/prompts.js +72 -0
- 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 +66 -0
- package/dist/settings.js +130 -0
- package/dist/skill-loader.d.ts +24 -0
- package/dist/skill-loader.js +93 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +5 -0
- package/dist/ui/agent-widget.d.ts +134 -0
- package/dist/ui/agent-widget.js +451 -0
- package/dist/ui/conversation-viewer.d.ts +35 -0
- package/dist/ui/conversation-viewer.js +252 -0
- 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/dist/worktree.d.ts +36 -0
- package/dist/worktree.js +139 -0
- package/docs/decisions/0001-deferred-patches.md +75 -0
- package/package.json +68 -0
- package/prek.toml +24 -0
- package/release-please-config.json +22 -0
- package/src/agent-manager.ts +482 -0
- package/src/agent-runner.ts +625 -0
- package/src/agent-types.ts +164 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +95 -0
- package/src/custom-agents.ts +136 -0
- package/src/default-agents.ts +123 -0
- package/src/env.ts +33 -0
- package/src/group-join.ts +141 -0
- package/src/index.ts +1894 -0
- package/src/invocation-config.ts +40 -0
- package/src/memory.ts +165 -0
- package/src/model-resolver.ts +81 -0
- package/src/output-file.ts +96 -0
- package/src/prompts.ts +105 -0
- package/src/schedule-store.ts +143 -0
- package/src/schedule.ts +365 -0
- package/src/settings.ts +186 -0
- package/src/skill-loader.ts +102 -0
- package/src/types.ts +176 -0
- package/src/ui/agent-widget.ts +533 -0
- package/src/ui/conversation-viewer.ts +261 -0
- package/src/ui/schedule-menu.ts +104 -0
- package/src/usage.ts +60 -0
- package/src/worktree.ts +162 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Displays a scrollable, live-updating view of an agent's conversation.
|
|
5
|
+
* Subscribes to session events for real-time streaming updates.
|
|
6
|
+
*/
|
|
7
|
+
import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
|
+
import { extractText } from "../context.js";
|
|
9
|
+
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
10
|
+
import { buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
11
|
+
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
|
|
12
|
+
const CHROME_LINES_BASE = 6;
|
|
13
|
+
const MIN_VIEWPORT = 3;
|
|
14
|
+
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
15
|
+
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
16
|
+
export class ConversationViewer {
|
|
17
|
+
tui;
|
|
18
|
+
session;
|
|
19
|
+
record;
|
|
20
|
+
activity;
|
|
21
|
+
theme;
|
|
22
|
+
done;
|
|
23
|
+
scrollOffset = 0;
|
|
24
|
+
autoScroll = true;
|
|
25
|
+
unsubscribe;
|
|
26
|
+
lastInnerW = 0;
|
|
27
|
+
closed = false;
|
|
28
|
+
constructor(tui, session, record, activity, theme, done) {
|
|
29
|
+
this.tui = tui;
|
|
30
|
+
this.session = session;
|
|
31
|
+
this.record = record;
|
|
32
|
+
this.activity = activity;
|
|
33
|
+
this.theme = theme;
|
|
34
|
+
this.done = done;
|
|
35
|
+
this.unsubscribe = session.subscribe(() => {
|
|
36
|
+
if (this.closed)
|
|
37
|
+
return;
|
|
38
|
+
this.tui.requestRender();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
handleInput(data) {
|
|
42
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
43
|
+
this.closed = true;
|
|
44
|
+
this.done(undefined);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const totalLines = this.buildContentLines(this.lastInnerW).length;
|
|
48
|
+
const viewportHeight = this.viewportHeight();
|
|
49
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
50
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
51
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
52
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
53
|
+
}
|
|
54
|
+
else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
55
|
+
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
56
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
57
|
+
}
|
|
58
|
+
else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
|
|
59
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
60
|
+
this.autoScroll = false;
|
|
61
|
+
}
|
|
62
|
+
else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
|
|
63
|
+
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
64
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
65
|
+
}
|
|
66
|
+
else if (matchesKey(data, "home")) {
|
|
67
|
+
this.scrollOffset = 0;
|
|
68
|
+
this.autoScroll = false;
|
|
69
|
+
}
|
|
70
|
+
else if (matchesKey(data, "end")) {
|
|
71
|
+
this.scrollOffset = maxScroll;
|
|
72
|
+
this.autoScroll = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
render(width) {
|
|
76
|
+
if (width < 6)
|
|
77
|
+
return []; // too narrow for any meaningful rendering
|
|
78
|
+
const th = this.theme;
|
|
79
|
+
const innerW = width - 4; // border + padding
|
|
80
|
+
this.lastInnerW = innerW;
|
|
81
|
+
const lines = [];
|
|
82
|
+
const pad = (s, len) => {
|
|
83
|
+
const vis = visibleWidth(s);
|
|
84
|
+
return s + " ".repeat(Math.max(0, len - vis));
|
|
85
|
+
};
|
|
86
|
+
const row = (content) => th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
|
|
87
|
+
const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
|
|
88
|
+
const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
|
|
89
|
+
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
90
|
+
// Header
|
|
91
|
+
lines.push(hrTop);
|
|
92
|
+
const name = getDisplayName(this.record.type);
|
|
93
|
+
const modeLabel = getPromptModeLabel(this.record.type);
|
|
94
|
+
const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
|
|
95
|
+
const statusIcon = this.record.status === "running"
|
|
96
|
+
? th.fg("accent", "●")
|
|
97
|
+
: this.record.status === "completed"
|
|
98
|
+
? th.fg("success", "✓")
|
|
99
|
+
: this.record.status === "error"
|
|
100
|
+
? th.fg("error", "✗")
|
|
101
|
+
: th.fg("dim", "○");
|
|
102
|
+
const duration = formatDuration(this.record.startedAt, this.record.completedAt);
|
|
103
|
+
const headerParts = [duration];
|
|
104
|
+
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
|
|
105
|
+
if (toolUses > 0)
|
|
106
|
+
headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
|
|
107
|
+
const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
|
|
108
|
+
if (tokens > 0) {
|
|
109
|
+
const percent = getSessionContextPercent(this.activity?.session);
|
|
110
|
+
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
|
|
111
|
+
}
|
|
112
|
+
lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`));
|
|
113
|
+
const invocationLine = this.invocationLine();
|
|
114
|
+
if (invocationLine)
|
|
115
|
+
lines.push(row(invocationLine));
|
|
116
|
+
lines.push(hrMid);
|
|
117
|
+
// Content area — rebuild every render (live data, no cache needed)
|
|
118
|
+
const contentLines = this.buildContentLines(innerW);
|
|
119
|
+
const viewportHeight = this.viewportHeight();
|
|
120
|
+
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
121
|
+
if (this.autoScroll) {
|
|
122
|
+
this.scrollOffset = maxScroll;
|
|
123
|
+
}
|
|
124
|
+
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
125
|
+
const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
|
|
126
|
+
for (let i = 0; i < viewportHeight; i++) {
|
|
127
|
+
lines.push(row(visible[i] ?? ""));
|
|
128
|
+
}
|
|
129
|
+
// Footer
|
|
130
|
+
lines.push(hrMid);
|
|
131
|
+
const scrollPct = contentLines.length <= viewportHeight
|
|
132
|
+
? "100%"
|
|
133
|
+
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
134
|
+
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
135
|
+
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
|
|
136
|
+
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
137
|
+
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
138
|
+
lines.push(hrBot);
|
|
139
|
+
return lines;
|
|
140
|
+
}
|
|
141
|
+
invalidate() { }
|
|
142
|
+
dispose() {
|
|
143
|
+
this.closed = true;
|
|
144
|
+
if (this.unsubscribe) {
|
|
145
|
+
this.unsubscribe();
|
|
146
|
+
this.unsubscribe = undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// ---- Private ----
|
|
150
|
+
viewportHeight() {
|
|
151
|
+
// Cap mirrors the overlay's maxHeight — otherwise the viewer would render
|
|
152
|
+
// more lines than the overlay shows and clip the footer.
|
|
153
|
+
const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
|
|
154
|
+
return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
|
|
155
|
+
}
|
|
156
|
+
chromeLines() {
|
|
157
|
+
return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0);
|
|
158
|
+
}
|
|
159
|
+
invocationLine() {
|
|
160
|
+
const { modelName, tags } = buildInvocationTags(this.record.invocation);
|
|
161
|
+
const parts = modelName ? [modelName, ...tags] : tags;
|
|
162
|
+
if (parts.length === 0)
|
|
163
|
+
return undefined;
|
|
164
|
+
return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
|
|
165
|
+
}
|
|
166
|
+
buildContentLines(width) {
|
|
167
|
+
if (width <= 0)
|
|
168
|
+
return [];
|
|
169
|
+
const th = this.theme;
|
|
170
|
+
const messages = this.session.messages;
|
|
171
|
+
const lines = [];
|
|
172
|
+
if (messages.length === 0) {
|
|
173
|
+
lines.push(th.fg("dim", "(waiting for first message...)"));
|
|
174
|
+
return lines;
|
|
175
|
+
}
|
|
176
|
+
let needsSeparator = false;
|
|
177
|
+
for (const msg of messages) {
|
|
178
|
+
if (msg.role === "user") {
|
|
179
|
+
const text = typeof msg.content === "string"
|
|
180
|
+
? msg.content
|
|
181
|
+
: extractText(msg.content);
|
|
182
|
+
if (!text.trim())
|
|
183
|
+
continue;
|
|
184
|
+
if (needsSeparator)
|
|
185
|
+
lines.push(th.fg("dim", "───"));
|
|
186
|
+
lines.push(th.fg("accent", "[User]"));
|
|
187
|
+
for (const line of wrapTextWithAnsi(text.trim(), width)) {
|
|
188
|
+
lines.push(line);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (msg.role === "assistant") {
|
|
192
|
+
const textParts = [];
|
|
193
|
+
const toolCalls = [];
|
|
194
|
+
for (const c of msg.content) {
|
|
195
|
+
if (c.type === "text" && c.text)
|
|
196
|
+
textParts.push(c.text);
|
|
197
|
+
else if (c.type === "toolCall") {
|
|
198
|
+
toolCalls.push(c.name ?? c.toolName ?? "unknown");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (needsSeparator)
|
|
202
|
+
lines.push(th.fg("dim", "───"));
|
|
203
|
+
lines.push(th.bold("[Assistant]"));
|
|
204
|
+
if (textParts.length > 0) {
|
|
205
|
+
for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
|
|
206
|
+
lines.push(line);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const name of toolCalls) {
|
|
210
|
+
lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else if (msg.role === "toolResult") {
|
|
214
|
+
const text = extractText(msg.content);
|
|
215
|
+
const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
|
|
216
|
+
if (!truncated.trim())
|
|
217
|
+
continue;
|
|
218
|
+
if (needsSeparator)
|
|
219
|
+
lines.push(th.fg("dim", "───"));
|
|
220
|
+
lines.push(th.fg("dim", "[Result]"));
|
|
221
|
+
for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
|
|
222
|
+
lines.push(th.fg("dim", line));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else if (msg.role === "bashExecution") {
|
|
226
|
+
const bash = msg;
|
|
227
|
+
if (needsSeparator)
|
|
228
|
+
lines.push(th.fg("dim", "───"));
|
|
229
|
+
lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
|
|
230
|
+
if (bash.output?.trim()) {
|
|
231
|
+
const out = bash.output.length > 500
|
|
232
|
+
? bash.output.slice(0, 500) + "... (truncated)"
|
|
233
|
+
: bash.output;
|
|
234
|
+
for (const line of wrapTextWithAnsi(out.trim(), width)) {
|
|
235
|
+
lines.push(th.fg("dim", line));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
needsSeparator = true;
|
|
243
|
+
}
|
|
244
|
+
// Streaming indicator for running agents
|
|
245
|
+
if (this.record.status === "running" && this.activity) {
|
|
246
|
+
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
|
|
247
|
+
lines.push("");
|
|
248
|
+
lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
|
|
249
|
+
}
|
|
250
|
+
return lines.map(l => truncateToWidth(l, width));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -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 "@earendil-works/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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree.ts — Git worktree isolation for agents.
|
|
3
|
+
*
|
|
4
|
+
* Creates a temporary git worktree so the agent works on an isolated copy of the repo.
|
|
5
|
+
* On completion, if no changes were made, the worktree is cleaned up.
|
|
6
|
+
* If changes exist, a branch is created and returned in the result.
|
|
7
|
+
*/
|
|
8
|
+
export interface WorktreeInfo {
|
|
9
|
+
/** Absolute path to the worktree directory. */
|
|
10
|
+
path: string;
|
|
11
|
+
/** Branch name created for this worktree (if changes exist). */
|
|
12
|
+
branch: string;
|
|
13
|
+
}
|
|
14
|
+
export interface WorktreeCleanupResult {
|
|
15
|
+
/** Whether changes were found in the worktree. */
|
|
16
|
+
hasChanges: boolean;
|
|
17
|
+
/** Branch name if changes were committed. */
|
|
18
|
+
branch?: string;
|
|
19
|
+
/** Worktree path if it was kept. */
|
|
20
|
+
path?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a temporary git worktree for an agent.
|
|
24
|
+
* Returns the worktree path, or undefined if not in a git repo.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Clean up a worktree after agent completion.
|
|
29
|
+
* - If no changes: remove worktree entirely.
|
|
30
|
+
* - If changes exist: create a branch, commit changes, return branch info.
|
|
31
|
+
*/
|
|
32
|
+
export declare function cleanupWorktree(cwd: string, worktree: WorktreeInfo, agentDescription: string): WorktreeCleanupResult;
|
|
33
|
+
/**
|
|
34
|
+
* Prune any orphaned worktrees (crash recovery).
|
|
35
|
+
*/
|
|
36
|
+
export declare function pruneWorktrees(cwd: string): void;
|
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree.ts — Git worktree isolation for agents.
|
|
3
|
+
*
|
|
4
|
+
* Creates a temporary git worktree so the agent works on an isolated copy of the repo.
|
|
5
|
+
* On completion, if no changes were made, the worktree is cleaned up.
|
|
6
|
+
* If changes exist, a branch is created and returned in the result.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
/**
|
|
14
|
+
* Create a temporary git worktree for an agent.
|
|
15
|
+
* Returns the worktree path, or undefined if not in a git repo.
|
|
16
|
+
*/
|
|
17
|
+
export function createWorktree(cwd, agentId) {
|
|
18
|
+
// Verify we're in a git repo with at least one commit (HEAD must exist)
|
|
19
|
+
try {
|
|
20
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
21
|
+
execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const branch = `pi-agent-${agentId}`;
|
|
27
|
+
const suffix = randomUUID().slice(0, 8);
|
|
28
|
+
const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
|
|
29
|
+
try {
|
|
30
|
+
// Create detached worktree at HEAD
|
|
31
|
+
execFileSync("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], {
|
|
32
|
+
cwd,
|
|
33
|
+
stdio: "pipe",
|
|
34
|
+
timeout: 30000,
|
|
35
|
+
});
|
|
36
|
+
return { path: worktreePath, branch };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// If worktree creation fails, return undefined (agent runs in normal cwd)
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Clean up a worktree after agent completion.
|
|
45
|
+
* - If no changes: remove worktree entirely.
|
|
46
|
+
* - If changes exist: create a branch, commit changes, return branch info.
|
|
47
|
+
*/
|
|
48
|
+
export function cleanupWorktree(cwd, worktree, agentDescription) {
|
|
49
|
+
if (!existsSync(worktree.path)) {
|
|
50
|
+
return { hasChanges: false };
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
// Check for uncommitted changes in the worktree
|
|
54
|
+
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
55
|
+
cwd: worktree.path,
|
|
56
|
+
stdio: "pipe",
|
|
57
|
+
timeout: 10000,
|
|
58
|
+
}).toString().trim();
|
|
59
|
+
if (!status) {
|
|
60
|
+
// No changes — remove worktree
|
|
61
|
+
removeWorktree(cwd, worktree.path);
|
|
62
|
+
return { hasChanges: false };
|
|
63
|
+
}
|
|
64
|
+
// Changes exist — stage, commit, and create a branch
|
|
65
|
+
execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
|
|
66
|
+
// Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
|
|
67
|
+
const safeDesc = agentDescription.slice(0, 200);
|
|
68
|
+
const commitMsg = `pi-agent: ${safeDesc}`;
|
|
69
|
+
execFileSync("git", ["commit", "-m", commitMsg], {
|
|
70
|
+
cwd: worktree.path,
|
|
71
|
+
stdio: "pipe",
|
|
72
|
+
timeout: 10000,
|
|
73
|
+
});
|
|
74
|
+
// Create a branch pointing to the worktree's HEAD.
|
|
75
|
+
// If the branch already exists, append a suffix to avoid overwriting previous work.
|
|
76
|
+
let branchName = worktree.branch;
|
|
77
|
+
try {
|
|
78
|
+
execFileSync("git", ["branch", branchName], {
|
|
79
|
+
cwd: worktree.path,
|
|
80
|
+
stdio: "pipe",
|
|
81
|
+
timeout: 5000,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Branch already exists — use a unique suffix
|
|
86
|
+
branchName = `${worktree.branch}-${Date.now()}`;
|
|
87
|
+
execFileSync("git", ["branch", branchName], {
|
|
88
|
+
cwd: worktree.path,
|
|
89
|
+
stdio: "pipe",
|
|
90
|
+
timeout: 5000,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Update branch name in worktree info for the caller
|
|
94
|
+
worktree.branch = branchName;
|
|
95
|
+
// Remove the worktree (branch persists in main repo)
|
|
96
|
+
removeWorktree(cwd, worktree.path);
|
|
97
|
+
return {
|
|
98
|
+
hasChanges: true,
|
|
99
|
+
branch: worktree.branch,
|
|
100
|
+
path: worktree.path,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Best effort cleanup on error
|
|
105
|
+
try {
|
|
106
|
+
removeWorktree(cwd, worktree.path);
|
|
107
|
+
}
|
|
108
|
+
catch { /* ignore */ }
|
|
109
|
+
return { hasChanges: false };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Force-remove a worktree.
|
|
114
|
+
*/
|
|
115
|
+
function removeWorktree(cwd, worktreePath) {
|
|
116
|
+
try {
|
|
117
|
+
execFileSync("git", ["worktree", "remove", "--force", worktreePath], {
|
|
118
|
+
cwd,
|
|
119
|
+
stdio: "pipe",
|
|
120
|
+
timeout: 10000,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// If git worktree remove fails, try pruning
|
|
125
|
+
try {
|
|
126
|
+
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
127
|
+
}
|
|
128
|
+
catch { /* ignore */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Prune any orphaned worktrees (crash recovery).
|
|
133
|
+
*/
|
|
134
|
+
export function pruneWorktrees(cwd) {
|
|
135
|
+
try {
|
|
136
|
+
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
137
|
+
}
|
|
138
|
+
catch { /* ignore */ }
|
|
139
|
+
}
|