@signalridge/pi-worktree 1.4.3 → 1.4.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.5
4
+ ### Patch Changes
5
+
6
+ - c2fd867: Bound stdin Git cancellation and timeout cleanup with owned TERM-to-KILL escalation and a final confirmation deadline. Retain every concurrent unconfirmed handle per cwd/argv key and refuse equivalent replacements while any failed owner remains live; one late exit cannot release another owner's lease. Release retained handle leases on late actual exit without requiring a retry, and preserve captured output and signal-failure diagnostics through GitWorktreeError wrapping.
7
+ - Updated dependencies [c2fd867]
8
+ - @signalridge/pi-ui@1.4.0
9
+
10
+ ## 1.4.4
11
+ ### Patch Changes
12
+
13
+ - 3364cc0: Use patched Vitest 4.1.11 for development and regression testing without moving to a new major version. Refresh compatible transitive dependencies to include fixed source-map and brace-expansion releases.
14
+ - 3364cc0: Adopt Pi 1.0.4's host-provided dependency contract: declare imported Pi and TypeBox modules as `*` peers rather than installing duplicate runtime modules, and align Pi development pins with the repository's tested 1.0.4 baseline. The wildcard identifies host module ownership; it does not promise compatibility with every Pi version.
15
+ - 3364cc0: Cancel credential loaders, settings dialogs, and worktree operations when their owning session or branch retires, and ignore late results. Include boundary-generated usage receipts in statusline polling, mark only subscription-backed OAuth costs, and report headless worktree rejection on stderr.
16
+ - 3364cc0: Keep runtime settings synchronized after successful atomic publication even when the command owner retires. Preserve sanitized recovery diagnostics for retained worktrees and uncertain metadata removal outcomes after cancellation, without notifying obsolete UI contexts or attempting to restore committed Git registrations.
17
+ - Updated dependencies [3364cc0]
18
+ - Updated dependencies [3364cc0]
19
+ - Updated dependencies [3364cc0]
20
+ - @signalridge/pi-ui@1.3.3
21
+
3
22
  ## 1.4.3
4
23
  ### Patch Changes
5
24
 
package/README.md CHANGED
@@ -23,6 +23,8 @@ This package is the full interactive worktree manager. It is publishable and sta
23
23
  - Always previews stale metadata before pruning it and revalidates the preview after confirmation.
24
24
  - Runs Git through argv-based subprocess calls, without interpolating user input into shell commands.
25
25
 
26
+ Stdin Git cancellation and timeout use bounded TERM/KILL escalation on the captured process handle. If termination remains unconfirmed, equivalent cwd/argv invocations are blocked until every failed live handle exits, including when multiple invocations were already concurrent. A late exit releases only its own handle; no saved PID is adopted or signaled. Captured output and signal failure diagnostics remain available for recovery.
27
+
26
28
  ## Install
27
29
 
28
30
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-worktree",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "Pi extension for safe interactive Git worktree management and workspace switching.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,16 +35,16 @@
35
35
  "lint": "biome check ."
36
36
  },
37
37
  "peerDependencies": {
38
- "@earendil-works/pi-coding-agent": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1"
38
+ "@earendil-works/pi-coding-agent": "*"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@biomejs/biome": "2.5.7",
42
- "@earendil-works/pi-coding-agent": "0.99.1",
43
- "@earendil-works/pi-tui": "0.99.1",
42
+ "@earendil-works/pi-coding-agent": "1.0.4",
43
+ "@earendil-works/pi-tui": "1.0.4",
44
44
  "@types/node": "26.1.2",
45
45
  "@types/proper-lockfile": "^4.1.4",
46
46
  "typescript": "7.0.2",
47
- "vitest": "4.0.18"
47
+ "vitest": "4.1.11"
48
48
  },
49
49
  "repository": {
50
50
  "type": "git",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "@narumitw/pi-tui-kit": "^0.54.0",
63
- "@signalridge/pi-ui": "^1.3.2",
63
+ "@signalridge/pi-ui": "^1.4.0",
64
64
  "proper-lockfile": "^4.1.2"
65
65
  }
66
66
  }
@@ -0,0 +1,51 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface WorktreeMenuOwner {
4
+ signal: AbortSignal;
5
+ isCurrent(): boolean;
6
+ }
7
+
8
+ /** Await host work without retaining abort listeners or requiring an RPC response. */
9
+ export async function awaitOwned<T>(owner: WorktreeMenuOwner, start: () => Promise<T>): Promise<T> {
10
+ assertOwner(owner);
11
+ let cancel!: () => void;
12
+ const cancelled = new Promise<never>((_resolve, reject) => {
13
+ cancel = () => reject(new DOMException("Worktree command cancelled", "AbortError"));
14
+ owner.signal.addEventListener("abort", cancel, { once: true });
15
+ });
16
+ try {
17
+ assertOwner(owner);
18
+ const result = await Promise.race([start(), cancelled]);
19
+ assertOwner(owner);
20
+ return result;
21
+ } finally {
22
+ owner.signal.removeEventListener("abort", cancel);
23
+ }
24
+ }
25
+
26
+ export function assertOwner(owner: WorktreeMenuOwner): void {
27
+ if (owner.signal.aborted || !owner.isCurrent()) {
28
+ throw new DOMException("Worktree command cancelled", "AbortError");
29
+ }
30
+ }
31
+
32
+ /** All flows use the lifecycle/action signal, never the agent-run ctx.signal. */
33
+ export function ownedContext(ctx: ExtensionCommandContext, owner: WorktreeMenuOwner): ExtensionCommandContext {
34
+ return {
35
+ ...ctx,
36
+ signal: owner.signal,
37
+ waitForIdle: () => awaitOwned(owner, () => ctx.waitForIdle()),
38
+ ui: {
39
+ ...ctx.ui,
40
+ input: (title, placeholder, options) =>
41
+ awaitOwned(owner, () => ctx.ui.input(title, placeholder, { ...options, signal: owner.signal })),
42
+ confirm: (title, message, options) =>
43
+ awaitOwned(owner, () => ctx.ui.confirm(title, message, { ...options, signal: owner.signal })),
44
+ select: (title, choices, options) =>
45
+ awaitOwned(owner, () => ctx.ui.select(title, choices, { ...options, signal: owner.signal })),
46
+ notify: (message, level) => {
47
+ if (!owner.signal.aborted && owner.isCurrent()) ctx.ui.notify(message, level);
48
+ },
49
+ },
50
+ };
51
+ }
package/src/command.ts CHANGED
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
4
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
5
5
  import { withBorderedCustomUi } from "@signalridge/pi-ui";
