@tt-a1i/openpi 0.1.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/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
fitNavigationSides,
|
|
5
|
+
type BelowEditorStripState,
|
|
6
|
+
} from "../shared/below-editor-navigation.ts";
|
|
7
|
+
import {
|
|
8
|
+
unreadActivityCounts,
|
|
9
|
+
type ActivityCounts,
|
|
10
|
+
} from "../shared/activity-status.ts";
|
|
11
|
+
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
|
|
12
|
+
import { formatElapsed, type SubagentSnapshot } from "./src/domain.ts";
|
|
13
|
+
import { formatContextUtilization } from "./src/format.ts";
|
|
14
|
+
|
|
15
|
+
export interface SubagentStripEntry {
|
|
16
|
+
snapshot: SubagentSnapshot;
|
|
17
|
+
counts: ActivityCounts;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cleanLine(value: string) {
|
|
21
|
+
return sanitizeTerminalText(value).replace(/\s+/g, " ").trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Normalize every title before it enters snapshots, artifacts, or the TUI. */
|
|
25
|
+
export function normalizeSubagentTitle(value: string, fallback = "subagent") {
|
|
26
|
+
return cleanLine(value).slice(0, 160) || fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Prefer the newest running child, then the newest unread settled child. */
|
|
30
|
+
export function selectSubagentStripEntry(
|
|
31
|
+
snapshots: readonly SubagentSnapshot[],
|
|
32
|
+
acknowledgedAt: number,
|
|
33
|
+
): SubagentStripEntry | undefined {
|
|
34
|
+
const counts = unreadActivityCounts(snapshots, acknowledgedAt);
|
|
35
|
+
const visible = snapshots.filter(
|
|
36
|
+
(snapshot) =>
|
|
37
|
+
snapshot.status === "running" ||
|
|
38
|
+
snapshot.settledAt === undefined ||
|
|
39
|
+
snapshot.settledAt >= acknowledgedAt,
|
|
40
|
+
);
|
|
41
|
+
const candidates = visible.some((snapshot) => snapshot.status === "running")
|
|
42
|
+
? visible.filter((snapshot) => snapshot.status === "running")
|
|
43
|
+
: visible;
|
|
44
|
+
let selected: SubagentSnapshot | undefined;
|
|
45
|
+
for (const snapshot of candidates) {
|
|
46
|
+
const timestamp = snapshot.settledAt ?? snapshot.createdAt;
|
|
47
|
+
const selectedTimestamp = selected
|
|
48
|
+
? (selected.settledAt ?? selected.createdAt)
|
|
49
|
+
: -Infinity;
|
|
50
|
+
if (timestamp >= selectedTimestamp) selected = snapshot;
|
|
51
|
+
}
|
|
52
|
+
return selected ? { snapshot: selected, counts } : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function statusColor(status: SubagentSnapshot["status"]) {
|
|
56
|
+
if (status === "running") return "warning" as const;
|
|
57
|
+
if (status === "done") return "success" as const;
|
|
58
|
+
return "error" as const;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function statusSquare(snapshot: SubagentSnapshot, theme: Theme) {
|
|
62
|
+
return theme.fg(statusColor(snapshot.status), "■");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One-line subagent manager entry with the same affordance as Workflow. */
|
|
66
|
+
export class SubagentStripWidget {
|
|
67
|
+
private readonly timer: ReturnType<typeof setInterval>;
|
|
68
|
+
private readonly tui: TUI;
|
|
69
|
+
private readonly theme: Theme;
|
|
70
|
+
private readonly strip: BelowEditorStripState;
|
|
71
|
+
private readonly getEntry: () => SubagentStripEntry | undefined;
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
tui: TUI,
|
|
75
|
+
theme: Theme,
|
|
76
|
+
strip: BelowEditorStripState,
|
|
77
|
+
getEntry: () => SubagentStripEntry | undefined,
|
|
78
|
+
) {
|
|
79
|
+
this.tui = tui;
|
|
80
|
+
this.theme = theme;
|
|
81
|
+
this.strip = strip;
|
|
82
|
+
this.getEntry = getEntry;
|
|
83
|
+
this.timer = setInterval(() => this.tui.requestRender(), 500);
|
|
84
|
+
this.timer.unref?.();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
dispose() {
|
|
88
|
+
clearInterval(this.timer);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
invalidate() {}
|
|
92
|
+
|
|
93
|
+
render(width: number) {
|
|
94
|
+
const entry = this.getEntry();
|
|
95
|
+
if (!entry || width <= 0) return [];
|
|
96
|
+
const { snapshot, counts } = entry;
|
|
97
|
+
const marker = this.strip.focused
|
|
98
|
+
? this.theme.fg("accent", "❯")
|
|
99
|
+
: this.theme.fg("dim", "○");
|
|
100
|
+
const titleText = normalizeSubagentTitle(snapshot.title, snapshot.id);
|
|
101
|
+
const title = this.strip.focused
|
|
102
|
+
? this.theme.bold(this.theme.fg("accent", titleText))
|
|
103
|
+
: this.theme.fg("text", titleText);
|
|
104
|
+
const model = snapshot.meta.modelLabel
|
|
105
|
+
? cleanLine(snapshot.meta.modelLabel)
|
|
106
|
+
: undefined;
|
|
107
|
+
const left = ` ${marker} ${statusSquare(snapshot, this.theme)} ${title}${model ? this.theme.fg("dim", ` · ${model}`) : ""}`;
|
|
108
|
+
const settled = counts.done + counts.failed;
|
|
109
|
+
const total = counts.running + settled;
|
|
110
|
+
const metrics = [
|
|
111
|
+
`${settled}/${total} agents`,
|
|
112
|
+
formatElapsed(snapshot),
|
|
113
|
+
formatContextUtilization(snapshot.usage),
|
|
114
|
+
this.strip.focused ? "enter open · ↑ back" : "↓ to manage",
|
|
115
|
+
]
|
|
116
|
+
.filter((part): part is string => Boolean(part))
|
|
117
|
+
.join(" · ");
|
|
118
|
+
const right = this.theme.fg(statusColor(snapshot.status), metrics);
|
|
119
|
+
return [fitNavigationSides(left, right, width)];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent types: reusable subagent definitions loaded from `agents/*.md`.
|
|
3
|
+
*
|
|
4
|
+
* A type fixes what a spawned child IS — an optional system-prompt addendum, an
|
|
5
|
+
* optional model/thinking level, and above all an enforced tool allowlist — so
|
|
6
|
+
* "this subagent is read-only" becomes a capability boundary instead of a
|
|
7
|
+
* request the child can ignore.
|
|
8
|
+
*
|
|
9
|
+
* The allowlist can only ever NARROW. Pi composes it with the child denylist as
|
|
10
|
+
* `(!allowed || allowed.has(name)) && !excluded.has(name)`, so naming an
|
|
11
|
+
* excluded tool here cannot resurrect it (see `../../shared/child-session.ts`).
|
|
12
|
+
*
|
|
13
|
+
* Discovery is two-tier: `<agentDir>/agents/*.md` always, and `<cwd>/.pi/agents/
|
|
14
|
+
* *.md` only in a trusted project — a project file supplies an
|
|
15
|
+
* attacker-controllable system prompt and tool list, so an untrusted repo must
|
|
16
|
+
* contribute none. Project files win on a name collision.
|
|
17
|
+
*
|
|
18
|
+
* Malformed files never throw: they are skipped and reported as diagnostics, so
|
|
19
|
+
* one bad file cannot take down spawning. Unknown frontmatter keys are malformed
|
|
20
|
+
* too: ignoring a misspelled restriction key could otherwise widen the child.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
26
|
+
import {
|
|
27
|
+
parseFrontmatter,
|
|
28
|
+
type ModelRegistry,
|
|
29
|
+
} from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import {
|
|
31
|
+
CHILD_EXCLUDED_TOOL_NAMES,
|
|
32
|
+
CHILD_SAFE_PACKAGE_TOOL_NAMES,
|
|
33
|
+
} from "../../shared/child-session.ts";
|
|
34
|
+
import { sanitizeTerminalText } from "../../shared/terminal-text.ts";
|
|
35
|
+
import {
|
|
36
|
+
isSubagentRoleName,
|
|
37
|
+
type SubagentRoleModel,
|
|
38
|
+
} from "../../shared/subagent-roles.ts";
|
|
39
|
+
import { REASONING_EFFORTS, type ReasoningEffort } from "./domain.ts";
|
|
40
|
+
|
|
41
|
+
/** Directory name scanned under both the agent dir and a project's `.pi`. */
|
|
42
|
+
export const AGENT_TYPES_DIR_NAME = "agents";
|
|
43
|
+
|
|
44
|
+
/** Bounds mirroring the Agent Skills conventions Pi uses for `SKILL.md`. */
|
|
45
|
+
export const AGENT_TYPE_LIMITS = Object.freeze({
|
|
46
|
+
nameChars: 64,
|
|
47
|
+
descriptionChars: 1024,
|
|
48
|
+
bodyChars: 16_384,
|
|
49
|
+
modelChars: 256,
|
|
50
|
+
toolNameChars: 128,
|
|
51
|
+
fileBytes: 64 * 1024,
|
|
52
|
+
tools: 64,
|
|
53
|
+
files: 128,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pi's built-in tools (`read bash edit write grep find ls`) plus the read-only
|
|
60
|
+
* tools this package exposes to children. Used only for an early diagnostic: a
|
|
61
|
+
* third-party extension can register other names, whose actual availability is
|
|
62
|
+
* verified against each final bound child registry before its first prompt.
|
|
63
|
+
*/
|
|
64
|
+
export const KNOWN_TOOL_NAMES: readonly string[] = [
|
|
65
|
+
"read",
|
|
66
|
+
"bash",
|
|
67
|
+
"edit",
|
|
68
|
+
"write",
|
|
69
|
+
"grep",
|
|
70
|
+
"find",
|
|
71
|
+
"ls",
|
|
72
|
+
...CHILD_SAFE_PACKAGE_TOOL_NAMES,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Every key `parseAgentType` reads. Anything else is reported, because a
|
|
77
|
+
* misspelled key fails in the dangerous direction: `tool:` leaves `tools`
|
|
78
|
+
* undefined and yields a child with the full inherited toolset, indis-
|
|
79
|
+
* tinguishable from a type that deliberately inherits everything.
|
|
80
|
+
*/
|
|
81
|
+
export const KNOWN_FRONTMATTER_KEYS: readonly string[] = [
|
|
82
|
+
"name",
|
|
83
|
+
"description",
|
|
84
|
+
"tools",
|
|
85
|
+
"model",
|
|
86
|
+
"reasoning_effort",
|
|
87
|
+
"reasoningEffort",
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export interface AgentType {
|
|
91
|
+
readonly name: string;
|
|
92
|
+
readonly description: string;
|
|
93
|
+
/** Omitted = the child keeps the normal tool set. Present = allowlist. */
|
|
94
|
+
readonly tools?: readonly string[];
|
|
95
|
+
/** "provider/model-id" or a bare id; resolved by the pi backend. */
|
|
96
|
+
readonly model?: string;
|
|
97
|
+
readonly reasoningEffort?: ReasoningEffort;
|
|
98
|
+
/** Markdown body, appended to the child's system prompt. */
|
|
99
|
+
readonly body?: string;
|
|
100
|
+
/** Absolute path this type was loaded from, for diagnostics. */
|
|
101
|
+
readonly source: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "fd", "rg"];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Built-in role definitions are deliberately provider-free: model selection is
|
|
108
|
+
* inherited unless the user assigns one in package setup. Files may replace a
|
|
109
|
+
* complete role definition; `loadAgentTypes` reports every replacement.
|
|
110
|
+
*/
|
|
111
|
+
export const BUILT_IN_AGENT_TYPES: readonly AgentType[] = [
|
|
112
|
+
{
|
|
113
|
+
name: "explorer",
|
|
114
|
+
description:
|
|
115
|
+
"Read-only codebase exploration. Use high for routine, local, direct tracing; xhigh for interacting state transitions, concurrency or trust boundaries, or subtle multi-path lifecycle/control-flow; max only for exceptionally difficult broad unfamiliar architecture with unresolved competing flows.",
|
|
116
|
+
tools: READ_ONLY_TOOLS,
|
|
117
|
+
reasoningEffort: "high",
|
|
118
|
+
body: "Explore the codebase read-only. Trace the real flow, inspect related callers, and report concise evidence with file paths and line references.",
|
|
119
|
+
source: "built-in:explorer",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "implementer",
|
|
123
|
+
description: "Focused implementation with repository checks.",
|
|
124
|
+
tools: ["read", "bash", "edit", "write", "grep", "find", "ls", "fd", "rg"],
|
|
125
|
+
reasoningEffort: "high",
|
|
126
|
+
body: "Implement the requested change carefully. Trace the affected flow first, make the smallest correct edit, and run relevant checks before reporting results.",
|
|
127
|
+
source: "built-in:implementer",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "reviewer",
|
|
131
|
+
description: "Read-only review for correctness, safety, and regressions.",
|
|
132
|
+
tools: READ_ONLY_TOOLS,
|
|
133
|
+
reasoningEffort: "medium",
|
|
134
|
+
body: "Review the requested code or change read-only. Identify concrete correctness, security, and regression risks with evidence; do not modify files.",
|
|
135
|
+
source: "built-in:reviewer",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "advisor",
|
|
139
|
+
description: "Deep read-only analysis and technical advice.",
|
|
140
|
+
tools: READ_ONLY_TOOLS,
|
|
141
|
+
reasoningEffort: "xhigh",
|
|
142
|
+
body: "Analyze the problem deeply without modifying files. Explain the relevant tradeoffs, risks, and recommended next step using repository evidence.",
|
|
143
|
+
source: "built-in:advisor",
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolves the model hint without turning inherited-parent selection into a
|
|
149
|
+
* hardcoded default. The backend receives undefined to preserve inheritance.
|
|
150
|
+
*/
|
|
151
|
+
export function selectSubagentModel(
|
|
152
|
+
explicitModel: string | undefined,
|
|
153
|
+
agentType: AgentType | undefined,
|
|
154
|
+
roleModel: SubagentRoleModel | undefined,
|
|
155
|
+
) {
|
|
156
|
+
return (
|
|
157
|
+
explicitModel ??
|
|
158
|
+
agentType?.model ??
|
|
159
|
+
(roleModel ? `${roleModel.provider}/${roleModel.model}` : undefined)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve a model hint consistently for direct and Workflow children.
|
|
165
|
+
* Provider-qualified hints are exact; bare ids prefer the parent provider and
|
|
166
|
+
* otherwise must be unique. No hint inherits the parent model.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveAgentModel(
|
|
169
|
+
registry: ModelRegistry,
|
|
170
|
+
hint: string | undefined,
|
|
171
|
+
inherited: { provider: string; id: string } | undefined,
|
|
172
|
+
): Model<any> | undefined {
|
|
173
|
+
if (!hint) {
|
|
174
|
+
if (!inherited) return undefined;
|
|
175
|
+
const found = registry.find(inherited.provider, inherited.id);
|
|
176
|
+
if (found) return found;
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Inherited model "${inherited.provider}/${inherited.id}" is no longer available. Choose an available model explicitly.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const slash = hint.indexOf("/");
|
|
182
|
+
if (slash > 0) {
|
|
183
|
+
const provider = hint.slice(0, slash);
|
|
184
|
+
const id = hint.slice(slash + 1);
|
|
185
|
+
const found = registry.find(provider, id);
|
|
186
|
+
if (found) return found;
|
|
187
|
+
throw new Error(`Unknown model "${hint}".`);
|
|
188
|
+
}
|
|
189
|
+
if (inherited) {
|
|
190
|
+
const found = registry.find(inherited.provider, hint);
|
|
191
|
+
if (found) return found;
|
|
192
|
+
}
|
|
193
|
+
const matches = registry.getAll().filter((model) => model.id === hint);
|
|
194
|
+
if (matches.length === 1) return matches[0];
|
|
195
|
+
if (matches.length > 1) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Model "${hint}" exists in multiple providers (${matches.map((model) => model.provider).join(", ")}). Use "provider/${hint}".`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
throw new Error(`Unknown model "${hint}".`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Return a package assignment only for the four built-in role names. */
|
|
204
|
+
export function roleModelForAgentType(
|
|
205
|
+
agentType: AgentType | undefined,
|
|
206
|
+
roleModels: Partial<Record<string, SubagentRoleModel>>,
|
|
207
|
+
) {
|
|
208
|
+
return agentType && isSubagentRoleName(agentType.name)
|
|
209
|
+
? roleModels[agentType.name]
|
|
210
|
+
: undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface AgentTypeDiagnostic {
|
|
214
|
+
/** File the problem came from, or the directory for a scan failure. */
|
|
215
|
+
readonly source: string;
|
|
216
|
+
readonly message: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
220
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** YAML gives us real types, so accept only a string and trim it. */
|
|
224
|
+
function readString(value: unknown) {
|
|
225
|
+
return typeof value === "string" ? value.trim() : undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Accept `[a, b]` and `a, b` alike: the flow-sequence form is what the docs
|
|
230
|
+
* show, but a plain comma string is the mistake people actually make.
|
|
231
|
+
*/
|
|
232
|
+
function readToolList(
|
|
233
|
+
value: unknown,
|
|
234
|
+
):
|
|
235
|
+
| { readonly error: string; readonly tools?: undefined }
|
|
236
|
+
| { readonly tools: string[]; readonly error?: undefined }
|
|
237
|
+
| undefined {
|
|
238
|
+
if (value === undefined || value === null) return undefined;
|
|
239
|
+
const raw = Array.isArray(value)
|
|
240
|
+
? value
|
|
241
|
+
: typeof value === "string"
|
|
242
|
+
? value.split(",")
|
|
243
|
+
: undefined;
|
|
244
|
+
if (!raw) return { error: "tools must be a list of tool names" };
|
|
245
|
+
const tools: string[] = [];
|
|
246
|
+
for (const entry of raw) {
|
|
247
|
+
const name = readString(entry);
|
|
248
|
+
if (!name) return { error: "tools entries must be non-empty strings" };
|
|
249
|
+
if (name.length > AGENT_TYPE_LIMITS.toolNameChars) {
|
|
250
|
+
return {
|
|
251
|
+
error: `tool name exceeds ${AGENT_TYPE_LIMITS.toolNameChars} characters`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (!tools.includes(name)) tools.push(name);
|
|
255
|
+
}
|
|
256
|
+
if (tools.length === 0) {
|
|
257
|
+
// An empty list would silently mean "no tools at all", which is never what
|
|
258
|
+
// someone means by writing the key. Omitting it is how you inherit.
|
|
259
|
+
return { error: "tools is empty; omit it to inherit the normal tool set" };
|
|
260
|
+
}
|
|
261
|
+
if (tools.length > AGENT_TYPE_LIMITS.tools) {
|
|
262
|
+
return { error: `tools exceeds ${AGENT_TYPE_LIMITS.tools} entries` };
|
|
263
|
+
}
|
|
264
|
+
return { tools };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export type ParseAgentTypeResult =
|
|
268
|
+
| {
|
|
269
|
+
readonly agentType: AgentType;
|
|
270
|
+
readonly diagnostics: AgentTypeDiagnostic[];
|
|
271
|
+
}
|
|
272
|
+
| {
|
|
273
|
+
readonly agentType?: undefined;
|
|
274
|
+
/** A malformed higher-precedence file blocks fallback to this name. */
|
|
275
|
+
readonly rejectedName: string;
|
|
276
|
+
readonly diagnostics: AgentTypeDiagnostic[];
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Parse one agent-type document. Pure: `source` is only echoed into
|
|
281
|
+
* diagnostics. Unknown tool names remain advisory because extensions can
|
|
282
|
+
* register them; unknown frontmatter keys reject the whole type because a
|
|
283
|
+
* misspelled restriction key could otherwise expand capability.
|
|
284
|
+
*/
|
|
285
|
+
export function parseAgentType(
|
|
286
|
+
content: string,
|
|
287
|
+
stem: string,
|
|
288
|
+
source = stem,
|
|
289
|
+
): ParseAgentTypeResult {
|
|
290
|
+
const diagnostics: AgentTypeDiagnostic[] = [];
|
|
291
|
+
let rejectedName = stem;
|
|
292
|
+
const fail = (message: string) => ({
|
|
293
|
+
rejectedName,
|
|
294
|
+
diagnostics: [...diagnostics, { source, message }],
|
|
295
|
+
});
|
|
296
|
+
if (Buffer.byteLength(content, "utf8") > AGENT_TYPE_LIMITS.fileBytes) {
|
|
297
|
+
return fail(`file exceeds ${AGENT_TYPE_LIMITS.fileBytes} bytes`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let frontmatter: Record<string, unknown>;
|
|
301
|
+
let body: string;
|
|
302
|
+
try {
|
|
303
|
+
const parsed = parseFrontmatter(content);
|
|
304
|
+
if (!isRecord(parsed.frontmatter)) return fail("frontmatter must be a map");
|
|
305
|
+
frontmatter = parsed.frontmatter;
|
|
306
|
+
body = parsed.body;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
return fail(
|
|
309
|
+
`invalid YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const name = readString(frontmatter.name);
|
|
314
|
+
if (!name) return fail("missing required `name`");
|
|
315
|
+
if (name.length > AGENT_TYPE_LIMITS.nameChars) {
|
|
316
|
+
return fail(`name exceeds ${AGENT_TYPE_LIMITS.nameChars} characters`);
|
|
317
|
+
}
|
|
318
|
+
if (!NAME_PATTERN.test(name)) {
|
|
319
|
+
return fail(
|
|
320
|
+
`name "${name}" must be lowercase letters, digits, and single hyphens`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
// Once a safe declared name is known, a malformed/misnamed file must block
|
|
324
|
+
// that role rather than only its filename spelling.
|
|
325
|
+
rejectedName = name;
|
|
326
|
+
// A renamed file with a stale `name` would otherwise shadow a different type
|
|
327
|
+
// than the filename suggests.
|
|
328
|
+
if (name !== stem) {
|
|
329
|
+
return fail(`name "${name}" does not match filename "${stem}.md"`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const description = readString(frontmatter.description);
|
|
333
|
+
if (!description) return fail("missing required `description`");
|
|
334
|
+
if (description.length > AGENT_TYPE_LIMITS.descriptionChars) {
|
|
335
|
+
return fail(
|
|
336
|
+
`description exceeds ${AGENT_TYPE_LIMITS.descriptionChars} characters`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const toolList = readToolList(frontmatter.tools);
|
|
341
|
+
if (toolList?.error !== undefined) return fail(toolList.error);
|
|
342
|
+
const tools = toolList?.tools;
|
|
343
|
+
for (const tool of tools ?? []) {
|
|
344
|
+
if (CHILD_EXCLUDED_TOOL_NAMES.includes(tool as never)) {
|
|
345
|
+
diagnostics.push({
|
|
346
|
+
source,
|
|
347
|
+
message: `"${tool}" in ${name} is a parent-only tool; the child cannot receive it, so it is ignored`,
|
|
348
|
+
});
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (KNOWN_TOOL_NAMES.includes(tool)) continue;
|
|
352
|
+
diagnostics.push({
|
|
353
|
+
source,
|
|
354
|
+
message: `unrecognized tool "${tool}" in ${name}; launch will verify it after child extensions initialize`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// A misspelled KEY is the dangerous direction: `tool:` or `allowed_tools:`
|
|
358
|
+
// parses cleanly, leaves `tools` undefined, and would produce a child with
|
|
359
|
+
// the full inherited toolset. There is no safe distinction between a typo
|
|
360
|
+
// in a restriction-relevant key and a harmless future key, so reject every
|
|
361
|
+
// unknown key rather than guessing and widening capability.
|
|
362
|
+
const unknownKeys = Object.keys(frontmatter).filter(
|
|
363
|
+
(key) => !KNOWN_FRONTMATTER_KEYS.includes(key),
|
|
364
|
+
);
|
|
365
|
+
if (unknownKeys.length > 0) {
|
|
366
|
+
return fail(
|
|
367
|
+
`unrecognized frontmatter key${unknownKeys.length === 1 ? "" : "s"} ${unknownKeys.map((key) => `"${key}"`).join(", ")}; the agent type was rejected because ignored keys could change its tool restrictions`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const model = readString(frontmatter.model);
|
|
372
|
+
if (model && model.length > AGENT_TYPE_LIMITS.modelChars) {
|
|
373
|
+
return fail(`model exceeds ${AGENT_TYPE_LIMITS.modelChars} characters`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const rawEffort =
|
|
377
|
+
readString(frontmatter.reasoning_effort) ??
|
|
378
|
+
readString(frontmatter.reasoningEffort);
|
|
379
|
+
if (rawEffort && !REASONING_EFFORTS.includes(rawEffort as ReasoningEffort)) {
|
|
380
|
+
return fail(
|
|
381
|
+
`reasoning_effort "${rawEffort}" must be one of ${REASONING_EFFORTS.join(", ")}`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const reasoningEffort = rawEffort as ReasoningEffort | undefined;
|
|
385
|
+
|
|
386
|
+
const trimmedBody = body.trim();
|
|
387
|
+
if (trimmedBody.length > AGENT_TYPE_LIMITS.bodyChars) {
|
|
388
|
+
return fail(`body exceeds ${AGENT_TYPE_LIMITS.bodyChars} characters`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
agentType: {
|
|
393
|
+
name,
|
|
394
|
+
description,
|
|
395
|
+
...(tools ? { tools } : {}),
|
|
396
|
+
...(model ? { model } : {}),
|
|
397
|
+
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
398
|
+
...(trimmedBody ? { body: trimmedBody } : {}),
|
|
399
|
+
source,
|
|
400
|
+
},
|
|
401
|
+
diagnostics,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Read one directory of `*.md` agent types. A missing directory is normal. */
|
|
406
|
+
function loadDirectory(directory: string) {
|
|
407
|
+
const agentTypes: AgentType[] = [];
|
|
408
|
+
const rejectedNames = new Set<string>();
|
|
409
|
+
const diagnostics: AgentTypeDiagnostic[] = [];
|
|
410
|
+
let blockAllFallback = false;
|
|
411
|
+
|
|
412
|
+
let entries: fs.Dirent[];
|
|
413
|
+
try {
|
|
414
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
415
|
+
} catch (error) {
|
|
416
|
+
// Not existing is the common case and not worth reporting; anything else
|
|
417
|
+
// (permissions, a file where a directory belongs) is.
|
|
418
|
+
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
419
|
+
blockAllFallback = true;
|
|
420
|
+
diagnostics.push({
|
|
421
|
+
source: directory,
|
|
422
|
+
message: `could not read agent types: ${error instanceof Error ? error.message : String(error)}; all lower-precedence definitions are blocked`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return { agentTypes, rejectedNames, diagnostics, blockAllFallback };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const files = entries
|
|
429
|
+
.filter((entry) => {
|
|
430
|
+
if (!entry.name.endsWith(".md")) return false;
|
|
431
|
+
if (entry.isFile()) return true;
|
|
432
|
+
// A symlinked type is normal for dotfiles. Include every `.md` symlink
|
|
433
|
+
// and let readFile reject broken/directory targets: silently skipping a
|
|
434
|
+
// higher-precedence `implementer.md` would expose the broader fallback.
|
|
435
|
+
return entry.isSymbolicLink();
|
|
436
|
+
})
|
|
437
|
+
.map((entry) => entry.name)
|
|
438
|
+
.sort();
|
|
439
|
+
|
|
440
|
+
if (files.length > AGENT_TYPE_LIMITS.files) {
|
|
441
|
+
diagnostics.push({
|
|
442
|
+
source: directory,
|
|
443
|
+
message: `more than ${AGENT_TYPE_LIMITS.files} agent types; rejecting the rest so they cannot fall back to broader definitions`,
|
|
444
|
+
});
|
|
445
|
+
for (const file of files.slice(AGENT_TYPE_LIMITS.files)) {
|
|
446
|
+
rejectedNames.add(file.slice(0, -3));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (const file of files.slice(0, AGENT_TYPE_LIMITS.files)) {
|
|
451
|
+
const filePath = path.join(directory, file);
|
|
452
|
+
let content: string;
|
|
453
|
+
try {
|
|
454
|
+
const size = fs.statSync(filePath).size;
|
|
455
|
+
if (size > AGENT_TYPE_LIMITS.fileBytes) {
|
|
456
|
+
throw new Error(`file exceeds ${AGENT_TYPE_LIMITS.fileBytes} bytes`);
|
|
457
|
+
}
|
|
458
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
459
|
+
} catch (error) {
|
|
460
|
+
rejectedNames.add(file.slice(0, -3));
|
|
461
|
+
diagnostics.push({
|
|
462
|
+
source: filePath,
|
|
463
|
+
message: `could not read file: ${error instanceof Error ? error.message : String(error)}`,
|
|
464
|
+
});
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const result = parseAgentType(content, file.slice(0, -3), filePath);
|
|
468
|
+
diagnostics.push(...result.diagnostics);
|
|
469
|
+
if (result.agentType) agentTypes.push(result.agentType);
|
|
470
|
+
else rejectedNames.add(result.rejectedName);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return { agentTypes, rejectedNames, diagnostics, blockAllFallback };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export interface LoadAgentTypesOptions {
|
|
477
|
+
readonly agentDir: string;
|
|
478
|
+
readonly cwd: string;
|
|
479
|
+
/** Untrusted projects contribute no agent types. */
|
|
480
|
+
readonly projectTrusted: boolean;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Load built-in then global then project agent types. Each more-specific layer
|
|
485
|
+
* overrides a complete same-name definition, and every replacement is reported
|
|
486
|
+
* so a role never changes meaning silently.
|
|
487
|
+
*/
|
|
488
|
+
export function loadAgentTypes(options: LoadAgentTypesOptions) {
|
|
489
|
+
const global = loadDirectory(
|
|
490
|
+
path.join(options.agentDir, AGENT_TYPES_DIR_NAME),
|
|
491
|
+
);
|
|
492
|
+
const project = options.projectTrusted
|
|
493
|
+
? loadDirectory(path.join(options.cwd, ".pi", AGENT_TYPES_DIR_NAME))
|
|
494
|
+
: {
|
|
495
|
+
agentTypes: [],
|
|
496
|
+
rejectedNames: new Set<string>(),
|
|
497
|
+
diagnostics: [],
|
|
498
|
+
blockAllFallback: false,
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const diagnostics = [...global.diagnostics, ...project.diagnostics];
|
|
502
|
+
const agentTypes = new Map<string, AgentType>(
|
|
503
|
+
BUILT_IN_AGENT_TYPES.map((agentType) => [agentType.name, agentType]),
|
|
504
|
+
);
|
|
505
|
+
for (const layer of [global, project]) {
|
|
506
|
+
if (layer.blockAllFallback) agentTypes.clear();
|
|
507
|
+
for (const rejectedName of layer.rejectedNames) {
|
|
508
|
+
const shadowed = agentTypes.get(rejectedName);
|
|
509
|
+
if (shadowed) {
|
|
510
|
+
diagnostics.push({
|
|
511
|
+
source: rejectedName,
|
|
512
|
+
message: `malformed higher-precedence definition blocks fallback to ${shadowed.source}`,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
agentTypes.delete(rejectedName);
|
|
516
|
+
}
|
|
517
|
+
for (const agentType of layer.agentTypes) {
|
|
518
|
+
const shadowed = agentTypes.get(agentType.name);
|
|
519
|
+
if (shadowed) {
|
|
520
|
+
diagnostics.push({
|
|
521
|
+
source: agentType.source,
|
|
522
|
+
message: `overrides the agent type of the same name from ${shadowed.source}`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
agentTypes.set(agentType.name, agentType);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return { agentTypes, diagnostics };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** One-line-per-problem notice, or undefined when everything loaded cleanly. */
|
|
533
|
+
export function formatAgentTypeDiagnostics(
|
|
534
|
+
diagnostics: readonly AgentTypeDiagnostic[],
|
|
535
|
+
) {
|
|
536
|
+
if (diagnostics.length === 0) return undefined;
|
|
537
|
+
return sanitizeTerminalText(
|
|
538
|
+
[
|
|
539
|
+
`Agent types: ${diagnostics.length} problem${diagnostics.length === 1 ? "" : "s"}.`,
|
|
540
|
+
...diagnostics.map((entry) => `- ${entry.source}: ${entry.message}`),
|
|
541
|
+
].join("\n"),
|
|
542
|
+
);
|
|
543
|
+
}
|