@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RECTOR <rector@rectorspace.com>
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,31 @@
1
+ # @getpipher/armory-fleet
2
+
3
+ > The armory suite's subagent orchestrator for the [pi coding agent](https://github.com/earendil-works/pi-coding-agent) — a cross-harness, superpowers-native fleet where every agent is **armory-native from birth**.
4
+
5
+ `@getpipher/armory-fleet` brings Claude-Code-style autonomous sub-agents to pi, built so that the armory ecosystem ([armory-todo](https://github.com/getpipher/armory-todo), [armory-memory](https://github.com/getpipher/armory-memory), [vision](https://github.com/getpipher/vision), [cursor](https://github.com/getpipher/cursor)) is the *default substrate* agents run on — not a bolt-on.
6
+
7
+ ## Why
8
+
9
+ The pi-subagent ecosystem is crowded (nicobailon/pi-subagents at 2688⭐, tintinweb at 702⭐, QuintinShaw at 287⭐, and more). Three structural gaps remain open:
10
+
11
+ 1. **Armory-native integration** — no external package can integrate with the armory suite; they don't own it. Agents that sync to armory-todo, hydrate from armory-memory, see via vision, and edit via cursor *by default* are uncopyable.
12
+ 2. **Cross-harness peers** — only one attempt (kky42/pi-flow, 66⭐) runs Claude Code + Pi as peer subagent backends, and it's early. A dual-arsenal topology deserves a first-class implementation.
13
+ 3. **Superpowers-native lifecycle** — only one attempt (teelicht/pi-superagents, 54⭐) wraps the superpowers skill pipeline, and it's synchronous-only.
14
+
15
+ `armory-fleet` owns all three, then reaches parity on the rest (fleet TUI, scheduling, worktree isolation, cost accounting, quality patterns, workflows-as-code, event-bus/RPC) — to be the best pi-subagent package in the ecosystem.
16
+
17
+ ## Status
18
+
19
+ 🚧 **Pre-implementation.** The master PRD + 7-SPEC roadmap are written and self-reviewed; SPEC-1 (core engine + armory-todo sync) is the next brainstorm cycle.
20
+
21
+ - **Master PRD:** [`PRD.md`](./PRD.md)
22
+ - **Landscape research:** [`research/`](./research) (11+ packages mapped, 5 contenders deep-read)
23
+ - **Roadmap:** PRD → SPEC-1 (core+todo-sync) → SPEC-2 (deep armory) → SPEC-3 (cross-harness) → SPEC-4 (superpowers lifecycle) → SPEC-5a (ops) → SPEC-5b (fleet TUI) → SPEC-6 (power-user → v1.0)
24
+
25
+ ## Compatibility
26
+
27
+ pi `^0.81.1`.
28
+
29
+ ## License
30
+
31
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: general-purpose
3
+ description: A focused general-purpose subagent delegate. Use for any task needing isolated work.
4
+ todoSync: true
5
+ memoryHydrate: true
6
+ vision: true
7
+ ---
8
+ You are a focused subagent delegate. Complete the assigned task thoroughly, work autonomously to completion, and return a concise result summary.
9
+
10
+ Do not call the `todo` tool — the fleet engine manages todo tracking for you.
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@getpipher/armory-fleet",
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "author": "RECTOR (https://github.com/rz1989s)",
9
+ "keywords": [
10
+ "pi-package",
11
+ "pi-extension",
12
+ "pi",
13
+ "pi-coding-agent",
14
+ "subagents",
15
+ "ai-agents",
16
+ "multi-agent",
17
+ "orchestration",
18
+ "agent-orchestration",
19
+ "claude-code",
20
+ "superpowers",
21
+ "armory",
22
+ "cross-harness"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/getpipher/armory-fleet.git"
27
+ },
28
+ "homepage": "https://github.com/getpipher/armory-fleet#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/getpipher/armory-fleet/issues"
31
+ },
32
+ "exports": {
33
+ ".": "./src/index.ts"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "agents",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "pi": {
42
+ "extensions": [
43
+ "./src/index.ts"
44
+ ]
45
+ },
46
+ "dependencies": {
47
+ "@earendil-works/pi-coding-agent": "^0.81.1",
48
+ "@earendil-works/pi-tui": "^0.81.1",
49
+ "@earendil-works/pi-ai": "^0.81.1",
50
+ "@getpipher/armory-todo": "^0.5.4",
51
+ "@getpipher/armory-memory": "^0.1.1",
52
+ "@getpipher/vision": "^0.5.2",
53
+ "typebox": "^1.1.38",
54
+ "yaml": "^2.5.0"
55
+ },
56
+ "devDependencies": {
57
+ "tsx": "^4.19.0",
58
+ "typescript": "^5.6.0",
59
+ "@types/node": "^22.0.0"
60
+ },
61
+ "scripts": {
62
+ "typecheck": "tsc --noEmit",
63
+ "test:run": "node --import tsx --test test/*.test.mts"
64
+ }
65
+ }
@@ -0,0 +1,47 @@
1
+ // src/engine/child-loader.ts — the fleet CustomResourceLoader builder.
2
+ // Promotes SPEC-1's DefaultResourceLoader-with-overrides to deliberate control:
3
+ // noExtensions (deterministic child, no host-extension leakage), composed
4
+ // systemPromptOverride (rolePrompt + memoryBlock + base), scoped skills.
5
+ import { DefaultResourceLoader, getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { dirname } from "node:path";
7
+ import type { AgentDef } from "../registry/frontmatter.ts";
8
+ import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
9
+
10
+ /** Fixed pseudo-cwd for the global cross-project user memory scope. */
11
+ export const USER_PSEUDO_CWD = "/__armory-fleet-user__";
12
+
13
+ /** Compose the child system prompt: rolePrompt → memoryBlock → base (empty memoryBlock omitted). */
14
+ export function composeChildPrompt(args: { rolePrompt: string; memoryBlock: string; base: string }): string {
15
+ return [args.rolePrompt, args.memoryBlock, args.base].filter((s) => s && s.trim().length > 0).join("\n\n");
16
+ }
17
+
18
+ /** Build the three memory scopes for a child: project=cwd, local=parent dir, user=sentinel. */
19
+ export function memoryScopesFor(cwd: string): { project: string; local: string; user: string } {
20
+ return { project: cwd, local: dirname(cwd) || cwd, user: USER_PSEUDO_CWD };
21
+ }
22
+
23
+ export interface ChildLoaderOpts {
24
+ cwd: string;
25
+ agent: AgentDef;
26
+ memoryPort: MemoryHydratePort;
27
+ }
28
+
29
+ /** Build the fleet CustomResourceLoader for a child session. */
30
+ export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader {
31
+ const scopes = memoryScopesFor(opts.cwd);
32
+ const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(scopes) : "";
33
+ return new DefaultResourceLoader({
34
+ cwd: opts.cwd,
35
+ agentDir: getAgentDir(),
36
+ noExtensions: true,
37
+ systemPromptOverride: (base) =>
38
+ composeChildPrompt({ rolePrompt: opts.agent.rolePrompt, memoryBlock, base: base ?? "" }),
39
+ skillsOverride: (cur) => ({
40
+ skills:
41
+ opts.agent.skills && opts.agent.skills.length
42
+ ? cur.skills.filter((s) => opts.agent.skills!.includes(s.name))
43
+ : cur.skills,
44
+ diagnostics: cur.diagnostics,
45
+ }),
46
+ });
47
+ }
@@ -0,0 +1,24 @@
1
+ // src/engine/concurrency-lock.ts
2
+ export interface SingleSlotLock {
3
+ /** true if acquired; false if busy (call .current() for the running id). */
4
+ tryAcquire(id: string): boolean;
5
+ release(): void;
6
+ current(): string | null;
7
+ }
8
+
9
+ export function createSingleSlotLock(): SingleSlotLock {
10
+ let holding: string | null = null;
11
+ return {
12
+ tryAcquire(id): boolean {
13
+ if (holding !== null) return false;
14
+ holding = id;
15
+ return true;
16
+ },
17
+ release(): void {
18
+ holding = null;
19
+ },
20
+ current(): string | null {
21
+ return holding;
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,39 @@
1
+ // src/engine/run-registry.ts
2
+ import type { FleetRunStatus } from "../todo-sync/port.ts";
3
+
4
+ export interface RunRecord {
5
+ runId: string;
6
+ agent: string;
7
+ model: string;
8
+ task: string;
9
+ track: boolean;
10
+ todoId: string | null;
11
+ status: FleetRunStatus;
12
+ startedAt: number;
13
+ endedAt?: number;
14
+ resultSummary?: string;
15
+ }
16
+
17
+ /** runId format: fl-<base36 ms>-<6 random> (SPEC-1 §5.1). */
18
+ export function genRunId(): string {
19
+ return "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
20
+ }
21
+
22
+ export class RunRegistry {
23
+ private readonly runs = new Map<string, RunRecord>();
24
+
25
+ add(r: RunRecord): void {
26
+ this.runs.set(r.runId, r);
27
+ }
28
+ get(id: string): RunRecord | undefined {
29
+ return this.runs.get(id);
30
+ }
31
+ /** Newest-first (by startedAt desc). */
32
+ list(): RunRecord[] {
33
+ return [...this.runs.values()].sort((a, b) => b.startedAt - a.startedAt);
34
+ }
35
+ update(id: string, patch: Partial<Omit<RunRecord, "runId">>): void {
36
+ const r = this.runs.get(id);
37
+ if (r) this.runs.set(id, { ...r, ...patch });
38
+ }
39
+ }
@@ -0,0 +1,234 @@
1
+ // src/engine/spawnSubagent.ts
2
+ import type { AgentDef, ThinkingLevel } from "../registry/frontmatter.ts";
3
+ import type { FleetRunStatus, TodoSyncPort } from "../todo-sync/port.ts";
4
+ import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
5
+ import type { VisionPort } from "../vision/port.ts";
6
+ import { genRunId, RunRegistry } from "./run-registry.ts";
7
+ import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
8
+ import type { SingleSlotLock } from "./concurrency-lock.ts";
9
+
10
+ const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
11
+
12
+ /** No-op ports used when a caller omits them (e.g. SPEC-1 unit tests). Production (index.ts) passes real ports. */
13
+ const NOOP_MEMORY_PORT: MemoryHydratePort = { renderScopes: () => "" };
14
+ const NOOP_VISION_PORT: VisionPort = {
15
+ isMultimodal: () => false,
16
+ isConfigured: () => false,
17
+ delegate: async () => ({ ok: false, error: "no vision port configured" }),
18
+ };
19
+
20
+ /** Minimal event shape the engine reads from a child session (decoupled from pi's internal event types). */
21
+ export interface ChildSessionEvent {
22
+ type: string;
23
+ message?: {
24
+ role?: string;
25
+ content?: Array<{ type: string; text?: string }>;
26
+ usage?: { cost?: { total?: number } };
27
+ };
28
+ }
29
+
30
+ export interface ChildSession {
31
+ prompt(text: string): Promise<void>;
32
+ subscribe(handler: (event: ChildSessionEvent) => void): () => void;
33
+ abort(): Promise<void>;
34
+ dispose(): void;
35
+ }
36
+
37
+ export interface ChildSessionOpts {
38
+ cwd: string;
39
+ model?: string;
40
+ thinkingLevel?: ThinkingLevel;
41
+ tools: string[];
42
+ rolePrompt: string;
43
+ skills: string[];
44
+ task: string;
45
+ agent: AgentDef;
46
+ memoryPort: MemoryHydratePort;
47
+ visionPort: VisionPort;
48
+ }
49
+
50
+ export interface ChildSessionFactory {
51
+ create(opts: ChildSessionOpts): Promise<{ session: ChildSession; model: string }>;
52
+ }
53
+
54
+ export interface SpawnOptions {
55
+ agent: string;
56
+ task: string;
57
+ todoId?: string;
58
+ track?: boolean; // default true
59
+ model?: string; // override
60
+ maxTurns?: number; // default 20
61
+ registry: Map<string, AgentDef>;
62
+ todoSync: TodoSyncPort;
63
+ runRegistry: RunRegistry;
64
+ lock: SingleSlotLock;
65
+ childFactory: ChildSessionFactory;
66
+ parentModel: { provider: string; id: string };
67
+ parentCwd: string;
68
+ memoryPort?: MemoryHydratePort;
69
+ visionPort?: VisionPort;
70
+ signal?: AbortSignal;
71
+ onEvent?: (e: ChildSessionEvent) => void;
72
+ }
73
+
74
+ export interface SpawnResult {
75
+ status: FleetRunStatus;
76
+ finalText: string;
77
+ runId: string;
78
+ todoId: string | null;
79
+ agent: string;
80
+ model: string;
81
+ durationMs: number;
82
+ tokenTotal: number;
83
+ error?: string;
84
+ }
85
+
86
+ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
87
+ const track = opts.track ?? true;
88
+ const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
89
+ const startedAt = Date.now();
90
+
91
+ // concurrency=1 (SPEC-1 §9.2) — peek before generating a runId so a rejected
92
+ // call doesn't mint a discarded id; the held id is named in the message.
93
+ const busyId = opts.lock.current();
94
+ if (busyId !== null) {
95
+ return fail("", startedAt, `a subagent is already running (concurrency=1 in v0.1); wait for ${busyId} to finish or abort it first`, opts.agent);
96
+ }
97
+ const runId = genRunId();
98
+ if (!opts.lock.tryAcquire(runId)) {
99
+ return fail("", startedAt, "concurrency lock unexpectedly unavailable", opts.agent);
100
+ }
101
+
102
+ try {
103
+ // resolve agent
104
+ const agentDef = opts.registry.get(opts.agent);
105
+ if (!agentDef) {
106
+ const available = [...opts.registry.keys()].sort().join(", ");
107
+ return fail(runId, startedAt, `agent '${opts.agent}' not in registry; available: ${available}`, opts.agent);
108
+ }
109
+
110
+ // resolve model
111
+ const model = opts.model ?? agentDef.model ?? `${opts.parentModel.provider}/${opts.parentModel.id}`;
112
+
113
+ // child tools pass through UNFILTERED — the single-writer `todo`-exclusion is enforced
114
+ // downstream by the child factory's `excludeTools: ["todo"]` (SPEC-2 §9.1 hardening).
115
+ const tools = agentDef.tools ?? PI_DEFAULT_TOOLS;
116
+ const memoryPort = opts.memoryPort ?? NOOP_MEMORY_PORT;
117
+ const visionPort = opts.visionPort ?? NOOP_VISION_PORT;
118
+
119
+ // run record
120
+ opts.runRegistry.add({
121
+ runId, agent: agentDef.name, model, task: opts.task, track,
122
+ todoId: null, status: "running", startedAt,
123
+ });
124
+
125
+ // todo-sync (before) — only when both caller tracks AND agent allows todoSync
126
+ let priorStatus: string | undefined;
127
+ let todoId: string | null = null;
128
+ try {
129
+ const link = await opts.todoSync.linkOrCreateRunTodo({
130
+ runId, agent: agentDef.name, task: opts.task,
131
+ todoId: opts.todoId, track: track && agentDef.todoSync,
132
+ });
133
+ todoId = link.todoId;
134
+ priorStatus = link.priorStatus;
135
+ opts.runRegistry.update(runId, { todoId });
136
+ } catch (e) {
137
+ return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, (e as Error).message, agentDef.name, model);
138
+ }
139
+
140
+ // spawn child
141
+ const { session } = await opts.childFactory.create({
142
+ cwd: opts.parentCwd,
143
+ model,
144
+ thinkingLevel: agentDef.thinkingLevel,
145
+ tools,
146
+ rolePrompt: agentDef.rolePrompt,
147
+ skills: agentDef.skills ?? [],
148
+ task: opts.task,
149
+ agent: agentDef,
150
+ memoryPort,
151
+ visionPort,
152
+ });
153
+
154
+ const budget = createTurnBudget(maxTurns);
155
+ let finalText = "";
156
+ let tokenTotal = 0;
157
+ let aborted = false;
158
+
159
+ const onSignalAbort = (): void => { aborted = true; void session.abort(); };
160
+ opts.signal?.addEventListener("abort", onSignalAbort);
161
+
162
+ const unsub = session.subscribe((e) => {
163
+ if (e.type === "turn_end") {
164
+ if (budget.consume()) void session.abort();
165
+ } else if (e.type === "message_end" && e.message?.role === "assistant") {
166
+ const text = e.message.content?.map((c) => (c.type === "text" ? c.text ?? "" : "")).join("") ?? "";
167
+ if (text) finalText = text;
168
+ const total = e.message.usage?.cost?.total;
169
+ if (typeof total === "number") tokenTotal += total;
170
+ }
171
+ opts.onEvent?.(e);
172
+ });
173
+
174
+ let runError: string | undefined;
175
+ try {
176
+ await session.prompt(opts.task);
177
+ } catch (e) {
178
+ runError = (e as Error).message;
179
+ } finally {
180
+ unsub();
181
+ opts.signal?.removeEventListener("abort", onSignalAbort);
182
+ session.dispose();
183
+ }
184
+
185
+ let status: FleetRunStatus;
186
+ let error: string | undefined;
187
+ if (aborted) {
188
+ status = "aborted";
189
+ error = "aborted by user";
190
+ } else if (budget.count() >= maxTurns) {
191
+ status = "failed";
192
+ error = `hit turn budget (${maxTurns}) mid-task; partial result: ${finalText.slice(0, 200)}`;
193
+ } else if (runError) {
194
+ status = "failed";
195
+ error = runError;
196
+ } else {
197
+ status = "completed";
198
+ }
199
+
200
+ return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal);
201
+ } finally {
202
+ opts.lock.release();
203
+ }
204
+ }
205
+
206
+ function fail(runId: string, startedAt: number, message: string, agent: string): SpawnResult {
207
+ return {
208
+ status: "failed", finalText: "", runId, todoId: null, agent,
209
+ model: "", durationMs: Date.now() - startedAt, tokenTotal: 0, error: message,
210
+ };
211
+ }
212
+
213
+ async function finishRun(
214
+ opts: SpawnOptions, runId: string, startedAt: number,
215
+ status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
216
+ error: string | undefined, agentName: string, model: string, tokenTotal = 0,
217
+ ): Promise<SpawnResult> {
218
+ const endedAt = Date.now();
219
+ opts.runRegistry.update(runId, { status, endedAt, resultSummary: finalText.slice(0, 120) });
220
+ // todo-sync reconciliation must not mask the run result
221
+ try {
222
+ if (status === "completed") {
223
+ await opts.todoSync.markRunTodoDone(todoId, priorStatus, finalText.slice(0, 500));
224
+ } else {
225
+ await opts.todoSync.markRunTodoReverted(todoId, priorStatus, error ?? status);
226
+ }
227
+ } catch {
228
+ // swallow — the run result is authoritative; the finally in spawnSubagent releases the lock
229
+ }
230
+ return {
231
+ status, finalText, runId, todoId, agent: agentName, model,
232
+ durationMs: endedAt - startedAt, tokenTotal, error,
233
+ };
234
+ }
@@ -0,0 +1,21 @@
1
+ // src/engine/turn-budget.ts
2
+ export const DEFAULT_MAX_TURNS = 20;
3
+
4
+ export interface TurnBudget {
5
+ /** Returns true when the just-consumed turn meets/exceeds the cap. */
6
+ consume(): boolean;
7
+ count(): number;
8
+ }
9
+
10
+ export function createTurnBudget(max: number = DEFAULT_MAX_TURNS): TurnBudget {
11
+ let n = 0;
12
+ return {
13
+ consume(): boolean {
14
+ n += 1;
15
+ return n >= max;
16
+ },
17
+ count(): number {
18
+ return n;
19
+ },
20
+ };
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,118 @@
1
+ // src/index.ts
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ createAgentSession,
5
+ ModelRuntime,
6
+ ModelRegistry,
7
+ SessionManager,
8
+ getAgentDir,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import type { Model } from "@earendil-works/pi-ai";
11
+ import { createSubagentTool, type SubagentToolDeps } from "./tools/subagent.ts";
12
+ import { openFleetPanel } from "./panel/fleet-panel.ts";
13
+ import { discoverAgents } from "./registry/discovery.ts";
14
+ import { RunRegistry } from "./engine/run-registry.ts";
15
+ import { createSingleSlotLock } from "./engine/concurrency-lock.ts";
16
+ import { ArmoryTodoAdapter } from "./todo-sync/adapter.ts";
17
+ import { ArmoryMemoryAdapter } from "./memory-hydrate/adapter.ts";
18
+ import { ArmoryVisionAdapter } from "./vision/adapter.ts";
19
+ import { buildChildLoader } from "./engine/child-loader.ts";
20
+ import { createDescribeImageTool } from "./vision/describe-image-tool.ts";
21
+ import type { MemoryHydratePort } from "./memory-hydrate/port.ts";
22
+ import type { VisionPort } from "./vision/port.ts";
23
+ import type { ChildSessionFactory, ChildSession } from "./engine/spawnSubagent.ts";
24
+ import { join } from "node:path";
25
+
26
+ /** The package builtin agents/ dir, resolved relative to this module. */
27
+ function builtinAgentsDir(): string {
28
+ return join(new URL(".", import.meta.url).pathname, "..", "agents");
29
+ }
30
+
31
+ /** Build the real (SDK-backed) child-session factory. memoryPort is shared (cwd-agnostic);
32
+ * the vision adapter is constructed per-spawn (needs the child cwd). */
33
+ function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort): ChildSessionFactory {
34
+ return {
35
+ async create(opts) {
36
+ let model: Model<any> | undefined;
37
+ if (opts.model) {
38
+ const slash = opts.model.indexOf("/");
39
+ if (slash < 0) throw new Error(`agent model '${opts.model}' must be 'provider/id'`);
40
+ const provider = opts.model.slice(0, slash);
41
+ const id = opts.model.slice(slash + 1);
42
+ model = modelRuntime.getModel(provider, id);
43
+ if (!model) throw new Error(`agent model '${opts.model}' not found in runtime (provider '${provider}', id '${id}')`);
44
+ }
45
+ // Fleet CustomResourceLoader: noExtensions + composed systemPromptOverride (rolePrompt + memory + base) + scoped skills.
46
+ const loader = buildChildLoader({ cwd: opts.cwd, agent: opts.agent, memoryPort });
47
+ await loader.reload();
48
+ // Vision adapter built per-spawn (needs the child cwd); ModelRegistry wraps the shared modelRuntime.
49
+ const visionPort: VisionPort = new ArmoryVisionAdapter({
50
+ modelRegistry: new ModelRegistry(modelRuntime),
51
+ cwd: opts.cwd,
52
+ agentDir: getAgentDir(),
53
+ });
54
+ const injectVision = opts.agent.vision && !visionPort.isMultimodal(model);
55
+ const { session } = await createAgentSession({
56
+ cwd: opts.cwd,
57
+ model,
58
+ thinkingLevel: opts.thinkingLevel,
59
+ tools: opts.tools,
60
+ excludeTools: ["todo"], // SPEC-2 §9.1 hardened single-writer guard
61
+ customTools: injectVision ? [createDescribeImageTool(visionPort) as never] : [],
62
+ resourceLoader: loader,
63
+ sessionManager: SessionManager.inMemory(),
64
+ modelRuntime,
65
+ });
66
+ return { session: session as unknown as ChildSession, model: opts.model ?? "" };
67
+ },
68
+ };
69
+ }
70
+
71
+ export default async function (pi: ExtensionAPI): Promise<void> {
72
+ const modelRuntime = await ModelRuntime.create();
73
+ const deps: SubagentToolDeps = {
74
+ registry: new Map(),
75
+ runRegistry: new RunRegistry(),
76
+ lock: createSingleSlotLock(),
77
+ todoSync: new ArmoryTodoAdapter(),
78
+ childFactory: createChildSessionFactory(modelRuntime, new ArmoryMemoryAdapter()),
79
+ parentModel: { provider: "", id: "" },
80
+ parentCwd: "",
81
+ };
82
+
83
+ const refresh = (ctx: { cwd: string; ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): void => {
84
+ const r = discoverAgents({
85
+ projectDir: join(ctx.cwd, ".pi", "agents"),
86
+ globalDir: join(process.env.HOME ?? "", ".pi", "agent", "agents"),
87
+ builtinDir: builtinAgentsDir(),
88
+ });
89
+ for (const e of r.errors) ctx.ui.notify(e, "error");
90
+ for (const w of r.warnings) ctx.ui.notify(w, "warning");
91
+ deps.registry = r.agents;
92
+ };
93
+
94
+ pi.on("session_start", (_event, ctx) => {
95
+ refresh(ctx);
96
+ const m = ctx.model;
97
+ deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
98
+ deps.parentCwd = ctx.cwd;
99
+ });
100
+
101
+ pi.on("resources_discover", (event, ctx) => {
102
+ if (event.reason === "reload") refresh(ctx);
103
+ return undefined;
104
+ });
105
+
106
+ pi.registerTool(createSubagentTool(deps) as never);
107
+
108
+ pi.registerCommand("fleet", {
109
+ description: "Open the armory-fleet panel (running + recent subagents + agent registry).",
110
+ handler: async (_args, ctx) => {
111
+ if (ctx.mode !== "tui") {
112
+ ctx.ui.notify("fleet panel is TUI-only; use the subagent tool in non-interactive modes.", "info");
113
+ return;
114
+ }
115
+ openFleetPanel(deps, ctx as never);
116
+ },
117
+ });
118
+ }
@@ -0,0 +1,12 @@
1
+ // src/memory-hydrate/adapter.ts — ONLY file importing @getpipher/armory-memory.
2
+ import { renderMemoryBlock, listMemory } from "@getpipher/armory-memory";
3
+ import type { MemoryHydratePort, MemoryScopes } from "./port.ts";
4
+
5
+ export class ArmoryMemoryAdapter implements MemoryHydratePort {
6
+ renderScopes(scopes: MemoryScopes): string {
7
+ return [scopes.project, scopes.local, scopes.user]
8
+ .filter((cwd) => listMemory(cwd).length > 0) // skip empty scopes cleanly (no placeholder to render)
9
+ .map((cwd) => renderMemoryBlock(cwd)) // armory-memory's existing cwd-keyed primitive
10
+ .join("\n\n"); // → "" when all three empty
11
+ }
12
+ }
@@ -0,0 +1,13 @@
1
+ // src/memory-hydrate/port.ts — fleet-owned port; fleet core depends only on this.
2
+ export interface MemoryScopes {
3
+ /** The cwd the child works in (= parentCwd in SPEC-2; the project cwd at SPEC-5a worktree). */
4
+ project: string;
5
+ /** Immediate parent directory of the project cwd (workspace/org level). */
6
+ local: string;
7
+ /** Fixed pseudo-cwd for global cross-project user memory. */
8
+ user: string;
9
+ }
10
+ export interface MemoryHydratePort {
11
+ /** Render the three-scope memory block (project → local → user), concatenated. Empty string when all scopes empty. */
12
+ renderScopes(scopes: MemoryScopes): string;
13
+ }