Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom

This commit is contained in:
2026-05-08 15:59:25 +10:00
parent d0d1d9b045
commit 31b4110c87
457 changed files with 85157 additions and 0 deletions

View File

@@ -0,0 +1,446 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.7.1] - 2026-05-07
> **Heads-up — behavior change:**
> - `isolation: "worktree"` now fails loud (returns an error) instead of silently falling back to the main tree. Affects users running pi in a non-git directory or a fresh repo with no commits.
### Changed
- **`isolation: "worktree"` now fails loud instead of silently falling back.** Previously when `createWorktree` returned undefined (not a git repo, no commits yet, or `git worktree add` failed), the agent ran in the main `cwd` with a `[WARNING: ...]` block prepended to its prompt — visible only to the LLM, never surfaced to the caller. Now the failure throws a structured error that propagates back to the `Agent` tool response; no agent record is created. Failed scheduled fires are recorded as `lastStatus: "error"` with the reason in the `subagents:scheduled` error event. Queued background spawns whose worktree creation fails when they dequeue are marked terminal-error and don't block the rest of the queue.
### Fixed
- **Headless `pi --print` runs no longer hang or crash after background
subagents complete.** Cleanup timers no longer keep the process alive, and
stale completion notifications are treated as best-effort shutdown side
effects.
## [0.7.0] - 2026-05-04
> **Heads-up — behavior changes:**
> - `subagents:completed`/`failed` event `tokens.total` now excludes `cacheRead` (previously double-counted across turns) — see Fixed [#38].
> - Cron `?` is now a wildcard (same as `*`), not "current time value" — affects Quartz-style expressions only.
### Changed
- **`@mariozechner/pi-{ai,coding-agent,tui}` moved to `peerDependencies` (`>=0.70.5`).** Avoids duplicate framework instances when the host loads this extension.
- **`@sinclair/typebox` pinned from `latest` to `^0.34.49`** so installs are reproducible.
- **`croner` bumped 8 → 10.** Heads-up: in cron strings, `?` now means wildcard (same as `*`) instead of "current time value" — affects Quartz-style expressions only.
### Added
- **Master switch for scheduling** — new `schedulingEnabled` setting (default `true`) under `/agents → Settings → Scheduling`. When set to `false`: the `schedule` parameter and its guideline are stripped from the `Agent` tool spec at registration (zero LLM-context cost), the scheduler does not bind to the session, the `/agents → Scheduled jobs` menu entry is hidden, and any in-flight scheduler is stopped immediately. The schema-level removal applies on next pi session; the runtime kill (menu, fire path) takes effect immediately. Persisted at `<cwd>/.pi/subagents.json`.
- **Schedule subagent spawns** — the `Agent` tool now accepts an optional `schedule` parameter. When set, the spawn registers a job that fires later instead of running immediately. Three formats: 6-field cron (`"0 0 9 * * 1"` — 9am every Monday), interval (`"5m"`, `"1h"`), or one-shot (`"+10m"` or ISO timestamp). Returns the job ID. Schedules are session-scoped — they reset on `/new`, restore on `/resume` (mirrors the persistence model of pi-chonky-tasks). Storage at `<cwd>/.pi/subagent-schedules/<sessionId>.json`, with PID-based file locking + atomic temp+rename for concurrent-instance safety. **Result delivery is identical to today's background-spawn completions**: when the scheduled agent finishes, the existing `subagent-notification` followUp path emits the result to the conversation — no new delivery code, no new message types. **Concurrency**: scheduled fires bypass `maxConcurrent` so a 5-minute interval can't be deferred behind 4 long-running manual agents. **Management**: `/agents` → "Scheduled jobs" lists active jobs and lets you cancel any one of them. Creation is via the `Agent` tool only — no parallel manual-create wizard in this iteration. **Events**: `subagents:scheduled` ({ type: "added" | "removed" | "updated" | "fired" | "error", … }) and `subagents:scheduler_ready` for cross-extension consumers. **Restrictions**: `schedule` is incompatible with `inherit_context` (no parent at fire time) and `resume` (schedules create fresh agents); forces `run_in_background: true`. Scheduler engine mirrors `pi-cron-schedule` (`croner` for cron, `setInterval`/`setTimeout` for interval/once); past one-shot timestamps and invalid cron expressions are caught at create time.
- **Context-window utilization indicator in the subagent overlay** — token count is now followed by a colored `(NN%)` showing how full the subagent's context is right now (`estimateContextTokens(messages) / model.contextWindow * 100`, sourced from upstream `contextUsage.percent`). Threshold colors: <70% dim, 7085% warning, ≥85% error. Gracefully omitted when the model has no `contextWindow` declared, or right after compaction before the next assistant turn (`tokens` is `null` in that window). The same annotation slot also surfaces a compaction count `↻N` when the agent has compacted at least once — e.g. `12.3k token (84% · ↻3)` (percent + compactions joined with `·`), `12.3k token (↻1)` (compactions only, immediately post-compaction while percent is still null). The compaction glyph stays dim regardless; the percent's threshold color carries the urgency signal. Two live overlays get the annotations (running stats line; inspect-overlay header); post-completion notifications and result/event payloads only get the count (the indicator is no longer actionable once the agent is done).
- **Token usage and context% exposed to the parent agent** at every interaction surface — `get_subagent_result` adds `Context: NN%` to its stats line; `steer_subagent` returns a `Current state: 12.3k token · 5 tool uses · context 72% full` line so the steering agent knows whether it has room before sending more context; `task-notification` XML adds `<context_percent>NN</context_percent>` (omitted when null). All plain-text, no ANSI codes — designed for LLM consumption, not human display.
- **New `subagents:compacted` lifecycle event** fires when a subagent's session successfully compacts. Payload: `{ id, type, description, reason: "manual" | "threshold" | "overflow", tokensBefore, compactionCount }``tokensBefore` is upstream's pre-compaction context size estimate; `compactionCount` is the running total for this agent (also persisted on `AgentRecord.compactionCount` and surfaced in `get_subagent_result` / `steer_subagent` / `task-notification` when > 0). Aborted compactions don't fire. Routed through a new manager-level `onCompact` constructor callback, matching the existing `onStart` / `onComplete` pattern.
### Fixed
- **Subagent token count was inflated 515× and reset mid-run** ([#38](https://github.com/tintinweb/pi-subagents/issues/38)). Two distinct bugs in the same field. (1) Upstream `getSessionStats().tokens.total` sums per-turn `cacheRead` across every assistant message — but each turn's `cacheRead` is the *cumulative* cached prefix re-read on that one API call, so summing N turns counts the prefix N times (quadratic inflation, very visible on long sessions). (2) Even with that fixed, anything derived from `session.state.messages` resets at compaction because upstream replaces the array via `this.agent.state.messages = sessionContext.messages`. Fix replaces all six display readers with a lifetime accumulator (`AgentRecord.lifetimeUsage` and `AgentActivity.lifetimeUsage``{ input, output, cacheWrite }`) fed by a new `onAssistantUsage` callback dispatched from `message_end` events in both `runAgent` and `resumeAgent`. The accumulator is independent of `state.messages` mutation, so it survives compaction; total = input + output + cacheWrite by construction (cacheRead deliberately excluded — same prefix-double-counting reason). The `subagents:completed`/`failed` event payload's `tokens` field is now also lifetime-accumulated for `input`, `output`, and `total` together (was: `total` lifetime, `input`/`output` session-derived → inconsistent after compaction).
- **ESC during a foreground `Agent` call now actually stops the subagent** ([#44](https://github.com/tintinweb/pi-subagents/pull/44) — thanks [@Zeng-Zer](https://github.com/Zeng-Zer)). Pi's interrupt path is `esc → agent.abort()` on the parent → `AbortSignal` delivered to every tool's `execute(toolCallId, params, signal, …)`, but the `Agent` tool dropped that signal on the floor: subagents ran on their own independent `AbortController` inside `AgentManager`, so the parent abort was invisible and the subagent kept running until natural completion or `max_turns`. Fix threads `signal` through `Agent.execute``manager.spawnAndWait()``SpawnOptions.signal`, and `AgentManager.startAgent()` now attaches an `{ once: true }` `"abort"` listener that calls `this.abort(id)` (which sets `status: "stopped"` and aborts the child controller). The listener is detached in both `.then` and `.catch` to avoid leaking on natural settle. **Scope:** foreground only — background agents intentionally outlive the parent tool call, so their spawn deliberately does not forward `signal`. Resume path (`AgentManager.resume()`) has the same blind spot and is tracked as a follow-up.
## [0.6.3] - 2026-04-28
### Fixed
- **`run_in_background: true` (and `inherit_context`, `isolated`) silently ignored on default agents** ([#37](https://github.com/tintinweb/pi-subagents/issues/37) — thanks [@kylesnowschwartz](https://github.com/kylesnowschwartz) for the diagnosis). The three built-in defaults (`general-purpose`, `Explore`, `Plan`) baked `runInBackground: false`, `inheritContext: false`, and `isolated: false` into their configs. `resolveAgentInvocationConfig` uses `agentConfig?.field ?? params.field ?? false`, and `??` only falls through on `null`/`undefined` — so an explicit `false` from the agent config silently won over the caller's `true`. Calling `Agent({ subagent_type: "general-purpose", run_in_background: true })` returned the result inline instead of backgrounding, blocking the parent UI for the agent's full runtime. Fix drops the three lines from each default (and from the unreachable defensive fallback in `agent-runner.ts`) — the type already declared each as `field?: boolean` with JSDoc *"undefined = caller decides"*, so the runtime now matches the documented contract. **Behavior:** custom agents that explicitly set these fields in frontmatter still lock as before (the v0.5.1 "frontmatter is authoritative" guarantee is preserved); the fix only stops *defaults* from spuriously claiming an opinion on callsite-strategy fields they don't actually have. The unreachable fallback now spreads `DEFAULT_AGENTS.get("general-purpose")` instead of duplicating the config inline, so future drift is impossible.
## [0.6.2] - 2026-04-28
### Fixed
- **`Agent` tool fails on Windows with `ENOENT` creating output directory** ([#27](https://github.com/tintinweb/pi-subagents/issues/27) — thanks [@sixnathan](https://github.com/sixnathan) for the diagnosis). The cwd-encoding regex in `output-file.ts` only handled POSIX `/` separators, so on Windows `cwd = "C:\\Users\\foo\\project"` survived unchanged and `path.join(tmpRoot, encoded, …)` produced an invalid nested-absolute path. Now extracts a small `encodeCwd()` helper that handles both `/` and `\\` separators, strips the Windows drive-letter prefix, and preserves UNC server/share segments. The `chmodSync(root, 0o700)` call is also wrapped in a try/catch that swallows errors only on Windows (where chmod is a no-op and can throw on some filesystems); on Unix the error still propagates so umask-defeating `0o700` enforcement is preserved.
## [0.6.1] - 2026-04-25
### Added
- **Persistent `/agents` → Settings** ([#24](https://github.com/tintinweb/pi-subagents/issues/24)) — the four runtime tuning values (`maxConcurrent`, `defaultMaxTurns`, `graceTurns`, `defaultJoinMode`) now survive pi restarts via a two-file dual-scope model mirroring pi's own `SettingsManager`. Global `~/.pi/agent/subagents.json` provides machine-wide defaults (edit by hand; the menu never writes here); project `<cwd>/.pi/subagents.json` holds per-project overrides (written by `/agents` → Settings). Load merges both with project winning on conflicts. Invalid fields are silently dropped per field; malformed JSON emits a warning to stderr and falls back to defaults so startup always proceeds; write failures downgrade the settings toast to a warning with `(session only; failed to persist)` so changes aren't silently reverted on next restart.
- **New lifecycle events** — `subagents:settings_loaded` (emitted once at extension init with the merged settings) and `subagents:settings_changed` (emitted on each `/agents` → Settings mutation with the new snapshot and a `persisted: boolean` flag so listeners can react to write failures).
### Fixed
- **`AGENTS.md` / `CLAUDE.md` / `APPEND_SYSTEM.md` no longer leak into sub-agent prompts** ([#26](https://github.com/tintinweb/pi-subagents/pull/26) — thanks [@mikeyobrien](https://github.com/mikeyobrien) for the diagnosis). Upstream `buildSystemPrompt()` re-appends `contextFiles` and `appendSystemPrompt` *after* our `systemPromptOverride` runs, which silently defeated `prompt_mode: replace` and `isolated: true` — parent project context (e.g. autoresearch-mode blocks) was bleeding into fresh `Explore` / custom sub-agents regardless of frontmatter. Fix uses upstream's `noContextFiles: true` flag (skips the load entirely, introduced in pi 0.68) plus `appendSystemPromptOverride: () => []` (no flag equivalent for append sources). **Behavior change:** subagents no longer implicitly inherit parent `AGENTS.md`/`CLAUDE.md`/`APPEND_SYSTEM.md`. To get parent project context into a subagent, use `prompt_mode: append` (parent's already-built system prompt flows in via `systemPromptOverride`), or `inherit_context: true` (parent conversation), or inline the content into the agent's own frontmatter.
- **Custom agent discovery respects `PI_CODING_AGENT_DIR`** ([#35](https://github.com/tintinweb/pi-subagents/pull/35), closes [#23](https://github.com/tintinweb/pi-subagents/issues/23) — thanks [@Amolith](https://github.com/Amolith) for the diagnosis). Two remaining hardcoded `~/.pi/agent/agents/` paths in `custom-agents.ts` and `index.ts` bypassed the env var, so users who relocated their agent directory (e.g. via `PI_CODING_AGENT_DIR`) still had global agents loaded from the default location and help text referencing the wrong path. Both now use upstream `getAgentDir()`, consistent with `agent-runner.ts` and `settings.ts`; tilde expansion is handled by upstream.
## [0.6.0] - 2026-04-24
> **⚠️ Breaking: drops support for `pi` < 0.68.** The upstream `pi-coding-agent` package shipped breaking API changes in v0.68 (and further ones in v0.70). This release migrates to `^0.70.2` and is **not** backward-compatible with hosts on `pi` 0.620.67. Users on those versions must upgrade their `pi` installation (`npm install -g @mariozechner/pi-coding-agent@latest`) before updating this extension.
### Changed
- **Bumped peer `@mariozechner/pi-coding-agent` to `^0.70.2`** ([#28](https://github.com/tintinweb/pi-subagents/pull/28)) — crosses the v0.68 breaking-change line upstream. Specifically: tools are now passed as `string[]` (was `Tool[]`); `cwd`/`agentDir` are mandatory on `SettingsManager.create()` and `DefaultResourceLoader`; `session_switch` event renamed to `session_before_switch`; `ToolDefinition.params` widens to `unknown` under contextual typing, requiring `defineTool(...)`.
- **Tool registrations wrapped with `defineTool(...)`** — preserves `TParams` inference so `execute` handlers get properly-typed `params` instead of `unknown`. Applies to the `Agent`, `get_subagent_result`, and `steer_subagent` tools.
### Removed
- **Cwd-bound tool factory registry** — the internal `TOOL_FACTORIES` closure table and `create{Bash,Edit,Read,Write,Grep,Find,Ls}Tool` imports are gone. Exported helpers renamed: `getToolsForType(type, cwd)``getToolNamesForType(type)`, `getMemoryTools(cwd, set)``getMemoryToolNames(set)`, `getReadOnlyMemoryTools(cwd, set)``getReadOnlyMemoryToolNames(set)` — all returning `string[]` instead of `Tool[]`. The host binds cwd when resolving tool names, so the extension no longer instantiates tools directly.
### Fixed
- **Subagent `SettingsManager` read wrong project settings in worktree mode** ([#30](https://github.com/tintinweb/pi-subagents/pull/30)) — `SettingsManager.create()` was called without arguments, defaulting `cwd` to `process.cwd()`. When the subagent's effective cwd differed (worktree isolation or explicit `cwd` override), its settings manager read `.pi/settings.json` from the parent's cwd rather than its own, diverging from the loader and session manager. Now passes `effectiveCwd` and `agentDir` explicitly, keeping all three managers consistent.
## [0.5.2] - 2026-03-26
### Fixed
- **Extension `session_start` handlers now fire in subagent sessions** ([#20](https://github.com/tintinweb/pi-subagents/issues/20)) — `bindExtensions()` was never called on subagent sessions, so extensions that initialize state in `session_start` (e.g. loading credentials, setting up connections) silently failed at runtime. Tools appeared registered but were non-functional. Now calls `session.bindExtensions()` after tool filtering and before prompting, matching the lifecycle used by pi's interactive, print, and RPC modes. Also triggers `extendResourcesFromExtensions("startup")` so extension-provided skills and prompts are discovered.
## [0.5.1] - 2026-03-24
### Changed
- **Agent config is authoritative** — frontmatter values for `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, and `isolation` now take precedence over `Agent` tool-call parameters. Tool-call params only fill fields the agent config leaves unspecified.
- **`join_mode` is now a global setting only** — removed the per-call `join_mode` parameter from the `Agent` tool. Join behavior is configured via `/agents` → Settings → Join mode.
- **`max_turns: 0` means unlimited** — agent files can now explicitly set `max_turns: 0` to lock unlimited turns. Previously `0` was silently clamped to `1`.
### Fixed
- **Final subagent text preserved from non-streaming providers** — agents using providers that return the final message without streaming `text_delta` events no longer return empty results. Falls back to extracting text from the completed session history.
- **`effectiveMaxTurns` passed to spawn calls** — previously `params.max_turns` was passed raw to both foreground and background spawn, bypassing the agent config entirely.
## [0.5.0] - 2026-03-22
### Added
- **RPC stop handler** — new `subagents:rpc:stop` event bus RPC allows other extensions to stop running subagents by agent ID. Returns structured error ("Agent not found") on failure.
- **`abort` in `SpawnCapable` interface** — cross-extension RPC consumers can now stop agents, not just spawn them.
- **Live turn counter** — all agents now show a live turn count in the widget, inline result, and completion notification. With a turn limit: `⟳5≤30` (5 of 30 turns). Without: `⟳5`. Updates in real time as turns progress via `onTurnEnd` callback.
- **Biome linting** — added [Biome](https://biomejs.dev/) for correctness linting (unused imports, suspicious patterns). Style rules disabled. Run `npm run lint` to check, `npm run lint:fix` to auto-fix.
- **CI workflow** — GitHub Actions runs lint, typecheck, and tests on push to master and PRs.
- **Auto-trigger parent turn on background completion** — background agent completion notifications now use `triggerTurn: true`, automatically prompting the parent agent to process results instead of waiting for user input.
### Changed
- **Standardized RPC envelope** — cross-extension RPC handlers (`ping`, `spawn`, `stop`) now use a `handleRpc` wrapper that emits structured envelopes (`{ success: true, data }` / `{ success: false, error }`), matching pi-mono's `RpcResponse` convention.
- **Protocol versioning via ping** — ping reply now includes `{ version: PROTOCOL_VERSION }` (currently v2). Callers can detect version mismatches and warn users to update.
- **Default max turns is now unlimited** — subagents no longer have a 50-turn default cap. The default is unlimited (no turn limit), matching Claude Code's main loop behavior. Users can still set explicit limits per-agent via `max_turns` frontmatter or the Agent tool parameter, or globally via `/agents` → Settings (`0` = unlimited).
- **Stale dist in published package** — added `prepublishOnly` hook to build fresh `dist/` on every `npm publish`.
### Fixed
- **Tool name display** — `getAgentConversation` now reads `ToolCall.name` (the correct property) instead of `toolName`, resolving `[Tool: unknown]` in conversation viewer and verbose output.
- **Env test CI failure** — `detectEnv` test assumed a branch name exists, but CI checks out detached HEAD. Split into separate tests for repo detection and branch detection with a controlled temp repo.
## [0.4.9] - 2026-03-18
### Fixed
- **Conversation viewer crash in narrow terminals** ([#7](https://github.com/tintinweb/pi-subagents/issues/7)) — `buildContentLines()` in the live conversation viewer could return lines wider than the terminal when `wrapTextWithAnsi()` misjudged visible width on ANSI-heavy input (e.g. tool output with embedded escape codes, long URLs, wide tables). All content lines are now clamped with `truncateToWidth()` before returning. Same class of bug as the widget fix in v0.2.7, different component.
### Added
- **Conversation viewer width-safety tests** — 17 tests covering `render()` and `buildContentLines()` across varied content (plain text, ANSI codes, unicode, tables, long URLs, narrow terminals). Includes mock-based regression tests that simulate upstream `wrapTextWithAnsi` returning overwidth lines, ensuring the safety net catches them.
## [0.4.8] - 2026-03-18
### Added
- **Cross-extension RPC** — other pi extensions can spawn subagents via `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`). Emits `subagents:ready` on load.
- **Session persistence for agent records** — completed agent records are persisted via `pi.appendEntry("subagents:record", ...)` for cross-extension history reconstruction.
### Fixed
- **Background agent notification race condition** — `pi.sendMessage()` is fire-and-forget, so completion notifications sent eagerly from `onComplete` could not be retracted when `get_subagent_result` was called in the same turn. Notifications are now held behind a 200ms cancellable timer; `get_subagent_result` cancels the pending timer before it fires, eliminating duplicate notifications. Group notifications also re-check `resultConsumed` at send time so consumed agents are filtered out.
## [0.4.7] - 2026-03-17
### Added
- **Custom notification renderer** — background agent completion notifications now render as styled, themed boxes instead of raw XML. Uses `pi.registerMessageRenderer()` with the `"subagent-notification"` custom message type. The LLM continues to receive `<task-notification>` XML via `content`; only the user-facing display changes.
- **Group notification rendering** — group completions render each agent as its own styled block (icon, description, stats, result preview) instead of showing only the first agent.
- **Output file streaming for background agents** — background agents now get the same output file transcript as foreground agents, with `onSessionCreated` wiring and proper cleanup on completion/error.
- `NotificationDetails` type in `types.ts` — structured details for the notification renderer, with optional `others` array for group notifications.
- `buildNotificationDetails()` helper — extracts renderer-facing details from an `AgentRecord`.
### Changed
- **Notification delivery** — `sendIndividualNudge` and group notification now use `pi.sendMessage()` (custom message) instead of `pi.sendUserMessage()` (plain text), enabling renderer-controlled display.
- **Steered status rendering** — steered agents show "completed (steered)" in the notification box instead of plain "completed".
### Fixed
- **Output file cleanup on completion** — `agent-manager.ts` now calls `record.outputCleanup()` in both the success and error paths of agent completion, ensuring the streaming subscription is flushed and released.
## [0.4.6] - 2026-03-16
### Fixed
- **Graceful shutdown aborts agents instead of blocking** — `session_shutdown` now calls `abortAll()` instead of `waitForAll()`, so the process exits immediately instead of hanging until all background agents complete. Agent results are undeliverable after shutdown anyway.
### Added
- `abortAll()` method on `AgentManager` — stops all queued and running agents at once, returning the count of affected agents.
## [0.4.5] - 2026-03-16
### Changed
- **Widget render-once pattern** — the widget callback is now registered once via `setWidget()` and subsequent updates use `requestRender()` instead of re-registering the entire widget on every `update()` call. Eliminates layout thrashing from repeated widget teardown/setup cycles.
- **Status bar dedup** — `setStatus()` is now only called when the status text actually changes, avoiding redundant TUI updates.
- **UICtx change detection** — `setUICtx()` detects context changes and forces widget re-registration, correctly handling session switches.
### Refactored
- Extracted `renderWidget()` private method — moves all widget content rendering out of the `update()` closure into a standalone method that reads live state on each call.
- `update()` is now a lightweight coordinator: counts agents, manages registration lifecycle, and triggers re-renders.
## [0.4.4] - 2026-03-16
### Fixed
- **Race condition in `get_subagent_result` with `wait: true`** — `resultConsumed` is now set before `await record.promise`, preventing a redundant follow-up notification. Previously the `onComplete` callback (attached at spawn time via `.then()`) always fired before the await resumed, seeing `resultConsumed` as false.
- **Stale agent records across sessions** — new `clearCompleted()` method removes all completed/stopped/errored agent records on `session_start` and `session_switch` events, so tasks from a prior session don't persist into a new one.
- **`steer_subagent` race on freshly launched agents** — steering an agent before its session initialized silently dropped the message. Now steers are queued on the record and flushed once `onSessionCreated` fires.
### Changed
- Extracted `removeRecord()` private helper in `AgentManager` — deduplicates dispose+delete logic between `cleanup()` and `clearCompleted()`.
### Added
- 8 new tests covering `resultConsumed` race condition and `clearCompleted` behavior (185 total).
## [0.4.3] - 2026-03-13
### Added
- **Persistent agent memory** — new `memory` frontmatter field with three scopes: `"user"` (global `~/.pi/`), `"project"` (per-project `.pi/`), `"local"` (gitignored `.pi/`). Agents with write/edit tools get full read-write memory; read-only agents get a read-only fallback that injects existing MEMORY.md content without granting write access or creating directories.
- **Git worktree isolation** — new `isolation: "worktree"` frontmatter field and Agent tool parameter. Creates a temporary `git worktree` so agents work on an isolated copy of the repo. On completion, changes are auto-committed to a `pi-agent-<id>` branch; clean worktrees are removed. Includes crash recovery via `pruneWorktrees()`.
- **Skill preloading** — `skills` frontmatter now accepts a comma-separated list of skill names (e.g. `skills: planning, review`). Reads from `.pi/skills/` (project) then `~/.pi/skills/` (global), tries `.md`/`.txt`/bare extensions. Content injected into the system prompt as `# Preloaded Skill: {name}`.
- **Tool denylist** — new `disallowed_tools` frontmatter field (e.g. `disallowed_tools: bash, write`). Blocks specified tools even if `builtinToolNames` or extensions would provide them. Enforced for both extension-enabled and extension-disabled agents.
- **Prompt extras system** — new `PromptExtras` interface in `prompts.ts`; `buildAgentPrompt()` accepts optional memory and skill blocks appended in both `replace` and `append` modes.
- `getMemoryTools()`, `getReadOnlyMemoryTools()` in `agent-types.ts`.
- `buildMemoryBlock()`, `buildReadOnlyMemoryBlock()`, `isSymlink()`, `safeReadFile()` in `memory.ts`.
- `preloadSkills()` in `skill-loader.ts`.
- `createWorktree()`, `cleanupWorktree()`, `pruneWorktrees()` in `worktree.ts`.
- `MemoryScope`, `IsolationMode` types; `memory`, `isolation`, `disallowedTools` fields on `AgentConfig`; `worktree`, `worktreeResult` fields on `AgentRecord`.
- 177 total tests across 8 test files (41 new tests).
### Fixed
- **Read-only agents no longer escalated to read-write** — enabling `memory` on a read-only agent (e.g. Explore) previously auto-added `write`/`edit` tools. Now the runner detects write capability and branches: read-write agents get full memory tools, read-only agents get read-only memory prompt with only the `read` tool added.
- **Denylist-aware memory detection** — write capability check now accounts for `disallowedTools`. An agent with `tools: write` + `disallowed_tools: write` correctly gets read-only memory instead of broken read-write instructions.
- **Worktree requires commits** — repos with no commits (empty HEAD) are now rejected early with a warning instead of failing silently at `git worktree add`.
- **Worktree failure warning** — when worktree creation fails, a warning is prepended to the agent's prompt instead of silently falling through to the main cwd.
- **No force-branch overwrite** — worktree cleanup appends a timestamp suffix on branch name conflict instead of using `git branch -f`.
### Security
- **Whitelist name validation** — agent/skill names must match `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`, max 128 chars. Rejects path traversal, leading dots, spaces, and special characters.
- **Symlink protection** — `safeReadFile()` and `isSymlink()` reject symlinks in memory directories, MEMORY.md files, and skill files, preventing arbitrary file reads.
- **Symlink-safe directory creation** — `ensureMemoryDir()` throws on symlinked directories.
### Changed
- `agent-runner.ts`: tool/extension/skill resolution moved before memory detection; `ctx.cwd``effectiveCwd` throughout.
- `custom-agents.ts`: extracted `parseCsvField()` helper; added `csvListOptional()` and `parseMemory()`.
- `skill-loader.ts`: uses `safeReadFile()` from `memory.ts` instead of raw `readFileSync`.
- Agent tool schema updated with `isolation` parameter and help text for `memory`, `isolation`, `disallowed_tools`, and skill list.
## [0.4.2] - 2026-03-12
### Added
- **Event bus** — agent lifecycle events emitted via `pi.events.emit()`, enabling other extensions to react to sub-agent activity:
- `subagents:created` — background agent registered (includes `id`, `type`, `description`, `isBackground`)
- `subagents:started` — agent transitions to running (includes queued→running)
- `subagents:completed` — agent finished successfully (includes `durationMs`, `tokens`, `toolUses`, `result`)
- `subagents:failed` — agent errored, stopped, or aborted (same payload as completed)
- `subagents:steered` — steering message sent to a running agent
- `OnAgentStart` callback and `onStart` constructor parameter on `AgentManager`.
- **Cross-package manager** now also exposes `spawn()` and `getRecord()` via the `Symbol.for("pi-subagents:manager")` global.
## [0.4.1] - 2026-03-11
### Fixed
- **Graceful shutdown in headless mode** — the CLI now waits for all running and queued background agents to complete before exiting (`waitForAll` on `session_shutdown`). Previously, background agents could be silently killed mid-execution when the session ended. Only affects headless/non-interactive mode; interactive sessions already kept the process alive.
### Added
- `hasRunning()` / `waitForAll()` methods on `AgentManager`.
- **Cross-package manager access** — agent manager exposed via `Symbol.for("pi-subagents:manager")` on `globalThis` for other extensions to check status or await completion.
## [0.4.0] - 2026-03-11
### Added
- **XML-delimited prompt sections** — append-mode agents now wrap inherited content in `<inherited_system_prompt>`, `<sub_agent_context>`, and `<agent_instructions>` XML tags, giving the model explicit structure to distinguish inherited rules from sub-agent-specific instructions. Replace mode is unchanged.
- **Token count in agent results** — foreground agent results, background completion notifications, and `get_subagent_result` now include the token count alongside tool uses and duration (e.g. `Agent completed in 4.2s (12 tool uses, 33.8k token)`).
- **Widget overflow cap** — the running agents widget now caps at 12 lines. When exceeded, running agents are prioritized over finished ones and an overflow summary line shows hidden counts (e.g. `+3 more (1 running, 2 finished)`).
### Changed - **changing behavior**
- **General-purpose agent inherits parent prompt** — the default `general-purpose` agent now uses `promptMode: "append"` with an empty system prompt, making it a "parent twin" that inherits the full parent system prompt (including CLAUDE.md rules, project conventions, and safety guardrails). Previously it used a standalone prompt that duplicated a subset of the parent's rules. Explore and Plan are unchanged (standalone prompts). To customize: eject via `/agents` → select `general-purpose` → Eject, then edit the resulting `.md` file. Set `prompt_mode: replace` to go back to a standalone prompt, or keep `prompt_mode: append` and add extra instructions in the body.
- **Append-mode agents receive parent system prompt** — `buildAgentPrompt` now accepts the parent's system prompt and threads it into append-mode agents (env header + parent prompt + sub-agent context bridge + optional custom instructions). Replace-mode agents are unchanged.
- **Prompt pipeline simplified** — removed `systemPromptOverride`/`systemPromptAppend` from `SpawnOptions` and `RunOptions`. These were a separate code path where `index.ts` pre-resolved the prompt mode and passed raw strings into the runner, bypassing `buildAgentPrompt`. Now all prompt assembly flows through `buildAgentPrompt` using the agent's `promptMode` config — one code path, no special cases.
### Removed
- Deprecated backwards-compat aliases: `registerCustomAgents`, `getCustomAgentConfig`, `getCustomAgentNames` (use `registerAgents`, `getAgentConfig`, `getUserAgentNames`).
- `resolveCustomPrompt()` helper in index.ts — no longer needed now that prompt routing is config-driven.
## [0.3.1] - 2026-03-09
### Added
- **Live conversation viewer** — selecting a running (or completed) agent in `/agents` → "Running agents" now opens a scrollable overlay showing the agent's full conversation in real time. Auto-scrolls to follow new content; scroll up to pause, End to resume. Press Esc to close.
## [0.3.0] - 2026-03-08
### Added
- **Case-insensitive agent type lookup** — `"explore"`, `"EXPLORE"`, and `"Explore"` all resolve to the same agent. LLMs frequently lowercase type names; this prevents validation failures.
- **Unknown type fallback** — unrecognized agent types fall back to `general-purpose` with a note, instead of hard-rejecting. Matches Claude Code behavior.
- **Dynamic tool list for general-purpose** — `builtinToolNames` is now optional in `AgentConfig`. When omitted, the agent gets all tools from `TOOL_FACTORIES` at lookup time, so new tools added upstream are automatically available.
- **Agent source indicators in `/agents` menu** — `•` (project), `◦` (global), `✕` (disabled) with legend. Defaults are unmarked.
- **Disabled agents visible in UI** — disabled agents now show in the "Agent types" list (marked `✕`) with an Enable action, instead of being invisible.
- **Enable action** — re-enable a disabled agent from the `/agents` menu. Stub files are auto-cleaned.
- **Disable action for all agent types** — custom and ejected default agents can now be disabled from the UI, not just built-in defaults.
- `resolveType()` export — case-insensitive type name resolution for external use.
- `getAllTypes()` export — returns all agent names including disabled (for UI listing).
- `source` field on `AgentConfig` — tracks where an agent was loaded from (`"default"`, `"project"`, `"global"`).
### Fixed
- **Model resolver checks auth for exact matches** — `resolveModel("anthropic/claude-haiku-4-5-20251001")` now fails gracefully when no Anthropic API key is configured, instead of returning a model that errors at the API call. Explore silently falls back to the parent model on non-Anthropic setups.
### Changed
- **Unified agent registry** — built-in and custom agents now use the same `AgentConfig` type and a single registry. No more separate code paths for built-in vs custom agents.
- **Default agents are overridable** — creating a `.md` file with the same name as a default agent (e.g. `.pi/agents/Explore.md`) overrides it.
- **`/agents` menu** — "Agent types" list shows defaults and custom agents together with source indicators. Default agents get Eject/Disable actions; overridden defaults get Reset to default.
- **Eject action** — export a default agent's embedded config as a `.md` file to project or personal location for customization.
- **Model labels** — provider-agnostic: strips `provider/` prefix and `-YYYYMMDD` date suffix (e.g. `anthropic/claude-haiku-4-5-20251001``claude-haiku-4-5`). Works for any provider.
- **New frontmatter fields** — `display_name` (UI display name) and `enabled` (default: true; set to false to disable).
- **Menu navigation** — Esc in agent detail returns to agent list (not main menu).
### Removed
- **`statusline-setup` and `claude-code-guide` agents** — removed as built-in types (never spawned programmatically). Users can recreate them as custom agents if needed.
- `BuiltinSubagentType` union type, `SUBAGENT_TYPES` array, `DISPLAY_NAMES` map, `SubagentTypeConfig` interface — replaced by unified `AgentConfig`.
- `buildSystemPrompt()` switch statement — replaced by config-driven `buildAgentPrompt()`.
- `HAIKU_MODEL_IDS` fallback array — Explore's haiku default is now just the `model` field in its config.
- `BUILTIN_MODEL_LABELS` — model labels now derived from config.
- `ALL_TOOLS` hardcoded constant — general-purpose now derives tools dynamically.
### Added
- `src/default-agents.ts` — embedded default configs for general-purpose, Explore, and Plan.
## [0.2.7] - 2026-03-08
### Fixed
- **Widget crash in narrow terminals** — agent widget lines were not truncated to terminal width, causing `doRender` to throw when the tmux pane was narrower than the rendered content. All widget lines are now truncated using `truncateToWidth()` with the actual terminal column count.
## [0.2.6] - 2026-03-07
### Added
- **Background task join strategies** — smart grouping of background agent completion notifications
- `smart` (default): 2+ background agents spawned in the same turn are auto-grouped into a single consolidated notification instead of individual nudges
- `async`: each agent notifies individually on completion (previous behavior)
- `group`: force grouping even for solo agents
- 30s timeout after first completion delivers partial results; 15s straggler re-batch window for remaining agents
- **`join_mode` parameter** on the `Agent` tool — override join strategy per agent (`"async"` or `"group"`)
- **Join mode setting** in `/agents` → Settings — configure the default join mode at runtime
- New `src/group-join.ts``GroupJoinManager` class for batched completion notifications
### Changed
- `AgentRecord` now includes optional `groupId`, `joinMode`, and `resultConsumed` fields
- Background agent completion routing refactored: individual nudge logic extracted to `sendIndividualNudge()`, group delivery via `GroupJoinManager`
### Fixed
- **Debounce window race** — agents that complete during the 100ms batch debounce window are now deferred and retroactively fed into the group once it's registered, preventing split notifications (one individual + one partial group) and zombie groups
- **Solo agent swallowed notification** — if only one agent was spawned (no group formed) but it completed during the debounce window, its deferred notification is now sent when the batch finalizes
- **Duplicate notifications after polling** — calling `get_subagent_result` on a completed agent now marks its result as consumed, suppressing the subsequent completion notification (both individual and group)
## [0.2.5] - 2026-03-06
### Added
- **Interactive `/agents` menu** — single command replaces `/agent` and `/agents` with a full management wizard
- Browse and manage running agents
- Custom agents submenu — edit or delete existing agents
- Create new custom agents via manual wizard or AI-generated (with comprehensive frontmatter documentation for the generator)
- Settings: configure max concurrency, default max turns, and grace turns at runtime
- Built-in agent types shown with model info (e.g. `Explore · haiku`)
- Aligned formatting for agent lists
- **Configurable turn limits** — `defaultMaxTurns` and `graceTurns` are now runtime-adjustable via `/agents` → Settings
- Sub-menus return to main menu instead of exiting
### Removed
- `/agent <type> <prompt>` command (use `Agent` tool directly, or create custom agents via `/agents`)
## [0.2.4] - 2026-03-06
### Added
- **Global custom agents** — agents in `~/.pi/agent/agents/*.md` are now discovered automatically and available across all projects
- Two-tier discovery hierarchy: project-level (`.pi/agents/`) overrides global (`~/.pi/agent/agents/`)
## [0.2.3] - 2026-03-05
### Added
- Screenshot in README
## [0.2.2] - 2026-03-05
### Changed
- Renamed package to `@tintinweb/pi-subagents`
- Fuzzy model resolver now only matches models with auth configured (prevents selecting unconfigured providers)
- Custom agents hot-reload on each `Agent` tool call (no restart needed for new `.pi/agents/*.md` files)
- Updated pi dependencies to 0.56.1
### Refactored
- Extracted `createActivityTracker()` — eliminates duplicated tool activity wiring between foreground and background paths
- Extracted `safeFormatTokens()` — replaces 4 repeated try-catch blocks
- Extracted `buildDetails()` — consolidates AgentDetails construction
- Extracted `getStatusLabel()` / `getStatusNote()` — consolidates 3 duplicated status formatting chains
- Shared `extractText()` — consolidated duplicate from context.ts and agent-runner.ts
- Added `ERROR_STATUSES` constant in widget for consistent status checks
- `getDisplayName()` now delegates to `getConfig()` instead of separate lookups
- Removed unused `Tool` type export from agent-types
## [0.2.1] - 2026-03-05
### Added
- **Persistent above-editor widget** — tree view of all running/queued/finished agents with animated spinners and live stats
- **Concurrency queue** — configurable max concurrent background agents (default: 4), auto-drain
- **Queued agents** collapsed to single summary line in widget
- **Turn-based widget linger** — completed agents clear after 1 turn, errors/aborted linger for 2 extra turns
- **Colored status icons** — themed rendering via `setWidget` callback form (`✓` green, `✓` yellow, `✗` red, `■` dim)
- **Live response streaming** — `onTextDelta` shows truncated agent response text instead of static "thinking..."
### Changed
- Tool names match Claude Code: `Agent`, `get_subagent_result`, `steer_subagent`
- Labels use "Agent" / "Agents" (not "Subagent")
- Widget heading: `●` when active, `○` when only lingering finished agents
- Extracted all UI code to `src/ui/agent-widget.ts`
## [0.2.0] - 2026-03-05
### Added
- **Claude Code-style UI rendering** — `renderCall`/`renderResult`/`onUpdate` for live streaming progress
- Live activity descriptions: "searching, reading 3 files…"
- Token count display: "33.8k token"
- Per-agent tool use counter
- Expandable completed results (ctrl+o)
- Distinct states: running, background, completed, error, aborted
- **Async environment detection** — replaced `execSync` with `pi.exec()` for non-blocking git/platform detection
- **Status bar integration** — running background agent count shown in pi's status bar
- **Fuzzy model selection** — `"haiku"`, `"sonnet"` resolve to best matching available model
### Changed
- Tool label changed from "Spawn Agent" to "Agent" (matches Claude Code style)
- `onToolUse` callback replaced with richer `onToolActivity` (includes tool name + start/end)
- `onSessionCreated` callback for accessing session stats (token counts)
- `env.ts` now requires `ExtensionAPI` parameter (async `pi.exec()` instead of `execSync`)
## [0.1.0] - 2026-03-05
Initial release.
### Added
- **Autonomous sub-agents** — spawn specialized agents via tool call, each running in an isolated pi session
- **Built-in agent types** — general-purpose, Explore (defaults to haiku), Plan, statusline-setup, claude-code-guide
- **Custom user-defined agents** — define agents in `.pi/agents/<name>.md` with YAML frontmatter + system prompt body
- **Frontmatter configuration** — tools, extensions, skills, model, thinking, max_turns, prompt_mode, inherit_context, run_in_background, isolated
- **Graceful max_turns** — steer message at limit, 5 grace turns, then hard abort
- **Background execution** — `run_in_background` with completion notifications
- **`get_subagent_result` tool** — check status, wait for completion, verbose conversation output
- **`steer_subagent` tool** — inject steering messages into running agents mid-execution
- **Agent resume** — continue a previous agent's session with a new prompt
- **Context inheritance** — fork the parent conversation into the sub-agent
- **Model override** — per-agent model selection
- **Thinking level** — per-agent extended thinking control
- **`/agent` and `/agents` commands**
[0.6.3]: https://github.com/tintinweb/pi-subagents/compare/v0.6.2...v0.6.3
[0.6.2]: https://github.com/tintinweb/pi-subagents/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/tintinweb/pi-subagents/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/tintinweb/pi-subagents/compare/v0.5.2...v0.6.0
[0.5.2]: https://github.com/tintinweb/pi-subagents/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/tintinweb/pi-subagents/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/tintinweb/pi-subagents/compare/v0.4.9...v0.5.0
[0.4.9]: https://github.com/tintinweb/pi-subagents/compare/v0.4.8...v0.4.9
[0.4.8]: https://github.com/tintinweb/pi-subagents/compare/v0.4.7...v0.4.8
[0.4.7]: https://github.com/tintinweb/pi-subagents/compare/v0.4.6...v0.4.7
[0.4.6]: https://github.com/tintinweb/pi-subagents/compare/v0.4.5...v0.4.6
[0.4.5]: https://github.com/tintinweb/pi-subagents/compare/v0.4.4...v0.4.5
[0.4.4]: https://github.com/tintinweb/pi-subagents/compare/v0.4.3...v0.4.4
[0.4.3]: https://github.com/tintinweb/pi-subagents/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/tintinweb/pi-subagents/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/tintinweb/pi-subagents/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/tintinweb/pi-subagents/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/tintinweb/pi-subagents/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/tintinweb/pi-subagents/compare/v0.2.7...v0.3.0
[0.2.7]: https://github.com/tintinweb/pi-subagents/compare/v0.2.6...v0.2.7
[0.2.6]: https://github.com/tintinweb/pi-subagents/compare/v0.2.5...v0.2.6
[0.2.5]: https://github.com/tintinweb/pi-subagents/compare/v0.2.4...v0.2.5
[0.2.4]: https://github.com/tintinweb/pi-subagents/compare/v0.2.3...v0.2.4
[0.2.3]: https://github.com/tintinweb/pi-subagents/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/tintinweb/pi-subagents/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/tintinweb/pi-subagents/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/tintinweb/pi-subagents/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/tintinweb/pi-subagents/releases/tag/v0.1.0

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 tintinweb
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,510 @@
# @tintinweb/pi-subagents
A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-agents** to pi. Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level. Run them in foreground or background, steer them mid-run, resume completed sessions, and define your own custom agent types.
> **Status:** Early release.
<img width="600" alt="pi-subagents screenshot" src="https://github.com/tintinweb/pi-subagents/raw/master/media/screenshot.png" />
https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
## Features
- **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
- **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications)
- **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons
- **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause)
- **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
- **Mid-run steering** — inject messages into running agents to redirect their work without restarting
- **Session resume** — pick up where an agent left off, preserving full conversation context
- **Graceful turn limits** — agents get a "wrap up" warning before hard abort, producing clean partial results instead of cut-off output
- **Case-insensitive agent types** — `"explore"`, `"Explore"`, `"EXPLORE"` all work. Unknown types fall back to general-purpose with a note
- **Fuzzy model selection** — specify models by name (`"haiku"`, `"sonnet"`) instead of full IDs, with automatic filtering to only available/configured models
- **Context inheritance** — optionally fork the parent conversation into a sub-agent so it knows what's been discussed
- **Persistent agent memory** — three scopes (project, local, user) with automatic read-only fallback for agents without write tools
- **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
- **Skill preloading** — inject named skill files from `.pi/skills/` into agent system prompts
- **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
- **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
- **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
- **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Standardized reply envelopes with protocol versioning. Emits `subagents:ready` on load
- **Schedule subagents** — pass `schedule` to the `Agent` tool to fire on cron / interval / one-shot. Session-scoped jobs with PID-locked persistence; results land via the same `subagent-notification` followUp path as manual background completions; manage via `/agents → Scheduled jobs`
## Install
```bash
pi install npm:@tintinweb/pi-subagents
```
Or load directly for development:
```bash
pi -e ./src/index.ts
```
## Quick Start
The parent agent spawns sub-agents using the `Agent` tool:
```
Agent({
subagent_type: "Explore",
prompt: "Find all files that handle authentication",
description: "Find auth files",
run_in_background: true,
})
```
Foreground agents block until complete and return results inline. Background agents return an ID immediately and notify you on completion.
### Scheduling
Add a `schedule` field to register the agent to fire later instead of running now:
```
Agent({
subagent_type: "Explore",
prompt: "Look at recent commits and summarize what changed since last week",
description: "Weekly commit review",
schedule: "0 0 9 * * 1", // 9am every Monday (6-field cron)
})
```
Schedule formats:
- **Cron** — 6-field (`second minute hour day-of-month month day-of-week`), e.g. `"0 0 9 * * 1"` for 9am every Monday, `"0 */15 * * * *"` for every 15 minutes.
- **Interval** — `"5m"`, `"1h"`, `"30s"`, `"2d"`. Fires repeatedly at that interval.
- **One-shot relative** — `"+10m"`, `"+2h"`, `"+1d"`. Fires once at that future time.
- **One-shot absolute** — full ISO timestamp, e.g. `"2026-12-25T09:00:00.000Z"`.
When a schedule fires, the spawn runs in background and its completion notification arrives in the conversation through the same `subagent-notification` followUp path as a manually-spawned background agent — your parent agent reasons about the result the same way.
Schedules are **session-scoped**: they reset on `/new` and restore on `/resume`. List and cancel via `/agents → Scheduled jobs` (creation is the `Agent` tool's job — there is no parallel manual-create wizard). Storage at `<cwd>/.pi/subagent-schedules/<sessionId>.json` with PID-based file locking for cross-instance safety.
**Disable the feature entirely**: `/agents → Settings → Scheduling → disabled` removes `schedule` from the `Agent` tool spec (no LLM-context cost), hides the menu entry, and stops any active scheduler. The schema-level removal takes effect on the next pi session; the runtime kill is immediate. Re-enable from the same menu.
Restrictions:
- `schedule` cannot be combined with `inherit_context` (no parent conversation exists at fire time) or `resume` (schedules create fresh agents).
- `run_in_background` is forced to `true`.
- Scheduled fires bypass the `maxConcurrent` queue so a 5-minute interval cannot be deferred behind long-running manual agents.
- **Headless `pi -p` doesn't wait for scheduled subagents.**
## UI
The extension renders a persistent widget above the editor showing all active agents:
```
● Agents
├─ ⠹ Agent Refactor auth module · ⟳5≤30 · 5 tool uses · 33.8k token (62%) · 12.3s
│ ⎿ editing 2 files…
├─ ⠹ Explore Find auth files · ⟳3 · 3 tool uses · 12.4k token (8%) · 4.1s
│ ⎿ searching…
├─ ⠹ Agent Long-running task · ⟳42 · 38 tool uses · 91.0k token (84% · ↻2) · 2m17s
│ ⎿ reading…
└─ 2 queued
```
The token field is annotated with two optional signals inside parens:
- **`NN%`** — context-window utilization (color-coded: <70% dim, 7085% warning, ≥85% error). Omitted when the model has no declared `contextWindow`, or briefly right after compaction.
- **`↻N`** — number of times the session has compacted, when > 0. Stays dim; the percent's color carries urgency.
Individual agent results render Claude Code-style in the conversation:
| State | Example |
|-------|---------|
| **Running** | `⠹ ⟳3≤30 · 3 tool uses · 12.4k token (8%)` / `⎿ searching, reading 3 files…` |
| **Completed** | `✓ ⟳8 · 5 tool uses · 33.8k token (62%) · 12.3s` / `⎿ Done` |
| **Wrapped up** | `✓ ⟳50≤50 · 50 tool uses · 89.1k token (84% · ↻2) · 45.2s` / `⎿ Wrapped up (turn limit)` |
| **Stopped** | `■ ⟳3 · 3 tool uses · 12.4k token (8%)` / `⎿ Stopped` |
| **Error** | `✗ ⟳3 · 3 tool uses · 12.4k token (8%)` / `⎿ Error: timeout` |
| **Aborted** | `✗ ⟳55≤50 · 55 tool uses · 102.3k token (95% · ↻3)` / `⎿ Aborted (max turns exceeded)` |
Completed results can be expanded (ctrl+o in pi) to show the full agent output inline.
Background agent completion notifications render as styled boxes:
```
✓ Find auth files completed
⟳3 · 3 tool uses · 12.4k token · 4.1s
⎿ Found 5 files related to authentication...
transcript: .pi/output/agent-abc123.jsonl
```
Group completions render each agent as a separate block. The LLM receives structured `<task-notification>` XML for parsing, while the user sees the themed visual.
## Default Agent Types
| Type | Tools | Model | Prompt Mode | Description |
|------|-------|-------|-------------|-------------|
| `general-purpose` | all 7 | inherit | `append` (parent twin) | Inherits the parent's full system prompt — same rules, CLAUDE.md, project conventions |
| `Explore` | read, bash, grep, find, ls | haiku (falls back to inherit) | `replace` (standalone) | Fast codebase exploration (read-only) |
| `Plan` | read, bash, grep, find, ls | inherit | `replace` (standalone) | Software architect for implementation planning (read-only) |
The `general-purpose` agent is a **parent twin** — it receives the parent's entire system prompt plus a sub-agent context bridge, so it follows the same rules the parent does. Explore and Plan use standalone prompts tailored to their read-only roles.
Default agents can be **ejected** (`/agents` → select agent → Eject) to export them as `.md` files for customization, **overridden** by creating a `.md` file with the same name (e.g. `.pi/agents/general-purpose.md`), or **disabled** per-project with `enabled: false` frontmatter.
## Custom Agents
Define custom agent types by creating `.md` files. The filename becomes the agent type name. Any name is allowed — using a default agent's name overrides it.
Agents are discovered from two locations (higher priority wins):
| Priority | Location | Scope |
|----------|----------|-------|
| 1 (highest) | `.pi/agents/<name>.md` | Project — per-repo agents |
| 2 | `$PI_CODING_AGENT_DIR/agents/<name>.md` (default `~/.pi/agent/agents/<name>.md`) | Global — available everywhere |
Project-level agents override global ones with the same name, so you can customize a global agent for a specific project. The global location follows the upstream `PI_CODING_AGENT_DIR` env var — set it to relocate all pi-coding-agent state (agents, skills, settings) to a custom directory.
### Example: `.pi/agents/auditor.md`
```markdown
---
description: Security Code Reviewer
tools: read, grep, find, bash
model: anthropic/claude-opus-4-6
thinking: high
max_turns: 30
---
You are a security auditor. Review code for vulnerabilities including:
- Injection flaws (SQL, command, XSS)
- Authentication and authorization issues
- Sensitive data exposure
- Insecure configurations
Report findings with file paths, line numbers, severity, and remediation advice.
```
Then spawn it like any built-in type:
```
Agent({ subagent_type: "auditor", prompt: "Review the auth module", description: "Security audit" })
```
### Frontmatter Fields
All fields are optional — sensible defaults for everything.
| Field | Default | Description |
|-------|---------|-------------|
| `description` | filename | Agent description shown in tool listings |
| `display_name` | — | Display name for UI (e.g. widget, agent list) |
| `tools` | all 7 | Comma-separated built-in tools: read, bash, edit, write, grep, find, ls. `none` for no tools |
| `extensions` | `true` | Inherit MCP/extension tools. `false` to disable |
| `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload from `.pi/skills/` |
| `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
| `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
| `isolation` | — | Set to `worktree` to run in an isolated git worktree |
| `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) |
| `thinking` | inherit | off, minimal, low, medium, high, xhigh |
| `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
| `prompt_mode` | `replace` | `replace`: body is the full system prompt (no AGENTS.md / CLAUDE.md inheritance). `append`: body appended to parent's prompt (agent acts as a "parent twin" — inherits parent's AGENTS.md / CLAUDE.md) |
| `inherit_context` | `false` | Fork parent conversation into agent |
| `run_in_background` | `false` | Run in background by default |
| `isolated` | `false` | No extension/MCP tools, only built-in |
| `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) |
Frontmatter is authoritative. If an agent file sets `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, or `isolation`, those values are locked for that agent. `Agent` tool parameters only fill fields the agent config leaves unspecified.
## Tools
### `Agent`
Launch a sub-agent.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `prompt` | string | yes | The task for the agent |
| `description` | string | yes | Short 3-5 word summary (shown in UI) |
| `subagent_type` | string | yes | Agent type (built-in or custom) |
| `model` | string | no | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) |
| `thinking` | string | no | Thinking level: off, minimal, low, medium, high, xhigh |
| `max_turns` | number | no | Max agentic turns. Omit for unlimited (default) |
| `run_in_background` | boolean | no | Run without blocking |
| `resume` | string | no | Agent ID to resume a previous session |
| `isolated` | boolean | no | No extension/MCP tools |
| `isolation` | `"worktree"` | no | Run in an isolated git worktree |
| `inherit_context` | boolean | no | Fork parent conversation into agent |
### `get_subagent_result`
Check status and retrieve results from a background agent.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `agent_id` | string | yes | Agent ID to check |
| `wait` | boolean | no | Wait for completion |
| `verbose` | boolean | no | Include full conversation log |
### `steer_subagent`
Send a steering message to a running agent. The message interrupts after the current tool execution.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `agent_id` | string | yes | Agent ID to steer |
| `message` | string | yes | Message to inject into agent conversation |
## Commands
| Command | Description |
|---------|-------------|
| `/agents` | Interactive agent management menu |
The `/agents` command opens an interactive menu:
```
Running agents (2) — 1 running, 1 done ← only shown when agents exist
Agent types (6) ← unified list: defaults + custom
Create new agent ← manual wizard or AI-generated
Settings ← max concurrency, max turns, grace turns, join mode
```
- **Agent types** — unified list with source indicators: `•` (project), `◦` (global), `✕` (disabled). Select an agent to manage it:
- **Default agents** (no override): Eject (export as `.md`), Disable
- **Default agents** (ejected/overridden): Edit, Disable, Reset to default, Delete
- **Custom agents**: Edit, Disable, Delete
- **Disabled agents**: Enable, Edit, Delete
- **Eject** — writes the embedded default config as a `.md` file to project or personal location, so you can customize it
- **Disable/Enable** — toggle agent availability. Disabled agents stay visible in the list (marked `✕`) and can be re-enabled
- **Create new agent** — choose project/personal location, then manual wizard (step-by-step prompts for name, tools, model, thinking, system prompt) or AI-generated (describe what the agent should do and a sub-agent writes the `.md` file). Any name is allowed, including default agent names (overrides them)
- **Settings** — configure max concurrency, default max turns, grace turns, and join mode at runtime
## Graceful Max Turns
Instead of hard-aborting at the turn limit, agents get a graceful shutdown:
1. At `max_turns` — steering message: *"Wrap up immediately — provide your final answer now."*
2. Up to 5 grace turns to finish cleanly
3. Hard abort only after the grace period
| Status | Meaning | Icon |
|--------|---------|------|
| `completed` | Finished naturally | `✓` green |
| `steered` | Hit limit, wrapped up in time | `✓` yellow |
| `aborted` | Grace period exceeded | `✗` red |
| `stopped` | User-initiated abort | `■` dim |
## Concurrency
Background agents are subject to a configurable concurrency limit (default: 4). Excess agents are automatically queued and start as running agents complete. The widget shows queued agents as a collapsed count.
Foreground agents bypass the queue — they block the parent anyway.
## Join Strategies
When background agents complete, they notify the main agent. The **join mode** controls how these notifications are delivered. It applies only to background agents.
| Mode | Behavior |
|------|----------|
| `smart` (default) | 2+ background agents spawned in the same turn are auto-grouped into a single consolidated notification. Solo agents notify individually. |
| `async` | Each agent sends its own notification on completion (original behavior). Best when results need incremental processing. |
| `group` | Force grouping even when spawning a single agent. Useful when you know more agents will follow. |
**Timeout behavior:** When agents are grouped, a 30-second timeout starts after the first agent completes. If not all agents finish in time, a partial notification is sent with completed results and remaining agents continue with a shorter 15-second re-batch window for stragglers.
**Configuration:**
- Configure join mode in `/agents` → Settings → Join mode
## Persistent Settings
Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, default join mode) persist across pi restarts. Two files, merged on load:
- **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
- **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
**Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `4`, default max turns unlimited, grace turns `5`, join mode `smart`).
**Example — global defaults for a beefy machine:**
```bash
mkdir -p ~/.pi/agent
cat > ~/.pi/agent/subagents.json <<'EOF'
{
"maxConcurrent": 16,
"graceTurns": 10
}
EOF
```
Every project now starts with concurrency 16 and grace 10, without ever touching the menu. Individual projects can still override via `/agents` → Settings.
**Failure behavior:** missing file is silent; malformed JSON logs a `[pi-subagents] Ignoring malformed settings at …` warning to stderr; invalid/out-of-range field values are dropped per-field; write failures downgrade the `/agents` toast to a warning with `(session only; failed to persist)`.
## Events
Agent lifecycle events are emitted via `pi.events.emit()` so other extensions can react:
| Event | When | Key fields |
|-------|------|------------|
| `subagents:created` | Background agent registered | `id`, `type`, `description`, `isBackground` |
| `subagents:started` | Agent transitions to running (including queued→running) | `id`, `type`, `description` |
| `subagents:completed` | Agent finished successfully | `id`, `type`, `durationMs`, `tokens` (lifetime `{ input, output, total }`), `toolUses`, `result` |
| `subagents:failed` | Agent errored, stopped, or aborted | same as completed + `error`, `status` |
| `subagents:steered` | Steering message sent | `id`, `message` |
| `subagents:compacted` | Agent's session successfully compacted | `id`, `type`, `description`, `reason` (`"manual"` / `"threshold"` / `"overflow"`), `tokensBefore`, `compactionCount` |
| `subagents:scheduled` | Schedule lifecycle change | `{ type: "added" \| "removed" \| "updated" \| "fired" \| "error", … }` (job/agentId/error fields per type) |
| `subagents:scheduler_ready` | Scheduler bound to session, enabled jobs armed | `sessionId`, `jobCount` |
| `subagents:ready` | Extension loaded and RPC handlers registered | — |
| `subagents:settings_loaded` | Persisted settings applied at extension init | `settings` (merged global + project) |
| `subagents:settings_changed` | `/agents` → Settings mutation was applied | `settings`, `persisted` (`boolean``false` on write failure) |
`tokens.total` = `input + output + cacheWrite`. `cacheRead` is excluded — each turn's `cacheRead` is the cumulative cached prefix re-read on that one API call, so summing per-message would over-count it. Use `contextUsage.percent` (surfaced as `(NN%)` in the widget) for current context size.
## Cross-Extension RPC
Other pi extensions can spawn and stop subagents programmatically via the `pi.events` event bus, without importing this package directly.
All RPC replies use a standardized envelope: `{ success: true, data?: T }` on success, `{ success: false, error: string }` on failure.
### Discovery
Listen for `subagents:ready` to know when RPC handlers are available:
```typescript
pi.events.on("subagents:ready", () => {
// RPC handlers are registered — safe to call ping/spawn/stop
});
```
### Ping
Check if the subagents extension is loaded and get the protocol version:
```typescript
const requestId = crypto.randomUUID();
const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, (reply) => {
unsub();
if (reply.success) console.log("Protocol version:", reply.data.version);
});
pi.events.emit("subagents:rpc:ping", { requestId });
```
### Spawn
Spawn a subagent and receive its ID:
```typescript
const requestId = crypto.randomUUID();
const unsub = pi.events.on(`subagents:rpc:spawn:reply:${requestId}`, (reply) => {
unsub();
if (!reply.success) {
console.error("Spawn failed:", reply.error);
} else {
console.log("Agent ID:", reply.data.id);
}
});
pi.events.emit("subagents:rpc:spawn", {
requestId,
type: "general-purpose",
prompt: "Do something useful",
options: { description: "My task", run_in_background: true },
});
```
### Stop
Stop a running agent by ID:
```typescript
const requestId = crypto.randomUUID();
const unsub = pi.events.on(`subagents:rpc:stop:reply:${requestId}`, (reply) => {
unsub();
if (!reply.success) console.error("Stop failed:", reply.error);
});
pi.events.emit("subagents:rpc:stop", { requestId, agentId: "agent-id-here" });
```
Reply channels are scoped per `requestId`, so concurrent requests don't interfere.
## Persistent Agent Memory
Agents can have persistent memory across sessions. Set `memory` in frontmatter to enable:
```yaml
---
memory: project # project | local | user
---
```
| Scope | Location | Use case |
|-------|----------|----------|
| `project` | `.pi/agent-memory/<name>/` | Shared across the team (committed) |
| `local` | `.pi/agent-memory-local/<name>/` | Machine-specific (gitignored) |
| `user` | `~/.pi/agent-memory/<name>/` | Global personal memory |
Memory uses a `MEMORY.md` index file and individual memory files with frontmatter. Agents with write tools get full read-write access. **Read-only agents** (no `write`/`edit` tools) automatically get read-only memory — they can consume memories written by other agents but cannot modify them. This prevents unintended tool escalation.
The `disallowed_tools` field is respected when determining write capability — an agent with `tools: write` + `disallowed_tools: write` correctly gets read-only memory.
## Worktree Isolation
Set `isolation: worktree` to run an agent in a temporary git worktree:
```
Agent({ subagent_type: "refactor", prompt: "...", isolation: "worktree" })
```
The agent gets a full, isolated copy of the repository. On completion:
- **No changes:** worktree is cleaned up automatically
- **Changes made:** changes are committed to a new branch (`pi-agent-<id>`) and returned in the result
If the worktree cannot be created (not a git repo, no commits, or `git worktree add` fails), the `Agent` tool returns a clear error instead of running unisolated — `isolation: "worktree"` is a strict guarantee, not a hint. Initialize git and commit at least once, or omit `isolation`.
## Skill Preloading
Skills can be preloaded as named files from `.pi/skills/` or `~/.pi/skills/`:
```yaml
---
skills: api-conventions, error-handling
---
```
Skill files (`.md`, `.txt`, or extensionless) are read and injected into the agent's system prompt. Project-level skills take priority over global ones. Symlinked skill files are rejected for security.
## Tool Denylist
Block specific tools from an agent even if extensions provide them:
```yaml
---
tools: read, bash, grep, write
disallowed_tools: write, edit
---
```
This is useful for creating agents that inherit extension tools but should not have write access.
## Architecture
```
src/
index.ts # Extension entry: tool/command registration, rendering
types.ts # Type definitions (AgentConfig, AgentRecord, etc.)
default-agents.ts # Embedded default agent configs (general-purpose, Explore, Plan)
agent-types.ts # Unified agent registry (defaults + user), tool name resolution
agent-runner.ts # Session creation, execution, graceful max_turns, steer/resume
agent-manager.ts # Agent lifecycle, concurrency queue, completion notifications
cross-extension-rpc.ts # RPC handlers for cross-extension spawn/ping via pi.events
group-join.ts # Group join manager: batched completion notifications with timeout
custom-agents.ts # Load user-defined agents from .pi/agents/*.md
memory.ts # Persistent agent memory (resolve, read, build prompt blocks)
skill-loader.ts # Preload skill files from .pi/skills/
output-file.ts # Streaming output file transcripts for agent sessions
worktree.ts # Git worktree isolation (create, cleanup, prune)
prompts.ts # Config-driven system prompt builder
context.ts # Parent conversation context for inherit_context
env.ts # Environment detection (git, platform)
ui/
agent-widget.ts # Persistent widget: spinners, activity, status icons, theming
conversation-viewer.ts # Live conversation overlay for viewing agent sessions
```
## License
MIT — [tintinweb](https://github.com/tintinweb)

5307
extensions/pi-subagents/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
{
"name": "@tintinweb/pi-subagents",
"version": "0.7.1",
"description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
"author": "tintinweb",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tintinweb/pi-subagents.git"
},
"homepage": "https://github.com/tintinweb/pi-subagents#readme",
"bugs": {
"url": "https://github.com/tintinweb/pi-subagents/issues"
},
"keywords": [
"pi-package",
"pi",
"pi-extension",
"subagent",
"agent",
"autonomous"
],
"peerDependencies": {
"@mariozechner/pi-ai": ">=0.70.5",
"@mariozechner/pi-coding-agent": ">=0.70.5",
"@mariozechner/pi-tui": ">=0.70.5"
},
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"croner": "^10.0.1",
"nanoid": "^5.0.0"
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "biome check src/ test/",
"lint:fix": "biome check --fix src/ test/"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^25.5.0",
"typescript": "^6.0.0",
"vitest": "^4.0.18"
},
"pi": {
"extensions": [
"./src/index.ts"
],
"video": "https://github.com/tintinweb/pi-subagents/raw/master/media/demo.mp4",
"image": "https://github.com/tintinweb/pi-subagents/raw/master/media/screenshot.png"
}
}

View File

@@ -0,0 +1,479 @@
/**
* agent-manager.ts — Tracks agents, background execution, resume support.
*
* Background agents are subject to a configurable concurrency limit (default: 4).
* Excess agents are queued and auto-started as running agents complete.
* Foreground agents bypass the queue (they block the parent anyway).
*/
import { randomUUID } from "node:crypto";
import type { Model } from "@mariozechner/pi-ai";
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
import { addUsage } from "./usage.js";
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
export type OnAgentComplete = (record: AgentRecord) => void;
export type OnAgentStart = (record: AgentRecord) => void;
export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
/** Default max concurrent background agents. */
const DEFAULT_MAX_CONCURRENT = 4;
interface SpawnArgs {
pi: ExtensionAPI;
ctx: ExtensionContext;
type: SubagentType;
prompt: string;
options: SpawnOptions;
}
interface SpawnOptions {
description: string;
model?: Model<any>;
maxTurns?: number;
isolated?: boolean;
inheritContext?: boolean;
thinkingLevel?: ThinkingLevel;
isBackground?: boolean;
/**
* Skip the maxConcurrent queue check for this spawn — start immediately even
* if the configured concurrency limit would otherwise queue it. Used by the
* scheduler so a fired job can't be deferred past its trigger window.
*/
bypassQueue?: boolean;
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
isolation?: IsolationMode;
/** Parent abort signal — when aborted, the subagent is also stopped. */
signal?: AbortSignal;
/** Called on tool start/end with activity info (for streaming progress to UI). */
onToolActivity?: (activity: ToolActivity) => void;
/** Called on streaming text deltas from the assistant response. */
onTextDelta?: (delta: string, fullText: string) => void;
/** Called when the agent session is created (for accessing session stats). */
onSessionCreated?: (session: AgentSession) => void;
/** Called at the end of each agentic turn with the cumulative count. */
onTurnEnd?: (turnCount: number) => void;
/** Called once per assistant message_end with that message's usage delta. */
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
/** Called when the session successfully compacts. */
onCompaction?: (info: CompactionInfo) => void;
}
export class AgentManager {
private agents = new Map<string, AgentRecord>();
private cleanupInterval: ReturnType<typeof setInterval>;
private onComplete?: OnAgentComplete;
private onStart?: OnAgentStart;
private onCompact?: OnAgentCompact;
private maxConcurrent: number;
/** Queue of background agents waiting to start. */
private queue: { id: string; args: SpawnArgs }[] = [];
/** Number of currently running background agents. */
private runningBackground = 0;
constructor(
onComplete?: OnAgentComplete,
maxConcurrent = DEFAULT_MAX_CONCURRENT,
onStart?: OnAgentStart,
onCompact?: OnAgentCompact,
) {
this.onComplete = onComplete;
this.onStart = onStart;
this.onCompact = onCompact;
this.maxConcurrent = maxConcurrent;
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
this.cleanupInterval.unref();
}
/** Update the max concurrent background agents limit. */
setMaxConcurrent(n: number) {
this.maxConcurrent = Math.max(1, n);
// Start queued agents if the new limit allows
this.drainQueue();
}
getMaxConcurrent(): number {
return this.maxConcurrent;
}
/**
* Spawn an agent and return its ID immediately (for background use).
* If the concurrency limit is reached, the agent is queued.
*/
spawn(
pi: ExtensionAPI,
ctx: ExtensionContext,
type: SubagentType,
prompt: string,
options: SpawnOptions,
): string {
const id = randomUUID().slice(0, 17);
const abortController = new AbortController();
const record: AgentRecord = {
id,
type,
description: options.description,
status: options.isBackground ? "queued" : "running",
toolUses: 0,
startedAt: Date.now(),
abortController,
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
compactionCount: 0,
};
this.agents.set(id, record);
const args: SpawnArgs = { pi, ctx, type, prompt, options };
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
// Queue it — will be started when a running agent completes
this.queue.push({ id, args });
return id;
}
// startAgent can throw (e.g. strict worktree-isolation failure) — clean
// up the record so callers don't see an orphan in `listAgents()`.
try {
this.startAgent(id, record, args);
} catch (err) {
this.agents.delete(id);
throw err;
}
return id;
}
/** Actually start an agent (called immediately or from queue drain). */
private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
// Worktree isolation: try to create a temporary git worktree. Strict —
// fail loud if not possible (no silent fallback to main tree). Done
// BEFORE state mutation so a throw doesn't leave the record half-running.
let worktreeCwd: string | undefined;
if (options.isolation === "worktree") {
const wt = createWorktree(ctx.cwd, id);
if (!wt) {
throw new Error(
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
'Initialize git and commit at least once, or omit `isolation`.',
);
}
record.worktree = wt;
worktreeCwd = wt.path;
}
record.status = "running";
record.startedAt = Date.now();
if (options.isBackground) this.runningBackground++;
this.onStart?.(record);
// Wire parent abort signal to stop the subagent when the parent is interrupted
let detachParentSignal: (() => void) | undefined;
if (options.signal) {
const onParentAbort = () => this.abort(id);
options.signal.addEventListener("abort", onParentAbort, { once: true });
detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
}
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
const promise = runAgent(ctx, type, prompt, {
pi,
model: options.model,
maxTurns: options.maxTurns,
isolated: options.isolated,
inheritContext: options.inheritContext,
thinkingLevel: options.thinkingLevel,
cwd: worktreeCwd,
signal: record.abortController!.signal,
onToolActivity: (activity) => {
if (activity.type === "end") record.toolUses++;
options.onToolActivity?.(activity);
},
onTurnEnd: options.onTurnEnd,
onTextDelta: options.onTextDelta,
onAssistantUsage: (usage) => {
addUsage(record.lifetimeUsage, usage);
options.onAssistantUsage?.(usage);
},
onCompaction: (info) => {
record.compactionCount++;
this.onCompact?.(record, info);
options.onCompaction?.(info);
},
onSessionCreated: (session) => {
record.session = session;
// Flush any steers that arrived before the session was ready
if (record.pendingSteers?.length) {
for (const msg of record.pendingSteers) {
session.steer(msg).catch(() => {});
}
record.pendingSteers = undefined;
}
options.onSessionCreated?.(session);
},
})
.then(({ responseText, session, aborted, steered }) => {
// Don't overwrite status if externally stopped via abort()
if (record.status !== "stopped") {
record.status = aborted ? "aborted" : steered ? "steered" : "completed";
}
record.result = responseText;
record.session = session;
record.completedAt ??= Date.now();
detach();
// Final flush of streaming output file
if (record.outputCleanup) {
try { record.outputCleanup(); } catch { /* ignore */ }
record.outputCleanup = undefined;
}
// Clean up worktree if used
if (record.worktree) {
const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
record.worktreeResult = wtResult;
if (wtResult.hasChanges && wtResult.branch) {
record.result = (record.result ?? "") +
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
}
}
if (options.isBackground) {
this.runningBackground--;
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
this.drainQueue();
}
return responseText;
})
.catch((err) => {
// Don't overwrite status if externally stopped via abort()
if (record.status !== "stopped") {
record.status = "error";
}
record.error = err instanceof Error ? err.message : String(err);
record.completedAt ??= Date.now();
detach();
// Final flush of streaming output file on error
if (record.outputCleanup) {
try { record.outputCleanup(); } catch { /* ignore */ }
record.outputCleanup = undefined;
}
// Best-effort worktree cleanup on error
if (record.worktree) {
try {
const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
record.worktreeResult = wtResult;
} catch { /* ignore cleanup errors */ }
}
if (options.isBackground) {
this.runningBackground--;
this.onComplete?.(record);
this.drainQueue();
}
return "";
});
record.promise = promise;
}
/** Start queued agents up to the concurrency limit. */
private drainQueue() {
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
const next = this.queue.shift()!;
const record = this.agents.get(next.id);
if (!record || record.status !== "queued") continue;
try {
this.startAgent(next.id, record, next.args);
} catch (err) {
// Late failure (e.g. strict worktree-isolation) — surface on the record
// so the user/agent can see it via /agents, then keep draining.
record.status = "error";
record.error = err instanceof Error ? err.message : String(err);
record.completedAt = Date.now();
this.onComplete?.(record);
}
}
}
/**
* Spawn an agent and wait for completion (foreground use).
* Foreground agents bypass the concurrency queue.
*/
async spawnAndWait(
pi: ExtensionAPI,
ctx: ExtensionContext,
type: SubagentType,
prompt: string,
options: Omit<SpawnOptions, "isBackground">,
): Promise<AgentRecord> {
const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
const record = this.agents.get(id)!;
await record.promise;
return record;
}
/**
* Resume an existing agent session with a new prompt.
*/
async resume(
id: string,
prompt: string,
signal?: AbortSignal,
): Promise<AgentRecord | undefined> {
const record = this.agents.get(id);
if (!record?.session) return undefined;
record.status = "running";
record.startedAt = Date.now();
record.completedAt = undefined;
record.result = undefined;
record.error = undefined;
try {
const responseText = await resumeAgent(record.session, prompt, {
onToolActivity: (activity) => {
if (activity.type === "end") record.toolUses++;
},
onAssistantUsage: (usage) => {
addUsage(record.lifetimeUsage, usage);
},
onCompaction: (info) => {
record.compactionCount++;
this.onCompact?.(record, info);
},
signal,
});
record.status = "completed";
record.result = responseText;
record.completedAt = Date.now();
} catch (err) {
record.status = "error";
record.error = err instanceof Error ? err.message : String(err);
record.completedAt = Date.now();
}
return record;
}
getRecord(id: string): AgentRecord | undefined {
return this.agents.get(id);
}
listAgents(): AgentRecord[] {
return [...this.agents.values()].sort(
(a, b) => b.startedAt - a.startedAt,
);
}
abort(id: string): boolean {
const record = this.agents.get(id);
if (!record) return false;
// Remove from queue if queued
if (record.status === "queued") {
this.queue = this.queue.filter(q => q.id !== id);
record.status = "stopped";
record.completedAt = Date.now();
return true;
}
if (record.status !== "running") return false;
record.abortController?.abort();
record.status = "stopped";
record.completedAt = Date.now();
return true;
}
/** Dispose a record's session and remove it from the map. */
private removeRecord(id: string, record: AgentRecord): void {
record.session?.dispose?.();
record.session = undefined;
this.agents.delete(id);
}
private cleanup() {
const cutoff = Date.now() - 10 * 60_000;
for (const [id, record] of this.agents) {
if (record.status === "running" || record.status === "queued") continue;
if ((record.completedAt ?? 0) >= cutoff) continue;
this.removeRecord(id, record);
}
}
/**
* Remove all completed/stopped/errored records immediately.
* Called on session start/switch so tasks from a prior session don't persist.
*/
clearCompleted(): void {
for (const [id, record] of this.agents) {
if (record.status === "running" || record.status === "queued") continue;
this.removeRecord(id, record);
}
}
/** Whether any agents are still running or queued. */
hasRunning(): boolean {
return [...this.agents.values()].some(
r => r.status === "running" || r.status === "queued",
);
}
/** Abort all running and queued agents immediately. */
abortAll(): number {
let count = 0;
// Clear queued agents first
for (const queued of this.queue) {
const record = this.agents.get(queued.id);
if (record) {
record.status = "stopped";
record.completedAt = Date.now();
count++;
}
}
this.queue = [];
// Abort running agents
for (const record of this.agents.values()) {
if (record.status === "running") {
record.abortController?.abort();
record.status = "stopped";
record.completedAt = Date.now();
count++;
}
}
return count;
}
/** Wait for all running and queued agents to complete (including queued ones). */
async waitForAll(): Promise<void> {
// Loop because drainQueue respects the concurrency limit — as running
// agents finish they start queued ones, which need awaiting too.
while (true) {
this.drainQueue();
const pending = [...this.agents.values()]
.filter(r => r.status === "running" || r.status === "queued")
.map(r => r.promise)
.filter(Boolean);
if (pending.length === 0) break;
await Promise.allSettled(pending);
}
}
dispose() {
clearInterval(this.cleanupInterval);
// Clear queue
this.queue = [];
for (const record of this.agents.values()) {
record.session?.dispose();
}
this.agents.clear();
// Prune any orphaned git worktrees (crash recovery)
try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
}
}

View File

@@ -0,0 +1,479 @@
/**
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
*/
import type { Model } from "@mariozechner/pi-ai";
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import {
type AgentSession,
type AgentSessionEvent,
createAgentSession,
DefaultResourceLoader,
type ExtensionAPI,
getAgentDir,
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
import { getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
import { buildParentContext, extractText } from "./context.js";
import { DEFAULT_AGENTS } from "./default-agents.js";
import { detectEnv } from "./env.js";
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
import { preloadSkills } from "./skill-loader.js";
import type { SubagentType, ThinkingLevel } from "./types.js";
/** Names of tools registered by this extension that subagents must NOT inherit. */
const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
/** Default max turns. undefined = unlimited (no turn limit). */
let defaultMaxTurns: number | undefined;
/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
export function normalizeMaxTurns(n: number | undefined): number | undefined {
if (n == null || n === 0) return undefined;
return Math.max(1, n);
}
/** Get the default max turns value. undefined = unlimited. */
export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
/** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); }
/** Additional turns allowed after the soft limit steer message. */
let graceTurns = 5;
/** Get the grace turns value. */
export function getGraceTurns(): number { return graceTurns; }
/** Set the grace turns value (minimum 1). */
export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
/**
* Try to find the right model for an agent type.
* Priority: explicit option > config.model > parent model.
*/
function resolveDefaultModel(
parentModel: Model<any> | undefined,
registry: { find(provider: string, modelId: string): Model<any> | undefined; getAvailable?(): Model<any>[] },
configModel?: string,
): Model<any> | undefined {
if (configModel) {
const slashIdx = configModel.indexOf("/");
if (slashIdx !== -1) {
const provider = configModel.slice(0, slashIdx);
const modelId = configModel.slice(slashIdx + 1);
// Build a set of available model keys for fast lookup
const available = registry.getAvailable?.();
const availableKeys = available
? new Set(available.map((m: any) => `${m.provider}/${m.id}`))
: undefined;
const isAvailable = (p: string, id: string) =>
!availableKeys || availableKeys.has(`${p}/${id}`);
const found = registry.find(provider, modelId);
if (found && isAvailable(provider, modelId)) return found;
}
}
return parentModel;
}
/** Info about a tool event in the subagent. */
export interface ToolActivity {
type: "start" | "end";
toolName: string;
}
export interface RunOptions {
/** ExtensionAPI instance — used for pi.exec() instead of execSync. */
pi: ExtensionAPI;
model?: Model<any>;
maxTurns?: number;
signal?: AbortSignal;
isolated?: boolean;
inheritContext?: boolean;
thinkingLevel?: ThinkingLevel;
/** Override working directory (e.g. for worktree isolation). */
cwd?: string;
/** Called on tool start/end with activity info. */
onToolActivity?: (activity: ToolActivity) => void;
/** Called on streaming text deltas from the assistant response. */
onTextDelta?: (delta: string, fullText: string) => void;
onSessionCreated?: (session: AgentSession) => void;
/** Called at the end of each agentic turn with the cumulative count. */
onTurnEnd?: (turnCount: number) => void;
/**
* Called once per assistant message_end with that message's usage delta.
* Lets callers maintain a lifetime accumulator that survives compaction
* (which replaces session.state.messages and resets stats-derived sums).
*/
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
/**
* Called when the session successfully compacts. `tokensBefore` is upstream's
* pre-compaction context size estimate. Aborted compactions don't fire.
*/
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
}
export interface RunResult {
responseText: string;
session: AgentSession;
/** True if the agent was hard-aborted (max_turns + grace exceeded). */
aborted: boolean;
/** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
steered: boolean;
}
/**
* Subscribe to a session and collect the last assistant message text.
* Returns an object with a `getText()` getter and an `unsubscribe` function.
*/
function collectResponseText(session: AgentSession) {
let text = "";
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "message_start") {
text = "";
}
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
text += event.assistantMessageEvent.delta;
}
});
return { getText: () => text, unsubscribe };
}
/** Get the last assistant text from the completed session history. */
function getLastAssistantText(session: AgentSession): string {
for (let i = session.messages.length - 1; i >= 0; i--) {
const msg = session.messages[i];
if (msg.role !== "assistant") continue;
const text = extractText(msg.content).trim();
if (text) return text;
}
return "";
}
/**
* Wire an AbortSignal to abort a session.
* Returns a cleanup function to remove the listener.
*/
function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
if (!signal) return () => {};
const onAbort = () => session.abort();
signal.addEventListener("abort", onAbort, { once: true });
return () => signal.removeEventListener("abort", onAbort);
}
export async function runAgent(
ctx: ExtensionContext,
type: SubagentType,
prompt: string,
options: RunOptions,
): Promise<RunResult> {
const config = getConfig(type);
const agentConfig = getAgentConfig(type);
// Resolve working directory: worktree override > parent cwd
const effectiveCwd = options.cwd ?? ctx.cwd;
const env = await detectEnv(options.pi, effectiveCwd);
// Get parent system prompt for append-mode agents
const parentSystemPrompt = ctx.getSystemPrompt();
// Build prompt extras (memory, skill preloading)
const extras: PromptExtras = {};
// Resolve extensions/skills: isolated overrides to false
const extensions = options.isolated ? false : config.extensions;
const skills = options.isolated ? false : config.skills;
// Skill preloading: when skills is string[], preload their content into prompt
if (Array.isArray(skills)) {
const loaded = preloadSkills(skills, effectiveCwd);
if (loaded.length > 0) {
extras.skillBlocks = loaded;
}
}
let toolNames = getToolNamesForType(type);
// Persistent memory: detect write capability and branch accordingly.
// Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
if (agentConfig?.memory) {
const existingNames = new Set(toolNames);
const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
const effectivelyHas = (name: string) => existingNames.has(name) && !denied?.has(name);
const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
if (hasWriteTools) {
// Read-write memory: add any missing memory tool names (read/write/edit)
const extraNames = getMemoryToolNames(existingNames);
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
} else {
// Read-only memory: only add read tool name, use read-only prompt
const extraNames = getReadOnlyMemoryToolNames(existingNames);
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
}
}
// Build system prompt from agent config
let systemPrompt: string;
if (agentConfig) {
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
} else {
// Unknown type fallback: spread the canonical general-purpose config (defensive —
// unreachable in practice since index.ts resolves unknown types before calling runAgent).
const fallback = DEFAULT_AGENTS.get("general-purpose");
if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`);
systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras);
}
// When skills is string[], we've already preloaded them into the prompt.
// Still pass noSkills: true since we don't need the skill loader to load them again.
const noSkills = skills === false || Array.isArray(skills);
const agentDir = getAgentDir();
// Load extensions/skills: true or string[] → load; false → don't.
// Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
// buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
// would defeat prompt_mode: replace and isolated: true. Parent context, if
// wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
// is embedded in systemPromptOverride) or inherit_context (conversation).
const loader = new DefaultResourceLoader({
cwd: effectiveCwd,
agentDir,
noExtensions: extensions === false,
noSkills,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
systemPromptOverride: () => systemPrompt,
appendSystemPromptOverride: () => [],
});
await loader.reload();
// Resolve model: explicit option > config.model > parent model
const model = options.model ?? resolveDefaultModel(
ctx.model, ctx.modelRegistry, agentConfig?.model,
);
// Resolve thinking level: explicit option > agent config > undefined (inherit)
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
const sessionOpts: Parameters<typeof createAgentSession>[0] = {
cwd: effectiveCwd,
agentDir,
sessionManager: SessionManager.inMemory(effectiveCwd),
settingsManager: SettingsManager.create(effectiveCwd, agentDir),
modelRegistry: ctx.modelRegistry,
model,
tools: toolNames,
resourceLoader: loader,
};
if (thinkingLevel) {
sessionOpts.thinkingLevel = thinkingLevel;
}
const { session } = await createAgentSession(sessionOpts);
// Build disallowed tools set from agent config
const disallowedSet = agentConfig?.disallowedTools
? new Set(agentConfig.disallowedTools)
: undefined;
// Filter active tools: remove our own tools to prevent nesting,
// apply extension allowlist if specified, and apply disallowedTools denylist
if (extensions !== false) {
const builtinToolNameSet = new Set(toolNames);
const activeTools = session.getActiveToolNames().filter((t) => {
if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
if (disallowedSet?.has(t)) return false;
if (builtinToolNameSet.has(t)) return true;
if (Array.isArray(extensions)) {
return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
}
return true;
});
session.setActiveToolsByName(activeTools);
} else if (disallowedSet) {
// Even with extensions disabled, apply denylist to built-in tools
const activeTools = session.getActiveToolNames().filter(t => !disallowedSet.has(t));
session.setActiveToolsByName(activeTools);
}
// Bind extensions so that session_start fires and extensions can initialize
// (e.g. loading credentials, setting up state). Placed after tool filtering
// so extension-provided skills/prompts from extendResourcesFromExtensions()
// respect the active tool set. All ExtensionBindings fields are optional.
await session.bindExtensions({
onError: (err) => {
options.onToolActivity?.({
type: "end",
toolName: `extension-error:${err.extensionPath}`,
});
},
});
options.onSessionCreated?.(session);
// Track turns for graceful max_turns enforcement
let turnCount = 0;
const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
let softLimitReached = false;
let aborted = false;
let currentMessageText = "";
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "turn_end") {
turnCount++;
options.onTurnEnd?.(turnCount);
if (maxTurns != null) {
if (!softLimitReached && turnCount >= maxTurns) {
softLimitReached = true;
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
} else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
aborted = true;
session.abort();
}
}
}
if (event.type === "message_start") {
currentMessageText = "";
}
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
currentMessageText += event.assistantMessageEvent.delta;
options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
}
if (event.type === "tool_execution_start") {
options.onToolActivity?.({ type: "start", toolName: event.toolName });
}
if (event.type === "tool_execution_end") {
options.onToolActivity?.({ type: "end", toolName: event.toolName });
}
if (event.type === "message_end" && event.message.role === "assistant") {
const u = (event.message as any).usage;
if (u) options.onAssistantUsage?.({
input: u.input ?? 0,
output: u.output ?? 0,
cacheWrite: u.cacheWrite ?? 0,
});
}
if (event.type === "compaction_end" && !event.aborted && event.result) {
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
}
});
const collector = collectResponseText(session);
const cleanupAbort = forwardAbortSignal(session, options.signal);
// Build the effective prompt: optionally prepend parent context
let effectivePrompt = prompt;
if (options.inheritContext) {
const parentContext = buildParentContext(ctx);
if (parentContext) {
effectivePrompt = parentContext + prompt;
}
}
try {
await session.prompt(effectivePrompt);
} finally {
unsubTurns();
collector.unsubscribe();
cleanupAbort();
}
const responseText = collector.getText().trim() || getLastAssistantText(session);
return { responseText, session, aborted, steered: softLimitReached };
}
/**
* Send a new prompt to an existing session (resume).
*/
export async function resumeAgent(
session: AgentSession,
prompt: string,
options: {
onToolActivity?: (activity: ToolActivity) => void;
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
signal?: AbortSignal;
} = {},
): Promise<string> {
const collector = collectResponseText(session);
const cleanupAbort = forwardAbortSignal(session, options.signal);
const unsubEvents = (options.onToolActivity || options.onAssistantUsage || options.onCompaction)
? session.subscribe((event: AgentSessionEvent) => {
if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
if (event.type === "message_end" && event.message.role === "assistant") {
const u = (event.message as any).usage;
if (u) options.onAssistantUsage?.({
input: u.input ?? 0,
output: u.output ?? 0,
cacheWrite: u.cacheWrite ?? 0,
});
}
if (event.type === "compaction_end" && !event.aborted && event.result) {
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
}
})
: () => {};
try {
await session.prompt(prompt);
} finally {
collector.unsubscribe();
unsubEvents();
cleanupAbort();
}
return collector.getText().trim() || getLastAssistantText(session);
}
/**
* Send a steering message to a running subagent.
* The message will interrupt the agent after its current tool execution.
*/
export async function steerAgent(
session: AgentSession,
message: string,
): Promise<void> {
await session.steer(message);
}
/**
* Get the subagent's conversation messages as formatted text.
*/
export function getAgentConversation(session: AgentSession): string {
const parts: string[] = [];
for (const msg of session.messages) {
if (msg.role === "user") {
const text = typeof msg.content === "string"
? msg.content
: extractText(msg.content);
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const toolCalls: string[] = [];
for (const c of msg.content) {
if (c.type === "text" && c.text) textParts.push(c.text);
else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).name ?? (c as any).toolName ?? "unknown"}`);
}
if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
} else if (msg.role === "toolResult") {
const text = extractText(msg.content);
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
}
}
return parts.join("\n\n");
}

View File

@@ -0,0 +1,164 @@
/**
* agent-types.ts — Unified agent type registry.
*
* Merges embedded default agents with user-defined agents from .pi/agents/*.md.
* User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
*/
import { DEFAULT_AGENTS } from "./default-agents.js";
import type { AgentConfig } from "./types.js";
/** All known built-in tool names. */
export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
/** Unified runtime registry of all agents (defaults + user-defined). */
const agents = new Map<string, AgentConfig>();
/**
* Register agents into the unified registry.
* Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
* Disabled agents (enabled === false) are kept in the registry but excluded from spawning.
*/
export function registerAgents(userAgents: Map<string, AgentConfig>): void {
agents.clear();
// Start with defaults
for (const [name, config] of DEFAULT_AGENTS) {
agents.set(name, config);
}
// Overlay user agents (overrides defaults with same name)
for (const [name, config] of userAgents) {
agents.set(name, config);
}
}
/** Case-insensitive key resolution. */
function resolveKey(name: string): string | undefined {
if (agents.has(name)) return name;
const lower = name.toLowerCase();
for (const key of agents.keys()) {
if (key.toLowerCase() === lower) return key;
}
return undefined;
}
/** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
export function resolveType(name: string): string | undefined {
return resolveKey(name);
}
/** Get the agent config for a type (case-insensitive). */
export function getAgentConfig(name: string): AgentConfig | undefined {
const key = resolveKey(name);
return key ? agents.get(key) : undefined;
}
/** Get all enabled type names (for spawning and tool descriptions). */
export function getAvailableTypes(): string[] {
return [...agents.entries()]
.filter(([_, config]) => config.enabled !== false)
.map(([name]) => name);
}
/** Get all type names including disabled (for UI listing). */
export function getAllTypes(): string[] {
return [...agents.keys()];
}
/** Get names of default agents currently in the registry. */
export function getDefaultAgentNames(): string[] {
return [...agents.entries()]
.filter(([_, config]) => config.isDefault === true)
.map(([name]) => name);
}
/** Get names of user-defined agents (non-defaults) currently in the registry. */
export function getUserAgentNames(): string[] {
return [...agents.entries()]
.filter(([_, config]) => config.isDefault !== true)
.map(([name]) => name);
}
/** Check if a type is valid and enabled (case-insensitive). */
export function isValidType(type: string): boolean {
const key = resolveKey(type);
if (!key) return false;
return agents.get(key)?.enabled !== false;
}
/** Tool names required for memory management. */
const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
/**
* Get memory tool names (read/write/edit) not already in the provided set.
*/
export function getMemoryToolNames(existingToolNames: Set<string>): string[] {
return MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
}
/** Tool names needed for read-only memory access. */
const READONLY_MEMORY_TOOL_NAMES = ["read"];
/**
* Get read-only memory tool names not already in the provided set.
*/
export function getReadOnlyMemoryToolNames(existingToolNames: Set<string>): string[] {
return READONLY_MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
}
/** Get built-in tool names for a type (case-insensitive). */
export function getToolNamesForType(type: string): string[] {
const key = resolveKey(type);
const raw = key ? agents.get(key) : undefined;
const config = raw?.enabled !== false ? raw : undefined;
const names = config?.builtinToolNames?.length ? config.builtinToolNames : [...BUILTIN_TOOL_NAMES];
return names;
}
/** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
export function getConfig(type: string): {
displayName: string;
description: string;
builtinToolNames: string[];
extensions: true | string[] | false;
skills: true | string[] | false;
promptMode: "replace" | "append";
} {
const key = resolveKey(type);
const config = key ? agents.get(key) : undefined;
if (config && config.enabled !== false) {
return {
displayName: config.displayName ?? config.name,
description: config.description,
builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
extensions: config.extensions,
skills: config.skills,
promptMode: config.promptMode,
};
}
// Fallback for unknown/disabled types — general-purpose config
const gp = agents.get("general-purpose");
if (gp && gp.enabled !== false) {
return {
displayName: gp.displayName ?? gp.name,
description: gp.description,
builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
extensions: gp.extensions,
skills: gp.skills,
promptMode: gp.promptMode,
};
}
// Absolute fallback (should never happen)
return {
displayName: "Agent",
description: "General-purpose agent for complex, multi-step tasks",
builtinToolNames: BUILTIN_TOOL_NAMES,
extensions: true,
skills: true,
promptMode: "append",
};
}

View File

@@ -0,0 +1,58 @@
/**
* context.ts — Extract parent conversation context for subagent inheritance.
*/
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
/** Extract text from a message content block array. */
export function extractText(content: unknown[]): string {
return content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text ?? "")
.join("\n");
}
/**
* Build a text representation of the parent conversation context.
* Used when inherit_context is true to give the subagent visibility
* into what has been discussed/done so far.
*/
export function buildParentContext(ctx: ExtensionContext): string {
const entries = ctx.sessionManager.getBranch();
if (!entries || entries.length === 0) return "";
const parts: string[] = [];
for (const entry of entries) {
if (entry.type === "message") {
const msg = entry.message;
if (msg.role === "user") {
const text = typeof msg.content === "string"
? msg.content
: extractText(msg.content);
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
} else if (msg.role === "assistant") {
const text = extractText(msg.content);
if (text.trim()) parts.push(`[Assistant]: ${text.trim()}`);
}
// Skip toolResult messages — too verbose for context
} else if (entry.type === "compaction") {
// Include compaction summaries — they're already condensed
if (entry.summary) {
parts.push(`[Summary]: ${entry.summary}`);
}
}
}
if (parts.length === 0) return "";
return `# Parent Conversation Context
The following is the conversation history from the parent session that spawned you.
Use this context to understand what has been discussed and decided so far.
${parts.join("\n\n")}
---
# Your Task (below)
`;
}

View File

@@ -0,0 +1,95 @@
/**
* Cross-extension RPC handlers for the subagents extension.
*
* Exposes ping, spawn, and stop RPCs over the pi.events event bus,
* using per-request scoped reply channels.
*
* Reply envelope follows pi-mono convention:
* success → { success: true, data?: T }
* error → { success: false, error: string }
*/
/** Minimal event bus interface needed by the RPC handlers. */
export interface EventBus {
on(event: string, handler: (data: unknown) => void): () => void;
emit(event: string, data: unknown): void;
}
/** RPC reply envelope — matches pi-mono's RpcResponse shape. */
export type RpcReply<T = void> =
| { success: true; data?: T }
| { success: false; error: string };
/** RPC protocol version — bumped when the envelope or method contracts change. */
export const PROTOCOL_VERSION = 2;
/** Minimal AgentManager interface needed by the spawn/stop RPCs. */
export interface SpawnCapable {
spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
abort(id: string): boolean;
}
export interface RpcDeps {
events: EventBus;
pi: unknown; // passed through to manager.spawn
getCtx: () => unknown | undefined; // returns current ExtensionContext
manager: SpawnCapable;
}
export interface RpcHandle {
unsubPing: () => void;
unsubSpawn: () => void;
unsubStop: () => void;
}
/**
* Wire a single RPC handler: listen on `channel`, run `fn(params)`,
* emit the reply envelope on `channel:reply:${requestId}`.
*/
function handleRpc<P extends { requestId: string }>(
events: EventBus,
channel: string,
fn: (params: P) => unknown | Promise<unknown>,
): () => void {
return events.on(channel, async (raw: unknown) => {
const params = raw as P;
try {
const data = await fn(params);
const reply: { success: true; data?: unknown } = { success: true };
if (data !== undefined) reply.data = data;
events.emit(`${channel}:reply:${params.requestId}`, reply);
} catch (err: any) {
events.emit(`${channel}:reply:${params.requestId}`, {
success: false, error: err?.message ?? String(err),
});
}
});
}
/**
* Register ping, spawn, and stop RPC handlers on the event bus.
* Returns unsub functions for cleanup.
*/
export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
const { events, pi, getCtx, manager } = deps;
const unsubPing = handleRpc(events, "subagents:rpc:ping", () => {
return { version: PROTOCOL_VERSION };
});
const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
const ctx = getCtx();
if (!ctx) throw new Error("No active session");
return { id: manager.spawn(pi, ctx, type, prompt, options ?? {}) };
},
);
const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
events, "subagents:rpc:stop", ({ agentId }) => {
if (!manager.abort(agentId)) throw new Error("Agent not found");
},
);
return { unsubPing, unsubSpawn, unsubStop };
}

View File

@@ -0,0 +1,136 @@
/**
* custom-agents.ts — Load user-defined agents from project (.pi/agents/) and global ($PI_CODING_AGENT_DIR/agents/, default ~/.pi/agent/agents/) locations.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
/**
* Scan for custom agent .md files from multiple locations.
* Discovery hierarchy (higher priority wins):
* 1. Project: <cwd>/.pi/agents/*.md
* 2. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md)
*
* Project-level agents override global ones with the same name.
* Any name is allowed — names matching defaults (e.g. "Explore") override them.
*/
export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
const globalDir = join(getAgentDir(), "agents");
const projectDir = join(cwd, ".pi", "agents");
const agents = new Map<string, AgentConfig>();
loadFromDir(globalDir, agents, "global"); // lower priority
loadFromDir(projectDir, agents, "project"); // higher priority (overwrites)
return agents;
}
/** Load agent configs from a directory into the map. */
function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global"): void {
if (!existsSync(dir)) return;
let files: string[];
try {
files = readdirSync(dir).filter(f => f.endsWith(".md"));
} catch {
return;
}
for (const file of files) {
const name = basename(file, ".md");
let content: string;
try {
content = readFileSync(join(dir, file), "utf-8");
} catch {
continue;
}
const { frontmatter: fm, body } = parseFrontmatter<Record<string, unknown>>(content);
agents.set(name, {
name,
displayName: str(fm.display_name),
description: str(fm.description) ?? name,
builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
disallowedTools: csvListOptional(fm.disallowed_tools),
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
skills: inheritField(fm.skills ?? fm.inherit_skills),
model: str(fm.model),
thinking: str(fm.thinking) as ThinkingLevel | undefined,
maxTurns: nonNegativeInt(fm.max_turns),
systemPrompt: body.trim(),
promptMode: fm.prompt_mode === "append" ? "append" : "replace",
inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
isolated: fm.isolated != null ? fm.isolated === true : undefined,
memory: parseMemory(fm.memory),
isolation: fm.isolation === "worktree" ? "worktree" : undefined,
enabled: fm.enabled !== false, // default true; explicitly false disables
source,
});
}
}
// ---- Field parsers ----
// All follow the same convention: omitted → default, "none"/empty → nothing, value → exact.
/** Extract a string or undefined. */
function str(val: unknown): string | undefined {
return typeof val === "string" ? val : undefined;
}
/** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
function nonNegativeInt(val: unknown): number | undefined {
return typeof val === "number" && val >= 0 ? val : undefined;
}
/**
* Parse a raw CSV field value into items, or undefined if absent/empty/"none".
*/
function parseCsvField(val: unknown): string[] | undefined {
if (val === undefined || val === null) return undefined;
const s = String(val).trim();
if (!s || s === "none") return undefined;
const items = s.split(",").map(t => t.trim()).filter(Boolean);
return items.length > 0 ? items : undefined;
}
/**
* Parse a comma-separated list field with defaults.
* omitted → defaults; "none"/empty → []; csv → listed items.
*/
function csvList(val: unknown, defaults: string[]): string[] {
if (val === undefined || val === null) return defaults;
return parseCsvField(val) ?? [];
}
/**
* Parse an optional comma-separated list field.
* omitted → undefined; "none"/empty → undefined; csv → listed items.
*/
function csvListOptional(val: unknown): string[] | undefined {
return parseCsvField(val);
}
/**
* Parse a memory scope field.
* omitted → undefined; "user"/"project"/"local" → MemoryScope.
*/
function parseMemory(val: unknown): MemoryScope | undefined {
if (val === "user" || val === "project" || val === "local") return val;
return undefined;
}
/**
* Parse an inherit field (extensions, skills).
* omitted/true → true (inherit all); false/"none"/empty → false; csv → listed names.
*/
function inheritField(val: unknown): true | string[] | false {
if (val === undefined || val === null || val === true) return true;
if (val === false || val === "none") return false;
const items = csvList(val, []);
return items.length > 0 ? items : false;
}

View File

@@ -0,0 +1,123 @@
/**
* default-agents.ts — Embedded default agent configurations.
*
* These are always available but can be overridden by user .md files with the same name.
*/
import type { AgentConfig } from "./types.js";
const READ_ONLY_TOOLS = ["read", "bash", "grep", "find", "ls"];
export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
[
"general-purpose",
{
name: "general-purpose",
displayName: "Agent",
description: "General-purpose agent for complex, multi-step tasks",
// builtinToolNames omitted — means "all available tools" (resolved at lookup time)
// inheritContext / runInBackground / isolated omitted — strategy fields, callers decide per-call.
// Setting them to false would lock callsite intent (see resolveAgentInvocationConfig in invocation-config.ts).
extensions: true,
skills: true,
systemPrompt: "",
promptMode: "append",
isDefault: true,
},
],
[
"Explore",
{
name: "Explore",
displayName: "Explore",
description: "Fast codebase exploration agent (read-only)",
builtinToolNames: READ_ONLY_TOOLS,
extensions: true,
skills: true,
model: "anthropic/claude-haiku-4-5-20251001",
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
You are STRICTLY PROHIBITED from:
- Creating new files
- Modifying existing files
- Deleting files
- Moving or copying files
- Creating temporary files anywhere, including /tmp
- Using redirect operators (>, >>, |) or heredocs to write to files
- Running ANY commands that change system state
Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find, cat, head, tail.
# Tool Usage
- Use the find tool for file pattern matching (NOT the bash find command)
- Use the grep tool for content search (NOT bash grep/rg command)
- Use the read tool for reading files (NOT bash cat/head/tail)
- Use Bash ONLY for read-only operations
- Make independent tool calls in parallel for efficiency
- Adapt search approach based on thoroughness level specified
# Output
- Use absolute file paths in all references
- Report findings as regular messages
- Do not use emojis
- Be thorough and precise`,
promptMode: "replace",
isDefault: true,
},
],
[
"Plan",
{
name: "Plan",
displayName: "Plan",
description: "Software architect for implementation planning (read-only)",
builtinToolNames: READ_ONLY_TOOLS,
extensions: true,
skills: true,
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
You are a software architect and planning specialist.
Your role is EXCLUSIVELY to explore the codebase and design implementation plans.
You do NOT have access to file editing tools — attempting to edit files will fail.
You are STRICTLY PROHIBITED from:
- Creating new files
- Modifying existing files
- Deleting files
- Moving or copying files
- Creating temporary files anywhere, including /tmp
- Using redirect operators (>, >>, |) or heredocs to write to files
- Running ANY commands that change system state
# Planning Process
1. Understand requirements
2. Explore thoroughly (read files, find patterns, understand architecture)
3. Design solution based on your assigned perspective
4. Detail the plan with step-by-step implementation strategy
# Requirements
- Consider trade-offs and architectural decisions
- Identify dependencies and sequencing
- Anticipate potential challenges
- Follow existing patterns where appropriate
# Tool Usage
- Use the find tool for file pattern matching (NOT the bash find command)
- Use the grep tool for content search (NOT bash grep/rg command)
- Use the read tool for reading files (NOT bash cat/head/tail)
- Use Bash ONLY for read-only operations
# Output Format
- Use absolute file paths
- Do not use emojis
- End your response with:
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan:
- /absolute/path/to/file.ts - [Brief reason]`,
promptMode: "replace",
isDefault: true,
},
],
]);

View File

@@ -0,0 +1,33 @@
/**
* env.ts — Detect environment info (git, platform) for subagent system prompts.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import type { EnvInfo } from "./types.js";
export async function detectEnv(pi: ExtensionAPI, cwd: string): Promise<EnvInfo> {
let isGitRepo = false;
let branch = "";
try {
const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 });
isGitRepo = result.code === 0 && result.stdout.trim() === "true";
} catch {
// Not a git repo or git not installed
}
if (isGitRepo) {
try {
const result = await pi.exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 });
branch = result.code === 0 ? result.stdout.trim() : "unknown";
} catch {
branch = "unknown";
}
}
return {
isGitRepo,
branch,
platform: process.platform,
};
}

View File

@@ -0,0 +1,141 @@
/**
* group-join.ts — Manages grouped background agent completion notifications.
*
* Instead of each agent individually nudging the main agent on completion,
* agents in a group are held until all complete (or a timeout fires),
* then a single consolidated notification is sent.
*/
import type { AgentRecord } from "./types.js";
export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
interface AgentGroup {
groupId: string;
agentIds: Set<string>;
completedRecords: Map<string, AgentRecord>;
timeoutHandle?: ReturnType<typeof setTimeout>;
delivered: boolean;
/** Shorter timeout for stragglers after a partial delivery. */
isStraggler: boolean;
}
/** Default timeout: 30s after first completion in a group. */
const DEFAULT_TIMEOUT = 30_000;
/** Straggler re-batch timeout: 15s. */
const STRAGGLER_TIMEOUT = 15_000;
export class GroupJoinManager {
private groups = new Map<string, AgentGroup>();
private agentToGroup = new Map<string, string>();
constructor(
private deliverCb: DeliveryCallback,
private groupTimeout = DEFAULT_TIMEOUT,
) {}
/** Register a group of agent IDs that should be joined. */
registerGroup(groupId: string, agentIds: string[]): void {
const group: AgentGroup = {
groupId,
agentIds: new Set(agentIds),
completedRecords: new Map(),
delivered: false,
isStraggler: false,
};
this.groups.set(groupId, group);
for (const id of agentIds) {
this.agentToGroup.set(id, groupId);
}
}
/**
* Called when an agent completes.
* Returns:
* - 'pass' — agent is not grouped, caller should send individual nudge
* - 'held' — result held, waiting for group completion
* - 'delivered' — this completion triggered the group notification
*/
onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
const groupId = this.agentToGroup.get(record.id);
if (!groupId) return 'pass';
const group = this.groups.get(groupId);
if (!group || group.delivered) return 'pass';
group.completedRecords.set(record.id, record);
// All done — deliver immediately
if (group.completedRecords.size >= group.agentIds.size) {
this.deliver(group, false);
return 'delivered';
}
// First completion in this batch — start timeout
if (!group.timeoutHandle) {
const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
group.timeoutHandle = setTimeout(() => {
this.onTimeout(group);
}, timeout);
}
return 'held';
}
private onTimeout(group: AgentGroup): void {
if (group.delivered) return;
group.timeoutHandle = undefined;
// Partial delivery — some agents still running
const remaining = new Set<string>();
for (const id of group.agentIds) {
if (!group.completedRecords.has(id)) remaining.add(id);
}
// Clean up agentToGroup for delivered agents (they won't complete again)
for (const id of group.completedRecords.keys()) {
this.agentToGroup.delete(id);
}
// Deliver what we have
this.deliverCb([...group.completedRecords.values()], true);
// Set up straggler group for remaining agents
group.completedRecords.clear();
group.agentIds = remaining;
group.isStraggler = true;
// Timeout will be started when the next straggler completes
}
private deliver(group: AgentGroup, partial: boolean): void {
if (group.timeoutHandle) {
clearTimeout(group.timeoutHandle);
group.timeoutHandle = undefined;
}
group.delivered = true;
this.deliverCb([...group.completedRecords.values()], partial);
this.cleanupGroup(group.groupId);
}
private cleanupGroup(groupId: string): void {
const group = this.groups.get(groupId);
if (!group) return;
for (const id of group.agentIds) {
this.agentToGroup.delete(id);
}
this.groups.delete(groupId);
}
/** Check if an agent is in a group. */
isGrouped(agentId: string): boolean {
return this.agentToGroup.has(agentId);
}
dispose(): void {
for (const group of this.groups.values()) {
if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
}
this.groups.clear();
this.agentToGroup.clear();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
interface AgentInvocationParams {
model?: string;
thinking?: string;
max_turns?: number;
run_in_background?: boolean;
inherit_context?: boolean;
isolated?: boolean;
isolation?: IsolationMode;
}
export function resolveAgentInvocationConfig(
agentConfig: AgentConfig | undefined,
params: AgentInvocationParams,
): {
modelInput?: string;
modelFromParams: boolean;
thinking?: ThinkingLevel;
maxTurns?: number;
inheritContext: boolean;
runInBackground: boolean;
isolated: boolean;
isolation?: IsolationMode;
} {
return {
modelInput: agentConfig?.model ?? params.model,
modelFromParams: agentConfig?.model == null && params.model != null,
thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
maxTurns: agentConfig?.maxTurns ?? params.max_turns,
inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
isolated: agentConfig?.isolated ?? params.isolated ?? false,
isolation: agentConfig?.isolation ?? params.isolation,
};
}
export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
return runInBackground ? defaultJoinMode : undefined;
}

View File

@@ -0,0 +1,165 @@
/**
* memory.ts — Persistent agent memory: per-agent memory directories that persist across sessions.
*
* Memory scopes:
* - "user" → ~/.pi/agent-memory/{agent-name}/
* - "project" → .pi/agent-memory/{agent-name}/
* - "local" → .pi/agent-memory-local/{agent-name}/
*/
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, } from "node:path";
import type { MemoryScope } from "./types.js";
/** Maximum lines to read from MEMORY.md */
const MAX_MEMORY_LINES = 200;
/**
* Returns true if a name contains characters not allowed in agent/skill names.
* Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
*/
export function isUnsafeName(name: string): boolean {
if (!name || name.length > 128) return true;
return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
}
/**
* Returns true if the given path is a symlink (defense against symlink attacks).
*/
export function isSymlink(filePath: string): boolean {
try {
return lstatSync(filePath).isSymbolicLink();
} catch {
return false;
}
}
/**
* Safely read a file, rejecting symlinks.
* Returns undefined if the file doesn't exist, is a symlink, or can't be read.
*/
export function safeReadFile(filePath: string): string | undefined {
if (!existsSync(filePath)) return undefined;
if (isSymlink(filePath)) return undefined;
try {
return readFileSync(filePath, "utf-8");
} catch {
return undefined;
}
}
/**
* Resolve the memory directory path for a given agent + scope + cwd.
* Throws if agentName contains path traversal characters.
*/
export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string): string {
if (isUnsafeName(agentName)) {
throw new Error(`Unsafe agent name for memory directory: "${agentName}"`);
}
switch (scope) {
case "user":
return join(homedir(), ".pi", "agent-memory", agentName);
case "project":
return join(cwd, ".pi", "agent-memory", agentName);
case "local":
return join(cwd, ".pi", "agent-memory-local", agentName);
}
}
/**
* Ensure the memory directory exists, creating it if needed.
* Refuses to create directories if any component in the path is a symlink
* to prevent symlink-based directory traversal attacks.
*/
export function ensureMemoryDir(memoryDir: string): void {
// If the directory already exists, verify it's not a symlink
if (existsSync(memoryDir)) {
if (isSymlink(memoryDir)) {
throw new Error(`Refusing to use symlinked memory directory: ${memoryDir}`);
}
return;
}
mkdirSync(memoryDir, { recursive: true });
}
/**
* Read the first N lines of MEMORY.md from the memory directory, if it exists.
* Returns undefined if no MEMORY.md exists or if the path is a symlink.
*/
export function readMemoryIndex(memoryDir: string): string | undefined {
// Reject symlinked memory directories
if (isSymlink(memoryDir)) return undefined;
const memoryFile = join(memoryDir, "MEMORY.md");
const content = safeReadFile(memoryFile);
if (content === undefined) return undefined;
const lines = content.split("\n");
if (lines.length > MAX_MEMORY_LINES) {
return lines.slice(0, MAX_MEMORY_LINES).join("\n") + "\n... (truncated at 200 lines)";
}
return content;
}
/**
* Build the memory block to inject into the agent's system prompt.
* Also ensures the memory directory exists (creates it if needed).
*/
export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
// Create the memory directory so the agent can immediately write to it
ensureMemoryDir(memoryDir);
const existingMemory = readMemoryIndex(memoryDir);
const header = `# Agent Memory
You have a persistent memory directory at: ${memoryDir}/
Memory scope: ${scope}
This memory persists across sessions. Use it to build up knowledge over time.`;
const memoryContent = existingMemory
? `\n\n## Current MEMORY.md\n${existingMemory}`
: `\n\nNo MEMORY.md exists yet. Create one at ${join(memoryDir, "MEMORY.md")} to start building persistent memory.`;
const instructions = `
## Memory Instructions
- MEMORY.md is an index file — keep it concise (under 200 lines). Lines after 200 are truncated.
- Store detailed memories in separate files within ${memoryDir}/ and link to them from MEMORY.md.
- Each memory file should use this frontmatter format:
\`\`\`markdown
---
name: <memory name>
description: <one-line description>
type: <user|feedback|project|reference>
---
<memory content>
\`\`\`
- Update or remove memories that become outdated. Check for existing memories before creating duplicates.
- You have Read, Write, and Edit tools available for managing memory files.`;
return header + memoryContent + instructions;
}
/**
* Build a read-only memory block for agents that lack write/edit tools.
* Does NOT create the memory directory — agents can only consume existing memory.
*/
export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
const existingMemory = readMemoryIndex(memoryDir);
const header = `# Agent Memory (read-only)
Memory scope: ${scope}
You have read-only access to memory. You can reference existing memories but cannot create or modify them.`;
const memoryContent = existingMemory
? `\n\n## Current MEMORY.md\n${existingMemory}`
: `\n\nNo memory is available yet. Other agents or sessions with write access can create memories for you to consume.`;
return header + memoryContent;
}

View File

@@ -0,0 +1,81 @@
/**
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
*/
export interface ModelEntry {
id: string;
name: string;
provider: string;
}
export interface ModelRegistry {
find(provider: string, modelId: string): any;
getAll(): any[];
getAvailable?(): any[];
}
/**
* Resolve a model string to a Model instance.
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
* Returns the Model on success, or an error message string on failure.
*/
export function resolveModel(
input: string,
registry: ModelRegistry,
): any | string {
// Available models (those with auth configured)
const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
// 1. Exact match: "provider/modelId" — only if available (has auth)
const slashIdx = input.indexOf("/");
if (slashIdx !== -1) {
const provider = input.slice(0, slashIdx);
const modelId = input.slice(slashIdx + 1);
if (availableSet.has(input.toLowerCase())) {
const found = registry.find(provider, modelId);
if (found) return found;
}
}
// 2. Fuzzy match against available models
const query = input.toLowerCase();
// Score each model: prefer exact id match > id contains > name contains > provider+id contains
let bestMatch: ModelEntry | undefined;
let bestScore = 0;
for (const m of all) {
const id = m.id.toLowerCase();
const name = m.name.toLowerCase();
const full = `${m.provider}/${m.id}`.toLowerCase();
let score = 0;
if (id === query || full === query) {
score = 100; // exact
} else if (id.includes(query) || full.includes(query)) {
score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
} else if (name.includes(query)) {
score = 40 + (query.length / name.length) * 20;
} else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) {
score = 20; // all parts present somewhere
}
if (score > bestScore) {
bestScore = score;
bestMatch = m;
}
}
if (bestMatch && bestScore >= 20) {
const found = registry.find(bestMatch.provider, bestMatch.id);
if (found) return found;
}
// 3. No match — list available models
const modelList = all
.map(m => ` ${m.provider}/${m.id}`)
.sort()
.join("\n");
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
}

View File

@@ -0,0 +1,96 @@
/**
* output-file.ts — Streaming JSONL output file for agent transcripts.
*
* Creates a per-agent output file that streams conversation turns as JSONL,
* matching Claude Code's task output file format.
*/
import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentSession, AgentSessionEvent } from "@mariozechner/pi-coding-agent";
/**
* Encode a cwd path as a filesystem-safe directory name. Handles:
* - POSIX: "/home/user/project" → "home-user-project"
* - Windows: "C:\Users\foo\project" → "Users-foo-project"
* - UNC: "\\\\server\\share\\project" → "server-share-project"
*/
export function encodeCwd(cwd: string): string {
return cwd
.replace(/[/\\]/g, "-") // both separators → dash
.replace(/^[A-Za-z]:-/, "") // strip Windows drive prefix ("C:-")
.replace(/^-+/, ""); // strip leading dashes (POSIX root, UNC)
}
/** Create the output file path, ensuring the directory exists.
* Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */
export function createOutputFilePath(cwd: string, agentId: string, sessionId: string): string {
const encoded = encodeCwd(cwd);
const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
mkdirSync(root, { recursive: true, mode: 0o700 });
// chmod is a no-op on Windows and throws on some Windows filesystems.
// On Unix we still want to enforce 0o700 past umask, so only swallow on Windows.
try {
chmodSync(root, 0o700);
} catch (err) {
if (process.platform !== "win32") throw err;
}
const dir = join(root, encoded, sessionId, "tasks");
mkdirSync(dir, { recursive: true });
return join(dir, `${agentId}.output`);
}
/** Write the initial user prompt entry. */
export function writeInitialEntry(path: string, agentId: string, prompt: string, cwd: string): void {
const entry = {
isSidechain: true,
agentId,
type: "user",
message: { role: "user", content: prompt },
timestamp: new Date().toISOString(),
cwd,
};
writeFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
}
/**
* Subscribe to session events and flush new messages to the output file on each turn_end.
* Returns a cleanup function that does a final flush and unsubscribes.
*/
export function streamToOutputFile(
session: AgentSession,
path: string,
agentId: string,
cwd: string,
): () => void {
let writtenCount = 1; // initial user prompt already written
const flush = () => {
const messages = session.messages;
while (writtenCount < messages.length) {
const msg = messages[writtenCount];
const entry = {
isSidechain: true,
agentId,
type: msg.role === "assistant" ? "assistant" : msg.role === "user" ? "user" : "toolResult",
message: msg,
timestamp: new Date().toISOString(),
cwd,
};
try {
appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
} catch { /* ignore write errors */ }
writtenCount++;
}
};
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "turn_end") flush();
});
return () => {
flush();
unsubscribe();
};
}

View File

@@ -0,0 +1,85 @@
/**
* prompts.ts — System prompt builder for agents.
*/
import type { AgentConfig, EnvInfo } from "./types.js";
/** Extra sections to inject into the system prompt (memory, skills, etc.). */
export interface PromptExtras {
/** Persistent memory content to inject (first 200 lines of MEMORY.md + instructions). */
memoryBlock?: string;
/** Preloaded skill contents to inject. */
skillBlocks?: { name: string; content: string }[];
}
/**
* Build the system prompt for an agent from its config.
*
* - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
* - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
* - "append" with empty systemPrompt: pure parent clone
*
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
* @param extras Optional extra sections to inject (memory, preloaded skills).
*/
export function buildAgentPrompt(
config: AgentConfig,
cwd: string,
env: EnvInfo,
parentSystemPrompt?: string,
extras?: PromptExtras,
): string {
const envBlock = `# Environment
Working directory: ${cwd}
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
Platform: ${env.platform}`;
// Build optional extras suffix
const extraSections: string[] = [];
if (extras?.memoryBlock) {
extraSections.push(extras.memoryBlock);
}
if (extras?.skillBlocks?.length) {
for (const skill of extras.skillBlocks) {
extraSections.push(`\n# Preloaded Skill: ${skill.name}\n${skill.content}`);
}
}
const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
if (config.promptMode === "append") {
const identity = parentSystemPrompt || genericBase;
const bridge = `<sub_agent_context>
You are operating as a sub-agent invoked to handle a specific task.
- Use the read tool instead of cat/head/tail
- Use the edit tool instead of sed/awk
- Use the write tool instead of echo/heredoc
- Use the find tool instead of bash find/ls for file search
- Use the grep tool instead of bash grep/rg for content search
- Make independent tool calls in parallel
- Use absolute file paths
- Do not use emojis
- Be concise but complete
</sub_agent_context>`;
const customSection = config.systemPrompt?.trim()
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
: "";
return envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection + extrasSuffix;
}
// "replace" mode — env header + the config's full system prompt
const replaceHeader = `You are a pi coding agent sub-agent.
You have been invoked to handle a specific task autonomously.
${envBlock}`;
return replaceHeader + "\n\n" + config.systemPrompt + extrasSuffix;
}
/** Fallback base prompt when parent system prompt is unavailable in append mode. */
const genericBase = `# Role
You are a general-purpose coding agent for complex, multi-step tasks.
You have full access to read, write, edit files, and execute commands.
Do what has been asked; nothing more, nothing less.`;

View File

@@ -0,0 +1,143 @@
/**
* schedule-store.ts — File-backed store for scheduled subagents.
*
* Session-scoped: each pi session owns its own schedules at
* `<cwd>/.pi/subagent-schedules/<sessionId>.json`. `/new` starts a fresh
* empty store; `/resume` reloads.
*
* Concurrency model lifted from pi-chonky-tasks/src/task-store.ts: every
* mutation acquires a PID-based exclusion lock, re-reads the latest state
* from disk, applies the change, atomic-writes via temp+rename, releases.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { ScheduledSubagent, ScheduleStoreData } from "./types.js";
const LOCK_RETRY_MS = 50;
const LOCK_MAX_RETRIES = 100;
function isProcessRunning(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
function acquireLock(lockPath: string): void {
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
try {
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
return;
} catch (e: any) {
if (e.code === "EEXIST") {
try {
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
if (pid && !isProcessRunning(pid)) {
unlinkSync(lockPath);
continue;
}
} catch { /* ignore — try again */ }
const start = Date.now();
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
continue;
}
throw e;
}
}
throw new Error(`Failed to acquire schedule lock: ${lockPath}`);
}
function releaseLock(lockPath: string): void {
try { unlinkSync(lockPath); } catch { /* ignore */ }
}
/** Resolve the storage path for a session-scoped store. */
export function resolveStorePath(cwd: string, sessionId: string): string {
return join(cwd, ".pi", "subagent-schedules", `${sessionId}.json`);
}
export class ScheduleStore {
private filePath: string;
private lockPath: string;
private jobs = new Map<string, ScheduledSubagent>();
constructor(filePath: string) {
this.filePath = filePath;
this.lockPath = filePath + ".lock";
mkdirSync(dirname(filePath), { recursive: true });
this.load();
}
/** Load from disk into the in-memory cache. Silent on parse errors. */
private load(): void {
if (!existsSync(this.filePath)) return;
try {
const data: ScheduleStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
this.jobs.clear();
for (const j of data.jobs ?? []) this.jobs.set(j.id, j);
} catch { /* corrupt — start fresh, next save rewrites */ }
}
/** Atomic write via temp file + rename (POSIX-atomic). */
private save(): void {
const data: ScheduleStoreData = { version: 1, jobs: [...this.jobs.values()] };
const tmp = this.filePath + ".tmp";
writeFileSync(tmp, JSON.stringify(data, null, 2));
renameSync(tmp, this.filePath);
}
/** Acquire lock → reload → mutate → save → release. */
private withLock<T>(fn: () => T): T {
acquireLock(this.lockPath);
try {
this.load();
const result = fn();
this.save();
return result;
} finally {
releaseLock(this.lockPath);
}
}
/** Read-only — returns a snapshot of the in-memory cache. */
list(): ScheduledSubagent[] {
return [...this.jobs.values()];
}
/** Read-only check — uses the cache. */
hasName(name: string, exceptId?: string): boolean {
for (const j of this.jobs.values()) {
if (j.id !== exceptId && j.name === name) return true;
}
return false;
}
get(id: string): ScheduledSubagent | undefined {
return this.jobs.get(id);
}
add(job: ScheduledSubagent): void {
this.withLock(() => {
this.jobs.set(job.id, job);
});
}
update(id: string, patch: Partial<ScheduledSubagent>): ScheduledSubagent | undefined {
return this.withLock(() => {
const existing = this.jobs.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...patch };
this.jobs.set(id, updated);
return updated;
});
}
remove(id: string): boolean {
return this.withLock(() => this.jobs.delete(id));
}
/** Delete the backing file (used when no jobs remain, optional cleanup). */
deleteFileIfEmpty(): void {
if (this.jobs.size === 0 && existsSync(this.filePath)) {
try { unlinkSync(this.filePath); } catch { /* ignore */ }
}
}
}

