@ferris1225/pi-subagents 4.1.5 → 4.1.6

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
@@ -380,16 +380,26 @@ worktree finalization to the same one-time lifecycle owner. `stop-all` interrupt
380
380
  every lane holder before waiting for finalization, avoiding self-deadlock when an
381
381
  isolated apply is queued behind shared work.
382
382
 
383
+ Every control operation is bounded: park, stop, resume, and fork never wait
384
+ indefinitely on a generation that is still settling (for example an isolated
385
+ apply queued behind the managed repository lane). Stop proceeds after a bounded
386
+ deadline once it owns the lifecycle, a still-running integration continues in the
387
+ background, and a durable recovery record is persisted pointing at the retained
388
+ worktree/patch so stopped work is never lost.
389
+
383
390
  ## Results and live status
384
391
 
385
392
  The active TUI widget shows standalone runs normally and projects each managed
386
- workflow as a compact timeline plus its current internal child:
393
+ workflow as a compact timeline plus its current internal child. Every row leads
394
+ with its stable run id — the handle for `subagent_control`, `subagent_status`,
395
+ and `subagent_stop`:
387
396
 
388
397
  ```text
389
- ◆ worker workflow · src/cache.ts · 42s
398
+ #12 worker workflow · src/cache.ts · wt:a91f3c · 42s
390
399
  ✓ implement ─ ● review ─ ○ docs
391
- └ ● reviewer · final review · claude-sonnet-4-5/high · 10s
400
+ └ ● #15 reviewer · final review · claude-sonnet-4-5/high · 10s
392
401
  git diff
402
+ ○ #23 worker · queued · redirect to ripgrep crates · 5m02s
393
403
  ```
394
404
 
395
405
  Success is green, the active stage uses the accent color and bold text, pending
@@ -398,6 +408,15 @@ error. Fix paths show their budget (`fix 1/2`, `re-review 1/2`). The timeline
398
408
  contains only stages that ran or are currently planned; `DOCUMENTATION: CLEAN`
399
409
  removes pending docs instead of pretending that stage ran.
400
410
 
411
+ Worktree-isolated runs carry a group badge on the row that owns the worktree:
412
+ `wt:<id>` while active, `wt:<id> applying` while the settled patch is being
413
+ applied to the original checkout, then `applied`/`clean`, or `retained` when
414
+ integration failed. The short id changes when a resumed generation creates a
415
+ continuation worktree, so a group boundary change is visible at a glance.
416
+ Nested stage rows inherit the group through the tree instead of repeating the
417
+ badge. Queued rows say `queued` and omit model/thinking — the route is
418
+ re-resolved when the run actually starts.
419
+
401
420
  A managed root keeps its original top-level role and workflow-wide elapsed time,
402
421
  but omits model/thinking because several model stages own it. The active nested
403
422
  row shows the current role, relation, selected/fallback model, thinking, stage
@@ -436,6 +455,12 @@ session. Searches, reads, reasoning, and edits already completed are preserved.
436
455
  Ordinary tool and test failures remain task failures and do not trigger a model
437
456
  handoff.
438
457
 
458
+ Model changes apply immediately to work that has not started: a run still
459
+ waiting for a concurrency slot re-resolves its route when it actually starts,
460
+ and managed workflow stages (fix rounds, re-reviews, the conditional documenter)
461
+ re-read the config before each stage launches. Only an already-running child
462
+ keeps the model it started with.
463
+
439
464
  Thinking defaults to **Auto**. pi-subagents starts from the role's preference and
440
465
  chooses only a level the effective model actually supports. A fallback re-checks
441
466
  the level for the main model. `documenter` deliberately ships with the same fast,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "4.1.5",
3
+ "version": "4.1.6",
4
4
  "description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/dispatch.ts CHANGED
@@ -208,7 +208,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
208
208
  if (!run) return; // already finished — stay idempotent
209
209
  if (opts?.silent || !runtime.sessionActive) return;
210
210
  const icon = status === "done" ? "✓" : "✗";
211
- ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
211
+ ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
212
212
  };
213
213
 
214
214
  // Live sub-agent activity → concise one-line status ("thinking",
@@ -315,7 +315,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
315
315
  const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
316
316
  resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
