@ferris1225/pi-subagents 4.1.18 → 4.1.21

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/format.ts CHANGED
@@ -1,179 +1,173 @@
1
- /**
2
- * Pure formatting/result helpers shared by the subagent tool and the lookup
3
- * tools (subagent_wait/status): usage rendering, completion blocks, synthetic
4
- * result constructors, and run-id matching.
5
- */
6
-
7
- import type { AgentConfig } from "./agents.ts";
8
- import { formatTaskSummary } from "./monitor.ts";
9
- import { emptyUsage } from "./rpc-run.ts";
10
- import {
11
- getResultOutput,
12
- isFailedResult,
13
- truncateResultOutput,
14
- writeResultArtifact,
15
- type SingleResult,
16
- type UsageStats,
17
- } from "./spawn.ts";
18
-
19
- export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
20
- return {
21
- agent: agent.name,
22
- task,
23
- exitCode: -1,
24
- messages: [],
25
- stderr: "",
26
- usage: emptyUsage(),
27
- model: agent.model,
28
- ...(thinking ? { thinking } : {}),
29
- };
30
- }
31
-
32
- export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
33
- return {
34
- agent: agentName,
35
- task,
36
- exitCode: 1,
37
- messages: [],
38
- stderr: errorMessage,
39
- usage: emptyUsage(),
40
- errorMessage,
41
- dispatchFailed: true,
42
- };
43
- }
44
-
45
- /** Failed result for a background task that crashed with an exception (spawn
46
- * infra, delivery API, ...) instead of returning a normal result. */
47
- export function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
48
- const errorMessage = error instanceof Error ? error.message : String(error);
49
- return {
50
- ...queuedResult(agent, task, thinking),
51
- exitCode: 1,
52
- stderr: errorMessage,
53
- stopReason: "error",
54
- errorMessage,
55
- dispatchFailed: true,
56
- };
57
- }
58
-
59
- function formatTokens(count: number): string {
60
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
61
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
62
- return String(count);
63
- }
64
-
65
- export function formatUsage(usage: UsageStats): string {
66
- const parts: string[] = [];
67
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
68
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
69
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
70
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
71
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
72
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
73
- return parts.join(" ");
74
- }
75
-
76
- export interface CompletionFormatOptions {
77
- /** Include individual failed-tool errors. Reserved for explicit status lookup. */
78
- failedToolDetails?: boolean;
79
- /** Project-scoped directory the full result is written to when the output
80
- * is truncated: <projectRoot>/results. */
81
- resultRoot?: string;
82
- }
83
-
84
- export function formatCompletionBlock(
85
- result: SingleResult,
86
- maxResultLines: number,
87
- options: CompletionFormatOptions = {},
88
- ): string {
89
- const failed = isFailedResult(result);
90
- const failedTools = result.failedTools ?? [];
91
- const status = failed
92
- ? "failed"
93
- : options.failedToolDetails && failedTools.length > 0
94
- ? `completed with ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}`
95
- : "completed";
96
- const usage = formatUsage(result.usage);
97
- const output = getResultOutput(result);
98
- const { text, truncated } = truncateResultOutput(output, maxResultLines);const fallbackNote = result.modelFallbackFrom
99
- ? ` (selected model ${result.modelFallbackFrom} failed main ${result.model ?? "dynamic default"})`
100
- : "";
101
- const startupRetryNote = result.startupRetries
102
- ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
103
- : "";
104
- const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
105
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
106
- if (result.isolation === "worktree") {
107
- const isolation =
108
- result.integrationStatus === "integrated"
109
- ? "worktree · changes integrated into the original working tree"
110
- : result.integrationStatus === "no_changes"
111
- ? "worktree · no changes; temporary worktree removed"
112
- : result.integrationStatus === "retained"
113
- ? result.integrationApplied
114
- ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
115
- : "worktree · integration failed; recovery artifacts retained"
116
- : "worktree · isolated";
117
- lines.push(`Isolation: ${isolation}`);
118
- if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
119
- if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
120
- if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
121
- lines.push("");
122
- }
123
- lines.push(text);
124
- // Failed-tool diagnostics are deliberate opt-in via subagent_status: agents
125
- // report their own verification in the output above, and a transient failed
126
- // call (no-match grep, rejected edit) is noise in an automatic delivery.
127
- if (options.failedToolDetails && failedTools.length > 0) {
128
- lines.push(
129
- "",
130
- `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
131
- ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
132
- );
133
- }
134
- if (truncated) {
135
- // The full text lives on disk so the main agent can read it on demand.
136
- const artifact = options.resultRoot
137
- ? writeResultArtifact(output, result.agent, options.resultRoot)
138
- : "(result root unavailable)";
139
- lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${artifact})`);
140
- }
141
- return lines.join("\n");
142
- }
143
-
144
- /** Instruction appended to a model-level failure: the sub-agent's provider never
145
- * produced usable output (or the run stalled), so the task is handed back to the
146
- * main window instead of being left as a dead failure. When the run preserved a
147
- * session with earlier work (and the run id is known), steer the main agent to
148
- * RESUME it in-context once a model is available, instead of re-dispatching
149
- * fresh (which would re-scan everything). */
150
- export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
151
- const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
152
- const detail = result.errorMessage?.trim();
153
- const cause = detail
154
- ? `its model/provider call failed (${detail})`
155
- : "its model was unavailable or failed (or the run stalled)";
156
- const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
157
- const recovery = sessionPreserved
158
- ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
159
- : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
160
- return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
161
- }
162
-
163
- /** Instruction appended whenever a failing gate verdict is delivered: the
164
- * findings return to the main agent, which owns the fix decision — the runtime
165
- * never auto-fixes. Stating it at this exact decision point keeps the main
166
- * agent from relaying the findings to the user and stopping. */
167
- export function reviewFailFollowUpNote(): string {
168
- return "This gate failed and the findings are yours to resolve now: fix them inline or dispatch a worker briefed with these fix instructions, then re-verify the change. Ask the user only before a genuinely destructive or scope-changing fix; do not deliver while a finding stands.";
169
- }
170
-
171
- /** Resolve a run-id request to actual ids: an exact numeric match always wins
172
- * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
173
- * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
174
- * from returning — or, for subagent_stop, acting on — a whole prefix family. */
175
- export function matchRunIds(ids: number[], requested: string): number[] {
176
- const exact = ids.filter((id) => String(id) === requested);
177
- if (exact.length > 0) return exact;
178
- return ids.filter((id) => String(id).startsWith(requested));
179
- }
1
+ /**
2
+ * Pure formatting/result helpers shared by the subagent tool and completion
3
+ * delivery: usage rendering, completion blocks, synthetic result constructors,
4
+ * and run-id matching.
5
+ */
6
+
7
+ import type { AgentConfig } from "./agents.ts";
8
+ import { formatTaskSummary } from "./monitor.ts";
9
+ import { emptyUsage } from "./rpc-run.ts";
10
+ import {
11
+ getResultOutput,
12
+ isFailedResult,
13
+ truncateResultOutput,
14
+ writeResultArtifact,
15
+ type SingleResult,
16
+ type UsageStats,
17
+ } from "./spawn.ts";
18
+
19
+ export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
20
+ return {
21
+ agent: agent.name,
22
+ task,
23
+ exitCode: -1,
24
+ messages: [],
25
+ stderr: "",
26
+ usage: emptyUsage(),
27
+ model: agent.model,
28
+ ...(thinking ? { thinking } : {}),
29
+ };
30
+ }
31
+
32
+ export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
33
+ return {
34
+ agent: agentName,
35
+ task,
36
+ exitCode: 1,
37
+ messages: [],
38
+ stderr: errorMessage,
39
+ usage: emptyUsage(),
40
+ errorMessage,
41
+ dispatchFailed: true,
42
+ };
43
+ }
44
+
45
+ /** Failed result for a background task that crashed with an exception (spawn
46
+ * infra, delivery API, ...) instead of returning a normal result. */
47
+ export function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
48
+ const errorMessage = error instanceof Error ? error.message : String(error);
49
+ return {
50
+ ...queuedResult(agent, task, thinking),
51
+ exitCode: 1,
52
+ stderr: errorMessage,
53
+ stopReason: "error",
54
+ errorMessage,
55
+ dispatchFailed: true,
56
+ };
57
+ }
58
+
59
+ function formatTokens(count: number): string {
60
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
61
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
62
+ return String(count);
63
+ }
64
+
65
+ export function formatUsage(usage: UsageStats): string {
66
+ const parts: string[] = [];
67
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
68
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
69
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
70
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
71
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
72
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
73
+ return parts.join(" ");
74
+ }
75
+
76
+ export interface CompletionFormatOptions {
77
+ /** Project-scoped directory the full result is written to when the output
78
+ * is truncated: <projectRoot>/results. */
79
+ resultRoot?: string;
80
+ }
81
+
82
+ export function formatCompletionBlock(
83
+ result: SingleResult,
84
+ maxResultLines: number,
85
+ options: CompletionFormatOptions = {},
86
+ ): string {
87
+ const failed = isFailedResult(result);
88
+ const failedTools = result.failedTools ?? [];
89
+ const status = failed ? "failed" : "completed";
90
+ const usage = formatUsage(result.usage);
91
+ const output = getResultOutput(result);
92
+ const { text, truncated } = truncateResultOutput(output, maxResultLines);const fallbackNote = result.modelFallbackFrom
93
+ ? ` (selected model ${result.modelFallbackFrom} failed → main ${result.model ?? "dynamic default"})`
94
+ : "";
95
+ const startupRetryNote = result.startupRetries
96
+ ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
97
+ : "";
98
+ const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
99
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
100
+ if (result.isolation === "worktree") {
101
+ const isolation =
102
+ result.integrationStatus === "integrated"
103
+ ? "worktree · changes integrated into the original working tree"
104
+ : result.integrationStatus === "no_changes"
105
+ ? "worktree · no changes; temporary worktree removed"
106
+ : result.integrationStatus === "retained"
107
+ ? result.integrationApplied
108
+ ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
109
+ : "worktree · integration failed; recovery artifacts retained"
110
+ : "worktree · isolated";
111
+ lines.push(`Isolation: ${isolation}`);
112
+ if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
113
+ if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
114
+ if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
115
+ lines.push("");
116
+ }
117
+ lines.push(text);
118
+ // Failed-tool diagnostics ride along only when the run itself failed: they
119
+ // explain the failure, while on a successful run a transient failed call
120
+ // (no-match grep, rejected edit) is noise the agent already worked around.
121
+ if (failed && failedTools.length > 0) {
122
+ lines.push(
123
+ "",
124
+ `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
125
+ ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
126
+ );
127
+ }
128
+ if (truncated) {
129
+ // The full text lives on disk so the main agent can read it on demand.
130
+ const artifact = options.resultRoot
131
+ ? writeResultArtifact(output, result.agent, options.resultRoot)
132
+ : "(result root unavailable)";
133
+ lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${artifact})`);
134
+ }
135
+ return lines.join("\n");
136
+ }
137
+
138
+ /** Instruction appended to a model-level failure: the sub-agent's provider never
139
+ * produced usable output (or the run stalled), so the task is handed back to the
140
+ * main window instead of being left as a dead failure. When the run preserved a
141
+ * session with earlier work (and the run id is known), steer the main agent to
142
+ * RESUME it in-context once a model is available, instead of re-dispatching
143
+ * fresh (which would re-scan everything). */
144
+ export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
145
+ const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
146
+ const detail = result.errorMessage?.trim();
147
+ const cause = detail
148
+ ? `its model/provider call failed (${detail})`
149
+ : "its model was unavailable or failed (or the run stalled)";
150
+ const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
151
+ const recovery = sessionPreserved
152
+ ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
153
+ : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
154
+ return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
155
+ }
156
+
157
+ /** Instruction appended whenever a failing gate verdict is delivered: the
158
+ * findings return to the main agent, which owns the fix decision the runtime
159
+ * never auto-fixes. Stating it at this exact decision point keeps the main
160
+ * agent from relaying the findings to the user and stopping. */
161
+ export function reviewFailFollowUpNote(): string {
162
+ return "This gate failed and the findings are yours to resolve now: fix them inline or dispatch a worker briefed with these fix instructions, then re-verify the change. Ask the user only before a genuinely destructive or scope-changing fix; do not deliver while a finding stands.";
163
+ }
164
+
165
+ /** Resolve a run-id request to actual ids: an exact numeric match always wins
166
+ * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
167
+ * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
168
+ * from returning or, for subagent_stop, acting on a whole prefix family. */
169
+ export function matchRunIds(ids: number[], requested: string): number[] {
170
+ const exact = ids.filter((id) => String(id) === requested);
171
+ if (exact.length > 0) return exact;
172
+ return ids.filter((id) => String(id).startsWith(requested));
173
+ }
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * The heavy lifting lives in focused modules:
6
6
  * - dispatch.ts — tool contract, managed role policy, internal steps
