@ferris1225/pi-subagents 4.1.2 → 4.1.3

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/src/monitor.ts CHANGED
@@ -19,6 +19,7 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
21
  export type RunStatus = "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed";
22
+ export type ContinuationKind = "resume-retained" | "resume-appended" | "fork-retained" | "fork-appended" | "retarget";
22
23
 
23
24
  export function isRunActiveStatus(status: RunStatus): boolean {
24
25
  return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
@@ -45,16 +46,25 @@ export interface RunView {
45
46
  usage: UsageStats;
46
47
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
47
48
  activity?: string;
48
- /** Epoch ms when the run started executing (set on first "running" status). */
49
+ /** Epoch ms when this logical run first started executing. */
49
50
  startedAt?: number;
50
- /** Epoch ms when the run finished (set on "done"/"failed"). */
51
+ /** Epoch ms when the current active segment started. */
52
+ activeSince?: number;
53
+ /** Cumulative active execution time from closed segments; parked time is excluded. */
54
+ elapsedMs: number;
55
+ /** Epoch ms when the latest active segment stopped. */
51
56
  endedAt?: number;
57
+ /** Why this generation reused retained context, shown in the widget/status. */
58
+ continuationKind?: ContinuationKind;
52
59
  /** When set, this is an internal managed-workflow step. */
53
60
  groupId?: string;
54
61
  /** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
55
62
  relationLabel?: string;
56
63
  /** Stable owning run whose row represents the whole managed workflow. */
57
64
  parentRunId?: number;
65
+ /** This stable top-level row currently owns a multi-stage managed workflow.
66
+ * Its elapsed time is workflow-wide; active child rows own stage telemetry. */
67
+ managedWorkflow?: boolean;
58
68
  }
59
69
 
60
70
  /** Optional metadata for documenter/reviewer/fix children of a stable parent run. */
@@ -64,6 +74,7 @@ export interface RunChainMeta {
64
74
  parentRunId?: number;
65
75
  isolation?: IsolationMode;
66
76
  forkedFromRunId?: number;
77
+ continuationKind?: ContinuationKind;
67
78
  }
68
79
 
69
80
  // ---------------------------------------------------------------------------
@@ -276,10 +287,32 @@ export function formatDuration(ms: number): string {
276
287
  return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
277
288
  }
278
289
 
279
- /** Elapsed wall time of a run: live while running, final once finished. */
290
+ /** Cumulative active time across generations; parked gaps never count. */
291
+ export function elapsedMilliseconds(run: RunView, now: number = Date.now()): number {
292
+ let elapsed = run.elapsedMs ?? 0;
293
+ if (run.activeSince !== undefined) elapsed += Math.max(0, now - run.activeSince);
294
+ // Keep formatting tolerant of older/synthetic RunView values that predate
295
+ // segmented timing and carry only startedAt/endedAt.
296
+ if (elapsed === 0 && run.startedAt !== undefined && run.activeSince === undefined) {
297
+ elapsed = Math.max(0, (run.endedAt ?? now) - run.startedAt);
298
+ }
299
+ return elapsed;
300
+ }
301
+
280
302
  export function formatElapsed(run: RunView, now: number = Date.now()): string {
281
- if (run.startedAt === undefined) return "";
282
- return formatDuration((run.endedAt ?? now) - run.startedAt);
303
+ if (run.startedAt === undefined && run.elapsedMs <= 0) return "";
304
+ return formatDuration(elapsedMilliseconds(run, now));
305
+ }
306
+
307
+ export function continuationLabel(kind: ContinuationKind | undefined, sourceRunId?: number): string | undefined {
308
+ switch (kind) {
309
+ case "resume-retained": return "resume: current objective";
310
+ case "resume-appended": return "resume: appended objective";
311
+ case "fork-retained": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: current objective`;
312
+ case "fork-appended": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: appended objective`;
313
+ case "retarget": return "retarget: replacement objective";
314
+ default: return undefined;
315
+ }
283
316
  }
284
317
 
285
318
  /** Max length of the argument target inside a formatted activity line. */
@@ -409,11 +442,13 @@ export class MonitorStore {
409
442
  thinking,
410
443
  status: "queued",
411
444
  usage: emptyUsage(),
445
+ elapsedMs: 0,
412
446
  ...(meta?.groupId ? { groupId: meta.groupId } : {}),
413
447
  ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
414
448
  ...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
415
449
  ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
416
450
  ...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
451
+ ...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
417
452
  });
418
453
  this.notify();
419
454
  return id;