317
317
  const agent = resolveLiveAgentTools(discoveredAgent);
318
- const resolvedRoute = resolveDispatchModelRoute(agent, request.config, request.ctx);
318
+ // Workflow policy (fix-round caps, agents) stays fixed for the chain,
319
+ // but model/thinking routes are re-read per stage so config edits
320
+ // apply to stages that have not launched yet.
321
+ const stageConfig = await loadConfig(runtime.configPath).catch(() => request.config);
322
+ const resolvedRoute = resolveDispatchModelRoute(agent, stageConfig, request.ctx);
319
323
  const route = request.isolation === "worktree"
320
324
  ? { ...resolvedRoute, agent: withWorktreeSystemPrompt(resolvedRoute.agent) }
321
325
  : resolvedRoute;
@@ -323,6 +327,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
323
327
  const runId = monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
324
328
  ...meta,
325
329
  isolation: request.isolation,
330
+ ...(request.worktreeId ? { worktreeId: request.worktreeId } : {}),
326
331
  });
327
332
  const onLive = makeLiveHandler(runId);
328
333
  try {
@@ -339,7 +344,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
339
344
  signal: request.signal,
340
345
  onLive,
341
346
  makeDetails: makeDetails("single", true),
342
- idleTimeoutMs: request.config.idleTimeoutSec * 1000,
347
+ idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
343
348
  },
344
349
  route.mainFallbackRef,
345
350
  );
package/src/monitor.ts CHANGED
@@ -34,13 +34,18 @@ export function isRunActiveStatus(status: RunStatus): boolean {
34
34
  return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
35
35
  }
36
36
 
37
+ /** Durable integration projection of a worktree-isolated run: pending before
38
+ * settlement, finalizing while the patch is applied/cleaned up, then the
39
+ * terminal WorktreeFinalizationStatus. */
40
+ export type RunIntegrationStatus = "pending" | "finalizing" | WorktreeFinalizationStatus;
41
+
37
42
  export interface RunView {
38
43
  id: number;
39
44
  agent: string;
40
45
  task: string;
41
46
  /** Short content label derived from the task (paths/symbols), shown next to
42
47
  * the agent name so concurrent same-agent runs are told apart by what they
43
- * are doing, not just their run id. */
48
+ * are doing, not just by their run id. */
44
49
  label?: string;
45
50
  model?: string;
46
51
  /** Selected model ref when the run handed off to current main. */
@@ -48,7 +53,10 @@ export interface RunView {
48
53
  /** Effective thinking strength this run was launched with (frontmatter/config/global). */
49
54
  thinking?: string;
50
55
  isolation?: IsolationMode;
51
- integrationStatus?: "pending" | WorktreeFinalizationStatus;
56
+ integrationStatus?: RunIntegrationStatus;
57
+ /** Short worktree-group identity (mkdtemp suffix) shared by every run inside
58
+ * one isolated worktree; changes when a continuation worktree is created. */
59
+ worktreeId?: string;
52
60
  forkedFromRunId?: number;
53
61
  forkChildRunIds?: number[];
54
62
  status: RunStatus;
@@ -85,6 +93,7 @@ export interface RunChainMeta {
85
93
  relationLabel?: string;
86
94
  parentRunId?: number;
87
95
  isolation?: IsolationMode;
96
+ worktreeId?: string;
88
97
  forkedFromRunId?: number;
89
98
  continuationKind?: ContinuationKind;
90
99
  }
@@ -458,7 +467,7 @@ export class MonitorStore {
458
467
  ...(meta?.groupId ? { groupId: meta.groupId } : {}),
459
468
  ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
460
469
  ...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
461
- ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
470
+ ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined, ...(meta.worktreeId ? { worktreeId: meta.worktreeId } : {}) } : {}),
462
471
  ...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
463
472
  ...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
464
473
  });
@@ -559,11 +568,17 @@ export class MonitorStore {
559
568
  this.notify();
560
569
  }
561
570
 