View File

@@ -0,0 +1,365 @@
/**
* schedule.ts — `SubagentScheduler`: timer-driven dispatcher of scheduled subagents.
*
* Mirrors the engine shape of pi-cron-schedule/src/scheduler.ts:
* - two-Map split (jobs = croner Cron, intervals = setInterval/setTimeout)
* - addJob/removeJob/updateJob/scheduleJob/unscheduleJob/executeJob
* - static parsers for cron / "+10m" / "5m" / ISO formats
*
* Differences vs pi-cron-schedule:
* - Persistence is via ScheduleStore (PID-locked, session-scoped, atomic).
* - `executeJob` calls `manager.spawn(..., { bypassQueue: true })` instead
* of dispatching a user message — schedule fires bypass maxConcurrent so
* a 5-minute interval can't be deferred behind 4 long-running agents.
* - Result delivery is implicit: spawn → background completion → existing
* `subagent-notification` followUp path. No new delivery code.
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Cron } from "croner";
import { nanoid } from "nanoid";
import type { AgentManager } from "./agent-manager.js";
import { resolveModel } from "./model-resolver.js";
import type { ScheduleStore } from "./schedule-store.js";
import type { IsolationMode, ScheduledSubagent, SubagentType, ThinkingLevel } from "./types.js";
/** Event emitted on `pi.events` for cross-extension consumers. */
export type ScheduleChangeEvent =
| { type: "added"; job: ScheduledSubagent }
| { type: "removed"; jobId: string }
| { type: "updated"; job: ScheduledSubagent }
| { type: "fired"; jobId: string; agentId: string; name: string }
| { type: "error"; jobId: string; error: string };
/** Params accepted at job creation — ID, timestamps, and state are derived. */
export interface NewJobInput {
name: string;
description: string;
schedule: string;
subagent_type: SubagentType;
prompt: string;
model?: string;
thinking?: ThinkingLevel;
max_turns?: number;
isolated?: boolean;
isolation?: IsolationMode;
}
export class SubagentScheduler {
private jobs = new Map<string, Cron>();
private intervals = new Map<string, NodeJS.Timeout>();
private store: ScheduleStore | undefined;
private pi: ExtensionAPI | undefined;
private ctx: ExtensionContext | undefined;
private manager: AgentManager | undefined;
/** Start the scheduler: bind to a session's store and arm enabled jobs. */
start(pi: ExtensionAPI, ctx: ExtensionContext, manager: AgentManager, store: ScheduleStore): void {
this.pi = pi;
this.ctx = ctx;
this.manager = manager;
this.store = store;
for (const job of store.list()) {
if (job.enabled) this.scheduleJob(job);
}
}
/** Stop all timers; drop refs. Safe to call repeatedly. */
stop(): void {
for (const cron of this.jobs.values()) cron.stop();
this.jobs.clear();
for (const t of this.intervals.values()) clearTimeout(t);
this.intervals.clear();
this.store = undefined;
this.pi = undefined;
this.ctx = undefined;
this.manager = undefined;
}
/** True if start() has bound a store and the scheduler is active. */
isActive(): boolean {
return this.store !== undefined;
}
list(): ScheduledSubagent[] {
return this.store?.list() ?? [];
}
/**
* Build a `ScheduledSubagent` from user input. Validates the schedule
* format and tags `scheduleType`. Throws on invalid input.
*/
buildJob(input: NewJobInput): ScheduledSubagent {
const detected = SubagentScheduler.detectSchedule(input.schedule);
return {
id: nanoid(10),
name: input.name,
description: input.description,
schedule: detected.normalized,
scheduleType: detected.type,
intervalMs: detected.intervalMs,
subagent_type: input.subagent_type,
prompt: input.prompt,
model: input.model,
thinking: input.thinking,
max_turns: input.max_turns,
isolated: input.isolated,
isolation: input.isolation,
enabled: true,
createdAt: new Date().toISOString(),
runCount: 0,
};
}
/** Add a job, persist, and arm if enabled. Returns the stored job. */
addJob(input: NewJobInput): ScheduledSubagent {
const store = this.requireStore();
if (store.hasName(input.name)) {
throw new Error(`A scheduled job named "${input.name}" already exists.`);
}
const job = this.buildJob(input);
store.add(job);
if (job.enabled) this.scheduleJob(job);
this.emit({ type: "added", job });
return job;
}
removeJob(id: string): boolean {
const store = this.requireStore();
if (!store.get(id)) return false;
this.unscheduleJob(id);
const ok = store.remove(id);
if (ok) this.emit({ type: "removed", jobId: id });
return ok;
}
/** Toggle / mutate a job. Re-arms based on the new `enabled` state. */
updateJob(id: string, patch: Partial<ScheduledSubagent>): ScheduledSubagent | undefined {
const store = this.requireStore();
const updated = store.update(id, patch);
if (!updated) return undefined;
this.unscheduleJob(id);
if (updated.enabled) this.scheduleJob(updated);
this.emit({ type: "updated", job: updated });
return updated;
}
/** Next-run time as ISO, or undefined if not currently armed. */
getNextRun(jobId: string): string | undefined {
const cron = this.jobs.get(jobId);
if (cron) return cron.nextRun()?.toISOString();
const job = this.store?.get(jobId);
if (!job?.enabled) return undefined;
if (job.scheduleType === "once") return job.schedule;
if (job.scheduleType === "interval" && job.intervalMs) {
// Before the first fire there's no `lastRun`, so fall back to "now" —
// accurate at create time (setInterval was just armed) and within
// intervalMs of correct in any pre-first-fire view.
const base = job.lastRun ? new Date(job.lastRun).getTime() : Date.now();
return new Date(base + job.intervalMs).toISOString();
}
return undefined;
}
// ── Scheduling primitives ────────────────────────────────────────────
private scheduleJob(job: ScheduledSubagent): void {
const store = this.store;
if (!store) return;
try {
if (job.scheduleType === "interval" && job.intervalMs) {
const t = setInterval(() => this.executeJob(job.id), job.intervalMs);
this.intervals.set(job.id, t);
} else if (job.scheduleType === "once") {
const target = new Date(job.schedule).getTime();
const delay = target - Date.now();
if (delay > 0) {
const t = setTimeout(() => {
this.executeJob(job.id);
// Auto-disable one-shots after they fire (mirrors pi-cron-schedule)
store.update(job.id, { enabled: false });
const updated = store.get(job.id);
if (updated) this.emit({ type: "updated", job: updated });
}, delay);
this.intervals.set(job.id, t);
} else {
// Past timestamp — disable, mark error, never fire
store.update(job.id, { enabled: false, lastStatus: "error" });
this.emit({ type: "error", jobId: job.id, error: `Scheduled time ${job.schedule} is in the past` });
}
} else {
const cron = new Cron(job.schedule, () => this.executeJob(job.id));
this.jobs.set(job.id, cron);
}
} catch (err) {
this.emit({ type: "error", jobId: job.id, error: err instanceof Error ? err.message : String(err) });
}
}
private unscheduleJob(id: string): void {
const cron = this.jobs.get(id);
if (cron) {
cron.stop();
this.jobs.delete(id);
}
const t = this.intervals.get(id);
if (t) {
clearTimeout(t);
clearInterval(t);
this.intervals.delete(id);
}
}
/**
* Fire a job: persist running state, spawn (bypassing the concurrency
* queue), persist completion. Fire-and-forget: the timer tick returns
* immediately so other jobs keep firing.
*/
private executeJob(id: string): void {
const store = this.store;
const pi = this.pi;
const ctx = this.ctx;
const manager = this.manager;
if (!store || !pi || !ctx || !manager) return;
const job = store.get(id);
if (!job?.enabled) return;
store.update(id, { lastStatus: "running" });
// Resolve model at fire time — registry contents may have changed since the
// job was created (auth added/removed). Fall back silently to spawn-default
// if resolution fails; the spawn path handles undefined model gracefully.
let resolvedModel: any | undefined;
if (job.model) {
const r = resolveModel(job.model, ctx.modelRegistry);
if (typeof r !== "string") resolvedModel = r;
}
let agentId: string;
try {
agentId = manager.spawn(pi, ctx, job.subagent_type, job.prompt, {
description: job.description,
isBackground: true,
bypassQueue: true,
model: resolvedModel,
maxTurns: job.max_turns,
isolated: job.isolated,
thinkingLevel: job.thinking,
isolation: job.isolation,
});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
store.update(id, { lastRun: new Date().toISOString(), lastStatus: "error" });
this.emit({ type: "error", jobId: id, error });
return;
}
this.emit({ type: "fired", jobId: id, agentId, name: job.name });
const record = manager.getRecord(agentId);
const finalize = (status: "success" | "error") => {
const next = this.getNextRun(id);
const current = store.get(id);
store.update(id, {
lastRun: new Date().toISOString(),
lastStatus: status,
runCount: (current?.runCount ?? 0) + 1,
nextRun: next,
});
};
// AgentManager's promise resolves either way (its .catch returns ""), so we
// can't infer success/failure from the promise — read record.status instead.
// Terminal states: completed/steered = success; error/aborted/stopped = error.
if (record?.promise) {
record.promise
.then(() => {
const r = manager.getRecord(agentId);
const failed = r?.status === "error" || r?.status === "aborted" || r?.status === "stopped";
finalize(failed ? "error" : "success");
})
.catch(() => finalize("error"));
} else {
// Spawn returned without a promise (defensive — bypassQueue path always sets one).
finalize("success");
}
}
private emit(event: ScheduleChangeEvent): void {
if (this.pi) this.pi.events.emit("subagents:scheduled", event);
}
private requireStore(): ScheduleStore {
if (!this.store) throw new Error("Scheduler not started — no active session.");
return this.store;
}
// ── Format detection / parsers (statics — pure) ──────────────────────
/**
* Sniff a schedule string and tag its type. Throws on invalid input.
* Order matters: relative ("+10m") and interval ("5m") both match digit+unit;
* relative requires the leading "+" to disambiguate.
*/
static detectSchedule(s: string): { type: "cron" | "once" | "interval"; intervalMs?: number; normalized: string } {
const trimmed = s.trim();
// "+10m" — relative one-shot
const rel = SubagentScheduler.parseRelativeTime(trimmed);
if (rel !== null) return { type: "once", normalized: rel };
// "5m" — interval
const ivl = SubagentScheduler.parseInterval(trimmed);
if (ivl !== null) return { type: "interval", intervalMs: ivl, normalized: trimmed };
// ISO timestamp — one-shot. Reject past timestamps upfront so we never
// create a dead-on-arrival record (scheduleJob's safety net still catches
// micro-races from `+0s`-style relatives).
if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
const d = new Date(trimmed);
if (!Number.isNaN(d.getTime())) {
if (d.getTime() <= Date.now()) {
throw new Error(`Scheduled time ${d.toISOString()} is in the past.`);
}
return { type: "once", normalized: d.toISOString() };
}
}
// Cron — 6-field
const cronCheck = SubagentScheduler.validateCronExpression(trimmed);
if (cronCheck.valid) return { type: "cron", normalized: trimmed };
throw new Error(
`Invalid schedule "${s}". Use 6-field cron (e.g. "0 0 9 * * 1" — 9am every Monday), interval ("5m"/"1h"), or one-shot ("+10m" / ISO).`
);
}
/** 6-field cron — 'second minute hour dom month dow'. */
static validateCronExpression(expr: string): { valid: boolean; error?: string } {
const fields = expr.trim().split(/\s+/);
if (fields.length !== 6) {
return {
valid: false,
error: `Cron must have 6 fields (second minute hour dom month dow), got ${fields.length}. Example: "0 0 9 * * 1" for 9am every Monday.`,
};
}
try {
// Croner validates by construction.
new Cron(expr, () => {});
return { valid: true };
} catch (e) {
return { valid: false, error: e instanceof Error ? e.message : "Invalid cron expression" };
}
}
/** "+10s"/"+5m"/"+1h"/"+2d" → ISO timestamp. */
static parseRelativeTime(s: string): string | null {
const m = s.match(/^\+(\d+)(s|m|h|d)$/);
if (!m) return null;
const ms = parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"];
return new Date(Date.now() + ms).toISOString();
}
/** "10s"/"5m"/"1h"/"2d" → milliseconds. */
static parseInterval(s: string): number | null {
const m = s.match(/^(\d+)(s|m|h|d)$/);
if (!m) return null;
return parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"];
}
}

