@maxiaochao/pi-toolkit 0.11.4 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 2026-10-13 - v0.11.5
2
+
3
+ - bundle herdr-subagents v0.4.14: allow explicit extension sources with native Pi package semantics and reliable cleanup checks
4
+
1
5
  ## 2026-10-12 - v0.11.4
2
6
 
3
7
  - bundle herdr-subagents v0.4.13: fix stale-idle child startup race and require real Herdr E2E release smoke
@@ -8,6 +12,8 @@
8
12
 
9
13
  ## Unreleased
10
14
 
15
+ - Add opt-in Herdr Subagents persona extension sources, including npm/git Pi packages, with saved restart configuration and documented native package-resource semantics.
16
+ - Harden Herdr Subagents cleanup checks with condition-based waits, lock/focus regressions, and retained failure diagnostics; keep production cleanup behavior unchanged.
11
17
  - Fix Herdr Subagents startup failures caused by stale idle observations and require real child-Agent smoke tests before releasing that package.
12
18
 
13
19
  - Replace split child/root publishing with one idempotent root-tag workflow, make final-version local checks authoritative, keep CI publication-only, and remove timing-sensitive Herdr lifecycle assertions.
package/README.md CHANGED
@@ -39,6 +39,8 @@ Restart Pi after updating. The `todo` tool and widget will no longer load. `pi u
39
39
 
40
40
  Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
41
41
 
42
+ Herdr Subagents personas can opt into local extension files or npm/git Pi packages through `extensions`. This uses native Pi package semantics: package-provided skills, prompt templates, and themes may also load, and `--no-skills` does not exclude explicitly selected package skills. See the [extension-source contract](packages/herdr-subagents/README.md#explicit-extension-sources-and-pi-packages) for precedence, isolation, and examples.
43
+
42
44
  Multiple standalone children may be installed together. `pi-todo` may also be installed alongside the complete toolkit because Todo is no longer bundled. Do not install another bundled child alongside the complete toolkit because that exposes the same resource twice.
43
45
 
44
46
  To keep Todo after updating the toolkit:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
4
4
  "description": "Personal pi coding agent toolkit: scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -275,7 +275,7 @@ Merging them into one physical file would either put long prompts into JSON or r
275
275
 
276
276
  ### Sources of confusion to remove
277
277
 
278
- 1. Persona Markdown and JSON both expose `model`, `thinking`, and `skills`; this is intentional override layering but must be described as one resolution pipeline.
278
+ 1. Persona Markdown and JSON both expose `model`, `thinking`, `skills`, and `extensions`; this is intentional override layering but must be described as one resolution pipeline.
279
279
  2. Call-level task model overrides are resolved later in `tool.ts`, after configuration loading.
280
280
  3. TypeBox validates shape while `validation.ts` validates action-dependent requirements; these are complementary but currently look like duplicated validation.
281
281
  4. Downstream modules receive partially resolved values instead of one canonical run plan.
@@ -313,6 +313,7 @@ interface ResolvedRunPlan {
313
313
  thinking?: string;
314
314
  tools?: string[];
315
315
  skills: string[];
316
+ extensions: string[];
316
317
  systemPrompt: string;
317
318
  }>;
318
319
  provenance: Record<string, string>;
@@ -1,3 +1,13 @@
1
+ ## 2026-10-13 - v0.4.14
2
+
3
+ - allow explicit extension sources with native Pi package semantics and reliable cleanup checks
4
+ - Add persona `extensions` in Markdown and JSON with project → global → effective Markdown precedence, replacement arrays, and explicit clearing.
5
+ - Pass local files/directories and native npm/git/built-in sources to child Pi while retaining `--no-extensions` and mandatory child extensions.
6
+ - Persist sources for historical, failed/blocked, and queued-task restarts; expose effective sources in persona listing.
7
+ - Document that explicit Pi packages can load skills, prompts, and themes, including package skills despite `--no-skills`, without automatically broadening explicit tool allowlists.
8
+ - Add deterministic source/precedence/restart tests, a real Pi file/package loading check, and package-source coverage in the real concurrent Herdr smoke.
9
+ - Replace deferred-cleanup test sleeps with bounded condition waits; add a controlled lock-contention regression. Make real Herdr smoke respect deferred cleanup, optionally test focus/restore, and preserve failure traces and live tab/pane snapshots instead of deleting the evidence.
10
+
1
11
  ## 2026-10-12 - v0.4.13
2
12
 
3
13
  - fix stale-idle child startup race and require real Herdr E2E release smoke
@@ -160,7 +160,7 @@ These are all supported top-level fields in `herdr-subagents.json`:
160
160
  | `defaultThinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | Unset | Thinking level below persona-specific settings. Provider/model support can vary. If unset, parent thinking is inherited only when the parent model is also inherited. |
161
161
  | `personas` | Object keyed by an existing persona name | `{}` | Per-persona JSON overrides described below. This does not create a persona; a matching Markdown definition must exist. |
162
162
 
163
- Known fields with an invalid JSON type, an empty required string, or a non-positive integer are ignored with a diagnostic shown by `/herdr-subagents settings`. Model IDs, thinking-level compatibility, and skill availability are ultimately validated by Pi when the child starts. When `defaultConcurrency` exceeds `maxConcurrency`, the effective default is reduced to the maximum.
163
+ Known fields with an invalid JSON type, an empty required string, or a non-positive integer are ignored with a diagnostic shown by `/herdr-subagents settings`. Model IDs, thinking-level compatibility, and skill/extension source availability are ultimately handled by Pi when the child starts. When `defaultConcurrency` exceeds `maxConcurrency`, the effective default is reduced to the maximum.
164
164
 
165
165
  Each `personas.<name>` object supports exactly these overrides:
166
166
 
@@ -168,7 +168,8 @@ Each `personas.<name>` object supports exactly these overrides:
168
168
  | --- | --- | --- |
169
169
  | `model` | Non-empty Pi model string | Overrides the model from the effective persona Markdown and `defaultModel`. |