6
+ import { assertOwner, ownedContext, type WorktreeMenuOwner } from "./command-owner.js";
6
7
  import {
7
8
  type AdministrativePruneCandidate,
8
9
  addWorktree,
@@ -28,6 +29,7 @@ import {
28
29
  unresolvableSymlinkAncestor,
29
30
  validateBranch,
30
31
  type WorktreeRecord,
32
+ WorktreeRecoveryError,
31
33
  withWorktreeMutationLock,
32
34
  worktreeAdministrativeDirectory,
33
35
  worktreeForBranch,
@@ -54,11 +56,6 @@ const ACTIONS = {
54
56
  configure: ACTION_CONFIGURE_ROOT,
55
57
  } as const;
56
58
 
57
- interface WorktreeMenuOwner {
58
- signal: AbortSignal;
59
- isCurrent(): boolean;
60
- }
61
-
62
59
  interface AdministrativeHistoryRisk {
63
60
  label: string;
64
61
  oids: string[];
@@ -90,19 +87,31 @@ export function registerWorktreeCommand(
90
87
  return;
91
88
  }
92
89
 
90
+ // Capture ownership before idle/Git preflight, not after it.
91
+ const owner = getMenuOwner();
92
+ const originalCtx = ctx;
93
+ if (owner.signal.aborted || !owner.isCurrent()) return;
94
+ ctx = ownedContext(ctx, owner);
93
95
  try {
94
96
  await ctx.waitForIdle();
95
97
  const records = await listWorktrees(pi, ctx.cwd, ctx.signal);
96
98
  const currentPath = await currentWorktreePath(pi, ctx.cwd, ctx.signal);
97
99
  const root = settings.get();
98
100
  const warning = root.warning ? " — settings warning" : "";
99
- const owner = getMenuOwner();
100
- if (owner.signal.aborted || !owner.isCurrent()) return;
101
- const runFlow = async (flow: () => Promise<void>) => {
101
+ assertOwner(owner);
102
+ const runFlow = async (signal: AbortSignal, flow: (flowCtx: ExtensionCommandContext) => Promise<void>) => {
103
+ const actionOwner = { signal: AbortSignal.any([owner.signal, signal]), isCurrent: owner.isCurrent };
102
104
  try {
103
- await flow();
105
+ assertOwner(actionOwner);
106
+ await flow(ownedContext(originalCtx, actionOwner));
104
107
  } catch (error) {
105
- safeNotify(ctx, formatError(error), "error");
108
+ if (!actionOwner.signal.aborted && actionOwner.isCurrent()) {
109
+ safeNotify(ctx, formatError(error), "error");
110
+ } else if (error instanceof WorktreeRecoveryError) {
111
+ // Retained trees/unknown mutation outcomes outlive their UI owner.
112
+ // Do not access a retired context, even to check whether it has UI.
113
+ console.error(stripTerminalControls(formatError(error)));
114
+ }
106
115
  }
107
116
  return { kind: "close" } as const;
108
117
  };
@@ -128,12 +137,15 @@ export function registerWorktreeCommand(
128
137
  }),
129
138
  },
130
139
  actions: {
131
- add: async () => runFlow(() => addFlow(pi, ctx, records, root.effectiveRoot)),
132
- switch: async ({ signal }) => runFlow(() => switchFlow(pi, ctx, records, currentPath, signal)),
133
- remove: async ({ signal }) => runFlow(() => removeFlow(pi, ctx, records, currentPath, signal)),
134
- status: async ({ signal }) => runFlow(() => statusFlow(pi, ctx, records, currentPath, signal)),
135
- prune: async () => runFlow(() => pruneFlow(pi, ctx, records)),
136
- configure: async () => runFlow(() => configureRootFlow(ctx, settings)),
140
+ add: async ({ signal }) => runFlow(signal, (flowCtx) => addFlow(pi, flowCtx, records, root.effectiveRoot)),
141
+ switch: async ({ signal }) =>
142
+ runFlow(signal, (flowCtx) => switchFlow(pi, flowCtx, records, currentPath, flowCtx.signal)),
143
+ remove: async ({ signal }) =>
144
+ runFlow(signal, (flowCtx) => removeFlow(pi, flowCtx, records, currentPath, flowCtx.signal)),
145
+ status: async ({ signal }) =>
146
+ runFlow(signal, (flowCtx) => statusFlow(pi, flowCtx, records, currentPath, flowCtx.signal)),
147
+ prune: async ({ signal }) => runFlow(signal, (flowCtx) => pruneFlow(pi, flowCtx, records)),
148
+ configure: async ({ signal }) => runFlow(signal, (flowCtx) => configureRootFlow(flowCtx, settings)),
137
149
  },
138
150
  });
139
151
  await runMenu(withBorderedCustomUi(ctx), menu, {
@@ -149,7 +161,8 @@ export function registerWorktreeCommand(
149
161
  }
150
162
 
151
163
  async function configureRootFlow(ctx: ExtensionCommandContext, settings: WorktreeSettingsRuntime): Promise<void> {
152
- const current = await settings.reload();
164
+ const current = await settings.reload(ctx.signal);
165
+ ctx.signal?.throwIfAborted();
153
166
  if (!current.canSave) {
154
167
  throw new Error(current.warning ?? `Fix ${settings.getPath()} before changing pi-worktree settings.`);
155
168
  }
@@ -159,7 +172,9 @@ async function configureRootFlow(ctx: ExtensionCommandContext, settings: Worktre
159
172
  );
160
173
  if (requested === undefined) return;
161
174
  const configuredRoot = requested.trim() || undefined;
162
- const updated = await settings.save(configuredRoot);
175
+ ctx.signal?.throwIfAborted();
176
+ const updated = await settings.save(configuredRoot, ctx.signal);
177
+ ctx.signal?.throwIfAborted();
163
178
  safeNotify(
164
179
  ctx,
165
180
  configuredRoot === undefined
@@ -320,7 +335,7 @@ async function addFlow(
320
335
  }
321
336
  return verified;
322
337
  } catch (error) {
323
- throw new Error(
338
+ throw new WorktreeRecoveryError(
324
339
  `Git add completed, so the worktree was retained at ${targetPath}, but verification failed: ${formatError(error)}. Inspect git worktree list before retrying.`,
325
340
  );
326
341
  }
@@ -859,10 +874,15 @@ async function selectWorktree(
859
874
  }
860
875
 
861
876
  function safeNotify(ctx: ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
877
+ const sanitized = stripTerminalControls(message);
878
+ if (!ctx.hasUI) {
879
+ console.error(sanitized);
880
+ return;
881
+ }
862
882
  try {
863
- ctx.ui.notify(stripTerminalControls(message), level);
883
+ ctx.ui.notify(sanitized, level);
864
884
  } catch {
865
- console.error(message);
885
+ console.error(sanitized);
866
886
  }
867
887
  }
868
888
 
@@ -0,0 +1,178 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
+ import type { ExecResult } from "@earendil-works/pi-coding-agent";
3
+
4
+ // An unconfirmed process remains owned by its actual handle. Never signal a
5
+ // saved PID or allow an equivalent invocation to replace it while still alive.
6
+ const unconfirmed = new Map<string, Set<ChildProcessWithoutNullStreams>>();
7
+ const TERM_GRACE_MS = 250;
8
+ const CONFIRMATION_MS = 1_000;
9
+
10
+ type SignalAttempt = { signal: NodeJS.Signals; accepted: boolean; error?: Error };
11
+
12
+ /** Captured diagnostics without inventing an exit code for an unconfirmed process. */
13
+ export class GitInputError extends Error {
14
+ readonly stdout: string;
15
+ readonly stderr: string;
16
+ readonly reason: string;
17
+ readonly terminationConfirmed: boolean;
18
+ readonly signals: readonly SignalAttempt[];
19
+
20
+ constructor(
21
+ message: string,
22
+ stdout: string,
23
+ stderr: string,
24
+ reason: string,
25
+ terminationConfirmed: boolean,
26
+ signals: readonly SignalAttempt[],
27
+ cause?: Error,
28
+ ) {
29
+ super(message, { cause });
30
+ this.name = "GitInputError";
31
+ this.stdout = stdout;
32
+ this.stderr = stderr;
33
+ this.reason = reason;
34
+ this.terminationConfirmed = terminationConfirmed;
35
+ this.signals = [...signals];
36
+ }
37
+ }
38
+
39
+ function retainUntilExit(key: string, child: ChildProcessWithoutNullStreams): void {
40
+ let owners = unconfirmed.get(key);
41
+ if (!owners) {
42
+ owners = new Set();
43
+ unconfirmed.set(key, owners);
44
+ }
45
+ owners.add(child);
46
+ const release = () => {
47
+ owners.delete(child);
48
+ if (owners.size === 0 && unconfirmed.get(key) === owners) unconfirmed.delete(key);
49
+ child.removeListener("exit", release);
50
+ child.removeListener("close", release);
51
+ };
52
+ child.once("exit", release);
53
+ child.once("close", release);
54
+ if (child.exitCode !== null || child.signalCode !== null) release();
55
+ }
56
+
57
+ export function runGitInput(
58
+ args: string[],
59
+ cwd: string,
60
+ input: string,
61
+ signal?: AbortSignal,
62
+ timeout = 15_000,
63
+ ): Promise<ExecResult> {
64
+ const key = JSON.stringify([cwd, args]);
65
+ const previous = unconfirmed.get(key);
66
+ if (previous) {
67
+ for (const child of previous) {
68
+ if (child.exitCode === null && child.signalCode === null) {
69
+ return Promise.reject(
70
+ new Error("Previous Git termination is unconfirmed. Check the owned Git process and retry after it exits."),
71
+ );
72
+ }
73
+ }
74
+ unconfirmed.delete(key);
75
+ }
76
+ if (signal?.aborted) return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true });
77
+ return new Promise((resolve, reject) => {
78
+ const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
79
+ let stdout = "";
80
+ let stderr = "";
81
+ let settled = false;
82
+ let stopping = false;
83
+ let reason = "";
84
+ let failure: Error | undefined;
85
+ const signals: SignalAttempt[] = [];
86
+ const capturedFailure = (message: string, confirmed: boolean, cause?: Error) =>
87
+ new GitInputError(message, stdout, stderr, reason, confirmed, signals, cause);
88
+ let escalation: ReturnType<typeof setTimeout> | undefined;
89
+ let confirmation: ReturnType<typeof setTimeout> | undefined;
90
+ const cleanup = () => {
91
+ clearTimeout(deadline);
92
+ clearTimeout(escalation);
93
+ clearTimeout(confirmation);
94
+ signal?.removeEventListener("abort", abort);
95
+ child.removeListener("error", error);
96
+ child.removeListener("close", close);
97
+ child.stdin.removeListener("error", inputError);
98
+ child.stdout.removeListener("data", output);
99
+ child.stderr.removeListener("data", diagnostics);
100
+ child.stdin.destroy();
101
+ child.stdout.destroy();
102
+ child.stderr.destroy();
103
+ };
104
+ const finish = (result?: ExecResult, error?: Error) => {
105
+ if (settled) return;
106
+ settled = true;
107
+ cleanup();
108
+ if (error) reject(error);
109
+ else if (result) resolve(result);
110
+ };
111
+ const sendSignal = (name: NodeJS.Signals) => {
112
+ try {
113
+ const accepted = child.kill(name);
114
+ signals.push({ signal: name, accepted });
115
+ if (!accepted) failure ??= new Error(`Git did not accept ${name}.`);
116
+ } catch (error) {
117
+ const cause = error instanceof Error ? error : new Error(String(error));
118
+ signals.push({ signal: name, accepted: false, error: cause });
119
+ failure ??= cause;
120
+ }
121
+ };
122
+ const stop = (why: string) => {
123
+ if (settled || stopping) return;
124
+ stopping = true;
125
+ reason = why;
126
+ clearTimeout(deadline);
127
+ sendSignal("SIGTERM");
128
+ escalation = setTimeout(() => {
129
+ if (settled) return;
130
+ sendSignal("SIGKILL");
131
+ confirmation = setTimeout(() => {
132
+ if (settled) return;
133
+ retainUntilExit(key, child);
134
+ finish(
135
+ undefined,
136
+ capturedFailure(
137
+ `Git termination could not be confirmed after ${reason}. Check the owned Git process before retrying.${failure ? ` ${failure.message}` : ""}`,
138
+ false,
139
+ failure,
140
+ ),
141
+ );
142
+ }, CONFIRMATION_MS);
143
+ }, TERM_GRACE_MS);
144
+ };
145
+ const abort = () => stop("cancellation");
146
+ const error = (value: Error) => {
147
+ failure = value;
148
+ if (child.pid === undefined) finish(undefined, capturedFailure(value.message, true, value));
149
+ else stop("process failure");
150
+ };
151
+ const inputError = (value: NodeJS.ErrnoException) => {
152
+ if (value.code !== "EPIPE") error(value);
153
+ };
154
+ const output = (chunk: string) => {
155
+ stdout += chunk;
156
+ };
157
+ const diagnostics = (chunk: string) => {
158
+ stderr += chunk;
159
+ };
160
+ const close = (code: number | null, closeSignal: NodeJS.Signals | null) => {
161
+ finish(
162
+ { stdout, stderr, code: code ?? 1, killed: stopping || closeSignal !== null },
163
+ failure ? capturedFailure(failure.message, true, failure) : undefined,
164
+ );
165
+ };
166
+ const deadline = setTimeout(() => stop(`${timeout}ms timeout`), timeout);
167
+ child.stdout.setEncoding("utf8");
168
+ child.stderr.setEncoding("utf8");
169
+ child.stdout.on("data", output);
170
+ child.stderr.on("data", diagnostics);
171
+ child.stdin.on("error", inputError);
172
+ child.once("error", error);
173
+ child.once("close", close);
174
+ signal?.addEventListener("abort", abort, { once: true });
175
+ if (signal?.aborted) abort();
176
+ child.stdin.end(input);
177
+ });
178
+ }
package/src/git.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { spawn } from "node:child_process";
2
1
  import { randomUUID } from "node:crypto";
3
2
  import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
4
3
  import { lstat, readdir, rename, rmdir, unlink } from "node:fs/promises";
5
4
  import { basename, dirname, join, resolve } from "node:path";
6
5
  import type { ExecResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
6
  import lockfile from "proper-lockfile";
7
+ import { GitInputError, runGitInput } from "./git-input.ts";
8
8
 
9
9
  const GIT_TIMEOUT_MS = 15_000;
10
10
  const GIT_MUTATION_TIMEOUT_MS = 60_000;
@@ -63,13 +63,27 @@ interface GitExecOptions {
63
63
 
64
64
  export class GitWorktreeError extends Error {
65
65
  readonly args?: readonly string[];
66
-
67
- constructor(message: string, args?: readonly string[]) {
68
- super(message);
66
+ readonly stdout?: string;
67
+ readonly stderr?: string;
68
+
69
+ constructor(message: string, args?: readonly string[], cause?: unknown) {
70
+ super(message, { cause });
71
+ if (cause instanceof GitInputError) {
72
+ this.stdout = cause.stdout;
73
+ this.stderr = cause.stderr;
74
+ }
69
75
  this.name = "GitWorktreeError";
70
76
  this.args = args;
71
77
  }
72
78
  }
79
+ /** A mutation left retained data or an outcome that requires recovery inspection. */
80
+ export class WorktreeRecoveryError extends GitWorktreeError {
81
+ constructor(message: string) {
82
+ super(message);
83
+ this.name = "WorktreeRecoveryError";
84
+ }
85
+ }
86
+
73
87
  class MetadataDeletionRetainedError extends Error {
74
88
  readonly retainedPath: string;
75
89
  readonly outcomeUnknown: boolean;
@@ -376,19 +390,35 @@ async function acquireFilesystemMutationLock(key: string, signal?: AbortSignal):
376
390
  }
377
391
  }
378
392
 
379
- async function withMetadataPruneLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
393
+ async function withMetadataPruneLock<T>(key: string, operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
394
+ signal?.throwIfAborted();
380
395
  const previous = metadataPruneLocks.get(key);
381
396
  let release!: () => void;
382
397
  const current = new Promise<void>((resolveLock) => {
383
398
  release = resolveLock;
384
399
  });
385
400
  metadataPruneLocks.set(key, current);
386
- if (previous) await previous;
401
+ let cancel: (() => void) | undefined;
387
402
  try {
403
+ if (previous) {
404
+ await Promise.race([
405
+ previous,
406
+ new Promise<never>((_resolve, reject) => {
407
+ cancel = () => reject(new GitWorktreeError("worktree mutation lock wait aborted."));
408
+ signal?.addEventListener("abort", cancel, { once: true });
409
+ if (signal?.aborted) cancel();
410
+ }),
411
+ ]);
412
+ }
413
+ signal?.throwIfAborted();
388
414
  return await operation();
389
415
  } finally {
390
- release();
391
- if (metadataPruneLocks.get(key) === current) metadataPruneLocks.delete(key);
416
+ if (cancel) signal?.removeEventListener("abort", cancel);
417
+ // A cancelled queue slot must not let its successors overtake the holder.
418
+ void Promise.resolve(previous).then(() => {
419
+ release();
420
+ if (metadataPruneLocks.get(key) === current) metadataPruneLocks.delete(key);
421
+ });
392
422
  }
393
423
  }
394
424
 
@@ -398,14 +428,19 @@ export function withWorktreeMutationLock<T>(
398
428
  signal?: AbortSignal,
399
429
  ): Promise<T> {
400
430
  const key = worktreeMutationLockKey(cwd);
401
- return withMetadataPruneLock(key, async () => {
402
- const release = await acquireFilesystemMutationLock(key, signal);
403
- try {
404
- return await operation();
405
- } finally {
406
- await release();
407
- }
408
- });
431
+ return withMetadataPruneLock(
432
+ key,
433
+ async () => {
434
+ const release = await acquireFilesystemMutationLock(key, signal);
435
+ try {
436
+ signal?.throwIfAborted();
437
+ return await operation();
438
+ } finally {
439
+ await release();
440
+ }
441
+ },
442
+ signal,
443
+ );
409
444
  }
410
445
 
411
446
  function commonDirectoryForGitdir(gitdir: string): string {
@@ -515,19 +550,36 @@ export async function removeWorktreeMetadata(
515
550
  throw new GitWorktreeError(`Refusing metadata prune because the stale-record preview changed.`);
516
551
  }
517
552
  if (targetAdministrative) {
518
- await removeAdministrativeRecord(targetAdministrative);
553
+ await removeAdministrativeRecord(targetAdministrative, signal);
519
554
  } else {
520
- await runGit(pi, ["worktree", "prune", "--expire", "now"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
521
- }
522
- const after = await listWorktrees(pi, cwd, signal);
523
- if (after.some((record) => pathsEqual(record.path, path))) {
524
- throw new GitWorktreeError(`Git did not remove worktree metadata for ${path}.`);
525
- }
526
- const administrativeAfter = await administrativePruneCandidatesIfPresent(pi, cwd, signal);
527
- if (administrativeAfter.length !== 0) {
528
- throw new GitWorktreeError(`Git left stale administrative records after pruning ${path}.`);
555
+ signal?.throwIfAborted();
556
+ try {
557
+ await runGit(pi, ["worktree", "prune", "--expire", "now"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
558
+ } catch (error) {
559
+ // An interrupted Git process may already have deregistered the tree.
560
+ throw new WorktreeRecoveryError(
561
+ `Worktree metadata removal outcome is unknown for ${path}: ${formatError(error)}`,
562
+ );
563
+ }
529
564
  }
565
+ // Deregistration is the commit point, not the subsequent verification.
566
+ // Never attempt registration rollback after successful metadata deletion.
530
567
  onMetadataRemoved?.();
568
+ try {
569
+ // Recovery verification must finish independently of the retired UI owner.
570
+ const after = await listWorktrees(pi, cwd);
571
+ if (after.some((record) => pathsEqual(record.path, path))) {
572
+ throw new GitWorktreeError(`Git did not remove worktree metadata for ${path}.`);
573
+ }
574
+ const administrativeAfter = await administrativePruneCandidatesIfPresent(pi, cwd);
575
+ if (administrativeAfter.length !== 0) {
576
+ throw new GitWorktreeError(`Git left stale administrative records after pruning ${path}.`);
577
+ }
578
+ } catch (error) {
579
+ throw new WorktreeRecoveryError(
580
+ `Worktree metadata removal completed for ${path}, but verification failed: ${formatError(error)}`,
581
+ );
582
+ }
531
583
  };
532
584
  if (lockHeld) await operation();
533
585
  else await withWorktreeMutationLock(cwd, operation, signal);
@@ -817,7 +869,11 @@ async function removeMetadataTree(path: string, expected: MetadataIdentity): Pro
817
869
  }
818
870
  }
819
871
 
820
- async function removeAdministrativeRecord(candidate: AdministrativePruneCandidate): Promise<void> {
872
+ async function removeAdministrativeRecord(
873
+ candidate: AdministrativePruneCandidate,
874
+ signal?: AbortSignal,
875
+ ): Promise<void> {
876
+ signal?.throwIfAborted();
821
877
  const expected = administrativeIdentities.get(candidate);
822
878
  if (!expected) throw new GitWorktreeError(`Git metadata identity was not captured for ${candidate.id}.`);
823
879
  const source = candidate.administrativePath;
@@ -829,6 +885,7 @@ async function removeAdministrativeRecord(candidate: AdministrativePruneCandidat
829
885
  if (worktreeGitFile && existsSync(worktreeGitFile)) {
830
886
  throw new Error(`worktree ${worktreePath} became valid before metadata removal`);
831
887
  }
888
+ signal?.throwIfAborted();
832
889
  await rename(source, tombstone);
833
890
  moved = true;
834
891
  if (worktreeGitFile && existsSync(worktreeGitFile)) {
@@ -836,6 +893,14 @@ async function removeAdministrativeRecord(candidate: AdministrativePruneCandidat
836
893
  moved = false;
837
894
  throw new Error(`worktree ${worktreePath} became valid while claiming metadata`);
838
895
  }
896
+ if (signal?.aborted) {
897
+ // Claiming metadata is reversible; finish rollback without the cancelled signal.
898
+ if (!existsSync(source)) {
899
+ await rename(tombstone, source);
900
+ moved = false;
901
+ }
902
+ signal.throwIfAborted();
903
+ }
839
904
  await removeMetadataTree(tombstone, expected);
840
905
  } catch (error: unknown) {
841
906
  const retainedPath =
@@ -844,9 +909,9 @@ async function removeAdministrativeRecord(candidate: AdministrativePruneCandidat
844
909
  error instanceof MetadataDeletionRetainedError && error.outcomeUnknown
845
910
  ? " Metadata removal outcome is unknown."
846
911
  : "";
847
- throw new GitWorktreeError(
848
- `Git administrative metadata removal failed for ${candidate.id}${retainedPath ? `; retained at ${retainedPath}` : ""}.${outcomeWarning} ${formatError(error)}`,
849
- );
912
+ const message = `Git administrative metadata removal failed for ${candidate.id}${retainedPath ? `; retained at ${retainedPath}` : ""}.${outcomeWarning} ${formatError(error)}`;
913
+ if (retainedPath || outcomeWarning) throw new WorktreeRecoveryError(message);
914
+ throw new GitWorktreeError(message);
850
915
  }
851
916
  }
852
917
  function sameAdministrativeCandidate(left: AdministrativePruneCandidate, right: AdministrativePruneCandidate): boolean {
@@ -959,7 +1024,7 @@ export async function pruneWorktrees(
959
1024
  const result = await runGit(pi, ["worktree", "prune", "--verbose"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
960
1025
  return combineOutput(result);
961
1026
  }
962
- for (const candidate of candidates) await removeAdministrativeRecord(candidate);
1027
+ for (const candidate of candidates) await removeAdministrativeRecord(candidate, signal);
963
1028
  return candidates.map((candidate) => `Removed ${candidate.id}`).join("\n");
964
1029
  };
965
1030
  return lockHeld ? operation() : withWorktreeMutationLock(cwd, operation, signal);
@@ -1050,7 +1115,10 @@ async function runGitAllowFailure(
1050
1115
  timeout = GIT_TIMEOUT_MS,
1051
1116
  ): Promise<ExecResult> {
1052
1117
  try {
1053
- return await pi.exec("git", args, { cwd, signal, timeout });
1118
+ signal?.throwIfAborted();
1119
+ const result = await pi.exec("git", args, { cwd, signal, timeout });
1120
+ signal?.throwIfAborted();
1121
+ return result;
1054
1122
  } catch (error) {
1055
1123
  const message = formatError(error);
1056
1124
  if (/\bENOENT\b|not found/i.test(message)) {
@@ -1159,65 +1227,13 @@ function runGitWithInputAllowFailure(
1159
1227
  if (signal?.aborted) {
1160
1228
  return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true });
1161
1229
  }
1162
- return new Promise((resolveResult, reject) => {
1163
- // ExtensionAPI.exec has no stdin channel, so this read-only check uses an argv-only child.
1164
- const child = spawn("git", args, {
1165
- cwd,
1166
- stdio: ["pipe", "pipe", "pipe"],
1167
- windowsHide: true,
1168
- });
1169
- let stdout = "";
1170
- let stderr = "";
1171
- let killed = false;
1172
- let settled = false;
1173
- const finish = (result: ExecResult) => {
1174
- if (settled) return;
1175
- settled = true;
1176
- clearTimeout(timeoutHandle);
1177
- signal?.removeEventListener("abort", stop);
1178
- resolveResult(result);
1179
- };
1180
- const fail = (error: unknown) => {
1181
- if (settled) return;
1182
- settled = true;
1183
- clearTimeout(timeoutHandle);
1184
- signal?.removeEventListener("abort", stop);
1185
- child.kill();
1186
- const message = formatError(error);
1187
- reject(
1188
- /\bENOENT\b|not found/i.test(message)
1189
- ? new GitWorktreeError("Git executable was not found. Install Git and retry.", args)
1190
- : new GitWorktreeError(`Could not start git ${args.slice(0, 2).join(" ")}: ${message}`, args),
1191
- );
1192
- };
1193
- const stop = () => {
1194
- killed = true;
1195
- child.kill();
1196
- };
1197
- const timeoutHandle = setTimeout(stop, timeout);
1198
- child.stdout.setEncoding("utf8");
1199
- child.stderr.setEncoding("utf8");
1200
- child.stdout.on("data", (chunk: string) => {
1201
- stdout += chunk;
1202
- });
1203
- child.stderr.on("data", (chunk: string) => {
1204
- stderr += chunk;
1205
- });
1206
- child.stdin.on("error", (error) => {
1207
- if (!isNodeError(error) || error.code !== "EPIPE") fail(error);
1208
- });
1209
- child.once("error", fail);
1210
- child.once("close", (code, closeSignal) => {
1211
- finish({
1212
- stdout,
1213
- stderr,
1214
- code: code ?? 1,
1215
- killed: killed || closeSignal !== null,
1216
- });
1217
- });
1218
- signal?.addEventListener("abort", stop, { once: true });
1219
- if (signal?.aborted) stop();
1220
- child.stdin.end(input);
1230
+ // ExtensionAPI.exec has no stdin channel. This local argv-only owner bounds
1231
+ // both cancellation and timeout cleanup without changing native Git settings.
1232
+ return runGitInput(args, cwd, input, signal, timeout).catch((error: unknown) => {
1233
+ const message = formatError(error);
1234
+ throw /\bENOENT\b|not found/i.test(message)
1235
+ ? new GitWorktreeError("Git executable was not found. Install Git and retry.", args, error)
1236
+ : new GitWorktreeError(`git ${args.slice(0, 2).join(" ")}: ${message}`, args, error);
1221
1237
  });
1222
1238
  }
1223
1239
 
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { lstat, readdir, rename, rmdir, unlink, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import { moveWorktree, removeWorktreeMetadata, withWorktreeMutationLock } from "./git.js";
5
+ import { moveWorktree, removeWorktreeMetadata, WorktreeRecoveryError, withWorktreeMutationLock } from "./git.js";
6
6
 
7
7
  interface TreeSnapshot {
8
8
  kind: "directory" | "leaf";
@@ -265,12 +265,14 @@ export async function removeWorktreeSafely(
265
265
  if (signal?.aborted) throw new Error("worktree removal aborted");
266
266
  const quarantinePath = join(dirname(path), `.${randomUUID()}.pi-worktree-quarantine`);
267
267
  let metadataRemoved = false;
268
+ let metadataOutcomeUnknown = false;
268
269
  let moved = false;
269
270
  let tombstonePath: string | undefined;
270
271
  let moveOutcomeUnknown = false;
271
272
  let quarantineReservation: TreeSnapshot | undefined;
272
273
  try {
273
274
  await validateRegisteredWorktree?.();
275
+ signal?.throwIfAborted();
274
276
  const beforeSnapshot = await snapshotTree(path);
275
277
  await moveWorktree(pi, cwd, path, quarantinePath, signal);
276
278
  moved = true;
@@ -279,6 +281,7 @@ export async function removeWorktreeSafely(
279
281
  throw new Error("worktree changed while entering quarantine");
280
282
  }
281
283
  await validateQuarantine(quarantinePath);
284
+ signal?.throwIfAborted();
282
285
 
283
286
  // Keep the real tree on a guarded tombstone. The registered path is reserved
284
287
  // with an exclusive non-directory entry so Git never receives an unprotected
@@ -290,16 +293,22 @@ export async function removeWorktreeSafely(
290
293
  if (!sameIdentity(reservedPath, quarantineReservation)) {
291
294
  throw new QuarantineRetainedError(quarantinePath, `quarantine reservation changed: ${quarantinePath}`);
292
295
  }
293
- await removeWorktreeMetadata(
294
- pi,
295
- cwd,
296
- quarantinePath,
297
- signal,
298
- () => {
299
- metadataRemoved = true;
300
- },
301
- true,
302
- );
296
+ try {
297
+ await removeWorktreeMetadata(
298
+ pi,
299
+ cwd,
300
+ quarantinePath,
301
+ signal,
302
+ () => {
303
+ metadataRemoved = true;
304
+ },
305
+ true,
306
+ );
307
+ } catch (error) {
308
+ // Partial metadata deletion cannot be rolled back by moving the tree.
309
+ metadataOutcomeUnknown = !metadataRemoved && error instanceof WorktreeRecoveryError;
310
+ throw error;
311
+ }
303
312
  await releaseQuarantineReservation(quarantinePath, quarantineReservation);
304
313
  quarantineReservation = undefined;
305
314
  if (!tombstonePath || !(await pathExists(tombstonePath))) {
@@ -309,6 +318,9 @@ export async function removeWorktreeSafely(
309
318
  true,
310
319
  );
311
320
  }
321
+ // Once metadata is committed, cancellation retains the recovery tree
322
+ // rather than beginning a fresh filesystem deletion.
323
+ signal?.throwIfAborted();
312
324
  await removeSnapshot(tombstonePath, snapshot, beforeDeleteEntry);
313
325
  } catch (error: unknown) {
314
326
  if (!tombstonePath && !metadataRemoved) {
@@ -332,11 +344,11 @@ export async function removeWorktreeSafely(
332
344
  }
333
345
  const primaryError = reservationError ?? error;
334
346
  if (moveOutcomeUnknown) {
335
- throw new Error(
347
+ throw new WorktreeRecoveryError(
336
348
  `Git worktree move outcome is unknown; inspect ${path} and ${quarantinePath} before retrying: ${errorDetail(primaryError)}`,
337
349
  );
338
350
  }
339
- if (moved && !metadataRemoved) {
351
+ if (moved && !metadataRemoved && !metadataOutcomeUnknown) {
340
352
  let restoreError: unknown = reservationError;
341
353
  let restored = false;
342
354
  if (!reservationError) {
@@ -361,16 +373,19 @@ export async function removeWorktreeSafely(
361
373
  : tombstonePath && (await pathExists(tombstonePath))
362
374
  ? tombstonePath
363
375
  : quarantinePath;
364
- throw new Error(
376
+ throw new WorktreeRecoveryError(
365
377
  `Git worktree removal failed: ${errorDetail(primaryError)}; quarantine retained at ${retainedPath}${
366
378
  restoreError ? ` (${errorDetail(restoreError)})` : ". The original path was recreated."
367
379
  }`,
368
380
  );
369
381
  }
370
- if (metadataRemoved && tombstonePath) {
382
+ if ((metadataRemoved || metadataOutcomeUnknown) && tombstonePath) {
383
+ const metadataStatus = metadataRemoved
384
+ ? "Worktree metadata was removed"
385
+ : "Worktree metadata outcome is unknown";
371
386
  if (primaryError instanceof QuarantineRetainedError && primaryError.outcomeUnknown) {
372
- throw new Error(
373
- `Worktree metadata was removed, but quarantine outcome is unknown: ${errorDetail(primaryError)}`,
387
+ throw new WorktreeRecoveryError(
388
+ `${metadataStatus}, but quarantine outcome is unknown: ${errorDetail(primaryError)}`,
374
389
  );
375
390
  }
376
391
  const retainedPath =
@@ -380,8 +395,8 @@ export async function removeWorktreeSafely(
380
395
  ? tombstonePath
381
396
  : undefined;
382
397
  if (retainedPath) {
383
- throw new Error(
384
- `Worktree metadata was removed, but quarantine was retained at ${retainedPath}: ${errorDetail(primaryError)}`,
398
+ throw new WorktreeRecoveryError(
399
+ `${metadataStatus}, but quarantine was retained at ${retainedPath}: ${errorDetail(primaryError)}`,
385
400
  );
386
401
  }
387
402
  }
package/src/session.ts CHANGED
@@ -12,7 +12,9 @@ export async function switchToWorktree(
12
12
  ): Promise<WorktreeSwitchResult> {
13
13
  let sessionPath: string | undefined;
14
14
  try {
15
+ ctx.signal?.throwIfAborted();
15
16
  sessionPath = createTargetSession(ctx, targetPath);
17
+ ctx.signal?.throwIfAborted();
16
18
  const result = await ctx.switchSession(sessionPath, {
17
19
  withSession: async (replacementCtx) => {
18
20
  replacementCtx.ui.notify(stripTerminalControls(`Switched Pi workspace to ${targetPath}.`), "info");
package/src/settings.ts CHANGED
@@ -33,8 +33,8 @@ export interface SettingsFileOperations {
33
33
  export interface WorktreeSettingsRuntime {
34
34
  get(): Readonly<WorktreeSettingsState>;
35
35
  getPath(): string;
36
- reload(): Promise<Readonly<WorktreeSettingsState>>;
37
- save(configuredRoot: string | undefined): Promise<Readonly<WorktreeSettingsState>>;
36
+ reload(signal?: AbortSignal): Promise<Readonly<WorktreeSettingsState>>;
37
+ save(configuredRoot: string | undefined, signal?: AbortSignal): Promise<Readonly<WorktreeSettingsState>>;
38
38
  flush?(): Promise<void>;
39
39
  }
40
40
 
@@ -153,18 +153,22 @@ export async function saveWorktreeSettings(
153
153
  configuredRoot: string | undefined,
154
154
  path = settingsFilePath(),
155
155
  operations: Partial<SettingsFileOperations> = {},
156
+ signal?: AbortSignal,
156
157
  ): Promise<Record<string, unknown>> {
158
+ signal?.throwIfAborted();
157
159
  const nextDocument = { ...document };
158
160
  if (configuredRoot === undefined) delete nextDocument.worktreeRoot;
159
161
  else nextDocument.worktreeRoot = configuredRoot;
160
162
 
161
163
  await mkdir(dirname(path), { recursive: true });
164
+ signal?.throwIfAborted();
162
165
  const temporaryPath = temporaryFilePath(path);
163
166
  try {
164
167
  await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
165
168
  temporaryPath,
166
169
  `${JSON.stringify(nextDocument, null, 2)}\n`,
167
170
  );
171
+ signal?.throwIfAborted();
168
172
  await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(temporaryPath, path);
169
173
  return nextDocument;
170
174
  } catch (error) {
@@ -202,9 +206,11 @@ export function createWorktreeSettingsRuntime(options: RuntimeOptions = {}): Wor
202
206
  async flush() {
203
207
  await operationQueue;
204
208
  },
205
- reload() {
209
+ reload(signal) {
206
210
  return enqueue(async () => {
211
+ signal?.throwIfAborted();
207
212
  const loaded = await loadWorktreeSettings(getPath(), home, platform);
213
+ signal?.throwIfAborted();
208
214
  if (loaded.kind === "invalid") {
209
215
  state = { ...state, warning: loaded.warning, canSave: false };
210
216
  return Object.freeze({ ...state });
@@ -213,8 +219,9 @@ export function createWorktreeSettingsRuntime(options: RuntimeOptions = {}): Wor
213
219
  return Object.freeze({ ...state });
214
220
  });
215
221
  },
216
- save(configuredRoot) {
222
+ save(configuredRoot, signal) {
217
223
  return enqueue(async () => {
224
+ signal?.throwIfAborted();
218
225
  if (!state.canSave) {
219
226
  throw new Error(`Fix the pi-worktree settings file at ${getPath()} before changing it.`);
220
227
  }
@@ -223,11 +230,14 @@ export function createWorktreeSettingsRuntime(options: RuntimeOptions = {}): Wor
223
230
  ? defaultWorktreeRoot(home, platform)
224
231
  : resolveWorktreeRoot(configuredRoot, home, platform);
225
232
  const latest = await loadWorktreeSettings(getPath(), home, platform);
233
+ signal?.throwIfAborted();
226
234
  if (latest.kind === "invalid") {
227
235
  state = { ...state, warning: latest.warning, canSave: false };
228
236
  throw new Error(`Fix the pi-worktree settings file at ${getPath()} before changing it.`);
229
237
  }
230
- await saveWorktreeSettings(latest.document ?? {}, configuredRoot, getPath(), options.operations);
238
+ await saveWorktreeSettings(latest.document ?? {}, configuredRoot, getPath(), options.operations, signal);
239
+ // Successful atomic publication commits the save, even if its UI owner
240
+ // retired while rename was pending. Keep same-session consumers in sync.
231
241
  state = {
232
242
  effectiveRoot,
233
243
  source: configuredRoot === undefined ? "default" : "user",
package/src/worktree.ts CHANGED
@@ -18,6 +18,15 @@ export default function worktreeExtension(pi: ExtensionAPI, options: WorktreeExt
18
18
  };
19
19
  });
20
20
 
21
+ const closeCommands = () => {
22
+ sessionGeneration += 1;
23
+ menuController.abort(new DOMException("Worktree navigation started", "AbortError"));
24
+ menuController = new AbortController();
25
+ };
26
+ pi.on("session_before_switch", () => closeCommands());
27
+ pi.on("session_before_tree", () => closeCommands());
28
+ pi.on("session_tree", () => closeCommands());
29
+
21
30
  pi.on("session_start", async (_event, ctx) => {
22
31
  const generation = ++sessionGeneration;
23
32
  menuController.abort(new DOMException("Worktree session replaced", "AbortError"));