@ferris1225/pi-subagents 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/spawn.ts CHANGED
@@ -3,16 +3,16 @@
3
3
  *
4
4
  * The process transport itself lives in rpc-run.ts. Each attempt starts pi in
5
5
  * persistent `--mode rpc`, sends commands over strict LF-delimited JSONL, and
6
- * settles only on `agent_settled`. This module preserves the existing startup
7
- * retry, same-model retry, model fallback, accounting, and result formatting
8
- * contracts around those attempts.
6
+ * settles only on `agent_settled`. This module owns startup-race recovery,
7
+ * selected-to-main model handoff, capability-clamped thinking, accounting, and
8
+ * result formatting around those attempts.
9
9
  */
10
10
 
11
- import { randomUUID } from "node:crypto";
12
- import { mkdirSync, writeFileSync } from "node:fs";
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ import { type Dirent, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
13
13
  import { mkdtemp, rm } from "node:fs/promises";
14
14
  import { tmpdir } from "node:os";
15
- import { basename, join } from "node:path";
15
+ import { basename, join, resolve } from "node:path";
16
16
  import type { Message } from "@earendil-works/pi-ai";
17
17
  import type { AgentConfig } from "./agents.ts";
18
18
  import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
@@ -25,6 +25,7 @@ import {
25
25
  RpcRunControl,
26
26
  runRpcAgentAttempt,
27
27
  sessionExists,
28
+ writeChildRetryPolicyExtension,
28
29
  SUBAGENT_KILL_GRACE_MS,
29
30
  type RpcSingleResult,
30
31
  type SubagentLiveEvent,
@@ -39,6 +40,7 @@ export {
39
40
  RpcRunControl,
40
41
  sessionExists,
41
42
  SUBAGENT_KILL_GRACE_MS,
43
+ writeChildRetryPolicyExtension,
42
44
  };
43
45
  export type { SubagentLiveEvent, UsageStats };
44
46
 
@@ -47,7 +49,6 @@ export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
47
49
  export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
48
50
  export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
49
51
  export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
50
- export const SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const;
51
52
 
52
53
  export interface SingleResult extends RpcSingleResult {}
53
54
 
@@ -97,14 +98,89 @@ export function truncateResultOutput(output: string, maxLines: number): Truncate
97
98
  return { text: kept.join("\n"), truncated: true };
98
99
  }
99
100
 
101
+ export const RESULT_ARTIFACT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
102
+ export const RESULT_ARTIFACT_MAX_FILES_PER_PROJECT = 50;
103
+ // Explicit current prefix plus the strict timestamp/token convention used by 1.1.0.
104
+ const RESULT_ARTIFACT_NAME = /^(?:pi-subagent-\d{13,}-[0-9a-f]{12}|\d{13,}-[a-z0-9]{6})-[\w.-]+\.md$/;
105
+
106
+ interface ResultArtifactRetentionOptions {
107
+ now?: number;
108
+ maxAgeMs?: number;
109
+ maxFilesPerProject?: number;
110
+ }
111
+
112
+ /** Remove only stale/overflow Markdown result artifacts. Unknown files and
113
+ * symlinks are never touched. Called on each artifact write, so storage stays
114
+ * bounded without deleting a result that the current completion just linked. */
115
+ export function pruneResultArtifacts(
116
+ rootDir: string = join(tmpdir(), "pi-subagents-results"),
117
+ options: ResultArtifactRetentionOptions = {},
118
+ ): void {
119
+ const now = options.now ?? Date.now();
120
+ const maxAgeMs = Math.max(0, options.maxAgeMs ?? RESULT_ARTIFACT_MAX_AGE_MS);
121
+ const maxFiles = Math.max(0, Math.floor(options.maxFilesPerProject ?? RESULT_ARTIFACT_MAX_FILES_PER_PROJECT));
122
+ let projects: Dirent[];
123
+ try {
124
+ projects = readdirSync(rootDir, { withFileTypes: true });
125
+ } catch {
126
+ return;
127
+ }
128
+
129
+ for (const project of projects) {
130
+ if (!project.isDirectory() || project.isSymbolicLink()) continue;
131
+ const projectDir = join(rootDir, project.name);
132
+ let entries: Dirent[];
133
+ try {
134
+ entries = readdirSync(projectDir, { withFileTypes: true });
135
+ } catch {
136
+ continue;
137
+ }
138
+ const artifacts = entries
139
+ .filter((entry) => entry.isFile() && !entry.isSymbolicLink() && RESULT_ARTIFACT_NAME.test(entry.name))
140
+ .flatMap((entry) => {
141
+ const path = join(projectDir, entry.name);
142
+ try {
143
+ return [{ path, mtimeMs: statSync(path).mtimeMs }];
144
+ } catch {
145
+ return [];
146
+ }
147
+ })
148
+ .sort((left, right) => right.mtimeMs - left.mtimeMs);
149
+
150
+ for (const [index, artifact] of artifacts.entries()) {
151
+ if (index < maxFiles && now - artifact.mtimeMs <= maxAgeMs) continue;
152
+ try {
153
+ rmSync(artifact.path, { force: true });
154
+ } catch {
155
+ // Temp cleanup is best-effort; result delivery must still succeed.
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ export function resultArtifactProjectKey(cwd?: string): string {
162
+ if (!cwd) return "default";
163
+ let canonical: string;
164
+ try {
165
+ canonical = realpathSync.native(cwd);
166
+ } catch {
167
+ canonical = resolve(cwd);
168
+ }
169
+ if (process.platform === "win32") canonical = canonical.toLowerCase();
170
+ const slug = basename(canonical).replace(/[^\w.-]+/g, "_") || "project";
171
+ const digest = createHash("sha256").update(canonical).digest("hex").slice(0, 12);
172
+ return `${slug}-${digest}`;
173
+ }
174
+
100
175
  export function writeResultArtifact(output: string, agentName: string, cwd?: string): string {
101
- const projectSlug = cwd ? basename(cwd).replace(/[^\w.-]+/g, "_") || "default" : "default";
102
- const dir = join(tmpdir(), "pi-subagents-results", projectSlug);
176
+ const rootDir = join(tmpdir(), "pi-subagents-results");
177
+ const dir = join(rootDir, resultArtifactProjectKey(cwd));
103
178
  mkdirSync(dir, { recursive: true });
104
- const safeName = agentName.replace(/[^\w.-]+/g, "_");
105
- const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
179
+ const safeName = agentName.replace(/[^\w.-]+/g, "_") || "agent";
180
+ const unique = `pi-subagent-${Date.now()}-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
106
181
  const filePath = join(dir, `${unique}-${safeName}.md`);
107
182
  writeFileSync(filePath, output, "utf8");
183
+ pruneResultArtifacts(rootDir);
108
184
  return filePath;
109
185
  }
110
186
 
@@ -121,13 +197,6 @@ function lastAssistantMessage(messages: Message[]): Extract<Message, { role: "as
121
197
  return undefined;
122
198
  }
123
199
 
124
- function assistantText(message: Extract<Message, { role: "assistant" }>): string {
125
- return message.content
126
- .filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text")
127
- .map((part) => part.text)
128
- .join("");
129
- }
130
-
131
200
  export function isModelLevelFailure(result: SingleResult): boolean {
132
201
  if (!isFailedResult(result)) return false;
133
202
  if (result.stopReason === "aborted") return false;
@@ -141,46 +210,26 @@ export function isModelLevelFailure(result: SingleResult): boolean {
141
210
  // must not hide a later provider error (for example a second-turn 503).
142
211
  const finalAssistant = lastAssistantMessage(result.messages);
143
212
  if (finalAssistant) {
144
- if (finalAssistant.stopReason !== "error") return false;
145
- if (assistantText(finalAssistant).trim()) return false;
146
- return Boolean(
147
- finalAssistant.errorMessage?.trim() ||
148
- result.errorMessage?.trim() ||
149
- result.stderr.trim() ||
150
- finalAssistant.content.length === 0
151
- );
213
+ // Provider streams may preserve partial text on a terminal error. The stop
214
+ // reason, not content emptiness, is the transport boundary; ordinary tool or
215
+ // task failures settle with a non-error assistant stop reason.
216
+ return finalAssistant.stopReason === "error";
152
217
  }
153
218
 
154
219
  if ((result.failedTools?.length ?? 0) > 0) return false;
155
- return Boolean(result.errorMessage?.trim()) || result.stderr.trim().length > 0;
156
- }
157
-
158
- const TERMINAL_MODEL_ERROR_PATTERN =
159
- /insufficient_quota|quota\s+exceeded|exceeded[^.\n]{0,40}quota|out\s+of\s+budget|billing|usage\s+limit|usage_limit|gousagelimiterror|freeusagelimiterror|monthly\s+usage\s+limit\s+reached|available\s+balance|invalid\s+(?:api\s+)?key|incorrect\s+api\s+key|unauthori[sz]ed|\b401\b|\b403\b|forbidden|permission\s+denied/i;
160
- const PERMANENT_MODEL_CANDIDATE_ERROR_PATTERN =
161
- /model[_ -]?not[_ -]?found|no\s+models?\s+(?:found|matched)|(?:model|provider)[^.\n]{0,80}(?:not\s+found|unknown|does\s+not\s+exist|unsupported|invalid)|(?:not\s+found|unknown|unsupported|invalid)[^.\n]{0,40}(?:model|provider)|\b404\b/i;
162
-
163
- export function isTerminalModelError(result: SingleResult): boolean {
164
- const message = result.errorMessage?.trim();
165
- if (message) return TERMINAL_MODEL_ERROR_PATTERN.test(message);
166
- const stderr = result.stderr.trim();
167
- return stderr.length > 0 && TERMINAL_MODEL_ERROR_PATTERN.test(stderr);
168
- }
169
-
170
- /** A permanent failure of this model/provider reference (stale id, unknown
171
- * provider, 404 config route). Skip same-candidate backoff, but keep advancing
172
- * through backup and current-main candidates. */
173
- export function isPermanentModelCandidateError(result: SingleResult): boolean {
174
- const message = result.errorMessage?.trim();
175
- if (message) return PERMANENT_MODEL_CANDIDATE_ERROR_PATTERN.test(message);
176
- const stderr = result.stderr.trim();
177
- return stderr.length > 0 && PERMANENT_MODEL_CANDIDATE_ERROR_PATTERN.test(stderr);
220
+ return Boolean(
221
+ result.rpcPromptAccepted ||
222
+ result.rpcActivity ||
223
+ result.errorMessage?.trim() ||
224
+ result.stderr.trim(),
225
+ );
178
226
  }
179
227
 
180
228
  export function isRetryableStartupFailure(result: SingleResult, durationMs: number): boolean {
181
229
  if (result.exitCode === 0) return false;
182
230
  if (result.stopReason === "aborted") return false;
183
231
  if (result.dispatchFailed) return false;
232
+ if (result.rpcPromptAccepted || result.rpcActivity) return false;
184
233
  if (result.errorMessage?.includes("idle timeout")) return false;
185
234
  if (getFinalOutput(result.messages)) return false;
186
235
  if (result.messages.length > 0) return false;
@@ -252,8 +301,8 @@ export function buildResumePrompt(task: string, reason: string): string {
252
301
 
253
302
  export function buildFallbackResumeReason(fromModel?: string): string {
254
303
  return fromModel
255
- ? `the previous model (${fromModel}) failed at the model/provider level, so the next model in its configured pool is continuing`
256
- : "the previous model failed at the model/provider level, so the next model in its configured pool is continuing";
304
+ ? `the selected model (${fromModel}) failed at the model/provider level, so the current main model is continuing`
305
+ : "the selected model failed at the model/provider level, so the current main model is continuing";
257
306
  }
258
307
 
259
308
  export interface RunSingleOptions {
@@ -263,9 +312,10 @@ export interface RunSingleOptions {
263
312
  task: string;
264
313
  cwd?: string;
265
314
  thinkingLevel?: ThinkingLevel;
315
+ /** Resolve the effective level for each runtime model candidate. */
316
+ thinkingLevelForModel?: (modelRef?: string) => ThinkingLevel;
266
317
  idleTimeoutMs?: number;
267
318
  startupRetryDelaysMs?: readonly number[];
268
- runLevelRetryDelaysMs?: readonly number[];
269
319
  sessionDir?: string;
270
320
  sessionId?: string;
271
321
  /** Initial RPC prompt. Kept under the old name to limit caller churn. */
@@ -347,19 +397,18 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
347
397
  }
348
398
 
349
399
  /**
350
- * Run one logical generation across an ordered model pool. Every candidate gets
351
- * startup retries plus same-model retries for transient provider failures;
352
- * terminal model errors skip those retries and advance immediately. All
353
- * candidates resume the same retained pi session.
400
+ * Run one logical generation on the selected model, then hand directly to the
401
+ * current main model after any model/provider-level failure. Startup-race retries
402
+ * remain process-level recovery; provider/model retries and extra candidates do not.
403
+ * Both attempts resume the same retained Pi session.
354
404
  */
355
- export async function runSingleAgentWithModelFallback(
405
+ export async function runSingleAgentWithMainFallback(
356
406
  options: RunSingleOptions,
357
- fallbackModelRefs: readonly string[] = [],
407
+ mainFallbackRef?: string,
358
408
  ): Promise<SingleResult> {
359
409
  const agent = options.agent;
360
410
  const launchedRef = agent?.model;
361
411
  const startupDelays = options.startupRetryDelaysMs ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
362
- const runDelays = options.runLevelRetryDelaysMs ?? SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS;
363
412
 
364
413
  const sessionId = options.sessionId ?? randomUUID();
365
414
  const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
@@ -438,26 +487,49 @@ export async function runSingleAgentWithModelFallback(
438
487
  }
439
488
  };
440
489
 
441
- const fallbackRefs: string[] = [];
442
- const seenRefs = new Set<string>();
443
- if (launchedRef?.trim()) seenRefs.add(launchedRef.trim());
444
- for (const candidate of fallbackModelRefs) {
445
- const ref = candidate.trim();
446
- if (!ref || seenRefs.has(ref)) continue;
447
- seenRefs.add(ref);
448
- fallbackRefs.push(ref);
449
- }
450
-
490
+ const selectedRef = launchedRef?.trim() || undefined;
491
+ const normalizedMainRef = mainFallbackRef?.trim() || undefined;
451
492
  const candidates: Array<{ agent: AgentConfig; ref?: string }> = [
452
- { agent, ref: launchedRef?.trim() || undefined },
493
+ { agent, ref: selectedRef },
453
494
  ];
454
- for (const ref of fallbackRefs) candidates.push({ agent: { ...agent, model: ref }, ref });
495
+ if (normalizedMainRef && normalizedMainRef !== selectedRef) {
496
+ candidates.push({ agent: { ...agent, model: normalizedMainRef }, ref: normalizedMainRef });
497
+ }
455
498
 
456
- let modelRetries = 0;
457
499
  let fallbackUsed = false;
458
500
  let result: SingleResult | undefined;
501
+ const priorFailedTools: NonNullable<SingleResult["failedTools"]> = [];
502
+ const priorUsage = emptyUsage();
503
+
504
+ const retainAttemptDiagnostics = (attempt: SingleResult): void => {
505
+ priorFailedTools.push(...(attempt.failedTools ?? []));
506
+ priorUsage.input += attempt.usage.input;
507
+ priorUsage.output += attempt.usage.output;
508
+ priorUsage.cacheRead += attempt.usage.cacheRead;
509
+ priorUsage.cacheWrite += attempt.usage.cacheWrite;
510
+ priorUsage.cost += attempt.usage.cost;
511
+ priorUsage.turns += attempt.usage.turns;
512
+ priorUsage.contextTokens = attempt.usage.contextTokens || priorUsage.contextTokens;
513
+ };
459
514
 
460
515
  const finish = async (settled: SingleResult): Promise<SingleResult> => {
516
+ if (priorFailedTools.length > 0) {
517
+ settled.failedTools = [...priorFailedTools, ...(settled.failedTools ?? [])];
518
+ }
519
+ if (
520
+ priorUsage.turns || priorUsage.input || priorUsage.output || priorUsage.cacheRead ||
521
+ priorUsage.cacheWrite || priorUsage.cost || priorUsage.contextTokens
522
+ ) {
523
+ settled.usage = {
524
+ input: priorUsage.input + settled.usage.input,
525
+ output: priorUsage.output + settled.usage.output,
526
+ cacheRead: priorUsage.cacheRead + settled.usage.cacheRead,
527
+ cacheWrite: priorUsage.cacheWrite + settled.usage.cacheWrite,
528
+ cost: priorUsage.cost + settled.usage.cost,
529
+ turns: priorUsage.turns + settled.usage.turns,
530
+ contextTokens: settled.usage.contextTokens || priorUsage.contextTokens,
531
+ };
532
+ }
461
533
  const persistedSession = sessionExists(sessionDir, sessionId);
462
534
  if (!settled.dispatchFailed || persistedSession || options.sessionDir) {
463
535
  settled.sessionId ??= sessionId;
@@ -469,7 +541,6 @@ export async function runSingleAgentWithModelFallback(
469
541
  }
470
542
  settled.task = options.control?.getObjective() ?? settled.task;
471
543
  if (fallbackUsed && launchedRef) settled.modelFallbackFrom = launchedRef;
472
- settled.modelRetries = modelRetries;
473
544
  options.control?.markSettled();
474
545
  return settled;
475
546
  };
@@ -478,9 +549,11 @@ export async function runSingleAgentWithModelFallback(
478
549
  const candidate = candidates[candidateIndex];
479
550
  fallbackUsed ||= candidateIndex > 0;
480
551
  const previousModel = result?.model ?? candidates[candidateIndex - 1]?.ref;
552
+ const candidateThinking = options.thinkingLevelForModel?.(candidate.ref) ?? options.thinkingLevel;
481
553
  const candidateOptions: RunSingleOptions = {
482
554
  ...baseOptions,
483
555
  agent: candidate.agent,
556
+ thinkingLevel: candidateThinking,
484
557
  ...(candidateIndex > 0
485
558
  ? {
486
559
  stdinText: buildResumePrompt(
@@ -494,6 +567,7 @@ export async function runSingleAgentWithModelFallback(
494
567
  options.onLive?.({
495
568
  kind: "model",
496
569
  model: candidate.ref,
570
+ thinking: candidateThinking,
497
571
  ...(candidateIndex > 0 && launchedRef ? { fallbackFrom: launchedRef } : {}),
498
572
  });
499
573
  } catch {
@@ -501,41 +575,12 @@ export async function runSingleAgentWithModelFallback(
501
575
  }
502
576
 
503
577
  result = await runWithStartupRetry(candidateOptions);
504
- if (result.parked || result.stopReason === "aborted") return result;
505
- if (!isModelLevelFailure(result)) return finish(result);
506
-
507
- if (!isTerminalModelError(result) && !isPermanentModelCandidateError(result)) {
508
- const retryOptions: RunSingleOptions = {
509
- ...candidateOptions,
510
- stdinText: buildResumePrompt(
511
- options.control?.getObjective() ?? options.task,
512
- "a transient provider error on the same model",
513
- ),
514
- };
515
- for (const delay of runDelays) {
516
- baseOptions.control?.markRetrying();
517
- try {
518
- options.onLive?.({ kind: "status", status: "running" });
519
- } catch {
520
- /* never throw from event handling */
521
- }
522
- if (!(await waitForControlledRetry(delay, options.signal, options.control))) {
523
- return controlledDisposition(baseOptions, result) ?? result;
524
- }
525
- result = await runWithStartupRetry(retryOptions);
526
- modelRetries++;
527
- if (result.parked || result.stopReason === "aborted") return result;
528
- if (
529
- !isModelLevelFailure(result) ||
530
- isTerminalModelError(result) ||
531
- isPermanentModelCandidateError(result)
532
- ) break;
533
- }
534
- }
535
-
578
+ if (result.parked || result.stopReason === "aborted") return finish(result);
536
579
  if (!isModelLevelFailure(result)) return finish(result);
537
- // Transient exhaustion plus terminal/permanent candidate errors advance
538
- // to the next configured candidate. Ordinary task/tool failures returned above.
580
+ // Any model-level failure advances immediately to the sole fallback (the
581
+ // current main model). Retain selected-attempt tool diagnostics and usage;
582
+ // ordinary task/tool failures returned above without a handoff.
583
+ if (candidateIndex < candidates.length - 1) retainAttemptDiagnostics(result);
539
584
  }
540
585
 
541
586
  return finish(result!);
package/src/tools.ts CHANGED
@@ -235,7 +235,10 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
235
235
  if (settledIds.length > 0) {
236
236
  return {
237
237
  content: [
238
- { type: "text", text: settledIds.map((id) => formatCompletionBlock(runtime.settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
238
+ { type: "text", text: settledIds.map((id) => {
239
+ const result = runtime.settledRuns.get(id)!;
240
+ return formatCompletionBlock(result, config.maxResultLines, result.projectCwd ?? ctx.cwd);
241
+ }).join("\n\n") },
239
242
  ],
240
243
  details: {},
241
244
  };
@@ -330,7 +333,9 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
330
333
 
331
334
  const outcomes = await Promise.all(targets.map((run) => waitForRun(run.id)));
332
335
  const blocks = outcomes.map((outcome) =>
333
- outcome.result ? formatCompletionBlock(outcome.result, config.maxResultLines, ctx.cwd) : (outcome.note ?? "(no outcome)"),
336
+ outcome.result
337
+ ? formatCompletionBlock(outcome.result, config.maxResultLines, outcome.result.projectCwd ?? ctx.cwd)
338
+ : (outcome.note ?? "(no outcome)"),
334
339
  );
335
340
  return { content: [{ type: "text", text: blocks.join("\n\n") }], details: {} };
336
341
  },
@@ -383,7 +388,17 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
383
388
  if (settledIds.length > 0) {
384
389
  return {
385
390
  content: [
386
- { type: "text", text: settledIds.map((id) => formatCompletionBlock(runtime.settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
391
+ {
392
+ type: "text",
393
+ text: settledIds
394
+ .map((id) => formatCompletionBlock(
395
+ runtime.settledRuns.get(id)!,
396
+ config.maxResultLines,
397
+ runtime.settledRuns.get(id)!.projectCwd ?? ctx.cwd,
398
+ { failedToolDetails: true },
399
+ ))
400
+ .join("\n\n"),
401
+ },
387
402
  ],
388
403
  details: {},
389
404
  };
@@ -421,7 +436,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
421
436
  const activeLines = activeRuns.map((run) => {
422
437
  const thread = runtime.threads.get(run.id);
423
438
  const model = run.modelFallbackFrom
424
- ? `${run.model ?? "?"} (pool fallback from ${run.modelFallbackFrom})`
439
+ ? `${run.model ?? "?"} (main after ${run.modelFallbackFrom} failed)`
425
440
  : (run.model ?? "?");
426
441
  const parts = [
427
442
  `#${run.id} ${run.agent}`,
@@ -450,7 +465,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
450
465
  const usage = formatUsage(result.usage);
451
466
  const label = runLabel(result.task);
452
467
  const model = result.modelFallbackFrom
453
- ? `${result.model ?? "?"} (pool fallback from ${result.modelFallbackFrom})`
468
+ ? `${result.model ?? "?"} (main after ${result.modelFallbackFrom} failed)`
454
469
  : (result.model ?? "?");
455
470
  const isolation = result.isolation === "worktree" ? ` · worktree ${result.integrationStatus ?? "unknown"}` : "";
456
471
  const relations = [
@@ -654,6 +669,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
654
669
  usage: emptyUsage(),
655
670
  model: run?.model,
656
671
  thinking: run?.thinking,
672
+ projectCwd: thread.cwd,
657
673
  stopReason: "aborted",
658
674
  errorMessage: stopMessage,
659
675
  runId,
@@ -677,7 +693,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
677
693
  const maxResultLines = (await configPromise)?.maxResultLines ?? DEFAULT_MAX_RESULT_LINES;
678
694
  runtime.sendCompletionGroup(completionResults.map((result) => ({
679
695
  agent: result.agent,
680
- block: formatCompletionBlock(result, maxResultLines, ctx.cwd),
696
+ block: formatCompletionBlock(result, maxResultLines, result.projectCwd ?? ctx.cwd),
681
697
  triggerTurn: true,
682
698
  })));
683
699
  runtime.completionBatcher.flush();
package/src/widget.ts CHANGED
@@ -4,10 +4,10 @@ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
4
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
5
  import {
6
6
  formatElapsed,
7
+ formatTaskSummary,
7
8
  isRunActiveStatus,
8
9
  monitor,
9
10
  statusIcon,
10
- statusLabel,
11
11
  type RunView,
12
12
  } from "./monitor.ts";
13
13
 
@@ -25,8 +25,10 @@ function compactLine(left: string, right: string, width: number): string {
25
25
  return `${truncateToWidth(left, leftWidth, "…")}${separator}${right}`;
26
26
  }
27
27
 
28
- /** One compact line per genuinely active run. Settled and parked threads never
29
- * appear, so elapsed time cannot keep ticking beside a terminal status. */
28
+ /** One compact primary line per genuinely active run, plus an optional indented
29
+ * activity line. The primary line reserves effective model/thinking and elapsed
30
+ * width before truncating the task. Settled and parked threads never appear, so
31
+ * elapsed time cannot keep ticking beside a terminal status. */
30
32
  export function formatActiveRunLines(
31
33
  runs: readonly RunView[],
32
34
  theme: Theme,
@@ -36,20 +38,55 @@ export function formatActiveRunLines(
36
38
  const dim = (text: string): string => theme.fg("dim", text);
37
39
  return runs
38
40
  .filter((run) => isRunActiveStatus(run.status))
39
- .map((run) => {
41
+ .flatMap((run) => {
40
42
  const icon = statusIcon(run.status, theme);
41
43
  const name = theme.fg("accent", theme.bold(run.agent));
42
- const context = run.relationLabel ?? run.label;
43
- const activity = run.status === "running"
44
- ? (run.activity ?? statusLabel(run.status))
45
- : statusLabel(run.status);
46
- const parts = [
47
- `${icon} ${dim(`#${run.id}`)} ${name}`,
48
- context ? dim(`· ${context}`) : undefined,
49
- activity ? dim(`· ${activity}`) : undefined,
50
- ].filter((part): part is string => Boolean(part));
44
+ const identity = `${icon} ${dim(`#${run.id}`)} ${name}`;
51
45
  const elapsed = formatElapsed(run, now);
52
- return compactLine(parts.join(" "), elapsed ? dim(elapsed) : "", width);
46
+ // Render only the resolved model id plus thinking level. Provider auth and
47
+ // other configuration never enter monitor state or this line.
48
+ const modelId = run.model?.split("/").at(-1);
49
+ const modelSource = formatTaskSummary(
50
+ modelId ? `${modelId}${run.thinking ? `/${run.thinking}` : ""}` : run.thinking ? `thinking:${run.thinking}` : "",
51
+ 64,
52
+ false,
53
+ );
54
+ const taskSource = formatTaskSummary(run.task, 64);
55
+ const primaryPartCount = 2 + (modelSource ? 1 : 0) + (elapsed ? 1 : 0);
56
+ const contentWidth = Math.max(
57
+ 0,
58
+ width -
59
+ visibleWidth(identity) -
60
+ visibleWidth(elapsed) -
61
+ (primaryPartCount - 1) * visibleWidth(" · "),
62
+ );
63
+ const modelDesired = visibleWidth(modelSource);
64
+ const modelFloor = Math.min(modelDesired, Math.min(12, contentWidth));
65
+ let modelWidth = modelSource
66
+ ? Math.min(modelDesired, Math.max(modelFloor, contentWidth - 8))
67
+ : 0;
68
+ let taskWidth = contentWidth - modelWidth;
69
+ if (visibleWidth(taskSource) < taskWidth) {
70
+ modelWidth = Math.min(modelDesired, modelWidth + taskWidth - visibleWidth(taskSource));
71
+ taskWidth = contentWidth - modelWidth;
72
+ }
73
+ const task = taskWidth > 0 ? formatTaskSummary(taskSource, taskWidth) : "";
74
+ const modelThinking = modelWidth > 0 ? formatTaskSummary(modelSource, modelWidth, false) : "";
75
+ const primaryLeft = [
76
+ identity,
77
+ task ? dim(task) : undefined,
78
+ modelThinking ? dim(modelThinking) : undefined,
79
+ ].filter((part): part is string => Boolean(part)).join(" · ");
80
+ const primary = compactLine(primaryLeft, elapsed ? dim(`· ${elapsed}`) : "", width);
81
+
82
+ const activity = run.activity?.trim();
83
+ if (!activity) return [primary];
84
+ const activityIndent = " ";
85
+ const activityWidth = width - visibleWidth(activityIndent);
86
+ if (activityWidth <= 0) return [primary];
87
+ const activitySummary = formatTaskSummary(activity, activityWidth);
88
+ if (!activitySummary) return [primary];
89
+ return [primary, truncateToWidth(`${activityIndent}${dim(activitySummary)}`, width, "")];
53
90
  });
54
91
  }
55
92