View File

@@ -0,0 +1,186 @@
// Persistence for pi-subagents operational settings.
// - Global: ~/.pi/agent/subagents.json (via getAgentDir()) — manual defaults, never written here
// - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getAgentDir } from "@mariozechner/pi-coding-agent";
import type { JoinMode } from "./types.js";
export interface SubagentsSettings {
maxConcurrent?: number;
/**
* 0 = unlimited — the extension's single source of truth for that convention:
* `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
* `/agents` → Settings input prompt explicitly says "0 = unlimited".
*/
defaultMaxTurns?: number;
graceTurns?: number;
defaultJoinMode?: JoinMode;
/**
* Master switch for the schedule subagent feature. Defaults to `true`.
* When `false`: the `Agent` tool's `schedule` param + its guideline are
* stripped from the tool spec at registration (zero LLM-context cost), the
* scheduler doesn't bind to the session, and the `/agents → Scheduled jobs`
* menu entry is hidden. Schema-level removal applies at extension load
* (next pi session); runtime menu/runtime-fire short-circuit is immediate.
*/
schedulingEnabled?: boolean;
}
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
export interface SettingsAppliers {
setMaxConcurrent: (n: number) => void;
setDefaultMaxTurns: (n: number) => void;
setGraceTurns: (n: number) => void;
setDefaultJoinMode: (mode: JoinMode) => void;
setSchedulingEnabled: (b: boolean) => void;
}
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
export type SettingsEmit = (event: string, payload: unknown) => void;
const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
// Sanity ceilings — prevent hand-edited configs from asking for values that
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
// that any realistic power-user setting passes through.
const MAX_CONCURRENT_CEILING = 1024;
const MAX_TURNS_CEILING = 10_000;
const GRACE_TURNS_CEILING = 1_000;
/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
function sanitize(raw: unknown): SubagentsSettings {
if (!raw || typeof raw !== "object") return {};
const r = raw as Record<string, unknown>;
const out: SubagentsSettings = {};
if (
Number.isInteger(r.maxConcurrent) &&
(r.maxConcurrent as number) >= 1 &&
(r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING
) {
out.maxConcurrent = r.maxConcurrent as number;
}
if (
Number.isInteger(r.defaultMaxTurns) &&
(r.defaultMaxTurns as number) >= 0 &&
(r.defaultMaxTurns as number) <= MAX_TURNS_CEILING
) {
out.defaultMaxTurns = r.defaultMaxTurns as number;
}
if (
Number.isInteger(r.graceTurns) &&
(r.graceTurns as number) >= 1 &&
(r.graceTurns as number) <= GRACE_TURNS_CEILING
) {
out.graceTurns = r.graceTurns as number;
}
if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
out.defaultJoinMode = r.defaultJoinMode as JoinMode;
}
if (typeof r.schedulingEnabled === "boolean") {
out.schedulingEnabled = r.schedulingEnabled;
}
return out;
}
function globalPath(): string {
return join(getAgentDir(), "subagents.json");
}
function projectPath(cwd: string): string {
return join(cwd, ".pi", "subagents.json");
}
/**
* Read a settings file. Missing file is silent (returns `{}`). A file that
* exists but can't be parsed emits a warning to stderr so users aren't
* silently reverted to defaults — and still returns `{}` so startup proceeds.
*/
function readSettingsFile(path: string): SubagentsSettings {
if (!existsSync(path)) return {};
try {
return sanitize(JSON.parse(readFileSync(path, "utf-8")));
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
return {};
}
}
/** Load merged settings: global provides defaults, project overrides. */
export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
return { ...readSettingsFile(globalPath()), ...readSettingsFile(projectPath(cwd)) };
}
/**
* Write project-local settings. Global is never touched from code.
* Returns `true` on success, `false` if the write (or mkdir) failed so the
* caller can surface a warning — persistence isn't fatal but isn't silent.
*/
export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean {
const path = projectPath(cwd);
try {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(s, null, 2), "utf-8");
return true;
} catch {
return false;
}
}
/** Apply persisted settings to the in-memory state via caller-supplied setters. */
export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void {
if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
}
/**
* Format the user-facing toast for a settings mutation. Pure function —
* routes the success/failure of `saveSettings` into the right message + level
* so the UI layer (index.ts) stays a thin wire between input and notification.
*/
export function persistToastFor(
successMsg: string,
persisted: boolean,
): { message: string; level: "info" | "warning" } {
return persisted
? { message: successMsg, level: "info" }
: { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
}
/**
* Load merged settings, apply them to in-memory state, and emit the
* `subagents:settings_loaded` lifecycle event. Returns the loaded settings so
* callers can log/inspect. Extension init wires this once.
*/
export function applyAndEmitLoaded(
appliers: SettingsAppliers,
emit: SettingsEmit,
cwd: string = process.cwd(),
): SubagentsSettings {
const settings = loadSettings(cwd);
applySettings(settings, appliers);
emit("subagents:settings_loaded", { settings });
return settings;
}
/**
* Persist a settings snapshot, emit the `subagents:settings_changed` event
* (regardless of persist outcome so listeners see the in-memory change), and
* return the toast the UI should display. Event payload carries the `persisted`
* flag so listeners can react to write failures.
*/
export function saveAndEmitChanged(
snapshot: SubagentsSettings,
successMsg: string,
emit: SettingsEmit,
cwd: string = process.cwd(),
): { message: string; level: "info" | "warning" } {
const persisted = saveSettings(snapshot, cwd);
emit("subagents:settings_changed", { settings: snapshot, persisted });
return persistToastFor(successMsg, persisted);
}

View File

@@ -0,0 +1,79 @@
/**
* skill-loader.ts — Preload specific skill files and inject their content into the system prompt.
*
* When skills is a string[], reads each named skill from .pi/skills/ or ~/.pi/skills/
* and returns their content for injection into the agent's system prompt.
*/
import { homedir } from "node:os";
import { join } from "node:path";
import { isUnsafeName, safeReadFile } from "./memory.js";
export interface PreloadedSkill {
name: string;
content: string;
}
/**
* Attempt to load named skills from project and global skill directories.
* Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
*
* @param skillNames List of skill names to preload.
* @param cwd Working directory for project-level skills.
* @returns Array of loaded skills (missing skills are skipped with a warning comment).
*/
export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
const results: PreloadedSkill[] = [];
for (const name of skillNames) {
// Unlike memory (which throws on unsafe names because it's part of agent setup),
// skills are optional — skip gracefully to avoid blocking agent startup.
if (isUnsafeName(name)) {
results.push({ name, content: `(Skill "${name}" skipped: name contains path traversal characters)` });
continue;
}
const content = findAndReadSkill(name, cwd);
if (content !== undefined) {
results.push({ name, content });
} else {
// Include a note about missing skills so the agent knows it was requested but not found
results.push({ name, content: `(Skill "${name}" not found in .pi/skills/ or ~/.pi/skills/)` });
}
}
return results;
}
/**
* Search for a skill file in project and global directories.
* Project-level takes priority over global.
*/
function findAndReadSkill(name: string, cwd: string): string | undefined {
const projectDir = join(cwd, ".pi", "skills");
const globalDir = join(homedir(), ".pi", "skills");
// Try project first, then global
for (const dir of [projectDir, globalDir]) {
const content = tryReadSkillFile(dir, name);
if (content !== undefined) return content;
}
return undefined;
}
/**
* Try to read a skill file from a directory.
* Tries extensions in order: .md, .txt, (no extension)
*/
function tryReadSkillFile(dir: string, name: string): string | undefined {
const extensions = [".md", ".txt", ""];
for (const ext of extensions) {
const path = join(dir, name + ext);
// safeReadFile rejects symlinks to prevent reading arbitrary files
const content = safeReadFile(path);
if (content !== undefined) return content.trim();
}
return undefined;
}