@@ -422,17 +457,33 @@ export class MonitorStore {
422
457
  setStatus(id: number, status: RunStatus): void {
423
458
  const run = this.find(id);
424
459
  if (!run) return;
460
+ const previousStatus = run.status;
461
+ const wasExecuting = previousStatus === "running" || previousStatus === "steering" || previousStatus === "interrupting";
462
+ const isExecuting = status === "running" || status === "steering" || status === "interrupting";
463
+ const now = Date.now();
425
464
  run.status = status;
426
- if (status === "running" || status === "steering" || status === "interrupting") {
427
- if (run.startedAt === undefined) run.startedAt = Date.now();
428
- // A selected-to-main handoff or resumed generation restarts the clock; a
429
- // stale endedAt would freeze the elapsed display at the first attempt.
430
- if (run.endedAt !== undefined) run.endedAt = undefined;
431
- } else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
432
- run.endedAt = Date.now();
465
+ if (isExecuting && !wasExecuting) {
466
+ run.startedAt ??= now;
467
+ run.activeSince = now;
468
+ run.endedAt = undefined;
469
+ } else if (!isExecuting && wasExecuting && run.activeSince !== undefined) {
470
+ run.elapsedMs += Math.max(0, now - run.activeSince);
471
+ run.activeSince = undefined;
433
472
  }
473
+ if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
474
+ run.endedAt = now;
475
+ }
476
+ this.notify();
477
+ }
478
+ /** Switch a stable top-level row from one model run to workflow ownership.
479
+ * The original role remains for identity; child rows show stage telemetry. */
480
+ setManagedWorkflow(id: number, active: boolean): void {
481
+ const run = this.find(id);
482
+ if (!run) return;
483
+ run.managedWorkflow = active || undefined;
434
484
  this.notify();
435
485
  }