7
7
  * - thread-lifecycle.ts — stable generations, controls, final integration/delivery
8
- * - tools.ts — subagent_control / subagent_wait / status / stop
8
+ * - tools.ts — subagent_control / subagent_stop
9
9
  * - announcements.ts — session-start recovery, notices, and widget install
10
10
  * - widget.ts — active-only TUI run status
11
11
  * - runtime.ts — shared per-session state
@@ -78,16 +78,16 @@ export default function (pi: ExtensionAPI): void {
78
78
 
79
79
  registerAnnouncements(pi, runtime);
80
80
 
81
- // Durable bootstrap: restore parked/settled threads from the manifest so a
82
- // reload or restart keeps status and resume working, then age out old
83
- // records and sweep leaked temp/state directories. Fire-and-forget; every
84
- // stage is best-effort and never blocks registration.
81
+ // Durable bootstrap: restore parked threads from the manifest so a reload or
82
+ // restart keeps status and resume working, then age out old records and sweep
83
+ // leaked temp/state directories. Registration never blocks on it and every
84
+ // stage is best-effort; the restore pass is published as runtime.durableRestore
85
+ // so the tools and the session-start notice wait for it instead of racing it.
85
86
  void bootstrapDurableState(runtime);
86
87
 
87
88
  // Proactive dispatch: inject the delegation directive into the parent system prompt.
88
89
  pi.on("before_agent_start", async (event, ctx) => {
89
90
  const config = await loadConfig(configPath);
90
- if (!config.proactiveInjection) return undefined;
91
91
  const { agents } = discoverAgents(ctx.cwd, {
92
92
  scope: config.agentScope,
93
93
  enabledNames: config.enabledAgents,
package/src/models.ts CHANGED
@@ -45,7 +45,6 @@ export interface ResolvedAgentModelRoute {
45
45
  export interface AgentModelRouteInput {
46
46
  selectedRef?: string;
47
47
  mainRef?: string;
48
- declaredDefaultRef?: string;
49
48
  /** When supplied, a configured selection outside this live set is skipped. */
50
49
  availableRefs?: readonly string[];
51
50
  }
@@ -107,20 +106,19 @@ export function filterUnavailableModelOverrides(
107
106
  *
108
107
  * configured selection -> current main-window model
109
108
  *
110
- * Without an override, current main is primary; the agent-declared default is
111
- * used only when no main model exists. A configured selection that Pi no longer
112
- * reports as available is skipped immediately instead of spawning a doomed child.
109
+ * Without an override the current main model is primary, so an agent never
110
+ * pins its own model. A configured selection that Pi no longer reports as
111
+ * available is skipped immediately instead of spawning a doomed child.
113
112
  */
114
113
  export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
115
114
  const selectedRef = cleanModelRef(input.selectedRef);
116
115
  const mainRef = cleanModelRef(input.mainRef);
117
- const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
118
116
  const available = input.availableRefs
119
117
  ? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
120
118
  : undefined;
121
119
  const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
122
120
  const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
123
- const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
121
+ const primaryRef = usableSelectedRef ?? mainRef;
124
122
  const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
125
123
  const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
126
124
  return {
package/src/monitor.ts CHANGED
@@ -22,6 +22,16 @@ export type RunStatus = "queued" | "running" | "interrupting" | "parked" | "done
22
22
  export type ContinuationKind = "resume-retained" | "resume-appended";
23
23
  export type WorkflowStageStatus = "done" | "active" | "pending" | "changes" | "failed";
24
24
 
25
+ /** Why a queued run has produced no output yet. Three genuinely different
26
+ * situations used to be reported as one "queued": waiting for a free process
27
+ * slot (real pool pacing), serialized behind the shared-checkout repository
28
+ * write lane (a slot is free — the wait is write serialization), and already
29
+ * starting its child process (a slot is held; output is seconds away).
30
+ * Conflating them taught the parent model that the pool was exhausted when it
31
+ * was not, so it stopped dispatching. Meaningful only while status is
32
+ * "queued"; cleared on every transition out of it. */
33
+ export type RunWaitReason = "process-slot" | "repository-lane" | "starting";
34
+
25
35
  /** Ephemeral projection of one real or currently planned managed stage. It is
26
36
  * live monitor state only; durable results remain the per-run chain records. */
27
37
  export interface WorkflowStage {
@@ -58,6 +68,8 @@ export interface RunView {
58
68
  * one isolated worktree; changes when a continuation worktree is created. */
59
69
  worktreeId?: string;
60
70
  status: RunStatus;
71
+ /** What a queued run is actually waiting for; see RunWaitReason. */
72
+ waitReason?: RunWaitReason;
61
73
  usage: UsageStats;
62
74
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
63
75
  activity?: string;
@@ -93,6 +105,10 @@ export interface RunChainMeta {
93
105
  isolation?: IsolationMode;
94
106
  worktreeId?: string;
95
107
  continuationKind?: ContinuationKind;
108
+ /** Initial wait reason; defaults to "process-slot" (a fresh dispatch enters
109
+ * the process queue). Workflow-internal children pass "starting" because
110
+ * they spawn immediately and never wait for a slot. */
111
+ waitReason?: RunWaitReason;
96
112
  }
97
113
 
98
114
  // ---------------------------------------------------------------------------
@@ -247,13 +263,16 @@ export const RUN_LABEL_MAX = 32;
247
263
 
248
264
  /**
249
265
  * Short content label for a run, derived from its task: the single most
250
- * distinguishing fragment (path, quoted phrase, symbol) so concurrent same-agent
251
- * runs are told apart by WHAT they do, not just their run id. A long path keeps
252
- * its tail (the filename is the recognisable part); a task with no recognizable
253
- * fragment falls back to a head slice of its prose. Grapheme-safe.
266
+ * distinguishing fragment so concurrent same-agent runs are told apart by WHAT
267
+ * they do, not just their run id. A file/directory path outranks other
268
+ * fragment kinds (a kebab-case word like "edge-case" never beats
269
+ * "tests/config.test.ts"); a long path keeps its tail (the filename is the
270
+ * recognisable part); a task with no recognizable fragment falls back to a
271
+ * head slice of its prose. Grapheme-safe.
254
272
  */
255
273
  export function runLabel(task: string): string {
256
- const fragment = extractKeyFragments(task)[0];
274
+ const fragments = extractKeyFragments(task);
275
+ const fragment = fragments.find((candidate) => /[\\/]|\.[A-Za-z0-9]{1,5}$/.test(candidate)) ?? fragments[0];
257
276
  const src = fragment ?? stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
258
277
  if (visibleWidth(src) <= RUN_LABEL_MAX) return src;
259
278
  const chars = [...graphemeSegmenter.segment(src)].map((s) => s.segment);
@@ -388,7 +407,11 @@ export function formatToolActivity(toolName: string, args: unknown): string {
388
407
  };
389
408
  let target: string;
390
409
  switch (toolName) {
410
+ // Pi ships one shell tool per platform flavor, all with a `command`
411
+ // parameter: a Windows parent that swapped bash for powershell must still
412
+ // show the command it is running, not a bare tool name.
391
413
  case "bash":
414
+ case "powershell":
392
415
  case "shell":
393
416
  target = pick("command");
394
417
  break;
@@ -476,6 +499,7 @@ export class MonitorStore {
476
499
  model,
477
500
  thinking,
478
501
  status: "queued",
502
+ waitReason: meta?.waitReason ?? "process-slot",
479
503
  usage: emptyUsage(),
480
504
  elapsedMs: 0,
481
505
  ...(meta?.groupId ? { groupId: meta.groupId } : {}),
@@ -496,6 +520,7 @@ export class MonitorStore {
496
520
  const isExecuting = status === "running" || status === "interrupting";
497
521
  const now = Date.now();
498
522
  run.status = status;
523
+ if (status !== "queued") run.waitReason = undefined;
499
524
  if (isExecuting && !wasExecuting) {
500
525
  run.startedAt ??= now;
501
526
  run.activeSince = now;
@@ -509,6 +534,15 @@ export class MonitorStore {
509
534
  }
510
535
  this.notify();
511
536
  }
537
+ /** Record what a still-queued run is actually waiting for, at the exact
538
+ * transition owned by the caller (lane wait begins, child process starts). */
539
+ setWaitReason(id: number, waitReason: RunWaitReason): void {
540
+ const run = this.find(id);
541
+ if (!run || run.status !== "queued" || run.waitReason === waitReason) return;
542
+ run.waitReason = waitReason;
543
+ this.notify();
544
+ }
545
+
512
546
  /** Switch a stable top-level row from one model run to workflow ownership.
513
547
  * The original role remains for identity; child rows show stage telemetry. */
514
548
  setManagedWorkflow(id: number, active: boolean): void {
@@ -645,6 +679,7 @@ export class MonitorStore {
645
679
  }
646
680
  : {}),
647
681
  status: "queued",
682
+ waitReason: "process-slot",
648
683
  usage: emptyUsage(),
649
684
  elapsedMs: meta?.elapsedMs ?? 0,
650
685
  continuationKind: meta?.continuationKind,
@@ -662,6 +697,7 @@ export class MonitorStore {
662
697
  if (isolation === "worktree" && meta?.worktreeId) run.worktreeId = meta.worktreeId;
663
698
  else if (isolation !== "worktree") run.worktreeId = undefined;
664
699
  run.status = "queued";
700
+ run.waitReason = "process-slot";
665
701
  run.usage = emptyUsage();
666
702
  run.activity = undefined;
667
703
  run.managedWorkflow = undefined;
@@ -778,3 +814,18 @@ export function statusLabel(status: RunStatus): string {
778
814
  return "stopped";
779
815
  }
780
816
  }
817
+
818
+ /** Truthful description of why a queued run has produced no output yet, so a
819
+ * repository-lane wait or a starting child is never mistaken for an exhausted
820
+ * process pool. Undefined for runs that are not queued. */
821
+ export function runWaitLabel(run: Pick<RunView, "status" | "waitReason">): string | undefined {
822
+ if (run.status !== "queued") return undefined;
823
+ switch (run.waitReason) {
824
+ case "repository-lane":
825
+ return "waiting for the repository write lane";
826
+ case "starting":
827
+ return "starting";
828
+ default:
829
+ return "queued for a free process slot";
830
+ }
831
+ }