@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/README.md +384 -337
- package/agents/cleaner.md +50 -45
- package/agents/documenter.md +40 -42
- package/agents/explorer.md +40 -45
- package/agents/reviewer.md +82 -82
- package/agents/synthesizer.md +39 -0
- package/agents/worker.md +43 -45
- package/package.json +55 -55
- package/src/agents.ts +25 -5
- package/src/announcements.ts +78 -75
- package/src/background.ts +11 -0
- package/src/completion.ts +19 -9
- package/src/config.ts +3 -10
- package/src/dispatch.ts +817 -647
- package/src/durable.ts +443 -402
- package/src/format.ts +173 -179
- package/src/index.ts +6 -6
- package/src/models.ts +4 -6
- package/src/monitor.ts +56 -5
- package/src/prompt.ts +14 -21
- package/src/recovery.ts +145 -145
- package/src/rpc-run.ts +993 -993
- package/src/runtime.ts +22 -4
- package/src/session-fork.ts +2 -0
- package/src/setup.ts +23 -43
- package/src/spawn.ts +668 -654
- package/src/temp-hygiene.ts +230 -174
- package/src/thread-lifecycle.ts +1487 -1399
- package/src/tools.ts +384 -712
- package/src/widget.ts +195 -157
- package/src/workflow.ts +24 -8
- package/src/worktree.ts +18 -0
package/src/format.ts
CHANGED
|
@@ -1,179 +1,173 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure formatting/result helpers shared by the subagent tool and
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
*
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 /
|
|
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
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// stage is best-effort
|
|
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
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
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
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
|
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
|
+
}
|