@justjoshn/pi-pstack 0.15.36-pi.12 → 0.15.36-pi.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.15.36-pi.13] - 2026-11-04
4
+
5
+ - Parse leading word intervals and preserve multiline prompts in `/loop`.
6
+ - Run different loop prompts independently. Return loop IDs and require an explicit ID for ambiguous wake or stop operations.
7
+ - Restore writable tools after leaving a planning branch with no recorded tool loadout.
8
+ - Preserve plan todo dependencies and project phases. Import unfinished root todos when building and synchronize later status updates without rewriting the rest of the plan file.
9
+ - Preserve completion when a reviewed plan omits a todo's optional status. Insert the missing field without rewriting existing metadata, comments, line endings, or body text.
10
+ - Persist plan-imported todos through native Pi branch entries so they survive a restart. Worktree plan builders import their own lists before the first request and retain the plan association on resume. Readonly resumes cannot synchronize plan files.
11
+ - Serialize Task stale-lock recovery with token-directory leases. Treat unreaped Linux zombies as exited writers.
12
+ - Open PR review canvases with the system browser instead of assuming Cursor's in-app browser.
13
+ - Keep Unix sockets blocked in the readonly shell. Refuse a missing seccomp helper instead of allowing a host service to change state on the command's behalf. Linux hosts that refuse the helper's nested namespaces cannot run readonly shell commands.
14
+ - Correct Benny's credential-isolation gate. Readonly children still inherit environment variables and can read credential files. Setup fails closed without separate isolation, and operational runs keep analysis and code work in the coordinator when isolation is unavailable.
15
+
3
16
  ## [0.15.36-pi.12] - 2026-11-04
4
17
 
5
18
  Each item below was checked against Cursor CLI 2026.10.22 with pstack and cursor-team-kit loaded, through its bundle and live runs. `parity/pi-pstack-behavior-parity/` in the repository records the evidence and the decisions.