486
+
436
487
  setUsage(id: number, usage: UsageStats, model?: string): void {
437
488
  const run = this.find(id);
438
489
  if (!run) return;
@@ -503,26 +554,38 @@ export class MonitorStore {
503
554
  this.notify();
504
555
  }
505
556
 
506
- /** Reflect the currently owned internal stage when a managed parent is parked
507
- * or inspected between children; the stable id and original task stay intact. */
508
- setAgent(id: number, agent: string): void {
557
+ /** Update the objective shown for a queued retarget or resumed generation. */
558
+ setTask(id: number, task: string): void {
509
559
  const run = this.find(id);
510
560
  if (!run) return;
511
- run.agent = agent;
561
+ run.task = task;
562
+ run.label = runLabel(task);
512
563
  this.notify();
513
564
  }
514
565
 
515
- /** Update the objective shown for a queued retarget or resumed generation. */
516
- setTask(id: number, task: string): void {
566
+ setContinuationKind(id: number, kind: ContinuationKind): void {
517
567
  const run = this.find(id);
518
568
  if (!run) return;
519
- run.task = task;
520
- run.label = runLabel(task);
569
+ run.continuationKind = kind;
521
570
  this.notify();
522
571
  }
523
572
 
524
- /** Reuse a stable logical run id for a resumed generation. */
525
- restartRun(id: number, agent: string, task: string, model?: string, thinking?: string, isolation?: IsolationMode): void {
573
+ getElapsedMs(id: number, now: number = Date.now()): number | undefined {
574
+ const run = this.find(id);
575
+ return run ? elapsedMilliseconds(run, now) : undefined;
576
+ }
577
+
578
+ /** Reuse a stable logical run id for a resumed generation without discarding
579
+ * active time accumulated by earlier generations. */
580
+ restartRun(
581
+ id: number,
582
+ agent: string,
583
+ task: string,
584
+ model?: string,
585
+ thinking?: string,
586
+ isolation?: IsolationMode,
587
+ meta?: { elapsedMs?: number; continuationKind?: ContinuationKind },
588
+ ): void {
526
589
  const run = this.find(id);
527
590
  if (!run) {
528
591
  this.runs.push({
@@ -535,6 +598,8 @@ export class MonitorStore {
535
598
  ...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
536
599
  status: "queued",
537
600
  usage: emptyUsage(),
601
+ elapsedMs: meta?.elapsedMs ?? 0,
602
+ continuationKind: meta?.continuationKind,
538
603
  });
539
604
  this.notify();
540
605
  return;
@@ -549,8 +614,11 @@ export class MonitorStore {
549
614
  run.status = "queued";
550
615
  run.usage = emptyUsage();
551
616
  run.activity = undefined;
552
- run.startedAt = undefined;
617
+ run.managedWorkflow = undefined;
618
+ run.activeSince = undefined;
553
619
  run.endedAt = undefined;
620
+ run.elapsedMs = Math.max(run.elapsedMs, meta?.elapsedMs ?? 0);
621
+ run.continuationKind = meta?.continuationKind;
554
622
  this.notify();
555
623
  }
556
624
 
@@ -589,12 +657,14 @@ export class MonitorStore {
589
657
 
590
658
  summarize(run: RunView): string {
591
659
  const usage = formatUsageCompact(run.usage);
592
- const parts = [run.agent];
660
+ const parts = [run.managedWorkflow ? `${run.agent} workflow` : run.agent];
661
+ const continuation = continuationLabel(run.continuationKind, run.forkedFromRunId);
662
+ if (continuation) parts.push(continuation);
593
663
  if (run.relationLabel) parts.push(run.relationLabel);
594
- if (run.model) parts.push(run.model);
595
- if (run.thinking) parts.push(`thinking ${run.thinking}`);
664
+ if (!run.managedWorkflow && run.model) parts.push(run.model);
665
+ if (!run.managedWorkflow && run.thinking) parts.push(`thinking ${run.thinking}`);
596
666
  if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
597
- if (usage) parts.push(usage);
667
+ if (!run.managedWorkflow && usage) parts.push(usage);
598
668
  const elapsed = formatElapsed(run);
599
669
  if (elapsed) parts.push(elapsed);
600
670
  return parts.join(" · ");
package/src/recovery.ts CHANGED
@@ -1,145 +1,145 @@
1
- /** Durable handoff for worktree integration/cleanup failures across sessions. */
2
-
3
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
- import { existsSync } from "node:fs";
5
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
- import { dirname, join } from "node:path";
7
- import { stripVTControlCharacters } from "node:util";
8
- import type { WorktreeFinalization } from "./worktree.ts";
9
-
10
- export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
11
- const RECOVERY_MANIFEST_VERSION = 1;
12
-
13
- export interface RecoveryRecord {
14
- runId: number;
15
- createdAt: number;
16
- integrated: boolean;
17
- worktreePath?: string;
18
- patchPath?: string;
19
- error?: string;
20
- }
21
-
22
- interface RecoveryManifest {
23
- version: number;
24
- records: RecoveryRecord[];
25
- }
26
-
27
- export function getRecoveryManifestPath(configPath: string): string {
28
- return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
29
- }
30
-
31
- function normalizeRecord(value: unknown): RecoveryRecord | undefined {
32
- if (!value || typeof value !== "object") return undefined;
33
- const raw = value as Record<string, unknown>;
34
- if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
35
- if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
36
- return {
37
- runId: raw.runId,
38
- createdAt: raw.createdAt,
39
- integrated: raw.integrated === true,
40
- ...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
41
- ...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
42
- ...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
43
- };
44
- }
45
-
46
- export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
47
- try {
48
- const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
49
- records?: unknown;
50
- };
51
- if (!Array.isArray(parsed.records)) return [];
52
- return parsed.records.flatMap((record) => {
53
- const normalized = normalizeRecord(record);
54
- return normalized ? [normalized] : [];
55
- });
56
- } catch {
57
- return [];
58
- }
59
- }
60
-
61
- function recoveryKey(record: RecoveryRecord): string {
62
- return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
63
- }
64
-
65
- async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
66
- if (records.length === 0) {
67
- await rm(path, { force: true });
68
- return;
69
- }
70
- await mkdir(dirname(path), { recursive: true });
71
- const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
72
- try {
73
- const manifest: RecoveryManifest = {
74
- version: RECOVERY_MANIFEST_VERSION,
75
- records: [...records],
76
- };
77
- await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
78
- await rename(temporaryPath, path);
79
- } finally {
80
- await rm(temporaryPath, { force: true }).catch(() => undefined);
81
- }
82
- }
83
-
84
- /** Merge retained artifacts into the durable manifest. */
85
- export async function persistRecoveryRecords(
86
- configPath: string,
87
- records: readonly RecoveryRecord[],
88
- ): Promise<void> {
89
- if (records.length === 0) return;
90
- const path = getRecoveryManifestPath(configPath);
91
- await withFileMutationQueue(path, async () => {
92
- const merged = new Map<string, RecoveryRecord>();
93
- for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
94
- for (const record of records) merged.set(recoveryKey(record), record);
95
- await writeManifest(path, [...merged.values()]);
96
- });
97
- }
98
-
99
- export function recoveryRecordFromFinalization(
100
- runId: number,
101
- finalization: WorktreeFinalization,
102
- now = Date.now(),
103
- ): RecoveryRecord {
104
- return {
105
- runId,
106
- createdAt: now,
107
- integrated: finalization.integrated,
108
- ...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
109
- ...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
110
- ...(finalization.error ? { error: finalization.error } : {}),
111
- };
112
- }
113
-
114
- /** Show retained recovery paths on every later session start until the user
115
- * removes the artifacts. Stale records are pruned automatically. */
116
- export async function announceRecoveryRecords(
117
- configPath: string,
118
- ctx: {
119
- hasUI?: boolean;
120
- ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
121
- },
122
- ): Promise<void> {
123
- if (ctx.hasUI === false) return;
124
- const records = await readRecoveryRecords(configPath);
125
- if (records.length === 0) return;
126
- const live = records.filter((record) =>
127
- (record.worktreePath ? existsSync(record.worktreePath) : false) ||
128
- (record.patchPath ? existsSync(record.patchPath) : false),
129
- );
130
- if (live.length !== records.length) {
131
- const path = getRecoveryManifestPath(configPath);
132
- await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
133
- }
134
- for (const record of live) {
135
- const paths = [
136
- record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
137
- record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
138
- ].filter(Boolean).join(" · ");
139
- const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
140
- ctx.ui.notify(
141
- `pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
142
- "error",
143
- );
144
- }
145
- }
1
+ /** Durable handoff for worktree integration/cleanup failures across sessions. */
2
+
3
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+ import { existsSync } from "node:fs";
5
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import { stripVTControlCharacters } from "node:util";
8
+ import type { WorktreeFinalization } from "./worktree.ts";
9
+
10
+ export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
11
+ const RECOVERY_MANIFEST_VERSION = 1;
12
+
13
+ export interface RecoveryRecord {
14
+ runId: number;
15
+ createdAt: number;
16
+ integrated: boolean;
17
+ worktreePath?: string;
18
+ patchPath?: string;
19
+ error?: string;
20
+ }
21
+
22
+ interface RecoveryManifest {
23
+ version: number;
24
+ records: RecoveryRecord[];
25
+ }
26
+
27
+ export function getRecoveryManifestPath(configPath: string): string {
28
+ return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
29
+ }
30
+
31
+ function normalizeRecord(value: unknown): RecoveryRecord | undefined {
32
+ if (!value || typeof value !== "object") return undefined;
33
+ const raw = value as Record<string, unknown>;
34
+ if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
35
+ if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
36
+ return {
37
+ runId: raw.runId,
38
+ createdAt: raw.createdAt,
39
+ integrated: raw.integrated === true,
40
+ ...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
41
+ ...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
42
+ ...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
43
+ };
44
+ }
45
+
46
+ export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
47
+ try {
48
+ const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
49
+ records?: unknown;
50
+ };
51
+ if (!Array.isArray(parsed.records)) return [];
52
+ return parsed.records.flatMap((record) => {
53
+ const normalized = normalizeRecord(record);
54
+ return normalized ? [normalized] : [];
55
+ });
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ function recoveryKey(record: RecoveryRecord): string {
62
+ return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
63
+ }
64
+
65
+ async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
66
+ if (records.length === 0) {
67
+ await rm(path, { force: true });
68
+ return;
69
+ }
70
+ await mkdir(dirname(path), { recursive: true });
71
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
72
+ try {
73
+ const manifest: RecoveryManifest = {
74
+ version: RECOVERY_MANIFEST_VERSION,
75
+ records: [...records],
76
+ };
77
+ await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
78
+ await rename(temporaryPath, path);
79
+ } finally {
80
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
81
+ }
82
+ }
83
+
84
+ /** Merge retained artifacts into the durable manifest. */
85
+ export async function persistRecoveryRecords(
86
+ configPath: string,
87
+ records: readonly RecoveryRecord[],
88
+ ): Promise<void> {
89
+ if (records.length === 0) return;
90
+ const path = getRecoveryManifestPath(configPath);
91
+ await withFileMutationQueue(path, async () => {
92
+ const merged = new Map<string, RecoveryRecord>();
93
+ for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
94
+ for (const record of records) merged.set(recoveryKey(record), record);
95
+ await writeManifest(path, [...merged.values()]);
96
+ });
97
+ }
98
+
99
+ export function recoveryRecordFromFinalization(
100
+ runId: number,
101
+ finalization: WorktreeFinalization,
102
+ now = Date.now(),
103
+ ): RecoveryRecord {
104
+ return {
105
+ runId,
106
+ createdAt: now,
107
+ integrated: finalization.integrated,
108
+ ...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
109
+ ...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
110
+ ...(finalization.error ? { error: finalization.error } : {}),
111
+ };
112
+ }
113
+
114
+ /** Show retained recovery paths on every later session start until the user
115
+ * removes the artifacts. Stale records are pruned automatically. */
116
+ export async function announceRecoveryRecords(
117
+ configPath: string,
118
+ ctx: {
119
+ hasUI?: boolean;
120
+ ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
121
+ },
122
+ ): Promise<void> {
123
+ if (ctx.hasUI === false) return;
124
+ const records = await readRecoveryRecords(configPath);
125
+ if (records.length === 0) return;
126
+ const live = records.filter((record) =>
127
+ (record.worktreePath ? existsSync(record.worktreePath) : false) ||
128
+ (record.patchPath ? existsSync(record.patchPath) : false),
129
+ );
130
+ if (live.length !== records.length) {
131
+ const path = getRecoveryManifestPath(configPath);
132
+ await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
133
+ }
134
+ for (const record of live) {
135
+ const paths = [
136
+ record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
137
+ record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
138
+ ].filter(Boolean).join(" · ");
139
+ const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
140
+ ctx.ui.notify(
141
+ `pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
142
+ "error",
143
+ );
144
+ }
145
+ }
package/src/rpc-run.ts CHANGED
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { basename, join } from "node:path";
15
15
  import { StringDecoder } from "node:string_decoder";
16
16
  import type { Message } from "@earendil-works/pi-ai";
17
- import type { AgentConfig } from "./agents.ts";
17
+ import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "./agents.ts";
18
18
  import type { ThinkingLevel } from "./config.ts";
19
19
  import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
20
20
 
@@ -469,7 +469,7 @@ export interface RunRpcAttemptOptions {
469
469
  /** Run one persistent RPC child until a stable `agent_settled` or control action. */
470
470
  export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise<RpcSingleResult> {
471
471
  const { agent, agentName, task, thinkingLevel, idleTimeoutMs, signal, onLive, control } = options;
472
- const args: string[] = ["--mode", "rpc", "--exclude-tools", "subagent,subagent_control"];
472
+ const args: string[] = ["--mode", "rpc", "--exclude-tools", SUBAGENT_TOOL_NAMES.join(",")];
473
473
  if (options.sessionDir && options.sessionId) {
474
474
  args.push("--session-dir", options.sessionDir);
475
475
  args.push(sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id", options.sessionId);
@@ -478,7 +478,10 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
478
478
  }
479
479
  if (agent.model) args.push("--model", agent.model);
480
480
  args.push("--thinking", thinkingLevel);
481
- if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
481
+ if (agent.tools) {
482
+ if (agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
483
+ else args.push("--no-tools");
484
+ }
482
485
 
483
486
  let tmpPromptDir: string | null = null;
484
487
  let tmpPromptPath: string | null = null;
package/src/runtime.ts CHANGED
@@ -67,6 +67,8 @@ export interface SubagentThread {
67
67
  lifecycleOperation?: ThreadLifecycleOperation;
68
68
  sessionId?: string;
69
69
  sessionDir?: string;
70
+ /** Active execution time accumulated across retained resume generations. */
71
+ elapsedMs: number;
70
72
  /** Most recent generation result, retained for parked destructive-stop output. */
71
73
  lastResult?: SingleResult;
72
74
  /** A destructive stop retires context even if the active child settles later. */
@@ -92,6 +94,8 @@ export interface SubagentThread {
92
94
  export interface SubagentRuntime {
93
95
  configPath: string;
94
96
  backgroundQueue: BackgroundTaskQueue;
97
+ /** Live parent tool names from ExtensionAPI, read again for each child launch. */
98
+ getActiveTools: () => string[];
95
99
  /** False after session_shutdown; guards delivery and queue work. */
96
100
  sessionActive: boolean;
97
101
  /** Deliver a batch of completion messages to the main window, waking it only
@@ -128,6 +132,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
128
132
  const runtime: SubagentRuntime = {
129
133
  configPath,
130
134
  backgroundQueue,
135
+ getActiveTools: () => pi.getActiveTools(),
131
136
  sessionActive: true,
132
137
  sendCompletionGroup: (items) => {
133
138
  if (!runtime.sessionActive || items.length === 0) return;