@getpipher/armory-fleet 1.0.0 → 1.1.1

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
@@ -254,6 +254,20 @@ Models are an ordered fallback chain (primary first; a spawn retries the next ca
254
254
 
255
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.
256
256
 
257
+ ### Fleet settings (v1.1.0)
258
+
259
+ `~/.pi/agent/fleet/settings.json` (global) and `<project>/.pi/fleet/settings.json` (project — wins per-field) hold fleet-wide defaults that aren't env-shaped. Currently one field:
260
+
261
+ ```json
262
+ {
263
+ "defaultSubagentThinking": "high"
264
+ }
265
+ ```
266
+
267
+ `defaultSubagentThinking` (#78) sets the thinking level for **every subagent whose frontmatter does not pin `thinkingLevel`** — so a controller can think at `max` while the fleet runs at `high`, without replacing builtin agents (agent-frontmatter overrides still win; per-dispatch overrides are not exposed yet). Valid values: `off | minimal | low | medium | high | xhigh | max`. On flat subscriptions (z.ai/GLM coding plans, Ollama) this is the quota lever — thinking tokens on every cheap subagent call are pure waste.
268
+
269
+ Invalid values and unknown keys produce a startup warning naming the file, the field, and the bad value — never a silent no-op. Absent files are normal.
270
+
257
271
  ### Operational runtime
258
272
 
259
273
  `src/runtime/` — the async/scheduling spine:
@@ -350,9 +364,20 @@ monotonic, one space per source store):
350
364
 
351
365
  Emit `{ id, verb, params }`, get exactly one reply `{ id, ok, data }` or
352
366
  `{ id, ok: false, error: { code, message } }`. Verbs: `spawn` (returns `{ runId }`
353
- immediately; result arrives via `fleet:run:ended`), `status`, `observe` (replay dump
354
- subscribe to the broadcast channels + dedupe by `(channel, runId, seq)` for the live tail),
355
- `steer`, `abort`.
367
+ immediately; result arrives via `fleet:run:ended`), `schedule` (#83 returns
368
+ `{ scheduleId, nextFire }`; schedules run lifecycles, not single delegates), `status`,
369
+ `observe` (replay dump — subscribe to the broadcast channels + dedupe by
370
+ `(channel, runId, seq)` for the live tail), `steer`, `abort`.
371
+
372
+ `spawn` params: `agent`, `task`, `background?`, `lifecycle?` (named lifecycle — with
373
+ `background: true` routes through the bg runner; without it runs as a detached
374
+ foreground-semantics lifecycle), `modelFallback?` (per-request retry-once on a retryable
375
+ provider failure — the retry mints a fresh runId and relinks the primary's todo), `cwd?`,
376
+ `isolation?`, `maxTurns?`, `model?`, `skills?`, `readOnly?`, `track?`, `todoId?`.
377
+
378
+ `schedule` params: `task`, `expression` (cron or interval), `lifecycle?`, `auto?`,
379
+ `isolation?`, `cwd?` — no `agent`: the scheduler runs lifecycles only. Registration emits
380
+ no run events; on fire, the normal `fleet:*` stream flows with `mode: "scheduled"`.
356
381
 
357
382
  Error codes: `E-CONTROL-DISABLED`, `E-RUN-NOT-FOUND`, `E-RUN-FINISHED`, `E-BAD-VERB`,
358
383
  `E-BAD-PARAMS`, `E-STEER-UNSUPPORTED`, `E-INTERNAL`.
@@ -381,7 +406,7 @@ export function fleetRpc(pi: { events: { emit(c: string, d: unknown): void; on(c
381
406
 
382
407
  ### Control gate
383
408
 
384
- `spawn`/`steer`/`abort` are on by default. Set `ARMORY_FLEET_RPC_CONTROL=0` (or `false`) to
409
+ `spawn`/`steer`/`abort`/`schedule` are on by default. Set `ARMORY_FLEET_RPC_CONTROL=0` (or `false`) to
385
410
  reject them with `E-CONTROL-DISABLED`; read-only `observe`/`status` stay available. Honest
386
411
  threat model: in-process extensions already have full system access through pi itself — the
387
412
  switch guards accidents, not adversaries.
@@ -400,6 +425,14 @@ The #62 tail of SPEC-6-5: `subagent({ cwd })` now scopes **background and schedu
400
425
  - **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.
401
426
  - **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.
402
427
 
428
+ ## RPC parity + fleet settings (v1.1.0)
429
+
430
+ Post-v1.0 batch (issues #78/#83):
431
+
432
+ - **`fleet.defaultSubagentThinking` (#78)** — a fleet-dir settings home (`~/.pi/agent/fleet/settings.json` global, `<project>/.pi/fleet/settings.json` project override) whose first field sets the thinking level for every subagent that doesn't pin its own — the quota lever for flat subscriptions. See [Fleet settings](#fleet-settings-v110).
433
+ - **RPC spawn parity (#83)** — `spawn` gains `lifecycle` (bg routing, or a detached foreground-semantics lifecycle run) and `modelFallback` (per-request retry-once; the retry mints a fresh runId and relinks the primary's todo).
434
+ - **New `schedule` verb (#83)** — `{ task, expression, lifecycle?, auto?, isolation?, cwd? }` → `{ scheduleId, nextFire }`. Schedules run lifecycles only, so `spawn`'s uniform `{ runId }` reply stays unbranched; the frozen surface needed zero renames and zero new error codes.
435
+
403
436
  ## Provider-agnostic tiers (v0.15.0)
404
437
 
405
438
  Small-backlog batch (issues #57/#63/#64/#65):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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",
@@ -31,3 +31,17 @@ export function withModelFallbackRetry(spawn: SpawnFn, fallback: string | undefi
31
31
  return first;
32
32
  };
33
33
  }
34
+
35
+ /** #83 D3: the RPC foreground-semantics detached spawn's retry — mirrors the tool's direct-path
36
+ * contract (retry ONCE, retryable failures only, DISTINCT fallback model) but the retry runs
37
+ * WITHOUT the pre-minted runId: the primary already journaled run:started/ended under it, so
38
+ * the retry must mint a fresh id (a reused id would double-emit run:started). `todoId` links
39
+ * the retry to the primary's armory-todo task (same relink contract as the tool). */
40
+ export async function retryForegroundOnce(
41
+ primary: SpawnResult,
42
+ fallback: string | undefined,
43
+ spawn: (o: { model: string; todoId?: string }) => Promise<SpawnResult>,
44
+ ): Promise<SpawnResult> {
45
+ if (!fallback || primary.status !== "failed" || !primary.retryable || fallback === primary.model) return primary;
46
+ return spawn({ model: fallback, todoId: primary.todoId ?? undefined });
47
+ }
@@ -0,0 +1,23 @@
1
+ // src/engine/session-rejection.ts
2
+ // #84: typed rejections for the live-session control path (steer/abort). The RPC verbs
3
+ // previously classified failures by fragile message-substring matching; the handles now
4
+ // throw this class and the verbs match on `reason` first. Message TEXT is unchanged at
5
+ // the existing throw sites — string matching survives as a back-compat fallback for
6
+ // third-party ChildSession implementations that bubble bare Errors.
7
+ export type SessionRejectionReason =
8
+ | "steer-unsupported" // the backend has no steer (e.g. claude children)
9
+ | "already-processing" // the session is mid-turn and cannot take the steer now
10
+ | "already-aborted"; // the session was already stopped
11
+
12
+ export class SessionRejectionError extends Error {
13
+ readonly reason: SessionRejectionReason;
14
+ constructor(reason: SessionRejectionReason, message: string) {
15
+ super(message);
16
+ this.name = "SessionRejectionError";
17
+ this.reason = reason;
18
+ }
19
+ }
20
+
21
+ export function isSessionRejection(e: unknown): e is SessionRejectionError {
22
+ return e instanceof SessionRejectionError;
23
+ }
@@ -7,6 +7,7 @@ import type { BackendRegistry } from "../backend/port.ts";
7
7
  import { genRunId, RunRegistry } from "./run-registry.ts";
8
8
  import type { RunRecord } from "./run-registry.ts";
9
9
  import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
10
+ import { SessionRejectionError } from "./session-rejection.ts";
10
11
  import type { ForegroundLock } from "./concurrency-lock.ts";
11
12
  import type { RunLog } from "../runtime/run-log.ts";
12
13
  import { buildToolEvent } from "../runtime/run-log.ts";
@@ -83,7 +84,7 @@ export interface LiveSessionHandle {
83
84
  * `supportsSteer` is derived from whether the backend implemented the optional `steer`. */
84
85
  export function toLiveHandle(session: ChildSession): LiveSessionHandle {
85
86
  return {
86
- steer: (text) => session.steer ? session.steer(text) : Promise.reject(new Error("steer not supported on this backend")),
87
+ steer: (text) => session.steer ? session.steer(text) : Promise.reject(new SessionRejectionError("steer-unsupported", "steer not supported on this backend")),
87
88
  abort: () => session.abort(),
88
89
  subscribe: (h) => session.subscribe(h),
89
90
  get isStreaming() { return session.isStreaming ?? false; },
@@ -158,6 +159,10 @@ export interface SpawnOptions {
158
159
  modelRegistry?: ModelRegistryLike;
159
160
  /** SPEC-6-3: workflow adapter tier override — replaces agent.tier before model resolution. */
160
161
  tierOverride?: string;
162
+ /** #78: fleet-wide default thinking level (`fleet.defaultSubagentThinking` settings field).
163
+ * Applied only when the agent frontmatter does NOT pin `thinkingLevel` — precedence:
164
+ * agent.thinkingLevel > opts.defaultThinkingLevel > backend/session default. */
165
+ defaultThinkingLevel?: ThinkingLevel;
161
166
  /** #31: when true, the caller asserts this dispatch will NOT mutate the working directory
162
167
  * (review/audit/research). Read-only dispatches bypass the foreground single-slot lock so
163
168
  * multiple readOnly dispatches — and/or a readOnly dispatch alongside a write dispatch — can
@@ -201,7 +206,7 @@ export interface SpawnResult {
201
206
  /** #49: extract file paths a tool event touched, for the structured partial-result report.
202
207
  * edit/write carry a `path` arg reliably; bash is best-effort (redirections `>`/`>>` + `tee` to a
203
208
  * path-like token). Reads are NOT mutations and are excluded. */
204
- function extractTouchedFiles(toolName: string, args: unknown): string[] {
209
+ export function extractTouchedFiles(toolName: string, args: unknown): string[] {
205
210
  if (!args || typeof args !== "object") return [];
206
211
  const a = args as Record<string, unknown>;
207
212
  // Case-insensitive: pi tools are lowercase ("edit"), claude's are capitalized ("Edit").
@@ -219,19 +224,41 @@ function extractTouchedFiles(toolName: string, args: unknown): string[] {
219
224
  const redir = />>?\s+([^\s|;&<>]+)/g;
220
225
  let m: RegExpExecArray | null;
221
226
  while ((m = redir.exec(cmd)) !== null) {
222
- const tok = m[1];
223
- if (tok && /[/.]/.test(tok)) out.push(tok);
227
+ const tok = m[1] ? shapeToken(m[1]) : undefined;
228
+ if (tok) out.push(tok);
224
229
  }
225
230
  const tee = /\btee\s+(?:-a\s+)?([^\s|;&<>]+)/g;
226
231
  while ((m = tee.exec(cmd)) !== null) {
227
- const tok = m[1];
228
- if (tok && /[/.]/.test(tok)) out.push(tok);
232
+ const tok = m[1] ? shapeToken(m[1]) : undefined;
233
+ if (tok) out.push(tok);
229
234
  }
230
235
  return out;
231
236
  }
232
237
  return [];
233
238
  }
234
239
 
240
+ /** #87: decide whether a redirect/tee capture is plausibly a file path. The bare `/[/.]/`
241
+ * test false-positived on `>` characters inside quoted strings/heredocs/code text
242
+ * ("cache.load(name,,", "[...active],,") and swallowed trailing punctuation from code-y
243
+ * commands ("/tmp/out.txt),"). Now: strip wrapping quotes, trim trailing punctuation,
244
+ * then require a real path shape (contains `/`, or a dotted filename, or a leading-dot
245
+ * file like .gitignore). */
246
+ function shapeToken(raw: string): string | undefined {
247
+ let tok = raw.trim().replace(/^["']+/, "");
248
+ // trailing junk can interleave ("'/tmp/x.txt',") — strip quotes+punct until stable
249
+ let prev: string;
250
+ do {
251
+ prev = tok;
252
+ tok = tok.replace(/["',;)\]}]+$/g, "");
253
+ } while (tok !== prev);
254
+ if (!tok) return undefined;
255
+ if (/^\/dev\/(null|stdout|stderr|stdin|tty|zero)$/.test(tok)) return undefined; // pseudo-devices, not touched files
256
+ if (tok.includes("/")) return tok;
257
+ if (/^\.[A-Za-z0-9._-]+$/.test(tok)) return tok; // .gitignore and friends
258
+ if (/^[A-Za-z0-9._-]+\.[A-Za-z0-9]+$/.test(tok)) return tok; // name.ext
259
+ return undefined;
260
+ }
261
+
235
262
  export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
236
263
  const track = opts.track ?? true;
237
264
  const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
@@ -324,7 +351,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
324
351
  priorStatus = link.priorStatus;
325
352
  opts.runRegistry.update(runId, { todoId });
326
353
  } catch (e) {
327
- return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, (e as Error).message, agentDef.name, model);
354
+ return await finishRun({
355
+ opts, runId, startedAt, status: "failed", finalText: "", todoId, priorStatus,
356
+ error: (e as Error).message, agentName: agentDef.name, model,
357
+ });
328
358
  }
329
359
 
330
360
  // SPEC-6-1: fallback retry loop — try candidates[0], on rejection retry candidates[1], etc.
@@ -335,7 +365,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
335
365
  const result = await backend.factory.create({
336
366
  cwd: childCwd,
337
367
  model: cand,
338
- thinkingLevel: childAgent.thinkingLevel,
368
+ // #78: agent frontmatter wins; the fleet-wide default fills the gap.
369
+ thinkingLevel: childAgent.thinkingLevel ?? opts.defaultThinkingLevel,
339
370
  tools,
340
371
  rolePrompt: childAgent.rolePrompt,
341
372
  skills: childAgent.skills ?? [],
@@ -349,7 +380,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
349
380
  } catch (e) { lastErr = e as Error; }
350
381
  }
351
382
  if (!session) {
352
- return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, `backend create failed: ${lastErr?.message ?? "unknown"}`, agentDef.name, model, 0, 0, 0);
383
+ return await finishRun({
384
+ opts, runId, startedAt, status: "failed", finalText: "", todoId, priorStatus,
385
+ error: `backend create failed: ${lastErr?.message ?? "unknown"}`, agentName: agentDef.name, model,
386
+ });
353
387
  }
354
388
 
355
389
  // SPEC-5b-4: retain a narrow live-session handle on the run record so the panel can
@@ -554,7 +588,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
554
588
  status = "completed";
555
589
  }
556
590
 
557
- return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary, toolCallCount);
591
+ return await finishRun({
592
+ opts, runId, startedAt, status, finalText, todoId, priorStatus, error,
593
+ agentName: agentDef.name, model, tokenTotal, costTotal, contextTokens,
594
+ retryable: modelError ? true : undefined, filesTouched: filesTouchedList,
595
+ reachedSummary, toolCallCount,
596
+ });
558
597
  } finally {
559
598
  // #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
560
599
  // (releasing a lock held by another concurrent write dispatch would corrupt serialization).
@@ -572,13 +611,34 @@ function fail(runId: string, startedAt: number, message: string, agent: string):
572
611
  /** SPEC-6-2: guard against double-finishRun (abort-then-complete). */
573
612
  const finalizedRunIds = new Set<string>();
574
613
 
575
- async function finishRun(
576
- opts: SpawnOptions, runId: string, startedAt: number,
577
- status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
578
- error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
579
- retryable?: boolean,
580
- filesTouched?: string[], reachedSummary?: boolean, toolCallCount = 0,
581
- ): Promise<SpawnResult> {
614
+ /** #NIT: options object — was 17 positional params (3 call sites, error-prone at the tail). */
615
+ interface FinishRunArgs {
616
+ opts: SpawnOptions;
617
+ runId: string;
618
+ startedAt: number;
619
+ status: FleetRunStatus;
620
+ finalText: string;
621
+ todoId: string | null;
622
+ priorStatus?: string;
623
+ error?: string;
624
+ agentName: string;
625
+ model: string;
626
+ tokenTotal?: number;
627
+ costTotal?: number;
628
+ contextTokens?: number;
629
+ retryable?: boolean;
630
+ filesTouched?: string[];
631
+ reachedSummary?: boolean;
632
+ toolCallCount?: number;
633
+ }
634
+
635
+ async function finishRun(a: FinishRunArgs): Promise<SpawnResult> {
636
+ const { opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentName, model } = a;
637
+ const tokenTotal = a.tokenTotal ?? 0;
638
+ const costTotal = a.costTotal ?? 0;
639
+ const contextTokens = a.contextTokens ?? 0;
640
+ const toolCallCount = a.toolCallCount ?? 0;
641
+ const { retryable, filesTouched, reachedSummary } = a;
582
642
  if (finalizedRunIds.has(runId)) {
583
643
  // Already finalized — return the existing registry record's result without re-appending.
584
644
  const existing = opts.runRegistry.get(runId);
package/src/index.ts CHANGED
@@ -8,10 +8,11 @@ import {
8
8
  getAgentDir,
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
- import { createSubagentTool, resolveDispatchCwd, type SubagentToolDeps } from "./tools/subagent.ts";
11
+ import { createSubagentTool, mergeLifecycleSkills, resolveDispatchCwd, type SubagentToolDeps } from "./tools/subagent.ts";
12
12
  // SPEC-6-3: /fleet uses openWorkflowPanelLoop (Task 12) instead of the raw openFleetPanel factory.
13
13
  import { discoverAgents } from "./registry/discovery.ts";
14
14
  import { RunRegistry } from "./engine/run-registry.ts";
15
+ import { SessionRejectionError } from "./engine/session-rejection.ts";
15
16
  import { createSingleSlotLock, createForegroundLock } from "./engine/concurrency-lock.ts";
16
17
  import { ArmoryTodoAdapter } from "./todo-sync/adapter.ts";
17
18
  import { ArmoryMemoryAdapter } from "./memory-hydrate/adapter.ts";
@@ -52,6 +53,7 @@ import { FleetWidgetController } from "./panel/fleet-widget.ts";
52
53
  import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts";
53
54
  import { BUILTIN_TIERS } from "./tiers/builtin.ts";
54
55
  import { TierStore } from "./tiers/tier-store.ts";
56
+ import { FleetSettingsStore } from "./settings/fleet-settings.ts";
55
57
  import { splitModel } from "./tiers/resolve.ts";
56
58
  import { WorkflowJournal } from "./workflows/journal.ts";
57
59
  import { discoverWorkflows, WorkflowRegistry, type WorkflowDef } from "./workflows/registry.ts";
@@ -82,7 +84,8 @@ function wrapPiSession(inner: ChildSession, backendSessionId: string): ChildSess
82
84
  return inner.subscribe(handler);
83
85
  },
84
86
  // SPEC-5b-4: forward the native SDK steer + isStreaming to the real pi session.
85
- steer: (t) => inner.steer ? inner.steer(t) : Promise.reject(new Error("pi session has no steer")),
87
+ // #84: typed rejection (message text unchanged back-compat with string matching).
88
+ steer: (t) => inner.steer ? inner.steer(t) : Promise.reject(new SessionRejectionError("steer-unsupported", "pi session has no steer")),
86
89
  get isStreaming() { return inner.isStreaming ?? false; },
87
90
  };
88
91
  }
@@ -304,7 +307,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
304
307
  // in-place runs so the cross-cwd surfacing (↗ glyph + sessionCwd audit) still fires.
305
308
  cwd: isolated ? opts.worktreePath : o.cwd,
306
309
  runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
307
- }), deps.defaultModelFallback),
310
+ defaultThinkingLevel: deps.defaultSubagentThinking,
311
+ }), opts.modelFallback ?? deps.defaultModelFallback),
308
312
  };
309
313
  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" } });
310
314
  return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
@@ -429,33 +433,84 @@ export default async function (pi: ExtensionAPI): Promise<void> {
429
433
  journal: fleetJournal,
430
434
  parentCwd: ctx.cwd,
431
435
  hasAsyncRunner: true,
436
+ // #83 D4: schedule registration — scheduler.register + nextFire lookup. Throws (invalid
437
+ // expression) propagate to the verb, which maps them to E-BAD-PARAMS.
438
+ schedule: (spec) => {
439
+ if (!deps.scheduler) throw new Error("scheduling not configured in this session (scheduler missing)");
440
+ const id = deps.scheduler.register({
441
+ task: String(spec.task),
442
+ expression: String(spec.expression),
443
+ ...(typeof spec.lifecycle === "string" ? { lifecycle: spec.lifecycle } : {}),
444
+ ...(typeof spec.auto === "boolean" ? { auto: spec.auto } : {}),
445
+ ...(spec.isolation === "worktree" || spec.isolation === "none" || spec.isolation === "auto" ? { isolation: spec.isolation } : {}),
446
+ ...(typeof spec.cwd === "string" ? { cwd: spec.cwd } : {}),
447
+ });
448
+ const entry = deps.scheduler.list().find((s) => s.id === id);
449
+ return { scheduleId: id, nextFire: entry?.nextFire?.toISOString() ?? null };
450
+ },
432
451
  spawn: (params: Record<string, unknown>, runId: string) => {
433
452
  // Detached fire-and-forget: NEVER throws — spawnSubagent journals its own fail path
434
453
  // (run:ended + todo revert), so the caller's pre-minted runId always resolves to events.
435
454
  void (async () => {
436
455
  const requestedCwd = typeof params.cwd === "string" ? params.cwd : undefined;
437
456
  const { cwd: resolvedCwd } = resolveDispatchCwd(requestedCwd, ctx.cwd);
457
+ // #83: lifecycle + per-request modelFallback params (validated by the verb; re-narrowed here).
458
+ const rpcLifecycle = typeof params.lifecycle === "string" && params.lifecycle ? params.lifecycle : "default";
459
+ const rpcFallback = typeof params.modelFallback === "string" && params.modelFallback ? params.modelFallback : undefined;
438
460
  if (params.background === true) {
439
461
  // RPC background: routed through the async runner (pool slot + isolation + origin),
440
462
  // identical to the tool's runBackground path (spec §3.2). Ghost-runId prevention:
441
463
  // a synchronous pre-flight failure journals run:ended failed under the caller's id.
442
464
  const handle = runBackground(String(params.task), {
443
465
  deps: deps.asyncRunner!,
444
- lifecycle: "default",
466
+ lifecycle: rpcLifecycle,
445
467
  mode: "auto",
446
468
  isolation: params.isolation as "worktree" | "none" | "auto" | undefined,
447
469
  cwd: resolvedCwd ?? ctx.cwd,
448
470
  origin: "background",
449
471
  runId,
472
+ ...(rpcFallback ? { modelFallback: rpcFallback } : {}),
450
473
  });
451
474
  if (handle.status === "failed") {
452
475
  deps.runLog?.append(runId, { type: "run:ended", runId, status: "failed", endedAt: Date.now(), tokenTotal: 0, error: handle.error });
453
476
  }
454
477
  return;
455
478
  }
479
+ if (params.lifecycle !== undefined) {
480
+ // #83 D2: lifecycle WITHOUT background = detached foreground-semantics lifecycle run
481
+ // (tool parity: mode "auto", failed-phase checkpoint aborts, session lock on the phase
482
+ // spawn, pre-minted runId via the genRunId override — same pattern as asyncRunLifecycle).
483
+ const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts");
484
+ const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
485
+ const { withModelFallbackRetry } = await import("./engine/retry-fallback.ts");
486
+ const lifecycleFullDeps = {
487
+ ...deps.lifecycleDeps,
488
+ genRunId: () => runId,
489
+ spawn: withModelFallbackRetry(async (o) => spawnSubagent({
490
+ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
491
+ skillsOverride: mergeLifecycleSkills(o.skills, Array.isArray(params.skills) ? params.skills as string[] : undefined),
492
+ backendOverride: o.backend,
493
+ registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
494
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: ctx.cwd,
495
+ runLog: deps.runLog, signal: undefined,
496
+ tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
497
+ defaultThinkingLevel: deps.defaultSubagentThinking,
498
+ maxTurns: typeof params.maxTurns === "number" ? params.maxTurns : undefined,
499
+ readOnly: params.readOnly === true,
500
+ cwd: o.cwd ?? resolvedCwd,
501
+ }), rpcFallback ?? deps.defaultModelFallback),
502
+ };
503
+ const res = await runLifecycle(String(params.task), rpcLifecycle, {
504
+ deps: lifecycleFullDeps, mode: "auto", entryCwd: resolvedCwd,
505
+ onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" },
506
+ });
507
+ deps.lifecycleRuns.set(res.runId, res); // panel Lifecycle-view visibility (workflow-lambda precedent)
508
+ return;
509
+ }
456
510
  // Foreground-semantics detached spawn (no mode — "foreground" is the truth).
457
511
  const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
458
- await spawnSubagent({
512
+ const { retryForegroundOnce } = await import("./engine/retry-fallback.ts");
513
+ const primary = await spawnSubagent({
459
514
  agent: params.agent as string,
460
515
  task: params.task as string,
461
516
  todoId: typeof params.todoId === "string" ? params.todoId : undefined,
@@ -476,7 +531,33 @@ export default async function (pi: ExtensionAPI): Promise<void> {
476
531
  modelRegistry: deps.modelRegistry,
477
532
  cwd: resolvedCwd ?? ctx.cwd,
478
533
  runId,
534
+ defaultThinkingLevel: deps.defaultSubagentThinking,
479
535
  });
536
+ // #83 D3: per-request fallback retry — the retry mints a FRESH runId (the primary's
537
+ // pre-minted id stays retired; a reuse would double-emit run:started) and relinks the
538
+ // primary's todo, exactly like the tool's direct path.
539
+ await retryForegroundOnce(primary, rpcFallback, (o) => spawnSubagent({
540
+ agent: params.agent as string,
541
+ task: params.task as string,
542
+ todoId: o.todoId,
543
+ track: params.track === true,
544
+ model: o.model,
545
+ readOnly: params.readOnly === true,
546
+ skillsOverride: Array.isArray(params.skills) ? params.skills as string[] : undefined,
547
+ registry: deps.registry,
548
+ todoSync: deps.todoSync,
549
+ runRegistry: deps.runRegistry,
550
+ lock: deps.lock,
551
+ backendRegistry: deps.backendRegistry,
552
+ parentModel: deps.parentModel,
553
+ parentCwd: ctx.cwd,
554
+ runLog: deps.runLog,
555
+ maxTurns: typeof params.maxTurns === "number" ? params.maxTurns : undefined,
556
+ tierRegistry: deps.tierRegistry,
557
+ modelRegistry: deps.modelRegistry,
558
+ cwd: resolvedCwd ?? ctx.cwd,
559
+ defaultThinkingLevel: deps.defaultSubagentThinking,
560
+ }));
480
561
  })().catch(() => {});
481
562
  },
482
563
  });
@@ -521,6 +602,16 @@ export default async function (pi: ExtensionAPI): Promise<void> {
521
602
  deps.reloadTiers = reloadTiers;
522
603
  reloadTiers(); // build the real merged registry (replaces the builtin-only placeholder)
523
604
 
605
+ // #78: fleet-dir settings.json (global + project, project wins) — currently carries
606
+ // `defaultSubagentThinking`. Warnings are surfaced, never swallowed silently.
607
+ const fleetSettingsStore = new FleetSettingsStore({
608
+ projectPath: join(dir, "settings.json"),
609
+ globalPath: join(process.env.HOME ?? "", ".pi", "agent", "fleet", "settings.json"),
610
+ });
611
+ const fleetSettings = fleetSettingsStore.load();
612
+ for (const w of fleetSettings.warnings) ctx.ui.notify(w, "warning");
613
+ deps.defaultSubagentThinking = fleetSettings.settings.defaultSubagentThinking;
614
+
524
615
  // SPEC-6-3: workflow journal + registry + runner + fleet tool wiring.
525
616
  const workflowJournal = new WorkflowJournal(join(dir, "workflows"));
526
617
  wfRegistry = new WorkflowRegistry(discoverWorkflows({
@@ -543,6 +634,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
543
634
  runLog: deps.runLog,
544
635
  tierRegistry: deps.tierRegistry,
545
636
  modelRegistry: deps.modelRegistry,
637
+ defaultThinkingLevel: deps.defaultSubagentThinking,
546
638
  lifecycleDeps: deps.lifecycleDeps,
547
639
  spawnSubagentFn: async (opts) => { const { spawnSubagent } = await import("./engine/spawnSubagent.ts"); return spawnSubagent(opts); },
548
640
  runLifecycleFn: async (task, name, lcOpts) => { const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts"); return runLifecycle(task, name, lcOpts); },
@@ -677,6 +769,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
677
769
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
678
770
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
679
771
  tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
772
+ defaultThinkingLevel: deps.defaultSubagentThinking,
680
773
  }), deps.defaultModelFallback),
681
774
  };
682
775
  const res = await runLifecycle(parsed.task, lcName, { deps: lifecycleFullDeps, mode: parsed.auto ? "auto" : "checkpointed", onCheckpoint });
@@ -9,7 +9,7 @@ import {
9
9
  matchesKey,
10
10
  type SelectItem,
11
11
  } from "@earendil-works/pi-tui";
12
- import type { AgentDef } from "../registry/frontmatter.ts";
12
+ import type { AgentDef, ThinkingLevel } from "../registry/frontmatter.ts";
13
13
  import { agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline, scheduleRow } from "./rows.ts";
14
14
  import { buildFleetItems } from "./fleet-items.ts";
15
15
  import { runsRow, runTimelineRow } from "./runs-rows.ts";
@@ -46,6 +46,9 @@ export interface FleetPanelDeps {
46
46
  backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
47
47
  parentModel: { provider: string; id: string };
48
48
  parentCwd: string;
49
+ /** #78: fleet-wide default thinking level (settings.json `defaultSubagentThinking`).
50
+ * Threads to every panel-driven spawn (Run/Resume/Fork + lifecycle-view phases). */
51
+ defaultSubagentThinking?: ThinkingLevel;
49
52
  /** SPEC-4: lifecycle registry + active/recent run records + deps to drive checkpoints. */
50
53
  lifecycleRegistry: Map<string, LifecycleDef>;
51
54
  lifecycleRuns: Map<string, LifecycleRunRecord>;
@@ -483,6 +486,7 @@ export class FleetPanel extends Container {
483
486
  backendRegistry: this.deps.backendRegistry,
484
487
  parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
485
488
  runLog: this.deps.runLog,
489
+ defaultThinkingLevel: this.deps.defaultSubagentThinking,
486
490
  // live Fleet row during the run (SPEC-1 §4c) — re-render on each turn_end
487
491
  onEvent: (e) => {
488
492
  if (e.type === "turn_end") {
@@ -1117,6 +1121,7 @@ export class FleetPanel extends Container {
1117
1121
  registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry,
1118
1122
  lock: this.deps.lock, backendRegistry: this.deps.backendRegistry,
1119
1123
  parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
1124
+ defaultThinkingLevel: this.deps.defaultSubagentThinking,
1120
1125
  onEvent: (e) => { if (e.type === "turn_end") { this.list = this.buildList(); this.renderShell(); } },
1121
1126
  });
1122
1127
  this.list = this.buildList();
@@ -1156,6 +1161,7 @@ export class FleetPanel extends Container {
1156
1161
  registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry,
1157
1162
  lock: this.deps.lock, backendRegistry: this.deps.backendRegistry,
1158
1163
  parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
1164
+ defaultThinkingLevel: this.deps.defaultSubagentThinking,
1159
1165
  onEvent: (e) => { if (e.type === "turn_end") { this.list = this.buildList(); this.renderShell(); } },
1160
1166
  });
1161
1167
  this.list = this.buildList();
@@ -1228,6 +1234,7 @@ export class FleetPanel extends Container {
1228
1234
  registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry, lock: this.deps.lock,
1229
1235
  backendRegistry: this.deps.backendRegistry, parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
1230
1236
  runLog: this.deps.runLog,
1237
+ defaultThinkingLevel: this.deps.defaultSubagentThinking,
1231
1238
  cwd: o.cwd,
1232
1239
  });
1233
1240
  },
package/src/panel/rows.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/panel/rows.ts
2
+ import { basename } from "node:path";
2
3
  import type { AgentDef } from "../registry/frontmatter.ts";
3
4
  import type { FleetRunStatus } from "../todo-sync/port.ts";
4
5
  import type { RunRecord } from "../engine/run-registry.ts";
@@ -140,6 +141,8 @@ export interface ScheduleRow {
140
141
  task: string;
141
142
  nextFire: Date | null;
142
143
  paused: boolean;
144
+ /** #62: pinned dispatch cwd — rendered as a ↗ basename so cross-cwd schedules are visible. */
145
+ cwd?: string;
143
146
  }
144
147
 
145
148
  export function scheduleRow(s: ScheduleRow): string {
@@ -147,7 +150,8 @@ export function scheduleRow(s: ScheduleRow): string {
147
150
  const next = s.nextFire ? `next: ${s.nextFire.toLocaleString()}` : "paused";
148
151
  const task = s.task.length > 24 ? s.task.slice(0, 23) + "…" : s.task;
149
152
  const lc = s.lifecycle ?? "default";
150
- return `${icon} ${s.expression} ${lc} "${task}" ${next} ${s.id}`;
153
+ const cwd = s.cwd ? ` ↗${basename(s.cwd)}` : "";
154
+ return `${icon} ${s.expression} ${lc} "${task}"${cwd} ${next} ${s.id}`;
151
155
  }
152
156
 
153
157
  const LC_GLYPH: Record<LifecycleStatus, string> = {
@@ -16,9 +16,13 @@ export function runsRow(r: RunMeta, getModelContextWindow?: (model: string) => n
16
16
  const maxCtx = getModelContextWindow?.(r.model);
17
17
  const ctx = (r.contextTokens != null && maxCtx != null && maxCtx > 0) ? ` ${Math.round(r.contextTokens / maxCtx * 100)}%` : "";
18
18
  const cost = r.costTotal ? ` $${r.costTotal.toFixed(4)}` : "";
19
+ // #59/#60/#61 NIT: the journal fields v0.14.0 added to run:ended, now visible in the list.
20
+ const err = r.error ? ` ✗"${r.error.length > 60 ? r.error.slice(0, 59) + "…" : r.error}"` : "";
21
+ const tools = r.toolCallCount != null ? ` ·${r.toolCallCount}t` : "";
22
+ const files = r.filesTouched?.length ? ` ✎${r.filesTouched.length}` : "";
19
23
  const summary = r.resultSummary ? ` "${r.resultSummary}"` : "";
20
24
  const prov = r.resumedFrom ? ` ← resumed:${r.resumedFrom}` : r.forkedFrom ? ` ← forked:${r.forkedFrom}` : "";
21
- return `${STATUS_GLYPH[r.status]} ${r.runId} ${r.agent} ${r.status} ${dur}${tok}${ctx}${cost}${summary}${prov}`;
25
+ return `${STATUS_GLYPH[r.status]} ${r.runId} ${r.agent} ${r.status} ${dur}${tok}${ctx}${cost}${tools}${files}${err}${summary}${prov}`;
22
26
  }
23
27
 
24
28
  export function runTimelineRow(e: MessageEvent | ToolEvent): string {
@@ -119,7 +119,13 @@ function widgetLine(r: WidgetRun, now: number): string {
119
119
  const label = r.task ? `"${r.task.slice(0, 40)}"` : r.runId;
120
120
  // SPEC-6-5: cross-cwd glyph — when the run's cwd differs from the session cwd, mark it so the
121
121
  // operator sees "this run is scoped to a different project" at a glance. Same-cwd → no glyph.
122
- const crossCwd = (r.cwd && r.sessionCwd && r.cwd !== r.sessionCwd) ? ` ↗${basename(r.cwd)}` : "";
122
+ // #62 NIT: isolated runs pin cwd to <child-cwd>/.pi/fleet/worktrees/<runId> the basename
123
+ // was the cryptic run-id. Strip the worktrees suffix so the glyph names the TARGET repo dir.
124
+ const displayCwd = (cwd: string): string => {
125
+ const wt = cwd.indexOf("/.pi/fleet/worktrees/");
126
+ return wt > 0 ? basename(cwd.slice(0, wt)) : basename(cwd);
127
+ };
128
+ const crossCwd = (r.cwd && r.sessionCwd && r.cwd !== r.sessionCwd) ? ` ↗${displayCwd(r.cwd)}` : "";
123
129
  const agentSeg = r.agent && r.agent !== "general-purpose" ? ` · ${r.agent}` : "";
124
130
  // #23: liveness — only after LIVENESS_THRESHOLD_MS, to keep short runs concise (per acceptance).
125
131
  // turn N/max + last-event class (no prompt content, no args/results — only the tool name)
@@ -7,6 +7,7 @@ import type { RunRegistry, RunRecord } from "../engine/run-registry.ts";
7
7
  import type { RunLog } from "../runtime/run-log.ts";
8
8
  import type { RunJournal } from "../runtime/run-journal.ts";
9
9
  import { resolveDispatchCwd } from "../tools/subagent.ts";
10
+ import { isSessionRejection } from "../engine/session-rejection.ts";
10
11
 
11
12
  export type RpcErrorCode =
12
13
  | "E-CONTROL-DISABLED" | "E-RUN-NOT-FOUND" | "E-RUN-FINISHED" | "E-BAD-VERB"
@@ -40,6 +41,9 @@ export interface RpcServerDeps {
40
41
  * Never throws — runtime failures land via the registry + RunLog journal (spawnSubagent's own
41
42
  * fail path journals run:ended), so the caller's { runId } always resolves to a real run. */
42
43
  spawn: (params: Record<string, unknown>, runId: string) => void;
44
+ /** #83: schedule registration (scheduler.register under the hood). Throws on an invalid
45
+ * expression (surfaced as E-BAD-PARAMS); absent = scheduling not configured in this session. */
46
+ schedule?: (spec: Record<string, unknown>) => { scheduleId: string; nextFire: string | null };
43
47
  }
44
48
 
45
49
  const LIST_CAP = 25;
@@ -84,14 +88,15 @@ export class RpcServer {
84
88
  case "spawn": return gated ? this.spawnVerb(id, params) : this.controlDisabled(id);
85
89
  case "steer": return gated ? this.steerVerb(id, params) : this.controlDisabled(id);
86
90
  case "abort": return gated ? this.abortVerb(id, params) : this.controlDisabled(id);
91
+ case "schedule": return gated ? this.scheduleVerb(id, params) : this.controlDisabled(id);
87
92
  case "observe": return this.observeVerb(id, params);
88
93
  case "status": return this.statusVerb(id, params);
89
- default: return this.err(id, "E-BAD-VERB", `unknown verb '${verb}' (known: spawn, steer, observe, abort, status)`);
94
+ default: return this.err(id, "E-BAD-VERB", `unknown verb '${verb}' (known: spawn, steer, observe, abort, status, schedule)`);
90
95
  }
91
96
  }
92
97
 
93
98
  private controlDisabled(id: string): RpcReply {
94
- return this.err(id, "E-CONTROL-DISABLED", "fleet rpc control is disabled (ARMORY_FLEET_RPC_CONTROL is set to off; remove it or set it to 1 to enable spawn/steer/abort)");
99
+ return this.err(id, "E-CONTROL-DISABLED", "fleet rpc control is disabled (ARMORY_FLEET_RPC_CONTROL is set to off; remove it or set it to 1 to enable spawn/steer/abort/schedule)");
95
100
  }
96
101
 
97
102
  private err(id: string, code: RpcErrorCode, message: string): RpcReply {
@@ -107,9 +112,9 @@ export class RpcServer {
107
112
  if (!p) return this.err(id, "E-BAD-PARAMS", "spawn requires params: { agent, task, ... }");
108
113
  if (typeof p.agent !== "string" || !p.agent) return this.err(id, "E-BAD-PARAMS", "params.agent must be a non-empty string");
109
114
  if (typeof p.task !== "string" || !p.task) return this.err(id, "E-BAD-PARAMS", "params.task must be a non-empty string");
110
- if (p.lifecycle !== undefined) return this.err(id, "E-BAD-PARAMS", "params.lifecycle is not supported over RPC spawn yet (single-delegate + background only — spec §7)");
111
- if (p.schedule !== undefined) return this.err(id, "E-BAD-PARAMS", "params.schedule is not supported over RPC spawn yet");
112
- if (p.modelFallback !== undefined) return this.err(id, "E-BAD-PARAMS", "params.modelFallback is not supported over RPC spawn yet");
115
+ if (p.lifecycle !== undefined && (typeof p.lifecycle !== "string" || !p.lifecycle)) return this.err(id, "E-BAD-PARAMS", "params.lifecycle must be a non-empty string when set (#83)");
116
+ if (p.schedule !== undefined) return this.err(id, "E-BAD-PARAMS", "params.schedule is not a spawn param schedules run lifecycles, not single delegates; use the 'schedule' verb (#83)");
117
+ if (p.modelFallback !== undefined && (typeof p.modelFallback !== "string" || !p.modelFallback)) return this.err(id, "E-BAD-PARAMS", "params.modelFallback must be a non-empty string when set (#83)");
113
118
  if (p.cwd !== undefined && (typeof p.cwd !== "string" || p.cwd === "")) return this.err(id, "E-BAD-PARAMS", "params.cwd must be a non-empty string when set");
114
119
  if (p.cwd !== undefined) {
115
120
  const { error } = resolveDispatchCwd(p.cwd, this.deps.parentCwd);
@@ -135,6 +140,42 @@ export class RpcServer {
135
140
  return { id, ok: true, data: { runId } };
136
141
  }
137
142
 
143
+ /** #83 D4: register a recurring lifecycle run. Reply shape { scheduleId, nextFire } — schedules
144
+ * are NOT runs, so no runId (spawn's uniform { runId } contract stays unbranched). */
145
+ private scheduleVerb(id: string, params: unknown): RpcReply {
146
+ const p = this.obj(params);
147
+ if (!p) return this.err(id, "E-BAD-PARAMS", "schedule requires params: { task, expression, ... }");
148
+ if (typeof p.task !== "string" || !p.task) return this.err(id, "E-BAD-PARAMS", "params.task must be a non-empty string");
149
+ if (typeof p.expression !== "string" || !p.expression) return this.err(id, "E-BAD-PARAMS", "params.expression must be a non-empty string (cron or interval, e.g. '*/5 * * * *' or '30m')");
150
+ if (p.lifecycle !== undefined && (typeof p.lifecycle !== "string" || !p.lifecycle)) return this.err(id, "E-BAD-PARAMS", "params.lifecycle must be a non-empty string when set");
151
+ if (p.auto !== undefined && typeof p.auto !== "boolean") return this.err(id, "E-BAD-PARAMS", "params.auto must be a boolean");
152
+ if (p.isolation !== undefined && p.isolation !== "worktree" && p.isolation !== "none" && p.isolation !== "auto") {
153
+ return this.err(id, "E-BAD-PARAMS", "params.isolation must be 'worktree' | 'none' | 'auto'");
154
+ }
155
+ if (p.cwd !== undefined && (typeof p.cwd !== "string" || p.cwd === "")) return this.err(id, "E-BAD-PARAMS", "params.cwd must be a non-empty string when set");
156
+ let cwd: string | undefined;
157
+ if (p.cwd !== undefined) {
158
+ const resolved = resolveDispatchCwd(p.cwd, this.deps.parentCwd);
159
+ if (resolved.error) return this.err(id, "E-BAD-PARAMS", resolved.error);
160
+ cwd = resolved.cwd;
161
+ }
162
+ if (!this.deps.schedule) {
163
+ return this.err(id, "E-BAD-PARAMS", "scheduling not configured in this session (scheduler missing)");
164
+ }
165
+ try {
166
+ const out = this.deps.schedule({
167
+ task: p.task, expression: p.expression,
168
+ ...(p.lifecycle !== undefined ? { lifecycle: p.lifecycle } : {}),
169
+ ...(p.auto !== undefined ? { auto: p.auto } : {}),
170
+ ...(p.isolation !== undefined ? { isolation: p.isolation } : {}),
171
+ ...(cwd !== undefined ? { cwd } : {}),
172
+ });
173
+ return { id, ok: true, data: { scheduleId: out.scheduleId, nextFire: out.nextFire } };
174
+ } catch (e) {
175
+ return this.err(id, "E-BAD-PARAMS", (e as Error).message || "schedule registration failed");
176
+ }
177
+ }
178
+
138
179
  private statusVerb(id: string, params: unknown): RpcReply {
139
180
  const p = this.obj(params) ?? {};
140
181
  if (p.runId !== undefined) {
@@ -143,8 +184,12 @@ export class RpcServer {
143
184
  if (!rec) return this.err(id, "E-RUN-NOT-FOUND", `no live run '${p.runId}' in the registry (finished runs older than the session are not listed)`);
144
185
  return { id, ok: true, data: { runs: [summarize(rec)] } };
145
186
  }
146
- const runs = this.deps.runRegistry.list().slice(0, LIST_CAP).map(summarize);
147
- return { id, ok: true, data: { runs } };
187
+ const runs = this.deps.runRegistry.list();
188
+ const capped = runs.slice(0, LIST_CAP).map(summarize);
189
+ // #84: surface the omitted count so RPC consumers know the list is partial. Absent
190
+ // when everything fit (additive field — consumers check presence, not falseness).
191
+ const truncated = runs.length - capped.length;
192
+ return { id, ok: true, data: truncated > 0 ? { runs: capped, truncated } : { runs: capped } };
148
193
  }
149
194
 
150
195
  private observeVerb(id: string, params: unknown): RpcReply {
@@ -211,6 +256,9 @@ export class RpcServer {
211
256
  try {
212
257
  await session.steer(p.message);
213
258
  } catch (e) {
259
+ // #84: typed rejections match by reason; string matching survives as a back-compat
260
+ // fallback for third-party ChildSession implementations that bubble bare Errors.
261
+ if (isSessionRejection(e) && e.reason === "steer-unsupported") return this.err(id, "E-STEER-UNSUPPORTED", e.message);
214
262
  const msg = (e as Error).message ?? "steer failed";
215
263
  if (msg.includes("not supported")) return this.err(id, "E-STEER-UNSUPPORTED", msg);
216
264
  return this.err(id, "E-INTERNAL", `steer failed: ${msg}`);
@@ -229,6 +277,8 @@ export class RpcServer {
229
277
  try {
230
278
  await session.abort();
231
279
  } catch (e) {
280
+ // #84: typed first (reason-based), string fallback for bare-Error handles.
281
+ if (isSessionRejection(e) && (e.reason === "already-aborted" || e.reason === "already-processing")) return this.err(id, "E-RUN-FINISHED", e.message);
232
282
  const msg = (e as Error).message ?? "abort failed";
233
283
  if (msg.includes("already")) return this.err(id, "E-RUN-FINISHED", msg);
234
284
  return this.err(id, "E-INTERNAL", `abort failed: ${msg}`);
@@ -31,6 +31,9 @@ export interface RunLifecycleOpts {
31
31
  /** #62: the dispatch target cwd for in-place runs (isolated runs pass the worktree path).
32
32
  * Flows into runLifecycle's SPEC-6-5 cwd resolution (lifecycle.cwd ?? entryCwd). */
33
33
  entryCwd?: string;
34
+ /** #83: per-run fallback model for the phase-spawn retry wrapper (wins over the host's
35
+ * global default). Undefined = use the host default (back-compat). */
36
+ modelFallback?: string;
34
37
  }
35
38
 
36
39
  export type RunLifecycleFn = (task: string, lifecycleName: string, opts: RunLifecycleOpts) => Promise<FakeLifecycleResult>;
@@ -66,6 +69,9 @@ export interface RunBackgroundOpts {
66
69
  runId?: string;
67
70
  /** v0.11.1: edit isolation for background runs. Default "auto" (worktree when cwd is a git repo, in-place otherwise). */
68
71
  isolation?: Isolation;
72
+ /** #83: per-run fallback model — forwarded to the runLifecycle adapter so its phase-spawn
73
+ * retry wrapper prefers this over the host's global default. Undefined = host default. */
74
+ modelFallback?: string;
69
75
  /** #62: the dispatch target cwd (undefined = session cwd, back-compat). Scopes the run:
70
76
  * isolation routing + worktree creation resolve against THIS cwd (via deps.worktreeFor),
71
77
  * and in-place runs pass it as the lifecycle entryCwd. */
@@ -120,7 +126,7 @@ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOp
120
126
  deps.journal.append(runId, ev0);
121
127
  emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
122
128
 
123
- const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode, entryCwd: isolated ? isolated.worktreePath : opts.cwd, fleetMode: opts.origin ?? "background" });
129
+ const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode, entryCwd: isolated ? isolated.worktreePath : opts.cwd, fleetMode: opts.origin ?? "background", ...(opts.modelFallback ? { modelFallback: opts.modelFallback } : {}) });
124
130
 
125
131
  if (res.status === "completed") {
126
132
  if (isolated) {
@@ -61,6 +61,12 @@ export interface RunMeta {
61
61
  cwd?: string;
62
62
  /** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */
63
63
  sessionCwd?: string;
64
+ /** #59 NIT: the failure reason on failed runs (from run:ended). */
65
+ error?: string;
66
+ /** #61 NIT: executed-tool count (the zero-work signal). */
67
+ toolCallCount?: number;
68
+ /** #60 NIT: file paths the run mutated. */
69
+ filesTouched?: string[];
64
70
  }
65
71
 
66
72
  const ARGS_LIMIT = 200;
@@ -145,6 +151,8 @@ export class RunLog {
145
151
  meta.resultSummary = ended.resultSummary; meta.tokenTotal = ended.tokenTotal;
146
152
  meta.resumedFrom = ended.resumedFrom; meta.forkedFrom = ended.forkedFrom;
147
153
  meta.costTotal = ended.costTotal; meta.contextTokens = ended.contextTokens;
154
+ // #59/#60/#61 NIT: surface the newer journal fields to the Runs tab too.
155
+ meta.error = ended.error; meta.toolCallCount = ended.toolCallCount; meta.filesTouched = ended.filesTouched;
148
156
  }
149
157
  out.push(meta);
150
158
  }
@@ -0,0 +1,103 @@
1
+ // src/settings/fleet-settings.ts
2
+ // #78: fleet-dir settings.json — the settings home for fleet-wide defaults that
3
+ // aren't env-shaped (sibling of tiers.json in the same global+project scheme).
4
+ //
5
+ // Locations:
6
+ // global: ~/.pi/agent/fleet/settings.json
7
+ // project: <cwd>/.pi/fleet/settings.json (project wins per-field)
8
+ //
9
+ // Design rules:
10
+ // - Absent files are normal (empty settings, no warning).
11
+ // - Present-but-invalid content produces ACTIONABLE warnings (file + field + bad
12
+ // value) and drops the field — never a silent swallow (the TierStore ENOENT
13
+ // lesson: silent loaders make wrong docs doubly dangerous).
14
+ // - The schema is intentionally small and additive; unknown keys warn so typos
15
+ // surface instead of no-oping.
16
+ import { readFileSync } from "node:fs";
17
+ import type { ThinkingLevel } from "../registry/frontmatter.ts";
18
+
19
+ const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
20
+
21
+ export function isThinkingLevel(v: unknown): v is ThinkingLevel {
22
+ return typeof v === "string" && (THINKING_LEVELS as readonly string[]).includes(v);
23
+ }
24
+
25
+ /** Fleet-wide defaults. Intentionally additive — new fields land here. */
26
+ export interface FleetSettings {
27
+ /** #78: applied to every subagent whose frontmatter does NOT pin `thinkingLevel`.
28
+ * Precedence: agent.thinkingLevel > this > the backend/session default. */
29
+ defaultSubagentThinking?: ThinkingLevel;
30
+ }
31
+
32
+ export interface FleetSettingsResult {
33
+ settings: FleetSettings;
34
+ /** Actionable, source-labeled warnings (never empty-string; always name the file). */
35
+ warnings: string[];
36
+ }
37
+
38
+ /** Parse one settings file's content. `label` names the file in warnings. */
39
+ export function parseFleetSettings(json: string, label = "settings.json"): FleetSettingsResult {
40
+ const warnings: string[] = [];
41
+ let raw: unknown;
42
+ try {
43
+ raw = JSON.parse(json);
44
+ } catch (e) {
45
+ return { settings: {}, warnings: [`${label}: invalid JSON (${(e as Error).message}) — fleet settings from this file ignored`] };
46
+ }
47
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
48
+ return { settings: {}, warnings: [`${label}: expected a JSON object, got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw} — fleet settings from this file ignored`] };
49
+ }
50
+ const obj = raw as Record<string, unknown>;
51
+ const settings: FleetSettings = {};
52
+
53
+ const thinking = obj["defaultSubagentThinking"];
54
+ if (thinking !== undefined) {
55
+ if (isThinkingLevel(thinking)) {
56
+ settings.defaultSubagentThinking = thinking;
57
+ } else {
58
+ warnings.push(`${label}: defaultSubagentThinking must be one of ${THINKING_LEVELS.join("|")}, got ${JSON.stringify(thinking)} — ignored`);
59
+ }
60
+ }
61
+
62
+ const known = new Set(["defaultSubagentThinking"]);
63
+ const unknownKeys = Object.keys(obj).filter((k) => !known.has(k));
64
+ if (unknownKeys.length > 0) {
65
+ warnings.push(`${label}: unknown setting${unknownKeys.length > 1 ? "s" : ""} ${unknownKeys.map((k) => `"${k}"`).join(", ")} — ignored (valid: ${[...known].join(", ")})`);
66
+ }
67
+
68
+ return { settings, warnings };
69
+ }
70
+
71
+ export interface FleetSettingsStoreOpts {
72
+ projectPath: string;
73
+ globalPath: string;
74
+ }
75
+
76
+ /** Global+project fleet settings (project wins per-field), mirroring TierStore's scheme. */
77
+ export class FleetSettingsStore {
78
+ constructor(private readonly opts: FleetSettingsStoreOpts) {}
79
+
80
+ private readOne(path: string, label: string): FleetSettingsResult {
81
+ let content: string;
82
+ try {
83
+ content = readFileSync(path, "utf8");
84
+ } catch (e) {
85
+ // ENOENT = the normal absent-file state, never a warning. Any OTHER read failure
86
+ // (EACCES, EISDIR, …) on a path that was expected to be readable must surface —
87
+ // silent swallows make misconfiguration invisible (the TierStore lesson).
88
+ const code = (e as NodeJS.ErrnoException).code;
89
+ if (code === "ENOENT") return { settings: {}, warnings: [] };
90
+ return { settings: {}, warnings: [`${label}: unreadable (${code ?? (e as Error).message}) — fleet settings from this file ignored`] };
91
+ }
92
+ return parseFleetSettings(content, label);
93
+ }
94
+
95
+ load(): FleetSettingsResult {
96
+ const global = this.readOne(this.opts.globalPath, this.opts.globalPath);
97
+ const project = this.readOne(this.opts.projectPath, this.opts.projectPath);
98
+ return {
99
+ settings: { ...global.settings, ...project.settings },
100
+ warnings: [...global.warnings, ...project.warnings],
101
+ };
102
+ }
103
+ }
@@ -2,7 +2,7 @@
2
2
  import { resolve } from "node:path";
3
3
  import { statSync } from "node:fs";
4
4
  import { Type, type Static } from "typebox";
5
- import type { AgentDef } from "../registry/frontmatter.ts";
5
+ import type { AgentDef, ThinkingLevel } from "../registry/frontmatter.ts";
6
6
  import type { TodoSyncPort } from "../todo-sync/port.ts";
7
7
  import type { RunRegistry } from "../engine/run-registry.ts";
8
8
  import type { ForegroundLock } from "../engine/concurrency-lock.ts";
@@ -96,6 +96,10 @@ export interface SubagentToolDeps {
96
96
  * Per-dispatch `modelFallback` (when passed) takes precedence. Applies to the direct foreground
97
97
  * path, the foreground lifecycle spawn, and the background/scheduled spawn. */
98
98
  defaultModelFallback?: string;
99
+ /** #78: fleet-wide default thinking level for subagents (settings.json `defaultSubagentThinking`).
100
+ * Applied when the agent frontmatter does NOT pin `thinkingLevel`. Threads to every spawn
101
+ * path (direct, lifecycle phase, fallback retry) exactly like `defaultModelFallback`. */
102
+ defaultSubagentThinking?: ThinkingLevel;
99
103
  /** SPEC-6-5: notify hook for cross-cwd dispatch surfacing. Wired from ctx.ui.notify in index.ts. */
100
104
  onNotify?: (message: string, kind?: "info" | "warning" | "error") => void;
101
105
  }
@@ -155,6 +159,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
155
159
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, runLog: deps.runLog, signal,
156
160
  maxTurns: params.maxTurns,
157
161
  tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
162
+ defaultThinkingLevel: deps.defaultSubagentThinking,
158
163
  readOnly: params.readOnly,
159
164
  cwd: o.cwd,
160
165
  }), params.modelFallback ?? deps.defaultModelFallback, signal),
@@ -192,6 +197,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
192
197
  signal,
193
198
  maxTurns: params.maxTurns,
194
199
  tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
200
+ defaultThinkingLevel: deps.defaultSubagentThinking,
195
201
  cwd: resolvedCwd,
196
202
  });
197
203
  // #39: auto-retry on a retryable provider rate-limit / auth failure (stopReason "error").
@@ -227,6 +233,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
227
233
  signal,
228
234
  maxTurns: params.maxTurns,
229
235
  tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
236
+ defaultThinkingLevel: deps.defaultSubagentThinking,
230
237
  cwd: resolvedCwd,
231
238
  });
232
239
  retriedWithModel = fallback;
@@ -22,6 +22,8 @@ export interface WorkflowAdapterBase {
22
22
  runLog?: unknown
23
23
  tierRegistry?: unknown
24
24
  modelRegistry?: unknown
25
+ /** #78: fleet-wide default thinking level, threaded into every workflow child spawn. */
26
+ defaultThinkingLevel?: SpawnOptions["defaultThinkingLevel"]
25
27
  lifecycleDeps: unknown
26
28
  spawnSubagentFn: (opts: SpawnOptions) => Promise<SpawnResult>
27
29
  runLifecycleFn: (task: string, name: string, opts: LifecycleRunOpts) => Promise<LifecycleRunResult>
@@ -69,6 +71,7 @@ export function createWorkflowAdapters(
69
71
  ...(base.runLog ? { runLog: base.runLog as SpawnOptions["runLog"] } : {}),
70
72
  ...(base.tierRegistry ? { tierRegistry: base.tierRegistry as SpawnOptions["tierRegistry"] } : {}),
71
73
  ...(base.modelRegistry ? { modelRegistry: base.modelRegistry as SpawnOptions["modelRegistry"] } : {}),
74
+ ...(base.defaultThinkingLevel ? { defaultThinkingLevel: base.defaultThinkingLevel } : {}),
72
75
  })
73
76
 
74
77
  const combineSignal = (timeoutMs?: number): AbortSignal => {