View File

@@ -0,0 +1,163 @@
/**
* types.ts — Type definitions for the subagent system.
*/
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { LifetimeUsage } from "./usage.js";
export type { ThinkingLevel };
/** Agent type: any string name (built-in defaults or user-defined). */
export type SubagentType = string;
/** Names of the three embedded default agents. */
export const DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as const;
/** Memory scope for persistent agent memory. */
export type MemoryScope = "user" | "project" | "local";
/** Isolation mode for agent execution. */
export type IsolationMode = "worktree";
/** Unified agent configuration — used for both default and user-defined agents. */
export interface AgentConfig {
name: string;
displayName?: string;
description: string;
builtinToolNames?: string[];
/** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
disallowedTools?: string[];
/** true = inherit all, string[] = only listed, false = none */
extensions: true | string[] | false;
/** true = inherit all, string[] = only listed, false = none */
skills: true | string[] | false;
model?: string;
thinking?: ThinkingLevel;
maxTurns?: number;
systemPrompt: string;
promptMode: "replace" | "append";
/** Default for spawn: fork parent conversation. undefined = caller decides. */
inheritContext?: boolean;
/** Default for spawn: run in background. undefined = caller decides. */
runInBackground?: boolean;
/** Default for spawn: no extension tools. undefined = caller decides. */
isolated?: boolean;
/** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
memory?: MemoryScope;
/** Isolation mode — "worktree" runs the agent in a temporary git worktree */
isolation?: IsolationMode;
/** true = this is an embedded default agent (informational) */
isDefault?: boolean;
/** false = agent is hidden from the registry */
enabled?: boolean;
/** Where this agent was loaded from */
source?: "default" | "project" | "global";
}
export type JoinMode = 'async' | 'group' | 'smart';
export interface AgentRecord {
id: string;
type: SubagentType;
description: string;
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
result?: string;
error?: string;
toolUses: number;
startedAt: number;
completedAt?: number;
session?: AgentSession;
abortController?: AbortController;
promise?: Promise<string>;
groupId?: string;
joinMode?: JoinMode;
/** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
resultConsumed?: boolean;
/** Steering messages queued before the session was ready. */
pendingSteers?: string[];
/** Worktree info if the agent is running in an isolated worktree. */
worktree?: { path: string; branch: string };
/** Worktree cleanup result after agent completion. */
worktreeResult?: { hasChanges: boolean; branch?: string };
/** The tool_use_id from the original Agent tool call. */
toolCallId?: string;
/** Path to the streaming output transcript file. */
outputFile?: string;
/** Cleanup function for the output file stream subscription. */
outputCleanup?: () => void;
/**
* Lifetime usage breakdown, accumulated via `message_end` events. Survives
* compaction. Total = input + output + cacheWrite (cacheRead deliberately
* excluded — see issue #38). Initialized to zeros at spawn.
*/
lifetimeUsage: LifetimeUsage;
/** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
compactionCount: number;
}
/** Details attached to custom notification messages for visual rendering. */
export interface NotificationDetails {
id: string;
description: string;
status: string;
toolUses: number;
turnCount: number;
maxTurns?: number;
totalTokens: number;
durationMs: number;
outputFile?: string;
error?: string;
resultPreview: string;
/** Additional agents in a group notification. */
others?: NotificationDetails[];
}
export interface EnvInfo {
isGitRepo: boolean;
branch: string;
platform: string;
}
/**
* A subagent spawn registered to fire on a schedule.
*
* Stored at `<cwd>/.pi/subagent-schedules/<sessionId>.json`. Session-scoped:
* survives `/resume` but resets on `/new`, mirroring pi-chonky-tasks.
*/
export interface ScheduledSubagent {
id: string;
/** Unique within store. Defaults to `description`. */
name: string;
description: string;
/** Raw user input — cron expr | "+10m" | ISO | "5m". */
schedule: string;
scheduleType: "cron" | "once" | "interval";
/** Computed at create time for interval/once. */
intervalMs?: number;
// spawn params (subset of Agent tool params; no inherit_context, no resume)
subagent_type: SubagentType;
prompt: string;
model?: string;
thinking?: ThinkingLevel;
max_turns?: number;
isolated?: boolean;
isolation?: IsolationMode;
// state
enabled: boolean;
/** ISO timestamp. */
createdAt: string;
lastRun?: string;
lastStatus?: "success" | "error" | "running";
/** Refreshed on every fire and on store load. */
nextRun?: string;
runCount: number;
}
export interface ScheduleStoreData {
/** For future migrations. */
version: 1;
jobs: ScheduledSubagent[];
}

View File

@@ -0,0 +1,518 @@
/**
* agent-widget.ts — Persistent widget showing running/completed agents above the editor.
*
* Displays a tree of agents with animated spinners, live stats, and activity descriptions.
* Uses the callback form of setWidget for themed rendering.
*/
import { truncateToWidth } from "@mariozechner/pi-tui";
import type { AgentManager } from "../agent-manager.js";
import { getConfig } from "../agent-types.js";
import type { SubagentType } from "../types.js";
import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
// ---- Constants ----
/** Maximum number of rendered lines before overflow collapse kicks in. */
const MAX_WIDGET_LINES = 12;
/** Braille spinner frames for animated running indicator. */
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
/** Tool name → human-readable action for activity descriptions. */
const TOOL_DISPLAY: Record<string, string> = {
read: "reading",
bash: "running command",
edit: "editing",
write: "writing",
grep: "searching",
find: "finding files",
ls: "listing",
};
// ---- Types ----
export type Theme = {
fg(color: string, text: string): string;
bold(text: string): string;
};
export type UICtx = {
setStatus(key: string, text: string | undefined): void;
setWidget(
key: string,
content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
options?: { placement?: "aboveEditor" | "belowEditor" },
): void;
};
/** Per-agent live activity state. */
export interface AgentActivity {
activeTools: Map<string, string>;
toolUses: number;
responseText: string;
session?: SessionLike;
/** Current turn count. */
turnCount: number;
/** Effective max turns for this agent (undefined = unlimited). */
maxTurns?: number;
/** Lifetime usage breakdown — see LifetimeUsage docs. */
lifetimeUsage: LifetimeUsage;
}
/** Metadata attached to Agent tool results for custom rendering. */
export interface AgentDetails {
displayName: string;
description: string;
subagentType: string;
toolUses: number;
tokens: string;
durationMs: number;
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
/** Human-readable description of what the agent is currently doing. */
activity?: string;
/** Current spinner frame index (for animated running indicator). */
spinnerFrame?: number;
/** Short model name if different from parent (e.g. "haiku", "sonnet"). */
modelName?: string;
/** Notable config tags (e.g. ["thinking: high", "isolated"]). */
tags?: string[];
/** Current turn count. */
turnCount?: number;
/** Effective max turns (undefined = unlimited). */
maxTurns?: number;
agentId?: string;
error?: string;
}
// ---- Formatting helpers ----
/** Format a token count compactly: "33.8k token", "1.2M token". */
export function formatTokens(count: number): string {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
return `${count} token`;
}
/**
* Token count with optional context-fill % and compaction-count annotations.
* Thresholds for percent: <70% dim, 7085% warning, ≥85% error.
* Compaction count rendered as `↻N` in dim.
*
* "12.3k token" — no annotations
* "12.3k token (45%)" — percent only
* "12.3k token (↻2)" — compactions only (e.g. right after compact)
* "12.3k token (45% · ↻2)" — both
*/
export function formatSessionTokens(
tokens: number,
percent: number | null,
theme: Theme,
compactions = 0,
): string {
const tokenStr = formatTokens(tokens);
const annot: string[] = [];
if (percent !== null) {
const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
annot.push(theme.fg(color, `${Math.round(percent)}%`));
}
if (compactions > 0) {
annot.push(theme.fg("dim", `${compactions}`));
}
if (annot.length === 0) return tokenStr;
return `${tokenStr} (${annot.join(" · ")})`;
}
/** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
return maxTurns != null ? `${turnCount}${maxTurns}` : `${turnCount}`;
}
/** Format milliseconds as human-readable duration. */
export function formatMs(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
/** Format duration from start/completed timestamps. */
export function formatDuration(startedAt: number, completedAt?: number): string {
if (completedAt) return formatMs(completedAt - startedAt);
return `${formatMs(Date.now() - startedAt)} (running)`;
}
/** Get display name for any agent type (built-in or custom). */
export function getDisplayName(type: SubagentType): string {
return getConfig(type).displayName;
}
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
export function getPromptModeLabel(type: SubagentType): string | undefined {
const config = getConfig(type);
return config.promptMode === "append" ? "twin" : undefined;
}
/** Truncate text to a single line, max `len` chars. */
function truncateLine(text: string, len = 60): string {
const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
if (line.length <= len) return line;
return line.slice(0, len) + "…";
}
/** Build a human-readable activity string from currently-running tools or response text. */
export function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
if (activeTools.size > 0) {
const groups = new Map<string, number>();
for (const toolName of activeTools.values()) {
const action = TOOL_DISPLAY[toolName] ?? toolName;
groups.set(action, (groups.get(action) ?? 0) + 1);
}
const parts: string[] = [];
for (const [action, count] of groups) {
if (count > 1) {
parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
} else {
parts.push(action);
}
}
return parts.join(", ") + "…";
}
// No tools active — show truncated response text if available
if (responseText && responseText.trim().length > 0) {
return truncateLine(responseText);
}
return "thinking…";
}
// ---- Widget manager ----
export class AgentWidget {
private uiCtx: UICtx | undefined;
private widgetFrame = 0;
private widgetInterval: ReturnType<typeof setInterval> | undefined;
/** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
private finishedTurnAge = new Map<string, number>();
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
private static readonly ERROR_LINGER_TURNS = 2;
/** Whether the widget callback is currently registered with the TUI. */
private widgetRegistered = false;
/** Cached TUI reference from widget factory callback, used for requestRender(). */
private tui: any | undefined;
/** Last status bar text, used to avoid redundant setStatus calls. */
private lastStatusText: string | undefined;
constructor(
private manager: AgentManager,
private agentActivity: Map<string, AgentActivity>,
) {}
/** Set the UI context (grabbed from first tool execution). */
setUICtx(ctx: UICtx) {
if (ctx !== this.uiCtx) {
// UICtx changed — the widget registered on the old context is gone.
// Force re-registration on next update().
this.uiCtx = ctx;
this.widgetRegistered = false;
this.tui = undefined;
this.lastStatusText = undefined;
}
}
/**
* Called on each new turn (tool_execution_start).
* Ages finished agents and clears those that have lingered long enough.
*/
onTurnStart() {
// Age all finished agents
for (const [id, age] of this.finishedTurnAge) {
this.finishedTurnAge.set(id, age + 1);
}
// Trigger a widget refresh (will filter out expired agents)
this.update();
}
/** Ensure the widget update timer is running. */
ensureTimer() {
if (!this.widgetInterval) {
this.widgetInterval = setInterval(() => this.update(), 80);
}
}
/** Check if a finished agent should still be shown in the widget. */
private shouldShowFinished(agentId: string, status: string): boolean {
const age = this.finishedTurnAge.get(agentId) ?? 0;
const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
return age < maxAge;
}
/** Record an agent as finished (call when agent completes). */
markFinished(agentId: string) {
if (!this.finishedTurnAge.has(agentId)) {
this.finishedTurnAge.set(agentId, 0);
}
}
/** Render a finished agent line. */
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
const name = getDisplayName(a.type);
const modeLabel = getPromptModeLabel(a.type);
const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
let icon: string;
let statusText: string;
if (a.status === "completed") {
icon = theme.fg("success", "✓");
statusText = "";
} else if (a.status === "steered") {
icon = theme.fg("warning", "✓");
statusText = theme.fg("warning", " (turn limit)");
} else if (a.status === "stopped") {
icon = theme.fg("dim", "■");
statusText = theme.fg("dim", " stopped");
} else if (a.status === "error") {
icon = theme.fg("error", "✗");
const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
statusText = theme.fg("error", ` error${errMsg}`);
} else {
// aborted
icon = theme.fg("error", "✗");
statusText = theme.fg("warning", " aborted");
}
const parts: string[] = [];
const activity = this.agentActivity.get(a.id);
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
parts.push(duration);
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
}
/**
* Render the widget content. Called from the registered widget's render() callback,
* reading live state each time instead of capturing it in a closure.
*/
private renderWidget(tui: any, theme: Theme): string[] {
const allAgents = this.manager.listAgents();
const running = allAgents.filter(a => a.status === "running");
const queued = allAgents.filter(a => a.status === "queued");
const finished = allAgents.filter(a =>
a.status !== "running" && a.status !== "queued" && a.completedAt
&& this.shouldShowFinished(a.id, a.status),
);
const hasActive = running.length > 0 || queued.length > 0;
const hasFinished = finished.length > 0;
// Nothing to show — return empty (widget will be unregistered by update())
if (!hasActive && !hasFinished) return [];
const w = tui.terminal.columns;
const truncate = (line: string) => truncateToWidth(line, w);
const headingColor = hasActive ? "accent" : "dim";
const headingIcon = hasActive ? "●" : "○";
const frame = SPINNER[this.widgetFrame % SPINNER.length];
// Build sections separately for overflow-aware assembly.
// Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
const finishedLines: string[] = [];
for (const a of finished) {
finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
}
const runningLines: string[][] = []; // each entry is [header, activity]
for (const a of running) {
const name = getDisplayName(a.type);
const modeLabel = getPromptModeLabel(a.type);
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
const elapsed = formatMs(Date.now() - a.startedAt);
const bg = this.agentActivity.get(a.id);
const toolUses = bg?.toolUses ?? a.toolUses;
const tokens = getLifetimeTotal(bg?.lifetimeUsage);
const contextPercent = getSessionContextPercent(bg?.session);
const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
const parts: string[] = [];
if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
if (tokenText) parts.push(tokenText);
parts.push(elapsed);
const statsText = parts.join(" · ");
const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
runningLines.push([
truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
truncate(theme.fg("dim", "│ ") + theme.fg("dim", `${activity}`)),
]);
}
const queuedLine = queued.length > 0
? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
: undefined;
// Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
if (totalBody <= maxBody) {
// Everything fits — add all lines and fix up connectors for the last item.
lines.push(...finishedLines);
for (const pair of runningLines) lines.push(...pair);
if (queuedLine) lines.push(queuedLine);
// Fix last connector: swap ├─ → └─ and │ → space for activity lines.
if (lines.length > 1) {
const last = lines.length - 1;
lines[last] = lines[last].replace("├─", "└─");
// If last item is a running agent activity line, fix indent of that line
// and fix the header line above it.
if (runningLines.length > 0 && !queuedLine) {
// The last two lines are the last running agent's header + activity.
if (last >= 2) {
lines[last - 1] = lines[last - 1].replace("├─", "└─");
lines[last] = lines[last].replace("│ ", " ");
}
}
}
} else {
// Overflow — prioritize: running > queued > finished.
// Reserve 1 line for overflow indicator.
let budget = maxBody - 1;
let hiddenRunning = 0;
let hiddenFinished = 0;
// 1. Running agents (2 lines each)
for (const pair of runningLines) {
if (budget >= 2) {
lines.push(...pair);
budget -= 2;
} else {
hiddenRunning++;
}
}
// 2. Queued line
if (queuedLine && budget >= 1) {
lines.push(queuedLine);
budget--;
}
// 3. Finished agents
for (const fl of finishedLines) {
if (budget >= 1) {
lines.push(fl);
budget--;
} else {
hiddenFinished++;
}
}
// Overflow summary
const overflowParts: string[] = [];
if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
const overflowText = overflowParts.join(", ");
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
);
}
return lines;
}
/** Force an immediate widget update. */
update() {
if (!this.uiCtx) return;
const allAgents = this.manager.listAgents();
// Lightweight existence checks — full categorization happens in renderWidget()
let runningCount = 0;
let queuedCount = 0;
let hasFinished = false;
for (const a of allAgents) {
if (a.status === "running") { runningCount++; }
else if (a.status === "queued") { queuedCount++; }
else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
}
const hasActive = runningCount > 0 || queuedCount > 0;
// Nothing to show — clear widget
if (!hasActive && !hasFinished) {
if (this.widgetRegistered) {
this.uiCtx.setWidget("agents", undefined);
this.widgetRegistered = false;
this.tui = undefined;
}
if (this.lastStatusText !== undefined) {
this.uiCtx.setStatus("subagents", undefined);
this.lastStatusText = undefined;
}
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
// Clean up stale entries
for (const [id] of this.finishedTurnAge) {
if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
}
return;
}
// Status bar — only call setStatus when the text actually changes
let newStatusText: string | undefined;
if (hasActive) {
const statusParts: string[] = [];
if (runningCount > 0) statusParts.push(`${runningCount} running`);
if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
const total = runningCount + queuedCount;
newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
}
if (newStatusText !== this.lastStatusText) {
this.uiCtx.setStatus("subagents", newStatusText);
this.lastStatusText = newStatusText;
}
this.widgetFrame++;
// Register widget callback once; subsequent updates use requestRender()
// which re-invokes render() without replacing the component (avoids layout thrashing).
if (!this.widgetRegistered) {
this.uiCtx.setWidget("agents", (tui, theme) => {
this.tui = tui;
return {
render: () => this.renderWidget(tui, theme),
invalidate: () => {
// Theme changed — force re-registration so factory captures fresh theme.
this.widgetRegistered = false;
this.tui = undefined;
},
};
}, { placement: "aboveEditor" });
this.widgetRegistered = true;
} else {
// Widget already registered — just request a re-render of existing components.
this.tui?.requestRender();
}
}
dispose() {
if (this.widgetInterval) {
clearInterval(this.widgetInterval);
this.widgetInterval = undefined;
}
if (this.uiCtx) {
this.uiCtx.setWidget("agents", undefined);
this.uiCtx.setStatus("subagents", undefined);
}
this.widgetRegistered = false;
this.tui = undefined;
this.lastStatusText = undefined;
}
}

