pi-jar 0.1.8 → 0.2.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/README.md +26 -13
- package/docs/DISCUSSION_BROKER_PLAN.md +333 -0
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/LONG_SESSION_PERFORMANCE_PLAN.md +731 -0
- package/docs/SUBAGENT-TOOLS.md +2 -0
- package/extensions/index.ts +304 -76
- package/package.json +2 -2
- package/src/activity-view.ts +10 -6
- package/src/advisor.ts +226 -73
- package/src/composer.ts +21 -4
- package/src/context-diet.ts +94 -0
- package/src/delegate-worktree.ts +33 -13
- package/src/delegate.ts +736 -429
- package/src/democracy.ts +6 -4
- package/src/discussion-hub.ts +418 -0
- package/src/discussion.ts +419 -187
- package/src/footer-settings.ts +3 -29
- package/src/model-roles.ts +29 -9
- package/src/perf.ts +419 -0
- package/src/profile-wizard.ts +61 -0
- package/src/profiles.ts +148 -0
- package/src/quota.ts +65 -25
- package/src/settings-ui.ts +46 -14
- package/src/settings.ts +6 -13
- package/src/shell-notifications.ts +33 -6
- package/src/shells.ts +138 -29
- package/src/side-model.ts +3 -1
- package/src/welcome.ts +6 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jar",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A warm, role-aware Pi terminal workspace with animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A warm, role-aware Pi terminal workspace with switchable profiles, animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
package/src/activity-view.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface ActivitySources {
|
|
|
16
16
|
subagents: DelegateRegistry;
|
|
17
17
|
shells?: ShellManager;
|
|
18
18
|
roles?: () => ActivityRole[];
|
|
19
|
+
/** Live-update coalescing in ms (default 100); the host widens it as the transcript under the overlay grows. */
|
|
20
|
+
repaintMs?: () => number;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** Doubles as the icon key for the row glyph. */
|
|
@@ -46,7 +48,7 @@ function collect(sources: ActivitySources): Item[] {
|
|
|
46
48
|
if (records.length) items.push({ kind: "header", label: withIcon("subagents", "SUBAGENTS") });
|
|
47
49
|
for (const record of records) {
|
|
48
50
|
const { run } = record;
|
|
49
|
-
const status: Status = run.state === "working" ? "running" : run.state === "queued" || run.state === "idle" ? "pending"
|
|
51
|
+
const status: Status = run.state === "working" ? "running" : run.state === "queued" || run.state === "idle" || run.state === "hibernated" ? "pending"
|
|
50
52
|
: run.state === "paused" || run.state === "stopped" ? "stopped" : run.state === "done" ? "success" : "error";
|
|
51
53
|
items.push({ kind: "subagent", id: record.key, status, label: run.name, record });
|
|
52
54
|
}
|
|
@@ -275,11 +277,12 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
275
277
|
const fg: Fg = (color, text) => theme.fg(color as never, text);
|
|
276
278
|
const shells = sources.shells;
|
|
277
279
|
const previous = shells?.onChange;
|
|
278
|
-
// Live streams notify far faster than a transcript view needs to repaint; ~10 frames a second reads
|
|
280
|
+
// Live streams notify far faster than a transcript view needs to repaint; ~10 frames a second reads
|
|
281
|
+
// as live. Each frame also re-renders the transcript beneath the overlay, so long sessions go slower.
|
|
279
282
|
let repaint: NodeJS.Timeout | undefined;
|
|
280
283
|
const repaintSoon = () => {
|
|
281
284
|
if (repaint || closed) return;
|
|
282
|
-
repaint = setTimeout(() => { repaint = undefined; if (!closed) tui.requestRender(); }, 100);
|
|
285
|
+
repaint = setTimeout(() => { repaint = undefined; if (!closed) tui.requestRender(); }, sources.repaintMs?.() ?? 100);
|
|
283
286
|
repaint.unref?.();
|
|
284
287
|
};
|
|
285
288
|
const unsubscribe = sources.subagents.subscribe(repaintSoon);
|
|
@@ -359,9 +362,10 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
359
362
|
const stopSelected = () => {
|
|
360
363
|
const item = selected();
|
|
361
364
|
if (!item || item.kind === "role") return;
|
|
362
|
-
|
|
365
|
+
// An unresolved recovery workspace stays stoppable: stop retries its reconciliation.
|
|
366
|
+
const subagentStoppable = item.kind === "subagent" && (!["done", "failed", "stopped"].includes(item.record.run.state) || !!item.record.run.workspace);
|
|
363
367
|
const shellLive = item.kind === "shell" && item.status === "running";
|
|
364
|
-
if (!
|
|
368
|
+
if (!subagentStoppable && !shellLive) return;
|
|
365
369
|
try {
|
|
366
370
|
if (item.kind === "subagent") void sources.subagents.stop(item.id).catch((error: unknown) => {
|
|
367
371
|
ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error");
|
|
@@ -460,7 +464,7 @@ ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(erro
|
|
|
460
464
|
// Footer: the two actions, the steering input for subagents, then key hints.
|
|
461
465
|
const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
|
|
462
466
|
"Follow the latest output"], -1, ACTIONS.map((action) => action.key));
|
|
463
|
-
const stoppable = item?.kind === "subagent" ? !["done", "failed", "stopped"].includes(item.record.run.state)
|
|
467
|
+
const stoppable = item?.kind === "subagent" ? !["done", "failed", "stopped"].includes(item.record.run.state) || !!item.record.run.workspace
|
|
464
468
|
: item?.kind === "shell" && item.status === "running";
|
|
465
469
|
if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
|
|
466
470
|
const footer = [...actions];
|
package/src/advisor.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
5
|
-
import { askRole, type SideUsage } from "./side-model.ts";
|
|
6
|
-
import { ROLE_PREFIX } from "./status.ts";
|
|
5
|
+
import { askRole, type SideUsage, type SideCall } from "./side-model.ts";
|
|
6
|
+
import { ROLE_PREFIX, cleanText } from "./status.ts";
|
|
7
7
|
import { sessionBranch } from "./session-branch.ts";
|
|
8
8
|
import { runProcess } from "./async-process.ts";
|
|
9
9
|
|
|
@@ -126,108 +126,261 @@ export interface AdvisorOptions {
|
|
|
126
126
|
usage: SideUsage;
|
|
127
127
|
/** Injectable subprocess boundary for hosts/tests; defaults to bounded asynchronous execution. */
|
|
128
128
|
processRunner?: typeof runProcess;
|
|
129
|
+
/** Total context/provider deadline, including models that ignore cancellation. */
|
|
130
|
+
timeoutMs?: number;
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
|
|
133
|
+
export interface AdvisorJob {
|
|
134
|
+
id: string;
|
|
135
|
+
state: "running" | "completed" | "failed" | "cancelled";
|
|
136
|
+
question: string;
|
|
137
|
+
startedAt: number;
|
|
138
|
+
endedAt?: number;
|
|
139
|
+
model?: string;
|
|
140
|
+
text?: string;
|
|
141
|
+
error?: string;
|
|
142
|
+
}
|
|
143
|
+
const MAX_ADVISOR_RUNNING = 2;
|
|
144
|
+
const MAX_ADVISOR_HISTORY = 16;
|
|
145
|
+
const ANSWER_LIMIT = 8_000;
|
|
146
|
+
const boundedRequest = (request: AdvisorRequest): AdvisorRequest => ({
|
|
147
|
+
...(request.question ? { question: request.question.slice(0, 2_000) } : {}),
|
|
148
|
+
...(request.draft ? { draft: request.draft.slice(0, 8_000) } : {}),
|
|
149
|
+
...(request.trigger ? { trigger: request.trigger.slice(0, 300) } : {})
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/** Detached advisor requests: the main agent never waits on a provider unless it explicitly asks to wait. */
|
|
132
153
|
export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, options: AdvisorOptions) {
|
|
133
154
|
const stuck = new StuckDetector();
|
|
134
|
-
|
|
155
|
+
const timeoutMs = Math.max(1, Math.min(120_000, options.timeoutMs ?? 60_000));
|
|
156
|
+
type Record = AdvisorJob & { controller: AbortController; generation: number; acknowledged: boolean; delivered: boolean; finished: Promise<void> };
|
|
157
|
+
const jobs = new Map<string, Record>();
|
|
158
|
+
const consultations = new Set<symbol>();
|
|
159
|
+
const consultationControllers = new Set<AbortController>();
|
|
160
|
+
let sequence = 0, generation = 0, active = false, interrupted = false;
|
|
161
|
+
let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
162
|
+
let deliveryContext: ExtensionContext | undefined;
|
|
163
|
+
const idle = () => { try { return deliveryContext?.isIdle() === true; } catch { return false; } };
|
|
135
164
|
|
|
136
165
|
const status = (ctx: ExtensionContext, task?: string) => {
|
|
137
166
|
if (!ctx.hasUI) return;
|
|
138
|
-
try {
|
|
139
|
-
|
|
140
|
-
} catch { /* status is decoration */ }
|
|
167
|
+
try { ctx.ui.setStatus(ROLE_PREFIX + "advisor", task ? JSON.stringify({ name: "Advisor", label: "advisor", state: "working", task: cleanText(task, 160), expiresAt: Date.now() + timeoutMs + 1_000 }) : undefined); }
|
|
168
|
+
catch { /* decoration must never fail a request */ }
|
|
141
169
|
};
|
|
142
|
-
|
|
143
|
-
|
|
170
|
+
const toast = (ctx: ExtensionContext, text: string, failed = false) => {
|
|
171
|
+
if (!ctx.hasUI) return;
|
|
172
|
+
try { ctx.ui.notify(text, failed ? "warning" : "info"); } catch { /* non-blocking decoration */ }
|
|
173
|
+
};
|
|
174
|
+
const snapshot = (job: Record): AdvisorJob => ({ id: job.id, state: job.state, question: job.question, startedAt: job.startedAt,
|
|
175
|
+
...(job.endedAt !== undefined ? { endedAt: job.endedAt } : {}), ...(job.model ? { model: job.model } : {}),
|
|
176
|
+
...(job.text ? { text: job.text } : {}), ...(job.error ? { error: job.error } : {}) });
|
|
177
|
+
const get = (id: string): AdvisorJob | undefined => { const job = jobs.get(id); return job && snapshot(job); };
|
|
178
|
+
const pending = () => [...jobs.values()].filter(job => job.state !== "running" && !job.acknowledged && !job.delivered);
|
|
179
|
+
const resultText = (job: AdvisorJob) => job.state === "completed"
|
|
180
|
+
? `◆ Advisor ${job.id} · ${job.model} · ${job.question}\n\n${job.text}`
|
|
181
|
+
: `Advisor ${job.id} · ${job.state}${job.error ? ": " + job.error : ""}`;
|
|
182
|
+
const message = (items: Record[]) => ({ type: "custom_message" as const, customType: ADVISOR_MESSAGE, display: true,
|
|
183
|
+
content: items.map(resultText).join("\n\n"), details: { ids: items.map(job => job.id), summary: `${items.length} advisor result(s)` } });
|
|
184
|
+
const drain = () => {
|
|
185
|
+
clearTimeout(deliveryTimer); deliveryTimer = undefined;
|
|
186
|
+
const items = pending();
|
|
187
|
+
if (!items.length) return undefined;
|
|
188
|
+
for (const job of items) job.delivered = true;
|
|
189
|
+
return message(items);
|
|
190
|
+
};
|
|
191
|
+
const notify = () => {
|
|
192
|
+
if (active || interrupted || !idle() || deliveryTimer || !pending().length) return;
|
|
193
|
+
const owner = generation;
|
|
194
|
+
deliveryTimer = setTimeout(() => {
|
|
195
|
+
deliveryTimer = undefined;
|
|
196
|
+
if (owner !== generation || active || interrupted || !idle()) return;
|
|
197
|
+
const items = pending();
|
|
198
|
+
if (!items.length) return;
|
|
199
|
+
const { type: _type, ...entry } = message(items);
|
|
200
|
+
try { pi.sendMessage(entry, { triggerTurn: true, deliverAs: "nextTurn" }); for (const job of items) job.delivered = true; }
|
|
201
|
+
catch { /* retain results for the next boundary or explicit get/wait */ }
|
|
202
|
+
}, 30);
|
|
203
|
+
deliveryTimer.unref?.();
|
|
204
|
+
};
|
|
205
|
+
const gitState = async (ctx: ExtensionContext, signal: AbortSignal): Promise<string> => {
|
|
144
206
|
const git = (args: string[]) => (options.processRunner ?? runProcess)(process.env.PI_JAR_GIT_PATH?.trim() || "git", args, {
|
|
145
207
|
cwd: ctx.cwd, signal, timeoutMs: 5_000, maxOutputBytes: GIT_LIMIT
|
|
146
208
|
}).then(output => output.toString("utf8"));
|
|
147
209
|
try {
|
|
148
|
-
const [
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
]);
|
|
152
|
-
return (status.trim() + (diff.trim() ? "\n" + diff.trim() : "")).slice(0, GIT_LIMIT);
|
|
153
|
-
} catch { return ""; } // repository state is optional context; not a git repo or git missing
|
|
210
|
+
const [state, diff] = await Promise.all([git(["status", "--short", "--branch"]), git(["diff", "--stat", "HEAD"])]);
|
|
211
|
+
return (state.trim() + (diff.trim() ? "\n" + diff.trim() : "")).slice(0, GIT_LIMIT);
|
|
212
|
+
} catch { return ""; }
|
|
154
213
|
};
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
busy
|
|
214
|
+
const consult = async (ctx: ExtensionContext, supplied: AdvisorRequest, signal?: AbortSignal) => {
|
|
215
|
+
signal?.throwIfAborted();
|
|
216
|
+
if ([...consultationControllers].filter(controller => !controller.signal.aborted).length >= MAX_ADVISOR_RUNNING) throw new Error("Advisor is busy; wait for or cancel an existing request");
|
|
217
|
+
const owner = generation, token = Symbol(), controller = new AbortController();
|
|
218
|
+
const request = boundedRequest(supplied);
|
|
219
|
+
const forwardAbort = () => controller.abort(signal?.reason);
|
|
220
|
+
signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
221
|
+
consultationControllers.add(controller); consultations.add(token);
|
|
158
222
|
status(ctx, request.trigger ?? request.question ?? "reviewing the current direction");
|
|
223
|
+
const timer = setTimeout(() => controller.abort(new Error(`Advisor timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
224
|
+
let abortListener: (() => void) | undefined;
|
|
159
225
|
try {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
226
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
227
|
+
abortListener = () => reject(controller.signal.reason ?? new Error("Advisor cancelled"));
|
|
228
|
+
controller.signal.addEventListener("abort", abortListener, { once: true });
|
|
229
|
+
if (controller.signal.aborted) abortListener();
|
|
230
|
+
});
|
|
231
|
+
const work = async () => {
|
|
232
|
+
// Freeze model/alias choices before asynchronous Git preparation or later role edits.
|
|
233
|
+
const primary = roles.resolve("advisor");
|
|
234
|
+
const fallbacks = [...(roles.fallbackSpecs?.("advisor") ?? [])];
|
|
235
|
+
const targets = new Map<string, () => ReturnType<ModelRoleManager["resolveSpec"]>>();
|
|
236
|
+
for (const spec of fallbacks) {
|
|
237
|
+
try { const target = roles.resolveSpec(spec); targets.set(spec, () => target); }
|
|
238
|
+
catch (error) { targets.set(spec, () => { throw error; }); }
|
|
239
|
+
}
|
|
240
|
+
const selectedRoles = { resolve: () => primary, fallbackSpecs: () => fallbacks,
|
|
241
|
+
resolveSpec: (spec: string) => targets.get(spec)!() } as unknown as ModelRoleManager;
|
|
242
|
+
const selectedContext = { model: ctx.model, modelRegistry: ctx.modelRegistry } as ExtensionContext;
|
|
243
|
+
const conversation = transcript(sessionBranch(ctx) as readonly Entry[], 12_000);
|
|
244
|
+
const git = await gitState(ctx, controller.signal);
|
|
245
|
+
controller.signal.throwIfAborted();
|
|
246
|
+
const usage = options.usage;
|
|
247
|
+
const requestUsage = usage && { add: (call: SideCall) => { if (owner === generation) usage.add(call); } };
|
|
248
|
+
const answer = await askRole(selectedContext, selectedRoles, requestUsage, "advisor", ADVISOR_SYSTEM, advisorPrompt(request, conversation, git), controller.signal);
|
|
249
|
+
return { ...answer, model: answer.model.slice(0, 200), text: answer.text.slice(0, ANSWER_LIMIT) };
|
|
250
|
+
};
|
|
251
|
+
return await Promise.race([work(), aborted]);
|
|
252
|
+
} finally {
|
|
253
|
+
clearTimeout(timer); signal?.removeEventListener("abort", forwardAbort);
|
|
254
|
+
if (abortListener) controller.signal.removeEventListener("abort", abortListener);
|
|
255
|
+
consultationControllers.delete(controller); consultations.delete(token);
|
|
256
|
+
if (owner === generation) status(ctx, consultations.size ? `${consultations.size} advisor request(s) running` : undefined);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const cancel = (id: string): AdvisorJob | undefined => {
|
|
260
|
+
const job = jobs.get(id);
|
|
261
|
+
if (!job) return undefined;
|
|
262
|
+
job.acknowledged = true;
|
|
263
|
+
if (job.state === "running") { job.state = "cancelled"; job.endedAt = Date.now(); job.controller.abort(new Error("Advisor cancelled")); }
|
|
264
|
+
return snapshot(job);
|
|
265
|
+
};
|
|
266
|
+
const start = (ctx: ExtensionContext, supplied: AdvisorRequest = {}, signal?: AbortSignal): AdvisorJob => {
|
|
267
|
+
if (!options.enabled()) throw new Error("the advisor is turned off in /jar settings");
|
|
268
|
+
signal?.throwIfAborted();
|
|
269
|
+
if ([...jobs.values()].filter(job => job.state === "running").length >= MAX_ADVISOR_RUNNING) throw new Error("Advisor is busy; get, wait for, or cancel an existing request");
|
|
270
|
+
for (const [id, job] of jobs) { if (jobs.size < MAX_ADVISOR_HISTORY) break; if (job.acknowledged || job.delivered) jobs.delete(id); }
|
|
271
|
+
if (jobs.size >= MAX_ADVISOR_HISTORY) throw new Error("Collect pending advisor results before starting another request");
|
|
272
|
+
const request = boundedRequest(supplied);
|
|
273
|
+
const job: Record = { id: `a${++sequence}`, state: "running", question: cleanText(request.trigger ?? request.question ?? "general review", 200),
|
|
274
|
+
startedAt: Date.now(), generation, controller: new AbortController(), acknowledged: false, delivered: false, finished: Promise.resolve() };
|
|
275
|
+
jobs.set(job.id, job); deliveryContext = ctx;
|
|
276
|
+
const abort = () => { cancel(job.id); };
|
|
277
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
278
|
+
job.finished = Promise.resolve().then(() => consult(ctx, request, job.controller.signal)).then(answer => {
|
|
279
|
+
if (job.generation !== generation || job.state !== "running") return;
|
|
280
|
+
job.state = "completed"; job.model = answer.model; job.text = answer.text;
|
|
281
|
+
}, error => {
|
|
282
|
+
if (job.generation !== generation || job.state !== "running") return;
|
|
283
|
+
job.state = "failed"; job.error = (error instanceof Error ? error.message : String(error)).slice(0, 2_000);
|
|
284
|
+
}).then(() => {
|
|
285
|
+
signal?.removeEventListener("abort", abort);
|
|
286
|
+
if (job.generation !== generation) return;
|
|
287
|
+
job.endedAt ??= Date.now();
|
|
288
|
+
if (!job.acknowledged) { toast(ctx, `Advisor ${job.id} ${job.state}; result available`, job.state !== "completed"); notify(); }
|
|
289
|
+
});
|
|
290
|
+
return snapshot(job);
|
|
291
|
+
};
|
|
292
|
+
const observe = (id: string) => {
|
|
293
|
+
const job = jobs.get(id);
|
|
294
|
+
if (!job) throw new Error("Unknown advisor request: " + id);
|
|
295
|
+
if (job.state !== "running") job.acknowledged = true;
|
|
296
|
+
return snapshot(job);
|
|
297
|
+
};
|
|
298
|
+
const wait = async (id: string, milliseconds = 1_000, signal?: AbortSignal): Promise<AdvisorJob> => {
|
|
299
|
+
const job = jobs.get(id);
|
|
300
|
+
if (!job) throw new Error("Unknown advisor request: " + id);
|
|
301
|
+
signal?.throwIfAborted();
|
|
302
|
+
if (job.state !== "running") return observe(id);
|
|
303
|
+
let timer: ReturnType<typeof setTimeout> | undefined, abort: (() => void) | undefined;
|
|
304
|
+
try {
|
|
305
|
+
await Promise.race([job.finished, new Promise<void>((resolve, reject) => {
|
|
306
|
+
timer = setTimeout(resolve, Math.max(0, Math.min(30_000, milliseconds)));
|
|
307
|
+
abort = () => reject(signal?.reason ?? new Error("Wait cancelled"));
|
|
308
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
309
|
+
if (signal?.aborted) abort();
|
|
310
|
+
})]);
|
|
311
|
+
} finally { clearTimeout(timer); if (abort) signal?.removeEventListener("abort", abort); }
|
|
312
|
+
if (jobs.get(id) !== job) return { ...snapshot(job), state: "cancelled", error: "Session changed" };
|
|
313
|
+
return observe(id);
|
|
314
|
+
};
|
|
315
|
+
const reset = (_event?: unknown, ctx?: ExtensionContext) => {
|
|
316
|
+
generation++; clearTimeout(deliveryTimer); deliveryTimer = undefined;
|
|
317
|
+
for (const job of jobs.values()) { job.acknowledged = true; job.controller.abort(new Error("Session changed")); }
|
|
318
|
+
for (const controller of consultationControllers) controller.abort(new Error("Session changed"));
|
|
319
|
+
jobs.clear(); consultationControllers.clear(); consultations.clear(); stuck.reset(); active = false; interrupted = false; deliveryContext = ctx;
|
|
320
|
+
if (ctx) status(ctx);
|
|
163
321
|
};
|
|
164
|
-
|
|
165
322
|
const Parameters = Type.Object({
|
|
166
|
-
|
|
167
|
-
|
|
323
|
+
action: Type.Optional(Type.Union([Type.Literal("start"), Type.Literal("status"), Type.Literal("get"), Type.Literal("wait"), Type.Literal("cancel")])),
|
|
324
|
+
id: Type.Optional(Type.String({ maxLength: 64, description: "Advisor request ID returned by start" })),
|
|
325
|
+
waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 30_000 })),
|
|
326
|
+
question: Type.Optional(Type.String({ description: "Focused question (first 2,000 characters are used)" })),
|
|
327
|
+
draft: Type.Optional(Type.String({ description: "Candidate approach (first 8,000 characters are used)" }))
|
|
168
328
|
});
|
|
169
|
-
|
|
170
329
|
if (typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool === "function") pi.registerTool({
|
|
171
|
-
name: ADVISOR_TOOL,
|
|
172
|
-
|
|
173
|
-
description: "Ask the advisor (a stronger reviewer model with a fresh view of this conversation and the repo state) for a second opinion. Returns its advice.",
|
|
330
|
+
name: ADVISOR_TOOL, label: "advisor",
|
|
331
|
+
description: "Start a background second opinion and return its request ID immediately. Use status/get/wait/cancel with that ID; wait is bounded to 30 seconds.",
|
|
174
332
|
promptSnippet: "Use jar_advisor for a second opinion on consequential decisions, when stuck, or before declaring hard work done.",
|
|
175
333
|
promptGuidelines: [
|
|
176
|
-
"Call jar_advisor before committing to a risky
|
|
177
|
-
"
|
|
178
|
-
|
|
179
|
-
parameters: Parameters,
|
|
334
|
+
"Call jar_advisor before committing to a risky approach, after two failed attempts, and before declaring complex work complete. Form a candidate first and pass it as draft.",
|
|
335
|
+
"Start returns immediately, not approval. Continue useful work; collect completed advice with get/wait before the consequential decision. A pending receipt is not a completed review.",
|
|
336
|
+
"Background completions reach the model at a safe agent boundary; they cannot interrupt an in-flight model response. Use bounded wait when no useful work remains."
|
|
337
|
+
], parameters: Parameters,
|
|
180
338
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
339
|
+
const action = params.action ?? "start";
|
|
340
|
+
if (action === "status") {
|
|
341
|
+
const items = params.id ? [get(params.id)] : [...jobs.values()].map(snapshot);
|
|
342
|
+
if (items.some(job => !job)) throw new Error("Unknown advisor request: " + params.id);
|
|
343
|
+
const summaries = items.map(job => { const { text: _text, error: _error, ...summary } = job!; return summary; });
|
|
344
|
+
return { content: [{ type: "text", text: summaries.map(job => `${job.id} · ${job.state} · ${job.question}`).join("\n") || "No advisor requests" }], details: params.id ? summaries[0] : { jobs: summaries } };
|
|
345
|
+
}
|
|
346
|
+
const job = action === "start" ? start(ctx, { question: params.question, draft: params.draft }, signal)
|
|
347
|
+
: action === "wait" ? await wait(params.id ?? "", params.waitMs, signal)
|
|
348
|
+
: action === "cancel" ? cancel(params.id ?? "") : observe(params.id ?? "");
|
|
349
|
+
if (!job) throw new Error("Unknown advisor request: " + (params.id ?? ""));
|
|
350
|
+
const text = job.state === "running" ? `Advisor ${job.id} ${action === "start" ? "started" : "still running"} · running. Continue useful work; use get/wait with this ID to collect the review.` : resultText(job);
|
|
351
|
+
return { content: [{ type: "text", text }], details: job };
|
|
184
352
|
}
|
|
185
353
|
});
|
|
186
|
-
|
|
187
|
-
const deliver = (ctx: ExtensionContext, heading: string, text: string) => {
|
|
188
|
-
pi.sendMessage({ customType: ADVISOR_MESSAGE, content: `${heading}\n\n${text}`, display: true },
|
|
189
|
-
ctx.isIdle() ? { deliverAs: "nextTurn" } : { deliverAs: "steer" });
|
|
190
|
-
};
|
|
191
|
-
|
|
192
354
|
pi.registerCommand("advisor", {
|
|
193
|
-
description: "
|
|
355
|
+
description: "Start a background second opinion: /advisor [focus]",
|
|
194
356
|
handler: async (args, ctx) => {
|
|
195
357
|
if (!options.enabled()) { ctx.ui.notify("The advisor is off; turn it on in /jar settings → Pi", "warning"); return; }
|
|
196
|
-
const
|
|
197
|
-
ctx.ui.notify("
|
|
198
|
-
try {
|
|
199
|
-
const answer = await consult(ctx, focus ? { question: focus } : {}, ctx.signal);
|
|
200
|
-
deliver(ctx, `◆ Advisor · ${answer.model}${focus ? " · " + focus : ""}`, answer.text);
|
|
201
|
-
} catch (error) { ctx.ui.notify("Advisor failed: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
|
358
|
+
try { const job = start(ctx, { question: args.trim() }, ctx.signal); toast(ctx, `Advisor ${job.id} started in the background`); }
|
|
359
|
+
catch (error) { ctx.ui.notify("Advisor: " + (error instanceof Error ? error.message : String(error)), "warning"); }
|
|
202
360
|
}
|
|
203
361
|
});
|
|
204
|
-
|
|
205
|
-
pi.on("input",
|
|
206
|
-
|
|
207
|
-
pi.on("
|
|
362
|
+
pi.on("session_start", reset); pi.on("session_shutdown", reset);
|
|
363
|
+
pi.on("input", event => { if (event.source === "interactive") stuck.reset(); });
|
|
364
|
+
pi.on("before_agent_start", () => { active = true; interrupted = false; const entry = drain(); if (entry) { const { type: _type, ...message } = entry; return { message }; } });
|
|
365
|
+
pi.on("agent_start", () => { active = true; clearTimeout(deliveryTimer); deliveryTimer = undefined; });
|
|
366
|
+
const boundary = (event: { outcome: string; context?: { canContinue?: boolean } }) => {
|
|
367
|
+
if (event.outcome !== "completed" || event.context?.canContinue === false) return;
|
|
368
|
+
const entry = drain(); if (entry) return { entries: [entry], continue: true };
|
|
369
|
+
};
|
|
370
|
+
pi.on("turn_end", boundary);
|
|
371
|
+
pi.on("agent_before_settle", event => { interrupted = event.outcome !== "completed"; return boundary(event); });
|
|
372
|
+
pi.on("agent_settled", (_event, ctx) => { active = false; if (ctx) deliveryContext = ctx; notify(); });
|
|
373
|
+
pi.on("tool_call", (event, ctx) => {
|
|
208
374
|
if (event.toolName === ADVISOR_TOOL || !options.enabled() || !options.gates()) return;
|
|
209
|
-
const trigger = stuck.call(callKey(event.toolName, event.input));
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const answer = await consult(ctx, { trigger }, ctx.signal);
|
|
213
|
-
return { block: true, reason: `Loop detected: ${trigger}. The advisor (${answer.model}) reviewed the situation:\n\n${answer.text}` };
|
|
214
|
-
} catch (error) {
|
|
215
|
-
ctx.ui.notify("Advisor gate failed: " + (error instanceof Error ? error.message : String(error)), "warning");
|
|
216
|
-
return undefined;
|
|
217
|
-
}
|
|
375
|
+
const trigger = stuck.call(callKey(event.toolName, event.input)); if (!trigger) return;
|
|
376
|
+
try { const job = start(ctx, { trigger }, ctx.signal); return { block: true, reason: `Loop detected: ${trigger}. Advisor ${job.id} is reviewing in the background. Change approach and collect its result with jar_advisor get/wait.` }; }
|
|
377
|
+
catch (error) { toast(ctx, "Advisor gate: " + (error instanceof Error ? error.message : String(error)), true); }
|
|
218
378
|
});
|
|
219
|
-
|
|
220
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
379
|
+
pi.on("tool_result", (event, ctx) => {
|
|
221
380
|
if (event.toolName === ADVISOR_TOOL || !options.enabled() || !options.gates()) return;
|
|
222
|
-
const trigger = stuck.result(event.isError, event.toolName);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const answer = await consult(ctx, { trigger }, ctx.signal);
|
|
226
|
-
deliver(ctx, `◆ Advisor · ${answer.model} · ${trigger}`, answer.text);
|
|
227
|
-
} catch (error) {
|
|
228
|
-
ctx.ui.notify("Advisor gate failed: " + (error instanceof Error ? error.message : String(error)), "warning");
|
|
229
|
-
}
|
|
381
|
+
const trigger = stuck.result(event.isError, event.toolName); if (!trigger) return;
|
|
382
|
+
try { start(ctx, { trigger }, ctx.signal); }
|
|
383
|
+
catch (error) { toast(ctx, "Advisor gate: " + (error instanceof Error ? error.message : String(error)), true); }
|
|
230
384
|
});
|
|
231
|
-
|
|
232
|
-
return { consult, stuck };
|
|
385
|
+
return { consult, stuck, start, get, wait, cancel, isBusy: () => consultations.size > 0 || [...jobs.values()].some(job => job.state === "running"), dispose: reset };
|
|
233
386
|
}
|
package/src/composer.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { SuggestionState } from "./suggest.ts";
|
|
|
5
5
|
import type { WorkingPhase } from "./working.ts";
|
|
6
6
|
import { imageChip, imageInfo, imagePaths } from "./attachments.ts";
|
|
7
7
|
import { RowCache } from "./row-cache.ts";
|
|
8
|
+
import { cleanText } from "./status.ts";
|
|
8
9
|
// Aliased: roundedInput has a parameter named `icon`.
|
|
9
10
|
import { icon as glyph, type IconKey } from "./icons.ts";
|
|
10
11
|
|
|
@@ -65,6 +66,8 @@ function bottomBorderIndex(lines: readonly string[], visibleRows?: number): numb
|
|
|
65
66
|
const overflowLabel = (line: string | undefined) => /[↑↓]\s*\d+\s*more/.exec(stripTerminalSequences(line ?? ""))?.[0];
|
|
66
67
|
|
|
67
68
|
export interface RoundedInputOptions {
|
|
69
|
+
/** Active profile shown between the mascot and human session title. */
|
|
70
|
+
profile?: string;
|
|
68
71
|
/** Dim inline suggestion shown after the cursor when the draft is empty. */
|
|
69
72
|
ghost?: string;
|
|
70
73
|
/** Dim key hints in the bottom border. */
|
|
@@ -96,12 +99,15 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
|
|
|
96
99
|
const border = focused && focusPaint ? focusPaint : theme.borderColor;
|
|
97
100
|
const dim = options.dim ?? ((text: string) => text);
|
|
98
101
|
// Painter samples catch a theme change behind the same function.
|
|
99
|
-
const
|
|
102
|
+
const profile = options.profile === undefined ? undefined : cleanText(options.profile, 32) || "Default";
|
|
103
|
+
const key = [width, icon, session, profile ?? "", options.ghost ?? "", options.hint ?? "", options.paintedIcon ?? "", options.visibleRows ?? "",
|
|
100
104
|
options.below ?? "", glyph("tab"), border("─"), dim("─")].join("\0");
|
|
101
105
|
if (lastFrame?.key === key && lastFrame.border === border && lastFrame.dim === options.dim && sameLines(lastFrame.lines, lines)) return lastFrame.framed.slice();
|
|
102
106
|
const bottom = bottomBorderIndex(lines, options.visibleRows);
|
|
103
107
|
const inner = width - 2;
|
|
104
|
-
const meta =
|
|
108
|
+
const meta = profile !== undefined
|
|
109
|
+
? `${icon} · (${profile})${session ? " " + cleanText(session, 120) : ""}`
|
|
110
|
+
: session ? `${icon} · session ${session}` : icon;
|
|
105
111
|
const title = truncateToWidth(` ${meta} `, width - 3);
|
|
106
112
|
const paintedTitle = options.paintedIcon && title.includes(icon) ? title.replace(icon, options.paintedIcon) : title;
|
|
107
113
|
const above = overflowLabel(lines[0]);
|
|
@@ -143,6 +149,7 @@ export interface ComposerDecor {
|
|
|
143
149
|
/** Flame-tip row above the frame, or undefined when there is no room. */
|
|
144
150
|
tip(width: number): string | undefined;
|
|
145
151
|
session(): string;
|
|
152
|
+
profile?(): string;
|
|
146
153
|
ghost(): string | undefined;
|
|
147
154
|
hint(): string | undefined;
|
|
148
155
|
accept(): string | undefined;
|
|
@@ -200,7 +207,7 @@ class RoundedEditor extends CustomEditor {
|
|
|
200
207
|
const paintedIcon = this.decor.paintedIcon();
|
|
201
208
|
const framed = roundedInput(inner, width, this.focused, this.colors, this.paint, icon, session, {
|
|
202
209
|
...(ghost ? { ghost } : {}), ...(hint ? { hint } : {}), ...(paintedIcon ? { paintedIcon } : {}),
|
|
203
|
-
...(visibleRows != null ? { visibleRows } : {}), dim: this.decor.dim,
|
|
210
|
+
...(visibleRows != null ? { visibleRows } : {}), profile: this.decor.profile?.(), dim: this.decor.dim,
|
|
204
211
|
...(below ? { below } : {})
|
|
205
212
|
});
|
|
206
213
|
const tip = width >= 8 ? this.decor.tip(width) : undefined;
|
|
@@ -272,7 +279,7 @@ class ThemedEditor implements EditorComponent {
|
|
|
272
279
|
const session = this.decor.session();
|
|
273
280
|
const paintedIcon = this.decor.paintedIcon();
|
|
274
281
|
const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, icon, session, {
|
|
275
|
-
...(ghost ? { ghost } : {}), ...(paintedIcon ? { paintedIcon } : {}), dim: this.decor.dim,
|
|
282
|
+
...(ghost ? { ghost } : {}), ...(paintedIcon ? { paintedIcon } : {}), profile: this.decor.profile?.(), dim: this.decor.dim,
|
|
276
283
|
...(below ? { below } : {})
|
|
277
284
|
});
|
|
278
285
|
const tip = width >= 8 ? this.decor.tip(width) : undefined;
|
|
@@ -303,6 +310,7 @@ export class ComposerStyle {
|
|
|
303
310
|
private animations = true;
|
|
304
311
|
private mascotOn = true;
|
|
305
312
|
private session = "";
|
|
313
|
+
private profile = "Default";
|
|
306
314
|
private cwd = process.cwd();
|
|
307
315
|
private lastKey = "";
|
|
308
316
|
private readonly mascot = new Mascot();
|
|
@@ -327,6 +335,7 @@ export class ComposerStyle {
|
|
|
327
335
|
return " ".repeat(3) + paintTip(tip, mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
|
|
328
336
|
},
|
|
329
337
|
session: () => this.session,
|
|
338
|
+
profile: () => this.profile,
|
|
330
339
|
ghost: () => this.suggestions?.text,
|
|
331
340
|
hint: () => this.suggestions?.text
|
|
332
341
|
? `${keyHint("tab", "accept")} · ${keyHint("enter", "send")} · ${keyHint("shiftEnter", "newline")}`
|
|
@@ -358,6 +367,14 @@ export class ComposerStyle {
|
|
|
358
367
|
this.unsubscribe = state.onChange(() => { if (this.enabled) this.tui?.requestRender(); });
|
|
359
368
|
}
|
|
360
369
|
|
|
370
|
+
/** Update the title without replacing the editor or losing its draft/history. */
|
|
371
|
+
setProfile(name: string): void {
|
|
372
|
+
const next = cleanText(name, 32) || "Default";
|
|
373
|
+
if (next === this.profile) return;
|
|
374
|
+
this.profile = next;
|
|
375
|
+
if (this.enabled) this.tui?.requestRender();
|
|
376
|
+
}
|
|
377
|
+
|
|
361
378
|
setMascot(on: boolean): void {
|
|
362
379
|
if (this.mascotOn === on) return;
|
|
363
380
|
this.mascotOn = on;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Character counts are diagnostics, NOT token estimates: signatures can be opaque ciphertext. */
|
|
4
|
+
export interface ContextDietStats {
|
|
5
|
+
removedThinkingParts: number;
|
|
6
|
+
removedVisibleChars: number;
|
|
7
|
+
removedSignatureChars: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DietMessage {
|
|
11
|
+
role: string;
|
|
12
|
+
content?: unknown;
|
|
13
|
+
toolCallId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const emptyStats = (): ContextDietStats => ({ removedThinkingParts: 0, removedVisibleChars: 0, removedSignatureChars: 0 });
|
|
17
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
18
|
+
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Opt-in provider-context projection; never edits the session or mutates its messages.
|
|
22
|
+
* Only prior user turns are eligible. The complete current reasoning/tool chain is retained.
|
|
23
|
+
* Ambiguous, unresolved or boundary-crossing historical tool calls make the whole projection
|
|
24
|
+
* a no-op: reducing context is less important than preserving provider sequencing constraints.
|
|
25
|
+
*/
|
|
26
|
+
export function trimCompletedThinking<T extends DietMessage>(messages: T[]): { messages: T[]; stats: ContextDietStats } {
|
|
27
|
+
const stats = emptyStats();
|
|
28
|
+
let boundary = -1;
|
|
29
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
30
|
+
if (messages[index]!.role === "user") { boundary = index; break; }
|
|
31
|
+
}
|
|
32
|
+
const firstUser = messages.findIndex(message => message.role === "user");
|
|
33
|
+
if (boundary <= 0 || firstUser === boundary) return { messages, stats };
|
|
34
|
+
|
|
35
|
+
const calls = new Map<string, number>();
|
|
36
|
+
const results = new Map<string, number>();
|
|
37
|
+
for (let index = 0; index < messages.length; index++) {
|
|
38
|
+
const message = messages[index]!;
|
|
39
|
+
if (message.role === "toolResult") {
|
|
40
|
+
const id = message.toolCallId;
|
|
41
|
+
if (typeof id !== "string" || !id || results.has(id)) return { messages, stats };
|
|
42
|
+
results.set(id, index);
|
|
43
|
+
}
|
|
44
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
45
|
+
for (const value of message.content) {
|
|
46
|
+
const part = record(value);
|
|
47
|
+
if (part?.type !== "toolCall") continue;
|
|
48
|
+
const id = part.id;
|
|
49
|
+
if (typeof id !== "string" || !id || calls.has(id)) return { messages, stats };
|
|
50
|
+
calls.set(id, index);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const [id, index] of calls) {
|
|
54
|
+
if (index >= boundary) continue;
|
|
55
|
+
const result = results.get(id);
|
|
56
|
+
if (result === undefined || result <= index || result >= boundary) return { messages, stats };
|
|
57
|
+
}
|
|
58
|
+
// A detached historical result means the original reasoning sequence is not fully known.
|
|
59
|
+
for (const [id, index] of results) {
|
|
60
|
+
if (index < boundary && !calls.has(id)) return { messages, stats };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let changed = false;
|
|
64
|
+
const projected = messages.map((message, index) => {
|
|
65
|
+
if (index < firstUser || index >= boundary || message.role !== "assistant" || !Array.isArray(message.content)) return message;
|
|
66
|
+
const kept = message.content.filter(value => record(value)?.type !== "thinking");
|
|
67
|
+
// Retain thinking-only rows unchanged rather than manufacturing empty assistant messages.
|
|
68
|
+
if (!kept.length || kept.length === message.content.length) return message;
|
|
69
|
+
for (const value of message.content) {
|
|
70
|
+
const part = record(value);
|
|
71
|
+
if (part?.type !== "thinking") continue;
|
|
72
|
+
stats.removedThinkingParts++;
|
|
73
|
+
if (typeof part.thinking === "string") stats.removedVisibleChars += part.thinking.length;
|
|
74
|
+
if (typeof part.thinkingSignature === "string") stats.removedSignatureChars += part.thinkingSignature.length;
|
|
75
|
+
}
|
|
76
|
+
changed = true;
|
|
77
|
+
return { ...message, content: kept } as T;
|
|
78
|
+
});
|
|
79
|
+
return { messages: changed ? projected : messages, stats };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Register a runtime-toggleable diet; caller owns its persisted, opt-in setting. */
|
|
83
|
+
export function registerContextDiet(pi: ExtensionAPI, enabled: () => boolean): { stats: () => ContextDietStats } {
|
|
84
|
+
let latest = emptyStats();
|
|
85
|
+
pi.on("context", event => {
|
|
86
|
+
latest = emptyStats();
|
|
87
|
+
if (!enabled()) return;
|
|
88
|
+
const result = trimCompletedThinking(event.messages);
|
|
89
|
+
latest = result.stats;
|
|
90
|
+
if (result.messages !== event.messages) return { messages: result.messages };
|
|
91
|
+
});
|
|
92
|
+
pi.on("session_start", () => { latest = emptyStats(); });
|
|
93
|
+
return { stats: () => ({ ...latest }) };
|
|
94
|
+
}
|