@getpipher/armory-fleet 0.3.0 → 0.5.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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/engine/run-registry.ts +13 -1
  3. package/src/engine/spawnSubagent.ts +33 -16
  4. package/src/index.ts +155 -2
  5. package/src/lifecycle/artifacts-parser.ts +57 -0
  6. package/src/lifecycle/default.ts +74 -0
  7. package/src/lifecycle/lifecycle-todo.ts +84 -0
  8. package/src/lifecycle/lifecycle-types.ts +66 -0
  9. package/src/lifecycle/port.ts +7 -0
  10. package/src/lifecycle/prompt-template.ts +40 -0
  11. package/src/lifecycle/registry.ts +169 -0
  12. package/src/lifecycle/run-lifecycle.ts +251 -0
  13. package/src/panel/bg-runs-store.ts +36 -0
  14. package/src/panel/fleet-items.ts +36 -0
  15. package/src/panel/fleet-panel.ts +334 -18
  16. package/src/panel/rows.ts +90 -0
  17. package/src/runtime/async-runner.ts +123 -0
  18. package/src/runtime/concurrency-pool.ts +27 -0
  19. package/src/runtime/results-inbox.ts +45 -0
  20. package/src/runtime/resume.ts +48 -0
  21. package/src/runtime/run-journal.ts +61 -0
  22. package/src/scheduling/expressions.ts +60 -0
  23. package/src/scheduling/pid-lock.ts +43 -0
  24. package/src/scheduling/scheduler.ts +147 -0
  25. package/src/todo-sync/adapter.ts +6 -0
  26. package/src/todo-sync/port.ts +2 -0
  27. package/src/tools/fleet-results.ts +35 -0
  28. package/src/tools/subagent.ts +58 -0
  29. package/src/vendor/cron-parser/NOTICE.md +23 -0
  30. package/src/vendor/cron-parser/lib/date.js +79 -0
  31. package/src/vendor/cron-parser/lib/expression.js +614 -0
  32. package/src/vendor/cron-parser/lib/number.js +8 -0
  33. package/src/vendor/cron-parser/lib/parser.js +103 -0
  34. package/src/vendor/cron-parser/types.d.ts +12 -0
  35. package/src/worktree/diff-service.ts +40 -0
  36. package/src/worktree/worktree-service.ts +92 -0
