@justjoshn/pi-pstack 0.15.36-pi.13 → 0.15.36-pi.14
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 +11 -0
- package/README.md +8 -6
- package/docs/guide/01-setup.md +1 -1
- package/docs/parity-status.md +16 -1
- package/package.json +1 -1
- package/skills/architect/references/runner-prompt.md +1 -1
- package/skills/arena/SKILL.md +3 -3
- package/src/ask.ts +3 -0
- package/src/background-shell.ts +698 -0
- package/src/index.ts +14 -3
- package/src/linux-readonly-sandbox.ts +121 -0
- package/src/loop.ts +1 -1
- package/src/paths.ts +5 -0
- package/src/readonly-shell.ts +22 -9
- package/src/sections.ts +22 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.15.36-pi.14] - 2026-11-04
|
|
4
|
+
|
|
5
|
+
Each item below was checked against Cursor CLI 2026.10.22 with pstack and cursor-team-kit loaded, through live captures. `parity/pstack-native-parity-2026-11-04/` in the repository records the evidence and the decisions.
|
|
6
|
+
|
|
7
|
+
- Add background shells: `bash_background` runs a command in the background, or after a foreground wait, writes `terminals/<id>.txt` with a status header and an exit footer, and sends a `kind: shell` notification when the command ends or prints a watched line. `AwaitShell` polls, waits for a pattern, or sleeps. pstack's babysit watcher, worktree-cleanup scan, PR review canvas server, and event-gated `/loop` wakes use them. The host section sends commands that can run for minutes to `bash_background`, because `bash` waits until a command ends.
|
|
8
|
+
- Run the Linux read-only shell under bubblewrap with a seccomp filter that bubblewrap loads itself, so readonly subagents get a working shell on Ubuntu 24.04 and later. Writes, the network, and Unix sockets stay blocked. macOS keeps sandbox-runtime.
|
|
9
|
+
- Tell the agent to run a loop's prompt once in the turn that arms it, as Cursor's loop skill does.
|
|
10
|
+
- Remove `AskQuestion` from subagents.
|
|
11
|
+
- State today's date in the host section.
|
|
12
|
+
- Restore upstream text in the architect runner prompt and arena where the port had added instructions with no host reason.
|
|
13
|
+
|
|
3
14
|
## [0.15.36-pi.13] - 2026-11-04
|
|
4
15
|
|
|
5
16
|
- Parse leading word intervals and preserve multiline prompts in `/loop`.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pstack for Pi (pi-pstack)
|
|
2
2
|
|
|
3
|
-
pi-pstack is pstack 0.15.36 for Pi 1.5.0: rigorous agent workflows (`/poteto-mode` and its playbooks), the principle skills, and the review, research, and verification skills, plus the full soft-dependency team-kit set (`deslop`, `control-cli`, `control-ui`, CI/PR helpers, thermo-nuclear review, and the rest) vendored in so you do not install a separate team-kit package. A small extension provides the host features the skills call by name: `/name` skill commands, `/mode`, `/plan`, the `Task` subagent tool with local worktree workers, forks, and `agent_status`, `TodoWrite`, `AskQuestion`, `GenerateImage`, `SwitchMode`, the model rule, `/loop`, subscriptions and webhook routines, and `visualize`.
|
|
3
|
+
pi-pstack is pstack 0.15.36 for Pi 1.5.0: rigorous agent workflows (`/poteto-mode` and its playbooks), the principle skills, and the review, research, and verification skills, plus the full soft-dependency team-kit set (`deslop`, `control-cli`, `control-ui`, CI/PR helpers, thermo-nuclear review, and the rest) vendored in so you do not install a separate team-kit package. A small extension provides the host features the skills call by name: `/name` skill commands, `/mode`, `/plan`, the `Task` subagent tool with local worktree workers, forks, and `agent_status`, background shells (`bash_background` and `AwaitShell`), `TodoWrite`, `AskQuestion`, `GenerateImage`, `SwitchMode`, the model rule, `/loop`, subscriptions and webhook routines, and `visualize`.
|
|
4
4
|
|
|
5
5
|
## Get set up
|
|
6
6
|
|
|
@@ -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
|
|
16
|
+
Pi does not install dependencies for a local package, so `bun install` (or `npm install` in `extensions/pi-pstack`) fetches `yaml` for plan metadata and, for the macOS read-only shell (readonly subagents and `/plan`), `@anthropic-ai/sandbox-runtime`. On Linux the read-only shell runs `bubblewrap` from the system instead (`sudo apt install bubblewrap`), with no user-namespace change on Ubuntu 24.04 and later. Where no sandbox can start, a read-only shell command fails instead of running unsandboxed. 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.
|
|
@@ -29,7 +29,7 @@ Remove the package with `pi remove ./extensions/pi-pstack`. Its state lives in `
|
|
|
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
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
|
|
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 or background shell is still running. A run that waits on an event (CI finishing, a ref moving, a log line) starts a watcher with `bash_background`, whose notification wakes the agent, and keeps a long wake as the fallback. 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 runs the prompt once in that turn and starts the loop with the `Loop` tool, whose first scheduled run comes after the interval or wake. 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. |
|
|
@@ -48,9 +48,9 @@ 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
|
|
51
|
+
| `readonly: true` | The child gets `read`, `grep`, `find`, `ls`, `TodoWrite`, and a read-only `bash`, and no MCP or background shell. That shell runs each command in an OS sandbox that blocks every file write, all network access, and Unix sockets, so `git log` works and `touch` or `curl` fail. On Linux it is `bubblewrap` with every namespace unshared, the filesystem bound read-only, and a seccomp filter that refuses `socket(AF_UNIX)` and `io_uring_setup`; on macOS it is [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime)'s `sandbox-exec` policy. 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
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
|
-
| `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
|
|
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 each question comes back skipped. Subagents have no `AskQuestion`. |
|
|
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`. |
|
|
56
56
|
| Subscriptions | `subscribe_github_pr` (`scope` `pr` with `prUrl`, `repo` with `repo`, or `author` with `author` and `repo`) polls with `gh` every 60 seconds and wakes the chat when a PR's state, head, mergeability, review decision, reviews, or comments change, or when a check concludes or its conclusion changes; the first poll is a baseline. `subscribe_timer` takes a `name`, a `prompt`, and a 5-field `cron` or `delaySeconds`, either with `once`; `sessionStrategy` `wake_self` queues a wake for this owning session. With `new_session`, a live owner starts a background Task and receives its notification. An offline owner permits a detached run with a recorded outcome. Both reuse the model, thinking level, and project-trust decision the timer was created with. Re-subscribing an existing timer name returns `created: false`. `list_subscriptions` and `unsubscribe` manage them. The authoritative registry is `<pstack dir>/durable/<workspace>/subscriptions.json`. Any session in that workspace can list or cancel subscriptions. Session history does not recreate cancelled or completed subscriptions. Owner-directed events wait for the creating session rather than waking another workspace session. A detached run records its own final message, shown by `list_subscriptions`. A timer does not expire unless `expiresInHours` says so (at most 30 days); `0` means no expiry. A GitHub watch keeps a 7-day default because it is this port's own surface. |
|
|
@@ -60,6 +60,7 @@ Remove the package with `pi remove ./extensions/pi-pstack`. Its state lives in `
|
|
|
60
60
|
| `visualize` | `title`, `kind` (`bar` or `line`), `labels`, `series` (`name`, `values`), and `unit` render a Unicode chart in the result and write a standalone SVG to the session's artifacts folder. |
|
|
61
61
|
| Transcripts | The system prompt names this workspace's Pi session directory, the current session file, and this workspace's subagent transcripts. |
|
|
62
62
|
| `GenerateImage` | Generates an image (`description` of at least six words, `filename`, `reference_image_paths`, `aspect_ratio`) on Pi's image models through `ctx.modelRegistry.generateImages()`. It is offered when an image model is available (for example an OpenRouter image model), not to readonly subagents or in plan mode, and saves each image to the session's artifacts folder. Without an image model, the host section tells skills to draw a Mermaid diagram instead. |
|
|
63
|
+
| Background shells | `bash_background` (`command`, `description`, `block_until_ms`, `notify_on_output` with `pattern`, `reason`, and `debounce_ms`, `working_directory`) runs a command in the background, or waits up to `block_until_ms` for it and then moves it there. Output goes to `<pstack dir>/workspaces/<workspace folder>/terminals/<shell id>.txt`, which starts with a header (pid, cwd, command, title, status, started_at, and running_for_ms, refreshed every 5 seconds) and ends with an exit footer. After the turn, a `<system_notification>` with a `kind: shell` task block arrives when the command finishes (`status: success`, `error` with `exit_code=N`, or `aborted`) and when a line matches `notify_on_output` (the first match at once, later ones counted and sent after each `debounce_ms` window, at least 5 seconds). `AwaitShell` (`shell_id`, `block_until_ms`, `pattern`) polls or waits on a job, or sleeps without one; seeing a job end there skips its notification. Print and JSON runs wait for their jobs before they exit. Ending the session stops running jobs. Readonly subagents and plan mode do not get these tools. |
|
|
63
64
|
| `SwitchMode` | `target_mode_id` (`plan` or `agent`) and `explanation`. The agent proposes entering or leaving plan mode, and the switch happens only after you confirm it. Offered in interactive and RPC sessions. |
|
|
64
65
|
|
|
65
66
|
Subagent transcripts are Pi sessions in `<pstack dir>/workspaces/<workspace folder>/subagents/`, with the agent ID in each file name, next to a small record that `resume`, `fork`, and `agent_status` read. Records belong to the main chat's shared workspace state, so the root chat can act on a descendant that ran in a worktree. Children inherit your installed extensions and MCP servers, and local ones run in your working directory. Give each local subagent that writes its own git worktree, or launch it with `environment: "cloud"`, because a child process does not isolate the filesystem. A child exits on its own if its parent Pi process dies.
|
|
@@ -78,7 +79,8 @@ Subagent transcripts are Pi sessions in `<pstack dir>/workspaces/<workspace fold
|
|
|
78
79
|
- 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
80
|
- 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
81
|
- 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.
|
|
82
|
+
- The read-only shell sandboxes shell subprocesses only. On Linux it needs `bubblewrap` that can create its user namespace (Ubuntu's default `bwrap` AppArmor profile allows that) on x86-64 or arm64; bubblewrap loads the seccomp filter itself, so no nested namespace is needed. A missing `bwrap`, another architecture, or a namespace refusal blocks the shell command. Unix sockets stay blocked because a host socket service can mutate files or access the network on a command's behalf, and the shell never falls back to permitting them. Do not treat a readonly child as a credential boundary. Environment variables and readable credential files are still available to its read tools.
|
|
83
|
+
- A subagent's background shells stop when the subagent finishes, and its launch result says so, because a subagent is its own process. A print or JSON run waits for its background shells, with no timeout of its own, so a job that never ends keeps the run open.
|
|
82
84
|
- Whether a model follows a playbook is the model's call.
|
|
83
85
|
- 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
86
|
- 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.
|
package/docs/guide/01-setup.md
CHANGED
|
@@ -13,7 +13,7 @@ bun install
|
|
|
13
13
|
pi install ./extensions/pi-pstack
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
`pi install <path-to-pi-pstack>` loads the package in place, so keep the checkout where it is. To install the published package instead, run `pi install npm:@justjoshn/pi-pstack`. On Linux, the read-only shell also needs `bubblewrap`,
|
|
16
|
+
`pi install <path-to-pi-pstack>` loads the package in place, so keep the checkout where it is. To install the published package instead, run `pi install npm:@justjoshn/pi-pstack`. On Linux, the read-only shell also needs `bubblewrap`, as the package README explains. Start Pi, and the pstack commands appear in the `/` menu.
|
|
17
17
|
|
|
18
18
|
## Pick your models
|
|
19
19
|
|
package/docs/parity-status.md
CHANGED
|
@@ -45,6 +45,20 @@ 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
|
+
## Repairs in 0.15.36-pi.14
|
|
49
|
+
|
|
50
|
+
An independent audit on 2026-11-04 compared every reference file and probed Cursor CLI 2026.10.22 live. The repository's `parity/pstack-native-parity-2026-11-04/` holds the notes, decision log, inventory, and the scripts; `.audit/pstack-native-parity-2026-11-04/cursor/` holds the raw Cursor captures.
|
|
51
|
+
|
|
52
|
+
- **Background shells.** Cursor's `Shell` runs a command in the background (`block_until_ms: 0`, or after the wait runs out), writes `terminals/<id>.txt` with a status header and an exit footer, notifies after the turn when the command ends (`kind: shell`) and on each `notify_on_output` match (the first at once, later ones counted per debounce window), and `AwaitShell` polls, waits for a pattern, or sleeps. pstack leans on it: babysit's "watcher output drives wakeups", worktree-cleanup's "background it", pr-review-canvas's "run this backgrounded", and the event-gated branch of Cursor's `/loop`. pi-pstack had no background shell. `bash_background` and `AwaitShell` now reproduce the captured texts, header layout (173 bytes for the probed job), and notification shapes. Two choices differ from one observation each. A job whose end `AwaitShell` saw sends no completion notice, as Cursor's AwaitShell description promises, though one Cursor run sent a duplicate. A subagent's jobs stop when the subagent ends, because a Pi subagent is its own process; Cursor ran the job on and reported it `aborted` to the parent.
|
|
53
|
+
- **Linux read-only shell.** sandbox-runtime's seccomp helper needs a nested user namespace, which Ubuntu 24.04 and later refuse by default (`kernel.apparmor_restrict_unprivileged_userns=1`), so every readonly subagent on such a host had no shell. Cursor documents readonly as "no file edits, no state-changing shell commands", so read-only commands must work. Linux now runs `bwrap` directly with every namespace unshared, `/` bound read-only, and a generated seccomp filter (`bwrap --seccomp`) that refuses `socket(AF_UNIX)` and `io_uring_setup`. Writes, TCP, and Unix sockets stay refused; `git log` works. macOS keeps sandbox-runtime.
|
|
54
|
+
- **`/loop` arming.** Cursor's loop skill runs the prompt once when the loop is armed. The host section and the `Loop` tool now say to do that in the arming turn; the first scheduled run still comes after the interval or wake.
|
|
55
|
+
- **Subagents have no `AskQuestion`.** Cursor's generalPurpose subagent listed no such tool (`subagent-tools.jsonl`), and one asked to call it replied that it does not exist (`subagent-askquestion.jsonl`).
|
|
56
|
+
- **Today's date.** Cursor's user info states "Today's date" in every chat (`context-paths.jsonl`). Pi's system prompt has no date, so `pstack_host` now states it in the same form.
|
|
57
|
+
- **Long commands.** Cursor's `Shell` moves a command to the background after its 30-second foreground window. Pi's `bash` waits until the command ends, so the host section sends watchers, polling scripts, and servers to `bash_background`.
|
|
58
|
+
- **Text.** The architect runner prompt is upstream's again, and arena drops a port-assignment sentence and a `cloud` instruction that no host difference required. Arena keeps upstream's `composer-*`/`cursor-grok-*` family rule.
|
|
59
|
+
|
|
60
|
+
Observed, not changed: Cursor CLI 2026.10.22 does not load plugin rules (team-kit's two always-applied rules) or name an agent store or Projects in its context (`context-paths.jsonl`); pi-pstack keeps both, because pstack's text relies on them.
|
|
61
|
+
|
|
48
62
|
## Reconstruction repairs in 0.15.36-pi.13
|
|
49
63
|
|
|
50
64
|
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.
|
|
@@ -119,7 +133,8 @@ These are not closed by extension code.
|
|
|
119
133
|
- **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.
|
|
120
134
|
- **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.
|
|
121
135
|
- **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
|
-
- **
|
|
136
|
+
- **A subagent's background shells end with it.** Cursor runs a subagent inside the parent process, so a background shell it starts outlives the subagent's Task result and its notification reaches the parent (`subagent-bg-shell.jsonl`, status `aborted`). A Pi subagent is its own process, so its jobs stop when it finishes, and its launch result says so.
|
|
137
|
+
- **Linux readonly shells need bubblewrap.** The policy blocks host Unix sockets as well as IP traffic and filesystem writes. bubblewrap must be able to create its user namespace (Ubuntu's `bwrap` AppArmor profile allows it); where it cannot, or on an architecture other than x86-64 or arm64, the command does not run. Readonly also does not isolate environment credentials or files readable through the ordinary read tools.
|
|
123
138
|
- **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.
|
|
124
139
|
- **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.
|
|
125
140
|
- **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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justjoshn/pi-pstack",
|
|
3
|
-
"version": "0.15.36-pi.
|
|
3
|
+
"version": "0.15.36-pi.14",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Architect runner prompt
|
|
2
2
|
|
|
3
|
-
The orchestrator passes this file through to every parallel candidate runner during Phase B and fills in the variable inputs around it: the task, the Phase A grounding artifacts, the isolated working directory, and the path to write outputs. The working directory is a git worktree when available, otherwise a per-runner subdirectory under the sketch dir. What matters is independence between candidates.
|
|
3
|
+
The orchestrator passes this file through to every parallel candidate runner during Phase B and fills in the variable inputs around it: the task, the Phase A grounding artifacts, the isolated working directory, and the path to write outputs. The working directory is a git worktree when available, otherwise a per-runner subdirectory under the sketch dir. What matters is independence between candidates.
|
|
4
4
|
|
|
5
5
|
You are producing one candidate design in architect's parallel exploration. Read the **architect** skill in full first. That's the workflow you're inside. Output a candidate design package: type sketch, function signatures, module map, and prose rationale shaped per [`rationale-template.md`](rationale-template.md).
|
|
6
6
|
|
package/skills/arena/SKILL.md
CHANGED
|
@@ -14,12 +14,12 @@ Every candidate gets the same prompt, so the prompt is the contract.
|
|
|
14
14
|
|
|
15
15
|
1. State the artifact each candidate is producing.
|
|
16
16
|
2. Derive the rubric, 3-6 concrete gradeable criteria for what success looks like on *this* task. It is the picker's tool in Phase D. Candidates only see the task.
|
|
17
|
-
3. Pick the runners. Use the `arena runners` line in the model rule at the path the system prompt names. If the rule or that line is missing, default to one each on `claude-opus-5-5:xhigh` and `grok-4.7[effort=xhigh,fast=true]`. An `auto` or `inherit-parent` entry in this line means the parent model, so omit `model` for it. If the Task tool rejects a configured entry, run that seat on its family's default and say so. Families go by the model id after the provider: `claude-*` and `
|
|
18
|
-
4. Give each candidate its own output location (a git worktree where possible, otherwise `/tmp/arena-<slug>/candidate-<n>/`), per the **separate-before-serializing-shared-state** principle skill.
|
|
17
|
+
3. Pick the runners. Use the `arena runners` line in the model rule at the path the system prompt names. If the rule or that line is missing, default to one each on `claude-opus-5-5:xhigh` and `grok-4.7[effort=xhigh,fast=true]`. An `auto` or `inherit-parent` entry in this line means the parent model, so omit `model` for it. If the Task tool rejects a configured entry, run that seat on its family's default and say so. Families go by the model id after the provider, by prefix: `claude-*`, and `grok-*` with `cursor-grok-*` and `composer-*`. A refused `composer-*` entry runs on `grok-4.7[effort=xhigh,fast=true]`. If the family has no default, or the Task tool rejects that default, use the closest valid slug of the same family from its error message, or drop the seat and say so when there is none. If dropped seats leave fewer than two runners, add runners until there are two, or one when the line has one entry. Run each added runner on a remaining runner's model, or omit `model` so it runs on the parent model when none remain, and say so. Spawn more runners when the arena covers multiple design directions. Use the same model N times when the work is generation-bound rather than judgment-sensitive. Pick each subagent's reasoning level (the `:<level>` suffix), and fast or standard speed when the model has a `-fast` variant, for the job, per the Subagents section of the **poteto-mode** skill.
|
|
18
|
+
4. Give each candidate its own output location (a git worktree where possible, otherwise `/tmp/arena-<slug>/candidate-<n>/`), per the **separate-before-serializing-shared-state** principle skill.
|
|
19
19
|
|
|
20
20
|
## Phase B: Fan out
|
|
21
21
|
|
|
22
|
-
Spawn all N subagents in one message with `run_in_background: true`, each with the task, the path to the shared grounding, its own output path, and instructions to produce the artifact and a short rationale naming the alternatives it considered and what it rejected.
|
|
22
|
+
Spawn all N subagents in one message with `run_in_background: true`, each with the task, the path to the shared grounding, its own output path, and instructions to produce the artifact and a short rationale naming the alternatives it considered and what it rejected.
|
|
23
23
|
|
|
24
24
|
If a candidate produces no output, proceed with N-1 and note the dropout.
|
|
25
25
|
|
package/src/ask.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { type Static, Type } from 'typebox';
|
|
3
|
+
import { childIdentity } from './child-env.ts';
|
|
3
4
|
import { canAsk } from './modes.ts';
|
|
4
5
|
|
|
5
6
|
export const DONE_LABEL = '✓ Done selecting';
|
|
@@ -122,6 +123,8 @@ export function registerAskQuestion(pi: ExtensionAPI): void {
|
|
|
122
123
|
parameters: AskParams,
|
|
123
124
|
exposure: 'model-only',
|
|
124
125
|
executionMode: 'sequential',
|
|
126
|
+
// Cursor's subagents have no AskQuestion: their owner, not a person, reads what they produce.
|
|
127
|
+
defaultActive: childIdentity.depth === 0,
|
|
125
128
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
126
129
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
127
130
|
validateQuestions(params.questions);
|
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
// Cursor's background shell: a command that keeps running while the agent works, an output file with a status
|
|
2
|
+
// header and an exit footer, a notification when the command finishes or prints a watched line, and AwaitShell to
|
|
3
|
+
// poll or wait. Texts follow what Cursor CLI 2026.10.22 returns and sends (parity/pstack-native-parity-2026-11-04).
|
|
4
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
5
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, statSync, writeSync } from 'node:fs';
|
|
6
|
+
import { constants } from 'node:os';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
10
|
+
import { getShellConfig, truncateTail } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
12
|
+
import { Type } from 'typebox';
|
|
13
|
+
import { isOneShot } from './modes.ts';
|
|
14
|
+
|
|
15
|
+
export const SHELL_NOTICE_TYPE = 'pstack/shell';
|
|
16
|
+
const BACKGROUND_TOOL = 'bash_background';
|
|
17
|
+
const AWAIT_TOOL = 'AwaitShell';
|
|
18
|
+
|
|
19
|
+
const FIELD_WIDTH = 9;
|
|
20
|
+
const HEADER_REFRESH_MS = 5_000;
|
|
21
|
+
const MIN_DEBOUNCE_MS = 5_000;
|
|
22
|
+
const DEFAULT_AWAIT_MS = 30_000;
|
|
23
|
+
const MAX_AWAIT_MS = 7_140_000;
|
|
24
|
+
const KILL_GRACE_MS = 2_000;
|
|
25
|
+
const FOLLOW_UP = 'Briefly inform the user about the task result and perform any follow-up actions (if needed).';
|
|
26
|
+
|
|
27
|
+
type JobStatus = 'running' | 'succeeded' | 'failed' | 'aborted';
|
|
28
|
+
type NoticeStatus = 'success' | 'error' | 'aborted';
|
|
29
|
+
|
|
30
|
+
interface OutputWatch {
|
|
31
|
+
readonly regex: RegExp;
|
|
32
|
+
readonly source: string;
|
|
33
|
+
readonly reason: string;
|
|
34
|
+
readonly debounceMs: number;
|
|
35
|
+
occurrences: number;
|
|
36
|
+
/** Matches since the last notification, sent when the debounce window closes. */
|
|
37
|
+
unreported: number;
|
|
38
|
+
latest: string;
|
|
39
|
+
notices: number;
|
|
40
|
+
window: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
partial: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ShellJob {
|
|
45
|
+
readonly id: string;
|
|
46
|
+
readonly pid: number;
|
|
47
|
+
readonly command: string;
|
|
48
|
+
readonly cwd: string;
|
|
49
|
+
readonly title: string;
|
|
50
|
+
readonly toolCallId: string;
|
|
51
|
+
readonly path: string;
|
|
52
|
+
readonly startedAt: number;
|
|
53
|
+
readonly fd: number;
|
|
54
|
+
readonly headerBytes: number;
|
|
55
|
+
readonly statusOffset: number;
|
|
56
|
+
readonly runningOffset: number;
|
|
57
|
+
readonly child: ChildProcess;
|
|
58
|
+
readonly watch: OutputWatch | undefined;
|
|
59
|
+
readonly listeners: Set<() => void>;
|
|
60
|
+
readonly done: Promise<void>;
|
|
61
|
+
/** Where the next output byte goes; the header is rewritten in place, so the file is never opened for append. */
|
|
62
|
+
size: number;
|
|
63
|
+
/** Where the output ends once the footer is written. */
|
|
64
|
+
bodyEnd: number | undefined;
|
|
65
|
+
status: JobStatus;
|
|
66
|
+
exitCode: number | undefined;
|
|
67
|
+
endedAt: number | undefined;
|
|
68
|
+
/** AwaitShell or a foreground wait saw the end, so no completion notification follows. */
|
|
69
|
+
observed: boolean;
|
|
70
|
+
/** Waits in progress (AwaitShell or a foreground wait); one of them reports the end instead of a notification. */
|
|
71
|
+
awaiting: number;
|
|
72
|
+
refresh: ReturnType<typeof setInterval> | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ShellNotice {
|
|
76
|
+
kind: 'progress' | 'finished';
|
|
77
|
+
status: NoticeStatus;
|
|
78
|
+
taskId: string;
|
|
79
|
+
shellId: string;
|
|
80
|
+
title: string;
|
|
81
|
+
toolCallId: string;
|
|
82
|
+
detail?: string;
|
|
83
|
+
outputPath?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pad = (value: string) => value.padEnd(FIELD_WIDTH, ' ');
|
|
87
|
+
|
|
88
|
+
/** The header Cursor writes: fixed-width status and running time, so both can be rewritten in place. */
|
|
89
|
+
export function terminalHeader(fields: { pid: number; cwd: string; command: string; title: string; startedAt: number }): { text: string; statusOffset: number; runningOffset: number } {
|
|
90
|
+
const before = ['---', `pid: ${fields.pid}`, `cwd: ${JSON.stringify(fields.cwd)}`, `command: ${JSON.stringify(fields.command)}`, `title: ${JSON.stringify(fields.title)}`, 'status: '].join('\n');
|
|
91
|
+
const middle = `${pad('running')}\nstarted_at: ${new Date(fields.startedAt).toISOString()}\nrunning_for_ms: `;
|
|
92
|
+
const text = `${before}${middle}${pad('0')}\n---\n`;
|
|
93
|
+
const statusOffset = Buffer.byteLength(before);
|
|
94
|
+
return { text, statusOffset, runningOffset: statusOffset + Buffer.byteLength(middle) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function terminalFooter(exitCode: number, elapsedMs: number, endedAt: number): string {
|
|
98
|
+
return `\n---\nexit_code: ${exitCode}\nelapsed_ms: ${elapsedMs}\nended_at: ${new Date(endedAt).toISOString()}\n---\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function launchText(job: Pick<ShellJob, 'id' | 'pid' | 'command' | 'path'>): string {
|
|
102
|
+
return ['Background command started successfully.', `Shell ID: ${job.id}`, `PID: ${job.pid}`, `Command: ${job.command}`, `Output will be written to ${job.path}. Don't mention Shell ID to the user.`].join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function movedText(job: Pick<ShellJob, 'id' | 'pid' | 'path'>, waitedMs: number, output: string): string {
|
|
106
|
+
return [
|
|
107
|
+
`The command did not complete in ${waitedMs}ms and was sent to the background.`,
|
|
108
|
+
`Shell ID: ${job.id}`,
|
|
109
|
+
`PID: ${job.pid}`,
|
|
110
|
+
`The output is being written to ${job.path}. Don't mention Shell ID to the user.`,
|
|
111
|
+
'',
|
|
112
|
+
'Output collected before backgrounding:',
|
|
113
|
+
'',
|
|
114
|
+
'```',
|
|
115
|
+
tail(output, job.path),
|
|
116
|
+
'```',
|
|
117
|
+
].join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function finishedText(exitCode: number, elapsedMs: number, output: string, path: string): string {
|
|
121
|
+
return [`Exit code: ${exitCode}`, '', 'Command output:', '', '```', tail(output, path), '```', '', `Command completed in ${elapsedMs} ms.`].join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function tail(output: string, path: string): string {
|
|
125
|
+
const result = truncateTail(output);
|
|
126
|
+
return result.truncated ? `[showing the last ${result.outputLines} of ${result.totalLines} lines; the full output is in ${path}]\n${result.content}` : output;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function noticeText(notice: ShellNotice): string {
|
|
130
|
+
return [
|
|
131
|
+
'<system_notification>',
|
|
132
|
+
`The following task has ${notice.kind === 'finished' ? 'finished' : 'notified'}. If you were already aware, ignore this notification and do not restate prior responses.`,
|
|
133
|
+
'',
|
|
134
|
+
'<task>',
|
|
135
|
+
'kind: shell',
|
|
136
|
+
`status: ${notice.status}`,
|
|
137
|
+
`task_id: ${notice.taskId}`,
|
|
138
|
+
`title: ${notice.title}`,
|
|
139
|
+
`tool_call_id: ${notice.toolCallId}`,
|
|
140
|
+
...(notice.detail === undefined ? [] : [`detail: ${notice.detail}`]),
|
|
141
|
+
...(notice.outputPath === undefined ? [] : [`output_path: ${notice.outputPath}`]),
|
|
142
|
+
'</task>',
|
|
143
|
+
'</system_notification>',
|
|
144
|
+
FOLLOW_UP,
|
|
145
|
+
].join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const firstMatchDetail = (watch: OutputWatch) => `Shell output has pattern /${watch.source}/. This is occurrence number ${watch.occurrences}. Matched text: ${watch.latest}. The title message is being displayed to the user.`;
|
|
149
|
+
const laterMatchesDetail = (watch: OutputWatch) => `Shell output matched /${watch.source}/ ${watch.unreported} more times since the previous notification. The title message is being displayed to the user. Latest match: ${watch.latest}.`;
|
|
150
|
+
|
|
151
|
+
function exitStatus(code: number | null, signal: NodeJS.Signals | null): { status: JobStatus; exitCode: number } {
|
|
152
|
+
if (signal !== null) return { status: 'aborted', exitCode: 128 + (constants.signals[signal] ?? 0) };
|
|
153
|
+
const exitCode = code ?? 1;
|
|
154
|
+
return { status: exitCode === 0 ? 'succeeded' : 'failed', exitCode };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function compile(pattern: string): RegExp {
|
|
158
|
+
try {
|
|
159
|
+
return new RegExp(pattern, 'm');
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new Error(`Invalid pattern /${pattern}/: ${error instanceof Error ? error.message : String(error)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sessionEnv(ctx: ExtensionContext): NodeJS.ProcessEnv {
|
|
166
|
+
const env: NodeJS.ProcessEnv = { ...process.env, PI_SESSION_ID: ctx.sessionManager.getSessionId() };
|
|
167
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
168
|
+
if (file !== undefined) env['PI_SESSION_FILE'] = file;
|
|
169
|
+
if (ctx.model !== undefined) {
|
|
170
|
+
env['PI_PROVIDER'] = ctx.model.provider;
|
|
171
|
+
env['PI_MODEL'] = ctx.model.id;
|
|
172
|
+
}
|
|
173
|
+
return env;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Process groups of running jobs, killed if this process exits before a session shutdown could stop them. */
|
|
177
|
+
const liveGroups = new Set<number>();
|
|
178
|
+
let exitHookInstalled = false;
|
|
179
|
+
function trackGroup(pid: number): void {
|
|
180
|
+
liveGroups.add(pid);
|
|
181
|
+
if (exitHookInstalled) return;
|
|
182
|
+
exitHookInstalled = true;
|
|
183
|
+
process.on('exit', () => {
|
|
184
|
+
for (const group of liveGroups) signalGroupId(group, 'SIGKILL');
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function signalGroupId(pid: number, signal: NodeJS.Signals): void {
|
|
189
|
+
try {
|
|
190
|
+
process.kill(process.platform === 'win32' ? pid : -pid, signal);
|
|
191
|
+
} catch {
|
|
192
|
+
// The process group is already gone.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const sleep = (ms: number, signal: AbortSignal | undefined) =>
|
|
197
|
+
new Promise<void>((done) => {
|
|
198
|
+
if (signal?.aborted === true) return done();
|
|
199
|
+
const timer = setTimeout(finish, ms);
|
|
200
|
+
function finish() {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
signal?.removeEventListener('abort', finish);
|
|
203
|
+
done();
|
|
204
|
+
}
|
|
205
|
+
signal?.addEventListener('abort', finish, { once: true });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
export interface ShellJobsOptions {
|
|
209
|
+
terminalsDir: (ctx: ExtensionContext) => string;
|
|
210
|
+
shellPath: () => string | undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface StartRequest {
|
|
214
|
+
command: string;
|
|
215
|
+
cwd: string;
|
|
216
|
+
title: string;
|
|
217
|
+
toolCallId: string;
|
|
218
|
+
watch?: { pattern: string; reason: string; debounceMs?: number };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Every background shell this runtime started, and the notifications they owe the agent. */
|
|
222
|
+
export function createShellJobs(options: ShellJobsOptions, deliver: (notice: ShellNotice) => void) {
|
|
223
|
+
const jobs = new Map<string, ShellJob>();
|
|
224
|
+
let nextId: number | undefined;
|
|
225
|
+
|
|
226
|
+
const allocateId = (dir: string): string => {
|
|
227
|
+
nextId ??= 100_000 + Math.floor(Math.random() * 800_000);
|
|
228
|
+
while (existsSync(join(dir, `${nextId}.txt`))) nextId += 1;
|
|
229
|
+
const id = String(nextId);
|
|
230
|
+
nextId += 1;
|
|
231
|
+
return id;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const writeAt = (job: ShellJob, text: string, position: number) => {
|
|
235
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
236
|
+
writeSync(job.fd, bytes, 0, bytes.length, position);
|
|
237
|
+
};
|
|
238
|
+
const append = (job: ShellJob, text: string | Buffer) => {
|
|
239
|
+
const bytes = typeof text === 'string' ? Buffer.from(text, 'utf8') : text;
|
|
240
|
+
writeSync(job.fd, bytes, 0, bytes.length, job.size);
|
|
241
|
+
job.size += bytes.length;
|
|
242
|
+
};
|
|
243
|
+
const refreshRunning = (job: ShellJob) => writeAt(job, pad(String((job.endedAt ?? Date.now()) - job.startedAt)), job.runningOffset);
|
|
244
|
+
const notify = (job: ShellJob) => {
|
|
245
|
+
for (const listener of [...job.listeners]) listener();
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const sendProgress = (job: ShellJob, watch: OutputWatch, detail: string) => {
|
|
249
|
+
watch.notices += 1;
|
|
250
|
+
deliver({ kind: 'progress', status: 'success', taskId: `${job.id}:task_progress:${watch.notices}`, shellId: job.id, title: watch.reason, toolCallId: job.toolCallId, detail, outputPath: job.path });
|
|
251
|
+
};
|
|
252
|
+
/** Trailing-edge debounce: the first match notifies at once, later ones in the window are counted and sent when it closes. */
|
|
253
|
+
const openWindow = (job: ShellJob, watch: OutputWatch) => {
|
|
254
|
+
watch.window = setTimeout(() => {
|
|
255
|
+
watch.window = undefined;
|
|
256
|
+
if (watch.unreported === 0) return;
|
|
257
|
+
sendProgress(job, watch, laterMatchesDetail(watch));
|
|
258
|
+
watch.unreported = 0;
|
|
259
|
+
openWindow(job, watch);
|
|
260
|
+
}, watch.debounceMs);
|
|
261
|
+
watch.window.unref();
|
|
262
|
+
};
|
|
263
|
+
const matchLine = (job: ShellJob, watch: OutputWatch, line: string) => {
|
|
264
|
+
const match = watch.regex.exec(line);
|
|
265
|
+
if (match === null) return;
|
|
266
|
+
watch.occurrences += 1;
|
|
267
|
+
watch.latest = match[0];
|
|
268
|
+
if (watch.window !== undefined) {
|
|
269
|
+
watch.unreported += 1;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
sendProgress(job, watch, firstMatchDetail(watch));
|
|
273
|
+
openWindow(job, watch);
|
|
274
|
+
};
|
|
275
|
+
const scan = (job: ShellJob, chunk: string, final: boolean) => {
|
|
276
|
+
const watch = job.watch;
|
|
277
|
+
if (watch === undefined) return;
|
|
278
|
+
const lines = (watch.partial + chunk).split('\n');
|
|
279
|
+
watch.partial = final ? '' : (lines.pop() ?? '');
|
|
280
|
+
for (const line of lines) if (line !== '' || final) matchLine(job, watch, line.replace(/\r$/, ''));
|
|
281
|
+
};
|
|
282
|
+
const flushWatch = (job: ShellJob) => {
|
|
283
|
+
const watch = job.watch;
|
|
284
|
+
if (watch === undefined) return;
|
|
285
|
+
scan(job, '', true);
|
|
286
|
+
if (watch.window !== undefined) clearTimeout(watch.window);
|
|
287
|
+
watch.window = undefined;
|
|
288
|
+
if (watch.unreported > 0) sendProgress(job, watch, laterMatchesDetail(watch));
|
|
289
|
+
watch.unreported = 0;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const finish = (job: ShellJob, status: JobStatus, exitCode: number, announce: boolean) => {
|
|
293
|
+
if (job.status !== 'running') return;
|
|
294
|
+
const endedAt = Date.now();
|
|
295
|
+
Object.assign(job, { status, exitCode, endedAt, observed: job.observed || job.awaiting > 0 });
|
|
296
|
+
if (job.refresh !== undefined) clearInterval(job.refresh);
|
|
297
|
+
writeAt(job, pad(status), job.statusOffset);
|
|
298
|
+
refreshRunning(job);
|
|
299
|
+
job.bodyEnd = job.size;
|
|
300
|
+
append(job, terminalFooter(exitCode, endedAt - job.startedAt, endedAt));
|
|
301
|
+
closeSync(job.fd);
|
|
302
|
+
if (announce) {
|
|
303
|
+
flushWatch(job);
|
|
304
|
+
if (!job.observed) {
|
|
305
|
+
const noticeStatus: NoticeStatus = status === 'succeeded' ? 'success' : status === 'failed' ? 'error' : 'aborted';
|
|
306
|
+
deliver({
|
|
307
|
+
kind: 'finished',
|
|
308
|
+
status: noticeStatus,
|
|
309
|
+
taskId: job.id,
|
|
310
|
+
shellId: job.id,
|
|
311
|
+
title: job.title,
|
|
312
|
+
toolCallId: job.toolCallId,
|
|
313
|
+
...(noticeStatus === 'error' ? { detail: `exit_code=${exitCode}` } : {}),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
} else if (job.watch?.window !== undefined) clearTimeout(job.watch.window);
|
|
317
|
+
notify(job);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const start = (ctx: ExtensionContext, request: StartRequest): ShellJob => {
|
|
321
|
+
if (!existsSync(request.cwd) || !statSync(request.cwd).isDirectory()) throw new Error(`Working directory ${request.cwd} does not exist.`);
|
|
322
|
+
const watchRegex = request.watch === undefined ? undefined : compile(request.watch.pattern);
|
|
323
|
+
const dir = options.terminalsDir(ctx);
|
|
324
|
+
mkdirSync(dir, { recursive: true });
|
|
325
|
+
const id = allocateId(dir);
|
|
326
|
+
const path = join(dir, `${id}.txt`);
|
|
327
|
+
const config = getShellConfig(options.shellPath());
|
|
328
|
+
const viaStdin = config.commandTransport === 'stdin';
|
|
329
|
+
const child = spawn(config.shell, viaStdin ? config.args : [...config.args, request.command], {
|
|
330
|
+
cwd: request.cwd,
|
|
331
|
+
env: sessionEnv(ctx),
|
|
332
|
+
detached: process.platform !== 'win32',
|
|
333
|
+
stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
334
|
+
windowsHide: true,
|
|
335
|
+
});
|
|
336
|
+
let spawnError: Error | undefined;
|
|
337
|
+
child.once('error', (error) => {
|
|
338
|
+
spawnError = error;
|
|
339
|
+
});
|
|
340
|
+
if (viaStdin) child.stdin?.end(request.command);
|
|
341
|
+
if (child.pid === undefined) throw new Error(`Could not start ${config.shell}${spawnError === undefined ? '' : `: ${spawnError.message}`}.`);
|
|
342
|
+
const startedAt = Date.now();
|
|
343
|
+
const header = terminalHeader({ pid: child.pid, cwd: request.cwd, command: request.command, title: request.title, startedAt });
|
|
344
|
+
const fd = openSync(path, 'w+');
|
|
345
|
+
let release: () => void = () => undefined;
|
|
346
|
+
const job: ShellJob = {
|
|
347
|
+
id,
|
|
348
|
+
pid: child.pid,
|
|
349
|
+
command: request.command,
|
|
350
|
+
cwd: request.cwd,
|
|
351
|
+
title: request.title,
|
|
352
|
+
toolCallId: request.toolCallId,
|
|
353
|
+
path,
|
|
354
|
+
startedAt,
|
|
355
|
+
fd,
|
|
356
|
+
headerBytes: Buffer.byteLength(header.text),
|
|
357
|
+
statusOffset: header.statusOffset,
|
|
358
|
+
runningOffset: header.runningOffset,
|
|
359
|
+
child,
|
|
360
|
+
watch:
|
|
361
|
+
request.watch === undefined || watchRegex === undefined
|
|
362
|
+
? undefined
|
|
363
|
+
: {
|
|
364
|
+
regex: watchRegex,
|
|
365
|
+
source: request.watch.pattern,
|
|
366
|
+
reason: request.watch.reason,
|
|
367
|
+
debounceMs: Math.max(MIN_DEBOUNCE_MS, request.watch.debounceMs ?? MIN_DEBOUNCE_MS),
|
|
368
|
+
occurrences: 0,
|
|
369
|
+
unreported: 0,
|
|
370
|
+
latest: '',
|
|
371
|
+
notices: 0,
|
|
372
|
+
window: undefined,
|
|
373
|
+
partial: '',
|
|
374
|
+
},
|
|
375
|
+
listeners: new Set(),
|
|
376
|
+
done: new Promise<void>((resolveDone) => {
|
|
377
|
+
release = resolveDone;
|
|
378
|
+
}),
|
|
379
|
+
size: 0,
|
|
380
|
+
bodyEnd: undefined,
|
|
381
|
+
status: 'running',
|
|
382
|
+
exitCode: undefined,
|
|
383
|
+
endedAt: undefined,
|
|
384
|
+
observed: false,
|
|
385
|
+
awaiting: 0,
|
|
386
|
+
refresh: undefined,
|
|
387
|
+
};
|
|
388
|
+
append(job, header.text);
|
|
389
|
+
job.refresh = setInterval(() => refreshRunning(job), HEADER_REFRESH_MS);
|
|
390
|
+
job.refresh.unref();
|
|
391
|
+
const onData = (chunk: Buffer) => {
|
|
392
|
+
if (job.status !== 'running') return;
|
|
393
|
+
append(job, chunk);
|
|
394
|
+
scan(job, chunk.toString('utf8'), false);
|
|
395
|
+
notify(job);
|
|
396
|
+
};
|
|
397
|
+
child.stdout?.on('data', onData);
|
|
398
|
+
child.stderr?.on('data', onData);
|
|
399
|
+
child.on('error', (error) => {
|
|
400
|
+
if (job.status === 'running') append(job, `${error.message}\n`);
|
|
401
|
+
finish(job, 'failed', 127, true);
|
|
402
|
+
release();
|
|
403
|
+
});
|
|
404
|
+
child.on('close', (code, signal) => {
|
|
405
|
+
liveGroups.delete(job.pid);
|
|
406
|
+
const { status, exitCode } = exitStatus(code, signal);
|
|
407
|
+
finish(job, status, exitCode, true);
|
|
408
|
+
release();
|
|
409
|
+
});
|
|
410
|
+
trackGroup(job.pid);
|
|
411
|
+
jobs.set(id, job);
|
|
412
|
+
return job;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/** The command's output so far: the file between the header and the footer. */
|
|
416
|
+
const output = (job: ShellJob): string => {
|
|
417
|
+
const length = Math.max(0, (job.bodyEnd ?? job.size) - job.headerBytes);
|
|
418
|
+
const buffer = Buffer.alloc(length);
|
|
419
|
+
const fd = job.status === 'running' ? job.fd : openSync(job.path, 'r');
|
|
420
|
+
try {
|
|
421
|
+
readSync(fd, buffer, 0, length, job.headerBytes);
|
|
422
|
+
} finally {
|
|
423
|
+
if (fd !== job.fd) closeSync(fd);
|
|
424
|
+
}
|
|
425
|
+
return buffer.toString('utf8');
|
|
426
|
+
};
|
|
427
|
+
const fileSize = (job: ShellJob) => (job.status === 'running' ? fstatSync(job.fd).size : statSync(job.path).size);
|
|
428
|
+
|
|
429
|
+
/** Resolves when the job ends, prints, or `ms` passes, or the signal aborts. A job that ends meanwhile counts as observed. */
|
|
430
|
+
const change = (job: ShellJob, ms: number, signal: AbortSignal | undefined) =>
|
|
431
|
+
new Promise<void>((done) => {
|
|
432
|
+
if (job.status !== 'running' || signal?.aborted === true) return done();
|
|
433
|
+
job.awaiting += 1;
|
|
434
|
+
const timer = setTimeout(stop, ms);
|
|
435
|
+
function stop() {
|
|
436
|
+
clearTimeout(timer);
|
|
437
|
+
job.awaiting -= 1;
|
|
438
|
+
job.listeners.delete(stop);
|
|
439
|
+
signal?.removeEventListener('abort', stop);
|
|
440
|
+
done();
|
|
441
|
+
}
|
|
442
|
+
job.listeners.add(stop);
|
|
443
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const awaitJob = async (shellId: string, waitMs: number, pattern: string | undefined, signal: AbortSignal | undefined): Promise<string> => {
|
|
447
|
+
const job = jobs.get(shellId.trim());
|
|
448
|
+
if (job === undefined) throw new Error(`Error awaiting task: No shell found for id ${shellId}`);
|
|
449
|
+
const regex = pattern === undefined || pattern === '' ? undefined : compile(pattern);
|
|
450
|
+
const deadline = Date.now() + waitMs;
|
|
451
|
+
for (;;) {
|
|
452
|
+
if (job.status !== 'running') {
|
|
453
|
+
job.observed = true;
|
|
454
|
+
return [`Task completed in ${(job.endedAt ?? Date.now()) - job.startedAt}ms with exit code: ${job.exitCode}.`, `output_file_path: ${job.path}`, `output_length: ${fileSize(job)}`].join('\n');
|
|
455
|
+
}
|
|
456
|
+
const matched = regex?.exec(output(job))?.[0];
|
|
457
|
+
const remaining = deadline - Date.now();
|
|
458
|
+
if (matched !== undefined || remaining <= 0 || signal?.aborted === true) {
|
|
459
|
+
const suffix = regex === undefined ? '' : matched === undefined ? ' Pattern did NOT match.' : ` Pattern matched: ${matched}`;
|
|
460
|
+
return [`Task still running after ${Date.now() - job.startedAt}ms...${suffix}`, `output_file_path: ${job.path}`, `output_length: ${fileSize(job)}`].join('\n');
|
|
461
|
+
}
|
|
462
|
+
await change(job, remaining, signal);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const signalGroup = (job: ShellJob, signal: NodeJS.Signals) => signalGroupId(job.pid, signal);
|
|
467
|
+
|
|
468
|
+
/** Session end: every running job is stopped and recorded as aborted, with no notification. */
|
|
469
|
+
const shutdown = () => {
|
|
470
|
+
for (const job of jobs.values()) {
|
|
471
|
+
if (job.status !== 'running') continue;
|
|
472
|
+
signalGroup(job, 'SIGTERM');
|
|
473
|
+
const kill = setTimeout(() => signalGroup(job, 'SIGKILL'), KILL_GRACE_MS);
|
|
474
|
+
kill.unref();
|
|
475
|
+
finish(job, 'aborted', 128 + constants.signals.SIGTERM, false);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
return {
|
|
479
|
+
start,
|
|
480
|
+
output,
|
|
481
|
+
awaitJob,
|
|
482
|
+
change,
|
|
483
|
+
shutdown,
|
|
484
|
+
running: () => [...jobs.values()].filter((job) => job.status === 'running'),
|
|
485
|
+
stop: (job: ShellJob) => {
|
|
486
|
+
signalGroup(job, 'SIGTERM');
|
|
487
|
+
setTimeout(() => signalGroup(job, 'SIGKILL'), KILL_GRACE_MS).unref();
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
type ShellJobs = ReturnType<typeof createShellJobs>;
|
|
493
|
+
|
|
494
|
+
const NotifyParams = Type.Object({
|
|
495
|
+
pattern: Type.String({ description: 'Regex pattern matched against each line of stdout/stderr output. Output redirected only to a file will not trigger it. Do not match all outputs.' }),
|
|
496
|
+
reason: Type.String({ description: '5 or less words describing why you are watching for this output. It titles the notification.' }),
|
|
497
|
+
debounce_ms: Type.Optional(Type.Number({ description: 'Milliseconds that must elapse between notifications. The minimum is 5000ms.' })),
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const BackgroundParams = Type.Object({
|
|
501
|
+
command: Type.String({ description: 'The command to execute' }),
|
|
502
|
+
description: Type.Optional(Type.String({ description: 'Very short phrase naming what this command does; it titles the notifications about it. Avoid leading with verbs or using trailing periods' })),
|
|
503
|
+
block_until_ms: Type.Optional(
|
|
504
|
+
Type.Number({
|
|
505
|
+
description:
|
|
506
|
+
'How long to wait for the command in the foreground before moving it to the background, in milliseconds. 0 (the default) starts it in the background at once. A command that finishes within the wait returns its output and sends no notification.',
|
|
507
|
+
}),
|
|
508
|
+
),
|
|
509
|
+
notify_on_output: Type.Optional(
|
|
510
|
+
Type.Object(NotifyParams.properties, { description: 'Optional output notification config. Each output line which matches the pattern will notify you. ONLY set this when the user explicitly requests monitoring.' }),
|
|
511
|
+
),
|
|
512
|
+
working_directory: Type.Optional(Type.String({ description: 'The absolute path to the working directory to execute the command in (defaults to current directory)' })),
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const AwaitParams = Type.Object({
|
|
516
|
+
shell_id: Type.Optional(Type.String({ description: 'Optional shell id to poll. If omitted, this tool sleeps for the full block_until_ms duration and then returns. Required when block_until_ms is 0.' })),
|
|
517
|
+
block_until_ms: Type.Optional(
|
|
518
|
+
Type.Number({
|
|
519
|
+
maximum: MAX_AWAIT_MS,
|
|
520
|
+
description: 'Max sleep time to block before returning (in milliseconds). Defaults to 30000ms. Set to 0 only together with shell_id, for a non-blocking status check of that shell. Must not exceed 7140000 (119 minutes).',
|
|
521
|
+
}),
|
|
522
|
+
),
|
|
523
|
+
pattern: Type.Optional(
|
|
524
|
+
Type.String({
|
|
525
|
+
description:
|
|
526
|
+
'Block until the regex matches the stdout/stderr output (or the command completes). Matches anywhere in the shell output, not just new output. Does not match the output file header or footer. JavaScript regex syntax, compiled in multiline mode so ^ and $ match at line boundaries.',
|
|
527
|
+
}),
|
|
528
|
+
),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const BACKGROUND_DESCRIPTION = [
|
|
532
|
+
'Run a shell command in the background, or wait up to block_until_ms for it in the foreground and then move it to the background. Use it for work that should keep running while you do other things: dev servers, watchers, slow scans and builds, and a watcher whose output should wake you. Use bash for ordinary commands.',
|
|
533
|
+
'',
|
|
534
|
+
'The result names a shell ID, a PID, and an output file. The file starts with a header (pid, cwd, command, title, status, started_at, running_for_ms, refreshed every 5000ms) and ends with a footer (exit_code, elapsed_ms, ended_at) once the command finishes. After your turn ends you are notified when the command finishes, and on each notify_on_output match. Do not wait for those: keep working or end your turn. Poll with AwaitShell only when you are blocked on this job. Stop a job with `kill <pid>`.',
|
|
535
|
+
].join('\n');
|
|
536
|
+
|
|
537
|
+
const AWAIT_DESCRIPTION = [
|
|
538
|
+
'Check or poll a backgrounded shell job. For work that does not have a shell id, you can omit the shell_id arg to sleep for the full `block_until_ms` duration (prefer this over sleeping in the shell). At the end of your turn, you will be notified about any unawaited jobs that completed. If you think a job completed (e.g. because you killed it), observe it with AwaitShell to skip the notification, because stale notifications can confuse the user.',
|
|
539
|
+
'',
|
|
540
|
+
'Prefer NOT to poll reflexively with AwaitShell. Multitask on independent work while backgrounded jobs run, or finish your turn and rely on the end-of-turn completion notification. Poll with AwaitShell only when one of the following is true:',
|
|
541
|
+
"- Your very next step is blocked on this specific job's result and you have no other productive work to do, OR",
|
|
542
|
+
'- The task requires close monitoring (see shell guidance below).',
|
|
543
|
+
'- NEVER USE THIS TO POLL OR WAIT VACUOUSLY FOR A SUBAGENT LAUNCHED WITH THE Task TOOL — rely on the end-of-turn completion notification instead (it is delivered as soon as the subagent finishes; guessing a wait time is inefficient).',
|
|
544
|
+
'- Shell: only poll with AwaitShell when the command requires close monitoring. Close monitoring means a long-running job that can silently hang, degrade, or need a course correction before it completes — e.g. training runs, eval runs, deployments, long builds, datagen pipelines, DB migrations, large data transfers. For fire-and-forget commands (tests, installs, dev servers/watchers, short scripts, etc.) the completion notification is enough — start them, keep working, and only poll with AwaitShell later if you end up blocked on the result.',
|
|
545
|
+
"- Shell sanity check (regardless of close monitoring): when you spawn a command directly into the background (`block_until_ms: 0`), do a single status check by reading the output file to confirm the command didn't fail to start. This is a one-shot smoke check, not a polling loop.",
|
|
546
|
+
'- Shell close-monitoring guidance (only applies in the close-monitoring case above):',
|
|
547
|
+
" - HARD STOPPING CONSTRAINT: once you've decided to actively poll, don't stop until (a) the job terminates, (b) the command reaches a healthy steady state (only for non-terminating commands, e.g. dev server/watcher), or (c) the command is hung — follow the hang guidance below.",
|
|
548
|
+
' - Waiting until a regex matches the output can be useful for e.g. known startup/status/error logs.',
|
|
549
|
+
" - Size `block_until_ms` to the command's expected runtime. When waiting further, avoid round 5-minute waits: prefer slices of 60–270s (keeps prompt cache warm) or 1200s+ (one cache miss buys a long wait).",
|
|
550
|
+
' - Output file header has `pid` and `running_for_ms` (updated every 5000ms).',
|
|
551
|
+
' - When finished, footer with `exit_code` and `elapsed_ms` appears (regex only matches the body, not header/footer).',
|
|
552
|
+
' - If the command is taking longer than expected and appears hung (use judgment based on command type), kill the process if safe to do so using the pid in the header. If possible, fix the hang and proceed.',
|
|
553
|
+
].join('\n');
|
|
554
|
+
|
|
555
|
+
interface AwaitDetails {
|
|
556
|
+
shellId?: string;
|
|
557
|
+
sleptMs?: number;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const SUBAGENT_NOTE = 'This subagent stops its background jobs when it finishes, so await any job whose result you need before you finish.';
|
|
561
|
+
|
|
562
|
+
const clampWait = (value: number | undefined, fallback: number) => Math.min(MAX_AWAIT_MS, Math.max(0, Math.round(value ?? fallback)));
|
|
563
|
+
|
|
564
|
+
function seconds(ms: number): string {
|
|
565
|
+
const value = ms / 1000;
|
|
566
|
+
return Number.isInteger(value) ? `${value}s` : `${value.toFixed(1)}s`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function shellNoticeCardText(message: { content: string | { type: string; text?: string }[]; details?: unknown }): string {
|
|
570
|
+
const notice = message.details as Partial<ShellNotice> | undefined;
|
|
571
|
+
if (notice?.title === undefined || notice.shellId === undefined) return typeof message.content === 'string' ? message.content : message.content.map((part) => part.text ?? '').join('');
|
|
572
|
+
const head = `${notice.title} · ${notice.kind === 'progress' ? 'output matched' : notice.status} · shell ${notice.shellId}`;
|
|
573
|
+
return notice.detail === undefined ? head : `${head}\n${notice.detail}`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export interface BackgroundShellOptions {
|
|
577
|
+
terminalsDir: (ctx: ExtensionContext) => string;
|
|
578
|
+
/** One-shot runs that may wait at settle for their jobs, as Cursor's print mode does: print and JSON runs, not subagents. */
|
|
579
|
+
waitsForJobs: (ctx: ExtensionContext) => boolean;
|
|
580
|
+
/** A subagent's jobs end with it. */
|
|
581
|
+
endsWithTurn: boolean;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** bash_background and AwaitShell, plus the notifications their jobs send. */
|
|
585
|
+
export function registerBackgroundShell(pi: ExtensionAPI, options: BackgroundShellOptions): { hasRunningJobs: () => boolean } {
|
|
586
|
+
const pending: ShellNotice[] = [];
|
|
587
|
+
const arrivals = new Set<() => void>();
|
|
588
|
+
let mode: ExtensionContext['mode'] | undefined;
|
|
589
|
+
let retired = false;
|
|
590
|
+
const deliver = (notice: ShellNotice) => {
|
|
591
|
+
if (retired) return;
|
|
592
|
+
if (mode !== undefined && isOneShot(mode)) {
|
|
593
|
+
pending.push(notice);
|
|
594
|
+
for (const arrived of [...arrivals]) arrived();
|
|
595
|
+
} else pi.sendMessage({ customType: SHELL_NOTICE_TYPE, content: noticeText(notice), display: true, details: notice }, { triggerTurn: true, deliverAs: 'followUp' });
|
|
596
|
+
};
|
|
597
|
+
const nextArrival = () =>
|
|
598
|
+
new Promise<void>((done) => {
|
|
599
|
+
const arrived = () => {
|
|
600
|
+
arrivals.delete(arrived);
|
|
601
|
+
done();
|
|
602
|
+
};
|
|
603
|
+
arrivals.add(arrived);
|
|
604
|
+
});
|
|
605
|
+
const jobs: ShellJobs = createShellJobs(
|
|
606
|
+
{
|
|
607
|
+
terminalsDir: options.terminalsDir,
|
|
608
|
+
shellPath: () => {
|
|
609
|
+
const value = (pi.getSettings() as { shellPath?: unknown }).shellPath;
|
|
610
|
+
return typeof value === 'string' && value !== '' ? value.replace(/^~(?=$|\/)/, process.env['HOME'] ?? '~') : undefined;
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
deliver,
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
pi.registerMessageRenderer?.(SHELL_NOTICE_TYPE, (message) => new Text(shellNoticeCardText(message), 0, 0));
|
|
617
|
+
pi.on('session_start', (_event, ctx) => {
|
|
618
|
+
mode = ctx.mode;
|
|
619
|
+
retired = false;
|
|
620
|
+
});
|
|
621
|
+
// Cursor's print mode holds its exit for background shells and reports each notification as a turn.
|
|
622
|
+
pi.on('agent_before_settle', async (event, ctx) => {
|
|
623
|
+
if (!isOneShot(ctx.mode) || event.outcome === 'aborted') return undefined;
|
|
624
|
+
if (pending.length === 0 && (!options.waitsForJobs(ctx) || jobs.running().length === 0)) return undefined;
|
|
625
|
+
if (pending.length === 0) await nextArrival();
|
|
626
|
+
const ready = pending.splice(0);
|
|
627
|
+
if (ready.length === 0) return undefined;
|
|
628
|
+
return {
|
|
629
|
+
entries: [...event.entries, ...ready.map((notice) => ({ type: 'custom_message' as const, customType: SHELL_NOTICE_TYPE, content: noticeText(notice), display: true, details: notice }))],
|
|
630
|
+
continue: true,
|
|
631
|
+
};
|
|
632
|
+
});
|
|
633
|
+
pi.on('session_shutdown', () => {
|
|
634
|
+
retired = true;
|
|
635
|
+
pending.splice(0);
|
|
636
|
+
for (const arrived of [...arrivals]) arrived();
|
|
637
|
+
jobs.shutdown();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
pi.registerTool({
|
|
641
|
+
name: BACKGROUND_TOOL,
|
|
642
|
+
label: 'bash (background)',
|
|
643
|
+
description: BACKGROUND_DESCRIPTION,
|
|
644
|
+
promptSnippet: 'Run a shell command in the background, with notifications when it finishes or prints a watched line',
|
|
645
|
+
parameters: BackgroundParams,
|
|
646
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
647
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
648
|
+
const cwd = params.working_directory === undefined || params.working_directory.trim() === '' ? ctx.cwd : resolve(ctx.cwd, params.working_directory);
|
|
649
|
+
const title = params.description?.trim() || params.command.trim().split('\n')[0]?.slice(0, 80) || params.command;
|
|
650
|
+
const watch = params.notify_on_output;
|
|
651
|
+
const job = jobs.start(ctx, {
|
|
652
|
+
command: params.command,
|
|
653
|
+
cwd,
|
|
654
|
+
title,
|
|
655
|
+
toolCallId,
|
|
656
|
+
...(watch === undefined ? {} : { watch: { pattern: watch.pattern, reason: watch.reason, ...(watch.debounce_ms === undefined ? {} : { debounceMs: watch.debounce_ms }) } }),
|
|
657
|
+
});
|
|
658
|
+
const blockMs = clampWait(params.block_until_ms, 0);
|
|
659
|
+
const details = { shellId: job.id, pid: job.pid, outputPath: job.path };
|
|
660
|
+
const note = options.endsWithTurn ? `\n${SUBAGENT_NOTE}` : '';
|
|
661
|
+
if (blockMs === 0) return { content: [{ type: 'text', text: `${launchText(job)}${note}` }], details };
|
|
662
|
+
const deadline = Date.now() + blockMs;
|
|
663
|
+
while (job.status === 'running' && Date.now() < deadline && signal?.aborted !== true) await jobs.change(job, deadline - Date.now(), signal);
|
|
664
|
+
if (signal?.aborted === true && job.status === 'running') {
|
|
665
|
+
jobs.stop(job);
|
|
666
|
+
throw new Error('The command was stopped because the call was cancelled.');
|
|
667
|
+
}
|
|
668
|
+
if (job.status !== 'running') {
|
|
669
|
+
job.observed = true;
|
|
670
|
+
return { content: [{ type: 'text', text: finishedText(job.exitCode ?? 1, (job.endedAt ?? Date.now()) - job.startedAt, jobs.output(job), job.path) }], details: { ...details, exitCode: job.exitCode } };
|
|
671
|
+
}
|
|
672
|
+
return { content: [{ type: 'text', text: `${movedText(job, blockMs, jobs.output(job))}${note}` }], details };
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
pi.registerTool({
|
|
677
|
+
name: AWAIT_TOOL,
|
|
678
|
+
label: 'AwaitShell',
|
|
679
|
+
description: AWAIT_DESCRIPTION,
|
|
680
|
+
promptSnippet: 'Poll or wait for a background shell job, or sleep',
|
|
681
|
+
parameters: AwaitParams,
|
|
682
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
683
|
+
async execute(_toolCallId, params, signal): Promise<{ content: { type: 'text'; text: string }[]; details: AwaitDetails }> {
|
|
684
|
+
const waitMs = clampWait(params.block_until_ms, DEFAULT_AWAIT_MS);
|
|
685
|
+
const shellId = params.shell_id?.trim();
|
|
686
|
+
if (shellId === undefined || shellId === '') {
|
|
687
|
+
if (waitMs === 0) throw new Error('block_until_ms 0 needs shell_id: it checks one shell without waiting.');
|
|
688
|
+
const started = Date.now();
|
|
689
|
+
await sleep(waitMs, signal);
|
|
690
|
+
return { content: [{ type: 'text', text: `Slept for ${seconds(Date.now() - started >= waitMs ? waitMs : Date.now() - started)}.` }], details: { sleptMs: Date.now() - started } };
|
|
691
|
+
}
|
|
692
|
+
const text = await jobs.awaitJob(shellId, waitMs, params.pattern, signal);
|
|
693
|
+
return { content: [{ type: 'text', text }], details: { shellId } };
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
return { hasRunningJobs: () => jobs.running().length > 0 };
|
|
698
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,16 +5,17 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
|
5
5
|
import { aliasInputResult, aliasTable, packageSkillNames } from './aliases.ts';
|
|
6
6
|
import { registerAskQuestion } from './ask.ts';
|
|
7
7
|
import { aliasAutocomplete } from './autocomplete.ts';
|
|
8
|
+
import { registerBackgroundShell } from './background-shell.ts';
|
|
8
9
|
import { childIdentity } from './child-env.ts';
|
|
9
10
|
import { registerGenerateImage } from './generate-image.ts';
|
|
10
11
|
import { registerLoop } from './loop.ts';
|
|
11
12
|
import { registerMode } from './mode.ts';
|
|
12
13
|
import { canAsk, isOneShot } from './modes.ts';
|
|
13
|
-
import { artifactsDir, PACKAGE_ROOT, projectsDir, rootStoreDir, rulePath, SKILLS_DIR, storeDir, subagentDir } from './paths.ts';
|
|
14
|
+
import { artifactsDir, PACKAGE_ROOT, projectsDir, rootStoreDir, rulePath, SKILLS_DIR, storeDir, subagentDir, terminalsDir } from './paths.ts';
|
|
14
15
|
import { registerPlanMode } from './plan.ts';
|
|
15
16
|
import { registerReadonlyShell } from './readonly-shell.ts';
|
|
16
17
|
import { migrateLegacyRule, modelsSection, readRule } from './rule.ts';
|
|
17
|
-
import { alwaysAppliedRules, hostSection, subagentsSection } from './sections.ts';
|
|
18
|
+
import { alwaysAppliedRules, hostSection, subagentsSection, todayText } from './sections.ts';
|
|
18
19
|
import { registerSubscriptions } from './subscriptions/index.ts';
|
|
19
20
|
import { type AgentDefinition, loadAgentDefinitions } from './task/agents.ts';
|
|
20
21
|
import { registerAgentStatus } from './task/status.ts';
|
|
@@ -39,6 +40,7 @@ function hostSections(pi: ExtensionAPI, ctx: ExtensionContext, agents: readonly
|
|
|
39
40
|
const activeTools = pi.getActiveTools();
|
|
40
41
|
return {
|
|
41
42
|
pstack_host: hostSection({
|
|
43
|
+
today: todayText(new Date()),
|
|
42
44
|
packageRoot: PACKAGE_ROOT,
|
|
43
45
|
skillsDir: SKILLS_DIR,
|
|
44
46
|
skillNames: packageSkillNames(pi.getCommands()),
|
|
@@ -51,6 +53,7 @@ function hostSections(pi: ExtensionAPI, ctx: ExtensionContext, agents: readonly
|
|
|
51
53
|
rootStoreDir: rootStoreDir(ctx.cwd, ctx.sessionManager.getSessionId(), childIdentity.rootStore),
|
|
52
54
|
projectsDir: projectsDir(ctx.cwd),
|
|
53
55
|
artifactsDir: artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()),
|
|
56
|
+
terminalsDir: terminalsDir(ctx.cwd),
|
|
54
57
|
loopAvailable: !isOneShot(ctx.mode),
|
|
55
58
|
questionsAvailable: canAsk(ctx),
|
|
56
59
|
activeTools,
|
|
@@ -122,7 +125,15 @@ export default function piPstack(pi: ExtensionAPI): void {
|
|
|
122
125
|
return undefined;
|
|
123
126
|
},
|
|
124
127
|
});
|
|
125
|
-
|
|
128
|
+
// A readonly agent's shell is the sandboxed one, and a background job would run outside that sandbox.
|
|
129
|
+
const shells = childIdentity.readonly
|
|
130
|
+
? undefined
|
|
131
|
+
: registerBackgroundShell(pi, {
|
|
132
|
+
terminalsDir: (ctx) => terminalsDir(ctx.cwd),
|
|
133
|
+
waitsForJobs: (ctx) => childIdentity.depth === 0 && (ctx.mode === 'print' || ctx.mode === 'json'),
|
|
134
|
+
endsWithTurn: childIdentity.depth > 0,
|
|
135
|
+
});
|
|
136
|
+
const loop = registerLoop(pi, { hasPendingWork: () => task.hasBackgroundWork() || shells?.hasRunningJobs() === true });
|
|
126
137
|
const todos = registerTodoWrite(pi, childIdentity.readonly ? {} : { onWrite: plan.syncTodos });
|
|
127
138
|
registerAskQuestion(pi);
|
|
128
139
|
if (childIdentity.parentPid !== undefined) watchParent(pi, childIdentity.parentPid);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The Linux read-only sandbox: bubblewrap with every namespace unshared, the whole filesystem bound read-only, and a
|
|
2
|
+
// seccomp filter that refuses Unix sockets. bubblewrap installs the filter itself, so this needs no nested user
|
|
3
|
+
// namespace, which hosts with kernel.apparmor_restrict_unprivileged_userns=1 (Ubuntu 24.04 and later) refuse.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { accessSync, closeSync, constants, existsSync, mkdtempSync, openSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { delimiter, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const LD_ABS_W = 0x20;
|
|
10
|
+
const JEQ_K = 0x15;
|
|
11
|
+
const JGE_K = 0x35;
|
|
12
|
+
const RET_K = 0x06;
|
|
13
|
+
const SECCOMP_RET_ALLOW = 0x7fff_0000;
|
|
14
|
+
const SECCOMP_RET_ERRNO_EPERM = 0x0005_0001;
|
|
15
|
+
const SECCOMP_RET_KILL_PROCESS = 0x8000_0000;
|
|
16
|
+
const X32_SYSCALL_BIT = 0x4000_0000;
|
|
17
|
+
const AF_UNIX = 1;
|
|
18
|
+
|
|
19
|
+
const ARCHES: Record<string, { audit: number; socket: number; ioUringSetup: number }> = {
|
|
20
|
+
x64: { audit: 0xc000_003e, socket: 41, ioUringSetup: 425 },
|
|
21
|
+
arm64: { audit: 0xc000_00b7, socket: 198, ioUringSetup: 425 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const instruction = (code: number, jt: number, jf: number, k: number): Buffer => {
|
|
25
|
+
const bytes = Buffer.alloc(8);
|
|
26
|
+
bytes.writeUInt16LE(code, 0);
|
|
27
|
+
bytes.writeUInt8(jt, 2);
|
|
28
|
+
bytes.writeUInt8(jf, 3);
|
|
29
|
+
bytes.writeUInt32LE(k >>> 0, 4);
|
|
30
|
+
return bytes;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A classic BPF seccomp program: socket(AF_UNIX, ...) and io_uring_setup fail with EPERM, any other architecture or
|
|
35
|
+
* the x32 ABI kills the process, and everything else is allowed. Jumps are relative to the next instruction.
|
|
36
|
+
*/
|
|
37
|
+
export function seccompFilter(arch: string): Buffer {
|
|
38
|
+
const target = ARCHES[arch];
|
|
39
|
+
if (target === undefined) throw new Error(`the read-only shell has no seccomp filter for ${arch}`);
|
|
40
|
+
return Buffer.concat([
|
|
41
|
+
instruction(LD_ABS_W, 0, 0, 4), // 0: architecture
|
|
42
|
+
instruction(JEQ_K, 0, 8, target.audit), // 1: native → 2, otherwise → 10
|
|
43
|
+
instruction(LD_ABS_W, 0, 0, 0), // 2: syscall number
|
|
44
|
+
instruction(JGE_K, 6, 0, X32_SYSCALL_BIT), // 3: x32 → 10
|
|
45
|
+
instruction(JEQ_K, 0, 2, target.socket), // 4: socket → 5, otherwise → 7
|
|
46
|
+
instruction(LD_ABS_W, 0, 0, 16), // 5: low word of the first argument, the domain
|
|
47
|
+
instruction(JEQ_K, 2, 1, AF_UNIX), // 6: AF_UNIX → 9, otherwise → 8
|
|
48
|
+
instruction(JEQ_K, 1, 0, target.ioUringSetup), // 7: io_uring_setup → 9, otherwise → 8
|
|
49
|
+
instruction(RET_K, 0, 0, SECCOMP_RET_ALLOW), // 8
|
|
50
|
+
instruction(RET_K, 0, 0, SECCOMP_RET_ERRNO_EPERM), // 9
|
|
51
|
+
instruction(RET_K, 0, 0, SECCOMP_RET_KILL_PROCESS), // 10
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
56
|
+
|
|
57
|
+
/** bubblewrap's arguments before the command: nothing is writable and nothing outside is reachable. */
|
|
58
|
+
export function bwrapArgs(cwd: string): string[] {
|
|
59
|
+
return ['--new-session', '--die-with-parent', '--unshare-all', '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--cap-drop', 'ALL', '--chdir', cwd, '--seccomp', '3'];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface Sandbox {
|
|
63
|
+
bwrap: string;
|
|
64
|
+
filterPath: string;
|
|
65
|
+
shell: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The first executable `name` on PATH, so every command runs the bubblewrap the check ran. */
|
|
69
|
+
function onPath(name: string): string | undefined {
|
|
70
|
+
for (const dir of (process.env['PATH'] ?? '').split(delimiter).filter((entry) => entry !== '')) {
|
|
71
|
+
const candidate = join(dir, name);
|
|
72
|
+
try {
|
|
73
|
+
accessSync(candidate, constants.X_OK);
|
|
74
|
+
return candidate;
|
|
75
|
+
} catch {
|
|
76
|
+
// Not here; keep looking.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let verified: { sandbox: Sandbox } | { error: Error } | undefined;
|
|
83
|
+
|
|
84
|
+
/** The checked sandbox, or the error that stopped it; either is kept, so a failed host never retries into a weaker state. */
|
|
85
|
+
function verifiedSandbox(): Sandbox {
|
|
86
|
+
verified ??= (() => {
|
|
87
|
+
try {
|
|
88
|
+
return { sandbox: checkSandbox() };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { error: error instanceof Error ? error : new Error(String(error)) };
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
93
|
+
if ('error' in verified) throw verified.error;
|
|
94
|
+
return verified.sandbox;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Writes the filter into a private directory and checks that bubblewrap can load it here. */
|
|
98
|
+
function checkSandbox(): Sandbox {
|
|
99
|
+
const filter = seccompFilter(process.arch);
|
|
100
|
+
const bwrap = onPath('bwrap');
|
|
101
|
+
if (bwrap === undefined) throw new Error('bubblewrap (bwrap) is not available: no bwrap on PATH');
|
|
102
|
+
const dir = mkdtempSync(join(tmpdir(), 'pi-pstack-ro-'));
|
|
103
|
+
const filterPath = join(dir, 'unix-block.bpf');
|
|
104
|
+
writeFileSync(filterPath, filter, { mode: 0o400 });
|
|
105
|
+
const shell = existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh';
|
|
106
|
+
const fd = openSync(filterPath, 'r');
|
|
107
|
+
try {
|
|
108
|
+
const probe = spawnSync(bwrap, [...bwrapArgs('/'), '--', shell, '-c', 'exit 0'], { stdio: ['ignore', 'ignore', 'pipe', fd], encoding: 'utf8', timeout: 15_000 });
|
|
109
|
+
if (probe.error !== undefined) throw new Error(`bubblewrap (bwrap) could not run: ${probe.error.message}`);
|
|
110
|
+
if (probe.status !== 0) throw new Error(`bubblewrap could not start the read-only sandbox: ${probe.stderr.trim() || `exit ${probe.status}`}`);
|
|
111
|
+
} finally {
|
|
112
|
+
closeSync(fd);
|
|
113
|
+
}
|
|
114
|
+
return { bwrap, filterPath, shell };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The shell command that runs `command` inside the read-only sandbox, or an error when no sandbox can start. */
|
|
118
|
+
export function linuxReadonlyCommand(command: string, cwd: string): string {
|
|
119
|
+
const { bwrap, filterPath, shell } = verifiedSandbox();
|
|
120
|
+
return `exec ${quote(bwrap)} ${bwrapArgs(cwd).map(quote).join(' ')} -- ${quote(shell)} -c ${quote(command)} 3< ${quote(filterPath)}`;
|
|
121
|
+
}
|
package/src/loop.ts
CHANGED
|
@@ -258,7 +258,7 @@ function registerLoopTool(pi: ExtensionAPI, registry: LoopRegistry): void {
|
|
|
258
258
|
name: 'Loop',
|
|
259
259
|
label: 'Loop',
|
|
260
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.',
|
|
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. Run the prompt once yourself in the turn that arms the loop; the first scheduled run comes after the interval or wake_in. Then confirm the interval, that the prompt already ran once, and when the first run comes. Each result names the loop ID. With several loops running, pass loop_id to wake or stop the intended loop.',
|
|
262
262
|
promptSnippet: 'Start a /loop, schedule its next wake, or stop it',
|
|
263
263
|
parameters: LoopParams,
|
|
264
264
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
package/src/paths.ts
CHANGED
|
@@ -75,6 +75,11 @@ export function artifactsDir(cwd: string, sessionId: string): string {
|
|
|
75
75
|
return join(workspaceStateDir(cwd), 'artifacts', sessionId);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** Background shell jobs' output files, `<shell id>.txt`, as Cursor keeps them in a workspace's terminals folder. */
|
|
79
|
+
export function terminalsDir(cwd: string): string {
|
|
80
|
+
return join(workspaceStateDir(cwd), 'terminals');
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
export function worktreesDir(cwd: string): string {
|
|
79
84
|
return join(workspaceStateDir(cwd), 'worktrees');
|
|
80
85
|
}
|
package/src/readonly-shell.ts
CHANGED
|
@@ -2,12 +2,14 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
|
|
3
3
|
import type { SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime';
|
|
4
4
|
import { type BashSpawnContext, createBashTool, type ExtensionAPI, type ExtensionToolContext, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { linuxReadonlyCommand } from './linux-readonly-sandbox.ts';
|
|
5
6
|
|
|
6
7
|
// Cursor's readonly subagent: "no file edits, no state-changing shell commands". The OS sandbox enforces it:
|
|
7
8
|
// nothing may be written and no host may be reached, so a pipe, substitution, or pager cannot get around it.
|
|
9
|
+
// Linux uses bubblewrap directly (linux-readonly-sandbox.ts); macOS uses sandbox-runtime's Seatbelt policy below.
|
|
8
10
|
// denyWrite names the paths the sandbox makes writable for itself (/tmp/claude is a dependency default), and
|
|
9
11
|
// denyRead drops the dependency's writable home defaults (~/.npm/_logs, ~/.claude/debug). Seatbelt is
|
|
10
|
-
// last-match-wins with deny emitted after allow
|
|
12
|
+
// last-match-wins with deny emitted after allow.
|
|
11
13
|
// Unix sockets must stay blocked too: a host service can mutate files on a command's behalf.
|
|
12
14
|
const READ_ONLY_POLICY: SandboxRuntimeConfig = {
|
|
13
15
|
network: { allowedDomains: [], deniedDomains: [], allowAllUnixSockets: false },
|
|
@@ -29,7 +31,7 @@ type Sandbox = typeof import('@anthropic-ai/sandbox-runtime')['SandboxManager'];
|
|
|
29
31
|
let ready: Promise<Sandbox> | undefined;
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
* Starts
|
|
34
|
+
* Starts sandbox-runtime once per process, loading it only in a readonly subagent: a local package install gets no
|
|
33
35
|
* dependency install, and everything else in the package works without it. A failure is kept, so no command ever
|
|
34
36
|
* runs outside the sandbox.
|
|
35
37
|
*/
|
|
@@ -73,11 +75,7 @@ export function createReadonlyBashTool(name = 'bash'): ToolDefinition<BashParame
|
|
|
73
75
|
description: `${base.description}${NOTE}`,
|
|
74
76
|
async execute(toolCallId, params, signal, onUpdate, ctx: ExtensionToolContext) {
|
|
75
77
|
// Fail closed: when the sandbox cannot start, the call fails here and nothing spawns.
|
|
76
|
-
const
|
|
77
|
-
// The per-command policy re-states READ_ONLY_POLICY, so the command never inherits a policy another
|
|
78
|
-
// consumer left on the shared manager. Wrapping honors abort, and Pi's own shell operations then enforce
|
|
79
|
-
// the pre-spawn abort check, timeouts, and process-tree cleanup.
|
|
80
|
-
const command = await sandbox.wrapWithSandbox(params.command, undefined, READ_ONLY_POLICY, signal);
|
|
78
|
+
const command = process.platform === 'linux' ? linuxCommand(params.command, ctx.cwd) : await wrapped(params.command, signal);
|
|
81
79
|
const bash = createBashTool(ctx.cwd, {
|
|
82
80
|
...(existsSync('/bin/bash') ? { shellPath: '/bin/bash' } : {}),
|
|
83
81
|
spawnHook: (spawn: BashSpawnContext) => ({ ...spawn, command, env: strippedEnv(spawn.env) }),
|
|
@@ -87,10 +85,25 @@ export function createReadonlyBashTool(name = 'bash'): ToolDefinition<BashParame
|
|
|
87
85
|
};
|
|
88
86
|
}
|
|
89
87
|
|
|
88
|
+
function linuxCommand(command: string, cwd: string): string {
|
|
89
|
+
try {
|
|
90
|
+
return linuxReadonlyCommand(command, cwd);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new Error(`no verified OS sandbox is available: ${error instanceof Error ? error.message : String(error)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
90
96
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
97
|
+
* The per-command policy re-states READ_ONLY_POLICY, so the command never inherits a policy another consumer left on
|
|
98
|
+
* the shared manager. Wrapping honors abort, and Pi's own shell operations then enforce the pre-spawn abort check,
|
|
99
|
+
* timeouts, and process-tree cleanup.
|
|
93
100
|
*/
|
|
101
|
+
async function wrapped(command: string, signal: AbortSignal | undefined): Promise<string> {
|
|
102
|
+
const sandbox = await sandboxReady();
|
|
103
|
+
return sandbox.wrapWithSandbox(command, undefined, READ_ONLY_POLICY, signal);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Releases sandbox-runtime if it started, so a process that used the shell can exit. */
|
|
94
107
|
export async function releaseReadonlySandbox(): Promise<void> {
|
|
95
108
|
const started = ready;
|
|
96
109
|
ready = undefined;
|
package/src/sections.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type { AgentDefinition } from './task/agents.ts';
|
|
|
6
6
|
import { type AvailableModel, type ModelSpec, modelLabel } from './task/models.ts';
|
|
7
7
|
|
|
8
8
|
export interface HostFacts {
|
|
9
|
+
/** Today's date as Cursor states it in its user info, such as "Wednesday Nov 4, 2026". */
|
|
10
|
+
today: string;
|
|
9
11
|
packageRoot: string;
|
|
10
12
|
skillsDir: string;
|
|
11
13
|
skillNames: readonly string[];
|
|
@@ -18,6 +20,8 @@ export interface HostFacts {
|
|
|
18
20
|
rootStoreDir: string;
|
|
19
21
|
projectsDir: string;
|
|
20
22
|
artifactsDir: string;
|
|
23
|
+
/** Background shell jobs' output files. */
|
|
24
|
+
terminalsDir: string;
|
|
21
25
|
/** False in print and JSON runs, including subagents, which exit when they settle. */
|
|
22
26
|
loopAvailable: boolean;
|
|
23
27
|
/** False when nobody can answer a dialog (print and JSON runs, including subagents). */
|
|
@@ -29,7 +33,9 @@ export interface HostFacts {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
const LOOP_LINE =
|
|
32
|
-
'- /loop: the user starts `/loop [interval] <prompt>`. When a playbook says to arm /loop, call the Loop tool with prompt plus interval (fixed cadence) or wake_in (agent-paced). Inside a loop, Loop with wake_in schedules the next run and stop: true ends it.';
|
|
36
|
+
'- /loop: the user starts `/loop [interval] <prompt>`. When a playbook says to arm /loop, run the prompt once now, in this turn, then call the Loop tool with prompt plus interval (fixed cadence) or wake_in (agent-paced); its first scheduled run comes after that interval or wake. Inside a loop, Loop with wake_in schedules the next run and stop: true ends it.';
|
|
37
|
+
const EVENT_LOOP_SENTENCE =
|
|
38
|
+
" When the next run waits on an event (CI finishing, a ref advancing, a log line), start a watcher with bash_background that exits, or prints a line its notify_on_output pattern matches, when the event fires, and keep a long wake_in as the fallback heartbeat: the watcher's notification wakes you.";
|
|
33
39
|
const NO_LOOP_LINE =
|
|
34
40
|
'- /loop: this run ends when it settles (a subagent, or a print or JSON run), so it has no Loop tool and cannot arm a loop. Keep working until the goal is met, and name in the final reply anything the parent chat should loop on.';
|
|
35
41
|
|
|
@@ -52,6 +58,14 @@ export function alwaysAppliedRules(skillsDir: string): string[] {
|
|
|
52
58
|
return bodies;
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
62
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
63
|
+
|
|
64
|
+
/** The local date in Cursor's user-info form: weekday, short month, day, year. */
|
|
65
|
+
export function todayText(date: Date): string {
|
|
66
|
+
return `${WEEKDAYS[date.getDay()]} ${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
const TASK_PARAMS = 'description, prompt, subagent_type, model, readonly, run_in_background, resume, fork, fork_turns, environment, cloud_base_branch, file_attachments';
|
|
56
70
|
|
|
57
71
|
function todoLine(facts: HostFacts): string {
|
|
@@ -79,7 +93,7 @@ function agentToolsLine(tools: readonly string[]): string[] {
|
|
|
79
93
|
export function hostSection(facts: HostFacts): string {
|
|
80
94
|
const transcript = facts.sessionFile === undefined ? 'This chat is not persisted (--no-session), so it has no transcript file.' : `This chat's transcript is ${facts.sessionFile}.`;
|
|
81
95
|
return [
|
|
82
|
-
`pstack runs here through the pi-pstack package at ${facts.packageRoot}.`,
|
|
96
|
+
`pstack runs here through the pi-pstack package at ${facts.packageRoot}. Today's date: ${facts.today}.`,
|
|
83
97
|
`- <SKILLS> = ${facts.skillsDir}. "the X skill", "/X", and "principle-X" mean <SKILLS>/X/SKILL.md, even for skills hidden from automatic invocation; a delegate that needs poteto-mode reads <SKILLS>/poteto-mode/SKILL.md. Installed skills (${facts.skillNames.length}): ${facts.skillNames.join(', ')}.`,
|
|
84
98
|
'- A <skill> block in a user message means the user attached that skill to this request. Follow its workflow from its first step, including the steps it says to take before answering, instead of answering directly.',
|
|
85
99
|
`- Skill folders: project \`.pi/skills/\`, personal \`${join(facts.agentDir, 'skills')}/\`.`,
|
|
@@ -95,7 +109,12 @@ export function hostSection(facts: HostFacts): string {
|
|
|
95
109
|
`- Files a tool makes for the user, such as visualize charts, go in ${facts.artifactsDir}.`,
|
|
96
110
|
facts.loopAvailable ? SUBSCRIPTIONS_LINE : NO_SUBSCRIPTIONS_LINE,
|
|
97
111
|
...agentToolsLine(facts.activeTools),
|
|
98
|
-
facts.loopAvailable ? LOOP_LINE : NO_LOOP_LINE,
|
|
112
|
+
facts.loopAvailable ? `${LOOP_LINE}${facts.activeTools.includes('bash_background') ? EVENT_LOOP_SENTENCE : ''}` : NO_LOOP_LINE,
|
|
113
|
+
...(facts.activeTools.includes('bash_background')
|
|
114
|
+
? [
|
|
115
|
+
`- Background shells: bash_background runs a command in the background, or moves it there after block_until_ms, and writes its output to ${facts.terminalsDir}/<shell id>.txt. The first lines of that file hold pid, cwd, command, title, status, and running_for_ms (\`head -n 10 *.txt\` there lists every job), and a footer with exit_code appears when the command ends. A notification wakes you after your turn when a job finishes and on each notify_on_output match; AwaitShell polls or waits. Stop a job with \`kill <pid>\`. bash waits for its command to finish and never moves it to the background, so start anything that can run for minutes (a watcher, a polling script such as watch-pr without --status-only, a dev server) with bash_background.`,
|
|
116
|
+
]
|
|
117
|
+
: []),
|
|
99
118
|
facts.activeTools.includes('GenerateImage')
|
|
100
119
|
? '- Image generation: the GenerateImage tool, which saves each image to the artifacts folder below and shows it in its result.'
|
|
101
120
|
: '- Image generation: no image model is configured, so this session has no GenerateImage tool. Say so and use a mermaid diagram or a table instead.',
|