@arhen/pi-core-subagent 1.3.66 → 1.3.67

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/outcome.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import type { RunSnapshot, TaskSnapshot, UsageStats } from "./types.ts";
3
+
4
+ export type Failure = { status: "failed" | "aborted"; message: string };
5
+
6
+ export function emptyUsage(): UsageStats {
7
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
8
+ }
9
+
10
+ export function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
11
+ const total = emptyUsage();
12
+ for (const task of tasks) {
13
+ total.input += task.usage.input;
14
+ total.output += task.usage.output;
15
+ total.cacheRead += task.usage.cacheRead;
16
+ total.cacheWrite += task.usage.cacheWrite;
17
+ total.cost += task.usage.cost;
18
+ total.turns += task.usage.turns;
19
+ }
20
+ return total;
21
+ }
22
+
23
+ export function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage): void {
24
+ if (message?.role !== "assistant") return;
25
+ task.usage.turns += 1;
26
+ const usage = message.usage;
27
+ if (!usage) return;
28
+ task.usage.input += usage.input ?? 0;
29
+ task.usage.output += usage.output ?? 0;
30
+ task.usage.cacheRead += usage.cacheRead ?? 0;
31
+ task.usage.cacheWrite += usage.cacheWrite ?? 0;
32
+ task.usage.cost += usage.cost?.total ?? 0;
33
+ if (message.model && !task.model) task.model = message.model;
34
+ }
35
+
36
+ export function classifyFailure(stopReason: string | undefined, errorMessage?: string): Failure | undefined {
37
+ if (!stopReason || stopReason === "stop" || stopReason === "end") return undefined;
38
+ if (stopReason === "aborted") return { status: "aborted", message: errorMessage || "Subagent was aborted." };
39
+ return { status: "failed", message: errorMessage || `Subagent ended with stopReason "${stopReason}".` };
40
+ }
41
+
42
+ export function lastAssistantFailure(messages: AssistantMessage[] | undefined): Failure | undefined {
43
+ for (const message of [...(messages ?? [])].reverse()) {
44
+ if (message?.role !== "assistant") continue;
45
+ return classifyFailure(message.stopReason, message.errorMessage);
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ export function failureError(failure: Failure): Error {
51
+ const error = new Error(failure.message);
52
+ (error as Error & { subagentStatus?: string }).subagentStatus = failure.status;
53
+ return error;
54
+ }
55
+
56
+ export function cloneRun(run: RunSnapshot): RunSnapshot {
57
+ return JSON.parse(JSON.stringify(run)) as RunSnapshot;
58
+ }
@@ -0,0 +1,122 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { TUI } from "@earendil-works/pi-tui";
3
+ import { isTalking, SubagentsWidget } from "./format.ts";
4
+ import { type RunSnapshot, TERMINAL } from "./types.ts";
5
+
6
+ const THROTTLE_MS = 150;
7
+ const PULSE_MS = 700;
8
+
9
+ /**
10
+ * The live-runs widget above the editor: throttled redraws per run, a pulse while a child is
11
+ * talking, and settled runs pruned (subagent_status/result still reach them — this shows live work).
12
+ */
13
+ export class RunWidget {
14
+ runs: RunSnapshot[] = [];
15
+ private tui: TUI | null = null;
16
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
17
+ private pulseTimer: ReturnType<typeof setTimeout> | null = null;
18
+
19
+ /** Remove the widget from the UI; pending timers keep running and re-create it on demand. */
20
+ clear(ctx?: ExtensionContext): void {
21
+ this.runs = [];
22
+ this.tui = null;
23
+ if (ctx?.hasUI) {
24
+ try {
25
+ ctx.ui.setWidget("subagents", undefined);
26
+ } catch {}
27
+ }
28
+ }
29
+
30
+ /** Session end: drop every timer and run without touching the UI. */
31
+ dispose(): void {
32
+ this.tui = null;
33
+ if (this.pulseTimer) {
34
+ clearTimeout(this.pulseTimer);
35
+ this.pulseTimer = null;
36
+ }
37
+ for (const timer of this.timers.values()) clearTimeout(timer);
38
+ this.timers.clear();
39
+ this.runs = [];
40
+ }
41
+
42
+ upsert(run: RunSnapshot | undefined): void {
43
+ this.prune();
44
+ if (!run || TERMINAL.includes(run.status)) return;
45
+ const idx = this.runs.findIndex((r) => r.id === run.id);
46
+ if (idx >= 0) this.runs[idx] = run;
47
+ else this.runs.push(run);
48
+ }
49
+
50
+ schedule(run: RunSnapshot | undefined, ctx?: ExtensionContext): void {
51
+ this.upsert(run);
52
+ if (!run || this.timers.has(run.id)) return;
53
+ this.timers.set(
54
+ run.id,
55
+ setTimeout(() => {
56
+ this.timers.delete(run.id);
57
+ if (ctx?.hasUI) {
58
+ this.ensure(ctx);
59
+ this.tui?.requestRender();
60
+ }
61
+ this.maybePulse(ctx);
62
+ }, THROTTLE_MS),
63
+ );
64
+ }
65
+
66
+ flush(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
67
+ if (run) {
68
+ const timer = this.timers.get(run.id);
69
+ if (timer) {
70
+ clearTimeout(timer);
71
+ this.timers.delete(run.id);
72
+ }
73
+ }
74
+ this.prune();
75
+ if (this.runs.length === 0) {
76
+ if (this.tui) this.clear(ctx);
77
+ } else {
78
+ if (ctx?.hasUI) {
79
+ this.ensure(ctx);
80
+ this.tui?.requestRender();
81
+ }
82
+ this.maybePulse(ctx);
83
+ }
84
+ if (!run) return;
85
+
86
+ onUpdate?.({
87
+ content: [
88
+ {
89
+ type: "text",
90
+ text: `${run.tasks.filter((t) => TERMINAL.includes(t.status)).length}/${run.tasks.length} done · ${run.status}`,
91
+ },
92
+ ],
93
+ });
94
+ }
95
+
96
+ private prune(): void {
97
+ this.runs = this.runs.filter((r) => !TERMINAL.includes(r.status));
98
+ }
99
+
100
+ private maybePulse(ctx?: ExtensionContext): void {
101
+ if (this.pulseTimer || !this.tui) return;
102
+ const talking = this.runs.some((r) => r.tasks.some(isTalking));
103
+ if (!talking) return;
104
+ this.pulseTimer = setTimeout(() => {
105
+ this.pulseTimer = null;
106
+ this.tui?.requestRender();
107
+ this.maybePulse(ctx);
108
+ }, PULSE_MS);
109
+ }
110
+
111
+ private ensure(ctx: ExtensionContext): void {
112
+ if (this.tui !== null || !ctx.hasUI) return;
113
+ ctx.ui.setWidget(
114
+ "subagents",
115
+ (tui, theme) => {
116
+ this.tui = tui;
117
+ return new SubagentsWidget(() => [...this.runs], theme);
118
+ },
119
+ { placement: "aboveEditor" },
120
+ );
121
+ }
122
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { join } from "node:path";
2
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+ import { type Api, clampThinkingLevel, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
4
+ import {
5
+ type DefaultResourceLoader,
6
+ type ExtensionContext,
7
+ getAgentDir,
8
+ ModelRuntime,
9
+ } from "@earendil-works/pi-coding-agent";
10
+
11
+ type ChildExtensionFactories = ConstructorParameters<typeof DefaultResourceLoader>[0]["extensionFactories"];
12
+
13
+ /**
14
+ * Children run with `noExtensions`, so they get none of the configured extensions — codemode is the
15
+ * one exception, because it is how a child batches tool calls. `createCodemodeExtension()` is the
16
+ * supported factory (pi >= 1.0); hosts that do not export it simply give children no codemode.
17
+ */
18
+ export async function codemodeFactories(): Promise<ChildExtensionFactories> {
19
+ try {
20
+ const host = (await import("@earendil-works/pi-coding-agent")) as unknown as {
21
+ createCodemodeExtension?: () => unknown;
22
+ };
23
+ const factory = host.createCodemodeExtension?.();
24
+ return factory ? ([factory] as ChildExtensionFactories) : [];
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ /**
31
+ * A resumed task keeps its stored thinking level, clamped to what the target model accepts — a
32
+ * resume that swaps model must not fail on an effort the new model does not define.
33
+ */
34
+ export function clampResumeThinking(
35
+ model: Model<Api> | undefined,
36
+ thinking: ThinkingLevel | undefined,
37
+ ): ThinkingLevel | undefined {
38
+ if (!thinking || !model) return thinking;
39
+ return clampThinkingLevel(model, thinking) as ThinkingLevel;
40
+ }
41
+
42
+ const PROBE_THINKING_LEVELS: ThinkingLevel[] = ["low", "minimal", "medium", "high", "xhigh", "max"];
43
+
44
+ /**
45
+ * Thinking level for the usability probe: exactly what the child session will send — the clamped
46
+ * requested level, or the cheapest the model accepts when none was requested. Probing without a
47
+ * level makes adaptive-thinking providers reject the request (9router claude models answer
48
+ * "thinking.type.disabled is not supported"), which used to make every preflight fail and fall
49
+ * back to the session model.
50
+ */
51
+ function probeThinking(model: Model<Api>, thinking?: string): ThinkingLevel | undefined {
52
+ if (!model.reasoning) return undefined;
53
+ const supported = getSupportedThinkingLevels(model);
54
+ if (thinking && thinking !== "off") return clampThinkingLevel(model, thinking as ThinkingLevel);
55
+ // the child clamps an unsupported "off" up to its cheapest level, so probe that instead
56
+ if (thinking === "off") return supported.includes("off") ? undefined : supported[0];
57
+ return PROBE_THINKING_LEVELS.find((level) => supported.includes(level));
58
+ }
59
+
60
+ async function probeModel(
61
+ ctx: ExtensionContext,
62
+ model: Model<Api>,
63
+ signal: AbortSignal | undefined,
64
+ thinking?: string,
65
+ ): Promise<string | undefined> {
66
+ try {
67
+ const reasoningEffort = probeThinking(model, thinking);
68
+ const reply = await ctx.modelRegistry.complete(
69
+ model,
70
+ { messages: [{ role: "user", content: "ping", timestamp: Date.now() }] },
71
+ { maxTokens: 16, signal, ...(reasoningEffort ? { reasoningEffort } : {}) },
72
+ );
73
+ return reply.stopReason === "error" ? (reply.errorMessage ?? "provider returned an error") : undefined;
74
+ } catch (err) {
75
+ return err instanceof Error ? err.message : String(err);
76
+ }
77
+ }
78
+
79
+ export async function ensureUsableModel(
80
+ ctx: ExtensionContext,
81
+ model: Model<Api> | undefined,
82
+ signal: AbortSignal | undefined,
83
+ thinking?: string,
84
+ ): Promise<{ model: Model<Api> | undefined; note?: string }> {
85
+ const session = ctx.model;
86
+ if (!model || !ctx.modelRegistry) return { model };
87
+ if (session && model.provider === session.provider && model.id === session.id) return { model };
88
+ const error = await probeModel(ctx, model, signal, thinking);
89
+ if (!error) return { model };
90
+ if (model.provider === "opencode-go" && /MissingSessionID|x-opencode-session/i.test(error)) {
91
+ // ponytail: opencode-go rejects stateless probes but accepts AgentSession requests, which add the session header.
92
+ // Upgrade path: remove this exception when modelRegistry.complete can carry AgentSession request transforms.
93
+ return { model, note: `preflight unavailable (${error}); child session will validate the model` };
94
+ }
95
+ if (!session) throw new Error(`Model ${model.provider}/${model.id} is unusable: ${error}`);
96
+ return {
97
+ model: session,
98
+ note: `${model.provider}/${model.id} failed preflight (${error}); using session model ${session.provider}/${session.id}`,
99
+ };
100
+ }
101
+
102
+ export async function createChildModelRuntime(ctx: ExtensionContext) {
103
+ const ids = ctx.modelRegistry.getRegisteredProviderIds?.() ?? [];
104
+ if (ids.length === 0) return undefined;
105
+ const agentDir = getAgentDir();
106
+ const runtime = await ModelRuntime.create({
107
+ authPath: join(agentDir, "auth.json"),
108
+ modelsPath: join(agentDir, "models.json"),
109
+ });
110
+ for (const id of ids) {
111
+ const native = ctx.modelRegistry.getRegisteredNativeProvider?.(id);
112
+ if (native) {
113
+ runtime.registerNativeProvider(native);
114
+ continue;
115
+ }
116
+ const config = ctx.modelRegistry.getRegisteredProviderConfig?.(id);
117
+ if (config) runtime.registerProvider(id, config);
118
+ }
119
+ await runtime.refresh({ allowNetwork: false });
120
+ return runtime;
121
+ }
122
+
123
+ export function validateThinking(model: Model<Api> | undefined, level: string | undefined): void {
124
+ if (!level || level === "off") return;
125
+ if (!model) return;
126
+ const map = model.thinkingLevelMap;
127
+ if (map && level in map && map[level as keyof typeof map] === null) {
128
+ const supported = Object.keys(map).filter((k) => map[k as keyof typeof map] !== null);
129
+ throw new Error(
130
+ `Thinking level "${level}" is not supported by ${model.provider}/${model.id}. Supported: ${supported.length ? supported.join(" | ") : 'none — use thinking: "off"'}.`,
131
+ );
132
+ }
133
+ if (!model.reasoning) {
134
+ throw new Error(`Model ${model.provider}/${model.id} does not support thinking. Use thinking: "off".`);
135
+ }
136
+ }
package/src/types.ts CHANGED
@@ -133,3 +133,12 @@ export interface ModelCatalog {
133
133
  /** Set when the catalog is empty, so the caller knows it is not looking at a legitimate empty list. */
134
134
  unavailable?: string;
135
135
  }
136
+
137
+ /** A child message collected by a parked `await` instead of being sent to the leader. */
138
+ export interface ParkedMsg {
139
+ kind: "ask" | "notify" | "done";
140
+ taskId: string;
141
+ agent: string;
142
+ text: string;
143
+ final?: boolean;
144
+ }