pi-open-agents 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/ATTRIBUTION.md ADDED
@@ -0,0 +1,38 @@
1
+ # Attribution
2
+
3
+ This project builds on ideas and code from the following open-source projects.
4
+ Significant portions of the initial implementation are derived from these sources.
5
+
6
+ ## pi-agent-mode
7
+
8
+ - **Source**: https://github.com/ZGltYQ/agent-mode
9
+ - **Author**: ZGltYQ
10
+ - **License**: MIT
11
+ - **Version at time of fork**: v1.3.0 (commit `751abef`)
12
+ - **What we use**: Primary agent loading, inline system prompt injection,
13
+ model switching, tool whitelist, TUI selector/search UI, status banner,
14
+ `set_agent`/`search_agents` tools, session persistence, `--agent` CLI flag.
15
+
16
+ ## pi-subagents
17
+
18
+ - **Source**: https://github.com/jwu/pi-subagents
19
+ - **Author**: jwu (John Wu)
20
+ - **License**: MIT
21
+ - **Version at time of fork**: v2.1.0 (commit `6c568dd`)
22
+ - **What we use**: Subagent execution engine, child session spawning,
23
+ tool scoping, parent-child IPC, TUI rendering for subagent streams,
24
+ `maxDepth`/`allowedAgents` controls, `thinking` frontmatter support.
25
+
26
+ ## opencode
27
+
28
+ - **Source**: https://github.com/anomalyco/opencode
29
+ - **Author**: anomalyco (fork of sst/opencode)
30
+ - **Version at time of clone**: commit `cfddb24`
31
+ - **What we reference**: Agent definition format, frontmatter schema,
32
+ discovery paths (`.opencode/agent/`), compatibility target for
33
+ cross-runtime agent definitions.
34
+
35
+ ## License compliance
36
+
37
+ Each source file derived from these projects retains appropriate attribution
38
+ in its header comments. The upstream licenses apply to their respective code.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrea Tomassi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # pi-open-agents
2
+
3
+ Unified agent and subagent management for [pi](https://github.com/earendil-works/pi-coding-agent), with [OpenCode](https://github.com/sst/opencode)-compatible agent definitions.
4
+
5
+ Replaces `pi-agent-mode` and `@johnnywu/pi-subagents` with a single, coherent system.
6
+
7
+ ## Why
8
+
9
+ Pi delegates agent management to two separate npm plugins that use incompatible frontmatter schemas, divergent defaults, and fragmented semantics. This plugin replaces both with one unified system that also reads OpenCode-format agent definitions.
10
+
11
+ ## Features
12
+
13
+ - **Unified agent definitions** — one `.md` file format, superset of OpenCode + pi
14
+ - **Mode-based visibility** — `primary` agents in the TUI selector, `subagent` agents for delegation, `all` for both
15
+ - **OpenCode-compatible** — reads agents from `.opencode/agent/`, `.opencode/mode/`, and `.agents/`
16
+ - **Permission system** — OpenCode-style `allow`/`deny`/`ask` with glob patterns, last-match-wins
17
+ - **Thinking level per-agent** — `off`/`minimal`/`low`/`medium`/`high`/`xhigh`, with fallback to `settings.json`
18
+ - **Subagent execution** — child pi process spawning with scoped tools, skills, and session fork
19
+ - **Skill loading** — per-agent skills with wildcard support (`lark-*`, `github`)
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pi install pi-open-agents
25
+ ```
26
+
27
+ Then remove the old plugins from `~/.pi/agent/settings.json`:
28
+
29
+ ```json
30
+ {
31
+ "packages": [
32
+ "pi-open-agents"
33
+ ]
34
+ }
35
+ ```
36
+
37
+ ## Agent Definition Format
38
+
39
+ ```yaml
40
+ ---
41
+ # === Identity ===
42
+ name: my-agent # required
43
+ description: When to use this agent
44
+ mode: primary|subagent|all # default: all
45
+ hidden: false # hide from TUI selectors
46
+ color: "#44BA81"
47
+
48
+ # === Model ===
49
+ model: provider/model-id # per-agent override
50
+ variant: thinking # model variant
51
+ temperature: 0.7
52
+ top_p: 0.95
53
+
54
+ # === Execution ===
55
+ thinking: xhigh # off|minimal|low|medium|high|xhigh
56
+ steps: 50 # max agentic iterations
57
+ systemPrompt: append|replace|replace-all # default: append
58
+
59
+ # === Permissions (OpenCode-style) ===
60
+ permission:
61
+ bash: allow
62
+ edit:
63
+ "*.env": deny
64
+ "*.md": allow
65
+ read: allow
66
+
67
+ # === Subagent controls ===
68
+ maxDepth: 10 # recursion limit
69
+ allowedAgents: worker, explorer # which subagents this can spawn
70
+ skills: lark-*, github # per-agent skills with wildcards
71
+ ---
72
+ You are a specialized agent. Your markdown body becomes the system prompt.
73
+ ```
74
+
75
+ ## Discovery Paths
76
+
77
+ Agents are loaded from multiple locations (project overrides global by name):
78
+
79
+ | Path | Scope | Format |
80
+ |------|-------|--------|
81
+ | `~/.pi/agent/agents/*.md` | Global | pi |
82
+ | `~/.opencode/{agent,agents,mode}/*.md` | Global | OpenCode |
83
+ | `.pi/agents/*.md` | Project | pi |
84
+ | `.opencode/{agent,agents,mode}/*.md` | Project | OpenCode |
85
+ | `.agents/*.md` | Project | Shared |
86
+
87
+ ## Usage
88
+
89
+ ### Interactive
90
+
91
+ | Action | What it does |
92
+ |---|---|
93
+ | `/agent` | Open agent selector (primary/all only) |
94
+ | `/agent <name>` | Switch to agent directly |
95
+ | `/agents` | List all agents |
96
+ | `/agent-search <query>` | Search agents |
97
+ | `Ctrl+Shift+M` | Cycle agents |
98
+ | `Alt+S` | Search prompt |
99
+ | `--agent <name>` | CLI flag for startup agent |
100
+
101
+ ### Programmatic (LLM tools)
102
+
103
+ | Tool | Description |
104
+ |---|---|
105
+ | `set_agent` | Switch agent programmatically |
106
+ | `search_agents` | Search agents by name/description/body |
107
+ | `subagent` | Delegate task to a subagent (subagent/all mode only) |
108
+
109
+ ## Migration from pi-agent-mode + pi-subagents
110
+
111
+ 1. Install: `pi install pi-open-agents`
112
+ 2. Remove `npm:pi-agent-mode` and `npm:@johnnywu/pi-subagents` from settings
113
+ 3. Existing agent `.md` files work without changes (default `mode: all`)
114
+ 4. Gradually add `mode: primary` or `mode: subagent` to control visibility
115
+ 5. Gradually adopt `permission:` over `tools:` whitelist
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ npm install
121
+ npm test # 84 tests
122
+ npm run typecheck # tsc --noEmit
123
+ ```
124
+
125
+ ## Attribution
126
+
127
+ This project builds on code and ideas from:
128
+
129
+ - [pi-agent-mode](https://github.com/ZGltYQ/agent-mode) by ZGltYQ (MIT)
130
+ - [pi-subagents](https://github.com/jwu/pi-subagents) by jwu (MIT)
131
+ - [opencode](https://github.com/sst/opencode) by sst (MIT)
132
+
133
+ See `ATTRIBUTION.md` for details.
134
+
135
+ ## License
136
+
137
+ MIT
package/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * pi-open-agents entry point.
3
+ *
4
+ * Re-exports from src/index.ts so pi can resolve the extension name
5
+ * from the package root (not the "src" directory).
6
+ */
7
+ export { default } from "./src/index.ts";
8
+ export * from "./src/index.ts";
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "pi-open-agents",
3
+ "version": "0.1.0",
4
+ "description": "Unified agent and subagent management for pi coding agent, with OpenCode-compatible agent definitions.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "extension",
10
+ "agents",
11
+ "subagents",
12
+ "opencode"
13
+ ],
14
+ "author": "Andrea Tomassi",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/andrea-tomassi/pi-open-agents.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/andrea-tomassi/pi-open-agents/issues"
22
+ },
23
+ "homepage": "https://github.com/andrea-tomassi/pi-open-agents#readme",
24
+ "type": "module",
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "files": [
29
+ "index.ts",
30
+ "src/",
31
+ "README.md",
32
+ "LICENSE",
33
+ "ATTRIBUTION.md"
34
+ ],
35
+ "scripts": {
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "node --test --import tsx test/**/*.test.ts"
38
+ },
39
+ "pi": {
40
+ "extensions": [
41
+ "./index.ts"
42
+ ]
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-ai": "*",
46
+ "@earendil-works/pi-coding-agent": "*",
47
+ "@earendil-works/pi-tui": "*",
48
+ "typebox": "*"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "@earendil-works/pi-ai": { "optional": true },
52
+ "@earendil-works/pi-coding-agent": { "optional": true },
53
+ "@earendil-works/pi-tui": { "optional": true },
54
+ "typebox": { "optional": true }
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^24.0.0",
58
+ "tsx": "^4.19.0",
59
+ "typescript": "^5.6.0"
60
+ }
61
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Self-contained YAML frontmatter parser.
3
+ *
4
+ * Handles the subset of YAML used by agent definitions:
5
+ * - Flat key-value pairs
6
+ * - Nested objects (for permission rules)
7
+ * - Type coercion (boolean, number, string)
8
+ * - Quoted values
9
+ *
10
+ * No external dependencies — works in isolation for testing.
11
+ */
12
+
13
+ interface RawLine {
14
+ indent: number;
15
+ content: string;
16
+ lineNumber: number;
17
+ }
18
+
19
+ export interface ParsedFrontmatter {
20
+ data: Record<string, unknown>;
21
+ body: string;
22
+ }
23
+
24
+ /**
25
+ * Extract and parse frontmatter from markdown content.
26
+ *
27
+ * Returns { data: {}, body: content } if no frontmatter is present.
28
+ */
29
+ export function parseFrontmatter(content: string): ParsedFrontmatter {
30
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
31
+
32
+ // No frontmatter block
33
+ if (!normalized.startsWith("---")) {
34
+ return { data: {}, body: normalized.trim() };
35
+ }
36
+
37
+ // Find the closing ---
38
+ // Skip the opening --- and the newline after it
39
+ const afterOpening = normalized.slice(3);
40
+ if (!afterOpening.startsWith("\n") && !afterOpening.startsWith("\r")) {
41
+ // --- immediately followed by content (not a frontmatter block)
42
+ return { data: {}, body: normalized.trim() };
43
+ }
44
+
45
+ const closingIdx = normalized.indexOf("\n---", 3);
46
+ if (closingIdx === -1) {
47
+ // No closing delimiter — treat entire content as body
48
+ return { data: {}, body: normalized.trim() };
49
+ }
50
+
51
+ const yamlString = normalized.slice(4, closingIdx);
52
+ const body = normalized.slice(closingIdx + 4).replace(/^\n/, "").trim();
53
+
54
+ const data = parseYaml(yamlString);
55
+
56
+ return { data, body };
57
+ }
58
+
59
+ // ─── Minimal YAML Parser ─────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Parse a YAML string into a nested object.
63
+ *
64
+ * Handles:
65
+ * - key: value (flat string/number/boolean)
66
+ * - key: (start of nested object)
67
+ * - subkey: value (nested under parent)
68
+ * - key: [a, b, c] (inline array)
69
+ * - Comments (# ...)
70
+ * - Quoted values
71
+ */
72
+ function parseYaml(input: string): Record<string, unknown> {
73
+ const lines = preprocessLines(input);
74
+ const root: Record<string, unknown> = {};
75
+ parseBlock(lines, 0, lines.length, 0, root);
76
+ return root;
77
+ }
78
+
79
+ function preprocessLines(input: string): RawLine[] {
80
+ const result: RawLine[] = [];
81
+ const rawLines = input.split("\n");
82
+
83
+ for (let i = 0; i < rawLines.length; i++) {
84
+ const line = rawLines[i];
85
+
86
+ // Skip empty lines and comments
87
+ const trimmed = line.trim();
88
+ if (!trimmed || trimmed.startsWith("#")) continue;
89
+
90
+ // Calculate indentation (spaces only, tabs not supported in YAML)
91
+ const indent = line.length - line.trimStart().length;
92
+
93
+ result.push({
94
+ indent,
95
+ content: line.trim(),
96
+ lineNumber: i + 1,
97
+ });
98
+ }
99
+
100
+ return result;
101
+ }
102
+
103
+ /**
104
+ * Recursively parse a block of lines at a given indentation level.
105
+ *
106
+ * @param lines All preprocessed lines
107
+ * @param start Start index in lines array
108
+ * @param end End index (exclusive)
109
+ * @param indent Expected indentation level for this block
110
+ * @param target Object to populate
111
+ * @returns Number of lines consumed
112
+ */
113
+ function parseBlock(
114
+ lines: RawLine[],
115
+ start: number,
116
+ end: number,
117
+ indent: number,
118
+ target: Record<string, unknown>,
119
+ ): number {
120
+ let i = start;
121
+
122
+ while (i < end) {
123
+ const line = lines[i];
124
+
125
+ // This line belongs to a shallower scope — we're done with this block
126
+ if (line.indent < indent) break;
127
+
128
+ // Deeper indentation without a parent key — skip (malformed)
129
+ if (line.indent > indent) {
130
+ i++;
131
+ continue;
132
+ }
133
+
134
+ const colonIdx = line.content.indexOf(":");
135
+ if (colonIdx === -1) {
136
+ // No colon — skip malformed line
137
+ i++;
138
+ continue;
139
+ }
140
+
141
+ const key = line.content.slice(0, colonIdx).trim().replace(/^['"]|['"]$/g, "");
142
+ const rawValue = line.content.slice(colonIdx + 1).trim();
143
+
144
+ if (!key) {
145
+ i++;
146
+ continue;
147
+ }
148
+
149
+ if (rawValue === "") {
150
+ // Value is empty — check if next lines are nested
151
+ if (i + 1 < end && lines[i + 1].indent > indent) {
152
+ // Parse nested object
153
+ const nested: Record<string, unknown> = {};
154
+ const childIndent = lines[i + 1].indent;
155
+ let consumed = 0;
156
+ let j = i + 1;
157
+
158
+ while (j < end && lines[j].indent >= childIndent) {
159
+ if (lines[j].indent === childIndent) {
160
+ const subColon = lines[j].content.indexOf(":");
161
+ if (subColon === -1) {
162
+ j++;
163
+ continue;
164
+ }
165
+ const subKey = lines[j].content.slice(0, subColon).trim().replace(/^['"]|['"]$/g, "");
166
+ const subRaw = lines[j].content.slice(subColon + 1).trim();
167
+
168
+ if (subRaw === "" && j + 1 < end && lines[j + 1].indent > childIndent) {
169
+ // Double nesting (e.g. edit: { "*.env": deny })
170
+ const deepNested: Record<string, unknown> = {};
171
+ const deepIndent = lines[j + 1].indent;
172
+ let deepJ = j + 1;
173
+ while (deepJ < end && lines[deepJ].indent >= deepIndent) {
174
+ if (lines[deepJ].indent === deepIndent) {
175
+ const dColon = lines[deepJ].content.indexOf(":");
176
+ if (dColon !== -1) {
177
+ const dKey = lines[deepJ].content.slice(0, dColon).trim().replace(/^['"]|['"]$/g, "");
178
+ const dVal = lines[deepJ].content.slice(dColon + 1).trim();
179
+ deepNested[dKey] = coerceValue(dVal);
180
+ }
181
+ }
182
+ deepJ++;
183
+ }
184
+ nested[subKey] = deepNested;
185
+ j = deepJ;
186
+ } else {
187
+ nested[subKey] = coerceValue(subRaw);
188
+ j++;
189
+ }
190
+ } else {
191
+ j++;
192
+ }
193
+ }
194
+ target[key] = nested;
195
+ i = j;
196
+ } else {
197
+ // Empty value with no children
198
+ target[key] = "";
199
+ i++;
200
+ }
201
+ } else {
202
+ // Inline value
203
+ target[key] = coerceValue(rawValue);
204
+ i++;
205
+ }
206
+ }
207
+
208
+ return i - start;
209
+ }
210
+
211
+ /**
212
+ * Coerce a raw string value into the appropriate JS type.
213
+ *
214
+ * Handles:
215
+ * - Quoted strings: "hello" or 'hello' → hello
216
+ * - Booleans: true/false
217
+ * - Numbers: 42, 0.7
218
+ * - Inline arrays: [a, b, c]
219
+ * - Everything else: string
220
+ */
221
+ function coerceValue(raw: string): unknown {
222
+ // Inline array: [a, b, c]
223
+ if (raw.startsWith("[") && raw.endsWith("]")) {
224
+ const inner = raw.slice(1, -1).trim();
225
+ if (!inner) return [];
226
+ return inner.split(",").map((s) => coerceValue(s.trim()) as string);
227
+ }
228
+
229
+ // Quoted strings — strip quotes, keep inner content
230
+ if (
231
+ (raw.startsWith('"') && raw.endsWith('"')) ||
232
+ (raw.startsWith("'") && raw.endsWith("'"))
233
+ ) {
234
+ return raw.slice(1, -1);
235
+ }
236
+
237
+ // Booleans
238
+ if (raw === "true") return true;
239
+ if (raw === "false") return false;
240
+
241
+ // Numbers (integer or float)
242
+ if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
243
+ if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
244
+
245
+ // Plain string
246
+ return raw;
247
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Discovery paths for agent definition files.
3
+ *
4
+ * Scans three path families: pi, OpenCode, and shared.
5
+ * Project paths override global paths by agent name.
6
+ */
7
+
8
+ import * as path from "node:path";
9
+ import * as os from "node:os";
10
+
11
+ export interface DiscoveryPath {
12
+ /** Absolute directory path to scan */
13
+ dir: string;
14
+ /** Whether this is a global or project-scoped path */
15
+ source: "global" | "project";
16
+ /** Which path family this belongs to */
17
+ family: "pi" | "opencode" | "shared";
18
+ /**
19
+ * Subdirectory pattern within the dir to scan.
20
+ * If empty, scans the dir directly.
21
+ * Multiple patterns are tried in order.
22
+ */
23
+ subdirs: string[];
24
+ }
25
+
26
+ // ─── Path Builders ───────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Build all discovery paths for the given cwd and agent dir.
30
+ *
31
+ * @param cwd Current working directory (project root)
32
+ * @param agentDir pi agent directory (~/.pi/agent by default)
33
+ * @returns Ordered list of paths to scan (global first, then project)
34
+ */
35
+ export function getDiscoveryPaths(cwd: string, agentDir: string): DiscoveryPath[] {
36
+ const home = os.homedir();
37
+ const opencodeHome = path.join(home, ".opencode");
38
+ const opencodeConfig = path.join(home, ".config", "opencode");
39
+
40
+ const paths: DiscoveryPath[] = [];
41
+
42
+ // ── Global paths (scanned first, lower priority) ──
43
+
44
+ // pi global
45
+ paths.push({
46
+ dir: path.join(agentDir, "agents"),
47
+ source: "global",
48
+ family: "pi",
49
+ subdirs: [""],
50
+ });
51
+
52
+ // OpenCode global (config dir)
53
+ paths.push({
54
+ dir: opencodeConfig,
55
+ source: "global",
56
+ family: "opencode",
57
+ subdirs: ["agent", "agents", "mode", "modes"],
58
+ });
59
+
60
+ // OpenCode global (home .opencode)
61
+ paths.push({
62
+ dir: opencodeHome,
63
+ source: "global",
64
+ family: "opencode",
65
+ subdirs: ["agent", "agents", "mode", "modes"],
66
+ });
67
+
68
+ // ── Project paths (scanned after global, higher priority) ──
69
+
70
+ // pi project
71
+ paths.push({
72
+ dir: path.join(cwd, ".pi", "agents"),
73
+ source: "project",
74
+ family: "pi",
75
+ subdirs: [""],
76
+ });
77
+
78
+ // OpenCode project
79
+ paths.push({
80
+ dir: path.join(cwd, ".opencode"),
81
+ source: "project",
82
+ family: "opencode",
83
+ subdirs: ["agent", "agents", "mode", "modes"],
84
+ });
85
+
86
+ // Shared (cross-runtime)
87
+ paths.push({
88
+ dir: path.join(cwd, ".agents"),
89
+ source: "project",
90
+ family: "shared",
91
+ subdirs: [""],
92
+ });
93
+
94
+ return paths;
95
+ }
96
+
97
+ /**
98
+ * Derive the agent name from a file path, using the discovery path info.
99
+ *
100
+ * For pi/shared: uses the filename without extension
101
+ * .pi/agents/my-agent.md → "my-agent"
102
+ *
103
+ * For OpenCode: strips the subdir prefix
104
+ * .opencode/agent/triage.md → "triage"
105
+ * .opencode/agents/deep/code-review.md → "deep-code-review" (flattened)
106
+ *
107
+ * An explicit `name:` in frontmatter always takes precedence.
108
+ */
109
+ export function deriveAgentName(filePath: string, basePath: string, subdir: string): string {
110
+ const fullPath = subdir ? path.join(basePath, subdir) : basePath;
111
+ const relative = path.relative(fullPath, filePath);
112
+
113
+ // Remove .md extension
114
+ const withoutExt = relative.replace(/\.md$/i, "");
115
+
116
+ // Normalize path separators
117
+ const normalized = withoutExt.replace(/[/\\]/g, "-");
118
+
119
+ return normalized;
120
+ }