@cr1ms0n/pi-subagent 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +352 -0
- package/LICENSE +21 -0
- package/README.md +543 -0
- package/docs/ARCHITECTURE.md +125 -0
- package/docs/COST-ACCOUNTING.md +66 -0
- package/docs/PLAN.md +325 -0
- package/docs/RELEASING.md +32 -0
- package/docs/ROADMAP.md +252 -0
- package/docs/SECURITY.md +85 -0
- package/docs/UI-OVERHAUL.md +186 -0
- package/docs/UX.md +141 -0
- package/extensions/subagent.ts +1 -0
- package/package.json +58 -0
- package/skills/subagent/SKILL.md +103 -0
- package/src/agents.ts +285 -0
- package/src/backend.ts +146 -0
- package/src/backends/claude.ts +384 -0
- package/src/backends/codex.ts +330 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/pi.ts +94 -0
- package/src/btw.ts +34 -0
- package/src/config.ts +254 -0
- package/src/distill.ts +222 -0
- package/src/extension.ts +1527 -0
- package/src/format.ts +365 -0
- package/src/index.ts +60 -0
- package/src/launch.ts +120 -0
- package/src/maintenance.ts +6 -0
- package/src/model-policy.ts +157 -0
- package/src/notifications.ts +106 -0
- package/src/orchestrator.ts +247 -0
- package/src/output.ts +124 -0
- package/src/persistence.ts +334 -0
- package/src/policy.ts +500 -0
- package/src/process-lock.ts +687 -0
- package/src/protocol.ts +290 -0
- package/src/registry.ts +632 -0
- package/src/runner.ts +850 -0
- package/src/schema.ts +166 -0
- package/src/semaphore.ts +123 -0
- package/src/structured.ts +169 -0
- package/src/transcript.ts +360 -0
- package/src/types.ts +197 -0
- package/src/ui.ts +545 -0
- package/src/usage.ts +274 -0
- package/src/worktree.ts +753 -0
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cr1ms0n/pi-subagent",
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"description": "Community fork of Luke Parke's pi-subagent with explicit model policy and model visibility for Pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Luke Parke",
|
|
8
|
+
"contributors": [
|
|
9
|
+
"Luke Parke (upstream author)",
|
|
10
|
+
"cr1ms0n (fork maintainer)"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://www.npmjs.com/package/@cr1ms0n/pi-subagent",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi",
|
|
16
|
+
"pi-coding-agent",
|
|
17
|
+
"subagent",
|
|
18
|
+
"agents",
|
|
19
|
+
"coding-agent",
|
|
20
|
+
"extension",
|
|
21
|
+
"community-fork"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./src/index.ts",
|
|
25
|
+
"./sdk": "./src/index.ts",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"extensions",
|
|
30
|
+
"src",
|
|
31
|
+
"skills",
|
|
32
|
+
"docs",
|
|
33
|
+
"README.md",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org/"
|
|
40
|
+
},
|
|
41
|
+
"pi": {
|
|
42
|
+
"extensions": [
|
|
43
|
+
"./extensions/subagent.ts"
|
|
44
|
+
],
|
|
45
|
+
"skills": [
|
|
46
|
+
"./skills"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@earendil-works/pi-ai": "*",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
52
|
+
"@earendil-works/pi-tui": "*",
|
|
53
|
+
"typebox": "*"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22.19.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: subagent
|
|
3
|
+
description: Delegate work to isolated child agents with the subagent tool — model and thinking policy, explore/review/general profiles, parallel fanout with synthesis, worktree isolation and the diff/apply/discard loop, background runs, steering, output_schema, context fork, and backend tradeoffs (pi/codex/claude). Use when delegating exploration or implementation, running tasks in parallel, or when a subagent run needs inspecting, steering, or landing.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Subagent
|
|
7
|
+
|
|
8
|
+
Delegate research, parallel exploration, and clean-context implementation to child
|
|
9
|
+
agents. Prefer `subagent` over long in-thread digressions when the work benefits
|
|
10
|
+
from isolation, parallelism, or a fresh context.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- Map a codebase area without bloating the parent context (`profile: "explore"`).
|
|
15
|
+
- Review a diff read-only (`profile: "review"`).
|
|
16
|
+
- Implement behind a worktree and land via apply (`profile: "general"`, `isolation: "worktree"`).
|
|
17
|
+
- Fan out independent questions, optionally with `synthesis` to fold results.
|
|
18
|
+
- Background long work (`async: true`) and collect later with `wait` / `subagent_wait`.
|
|
19
|
+
|
|
20
|
+
## Core calls
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// Examples use placeholders only. Replace these with the exact model selected
|
|
24
|
+
// from the current modelPolicy route; they do not configure a real model.
|
|
25
|
+
const routeModel = "<exact model from current modelPolicy route>";
|
|
26
|
+
|
|
27
|
+
// Single foreground task (default profile: general)
|
|
28
|
+
{ task: "Find call sites of parseConfig", description: "Map parseConfig", model: routeModel }
|
|
29
|
+
|
|
30
|
+
// Parallel read-only explorers (default profile for tasks[]: explore)
|
|
31
|
+
{
|
|
32
|
+
tasks: [
|
|
33
|
+
{ task: "Map auth middleware", description: "Auth flow", model: routeModel },
|
|
34
|
+
{ task: "List env vars in server/", description: "Env inventory", model: routeModel }
|
|
35
|
+
],
|
|
36
|
+
synthesis: "Merge into one prioritized brief"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Background — notified on completion; wait/status still work
|
|
40
|
+
{ task: "Audit dependency licenses", model: routeModel, async: true }
|
|
41
|
+
{ action: "status", id: "abc123" }
|
|
42
|
+
{ action: "wait", id: "abc123" } // interruptible; does not cancel
|
|
43
|
+
{ action: "cancel", id: "abc123" }
|
|
44
|
+
// Same wait semantics as a dedicated tool:
|
|
45
|
+
// subagent_wait { id: "abc123", timeout_ms?: number }
|
|
46
|
+
|
|
47
|
+
// Worktree loop
|
|
48
|
+
{ task: "Implement feature A", model: routeModel, profile: "general", isolation: "worktree" }
|
|
49
|
+
{ action: "diff", id: "abc123", index: 1 }
|
|
50
|
+
{ action: "apply", id: "abc123", index: 1 }
|
|
51
|
+
{ action: "discard", id: "abc123", index: 1 }
|
|
52
|
+
|
|
53
|
+
// Dry-run validation + resolved plan (no spawn)
|
|
54
|
+
// plan is a dry-run, but every task still needs the policy-routed model.
|
|
55
|
+
{ action: "plan", tasks: [{ task: "…", model: routeModel, isolation: "worktree" }] }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Profiles
|
|
59
|
+
|
|
60
|
+
| Profile | Tools | Writes |
|
|
61
|
+
| --------- | ---------------------- | ------------------------------------------- |
|
|
62
|
+
| `explore` | read/search/ls (+safe) | no |
|
|
63
|
+
| `review` | same as explore | no |
|
|
64
|
+
| `general` | inherited active tools | yes if tools include bash/edit/write |
|
|
65
|
+
|
|
66
|
+
Parallel write-capable tasks sharing one checkout are rejected unless each uses
|
|
67
|
+
`isolation: "worktree"`, a distinct `cwd`, or `allow_shared_writes: true`.
|
|
68
|
+
|
|
69
|
+
## Backends
|
|
70
|
+
|
|
71
|
+
`backend: "pi" | "codex" | "claude"` (default `pi`). Unsupported combinations are
|
|
72
|
+
**refused**, not silently degraded:
|
|
73
|
+
|
|
74
|
+
| | pi | codex | claude |
|
|
75
|
+
| ------------------------ | -------------- | ---------------------- | -------------- |
|
|
76
|
+
| `max_cost` | yes | refused (tokens only) | yes |
|
|
77
|
+
| read-only profile | tool allowlist | OS sandbox | tool allowlist |
|
|
78
|
+
| steering / grace wrap-up | yes | no | no |
|
|
79
|
+
| `context: "fork"` | yes | refused | yes |
|
|
80
|
+
| `thinking` | yes | no | no |
|
|
81
|
+
| `output_schema` | yes | yes | yes |
|
|
82
|
+
|
|
83
|
+
## Budgets and safety
|
|
84
|
+
|
|
85
|
+
- Prefer `max_turns`, `max_cost`, and/or `timeout_ms` on long or write-capable runs.
|
|
86
|
+
- `output_schema` asks the child for a fenced `json:result` block (one repair round).
|
|
87
|
+
- `context: "fork"` continues from a fork of the parent session (pi/claude).
|
|
88
|
+
- Do not poll `status` in a tight loop — use `wait` / `subagent_wait`, or let the
|
|
89
|
+
completion notification arrive for `async: true` runs.
|
|
90
|
+
- Point the user at `/subagents` for the live inspector and `/subagent-cost` for
|
|
91
|
+
the root / subagent / combined ledger.
|
|
92
|
+
|
|
93
|
+
## Model policy
|
|
94
|
+
|
|
95
|
+
Every new task must pass a `model` that exactly matches the current
|
|
96
|
+
`modelPolicy` mapping in `~/.pi/subagent.json`; agent frontmatter,
|
|
97
|
+
`taskDefaults.model`, and parent-session model inheritance are ignored. An
|
|
98
|
+
agent route replaces the default route, and configured fallback order is
|
|
99
|
+
immutable. Omit `fallback_models` to use the route; if supplied, it must match
|
|
100
|
+
exactly. Management actions do not require model. The extension re-reads this
|
|
101
|
+
policy on each dispatch and injects it into the parent prompt. If the policy is
|
|
102
|
+
missing or invalid, management remains available but new spawns and synthesis
|
|
103
|
+
are rejected.
|
package/src/agents.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { TaskProfile } from "./types.js";
|
|
5
|
+
import type { ThinkingLevel } from "./config.js";
|
|
6
|
+
import { isPlausibleSchema } from "./structured.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Named agent files: reusable subagent personas discovered from the same
|
|
10
|
+
* conventional locations the ecosystem uses for skills.
|
|
11
|
+
*
|
|
12
|
+
* Discovery roots (highest precedence first — same name in a higher root wins):
|
|
13
|
+
* 1. <cwd>/.pi/agents/<name>.md project (authoritative)
|
|
14
|
+
* 2. <cwd>/.agents/agents/<name>.md shared cross-tool workspace
|
|
15
|
+
* 3. $PI_CODING_AGENT_DIR/agents/<name>.md global (default ~/.pi/agent/agents/)
|
|
16
|
+
*
|
|
17
|
+
* Format: YAML frontmatter + markdown body. The body becomes the child's
|
|
18
|
+
* appended system prompt. Frontmatter fields use the same snake_case names as
|
|
19
|
+
* the tool parameters they default.
|
|
20
|
+
*
|
|
21
|
+
* Model routing is intentionally excluded from agent files. Agent frontmatter
|
|
22
|
+
* may still contain legacy model/fallback fields for compatibility, but the
|
|
23
|
+
* model policy is the only source used for new spawns.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
27
|
+
const PROFILES = ["explore", "review", "general"] as const;
|
|
28
|
+
|
|
29
|
+
export interface AgentDefinition {
|
|
30
|
+
/** Agent CLI backend this persona runs on (pi | codex | claude). */
|
|
31
|
+
backend?: "pi" | "codex" | "claude";
|
|
32
|
+
/** Agent name (the file name without extension). */
|
|
33
|
+
name: string;
|
|
34
|
+
/** One-line routing description shown to the orchestrating model. */
|
|
35
|
+
description: string;
|
|
36
|
+
/** Markdown body — appended to the child's system prompt. */
|
|
37
|
+
systemPrompt?: string;
|
|
38
|
+
/** File the definition was loaded from. */
|
|
39
|
+
source: string;
|
|
40
|
+
/** Which discovery root supplied it. */
|
|
41
|
+
scope: "project" | "shared" | "global";
|
|
42
|
+
model?: string;
|
|
43
|
+
thinking?: ThinkingLevel;
|
|
44
|
+
profile?: TaskProfile;
|
|
45
|
+
tools?: string[];
|
|
46
|
+
maxTurns?: number;
|
|
47
|
+
maxCost?: number;
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
graceTurns?: number;
|
|
50
|
+
fallbackModels?: string[];
|
|
51
|
+
maxRetries?: number;
|
|
52
|
+
isolation?: "shared" | "worktree";
|
|
53
|
+
/** JSON Schema the persona's final result must satisfy (inline JSON or @file.json). */
|
|
54
|
+
outputSchema?: Record<string, unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* Which agents this persona may spawn as children.
|
|
57
|
+
* `false` disables nesting; `"*"` unrestricted; string[] is an allowlist.
|
|
58
|
+
* Absent means unrestricted (same as `"*"`).
|
|
59
|
+
*/
|
|
60
|
+
spawns?: false | "*" | string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Agent names are file-name-safe identifiers; anything else is skipped. */
|
|
64
|
+
|
|
65
|
+
const MAX_AGENT_FILE_BYTES = 64 * 1024;
|
|
66
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
67
|
+
|
|
68
|
+
function agentDir(): string {
|
|
69
|
+
const envDir = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
70
|
+
if (envDir) {
|
|
71
|
+
return envDir.startsWith("~") ? path.join(os.homedir(), envDir.slice(1)) : envDir;
|
|
72
|
+
}
|
|
73
|
+
return path.join(os.homedir(), ".pi", "agent");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function discoveryRoots(cwd: string): Array<{ dir: string; scope: AgentDefinition["scope"] }> {
|
|
77
|
+
return [
|
|
78
|
+
{ dir: path.join(cwd, ".pi", "agents"), scope: "project" },
|
|
79
|
+
{ dir: path.join(cwd, ".agents", "agents"), scope: "shared" },
|
|
80
|
+
{ dir: path.join(agentDir(), "agents"), scope: "global" },
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Frontmatter parsing (flat YAML subset; no dependency) ───────────────────
|
|
85
|
+
|
|
86
|
+
function stripQuotes(value: string): string {
|
|
87
|
+
const trimmed = value.trim();
|
|
88
|
+
if (trimmed.length >= 2 && ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
|
89
|
+
return trimmed.slice(1, -1);
|
|
90
|
+
}
|
|
91
|
+
return trimmed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `[a, b]` or `a, b` → string array. */
|
|
95
|
+
function parseList(value: string): string[] {
|
|
96
|
+
const inner = value.trim().startsWith("[") && value.trim().endsWith("]")
|
|
97
|
+
? value.trim().slice(1, -1)
|
|
98
|
+
: value;
|
|
99
|
+
return inner.split(",").map((item) => stripQuotes(item)).filter(Boolean);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseNumber(value: string): number | undefined {
|
|
103
|
+
const parsed = Number(value.trim());
|
|
104
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Frontmatter `spawns:` — false | * | list. Invalid forms degrade to unrestricted (absent). */
|
|
108
|
+
function parseSpawns(value: string): AgentDefinition["spawns"] | undefined {
|
|
109
|
+
const trimmed = stripQuotes(value.trim());
|
|
110
|
+
if (!trimmed) return undefined;
|
|
111
|
+
const lower = trimmed.toLowerCase();
|
|
112
|
+
if (lower === "false" || lower === "off" || lower === "none") return false;
|
|
113
|
+
if (trimmed === "*" || lower === "true" || lower === "any") return "*";
|
|
114
|
+
const list = parseList(trimmed).map((item) => item.toLowerCase()).filter((item) => NAME_PATTERN.test(item));
|
|
115
|
+
// Non-empty list only; garbage becomes unrestricted (same as absent frontmatter).
|
|
116
|
+
return list.length > 0 ? list : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Shared 64KB / regular-file / readable guard for `@path` references.
|
|
121
|
+
* Symlinks and oversized/missing files return undefined (callers degrade open).
|
|
122
|
+
*/
|
|
123
|
+
function readGuardedRelativeFile(agentFile: string, relativePath: string): string | undefined {
|
|
124
|
+
if (!relativePath || relativePath.includes("\0")) return undefined;
|
|
125
|
+
const file = path.resolve(path.dirname(agentFile), relativePath);
|
|
126
|
+
try {
|
|
127
|
+
const stat = fs.lstatSync(file);
|
|
128
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_AGENT_FILE_BYTES) return undefined;
|
|
129
|
+
return fs.readFileSync(file, "utf8");
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `output_schema` value: inline single-line JSON, or `@relative/path.json`. */
|
|
136
|
+
function parseSchemaValue(value: string, agentFile: string): Record<string, unknown> | undefined {
|
|
137
|
+
let text = value.trim();
|
|
138
|
+
if (text.startsWith("@")) {
|
|
139
|
+
const loaded = readGuardedRelativeFile(agentFile, text.slice(1));
|
|
140
|
+
if (loaded === undefined) return undefined;
|
|
141
|
+
text = loaded;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(text);
|
|
145
|
+
return isPlausibleSchema(parsed) ? parsed : undefined;
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Expand one-level `@include relative/path.md` body lines; missing/bad refs stay verbatim. */
|
|
152
|
+
function expandIncludes(body: string, agentFile: string): string {
|
|
153
|
+
const lines = body.split(/\r?\n/);
|
|
154
|
+
let changed = false;
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
const match = /^\s*@include\s+(\S+)\s*$/.exec(lines[i]!);
|
|
157
|
+
if (!match) continue;
|
|
158
|
+
const loaded = readGuardedRelativeFile(agentFile, match[1]!);
|
|
159
|
+
if (loaded === undefined) continue;
|
|
160
|
+
lines[i] = loaded.replace(/\r?\n$/, "");
|
|
161
|
+
changed = true;
|
|
162
|
+
}
|
|
163
|
+
return changed ? lines.join("\n") : body;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function parseAgentFile(name: string, raw: string, source: string, scope: AgentDefinition["scope"]): AgentDefinition | undefined {
|
|
167
|
+
let frontmatter: Record<string, string> = {};
|
|
168
|
+
let body = raw;
|
|
169
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
|
|
170
|
+
if (match) {
|
|
171
|
+
body = raw.slice(match[0].length);
|
|
172
|
+
for (const line of match[1]!.split(/\r?\n/)) {
|
|
173
|
+
const separator = line.indexOf(":");
|
|
174
|
+
if (separator <= 0 || /^\s*#/.test(line)) continue;
|
|
175
|
+
const key = line.slice(0, separator).trim();
|
|
176
|
+
const value = line.slice(separator + 1).trim();
|
|
177
|
+
if (key && value) frontmatter[key] = value;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const thinking = frontmatter.thinking && (THINKING_LEVELS as readonly string[]).includes(frontmatter.thinking)
|
|
182
|
+
? (frontmatter.thinking as ThinkingLevel)
|
|
183
|
+
: undefined;
|
|
184
|
+
const profile = frontmatter.profile && (PROFILES as readonly string[]).includes(frontmatter.profile)
|
|
185
|
+
? (frontmatter.profile as TaskProfile)
|
|
186
|
+
: undefined;
|
|
187
|
+
const isolation = frontmatter.isolation === "worktree" ? "worktree" as const
|
|
188
|
+
: frontmatter.isolation === "shared" ? "shared" as const
|
|
189
|
+
: undefined;
|
|
190
|
+
const spawns = frontmatter.spawns !== undefined ? parseSpawns(frontmatter.spawns) : undefined;
|
|
191
|
+
const backend = frontmatter.backend === "codex" ? "codex" as const
|
|
192
|
+
: frontmatter.backend === "claude" ? "claude" as const
|
|
193
|
+
: frontmatter.backend === "pi" ? "pi" as const
|
|
194
|
+
: undefined;
|
|
195
|
+
|
|
196
|
+
const expanded = expandIncludes(body, source);
|
|
197
|
+
const systemPrompt = expanded.trim() || undefined;
|
|
198
|
+
const definition: AgentDefinition = {
|
|
199
|
+
name,
|
|
200
|
+
description: stripQuotes(frontmatter.description ?? "").slice(0, 200) || name,
|
|
201
|
+
systemPrompt,
|
|
202
|
+
source,
|
|
203
|
+
scope,
|
|
204
|
+
model: frontmatter.model ? stripQuotes(frontmatter.model) : undefined,
|
|
205
|
+
thinking,
|
|
206
|
+
profile,
|
|
207
|
+
tools: frontmatter.tools ? parseList(frontmatter.tools) : undefined,
|
|
208
|
+
maxTurns: frontmatter.max_turns ? parseNumber(frontmatter.max_turns) : undefined,
|
|
209
|
+
maxCost: frontmatter.max_cost ? parseNumber(frontmatter.max_cost) : undefined,
|
|
210
|
+
timeoutMs: frontmatter.timeout_ms ? parseNumber(frontmatter.timeout_ms) : undefined,
|
|
211
|
+
graceTurns: frontmatter.grace_turns ? parseNumber(frontmatter.grace_turns) : undefined,
|
|
212
|
+
fallbackModels: frontmatter.fallback_models ? parseList(frontmatter.fallback_models) : undefined,
|
|
213
|
+
maxRetries: frontmatter.max_retries ? parseNumber(frontmatter.max_retries) : undefined,
|
|
214
|
+
isolation,
|
|
215
|
+
backend,
|
|
216
|
+
outputSchema: frontmatter.output_schema ? parseSchemaValue(frontmatter.output_schema, source) : undefined,
|
|
217
|
+
spawns,
|
|
218
|
+
};
|
|
219
|
+
return definition;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Discovery ────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Discover agent definitions across the conventional roots. Synchronous by
|
|
226
|
+
* design: it runs at session start and on tool execute, reads a handful of
|
|
227
|
+
* small files, and failure of any root is silent (missing dirs are normal).
|
|
228
|
+
* Symlinked agent files are skipped (matching skill-loading conservatism).
|
|
229
|
+
*/
|
|
230
|
+
export function discoverAgents(cwd: string): Map<string, AgentDefinition> {
|
|
231
|
+
const catalog = new Map<string, AgentDefinition>();
|
|
232
|
+
// Iterate lowest precedence first so higher roots overwrite.
|
|
233
|
+
for (const root of [...discoveryRoots(cwd)].reverse()) {
|
|
234
|
+
let entries: fs.Dirent[];
|
|
235
|
+
try {
|
|
236
|
+
entries = fs.readdirSync(root.dir, { withFileTypes: true });
|
|
237
|
+
} catch {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".md")) continue;
|
|
242
|
+
const name = entry.name.slice(0, -3);
|
|
243
|
+
if (!NAME_PATTERN.test(name)) continue;
|
|
244
|
+
const file = path.join(root.dir, entry.name);
|
|
245
|
+
try {
|
|
246
|
+
const stat = fs.lstatSync(file);
|
|
247
|
+
if (!stat.isFile() || stat.size > MAX_AGENT_FILE_BYTES) continue;
|
|
248
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
249
|
+
const definition = parseAgentFile(name.toLowerCase(), raw, file, root.scope);
|
|
250
|
+
if (definition) catalog.set(definition.name, definition);
|
|
251
|
+
} catch {
|
|
252
|
+
/* unreadable file: skip */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return catalog;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Case-insensitive lookup with a helpful error listing available names. */
|
|
260
|
+
export function resolveAgent(
|
|
261
|
+
catalog: Map<string, AgentDefinition>,
|
|
262
|
+
name: string,
|
|
263
|
+
): { agent?: AgentDefinition; error?: string } {
|
|
264
|
+
const agent = catalog.get(name.toLowerCase());
|
|
265
|
+
if (agent) return { agent };
|
|
266
|
+
const available = [...catalog.keys()].sort();
|
|
267
|
+
return {
|
|
268
|
+
error: available.length
|
|
269
|
+
? `Unknown agent "${name}". Available agents: ${available.join(", ")}`
|
|
270
|
+
: `Unknown agent "${name}". No agent files found (define them in .pi/agents/<name>.md).`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** One line per agent for the tool guidelines / status output. */
|
|
275
|
+
export function describeCatalog(catalog: Map<string, AgentDefinition>): string[] {
|
|
276
|
+
return [...catalog.values()]
|
|
277
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
278
|
+
.map((agent) => {
|
|
279
|
+
const traits = [
|
|
280
|
+
agent.profile,
|
|
281
|
+
agent.isolation === "worktree" ? "worktree" : "",
|
|
282
|
+
].filter(Boolean).join(", ");
|
|
283
|
+
return `${agent.name}: ${agent.description}${traits ? ` (${traits})` : ""}`;
|
|
284
|
+
});
|
|
285
|
+
}
|
package/src/backend.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend adapter seam.
|
|
3
|
+
*
|
|
4
|
+
* `ChildRunner` owns everything that is *not* agent-specific: semaphore and
|
|
5
|
+
* global slot acquisition, worktree-prepared cwd, timeouts, budget wrap-up,
|
|
6
|
+
* the stall watchdog, group-kill/PID-identity safety, structured-output
|
|
7
|
+
* validation and repair, checkpointing and persistence.
|
|
8
|
+
*
|
|
9
|
+
* Only four things actually vary per agent CLI, and they live here:
|
|
10
|
+
*
|
|
11
|
+
* 1. `buildInvocation` — how to turn a TaskSpec into command + argv (+ any
|
|
12
|
+
* temp files that must be cleaned up afterwards).
|
|
13
|
+
* 2. `createParser` — how to turn that process's stdout into our
|
|
14
|
+
* normalized `ProtocolUpdate` stream and a final `TaskResult`.
|
|
15
|
+
* 3. `steerCommand` / `stopCommand` — what to write on stdin to inject a
|
|
16
|
+
* message or ask for a graceful stop (undefined = unsupported).
|
|
17
|
+
* 4. `capabilities` — which features the backend can actually honor, so
|
|
18
|
+
* unsupported requests are *refused* rather than silently ignored.
|
|
19
|
+
*
|
|
20
|
+
* Capability honesty is the important part. A backend that cannot report
|
|
21
|
+
* per-turn cost cannot enforce `max_cost`; pretending otherwise would let a
|
|
22
|
+
* runaway child spend without a ceiling. Policy validation rejects such
|
|
23
|
+
* combinations up front (see `assertCapabilities`).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ProtocolUpdate } from "./protocol.js";
|
|
27
|
+
import type { TaskResult, TaskSpec } from "./types.js";
|
|
28
|
+
|
|
29
|
+
/** Normalized event-stream parser contract, implemented per backend. */
|
|
30
|
+
export interface BackendParser {
|
|
31
|
+
/** Consume a stdout chunk, yielding zero or more normalized updates. */
|
|
32
|
+
feed(data: Buffer | string): ProtocolUpdate[];
|
|
33
|
+
/** Flush any buffered partial line at stream end. */
|
|
34
|
+
flush(): ProtocolUpdate[];
|
|
35
|
+
/** Build the terminal TaskResult from exit status. */
|
|
36
|
+
finalize(exitCode: number | null, signal?: NodeJS.Signals, stderr?: string): TaskResult;
|
|
37
|
+
/** Transcript for checkpointing, if the backend can produce one. */
|
|
38
|
+
getTranscript(): string | undefined;
|
|
39
|
+
/** Text of the in-flight assistant message. */
|
|
40
|
+
getLiveText(): string;
|
|
41
|
+
/** Completed messages so far. */
|
|
42
|
+
getMessages(): import("@earendil-works/pi-ai").Message[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BackendInvocation {
|
|
46
|
+
command: string;
|
|
47
|
+
args: string[];
|
|
48
|
+
/** Extra env for the child, merged over the inherited environment. */
|
|
49
|
+
env?: Record<string, string>;
|
|
50
|
+
/** Directories to remove once the child exits (temp prompt files, etc.). */
|
|
51
|
+
cleanupDirs?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a backend can actually do. Anything false is refused at validation
|
|
56
|
+
* time with an explanatory error rather than silently degraded.
|
|
57
|
+
*/
|
|
58
|
+
export interface BackendCapabilities {
|
|
59
|
+
/** Mid-run steering via a stdin command channel. */
|
|
60
|
+
steer: boolean;
|
|
61
|
+
/** Graceful budget wrap-up (needs steering to ask for a summary). */
|
|
62
|
+
gracefulWrapUp: boolean;
|
|
63
|
+
/** Per-turn provider usage/cost reporting — required for max_cost. */
|
|
64
|
+
costReporting: boolean;
|
|
65
|
+
/** Resuming a previous child session. */
|
|
66
|
+
resume: boolean;
|
|
67
|
+
/** Forking a session (context:'fork' / fork_resume). */
|
|
68
|
+
fork: boolean;
|
|
69
|
+
/** Restricting the child's tool set (profiles: explore/review). */
|
|
70
|
+
toolRestriction: boolean;
|
|
71
|
+
/** Reasoning-effort control. */
|
|
72
|
+
thinking: boolean;
|
|
73
|
+
/** Structured output via an appended schema contract. */
|
|
74
|
+
outputSchema: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BackendAdapter {
|
|
78
|
+
readonly name: BackendName;
|
|
79
|
+
readonly capabilities: BackendCapabilities;
|
|
80
|
+
/**
|
|
81
|
+
* Build the child invocation. May write temp files; return their parent
|
|
82
|
+
* directories in `cleanupDirs` so the runner removes them on exit.
|
|
83
|
+
*/
|
|
84
|
+
buildInvocation(spec: TaskSpec, context: BackendLaunchContext): Promise<BackendInvocation>;
|
|
85
|
+
createParser(): BackendParser;
|
|
86
|
+
/** stdin payload that injects a message mid-run, or undefined if unsupported. */
|
|
87
|
+
steerCommand?(message: string): unknown;
|
|
88
|
+
/** stdin payload requesting a graceful stop, or undefined if unsupported. */
|
|
89
|
+
stopCommand?(): unknown;
|
|
90
|
+
/** stdin payload answering an interactive UI request (headless auto-cancel). */
|
|
91
|
+
uiCancelCommand?(id: string): unknown;
|
|
92
|
+
/** stdin payload asking for session state (used to learn the session id). */
|
|
93
|
+
stateCommand?(): unknown;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface BackendLaunchContext {
|
|
97
|
+
/** Directory child sessions are written to. */
|
|
98
|
+
sessionDir: string;
|
|
99
|
+
/** Resolves the pi command/argv (pi backend only). */
|
|
100
|
+
getPiCommand: (args: string[]) => { command: string; args: string[] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type BackendName = "pi" | "codex" | "claude";
|
|
104
|
+
|
|
105
|
+
export const BACKEND_NAMES: readonly BackendName[] = ["pi", "codex", "claude"];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reject requests a backend cannot honor. Returns a list of human-readable
|
|
109
|
+
* problems; empty means the spec is satisfiable.
|
|
110
|
+
*
|
|
111
|
+
* This is deliberately strict: silently dropping `max_cost` or a read-only
|
|
112
|
+
* profile would turn a safety feature into a no-op.
|
|
113
|
+
*/
|
|
114
|
+
export function checkCapabilities(
|
|
115
|
+
spec: Partial<
|
|
116
|
+
Pick<
|
|
117
|
+
TaskSpec,
|
|
118
|
+
"maxCost" | "resume" | "forkResume" | "contextFork" | "tools" | "thinking" | "outputSchema" | "profile" | "canWrite"
|
|
119
|
+
>
|
|
120
|
+
>,
|
|
121
|
+
capabilities: BackendCapabilities,
|
|
122
|
+
backend: BackendName,
|
|
123
|
+
): string[] {
|
|
124
|
+
const problems: string[] = [];
|
|
125
|
+
if (spec.maxCost !== undefined && !capabilities.costReporting) {
|
|
126
|
+
problems.push(
|
|
127
|
+
`backend '${backend}' does not report per-turn cost, so max_cost cannot be enforced; drop max_cost or use max_turns/timeout_ms instead`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (spec.resume && !capabilities.resume) {
|
|
131
|
+
problems.push(`backend '${backend}' cannot resume child sessions; drop resume`);
|
|
132
|
+
}
|
|
133
|
+
if ((spec.forkResume || spec.contextFork) && !capabilities.fork) {
|
|
134
|
+
problems.push(`backend '${backend}' cannot fork sessions; drop fork_resume / context:'fork'`);
|
|
135
|
+
}
|
|
136
|
+
// A read-only profile that cannot be enforced is a write-safety hole.
|
|
137
|
+
if (spec.tools !== undefined && !capabilities.toolRestriction) {
|
|
138
|
+
problems.push(
|
|
139
|
+
`backend '${backend}' cannot restrict the child's tools, so profile '${spec.profile ?? "explore"}' cannot be enforced; use profile:'general' with an explicitly writable backend, or the pi backend`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (spec.outputSchema && !capabilities.outputSchema) {
|
|
143
|
+
problems.push(`backend '${backend}' does not support output_schema; drop it`);
|
|
144
|
+
}
|
|
145
|
+
return problems;
|
|
146
|
+
}
|