pi-jar 0.1.9 → 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/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
- /** First-class advisor: the jar_advisor tool, /advisor, and automatic gates for loops and failure streaks. */
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
- let busy = 0;
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
- ctx.ui.setStatus(ROLE_PREFIX + "advisor", task ? JSON.stringify({ name: "Advisor", label: "advisor", state: "working", task, expiresAt: Date.now() + 30_000 }) : undefined);
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
- const gitState = async (ctx: ExtensionContext, signal?: AbortSignal): Promise<string> => {
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 [status, diff] = await Promise.all([
149
- git(["status", "--short", "--branch"]),
150
- git(["diff", "--stat", "HEAD"])
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
- const consult = async (ctx: ExtensionContext, request: AdvisorRequest, signal?: AbortSignal) => {
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 conversation = transcript(sessionBranch(ctx) as readonly Entry[]);
161
- return await askRole(ctx, roles, options.usage, "advisor", ADVISOR_SYSTEM, advisorPrompt(request, conversation, await gitState(ctx, signal)), signal);
162
- } finally { if (--busy === 0) status(ctx); }
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
- question: Type.Optional(Type.String({ description: "A focused question or decision for the advisor" })),
167
- draft: Type.Optional(Type.String({ description: "Your candidate plan, answer or fix for the advisor to critique" }))
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
- label: "advisor",
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 or hard-to-reverse approach, after two failed attempts at the same problem, and before declaring a complex task complete.",
177
- "Form your own candidate first and pass it as draft; ask a specific question. Do not call it for routine steps."
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
- if (!options.enabled()) throw new Error("the advisor is turned off in /jar settings");
182
- const answer = await consult(ctx, { ...(params.question ? { question: params.question } : {}), ...(params.draft ? { draft: params.draft } : {}) }, signal);
183
- return { content: [{ type: "text", text: answer.text }], details: { model: answer.model } };
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: "Ask the advisor for a second opinion on the current work: /advisor [focus]",
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 focus = args.trim();
197
- ctx.ui.notify("Consulting the advisor…", "info");
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", (event) => { if (event.source === "interactive") stuck.reset(); });
206
-
207
- pi.on("tool_call", async (event, ctx) => {
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
- if (!trigger) return;
211
- try {
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
- if (!trigger) return;
224
- try {
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 key = [width, icon, session, options.ghost ?? "", options.hint ?? "", options.paintedIcon ?? "", options.visibleRows ?? "",
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 = session ? `${icon} · session ${session}` : icon;
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
+ }
@@ -202,24 +202,44 @@ export async function createDelegateWorktree(cwd: string, signal?: AbortSignal):
202
202
  // and stage in bounded batches rather than two git processes per repository file.
203
203
  const entries: { rel: string; mode: string; oid: string }[] = [];
204
204
  const batched: { entry: (typeof entries)[number]; path: string; size: number }[] = [];
205
+ // Small bounded batches overlap independent file I/O without loading an entire repository
206
+ // into memory. All writes settle before failure cleanup removes the disposable worktree.
207
+ const pending: { rel: string; stat: Stats }[] = [];
208
+ let pendingBytes = 0;
209
+ const flush = async () => {
210
+ const chunk = pending.splice(0);
211
+ pendingBytes = 0;
212
+ const results = await Promise.allSettled(chunk.map(async ({ rel, stat }) => {
213
+ signal?.throwIfAborted();
214
+ const source = resolve(repoRoot, rel);
215
+ const bytes = stat.isSymbolicLink() ? Buffer.from(await readlink(source)) : await readFile(source);
216
+ signal?.throwIfAborted();
217
+ await writeSnapshot(root, rel, bytes, stat);
218
+ const entry = { rel, mode: stat.isSymbolicLink() ? "120000" : (stat.mode & 0o111) ? "100755" : "100644", oid: "" };
219
+ // stdin-paths follows symlinks and is line-based: hash those exceptional paths by bytes.
220
+ const path = resolve(root, rel);
221
+ if (stat.isSymbolicLink() || path.includes("\n")) entry.oid = (await gitText(root, ["hash-object", "-w", "--no-filters", "--stdin"], bytes, signal)).trim();
222
+ return { entry, path, size: bytes.length };
223
+ }));
224
+ const failure = results.find(result => result.status === "rejected");
225
+ if (failure?.status === "rejected") throw failure.reason;
226
+ for (const result of results) {
227
+ if (result.status !== "fulfilled") continue;
228
+ entries.push(result.value.entry);
229
+ if (!result.value.entry.oid) batched.push(result.value);
230
+ }
231
+ };
205
232
  for (const rel of paths) {
233
+ signal?.throwIfAborted();
206
234
  if (!safeFilePath(repoRoot, rel)) throw new Error("unsafe snapshot path: " + rel);
207
- const source = resolve(repoRoot, rel);
208
- const stat = statIfPresent(source);
235
+ const stat = statIfPresent(resolve(repoRoot, rel));
209
236
  if (!stat) continue;
210
237
  if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error("unsupported snapshot file: " + rel);
211
- const bytes = stat.isSymbolicLink() ? Buffer.from(await readlink(source)) : await readFile(source);
212
- signal?.throwIfAborted();
213
- await writeSnapshot(root, rel, bytes, stat);
214
- const entry = { rel, mode: stat.isSymbolicLink() ? "120000" : (stat.mode & 0o111) ? "100755" : "100644", oid: "" };
215
- entries.push(entry);
216
- // The private copy holds exactly the bytes read above. Hashing it by path would follow a
217
- // symlink, and --stdin-paths is line-based, so those entries hash their bytes directly.
218
- const path = resolve(root, rel);
219
- if (stat.isSymbolicLink() || path.includes("\n")) {
220
- entry.oid = (await gitText(root, ["hash-object", "-w", "--no-filters", "--stdin"], bytes, signal)).trim();
221
- } else batched.push({ entry, path, size: bytes.length });
238
+ if (pending.length && (pending.length >= 4 || pendingBytes + stat.size > 32 * 1024 * 1024)) await flush();
239
+ pending.push({ rel, stat });
240
+ pendingBytes += stat.size;
222
241
  }
242
+ if (pending.length) await flush();
223
243
  const oids = await hashPaths(root, batched, true, signal);
224
244
  batched.forEach((item, index) => { item.entry.oid = oids[index]!; });
225
245
  if (entries.length) {
package/src/delegate.ts CHANGED
@@ -171,15 +171,18 @@ export function delegateArgs(model: string | undefined, thinking: string | undef
171
171
  : session.source ? ["--fork", session.source, "--session-dir", session.sessionDir]
172
172
  : ["--session-dir", session.sessionDir];
173
173
  const extensions = extensionFlags(argv);
174
+ const tools = session?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
175
+ const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
176
+ // Default read-only children need only core file tools and pi-jar. Avoid importing every
177
+ // personal extension (and opening unused MCP/provider connections) for each scout.
178
+ // Explicit extension flags and optional inherited tools retain the parent's full setup.
179
+ const lean = !write && extensions.length === 0 && tools?.every(tool => (READ_ONLY_TOOLS as readonly string[]).includes(tool));
180
+ if (lean) extensions.push("--no-extensions", "--extension", ownExtension);
174
181
  // Worktree cwd/session forks (resumed ones too) do not inherit project extension discovery from the parent.
175
- if (session?.source) {
176
- const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
177
- if (!extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
178
- }
182
+ else if (session?.source && !extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
179
183
  const args = ["--mode", "rpc", ...sessionArgs, ...extensions];
180
184
  if (model) args.push("--model", model);
181
185
  if (thinking) args.push("--thinking", thinking);
182
- const tools = session?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
183
186
  if (tools) args.push("--tools", tools.join(","));
184
187
  return args;
185
188
  }
@@ -846,12 +849,11 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
846
849
  // Hibernated: there is no process to stop, so the agent simply ends here.
847
850
  if (!proc) { finish("done"); return; }
848
851
  if (run.state === "working" && !proc.hibernating) {
849
- const waiting = waitSettled();
850
852
  send({ type: "clear_queue" });
851
- if (send({ type: "abort" })) {
852
- if (!await settlesWithin(waiting, 3000) && !finished) hardStop();
853
- } else hardStop();
853
+ if (!send({ type: "abort" })) hardStop();
854
854
  }
855
+ // EOF follows the queued abort in the same pipe. Pi can now dispose immediately;
856
+ // do not spend an abort grace and then another full process-exit grace serially.
855
857
  const stdin = proc.child.stdin;
856
858
  if (stdin && !stdin.writableEnded) stdin.end();
857
859
  const killer = setTimeout(() => { if (proc.child.exitCode === null) hardStop(); }, 3000);