View File

@@ -0,0 +1,243 @@
/**
* conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
*
* Displays a scrollable, live-updating view of an agent's conversation.
* Subscribes to session events for real-time streaming updates.
*/
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
import { extractText } from "../context.js";
import type { AgentRecord } from "../types.js";
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
import type { Theme } from "./agent-widget.js";
import { type AgentActivity, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
/** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
const CHROME_LINES = 6;
const MIN_VIEWPORT = 3;
export class ConversationViewer implements Component {
private scrollOffset = 0;
private autoScroll = true;
private unsubscribe: (() => void) | undefined;
private lastInnerW = 0;
private closed = false;
constructor(
private tui: TUI,
private session: AgentSession,
private record: AgentRecord,
private activity: AgentActivity | undefined,
private theme: Theme,
private done: (result: undefined) => void,
) {
this.unsubscribe = session.subscribe(() => {
if (this.closed) return;
this.tui.requestRender();
});
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
this.closed = true;
this.done(undefined);
return;
}
const totalLines = this.buildContentLines(this.lastInnerW).length;
const viewportHeight = this.viewportHeight();
const maxScroll = Math.max(0, totalLines - viewportHeight);
if (matchesKey(data, "up") || matchesKey(data, "k")) {
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (matchesKey(data, "pageUp")) {
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
this.autoScroll = false;
} else if (matchesKey(data, "pageDown")) {
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (matchesKey(data, "home")) {
this.scrollOffset = 0;
this.autoScroll = false;
} else if (matchesKey(data, "end")) {
this.scrollOffset = maxScroll;
this.autoScroll = true;
}
}
render(width: number): string[] {
if (width < 6) return []; // too narrow for any meaningful rendering
const th = this.theme;
const innerW = width - 4; // border + padding
this.lastInnerW = innerW;
const lines: string[] = [];
const pad = (s: string, len: number) => {
const vis = visibleWidth(s);
return s + " ".repeat(Math.max(0, len - vis));
};
const row = (content: string) =>
th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
const hrTop = th.fg("border", `${"─".repeat(width - 2)}`);
const hrBot = th.fg("border", `${"─".repeat(width - 2)}`);
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
// Header
lines.push(hrTop);
const name = getDisplayName(this.record.type);
const modeLabel = getPromptModeLabel(this.record.type);
const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
const statusIcon = this.record.status === "running"
? th.fg("accent", "●")
: this.record.status === "completed"
? th.fg("success", "✓")
: this.record.status === "error"
? th.fg("error", "✗")
: th.fg("dim", "○");
const duration = formatDuration(this.record.startedAt, this.record.completedAt);
const headerParts: string[] = [duration];
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
if (tokens > 0) {
const percent = getSessionContextPercent(this.activity?.session);
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
}
lines.push(row(
`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
));
lines.push(hrMid);
// Content area — rebuild every render (live data, no cache needed)
const contentLines = this.buildContentLines(innerW);
const viewportHeight = this.viewportHeight();
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
if (this.autoScroll) {
this.scrollOffset = maxScroll;
}
const visibleStart = Math.min(this.scrollOffset, maxScroll);
const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
for (let i = 0; i < viewportHeight; i++) {
lines.push(row(visible[i] ?? ""));
}
// Footer
lines.push(hrMid);
const scrollPct = contentLines.length <= viewportHeight
? "100%"
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
lines.push(hrBot);
return lines;
}
invalidate(): void { /* no cached state to clear */ }
dispose(): void {
this.closed = true;
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = undefined;
}
}
// ---- Private ----
private viewportHeight(): number {
return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
}
private buildContentLines(width: number): string[] {
if (width <= 0) return [];
const th = this.theme;
const messages = this.session.messages;
const lines: string[] = [];
if (messages.length === 0) {
lines.push(th.fg("dim", "(waiting for first message...)"));
return lines;
}
let needsSeparator = false;
for (const msg of messages) {
if (msg.role === "user") {
const text = typeof msg.content === "string"
? msg.content
: extractText(msg.content);
if (!text.trim()) continue;
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.fg("accent", "[User]"));
for (const line of wrapTextWithAnsi(text.trim(), width)) {
lines.push(line);
}
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const toolCalls: string[] = [];
for (const c of msg.content) {
if (c.type === "text" && c.text) textParts.push(c.text);
else if (c.type === "toolCall") {
toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
}
}
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.bold("[Assistant]"));
if (textParts.length > 0) {
for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
lines.push(line);
}
}
for (const name of toolCalls) {
lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
}
} else if (msg.role === "toolResult") {
const text = extractText(msg.content);
const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
if (!truncated.trim()) continue;
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.fg("dim", "[Result]"));
for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
lines.push(th.fg("dim", line));
}
} else if ((msg as any).role === "bashExecution") {
const bash = msg as any;
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
if (bash.output?.trim()) {
const out = bash.output.length > 500
? bash.output.slice(0, 500) + "... (truncated)"
: bash.output;
for (const line of wrapTextWithAnsi(out.trim(), width)) {
lines.push(th.fg("dim", line));
}
}
} else {
continue;
}
needsSeparator = true;
}
// Streaming indicator for running agents
if (this.record.status === "running" && this.activity) {
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
lines.push("");
lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
}
return lines.map(l => truncateToWidth(l, width));
}
}

