@getpipher/armory-fleet 0.2.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/LICENSE +21 -0
- package/README.md +31 -0
- package/agents/general-purpose.md +10 -0
- package/package.json +65 -0
- package/src/engine/child-loader.ts +47 -0
- package/src/engine/concurrency-lock.ts +24 -0
- package/src/engine/run-registry.ts +39 -0
- package/src/engine/spawnSubagent.ts +234 -0
- package/src/engine/turn-budget.ts +21 -0
- package/src/index.ts +118 -0
- package/src/memory-hydrate/adapter.ts +12 -0
- package/src/memory-hydrate/port.ts +13 -0
- package/src/panel/fleet-panel.ts +227 -0
- package/src/panel/rows.ts +53 -0
- package/src/registry/discovery.ts +83 -0
- package/src/registry/frontmatter.ts +69 -0
- package/src/todo-sync/adapter.ts +84 -0
- package/src/todo-sync/port.ts +41 -0
- package/src/tools/subagent.ts +75 -0
- package/src/vision/adapter.ts +51 -0
- package/src/vision/describe-image-tool.ts +34 -0
- package/src/vision/port.ts +19 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// src/panel/fleet-panel.ts
|
|
2
|
+
import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
Container,
|
|
5
|
+
Input,
|
|
6
|
+
SelectList,
|
|
7
|
+
Spacer,
|
|
8
|
+
Text,
|
|
9
|
+
matchesKey,
|
|
10
|
+
type SelectItem,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
13
|
+
import type { RunRecord } from "../engine/run-registry.ts";
|
|
14
|
+
import { fleetRow, agentsRow, agentInfo } from "./rows.ts";
|
|
15
|
+
import { spawnSubagent, type ChildSessionFactory, type SpawnResult } from "../engine/spawnSubagent.ts";
|
|
16
|
+
import type { RunRegistry } from "../engine/run-registry.ts";
|
|
17
|
+
import type { SingleSlotLock } from "../engine/concurrency-lock.ts";
|
|
18
|
+
import type { TodoSyncPort } from "../todo-sync/port.ts";
|
|
19
|
+
|
|
20
|
+
type View = "fleet" | "agents";
|
|
21
|
+
|
|
22
|
+
export interface FleetPanelDeps {
|
|
23
|
+
registry: Map<string, AgentDef>;
|
|
24
|
+
runRegistry: RunRegistry;
|
|
25
|
+
lock: SingleSlotLock;
|
|
26
|
+
todoSync: TodoSyncPort;
|
|
27
|
+
childFactory: ChildSessionFactory;
|
|
28
|
+
parentModel: { provider: string; id: string };
|
|
29
|
+
parentCwd: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FleetPanelOpts {
|
|
33
|
+
theme: Theme;
|
|
34
|
+
deps: FleetPanelDeps;
|
|
35
|
+
onDone: () => void;
|
|
36
|
+
onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class FleetPanel extends Container {
|
|
40
|
+
private readonly theme: Theme;
|
|
41
|
+
private readonly deps: FleetPanelDeps;
|
|
42
|
+
private readonly onDone: () => void;
|
|
43
|
+
private readonly onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
|
|
44
|
+
private view: View = "fleet";
|
|
45
|
+
private list: SelectList;
|
|
46
|
+
private runMode = false;
|
|
47
|
+
private taskInput: Input | null = null;
|
|
48
|
+
private linkInput: Input | null = null;
|
|
49
|
+
private linkPhase: "task" | "link" = "task";
|
|
50
|
+
private infoAgent: AgentDef | null = null;
|
|
51
|
+
|
|
52
|
+
constructor(opts: FleetPanelOpts) {
|
|
53
|
+
super();
|
|
54
|
+
this.theme = opts.theme;
|
|
55
|
+
this.deps = opts.deps;
|
|
56
|
+
this.onDone = opts.onDone;
|
|
57
|
+
this.onNotify = opts.onNotify;
|
|
58
|
+
|
|
59
|
+
const accent = (s: string): string => this.theme.fg("accent", s);
|
|
60
|
+
this.addChild(new DynamicBorder(accent));
|
|
61
|
+
this.addChild(new Spacer(1));
|
|
62
|
+
this.list = this.buildList();
|
|
63
|
+
this.renderShell();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private buildList(): SelectList {
|
|
67
|
+
const items: SelectItem[] =
|
|
68
|
+
this.view === "fleet"
|
|
69
|
+
? this.deps.runRegistry.list().map((r: RunRecord) => ({ value: r.runId, label: fleetRow(r) }))
|
|
70
|
+
: [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }));
|
|
71
|
+
const fresh = new SelectList(items, 12, {
|
|
72
|
+
selectedPrefix: (s: string) => this.theme.fg("accent", s),
|
|
73
|
+
selectedText: (s: string) => this.theme.fg("accent", s),
|
|
74
|
+
description: (s: string) => this.theme.fg("muted", s),
|
|
75
|
+
scrollInfo: (s: string) => this.theme.fg("dim", s),
|
|
76
|
+
noMatch: (s: string) => this.theme.fg("warning", s),
|
|
77
|
+
});
|
|
78
|
+
fresh.onSelect = (item: SelectItem) => this.onSelect(item.value);
|
|
79
|
+
fresh.onCancel = () => this.onDone();
|
|
80
|
+
return fresh;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private renderShell(): void {
|
|
84
|
+
const keep = this.children.slice(0, 2);
|
|
85
|
+
this.children.length = 0;
|
|
86
|
+
this.children.push(...keep);
|
|
87
|
+
const accent = (s: string): string => this.theme.fg("accent", s);
|
|
88
|
+
const tabs = (["fleet", "agents"] as View[])
|
|
89
|
+
.map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
|
|
90
|
+
.join(" ");
|
|
91
|
+
this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
|
|
92
|
+
this.addChild(new Spacer(1));
|
|
93
|
+
|
|
94
|
+
if (this.runMode && (this.taskInput || this.linkInput)) {
|
|
95
|
+
const prompt = this.linkPhase === "task" ? " task> " : " link to todo? (id or blank to create fleet task): ";
|
|
96
|
+
this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
|
|
97
|
+
this.addChild(this.linkPhase === "task" ? this.taskInput! : this.linkInput!);
|
|
98
|
+
this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
|
|
99
|
+
} else if (this.infoAgent) {
|
|
100
|
+
// i:Info read-only detail pane (agents view)
|
|
101
|
+
for (const line of agentInfo(this.infoAgent).split("\n")) {
|
|
102
|
+
this.addChild(new Text(this.theme.fg("text", line), 0, 0));
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
this.addChild(this.list);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.addChild(new Spacer(1));
|
|
109
|
+
const hint =
|
|
110
|
+
this.infoAgent
|
|
111
|
+
? " esc:Back"
|
|
112
|
+
: this.view === "fleet"
|
|
113
|
+
? " r:Run-new s:Stop o:Open-todo tab:Agents q:Quit"
|
|
114
|
+
: " r:Run e:Edit i:Info d:Reload tab:Fleet q:Quit";
|
|
115
|
+
this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
|
|
116
|
+
this.addChild(new Spacer(1));
|
|
117
|
+
this.addChild(new DynamicBorder(accent));
|
|
118
|
+
this.invalidate();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private onSelect(value: string): void {
|
|
122
|
+
if (this.view === "agents") this.startRun(value);
|
|
123
|
+
// Fleet view: selection is informational; actions are the `r`/`s`/`o` keys.
|
|
124
|
+
void value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private startRun(agentName: string): void {
|
|
128
|
+
this.linkPhase = "task";
|
|
129
|
+
this.taskInput = new Input();
|
|
130
|
+
this.taskInput.onSubmit = (task: string) => {
|
|
131
|
+
if (!task.trim()) { this.cancelRun(); return; }
|
|
132
|
+
this.linkPhase = "link";
|
|
133
|
+
this.linkInput = new Input();
|
|
134
|
+
this.linkInput.onSubmit = (todoIdRaw: string) => {
|
|
135
|
+
void this.executeRun(agentName, task.trim(), todoIdRaw.trim() || undefined);
|
|
136
|
+
};
|
|
137
|
+
this.linkInput.onEscape = () => { void this.executeRun(agentName, task.trim(), undefined); };
|
|
138
|
+
this.renderShell();
|
|
139
|
+
};
|
|
140
|
+
this.taskInput.onEscape = () => this.cancelRun();
|
|
141
|
+
this.runMode = true;
|
|
142
|
+
this.renderShell();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async executeRun(agent: string, task: string, todoId?: string): Promise<void> {
|
|
146
|
+
this.runMode = false;
|
|
147
|
+
this.taskInput = null;
|
|
148
|
+
this.linkInput = null;
|
|
149
|
+
this.renderShell();
|
|
150
|
+
const res: SpawnResult = await spawnSubagent({
|
|
151
|
+
agent, task, todoId, track: true,
|
|
152
|
+
registry: this.deps.registry, todoSync: this.deps.todoSync,
|
|
153
|
+
runRegistry: this.deps.runRegistry, lock: this.deps.lock,
|
|
154
|
+
childFactory: this.deps.childFactory,
|
|
155
|
+
parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
|
|
156
|
+
// live Fleet row during the run (SPEC-1 §4c) — re-render on each turn_end
|
|
157
|
+
onEvent: (e) => {
|
|
158
|
+
if (e.type === "turn_end") {
|
|
159
|
+
this.list = this.buildList();
|
|
160
|
+
this.renderShell();
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
this.list = this.buildList();
|
|
165
|
+
this.renderShell();
|
|
166
|
+
this.onNotify(
|
|
167
|
+
`${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`,
|
|
168
|
+
res.status === "completed" ? "info" : "warning",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private cancelRun(): void {
|
|
173
|
+
this.runMode = false;
|
|
174
|
+
this.taskInput = null;
|
|
175
|
+
this.linkInput = null;
|
|
176
|
+
this.renderShell();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private switchView(): void {
|
|
180
|
+
this.view = this.view === "fleet" ? "agents" : "fleet";
|
|
181
|
+
this.list = this.buildList();
|
|
182
|
+
this.renderShell();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
handleInput(data: string): void {
|
|
186
|
+
if (this.infoAgent) {
|
|
187
|
+
if (matchesKey(data, "escape")) { this.infoAgent = null; this.renderShell(); }
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (this.runMode && (this.taskInput || this.linkInput)) {
|
|
191
|
+
if (matchesKey(data, "escape")) { this.cancelRun(); return; }
|
|
192
|
+
(this.linkPhase === "task" ? this.taskInput! : this.linkInput!).handleInput(data);
|
|
193
|
+
this.invalidate();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (matchesKey(data, "escape")) { this.onDone(); return; }
|
|
197
|
+
if (matchesKey(data, "tab")) { this.switchView(); return; }
|
|
198
|
+
if (matchesKey(data, "q")) { this.onDone(); return; }
|
|
199
|
+
if (matchesKey(data, "r") && this.view === "agents") {
|
|
200
|
+
const sel = this.list.getSelectedItem();
|
|
201
|
+
if (sel) this.startRun(sel.value);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (matchesKey(data, "i") && this.view === "agents") {
|
|
205
|
+
const sel = this.list.getSelectedItem();
|
|
206
|
+
if (sel) { this.infoAgent = this.deps.registry.get(sel.value) ?? null; this.renderShell(); }
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this.list.handleInput(data);
|
|
210
|
+
this.invalidate();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Factory used by src/index.ts to open the panel via ctx.ui.custom. */
|
|
215
|
+
export function openFleetPanel(
|
|
216
|
+
deps: FleetPanelDeps,
|
|
217
|
+
ctx: {
|
|
218
|
+
ui: {
|
|
219
|
+
custom: (factory: (tui: unknown, theme: Theme, kb: unknown, done: () => void) => Container) => void;
|
|
220
|
+
notify: (m: string, t?: "info" | "warning" | "error") => void;
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
): void {
|
|
224
|
+
ctx.ui.custom((_tui, theme, _kb, done) => {
|
|
225
|
+
return new FleetPanel({ theme, deps, onDone: done, onNotify: (m, t) => ctx.ui.notify(m, t) });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/panel/rows.ts
|
|
2
|
+
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
3
|
+
import type { FleetRunStatus } from "../todo-sync/port.ts";
|
|
4
|
+
import type { RunRecord } from "../engine/run-registry.ts";
|
|
5
|
+
|
|
6
|
+
export function fmtDuration(ms: number): string {
|
|
7
|
+
const s = Math.floor(ms / 1000);
|
|
8
|
+
if (s < 60) return `${s}s`;
|
|
9
|
+
const m = Math.floor(s / 60);
|
|
10
|
+
return `${m}m${s % 60}s`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const STATUS_GLYPH: Record<FleetRunStatus, string> = {
|
|
14
|
+
running: "▶",
|
|
15
|
+
completed: "✓",
|
|
16
|
+
failed: "✗",
|
|
17
|
+
aborted: "✗",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function fleetRow(run: RunRecord, ctxPercent?: number): string {
|
|
21
|
+
const dur = run.endedAt ? fmtDuration(run.endedAt - run.startedAt) : "—";
|
|
22
|
+
const todo = run.todoId ? ` ${run.todoId}` : "";
|
|
23
|
+
const summary = run.resultSummary ? ` "${run.resultSummary}"` : "";
|
|
24
|
+
const ctx = ctxPercent !== undefined ? ` ${ctxPercent}% ctx` : "";
|
|
25
|
+
return `${STATUS_GLYPH[run.status]} ${run.runId} ${run.agent} ${run.status} ${dur}${ctx}${todo}${summary}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function agentsRow(agent: AgentDef): string {
|
|
29
|
+
const model = agent.model ?? "(default)";
|
|
30
|
+
const chip = `armory:[t${agent.todoSync ? "✓" : "✗"} m${agent.memoryHydrate ? "✓" : "✗"} v${agent.vision ? "✓" : "✗"}]`;
|
|
31
|
+
const skills = agent.skills?.length ? ` skills: ${agent.skills.join(",")}` : "";
|
|
32
|
+
const tools = agent.tools?.length ? ` tools: ${agent.tools.join(",")}` : "";
|
|
33
|
+
return `${agent.name} [${agent.source}] ${model}${tools}${skills} ${chip}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function agentInfo(agent: AgentDef): string {
|
|
37
|
+
const lines = [
|
|
38
|
+
`name: ${agent.name}`,
|
|
39
|
+
`source: ${agent.source}`,
|
|
40
|
+
`model: ${agent.model ?? "(default)"}`,
|
|
41
|
+
`thinkingLevel: ${agent.thinkingLevel ?? "(model default)"}`,
|
|
42
|
+
`tools: ${agent.tools?.length ? agent.tools.join(", ") : "(pi default)"}`,
|
|
43
|
+
`skills: ${agent.skills?.length ? agent.skills.join(", ") : "(none)"}`,
|
|
44
|
+
`todoSync: ${agent.todoSync ? "✓" : "✗"}`,
|
|
45
|
+
`memoryHydrate: ${agent.memoryHydrate ? "✓" : "✗"}`,
|
|
46
|
+
`vision: ${agent.vision ? "✓" : "✗"}`,
|
|
47
|
+
`file: ${agent.filePath}`,
|
|
48
|
+
"",
|
|
49
|
+
"── role prompt ──",
|
|
50
|
+
agent.rolePrompt.trim(),
|
|
51
|
+
];
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// src/registry/discovery.ts
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseAgentFile, type AgentDef, FrontmatterError } from "./frontmatter.ts";
|
|
5
|
+
|
|
6
|
+
export interface DiscoverOpts {
|
|
7
|
+
projectDir: string | null;
|
|
8
|
+
globalDir: string | null;
|
|
9
|
+
builtinDir: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DiscoverResult {
|
|
13
|
+
agents: Map<string, AgentDef>;
|
|
14
|
+
warnings: string[];
|
|
15
|
+
errors: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Recursively collect *.md file paths under dir, with a realpath visited-set
|
|
19
|
+
* guard against symlink cycles (trusted dev env, but cheap insurance). */
|
|
20
|
+
function collectMarkdown(dir: string): string[] {
|
|
21
|
+
const out: string[] = [];
|
|
22
|
+
const visited = new Set<string>();
|
|
23
|
+
const walk = (d: string): void => {
|
|
24
|
+
if (!existsSync(d)) return;
|
|
25
|
+
let real: string;
|
|
26
|
+
try {
|
|
27
|
+
real = realpathSync(d);
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (visited.has(real)) return;
|
|
32
|
+
visited.add(real);
|
|
33
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
34
|
+
const full = join(d, entry.name);
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
walk(full);
|
|
37
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
38
|
+
out.push(full);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
walk(dir);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Load order: builtin -> global -> project (later wins on name; same-scope dup = error). */
|
|
47
|
+
export function discoverAgents(opts: DiscoverOpts): DiscoverResult {
|
|
48
|
+
const agents = new Map<string, AgentDef>();
|
|
49
|
+
const warnings: string[] = [];
|
|
50
|
+
const errors: string[] = [];
|
|
51
|
+
|
|
52
|
+
const loadScope = (dir: string | null, source: AgentDef["source"]): void => {
|
|
53
|
+
if (!dir) return;
|
|
54
|
+
const files = collectMarkdown(dir).sort(); // stable order for collision reporting
|
|
55
|
+
for (const f of files) {
|
|
56
|
+
let content: string;
|
|
57
|
+
try {
|
|
58
|
+
content = readFileSync(f, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
warnings.push(`${f}: unreadable file, skipped`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const def = parseAgentFile(content, f, source);
|
|
65
|
+
const existing = agents.get(def.name);
|
|
66
|
+
if (existing && existing.source === source) {
|
|
67
|
+
errors.push(`duplicate agent '${def.name}' in ${source} scope (${f}); first kept`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// cross-scope override (project over global/builtin) is fine
|
|
71
|
+
agents.set(def.name, def);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e instanceof FrontmatterError) warnings.push(e.message);
|
|
74
|
+
else warnings.push(`${f}: ${String(e)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
loadScope(opts.builtinDir, "builtin");
|
|
80
|
+
loadScope(opts.globalDir, "global");
|
|
81
|
+
loadScope(opts.projectDir, "project"); // project overrides
|
|
82
|
+
return { agents, warnings, errors };
|
|
83
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/registry/frontmatter.ts
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
import { basename, extname } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type AgentSource = "builtin" | "project" | "global";
|
|
6
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
7
|
+
|
|
8
|
+
export interface AgentDef {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
model?: string;
|
|
12
|
+
thinkingLevel?: ThinkingLevel;
|
|
13
|
+
tools?: string[];
|
|
14
|
+
skills?: string[];
|
|
15
|
+
rolePrompt: string;
|
|
16
|
+
todoSync: boolean;
|
|
17
|
+
memoryHydrate: boolean;
|
|
18
|
+
vision: boolean;
|
|
19
|
+
source: AgentSource;
|
|
20
|
+
filePath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class FrontmatterError extends Error {
|
|
24
|
+
override name = "FrontmatterError" as const;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
28
|
+
|
|
29
|
+
export function parseAgentFile(content: string, filePath: string, source: AgentSource): AgentDef {
|
|
30
|
+
const m = FRONTMATTER_RE.exec(content);
|
|
31
|
+
if (!m || m[1] === undefined || m[2] === undefined) {
|
|
32
|
+
throw new FrontmatterError(`${filePath}: missing --- frontmatter delimiters`);
|
|
33
|
+
}
|
|
34
|
+
let raw: Record<string, unknown>;
|
|
35
|
+
try {
|
|
36
|
+
raw = (parseYaml(m[1]) ?? {}) as Record<string, unknown>;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
throw new FrontmatterError(`${filePath}: invalid YAML (${(e as Error).message})`);
|
|
39
|
+
}
|
|
40
|
+
const body = m[2];
|
|
41
|
+
|
|
42
|
+
const name = typeof raw.name === "string" && raw.name.trim()
|
|
43
|
+
? raw.name.trim()
|
|
44
|
+
: basename(filePath, extname(filePath));
|
|
45
|
+
const description = typeof raw.description === "string" ? raw.description.trim() : "";
|
|
46
|
+
if (!description) throw new FrontmatterError(`${filePath}: description is required`);
|
|
47
|
+
|
|
48
|
+
const strList = (v: unknown): string[] | undefined =>
|
|
49
|
+
Array.isArray(v) ? v.map((x) => String(x)) : undefined;
|
|
50
|
+
|
|
51
|
+
const todoSync = raw.todoSync === undefined ? true : Boolean(raw.todoSync);
|
|
52
|
+
const memoryHydrate = raw.memoryHydrate === undefined ? true : Boolean(raw.memoryHydrate);
|
|
53
|
+
const vision = raw.vision === undefined ? true : Boolean(raw.vision);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
name,
|
|
57
|
+
description,
|
|
58
|
+
model: typeof raw.model === "string" ? raw.model : undefined,
|
|
59
|
+
thinkingLevel: typeof raw.thinkingLevel === "string" ? (raw.thinkingLevel as ThinkingLevel) : undefined,
|
|
60
|
+
tools: strList(raw.tools),
|
|
61
|
+
skills: strList(raw.skills),
|
|
62
|
+
rolePrompt: body,
|
|
63
|
+
todoSync,
|
|
64
|
+
memoryHydrate,
|
|
65
|
+
vision,
|
|
66
|
+
source,
|
|
67
|
+
filePath,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// src/todo-sync/adapter.ts
|
|
2
|
+
import {
|
|
3
|
+
addTodo,
|
|
4
|
+
getTodo,
|
|
5
|
+
updateTodo,
|
|
6
|
+
type Status,
|
|
7
|
+
} from "@getpipher/armory-todo";
|
|
8
|
+
import type { LinkResult, RunMeta, TodoSyncPort } from "./port.ts";
|
|
9
|
+
|
|
10
|
+
const FLEET_PROJECT = "fleet";
|
|
11
|
+
const FLEET_SOURCE = "armory-fleet";
|
|
12
|
+
const FLEET_TAG = "fleet-run";
|
|
13
|
+
const OPEN_STATES: Status[] = ["open", "in_progress"];
|
|
14
|
+
|
|
15
|
+
function titleFor(run: RunMeta): string {
|
|
16
|
+
const raw = `[${run.agent}] ${run.task}`.trim();
|
|
17
|
+
return raw.length > 120 ? raw.slice(0, 117) + "…" : raw;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Append a note line to a todo (read-then-write; updateTodo replaces notes). */
|
|
21
|
+
function appendNote(id: string, line: string): void {
|
|
22
|
+
const t = getTodo(id);
|
|
23
|
+
const sep = t.notes ? "\n\n" : "";
|
|
24
|
+
updateTodo(id, { notes: t.notes + sep + line });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Add the fleet-run tag if missing (read-then-write; updateTodo replaces tags). */
|
|
28
|
+
function ensureFleetTag(id: string): void {
|
|
29
|
+
const t = getTodo(id);
|
|
30
|
+
if (!t.tags.includes(FLEET_TAG)) updateTodo(id, { tags: [...t.tags, FLEET_TAG] });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ArmoryTodoAdapter implements TodoSyncPort {
|
|
34
|
+
async linkOrCreateRunTodo(run: RunMeta): Promise<LinkResult> {
|
|
35
|
+
if (!run.track) return { todoId: null };
|
|
36
|
+
|
|
37
|
+
if (run.todoId) {
|
|
38
|
+
const t = getTodo(run.todoId);
|
|
39
|
+
if (!OPEN_STATES.includes(t.status)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`linked todo ${run.todoId} is ${t.status}; cannot start run against a closed todo`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const priorStatus = t.status;
|
|
45
|
+
ensureFleetTag(run.todoId);
|
|
46
|
+
appendNote(run.todoId, `fleet-run:${run.runId}`);
|
|
47
|
+
updateTodo(run.todoId, { status: "in_progress" });
|
|
48
|
+
return { todoId: run.todoId, priorStatus: String(priorStatus) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const created = addTodo({
|
|
52
|
+
title: titleFor(run),
|
|
53
|
+
project: FLEET_PROJECT,
|
|
54
|
+
source: FLEET_SOURCE,
|
|
55
|
+
priority: "med",
|
|
56
|
+
tags: [FLEET_TAG],
|
|
57
|
+
notes: `fleet-run:${run.runId}\n\nTask: ${run.task}`,
|
|
58
|
+
});
|
|
59
|
+
updateTodo(created.id, { status: "in_progress" });
|
|
60
|
+
return { todoId: created.id }; // priorStatus undefined -> created
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async markRunTodoDone(todoId: string | null, priorStatus: string | undefined, result: string): Promise<void> {
|
|
64
|
+
if (!todoId) return;
|
|
65
|
+
if (priorStatus === undefined) {
|
|
66
|
+
// fleet-created -> fleet closes it
|
|
67
|
+
updateTodo(todoId, { status: "done" });
|
|
68
|
+
} else {
|
|
69
|
+
// linked -> restore prior (user owns the close)
|
|
70
|
+
updateTodo(todoId, { status: priorStatus as Status });
|
|
71
|
+
}
|
|
72
|
+
appendNote(todoId, `fleet-run done: ${result}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async markRunTodoReverted(todoId: string | null, priorStatus: string | undefined, reason: string): Promise<void> {
|
|
76
|
+
if (!todoId) return;
|
|
77
|
+
if (priorStatus === undefined) {
|
|
78
|
+
updateTodo(todoId, { status: "open" }); // created -> retryable
|
|
79
|
+
} else {
|
|
80
|
+
updateTodo(todoId, { status: priorStatus as Status });
|
|
81
|
+
}
|
|
82
|
+
appendNote(todoId, `fleet-run reverted: ${reason}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/todo-sync/port.ts
|
|
2
|
+
/**
|
|
3
|
+
* Fleet-owned todo-sync contract. Fleet core depends only on this port;
|
|
4
|
+
* ArmoryTodoAdapter (src/todo-sync/adapter.ts) is the sole importer of
|
|
5
|
+
* @getpipher/armory-todo. This insulation is what makes armory-todo
|
|
6
|
+
* evolution safe — see SPEC-1 §6 / §2.2.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type FleetRunStatus = "running" | "completed" | "failed" | "aborted";
|
|
10
|
+
|
|
11
|
+
/** Minimum info the engine passes the port to link-or-create a run's todo. */
|
|
12
|
+
export interface RunMeta {
|
|
13
|
+
runId: string;
|
|
14
|
+
agent: string;
|
|
15
|
+
task: string;
|
|
16
|
+
/** explicit link to an existing open/in_progress todo; undefined = create. */
|
|
17
|
+
todoId?: string;
|
|
18
|
+
/** tracked-by-default (SPEC-1 Q3b); false = do not touch armory-todo. */
|
|
19
|
+
track: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Implementations return the linked/created todoId (or null when untracked)
|
|
24
|
+
* and, for a linked todo, its prior status so the engine can restore it.
|
|
25
|
+
* priorStatus is a string (armory-todo's Status union) to keep the port
|
|
26
|
+
* decoupled from armory-todo's types.
|
|
27
|
+
*/
|
|
28
|
+
export interface LinkResult {
|
|
29
|
+
todoId: string | null;
|
|
30
|
+
/** undefined when the todo was freshly created (no prior status exists). */
|
|
31
|
+
priorStatus?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TodoSyncPort {
|
|
35
|
+
/** Before the run: link to todoId (validate open/in_progress) or create a fleet task. */
|
|
36
|
+
linkOrCreateRunTodo(run: RunMeta): Promise<LinkResult>;
|
|
37
|
+
/** After a completed run: fleet-created -> done; linked -> restore prior + result note. */
|
|
38
|
+
markRunTodoDone(todoId: string | null, priorStatus: string | undefined, result: string): Promise<void>;
|
|
39
|
+
/** After a failed/aborted run: fleet-created -> open; linked -> restore prior. + reason note. */
|
|
40
|
+
markRunTodoReverted(todoId: string | null, priorStatus: string | undefined, reason: string): Promise<void>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/tools/subagent.ts
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
4
|
+
import type { TodoSyncPort } from "../todo-sync/port.ts";
|
|
5
|
+
import type { RunRegistry } from "../engine/run-registry.ts";
|
|
6
|
+
import type { SingleSlotLock } from "../engine/concurrency-lock.ts";
|
|
7
|
+
import type { ChildSessionFactory, SpawnResult } from "../engine/spawnSubagent.ts";
|
|
8
|
+
import { spawnSubagent } from "../engine/spawnSubagent.ts";
|
|
9
|
+
|
|
10
|
+
export const subagentParams = Type.Object({
|
|
11
|
+
agent: Type.String({ description: "Agent name from the registry (builtin, project, or global)." }),
|
|
12
|
+
task: Type.String({ description: "The prompt to hand the child subagent." }),
|
|
13
|
+
todoId: Type.Optional(Type.String({ description: "Explicit link to an existing open/in_progress armory-todo todo. Omit to create a fleet task." })),
|
|
14
|
+
track: Type.Optional(Type.Boolean({ description: "Default true. Pass false only for throwaway lookups that don't represent real work." })),
|
|
15
|
+
model: Type.Optional(Type.String({ description: 'Override the agent model, e.g. "anthropic/claude-sonnet-4".' })),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type SubagentInput = Static<typeof subagentParams>;
|
|
19
|
+
|
|
20
|
+
export interface SubagentToolDeps {
|
|
21
|
+
registry: Map<string, AgentDef>;
|
|
22
|
+
runRegistry: RunRegistry;
|
|
23
|
+
lock: SingleSlotLock;
|
|
24
|
+
todoSync: TodoSyncPort;
|
|
25
|
+
childFactory: ChildSessionFactory;
|
|
26
|
+
parentModel: { provider: string; id: string };
|
|
27
|
+
parentCwd: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
|
|
31
|
+
export function createSubagentTool(deps: SubagentToolDeps) {
|
|
32
|
+
return {
|
|
33
|
+
name: "subagent",
|
|
34
|
+
label: "Subagent",
|
|
35
|
+
description: "Delegate a task to a named armory-native subagent (foreground, synchronous). The run is tracked in armory-todo by default.",
|
|
36
|
+
promptSnippet: "Delegate a focused task to a subagent",
|
|
37
|
+
promptGuidelines: [
|
|
38
|
+
"Use subagent to delegate an isolated, well-scoped task to a named agent; it runs in the foreground and returns the result + a runId.",
|
|
39
|
+
"Pass todoId to link the run to an existing open todo you see in the Open TODOs block; otherwise fleet creates a tracked fleet task.",
|
|
40
|
+
"Pass track:false only for trivial throwaway lookups that don't represent real work.",
|
|
41
|
+
],
|
|
42
|
+
parameters: subagentParams,
|
|
43
|
+
async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
44
|
+
const res: SpawnResult = await spawnSubagent({
|
|
45
|
+
agent: params.agent,
|
|
46
|
+
task: params.task,
|
|
47
|
+
todoId: params.todoId,
|
|
48
|
+
track: params.track,
|
|
49
|
+
model: params.model,
|
|
50
|
+
registry: deps.registry,
|
|
51
|
+
todoSync: deps.todoSync,
|
|
52
|
+
runRegistry: deps.runRegistry,
|
|
53
|
+
lock: deps.lock,
|
|
54
|
+
childFactory: deps.childFactory,
|
|
55
|
+
parentModel: deps.parentModel,
|
|
56
|
+
parentCwd: deps.parentCwd,
|
|
57
|
+
signal,
|
|
58
|
+
onEvent: (e) => {
|
|
59
|
+
if (ctx?.ui?.setWidget && e.type === "turn_end") {
|
|
60
|
+
ctx.ui.setWidget("fleet", [`▶ ${params.agent} · running`]);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const isError = res.status === "failed" || res.status === "aborted";
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: "text" as const, text: isError ? (res.error ?? res.status) : res.finalText }],
|
|
67
|
+
details: {
|
|
68
|
+
runId: res.runId, todoId: res.todoId, agent: res.agent, model: res.model,
|
|
69
|
+
status: res.status, durationMs: res.durationMs, tokenTotal: res.tokenTotal,
|
|
70
|
+
},
|
|
71
|
+
isError,
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/vision/adapter.ts — ONLY file importing @getpipher/vision.
|
|
2
|
+
import {
|
|
3
|
+
isMultimodal,
|
|
4
|
+
createVisionDelegator,
|
|
5
|
+
type VisionConfig,
|
|
6
|
+
type DelegateResult,
|
|
7
|
+
type ModelRegistryLike,
|
|
8
|
+
} from "@getpipher/vision";
|
|
9
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { VisionPort, VisionDelegateParams, VisionDelegateResult } from "./port.ts";
|
|
11
|
+
|
|
12
|
+
export interface ArmoryVisionAdapterDeps {
|
|
13
|
+
/** A ModelRegistry (or the { find, getApiKeyAndHeaders } slice). Fleet constructs new ModelRegistry(modelRuntime). */
|
|
14
|
+
modelRegistry: ModelRegistryLike;
|
|
15
|
+
/** The cwd for image path resolution. */
|
|
16
|
+
cwd: string;
|
|
17
|
+
/** The pi agent dir (where vision.json lives). */
|
|
18
|
+
agentDir: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ArmoryVisionAdapter implements VisionPort {
|
|
22
|
+
private readonly delegator: ReturnType<typeof createVisionDelegator>;
|
|
23
|
+
constructor(deps: ArmoryVisionAdapterDeps) {
|
|
24
|
+
this.delegator = createVisionDelegator({ modelRegistry: deps.modelRegistry, cwd: deps.cwd, agentDir: deps.agentDir });
|
|
25
|
+
}
|
|
26
|
+
isMultimodal(model: Model<any> | undefined): boolean {
|
|
27
|
+
return isMultimodal(model);
|
|
28
|
+
}
|
|
29
|
+
isConfigured(): boolean {
|
|
30
|
+
const c = this.delegator.config as VisionConfig;
|
|
31
|
+
return Boolean(c.enabled && c.provider && c.model);
|
|
32
|
+
}
|
|
33
|
+
async delegate(params: VisionDelegateParams, signal?: AbortSignal): Promise<VisionDelegateResult> {
|
|
34
|
+
if (!this.isConfigured()) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: "no vision model configured; run `/vision model <id>` in the host or set `vision: false` on this agent.",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const result: DelegateResult = await this.delegator.delegate(
|
|
41
|
+
{
|
|
42
|
+
image_path: params.imagePath,
|
|
43
|
+
prompt: params.prompt ?? "",
|
|
44
|
+
compress: true,
|
|
45
|
+
reasoning: this.delegator.config.defaultReasoningEffort ?? "off",
|
|
46
|
+
},
|
|
47
|
+
signal,
|
|
48
|
+
);
|
|
49
|
+
return result.ok ? { ok: true, text: result.text } : { ok: false, error: result.error.message };
|
|
50
|
+
}
|
|
51
|
+
}
|