@arhen/pi-core-subagent 1.3.42 → 1.3.44
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/package.json +1 -1
- package/src/format.ts +4 -2
- package/src/manager.ts +67 -5
- package/src/peek.ts +100 -26
- package/src/types.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arhen/pi-core-subagent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.44",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
|
|
6
6
|
"license": "MIT",
|
package/src/format.ts
CHANGED
|
@@ -242,8 +242,9 @@ export function makeSummary(run: RunSnapshot): string {
|
|
|
242
242
|
// Edges are named so the leader can compare what it delegated against what came back.
|
|
243
243
|
const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
|
|
244
244
|
const fileNote = task.agentFile ? ` [${task.agentFile}]` : "";
|
|
245
|
+
const swap = task.modelNote ? `\nModel: ${task.modelNote}` : "";
|
|
245
246
|
lines.push(
|
|
246
|
-
`\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task, run.tasks)}`,
|
|
247
|
+
`\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${swap}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task, run.tasks)}`,
|
|
247
248
|
);
|
|
248
249
|
}
|
|
249
250
|
// Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
|
|
@@ -265,9 +266,10 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
|
|
|
265
266
|
// 403, wrong persona) that override is the likeliest cause, so it has to be
|
|
266
267
|
// in the notice, not only in the run summary the leader may never read.
|
|
267
268
|
const src = task.agentFile ? `\nAgent file: ${task.agentFile}${task.model ? ` (model ${task.model})` : ""}` : "";
|
|
269
|
+
const swap = task.modelNote ? `\nModel: ${task.modelNote}` : "";
|
|
268
270
|
return [
|
|
269
271
|
`Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}${wt}`,
|
|
270
|
-
`Goal: ${goal}${src}`,
|
|
272
|
+
`Goal: ${goal}${src}${swap}`,
|
|
271
273
|
isStartupFailure(task, kind)
|
|
272
274
|
? "Never started — stop and diagnose before spawning anything else: a config-level error (model, plan, auth, agent file) fails identically on every respawn."
|
|
273
275
|
: `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`,
|
package/src/manager.ts
CHANGED
|
@@ -22,10 +22,10 @@ import {
|
|
|
22
22
|
activitySnippet,
|
|
23
23
|
describeCall,
|
|
24
24
|
getFirstText,
|
|
25
|
+
isStartupFailure,
|
|
25
26
|
isTalking,
|
|
26
27
|
makeNotice,
|
|
27
28
|
makeTaskNotice,
|
|
28
|
-
isStartupFailure,
|
|
29
29
|
SubagentsWidget,
|
|
30
30
|
truncateText,
|
|
31
31
|
} from "./format.ts";
|
|
@@ -160,14 +160,26 @@ export function cloneRun(run: RunSnapshot): RunSnapshot {
|
|
|
160
160
|
return JSON.parse(JSON.stringify(run)) as RunSnapshot;
|
|
161
161
|
}
|
|
162
162
|
/** Resolve a child model from the pi model registry.
|
|
163
|
-
* Order: explicit "provider/model-id" or bare id
|
|
164
|
-
*
|
|
165
|
-
*
|
|
163
|
+
* Order: explicit "provider/model-id" or bare id → agent file model → parent's
|
|
164
|
+
* current model (ctx.model) → undefined (createAgentSession falls back to settings).
|
|
165
|
+
*
|
|
166
|
+
* A BARE id is ambiguous: `claude-sonnet-5` exists on anthropic, commandcode,
|
|
167
|
+
* github-copilot and openrouter at once, and an agent file's `model:` is written
|
|
168
|
+
* without a provider. Taking the registry's first match routed children to a
|
|
169
|
+
* provider the session never chose (403 MODEL_NOT_IN_PLAN on every spawn), so the
|
|
170
|
+
* ACTIVE SESSION's provider is searched first — exact id, then its own prefixed
|
|
171
|
+
* form (9router carries `cc/claude-opus-5`, not `claude-opus-5`). */
|
|
166
172
|
export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
|
|
167
173
|
if (!explicit?.trim()) return ctx.model; // inherit the parent's active model
|
|
168
174
|
const ref = explicit.trim();
|
|
169
175
|
if (!ctx.modelRegistry) return ctx.model; // no registry to check against (tests, headless)
|
|
170
176
|
const available = ctx.modelRegistry.getAvailable();
|
|
177
|
+
const sessionProvider = ctx.model?.provider;
|
|
178
|
+
if (sessionProvider && !ref.includes("/")) {
|
|
179
|
+
const own = available.filter((m) => m.provider === sessionProvider);
|
|
180
|
+
const hit = own.find((m) => m.id === ref) ?? own.find((m) => m.id.endsWith(`/${ref}`));
|
|
181
|
+
if (hit) return hit;
|
|
182
|
+
}
|
|
171
183
|
// Model ids can contain slashes (e.g. 9router/cc/claude-opus-5), so a bare id
|
|
172
184
|
// match and every provider/id split point must be tried, not just the first.
|
|
173
185
|
const byId = available.find((m) => m.id === ref);
|
|
@@ -179,6 +191,48 @@ export function resolveChildModel(ctx: ExtensionContext, explicit: string | unde
|
|
|
179
191
|
throw new Error(`Model not found: ${ref}`);
|
|
180
192
|
}
|
|
181
193
|
|
|
194
|
+
/** One throwaway request against the resolved model. A child that cannot reach its
|
|
195
|
+
* model dies on its FIRST turn with no output, after a worktree and a session have
|
|
196
|
+
* already been built — and an agent file's `model:` is chosen by a file the leader
|
|
197
|
+
* never wrote, so "it resolved" is not evidence it is usable (plan gates, ZDR,
|
|
198
|
+
* dead keys all pass resolution). Returns the error text, or undefined when OK. */
|
|
199
|
+
async function probeModel(
|
|
200
|
+
ctx: ExtensionContext,
|
|
201
|
+
model: Model<Api>,
|
|
202
|
+
signal: AbortSignal | undefined,
|
|
203
|
+
): Promise<string | undefined> {
|
|
204
|
+
try {
|
|
205
|
+
const reply = await ctx.modelRegistry.complete(
|
|
206
|
+
model,
|
|
207
|
+
{ messages: [{ role: "user", content: "ping", timestamp: Date.now() }] },
|
|
208
|
+
{ maxTokens: 16, signal },
|
|
209
|
+
);
|
|
210
|
+
return reply.stopReason === "error" ? (reply.errorMessage ?? "provider returned an error") : undefined;
|
|
211
|
+
} catch (err) {
|
|
212
|
+
return err instanceof Error ? err.message : String(err);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Preflight for the model a child is about to run on. Probes only what is not
|
|
217
|
+
* already proven: the session's own model answered this very turn. On failure the
|
|
218
|
+
* session model is the fallback — the one model known to work right now. */
|
|
219
|
+
export async function ensureUsableModel(
|
|
220
|
+
ctx: ExtensionContext,
|
|
221
|
+
model: Model<Api> | undefined,
|
|
222
|
+
signal: AbortSignal | undefined,
|
|
223
|
+
): Promise<{ model: Model<Api> | undefined; note?: string }> {
|
|
224
|
+
const session = ctx.model;
|
|
225
|
+
if (!model || !ctx.modelRegistry) return { model };
|
|
226
|
+
if (session && model.provider === session.provider && model.id === session.id) return { model };
|
|
227
|
+
const error = await probeModel(ctx, model, signal);
|
|
228
|
+
if (!error) return { model };
|
|
229
|
+
if (!session) throw new Error(`Model ${model.provider}/${model.id} is unusable: ${error}`);
|
|
230
|
+
return {
|
|
231
|
+
model: session,
|
|
232
|
+
note: `${model.provider}/${model.id} failed preflight (${error}); using session model ${session.provider}/${session.id}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
182
236
|
/** Extension-registered providers (e.g. 9router) live only in the parent's
|
|
183
237
|
* in-memory runtime. A child builds its runtime from disk and would lose them,
|
|
184
238
|
* so replay the parent's registrations before the child resolves auth. */
|
|
@@ -810,10 +864,18 @@ export class SubagentManager {
|
|
|
810
864
|
try {
|
|
811
865
|
model = resolveChildModel(ctx, file?.model ?? input.model);
|
|
812
866
|
validateThinking(model, thinking);
|
|
867
|
+
// Resolution only proves the id exists. Probe it before anything is built,
|
|
868
|
+
// and fall back to the session's own model when the probe fails.
|
|
869
|
+
const checked = await ensureUsableModel(ctx, model, signal);
|
|
870
|
+
model = checked.model;
|
|
871
|
+
if (checked.note) {
|
|
872
|
+
task.modelNote = checked.note;
|
|
873
|
+
validateThinking(model, thinking);
|
|
874
|
+
}
|
|
813
875
|
// Record the model ACTUALLY used — an agent file's `model:` overrides the
|
|
814
876
|
// requested one, and showing the request in the widget hides that entirely
|
|
815
877
|
// (a 403 then names a model the leader never asked for).
|
|
816
|
-
if (model) this.updateTask(run, task, { model: model.id }, ctx, onUpdate);
|
|
878
|
+
if (model) this.updateTask(run, task, { model: model.id, modelNote: checked.note }, ctx, onUpdate);
|
|
817
879
|
} catch (err) {
|
|
818
880
|
this.updateTask(
|
|
819
881
|
run,
|
package/src/peek.ts
CHANGED
|
@@ -38,51 +38,119 @@ function readTail(path: string): string {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Reasoning markup is the model talking to itself, and empty `<think></think>`
|
|
42
|
+
* pairs are the commonest single line in a transcript — they filled the pane with
|
|
43
|
+
* rows carrying no information at all. */
|
|
44
|
+
function stripThinking(text: string): string {
|
|
45
|
+
return text
|
|
46
|
+
.replace(/<think>[\s\S]*?<\/think>/g, " ")
|
|
47
|
+
.replace(/<\/?(?:think|thinking|reasoning)>/g, " ")
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping real terminal escapes is the point
|
|
52
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
53
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: same
|
|
54
|
+
const CONTROL = /[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
55
|
+
|
|
56
|
+
/** Tool output is terminal output: it carries colour escapes and carriage returns
|
|
57
|
+
* that corrupt the pane's own styling (stray `[0m` mid-row, cursor jumps). */
|
|
41
58
|
function clip(text: string, max: number): string {
|
|
42
|
-
const flat = text.replace(/\s+/g, " ").trim();
|
|
59
|
+
const flat = text.replace(ANSI, "").replace(CONTROL, " ").replace(/\s+/g, " ").trim();
|
|
43
60
|
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
63
|
+
/** Long absolute paths are almost entirely prefix. The tail is what identifies
|
|
64
|
+
* the file, so shorten from the left and keep the last segments. */
|
|
65
|
+
function shortenPath(text: string): string {
|
|
66
|
+
return text.replace(/(?:\/[\w.@+-]+){3,}/g, (path) => {
|
|
67
|
+
const parts = path.split("/").filter(Boolean);
|
|
68
|
+
return parts.length <= 3 ? path : `…/${parts.slice(-3).join("/")}`;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const PATH_ARGS = ["path", "file", "filePath", "command", "pattern", "query", "url", "task", "subject"];
|
|
73
|
+
|
|
74
|
+
/** The argument that says WHAT a call does. Object.values() order is insertion
|
|
75
|
+
* order, so the old "first string wins" picked `oldText` blobs over `path`. */
|
|
76
|
+
function callSummary(args: Record<string, unknown> | undefined): string {
|
|
77
|
+
for (const key of PATH_ARGS) {
|
|
78
|
+
const value = args?.[key];
|
|
79
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
80
|
+
}
|
|
81
|
+
const first = Object.values(args ?? {}).find((v) => typeof v === "string" && v.trim() !== "");
|
|
82
|
+
return typeof first === "string" ? first : "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type PeekLine = { gutter: string; text: string; kind: "call" | "result" | "error" | "say" };
|
|
86
|
+
|
|
87
|
+
/** One session-file entry → display lines. A turn with 4 tool calls is 4 lines:
|
|
88
|
+
* keeping only the first hid the parallel calls that explain what the child did. */
|
|
89
|
+
function eventLines(raw: string): PeekLine[] {
|
|
48
90
|
let entry: any;
|
|
49
91
|
try {
|
|
50
92
|
entry = JSON.parse(raw);
|
|
51
93
|
} catch {
|
|
52
|
-
return
|
|
94
|
+
return [];
|
|
53
95
|
}
|
|
54
96
|
const msg = entry?.message;
|
|
55
|
-
if (!msg) return
|
|
56
|
-
const out: [
|
|
57
|
-
|
|
97
|
+
if (!msg) return [];
|
|
98
|
+
const out: PeekLine[] = [];
|
|
99
|
+
const isResult = msg.role === "toolResult";
|
|
100
|
+
for (const block of Array.isArray(msg.content) ? msg.content : []) {
|
|
58
101
|
if (block.type === "toolCall") {
|
|
59
|
-
const arg =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
102
|
+
const arg = callSummary(block.arguments);
|
|
103
|
+
out.push({ gutter: "→", kind: "call", text: `${block.name}${arg ? ` ${shortenPath(clip(arg, 110))}` : ""}` });
|
|
104
|
+
} else if (block.type === "text") {
|
|
105
|
+
const text = stripThinking(block.text ?? "");
|
|
106
|
+
if (!text) continue; // a turn that was pure reasoning has nothing to show
|
|
107
|
+
if (isResult) {
|
|
108
|
+
const kind = msg.isError ? "error" : "result";
|
|
109
|
+
const name = msg.toolName ? `${msg.toolName}: ` : "";
|
|
110
|
+
out.push({ gutter: msg.isError ? "✗" : "←", kind, text: `${name}${shortenPath(clip(text, 150))}` });
|
|
111
|
+
} else {
|
|
112
|
+
out.push({ gutter: "·", kind: "say", text: clip(text, 150) });
|
|
113
|
+
}
|
|
65
114
|
}
|
|
66
115
|
}
|
|
67
|
-
return out
|
|
116
|
+
return out;
|
|
68
117
|
}
|
|
69
118
|
|
|
70
|
-
function tailLines(path: string, max: number): [
|
|
119
|
+
function tailLines(path: string, max: number): PeekLine[] {
|
|
71
120
|
let text: string;
|
|
72
121
|
try {
|
|
73
122
|
text = readTail(path);
|
|
74
123
|
} catch {
|
|
75
|
-
return [
|
|
124
|
+
return [{ gutter: "·", kind: "say", text: "(session file not readable yet)" }];
|
|
76
125
|
}
|
|
77
|
-
const lines: [
|
|
126
|
+
const lines: PeekLine[] = [];
|
|
78
127
|
// First line of a mid-file read is usually a fragment — drop it.
|
|
79
|
-
for (const raw of text.split("\n").slice(1))
|
|
80
|
-
const line = eventLine(raw);
|
|
81
|
-
if (line) lines.push(line);
|
|
82
|
-
}
|
|
128
|
+
for (const raw of text.split("\n").slice(1)) lines.push(...eventLines(raw));
|
|
83
129
|
return lines.slice(-max);
|
|
84
130
|
}
|
|
85
131
|
|
|
132
|
+
/** Gutter colour carries the line's role, so the eye can skip to failures without
|
|
133
|
+
* reading: → call, ← result, ✗ error, · the child speaking. */
|
|
134
|
+
function renderLine(line: PeekLine, theme: Theme): string {
|
|
135
|
+
if (line.kind === "error") return `${theme.fg("error", line.gutter)} ${theme.fg("error", line.text)}`;
|
|
136
|
+
if (line.kind === "call") {
|
|
137
|
+
const space = line.text.indexOf(" ");
|
|
138
|
+
const name = space === -1 ? line.text : line.text.slice(0, space);
|
|
139
|
+
const arg = space === -1 ? "" : line.text.slice(space + 1);
|
|
140
|
+
return `${theme.fg("accent", line.gutter)} ${theme.fg("toolTitle", name)}${arg ? ` ${theme.fg("muted", arg)}` : ""}`;
|
|
141
|
+
}
|
|
142
|
+
if (line.kind === "result") return `${theme.fg("dim", line.gutter)} ${theme.fg("dim", line.text)}`;
|
|
143
|
+
return `${theme.fg("dim", line.gutter)} ${line.text}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Status as a coloured word, the way pi marks tool state. */
|
|
147
|
+
function statusTag(status: string, theme: Theme): string {
|
|
148
|
+
if (status === "failed") return theme.fg("error", status);
|
|
149
|
+
if (status === "completed") return theme.fg("success", status);
|
|
150
|
+
if (status === "aborted") return theme.fg("warning", status);
|
|
151
|
+
return theme.fg("muted", status);
|
|
152
|
+
}
|
|
153
|
+
|
|
86
154
|
export interface PeekPane {
|
|
87
155
|
render(width: number): string[];
|
|
88
156
|
handleInput(data: string): void;
|
|
@@ -128,8 +196,14 @@ export function createPeekPane(
|
|
|
128
196
|
const hint = confirming
|
|
129
197
|
? theme.fg("error", `abort ${task.agent}? y / n`)
|
|
130
198
|
: theme.fg("dim", tailing ? "esc back · x abort" : "shift+↑↓ / jk move · enter tail · x abort · esc close");
|
|
131
|
-
|
|
132
|
-
|
|
199
|
+
// Breadcrumb, not a bare name: while tailing, WHERE you are is the thing you
|
|
200
|
+
// lose track of first, and the child's status belongs next to its name.
|
|
201
|
+
const crumb = tailing
|
|
202
|
+
? `${theme.fg("muted", "Subagents")}${theme.fg("dim", " › ")}${theme.fg("accent", theme.bold(task.agent))} ${statusTag(task.status, theme)}`
|
|
203
|
+
: theme.fg("accent", theme.bold("Subagents"));
|
|
204
|
+
const title = `${crumb} ${theme.fg("muted", `${selected + 1}/${tasks.length}`)}`;
|
|
205
|
+
const rule = theme.fg("borderMuted", "─".repeat(Math.max(0, width - 4)));
|
|
206
|
+
const lines = [row("", width), row(title, width), row(hint, width), row(rule, width)];
|
|
133
207
|
|
|
134
208
|
if (!tailing) {
|
|
135
209
|
for (const [i, t] of tasks.entries()) {
|
|
@@ -140,9 +214,9 @@ export function createPeekPane(
|
|
|
140
214
|
lines.push(row(theme.fg("dim", "(no session file — agent has not started yet)"), width));
|
|
141
215
|
} else {
|
|
142
216
|
// ponytail: re-reads the tail each render (700ms poll). A watcher only pays off for files far bigger than a child session.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
217
|
+
const tail = tailLines(task.sessionFile, TAIL_ROWS);
|
|
218
|
+
if (tail.length === 0) lines.push(row(theme.fg("dim", "(no activity yet)"), width));
|
|
219
|
+
for (const line of tail) lines.push(row(renderLine(line, theme), width));
|
|
146
220
|
}
|
|
147
221
|
lines.push(row("", width));
|
|
148
222
|
return lines;
|
package/src/types.ts
CHANGED
|
@@ -36,6 +36,9 @@ export interface TaskSnapshot {
|
|
|
36
36
|
finalText?: string;
|
|
37
37
|
error?: string;
|
|
38
38
|
model?: string;
|
|
39
|
+
/** Why `model` is not what was requested: preflight failed and the session's
|
|
40
|
+
* model took over. Silent substitution is worse than a slow spawn. */
|
|
41
|
+
modelNote?: string;
|
|
39
42
|
thinking?: string;
|
|
40
43
|
tools?: string[];
|
|
41
44
|
usage: UsageStats;
|