@maxiaochao/pi-toolkit 0.10.5 → 0.10.7

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 (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +27 -4
  3. package/package.json +7 -7
  4. package/packages/herdr-subagents/CHANGELOG.md +6 -0
  5. package/packages/herdr-subagents/README.md +12 -1
  6. package/packages/herdr-subagents/TESTING.md +4 -3
  7. package/packages/herdr-subagents/agents/analyst.md +8 -0
  8. package/packages/herdr-subagents/extensions/herdr-subagents.ts +1 -1
  9. package/packages/herdr-subagents/package.json +1 -1
  10. package/packages/herdr-subagents/src/parameters.ts +2 -2
  11. package/packages/herdr-subagents/src/status.ts +95 -0
  12. package/packages/herdr-subagents/src/tool.ts +13 -2
  13. package/packages/herdr-subagents/src/validation.ts +2 -1
  14. package/packages/herdr-subagents/tests/extension-contract.ts +4 -0
  15. package/packages/herdr-subagents/tests/run-tests.mjs +40 -2
  16. package/packages/scheduler/CHANGELOG.md +5 -0
  17. package/packages/scheduler/README.md +57 -0
  18. package/packages/scheduler/TESTING.md +19 -0
  19. package/packages/scheduler/docs/demo.gif +0 -0
  20. package/packages/scheduler/docs/screenshot.png +0 -0
  21. package/packages/scheduler/extensions/scheduler.ts +133 -0
  22. package/packages/{todo → scheduler}/package.json +7 -7
  23. package/packages/scheduler/src/scheduler.ts +171 -0
  24. package/packages/scheduler/tests/scheduler-core.mjs +63 -0
  25. package/packages/scheduler/tests/scheduler-extension.mjs +81 -0
  26. package/skills/agent-team/SKILL.md +33 -57
  27. package/skills/agent-team/models.json +19 -12
  28. package/packages/todo/CHANGELOG.md +0 -9
  29. package/packages/todo/README.md +0 -73
  30. package/packages/todo/TESTING.md +0 -22
  31. package/packages/todo/docs/demo.gif +0 -0
  32. package/packages/todo/docs/screenshot.png +0 -0
  33. package/packages/todo/extensions/todo.ts +0 -279
  34. package/packages/todo/src/todo-state.ts +0 -159
  35. package/packages/todo/tests/todo-extension.mjs +0 -134
  36. package/packages/todo/tests/todo-state.mjs +0 -108
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 2026-10-09 - v0.10.7
2
+
3
+ - bundle scheduler v0.1.0: add session-scoped interval prompts with Agent and user controls
4
+ - Add the bundled and independently installable `@maxiaochao/pi-scheduler` package for session-scoped interval prompts, manual triggering, lightweight TUI status, and shared Agent/user controls.
5
+
6
+ ## 2026-10-09 - v0.10.6
7
+
8
+ - bundle herdr-subagents v0.4.3: add per-run status, neutral analyst persona, and tool-agnostic Agent Team protocol
9
+ - Add per-run Herdr Subagents status queries so background review gates can inspect one run without polling the full history archive.
10
+ - Make Agent Team a tool-agnostic concurrent review protocol, decouple models from rotating methodological roles, and add a neutral read-only analyst persona.
11
+ - Remove Todo from the default toolkit bundle and loadout; users who want its progress UI can install `@maxiaochao/pi-todo` explicitly.
12
+
1
13
  ## 2026-10-08 - v0.10.5
2
14
 
3
15
  - bundle herdr-subagents v0.4.2: add Alt+P and /parent controls to return from child Agents
package/README.md CHANGED
@@ -16,21 +16,44 @@ Update it:
16
16
  pi update npm:@maxiaochao/pi-toolkit
17
17
  ```
18
18
 
19
- The root package embeds the child-package source snapshots listed below. Install the complete toolkit or selected standalone children, not both.
19
+ The root package embeds its enabled child-package source snapshots. Todo is intentionally excluded from the default toolkit and remains available as an opt-in standalone package.
20
+
21
+ Existing toolkit users can pick up the change with:
22
+
23
+ ```bash
24
+ pi update npm:@maxiaochao/pi-toolkit
25
+ ```
26
+
27
+ Restart Pi after updating. The `todo` tool and widget will no longer load. `pi update --extensions` has the same effect when updating all installed packages. Explicitly versioned sources remain pinned and must be changed or reinstalled without the old version suffix.
20
28
 
21
29
  ## Child packages
22
30
 
23
31
  | Package | Capability | Install | Update |
24
32
  |---|---|---|---|
25
- | [pi-todo](packages/todo/README.md) | Branch-local execution plans, guarded advancement, `/todos` history | `pi install npm:@maxiaochao/pi-todo` | `pi update npm:@maxiaochao/pi-todo` |
33
+ | [pi-todo](packages/todo/README.md) | Optional branch-local execution plan UI; not bundled with pi-toolkit | `pi install npm:@maxiaochao/pi-todo` | `pi update npm:@maxiaochao/pi-todo` |
26
34
  | [pi-cache-export](packages/cache-export/README.md) | Tau-aligned interactive cache dashboard for Pi sessions | `pi install npm:@maxiaochao/pi-cache-export` | `pi update npm:@maxiaochao/pi-cache-export` |
27
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` |
28
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
+ | [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` |
29
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` |
30
39
 
31
40
  Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
32
41
 
33
- Multiple standalone children may be installed together. Do not install a child alongside the complete toolkit because that exposes the same resource twice.
42
+ 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
+
44
+ To keep Todo after updating the toolkit:
45
+
46
+ ```bash
47
+ pi install npm:@maxiaochao/pi-todo
48
+ ```
49
+
50
+ If Todo was already installed separately and should be removed completely:
51
+
52
+ ```bash
53
+ pi remove npm:@maxiaochao/pi-todo
54
+ ```
55
+
56
+ Use `--local` with install or remove when the package declaration belongs to a project's `.pi/settings.json` rather than personal settings.
34
57
 
35
58
  Root and child versions remain independent, but repository release tooling publishes a modified child and a new toolkit snapshot from the same commit. Run `pi update --extensions` to update all installed packages. Explicit version sources such as `npm:@maxiaochao/pi-toolkit@0.9.3` remain pinned.
36
59
 
@@ -64,7 +87,7 @@ bash scripts/install.sh
64
87
  | `html-artifact` | Build self-contained HTML explainers and diagrams |
65
88
  | `pdlog` | Decompress and inspect Pudu `.pdlog` files |
66
89
  | `herdr` | Control Herdr workspaces, tabs, panes, and agents |
67
- | `agent-team` | Run cross-vendor adversarial review through Herdr |
90
+ | `agent-team` | Run tool-agnostic, cross-vendor adversarial review with independent concurrent agents |
68
91
  | `tapd` | Use the TAPD CLI for requirements, defects, tasks, and wikis |
69
92
 
70
93
  Package-specific skills, such as `pi-worktree`, are documented by their child package.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.10.5",
4
- "description": "Personal pi coding agent toolkit: execution plans, /btw, /cache_export and apply_patch extensions, browser automation, and bundled theme/skills.",
3
+ "version": "0.10.7",
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": [
7
7
  "packages/*"
@@ -12,10 +12,10 @@
12
12
  ],
13
13
  "files": [
14
14
  "extensions",
15
- "packages/todo",
16
15
  "packages/cache-export",
17
16
  "packages/codex-edit",
18
17
  "packages/herdr-subagents",
18
+ "packages/scheduler",
19
19
  "packages/worktree",
20
20
  "themes",
21
21
  "README.md",
@@ -30,11 +30,11 @@
30
30
  },
31
31
  "pi": {
32
32
  "extensions": [
33
- "./packages/todo/extensions/todo.ts",
34
33
  "./packages/cache-export/extensions/cache-export.ts",
35
34
  "./extensions/btw/index.ts",
36
35
  "./packages/codex-edit/extensions/codex-edit.ts",
37
- "./packages/herdr-subagents/extensions/herdr-subagents.ts"
36
+ "./packages/herdr-subagents/extensions/herdr-subagents.ts",
37
+ "./packages/scheduler/extensions/scheduler.ts"
38
38
  ],
39
39
  "skills": [
40
40
  "./skills",
@@ -57,8 +57,8 @@
57
57
  "ws": "^8.19.0"
58
58
  },
59
59
  "scripts": {
60
- "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/worktree test && node --experimental-strip-types extensions/btw/tests/history.mjs",
61
- "load-test": "pi -ne -e ./packages/todo/extensions/todo.ts && 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",
60
+ "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
62
  "eval:agents": "node evaluations/agents-md/run.mjs",
63
63
  "eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",
64
64
  "postinstall": "node scripts/install-agents.mjs"
@@ -1,3 +1,9 @@
1
+ ## 2026-10-09 - v0.4.3
2
+
3
+ - add per-run status, neutral analyst persona, and tool-agnostic Agent Team protocol
4
+ - Add `herdr_subagents_control({ action: "status", runId })` for a compact snapshot of one queued, active, blocked, or settled run without loading the full history archive.
5
+ - Add a neutral read-only `analyst` persona for tasks that assign models and methodological roles dynamically.
6
+
1
7
  ## 2026-10-08 - v0.4.2
2
8
 
3
9
  - add Alt+P and /parent controls to return from child Agents
@@ -66,6 +66,14 @@ herdr_subagents({
66
66
 
67
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.
68
68
 
69
+ 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
+
71
+ ```text
72
+ herdr_subagents_control({ action: "status", runId: "<run-id>" })
73
+ ```
74
+
75
+ The snapshot reports the aggregate run state and every task's state, including a blocked question when present. It checks durable run state before queued state, never returns unrelated runs, and does not wait or trigger a parent turn. Do not poll full `history` for progress.
76
+
69
77
  A child missing required information reports `needs-input` with an exact question. The run becomes blocked, keeps its Herdr tab, pane, partial report, and Pi session, and returns control to the parent. Answer through the control tool to continue the original session:
70
78
 
71
79
  ```text
@@ -80,7 +88,7 @@ With an empty editor, press `↓` to enter the list, `↑`/`↓` to choose a tas
80
88
 
81
89
  ## History
82
90
 
83
- Run `/herdr-subagents history` or `herdr_subagents_control({ action: "history" })` to list compact project-local run and task reports after Herdr tabs close. Full results, described documents, artifacts, and persistent Pi sessions remain separate on disk and are not loaded into parent context.
91
+ Run `/herdr-subagents history` or `herdr_subagents_control({ action: "history" })` to list the full compact project-local run and task archive after Herdr tabs close. History does not accept a run ID and is not a status interface. Full results, described documents, artifacts, and persistent Pi sessions remain separate on disk and are not loaded into parent context.
84
92
 
85
93
  Continue a saved conversation in a new Herdr tab with `herdr_subagents_control({ action: "resume", runId: "<run-id>", task: 1, prompt: "Follow up..." })`. Remove only a selected archive with `herdr_subagents_control({ action: "cleanup", runId: "<run-id>" })`. History has no automatic count-based eviction. The extension adds the runtime path to local Git excludes when available (and otherwise uses `.pi/.gitignore`) without overwriting unrelated rules.
86
94
 
@@ -192,6 +200,9 @@ The package includes these personas:
192
200
  | `worker` | write | `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` | Focused implementation work |
193
201
  | `explorer` | read | `read`, `bash`, `grep`, `find`, `ls` | Fast codebase investigation |
194
202
  | `reviewer` | read | `read`, `bash`, `grep`, `find`, `ls` | Correctness and maintainability review |
203
+ | `analyst` | read | `read`, `bash`, `grep`, `find`, `ls` | Neutral analysis with the method and role supplied by each task |
204
+
205
+ `analyst` deliberately does not bind a model or review method. Use the task-level `model` field when independent tasks need different models, and put roles such as auditor, replicator, or adversary in each task prompt.
195
206
 
196
207
  Persona definitions use Markdown frontmatter followed by the child system prompt. For example, save this as `.pi/herdr-subagents/agents/security-reviewer.md`:
197
208
 
@@ -10,18 +10,19 @@
10
10
 
11
11
  ## Cases
12
12
 
13
- - Resolve package, global, and project personas and validate frontmatter/configuration.
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
15
  - Verify all paths use interactive Agent start/prompt without print mode, shell injection, markers, or pane reuse.
16
- - Force write-capable batches to serial execution while allowing bounded read-only parallelism.
16
+ - Force write-capable batches to serial execution while allowing bounded read-only parallelism, including three concurrent `analyst` tasks with independent task-level models.
17
17
  - Preserve compact reports, full results, Pi sessions, blocked questions, and historical follow-ups.
18
18
  - Recover queued/running work after interruption without retrying post-prompt side effects, including truncated reports and recovery errors.
19
19
  - Verify the split public contracts (`herdr_subagents` with required `tasks`, plus `herdr_subagents_control` with required `action`) and injectable extension wiring, including conditional registration only inside Herdr, Fleet rendering, editor focus ownership, terminal-input passthrough, exact-pane navigation, child `/parent` and `Alt+P` return controls, and stable display while another background run queues.
20
20
  - Verify Fleet divider/row formatting, warning/yellow running indicators, success/green completed indicators, persona and task projections, cumulative active duration across blocking/resume, exact Pi usage aggregation from assistant messages, tool results, standalone usage, compaction, and branch summaries, malformed/truncated JSONL tolerance, queued metric omission, and visible-column bounds at narrow widths.
21
21
  - Verify live token updates are file-event driven and polled on each running one-second tick, elapsed repainting exists only while work runs, and widget disposal closes watchers and timers.
22
22
  - Verify selected active/queued run cancellation, blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, idempotent missing-tab cleanup, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
23
+ - Verify `status(runId)` isolates one run, projects queue-only state, prefers durable state over a stale queue record, exposes blocked questions, marks terminal states settled, rejects unknown IDs, and leaves full `history` semantics unchanged.
23
24
  - Verify resumed batches retain newly allocated pane IDs and historical follow-ups exclude simultaneous session writers.
24
25
 
25
26
  ## Latest baseline
26
27
 
27
- All 169 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.
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.
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: analyst
3
+ description: Neutral read-only analyst for dynamically assigned roles
4
+ access: read
5
+ tools: read, bash, grep, find, ls
6
+ ---
7
+
8
+ Act as an independent analyst and follow the role and evaluation method in the task prompt. Do not modify project files. Separate direct evidence, inference, and unknowns, and report concrete evidence with useful paths.
@@ -259,7 +259,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
259
259
  pi.registerTool({
260
260
  name: "herdr_subagents_control",
261
261
  label: "Herdr Subagents Control",
262
- description: "Inspect Herdr personas and run history, cancel active or queued work, answer a blocked task, resume a saved task, or remove a selected archived run. Use herdr_subagents to start new work.",
262
+ description: "Inspect Herdr personas, query one run by ID, list run history, cancel active or queued work, answer a blocked task, resume a saved task, or remove a selected archived run. Use status for one run and history only for the full archive. Use herdr_subagents to start new work.",
263
263
  promptSnippet: "Inspect and control Herdr subagent runs",
264
264
  parameters: HerdrSubagentsControlParams,
265
265
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -16,8 +16,8 @@ export const HerdrSubagentsParams = Type.Object({
16
16
  }, { additionalProperties: false });
17
17
 
18
18
  export const HerdrSubagentsControlParams = Type.Object({
19
- action: StringEnum(["list", "respond", "history", "resume", "cancel", "cleanup"] as const, { description: "Control operation to perform." }),
20
- runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by respond, resume, cancel, or cleanup." })),
19
+ action: StringEnum(["list", "status", "respond", "history", "resume", "cancel", "cleanup"] as const, { description: "Control operation to perform." }),
20
+ runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by status, respond, resume, cancel, or cleanup." })),
21
21
  answer: Type.Optional(Type.String({ minLength: 1, description: "Answer sent to a blocked task when action is respond." })),
22
22
  task: Type.Optional(Type.Integer({ minimum: 1, description: "One-based task number selected for respond or resume." })),
23
23
  prompt: Type.Optional(Type.String({ minLength: 1, description: "Follow-up prompt used by resume." })),
@@ -0,0 +1,95 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadQueuedRuns } from "./queued-runs.ts";
4
+ import { isSettledRunStatus, readReport, readRunRecord, readTaskRecord, type RunStatus, type TaskStatus } from "./records.ts";
5
+ import { findRunDirectory } from "./run-locator.ts";
6
+
7
+ export interface RunStatusTask {
8
+ order: number;
9
+ name: string;
10
+ agent: string;
11
+ status: TaskStatus;
12
+ summary?: string;
13
+ question?: string;
14
+ error?: string;
15
+ }
16
+
17
+ export interface RunStatusSnapshot {
18
+ runId: string;
19
+ label: string;
20
+ status: RunStatus;
21
+ settled: boolean;
22
+ source: "run" | "queue";
23
+ queuedAt?: string;
24
+ startedAt?: string;
25
+ updatedAt?: string;
26
+ completedAt?: string;
27
+ question?: string;
28
+ error?: string;
29
+ tasks: RunStatusTask[];
30
+ }
31
+
32
+ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusSnapshot | undefined> {
33
+ const runsDirectory = join(cwd, ".pi", "herdr-subagents", "runs");
34
+ const runDirectory = await findRunDirectory(runsDirectory, runId);
35
+ if (!runDirectory) return undefined;
36
+ const run = await readRunRecord(join(runDirectory, "run.json"));
37
+ const tasksDirectory = join(runDirectory, "tasks");
38
+ const tasks: RunStatusTask[] = [];
39
+ if (existsSync(tasksDirectory)) for (const entry of readdirSync(tasksDirectory).sort()) {
40
+ const taskDirectory = join(tasksDirectory, entry);
41
+ const task = await readTaskRecord(join(taskDirectory, "task.json"));
42
+ const reportFile = join(taskDirectory, "report.json");
43
+ const reportIsCurrent = task.status === "blocked" || isSettledRunStatus(task.status);
44
+ const report = reportIsCurrent && existsSync(reportFile) ? await readReport(reportFile) : undefined;
45
+ const question = report?.question ?? task.question;
46
+ const error = report?.error ?? task.error;
47
+ tasks.push({
48
+ order: task.order,
49
+ name: task.name ?? task.agent ?? `Task ${task.order}`,
50
+ agent: task.agent ?? "worker",
51
+ status: task.status,
52
+ ...(report?.summary ? { summary: report.summary } : {}),
53
+ ...(question ? { question } : {}),
54
+ ...(error ? { error } : {}),
55
+ });
56
+ }
57
+ return {
58
+ runId: run.id,
59
+ label: run.label,
60
+ status: run.status,
61
+ settled: isSettledRunStatus(run.status),
62
+ source: "run",
63
+ ...(run.startedAt ? { startedAt: run.startedAt } : {}),
64
+ ...(run.updatedAt ? { updatedAt: run.updatedAt } : {}),
65
+ ...(run.completedAt ? { completedAt: run.completedAt } : {}),
66
+ ...(run.question ? { question: run.question } : {}),
67
+ ...(run.error ? { error: run.error } : {}),
68
+ tasks: tasks.sort((left, right) => left.order - right.order),
69
+ };
70
+ }
71
+
72
+ export async function getSubagentRunStatus(cwd: string, runId: string): Promise<RunStatusSnapshot | undefined> {
73
+ const durable = await loadDurableStatus(cwd, runId);
74
+ if (durable) return durable;
75
+ const queued = (await loadQueuedRuns(cwd)).find((candidate) => candidate.runId === runId);
76
+ if (!queued) return undefined;
77
+ return {
78
+ runId: queued.runId,
79
+ label: queued.params.label ?? "batch",
80
+ status: "queued",
81
+ settled: false,
82
+ source: "queue",
83
+ queuedAt: queued.queuedAt,
84
+ tasks: queued.params.tasks.map((task, index) => ({ order: index + 1, name: task.name, agent: task.agent ?? "worker", status: "queued" })),
85
+ };
86
+ }
87
+
88
+ export function runStatusText(status: RunStatusSnapshot): string {
89
+ const lines = [`${status.runId} · ${status.label} · ${status.status} · ${status.tasks.length} tasks`];
90
+ for (const task of status.tasks) {
91
+ const detail = task.question ? ` — ${task.question}` : task.summary ? ` — ${task.summary}` : task.error ? ` — ${task.error}` : "";
92
+ lines.push(` ${task.order}. ${task.name}: ${task.status}${detail}`);
93
+ }
94
+ return lines.join("\n");
95
+ }
@@ -8,6 +8,7 @@ import { HERDR_ACTIONS, validateControlParams, validateRunParams } from "./valid
8
8
  import type { OwnerIdentity } from "./ownership.ts";
9
9
  import { cancelActiveSubagentRuns } from "./reconcile.ts";
10
10
  import { cancelQueuedRuns, loadQueuedRuns } from "./queued-runs.ts";
11
+ import { getSubagentRunStatus, runStatusText } from "./status.ts";
11
12
 
12
13
  export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent?: string; model?: string; }
13
14
 
@@ -19,7 +20,7 @@ export interface HerdrSubagentsRunParams {
19
20
  }
20
21
 
21
22
  export interface HerdrSubagentsControlToolParams {
22
- action: "list" | "respond" | "history" | "resume" | "cancel" | "cleanup";
23
+ action: "list" | "status" | "respond" | "history" | "resume" | "cancel" | "cleanup";
23
24
  runId?: string;
24
25
  answer?: string;
25
26
  task?: number;
@@ -28,7 +29,7 @@ export interface HerdrSubagentsControlToolParams {
28
29
 
29
30
  /** Internal superset used by shared execution helpers. */
30
31
  export interface HerdrSubagentsToolParams {
31
- action?: "list" | "respond" | "history" | "resume" | "cancel" | "cleanup";
32
+ action?: "list" | "status" | "respond" | "history" | "resume" | "cancel" | "cleanup";
32
33
  prompt?: string;
33
34
  agent?: string;
34
35
  label?: string;
@@ -114,6 +115,16 @@ const errorResponse = (text: string, details: Record<string, unknown> = {}): Too
114
115
  });
115
116
 
116
117
  const actionHandlers = {
118
+ async status({ params, ctx }: ToolExecutionContext) {
119
+ try {
120
+ const runId = (params.runId as string).trim();
121
+ const status = await getSubagentRunStatus(ctx.cwd, runId);
122
+ if (!status) return errorResponse(`Unknown Subagent run '${runId}'.`, { action: "status", runId });
123
+ return { content: [{ type: "text", text: runStatusText(status) }], details: { action: "status", ...status } };
124
+ } catch (error) {
125
+ return errorResponse(error instanceof Error ? error.message : String(error), { action: "status", runId: params.runId });
126
+ }
127
+ },
117
128
  async history({ ctx }: ToolExecutionContext) {
118
129
  const history = await listSubagentHistory(ctx.cwd);
119
130
  return { content: [{ type: "text", text: historyText(history) }], details: { action: "history", runs: history } };
@@ -3,6 +3,7 @@ import type { HerdrSubagentsControlToolParams, HerdrSubagentsRunParams } from ".
3
3
  export const HERDR_ACTIONS = {
4
4
  list: { allowed: ["action"], includeRunId: false },
5
5
  history: { allowed: ["action"], includeRunId: false },
6
+ status: { allowed: ["action", "runId"], includeRunId: true },
6
7
  cancel: { allowed: ["action", "runId"], includeRunId: true },
7
8
  cleanup: { allowed: ["action", "runId"], includeRunId: true },
8
9
  respond: { allowed: ["action", "runId", "answer", "task"], includeRunId: true },
@@ -19,7 +20,7 @@ export function validateControlParams(params: HerdrSubagentsControlToolParams):
19
20
  if (!allowed) return "Provide a valid control action.";
20
21
  const extra = Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsControlToolParams));
21
22
  if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
22
- if ((params.action === "cancel" || params.action === "cleanup") && !params.runId?.trim()) return `runId is required for action '${params.action}'.`;
23
+ if ((params.action === "status" || params.action === "cancel" || params.action === "cleanup") && !params.runId?.trim()) return `runId is required for action '${params.action}'.`;
23
24
  if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim())) return "runId and answer are required for action 'respond'.";
24
25
  if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
25
26
  return undefined;
@@ -251,6 +251,10 @@ export default function (_pi: ExtensionAPI) {
251
251
  if (!controlSchema.properties?.[name]?.description) throw new Error(`control schema property '${name}' is missing or undocumented`);
252
252
  }
253
253
  if (!controlSchema.required?.includes("action")) throw new Error("herdr_subagents_control action must be required");
254
+ const controlActions = controlSchema.properties.action.enum ?? controlSchema.properties.action.anyOf?.map((entry: Record<string, unknown>) => entry.const);
255
+ if (!controlActions?.includes("status")) throw new Error("herdr_subagents_control action enum must include status");
256
+ if (!/status/.test(controlSchema.properties.runId.description)) throw new Error("control runId description must document status");
257
+ if (!/Use status for one run/.test(controlTool.description)) throw new Error("control tool description must distinguish status from history");
254
258
  if (!/Foreground calls block/.test(tool.description) || !/background calls return a run ID immediately/.test(tool.description)) {
255
259
  throw new Error("tool description does not distinguish foreground and background execution");
256
260
  }
@@ -141,14 +141,16 @@ function latestRunDirectory() {
141
141
  try {
142
142
  check("run validation accepts one or more tasks", validateRunParams({ tasks: [{ name: "one", prompt: "do it" }], concurrency: 2, background: true }) === undefined);
143
143
  check("control validation accepts supported calls", [
144
- { action: "list" }, { action: "history" }, { action: "cancel", runId: "run" }, { action: "cleanup", runId: "run" },
144
+ { action: "list" }, { action: "history" }, { action: "status", runId: "run" }, { action: "cancel", runId: "run" }, { action: "cleanup", runId: "run" },
145
145
  { action: "respond", runId: "run", answer: "yes", task: 1 },
146
146
  { action: "resume", runId: "run", task: 1, prompt: "continue" },
147
147
  ].every((params) => validateControlParams(params) === undefined));
148
- check("all supported actions have shared validation and rendering metadata", ["list", "history", "cancel", "cleanup", "respond", "resume"].every((action) => action in HERDR_ACTIONS));
148
+ check("all supported actions have shared validation and rendering metadata", ["list", "history", "status", "cancel", "cleanup", "respond", "resume"].every((action) => action in HERDR_ACTIONS));
149
149
  check("split validation rejects missing tasks and invalid control fields", [
150
150
  validateRunParams({ tasks: [] }),
151
151
  validateControlParams({ action: "list", prompt: "not allowed" }),
152
+ validateControlParams({ action: "status" }),
153
+ validateControlParams({ action: "history", runId: "not-allowed" }),
152
154
  validateControlParams({ action: "respond", runId: "run" }),
153
155
  validateControlParams({ action: "resume", runId: "run", task: 1 }),
154
156
  ].every((message) => typeof message === "string" && message.length > 0));
@@ -404,6 +406,9 @@ try {
404
406
  writeFileSync(join(agentsDir, "broken.md"), "not frontmatter");
405
407
  const discovery = discoverPackageAgents(agentsDir);
406
408
  check("persona discovery and diagnostics", discovery.agents.length === 1 && discovery.agents[0].name === "worker" && discovery.diagnostics.length === 1);
409
+ const packageDiscovery = discoverPackageAgents(join(process.cwd(), "agents"));
410
+ const analystPersona = packageDiscovery.agents.find((persona) => persona.name === "analyst");
411
+ check("built-in analyst is neutral and read-only", packageDiscovery.diagnostics.length === 0 && analystPersona?.access === "read" && analystPersona.model === undefined && analystPersona.tools?.join(",") === "read,bash,grep,find,ls" && /independent analyst/.test(analystPersona.systemPrompt) && /direct evidence, inference, and unknowns/.test(analystPersona.systemPrompt) && !/auditor|replicator|adversary/.test(analystPersona.systemPrompt));
407
412
 
408
413
  const configRoot = join(root, "configuration");
409
414
  const packageAgents = join(configRoot, "package-agents");
@@ -443,6 +448,18 @@ try {
443
448
  const configuredStart = configuredHerdr.calls.find(([name]) => name === "startAgent")[1];
444
449
  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"));
445
450
 
451
+ const analystHerdr = fakeHerdr();
452
+ const analystBatch = await executeHerdrSubagents({ label: "independent-analysis", concurrency: 3, tasks: [
453
+ { name: "sol-audit", prompt: "slow audit", agent: "analyst", model: "vendor/sol" },
454
+ { name: "grok-replicate", prompt: "slow replicate", agent: "analyst", model: "vendor/grok" },
455
+ { name: "opus-challenge", prompt: "slow challenge", agent: "analyst", model: "vendor/opus" },
456
+ ] }, undefined, undefined, { cwd: root, model: { provider: "parent", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: analystHerdr });
457
+ const analystStarts = analystHerdr.calls.filter(([name]) => name === "startAgent").map(([, request]) => request);
458
+ const analystModels = analystStarts.map((request) => request.args[request.args.indexOf("--model") + 1]);
459
+ check("three read-only analyst tasks run concurrently", !analystBatch.isError && analystBatch.details.effectiveConcurrency === 3 && analystHerdr.maxActive === 3);
460
+ check("analyst tasks preserve independent model overrides", analystModels.join(",") === "vendor/sol,vendor/grok,vendor/opus");
461
+ check("completed analyst batch closes its run tab", analystHerdr.calls.at(-1)?.[0] === "closeTab");
462
+
446
463
  const batchHerdr = fakeHerdr();
447
464
  const batchUpdates = [];
448
465
  const batch = await executeHerdrSubagents({ label: "reviews", concurrency: 2, tasks: [
@@ -603,10 +620,31 @@ try {
603
620
  const stableHerdr = fakeHerdr();
604
621
  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 });
605
622
  check("preallocated background run id stays stable", stable.details.runId === backgroundId && readFileSync(join(stable.details.recordDirectory, "run.json"), "utf8").includes(backgroundId));
623
+ const stableStatus = await executeHerdrSubagents({ action: "status", runId: backgroundId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents") });
624
+ check("status returns only one durable run with task details", !stableStatus.isError && stableStatus.details.runId === backgroundId && stableStatus.details.status === "completed" && stableStatus.details.settled === true && stableStatus.details.source === "run" && stableStatus.details.tasks[0].name === "stable" && !stableStatus.content[0].text.includes("run-one"));
625
+ await persistQueuedRun(root, backgroundId, { label: "stale-queue", tasks: [{ name: "wrong-source", prompt: "queued" }], background: true });
626
+ const durableFirstStatus = await executeHerdrSubagents({ action: "status", runId: backgroundId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents") });
627
+ check("status prefers durable state over a stale queue record", durableFirstStatus.details.source === "run" && durableFirstStatus.details.label !== "stale-queue" && durableFirstStatus.details.tasks[0].name === "stable");
628
+ const queuedStatusId = "queued-status-id";
629
+ await persistQueuedRun(root, queuedStatusId, { label: "queued-status", tasks: [{ name: "waiting", prompt: "wait", agent: "reviewer" }], background: true });
630
+ const queuedStatus = await executeHerdrSubagents({ action: "status", runId: queuedStatusId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents") });
631
+ check("status projects a queued run before durable records exist", !queuedStatus.isError && queuedStatus.details.source === "queue" && queuedStatus.details.status === "queued" && queuedStatus.details.settled === false && queuedStatus.details.tasks[0].status === "queued" && queuedStatus.details.tasks[0].agent === "reviewer");
632
+ const unknownStatus = await executeHerdrSubagents({ action: "status", runId: "unknown-status-id" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents") });
633
+ check("status rejects unknown run IDs without falling back to history", unknownStatus.isError && unknownStatus.content[0].text === "Unknown Subagent run 'unknown-status-id'.");
634
+ const staleStatusRoot = join(root, "stale-status-report");
635
+ const staleStatusTaskDirectory = join(staleStatusRoot, ".pi", "herdr-subagents", "runs", "stale", "tasks", "01-task");
636
+ mkdirSync(staleStatusTaskDirectory, { recursive: true });
637
+ writeFileSync(join(staleStatusTaskDirectory, "..", "..", "run.json"), JSON.stringify({ id: "stale-status", label: "stale-status", status: "running", startedAt: "2026-10-08T00:00:00.000Z" }));
638
+ writeFileSync(join(staleStatusTaskDirectory, "task.json"), JSON.stringify({ id: "stale-task", runId: "stale-status", order: 1, name: "stale-task", agent: "reviewer", status: "running" }));
639
+ writeFileSync(join(staleStatusTaskDirectory, "report.json"), JSON.stringify({ status: "needs-input", summary: "Old blocked report.", question: "Old question?", documents: [] }));
640
+ const runningStatus = await executeHerdrSubagents({ action: "status", runId: "stale-status" }, undefined, undefined, { cwd: staleStatusRoot }, { agentsDirectory: join(process.cwd(), "agents") });
641
+ check("running status does not expose a stale report from an earlier blocked turn", runningStatus.details.tasks[0].status === "running" && !runningStatus.details.tasks[0].question && !runningStatus.details.tasks[0].summary && !runningStatus.content[0].text.includes("Old question"));
606
642
 
607
643
  const blockedHerdr = fakeHerdr("blocked");
608
644
  const blocked = await executeHerdrSubagents({ concurrency: 1, tasks: [{ name: "question", prompt: "inspect branch", agent: "explorer" }, { name: "after-answer", prompt: "continue queued", agent: "reviewer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
609
645
  check("needs-input returns foreground control and preserves tab", blocked.details.status === "blocked" && blocked.details.tasks[0].question === "Which branch should I inspect?" && !blockedHerdr.calls.some(([name]) => name === "closeTab"));
646
+ const blockedStatus = await executeHerdrSubagents({ action: "status", runId: blocked.details.runId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents") });
647
+ check("status exposes blocked task questions for respond", blockedStatus.details.status === "blocked" && blockedStatus.details.settled === false && blockedStatus.details.tasks[0].question === "Which branch should I inspect?" && blockedStatus.content[0].text.includes("Which branch should I inspect?"));
610
648
  const activeCleanup = await executeHerdrSubagents({ action: "cleanup", runId: blocked.details.runId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
611
649
  check("cleanup refuses to delete an active blocked run", activeCleanup.isError && activeCleanup.content[0].text.includes("still active") && existsSync(blocked.details.recordDirectory));
612
650
  const resumeUpdates = [];
@@ -0,0 +1,5 @@
1
+ ## 2026-10-09 - v0.1.0
2
+
3
+ - add session-scoped interval prompts with Agent and user controls
4
+ - Add session-scoped interval prompts controlled by both the Agent tool and `/schedule` command.
5
+ - Add manual trigger, cancellation, missed-tick coalescing, and lightweight TUI status.
@@ -0,0 +1,57 @@
1
+ # Pi Scheduler
2
+
3
+ Session-scoped interval prompts for Pi. Scheduler does one thing: at a fixed interval, queue a prompt for the current main agent at a safe turn boundary.
4
+
5
+ ![Scheduler status and commands](docs/screenshot.png)
6
+
7
+ ![Scheduler demo](docs/demo.gif)
8
+
9
+ ## Why
10
+
11
+ Use Scheduler when the agent should periodically re-check CI, logs, a review, or another changing condition. It is not Cron, a background process manager, or a durable workflow engine.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pi install npm:@maxiaochao/pi-scheduler
17
+ ```
18
+
19
+ Update it with `pi update npm:@maxiaochao/pi-scheduler`.
20
+
21
+ ## Agent tool
22
+
23
+ ```text
24
+ schedule({ action: "add", every: "5m", prompt: "Check CI; report completion or failure." })
25
+ schedule({ action: "list" })
26
+ schedule({ action: "trigger", id: "schedule-1" })
27
+ schedule({ action: "cancel", id: "schedule-1" })
28
+ ```
29
+
30
+ ## User command
31
+
32
+ ```text
33
+ /schedule add 5m Check CI; report completion or failure.
34
+ /schedule list
35
+ /schedule trigger schedule-1
36
+ /schedule cancel schedule-1
37
+ ```
38
+
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.
40
+
41
+ ## Semantics and limits
42
+
43
+ - Intervals accept `s`, `m`, `h`, and `d`, from 1 second through 7 days.
44
+ - Prompts use Pi `followUp`: an active agent turn finishes before the scheduled prompt runs.
45
+ - At most one prompt per schedule remains pending; missed intervals coalesce instead of flooding the queue.
46
+ - 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.
48
+ - Timing is best-effort, not real-time. Recursive `setTimeout` uses absolute target times to avoid cumulative drift.
49
+ - Calendar expressions, time zones, persistence, process monitoring, and script notifications are intentionally out of scope.
50
+
51
+ ## Remove
52
+
53
+ ```bash
54
+ pi remove npm:@maxiaochao/pi-scheduler
55
+ ```
56
+
57
+ Do not install both the standalone package and a toolkit release that already bundles Scheduler, or the tool and command will be registered twice.
@@ -0,0 +1,19 @@
1
+ # Testing Pi Scheduler
2
+
3
+ Run from the repository root:
4
+
5
+ ```bash
6
+ npm --prefix packages/scheduler test
7
+ npm --prefix packages/scheduler run load-test
8
+ npm pack ./packages/scheduler --dry-run
9
+ ```
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.
12
+
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
+
15
+ Regenerate package media with:
16
+
17
+ ```bash
18
+ node scripts/capture-package-media.mjs
19
+ ```
Binary file