562
- setIsolation(id: number, isolation: IsolationMode, integrationStatus?: "pending" | WorktreeFinalizationStatus): void {
571
+ setIsolation(
572
+ id: number,
573
+ isolation: IsolationMode,
574
+ integrationStatus?: RunIntegrationStatus,
575
+ worktreeId?: string,
576
+ ): void {
563
577
  const run = this.find(id);
564
578
  if (!run) return;
565
579
  run.isolation = isolation;
566
580
  run.integrationStatus = integrationStatus;
581
+ if (worktreeId) run.worktreeId = worktreeId;
567
582
  this.notify();
568
583
  }
569
584
 
@@ -608,7 +623,7 @@ export class MonitorStore {
608
623
  model?: string,
609
624
  thinking?: string,
610
625
  isolation?: IsolationMode,
611
- meta?: { elapsedMs?: number; continuationKind?: ContinuationKind },
626
+ meta?: { elapsedMs?: number; continuationKind?: ContinuationKind; worktreeId?: string },
612
627
  ): void {
613
628
  const run = this.find(id);
614
629
  if (!run) {
@@ -619,7 +634,13 @@ export class MonitorStore {
619
634
  label: runLabel(task),
620
635
  model,
621
636
  thinking,
622
- ...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
637
+ ...(isolation
638
+ ? {
639
+ isolation,
640
+ integrationStatus: isolation === "worktree" ? "pending" as const : undefined,
641
+ ...(isolation === "worktree" && meta?.worktreeId ? { worktreeId: meta.worktreeId } : {}),
642
+ }
643
+ : {}),
623
644
  status: "queued",
624
645
  usage: emptyUsage(),
625
646
  elapsedMs: meta?.elapsedMs ?? 0,
@@ -635,6 +656,8 @@ export class MonitorStore {
635
656
  run.thinking = thinking;
636
657
  if (isolation) run.isolation = isolation;
637
658
  run.integrationStatus = isolation === "worktree" ? "pending" : undefined;
659
+ if (isolation === "worktree" && meta?.worktreeId) run.worktreeId = meta.worktreeId;
660
+ else if (isolation !== "worktree") run.worktreeId = undefined;
638
661
  run.status = "queued";
639
662
  run.usage = emptyUsage();
640
663
  run.activity = undefined;
package/src/rpc-run.ts CHANGED
@@ -788,8 +788,32 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
788
788
  if (!closed) await processClosed.promise;
789
789
  return;
790
790
  }
791
- const accepted = await abortAcceptedPrompt();
792
- if (!accepted && !closed) await processClosed.promise;
791
+ // Bound the abort settlement exactly like stop: a child that never
792
+ // settles after abort must not hold the control operation forever.
793
+ let parkTimer: ReturnType<typeof setTimeout> | undefined;
794
+ let parkTimedOut = false;
795
+ const parkDeadline = new Promise<boolean>((resolve) => {
796
+ parkTimer = setTimeout(() => {
797
+ parkTimedOut = true;
798
+ resolve(false);
799
+ }, RPC_ABORT_SETTLE_TIMEOUT_MS);
800
+ if (typeof parkTimer.unref === "function") parkTimer.unref();
801
+ });
802
+ let accepted: boolean;
803
+ try {
804
+ accepted = await Promise.race([abortAcceptedPrompt(), parkDeadline]);
805
+ } catch {
806
+ /* a rejected abort still parks; termination below is the bounded fallback */
807
+ accepted = false;
808
+ } finally {
809
+ if (parkTimer) clearTimeout(parkTimer);
810
+ }
811
+ if (abortSettlement) {
812
+ const stable = abortSettlement;
813
+ abortSettlement = undefined;
814
+ stable.resolve();
815
+ }
816
+ if (!accepted && !parkTimedOut && !closed) await processClosed.promise;
793
817
  if (finished && accepted) throw new Error("Thread exited while parking.");
794
818
  markParked();
795
819
  setAttemptPhase("parked");
@@ -67,6 +67,7 @@ import {
67
67
  } from "./spawn.ts";
68
68
  import {
69
69
  createWorktreeIsolation,
70
+ worktreeGroupId,
70
71
  type IsolationMode,
71
72
  type WorktreeFinalization,
72
73
  type WorktreeIsolation,
@@ -75,6 +76,24 @@ import {
75
76
  export const FORK_CONTINUATION_PROMPT =
76
77
  "Continue from the retained context above. Review the prior work, then take the most useful next step toward completing the existing objective without repeating completed work.";
77
78
 
79
+ /** Control operations must never wait forever on a settling generation: the
80
+ * queue task can legitimately spend minutes in worktree finalization (bounded
81
+ * per-Git-command timeouts) or wait behind the managed repository lane. After
82
+ * this deadline the control path owns the lifecycle synchronously and proceeds
83
+ * while the stuck tail settles silently in the background. */
84
+ export const CONTROL_QUIESCE_TIMEOUT_MS = 20_000;
85
+
86
+ /** Resolve true when the promise settles, or false after the bounded deadline. */
87
+ export function quiesced(promise: Promise<unknown>, timeoutMs: number = CONTROL_QUIESCE_TIMEOUT_MS): Promise<boolean> {
88
+ return Promise.race([
89
+ promise.then(() => true, () => true),
90
+ new Promise<boolean>((resolve) => {
91
+ const timer = setTimeout(() => resolve(false), timeoutMs);
92
+ if (typeof timer.unref === "function") timer.unref();
93
+ }),
94
+ ]);
95
+ }
96
+
78
97
  const WORKTREE_ISOLATION_INSTRUCTIONS =
79
98
  "You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
80
99
 
@@ -148,6 +167,8 @@ export interface ManagedWorkflowRequest extends DispatchEnvironment {
148
167
  executionCwd: string;
149
168
  projectCwd: string;
150
169
  isolation: IsolationMode;
170
+ /** Short identity of the isolated worktree shared by every workflow stage. */
171
+ worktreeId?: string;
151
172
  signal: AbortSignal;
152
173
  rememberLatest: (result: SingleResult) => void;
153
174
  }
@@ -294,6 +315,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
294
315
  }
295
316
  }
296
317
  const executionCwd = worktree?.cwd ?? originalCwd;
318
+ const worktreeGroup = worktree ? worktreeGroupId(worktree) : undefined;
297
319
  const resolvedRoute = resolveDispatchModelRoute(agent, runConfig, runCtx);
298
320
  // Isolation is a persistent system-level invariant, not a one-shot task
299
321
  // prefix: queued retargets, live retargets, resumes, and main-model
@@ -310,6 +332,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
310
332
  }
311
333
  const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
312
334
  isolation,
335
+ ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
313
336
  ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
314
337
  ...(seed?.continuationKind ? { continuationKind: seed.continuationKind } : {}),
315
338
  });
@@ -329,6 +352,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
329
352
  monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation, {
330
353
  elapsedMs: existingThread.elapsedMs,
331
354
  continuationKind: appendedObjectiveOnResume ? "resume-appended" : "resume-retained",
355
+ ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
332
356
  });