View File

@@ -0,0 +1,104 @@
/**
* schedule-menu.ts — `/agents → Scheduled jobs` submenu.
*
* Minimal v1 surface: list scheduled jobs, select one to inspect details +
* confirm cancellation. No create wizard (the `Agent` tool's `schedule` param
* is the canonical creation path), no toggle/cleanup (cancel is enough for
* "I scheduled something dumb, get rid of it"). Add management surfaces here
* if real demand emerges.
*/
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
import type { SubagentScheduler } from "../schedule.js";
import type { ScheduledSubagent } from "../types.js";
/** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
function relTime(iso: string | undefined, now = Date.now()): string {
if (!iso) return "—";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return "—";
const diff = t - now;
const abs = Math.abs(diff);
const future = diff > 0;
if (abs < 60_000) return future ? "in <1m" : "<1m ago";
const m = Math.round(abs / 60_000);
if (m < 60) return future ? `in ${m}m` : `${m}m ago`;
const h = Math.round(abs / 3_600_000);
if (h < 24) return future ? `in ${h}h` : `${h}h ago`;
const d = Math.round(abs / 86_400_000);
return future ? `in ${d}d` : `${d}d ago`;
}
/** One-line status icon. */
function statusIcon(j: ScheduledSubagent): string {
if (!j.enabled) return "✗";
if (j.lastStatus === "error") return "!";
if (j.lastStatus === "running") return "⋯";
return "✓";
}
/** Compact selectable row — name, schedule, agent type, next/last run, count. */
function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
const next = scheduler.getNextRun(j.id);
return [
statusIcon(j),
j.name.padEnd(18).slice(0, 18),
j.schedule.padEnd(14).slice(0, 14),
`[${j.subagent_type}]`,
`next ${relTime(next)}`,
`last ${relTime(j.lastRun)}`,
`runs ${j.runCount}`,
].join(" ");
}
/** Multi-line details block for the cancel confirm. */
function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
const next = scheduler.getNextRun(j.id) ?? "—";
return [
`name: ${j.name}`,
`schedule: ${j.schedule} (${j.scheduleType})`,
`agent: ${j.subagent_type}`,
`prompt: ${j.prompt.slice(0, 200)}${j.prompt.length > 200 ? "…" : ""}`,
`created: ${j.createdAt}`,
`last run: ${j.lastRun ?? "—"} (${j.lastStatus ?? "—"})`,
`next run: ${next}`,
`runs: ${j.runCount}`,
].join("\n");
}
/**
* List scheduled jobs; selecting one opens a cancel-confirm with details.
* Returns when the user backs out or after a cancellation.
*/
export async function showSchedulesMenu(
ctx: ExtensionCommandContext,
scheduler: SubagentScheduler,
): Promise<void> {
if (!scheduler.isActive()) {
ctx.ui.notify("Scheduler is not active in this session.", "warning");
return;
}
const jobs = scheduler.list();
if (jobs.length === 0) {
ctx.ui.notify("No scheduled jobs.", "info");
return;
}
const labels = jobs.map(j => formatJob(j, scheduler));
const choice = await ctx.ui.select(
`Scheduled jobs (${jobs.length}) — select to cancel`,
labels,
);
if (!choice) return;
const idx = labels.indexOf(choice);
if (idx < 0) return;
const job = jobs[idx];
const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
if (!ok) return;
scheduler.removeJob(job.id);
ctx.ui.notify(`Cancelled "${job.name}".`, "info");
}

