@maxiaochao/pi-toolkit 0.10.7 → 0.10.8

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -3
  4. package/packages/herdr-subagents/CHANGELOG.md +7 -0
  5. package/packages/herdr-subagents/README.md +4 -2
  6. package/packages/herdr-subagents/TESTING.md +6 -1
  7. package/packages/herdr-subagents/extensions/herdr-subagents.ts +83 -19
  8. package/packages/herdr-subagents/src/batch-runner.ts +3 -1
  9. package/packages/herdr-subagents/src/dispatcher.ts +8 -1
  10. package/packages/herdr-subagents/tests/extension-contract.ts +198 -2
  11. package/packages/herdr-subagents/tests/run-tests.mjs +25 -0
  12. package/packages/scheduler/CHANGELOG.md +5 -0
  13. package/packages/scheduler/README.md +4 -2
  14. package/packages/scheduler/TESTING.md +1 -1
  15. package/packages/scheduler/docs/demo.gif +0 -0
  16. package/packages/scheduler/docs/screenshot.png +0 -0
  17. package/packages/scheduler/extensions/scheduler.ts +87 -14
  18. package/packages/scheduler/src/scheduler.ts +4 -2
  19. package/packages/scheduler/tests/scheduler-core.mjs +7 -0
  20. package/packages/scheduler/tests/scheduler-extension.mjs +38 -2
  21. package/packages/worktree/CHANGELOG.md +7 -0
  22. package/packages/worktree/README.md +18 -3
  23. package/packages/worktree/TESTING.md +5 -2
  24. package/packages/worktree/bin/pi-worktree +118 -23
  25. package/packages/worktree/bin/pi-worktree-completion.bash +2 -2
  26. package/packages/worktree/extensions/worktree.ts +173 -0
  27. package/packages/worktree/package.json +37 -8
  28. package/packages/worktree/skills/pi-worktree/SKILL.md +11 -6
  29. package/packages/worktree/tests/extension.test.mjs +114 -0
  30. package/packages/worktree/tests/run-tests.sh +86 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## 2026-10-09 - v0.10.8
2
+
3
+ - bundle worktree v0.2.0: add current-session worktree switching for users, Agents, and SDK Harnesses
4
+ - Improve Scheduler with clear-all controls and a visible above-editor row for every active schedule, separate from the below-editor Herdr Fleet.
5
+ - Bundle the Worktree Extension with `/worktree start`, the `enter_worktree` Agent Tool, and the structured Harness workspace-switch protocol.
6
+ - Add the idempotent `pi-worktree prepare --json` machine interface so the CLI remains the single implementation for worktree creation and reuse.
7
+
1
8
  ## 2026-10-09 - v0.10.7
2
9
 
3
10
  - bundle scheduler v0.1.0: add session-scoped interval prompts with Agent and user controls
package/README.md CHANGED
@@ -35,7 +35,7 @@ Restart Pi after updating. The `todo` tool and widget will no longer load. `pi u
35
35
  | [pi-codex-edit](packages/codex-edit/README.md) | Codex-harness `apply_patch` routing for selected GPT models | `pi install npm:@maxiaochao/pi-codex-edit` | `pi update npm:@maxiaochao/pi-codex-edit` |
36
36
  | [pi-herdr-subagents](packages/herdr-subagents/README.md) | Interactive Herdr Pi Agents with bounded concurrency, direct navigation, and recovery | `pi install npm:@maxiaochao/pi-herdr-subagents` | `pi update npm:@maxiaochao/pi-herdr-subagents` |
37
37
  | [pi-scheduler](packages/scheduler/README.md) | Session-scoped interval prompts with Agent and `/schedule` controls | `pi install npm:@maxiaochao/pi-scheduler` | `pi update npm:@maxiaochao/pi-scheduler` |
38
- | [pi-worktree](packages/worktree/README.md) | Worktree CLI, completion, installer, and agent skill | `pi install npm:@maxiaochao/pi-worktree` | `pi update npm:@maxiaochao/pi-worktree` |
38
+ | [pi-worktree](packages/worktree/README.md) | Worktree CLI, current-session switching Extension, and agent skill | `pi install npm:@maxiaochao/pi-worktree` | `pi update npm:@maxiaochao/pi-worktree` |
39
39
 
40
40
  Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
41
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.10.7",
3
+ "version": "0.10.8",
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": [
@@ -34,7 +34,8 @@
34
34
  "./extensions/btw/index.ts",
35
35
  "./packages/codex-edit/extensions/codex-edit.ts",
36
36
  "./packages/herdr-subagents/extensions/herdr-subagents.ts",
37
- "./packages/scheduler/extensions/scheduler.ts"
37
+ "./packages/scheduler/extensions/scheduler.ts",
38
+ "./packages/worktree/extensions/worktree.ts"
38
39
  ],
39
40
  "skills": [
40
41
  "./skills",
@@ -58,7 +59,7 @@
58
59
  },
59
60
  "scripts": {
60
61
  "test": "node tests/package-layout.mjs && npm --prefix packages/todo test && npm --prefix packages/cache-export test && npm --prefix packages/codex-edit test && npm --prefix packages/herdr-subagents test && npm --prefix packages/scheduler test && npm --prefix packages/worktree test && node --experimental-strip-types extensions/btw/tests/history.mjs",
61
- "load-test": "pi -ne -e ./packages/cache-export/extensions/cache-export.ts && pi -ne -e ./extensions/btw/index.ts && pi -ne -e ./packages/codex-edit/extensions/codex-edit.ts && pi -ne -e ./packages/herdr-subagents/extensions/herdr-subagents.ts && pi -ne -e ./packages/scheduler/extensions/scheduler.ts",
62
+ "load-test": "pi -ne -e ./packages/cache-export/extensions/cache-export.ts && pi -ne -e ./extensions/btw/index.ts && pi -ne -e ./packages/codex-edit/extensions/codex-edit.ts && pi -ne -e ./packages/herdr-subagents/extensions/herdr-subagents.ts && pi -ne -e ./packages/scheduler/extensions/scheduler.ts && pi -ne -e ./packages/worktree/extensions/worktree.ts",
62
63
  "eval:agents": "node evaluations/agents-md/run.mjs",
63
64
  "eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",
64
65
  "postinstall": "node scripts/install-agents.mjs"
@@ -1,3 +1,10 @@
1
+ ## Unreleased
2
+
3
+ - Return a foreground concurrent batch as soon as one task fails, keep surviving siblings running under durable background ownership, and send exactly one final follow-up after the whole batch settles.
4
+ - Release the dispatcher slot at foreground failure handoff so later batches start immediately, while detached work remains cancellable, participates in shutdown, and cannot clear a newer active run when it settles.
5
+ - Preserve full-batch waiting for successful foreground runs and the immediate-return behavior of explicit background runs.
6
+ - Join every started batch worker before resolving its run promise, including when another worker's final state persistence fails.
7
+
1
8
  ## 2026-10-09 - v0.4.3
2
9
 
3
10
  - add per-run status, neutral analyst persona, and tool-agnostic Agent Team protocol
@@ -64,7 +64,9 @@ herdr_subagents({
64
64
  })
65
65
  ```
