@getpipher/armory-fleet 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -326,7 +326,15 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
326
326
  - **`userMemory` default flip:** the global cross-project user memory scope (`/__armory-fleet-user__`) is no longer hydrated by default. If you populated that dir + relied on it, add `userMemory: true` to the agent frontmatter (only meaningful with `memoryHydrate: true`). TS consumers constructing `AgentDef` literals must now include `userMemory: boolean` (required field; use `false` for the old default behavior).
327
327
  - **Lifecycle `cwd` field:** lifecycles accept an optional `cwd` frontmatter field to pin a target repo; absent → the entry-point cwd (the panel's chosen cwd, or the dispatching `subagent` tool's cwd/session cwd). When present, it overrides the entry-point cwd for all phases.
328
328
  - **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
329
- - **Deferred:** bg/scheduled + worktree cwd-isolation (the `cwd` param is honored by foreground dispatches only for now) tracked in #62.
329
+ - **bg/scheduled + worktree cwd-isolation (#62):** the `cwd` param now scopes background and scheduled runs too — in-place bg runs pass it as the lifecycle entry cwd, and `isolation: 'worktree'`/`'auto'` resolve isolation (and create the worktree) against the **dispatch cwd's** repo, not the session's. Cross-cwd bg worktrees land in `<child-cwd>/.pi/fleet/worktrees/`.
330
+
331
+ ## Cross-cwd everywhere (v0.16.0)
332
+
333
+ The #62 tail of SPEC-6-5: `subagent({ cwd })` now scopes **background and scheduled** runs too, not just foreground —
334
+
335
+ - **bg/scheduled cwd** — in-place bg runs pass the dispatch cwd as the lifecycle entry cwd (full context scoping: cascade, skills, memory); schedules persist the pinned cwd and honor it on every fire.
336
+ - **Per-dispatch worktrees** — `isolation: 'worktree'` / `'auto'` resolve against the **dispatch cwd's repo**, not the session's; cross-cwd worktrees land in `<child-cwd>/.pi/fleet/worktrees/` and are cleaned up via the same service.
337
+ - **bg lifecycle parity** — the bg adapter now honors the lifecycle cwd chain (`lifecycle.cwd` → dispatch `cwd`), which also fixes a pre-existing mismatch where bg isolated runs spawned phases in the session cwd but committed in the worktree.
330
338
 
331
339
  ## Provider-agnostic tiers (v0.15.0)
332
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",
package/src/index.ts CHANGED
@@ -293,10 +293,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
293
293
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
294
294
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
295
295
  parentCwd: isolated ? opts.worktreePath! : deps.parentCwd,
296
+ // #62: in-place bg runs honor the lifecycle cwd resolution (lifecycle.cwd ?? entryCwd);
297
+ // isolated runs pin the phase cwd to the worktree. parentCwd stays the SESSION cwd for
298
+ // in-place runs so the cross-cwd surfacing (↗ glyph + sessionCwd audit) still fires.
299
+ cwd: isolated ? opts.worktreePath : o.cwd,
296
300
  runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
297
301
  }), deps.defaultModelFallback),
298
302
  };
299
- const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
303
+ const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", entryCwd: opts.entryCwd, onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
300
304
  return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
301
305
  };
302
306
  // asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start.
@@ -366,8 +370,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
366
370
  ctx.ui.notify(`reconciled ${reconciled.length} interrupted fleet run${reconciled.length > 1 ? "s" : ""} (marked aborted; linked TODOs reverted to open)`, "info");
367
371
  }
368
372
  });
373
+ const sessionWorktree = new WorktreeService({ rootDir: ctx.cwd });
369
374
  deps.asyncRunner = {
370
- worktree: new WorktreeService({ rootDir: ctx.cwd }),
375
+ worktree: sessionWorktree,
376
+ // #62: cross-cwd bg dispatches get a per-dispatch worktree service rooted at the dispatch
377
+ // cwd, so isolation:'worktree' creates the worktree from the CHILD's repo (the session
378
+ // service would create it from the session's repo — the #62 gap). Same-cwd → shared.
379
+ worktreeFor: (cwd: string) => (cwd === ctx.cwd ? sessionWorktree : new WorktreeService({ rootDir: cwd })),
371
380
  diff: new DiffService(),
372
381
  journal: new RunJournal(join(dir, "runs")),
373
382
  pool: new ConcurrencyPool(3),
@@ -383,7 +392,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
383
392
  lockPath: join(dir, "schedules.lock"),
384
393
  onFire: (spec) => {
385
394
  if (!deps.asyncRunner) return;
386
- runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation });
395
+ runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation, cwd: spec.cwd });
387
396
  },
388
397
  });
389
398
  deps.scheduler.start();
@@ -26,6 +26,9 @@ export interface RunLifecycleOpts {
26
26
  worktreePath?: string;
27
27
  branch?: string;
28
28
  mode: "auto" | "checkpointed";
29
+ /** #62: the dispatch target cwd for in-place runs (isolated runs pass the worktree path).
30
+ * Flows into runLifecycle's SPEC-6-5 cwd resolution (lifecycle.cwd ?? entryCwd). */
31
+ entryCwd?: string;
29
32
  }
