@maxiaochao/pi-toolkit 0.13.13 → 0.13.15

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,3 +1,12 @@
1
+ ## 2026-10-27 - v0.13.15
2
+
3
+ - worktree v0.4.2: support removing multiple worktrees in order
4
+ - Allow bundled pi-worktree to remove multiple worktrees in one command.
5
+
6
+ ## 2026-10-26 - v0.13.14
7
+
8
+ - herdr-subagents v0.7.6: Esc in a child waits abortGraceSeconds (default 30) for a correction before publishing child_aborted
9
+
1
10
  ## 2026-10-26 - v0.13.13
2
11
 
3
12
  - background-tasks v0.1.2: restrict background tasks to model-only and use concise automatic-notification guidance
package/README.md CHANGED
@@ -108,4 +108,4 @@ The read-only check verifies remote tag commits, waits for the matching `publish
108
108
 
109
109
  ## Safety and evidence boundaries
110
110
 
111
- Cache Export reports provider/Pi usage, not measured server cache state. Main requests and auxiliary calls are separate; missing reasoning or retention remains unknown. Relative paths use the session cwd. Herdr children get at most one missing-report reminder and report unconfirmed outcomes explicitly. Codex Edit preflights path hierarchy conflicts and preserves executable bits on moves. Account-pool transient refresh errors never justify rotation, and delivered output forbids replay. Worktree reuse always returns the worktree root. Browser profile reset requires Chrome to be stopped.
111
+ Cache Export reports provider/Pi usage, not measured server cache state. Main requests and auxiliary calls are separate; missing reasoning or retention remains unknown. Relative paths use the session cwd. Herdr children get at most one missing-report reminder and report unconfirmed outcomes explicitly. Codex Edit preflights path hierarchy conflicts and preserves executable bits on moves. Account-pool transient refresh errors never justify rotation, and delivered output forbids replay. Worktree reuse always returns the worktree root; `pi-worktree remove` accepts multiple names. Browser profile reset requires Chrome to be stopped.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.13.13",
3
+ "version": "0.13.15",
4
4
  "description": "Personal pi coding agent toolkit: background tasks, scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -82,7 +82,7 @@ There is no global dispatcher, persisted background queue, blocked response lane
82
82
 
83
83
  Each task owns one Herdr pane and one single-task Pi process. Herdr first starts and detects the interactive Pi, then submits the prompt exactly once. At Pi’s final `agent_settled` boundary, the child extension records settled state but leaves the interactive process alive. The atomic report remains the authoritative task result. Early-finishing children remain visible while sibling tasks continue. Once the whole run settles, the orchestrator closes an unfocused owned tab immediately and defers a focused tab until the user leaves it, so Pi can restore the terminal normally.
84
84
 
85
- A completed round without a terminal report gets one child-side reminder at `agent_before_settle`. The marker is written before continuation. Another omission publishes an unconfirmed `missing_report` diagnostic; final errors use `child_error`. Native cancellation skips that boundary, so `agent_settled` publishes only `child_aborted`, never a model wake-up. Existing terminal reports are not replaced. None of this adds a parent monitoring loop.
85
+ A completed round without a terminal report gets one child-side reminder at `agent_before_settle`. The marker is written before continuation. Another omission publishes an unconfirmed `missing_report` diagnostic; final errors use `child_error`. A user abort (Esc) skips that boundary. `agent_settled(aborted)` starts a grace timer (`abortGraceSeconds`, default 30); a new round (`before_agent_start`) or session shutdown clears it, otherwise it publishes `child_aborted`. Existing terminal reports are not replaced. None of this adds a parent monitoring loop.
86
86
 
87
87
  The child writes:
88
88
 
@@ -1,3 +1,7 @@
1
+ ## 2026-10-26 - v0.7.6
2
+
3
+ - Esc in a child waits abortGraceSeconds (default 30) for a correction before publishing child_aborted
4
+
1
5
  ## 2026-10-23 - v0.7.5
2
6
 
3
7
  - bound missing-report recovery and close unconfirmed child outcomes
@@ -175,6 +175,7 @@ These are all supported top-level fields in `herdr-subagents.json`:
175
175
  | `defaultConcurrency` | Positive integer | `1` | Concurrency used when a call omits `concurrency`; capped to `maxConcurrency`. |
176
176
  | `maxConcurrency` | Positive integer | `4` | Hard ceiling for requested/default read-only concurrency. It does not make write-capable batches safe. |
177
177
  | `stalledWarningSeconds` | Positive integer | `300` | Emit a warning after a child runs this long. This is not a timeout and does not stop it. |
178
+ | `abortGraceSeconds` | Positive integer | `30` | After the user aborts a child with Esc, wait this long for a correction. If no new round starts, publish an unconfirmed `child_aborted` failure. |
178
179
  | `defaultModel` | Non-empty Pi model ID, normally `provider/model` | Unset | Fallback child model. If unset, the parent model is inherited. |
179
180
  | `defaultThinking` | Non-empty Pi thinking value | Unset | Fallback thinking level below Persona-specific settings. Typical Pi values are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`; model support varies. |
