@getpipher/armory-fleet 0.14.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
@@ -123,22 +123,22 @@ export const meta = {
123
123
  }
124
124
 
125
125
  phase('Plan')
126
- const plan = await agent('Plan this feature: ' + args.task, { tier: 'low' })
126
+ const plan = await agent('Plan this feature: ' + args.task, { tier: 'economy' })
127
127
 
128
128
  phase('Implement')
129
- const impl = await agent(`Implement the plan:\n${plan}`, { tier: 'medium' })
129
+ const impl = await agent(`Implement the plan:\n${plan}`, { tier: 'standard' })
130
130
 
131
131
  phase('Review')
132
132
  const angles = ['security', 'performance', 'correctness']
133
133
  const reviews = await parallel(
134
- angles.map((a) => () => agent(`Review the implementation for ${a} issues.`, { tier: 'low' })),
134
+ angles.map((a) => () => agent(`Review the implementation for ${a} issues.`, { tier: 'economy' })),
135
135
  )
136
136
 
137
137
  // gate: revise the synthesis until it passes a validator
138
138
  const synthesis = await gate(
139
139
  async (_feedback, n) => n === 0
140
- ? agent(`Synthesize ${reviews.length} reviews.`, { tier: 'low' })
141
- : agent('Revise synthesis per feedback.', { tier: 'low' }),
140
+ ? agent(`Synthesize ${reviews.length} reviews.`, { tier: 'economy' })
141
+ : agent('Revise synthesis per feedback.', { tier: 'economy' }),
142
142
  (v) => typeof v === 'string' && v.length > 200 ? { ok: true } : { ok: false, feedback: 'more detail' },
143
143
  { attempts: 3 },
144
144
  )
@@ -236,9 +236,21 @@ Composite helpers (`src/workflows/helpers/`) — usable from any workflow:
236
236
 
237
237
  | Tier | Models | Cost cap | Context floor |
238
238
  |---|---|---|---|
239
- | `economy` | `Ollama/minimax-m3:cloud` | — | — |
240
- | `standard` | `Ollama/glm-5.2:cloud`, `Ollama/minimax-m3:cloud` | — | — |
241
- | `frontier` | `anthropic/claude-sonnet-4`, `Ollama/glm-5.2:cloud` | $5 | 200k ctx |
239
+ | `economy` | `inherit` | — | — |
240
+ | `standard` | `inherit` | — | — |
241
+ | `frontier` | `inherit` | | 200k ctx |
242
+
243
+ The shipped defaults use the **`inherit` sentinel** — each tier resolves to your **active session model**, so tier routing works on any provider out of the box. To route across models, override a tier by name with a concrete `provider/id` chain in `~/.pi/agent/fleet/tiers.json` (global) or `<project>/.pi/fleet/tiers.json` (project):
244
+
245
+ ```json
246
+ [
247
+ { "name": "economy", "models": ["Ollama/minimax-m3:cloud"] },
248
+ { "name": "standard", "models": ["Ollama/glm-5.2:cloud", "inherit"] },
249
+ { "name": "frontier", "models": ["anthropic/claude-sonnet-4"], "costCap": 5, "contextFloor": 200000 }
250
+ ]
251
+ ```
252
+
253
+ Models are an ordered fallback chain (primary first; a spawn retries the next candidate if model creation is rejected); the `inherit` sentinel (case-insensitive) may appear anywhere in the chain as a provider-agnostic fallback and always resolves to the session model without catalog or floor checks. `contextFloor` skips catalog models below the window size; `costCap` aborts a run whose live cost exceeds the cap (a no-op on flat subscriptions). Tier routing applies to pi-backend agents — `backend: "claude"` agents receive the resolved string via `--model` and the claude CLI expects its own model names, so route those by `model:` instead.
242
254
 
243
255
  Live cost $ and context % are tracked per run and surfaced in the Tiers view. Override per-run with `model`, or let the tier registry route based on the task class.
244
256
 
@@ -314,7 +326,24 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
314
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).
315
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.
316
328
  - **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