package/README.md CHANGED
@@ -13,7 +13,7 @@ pi-pstack is pstack 0.15.36 for Pi 1.5.0: rigorous agent workflows (`/poteto-mod
13
13
  pi install ./extensions/pi-pstack
14
14
  ```
15
15
 
16
- Pi does not install dependencies for a local package, so `bun install` (or `npm install` in `extensions/pi-pstack`) fetches the one runtime dependency, `@anthropic-ai/sandbox-runtime`, which the read-only shell loads (readonly subagents and `/plan`). Without it everything else works, and a read-only shell command fails instead of running unsandboxed. On Linux the sandbox also needs `bubblewrap`, `socat`, and `ripgrep` (`sudo apt install bubblewrap socat ripgrep`), and Ubuntu 24.04 and later need the user-namespace change under Limits. macOS uses the built-in `sandbox-exec`. pi-pstack needs Node 22.19 or newer. To try it for one session without installing, run `pi -e ./extensions/pi-pstack`. To install the published package instead, run `pi install npm:@justjoshn/pi-pstack`.
16
+ Pi does not install dependencies for a local package, so `bun install` (or `npm install` in `extensions/pi-pstack`) fetches `@anthropic-ai/sandbox-runtime` for the read-only shell (readonly subagents and `/plan`) and `yaml` for plan metadata. If sandbox-runtime is missing, a read-only shell command fails instead of running unsandboxed. On Linux the sandbox also needs `bubblewrap`, `socat`, and `ripgrep` (`sudo apt install bubblewrap socat ripgrep`), and Ubuntu 24.04 and later need the user-namespace change under Limits. macOS uses the built-in `sandbox-exec`. pi-pstack needs Node 22.19 or newer. To try it for one session without installing, run `pi -e ./extensions/pi-pstack`. To install the published package instead, run `pi install npm:@justjoshn/pi-pstack`.
17
17
  Installing loads pi-pstack's tools and system-prompt sections at once, but no skill runs until you invoke it. 61 workflow skills stay out of the model's automatic skill list. Setup, create-skill, and the team-kit shipping and review skills (`deslop`, `control-cli`, `control-ui`, CI and PR helpers, and the rest of that set) can load from your words alone.
18
18
  2. Run `/setup-pstack`. It lists the models the `Task` tool can use, asks for a reasoning budget, maps a model to each role, and writes the model rule. A new rule applies from the next request.
19
19
  3. Start real work with `/poteto-mode`, a goal, and a check that can pass or fail.
@@ -28,8 +28,8 @@ Remove the package with `pi remove ./extensions/pi-pstack`. Its state lives in `
28
28
  |---|---|
29
29
  | `/poteto-mode <task>` | The skill attaches to that one message. Every skill Pi loaded works this way: pstack's (`/how`, `/why`, `/arena`, `/interrogate`, `/principle-prove-it-works`, `/deslop`) and your own, such as a `/<handle>-mode` skill from `/automate-me`. `/skill:<name>` works too. |
30
30
  | `/mode poteto-mode [task]` | Keeps the skill in context on every turn until you leave the mode: the skill rides every request in its own system-prompt section, which Pi re-sends only when it changes. The first request after entry says the user entered the mode, later requests carry a reminder that names the skill file, and the request after `/mode off` or a switch to another mode says the user left it. The footer shows a badge with the skill's `icon` and `color` metadata, such as a yellow `♛ mode: poteto-mode` (other skills get `⚡`). `/mode off` exits and `/mode` reports. Any loaded skill can back a mode. |
31
- | `/plan [task]` | Plan mode: the agent researches with read-only tools (`read`, `grep`, `find`, `ls`, the sandboxed `bash_readonly`, `AskQuestion`, `TodoWrite`, `agent_status`, `Task`, whose launches run readonly: a fresh one runs here with no worktree, and a resume or fork stays where its agent ran, and `stop_agent`), asks its clarifying questions early, and presents the plan with `CreatePlan` (`plan`, `name`, `overview`, `todos`). `CreatePlan` writes the plan file under `<pstack dir>/plans/` (`<name>-<8 hex>.plan.md`: the chat id in a comment, the todos as front matter, then the plan), one file per plan name in a chat. `edit` and `write` work on markdown files only, so the agent refines the plan in its file. After a run that saved a plan, an interactive session asks `Yes, build locally`, `Yes, build in cloud` (in a git repository: a background isolated worker builds it on its own branch), or `No, propose changes`. `/plan build` builds the saved plan too, and `/plan off` restores your tools. A bare `/plan` enters plan mode. The footer shows `plan`. |
32
- | `/loop 30m <prompt>` | Runs the prompt now, then again every 30 minutes from each fire, so a long run does not push the next tick out. A tick that comes due while a run is still going fires once when that run settles; several missed ticks still fire once. The interval can come first or last, written as `30m`, `10 minutes`, or `every 10 minutes`. Without an interval the agent picks each wake with the `Loop` tool, and the loop ends when it picks none and no background subagent is still running. `/loop stop` or Escape during a run ends it. The prompt can be a skill command, such as `/loop 1h /poteto-mode run the audit tick`. When a playbook says to arm `/loop`, the agent starts it with the `Loop` tool. Intervals go up to 24 days. |
31
+ | `/plan [task]` | Plan mode: the agent researches with read-only tools (`read`, `grep`, `find`, `ls`, the sandboxed `bash_readonly`, `AskQuestion`, `TodoWrite`, `agent_status`, `Task`, whose launches run readonly: a fresh one runs here with no worktree, and a resume or fork stays where its agent ran, and `stop_agent`), asks its clarifying questions early, and presents the plan with `CreatePlan` (`plan`, `name`, `overview`, `todos`, `is_project`, `phases`). `CreatePlan` writes the plan file under `<pstack dir>/plans/` (`<name>-<8 hex>.plan.md`: the chat id in a comment, the todos as front matter, then the plan), one file per plan name in a chat. Todo dependencies and project phases stay in the file. Building imports the file's unfinished root todos into the session, including edits made during review. Worktree builders import their own lists before the first model request and retain the associated plan on resume. Later writable `TodoWrite` status updates synchronize those root todos without rewriting other metadata or the plan body. An omitted status imports as pending and is inserted when the todo is updated. A readonly resume keeps its todo changes inside the session. `edit` and `write` work on markdown files only, so the agent refines the plan in its file. After a run that saved a plan, an interactive session asks `Yes, build locally`, `Yes, build in cloud` (in a git repository: a background isolated worker builds it on its own branch), or `No, propose changes`. `/plan build` builds the saved plan too, and `/plan off` restores your tools. A bare `/plan` enters plan mode. The footer shows `plan`. |
32
+ | `/loop 30m <prompt>` | Runs the prompt now, then again every 30 minutes from each fire, so a long run does not push the next tick out. A tick that comes due while a run is still going fires once when that run settles; several missed ticks still fire once. The interval can come first or last, written as `30m`, `10 minutes`, or `every 10 minutes`. Without an interval the agent picks each wake with the `Loop` tool, and the loop ends when it picks none and no background subagent is still running. Different prompts create independent loops. The same prompt replaces its schedule. `/loop` lists them. `/loop stop` ends the only loop, or `/loop stop <id>` selects one when several are running. Escape during a run ends the session's loops. `Loop` results include the loop ID in text and details; pass `loop_id` to wake, stop, or replace one of several loops. The prompt can be a skill command, such as `/loop 1h /poteto-mode run the audit tick`. When a playbook says to arm `/loop`, the agent starts it with the `Loop` tool. Intervals go up to 24 days. |
33
33
  | `/routines` | Lists webhook routines with their URL, credential file, and status. |
34
34
  | `/setup-pstack` | Configures the per-role models. |
35
35
  | `/poteto-help <question>` | Answers a question about pstack and hands you a prompt to send. |
@@ -40,7 +40,7 @@ Remove the package with `pi remove ./extensions/pi-pstack`. Its state lives in `
40
40
 
41
41
  | Feature | Behavior |
42
42
  |---|---|
43
- | `Task` (`description`, `prompt`, `subagent_type`, `model`, `run_in_background`, `resume`, `fork`, `fork_turns`, `readonly`, `file_attachments`, `environment`, `cloud_base_branch`) | Starts a separate `pi --mode rpc` process per subagent, so this Pi process can steer and abort it. Foreground calls return the response and an agent ID. Background calls return at once, and a `<system_notification>` with a `<task>` block arrives after your turn ends, titled by the first line of the prompt (80 characters at most) and followed by a request to do any follow-up work; the notification card shows the call's `description`. Print and JSON runs wait for pending notifications before exiting, unless aborted. Several calls in one message run in parallel. A subagent's own subagents cannot spawn more, and every subagent is told to spawn none unless asked. A run lock per agent ID keeps out a second writer, even from another Pi process (Limits notes the stale-lock race). Children inherit the parent's project-trust decision. Foreground usage counts toward the session totals. The Task card leads with the call's `description`, then the agent type and the model that ran, and while a foreground child works it shows its last few tool steps and latest line instead of a blank wait. |
43
+ | `Task` (`description`, `prompt`, `subagent_type`, `model`, `run_in_background`, `resume`, `fork`, `fork_turns`, `readonly`, `file_attachments`, `environment`, `cloud_base_branch`) | Starts a separate `pi --mode rpc` process per subagent, so this Pi process can steer and abort it. Foreground calls return the response and an agent ID. Background calls return at once, and a `<system_notification>` with a `<task>` block arrives after your turn ends, titled by the first line of the prompt (80 characters at most) and followed by a request to do any follow-up work; the notification card shows the call's `description`. Print and JSON runs wait for pending notifications before exiting, unless aborted. Several calls in one message run in parallel. A subagent's own subagents cannot spawn more, and every subagent is told to spawn none unless asked. A run lock per agent ID keeps out a second writer, even from another Pi process. A token-directory lease serializes stale-lock recovery while the PID file identifies the transcript writer. Children inherit the parent's project-trust decision. Foreground usage counts toward the session totals. The Task card leads with the call's `description`, then the agent type and the model that ran, and while a foreground child works it shows its last few tool steps and latest line instead of a blank wait. |
44
44
  | `resume` | Sends a follow-up to an idle agent with its context, model, type, and directory. The root chat can resume or fork a descendant that a worktree worker started inside that worktree, and the descendant still runs where it first ran. On an agent running in this Pi process, it delivers the prompt as a steer and returns at once. A readonly or plan-mode call cannot steer a writable agent. An agent running in another Pi process is refused. This is how a playbook "messages" a worker. Pi's own `/resume` command reopens a chat session instead. |
45
45
  | `fork` | Starts a new agent whose transcript begins as a copy of an idle agent's transcript, in that agent's directory (its worktree for an isolated worker), so it sees the built checkout and uncommitted changes. Type, model, and readonly default from the source; readonly can be added, never lifted. `fork_turns: N` (only with `fork`) copies just the first N user turns. `fork` excludes `resume`, `environment`, and `cloud_base_branch`. Pi's own `/fork` command branches the chat session instead. |
46
46
  | `stop_agent` | `agent_id`. Stops a running subagent. One this Pi process owns gets an RPC abort, then a kill after a grace period. One another of your Pi processes owns gets a signal to the live pid in its run lock. An idle or finished agent is left alone, and the result says so. |
@@ -48,8 +48,8 @@ Remove the package with `pi remove ./extensions/pi-pstack`. Its state lives in `
48
48
  | Subagent types | The built-in `generalPurpose` (default) and `explore` (a file-search specialist the caller tells how thorough to be, with `grep`, `find`, and `ls` on and no `Task`), then `poteto-agent` (a background delegate that reads poteto-mode first), `ci-watcher` (a background PR check watcher), `comment-sicko`, and `thermo-nuclear-code-quality-review`, the last four read from `agents/*.md`. Unknown types fail with the list. |
49
49
  | Models | A Pi model spec such as `claude-opus-5-5:xhigh` or `xai/grok-4.7:high`, model parameters such as `claude-opus-5-5[effort=high]` or `grok-4.7[fast=true]` (picks a listed `-fast` id of that model), `fast` for the parent model's listed `-fast` variant when it has one, else the parent model, or `inherit`, `inherit-parent`, `auto`, or nothing for the parent model. A model Pi does not list, or a thinking level the model does not support, fails with the allowed list. A bare model id that several providers offer resolves to the parent chat's provider when it offers that id, and otherwise fails with the choices. Older rules that wrote `claude-opus-5-5-xhigh` still resolve. The system prompt's `pstack_subagents` section lists the types, the parent model, and the models with their levels. |
50
50
  | Isolated workers (`environment: "cloud"`) | The subagent runs in a new git worktree at `<pstack dir>/workspaces/<workspace folder>/worktrees/<agent id>` on branch `pstack/<agent id>`, from `cloud_base_branch` (`origin/<branch>` when that remote-tracking ref exists, else a local branch or other ref) or your HEAD commit, so uncommitted changes do not reach it. The result and the card name the branch to merge, and `resume` and `fork` run in the same worktree. It fails clearly outside a git repository, in a repository with no commits, or when the base does not resolve. The worker shares the root chat's workspace state, so it sees the same projects, stores, and subagent transcripts. It also shares this machine's ports, processes, writable caches, simulators, and credentials. Assign separate ports, output directories, and writable caches to concurrent runtime lanes. `swarm` and `orchestrate` spawn worktree workers by default. |
51
- | `readonly: true` | The child gets `read`, `grep`, `find`, `ls`, `TodoWrite`, and a read-only `bash`, and no MCP. That shell runs each command in an OS sandbox ([`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime): `sandbox-exec` on macOS, bubblewrap on Linux) that blocks every file write and all network access, so `git log` works and `touch` or `curl` fail. Where no sandbox can start, the command fails rather than running unsandboxed. |
52
- | `TodoWrite` | `todos` with `id`, `content`, `status`, and `merge`. Its description says when to keep a list, the four states, and one task in progress at a time. The list follows the session branch, shows above the editor, and rides every request's system prompt. |
51
+ | `readonly: true` | The child gets `read`, `grep`, `find`, `ls`, `TodoWrite`, and a read-only `bash`, and no MCP. That shell runs each command in an OS sandbox ([`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime): `sandbox-exec` on macOS, bubblewrap on Linux) that blocks every file write and all network access, so `git log` works and `touch` or `curl` fail. Where no sandbox can start, the command fails rather than running unsandboxed. Readonly is not credential isolation. The child still inherits environment variables and can read credential files through its read tools. |
52
+ | `TodoWrite` | `todos` with `id`, `content`, `status`, and `merge`. Its description says when to keep a list, the four states, and one task in progress at a time. The list follows the session branch, shows above the editor, and rides every request's system prompt. Plan-imported lists persist as branch entries. Their dependencies survive status-only updates, and matching plan-file statuses are synchronized. |
53
53
  | `AskQuestion` | `title`, and per question `id`, `prompt`, `options` (at least two, each `id`, `label`, optional `description`), and `allow_multiple`, through Pi's select dialog. It is for decisions only the user can make, and a recommended option goes first with `(Recommended)`. Every question ends with `Other: (type to answer)`; a typed answer comes back as `other` with its text. In print and JSON runs, including subagents, each question comes back skipped. |
54
54
  | Always-applied rules | The two team-kit rules are in `pstack_host` as their shipped text on every request, alongside the same rules as hidden skills. |
55
55
  | The model rule | `<pstack dir>/pstack-models.md`, plain Markdown with one line per role, added to every request's system prompt. A `pstack-models.mdc` from an earlier version is moved there once, without its frontmatter, and kept as `pstack-models.mdc.migrated`. |
@@ -78,10 +78,10 @@ Subagent transcripts are Pi sessions in `<pstack dir>/workspaces/<workspace fold
78
78
  - Depth limits apply to `Task`. A writable subagent can still start `pi` through `bash`, and that process is outside the depth limit and parent watchdog.
79
79
  - A child loads your installed extensions plus this package. If you run one checkout with `-e` while a different checkout of pi-pstack is installed, the child loads both and fails on duplicate tools; use one copy, or a separate `PI_CODING_AGENT_DIR`, as `live:e2e` does.
80
80
  - Subagents run headless in RPC mode, so an extension that asks for permission through a dialog cannot ask there.
81
- - The read-only shell sandboxes shell subprocesses only, and is verified on macOS and on the Ubuntu CI runner. On Linux it needs `bubblewrap`, `socat`, and `ripgrep`. Ubuntu 24.04 and later restrict unprivileged user namespaces: either add an AppArmor profile that grants `userns` to `bwrap`, or run `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`, which lifts the restriction for every program on the machine. The policy sets `allowAllUnixSockets: true` so Linux skips `apply-seccomp`, whose nested userns fails on GitHub-hosted runners; filesystem write denial and network denial still come from bubblewrap.
81
+ - The read-only shell sandboxes shell subprocesses only. Earlier macOS and Ubuntu CI runs do not verify the stricter Unix-socket policy. On Linux it needs `bubblewrap`, `socat`, and `ripgrep`. Ubuntu 24.04 and later restrict unprivileged user namespaces: either add an AppArmor profile that grants `userns` to `bwrap`, or run `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`, which lifts the restriction for every program on the machine. Unix sockets stay blocked because a host socket service can mutate files or access the network on a command's behalf. Linux also needs sandbox-runtime's bundled `apply-seccomp` helper and permission to create its nested user, PID, and mount namespaces. A missing helper or a namespace refusal blocks the shell command. The shell never falls back to permitting host sockets. Do not treat a readonly child as a credential boundary. Environment variables and readable credential files are still available to its read tools.
82
82
  - Whether a model follows a playbook is the model's call.
83
83
  - Workspace folders follow Pi's session-folder naming, so two paths that differ only by `/` versus `-` share a folder. Resume and fork refuse an agent whose record names the other directory, except that a record created inside this workspace's own worktrees belongs to this workspace. Subscription reads and mutations also reject a registry owned by the other workspace.
84
- - The run lock that keeps one writer per agent ID names the process writing the transcript. A lock file without a readable pid counts as held; the error names the file to delete. `agent_status` shows such an agent as `idle`. A lock whose pid is dead is taken over, and two processes that take over the same stale lock at the same moment can both succeed.
84
+ - The run lock that keeps one writer per agent ID names the process writing the transcript. A lock file without a readable pid counts as held; the error names the file to delete. `agent_status` shows such an agent as `idle`. A token-directory lease beside the PID file serializes stale-lock recovery. A dead process or an unreaped Linux zombie does not count as a live writer. Older installed versions without the lease do not participate in this protection.
85
85
  - `skills/poteto-mode/scripts/watch-pr` needs Bun and installs its one dependency on first run. `check-plan.mjs` needs only Node. `worktree-audit.sh` needs `rg` and `jq`, and works with BSD (macOS) or GNU `stat` and `date`.
86
86
  - `statechart` runs `skills/statechart/scripts/statechart` on Node 22.18 to 22.x, Node 23.6 or later, Bun 1.1.5 or later, or Deno 2.0 or later. The first run installs TLC and Java through [mise](https://mise.jdx.dev/) and needs the network. Without a SPEC it prints `Give one SPEC.tla.` and its usage, then exits 2.
87
87
 
@@ -27,7 +27,7 @@ i want two pi automations that work together in one slack issue channel. each on
27
27
 
28
28
  - i want the source channel and root thread coordinates to stay immutable for the whole run.
29
29
  - i treat utility and debug bots as evidence, not delegation or fix ownership.
30
- - i allow subagents to help, but they cannot post to slack or receive slack credentials. analysis subagents run as `Task` with `readonly: true`, which gives them no mcp servers and no network.
30
+ - i allow subagents to help, but they cannot post to slack or receive slack credentials. analysis subagents run as `Task` with `readonly: true`. readonly removes mcp tools and restricts shell operations, but does not isolate credentials. when separate credential isolation cannot be proven, keep the work in the coordinator.
31
31
  - i want this entire pack committed at `.pi/automations/benny/` in the target repository. its `SKILL.md` files are direct automation instructions, not skills pi discovers.
32
32
  - i want pi-pstack installed in pi for shared dependencies such as `how`, `why`, `tdd`, `unslop`, and the required principle skills.
33
33
  - i want each subscription prompt to read its operational file directly from the target repository. i do not want pi-pstack package paths, copied excerpts, or slash-skill discovery.
@@ -16,8 +16,8 @@ Load the external Benny configuration supplied by the automation. If the config,
16
16
  - Never post a root message in the source channel.
17
17
  - Preflight the source parent before every source-thread post.
18
18
  - The coordinator is the only Slack poster.
19
- - Delegated analysis workers are read-only and return findings or media notes. In Pi, start each one with `Task` and `readonly: true`, which gives it no MCP servers and a shell with no network.
20
- - A fix-phase code worker may edit only when its environment provably excludes Slack credentials and every Slack write action. Otherwise the coordinator edits. A writable Pi subagent inherits the session's MCP servers and environment, so it qualifies only when the Pi process that owns the subscriptions loads no Slack MCP server with write tools and has no Slack token in its environment.
19
+ - Delegated analysis workers are read-only and return findings or media notes. `Task` with `readonly: true` removes MCP tools and restricts shell operations, but still inherits environment credentials and can read credential files. Launch a child only after separate credential isolation is proven. Otherwise do the work in the coordinator.
20
+ - A fix-phase code worker may edit only when its environment and readable files provably exclude Slack credentials and every Slack write action. Otherwise the coordinator edits. A writable Pi subagent also inherits the session's MCP servers. Absence of a Slack MCP server or an environment token alone is not proof of credential isolation.
21
21
  - Every child prompt must explicitly forbid every Slack MCP write tool (`mcp__<slack-server>__*` tools that post, edit, react, or upload), `chat.postMessage`, and all other Slack writes.
22
22
  - Never give a child a Slack token, posting instructions, source coordinates for posting, or permission to report externally.
23
23
  - If a child needs Slack write access to run, do not launch it.
@@ -36,7 +36,7 @@ Load the external Benny configuration supplied by the automation. If the config,
36
36
  Before making a work list or delegating:
37
37
 
38
38
  1. Require the trigger channel to equal the configured source channel.
39
- 2. Set `SOURCE_THREAD_TS` to `trigger.thread_ts` when present. Otherwise use `trigger.ts`.
39
+ 2. Set `SOURCE_THREAD_TS` to `trigger.thread_ts` when present. Otherwise use `trigger.message_ts`, then `trigger.ts`.
40
40
  3. Require a nonempty `SOURCE_THREAD_TS`.
41
41
  4. Store `SOURCE_CHANNEL_ID` and `SOURCE_THREAD_TS` as immutable values.
42
42
  5. Read the source thread and verify its root has those exact coordinates.
@@ -38,7 +38,7 @@ If this file is already being read from the target destination, treat the copy a
38
38
 
39
39
  Confirm that pi-pstack is installed in Pi. A personal install (`pi install <path-to-pi-pstack>`) serves every repository on this machine. A project install (`pi install -l <path-to-pi-pstack>`) writes the target repository's `.pi/settings.json`; preserve every unrelated setting and package entry when that file exists, and validate it after editing.
40
40
 
41
- This setup session must run in the target repository, because a `Task` subagent starts in its parent's working directory. If it does not, stop and tell the user to start Pi in the target repository and point it at `FOR_AGENTS.md` again. Start a fresh `Task` subagent with `readonly: true`. Ask it to resolve `<SKILLS>` from its own system prompt and read each required `<SKILLS>/<name>/SKILL.md` file below. It must report each absolute path and confirm the file's frontmatter names the required skill. Hidden skills need not appear in Pi's automatic skill catalog.
41
+ This setup session must run in the target repository, because a `Task` subagent starts in its parent's working directory. If it does not, stop and tell the user to start Pi in the target repository and point it at `FOR_AGENTS.md` again. Before the dependency check, prove that its child cannot read Slack credentials from the inherited environment or files. `readonly: true` removes MCP tools and restricts shell operations, but does not remove credentials or restrict the `read` tool. If credential isolation is unavailable, stop setup before starting a child or enabling subscriptions. Otherwise start a fresh `Task` subagent with `readonly: true`. Ask it to resolve `<SKILLS>` from its own system prompt and read each required `<SKILLS>/<name>/SKILL.md` file below. It must report each absolute path and confirm the file's frontmatter names the required skill. Hidden skills need not appear in Pi's automatic skill catalog.
42
42
 
43
43
  - `how`
44
44
  - `why`
@@ -132,7 +132,7 @@ The repro automation needs:
132
132
  - `gh` authenticated for the repository, so `gh pr create --draft` can open a draft pull request
133
133
  - The configured control-adapter skill
134
134
 
135
- Prefer the configured Slack MCP tools for reads and posts. The optional `BENNY_SLACK_BOT_TOKEN` may fill a narrow gap such as editing one operations status message or downloading an attachment. Store the value in a secret manager or the owning session's environment, not in YAML. A writable subagent inherits that environment, which is one reason Benny's workers run readonly.
135
+ Prefer the configured Slack MCP tools for reads and posts. The optional `BENNY_SLACK_BOT_TOKEN` may fill a narrow gap such as editing one operations status message or downloading an attachment. Store the value in a secret manager or the owning session's environment, not in YAML. Every subagent inherits that environment, including readonly children. Removing a token from the prompt does not remove it from the process or make credential files unreadable. Unless separate credential isolation is established, keep analysis and code work in the coordinator.
136
136
 
137
137
  Do not use undocumented integration endpoints.
138
138
 
@@ -247,7 +247,7 @@ Verify:
247
247
  3. Repro accepts the marker only from the configured triage identity.
248
248
  4. Repro keeps the same immutable source coordinates.
249
249
  5. No source-channel root message appears.
250
- 6. A delegated worker cannot use any Slack write action: it runs as a `readonly` `Task`, so it has no MCP tools.
250
+ 6. Every delegated worker has verified credential isolation as well as no Slack write actions. `readonly: true` alone is insufficient. If that isolation is unavailable, the coordinator performs the work without an analysis or code child.
251
251
  7. Missing coordinates, a deleted parent, or a failed preflight produces no post and no tracker issue.
252
252
  8. A second fire, or a repeated webhook POST, for the same report does no work, because the ledger already lists it.
253
253
 
@@ -19,7 +19,7 @@ Load the external Benny configuration supplied by the automation. If the config
19
19
  - If the parent is missing, deleted, inaccessible, or uncertain, stop with no writes.
20
20
  - Post one substantive verdict. Do not narrate progress.
21
21
  - The coordinator is the only Slack poster.
22
- - Delegated workers return findings only. They must be read-only and receive no Slack credentials or write actions. In Pi, start each one with `Task` and `readonly: true`: a readonly subagent gets no MCP servers, and its shell can neither write files nor reach the network.
22
+ - Delegated workers return findings only. They must be read-only and receive no Slack credentials or write actions. In Pi, `Task` with `readonly: true` removes MCP tools and restricts shell operations, but still inherits environment credentials and can read credential files. Launch a child only after separate credential isolation is proven. Otherwise do the work in the coordinator.
23
23
  - Every child prompt must forbid every Slack MCP write tool (`mcp__<slack-server>__*` tools that post, edit, react, or upload), `chat.postMessage`, and every other Slack write.
24
24
  - If worker isolation cannot enforce those limits, do the work in the coordinator.
25
25
  - Never create an issue that cannot link back to the source thread.
@@ -33,7 +33,7 @@ Before making a work list or delegating:
33
33
 
34
34
  1. Read `source_channel_id` from the trigger.
35
35
  2. Require it to equal the configured source channel.
36
- 3. Set `SOURCE_THREAD_TS` to `trigger.thread_ts` when present. Otherwise use `trigger.ts`.
36
+ 3. Set `SOURCE_THREAD_TS` to `trigger.thread_ts` when present. Otherwise use `trigger.message_ts`, then `trigger.ts`.
37
37
  4. Require a nonempty `SOURCE_THREAD_TS`.
38
38
  5. Store `SOURCE_CHANNEL_ID` and `SOURCE_THREAD_TS` as immutable values.
39
39
  6. Read the thread and verify that its root has exactly those coordinates.
@@ -8,7 +8,7 @@ This page records what `pi-pstack` reproduces from Cursor `pstack` 0.15.36 and i
8
8
  - Installed Cursor CLI at the time of the reference capture: `2026.10.22-27dcf5f`.
9
9
  - Native implementation: this package, which pins its upstream provenance in `package.json.pstackSource`.
10
10
 
11
- A path and byte comparison of the tracked trees shows every pstack skill, both pstack agents, all 18 team-kit skills, both team-kit agents, and both team-kit rules in this package. Seventeen team-kit skill files are byte-identical; `workflow-from-chats` describes Pi paths instead of Cursor paths. The changed pstack files are host translations. One upstream paragraph has no counterpart: `poteto-help`'s Claude Code install path and its `docs/harness/claude-code.md` adapter, because this package targets Pi only. The two non-portable frontmatter keys Cursor ships (`icon`/`color` on `poteto-mode`, `paths` on `typescript-best-practices`) are kept under `metadata`, the Agent Skills extension point. The port reads `icon` and `color` for the mode badge. `paths` stays as metadata only, because Cursor loads a `paths` skill that sets `disable-model-invocation` only when it is invoked by name. team-kit's thermo-nuclear agent changes only where it names the plugin that ships its rubric.
11
+ A path and byte comparison of the tracked trees shows every pstack skill, both pstack agents, all 18 team-kit skills, both team-kit agents, and both team-kit rules in this package. Sixteen team-kit skill files are byte-identical. `workflow-from-chats` describes Pi paths instead of Cursor paths, and `pr-review-canvas` opens a system browser instead of Cursor's in-app browser. The changed pstack files are host translations. One upstream paragraph has no counterpart: `poteto-help`'s Claude Code install path and its `docs/harness/claude-code.md` adapter, because this package targets Pi only. The two non-portable frontmatter keys Cursor ships (`icon`/`color` on `poteto-mode`, `paths` on `typescript-best-practices`) are kept under `metadata`, the Agent Skills extension point. The port reads `icon` and `color` for the mode badge. `paths` stays as metadata only, because Cursor loads a `paths` skill that sets `disable-model-invocation` only when it is invoked by name. team-kit's thermo-nuclear agent changes only where it names the plugin that ships its rubric.
12
12
 
13
13
  ## Native translations
14
14
 
@@ -45,6 +45,24 @@ A live Cursor CLI session with both plugins loaded produced these contracts.
45
45
 
46
46
  Pi returns the child's final text with a transcript path and usage, and reports background completion as a `pstack/task` notification. The payload shape differs because the hosts are different; the observable workflow, a subagent result arriving after the parent turn, is preserved.
47
47
 
48
+ ## Reconstruction repairs in 0.15.36-pi.13
49
+
50
+ The repository's `parity/pstack-reconstruction-2026-11-04/` holds the reference inventory, baseline and treatment receipts, and decision log. These repairs do not establish that every workflow has been observed on both hosts.
51
+
52
+ The pre-bump PR-preparation run, `pr-make-verify.txt`, exits at pi-pstack's coverage suite with 680 passing tests and five failing positive readonly-shell checks. Lint, typecheck, mechanisms, toolchain, and test conventions pass before that stop. No later package result is inferred. `pr-preparation.md` records the focused checks, local review, and publication redactions.
53
+
54
+ The later [Linux CI run at `83cf4d9b`](https://github.com/just-joshn/pi-customizations/actions/runs/41885522222/job/126768816594) passes all 685 package tests and 367 statechart tests on Ubuntu 24.04. This includes successful restricted commands and the host Unix-socket regression. The workflow pins Ubuntu 24.04 after `ubuntu-latest` selected Ubuntu 26.04 and the helper refused nested namespace setup. The sandbox policy and assertions are unchanged. This result does not resolve startup on Ubuntu 26.04 or replace the failed local full-repository receipt.
55
+
56
+ - Leading word intervals such as `/loop every 10 minutes check CI` set the cadence instead of becoming part of the submitted prompt. Interval extraction preserves internal newlines.
57
+ - Different loop prompts run independently. Each `Loop` result includes an ID in text and details. With several loops running, `loop_id` selects a wake, stop, or replacement, and `/loop stop <id>` selects a command-line stop. Re-arming the same prompt replaces its schedule. Real Pi RPC regressions observe both prompts repeating and one continuing after the other stops.
58
+ - Leaving a planning branch restores the pre-plan tools when Pi has no recorded system-message tool loadout to replay. Both the recorded and unrecorded cases run through real Pi tree navigation.
59
+ - `CreatePlan` preserves todo dependencies, `is_project`, and project phases in the plan file. Building loads unfinished root todos from that file, including review edits. Imported lists survive a Pi restart. Worktree builders import their own lists before their first model request and restore the plan association on resume. Writable `TodoWrite` updates synchronize matching root statuses while preserving quotes, comments, custom metadata, and the plan body. Readonly resumes update only the session list. `plan-worker-baseline.txt` and `plan-worker-treatment.txt` record the actual Pi RPC regression.
60
+ - A reviewed todo without a status imports as pending and now records later completion in the saved file. The synchronizer inserts only the missing field. Unit regressions cover block and flow mappings, trailing commas, and CRLF. An actual Pi RPC regression covers the same edit. `plan-missing-status-baseline.txt` and `plan-missing-status-treatment.txt` retain the failing and passing cases.
61
+ - Task stale-lock recovery uses the same token-directory lease as subscription ownership. A two-process regression forces the former check-to-remove race and observes one owner after the repair. Task status also treats an unreaped Linux zombie as exited rather than blocking recovery indefinitely. Older versions without the lease do not participate in this protection.
62
+ - `pr-review-canvas` names the system browser and `control-ui` instead of assuming Cursor's in-app browser. This is an instruction translation, not evidence that a real model completed a canvas review.
63
+ - A readonly Pi child reached a disposable Unix socket and caused its unsandboxed server to modify a file. The shell now keeps Unix sockets blocked and refuses a missing seccomp helper. The Linux host used for this audit refuses the helper's nested namespace setup, so the treatment proves failure-closed behavior, not successful restricted command execution. `readonly-boundary-baseline.txt`, `readonly-boundary-treatment.txt`, and `package-suite-readonly-strict.txt` retain the evidence. The latter has 675 passing tests and five failing positive shell checks. The earlier full repository pass predates this safety change.
64
+ - A fake Slack token remained readable in a readonly child's inherited environment. Benny's instructions now require separate credential isolation rather than inferring it from readonly tools. Setup stops without that proof, and operational runs keep analysis and code work in the coordinator. This instruction repair is not a live Slack integration test.
65
+
48
66
  ## Repairs in 0.15.36-pi.12
49
67
 
50
68
  A fresh reverse engineering of Cursor CLI 2026.10.22 with pstack and cursor-team-kit loaded found these differences. The repository's `parity/pi-pstack-behavior-parity/` holds the findings, the decision trail, and `probe-cursor.sh`, which reruns every Cursor probe. Each change has a test that failed on the earlier code.
@@ -101,9 +119,9 @@ These are not closed by extension code.
101
119
  - **Background workers stop when the chat exits.** A Pi subagent is a child process of the chat, so quitting or shutting the session stops the agents it started and reports how many. Whether a Cursor background subagent outlives its parent is unobserved; the CLI changelog says cancelling the prompt stops its subagents, and only the cloud-agent product promises work after the laptop closes. A `new_session` subscription fire is Pi's one detached path, and it starts a fresh run rather than continuing a subagent.
102
120
  - **A mode's skill rides the system prompt.** Cursor attaches the skill to the user message that enters the mode and keeps it durable in the conversation; its notices travel in user-message fields. Pi carries the skill and the notices in system-prompt sections, which reach the model on every request and survive compaction, so the model sees the same text in a different place.
103
121
  - **A fork needs an idle source.** Cursor exposes `fork` only on its internal and cloud-agent protos, never on the CLI Task schema, and no doc states whether a fork may start while the source runs. A Pi fork runs in the source's directory or worktree, so a running source is refused; the orchestrate playbook says to wait for its completion notification.
122
+ - **Linux readonly shells require nested namespace support.** The stricter policy must block host Unix sockets as well as IP traffic and filesystem writes. On a host that refuses sandbox-runtime's seccomp helper, the command does not run. Earlier checks that permitted all Unix sockets are not evidence for this policy. Readonly also does not isolate environment credentials or files readable through the ordinary read tools.
104
123
  - **Plan mode's restriction is stricter than the host's.** Cursor's plan mode keeps every tool and restricts by instruction: edit only markdown files, run only read-only commands. Pi enforces the same rules with a tool allowlist, markdown-only `edit` and `write`, and a sandboxed shell that also denies network, so `gh`, `curl`, and package queries can fail while planning. Allowing network in a read-only shell would also open a path for remote changes, so the sandbox keeps it closed.
105
124
  - **The plan review is a dialog, not a card.** Cursor renders the plan with a build menu. Pi shows the same three choices in a select dialog after the run that saved the plan, and "build in cloud" starts a background isolated worktree worker, Pi's counterpart to a cloud agent, rather than a remote VM.
106
- - **One loop at a time.** Cursor's local `/loop` runs one shell loop per purpose, so several can run at once. pi-pstack runs one loop per session; a new `/loop` or `Loop` call replaces the running one. pstack's playbooks arm one audit loop at a time.
107
125
  - **benny's default cadence still needs its owning session open.** `wake_self` timers and webhook routines park while the owner is offline. A timer or webhook routine created with `sessionStrategy: "new_session"` runs detached with no chat open, but a detached run is one-shot: it must do the work in that run, because a background `Task` ends with it. Cursor automations run in the cloud and can hand work to anything. So benny keeps `wake_self` by default and setup names the tradeoff before switching.
108
126
  - **A routines listener is local, one port per workspace.** Cursor hosts each routine URL. Pi binds a loopback port per workspace, so two workspaces serve routines at once only because the default port falls back to an ephemeral one that the workspace then reuses and reports. A sender outside the machine still needs its own forwarding path.
109
127
  - **Cloud execution is local.** Cursor documents cloud VMs for its cloud product, but the inspected CLI schema omits `environment` and a requested cloud Task ran on the same machine and directory. Pi's `environment: "cloud"` goes further with a git worktree and a separate local process. Neither runs with the laptop off or provides a remote VM.
@@ -114,11 +132,22 @@ These are not closed by extension code.
114
132
  - **Hosted guidance and pages are outside the package.** MCP providers, ticket trackers, browsers, simulators, and Tailscale remain the user's own dependencies, as they are for Cursor.
115
133
  - **Background usage cannot enter parent totals.** Pi exposes no API that adds a later notification's usage to the parent session. It stays in the notification details and the child transcript.
116
134
 
135
+ ## Session probes on 2026-11-04 (continuation)
136
+
137
+ These observations update earlier open notes. They do not make the package VERIFIED.
138
+
139
+ - Cursor's live `TodoWrite` accepted a one-item write even though the published schema sets `minItems: 2` and requires `content` and `status` on every item. Pi keeps `minItems: 1` and status-only merges, which matches that runtime behavior. Evidence: this session's `CallDynamicTool` one-item call, plus `test/e2e/todo.e2e.test.ts` merge updates.
140
+ - On this Linux host, `kernel.apparmor_restrict_unprivileged_userns=1`. Socket-restricted sandbox commands fail in `apply-seccomp` while writing `/proc/self/setgroups`. The same sandbox with `allowAllUnixSockets: true` runs under bubblewrap and denies writes. Docker and passwordless sudo were unavailable here, so the positive restricted path remains a host blocker. CI on Ubuntu 24.04 still owns that green path.
141
+ - Benny operational skills now resolve `SOURCE_THREAD_TS` from `thread_ts`, then `message_ts`, then `ts`, so the documented webhook payload works when `thread_ts` is empty. Upstream Cursor skills still name only `trigger.ts`. Completeness audit: `parity/pstack-reconstruction-2026-11-04/session-2026-11-04T19-completeness.json`.
142
+ - The sibling `cursor-sdk` plugin remains out of scope. No pstack skill or agent references it.
143
+
117
144
  ## Open evidence
118
145
 
119
146
  The following were not observed end to end and are not claimed.
120
147
 
121
148
  - A real model following every ported skill on both hosts.
149
+ - Cursor's project-phase execution UI and per-phase build behavior. Pi preserves phase metadata but does not reproduce that UI.
150
+ - Hosted cloud plan execution's full todo lifecycle and simultaneous independent builders updating one plan. Native worktree startup, resume, dependency retention, writable status synchronization, and readonly non-synchronization have actual Pi RPC evidence.
122
151
  - Authenticated MCP exchanges and provider-specific tool behavior.
123
152
  - A successful live Cursor run for each workflow after the reference capture.
124
153
  - Reboot survival through a real launchd or systemd unit on both platforms.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justjoshn/pi-pstack",
3
- "version": "0.15.36-pi.12",
3
+ "version": "0.15.36-pi.13",
4
4
  "description": "pstack 0.15.36 for Pi with its soft-dependency team-kit skills vendored in: rigorous agent workflows and principles as Pi skills, with the host features they use: subagents, todos, modes, plan mode, the model rule, /loop, subscriptions and routines, AskQuestion, SwitchMode, image generation, and visualize",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,7 +64,8 @@
64
64
  "live:e2e": "node scripts/live-e2e.mjs"
65
65
  },
66
66
  "dependencies": {
67
- "@anthropic-ai/sandbox-runtime": "0.0.79"
67
+ "@anthropic-ai/sandbox-runtime": "0.0.79",
68
+ "yaml": "2.9.0"
68
69
  },
69
70
  "peerDependencies": {
70
71
  "@earendil-works/pi-ai": "*",
@@ -157,7 +157,7 @@ Since renderer.js loads in `<head>`, you can also call `renderDiff(target, lines
157
157
  ```bash
158
158
  cd /tmp && python3 -m http.server 8432 --bind 127.0.0.1
159
159
  ```
160
- Run this backgrounded, then navigate the in-app browser to `http://127.0.0.1:8432/pr-review-{number}.html`.
160
+ Run this backgrounded. Open `http://127.0.0.1:8432/pr-review-{number}.html` in the system browser with `xdg-open` on Linux or `open` on macOS. Pi has no in-app browser. For automated inspection, use the `control-ui` skill and a local browser harness. If no graphical browser is available, report that limit and give the URL.
161
161
 
162
162
  **Why a fixed port and `cd /tmp`:** Background shells have no TTY, so Python buffers its startup message ("Serving HTTP on...") indefinitely — using port 0 means you can never read which port was chosen. And `--directory /tmp` works but `cd /tmp` is more robust across Python versions. If port 8432 is taken, try 8433, 8434, etc.
163
163
 
@@ -454,7 +454,16 @@ for (const signal of ["SIGINT", "SIGTERM"] satisfies NodeJS.Signals[]) {
454
454
  signalWhile(signal, () => ({
455
455
  dir: gridCase(150),
456
456
  reached: (parent) => {
457
- for (const f of fs.readdirSync(parent, { recursive: true, encoding: "utf8" }))
457
+ // The command creates and replaces draft trees while it writes. A recursive
458
+ // readdir can see a draft entry that is gone before Node descends into it.
459
+ let files: string[];
460
+ try {
461
+ files = fs.readdirSync(parent, { recursive: true, encoding: "utf8" });
462
+ } catch (error) {
463
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
464
+ throw error;
465
+ }
466
+ for (const f of files)
458
467
  if (
459
468
  path.basename(f) === "chart.mmd" &&
460
469
  fs.statSync(path.join(parent, f), { throwIfNoEntry: false })?.size
package/src/child-env.ts CHANGED
@@ -7,6 +7,7 @@ export const READONLY_ENV = 'PI_PSTACK_READONLY';
7
7
  export const ROOT_STORE_ENV = 'PI_PSTACK_ROOT_STORE';
8
8
  export const WORKSPACE_ENV = 'PI_PSTACK_WORKSPACE_STATE';
9
9
  export const ROOT_WORKSPACE_ENV = 'PI_PSTACK_ROOT_WORKSPACE';
10
+ export const BUILD_PLAN_ENV = 'PI_PSTACK_BUILD_PLAN';
10
11
 
11
12
  export interface ChildIdentity {
12
13
  depth: number;
@@ -18,6 +19,7 @@ export interface ChildIdentity {
18
19
  workspaceState: string | undefined;
19
20
  /** The main chat's working directory, which authorizes every record stored in its shared workspace. */
20
21
  rootWorkspace: string | undefined;
22
+ buildPlan: string | undefined;
21
23
  }
