@getpipher/armory-fleet 0.2.0 → 0.3.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.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: general-purpose-cc
3
+ description: A focused general-purpose CC subagent. Use for any task needing Claude Code as the worker.
4
+ backend: claude
5
+ todoSync: true
6
+ memoryHydrate: true
7
+ vision: true
8
+ ---
9
+ You are a focused subagent delegate running under Claude Code. Complete the assigned task
10
+ thoroughly, work autonomously to completion, and return a concise result summary.
11
+ Do not call the `todo` tool — the fleet engine manages todo tracking for you.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
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
6
  "license": "MIT",
@@ -0,0 +1,75 @@
1
+ // src/backend/claude-detector.ts — version + stream-json schema smoke + flag-support probe (SPEC-3 §5).
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { mapClaudeEvent } from "./claude-events.ts";
5
+ import type { BackendVersionInfo } from "./registry.ts";
6
+
7
+ const DEFAULT_BIN = "claude";
8
+
9
+ export interface DetectOpts {
10
+ /** Fixture hook: an arg passed to the fake-claude via FLEET_FAKE_CLAUDE_PROBE env to select init-ok/init-drift. */
11
+ schemaProbeArg?: string;
12
+ }
13
+
14
+ function run(bin: string, args: string[], env?: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string; code: number | null }> {
15
+ return new Promise((resolve) => {
16
+ const child = spawn(bin, args, { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });
17
+ let stdout = "";
18
+ let stderr = "";
19
+ child.stdout?.on("data", (d) => { stdout += d.toString(); });
20
+ child.stderr?.on("data", (d) => { stderr += d.toString(); });
21
+ child.on("close", (code) => resolve({ stdout, stderr, code }));
22
+ // Capture the spawn error message into stderr so the caller can detect ENOENT (binary missing).
23
+ child.on("error", (err) => resolve({ stdout: "", stderr: err.message, code: null }));
24
+ });
25
+ }
26
+
27
+ function parseVersion(stdout: string): string {
28
+ const m = stdout.trim().match(/(\d+\.\d+\.\d+)/);
29
+ return m && m[1] ? m[1] : stdout.trim();
30
+ }
31
+
32
+ function probeFlags(helpText: string): Record<string, boolean> {
33
+ const has = (flag: string): boolean => new RegExp(`(^|\\s)${flag.replace(/-/g, "\\-")}(\\s|$)`).test(helpText);
34
+ return {
35
+ "--disallowed-tools": has("--disallowed-tools"),
36
+ "--allowed-tools": has("--allowed-tools"),
37
+ "--max-turns": has("--max-turns"),
38
+ "--resume": has("--resume"),
39
+ "--append-system-prompt": has("--append-system-prompt"),
40
+ "--output-format": has("--output-format"),
41
+ };
42
+ }
43
+
44
+ export async function detectClaude(bin: string = DEFAULT_BIN, opts: DetectOpts = {}): Promise<BackendVersionInfo | null> {
45
+ // Missing binary check: for an explicit path that doesn't exist, return null;
46
+ // for the default `claude` on PATH, the version run below resolves ENOENT → null.
47
+ if (bin !== DEFAULT_BIN && !existsSync(bin)) return null;
48
+ const versionRun = await run(bin, ["--version"]);
49
+ if (versionRun.code === null && /ENOENT/i.test(versionRun.stderr)) return null;
50
+ if (versionRun.code !== 0 && !versionRun.stdout) {
51
+ return { version: "", schemaOk: false, flagSupport: {}, note: `claude --version failed (code ${versionRun.code})` };
52
+ }
53
+ const version = parseVersion(versionRun.stdout);
54
+
55
+ // Schema smoke: spawn a throwaway ping in stream-json mode; scan the lines for a `system/init` event with session_id.
56
+ // (Real CC emits hook_started/hook_response system events before init; first-line-only would false-fail.)
57
+ const env = opts.schemaProbeArg ? { FLEET_FAKE_CLAUDE_PROBE: opts.schemaProbeArg } : undefined;
58
+ const smoke = await run(bin, ["-p", "--verbose", "--output-format", "stream-json", "ping"], env);
59
+ let schemaOk = false;
60
+ let note: string | undefined;
61
+ const lines = smoke.stdout.split("\n").filter((l) => l.trim());
62
+ for (const line of lines) {
63
+ const ev = mapClaudeEvent(line);
64
+ if (ev && ev.type === "session_init" && ev.backendSessionId) { schemaOk = true; break; }
65
+ }
66
+ if (!schemaOk) {
67
+ note = lines.length ? `schema drift (no init event with session_id; first line: ${lines[0]!.slice(0, 80)})` : "schema drift (no output emitted)";
68
+ }
69
+
70
+ // Flag-support probe (only meaningful if --help works).
71
+ const helpRun = await run(bin, ["--help"]);
72
+ const flagSupport = helpRun.code === 0 ? probeFlags(helpRun.stdout) : {};
73
+
74
+ return { version, schemaOk, flagSupport, note };
75
+ }
@@ -0,0 +1,39 @@
1
+ // src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent (SPEC-3 §4.2).
2
+ // Returns null for: filtered echoes (our own user writes), unknown types, malformed lines.
3
+ // The caller logs null-but-parseable lines at debug (forward-compat: CC may add types we don't need).
4
+ import type { ChildSessionEvent } from "../engine/spawnSubagent.ts";
5
+
6
+ interface CCMessage { role?: string; content?: Array<{ type: string; text?: string }>; usage?: Record<string, unknown>; }
7
+ interface CCEvent { type: string; subtype?: string; session_id?: string; message?: CCMessage; error?: { message?: string }; }
8
+
9
+ export function mapClaudeEvent(line: string): ChildSessionEvent | null {
10
+ let ev: CCEvent;
11
+ try {
12
+ ev = JSON.parse(line) as CCEvent;
13
+ } catch {
14
+ return null; // malformed line — resilient
15
+ }
16
+ switch (ev.type) {
17
+ case "system":
18
+ if (ev.subtype === "init" && typeof ev.session_id === "string") {
19
+ return { type: "session_init", backendSessionId: ev.session_id };
20
+ }
21
+ return null;
22
+ case "assistant": {
23
+ const msg = ev.message;
24
+ if (!msg) return null;
25
+ const content = (msg.content ?? []).map((c) => ({ type: c.type, text: c.text }));
26
+ const usage = msg.usage as { cost?: { total?: number } } | undefined;
27
+ return { type: "message_end", message: { role: msg.role ?? "assistant", content, usage } };
28
+ }
29
+ case "result":
30
+ // turn boundary (success or error_max_turns) → turn_end drives the budget
31
+ return { type: "turn_end" };
32
+ case "error":
33
+ return { type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } };
34
+ case "user":
35
+ return null; // echo of our own stdin write — filtered
36
+ default:
37
+ return null; // unknown — forward-compat, caller logs at debug
38
+ }
39
+ }
@@ -0,0 +1,50 @@
1
+ // src/backend/claude-factory.ts — createClaudeChildFactory (SPEC-3 §4.1, §4.5, §4.6, §9.1).
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import type { ChildSessionFactory, ChildSessionOpts } from "../engine/spawnSubagent.ts";
4
+ import type { BackendVersionInfo } from "./registry.ts";
5
+ import type { ResumeStore } from "./resume-store.ts";
6
+ import { ClaudeChildSession } from "./claude-session.ts";
7
+ import { memoryScopesFor } from "../engine/child-loader.ts";
8
+
9
+ export interface ClaudeFactoryOverrides {
10
+ /** Test hook: called instead of `spawn` to inspect args. Returns a ChildProcess-shaped stub. */
11
+ spawnOverride?: (args: string[]) => ChildProcess;
12
+ }
13
+
14
+ export function createClaudeChildFactory(
15
+ detector: BackendVersionInfo | null,
16
+ resumeStore: ResumeStore,
17
+ bin: string = "claude",
18
+ overrides: ClaudeFactoryOverrides = {},
19
+ ): ChildSessionFactory {
20
+ return {
21
+ async create(opts: ChildSessionOpts): Promise<{ session: ClaudeChildSession; model: string }> {
22
+ if (!detector?.schemaOk) {
23
+ throw new Error(`claude backend unavailable: ${detector?.note ?? "schema not ok"}`);
24
+ }
25
+ const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(memoryScopesFor(opts.cwd)) : "";
26
+ const sys = memoryBlock ? `${opts.rolePrompt}\n\n${memoryBlock}` : opts.rolePrompt;
27
+ const resumeId = resumeStore.get("claude", opts.agent.sessionKey);
28
+
29
+ const args: string[] = ["-p", "--output-format", "stream-json", "--input-format", "stream-json", "--verbose"];
30
+ if (opts.model) args.push("--model", opts.model);
31
+ args.push("--append-system-prompt", sys);
32
+ // todo exclusion: prefer --disallowed-tools; fall back to --allowed-tools allow-list when the agent pins tools.
33
+ if (detector.flagSupport["--disallowed-tools"]) {
34
+ args.push("--disallowed-tools", "todo");
35
+ } else if (detector.flagSupport["--allowed-tools"] && opts.tools.length) {
36
+ const allowed = opts.tools.filter((t) => t !== "todo").join(",");
37
+ args.push("--allowed-tools", allowed);
38
+ }
39
+ // v0.3 leaves maxTurns to the engine's turn_end belt (no --max-turns flag; avoids double-enforcement).
40
+ if (resumeId && detector.flagSupport["--resume"]) args.push("--resume", resumeId);
41
+ args.push(opts.task);
42
+
43
+ const proc = overrides.spawnOverride
44
+ ? overrides.spawnOverride(args)
45
+ : spawn(bin, args, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
46
+ const session = new ClaudeChildSession(proc, opts.agent.sessionKey, resumeStore);
47
+ return { session, model: opts.model ?? "" };
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,75 @@
1
+ // src/backend/claude-session.ts — ChildSession over a claude -p child process (SPEC-3 §4.4, §4.3).
2
+ import type { ChildProcess } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import type { ChildSession, ChildSessionEvent } from "../engine/spawnSubagent.ts";
5
+ import { mapClaudeEvent } from "./claude-events.ts";
6
+ import type { ResumeStore } from "./resume-store.ts";
7
+
8
+ export class ClaudeChildSession implements ChildSession {
9
+ private readonly proc: ChildProcess;
10
+ private readonly sessionKey: string;
11
+ private readonly resumeStore: ResumeStore;
12
+ private readonly handlers: Array<(e: ChildSessionEvent) => void> = [];
13
+ private disposed = false;
14
+ private initCaptured = false;
15
+ private turnResolve: (() => void) | null = null;
16
+
17
+ constructor(proc: ChildProcess, sessionKey: string, resumeStore: ResumeStore) {
18
+ this.proc = proc;
19
+ this.sessionKey = sessionKey;
20
+ this.resumeStore = resumeStore;
21
+ const rl = createInterface({ input: proc.stdout! });
22
+ rl.on("line", (line) => this.onLine(line));
23
+ proc.on("close", () => { if (this.turnResolve) { const r = this.turnResolve; this.turnResolve = null; r(); } });
24
+ }
25
+
26
+ private onLine(line: string): void {
27
+ const ev = mapClaudeEvent(line);
28
+ if (!ev) return;
29
+ if (ev.type === "session_init" && ev.backendSessionId && !this.initCaptured) {
30
+ this.initCaptured = true;
31
+ this.resumeStore.set("claude", this.sessionKey, ev.backendSessionId);
32
+ }
33
+ if (ev.type === "turn_end" || ev.type === "error") {
34
+ if (this.turnResolve) { const r = this.turnResolve; this.turnResolve = null; r(); }
35
+ }
36
+ for (const h of this.handlers) h(ev);
37
+ }
38
+
39
+ async prompt(text: string): Promise<void> {
40
+ if (this.disposed) throw new Error("session disposed");
41
+ if (!this.proc.stdin) throw new Error("claude child has no stdin pipe");
42
+ const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } }) + "\n";
43
+ return new Promise<void>((resolve) => {
44
+ this.turnResolve = resolve;
45
+ this.proc.stdin!.write(msg, () => { /* fire-and-forget; resolved on turn_end/close */ });
46
+ });
47
+ }
48
+
49
+ subscribe(handler: (e: ChildSessionEvent) => void): () => void {
50
+ this.handlers.push(handler);
51
+ return () => {
52
+ const i = this.handlers.indexOf(handler);
53
+ if (i >= 0) this.handlers.splice(i, 1);
54
+ };
55
+ }
56
+
57
+ async abort(): Promise<void> {
58
+ if (this.disposed) return;
59
+ try { this.proc.kill("SIGTERM"); } catch { /* already dead */ }
60
+ this.disposed = true; // SIGTERM kills the child process; the session is irrecoverable
61
+ }
62
+
63
+ dispose(): void {
64
+ if (this.disposed) return;
65
+ this.disposed = true;
66
+ try { this.proc.kill("SIGKILL"); } catch { /* already dead */ }
67
+ this.proc.stdout?.destroy();
68
+ try { this.proc.stdin?.end(); } catch { /* already closed */ }
69
+ this.proc.removeAllListeners();
70
+ }
71
+
72
+ isDisposed(): boolean {
73
+ return this.disposed;
74
+ }
75
+ }
@@ -0,0 +1,19 @@
1
+ // src/backend/hook-parity.ts — declared per-backend hook parity (SPEC-3 §2.3, §4.6).
2
+ // The chip is a static backend property, never inferred at spawn time.
3
+
4
+ export type HookState = "✓" | "~";
5
+
6
+ export interface BackendHookParity {
7
+ /** `todo` tool excluded from the child. */
8
+ todo: HookState;
9
+ /** memory-hydrate (3-scope) active in the child. */
10
+ memory: HookState;
11
+ /** vision: capability-aware. `✓` = full (describe_image fallback); `~` = pass-through only. */
12
+ vision: HookState;
13
+ }
14
+
15
+ /** Pi backend: full moat via loader injection + customTools (SPEC-2). */
16
+ export const PI_HOOK_PARITY: BackendHookParity = { todo: "✓", memory: "✓", vision: "✓" };
17
+
18
+ /** CC backend: moat via prompt/flag translation. Vision has no describe_image fallback (`~`). */
19
+ export const CLAUDE_HOOK_PARITY: BackendHookParity = { todo: "✓", memory: "✓", vision: "~" };
@@ -0,0 +1,5 @@
1
+ // src/backend/port.ts — single import surface for engine + views (SPEC-3 §3).
2
+ export type { BackendHookParity, HookState } from "./hook-parity.ts";
3
+ export { PI_HOOK_PARITY, CLAUDE_HOOK_PARITY } from "./hook-parity.ts";
4
+ export type { Backend, BackendVersionInfo } from "./registry.ts";
5
+ export { BackendRegistry } from "./registry.ts";
@@ -0,0 +1,36 @@
1
+ // src/backend/registry.ts — BackendRegistry + Backend descriptor (SPEC-3 §2.1).
2
+ import type { ChildSessionFactory } from "../engine/spawnSubagent.ts";
3
+ import type { BackendHookParity } from "./hook-parity.ts";
4
+
5
+ export interface BackendVersionInfo {
6
+ version: string;
7
+ schemaOk: boolean;
8
+ /** Flag support matrix probed at detect time (kebab-case flag → supported?). */
9
+ flagSupport: Record<string, boolean>;
10
+ note?: string;
11
+ }
12
+
13
+ export interface Backend {
14
+ id: "pi" | "claude";
15
+ factory: ChildSessionFactory;
16
+ available: () => boolean;
17
+ versionInfo: () => BackendVersionInfo | null;
18
+ hookParity: BackendHookParity;
19
+ }
20
+
21
+ export class BackendRegistry {
22
+ private readonly backends = new Map<string, Backend>();
23
+ private readonly order: string[] = [];
24
+
25
+ register(b: Backend): void {
26
+ if (!this.backends.has(b.id)) this.order.push(b.id);
27
+ this.backends.set(b.id, b);
28
+ }
29
+ get(id: string): Backend | undefined {
30
+ return this.backends.get(id);
31
+ }
32
+ /** Registration-order list — the data source for the Backends view + engine lookup. */
33
+ list(): Backend[] {
34
+ return this.order.map((id) => this.backends.get(id)!).filter(Boolean);
35
+ }
36
+ }
@@ -0,0 +1,44 @@
1
+ // src/backend/resume-store.ts — file-backed sessionKey → backendSessionId, per backend (SPEC-3 §2.4, §4.3).
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ function rootDir(): string {
6
+ return process.env.FLEET_RESUME_ROOT ?? join(process.env.HOME ?? "/tmp", ".pi", "agent", "cache", "fleet-resume");
7
+ }
8
+
9
+ /** Per-backend JSON map: { [sessionKey]: backendSessionId }. */
10
+ function fileFor(backendId: string): string {
11
+ return join(rootDir(), `${backendId}.json`);
12
+ }
13
+
14
+ function readMap(backendId: string): Record<string, string> {
15
+ const f = fileFor(backendId);
16
+ if (!existsSync(f)) return {};
17
+ try {
18
+ return JSON.parse(readFileSync(f, "utf8")) as Record<string, string>;
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ function writeMap(backendId: string, m: Record<string, string>): void {
25
+ const dir = rootDir();
26
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
27
+ writeFileSync(fileFor(backendId), JSON.stringify(m, null, 2));
28
+ }
29
+
30
+ export class ResumeStore {
31
+ get(backendId: string, sessionKey: string): string | null {
32
+ return readMap(backendId)[sessionKey] ?? null;
33
+ }
34
+ set(backendId: string, sessionKey: string, backendSessionId: string): void {
35
+ const m = readMap(backendId);
36
+ m[sessionKey] = backendSessionId;
37
+ writeMap(backendId, m);
38
+ }
39
+ clear(backendId: string, sessionKey: string): void {
40
+ const m = readMap(backendId);
41
+ delete m[sessionKey];
42
+ writeMap(backendId, m);
43
+ }
44
+ }
@@ -12,6 +12,10 @@ export interface RunRecord {
12
12
  startedAt: number;
13
13
  endedAt?: number;
14
14
  resultSummary?: string;
15
+ /** Backend-native session id for resume (SPEC-3). */
16
+ backendSessionId?: string | null;
17
+ /** The sessionKey whose resume this run belongs to (SPEC-3). */
18
+ sessionKey?: string | null;
15
19
  }
16
20
 
17
21
  /** runId format: fl-<base36 ms>-<6 random> (SPEC-1 §5.1). */
@@ -3,6 +3,7 @@ import type { AgentDef, ThinkingLevel } from "../registry/frontmatter.ts";
3
3
  import type { FleetRunStatus, TodoSyncPort } from "../todo-sync/port.ts";
4
4
  import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
5
5
  import type { VisionPort } from "../vision/port.ts";
6
+ import type { BackendRegistry } from "../backend/port.ts";
6
7
  import { genRunId, RunRegistry } from "./run-registry.ts";
7
8
  import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
8
9
  import type { SingleSlotLock } from "./concurrency-lock.ts";
@@ -25,6 +26,8 @@ export interface ChildSessionEvent {
25
26
  content?: Array<{ type: string; text?: string }>;
26
27
  usage?: { cost?: { total?: number } };
27
28
  };
29
+ /** Emitted by a backend on session init (SPEC-3). Drives runRecord.backendSessionId. */
30
+ backendSessionId?: string;
28
31
  }
29
32
 
30
33
  export interface ChildSession {
@@ -62,7 +65,7 @@ export interface SpawnOptions {
62
65
  todoSync: TodoSyncPort;
63
66
  runRegistry: RunRegistry;
64
67
  lock: SingleSlotLock;
65
- childFactory: ChildSessionFactory;
68
+ backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory — engine looks up by agentDef.backend
66
69
  parentModel: { provider: string; id: string };
67
70
  parentCwd: string;
68
71
  memoryPort?: MemoryHydratePort;
@@ -107,6 +110,13 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
107
110
  return fail(runId, startedAt, `agent '${opts.agent}' not in registry; available: ${available}`, opts.agent);
108
111
  }
109
112
 
113
+ // SPEC-3: route via the backend registry; fail fast if the backend is missing/unavailable.
114
+ const backend = opts.backendRegistry.get(agentDef.backend);
115
+ if (!backend || !backend.available()) {
116
+ const note = backend?.versionInfo()?.note ?? "not registered";
117
+ return fail(runId, startedAt, `backend '${agentDef.backend}' unavailable: ${note}`, opts.agent);
118
+ }
119
+
110
120
  // resolve model
111
121
  const model = opts.model ?? agentDef.model ?? `${opts.parentModel.provider}/${opts.parentModel.id}`;
112
122
 
@@ -138,7 +148,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
138
148
  }
139
149
 
140
150
  // spawn child
141
- const { session } = await opts.childFactory.create({
151
+ const { session } = await backend.factory.create({
142
152
  cwd: opts.parentCwd,
143
153
  model,
144
154
  thinkingLevel: agentDef.thinkingLevel,
@@ -160,7 +170,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
160
170
  opts.signal?.addEventListener("abort", onSignalAbort);
161
171
 
162
172
  const unsub = session.subscribe((e) => {
163
- if (e.type === "turn_end") {
173
+ if (e.type === "session_init" && e.backendSessionId) {
174
+ opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
175
+ } else if (e.type === "turn_end") {
164
176
  if (budget.consume()) void session.abort();
165
177
  } else if (e.type === "message_end" && e.message?.role === "assistant") {
166
178
  const text = e.message.content?.map((c) => (c.type === "text" ? c.text ?? "" : "")).join("") ?? "";
package/src/index.ts CHANGED
@@ -21,6 +21,10 @@ import { createDescribeImageTool } from "./vision/describe-image-tool.ts";
21
21
  import type { MemoryHydratePort } from "./memory-hydrate/port.ts";
22
22
  import type { VisionPort } from "./vision/port.ts";
23
23
  import type { ChildSessionFactory, ChildSession } from "./engine/spawnSubagent.ts";
24
+ import { BackendRegistry, PI_HOOK_PARITY, CLAUDE_HOOK_PARITY, type Backend } from "./backend/port.ts";
25
+ import { ResumeStore } from "./backend/resume-store.ts";
26
+ import { detectClaude } from "./backend/claude-detector.ts";
27
+ import { createClaudeChildFactory } from "./backend/claude-factory.ts";
24
28
  import { join } from "node:path";
25
29
 
26
30
  /** The package builtin agents/ dir, resolved relative to this module. */
@@ -30,7 +34,44 @@ function builtinAgentsDir(): string {
30
34
 
31
35
  /** Build the real (SDK-backed) child-session factory. memoryPort is shared (cwd-agnostic);
32
36
  * the vision adapter is constructed per-spawn (needs the child cwd). */
33
- function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort): ChildSessionFactory {
37
+ /** SPEC-3: wrap a pi SDK session so it emits session_init on subscribe + forwards the rest. */
38
+ function wrapPiSession(inner: ChildSession, backendSessionId: string): ChildSession {
39
+ return {
40
+ prompt: (t) => inner.prompt(t),
41
+ abort: () => inner.abort(),
42
+ dispose: () => inner.dispose(),
43
+ subscribe: (handler) => {
44
+ handler({ type: "session_init", backendSessionId });
45
+ return inner.subscribe(handler);
46
+ },
47
+ };
48
+ }
49
+
50
+ /** SPEC-3: build the BackendRegistry — pi always; claude registered with availability reflecting detectClaude(). */
51
+ async function buildDefaultBackendRegistry(modelRuntime: ModelRuntime): Promise<BackendRegistry> {
52
+ const resumeStore = new ResumeStore();
53
+ const claudeInfo = await detectClaude();
54
+ const reg = new BackendRegistry();
55
+ const pi: Backend = {
56
+ id: "pi",
57
+ factory: createChildSessionFactory(modelRuntime, new ArmoryMemoryAdapter(), resumeStore),
58
+ available: () => true,
59
+ versionInfo: () => null,
60
+ hookParity: PI_HOOK_PARITY,
61
+ };
62
+ reg.register(pi);
63
+ // claude is registered regardless of availability so the Backends view can show it; available reflects detection.
64
+ reg.register({
65
+ id: "claude",
66
+ factory: createClaudeChildFactory(claudeInfo, resumeStore),
67
+ available: () => claudeInfo?.schemaOk === true,
68
+ versionInfo: () => claudeInfo,
69
+ hookParity: CLAUDE_HOOK_PARITY,
70
+ });
71
+ return reg;
72
+ }
73
+
74
+ export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort, resumeStore: ResumeStore): ChildSessionFactory {
34
75
  return {
35
76
  async create(opts) {
36
77
  let model: Model<any> | undefined;
@@ -52,7 +93,10 @@ function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: Memor
52
93
  agentDir: getAgentDir(),
53
94
  });
54
95
  const injectVision = opts.agent.vision && !visionPort.isMultimodal(model);
55
- const { session } = await createAgentSession({
96
+ // SPEC-3 §3.1: file-backed SessionManager so resume works. Resume a prior session when the store has a path.
97
+ const resumePath = resumeStore.get("pi", opts.agent.sessionKey);
98
+ const sessionManager = resumePath ? SessionManager.open(resumePath) : SessionManager.create(opts.cwd);
99
+ const { session: piSession } = await createAgentSession({
56
100
  cwd: opts.cwd,
57
101
  model,
58
102
  thinkingLevel: opts.thinkingLevel,
@@ -60,10 +104,14 @@ function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: Memor
60
104
  excludeTools: ["todo"], // SPEC-2 §9.1 hardened single-writer guard
61
105
  customTools: injectVision ? [createDescribeImageTool(visionPort) as never] : [],
62
106
  resourceLoader: loader,
63
- sessionManager: SessionManager.inMemory(),
107
+ sessionManager,
64
108
  modelRuntime,
65
109
  });
66
- return { session: session as unknown as ChildSession, model: opts.model ?? "" };
110
+ // SPEC-3 §4.3: wrap + emit session_init + persist the session file path for resume.
111
+ const backendSessionId = piSession.sessionFile ?? piSession.sessionId;
112
+ if (piSession.sessionFile) resumeStore.set("pi", opts.agent.sessionKey, piSession.sessionFile);
113
+ const session: ChildSession = wrapPiSession(piSession as unknown as ChildSession, backendSessionId);
114
+ return { session, model: opts.model ?? "" };
67
115
  },
68
116
  };
69
117
  }
@@ -75,7 +123,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
75
123
  runRegistry: new RunRegistry(),
76
124
  lock: createSingleSlotLock(),
77
125
  todoSync: new ArmoryTodoAdapter(),
78
- childFactory: createChildSessionFactory(modelRuntime, new ArmoryMemoryAdapter()),
126
+ backendRegistry: await buildDefaultBackendRegistry(modelRuntime),
79
127
  parentModel: { provider: "", id: "" },
80
128
  parentCwd: "",
81
129
  };
@@ -11,20 +11,21 @@ import {
11
11
  } from "@earendil-works/pi-tui";
12
12
  import type { AgentDef } from "../registry/frontmatter.ts";
13
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";
14
+ import { fleetRow, agentsRow, agentInfo, backendsRow, backendInfo } from "./rows.ts";
15
+ import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts";
16
+ import type { Backend, BackendRegistry } from "../backend/port.ts";
16
17
  import type { RunRegistry } from "../engine/run-registry.ts";
17
18
  import type { SingleSlotLock } from "../engine/concurrency-lock.ts";
18
19
  import type { TodoSyncPort } from "../todo-sync/port.ts";
19
20
 
20
- type View = "fleet" | "agents";
21
+ type View = "fleet" | "agents" | "backends";
21
22
 
22
23
  export interface FleetPanelDeps {
23
24
  registry: Map<string, AgentDef>;
24
25
  runRegistry: RunRegistry;
25
26
  lock: SingleSlotLock;
26
27
  todoSync: TodoSyncPort;
27
- childFactory: ChildSessionFactory;
28
+ backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
28
29
  parentModel: { provider: string; id: string };
29
30
  parentCwd: string;
30
31
  }
@@ -48,6 +49,7 @@ export class FleetPanel extends Container {
48
49
  private linkInput: Input | null = null;
49
50
  private linkPhase: "task" | "link" = "task";
50
51
  private infoAgent: AgentDef | null = null;
52
+ private selectedBackend: Backend | null = null; // SPEC-3: Backends view i:Info
51
53
 
52
54
  constructor(opts: FleetPanelOpts) {
53
55
  super();
@@ -67,7 +69,9 @@ export class FleetPanel extends Container {
67
69
  const items: SelectItem[] =
68
70
  this.view === "fleet"
69
71
  ? 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) }));
72
+ : this.view === "agents"
73
+ ? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
74
+ : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
71
75
  const fresh = new SelectList(items, 12, {
72
76
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
73
77
  selectedText: (s: string) => this.theme.fg("accent", s),
@@ -85,7 +89,7 @@ export class FleetPanel extends Container {
85
89
  this.children.length = 0;
86
90
  this.children.push(...keep);
87
91
  const accent = (s: string): string => this.theme.fg("accent", s);
88
- const tabs = (["fleet", "agents"] as View[])
92
+ const tabs = (["fleet", "agents", "backends"] as View[])
89
93
  .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
90
94
  .join(" ");
91
95
  this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
@@ -101,17 +105,26 @@ export class FleetPanel extends Container {
101
105
  for (const line of agentInfo(this.infoAgent).split("\n")) {
102
106
  this.addChild(new Text(this.theme.fg("text", line), 0, 0));
103
107
  }
108
+ } else if (this.selectedBackend) {
109
+ // SPEC-3: i:Info detail pane (backends view)
110
+ this.addChild(new Text(this.theme.fg("dim", " ── backend info ──"), 0, 0));
111
+ for (const line of backendInfo(this.selectedBackend).split("\n")) {
112
+ this.addChild(new Text(this.theme.fg("text", line), 0, 0));
113
+ }
114
+ this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
104
115
  } else {
105
116
  this.addChild(this.list);
106
117
  }
107
118
 
108
119
  this.addChild(new Spacer(1));
109
120
  const hint =
110
- this.infoAgent
121
+ this.infoAgent || this.selectedBackend
111
122
  ? " esc:Back"
112
123
  : this.view === "fleet"
113
124
  ? " 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";
125
+ : this.view === "agents"
126
+ ? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
127
+ : " r:Refresh i:Info tab:Fleet q:Quit";
115
128
  this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
116
129
  this.addChild(new Spacer(1));
117
130
  this.addChild(new DynamicBorder(accent));
@@ -151,7 +164,7 @@ export class FleetPanel extends Container {
151
164
  agent, task, todoId, track: true,
152
165
  registry: this.deps.registry, todoSync: this.deps.todoSync,
153
166
  runRegistry: this.deps.runRegistry, lock: this.deps.lock,
154
- childFactory: this.deps.childFactory,
167
+ backendRegistry: this.deps.backendRegistry,
155
168
  parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
156
169
  // live Fleet row during the run (SPEC-1 §4c) — re-render on each turn_end
157
170
  onEvent: (e) => {
@@ -177,7 +190,8 @@ export class FleetPanel extends Container {
177
190
  }
178
191
 
179
192
  private switchView(): void {
180
- this.view = this.view === "fleet" ? "agents" : "fleet";
193
+ this.view = this.view === "fleet" ? "agents" : this.view === "agents" ? "backends" : "fleet";
194
+ this.selectedBackend = null;
181
195
  this.list = this.buildList();
182
196
  this.renderShell();
183
197
  }
@@ -187,6 +201,10 @@ export class FleetPanel extends Container {
187
201
  if (matchesKey(data, "escape")) { this.infoAgent = null; this.renderShell(); }
188
202
  return;
189
203
  }
204
+ if (this.selectedBackend) {
205
+ if (matchesKey(data, "escape")) { this.selectedBackend = null; this.renderShell(); }
206
+ return;
207
+ }
190
208
  if (this.runMode && (this.taskInput || this.linkInput)) {
191
209
  if (matchesKey(data, "escape")) { this.cancelRun(); return; }
192
210
  (this.linkPhase === "task" ? this.taskInput! : this.linkInput!).handleInput(data);
@@ -206,6 +224,16 @@ export class FleetPanel extends Container {
206
224
  if (sel) { this.infoAgent = this.deps.registry.get(sel.value) ?? null; this.renderShell(); }
207
225
  return;
208
226
  }
227
+ if (matchesKey(data, "i") && this.view === "backends") {
228
+ const sel = this.list.getSelectedItem();
229
+ if (sel) { this.selectedBackend = this.deps.backendRegistry.list().find((x) => x.id === sel.value) ?? null; this.renderShell(); }
230
+ return;
231
+ }
232
+ if (matchesKey(data, "r") && this.view === "backends") {
233
+ this.onNotify("Backends reflect init-time detection; restart pi to re-detect.", "info");
234
+ this.renderShell();
235
+ return;
236
+ }
209
237
  this.list.handleInput(data);
210
238
  this.invalidate();
211
239
  }
package/src/panel/rows.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import type { AgentDef } from "../registry/frontmatter.ts";
3
3
  import type { FleetRunStatus } from "../todo-sync/port.ts";
4
4
  import type { RunRecord } from "../engine/run-registry.ts";
5
+ import type { Backend, BackendHookParity } from "../backend/port.ts";
5
6
 
6
7
  export function fmtDuration(ms: number): string {
7
8
  const s = Math.floor(ms / 1000);
@@ -30,7 +31,7 @@ export function agentsRow(agent: AgentDef): string {
30
31
  const chip = `armory:[t${agent.todoSync ? "✓" : "✗"} m${agent.memoryHydrate ? "✓" : "✗"} v${agent.vision ? "✓" : "✗"}]`;
31
32
  const skills = agent.skills?.length ? ` skills: ${agent.skills.join(",")}` : "";
32
33
  const tools = agent.tools?.length ? ` tools: ${agent.tools.join(",")}` : "";
33
- return `${agent.name} [${agent.source}] ${model}${tools}${skills} ${chip}`;
34
+ return `${agent.name} [${agent.backend}] [${agent.source}] ${model}${tools}${skills} ${chip}`;
34
35
  }
35
36
 
36
37
  export function agentInfo(agent: AgentDef): string {
@@ -50,4 +51,34 @@ export function agentInfo(agent: AgentDef): string {
50
51
  agent.rolePrompt.trim(),
51
52
  ];
52
53
  return lines.join("\n");
53
- }
54
+ }
55
+ function chipStr(p: BackendHookParity): string {
56
+ return `t${p.todo} m${p.memory} v${p.vision}`;
57
+ }
58
+
59
+ export function backendsRow(b: Backend): string {
60
+ const avail = b.available() ? "✓" : "✗";
61
+ const vi = b.versionInfo();
62
+ const version = vi?.version ? vi.version : "—";
63
+ const schema = vi ? (vi.schemaOk ? "✓" : "✗") : "—";
64
+ const note = vi && !vi.schemaOk && vi.note ? ` ${vi.note}` : "";
65
+ return `${b.id} ${avail} ${version} schema:${schema} armory:[${chipStr(b.hookParity)}]${note}`;
66
+ }
67
+
68
+ export function backendInfo(b: Backend): string {
69
+ const vi = b.versionInfo();
70
+ const lines = [
71
+ `id: ${b.id}`,
72
+ `available: ${b.available() ? "✓" : "✗"}`,
73
+ `version: ${vi?.version ?? "—"}`,
74
+ `schemaOk: ${vi ? vi.schemaOk : "—"}`,
75
+ ];
76
+ if (vi?.note) lines.push(`note: ${vi.note}`);
77
+ lines.push("flagSupport:");
78
+ for (const [flag, ok] of Object.entries(vi?.flagSupport ?? {})) lines.push(` ${flag}: ${ok ? "✓" : "✗"}`);
79
+ lines.push("hookParity:");
80
+ lines.push(` todo: ${b.hookParity.todo} (excluded via ${b.id === "pi" ? "excludeTools+noExtensions" : "--disallowed-tools/prompt-nudge"})`);
81
+ lines.push(` memory: ${b.hookParity.memory} (${b.id === "pi" ? "CustomResourceLoader systemPromptOverride" : "--append-system-prompt"})`);
82
+ lines.push(` vision: ${b.hookParity.vision} (${b.hookParity.vision === "✓" ? "describe_image fallback injected" : "pass-through only; no describe_image fallback — customTools not injectable into claude -p"})`);
83
+ return lines.join("\n");
84
+ }
@@ -16,6 +16,10 @@ export interface AgentDef {
16
16
  todoSync: boolean;
17
17
  memoryHydrate: boolean;
18
18
  vision: boolean;
19
+ /** Cross-harness backend routing (SPEC-3). Invalid value → FrontmatterError. */
20
+ backend: "pi" | "claude";
21
+ /** Stable id for backend-native resume (SPEC-3). Defaults to name. */
22
+ sessionKey: string;
19
23
  source: AgentSource;
20
24
  filePath: string;
21
25
  }
@@ -52,6 +56,13 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS
52
56
  const memoryHydrate = raw.memoryHydrate === undefined ? true : Boolean(raw.memoryHydrate);
53
57
  const vision = raw.vision === undefined ? true : Boolean(raw.vision);
54
58
 
59
+ const rawBackend = typeof raw.backend === "string" ? raw.backend.trim() : "pi";
60
+ if (rawBackend !== "pi" && rawBackend !== "claude") {
61
+ throw new FrontmatterError(`${filePath}: invalid backend '${rawBackend}' (must be 'pi' | 'claude')`);
62
+ }
63
+ const backend = rawBackend as "pi" | "claude";
64
+ const sessionKey = typeof raw.sessionKey === "string" && raw.sessionKey.trim() ? raw.sessionKey.trim() : name;
65
+
55
66
  return {
56
67
  name,
57
68
  description,
@@ -63,6 +74,8 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS
63
74
  todoSync,
64
75
  memoryHydrate,
65
76
  vision,
77
+ backend,
78
+ sessionKey,
66
79
  source,
67
80
  filePath,
68
81
  };
@@ -4,8 +4,9 @@ import type { AgentDef } from "../registry/frontmatter.ts";
4
4
  import type { TodoSyncPort } from "../todo-sync/port.ts";
5
5
  import type { RunRegistry } from "../engine/run-registry.ts";
6
6
  import type { SingleSlotLock } from "../engine/concurrency-lock.ts";
7
- import type { ChildSessionFactory, SpawnResult } from "../engine/spawnSubagent.ts";
7
+ import type { SpawnResult } from "../engine/spawnSubagent.ts";
8
8
  import { spawnSubagent } from "../engine/spawnSubagent.ts";
9
+ import type { BackendRegistry } from "../backend/port.ts";
9
10
 
10
11
  export const subagentParams = Type.Object({
11
12
  agent: Type.String({ description: "Agent name from the registry (builtin, project, or global)." }),
@@ -22,7 +23,7 @@ export interface SubagentToolDeps {
22
23
  runRegistry: RunRegistry;
23
24
  lock: SingleSlotLock;
24
25
  todoSync: TodoSyncPort;
25
- childFactory: ChildSessionFactory;
26
+ backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
26
27
  parentModel: { provider: string; id: string };
27
28
  parentCwd: string;
28
29
  }
@@ -51,7 +52,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
51
52
  todoSync: deps.todoSync,
52
53
  runRegistry: deps.runRegistry,
53
54
  lock: deps.lock,
54
- childFactory: deps.childFactory,
55
+ backendRegistry: deps.backendRegistry,
55
56
  parentModel: deps.parentModel,
56
57
  parentCwd: deps.parentCwd,
57
58
  signal,