@@ -0,0 +1,27 @@
1
+ // src/runtime/concurrency-pool.ts
2
+ // SPEC-5a §8 — N-slot semaphore for async/bg runs (Q4=A). Foreground keeps its own
3
+ // single-slot lock (unchanged); this pool is independent.
4
+
5
+ export class ConcurrencyPool {
6
+ private active = 0;
7
+ private waiters: Array<() => void> = [];
8
+
9
+ constructor(private readonly cap = 3) {}
10
+
11
+ busy(): number { return this.active; }
12
+ queued(): number { return this.waiters.length; }
13
+
14
+ async withSlot<T>(fn: () => Promise<T>): Promise<T> {
15
+ if (this.active >= this.cap) {
16
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
17
+ }
18
+ this.active++;
19
+ try {
20
+ return await fn();
21
+ } finally {
22
+ this.active--;
23
+ const next = this.waiters.shift();
24
+ if (next) next();
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,45 @@
1
+ // src/runtime/results-inbox.ts
2
+ // SPEC-5a §10 — in-memory results inbox for completed bg runs (Q6=C).
3
+ // The durable record is the lifecycle TODO notes + journal; this is the fast in-session
4
+ // pointer the agent pulls via fleet.results().
5
+
6
+ export interface RunResult {
7
+ runId: string;
8
+ task: string;
9
+ status: "completed" | "failed";
10
+ summary: string;
11
+ paths: string[];
12
+ branch?: string;
13
+ completedAt: number;
14
+ }
15
+
16
+ export class ResultsInbox {
17
+ private ready = new Map<string, RunResult>();
18
+
19
+ push(result: RunResult): void {
20
+ this.ready.set(result.runId, result);
21
+ }
22
+
23
+ readyCount(): number {
24
+ return this.ready.size;
25
+ }
26
+
27
+ pull(runId?: string): RunResult[] {
28
+ if (runId) {
29
+ const r = this.ready.get(runId);
30
+ if (!r) return [];
31
+ this.ready.delete(runId);
32
+ return [r];
33
+ }
34
+ const all = [...this.ready.values()];
35
+ this.ready.clear();
36
+ return all;
37
+ }
38
+
39
+ /** Bounded hint for the parent agent's context: cap at 5, one line, empty when nothing ready. */
40
+ renderHint(): string {
41
+ const n = this.ready.size;
42
+ if (n === 0) return "";
43
+ return n > 5 ? "5+ fleet results ready (use fleet.results to pull)" : `${n} fleet result${n > 1 ? "s" : ""} ready (use fleet.results to pull)`;
44
+ }
45
+ }
@@ -0,0 +1,48 @@
1
+ // src/runtime/resume.ts
2
+ // SPEC-5a §5.3 — on pi start, scan .pi/fleet/runs/ for non-terminal journals and offer resume.
3
+ // If the worktree is gone, mark the journal run:aborted (worktree-missing).
4
+ import { RunJournal, type JournalEvent } from "./run-journal.ts";
5
+ import type { WorktreeService } from "../worktree/worktree-service.ts";
6
+
7
+ export interface ResumeCandidate {
8
+ runId: string;
9
+ task: string;
10
+ lifecycle: string;
11
+ worktreePath: string;
12
+ branch: string;
13
+ lastPhase: string | null;
14
+ canResume: boolean;
15
+ }
16
+
17
+ export interface ScanResumeOpts {
18
+ runsDir: string;
19
+ worktree: WorktreeService;
20
+ }
21
+
22
+ export function scanResumeCandidates(_projectDir: string, opts: ScanResumeOpts): ResumeCandidate[] {
23
+ const journal = new RunJournal(opts.runsDir);
24
+ const ids = journal.scanNonTerminal();
25
+ const cands: ResumeCandidate[] = [];
26
+ for (const runId of ids) {
27
+ const events = journal.replay(runId);
28
+ const started = events.find((e) => e.type === "run:started") as
29
+ | (JournalEvent & { type: "run:started" }) | undefined;
30
+ if (!started) continue;
31
+ const phaseEvents = events.filter((e) => e.type === "phase:completed" || e.type === "phase:started" || e.type === "phase:failed") as Array<{ phase: string }>;
32
+ const lastPhase = phaseEvents.length > 0 ? phaseEvents[phaseEvents.length - 1]!.phase : null;
33
+ const wtExists = opts.worktree.exists(runId);
34
+ if (!wtExists) {
35
+ journal.append(runId, { type: "run:aborted", runId, reason: "worktree-missing", ts: Date.now() });
36
+ }
37
+ cands.push({
38
+ runId,
39
+ task: started.task,
40
+ lifecycle: started.lifecycle,
41
+ worktreePath: started.worktree.path,
42
+ branch: started.worktree.branch,
43
+ lastPhase,
44
+ canResume: wtExists,
45
+ });
46
+ }
47
+ return cands;
48
+ }
@@ -0,0 +1,61 @@
1
+ // src/runtime/run-journal.ts
2
+ // SPEC-5a §5 — JSONL run journal. Append-only (crash-safe: a partial last line is discarded).
3
+ // The event log IS the i:Info timeline + the resume source of truth.
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ export interface RunStartedEvent { type: "run:started"; runId: string; task: string; lifecycle: string; worktree: { path: string; branch: string }; mode: "auto" | "checkpointed"; ts: number; }
8
+ export interface PhaseStartedEvent { type: "phase:started"; phase: string; ts: number; }
9
+ export interface PhaseCompletedEvent { type: "phase:completed"; phase: string; summary: string; paths: string[]; ts: number; }
10
+ export interface PhaseFailedEvent { type: "phase:failed"; phase: string; error: string; ts: number; }
11
+ export interface CheckpointEvent { type: "checkpoint"; phase: string; decision: "continue" | "revise" | "abort"; ts: number; }
12
+ export interface RunCompletedEvent { type: "run:completed"; runId: string; branch: string; ts: number; }
13
+ export interface RunAbortedEvent { type: "run:aborted"; runId: string; reason: string; ts: number; }
14
+
15
+ export type JournalEvent =
16
+ | RunStartedEvent | PhaseStartedEvent | PhaseCompletedEvent | PhaseFailedEvent
17
+ | CheckpointEvent | RunCompletedEvent | RunAbortedEvent;
18
+
19
+ const TERMINAL = new Set<JournalEvent["type"]>(["run:completed", "run:aborted"]);
20
+
21
+ export class RunJournal {
22
+ constructor(private readonly dir: string) {}
23
+
24
+ private file(runId: string): string {
25
+ return join(this.dir, `${runId}.jsonl`);
26
+ }
27
+
28
+ append(runId: string, event: JournalEvent): void {
29
+ mkdirSync(this.dir, { recursive: true });
30
+ appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8");
31
+ }
32
+
33
+ replay(runId: string): JournalEvent[] {
34
+ const f = this.file(runId);
35
+ if (!existsSync(f)) return [];
36
+ const lines = readFileSync(f, "utf8").split("\n");
37
+ const events: JournalEvent[] = [];
38
+ for (const line of lines) {
39
+ if (!line) continue;
40
+ try {
41
+ events.push(JSON.parse(line) as JournalEvent);
42
+ } catch {
43
+ // partial last line (crash mid-append) — discard
44
+ }
45
+ }
46
+ return events;
47
+ }
48
+
49
+ scanNonTerminal(): string[] {
50
+ if (!existsSync(this.dir)) return [];
51
+ const ids: string[] = [];
52
+ for (const f of readdirSync(this.dir)) {
53
+ if (!f.endsWith(".jsonl")) continue;
54
+ const runId = f.slice(0, -".jsonl".length);
55
+ const events = this.replay(runId);
56
+ const last = events[events.length - 1];
57
+ if (last && !TERMINAL.has(last.type)) ids.push(runId);
58
+ }
59
+ return ids;
60
+ }
61
+ }
@@ -0,0 +1,60 @@
1
+ // src/scheduling/expressions.ts
2
+ // SPEC-5a §9 — schedule expressions: cron (vendored) + interval + one-shot (Q5=A).
3
+ // The vendored cron-parser lib (v1.1.1) is CommonJS — use createRequire for CJS-in-ESM interop.
4
+ // v1.1.1 has no `tz` option; it uses the process local timezone (the right default for a dev tool).
5
+ import { createRequire } from "node:module";
6
+
7
+ const cronRequire = createRequire(import.meta.url);
8
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
9
+ const cronParser = cronRequire("../vendor/cron-parser/lib/parser.js") as {
10
+ parseExpression(expr: string, opts?: { currentDate?: Date; endDate?: Date }): { next(): Date; prev(): Date; hasNext(): boolean };
11
+ };
12
+
13
+ export type ScheduleType = "cron" | "interval" | "once";
14
+
15
+ export interface ScheduleExpression {
16
+ type: ScheduleType;
17
+ /** Next fire after `prev` (or from now if prev is null). Returns null when a one-shot has already fired. */
18
+ nextFire(prev: Date | null): Date | null;
19
+ }
20
+
21
+ const INTERVAL_RE = /^(\d+)([smhd])$/;
22
+ const INTERVAL_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
23
+
24
+ export function parseScheduleExpr(expr: string): ScheduleExpression {
25
+ const s = expr.trim();
26
+ if (INTERVAL_RE.test(s)) {
27
+ const m = s.match(INTERVAL_RE)!;
28
+ const unit = m[2] as "s" | "m" | "h" | "d";
29
+ const ms = Number(m[1]) * (INTERVAL_MS[unit] ?? 0);
30
+ return {
31
+ type: "interval",
32
+ nextFire: (prev) => new Date((prev ?? new Date()).getTime() + ms),
33
+ };
34
+ }
35
+ // one-shot ISO datetime (contains a 'T' and parses as a single Date)
36
+ if (s.includes("T") && /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) {
37
+ const fire = new Date(s);
38
+ if (isNaN(fire.getTime())) throw new Error(`invalid schedule expression (one-shot datetime): ${expr}`);
39
+ let fired = false;
40
+ return {
41
+ type: "once",
42
+ nextFire: (prev) => {
43
+ if (fired) return null;
44
+ if (prev && fire.getTime() <= prev.getTime()) { fired = true; return null; }
45
+ fired = true;
46
+ return fire;
47
+ },
48
+ };
49
+ }
50
+ // cron (5-field) — validate immediately (resolve-time error, not fire-time)
51
+ try {
52
+ cronParser.parseExpression(s, { currentDate: new Date() });
53
+ } catch (e) {
54
+ throw new Error(`invalid schedule expression (not cron/interval/once): ${expr} — ${(e as Error).message}`);
55
+ }
56
+ return {
57
+ type: "cron",
58
+ nextFire: (prev) => cronParser.parseExpression(s, { currentDate: prev ?? new Date() }).next(),
59
+ };
60
+ }
@@ -0,0 +1,43 @@
1
+ // src/scheduling/pid-lock.ts
2
+ // SPEC-5a §9 — PID lock so only one pi session fires schedules (Q5=A).
3
+ // A stale PID (dead process) is reclaimed.
4
+ import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
5
+
6
+ function isPidAlive(pid: number): boolean {
7
+ try {
8
+ process.kill(pid, 0); // signal 0 = existence check
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ export class PidLock {
16
+ private lockPath: string | null = null;
17
+
18
+ acquire(lockPath: string): boolean {
19
+ if (existsSync(lockPath)) {
20
+ const raw = readFileSync(lockPath, "utf8").trim();
21
+ const ownerPid = Number(raw);
22
+ if (Number.isFinite(ownerPid) && ownerPid !== process.pid && isPidAlive(ownerPid)) {
23
+ // a different live process owns it
24
+ return false;
25
+ }
26
+ // stale pid (dead) or already us → reclaim/keep
27
+ }
28
+ writeFileSync(lockPath, String(process.pid), "utf8");
29
+ this.lockPath = lockPath;
30
+ return true;
31
+ }
32
+
33
+ isOwner(): boolean {
34
+ return this.lockPath !== null;
35
+ }
36
+
37
+ release(): void {
38
+ if (this.lockPath && existsSync(this.lockPath)) {
39
+ try { unlinkSync(this.lockPath); } catch { /* already gone */ }
40
+ }
41
+ this.lockPath = null;
42
+ }
43
+ }
@@ -0,0 +1,147 @@
1
+ // src/scheduling/scheduler.ts
2
+ // SPEC-5a §9 — in-process scheduler. Session-scoped (fires only while pi open, no daemon).
3
+ // PID-locked so two open pi sessions on the same project don't double-fire. No catch-up.
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ import { parseScheduleExpr, type ScheduleExpression } from "./expressions.ts";
7
+ import { PidLock } from "./pid-lock.ts";
8
+
9
+ export interface ScheduleSpec {
10
+ task: string;
11
+ expression: string;
12
+ lifecycle?: string; // default "default"
13
+ auto?: boolean;
14
+ }
15
+
16
+ export interface Schedule extends ScheduleSpec {
17
+ id: string;
18
+ nextFire: Date | null;
19
+ paused: boolean;
20
+ }
21
+
22
+ interface StoredSchedule extends ScheduleSpec {
23
+ id: string;
24
+ paused: boolean;
25
+ }
26
+
27
+ export interface SchedulerOpts {
28
+ storePath: string;
29
+ lockPath: string;
30
+ onFire: (spec: ScheduleSpec) => void;
31
+ }
32
+
33
+ interface Entry { spec: StoredSchedule; expr: ScheduleExpression; timer: NodeJS.Timeout | null }
34
+
35
+ export class Scheduler {
36
+ private schedules = new Map<string, Entry>();
37
+ private pidLock = new PidLock();
38
+ private running = false;
39
+
40
+ constructor(private readonly opts: SchedulerOpts) {
41
+ this.load();
42
+ }
43
+
44
+ private load(): void {
45
+ if (!existsSync(this.opts.storePath)) return;
46
+ try {
47
+ const arr = JSON.parse(readFileSync(this.opts.storePath, "utf8")) as StoredSchedule[];
48
+ for (const s of arr) {
49
+ try {
50
+ const expr = parseScheduleExpr(s.expression);
51
+ this.schedules.set(s.id, { spec: s, expr, timer: null });
52
+ } catch {
53
+ // skip a schedule whose expression no longer parses
54
+ }
55
+ }
56
+ } catch { /* corrupt store — start empty */ }
57
+ }
58
+
59
+ private persist(): void {
60
+ mkdirSync(dirname(this.opts.storePath), { recursive: true });
61
+ const arr = [...this.schedules.values()].map((e) => e.spec);
62
+ writeFileSync(this.opts.storePath, JSON.stringify(arr, null, 2), "utf8");
63
+ }
64
+
65
+ register(spec: ScheduleSpec): string {
66
+ const expr = parseScheduleExpr(spec.expression); // throws on invalid → resolve-time error
67
+ const id = "sch-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
68
+ const stored: StoredSchedule = {
69
+ id,
70
+ task: spec.task,
71
+ expression: spec.expression,
72
+ lifecycle: spec.lifecycle ?? "default",
73
+ auto: spec.auto ?? true,
74
+ paused: false,
75
+ };
76
+ this.schedules.set(id, { spec: stored, expr, timer: null });
77
+ this.persist();
78
+ if (this.running) this.arm(id);
79
+ return id;
80
+ }
81
+
82
+ list(): Schedule[] {
83
+ return [...this.schedules.values()].map((e) => ({
84
+ id: e.spec.id,
85
+ task: e.spec.task,
86
+ expression: e.spec.expression,
87
+ lifecycle: e.spec.lifecycle,
88
+ auto: e.spec.auto,
89
+ paused: e.spec.paused,
90
+ nextFire: e.spec.paused ? null : e.expr.nextFire(new Date()),
91
+ }));
92
+ }
93
+
94
+ pause(id: string): void {
95
+ const e = this.schedules.get(id);
96
+ if (!e) return;
97
+ e.spec.paused = true;
98
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
99
+ this.persist();
100
+ }
101
+
102
+ resume(id: string): void {
103
+ const e = this.schedules.get(id);
104
+ if (!e) return;
105
+ e.spec.paused = false;
106
+ if (this.running) this.arm(id);
107
+ this.persist();
108
+ }
109
+
110
+ delete(id: string): void {
111
+ const e = this.schedules.get(id);
112
+ if (!e) return;
113
+ if (e.timer) clearTimeout(e.timer);
114
+ this.schedules.delete(id);
115
+ this.persist();
116
+ }
117
+
118
+ start(): boolean {
119
+ if (this.running) return true;
120
+ if (!this.pidLock.acquire(this.opts.lockPath)) return false;
121
+ this.running = true;
122
+ for (const id of this.schedules.keys()) this.arm(id);
123
+ return true;
124
+ }
125
+
126
+ stop(): void {
127
+ if (!this.running) return;
128
+ for (const e of this.schedules.values()) if (e.timer) { clearTimeout(e.timer); e.timer = null; }
129
+ this.pidLock.release();
130
+ this.running = false;
131
+ }
132
+
133
+ private arm(id: string): void {
134
+ const e = this.schedules.get(id);
135
+ if (!e || e.spec.paused) return;
136
+ const now = new Date();
137
+ const next = e.expr.nextFire(now);
138
+ if (!next) { this.delete(id); return; } // one-shot exhausted
139
+ const delay = Math.max(0, next.getTime() - now.getTime());
140
+ e.timer = setTimeout(() => {
141
+ this.opts.onFire(e.spec);
142
+ const nx = e.expr.nextFire(new Date());
143
+ if (!nx) { this.delete(id); return; }
144
+ this.arm(id);
145
+ }, delay);
146
+ }
147
+ }
@@ -81,4 +81,10 @@ export class ArmoryTodoAdapter implements TodoSyncPort {
81
81
  }
82
82
  appendNote(todoId, `fleet-run reverted: ${reason}`);
83
83
  }
84
+
85
+ async updateLifecycleProgress(todoId: string, progressBlock: string): Promise<void> {
86
+ if (!todoId) return;
87
+ // single-writer: replace notes wholesale with the progress block (the lifecycle owns it)
88
+ updateTodo(todoId, { notes: progressBlock });
89
+ }
84
90
  }
@@ -38,4 +38,6 @@ export interface TodoSyncPort {
38
38
  markRunTodoDone(todoId: string | null, priorStatus: string | undefined, result: string): Promise<void>;
39
39
  /** After a failed/aborted run: fleet-created -> open; linked -> restore prior. + reason note. */
40
40
  markRunTodoReverted(todoId: string | null, priorStatus: string | undefined, reason: string): Promise<void>;
41
+ /** SPEC-4: replace a lifecycle todo's notes with the phase-progress block (single source of truth). */
42
+ updateLifecycleProgress(todoId: string, progressBlock: string): Promise<void>;
41
43
  }
@@ -0,0 +1,35 @@
1
+ // src/tools/fleet-results.ts
2
+ // SPEC-5a §10/§12.2 — the agent pulls completed bg-run results from the inbox (Q6=C).
3
+ import { Type, type Static } from "typebox";
4
+ import type { ResultsInbox } from "../runtime/results-inbox.ts";
5
+
6
+ export const fleetResultsParams = Type.Object({
7
+ runId: Type.Optional(Type.String({ description: "Pull a specific run's result. Omit to pull all ready (undelivered) results." })),
8
+ });
9
+
10
+ export type FleetResultsInput = Static<typeof fleetResultsParams>;
11
+
12
+ export interface FleetResultsToolDeps {
13
+ inbox: ResultsInbox;
14
+ }
15
+
16
+ export function createFleetResultsTool(deps: FleetResultsToolDeps) {
17
+ return {
18
+ name: "fleet_results",
19
+ label: "Fleet results",
20
+ description: "Pull completed background fleet-run results from the inbox. With a runId, returns that run's result. Without, returns all ready (undelivered) results. Pulling marks them delivered. The durable record also lives in the lifecycle TODO notes + the /fleet panel.",
21
+ promptSnippet: "Pull completed background fleet-run results",
22
+ promptGuidelines: [
23
+ "Use fleet_results to pull completed background runs when the 'N fleet results ready' hint appears.",
24
+ "Without a runId, returns all ready results and marks them delivered.",
25
+ ],
26
+ parameters: fleetResultsParams,
27
+ async execute(_toolCallId: string, input: FleetResultsInput) {
28
+ const results = deps.inbox.pull(input.runId);
29
+ return {
30
+ content: [{ type: "text" as const, text: results.length === 0 ? "no results ready" : results.map((r) => `${r.runId}: ${r.status} — ${r.summary}`).join("\n") }],
31
+ details: { results },
32
+ };
33
+ },
34
+ };
35
+ }
@@ -7,6 +7,11 @@ import type { SingleSlotLock } from "../engine/concurrency-lock.ts";
7
7
  import type { SpawnResult } from "../engine/spawnSubagent.ts";
8
8
  import { spawnSubagent } from "../engine/spawnSubagent.ts";
9
9
  import type { BackendRegistry } from "../backend/port.ts";
10
+ import type { LifecycleRunDeps } from "../lifecycle/run-lifecycle.ts";
11
+ import type { LifecycleDef } from "../lifecycle/lifecycle-types.ts";
12
+ import type { AsyncRunnerDeps } from "../runtime/async-runner.ts";
13
+ import { runBackground } from "../runtime/async-runner.ts";
14
+ import type { Scheduler } from "../scheduling/scheduler.ts";
10
15
 
11
16
  export const subagentParams = Type.Object({
12
17
  agent: Type.String({ description: "Agent name from the registry (builtin, project, or global)." }),
@@ -14,6 +19,10 @@ export const subagentParams = Type.Object({
14
19
  todoId: Type.Optional(Type.String({ description: "Explicit link to an existing open/in_progress armory-todo todo. Omit to create a fleet task." })),
15
20
  track: Type.Optional(Type.Boolean({ description: "Default true. Pass false only for throwaway lookups that don't represent real work." })),
16
21
  model: Type.Optional(Type.String({ description: 'Override the agent model, e.g. "anthropic/claude-sonnet-4".' })),
22
+ lifecycle: Type.Optional(Type.String({ description: "Run a multi-phase superpowers lifecycle by name (e.g. 'default') instead of a single delegate. Tool-driven lifecycles run end-to-end (auto) — checkpoints are a /fleet panel feature." })),
23
+ auto: Type.Optional(Type.Boolean({ description: "Only relevant with `lifecycle`. Tool-driven is always auto; this flag is forward-compat. Panel-driven uses --auto on /fleet-implement." })),
24
+ background: Type.Optional(Type.Boolean({ description: "Fire without awaiting. The run goes to the async/bg pool on an isolated git worktree; this returns { runId, status: 'background' } immediately. Foreground (default) awaits the result." })),
25
+ schedule: Type.Optional(Type.String({ description: 'Schedule the run instead of firing now: a cron string ("0 9 * * 1-5"), an interval ("30m"/"2h"), or a one-shot ISO datetime ("2026-07-25T14:00"). Returns { scheduleId, nextFire }. Session-scoped (fires only while pi is open); no catch-up.' })),
17
26
  });
18
27
 
19
28
  export type SubagentInput = Static<typeof subagentParams>;
@@ -26,6 +35,16 @@ export interface SubagentToolDeps {
26
35
  backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
27
36
  parentModel: { provider: string; id: string };
28
37
  parentCwd: string;
38
+ /** SPEC-4: lifecycle registry + spawn adapter (tool-driven = auto). */
39
+ lifecycleRegistry: Map<string, LifecycleDef>;
40
+ lifecycleRuns: Map<string, import("../lifecycle/lifecycle-types.ts").LifecycleRunRecord>;
41
+ lifecycleDeps: Omit<LifecycleRunDeps, "spawn">;
42
+ /** SPEC-5a: async/bg runtime deps. Present when the extension wires the operational runtime. */
43
+ asyncRunner?: AsyncRunnerDeps;
44
+ /** SPEC-5a: scheduler. Present when the extension wires scheduling. */
45
+ scheduler?: Scheduler;
46
+ /** SPEC-5a: live bg run status rows for the /fleet panel. Optional. */
47
+ bgRuns?: import("../panel/bg-runs-store.ts").BgRunsStore;
29
48
  }
30
49
 
31
50
  /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
@@ -42,6 +61,45 @@ export function createSubagentTool(deps: SubagentToolDeps) {
42
61
  ],
43
62
  parameters: subagentParams,
44
63
  async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
64
+ // SPEC-5a: background + schedule routing (Q1/Q2/Q5).
65
+ if (params.background && params.schedule) {
66
+ return { isError: true, content: [{ type: "text" as const, text: "A scheduled run is inherently background — pass only one of `background` or `schedule`, not both." }] };
67
+ }
68
+ if (params.schedule) {
69
+ if (!deps.scheduler) return { isError: true, content: [{ type: "text" as const, text: "scheduling not configured (scheduler missing)" }] };
70
+ const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true });
71
+ const entry = deps.scheduler.list().find((s) => s.id === id);
72
+ return { content: [{ type: "text" as const, text: `scheduled: ${id} · next fire: ${entry?.nextFire?.toISOString() ?? "(paused)"}` }], details: { scheduleId: id, nextFire: entry?.nextFire ?? null } };
73
+ }
74
+ if (params.background) {
75
+ if (!deps.asyncRunner) return { isError: true, content: [{ type: "text" as const, text: "background runs not configured (asyncRunner missing)" }] };
76
+ const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto" });
77
+ return { content: [{ type: "text" as const, text: `background run: ${handle.runId}` }], details: handle };
78
+ }
79
+ if (params.lifecycle) {
80
+ const { runLifecycle } = await import("../lifecycle/run-lifecycle.ts");
81
+ const lifecycleFullDeps: LifecycleRunDeps = {
82
+ ...deps.lifecycleDeps,
83
+ spawn: async (o) => spawnSubagent({
84
+ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
85
+ skillsOverride: o.skills, backendOverride: o.backend,
86
+ registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
87
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, signal,
88
+ }),
89
+ };
90
+ const res = await runLifecycle(params.task, params.lifecycle, {
91
+ deps: lifecycleFullDeps, mode: "auto",
92
+ onCheckpoint: async (phase) => phase.status === "failed" ? { action: "abort" } : { action: "continue" },
93
+ });
94
+ const isError = res.status === "failed" || res.status === "aborted";
95
+ const summary = `lifecycle ${res.lifecycleName}: ${res.status} (${res.phases.length} phases)\n` +
96
+ res.phases.map((p) => ` ${p.name}: ${p.status}${p.paths.length ? " → " + p.paths.join(", ") : ""}`).join("\n");
97
+ return {
98
+ content: [{ type: "text" as const, text: isError ? (res.error ?? res.status) : summary }],
99
+ details: { runId: res.runId, todoId: res.todoId, lifecycle: res.lifecycleName, status: res.status, phases: res.phases.length },
100
+ isError,
101
+ };
102
+ }
45
103
  const res: SpawnResult = await spawnSubagent({
46
104
  agent: params.agent,
47
105
  task: params.task,
@@ -0,0 +1,23 @@
1
+ # cron-parser (vendored)
2
+
3
+ - **Origin:** https://github.com/harrisi/cron-parser
4
+ - **npm:** `cron-parser`
5
+ - **Version:** 1.1.1 (latest 1.x — dependency-free; later versions pull in `luxon`)
6
+ - **License:** MIT (see upstream LICENSE)
7
+ - **Vendored on:** 2026-07-24
8
+ - **Vendored surface:** `lib/` (4 files: `parser.js`, `expression.js`, `date.js`, `number.js` — CommonJS, all-relative `require()`s, zero runtime deps)
9
+ - **Frozen:** do NOT edit files under `lib/`. To upgrade, replace `lib/` + update this NOTICE (version + date). Note: v2+ adds `luxon` as a runtime dep — vendoring those would require also vendoring luxon; v1.1.1 is intentionally dep-free.
10
+
11
+ ## Why vendored (per SPEC-5a §9, Q9=A)
12
+ cron expression parsing is commodity plumbing (DST, month-length, DOW/DOM OR-semantics, Feb 29).
13
+ We freeze a battle-tested MIT copy rather than reinvent it. The worktree lifecycle, by contrast,
14
+ is greenfield (thin git shell-outs) — see `src/worktree/`.
15
+
16
+ ## API used (v1.1.1)
17
+ ```js
18
+ const cp = require("./lib/parser.js");
19
+ const expr = cp.parseExpression("0 9 * * 1-5", { currentDate: new Date() });
20
+ const nextDate = expr.next(); // CronDate (extends Date) — a real Date instance
21
+ ```
22
+ Note: v1.1.1 has no `tz` option — it uses the process local timezone, which is the right default
23
+ for a dev tool ("9am" means 9am the user's time).
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Date class extension methods
5
+ */
6
+ var extensions = {
7
+ addYear: function addYear() {
8
+ this.setFullYear(this.getFullYear() + 1);
9
+ },
10
+
11
+ addMonth: function addMonth() {
12
+ this.setDate(1);
13
+ this.setHours(0);
14
+ this.setMinutes(0);
15
+ this.setSeconds(0);
16
+ this.setMonth(this.getMonth() + 1);
17
+ },
18
+
19
+ addDay: function addDay() {
20
+ var day = this.getDate();
21
+ this.setDate(day + 1);
22
+
23
+ this.setHours(0);
24
+ this.setMinutes(0);
25
+ this.setSeconds(0);
26
+
27
+ if (this.getDate() === day) {
28
+ this.setDate(day + 2);
29
+ }
30
+ },
31
+
32
+ addHour: function addHour() {
33
+ var hours = this.getHours();
34
+ this.setHours(hours + 1);
35
+
36
+ if (this.getHours() === hours) {
37
+ this.setHours(hours + 2);
38
+ }
39
+
40
+ this.setMinutes(0);
41
+ this.setSeconds(0);
42
+ },
43
+
44
+ addMinute: function addMinute() {
45
+ this.setMinutes(this.getMinutes() + 1);
46
+ this.setSeconds(0);
47
+ },
48
+
49
+ addSecond: function addSecond() {
50
+ this.setSeconds(this.getSeconds() + 1);
51
+ },
52
+
53
+ toUTC: function toUTC() {
54
+ var to = new CronDate(this);
55
+ var ms = to.getTime() + (to.getTimezoneOffset() * 60000);
56
+ to.setTime(ms);
57
+ return to;
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Extends Javascript Date class by adding
63
+ * utility methods for basic date incrementation
64
+ */
65
+
66
+ function CronDate (timestamp) {
67
+ var date = timestamp ? new Date(timestamp) : new Date();
68
+
69
+ // Attach extensions
70
+ var methods = Object.keys(extensions);
71
+ for (var i = 0, c = methods.length; i < c; i++) {
72
+ var method = methods[i];
73
+ date[method] = extensions[method].bind(date);
74
+ }
75
+
76
+ return date;
77
+ }
78
+
79
+ module.exports = CronDate;