22
24
 
23
25
  function read(): ChildIdentity {
@@ -31,6 +33,7 @@ function read(): ChildIdentity {
31
33
  rootStore: process.env[ROOT_STORE_ENV] || undefined,
32
34
  workspaceState: process.env[WORKSPACE_ENV] || undefined,
33
35
  rootWorkspace: process.env[ROOT_WORKSPACE_ENV] || undefined,
36
+ buildPlan: process.env[BUILD_PLAN_ENV] || undefined,
34
37
  };
35
38
  }
36
39
 
@@ -42,6 +45,7 @@ delete process.env[READONLY_ENV];
42
45
  delete process.env[ROOT_STORE_ENV];
43
46
  delete process.env[WORKSPACE_ENV];
44
47
  delete process.env[ROOT_WORKSPACE_ENV];
48
+ delete process.env[BUILD_PLAN_ENV];
45
49
 
46
50
  /** Variables for a child of this process at `depth` (an agent keeps the depth it was created at). */
47
51
  export function childVariables(agentType: string, depth: number, readonly: boolean, rootStore: string, workspaceState: string, rootWorkspace: string): Record<string, string> {
package/src/index.ts CHANGED
@@ -103,7 +103,11 @@ export default function piPstack(pi: ExtensionAPI): void {
103
103
  });
