pi-dag-workflow 0.0.0-stage → 0.7.0
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/CHANGELOG.md +454 -0
- package/LICENSE +21 -0
- package/README.md +211 -2
- package/README.zh-CN.md +212 -0
- package/docs/COMMUNICATION.md +129 -0
- package/docs/RELEASING.md +66 -0
- package/package.json +70 -4
- package/src/agents/commands/child.ts +148 -0
- package/src/agents/commands/register.ts +404 -0
- package/src/agents/domain/context.ts +183 -0
- package/src/agents/domain/diagnostics.ts +32 -0
- package/src/agents/domain/owner.ts +448 -0
- package/src/agents/domain/profiles.ts +216 -0
- package/src/agents/domain/runtime.ts +603 -0
- package/src/agents/domain/schema.ts +15 -0
- package/src/agents/index.ts +5 -0
- package/src/agents/io/contracts.ts +50 -0
- package/src/agents/io/host.ts +16 -0
- package/src/agents/io/notices.ts +27 -0
- package/src/agents/io/persistence.ts +210 -0
- package/src/agents/io/process.ts +100 -0
- package/src/agents/io/profile-file.ts +16 -0
- package/src/agents/io/skills.ts +46 -0
- package/src/goal/commands/register.ts +235 -0
- package/src/goal/domain/failure.ts +14 -0
- package/src/goal/domain/owner.ts +575 -0
- package/src/goal/domain/policy.ts +8 -0
- package/src/goal/domain/prompt.ts +57 -0
- package/src/goal/domain/state.ts +176 -0
- package/src/goal/index.ts +5 -0
- package/src/goal/io/host.ts +8 -0
- package/src/plan/commands/register.ts +101 -0
- package/src/plan/domain/owner.ts +51 -0
- package/src/plan/domain/policy.ts +27 -0
- package/src/plan/domain/state.ts +15 -0
- package/src/plan/index.ts +13 -0
- package/src/plan/io/records.ts +3 -0
- package/src/retry/commands/menu.ts +158 -0
- package/src/retry/commands/register.ts +26 -0
- package/src/retry/domain/owner.ts +418 -0
- package/src/retry/domain/state.ts +71 -0
- package/src/retry/index.ts +5 -0
- package/src/retry/io/host.ts +15 -0
- package/src/shared/agent-view.ts +18 -0
- package/src/shared/completion.ts +111 -0
- package/src/shared/config-file.ts +106 -0
- package/src/shared/config.ts +112 -0
- package/src/shared/dag/cache.ts +26 -0
- package/src/shared/dag/graph.ts +170 -0
- package/src/shared/dag/layout.ts +130 -0
- package/src/shared/goal-view.ts +2 -0
- package/src/shared/i18n.ts +62 -0
- package/src/shared/message-types.ts +12 -0
- package/src/shared/messages.en.ts +1290 -0
- package/src/shared/passive-message.ts +50 -0
- package/src/shared/pi-cli.ts +29 -0
- package/src/shared/profile-launch.ts +110 -0
- package/src/shared/state-types.ts +5 -0
- package/src/shared/task-schema.ts +39 -0
- package/src/shared/task-types.ts +25 -0
- package/src/shared/terminal.ts +19 -0
- package/src/shared/tool-info.ts +8 -0
- package/src/shared/ui/filter.ts +144 -0
- package/src/shared/ui/menu.ts +447 -0
- package/src/shared/ui/panel.ts +768 -0
- package/src/shared/ui/presentation.ts +624 -0
- package/src/shared/ui/render-cache.ts +63 -0
- package/src/shared/ui/render.ts +641 -0
- package/src/shared/ui/symbols.ts +136 -0
- package/src/team/commands/bootstrap.ts +52 -0
- package/src/team/commands/controls.ts +91 -0
- package/src/team/commands/history.ts +100 -0
- package/src/team/commands/menu.ts +455 -0
- package/src/team/commands/register.ts +1321 -0
- package/src/team/domain/inbox.ts +178 -0
- package/src/team/domain/launcher.ts +355 -0
- package/src/team/domain/lifecycle.ts +34 -0
- package/src/team/domain/teams.ts +161 -0
- package/src/team/index.ts +4 -0
- package/src/team/io/inbox.ts +255 -0
- package/src/team/io/launcher.ts +270 -0
- package/src/team/io/lock.ts +19 -0
- package/src/team/io/registry.ts +477 -0
- package/src/team/io/runtime.ts +353 -0
- package/src/team/io/session-activity.ts +185 -0
- package/src/team/io/session-delete.ts +98 -0
- package/src/team/io/session-history.ts +153 -0
- package/src/team/io/session-tail.ts +132 -0
- package/src/team/io/sid-lock.ts +115 -0
- package/src/team/io/sid-session.ts +98 -0
- package/src/todos/commands/checkpoints.ts +31 -0
- package/src/todos/commands/register.ts +395 -0
- package/src/todos/domain/checkpoint-state.ts +32 -0
- package/src/todos/domain/checkpoint-summary.ts +146 -0
- package/src/todos/domain/cleanup.ts +25 -0
- package/src/todos/domain/owner.ts +109 -0
- package/src/todos/domain/presets.ts +192 -0
- package/src/todos/domain/state.ts +287 -0
- package/src/todos/index.ts +15 -0
- package/src/todos/io/records.ts +8 -0
- package/src/ui/index.ts +4 -0
- package/src/workflow/commands/connect.ts +81 -0
- package/src/workflow/commands/view.ts +121 -0
- package/src/workflow/domain/features.ts +5 -0
- package/src/workflow/domain/view-owner.ts +18 -0
- package/src/workflow/domain/view-state.ts +12 -0
- package/src/workflow/index.ts +225 -0
- package/src/workflow/io/session.ts +48 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { CHILD_NOTICE_PREFIX, CHILD_QUESTION_PREFIX, CHILD_TOOLS_ENV, CHILD_CONTROL_ENV, CHILD_CONTROL_PREFIX, CHILD_CONTROL_REPLY } from "../io/contracts.ts";
|
|
4
|
+
import { RpcPipe, resolvePiCli } from "../io/process.ts";
|
|
5
|
+
export { resolvePiCli } from "../io/process.ts";
|
|
6
|
+
import { ProfileStore, THINKING_LEVELS, CORE_TOOLS, type ModelRef, type ThinkingLevel, type ResolvedProfile } from "./profiles.ts";
|
|
7
|
+
import { agentFailure, safeDiagnostic } from "./diagnostics.ts";
|
|
8
|
+
import { CHILD_SKILLS_ENV, skillFiles } from "../io/skills.ts";
|
|
9
|
+
|
|
10
|
+
import type { JobStatus, JobSummary, AgentUsage, JobObservation, JobInspection, JobResult, AgentNotice, JobActivity, RpcRecord } from '../io/contracts.ts';
|
|
11
|
+
export type { JobSummary, AgentRequest, AgentUsage, JobResult, AgentNotice } from '../io/contracts.ts';
|
|
12
|
+
interface AgentSendResult { delivered: true; delivery: 'accepted' | 'queued' | 'answered'; interrupted: boolean; status: JobStatus; tools?: string[] }
|
|
13
|
+
interface AgentSendFailure {
|
|
14
|
+
jobId: string; stage: 'preflight' | 'clear_queue' | 'interrupt' | 'apply_tools' | 'deliver_direction'; status: JobStatus;
|
|
15
|
+
interrupted?: boolean | null; queueCleared?: boolean | null; questionsCleared?: boolean | null; toolsApplied?: boolean | null;
|
|
16
|
+
directionDelivery: 'not_sent' | 'unknown'; currentTools: string[]; requestedTools?: string[]; error: string; recovery: string;
|
|
17
|
+
}
|
|
18
|
+
export class AgentSendError extends Error {
|
|
19
|
+
readonly details: AgentSendFailure;
|
|
20
|
+
constructor(reason: string, details: AgentSendFailure) { super(reason); this.name = 'AgentSendError'; this.details = details; }
|
|
21
|
+
}
|
|
22
|
+
interface ToolConfirmation { nonce: string; action: 'check' | 'apply'; ok: boolean; tools?: string[]; error?: string }
|
|
23
|
+
interface ToolControl { token: string; pending?: { nonce: string; action: 'check' | 'apply'; resolve(reply: ToolConfirmation): void; reject(error: Error): void } }
|
|
24
|
+
export interface SpawnOptions { task: string; todoId?: number; profile?: string; model?: ModelRef; thinking?: ThinkingLevel; skills?: string[]; tools?: string[]; timeout?: number; context?: string; resumeFrom?: string; taskDefinition?: string; signal?: AbortSignal }
|
|
25
|
+
export interface AgentRuntimeOptions {
|
|
26
|
+
cwd: string; profiles: ProfileStore; getInheritedModel: () => ModelRef | undefined;
|
|
27
|
+
getInheritedThinking?: () => ThinkingLevel; getInheritedTools?: () => readonly string[];
|
|
28
|
+
onChanged?: () => void; onNotice?: (notice: AgentNotice) => void; onActivity?: () => void;
|
|
29
|
+
childEnv?: NodeJS.ProcessEnv;
|
|
30
|
+
getModelBootstrap?: (ref: ModelRef) => Promise<{ model: Record<string, unknown>; apiKey?: string; env?: NodeJS.ProcessEnv }>;
|
|
31
|
+
getChildExtensions?: (profile: ResolvedProfile) => Promise<string[]>;
|
|
32
|
+
/** Explicit test provider/resource paths, separate from configured child extensions. */
|
|
33
|
+
testExtensions?: string[];
|
|
34
|
+
/** Test startup/protocol failures with a uniquely named local fixture. */
|
|
35
|
+
testCliPath?: string;
|
|
36
|
+
}
|
|
37
|
+
const ACTIVE = new Set<JobStatus>(["starting", "running", "waiting"]);
|
|
38
|
+
/** Concurrent child processes; a busy pool reports back instead of queueing work. */
|
|
39
|
+
const MAX_ACTIVE_JOBS = 8;
|
|
40
|
+
const DEFAULT_TIMEOUT = 600;
|
|
41
|
+
|
|
42
|
+
interface LiveJob {
|
|
43
|
+
summary: JobSummary; profileInstructions?: string; control?: ToolControl; assignment?: string; taskDefinition?: string; output: string; requests: Map<string, string>; usage: AgentUsage;
|
|
44
|
+
activity?: JobActivity; activeTools: Map<string, { tool: string; since: number }>; pipe?: RpcPipe; timer?: NodeJS.Timeout; listeners: Set<() => void>; finishing?: Promise<void>; startupAbort?: AbortController;
|
|
45
|
+
sawEnd: boolean; lastStop?: string; lastError?: string; settling: boolean; settleAgain: boolean; generation: number; sends: number;
|
|
46
|
+
/** Reports the child sent while it kept working; a bound task may be closed once it reported. */
|
|
47
|
+
messages: number;
|
|
48
|
+
/** Old tool-batch reports cannot accept a changed assignment before its direction is consumed. */
|
|
49
|
+
reportBarrier: boolean;
|
|
50
|
+
hasOutput: boolean; reportVersion: number; lastEventAt: number; deadlineAt?: number; redirecting: boolean; directions: string[];
|
|
51
|
+
}
|
|
52
|
+
function seconds(value: number | undefined, fallback: number, max: number): number {
|
|
53
|
+
const result = value ?? fallback;
|
|
54
|
+
if (!Number.isFinite(result) || result <= 0 || result > max) throw new Error(`timeout must be greater than 0 and at most ${max} seconds`);
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
function checkAbort(signal?: AbortSignal): void { if (signal?.aborted) throw new Error('Agent operation aborted'); }
|
|
58
|
+
function abortable<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
59
|
+
if (!signal) return work;
|
|
60
|
+
return new Promise<T>((resolve, reject) => {
|
|
61
|
+
const cleanup = () => signal.removeEventListener('abort', abort);
|
|
62
|
+
const abort = () => { cleanup(); reject(new Error('Agent operation aborted')); };
|
|
63
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
64
|
+
if (signal.aborted) abort();
|
|
65
|
+
work.then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function message(value: string): string {
|
|
69
|
+
if (typeof value !== "string" || !value.trim()) throw new Error("message must contain text");
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One clean line for the panel: the spawn task can span lines, carry control characters, or be
|
|
75
|
+
* arbitrarily long, and it is shown next to jobs that are not bound to any Todo.
|
|
76
|
+
*/
|
|
77
|
+
function jobLabel(task: string): string {
|
|
78
|
+
const line = task
|
|
79
|
+
// Escape sequences first: without this, removing the control bytes would leave "[31m" behind.
|
|
80
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
|
|
81
|
+
.replace(/\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
82
|
+
.replace(/\s+/g, " ")
|
|
83
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, "")
|
|
84
|
+
.trim()
|
|
85
|
+
.slice(0, 80);
|
|
86
|
+
return line.replace(/[\uD800-\uDBFF]$/, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** In-memory live processes, with compact plain summaries exported for native Pi entries. */
|
|
90
|
+
export class AgentRuntime {
|
|
91
|
+
private readonly options: AgentRuntimeOptions;
|
|
92
|
+
private jobs = new Map<string, LiveJob>();
|
|
93
|
+
private sequence = 0;
|
|
94
|
+
private unavailable = false;
|
|
95
|
+
private restoring = false;
|
|
96
|
+
constructor(options: AgentRuntimeOptions) { this.options = options; }
|
|
97
|
+
nextId(): number { return this.sequence + 1; }
|
|
98
|
+
activeCount(): number { return [...this.jobs.values()].filter((job) => ACTIVE.has(job.summary.status) || job.pipe !== undefined).length; }
|
|
99
|
+
/** Model-facing projection: ids, states and progress only, never task text or child output. */
|
|
100
|
+
private observation(job: LiveJob): JobObservation {
|
|
101
|
+
const phase = job.redirecting ? 'redirecting' : job.summary.status === 'starting' ? 'starting' : job.requests.size ? 'waiting' : !ACTIVE.has(job.summary.status) ? 'closed' : job.settling || job.sawEnd ? 'settling' : job.activity?.kind ?? 'running';
|
|
102
|
+
return { phase, elapsedMs: Math.max(0, (job.summary.endedAt ?? Date.now()) - job.summary.startedAt), lastEventAt: job.lastEventAt, ...(job.deadlineAt === undefined ? {} : { deadlineAt: job.deadlineAt }), reportVersion: job.reportVersion, hasOutput: job.hasOutput, pendingMessages: job.directions.length, ...(job.activity ? { activity: { ...job.activity } } : {}), activeTools: [...job.activeTools.values()].map((tool) => ({ ...tool })), questions: [...job.requests].map(([requestId, text]) => ({ requestId, message: text })) };
|
|
103
|
+
}
|
|
104
|
+
inspect(jobId?: string): JobInspection[] {
|
|
105
|
+
const plain = (job: LiveJob): JobInspection => { const { label: _label, ...rest } = job.summary; return { ...rest, ...this.observation(job) }; };
|
|
106
|
+
return structuredClone(jobId ? [plain(this.job(jobId))] : [...this.jobs.values()].map(plain));
|
|
107
|
+
}
|
|
108
|
+
viewSummaries(): (Pick<JobSummary, 'id' | 'todoId' | 'profile' | 'model' | 'status' | 'reportDelivery' | 'reportEmpty' | 'label' | 'taskReportStale' | 'dismissed'> & { activity?: JobActivity })[] {
|
|
109
|
+
return [...this.jobs.values()].map((job) => ({ id: job.summary.id, profile: job.summary.profile, model: { ...job.summary.model }, status: job.summary.status,
|
|
110
|
+
...(job.summary.todoId !== undefined ? { todoId: job.summary.todoId } : {}),
|
|
111
|
+
...(job.summary.reportDelivery !== undefined ? { reportDelivery: job.summary.reportDelivery } : {}),
|
|
112
|
+
...(job.summary.reportEmpty ? { reportEmpty: true as const } : {}),
|
|
113
|
+
...(job.summary.label !== undefined ? { label: job.summary.label } : {}),
|
|
114
|
+
...(job.summary.taskReportStale ? { taskReportStale: true } : {}),
|
|
115
|
+
...(job.summary.dismissed ? { dismissed: true } : {}),
|
|
116
|
+
...(job.activity ? { activity: { ...job.activity } } : {}),
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
exportSummaries(): JobSummary[] { return structuredClone([...this.jobs.values()].map((job) => job.summary)); }
|
|
120
|
+
exportRecords(): JobResult[] { return [...this.jobs.values()].map((job) => ({ ...this.result(job), ...(job.profileInstructions === undefined ? {} : { profileInstructions: job.profileInstructions }), ...(job.assignment === undefined ? {} : { assignment: job.assignment }), ...(job.taskDefinition === undefined ? {} : { taskDefinition: job.taskDefinition }) })); }
|
|
121
|
+
/** Forget finished history only; this never aborts or stops a live process. */
|
|
122
|
+
prune(jobIds: readonly string[]): number {
|
|
123
|
+
const selected = jobIds.map((id) => this.job(id));
|
|
124
|
+
if (selected.some((job) => ACTIVE.has(job.summary.status) || job.pipe)) throw new Error('Cannot clear an active agent record');
|
|
125
|
+
if (selected.some((job) => job.summary.reportDelivery === 'pending')) throw new Error('Cannot clear an undelivered agent report; inspect it or explicitly remove the job');
|
|
126
|
+
let removed = 0;
|
|
127
|
+
for (const job of selected) if (this.jobs.delete(job.summary.id)) removed++;
|
|
128
|
+
if (removed) this.changed();
|
|
129
|
+
return removed;
|
|
130
|
+
}
|
|
131
|
+
activities(): { jobId: string; activity: JobActivity }[] {
|
|
132
|
+
return [...this.jobs.values()].filter((job): job is LiveJob & { activity: JobActivity } => Boolean(job.activity)).map((job) => ({ jobId: job.summary.id, activity: structuredClone(job.activity) }));
|
|
133
|
+
}
|
|
134
|
+
/** Interim reports this job sent so far; 0 means it has not reported on its work yet. */
|
|
135
|
+
reports(jobId: string): number { return this.jobs.get(jobId)?.messages ?? 0; }
|
|
136
|
+
invalidateReports(jobId: string, awaitingDirection = false): void {
|
|
137
|
+
const job = this.job(jobId);
|
|
138
|
+
job.messages = 0;
|
|
139
|
+
job.reportBarrier = job.reportBarrier || awaitingDirection || job.directions.length > 0;
|
|
140
|
+
if (job.hasOutput) this.append(job, '[Assignment updated; earlier output is historical]\n');
|
|
141
|
+
job.summary.outputStart = job.output.length;
|
|
142
|
+
this.changed(job);
|
|
143
|
+
}
|
|
144
|
+
invalidateTaskReports(ids: readonly number[], takeover = false): void {
|
|
145
|
+
const affected = new Set(ids);
|
|
146
|
+
let changed = false;
|
|
147
|
+
for (const job of this.jobs.values()) {
|
|
148
|
+
if (job.summary.todoId === undefined || !affected.has(job.summary.todoId) || ACTIVE.has(job.summary.status)) continue;
|
|
149
|
+
if (!job.summary.taskReportStale) { job.summary.taskReportStale = true; changed = true; }
|
|
150
|
+
if (takeover && job.summary.dismissed) { delete job.summary.dismissed; changed = true; }
|
|
151
|
+
}
|
|
152
|
+
if (changed) this.changed();
|
|
153
|
+
}
|
|
154
|
+
hasRequest(jobId: string, requestId: string): boolean { return this.jobs.get(jobId)?.requests.has(requestId) ?? false; }
|
|
155
|
+
private job(id: string): LiveJob { const job = this.jobs.get(id); if (!job) throw new Error(`Unknown agent: ${id}`); return job; }
|
|
156
|
+
private changed(job?: LiveJob): void {
|
|
157
|
+
if (job) { job.summary.pendingRequests = job.requests.size; for (const listener of job.listeners) listener(); }
|
|
158
|
+
if (!this.restoring) this.options.onChanged?.();
|
|
159
|
+
}
|
|
160
|
+
private notice(job: LiveJob, notice: Omit<AgentNotice, "jobId">): void {
|
|
161
|
+
if (this.jobs.get(job.summary.id) === job && !job.finishing && !this.restoring) this.options.onNotice?.({ jobId: job.summary.id, ...notice });
|
|
162
|
+
}
|
|
163
|
+
private append(job: LiveJob, text: string): void {
|
|
164
|
+
job.output += text;
|
|
165
|
+
if (!job.hasOutput && /\S/.test(text)) job.hasOutput = true;
|
|
166
|
+
}
|
|
167
|
+
async spawn(input: SpawnOptions): Promise<JobSummary> {
|
|
168
|
+
checkAbort(input.signal);
|
|
169
|
+
if (this.unavailable) throw new Error("Agent runtime is resetting or shut down");
|
|
170
|
+
if (typeof input.task !== "string" || !input.task.trim() || input.task.length > 65536) throw new Error("task must contain 1–65536 characters");
|
|
171
|
+
if (input.taskDefinition !== undefined && !/^[0-9a-f]{64}$/.test(input.taskDefinition)) throw new Error('Invalid task definition signature');
|
|
172
|
+
if (this.jobs.size >= 128) throw new Error("128 agent records retained. Remove old agents before spawning more.");
|
|
173
|
+
if (this.activeCount() >= MAX_ACTIVE_JOBS) throw new Error(`All ${MAX_ACTIVE_JOBS} agent slots are busy. Wait or cancel an active agent; work is not queued.`);
|
|
174
|
+
if (input.todoId !== undefined && (!Number.isSafeInteger(input.todoId) || input.todoId < 1)) throw new Error("Invalid todoId");
|
|
175
|
+
if (input.todoId !== undefined && [...this.jobs.values()].some((job) => job.summary.todoId === input.todoId && (ACTIVE.has(job.summary.status) || job.pipe))) throw new Error(`Task #${input.todoId} already has an active agent`);
|
|
176
|
+
const previous = input.resumeFrom === undefined ? undefined : this.job(input.resumeFrom);
|
|
177
|
+
if (previous && (ACTIVE.has(previous.summary.status) || previous.pipe)) throw new Error('Resume requires a closed job; use send/interrupt for active work');
|
|
178
|
+
if (previous && previous.summary.todoId !== input.todoId) throw new Error('resumeFrom must keep the original Todo binding');
|
|
179
|
+
if (previous?.taskDefinition !== undefined && previous.taskDefinition !== input.taskDefinition) throw new Error('Todo definition changed; provide a fresh self-contained assignment without resumeFrom');
|
|
180
|
+
const snapshot: ResolvedProfile | undefined = previous ? { name: previous.summary.profile, model: previous.summary.model, thinking: previous.summary.thinking, tools: previous.summary.tools, ...(previous.summary.skills === undefined ? {} : { skills: previous.summary.skills }), ...(previous.profileInstructions === undefined ? {} : { instructions: previous.profileInstructions }) } : undefined;
|
|
181
|
+
const profile = this.options.profiles.resolve(input.profile, this.options.getInheritedModel(), input.tools, { thinking: this.options.getInheritedThinking?.(), tools: this.options.getInheritedTools?.() }, { ...(input.model === undefined ? {} : { model: input.model }), ...(input.thinking === undefined ? {} : { thinking: input.thinking }), ...(input.skills === undefined ? {} : { skills: input.skills }) }, snapshot);
|
|
182
|
+
const childExtensions = this.options.getChildExtensions ? await this.options.getChildExtensions(profile) : [];
|
|
183
|
+
const allowedTools = this.options.profiles.allowedTools();
|
|
184
|
+
const originalAssignment = previous?.assignment ?? input.task;
|
|
185
|
+
const taskPrompt = previous
|
|
186
|
+
? `Resume attempt from ${previous.summary.id} (metadata, not new permission). Verify files and external side effects before continuing; do not blindly replay. ${previous.assignment === undefined ? 'Legacy record has no full assignment: the continuation task must be self-contained.' : `Original assignment:\n${previous.assignment}`}\nPrevious diagnosis: ${previous.summary.failure?.reason ?? previous.summary.error ?? previous.summary.status}\nPartial output (unverified):\n${safeDiagnostic(previous.output.slice(previous.summary.outputStart ?? 0), 2000)}\nCurrent continuation task:\n${input.task}`
|
|
187
|
+
: input.task;
|
|
188
|
+
const explicitThinking = input.thinking !== undefined || input.profile !== undefined && this.options.profiles.get(input.profile)?.thinking !== undefined;
|
|
189
|
+
const timeout = seconds(input.timeout, DEFAULT_TIMEOUT, 86400);
|
|
190
|
+
const label = jobLabel(input.task);
|
|
191
|
+
const summary: JobSummary = { id: `a${++this.sequence}`, ...(input.resumeFrom === undefined ? {} : { resumeFrom: input.resumeFrom }), ...(input.todoId === undefined ? {} : { todoId: input.todoId }), profile: profile.name, model: profile.model, thinking: profile.thinking, tools: profile.tools, skills: profile.skills ?? [], ...(profile.ignoredInheritedTools ? { ignoredInheritedTools: profile.ignoredInheritedTools } : {}), status: "starting", startedAt: Date.now(), pendingRequests: 0, ...(label ? { label } : {}) };
|
|
192
|
+
const job: LiveJob = { summary, ...(profile.instructions === undefined ? {} : { profileInstructions: profile.instructions }), control: { token: randomUUID() }, assignment: originalAssignment, ...(input.taskDefinition === undefined ? {} : { taskDefinition: input.taskDefinition }), startupAbort: new AbortController(), output: "", usage: { requests: 0, input: 0, output: 0, estimatedCost: 0 }, requests: new Map(), activeTools: new Map(), listeners: new Set(), sawEnd: false, settling: false, settleAgain: false, generation: 0, sends: 0, messages: 0, reportBarrier: false, hasOutput: false, reportVersion: 0, lastEventAt: summary.startedAt, deadlineAt: summary.startedAt + timeout * 1000, redirecting: false, directions: [] };
|
|
193
|
+
this.jobs.set(summary.id, job);
|
|
194
|
+
job.timer = setTimeout(() => { void this.finish(job, "failed", "Agent deadline exceeded"); }, timeout * 1000);
|
|
195
|
+
this.changed(job); // Reserve the slot before the first await; concurrent spawn cannot exceed the limit.
|
|
196
|
+
try {
|
|
197
|
+
if (job.finishing) { await job.finishing; return structuredClone(job.summary); }
|
|
198
|
+
const startupSignal = input.signal ? AbortSignal.any([input.signal, job.startupAbort!.signal]) : job.startupAbort!.signal;
|
|
199
|
+
const selectedSkills = await abortable(skillFiles(profile.skills, this.options.cwd, profile.tools, startupSignal), startupSignal);
|
|
200
|
+
job.summary.skills = selectedSkills;
|
|
201
|
+
checkAbort(input.signal);
|
|
202
|
+
if (job.finishing) { await job.finishing; return structuredClone(job.summary); }
|
|
203
|
+
const cli = this.options.testCliPath ?? resolvePiCli();
|
|
204
|
+
let bootstrapEnv: NodeJS.ProcessEnv = {};
|
|
205
|
+
if (this.options.getModelBootstrap) {
|
|
206
|
+
const bootstrap = await abortable(this.options.getModelBootstrap(profile.model), startupSignal);
|
|
207
|
+
checkAbort(input.signal);
|
|
208
|
+
if (job.finishing) { await job.finishing; return structuredClone(job.summary); }
|
|
209
|
+
bootstrapEnv = { ...bootstrap.env, PI_DAG_AGENT_MODEL_BOOTSTRAP: JSON.stringify({ model: { ...bootstrap.model, provider: profile.model.provider, id: profile.model.id }, ...(bootstrap.apiKey === undefined ? {} : { apiKey: bootstrap.apiKey }) }) };
|
|
210
|
+
}
|
|
211
|
+
checkAbort(input.signal);
|
|
212
|
+
const childExtension = fileURLToPath(new URL("../commands/child.ts", import.meta.url));
|
|
213
|
+
const args = ["--mode", "rpc", "--no-session", "--offline", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--provider", profile.model.provider, "--model", profile.model.id, "--tools", [...new Set([...allowedTools, ...CORE_TOOLS]), "subagent_send"].join(","), "-e", childExtension, ...childExtensions.flatMap((path) => ["-e", path]), ...(this.options.testExtensions ?? []).flatMap((path) => ["-e", path])];
|
|
214
|
+
const childEnv = { ...process.env, ...this.options.childEnv, ...bootstrapEnv };
|
|
215
|
+
// Selection-only guidance must not be inherited accidentally from the parent environment.
|
|
216
|
+
delete childEnv.PI_DAG_AGENT_PROFILE_PROMPT; delete childEnv.PI_DAG_AGENT_CONTEXT; delete childEnv[CHILD_SKILLS_ENV];
|
|
217
|
+
delete childEnv[CHILD_TOOLS_ENV]; delete childEnv[CHILD_CONTROL_ENV];
|
|
218
|
+
delete childEnv.PI_DAG_AGENT_MODEL_BOOTSTRAP;
|
|
219
|
+
if (bootstrapEnv.PI_DAG_AGENT_MODEL_BOOTSTRAP !== undefined) childEnv.PI_DAG_AGENT_MODEL_BOOTSTRAP = bootstrapEnv.PI_DAG_AGENT_MODEL_BOOTSTRAP;
|
|
220
|
+
job.pipe = new RpcPipe(cli, args, this.options.cwd, { ...childEnv, ...(profile.instructions ? { PI_DAG_AGENT_PROFILE_PROMPT: profile.instructions } : {}), ...(input.context ? { PI_DAG_AGENT_CONTEXT: input.context } : {}), ...(selectedSkills.length ? { [CHILD_SKILLS_ENV]: JSON.stringify(selectedSkills) } : {}), [CHILD_TOOLS_ENV]: JSON.stringify(profile.tools), [CHILD_CONTROL_ENV]: job.control!.token, PI_OFFLINE: "1", PI_DAG_CHILD: "1" }, (record) => this.record(job, record), (error) => { void this.finish(job, "failed", "Child process failed", error.message); }, safeDiagnostic);
|
|
221
|
+
await job.pipe.command("get_state", {}, input.signal);
|
|
222
|
+
await this.setTools(job, profile.tools, input.signal);
|
|
223
|
+
if (job.finishing) { await job.finishing; return structuredClone(job.summary); }
|
|
224
|
+
// CLI model lookup permits fuzzy IDs. RPC set_model must resolve the exact registry ID.
|
|
225
|
+
await job.pipe.command("set_model", { provider: profile.model.provider, modelId: profile.model.id }, input.signal);
|
|
226
|
+
const supported = await job.pipe.command("get_available_thinking_levels", {}, input.signal);
|
|
227
|
+
const levels = (supported.data as { levels?: string[] } | undefined)?.levels;
|
|
228
|
+
if (!levels?.includes(profile.thinking)) {
|
|
229
|
+
if (explicitThinking || !levels?.length) throw new Error(`Thinking level ${profile.thinking} is unsupported by selected child model`);
|
|
230
|
+
const compatible = THINKING_LEVELS.filter((level) => levels.includes(level) && THINKING_LEVELS.indexOf(level) <= THINKING_LEVELS.indexOf(profile.thinking)).at(-1);
|
|
231
|
+
if (!compatible) throw new Error(`Thinking level ${profile.thinking} is unsupported by selected child model`);
|
|
232
|
+
profile.thinking = compatible; job.summary.thinking = compatible;
|
|
233
|
+
}
|
|
234
|
+
await job.pipe.command("set_thinking_level", { level: profile.thinking }, input.signal);
|
|
235
|
+
await job.pipe.command("set_auto_retry", { enabled: false }, input.signal);
|
|
236
|
+
if (job.finishing) { await job.finishing; return structuredClone(job.summary); }
|
|
237
|
+
checkAbort(input.signal);
|
|
238
|
+
job.summary.status = "running";
|
|
239
|
+
this.changed(job);
|
|
240
|
+
const accepted = await job.pipe.command("prompt", { message: taskPrompt }, input.signal);
|
|
241
|
+
checkAbort(input.signal);
|
|
242
|
+
if ((accepted.data as { disposition?: string } | undefined)?.disposition === "handled") throw new Error("Child task was handled without an agent run");
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (input.signal?.aborted) { await this.finish(job, 'cancelled', 'Dispatch cancelled'); throw new Error('Dispatch aborted; child stopped'); }
|
|
245
|
+
await this.finish(job, "failed", "Child startup or prompt failed", `${String(error)}${job.pipe?.stderr ? `; stderr: ${job.pipe.stderr}` : ''}`);
|
|
246
|
+
} finally { delete job.startupAbort; }
|
|
247
|
+
return structuredClone(job.summary);
|
|
248
|
+
}
|
|
249
|
+
private async controlTools(job: LiveJob, tools: string[], action: 'check' | 'apply', signal?: AbortSignal): Promise<void> {
|
|
250
|
+
if (!job.control || !job.pipe || job.finishing) throw new Error('Child does not support tool updates');
|
|
251
|
+
if (job.control.pending) throw new Error('A child tool check is already pending');
|
|
252
|
+
const nonce = randomUUID(), controller = new AbortController();
|
|
253
|
+
const operationSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
254
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
255
|
+
const confirmation = new Promise<ToolConfirmation>((resolve, reject) => {
|
|
256
|
+
job.control!.pending = { nonce, action, resolve, reject };
|
|
257
|
+
timer = setTimeout(() => { reject(new Error('Child tool update was not confirmed: confirmation timed out')); controller.abort(); }, 5000);
|
|
258
|
+
});
|
|
259
|
+
try {
|
|
260
|
+
const [reply, response] = await Promise.all([confirmation, job.pipe.command('prompt', { message: CHILD_CONTROL_PREFIX + JSON.stringify({ token: job.control.token, nonce, action, tools }) }, operationSignal)]);
|
|
261
|
+
if (job.finishing || !job.pipe) throw new Error('Agent ended while updating tools');
|
|
262
|
+
if ((response.data as { disposition?: string } | undefined)?.disposition !== 'handled') throw new Error('Private child tool command was not handled');
|
|
263
|
+
if (!reply.ok) throw new Error(reply.error!);
|
|
264
|
+
if (!Array.isArray(reply.tools) || reply.tools.length !== tools.length || new Set(reply.tools).size !== tools.length || tools.some((name) => !reply.tools!.includes(name))) throw new Error('Child tool update was not confirmed: unexpected tool set');
|
|
265
|
+
if (action === 'apply') job.summary.tools = [...reply.tools];
|
|
266
|
+
} finally {
|
|
267
|
+
if (timer) clearTimeout(timer);
|
|
268
|
+
delete job.control.pending;
|
|
269
|
+
controller.abort();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
private async setTools(job: LiveJob, tools: string[], signal?: AbortSignal): Promise<void> { await this.controlTools(job, tools, 'apply', signal); }
|
|
273
|
+
private record(job: LiveJob, record: RpcRecord): void {
|
|
274
|
+
if (job.finishing || this.jobs.get(job.summary.id) !== job) return;
|
|
275
|
+
job.lastEventAt = Date.now();
|
|
276
|
+
if (record.type === 'message_start') {
|
|
277
|
+
const item = record.message as { role?: string; content?: string | { type?: string; text?: string }[] } | undefined;
|
|
278
|
+
if (item?.role === 'user') {
|
|
279
|
+
const text = typeof item.content === 'string' ? item.content : (item.content ?? []).filter((part) => part.type === 'text').map((part) => part.text ?? '').join('\n');
|
|
280
|
+
const at = job.directions.indexOf(text);
|
|
281
|
+
if (at >= 0) {
|
|
282
|
+
job.directions.splice(at, 1);
|
|
283
|
+
if (job.reportBarrier && !job.directions.length) { job.reportBarrier = false; job.summary.outputStart = job.output.length; this.changed(job); }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} else if (record.type === "message_update") {
|
|
287
|
+
const update = record.assistantMessageEvent as { type?: string } | undefined;
|
|
288
|
+
const kind = update?.type === "thinking_delta" || update?.type === "thinking_start" ? "thinking" : update?.type === "text_delta" || update?.type === "text_start" ? "output" : undefined;
|
|
289
|
+
if (kind && !job.activeTools.size && job.activity?.kind !== kind) { job.activity = { kind, since: Date.now() }; this.options.onActivity?.(); }
|
|
290
|
+
} else if (record.type === "tool_execution_start") {
|
|
291
|
+
job.activeTools.set(String(record.toolCallId ?? record.toolName), { tool: String(record.toolName ?? ""), since: Date.now() });
|
|
292
|
+
this.toolActivity(job);
|
|
293
|
+
} else if (record.type === "tool_execution_end") {
|
|
294
|
+
job.activeTools.delete(String(record.toolCallId ?? record.toolName));
|
|
295
|
+
this.toolActivity(job);
|
|
296
|
+
} else if (record.type === "message_end") {
|
|
297
|
+
const msg = record.message as { role?: string; content?: { type?: string; text?: string }[]; stopReason?: string; errorMessage?: string; usage?: { input?: number; output?: number; cost?: { total?: number } } } | undefined;
|
|
298
|
+
if (msg?.role === "assistant") {
|
|
299
|
+
const number = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
300
|
+
job.usage.requests++;
|
|
301
|
+
job.usage.input += number(msg.usage?.input); job.usage.output += number(msg.usage?.output);
|
|
302
|
+
job.usage.estimatedCost += number(msg.usage?.cost?.total);
|
|
303
|
+
this.append(job, (job.reportBarrier ? '[Historical output] ' : '') + (msg.content ?? []).filter((block) => block.type === "text").map((block) => block.text ?? "").join("\n") + "\n");
|
|
304
|
+
if (job.reportBarrier) job.summary.outputStart = job.output.length;
|
|
305
|
+
job.lastStop = msg.stopReason ?? "stop";
|
|
306
|
+
if (msg.errorMessage) job.lastError = msg.errorMessage;
|
|
307
|
+
}
|
|
308
|
+
} else if (record.type === "agent_start") {
|
|
309
|
+
job.sawEnd = false; job.generation++; delete job.activity; job.activeTools.clear(); job.summary.status = job.requests.size ? "waiting" : "running"; this.changed(job);
|
|
310
|
+
} else if (record.type === "agent_end") {
|
|
311
|
+
job.sawEnd = true; delete job.activity; job.activeTools.clear(); this.options.onActivity?.();
|
|
312
|
+
} else if (record.type === "agent_settled") {
|
|
313
|
+
void this.settled(job);
|
|
314
|
+
} else if (record.type === "extension_ui_request") {
|
|
315
|
+
const id = record.id;
|
|
316
|
+
if (record.method === 'notify' && typeof record.message === 'string' && record.message.startsWith(CHILD_CONTROL_REPLY)) {
|
|
317
|
+
const pending = job.control?.pending;
|
|
318
|
+
if (pending) {
|
|
319
|
+
try {
|
|
320
|
+
const reply = JSON.parse(record.message.slice(CHILD_CONTROL_REPLY.length)) as ToolConfirmation;
|
|
321
|
+
if (reply?.nonce !== pending.nonce) return;
|
|
322
|
+
if (reply.action !== pending.action || typeof reply.ok !== 'boolean' || !reply.ok && (typeof reply.error !== 'string' || !reply.error.trim())) throw new Error('Malformed child tool confirmation');
|
|
323
|
+
pending.resolve({ ...reply, ...(reply.error ? { error: safeDiagnostic(reply.error) } : {}) });
|
|
324
|
+
} catch { pending.reject(new Error('Malformed child tool confirmation')); }
|
|
325
|
+
}
|
|
326
|
+
} else if (record.method === "notify" && typeof record.message === "string" && record.message.startsWith(CHILD_NOTICE_PREFIX)) {
|
|
327
|
+
const text = record.message.slice(CHILD_NOTICE_PREFIX.length);
|
|
328
|
+
// A blank notification is protocol decoration, not a report: wrapping it in `[Report]` would
|
|
329
|
+
// manufacture a body that the child never sent, and a report event must stay empty.
|
|
330
|
+
if (!/\S/.test(text)) return;
|
|
331
|
+
if (job.reportBarrier) { this.append(job, `[Historical report] ${text}\n`); job.summary.outputStart = job.output.length; }
|
|
332
|
+
else {
|
|
333
|
+
job.messages += 1; job.reportVersion++;
|
|
334
|
+
this.append(job, `[Report] ${text}\n`);
|
|
335
|
+
this.notice(job, { kind: "message", message: text, version: job.reportVersion });
|
|
336
|
+
}
|
|
337
|
+
this.changed(job);
|
|
338
|
+
} else if (record.method === "input" && id && typeof record.title === "string" && record.title.startsWith(CHILD_QUESTION_PREFIX)) {
|
|
339
|
+
if (job.requests.size >= 16) { void this.finish(job, "failed", "Too many pending child questions"); return; }
|
|
340
|
+
const text = record.title.slice(CHILD_QUESTION_PREFIX.length);
|
|
341
|
+
job.reportVersion++;
|
|
342
|
+
job.requests.set(id, text); job.summary.status = "waiting"; this.changed(job);
|
|
343
|
+
this.notice(job, { kind: "question", message: text, requestId: id, version: job.reportVersion });
|
|
344
|
+
} else if (id && ["input", "select", "confirm", "editor"].includes(String(record.method))) {
|
|
345
|
+
void job.pipe?.write({ type: "extension_ui_response", id, cancelled: true }).catch(() => undefined);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
private toolActivity(job: LiveJob): void {
|
|
350
|
+
const tool = [...job.activeTools.values()].at(-1);
|
|
351
|
+
if (tool) job.activity = { kind: "tool", tool: tool.tool, since: tool.since };
|
|
352
|
+
else delete job.activity;
|
|
353
|
+
this.options.onActivity?.();
|
|
354
|
+
}
|
|
355
|
+
private async settled(job: LiveJob): Promise<void> {
|
|
356
|
+
if (!job.sawEnd || job.finishing || !job.pipe || job.redirecting) return;
|
|
357
|
+
if (job.settling) { job.settleAgain = true; return; }
|
|
358
|
+
job.settling = true;
|
|
359
|
+
const generation = job.generation;
|
|
360
|
+
try {
|
|
361
|
+
const state = await job.pipe.command("get_state");
|
|
362
|
+
const data = state.data as { isStreaming?: boolean; pendingMessageCount?: number; isCompacting?: boolean };
|
|
363
|
+
if (job.finishing || job.redirecting || generation !== job.generation || job.sends || data.isStreaming || data.isCompacting || data.pendingMessageCount || job.requests.size) return;
|
|
364
|
+
const failed = job.lastStop === "error" || job.lastStop === "aborted";
|
|
365
|
+
await this.finish(job, failed ? "failed" : "completed", failed ? "Child model failed or aborted" : undefined, job.lastError);
|
|
366
|
+
} catch (error) { if (!job.finishing) await this.finish(job, "failed", "Child completion check failed", String(error)); }
|
|
367
|
+
finally {
|
|
368
|
+
job.settling = false;
|
|
369
|
+
if (job.settleAgain) { job.settleAgain = false; void this.settled(job); }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Only the current attempt counts: earlier output stays readable but is not this report. */
|
|
373
|
+
private readableAttempt(job: LiveJob): boolean { return /\S/.test(job.output.slice(job.summary.outputStart ?? 0)); }
|
|
374
|
+
private finish(job: LiveJob, status: JobStatus, error?: string, diagnostic?: string): Promise<void> {
|
|
375
|
+
if (job.finishing) return job.finishing;
|
|
376
|
+
if (!ACTIVE.has(job.summary.status) && !job.pipe) return Promise.resolve();
|
|
377
|
+
job.finishing = (async () => {
|
|
378
|
+
if (job.timer) clearTimeout(job.timer);
|
|
379
|
+
job.control?.pending?.reject(new Error('Agent ended while waiting for tool confirmation'));
|
|
380
|
+
job.startupAbort?.abort();
|
|
381
|
+
if (diagnostic) this.append(job, `[Error] ${safeDiagnostic(diagnostic)}\n`);
|
|
382
|
+
await job.pipe?.stop();
|
|
383
|
+
delete job.pipe;
|
|
384
|
+
delete job.activity;
|
|
385
|
+
job.activeTools.clear();
|
|
386
|
+
job.requests.clear(); job.directions.length = 0; job.reportBarrier = false; job.reportVersion++;
|
|
387
|
+
job.summary.status = status; job.summary.endedAt = Date.now();
|
|
388
|
+
// Only a normally finished attempt with nothing readable has no delivery debt. A failure keeps
|
|
389
|
+
// its unread state even with an empty body: its diagnostic is what the parent must still read,
|
|
390
|
+
// and the diagnostic above is not the only way a failure carries one.
|
|
391
|
+
const readable = status === 'failed' || this.readableAttempt(job);
|
|
392
|
+
if (status === "completed" || status === "failed") {
|
|
393
|
+
job.summary.reportDelivery = readable ? "pending" : "delivered";
|
|
394
|
+
if (readable) delete job.summary.reportEmpty; else job.summary.reportEmpty = true;
|
|
395
|
+
}
|
|
396
|
+
if (error) job.summary.error = safeDiagnostic(error, 200);
|
|
397
|
+
if (status === 'failed') job.summary.failure = agentFailure(diagnostic ?? error ?? 'Child failed without a diagnostic');
|
|
398
|
+
// Queue the terminal notice before the completion paint so a pending report never flashes as returned.
|
|
399
|
+
if (!this.restoring && this.jobs.get(job.summary.id) === job && (status === "completed" || status === "failed")) this.options.onNotice?.({ jobId: job.summary.id, kind: status, message: status === "completed" ? job.output : `${job.summary.error ?? 'Child failed'} [${job.summary.failure!.kind}]: ${job.summary.failure!.reason}. ${job.summary.failure!.recovery}`, version: job.reportVersion });
|
|
400
|
+
this.changed(job);
|
|
401
|
+
})();
|
|
402
|
+
return job.finishing;
|
|
403
|
+
}
|
|
404
|
+
async send(input: { recipient?: string; message: string; requestId?: string; interrupt?: boolean; tools?: string[]; signal?: AbortSignal }): Promise<AgentSendResult> {
|
|
405
|
+
checkAbort(input.signal);
|
|
406
|
+
const text = message(input.message);
|
|
407
|
+
if (input.tools !== undefined && (!input.recipient || input.requestId !== undefined || input.interrupt !== true)) throw new Error('tools requires recipient and interrupt:true, without requestId');
|
|
408
|
+
const tools = input.tools === undefined ? undefined : this.options.profiles.validateTools(input.tools);
|
|
409
|
+
const matches = input.requestId ? [...this.jobs.values()].filter((item) => item.requests.has(input.requestId!)) : [];
|
|
410
|
+
if (!input.recipient && matches.length !== 1) throw new Error("Specify recipient, or a unique pending child requestId");
|
|
411
|
+
const job = input.recipient ? this.job(input.recipient) : matches[0]!;
|
|
412
|
+
if (!ACTIVE.has(job.summary.status) || !job.pipe || job.finishing) throw new Error("Agent is no longer active");
|
|
413
|
+
if (job.summary.status === "starting") throw new Error("Agent is still starting; wait briefly before sending");
|
|
414
|
+
if (job.redirecting) throw new Error('Agent is already redirecting; wait for the current adjustment');
|
|
415
|
+
if (input.interrupt && input.requestId) throw new Error('interrupt applies to recipient direction only');
|
|
416
|
+
if (input.requestId) {
|
|
417
|
+
if (!job.requests.has(input.requestId)) throw new Error("Unknown or already answered child requestId");
|
|
418
|
+
job.requests.delete(input.requestId);
|
|
419
|
+
try { await abortable(job.pipe.write({ type: "extension_ui_response", id: input.requestId, value: text }), input.signal); }
|
|
420
|
+
catch (error) { await this.finish(job, input.signal?.aborted ? 'cancelled' : 'failed', input.signal?.aborted ? 'Reply cancelled' : 'Child reply failed', String(error)); throw error; }
|
|
421
|
+
if (!job.finishing) { job.summary.status = job.requests.size ? "waiting" : "running"; this.changed(job); }
|
|
422
|
+
return { delivered: true, delivery: 'answered', interrupted: false, status: job.summary.status };
|
|
423
|
+
} else {
|
|
424
|
+
if (job.sends >= 16) throw new Error("Too many pending parent messages; wait for the child to consume them");
|
|
425
|
+
job.sends++; job.generation++;
|
|
426
|
+
if (input.interrupt) { job.redirecting = true; this.changed(job); }
|
|
427
|
+
let queuedDirection = false;
|
|
428
|
+
let mutated = false;
|
|
429
|
+
let previousEnd = job.sawEnd;
|
|
430
|
+
let stage: AgentSendFailure['stage'] = 'preflight';
|
|
431
|
+
let interrupted: boolean | null = false, queueCleared: boolean | null = false, questionsCleared: boolean | null = false, toolsApplied: boolean | null = false;
|
|
432
|
+
let directionDelivery: AgentSendFailure['directionDelivery'] = 'not_sent';
|
|
433
|
+
const directionGeneration = job.generation;
|
|
434
|
+
try {
|
|
435
|
+
if (input.interrupt) {
|
|
436
|
+
if (tools !== undefined) await this.controlTools(job, tools, 'check', input.signal);
|
|
437
|
+
checkAbort(input.signal);
|
|
438
|
+
if (job.finishing || !job.pipe) throw new Error('Agent ended before interruption');
|
|
439
|
+
// Only a successful non-mutating check may retire the old assignment.
|
|
440
|
+
mutated = true; stage = 'clear_queue'; queueCleared = null;
|
|
441
|
+
this.invalidateReports(job.summary.id, true);
|
|
442
|
+
await job.pipe.command('clear_queue', {}, input.signal); queueCleared = true;
|
|
443
|
+
stage = 'interrupt'; interrupted = null; questionsCleared = null;
|
|
444
|
+
await job.pipe.command('abort', {}, input.signal); interrupted = true; questionsCleared = true;
|
|
445
|
+
if (job.finishing || !job.pipe) throw new Error('Agent ended while interrupting');
|
|
446
|
+
await job.pipe.command('clear_queue', {}, input.signal);
|
|
447
|
+
job.requests.clear(); job.directions.length = 0; job.summary.status = 'running';
|
|
448
|
+
// Keep the aborted outcome until a new response replaces it: a rejected direction
|
|
449
|
+
// must not turn the interrupted assignment into a successful completion.
|
|
450
|
+
delete job.lastError;
|
|
451
|
+
if (tools !== undefined) {
|
|
452
|
+
stage = 'apply_tools'; toolsApplied = null;
|
|
453
|
+
await this.setTools(job, tools, input.signal); toolsApplied = true;
|
|
454
|
+
this.changed(job);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
stage = 'deliver_direction';
|
|
458
|
+
const state = await job.pipe.command("get_state", {}, input.signal);
|
|
459
|
+
const queued = (state.data as { pendingMessageCount?: number } | undefined)?.pendingMessageCount ?? 0;
|
|
460
|
+
if (queued + job.sends > 16) throw new Error("Child message buffer is full; reply to questions or wait before sending more");
|
|
461
|
+
job.directions.push(text); queuedDirection = true;
|
|
462
|
+
previousEnd = job.sawEnd; job.sawEnd = false;
|
|
463
|
+
checkAbort(input.signal); mutated = true;
|
|
464
|
+
directionDelivery = 'unknown';
|
|
465
|
+
const accepted = await job.pipe.command("prompt", { message: text, streamingBehavior: "steer" }, input.signal);
|
|
466
|
+
const disposition = (accepted.data as { disposition?: string } | undefined)?.disposition;
|
|
467
|
+
if (disposition === 'handled') { directionDelivery = 'not_sent'; throw new Error('Child direction was handled without a model run'); }
|
|
468
|
+
return { delivered: true, delivery: disposition === 'queued' ? 'queued' : 'accepted', interrupted: input.interrupt ?? false, status: job.summary.status, ...(tools === undefined ? {} : { tools: [...job.summary.tools] }) };
|
|
469
|
+
} catch (cause) {
|
|
470
|
+
if (job.generation === directionGeneration) job.sawEnd = previousEnd;
|
|
471
|
+
if (queuedDirection) { const at = job.directions.indexOf(text); if (at >= 0) job.directions.splice(at, 1); }
|
|
472
|
+
// A mutating RPC may have reached the child before cancellation; stop it rather than
|
|
473
|
+
// leave an unconfirmed new direction running. A cancelled observation leaves it alone.
|
|
474
|
+
if (input.signal?.aborted && mutated) await this.finish(job, 'cancelled', 'Direction cancelled');
|
|
475
|
+
else if (input.interrupt && mutated) await this.finish(job, 'failed', 'Child redirection failed', `${stage}: ${String(cause)}`);
|
|
476
|
+
if (job.finishing) await job.finishing;
|
|
477
|
+
const error = safeDiagnostic(String(cause));
|
|
478
|
+
throw new AgentSendError(error, { jobId: job.summary.id, stage, status: job.summary.status, ...(input.interrupt ? { interrupted, queueCleared, questionsCleared } : {}), directionDelivery, currentTools: [...job.summary.tools], ...(tools === undefined ? {} : { requestedTools: tools, toolsApplied }), error,
|
|
479
|
+
recovery: !ACTIVE.has(job.summary.status) ? 'The child stopped; inspect partial files and external effects before an explicitly authorized resumeFrom attempt. Queue/question waits are not restored automatically.' : mutated ? 'The child is still active without a confirmed new direction. Inspect its current output and pending messages before sending again; do not blindly replay.' : 'This request did not interrupt or change permissions. Correct the tool selection and retry, or answer the original question if the child is still waiting.' });
|
|
480
|
+
} finally {
|
|
481
|
+
job.sends--; if (input.interrupt) job.redirecting = false; this.changed(job);
|
|
482
|
+
if (job.sawEnd && !job.finishing) void this.settled(job);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
private result(job: LiveJob, timedOut = false, reason?: JobResult['reason']): JobResult {
|
|
487
|
+
return { ...structuredClone(job.summary), output: job.output, usage: { ...job.usage }, requests: [...job.requests].map(([requestId, text]) => ({ requestId, message: text })), reportVersion: job.reportVersion, ...(reason ? { reason, observation: this.observation(job) } : {}), ...(timedOut ? { timedOut: true } : {}) };
|
|
488
|
+
}
|
|
489
|
+
async wait(jobId: string, options: { timeout?: number; signal?: AbortSignal; until?: 'update' | 'finish'; after?: number } = {}): Promise<JobResult> {
|
|
490
|
+
const job = this.job(jobId);
|
|
491
|
+
const until = options.until ?? 'update';
|
|
492
|
+
if (!['update', 'finish'].includes(until) || options.after !== undefined && (!Number.isSafeInteger(options.after) || options.after < 0)) throw new Error('Invalid wait condition/version');
|
|
493
|
+
const after = options.after ?? job.reportVersion;
|
|
494
|
+
const timeout = options.timeout === 0 ? 0 : seconds(options.timeout, until === 'finish' ? 30 : 5, 300);
|
|
495
|
+
if (options.signal?.aborted) throw new Error("Wait aborted (agent continues)");
|
|
496
|
+
if (timeout === 0) return this.result(job, false, 'snapshot');
|
|
497
|
+
if (!ACTIVE.has(job.summary.status) && !job.pipe) return this.result(job, false, 'finished');
|
|
498
|
+
if (job.requests.size) return this.result(job, false, 'question');
|
|
499
|
+
if (until === 'update' && job.reportVersion > after) return this.result(job, false, 'update');
|
|
500
|
+
return new Promise((resolveWait, reject) => {
|
|
501
|
+
const finish = (timedOut = false, aborted = false) => {
|
|
502
|
+
clearTimeout(timer); job.listeners.delete(check); options.signal?.removeEventListener("abort", abort);
|
|
503
|
+
if (aborted) reject(new Error("Wait aborted (agent continues)")); else resolveWait(this.result(job, timedOut, timedOut ? 'timeout' : job.requests.size ? 'question' : !ACTIVE.has(job.summary.status) && !job.pipe ? 'finished' : 'update'));
|
|
504
|
+
};
|
|
505
|
+
const check = () => { if ((!ACTIVE.has(job.summary.status) && !job.pipe) || job.requests.size || until === 'update' && job.reportVersion > after) finish(); };
|
|
506
|
+
const abort = () => finish(false, true);
|
|
507
|
+
const timer = setTimeout(() => finish(true), timeout * 1000);
|
|
508
|
+
job.listeners.add(check); options.signal?.addEventListener("abort", abort, { once: true }); check();
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
async cancel(jobId: string, options: { remove?: boolean } = {}): Promise<void> {
|
|
512
|
+
const job = this.job(jobId);
|
|
513
|
+
await this.finish(job, "cancelled");
|
|
514
|
+
// A successful execution has already stopped. A stop request must not hide its
|
|
515
|
+
// unreceived acceptance evidence; remove is the explicit discard operation.
|
|
516
|
+
if (!options.remove && job.summary.status === 'completed' && job.summary.reportDelivery === 'pending') return;
|
|
517
|
+
job.summary.dismissed = true;
|
|
518
|
+
if (job.summary.todoId !== undefined) job.summary.taskReportStale = true;
|
|
519
|
+
this.changed(job);
|
|
520
|
+
if (options.remove) { this.jobs.delete(jobId); this.changed(); }
|
|
521
|
+
}
|
|
522
|
+
async remove(jobId: string): Promise<void> { await this.cancel(jobId, { remove: true }); }
|
|
523
|
+
/** Acknowledge that a terminal report reached the parent context. Never revives a process. */
|
|
524
|
+
markReportDelivered(jobId: string): boolean {
|
|
525
|
+
const job = this.jobs.get(jobId);
|
|
526
|
+
if (!job || (job.summary.status !== "completed" && job.summary.status !== "failed") || job.summary.reportDelivery !== "pending") return false;
|
|
527
|
+
job.summary.reportDelivery = "delivered";
|
|
528
|
+
this.changed(job);
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
async importSummaries(summaries: readonly (JobSummary | JobResult)[], nextId?: number): Promise<void> {
|
|
532
|
+
// Decode untrusted branch entries before touching the live runtime.
|
|
533
|
+
if (!Array.isArray(summaries) || summaries.length > 128) throw new Error("Invalid agent summaries");
|
|
534
|
+
const restored = summaries.map((item) => {
|
|
535
|
+
if (!item || !/^a[1-9]\d{0,8}$/.test(item.id) || ![...ACTIVE, "completed", "failed", "cancelled", "interrupted"].includes(item.status) || !Number.isFinite(item.startedAt) || typeof item.profile !== "string" || !Array.isArray(item.tools) || !item.model || typeof item.model.provider !== "string" || typeof item.model.id !== "string") throw new Error("Invalid agent summary");
|
|
536
|
+
if (!THINKING_LEVELS.includes(item.thinking) || item.endedAt !== undefined && !Number.isFinite(item.endedAt) || item.todoId !== undefined && (!Number.isSafeInteger(item.todoId) || item.todoId < 1)) throw new Error("Invalid agent summary");
|
|
537
|
+
if (item.taskReportStale !== undefined && typeof item.taskReportStale !== 'boolean') throw new Error("Invalid agent summary");
|
|
538
|
+
if (item.dismissed !== undefined && typeof item.dismissed !== 'boolean') throw new Error('Invalid dismissed agent marker');
|
|
539
|
+
if (item.resumeFrom !== undefined && (typeof item.resumeFrom !== 'string' || !/^a[1-9]\d{0,8}$/.test(item.resumeFrom))) throw new Error('Invalid resumeFrom');
|
|
540
|
+
const saved = item as JobResult;
|
|
541
|
+
if (saved.profileInstructions !== undefined && (typeof saved.profileInstructions !== 'string' || !saved.profileInstructions.trim() || saved.profileInstructions.length > 2000)) throw new Error('Invalid saved profile instructions');
|
|
542
|
+
if (item.skills !== undefined) this.options.profiles.validateSkills(item.skills);
|
|
543
|
+
if (item.ignoredInheritedTools !== undefined && (!Array.isArray(item.ignoredInheritedTools) || item.ignoredInheritedTools.some((name: unknown) => typeof name !== 'string'))) throw new Error('Invalid inherited tool omissions');
|
|
544
|
+
if (saved.assignment !== undefined && (typeof saved.assignment !== 'string' || !saved.assignment.trim() || saved.assignment.length > 65536)) throw new Error('Invalid saved assignment');
|
|
545
|
+
if (saved.taskDefinition !== undefined && (typeof saved.taskDefinition !== 'string' || !/^[0-9a-f]{64}$/.test(saved.taskDefinition))) throw new Error('Invalid task definition');
|
|
546
|
+
if (item.failure !== undefined && (!item.failure || !['quota', 'auth', 'network', 'timeout', 'process', 'unknown'].includes(item.failure.kind) || typeof item.failure.reason !== 'string' || typeof item.failure.recovery !== 'string' || ![true, false, null].includes(item.failure.retryable))) throw new Error('Invalid failure diagnostic');
|
|
547
|
+
const rawOutput = (item as JobResult).output ?? '';
|
|
548
|
+
if (item.outputStart !== undefined && (!Number.isSafeInteger(item.outputStart) || item.outputStart < 0 || typeof rawOutput !== 'string' || item.outputStart > rawOutput.length || /[\uD800-\uDBFF]/.test(rawOutput.charAt(item.outputStart - 1)) && /[\uDC00-\uDFFF]/.test(rawOutput.charAt(item.outputStart)))) throw new Error('Invalid current output offset');
|
|
549
|
+
const reportVersion = (item as JobResult).reportVersion;
|
|
550
|
+
if (reportVersion !== undefined && (!Number.isSafeInteger(reportVersion) || reportVersion < 0)) throw new Error('Invalid reportVersion');
|
|
551
|
+
const delivery = (item as { reportDelivery?: unknown }).reportDelivery;
|
|
552
|
+
if (delivery !== undefined && delivery !== "pending" && delivery !== "delivered") throw new Error("Invalid agent summary");
|
|
553
|
+
const terminal = item.status === "completed" || item.status === "failed";
|
|
554
|
+
// Re-derive what the current attempt left behind: a legacy blank pending *completion* must not
|
|
555
|
+
// keep owning a default row forever. A failed record always keeps its unread state, and an old
|
|
556
|
+
// record that was already delivered is never turned back into debt.
|
|
557
|
+
const readableRestored = /\S/.test(rawOutput.slice(typeof item.outputStart === 'number' ? item.outputStart : 0));
|
|
558
|
+
const blankCompletion = !readableRestored && item.status === 'completed';
|
|
559
|
+
const summary: JobSummary = {
|
|
560
|
+
id: item.id, ...(Number.isSafeInteger(item.todoId) && item.todoId! > 0 ? { todoId: item.todoId } : {}),
|
|
561
|
+
profile: item.profile.slice(0, 48), model: { provider: item.model.provider.slice(0, 200), id: item.model.id.slice(0, 200) },
|
|
562
|
+
...(typeof (item as { label?: unknown }).label === "string" ? { label: jobLabel(String((item as { label?: unknown }).label)) } : {}),
|
|
563
|
+
...(item.taskReportStale ? { taskReportStale: true } : {}),
|
|
564
|
+
...(item.dismissed ? { dismissed: true } : {}),
|
|
565
|
+
...(item.resumeFrom ? { resumeFrom: item.resumeFrom } : {}),
|
|
566
|
+
...(item.failure ? { failure: agentFailure(String(item.failure.reason ?? item.error ?? 'Unknown failure')) } : {}),
|
|
567
|
+
...(item.outputStart !== undefined ? { outputStart: item.outputStart } : {}),
|
|
568
|
+
...(item.skills === undefined ? {} : { skills: this.options.profiles.validateSkills(item.skills) }),
|
|
569
|
+
...(item.ignoredInheritedTools === undefined ? {} : { ignoredInheritedTools: [...item.ignoredInheritedTools] }),
|
|
570
|
+
thinking: item.thinking, tools: item.tools.filter((tool: unknown): tool is string => typeof tool === "string"),
|
|
571
|
+
status: ACTIVE.has(item.status) ? "interrupted" : item.status, startedAt: item.startedAt, pendingRequests: 0,
|
|
572
|
+
...(typeof item.endedAt === "number" ? { endedAt: item.endedAt } : {}),
|
|
573
|
+
...(terminal && (delivery === 'pending' || delivery === 'delivered') ? { reportDelivery: blankCompletion ? 'delivered' as const : delivery } : {}),
|
|
574
|
+
...(blankCompletion ? { reportEmpty: true as const } : {}),
|
|
575
|
+
...(ACTIVE.has(item.status) ? { endedAt: Date.now(), error: "Interrupted on session restore; process was not revived" } : item.error ? { error: safeDiagnostic(String(item.error), 200) } : {}),
|
|
576
|
+
};
|
|
577
|
+
return summary;
|
|
578
|
+
});
|
|
579
|
+
if (new Set(restored.map((item) => item.id)).size !== restored.length) throw new Error("Duplicate restored agent ID");
|
|
580
|
+
const highestId = restored.reduce((highest, item) => Math.max(highest, Number(item.id.slice(1))), 0);
|
|
581
|
+
if (nextId !== undefined && (!Number.isSafeInteger(nextId) || nextId <= highestId || nextId < 1)) throw new Error('Invalid agent nextId');
|
|
582
|
+
this.unavailable = true;
|
|
583
|
+
this.restoring = true;
|
|
584
|
+
try {
|
|
585
|
+
await Promise.all([...this.jobs.values()].map((job) => this.finish(job, "interrupted")));
|
|
586
|
+
this.jobs.clear();
|
|
587
|
+
this.sequence = Math.max(this.sequence, (nextId ?? 1) - 1);
|
|
588
|
+
for (const [index, summary] of restored.entries()) {
|
|
589
|
+
const record = summaries[index] as Partial<JobResult>;
|
|
590
|
+
const output = typeof record.output === "string" ? record.output : "";
|
|
591
|
+
this.sequence = Math.max(this.sequence, Number(summary.id.slice(1)));
|
|
592
|
+
const number = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
593
|
+
const usage = { requests: number(record.usage?.requests), input: number(record.usage?.input), output: number(record.usage?.output), estimatedCost: number(record.usage?.estimatedCost) };
|
|
594
|
+
this.jobs.set(summary.id, { summary, ...(record.profileInstructions === undefined ? {} : { profileInstructions: record.profileInstructions }), ...(record.assignment === undefined ? {} : { assignment: record.assignment }), ...(record.taskDefinition === undefined ? {} : { taskDefinition: record.taskDefinition }), usage, output, requests: new Map(), activeTools: new Map(), listeners: new Set(), sawEnd: false, settling: false, settleAgain: false, generation: 0, messages: 0, sends: 0, reportBarrier: false, hasOutput: /\S/.test(output), reportVersion: Number.isSafeInteger(record.reportVersion) && record.reportVersion! >= 0 ? record.reportVersion! : 0, lastEventAt: summary.endedAt ?? summary.startedAt, redirecting: false, directions: [] });
|
|
595
|
+
}
|
|
596
|
+
} finally { this.unavailable = false; this.restoring = false; }
|
|
597
|
+
this.changed();
|
|
598
|
+
}
|
|
599
|
+
async shutdown(): Promise<void> {
|
|
600
|
+
this.unavailable = true;
|
|
601
|
+
await Promise.all([...this.jobs.values()].map((job) => this.finish(job, "interrupted")));
|
|
602
|
+
}
|
|
603
|
+
}
|