@maxiaochao/pi-toolkit 0.13.12 → 0.13.14

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,11 @@
1
+ ## 2026-10-26 - v0.13.14
2
+
3
+ - herdr-subagents v0.7.6: Esc in a child waits abortGraceSeconds (default 30) for a correction before publishing child_aborted
4
+
5
+ ## 2026-10-26 - v0.13.13
6
+
7
+ - background-tasks v0.1.2: restrict background tasks to model-only and use concise automatic-notification guidance
8
+
1
9
  ## 2026-10-23 - v0.13.12
2
10
 
3
11
  - worktree v0.4.1: return the worktree root from already-active nested directories
@@ -54,6 +62,8 @@
54
62
 
55
63
  ## Unreleased
56
64
 
65
+ - Background Tasks: fix tool exposure to `model-only` and shorten startup guidance to English, emphasizing automatic notification without sleep or polling.
66
+
57
67
  - OpenAI Account Pool: clear stranded `openai-codex` references after deleting the last real account and on startup, including stale membership left by external credential deletion.
58
68
  - OpenAI Account Pool: recover email labels from both profile and root-level token claims; cover removal persistence and last-account cleanup through the production extension and Pi credential cache.
59
69
  - Background Tasks: expose the approved non-polling startup guidance to direct and Codemode callers. Retain results after a user interrupt without restarting the agent, then hand them to the next user-message run; verify native tool batches, interrupts and compaction cancellation without changing Pi itself.
package/README.md CHANGED
@@ -40,7 +40,7 @@ Restart Pi after updating. `pi update --extensions` has the same effect when upd
40
40
 
41
41
  Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
42
42
 
43
- Background Tasks combines unread completions after any current tool batch, before the next model request, including when the main agent waits for a synchronous subagent. Idle completions wake the main agent automatically, except after a user interrupt: results remain available until the next user-message run. Reading a finished task's `output` through the tool consumes its pending reminder. Startup guidance tells direct and Codemode callers to continue independent work or end the turn, not query to wait. See [completion delivery](packages/background-tasks/README.md#user-controls-and-status).
43
+ Background Tasks combines unread completions after any current tool batch, before the next model request, including when the main agent waits for a synchronous subagent. Idle completions wake the main agent automatically, except after a user interrupt: results remain available until the next user-message run. Reading a finished task's `output` through the tool consumes its pending reminder. The tool is fixed to `model-only`, not callable through Codemode. Startup guidance emphasizes automatic notification without sleep or polling. See [completion delivery](packages/background-tasks/README.md#user-controls-and-status).
44
44
 