180
181
  | `defaultExtensions` | Array of non-empty Pi source strings | Unset (no optional extensions) | Fallback extension sources for Personas without an explicit `extensions` list. Project replaces global; `[]` clears inherited defaults. |
@@ -219,6 +220,7 @@ Save this as `~/.pi/agent/herdr-subagents.json`. Model IDs, skills, and extensio
219
220
  "defaultConcurrency": 2,
220
221
  "maxConcurrency": 4,
221
222
  "stalledWarningSeconds": 300,
223
+ "abortGraceSeconds": 30,
222
224
  "defaultModel": "anthropic/claude-sonnet-4-5",
223
225
  "defaultThinking": "medium",
224
226
  "defaultExtensions": ["~/.pi/agent/extensions/provider-routing.ts"],
@@ -354,6 +356,6 @@ See [TESTING.md](TESTING.md) for deterministic tests, extension loading, packagi
354
356
 
355
357
  The child checks `reports/<attempt-id>.json` at `agent_before_settle`. A valid completed or failed report adds zero requests. A normally completed round without a report receives one reminder to submit existing results, not redo the task. `attempts/<attempt-id>.reminded` is persisted before continuing; rounds and reloads cannot reset it.
356
358
 
357
- A second omission produces a `failed` protocol report with `error: missing_report`, an explicitly **unconfirmed** result, and the child session path. This does not assert the delegated work itself failed. Final provider errors produce `child_error` without a reminder. Pi skips before-settle on cancellation; the child writes `child_aborted` at `agent_settled`, without waking the model or replacing an existing report. Parent-requested cancellation retains its existing cancelled lifecycle.
359
+ A second omission produces a `failed` protocol report with `error: missing_report`, an explicitly **unconfirmed** result, and the child session path. This does not assert the delegated work itself failed. Final provider errors produce `child_error` without a reminder. A user abort (Esc) skips before-settle. The child waits `abortGraceSeconds` (default 30) for a correction; a new round cancels the timer and the child continues. If nothing starts in time, it publishes an unconfirmed `child_aborted` failure. Parent-requested cancellation retains its existing cancelled lifecycle.
358
360
 
359
361
  The parent still waits for an atomic attempt report or process exit. It does not infer success from idle/settled or run a second reminder monitor. Report idempotency and focus-aware tab cleanup are unchanged.
@@ -60,9 +60,9 @@ For real smoke testing, verify that each child appears in a visible pane and can
60
60
 
61
61
  ## Missing-report regressions
62
62
 
63
- `tests/report-boundary.mjs` checks completed/failed report idempotency, one durable reminder across rounds and extension reload, conflicting report rejection, unconfirmed diagnostics with a session path, and cancellation/error without continuation.
63
+ `tests/report-boundary.mjs` checks completed/failed report idempotency, one durable reminder across rounds and extension reload, conflicting report rejection, unconfirmed diagnostics with a session path, Esc grace period (timer cancelled by steering or shutdown, `child_aborted` after expiry), and error without continuation.
64
64
 