317
- - **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.
338
+
339
+ ## Provider-agnostic tiers (v0.15.0)
340
+
341
+ Small-backlog batch (issues #57/#63/#64/#65):
342
+
343
+ - **Tier `inherit` sentinel (#64)** — builtin tiers no longer hardcode a provider: `economy`/`standard`/`frontier` now resolve to your **active session model** out of the box (frontier keeps its 200k context floor; the $5 cost cap moved to the override example — a no-op on flat subs). Override by name in `tiers.json` with concrete `provider/id` chains for real multi-model routing; `inherit` can appear mid-chain as a provider-agnostic fallback. See [Cost-aware tiers](#cost-aware-tiers).
344
+ - **Self-correcting model errors (#57)** — dispatching with a model the runtime doesn't have now lists the session's available (authed) models in the error, so the orchestrating model can pick a valid one on the retry instead of guessing.
345
+ - **Panel Escape semantics documented + dead code removed (#63)** — Escape always cancels the active panel flow; defaults are accepted via Enter-on-blank. (Also fixed: ctrl+c could trigger the never-documented "escape accepts default" callbacks.)
346
+ - **README example tier names fixed (#65)** — the `ship-feature` example now uses real tier names (`economy`/`standard`).
318
347
 
319
348
  ## Dogfood reliability (v0.14.0)
320
349
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.14.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
@@ -109,6 +109,26 @@ async function buildDefaultBackendRegistry(modelRuntime: ModelRuntime): Promise<
109
109
  return reg;
110
110
  }
111
111
 
112
+ /**
113
+ * Format the runtime's available models for the #57 self-correcting error message:
114
+ * dedup `provider/id` pairs, cap the list (min 1 — a non-empty input always lists
115
+ * something), actionable hint when empty.
116
+ */
117
+ export function formatAvailableModels(models: readonly { provider: string; id: string }[], cap = 12): string {
118
+ const effectiveCap = Math.max(1, cap);
119
+ const seen = new Set<string>();
120
+ const list: string[] = [];
121
+ for (const m of models) {
122
+ const ref = `${m.provider}/${m.id}`;
123
+ if (seen.has(ref)) continue;
124
+ seen.add(ref);
125
+ if (list.length < effectiveCap) list.push(ref);
126
+ }
127
+ if (list.length === 0) return "(none — check provider auth / models.json)";
128
+ const omitted = seen.size - list.length;
129
+ return list.join(", ") + (omitted > 0 ? ` … +${omitted} more` : "");
130
+ }
131
+
112
132
  export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort, resumeStore: ResumeStore): ChildSessionFactory {
113
133
  return {
114
134
  async create(opts) {
@@ -119,7 +139,13 @@ export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort
119
139
  const provider = opts.model.slice(0, slash);
120
140
  const id = opts.model.slice(slash + 1);
121
141
  model = modelRuntime.getModel(provider, id);
122
- if (!model) throw new Error(`agent model '${opts.model}' not found in runtime (provider '${provider}', id '${id}')`);
142
+ // #57: name what IS usable so the orchestrating model can self-correct on retry.
143
+ if (!model) {
144
+ throw new Error(
145
+ `agent model '${opts.model}' not found in runtime (provider '${provider}', id '${id}'). ` +
146
+ `Available: ${formatAvailableModels(modelRuntime.getAvailableSnapshot())} — pick one of these for the model param`,
147
+ );
148
+ }
123
149
  }
124
150
  // Fleet CustomResourceLoader: noExtensions + composed systemPromptOverride (rolePrompt + memory + base) + scoped skills.
125
151
  const loader = buildChildLoader({ cwd: opts.cwd, agent: opts.agent, memoryPort });
@@ -267,10 +293,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
267
293
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
268
294
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
269
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,
270
300
  runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
271
301
  }), deps.defaultModelFallback),
272
302
  };
273
- 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" } });
274
304
  return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
275
305
  };
276
306
  // asyncRunnerDeps + scheduler are built per-session (need the session cwd); wired on session_start.
@@ -340,8 +370,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
340
370
  ctx.ui.notify(`reconciled ${reconciled.length} interrupted fleet run${reconciled.length > 1 ? "s" : ""} (marked aborted; linked TODOs reverted to open)`, "info");
341
371
  }
342
372
  });
373
+ const sessionWorktree = new WorktreeService({ rootDir: ctx.cwd });
343
374
  deps.asyncRunner = {
344
- 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 })),
345
380
  diff: new DiffService(),
346
381
  journal: new RunJournal(join(dir, "runs")),
347
382
  pool: new ConcurrencyPool(3),
@@ -357,7 +392,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
357
392
  lockPath: join(dir, "schedules.lock"),
358
393
  onFire: (spec) => {
359
394
  if (!deps.asyncRunner) return;
360
- 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 });
361
396
  },
362
397
  });
363
398
  deps.scheduler.start();
@@ -425,10 +425,8 @@ export class FleetPanel extends Container {
425
425
  this.linkInput.onSubmit = (todoIdRaw: string) => {
426
426
  void this.executeRun(agentName, task.trim(), todoIdRaw.trim() || undefined);
427
427
  };
428
- this.linkInput.onEscape = () => { void this.executeRun(agentName, task.trim(), undefined); };
429
428
  this.renderShell();
430
429
  };