45
45
  Codex Edit personal routing rules belong in `~/.pi/agent/codex-edit.json`, not inside `node_modules`. User fields override bundled defaults; `models` replaces the complete default list. The file survives package updates and is shared by parent sessions and explicitly configured subagents using the same agent directory. Use `/reload` after editing it, and start new subagents. See [Codex Edit configuration](packages/codex-edit/README.md#configuration).
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.13.12",
3
+ "version": "0.13.14",
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": [
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026-10-26 - v0.1.2
4
+
5
+ - restrict background tasks to model-only and use concise automatic-notification guidance
6
+ - Fix `background_task` exposure to `model-only`; reject Codemode and other nested callers without adding a configuration file.
7
+ - Use concise English startup guidance emphasizing automatic notification without sleep or polling.
8
+
3
9
  ## 2026-10-22 - v0.1.1
4
10
 
5
11
  - batch unread completions at tool boundaries, preserve interrupted runs, and provide non-polling startup guidance
@@ -31,9 +31,11 @@ The complete `@maxiaochao/pi-toolkit` package **bundles and enables Background T
31
31
  background_task({ action: "start", command: "sleep 10; printf 'checks complete\\n'", description: "Delayed check", timeout: 120 })
32
32
  ```
33
33
 
34
- Each accepted start includes this exact guidance in the tool's text and `structuredContent.guidance`. The tool description carries it too, including for Codemode discovery:
34
+ The tool is fixed to `exposure: "model-only"`: the model calls it directly; Codemode and other tools cannot call it. There is no exposure setting.
35
35
 
36
- > 完成会自动通知。现在不要查询等待;继续其他独立工作,没有其他工作就结束当前轮,收到通知后再取结果。
36
+ Each accepted start includes this guidance in the tool description, result text and `structuredContent.guidance`:
37
+
38
+ > Notifies automatically on completion. Do not sleep or poll; continue other work or end the turn.
37
39
 
38
40
  After the completion notification, read the result:
39
41
 
@@ -79,7 +81,7 @@ Completion, failure, cancellation, timeout and output-limit termination update t
79
81
  - While idle, pending completions are combined on the next event-loop tick and wake the main agent once. Jobs that finish after that batch can produce another notification. Pi's global steering/follow-up queue settings are unchanged.
80
82
  - If another operation such as manual compaction keeps the session busy without an agent boundary, one local timer retries after 1, 2, 4, 8, 16, then 30 seconds, staying at 30 seconds thereafter. There is no retry-count limit or expiry that discards unread results. This does not call a model or poll shell output.
81
83
  - Normal tool/settlement boundaries still deliver immediately, without waiting for the retry timer. Without a boundary, an idle session can wait up to the current retry interval (at most 30 seconds, subject to native timer delays). Once a batch is delivered or fully consumed, the next batch starts with an immediate check and fresh backoff.
82
- - A successful tool `output` read of a finished task consumes its pending reminder, including failed tasks. This applies to direct and Codemode callers; a script that reads and filters a result has consumed it even if it does not print it. A running snapshot, `list`, a failed log read, or a user `/bg output` inspection does not consume the main agent's reminder. Consumption does not mean the task passed validation.
84
+ - A successful tool `output` read of a finished task consumes its pending reminder, including failed tasks. A running snapshot, `list`, a failed log read, or a user `/bg output` inspection does not consume the main agent's reminder. Consumption does not mean the task passed validation.
83
85
  - Aborted/error boundaries do not request continuation or replay a previously scheduled wake. An error does not suppress later completions.
84
86
  - A user interrupt keeps the agent stopped: accepted jobs still run, but even later completions cannot restart it. Unread results remain queryable and are handed to the next user-message run before its first model request. Another extension's custom wake does not release this interrupt state; a new user-message run or session replacement does. This adds no notification pause switch.
85
87
  - Cancelling manual compaction is separate from interrupting the agent. Pending completions still wake an otherwise-idle session after compaction succeeds, fails or is cancelled.
@@ -95,7 +97,7 @@ Pi reports asynchronous message/run-start failures through its extension error c
95
97
  | View | Limit |
96
98
  | --- | --- |
97
99
  | Model-visible output | Last 2000 lines or 50 KiB, whichever is reached first, like Bash |
98
- | Codemode's `structuredContent.output` | Up to 1 MiB of raw log bytes; larger logs keep their first and last 512 KiB with an omission marker |
100
+ | Internal `structuredContent.output` | Up to 1 MiB of raw log bytes; larger logs keep their first and last 512 KiB with an omission marker |
99
101
  | Disk capture | 32 MiB per task, 256 MiB total per session |
100
102
 
101
103
  Display/program-view truncation does **not** stop the command. Disk limits **do** stop the overflowing task with `output_limit`; a full session budget rejects new starts. Nothing silently evicts old logs.
@@ -104,22 +106,11 @@ Output includes a log path for further reads. `truncated` describes the program
104
106
 
105
107
  Output streams to private temporary logs (directory 0700, files 0600 on Unix), with bounded memory and range-limited reads. Log errors stop the task and are reported. Stdout and stderr are merged; strict ordering across the two streams is not promised. Logs and command arguments can contain secrets: choose commands accordingly.
106
108
 
107
- ## Codemode and Bash
108
-
109
- Use Bash when you need a command's result before proceeding. Codemode composes tool calls and filters their results; it does not itself own background jobs.
109
+ ## Bash and background execution
110
110
 
111
- Codemode can start a managed job and finish immediately:
112
-
113
- ```javascript
114
- const started = await tools.background_task({
115
- action: "start",
116
- command: "sleep 10; printf 'done\\n'",
117
- timeout: 120,
118
- });
119
- text(started);
120
- ```
111
+ Use Bash when you need a command's result before proceeding. Use `background_task` directly when the command should keep running after the model's turn ends. Completion notifies the model automatically; do not add sleep or polling calls to wait for it.
121
112
 
122
- This prints the startup guidance as well as the task. Continue independent work or end the turn; do not keep a Codemode worker open to sleep or poll for completion. After notification, another script can read the result. Merely starting an unawaited Bash call is different: Codemode cancels unfinished nested calls when its script ends.
113
+ Codemode cannot call this model-only tool. Starting an unawaited Bash call in Codemode is not a substitute: Codemode cancels unfinished nested calls when its script ends.
123
114
 
124
115
  The tool description is compact. This package adds no `promptGuidelines` or extra global system-prompt rules.
125
116
 
@@ -13,8 +13,8 @@ Tests use Node's built-in test runner. They resolve host peers from the installe
13
13
  - `tasks.mjs`: fake execution and clock at the task-manager interface; immediate return, two slots, validation, timeout/cancel races, final failure, disk budgets, write errors, bounded history and shutdown.
14
14
  - `output.mjs`: compares both output views with the real host Bash tool. Covers line/byte limits, long lines, empty output, missing final newline, UTF-8 split across chunks, invalid UTF-8 and logs larger than 1 MiB.
15
15
  - `processes.mjs`: the native backend, a multi-line Python log check, non-blocking concurrency, PID-observed process-tree cleanup, silent busy-loop timeout and the default 32 MiB disk limit.
16
- - `extension.mjs`: real extension registration with fake execution/clock; schema errors, command parity, parent-signal independence, merged busy/idle notifications, terminal-output consumption and failed-read retention, abort/error boundaries, manual-compaction-style busy intervals without agent boundaries, other extensions' draft preservation, no output in notifications, above-editor placement, five-second retention, narrow/CJK rendering, coexistence and non-TUI modes. Checks the exact approved startup guidance in both return paths and the tool description; verifies 24-hour retention after an interrupt, output consumption while stopped, custom-wake isolation and restoration of automatic delivery by a new user-message run.
17
- - `notifications.mjs`: isolated real Pi with direct tool batches or its native Codemode tool, default one-at-a-time queues and an offline provider. Four actual failing shell jobs finish while a synchronous tool stands in for a subagent. The next model request gets one merged notification, excluding zero, one or four consumed terminal outputs; no extra requests follow the final report. Also verifies that native Codemode and the next model request receive the exact startup guidance. The blocking tool is a fixture, not a real Herdr child. `BG_TEST_EXTENSION=/absolute/path/to/background-tasks.ts` can run the queue regressions against an older implementation.
16
+ - `extension.mjs`: real extension registration with fake execution/clock; schema errors, command parity, parent-signal independence, merged busy/idle notifications, terminal-output consumption and failed-read retention, abort/error boundaries, manual-compaction-style busy intervals without agent boundaries, other extensions' draft preservation, no output in notifications, above-editor placement, five-second retention, narrow/CJK rendering, coexistence and non-TUI modes. Checks fixed `model-only` exposure and the concise English startup guidance in both return paths and the tool description; verifies 24-hour retention after an interrupt, output consumption while stopped, custom-wake isolation and restoration of automatic delivery by a new user-message run.
17
+ - `notifications.mjs`: isolated real Pi with model-issued background calls, default one-at-a-time queues and an offline provider. Sequential blocking fixtures hold one native tool batch open across two waves of actual failing shell jobs. The blocking fixture runs directly or through native Codemode, but background calls always come from the model. The next model request gets one merged notification for four completions, excluding zero, one or two first-wave terminal outputs read directly by the model before the boundary; no extra requests follow the final report. Complete pending-batch consumption remains covered by the deterministic extension tests. Also verifies that the model receives the English guidance without a Codemode suffix, and that native Codemode cannot discover or call `background_task`. The blocking tool is a fixture, not a real Herdr child. `BG_TEST_EXTENSION=/absolute/path/to/background-tasks.ts` can run the queue regressions against an older implementation.
18
18
  - `compaction.mjs`: isolated native Pi SDK sessions call `session.compact()`, the same manual-compaction entry point used by `/compact` and RPC. Real shell jobs exit during a held offline summary request. Covers successful native compaction, a provider summary error and `abortCompaction()` cancellation; each must retain the pending completion while busy and automatically deliver it once afterward, without another user prompt. Checks actual compaction lifecycle/events, saved compaction entries, completion timestamps/logs and exactly one automatic conversation request. Uses real timers and native `isIdle`/`isCompacting`, not injected busy state or replacement compaction results; no paid model call or TUI keyboard invocation of `/compact`. `BG_TEST_EXTENSION` also supports a negative-control extension.
19
19
  - `interruptions.mjs`: isolated native Pi SDK sessions call the real `session.abort()` while a foreground tool is held. A real shell finishes before or after the interrupt; terminal timestamps verify the ordering. Native `agent_settled.aborted` must confirm the stop; no completion may start another model request. A new user prompt sees the retained completion in its first request, reads the real failed task's output, and finishes without duplicate delivery or extension errors. A third case starts actual manual compaction while the foreground tool is held: Pi itself interrupts the active agent, which must stay stopped even after compaction and background completion. The public abort event has no reason field, so the extension preserves this native interruption rather than guessing its source. `BG_TEST_EXTENSION` supports a negative control without interrupt suppression.
20
20
 
@@ -32,7 +32,7 @@ Requires Python 3 with the existing `pexpect` module; process fixtures use Bash,
32
32
  python3 packages/background-tasks/tests/tui-smoke.py .cache/verification/background-tui
33
33
  ```
34
34
 
35
- This starts a real Pi in a PTY with the production extension, Scheduler, the built-in Codemode tool and a deterministic fixture provider. It checks two jobs, rejection of a third, Codemode completion while jobs remain alive, explicit output, cancellation, automatic main-agent notification, reload/new/quit cleanup and log removal. It observes both persisted boundary entries and idle custom messages by unique session-entry ID, and rejects duplicate notifications. No network model calls occur. Mocked UI tests separately verify the exact five-second boundary.
35
+ This starts a real Pi in a PTY with the production extension, Scheduler, the built-in Codemode tool and a deterministic fixture provider. It checks two jobs, rejection of a third, model turn completion while jobs remain alive and rejection of Codemode background calls, explicit output, cancellation, automatic main-agent notification, reload/new/quit cleanup and log removal. It observes both persisted boundary entries and idle custom messages by unique session-entry ID, and rejects duplicate notifications. No network model calls occur. Mocked UI tests separately verify the exact five-second boundary.
36
36
 
37
37
  Results and the actual ANSI stream are stored under the chosen evidence directory. Real screenshots require an isolated X11 terminal window, `gnome-terminal.real`, `xwininfo` and ImageMagick `import`:
38
38
 
@@ -7,7 +7,7 @@ import { BackgroundTasks, errorText, summary, systemClock, type Clock, type Task
7
7
  import { BackgroundWidget, FINISHED_VISIBLE_MS, WIDGET_KEY, duration, visibleTasks } from "../src/widget.ts";
8
8
 
9
9
  const actions = ["start", "list", "output", "cancel"] as const;
10
- const startGuidance = "完成会自动通知。现在不要查询等待;继续其他独立工作,没有其他工作就结束当前轮,收到通知后再取结果。";
10
+ const startGuidance = "Notifies automatically on completion. Do not sleep or poll; continue other work or end the turn.";
11
11
  type Action = typeof actions[number];
12
12
  interface Params {
13
13
  action: Action;
@@ -23,7 +23,7 @@ const Parameters = Type.Object({
23
23
  description: Type.Optional(Type.String({ description: "Short display label for start" })),
24
24
  cwd: Type.Optional(Type.String({ description: "Working directory for start; defaults to the session directory. Relative paths resolve from it." })),
25
25
  timeout: Type.Optional(Type.Integer({ minimum: 1, maximum: 7200, description: "Runtime limit for start, in seconds: default 1800 (30 minutes), maximum 7200 (2 hours); no unlimited mode" })),
26
- id: Type.Optional(Type.String({ description: "Task ID for output or cancel; output shows the last 2000 lines or 50 KiB. Codemode receives up to 1 MiB, keeping head/tail if larger, plus a log path." })),
26
+ id: Type.Optional(Type.String({ description: "Task ID for output or cancel; output shows the last 2000 lines or 50 KiB, plus a log path." })),
27
27
  }, { additionalProperties: false });
28
28
  const TaskSchema = Type.Object({
29
29
  id: Type.String(), status: StringEnum(["running", "succeeded", "failed", "cancelled", "timed_out", "output_limit"] as const),
@@ -240,6 +240,7 @@ export default function (pi: ExtensionAPI, dependencies: { operations?: BashOper
240
240
 
241
241
  pi.registerTool({
242
242
  name: "background_task", label: "Background task",
243
+ exposure: "model-only",
243
244
  description: `Start shell commands as background tasks and return task IDs immediately. Supports status, output, and cancellation. After start: ${startGuidance}`,
244
245
  parameters: Parameters, outputSchema: OutputSchema,
245
246
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
@@ -247,7 +248,7 @@ export default function (pi: ExtensionAPI, dependencies: { operations?: BashOper
247
248
  try {
248
249
  if (signal?.aborted) throw new Error("Tool call aborted before task acceptance");
249
250
  const result = await perform(params, ctx?.cwd ?? context?.cwd ?? process.cwd());
250
- // A terminal result was consumed by this tool's caller (including Codemode).
251
+ // A terminal result was consumed by the model.
251
252
  // User /bg inspections and running snapshots do not acknowledge completion.
252
253
  if (params.action === "output" && "task" in result.data && result.data.task.status !== "running") {
253
254
  pending.delete(result.data.task.id);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-background-tasks",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Session-scoped background shell tasks with completion notifications for Pi.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -325,6 +325,7 @@ test("registration is compact, does not inject prompt rules or start session res
325
325
  const h = harness();
326
326
  try {
327
327
  assert.equal(h.tool.name, "background_task");
328
+ assert.equal(h.tool.exposure, "model-only");
328
329
  assert.match(h.tool.description, /return task IDs immediately/);
329
330
  assert.ok(h.tool.description.length < 500);
330
331
  assert.equal(h.tool.promptGuidelines, undefined);
@@ -344,9 +345,9 @@ test("start provides non-blocking next steps in both model text and structured r
344
345
  const started = await h.tool.execute("start", { action: "start", command: "sleep 60" });
345
346
  const { task, guidance } = started.structuredContent;
346
347
  assert.equal(task.status, "running");
347
- assert.equal(guidance, "完成会自动通知。现在不要查询等待;继续其他独立工作,没有其他工作就结束当前轮,收到通知后再取结果。");
348
- assert.ok(started.content[0].text.includes(guidance), "ordinary tool callers must see the same guidance as Codemode");
349
- assert.ok(h.tool.description.includes(guidance), "scripts that filter out guidance still discover the lifecycle in the tool description");
348
+ assert.equal(guidance, "Notifies automatically on completion. Do not sleep or poll; continue other work or end the turn.");
349
+ assert.ok(started.content[0].text.includes(guidance), "the start result must include the guidance");
350
+ assert.ok(h.tool.description.includes(guidance), "the tool description must include the same guidance");
350
351
  assert.equal(h.tool.outputSchema.properties.guidance.type, "string");
351
352
  assert.equal(h.tool.outputSchema.required?.includes("guidance") ?? false, false);
352
353
  const list = await h.tool.execute("list", { action: "list" });
@@ -1,5 +1,5 @@
1
- // A blocking tool stands in for a synchronous subagent. Pi queues and shell jobs are real.
2
- import { appendFileSync, writeFileSync } from "node:fs";
1
+ // Sequential fixture tools hold one native batch open; background calls remain model-issued.
2
+ import { appendFileSync, existsSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
5
  import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
@@ -8,32 +8,40 @@ import { Type } from "typebox";
8
8
 
9
9
  export default function (pi: ExtensionAPI) {
10
10
  const record = (entry: unknown) => appendFileSync(process.env.BG_NOTIFICATION_EVENTS!, JSON.stringify(entry) + "\n");
11
+ const tasks = new Map<string, any>();
12
+ const consumed: string[] = [];
13
+ let finished = false;
11
14
  pi.registerTool({
12
15
  name: "synchronous_subagent_fixture", label: "Synchronous subagent fixture",
13
- description: "Wait for four real shell jobs while holding one synchronous tool call open.",
14
- parameters: Type.Object({ reads: Type.Number() }),
15
- async execute(_id, { reads }, _signal, _update, ctx) {
16
- record({ event: "waiting" });
17
- writeFileSync(join(ctx.cwd, "release"), "ready");
18
- const list = async () => (await ctx.executeTool("background_task", { action: "list" })).result.structuredContent as any;
19
- const wait = async () => {
20
- for (let i = 0; i < 1000; i++) {
21
- const tasks = await list();
22
- if (tasks.running === 0) return tasks.tasks;
23
- await delay(5);
16
+ description: "Wait for two real shell jobs while holding one synchronous tool call open.",
17
+ parameters: Type.Object({ wave: Type.Number() }), executionMode: "sequential",
18
+ async execute(_id, { wave }, _signal, _update, ctx) {
19
+ record({ event: "waiting", wave });
20
+ writeFileSync(join(ctx.cwd, `release-${wave}`), "ready");
21
+ for (let i = 0; i < 1000; i++) {
22
+ if ([0, 1].every((job) => existsSync(join(ctx.cwd, `exited-${wave}-${job}`)))) {
23
+ // Exit markers precede process exit. Let the native shell backend settle.
24
+ await delay(100);
25
+ if (wave === 1) finished = true;
26
+ return { content: [{ type: "text", text: "Synchronous subagent returned; shell wave finished." }], details: undefined };
24
27
  }
25
- throw new Error("Fixture shell jobs did not finish");
26
- };
27
- await wait();
28
- for (let i = 0; i < 2; i++) await ctx.executeTool("background_task", {
29
- action: "start", command: "printf 'second wave\\n'; exit 7", description: `second wave ${i}`, timeout: 10,
30
- });
31
- const tasks = await wait();
32
- for (const task of tasks.slice(0, reads)) await ctx.executeTool("background_task", { action: "output", id: task.id });
33
- record({ event: "returned", tasks, consumed: tasks.slice(0, reads).map((task: any) => task.id) });
34
- return { content: [{ type: "text", text: "Synchronous subagent returned; final build report is ready." }], details: undefined };
28
+ await delay(5);
29
+ }
30
+ throw new Error("Fixture shell jobs did not finish");
35
31
  },
36
32
  });
33
+ pi.on("tool_execution_end", (event) => {
34
+ if (event.toolName !== "background_task") return;
35
+ const data = event.result.structuredContent as any;
36
+ if (data?.task) tasks.set(data.task.id, data.task);
37
+ if (data?.action === "output" && data.task.status !== "running") consumed.push(data.task.id);
38
+ });
39
+ pi.on("context", () => {
40
+ if (finished) {
41
+ record({ event: "returned", tasks: [...tasks.values()], consumed });
42
+ finished = false;
43
+ }
44
+ });
37
45
  let requests = 0;
38
46
  pi.registerProvider("bg-notifications", {
39
47
  name: "Offline notification fixture", baseUrl: "http://127.0.0.1:1", api: "bg-notifications", apiKey: "fixture-not-a-secret",
@@ -43,21 +51,23 @@ export default function (pi: ExtensionAPI) {
43
51
  queueMicrotask(() => {
44
52
  requests++;
45
53
  record({ event: "request", number: requests, messages: context.messages });
46
- const direct = process.env.BG_NOTIFICATION_MODE === "direct";
47
- const toolTurn = requests === 1 || (direct && requests === 2);
48
- const message: any = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: toolTurn ? "toolUse" : "stop",
54
+ const message: any = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: requests <= 2 ? "toolUse" : "stop",
49
55
  usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
50
56
  stream.push({ type: "start", partial: message });
51
- if (toolTurn) {
52
- const reads = Number(process.env.BG_NOTIFICATION_READS);
53
- if (direct && requests === 1) {
54
- for (let i = 0; i < 2; i++) message.content.push({ type: "toolCall", id: `direct-start-${i}`, name: "background_task", arguments: {
55
- action: "start", command: "while [ ! -f release ]; do sleep 0.01; done; printf 'first wave\\n'; exit 7", description: `first wave ${i}`, timeout: 10,
56
- } });
57
- } else if (direct) message.content.push({ type: "toolCall", id: "direct-blocking-tool", name: "synchronous_subagent_fixture", arguments: { reads } });
58
- else {
59
- const code = `for (let i = 0; i < 2; i++) text(await tools.background_task({ action: "start", command: "while [ ! -f release ]; do sleep 0.01; done; printf 'first wave\\\\n'; exit 7", description: "first wave " + i, timeout: 10 }));\ntext(await tools.synchronous_subagent_fixture({ reads: ${reads} }));`;
60
- message.content.push({ type: "toolCall", id: "notification-fixture", name: "codemode", arguments: { code } });
57
+ if (requests <= 2) {
58
+ // First start a pair to obtain IDs. The second native batch waits, starts
59
+ // another pair, waits again, and reads selected outputs before its boundary.
60
+ const wave = requests - 1;
61
+ if (requests === 2) message.content.push({ type: "toolCall", id: "wait-first-wave", name: "synchronous_subagent_fixture", arguments: { wave: 0 } });
62
+ for (let i = 0; i < 2; i++) message.content.push({ type: "toolCall", id: `start-${wave}-${i}`, name: "background_task", arguments: {
63
+ action: "start", command: `while [ ! -f release-${wave} ]; do sleep 0.01; done; printf 'wave ${wave}\\n'; touch exited-${wave}-${i}; exit 7`, description: `wave ${wave} job ${i}`, timeout: 10,
64
+ } });
65
+ if (requests === 2) {
66
+ if (process.env.BG_NOTIFICATION_MODE === "codemode") message.content.push({ type: "toolCall", id: "wait-second-wave", name: "codemode", arguments: { code: "text(await tools.synchronous_subagent_fixture({ wave: 1 }));" } });
67
+ else message.content.push({ type: "toolCall", id: "wait-second-wave", name: "synchronous_subagent_fixture", arguments: { wave: 1 } });
68
+ // Only first-wave IDs are known to the model before this batch returns.
69
+ const reads = Number(process.env.BG_NOTIFICATION_READS);
70
+ for (const task of [...tasks.values()].slice(0, reads)) message.content.push({ type: "toolCall", id: `read-${task.id}`, name: "background_task", arguments: { action: "output", id: task.id } });
61
71
  }
62
72
  for (let i = 0; i < message.content.length; i++) {
63
73
  stream.push({ type: "toolcall_start", contentIndex: i, partial: message });
@@ -8,6 +8,7 @@ export default function (pi: ExtensionAPI) {
8
8
  if (process.env.BG_SMOKE_EVENTS) appendFileSync(process.env.BG_SMOKE_EVENTS, JSON.stringify(entry) + "\n");
9
9
  };
10
10
  let sequence = 0;
11
+ const tasks = new Map<string, any>();
11
12
  const observedNotifications = new Set<string>();
12
13
  pi.registerProvider("bg-fixture", {
13
14
  name: "Offline background-task fixture", baseUrl: "http://127.0.0.1:1", api: "bg-fixture",
@@ -21,29 +22,38 @@ export default function (pi: ExtensionAPI) {
21
22
  const tools = getCurrentTools(context.messages);
22
23
  record({ event: "request", lastRole: last?.role, text: text.slice(0, 2000), tools: tools.map((tool) => tool.name),
23
24
  backgroundDescription: tools.find((tool) => tool.name === "background_task")?.description });
24
- let code: string | undefined;
25
+ const calls: { name: string; arguments: any }[] = [];
26
+ const call = (args: any) => calls.push({ name: "background_task", arguments: args });
25
27
  if (last?.role !== "toolResult") {
26
- if (text.includes("SMOKE_START")) code = `const a = await tools.background_task({ action: "start", command: "sleep 9; printf 'BUILD SUCCESSFUL\\\\n'", description: "Release build", timeout: 30 });
27
- const b = await tools.background_task({ action: "start", command: "sleep 300", description: "Cancel this task", timeout: 300 });
28
- const c = await tools.background_task({ action: "start", command: "printf third" });
29
- text({ first: a.task.id, second: b.task.id, third: c });`;
30
- else if (text.includes("SMOKE_CANCEL")) code = `const list = await tools.background_task({ action: "list" });
31
- const task = list.tasks.find(t => t.description === "Cancel this task");
32
- text(await tools.background_task({ action: "cancel", id: task.id }));`;
33
- else if (text.includes("SMOKE_OUTPUT")) code = `const list = await tools.background_task({ action: "list" });
34
- const task = list.tasks.find(t => t.description === "Release build");
35
- const result = await tools.background_task({ action: "output", id: task.id });
36
- text({ status: result.task.status, output: result.output });`;
37
- else if (text.includes("SMOKE_LONG")) code = `text(await tools.background_task({ action: "start", command: "echo $$ > shell.pid; sleep 300 & echo $! > child.pid; wait", description: "Lifecycle cleanup check", timeout: 300 }));`;
38
- else if (text.includes("SMOKE_GUIDANCE")) code = `text(await tools.background_task({ action: "start", command: "sleep 300", description: "Guidance check", timeout: 300 }));`;
28
+ if (text.includes("SMOKE_START")) {
29
+ call({ action: "start", command: "sleep 9; printf 'BUILD SUCCESSFUL\\n'", description: "Release build", timeout: 30 });
30
+ call({ action: "start", command: "sleep 300", description: "Cancel this task", timeout: 300 });
31
+ call({ action: "start", command: "printf third" });
32
+ } else if (text.includes("SMOKE_CANCEL")) {
33
+ call({ action: "cancel", id: tasks.get("Cancel this task").id });
34
+ } else if (text.includes("SMOKE_OUTPUT")) {
35
+ call({ action: "output", id: tasks.get("Release build").id });
36
+ } else if (text.includes("SMOKE_LONG")) {
37
+ call({ action: "start", command: "echo $$ > shell.pid; sleep 300 & echo $! > child.pid; wait", description: "Lifecycle cleanup check", timeout: 300 });
38
+ } else if (text.includes("SMOKE_GUIDANCE")) {
39
+ call({ action: "start", command: "sleep 300", description: "Guidance check", timeout: 300 });
40
+ } else if (text.includes("SMOKE_EXPOSURE")) {
41
+ calls.push({ name: "codemode", arguments: { code: `
42
+ text({ discovered: ALL_TOOLS.some(t => t.name === "background_task"), available: "background_task" in tools });
43
+ try { await tools.background_task({ action: "start", command: "touch should-not-exist" }); text("UNEXPECTED_SUCCESS"); }
44
+ catch { text("BACKGROUND_CALL_BLOCKED"); }` } });
45
+ }
39
46
  }
40
- const message: any = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: code ? "toolUse" : "stop",
47
+ const message: any = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: calls.length ? "toolUse" : "stop",
41
48
  usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
42
49
  stream.push({ type: "start", partial: message });
43
- if (code) {
44
- message.content.push({ type: "toolCall", id: `fixture-${++sequence}`, name: "codemode", arguments: { code } });
45
- stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
46
- stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: message.content[0], partial: message });
50
+ if (calls.length) {
51
+ for (const call of calls) {
52
+ const index = message.content.length;
53
+ message.content.push({ type: "toolCall", id: `fixture-${++sequence}`, ...call });
54
+ stream.push({ type: "toolcall_start", contentIndex: index, partial: message });
55
+ stream.push({ type: "toolcall_end", contentIndex: index, toolCall: message.content[index], partial: message });
56
+ }
47
57
  } else {
48
58
  const reply = text.includes("[Background task finished]") ? "BACKGROUND_NOTIFICATION_RECEIVED" : "Main agent is ready; accepted background tasks keep running.";
49
59
  message.content.push({ type: "text", text: reply });
@@ -60,7 +70,12 @@ text({ status: result.task.status, output: result.output });`;
60
70
  record({ event: "loaded", tools: pi.getAllTools().map((tool) => tool.name) });
61
71
  if (ctx.hasUI) ctx.ui.setTitle("Pi Background Tasks Smoke");
62
72
  });
63
- pi.on("tool_execution_end", (event) => record({ event: "tool_end", name: event.toolName, isError: event.isError, result: event.result }));
73
+ pi.on("agent_settled", () => record({ event: "settled" }));
74
+ pi.on("tool_execution_end", (event) => {
75
+ const data = event.result.structuredContent as any;
76
+ if (event.toolName === "background_task" && data?.action === "start" && data.task) tasks.set(data.task.description, data.task);
77
+ record({ event: "tool_end", name: event.toolName, isError: event.isError, result: event.result });
78
+ });
64
79
  pi.on("context", (_event, ctx) => {
65
80
  // Both boundary entries and idle messages are persisted before the provider request.
66
81
  // Key by entry ID so two actual notifications for the same tasks remain observable.
@@ -1,13 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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
6
  import { fileURLToPath } from "node:url";
7
7
  import { test } from "node:test";
8
8
  import { piBinary } from "./runtime.mjs";
9
9
 
10
- test("native Codemode receives start guidance and exposes it to the model", () => {
10
+ test("native model receives concise start guidance without Codemode instructions", () => {
11
11
  const directory = mkdtempSync(join(tmpdir(), "pi-bg-guidance-"));
12
12
  try {
13
13
  const events = join(directory, "events.jsonl");
@@ -23,19 +23,44 @@ test("native Codemode receives start guidance and exposes it to the model", () =
23
23
  const started = records.find((entry) => entry.event === "tool_end" && entry.name === "background_task");
24
24
  assert.equal(started.isError, false);
25
25
  const guidance = started.result.structuredContent.guidance;
26
- assert.equal(guidance, "完成会自动通知。现在不要查询等待;继续其他独立工作,没有其他工作就结束当前轮,收到通知后再取结果。");
27
- const codemode = records.find((entry) => entry.event === "tool_end" && entry.name === "codemode");
28
- assert.equal(codemode.isError, false);
29
- assert.ok(codemode.result.content.some((block) => block.type === "text" && block.text.includes(guidance)));
26
+ assert.equal(guidance, "Notifies automatically on completion. Do not sleep or poll; continue other work or end the turn.");
27
+ assert.equal(records.some((entry) => entry.event === "tool_end" && entry.name === "codemode"), false);
30
28
  const requests = records.filter((entry) => entry.event === "request");
31
29
  assert.ok(requests[0].backgroundDescription.includes(guidance));
32
- assert.ok(requests[1].text.includes(guidance), "the next model request sees guidance printed from the programmatic result");
30
+ assert.ok(!requests[0].backgroundDescription.includes("Codemode:"));
31
+ assert.ok(requests[1].text.includes(guidance), "the next model request sees the startup guidance");
32
+ } finally { rmSync(directory, { recursive: true, force: true }); }
33
+ });
34
+
35
+ test("native Codemode cannot discover or call the model-only background tool", () => {
36
+ const directory = mkdtempSync(join(tmpdir(), "pi-bg-exposure-"));
37
+ try {
38
+ const events = join(directory, "events.jsonl");
39
+ const output = execFileSync(piBinary, [
40
+ "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files", "--no-session", "--offline",
41
+ "-e", "builtin:codemode", "-e", fileURLToPath(new URL("../extensions/background-tasks.ts", import.meta.url)),
42
+ "-e", fileURLToPath(new URL("fixtures/provider.ts", import.meta.url)),
43
+ "--tools", "codemode,background_task", "--model", "bg-fixture/deterministic", "-p", "SMOKE_EXPOSURE",
44
+ ], { cwd: directory, encoding: "utf8", timeout: 30000,
45
+ env: { ...process.env, PI_CODING_AGENT_DIR: directory, PI_OFFLINE: "1", BG_SMOKE_EVENTS: events }, stdio: ["ignore", "pipe", "pipe"] });
46
+ assert.match(output, /Main agent is ready/);
47
+ const records = readFileSync(events, "utf8").trim().split("\n").map((line) => JSON.parse(line));
48
+ const codemode = records.find((entry) => entry.event === "tool_end" && entry.name === "codemode");
49
+ assert.equal(codemode.isError, false);
50
+ const text = codemode.result.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
51
+ assert.match(text, /"discovered":false/);
52
+ assert.match(text, /"available":false/);
53
+ assert.match(text, /BACKGROUND_CALL_BLOCKED/);
54
+ assert.doesNotMatch(text, /UNEXPECTED_SUCCESS/);
55
+ assert.equal(records.some((entry) => entry.event === "tool_end" && entry.name === "background_task"), false);
56
+ assert.equal(existsSync(join(directory, "should-not-exist")), false);
57
+ assert.ok(records.find((entry) => entry.event === "request").tools.includes("background_task"));
33
58
  } finally { rmSync(directory, { recursive: true, force: true }); }
34
59
  });
35
60
 
36
61
  for (const mode of ["codemode", "direct"]) {
37
- for (const reads of [0, 1, 4]) {
38
- test(`real Pi ${mode} merges four completions before the next model call, excluding ${reads} consumed results`, () => {
62
+ for (const reads of [0, 1, 2]) {
63
+ test(`real Pi ${mode} blocker merges four completions, excluding ${reads} model-read results`, () => {
39
64
  const directory = mkdtempSync(join(tmpdir(), "pi-bg-notifications-"));
40
65
  try {
41
66
  const events = join(directory, "events.jsonl");
@@ -51,26 +76,27 @@ for (const mode of ["codemode", "direct"]) {
51
76
  const returned = records.find((entry) => entry.event === "returned");
52
77
  assert.ok(returned, `the blocking tool finished all real shell jobs\n${output}`);
53
78
  assert.equal(returned.tasks.length, 4);
54
- assert.ok(returned.tasks.every((task) => task.status === "failed" && task.exit_code === 7));
79
+ assert.equal(returned.consumed.length, reads);
80
+ assert.ok(returned.tasks.filter((task) => returned.consumed.includes(task.id)).every((task) => task.status === "failed" && task.exit_code === 7));
55
81
  const transcript = output.trim().split("\n").map((line) => JSON.parse(line));
56
82
  const notifications = transcript.filter((entry) =>
57
83
  (entry.type === "entry_appended" && entry.entry.customType === "background-task-finished") ||
58
84
  (entry.type === "message_start" && entry.message.customType === "background-task-finished")
59
85
  );
60
- assert.equal(notifications.length, reads === 4 ? 0 : 1);
61
- if (reads !== 4) {
62
- const message = notifications[0].entry ?? notifications[0].message;
63
- const tasks = message.details.tasks;
64
- assert.equal(tasks.length, 4 - reads);
65
- assert.ok(tasks.every((task) => !returned.consumed.includes(task.id)));
66
- const toolEnd = transcript.findIndex((entry) => entry.type === "tool_execution_end" && entry.toolName === "synchronous_subagent_fixture");
67
- assert.ok(toolEnd >= 0 && transcript.indexOf(notifications[0]) > toolEnd, "the notification cannot interrupt the synchronous tool");
68
- assert.match(message.content, /Ended at: \d{4}-/);
69
- }
86
+ assert.equal(notifications.length, 1);
87
+ const message = notifications[0].entry ?? notifications[0].message;
88
+ const tasks = message.details.tasks;
89
+ assert.equal(tasks.length, 4 - reads);
90
+ assert.ok(tasks.every((task) => task.status === "failed" && task.exit_code === 7));
91
+ assert.ok(tasks.every((task) => !returned.consumed.includes(task.id)));
92
+ assert.deepEqual([...returned.consumed, ...tasks.map((task) => task.id)].sort(), returned.tasks.map((task) => task.id).sort());
93
+ const toolEnd = transcript.findLastIndex((entry) => entry.type === "tool_execution_end" && ["synchronous_subagent_fixture", "codemode"].includes(entry.toolName));
94
+ assert.ok(toolEnd >= 0 && transcript.indexOf(notifications[0]) > toolEnd, "the notification cannot interrupt the synchronous tool");
95
+ assert.match(message.content, /Ended at: \d{4}-/);
70
96
  const requests = records.filter((entry) => entry.event === "request");
71
- assert.equal(requests.length, mode === "direct" ? 3 : 2, "no extra model turns after the final report");
97
+ assert.equal(requests.length, 3, "no extra model turns after the final report");
72
98
  const nextContext = JSON.stringify(requests.at(-1).messages);
73
- assert.equal((nextContext.match(/\[Background task finished\]/g) ?? []).length, reads === 4 ? 0 : 1);
99
+ assert.equal((nextContext.match(/\[Background task finished\]/g) ?? []).length, 1);
74
100
  assert.doesNotMatch(nextContext, /FINAL_REPORT/, "completion is delivered before, not after, the final report");
75
101
  const contextText = requests.at(-1).messages.flatMap((message) => typeof message.content === "string" ? [message.content] : (message.content ?? []).filter((block) => block.type === "text").map((block) => block.text)).join("\n");
76
102
  for (const task of returned.tasks.filter((task) => !returned.consumed.includes(task.id))) assert.ok(contextText.includes(task.id));
@@ -126,15 +126,21 @@ try:
126
126
  assert loaded['tools'].count('background_task') == 1
127
127
  send('/schedule add 1h Check release status')
128
128
  wait(lambda: 'active schedule' in raw, 'Scheduler widget')
129
+ send('SMOKE_EXPOSURE')
130
+ wait(lambda: bool(tools('codemode')), 'Codemode exposure check')
131
+ assert 'BACKGROUND_CALL_BLOCKED' in str(tools('codemode')[-1]['result'])
132
+ assert not (work / 'should-not-exist').exists()
133
+ wait(lambda: any(e.get('event') == 'settled' for e in events()), 'exposure turn completion')
134
+ settled_count = len([e for e in events() if e.get('event') == 'settled'])
129
135
  send('SMOKE_START')
130
- wait(lambda: len(tools()) >= 3 and any(e.get('name') == 'codemode' for e in tools('codemode')), 'Codemode startup batch')
136
+ wait(lambda: len(tools()) >= 3, 'model startup batch')
131
137
  starts = tools()[:3]
132
138
  assert not starts[0]['isError'] and not starts[1]['isError'] and starts[2]['isError'], starts
133
139
  first = starts[0]['result']['structuredContent']['task']
134
140
  second = starts[1]['result']['structuredContent']['task']
135
141
  assert first['status'] == second['status'] == 'running'
136
- assert any('Script completed' in str(e.get('result')) for e in tools('codemode')), tools('codemode')
137
- assert not finished('succeeded'), 'Sleeping job must survive Codemode completion'
142
+ wait(lambda: len([e for e in events() if e.get('event') == 'settled']) > settled_count, 'foreground turn completion')
143
+ assert not finished('succeeded'), 'Sleeping job must survive foreground turn completion'
138
144
  shot('running')
139
145
  send('/bg output ' + first['id'])
140
146
  send('SMOKE_CANCEL')
@@ -176,7 +182,7 @@ try:
176
182
  assert all(not Path(path).exists() for path in logs), 'Session logs were not removed'
177
183
  (OUT / 'events.jsonl').write_text('\n'.join(json.dumps(e) for e in records) + '\n')
178
184
  (OUT / 'result.json').write_text(json.dumps({'passed': True, 'screenshots': screens,
179
- 'checks': ['two jobs', 'third rejected', 'Codemode returns while jobs live', 'Scheduler coexistence', '/bg output',
185
+ 'checks': ['two jobs', 'third rejected', 'Codemode background calls blocked', 'model returns while jobs live', 'Scheduler coexistence', '/bg output',
180
186
  'cancel notification', 'success notification', 'main agent notification', 'no duplicate persisted notifications',
181
187
  '5-second terminal row', 'reload cleanup', 'new-session cleanup', 'quit cleanup']}, indent=2))
182
188
  except Exception:
@@ -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);