333
357
  runtime.settledRuns.delete(runId);
334
358
  }
@@ -426,12 +450,20 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
426
450
  // All normal, destructive-stop, and shutdown owners converge here. Cache
427
451
  // the lane-protected apply itself so superseding lifecycle paths can project
428
452
  // the same finalization onto their own result without acquiring twice.
429
- generationFinalization ??= runInManagedRepositoryLane(
430
- generationWorktree.originalRoot,
431
- () => generationWorktree.finalize(),
432
- );
453
+ if (!generationFinalization) {
454
+ monitor.setIsolation(
455
+ runId,
456
+ "worktree",
457
+ "finalizing",
458
+ worktreeGroupId(generationWorktree),
459
+ );
460
+ generationFinalization = runInManagedRepositoryLane(
461
+ generationWorktree.originalRoot,
462
+ () => generationWorktree.finalize(),
463
+ );
464
+ }
433
465
  const finalization = await generationFinalization;
434
- monitor.setIsolation(runId, "worktree", finalization.status);
466
+ monitor.setIsolation(runId, "worktree", finalization.status, worktreeGroupId(generationWorktree));
435
467
  if (result) {
436
468
  result.runId = runId;
437
469
  result.isolation = "worktree";
@@ -565,7 +597,12 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
565
597
  // control after that child settles, so cancel its queue owner explicitly.
566
598
  if (phase === "settled") runtime.backgroundQueue.cancel(controller);
567
599
  }
