@justjoshn/pi-pstack 0.15.36-pi.14 → 0.15.36-pi.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.15.36-pi.16] - 2026-11-04
4
+
5
+ Each item below is checked against the Cursor CLI 2026.10.22 bundle, Cursor's built-in skills, or Cursor's docs. `parity/pstack-parity-2026-11-05/` in the repository records the evidence, the decisions, and the behavior inventory.
6
+
7
+ - Attach every skill a message names, as Cursor's `findSkillReferences` does: a `/name` after the start of the message attaches its skill too, and several skills ride one message, each once, in order. Before, only a leading `/name` attached a skill.
8
+ - Return Cursor's reject reason from `AskQuestion` when nobody can answer: `Questions skipped by the user, continue with the information you already have`, or `Subagent cannot ask questions` in a subagent.
9
+ - Inject the two always-applied team-kit rules with their exact bodies. The port had added a heading to each.
10
+ - Keep a `/loop` running when one of its runs is interrupted. Cursor's loop is a background shell that a turn interrupt leaves running, so only `/loop stop` or the `Loop` tool's `stop` ends it.
11
+ - Run the built-in `explore` subagent on the parent model's `-fast` variant when Pi lists one, as Cursor's explore uses a faster model by default.
12
+
13
+ ## [0.15.36-pi.15] - 2026-11-04
14
+
15
+ - Match watched shell output before a newline or process exit. Count every occurrence within a chunk and retain matches across chunk boundaries.
16
+ - Match the captured observer's batching, minimum debounce, completion flush, and 100-occurrence limit notice.
17
+ - Decode stdout and stderr independently so split UTF-8 characters reach the output file and monitor intact.
18
+ - Pin `re2js` 1.2.2 for output-watch patterns. Invalid patterns warn without stopping the command. A missing dependency leaves unrelated tools available but rejects a valid requested watch before the command runs.
19
+ - Record the pinned reference, original-component comparisons, and open requirements in `parity/pstack-full-parity-2026-11-04/`. Whole-plugin parity remains `NOT VERIFIED`.
20
+
3
21
  ## [0.15.36-pi.14] - 2026-11-04
4
22
 
5
23
  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.
package/README.md CHANGED
@@ -26,10 +26,10 @@ Remove the package with `pi remove ./extensions/pi-pstack`. Its state lives in `
26
26
 
27
27
  | You type | What happens |
28
28
  |---|---|
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. |
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`. A `/name` later in the message attaches its skill too, such as `design the metrics. /architect this first.`, and every skill a message names attaches once, in order. `/skill:<name>` works too and attaches only that skill. |
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 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. |
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 interrupts one run and leaves the loop running. `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. |
@@ -45,12 +45,12 @@ Remove the package with `pi remove ./extensions/pi-pstack`. Its state lives in `
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 | 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. |
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`, that runs on the parent model's `-fast` variant when Pi lists one), 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 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 each question comes back skipped. Subagents have no `AskQuestion`. |
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 the call comes back as `Questions skipped by the user, continue with the information you already have`. 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. |
@@ -14,7 +14,7 @@ A path and byte comparison of the tracked trees shows every pstack skill, both p
14
14
 
15
15
  | Cursor dependency | Pi mechanism |
16
16
  | --- | --- |
17
- | Skill invocation and the slash menu | `pi.getCommands()` plus `/name` to `/skill:name` input rewriting, with TUI completions |
17
+ | Skill invocation and the slash menu | `pi.getCommands()` plus an input transform that attaches every `/name` a message names, the first through Pi's `/skill:name` expansion and the rest as skill blocks, with TUI completions |
18
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
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` |
@@ -45,6 +45,16 @@ 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.16
49
+
50
+ A run on 2026-11-05 (UTC) compared pi-pstack with the Cursor CLI 2026.10.22 bundle, Cursor's built-in skills, and Cursor's docs, without starting any Cursor or Pi model session. The repository's `parity/pstack-parity-2026-11-05/` holds the decision trail, the behavior inventory (`inventory.md`, `inventory.json`), and the scripts that rebuild them.
51
+
52
+ - **Skill references anywhere in a message.** The CLI's `findSkillReferences` scans the whole submitted message with `(?:^|\s)/([a-zA-Z0-9_-]+)` and attaches every skill it names, each once, on both the interactive and headless submit paths. pi-pstack attached only a leading `/name`, so the README's `design this instrumentation ... /architect this first.` lost the skill. Every referenced skill now attaches, in order of first reference.
53
+ - **AskQuestion without a person.** The CLI's headless path rejects with `Questions skipped by the user, continue with the information you already have`, and its subagent runner with `Subagent cannot ask questions`. pi-pstack now returns those reasons.
54
+ - **Rule text.** The two always-applied team-kit rule bodies are injected exactly. The port had added a heading above each.
55
+ - **Interrupted loop runs.** Cursor's local `/loop` is a background shell that echoes a sentinel. The interactive CLI never aborts background work when the user interrupts a turn (`abortAllWork` runs only in `src/headless.ts`), so the loop keeps ticking. pi-pstack had stopped every loop on Escape. A loop now keeps running until `/loop stop` or the `Loop` tool's `stop`.
56
+ - **Explore model.** Cursor's docs state that the built-in explore subagent uses a faster model by default. pi-pstack's `explore` now defaults to the parent model's `-fast` variant when Pi lists one, the same rule as `ci-watcher`'s `model: fast`.
57
+
48
58
  ## Repairs in 0.15.36-pi.14