65
- `tests/report-lifecycle.mjs` uses the real installed Pi SDK and a fully offline provider. It covers ten cases: normal report, failed report, successful reminder, repeated omission, transient retry, exhausted retry, queued follow-up, another before-settle continuation, cancellation, and a fresh prompt from a settled listener. A normal report makes one request; a reminder adds at most one. Native cancellation does not emit before-settle and must still get a terminal diagnostic. These are native lifecycle checks, not a claim of real-Herdr pane/concurrency coverage.
65
+ `tests/report-lifecycle.mjs` uses the real installed Pi SDK and a fully offline provider. It covers ten cases: normal report, failed report, successful reminder, repeated omission, transient retry, exhausted retry, queued follow-up, another before-settle continuation, Esc abort, and a fresh prompt from a settled listener. A normal report makes one request; a reminder adds at most one. Native abort does not emit before-settle; its terminal diagnostic appears only after `abortGraceSeconds` (set to 1 in the test). These are native lifecycle checks, not a claim of real-Herdr pane/concurrency coverage.
66
66
 
67
67
  Use `PI_TEST_BINARY` to select Pi 1.4.0 for these checks rather than an older workspace-local Pi. No paid model or real credential is used by the new regression fixtures.
68
68
 
@@ -89,7 +89,7 @@ async function handleHerdrCommand(args: string, ctx: ExtensionCommandContext, fl
89
89
  }
