@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 +1 -1
- package/src/engine/session-rejection.ts +23 -0
- package/src/engine/spawnSubagent.ts +71 -16
- package/src/index.ts +3 -1
- package/src/panel/rows.ts +5 -1
- package/src/panel/runs-rows.ts +5 -1
- package/src/panel/widget-rows.ts +7 -1
- package/src/rpc/rpc-server.ts +12 -2
- package/src/runtime/run-log.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "1.1.
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> = {
|
package/src/panel/runs-rows.ts
CHANGED
|
@@ -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 {
|
package/src/panel/widget-rows.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/rpc/rpc-server.ts
CHANGED
|
@@ -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()
|
|
187
|
-
|
|
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}`);
|
package/src/runtime/run-log.ts
CHANGED
|
@@ -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
|
}
|