View File

@@ -0,0 +1,60 @@
/** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
/**
* Lifetime usage components, accumulated via `message_end` events. Survives
* compaction (which replaces session.state.messages and would reset any
* stats-derived sum). cacheRead is excluded because each turn's cacheRead is
* the cumulative cached prefix re-read on that one call — summing across
* turns counts the prefix N times. See issue #38.
*/
export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
/** Sum of lifetime usage components, or 0 if undefined. */
export function getLifetimeTotal(u?: LifetimeUsage): number {
return u ? u.input + u.output + u.cacheWrite : 0;
}
/** Add a usage delta into a target accumulator (mutates target). */
export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
into.input += delta.input;
into.output += delta.output;
into.cacheWrite += delta.cacheWrite;
}
/** Minimal shape we read from upstream `getSessionStats()`. */
export type SessionStatsLike = {
tokens: { input: number; output: number; cacheWrite: number };
contextUsage?: { percent: number | null };
};
export type SessionLike = { getSessionStats(): SessionStatsLike };
/**
* Session-scoped token count: input + output + cacheWrite as reported by
* upstream `getSessionStats().tokens` for the *current* session window.
*
* RESETS at compaction — upstream replaces `session.state.messages` and the
* stats are derived from that array. For a lifetime total that survives
* compaction, use `getLifetimeTotal(lifetimeUsage)` instead, which reads
* from an independent accumulator fed by `message_end` events.
*
* Avoids upstream's `tokens.total` field, which sums per-turn `cacheRead`
* and so counts the cumulative cached prefix N times across N turns
* (issue #38).
*/
export function getSessionTokens(session: SessionLike | undefined): number {
if (!session) return 0;
try {
const t = session.getSessionStats().tokens;
return t.input + t.output + t.cacheWrite;
} catch { return 0; }
}
/**
* Context-window utilization (0100), or null when unavailable
* (no model contextWindow, or post-compaction before the next response).
*/
export function getSessionContextPercent(session: SessionLike | undefined): number | null {
if (!session) return null;
try { return session.getSessionStats().contextUsage?.percent ?? null; }
catch { return null; }
}

