@justjoshn/pi-pstack 0.15.36-pi.11 → 0.15.36-pi.12
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 +15 -0
- package/README.md +10 -10
- package/agents/thermo-nuclear-code-quality-review.md +1 -1
- package/docs/native-gaps.md +1 -1
- package/docs/parity-status.md +26 -11
- package/package.json +2 -2
- package/skills/make-bot-ui/SKILL.md +1 -1
- package/skills/teach/SKILL.md +1 -1
- package/src/ask.ts +24 -10
- package/src/generate-image.ts +98 -0
- package/src/index.ts +14 -10
- package/src/loop.ts +21 -13
- package/src/mode.ts +84 -46
- package/src/plan.ts +212 -42
- package/src/sections.ts +6 -1
- package/src/task/agents.ts +39 -3
- package/src/task/card.ts +4 -2
- package/src/task/format.ts +17 -3
- package/src/task/registry.ts +2 -1
- package/src/task/runner.ts +4 -1
- package/src/task/stop.ts +3 -3
- package/src/task/tool.ts +52 -20
- package/src/todo.ts +21 -1
- package/src/path-skills.ts +0 -104
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.15.36-pi.12] - 2026-11-04
|
|
4
|
+
|
|
5
|
+
Each item below was checked against Cursor CLI 2026.10.22 with pstack and cursor-team-kit loaded, through its bundle and live runs. `parity/pi-pstack-behavior-parity/` in the repository records the evidence and the decisions.
|
|
6
|
+
|
|
7
|
+
- A mode keeps its skill in context on every request until the user leaves it, as a Cursor Custom Mode does, and sends Cursor's notices: entered, the per-turn reminder, and an exit or switch notice once. The previous version sent the body on one request only, so models that replay the system prompt lost it.
|
|
8
|
+
- A background completion arrives as Cursor's system notification: its preamble, a title from the first line of the prompt (80 characters at most), and its follow-up request. The background launch text and the Agent ID line use Cursor's wording.
|
|
9
|
+
- Task, TodoWrite, and AskQuestion descriptions carry Cursor's usage rules, and AskQuestion needs two options per question. The model list adds Cursor's rules for user-requested models.
|
|
10
|
+
- Add Cursor's built-in `explore` subagent type, a file-search specialist with Pi's search tools and no Task tool. team-kit's thermo-nuclear agent text, which names it, is upstream's again.
|
|
11
|
+
- Every subagent hears Cursor's reminder that it runs under a parent and spawns no subagents unless asked.
|
|
12
|
+
- Add `GenerateImage`, Cursor's image tool, on Pi's image models. It is offered when an image model is available and saves each image to the session's artifacts folder. `teach` asks for the image tool again, as upstream does.
|
|
13
|
+
- Stop attaching `typescript-best-practices` when a `.ts` file is read. Cursor loads a skill that sets `paths` and `disable-model-invocation` only when invoked by name.
|
|
14
|
+
- Plan mode follows Cursor's: its instructions, `CreatePlan {plan, name, overview, todos}` writing Cursor's plan file, edits to markdown files only, `SwitchMode` with the user's consent, and a build-locally, build-in-a-worker, or propose-changes choice after a saved plan. `create_plan` is gone.
|
|
15
|
+
- A `/loop` wake that comes due during a run fires once when the run settles. A fixed tick was dropped before, and an agent-paced wake that landed during another run stopped the loop.
|
|
16
|
+
- `make-bot-ui` describes webhook routines as they behave: no default expiry, and `sessionStrategy: "new_session"` for webhooks.
|
|
17
|
+
|
|
3
18
|
## [0.15.36-pi.11] - 2026-11-04
|
|
4
19
|
|
|
5
20
|
- Title a background completion notice with the Task `description`, as Cursor names a subagent, instead of a slice of its prompt.
|
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`, 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`, `TodoWrite`, `AskQuestion`, `GenerateImage`, `SwitchMode`, the model rule, `/loop`, subscriptions and webhook routines, and `visualize`.
|
|
4
4
|
|
|
5
5
|
## Get set up
|
|
6
6
|
|
|
@@ -27,9 +27,9 @@ Remove the package with `pi remove ./extensions/pi-pstack`. Its state lives in `
|
|
|
27
27
|
| You type | What happens |
|
|
28
28
|
|---|---|
|
|
29
29
|
| `/poteto-mode <task>` | The skill attaches to that one message. Every skill Pi loaded works this way: pstack's (`/how`, `/why`, `/arena`, `/interrogate`, `/principle-prove-it-works`, `/deslop`) and your own, such as a `/<handle>-mode` skill from `/automate-me`. `/skill:<name>` works too. |
|
|
30
|
-
| `/mode poteto-mode [task]` | Keeps the skill in context on every turn
|
|
31
|
-
| `/plan [task]` | Plan mode: the agent
|
|
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
|
|
30
|
+
| `/mode poteto-mode [task]` | Keeps the skill in context on every turn until you leave the mode: the skill rides every request in its own system-prompt section, which Pi re-sends only when it changes. The first request after entry says the user entered the mode, later requests carry a reminder that names the skill file, and the request after `/mode off` or a switch to another mode says the user left it. The footer shows a badge with the skill's `icon` and `color` metadata, such as a yellow `♛ mode: poteto-mode` (other skills get `⚡`). `/mode off` exits and `/mode` reports. Any loaded skill can back a mode. |
|
|
31
|
+
| `/plan [task]` | Plan mode: the agent researches with read-only tools (`read`, `grep`, `find`, `ls`, the sandboxed `bash_readonly`, `AskQuestion`, `TodoWrite`, `agent_status`, `Task`, whose launches run readonly: a fresh one runs here with no worktree, and a resume or fork stays where its agent ran, and `stop_agent`), asks its clarifying questions early, and presents the plan with `CreatePlan` (`plan`, `name`, `overview`, `todos`). `CreatePlan` writes the plan file under `<pstack dir>/plans/` (`<name>-<8 hex>.plan.md`: the chat id in a comment, the todos as front matter, then the plan), one file per plan name in a chat. `edit` and `write` work on markdown files only, so the agent refines the plan in its file. After a run that saved a plan, an interactive session asks `Yes, build locally`, `Yes, build in cloud` (in a git repository: a background isolated worker builds it on its own branch), or `No, propose changes`. `/plan build` builds the saved plan too, and `/plan off` restores your tools. A bare `/plan` enters plan mode. The footer shows `plan`. |
|
|
32
|
+
| `/loop 30m <prompt>` | Runs the prompt now, then again every 30 minutes from each fire, so a long run does not push the next tick out. A tick that comes due while a run is still going fires once when that run settles; several missed ticks still fire once. The interval can come first or last, written as `30m`, `10 minutes`, or `every 10 minutes`. Without an interval the agent picks each wake with the `Loop` tool, and the loop ends when it picks none and no background subagent is still running. `/loop stop` or Escape during a run ends it. The prompt can be a skill command, such as `/loop 1h /poteto-mode run the audit tick`. When a playbook says to arm `/loop`, the agent starts it with the `Loop` tool. Intervals go up to 24 days. |
|
|
33
33
|
| `/routines` | Lists webhook routines with their URL, credential file, and status. |
|
|
34
34
|
| `/setup-pstack` | Configures the per-role models. |
|
|
35
35
|
| `/poteto-help <question>` | Answers a question about pstack and hands you a prompt to send. |
|
|
@@ -40,18 +40,17 @@ Remove the package with `pi remove ./extensions/pi-pstack`. Its state lives in `
|
|
|
40
40
|
|
|
41
41
|
| Feature | Behavior |
|
|
42
42
|
|---|---|
|
|
43
|
-
| `Task` (`description`, `prompt`, `subagent_type`, `model`, `run_in_background`, `resume`, `fork`, `fork_turns`, `readonly`, `file_attachments`, `environment`, `cloud_base_branch`) | Starts a separate `pi --mode rpc` process per subagent, so this Pi process can steer and abort it. Foreground calls return the response and an agent ID. Background calls return at once, and a `<task>`
|
|
43
|
+
| `Task` (`description`, `prompt`, `subagent_type`, `model`, `run_in_background`, `resume`, `fork`, `fork_turns`, `readonly`, `file_attachments`, `environment`, `cloud_base_branch`) | Starts a separate `pi --mode rpc` process per subagent, so this Pi process can steer and abort it. Foreground calls return the response and an agent ID. Background calls return at once, and a `<system_notification>` with a `<task>` block arrives after your turn ends, titled by the first line of the prompt (80 characters at most) and followed by a request to do any follow-up work; the notification card shows the call's `description`. Print and JSON runs wait for pending notifications before exiting, unless aborted. Several calls in one message run in parallel. A subagent's own subagents cannot spawn more, and every subagent is told to spawn none unless asked. A run lock per agent ID keeps out a second writer, even from another Pi process (Limits notes the stale-lock race). Children inherit the parent's project-trust decision. Foreground usage counts toward the session totals. The Task card leads with the call's `description`, then the agent type and the model that ran, and while a foreground child works it shows its last few tool steps and latest line instead of a blank wait. |
|
|
44
44
|
| `resume` | Sends a follow-up to an idle agent with its context, model, type, and directory. The root chat can resume or fork a descendant that a worktree worker started inside that worktree, and the descendant still runs where it first ran. On an agent running in this Pi process, it delivers the prompt as a steer and returns at once. A readonly or plan-mode call cannot steer a writable agent. An agent running in another Pi process is refused. This is how a playbook "messages" a worker. Pi's own `/resume` command reopens a chat session instead. |
|
|
45
45
|
| `fork` | Starts a new agent whose transcript begins as a copy of an idle agent's transcript, in that agent's directory (its worktree for an isolated worker), so it sees the built checkout and uncommitted changes. Type, model, and readonly default from the source; readonly can be added, never lifted. `fork_turns: N` (only with `fork`) copies just the first N user turns. `fork` excludes `resume`, `environment`, and `cloud_base_branch`. Pi's own `/fork` command branches the chat session instead. |
|
|
46
46
|
| `stop_agent` | `agent_id`. Stops a running subagent. One this Pi process owns gets an RPC abort, then a kill after a grace period. One another of your Pi processes owns gets a signal to the live pid in its run lock. An idle or finished agent is left alone, and the result says so. |
|
|
47
47
|
| `agent_status` | Reads a subagent's state (`running` while its run lock names a live pid, `idle`, or `missing`), type, model, directory, branch, times, and last reply, without waking it. It also shows whether the agent is readonly, the agent it was forked from, its pid while running, and its transcript. Without `agent_id` it lists the workspace's 20 newest agents. In the root chat that includes every descendant, isolated workers too. |
|
|
48
|
-
| Subagent types | `generalPurpose` (default), `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. |
|
|
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
51
|
| `readonly: true` | The child gets `read`, `grep`, `find`, `ls`, `TodoWrite`, and a read-only `bash`, and no MCP. That shell runs each command in an OS sandbox ([`@anthropic-ai/sandbox-runtime`](https://github.com/anthropics/sandbox-runtime): `sandbox-exec` on macOS, bubblewrap on Linux) that blocks every file write and all network access, so `git log` works and `touch` or `curl` fail. Where no sandbox can start, the command fails rather than running unsandboxed. |
|
|
52
|
-
| `TodoWrite` | `todos` with `id`, `content`, `status`, and `merge`. The list follows the session branch
|
|
53
|
-
| `AskQuestion` | `title`, and per question `id`, `prompt`, `options` (`id`, `label`, optional `description`), and `allow_multiple`, through Pi's select dialog. Every question ends with `Other: (type to answer)`; a typed answer comes back as `other` with its text. In print and JSON runs, including subagents, each question comes back skipped. |
|
|
54
|
-
| Path-scoped skills | A skill whose frontmatter declares `paths` or `globs` (also under `metadata`) attaches once per session when the agent reads or edits a matching file, the way a path-scoped rule attaches. The shipped `typescript-best-practices` skill attaches on `.ts` and `.tsx` files this way. |
|
|
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. |
|
|
53
|
+
| `AskQuestion` | `title`, and per question `id`, `prompt`, `options` (at least two, each `id`, `label`, optional `description`), and `allow_multiple`, through Pi's select dialog. It is for decisions only the user can make, and a recommended option goes first with `(Recommended)`. Every question ends with `Other: (type to answer)`; a typed answer comes back as `other` with its text. In print and JSON runs, including subagents, each question comes back skipped. |
|
|
55
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. |
|
|
56
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`. |
|
|
57
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,7 +59,8 @@ Remove the package with `pi remove ./extensions/pi-pstack`. Its state lives in `
|
|
|
60
59
|
| Stores and projects | The system prompt names "the current agent's store" (per session), "the root agent's store" (the main chat's, named to every subagent however deep), and the workspace's `projects/` folder, where a program's Project is `<slug>/notes.md` (its tracker) and `<slug>/store/`. |
|
|
61
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. |
|
|
62
61
|
| Transcripts | The system prompt names this workspace's Pi session directory, the current session file, and this workspace's subagent transcripts. |
|
|
63
|
-
|
|
|
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
|
+
| `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
64
|
|
|
65
65
|
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.
|
|
66
66
|
|
|
@@ -20,4 +20,4 @@ You are a **Task subagent**. The parent agent already collected git output and c
|
|
|
20
20
|
|
|
21
21
|
## Parent orchestration
|
|
22
22
|
|
|
23
|
-
Typical flow:
|
|
23
|
+
Typical flow: in **one** message, run two `Task` calls in parallel — `subagent_type: "shell"` and `subagent_type: "explore"` — to collect `git diff <base>...HEAD` output and full contents of changed files (default base `main`). Then invoke this agent with `subagent_type: "thermo-nuclear-code-quality-review"` and a user prompt containing `### Git / diff output` and `### Changed file contents`.
|
package/docs/native-gaps.md
CHANGED
|
@@ -41,7 +41,7 @@ thin adapter over `RpcClient`.
|
|
|
41
41
|
|
|
42
42
|
## 2. The skill block `/mode` sends
|
|
43
43
|
|
|
44
|
-
`
|
|
44
|
+
`modeSkill` (`src/mode.ts:102`) strips frontmatter with Pi's exported `stripFrontmatter` (`dist/index.d.ts:50`) and
|
|
45
45
|
wraps the body in the `<skill name="..." location="...">` block Pi's own `/skill:name` expansion builds
|
|
46
46
|
(`AgentSession._expandSkillCommand`, `dist/core/agent-session.js:1739`). Pi exports `parseSkillBlock` and
|
|
47
47
|
`formatSkillsForPrompt` (`dist/index.d.ts:3,23`), which parse a block and format the skills catalog; neither formats
|
package/docs/parity-status.md
CHANGED
|
@@ -8,23 +8,24 @@ This page records what `pi-pstack` reproduces from Cursor `pstack` 0.15.36 and i
|
|
|
8
8
|
- Installed Cursor CLI at the time of the reference capture: `2026.10.22-27dcf5f`.
|
|
9
9
|
- Native implementation: this package, which pins its upstream provenance in `package.json.pstackSource`.
|
|
10
10
|
|
|
11
|
-
A path and byte comparison of the tracked trees shows every pstack skill, both pstack agents, all 18 team-kit skills, both team-kit agents, and both team-kit rules in this package. Seventeen team-kit skill files are byte-identical; `workflow-from-chats` describes Pi paths instead of Cursor paths. The changed pstack files are host translations. One upstream paragraph has no counterpart: `poteto-help`'s Claude Code install path and its `docs/harness/claude-code.md` adapter, because this package targets Pi only. The two non-portable frontmatter keys Cursor ships (`icon`/`color` on `poteto-mode`, `paths` on `typescript-best-practices`) are kept under `metadata`, the Agent Skills extension point
|
|
11
|
+
A path and byte comparison of the tracked trees shows every pstack skill, both pstack agents, all 18 team-kit skills, both team-kit agents, and both team-kit rules in this package. Seventeen team-kit skill files are byte-identical; `workflow-from-chats` describes Pi paths instead of Cursor paths. The changed pstack files are host translations. One upstream paragraph has no counterpart: `poteto-help`'s Claude Code install path and its `docs/harness/claude-code.md` adapter, because this package targets Pi only. The two non-portable frontmatter keys Cursor ships (`icon`/`color` on `poteto-mode`, `paths` on `typescript-best-practices`) are kept under `metadata`, the Agent Skills extension point. The port reads `icon` and `color` for the mode badge. `paths` stays as metadata only, because Cursor loads a `paths` skill that sets `disable-model-invocation` only when it is invoked by name. team-kit's thermo-nuclear agent changes only where it names the plugin that ships its rubric.
|
|
12
12
|
|
|
13
13
|
## Native translations
|
|
14
14
|
|
|
15
15
|
| Cursor dependency | Pi mechanism |
|
|
16
16
|
| --- | --- |
|
|
17
17
|
| Skill invocation and the slash menu | `pi.getCommands()` plus `/name` to `/skill:name` input rewriting, with TUI completions |
|
|
18
|
-
| Custom Mode | `/mode <skill>`, a branch-persisted entry, the skill
|
|
19
|
-
| Plan mode | `/plan`, a reduced tool set
|
|
18
|
+
| Custom Mode | `/mode <skill>`, a branch-persisted entry, the skill in a `pstack_mode_skill` prompt section on every request while the mode is on, and Cursor's entered, reminder, and exit or switch notices in `pstack_mode` |
|
|
19
|
+
| Plan mode | `/plan` and `SwitchMode`, Cursor's plan-mode instructions in `pstack_plan`, a reduced tool set with `bash_readonly` in an OS sandbox and markdown-only `edit` and `write`, `CreatePlan` writing Cursor's plan file under `plans/`, and a build prompt after a saved plan (`/plan build` too) |
|
|
20
20
|
| Always-applied model rule | `pstack-models.md` read before every request and injected as `pstack_models` |
|
|
21
|
-
| Task subagents | One `pi --mode rpc` process per agent, with resume, steer, fork, `fork_turns`, readonly, attachments,
|
|
22
|
-
| Agent types |
|
|
21
|
+
| Task subagents | One `pi --mode rpc` process per agent, with resume, steer, fork, `fork_turns`, readonly, attachments, Cursor's result and completion texts, Cursor's subagent reminder, and local worktrees |
|
|
22
|
+
| Agent types | Cursor's built-in `generalPurpose` and `explore`, then the four bundled agent Markdown files, loaded into the child system prompt |
|
|
23
23
|
| Project and agent stores | Workspace state folders named in the host system prompt |
|
|
24
24
|
| Todo list | `TodoWrite` with branch reconstruction and a TUI widget |
|
|
25
|
-
| Structured questions | `AskQuestion` over Pi select and input dialogs |
|
|
26
|
-
|
|
|
27
|
-
|
|
|
25
|
+
| Structured questions | `AskQuestion` over Pi select and input dialogs, with Cursor's schema minimums and usage rules |
|
|
26
|
+
| Image generation (`GenerateImage`) | `GenerateImage` on `ctx.modelRegistry.generateImages()`, offered when an image model is available |
|
|
27
|
+
| `/loop` | `Loop` tool and `/loop` command. A fixed interval is wall-clock from each fire, like Cursor's sleep loop, and accepts the interval first or last (`5m`, `10 minutes`, `every 10 minutes`). An interval-less loop is agent-paced through `Loop` `wake_in`. A wake that comes due mid-run fires once when the run settles, as Cursor's monitored shell output notifies after the turn. |
|
|
28
|
+
| Path-scoped skills | None needed. pstack's only `paths` skill also sets `disable-model-invocation`, and Cursor loads such a skill only by name, which Pi does natively |
|
|
28
29
|
| Always-applied rules | The two shipped rule bodies are read into `pstack_host` verbatim on every request, not paraphrased |
|
|
29
30
|
| Todo list state | `TodoWrite` writes branch entries, and the current list rides in the `pstack_todos` prompt section on every request, so a compacted context still sees it |
|
|
30
31
|
| Automations | Durable timer, GitHub, and webhook subscriptions with a detached supervisor |
|
|
@@ -44,6 +45,19 @@ A live Cursor CLI session with both plugins loaded produced these contracts.
|
|
|
44
45
|
|
|
45
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.
|
|
46
47
|
|
|
48
|
+
## Repairs in 0.15.36-pi.12
|
|
49
|
+
|
|
50
|
+
A fresh reverse engineering of Cursor CLI 2026.10.22 with pstack and cursor-team-kit loaded found these differences. The repository's `parity/pi-pstack-behavior-parity/` holds the findings, the decision trail, and `probe-cursor.sh`, which reruns every Cursor probe. Each change has a test that failed on the earlier code.
|
|
51
|
+
|
|
52
|
+
- A Custom Mode keeps its skill in context for the whole session in Cursor (its docs, and `durable_skill_blocks` in the CLI's conversation state). pi-pstack sent the body on one request, so a model that replays the system prompt lost it after a turn. The skill now rides every request, and Cursor's exit and switch notice is sent once after a mode ends.
|
|
53
|
+
- A background completion uses Cursor's `<system_notification>` text, a title from the prompt's first line (80 characters at most), and its follow-up request; the launch text and Agent ID line use Cursor's words. An earlier repair had titled it by `description`, which Cursor shows in its UI but not in the model-facing notice.
|
|
54
|
+
- Task, TodoWrite, and AskQuestion carry Cursor's usage rules; AskQuestion needs two options per question.
|
|
55
|
+
- `explore` exists, and every subagent hears Cursor's subagent reminder. team-kit's thermo-nuclear agent text is upstream's again.
|
|
56
|
+
- `GenerateImage` exists on Pi's image models. pstack's `teach` and `ketchup` reach for an image tool, which codemode, off by default, did not provide.
|
|
57
|
+
- The path-skills module is gone. A live probe showed Cursor never surfaces a `paths` skill that sets `disable-model-invocation`, which is `typescript-best-practices`.
|
|
58
|
+
- Plan mode follows Cursor's instructions and `CreatePlan` contract, allows markdown edits, offers `SwitchMode`, and asks whether to build a saved plan.
|
|
59
|
+
- A `/loop` wake that comes due mid-run fires once after the run instead of being dropped, which had also stalled agent-paced loops.
|
|
60
|
+
|
|
47
61
|
## Repairs in 0.15.36-pi.11
|
|
48
62
|
|
|
49
63
|
Each code change has a regression in the package suite. Text restorations are recorded here and checked against the reference by the parity audit.
|
|
@@ -85,10 +99,11 @@ Each code change has a regression in the package suite. Text restorations are re
|
|
|
85
99
|
These are not closed by extension code.
|
|
86
100
|
|
|
87
101
|
- **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.
|
|
88
|
-
- **A mode
|
|
102
|
+
- **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.
|
|
89
103
|
- **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.
|
|
90
|
-
- **Plan mode's restriction is stricter than the host's.** Cursor's
|
|
91
|
-
- **
|
|
104
|
+
- **Plan mode's restriction is stricter than the host's.** Cursor's plan mode keeps every tool and restricts by instruction: edit only markdown files, run only read-only commands. Pi enforces the same rules with a tool allowlist, markdown-only `edit` and `write`, and a sandboxed shell that also denies network, so `gh`, `curl`, and package queries can fail while planning. Allowing network in a read-only shell would also open a path for remote changes, so the sandbox keeps it closed.
|
|
105
|
+
- **The plan review is a dialog, not a card.** Cursor renders the plan with a build menu. Pi shows the same three choices in a select dialog after the run that saved the plan, and "build in cloud" starts a background isolated worktree worker, Pi's counterpart to a cloud agent, rather than a remote VM.
|
|
106
|
+
- **One loop at a time.** Cursor's local `/loop` runs one shell loop per purpose, so several can run at once. pi-pstack runs one loop per session; a new `/loop` or `Loop` call replaces the running one. pstack's playbooks arm one audit loop at a time.
|
|
92
107
|
- **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.
|
|
93
108
|
- **A routines listener is local, one port per workspace.** Cursor hosts each routine URL. Pi binds a loopback port per workspace, so two workspaces serve routines at once only because the default port falls back to an ephemeral one that the workspace then reuses and reports. A sender outside the machine still needs its own forwarding path.
|
|
94
109
|
- **Cloud execution is local.** Cursor documents cloud VMs for its cloud product, but the inspected CLI schema omits `environment` and a requested cloud Task ran on the same machine and directory. Pi's `environment: "cloud"` goes further with a git worktree and a separate local process. Neither runs with the laptop off or provides a remote VM.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justjoshn/pi-pstack",
|
|
3
|
-
"version": "0.15.36-pi.
|
|
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, and visualize",
|
|
3
|
+
"version": "0.15.36-pi.12",
|
|
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",
|
|
7
7
|
"pstackSource": {
|
|
@@ -20,7 +20,7 @@ Call `subscribe_webhook`. Set these fields:
|
|
|
20
20
|
The routine slug is the kebab-case form of the name.
|
|
21
21
|
The result gives the routine URL, `http://127.0.0.1:<port>/routines/<slug>`, and the credential file path. Use the path the result names, such as `~/.pi/agent/pstack/routines/<slug>/credentials.json` (`PI_CODING_AGENT_DIR` moves it).
|
|
22
22
|
The result does not include the sender key.
|
|
23
|
-
|
|
23
|
+
By default the routine wakes the owning Pi session, which keeps its context across presses the way a bot does. The durable supervisor keeps the listener available after the chat exits. Wakes received while the owner is offline park until that session is restored. Keep the owner running for immediate UI actions, and check the routine with `list_subscriptions`. To act on presses with no chat open, pass `sessionStrategy: "new_session"`: each accepted POST starts a fresh detached run without the owner's context, which must do the work itself. The routine does not expire unless `expiresInHours` sets a limit.
|
|
24
24
|
|
|
25
25
|
## Copy the URL and the sender key
|
|
26
26
|
|
package/skills/teach/SKILL.md
CHANGED
|
@@ -14,7 +14,7 @@ Teach sits on top of `how` and `why`. Get your bearings on what the work is and
|
|
|
14
14
|
2. Let `how` and `why` do the work, don't redo it. Read the code yourself to get oriented, then run `how` for how it works and `why` for why. Run them in parallel and combine the results. Match the size to the question. Run both for a subsystem, maybe one is enough for a small change. Keep `why` narrow by default since its full sweep is slow. Put the narrowing in the ask itself (a scoped question, git plus a source or two) so `why` records the skipped categories per its own contract, and widen it only when the reasons are the point.
|
|
15
15
|
3. Start with a plain definition. Name the thing and say what it is in general terms, the way a senior engineer would say it out loud, with its common name if it has one. Then tie it to the case in front of you ("in X, we use this to ...") and build from there: how it works, the deeper reasons, the edge cases. For each part, explain the idea so it clicks: the problem it solves and how it actually works. Walk through what happens as the person does the thing (opens a long chat, scrolls up) when that is what makes it land. Listing functions and constants is reference, not teaching. Don't print framing labels ("the one idea to hold onto", "the thing to walk away with", "the key insight", "at its core", "TL;DR"). Give the smallest complete answer first, a sentence or two, not a dense paragraph, then stop. Add layers when they ask. Never a wall of text.
|
|
16
16
|
4. Keep it a conversation, not a lecture or a performance. Offer to go deeper or move on, and follow their lead. No quizzes. No pacing theater. Don't print "Pause", don't ask them to say it back, don't announce "the sentence to nail", and don't flag a part as important or hard ("here is the part worth slowing down on", "this is the tricky part", "here is where it gets interesting"). Just say it. When you would pause, stop and let them respond. Running one-shot with no live human, deliver it cleanly and put any offer to go deeper at the end.
|
|
17
|
-
5. Show, don't only tell, and build the picture up diagram by diagram. Open the diff, the code, or the debugger when that is the fastest way to land it. Draw when a picture lands faster than words. For anything with three or more moving parts, do not draw one diagram with all of them at once. Draw a short series instead, where each diagram redraws the last and adds a single part, so the reader watches the system assemble. A single all-at-once diagram, especially one saved for the end, is a reference, not teaching. Concretely, to teach a flow from A to B to C, draw it three times. First A to B. Then redraw and add C. Then redraw and add the return edge or the next piece. Match the medium to the idea, and use both kinds when both help. A mermaid diagram fits a flow or structure where the labels carry the meaning. When the idea is spatial, like layout, overlap, scroll position, or a before and after, reach for image
|
|
17
|
+
5. Show, don't only tell, and build the picture up diagram by diagram. Open the diff, the code, or the debugger when that is the fastest way to land it. Draw when a picture lands faster than words. For anything with three or more moving parts, do not draw one diagram with all of them at once. Draw a short series instead, where each diagram redraws the last and adds a single part, so the reader watches the system assemble. A single all-at-once diagram, especially one saved for the end, is a reference, not teaching. Concretely, to teach a flow from A to B to C, draw it three times. First A to B. Then redraw and add C. Then redraw and add the return edge or the next piece. Match the medium to the idea, and use both kinds when both help. A mermaid diagram fits a flow or structure where the labels carry the meaning. When the idea is spatial, like layout, overlap, scroll position, or a before and after, reach for the image-generation tool and draw it marker-on-whiteboard style with a few short labels, since image models garble long text. Generate that picture, don't settle for describing it in words. The build-up rule holds for generated images too. A single simple point needs no figure.
|
|
18
18
|
|
|
19
19
|
Write every response through the **unslop** skill, in plain spoken English, the way you'd explain it to a colleague. Be tight, not terse. Cut filler and hedging, keep the part that makes it click. State the concrete mechanism, not a metaphor, a framing, or a preview of what is coming. This is the target density: "Virtualization runs in two parts, one for rendering and one for loading from disk. When an item scrolls out past the buffer, both its DOM node and its in-memory data are evicted." Normal sentence case, not all-lowercase. No em dashes. Prefer periods over commas. Keep each sentence to one or two commas. If clauses pile up, split them into separate sentences. Give each concept one name and keep it. Avoid mirror sentences ("A without B, or B without A") and tidy closers ("the rest follows", "it all falls out"). The words in these steps are directions to you, not labels to print. Don't echo the structure as headers or stock phrases.
|
|
20
20
|
|
package/src/ask.ts
CHANGED
|
@@ -9,17 +9,22 @@ export const OTHER_ID = 'other';
|
|
|
9
9
|
const SKIPPED = 'skipped (no one can answer questions in this session; decide yourself and say what you chose)';
|
|
10
10
|
|
|
11
11
|
export const AskParams = Type.Object({
|
|
12
|
-
title: Type.Optional(Type.String({ description: '
|
|
12
|
+
title: Type.Optional(Type.String({ description: 'Optional title for the questions form' })),
|
|
13
13
|
questions: Type.Array(
|
|
14
14
|
Type.Object({
|
|
15
|
-
id: Type.String({ description: '
|
|
16
|
-
prompt: Type.String({ description: 'The question
|
|
17
|
-
options: Type.Array(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
id: Type.String({ description: 'Unique identifier for this question' }),
|
|
16
|
+
prompt: Type.String({ description: 'The question text to display to the user, without the options.' }),
|
|
17
|
+
options: Type.Array(
|
|
18
|
+
Type.Object({
|
|
19
|
+
id: Type.String({ description: 'Unique identifier for this option' }),
|
|
20
|
+
label: Type.String({ description: 'Display text for this option' }),
|
|
21
|
+
description: Type.Optional(Type.String({ description: 'Detail shown after the label' })),
|
|
22
|
+
}),
|
|
23
|
+
{ minItems: 2, description: 'Array of answer options (minimum 2 required)' },
|
|
24
|
+
),
|
|
25
|
+
allow_multiple: Type.Optional(Type.Boolean({ description: 'If true, the user can select several options. Defaults to false.' })),
|
|
21
26
|
}),
|
|
22
|
-
{ description: 'Questions
|
|
27
|
+
{ minItems: 1, description: 'Questions to present to the user, one after another (minimum 1 required)' },
|
|
23
28
|
),
|
|
24
29
|
});
|
|
25
30
|
export type Question = Static<typeof AskParams>['questions'][number];
|
|
@@ -102,8 +107,17 @@ export function registerAskQuestion(pi: ExtensionAPI): void {
|
|
|
102
107
|
pi.registerTool({
|
|
103
108
|
name: 'AskQuestion',
|
|
104
109
|
label: 'Ask question',
|
|
105
|
-
description:
|
|
106
|
-
|
|
110
|
+
description: [
|
|
111
|
+
"Collect structured multiple-choice answers from the user. Use this tool only when you are blocked on a decision that is genuinely the user's to make: one you cannot resolve from the request, the code, or sensible defaults.",
|
|
112
|
+
'',
|
|
113
|
+
'Usage notes:',
|
|
114
|
+
'- Give each question at least 2 options.',
|
|
115
|
+
'- The user can always pick "Other" and type an answer, which comes back as other with the text.',
|
|
116
|
+
'- Set allow_multiple: true to let the user pick several options.',
|
|
117
|
+
'- If you recommend an option, put it first and end its label with "(Recommended)".',
|
|
118
|
+
'',
|
|
119
|
+
'Prefer this tool over listing options in your reply text as letters, numbers, or bullets.',
|
|
120
|
+
].join('\n'),
|
|
107
121
|
promptSnippet: 'Structured multiple-choice questions for the user',
|
|
108
122
|
parameters: AskParams,
|
|
109
123
|
exposure: 'model-only',
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, extname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { detectSupportedImageMimeTypeFromFile, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { Type } from 'typebox';
|
|
6
|
+
|
|
7
|
+
export const GENERATE_IMAGE = 'GenerateImage';
|
|
8
|
+
|
|
9
|
+
const ASPECT_RATIOS = ['1:1', '4:3', '3:4', '16:9', '9:16'] as const;
|
|
10
|
+
const EXTENSIONS: Record<string, string> = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp' };
|
|
11
|
+
|
|
12
|
+
// Cursor's GenerateImage rules: explicit requests only, no data charts, a concrete description, and no Markdown echo.
|
|
13
|
+
const DESCRIPTION = [
|
|
14
|
+
'Generate an image file from a text description.',
|
|
15
|
+
'',
|
|
16
|
+
'Use this tool only when the user explicitly asks for an image; do not generate images just to be helpful. Do not use it for data-heavy visuals such as charts, plots, or tables; make those with code or the visualize tool.',
|
|
17
|
+
'',
|
|
18
|
+
'Describe the image concretely first: the subjects, layout, style, colors, any text, and constraints, in at least 6 words. A description of 3 words or fewer is rejected. Set aspect_ratio when the user asks for one, and pass reference images in reference_image_paths.',
|
|
19
|
+
'The result shows the generated image and names the saved file, so do not repeat the image as Markdown in your reply.',
|
|
20
|
+
].join('\n');
|
|
21
|
+
|
|
22
|
+
const Params = Type.Object({
|
|
23
|
+
description: Type.String({ description: 'A detailed description of the image, at least 6 words. Descriptions of 3 words or fewer are rejected.' }),
|
|
24
|
+
filename: Type.Optional(Type.String({ description: "Optional file name for the image, such as 'diagram.png', without a directory. Defaults to a timestamped name." })),
|
|
25
|
+
reference_image_paths: Type.Optional(Type.Array(Type.String(), { description: 'Optional paths of reference images to use as additional inputs.' })),
|
|
26
|
+
aspect_ratio: Type.Optional(Type.Enum(ASPECT_RATIOS, { type: 'string', description: 'Optional aspect ratio: "1:1", "4:3", "3:4", "16:9", or "9:16".' })),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type ImageBlock = { type: 'image'; data: string; mimeType: string };
|
|
30
|
+
|
|
31
|
+
async function referenceImage(path: string, cwd: string): Promise<ImageBlock> {
|
|
32
|
+
const absolute = isAbsolute(path) ? path : resolve(cwd, path);
|
|
33
|
+
const mimeType = await detectSupportedImageMimeTypeFromFile(absolute);
|
|
34
|
+
if (mimeType === null) throw new Error(`reference_image_paths: ${absolute} is not a PNG, JPEG, GIF, or WebP image.`);
|
|
35
|
+
return { type: 'image', data: readFileSync(absolute).toString('base64'), mimeType };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The saved file name: the requested name without any directory, with the extension the image data has. */
|
|
39
|
+
export function imageFileName(requested: string | undefined, mimeType: string, index: number, now: Date): string {
|
|
40
|
+
const extension = EXTENSIONS[mimeType] ?? '.png';
|
|
41
|
+
const base = requested === undefined || basename(requested).trim() === '' ? `image-${now.toISOString().replace(/[:.]/g, '-')}` : basename(requested, extname(requested));
|
|
42
|
+
return `${base}${index === 0 ? '' : `-${index + 1}`}${extension}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The first image model Pi can call with the configured credentials. */
|
|
46
|
+
async function imageModel(ctx: ExtensionContext) {
|
|
47
|
+
const [model] = await ctx.modelRegistry.getAvailableOfType('image');
|
|
48
|
+
return model;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Cursor's GenerateImage on Pi's image models, offered when one is available. Images save to the session's artifacts folder. */
|
|
52
|
+
export interface GenerateImageOptions {
|
|
53
|
+
artifactsDir: (ctx: ExtensionContext) => string;
|
|
54
|
+
/** False where the session may not write files: a readonly subagent, or plan mode. */
|
|
55
|
+
mayOffer: () => boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function registerGenerateImage(pi: ExtensionAPI, options: GenerateImageOptions): void {
|
|
59
|
+
pi.registerTool({
|
|
60
|
+
name: GENERATE_IMAGE,
|
|
61
|
+
label: 'Generate image',
|
|
62
|
+
description: DESCRIPTION,
|
|
63
|
+
promptSnippet: 'Generate an image file from a text description',
|
|
64
|
+
parameters: Params,
|
|
65
|
+
defaultActive: false,
|
|
66
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
67
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
68
|
+
if (params.description.trim().split(/\s+/).length <= 3) throw new Error('Describe the image in at least 6 words. Descriptions of 3 words or fewer are rejected.');
|
|
69
|
+
const model = await imageModel(ctx);
|
|
70
|
+
if (model === undefined) throw new Error('No image model is available. Configure one, such as an OpenRouter image model, to generate images.');
|
|
71
|
+
const references = await Promise.all((params.reference_image_paths ?? []).map((path) => referenceImage(path, ctx.cwd)));
|
|
72
|
+
const text = params.aspect_ratio === undefined ? params.description.trim() : `${params.description.trim()}\n\nAspect ratio: ${params.aspect_ratio}.`;
|
|
73
|
+
const result = await ctx.modelRegistry.generateImages(model, { input: [{ type: 'text', text }, ...references] }, signal === undefined ? undefined : { signal });
|
|
74
|
+
if (result.stopReason !== 'stop') throw new Error(`Image generation failed: ${result.errorMessage ?? result.stopReason}`);
|
|
75
|
+
const images = result.output.filter((block): block is ImageBlock => block.type === 'image');
|
|
76
|
+
if (images.length === 0) throw new Error('The image model returned no image.');
|
|
77
|
+
const dir = options.artifactsDir(ctx);
|
|
78
|
+
mkdirSync(dir, { recursive: true });
|
|
79
|
+
const now = new Date();
|
|
80
|
+
const paths = images.map((image, index) => {
|
|
81
|
+
const path = join(dir, imageFileName(params.filename, image.mimeType, index, now));
|
|
82
|
+
writeFileSync(path, Buffer.from(image.data, 'base64'));
|
|
83
|
+
return path;
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: 'text' as const, text: `Generated ${paths.length === 1 ? 'an image' : `${paths.length} images`} with ${model.provider}/${model.id}, saved to ${paths.join(', ')}.` }, ...images],
|
|
87
|
+
details: { paths, model: `${model.provider}/${model.id}` },
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// The tool is on only while an image model is available, as Cursor offers GenerateImage only where it can run.
|
|
93
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
94
|
+
if (!options.mayOffer() || (await imageModel(ctx)) === undefined) return;
|
|
95
|
+
const active = pi.getActiveTools();
|
|
96
|
+
if (!active.includes(GENERATE_IMAGE)) pi.setActiveTools([...active, GENERATE_IMAGE]);
|
|
97
|
+
});
|
|
98
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
|
|
1
3
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
4
|
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
3
5
|
import { aliasInputResult, aliasTable, packageSkillNames } from './aliases.ts';
|
|
4
6
|
import { registerAskQuestion } from './ask.ts';
|
|
5
7
|
import { aliasAutocomplete } from './autocomplete.ts';
|
|
6
8
|
import { childIdentity } from './child-env.ts';
|
|
9
|
+
import { registerGenerateImage } from './generate-image.ts';
|
|
7
10
|
import { registerLoop } from './loop.ts';
|
|
8
11
|
import { registerMode } from './mode.ts';
|
|
9
12
|
import { canAsk, isOneShot } from './modes.ts';
|
|
10
|
-
import { createPathSkills } from './path-skills.ts';
|
|
11
13
|
import { artifactsDir, PACKAGE_ROOT, projectsDir, rootStoreDir, rulePath, SKILLS_DIR, storeDir, subagentDir } from './paths.ts';
|
|
12
14
|
import { registerPlanMode } from './plan.ts';
|
|
13
15
|
import { registerReadonlyShell } from './readonly-shell.ts';
|
|
@@ -23,10 +25,14 @@ import { registerVisualize } from './visualize.ts';
|
|
|
23
25
|
const WATCHDOG_MS = 1_000;
|
|
24
26
|
const ORPHAN_EXIT_MS = 5_000;
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
// What Cursor tells every subagent in a system reminder, minus its sentence about Cursor Canvas files.
|
|
29
|
+
const SUBAGENT_REMINDER = 'You are running as a subagent under a parent agent. Do not spawn additional subagents unless requested by the user or by your instructions.';
|
|
30
|
+
|
|
31
|
+
/** In a subagent, Cursor's subagent reminder, and the agent definition body as the agent's own instructions. */
|
|
27
32
|
function agentSection(agents: readonly AgentDefinition[]): Record<string, string> {
|
|
33
|
+
if (childIdentity.depth === 0) return {};
|
|
28
34
|
const prompt = agents.find((agent) => agent.type === childIdentity.agentType)?.prompt ?? '';
|
|
29
|
-
return prompt === '' ? {} : { pstack_agent: prompt };
|
|
35
|
+
return { pstack_subagent: SUBAGENT_REMINDER, ...(prompt === '' ? {} : { pstack_agent: prompt }) };
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
function hostSections(pi: ExtensionAPI, ctx: ExtensionContext, agents: readonly AgentDefinition[]): Record<string, string> {
|
|
@@ -97,9 +103,11 @@ export default function piPstack(pi: ExtensionAPI): void {
|
|
|
97
103
|
});
|
|
98
104
|
|
|
99
105
|
const mode = registerMode(pi);
|
|
100
|
-
const plan = registerPlanMode(pi);
|
|
106
|
+
const plan = registerPlanMode(pi, { buildInWorker: (planFile, request) => task.startBackground(`Build ${basename(planFile)}`, request, { environment: 'cloud' }) });
|
|
101
107
|
registerVisualize(pi, { artifactsDir: (ctx) => artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()) });
|
|
102
|
-
|
|
108
|
+
registerGenerateImage(pi, { artifactsDir: (ctx) => artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()), mayOffer: () => !childIdentity.readonly && !plan.enabled() });
|
|
109
|
+
const ownType = agents.find((agent) => agent.type === childIdentity.agentType);
|
|
110
|
+
const task = registerTask(pi, { agents, depth: childIdentity.depth, canSpawn: childIdentity.depth < 2 && ownType?.spawns !== false, planMode: plan.enabled });
|
|
103
111
|
registerAgentStatus(pi);
|
|
104
112
|
registerSubscriptions(pi, {
|
|
105
113
|
runFresh: async (prompt, name, meta) => {
|
|
@@ -112,25 +120,21 @@ export default function piPstack(pi: ExtensionAPI): void {
|
|
|
112
120
|
});
|
|
113
121
|
const loop = registerLoop(pi, { hasPendingWork: task.hasBackgroundWork });
|
|
114
122
|
const todos = registerTodoWrite(pi);
|
|
115
|
-
const pathSkills = createPathSkills(pi);
|
|
116
123
|
registerAskQuestion(pi);
|
|
117
124
|
if (childIdentity.parentPid !== undefined) watchParent(pi, childIdentity.parentPid);
|
|
118
125
|
if (childIdentity.readonly) registerReadonlyShell(pi);
|
|
119
126
|
|
|
120
127
|
pi.on('before_agent_start', (event, ctx) => {
|
|
121
|
-
const modeText = mode.section();
|
|
122
128
|
const loopText = loop.section();
|
|
123
129
|
const planText = plan.section();
|
|
124
130
|
const todoText = todos.section();
|
|
125
|
-
const pathSkillText = pathSkills.section();
|
|
126
131
|
Object.assign(
|
|
127
132
|
event.systemPromptOptions.sections,
|
|
128
133
|
hostSections(pi, ctx, agents),
|
|
129
|
-
|
|
134
|
+
mode.sections(),
|
|
130
135
|
loopText === undefined ? {} : { pstack_loop: loopText },
|
|
131
136
|
planText === undefined ? {} : { pstack_plan: planText },
|
|
132
137
|
todoText === undefined ? {} : { pstack_todos: todoText },
|
|
133
|
-
pathSkillText === undefined ? {} : { pstack_path_skills: pathSkillText },
|
|
134
138
|
);
|
|
135
139
|
});
|
|
136
140
|
|
package/src/loop.ts
CHANGED
|
@@ -75,6 +75,8 @@ interface Loop {
|
|
|
75
75
|
iteration: number;
|
|
76
76
|
nextWakeMs?: number;
|
|
77
77
|
timer?: ReturnType<typeof setTimeout>;
|
|
78
|
+
/** A wake came due while a run was going; it fires once when that run settles. */
|
|
79
|
+
due?: boolean;
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
const formatMs = (ms: number) => (ms % 86_400_000 === 0 ? `${ms / 86_400_000}d` : ms % 3_600_000 === 0 ? `${ms / 3_600_000}h` : ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 1_000)}s`);
|
|
@@ -107,39 +109,45 @@ function createLoopController(pi: ExtensionAPI, options: LoopOptions) {
|
|
|
107
109
|
clear();
|
|
108
110
|
notify(ctx, `Loop stopped: ${reason}`);
|
|
109
111
|
};
|
|
110
|
-
const
|
|
112
|
+
const run = () => {
|
|
111
113
|
if (loop === undefined) return;
|
|
112
|
-
|
|
113
|
-
if (!first && lastCtx !== undefined && !lastCtx.isIdle()) {
|
|
114
|
-
if (loop.intervalMs !== undefined) loop = { ...loop, timer: setTimeout(fire, loop.intervalMs) };
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
const { timer: _timer, nextWakeMs: _wake, ...rest } = loop;
|
|
118
|
-
loop = { ...rest, iteration: rest.iteration + 1 };
|
|
114
|
+
loop = { ...loop, iteration: loop.iteration + 1, due: false };
|
|
119
115
|
// Expansion lets a loop run a skill or template each time.
|
|
120
116
|
pi.sendUserMessage(loop.prompt, { expandPromptTemplates: true });
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* A timer fired. A fixed loop keeps its wall-clock cadence, like Cursor's sleep-and-echo shell loop, so a long run
|
|
120
|
+
* does not push the next tick out. A wake that lands while a run is going is delivered once that run settles, as
|
|
121
|
+
* Cursor's monitored shell output notifies after the turn, and several such wakes fire once, not once each.
|
|
122
|
+
*/
|
|
123
|
+
const tick = () => {
|
|
124
|
+
if (loop === undefined) return;
|
|
125
|
+
const { timer: _spent, nextWakeMs: _wake, ...rest } = loop;
|
|
126
|
+
loop = rest.intervalMs === undefined ? rest : { ...rest, timer: setTimeout(tick, rest.intervalMs) };
|
|
127
|
+
if (lastCtx !== undefined && !lastCtx.isIdle()) loop = { ...loop, due: true };
|
|
128
|
+
else run();
|
|
123
129
|
};
|
|
124
130
|
const settled = (ctx: ExtensionContext, aborted: boolean) => {
|
|
125
131
|
lastCtx = ctx;
|
|
126
132
|
if (loop === undefined) return;
|
|
127
133
|
if (aborted) return stop(ctx, 'the run was aborted.');
|
|
134
|
+
if (loop.due === true) return run();
|
|
128
135
|
if (loop.timer !== undefined) return;
|
|
129
136
|
if (loop.intervalMs !== undefined) {
|
|
130
137
|
// An armed fixed loop has no timer yet; its first tick waits for this turn to end.
|
|
131
|
-
loop = { ...loop, timer: setTimeout(
|
|
138
|
+
loop = { ...loop, timer: setTimeout(tick, loop.intervalMs) };
|
|
132
139
|
return;
|
|
133
140
|
}
|
|
134
141
|
const delay = loop.nextWakeMs;
|
|
135
|
-
if (delay !== undefined) loop = { ...loop, timer: setTimeout(
|
|
142
|
+
if (delay !== undefined) loop = { ...loop, timer: setTimeout(tick, delay) };
|
|
136
143
|
else if (!options.hasPendingWork()) stop(ctx, 'the agent scheduled no next wake.');
|
|
137
144
|
};
|
|
138
145
|
const start = (ctx: ExtensionContext, prompt: string, intervalMs: number | undefined) => {
|
|
139
146
|
if (loop !== undefined) stop(ctx, 'replaced by a new /loop.');
|
|
140
147
|
lastCtx = ctx;
|
|
141
148
|
loop = { prompt, iteration: 0, ...(intervalMs === undefined ? {} : { intervalMs }) };
|
|
142
|
-
|
|
149
|
+
run();
|
|
150
|
+
if (loop.intervalMs !== undefined) loop = { ...loop, timer: setTimeout(tick, loop.intervalMs) };
|
|
143
151
|
};
|
|
144
152
|
/** An agent arms a loop mid-turn, so its first run waits for this turn to settle and then for the first delay. */
|
|
145
153
|
const arm = (ctx: ExtensionContext, prompt: string, schedule: { intervalMs: number } | { firstWakeMs: number }) => {
|