30
33
 
31
34
  export type RunLifecycleFn = (task: string, lifecycleName: string, opts: RunLifecycleOpts) => Promise<FakeLifecycleResult>;
@@ -43,6 +46,11 @@ export interface AsyncRunnerDeps {
43
46
  onProgress?: (runId: string, status: import("../panel/rows.ts").BgRunStatus) => void;
44
47
  /** SPEC-6-2: the RunRegistry so emitProgress can read the run's actual backend. */
45
48
  runRegistry?: import("../engine/run-registry.ts").RunRegistry;
49
+ /** #62: per-dispatch worktree service for cross-cwd bg runs (rootDir = the dispatch cwd).
50
+ * Consulted whenever a bg dispatch carries a `cwd`; absent → deps.worktree (session-scoped,
51
+ * pre-#62 behavior). index.ts wires it so a cross-cwd worktree is created from the CHILD's
52
+ * repo, not the session's. */
53
+ worktreeFor?: (cwd: string) => WorktreeService;
46
54
  }
47
55
 
48
56
  export interface RunBackgroundOpts {
@@ -51,6 +59,10 @@ export interface RunBackgroundOpts {
51
59
  mode: "auto" | "checkpointed";
52
60
  /** v0.11.1: edit isolation for background runs. Default "auto" (worktree when cwd is a git repo, in-place otherwise). */
53
61
  isolation?: Isolation;
62
+ /** #62: the dispatch target cwd (undefined = session cwd, back-compat). Scopes the run:
63
+ * isolation routing + worktree creation resolve against THIS cwd (via deps.worktreeFor),
64
+ * and in-place runs pass it as the lifecycle entryCwd. */
65
+ cwd?: string;
54
66
  }
55
67
 
56
68
  /** The success shape (back-compat: existing external refs to RunBackgroundHandle still typecheck). */
@@ -82,9 +94,16 @@ function sh(cmd: string, cwd: string): void {
82
94
  execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
83
95
  }
84
96
 
97
+ /** #62: the worktree service for a dispatch — per-dispatch (rootDir = the dispatch cwd) when
98
+ * deps.worktreeFor is wired, else the session-scoped deps.worktree (back-compat). */
99
+ function worktreeFor(deps: AsyncRunnerDeps, cwd?: string): WorktreeService {
100
+ if (!cwd) return deps.worktree;
101
+ return deps.worktreeFor?.(cwd) ?? deps.worktree;
102
+ }
103
+
85
104
  /** The git-agnostic core: pool slot → journal → runLifecycle in ctx.cwd (or a worktree, when `isolated` is set) → inbox + notify.
86
105
  * Fire-and-forget. When `isolated` is present, journals the worktree field, commits on completion, and removes the worktree. */
87
- function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOpts, isolated?: { worktreePath: string; branch: string }): void {
106
+ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOpts, isolated?: { worktreePath: string; branch: string }, worktree: WorktreeService = opts.deps.worktree): void {
88
107
  const { deps } = opts;
89
108
  void deps.pool.withSlot(async () => {
90
109
  try {
@@ -94,7 +113,7 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
94
113
  deps.journal.append(runId, ev0);
95
114
  emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
96
115
 
97
- const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode });
116
+ const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode, entryCwd: isolated ? isolated.worktreePath : opts.cwd });
98
117
 
99
118
  if (res.status === "completed") {
100
119
  if (isolated) {
@@ -116,17 +135,17 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
116
135
  };
117
136
  deps.inbox.push(result);
118
137
  deps.notify(`fleet run ${runId} completed`, "info");
119
- if (isolated) deps.worktree.removeWorktree(runId);
138
+ if (isolated) worktree.removeWorktree(runId);
120
139
  } else {
121
140
  deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() });
122
- if (isolated) deps.worktree.remove(runId);
141
+ if (isolated) worktree.remove(runId);
123
142
  emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: res.phases.length, lifecycle: opts.lifecycle, mode: opts.mode, task });
124
143
  deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning");
125
144
  }
126
145
  } catch (e) {
127
146
  const msg = (e as Error).message;
128
147
  deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() });
129
- if (isolated) deps.worktree.remove(runId);
148
+ if (isolated) worktree.remove(runId);
130
149
  deps.notify(`fleet run ${runId} failed: ${msg}`, "error");
131
150
  emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
132
151
  }
@@ -135,34 +154,34 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
135
154
 
136
155
  /** The worktree wrapper: SYNCHRONOUS pre-flight + worktree create, then core-with-isolation. */