49
59
 
50
60
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justjoshn/pi-pstack",
3
- "version": "0.15.36-pi.14",
3
+ "version": "0.15.36-pi.16",
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",
@@ -65,6 +65,7 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "@anthropic-ai/sandbox-runtime": "0.0.79",
68
+ "re2js": "1.2.2",
68
69
  "yaml": "2.9.0"
69
70
  },
70
71
  "peerDependencies": {
@@ -4,8 +4,6 @@ description: Keep imports at top of file and avoid inline imports
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
7
- # No Inline Imports
8
-
9
7
  # No inline imports
10
8
 
11
9
  Always place imports at the top of the module. Avoid inline imports in function bodies, type annotations, or interface fields unless there is a strict circular-dependency reason and it is documented.
@@ -5,7 +5,7 @@
5
5
  1. When the change is one or two files with an obvious approach, skip the plan. Say so and stop.
6
6
  2. Settle open questions by prototype before you write. Run `playbooks/prototype.md` for each. Keep the branch, the SHA, and the screenshots for Appendix A. Ask the operator only about a product or preference call that no run can settle. Give options (the **never-block-on-the-human** principle skill).
7
7
  3. Explore in subagents with `subagent_type: "poteto-agent"` and an explicit model per the Subagents section (the **guard-the-context-window** principle skill). 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. Each returns file pointers, conventions, test commands, and entry points. No inlined dumps.
8
- 4. Copy the skeleton below into the plan file and fill every placeholder. `<SKILLS>` is the pi-pstack skills directory the system prompt names. Fill `<pi-pstack path>` with the program repository's path to its pi-pstack copy. If the repository has none, write each `git show origin/main:<pi-pstack path>/...` box as a `read` of the same file under `<SKILLS>`. Unless the operator names a path, write the file under the agent store's `docs/`. Keep every heading and every sub-block in the order shown. One section per PR. One PR is one change with its own evidence (the **sequence-verifiable-units** principle skill). Name the execution playbook in **How to read this**. Pick `playbooks/autopilot-full.md` when the run may merge. Pick `playbooks/autopilot-stack.md` when the operator lands the PRs. A standing program takes `playbooks/orchestrate.md`.
8
+ 4. Copy the skeleton below into the plan file and fill every placeholder. `<SKILLS>` is the pi-pstack skills directory the system prompt names. Fill `<pi-pstack path>` with the program repository's path to its pi-pstack copy. Unless the operator names a path, write the file under the agent store's `docs/`. Keep every heading and every sub-block in the order shown. One section per PR. One PR is one change with its own evidence (the **sequence-verifiable-units** principle skill). Name the execution playbook in **How to read this**. Pick `playbooks/autopilot-full.md` when the run may merge. Pick `playbooks/autopilot-stack.md` when the operator lands the PRs. A standing program takes `playbooks/orchestrate.md`.
9
9
  5. Write under `/technical-writing` in full, then `/unslop`. The body is one Diátaxis mode, how-to. Appendices hold explanation and reference. Each heading states the task or the finding. No long dashes. No mid-sentence colons.
10
10
  6. Run `node <SKILLS>/poteto-mode/scripts/check-plan.mjs <plan.md>` and fix every line it prints (the **encode-lessons-in-structure** principle skill).
11
11
  7. Hand back. Post the plan path and the script's output, then stop. Execution starts on the operator's explicit go, under the execution playbook the plan names.
@@ -4,7 +4,5 @@ description: Use exhaustive switch handling for TypeScript unions and enums
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
7
- # Typescript Exhaustive Switch
8
-
9
7
  typescript-exhaustive-switch: In switch statements over discriminated unions or enums, use a `never` check in the default case so newly added variants cause compile-time failures until handled.
10
8
 
package/src/aliases.ts CHANGED
@@ -4,19 +4,27 @@ import { sep } from 'node:path';
4
4
  import type { ImageContent } from '@earendil-works/pi-ai';
5
5
  import type { SlashCommandInfo } from '@earendil-works/pi-coding-agent';
6
6
  import { SKILLS_DIR } from './paths.ts';
7
+ import { skillBlock } from './skill-block.ts';
7
8
 
8
9
  const ALIAS = /^\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/;
10
+ // Cursor CLI `findSkillReferences`: every `/name` at the start or after whitespace that names a skill attaches it.
11
+ const REFERENCE = /(?:^|\s)\/([a-zA-Z0-9_-]+)/g;
9
12
  const SKILL_PREFIX = 'skill:';
10
13
 
11
14
  const isSkill = (command: SlashCommandInfo) => command.source === 'skill' && command.name.startsWith(SKILL_PREFIX);
12
15
 
16
+ /** Names that prompt templates and extension commands own. Pi expands a leading template after the input event. */
17
+ export function commandNames(commands: readonly SlashCommandInfo[]): ReadonlySet<string> {
18
+ return new Set(commands.filter((command) => command.source !== 'skill').map((command) => command.name));
19
+ }
20
+
13
21
  /**
14
22
  * `/name` aliases with their descriptions: every skill Pi loaded, so any skill attaches by name
15
23
  * (pstack itself creates skills such as `/<handle>-mode`), minus any name a prompt template or extension command owns.
16
24
  * Settings that filter a skill out also drop its alias. Pi handles its built-in commands before the input event.
17
25
  */
18
26
  export function aliasTable(commands: readonly SlashCommandInfo[]): ReadonlyMap<string, string> {
19
- const taken = new Set(commands.filter((command) => command.source !== 'skill').map((command) => command.name));
27
+ const taken = commandNames(commands);
20
28
  const table = new Map<string, string>();
21
29
  for (const command of commands.filter(isSkill)) {
22
30
  const name = command.name.slice(SKILL_PREFIX.length);
@@ -25,6 +33,14 @@ export function aliasTable(commands: readonly SlashCommandInfo[]): ReadonlyMap<s
25
33
  return table;
26
34
  }
27
35
 
36
+ /** A loaded skill's block by name, read when a message names it. */
37
+ export const loadedSkillBlock =
38
+ (commands: readonly SlashCommandInfo[]) =>
39
+ (name: string): string | undefined => {
40
+ const path = commands.find((command) => isSkill(command) && command.name === `${SKILL_PREFIX}${name}`)?.sourceInfo.path;
41
+ return path === undefined ? undefined : skillBlock(name, path);
42
+ };
43
+
28
44
  // Pi records a skill path as configured, which can run through a symlink; realpaths are cached per runtime.
29
45
  const realPaths = new Map<string, string | undefined>();
30
46
  function realPath(path: string): string | undefined {
@@ -41,24 +57,36 @@ export function packageSkillNames(commands: readonly SlashCommandInfo[], skillsD
41
57
  .toSorted();
42
58
  }
43
59
 
60
+ /** The aliased skills a message names, each once, in order of first reference. */
61
+ export function skillReferences(text: string, names: ReadonlySet<string>): string[] {
62
+ const found: string[] = [];
63
+ for (const [, name = ''] of text.matchAll(REFERENCE)) if (names.has(name) && !found.includes(name)) found.push(name);
64
+ return found;
65
+ }
66
+
44
67
  /**
45
- * Rewrites `/name args` to Pi's native `/skill:name args` for an aliased skill, keeping the argument text.
46
- * Pi splits the skill name at the first space and trims the arguments, so a space always follows the name.
68
+ * Attaches every skill the message names, as Cursor does, and keeps the message. The first skill rides Pi's native
69
+ * `/skill:name args` expansion; a leading `/name` becomes that command, as Pi's own syntax drops it. Each further skill
70
+ * is a block at the head of the arguments. Pi splits the skill name at the first space and trims the arguments.
71
+ * An explicit `/skill:` command is Pi's own and stays as typed, and so does a message that starts with a prompt
72
+ * template, which Pi can expand only while the message still starts with it.
47
73
  */
48
- export function aliasTransform(text: string, names: ReadonlySet<string>): string | undefined {
49
- const match = ALIAS.exec(text);
50
- const name = match?.[1];
51
- if (match === null || name === undefined || !names.has(name)) return undefined;
52
- const rest = text.slice(match[0].length);
53
- if (rest.length === 0) return `/skill:${name}`;
54
- return `/skill:${name} ${rest.startsWith(' ') ? rest.slice(1) : rest}`;
74
+ export function aliasTransform(text: string, names: ReadonlySet<string>, blockFor: (name: string) => string | undefined, commands: ReadonlySet<string> = new Set()): string | undefined {
75
+ if (text.startsWith(`/${SKILL_PREFIX}`) || commands.has(/^\/(\S+)/.exec(text)?.[1] ?? '')) return undefined;
76
+ const [first, ...others] = skillReferences(text, names);
77
+ if (first === undefined) return undefined;
78
+ const leading = ALIAS.exec(text);
79
+ const rest = leading?.[1] === first ? text.slice(leading[0].length) : undefined;
80
+ const body = rest === undefined ? text : rest.startsWith(' ') ? rest.slice(1) : rest;
81
+ const args = [...others.flatMap((name) => blockFor(name) ?? []), ...(body === '' ? [] : [body])].join('\n\n');
82
+ return args === '' ? `/skill:${first}` : `/skill:${first} ${args}`;
55
83
  }
56
84
 
57
85
  export type AliasInputResult = { action: 'continue' } | { action: 'transform'; text: string; images?: ImageContent[] };
58
86
 
59
- /** The `input` handler result: Pi's native skill command for an aliased skill, otherwise continue unchanged. */
60
- export function aliasInputResult(event: { text: string; images?: ImageContent[] }, names: ReadonlySet<string>): AliasInputResult {
61
- const text = aliasTransform(event.text, names);
87
+ /** The `input` handler result: Pi's native skill command carrying every referenced skill, otherwise continue unchanged. */
88
+ export function aliasInputResult(event: { text: string; images?: ImageContent[] }, names: ReadonlySet<string>, blockFor: (name: string) => string | undefined, commands: ReadonlySet<string> = new Set()): AliasInputResult {
89
+ const text = aliasTransform(event.text, names, blockFor, commands);
62
90
  if (text === undefined) return { action: 'continue' };
63
91
  return { action: 'transform', text, ...(event.images === undefined ? {} : { images: event.images }) };
64
92
  }
package/src/ask.ts CHANGED
@@ -7,7 +7,9 @@ export const DONE_LABEL = '✓ Done selecting';
7
7
  // Every question ends with a typed-answer option.
8
8
  export const OTHER_LABEL = 'Other: (type to answer)';
9
9
  export const OTHER_ID = 'other';
10
- const SKIPPED = 'skipped (no one can answer questions in this session; decide yourself and say what you chose)';
10
+ // Cursor CLI's reject reasons: src/headless.ts for a run with nobody to answer, the local subagent runner for a subagent.
11
+ const SKIPPED = 'Questions skipped by the user, continue with the information you already have';
12
+ const SUBAGENT_SKIPPED = 'Subagent cannot ask questions';
11
13
 
12
14
  export const AskParams = Type.Object({
13
15
  title: Type.Optional(Type.String({ description: 'Optional title for the questions form' })),
@@ -130,7 +132,7 @@ export function registerAskQuestion(pi: ExtensionAPI): void {
130
132
  validateQuestions(params.questions);
131
133
  // Print and JSON runs and subagents have no one to answer, so the questions come back skipped.
132
134
  if (!canAsk(ctx)) {
133
- const text = params.questions.map((question) => `${question.id}: ${SKIPPED}`).join('\n');
135
+ const text = childIdentity.depth > 0 ? SUBAGENT_SKIPPED : SKIPPED;
134
136
  return { content: [{ type: 'text', text }], details: { answers: {}, skipped: params.questions.map((question) => question.id) } };
135
137
  }
136
138
  const answers: Record<string, string[]> = {};
@@ -5,12 +5,14 @@ import { type ChildProcess, spawn } from 'node:child_process';
5
5
  import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, statSync, writeSync } from 'node:fs';
6
6
  import { constants } from 'node:os';
7
7
  import { join, resolve } from 'node:path';
8
+ import { StringDecoder } from 'node:string_decoder';
8
9
 
9
10
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
10
11
  import { getShellConfig, truncateTail } from '@earendil-works/pi-coding-agent';
11
12
  import { Text } from '@earendil-works/pi-tui';
12
13
  import { Type } from 'typebox';
13
14
  import { isOneShot } from './modes.ts';
15
+ import { OutputWatchDependencyError, type OutputWatchRequest, ShellOutputWatch } from './shell-output-watch.ts';
14
16
 
15
17
  export const SHELL_NOTICE_TYPE = 'pstack/shell';
16
18
  const BACKGROUND_TOOL = 'bash_background';
@@ -18,7 +20,6 @@ const AWAIT_TOOL = 'AwaitShell';
18
20
 
19
21
  const FIELD_WIDTH = 9;
20
22
  const HEADER_REFRESH_MS = 5_000;
21
- const MIN_DEBOUNCE_MS = 5_000;
22
23
  const DEFAULT_AWAIT_MS = 30_000;
23
24
  const MAX_AWAIT_MS = 7_140_000;
24
25
  const KILL_GRACE_MS = 2_000;
@@ -27,20 +28,6 @@ const FOLLOW_UP = 'Briefly inform the user about the task result and perform any
27
28
  type JobStatus = 'running' | 'succeeded' | 'failed' | 'aborted';
28
29
  type NoticeStatus = 'success' | 'error' | 'aborted';
29
30
 
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
31
  interface ShellJob {
45
32
  readonly id: string;
46
33
  readonly pid: number;
@@ -55,7 +42,7 @@ interface ShellJob {
55
42
  readonly statusOffset: number;
56
43
  readonly runningOffset: number;
57
44
  readonly child: ChildProcess;
58
- readonly watch: OutputWatch | undefined;
45
+ readonly watch: ShellOutputWatch | undefined;
59
46
  readonly listeners: Set<() => void>;
60
47
  readonly done: Promise<void>;
61
48
  /** Where the next output byte goes; the header is rewritten in place, so the file is never opened for append. */
@@ -145,9 +132,6 @@ export function noticeText(notice: ShellNotice): string {
145
132
  ].join('\n');
146
133
  }
147
134
 
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
135
  function exitStatus(code: number | null, signal: NodeJS.Signals | null): { status: JobStatus; exitCode: number } {
152
136
  if (signal !== null) return { status: 'aborted', exitCode: 128 + (constants.signals[signal] ?? 0) };
153
137
  const exitCode = code ?? 1;
@@ -215,7 +199,7 @@ export interface StartRequest {
215
199
  cwd: string;
216
200
  title: string;
217
201
  toolCallId: string;
218
- watch?: { pattern: string; reason: string; debounceMs?: number };
202
+ watch?: OutputWatchRequest;
219
203
  }
220
204
 
221
205
  /** Every background shell this runtime started, and the notifications they owe the agent. */
@@ -245,50 +229,6 @@ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: She
245
229
  for (const listener of [...job.listeners]) listener();
246
230
  };
247
231
 
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
232
  const finish = (job: ShellJob, status: JobStatus, exitCode: number, announce: boolean) => {
293
233
  if (job.status !== 'running') return;
294
234
  const endedAt = Date.now();
@@ -299,8 +239,8 @@ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: She
299
239
  job.bodyEnd = job.size;
300
240
  append(job, terminalFooter(exitCode, endedAt - job.startedAt, endedAt));
301
241
  closeSync(job.fd);
242
+ job.watch?.finish(announce);
302
243
  if (announce) {
303
- flushWatch(job);
304
244
  if (!job.observed) {
305
245
  const noticeStatus: NoticeStatus = status === 'succeeded' ? 'success' : status === 'failed' ? 'error' : 'aborted';
306
246
  deliver({
@@ -313,17 +253,27 @@ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: She
313
253
  ...(noticeStatus === 'error' ? { detail: `exit_code=${exitCode}` } : {}),
314
254
  });
315
255
  }
316
- } else if (job.watch?.window !== undefined) clearTimeout(job.watch.window);
256
+ }
317
257
  notify(job);
318
258
  };
319
259
 
320
260
  const start = (ctx: ExtensionContext, request: StartRequest): ShellJob => {
321
261
  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
262
  const dir = options.terminalsDir(ctx);
324
263
  mkdirSync(dir, { recursive: true });
325
264
  const id = allocateId(dir);
326
265
  const path = join(dir, `${id}.txt`);
266
+ let watch: ShellOutputWatch | undefined;
267
+ if (request.watch !== undefined && request.watch.pattern.trim() !== '') {
268
+ try {
269
+ watch = new ShellOutputWatch(request.watch, (notice) => {
270
+ deliver({ kind: 'progress', status: 'success', taskId: `${id}:task_progress:${notice.sequence}`, shellId: id, title: notice.title, toolCallId: request.toolCallId, detail: notice.detail, outputPath: path });
271
+ });
272
+ } catch (error) {
273
+ if (error instanceof OutputWatchDependencyError) throw error;
274
+ console.warn('Invalid shell output notification pattern', { shellId: id, error: error instanceof Error ? error.message : String(error) });
275
+ }
276
+ }
327
277
  const config = getShellConfig(options.shellPath());
328
278
  const viaStdin = config.commandTransport === 'stdin';
329
279
  const child = spawn(config.shell, viaStdin ? config.args : [...config.args, request.command], {
@@ -357,21 +307,7 @@ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: She
357
307
  statusOffset: header.statusOffset,
358
308
  runningOffset: header.runningOffset,
359
309
  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
- },
310
+ watch,
375
311
  listeners: new Set(),
376
312
  done: new Promise<void>((resolveDone) => {
377
313
  release = resolveDone;
@@ -388,14 +324,24 @@ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: She
388
324
  append(job, header.text);
389
325
  job.refresh = setInterval(() => refreshRunning(job), HEADER_REFRESH_MS);
390
326
  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);
327
+ for (const stream of [child.stdout, child.stderr]) {
328
+ if (stream === null) continue;
329
+ const decoder = new StringDecoder('utf8');
330
+ stream.on('data', (chunk: Buffer) => {
331
+ if (job.status !== 'running') return;
332
+ const text = decoder.write(chunk);
333
+ append(job, text);
334
+ job.watch?.observe(text);
335
+ notify(job);
336
+ });
337
+ stream.on('end', () => {
338
+ if (job.status !== 'running') return;
339
+ const text = decoder.end();
340
+ append(job, text);
341
+ job.watch?.observe(text);
342
+ notify(job);
343
+ });
344
+ }
399
345
  child.on('error', (error) => {
400
346
  if (job.status === 'running') append(job, `${error.message}\n`);
401
347
  finish(job, 'failed', 127, true);
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import { basename } from 'node:path';
2
2
 
3
3
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
4
4
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
5
- import { aliasInputResult, aliasTable, packageSkillNames } from './aliases.ts';
5
+ import { aliasInputResult, aliasTable, commandNames, loadedSkillBlock, packageSkillNames } from './aliases.ts';
6
6
  import { registerAskQuestion } from './ask.ts';
7
7
  import { aliasAutocomplete } from './autocomplete.ts';
8
8
  import { registerBackgroundShell } from './background-shell.ts';
@@ -100,7 +100,10 @@ function watchParent(pi: ExtensionAPI, parentPid: number): void {
100
100
  export default function piPstack(pi: ExtensionAPI): void {
101
101
  const agents = loadAgentDefinitions();
102
102
 
103
- pi.on('input', (event) => aliasInputResult(event, new Set(aliasTable(pi.getCommands()).keys())));
103
+ pi.on('input', (event) => {
104
+ const commands = pi.getCommands();
105
+ return aliasInputResult(event, new Set(aliasTable(commands).keys()), loadedSkillBlock(commands), commandNames(commands));
106
+ });
104
107
  pi.on('session_start', (_event, ctx) => {
105
108
  if (ctx.mode === 'tui') ctx.ui.addAutocompleteProvider((current) => aliasAutocomplete(current, aliasTable(pi.getCommands())));
106
109
  });
package/src/loop.ts CHANGED
@@ -119,10 +119,13 @@ function createLoopController(pi: ExtensionAPI, options: LoopOptions) {
119
119
  if (lastCtx !== undefined && !lastCtx.isIdle()) loop = { ...loop, due: true };
120
120
  else run();
121
121
  };
122
- const settled = (ctx: ExtensionContext, aborted: boolean) => {
122
+ /**
123
+ * A run settled. An aborted run is no stop: Cursor's loop is a background shell that a turn interrupt leaves
124
+ * running, so the user or the agent stops a loop explicitly.
125
+ */
126
+ const settled = (ctx: ExtensionContext) => {
123
127
  lastCtx = ctx;
124
128
  if (loop === undefined) return;
125
- if (aborted) return stop(ctx, 'the run was aborted.');
126
129
  if (loop.due === true) return run();
127
130
  if (loop.timer !== undefined) return;
128
131
  if (loop.intervalMs !== undefined) {
@@ -276,8 +279,8 @@ export function registerLoop(pi: ExtensionAPI, options: LoopOptions): { section:
276
279
  pi.on('session_start', (_event, ctx) => {
277
280
  if (isOneShot(ctx.mode)) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== 'Loop'));
278
281
  });
279
- pi.on('agent_settled', (event, ctx) => {
280
- for (const [, controller] of registry.entries()) controller.settled(ctx, event.aborted);
282
+ pi.on('agent_settled', (_event, ctx) => {
283
+ for (const [, controller] of registry.entries()) controller.settled(ctx);
281
284
  });
282
285
  pi.on('session_shutdown', () => {
283
286
  for (const [, controller] of registry.entries()) controller.clear();
package/src/mode.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { dirname } from 'node:path';
3
2
 
4
3
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ThemeColor } from '@earendil-works/pi-coding-agent';
5
- import { parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent';
4
+ import { parseFrontmatter } from '@earendil-works/pi-coding-agent';
5
+ import { skillBlock } from './skill-block.ts';
6
6
 
7
7
  export const MODE_ENTRY = 'pstack/mode';
8
8
  const STATUS_KEY = 'pstack-mode';
@@ -100,10 +100,8 @@ export function modeNotice(state: ModeState): string | undefined {
100
100
  * through compaction and restarts, and Pi re-sends a section only when it changes.
101
101
  */
102
102
  export function modeSkill(mode: ActiveMode): string | undefined {
103
- if (mode.path === undefined || !existsSync(mode.path)) return undefined;
104
103
  // The frontmatter stays out of the model's context in a mode (MODE-004).
105
- const body = stripFrontmatter(readFileSync(mode.path, 'utf8')).trim();
106
- return [`<skill name="${mode.skill}" location="${mode.path}">`, `References are relative to ${dirname(mode.path)}.`, '', body, '</skill>'].join('\n');
104
+ return mode.path === undefined ? undefined : skillBlock(mode.skill, mode.path);
107
105
  }
108
106
 
109
107
  // A mode's badge shows the skill's `icon` (default: lightning) and `color`. Icon names map to terminal glyphs and
@@ -0,0 +1,126 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ import type { RE2JS } from 're2js';
4
+
5
+ const require = createRequire(import.meta.url);
6
+ let regexEngine: typeof RE2JS | undefined;
7
+ try {
8
+ regexEngine = (require('re2js') as { RE2JS: typeof RE2JS }).RE2JS;
9
+ } catch {}
10
+
11
+ export class OutputWatchDependencyError extends Error {}
12
+
13
+ export interface OutputWatchRequest {
14
+ pattern: string;
15
+ reason: string;
16
+ debounceMs?: number;
17
+ }
18
+
19
+ export interface OutputMatchNotice {
20
+ sequence: number;
21
+ title: string;
22
+ detail: string;
23
+ }
24
+
25
+ interface MatchBatch {
26
+ start: number;
27
+ count: number;
28
+ matchedText: string;
29
+ }
30
+
31
+ const MATCH_LIMIT = 100;
32
+ const MAX_CHUNK = 65_536;
33
+ const MIN_DEBOUNCE_MS = 5_000;
34
+
35
+ export class ShellOutputWatch {
36
+ private readonly request: OutputWatchRequest;
37
+ private readonly notify: (notice: OutputMatchNotice) => void;
38
+ private readonly regex: RE2JS;
39
+ private readonly title: string;
40
+ private readonly debounceMs: number;
41
+ private state: 'watching' | 'limited' | 'finished' = 'watching';
42
+ private carryover = '';
43
+ private occurrences = 0;
44
+ private notices = 0;
45
+ private batch: MatchBatch | undefined;
46
+ private timer: ReturnType<typeof setTimeout> | undefined;
47
+
48
+ constructor(request: OutputWatchRequest, notify: (notice: OutputMatchNotice) => void) {
49
+ this.request = request;
50
+ this.notify = notify;
51
+ if (request.pattern.length > 500) throw new Error('Output notification pattern exceeds 500 characters');
52
+ if (regexEngine === undefined) throw new OutputWatchDependencyError('Shell output monitoring needs the re2js dependency. Run bun install in the pi-pstack package directory, then reload Pi.');
53
+ this.regex = regexEngine.compile(request.pattern, regexEngine.MULTILINE);
54
+ const title = request.reason.trim() || `Shell output contains /${request.pattern}/`;
55
+ this.title = title.length <= 500 ? title : `${title.slice(0, 500)}...`;
56
+ this.debounceMs = Math.max(request.debounceMs ?? MIN_DEBOUNCE_MS, MIN_DEBOUNCE_MS);
57
+ }
58
+
59
+ observe(chunk: string): void {
60
+ if (this.state !== 'watching' || chunk.length === 0) return;
61
+ for (let offset = 0; offset < chunk.length && this.state === 'watching'; offset += MAX_CHUNK) {
62
+ const previous = this.carryover.length;
63
+ const text = this.carryover + chunk.slice(offset, offset + MAX_CHUNK);
64
+ const matcher = this.regex.matcher(text);
65
+ while (this.state === 'watching' && matcher.find()) {
66
+ if (matcher.end() > previous) this.match(matcher.group() ?? '');
67
+ }
68
+ this.carryover = text.slice(-1_024);
69
+ }
70
+ }
71
+
72
+ finish(announce = true): void {
73
+ if (this.state === 'finished') return;
74
+ this.state = 'finished';
75
+ if (announce) this.flush();
76
+ this.batch = undefined;
77
+ this.clearTimer();
78
+ }
79
+
80
+ private match(text: string): void {
81
+ this.occurrences += 1;
82
+ const label = this.occurrences === 1 ? 'Matched text' : 'Latest match';
83
+ const matchedText = text.length <= 5_000 ? `${label}: ${text}` : `${label} is ${text.length} characters; omitted from notification detail.`;
84
+ if (this.occurrences === 1) {
85
+ this.emit({ start: 1, count: 1, matchedText });
86
+ } else {
87
+ this.batch = this.batch === undefined ? { start: this.occurrences, count: 1, matchedText } : { start: this.batch.start, count: this.batch.count + 1, matchedText };
88
+ if (this.occurrences < MATCH_LIMIT && this.timer === undefined) {
89
+ this.timer = setTimeout(() => this.flush(), this.debounceMs);
90
+ this.timer.unref();
91
+ }
92
+ }
93
+ if (this.occurrences === MATCH_LIMIT) {
94
+ this.state = 'limited';
95
+ this.flush();
96
+ this.notices += 1;
97
+ this.notify({
98
+ sequence: this.notices,
99
+ title: `Notification limit reached (${MATCH_LIMIT})`,
100
+ detail: `Shell output matched /${this.request.pattern}/ ${MATCH_LIMIT} times and has reached the notification limit. No further notifications will be sent for this pattern. To continue monitoring, read the output file directly at the output path.`,
101
+ });
102
+ }
103
+ }
104
+
105
+ private clearTimer(): void {
106
+ if (this.timer !== undefined) clearTimeout(this.timer);
107
+ this.timer = undefined;
108
+ }
109
+
110
+ private flush(): void {
111
+ const batch = this.batch;
112
+ this.batch = undefined;
113
+ this.clearTimer();
114
+ if (batch !== undefined) this.emit(batch);
115
+ }
116
+
117
+ private emit(batch: MatchBatch): void {
118
+ this.notices += 1;
119
+ const pattern = this.request.pattern;
120
+ const detail =
121
+ batch.count === 1
122
+ ? `Shell output has pattern /${pattern}/. This is occurrence number ${batch.start}. ${batch.matchedText}. The title message is being displayed to the user.`
123
+ : `Shell output matched /${pattern}/ ${batch.count} more times since the previous notification. The title message is being displayed to the user. ${batch.matchedText}.`;
124
+ this.notify({ sequence: this.notices, title: this.title, detail });
125
+ }
126
+ }
@@ -0,0 +1,14 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ import { stripFrontmatter } from '@earendil-works/pi-coding-agent';
5
+
6
+ /**
7
+ * A skill's body in the block Pi's own `/skill:name` expansion builds (`AgentSession._expandSkillCommand`), or
8
+ * undefined when the file is gone. Pi exports no formatter for one block.
9
+ */
10
+ export function skillBlock(name: string, path: string): string | undefined {
11
+ if (!existsSync(path)) return undefined;
12
+ const body = stripFrontmatter(readFileSync(path, 'utf8')).trim();
13
+ return [`<skill name="${name}" location="${path}">`, `References are relative to ${dirname(path)}.`, '', body, '</skill>'].join('\n');
14
+ }
@@ -29,10 +29,11 @@ export const GENERAL_PURPOSE: AgentDefinition = {
29
29
 
30
30
  /**
31
31
  * Cursor's built-in codebase explorer: a file-search specialist that the caller tells how thorough to be, and that has
32
- * no Task tool of its own.
32
+ * no Task tool of its own. Cursor documents that it uses a faster model by default; here that is the parent's -fast variant.
33
33
  */
34
34
  export const EXPLORE: AgentDefinition = {
35
35
  type: 'explore',
36
+ model: 'fast',
36
37
  description:
37
38
  'Fast agent specialized for exploring codebases. Use it to find files by pattern (such as "src/components/**/*.tsx"), search code for keywords (such as "API endpoints"), or answer questions about the codebase (such as "how do API endpoints work?"). When you call it, name the thoroughness you want: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for a comprehensive search across many locations and naming conventions.',
38
39
  readonly: false,