View File

@@ -0,0 +1,162 @@
/**
* worktree.ts — Git worktree isolation for agents.
*
* Creates a temporary git worktree so the agent works on an isolated copy of the repo.
* On completion, if no changes were made, the worktree is cleaned up.
* If changes exist, a branch is created and returned in the result.
*/
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
export interface WorktreeInfo {
/** Absolute path to the worktree directory. */
path: string;
/** Branch name created for this worktree (if changes exist). */
branch: string;
}
export interface WorktreeCleanupResult {
/** Whether changes were found in the worktree. */
hasChanges: boolean;
/** Branch name if changes were committed. */
branch?: string;
/** Worktree path if it was kept. */
path?: string;
}
/**
* Create a temporary git worktree for an agent.
* Returns the worktree path, or undefined if not in a git repo.
*/
export function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined {
// Verify we're in a git repo with at least one commit (HEAD must exist)
try {
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 });
} catch {
return undefined;
}
const branch = `pi-agent-${agentId}`;
const suffix = randomUUID().slice(0, 8);
const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
try {
// Create detached worktree at HEAD
execFileSync("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], {
cwd,
stdio: "pipe",
timeout: 30000,
});
return { path: worktreePath, branch };
} catch {
// If worktree creation fails, return undefined (agent runs in normal cwd)
return undefined;
}
}
/**
* Clean up a worktree after agent completion.
* - If no changes: remove worktree entirely.
* - If changes exist: create a branch, commit changes, return branch info.
*/
export function cleanupWorktree(
cwd: string,
worktree: WorktreeInfo,
agentDescription: string,
): WorktreeCleanupResult {
if (!existsSync(worktree.path)) {
return { hasChanges: false };
}
try {
// Check for uncommitted changes in the worktree
const status = execFileSync("git", ["status", "--porcelain"], {
cwd: worktree.path,
stdio: "pipe",
timeout: 10000,
}).toString().trim();
if (!status) {
// No changes — remove worktree
removeWorktree(cwd, worktree.path);
return { hasChanges: false };
}
// Changes exist — stage, commit, and create a branch
execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
// Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
const safeDesc = agentDescription.slice(0, 200);
const commitMsg = `pi-agent: ${safeDesc}`;
execFileSync("git", ["commit", "-m", commitMsg], {
cwd: worktree.path,
stdio: "pipe",
timeout: 10000,
});
// Create a branch pointing to the worktree's HEAD.
// If the branch already exists, append a suffix to avoid overwriting previous work.
let branchName = worktree.branch;
try {
execFileSync("git", ["branch", branchName], {
cwd: worktree.path,
stdio: "pipe",
timeout: 5000,
});
} catch {
// Branch already exists — use a unique suffix
branchName = `${worktree.branch}-${Date.now()}`;
execFileSync("git", ["branch", branchName], {
cwd: worktree.path,
stdio: "pipe",
timeout: 5000,
});
}
// Update branch name in worktree info for the caller
worktree.branch = branchName;
// Remove the worktree (branch persists in main repo)
removeWorktree(cwd, worktree.path);
return {
hasChanges: true,
branch: worktree.branch,
path: worktree.path,
};
} catch {
// Best effort cleanup on error
try { removeWorktree(cwd, worktree.path); } catch { /* ignore */ }
return { hasChanges: false };
}
}
/**
* Force-remove a worktree.
*/
function removeWorktree(cwd: string, worktreePath: string): void {
try {
execFileSync("git", ["worktree", "remove", "--force", worktreePath], {
cwd,
stdio: "pipe",
timeout: 10000,
});
} catch {
// If git worktree remove fails, try pruning
try {
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
} catch { /* ignore */ }
}
}
/**
* Prune any orphaned worktrees (crash recovery).
*/
export function pruneWorktrees(cwd: string): void {
try {
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
} catch { /* ignore */ }
}