@oas-framework/pi 0.4.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 +23 -0
- package/extension/core-loader.mjs +66 -0
- package/extension/index.ts +437 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @oas-framework/pi
|
|
2
|
+
|
|
3
|
+
The OAS pi adapter. Installs the agent tools (`spawn_agent`, `agents_status`,
|
|
4
|
+
`retire_agent`, `create_agent`), the `/agents` command, and OAS skill
|
|
5
|
+
discovery into [pi](https://github.com/earendil-works/pi).
|
|
6
|
+
|
|
7
|
+
It is a thin adapter: the kernel, CLI, skills, injects, and bundled
|
|
8
|
+
integrations all live in the globally installed
|
|
9
|
+
[`@oas-framework/oas`](https://www.npmjs.com/package/@oas-framework/oas)
|
|
10
|
+
package — the single source of truth shared by every runtime adapter.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g @oas-framework/oas # the kernel + oas CLI (required)
|
|
16
|
+
pi install npm:@oas-framework/pi # this adapter
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The adapter locates the kernel via the `oas` binary on PATH (override with
|
|
20
|
+
`OAS_PKG_ROOT`, e.g. to point at a dev clone).
|
|
21
|
+
|
|
22
|
+
See the [oas-framework repo](https://github.com/josep-reyero/oas-framework)
|
|
23
|
+
for the full documentation.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core-loader.mjs — locate the globally installed @oas-framework/oas kernel and
|
|
3
|
+
* re-export its lib/core.mjs. The pi package is a thin adapter: it never ships
|
|
4
|
+
* the kernel, skills, injects, or integrations — those live in the global CLI
|
|
5
|
+
* package (npm i -g @oas-framework/oas), the single source of truth that the
|
|
6
|
+
* future Claude plugin shares.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order:
|
|
9
|
+
* 1. $OAS_PKG_ROOT (explicit override, e.g. a dev clone)
|
|
10
|
+
* 2. the `oas` binary on PATH → realpath → its package root
|
|
11
|
+
* 3. `npm root -g`/@oas-framework/oas (binary not linked but package present)
|
|
12
|
+
*/
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
const PKG_NAME = "@oas-framework/oas";
|
|
19
|
+
|
|
20
|
+
function isKernelRoot(dir) {
|
|
21
|
+
const pj = join(dir, "package.json");
|
|
22
|
+
if (!existsSync(pj) || !existsSync(join(dir, "lib", "core.mjs"))) return false;
|
|
23
|
+
try { return JSON.parse(readFileSync(pj, "utf8")).name === PKG_NAME; } catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findKernelRoot() {
|
|
27
|
+
if (process.env.OAS_PKG_ROOT && isKernelRoot(process.env.OAS_PKG_ROOT)) return process.env.OAS_PKG_ROOT;
|
|
28
|
+
try {
|
|
29
|
+
const bin = execSync("command -v oas", { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
30
|
+
if (bin) {
|
|
31
|
+
let d = dirname(realpathSync(bin)); // <pkg>/bin/oas.mjs → <pkg>/bin
|
|
32
|
+
while (d !== dirname(d)) {
|
|
33
|
+
if (isKernelRoot(d)) return d;
|
|
34
|
+
d = dirname(d);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch { /* not on PATH */ }
|
|
38
|
+
try {
|
|
39
|
+
const g = execSync("npm root -g", { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
40
|
+
const cand = join(g, PKG_NAME);
|
|
41
|
+
if (isKernelRoot(cand)) return cand;
|
|
42
|
+
} catch { /* no npm */ }
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const OAS_PKG_ROOT = findKernelRoot();
|
|
47
|
+
if (!OAS_PKG_ROOT) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"OAS kernel not found — the pi adapter needs the oas CLI installed globally.\n" +
|
|
50
|
+
" Install it: npm install -g @oas-framework/oas\n" +
|
|
51
|
+
" (or point OAS_PKG_ROOT at a checkout of the oas-framework repo)",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const core = await import(pathToFileURL(join(OAS_PKG_ROOT, "lib", "core.mjs")).href);
|
|
56
|
+
|
|
57
|
+
export const {
|
|
58
|
+
appendLogEntry, createAgent, defaultRepo, ensureRoot, findAgent, findRoot, listAgentDefs, listAgents,
|
|
59
|
+
listInstances, retireInstance, spawnInstance, upsertTmpAgent, workspaceOf, writeSoul, PACKAGED_SKILLS_DIR,
|
|
60
|
+
workspaceConfigSkillDirs, isOasWorkspace, resolveOasConfig, runLifecycleHooks,
|
|
61
|
+
} = core;
|
|
62
|
+
|
|
63
|
+
/** Kernel package version (for skew diagnostics against the adapter). */
|
|
64
|
+
export function kernelVersion() {
|
|
65
|
+
try { return JSON.parse(readFileSync(join(OAS_PKG_ROOT, "package.json"), "utf8")).version; } catch { return "unknown"; }
|
|
66
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oas (Open Agent Specialization) — launch specialized agents from the closest agents/ directory (a2am-style souls & instances).
|
|
3
|
+
*
|
|
4
|
+
* Global pi extension. From any directory:
|
|
5
|
+
* - finds the closest `agents/` root walking up from cwd (or $PI_AGENTS_ROOT)
|
|
6
|
+
* - spawns persistent agents (<root>/<name>/soul) or local agents (<root>/local-agents/<name>/soul; legacy tmp-agents/ read)
|
|
7
|
+
* - imports single-file defs from .claude/agents/*.md and .agents/agents/*.md as tmp agents
|
|
8
|
+
* - work mode per soul: `worktree` (own git worktree + branch) or `checkout` (symlink to repo)
|
|
9
|
+
* - sessions run in tmux (session "pi-agents"), model selectable from pi's enabled models
|
|
10
|
+
*
|
|
11
|
+
* Tools: spawn_agent, agents_status, retire_agent. Command: /agents.
|
|
12
|
+
*/
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
17
|
+
import {
|
|
18
|
+
appendLogEntry, createAgent, defaultRepo, ensureRoot, findAgent, findRoot, listAgentDefs, listAgents,
|
|
19
|
+
listInstances, retireInstance, spawnInstance, upsertTmpAgent, workspaceOf, writeSoul, PACKAGED_SKILLS_DIR,
|
|
20
|
+
workspaceConfigSkillDirs, isOasWorkspace, resolveOasConfig, runLifecycleHooks,
|
|
21
|
+
} from "./core-loader.mjs";
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join, resolve } from "node:path";
|
|
24
|
+
|
|
25
|
+
/** .agents/skills dirs in ancestors ABOVE the git repo root — pi's own walk stops at the
|
|
26
|
+
* repo root, so workspace-level skills (e.g. <workspace>/.agents/skills) need contributing. */
|
|
27
|
+
function workspaceSkillDirs(cwd: string): string[] {
|
|
28
|
+
let repoRoot: string | undefined;
|
|
29
|
+
let d = resolve(cwd);
|
|
30
|
+
while (true) {
|
|
31
|
+
if (existsSync(join(d, ".git"))) { repoRoot = d; break; }
|
|
32
|
+
const parent = dirname(d);
|
|
33
|
+
if (parent === d) break;
|
|
34
|
+
d = parent;
|
|
35
|
+
}
|
|
36
|
+
if (!repoRoot) return []; // not in a repo: pi already walks to the filesystem root
|
|
37
|
+
const dirs: string[] = [];
|
|
38
|
+
d = dirname(repoRoot);
|
|
39
|
+
while (true) {
|
|
40
|
+
const cand = join(d, ".agents", "skills");
|
|
41
|
+
if (existsSync(cand)) dirs.push(cand);
|
|
42
|
+
const parent = dirname(d);
|
|
43
|
+
if (parent === d) break;
|
|
44
|
+
d = parent;
|
|
45
|
+
}
|
|
46
|
+
return dirs;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function globToRegex(glob: string): RegExp {
|
|
50
|
+
const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
51
|
+
return new RegExp(`^${esc}$`, "i");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Models enabled in pi: settings `enabledModels` patterns (scoped models) expanded
|
|
55
|
+
* against the registry; falls back to all auth-configured models if unscoped. */
|
|
56
|
+
function availableModels(ctx: ExtensionContext): string[] {
|
|
57
|
+
const all = ctx.modelRegistry.getAvailable().map((m) => ({ full: `${m.provider}/${m.id}`, id: m.id }));
|
|
58
|
+
let patterns: string[] | undefined;
|
|
59
|
+
try {
|
|
60
|
+
patterns = SettingsManager.create(ctx.cwd).getEnabledModels();
|
|
61
|
+
} catch { /* fall through to unscoped */ }
|
|
62
|
+
if (!patterns || patterns.length === 0) return all.map((m) => m.full);
|
|
63
|
+
|
|
64
|
+
const out: string[] = [];
|
|
65
|
+
for (const raw of patterns) {
|
|
66
|
+
// strip optional :thinking suffix (e.g. "provider/id:high")
|
|
67
|
+
const pattern = raw.replace(/:(off|minimal|low|medium|high|xhigh)$/i, "");
|
|
68
|
+
const isGlob = /[*?[]/.test(pattern);
|
|
69
|
+
const matches = isGlob
|
|
70
|
+
? all.filter((m) => globToRegex(pattern).test(m.full) || globToRegex(pattern).test(m.id))
|
|
71
|
+
: all.filter((m) => m.full.toLowerCase() === pattern.toLowerCase() || m.id.toLowerCase() === pattern.toLowerCase());
|
|
72
|
+
for (const m of matches) if (!out.includes(m.full)) out.push(m.full);
|
|
73
|
+
}
|
|
74
|
+
return out.length > 0 ? out : all.map((m) => m.full);
|
|
75
|
+
}
|
|
76
|
+
function validateModel(ctx: ExtensionContext, model?: string): string | undefined {
|
|
77
|
+
if (!model) return undefined;
|
|
78
|
+
const models = availableModels(ctx);
|
|
79
|
+
if (models.includes(model)) return model;
|
|
80
|
+
const matches = models.filter((m) => m.toLowerCase().includes(model.toLowerCase()));
|
|
81
|
+
if (matches.length === 1) return matches[0];
|
|
82
|
+
throw new Error(
|
|
83
|
+
matches.length === 0
|
|
84
|
+
? `model "${model}" is not enabled in pi — available: ${models.join(", ")}`
|
|
85
|
+
: `model "${model}" is ambiguous (${matches.join(", ")}) — be more specific`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
function text(s: string) {
|
|
89
|
+
return { content: [{ type: "text" as const, text: s }] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default function (pi: ExtensionAPI) {
|
|
93
|
+
// (PI_AGENT_HOME is set by spawnInstance's launch command)
|
|
94
|
+
const agentHome = process.env.PI_AGENT_HOME;
|
|
95
|
+
const isInstance = !!agentHome && existsSync(join(agentHome ?? "", "instance.json"));
|
|
96
|
+
|
|
97
|
+
// ---------- kernel + workspace skills: only inside OAS workspaces ----------
|
|
98
|
+
// (a workspace/repo with an oas-config, an agents/ root, or an instance home —
|
|
99
|
+
// agents elsewhere on the machine do not get OAS skills)
|
|
100
|
+
pi.on("resources_discover", async (event, ctx) => {
|
|
101
|
+
const paths: string[] = [];
|
|
102
|
+
const inOas = isInstance || isOasWorkspace(event.cwd);
|
|
103
|
+
if (inOas && existsSync(PACKAGED_SKILLS_DIR)) paths.push(PACKAGED_SKILLS_DIR);
|
|
104
|
+
if (ctx.isProjectTrusted()) {
|
|
105
|
+
paths.push(...workspaceSkillDirs(event.cwd));
|
|
106
|
+
for (const p of workspaceConfigSkillDirs(event.cwd)) if (!paths.includes(p)) paths.push(p);
|
|
107
|
+
}
|
|
108
|
+
if (paths.length === 0) return;
|
|
109
|
+
return { skillPaths: paths };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---------- inside a spawned instance: memory automation ----------
|
|
113
|
+
|
|
114
|
+
if (isInstance) {
|
|
115
|
+
// Memory automation is gated on the knowledge integration having scaffolded
|
|
116
|
+
// instance memory (STATE.md/log.md are OKF conventions — kernel is agnostic;
|
|
117
|
+
// an instance without a knowledge integration has neither file, so both
|
|
118
|
+
// hooks below no-op for it).
|
|
119
|
+
// Compaction survival: journal every compaction summary to the instance log,
|
|
120
|
+
// and remind the model to refresh STATE.md — zero agent discipline required.
|
|
121
|
+
pi.on("session_compact", async (event) => {
|
|
122
|
+
if (!existsSync(join(agentHome!, "STATE.md"))) return;
|
|
123
|
+
try {
|
|
124
|
+
const summary = (event.compactionEntry?.summary ?? "").replace(/\s+/g, " ").trim();
|
|
125
|
+
appendLogEntry(
|
|
126
|
+
join(agentHome!, "log.md"),
|
|
127
|
+
`**Compaction** (${event.reason}): ${summary.slice(0, 400)}${summary.length > 400 ? "…" : ""}`,
|
|
128
|
+
"Instance Log",
|
|
129
|
+
);
|
|
130
|
+
} catch { /* never break the session over journaling */ }
|
|
131
|
+
pi.sendMessage({
|
|
132
|
+
customType: "oas-memory",
|
|
133
|
+
content: "Context was just compacted. Before continuing, update ./STATE.md (Plan/Progress/Next) so a fresh session could resume from files alone.",
|
|
134
|
+
display: false,
|
|
135
|
+
}, { deliverAs: "steer" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Post-commit lifecycle: after a successful `git commit` by this instance, fire
|
|
139
|
+
// the integrations' post-commit hooks (e.g. oas-okf spawns a knowledge-harvest
|
|
140
|
+
// agent attached to this instance's work tree). Kernel stays layer-agnostic.
|
|
141
|
+
pi.on("tool_result", async (event) => {
|
|
142
|
+
if (event.toolName !== "bash" || event.isError) return;
|
|
143
|
+
const cmd = String((event.input as { command?: string })?.command ?? "");
|
|
144
|
+
if (!/\bgit\b[^\n;&|]*\bcommit\b/.test(cmd)) return;
|
|
145
|
+
try {
|
|
146
|
+
const meta = JSON.parse(readFileSync(join(agentHome!, "instance.json"), "utf8"));
|
|
147
|
+
const root = findRoot(agentHome!);
|
|
148
|
+
if (!root || !meta.repo) return;
|
|
149
|
+
runLifecycleHooks("post-commit", {
|
|
150
|
+
home: agentHome!, instance: meta.instance, agentName: meta.agent,
|
|
151
|
+
soulDir: join(agentHome!, "soul"), contextDir: meta.repo,
|
|
152
|
+
workspaceDir: workspaceOf(root), rootDir: root,
|
|
153
|
+
resolved: resolveOasConfig(meta.repo), priorMeta: meta.providerMeta || {},
|
|
154
|
+
});
|
|
155
|
+
} catch { /* hooks never break the session */ }
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Resume nudge: on any later session in this home (restart, model switch, /new),
|
|
159
|
+
// point the model at its own memory before it does anything else.
|
|
160
|
+
pi.on("session_start", async (event) => {
|
|
161
|
+
if (event.reason !== "startup" && event.reason !== "resume" && event.reason !== "new") return;
|
|
162
|
+
const statePath = join(agentHome!, "STATE.md");
|
|
163
|
+
if (!existsSync(statePath)) return;
|
|
164
|
+
const state = readFileSync(statePath, "utf8");
|
|
165
|
+
const touched = !/_No task assigned yet/.test(state) || !/_\(the single next action/.test(state);
|
|
166
|
+
if (event.reason === "startup" && !touched) return; // fresh spawn — TASK.md covers it
|
|
167
|
+
pi.sendMessage({
|
|
168
|
+
customType: "oas-memory",
|
|
169
|
+
content: `You are agent instance home ${agentHome}. Read ./STATE.md and the recent entries of ./log.md now, then continue from STATE.md's "Next" section. Keep STATE.md current as you work.`,
|
|
170
|
+
display: false,
|
|
171
|
+
}, { deliverAs: "steer", triggerTurn: false });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------- tools ----------
|
|
176
|
+
pi.registerTool({
|
|
177
|
+
name: "spawn_agent",
|
|
178
|
+
label: "Spawn Agent",
|
|
179
|
+
description: [
|
|
180
|
+
"Spawn an agent instance in tmux from the closest agents/ directory (walking up from cwd).",
|
|
181
|
+
"Modes: spawn an existing agent by name; create a tmp (adhoc) agent by passing `instructions`",
|
|
182
|
+
"(a full AGENTS.md defining behavior); or import a .claude/agents/.agents/agents def by passing `defFile`.",
|
|
183
|
+
"Each instance gets a home dir with the soul linked in, a TASK.md briefing, and a work tree:",
|
|
184
|
+
"`worktree` = own git worktree + branch, `checkout` = symlink to the repo's checked-out branch.",
|
|
185
|
+
"Returns instance metadata including the tmux window. Model must be one enabled in pi (see agents_status).",
|
|
186
|
+
].join(" "),
|
|
187
|
+
promptSnippet: "Spawn an agent (persistent, adhoc, or from an agent def file) in tmux",
|
|
188
|
+
promptGuidelines: [
|
|
189
|
+
"Use spawn_agent when the user asks to launch/spin up an agent, delegate a task to a new agent session, or create a subagent.",
|
|
190
|
+
"Before spawn_agent with a model the user named loosely, call agents_status to see enabled models.",
|
|
191
|
+
"For adhoc subagents, write complete standalone instructions — the tmp agent knows nothing about this conversation.",
|
|
192
|
+
],
|
|
193
|
+
parameters: Type.Object({
|
|
194
|
+
agent: Type.String({ description: "Agent name (existing agent, or name for a new tmp agent)" }),
|
|
195
|
+
instructions: Type.Optional(Type.String({
|
|
196
|
+
description: "Full AGENTS.md content for a local/adhoc agent. Creates/updates <root>/local-agents/<agent>/soul.",
|
|
197
|
+
})),
|
|
198
|
+
defFile: Type.Optional(Type.String({
|
|
199
|
+
description: "Path to a single-file agent def (.claude/agents/<x>.md style, frontmatter + body) to import as a tmp agent",
|
|
200
|
+
})),
|
|
201
|
+
task: Type.Optional(Type.String({ description: "Task briefing written to the instance's TASK.md and given as the first prompt" })),
|
|
202
|
+
purpose: Type.Optional(Type.String({ description: "Short kebab slug describing what this instance is for; names the instance <agent>-<purpose>" })),
|
|
203
|
+
model: Type.Optional(Type.String({ description: "Model as provider/id (must be enabled in pi); defaults to the soul's model or pi's default" })),
|
|
204
|
+
repo: Type.Optional(Type.String({ description: "Target repo (absolute, or relative to the agents root's parent). Defaults to the soul's repo, else the current git repo" })),
|
|
205
|
+
work: Type.Optional(StringEnum(["worktree", "checkout"] as const, {
|
|
206
|
+
description: "worktree = own git worktree + branch; checkout = work directly on the repo's checked-out branch. Defaults to the soul's setting",
|
|
207
|
+
})),
|
|
208
|
+
runtime: Type.Optional(StringEnum(["pi", "claude"] as const, { description: "Session runtime (default: soul's setting, else pi)" })),
|
|
209
|
+
branch: Type.Optional(Type.String({ description: "Branch name for worktree mode (default: agents/<instance>)" })),
|
|
210
|
+
launch: Type.Optional(Type.Boolean({ description: "Launch in tmux (default true). false = scaffold only and return the launch command" })),
|
|
211
|
+
}),
|
|
212
|
+
async execute(_id, p, _signal, _onUpdate, ctx) {
|
|
213
|
+
const root = ensureRoot(ctx.cwd);
|
|
214
|
+
const model = validateModel(ctx, p.model);
|
|
215
|
+
|
|
216
|
+
let agent = findAgent(root, p.agent);
|
|
217
|
+
if (p.instructions !== undefined || p.defFile !== undefined || (!agent && !p.instructions)) {
|
|
218
|
+
if (!agent && p.instructions === undefined && p.defFile === undefined) {
|
|
219
|
+
// Maybe it matches a .claude/agents / .agents/agents def by name.
|
|
220
|
+
const def = listAgentDefs(ctx.cwd).find((d) => d.name === p.agent);
|
|
221
|
+
if (!def) {
|
|
222
|
+
const known = [...listAgents(root).map((a) => a.name), ...listAgentDefs(ctx.cwd).map((d) => d.name)];
|
|
223
|
+
throw new Error(`unknown agent "${p.agent}" — known: ${known.join(", ") || "(none)"}. Pass instructions to create a tmp agent.`);
|
|
224
|
+
}
|
|
225
|
+
agent = upsertTmpAgent(root, { name: def.name, file: def.path, repo: p.repo, work: p.work, runtime: p.runtime, model: p.model });
|
|
226
|
+
} else if (!agent || agent.kind === "tmp") {
|
|
227
|
+
agent = upsertTmpAgent(root, {
|
|
228
|
+
name: p.agent, instructions: p.instructions, file: p.defFile,
|
|
229
|
+
repo: p.repo, work: p.work, runtime: p.runtime, model: p.model,
|
|
230
|
+
});
|
|
231
|
+
} else if (p.instructions !== undefined) {
|
|
232
|
+
throw new Error(`"${p.agent}" is a persistent agent — edit its soul instead of passing instructions`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const result = spawnInstance(root, agent!, {
|
|
237
|
+
instance: undefined, purpose: p.purpose, repo: p.repo || agent!.repo || defaultRepo(ctx.cwd),
|
|
238
|
+
work: p.work, runtime: p.runtime, model, task: p.task, branch: p.branch, launch: p.launch,
|
|
239
|
+
});
|
|
240
|
+
return { ...text(JSON.stringify(result, null, 2)), details: result };
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
pi.registerTool({
|
|
245
|
+
name: "agents_status",
|
|
246
|
+
label: "Agents Status",
|
|
247
|
+
description: [
|
|
248
|
+
"List the closest agents/ root: agents (persistent + tmp) with their souls and instances (running = has a live tmux window),",
|
|
249
|
+
"importable single-file defs from .claude/agents and .agents/agents, and the models currently enabled in pi.",
|
|
250
|
+
].join(" "),
|
|
251
|
+
promptSnippet: "List agents, their running instances, importable defs, and enabled models",
|
|
252
|
+
promptGuidelines: [
|
|
253
|
+
"Use agents_status to discover what agents exist and which models are enabled before spawning.",
|
|
254
|
+
],
|
|
255
|
+
parameters: Type.Object({}),
|
|
256
|
+
async execute(_id, _p, _signal, _onUpdate, ctx) {
|
|
257
|
+
const root = findRoot(ctx.cwd);
|
|
258
|
+
const details = {
|
|
259
|
+
root: root ?? null,
|
|
260
|
+
workspace: root ? workspaceOf(root) : null,
|
|
261
|
+
agents: root ? listInstances(root) : [],
|
|
262
|
+
agentDefs: listAgentDefs(ctx.cwd),
|
|
263
|
+
enabledModels: availableModels(ctx),
|
|
264
|
+
};
|
|
265
|
+
return { ...text(JSON.stringify(details, null, 2)), details };
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
pi.registerTool({
|
|
270
|
+
name: "retire_agent",
|
|
271
|
+
label: "Retire Agent",
|
|
272
|
+
description: "Retire an agent instance: kill its tmux window, remove its git worktree (if any), and delete its instance dir. Optionally delete its branch.",
|
|
273
|
+
promptSnippet: "Retire an agent instance (tmux window + worktree + instance dir)",
|
|
274
|
+
parameters: Type.Object({
|
|
275
|
+
instance: Type.String({ description: "Instance name (see agents_status)" }),
|
|
276
|
+
deleteBranch: Type.Optional(Type.Boolean({ description: "Also delete the instance's git branch (worktree mode)" })),
|
|
277
|
+
keepDir: Type.Optional(Type.Boolean({ description: "Keep the instance directory on disk" })),
|
|
278
|
+
}),
|
|
279
|
+
async execute(_id, p, _signal, _onUpdate, ctx) {
|
|
280
|
+
const root = ensureRoot(ctx.cwd);
|
|
281
|
+
if (isInstance && p.instance === process.env.PI_AGENT_INSTANCE) {
|
|
282
|
+
return { ...text(`"${p.instance}" is YOU — use the retire_self tool instead (it harvests your notes and schedules your window kill safely).`), isError: true };
|
|
283
|
+
}
|
|
284
|
+
const result = retireInstance(root, p.instance, { deleteBranch: p.deleteBranch, keepDir: p.keepDir });
|
|
285
|
+
return { ...text(JSON.stringify(result, null, 2)), details: result };
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ---------- retire_self: only registered inside a spawned instance ----------
|
|
290
|
+
if (isInstance) {
|
|
291
|
+
pi.registerTool({
|
|
292
|
+
name: "retire_self",
|
|
293
|
+
label: "Retire Self",
|
|
294
|
+
description: "Retire THIS instance: run integration retire hooks (e.g. messaging identity self-delete, knowledge consolidation/harvest), remove the worktree and home dir, then kill this tmux window after a short delay. Irreversible. Use only when your task is complete (or your human told you to retire). Finish your memory FIRST per your knowledge integration's conventions (with okf: STATE.md, log.md, notes/) — retire hooks only take what is on disk when you call this.",
|
|
295
|
+
promptSnippet: "Retire this instance (harvest + cleanup + delayed window kill)",
|
|
296
|
+
parameters: Type.Object({
|
|
297
|
+
confirm: Type.Literal("retire", { description: 'Must be the string "retire" — confirms you mean to permanently retire this instance' }),
|
|
298
|
+
deleteBranch: Type.Optional(Type.Boolean({ description: "Also delete this instance's git branch (worktree mode)" })),
|
|
299
|
+
}),
|
|
300
|
+
async execute(_id, p, _signal, _onUpdate, ctx) {
|
|
301
|
+
const name = process.env.PI_AGENT_INSTANCE!;
|
|
302
|
+
const root = ensureRoot(ctx.cwd);
|
|
303
|
+
const result = retireInstance(root, name, { self: true, deleteBranch: p.deleteBranch });
|
|
304
|
+
return {
|
|
305
|
+
...text(`Retired. Harvested: ${JSON.stringify(result.harvested)}. This window dies in ~8s — say any goodbyes now.`),
|
|
306
|
+
details: result,
|
|
307
|
+
};
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------- /agents command ----------
|
|
313
|
+
pi.registerCommand("agents", {
|
|
314
|
+
description: "Spawn/list/retire agents from the closest agents/ directory",
|
|
315
|
+
handler: async (args, ctx) => {
|
|
316
|
+
if (!ctx.hasUI) { ctx.ui.notify("/agents needs an interactive UI", "error"); return; }
|
|
317
|
+
let root = findRoot(ctx.cwd);
|
|
318
|
+
const arg = (args || "").trim();
|
|
319
|
+
|
|
320
|
+
if (arg === "list" || (arg === "" && !root)) {
|
|
321
|
+
if (!root) { ctx.ui.notify(`No agents/ dir found above ${ctx.cwd} — mkdir agents, or set PI_AGENTS_ROOT`, "warning"); return; }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!root) return;
|
|
325
|
+
const agents = listInstances(root);
|
|
326
|
+
const defs = listAgentDefs(ctx.cwd).filter((d) => !agents.some((a) => a.name === d.name));
|
|
327
|
+
const running = agents.flatMap((a) => a.instances.filter((i: any) => i.running).map((i: any) => i.instance));
|
|
328
|
+
|
|
329
|
+
const items = [
|
|
330
|
+
...agents.map((a) => `spawn: ${a.name}${a.kind === "tmp" ? " (tmp)" : ""} — ${a.description || a.repo || ""}`),
|
|
331
|
+
...defs.map((d) => `import: ${d.name} — ${d.description || d.source}`),
|
|
332
|
+
"new adhoc agent…",
|
|
333
|
+
...(running.length ? ["retire an instance…"] : []),
|
|
334
|
+
"status",
|
|
335
|
+
];
|
|
336
|
+
const choice = await ctx.ui.select(`agents @ ${root}`, items);
|
|
337
|
+
if (!choice) return;
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
if (choice === "status") {
|
|
341
|
+
const lines = agents.flatMap((a) => [
|
|
342
|
+
`${a.kind === "tmp" ? "[tmp] " : ""}${a.name} — repo=${a.repo || "?"} work=${a.work} runtime=${a.runtime}${a.model ? ` model=${a.model}` : ""}`,
|
|
343
|
+
...a.instances.map((i: any) => ` ${i.running ? "●" : "○"} ${i.instance}${i.branch ? ` [${i.branch}]` : ""}`),
|
|
344
|
+
]);
|
|
345
|
+
ctx.ui.notify(lines.join("\n") || "no agents", "info");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (choice === "retire an instance…") {
|
|
350
|
+
const inst = await ctx.ui.select("Retire which instance?", running);
|
|
351
|
+
if (!inst) return;
|
|
352
|
+
const delBranch = await ctx.ui.confirm("Delete branch?", "Also delete the instance's git branch (if worktree)?");
|
|
353
|
+
const r = retireInstance(root, inst, { deleteBranch: delBranch });
|
|
354
|
+
ctx.ui.notify(`Retired ${r.retired}`, "info");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Model picker shared by all spawn paths.
|
|
359
|
+
const pickModel = async (soulModel?: string): Promise<string | undefined> => {
|
|
360
|
+
const models = availableModels(ctx);
|
|
361
|
+
const def = soulModel ? `soul default (${soulModel})` : "pi default";
|
|
362
|
+
const picked = await ctx.ui.select("Model for this instance:", [def, ...models]);
|
|
363
|
+
if (!picked || picked === def) return soulModel;
|
|
364
|
+
return picked;
|
|
365
|
+
};
|
|
366
|
+
const askTask = async () => (await ctx.ui.editor("Task briefing (TASK.md):", "")) || undefined;
|
|
367
|
+
|
|
368
|
+
if (choice === "new adhoc agent…") {
|
|
369
|
+
const name = await ctx.ui.input("Adhoc agent name:", "e.g. api-doc-writer");
|
|
370
|
+
if (!name) return;
|
|
371
|
+
const instructions = await ctx.ui.editor("Agent instructions (its AGENTS.md):", "# Role\n\nYou are ");
|
|
372
|
+
if (!instructions?.trim()) return;
|
|
373
|
+
const repo = await ctx.ui.input("Repo (abs or relative to workspace):", defaultRepo(ctx.cwd) || "");
|
|
374
|
+
const work = await ctx.ui.select("Work mode:", ["checkout (work on the repo's current branch)", "worktree (own worktree + branch)"]);
|
|
375
|
+
if (!work) return;
|
|
376
|
+
const model = await pickModel();
|
|
377
|
+
const agent = upsertTmpAgent(root, {
|
|
378
|
+
name, instructions, repo: repo || defaultRepo(ctx.cwd) || undefined,
|
|
379
|
+
work: work.startsWith("worktree") ? "worktree" : "checkout", model,
|
|
380
|
+
});
|
|
381
|
+
const r = spawnInstance(root, agent, { task: await askTask(), model });
|
|
382
|
+
ctx.ui.notify(`Launched ${r.instance} — ${r.attach}`, "info");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (choice.startsWith("import: ")) {
|
|
387
|
+
const name = choice.slice("import: ".length).split(" — ")[0];
|
|
388
|
+
const def = listAgentDefs(ctx.cwd).find((d) => d.name === name)!;
|
|
389
|
+
const repo = await ctx.ui.input("Repo (abs or relative to workspace):", defaultRepo(ctx.cwd) || "");
|
|
390
|
+
const agent = upsertTmpAgent(root, { name: def.name, file: def.path, repo: repo || defaultRepo(ctx.cwd) || undefined });
|
|
391
|
+
const model = await pickModel(agent.model);
|
|
392
|
+
const r = spawnInstance(root, agent, { task: await askTask(), model });
|
|
393
|
+
ctx.ui.notify(`Launched ${r.instance} — ${r.attach}`, "info");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (choice.startsWith("spawn: ")) {
|
|
398
|
+
const name = choice.slice("spawn: ".length).split(" — ")[0].replace(" (tmp)", "");
|
|
399
|
+
const agent = findAgent(root, name)!;
|
|
400
|
+
const purpose = await ctx.ui.input("Purpose slug (names the instance):", "e.g. fix-auth");
|
|
401
|
+
const model = await pickModel(agent.model);
|
|
402
|
+
const r = spawnInstance(root, agent, { purpose: purpose || undefined, model, task: await askTask() });
|
|
403
|
+
ctx.ui.notify(`Launched ${r.instance} — ${r.attach}`, "info");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
} catch (e: any) {
|
|
407
|
+
ctx.ui.notify(e.message, "error");
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ---------- convenience: create a persistent agent via tool too ----------
|
|
413
|
+
pi.registerTool({
|
|
414
|
+
name: "create_agent",
|
|
415
|
+
label: "Create Agent",
|
|
416
|
+
description: "Create a persistent agent soul under the closest agents/ directory (<root>/<name>/soul with soul.yaml + AGENTS.md). Does not spawn an instance.",
|
|
417
|
+
promptSnippet: "Create a persistent agent soul (soul.yaml + AGENTS.md) under agents/",
|
|
418
|
+
parameters: Type.Object({
|
|
419
|
+
name: Type.String(),
|
|
420
|
+
repo: Type.Optional(Type.String({ description: "Target repo (absolute, or relative to the agents root's parent)" })),
|
|
421
|
+
work: Type.Optional(StringEnum(["worktree", "checkout"] as const)),
|
|
422
|
+
runtime: Type.Optional(StringEnum(["pi", "claude"] as const)),
|
|
423
|
+
model: Type.Optional(Type.String({ description: "Default model as provider/id (must be enabled in pi)" })),
|
|
424
|
+
description: Type.Optional(Type.String()),
|
|
425
|
+
instructions: Type.Optional(Type.String({ description: "AGENTS.md content for the soul (default: a starter template)" })),
|
|
426
|
+
}),
|
|
427
|
+
async execute(_id, p, _signal, _onUpdate, ctx) {
|
|
428
|
+
const root = ensureRoot(ctx.cwd);
|
|
429
|
+
const model = validateModel(ctx, p.model);
|
|
430
|
+
const r = createAgent(root, { ...p, model });
|
|
431
|
+
if (p.instructions) {
|
|
432
|
+
writeSoul(root, { name: r.agent, kind: "persistent", repo: p.repo, work: p.work, runtime: p.runtime, model, description: p.description, instructions: p.instructions });
|
|
433
|
+
}
|
|
434
|
+
return { ...text(`Created soul: ${r.soul}\nEdit ${r.soul}/AGENTS.md to shape the agent, then spawn_agent.`), details: r };
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oas-framework/pi",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "OAS pi adapter — agent tools (spawn_agent, agents_status, retire_agent) and skill discovery for pi, over the globally installed @oas-framework/oas kernel",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"agents",
|
|
8
|
+
"oas"
|
|
9
|
+
],
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/josep-reyero/oas-framework",
|
|
13
|
+
"directory": "packages/pi"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"pi": {
|
|
18
|
+
"extensions": [
|
|
19
|
+
"./extension/index.ts"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"extension/",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-ai": "*",
|
|
28
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
29
|
+
"typebox": "*"
|
|
30
|
+
}
|
|
31
|
+
}
|