568
- await completion;
600
+ // The settling tail may be blocked on worktree finalization or the
601
+ // managed repository lane. Park already owns the lifecycle, so proceed
602
+ // after a bounded wait and let the tail finish silently in the background.
603
+ if (!(await quiesced(completion))) {
604
+ runtime.backgroundQueue.cancel(controller);
605
+ }
569
606
  if (
570
607
  thread.generation !== generation ||
571
608
  thread.lifecycleVersion !== version ||
@@ -631,7 +668,15 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
631
668
  let continuationWorktree: WorktreeIsolation | undefined;
632
669
  let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
633
670
  try {
634
- await thread.generationCompletion;
671
+ // Never wait forever on a previous generation that is still settling
672
+ // (e.g. blocked behind the managed repository lane in finalization).
673
+ if (!(await quiesced(thread.generationCompletion))) {
674
+ return failedStartResult(
675
+ thread.agentName,
676
+ thread.task,
677
+ `Run #${runId}'s previous generation is still settling; retry the resume shortly.`,
678
+ );
679
+ }
635
680
  if (!ownsResumeReservation(thread, reservation)) {
636
681
  return failedStartResult(
637
682
  thread.agentName,
@@ -811,7 +856,15 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
811
856
  let childWorktree: WorktreeIsolation | undefined;
812
857
  let forkedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
813
858
  try {
814
- await thread.generationCompletion;
859
+ // Same bounded preflight as resume: a still-settling source generation
860
+ // must not block the control operation forever.
861
+ if (!(await quiesced(thread.generationCompletion))) {
862
+ return failedStartResult(
863
+ thread.agentName,
864
+ thread.task,
865
+ `Run #${runId}'s previous generation is still settling; retry the fork shortly.`,
866
+ );
867
+ }
815
868
  if (!ownsFork()) {
816
869
  return failedStartResult(thread.agentName, thread.task, `Run #${runId} changed while fork was preparing; no child was started.`);
817
870
  }
@@ -911,23 +964,41 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
911
964
  isolation === "shared" && canStartManagedWorkflow(agent, workflowAvailability);
912
965
  const runGeneration = async (backgroundSignal: AbortSignal): Promise<void> => {
913
966
  if (runtime.threads.get(runId)?.generation !== generation) return;
967
+ // Model/thinking config may have changed while this generation sat
968
+ // queued behind the concurrency limit. Re-resolve the route at actual
969
+ // start so /subagents-setup edits apply to not-yet-started runs.
970
+ let activeRoute = route;
971
+ let activeIdleTimeoutMs = runConfig.idleTimeoutSec * 1000;
972
+ try {
973
+ const startConfig = await loadConfig(runtime.configPath);
974
+ runtime.backgroundQueue.setConcurrency(startConfig.maxConcurrency);
975
+ const resolvedStart = resolveDispatchModelRoute(agent, startConfig, runCtx);
976
+ activeRoute = isolation === "worktree"
977
+ ? { ...resolvedStart, agent: withWorktreeSystemPrompt(resolvedStart.agent) }
978
+ : resolvedStart;
979
+ activeIdleTimeoutMs = startConfig.idleTimeoutSec * 1000;
980
+ monitor.setModel(runId, activeRoute.agent.model);
981
+ monitor.setThinking(runId, activeRoute.thinkingLevel);
982
+ } catch {
983
+ /* keep the dispatch-time route when fresh config is unavailable */
984
+ }
914
985
  let result: SingleResult;
915
986
  try {
916
987
  result = await runSingleAgentWithMainFallback(
917
988
  {
918
989
  defaultCwd: executionCwd,
919
- agent: route.agent,
990
+ agent: activeRoute.agent,
920
991
  resolveAgentForAttempt: resolveLiveAgentTools,
921
992
  agentName,
922
993
  task,
923
994
  cwd: executionCwd,
924
- thinkingLevel,
925
- thinkingLevelForModel: route.thinkingLevelForModel,
995
+ thinkingLevel: activeRoute.thinkingLevel,
996
+ thinkingLevelForModel: activeRoute.thinkingLevelForModel,
926
997
  signal: backgroundSignal,
927
998
  onLive,
928
999
  control,
929
1000
  makeDetails: makeDetails("single", true),
930
- idleTimeoutMs: runConfig.idleTimeoutSec * 1000,
1001
+ idleTimeoutMs: activeIdleTimeoutMs,
931
1002
  ...(priorSessionId && priorSessionDir
932
1003
  ? {
933
1004
  sessionId: priorSessionId,
@@ -938,7 +1009,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
938
1009
  }
939
1010
  : {}),
940
1011
  },
941
- route.mainFallbackRef,
1012
+ activeRoute.mainFallbackRef,
942
1013
  );
943
1014
  } catch (error) {
944
1015
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -1013,6 +1084,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
1013
1084
  executionCwd: thread.executionCwd,
1014
1085
  projectCwd: originalCwd,
1015
1086
  isolation,
1087
+ ...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
1016
1088
  signal: backgroundSignal,
1017
1089
  ctx: runCtx,
1018
1090
  config: runConfig,
package/src/tools.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { StringEnum } from "@earendil-works/pi-ai";
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  import { Text } from "@earendil-works/pi-tui";
10
+ import { existsSync } from "node:fs";
10
11
  import { Type } from "typebox";
11
12
  import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
12
13
  import { formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
@@ -19,8 +20,11 @@ import {
19
20
  statusLabel,
20
21
  type RunStatus,
21
22
  } from "./monitor.ts";
23
+ import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
22
24
  import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
25
+ import { CONTROL_QUIESCE_TIMEOUT_MS, quiesced } from "./thread-lifecycle.ts";
23
26
  import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
27
+ import type { WorktreeFinalization } from "./worktree.ts";
24
28
 
25
29
  /** In-turn result lookup. Dispatch already ended the turn and results arrive as
26
30
  * wake-up messages, so the default must NOT block: a settled run returns its
@@ -648,6 +652,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
648
652
 
649
653
  const stopped: string[] = [];
650
654
  const retainedIntegration: string[] = [];
655
+ const pendingIntegration: string[] = [];
651
656
  for (const [claimIndex, claim] of claimed.entries()) {
652
657
  const {
653
658
  runId,
@@ -663,8 +668,13 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
663
668
  stopVersion,
664
669
  stopMessage,
665
670
  } = claim;
666
- await interruptionPromises[claimIndex];
667
- await completion;
671
+ // Every wait here is bounded: the queue task can sit for minutes in
672
+ // worktree finalization or behind the managed repository lane, and an
673
+ // unkillable child can stall even the RPC-level stop. Stop owns the
674
+ // lifecycle synchronously, so a stuck tail settles silently after we
675
+ // proceed; none of its late paths can publish a second result.
676
+ await quiesced(interruptionPromises[claimIndex]);
677
+ if (!(await quiesced(completion))) runtime.backgroundQueue.cancel(controller);
668
678
  if (runtime.runControllers.get(runId) === controller) runtime.runControllers.delete(runId);
669
679
  if (thread.queueController === controller) thread.queueController = undefined;
670
680
 
@@ -703,8 +713,42 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
703
713
  runId,
704
714
  isolation: thread.isolation,
705
715
  };
706
- const finalization = await thread.finalizeIsolation(generation, stoppedResult);
707
- if (finalization?.status === "retained") retainedIntegration.push(`#${runId}`);
716
+ const worktree = thread.worktree;
717
+ let finalization: WorktreeFinalization | undefined;
718
+ try {
719
+ finalization = await Promise.race([
720
+ thread.finalizeIsolation(generation, stoppedResult),
721
+ new Promise<undefined>((resolve) => {
722
+ const timer = setTimeout(() => resolve(undefined), CONTROL_QUIESCE_TIMEOUT_MS);
723
+ if (typeof timer.unref === "function") timer.unref();
724
+ }),
725
+ ]);
726
+ } catch {
727
+ /* an unexpected finalize rejection must not block the stop */
728
+ }
729
+ if (finalization === undefined) {
730
+ // Integration is still settling in the background. Point a
731
+ // durable recovery record at the artifacts so the isolated work
732
+ // stays findable even if the background tail later fails; a
733
+ // successful tail removes them and the record self-prunes.
734
+ if (thread.isolation === "worktree" && worktree) {
735
+ stoppedResult.integrationStatus = "pending";
736
+ stoppedResult.integrationWorktreePath = worktree.worktreePath;
737
+ await persistRecoveryRecords(runtime.configPath, [
738
+ recoveryRecordFromFinalization(runId, {
739
+ status: "retained",
740
+ integrated: false,
741
+ hadChanges: false,
742
+ ...(existsSync(worktree.worktreePath) ? { worktreePath: worktree.worktreePath } : {}),
743
+ ...(existsSync(worktree.patchPath) ? { patchPath: worktree.patchPath } : {}),
744
+ error: "subagent_stop timed out waiting for worktree integration; it continues in the background",
745
+ }),
746
+ ]).catch(() => undefined);
747
+ }
748
+ pendingIntegration.push(`#${runId}`);
749
+ } else if (finalization.status === "retained") {
750
+ retainedIntegration.push(`#${runId}`);
751
+ }
708
752
  runtime.registerRunResult(runId, stoppedResult);
709
753
  thread.lastResult = stoppedResult;
710
754
  }
@@ -730,7 +774,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
730
774
  return {
731
775
  content: [{
732
776
  type: "text",
733
- text: `Stopped ${stopped.length} thread${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. Retained sessions were retired; worktree changes are integrated on settlement.${retainedIntegration.length > 0 ? ` Integration failed for ${retainedIntegration.join(", ")}; inspect its result for retained recovery paths.` : ""}`,
777
+ text: `Stopped ${stopped.length} thread${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. Retained sessions were retired; worktree changes are integrated on settlement.${retainedIntegration.length > 0 ? ` Integration failed for ${retainedIntegration.join(", ")}; inspect its result for retained recovery paths.` : ""}${pendingIntegration.length > 0 ? ` Integration is still settling in the background for ${pendingIntegration.join(", ")}; a recovery record was persisted in case it fails.` : ""}`,
734
778
  }],
735
779
  details: {},
736
780
  };
package/src/widget.ts CHANGED
@@ -39,6 +39,26 @@ function formatContinuationTask(label: string, task: string, width: number): str
39
39
  return summary ? `${label}${separator}${summary}` : label;
40
40
  }
41
41
 
42
+ /** Worktree-group badge shown on the row that owns the isolated worktree: the
43
+ * short group identity plus its integration state, so a workflow visibly moves
44
+ * through applying → applied (or retained) and a continuation worktree (new
45
+ * identity) is distinguishable from the original one. */
46
+ function worktreeBadge(run: RunView): string {
47
+ const id = run.worktreeId ?? "?";
48
+ switch (run.integrationStatus) {
49
+ case "finalizing":
50
+ return `wt:${id} applying`;
51
+ case "integrated":
52
+ return `wt:${id} applied`;
53
+ case "no_changes":
54
+ return `wt:${id} clean`;
55
+ case "retained":
56
+ return `wt:${id} retained`;
57
+ default:
58
+ return `wt:${id}`;
59
+ }
60
+ }
61
+
42
62
  /** One compact primary line per genuinely active run, plus an optional indented
43
63
  * activity line. The primary line reserves stage model/thinking when present and
44
64
  * elapsed width before truncating the task. Settled and parked threads never
@@ -49,6 +69,7 @@ function runPrimaryLine(
49
69
  width: number,
50
70
  now: number,
51
71
  prefix: string,
72
+ isGroupOwner: boolean,
52
73
  ): string {
53
74
  const dim = (text: string): string => theme.fg("dim", text);
54
75
  const icon = run.managedWorkflow && run.status === "running"
@@ -56,18 +77,24 @@ function runPrimaryLine(
56
77
  : statusIcon(run.status, theme);
57
78
  const displayName = run.managedWorkflow ? `${run.agent} workflow` : run.agent;
58
79
  const name = theme.fg("accent", theme.bold(displayName));
59
- const identity = `${prefix}${icon} ${name}`;
80
+ // The stable run id is the handle for subagent_control/subagent_status; a
81
+ // queued run has not started, which must be visible at a glance. Its model
82
+ // is omitted too: the route is re-resolved when the run actually starts.
83
+ const queued = run.status === "queued";
84
+ const queuedTag = queued ? ` ${dim("· queued")}` : "";
85
+ const identity = `${prefix}${icon} #${run.id} ${name}${queuedTag}`;
60
86
  const elapsed = formatElapsed(run, now);
61
87
  // Render only the resolved model id plus thinking level. Provider auth and
62
88
  // other configuration never enter monitor state or this line.
63
89
  const modelId = run.model?.split("/").at(-1);
64
- const modelSource = run.managedWorkflow
90
+ const modelSource = run.managedWorkflow || queued
65
91
  ? ""
66
92
  : formatTaskSummary(
67
93
  modelId ? `${modelId}${run.thinking ? `/${run.thinking}` : ""}` : run.thinking ? `thinking:${run.thinking}` : "",
68
94
  64,
69
95
  false,
70
96
  );
97
+ const badge = isGroupOwner && run.isolation === "worktree" ? worktreeBadge(run) : "";
71
98
  // A chain child shows its role in the chain plus a task-derived label; the
72
99
  // templated fix brief itself would only repeat the parent review's content.
73
100
  const continuation = run.parentRunId === undefined
@@ -81,7 +108,7 @@ function runPrimaryLine(
81
108
  const taskDesiredSource = [continuation, taskSource]
82
109
  .filter((part): part is string => Boolean(part))
83
110
  .join(" · ");
84
- const primaryPartCount = 2 + (modelSource ? 1 : 0) + (elapsed ? 1 : 0);
111
+ const primaryPartCount = 2 + (modelSource ? 1 : 0) + (badge ? 1 : 0) + (elapsed ? 1 : 0);
85
112
  const contentWidth = Math.max(
86
113
  0,
87
114
  width -
@@ -130,6 +157,7 @@ function runPrimaryLine(
130
157
  identity,
131
158
  task ? dim(task) : undefined,
132
159
  modelThinking ? dim(modelThinking) : undefined,
160
+ badge ? dim(badge) : undefined,
133
161
  ].filter((part): part is string => Boolean(part)).join(" · ");
134
162
  return compactLine(primaryLeft, elapsed ? dim(`· ${elapsed}`) : "", width);
135
163
  }
@@ -211,7 +239,9 @@ export function formatActiveRunLines(
211
239
  const lines: string[] = [];
212
240
  for (const root of roots) {
213
241
  const children = childrenOf.get(root.id) ?? [];
214
- lines.push(runPrimaryLine(root, theme, width, now, ""));
242
+ // Roots (including orphaned chain children whose parent row is gone) own
243
+ // their worktree group; nested children inherit the group via the tree.
244
+ lines.push(runPrimaryLine(root, theme, width, now, "", true));
215
245
  const hasTimeline = Boolean(root.managedWorkflow && root.workflowStages?.length);
216
246
  const timeline = hasTimeline ? workflowTimelineLine(root, theme, width) : undefined;
217
247
  if (timeline) lines.push(timeline);
@@ -222,7 +252,7 @@ export function formatActiveRunLines(
222
252
  }
223
253
  children.forEach((child, index) => {
224
254
  const connector = index === children.length - 1 ? "└ " : "├ ";
225
- lines.push(runPrimaryLine(child, theme, width, now, theme.fg("dim", ` ${connector}`)));
255
+ lines.push(runPrimaryLine(child, theme, width, now, theme.fg("dim", ` ${connector}`), false));
226
256
  lines.push(...runActivityLine(child, theme, width, " "));
227
257
  });
228
258
  }
package/src/worktree.ts CHANGED
@@ -16,6 +16,18 @@ import { isAbsolute, join, relative, resolve } from "node:path";
16
16
 
17
17
  export type IsolationMode = "shared" | "worktree";
18
18
 
19
+ const WORKTREE_TEMP_DIR_PREFIX = "pi-subagent-worktree-";
20
+
21
+ /** Short stable identity of one isolated worktree group (the mkdtemp suffix).
22
+ * Continuation/fork generations create a fresh worktree, so the identity
23
+ * visibly changes when the group's filesystem boundary changes. */
24
+ export function worktreeGroupId(worktree: Pick<WorktreeIsolation, "tempDir">): string {
25
+ const base = worktree.tempDir.split(/[\\/]/).filter(Boolean).pop() ?? worktree.tempDir;
26
+ return base.startsWith(WORKTREE_TEMP_DIR_PREFIX)
27
+ ? base.slice(WORKTREE_TEMP_DIR_PREFIX.length)
28
+ : base;
29
+ }
30
+
19
31
  export interface CommandRunOptions {
20
32
  cwd: string;
21
33
  input?: Buffer;