@getpipher/armory-fleet 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "1.1.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",
@@ -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; },
@@ -205,7 +206,7 @@ export interface SpawnResult {
205
206
  /** #49: extract file paths a tool event touched, for the structured partial-result report.
206
207
  * edit/write carry a `path` arg reliably; bash is best-effort (redirections `>`/`>>` + `tee` to a
207
208
  * path-like token). Reads are NOT mutations and are excluded. */
208
- function extractTouchedFiles(toolName: string, args: unknown): string[] {
209
+ export function extractTouchedFiles(toolName: string, args: unknown): string[] {
209
210
  if (!args || typeof args !== "object") return [];
210
211
  const a = args as Record<string, unknown>;
211
212
  // Case-insensitive: pi tools are lowercase ("edit"), claude's are capitalized ("Edit").
@@ -223,19 +224,41 @@ function extractTouchedFiles(toolName: string, args: unknown): string[] {
223
224
  const redir = />>?\s+([^\s|;&<>]+)/g;
224
225
  let m: RegExpExecArray | null;
225
226
  while ((m = redir.exec(cmd)) !== null) {
226
- const tok = m[1];
227
- if (tok && /[/.]/.test(tok)) out.push(tok);
227
+ const tok = m[1] ? shapeToken(m[1]) : undefined;
228
+ if (tok) out.push(tok);
228
229
  }
229
230
  const tee = /\btee\s+(?:-a\s+)?([^\s|;&<>]+)/g;
230
231
  while ((m = tee.exec(cmd)) !== null) {
231
- const tok = m[1];
232
- if (tok && /[/.]/.test(tok)) out.push(tok);
232
+ const tok = m[1] ? shapeToken(m[1]) : undefined;
233
+ if (tok) out.push(tok);
233
234
  }
234
235
  return out;
235
236
  }
236
237
  return [];
237
238
  }
238
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
+
239
262
  export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
240
263
  const track = opts.track ?? true;
241
264
  const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
@@ -328,7 +351,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
328
351
  priorStatus = link.priorStatus;
329
352
  opts.runRegistry.update(runId, { todoId });
330
353
  } catch (e) {
331
- 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
+ });
332
358
  }
333
359
 
334
360
  // SPEC-6-1: fallback retry loop — try candidates[0], on rejection retry candidates[1], etc.
@@ -354,7 +380,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
354
380
  } catch (e) { lastErr = e as Error; }
355
381
  }
356
382
  if (!session) {
357
- 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
+ });
358
387
  }
359
388
 
360
389
  // SPEC-5b-4: retain a narrow live-session handle on the run record so the panel can
@@ -559,7 +588,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
559
588
  status = "completed";
560
589
  }
561
590
 
562
- 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
+ });
563
597
  } finally {
564
598
  // #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
565
599
  // (releasing a lock held by another concurrent write dispatch would corrupt serialization).
@@ -577,13 +611,34 @@ function fail(runId: string, startedAt: number, message: string, agent: string):
577
611
  /** SPEC-6-2: guard against double-finishRun (abort-then-complete). */
578
612
  const finalizedRunIds = new Set<string>();
579
613
 
580
- async function finishRun(
581
- opts: SpawnOptions, runId: string, startedAt: number,
582
- status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
583
- error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
584
- retryable?: boolean,
585
- filesTouched?: string[], reachedSummary?: boolean, toolCallCount = 0,
586
- ): 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;
587
642
  if (finalizedRunIds.has(runId)) {
588
643
  // Already finalized — return the existing registry record's result without re-appending.
589
644
  const existing = opts.runRegistry.get(runId);
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import { createSubagentTool, mergeLifecycleSkills, resolveDispatchCwd, type Suba
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";
@@ -83,7 +84,8 @@ function wrapPiSession(inner: ChildSession, backendSessionId: string): ChildSess
83
84
  return inner.subscribe(handler);
84
85
  },
85
86
  // SPEC-5b-4: forward the native SDK steer + isStreaming to the real pi session.
86
- 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")),
87
89
  get isStreaming() { return inner.isStreaming ?? false; },
88
90
  };
89
91
  }
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"
@@ -183,8 +184,12 @@ export class RpcServer {
183
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)`);
184
185
  return { id, ok: true, data: { runs: [summarize(rec)] } };
185
186
  }
186
- const runs = this.deps.runRegistry.list().slice(0, LIST_CAP).map(summarize);
187
- 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 } };
188
193
  }
189
194
 
190
195
  private observeVerb(id: string, params: unknown): RpcReply {
@@ -251,6 +256,9 @@ export class RpcServer {
251
256
  try {
252
257
  await session.steer(p.message);
253
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);
254
262
  const msg = (e as Error).message ?? "steer failed";
255
263
  if (msg.includes("not supported")) return this.err(id, "E-STEER-UNSUPPORTED", msg);
256
264
  return this.err(id, "E-INTERNAL", `steer failed: ${msg}`);
@@ -269,6 +277,8 @@ export class RpcServer {
269
277
  try {
270
278
  await session.abort();
271
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);
272
282
  const msg = (e as Error).message ?? "abort failed";
273
283
  if (msg.includes("already")) return this.err(id, "E-RUN-FINISHED", msg);
274
284
  return this.err(id, "E-INTERNAL", `abort failed: ${msg}`);
@@ -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
  }