90
90
  if (section === "settings") {
91
91
  const settings = configuration.settings;
92
- const values: Record<string, string | number | undefined> = { defaultConcurrency: settings.defaultConcurrency, maxConcurrency: settings.maxConcurrency, stalledWarningSeconds: settings.stalledWarningSeconds, defaultModel: settings.defaultModel, defaultThinking: settings.defaultThinking, defaultExtensions: JSON.stringify(settings.defaultExtensions ?? []) };
92
+ const values: Record<string, string | number | undefined> = { defaultConcurrency: settings.defaultConcurrency, maxConcurrency: settings.maxConcurrency, stalledWarningSeconds: settings.stalledWarningSeconds, abortGraceSeconds: settings.abortGraceSeconds, defaultModel: settings.defaultModel, defaultThinking: settings.defaultThinking, defaultExtensions: JSON.stringify(settings.defaultExtensions ?? []) };
93
93
  const lines = [`exposure=${registeredExposure.value ?? "Pi default"} (${registeredExposure.source}; loaded at extension registration)`, ...Object.entries(values).map(([key, value]) => `${key}=${String(value ?? "unset")} (${configuration.settingSources[key] ?? "built-in"})`)];
94
94
  if (configuration.diagnostics.length) lines.push("Diagnostics:", ...configuration.diagnostics);
95
95
  ctx.ui.notify(lines.join("\n"), "info");
@@ -9,6 +9,10 @@ import { parseReport } from "../src/records.ts";
9
9
  import { resultPathForAttemptId } from "../src/result-path.ts";
10
10
  import { PARENT_PANE_ENV } from "../src/live-agent.ts";
11
11
  import { attemptReportPath, attemptStatePath, requireAttemptId, type AttemptState } from "../src/attempts.ts";
12
+ import { loadSubagentConfiguration } from "../src/config.ts";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const packageAgentsDir = fileURLToPath(new URL("../agents/", import.meta.url));
12
16
 
13
17
  const RETURN_SHORTCUT = "alt+p";
14
18
 
@@ -108,7 +112,10 @@ function reportHerdrState(state: "working" | "blocked" | "idle", message?: strin
108
112
  export default function (pi: ExtensionAPI) {
109
113
  let activeAttemptId: string | undefined;
110
114
  let blockedCount = 0;
115
+ let abortTimer: ReturnType<typeof setTimeout> | undefined;
116
+ const clearAbortTimer = () => { if (abortTimer) clearTimeout(abortTimer); abortTimer = undefined; };
111
117
  pi.on("before_agent_start", async () => {
118
+ clearAbortTimer(); // The user steered the child after Esc.
112
119
  const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
113
120
  if (!taskDirectory) return;
114
121
  activeAttemptId = currentAttemptId(taskDirectory);
@@ -144,19 +151,25 @@ export default function (pi: ExtensionAPI) {
144
151
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
145
152
  }
146
153
  }
147
- const reason = event.outcome === "completed" ? "missing_report" : event.outcome === "aborted" ? "child_aborted" : "child_error";
154
+ // Esc normally skips this boundary; if it lands mid-boundary, still let the user steer.
155
+ if (event.outcome === "aborted") return;
156
+ const reason = event.outcome === "completed" ? "missing_report" : "child_error";
148
157
  saveUnconfirmed(taskDirectory, attemptId, reason, ctx.sessionManager.getSessionFile());
149
- // Cancellation and final provider errors receive a diagnostic, never a wake-up.
158
+ // Final provider errors receive a diagnostic, never a wake-up.
150
159
  });
151
160
  pi.on("agent_settled", (event, ctx) => {
152
161
  const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
153
- if (taskDirectory && activeAttemptId) {
154
- // Pi skips before-settle on cancellation. Close the protocol without
155
- // claiming the work failed, replacing a report, or requesting more tokens.
156
- if (event.aborted && !existsSync(attemptReportPath(taskDirectory, activeAttemptId))) {
157
- saveUnconfirmed(taskDirectory, activeAttemptId, "child_aborted", ctx.sessionManager.getSessionFile());
158
- }
159
- writeAttemptState(taskDirectory, activeAttemptId, "settled");
162
+ if (event.aborted && taskDirectory && activeAttemptId) {
163
+ // Esc skips before-settle. Give the user time to steer; if nothing starts a new
164
+ // round within the grace period, close the attempt with an unconfirmed diagnostic.
165
+ const attemptId = activeAttemptId;
166
+ const sessionFile = ctx.sessionManager.getSessionFile();
167
+ const graceMs = loadSubagentConfiguration(ctx.cwd, packageAgentsDir).settings.abortGraceSeconds * 1000;
168
+ clearAbortTimer();
169
+ abortTimer = setTimeout(() => {
170
+ abortTimer = undefined;
171
+ if (!existsSync(attemptReportPath(taskDirectory, attemptId))) saveUnconfirmed(taskDirectory, attemptId, "child_aborted", sessionFile);
172
+ }, graceMs);
160
173
  }
161
174
  // Keep the interactive child alive at Pi's final automatic-work boundary.
162
175
  // The orchestrator settles each task from its atomic report. Once every task in
@@ -176,6 +189,7 @@ export default function (pi: ExtensionAPI) {
176
189
  description: "Return to parent Agent",
177
190
  async handler(ctx) { await returnToParent(pi, ctx); },
178
191
  });
192
+ pi.on("session_shutdown", clearAbortTimer);
179
193
  pi.on("session_start", async (_event, ctx) => {
180
194
  // Remove the legacy hint on reload without touching editor/plugin status.
181
195
  ctx.ui.setStatus("herdr-subagents-parent", undefined);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -11,6 +11,7 @@ export interface SubagentConfig {
11
11
  defaultConcurrency: number;
12
12
  maxConcurrency: number;
13
13
  stalledWarningSeconds: number;
14
+ abortGraceSeconds: number;
14
15
  defaultModel?: string;
15
16
  defaultThinking?: string;
16
17
  defaultExtensions?: string[];
@@ -39,6 +40,7 @@ interface ConfigFile {
39
40
  defaultConcurrency?: number;
40
41
  maxConcurrency?: number;
41
42
  stalledWarningSeconds?: number;
43
+ abortGraceSeconds?: number;
42
44
  defaultModel?: string;
43
45
  defaultThinking?: string;
44
46
  defaultExtensions?: string[];
@@ -50,7 +52,7 @@ export interface ConfigurationPaths {
50
52
  globalAgents?: string;
51
53
  projectAgents?: string;
52
54
  }
53
- const DEFAULTS: SubagentConfig = { defaultConcurrency: 1, maxConcurrency: 4, stalledWarningSeconds: 300, personas: {} };
55
+ const DEFAULTS: SubagentConfig = { defaultConcurrency: 1, maxConcurrency: 4, stalledWarningSeconds: 300, abortGraceSeconds: 30, personas: {} };
54
56
 
55
57
  function readConfig(path: string, source: string, diagnostics: string[]): ConfigFile {
56
58
  if (!existsSync(path)) return {};
@@ -70,7 +72,7 @@ function mergeConfig(base: SubagentConfig, input: ConfigFile, source: string, so
70
72
  if (input.exposure === "direct" || input.exposure === "model-only") { result.exposure = input.exposure; sources.exposure = source; }
71
73
  else diagnostics.push(`${source} exposure must be 'direct' or 'model-only'`);
72
74
  }
73
- for (const key of ["defaultConcurrency", "maxConcurrency", "stalledWarningSeconds"] as const) {
75
+ for (const key of ["defaultConcurrency", "maxConcurrency", "stalledWarningSeconds", "abortGraceSeconds"] as const) {
74
76
  const value = input[key];
75
77
  if (value === undefined) continue;
76
78
  if (!Number.isInteger(value) || Number(value) <= 0) { diagnostics.push(`${source} ${key} must be a positive integer`); continue; }
@@ -121,7 +123,7 @@ export function loadSubagentConfiguration(cwd: string, packageAgentsDirectory: s
121
123
  const projectConfigPath = paths.projectConfig ?? join(cwd, ".pi", "herdr-subagents.json");
122
124
  const globalAgentsDirectory = paths.globalAgents ?? join(dirname(globalConfigPath), "herdr-subagents", "agents");
123
125
  const projectAgentsDirectory = paths.projectAgents ?? join(cwd, ".pi", "herdr-subagents", "agents");
124
- const settingSources: Record<string, string> = { defaultConcurrency: "built-in", maxConcurrency: "built-in", stalledWarningSeconds: "built-in" };
126
+ const settingSources: Record<string, string> = { defaultConcurrency: "built-in", maxConcurrency: "built-in", stalledWarningSeconds: "built-in", abortGraceSeconds: "built-in" };
125
127
  const globalInput = readConfig(globalConfigPath, "global", diagnostics);
126
128
  const projectInput = readConfig(projectConfigPath, "project", diagnostics);
127
129
  const globalSettings = mergeConfig(DEFAULTS, globalInput, "global", settingSources, diagnostics);
@@ -357,8 +357,6 @@ assert.throws(() => applyTaskStatus(terminal, "running"), /terminal tasks are im
357
357
  process.exit = originalExit;
358
358
  }
359
359
  assert.equal(exitCode, undefined, "settled child must remain alive for focus-aware tab cleanup");
360
- const attempt = JSON.parse(readFileSync(join(directory, "attempts", `${attemptId}.json`), "utf8"));
361
- assert.equal(attempt.state, "settled");
362
360
  const theme = { fg: (_color, text) => text, bold: (text) => text };
363
361
  const renderedCall = reportTool.renderCall({ ...report, result: "FULL RESULT MUST NOT RENDER" }, theme).render(120).join("\n");
364
362
  assert.match(renderedCall, /completed.*saved to disk/);
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { mkdirSync } from "node:fs";
6
7
  import registerReport from "../extensions/subagent-report.ts";
7
8
 
8
9
  const root = mkdtempSync(join(tmpdir(), "herdr-report-boundary-"));
@@ -10,7 +11,9 @@ const previous = process.env.PI_HERDR_SUBAGENTS_TASK_DIR;
10
11
  process.env.PI_HERDR_SUBAGENTS_TASK_DIR = root;
11
12
  const sessionFile = join(root, "session.jsonl");
12
13
  writeFileSync(sessionFile, "");
13
- const ctx = { sessionManager: { getSessionFile: () => sessionFile }, isIdle: () => true };
14
+ mkdirSync(join(root, ".pi"));
15
+ writeFileSync(join(root, ".pi", "herdr-subagents.json"), JSON.stringify({ abortGraceSeconds: 1 }));
16
+ const ctx = { cwd: root, sessionManager: { getSessionFile: () => sessionFile }, isIdle: () => true };
14
17
  function fixture() {
15
18
  const handlers = new Map();
16
19
  let tool;
@@ -58,14 +61,44 @@ try {
58
61
  assert.ok(diagnostic.documents.some(({ path }) => path === sessionFile));
59
62
  assert.equal(await reloaded.handlers.get("agent_before_settle")(boundary(), ctx), undefined);
60
63
 
61
- for (const outcome of ["aborted", "error"]) {
64
+ {
62
65
  const current = attempt();
63
66
  const run = fixture();
64
67
  await run.handlers.get("before_agent_start")();
65
- assert.equal(await run.handlers.get("agent_before_settle")(boundary(outcome), ctx), undefined, `${outcome}: do not wake or retry the child`);
68
+ assert.equal(await run.handlers.get("agent_before_settle")(boundary("aborted"), ctx), undefined);
69
+ assert.equal(existsSync(current.report), false, "Esc abort must not publish a terminal report; the user may steer");
70
+ }
71
+ {
72
+ // Esc skips before-settle and only emits agent_settled(aborted). Grace period, configurable.
73
+ const current = attempt();
74
+ const run = fixture();
75
+ await run.handlers.get("before_agent_start")();
76
+ await run.handlers.get("agent_settled")({ aborted: true }, ctx);
77
+ assert.equal(existsSync(current.report), false, "no report inside the grace period");
78
+ await run.handlers.get("before_agent_start")(); // user steers: timer must be cancelled
79
+ await new Promise((resolve) => setTimeout(resolve, 1300));
80
+ assert.equal(existsSync(current.report), false, "steering within the grace period cancels the diagnostic");
81
+ await run.handlers.get("agent_settled")({ aborted: true }, ctx);
82
+ await new Promise((resolve) => setTimeout(resolve, 1300));
83
+ const report = JSON.parse(readFileSync(current.report, "utf8"));
84
+ assert.equal(report.error, "child_aborted");
85
+ assert.match(report.summary, /unconfirmed/);
86
+ const shutdown = fixture(); // shutdown must clear a pending timer
87
+ const other = attempt();
88
+ await shutdown.handlers.get("before_agent_start")();
89
+ await shutdown.handlers.get("agent_settled")({ aborted: true }, ctx);
90
+ shutdown.handlers.get("session_shutdown")();
91
+ await new Promise((resolve) => setTimeout(resolve, 1300));
92
+ assert.equal(existsSync(other.report), false, "session shutdown clears the grace timer");
93
+ }
94
+ {
95
+ const current = attempt();
96
+ const run = fixture();
97
+ await run.handlers.get("before_agent_start")();
98
+ assert.equal(await run.handlers.get("agent_before_settle")(boundary("error"), ctx), undefined, "error: do not wake or retry the child");
66
99
  const report = JSON.parse(readFileSync(current.report, "utf8"));
67
100
  assert.equal(report.status, "failed");
68
- assert.equal(report.error, outcome === "aborted" ? "child_aborted" : "child_error");
101
+ assert.equal(report.error, "child_error");
69
102
  assert.match(report.summary, /unconfirmed/);
70
103
  }
71
104
  const repaired = attempt();
@@ -18,6 +18,7 @@ try{
18
18
  const dir=join(root,scenario);mkdirSync(dir);
19
19
  const attemptId=randomUUID();const statePath=join(dir,'attempts',attemptId+'.json');
20
20
  process.env.PI_HERDR_SUBAGENTS_TASK_DIR=dir;
21
+ if(scenario==='abort'){mkdirSync(join(dir,'.pi'));writeFileSync(join(dir,'.pi','herdr-subagents.json'),JSON.stringify({abortGraceSeconds:1}));}
21
22
  writeFileSync(join(dir,'task.json'),JSON.stringify({currentAttemptId:attemptId}));
22
23
  const trace=[],states=[],errors=[],inputs=[];let requests=0,ended=0,boundaries=0,settled=0;
23
24
  const entered=Promise.withResolvers();let session;
@@ -52,6 +53,7 @@ try{
52
53
  const running=session.prompt('Run offline lifecycle check');
53
54
  if(scenario==='abort'){await entered.promise;await session.abort();}
54
55
  await running;
56
+ if(scenario==='abort'){assert.equal(existsSync(join(dir,'reports',attemptId+'.json')),false,'no report inside the grace period');await new Promise(r=>setTimeout(r,1500));}
55
57
  assert.equal(errors.length,0,JSON.stringify(errors));
56
58
  const expectedRequests={reported:1,failed_report:1,remind_success:2,normal_no_report:2,transient_retry:3,retry_exhausted:2,queued_followup:3,boundary_continue:2,abort:1,settled_hook_new_prompt:3}[scenario];
57
59
  assert.equal(requests,expectedRequests,scenario);
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026-10-27 - v0.4.2
4
+
5
+ - support removing multiple worktrees in order
6
+ - Allow removing multiple worktrees in order, retaining individual safety confirmations and stopping on failure.
7
+
3
8
  ## 2026-10-23 - v0.4.1
4
9
 
5
10
  - return the worktree root from already-active nested directories
@@ -17,7 +17,7 @@ pi-worktree create feature
17
17
  pi-worktree create --base main --json feature
18
18
  pi-worktree info feature
19
19
  pi-worktree out
20
- pi-worktree remove feature
20
+ pi-worktree remove feature another-feature
21
21
  ```
22
22
 
23
23
  `create` 从当前分支(或显式 `--base`)创建或复用 `.worktrees/feature` 并记录 base,不启动 Pi。`--json` 提供相同操作的机器可读结果。
@@ -65,12 +65,14 @@ pi-worktree create [--base <ref>] [--json] <name>
65
65
  pi-worktree list
66
66
  pi-worktree info <name>
67
67
  pi-worktree out
68
- pi-worktree remove <name>
68
+ pi-worktree remove <name> [<name> ...]
69
69
  pi-worktree prune
70
70
  ```
71
71
 
72
72
  `create` 是幂等接口;`create --json` 供 Extension 和其他宿主使用,返回 `created`、`attached`、`reused` 或 `already-active`,以及绝对路径、分支和 dirty 状态。已有 worktree 会复用;已存在但未挂载的同名分支会挂载;stale 注册、非法名称和路径冲突会显式失败。
73
73
 
74
+ `remove` 按顺序删除指定 worktree;每个脏 worktree 或未合并分支仍需单独确认。遇到错误或拒绝确认时停止,已经删除的 worktree 不会恢复。
75
+
74
76
  Agent 用法见 `skills/pi-worktree/SKILL.md`。测试流程、夹具和结果见 [TESTING.md](TESTING.md)。
75
77
 
76
78
  ## Already-active paths
@@ -19,6 +19,7 @@
19
19
  - 已删除的 CLI `start` 和 `prepare` 会显式失败。
20
20
  - `list` 标记主 checkout 和子 worktree。
21
21
  - `info` 报告 clean、branch、base 和 ahead。
22
+ - `remove` 支持多个名称,逐个确认风险;拒绝时停止且保留后续 worktree。补全支持后续名称。
22
23
  - postinstall 把可执行 CLI 与 completion 复制到隔离 HOME,并保持执行权限。
23
24
 
24
25
  ## 最近验证结果
@@ -6,7 +6,7 @@
6
6
  # pi-worktree -l | --list # list worktrees (main checkout marked "main checkout")
7
7
  # pi-worktree -i <name> # show base commit, ahead/behind, unique commits
8
8
  # pi-worktree -o | --out # cd back to the main checkout
9
- # pi-worktree -r <name> # remove worktree <name> (must be clean & merged)
9
+ # pi-worktree -r <name> [<name> ...] # remove worktrees (confirms dirty or unmerged)
10
10
  # pi-worktree -p | --prune # drop stale worktree registrations
11
11
  set -euo pipefail
12
12
 
@@ -150,7 +150,14 @@ cmd_info() {
150
150
 
151
151
  cmd_remove() {
152
152
  require_repo
153
- [[ $# -eq 1 ]] || die "usage: pi-worktree -r <name>"
153
+ [[ $# -ge 1 ]] || die "usage: pi-worktree -r <name> [<name> ...]"
154
+ local name
155
+ for name in "$@"; do
156
+ remove_one "$name"
157
+ done
158
+ }
159
+
160
+ remove_one() {
154
161
  local dir; dir="$(wt_dir "$1")"
155
162
  local branch registered rm_anyway=0 force=0
156
163
  if [[ ! -d "$dir" ]]; then
@@ -23,9 +23,11 @@ _pi_worktree() {
23
23
  fi
24
24
 
25
25
  case "$first" in
26
- -i|--info|info|-r|--remove|remove|rm|create)
27
- # only the second word of info/remove completes worktree names;
28
- # later words offer nothing (info/remove take no further args)
26
+ -r|--remove|remove|rm)
27
+ COMPREPLY=($(compgen -W "$wts" -- "$cur"))
28
+ ;;
29
+ -i|--info|info|create)
30
+ # info and create accept one name
29
31
  if (( COMP_CWORD == 2 )); then
30
32
  COMPREPLY=($(compgen -W "$wts" -- "$cur"))
31
33
  else
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-worktree",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Git worktree CLI, Pi extension, and skill for isolated coding tasks.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -19,7 +19,7 @@ Installed by the package postinstall hook to `~/.local/bin/pi-worktree`, with ba
19
19
  | `pi-worktree list` | All worktrees; the main checkout is marked `(main checkout)`, stale registrations are flagged. |
20
20
  | `pi-worktree info <name>` | Clean/dirty status, branch and HEAD, base commit, ahead/behind `main`, unique commits. |
21
21
  | `pi-worktree out` | Open a shell in the main checkout (for merging). |
22
- | `pi-worktree remove <name>` | Remove the worktree and its branch; asks before discarding dirty files or unmerged commits. |
22
+ | `pi-worktree remove <name> [<name> ...]` | Remove worktrees and branches in order; asks before discarding dirty files or unmerged commits. Stops on failure without undoing earlier removals. |
23
23
  | `pi-worktree prune` | Drop registrations whose directory was deleted by hand. |
24
24
 
25
25
  ## Isolating a task
@@ -127,6 +127,49 @@ mkdir "$ROOT/symlink-repo" "$ROOT/outside-worktrees"
127
127
  )
128
128
 
129
129
  bash "$PACKAGE_DIR/bin/pi-worktree" --help | grep -q 'pi-worktree create'
130
+ bash "$PACKAGE_DIR/bin/pi-worktree" remove feature attachable
131
+ test ! -d .worktrees/feature && test ! -d .worktrees/attachable
132
+ ! git show-ref --verify --quiet refs/heads/feature
133
+ ! git show-ref --verify --quiet refs/heads/attachable
134
+
135
+ bash "$PACKAGE_DIR/bin/pi-worktree" create --json batch-dirty >/dev/null
136
+ bash "$PACKAGE_DIR/bin/pi-worktree" create --json batch-later >/dev/null
137
+ echo dirty >> .worktrees/batch-dirty/tracked.txt
138
+ if printf 'n\n' | bash "$PACKAGE_DIR/bin/pi-worktree" rm batch-dirty batch-later; then
139
+ echo "remove ignored a declined confirmation" >&2
140
+ exit 1
141
+ fi
142
+ test -d .worktrees/batch-dirty && test -d .worktrees/batch-later
143
+ printf 'y\n' | bash "$PACKAGE_DIR/bin/pi-worktree" -r batch-dirty batch-later
144
+ test ! -d .worktrees/batch-dirty && test ! -d .worktrees/batch-later
145
+
146
+ bash "$PACKAGE_DIR/bin/pi-worktree" create --json batch-first >/dev/null
147
+ bash "$PACKAGE_DIR/bin/pi-worktree" create --json batch-last >/dev/null
148
+ if bash "$PACKAGE_DIR/bin/pi-worktree" --remove batch-first missing-name batch-last; then
149
+ echo "remove ignored a missing worktree" >&2
150
+ exit 1
151
+ fi
152
+ test ! -d .worktrees/batch-first && test -d .worktrees/batch-last
153
+ bash "$PACKAGE_DIR/bin/pi-worktree" create --json batch-unmerged >/dev/null
154
+ git -C .worktrees/batch-unmerged commit --allow-empty -qm unmerged
155
+ if printf 'n\n' | bash "$PACKAGE_DIR/bin/pi-worktree" remove batch-unmerged batch-last; then
156
+ echo "remove ignored an unmerged branch confirmation" >&2
157
+ exit 1
158
+ fi
159
+ test -d .worktrees/batch-unmerged && test -d .worktrees/batch-last
160
+ printf 'y\n' | bash "$PACKAGE_DIR/bin/pi-worktree" remove batch-unmerged batch-last
161
+ ! git show-ref --verify --quiet refs/heads/batch-unmerged
162
+ test ! -d .worktrees/batch-last
163
+
164
+ if bash "$PACKAGE_DIR/bin/pi-worktree" remove >/dev/null 2>&1; then
165
+ echo "remove accepted no names" >&2
166
+ exit 1
167
+ fi
168
+ source "$PACKAGE_DIR/bin/pi-worktree-completion.bash"
169
+ COMP_WORDS=(pi-worktree remove prepared f)
170
+ COMP_CWORD=3
171
+ _pi_worktree
172
+ [[ " ${COMPREPLY[*]} " == *" from-subdir "* ]]
130
173
  if bash "$PACKAGE_DIR/bin/pi-worktree" prepare feature >/dev/null 2>&1; then
131
174
  echo "removed prepare command is still accepted" >&2
132
175
  exit 1