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,15 @@
|
|
|
1
|
+
import { Type, type Static } from 'typebox';
|
|
2
|
+
import { THINKING_LEVELS } from './profiles.ts';
|
|
3
|
+
|
|
4
|
+
const idSchema = Type.String({ minLength: 1, maxLength: 80 });
|
|
5
|
+
const seconds = Type.Number({ exclusiveMinimum: 0, maximum: 86400 });
|
|
6
|
+
const text = Type.String({ minLength: 1 });
|
|
7
|
+
|
|
8
|
+
export const AgentSpawnParamsSchema = Type.Object({ task: text, resumeFrom: Type.Optional(idSchema), todoId: Type.Optional(Type.Integer({ minimum: 1, description: 'Bind this child to an existing unblocked Todo; the report never completes the Todo' })), profile: Type.Optional(Type.String({ minLength: 1, maxLength: 80, description: 'New work defaults to parent; resume defaults to last actual configuration. inherit explicitly resets to current parent; otherwise a saved profile name' })), model: Type.Optional(Type.Object({ provider: text, id: text }, { additionalProperties: false, description: 'Explicit provider/id from subagent_inspect models:true; overrides the base model' })), thinking: Type.Optional(Type.Enum(THINKING_LEVELS, { type: 'string', description: 'Overrides the base thinking level' })), tools: Type.Optional(Type.Array(Type.String(), { description: 'Replace the tool list with exact names from subagent_inspect tools:true; read is not added implicitly' })), skills: Type.Optional(Type.Array(text, { description: 'Omit to inherit base skills; [] disables; local Markdown paths replace. Requires read; no automatic discovery or system-prompt injection' })), timeout: Type.Optional(seconds), context: Type.Optional(Type.Boolean({ description: "Attach a bounded task/prerequisite brief; Todo metadata.artifacts can declare file references" })) }, { additionalProperties: false });
|
|
9
|
+
export type AgentSpawnParams = Static<typeof AgentSpawnParamsSchema>;
|
|
10
|
+
export const AgentInspectParamsSchema = Type.Object({ tools: Type.Optional(Type.Boolean({ description: 'List delegable core and configured extension tools; directory query only' })), models: Type.Optional(Type.Boolean({ description: 'Available model catalog only; exclusive with job/profile/output/wait fields' })), query: Type.Optional(Type.String({ description: 'Case-insensitive provider/id/name search; models:true only' })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: 'Model page size; default 20; models:true only' })), offset: Type.Optional(Type.Integer({ minimum: 0, description: 'Model page offset; default 0; models:true only' })), jobId: Type.Optional(idSchema), profiles: Type.Optional(Type.Union([Type.Boolean(), Type.Literal('summary')], { description: "Omit for jobs only; 'summary' lists profiles without instructions; true returns all profiles with full instructions" })), profile: Type.Optional(Type.String({ minLength: 1, maxLength: 80, description: 'Return this one profile in full, including instructions; cannot be combined with profiles' })), output: Type.Optional(Type.Boolean({ description: 'Collect output or wait for a report; requires jobId. Default false: status only.' })), timeout: Type.Optional(Type.Number({ minimum: 0, maximum: 300 })), until: Type.Optional(Type.Enum(['update', 'finish'] as const, { type: 'string' })), after: Type.Optional(Type.Integer({ minimum: 0, description: 'Wait for reports newer than this reportVersion; default captures the current version at call time' })) }, { additionalProperties: false });
|
|
11
|
+
export type AgentInspectParams = Static<typeof AgentInspectParamsSchema>;
|
|
12
|
+
export const AgentSendParamsSchema = Type.Object({ recipient: Type.Optional(idSchema), requestId: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })), message: text, tools: Type.Optional(Type.Array(Type.String(), { description: 'Replace the tool list with exact names from subagent_inspect tools:true; read is not added implicitly' })), interrupt: Type.Optional(Type.Boolean({ description: 'recipient only: interrupt local turn, discard old queue, and continue the same child with this direction' })) }, { additionalProperties: false });
|
|
13
|
+
export type AgentSendParams = Static<typeof AgentSendParamsSchema>;
|
|
14
|
+
export const AgentCancelParamsSchema = Type.Object({ jobId: idSchema, remove: Type.Optional(Type.Boolean()) }, { additionalProperties: false });
|
|
15
|
+
export type AgentCancelParams = Static<typeof AgentCancelParamsSchema>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { connectFeature } from '../workflow/commands/connect.ts';
|
|
3
|
+
|
|
4
|
+
/** Public Pi resource, selected independently with pi config. */
|
|
5
|
+
export default async function (pi: ExtensionAPI): Promise<void> { await connectFeature(pi, 'agents'); }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const CORE_TOOLS = ["read", "grep", "find", "ls"] as const;
|
|
2
|
+
export const CHILD_TOOLS = [...CORE_TOOLS, 'bash', 'edit', 'write', 'powershell'] as const;
|
|
3
|
+
export const CHILD_TOOLS_ENV = 'PI_DAG_AGENT_TOOLS';
|
|
4
|
+
export const CHILD_CONTROL_ENV = 'PI_DAG_AGENT_CONTROL';
|
|
5
|
+
export const CHILD_CONTROL_PREFIX = 'pi-dag-child-control:';
|
|
6
|
+
export const CHILD_CONTROL_REPLY = 'pi-dag-child-tools:';
|
|
7
|
+
|
|
8
|
+
/** Private RPC pipe markers, not a network endpoint. Loaded explicitly in children only. */
|
|
9
|
+
export const CHILD_NOTICE_PREFIX = "pi-dag-child-message:";
|
|
10
|
+
export const CHILD_QUESTION_PREFIX = "pi-dag-child-question:";
|
|
11
|
+
/** Passive carrier for the selected profile/fragment guidance; never a system prompt section. */
|
|
12
|
+
export const CHILD_GUIDANCE_TYPE = "pi-dag-child.assignment-guidance";
|
|
13
|
+
|
|
14
|
+
export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
15
|
+
export interface ModelRef { provider: string; id: string }
|
|
16
|
+
export interface Profile { name: string; model?: ModelRef; thinking?: ThinkingLevel; tools?: string[]; skills?: string[]; instructions?: string; childExtensions?: string[]; /** opt-in launch isolation; execution semantics are unchanged. */ isolate?: boolean }
|
|
17
|
+
export interface ResolvedProfile { name: string; model: ModelRef; thinking: ThinkingLevel; tools: string[]; skills?: string[]; instructions?: string; childExtensions?: string[]; ignoredInheritedTools?: string[] }
|
|
18
|
+
export type JobStatus = "starting" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "interrupted";
|
|
19
|
+
export interface JobSummary {
|
|
20
|
+
id: string; todoId?: number; profile: string; model: ModelRef; thinking: ThinkingLevel;
|
|
21
|
+
tools: string[]; skills?: string[]; ignoredInheritedTools?: string[]; status: JobStatus; startedAt: number; endedAt?: number; error?: string; pendingRequests: number;
|
|
22
|
+
/** Display-only one-line excerpt of the spawn task; never used for control flow. */
|
|
23
|
+
label?: string;
|
|
24
|
+
/** Terminal jobs stay "pending" until their report is delivered into the parent context. */
|
|
25
|
+
reportDelivery?: "pending" | "delivered";
|
|
26
|
+
/** Terminal, but this attempt produced nothing readable: delivered does not mean it was read. */
|
|
27
|
+
reportEmpty?: true;
|
|
28
|
+
/** The bound task was reopened; retained output belongs to a previous attempt. */
|
|
29
|
+
taskReportStale?: boolean;
|
|
30
|
+
/** UTF-16 offset of current-assignment output; preceding text remains queryable history. */
|
|
31
|
+
outputStart?: number;
|
|
32
|
+
/** Explicitly acknowledged stop; history remains, but no longer owns the current row. */
|
|
33
|
+
dismissed?: boolean;
|
|
34
|
+
resumeFrom?: string;
|
|
35
|
+
failure?: AgentFailure;
|
|
36
|
+
}
|
|
37
|
+
export interface AgentRequest { requestId: string; message: string }
|
|
38
|
+
export interface AgentUsage { requests: number; input: number; output: number; estimatedCost: number }
|
|
39
|
+
export interface JobObservation { phase: 'starting' | 'thinking' | 'tool' | 'output' | 'waiting' | 'settling' | 'running' | 'redirecting' | 'closed'; elapsedMs: number; lastEventAt: number; deadlineAt?: number; reportVersion: number; hasOutput: boolean; pendingMessages: number; activity?: JobActivity; activeTools: { tool: string; since: number }[]; questions: AgentRequest[] }
|
|
40
|
+
export interface JobInspection extends JobSummary, JobObservation {}
|
|
41
|
+
export interface JobResult extends JobSummary { profileInstructions?: string; assignment?: string; taskDefinition?: string; output: string; requests: AgentRequest[]; usage?: AgentUsage; timedOut?: boolean; reportVersion?: number; reason?: 'snapshot' | 'update' | 'question' | 'finished' | 'timeout'; observation?: JobObservation }
|
|
42
|
+
export interface AgentNotice { jobId: string; kind: "message" | "question" | "completed" | "failed"; message: string; requestId?: string; version?: number }
|
|
43
|
+
export interface AgentFailure {
|
|
44
|
+
kind: 'quota' | 'auth' | 'network' | 'timeout' | 'process' | 'unknown';
|
|
45
|
+
reason: string;
|
|
46
|
+
retryable: boolean | null;
|
|
47
|
+
recovery: string;
|
|
48
|
+
}
|
|
49
|
+
export interface JobActivity { kind: "thinking" | "tool" | "output"; tool?: string; since?: number }
|
|
50
|
+
export interface RpcRecord { type: string; id?: string; success?: boolean; error?: string; [key: string]: unknown }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { CHILD_TOOLS } from './contracts.ts';
|
|
4
|
+
|
|
5
|
+
export function agentsIO(pi: ExtensionAPI, namespace: string) {
|
|
6
|
+
async function parentTools() {
|
|
7
|
+
const registered = pi.getAllTools?.() ?? CHILD_TOOLS.map(name => ({ name, description: name, exposure: 'direct', namespace: undefined, sourceInfo: { path: `builtin:${name}` } }));
|
|
8
|
+
return Promise.all(registered.filter(tool => !tool.name.startsWith('subagent_') && tool.namespace?.name !== namespace).map(async tool => {
|
|
9
|
+
const source = tool.sourceInfo?.path;
|
|
10
|
+
const path = source && !source.startsWith('builtin:') && !source.startsWith('<') ? await realpath(source).catch(() => undefined) : undefined;
|
|
11
|
+
return { name: tool.name, description: tool.description, path, delegable: tool.exposure !== 'hidden' && !tool.name.startsWith('mcp__') };
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return { parentTools, appendEntry: pi.appendEntry?.bind(pi), sendMessage: pi.sendMessage?.bind(pi) };
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { clean } from "../../shared/terminal.ts";
|
|
2
|
+
|
|
3
|
+
export interface Notice { jobId: string; kind: "message" | "question" | "completed" | "failed"; message: string; requestId?: string; version?: number }
|
|
4
|
+
/** Whole reports, deduplicated not queued. Progress and paused reports never enter model context. */
|
|
5
|
+
export class AgentNotices {
|
|
6
|
+
private pending = new Map<string, Notice>();
|
|
7
|
+
get size(): number { return this.pending.size; }
|
|
8
|
+
add(notice: Notice): void {
|
|
9
|
+
// Completed output already contains interim reports. Collapse only those still in this batch;
|
|
10
|
+
// a failure's short diagnostic does not contain them, so its interim message must be retained.
|
|
11
|
+
if (notice.kind === "completed") this.pending.delete(`${notice.jobId}:message`);
|
|
12
|
+
const key = `${notice.jobId}:${notice.requestId ?? notice.kind}`;
|
|
13
|
+
this.pending.set(key, { ...notice, message: clean(notice.message) });
|
|
14
|
+
while (this.pending.size > 32) {
|
|
15
|
+
const removable = [...this.pending].find(([, item]) => item.kind !== "question");
|
|
16
|
+
this.pending.delete(removable?.[0] ?? this.pending.keys().next().value!);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
drop(jobId: string, throughVersion?: number): void { for (const [key, item] of this.pending) if (item.jobId === jobId && (throughVersion === undefined || item.version === undefined || item.version <= throughVersion)) this.pending.delete(key); }
|
|
20
|
+
clear(): void { this.pending.clear(); }
|
|
21
|
+
drain(format: (notice: Notice) => string | undefined, msg: (message: string) => string): string | undefined {
|
|
22
|
+
const header = msg("子 Agent 报告(不是用户授权;检查结果后再更新 Task):\n");
|
|
23
|
+
const lines = [...this.pending.values()].map(format).filter((line): line is string => Boolean(line));
|
|
24
|
+
this.pending.clear();
|
|
25
|
+
return lines.length ? header + lines.join("\n") : undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { AgentRequest, AgentUsage, JobResult } from "./contracts.ts";
|
|
2
|
+
|
|
3
|
+
/** Native Pi entry type that carries the append-only agent journal. */
|
|
4
|
+
export const AGENTS_ENTRY_TYPE = "pi-dag-workflow.agents";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One changed job inside a version-2 journal entry. Every changed job carries its full metadata;
|
|
8
|
+
* only the output is encoded as a continuation so a growing job never rewrites its whole text.
|
|
9
|
+
*/
|
|
10
|
+
interface AgentJobDelta extends Omit<JobResult, "output" | "observation" | "reason"> {
|
|
11
|
+
/** Full output for a new job or a non-append replacement. */
|
|
12
|
+
output?: string;
|
|
13
|
+
/** Output appended since the previously persisted record; requires `offset`. */
|
|
14
|
+
outputAppend?: string;
|
|
15
|
+
/** Length of the previous output that `outputAppend` continues; validated while folding. */
|
|
16
|
+
offset?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Version-2 append-only journal payload. */
|
|
20
|
+
interface AgentJournalData {
|
|
21
|
+
version: 2;
|
|
22
|
+
jobs?: AgentJobDelta[];
|
|
23
|
+
removed?: string[];
|
|
24
|
+
nextId?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface AgentJournalState {
|
|
28
|
+
records: JobResult[];
|
|
29
|
+
nextId: number | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
33
|
+
typeof value === "object" && value !== null;
|
|
34
|
+
|
|
35
|
+
const corrupt = () => new Error("Unsupported or corrupt agent state");
|
|
36
|
+
|
|
37
|
+
function nextIdOf(value: unknown): number {
|
|
38
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1) throw new Error("Invalid agent nextId");
|
|
39
|
+
return value as number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sameStrings(a: readonly string[], b: readonly string[]): boolean {
|
|
43
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sameRequests(a: readonly AgentRequest[], b: readonly AgentRequest[]): boolean {
|
|
47
|
+
return a.length === b.length && a.every((value, index) => value.requestId === b[index]!.requestId && value.message === b[index]!.message);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sameUsage(a: AgentUsage | undefined, b: AgentUsage | undefined): boolean {
|
|
51
|
+
if (a === undefined || b === undefined) return a === b;
|
|
52
|
+
return a.requests === b.requests && a.input === b.input && a.output === b.output && a.estimatedCost === b.estimatedCost;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Field-by-field equality; output compares by identity so unchanged jobs cost nothing. */
|
|
56
|
+
function sameRecord(a: JobResult, b: JobResult): boolean {
|
|
57
|
+
if (a.id !== b.id || a.todoId !== b.todoId || a.profile !== b.profile || a.thinking !== b.thinking || a.status !== b.status) return false;
|
|
58
|
+
if (a.startedAt !== b.startedAt || a.endedAt !== b.endedAt || a.error !== b.error || a.pendingRequests !== b.pendingRequests) return false;
|
|
59
|
+
if (a.label !== b.label || a.reportDelivery !== b.reportDelivery || a.taskReportStale !== b.taskReportStale) return false;
|
|
60
|
+
if (a.reportVersion !== b.reportVersion || a.timedOut !== b.timedOut || a.outputStart !== b.outputStart) return false;
|
|
61
|
+
if (a.dismissed !== b.dismissed || a.resumeFrom !== b.resumeFrom || a.assignment !== b.assignment || a.taskDefinition !== b.taskDefinition || a.profileInstructions !== b.profileInstructions) return false;
|
|
62
|
+
if (a.skills === undefined || b.skills === undefined) { if (a.skills !== b.skills) return false; }
|
|
63
|
+
else if (!sameStrings(a.skills, b.skills)) return false;
|
|
64
|
+
if (a.ignoredInheritedTools === undefined || b.ignoredInheritedTools === undefined) { if (a.ignoredInheritedTools !== b.ignoredInheritedTools) return false; }
|
|
65
|
+
else if (!sameStrings(a.ignoredInheritedTools, b.ignoredInheritedTools)) return false;
|
|
66
|
+
if (JSON.stringify(a.failure) !== JSON.stringify(b.failure)) return false;
|
|
67
|
+
if (a.model.provider !== b.model.provider || a.model.id !== b.model.id) return false;
|
|
68
|
+
return a.output === b.output && sameStrings(a.tools, b.tools) && sameRequests(a.requests, b.requests) && sameUsage(a.usage, b.usage);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build a delta. Output stays untouched unless the record is new or the text was replaced. */
|
|
72
|
+
function deltaOf(record: JobResult, previous: JobResult | undefined): AgentJobDelta {
|
|
73
|
+
const { output, observation: _observation, reason: _reason, ...meta } = record;
|
|
74
|
+
const delta: AgentJobDelta = { ...meta };
|
|
75
|
+
if (!previous) { delta.output = output; return delta; }
|
|
76
|
+
// Resume inputs are immutable for an attempt; do not serialize them on every output delta.
|
|
77
|
+
if (record.assignment === previous.assignment) delete delta.assignment;
|
|
78
|
+
if (record.taskDefinition === previous.taskDefinition) delete delta.taskDefinition;
|
|
79
|
+
if (record.profileInstructions === previous.profileInstructions) delete delta.profileInstructions;
|
|
80
|
+
if (output === previous.output) return delta;
|
|
81
|
+
if (output.length >= previous.output.length && output.startsWith(previous.output)) {
|
|
82
|
+
delta.offset = previous.output.length;
|
|
83
|
+
delta.outputAppend = output.slice(previous.output.length);
|
|
84
|
+
return delta;
|
|
85
|
+
}
|
|
86
|
+
delta.output = output;
|
|
87
|
+
return delta;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Apply one decoded delta, rejecting missing bases and mismatched offsets instead of losing data. */
|
|
91
|
+
function applyDelta(records: Map<string, JobResult>, raw: unknown): void {
|
|
92
|
+
if (!isObject(raw) || typeof raw.id !== "string") throw corrupt();
|
|
93
|
+
const { id, output, outputAppend, offset, observation: _observation, reason: _reason, ...meta } = raw;
|
|
94
|
+
const previous = records.get(id);
|
|
95
|
+
const savedInputs = previous ? { ...(previous.assignment === undefined ? {} : { assignment: previous.assignment }), ...(previous.taskDefinition === undefined ? {} : { taskDefinition: previous.taskDefinition }), ...(previous.profileInstructions === undefined ? {} : { profileInstructions: previous.profileInstructions }) } : {};
|
|
96
|
+
if (output !== undefined) {
|
|
97
|
+
if (typeof output !== "string" || outputAppend !== undefined || offset !== undefined) throw corrupt();
|
|
98
|
+
records.set(id, { ...savedInputs, ...meta, id, output } as unknown as JobResult);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (outputAppend !== undefined) {
|
|
102
|
+
if (typeof outputAppend !== "string" || !Number.isSafeInteger(offset) || (offset as number) < 0) throw new Error("Invalid agent journal output offset");
|
|
103
|
+
if (!previous) throw new Error("Agent journal output delta has no base record");
|
|
104
|
+
if ((offset as number) !== previous.output.length) throw new Error("Agent journal output offset mismatch");
|
|
105
|
+
records.set(id, { ...savedInputs, ...meta, id, output: previous.output + outputAppend } as unknown as JobResult);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (offset !== undefined) throw new Error("Invalid agent journal output offset");
|
|
109
|
+
if (!previous) throw new Error("Agent journal record is missing its initial output");
|
|
110
|
+
records.set(id, { ...savedInputs, ...meta, id, output: previous.output } as unknown as JobResult);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Fold version-1 full snapshots and version-2 deltas in branch order into the live record set. */
|
|
114
|
+
export function foldAgentData(payloads: readonly unknown[]): AgentJournalState {
|
|
115
|
+
let records = new Map<string, JobResult>();
|
|
116
|
+
let nextId: number | undefined;
|
|
117
|
+
// A full snapshot supersedes everything before it, including an explicit user reset.
|
|
118
|
+
// Start at the newest one so old redundant snapshots are neither decoded nor resurrected.
|
|
119
|
+
let start = 0;
|
|
120
|
+
for (let index = payloads.length - 1; index >= 0; index--) {
|
|
121
|
+
const payload = payloads[index];
|
|
122
|
+
if (isObject(payload) && payload.version === 1) { start = index; break; }
|
|
123
|
+
}
|
|
124
|
+
for (let index = start; index < payloads.length; index++) {
|
|
125
|
+
const raw = payloads[index];
|
|
126
|
+
if (raw === undefined) throw corrupt();
|
|
127
|
+
if (!isObject(raw)) throw corrupt();
|
|
128
|
+
if (raw.version === 1) {
|
|
129
|
+
if (!Array.isArray(raw.jobs)) throw corrupt();
|
|
130
|
+
const replaced = new Map<string, JobResult>();
|
|
131
|
+
for (const job of raw.jobs) {
|
|
132
|
+
if (!isObject(job) || typeof job.id !== "string" || job.output !== undefined && typeof job.output !== "string" || replaced.has(job.id)) throw corrupt();
|
|
133
|
+
replaced.set(job.id, { ...job, output: job.output ?? '', requests: Array.isArray(job.requests) ? job.requests : [] } as unknown as JobResult);
|
|
134
|
+
}
|
|
135
|
+
records = replaced;
|
|
136
|
+
nextId = raw.nextId === undefined ? undefined : nextIdOf(raw.nextId);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (raw.version === 2) {
|
|
140
|
+
if (raw.jobs !== undefined && !Array.isArray(raw.jobs)) throw corrupt();
|
|
141
|
+
if (raw.removed !== undefined && (!Array.isArray(raw.removed) || raw.removed.some((id) => typeof id !== "string"))) throw corrupt();
|
|
142
|
+
for (const id of (raw.removed ?? []) as string[]) records.delete(id);
|
|
143
|
+
for (const delta of (raw.jobs ?? []) as unknown[]) applyDelta(records, delta);
|
|
144
|
+
if (raw.nextId !== undefined) {
|
|
145
|
+
const candidate = nextIdOf(raw.nextId);
|
|
146
|
+
if (nextId !== undefined && candidate < nextId) throw new Error('Agent journal nextId decreased');
|
|
147
|
+
nextId = candidate;
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`Unsupported agent journal version: ${String(raw.version)}`);
|
|
152
|
+
}
|
|
153
|
+
return { records: [...records.values()], nextId };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Fold native branch entries, ignoring every entry that is not the agent journal. */
|
|
157
|
+
export function foldAgentEntries(entries: readonly unknown[]): AgentJournalState {
|
|
158
|
+
const payloads: unknown[] = [];
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (isObject(entry) && entry.type === 'custom' && entry.customType === AGENTS_ENTRY_TYPE) payloads.push(entry.data);
|
|
161
|
+
}
|
|
162
|
+
return foldAgentData(payloads);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Diffs the live records against the last confirmed append. A prepared payload is published with
|
|
167
|
+
* `confirm` only after the session append succeeded, so a failed write is retried next time.
|
|
168
|
+
*/
|
|
169
|
+
export class AgentJournal {
|
|
170
|
+
private confirmed = new Map<string, JobResult>();
|
|
171
|
+
private confirmedNextId = 1;
|
|
172
|
+
private pending: { records: readonly JobResult[]; nextId: number } | undefined;
|
|
173
|
+
|
|
174
|
+
hydrate(records: readonly JobResult[], nextId: number): void {
|
|
175
|
+
this.confirmed = new Map(records.map((record) => [record.id, record]));
|
|
176
|
+
this.confirmedNextId = nextId;
|
|
177
|
+
this.pending = undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
reset(): void {
|
|
181
|
+
this.confirmed.clear();
|
|
182
|
+
this.confirmedNextId = 1;
|
|
183
|
+
this.pending = undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Current confirmed high-water id; useful for assertions and diagnostics. */
|
|
187
|
+
get nextId(): number { return this.confirmedNextId; }
|
|
188
|
+
|
|
189
|
+
prepare(records: readonly JobResult[], nextId: number): AgentJournalData | null {
|
|
190
|
+
const jobs: AgentJobDelta[] = [];
|
|
191
|
+
const present = new Set<string>();
|
|
192
|
+
for (const record of records) {
|
|
193
|
+
present.add(record.id);
|
|
194
|
+
const previous = this.confirmed.get(record.id);
|
|
195
|
+
if (previous && sameRecord(previous, record)) continue;
|
|
196
|
+
jobs.push(deltaOf(record, previous));
|
|
197
|
+
}
|
|
198
|
+
const removed = [...this.confirmed.keys()].filter((id) => !present.has(id));
|
|
199
|
+
if (!jobs.length && !removed.length && nextId === this.confirmedNextId) { this.pending = undefined; return null; }
|
|
200
|
+
this.pending = { records, nextId };
|
|
201
|
+
return { version: 2, jobs, nextId, ...(removed.length ? { removed } : {}) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
confirm(): void {
|
|
205
|
+
if (!this.pending) return;
|
|
206
|
+
this.confirmed = new Map(this.pending.records.map((record) => [record.id, record]));
|
|
207
|
+
this.confirmedNextId = this.pending.nextId;
|
|
208
|
+
this.pending = undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import type { RpcRecord } from './contracts.ts';
|
|
3
|
+
interface PendingCommand { resolve: (record: RpcRecord) => void; reject: (error: Error) => void; cleanup: () => void }
|
|
4
|
+
|
|
5
|
+
/** The resolver lives in shared because team spawns children too; re-exported for existing callers. */
|
|
6
|
+
export { resolvePiCli } from '../../shared/pi-cli.ts';
|
|
7
|
+
|
|
8
|
+
export class RpcPipe {
|
|
9
|
+
readonly child: ChildProcessWithoutNullStreams;
|
|
10
|
+
readonly closed: Promise<void>;
|
|
11
|
+
private pending = new Map<string, PendingCommand>();
|
|
12
|
+
private sequence = 0;
|
|
13
|
+
private buffer = "";
|
|
14
|
+
stderr = "";
|
|
15
|
+
private dead = false;
|
|
16
|
+
private stopping = false;
|
|
17
|
+
private stopPromise: Promise<void> | undefined;
|
|
18
|
+
constructor(cli: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, onRecord: (record: RpcRecord) => void, onFailure: (error: Error) => void, sanitize: (value: string) => string) {
|
|
19
|
+
this.child = spawn(process.execPath, [cli, ...args], { cwd, env, detached: process.platform !== "win32", stdio: "pipe" });
|
|
20
|
+
this.closed = new Promise((resolveClosed) => this.child.once("close", () => resolveClosed()));
|
|
21
|
+
const fail = (error: Error) => {
|
|
22
|
+
if (this.dead) return;
|
|
23
|
+
this.dead = true;
|
|
24
|
+
for (const pending of this.pending.values()) { pending.cleanup(); pending.reject(error); }
|
|
25
|
+
this.pending.clear();
|
|
26
|
+
if (!this.stopping) onFailure(new Error(sanitize(`${error.message}${this.stderr ? `; stderr: ${this.stderr}` : ''}`)));
|
|
27
|
+
};
|
|
28
|
+
this.child.stdout.setEncoding("utf8");
|
|
29
|
+
this.child.stderr.setEncoding("utf8");
|
|
30
|
+
this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-8192); });
|
|
31
|
+
this.child.stdin.on("error", (error) => fail(error));
|
|
32
|
+
this.child.on("error", (error) => fail(error));
|
|
33
|
+
this.child.on("close", (code, signal) => fail(new Error(`Pi exited before completion (${signal ?? code ?? "unknown"})`)));
|
|
34
|
+
this.child.stdout.on("data", (chunk: string) => {
|
|
35
|
+
if (this.dead) return;
|
|
36
|
+
this.buffer += chunk;
|
|
37
|
+
let at: number;
|
|
38
|
+
while ((at = this.buffer.indexOf("\n")) >= 0) {
|
|
39
|
+
const line = this.buffer.slice(0, at).replace(/\r$/, "");
|
|
40
|
+
this.buffer = this.buffer.slice(at + 1);
|
|
41
|
+
if (!line) continue;
|
|
42
|
+
let record: RpcRecord;
|
|
43
|
+
try { record = JSON.parse(line) as RpcRecord; if (!record || typeof record.type !== "string") throw new Error("Invalid record"); }
|
|
44
|
+
catch { fail(new Error("Invalid Pi RPC JSONL record")); return; }
|
|
45
|
+
if (record.type === "response" && record.id) {
|
|
46
|
+
const pending = this.pending.get(record.id);
|
|
47
|
+
if (pending) {
|
|
48
|
+
pending.cleanup(); this.pending.delete(record.id);
|
|
49
|
+
if (record.success) pending.resolve(record); else pending.reject(new Error(String(record.error ?? "Pi command failed")));
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
try { onRecord(record); }
|
|
53
|
+
catch { fail(new Error("Invalid Pi RPC event")); return; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
command(type: string, fields: Record<string, unknown> = {}, signal?: AbortSignal): Promise<RpcRecord> {
|
|
59
|
+
if (signal?.aborted) return Promise.reject(new Error(`Pi ${type} command aborted`));
|
|
60
|
+
if (this.dead || this.stopping) return Promise.reject(new Error("Pi process is closed"));
|
|
61
|
+
if (this.pending.size >= 32) return Promise.reject(new Error("Too many outstanding child commands"));
|
|
62
|
+
const id = `rpc-${++this.sequence}`;
|
|
63
|
+
return new Promise((resolveCommand, reject) => {
|
|
64
|
+
const fail = (error: Error) => { const pending = this.pending.get(id); if (!pending) return; pending.cleanup(); this.pending.delete(id); reject(error); };
|
|
65
|
+
const abort = () => fail(new Error(`Pi ${type} command aborted`));
|
|
66
|
+
const timer = setTimeout(() => fail(new Error(`Pi ${type} command timed out`)), 15000);
|
|
67
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
|
|
68
|
+
this.pending.set(id, { resolve: resolveCommand, reject, cleanup });
|
|
69
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
70
|
+
this.write({ type, ...fields, id }).catch(fail);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
async write(record: Record<string, unknown>): Promise<void> {
|
|
74
|
+
if (this.dead || this.stopping) throw new Error("Pi process is closed");
|
|
75
|
+
await new Promise<void>((resolveWrite, reject) => this.child.stdin.write(`${JSON.stringify(record)}\n`, (error) => error ? reject(error) : resolveWrite()));
|
|
76
|
+
}
|
|
77
|
+
stop(): Promise<void> {
|
|
78
|
+
if (this.stopPromise) return this.stopPromise;
|
|
79
|
+
this.stopping = true;
|
|
80
|
+
this.stopPromise = (async () => {
|
|
81
|
+
for (const pending of this.pending.values()) { pending.cleanup(); pending.reject(new Error("Pi process stopped")); }
|
|
82
|
+
this.pending.clear();
|
|
83
|
+
// Each Unix child owns a new process group. Kill that group, including shell tools.
|
|
84
|
+
// On Windows detached groups are not safe to address with negative PIDs.
|
|
85
|
+
const signal = (name: NodeJS.Signals) => {
|
|
86
|
+
if (!this.child.pid) return;
|
|
87
|
+
try { if (process.platform !== "win32") process.kill(-this.child.pid, name); else this.child.kill(name); }
|
|
88
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") this.child.kill(name); }
|
|
89
|
+
};
|
|
90
|
+
this.child.stdin.end();
|
|
91
|
+
signal("SIGTERM");
|
|
92
|
+
const timer = setTimeout(() => signal("SIGKILL"), 1000);
|
|
93
|
+
await this.closed;
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
// A shell descendant can retain the group after the Pi leader exits.
|
|
96
|
+
signal("SIGKILL");
|
|
97
|
+
})();
|
|
98
|
+
return this.stopPromise;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export async function saveProfileText(path: string, source: string): Promise<void> {
|
|
6
|
+
await mkdir(dirname(path), { recursive: true });
|
|
7
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
8
|
+
try {
|
|
9
|
+
await writeFile(temporary, source, { mode: 0o600 });
|
|
10
|
+
await rename(temporary, path);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
const { rm } = await import("node:fs/promises");
|
|
13
|
+
await rm(temporary, { force: true });
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { access, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { extname, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const CHILD_SKILLS_ENV = 'PI_DAG_AGENT_SKILLS';
|
|
7
|
+
/** Explicit file entries only. No discovery, frontmatter loading, scripts or tool activation. */
|
|
8
|
+
export async function skillFiles(paths: readonly string[] | undefined, cwd: string, tools: readonly string[], signal?: AbortSignal): Promise<string[]> {
|
|
9
|
+
if (!paths?.length) return [];
|
|
10
|
+
if (!tools.includes('read')) throw new Error('Profile skills require the read tool; skills cannot add tools');
|
|
11
|
+
if (paths.length > 8) throw new Error('Profile skills accepts at most 8 files');
|
|
12
|
+
const result = new Set<string>();
|
|
13
|
+
for (const [index, path] of paths.entries()) {
|
|
14
|
+
if (signal?.aborted) throw new Error('Skill validation aborted');
|
|
15
|
+
if (typeof path !== 'string' || !path.trim() || path.length > 2048 || /[\p{Cc}\p{Bidi_Control}\u2028\u2029]/u.test(path) || /^(?![a-z]:[\\/])[a-z][\w+.-]*:/i.test(path)) throw new Error(`Invalid local skill path at entry ${index + 1}`);
|
|
16
|
+
const expanded = path.startsWith('~/') || path.startsWith('~\\') ? resolve(homedir(), path.slice(2)) : resolve(cwd, path);
|
|
17
|
+
if (extname(expanded).toLowerCase() !== '.md') throw new Error(`Skill entry ${index + 1} must name a Markdown file, not a directory or package`);
|
|
18
|
+
try {
|
|
19
|
+
const canonical = await realpath(expanded);
|
|
20
|
+
if (extname(canonical).toLowerCase() !== '.md') throw new Error('Expected a Markdown target');
|
|
21
|
+
const info = await stat(canonical);
|
|
22
|
+
if (!info.isFile() || !info.size || info.size > 65536 || canonical.length > 2048 || /[\p{Cc}\p{Bidi_Control}\u2028\u2029]/u.test(canonical)) throw new Error('Expected a nonempty regular file no larger than 64 KiB');
|
|
23
|
+
await access(canonical, constants.R_OK);
|
|
24
|
+
if (signal?.aborted) throw new Error('Skill validation aborted');
|
|
25
|
+
result.add(canonical);
|
|
26
|
+
} catch (cause) {
|
|
27
|
+
// Do not expose paths or host errors for requested resources in failure summaries.
|
|
28
|
+
throw new Error(`Skill entry ${index + 1} is not a readable local Markdown file (1–65536 bytes)${signal?.aborted ? '; validation aborted' : ''}`, { cause });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const selected = [...result];
|
|
32
|
+
if (Buffer.byteLength(JSON.stringify(selected)) > 20000) throw new Error('Selected skill path entries exceed 20 KiB');
|
|
33
|
+
return selected;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The child consumes and removes only the selected entries; it does not inspect their bodies. */
|
|
37
|
+
export function takeSkillFiles(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
38
|
+
const raw = env[CHILD_SKILLS_ENV];
|
|
39
|
+
delete env[CHILD_SKILLS_ENV];
|
|
40
|
+
if (raw === undefined) return [];
|
|
41
|
+
if (Buffer.byteLength(raw) > 20000) throw new Error('Invalid child skill entries');
|
|
42
|
+
let files: unknown;
|
|
43
|
+
try { files = JSON.parse(raw); } catch { throw new Error('Invalid child skill entries'); }
|
|
44
|
+
if (!Array.isArray(files) || files.length > 8 || files.some((file) => typeof file !== 'string' || !file || file.length > 2048 || /[\p{Cc}\p{Bidi_Control}\u2028\u2029]/u.test(file))) throw new Error('Invalid child skill entries');
|
|
45
|
+
return [...new Set(files as string[])];
|
|
46
|
+
}
|