431
- this.taskInput.onEscape = () => this.cancelRun();
432
430
  this.runMode = true;
433
431
  this.renderShell();
434
432
  }
@@ -492,6 +490,12 @@ export class FleetPanel extends Container {
492
490
  }
493
491
 
494
492
  handleInput(data: string): void {
493
+ // Escape policy (#63): every modal branch below intercepts Escape BEFORE the active Input
494
+ // sees it, so pi-tui's Input.onEscape never fires in this panel — Escape always cancels
495
+ // the active flow. Defaults are accepted via Enter-on-blank ("blank=default" prompts).
496
+ // Caveat: ctrl+c also matches pi-tui's tui.select.cancel but is NOT intercepted here —
497
+ // it forwards to the Input, which ignores control characters (silent no-op). An onEscape
498
+ // callback re-added later would fire on ctrl+c but never on Escape — do not re-add.
495
499
  if (this.infoAgent) {
496
500
  if (matchesKey(data, "escape")) { this.infoAgent = null; this.renderShell(); }
497
501
  return;
@@ -811,13 +815,13 @@ export class FleetPanel extends Container {
811
815
  this.pendingCheckpoint = null;
812
816
  this.renderShell();
813
817
  };
814
- this.lcReviseInput.onEscape = () => { this.lcRevising = false; this.lcReviseInput = null; this.renderShell(); };
815
818
  this.renderShell();
816
819
  return;
817
820
  }
818
821
  }
819
822
  if (this.lcRevising && this.lcReviseInput) {
820
- if (matchesKey(data, "escape")) { this.lcRevising = false; this.lcReviseInput = null; this.renderShell(); return; }
823
+ // Escape never reaches here the panel-level intercept above resolves the pending
824
+ // checkpoint as abort + closes the panel first (#63). Only printable input forwards.
821
825
  this.lcReviseInput.handleInput(data);
822
826
  this.invalidate();
823
827
  return;
@@ -842,13 +846,10 @@ export class FleetPanel extends Container {
842
846
  const lcName = name.trim() || "default";
843
847
  this.executeScheduleAdd(task.trim(), expr.trim(), lcName);
844
848
  };
845
- this.schedNameInput.onEscape = () => { this.executeScheduleAdd(task.trim(), expr.trim(), "default"); };
846
849
  this.renderShell();
847
850
  };
848
- this.schedExprInput.onEscape = () => this.cancelScheduleAdd();
849
851
  this.renderShell();
850
852
  };
851
- this.schedTaskInput.onEscape = () => this.cancelScheduleAdd();
852
853
  this.schedRunMode = true;
853
854
  this.renderShell();
854
855
  }
@@ -892,7 +893,6 @@ export class FleetPanel extends Container {
892
893
  if (!followUp.trim()) { this.cancelResume(); return; }
893
894
  void this.executeResume(run, followUp.trim());
894
895
  };
895
- this.resumeInput.onEscape = () => this.cancelResume();
896
896
  this.resumeMode = true;
897
897
  this.renderShell();
898
898
  }
@@ -916,7 +916,6 @@ export class FleetPanel extends Container {
916
916
  if (!text.trim()) { this.cancelSteer(); return; }
917
917
  void this.executeSteer(run.runId, text.trim());
918
918
  };
919
- this.steerInput.onEscape = () => this.cancelSteer();
920
919
  this.steerMode = true;
921
920
  this.renderShell();
922
921
  }
@@ -969,7 +968,6 @@ export class FleetPanel extends Container {
969
968
  }
970
969
  this.tiersInput = new Input();
971
970
  this.tiersInput.onSubmit = (value: string) => { void this.executeTiersEdit(value, phase); };
972
- this.tiersInput.onEscape = () => this.cancelTiersEdit();
973
971
  this.tiersEditPhase = phase;
974
972
  this.renderShell();
975
973
  }
@@ -995,7 +993,6 @@ export class FleetPanel extends Container {
995
993
  }
996
994
  this.cancelWorkflowRun();
997
995
  };
998
- this.wfPromptInput.onEscape = () => this.cancelWorkflowRun();
999
996
  this.wfRunMode = true;
1000
997
  this.renderShell();
1001
998
  }
@@ -1097,10 +1094,8 @@ export class FleetPanel extends Container {
1097
1094
  this.linkInput.onSubmit = (todoIdRaw: string) => {
1098
1095
  void this.executeFork(run.agent, finalTask, todoIdRaw.trim() || undefined, run.runId);
1099
1096
  };
1100
- this.linkInput.onEscape = () => { void this.executeFork(run.agent, finalTask, undefined, run.runId); };
1101
1097
  this.renderShell();
1102
1098
  };