170
170
  | `thinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | Overrides persona Markdown and `defaultThinking`. The value is passed to Pi's `--thinking` option. |
171
- | `skills` | Array of non-empty strings | Replaces the entire lower-precedence skill list. Use `[]` to clear inherited skills. Each item is passed to child Pi as `--skill`. |
171
+ | `skills` | Array of non-empty strings | Replaces the entire lower-precedence explicit skill list. Use `[]` to clear that list. Each item is passed to child Pi as `--skill`. Package-provided skills are separate; see below. |
172
+ | `extensions` | Array of non-empty Pi source strings | Replaces the entire lower-precedence extension-source list. Use `[]` to clear it. Each item is passed as `--extension` after the mandatory child extensions. Accepts local files/directories, `npm:` and `git:` Pi packages, and `builtin:` sources. |
172
173
 
173
174
  JSON persona overrides cannot change `name`, `description`, `access`, `tools`, or the system prompt; define those in persona Markdown.
174
175
 
@@ -281,7 +282,49 @@ The child thinking level is selected in this order:
281
282
  4. effective `defaultThinking` JSON setting (project overrides global)
282
283
  5. parent Pi thinking level when the child also inherits the parent model
283
284
 
284
- Thinking has no call-level override. `skills` uses the first three persona-level steps and has no global default; a project `skills` array replaces, rather than appends to, the global or Markdown list.
285
+ Thinking has no call-level override. `skills` and `extensions` use the first three persona-level steps and have no global default. A project array replaces, rather than appends to, the global or effective Markdown list; `[]` explicitly clears it. There is no task-level extension override.
286
+
287
+ ### Explicit extension sources and Pi packages
288
+
289
+ For a local routing hook, add this frontmatter to a persona Markdown file (the hook must already exist):
290
+
291
+ ```md
292
+ ---
293
+ name: routed-explorer
294
+ description: Explorer with a provider routing hook
295
+ access: read
296
+ tools: read, bash, grep, find, ls, codemode
297
+ extensions: ~/.pi/agent/extensions/gateway-routing.ts
298
+ ---
299
+ Inspect the requested code and report your findings.
300
+ ```
301
+
302
+ Frontmatter accepts a single source or a comma-separated inline list, optionally enclosed in brackets. Use `extensions: []` to clear the list. Like the existing persona format, it does not support multiline YAML lists.
303
+
304
+ JSON accepts an array only. For example, override the built-in explorer with a local hook and an npm Pi package:
305
+
306
+ ```json
307
+ {
308
+ "personas": {
309
+ "explorer": {
310
+ "extensions": [
311
+ "~/.pi/agent/extensions/gateway-routing.ts",
312
+ "npm:@example/pi-tools@1.0.0"
313
+ ]
314
+ }
315
+ }
316
+ }
317
+ ```
318
+
319
+ `@example/pi-tools` is illustrative; replace it with a trusted Pi package. An ordinary npm library is not automatically a Pi extension package. Pin npm versions or git refs when reproducible restarts matter. Relative local paths resolve from the child working directory (the project root), **not** from the JSON or Markdown file. `~` resolves to the user's home directory. Source strings are passed to Pi without rewriting `npm:`, `git:`, or `builtin:` identifiers.
320
+
321
+ **This field uses Pi's native package-loading semantics. It is not an extensions-only resource filter.** A file loads that extension. A Pi package can also load its declared **skills, prompt templates, and themes**. In particular, explicitly selected package skills still load despite the child's `--no-skills`; persona `skills: []` clears only the explicit skill list, not skills carried by an extension package. To select one extension without the package's other resources, use its local extension-file path or a dedicated package that exposes only the intended resources. The string list does not support Pi's settings object-form resource filters.
322
+
323
+ Children still start with `--no-extensions`, then explicitly load `builtin:codemode`, the bundled `subagent-report`, and the configured sources in order. Unconfigured personas retain their existing behavior. The list does not inherit the parent's extensions, installed-package settings, or package resource filters. Pi manages source resolution, caching, dependency installation, and startup diagnostics; an npm/git source may require network access. Herdr Subagents does not implement a separate installer or guarantee that Pi's extension warnings terminate a task. Inspect the child's startup diagnostics when a required routing hook does not run.
324
+
325
+ Effective sources are saved in each task record and shown by persona listing. Historical resume, failed/blocked-task restarts, and queued-task recovery reuse the saved list instead of re-reading current persona overrides. A live child is not reloaded when configuration changes. Saving a source does not freeze its code: local files and unpinned package versions can change.
326
+
327
+ Load only trusted extension code. `access: read` controls scheduling; it is not a sandbox for extension hooks, which can perform side effects outside model tools. `PI_HERDR_SUBAGENTS_CHILD` prevents **this package** from registering recursively, not arbitrary third-party extensions. Loading a package does not broaden an explicit `tools` allowlist. Persona `tools` still accepts only the existing built-in names; adding extension-defined tools to that schema is a separate feature.
285
328
 
286
329
  Override the model for one task:
287
330
 
@@ -313,7 +356,7 @@ Queued background submissions are persisted before dispatch. Closing the parent
313
356
 
314
357
  Tab, pane, and child startup commands are not automatically retried: an error may arrive after Herdr already created the resource. Recovery is best effort, not complete: Herdr 0.9.2 has no idempotency key for tab creation or pane splitting, so a `SIGKILL` after the external resource is created but before its ID is durably persisted can leave an orphan that cannot be identified for cleanup. A failure after prompt submission is preserved and never automatically rerun. Historical follow-ups use a recoverable task-scoped `.resume.lock`, so different tasks can continue concurrently without allowing two writers for one saved session. Herdr exposes no retrying state, and Pi exposes no reliable public auto-retry hook, so `retrying` is supported by the runtime schema and Fleet but is not fabricated from ambiguous lifecycle events. `stalledWarningSeconds` emits a warning without killing long-running work. Run/task/report snapshots use atomic replacement so interruption cannot expose half-written JSON.
315
358
 
316
- The child does not inherit parent extensions, parent conversation, or unconfigured skills, but normal project context files still apply. It uses the same Pi installation, provider configuration, model catalog, and credentials as the parent.
359
+ The child does not inherit parent extensions or parent conversation. Skills load from explicit persona paths and from explicitly selected Pi packages; normal project context files still apply. It uses the same Pi installation, provider configuration, model catalog, and credentials as the parent.
317
360
 
318
361
  ## Development and tests
319
362
 
@@ -2,9 +2,11 @@
2
2
 
3
3
  ## Flow
4
4
 
5
- 1. `npm --prefix packages/herdr-subagents test` runs deterministic scheduler, recovery, and persona tests.
6
- 2. `pi -ne -ns --no-session -e ./packages/herdr-subagents/extensions/herdr-subagents.ts` verifies extension loading.
7
- 3. From a Pi session inside Herdr, run `npm --prefix packages/herdr-subagents run test:real-herdr`. This is a mandatory child-package and root-bundle release gate. It starts one real child Pi and then two concurrent read-only child Pi Agents, requires assistant events and matching attempt-scoped reports, and verifies final tab cleanup. `HERDR_SUBAGENTS_E2E_MODEL=provider/model` optionally pins the smoke model. Failed cleanup preserves the temporary durable records and fails the gate.
5
+ 1. `npm --prefix packages/herdr-subagents test` runs deterministic scheduler, recovery, and persona tests, then a controlled run-state-lock regression for deferred cleanup. The regression proves that the old 10 ms assertion can observe unfinished cleanup while both successful cleanup and error persistence eventually settle after lock release; it also checks that the condition waiter fails on timeout.
6
+ 2. `PI_TEST_BINARY="$(command -v pi)" npm --prefix packages/herdr-subagents run load-test` verifies extension contracts and loading, then starts real Pi with generated child arguments against temporary file/package fixtures. The package fixture must expose its skill and prompt despite discovery flags, while the explicit tool allowlist remains unchanged. This check makes no provider calls or network installs. `PI_TEST_BINARY` pins the tested CLI so npm cannot substitute an older workspace-local Pi binary; use a Pi version supporting native `builtin:codemode` and package sources.
7
+ 3. From a Pi session inside Herdr, run `npm --prefix packages/herdr-subagents run test:real-herdr`. This is a mandatory child-package and root-bundle release gate. It starts one unconfigured real child Pi and then two concurrent read-only child Pi Agents with an explicit local Pi package. It requires assistant events, matching attempt-scoped reports, saved extension sources, a package-extension startup marker, package skill availability despite `--no-skills`, and final tab cleanup. `HERDR_SUBAGENTS_E2E_MODEL=provider/model` optionally pins the smoke model. The check waits up to five seconds for pane removal and the durable cleanup commit; a recorded `cleanupError` fails immediately instead of being retried away. A failed assertion or runtime check preserves the temporary durable records, `trace.json`, and `failure.json` with tab/pane snapshots. Teardown errors are retained separately and fail the gate; already-missing tabs are successful cleanup.
8
+
9
+ For an intentional focus regression, run `HERDR_SUBAGENTS_E2E_FOCUS_CLEANUP=1 HERDR_SUBAGENTS_E2E_MODEL=provider/model npm --prefix packages/herdr-subagents run test:real-herdr`. It briefly focuses only the concurrent test's tab at cleanup, requires `cleanupPendingTabIds`, restores the globally focused workspace and its active tab, asserts that focus is still restored after cleanup, then verifies automatic cleanup without manually closing the test tab. Run this only when brief visible focus changes are acceptable; it requires an existing focused tab to restore.
8
10
  4. From a dedicated visible X11 Herdr client/window with the GStreamer X11 and VP8 plugins installed, run `HERDR_MEDIA_WINDOW_ID=<window-id> node scripts/capture-package-media.mjs`. Capture intentionally cycles through the live child panes, so avoid using that client/window for other work while recording; the script restores the previously focused pane afterward when it still exists. The Herdr capture must show a parent Pi conversation, a three-task run tab, each of the three real interactive Agent panes receiving focus in turn, the active parent Fleet footer with yellow running indicators and real metrics, and the final compact parent response. The published screenshot must be the parent Fleet view, while the full WebM preserves the parent → three focused children → Fleet → parent sequence.
9
11
  5. `npm pack ./packages/herdr-subagents --dry-run` verifies package contents.
10
12
  6. Verify the README renders the GitHub attachment video directly and live Herdr UI media `docs/screenshot.png` and `docs/demo.webm` are present in the tarball; `docs/demo.gif` must be absent. Inspect the WebM metadata and play it through once to confirm the parent → three focused children → Fleet → parent sequence.
@@ -12,6 +14,7 @@
12
14
  ## Cases
13
15
 
14
16
  - Resolve package, global, and project personas and validate frontmatter/configuration, including the neutral read-only `analyst` persona.
17
+ - Verify extension-source Markdown parsing, JSON type diagnostics, precedence, trimming, replacement/empty lists, unchanged unconfigured startup, local/npm source argument preservation, persona listing, durable history, historical resume, blocked-task restart, and queued-task recovery.
15
18
  - Run foreground tasks and FIFO bounded-concurrency batches through an injectable Herdr boundary.
16
19
  - Simulate one fast failure beside a controlled slow success and verify the foreground call returns the stable run ID and failure before releasing the slow task, detaches caller cancellation without aborting that task, releases the dispatcher so the next batch starts before the slow task settles, removes durable queue state, and emits exactly one final follow-up.
17
20
  - Verify a detached dispatcher promise can settle normally after its successor starts without clearing or otherwise disturbing the successor's active slot, while blocked foreground runs retain their existing dispatcher ownership.
@@ -37,6 +40,7 @@
37
40
  - Simulate a false screen-settled return while the authoritative Pi lifecycle remains working, then write the report later and verify the task completes without a transient failed state.
38
41
  - Verify reports are bound to unique attempt IDs, blocked results are not tool errors, duplicate blocked responses are serialized, mixed failed/cancelled aggregates remain partial, and cleanup failures never overwrite business outcomes.
39
42
  - Verify settling a batch merges run state written concurrently under the run lock instead of overwriting `run.json` from a stale in-memory copy.
43
+ - Wait for deferred cleanup commits/notifications rather than fixed sleeps. Hold the real run-state lock across the old assertion point to reproduce both successful-cleanup and error-persistence races, then require the expected durable state after release. Real smoke failures retain evidence before teardown.
40
44
  - Verify an explicitly named failed task continues from its saved Pi session: it reuses its own live pane while its batch still runs, restarts in a new pane when that pane is gone, refuses to continue without a saved session, is never selected by an unqualified response, and does not start queued siblings that the running batch still owns.
41
45
  - Verify a settling batch adopts the task records on disk, so a continued task keeps its run `running` and its Herdr tab open instead of being overwritten by the batch's own stale view.
42
46
  - Verify a continuation claims its task before touching any pane, so a sibling continuation that finishes first leaves the shared tab open, and only the last one to settle closes it.
@@ -46,4 +50,4 @@
46
50
 
47
51
  ## Latest baseline
48
52
 
49
- All 246 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, lifecycle, attempt-isolation, and persona cases plus the extension-contract lifecycle simulations must pass. The startup regression test reproduces the real ordering where `before_agent_start` records the attempt as working while Herdr can still report stale idle. Every release also runs the real single-Agent and concurrent read-only Herdr smoke; a skipped smoke is not a passing release check.
53
+ All 266 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, lifecycle, attempt-isolation, and persona cases, the controlled cleanup-timing regression, and the extension-contract lifecycle simulations must pass. The startup regression test reproduces the real ordering where `before_agent_start` records the attempt as working while Herdr can still report stale idle. Every release also runs the real single-Agent and concurrent read-only Herdr smoke; a skipped smoke is not a passing release check.
@@ -88,7 +88,7 @@ async function handleHerdrCommand(args: string, ctx: ExtensionCommandContext, fl
88
88
  }
89
89
  const configuration = loadSubagentConfiguration(ctx.cwd, packageAgentsDir, ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
90
90
  if (section === "agents") {
91
- ctx.ui.notify(configuration.personas.map((persona) => `${persona.name}: ${persona.source}, ${persona.access}, model=${persona.model ?? "unresolved"} (${persona.modelSource}), thinking=${persona.thinking ?? "default"}, tools=${persona.tools?.join(",") ?? "default"}, skills=${persona.skills.join(",") || "none"}`).join("\n"), "info");
91
+ ctx.ui.notify(configuration.personas.map((persona) => `${persona.name}: ${persona.source}, ${persona.access}, model=${persona.model ?? "unresolved"} (${persona.modelSource}), thinking=${persona.thinking ?? "default"}, tools=${persona.tools?.join(",") ?? "default"}, skills=${persona.skills.join(",") || "none"}, extensions=${persona.extensions.join(",") || "none"} (${persona.extensionsSource})`).join("\n"), "info");
92
92
  return;
93
93
  }
94
94
  if (section === "settings") {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -37,9 +37,9 @@
37
37
  "typebox": "*"
38
38
  },
39
39
  "scripts": {
40
- "test": "node --experimental-strip-types tests/run-tests.mjs",
40
+ "test": "node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types tests/cleanup-timing.mjs",
41
41
  "test:real-herdr": "node --experimental-strip-types tests/real-herdr-smoke.mjs",
42
- "load-test": "env -u PI_HERDR_SUBAGENTS_CHILD pi -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD pi -ne -ns --no-session -e ./extensions/herdr-subagents.ts"
42
+ "load-test": "env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./extensions/herdr-subagents.ts && node --experimental-strip-types tests/extension-sources-load.mjs"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public"
@@ -27,6 +27,7 @@ export interface BatchTask {
27
27
  thinking?: string;
28
28
  tools?: string[];
29
29
  skills: string[];
30
+ extensions?: string[];
30
31
  systemPrompt: string;
31
32
  }
32
33
 
@@ -103,7 +104,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
103
104
  const taskRecord: TaskRecord = {
104
105
  id: taskId, runId, order: index + 1, name: task.name, status: "queued", prompt: task.prompt,
105
106
  agent: task.agent, access: task.access, model: task.model, thinking: task.thinking,
106
- tools: task.tools, skills: task.skills, systemPrompt: reportProtocolPrompt(task.systemPrompt), sessionFile,
107
+ tools: task.tools, skills: task.skills, extensions: task.extensions, systemPrompt: reportProtocolPrompt(task.systemPrompt), sessionFile,
107
108
  };
108
109
  await mkdir(taskDirectory, { recursive: true, mode: 0o700 });
109
110
  await writeFile(systemPromptFile, String(taskRecord.systemPrompt), { encoding: "utf8", mode: 0o600 });
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { discoverPackageAgents, type AgentDefinition } from "./personas.ts";
5
5
 
6
6
  export type PersonaAccess = "read" | "write";
7
- export interface PersonaConfig { model?: string; thinking?: string; skills?: string[]; }
7
+ export interface PersonaConfig { model?: string; thinking?: string; skills?: string[]; extensions?: string[]; }
8
8
  export interface SubagentConfig {
9
9
  defaultConcurrency: number;
10
10
  maxConcurrency: number;
@@ -22,6 +22,8 @@ export interface EffectivePersona extends AgentDefinition {
22
22
  thinkingSource: string;
23
23
  skills: string[];
24
24
  skillsSource: string;
25
+ extensions: string[];
26
+ extensionsSource: string;
25
27
  }
26
28
  export interface SubagentConfiguration {
27
29
  settings: SubagentConfig;
@@ -83,9 +85,11 @@ function mergeConfig(base: SubagentConfig, input: ConfigFile, source: string, so
83
85
  if (typeof value === "string" && value.trim()) valid[field] = value.trim();
84
86
  else diagnostics.push(`${source} persona '${name}' ${field} must be a non-empty string`);
85
87
  }
86
- if (raw.skills !== undefined) {
87
- if (Array.isArray(raw.skills) && raw.skills.every((value) => typeof value === "string" && value.trim())) valid.skills = raw.skills.map((value) => value.trim());
88
- else diagnostics.push(`${source} persona '${name}' skills must be an array of non-empty strings`);
88
+ for (const field of ["skills", "extensions"] as const) {
89
+ const value = raw[field];
90
+ if (value === undefined) continue;
91
+ if (Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim())) valid[field] = value.map((item) => item.trim());
92
+ else diagnostics.push(`${source} persona '${name}' ${field} must be an array of non-empty strings`);
89
93
  }
90
94
  input.personas[name] = valid;
91
95
  result.personas[name] = { ...(result.personas[name] ?? {}), ...valid };
@@ -123,7 +127,9 @@ export function loadSubagentConfiguration(cwd: string, packageAgentsDirectory: s
123
127
  const thinkingSource = projectPersona.thinking ? "project persona" : globalPersona.thinking ? "global persona" : definition.thinking ? `${source} persona` : settings.defaultThinking ? settingSources.defaultThinking : "default";
124
128
  const skills = projectPersona.skills ?? globalPersona.skills ?? definition.skills ?? [];
125
129
  const skillsSource = projectPersona.skills ? "project persona" : globalPersona.skills ? "global persona" : definition.skills ? `${source} persona` : "none";
126
- return { ...definition, source, access: definition.access ?? "write", model, modelSource, thinking, thinkingSource, skills, skillsSource };
130
+ const extensions = projectPersona.extensions ?? globalPersona.extensions ?? definition.extensions ?? [];
131
+ const extensionsSource = projectPersona.extensions ? "project persona" : globalPersona.extensions ? "global persona" : definition.extensions ? `${source} persona` : "none";
132
+ return { ...definition, source, access: definition.access ?? "write", model, modelSource, thinking, thinkingSource, skills, skillsSource, extensions, extensionsSource };
127
133
  }).sort((left, right) => left.name.localeCompare(right.name));
128
134
  return { settings, settingSources, personas, diagnostics };
129
135
  }
@@ -7,7 +7,7 @@ import { isActiveStatus, readReport, readRunRecord, readTaskRecord, type Persist
7
7
  import { findRunDirectory } from "./run-locator.ts";
8
8
  import { resumeHistoricalTaskConcurrent } from "./historical-resume.ts";
9
9
 
10
- export interface HistoricalTask { order: number; id: string; name: string; agent: string; status: TaskStatus; summary?: string; question?: string; documents: PersistedDocument[]; recordDirectory: string; sessionFile: string; model?: string; thinking?: string; tools?: string[]; skills: string[]; access?: string; }
10
+ export interface HistoricalTask { order: number; id: string; name: string; agent: string; status: TaskStatus; summary?: string; question?: string; documents: PersistedDocument[]; recordDirectory: string; sessionFile: string; model?: string; thinking?: string; tools?: string[]; skills: string[]; extensions?: string[]; access?: string; }
11
11
  export interface HistoricalRun { id: string; label: string; status: RunStatus; startedAt: string; completedAt?: string; effectiveConcurrency?: number; cleanupPendingTabIds?: string[]; tasks: HistoricalTask[]; recordDirectory: string; }
12
12
 
13
13
  export async function ensureRuntimeIgnored(cwd: string): Promise<void> {
@@ -66,7 +66,7 @@ export async function listSubagentHistory(cwd: string): Promise<HistoricalRun[]>
66
66
  historyStatus = "failed";
67
67
  report = { summary: `Missing structured report: ${reportFile}`, documents: [] };
68
68
  }
69
- tasks.push({ order: task.order ?? tasks.length + 1, id: task.id, name: task.name ?? task.agent ?? "worker", agent: task.agent ?? "worker", status: historyStatus, summary: report.summary, question: report.question, documents: report.documents ?? [], recordDirectory: taskDirectory, sessionFile: task.sessionFile, model: task.model, thinking: task.thinking, tools: task.tools, skills: task.skills ?? [], access: task.access });
69
+ tasks.push({ order: task.order ?? tasks.length + 1, id: task.id, name: task.name ?? task.agent ?? "worker", agent: task.agent ?? "worker", status: historyStatus, summary: report.summary, question: report.question, documents: report.documents ?? [], recordDirectory: taskDirectory, sessionFile: task.sessionFile, model: task.model, thinking: task.thinking, tools: task.tools, skills: task.skills ?? [], extensions: task.extensions ?? [], access: task.access });
70
70
  }
71
71
  history.push({ id: run.id, label: run.label, status: projectedStatus, startedAt: run.startedAt, completedAt: run.completedAt, effectiveConcurrency: run.effectiveConcurrency, cleanupPendingTabIds: run.cleanupPendingTabIds, tasks, recordDirectory: directory });
72
72
  } catch (error) {
@@ -16,6 +16,7 @@ export interface LiveTaskRecord {
16
16
  thinking?: string;
17
17
  tools?: string[];
18
18
  skills?: string[];
19
+ extensions?: string[];
19
20
  currentAttemptId?: string;
20
21
  }
21
22
 
@@ -41,6 +42,7 @@ export function childPiArgs(task: LiveTaskRecord, systemPromptFile: string): str
41
42
  "--no-skills",
42
43
  "--append-system-prompt", systemPromptFile,
43
44
  ];
45
+ for (const extension of task.extensions ?? []) args.push("--extension", extension);
44
46
  if (task.model) args.push("--model", task.model);
45
47
  if (task.thinking) args.push("--thinking", task.thinking);
46
48
  if (task.tools?.length) args.push("--tools", [...new Set([...task.tools, "codemode", "subagent_report"])].join(","));
@@ -21,6 +21,7 @@ export interface AgentDefinition {
21
21
  access?: "read" | "write";
22
22
  thinking?: string;
23
23
  skills?: string[];
24
+ extensions?: string[];
24
25
  systemPrompt: string;
25
26
  filePath: string;
26
27
  }
@@ -79,6 +80,9 @@ export function parseAgentMarkdown(content: string, filePath = "<memory>"): Agen
79
80
  const access = rawAccess as "read" | "write" | undefined;
80
81
  const thinking = fields.get("thinking")?.trim() || undefined;
81
82
  const skills = fields.has("skills") ? parseList(fields.get("skills") ?? "") : undefined;
83
+ const rawExtensions = fields.get("extensions");
84
+ const extensions = rawExtensions?.trim() === "[]" ? [] : rawExtensions === undefined ? undefined : parseList(rawExtensions);
85
+ if (rawExtensions !== undefined && !extensions) throw new Error("frontmatter extensions must contain non-empty sources or []");
82
86
  return {
83
87
  name,
84
88
  description,
@@ -87,6 +91,7 @@ export function parseAgentMarkdown(content: string, filePath = "<memory>"): Agen
87
91
  ...(access ? { access } : {}),
88
92
  ...(thinking ? { thinking } : {}),
89
93
  ...(skills ? { skills } : {}),
94
+ ...(extensions ? { extensions } : {}),
90
95
  systemPrompt: lines.slice(end + 1).join("\n").trim(),
91
96
  filePath,
92
97
  };
@@ -83,6 +83,7 @@ export interface TaskRecord {
83
83
  thinking?: string;
84
84
  tools?: string[];
85
85
  skills?: string[];
86
+ extensions?: string[];
86
87
  systemPrompt?: string;
87
88
  sessionFile?: string;
88
89
  startedAt?: string;
@@ -73,7 +73,7 @@ export function listText(discovery: SubagentConfiguration): string {
73
73
  const model = agent.model ?? "unresolved";
74
74
  const tools = agent.tools?.join(", ") ?? "Pi default built-in tools";
75
75
  const skills = agent.skills.join(", ") || "none";
76
- lines.push(`- ${agent.name}: ${agent.description} (source: ${agent.source}; access: ${agent.access}; model: ${model} [${agent.modelSource}]; thinking: ${agent.thinking ?? "default"}; tools: ${tools}; skills: ${skills})`);
76
+ lines.push(`- ${agent.name}: ${agent.description} (source: ${agent.source}; access: ${agent.access}; model: ${model} [${agent.modelSource}]; thinking: ${agent.thinking ?? "default"}; tools: ${tools}; skills: ${skills}; extensions: ${agent.extensions.join(", ") || "none"} [${agent.extensionsSource}])`);
77
77
  }
78
78
  if (discovery.diagnostics.length > 0) {
79
79
  lines.push("", "Persona diagnostics:", ...discovery.diagnostics.map((diagnostic) => `- ${diagnostic}`));
@@ -184,7 +184,7 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
184
184
  const personaName = task.agent?.trim() || "worker";
185
185
  const persona = discovery.personas.find((candidate) => candidate.name === personaName);
186
186
  if (!persona) return errorResponse(`Unknown persona '${personaName}'.\n\n${listText(discovery)}`);
187
- resolved.push({ name: task.name, prompt: task.prompt, agent: persona.name, access: persona.access, model: task.model?.trim() || persona.model, thinking: persona.thinking ?? (persona.modelSource === "parent session" && !task.model ? ctx.thinkingLevel : undefined), tools: persona.tools, skills: persona.skills, systemPrompt: persona.systemPrompt });
187
+ resolved.push({ name: task.name, prompt: task.prompt, agent: persona.name, access: persona.access, model: task.model?.trim() || persona.model, thinking: persona.thinking ?? (persona.modelSource === "parent session" && !task.model ? ctx.thinkingLevel : undefined), tools: persona.tools, skills: persona.skills, extensions: persona.extensions, systemPrompt: persona.systemPrompt });
188
188
  }
189
189
  try {
190
190
  const result = await runHerdrSubagentsBatch(resolved, {
@@ -0,0 +1,61 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { cleanupRunTab } from "../src/tab-cleanup.ts";
6
+ import { acquireDirectoryLock } from "../src/run-state.ts";
7
+ import { writeJsonAtomic } from "../src/state.ts";
8
+ import { waitFor } from "./wait-for.mjs";
9
+
10
+ const root = mkdtempSync(join(tmpdir(), "pi-herdr-cleanup-timing-"));
11
+ try {
12
+ await assert.rejects(waitFor(() => false, "controlled missing completion", 20), /Timed out.*controlled missing completion/);
13
+ for (const failClose of [false, true]) {
14
+ const runFile = join(root, "run.json");
15
+ const run = { id: "cleanup-timing", label: "cleanup-timing", status: "completed" };
16
+ await writeJsonAtomic(runFile, run);
17
+ let leaveFocus;
18
+ const focus = new Promise((resolve) => { leaveFocus = resolve; });
19
+ let closeAttempted = false;
20
+ const herdr = {
21
+ async isTabFocused() { return true; },
22
+ async waitForTabUnfocused() { await focus; },
23
+ async closeTab() {
24
+ closeAttempted = true;
25
+ if (failClose) throw new Error("controlled close failure");
26
+ },
27
+ };
28
+ const cleanup = await cleanupRunTab({ herdr, tabId: "test-tab", runFile, runRecord: run });
29
+ assert.equal(cleanup.deferred, true);
30
+ const lock = await acquireDirectoryLock(join(root, ".state.lock"), { wait: true });
31
+ try {
32
+ leaveFocus();
33
+ await waitFor(() => closeAttempted, "controlled close attempt");
34
+ await new Promise((resolve) => setTimeout(resolve, 10));
35
+ const premature = JSON.parse(readFileSync(runFile, "utf8"));
36
+ assert.deepEqual(premature.cleanupPendingTabIds, ["test-tab"]);
37
+ assert.equal(premature.cleanupError, undefined);
38
+ console.log(`REPRO ${failClose ? "error persistence" : "successful cleanup"}: at the old 10 ms assertion point, the legitimate run-state lock keeps cleanup pending`);
39
+ } finally {
40
+ await lock.release();
41
+ }
42
+ const settled = await waitFor(() => {
43
+ const record = JSON.parse(readFileSync(runFile, "utf8"));
44
+ return (failClose ? record.cleanupError : !record.cleanupPendingTabIds) ? record : false;
45
+ }, "cleanup state commit");
46
+ assert.equal(settled.status, "completed");
47
+ if (failClose) {
48
+ assert.equal(settled.cleanupError, "controlled close failure");
49
+ assert.deepEqual(settled.cleanupPendingTabIds, ["test-tab"]);
50
+ } else {
51
+ assert.equal(settled.cleanupPendingTabIds, undefined);
52
+ assert.equal(settled.cleanupError, undefined);
53
+ }
54
+ // Wait for the detached chain's lock release before reusing or deleting its record.
55
+ const joined = await acquireDirectoryLock(join(root, ".state.lock"), { wait: true });
56
+ await joined.release();
57
+ console.log(`PASS ${failClose ? "error persistence" : "successful cleanup"} settles after the lock releases`);
58
+ }
59
+ } finally {
60
+ rmSync(root, { recursive: true, force: true });
61
+ }
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { childPiArgs } from "../src/live-agent.ts";
7
+
8
+ // Exercise native Pi package semantics without provider calls or network installs.
9
+ const root = mkdtempSync(join(tmpdir(), "pi-herdr-extension-sources-"));
10
+ try {
11
+ const packageDirectory = join(root, "local package");
12
+ mkdirSync(join(packageDirectory, "skills", "package-skill"), { recursive: true });
13
+ mkdirSync(join(packageDirectory, "prompts"), { recursive: true });
14
+ writeFileSync(join(packageDirectory, "package.json"), JSON.stringify({
15
+ name: "extension-source-fixture",
16
+ pi: { extensions: ["extension.ts"], skills: ["skills"], prompts: ["prompts"] },
17
+ }));
18
+ writeFileSync(join(packageDirectory, "extension.ts"), `export default function (pi) {
19
+ pi.registerCommand("package-extension", { description: "Package extension loaded", handler() {} });
20
+ }`);
21
+ writeFileSync(join(packageDirectory, "skills", "package-skill", "SKILL.md"), "---\nname: package-skill\ndescription: Package skill loading probe\n---\n# Probe\n");
22
+ writeFileSync(join(packageDirectory, "prompts", "package-prompt.md"), "Package prompt loading probe.");
23
+ const fileExtension = join(root, "file extension.ts");
24
+ writeFileSync(fileExtension, `export default function (pi) {
25
+ pi.registerCommand("file-extension", { description: "File extension loaded", handler() {} });
26
+ }`);
27
+ const probe = join(root, "probe.ts");
28
+ const resultPath = join(root, "result.json");
29
+ writeFileSync(probe, `import { writeFileSync } from "node:fs";
30
+ export default function (pi) {
31
+ pi.on("session_start", (_event, ctx) => {
32
+ writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({
33
+ commands: pi.getCommands().map((command) => command.name),
34
+ tools: pi.getActiveTools(),
35
+ }));
36
+ ctx.shutdown();
37
+ });
38
+ }`);
39
+ const args = childPiArgs({ id: "probe", sessionFile: join(root, "session.jsonl"), tools: ["read"], extensions: [fileExtension, "./local package", probe] }, join(root, "system.md"));
40
+ writeFileSync(join(root, "system.md"), "Extension source loading probe.");
41
+ const env = { ...process.env, PI_CODING_AGENT_DIR: join(root, "agent"), PI_HERDR_SUBAGENTS_CHILD: "1" };
42
+ for (const key of Object.keys(env)) if (key.startsWith("HERDR_") || key === "PI_HERDR_SUBAGENTS_TASK_DIR" || key === "PI_HERDR_SUBAGENTS_PARENT_PANE_ID") delete env[key];
43
+ const result = spawnSync(process.env.PI_TEST_BINARY?.trim() || "pi", [...args, "--offline", "--no-mcp"], { cwd: root, env, encoding: "utf8", timeout: 30_000 });
44
+ assert.equal(result.error, undefined, String(result.error));
45
+ assert.equal(result.status, 0, result.stderr || result.stdout);
46
+ const loaded = JSON.parse(readFileSync(resultPath, "utf8"));
47
+ for (const command of ["file-extension", "package-extension", "skill:package-skill", "package-prompt", "parent"]) assert.ok(loaded.commands.includes(command), `Missing ${command}: ${JSON.stringify(loaded)}`);
48
+ assert.deepEqual([...loaded.tools].sort(), ["codemode", "read", "subagent_report"]);
49
+ console.log("PASS real Pi loads explicit file and package sources, including package skills/prompts despite discovery flags, without broadening the tool allowlist");
50
+ } finally {
51
+ rmSync(root, { recursive: true, force: true });
52
+ }
@@ -1,10 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
+ import { spawnSync } from "node:child_process";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { executeHerdrSubagents } from "../src/tool.ts";
7
8
  import { CliHerdrAutomation } from "../src/herdr.ts";
9
+ import { waitFor } from "./wait-for.mjs";
8
10
 
9
11
  if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) {
10
12
  throw new Error("The real Herdr smoke test must run from a Pi session inside Herdr.");
@@ -14,6 +16,37 @@ const packageDirectory = dirname(dirname(fileURLToPath(import.meta.url)));
14
16
  const root = mkdtempSync(join(tmpdir(), "pi-herdr-subagents-real-"));
15
17
  const herdr = new CliHerdrAutomation();
16
18
  const tabIds = new Set();
19
+ const paneIds = new Set();
20
+ const trace = [];
21
+ const startedAt = performance.now();
22
+ let failed = false;
23
+ let controlledFocus;
24
+ function record(event) {
25
+ trace.push({ elapsedMs: Math.round(performance.now() - startedAt), ...event });
26
+ writeFileSync(join(root, "trace.json"), JSON.stringify(trace, null, 2));
27
+ }
28
+ for (const method of ["createTab", "splitPane", "startAgent", "isTabFocused", "waitForTabUnfocused", "closeTab", "paneExists"]) {
29
+ const original = herdr[method].bind(herdr);
30
+ herdr[method] = async (...args) => {
31
+ if (method === "isTabFocused" && controlledFocus && !controlledFocus.triggered && tabIds.has(args[0])) {
32
+ const focused = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["tab", "focus", args[0]], { encoding: "utf8", timeout: 10_000 });
33
+ assert.equal(focused.status, 0, focused.stderr);
34
+ controlledFocus.triggered = true;
35
+ record({ event: "controlled-focus", tabId: args[0], previousWorkspaceId: controlledFocus.previousWorkspaceId, previousTabId: controlledFocus.previousTabId });
36
+ }
37
+ record({ method, phase: "start", args: args.map((arg) => typeof arg === "object" ? { ...arg, signal: undefined } : arg) });
38
+ try {
39
+ const result = await original(...args);
40
+ if (result?.tabId) tabIds.add(result.tabId);
41
+ if (result?.paneId) paneIds.add(result.paneId);
42
+ record({ method, phase: "end", result });
43
+ return result;
44
+ } catch (error) {
45
+ record({ method, phase: "error", error: String(error), stderr: error.stderr });
46
+ throw error;
47
+ }
48
+ };
49
+ }
17
50
  const model = process.env.HERDR_SUBAGENTS_E2E_MODEL?.trim();
18
51
  const configurationPaths = {
19
52
  globalConfig: join(root, "global-config.json"),
@@ -30,7 +63,45 @@ function hasAssistantMessage(path) {
30
63
  });
31
64
  }
32
65
 
33
- async function runCase(label, names, concurrency) {
66
+ function restoreTestFocus() {
67
+ if (!controlledFocus?.triggered || controlledFocus.restored) return;
68
+ const restored = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["tab", "focus", controlledFocus.previousTabId], { encoding: "utf8", timeout: 10_000 });
69
+ assert.equal(restored.status, 0, restored.stderr);
70
+ const workspace = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["workspace", "focus", controlledFocus.previousWorkspaceId], { encoding: "utf8", timeout: 10_000 });
71
+ assert.equal(workspace.status, 0, workspace.stderr);
72
+ controlledFocus.restored = true;
73
+ assertRestoredFocus();
74
+ record({ event: "restored-focus", workspaceId: controlledFocus.previousWorkspaceId, tabId: controlledFocus.previousTabId });
75
+ }
76
+
77
+ function assertRestoredFocus() {
78
+ const result = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["workspace", "list"], { encoding: "utf8", timeout: 10_000 });
79
+ assert.equal(result.status, 0, result.stderr);
80
+ const focused = JSON.parse(result.stdout).result.workspaces.find((workspace) => workspace.focused);
81
+ assert.equal(focused?.workspace_id, controlledFocus.previousWorkspaceId, "The original workspace focus was not restored");
82
+ assert.equal(focused?.active_tab_id, controlledFocus.previousTabId, "The original tab focus was not restored");
83
+ }
84
+
85
+ async function waitForCleanup(result) {
86
+ const signal = AbortSignal.timeout(5_000);
87
+ await waitFor(async () => {
88
+ const run = JSON.parse(readFileSync(join(result.recordDirectory, "run.json"), "utf8"));
89
+ if (run.cleanupError) throw new Error(`Cleanup failed: ${run.cleanupError}`);
90
+ const survivingPaneIds = [];
91
+ for (const task of result.tasks) if (await herdr.paneExists(task.paneId, signal)) survivingPaneIds.push(task.paneId);
92
+ record({ event: "cleanup-observation", run, survivingPaneIds });
93
+ return !run.cleanupPendingTabIds?.length && survivingPaneIds.length === 0 && !existsSync(join(result.recordDirectory, ".state.lock"));
94
+ }, `cleanup of ${result.tabId}`, 5_000);
95
+ }
96
+
97
+ async function runCase(label, names, concurrency, extensions = []) {
98
+ if (concurrency > 1 && process.env.HERDR_SUBAGENTS_E2E_FOCUS_CLEANUP === "1") {
99
+ const workspaces = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["workspace", "list"], { encoding: "utf8", timeout: 10_000 });
100
+ assert.equal(workspaces.status, 0, workspaces.stderr);
101
+ const previous = JSON.parse(workspaces.stdout).result.workspaces.find((workspace) => workspace.focused);
102
+ assert.ok(previous?.active_tab_id, "The focused cleanup probe needs an existing focused tab to restore");
103
+ controlledFocus = { previousWorkspaceId: previous.workspace_id, previousTabId: previous.active_tab_id, triggered: false, restored: false };
104
+ }
34
105
  const response = await executeHerdrSubagents({
35
106
  label,
36
107
  concurrency,
@@ -42,6 +113,7 @@ async function runCase(label, names, concurrency) {
42
113
  })),
43
114
  }, timeout, (update) => {
44
115
  if (update.details?.tabId) tabIds.add(update.details.tabId);
116
+ if (update.details?.cleanupError) record({ event: "cleanup-warning", details: update.details, content: update.content });
45
117
  }, { cwd: root }, {
46
118
  agentsDirectory: join(packageDirectory, "agents"),
47
119
  configurationPaths,
@@ -50,16 +122,33 @@ async function runCase(label, names, concurrency) {
50
122
 
51
123
  const result = response.details;
52
124
  if (result?.tabId) tabIds.add(result.tabId);
125
+ record({ event: "case-result", label, result, run: result?.recordDirectory ? JSON.parse(readFileSync(join(result.recordDirectory, "run.json"), "utf8")) : undefined });
53
126
  assert.equal(response.isError, undefined, response.content?.[0]?.text);
54
127
  assert.equal(result.status, "completed");
55
128
  assert.equal(result.effectiveConcurrency, concurrency);
56
129
  assert.equal(result.tasks.length, names.length);
57
130
  assert.equal(new Set(result.tasks.map((task) => task.paneId)).size, names.length);
58
131
 
132
+ if (controlledFocus) {
133
+ assert.equal(controlledFocus.triggered, true, "The focus probe did not reach cleanup");
134
+ const run = JSON.parse(readFileSync(join(result.recordDirectory, "run.json"), "utf8"));
135
+ assert.ok(run.cleanupPendingTabIds?.includes(result.tabId), "Focused cleanup was not deferred");
136
+ restoreTestFocus();
137
+ }
138
+ await waitForCleanup(result);
139
+ if (controlledFocus) {
140
+ assertRestoredFocus();
141
+ console.log("PASS focused cleanup: deferred while focused, original workspace/tab focus restored, automatic cleanup observed");
142
+ }
59
143
  for (const taskResult of result.tasks) {
60
144
  const task = JSON.parse(readFileSync(join(taskResult.recordDirectory, "task.json"), "utf8"));
61
145
  const report = JSON.parse(readFileSync(join(taskResult.recordDirectory, "report.json"), "utf8"));
62
146
  assert.equal(task.status, "completed");
147
+ assert.deepEqual(task.extensions, extensions);
148
+ if (extensions.length) {
149
+ const commands = JSON.parse(readFileSync(join(taskResult.recordDirectory, "extension-loaded.json"), "utf8"));
150
+ assert.ok(commands.includes("skill:smoke-package-skill"), "Explicit package skill did not load in the real child");
151
+ }
63
152
  assert.equal(report.status, "completed");
64
153
  assert.equal(report.attemptId, task.currentAttemptId);
65
154
  assert.equal(hasAssistantMessage(task.sessionFile), true, `${taskResult.name} produced no assistant event`);
@@ -70,8 +159,38 @@ async function runCase(label, names, concurrency) {
70
159
 
71
160
  try {
72
161
  await runCase("real-single", ["hello-single"], 1);
73
- await runCase("real-concurrent", ["hello-one", "hello-two"], 2);
162
+ const packageSource = join(root, "local-package");
163
+ mkdirSync(join(packageSource, "skills", "smoke-package-skill"), { recursive: true });
164
+ writeFileSync(join(packageSource, "package.json"), JSON.stringify({ name: "herdr-smoke-package", pi: { extensions: ["extension.ts"], skills: ["skills"] } }));
165
+ writeFileSync(join(packageSource, "skills", "smoke-package-skill", "SKILL.md"), "---\nname: smoke-package-skill\ndescription: A real Herdr package-loading probe\n---\n# Probe\n");
166
+ writeFileSync(join(packageSource, "extension.ts"), `import { writeFileSync } from "node:fs";
167
+ import { join } from "node:path";
168
+ export default function (pi) {
169
+ pi.on("session_start", () => writeFileSync(join(process.env.PI_HERDR_SUBAGENTS_TASK_DIR, "extension-loaded.json"), JSON.stringify(pi.getCommands().map((command) => command.name))));
170
+ }`);
171
+ writeFileSync(configurationPaths.globalConfig, JSON.stringify({ personas: { analyst: { extensions: [packageSource] } } }));
172
+ await runCase("real-concurrent", ["hello-one", "hello-two"], 2, [packageSource]);
173
+ } catch (error) {
174
+ failed = true;
175
+ const snapshots = [];
176
+ for (const [kind, ids] of [["tab", tabIds], ["pane", paneIds]]) for (const id of ids) {
177
+ const result = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", [kind, "get", id], { encoding: "utf8", timeout: 10_000 });
178
+ snapshots.push({ kind, id, status: result.status, stdout: result.stdout, stderr: result.stderr, error: result.error ? String(result.error) : undefined });
179
+ }
180
+ writeFileSync(join(root, "failure.json"), JSON.stringify({ error: String(error), snapshots }, null, 2));
181
+ console.error(`FAIL real Herdr smoke; preserved diagnostics: ${root}`);
182
+ throw error;
74
183
  } finally {
75
- for (const tabId of tabIds) await herdr.closeTab(tabId).catch(() => undefined);
76
- rmSync(root, { recursive: true, force: true });
184
+ const cleanupErrors = [];
185
+ try { restoreTestFocus(); } catch (error) { cleanupErrors.push(String(error)); }
186
+ for (const tabId of tabIds) await herdr.closeTab(tabId, AbortSignal.timeout(10_000)).catch((error) => {
187
+ if (!/tab[_ -]?not[_ -]?found|unknown tab/i.test(`${error}\n${error.stderr ?? ""}`)) cleanupErrors.push(String(error));
188
+ });
189
+ if (cleanupErrors.length) {
190
+ failed = true;
191
+ writeFileSync(join(root, "teardown-errors.json"), JSON.stringify(cleanupErrors, null, 2));
192
+ console.error(`FAIL smoke teardown; preserved diagnostics: ${root}`);
193
+ process.exitCode = 1;
194
+ }
195
+ if (!failed) rmSync(root, { recursive: true, force: true });
77
196
  }
@@ -26,6 +26,7 @@ import { isAbortError } from "../src/errors.ts";
26
26
  import { acquireDirectoryLock, withRunStateLock } from "../src/run-state.ts";
27
27
  import { childPiArgs } from "../src/live-agent.ts";
28
28
  import registerSubagentReport from "../extensions/subagent-report.ts";
29
+ import { waitFor } from "./wait-for.mjs";
29
30
 
30
31
  /** Drives one task through the batch runner and projects its single result like a task-scoped run. */
31
32
  async function runSingleTask(prompt, { label, agent, model, tools, systemPrompt, onStalled, ...options } = {}) {
@@ -581,6 +582,10 @@ try {
581
582
 
582
583
  const definition = parseAgentMarkdown(`---\nname: reviewer\ndescription: Review code\nmodel: fake/model\ntools: read, grep\n---\nReview carefully.`, "reviewer.md");
583
584
  check("persona frontmatter parsing", definition.name === "reviewer" && definition.model === "fake/model" && definition.tools?.join(",") === "read,grep" && definition.systemPrompt === "Review carefully.");
585
+ const extensionDefinition = parseAgentMarkdown(`---\nname: routed\ndescription: Routed explorer\nextensions: "~/.pi/agent/extensions/gateway-routing.ts", npm:@example/pi-tools@1.0.0\n---\nExplore.`, "routed.md");
586
+ check("persona frontmatter accepts local and npm extension sources", extensionDefinition.extensions?.join(",") === "~/.pi/agent/extensions/gateway-routing.ts,npm:@example/pi-tools@1.0.0");
587
+ const clearedExtensions = parseAgentMarkdown(`---\nname: routed\ndescription: Routed explorer\nextensions: []\n---\nExplore.`);
588
+ check("persona frontmatter can explicitly clear extension sources", Array.isArray(clearedExtensions.extensions) && clearedExtensions.extensions.length === 0);
584
589
  const inheritedToolArgs = childPiArgs({ id: "inherited", sessionFile: "/tmp/inherited.jsonl" }, "/tmp/system.md");
585
590
  check("child Pi enables codemode when a persona inherits default tools", inheritedToolArgs.includes("builtin:codemode") && inheritedToolArgs[inheritedToolArgs.indexOf("--tools") + 1] === "+codemode");
586
591
  const explicitToolArgs = childPiArgs({ id: "explicit", sessionFile: "/tmp/explicit.jsonl", tools: ["read"] }, "/tmp/system.md");
@@ -633,6 +638,51 @@ try {
633
638
  const configuredStart = configuredHerdr.calls.find(([name]) => name === "startAgent")[1];
634
639
  check("configured persona uses displayed effective values", !configuredRun.isError && configuredStart.args.includes("task/model") && configuredStart.args.includes("high") && configuredStart.args.includes("--skill") && configuredStart.args.includes("project-skill"));
635
640
 
641
+ const extensionRoot = join(root, "extension-sources");
642
+ const extensionAgents = join(extensionRoot, "agents");
643
+ mkdirSync(extensionAgents, { recursive: true });
644
+ writeFileSync(join(extensionAgents, "routed.md"), `---\nname: routed\ndescription: Routed explorer\naccess: read\ntools: read\nextensions: ./markdown.ts\n---\nExplore.`);
645
+ const extensionPaths = { globalConfig: join(extensionRoot, "global.json"), projectConfig: join(extensionRoot, "project.json"), globalAgents: join(extensionRoot, "missing-global"), projectAgents: join(extensionRoot, "missing-project") };
646
+ const extensionPersona = () => loadSubagentConfiguration(extensionRoot, extensionAgents, undefined, extensionPaths).personas[0];
647
+ check("Markdown extension sources are effective without JSON overrides", extensionPersona()?.extensions?.join(",") === "./markdown.ts" && extensionPersona()?.extensionsSource === "package persona");
648
+ writeFileSync(extensionPaths.globalConfig, JSON.stringify({ personas: { routed: { extensions: ["./global.ts"] } } }));
649
+ check("global JSON replaces Markdown extension sources", extensionPersona()?.extensions?.join(",") === "./global.ts" && extensionPersona()?.extensionsSource === "global persona");
650
+ const extensionSources = ["~/.pi/agent/extensions/gateway-routing.ts", "npm:@example/pi-tools@1.0.0", "./local package"];
651
+ writeFileSync(extensionPaths.projectConfig, JSON.stringify({ personas: { routed: { extensions: extensionSources.map((source) => ` ${source} `) } } }));
652
+ check("project JSON trims and replaces global extension sources", JSON.stringify(extensionPersona()?.extensions) === JSON.stringify(extensionSources) && extensionPersona()?.extensionsSource === "project persona");
653
+ const extensionDependencies = { agentsDirectory: extensionAgents, configurationPaths: extensionPaths };
654
+ const sourceValues = (args) => args.flatMap((value, index) => value === "--extension" ? [args[index + 1]] : []);
655
+ const extensionRunHerdr = fakeHerdr();
656
+ const extensionRun = await executeHerdrSubagents({ tasks: [{ name: "routed", agent: "routed", prompt: "Review" }] }, undefined, undefined, { cwd: extensionRoot }, { ...extensionDependencies, herdr: extensionRunHerdr });
657
+ const extensionStart = extensionRunHerdr.calls.find(([name]) => name === "startAgent")[1];
658
+ check("explicit extension sources follow mandatory child extensions without shell splitting", !extensionRun.isError && extensionStart.args.includes("--no-extensions") && extensionStart.args.includes("--no-skills") && sourceValues(extensionStart.args)[0] === "builtin:codemode" && sourceValues(extensionStart.args)[1].endsWith("/extensions/subagent-report.ts") && JSON.stringify(sourceValues(extensionStart.args).slice(2)) === JSON.stringify(extensionSources) && extensionStart.args.includes("read,codemode,subagent_report"));
659
+ const extensionRecord = JSON.parse(readFileSync(join(extensionRun.details.tasks[0].recordDirectory, "task.json"), "utf8"));
660
+ check("effective extension sources are persisted in durable tasks", JSON.stringify(extensionRecord.extensions) === JSON.stringify(extensionSources));
661
+ const extensionHistory = await listSubagentHistory(extensionRoot);
662
+ check("history exposes saved extension sources", JSON.stringify(extensionHistory[0]?.tasks[0]?.extensions) === JSON.stringify(extensionSources));
663
+ const extensionList = await executeHerdrSubagents({ action: "list" }, undefined, undefined, { cwd: extensionRoot }, extensionDependencies);
664
+ check("persona listing shows extension sources and their precedence", extensionList.content[0].text.includes("npm:@example/pi-tools@1.0.0") && extensionList.content[0].text.includes("project persona"));
665
+ writeFileSync(extensionPaths.projectConfig, JSON.stringify({ personas: { routed: { extensions: [] } } }));
666
+ check("empty project extension list clears lower-precedence sources", extensionPersona()?.extensions?.length === 0 && extensionPersona()?.extensionsSource === "project persona");
667
+ const extensionResumeHerdr = fakeHerdr();
668
+ const extensionResume = await executeHerdrSubagents({ action: "resume", runId: extensionRun.details.runId, task: 1, prompt: "Follow up" }, undefined, undefined, { cwd: extensionRoot }, { ...extensionDependencies, herdr: extensionResumeHerdr });
669
+ const extensionResumeStart = extensionResumeHerdr.calls.find(([name]) => name === "startAgent")[1];
670
+ check("historical restart retains saved sources after configuration changes", !extensionResume.isError && JSON.stringify(sourceValues(extensionResumeStart.args).slice(2)) === JSON.stringify(extensionSources));
671
+ for (const invalidSources of ["npm:example", [""], ["valid", 42], null]) {
672
+ writeFileSync(extensionPaths.projectConfig, JSON.stringify({ personas: { routed: { extensions: invalidSources } } }));
673
+ const invalidSourcesConfig = loadSubagentConfiguration(extensionRoot, extensionAgents, undefined, extensionPaths);
674
+ check(`invalid extension sources are diagnosed: ${JSON.stringify(invalidSources)}`, invalidSourcesConfig.diagnostics.some((message) => message.includes("extensions must be an array of non-empty strings")) && invalidSourcesConfig.personas[0]?.extensions?.join(",") === "./global.ts");
675
+ }
676
+ writeFileSync(extensionPaths.projectConfig, JSON.stringify({ personas: { routed: { extensions: extensionSources } } }));
677
+ const extensionBlockedHerdr = fakeHerdr("blocked");
678
+ const extensionBlocked = await executeHerdrSubagents({ tasks: [{ name: "routed-blocked", agent: "routed", prompt: "Ask a question" }] }, undefined, undefined, { cwd: extensionRoot }, { ...extensionDependencies, herdr: extensionBlockedHerdr });
679
+ const extensionResponseHerdr = fakeHerdr();
680
+ extensionResponseHerdr.isTaskRunning = async () => false;
681
+ const extensionAnswered = await executeHerdrSubagents({ action: "respond", runId: extensionBlocked.details.runId, task: 1, answer: "Use main" }, undefined, undefined, { cwd: extensionRoot }, { ...extensionDependencies, herdr: extensionResponseHerdr });
682
+ const extensionResponseStart = extensionResponseHerdr.calls.find(([name]) => name === "startAgent")?.[1];
683
+ check("blocked response restart retains saved extension sources", !extensionAnswered.isError && extensionResponseStart && JSON.stringify(sourceValues(extensionResponseStart.args).slice(2)) === JSON.stringify(extensionSources));
684
+ check("unconfigured children keep only mandatory extensions", sourceValues(inheritedToolArgs).length === 2 && JSON.stringify(childPiArgs({ id: "inherited", sessionFile: "/tmp/inherited.jsonl", extensions: [] }, "/tmp/system.md")) === JSON.stringify(inheritedToolArgs));
685
+
636
686
  const analystHerdr = fakeHerdr();
637
687
  analystHerdr.expectConcurrentPrompts(3);
638
688
  const analystBatch = await executeHerdrSubagents({ label: "independent-analysis", concurrency: 3, tasks: [
@@ -761,7 +811,7 @@ try {
761
811
  check("write-capable batch is forced serial", writerBatch.details.effectiveConcurrency === 1 && writerHerdr.maxActive === 1 && writerHerdr.calls.filter(([name]) => name === "splitPane").length === 1);
762
812
  const focusedHerdr = fakeHerdr("focused");
763
813
  await executeHerdrSubagents({ tasks: [{ name: "inspect", prompt: "inspect", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: focusedHerdr });
764
- await new Promise((resolve) => setTimeout(resolve, 0));
814
+ await waitFor(() => focusedHerdr.calls.some(([name]) => name === "closeTab"), "focused tab close");
765
815
  const focusedCalls = focusedHerdr.calls.map(([name]) => name);
766
816
  check("focused completed tab defers cleanup until focus leaves", focusedCalls.indexOf("waitForTabUnfocused") >= 0 && focusedCalls.indexOf("closeTab") > focusedCalls.indexOf("waitForTabUnfocused"));
767
817
  const pendingCleanupHerdr = fakeHerdr();
@@ -774,7 +824,7 @@ try {
774
824
  const resumeWhilePending = await executeHerdrSubagents({ action: "resume", runId: pendingCleanupRun.details.runId, task: 1, prompt: "too soon" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: pendingCleanupHerdr });
775
825
  check("pending tab cleanup blocks deletion but permits an isolated resume generation", pendingCleanupRecord.cleanupPendingTabIds?.includes(pendingCleanupRun.details.tabId) && cleanupWhilePending.isError && !resumeWhilePending.isError);
776
826
  releasePendingCleanup();
777
- await new Promise((resolve) => setTimeout(resolve, 10));
827
+ await waitFor(() => !JSON.parse(readFileSync(join(pendingCleanupRun.details.recordDirectory, "run.json"), "utf8")).cleanupPendingTabIds && !existsSync(join(pendingCleanupRun.details.recordDirectory, ".state.lock")), "deferred cleanup state commit");
778
828
  const releasedCleanupRecord = JSON.parse(readFileSync(join(pendingCleanupRun.details.recordDirectory, "run.json"), "utf8"));
779
829
  check("deferred cleanup merges into the latest run record", releasedCleanupRecord.status === "completed" && !("cleanupPendingTabIds" in releasedCleanupRecord));
780
830
  const shutdownDeferredHerdr = fakeHerdr();
@@ -789,13 +839,13 @@ try {
789
839
  const shutdownDeferredController = new AbortController();
790
840
  await executeHerdrSubagents({ tasks: [{ name: "shutdown-deferred", prompt: "inspect", agent: "explorer" }] }, shutdownDeferredController.signal, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: shutdownDeferredHerdr });
791
841
  shutdownDeferredController.abort(new Error("Parent Pi session closed."));
792
- await new Promise((resolve) => setTimeout(resolve, 10));
842
+ await waitFor(() => shutdownDeferredHerdr.calls.some(([name]) => name === "closeTab"), "shutdown close of a focused tab");
793
843
  check("parent shutdown force-closes a settled focused tab", shutdownDeferredHerdr.calls.some(([name]) => name === "closeTab"));
794
844
  const deferredFailureHerdr = fakeHerdr();
795
845
  deferredFailureHerdr.isTabFocused = async () => true;
796
846
  deferredFailureHerdr.closeTab = async () => { throw new Error("deferred close failed"); };
797
847
  const deferredFailure = await executeHerdrSubagents({ tasks: [{ name: "deferred-cleanup", prompt: "inspect", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: deferredFailureHerdr });
798
- await new Promise((resolve) => setTimeout(resolve, 10));
848
+ await waitFor(() => JSON.parse(readFileSync(join(deferredFailure.details.recordDirectory, "run.json"), "utf8")).cleanupError === "deferred close failed" && !existsSync(join(deferredFailure.details.recordDirectory, ".state.lock")), "deferred cleanup error commit");
799
849
  const deferredFailureRecord = JSON.parse(readFileSync(join(deferredFailure.details.recordDirectory, "run.json"), "utf8"));
800
850
  check("deferred focus cleanup failure remains durably retryable", deferredFailureRecord.cleanupError === "deferred close failed" && deferredFailureRecord.cleanupPendingTabIds?.includes(deferredFailure.details.tabId));
801
851
  const deferredSingleHerdr = fakeHerdr();
@@ -803,7 +853,7 @@ try {
803
853
  deferredSingleHerdr.closeTab = async () => { throw new Error("single deferred close failed"); };
804
854
  const deferredSingleUpdates = [];
805
855
  const deferredSingle = await executeHerdrSubagents({ tasks: [{ name: "deferred-single", prompt: "single deferred cleanup", agent: "explorer" }] }, undefined, (update) => deferredSingleUpdates.push(update), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: deferredSingleHerdr });
806
- await new Promise((resolve) => setTimeout(resolve, 10));
856
+ await waitFor(() => deferredSingleUpdates.some((update) => update.details.cleanupError) && !existsSync(join(deferredSingle.details.recordDirectory, ".state.lock")), "deferred cleanup error notification");
807
857
  const deferredSingleRun = JSON.parse(readFileSync(join(deferredSingle.details.recordDirectory, "run.json"), "utf8"));
808
858
  check("deferred cleanup failure is persisted and surfaced without changing completion", deferredSingleRun.status === "completed" && deferredSingleRun.cleanupError === "single deferred close failed" && deferredSingleUpdates.some((update) => update.content[0].text.includes("cleanup failed") && update.details.cleanupError));
809
859
  const cancelledHerdr = fakeHerdr();
@@ -1791,13 +1841,14 @@ try {
1791
1841
  const staleContinuationTask = join(staleContinuationRun, "tasks", "01-task");
1792
1842
  mkdirSync(staleContinuationTask, { recursive: true });
1793
1843
  writeFileSync(join(staleContinuationRun, "run.json"), JSON.stringify({ id: "stale-continuation", label: "stale-continuation", status: "running", cwd: staleContinuationRoot, effectiveConcurrency: 1, tabId: "w1:t-old", paneIds: ["w1:p-old"], startedAt: new Date().toISOString() }));
1794
- writeFileSync(join(staleContinuationTask, "task.json"), JSON.stringify({ id: "stale-task", runId: "stale-continuation", order: 1, name: "stale-task", status: "queued", prompt: "continue", agent: "explorer", skills: [], sessionFile: join(staleContinuationTask, "session.jsonl") }));
1844
+ writeFileSync(join(staleContinuationTask, "task.json"), JSON.stringify({ id: "stale-task", runId: "stale-continuation", order: 1, name: "stale-task", status: "queued", prompt: "continue", agent: "explorer", skills: [], extensions: extensionSources, sessionFile: join(staleContinuationTask, "session.jsonl") }));
1795
1845
  writeFileSync(join(staleContinuationTask, "system-prompt.md"), "Report the result.");
1796
1846
  const staleContinuationHerdr = fakeHerdr();
1797
1847
  staleContinuationHerdr.paneExists = async () => false;
1798
1848
  staleContinuationHerdr.createTab = async (request) => { staleContinuationHerdr.calls.push(["createTab", request]); return { tabId: "w1:t-new", paneId: "w1:p-new" }; };
1799
1849
  await continueQueuedRun(staleContinuationRun, staleContinuationHerdr);
1800
1850
  const staleContinuationRecord = JSON.parse(readFileSync(join(staleContinuationRun, "run.json"), "utf8"));
1851
+ check("queued recovery retains saved extension sources", JSON.stringify(sourceValues(staleContinuationHerdr.calls.find(([name]) => name === "startAgent")[1].args).slice(2)) === JSON.stringify(extensionSources));
1801
1852
  check("queued recovery closes a replaced stale tab", staleContinuationRecord.status === "completed" && staleContinuationRecord.tabId === "w1:t-new" && !staleContinuationRecord.staleTabIds && staleContinuationHerdr.calls.some(([name, request]) => name === "closeTab" && request.tabId === "w1:t-old"));
1802
1853
  const failedCleanupRoot = join(root, "stale-continuation-cleanup-failure");
1803
1854
  const failedCleanupRun = join(failedCleanupRoot, ".pi", "herdr-subagents", "runs", "run");
@@ -0,0 +1,12 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ /** Wait for an observable condition, not an assumed completion time. */
4
+ export async function waitFor(check, description, timeoutMs = 5_000) {
5
+ const deadline = performance.now() + timeoutMs;
6
+ for (;;) {
7
+ const result = await check();
8
+ if (result) return result;
9
+ if (performance.now() >= deadline) throw new Error(`Timed out after ${timeoutMs} ms waiting for ${description}`);
10
+ await delay(10);
11
+ }
12
+ }