66
66
 
67
- `tasks` is always a non-empty array: one item is a single task and multiple items form a batch. Set `background: true` for either form to return a stable run ID immediately while the queue continues. One run is dispatched at a time by an extension instance; later runs remain queued and start FIFO. Each background run sends one compact parent notification only when the whole run completes, partially fails, fails, or blocks. Individual successful tasks do not wake the parent model.
67
+ `tasks` is always a non-empty array: one item is a single task and multiple items form a batch. Set `background: true` for either form to return a stable run ID immediately while the queue continues. The dispatcher starts batches FIFO, with one batch holding its active slot at a time; a failed foreground handoff releases that slot as described below. Each background run sends one compact parent notification only when the whole run completes, partially fails, fails, or blocks. Individual successful tasks do not wake the parent model.
68
+
69
+ A foreground concurrent batch normally waits for the whole batch. If any task fails while siblings are still running, the call immediately returns the stable `runId` and that failure's details, then durably continues the same batch in the background without cancelling its siblings. The handed-off batch releases its dispatcher slot immediately, so a later submission can start while its surviving tasks are still running. The detached batch remains cancellable and is still joined during session shutdown. Its final aggregate is delivered exactly once through the same follow-up notification used by an explicit background run.
68
70
 
69
71
  Background work normally relies on that terminal notification. When the user asks for progress or the parent must confirm a review gate before continuing, query only that run's compact snapshot:
70
72
 
@@ -96,7 +98,7 @@ Read-only tasks start FIFO up to the configured concurrency limit. A pane is cre
96
98
 
97
99
  The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning. An already-missing tab is treated as successfully cleaned, so retries are idempotent.
98
100
 
99
- Foreground calls block until all children report completion, failure, or a request for input. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane.
101
+ Foreground calls wait until all children report completion or a request for input when no task fails. On the first task failure, they return early as described above while the surviving children continue. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane.
100
102
 
101
103
  ## Reports and records
102
104
 
@@ -12,6 +12,11 @@
12
12
 
13
13
  - Resolve package, global, and project personas and validate frontmatter/configuration, including the neutral read-only `analyst` persona.
14
14
  - Run foreground tasks and FIFO bounded-concurrency batches through an injectable Herdr boundary.
15
+ - 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.
16
+ - 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.
17
+ - Let a detached run later block or be explicitly cancelled and verify the newer dispatcher owner remains intact and each detached run emits exactly one final notification.
18
+ - Shut down the parent while a handed-off sibling is active and verify it is cancelled and joined, durable queue state is removed, and no stale follow-up is sent.
19
+ - Verify all-success foreground batches still wait for every task and explicit `background: true` submissions still return immediately.
15
20
  - Verify all paths use interactive Agent start/prompt without print mode, shell injection, markers, or pane reuse.
16
21
  - Force write-capable batches to serial execution while allowing bounded read-only parallelism, including three concurrent `analyst` tasks with independent task-level models.
17
22
  - Preserve compact reports, full results, Pi sessions, blocked questions, and historical follow-ups.
@@ -25,4 +30,4 @@
25
30
 
26
31
  ## Latest baseline
27
32
 