104
104
 
105
105
  const mode = registerMode(pi);
106
- const plan = registerPlanMode(pi, { buildInWorker: (planFile, request) => task.startBackground(`Build ${basename(planFile)}`, request, { environment: 'cloud' }) });
106
+ const plan = registerPlanMode(pi, {
107
+ ...(childIdentity.buildPlan === undefined ? {} : { buildPlan: childIdentity.buildPlan }),
108
+ setTodos: (items, ctx) => todos.replace(items, ctx),
109
+ buildInWorker: (planFile, request) => task.startBackground(`Build ${basename(planFile)}`, request, { environment: 'cloud', buildPlan: planFile }),
110
+ });
107
111
  registerVisualize(pi, { artifactsDir: (ctx) => artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()) });
108
112
  registerGenerateImage(pi, { artifactsDir: (ctx) => artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()), mayOffer: () => !childIdentity.readonly && !plan.enabled() });
109
113
  const ownType = agents.find((agent) => agent.type === childIdentity.agentType);
@@ -119,7 +123,7 @@ export default function piPstack(pi: ExtensionAPI): void {
119
123
  },
120
124
  });
121
125
  const loop = registerLoop(pi, { hasPendingWork: task.hasBackgroundWork });
122
- const todos = registerTodoWrite(pi);
126
+ const todos = registerTodoWrite(pi, childIdentity.readonly ? {} : { onWrite: plan.syncTodos });
123
127
  registerAskQuestion(pi);