1103
- this.taskInput.onEscape = () => this.cancelRun();
1104
1099
  this.runMode = true;
1105
1100
  this.renderShell();
1106
1101
  }
@@ -1134,19 +1129,16 @@ export class FleetPanel extends Container {
1134
1129
  const lcName = name.trim() || "default";
1135
1130
  this.lcPhase = "cwd";
1136
1131
  this.lcCwdInput = new Input();
1137
- // SPEC-6-5: 3rd input step — the dispatch cwd. Prefilled with the session cwd; Enter
1138
- // accepts it, Escape accepts the default (mirrors the name step's Escape-accepts-default).
1132
+ // SPEC-6-5: 3rd input step — the dispatch cwd. Enter accepts the session cwd (blank)
1133
+ // or a typed path; Escape cancels the run (panel-level intercept #63).
1139
1134
  this.lcCwdInput.onSubmit = (cwd: string) => {
1140
1135
  const picked = cwd.trim() || this.deps.parentCwd;
1141
1136
  void this.executeLifecycleRun(task.trim(), lcName, picked);
1142
1137
  };
1143
- this.lcCwdInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), lcName, this.deps.parentCwd); };
1144
1138
  this.renderShell();
1145
1139
  };
1146
- this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default", this.deps.parentCwd); };
1147
1140
  this.renderShell();
1148
1141
  };
1149
- this.lcTaskInput.onEscape = () => this.cancelLifecycleRun();
1150
1142
  this.lcRunMode = true;
1151
1143
  this.renderShell();
1152
1144
  }
@@ -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
  }));
@@ -1,8 +1,17 @@
1
1
  import type { Tier } from "./tier-registry.ts";
2
2
 
3
- /** Shipped default tiers (Q10). Overridable via global/project tiers.json. */
3
+ /**
4
+ * Shipped default tiers (Q10). Overridable via global/project tiers.json.
5
+ *
6
+ * Provider-agnostic since #64: the `inherit` sentinel resolves to the parent/active
7
+ * session model, so zero-config fleets work on ANY provider. Users who want real
8
+ * multi-model cost routing override these by name with concrete `provider/id`
9
+ * chains (`inherit` may also appear mid-chain as a fallback). `contextFloor`
10
+ * guards concrete candidates; `costCap` is a per-run $ abort (configure where
11
+ * meaningful — it is a no-op on flat subscriptions).
12
+ */
4
13
  export const BUILTIN_TIERS: Tier[] = [
5
- { name: "economy", models: ["Ollama/minimax-m3:cloud"] },
6
- { name: "standard", models: ["Ollama/glm-5.2:cloud", "Ollama/minimax-m3:cloud"] },
7
- { name: "frontier", models: ["anthropic/claude-sonnet-4", "Ollama/glm-5.2:cloud"], costCap: 5, contextFloor: 200000 },
8
- ];
14
+ { name: "economy", models: ["inherit"] },
15
+ { name: "standard", models: ["inherit"] },
16
+ { name: "frontier", models: ["inherit"], contextFloor: 200000 },
17
+ ];
@@ -34,6 +34,20 @@ export function resolveAgentModel(
34
34
  if (!tier) return { error: `tier '${agent.tier}' not found; available: ${tiers.list().map((t) => t.name).join(", ")}` };
35
35
  const candidates: string[] = [];
36
36
  for (const m of tier.models) {
37
+ if (m.trim().toLowerCase() === "inherit") {
38
+ // #64: "inherit" = use the parent/active model — always eligible, no catalog or
39
+ // contextFloor check (the session model is presumed appropriate; the floor guards
40
+ // concrete candidates). Participates in the chain: eligible concrete models listed
41
+ // before it win; after them it acts as the provider-agnostic fallback.
42
+ // Sentinel is case-insensitive/trimmed; concrete model strings stay verbatim.
43
+ // Empty parentModel (dispatch before a session model exists) is NOT pushed — a
44
+ // pure-inherit tier then falls to the descriptive no-eligible-model error below.
45
+ if (parentModel.provider || parentModel.id) {
46
+ const parent = `${parentModel.provider}/${parentModel.id}`;
47
+ if (!candidates.includes(parent)) candidates.push(parent);
48
+ }
49
+ continue;
50
+ }
37
51
  const { provider, id } = splitModel(m, parentModel.provider);
38
52
  const model = modelRegistry.find(provider, id);
39
53
  if (!model) continue; // not in catalog → skip
@@ -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
  }