pi-jar 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -12
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +2 -1
- package/extensions/index.ts +212 -89
- package/package.json +1 -1
- package/src/activity-view.ts +252 -0
- package/src/advisor.ts +30 -6
- package/src/changes.ts +12 -20
- package/src/compact-tools.ts +62 -18
- package/src/composer.ts +36 -14
- package/src/delegate.ts +187 -23
- package/src/diff-view.ts +2 -2
- package/src/footer.ts +105 -18
- package/src/goal-loop.ts +34 -9
- package/src/goals.ts +13 -2
- package/src/history-ui.ts +2 -1
- package/src/icons.ts +57 -0
- package/src/info-panels.ts +19 -7
- package/src/plan.ts +37 -10
- package/src/prompt-search.ts +27 -8
- package/src/quota.ts +14 -4
- package/src/session-branch.ts +24 -0
- package/src/session-gallery.ts +10 -5
- package/src/session-ui.ts +119 -45
- package/src/settings-ui.ts +5 -2
- package/src/settings.ts +5 -1
- package/src/shells.ts +77 -99
- package/src/side-model.ts +14 -2
- package/src/status.ts +36 -24
- package/src/suggest.ts +6 -25
- package/src/task-tool.ts +35 -11
- package/src/tasks.ts +24 -7
- package/src/usage.ts +2 -1
- package/src/welcome.ts +32 -12
- package/src/working.ts +3 -1
package/src/shells.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import type { ExtensionAPI
|
|
3
|
-
import {
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import { safeLine } from "./diff-view.ts";
|
|
6
6
|
import { cleanText } from "./status.ts";
|
|
7
|
-
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
8
7
|
|
|
9
8
|
export const SHELL_TOOL = "jar_shell";
|
|
10
9
|
export const SHELL_MESSAGE = "pi-jar.shell";
|
|
11
10
|
export const MAX_RUNNING_SHELLS = 8;
|
|
12
11
|
const MAX_KEPT_SHELLS = 20;
|
|
13
12
|
const MAX_LINES = 2000;
|
|
13
|
+
const LINE_PRUNE_BATCH = 128;
|
|
14
14
|
const MAX_LINE_CHARS = 2000;
|
|
15
|
+
const MAX_RETAINED_CHARS = 1024 * 1024;
|
|
16
|
+
const MAX_TOOL_OUTPUT_CHARS = 32 * 1024;
|
|
15
17
|
const KILL_GRACE_MS = 3000;
|
|
16
18
|
|
|
17
19
|
export type ShellStatus = "running" | "exited" | "killed" | "failed";
|
|
@@ -44,14 +46,25 @@ const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
|
|
|
44
46
|
export class ShellManager {
|
|
45
47
|
private jobs = new Map<string, { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> }>();
|
|
46
48
|
private next = 1;
|
|
49
|
+
private lineChars = new Map<string, number>();
|
|
47
50
|
private readonly onEvent: (event: ShellEvent) => void;
|
|
48
51
|
private readonly spawnShell: typeof spawn;
|
|
49
52
|
onChange?: () => void;
|
|
50
53
|
constructor(onEvent: (event: ShellEvent) => void, spawnShell: typeof spawn = spawn) { this.onEvent = onEvent; this.spawnShell = spawnShell; }
|
|
51
54
|
|
|
52
55
|
list(): ShellJob[] { return [...this.jobs.values()].map(({ job }) => ({ ...job, lines: [...job.lines] })); }
|
|
56
|
+
summaries(): Array<Omit<ShellJob, "lines">> {
|
|
57
|
+
return [...this.jobs.values()].map(({ job }) => {
|
|
58
|
+
const { lines: _lines, ...summary } = job;
|
|
59
|
+
return { ...summary };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
53
62
|
get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }
|
|
54
|
-
running(): number {
|
|
63
|
+
running(): number {
|
|
64
|
+
let count = 0;
|
|
65
|
+
for (const { job } of this.jobs.values()) if (job.status === "running") count++;
|
|
66
|
+
return count;
|
|
67
|
+
}
|
|
55
68
|
|
|
56
69
|
start(options: StartOptions): ShellJob {
|
|
57
70
|
const command = options.command.trim();
|
|
@@ -68,6 +81,7 @@ export class ShellManager {
|
|
|
68
81
|
notify: options.notify ?? true, lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
|
|
69
82
|
const entry: { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> } = { job, partial: "", ...(pattern ? { pattern } : {}) };
|
|
70
83
|
this.jobs.set(id, entry);
|
|
84
|
+
this.lineChars.set(id, 0);
|
|
71
85
|
// Own process group so kill() stops the whole tree (dev servers spawn children).
|
|
72
86
|
const child = this.spawnShell("/bin/sh", ["-c", command], { cwd: options.cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
73
87
|
entry.child = child;
|
|
@@ -105,16 +119,26 @@ export class ShellManager {
|
|
|
105
119
|
const line = raw.replace(ANSI, "").replace(/\r/g, "").slice(0, MAX_LINE_CHARS);
|
|
106
120
|
const { job } = entry;
|
|
107
121
|
job.lines.push(line);
|
|
108
|
-
|
|
122
|
+
this.lineChars.set(job.id, (this.lineChars.get(job.id) ?? 0) + line.length);
|
|
123
|
+
// Bound both line count and retained characters. A shell that prints very wide lines should
|
|
124
|
+
// not quietly reserve several MiB forever just because it has not reached MAX_LINES yet.
|
|
125
|
+
while (job.lines.length > MAX_LINES + LINE_PRUNE_BATCH || (this.lineChars.get(job.id) ?? 0) > MAX_RETAINED_CHARS) {
|
|
126
|
+
const removed = job.lines.splice(0, Math.min(LINE_PRUNE_BATCH, job.lines.length));
|
|
127
|
+
let removedChars = 0;
|
|
128
|
+
for (const item of removed) removedChars += item.length;
|
|
129
|
+
this.lineChars.set(job.id, Math.max(0, (this.lineChars.get(job.id) ?? 0) - removedChars));
|
|
130
|
+
job.dropped += removed.length;
|
|
131
|
+
if (!removed.length) break;
|
|
132
|
+
}
|
|
109
133
|
if (entry.pattern && !job.matched && entry.pattern.test(line)) {
|
|
110
134
|
job.matched = line;
|
|
111
|
-
this.onEvent({ kind: "match", job: { ...job, lines:
|
|
135
|
+
this.onEvent({ kind: "match", job: { ...job, lines: job.lines.slice(-20) } });
|
|
112
136
|
}
|
|
113
137
|
}
|
|
114
138
|
|
|
115
139
|
private finish(entry: { job: ShellJob; timer?: ReturnType<typeof setTimeout> }): void {
|
|
116
140
|
if (entry.timer) clearTimeout(entry.timer);
|
|
117
|
-
this.onEvent({ kind: "exit", job: { ...entry.job, lines:
|
|
141
|
+
this.onEvent({ kind: "exit", job: { ...entry.job, lines: entry.job.lines.slice(-20) } });
|
|
118
142
|
this.onChange?.();
|
|
119
143
|
}
|
|
120
144
|
|
|
@@ -122,8 +146,10 @@ export class ShellManager {
|
|
|
122
146
|
output(id: string, count = 40): string[] {
|
|
123
147
|
const entry = this.jobs.get(id);
|
|
124
148
|
if (!entry) throw new Error("no shell " + id);
|
|
125
|
-
const
|
|
126
|
-
return lines.slice(-
|
|
149
|
+
const limit = Math.max(1, Math.min(400, Math.floor(count)));
|
|
150
|
+
if (!entry.partial) return entry.job.lines.slice(-limit);
|
|
151
|
+
if (limit === 1) return [entry.partial];
|
|
152
|
+
return [...entry.job.lines.slice(-(limit - 1)), entry.partial];
|
|
127
153
|
}
|
|
128
154
|
|
|
129
155
|
kill(id: string): boolean {
|
|
@@ -147,22 +173,42 @@ export class ShellManager {
|
|
|
147
173
|
/** Forget finished jobs beyond the retention limit (oldest first). */
|
|
148
174
|
private prune(): void {
|
|
149
175
|
const finished = [...this.jobs.values()].filter(({ job }) => job.status !== "running");
|
|
150
|
-
for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1)))
|
|
176
|
+
for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1))) {
|
|
177
|
+
this.jobs.delete(job.id);
|
|
178
|
+
this.lineChars.delete(job.id);
|
|
179
|
+
}
|
|
151
180
|
}
|
|
152
181
|
|
|
153
182
|
dispose(): void {
|
|
154
183
|
for (const id of this.jobs.keys()) { try { this.kill(id); } catch { /* already gone */ } }
|
|
155
184
|
this.jobs.clear();
|
|
185
|
+
this.lineChars.clear();
|
|
156
186
|
}
|
|
157
187
|
}
|
|
158
188
|
|
|
159
|
-
|
|
160
|
-
|
|
189
|
+
type ShellMeta = Omit<ShellJob, "lines">;
|
|
190
|
+
/** `12s` / `3m 4s` since `startedAt`, up to `endedAt` once finished. */
|
|
191
|
+
export const elapsed = (span: { startedAt: number; endedAt?: number }, now = Date.now()) => {
|
|
192
|
+
const seconds = Math.max(0, Math.round(((span.endedAt ?? now) - span.startedAt) / 1000));
|
|
161
193
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
162
194
|
};
|
|
163
|
-
export const shellState = (job: ShellJob) => job.status === "running" ? "running"
|
|
195
|
+
export const shellState = (job: ShellMeta | ShellJob) => job.status === "running" ? "running"
|
|
164
196
|
: job.status === "exited" ? `exited ${job.exitCode ?? "?"}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
|
|
165
|
-
const describe = (job: ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
|
|
197
|
+
export const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
|
|
198
|
+
const boundedTail = (lines: readonly string[], limit = MAX_TOOL_OUTPUT_CHARS): string => {
|
|
199
|
+
let size = 0;
|
|
200
|
+
const kept: string[] = [];
|
|
201
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
202
|
+
const line = lines[index]!;
|
|
203
|
+
const cost = line.length + (kept.length ? 1 : 0);
|
|
204
|
+
if (size + cost > limit) break;
|
|
205
|
+
kept.push(line);
|
|
206
|
+
size += cost;
|
|
207
|
+
}
|
|
208
|
+
kept.reverse();
|
|
209
|
+
const omitted = lines.length - kept.length;
|
|
210
|
+
return (omitted ? `… ${omitted} earlier line(s) omitted\n` : "") + kept.join("\n");
|
|
211
|
+
};
|
|
166
212
|
|
|
167
213
|
/** The message that wakes the agent when a watched shell matches or ends. */
|
|
168
214
|
export function shellEventMessage(event: ShellEvent): string {
|
|
@@ -198,7 +244,9 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
|
|
|
198
244
|
parameters: Parameters,
|
|
199
245
|
async execute(_id, params, _signal, _update, ctx) {
|
|
200
246
|
const manager = shells();
|
|
201
|
-
const reply = (text: string
|
|
247
|
+
const reply = (text: string) => ({ content: [{ type: "text" as const, text: text.slice(0, MAX_TOOL_OUTPUT_CHARS) }], details: {
|
|
248
|
+
jobs: (manager?.summaries() ?? []).map((job) => ({ id: job.id, name: job.name, status: job.status, pid: job.pid, exitCode: job.exitCode }))
|
|
249
|
+
} });
|
|
202
250
|
if (!manager) return reply("Background shells are unavailable before a Pi session starts.");
|
|
203
251
|
try {
|
|
204
252
|
switch (params.action) {
|
|
@@ -210,14 +258,14 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
|
|
|
210
258
|
case "output": {
|
|
211
259
|
if (!params.id) return reply("output needs an id.");
|
|
212
260
|
const lines = manager.output(params.id, params.lines ?? 40);
|
|
213
|
-
return reply(`${describe(manager.get(params.id)!)}\n${lines.length ? lines
|
|
261
|
+
return reply(`${describe(manager.get(params.id)!)}\n${lines.length ? boundedTail(lines) : "(no output yet)"}`);
|
|
214
262
|
}
|
|
215
263
|
case "kill":
|
|
216
264
|
if (!params.id) return reply("kill needs an id.");
|
|
217
265
|
return reply(manager.kill(params.id) ? `Stopping ${params.id}.` : `${params.id} is not running.`);
|
|
218
266
|
default: {
|
|
219
|
-
const jobs = manager.
|
|
220
|
-
return reply(jobs.length ? jobs.map(describe).join("\n") : "No background shells."
|
|
267
|
+
const jobs = manager.summaries();
|
|
268
|
+
return reply(jobs.length ? jobs.map(describe).join("\n") : "No background shells.");
|
|
221
269
|
}
|
|
222
270
|
}
|
|
223
271
|
} catch (error) {
|
|
@@ -232,88 +280,18 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
|
|
|
232
280
|
return new Text(text, 0, 0);
|
|
233
281
|
},
|
|
234
282
|
renderResult(result, { expanded }, theme) {
|
|
235
|
-
const text = result.content.
|
|
236
|
-
|
|
237
|
-
|
|
283
|
+
const text = result.content.find((part) => part.type === "text")?.text ?? "";
|
|
284
|
+
if (expanded) {
|
|
285
|
+
const lines = text.split("\n");
|
|
286
|
+
return new Text(lines.map((line, index) => theme.fg(index ? "muted" : "accent", safeLine(line))).join("\n"), 0, 0);
|
|
287
|
+
}
|
|
288
|
+
// Historical shell cards are rebuilt on resume. Split only enough text to draw the collapsed
|
|
289
|
+
// card instead of counting every stored output line.
|
|
290
|
+
const sample = text.split("\n", 7);
|
|
291
|
+
const more = sample.length > 6;
|
|
292
|
+
const shown = sample.slice(0, 6);
|
|
238
293
|
return new Text(shown.map((line, index) => theme.fg(index ? "muted" : "accent", safeLine(line))).join("\n")
|
|
239
|
-
+ (
|
|
294
|
+
+ (more ? "\n" + theme.fg("dim", "… more lines (expand)") : ""), 0, 0);
|
|
240
295
|
}
|
|
241
296
|
});
|
|
242
297
|
}
|
|
243
|
-
|
|
244
|
-
const SHELL_ACTIONS = [
|
|
245
|
-
{ key: "x", label: "Kill the selected shell" },
|
|
246
|
-
{ key: "f", label: "Follow the latest output" }
|
|
247
|
-
] as const;
|
|
248
|
-
|
|
249
|
-
/** Overlay: shells on the left, the selected shell's live output on the right; k kills. */
|
|
250
|
-
export async function openShellsView(ctx: ExtensionContext, manager: ShellManager): Promise<void> {
|
|
251
|
-
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
252
|
-
if (!manager.list().length) { ctx.ui.notify("pi-jar: no background shells (the agent starts them with jar_shell)", "info"); return; }
|
|
253
|
-
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
254
|
-
let selected = 0;
|
|
255
|
-
let follow = true;
|
|
256
|
-
let scroll = 0;
|
|
257
|
-
let rows = 0;
|
|
258
|
-
let width = 80;
|
|
259
|
-
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
260
|
-
const fg = (color: string, text: string) => theme.fg(color as never, text);
|
|
261
|
-
const previous = manager.onChange;
|
|
262
|
-
const timer = setInterval(() => tui.requestRender(), 500);
|
|
263
|
-
timer.unref?.();
|
|
264
|
-
manager.onChange = () => { previous?.(); tui.requestRender(); };
|
|
265
|
-
const close = () => { clearInterval(timer); manager.onChange = previous; done(); };
|
|
266
|
-
const component = {
|
|
267
|
-
invalidate() {},
|
|
268
|
-
handleInput(data: string) {
|
|
269
|
-
const jobs = manager.list();
|
|
270
|
-
if (matchesKey(data, Key.escape) || data === "q") return close();
|
|
271
|
-
if (matchesKey(data, Key.up) || data === "k") selected = Math.max(0, selected - 1);
|
|
272
|
-
else if (matchesKey(data, Key.down) || data === "j") selected = Math.min(jobs.length - 1, selected + 1);
|
|
273
|
-
else if (data === "x" || data === "K") { const job = jobs[selected]; if (job) { try { manager.kill(job.id); } catch (error) { ctx.ui.notify("pi-jar: " + String(error), "error"); } } }
|
|
274
|
-
else if (matchesKey(data, Key.pageUp)) { follow = false; scroll = Math.max(0, scroll - Math.max(1, rows - 2)); }
|
|
275
|
-
else if (matchesKey(data, Key.pageDown)) scroll += Math.max(1, rows - 2);
|
|
276
|
-
else if (data === "f" || data === "G") follow = true;
|
|
277
|
-
tui.requestRender();
|
|
278
|
-
},
|
|
279
|
-
handleMouse(event: TuiMouseEvent) {
|
|
280
|
-
const row = event.y - layout.top;
|
|
281
|
-
if (event.type === "wheel" && event.wheelDelta) { follow = false; scroll = Math.max(0, scroll + Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
|
|
282
|
-
if (event.type !== "click" || event.button !== "left") return;
|
|
283
|
-
if (event.y === 0 && event.x >= width - 3) { close(); return { handled: true }; }
|
|
284
|
-
if (row >= 0 && row < layout.rows && layout.leftWidth && event.x < layout.leftWidth + 3 && row < manager.list().length) { selected = row; follow = true; tui.requestRender(); return { handled: true, focus: true }; }
|
|
285
|
-
const action = SHELL_ACTIONS[event.y - layout.footerTop];
|
|
286
|
-
if (action) { component.handleInput(action.key); return { handled: true }; }
|
|
287
|
-
},
|
|
288
|
-
render(available: number): string[] {
|
|
289
|
-
width = Math.max(24, available);
|
|
290
|
-
rows = contentRows(6 + SHELL_ACTIONS.length, 6);
|
|
291
|
-
const jobs = manager.list();
|
|
292
|
-
selected = Math.min(selected, Math.max(0, jobs.length - 1));
|
|
293
|
-
const job = jobs[selected];
|
|
294
|
-
const listWidth = sidebarWidth(width, 20, 34);
|
|
295
|
-
const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
|
|
296
|
-
const output = job ? manager.output(job.id, 400) : [];
|
|
297
|
-
const maxScroll = Math.max(0, output.length - rows);
|
|
298
|
-
scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
|
|
299
|
-
if (scroll >= maxScroll) follow = true;
|
|
300
|
-
const list = jobs.slice(0, rows).map((item, index) => {
|
|
301
|
-
const color = item.status === "running" ? "accent" : item.status === "exited" && item.exitCode === 0 ? "success" : item.status === "killed" ? "dim" : "error";
|
|
302
|
-
const glyph = item.status === "running" ? "●" : item.status === "exited" && item.exitCode === 0 ? "✔" : item.status === "killed" ? "■" : "✖";
|
|
303
|
-
return fg(index === selected ? "accent" : "muted", (index === selected ? "▌" : " ")) + fg(color, glyph + " ") + fg(index === selected ? "accent" : "muted", truncateToWidth(`${item.id} ${item.name}`, Math.max(4, listWidth - 4)));
|
|
304
|
-
});
|
|
305
|
-
const body = output.slice(scroll, scroll + rows).map((line) => fg("muted", truncateToWidth(safeLine(line), bodyWidth)));
|
|
306
|
-
if (job && !output.length) body.push(fg("dim", "(no output yet)"));
|
|
307
|
-
const title = `⚙ SHELLS · ${manager.running()} running` + (job ? ` · ${describe(job)}` : "");
|
|
308
|
-
const footer = [
|
|
309
|
-
...optionList(theme, SHELL_ACTIONS.map((item) => item.label), -1, SHELL_ACTIONS.map((item) => item.key)),
|
|
310
|
-
fg("dim", (job ? "$ " + truncateToWidth(cleanText(job.command, 400), Math.max(8, width - 60)) + " " : "") + "↑↓ shell · PgUp/PgDn scroll · Esc close")
|
|
311
|
-
];
|
|
312
|
-
const split = splitFrame(theme, width, title, list, body, footer, rows, listWidth);
|
|
313
|
-
layout = split.layout;
|
|
314
|
-
return split.lines;
|
|
315
|
-
}
|
|
316
|
-
};
|
|
317
|
-
return component;
|
|
318
|
-
}, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
|
|
319
|
-
}
|
package/src/side-model.ts
CHANGED
|
@@ -16,7 +16,10 @@ export interface SideCall {
|
|
|
16
16
|
/** Session-scoped record of side calls, so /usage can show what they cost. */
|
|
17
17
|
export class SideUsage {
|
|
18
18
|
private calls: SideCall[] = [];
|
|
19
|
-
add(call: SideCall): void {
|
|
19
|
+
add(call: SideCall): void {
|
|
20
|
+
this.calls.push(call);
|
|
21
|
+
if (this.calls.length > 512) this.calls.splice(0, 64);
|
|
22
|
+
}
|
|
20
23
|
all(): readonly SideCall[] { return this.calls; }
|
|
21
24
|
clear(): void { this.calls = []; }
|
|
22
25
|
}
|
|
@@ -42,7 +45,16 @@ export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, us
|
|
|
42
45
|
usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
|
|
43
46
|
cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
|
|
44
47
|
if (result.stopReason === "error" || result.stopReason === "aborted") throw new Error(result.errorMessage || `${name} ${result.stopReason}`);
|
|
45
|
-
const
|
|
48
|
+
const MAX_ANSWER_CHARS = 32 * 1024;
|
|
49
|
+
let text = "";
|
|
50
|
+
for (const part of result.content) {
|
|
51
|
+
if (part.type !== "text") continue;
|
|
52
|
+
const value = (part as { text: string }).text;
|
|
53
|
+
const room = MAX_ANSWER_CHARS - text.length;
|
|
54
|
+
if (room <= 0) break;
|
|
55
|
+
text += value.slice(0, room);
|
|
56
|
+
}
|
|
57
|
+
text = text.trim();
|
|
46
58
|
if (!text) throw new Error(`${name} returned an empty answer`);
|
|
47
59
|
return { text, model: name };
|
|
48
60
|
}
|
package/src/status.ts
CHANGED
|
@@ -24,6 +24,36 @@ export interface JarStatus {
|
|
|
24
24
|
extras: string[];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
const ROLE_CACHE_LIMIT = 64;
|
|
28
|
+
const roleCache = new Map<string, JarRole | null>();
|
|
29
|
+
|
|
30
|
+
function cachedRole(id: string, rawValue: string): JarRole | undefined {
|
|
31
|
+
const cacheKey = id + "\0" + rawValue;
|
|
32
|
+
if (roleCache.has(cacheKey)) return roleCache.get(cacheKey) ?? undefined;
|
|
33
|
+
let parsed: JarRole | undefined;
|
|
34
|
+
try {
|
|
35
|
+
if (rawValue.length > 4096) throw new Error("oversized status");
|
|
36
|
+
const data: unknown = JSON.parse(rawValue);
|
|
37
|
+
if (!data || typeof data !== "object") throw new Error("invalid status");
|
|
38
|
+
const role = data as Record<string, unknown>;
|
|
39
|
+
if (typeof role.name !== "string" || typeof role.state !== "string" || !STATES.has(role.state as RoleState)) throw new Error("invalid role");
|
|
40
|
+
const expiresAt = role.expiresAt;
|
|
41
|
+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) throw new Error("invalid expiry");
|
|
42
|
+
const name = cleanText(role.name, 32) || id;
|
|
43
|
+
parsed = {
|
|
44
|
+
id,
|
|
45
|
+
name,
|
|
46
|
+
label: cleanText(typeof role.label === "string" ? role.label : name, 12) || id,
|
|
47
|
+
state: role.state as RoleState,
|
|
48
|
+
task: typeof role.task === "string" ? cleanText(role.task, 48) : undefined,
|
|
49
|
+
expiresAt
|
|
50
|
+
};
|
|
51
|
+
} catch { parsed = undefined; }
|
|
52
|
+
roleCache.set(cacheKey, parsed ?? null);
|
|
53
|
+
if (roleCache.size > ROLE_CACHE_LIMIT) roleCache.delete(roleCache.keys().next().value!);
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
|
|
27
57
|
/** Explicit opt-in contract; never guess teammate state from arbitrary status prose. */
|
|
28
58
|
export function collectStatuses(statuses: ReadonlyMap<string, string>, now: number): JarStatus {
|
|
29
59
|
const roles: JarRole[] = [];
|
|
@@ -32,6 +62,8 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
|
|
|
32
62
|
const key = cleanText(rawKey, 48);
|
|
33
63
|
if (!key || typeof rawValue !== "string") continue;
|
|
34
64
|
if (rawKey.startsWith("pi-jar.quota.")) continue;
|
|
65
|
+
// SoL-Pi already announces each saving in its own notification; reprinting it here duplicates it.
|
|
66
|
+
if (rawKey.startsWith("sol-pi")) continue;
|
|
35
67
|
if (!rawKey.startsWith(ROLE_PREFIX)) {
|
|
36
68
|
const value = cleanText(rawValue);
|
|
37
69
|
// pi-jar's own indicators (such as `role:default`) are already self-describing.
|
|
@@ -40,32 +72,12 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
|
|
|
40
72
|
}
|
|
41
73
|
const id = rawKey.slice(ROLE_PREFIX.length);
|
|
42
74
|
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(id)) continue;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const data: unknown = JSON.parse(rawValue);
|
|
46
|
-
if (!data || typeof data !== "object") throw new Error("invalid status");
|
|
47
|
-
const role = data as Record<string, unknown>;
|
|
48
|
-
if (typeof role.name !== "string" || typeof role.state !== "string" || !STATES.has(role.state as RoleState)) {
|
|
49
|
-
throw new Error("invalid role");
|
|
50
|
-
}
|
|
51
|
-
const state = role.state as RoleState;
|
|
52
|
-
const expiresAt = role.expiresAt;
|
|
53
|
-
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= now || expiresAt > now + ROLE_TTL_MS) {
|
|
54
|
-
extras.push(`${id}: unavailable`);
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
const name = cleanText(role.name, 32) || id;
|
|
58
|
-
roles.push({
|
|
59
|
-
id,
|
|
60
|
-
name,
|
|
61
|
-
label: cleanText(typeof role.label === "string" ? role.label : name, 12) || id,
|
|
62
|
-
state,
|
|
63
|
-
task: typeof role.task === "string" ? cleanText(role.task, 48) : undefined,
|
|
64
|
-
expiresAt: typeof expiresAt === "number" ? expiresAt : undefined
|
|
65
|
-
});
|
|
66
|
-
} catch {
|
|
75
|
+
const role = cachedRole(id, rawValue);
|
|
76
|
+
if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ROLE_TTL_MS) {
|
|
67
77
|
extras.push(`${id}: unavailable`);
|
|
78
|
+
continue;
|
|
68
79
|
}
|
|
80
|
+
roles.push(role);
|
|
69
81
|
}
|
|
70
82
|
return { roles: roles.slice(0, 16), extras: extras.slice(0, 16) };
|
|
71
83
|
}
|
package/src/suggest.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { Type } from "typebox";
|
|
|
4
4
|
import { cleanText } from "./status.ts";
|
|
5
5
|
|
|
6
6
|
export const SUGGEST_TOOL = "jar_suggest";
|
|
7
|
-
const REMINDER_TYPE = "pi-jar.suggest-reminder";
|
|
8
7
|
const MAX_SUGGESTION = 160;
|
|
9
8
|
|
|
10
9
|
/** The next-prompt suggestion shown as ghost text in an empty composer. Session-only, never persisted. */
|
|
@@ -25,27 +24,22 @@ export class SuggestionState {
|
|
|
25
24
|
|
|
26
25
|
export interface SuggestionOptions {
|
|
27
26
|
enabled: () => boolean;
|
|
28
|
-
/** Skip enforcement while another workflow owns the next step (plan review, goal loop). */
|
|
29
|
-
skip: () => boolean;
|
|
30
27
|
}
|
|
31
28
|
|
|
32
29
|
/** Ask the agent for one next-prompt suggestion per finished request, like an inline autocomplete. */
|
|
33
30
|
export function registerSuggestions(pi: ExtensionAPI, state: SuggestionState, options: SuggestionOptions): { sync(): void } {
|
|
34
|
-
let suggested = false;
|
|
35
|
-
let reminded = false;
|
|
36
31
|
pi.registerTool?.({
|
|
37
32
|
name: SUGGEST_TOOL,
|
|
38
33
|
label: "suggest",
|
|
39
|
-
description: "
|
|
40
|
-
promptSnippet: "
|
|
34
|
+
description: "Optionally offer one useful next prompt as ghost text in the input box; Tab accepts it for editing.",
|
|
35
|
+
promptSnippet: "Optionally use jar_suggest at the end when one genuinely useful next prompt would help the user continue.",
|
|
41
36
|
promptGuidelines: [
|
|
42
|
-
"
|
|
43
|
-
"
|
|
37
|
+
"jar_suggest is best-effort, not mandatory. Use it only when there is one clear next action worth surfacing; skip it for routine completions.",
|
|
38
|
+
"When used, call it once as the final action with one imperative line under 120 characters. Do not mention jar_suggest in your reply."
|
|
44
39
|
],
|
|
45
40
|
parameters: Type.Object({ suggestion: Type.String({ description: "The user's likely next prompt, one line." }) }),
|
|
46
41
|
execute: async (_id, params) => {
|
|
47
42
|
const ok = state.set(params.suggestion ?? "");
|
|
48
|
-
suggested ||= ok;
|
|
49
43
|
return { content: [{ type: "text", text: ok ? "Suggestion shown to the user. End your turn now." : "Suggestion was empty; skipped." }], details: { suggestion: state.text }, terminate: true };
|
|
50
44
|
},
|
|
51
45
|
renderCall() { return new Text("", 0, 0); },
|
|
@@ -66,24 +60,11 @@ export function registerSuggestions(pi: ExtensionAPI, state: SuggestionState, op
|
|
|
66
60
|
|
|
67
61
|
pi.on("input", (event) => {
|
|
68
62
|
if (event.source !== "interactive") return;
|
|
69
|
-
suggested = false;
|
|
70
|
-
reminded = false;
|
|
71
63
|
state.clear();
|
|
72
64
|
});
|
|
73
65
|
pi.on("agent_start", () => { state.clear(); });
|
|
74
|
-
pi.on("session_start", () => {
|
|
66
|
+
pi.on("session_start", () => { state.clear(); });
|
|
75
67
|
pi.on("session_tree", () => { state.clear(); });
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
pi.on("agent_before_settle", (event) => {
|
|
79
|
-
if (!options.enabled() || options.skip() || event.continue || event.outcome !== "completed" || suggested || reminded) return;
|
|
80
|
-
if (typeof pi.getActiveTools === "function" && !pi.getActiveTools().includes(SUGGEST_TOOL)) return;
|
|
81
|
-
reminded = true;
|
|
82
|
-
return {
|
|
83
|
-
entries: [{ type: "custom_message", customType: REMINDER_TYPE, display: false,
|
|
84
|
-
content: "[PI-JAR] Call jar_suggest now with the single most useful next prompt for the user. Do not add any other text." }],
|
|
85
|
-
continue: true
|
|
86
|
-
};
|
|
87
|
-
});
|
|
68
|
+
// No settle hook: suggestions must not add a provider turn or a hidden session entry.
|
|
88
69
|
return { sync };
|
|
89
70
|
}
|
package/src/task-tool.ts
CHANGED
|
@@ -22,14 +22,27 @@ const Parameters = Type.Object({
|
|
|
22
22
|
|
|
23
23
|
interface TaskToolDetails {
|
|
24
24
|
action: string;
|
|
25
|
+
/** Bounded preview only; the canonical list lives in TodoStore. */
|
|
25
26
|
items: Todo[];
|
|
27
|
+
total: number;
|
|
28
|
+
done: number;
|
|
29
|
+
current?: string;
|
|
26
30
|
changed?: string;
|
|
31
|
+
truncated?: boolean;
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
const stats = (items: readonly Todo[]) => {
|
|
35
|
+
let done = 0;
|
|
36
|
+
let current: Todo | undefined;
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
if (item.done) done++;
|
|
39
|
+
if (!current && item.status === "in_progress") current = item;
|
|
40
|
+
}
|
|
41
|
+
return { total: items.length, done, current };
|
|
42
|
+
};
|
|
29
43
|
const summary = (items: readonly Todo[]) => {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
return `${done}/${items.length} done` + (current ? ` · now: ${current.title}` : "");
|
|
44
|
+
const value = stats(items);
|
|
45
|
+
return `${value.done}/${value.total} done` + (value.current ? ` · now: ${value.current.title}` : "");
|
|
33
46
|
};
|
|
34
47
|
|
|
35
48
|
const lines = (items: readonly Todo[]) => items.length
|
|
@@ -60,7 +73,7 @@ export function registerTaskTool(
|
|
|
60
73
|
const current = store();
|
|
61
74
|
const action = params.todos ? "write" : params.action ?? "list";
|
|
62
75
|
if (!current) {
|
|
63
|
-
return { content: [{ type: "text", text: "Task tracking is unavailable before a Pi session starts." }], details: { action, items: [] } satisfies TaskToolDetails };
|
|
76
|
+
return { content: [{ type: "text", text: "Task tracking is unavailable before a Pi session starts." }], details: { action, items: [], total: 0, done: 0 } satisfies TaskToolDetails };
|
|
64
77
|
}
|
|
65
78
|
let changedId: string | undefined;
|
|
66
79
|
let error: string | undefined;
|
|
@@ -94,12 +107,22 @@ export function registerTaskTool(
|
|
|
94
107
|
}
|
|
95
108
|
if (action !== "list" && !error) changed(ctx);
|
|
96
109
|
const items = current.all();
|
|
110
|
+
const state = stats(items);
|
|
111
|
+
const changedItem = changedId ? items.find((item) => item.id === changedId) : undefined;
|
|
97
112
|
const message = error
|
|
98
|
-
? `Could not ${action} tasks: ${error}
|
|
99
|
-
:
|
|
113
|
+
? `Could not ${action} tasks: ${error} (${summary(items)}).`
|
|
114
|
+
: action === "list"
|
|
115
|
+
? `Tasks (${summary(items)}):\n${lines(items)}`
|
|
116
|
+
: `Task list updated (${summary(items)}).${changedItem ? `\n${todoMark(changedItem)} [${changedItem.status}] ${changedItem.title} (${changedItem.id})` : ""}\n${REMINDER}`;
|
|
117
|
+
const preview = items.slice(0, 8);
|
|
100
118
|
return {
|
|
101
119
|
content: [{ type: "text", text: message }],
|
|
102
|
-
details: {
|
|
120
|
+
details: {
|
|
121
|
+
action, items: preview, total: state.total, done: state.done,
|
|
122
|
+
...(state.current ? { current: state.current.title } : {}),
|
|
123
|
+
...(preview.length < items.length ? { truncated: true } : {}),
|
|
124
|
+
...(changedId ? { changed: changedId } : {})
|
|
125
|
+
} satisfies TaskToolDetails
|
|
103
126
|
};
|
|
104
127
|
},
|
|
105
128
|
renderCall(args, theme) {
|
|
@@ -115,12 +138,13 @@ export function registerTaskTool(
|
|
|
115
138
|
if (isPartial) return new Text(theme.fg("warning", "Updating tasks…"), 0, 0);
|
|
116
139
|
const details = result.details as TaskToolDetails | undefined;
|
|
117
140
|
const items = details?.items ?? [];
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
const total = details?.total ?? items.length;
|
|
142
|
+
const done = details?.done ?? items.filter((item) => item.done).length;
|
|
143
|
+
if (!total) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
|
|
120
144
|
const shown = expanded ? items : items.slice(0, 8);
|
|
121
|
-
let text = theme.fg("dim",
|
|
145
|
+
let text = theme.fg("dim", `${done}/${total} done${details?.current ? " · now: " + details.current : ""}`);
|
|
122
146
|
for (const item of shown) text += "\n" + todoRow(item, (color, value) => theme.fg(color, value), (value) => theme.bold(value));
|
|
123
|
-
if (shown.length <
|
|
147
|
+
if (details?.truncated || shown.length < total) text += "\n" + theme.fg("dim", ` … +${Math.max(0, total - shown.length)} more · /jar tasks`);
|
|
124
148
|
return new Text(text, 0, 0);
|
|
125
149
|
}
|
|
126
150
|
});
|
package/src/tasks.ts
CHANGED
|
@@ -22,6 +22,18 @@ export type TodoEvent =
|
|
|
22
22
|
const validId = (id: unknown): id is string => typeof id === "string" && /^[a-zA-Z0-9-]{1,64}$/.test(id);
|
|
23
23
|
const validTitle = (title: unknown): title is string => typeof title === "string" && title.length <= 256 && !!cleanText(title, 120);
|
|
24
24
|
const validStatus = (status: unknown): status is TodoStatus => TODO_STATUSES.includes(status as TodoStatus);
|
|
25
|
+
const validWrite = (event: Record<string, unknown>): boolean => {
|
|
26
|
+
if (event.v !== 1 || event.op !== "write" || !Array.isArray(event.items) || event.items.length > MAX_TODOS) return false;
|
|
27
|
+
const ids = new Set<string>();
|
|
28
|
+
let running = 0;
|
|
29
|
+
for (const raw of event.items as unknown[]) {
|
|
30
|
+
const item = raw as Record<string, unknown> | null;
|
|
31
|
+
if (!item || !validId(item.id) || !validTitle(item.title) || !validStatus(item.status) || ids.has(item.id)) return false;
|
|
32
|
+
ids.add(item.id);
|
|
33
|
+
if (item.status === "in_progress" && ++running > 1) return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
25
37
|
const make = (id: string, title: string, status: TodoStatus, activeForm?: unknown, details?: unknown): Todo => ({
|
|
26
38
|
id, title: cleanText(title, 120), status, done: status === "completed",
|
|
27
39
|
...(typeof activeForm === "string" && cleanText(activeForm, 80) ? { activeForm: cleanText(activeForm, 80) } : {}),
|
|
@@ -50,14 +62,12 @@ export class TodoStore {
|
|
|
50
62
|
const event = raw as Record<string, unknown>;
|
|
51
63
|
if (event.v !== 1) return false;
|
|
52
64
|
if (event.op === "write") {
|
|
53
|
-
if (!
|
|
65
|
+
if (!validWrite(event)) return false;
|
|
54
66
|
const next = new Map<string, Todo>();
|
|
55
67
|
for (const raw of event.items as unknown[]) {
|
|
56
|
-
const item = raw as Record<string, unknown
|
|
57
|
-
|
|
58
|
-
next.set(item.id, make(item.id, item.title, item.status, item.activeForm, this.items.get(item.id)?.details));
|
|
68
|
+
const item = raw as Record<string, unknown>;
|
|
69
|
+
next.set(item.id as string, make(item.id as string, item.title as string, item.status as TodoStatus, item.activeForm, this.items.get(item.id as string)?.details));
|
|
59
70
|
}
|
|
60
|
-
if ([...next.values()].filter((item) => item.status === "in_progress").length > 1) return false;
|
|
61
71
|
this.items = next;
|
|
62
72
|
return true;
|
|
63
73
|
}
|
|
@@ -86,11 +96,18 @@ export class TodoStore {
|
|
|
86
96
|
}
|
|
87
97
|
restore(branch: readonly unknown[]): void {
|
|
88
98
|
this.items.clear();
|
|
89
|
-
|
|
99
|
+
// Walk the transcript once from newest to oldest, collecting only task events. A valid full
|
|
100
|
+
// write is a checkpoint, so everything before it is irrelevant.
|
|
101
|
+
const replay: unknown[] = [];
|
|
102
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
103
|
+
const raw = branch[index];
|
|
90
104
|
if (!raw || typeof raw !== "object") continue;
|
|
91
105
|
const entry = raw as Record<string, unknown>;
|
|
92
|
-
if (entry.type
|
|
106
|
+
if (entry.type !== "custom" || entry.customType !== TASK_ENTRY) continue;
|
|
107
|
+
replay.push(entry.data);
|
|
108
|
+
if (entry.data && typeof entry.data === "object" && validWrite(entry.data as Record<string, unknown>)) break;
|
|
93
109
|
}
|
|
110
|
+
for (let index = replay.length - 1; index >= 0; index--) this.apply(replay[index]);
|
|
94
111
|
}
|
|
95
112
|
private commit(event: TodoEvent): boolean {
|
|
96
113
|
const before = new Map([...this.items].map(([id, item]) => [id, { ...item }]));
|
package/src/usage.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { sessionBranch } from "./session-branch.ts";
|
|
1
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
|
|
3
4
|
/** Sum the active session branch, not all historical branches, using Pi's reported monetary cost. */
|
|
4
5
|
export function sessionCost(ctx: Pick<ExtensionContext, "sessionManager">): number {
|
|
5
6
|
let total = 0;
|
|
6
|
-
for (const entry of ctx.sessionManager.getBranch
|
|
7
|
+
for (const entry of sessionBranch(ctx) as ReturnType<typeof ctx.sessionManager.getBranch>) {
|
|
7
8
|
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
8
9
|
const value = entry.message.usage.cost.total;
|
|
9
10
|
if (Number.isFinite(value) && value >= 0) total += value;
|