124
128
  if (childIdentity.parentPid !== undefined) watchParent(pi, childIdentity.parentPid);
125
129
  if (childIdentity.readonly) registerReadonlyShell(pi);
package/src/loop.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
1
3
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import { Type } from 'typebox';
4
+ import { type Static, Type } from 'typebox';
3
5
  import { isOneShot } from './modes.ts';
4
6
 
5
7
  const DAY_MS = 86_400_000;
@@ -30,19 +32,16 @@ function delayOf(text: string): number {
30
32
  return ms;
31
33
  }
32
34
 
33
- export type LoopCommand = { kind: 'status' } | { kind: 'stop' } | { kind: 'start'; prompt: string; intervalMs?: number } | { kind: 'error'; message: string };
35
+ export type LoopCommand = { kind: 'status' } | { kind: 'stop'; loopId?: string } | { kind: 'start'; prompt: string; intervalMs?: number } | { kind: 'error'; message: string };
34
36
 
35
- /** The longest trailing phrase that parses as a duration, such as `5m`, `every 10 minutes`, or `1 hour 30 minutes`. */
36
- function trailingInterval(text: string): { prompt: string; intervalMs: number } | undefined {
37
- const tokens = text.split(/\s+/);
38
- for (let take = Math.min(6, tokens.length); take >= 1; take -= 1) {
39
- const intervalMs = parseDuration(tokens.slice(-take).join(' '));
37
+ function edgeInterval(text: string, edge: 'leading' | 'trailing'): { prompt: string; intervalMs: number } | undefined {
38
+ const tokens = [...text.matchAll(/\S+/g)];
39
+ for (let take = tokens.length; take >= 1; take -= 1) {
40
+ const start = edge === 'leading' ? 0 : (tokens[tokens.length - take]?.index ?? 0);
41
+ const end = edge === 'trailing' ? text.length : (tokens[take - 1]?.index ?? 0) + (tokens[take - 1]?.[0].length ?? 0);
42
+ const intervalMs = parseDuration(text.slice(start, end));
40
43
  if (intervalMs === undefined) continue;
41
- const prompt = tokens
42
- .slice(0, -take)
43
- .join(' ')
44
- .replace(/\s+every$/i, '')
45
- .trim();
44
+ const prompt = (edge === 'leading' ? text.slice(end) : text.slice(0, start).replace(/\s+every\s*$/i, '')).trim();
46
45
  return { prompt, intervalMs };
47
46
  }
48
47
  return undefined;
@@ -51,20 +50,13 @@ function trailingInterval(text: string): { prompt: string; intervalMs: number }
51
50
  export function parseLoopArgs(args: string): LoopCommand {
52
51
  const trimmed = args.trim();
53
52
  if (trimmed === '') return { kind: 'status' };
54
- if (trimmed === 'stop' || trimmed === 'off') return { kind: 'stop' };
55
- const [, first = '', rest = ''] = /^(\S+)\s*([\s\S]*)$/.exec(trimmed) ?? [];
56
- const leadMs = parseDuration(first) ?? (first.toLowerCase() === 'every' ? parseDuration(rest.split(/\s+/)[0] ?? '') : undefined);
57
- if (leadMs !== undefined) {
58
- if (leadMs > MAX_DELAY_MS) return { kind: 'error', message: TOO_LONG };
59
- const after = first.toLowerCase() === 'every' ? rest.split(/\s+/).slice(1).join(' ') : rest;
60
- return after.trim() === '' ? { kind: 'error', message: '/loop needs a prompt: /loop [interval] <prompt>' } : { kind: 'start', prompt: after.trim(), intervalMs: leadMs };
61
- }
62
- // Cursor accepts the interval at either end, including `every 10 minutes`, so both ends are tried before treating it as a prompt.
63
- const trailing = trailingInterval(trimmed);
64
- if (trailing !== undefined) {
65
- if (trailing.intervalMs > MAX_DELAY_MS) return { kind: 'error', message: TOO_LONG };
66
- if (trailing.prompt === '') return { kind: 'error', message: '/loop needs a prompt: /loop [interval] <prompt>' };
67
- return { kind: 'start', prompt: trailing.prompt, intervalMs: trailing.intervalMs };
53
+ const stop = /^(?:stop|off)(?:\s+(\S+))?$/.exec(trimmed);
54
+ if (stop !== null) return { kind: 'stop', ...(stop[1] === undefined ? {} : { loopId: stop[1] }) };
55
+ const interval = edgeInterval(trimmed, 'leading') ?? edgeInterval(trimmed, 'trailing');
56
+ if (interval !== undefined) {
57
+ if (interval.intervalMs > MAX_DELAY_MS) return { kind: 'error', message: TOO_LONG };
58
+ if (interval.prompt === '') return { kind: 'error', message: '/loop needs a prompt: /loop [interval] <prompt>' };
59
+ return { kind: 'start', ...interval };
68
60
  }
69
61
  return { kind: 'start', prompt: trimmed };
70
62
  }
@@ -113,7 +105,7 @@ function createLoopController(pi: ExtensionAPI, options: LoopOptions) {
113
105
  if (loop === undefined) return;
114
106
  loop = { ...loop, iteration: loop.iteration + 1, due: false };
115
107
  // Expansion lets a loop run a skill or template each time.
116
- pi.sendUserMessage(loop.prompt, { expandPromptTemplates: true });
108
+ pi.sendUserMessage(loop.prompt, { expandPromptTemplates: true, deliverAs: 'followUp' });
117
109
  };
118
110
  /**
119
111
  * A timer fired. A fixed loop keeps its wall-clock cadence, like Cursor's sleep-and-echo shell loop, so a long run
@@ -167,31 +159,77 @@ function createLoopController(pi: ExtensionAPI, options: LoopOptions) {
167
159
  loop = { ...rest, nextWakeMs: delayMs };
168
160
  };
169
161
  const section = () => (loop === undefined ? undefined : loopSection(loop));
170
- return { notify, stop, settled, start, arm, status, wake, clear, section, active: () => loop !== undefined };
162
+ return { stop, settled, start, arm, status, wake, clear, section, prompt: () => loop?.prompt, active: () => loop !== undefined };
163
+ }
164
+
165
+ function createLoopRegistry(pi: ExtensionAPI, options: LoopOptions) {
166
+ const controllers = new Map<string, ReturnType<typeof createLoopController>>();
167
+ const entries = () => {
168
+ for (const [id, controller] of controllers) if (!controller.active()) controllers.delete(id);
169
+ return [...controllers];
170
+ };
171
+ const choose = (loopId?: string, prompt?: string) => {
172
+ const active = entries();
173
+ const existing = active.find(([id, controller]) => (loopId !== undefined ? id === loopId : prompt !== undefined ? controller.prompt() === prompt : active.length === 1));
174
+ if (existing !== undefined) return { loopId: existing[0], controller: existing[1] };
175
+ if (loopId !== undefined) throw new Error(`No running loop has id "${loopId}". /loop lists running loops.`);
176
+ if (prompt !== undefined) {
177
+ const id = randomUUID();
178
+ const controller = createLoopController(pi, options);
179
+ controllers.set(id, controller);
180
+ return { loopId: id, controller };
181
+ }
182
+ if (active.length === 0) throw new Error('No loop is running.');
183
+ throw new Error(`Several loops are running. Pass loop_id to choose one, or use /loop stop <id>. Running loops:\n${active.map(([id, controller]) => `${id} ${controller.status()}`).join('\n')}`);
184
+ };
185
+ const status = () => {
186
+ const active = entries();
187
+ if (active.length === 0) return 'No loop is running. Usage: /loop [interval] <prompt>, with the interval at either end; /loop stop ends one.';
188
+ return active.map(([id, controller]) => (active.length === 1 ? controller.status() : `${id} ${controller.status()}`)).join('\n');
189
+ };
190
+ const section = () => {
191
+ const active = entries();
192
+ if (active.length === 0) return undefined;
193
+ if (active.length === 1) return active[0]?.[1].section();
194
+ return [...active.map(([id, controller]) => `Loop ${id} runs ${JSON.stringify(controller.prompt())}.\n${controller.section()}`), 'Several loops are running. Pass loop_id with wake_in or stop so only the intended loop changes.'].join(
195
+ '\n\n',
196
+ );
197
+ };
198
+ return { choose, entries, status, section };
171
199
  }
172
200
 
173
201
  type LoopController = ReturnType<typeof createLoopController>;
202
+ type LoopRegistry = ReturnType<typeof createLoopRegistry>;
174
203
 
175
204
  const ONE_SHOT = '/loop needs an interactive or RPC session; print and JSON runs exit when the agent settles.';
176
205
 
177
- function handleLoopCommand(controller: LoopController, args: string, ctx: ExtensionCommandContext): void {
206
+ function handleLoopCommand(registry: LoopRegistry, args: string, ctx: ExtensionCommandContext): void {
178
207
  const command = parseLoopArgs(args);
179
- if (command.kind === 'status') controller.notify(ctx, controller.status());
180
- else if (command.kind === 'stop') controller.stop(ctx, 'stopped by /loop stop.');
181
- else if (command.kind === 'error') controller.notify(ctx, command.message);
182
- else if (isOneShot(ctx.mode)) process.stderr.write(`${ONE_SHOT}\n`);
183
- else controller.start(ctx, command.prompt, command.intervalMs);
208
+ try {
209
+ if (command.kind === 'status') ctx.ui.notify(registry.status(), 'info');
210
+ else if (command.kind === 'stop') registry.choose(command.loopId).controller.stop(ctx, 'stopped by /loop stop.');
211
+ else if (command.kind === 'error') ctx.ui.notify(command.message, 'error');
212
+ else if (isOneShot(ctx.mode)) process.stderr.write(`${ONE_SHOT}\n`);
213
+ else registry.choose(undefined, command.prompt).controller.start(ctx, command.prompt, command.intervalMs);
214
+ } catch (error) {
215
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error');
216
+ }
184
217
  }
185
218
 
186
219
  const LoopParams = Type.Object({
187
- prompt: Type.Optional(Type.String({ description: 'Start (or replace) a loop that sends this prompt each run. It may be a skill or template command such as /poteto-mode <task>.' })),
220
+ loop_id: Type.Optional(Type.String({ description: 'Loop ID from an earlier Loop result. Required to wake, stop, or replace one loop when several are running. Omit with a new prompt to start an independent loop.' })),
221
+ prompt: Type.Optional(
222
+ Type.String({
223
+ description:
224
+ 'Start a loop that sends this prompt each run. A different prompt starts an independent loop; the same prompt or an explicit loop_id replaces its earlier schedule. It may be a skill or template command such as /poteto-mode <task>.',
225
+ }),
226
+ ),
188
227
  interval: Type.Optional(Type.String({ description: 'With prompt: a fixed cadence such as 30m or 1h; the first run starts that long after this turn ends.' })),
189
228
  wake_in: Type.Optional(Type.String({ description: 'With prompt: the first run of an agent-paced loop. Alone: the next run of the running agent-paced loop. A duration such as 10m, at most 24d.' })),
190
229
  stop: Type.Optional(Type.Boolean({ description: 'End the loop once its goal is met.' })),
191
230
  });
192
231
 
193
- function loopToolText(controller: LoopController, params: { prompt?: string; interval?: string; wake_in?: string; stop?: boolean }, ctx: ExtensionContext): string {
194
- if (isOneShot(ctx.mode)) throw new Error(`${ONE_SHOT} Do the work now instead of scheduling it.`);
232
+ function loopToolText(controller: LoopController, params: Static<typeof LoopParams>, ctx: ExtensionContext): string {
195
233
  if (params.stop === true) {
196
234
  if (!controller.active()) throw new Error('No loop is running.');
197
235
  controller.stop(ctx, 'the agent ended it.');
@@ -215,32 +253,39 @@ function loopToolText(controller: LoopController, params: { prompt?: string; int
215
253
  return `Next loop run in ${formatMs(delay)}, after this turn ends.`;
216
254
  }
217
255
 
218
- function registerLoopTool(pi: ExtensionAPI, controller: LoopController): void {
256
+ function registerLoopTool(pi: ExtensionAPI, registry: LoopRegistry): void {
219
257
  pi.registerTool({
220
258
  name: 'Loop',
221
259
  label: 'Loop',
222
- description: '/loop for you: start a loop that re-sends a prompt at a fixed interval or at wakes you pick, schedule the next wake of an agent-paced loop, or stop the loop once its goal is met. Use it when a playbook says to arm /loop.',
260
+ description:
261
+ '/loop for you: start an independent loop that re-sends a prompt at a fixed interval or at wakes you pick, schedule its next wake, or stop it once its goal is met. Use it when a playbook says to arm /loop. Each result names the loop ID. With several loops running, pass loop_id to wake or stop the intended loop.',
223
262
  promptSnippet: 'Start a /loop, schedule its next wake, or stop it',
224
263
  parameters: LoopParams,
225
264
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
226
265
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
227
- return { content: [{ type: 'text', text: loopToolText(controller, params, ctx) }], details: undefined };
266
+ if (isOneShot(ctx.mode)) throw new Error(`${ONE_SHOT} Do the work now instead of scheduling it.`);
267
+ const { loopId, controller } = registry.choose(params.loop_id, params.prompt);
268
+ return { content: [{ type: 'text', text: `${loopToolText(controller, params, ctx)}\nLoop ID: ${loopId}` }], details: { loopId } };
228
269
  },
229
270
  });
230
271
  }
231
272
 
232
273
  /** /loop: run a prompt at a fixed interval, or at wakes the agent picks, until stopped. */
233
274
  export function registerLoop(pi: ExtensionAPI, options: LoopOptions): { section: () => string | undefined } {
234
- const controller = createLoopController(pi, options);
275
+ const registry = createLoopRegistry(pi, options);
235
276
  pi.on('session_start', (_event, ctx) => {
236
277
  if (isOneShot(ctx.mode)) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== 'Loop'));
237
278
  });
238
- pi.on('agent_settled', (event, ctx) => controller.settled(ctx, event.aborted));
239
- pi.on('session_shutdown', () => controller.clear());
279
+ pi.on('agent_settled', (event, ctx) => {
280
+ for (const [, controller] of registry.entries()) controller.settled(ctx, event.aborted);
281
+ });
282
+ pi.on('session_shutdown', () => {
283
+ for (const [, controller] of registry.entries()) controller.clear();
284
+ });
240
285
  pi.registerCommand('loop', {
241
- description: 'Run a prompt repeatedly: /loop [interval] <prompt>, interval either end and like 30s, 5m, 1h, every 10 minutes (at most 24d); /loop stop',
242
- handler: async (args, ctx) => handleLoopCommand(controller, args, ctx),
286
+ description: 'Run independent prompts repeatedly: /loop [interval] <prompt>, interval either end and like 30s, 5m, 1h, every 10 minutes (at most 24d); /loop lists them; /loop stop [id] stops one',
287
+ handler: async (args, ctx) => handleLoopCommand(registry, args, ctx),
243
288
  });
244
- registerLoopTool(pi, controller);
245
- return { section: controller.section };
289
+ registerLoopTool(pi, registry);
290
+ return { section: registry.section };
246
291
  }