pi-jar 0.1.1 → 0.1.2
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/extensions/index.ts +74 -56
- package/package.json +1 -1
- package/src/advisor.ts +30 -6
- package/src/changes.ts +12 -20
- package/src/compact-tools.ts +62 -18
- package/src/composer.ts +6 -5
- package/src/delegate.ts +38 -9
- package/src/diff-view.ts +2 -2
- package/src/goal-loop.ts +34 -9
- package/src/goals.ts +13 -2
- package/src/history-ui.ts +2 -1
- package/src/info-panels.ts +19 -7
- package/src/plan.ts +36 -10
- package/src/prompt-search.ts +25 -8
- package/src/quota.ts +14 -4
- package/src/session-branch.ts +24 -0
- package/src/session-gallery.ts +2 -2
- package/src/session-ui.ts +15 -6
- package/src/shells.ts +78 -23
- package/src/side-model.ts +14 -2
- package/src/status.ts +34 -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/shells.ts
CHANGED
|
@@ -11,7 +11,10 @@ export const SHELL_MESSAGE = "pi-jar.shell";
|
|
|
11
11
|
export const MAX_RUNNING_SHELLS = 8;
|
|
12
12
|
const MAX_KEPT_SHELLS = 20;
|
|
13
13
|
const MAX_LINES = 2000;
|
|
14
|
+
const LINE_PRUNE_BATCH = 128;
|
|
14
15
|
const MAX_LINE_CHARS = 2000;
|
|
16
|
+
const MAX_RETAINED_CHARS = 1024 * 1024;
|
|
17
|
+
const MAX_TOOL_OUTPUT_CHARS = 32 * 1024;
|
|
15
18
|
const KILL_GRACE_MS = 3000;
|
|
16
19
|
|
|
17
20
|
export type ShellStatus = "running" | "exited" | "killed" | "failed";
|
|
@@ -44,14 +47,25 @@ const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
|
|
|
44
47
|
export class ShellManager {
|
|
45
48
|
private jobs = new Map<string, { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> }>();
|
|
46
49
|
private next = 1;
|
|
50
|
+
private lineChars = new Map<string, number>();
|
|
47
51
|
private readonly onEvent: (event: ShellEvent) => void;
|
|
48
52
|
private readonly spawnShell: typeof spawn;
|
|
49
53
|
onChange?: () => void;
|
|
50
54
|
constructor(onEvent: (event: ShellEvent) => void, spawnShell: typeof spawn = spawn) { this.onEvent = onEvent; this.spawnShell = spawnShell; }
|
|
51
55
|
|
|
52
56
|
list(): ShellJob[] { return [...this.jobs.values()].map(({ job }) => ({ ...job, lines: [...job.lines] })); }
|
|
57
|
+
summaries(): Array<Omit<ShellJob, "lines">> {
|
|
58
|
+
return [...this.jobs.values()].map(({ job }) => {
|
|
59
|
+
const { lines: _lines, ...summary } = job;
|
|
60
|
+
return { ...summary };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
53
63
|
get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }
|
|
54
|
-
running(): number {
|
|
64
|
+
running(): number {
|
|
65
|
+
let count = 0;
|
|
66
|
+
for (const { job } of this.jobs.values()) if (job.status === "running") count++;
|
|
67
|
+
return count;
|
|
68
|
+
}
|
|
55
69
|
|
|
56
70
|
start(options: StartOptions): ShellJob {
|
|
57
71
|
const command = options.command.trim();
|
|
@@ -68,6 +82,7 @@ export class ShellManager {
|
|
|
68
82
|
notify: options.notify ?? true, lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
|
|
69
83
|
const entry: { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> } = { job, partial: "", ...(pattern ? { pattern } : {}) };
|
|
70
84
|
this.jobs.set(id, entry);
|
|
85
|
+
this.lineChars.set(id, 0);
|
|
71
86
|
// Own process group so kill() stops the whole tree (dev servers spawn children).
|
|
72
87
|
const child = this.spawnShell("/bin/sh", ["-c", command], { cwd: options.cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
73
88
|
entry.child = child;
|
|
@@ -105,16 +120,26 @@ export class ShellManager {
|
|
|
105
120
|
const line = raw.replace(ANSI, "").replace(/\r/g, "").slice(0, MAX_LINE_CHARS);
|
|
106
121
|
const { job } = entry;
|
|
107
122
|
job.lines.push(line);
|
|
108
|
-
|
|
123
|
+
this.lineChars.set(job.id, (this.lineChars.get(job.id) ?? 0) + line.length);
|
|
124
|
+
// Bound both line count and retained characters. A shell that prints very wide lines should
|
|
125
|
+
// not quietly reserve several MiB forever just because it has not reached MAX_LINES yet.
|
|
126
|
+
while (job.lines.length > MAX_LINES + LINE_PRUNE_BATCH || (this.lineChars.get(job.id) ?? 0) > MAX_RETAINED_CHARS) {
|
|
127
|
+
const removed = job.lines.splice(0, Math.min(LINE_PRUNE_BATCH, job.lines.length));
|
|
128
|
+
let removedChars = 0;
|
|
129
|
+
for (const item of removed) removedChars += item.length;
|
|
130
|
+
this.lineChars.set(job.id, Math.max(0, (this.lineChars.get(job.id) ?? 0) - removedChars));
|
|
131
|
+
job.dropped += removed.length;
|
|
132
|
+
if (!removed.length) break;
|
|
133
|
+
}
|
|
109
134
|
if (entry.pattern && !job.matched && entry.pattern.test(line)) {
|
|
110
135
|
job.matched = line;
|
|
111
|
-
this.onEvent({ kind: "match", job: { ...job, lines:
|
|
136
|
+
this.onEvent({ kind: "match", job: { ...job, lines: job.lines.slice(-20) } });
|
|
112
137
|
}
|
|
113
138
|
}
|
|
114
139
|
|
|
115
140
|
private finish(entry: { job: ShellJob; timer?: ReturnType<typeof setTimeout> }): void {
|
|
116
141
|
if (entry.timer) clearTimeout(entry.timer);
|
|
117
|
-
this.onEvent({ kind: "exit", job: { ...entry.job, lines:
|
|
142
|
+
this.onEvent({ kind: "exit", job: { ...entry.job, lines: entry.job.lines.slice(-20) } });
|
|
118
143
|
this.onChange?.();
|
|
119
144
|
}
|
|
120
145
|
|
|
@@ -122,8 +147,10 @@ export class ShellManager {
|
|
|
122
147
|
output(id: string, count = 40): string[] {
|
|
123
148
|
const entry = this.jobs.get(id);
|
|
124
149
|
if (!entry) throw new Error("no shell " + id);
|
|
125
|
-
const
|
|
126
|
-
return lines.slice(-
|
|
150
|
+
const limit = Math.max(1, Math.min(400, Math.floor(count)));
|
|
151
|
+
if (!entry.partial) return entry.job.lines.slice(-limit);
|
|
152
|
+
if (limit === 1) return [entry.partial];
|
|
153
|
+
return [...entry.job.lines.slice(-(limit - 1)), entry.partial];
|
|
127
154
|
}
|
|
128
155
|
|
|
129
156
|
kill(id: string): boolean {
|
|
@@ -147,22 +174,41 @@ export class ShellManager {
|
|
|
147
174
|
/** Forget finished jobs beyond the retention limit (oldest first). */
|
|
148
175
|
private prune(): void {
|
|
149
176
|
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)))
|
|
177
|
+
for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1))) {
|
|
178
|
+
this.jobs.delete(job.id);
|
|
179
|
+
this.lineChars.delete(job.id);
|
|
180
|
+
}
|
|
151
181
|
}
|
|
152
182
|
|
|
153
183
|
dispose(): void {
|
|
154
184
|
for (const id of this.jobs.keys()) { try { this.kill(id); } catch { /* already gone */ } }
|
|
155
185
|
this.jobs.clear();
|
|
186
|
+
this.lineChars.clear();
|
|
156
187
|
}
|
|
157
188
|
}
|
|
158
189
|
|
|
159
|
-
|
|
190
|
+
type ShellMeta = Omit<ShellJob, "lines">;
|
|
191
|
+
const elapsed = (job: ShellMeta | ShellJob, now = Date.now()) => {
|
|
160
192
|
const seconds = Math.max(0, Math.round(((job.endedAt ?? now) - job.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
|
+
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,11 +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
|
}
|
|
@@ -249,7 +304,7 @@ const SHELL_ACTIONS = [
|
|
|
249
304
|
/** Overlay: shells on the left, the selected shell's live output on the right; k kills. */
|
|
250
305
|
export async function openShellsView(ctx: ExtensionContext, manager: ShellManager): Promise<void> {
|
|
251
306
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
252
|
-
if (!manager.
|
|
307
|
+
if (!manager.summaries().length) { ctx.ui.notify("pi-jar: no background shells (the agent starts them with jar_shell)", "info"); return; }
|
|
253
308
|
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
254
309
|
let selected = 0;
|
|
255
310
|
let follow = true;
|
|
@@ -259,14 +314,14 @@ export async function openShellsView(ctx: ExtensionContext, manager: ShellManage
|
|
|
259
314
|
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
260
315
|
const fg = (color: string, text: string) => theme.fg(color as never, text);
|
|
261
316
|
const previous = manager.onChange;
|
|
262
|
-
const timer = setInterval(() => tui.requestRender(),
|
|
317
|
+
const timer = setInterval(() => tui.requestRender(), 1000);
|
|
263
318
|
timer.unref?.();
|
|
264
319
|
manager.onChange = () => { previous?.(); tui.requestRender(); };
|
|
265
320
|
const close = () => { clearInterval(timer); manager.onChange = previous; done(); };
|
|
266
321
|
const component = {
|
|
267
322
|
invalidate() {},
|
|
268
323
|
handleInput(data: string) {
|
|
269
|
-
const jobs = manager.
|
|
324
|
+
const jobs = manager.summaries();
|
|
270
325
|
if (matchesKey(data, Key.escape) || data === "q") return close();
|
|
271
326
|
if (matchesKey(data, Key.up) || data === "k") selected = Math.max(0, selected - 1);
|
|
272
327
|
else if (matchesKey(data, Key.down) || data === "j") selected = Math.min(jobs.length - 1, selected + 1);
|
|
@@ -281,14 +336,14 @@ export async function openShellsView(ctx: ExtensionContext, manager: ShellManage
|
|
|
281
336
|
if (event.type === "wheel" && event.wheelDelta) { follow = false; scroll = Math.max(0, scroll + Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
|
|
282
337
|
if (event.type !== "click" || event.button !== "left") return;
|
|
283
338
|
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.
|
|
339
|
+
if (row >= 0 && row < layout.rows && layout.leftWidth && event.x < layout.leftWidth + 3 && row < manager.summaries().length) { selected = row; follow = true; tui.requestRender(); return { handled: true, focus: true }; }
|
|
285
340
|
const action = SHELL_ACTIONS[event.y - layout.footerTop];
|
|
286
341
|
if (action) { component.handleInput(action.key); return { handled: true }; }
|
|
287
342
|
},
|
|
288
343
|
render(available: number): string[] {
|
|
289
344
|
width = Math.max(24, available);
|
|
290
345
|
rows = contentRows(6 + SHELL_ACTIONS.length, 6);
|
|
291
|
-
const jobs = manager.
|
|
346
|
+
const jobs = manager.summaries();
|
|
292
347
|
selected = Math.min(selected, Math.max(0, jobs.length - 1));
|
|
293
348
|
const job = jobs[selected];
|
|
294
349
|
const listWidth = sidebarWidth(width, 20, 34);
|
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[] = [];
|
|
@@ -40,32 +70,12 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
|
|
|
40
70
|
}
|
|
41
71
|
const id = rawKey.slice(ROLE_PREFIX.length);
|
|
42
72
|
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 {
|
|
73
|
+
const role = cachedRole(id, rawValue);
|
|
74
|
+
if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ROLE_TTL_MS) {
|
|
67
75
|
extras.push(`${id}: unavailable`);
|
|
76
|
+
continue;
|
|
68
77
|
}
|
|
78
|
+
roles.push(role);
|
|
69
79
|
}
|
|
70
80
|
return { roles: roles.slice(0, 16), extras: extras.slice(0, 16) };
|
|
71
81
|
}
|
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;
|