@cr1ms0n/pi-subagent 0.8.1

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/LICENSE +21 -0
  3. package/README.md +543 -0
  4. package/docs/ARCHITECTURE.md +125 -0
  5. package/docs/COST-ACCOUNTING.md +66 -0
  6. package/docs/PLAN.md +325 -0
  7. package/docs/RELEASING.md +32 -0
  8. package/docs/ROADMAP.md +252 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/UI-OVERHAUL.md +186 -0
  11. package/docs/UX.md +141 -0
  12. package/extensions/subagent.ts +1 -0
  13. package/package.json +58 -0
  14. package/skills/subagent/SKILL.md +103 -0
  15. package/src/agents.ts +285 -0
  16. package/src/backend.ts +146 -0
  17. package/src/backends/claude.ts +384 -0
  18. package/src/backends/codex.ts +330 -0
  19. package/src/backends/index.ts +26 -0
  20. package/src/backends/pi.ts +94 -0
  21. package/src/btw.ts +34 -0
  22. package/src/config.ts +254 -0
  23. package/src/distill.ts +222 -0
  24. package/src/extension.ts +1527 -0
  25. package/src/format.ts +365 -0
  26. package/src/index.ts +60 -0
  27. package/src/launch.ts +120 -0
  28. package/src/maintenance.ts +6 -0
  29. package/src/model-policy.ts +157 -0
  30. package/src/notifications.ts +106 -0
  31. package/src/orchestrator.ts +247 -0
  32. package/src/output.ts +124 -0
  33. package/src/persistence.ts +334 -0
  34. package/src/policy.ts +500 -0
  35. package/src/process-lock.ts +687 -0
  36. package/src/protocol.ts +290 -0
  37. package/src/registry.ts +632 -0
  38. package/src/runner.ts +850 -0
  39. package/src/schema.ts +166 -0
  40. package/src/semaphore.ts +123 -0
  41. package/src/structured.ts +169 -0
  42. package/src/transcript.ts +360 -0
  43. package/src/types.ts +197 -0
  44. package/src/ui.ts +545 -0
  45. package/src/usage.ts +274 -0
  46. package/src/worktree.ts +753 -0