28
- All 179 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, and persona cases must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
33
+ All 179 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, and persona cases plus the extension-contract lifecycle simulations must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
@@ -195,9 +195,9 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
195
195
  pi.registerTool({
196
196
  name: "herdr_subagents",
197
197
  label: "Herdr Subagents",
198
- description: "Run one or more ordered tasks in visible interactive Herdr Pi Agents. Use one tasks item for a single task. Independent read-only tasks may run concurrently; write-capable runs are serialized. Foreground calls block, while background calls return a run ID immediately. Complete results and Pi sessions stay in project-local records.",
198
+ description: "Run one or more ordered tasks in visible interactive Herdr Pi Agents. Use one tasks item for a single task. Independent read-only tasks may run concurrently; write-capable runs are serialized. Foreground calls wait for the batch unless a task fails, when the run continues in the background and returns its run ID immediately. Explicit background calls always return a run ID immediately. Complete results and Pi sessions stay in project-local records.",
199
199
  promptSnippet: "Run focused work in visible Herdr subagents",
200
- promptGuidelines: ["Call herdr_subagents_control with action=list once before the first execution. Submit an ordered tasks list; use one item for a single task and concurrency only for independent read-only tasks. Foreground calls block until the run settles; background calls return a run ID immediately."],
200
+ promptGuidelines: ["Call herdr_subagents_control with action=list once before the first execution. Submit an ordered tasks list; use one item for a single task and concurrency only for independent read-only tasks. Foreground calls wait for full success or blocking input, but return a run ID as soon as one task fails while surviving siblings continue in the background. Explicit background calls return a run ID immediately."],
201
201
  parameters: HerdrSubagentsParams,
202
202
  prepareArguments: normalizeHerdrSubagentsArguments,
203
203
 
@@ -205,35 +205,67 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
205
205
  const generation = sessionGeneration;
206
206
  const runId = randomUUID();
207
207
  const controller = new AbortController();
208
+ const foregroundController = new AbortController();
209
+ let foregroundAttached = !params.background;
210
+ const abortForeground = () => {
211
+ if (foregroundAttached && signal?.aborted) foregroundController.abort(signal.reason);
212
+ };
213
+ if (foregroundAttached) {
214
+ if (signal?.aborted) abortForeground();
215
+ else signal?.addEventListener("abort", abortForeground, { once: true });
216
+ }
208
217
  runControllers.set(runId, controller);
209
- const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(!params.background && signal ? [signal] : [])]);
218
+ const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(!params.background ? [foregroundController.signal] : [])]);
219
+ let resolveEarlyFailure: ((details: HerdrSubagentsBatchDetails) => void) | undefined;
220
+ const earlyFailure = new Promise<HerdrSubagentsBatchDetails>((resolve) => { resolveEarlyFailure = resolve; });
221
+ let failureObserved = false;
222
+ let runSettled = false;
210
223
  const executeRun = async () => {
211
224
  const result = await execute(params, runSignal, (update) => {
212
- const details = update.details as HerdrSubagentsBatchDetails | undefined;
213
- if (details?.activity) fleet.sync(details);
214
- const updateText = update.content.find((item) => item.type === "text")?.text;
215
- const updateDetails = update.details as { warning?: string };
216
- if (updateDetails.warning || updateText?.includes("cleanup failed") || (params.background && updateText?.includes("appears stalled"))) ctx.ui.notify(updateDetails.warning ?? updateText ?? "Subagent warning", "warning");
217
- if (!params.background) onUpdate?.(update);
225
+ const details = update.details as HerdrSubagentsBatchDetails | undefined;
226
+ if (details?.activity) fleet.sync(details);
227
+ const hasUnfinishedSibling = details?.activity?.some((task) => task.status === "queued" || task.status === "running");
228
+ if (!params.background && !failureObserved && details?.effectiveConcurrency > 1 && hasUnfinishedSibling && details.tasks?.some((task) => task.status === "failed")) {
229
+ failureObserved = true;
230
+ foregroundAttached = false;
231
+ signal?.removeEventListener("abort", abortForeground);
232
+ resolveEarlyFailure?.(details);
233
+ }
234
+ const updateText = update.content.find((item) => item.type === "text")?.text;
235
+ const updateDetails = update.details as { warning?: string };
236
+ if (updateDetails.warning || updateText?.includes("cleanup failed") || (!foregroundAttached && updateText?.includes("appears stalled"))) ctx.ui.notify(updateDetails.warning ?? updateText ?? "Subagent warning", "warning");
237
+ if (foregroundAttached) onUpdate?.(update);
218
238
  }, ctx, { agentsDirectory: packageAgentsDir, owner: { sessionId: ownerSessionId, processId: process.pid }, runId });
219
239
  fleet.sync(result.details);
220
240
  return result;
221
241
  };
222
242
  if (params.background) await persistQueuedRun(ctx.cwd, runId, params, { sessionId: ownerSessionId, processId: process.pid });