137
156
  function runBackgroundIsolated(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
138
- const { deps } = opts;
139
- if (!deps.worktree.isGitRepo()) {
157
+ const worktree = worktreeFor(opts.deps, opts.cwd);
158
+ if (!worktree.isGitRepo()) {
140
159
  return { status: "failed", error: "isolation: 'worktree' requires a git repo; cwd is not one — use isolation: 'none' or run in a git repo" };
141
160
  }
142
- const runId = deps.genRunId();
161
+ const runId = opts.deps.genRunId();
143
162
  const baseRef = "HEAD";
144
163
  let wt: { path: string; branch: string };
145
164
  try {
146
- wt = deps.worktree.create(runId, baseRef);
165
+ wt = worktree.create(runId, baseRef);
147
166
  } catch (e) {
148
167
  return { status: "failed", error: (e as Error).message };
149
168
  }
150
- runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch });
169
+ runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch }, worktree);
151
170
  return { runId, status: "background" };
152
171
  }
153
172
 
154
- /** The auto router: isolated when cwd is a git repo, in-place + one per-session notify when not. */
173
+ /** The auto router: isolated when the DISPATCH cwd is a git repo, in-place + one per-session notify when not. */
155
174
  function runBackgroundAuto(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
156
- const { deps } = opts;
157
- if (deps.worktree.isGitRepo()) {
175
+ const worktree = worktreeFor(opts.deps, opts.cwd);
176
+ if (worktree.isGitRepo()) {
158
177
  return runBackgroundIsolated(task, opts);
159
178
  }
160
179
  if (!inPlaceFallbackWarned) {
161
180
  inPlaceFallbackWarned = true;
162
- deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
181
+ opts.deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
163
182
  }
164
- const runId = deps.genRunId();
165
- runBackgroundInPlace(runId, task, opts);
183
+ const runId = opts.deps.genRunId();
184
+ runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
166
185
  return { runId, status: "background" };
167
186
  }
168
187
 
@@ -172,7 +191,7 @@ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgro
172
191
  if (isolation === "worktree") return runBackgroundIsolated(task, opts);
173
192
  if (isolation === "none") {
174
193
  const runId = opts.deps.genRunId();
175
- runBackgroundInPlace(runId, task, opts);
194
+ runBackgroundInPlace(runId, task, opts, undefined, worktreeFor(opts.deps, opts.cwd));
176
195
  return { runId, status: "background" };
177
196
  }
178
197
  return runBackgroundAuto(task, opts);
@@ -13,6 +13,8 @@ export interface ScheduleSpec {
13
13
  auto?: boolean;
14
14
  /** v0.11.1: edit isolation for the background run on fire. Default "auto". */
15
15
  isolation?: "worktree" | "none" | "auto";
16
+ /** #62: the dispatch target cwd (undefined = session cwd). Threaded to runBackground on fire. */
17
+ cwd?: string;
16
18
  }
17
19
 
18
20
  export interface Schedule extends ScheduleSpec {
@@ -74,6 +76,7 @@ export class Scheduler {
74
76
  lifecycle: spec.lifecycle ?? "default",
75
77
  auto: spec.auto ?? true,
76
78
  isolation: spec.isolation,
79
+ ...(spec.cwd ? { cwd: spec.cwd } : {}),
77
80
  paused: false,
78
81
  };
79
82
  this.schedules.set(id, { spec: stored, expr, timer: null });
@@ -90,6 +93,7 @@ export class Scheduler {
90
93
  lifecycle: e.spec.lifecycle,
91
94
  auto: e.spec.auto,
92
95
  isolation: e.spec.isolation,
96
+ ...(e.spec.cwd ? { cwd: e.spec.cwd } : {}),
93
97
  paused: e.spec.paused,
94
98
  nextFire: e.spec.paused ? null : e.expr.nextFire(new Date()),
95
99
  }));
@@ -131,13 +131,13 @@ export function createSubagentTool(deps: SubagentToolDeps) {
131
131
  }
132
132
  if (params.schedule) {
133
133
  if (!deps.scheduler) return { isError: true, content: [{ type: "text" as const, text: "scheduling not configured (scheduler missing)" }] };
134
- const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true, isolation: params.isolation });
134
+ const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true, isolation: params.isolation, cwd: resolvedCwd });
135
135
  const entry = deps.scheduler.list().find((s) => s.id === id);
136
136
  return { content: [{ type: "text" as const, text: `scheduled: ${id} · next fire: ${entry?.nextFire?.toISOString() ?? "(paused)"}` }], details: { scheduleId: id, nextFire: entry?.nextFire ?? null } };
137
137
  }
138
138
  if (params.background) {
139
139
  if (!deps.asyncRunner) return { isError: true, content: [{ type: "text" as const, text: "background runs not configured (asyncRunner missing)" }] };
140
- const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto", isolation: params.isolation });
140
+ const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto", isolation: params.isolation, cwd: resolvedCwd });
141
141
  if (handle.status === "failed") return { isError: true, content: [{ type: "text" as const, text: handle.error }] };
142
142
  return { content: [{ type: "text" as const, text: `background run: ${handle.runId}` }], details: handle };
143
143
  }