@@ -0,0 +1,157 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export const MODEL_POLICY_CONFIG_FILE = path.join(os.homedir(), ".pi", "subagent.json");
6
+
7
+ export interface ModelRoute {
8
+ model: string;
9
+ /** Immutable order owned by the user configuration. */
10
+ fallbackModels: readonly string[];
11
+ }
12
+
13
+ export interface ModelPolicySnapshot {
14
+ default: ModelRoute;
15
+ agents: ReadonlyMap<string, ModelRoute>;
16
+ source: string;
17
+ }
18
+
19
+ export interface ModelPolicyValidation {
20
+ route?: ModelRoute;
21
+ error?: string;
22
+ }
23
+
24
+ const AGENT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
25
+
26
+ function invalid(source: string, message: string): never {
27
+ throw new Error(`Invalid modelPolicy in ${source}: ${message}`);
28
+ }
29
+
30
+ function modelId(value: unknown, pathName: string, source: string): string {
31
+ if (typeof value !== "string") invalid(source, `${pathName}.model must be a string in provider/model-id form`);
32
+ const model = value.trim();
33
+ const slash = model.indexOf("/");
34
+ if (!model || slash <= 0 || slash === model.length - 1 || /[\x00-\x1f\s]/.test(model)) {
35
+ invalid(source, `${pathName}.model must be a non-empty provider/model-id without whitespace`);
36
+ }
37
+ return model;
38
+ }
39
+
40
+ function route(value: unknown, pathName: string, source: string): ModelRoute {
41
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
42
+ invalid(source, `${pathName} must be an object`);
43
+ }
44
+ const record = value as Record<string, unknown>;
45
+ const unknown = Object.keys(record).filter((key) => key !== "model" && key !== "fallbackModels");
46
+ if (unknown.length) invalid(source, `${pathName} has unknown field(s): ${unknown.join(", ")}`);
47
+ const model = modelId(record.model, pathName, source);
48
+ const rawFallbacks = record.fallbackModels === undefined ? [] : record.fallbackModels;
49
+ if (!Array.isArray(rawFallbacks)) invalid(source, `${pathName}.fallbackModels must be an array`);
50
+ const fallbackModels = rawFallbacks.map((value, index) => modelId(value, `${pathName}.fallbackModels[${index}]`, source));
51
+ if (new Set(fallbackModels).size !== fallbackModels.length) invalid(source, `${pathName}.fallbackModels must not contain duplicates`);
52
+ if (fallbackModels.includes(model)) invalid(source, `${pathName}.fallbackModels must not repeat the primary model`);
53
+ return Object.freeze({ model, fallbackModels: Object.freeze([...fallbackModels]) });
54
+ }
55
+
56
+ /** Parse only the modelPolicy subtree; no provider catalog or credentials are read. */
57
+ export function parseModelPolicy(raw: unknown, source = MODEL_POLICY_CONFIG_FILE): ModelPolicySnapshot {
58
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
59
+ invalid(source, "modelPolicy must be an object");
60
+ }
61
+ const record = raw as Record<string, unknown>;
62
+ const unknown = Object.keys(record).filter((key) => key !== "default" && key !== "agents");
63
+ if (unknown.length) invalid(source, `modelPolicy has unknown field(s): ${unknown.join(", ")}`);
64
+ if (!("default" in record)) invalid(source, "modelPolicy.default is required");
65
+ const defaultRoute = route(record.default, "modelPolicy.default", source);
66
+ const agents = new Map<string, ModelRoute>();
67
+ const rawAgents = record.agents === undefined ? {} : record.agents;
68
+ if (!rawAgents || typeof rawAgents !== "object" || Array.isArray(rawAgents)) {
69
+ invalid(source, "modelPolicy.agents must be an object");
70
+ }
71
+ for (const [name, value] of Object.entries(rawAgents as Record<string, unknown>)) {
72
+ const normalized = name.trim().toLowerCase();
73
+ if (!AGENT_NAME.test(normalized)) invalid(source, `modelPolicy.agents key ${JSON.stringify(name)} is not a valid agent name`);
74
+ if (agents.has(normalized)) invalid(source, `modelPolicy.agents contains duplicate agent ${normalized}`);
75
+ agents.set(normalized, route(value, `modelPolicy.agents.${normalized}`, source));
76
+ }
77
+ return Object.freeze({ default: defaultRoute, agents, source });
78
+ }
79
+
80
+ /** Read the raw config only far enough to validate modelPolicy. */
81
+ export async function readModelPolicyFile(file = MODEL_POLICY_CONFIG_FILE): Promise<ModelPolicySnapshot> {
82
+ let raw: unknown;
83
+ try {
84
+ raw = JSON.parse(await fs.readFile(file, "utf8"));
85
+ } catch (error: any) {
86
+ if (error?.code === "ENOENT") throw new Error(`Model policy is not configured. Create ${file} using the model policy template.`);
87
+ throw new Error(`Could not read ${file} as JSON; model policy was not loaded.`);
88
+ }
89
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("modelPolicy" in (raw as Record<string, unknown>))) {
90
+ throw new Error(`Model policy is missing from ${file}. Add modelPolicy.default using the model policy template.`);
91
+ }
92
+ return parseModelPolicy((raw as Record<string, unknown>).modelPolicy, file);
93
+ }
94
+
95
+ export function resolveModelRoute(policy: ModelPolicySnapshot, agent?: string): ModelRoute {
96
+ const name = agent?.trim().toLowerCase();
97
+ return (name && policy.agents.get(name)) || policy.default;
98
+ }
99
+
100
+ /** Validate the caller-owned fields against the immutable configured route. */
101
+ export function validateModelRequest(
102
+ policy: ModelPolicySnapshot,
103
+ options: { agent?: string; model?: string; fallbackModels?: string[]; fallbackModelsProvided?: boolean },
104
+ ): ModelPolicyValidation {
105
+ const route = resolveModelRoute(policy, options.agent);
106
+ const actualModel = options.model?.trim();
107
+ if (!actualModel) return { error: `model is required; expected configured model ${route.model}` };
108
+ if (actualModel !== route.model) {
109
+ return { error: `model ${JSON.stringify(actualModel)} does not match the configured model ${JSON.stringify(route.model)}${options.agent ? ` for agent ${options.agent}` : ""}` };
110
+ }
111
+ if (options.fallbackModelsProvided) {
112
+ const requested = (options.fallbackModels ?? []).map((model) => model.trim());
113
+ if (requested.length !== route.fallbackModels.length || requested.some((model, index) => model !== route.fallbackModels[index])) {
114
+ return {
115
+ error: `fallback_models must exactly match the configured order for ${actualModel}: [${route.fallbackModels.join(", ")}]`,
116
+ };
117
+ }
118
+ }
119
+ return { route };
120
+ }
121
+
122
+ export function formatModelPolicyPrompt(policy: ModelPolicySnapshot | undefined, error?: string): string {
123
+ if (!policy) {
124
+ return [
125
+ "## Subagent model policy",
126
+ error || `No valid model policy is configured at ${MODEL_POLICY_CONFIG_FILE}.`,
127
+ "Management actions (status/wait/cancel/steer/diff/apply/discard) remain available, but every new task/tasks[] spawn and plan request is rejected until modelPolicy is configured.",
128
+ "Use the package model-policy template; do not invent model IDs or fallback models.",
129
+ ].join("\n");
130
+ }
131
+ const lines = [
132
+ "## Subagent model policy",
133
+ "Every new task/tasks[] spawn must pass model explicitly and it must exactly match this mapping. Management actions do not need model.",
134
+ `default (agentless and unmapped agents): model=${policy.default.model}; fallback_models=[${policy.default.fallbackModels.join(", ")}]`,
135
+ ];
136
+ for (const [agent, route] of [...policy.agents.entries()].sort(([a], [b]) => a.localeCompare(b))) {
137
+ lines.push(`agent:${agent}: model=${route.model}; fallback_models=[${route.fallbackModels.join(", ")}]`);
138
+ }
139
+ lines.push(
140
+ "If fallback_models is omitted, the configured list is used. If it is supplied, it must match the configured list exactly, including order.",
141
+ "Do not use model/fallback_models from agent frontmatter, taskDefaults, or the parent session; those legacy fields are ignored.",
142
+ );
143
+ return lines.join("\n");
144
+ }
145
+
146
+ export function modelPolicyTemplate(): string {
147
+ return JSON.stringify({
148
+ modelPolicy: {
149
+ default: { model: "<provider/model-id>", fallbackModels: [] },
150
+ agents: { "<agent-name>": { model: "<provider/model-id>", fallbackModels: [] } },
151
+ },
152
+ }, null, 2);
153
+ }
154
+
155
+ export function modelPolicySource(file = MODEL_POLICY_CONFIG_FILE): string {
156
+ return path.normalize(file);
157
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Background-run completion notifications.
3
+ *
4
+ * When an async run reaches a terminal state, the parent LLM is notified with
5
+ * a followUp message (delivered when the agent is idle / between turns) so it
6
+ * can react without polling status/wait. Matches the notification-as-delivery
7
+ * semantics of `wait`: whichever path delivers first wins via markDelivered.
8
+ *
9
+ * Batching: successes completing within a short window group into a single
10
+ * message (no notification spam in fanouts). Failures bypass batching and
11
+ * flush immediately, carrying any held successes with them.
12
+ */
13
+
14
+ export interface CompletionBatcherOptions {
15
+ /** Quiet window after the most recent completion before flushing. */
16
+ debounceMs?: number;
17
+ /** Hard cap measured from the first held completion; nothing waits longer. */
18
+ maxWaitMs?: number;
19
+ }
20
+
21
+ export class CompletionBatcher {
22
+ private readonly debounceMs: number;
23
+ private readonly maxWaitMs: number;
24
+ private pending: string[] = [];
25
+ private timer?: NodeJS.Timeout;
26
+ private firstHeldAt?: number;
27
+ private disposed = false;
28
+
29
+ constructor(
30
+ private readonly onFlush: (runIds: string[]) => void,
31
+ options: CompletionBatcherOptions = {},
32
+ ) {
33
+ this.debounceMs = options.debounceMs ?? 2_000;
34
+ this.maxWaitMs = options.maxWaitMs ?? 10_000;
35
+ }
36
+
37
+ add(runId: string, isFailure: boolean): void {
38
+ if (this.disposed) return;
39
+ if (!this.pending.includes(runId)) this.pending.push(runId);
40
+ if (isFailure) {
41
+ // Failure signals are never delayed; held successes ride along.
42
+ this.flushNow();
43
+ return;
44
+ }
45
+ const now = Date.now();
46
+ this.firstHeldAt ??= now;
47
+ const remainingCap = Math.max(0, this.firstHeldAt + this.maxWaitMs - now);
48
+ const wait = Math.min(this.debounceMs, remainingCap);
49
+ if (this.timer) clearTimeout(this.timer);
50
+ this.timer = setTimeout(() => this.flushNow(), wait);
51
+ this.timer.unref?.();
52
+ }
53
+
54
+ flushNow(): void {
55
+ if (this.timer) clearTimeout(this.timer);
56
+ this.timer = undefined;
57
+ this.firstHeldAt = undefined;
58
+ if (!this.pending.length) return;
59
+ const batch = this.pending;
60
+ this.pending = [];
61
+ this.onFlush(batch);
62
+ }
63
+
64
+ dispose(): void {
65
+ this.disposed = true;
66
+ if (this.timer) clearTimeout(this.timer);
67
+ this.timer = undefined;
68
+ this.pending = [];
69
+ }
70
+ }
71
+
72
+ /** Compact renderer-facing payload for one completed task in a run. */
73
+ export interface CompletionDetailsTask {
74
+ label: string;
75
+ state: string;
76
+ preview: string;
77
+ turns: number;
78
+ tokens: number;
79
+ cost: number;
80
+ model?: string;
81
+ attemptedModels?: string[];
82
+ pointers: string[];
83
+ }
84
+
85
+ /** Compact renderer-facing payload for one completed run. */
86
+ export interface CompletionDetailsRun {
87
+ id: string;
88
+ label: string;
89
+ state: string;
90
+ preview: string;
91
+ turns: number;
92
+ tokens: number;
93
+ cost: number;
94
+ durationMs: number;
95
+ /** Kept for single-task consumers; parallel consumers must use tasks[]. */
96
+ model?: string;
97
+ attemptedModels?: string[];
98
+ pointers: string[];
99
+ tasks: CompletionDetailsTask[];
100
+ }
101
+
102
+ export interface CompletionDetails {
103
+ runs: CompletionDetailsRun[];
104
+ }
105
+
106
+ export const COMPLETION_MESSAGE_TYPE = "subagent-completion";
@@ -0,0 +1,247 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { defaultConfig } from "./config.js";
4
+ import { createGetPiCommand } from "./launch.js";
5
+ import type { ProcessLockManager } from "./process-lock.js";
6
+ import { ChildRunner, type GetPiCommand } from "./runner.js";
7
+ import { Semaphore } from "./semaphore.js";
8
+ import type { RunMode, RunState, TaskResult, TaskSpec } from "./types.js";
9
+ import { addUsage } from "./usage.js";
10
+ import { WorktreeManager, type WorktreeHandle } from "./worktree.js";
11
+
12
+ export interface OrchestratorDeps {
13
+ semaphore?: Semaphore;
14
+ getPiCommand?: GetPiCommand;
15
+ sessionDir?: string;
16
+ worktrees?: WorktreeManager;
17
+ killGraceMs?: number;
18
+ locks?: ProcessLockManager;
19
+ runId?: string;
20
+ parentSessionKey?: string;
21
+ onTaskProgress?: (index: number, partial: Partial<TaskResult>) => void;
22
+ /** Exposes each task's live runner (for mid-run steering). Re-fires per retry attempt. */
23
+ onRunnerCreated?: (index: number, runner: ChildRunner) => void;
24
+ /** Wrap-up grace turns after budget breach (per-spec graceTurns overrides). */
25
+ graceTurns?: number;
26
+ /** Stall watchdog windows (0 disables). */
27
+ stallAfterMs?: number;
28
+ stallKillAfterMs?: number;
29
+ /** Default extra attempts on transient failures (per-spec maxRetries overrides). */
30
+ maxRetries?: number;
31
+ }
32
+
33
+ /**
34
+ * Transient failures are infrastructure problems, not task problems: the same
35
+ * spec is safe to retry without duplicating side effects because no meaningful
36
+ * work happened (queued timeout) or the child died from environment causes
37
+ * (stall, provider error, spawn error, unexpected signal).
38
+ *
39
+ * Never retried: real task failures (nonzero exit with complete protocol),
40
+ * cancellations, budget stops, and running timeouts (work may be half-done).
41
+ */
42
+ export function isTransientFailure(result: TaskResult): boolean {
43
+ if (result.state === "timeout" && result.timeoutPhase === "queued") return true;
44
+ if (result.stopReason === "stalled") return true;
45
+ if (result.stopReason === "spawn_error") return true;
46
+ // Provider/stream errors: stopReason "error" comes from provider-reported
47
+ // failure or the fatal RPC path; both are retry-with-fallback candidates.
48
+ if (result.state === "failed" && ["error", "protocol_error", "unexpected_signal"].includes(result.stopReason ?? "")) return true;
49
+ return false;
50
+ }
51
+
52
+ export interface OrchestratedRun {
53
+ mode: RunMode;
54
+ results: TaskResult[];
55
+ state: RunState;
56
+ summary: string;
57
+ }
58
+
59
+ function aggregateState(results: TaskResult[]): RunState {
60
+ if (results.every((r) => r.state === "completed")) return "completed";
61
+ // Budget-stopped / truncated tasks ("partial") carry useful output.
62
+ if (results.some((r) => r.state === "completed" || r.state === "partial")) return "partial";
63
+ if (results.every((r) => r.state === "cancelled")) return "cancelled";
64
+ if (results.every((r) => r.state === "timeout" || r.state === "cancelled")) return "timeout";
65
+ if (results.some((r) => r.state === "timeout") && results.every((r) => ["timeout", "cancelled", "failed"].includes(r.state))) {
66
+ return results.every((r) => r.state === "timeout") ? "timeout" : "failed";
67
+ }
68
+ return "failed";
69
+ }
70
+
71
+ function summarize(results: TaskResult[]): string {
72
+ return results.map((r) => {
73
+ const body = r.outputMode === "file-only"
74
+ ? r.outputFile ? `Output written to ${r.outputFile}` : "No output artifact"
75
+ : r.liveText || r.errorMessage || r.stderr || "(no output)";
76
+ return `[${r.label}] ${r.state}\n${body}`;
77
+ }).join("\n\n");
78
+ }
79
+
80
+ async function writeArtifact(spec: TaskSpec, result: TaskResult): Promise<void> {
81
+ if (!spec.output) return;
82
+ const text = result.liveText || result.errorMessage || result.stderr || "(no output)";
83
+ await fs.mkdir(path.dirname(spec.output), { recursive: true });
84
+ await fs.writeFile(spec.output, text, "utf8");
85
+ result.outputFile = spec.output;
86
+ result.outputMode = spec.outputMode;
87
+ }
88
+
89
+ export async function runTasks(
90
+ specs: TaskSpec[],
91
+ options: OrchestratorDeps & { signal?: AbortSignal } = {},
92
+ ): Promise<OrchestratedRun> {
93
+ const semaphore = options.semaphore ?? new Semaphore(defaultConfig.maxActiveProcesses, defaultConfig.maxQueuedTasks);
94
+ const worktrees = options.worktrees ?? new WorktreeManager();
95
+ const handles: Array<WorktreeHandle | undefined> = new Array(specs.length);
96
+ const prepared: TaskSpec[] = [];
97
+
98
+ try {
99
+ for (let index = 0; index < specs.length; index++) {
100
+ if (options.signal?.aborted) throw new Error("Subagent run cancelled before worktree setup");
101
+ const spec = { ...specs[index]! };
102
+ if (spec.isolation === "worktree") {
103
+ const handle = await worktrees.create(spec.cwd || process.cwd(), spec.task.slice(0, 20), options.signal, {
104
+ includeWip: spec.includeWip === true,
105
+ });
106
+ handles[index] = handle;
107
+ spec.cwd = handle.cwd;
108
+ // Announce the worktree immediately so live runs can shield it from GC sweeps.
109
+ options.onTaskProgress?.(index, {
110
+ worktree: { cwd: handle.cwd, branch: handle.branch, baseCommit: handle.baseCommit, changed: false },
111
+ });
112
+ }
113
+ prepared.push(spec);
114
+ }
115
+ } catch (error) {
116
+ // Setup failure: remove only unchanged worktrees. Preserve modified ones.
117
+ for (const handle of handles.filter((h): h is WorktreeHandle => !!h)) {
118
+ await worktrees.finalize(handle).catch(() => {});
119
+ }
120
+ if (options.signal?.aborted) {
121
+ const results = specs.map<TaskResult>((spec, index) => ({
122
+ label: `task-${index + 1}`,
123
+ task: spec.task,
124
+ model: spec.model,
125
+ state: "cancelled",
126
+ exitCode: 1,
127
+ messages: [],
128
+ stderr: "",
129
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0, contextTokens: 0, turns: 0 },
130
+ stopReason: "cancelled",
131
+ errorMessage: error instanceof Error ? error.message : String(error),
132
+ thinking: spec.thinking,
133
+ profile: spec.profile,
134
+ canWrite: spec.canWrite,
135
+ outputFile: spec.output,
136
+ outputMode: spec.outputMode,
137
+ protocol: { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
138
+ }));
139
+ return { mode: specs.length > 1 ? "parallel" : "single", results, state: "cancelled", summary: "Subagent run cancelled during setup" };
140
+ }
141
+ throw error;
142
+ }
143
+
144
+ const runOne = async (index: number): Promise<TaskResult> => {
145
+ const spec = prepared[index]!;
146
+ // Per-task durable id stays unique under a multi-task run by appending index.
147
+ const taskRunId = options.runId
148
+ ? (specs.length > 1 ? `${options.runId}:${index}` : options.runId)
149
+ : undefined;
150
+
151
+ // Retry with model fallback on transient failures. Attempt N uses the
152
+ // N-1th fallback model (attempt 1 = primary). Usage accumulates across
153
+ // attempts so the cost ledger reflects everything billed.
154
+ const fallbacks = spec.fallbackModels ?? [];
155
+ const maxRetries = spec.maxRetries ?? options.maxRetries ?? defaultConfig.maxRetries;
156
+ // Providing fallback models implies wanting them all tried; otherwise
157
+ // maxRetries bounds same-model retries.
158
+ const maxAttempts = 1 + Math.max(maxRetries, fallbacks.length);
159
+ const attemptedModels: string[] = [];
160
+ let priorUsage: ReturnType<typeof addUsage> | undefined;
161
+ let result!: TaskResult;
162
+
163
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
164
+ const model = attempt === 1 ? spec.model : (fallbacks[attempt - 2] ?? spec.model);
165
+ if (model) attemptedModels.push(model);
166
+ const attemptSpec: TaskSpec = { ...spec, model };
167
+ const runner = new ChildRunner(
168
+ semaphore,
169
+ options.getPiCommand ?? createGetPiCommand(),
170
+ options.sessionDir,
171
+ (partial) => options.onTaskProgress?.(index, {
172
+ ...partial,
173
+ attempts: attempt > 1 ? attempt : undefined,
174
+ usage: partial.usage && priorUsage ? addUsage(priorUsage, partial.usage) : partial.usage,
175
+ }),
176
+ options.killGraceMs,
177
+ options.locks,
178
+ taskRunId,
179
+ options.parentSessionKey,
180
+ undefined,
181
+ { graceTurns: options.graceTurns, stallAfterMs: options.stallAfterMs, stallKillAfterMs: options.stallKillAfterMs },
182
+ );
183
+ options.onRunnerCreated?.(index, runner);
184
+ result = await runner.run(attemptSpec, options.signal);
185
+ if (priorUsage) result.usage = addUsage(priorUsage, result.usage);
186
+
187
+ const canRetry = attempt < maxAttempts && !options.signal?.aborted && isTransientFailure(result);
188
+ if (!canRetry) break;
189
+ priorUsage = result.usage;
190
+ const nextModel = fallbacks[attempt - 1];
191
+ options.onTaskProgress?.(index, {
192
+ state: "queued",
193
+ model: nextModel ?? spec.model,
194
+ attempts: attempt + 1,
195
+ liveText: `Attempt ${attempt} ${result.stopReason ?? result.state}; retrying${nextModel ? ` on ${nextModel}` : ""}…`,
196
+ });
197
+ }
198
+
199
+ if (attemptedModels.length > 1) {
200
+ result.attempts = attemptedModels.length;
201
+ result.attemptedModels = attemptedModels;
202
+ if (result.errorMessage && (result.state === "failed" || result.state === "timeout")) {
203
+ result.errorMessage += ` (after ${attemptedModels.length} attempts: ${attemptedModels.join(" → ")})`;
204
+ }
205
+ }
206
+ result.index = index;
207
+ result.label = spec.label || `task-${index + 1}`;
208
+ result.outputMode = spec.outputMode;
209
+
210
+ try {
211
+ await writeArtifact(spec, result);
212
+ } catch (error: any) {
213
+ result.errorMessage = `${result.errorMessage ? `${result.errorMessage}; ` : ""}Artifact write failed: ${error?.message ?? error}`;
214
+ if (result.state === "completed") result.state = "partial";
215
+ }
216
+
217
+ const handle = handles[index];
218
+ if (handle) {
219
+ try {
220
+ // Finalize even on cancellation: it either preserves changed work or
221
+ // removes an unchanged worktree, and both are quick local git calls.
222
+ const final = await worktrees.finalize(handle);
223
+ if (final.changed) {
224
+ result.worktree = {
225
+ cwd: final.cwd,
226
+ branch: final.branch,
227
+ baseCommit: final.baseCommit,
228
+ changed: true,
229
+ diffSummary: final.diffSummary,
230
+ };
231
+ }
232
+ } catch (error: any) {
233
+ result.errorMessage = `${result.errorMessage ? `${result.errorMessage}; ` : ""}Worktree finalization failed: ${error?.message ?? error}`;
234
+ if (result.state === "completed") result.state = "partial";
235
+ }
236
+ }
237
+ return result;
238
+ };
239
+
240
+ const results = await Promise.all(prepared.map((_, index) => runOne(index)));
241
+ return {
242
+ mode: results.length > 1 ? "parallel" : "single",
243
+ results,
244
+ state: aggregateState(results),
245
+ summary: summarize(results),
246
+ };
247
+ }
package/src/output.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { Buffer } from "node:buffer";
2
+ import type { SubagentConfig } from "./config.js";
3
+ import type { OutputMode, RunSnapshot, TaskResult } from "./types.js";
4
+
5
+ export interface CappedDelivery {
6
+ text: string;
7
+ cappedResults: Array<Record<string, unknown>>;
8
+ totalBytes: number;
9
+ totalLines: number;
10
+ }
11
+
12
+ function finalAssistantText(messages: any[]): string | undefined {
13
+ for (let i = messages.length - 1; i >= 0; i--) {
14
+ const message = messages[i];
15
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
16
+ const text = message.content
17
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
18
+ .map((part: any) => part.text)
19
+ .join("");
20
+ if (text) return text;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ /** Truncate a string to UTF-8 bytes without splitting a code point. */
26
+ function utf8Prefix(value: string, maxBytes: number): string {
27
+ if (maxBytes <= 0) return "";
28
+ const buffer = Buffer.from(value, "utf8");
29
+ if (buffer.length <= maxBytes) return value;
30
+ let end = maxBytes;
31
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
32
+ return buffer.subarray(0, end).toString("utf8");
33
+ }
34
+
35
+ function enforceBudget(value: string, maxBytes: number, maxLines: number, marker: string): string {
36
+ const safeLines = Math.max(1, maxLines);
37
+ let text = value.split("\n").slice(0, safeLines).join("\n");
38
+ const wasLineCapped = value.split("\n").length > safeLines;
39
+ const wasByteCapped = Buffer.byteLength(text, "utf8") > maxBytes;
40
+ if (!wasLineCapped && !wasByteCapped) return text;
41
+
42
+ const markerLines = marker.split("\n").length;
43
+ const contentLineBudget = Math.max(0, safeLines - markerLines);
44
+ text = text.split("\n").slice(0, contentLineBudget).join("\n");
45
+ const separator = text ? "\n" : "";
46
+ const reserved = Buffer.byteLength(separator + marker, "utf8");
47
+ text = utf8Prefix(text, Math.max(0, maxBytes - reserved));
48
+ return `${text}${text ? separator : ""}${marker}`;
49
+ }
50
+
51
+ export class OutputManager {
52
+ constructor(private readonly config: SubagentConfig) {}
53
+
54
+ makeStatusPreview(run: RunSnapshot, maxBytes = 200): string {
55
+ const base = `${run.id} [${run.state}] ${run.mode} (${run.results.length} tasks)`;
56
+ return utf8Prefix(base, maxBytes);
57
+ }
58
+
59
+ capOutputForDelivery(
60
+ results: TaskResult[] | RunSnapshot["results"],
61
+ isMultiWait = false,
62
+ requestedOutputMode?: OutputMode,
63
+ ): CappedDelivery {
64
+ const count = Math.max(1, results.length);
65
+ const sections: string[] = [];
66
+ const cappedResults: Array<Record<string, unknown>> = [];
67
+ const separator = "\n\n---\n\n";
68
+ const headerBytes = isMultiWait
69
+ ? results.reduce((sum, _, i) => sum + Buffer.byteLength(`## Run section ${i + 1}\n\n`, "utf8"), 0)
70
+ : 0;
71
+ const separatorBytes = Math.max(0, count - 1) * Buffer.byteLength(separator, "utf8");
72
+ const separatorLines = Math.max(0, count - 1) * 4;
73
+ const availableBytes = Math.max(0, this.config.maxResultBytes - headerBytes - separatorBytes);
74
+ const availableLines = Math.max(1, this.config.maxResultLines - separatorLines - (isMultiWait ? count * 2 : 0));
75
+ const perBytes = Math.max(1, Math.floor(availableBytes / count));
76
+ const perLines = Math.max(1, Math.floor(availableLines / count));
77
+
78
+ results.forEach((result: any, index) => {
79
+ const mode = requestedOutputMode ?? result.outputMode;
80
+ let raw: string;
81
+ if (mode === "file-only") {
82
+ raw = result.outputFile
83
+ ? `Output written to ${result.outputFile}${result.sessionId ? `\nSession: ${result.sessionId}` : ""}`
84
+ : result.errorMessage || "file-only output requested, but no artifact was produced";
85
+ } else if (result.structuredOutput !== undefined) {
86
+ // Validated structured result: deliver the machine-readable JSON, not
87
+ // the narrative preamble around the fenced block.
88
+ raw = JSON.stringify(result.structuredOutput, null, 2);
89
+ } else {
90
+ raw = result.finalOutput || finalAssistantText(result.messages || []) || result.liveText || result.errorMessage || result.stderr || "(no output)";
91
+ }
92
+ const artifact = result.outputFile ? ` Full output: ${result.outputFile}` : " Full output is in the child session transcript.";
93
+ const marker = `[Truncated.${artifact}]`;
94
+ const section = enforceBudget(raw, perBytes, perLines, marker);
95
+ const prefix = isMultiWait ? `## Run section ${index + 1}\n\n` : "";
96
+ sections.push(prefix + section);
97
+ cappedResults.push({
98
+ ...result,
99
+ finalOutput: section,
100
+ capped: section !== raw,
101
+ bytes: Buffer.byteLength(section, "utf8"),
102
+ });
103
+ });
104
+
105
+ let text = sections.join(separator);
106
+ // Defensive final enforcement includes the global marker inside both limits.
107
+ text = enforceBudget(
108
+ text,
109
+ this.config.maxResultBytes,
110
+ this.config.maxResultLines,
111
+ "[Global output cap reached. Full output remains in artifacts or child sessions.]",
112
+ ).trim();
113
+ return {
114
+ text,
115
+ cappedResults,
116
+ totalBytes: Buffer.byteLength(text, "utf8"),
117
+ totalLines: text ? text.split("\n").length : 0,
118
+ };
119
+ }
120
+
121
+ testUnicodeBytes(value: string): { bytes: number; lines: number } {
122
+ return { bytes: Buffer.byteLength(value, "utf8"), lines: value.split("\n").length };
123
+ }
124
+ }