223
243
  const promise = dispatcher.submit(runId, async () => {
224
- const result = await executeRun().finally(async () => { if (params.background) await removeQueuedRun(ctx.cwd, runId); });
225
- const details = result.details as HerdrSubagentsBatchDetails;
226
- fleet.setActive(details.status === "blocked" ? details : undefined);
227
- if (fleet.active) {
228
- dispatcher.retain(runId);
244
+ const result = await executeRun();
245
+ const details = result.details as HerdrSubagentsBatchDetails;
246
+ const ownsDispatcher = dispatcher.snapshot().activeRunId === runId;
247
+ if (ownsDispatcher) fleet.setActive(details.status === "blocked" ? details : undefined);
248
+ else if (details.status === "blocked") fleet.clearRun(runId);
249
+ if (ownsDispatcher && fleet.active) {
250
+ dispatcher.retain(runId);
229
251
  }
230
252
  return result;
231
253
  });
232
254
  runPromises.set(runId, promise);
233
255
  inFlight.add(promise);
234
- void promise.finally(() => { inFlight.delete(promise); runControllers.delete(runId); runPromises.delete(runId); }).catch(() => undefined);
235
- if (params.background) {
236
- void promise.then((result) => {
256
+ void promise.finally(() => {
257
+ runSettled = true;
258
+ foregroundAttached = false;
259
+ signal?.removeEventListener("abort", abortForeground);
260
+ inFlight.delete(promise);
261
+ runControllers.delete(runId);
262
+ runPromises.delete(runId);
263
+ }).catch(() => undefined);
264
+ let backgroundLifecycleStarted = false;
265
+ const startBackgroundLifecycle = () => {
266
+ if (backgroundLifecycleStarted) return;
267
+ backgroundLifecycleStarted = true;
268
+ const lifecycle = promise.then((result) => {
237
269
  if (generation !== sessionGeneration) return;
238
270
  notifyRun(runId, result);
239
271
  if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.render();
@@ -244,10 +276,42 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
244
276
  return;
245
277
  }
246
278
  pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} failed: ${error instanceof Error ? error.message : String(error)}`, display: true, details: { runId, status: "failed" } }, { triggerTurn: true, deliverAs: "followUp" });
247
- });
279
+ }).finally(() => removeQueuedRun(ctx.cwd, runId));
280
+ inFlight.add(lifecycle);
281
+ void lifecycle.finally(() => inFlight.delete(lifecycle)).catch(() => undefined);
282
+ };
283
+ if (params.background) {
284
+ startBackgroundLifecycle();
248
285
  return { content: [{ type: "text" as const, text: `Subagent run queued: ${runId}` }], details: { runId, status: "queued", background: true } };
249
286
  }
250
- const result = await promise;
287
+ const foregroundOutcome = await Promise.race([
288
+ promise.then((result) => ({ kind: "settled" as const, result })),
289
+ earlyFailure.then((details) => ({ kind: "failed" as const, details })),
290
+ ]);
291
+ if (foregroundOutcome.kind === "failed") {
292
+ try {
293
+ await persistQueuedRun(ctx.cwd, runId, params, { sessionId: ownerSessionId, processId: process.pid });
294
+ } catch (error) {
295
+ controller.abort(new Error("Could not persist failed foreground run for background continuation."));
296
+ await promise.catch(() => undefined);
297
+ await removeQueuedRun(ctx.cwd, runId).catch(() => undefined);
298
+ throw error;
299
+ }
300
+ if (runSettled) {
301
+ await removeQueuedRun(ctx.cwd, runId);
302
+ return promise;
303
+ }
304
+ dispatcher.detach(runId);
305
+ startBackgroundLifecycle();
306
+ const failedTask = foregroundOutcome.details.tasks.find((task) => task.status === "failed");
307
+ const failure = failedTask?.error ?? failedTask?.summary ?? "Unknown task failure.";
308
+ return {
309
+ content: [{ type: "text" as const, text: `Subagent run ${runId} continues in the background after ${failedTask?.name ?? "a task"} failed: ${failure}` }],
310
+ details: { ...foregroundOutcome.details, runId, background: true, earlyReturn: true, failedTask: failedTask ? { index: failedTask.index, name: failedTask.name, summary: failedTask.summary, ...(failedTask.error ? { error: failedTask.error } : {}) } : undefined },
311
+ isError: true,
312
+ };
313
+ }
314
+ const result = foregroundOutcome.result;
251
315
  if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.resetSelection();
252
316
  return result;
253
317
  },
@@ -176,7 +176,9 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
176
176
  if (results[index]?.status === "blocked") return;
177
177
  }
178
178
  };
179
- await Promise.all(Array.from({ length: effectiveConcurrency }, worker));
179
+ const workers = await Promise.allSettled(Array.from({ length: effectiveConcurrency }, worker));
180
+ const rejectedWorker = workers.find((worker): worker is PromiseRejectedResult => worker.status === "rejected");
181
+ if (rejectedWorker) throw rejectedWorker.reason;
180
182
  if (options.signal?.aborted) {
181
183
  for (let index = 0; index < tasks.length; index += 1) {
182
184
  if (activity[index].status !== "queued") continue;
@@ -37,6 +37,13 @@ export class RunDispatcher {
37
37
  this.retainedRunId = id;
38
38
  this.changed();
39
39
  }
40
+ detach(id: string): boolean {
41
+ if (this.activeRunId !== id || this.retainedRunId === id) return false;
42
+ this.activeRunId = undefined;
43
+ this.changed();
44
+ void this.drain();
45
+ return true;
46
+ }
40
47
  release(id: string): boolean {
41
48
  if (this.activeRunId !== id || this.retainedRunId !== id) return false;
42
49
  this.retainedRunId = undefined;
@@ -71,7 +78,7 @@ export class RunDispatcher {
71
78
  try { run.resolve(await run.execute()); }
72
79
  catch (error) { run.reject(error); }
73
80
  finally {
74
- if (this.retainedRunId !== run.id) {
81
+ if (this.activeRunId === run.id && this.retainedRunId !== run.id) {
75
82
  this.activeRunId = undefined;
76
83
  this.changed();
77
84
  void this.drain();
@@ -16,6 +16,8 @@ function harness(execute: (...args: any[]) => Promise<any>) {
16
16
  const editor = { kind: "editor" };
17
17
  const focusedPanes: string[] = [];
18
18
  const notifications: Array<{ message: string; level?: string }> = [];
19
+ const messages: Array<{ message: any; options: any }> = [];
20
+ const messageWaiters: Array<{ predicate: (message: any) => boolean; resolve: () => void }> = [];
19
21
  const ui = {
20
22
  setWidget(_id: string, widget: unknown) {
21
23
  widgetCalls += 1;
@@ -32,7 +34,13 @@ function harness(execute: (...args: any[]) => Promise<any>) {
32
34
  if (definition.name === "herdr_subagents_control") controlTool = definition;
33
35
  },
34
36
  registerCommand(name: string) { commands.push(name); },
35
- sendMessage() {},
37
+ sendMessage(message: any, options: any) {
38
+ messages.push({ message, options });
39
+ for (const waiter of messageWaiters.splice(0)) {
40
+ if (waiter.predicate(message)) waiter.resolve();
41
+ else messageWaiters.push(waiter);
42
+ }
43
+ },
36
44
  };
37
45
  registerHerdrSubagents(pi as never, {
38
46
  herdr: { async focusPane(paneId: string) { focusedPanes.push(paneId); } } as never,
@@ -51,6 +59,11 @@ function harness(execute: (...args: any[]) => Promise<any>) {
51
59
  get renderedLines() { return renderedLines; },
52
60
  focusedPanes,
53
61
  notifications,
62
+ messages,
63
+ waitForMessage(predicate: (message: any) => boolean) {
64
+ if (messages.some(({ message }) => predicate(message))) return Promise.resolve();
65
+ return new Promise<void>((resolve) => messageWaiters.push({ predicate, resolve }));
66
+ },
54
67
  ui,
55
68
  };
56
69
  }
@@ -64,6 +77,188 @@ async function startHarness(instance: ReturnType<typeof harness>, cwd: string) {
64
77
  });
65
78
  }
66
79
 
80
+ function deferred<T>() {
81
+ let resolve!: (value: T) => void;
82
+ const promise = new Promise<T>((settle) => { resolve = settle; });
83
+ return { promise, resolve };
84
+ }
85
+
86
+ async function waitUntil(predicate: () => boolean, message: string) {
87
+ for (let attempt = 0; attempt < 100; attempt += 1) {
88
+ if (predicate()) return;
89
+ await new Promise<void>((resolve) => setImmediate(resolve));
90
+ }
91
+ throw new Error(message);
92
+ }
93
+
94
+ async function verifyForegroundFailureHandoff() {
95
+ const cwd = mkdtempSync(join(tmpdir(), "herdr-early-failure-"));
96
+ try {
97
+ const slow = deferred<void>();
98
+ let slowSignal: AbortSignal | undefined;
99
+ let secondStarted = false;
100
+ let callCount = 0;
101
+ const caller = new AbortController();
102
+ const failedTask = { index: 0, name: "fast failure", status: "failed", summary: "failed quickly", error: "boom", documents: [], paneId: "w1:p1", recordDirectory: "task-1", sessionFile: "task-1/session.jsonl" };
103
+ const slowTask = { index: 1, name: "slow success", status: "completed", summary: "finished slowly", documents: [], paneId: "w1:p2", recordDirectory: "task-2", sessionFile: "task-2/session.jsonl" };
104
+ const instance = harness(async (_params, signal, onUpdate, _ctx, dependencies) => {
105
+ callCount += 1;
106
+ if (callCount > 1) {
107
+ secondStarted = true;
108
+ return { content: [{ type: "text", text: "second done" }], details: { runId: dependencies.runId, label: "second", status: "completed", summary: "done", documents: [], tasks: [], activity: [] } };
109
+ }
110
+ slowSignal = signal;
111
+ onUpdate?.({
112
+ content: [{ type: "text", text: "1/2 tasks settled." }],
113
+ details: { runId: dependencies.runId, label: "handoff", status: "running", summary: "1/2 tasks settled.", documents: [], tasks: [failedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: slowTask.name, status: "running" }], effectiveConcurrency: 2, requestedConcurrency: 2 },
114
+ });
115
+ queueMicrotask(() => caller.abort(new Error("foreground tool call ended")));
116
+ await slow.promise;
117
+ return { content: [{ type: "text", text: "1/2 tasks completed; 1 failed." }], details: { runId: dependencies.runId, label: "handoff", status: "partial", summary: "1/2 tasks completed; 1 failed.", documents: [], tasks: [failedTask, slowTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: slowTask.name, status: "completed" }], effectiveConcurrency: 2, requestedConcurrency: 2 }, isError: true };
118
+ });
119
+ await startHarness(instance, cwd);
120
+ const foreground = instance.tool?.execute("handoff", { concurrency: 2, tasks: [{ name: failedTask.name, prompt: "fail" }, { name: slowTask.name, prompt: "wait" }] }, caller.signal, undefined, { cwd, ui: instance.ui });
121
+ const early = await Promise.race([foreground, new Promise((_, reject) => setTimeout(() => reject(new Error("foreground did not return after the first task failed")), 100))]);
122
+ if (!early.details?.runId || early.details?.earlyReturn !== true || early.details?.background !== true || early.details?.failedTask?.error !== "boom" || !early.content?.[0]?.text.includes("boom")) throw new Error("early foreground result omitted the run ID or failure details");
123
+ if (slowSignal?.aborted) throw new Error("early foreground handoff cancelled the still-running sibling");
124
+ const queuedAfterHandoff = readdirSync(join(cwd, ".pi", "herdr-subagents", "queue")).filter((name) => name.endsWith(".json"));
125
+ if (queuedAfterHandoff.length !== 1) throw new Error("early foreground handoff was not persisted as background work");
126
+ const second = instance.tool?.execute("second", { background: true, tasks: [{ name: "second", prompt: "later" }] }, undefined, undefined, { cwd, ui: instance.ui });
127
+ await second;
128
+ await waitUntil(() => secondStarted, "dispatcher did not start the next batch after the failed foreground batch detached");
129
+ if (slowSignal?.aborted) throw new Error("starting the next batch cancelled the detached slow sibling");
130
+ const handoffNotified = instance.waitForMessage((message) => message.details?.runId === early.details.runId);
131
+ slow.resolve();
132
+ await handoffNotified;
133
+ if (slowSignal?.aborted) throw new Error("detached slow sibling was cancelled before it finished");
134
+ const handoffMessages = instance.messages.filter(({ message }) => message.details?.runId === early.details.runId);
135
+ if (handoffMessages.length !== 1 || handoffMessages[0].options?.deliverAs !== "followUp") throw new Error("handed-off run did not send exactly one final follow-up");
136
+ await waitUntil(() => !readdirSync(join(cwd, ".pi", "herdr-subagents", "queue")).some((name) => name.startsWith(early.details.runId)), "settled handed-off run left its durable queue record behind");
137
+
138
+ const blockedRoot = join(cwd, "blocked-handoff");
139
+ mkdirSync(blockedRoot, { recursive: true });
140
+ const becomeBlocked = deferred<void>();
141
+ const finishSuccessor = deferred<void>();
142
+ let blockedCalls = 0;
143
+ let successorStarted = false;
144
+ let thirdStarted = false;
145
+ const blockedTask = { index: 1, name: "needs input", status: "blocked", summary: "waiting for an answer", question: "Continue?", documents: [], paneId: "w1:p2", recordDirectory: "task-2", sessionFile: "task-2/session.jsonl" };
146
+ const blockedHandoff = harness(async (_params, _signal, onUpdate, _ctx, dependencies) => {
147
+ blockedCalls += 1;
148
+ if (blockedCalls === 2) {
149
+ successorStarted = true;
150
+ await finishSuccessor.promise;
151
+ return { content: [{ type: "text", text: "successor done" }], details: { runId: dependencies.runId, label: "successor", status: "completed", summary: "done", documents: [], tasks: [], activity: [] } };
152
+ }
153
+ if (blockedCalls === 3) {
154
+ thirdStarted = true;
155
+ return { content: [{ type: "text", text: "third done" }], details: { runId: dependencies.runId, label: "third", status: "completed", summary: "done", documents: [], tasks: [], activity: [] } };
156
+ }
157
+ onUpdate?.({
158
+ content: [{ type: "text", text: "1/2 tasks settled." }],
159
+ details: { runId: dependencies.runId, label: "blocks later", status: "running", summary: "1/2 tasks settled.", documents: [], tasks: [failedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: blockedTask.name, status: "running" }], effectiveConcurrency: 2, requestedConcurrency: 2 },
160
+ });
161
+ await becomeBlocked.promise;
162
+ return { content: [{ type: "text", text: "run blocked" }], details: { runId: dependencies.runId, label: "blocks later", status: "blocked", summary: "waiting for input", documents: [], tasks: [failedTask, blockedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: blockedTask.name, status: "blocked" }], effectiveConcurrency: 2, requestedConcurrency: 2 } };
163
+ });
164
+ await startHarness(blockedHandoff, blockedRoot);
165
+ const blockedEarly = await blockedHandoff.tool?.execute("blocks-later", { concurrency: 2, tasks: [{ name: failedTask.name, prompt: "fail" }, { name: blockedTask.name, prompt: "block" }] }, undefined, undefined, { cwd: blockedRoot, ui: blockedHandoff.ui });
166
+ await blockedHandoff.tool?.execute("successor", { background: true, tasks: [{ name: "successor", prompt: "wait" }] }, undefined, undefined, { cwd: blockedRoot, ui: blockedHandoff.ui });
167
+ await waitUntil(() => successorStarted, "successor did not start while detached run was still active");
168
+ const blockedNotified = blockedHandoff.waitForMessage((message) => message.details?.runId === blockedEarly.details.runId);
169
+ becomeBlocked.resolve();
170
+ await blockedNotified;
171
+ await blockedHandoff.tool?.execute("third", { background: true, tasks: [{ name: "third", prompt: "later" }] }, undefined, undefined, { cwd: blockedRoot, ui: blockedHandoff.ui });
172
+ await new Promise<void>((resolve) => setImmediate(resolve));
173
+ if (thirdStarted) throw new Error("detached blocked settlement cleared the newer active dispatcher slot");
174
+ if (blockedHandoff.messages.filter(({ message }) => message.details?.runId === blockedEarly.details.runId).length !== 1) throw new Error("detached run that later blocked did not notify exactly once");
175
+ finishSuccessor.resolve();
176
+ await waitUntil(() => thirdStarted, "queued run did not start after the newer active run settled");
177
+
178
+ const cancelRoot = join(cwd, "cancel-handoff");
179
+ mkdirSync(cancelRoot, { recursive: true });
180
+ let detachedSignal: AbortSignal | undefined;
181
+ const cancelledHandoff = harness(async (params, signal, onUpdate, _ctx, dependencies) => {
182
+ if (params.action === "cancel") {
183
+ const result = await dependencies.cancelRun(params.runId);
184
+ return { content: [{ type: "text", text: "cancelled" }], details: { action: "cancel", runId: params.runId, ...result } };
185
+ }
186
+ detachedSignal = signal;
187
+ onUpdate?.({
188
+ content: [{ type: "text", text: "1/2 tasks settled." }],
189
+ details: { runId: dependencies.runId, label: "cancel detached", status: "running", summary: "1/2 tasks settled.", documents: [], tasks: [failedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: slowTask.name, status: "running" }], effectiveConcurrency: 2, requestedConcurrency: 2 },
190
+ });
191
+ await new Promise<void>((_resolve, reject) => {
192
+ if (signal.aborted) reject(signal.reason);
193
+ else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
194
+ });
195
+ });
196
+ await startHarness(cancelledHandoff, cancelRoot);
197
+ const cancelEarly = await cancelledHandoff.tool?.execute("cancel-detached", { concurrency: 2, tasks: [{ name: failedTask.name, prompt: "fail" }, { name: slowTask.name, prompt: "wait" }] }, undefined, undefined, { cwd: cancelRoot, ui: cancelledHandoff.ui });
198
+ const cancelledNotice = cancelledHandoff.waitForMessage((message) => message.details?.runId === cancelEarly.details.runId);
199
+ const cancelResult = await cancelledHandoff.controlTool?.execute("cancel", { action: "cancel", runId: cancelEarly.details.runId }, undefined, undefined, { cwd: cancelRoot, ui: cancelledHandoff.ui });
200
+ await cancelledNotice;
201
+ if (!detachedSignal?.aborted || cancelResult?.details?.cancelled !== true) throw new Error("cancel did not abort the detached handed-off run");
202
+ if (cancelledHandoff.messages.filter(({ message }) => message.details?.runId === cancelEarly.details.runId).length !== 1) throw new Error("cancelled detached run did not notify exactly once");
203
+
204
+ const shutdownRoot = join(cwd, "shutdown-handoff");
205
+ mkdirSync(shutdownRoot, { recursive: true });
206
+ let shutdownSignal: AbortSignal | undefined;
207
+ const shutdownHandoff = harness(async (_params, signal, onUpdate, _ctx, dependencies) => {
208
+ shutdownSignal = signal;
209
+ onUpdate?.({
210
+ content: [{ type: "text", text: "1/2 tasks settled." }],
211
+ details: { runId: dependencies.runId, label: "shutdown", status: "running", summary: "1/2 tasks settled.", documents: [], tasks: [failedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: slowTask.name, status: "running" }], effectiveConcurrency: 2, requestedConcurrency: 2 },
212
+ });
213
+ await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
214
+ return { content: [{ type: "text", text: "cancelled" }], details: { runId: dependencies.runId, label: "shutdown", status: "cancelled", summary: "cancelled", documents: [], tasks: [failedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: slowTask.name, status: "cancelled" }], effectiveConcurrency: 2, requestedConcurrency: 2 }, isError: true };
215
+ });
216
+ await startHarness(shutdownHandoff, shutdownRoot);
217
+ const shutdownEarly = await shutdownHandoff.tool?.execute("shutdown", { concurrency: 2, tasks: [{ name: failedTask.name, prompt: "fail" }, { name: slowTask.name, prompt: "wait" }] }, undefined, undefined, { cwd: shutdownRoot, ui: shutdownHandoff.ui });
218
+ await Promise.race([
219
+ shutdownHandoff.handlers.get("session_shutdown")?.({}, { cwd: shutdownRoot, ui: shutdownHandoff.ui }),
220
+ new Promise((_, reject) => setTimeout(() => reject(new Error("session shutdown leaked a handed-off run")), 100)),
221
+ ]);
222
+ if (!shutdownSignal?.aborted) throw new Error("session shutdown did not abort the active handed-off sibling");
223
+ if (shutdownHandoff.messages.some(({ message }) => message.details?.runId === shutdownEarly.details.runId)) throw new Error("handed-off run notified after its parent session shut down");
224
+ const shutdownQueue = join(shutdownRoot, ".pi", "herdr-subagents", "queue");
225
+ if (existsSync(shutdownQueue) && readdirSync(shutdownQueue).some((name) => name.endsWith(".json"))) throw new Error("session shutdown left a handed-off queue record behind");
226
+
227
+ const successGate = deferred<void>();
228
+ const successStarted = deferred<void>();
229
+ const allSuccess = harness(async (_params, _signal, _onUpdate, _ctx, dependencies) => {
230
+ successStarted.resolve();
231
+ await successGate.promise;
232
+ return { content: [{ type: "text", text: "all done" }], details: { runId: dependencies.runId, label: "success", status: "completed", summary: "2/2 tasks completed.", documents: [], tasks: [], activity: [], effectiveConcurrency: 2, requestedConcurrency: 2 } };
233
+ });
234
+ await startHarness(allSuccess, cwd);
235
+ let successSettled = false;
236
+ const successPromise = allSuccess.tool?.execute("success", { concurrency: 2, tasks: [{ name: "one", prompt: "one" }, { name: "two", prompt: "two" }] }, undefined, undefined, { cwd, ui: allSuccess.ui }).then((result: any) => { successSettled = true; return result; });
237
+ await successStarted.promise;
238
+ if (successSettled) throw new Error("all-success foreground batch returned before the full batch settled");
239
+ successGate.resolve();
240
+ await successPromise;
241
+
242
+ const backgroundGate = deferred<void>();
243
+ const explicitBackground = harness(async (_params, _signal, _onUpdate, _ctx, dependencies) => {
244
+ await backgroundGate.promise;
245
+ return { content: [{ type: "text", text: "background done" }], details: { runId: dependencies.runId, label: "background", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
246
+ });
247
+ await startHarness(explicitBackground, cwd);
248
+ const queued = await Promise.race([
249
+ explicitBackground.tool?.execute("background", { background: true, tasks: [{ name: "background", prompt: "wait" }] }, undefined, undefined, { cwd, ui: explicitBackground.ui }),
250
+ new Promise((_, reject) => setTimeout(() => reject(new Error("explicit background run stopped returning immediately")), 100)),
251
+ ]);
252
+ if (queued.details?.background !== true || queued.details?.status !== "queued") throw new Error("explicit background result contract regressed");
253
+ const backgroundNotified = explicitBackground.waitForMessage((message) => message.details?.runId === queued.details.runId);
254
+ backgroundGate.resolve();
255
+ await backgroundNotified;
256
+ if (explicitBackground.messages.filter(({ message }) => message.details?.runId === queued.details.runId).length !== 1) throw new Error("explicit background run did not send one final follow-up");
257
+ } finally {
258
+ rmSync(cwd, { recursive: true, force: true });
259
+ }
260
+ }
261
+
67
262
  async function verifyPublicUiWiring() {
68
263
  const cwd = mkdtempSync(join(tmpdir(), "herdr-extension-contract-"));
69
264
  try {
@@ -194,6 +389,7 @@ try {
194
389
  if (child.tool || child.commands.length || child.handlers.size) throw new Error("Subagents registered inside a child Agent");
195
390
  delete process.env.PI_HERDR_SUBAGENTS_CHILD;
196
391
  await verifyPublicUiWiring();
392
+ await verifyForegroundFailureHandoff();
197
393
  } finally {
198
394
  if (originalHerdrEnv === undefined) delete process.env.HERDR_ENV;
199
395
  else process.env.HERDR_ENV = originalHerdrEnv;
@@ -255,7 +451,7 @@ export default function (_pi: ExtensionAPI) {
255
451
  if (!controlActions?.includes("status")) throw new Error("herdr_subagents_control action enum must include status");
256
452
  if (!/status/.test(controlSchema.properties.runId.description)) throw new Error("control runId description must document status");
257
453
  if (!/Use status for one run/.test(controlTool.description)) throw new Error("control tool description must distinguish status from history");
258
- if (!/Foreground calls block/.test(tool.description) || !/background calls return a run ID immediately/.test(tool.description)) {
454
+ if (!/Foreground calls wait/.test(tool.description) || !/Explicit background calls always return a run ID immediately/.test(tool.description)) {
259
455
  throw new Error("tool description does not distinguish foreground and background execution");
260
456
  }
261
457
  }
@@ -614,8 +614,33 @@ try {
614
614
  const retainedFirst = retainedDispatcher.submit("blocked-run", async () => { retainedStarts.push("blocked-run"); retainedDispatcher.retain("blocked-run"); return "blocked"; });
615
615
  const retainedSecond = retainedDispatcher.submit("queued-run", async () => { retainedStarts.push("queued-run"); return "completed"; });
616
616
  check("blocked run retains dispatcher ownership after its call returns", await retainedFirst === "blocked" && retainedDispatcher.snapshot().activeRunId === "blocked-run" && retainedStarts.join(",") === "blocked-run" && retainedDispatcher.snapshot().queuedRunIds.join(",") === "queued-run");
617
+ check("dispatcher does not detach a retained blocked run", retainedDispatcher.detach("blocked-run") === false);
617
618
  retainedDispatcher.release("blocked-run");
618
619
  check("respond settlement releases dispatcher ownership to the queued run", await retainedSecond === "completed" && retainedStarts.join(",") === "blocked-run,queued-run");
620
+ const detachedDispatcher = new RunDispatcher();
621
+ let settleDetached;
622
+ let settleSuccessor;
623
+ let detachedSettled = false;
624
+ const detachedStarts = [];
625
+ const detachedFirst = detachedDispatcher.submit("detached-run", async () => {
626
+ detachedStarts.push("detached-run");
627
+ await new Promise((resolve) => { settleDetached = resolve; });
628
+ detachedSettled = true;
629
+ return "detached";
630
+ });
631
+ const afterDetached = detachedDispatcher.submit("after-detach", async () => {
632
+ detachedStarts.push("after-detach");
633
+ await new Promise((resolve) => { settleSuccessor = resolve; });
634
+ return "next";
635
+ });
636
+ await new Promise((resolve) => setTimeout(resolve, 0));
637
+ const detached = detachedDispatcher.detach("detached-run");
638
+ await new Promise((resolve) => setTimeout(resolve, 0));
639
+ check("dispatcher detaches an unsettled active run and starts its successor", detached && !detachedSettled && detachedStarts.join(",") === "detached-run,after-detach" && detachedDispatcher.snapshot().activeRunId === "after-detach");
640
+ settleDetached();
641
+ check("detached run still resolves normally without disturbing its active successor", await detachedFirst === "detached" && detachedDispatcher.snapshot().activeRunId === "after-detach");
642
+ settleSuccessor();
643
+ check("successor settles normally after detached predecessor", await afterDetached === "next" && detachedDispatcher.snapshot().activeRunId === undefined);
619
644
  const backgroundId = "stable-background-id";
620
645
  const stableHerdr = fakeHerdr();
621
646
  const stable = await executeHerdrSubagents({ tasks: [{ name: "stable", prompt: "stable", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: stableHerdr, runId: backgroundId });
@@ -1,3 +1,8 @@
1
+ ## Unreleased
2
+
3
+ - Add `clear` to the Agent tool and `/schedule clear` for stopping every active schedule.
4
+ - Replace the compact footer status with an above-editor multi-line widget that shows one row per schedule and coexists with the below-editor Herdr Fleet.
5
+
1
6
  ## 2026-10-09 - v0.1.0
2
7
 
3
8
  - add session-scoped interval prompts with Agent and user controls
@@ -25,6 +25,7 @@ schedule({ action: "add", every: "5m", prompt: "Check CI; report completion or f
25
25
  schedule({ action: "list" })
26
26
  schedule({ action: "trigger", id: "schedule-1" })
27
27
  schedule({ action: "cancel", id: "schedule-1" })
28
+ schedule({ action: "clear" })
28
29
  ```
29
30
 
30
31
  ## User command
@@ -34,9 +35,10 @@ schedule({ action: "cancel", id: "schedule-1" })
34
35
  /schedule list
35
36
  /schedule trigger schedule-1
36
37
  /schedule cancel schedule-1
38
+ /schedule clear
37
39
  ```
38
40
 
39
- Manual trigger queues one prompt and does not move the regular next time. The TUI status line shows the active count and next trigger.
41
+ Manual trigger queues one prompt and does not move the regular next time. A multi-line widget above the editor shows one row per active schedule with its ID, interval, next trigger, pending state, and prompt. Herdr Fleet remains below the editor, so the two live displays do not compete for the same area or overwrite each other.
40
42
 
41
43
  ## Semantics and limits
42
44
 
@@ -44,7 +46,7 @@ Manual trigger queues one prompt and does not move the regular next time. The TU
44
46
  - Prompts use Pi `followUp`: an active agent turn finishes before the scheduled prompt runs.
45
47
  - At most one prompt per schedule remains pending; missed intervals coalesce instead of flooding the queue.
46
48
  - Schedules live only in the current session and are cleared on new/resume/fork/reload/quit.
47
- - Cancel stops future timer events. Pi cannot selectively withdraw a prompt already in its follow-up queue.
49
+ - Cancel stops one schedule; clear stops all active schedules. Pi cannot selectively withdraw a prompt already in its follow-up queue.
48
50
  - Timing is best-effort, not real-time. Recursive `setTimeout` uses absolute target times to avoid cumulative drift.
49
51
  - Calendar expressions, time zones, persistence, process monitoring, and script notifications are intentionally out of scope.
50
52
 
@@ -8,7 +8,7 @@ npm --prefix packages/scheduler run load-test
8
8
  npm pack ./packages/scheduler --dry-run
9
9
  ```
10
10
 
11
- The deterministic core test uses a fake clock to verify duration parsing, absolute-time cadence, timed triggering, missed-tick coalescing, manual triggering, dropped-follow-up release, and cancellation. The extension test verifies tool/command parity, manual follow-up delivery, message-specific pending acknowledgement, settled-run fallback cleanup, TUI status, duplicate suppression, and shutdown timer cleanup.
11
+ The deterministic core test uses a fake clock to verify duration parsing, absolute-time cadence, timed triggering, missed-tick coalescing, manual triggering, dropped-follow-up release, single cancellation, and clear-all timer cleanup. The extension test verifies tool/command parity, manual follow-up delivery, message-specific pending acknowledgement, settled-run fallback cleanup, the above-editor multi-line widget, coexistence with the below-editor Herdr Fleet, duplicate suppression, clear, and shutdown timer cleanup.
12
12
 
13
13
  For a manual TUI check, run Pi with `-e ./packages/scheduler/extensions/scheduler.ts`, create a short schedule, confirm the status line appears, and verify the prompt starts only after the current turn settles.
14
14
 
Binary file