diff --git a/.gitignore b/.gitignore index 5075a81..7634dc1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules/ coverage/ dist/ +__pycache__/ +*.py[cod] +*.tgz *.log .DS_Store .mesh diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..3f64242 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@_jb:registry=https://git.jb.codes/api/packages/jb/npm/ diff --git a/README.md b/README.md index cc4f5b8..d20c928 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,12 @@ Personal [Pi](https://github.com/earendil-works/pi-mono) extensions, skills, glo - global agent instructions for Pi, Codex, and Claude; - 34 shared skills under `~/.agents/skills/`; - the `manage-pi-links` and `nono-profiles` project-only skills under `.pi/skills/`; -- five global Pi extensions under `~/.pi/agent/extensions/`: +- three global Pi extensions under `~/.pi/agent/extensions/`: - **dictate**: local Linux voice dictation; - **effort-controls**: `/effort` reasoning selection and `/fast` priority mode; - - **pi-workflow**: persisted, gated feature workflows; - - **prompt-snippets**: per-message prompt rule toggles; - - **update**: `/update` and `--update` support. + - **prompt-snippets**: per-message prompt rule toggles. -The repository also contains `copy-all`, `file-search`, and `subagents` extensions, but the normal manifest does not install them globally. The `pptx`, `stop-slop`, `subagents`, and `xlsx` skills are also intentionally excluded. Archived resources under `__archived/` are never installed. Update `config/pi-links.json` deliberately if that policy changes. +The repository also contains `copy-all`, `file-search`, `interactive-subagents`, `lm-studio`, `native-hooks`, `subagents`, `todo-herdr`, `update`, and the private `workflow` extension, but the normal manifest does not install them globally. The `pptx`, `stop-slop`, `subagents`, and `xlsx` skills are also intentionally excluded. Archived resources under `__archived/` are never installed. Update `config/pi-links.json` deliberately if that policy changes. ## Fresh-machine setup @@ -27,7 +25,8 @@ The commands below reproduce the normal setup. They assume Linux, a systemd user Required: - Git with access to `git.jb.codes`; -- Node.js 22.19 or newer with npm; +- Node.js 22.19 or newer; +- Bun 1.3.14 or newer; - Pi Coding Agent; - a configured model provider. @@ -84,11 +83,11 @@ The linked TypeScript extensions resolve runtime dependencies from this checkout ```bash cd /srv/data/code/harness-tools -npm ci -npm run typecheck +bun install --frozen-lockfile +bun run typecheck ``` -Use `npm ci` after pulling a lockfile change. Node.js must satisfy the version in [`package.json`](package.json). +Use `bun install --frozen-lockfile` after pulling a lockfile change and plain `bun install` only when intentionally updating [`bun.lock`](bun.lock). Node.js and Bun must satisfy the versions in [`package.json`](package.json). ### 4. Install instructions, skills, and extensions @@ -109,7 +108,7 @@ A successful check currently reports 44 links and zero local package entries. Th Resolve any reported collision manually instead of deleting it blindly. Run `/reload` in an active Pi session, or restart Pi, after the sync. -Do not also run `pi install /path/to/harness-tools` for the normal setup. That installs the root as a Pi package and applies the broader `package.json` resource globs, which is different from the curated `config/pi-links.json` policy and can duplicate resources. It remains useful for a temporary package-development test in an isolated Pi agent directory. +The repository root is a private Bun workspace and is not installable as a Pi package. Use the curated `config/pi-links.json` policy for live development, or install one independently published `@_jb/pi-*` package. See [Bun workspace packages and Gitea npm publishing](docs/npm-packages.md). ### 5. Verify Pi @@ -120,44 +119,39 @@ pi Inside Pi: 1. Run `/reload`. -2. Confirm `/effort`, `/snippets`, `/update`, and `/workflow` are available. +2. Confirm `/effort` and `/snippets` are available. 3. Ask Pi to list its available skills and confirm a shared skill such as `tdd` appears. 4. From this repository, confirm the project-only `manage-pi-links` and `nono-profiles` skills appear. -## Additional Pi packages from Gitea +## Optional Pi packages from Gitea -Two extensions are maintained in separate Gitea repositories rather than `harness-tools`. Install them as Pi-managed Git packages after completing the canonical link sync above. Pi clones each package into its agent directory and installs its package dependencies. +The monorepo contains three optional packages that are not enabled by the curated link policy: -Run these commands from a normal host terminal. A restrictive nono session may intentionally lack the SSH key or agent socket needed to clone private repositories. +- `@_jb/pi-interactive-subagents`; +- `@_jb/pi-native-hooks`; +- `@_jb/pi-todo-herdr`. -### Verify Gitea SSH access +After publishing them, configure the scoped Gitea npm registry and install only the packages you want. Keep the token in the user environment or user-level npm configuration; never commit it: -Both repositories use SSH on port 7722. Confirm access without changing either checkout: - -```bash -GIT_TERMINAL_PROMPT=0 git ls-remote \ - ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git HEAD -GIT_TERMINAL_PROMPT=0 git ls-remote \ - ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git HEAD +```ini +@_jb:registry=https://git.jb.codes/api/packages/jb/npm/ +//git.jb.codes/api/packages/jb/npm/:_authToken=${GITEA_NPM_TOKEN} ``` -Each command should print a commit hash and `HEAD`. Configure the machine's SSH key and `git.jb.codes` host key before continuing if either command fails. - -### Install both packages - ```bash -pi install ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git -pi install ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git +pi install npm:@_jb/pi-interactive-subagents +pi install npm:@_jb/pi-native-hooks +pi install npm:@_jb/pi-todo-herdr pi list ``` -Confirm both Gitea URLs appear under `User packages`, then restart Pi or run `/reload`. These package entries are independent of `config/pi-links.json`, so `./scripts/pi-links.mjs check` still reports zero local package entries. +Restart Pi or run `/reload` afterward. These package entries remain independent of `config/pi-links.json`, so `./scripts/pi-links.mjs check` still reports zero local package entries. Do not globally enable `harness-tools/extensions/subagents` at the same time as `pi-interactive-subagents`. They provide overlapping subagent tools. The curated harness-tools manifest leaves its in-repository implementation disabled for this reason. ### Configure interactive subagents -[`pi-interactive-subagents`](https://git.jb.codes/jb/pi-interactive-subagents) creates asynchronous child Pi sessions in Herdr tabs or tmux panes. It requires one terminal backend: +[`@_jb/pi-interactive-subagents`](extensions/interactive-subagents) creates asynchronous child Pi sessions in Herdr tabs or tmux panes. It requires one terminal backend: - **Herdr:** install its Pi integration, then launch Pi inside a Herdr-managed pane; - **tmux:** launch Pi inside tmux when Herdr is unavailable. @@ -188,11 +182,15 @@ Inside Pi, verify and try the extension: 4. Run `/subagent scout Summarize this repository` from a disposable test checkout. 5. Confirm a Herdr tab or tmux pane opens and the completed result returns to the parent session. -The status widget is enabled by the package's example configuration when no local `config.json` exists. Copy and edit `config.json.example` beside the installed package only when a status override is needed. Treat Pi's managed Git checkout as disposable because package updates may reset it; prefer global or project agent overrides for durable customization. +The status widget is enabled by the package's example configuration when no local `config.json` exists. Copy and edit `config.json.example` beside the installed package only when a status override is needed. Treat Pi's managed package installation as disposable because package updates may reset it; prefer global or project agent overrides for durable customization. + +### Configure Native Hooks + +[`@_jb/pi-native-hooks`](extensions/native-hooks) coordinates trusted personal diagnostic and verification policies after workspace mutations. Review its [trust model and initialization workflow](extensions/native-hooks/README.md) before use. Preview with `/hooks-init --dry-run`; do not run `/hooks-init --apply` until every destination and executable policy has been reviewed. ### Configure Pi Todo Herdr -[`pi-todo-herdr`](https://git.jb.codes/jb/pi-todo-herdr) provides the `set_tasks`, `update_task`, `rm_task`, `list_task`, and `get_task` tools plus a session-local Pi task widget. Herdr is optional for the tools and widget. +[`@_jb/pi-todo-herdr`](extensions/todo-herdr) provides the `set_tasks`, `patch_tasks`, `rm_task`, `list_task`, and `get_task` tools plus a session-local Pi task widget. Herdr is optional for the tools and widget. Inside Pi after `/reload`: @@ -224,41 +222,33 @@ herdr status client ### Update or remove the Gitea packages -Use the same source strings stored by Pi: - ```bash -pi update ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git -pi update ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git -``` +pi update npm:@_jb/pi-interactive-subagents +pi update npm:@_jb/pi-native-hooks +pi update npm:@_jb/pi-todo-herdr -Remove them with: - -```bash -pi remove ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git -pi remove ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git +pi remove npm:@_jb/pi-interactive-subagents +pi remove npm:@_jb/pi-native-hooks +pi remove npm:@_jb/pi-todo-herdr ``` Run `/reload` after an update or removal. -### Optional development checkouts +### Imported source provenance -A Pi-managed package clone is enough for normal use. For source development, use separate durable checkouts so Pi package updates cannot discard work: +The three packages were imported as snapshots of their source repositories' `main` branches. [`config/imported-packages.json`](config/imported-packages.json) records each source and exact commit. Check for upstream movement without changing this checkout: ```bash -mkdir -p /srv/data/repo-store -git clone ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git \ - /srv/data/repo-store/pi-interactive-subagents -git clone ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git \ - /srv/data/repo-store/pi-todo-herdr - -npm ci --prefix /srv/data/repo-store/pi-interactive-subagents -npm test --prefix /srv/data/repo-store/pi-interactive-subagents -npm ci --prefix /srv/data/repo-store/pi-todo-herdr -npm run typecheck --prefix /srv/data/repo-store/pi-todo-herdr -npm test --prefix /srv/data/repo-store/pi-todo-herdr +bun run imports:check ``` -Both repositories are separate projects. Keep their changes and releases out of the `harness-tools` checkout. +Stage fresh snapshots outside the repository for review and reconciliation: + +```bash +bun scripts/import-package-snapshots.mjs stage all --output /tmp/harness-tools-imports +``` + +The stage command deliberately does not overwrite monorepo files. Review upstream changes, preserve the scoped package manifests and catalog dependencies, run the full validation suite, and then update the recorded commit. ## Companion tools used by installed skills @@ -475,11 +465,11 @@ After pulling repository changes: ```bash cd /srv/data/code/harness-tools git pull --rebase -npm ci +bun install --frozen-lockfile ./scripts/pi-links.mjs sync --dry-run ./scripts/pi-links.mjs sync ./scripts/pi-links.mjs check -npm run typecheck +bun run typecheck ``` Run `/reload` after skill, extension, instruction, or separately installed Gitea package changes. Re-run `./services/dictate/install.sh` when Dictate's host service or protocol changes. @@ -497,9 +487,9 @@ Only run `./scripts/pi-links.mjs prune` after reviewing every removal. The manag Extensions are TypeScript modules loaded directly by Pi; no build step is required. ```bash -npm ci -npm run typecheck -npm test +bun install --frozen-lockfile +bun run typecheck +bun run test ``` For a one-off extension development run: @@ -514,7 +504,7 @@ pi -e ./extensions/update ```text config/ Installation and launcher manifests docs/ Usage guides, plans, and ADRs -extensions/ Pi extensions +extensions/ Bun workspace packages for Pi extensions and shared runtime code profiles/nono/ Repository-managed nono profiles scripts/ Link manager and sandbox launch helpers services/ Host-side services used by extensions diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..b2595a3 --- /dev/null +++ b/bun.lock @@ -0,0 +1,1007 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "harness-tools", + "devDependencies": { + "@earendil-works/pi-ai": "catalog:", + "@earendil-works/pi-coding-agent": "catalog:", + "@earendil-works/pi-tui": "catalog:", + "@effect/tsgo": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "acorn": "catalog:", + "tsx": "catalog:", + "typebox": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, + "extensions/copy-all": { + "name": "@_jb/pi-copy-all", + "version": "0.1.0", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + }, + }, + "extensions/dictate": { + "name": "@_jb/pi-dictate", + "version": "0.1.0", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + }, + }, + "extensions/effort-controls": { + "name": "@_jb/pi-effort-controls", + "version": "0.1.0", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + }, + }, + "extensions/file-search": { + "name": "@_jb/pi-file-search", + "version": "0.1.0", + "dependencies": { + "@effect/platform-node": "catalog:", + "@effect/platform-node-shared": "catalog:", + "effect": "catalog:", + }, + "peerDependencies": { + "@earendil-works/pi-ai": "catalog:pi-peer", + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + "typebox": "catalog:pi-peer", + }, + }, + "extensions/interactive-subagents": { + "name": "@_jb/pi-interactive-subagents", + "version": "3.7.2", + "devDependencies": { + "@earendil-works/pi-coding-agent": "catalog:", + "@earendil-works/pi-tui": "catalog:", + "@types/node": "catalog:", + "typebox": "catalog:", + "typescript": "catalog:", + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + "typebox": "catalog:pi-peer", + }, + }, + "extensions/lm-studio": { + "name": "@_jb/pi-lm-studio", + "version": "0.1.0", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + }, + }, + "extensions/native-hooks": { + "name": "@_jb/pi-native-hooks", + "version": "0.1.0", + "devDependencies": { + "@earendil-works/pi-coding-agent": "catalog:", + "@types/node": "catalog:", + "tsx": "catalog:", + "typebox": "catalog:", + "typescript": "catalog:", + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "typebox": "catalog:pi-peer", + }, + }, + "extensions/prompt-snippets": { + "name": "@_jb/pi-prompt-snippets", + "version": "0.1.0", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + }, + }, + "extensions/shared": { + "name": "@_jb/pi-shared", + "version": "0.1.0", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "typebox": "catalog:pi-peer", + }, + }, + "extensions/subagents": { + "name": "@_jb/pi-subagents", + "version": "0.1.0", + "dependencies": { + "@_jb/pi-shared": "workspace:^", + "@anthropic-ai/claude-agent-sdk": "catalog:", + "effect": "catalog:", + }, + "peerDependencies": { + "@earendil-works/pi-ai": "catalog:pi-peer", + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + "typebox": "catalog:pi-peer", + }, + }, + "extensions/todo-herdr": { + "name": "@_jb/pi-todo-herdr", + "version": "0.2.5", + "devDependencies": { + "@earendil-works/pi-ai": "catalog:", + "@earendil-works/pi-coding-agent": "catalog:", + "@earendil-works/pi-tui": "catalog:", + "typebox": "catalog:", + "typescript": "catalog:", + }, + "peerDependencies": { + "@earendil-works/pi-ai": "catalog:pi-peer", + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + "typebox": "catalog:pi-peer", + }, + }, + "extensions/update": { + "name": "@_jb/pi-update", + "version": "0.1.0", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + }, + }, + "extensions/workflow": { + "name": "@_jb/pi-workflow", + "version": "0.1.0", + "dependencies": { + "@_jb/pi-shared": "workspace:^", + }, + "peerDependencies": { + "@earendil-works/pi-ai": "catalog:pi-peer", + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + "typebox": "catalog:pi-peer", + }, + }, + }, + "overrides": { + "@effect/platform-node-shared": "4.0.0-beta.101", + }, + "catalog": { + "@anthropic-ai/claude-agent-sdk": "0.3.218", + "@earendil-works/pi-ai": "0.84.2", + "@earendil-works/pi-coding-agent": "^0.84.2", + "@earendil-works/pi-tui": "0.84.2", + "@effect/platform-node": "4.0.0-beta.101", + "@effect/platform-node-shared": "4.0.0-beta.101", + "@effect/tsgo": "0.24.3", + "@effect/vitest": "4.0.0-beta.101", + "@types/node": "^22.0.0", + "acorn": "8.18.0", + "effect": "4.0.0-beta.101", + "tsx": "4.21.0", + "typebox": "1.3.17", + "typescript": "7.0.2", + "vitest": "4.1.10", + }, + "catalogs": { + "pi-peer": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*", + }, + }, + "packages": { + "@_jb/pi-copy-all": ["@_jb/pi-copy-all@workspace:extensions/copy-all"], + + "@_jb/pi-dictate": ["@_jb/pi-dictate@workspace:extensions/dictate"], + + "@_jb/pi-effort-controls": ["@_jb/pi-effort-controls@workspace:extensions/effort-controls"], + + "@_jb/pi-file-search": ["@_jb/pi-file-search@workspace:extensions/file-search"], + + "@_jb/pi-interactive-subagents": ["@_jb/pi-interactive-subagents@workspace:extensions/interactive-subagents"], + + "@_jb/pi-lm-studio": ["@_jb/pi-lm-studio@workspace:extensions/lm-studio"], + + "@_jb/pi-native-hooks": ["@_jb/pi-native-hooks@workspace:extensions/native-hooks"], + + "@_jb/pi-prompt-snippets": ["@_jb/pi-prompt-snippets@workspace:extensions/prompt-snippets"], + + "@_jb/pi-shared": ["@_jb/pi-shared@workspace:extensions/shared"], + + "@_jb/pi-subagents": ["@_jb/pi-subagents@workspace:extensions/subagents"], + + "@_jb/pi-todo-herdr": ["@_jb/pi-todo-herdr@workspace:extensions/todo-herdr"], + + "@_jb/pi-update": ["@_jb/pi-update@workspace:extensions/update"], + + "@_jb/pi-workflow": ["@_jb/pi-workflow@workspace:extensions/workflow"], + + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.218", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.218", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.218", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.218", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.218", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.218", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.218", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.218", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.218" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-lbVOmgdzjBdSUCTbxElpoTxxP/u9jY8gpG/3cr5l1jSSvSY5FrANdRcze/z6gM330xo32SHH6WPGFd4+K0fMoQ=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.218", "", { "os": "darwin", "cpu": "arm64" }, "sha512-foTI71ua2r5lJo7sfcw/3UafBCJYjxPmOXZ98wOz1UWIJxaP/Oq9Xp4sUavBd1efFVRxPnAcRnrZ5yrmXK1xUw=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.218", "", { "os": "darwin", "cpu": "x64" }, "sha512-rAdlR2ukJd01Osfsb7nPPv31MCVJBennBDaL7ZP/bv+dednwNuY6thuPO5PMFJUM+54Gbnziqm+hqoNTgndqDg=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.218", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y6vFEnz6wwd+rYpVGsPqgeXZuZP7NvQg6vjkC+N6cs1+I41LdKjfs+F+WG3daIqgt+vJBz/EB5Q0PByABkwufA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.218", "", { "os": "linux", "cpu": "arm64" }, "sha512-qfFBwOf0uh/mAtNGEkBJlLWt1XIgFqcQeeX966g2NexasCdSJGnwfc0YWA0QOeAAU/5xk6tSCW/Nn0zHGBO/3A=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.218", "", { "os": "linux", "cpu": "x64" }, "sha512-gCq/i7yRXeuoQXidGQynuiw5AeczQXSIifxH5Vpr9OFDyHCBNotS1mQ8qutuclZeFpLOPkJ2ic/nvEgHJvfXdg=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.218", "", { "os": "linux", "cpu": "x64" }, "sha512-e47oi8dYWnS0wx2/F6FL3YhaD2HedMd2nhI/nududP4PBzytuXeaDE/sJ0eIqtUVkl6/bn5uBhtIEHHWoj37JQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.218", "", { "os": "win32", "cpu": "arm64" }, "sha512-D0UKI8jOpEsWO1A+i0DlIrgjXFKEZX3RrID2l49S4pShUlLsnSLJGMTPy4nQt6CCl5MzPqWiJDE/lCpImFTDcg=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.218", "", { "os": "win32", "cpu": "x64" }, "sha512-pgE39WnKPPSRsiRJ2pm5NETxQgtVhrquqg7UByZxne0xQw2gNS0Qy+9ZumrsNBNlfrrnQCVDU1I2ks3ZLrEa8Q=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.82", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/token-providers": "3.1116.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.34", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw=="], + + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.29", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ=="], + + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.52", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.46", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.10", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.84.4", "", { "dependencies": { "@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-telemetry": "^0.84.4", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", "yaml": "2.9.0" } }, "sha512-HyUnjaOXj6oN/6SNcr8A1J/ElRQA50FtIE0XUTSKAQVqmdlb9qdojOyUQwF/jULE5+yOEtGuVgi/N1RnBiNG+g=="], + + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.84.2", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.2", "@google/genai": "1.52.0", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-6MzsrYIYNVlE7SfpbL2yYb67Qo58p/7Q+xWG1RZvoX1P80aRCHSod2/13aFpxkow1lPO2LEh3c495J0Gwmyjig=="], + + "@earendil-works/pi-client": ["@earendil-works/pi-client@0.84.4", "", { "dependencies": { "@earendil-works/pi-protocol": "^0.84.4" } }, "sha512-q398WY/3ZQHTizk7IKxApzqFV0xt4yM9LkSkwyqeLK5Bj5RwRjOWxESt26z4LgNp4O+8hqhqFPf/8fj4H5rE4A=="], + + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.84.4", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.84.4", "@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-client": "^0.84.4", "@earendil-works/pi-protocol": "^0.84.4", "@earendil-works/pi-tui": "^0.84.4", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.3.7", "undici": "8.9.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-jmOlrqUmvhh/siNWFRXjYLJzhKFIHNsAQaysRwzQPQFnPAaV/vhqHsLH/MBsIISA1Rjj7WTUFR3nJrpXoLx39w=="], + + "@earendil-works/pi-protocol": ["@earendil-works/pi-protocol@0.84.4", "", { "dependencies": { "typebox": "1.3.7" } }, "sha512-acyE9ozxkMiWiz/xyWpU0O9vwnYv0hyG889Vniv6Sg9c9zfsX+8MePnDNphBacY2Fvm1rxdsGmiVDSZl9yuDFA=="], + + "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.84.4", "", {}, "sha512-8e2CuxM+ht+hedQXTZmi5JVl6/xDK9RpSDL2+MbITevKYQhMZ/z6lJOTFgox3HQyGxO8mOZEtYGVeQNaD4OzqA=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.84.2", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-ds2TLihOnM5sLJB3VpXV6y0uR5efVuHf4MN7yDpsty6hA2DUO/EDVzjp/0od0G2JslzVLMjT8T8zavtxVb+qbg=="], + + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.101", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.101", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101", "ioredis": "^5.7.0" } }, "sha512-pClk7dmMtHgM6Byu7CzGfrPvZ1/4BwmrRlCg2Op+iJozMkwVUxq8v4beK8b9SvxsliJjHZFznjvkVLX7LQjBqw=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.101", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-g4L7XiyJSNJLJVhlslyg2zBCQsoKQf1y1gd+Yfd+3wD9ymC+m7ymbd/5FGqnT1aXV6E2AwRr4D/R1eyRUikvWQ=="], + + "@effect/tsgo": ["@effect/tsgo@0.24.3", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.24.3", "@effect/tsgo-darwin-x64": "0.24.3", "@effect/tsgo-linux-arm": "0.24.3", "@effect/tsgo-linux-arm64": "0.24.3", "@effect/tsgo-linux-x64": "0.24.3", "@effect/tsgo-win32-arm64": "0.24.3", "@effect/tsgo-win32-x64": "0.24.3" }, "bin": { "effect-tsgo": "dist/effect-tsgo.js" } }, "sha512-WQxKU3MFzWzI38GbE9cf0POkVX4y0xahD8QqDjLfixW1AEValuHNfOQvyKM2MDWOLdGff4hP8VnIOeduwQt66A=="], + + "@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.24.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eO83D7ZmpAsocA5WJQ8SSAPnAOZ+dEduFIozg9c2SmH36PCHG1Eb++I0UiQml+2LAr4o/QE2wKdWj7LExebjZA=="], + + "@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.24.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-ksX9BdCM8289RRuN0lWhtzuvstH1frxwdxhoW08rrGW3ryER3udEFqEtgtveP0cYvK9gTlV5lXbQPNGktQ2sTw=="], + + "@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.24.3", "", { "os": "linux", "cpu": "arm" }, "sha512-0TzebGLMNZkHOGSK0w1B4zuvHGlA+Od4LdElWeHHP6iMPEiEWPq17If787+oc+Y67yITPJ9DZcibu69eI9mZhg=="], + + "@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.24.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-CMiThuURi14rT5Ikag5pVnUC3tx4IcmPpRR4WBQkHxAbOf5a1Lo1NUF82zY0azhLW2WszBamTSZP5Tcv4uks2A=="], + + "@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.24.3", "", { "os": "linux", "cpu": "x64" }, "sha512-R4jl1JWoYEldUU1rtCG1fXWW2ywj/rgY1xaCzK8HSb8fQhQ71WRLLAyAe8ewROuseWQ9Ip+IbbfAeXQOpmpdDA=="], + + "@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.24.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-DOJ4oa8pk8qxEQwKHpJOqtjOfB9iOMuc6Cnb2ZbTG32PBfhqeRYv6YFGhFtgAojXyxfuRP2gCJVpFfjzHJh7nQ=="], + + "@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.24.3", "", { "os": "win32", "cpu": "x64" }, "sha512-6GpENpPn3mXldmJoM+Z7qi+8fe39xJYB2bLD5jqCFWl14vFp1AeJvYwHYQak8HRm1FiPz72Ninslv8DjHYtZgA=="], + + "@effect/vitest": ["@effect/vitest@4.0.0-beta.101", "", { "peerDependencies": { "effect": "^4.0.0-beta.101", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-F5Ur8pZYti0xkZFyb4hPZt8RrKQ1XBoC28ZkKxc7N1iPbhE9fWOvlWA1NC2XlN2wWG08yb5g5jR53LdOxjlhpQ=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], + + "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="], + + "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="], + + "@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="], + + "@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="], + + "@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="], + + "@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="], + + "@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="], + + "@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="], + + "@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="], + + "@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="], + + "@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + + "@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + + "@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], + + "google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "grok-mermaid": ["grok-mermaid@0.2.2", "", {}, "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + + "hono": ["hono@4.13.5", "", {}, "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw=="], + + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + + "ioredis": ["ioredis@5.11.1", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A=="], + + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "jose": ["jose@6.2.10", "", {}, "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], + + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "openai": ["openai@6.40.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "typebox": ["typebox@1.3.17", "", {}, "sha512-20PsSaZV1pN7pIfM/YEUHZNTv8X21+1ilPo/HN+6GtFbhCaQhLrIoKCkAkcBwIva3nYI+Ao0MxM1iDj5H3SOhw=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], + + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-0mq1pHadfyXCYCqm2cNpbjNIT+fbaUpNxewZb/YNr2L0IrEVMOb8gM/Fl4K6XvHCW3uSNDFwPl/+iKm0bx9jYg=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + + "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-0mq1pHadfyXCYCqm2cNpbjNIT+fbaUpNxewZb/YNr2L0IrEVMOb8gM/Fl4K6XvHCW3uSNDFwPl/+iKm0bx9jYg=="], + + "@earendil-works/pi-agent-core/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.84.4", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.4", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-AClAZxf5+c4RRu44NJPS6wyQy+Nmq+Mzyyrdvm4ZVMNuixelO02RZX4G4Aq1F145Yzp43wnM5S+hLlSI7ypfVw=="], + + "@earendil-works/pi-agent-core/typebox": ["typebox@1.3.7", "", {}, "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg=="], + + "@earendil-works/pi-ai/typebox": ["typebox@1.3.7", "", {}, "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg=="], + + "@earendil-works/pi-coding-agent/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.84.4", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.4", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-AClAZxf5+c4RRu44NJPS6wyQy+Nmq+Mzyyrdvm4ZVMNuixelO02RZX4G4Aq1F145Yzp43wnM5S+hLlSI7ypfVw=="], + + "@earendil-works/pi-coding-agent/@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.84.4", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-nPUnwDkLtupPXnZQYrCwPFcuTydCDqTY6ZbFqhsL4S4kVq0AT418kPa/6uXwtaCD+MjBNBltb7ScTYX65yeE1w=="], + + "@earendil-works/pi-coding-agent/typebox": ["typebox@1.3.7", "", {}, "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg=="], + + "@earendil-works/pi-protocol/typebox": ["typebox@1.3.7", "", {}, "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg=="], + + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + } +} diff --git a/config/imported-packages.json b/config/imported-packages.json new file mode 100644 index 0000000..06540b3 --- /dev/null +++ b/config/imported-packages.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "packages": [ + { + "name": "pi-interactive-subagents", + "source": "ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git", + "branch": "main", + "commit": "a3cd328edef59ad491d4f35f9fbadf8ec80cadc4", + "target": "extensions/interactive-subagents" + }, + { + "name": "pi-native-hooks", + "source": "ssh://git@git.jb.codes:7722/jb/pi-native-hooks.git", + "branch": "main", + "commit": "73fa3299bf30fd6da5abfc74a0ee7a36e0a07f0a", + "target": "extensions/native-hooks" + }, + { + "name": "pi-todo-herdr", + "source": "ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git", + "branch": "main", + "commit": "d9e1ffec51e25238e0baec48ce1303893f13849f", + "target": "extensions/todo-herdr" + } + ] +} diff --git a/docs/adr/0001-pi-workflow-architecture-24AUG2026.md b/docs/adr/0001-workflow-architecture-24AUG2026.md similarity index 67% rename from docs/adr/0001-pi-workflow-architecture-24AUG2026.md rename to docs/adr/0001-workflow-architecture-24AUG2026.md index ca4f925..bd65a79 100644 --- a/docs/adr/0001-pi-workflow-architecture-24AUG2026.md +++ b/docs/adr/0001-workflow-architecture-24AUG2026.md @@ -2,9 +2,9 @@ status: accepted --- -# Build pi-workflow around persisted policy gates +# Build workflow around persisted policy gates -`pi-workflow` will be a new extension rather than an activation or rename of `_workflows`. It will expose one command-style tool backed by atomic revisioned snapshots, Markdown Agent Profiles, deterministic eligibility followed by planner selection, a mandatory feature pipeline, and restricted feature or read-only analysis scripts. Every feature run receives a distinct exclusive worktree. Concurrency defaults to 8 under an immutable process-wide maximum of 32. Non-trivial UI work requires at least three structurally distinct prototypes and explicit user acceptance. Before implementation, the user chooses a fresh plan-seeded session, compaction of the current session that prefers `smart_compact` and falls back to normal Pi compaction when unavailable, or unchanged current context. Review uses a fresh session and excludes every Agent Profile that mutated the reviewed diff. +`workflow` will be a new extension rather than an activation or rename of `_workflows`. It will expose one command-style tool backed by atomic revisioned snapshots, Markdown Agent Profiles, deterministic eligibility followed by planner selection, a mandatory feature pipeline, and restricted feature or read-only analysis scripts. Every feature run receives a distinct exclusive worktree. Concurrency defaults to 8 under an immutable process-wide maximum of 32. Non-trivial UI work requires at least three structurally distinct prototypes and explicit user acceptance. Before implementation, the user chooses a fresh plan-seeded session, compaction of the current session that prefers `smart_compact` and falls back to normal Pi compaction when unavailable, or unchanged current context. Review uses a fresh session and excludes every Agent Profile that mutated the reviewed diff. This keeps the caller interface small while making approval, prototype, recovery, and reviewer-independence rules enforceable in one workflow engine. We rejected unrestricted custom graphs because they could bypass policy, a general registry architecture because it adds speculative extension points, and event-sourced persistence because revisioned snapshots already provide the required recovery and audit data with less machinery. diff --git a/docs/adr/0002-pi-workflow-standard-agent-overlays-27AUG2026.md b/docs/adr/0002-pi-workflow-standard-agent-overlays-27AUG2026.md deleted file mode 100644 index ce3a980..0000000 --- a/docs/adr/0002-pi-workflow-standard-agent-overlays-27AUG2026.md +++ /dev/null @@ -1,3 +0,0 @@ -# Reuse standard agents through workflow role overlays - -`pi-workflow` will reuse standard `pi-interactive-subagents` definitions instead of duplicating their prompts and model settings in workflow-only agents. Explicit configuration maps standard agent names to workflow roles, while `pi-workflow` retains ownership, tool-intersection, Gate, verification, and reviewer-independence policy. The default maps `scout` to Discovery and `worker` to Implementation; bundled profiles remain fallbacks for roles without a standard mapping. This keeps one source of truth for agent identity without allowing arbitrary prompt prose or agent names to imply mutability or workflow authority. diff --git a/docs/adr/0002-pi-workflow-interaction-and-recovery-25AUG2026.md b/docs/adr/0002-workflow-interaction-and-recovery-25AUG2026.md similarity index 92% rename from docs/adr/0002-pi-workflow-interaction-and-recovery-25AUG2026.md rename to docs/adr/0002-workflow-interaction-and-recovery-25AUG2026.md index 9f559af..ffdbda4 100644 --- a/docs/adr/0002-pi-workflow-interaction-and-recovery-25AUG2026.md +++ b/docs/adr/0002-workflow-interaction-and-recovery-25AUG2026.md @@ -1,11 +1,11 @@ --- status: accepted -supersedes: 0001-pi-workflow-architecture-24AUG2026.md +supersedes: 0001-workflow-architecture-24AUG2026.md --- # Use drill-down overlays and bounded recovery -This decision supersedes the dashboard, dirty-source, execution-timeout, and startup-ownership assumptions in [ADR 0001](./0001-pi-workflow-architecture-24AUG2026.md). The persisted gated pipeline, worktree isolation, and independent-review decisions from ADR 0001 remain accepted. +This decision supersedes the dashboard, dirty-source, execution-timeout, and startup-ownership assumptions in [ADR 0001](./0001-workflow-architecture-24AUG2026.md). The persisted gated pipeline, worktree isolation, and independent-review decisions from ADR 0001 remain accepted. ## Context @@ -39,7 +39,7 @@ Static prototypes compared a centered compact modal, a near-full-screen workspac - Dead ownership can recover without manual state surgery. A running process or Assignment cannot be displaced based only on an old heartbeat. - Users may proceed from a dirty source checkout without copying local changes into the worktree. Persisted evidence records what was knowingly omitted. - Git, host-command cleanup, and child execution now have bounded failure paths rather than indefinite waits. -- Direction C has automated renderer and orchestration coverage. Its completed manual application validation is recorded below. The static frames in `docs/pi-workflow/dashboard-mocks.md` remain labeled prototype evidence, not production screenshots. +- Direction C has automated renderer and orchestration coverage. Its completed manual application validation is recorded below. The static frames in `docs/workflow/dashboard-mocks.md` remain labeled prototype evidence, not production screenshots. ## Manual application validation diff --git a/docs/adr/0002-workflow-standard-agent-overlays-27AUG2026.md b/docs/adr/0002-workflow-standard-agent-overlays-27AUG2026.md new file mode 100644 index 0000000..210eb58 --- /dev/null +++ b/docs/adr/0002-workflow-standard-agent-overlays-27AUG2026.md @@ -0,0 +1,3 @@ +# Reuse standard agents through workflow role overlays + +`workflow` will reuse standard `pi-interactive-subagents` definitions instead of duplicating their prompts and model settings in workflow-only agents. Explicit configuration maps standard agent names to workflow roles, while `workflow` retains ownership, tool-intersection, Gate, verification, and reviewer-independence policy. The default maps `scout` to Discovery and `worker` to Implementation; bundled profiles remain fallbacks for roles without a standard mapping. This keeps one source of truth for agent identity without allowing arbitrary prompt prose or agent names to imply mutability or workflow authority. diff --git a/docs/adr/0004-bun-extension-workspaces-01SEP2026.md b/docs/adr/0004-bun-extension-workspaces-01SEP2026.md new file mode 100644 index 0000000..2e641c6 --- /dev/null +++ b/docs/adr/0004-bun-extension-workspaces-01SEP2026.md @@ -0,0 +1,21 @@ +# Package Pi extensions as a Bun workspace catalog + +## Decision + +Keep extension sources under `extensions/*` and make each maintained extension directory a Bun workspace with its own package manifest. Use the `@_jb/pi-*` namespace, a root Bun catalog for external dependency versions, and `workspace:^` for repository-owned libraries. The repository root is private and is no longer itself a Pi package. + +Publish packages to the `jb` owner endpoint of the Gitea npm registry. Repository configuration maps `@_jb` to `https://git.jb.codes/api/packages/jb/npm/`; credentials remain in the user environment or user-level npm configuration and are never committed. + +`extensions/shared` is the publishable `@_jb/pi-shared` library. Extensions that import it declare a normal runtime dependency so Pi's production package installation receives the required code. + +`extensions/_workflows` is reference code, not a package or publishable extension. `@_jb/pi-workflow` remains a private workspace until the required reference runtime is merged under `extensions/workflow`; this prevents publishing two workflow packages or publishing an incomplete package. + +The existing `config/pi-links.json` policy remains the authority for live-development exposure. Package boundaries and publication do not automatically enable extensions globally. + +## Consequences + +- Bun owns dependency installation and `bun.lock`; `package-lock.json` is removed. +- Catalog and workspace protocols require `bun publish` or `bun pm pack` before npm publication so concrete versions are written into package metadata. +- Shared libraries must be published before extensions that depend on the same new version. +- Pi packages continue shipping TypeScript source because Pi loads extension entrypoints directly. +- The `workflow` merge is an explicit blocker for its first release. diff --git a/docs/adr/0005-imported-extension-snapshots-01SEP2026.md b/docs/adr/0005-imported-extension-snapshots-01SEP2026.md new file mode 100644 index 0000000..25eadfa --- /dev/null +++ b/docs/adr/0005-imported-extension-snapshots-01SEP2026.md @@ -0,0 +1,25 @@ +# Import external Pi packages as reviewed main-branch snapshots + +## Decision + +Import the tracked files from the `main` branch of these repositories into Bun workspaces: + +| Source | Imported commit | Workspace | Package | +| --- | --- | --- | --- | +| `jb/pi-interactive-subagents` | `a3cd328edef59ad491d4f35f9fbadf8ec80cadc4` | `extensions/interactive-subagents` | `@_jb/pi-interactive-subagents` | +| `jb/pi-native-hooks` | `73fa3299bf30fd6da5abfc74a0ee7a36e0a07f0a` | `extensions/native-hooks` | `@_jb/pi-native-hooks` | +| `jb/pi-todo-herdr` | `d9e1ffec51e25238e0baec48ce1303893f13849f` | `extensions/todo-herdr` | `@_jb/pi-todo-herdr` | + +Carry snapshots rather than submodules, subtrees, or unrelated Git histories. The monorepo owns package manifests, dependency normalization, compatibility changes, tests, and releases after import. Preserve source attribution in each package. + +Record source URLs, branches, and commits in `config/imported-packages.json`. `bun run imports:check` compares those commits with current remote `main` heads. The staging command in `scripts/import-package-snapshots.mjs` clones fresh snapshots outside the repository for explicit review; it never overwrites the maintained workspaces. + +Rename the private `pi-workflow` extension identity to `workflow`. Its workspace is `extensions/workflow`, its tool and user-facing runtime identifiers use `workflow`, and its configuration/state paths use the same name. The npm package remains `@_jb/pi-workflow` under the repository's `@_jb/pi-` convention. + +## Consequences + +- The three imported extensions share the Bun lockfile and catalog with the rest of the monorepo. +- Published packages use the scoped Gitea registry instead of Pi-managed Git sources. +- Upstream movement is detected but never merged automatically; imported changes require reconciliation with monorepo-specific manifests and APIs. +- Only `main` is tracked. Source tags, branches, and historical commits are not copied. +- The renamed `workflow` extension remains private until its `_workflows` dependencies are merged and isolated packaging succeeds. diff --git a/docs/nono-dynamic-permissions-plan.md b/docs/nono-dynamic-permissions-plan.md index 931533d..928b264 100644 --- a/docs/nono-dynamic-permissions-plan.md +++ b/docs/nono-dynamic-permissions-plan.md @@ -94,7 +94,7 @@ Node/Bun Python/Rust/Go/Java local integrations child isol | | | | +--------------------+--------------------------+---------------------+ v - Slice 6B2: pi-workflow/toolchain broker + Slice 6B2: workflow/toolchain broker | v Slice 7A: Docker/browser Slice 7B: release/production Slice 7C: maintenance @@ -155,7 +155,7 @@ Slices in the same row may proceed in parallel after their listed dependencies a - one selected provider route; - no package registries, SSH, Docker, browser, microphone, deployment credentials, or broad localhost access. 4. Separate extension source from extension state. Confirm that normal sessions cannot modify global instructions, installed extension code, authentication state, other Pi profiles, or sibling repositories. -5. Materialize the normal in-process extension allowlist as a generated, isolated Pi `settings.json` under the selected personal/work state root. Its package/extension entries come only from `config/pi-package-lock.json` and the trusted repository map. Disable `pi-ask` arbitrary notification commands, `/update`, `pi-workflow`, background shell jobs, and any privileged direct spawn path until a controlled subprocess policy exists. +5. Materialize the normal in-process extension allowlist as a generated, isolated Pi `settings.json` under the selected personal/work state root. Its package/extension entries come only from `config/pi-package-lock.json` and the trusted repository map. Disable `pi-ask` arbitrary notification commands, `/update`, `workflow`, background shell jobs, and any privileged direct spawn path until a controlled subprocess policy exists. 6. Import the unmanaged `~/.pi/agent/extensions/lm-studio.ts` implementation into a repository-managed extension at `extensions/lm-studio/index.ts`, lock its source revision, and change its fixed base URL contract to `http://127.0.0.1:1234/v1`. Maintenance moves the old extension to the quarantined state store and removes its active settings entry. A duplicate-provider test fails if the old source, another `lm-studio` registration, or `localhost` base URL remains loadable. 7. Define and own stable profile fragments for `provider-lm-studio`, `provider-openai`, `provider-anthropic`, and `provider-morph`, granting only one route at launch. Prove nono 0.74's `tcp_connect_ports` behavior with real loopback fixtures listening on ports 1234 and 1235: the selected provider must connect to 1234 while 1235 and every other loopback port fail. Do not use hostname-only `allow_domain` as evidence of port restriction. If the fixture cannot prove exact enforcement, `provider-lm-studio` remains unavailable until a separately reviewed mediated relay is designed. @@ -165,7 +165,7 @@ Slices in the same row may proceed in parallel after their listed dependencies a - The selected provider works while non-selected provider hosts, raw key environment names, and credentials are absent from the environment and process table. - Reads outside the exact Pi state, declared active-worktree access, and per-launch temporary paths fail; writes to a read-only active worktree fail; another session's temporary directory is inaccessible. - LM Studio, when selected, reaches only `127.0.0.1:1234`. -- `/update`, `pi-workflow`, arbitrary notification commands, and unbrokered background shell jobs are unavailable. +- `/update`, `workflow`, arbitrary notification commands, and unbrokered background shell jobs are unavailable. - The repository-managed LM Studio provider uses only the exact `127.0.0.1:1234` base URL, the old unmanaged source is quarantined, duplicate registration fails, and a real two-port fixture proves all other loopback ports are denied. - All base and provider profiles pass strict validation and resolved-policy review. @@ -342,7 +342,7 @@ A failed pilot returns to the owning slice. Do not compensate by broadening the **Owner surfaces:** `profiles/nono/{node-npm,pnpm,bun}.json` and disjoint tests under `tests/nono/capabilities/{node-npm,pnpm,bun}/`. -**Work:** grant each command the active repository, exact cache/store paths, required registry and artifact hosts, and a token-free normal configuration. Separate install/build/test from publish. Cover lockfile-respecting commands used by the mapped repositories. Treat npm/pnpm/Bun lifecycle scripts and `npm test`, `npm run`, `pnpm run`, and equivalents as shell-spawning paths: prove their children inherit the package-manager command sandbox and cannot invoke SSH, Docker, browsers, deployment tools, sibling repositories, raw credentials, or an alternate package binary. Keep `pi-workflow` disabled until Slice 6B2 integrates its executor with these completed policies. +**Work:** grant each command the active repository, exact cache/store paths, required registry and artifact hosts, and a token-free normal configuration. Separate install/build/test from publish. Cover lockfile-respecting commands used by the mapped repositories. Treat npm/pnpm/Bun lifecycle scripts and `npm test`, `npm run`, `pnpm run`, and equivalents as shell-spawning paths: prove their children inherit the package-manager command sandbox and cannot invoke SSH, Docker, browsers, deployment tools, sibling repositories, raw credentials, or an alternate package binary. Keep `workflow` disabled until Slice 6B2 integrates its executor with these completed policies. **Acceptance criteria:** installs, builds, and tests work in representative npm, pnpm, and Bun fixtures; adversarial lifecycle/run scripts remain inside the command policy; sibling repositories, unrelated caches, privileged commands, and raw credentials remain unavailable; publish credentials and commands are absent outside release mode. @@ -381,32 +381,32 @@ A failed pilot returns to the owning slice. Do not compensate by broadening the ## Slice 6B1: Child-agent and background-task isolation -**Goal:** isolate spawned Pi, Claude, Codex, bounded background jobs, Herdr panes, and tmux panes without enabling repository toolchain execution or `pi-workflow`. +**Goal:** isolate spawned Pi, Claude, Codex, bounded background jobs, Herdr panes, and tmux panes without enabling repository toolchain execution or `workflow`. **Owner surfaces:** `profiles/nono/{child-pi,child-claude,child-codex,background-jobs}.json`, new spawn-wrapper sources under `scripts/nono/child-agents/`, isolated child-state configuration, and `tests/nono/capabilities/child-agents/`. Coordinate with extension owners but avoid editing Slice 6A's socket fragments or shared extension files concurrently. **Work:** -1. Inventory every spawn path in active subagent, `pi-ask`, update, and background-task extensions. Classify each in-process operation as safe UI/state access, disabled behavior, or a brokered subprocess. Keep `pi-workflow` absent from generated settings. +1. Inventory every spawn path in active subagent, `pi-ask`, update, and background-task extensions. Classify each in-process operation as safe UI/state access, disabled behavior, or a brokered subprocess. Keep `workflow` absent from generated settings. 2. Route child Pi, Claude, and Codex through command policies with isolated child state and the immutable provider selected by Slice 3. 3. Broker only named background jobs and notification commands that do not invoke repository toolchains; keep arbitrary commands, package updates, and direct privileged spawn paths disabled. 4. Define caller relationships and prohibit privileged-command escalation through child commands. 5. Verify Herdr and tmux targets launch under equivalent or narrower generated policies before enabling command delivery. 6. Add containment tests for nested children, background completion, cancellation, inherited environment, provider override denial, notification commands, package-update denial, and artifact paths. -**Acceptance criteria:** child commands receive no broader filesystem, network, credential, socket, environment, or provider access than declared; nested child tests prove containment; unsandboxed Herdr/tmux targets are rejected; bounded extension behavior remains functional without `pi-workflow` or toolchain execution. +**Acceptance criteria:** child commands receive no broader filesystem, network, credential, socket, environment, or provider access than declared; nested child tests prove containment; unsandboxed Herdr/tmux targets are rejected; bounded extension behavior remains functional without `workflow` or toolchain execution. **Dependencies:** pilot acceptance. May run in parallel with Slices 5A, 5B, and 6A. -## Slice 6B2: `pi-workflow` and toolchain integration +## Slice 6B2: `workflow` and toolchain integration -**Goal:** enable `pi-workflow` only after every toolchain command it can invoke has a completed command policy. +**Goal:** enable `workflow` only after every toolchain command it can invoke has a completed command policy. -**Owner surfaces:** `profiles/nono/pi-workflow.json`, workflow spawn-wrapper sources under `scripts/nono/child-agents/pi-workflow/`, the generated extension allowlist entry, and `tests/nono/capabilities/pi-workflow/`. +**Owner surfaces:** `profiles/nono/workflow.json`, workflow spawn-wrapper sources under `scripts/nono/child-agents/workflow/`, the generated extension allowlist entry, and `tests/nono/capabilities/workflow/`. -**Work:** route `pi-workflow` executable resolution and every npm/pnpm/Bun/Python/Rust/Go/Maven/Gradle task through the completed Slice 5 policies. Include lifecycle/run scripts, nested package executables, verification commands, and cancellation. Any unmodeled executable or repository without the required toolchain fragment fails before workflow execution. +**Work:** route `workflow` executable resolution and every npm/pnpm/Bun/Python/Rust/Go/Maven/Gradle task through the completed Slice 5 policies. Include lifecycle/run scripts, nested package executables, verification commands, and cancellation. Any unmodeled executable or repository without the required toolchain fragment fails before workflow execution. -**Acceptance criteria:** adversarial workflow tasks cannot bypass toolchain policies, select another provider, invoke high-risk commands, read sibling repositories, or access raw credentials; repositories lacking a required toolchain capability do not load `pi-workflow`. +**Acceptance criteria:** adversarial workflow tasks cannot bypass toolchain policies, select another provider, invoke high-risk commands, read sibling repositories, or access raw credentials; repositories lacking a required toolchain capability do not load `workflow`. **Dependencies:** Slices 5A, all applicable 5B language fragments, and Slice 6B1. It does not run in parallel with the toolchain policy it consumes. @@ -479,7 +479,7 @@ To keep parallel work mergeable: | Base filesystem | Active registered worktree has exactly its mapped read-only or read/write access; exact mutable Pi state and the per-launch temporary directory are writable | A read-only worktree cannot be written; broad `/tmp`, runtime parents, other sessions, sibling or unregistered worktrees, global instructions, extension source, other Pi state, `~/.ssh`, and `~/.config/gh` are inaccessible or read-only as designed | | Temporary lifecycle | Normal exit and cancellation remove the private runtime directory; approved active use remains within its aggregate bound | Cleanup preserves a held lease, matching PID/start time, populated cgroup, live unit, or inconclusive identity; proven crash leftovers expire, unsafe paths survive for review, and an active session cannot exhaust `/run` | | Worktree lifecycle | Broker creates, registers, resolves, and safely removes an external clean worktree | Broad worktree-root access, age-only deletion, dirty/untracked/unpushed removal, active-worktree removal, and linked-worktree confidentiality claims fail | -| Environment/extensions | Reviewed extensions and selected mediated provider credential work | Raw credential variables, old LM Studio source, duplicate providers, `/update`, `pi-workflow` before 6B2, arbitrary notification commands, and unbrokered shell jobs are absent | +| Environment/extensions | Reviewed extensions and selected mediated provider credential work | Raw credential variables, old LM Studio source, duplicate providers, `/update`, `workflow` before 6B2, arbitrary notification commands, and unbrokered shell jobs are absent | | Provider | Trusted default or one pre-boundary approved flag selects the route; LM Studio reaches port 1234 | Ambiguous/post-boundary/child overrides, non-selected providers, LM Studio port 1235, and broad localhost fail | | Git/SSH | Fetch/push uses the correct provisioned agent and immutable wrapper | Direct SSH, stale/substituted agent, bypass config, alternate binary, same-port host, wrong agent, wrong host, and wrong port fail | | GitHub CLI | Active-repository operation succeeds through proxy | Real token exposure, cross-repository API, and unapproved endpoints fail | diff --git a/docs/nono-profile-baseline.md b/docs/nono-profile-baseline.md index e74bbe0..7a1f0da 100644 --- a/docs/nono-profile-baseline.md +++ b/docs/nono-profile-baseline.md @@ -20,9 +20,9 @@ Captured for Slice 0 on 27AUG2026 with nono 0.74.0. This record contains no cred | `pi-orca-local` | Symlink to `profiles/nono/pi-orca-local.json` | Approved drift imported, narrowed to read-only trusted-file access, and relinked on 27AUG2026. | | `pi-harness-tools` | Symlink to `profiles/nono/pi-harness-tools.json` | Approved drift imported, narrowed to read-only trusted-file access, and relinked on 27AUG2026. | -The imported `pi-orca-local` drift is limited to exact read-only access for `$HOME/.pi/agent/AGENTS.md`. Its saved grant for `$HOME/.pi/agent/extensions/pi-workflow/index.ts` was not imported because the user removed that extension and deferred its replacement to a future skill. The imported `pi-harness-tools` drift is limited to exact read-only access for `$HOME/.pi/agent/extensions/pi-todo-herdr/index.ts` and `$HOME/.config/herdr/herdr.sock`. Its inherited `pi-local` policy already contains the Herdr Unix-socket grant. The active files used read/write `allow_file` saved grants; the repository deliberately narrows them to `read_file` because normal sessions do not need to modify trusted instructions or extension code. No other active saved grant was imported. +The imported `pi-orca-local` drift is limited to exact read-only access for `$HOME/.pi/agent/AGENTS.md`. Its saved grant for `$HOME/.pi/agent/extensions/workflow/index.ts` was not imported because the user removed that extension and deferred its replacement to a future skill. The imported `pi-harness-tools` drift is limited to exact read-only access for `$HOME/.pi/agent/extensions/pi-todo-herdr/index.ts` and `$HOME/.config/herdr/herdr.sock`. Its inherited `pi-local` policy already contains the Herdr Unix-socket grant. The active files used read/write `allow_file` saved grants; the repository deliberately narrows them to `read_file` because normal sessions do not need to modify trusted instructions or extension code. No other active saved grant was imported. -The `pi-workflow` extension was unlinked from active Pi state by the user during this slice. It is also absent from normal `config/pi-links.json` exposure so reconciliation will not recreate it. The future replacement is tracked in `docs/todos.md`. +The `workflow` extension was unlinked from active Pi state by the user during this slice. It is also absent from normal `config/pi-links.json` exposure so reconciliation will not recreate it. The future replacement is tracked in `docs/todos.md`. ## Draft disposition @@ -53,7 +53,7 @@ The `.nono-pi-pack-marker` is package wiring, not a profile draft, and remains i Only `@pi-kaush/pi-welcome-screen@0.1.3` is currently locked end to end. Bare npm sources, `@latest`, and Git URLs without revisions remain blocked even though their installed contents are identified. `nolabs-ai/pi` remains blocked because the nono lockfile records `pinned: false`. -The extension inventory records the active repository-backed links and their observed commits. Mutable checkout targets and unmanaged links remain blocked until maintenance installs immutable snapshots. The unmanaged active `lm-studio.ts` file is recorded by SHA-256 and remains blocked pending the Slice 1 import and quarantine. `update` remains actively linked at capture time but has been removed from normal link exposure. The active `pi-workflow` link was removed by the user and is not part of the current active inventory. +The extension inventory records the active repository-backed links and their observed commits. Mutable checkout targets and unmanaged links remain blocked until maintenance installs immutable snapshots. The unmanaged active `lm-studio.ts` file is recorded by SHA-256 and remains blocked pending the Slice 1 import and quarantine. `update` remains actively linked at capture time but has been removed from normal link exposure. The active `workflow` link was removed by the user and is not part of the current active inventory. ## Maintenance-only follow-up diff --git a/docs/npm-packages.md b/docs/npm-packages.md new file mode 100644 index 0000000..591ea0e --- /dev/null +++ b/docs/npm-packages.md @@ -0,0 +1,59 @@ +# Bun workspace packages and Gitea npm publishing + +The repository uses Bun workspaces under `extensions/*`. External dependency versions live in the root `package.json` catalog; repository-owned dependencies use `workspace:^`. + +Publishable packages include the repository-native extensions plus the imported `@_jb/pi-interactive-subagents`, `@_jb/pi-native-hooks`, and `@_jb/pi-todo-herdr` snapshots. `config/imported-packages.json` records their source `main` commits; use `bun run imports:check` to detect upstream movement. + +## Install and verify + +```bash +bun install --frozen-lockfile +bun run typecheck +bun run test +``` + +Use plain `bun install` when intentionally updating `bun.lock`. + +## Registry configuration + +The tracked `.npmrc` routes only the `@_jb` scope: + +```ini +@_jb:registry=https://git.jb.codes/api/packages/jb/npm/ +``` + +Configure a Gitea personal access token with package permissions outside the repository: + +```ini +//git.jb.codes/api/packages/jb/npm/:_authToken=${GITEA_NPM_TOKEN} +``` + +The token line may live in `~/.npmrc`, or `GITEA_NPM_TOKEN` may be supplied by the publishing environment. Never commit a token. + +## Pack and publish + +Bun must resolve `catalog:` and `workspace:` references before publication. From a publishable workspace: + +```bash +cd extensions/shared +bun pm pack --destination ../../dist +bun publish +``` + +Publish `@_jb/pi-shared` before a new version of any extension that depends on it. Gitea rejects republishing an existing name/version, so bump the changed package version first. + +`extensions/workflow` is intentionally private until the `_workflows` reference dependencies are merged into that package. `extensions/_workflows` must not be published. + +## Install with Pi + +After configuring the same scope and token in the npm configuration visible to Pi: + +```bash +pi install npm:@_jb/pi-dictate +pi install npm:@_jb/pi-file-search +pi install npm:@_jb/pi-interactive-subagents +pi install npm:@_jb/pi-native-hooks +pi install npm:@_jb/pi-todo-herdr +``` + +Pi installs runtime dependencies from the same scoped registry. The repository's symlink-based development exposure remains controlled separately by `config/pi-links.json`. diff --git a/docs/todo.md b/docs/todo.md index 5223976..c180622 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,3 +1,3 @@ # TODO -- [ ] Make pi-workflow verification tolerate inaccessible sibling Git worktrees. `GitStateProbe.captureRepository()` currently enumerates every path from `git worktree list --porcelain` and fails the verification command before execution when any unrelated worktree cannot be inspected. Preserve detection of cross-worktree mutations without making unrelated worktrees a hard verification dependency. Add focused coverage for an inaccessible registered sibling worktree. +- [ ] Make workflow verification tolerate inaccessible sibling Git worktrees. `GitStateProbe.captureRepository()` currently enumerates every path from `git worktree list --porcelain` and fails the verification command before execution when any unrelated worktree cannot be inspected. Preserve detection of cross-worktree mutations without making unrelated worktrees a hard verification dependency. Add focused coverage for an inaccessible registered sibling worktree. diff --git a/docs/todos.md b/docs/todos.md index 996434b..41d66fb 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -1,5 +1,9 @@ # Todos +## Merge `_workflows` reference runtime into `workflow` + +`extensions/_workflows` was retained only as a reference while `extensions/workflow` was developed, but `workflow` still imports its runner, model, prompt, and serialization modules. Move the required code under `extensions/workflow`, remove obsolete model-authored workflow surfaces, update tests and documentation, and then delete the reference directory. Until that work is complete, `@_jb/pi-workflow` must remain a private Bun workspace and `extensions/_workflows` must remain excluded from workspaces and publication. Acceptance requires `bun pm pack` for `workflow` to contain every runtime import without reaching outside the package root, followed by an isolated Pi package smoke test. + ## Make `pi-links` checks work from linked worktrees A Slice 0 `node scripts/pi-links.mjs prune --dry-run` from `.worktrees/nono-dynamic-permissions-slice-zero-direct` exited 2 and classified every active link into the primary checkout as an outside-repository conflict. The tool anchors ownership to the current worktree root, while active links correctly target the primary repository checkout. Teach check and dry-run planning to recognize canonical checkouts sharing the same Git common directory without broadening stale-link deletion to unrelated repositories. Add linked-worktree fixtures before changing prune behavior. Until fixed, run host reconciliation only from the integrated primary checkout. @@ -8,9 +12,9 @@ A Slice 0 `node scripts/pi-links.mjs prune --dry-run` from `.worktrees/nono-dyna Slice 1 found two Nono 0.74 limitations while building behavioral policy proofs. First, command-policy startup fails with `path must be shorter than SUN_LEN` when Nono creates its supervisor socket beneath a moderately long isolated `TMPDIR` such as `/tmp/nono-slice-one-*/sessions/personal/tmp`. Before broker profiles activate command policies, reproduce the maximum safe path, then have the launcher select a private short socket root or confirm an upstream fix. Second, `nono profile show --json` omits resolved `environment`, `command_policies`, `network.connect_port`, and `network.custom_credentials` fields even though strict validation and live enforcement accept them. Do not treat resolved JSON as complete evidence for these capabilities; retain behavioral `nono run` and `nono why --command` proofs until the output is complete and regression-tested. -## Replace `pi-workflow` with a skill-based orchestrator +## Replace `workflow` with a skill-based orchestrator -The strict `pi-workflow` extension repeatedly failed before implementation because planner output and persisted state-machine constraints were too brittle for adaptive subagent orchestration. In a separate session, design a repository-managed skill where the main Pi agent owns the plan, task tree, isolated worktree, explicit user approval gates, verification, and independent review while launching standard `scout` and `worker` subagents directly. Preserve file-ownership boundaries, no inferred approvals, review independence, and resumable repository evidence, but prefer native Pi primitives over a custom orchestration engine. Evaluate a small committed state manifest for crash recovery before retiring or archiving the extension. Use the abandoned `.worktrees/pi-workflow-plan-seed` implementation only as reference; do not integrate it by default. +The strict `workflow` extension repeatedly failed before implementation because planner output and persisted state-machine constraints were too brittle for adaptive subagent orchestration. In a separate session, design a repository-managed skill where the main Pi agent owns the plan, task tree, isolated worktree, explicit user approval gates, verification, and independent review while launching standard `scout` and `worker` subagents directly. Preserve file-ownership boundaries, no inferred approvals, review independence, and resumable repository evidence, but prefer native Pi primitives over a custom orchestration engine. Evaluate a small committed state manifest for crash recovery before retiring or archiving the extension. Use the abandoned `.worktrees/workflow-plan-seed` implementation only as reference; do not integrate it by default. ## Support subagent launches through `multi_tool_use.parallel` diff --git a/docs/pi-workflow/README.md b/docs/workflow/README.md similarity index 86% rename from docs/pi-workflow/README.md rename to docs/workflow/README.md index 881e8d4..b60b3b9 100644 --- a/docs/pi-workflow/README.md +++ b/docs/workflow/README.md @@ -1,6 +1,6 @@ -# pi-workflow +# workflow -`pi-workflow` plans and implements feature work through persisted human Gates, isolated Git worktrees, focused verification, and independent review. +`workflow` plans and implements feature work through persisted human Gates, isolated Git worktrees, focused verification, and independent review. ## Start a feature @@ -10,9 +10,9 @@ Start with: /workflow start ``` -The command sends the message through the assistant, which chooses the descriptive feature name and idempotency key before invoking `pi_workflow` through the normal tool path. In interactive TUI mode, `/workflow start` without a message asks what you want to start working on. Non-interactive modes require the message in the command. +The command sends the message through the assistant, which chooses the descriptive feature name and idempotency key before invoking `workflow` through the normal tool path. In interactive TUI mode, `/workflow start` without a message asks what you want to start working on. Non-interactive modes require the message in the command. -You can also ask the assistant to use `pi_workflow` with: +You can also ask the assistant to use `workflow` with: - `action: "start"` - `workflow: { mode: "feature" }` (or omit `workflow`) @@ -37,9 +37,9 @@ The assistant must inspect the current run before each state-changing call and o ## Agent selection -`pi-workflow` reuses explicitly mapped standard definitions from `pi-interactive-subagents` before falling back to its bundled workflow profiles. By default, `scout` owns Discovery and `worker` owns Implementation; bundled profiles continue to supply Design, Prototype, Verify, and independent Review. Definitions resolve with package, global, then trusted-project precedence and are pinned into the run with their workflow role overlay. +`workflow` reuses explicitly mapped standard definitions from `pi-interactive-subagents` before falling back to its bundled workflow profiles. By default, `scout` owns Discovery and `worker` owns Implementation; bundled profiles continue to supply Design, Prototype, Verify, and independent Review. Definitions resolve with package, global, then trusted-project precedence and are pinned into the run with their workflow role overlay. -Configure or disable mappings in `~/.pi/agent/pi-workflow.json` or trusted `.pi/pi-workflow.json`: +Configure or disable mappings in `~/.pi/agent/workflow.json` or trusted `.pi/workflow.json`: ```json { @@ -99,11 +99,11 @@ Interrupted work opens a Recovery Gate with retry, inspect, and abort choices. C Run state and bounded artifacts are stored under: ```text -~/.pi/agent/pi-workflows/runs// +~/.pi/agent/workflows/runs// ``` Important files include immutable plan revisions, Assignment transcripts, retained prototype reports and screenshots, `result.json`, and the terminal Markdown report. Report headings use the readable label and include the full canonical Run ID as explicit metadata. Completed worktrees remain available for manual inspection and integration. ## Reload after exposure -Repository-managed extension links are controlled through `config/pi-links.json`. Run `/reload` after the managed link is synchronized so Pi discovers `pi-workflow`. +Repository-managed extension links are controlled through `config/pi-links.json`. Run `/reload` after the managed link is synchronized so Pi discovers `workflow`. diff --git a/docs/pi-workflow/dashboard-mocks.md b/docs/workflow/dashboard-mocks.md similarity index 88% rename from docs/pi-workflow/dashboard-mocks.md rename to docs/workflow/dashboard-mocks.md index 4689bd9..73da988 100644 --- a/docs/pi-workflow/dashboard-mocks.md +++ b/docs/workflow/dashboard-mocks.md @@ -1,10 +1,10 @@ -# pi-workflow popup prototypes +# workflow popup prototypes **Status:** Direction C accepted These static terminal frames compare popup directions for `/workflow`. They are prototype evidence, not production screenshots or manual validation of the implemented extension. All variants use Pi theme tokens, keyboard-visible focus, one border at most, and no animation. -Representative run: `pi-workflow-usability-resume`, waiting at Plan Approval after Discover and Design. +Representative run: `workflow-usability-resume`, waiting at Plan Approval after Discover and Design. ## A. Centered compact modal @@ -13,8 +13,8 @@ A small modal preserves the surrounding conversation and keeps one decision in f ```text ┌──────────────────────────────────────────────────────────────────────┐ │ │ -│ ╭─ pi-workflow ──────────────────────────────╮ │ -│ │ pi-workflow-usability-resume │ │ +│ ╭─ workflow ──────────────────────────────╮ │ +│ │ workflow-usability-resume │ │ │ │ WAITING · Plan Approval · revision 12 │ │ │ │ │ │ │ │ ✓ Discover ✓ Design · Prototype │ │ @@ -46,7 +46,7 @@ Weaknesses: ### A at 60 columns ```text - ╭─ pi-workflow ───────────────────────╮ + ╭─ workflow ───────────────────────╮ │ usability-resume · WAITING │ │ Plan Approval · r12 │ │ │ @@ -64,9 +64,9 @@ Weaknesses: A stable workspace keeps the run list, selected run, progress, and actions visible together. ```text -╭─ pi-workflow ────────────────────────────────────────────────────────────────╮ -│ Runs │ pi-workflow-usability-resume WAITING │ -│ │ feature/pi-workflow-usability-resume · r12 │ +╭─ workflow ────────────────────────────────────────────────────────────────╮ +│ Runs │ workflow-usability-resume WAITING │ +│ │ feature/workflow-usability-resume · r12 │ │ > usability-resume │ │ │ auth-cleanup │ Progress │ │ cache-audit │ ✓ Discover ✓ Design · Prototype · Implement │ @@ -101,7 +101,7 @@ Weaknesses: ### B at 80x24 ```text -╭─ pi-workflow ────────────────────────────────────────────────────────────────╮ +╭─ workflow ────────────────────────────────────────────────────────────────╮ │ Runs │ usability-resume · WAITING · r12 │ │ > usability │ ✓ Discover ✓ Design · Prototype │ │ auth-cleanup │ · Implement · Verify · Review │ @@ -127,7 +127,7 @@ Direction C uses shallow browser-style navigation. Each overlay does one job. Es ### C1. Run list popup ```text - ╭─ pi-workflow ─────────────────────────────╮ + ╭─ workflow ─────────────────────────────╮ │ > usability-resume WAITING Plan Approval│ │ auth-cleanup RUNNING Implement │ │ cache-audit DONE report ready │ @@ -160,11 +160,11 @@ Direction C uses shallow browser-style navigation. Each overlay does one job. Es The reader uses the whole overlay. The terminal edge is the outer frame, so there is no decorative full-screen border. The left outline tracks the heading containing the top visible document line. ```text - pi-workflow plan · usability-resume line 42/216 + workflow plan · usability-resume line 42/216 Outline Plan ───────────────────────────── ───────────────────────────────────────────── - pi-workflow usability update # pi-workflow usability update plan + workflow usability update # workflow usability update plan Locked decisions > Implementation phases ## Implementation phases Prototype overlays @@ -186,7 +186,7 @@ The reader uses the whole overlay. The terminal edge is the outer frame, so ther ### C3 at 80x24 ```text - pi-workflow plan · usability-resume row 42/216 + workflow plan · usability-resume row 42/216 Outline Plan ────────────────────── ───────────────────────────────────────────── @@ -207,7 +207,7 @@ The reader uses the whole overlay. The terminal edge is the outer frame, so ther The outline hides and the document becomes the only pane. The current heading remains visible in the header. ```text - pi-workflow plan · Implementation phases · row 42/216 + workflow plan · Implementation phases · row 42/216 ## Implementation phases @@ -228,7 +228,7 @@ The outline hides and the document becomes the only pane. The current heading re terminal too small - pi-workflow plan needs 48x12 + workflow plan needs 48x12 current terminal is 47x11 resize the terminal or press esc @@ -271,7 +271,7 @@ Use direction C. It has one border only on bounded popup cards, no redundant fra ## Production mapping -The accepted direction maps to `extensions/pi-workflow/tui.ts` and `dashboard.ts`. The implementation uses popup overlays for bounded selections and text entry, then switches to the full-screen reader for verified plans and retained evidence. `progress.ts` owns the separate one-line widget. Pure renderer tests cover the wide, 80-column, 60-column, and below-48x12 contracts; interactive Pi validation remains a separate manual step. +The accepted direction maps to `extensions/workflow/tui.ts` and `dashboard.ts`. The implementation uses popup overlays for bounded selections and text entry, then switches to the full-screen reader for verified plans and retained evidence. `progress.ts` owns the separate one-line widget. Pure renderer tests cover the wide, 80-column, 60-column, and below-48x12 contracts; interactive Pi validation remains a separate manual step. ## Interaction contract diff --git a/docs/pi-workflow/design.md b/docs/workflow/design.md similarity index 93% rename from docs/pi-workflow/design.md rename to docs/workflow/design.md index e6bf6b1..45f3acf 100644 --- a/docs/pi-workflow/design.md +++ b/docs/workflow/design.md @@ -1,13 +1,13 @@ -# pi-workflow Design +# workflow Design **Status:** Accepted **Date:** 24AUG2026 ## Purpose -`pi-workflow` coordinates feature planning and implementation through selected Markdown-defined agents, persisted human gates, isolated Pi child sessions, and mandatory independent review. +`workflow` coordinates feature planning and implementation through selected Markdown-defined agents, persisted human gates, isolated Pi child sessions, and mandatory independent review. -The extension is based on `extensions/_workflows`, but it is a new extension rather than a rename. The existing extension remains an inactive reference until `pi-workflow` is verified and explicitly exposed. +The extension is based on `extensions/_workflows`, but it is a new extension rather than a rename. The existing extension remains an inactive reference until `workflow` is verified and explicitly exposed. ## Goals @@ -58,7 +58,7 @@ The extension is based on `extensions/_workflows`, but it is a new extension rat The extension exposes one state-changing tool and one workflow command. ```ts -type PiWorkflowInput = +type WorkflowInput = | { action: "start"; request: string; @@ -152,13 +152,13 @@ Feature and feature-script starts require `featureName` as a concise lowercase k Human-facing dashboard, selector, notification, handoff, compaction, tool, and report prose uses a shared presentation label. It prefers `featureName`; otherwise it normalizes the request to one line and bounds it to 48 characters, preserving whole words where possible and adding `…` when truncated; only a missing request falls back to `runId`. When derived labels collide within an available run list, only those labels receive ` · `, where the run-ID prefix is the shortest unique prefix with a minimum of eight characters. This label is never persisted as identity or accepted as a lookup alias. It requires no schema migration and does not change persisted paths. The complete `runId` remains canonical for snapshots, structured fields, command arguments, paths, receipts, leases, and idempotency. Copyable `/workflow continue ` and `/workflow stop ` commands retain it, and reports include it as `Run ID` metadata beneath the readable heading. -The `pi_workflow` tool calls a single internal `execute(command)` interface. `/workflow` opens a drill-down TUI dashboard and may accept a run ID for direct inspection. Run selection, actions, Gates, confirmations, questions, and feedback use bounded popup overlays. Verified plans, reports, prototype evidence, and Assignment transcripts use a sanitized full-screen reader with H1-H3 outline navigation at widths of 80 columns or more and a single document pane from 60 through 79 columns. Below 48x12 it renders only a truthful minimum-size state. RPC, print, and JSON paths remain linear and do not call `ctx.ui.custom()`. +The `workflow` tool calls a single internal `execute(command)` interface. `/workflow` opens a drill-down TUI dashboard and may accept a run ID for direct inspection. Run selection, actions, Gates, confirmations, questions, and feedback use bounded popup overlays. Verified plans, reports, prototype evidence, and Assignment transcripts use a sanitized full-screen reader with H1-H3 outline navigation at widths of 80 columns or more and a single document pane from 60 through 79 columns. Below 48x12 it renders only a truthful minimum-size state. RPC, print, and JSON paths remain linear and do not call `ctx.ui.custom()`. -`/workflow start ` sends the initial request through the assistant so the normal `pi_workflow` tool path owns feature naming and idempotency; in interactive TUI mode, omitting the message opens an input overlay. `/workflow continue` presents eligible pending context transitions for selection, while `/workflow continue ` continues one directly; `/workflow resume` is an alias for both forms. Selecting a fresh-session continuation from the dashboard routes directly through the same handler and calls `newSession()` without a second command. `/workflow stop [run-id]` aborts a non-terminal run owned by the active parent session and presents a selector when multiple owned runs are active. The misspelled `/worlflow` alias is intentionally unsupported. Continue and stop selectors require interactive TUI mode and provide direct-run-ID guidance otherwise. +`/workflow start ` sends the initial request through the assistant so the normal `workflow` tool path owns feature naming and idempotency; in interactive TUI mode, omitting the message opens an input overlay. `/workflow continue` presents eligible pending context transitions for selection, while `/workflow continue ` continues one directly; `/workflow resume` is an alias for both forms. Selecting a fresh-session continuation from the dashboard routes directly through the same handler and calls `newSession()` without a second command. `/workflow stop [run-id]` aborts a non-terminal run owned by the active parent session and presents a selector when multiple owned runs are active. The misspelled `/worlflow` alias is intentionally unsupported. Continue and stop selectors require interactive TUI mode and provide direct-run-ID guidance otherwise. Continuation performs only a pending fresh-session handoff or normal-compaction fallback after the run is safely waiting; it cannot approve plans, select prototypes, or make other Decisions. Stop treats `running` and `waiting` runs as active and excludes terminal runs and runs owned only by historical parent sessions. -When a run reaches a Gate, the tool returns a structured waiting view. The parent agent asks the user through its normal interaction tools, then calls `pi_workflow` with `action: "decide"`. Child agents never receive `ask_user`. +When a run reaches a Gate, the tool returns a structured waiting view. The parent agent asks the user through its normal interaction tools, then calls `workflow` with `action: "decide"`. Child agents never receive `ask_user`. Background runs return a structured run ID immediately. When they reach a Gate or settle, the extension sends one custom follow-up message with a stable delivery key to the active parent session and a readable run label in its prose. @@ -170,7 +170,7 @@ After `start` creates the run lease, every subsequent state-changing parent oper Profiles are loaded in this order: -1. Bundled workflow profiles from `extensions/pi-workflow/agents/*.md`. +1. Bundled workflow profiles from `extensions/workflow/agents/*.md`. 2. Configured standard definitions from `${PI_CODING_AGENT_DIR:-~/.pi/agent}/extensions/pi-interactive-subagents/agents/`. 3. Configured standard definitions from `${PI_CODING_AGENT_DIR:-~/.pi/agent}/agents/`. 4. Workflow profiles or configured standard definitions from the nearest `.pi/agents/*.md`, only when `ctx.isProjectTrusted()` is true. @@ -252,8 +252,8 @@ The engine selects the initial discovery profile deterministically from eligible Configuration sources are: 1. Bundled defaults. -2. `~/.pi/agent/pi-workflow.json`. -3. Trusted `.pi/pi-workflow.json`. +2. `~/.pi/agent/workflow.json`. +3. Trusted `.pi/workflow.json`. 4. Per-run invocation values. Invocation values may change requested concurrency but never the safety ceiling. A project may lower the global ceiling but cannot raise it. @@ -484,7 +484,7 @@ return results; Every feature Workflow Run owns a distinct, exclusive worktree under: ```text -/.worktrees/pi-workflow--/ +/.worktrees/workflow--/ ``` The corresponding branch is: @@ -506,7 +506,7 @@ Read-only analysis runs use the parent cwd and do not create worktrees. All chil Artifacts live under: ```text -~/.pi/agent/pi-workflows/runs// +~/.pi/agent/workflows/runs// ``` Files include: @@ -554,11 +554,11 @@ The effective model is resolved in this order: Unknown or ambiguous models fail the Assignment before execution. The effective provider, model, effort, profile digest, child session ID, and context capacity are persisted. -Children cannot access recursive workflow or subagent tools or `ask_user`. Standard definitions are execution identities, not permission grants: `worker` may declare `bash` and nested subagents, but mutating workflow Assignments still receive only audited reads plus guarded `edit`/`write`, and read-only Assignments receive only audited reads. `pi_workflow` is added to both the shared child-tool exclusion policy and the separate Pi-subagent child exclusion list alongside the legacy `workflow` name; implementation should consolidate the duplicate list onto the shared policy when possible. Each child tool call retains the shared timeout guard, including tools registered after startup. The first-response watchdog and bounded shutdown behavior from `_workflows` are retained. +Children cannot access recursive workflow or subagent tools or `ask_user`. Standard definitions are execution identities, not permission grants: `worker` may declare `bash` and nested subagents, but mutating workflow Assignments still receive only audited reads plus guarded `edit`/`write`, and read-only Assignments receive only audited reads. `workflow` is added to the shared child-tool exclusion policy used by Pi-subagent children so recursive orchestration remains unavailable. Each child tool call retains the shared timeout guard, including tools registered after startup. The first-response watchdog and bounded shutdown behavior from `_workflows` are retained. ## Internal Modules -`pi-workflow` should remain a small set of deep modules: +`workflow` should remain a small set of deep modules: - `index.ts`: Pi tool, command, lifecycle, and composition root. - `engine.ts`: command handling, state transitions, gates, policies, and scheduling. @@ -578,9 +578,9 @@ Pure transition and eligibility logic stay inside the engine and profile modules ## Exposure and Migration - Do not rename or activate `_workflows` during initial development. -- Add `extensions/pi-workflow` tests to `package.json` explicitly. +- Add `extensions/workflow` tests to `package.json` explicitly. - Typecheck through the existing `extensions/**/*.ts` configuration. -- Add `pi-workflow` to `config/pi-links.json` only after focused tests, full tests, independent review, and manual TUI validation pass. +- Add `workflow` to `config/pi-links.json` only after focused tests, full tests, independent review, and manual TUI validation pass. - Run the link reconciler in check mode before sync. - Preserve unrelated managed links and require `/reload` after exposure. @@ -627,7 +627,7 @@ Implement the production Pi Assignment executor and eligible planner-selection p - Planner choices are restricted to eligible profiles and persist selected, rejected, and rationale data. - Aggregate running Assignments across all Workflow Runs never exceed 32 or a lower configured ceiling. - Capacity is reserved synchronously; queued cancellation and bounded session shutdown release capacity exactly once. -- `pi_workflow`, legacy workflow/subagent tools, and user interaction remain unavailable through both shared workflow-child and Pi-subagent child policies. +- `workflow`, legacy workflow/subagent tools, and user interaction remain unavailable through both shared workflow-child and Pi-subagent child policies. - A tool registered after child startup is still guarded by `extensions/shared/tool-call-timeout.ts`. ### Phase 4: Worktrees, implementation, verification, and review @@ -640,7 +640,7 @@ Extend the Phase 2 worktree scaffold with the Implementation Context Gate, `/wor - Workspace creation intent is persisted first; creation failure rolls back safely or remains reconcilable after restart. - Every feature run receives a distinct leased worktree and worktrees are retained until explicitly removed. - A versioned `plan.md` is persisted before the Implementation Context Gate and revised with an accepted prototype when applicable. -- Phase 4 registers `/workflow start`, `/workflow stop`, `/workflow continue`, and its `/workflow resume` alias. Start routes through the assistant's normal `pi_workflow` tool path, stop is scoped to active runs owned by the current parent session, and continuation without a run ID selects from valid candidates. Its `ExtensionCommandContext` performs both fresh-session replacement and approved normal-compaction fallback rather than deferring either path to dashboard integration. +- Phase 4 registers `/workflow start`, `/workflow stop`, `/workflow continue`, and its `/workflow resume` alias. Start routes through the assistant's normal `workflow` tool path, stop is scoped to active runs owned by the current parent session, and continuation without a run ID selects from valid candidates. Its `ExtensionCommandContext` performs both fresh-session replacement and approved normal-compaction fallback rather than deferring either path to dashboard integration. - `fresh-session` persists a Gate-, revision-, plan-, preparation-, and source-session-bound handoff intent before `newSession`; only the replacement `withSession` callback consumes it, attaches the new active parent session, seeds the bounded plan handoff, and resumes. - `smart-compact` uses a Gate-bound `report-context-preparation` transition with distinct `unavailable`, `succeeded`, `no-op`, and `failed` outcomes; `failed` requires a non-empty actionable error. The host validates `unavailable` against `pi.getActiveTools()`; only that outcome permits plan-focused `ctx.compact({ onComplete, onError })`, whose intent-bound callback persists success or failure. - An available-but-failed `smart_compact` call does not silently fall back and keeps the Implementation Context Gate waiting with an actionable error; `current-session` preserves context unchanged. diff --git a/docs/pi-workflow/usability-plan.md b/docs/workflow/usability-plan.md similarity index 98% rename from docs/pi-workflow/usability-plan.md rename to docs/workflow/usability-plan.md index 490eff9..1a09e00 100644 --- a/docs/pi-workflow/usability-plan.md +++ b/docs/workflow/usability-plan.md @@ -1,4 +1,4 @@ -# pi-workflow usability update plan +# workflow usability update plan Status: manual recovery plan after managed Workflow Runs `512d6c31`, `51b0c1cb`, and `ecb11e1e` failed or were interrupted before implementation. @@ -26,7 +26,7 @@ These failures are acceptance-test inputs for assignment timeout handling and de ### 1. Prototype overlays -Own only `docs/pi-workflow/dashboard-mocks.md` and isolated prototype artifacts. Compare: +Own only `docs/workflow/dashboard-mocks.md` and isolated prototype artifacts. Compare: - A: centered compact modal - B: near-full-screen workspace with list/detail panes diff --git a/extensions/_workflows/artifacts.test.ts b/extensions/_workflows/artifacts.test.ts index bcfe88f..a610802 100644 --- a/extensions/_workflows/artifacts.test.ts +++ b/extensions/_workflows/artifacts.test.ts @@ -58,7 +58,7 @@ test("artifact transcript keeps the initial prompt, marker, and newest entries", }); test("live artifact persistence includes current agents and transcripts", () => { - const directory = mkdtempSync(join(tmpdir(), "pi-workflow-artifacts-")); + const directory = mkdtempSync(join(tmpdir(), "workflow-artifacts-")); try { const details = workflowDetails(); details.agents.push({ diff --git a/extensions/_workflows/sandbox-child.cjs b/extensions/_workflows/sandbox-child.cjs index 8206358..2a4ae01 100644 --- a/extensions/_workflows/sandbox-child.cjs +++ b/extensions/_workflows/sandbox-child.cjs @@ -226,7 +226,7 @@ function run(source, argsJson) { }; const context = vm.createContext(sandbox, { - name: "pi-workflow", + name: "workflow", codeGeneration: { strings: false, wasm: false }, }); new vm.Script(BOOTSTRAP, { diff --git a/extensions/_workflows/serialization.test.ts b/extensions/_workflows/serialization.test.ts index 668fb39..59566f9 100644 --- a/extensions/_workflows/serialization.test.ts +++ b/extensions/_workflows/serialization.test.ts @@ -27,7 +27,7 @@ test("safeStringify handles cycles, bigint, depth, and size", () => { }); test("atomic writes leave complete readable content", () => { - const directory = mkdtempSync(join(tmpdir(), "pi-workflow-test-")); + const directory = mkdtempSync(join(tmpdir(), "workflow-test-")); try { const file = join(directory, "artifact.json"); writeFileAtomic(file, '{"value":1}'); diff --git a/extensions/copy-all/LICENSE b/extensions/copy-all/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/copy-all/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/copy-all/README.md b/extensions/copy-all/README.md new file mode 100644 index 0000000..de97807 --- /dev/null +++ b/extensions/copy-all/README.md @@ -0,0 +1,7 @@ +# @_jb/pi-copy-all + +Pi extension that adds `/copy-all` for copying the current conversation as Markdown. + +```bash +pi install npm:@_jb/pi-copy-all +``` diff --git a/extensions/copy-all/package.json b/extensions/copy-all/package.json new file mode 100644 index 0000000..c127741 --- /dev/null +++ b/extensions/copy-all/package.json @@ -0,0 +1,22 @@ +{ + "name": "@_jb/pi-copy-all", + "version": "0.1.0", + "description": "Pi command for copying the current conversation as Markdown.", + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.19.0" }, + "keywords": ["pi-package", "pi-extension"], + "files": ["index.ts", "README.md", "LICENSE"], + "pi": { "extensions": ["./index.ts"] }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer" + }, + "publishConfig": { + "registry": "https://git.jb.codes/api/packages/jb/npm/" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/copy-all" + } +} diff --git a/extensions/dictate/LICENSE b/extensions/dictate/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/dictate/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/dictate/package.json b/extensions/dictate/package.json new file mode 100644 index 0000000..2889715 --- /dev/null +++ b/extensions/dictate/package.json @@ -0,0 +1,22 @@ +{ + "name": "@_jb/pi-dictate", + "version": "0.1.0", + "description": "Pi extension for local Linux voice dictation through a Unix socket service.", + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.19.0" }, + "keywords": ["pi-package", "pi-extension", "dictation"], + "files": ["index.ts", "README.md", "LICENSE"], + "pi": { "extensions": ["./index.ts"] }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer" + }, + "publishConfig": { + "registry": "https://git.jb.codes/api/packages/jb/npm/" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/dictate" + } +} diff --git a/extensions/effort-controls/LICENSE b/extensions/effort-controls/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/effort-controls/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/effort-controls/README.md b/extensions/effort-controls/README.md new file mode 100644 index 0000000..76b3b61 --- /dev/null +++ b/extensions/effort-controls/README.md @@ -0,0 +1,7 @@ +# @_jb/pi-effort-controls + +Pi extension that adds `/effort` reasoning selection and `/fast` priority-mode controls. + +```bash +pi install npm:@_jb/pi-effort-controls +``` diff --git a/extensions/effort-controls/package.json b/extensions/effort-controls/package.json new file mode 100644 index 0000000..0149fda --- /dev/null +++ b/extensions/effort-controls/package.json @@ -0,0 +1,22 @@ +{ + "name": "@_jb/pi-effort-controls", + "version": "0.1.0", + "description": "Pi commands for reasoning effort and priority-mode controls.", + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.19.0" }, + "keywords": ["pi-package", "pi-extension"], + "files": ["index.ts", "README.md", "LICENSE"], + "pi": { "extensions": ["./index.ts"] }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer" + }, + "publishConfig": { + "registry": "https://git.jb.codes/api/packages/jb/npm/" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/effort-controls" + } +} diff --git a/extensions/file-search/LICENSE b/extensions/file-search/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/file-search/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/file-search/README.md b/extensions/file-search/README.md new file mode 100644 index 0000000..b6edee7 --- /dev/null +++ b/extensions/file-search/README.md @@ -0,0 +1,7 @@ +# @_jb/pi-file-search + +Pi extension providing structured `fd` file search and `rg` text search tools. + +```bash +pi install npm:@_jb/pi-file-search +``` diff --git a/extensions/file-search/package.json b/extensions/file-search/package.json new file mode 100644 index 0000000..31c1a7a --- /dev/null +++ b/extensions/file-search/package.json @@ -0,0 +1,30 @@ +{ + "name": "@_jb/pi-file-search", + "version": "0.1.0", + "description": "Pi file and text search tools backed by fd and ripgrep.", + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.19.0" }, + "keywords": ["pi-package", "pi-extension", "search"], + "files": ["index.ts", "src", "README.md", "LICENSE"], + "pi": { "extensions": ["./index.ts"] }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@effect/platform-node-shared": "catalog:", + "effect": "catalog:" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "catalog:pi-peer", + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + "typebox": "catalog:pi-peer" + }, + "publishConfig": { + "registry": "https://git.jb.codes/api/packages/jb/npm/" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/file-search" + } +} diff --git a/extensions/interactive-subagents/.gitignore b/extensions/interactive-subagents/.gitignore new file mode 100644 index 0000000..4625d62 --- /dev/null +++ b/extensions/interactive-subagents/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +config.json +.DS_Store +.memory/ +.pi/ +.worktrees/ \ No newline at end of file diff --git a/extensions/interactive-subagents/LICENSE b/extensions/interactive-subagents/LICENSE new file mode 100644 index 0000000..509aa21 --- /dev/null +++ b/extensions/interactive-subagents/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 HazAT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/interactive-subagents/README.md b/extensions/interactive-subagents/README.md new file mode 100644 index 0000000..fd1829f --- /dev/null +++ b/extensions/interactive-subagents/README.md @@ -0,0 +1,227 @@ +## Forked from [amosblomqvist/pi-interactive-subagents](https://github.com/amosblomqvist/pi-interactive-subagents) + +# pi-interactive-subagents + +Async subagents for [pi](https://github.com/badlogic/pi-mono), running in Herdr tabs, Orca terminal tabs, or tmux panes. Spawn a sub-agent, keep working in the main session, and get the result steered back when it finishes. Fully non-blocking. + +The backend is selected automatically: Herdr is used when Pi runs in a Herdr-managed pane (`HERDR_ENV=1`), then Orca when Pi runs in an Orca-managed terminal (`ORCA_TERMINAL_HANDLE`), otherwise tmux is used when available. + +## How it works + +`subagent()` returns immediately. In Herdr, the sub-agent runs in a new unfocused tab in the current workspace. In Orca, it runs in a new unfocused terminal tab in the current worktree, started in the parent Pi's working directory. In tmux, it runs in a right split off the parent Pi pane. A live widget above the input tracks every running sub-agent, and when one finishes, its result is steered into the main session as a notification that triggers a new turn. + +``` +Subagents 2 running + ⟳ 00:23 scout active · bash 7m + ○ 00:45 scout-2 waiting 2m +``` + +Spawn several in parallel — they run concurrently and steer results back independently as each finishes. + +Running subagents survive a parent `/reload`. Their terminal sessions and completion watchers stay active, then rebind result delivery and the status widget to the fresh Pi extension runtime. Replacing or closing the parent session still cancels its running subagents. This lifecycle behavior requires Pi 0.68 or newer. + +tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr and Orca subagents use separate tabs, so they do not resize the parent tab. + +If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay: + +```bash +export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, Orca 500, tmux 500 +``` + +## Tools + +| Tool | Description | +| --- | --- | +| `subagent` | Spawn a sub-agent in a dedicated terminal surface (async) | +| `subagent_message` | Message a sub-agent by name — steers it if running, resumes its session if finished | +| `subagents_list` | List available agent definitions | +| `ask_question` | *(sub-agent sessions only)* Ask the orchestrator a question and wait for the reply | + +There is also a `/subagent ` command for spawning directly. + +### Spawning + +```typescript +subagent({ agent: "scout", task: "Analyze the auth module" }); +subagent({ agent: "worker", name: "dark-mode", task: "Implement the dark mode toggle" }); +``` + +| Parameter | Type | Default | Description | +| --------- | ---- | ------- | ----------- | +| `agent` | string | required | Which agent to spawn (must be known and permitted) | +| `task` | string | required | Task prompt | +| `name` | string | agent name | Display name for the pane and widget. Must be unique — duplicates are auto-suffixed (`scout`, `scout-2`, …) | +| `model` | string | agent's model | Override the model for this spawn | +| `cwd` | string | agent's `cwd` | Working directory (see [Role folders](#role-folders)) | + +### Messaging + +`subagent_message` is addressed **by name only**. Names are unique per session and persist after a sub-agent finishes, so the same name works either way: + +```typescript +subagent_message({ name: "scout", message: "Also check the auth middleware" }); +``` + +- **Running** — the message is typed into the live pane (newlines flattened) and picked up at the next turn boundary. The call returns immediately; the eventual completion still arrives as a steer message. +- **Finished** — the session is resumed with the message as the follow-up task, like a fresh spawn: fire-and-forget, always autonomous, result steered back later. The resumed run reclaims its original name. + +Every spawn records name → session file in `artifacts//subagent-registry.json`, so names stay addressable across pi restarts. A nested sub-agent that spawns children gets its own registry keyed by its own session id. Resume is refused with a clear error (listing known names) if the name isn't registered, the session file is gone, or the session predates sandboxed resume. + +**Resume replays the original sandbox.** At spawn time the fully-resolved loadout — tool allowlist, backing extensions, model, thinking level, system prompt, spawn whitelist, cwd — is snapshotted to `.loadout.json`. Resume rebuilds the exact same restricted process from that snapshot rather than relaunching unrestricted. + +### ask_question + +A sub-agent can ask its orchestrator a single freeform question when requirements are ambiguous or a decision materially affects the work. The session **stays open** (parked as `waiting`) instead of exiting; the parent is notified with the sub-agent's name, replies via `subagent_message({ name, message })`, and the reply arrives as the sub-agent's next turn. Parallel questions are supported — each waiting sub-agent has its own name. + +If the reply arrives while the sub-agent is still mid-turn, it is absorbed into the current turn — either way the question is marked answered and the session exits normally when the work is done. If the parent never replies, the pane stays open until a human closes it. Only available inside sub-agent sessions. + +## Bundled agents + +| Agent | Model | Tools | Role | +| ----- | ----- | ----- | ---- | +| **scout** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `grep`, `find`, `ls` | Fast read-only codebase recon | +| **researcher** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `web_search`, `web_fetch`, `safe_bash` | Web research, synthesized into a sourced brief | +| **worker** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `write`, `edit`, `bash`, `web_search`, `web_fetch` + spawning | General implementer; may spawn `scout` and `researcher` | + +All three are autonomous (`auto-exit: true`) and carry their identity in the system prompt (`system-prompt: append`). + +## Custom agents + +Place a `.md` file in `.pi/agents/` (project) or `~/.pi/agent/agents/` (global). Discovery priority: **project > global > package-bundled** — a project-local file overrides a bundled agent with the same name. + +```markdown +--- +name: my-agent +description: Does something specific +model: openai-codex/gpt-5.6-luna +thinking: xhigh +tools: read, edit, write, safe_bash, web_search +session-mode: lineage-only +auto-exit: true +--- + +You are a specialized agent that does X... +``` + +### Frontmatter reference + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `name` | string | Agent name (used in `agent: "my-agent"`) | +| `description` | string | Shown in `subagents_list` | +| `model` | string | Default model | +| `thinking` | string | `minimal`, `low`, `medium`, or `high` | +| `tools` | string | Strict tool allowlist. Built-ins: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`. Extension-backed: `web_search`, `web_fetch`, `safe_bash`, `video_extract`, `youtube_search`, `google_image_search`. Only the extensions backing the listed tools are loaded into the child | +| `subagent_agents` | string | Comma-separated agent names this agent may spawn. **Presence of this field grants the spawning toolset** (`subagent`, `subagent_message`, `subagents_list`) and restricts spawn targets to the list. Omit it and the agent cannot spawn at all | +| `skills` | string | Comma-separated skill names to auto-load | +| `session-mode` | string | `standalone` (default), `lineage-only`, or `fork` — see below | +| `system-prompt` | string | `append` or `replace`: pass the body as the child's `--append-system-prompt` / `--system-prompt`. Omit and the body is prepended to the task prompt instead | +| `auto-exit` | boolean | Auto-shutdown when the agent finishes (see below) | +| `interactive` | boolean | Whether stall/recovery transitions wake the parent (see below) | +| `cwd` | string | Default working directory | +| `disable-model-invocation` | boolean | Hide from `subagents_list`; still spawnable by explicit name | +| `cli` | string | `claude` runs the agent via the Claude Code CLI instead of pi | + +### session-mode + +- `standalone` — fresh session, no lineage link to the caller (default) +- `lineage-only` — fresh session with `parentSession` linkage for discovery/fork UX, but no copied turns +- `fork` — child session seeded with the caller's conversation context + +### auto-exit + +With `auto-exit: true`, the session shuts down when the agent's turn ends — the agent just writes its final message and stops (there is no "done" tool). The last assistant message becomes the summary returned to the parent. Recommended for all autonomous agents. + +Notes: + +- **Manual input does not strand an auto-exit sub-agent.** If a human types into the pane, the session still closes once that turn completes normally — only an escape/abort leaves it open. +- **Auto-exit is suppressed while work is in flight:** the session parks as `waiting` instead of exiting when an `ask_question` is still unanswered, or when the agent's own child sub-agents are still running (a worker can stop after dispatching children and stays open until the last result returns). + +### interactive + +Controls whether `stalled`/`recovered` status transitions send a steer message to the parent session. Defaults to the inverse of `auto-exit`: autonomous agents get stall pings; user-driven agents stay quiet (the user is already working in that pane — the widget still updates). Set explicitly to override. + +## Tool access control + +Access is **whitelist-only**. Every sub-agent process is launched with `--no-extensions` (extension discovery disabled) and `--tools `; only the extensions backing the listed tools are loaded back in explicitly. There is no default toolset and no deny-list — an agent gets exactly what its frontmatter lists. The restriction survives resume via the loadout snapshot. + +Spawns must name a known agent at **every** depth. A top-level session may spawn anything discoverable; a sub-agent may only spawn the agents in its `subagent_agents` list (enforced via `PI_SUBAGENT_ALLOWED`). There is no agentless spawn route, so a child can never escalate to a full-toolset profile by omitting its agent. + +Extensions can register additional tools for sub-agents at runtime via `registerToolExtension(name, path)` on the `__pi_interactive_subagents` process global. Hosts that compose widgets can optionally use its reload-safe `statusWidget` surface: + +```typescript +const { statusWidget } = globalThis.__pi_interactive_subagents; +const onChange = () => rerender(); +const release = statusWidget.claim(onChange); + +statusWidget.hasContent(); // whether any subagent rows exist +statusWidget.getBodyRowCount(); // uncapped number of running agents +statusWidget.render(width, maxBodyRows, ctx.ui.theme); // compact unboxed lines; cap includes an overflow row +statusWidget.isClaimed(onChange); // true only for the current provider claim +release(); // restore the standalone subagent-status widget +``` + +`render(width, maxBodyRows?, theme?)` accepts Pi's current theme as an optional structural value. The standalone widget receives and uses its own Pi theme automatically. When no theme is passed, a legacy ANSI fallback keeps status colors usable. The header is a compact unboxed `Subagents running` row; body rows remain width-safe, and `maxBodyRows` counts body rows including the overflow summary, not the header. Semantic row colors match Pi's Todo conventions: accent header, muted count, warning active/running, error stalled, dim starting/provider tags/elapsed time, and accent waiting. + +`claim()` replaces any prior claim, and an old release function cannot release a newer claim. Standalone-widget clearing/restoration is deferred to a microtask, so hosts may release and immediately re-claim without a transient fallback widget. `isClaimed(onChange)` checks both callback identity and the current runtime ownership, so hosts can detect invalidation after reload/session rebind. Claims are cleared during session shutdown, including `/reload`, so a fresh host runtime must claim again rather than retaining a stale callback. Calling `render()` directly does not claim or change the standalone widget. + +## Role folders + +`cwd` starts a sub-agent in a directory with its own config, so role-specific setups (CLAUDE.md, skills, extensions) apply: + +``` +project/ +└── agents/ + ├── game-designer/ ← CLAUDE.md, .pi/… + └── sre/ ← CLAUDE.md, .pi/… +``` + +```typescript +subagent({ agent: "worker", cwd: "agents/sre", task: "Review the deployment pipeline" }); +``` + +Set a per-agent default with `cwd:` in frontmatter. + +## Status widget & configuration + +The widget tracks each sub-agent from a runtime activity snapshot written by the child: `starting`, `active` (turn/provider/tool work), `waiting` (open for input or another stage), `stalled` (no valid snapshot for too long), or `running` (fallback). Sub-agent sessions also show their own tools widget — toggle it with `Ctrl+Alt+O`. Completion messages expand with `Ctrl+O`. + +Status display is configured via `config.json` in the extension directory (copy `config.json.example`; it's gitignored): + +```json +{ + "status": { "enabled": true } +} +``` + +## Install + +```bash +pi install npm:@_jb/pi-interactive-subagents +``` + +## Requirements + +- [Pi](https://github.com/earendil-works/pi-mono) +- One supported terminal manager: + - [Herdr](https://herdr.dev), with Pi launched inside Herdr + - [Orca](https://orca.build), with Pi launched inside an Orca-managed terminal + - [tmux](https://github.com/tmux/tmux) + +Herdr requires no extension configuration once its Pi integration is installed. The extension detects `HERDR_ENV=1` and creates subagents as unfocused tabs in the current workspace. + +Orca requires no extension configuration either. The extension detects `ORCA_TERMINAL_HANDLE` and creates subagents as unfocused terminal tabs in the current worktree via the `orca` CLI (`ORCA_CLI_COMMAND` is honored when set). New tabs start in the parent Pi's working directory with a bash shell. + +For tmux: + +```bash +tmux new -A -s pi 'pi' +``` + +## Acknowledgements + +Forked from [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents), which originated the subagent architecture, the multi-multiplexer surface layer, and the status widget; its supervision features were inspired by [RepoPrompt](https://repoprompt.com/). + +## License + +MIT diff --git a/extensions/interactive-subagents/agents/researcher.md b/extensions/interactive-subagents/agents/researcher.md new file mode 100644 index 0000000..81cb307 --- /dev/null +++ b/extensions/interactive-subagents/agents/researcher.md @@ -0,0 +1,51 @@ +--- +name: researcher +description: Web researcher — searches the web and synthesizes findings +tools: safe_bash +model: openai-codex/gpt-5.6-luna +thinking: xhigh +system-prompt: append +auto-exit: true +--- + +You are a research specialist. Given a question or topic, conduct thorough web research and produce a focused, well-sourced brief. + +You operate in an isolated context with no knowledge of any prior conversation. All necessary context is in the task description. + +Process: +1. Break the question into 2-4 searchable facets +2. Search with the web using varied angles +3. Read the answers. Identify what's well-covered, what has gaps. +4. For the 2-3 most promising source URLs, use firecrawl to get full page content +5. Synthesize everything into a brief that directly answers the question + +Search strategy — always vary your angles: +- Direct answer query (the obvious one) +- Authoritative source query (official docs, specs, primary sources) +- Practical experience query (case studies, benchmarks, real-world usage) +- Recent developments query (only if the topic is time-sensitive) + +Evaluation — what to keep vs drop: +- Official docs and primary sources outweigh blog posts and forum threads +- Recent sources outweigh stale ones +- Sources that directly address the question outweigh tangentially related ones +- Drop: SEO filler, outdated info, beginner tutorials (unless that's the audience) + +If the first round of searches doesn't fully answer the question, search again with refined queries targeting the gaps. + +Your FINAL assistant message is your entire deliverable — it must stand alone, using this format: + +## Summary +2-3 sentence direct answer. + +## Findings +Numbered findings with inline source citations: +1. **Finding** — explanation. [Source](url) +2. **Finding** — explanation. [Source](url) + +## Sources +- Kept: Source Title (url) — why relevant +- Dropped: Source Title — why excluded + +## Gaps +What couldn't be answered. Suggested next steps. diff --git a/extensions/interactive-subagents/agents/scout.md b/extensions/interactive-subagents/agents/scout.md new file mode 100644 index 0000000..5069909 --- /dev/null +++ b/extensions/interactive-subagents/agents/scout.md @@ -0,0 +1,40 @@ +--- +name: scout +description: Fast codebase recon — explores files, finds patterns, maps architecture +tools: read, grep, find, ls +model: openai-codex/gpt-5.6-luna +thinking: xhigh +system-prompt: append +auto-exit: true +--- + +You are a scout agent. Quickly investigate a codebase and return structured findings. + +You operate in an isolated context with no knowledge of any prior conversation. All necessary context is in the task description. You are read-only: never build, test, or modify anything. + +Thoroughness (infer from task, default medium): +- Quick: Targeted lookups, key files only +- Medium: Follow imports, read critical sections +- Thorough: Trace all dependencies, check tests/types + +Strategy: +1. grep/find to locate relevant code +2. Read key sections (not entire files) +3. Identify types, interfaces, key functions +4. Note dependencies between files + +Your FINAL assistant message is your entire deliverable — it must stand alone, using this format: + +## Files Found +List with exact line ranges: +1. `path/to/file.ts` (lines 10-50) — Description +2. `path/to/other.ts` (lines 100-150) — Description + +## Key Code +Critical types, interfaces, or functions with actual code snippets. + +## Architecture +Brief explanation of how the pieces connect. + +## Start Here +Which file to look at first and why. diff --git a/extensions/interactive-subagents/agents/worker.md b/extensions/interactive-subagents/agents/worker.md new file mode 100644 index 0000000..0066911 --- /dev/null +++ b/extensions/interactive-subagents/agents/worker.md @@ -0,0 +1,79 @@ +--- +name: worker +description: General-purpose worker — reads, writes, and edits code +tools: read, write, edit, bash +subagent_agents: scout, researcher +model: openai-codex/gpt-5.6-luna +thinking: xhigh +system-prompt: append +auto-exit: true +--- + +You are a worker agent. You operate in an isolated context — you have no knowledge of any prior conversation. All necessary context will be provided in the task description. + +You run in your own pane and work autonomously to complete the assigned task. When you are finished, simply write your final summary message and stop — your session ends automatically and your results are returned to the orchestrator. Do not announce that you are finishing; just produce the answer. If you get stuck, hit ambiguous requirements, or need a decision only the orchestrator can make, call `ask_question` with a single freeform question instead of guessing. Your session stays open while you wait, and the orchestrator's reply arrives as your next message. + +Guidelines: +- Read files before editing to understand existing code +- Make targeted edits, not wholesale rewrites +- Use `bash` for running commands (tests, builds, installs, etc.) +- If something fails, diagnose and fix it +- Your FINAL assistant message should summarize what you did and what changed + +## Delegation — protecting your context window + +Your context is finite. Reading large or unfamiliar codebases directly will burn it before you can edit anything. You have a `subagent` tool that spawns disposable child agents whose context is separate from yours — you only receive their summary. Use it. + +You can dispatch: +- **scout** — read-only recon (read, grep, find, ls). Returns a structured map of files, line ranges, and key snippets. Cheap (haiku). Use for *exploring unfamiliar territory*. +- **researcher** — web research. Returns a sourced brief. Use for *external knowledge* (library docs, error messages, API references). + +You may only dispatch `scout` and `researcher` — no other agents are available to you. + +**Always select the agent with the `agent` field**, e.g. `subagent({ agent: "scout", name: "recon", task: "…" })`. The `name` field is only a cosmetic pane label — it does NOT pick the agent. If you put "scout" in `name` and leave `agent` empty, the spawn is rejected (you're restricted to named agents). + +### When to dispatch a scout vs. read directly + +Dispatch a scout when: +- The task brief names a feature/area but not specific files ("fix the auth flow", "add a field to user settings") +- You'd need to grep + read 5+ files just to orient +- You only need to know *where* something lives or *what shape* it has, not its full source + +Read directly when: +- The brief gives you explicit file paths +- You already know the file you need to edit +- You need the exact bytes for an `edit` call (scouts return summaries, not verbatim source — re-read the 1–3 files you actually edit) + +A good rhythm: **scout to find, read to edit.** One scout dispatch up front often replaces a dozen grep/read calls and pays for itself many times over. + +### When to dispatch a researcher vs. web_fetch directly + +Dispatch a researcher when: +- The question is open-ended ("what's the idiomatic way to X in library Y") +- You'd need to search + read 3+ pages to triangulate +- You want sources synthesized, not raw HTML in your context + +Fetch directly when: +- You already have the exact URL (a known docs page, a GitHub issue) +- You need a single specific piece of information from one page + +### Parallelism + +If you need two independent investigations (e.g. "map the auth code" AND "look up the library's session API"), emit multiple `subagent` tool calls in the same turn — they run in parallel automatically. Don't serialize independent work. After spawning, the results arrive as steer messages — don't poll or fabricate them. + +After dispatching subagents you can just say what you're waiting for and stop the turn — your session will **not** close while children are still running. It stays open until every child has reported back, then wakes you with each result. Don't spin in a loop trying to "check" on them. + +### What a subagent doesn't replace + +Subagents can't edit files for you. You still do the `edit`/`write` calls yourself, with the focused context the scouts gave you. Treat them as a context-protecting prefetch, not a substitute for thinking. + +## Output format when done + +## Changes Made +- `path/to/file.ts` — what changed and why + +## Verification +How you verified the changes work (tests run, build succeeded, etc.) + +## Notes +Any caveats, follow-up items, or decisions made. diff --git a/extensions/interactive-subagents/config.json.example b/extensions/interactive-subagents/config.json.example new file mode 100644 index 0000000..9c8db04 --- /dev/null +++ b/extensions/interactive-subagents/config.json.example @@ -0,0 +1,5 @@ +{ + "status": { + "enabled": true + } +} diff --git a/extensions/interactive-subagents/docs/adr/0001-lease-the-status-widget-to-compositors.md b/extensions/interactive-subagents/docs/adr/0001-lease-the-status-widget-to-compositors.md new file mode 100644 index 0000000..c198231 --- /dev/null +++ b/extensions/interactive-subagents/docs/adr/0001-lease-the-status-widget-to-compositors.md @@ -0,0 +1,5 @@ +# Lease the status widget to optional compositors + +Subagents exposes its running-status panel through a structural, process-local interface while retaining ownership of the standalone widget. A host claims a callback-identified lease and must verify it before rendering; runtime replacement invalidates the lease and stale releases cannot affect a newer owner. This avoids a package dependency or shared TUI class identity while preserving running subagents across reload and restoring the standalone panel whenever no valid host owns it. + +The composable surface is `statusWidget.render(width, maxBodyRows?, theme?)`. The optional third argument accepts the small structural `{ fg(color, text) }` theme contract, allowing hosted output to use the current Pi theme without importing a shared Theme class. Standalone rendering passes its factory-provided Pi theme. Calls without a theme use a legacy ANSI fallback. Both hosted and standalone output use a compact unboxed `Subagents running` header followed by capped, width-safe body rows; the cap includes an overflow row but not the header. Status colors remain semantic: active/running warning, stalled error, starting dim, waiting accent, with provider tags and elapsed times dimmed. diff --git a/extensions/interactive-subagents/package.json b/extensions/interactive-subagents/package.json new file mode 100644 index 0000000..12ef4ee --- /dev/null +++ b/extensions/interactive-subagents/package.json @@ -0,0 +1,61 @@ +{ + "name": "@_jb/pi-interactive-subagents", + "version": "3.7.2", + "description": "Interactive subagents for Pi — spawn, orchestrate, and manage sub-agent sessions in Orca, Herdr or tmux.", + "type": "module", + "license": "MIT", + "author": "JB", + "homepage": "https://git.jb.codes/jb/harness-tools/src/branch/main/extensions/interactive-subagents", + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/interactive-subagents" + }, + "bugs": { + "url": "https://git.jb.codes/jb/harness-tools/issues" + }, + "engines": { + "node": ">=22.19.0" + }, + "files": [ + "pi-extension/**/*", + "agents/**/*", + "config.json.example", + "README.md", + "LICENSE" + ], + "keywords": [ + "pi-package", + "pi-extension", + "subagents", + "herdr", + "tmux" + ], + "pi": { + "extensions": [ + "./pi-extension/subagents/index.ts" + ] + }, + "scripts": { + "test": "node --test test/test.ts test/system-prompt-mode.test.ts", + "test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts", + "typecheck": "tsc --noEmit", + "prepublishOnly": "bun run typecheck && bun run test" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + "typebox": "catalog:pi-peer" + }, + "devDependencies": { + "@earendil-works/pi-coding-agent": "catalog:", + "@earendil-works/pi-tui": "catalog:", + "@types/node": "catalog:", + "typebox": "catalog:", + "typescript": "catalog:" + }, + "publishConfig": { + "access": "public", + "registry": "https://git.jb.codes/api/packages/jb/npm/" + } +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/activity.ts b/extensions/interactive-subagents/pi-extension/subagents/activity.ts new file mode 100644 index 0000000..d480ed6 --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/activity.ts @@ -0,0 +1,513 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export type SubagentActivityPhase = "starting" | "active" | "waiting" | "done"; +export type SubagentActivityScope = "agent" | "turn" | "provider" | "streaming" | "tool"; + +export type SubagentActivityEvent = + | "session_start" + | "input" + | "before_agent_start" + | "agent_start" + | "agent_end" + | "turn_start" + | "turn_end" + | "before_provider_request" + | "after_provider_response" + | "message_update" + | "tool_execution_start" + | "tool_call" + | "tool_execution_update" + | "tool_result" + | "tool_execution_end" + | "ask_question" + | "session_shutdown"; + +export interface SubagentActivityState { + version: 1; + runningChildId: string; + createdAt: number; + updatedAt: number; + sequence: number; + latestEvent: SubagentActivityEvent; + phase: SubagentActivityPhase; + agentActive: boolean; + turnActive: boolean; + providerActive: boolean; + toolActive: boolean; + activeScope?: SubagentActivityScope; + activeSince?: number; + waitingSince?: number; + turnIndex?: number; + messageEventType?: string; + toolCallId?: string; + toolName?: string; + toolStartedAt?: number; + toolEndedAt?: number; +} + +export type ActivityReadResult = + | { ok: true; activity: SubagentActivityState } + | { ok: false; reason: "missing" | "invalid" | "wrong-id"; error?: string }; + +export type SubagentShutdownReason = "quit" | "reload" | "new" | "resume" | "fork"; + +export interface SubagentActivityRecorder { + sessionStart(): void; + input(): void; + beforeAgentStart(): void; + agentStart(): void; + agentEndWaiting(): void; + agentEndDone(): void; + turnStart(turnIndex?: number): void; + turnEnd(turnIndex?: number): void; + beforeProviderRequest(): void; + afterProviderResponse(): void; + messageUpdate(messageEventType?: string): void; + toolExecutionStart(toolCallId?: string, toolName?: string): void; + toolCall(toolCallId?: string, toolName?: string): void; + toolExecutionUpdate(toolCallId?: string, toolName?: string): void; + toolResult(toolCallId?: string, toolName?: string): void; + toolExecutionEnd(toolCallId?: string, toolName?: string): void; + askQuestion(): void; + sessionShutdown(reason: SubagentShutdownReason): void; +} + +const ACTIVITY_UPDATE_THROTTLE_MS = 500; +const MAX_WRITE_FAILURES = 3; +const KNOWN_PHASES = new Set(["starting", "active", "waiting", "done"]); +const KNOWN_SCOPES = new Set(["agent", "turn", "provider", "streaming", "tool"]); +const KNOWN_EVENTS = new Set([ + "session_start", + "input", + "before_agent_start", + "agent_start", + "agent_end", + "turn_start", + "turn_end", + "before_provider_request", + "after_provider_response", + "message_update", + "tool_execution_start", + "tool_call", + "tool_execution_update", + "tool_result", + "tool_execution_end", + "ask_question", + "session_shutdown", +]); +const MAX_ACTIVITY_STRING_LENGTH = 200; + +export function getSubagentActivityFile(artifactDir: string, runningChildId: string): string { + return join(artifactDir, "subagent-activity", `${runningChildId}.json`); +} + +function requireObject(value: unknown): Record | null { + if (value == null || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function validateFiniteNumber(object: Record, fieldName: string): string | null { + return Number.isFinite(object[fieldName]) ? null : `${fieldName} must be finite`; +} + +function validateOptionalFiniteNumber(object: Record, fieldName: string): string | null { + const value = object[fieldName]; + return value == null || Number.isFinite(value) ? null : `${fieldName} must be finite when present`; +} + +function validateInteger(object: Record, fieldName: string): string | null { + return Number.isInteger(object[fieldName]) ? null : `${fieldName} must be an integer`; +} + +function validateOptionalInteger(object: Record, fieldName: string): string | null { + const value = object[fieldName]; + return value == null || Number.isInteger(value) ? null : `${fieldName} must be an integer when present`; +} + +function validateBoolean(object: Record, fieldName: string): string | null { + return typeof object[fieldName] === "boolean" ? null : `${fieldName} must be a boolean`; +} + +function validateOptionalActivityString(object: Record, fieldName: string): string | null { + const value = object[fieldName]; + if (value == null) return null; + if (typeof value !== "string") return `${fieldName} must be a string when present`; + if (/\r|\n/.test(value)) return `${fieldName} must not contain newlines`; + return value.length <= MAX_ACTIVITY_STRING_LENGTH ? null : `${fieldName} is too long`; +} + +function invalidActivity(error: string): ActivityReadResult { + return { ok: false, reason: "invalid", error }; +} + +function validateActivity(value: unknown, expectedRunningChildId: string): ActivityReadResult { + const object = requireObject(value); + if (!object) return invalidActivity("activity must be an object"); + if (object.version !== 1) return invalidActivity("unsupported activity version"); + if (typeof object.runningChildId !== "string") return invalidActivity("runningChildId must be a string"); + if (object.runningChildId !== expectedRunningChildId) return { ok: false, reason: "wrong-id" }; + if (typeof object.latestEvent !== "string" || !KNOWN_EVENTS.has(object.latestEvent as SubagentActivityEvent)) { + return invalidActivity("unknown latestEvent"); + } + if (typeof object.phase !== "string" || !KNOWN_PHASES.has(object.phase as SubagentActivityPhase)) { + return invalidActivity("unknown activity phase"); + } + if ( + object.activeScope != null && + (typeof object.activeScope !== "string" || !KNOWN_SCOPES.has(object.activeScope as SubagentActivityScope)) + ) { + return invalidActivity("unknown activeScope"); + } + + const validationError = [ + validateFiniteNumber(object, "createdAt"), + validateFiniteNumber(object, "updatedAt"), + validateInteger(object, "sequence"), + validateBoolean(object, "agentActive"), + validateBoolean(object, "turnActive"), + validateBoolean(object, "providerActive"), + validateBoolean(object, "toolActive"), + validateOptionalFiniteNumber(object, "activeSince"), + validateOptionalFiniteNumber(object, "waitingSince"), + validateOptionalInteger(object, "turnIndex"), + validateOptionalFiniteNumber(object, "toolStartedAt"), + validateOptionalFiniteNumber(object, "toolEndedAt"), + validateOptionalActivityString(object, "messageEventType"), + validateOptionalActivityString(object, "toolCallId"), + validateOptionalActivityString(object, "toolName"), + ].find((error) => error != null); + if (validationError) return invalidActivity(validationError); + + return { ok: true, activity: object as unknown as SubagentActivityState }; +} + +export function readSubagentActivityFile( + activityFile: string, + expectedRunningChildId: string, +): ActivityReadResult { + if (!existsSync(activityFile)) return { ok: false, reason: "missing" }; + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(activityFile, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, reason: "invalid", error: message }; + } + + return validateActivity(parsed, expectedRunningChildId); +} + +export function writeSubagentActivityFile(activityFile: string, activity: SubagentActivityState): void { + const dir = dirname(activityFile); + mkdirSync(dir, { recursive: true }); + const tempFile = join(dir, `${activity.runningChildId}.json.${process.pid}.${activity.sequence}.tmp`); + + try { + writeFileSync(tempFile, `${JSON.stringify(activity)}\n`, "utf8"); + renameSync(tempFile, activityFile); + } catch (error) { + try { + unlinkSync(tempFile); + } catch (cleanupError) { + // Temp cleanup is best effort; preserve the original write/rename failure + void cleanupError; + } + throw error; + } +} + +function createNoopRecorder(): SubagentActivityRecorder { + return { + sessionStart() {}, + input() {}, + beforeAgentStart() {}, + agentStart() {}, + agentEndWaiting() {}, + agentEndDone() {}, + turnStart() {}, + turnEnd() {}, + beforeProviderRequest() {}, + afterProviderResponse() {}, + messageUpdate() {}, + toolExecutionStart() {}, + toolCall() {}, + toolExecutionUpdate() {}, + toolResult() {}, + toolExecutionEnd() {}, + askQuestion() {}, + sessionShutdown() {}, + }; +} + +function clearActiveState(activity: SubagentActivityState): void { + activity.agentActive = false; + activity.turnActive = false; + activity.providerActive = false; + activity.toolActive = false; + delete activity.activeScope; + delete activity.activeSince; +} + +function refreshActiveScope(activity: SubagentActivityState): void { + if (activity.toolActive) { + activity.phase = "active"; + activity.activeScope = "tool"; + return; + } + if (activity.providerActive) { + activity.phase = "active"; + activity.activeScope = "provider"; + return; + } + if (activity.turnActive) { + activity.phase = "active"; + activity.activeScope = "turn"; + return; + } + if (activity.agentActive) { + activity.phase = "active"; + activity.activeScope = "agent"; + return; + } + delete activity.activeScope; + delete activity.activeSince; +} + +function markActive( + activity: SubagentActivityState, + scope: SubagentActivityScope, + now: number, + resetActiveSince = false, +): void { + activity.phase = "active"; + activity.activeScope = scope; + if (activity.activeSince == null || resetActiveSince) activity.activeSince = now; + delete activity.waitingSince; +} + +export function createSubagentActivityRecorder(params: { + runningChildId?: string; + activityFile?: string; + now?: () => number; +}): SubagentActivityRecorder { + const candidateRunningChildId = params.runningChildId?.trim(); + const candidateActivityFile = params.activityFile?.trim(); + if (!candidateRunningChildId || !candidateActivityFile) return createNoopRecorder(); + const runningChildId: string = candidateRunningChildId; + const activityFile: string = candidateActivityFile; + + const now = params.now ?? (() => Date.now()); + const createdAt = now(); + const activity: SubagentActivityState = { + version: 1, + runningChildId, + createdAt, + updatedAt: createdAt, + sequence: 0, + latestEvent: "session_start", + phase: "starting", + agentActive: false, + turnActive: false, + providerActive: false, + toolActive: false, + }; + + let disabled = false; + let failureCount = 0; + let lastFlushAt = 0; + let pendingFlush: ReturnType | null = null; + + function clearPendingFlush(): void { + if (!pendingFlush) return; + clearTimeout(pendingFlush); + pendingFlush = null; + } + + function disable(): void { + disabled = true; + clearPendingFlush(); + } + + function flushNow(): void { + if (disabled) return; + try { + writeSubagentActivityFile(activityFile, activity); + lastFlushAt = now(); + failureCount = 0; + } catch { + failureCount += 1; + if (failureCount >= MAX_WRITE_FAILURES) disable(); + } + } + + function scheduleFlush(): void { + if (disabled || pendingFlush) return; + + const remainingMs = Math.max(0, ACTIVITY_UPDATE_THROTTLE_MS - (now() - lastFlushAt)); + if (remainingMs === 0) { + flushNow(); + return; + } + + pendingFlush = setTimeout(() => { + pendingFlush = null; + flushNow(); + }, remainingMs); + } + + function record( + latestEvent: SubagentActivityEvent, + update: (current: SubagentActivityState, now: number) => void, + flush: "immediate" | "throttled", + ): void { + if (disabled) return; + if (flush === "immediate") clearPendingFlush(); + + const observedAt = now(); + activity.latestEvent = latestEvent; + activity.updatedAt = observedAt; + activity.sequence += 1; + update(activity, observedAt); + + if (flush === "immediate") flushNow(); + else scheduleFlush(); + } + + function markDone(latestEvent: SubagentActivityEvent): void { + record(latestEvent, (current) => { + current.phase = "done"; + clearActiveState(current); + delete current.waitingSince; + }, "immediate"); + disable(); + } + + return { + sessionStart() { + record("session_start", (current) => { + current.phase = "starting"; + clearActiveState(current); + delete current.waitingSince; + }, "immediate"); + }, + input() { + record("input", () => {}, "immediate"); + }, + beforeAgentStart() { + record("before_agent_start", (current, observedAt) => { + current.agentActive = true; + markActive(current, "agent", observedAt); + }, "immediate"); + }, + agentStart() { + record("agent_start", (current, observedAt) => { + current.agentActive = true; + markActive(current, "agent", observedAt); + }, "immediate"); + }, + agentEndWaiting() { + record("agent_end", (current, observedAt) => { + clearActiveState(current); + current.phase = "waiting"; + current.waitingSince = observedAt; + }, "immediate"); + }, + agentEndDone() { + markDone("agent_end"); + }, + turnStart(turnIndex) { + record("turn_start", (current, observedAt) => { + current.agentActive = true; + current.turnActive = true; + if (turnIndex != null) current.turnIndex = turnIndex; + markActive(current, current.toolActive || current.providerActive ? current.activeScope ?? "turn" : "turn", observedAt); + }, "immediate"); + }, + turnEnd(turnIndex) { + record("turn_end", (current) => { + current.turnActive = false; + current.providerActive = false; + current.toolActive = false; + if (turnIndex != null) current.turnIndex = turnIndex; + refreshActiveScope(current); + }, "immediate"); + }, + beforeProviderRequest() { + record("before_provider_request", (current, observedAt) => { + current.providerActive = true; + markActive(current, "provider", observedAt, true); + }, "immediate"); + }, + afterProviderResponse() { + record("after_provider_response", (current) => { + current.providerActive = false; + refreshActiveScope(current); + }, "immediate"); + }, + messageUpdate(messageEventType) { + record("message_update", (current, observedAt) => { + current.agentActive = true; + current.turnActive = true; + current.messageEventType = messageEventType; + if (!current.toolActive) markActive(current, "streaming", observedAt); + }, "throttled"); + }, + toolExecutionStart(toolCallId, toolName) { + record("tool_execution_start", (current, observedAt) => { + current.toolActive = true; + current.toolCallId = toolCallId; + current.toolName = toolName; + current.toolStartedAt = observedAt; + markActive(current, "tool", observedAt, true); + }, "immediate"); + }, + toolCall(toolCallId, toolName) { + record("tool_call", (current, observedAt) => { + current.toolActive = true; + current.toolCallId = toolCallId ?? current.toolCallId; + current.toolName = toolName ?? current.toolName; + markActive(current, "tool", observedAt); + }, "immediate"); + }, + toolExecutionUpdate(toolCallId, toolName) { + record("tool_execution_update", (current, observedAt) => { + current.toolActive = true; + current.toolCallId = toolCallId ?? current.toolCallId; + current.toolName = toolName ?? current.toolName; + markActive(current, "tool", observedAt); + }, "throttled"); + }, + toolResult(toolCallId, toolName) { + record("tool_result", (current) => { + current.toolCallId = toolCallId ?? current.toolCallId; + current.toolName = toolName ?? current.toolName; + refreshActiveScope(current); + }, "immediate"); + }, + toolExecutionEnd(toolCallId, toolName) { + record("tool_execution_end", (current, observedAt) => { + current.toolActive = false; + current.toolCallId = toolCallId ?? current.toolCallId; + current.toolName = toolName ?? current.toolName; + current.toolEndedAt = observedAt; + refreshActiveScope(current); + }, "immediate"); + }, + askQuestion() { + // The subagent paused to ask the orchestrator a question. Park it in the + // "waiting" phase (do NOT disable the recorder) so the status widget shows + // it as waiting and recording resumes when the answer arrives. + record("ask_question", (current, observedAt) => { + clearActiveState(current); + current.phase = "waiting"; + current.waitingSince = observedAt; + }, "immediate"); + }, + sessionShutdown(reason) { + if (reason === "quit") markDone("session_shutdown"); + else disable(); + }, + }; +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/herdr.ts b/extensions/interactive-subagents/pi-extension/subagents/herdr.ts new file mode 100644 index 0000000..8f2da59 --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/herdr.ts @@ -0,0 +1,178 @@ +/** Herdr surface backend. Subagents run in unfocused tabs in the current workspace. */ +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const commandAvailability = new Map(); +const tabByPane = new Map(); + +interface HerdrTabCreated { + result?: { + root_pane?: { pane_id?: unknown }; + tab?: { tab_id?: unknown }; + }; +} + +interface HerdrPaneCreated { + result?: { + pane?: { pane_id?: unknown }; + }; +} + +function hasCommand(command: string): boolean { + const cached = commandAvailability.get(command); + if (cached !== undefined) return cached; + + let available = false; + try { + execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" }); + available = true; + } catch { + available = false; + } + + commandAvailability.set(command, available); + return available; +} + +export function isAvailable(): boolean { + return ( + process.env.HERDR_ENV === "1" && + !!process.env.HERDR_WORKSPACE_ID && + !!process.env.HERDR_PANE_ID && + hasCommand("herdr") + ); +} + +export function setupHint(): string { + return "Start pi inside a Herdr-managed pane with the `herdr` CLI on PATH."; +} + +function requireHerdr(): void { + if (!isAvailable()) { + throw new Error(`Herdr is required for this surface. ${setupHint()}`); + } +} + +function parseJson(command: string, output: string): T { + try { + return JSON.parse(output) as T; + } catch { + throw new Error(`Unexpected Herdr ${command} output: ${output.trim() || "(empty)"}`); + } +} + +function requireId(value: unknown, command: string, field: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Herdr ${command} did not return ${field}`); + } + return value; +} + +export function parseCreatedTab(output: string): { paneId: string; tabId: string } { + const response = parseJson("tab create", output); + return { + paneId: requireId(response.result?.root_pane?.pane_id, "tab create", "result.root_pane.pane_id"), + tabId: requireId(response.result?.tab?.tab_id, "tab create", "result.tab.tab_id"), + }; +} + +export function parseCreatedPane(output: string): string { + const response = parseJson("pane split", output); + return requireId(response.result?.pane?.pane_id, "pane split", "result.pane.pane_id"); +} + +export function createSurface(name: string): string { + requireHerdr(); + const workspace = process.env.HERDR_WORKSPACE_ID!; + const output = execFileSync( + "herdr", + [ + "tab", + "create", + "--workspace", + workspace, + "--cwd", + process.cwd(), + "--label", + name, + "--no-focus", + ], + { encoding: "utf8" }, + ); + const { paneId, tabId } = parseCreatedTab(output); + tabByPane.set(paneId, tabId); + return paneId; +} + +export function createSurfaceSplit( + name: string, + direction: "left" | "right" | "up" | "down", + fromSurface?: string, +): string { + requireHerdr(); + if (direction === "left" || direction === "up") { + throw new Error(`Herdr pane splits only support right or down, not ${direction}`); + } + + const source = fromSurface ?? process.env.HERDR_PANE_ID!; + const output = execFileSync( + "herdr", + [ + "pane", + "split", + "--pane", + source, + "--direction", + direction, + "--cwd", + process.cwd(), + "--no-focus", + ], + { encoding: "utf8" }, + ); + const paneId = parseCreatedPane(output); + + try { + execFileSync("herdr", ["pane", "rename", paneId, name], { encoding: "utf8" }); + } catch { + // A cosmetic label must not break a successful split. + } + + return paneId; +} + +export function sendCommand(surface: string, command: string): void { + requireHerdr(); + execFileSync("herdr", ["pane", "run", surface, command], { encoding: "utf8" }); +} + +export function readScreen(surface: string, lines = 50): string { + requireHerdr(); + return execFileSync( + "herdr", + ["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))], + { encoding: "utf8" }, + ); +} + +export async function readScreenAsync(surface: string, lines = 50): Promise { + requireHerdr(); + const { stdout } = await execFileAsync( + "herdr", + ["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))], + { encoding: "utf8" }, + ); + return stdout; +} + +export function closeSurface(surface: string): void { + requireHerdr(); + const tabId = tabByPane.get(surface); + if (tabId) { + execFileSync("herdr", ["tab", "close", tabId], { encoding: "utf8" }); + tabByPane.delete(surface); + return; + } + execFileSync("herdr", ["pane", "close", surface], { encoding: "utf8" }); +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/index.ts b/extensions/interactive-subagents/pi-extension/subagents/index.ts new file mode 100644 index 0000000..191435a --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/index.ts @@ -0,0 +1,2981 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { keyHint } from "@earendil-works/pi-coding-agent"; +import { Type, type Static } from "typebox"; +import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + readdirSync, + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + copyFileSync, + unlinkSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { + isMuxAvailable, + muxSetupHint, + createSurface, + sendCommand, + sendLongCommand, + pollForExit, + closeSurface, + shellEscape, + readScreen, + getMuxBackend, + wrapWithCompletionFile, +} from "./surface.ts"; + +import { + countSessionEntryLines, + findLastAssistantMessage, + getNewEntries, + getSessionId, + readNameRegistry, + readSubagentLoadout, + registerName, + resolveNameInRegistry, + seedSubagentSessionFile, + summarizeSessionStats, + writeSubagentLoadout, + type SessionStats, + type SubagentLoadout, +} from "./session.ts"; +import { + type StatusSnapshot, + type SubagentStatusState, + advanceStatusState, + capStatusLines, + classifyStatus, + createStatusState, + forceStatusAfterInterrupt, + formatStatusAggregate, + formatTransitionLine, + observeStatus, + loadStatusConfig, + normalizeStatusName, +} from "./status.ts"; +import { + getSubagentActivityFile, + readSubagentActivityFile, + type ActivityReadResult, + type SubagentActivityState, +} from "./activity.ts"; + +/** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */ +const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url)); + +// Pi re-imports extension modules during /reload while callbacks from the old +// module can still be running. Keep live subagent ownership process-global so a +// fresh extension runtime can rebind to existing watchers instead of cancelling +// their terminal sessions. +const SHARED_RUNTIME_KEY = Symbol.for("pi-subagents/shared-runtime"); + +const SubagentParams = Type.Object({ + agent: Type.String({ + description: + "Which agent to spawn (e.g. 'worker', 'scout', 'researcher'). This loads the agent's " + + "fixed profile — its model, tool loadout, and system prompt. Must be one of the available agents.", + }), + task: Type.String({ description: "Task/prompt for the sub-agent" }), + name: Type.Optional( + Type.String({ + description: + "Optional cosmetic label for the subagent's pane and widget row. Defaults to the agent name. " + + "Has no effect on which agent runs — use `agent` for that.", + }), + ), + model: Type.Optional(Type.String({ description: "Model override (overrides agent default)" })), + cwd: Type.Optional( + Type.String({ + description: + "Working directory for the sub-agent. The agent starts in this folder and picks up its local .pi/ config, CLAUDE.md, skills, and extensions. Use for role-specific subfolders.", + }), + ), +}); + +type SubagentSessionMode = "standalone" | "lineage-only" | "fork"; + +/** + * Optional host integration for composing the running-subagents widget into a + * larger widget. The rendered body is capped by `maxBodyRows`, including the + * overflow summary row when one is needed. The unboxed header is not included + * in that cap. + */ +export type StatusWidgetColor = "accent" | "muted" | "warning" | "error" | "dim"; + +/** + * The small structural theme surface used by the status widget. Keeping this + * local avoids coupling hosts to a particular Theme class/module instance. + */ +export interface StatusWidgetTheme { + fg(color: StatusWidgetColor, text: string): string; +} + +export interface StatusWidget { + hasContent(): boolean; + getBodyRowCount(): number; + render(width: number, maxBodyRows?: number, theme?: StatusWidgetTheme): string[]; + claim(onChange: () => void): () => void; + isClaimed(onChange: () => void): boolean; +} + +interface AgentDefaults { + model?: string; + tools?: string; + skills?: string; + thinking?: string; + /** + * If set (non-empty), this agent is granted the full subagent spawning + * toolset and may only spawn the listed agents. Presence of this field — + * not the `tools` list — is what grants spawning. Enforced in the child via + * the PI_SUBAGENT_ALLOWED env var. + */ + subagentAgents?: string[]; + autoExit?: boolean; + interactive?: boolean; + systemPromptMode?: "append" | "replace"; + sessionMode?: SubagentSessionMode; + cwd?: string; + cli?: string; + body?: string; + disableModelInvocation?: boolean; +} + +type AgentSource = "package" | "global" | "project"; + +interface AgentDefinition extends AgentDefaults { + name: string; + description?: string; + disableModelInvocation: boolean; +} + +interface ListedAgentDefinition extends AgentDefinition { + source: AgentSource; +} + +/** + * The full subagent lifecycle/spawning toolset registered by this extension. + * An agent is granted these (and this extension is loaded into its child + * process) only when its frontmatter declares a non-empty `subagent_agents`. + */ +const SPAWNING_TOOLS = [ + "subagent", + "subagent_message", + "subagents_list", +] as const; + +/** Built-in tools pi provides natively — no extension needs to be loaded. */ +const BUILTIN_TOOLS = new Set(["read", "write", "edit", "bash", "grep", "find", "ls"]); + +/** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */ +function getAgentConfigDir(): string { + return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); +} + +// ── Runtime tool-extension registration ───────────────────────────────────── +// `getToolExtensionPath` otherwise only knows a closed set of tool names. Other +// pi extensions that bundle a tool for subagents (e.g. a project-local +// extension exposing a bespoke tool) register its name → extension-file path +// here at load/session_start time so a child process can be launched with +// `--no-extensions` + an explicit `-e ` for it. Mirrors the legacy +// `subagents` extension's `registerToolExtension` hook. +const EXTRA_TOOL_EXTENSIONS = new Map(); + +/** Register (or re-register) a custom tool's backing extension file. */ +export function registerToolExtension(name: string, extensionPath: string): void { + if (BUILTIN_TOOLS.has(name)) { + throw new Error(`Cannot register custom tool "${name}": shadows a built-in pi tool`); + } + if ((SPAWNING_TOOLS as readonly string[]).includes(name)) { + throw new Error(`Cannot register custom tool "${name}": shadows a spawning tool`); + } + const existing = EXTRA_TOOL_EXTENSIONS.get(name); + if (existing === extensionPath) return; // idempotent / reload-safe + if (existing !== undefined) { + throw new Error( + `Tool extension already registered for "${name}": ${existing} (refusing to overwrite with ${extensionPath})`, + ); + } + EXTRA_TOOL_EXTENSIONS.set(name, extensionPath); +} + +// Expose registration on a process-global so project-local extensions loaded +// via jiti (separate module instances) can reach this shared map. Keep the +// object itself stable across /reload so an optional host integration can keep +// its reference while this extension is re-imported. +const existingInteractiveSubagentsApi = (globalThis as any).__pi_interactive_subagents; +const interactiveSubagentsApi = ( + existingInteractiveSubagentsApi != null + && (typeof existingInteractiveSubagentsApi === "object" || typeof existingInteractiveSubagentsApi === "function") + && !Array.isArray(existingInteractiveSubagentsApi) + ? existingInteractiveSubagentsApi + : {} +) as { + registerToolExtension: typeof registerToolExtension; + statusWidget?: StatusWidget; +}; +(globalThis as any).__pi_interactive_subagents = interactiveSubagentsApi; +interactiveSubagentsApi.registerToolExtension = registerToolExtension; + +/** + * Map a custom (non-built-in) tool name to the pi-extension file that + * registers it. Used to build the child's `--extension` whitelist after + * `--no-extensions` disables global discovery. Returns undefined for built-in + * tools and for unknown names (which simply won't be granted). + */ +function getToolExtensionPath(tool: string): string | undefined { + if (BUILTIN_TOOLS.has(tool)) return undefined; + // The four spawning tools are registered by THIS extension. + if ((SPAWNING_TOOLS as readonly string[]).includes(tool)) { + return fileURLToPath(import.meta.url); + } + const extBase = join(getAgentConfigDir(), "extensions"); + const map: Record = { + web_search: join(extBase, "web-search", "index.ts"), + web_fetch: join(extBase, "web-fetch", "index.ts"), + video_extract: join(extBase, "video-extract", "index.ts"), + youtube_search: join(extBase, "youtube-search", "index.ts"), + google_image_search: join(extBase, "google-image-search", "index.ts"), + safe_bash: join(SUBAGENTS_DIR, "tools", "safe-bash.ts"), + }; + // Prefer the built-in path, but fall back to a runtime-registered extension + // when that path no longer exists on disk (e.g. a built-in tool extension + // was disabled/removed but a project-local extension re-registered it). + const builtin = map[tool]; + if (builtin && existsSync(builtin)) return builtin; + return EXTRA_TOOL_EXTENSIONS.get(tool); +} + +/** + * When this process was spawned as a restricted subagent, the parent pins the + * set of agents it may itself spawn via PI_SUBAGENT_ALLOWED. `null` means no + * restriction (top-level session, or an unrestricted child). + */ +const SUBAGENT_ALLOWLIST: Set | null = (() => { + const raw = process.env.PI_SUBAGENT_ALLOWED; + if (!raw) return null; + const list = raw.split(",").map((s) => s.trim()).filter(Boolean); + return list.length > 0 ? new Set(list) : null; +})(); + +function getBundledAgentsDir(): string { + return join(SUBAGENTS_DIR, "../../agents"); +} + +function getFrontmatterValue(frontmatter: string, key: string): string | undefined { + const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m")); + return match ? match[1].trim() : undefined; +} + +function parseOptionalBoolean(value: string | undefined): boolean | undefined { + return value != null ? value === "true" : undefined; +} + +/** Parse a comma-separated frontmatter value into a trimmed list (or undefined). */ +function parseCommaList(value: string | undefined): string[] | undefined { + if (value == null) return undefined; + const list = value.split(",").map((s) => s.trim()).filter(Boolean); + return list.length > 0 ? list : undefined; +} + +function parseSessionMode(value: string | undefined): SubagentSessionMode | undefined { + if (value === "standalone" || value === "lineage-only" || value === "fork") { + return value; + } + return undefined; +} + +function parseAgentDefinition(content: string, fallbackName: string): AgentDefinition | null { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + + const frontmatter = match[1]; + const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim(); + const systemPromptMode = getFrontmatterValue(frontmatter, "system-prompt"); + + return { + name: getFrontmatterValue(frontmatter, "name") ?? fallbackName, + description: getFrontmatterValue(frontmatter, "description"), + model: getFrontmatterValue(frontmatter, "model"), + tools: getFrontmatterValue(frontmatter, "tools"), + systemPromptMode: + systemPromptMode === "replace" + ? "replace" + : systemPromptMode === "append" + ? "append" + : undefined, + skills: getFrontmatterValue(frontmatter, "skill") ?? getFrontmatterValue(frontmatter, "skills"), + thinking: getFrontmatterValue(frontmatter, "thinking"), + subagentAgents: parseCommaList(getFrontmatterValue(frontmatter, "subagent_agents")), + autoExit: parseOptionalBoolean(getFrontmatterValue(frontmatter, "auto-exit")), + interactive: parseOptionalBoolean(getFrontmatterValue(frontmatter, "interactive")), + sessionMode: parseSessionMode(getFrontmatterValue(frontmatter, "session-mode")), + cwd: getFrontmatterValue(frontmatter, "cwd"), + cli: getFrontmatterValue(frontmatter, "cli"), + body: body || undefined, + disableModelInvocation: + getFrontmatterValue(frontmatter, "disable-model-invocation")?.toLowerCase() === "true", + }; +} + +function discoverAgentDefinitions(): ListedAgentDefinition[] { + const agents = new Map(); + const dirs: Array<{ path: string; source: AgentSource }> = [ + { path: getBundledAgentsDir(), source: "package" }, + { path: join(getAgentConfigDir(), "agents"), source: "global" }, + { path: join(process.cwd(), ".pi", "agents"), source: "project" }, + ]; + + for (const { path: dir, source } of dirs) { + if (!existsSync(dir)) continue; + for (const file of readdirSync(dir).filter((entry) => entry.endsWith(".md"))) { + const parsed = parseAgentDefinition( + readFileSync(join(dir, file), "utf8"), + file.replace(/\.md$/, ""), + ); + if (!parsed) continue; + agents.set(parsed.name, { ...parsed, source }); + } + } + + // When this process is itself a restricted subagent, only expose the agents + // it is permitted to spawn (PI_SUBAGENT_ALLOWED). Top-level sessions see all. + const all = [...agents.values()]; + return SUBAGENT_ALLOWLIST ? all.filter((a) => SUBAGENT_ALLOWLIST.has(a.name)) : all; +} + +function resolveSubagentPaths( + params: Static, + agentDefs: AgentDefaults | null, +): { effectiveCwd: string | null; localAgentDir: string | null; effectiveAgentDir: string } { + const rawCwd = params.cwd ?? agentDefs?.cwd ?? null; + const cwdIsFromAgent = !params.cwd && agentDefs?.cwd != null; + const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : process.cwd(); + const effectiveCwd = rawCwd + ? rawCwd.startsWith("/") + ? rawCwd + : join(cwdBase, rawCwd) + : null; + const localAgentDir = effectiveCwd ? join(effectiveCwd, ".pi", "agent") : null; + const effectiveAgentDir = + localAgentDir && existsSync(localAgentDir) ? localAgentDir : getAgentConfigDir(); + return { effectiveCwd, localAgentDir, effectiveAgentDir }; +} + +function getDefaultSessionDirFor(cwd: string, agentDir: string): string { + const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + const sessionDir = join(agentDir, "sessions", safePath); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + return sessionDir; +} + +function resolveEffectiveSessionMode( + params: Static, + agentDefs: AgentDefaults | null, +): SubagentSessionMode { + return agentDefs?.sessionMode ?? "standalone"; +} + +function resolveLaunchBehavior( + params: Static, + agentDefs: AgentDefaults | null, +): { + sessionMode: SubagentSessionMode; + seededSessionMode: "lineage-only" | "fork" | null; + inheritsConversationContext: boolean; + taskDelivery: "direct" | "artifact"; +} { + const sessionMode = resolveEffectiveSessionMode(params, agentDefs); + const inheritsConversationContext = sessionMode === "fork"; + return { + sessionMode, + seededSessionMode: sessionMode === "standalone" ? null : sessionMode, + inheritsConversationContext, + taskDelivery: inheritsConversationContext ? "direct" : "artifact", + }; +} + +/** + * Decide whether a subagent is interactive (user-driven, long-running). + * + * Resolution order: + * 1. Explicit `interactive` frontmatter field on the agent. + * 2. Default: the inverse of `auto-exit`. Agents that auto-exit are + * autonomous (scout, researcher) and the parent session should be + * woken on stall/recovery transitions. Agents that don't auto-exit are + * driven by the user in their own pane (worker) and stall pings are noise. + */ +function resolveEffectiveInteractive( + _params: Static, + agentDefs: AgentDefaults | null, +): boolean { + if (agentDefs?.interactive != null) return agentDefs.interactive; + return !(agentDefs?.autoExit ?? false); +} + +function loadAgentDefaults(agentName: string): AgentDefaults | null { + const configDir = getAgentConfigDir(); + const paths = [ + join(process.cwd(), ".pi", "agents", `${agentName}.md`), + join(configDir, "agents", `${agentName}.md`), + join(getBundledAgentsDir(), `${agentName}.md`), + ]; + + for (const p of paths) { + if (!existsSync(p)) continue; + const parsed = parseAgentDefinition(readFileSync(p, "utf8"), agentName); + if (parsed) return parsed; + } + + return null; +} + +function formatElapsed(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}m ${s}s`; +} + +/** Compact token count: 850, 3.2k, 45k. */ +function formatTokens(n: number): string { + return n < 1000 ? String(n) : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`; +} + +/** + * Known context-window sizes by model id substring, used for the context-usage + * gauge. Unknown models fall back to a window-less "Nk ctx" label. + */ +function contextWindowFor(model: string | null | undefined): number | undefined { + if (!model) return undefined; + const m = model.toLowerCase(); + if (m.includes("claude")) return 200_000; + if (m.includes("gpt-4.1") || m.includes("gpt-4o")) return 128_000; + if (m.includes("gemini")) return 1_000_000; + return undefined; +} + +/** Context-usage gauge: "18.0%/200k" when window known, else "37k ctx". */ +function formatContextUsage(tokens: number, contextWindow: number | undefined): string { + if (!contextWindow) return `${formatTokens(tokens)} ctx`; + const pct = (tokens / contextWindow) * 100; + const maxStr = + contextWindow >= 1_000_000 + ? `${(contextWindow / 1_000_000).toFixed(1)}M` + : `${Math.round(contextWindow / 1000)}k`; + return `${pct.toFixed(1)}%/${maxStr}`; +} + +/** + * Build the dim usage line for a completed subagent, mirroring the format of + * the in-process subagents extension: "↑in ↓out R… W… $cost · ctx". + * `theme.fg` is applied by the caller; this returns plain segments joined. + */ +function formatUsageSegments(stats: SessionStats): string[] { + const segs: string[] = []; + if (stats.inputTokens) segs.push(`↑${formatTokens(stats.inputTokens)}`); + if (stats.outputTokens) segs.push(`↓${formatTokens(stats.outputTokens)}`); + if (stats.cacheReadTokens) segs.push(`R${formatTokens(stats.cacheReadTokens)}`); + if (stats.cacheWriteTokens) segs.push(`W${formatTokens(stats.cacheWriteTokens)}`); + if (stats.cost) segs.push(`$${stats.cost.toFixed(3)}`); + return segs; +} + +/** + * ANSI fallback for hosts that still call render() without passing Pi's theme. + * The widget remains useful in older hosts while themed hosts get the same + * semantic colors as the rest of Pi's UI. + */ +const FALLBACK_STATUS_ANSI: Record = { + accent: "\x1b[38;2;77;163;255m", + muted: "\x1b[38;2;128;128;128m", + warning: "\x1b[38;2;214;181;94m", + error: "\x1b[38;2;224;108;117m", + dim: "\x1b[38;2;128;128;128m", +}; +const ANSI_RESET = "\x1b[0m"; + +const FALLBACK_STATUS_THEME: StatusWidgetTheme = { + fg(color, text) { + return `${FALLBACK_STATUS_ANSI[color]}${text}${ANSI_RESET}`; + }, +}; + +function resolveStatusWidgetTheme(theme?: StatusWidgetTheme): StatusWidgetTheme { + return theme && typeof theme.fg === "function" ? theme : FALLBACK_STATUS_THEME; +} + +function statusWidgetColor(kind: StatusSnapshot["kind"]): StatusWidgetColor { + switch (kind) { + case "active": + case "running": + return "warning"; + case "stalled": + return "error"; + case "waiting": + return "accent"; + case "starting": + default: + return "dim"; + } +} + +/** Map a live status kind to a themed single-char icon for the widget. */ +function widgetIcon( + kind: StatusSnapshot["kind"], + theme: StatusWidgetTheme = FALLBACK_STATUS_THEME, +): string { + const icon = kind === "active" || kind === "running" || kind === "stalled" ? "⟳" : "○"; + return theme.fg(statusWidgetColor(kind), icon); +} + +/** + * Wait long enough for a freshly created pane to finish shell startup. + * + * Some environments do extra shell-init work before the prompt is ready + * (for example direnv/devenv), so the delay is configurable for users who hit + * dropped commands. Keep the historical default at 500ms. + */ +function defaultShellReadyDelayMs(backend: ReturnType): number { + // Orca surfaces block on a readiness marker at create time, so they only + // need the same small buffer as tmux. + return backend === "herdr" ? 1000 : 500; +} + +function getShellReadyDelayMs(): number { + const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim(); + const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; + if (Number.isFinite(parsed) && parsed >= 0) return parsed; + return defaultShellReadyDelayMs(getMuxBackend()); +} + +function muxUnavailableResult() { + return { + content: [ + { + type: "text" as const, + text: `Subagents require a supported terminal multiplexer. ${muxSetupHint()}`, + }, + ], + details: { error: "terminal multiplexer not available" }, + }; +} + +/** + * Build the internal artifact directory path for the current session. + * Used by the subagents extension to stash task files, system prompts, and + * launch scripts for sub-agents. Path convention: + * /artifacts// + */ +function getArtifactDir(sessionDir: string, sessionId: string): string { + return join(sessionDir, "artifacts", sessionId); +} + +const statusConfig = loadStatusConfig(); + +function formatWidgetRightLabel( + snapshot: StatusSnapshot, + theme: StatusWidgetTheme = FALLBACK_STATUS_THEME, +): string { + const color = statusWidgetColor(snapshot.kind); + const status = (text: string) => theme.fg(color, text); + const elapsed = (text: string) => theme.fg("dim", text); + + if (snapshot.kind === "starting") return status(" starting… "); + if (snapshot.kind === "running") { + return status(" running ") + elapsed(snapshot.elapsedText) + status(" "); + } + if (snapshot.kind === "active") { + const label = snapshot.activityLabel ?? snapshot.activeScope; + const duration = snapshot.activeDurationText + ? ` ${elapsed(snapshot.activeDurationText)}` + : ""; + return label + ? status(" active · ") + status(label) + duration + status(" ") + : status(" active ") + duration; + } + if (snapshot.kind === "waiting") { + const duration = snapshot.waitingDurationText + ? ` ${elapsed(snapshot.waitingDurationText)}` + : ""; + const detail = snapshot.statusLabel ? status(` · ${snapshot.statusLabel}`) : ""; + return status(" waiting") + duration + detail + status(" "); + } + + const detail = snapshot.statusLabel ? status(` · ${snapshot.statusLabel}`) : ""; + const duration = snapshot.snapshotProblemText + ? status(` ${snapshot.snapshotProblemText}`) + : ""; + return status(" stalled") + detail + duration + status(" "); +} + +function resolveResultPresentation( + result: Pick< + SubagentResult, + "exitCode" | "elapsed" | "summary" | "sessionFile" | "sessionId" | "errorMessage" + >, + name: string, +): string { + // Name is the persistent handle: the same name steers a running subagent or + // resumes a finished one, so follow-ups always reference it. + const sessionRef = `\n\nFollow up with subagent_message({ name: "${name}", message: "…" })`; + + if (result.errorMessage) { + // Auto-retry exhausted or other agent-loop error. The subagent did not + // produce a usable result — surface the underlying provider/network + // failure so the orchestrator can decide whether to retry, resume, or + // change approach instead of silently treating the run as completed. + return ( + `Sub-agent "${name}" failed after ${formatElapsed(result.elapsed)} ` + + `(provider/agent error — auto-retry exhausted).\n\n` + + `Error: ${result.errorMessage}\n\n` + + `The subagent did not produce a result. You can retry by spawning a new ` + + `subagent or resume the session with subagent_message.${sessionRef}` + ); + } + + return result.exitCode !== 0 + ? `Sub-agent "${name}" failed (exit code ${result.exitCode}).\n\n${result.summary}${sessionRef}` + : `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}${sessionRef}`; +} + +/** + * Result from running a single subagent. + */ +interface SubagentResult { + name: string; + task: string; + summary: string; + sessionFile?: string; + /** Canonical session header id, used for follow-ups via subagent_message. */ + sessionId?: string; + claudeSessionId?: string; + exitCode: number; + elapsed: number; + error?: string; + /** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */ + errorMessage?: string; + /** Aggregate usage/model/tool stats parsed from the completed session file. */ + stats?: SessionStats; +} + +/** + * State for a launched (but not yet completed) subagent. + */ +interface RunningSubagent { + id: string; + name: string; + task: string; + agent?: string; + surface: string; + startTime: number; + sessionFile: string; + launchScriptFile?: string; + activityFile?: string; + activity?: SubagentActivityState; + activityRead?: { + ok: boolean; + reason?: "missing" | "invalid" | "wrong-id"; + error?: string; + }; + abortController?: AbortController; + cli?: string; + sentinelFile?: string; + completionFile?: string; + statusState: SubagentStatusState; + /** + * When true, status transitions (stalled/recovered) do not wake the parent + * session via a steer message. The widget still updates locally. Used for + * long-running agents where the user drives the conversation in the + * subagent's pane (e.g. planner). + */ + interactive: boolean; + /** Suppress terminal delivery when a real session shutdown cancels this run. */ + suppressDelivery?: boolean; +} + +type PendingDelivery = (pi: ExtensionAPI) => void; + +interface StatusWidgetClaim { + ownerToken: symbol; + runtimeToken: symbol | null; + onChange: () => void; +} + +type StatusWidgetRenderer = ( + width: number, + maxBodyRows?: number, + theme?: StatusWidgetTheme, +) => string[]; + +const STATUS_WIDGET_MARKER = Symbol.for("pi-subagents/status-widget"); +const STATUS_WIDGET_CLAIM_MARKER = Symbol.for("pi-subagents/status-widget-claim"); + +function isStatusWidgetClaim(value: unknown): value is StatusWidgetClaim { + try { + if (value == null || typeof value !== "object") return false; + const claim = value as Record; + return claim[STATUS_WIDGET_CLAIM_MARKER] === true + && typeof claim.ownerToken === "symbol" + && (claim.runtimeToken === null || typeof claim.runtimeToken === "symbol") + && typeof claim.onChange === "function"; + } catch { + return false; + } +} + +interface SharedSubagentRuntime { + runningSubagents: Map; + latestCtx: ExtensionContext | null; + latestPi: ExtensionAPI | null; + widgetInterval: ReturnType | null; + statusInterval: ReturnType | null; + standaloneWidgetSyncScheduled: boolean; + pendingDeliveries: PendingDelivery[]; + deliveryFlushScheduled: boolean; + activeRuntimeToken: symbol | null; + registeredRuntimeToken: symbol | null; + statusWidgetClaim: StatusWidgetClaim | null; + statusWidgetRenderer: StatusWidgetRenderer; + statusWidget?: StatusWidget; +} + +function createSharedRuntime(): SharedSubagentRuntime { + return { + runningSubagents: new Map(), + latestCtx: null, + latestPi: null, + widgetInterval: null, + statusInterval: null, + standaloneWidgetSyncScheduled: false, + pendingDeliveries: [], + deliveryFlushScheduled: false, + activeRuntimeToken: null, + registeredRuntimeToken: null, + statusWidgetClaim: null, + statusWidgetRenderer: () => [], + }; +} + +const sharedRuntime = ((globalThis as any)[SHARED_RUNTIME_KEY] ??= + createSharedRuntime()) as SharedSubagentRuntime; + +// Runtimes created by older extension versions do not have the optional widget +// fields yet. Initialize them in place so a parent /reload can upgrade without +// replacing the process-global runtime or its running children. +if (!isStatusWidgetClaim(sharedRuntime.statusWidgetClaim)) { + sharedRuntime.statusWidgetClaim = null; +} +if (typeof sharedRuntime.statusWidgetRenderer !== "function") { + sharedRuntime.statusWidgetRenderer = () => []; +} +if (sharedRuntime.registeredRuntimeToken !== null && typeof sharedRuntime.registeredRuntimeToken !== "symbol") { + sharedRuntime.registeredRuntimeToken = null; +} +if (sharedRuntime.activeRuntimeToken !== null && typeof sharedRuntime.activeRuntimeToken !== "symbol") { + sharedRuntime.activeRuntimeToken = null; +} +if (typeof sharedRuntime.standaloneWidgetSyncScheduled !== "boolean") { + sharedRuntime.standaloneWidgetSyncScheduled = false; +} + +/** All currently running subagents, preserved across /reload. */ +const runningSubagents = sharedRuntime.runningSubagents; + +// When this extension is loaded inside a subagent that itself spawns children +// (e.g. a worker delegating to scout/researcher), `subagent-done.ts` runs in the +// same process and needs to know whether this session still has children in +// flight — so it can suppress auto-exit and keep the session open until they all +// report back. Expose a live count through a process-global symbol that both +// modules share. (subagent-done.ts reads it; if absent it assumes zero.) +const RUNNING_CHILDREN_COUNT_KEY = Symbol.for("pi-subagents/running-children-count"); +(globalThis as any)[RUNNING_CHILDREN_COUNT_KEY] = () => runningSubagents.size; + +// ── Widget and runtime delivery management ── + +function sendWhenRuntimeReady(delivery: PendingDelivery): void { + const pi = sharedRuntime.latestPi; + if (pi) { + delivery(pi); + return; + } + sharedRuntime.pendingDeliveries.push(delivery); +} + +function flushPendingDeliveries(): void { + sharedRuntime.deliveryFlushScheduled = false; + const pi = sharedRuntime.latestPi; + if (!pi || sharedRuntime.pendingDeliveries.length === 0) return; + + const pending = sharedRuntime.pendingDeliveries.splice(0); + for (const delivery of pending) delivery(pi); +} + +function schedulePendingDeliveryFlush(): void { + if (sharedRuntime.deliveryFlushScheduled) return; + sharedRuntime.deliveryFlushScheduled = true; + setImmediate(flushPendingDeliveries); +} + +type RefreshIntervalKind = "widget" | "status"; + +function getRefreshInterval(kind: RefreshIntervalKind): ReturnType | null { + return kind === "widget" ? sharedRuntime.widgetInterval : sharedRuntime.statusInterval; +} + +function clearRefreshInterval(kind: RefreshIntervalKind): void { + if (kind === "widget") { + if (sharedRuntime.widgetInterval) clearInterval(sharedRuntime.widgetInterval); + sharedRuntime.widgetInterval = null; + return; + } + + if (sharedRuntime.statusInterval) clearInterval(sharedRuntime.statusInterval); + sharedRuntime.statusInterval = null; +} + +function clearRefreshIntervals(): void { + clearRefreshInterval("widget"); + clearRefreshInterval("status"); +} + +function ownsRefreshInterval( + kind: RefreshIntervalKind, + interval: ReturnType, + runtimeToken: symbol, +): boolean { + if (getRefreshInterval(kind) !== interval) { + clearInterval(interval); + return false; + } + if (sharedRuntime.activeRuntimeToken !== runtimeToken) { + clearRefreshInterval(kind); + return false; + } + return true; +} + +function formatElapsedMMSS(startTime: number): string { + const seconds = Math.floor((Date.now() - startTime) / 1000); + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +/** + * Build an unboxed content line with a right-aligned status. The helper keeps + * its historical name for structural compatibility with existing consumers. + * Left and right values may contain ANSI styling, so all fitting decisions use + * visible cell width rather than string length. + */ +function borderLine(left: string, right: string, width: number): string { + if (width <= 0) return ""; + + const rightVis = visibleWidth(right); + // If the status chunk alone is too wide, preserve it rather than overflowing + // the terminal. The left side is intentionally dropped in this case. + if (rightVis >= width) { + const truncRight = truncateToWidth(right, width); + return `${truncRight}${" ".repeat(Math.max(0, width - visibleWidth(truncRight)))}`; + } + + const maxLeft = Math.max(0, width - rightVis); + const truncLeft = truncateToWidth(left, maxLeft); + const pad = Math.max(0, width - visibleWidth(truncLeft) - rightVis); + return `${truncLeft}${" ".repeat(pad)}${right}`; +} + +/** Build the compact, unboxed header row for the status panel. */ +function renderWidgetHeader(count: number, width: number, theme: StatusWidgetTheme): string { + if (width <= 0) return ""; + const title = theme.fg("accent", "Subagents"); + const info = theme.fg("muted", `${count} running`); + return truncateToWidth(`${title} ${info}`, width); +} + +function renderSubagentWidgetLines( + agents: RunningSubagent[], + width: number, + maxBodyRows?: number, + theme?: StatusWidgetTheme, +): string[] { + const count = agents.length; + const renderTheme = resolveStatusWidgetTheme(theme); + const bodyLimit = maxBodyRows == null || !Number.isFinite(maxBodyRows) + ? null + : Math.max(0, Math.floor(maxBodyRows)); + + // The body limit includes the overflow summary. This lets a host reserve a + // fixed number of rows without the summary unexpectedly pushing the + // composite widget over its available height. + const visibleAgentCount = bodyLimit == null || agents.length <= bodyLimit + ? agents.length + : Math.max(0, bodyLimit - 1); + const overflow = agents.length - visibleAgentCount; + + const lines: string[] = [renderWidgetHeader(count, width, renderTheme)]; + + for (const agent of agents.slice(0, visibleAgentCount)) { + const elapsed = renderTheme.fg("dim", formatElapsedMMSS(agent.startTime)); + const snapshot = classifyStatus(agent.statusState, Date.now()); + const statusColor = statusWidgetColor(snapshot.kind); + const icon = widgetIcon(snapshot.kind, renderTheme); + const name = renderTheme.fg(statusColor, normalizeStatusName(agent.name)); + const agentTag = agent.agent + ? renderTheme.fg("dim", ` (${normalizeStatusName(agent.agent)})`) + : ""; + const left = ` ${icon} ${elapsed} ${name}${agentTag} `; + const right = statusConfig.enabled + ? formatWidgetRightLabel(snapshot, renderTheme) + : renderTheme.fg( + statusColor, + agent.cli === "claude" ? " running… " : " starting… ", + ); + + lines.push(borderLine(left, right, width)); + } + + if (overflow > 0 && bodyLimit !== 0) { + lines.push(borderLine(renderTheme.fg("dim", `+${overflow} more running.`), "", width)); + } + + return lines; +} + +function clearStandaloneWidget(): void { + const ctx = sharedRuntime.latestCtx; + if (!ctx?.hasUI) return; + try { + ctx.ui.setWidget("subagent-status", undefined); + } catch { + // Pi may be tearing down the UI while a host releases its claim. + } +} + +function setStandaloneWidget(): void { + const ctx = sharedRuntime.latestCtx; + if (!ctx?.hasUI) return; + + try { + ctx.ui.setWidget( + "subagent-status", + (_tui: any, theme?: StatusWidgetTheme) => { + return { + invalidate() {}, + render(width: number) { + return renderSubagentWidgetLines( + Array.from(runningSubagents.values()), + width, + undefined, + theme, + ); + }, + }; + }, + { placement: "aboveEditor" }, + ); + } catch { + // Pi may be tearing down the UI while a claim is being released. + } +} + +function getCurrentStatusWidgetClaim(): StatusWidgetClaim | null { + const claim = sharedRuntime.statusWidgetClaim; + return isStatusWidgetClaim(claim) + && claim.runtimeToken === sharedRuntime.activeRuntimeToken + ? claim + : null; +} + +function synchronizeStandaloneWidget(): void { + const claim = sharedRuntime.statusWidgetClaim; + if (getCurrentStatusWidgetClaim()) { + clearStandaloneWidget(); + return; + } + + // Drop malformed or stale ownership before restoring the fallback widget. + if (claim !== null) sharedRuntime.statusWidgetClaim = null; + if (runningSubagents.size > 0) setStandaloneWidget(); + else clearStandaloneWidget(); +} + +function scheduleStandaloneWidgetSync(): void { + if (sharedRuntime.standaloneWidgetSyncScheduled) return; + sharedRuntime.standaloneWidgetSyncScheduled = true; + queueMicrotask(() => { + sharedRuntime.standaloneWidgetSyncScheduled = false; + synchronizeStandaloneWidget(); + }); +} + +function notifyStatusWidgetClaim(): void { + const claim = sharedRuntime.statusWidgetClaim; + if (!claim) return; + + const currentClaim = getCurrentStatusWidgetClaim(); + if (!currentClaim) { + if (sharedRuntime.statusWidgetClaim === claim) { + sharedRuntime.statusWidgetClaim = null; + scheduleStandaloneWidgetSync(); + } + return; + } + + try { + currentClaim.onChange(); + } catch { + // A host can be destroyed independently of this extension. Drop a claim + // whose callback is no longer usable, then restore the standalone widget. + if (sharedRuntime.statusWidgetClaim === claim) { + sharedRuntime.statusWidgetClaim = null; + scheduleStandaloneWidgetSync(); + } + } +} + +function isCurrentStatusWidgetClaim(onChange: () => void): boolean { + return typeof onChange === "function" && getCurrentStatusWidgetClaim()?.onChange === onChange; +} + +function releaseStatusWidgetClaim(ownerToken: symbol): void { + const claim = sharedRuntime.statusWidgetClaim; + if (!isStatusWidgetClaim(claim) || claim.ownerToken !== ownerToken) return; + sharedRuntime.statusWidgetClaim = null; + if (runningSubagents.size === 0) clearRefreshIntervals(); + scheduleStandaloneWidgetSync(); +} + +function isStatusWidget(value: unknown): value is StatusWidget { + try { + if (value == null || typeof value !== "object") return false; + const candidate = value as Record; + return candidate[STATUS_WIDGET_MARKER] === true + && typeof candidate.hasContent === "function" + && typeof candidate.getBodyRowCount === "function" + && typeof candidate.render === "function" + && typeof candidate.claim === "function" + && typeof candidate.isClaimed === "function"; + } catch { + return false; + } +} + +function createStatusWidget(): StatusWidget { + const widget: StatusWidget = { + hasContent: () => runningSubagents.size > 0, + getBodyRowCount: () => runningSubagents.size, + render: (width, maxBodyRows, theme) => runningSubagents.size === 0 + ? [] + : sharedRuntime.statusWidgetRenderer(width, maxBodyRows, theme), + isClaimed: (onChange) => isCurrentStatusWidgetClaim(onChange), + claim(onChange) { + if (typeof onChange !== "function") { + throw new TypeError("statusWidget.claim requires an onChange callback"); + } + + const ownerToken = Symbol("pi-subagents/status-widget-claim"); + const claim: StatusWidgetClaim = { + ownerToken, + runtimeToken: sharedRuntime.activeRuntimeToken, + onChange, + }; + Object.defineProperty(claim, STATUS_WIDGET_CLAIM_MARKER, { value: true }); + sharedRuntime.statusWidgetClaim = claim; + scheduleStandaloneWidgetSync(); + + return () => releaseStatusWidgetClaim(ownerToken); + }, + }; + Object.defineProperty(widget, STATUS_WIDGET_MARKER, { value: true }); + return widget; +} + +// Keep the integration object stable across reload while rebinding its +// renderer to this module instance's current status configuration. A widget +// from an older/malformed runtime is replaced rather than exposing an +// untrusted contract to the host. +sharedRuntime.statusWidgetRenderer = (width, maxBodyRows, theme) => + renderSubagentWidgetLines(Array.from(runningSubagents.values()), width, maxBodyRows, theme); +const statusWidget = isStatusWidget(sharedRuntime.statusWidget) + ? sharedRuntime.statusWidget + : createStatusWidget(); +sharedRuntime.statusWidget = statusWidget; +interactiveSubagentsApi.statusWidget = statusWidget; + +function updateWidget() { + if (runningSubagents.size === 0) { + clearStandaloneWidget(); + clearRefreshIntervals(); + notifyStatusWidgetClaim(); + return; + } + + if (sharedRuntime.statusWidgetClaim) { + clearStandaloneWidget(); + notifyStatusWidgetClaim(); + return; + } + + setStandaloneWidget(); +} + +/** + * Build the positional prompt args for a Pi CLI subagent launch. + * + * In artifact-backed launches (lineage-only, standalone), Pi's buildInitialMessage() + * concatenates @file content with messages[0] into one initial prompt. That breaks + * /skill: expansion because the message no longer starts with "/skill:". Only + * messages[1..] are sent as separate follow-up prompts where /skill: is recognized. + * + * When there are skill prompts AND artifact-backed delivery, we prepend an empty + * first positional message so that /skill: args land in messages[1..] and arrive + * as standalone prompts in the child session. + */ +const SUBAGENT_CONTROL_TOOLS = ["ask_question"] as const; + +/** + * Build the child --tools allowlist. + * + * Pi 0.70+ applies --tools to built-in, extension, and custom tools. If a + * subagent definition restricts tools to e.g. "read,bash,write", the child + * control tools from subagent-done.ts would otherwise be hidden, leaving a + * manually resumed or user-touched subagent unable to call ask_question. + */ +function buildSubagentToolAllowlist( + effectiveTools?: string, + opts?: { grantSpawning?: boolean }, +): string | null { + const requested = (effectiveTools ?? "") + .split(",") + .map((tool) => tool.trim()) + .filter(Boolean); + + const grantSpawning = opts?.grantSpawning ?? false; + + // No explicit tool restriction and no spawning grant → don't pass --tools at + // all (the child keeps its default toolset). + if (requested.length === 0 && !grantSpawning) return null; + + const allow = new Set(requested); + if (grantSpawning) { + for (const tool of SPAWNING_TOOLS) allow.add(tool); + } + for (const tool of SUBAGENT_CONTROL_TOOLS) { + allow.add(tool); + } + + return [...allow].join(","); +} + +/** + * Apply a loadout snapshot's sandbox to a pi command's `parts` array: model, + * identity (system prompt), and the default-deny tool/extension restriction + * (`--no-extensions` + `--tools` + one `-e` per tool-backing extension). + * + * This is the single source of truth for reconstructing a subagent's sandbox, + * used both by the initial `launchSubagent` and by the `subagent_message` + * resume path so the two can never drift. Env vars (PI_SUBAGENT_AGENT / + * PI_SUBAGENT_ALLOWED / PI_CODING_AGENT_DIR) and cwd are the caller's + * responsibility since they differ slightly between launch and resume. + */ +function applySandboxToParts( + parts: string[], + loadout: SubagentLoadout, + opts: { artifactDir: string; name: string }, +): void { + if (loadout.model) { + const model = loadout.thinking ? `${loadout.model}:${loadout.thinking}` : loadout.model; + parts.push("--model", shellEscape(model)); + } + + if (loadout.identity) { + const flag = loadout.systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt"; + const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const spSafeName = opts.name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + const spPath = join(opts.artifactDir, `context/${spSafeName || "subagent"}-sysprompt-${spTimestamp}.md`); + mkdirSync(dirname(spPath), { recursive: true }); + writeFileSync(spPath, loadout.identity, "utf8"); + parts.push(flag, shellEscape(spPath)); + } + + // Default-deny: disable global extension discovery and re-enable only the + // extensions backing the whitelisted tools. A null allowlist means the spawn + // was intentionally unrestricted (e.g. a fork clone) and is replayed as-is. + if (loadout.toolAllowlist) { + parts.push("--no-extensions"); + parts.push("--tools", shellEscape(loadout.toolAllowlist)); + + const extPaths = new Set(); + for (const tool of loadout.toolAllowlist.split(",")) { + const extPath = getToolExtensionPath(tool); + if (extPath && existsSync(extPath)) extPaths.add(extPath); + } + for (const extPath of extPaths) { + parts.push("-e", shellEscape(extPath)); + } + } +} + +function buildPiPromptArgs(params: { + effectiveSkills?: string; + taskDelivery: "direct" | "artifact"; + taskArg: string; +}): string[] { + const skillPrompts = (params.effectiveSkills ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((skill) => `/skill:${skill}`); + + const needsSeparator = params.taskDelivery === "artifact" && skillPrompts.length > 0; + + return [ + ...(needsSeparator ? [""] : []), + ...skillPrompts, + params.taskArg, + ]; +} + +function activityLabel(activity: SubagentActivityState): string | undefined { + if (activity.phase !== "active") return undefined; + if (activity.activeScope === "tool") return activity.toolName ?? "tool"; + if (activity.activeScope === "provider") return "provider"; + if (activity.activeScope === "streaming") return "streaming"; + return activity.activeScope; +} + +function observeRunningSubagent(running: RunningSubagent, observedAt = Date.now()) { + if (running.cli === "claude") return; + + const activityFile = running.activityFile; + const read: ActivityReadResult = activityFile + ? readSubagentActivityFile(activityFile, running.id) + : { ok: false, reason: "missing" }; + + running.activityRead = read.ok + ? { ok: true } + : { ok: false, reason: read.reason, error: read.error }; + + if (read.ok) { + running.activity = read.activity; + running.statusState = observeStatus(running.statusState, { + snapshot: "present", + updatedAt: read.activity.updatedAt, + sequence: read.activity.sequence, + phase: read.activity.phase, + active: read.activity.phase === "active", + activeScope: read.activity.activeScope, + activeSince: read.activity.activeSince, + waitingSince: read.activity.waitingSince, + latestEvent: read.activity.latestEvent, + activityLabel: activityLabel(read.activity), + }, observedAt); + return; + } + + running.statusState = observeStatus(running.statusState, { + snapshot: read.reason, + snapshotError: read.error, + }, observedAt); +} + +/** + * Names claimed by spawns that are mid-launch but not yet registered in + * `runningSubagents`. Parallel `subagent` tool calls run their synchronous + * prefix (name defaulting) before any of them finishes `launchSubagent` and + * registers, so without this they'd all see an empty map and pick the same + * name. Reserved synchronously when a default name is chosen and released once + * the subagent registers (or its launch fails). + */ +const reservedNames = new Set(); + +/** + * Return `base`, or `base-2`, `base-3`, … so the result is unique within this + * spawner session. Considers (a) currently-running subagents, (b) names + * reserved by parallel in-flight spawns, and (c) every name already recorded in + * the spawner's persistent registry — so a defaulted name never collides with a + * finished subagent either. This lets `subagent_message({ name })` address any + * subagent of this session unambiguously, running or finished. + * + * `registryNames` is the set of names already taken in the registry (empty when + * there is no session file / artifact dir yet). + */ +function uniqueRunningName(base: string, registryNames?: Set): string { + const taken = new Set(Array.from(runningSubagents.values()).map((r) => r.name)); + for (const reserved of reservedNames) taken.add(reserved); + if (registryNames) for (const n of registryNames) taken.add(n); + if (!taken.has(base)) return base; + let n = 2; + while (taken.has(`${base}-${n}`)) n++; + return `${base}-${n}`; +} + +function resolveRunningByName(name: string): + | { running: RunningSubagent } + | { error: string } { + const requestedName = name.trim(); + if (!requestedName) { + return { error: "Provide the exact display name of a running subagent." }; + } + + const matches = Array.from(runningSubagents.values()).filter((running) => running.name === requestedName); + if (matches.length === 1) return { running: matches[0] }; + if (matches.length === 0) { + const names = Array.from(runningSubagents.values()).map((r) => r.name); + const hint = names.length + ? ` Currently running: ${[...new Set(names)].join(", ")}.` + : " No subagents are currently running."; + return { error: `No running subagent named "${requestedName}".${hint}` }; + } + + const candidates = matches.map((running) => `${running.name} [${running.id}]`).join(", "); + return { error: `Ambiguous subagent name "${requestedName}". Matches: ${candidates}` }; +} + +/** + * Type a follow-up message into a running subagent's live pane. Newlines are + * collapsed to spaces because each newline submits a turn in the child's TUI + * editor; a multi-line message would otherwise fire as several partial turns. + */ +function steerSubagent( + running: RunningSubagent, + message: string, + send: (surface: string, command: string) => void = sendCommand, +): { ok: true } | { error: string } { + const flattened = message.replace(/\s*\n\s*/g, " ").trim(); + try { + send(running.surface, flattened); + return { ok: true }; + } catch (error: any) { + return { + error: + `Failed to deliver message to subagent "${running.name}" via the terminal surface: ` + + `${error?.message ?? String(error)}`, + }; + } +} + +function handleSubagentSteer( + params: { name?: string; message?: string }, + send: (surface: string, command: string) => void = sendCommand, +) { + const message = params.message?.trim(); + if (!message) { + const err = "`message` is required to steer a running subagent."; + return { content: [{ type: "text" as const, text: err }], details: { error: err } }; + } + + const resolved = resolveRunningByName(params.name ?? ""); + if ("error" in resolved) { + return { + content: [{ type: "text" as const, text: resolved.error }], + details: { error: resolved.error }, + }; + } + + const running = resolved.running; + const now = Date.now(); + observeRunningSubagent(running, now); + + const steer = steerSubagent(running, message, send); + if ("error" in steer) { + return { + content: [{ type: "text" as const, text: steer.error }], + details: { error: steer.error, id: running.id, name: running.name }, + }; + } + + running.statusState = forceStatusAfterInterrupt(running.statusState, now); + updateWidget(); + + return { + content: [{ + type: "text" as const, + text: + `Message delivered to running subagent "${running.name}". It picks this up at its next ` + + `turn boundary. If it exits, its result still arrives as a steer message.`, + }], + details: { id: running.id, name: running.name, status: "steered" }, + }; +} + +function startStatusRefresh() { + if (!statusConfig.enabled) return; + const runtimeToken = sharedRuntime.activeRuntimeToken; + if (!runtimeToken) return; + if (getRefreshInterval("status")) return; + + const interval = setInterval(() => { + if (!ownsRefreshInterval("status", interval, runtimeToken)) return; + if (runningSubagents.size === 0) { + clearRefreshInterval("status"); + return; + } + + const transitionLines: string[] = []; + const now = Date.now(); + let shouldRefreshWidget = false; + + for (const running of runningSubagents.values()) { + observeRunningSubagent(running, now); + const { nextState, snapshot, transition } = advanceStatusState(running.statusState, now); + if (nextState.currentKind !== running.statusState.currentKind) { + shouldRefreshWidget = true; + } + running.statusState = nextState; + + // Interactive subagents (long-running, user-driven) intentionally don't + // wake the parent session on stalled/recovered transitions — the user is + // working in the subagent's pane, and a steer message here would burn an + // orchestrator turn on a no-op "still waiting" ping. Widget still updates. + if (transition && !running.interactive) { + transitionLines.push(formatTransitionLine(running.name, snapshot, transition)); + } + } + + if (shouldRefreshWidget) updateWidget(); + + if (transitionLines.length > 0) { + const capped = capStatusLines(transitionLines, statusConfig.lineLimit); + sendWhenRuntimeReady((pi) => { + pi.sendMessage( + { + customType: "subagent_status", + content: formatStatusAggregate(transitionLines, statusConfig.lineLimit), + display: true, + details: { lines: capped.visibleLines, overflow: capped.overflow }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + }); + } + }, 1000); + sharedRuntime.statusInterval = interval; +} + +// Resuming a finished session is always autonomous: the relaunched agent runs +// its follow-up task to completion and the harness delivers the result as a +// steer message (fire-and-forget). An interactive resume would park the pane +// waiting for the user, contradicting that result-delivery model. +function resolveResumeLaunchBehavior(): { autoExit: boolean; interactive: boolean } { + return { autoExit: true, interactive: false }; +} + +export const __test__ = { + borderLine, + defaultShellReadyDelayMs, + getShellReadyDelayMs, + renderSubagentWidgetLines, + statusWidget, + updateWidget, + loadAgentDefaults, + discoverAgentDefinitions, + resolveEffectiveSessionMode, + resolveLaunchBehavior, + resolveEffectiveInteractive, + buildSubagentToolAllowlist, + applySandboxToParts, + buildPiPromptArgs, + formatWidgetRightLabel, + observeRunningSubagent, + getToolExtensionPath, + resolveRunningByName, + uniqueRunningName, + reservedNames, + steerSubagent, + handleSubagentSteer, + resolveResultPresentation, + resolveResumeLaunchBehavior, + wrapWithCompletionFile, + runningSubagents, + formatElapsed, + formatTokens, + formatContextUsage, + contextWindowFor, + formatUsageSegments, + widgetIcon, + sendWhenRuntimeReady, + flushPendingDeliveries, + sharedRuntime, +}; + +function startWidgetRefresh() { + const runtimeToken = sharedRuntime.activeRuntimeToken; + if (!runtimeToken) return; + if (getRefreshInterval("widget")) return; + + updateWidget(); // immediate first render + if (runningSubagents.size === 0) return; + + const interval = setInterval(() => { + if (!ownsRefreshInterval("widget", interval, runtimeToken)) return; + updateWidget(); + }, 1000); + sharedRuntime.widgetInterval = interval; +} + +/** + * Launch a subagent: creates a terminal surface, builds the command, and + * sends it. Returns a RunningSubagent — does NOT poll. + * + * Call watchSubagent() on the returned object to observe completion. + */ +async function launchSubagent( + params: Static, + ctx: { sessionManager: { getSessionFile(): string | null | undefined; getSessionId(): string; getSessionDir(): string }; cwd: string }, + options?: { surface?: string }, +): Promise { + const startTime = Date.now(); + const id = Math.random().toString(16).slice(2, 10); + const name = params.name?.trim() || params.agent; + + const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null; + const effectiveModel = params.model ?? agentDefs?.model; + const effectiveTools = agentDefs?.tools; + const effectiveSkills = agentDefs?.skills; + const effectiveThinking = agentDefs?.thinking; + const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs); + + const sessionFile = ctx.sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("No session file"); + const sessionId = ctx.sessionManager.getSessionId(); + const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId); + const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`); + mkdirSync(dirname(completionFile), { recursive: true }); + + const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(params, agentDefs); + const targetCwdForSession = effectiveCwd ?? ctx.cwd; + const sessionDir = getDefaultSessionDirFor(targetCwdForSession, effectiveAgentDir); + + // Generate a deterministic session file path for this subagent. + // This eliminates race conditions when multiple agents launch simultaneously — + // each agent knows exactly which file is theirs. + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23) + "Z"; + const uuid = [ + id, + Math.random().toString(16).slice(2, 10), + Math.random().toString(16).slice(2, 10), + Math.random().toString(16).slice(2, 6), + ].join("-"); + const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`); + + // Use a pre-created surface or create a new one. New shells need a brief + // startup window before terminal input is reliable. + const surfacePreCreated = !!options?.surface; + const surface = options?.surface ?? createSurface(name); + if (!surfacePreCreated) { + await new Promise((resolve) => setTimeout(resolve, getShellReadyDelayMs())); + } + + try { + const launchBehavior = resolveLaunchBehavior(params, agentDefs); + + if (launchBehavior.seededSessionMode) { + seedSubagentSessionFile({ + mode: launchBehavior.seededSessionMode, + parentSessionFile: sessionFile, + childSessionFile: subagentSessionFile, + childCwd: targetCwdForSession, + }); + } + + const activityFile = getSubagentActivityFile(artifactDir, id); + mkdirSync(dirname(activityFile), { recursive: true }); + const { inheritsConversationContext } = launchBehavior; + + // Build the task message + // Only full-context fork mode inherits prior conversation state. + // Blank-session modes need the wrapper instructions and artifact-backed handoff. + const modeHint = agentDefs?.autoExit + ? "Complete your task autonomously. When you are finished, simply stop — your session ends automatically." + : "Complete your task. The user can interact with you at any time, and the session ends when the user exits the pane."; + const summaryInstruction = agentDefs?.autoExit + ? "Your FINAL assistant message should summarize what you accomplished." + : "Your FINAL assistant message (before the user exits) should summarize what you accomplished."; + // An agent with a non-empty subagent_agents list is granted the spawning + // toolset and may only spawn the listed agents (enforced via PI_SUBAGENT_ALLOWED). + const grantSpawning = !!(agentDefs?.subagentAgents && agentDefs.subagentAgents.length > 0); + const identity = agentDefs?.body ?? null; + const systemPromptMode = agentDefs?.systemPromptMode; + const identityInSystemPrompt = systemPromptMode && identity; + const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : ""; + const fullTask = inheritsConversationContext + ? params.task + : `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`; + // ── Claude Code CLI path ── + if (agentDefs?.cli === "claude") { + const sentinelFile = `/tmp/pi-claude-${id}-done`; + const pluginDir = join(SUBAGENTS_DIR, "plugin"); + + const cmdParts: string[] = []; + cmdParts.push(`PI_CLAUDE_SENTINEL=${shellEscape(sentinelFile)}`); + cmdParts.push("claude"); + cmdParts.push("--dangerously-skip-permissions"); + + if (existsSync(pluginDir)) { + cmdParts.push("--plugin-dir", shellEscape(pluginDir)); + } + + if (effectiveModel) { + cmdParts.push("--model", shellEscape(effectiveModel)); + } + + const sp = agentDefs.body; + if (sp) { + cmdParts.push("--append-system-prompt", shellEscape(sp)); + } + + // Always pass the task as the prompt — even for resumed sessions, + // the caller's task is the follow-up instruction. + cmdParts.push(shellEscape(params.task)); + + const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : ""; + const command = wrapWithCompletionFile(`${cdPrefix}${cmdParts.join(" ")}`, completionFile); + + const launchScriptName = `${name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "subagent"}-${id}.sh`; + const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName); + + sendLongCommand(surface, command, { + scriptPath: launchScriptFile, + scriptPreamble: [ + `# Claude Code subagent launch script for ${name}`, + `# Generated: ${new Date().toISOString()}`, + `# Surface: ${surface}`, + ].join("\n"), + }); + + const running: RunningSubagent = { + id, + name, + task: params.task, + agent: params.agent, + surface, + startTime, + sessionFile: subagentSessionFile, + launchScriptFile, + cli: "claude", + sentinelFile, + completionFile, + interactive: effectiveInteractive, + statusState: createStatusState({ + source: "claude", + startTimeMs: startTime, + }), + }; + + runningSubagents.set(id, running); + return running; + } + + // ── Pi CLI path ── + + // Build pi command + const parts: string[] = ["pi"]; + parts.push("--session", shellEscape(subagentSessionFile)); + + const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts"); + parts.push("-e", shellEscape(subagentDonePath)); + + // Resolve the config dir the child sees: a target-local .pi/agent/ wins, + // else the propagated global dir. Captured once so the launch env and the + // resume snapshot agree. + const resolvedAgentDir = + localAgentDir && existsSync(localAgentDir) + ? localAgentDir + : process.env.PI_CODING_AGENT_DIR ?? null; + + // Default-deny model: when an agent restricts its tools (or is granted the + // spawning toolset), we disable global extension discovery and re-enable only + // the extensions backing the whitelisted tools. Bare/fork spawns with no tool + // restriction keep their full default toolset and all global extensions. + const toolAllowlist = buildSubagentToolAllowlist(effectiveTools, { grantSpawning }); + + // Snapshot the fully-resolved sandbox beside the session file so a later + // `subagent_message({ name })` resume can replay the exact same + // restriction instead of relaunching pi with all global extensions + tools. + const loadout: SubagentLoadout = { + agent: params.agent ?? null, + toolAllowlist, + model: effectiveModel ?? null, + thinking: effectiveThinking ?? null, + systemPromptMode: systemPromptMode ?? null, + identity: identityInSystemPrompt ? identity : null, + spawnable: agentDefs?.subagentAgents ?? null, + autoExit: agentDefs?.autoExit ?? false, + cwd: effectiveCwd ?? null, + agentDir: resolvedAgentDir, + }; + writeSubagentLoadout(subagentSessionFile, loadout); + + // Apply model, identity, and the default-deny tool/extension restriction via + // the shared helper (same code path resume uses — they can't drift). + applySandboxToParts(parts, loadout, { artifactDir, name }); + + // Build env prefix: subagent identity + config dir propagation + spawn allowlist + const envParts: string[] = []; + + if (resolvedAgentDir) { + envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(resolvedAgentDir)}`); + } + + if (grantSpawning && agentDefs?.subagentAgents) { + envParts.push(`PI_SUBAGENT_ALLOWED=${shellEscape(agentDefs.subagentAgents.join(","))}`); + } + envParts.push(`PI_SUBAGENT_NAME=${shellEscape(name)}`); + if (params.agent) { + envParts.push(`PI_SUBAGENT_AGENT=${shellEscape(params.agent)}`); + } + if (agentDefs?.autoExit) { + envParts.push(`PI_SUBAGENT_AUTO_EXIT=1`); + } + envParts.push(`PI_SUBAGENT_SESSION=${shellEscape(subagentSessionFile)}`); + envParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`); + envParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`); + envParts.push(`PI_SUBAGENT_SURFACE=${shellEscape(surface)}`); + const envPrefix = envParts.join(" ") + " "; + + // Pass task and skill prompts to the sub-agent. + // Only full-context fork mode gets a direct task argument because it already + // inherits the parent conversation. Blank-session modes use artifact-backed + // handoff so the wrapper instructions arrive as the initial user message. + let taskArg: string; + if (launchBehavior.taskDelivery === "direct") { + taskArg = fullTask; + } else { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const safeName = name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") // strip everything except alphanumeric, spaces, hyphens + .replace(/\s+/g, "-") // spaces to hyphens + .replace(/-+/g, "-") // collapse multiple hyphens + .replace(/^-|-$/g, ""); // trim leading/trailing hyphens + const artifactName = `context/${safeName || "subagent"}-${timestamp}.md`; + const artifactPath = join(artifactDir, artifactName); + mkdirSync(dirname(artifactPath), { recursive: true }); + writeFileSync(artifactPath, fullTask, "utf8"); + taskArg = `@${artifactPath}`; + } + + for (const promptArg of buildPiPromptArgs({ + effectiveSkills, + taskDelivery: launchBehavior.taskDelivery, + taskArg, + })) { + parts.push(shellEscape(promptArg)); + } + + // Resolve cwd — param overrides agent default, supports absolute and relative paths. + // This was already computed above so session placement, PI_CODING_AGENT_DIR, and cd agree. + const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : ""; + + const piCommand = cdPrefix + envPrefix + parts.join(" "); + const command = wrapWithCompletionFile(piCommand, completionFile); + const launchScriptName = `${name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "subagent"}-${id}.sh`; + const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName); + sendLongCommand(surface, command, { + scriptPath: launchScriptFile, + scriptPreamble: [ + `# Subagent launch script for ${name}`, + `# Generated: ${new Date().toISOString()}`, + `# Session: ${subagentSessionFile}`, + `# Surface: ${surface}`, + ].join("\n"), + }); + + const running: RunningSubagent = { + id, + name, + task: params.task, + agent: params.agent, + surface, + startTime, + sessionFile: subagentSessionFile, + launchScriptFile, + activityFile, + completionFile, + interactive: effectiveInteractive, + statusState: createStatusState({ + source: "pi", + startTimeMs: startTime, + }), + }; + + runningSubagents.set(id, running); + return running; + } catch (error) { + runningSubagents.delete(id); + if (!surfacePreCreated) { + try { + closeSurface(surface); + } catch { + // Preserve the launch error; cleanup is best-effort. + } + } + throw error; + } +} + +/** + * Watch a launched subagent until it exits. Polls for completion, extracts + * the summary from the session file, cleans up the surface, + * and removes the entry from runningSubagents. + */ +const CLAUDE_SESSIONS_DIR = join( + process.env.HOME ?? "/tmp", + ".pi", "agent", "sessions", "claude-code", +); + +function copyClaudeSession(sentinelFile: string): string | null { + try { + const transcriptFile = sentinelFile + ".transcript"; + if (!existsSync(transcriptFile)) return null; + const transcriptPath = readFileSync(transcriptFile, "utf-8").trim(); + if (!transcriptPath || !existsSync(transcriptPath)) return null; + mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true }); + const filename = transcriptPath.split("/").pop() ?? `claude-${Date.now()}.jsonl`; + const dest = join(CLAUDE_SESSIONS_DIR, filename); + copyFileSync(transcriptPath, dest); + return filename; + } catch { + return null; + } +} + +/** + * Detect an `ask_question` signal from a still-running subagent and notify the + * orchestrator without ending the subagent. Each subagent has its own + * `${sessionFile}.ask` file and its own watcher, so parallel questions from + * multiple subagents are delivered independently. The file is deleted after + * delivery so it fires once per question (a subagent may ask again later). + */ +function deliverPendingQuestion(running: RunningSubagent): void { + const askFile = `${running.sessionFile}.ask`; + let payload: any = null; + try { + if (!existsSync(askFile)) return; + payload = JSON.parse(readFileSync(askFile, "utf-8")); + } catch { + // Malformed/partway-written file — drop it and move on. + } + try { + unlinkSync(askFile); + } catch {} + if (!payload?.question) return; + + const name = running.name; // unique per session (deduped at spawn) — targets the reply + const sessionId = existsSync(running.sessionFile) ? getSessionId(running.sessionFile) : null; + const elapsed = Math.floor((Date.now() - running.startTime) / 1000); + const replyHint = `\n\nReply with subagent_message({ name: "${name}", message: "…" }) — the same name works whether it is still running or has since exited. It stays open until you reply.`; + + sendWhenRuntimeReady((pi) => { + pi.sendMessage( + { + customType: "subagent_question", + content: `Sub-agent "${name}" asks (${formatElapsed(elapsed)}):\n\n${payload.question}${replyHint}`, + display: true, + details: { + name, + agent: running.agent, + question: payload.question, + ...(sessionId ? { sessionId } : {}), + }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + }); +} + +async function watchSubagent( + running: RunningSubagent, + signal: AbortSignal, +): Promise { + const { name, task, surface, startTime, sessionFile } = running; + + try { + const result = await pollForExit(surface, signal, { + interval: 1000, + sessionFile, + sentinelFile: running.sentinelFile, + completionFile: running.completionFile, + onTick() { + observeRunningSubagent(running); + deliverPendingQuestion(running); + }, + }); + + const elapsed = Math.floor((Date.now() - startTime) / 1000); + + if (running.cli === "claude") { + // Claude Code result extraction + let summary = ""; + + if (running.sentinelFile) { + try { + summary = readFileSync(running.sentinelFile, "utf-8").trim(); + } catch {} + } + + if (!summary) { + summary = readScreen(surface, 200) + .replace(/__SUBAGENT_DONE_\d+__/, "") + .trimEnd(); + } + + if (!summary) { + summary = result.exitCode !== 0 + ? `Claude Code exited with code ${result.exitCode}` + : "Claude Code exited without output"; + } + + // Copy Claude session transcript + let sessionId: string | null = null; + if (running.sentinelFile) { + sessionId = copyClaudeSession(running.sentinelFile); + try { unlinkSync(running.sentinelFile); } catch {} + try { unlinkSync(running.sentinelFile + ".transcript"); } catch {} + } + + closeSurface(surface); + runningSubagents.delete(running.id); + + return { name, task, summary, exitCode: result.exitCode, elapsed, ...(sessionId ? { claudeSessionId: sessionId } : {}) }; + } + + // Pi subagent result extraction + let summary: string; + if (existsSync(sessionFile)) { + const allEntries = getNewEntries(sessionFile, 0); + summary = + findLastAssistantMessage(allEntries) ?? + (result.errorMessage + ? `Subagent error: ${result.errorMessage}` + : result.exitCode !== 0 + ? `Sub-agent exited with code ${result.exitCode}` + : "Sub-agent exited without output"); + } else { + summary = result.errorMessage + ? `Subagent error: ${result.errorMessage}` + : result.exitCode !== 0 + ? `Sub-agent exited with code ${result.exitCode}` + : "Sub-agent exited without output"; + } + + const stats = existsSync(sessionFile) ? summarizeSessionStats(sessionFile) : null; + const subagentSessionId = existsSync(sessionFile) ? getSessionId(sessionFile) : null; + + closeSurface(surface); + runningSubagents.delete(running.id); + + return { + name, + task, + summary, + sessionFile, + ...(subagentSessionId ? { sessionId: subagentSessionId } : {}), + exitCode: result.exitCode, + elapsed, + ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), + ...(stats ? { stats } : {}), + }; + } catch (err: any) { + try { + closeSurface(surface); + } catch {} + runningSubagents.delete(running.id); + + if (signal.aborted) { + return { + name, + task, + summary: "Subagent cancelled.", + exitCode: 1, + elapsed: Math.floor((Date.now() - startTime) / 1000), + error: "cancelled", + sessionFile, + }; + } + return { + name, + task, + summary: `Subagent error: ${err?.message ?? String(err)}`, + exitCode: 1, + elapsed: Math.floor((Date.now() - startTime) / 1000), + error: err?.message ?? String(err), + }; + } +} + +export default function subagentsExtension(pi: ExtensionAPI) { + const runtimeToken = Symbol("pi-subagents/runtime"); + // The newest registered extension instance is the only one allowed to bind + // a session. This prevents a delayed old session_start from rebinding stale + // callbacks after a replacement runtime has already started. + sharedRuntime.registeredRuntimeToken = runtimeToken; + + // Bind only after Pi has installed the fresh runtime. During /reload the old + // session_shutdown handler clears these references first, so watcher results + // cannot enqueue a turn through stale extension APIs. + pi.on("session_start", (_event, ctx) => { + if (sharedRuntime.registeredRuntimeToken !== runtimeToken) return; + + // A fresh runtime may start before the old runtime emits shutdown. Drop + // old UI ownership and intervals before binding the fresh context. + if (sharedRuntime.activeRuntimeToken !== runtimeToken) { + sharedRuntime.statusWidgetClaim = null; + clearRefreshIntervals(); + scheduleStandaloneWidgetSync(); + } + + sharedRuntime.activeRuntimeToken = runtimeToken; + sharedRuntime.latestPi = pi; + sharedRuntime.latestCtx = ctx; + + if (runningSubagents.size > 0) { + startWidgetRefresh(); + startStatusRefresh(); + } + // A completion can land in the short gap between old shutdown and this + // fresh session_start. Flush on the next macrotask, after reload finishes. + schedulePendingDeliveryFlush(); + }); + + pi.on("session_shutdown", (event, _ctx) => { + // A delayed event from an invalidated extension instance must not tear down + // state already rebound to the replacement runtime. + if (sharedRuntime.activeRuntimeToken !== runtimeToken) return; + + // The callback belongs to the runtime/UI that is shutting down. A fresh + // runtime must explicitly claim again rather than inheriting a stale host + // callback from the old session. + clearStandaloneWidget(); + sharedRuntime.statusWidgetClaim = null; + if (sharedRuntime.registeredRuntimeToken === runtimeToken) { + sharedRuntime.registeredRuntimeToken = null; + } + sharedRuntime.activeRuntimeToken = null; + sharedRuntime.latestPi = null; + sharedRuntime.latestCtx = null; + clearRefreshIntervals(); + + // /reload keeps the same parent session and terminal surfaces. Existing + // watchers continue and rebind to the fresh runtime in session_start. + if (event.reason === "reload") return; + + // Real session replacement/quit owns a different lifecycle. Cancel those + // children and suppress the synthetic cancellation result; there is no + // valid destination session to receive it. + sharedRuntime.pendingDeliveries.length = 0; + for (const agent of runningSubagents.values()) { + agent.suppressDelivery = true; + agent.abortController?.abort(); + } + runningSubagents.clear(); + }); + + // The spawning tools are always registered here. Whether a child process can + // actually see/use them is governed by the parent's `--tools` allowlist and + // by which extensions are loaded into the child (default-deny --no-extensions + // + explicit -e). See launchSubagent(). + + // ── subagent tool ── + pi.registerTool({ + name: "subagent", + label: "Subagent", + description: + "Spawn a sub-agent in a dedicated terminal multiplexer pane. " + + "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + + "When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + + "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " + + "DO NOT fabricate, assume, or summarize results after calling this tool. " + + "After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.", + promptSnippet: + "Spawn a sub-agent in a dedicated terminal multiplexer pane. " + + "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + + "When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + + "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " + + "DO NOT fabricate, assume, or summarize results after calling this tool. " + + "After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.", + parameters: SubagentParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + // Prevent self-spawning (e.g. planner spawning another planner) + const currentAgent = process.env.PI_SUBAGENT_AGENT; + if (params.agent && currentAgent && params.agent === currentAgent) { + return { + content: [ + { + type: "text", + text: `You are the ${currentAgent} agent — do not start another ${currentAgent}. You were spawned to do this work yourself. Complete the task directly.`, + }, + ], + details: { error: "self-spawn blocked" }, + }; + } + + // Strict whitelist at every depth. The caller's permitted set is: + // • a restricted subagent (PI_SUBAGENT_ALLOWED) → only its pinned agents; + // • a top-level session → every discoverable agent, i.e. exactly what + // `subagents_list` shows. + // Every spawn must name an agent in that set. The lone exception is a + // top-level `fork: true` clone, which has no role and inherits the + // caller's own already-trusted toolset. Without this guard a missing or + // unknown `agent` silently launches an unrestricted, full-toolset child. + const permittedAgents = SUBAGENT_ALLOWLIST + ? [...SUBAGENT_ALLOWLIST] + : discoverAgentDefinitions().map((a) => a.name); + const permittedSet = new Set(permittedAgents); + const permittedList = permittedAgents.join(", ") || "(none)"; + + if (!params.agent) { + return { + content: [ + { + type: "text", + text: + `You must specify which agent to spawn via the "agent" field. ` + + `Available agents: ${permittedList}.`, + }, + ], + details: { error: "agent required" }, + }; + } else if (!permittedSet.has(params.agent)) { + return { + content: [ + { + type: "text", + text: + `You may not spawn the "${params.agent}" agent — it is not ` + + `${SUBAGENT_ALLOWLIST ? "in your allowlist" : "a known agent"}. ` + + `Available agents: ${permittedList}.`, + }, + ], + details: { + error: SUBAGENT_ALLOWLIST ? "agent not in allowlist" : "unknown agent", + }, + }; + } + + // Validate prerequisites (need mux + a session file to derive the + // artifact dir that hosts this session's name registry). + if (!isMuxAvailable()) { + return muxUnavailableResult(); + } + + if (!ctx.sessionManager.getSessionFile()) { + return { + content: [ + { + type: "text", + text: "Error: no session file. Start pi with a persistent session to use subagents.", + }, + ], + details: { error: "no session file" }, + }; + } + + // This spawner session's artifact dir hosts its persistent name + // registry (artifacts//subagent-registry.json). + const parentArtifactDir = getArtifactDir( + ctx.sessionManager.getSessionDir(), + ctx.sessionManager.getSessionId(), + ); + + // Default the cosmetic pane label to the agent name when omitted, + // disambiguating against running subagents, in-flight reservations, and + // every name already in the registry — so names stay unique across the + // whole session, running or finished. Reserve the chosen name + // synchronously (before any await) so parallel spawns don't collide. + let reservedName: string | null = null; + if (!params.name?.trim()) { + const registryNames = new Set(Object.keys(readNameRegistry(parentArtifactDir))); + params.name = uniqueRunningName(params.agent, registryNames); + reservedName = params.name; + reservedNames.add(reservedName); + } + + // Launch the subagent (creates pane, sends command). Release the name + // reservation once it registers in runningSubagents (or launch fails) — + // from then on uniqueRunningName tracks it via the running map. + let running; + try { + running = await launchSubagent(params, ctx); + } finally { + if (reservedName) reservedNames.delete(reservedName); + } + + // Persist name → session so subagent_message({ name }) can resume this + // subagent after it finishes (and after a pi restart). Done at launch, + // not completion, so the handle exists even if the parent dies mid-run. + registerName(parentArtifactDir, running.name, { + sessionFile: running.sessionFile, + sessionId: getSessionId(running.sessionFile), + }); + + // Create a separate AbortController for the watcher + // (the tool's signal completes when we return) + const watcherAbort = new AbortController(); + running.abortController = watcherAbort; + + // Start widget refresh and status supervision when the first agent launches + startWidgetRefresh(); + startStatusRefresh(); + + // Fire-and-forget: start watching in background. Delivery resolves the + // current Pi runtime at completion time so /reload cannot retain a stale + // ExtensionAPI closure. + watchSubagent(running, watcherAbort.signal) + .then((result) => { + updateWidget(); // reflect removal from Map immediately + if (running.suppressDelivery) return; + + const presentation = resolveResultPresentation(result, running.name); + sendWhenRuntimeReady((currentPi) => { + currentPi.sendMessage( + { + customType: "subagent_result", + content: presentation, + display: true, + details: { + name: running.name, + task: running.task, + agent: running.agent, + exitCode: result.exitCode, + elapsed: result.elapsed, + sessionFile: result.sessionFile, + ...(result.sessionId ? { sessionId: result.sessionId } : {}), + ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), + ...(result.claudeSessionId ? { claudeSessionId: result.claudeSessionId } : {}), + ...(result.stats ? { stats: result.stats } : {}), + }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + }); + }) + .catch((err) => { + updateWidget(); + if (running.suppressDelivery) return; + sendWhenRuntimeReady((currentPi) => { + currentPi.sendMessage( + { + customType: "subagent_result", + content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`, + display: true, + details: { name: running.name, task: running.task, error: err?.message }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + }); + }); + + // Return immediately + return { + content: [ + { + type: "text", + text: + `Sub-agent "${params.name}" launched and is now running in the background. ` + + `Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` + + `The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` + + `Until then, move on to other work or tell the user you're waiting.`, + }, + ], + details: { + id: running.id, + name: params.name, + task: params.task, + agent: params.agent, + sessionFile: running.sessionFile, + launchScriptFile: running.launchScriptFile, + status: "started", + }, + }; + }, + + renderCall(args, theme) { + const partialArgs = args as Record; + const agentName = + typeof partialArgs.agent === "string" && partialArgs.agent ? partialArgs.agent : ""; + const name = + typeof partialArgs.name === "string" && partialArgs.name + ? partialArgs.name + : agentName || "(unnamed)"; + const task = typeof partialArgs.task === "string" ? partialArgs.task : ""; + // Only show the agent tag separately when a distinct cosmetic name was given. + const agent = + agentName && name !== agentName ? theme.fg("dim", ` (${agentName})`) : ""; + const cwdHint = typeof partialArgs.cwd === "string" && partialArgs.cwd + ? theme.fg("dim", ` in ${partialArgs.cwd}`) + : ""; + let text = + "○ " + + theme.fg("toolTitle", theme.bold(name)) + + agent + + cwdHint; + + // Show a one-line task preview. renderCall is called repeatedly as the + // LLM generates tool arguments, so args.task grows token by token. + // We keep it compact here — Ctrl+O on renderResult expands the full content. + if (task) { + const firstLine = task.split("\n").find((l: string) => l.trim()) ?? ""; + const preview = firstLine.length > 100 ? firstLine.slice(0, 100) + "…" : firstLine; + if (preview) { + text += "\n" + theme.fg("toolOutput", preview); + } + const totalLines = task.split("\n").length; + if (totalLines > 1) { + text += theme.fg("muted", ` (${totalLines} lines)`); + } + } + + return new Text(text, 0, 0); + }, + + renderResult(result, _opts, theme) { + const details = result.details as any; + const name = details?.name ?? "(unnamed)"; + + // "Started" result — tool returned immediately + if (details?.status === "started") { + return new Text( + theme.fg("accent", "⟳") + + " " + + theme.fg("toolTitle", theme.bold(name)) + + theme.fg("dim", " — started"), + 0, + 0, + ); + } + + // Fallback (shouldn't happen) + const firstContent = result.content[0]; + const text = firstContent?.type === "text" ? firstContent.text : ""; + return new Text(theme.fg("dim", text), 0, 0); + }, + }); + + // ── subagents_list tool ── + pi.registerTool({ + name: "subagents_list", + label: "List Subagents", + description: + "List all available subagent definitions. " + + "Scans project-local .pi/agents/ and global ~/.pi/agent/agents/. " + + "Project-local agents override global ones with the same name.", + promptSnippet: + "List all available subagent definitions. " + + "Scans project-local .pi/agents/ and global ~/.pi/agent/agents/. " + + "Project-local agents override global ones with the same name.", + parameters: Type.Object({}), + + async execute() { + const list = discoverAgentDefinitions().filter((agent) => !agent.disableModelInvocation); + + if (list.length === 0) { + return { + content: [{ type: "text", text: "No subagent definitions found." }], + details: { agents: [] }, + }; + } + + const lines = list.map((a) => { + const badge = a.source === "project" ? " (project)" : ""; + const desc = a.description ? ` — ${a.description}` : ""; + const model = a.model ? ` [${a.model}]` : ""; + return `• ${a.name}${badge}${model}${desc}`; + }); + + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { agents: list }, + }; + }, + + renderResult(result, _opts, theme) { + const details = result.details as any; + const agents = details?.agents ?? []; + if (agents.length === 0) { + return new Text(theme.fg("dim", "No subagent definitions found."), 0, 0); + } + const lines = agents.map((a: any) => { + const badge = a.source === "project" ? theme.fg("accent", " (project)") : ""; + const desc = a.description ? theme.fg("dim", ` — ${a.description}`) : ""; + const model = a.model ? theme.fg("dim", ` [${a.model}]`) : ""; + return ` ${theme.fg("toolTitle", theme.bold(a.name))}${badge}${model}${desc}`; + }); + return new Text(lines.join("\n"), 0, 0); + }, + }); + + + + // ── subagent_message tool ── + pi.registerTool({ + name: "subagent_message", + label: "Message Subagent", + description: + "Send a message to a subagent by name. Names are unique within your session and persist after a subagent finishes, " + + "so the SAME name works whether the subagent is running or finished: if it is still running, your message steers its live session; " + + "if it has finished, your message resumes that session and continues it. " + + "`name` and `message` are both required. " + + "Steering a running subagent returns immediately with a local acknowledgement and does NOT, by itself, emit a new result. " + + "Resuming is a fire-and-forget async call: when the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up. " + + "DO NOT poll, sleep, tail logs, or read session files to detect completion — the harness handles delivery. " + + "DO NOT fabricate or assume results. After calling, either end your turn or work on other independent tasks.", + promptSnippet: + "Message a subagent by name: steers it if running, resumes it if finished (same name either way). " + + "`name` and `message` are required. Steering returns immediately; resuming delivers its result later as a steer message. " + + "Do not poll or fabricate results.", + parameters: Type.Object({ + name: Type.String({ + description: + "Exact display name of the subagent. Steers it if it is still running; resumes its session if it has finished.", + }), + message: Type.String({ + description: + "The message to deliver: a follow-up instruction for a running subagent, or the next task for a resumed session.", + }), + }), + + renderCall(args, theme) { + const target = args.name ?? "(unknown)"; + return new Text( + "○ " + theme.fg("toolTitle", theme.bold(target)) + theme.fg("dim", " — message"), + 0, + 0, + ); + }, + + renderResult(result, _opts, theme) { + const details = result.details as any; + + if (details?.status === "steered") { + return new Text( + theme.fg("success", "✓") + + " " + + theme.fg("toolTitle", theme.bold(details.name ?? "subagent")) + + theme.fg("dim", " — message delivered"), + 0, + 0, + ); + } + + if (details?.status === "started") { + return new Text( + theme.fg("accent", "⟳") + + " " + + theme.fg("toolTitle", theme.bold(details.name ?? "Resume")) + + theme.fg("dim", " — resumed"), + 0, + 0, + ); + } + + // Fallback / error + const firstContent = result.content[0]; + const text = firstContent?.type === "text" ? firstContent.text : ""; + return new Text(theme.fg("dim", text), 0, 0); + }, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const requestedName = params.name?.trim(); + if (!requestedName) { + const err = "Provide the subagent's `name` to steer (if running) or resume (if finished)."; + return { content: [{ type: "text" as const, text: err }], details: { error: err } }; + } + + if (!isMuxAvailable()) { + return muxUnavailableResult(); + } + + // ── Steer a running subagent ── + // A name that matches a currently-running subagent always steers it. + const runningMatch = Array.from(runningSubagents.values()).find((r) => r.name === requestedName); + if (runningMatch) { + return handleSubagentSteer({ name: requestedName, message: params.message }); + } + + // ── Resume a finished session by name ── + const message = params.message; + const name = requestedName; // identity preservation: the resumed run reclaims its name + const { autoExit, interactive } = resolveResumeLaunchBehavior(); + const startTime = Date.now(); + const id = Math.random().toString(16).slice(2, 10); + + // Resolve the name to its session file via this session's registry. + const parentArtifactDir = getArtifactDir( + ctx.sessionManager.getSessionDir(), + ctx.sessionManager.getSessionId(), + ); + const entry = resolveNameInRegistry(parentArtifactDir, requestedName); + if (!entry) { + const known = Object.keys(readNameRegistry(parentArtifactDir)); + const err = + `No subagent named "${requestedName}" in this session. ` + + (known.length > 0 + ? `Known subagents: ${known.join(", ")}.` + : "No subagents have been spawned in this session yet."); + return { content: [{ type: "text" as const, text: err }], details: { error: err } }; + } + + const sessionPath = entry.sessionFile; + if (!sessionPath || !existsSync(sessionPath)) { + const err = + `Subagent "${requestedName}" is registered but its session file is gone ` + + `(${sessionPath}). It cannot be resumed. Spawn a fresh subagent instead.`; + return { content: [{ type: "text" as const, text: err }], details: { error: err } }; + } + + // Guard: never resume a session that is still running — two processes + // mutating the same .jsonl corrupts it. Steer it by name instead. + for (const r of runningSubagents.values()) { + if (resolve(r.sessionFile) === resolve(sessionPath)) { + const err = `Subagent "${requestedName}" is still running as "${r.name}". Your message will steer it; resending as a steer.`; + return handleSubagentSteer({ name: r.name, message: params.message }); + } + } + + // Reconstruct the sandbox from the snapshot written at spawn time. + // Without it we cannot safely resume: relaunching bare would load every + // global extension + the full toolset. Refuse rather than escalate. + const loadout = readSubagentLoadout(sessionPath); + if (!loadout) { + const err = + `Cannot safely resume "${requestedName}": no sandbox snapshot found for this session ` + + `(it predates sandboxed resume, or its .loadout.json sidecar was removed). ` + + `Resuming would relaunch with all global extensions and the full toolset, so this is refused. ` + + `Re-run the task as a fresh subagent instead.`; + return { content: [{ type: "text" as const, text: err }], details: { error: err } }; + } + + const resumedSessionId = entry.sessionId ?? getSessionId(sessionPath) ?? requestedName; + + // Record entry count before resuming so we can extract new messages. + // Count lines cheaply (no per-line JSON.parse) so resuming a large + // transcript doesn't block the UI. + const entryCountBefore = countSessionEntryLines(sessionPath); + + const surface = createSurface(name); + await new Promise((resolve) => setTimeout(resolve, getShellReadyDelayMs())); + + try { + // Build pi resume command + const parts = ["pi", "--session", shellEscape(sessionPath)]; + + // Load subagent-done extension so the agent can self-terminate if needed + const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts"); + parts.push("-e", shellEscape(subagentDonePath)); + + const sessionId = ctx.sessionManager.getSessionId(); + const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId); + const activityFile = getSubagentActivityFile(artifactDir, id); + const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`); + mkdirSync(dirname(activityFile), { recursive: true }); + mkdirSync(dirname(completionFile), { recursive: true }); + + // Replay the model, identity, and default-deny tool/extension sandbox. + applySandboxToParts(parts, loadout, { artifactDir, name }); + + let resumeMsgFile: string | undefined; + if (params.message) { + const msgTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + resumeMsgFile = join( + artifactDir, + "subagent-resume", + `${name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "resume"}-${msgTimestamp}.md`, + ); + mkdirSync(dirname(resumeMsgFile), { recursive: true }); + writeFileSync(resumeMsgFile, message, "utf8"); + parts.push(shellEscape(`@${resumeMsgFile}`)); + } + + // Build env prefix — replay the snapshot's config dir + spawn whitelist + // so the resumed process resolves the same agents/extensions and keeps + // the same nested-spawn restriction it originally ran with. + const resumeEnvParts: string[] = []; + const resumeAgentDir = loadout.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? null; + if (resumeAgentDir) { + resumeEnvParts.push(`PI_CODING_AGENT_DIR=${shellEscape(resumeAgentDir)}`); + } + if (loadout.spawnable && loadout.spawnable.length > 0) { + resumeEnvParts.push(`PI_SUBAGENT_ALLOWED=${shellEscape(loadout.spawnable.join(","))}`); + } + if (loadout.agent) { + resumeEnvParts.push(`PI_SUBAGENT_AGENT=${shellEscape(loadout.agent)}`); + } + resumeEnvParts.push(`PI_SUBAGENT_NAME=${shellEscape(name)}`); + resumeEnvParts.push(`PI_SUBAGENT_SESSION=${shellEscape(sessionPath)}`); + resumeEnvParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`); + resumeEnvParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`); + if (autoExit) { + resumeEnvParts.push(`PI_SUBAGENT_AUTO_EXIT=1`); + } + const resumeEnvPrefix = resumeEnvParts.join(" ") + " "; + + // Resume in the subagent's original cwd so its tools (safe_bash, edits) + // operate where they did before. + const resumeCdPrefix = loadout.cwd ? `cd ${shellEscape(loadout.cwd)} && ` : ""; + + const command = wrapWithCompletionFile( + `${resumeCdPrefix}${resumeEnvPrefix}${parts.join(" ")}`, + completionFile, + ); + const launchScriptFile = join( + artifactDir, + "subagent-scripts", + `${name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "resume"}-resume-${Date.now()}.sh`, + ); + sendLongCommand(surface, command, { + scriptPath: launchScriptFile, + scriptPreamble: [ + `# Subagent resume script for ${name}`, + `# Generated: ${new Date().toISOString()}`, + `# Session: ${sessionPath}`, + `# Surface: ${surface}`, + ...(resumeMsgFile ? [`# Resume message file: ${resumeMsgFile}`] : []), + ].join("\n"), + }); + + // Register as a running subagent for widget tracking + const running: RunningSubagent = { + id, + name, + task: message, + surface, + startTime, + sessionFile: sessionPath, + launchScriptFile, + activityFile, + completionFile, + interactive, + statusState: createStatusState({ + source: "pi", + startTimeMs: startTime, + }), + }; + runningSubagents.set(id, running); + startWidgetRefresh(); + startStatusRefresh(); + + // Fire-and-forget watcher + const watcherAbort = new AbortController(); + running.abortController = watcherAbort; + + watchSubagent(running, watcherAbort.signal) + .then((result) => { + updateWidget(); + if (running.suppressDelivery) return; + + const allEntries = getNewEntries(sessionPath, entryCountBefore); + const summary = findLastAssistantMessage(allEntries) ?? + (result.errorMessage + ? `Subagent error: ${result.errorMessage}` + : result.exitCode !== 0 + ? `Resumed session exited with code ${result.exitCode}` + : "Resumed session exited without new output"); + const presentation = resolveResultPresentation( + { ...result, summary, sessionFile: sessionPath, sessionId: resumedSessionId }, + name, + ); + + sendWhenRuntimeReady((currentPi) => { + currentPi.sendMessage( + { + customType: "subagent_result", + content: presentation, + display: true, + details: { + name, + task: message, + exitCode: result.exitCode, + elapsed: result.elapsed, + sessionFile: sessionPath, + sessionId: resumedSessionId, + ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), + }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + }); + }) + .catch((err) => { + updateWidget(); + if (running.suppressDelivery) return; + sendWhenRuntimeReady((currentPi) => { + currentPi.sendMessage( + { + customType: "subagent_result", + content: `Resume error: ${err?.message ?? String(err)}`, + display: true, + details: { name, error: err?.message }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + }); + }); + + return { + content: [{ type: "text", text: `Session "${name}" resumed.` }], + details: { + id, + name, + sessionId: resumedSessionId, + sessionFile: sessionPath, + launchScriptFile, + status: "started", + }, + }; + } catch (error) { + runningSubagents.delete(id); + try { + closeSurface(surface); + } catch { + // Preserve the resume error; cleanup is best-effort. + } + throw error; + } + }, + }); + + // /subagent command — spawn a subagent by name + pi.registerCommand("subagent", { + description: "Spawn a subagent: /subagent ", + handler: async (args, ctx) => { + const trimmed = args.trim(); + if (!trimmed) { + ctx.ui.notify("Usage: /subagent [task]", "warning"); + return; + } + + const spaceIdx = trimmed.indexOf(" "); + const agentName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); + const task = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); + + const defs = loadAgentDefaults(agentName); + if (!defs) { + ctx.ui.notify( + `Agent "${agentName}" not found in ~/.pi/agent/agents/ or .pi/agents/`, + "error", + ); + return; + } + + const taskText = task || `You are the ${agentName} agent. Wait for instructions.`; + const displayName = agentName[0].toUpperCase() + agentName.slice(1); + const toolCall = `Use subagent with agent: "${agentName}", name: "${displayName}", task: ${JSON.stringify(taskText)}`; + pi.sendUserMessage(toolCall); + }, + }); + + // ── subagent_result message renderer ── + pi.registerMessageRenderer("subagent_result", (message, options, theme) => { + const details = message.details as any; + if (!details) return undefined; + + return { + invalidate() {}, + render(width: number): string[] { + const name = details.name ?? "subagent"; + const exitCode = details.exitCode ?? 0; + const errorMessage = typeof details.errorMessage === "string" ? details.errorMessage : ""; + const failed = exitCode !== 0 || !!errorMessage; + const elapsed = details.elapsed != null ? formatElapsed(details.elapsed) : "?"; + const bgFn = failed + ? (text: string) => theme.bg("toolErrorBg", text) + : (text: string) => theme.bg("toolSuccessBg", text); + const stats = (details.stats ?? null) as SessionStats | null; + const icon = failed + ? theme.fg("error", "✗") + : theme.fg("success", "✓"); + const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : ""; + const modelTag = stats?.model ? theme.fg("dim", ` (${stats.model})`) : ""; + const titleSegment = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag}${modelTag} ${theme.fg("dim", "—")} `; + + // Success: icon already conveys "completed", so show "N tools · duration" + // like the in-process extension. Failure: surface the failure reason. + let header: string; + if (failed) { + const reason = errorMessage ? "failed (provider/agent error)" : `failed (exit ${exitCode})`; + header = `${titleSegment}${theme.fg("error", reason)} ${theme.fg("dim", `· ${elapsed}`)}`; + } else { + const toolPart = stats ? `${stats.toolCount} tools · ${elapsed}` : elapsed; + header = `${titleSegment}${theme.fg("dim", toolPart)}`; + } + + // Usage line: ↑in ↓out R… W… $cost · context-gauge (color-coded by %). + let usageLine: string | null = null; + if (stats) { + const segs = formatUsageSegments(stats).map((s) => theme.fg("dim", s)); + if (stats.contextTokens > 0) { + const window = contextWindowFor(stats.model); + const ctxStr = formatContextUsage(stats.contextTokens, window); + const pct = window ? (stats.contextTokens / window) * 100 : 0; + const coloredCtx = + pct > 90 ? theme.fg("error", ctxStr) : pct > 70 ? theme.fg("warning", ctxStr) : theme.fg("dim", ctxStr); + segs.push(coloredCtx); + } + if (segs.length > 0) usageLine = segs.join(theme.fg("dim", " ")); + } + + const rawContent = typeof message.content === "string" ? message.content : ""; + + // Clean summary (remove follow-up ref and leading label for display) + const summary = rawContent + .replace(/\n\nFollow up with subagent_message[\s\S]+$/, "") + .replace(`Sub-agent "${name}" completed (${elapsed}).\n\n`, "") + .replace(`Sub-agent "${name}" failed (exit code ${exitCode}).\n\n`, "") + .replace( + new RegExp( + `^Sub-agent "${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" failed after ${elapsed} \\(provider/agent error — auto-retry exhausted\\)\\.\\n\\n`, + ), + "", + ); + + // Build content for the box + const contentLines = [header]; + if (usageLine) contentLines.push(usageLine); + + if (options.expanded) { + // Full view: complete summary + session info + if (summary) { + for (const line of summary.split("\n")) { + contentLines.push(line.slice(0, width - 6)); + } + } + if (details.name || details.sessionFile) { + contentLines.push(""); + if (details.name) { + contentLines.push( + theme.fg( + "dim", + `Follow up: subagent_message({ name: "${details.name}", message: "…" })`, + ), + ); + } + if (details.sessionFile) { + contentLines.push(theme.fg("muted", `Session file: ${details.sessionFile}`)); + } + } + } else { + // Collapsed: preview + expand hint + if (summary) { + const previewLines = summary.split("\n").slice(0, 5); + for (const line of previewLines) { + contentLines.push(theme.fg("dim", line.slice(0, width - 6))); + } + const totalLines = summary.split("\n").length; + if (totalLines > 5) { + contentLines.push(theme.fg("muted", `… ${totalLines - 5} more lines`)); + } + } + contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand"))); + } + + // Render via Box for background + padding, with blank line above for separation + const box = new Box(1, 1, bgFn); + box.addChild(new Text(contentLines.join("\n"), 0, 0)); + return ["", ...box.render(width)]; + }, + }; + }); + + // ── subagent_status message renderer ── + pi.registerMessageRenderer("subagent_status", (message, options, theme) => { + const details = message.details as any; + const lines = Array.isArray(details?.lines) ? details.lines : []; + const overflow = typeof details?.overflow === "number" ? details.overflow : 0; + if (lines.length === 0 && overflow === 0) return undefined; + + return { + invalidate() {}, + render(width: number): string[] { + const lineWidth = Math.max(0, width - 6); + const contentLines = [ + `${theme.fg("accent", "•")} ${theme.fg("toolTitle", theme.bold("Subagent status"))}`, + ...lines.map((line: string) => theme.fg("dim", truncateToWidth(line, lineWidth))), + ]; + + if (overflow > 0) { + contentLines.push(theme.fg("muted", `+${overflow} more running.`)); + } + if (!options.expanded) { + contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand"))); + } + + const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text)); + box.addChild(new Text(contentLines.join("\n"), 0, 0)); + return ["", ...box.render(width)]; + }, + }; + }); + + // ── subagent_question message renderer ── + pi.registerMessageRenderer("subagent_question", (message, options, theme) => { + const details = message.details as any; + if (!details) return undefined; + + return { + invalidate() {}, + render(width: number): string[] { + const name = details.name ?? "subagent"; + const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : ""; + const bgFn = (text: string) => theme.bg("toolSuccessBg", text); + + const icon = theme.fg("accent", "?"); + const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "— asks a question")}`; + + const contentLines = [header]; + + if (options.expanded) { + contentLines.push(""); + contentLines.push(details.question ?? ""); + contentLines.push(""); + contentLines.push( + theme.fg("dim", `Reply: subagent_message({ name: "${name}", message: "…" })`), + ); + } else { + const preview = (details.question ?? "").split("\n")[0].slice(0, width - 10); + contentLines.push(theme.fg("dim", preview)); + contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand"))); + } + + const box = new Box(1, 1, bgFn); + box.addChild(new Text(contentLines.join("\n"), 0, 0)); + return ["", ...box.render(width)]; + }, + }; + }); + +} +// test diff --git a/extensions/interactive-subagents/pi-extension/subagents/orca.ts b/extensions/interactive-subagents/pi-extension/subagents/orca.ts new file mode 100644 index 0000000..a82edd3 --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/orca.ts @@ -0,0 +1,294 @@ +/** + * Orca surface backend. Subagents run in unfocused terminal tabs in the + * current Orca worktree. + * + * Detection relies on ORCA_TERMINAL_HANDLE, which Orca exports inside its + * managed terminals. Inside those terminals `orca` resolves to the Orca CLI + * shim; outside them bare `orca` on Linux is usually the GNOME screen reader, + * so resolution explicitly rejects /usr/bin/orca. + */ +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** GNOME screen reader path — never a valid Orca CLI. */ +const GNOME_SCREEN_READER = "/usr/bin/orca"; + +let cachedCli: string | null | undefined; + +/** Terminal handles created as whole tabs (vs splits), mapped for tab close. */ +const tabByHandle = new Map(); + +interface OrcaResponse { + ok?: boolean; + error?: { message?: unknown }; +} + +interface OrcaTerminalCreated extends OrcaResponse { + result?: { + terminal?: { handle?: unknown; tabId?: unknown }; + }; +} + +interface OrcaSplitCreated extends OrcaResponse { + result?: { + split?: { handle?: unknown }; + }; +} + +interface OrcaTerminalRead extends OrcaResponse { + result?: { + terminal?: { tail?: unknown; status?: unknown }; + }; +} + +/** + * Marker echoed by the startup command once the new terminal has cd'd into + * the target directory and is about to exec bash. Written with a quote split + * (`__PI_SURFACE_''READY__`) so the typed command line on screen never + * matches, only the echoed output does. + */ +const READY_MARKER = "__PI_SURFACE_READY__"; + +function resolveCli(): string | null { + if (cachedCli !== undefined) return cachedCli; + + const override = process.env.ORCA_CLI_COMMAND?.trim(); + if (override) { + cachedCli = override; + return cachedCli; + } + + try { + const resolved = execFileSync("sh", ["-c", "command -v orca"], { encoding: "utf8" }).trim(); + cachedCli = resolved && resolved !== GNOME_SCREEN_READER ? "orca" : null; + } catch { + cachedCli = null; + } + return cachedCli; +} + +export function isAvailable(): boolean { + return !!process.env.ORCA_TERMINAL_HANDLE && resolveCli() !== null; +} + +export function setupHint(): string { + return "Start pi inside an Orca-managed terminal with the `orca` CLI on PATH."; +} + +function requireOrca(): string { + const cli = resolveCli(); + if (!isAvailable() || !cli) { + throw new Error(`Orca is required for this surface. ${setupHint()}`); + } + return cli; +} + +function shellEscape(value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'"; +} + +/** Startup command that lands the new terminal in pi's cwd with a bash shell. */ +function startupCommand(): string { + return `cd ${shellEscape(process.cwd())} && echo __PI_SURFACE_''READY__ && exec bash`; +} + +function sleepSync(seconds: number): void { + try { + execFileSync("sleep", [String(seconds)], { stdio: "ignore" }); + } catch {} +} + +/** Probe marker proving the shell reads and executes typed input. */ +const SHELL_UP_MARKER = "__PI_SHELL_UP__"; + +/** + * Block until the new terminal's shell is genuinely interactive. + * + * Two races exist at startup: input typed before the initial shell execs bash + * is swallowed with it, and input typed while bash itself initializes can be + * flushed (observed as stray kitty-keyboard residue like `0u` at the prompt). + * So first wait for the startup command's ready marker, then repeatedly send a + * probe echo until its output appears — an executed probe also clears any + * residue from the readline buffer. Falls through after the deadline as + * best-effort. + */ +function waitForSurfaceReady(surface: string, timeoutMs = 15_000): void { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + try { + if (readScreen(surface, 40).includes(READY_MARKER)) break; + } catch {} + sleepSync(0.25); + } + + while (Date.now() < deadline) { + try { + sendCommand(surface, "echo __PI_SHELL_''UP__"); + } catch { + sleepSync(0.25); + continue; + } + const probeDeadline = Math.min(Date.now() + 2_000, deadline); + while (Date.now() < probeDeadline) { + sleepSync(0.25); + try { + if (readScreen(surface, 40).includes(SHELL_UP_MARKER)) return; + } catch {} + } + } +} + +function parseResponse(command: string, output: string): T { + let response: T; + try { + response = JSON.parse(output) as T; + } catch { + throw new Error(`Unexpected Orca ${command} output: ${output.trim() || "(empty)"}`); + } + if (response.ok === false) { + const message = + typeof response.error?.message === "string" ? response.error.message : "unknown error"; + throw new Error(`Orca ${command} failed: ${message}`); + } + return response; +} + +function requireId(value: unknown, command: string, field: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Orca ${command} did not return ${field}`); + } + return value; +} + +export function parseCreatedTerminal(output: string): { handle: string; tabId: string } { + const response = parseResponse("terminal create", output); + return { + handle: requireId(response.result?.terminal?.handle, "terminal create", "result.terminal.handle"), + tabId: requireId(response.result?.terminal?.tabId, "terminal create", "result.terminal.tabId"), + }; +} + +export function parseCreatedSplit(output: string): string { + const response = parseResponse("terminal split", output); + return requireId(response.result?.split?.handle, "terminal split", "result.split.handle"); +} + +export function parseReadTail(output: string): string { + const response = parseResponse("terminal read", output); + const status = response.result?.terminal?.status; + if (typeof status === "string" && status !== "running") { + throw new Error(`Orca terminal read: terminal is not running (status: ${status})`); + } + const tail = response.result?.terminal?.tail; + if (!Array.isArray(tail)) { + throw new Error("Orca terminal read did not return result.terminal.tail"); + } + return tail.filter((line): line is string => typeof line === "string").join("\n"); +} + +export function createSurface(name: string): string { + const cli = requireOrca(); + // Target the enclosing terminal's worktree explicitly: pi's cwd (which the + // CLI would otherwise resolve against) may live outside any Orca worktree. + const worktreeId = process.env.ORCA_WORKTREE_ID; + const output = execFileSync( + cli, + [ + "terminal", + "create", + ...(worktreeId ? ["--worktree", `id:${worktreeId}`] : []), + "--title", + name, + "--command", + startupCommand(), + "--json", + ], + { encoding: "utf8" }, + ); + const { handle, tabId } = parseCreatedTerminal(output); + tabByHandle.set(handle, tabId); + waitForSurfaceReady(handle); + return handle; +} + +export function createSurfaceSplit( + name: string, + direction: "left" | "right" | "up" | "down", + fromSurface?: string, +): string { + void name; + const cli = requireOrca(); + if (direction === "left" || direction === "up") { + throw new Error(`Orca terminal splits only support right or down, not ${direction}`); + } + + const source = fromSurface ?? process.env.ORCA_TERMINAL_HANDLE!; + const output = execFileSync( + cli, + [ + "terminal", + "split", + "--terminal", + source, + "--direction", + direction === "right" ? "horizontal" : "vertical", + "--command", + startupCommand(), + "--json", + ], + { encoding: "utf8" }, + ); + const handle = parseCreatedSplit(output); + waitForSurfaceReady(handle); + return handle; +} + +export function sendCommand(surface: string, command: string): void { + const cli = requireOrca(); + const output = execFileSync( + cli, + ["terminal", "send", "--terminal", surface, "--text", command, "--enter", "--json"], + { encoding: "utf8" }, + ); + parseResponse("terminal send", output); +} + +function readScreenArgs(surface: string, lines: number): string[] { + return [ + "terminal", + "read", + "--terminal", + surface, + "--screen", + "--limit", + String(Math.max(1, lines)), + "--json", + ]; +} + +export function readScreen(surface: string, lines = 50): string { + const cli = requireOrca(); + const output = execFileSync(cli, readScreenArgs(surface, lines), { encoding: "utf8" }); + return parseReadTail(output); +} + +export async function readScreenAsync(surface: string, lines = 50): Promise { + const cli = requireOrca(); + const { stdout } = await execFileAsync(cli, readScreenArgs(surface, lines), { + encoding: "utf8", + }); + return parseReadTail(stdout); +} + +export function closeSurface(surface: string): void { + const cli = requireOrca(); + const args = ["terminal", "close", "--terminal", surface]; + if (tabByHandle.has(surface)) args.push("--tab"); + args.push("--json"); + const output = execFileSync(cli, args, { encoding: "utf8" }); + parseResponse("terminal close", output); + tabByHandle.delete(surface); +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/plugin/.claude-plugin/plugin.json b/extensions/interactive-subagents/pi-extension/subagents/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..8ce9b89 --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/plugin/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "pi-auto-exit", + "version": "1.0.0", + "description": "Auto-exit plugin for pi-spawned Claude sessions" +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/plugin/hooks/hooks.json b/extensions/interactive-subagents/pi-extension/subagents/plugin/hooks/hooks.json new file mode 100644 index 0000000..b2a7135 --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/plugin/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/on-stop.sh", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/plugin/hooks/on-stop.sh b/extensions/interactive-subagents/pi-extension/subagents/plugin/hooks/on-stop.sh new file mode 100755 index 0000000..4484272 --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/plugin/hooks/on-stop.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Stop hook for pi-spawned Claude sessions. +# Writes a sentinel file when Claude completes autonomously (no user interjection). + +set -euo pipefail + +# Read JSON input from stdin +input=$(cat) + +# Guard: if stop_hook_active is true, we're in a loop — bail out +stop_hook_active=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('stop_hook_active', False))" 2>/dev/null || echo "False") +if [ "$stop_hook_active" = "True" ]; then + exit 0 +fi + +# Guard: only act for pi-spawned sessions +if [ -z "${PI_CLAUDE_SENTINEL:-}" ]; then + exit 0 +fi + +# Get transcript path +transcript_path=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('transcript_path', ''))" 2>/dev/null || echo "") +if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + exit 0 +fi + +# Count real human messages in transcript (not tool results) +# Claude's transcript format: +# Human message: {"type": "user", "message": {"role": "user", "content": "..."}} +# Tool result: {"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", ...}]}} +# We only count entries where content is a string (real human input) +user_msg_count=$(python3 - "$transcript_path" <<'EOF' +import sys, json + +transcript_path = sys.argv[1] +count = 0 +with open(transcript_path, 'r') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + if entry.get('type') != 'user': + continue + content = entry.get('message', {}).get('content', '') + # Real human messages have string content + # Tool results have array content with tool_result blocks + if isinstance(content, str): + count += 1 + except (json.JSONDecodeError, AttributeError): + pass +print(count) +EOF +) + +# Always write transcript path so the watcher can copy the session file +if [ -n "$transcript_path" ]; then + echo "$transcript_path" > "${PI_CLAUDE_SENTINEL}.transcript" 2>/dev/null || true +fi + +# If exactly 1 user message (the initial prompt), this was autonomous — signal completion +if [ "$user_msg_count" -eq 1 ]; then + # Write last_assistant_message to sentinel so the watcher gets a clean result + echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('last_assistant_message', ''))" > "$PI_CLAUDE_SENTINEL" 2>/dev/null || touch "$PI_CLAUDE_SENTINEL" +fi + +exit 0 diff --git a/extensions/interactive-subagents/pi-extension/subagents/session.ts b/extensions/interactive-subagents/pi-extension/subagents/session.ts new file mode 100644 index 0000000..24d89a3 --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/session.ts @@ -0,0 +1,625 @@ +import { + appendFileSync, + closeSync, + copyFileSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + readSync, + readdirSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { randomBytes, randomUUID } from "node:crypto"; +import { dirname, join } from "node:path"; + +export interface SessionEntry { + type: string; + id: string; + parentId?: string; + [key: string]: unknown; +} + +export interface MessageEntry extends SessionEntry { + type: "message"; + message: { + role: "user" | "assistant" | "toolResult"; + content: Array<{ type: string; text?: string; [key: string]: unknown }>; + }; +} + +export type SeededSubagentSessionMode = "lineage-only" | "fork"; + +function getForkContentLines(parentSessionFile: string): string[] { + const raw = readFileSync(parentSessionFile, "utf8"); + const lines = raw.split("\n").filter((line) => line.trim()); + + let truncateAt = lines.length; + for (let i = lines.length - 1; i >= 0; i--) { + try { + const entry = JSON.parse(lines[i]); + if (entry.type === "message" && entry.message?.role === "user") { + truncateAt = i; + break; + } + } catch { + // ignore malformed lines + } + } + + return lines.slice(0, truncateAt).filter((line) => { + try { + return JSON.parse(line).type !== "session"; + } catch { + return true; + } + }); +} + +export function seedSubagentSessionFile(params: { + mode: SeededSubagentSessionMode; + parentSessionFile: string; + childSessionFile: string; + childCwd: string; +}): void { + const header = { + type: "session", + version: 3, + id: randomUUID(), + timestamp: new Date().toISOString(), + cwd: params.childCwd, + parentSession: params.parentSessionFile, + }; + const contentLines = + params.mode === "fork" ? getForkContentLines(params.parentSessionFile) : []; + const lines = [JSON.stringify(header), ...contentLines]; + + mkdirSync(dirname(params.childSessionFile), { recursive: true }); + writeFileSync(params.childSessionFile, lines.join("\n") + "\n", "utf8"); +} + +/** + * A snapshot of everything needed to reconstruct a subagent's sandbox when its + * session is later resumed via `subagent_message({ sessionId })`. + * + * Written next to the session file as `.loadout.json` at spawn + * time. Resume replays this exact snapshot so the reincarnated process gets the + * same `--no-extensions` + `--tools` restriction, model, identity, spawn + * whitelist, cwd, and config dir it originally ran with — instead of falling + * back to pi's default (all global extensions + full toolset). Storing the + * resolved loadout (rather than re-deriving from the agent `.md` by name) keeps + * resume faithful even if the agent definition is later edited, moved, or + * deleted. + */ +export interface SubagentLoadout { + /** Agent profile name (for PI_SUBAGENT_AGENT); null for agentless spawns. */ + agent: string | null; + /** The `--tools` allowlist string, or null when the spawn was unrestricted. */ + toolAllowlist: string | null; + /** Model id (without thinking suffix), or null to use the session default. */ + model: string | null; + /** Thinking level appended to the model as `model:level`, or null. */ + thinking: string | null; + /** How the identity text was applied: append/replace, or null. */ + systemPromptMode: "append" | "replace" | null; + /** The system-prompt/identity text, only when it lived in the system prompt. */ + identity: string | null; + /** Agents this subagent was allowed to spawn (for PI_SUBAGENT_ALLOWED). */ + spawnable: string[] | null; + /** Whether the agent auto-exits (informational; resume forces autonomous). */ + autoExit: boolean; + /** Working directory the subagent ran in, or null. */ + cwd: string | null; + /** PI_CODING_AGENT_DIR the subagent resolved config/extensions from, or null. */ + agentDir: string | null; +} + +/** Path of the loadout sidecar written next to a subagent session file. */ +export function loadoutSidecarPath(sessionFile: string): string { + return `${sessionFile}.loadout.json`; +} + +/** Persist a subagent's resolved sandbox loadout beside its session file. */ +export function writeSubagentLoadout(sessionFile: string, loadout: SubagentLoadout): void { + try { + writeFileSync(loadoutSidecarPath(sessionFile), JSON.stringify(loadout), "utf8"); + } catch { + // Best-effort: a missing snapshot only means resume will refuse, never that + // it launches unrestricted. + } +} + +/** Read a subagent's loadout snapshot, or null if absent/unparseable. */ +export function readSubagentLoadout(sessionFile: string): SubagentLoadout | null { + try { + const p = loadoutSidecarPath(sessionFile); + if (!existsSync(p)) return null; + const parsed = JSON.parse(readFileSync(p, "utf8")); + if (!parsed || typeof parsed !== "object") return null; + return parsed as SubagentLoadout; + } catch { + return null; + } +} + +// ── Name registry ──────────────────────────────────────────────────────────── +// Each spawner session (the top-level pi session, or a worker that spawns its +// own children) gets a registry mapping a subagent's display name to the +// session file it ran in. Names are unique per spawner session and persist on +// disk, so `subagent_message({ name })` can steer a running subagent or resume +// a finished one by the same handle — even across a pi restart. The registry +// lives in the spawner's own artifact dir, which is directly addressable from +// the spawner's session id (no sessions-tree scan, so resume stays fast). + +export interface NameRegistryEntry { + /** Absolute path to the subagent's session .jsonl file. */ + sessionFile: string; + /** Canonical session header id (kept for display/lineage). */ + sessionId: string | null; +} + +export type NameRegistry = Record; + +/** Path of the name registry for a given spawner session's artifact dir. */ +export function nameRegistryPath(artifactDir: string): string { + return join(artifactDir, "subagent-registry.json"); +} + +/** Read a spawner session's name registry, or {} if absent/corrupt. */ +export function readNameRegistry(artifactDir: string): NameRegistry { + try { + const p = nameRegistryPath(artifactDir); + if (!existsSync(p)) return {}; + const parsed = JSON.parse(readFileSync(p, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return parsed as NameRegistry; + } catch { + return {}; + } +} + +/** + * Register (or overwrite) a name → session mapping for a spawner session. + * Writes atomically (temp file + rename) so a concurrent reader never sees a + * partial registry. + */ +export function registerName( + artifactDir: string, + name: string, + entry: NameRegistryEntry, +): void { + try { + mkdirSync(artifactDir, { recursive: true }); + const registry = readNameRegistry(artifactDir); + registry[name] = entry; + const p = nameRegistryPath(artifactDir); + const tmp = `${p}.tmp-${process.pid}-${Math.random().toString(16).slice(2, 8)}`; + writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf8"); + renameSync(tmp, p); + } catch { + // Best-effort: a failed registration only means resume-by-name won't find + // this subagent later; it never breaks the spawn itself. + } +} + +/** Resolve a name to its registry entry within a spawner session, or null. */ +export function resolveNameInRegistry( + artifactDir: string, + name: string, +): NameRegistryEntry | null { + const entry = readNameRegistry(artifactDir)[name]; + return entry && typeof entry.sessionFile === "string" ? entry : null; +} + +function readEntries(sessionFile: string): SessionEntry[] { + const raw = readFileSync(sessionFile, "utf8"); + return raw + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as SessionEntry); +} + +/** + * Return the id of the last entry in the session file (current branch point / leaf). + */ +export function getLeafId(sessionFile: string): string | null { + const entries = readEntries(sessionFile); + return entries.length > 0 ? entries[entries.length - 1].id : null; +} + +/** + * Read the canonical session id from a session file's header. + * + * pi's `--session ` flag resolves against this header `id` (exact match, + * then prefix), NOT the filename — so this is the value to hand back to the + * orchestrator for follow-ups. + */ +/** + * Read only the first line of a file without loading the whole thing into + * memory. Session files grow to many MB, but the header we need is always the + * first JSON line, so reading a small prefix keeps header lookups cheap — this + * is what makes scanning a large session tree fast enough to avoid blocking the + * event loop. Returns the first line (sans trailing newline), or null. + */ +function readFirstLine(path: string, maxBytes = 65536): string | null { + let fd: number | undefined; + try { + fd = openSync(path, "r"); + const buf = Buffer.allocUnsafe(maxBytes); + const bytes = readSync(fd, buf, 0, maxBytes, 0); + if (bytes <= 0) return null; + const nl = buf.indexOf(0x0a); // '\n' + const end = nl === -1 || nl >= bytes ? bytes : nl; + return buf.toString("utf8", 0, end); + } catch { + return null; + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + /* ignore */ + } + } + } +} + +export function getSessionId(sessionFile: string): string | null { + return readHeaderId(sessionFile); +} + +function readHeaderId(sessionFile: string): string | null { + const firstLine = readFirstLine(sessionFile)?.trim(); + if (!firstLine) return null; + try { + const entry = JSON.parse(firstLine) as { type?: string; id?: string }; + return entry.type === "session" && typeof entry.id === "string" ? entry.id : null; + } catch { + return null; + } +} + +/** + * Resolve a session id (or id prefix) to a session file path by scanning every + * `*.jsonl` under `sessionsRoot` and matching the header `id`. Mirrors pi's own + * resolution order: exact match first, then prefix match. Most recently + * modified file wins on ties. Returns null when nothing matches. + */ +/** + * In-process index of session id → session file, per sessions root. + * + * Resolving a session id naively walks every `.jsonl` under the sessions tree + * and reads each header. With a few thousand sessions that is thousands of + * synchronous open/read/stat syscalls — on the extension host's single thread + * that blocks the entire terminal UI for many seconds (measured ~67s on a + * 2010-file tree). To avoid that, we build the index once per root and cache + * it; subsequent lookups are O(1). The cache is validated cheaply (a directory + * listing plus statSync-only mtime checks) on every call, so new sessions are + * picked up without re-reading unchanged headers and without ever freezing the + * UI again. + */ +interface SessionIndex { + idToFile: Map; + /** file path → mtime when indexed (staleness detection). */ + files: Map; + /** top-level dir signature used to detect newly added cwd dirs. */ + topSig: string; +} +const sessionIndexCache = new Map(); + +function topLevelSignature(root: string): string { + const parts: string[] = []; + let entries: import("node:fs").Dirent[]; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch { + return ""; + } + for (const e of entries) { + const full = join(root, e.name); + if (e.isDirectory()) { + let m = 0; + try { + m = statSync(full).mtimeMs; + } catch { + /* ignore */ + } + parts.push(`d:${e.name}:${m}`); + } else if (e.isFile() && e.name.endsWith(".jsonl")) { + parts.push(`f:${e.name}`); + } + } + parts.sort(); + return parts.join("|"); +} + +/** Recursively index new/changed .jsonl files under dir into idx. */ +function indexDir(dir: string, idx: SessionIndex): void { + let entries: import("node:fs").Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + indexDir(full, idx); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + let mtime = 0; + try { + mtime = statSync(full).mtimeMs; + } catch { + continue; + } + const known = idx.files.get(full); + if (known !== undefined && known === mtime) continue; // unchanged + const id = readHeaderId(full); // only read headers for new/changed files + idx.files.set(full, mtime); + if (!id) continue; + const prev = idx.idToFile.get(id); + if (!prev || mtime >= prev.mtime) { + idx.idToFile.set(id, { path: full, mtime }); + } + } + } +} + +function getSessionIndex(sessionsRoot: string): SessionIndex { + let idx = sessionIndexCache.get(sessionsRoot); + const sig = topLevelSignature(sessionsRoot); + if (!idx) { + idx = { idToFile: new Map(), files: new Map(), topSig: sig }; + sessionIndexCache.set(sessionsRoot, idx); + indexDir(sessionsRoot, idx); // first build: full scan, once per process + } else if (idx.topSig !== sig) { + idx.topSig = sig; + indexDir(sessionsRoot, idx); // a cwd dir was added/changed: incremental rescan + } else { + indexDir(sessionsRoot, idx); // cheap: stats files, reads only new/changed headers + } + return idx; +} + +export function resolveSessionFileById(sessionId: string, sessionsRoot: string): string | null { + if (!sessionId || !existsSync(sessionsRoot)) return null; + const idx = getSessionIndex(sessionsRoot); + return lookupSessionIndex(idx, sessionId); +} + +function lookupSessionIndex( + idx: { idToFile: Map }, + sessionId: string, +): string | null { + // Exact match first. + const exact = idx.idToFile.get(sessionId); + if (exact && existsSync(exact.path)) return exact.path; + + // Prefix match: most recently modified wins (ids are unique in practice, so + // this is only a convenience for hand-typed short prefixes). + let best: { path: string; mtime: number } | null = null; + for (const [id, rec] of idx.idToFile) { + if (!id.startsWith(sessionId)) continue; + if (!existsSync(rec.path)) continue; + if (!best || rec.mtime > best.mtime) best = rec; + } + return best ? best.path : null; +} + +/** + * Async variant used by the interactive resume path. Index building/refresh is + * synchronous I/O, which can take many seconds on a cold OS page cache with a + * few thousand sessions; running it synchronously would block the extension + * host's single thread and freeze the terminal UI. Deferring to a macrotask + * keeps the event loop responsive. The heavy work only happens on the first + * resolution per process (and incrementally thereafter); warm lookups are ~50ms. + */ +export async function resolveSessionFileByIdAsync( + sessionId: string, + sessionsRoot: string, +): Promise { + if (!sessionId || !existsSync(sessionsRoot)) return null; + // Let the event loop breathe (and the UI repaint) before the sync scan. + await new Promise((r) => setImmediate(r)); + const idx = getSessionIndex(sessionsRoot); + return lookupSessionIndex(idx, sessionId); +} + +/** Test hook: drop the cached session index so tests start clean. */ +export function resetSessionIndexCache(): void { + sessionIndexCache.clear(); +} + +/** + * Return entries added after `afterLine` (1-indexed count of existing entries). + */ +/** + * Count the number of entry lines in a session file without parsing each line + * into an object. Used by the resume path, which only needs the *count* of + * pre-existing entries (so it can later slice out the new ones). Parsing every + * line of a large resumed transcript synchronously at resume time would block + * the UI; counting newlines is dramatically cheaper. + */ +export function countSessionEntryLines(sessionFile: string): number { + try { + const raw = readFileSync(sessionFile, "utf8"); + // Count non-blank lines, mirroring getNewEntries' `.filter(line => line.trim())` + // but skipping the per-line JSON.parse that makes resume slow on big files. + let count = 0; + for (const line of raw.split("\n")) { + if (line.trim()) count++; + } + return count; + } catch { + return 0; + } +} + +export function getNewEntries(sessionFile: string, afterLine: number): SessionEntry[] { + const raw = readFileSync(sessionFile, "utf8"); + const lines = raw.split("\n").filter((line) => line.trim()); + return lines.slice(afterLine).map((line) => JSON.parse(line) as SessionEntry); +} + +/** + * Find the last assistant message text in a list of entries. + * + * Falls back to the `errorMessage` field when the last assistant message has + * `stopReason: "error"` and no usable text content — this happens when + * auto-retry exhausts on a provider overload / rate limit / server error, and + * without this fallback the parent would silently see a stale earlier message. + */ +export function findLastAssistantMessage(entries: SessionEntry[]): string | null { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type !== "message") continue; + const msg = entry as MessageEntry; + if (msg.message.role !== "assistant") continue; + + const texts = msg.message.content + .filter( + (block) => + block.type === "text" && typeof block.text === "string" && block.text.trim() !== "", + ) + .map((block) => block.text as string); + + if (texts.length > 0 && texts.join("").trim()) return texts.join("\n"); + + const stopReason = (msg.message as { stopReason?: unknown }).stopReason; + const errorMessage = (msg.message as { errorMessage?: unknown }).errorMessage; + if ( + stopReason === "error" && + typeof errorMessage === "string" && + errorMessage.trim() !== "" + ) { + return `Subagent error: ${errorMessage.trim()}`; + } + } + return null; +} + +/** + * Append a branch_summary entry to the session file. + * Returns the new entry's id. + */ +export function appendBranchSummary( + sessionFile: string, + branchPointId: string, + fromId: string | null, + summary: string, +): string { + const id = randomBytes(4).toString("hex"); + const entry = { + type: "branch_summary", + id, + parentId: branchPointId, + timestamp: new Date().toISOString(), + fromId: fromId ?? branchPointId, + summary, + }; + appendFileSync(sessionFile, JSON.stringify(entry) + "\n", "utf8"); + return id; +} + +/** + * Copy the session file to destDir for parallel worker isolation. + * Returns the path of the copy. + */ +export function copySessionFile(sessionFile: string, destDir: string): string { + const id = randomBytes(4).toString("hex"); + const dest = join(destDir, `subagent-${id}.jsonl`); + copyFileSync(sessionFile, dest); + return dest; +} + +/** + * Read new entries from sourceFile (after afterLine), append them to targetFile. + * Returns the appended entries. + */ +export function mergeNewEntries( + sourceFile: string, + targetFile: string, + afterLine: number, +): SessionEntry[] { + const entries = getNewEntries(sourceFile, afterLine); + for (const entry of entries) { + appendFileSync(targetFile, JSON.stringify(entry) + "\n", "utf8"); + } + return entries; +} + +export interface SessionStats { + model: string | null; + toolCount: number; + /** Cumulative token usage across all assistant turns. */ + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + /** Current context size: the last assistant turn's totalTokens. */ + contextTokens: number; + /** Cumulative cost in USD across all assistant turns. */ + cost: number; +} + +/** + * Parse a completed subagent session JSONL into aggregate stats for display: + * model, tool-call count, cumulative token usage + cost, and current context + * size. Cumulative usage fields are summed across every assistant turn; the + * context size is taken from the last assistant turn's `totalTokens` (the live + * context window occupancy). Returns null if the file can't be read. + */ +export function summarizeSessionStats(sessionFile: string): SessionStats | null { + let entries: SessionEntry[]; + try { + entries = readEntries(sessionFile); + } catch { + return null; + } + + const stats: SessionStats = { + model: null, + toolCount: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + contextTokens: 0, + cost: 0, + }; + + for (const entry of entries) { + if (entry.type === "model_change") { + const modelId = (entry as { modelId?: unknown }).modelId; + if (typeof modelId === "string" && modelId) stats.model = modelId; + continue; + } + if (entry.type !== "message") continue; + const msg = (entry as MessageEntry).message; + if (msg.role !== "assistant") continue; + + const model = (msg as { model?: unknown }).model; + if (typeof model === "string" && model) stats.model = model; + + for (const block of msg.content) { + if (block.type === "toolCall") stats.toolCount++; + } + + const usage = (msg as { usage?: Record }).usage; + if (usage && typeof usage === "object") { + const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); + stats.inputTokens += num(usage.input); + stats.outputTokens += num(usage.output); + stats.cacheReadTokens += num(usage.cacheRead); + stats.cacheWriteTokens += num(usage.cacheWrite); + const total = num(usage.totalTokens); + if (total > 0) stats.contextTokens = total; + const cost = usage.cost; + if (cost && typeof cost === "object") stats.cost += num((cost as Record).total); + } + } + + return stats; +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/status.ts b/extensions/interactive-subagents/pi-extension/subagents/status.ts new file mode 100644 index 0000000..970dbe6 --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/status.ts @@ -0,0 +1,513 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const SNAPSHOT_STALLED_AFTER_MS = 60_000; +export const DEFAULT_STATUS_LINE_LIMIT = 4; +export const MAX_STATUS_NAME_LENGTH = 72; +export const MAX_STATUS_LINE_LENGTH = 120; + +const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const DEFAULT_STATUS_CONFIG_PATH = join(PACKAGE_ROOT, "config.json"); +const STATUS_CONFIG_EXAMPLE_PATH = join(PACKAGE_ROOT, "config.json.example"); + +export type SubagentStatusKind = "starting" | "active" | "waiting" | "stalled" | "running"; +export type SubagentStatusSource = "pi" | "claude"; +export type SubagentStatusTransition = "stalled" | "recovered" | null; +export type StatusSnapshotState = "unseen" | "present" | "missing" | "invalid" | "wrong-id"; +export type StatusActivityPhase = "starting" | "active" | "waiting" | "done"; + +export interface StatusConfig { + enabled: boolean; + lineLimit: number; +} + +export type StatusObservation = + | { + snapshot: "present"; + updatedAt: number; + sequence: number; + phase: StatusActivityPhase; + active?: boolean; + activeScope?: string; + activeSince?: number; + waitingSince?: number; + latestEvent?: string; + activityLabel?: string; + } + | { + snapshot: "missing" | "invalid" | "wrong-id"; + snapshotError?: string; + }; + +export interface SubagentStatusState { + source: SubagentStatusSource; + startTimeMs: number; + firstObservationAtMs: number | null; + lastActivityAtMs: number | null; + lastActivitySequence: number | null; + localOverrideAtMs: number | null; + localOverrideSequence: number | null; + activeNow: boolean; + activeSinceMs: number | null; + activeScope: string | null; + waitingSinceMs: number | null; + phase: StatusActivityPhase | null; + latestEvent: string | null; + activityLabel: string | null; + snapshotState: StatusSnapshotState; + snapshotProblemSinceMs: number | null; + snapshotError: string | null; + currentKind: SubagentStatusKind; +} + +export interface StatusSnapshot { + kind: SubagentStatusKind; + elapsedMs: number; + elapsedText: string; + activeSinceMs: number | null; + activeDurationText: string | null; + activeScope: string | null; + waitingSinceMs: number | null; + waitingDurationText: string | null; + latestEvent: string | null; + activityLabel: string | null; + snapshotState: StatusSnapshotState; + snapshotError: string | null; + snapshotProblemText: string | null; + statusLabel: string | null; +} + +export interface CappedStatusLines { + visibleLines: string[]; + overflow: number; +} + +function invalidStatusConfig(source: string, message: string): never { + throw new Error(`Invalid subagent status config in ${source}: ${message}`); +} + +function requireObject(value: unknown, source: string, fieldName: string): Record { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + invalidStatusConfig(source, `${fieldName} must be an object`); + } + return value as Record; +} + +function requireBoolean(value: unknown, source: string, fieldName: string): boolean { + if (typeof value !== "boolean") { + invalidStatusConfig(source, `${fieldName} must be a boolean`); + } + return value; +} + +function rejectUnsupportedKeys( + value: Record, + allowedKeys: string[], + source: string, + fieldName: string, +): void { + const unsupportedKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key)); + if (unsupportedKeys.length > 0) { + invalidStatusConfig(source, `${fieldName} has unsupported key(s): ${unsupportedKeys.join(", ")}`); + } +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + if (maxLength <= 1) return text.slice(0, maxLength); + return `${text.slice(0, maxLength - 1)}…`; +} + +export function normalizeStatusName(name: string): string { + const collapsed = name.replace(/\s+/g, " ").trim() || "subagent"; + return truncateText(collapsed, MAX_STATUS_NAME_LENGTH); +} + +function boundStatusLine(line: string): string { + return truncateText(line.replace(/\s+/g, " ").trim(), MAX_STATUS_LINE_LENGTH); +} + +function snapshotProblemLabel(snapshotState: StatusSnapshotState): string | null { + if (snapshotState === "wrong-id") return "wrong activity id"; + return null; +} + +function activityLabel(snapshot: Pick): string | null { + return snapshot.activityLabel ?? snapshot.activeScope; +} + +export function parseStatusConfig(rawConfig: unknown, source = "config.json"): StatusConfig { + const config = requireObject(rawConfig, source, "root"); + const status = requireObject(config.status, source, "status"); + rejectUnsupportedKeys(status, ["enabled"], source, "status"); + const enabled = requireBoolean(status.enabled, source, "status.enabled"); + + return { + enabled, + lineLimit: DEFAULT_STATUS_LINE_LIMIT, + }; +} + +function readStatusConfigFile(configPath: string, examplePath: string): { sourcePath: string; rawConfig: string } { + try { + return { sourcePath: configPath, rawConfig: readFileSync(configPath, "utf8") }; + } catch (error) { + const errno = error as NodeJS.ErrnoException; + if (errno.code !== "ENOENT") throw error; + } + + try { + return { sourcePath: examplePath, rawConfig: readFileSync(examplePath, "utf8") }; + } catch (error) { + const errno = error as NodeJS.ErrnoException; + if (errno.code === "ENOENT") { + throw new Error( + `Missing subagent status config. Expected ${configPath} or ${examplePath}.`, + ); + } + throw error; + } +} + +export function loadStatusConfig( + configPath = DEFAULT_STATUS_CONFIG_PATH, + examplePath = STATUS_CONFIG_EXAMPLE_PATH, +): StatusConfig { + const { sourcePath, rawConfig } = readStatusConfigFile(configPath, examplePath); + + let parsed: unknown; + try { + parsed = JSON.parse(rawConfig) as unknown; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid JSON in subagent config ${sourcePath}: ${detail}`); + } + + return parseStatusConfig(parsed, sourcePath); +} + +export function formatElapsedDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + if (totalSeconds < 60) return `${totalSeconds}s`; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + + return `${minutes}m`; +} + +export function createStatusState(params: { + source: SubagentStatusSource; + startTimeMs: number; +}): SubagentStatusState { + const initialKind = params.source === "claude" ? "running" : "starting"; + return { + source: params.source, + startTimeMs: params.startTimeMs, + firstObservationAtMs: null, + lastActivityAtMs: null, + lastActivitySequence: null, + localOverrideAtMs: null, + localOverrideSequence: null, + activeNow: false, + activeSinceMs: null, + activeScope: null, + waitingSinceMs: null, + phase: null, + latestEvent: null, + activityLabel: null, + snapshotState: params.source === "claude" ? "unseen" : "unseen", + snapshotProblemSinceMs: null, + snapshotError: null, + currentKind: initialKind, + }; +} + +export function observeStatus( + state: SubagentStatusState, + observation: StatusObservation, + now: number, +): SubagentStatusState { + if (state.source === "claude") return state; + + if (observation.snapshot !== "present") { + return { + ...state, + firstObservationAtMs: state.firstObservationAtMs ?? now, + snapshotState: observation.snapshot, + snapshotProblemSinceMs: state.snapshotProblemSinceMs ?? now, + snapshotError: observation.snapshotError ?? null, + }; + } + + const updatedAt = observation.updatedAt; + const sequence = observation.sequence; + const lastActivityAtMs = state.lastActivityAtMs; + const lastActivitySequence = state.lastActivitySequence; + const olderThanLastActivity = lastActivityAtMs != null && ( + updatedAt < lastActivityAtMs || + (updatedAt === lastActivityAtMs && lastActivitySequence != null && sequence < lastActivitySequence) + ); + if (olderThanLastActivity) return state; + + const blockedByLocalOverride = state.localOverrideAtMs != null && ( + updatedAt < state.localOverrideAtMs || + (updatedAt === state.localOverrideAtMs && state.localOverrideSequence != null && sequence <= state.localOverrideSequence) + ); + if (blockedByLocalOverride) return state; + + const phase = observation.phase; + const activeNow = phase === "active" || observation.active === true; + const activeSinceMs = activeNow + ? observation.activeSince ?? state.activeSinceMs ?? updatedAt + : null; + const waitingSinceMs = phase === "waiting" + ? observation.waitingSince ?? state.waitingSinceMs ?? updatedAt + : null; + + return { + ...state, + firstObservationAtMs: state.firstObservationAtMs ?? now, + lastActivityAtMs: updatedAt, + lastActivitySequence: sequence, + activeNow, + activeSinceMs, + activeScope: activeNow ? observation.activeScope ?? null : null, + waitingSinceMs, + phase, + latestEvent: observation.latestEvent ?? null, + activityLabel: observation.activityLabel ?? null, + snapshotState: "present", + snapshotProblemSinceMs: null, + snapshotError: null, + localOverrideAtMs: null, + localOverrideSequence: null, + }; +} + +export function forceStatusAfterInterrupt(state: SubagentStatusState, now: number): SubagentStatusState { + if (state.source === "claude") return state; + + return { + ...state, + firstObservationAtMs: state.firstObservationAtMs ?? now, + lastActivityAtMs: now, + localOverrideAtMs: now, + localOverrideSequence: state.lastActivitySequence, + activeNow: false, + activeSinceMs: null, + activeScope: null, + waitingSinceMs: now, + phase: "waiting", + latestEvent: "interrupt_requested", + activityLabel: "interrupted", + snapshotState: "present", + snapshotProblemSinceMs: null, + snapshotError: null, + currentKind: "waiting", + }; +} + +function classifyProblemState(state: SubagentStatusState, now: number): Pick { + const problemLabel = snapshotProblemLabel(state.snapshotState); + const hasValidSnapshot = state.lastActivityAtMs != null; + + if (!hasValidSnapshot) { + const referenceMs = state.firstObservationAtMs ?? state.startTimeMs; + const elapsedMs = Math.max(0, now - referenceMs); + return elapsedMs >= SNAPSHOT_STALLED_AFTER_MS + ? { kind: "stalled", statusLabel: problemLabel } + : { kind: "starting", statusLabel: null }; + } + + const problemSinceMs = state.snapshotProblemSinceMs ?? now; + const problemMs = Math.max(0, now - problemSinceMs); + if (problemMs >= SNAPSHOT_STALLED_AFTER_MS) return { kind: "stalled", statusLabel: problemLabel }; + + const lastHealthyKind = state.activeNow + ? "active" + : state.waitingSinceMs != null || state.phase === "done" + ? "waiting" + : state.currentKind === "stalled" + ? "starting" + : state.currentKind; + return { kind: lastHealthyKind, statusLabel: problemLabel }; +} + +export function classifyStatus(state: SubagentStatusState, now: number): StatusSnapshot { + const elapsedMs = Math.max(0, now - state.startTimeMs); + const elapsedText = formatElapsedDuration(elapsedMs); + + if (state.source === "claude") { + return { + kind: "running", + elapsedMs, + elapsedText, + activeSinceMs: null, + activeDurationText: null, + activeScope: null, + waitingSinceMs: null, + waitingDurationText: null, + latestEvent: null, + activityLabel: null, + snapshotState: state.snapshotState, + snapshotError: null, + snapshotProblemText: null, + statusLabel: null, + }; + } + + let kind: SubagentStatusKind; + let statusLabel: string | null = null; + + if (state.snapshotState === "present") { + if (state.phase === "active" || state.activeNow) { + kind = "active"; + } else if (state.phase === "waiting") { + kind = "waiting"; + } else if (state.phase === "done") { + kind = "waiting"; + statusLabel = "done"; + } else { + const referenceMs = state.firstObservationAtMs ?? state.startTimeMs; + const elapsedSinceObservationMs = Math.max(0, now - referenceMs); + kind = elapsedSinceObservationMs >= SNAPSHOT_STALLED_AFTER_MS ? "stalled" : "starting"; + statusLabel = null; + } + } else { + const classified = classifyProblemState(state, now); + kind = classified.kind; + statusLabel = classified.statusLabel; + } + + const activeDurationText = state.activeSinceMs == null + ? null + : formatElapsedDuration(now - state.activeSinceMs); + const waitingDurationText = state.waitingSinceMs == null + ? null + : formatElapsedDuration(now - state.waitingSinceMs); + const snapshotProblemText = state.snapshotProblemSinceMs == null + ? null + : formatElapsedDuration(now - state.snapshotProblemSinceMs); + + return { + kind, + elapsedMs, + elapsedText, + activeSinceMs: state.activeSinceMs, + activeDurationText, + activeScope: state.activeScope, + waitingSinceMs: state.waitingSinceMs, + waitingDurationText, + latestEvent: state.latestEvent, + activityLabel: state.activityLabel, + snapshotState: state.snapshotState, + snapshotError: state.snapshotError, + snapshotProblemText, + statusLabel, + }; +} + +export function advanceStatusState( + state: SubagentStatusState, + now: number, +): { + nextState: SubagentStatusState; + snapshot: StatusSnapshot; + transition: SubagentStatusTransition; +} { + const snapshot = classifyStatus(state, now); + const transition = + state.currentKind !== "stalled" && snapshot.kind === "stalled" + ? "stalled" + : state.currentKind === "stalled" && (snapshot.kind === "active" || snapshot.kind === "waiting") + ? "recovered" + : null; + + return { + snapshot, + transition, + nextState: { + ...state, + currentKind: snapshot.kind, + }, + }; +} + +function formatActiveDetail(snapshot: StatusSnapshot): string { + const label = activityLabel(snapshot); + if (!label) return "active"; + const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : ""; + return `active (${label}${duration})`; +} + +function formatWaitingDetail(snapshot: StatusSnapshot): string { + const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : ""; + return `waiting${duration}`; +} + +function formatStalledDetail(snapshot: StatusSnapshot): string { + const detail = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : ""; + const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : ""; + return `stalled${duration}${detail}`; +} + +export function formatStatusLine(name: string, snapshot: StatusSnapshot): string { + const boundedName = normalizeStatusName(name); + + if (snapshot.kind === "starting") { + const label = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : ""; + return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, starting${label}.`); + } + + if (snapshot.kind === "running") { + return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}.`); + } + + if (snapshot.kind === "active") { + return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatActiveDetail(snapshot)}.`); + } + + if (snapshot.kind === "waiting") { + const problem = snapshot.statusLabel && snapshot.statusLabel !== "done" + ? ` (${snapshot.statusLabel})` + : snapshot.statusLabel === "done" + ? " (done)" + : ""; + return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatWaitingDetail(snapshot)}${problem}.`); + } + + return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatStalledDetail(snapshot)}.`); +} + +export function formatTransitionLine( + name: string, + snapshot: StatusSnapshot, + transition: Exclude, +): string { + const boundedName = normalizeStatusName(name); + + if (transition === "recovered") { + const detail = snapshot.kind === "waiting" ? formatWaitingDetail(snapshot) : formatActiveDetail(snapshot); + return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, recovered; ${detail}.`); + } + + return formatStatusLine(boundedName, snapshot); +} + +export function capStatusLines(lines: string[], lineLimit: number): CappedStatusLines { + const visibleLines = lines.slice(0, lineLimit); + return { + visibleLines, + overflow: Math.max(0, lines.length - visibleLines.length), + }; +} + +export function formatStatusAggregate(lines: string[], lineLimit: number): string { + const { visibleLines, overflow } = capStatusLines(lines, lineLimit); + const bulletLines = visibleLines.map((line) => `• ${line}`); + if (overflow > 0) bulletLines.push(`• +${overflow} more running.`); + return `Subagent status:\n${bulletLines.join("\n")}`; +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/subagent-done.ts b/extensions/interactive-subagents/pi-extension/subagents/subagent-done.ts new file mode 100644 index 0000000..0d4605a --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/subagent-done.ts @@ -0,0 +1,399 @@ +/** + * Extension loaded into sub-agents. + * - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+Alt+O) + * - Provides an `ask_question` tool for asking the parent orchestrator a question + * + * Subagents do NOT self-terminate via a tool. Auto-exit agents shut down + * automatically when their agent loop ends (see the `agent_end` handler); + * interactive agents end when the human exits the pane. + * + * `ask_question` keeps the session OPEN: it writes a `${sessionFile}.ask` + * signal the parent's watcher picks up, parks the session in a "waiting" state + * (auto-exit is suppressed for that turn via `awaitingAnswer`), and the parent + * replies with subagent_message — which lands as the subagent's next turn. + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Box, Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { writeFileSync } from "node:fs"; +import { createSubagentActivityRecorder } from "./activity.ts"; + +export function shouldMarkUserTookOver(agentStarted: boolean): boolean { + return agentStarted; +} + +/** + * Number of child subagents this session itself still has in flight. + * + * When this extension is loaded inside a subagent that can spawn its own + * children (e.g. a worker delegating to scout/researcher), `index.ts` runs in + * the same process and publishes a live count through a shared process-global + * symbol. A subagent that spawns children and then writes a "waiting for + * results" message would otherwise auto-exit the instant that turn ends — + * killing the session before its children report back. Reading this count lets + * `agent_end` keep the session open until every child has finished and its + * result has been delivered. + * + * Returns 0 when the spawning tools aren't loaded (scout/researcher, or a + * standalone session), so those agents auto-exit exactly as before. + */ +export function runningChildrenCount(): number { + const fn = (globalThis as any)[Symbol.for("pi-subagents/running-children-count")]; + if (typeof fn !== "function") return 0; + try { + const n = fn(); + return typeof n === "number" && n > 0 ? n : 0; + } catch { + return 0; + } +} + +export function shouldAutoExitOnAgentEnd( + _userTookOver: boolean, + messages: any[] | undefined, +): boolean { + // Manual input should not strand an auto-exit subagent. If the latest agent + // turn completed normally, close the session. Escape/abort still leaves it + // open for inspection or another prompt. + // + // stopReason: "error" (e.g. exhausted retries on a provider overload) also + // returns true — we want to shut down so the parent is woken up — but we + // pair this with findLatestAssistantError() so the parent learns it was an + // error, not a clean completion. + if (messages) { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg?.role === "assistant") { + return msg.stopReason !== "aborted"; + } + } + } + + return true; +} + +export interface SubagentErrorInfo { + errorMessage: string; + stopReason: "error"; +} + +/** + * If the last assistant message in the turn ended with `stopReason: "error"` + * (typically auto-retry exhausted on an overload / rate limit / server error), + * return its error info so the parent orchestrator can surface a clear + * failure instead of silently treating the run as completed. + * + * Returns `null` when the latest assistant turn completed normally or was + * aborted by the user (handled separately by shouldAutoExitOnAgentEnd). + */ +export function findLatestAssistantError( + messages: any[] | undefined, +): SubagentErrorInfo | null { + if (!messages) return null; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg?.role !== "assistant") continue; + if (msg.stopReason !== "error") return null; + const raw = typeof msg.errorMessage === "string" ? msg.errorMessage.trim() : ""; + return { + errorMessage: raw || "Subagent agent loop ended with stopReason=error (no errorMessage field).", + stopReason: "error", + }; + } + return null; +} + +export function parseDeniedTools(rawValue: string | undefined): string[] { + return (rawValue ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +} + +export default function (pi: ExtensionAPI) { + let toolNames: string[] = []; + let denied: string[] = []; + let expanded = false; + + // Read subagent identity from env vars (set by parent orchestrator) + const subagentName = process.env.PI_SUBAGENT_NAME ?? ""; + const subagentAgent = process.env.PI_SUBAGENT_AGENT ?? ""; + const deniedToolsValue = process.env.PI_DENY_TOOLS; + const autoExit = process.env.PI_SUBAGENT_AUTO_EXIT === "1"; + const recorder = createSubagentActivityRecorder({ + runningChildId: process.env.PI_SUBAGENT_ID, + activityFile: process.env.PI_SUBAGENT_ACTIVITY_FILE, + }); + + function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) { + ctx.ui.setWidget( + "subagent-tools", + (_tui: any, theme: any) => { + const box = new Box(1, 0, (text: string) => theme.bg("toolSuccessBg", text)); + + const label = subagentAgent || subagentName; + const agentTag = label ? theme.bold(theme.fg("accent", `[${label}]`)) : ""; + + if (expanded) { + // Expanded: full tool list + denied + const countInfo = theme.fg("dim", ` — ${toolNames.length} available`); + const hint = theme.fg("muted", " (Ctrl+Alt+O to collapse)"); + + const toolList = toolNames + .map((name: string) => theme.fg("dim", name)) + .join(theme.fg("muted", ", ")); + + let deniedLine = ""; + if (denied.length > 0) { + const deniedList = denied + .map((name: string) => theme.fg("error", name)) + .join(theme.fg("muted", ", ")); + deniedLine = "\n" + theme.fg("muted", "denied: ") + deniedList; + } + + const content = new Text( + `${agentTag}${countInfo}${hint}\n${toolList}${deniedLine}`, + 0, + 0, + ); + box.addChild(content); + } else { + // Collapsed: one-line summary + const countInfo = theme.fg("dim", ` — ${toolNames.length} tools`); + const deniedInfo = + denied.length > 0 + ? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`) + : ""; + const hint = theme.fg("muted", " (Ctrl+Alt+O to expand)"); + + const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0); + box.addChild(content); + } + + return box; + }, + { placement: "aboveEditor" }, + ); + } + + let userTookOver = false; + let agentStarted = false; + // Set when ask_question is called; suppresses auto-exit so the session stays + // open while it waits for the orchestrator's reply. Cleared when the reply + // lands — on `input` (covers a reply steered into the current run) and on + // `agent_start` (covers a reply that starts a fresh turn after parking). + let awaitingAnswer = false; + + // Show widget + status bar on session start + pi.on("session_start", (_event, ctx) => { + recorder.sessionStart(); + const tools = pi.getAllTools(); + toolNames = tools.map((t) => t.name).sort(); + denied = parseDeniedTools(deniedToolsValue); + + renderWidget(ctx, null); + }); + + pi.on("input", () => { + recorder.input(); + // A submitted message is the orchestrator's (or a human's) reply — the + // pending ask_question has been answered, however it was delivered. Clear + // here, not only on agent_start, because a reply steered in *mid-run* is + // absorbed into the current run (pi's `steer` behavior injects it before + // the next LLM call): no new agent_start fires, so without this the flag + // would stay set and agent_end would park the session as `waiting` even + // though the answer already arrived and was consumed. (The `input` event + // fires for mid-run steers because prompt() emits it before queueing.) + awaitingAnswer = false; + // Ignore the initial task message that starts an autonomous subagent. + // Only inputs after the first agent run has started count as user takeover. + if (!shouldMarkUserTookOver(agentStarted)) return; + userTookOver = true; + }); + + pi.on("before_agent_start", () => { + recorder.beforeAgentStart(); + }); + + pi.on("agent_start", () => { + agentStarted = true; + // A new turn is starting — any pending ask_question has now been answered + // (or superseded), so let auto-exit resume normally when this turn ends. + awaitingAnswer = false; + recorder.agentStart(); + }); + + pi.on("agent_end", (event, ctx) => { + const messages = (event as any).messages as any[] | undefined; + // Never shut down while this session still has work in flight: + // - awaitingAnswer: an ask_question is pending the orchestrator's reply. + // - runningChildrenCount(): this subagent spawned its own children and is + // waiting for their results (delivered as steered turns). Exiting now + // would strand those children and drop their results. + // In both cases the session parks as `waiting` and resumes when the next + // turn lands. + const hasPendingChildren = runningChildrenCount() > 0; + const shouldExit = + !awaitingAnswer && + !hasPendingChildren && + autoExit && + shouldAutoExitOnAgentEnd(userTookOver, messages); + + if (shouldExit) { + // Surface stopReason: "error" turns (auto-retry exhausted, provider + // overload, etc.) to the parent via the .exit sidecar so the watcher + // can report a clear failure with the underlying error message. + // Without this the parent would only see exit code 0 and a stale + // assistant message, mistaking the crash for a successful completion. + const errorInfo = findLatestAssistantError(messages); + const sessionFile = process.env.PI_SUBAGENT_SESSION; + if (errorInfo && sessionFile) { + try { + writeFileSync( + `${sessionFile}.exit`, + JSON.stringify({ + type: "error", + errorMessage: errorInfo.errorMessage, + stopReason: errorInfo.stopReason, + }), + ); + } catch { + // Best effort — even without the sidecar, watcher's session-file + // fallback can still recover the errorMessage. + } + } + + recorder.agentEndDone(); + ctx.shutdown(); + return; + } + + recorder.agentEndWaiting(); + if (autoExit) { + // Reset any recorded manual input marker. Auto-exit is decided by whether + // the latest agent turn completed normally, not by who initiated it. + userTookOver = false; + } + }); + + pi.on("turn_start", (event) => { + recorder.turnStart((event as any).turnIndex); + }); + + pi.on("turn_end", (event) => { + recorder.turnEnd((event as any).turnIndex); + }); + + pi.on("before_provider_request", () => { + recorder.beforeProviderRequest(); + }); + + pi.on("after_provider_response", () => { + recorder.afterProviderResponse(); + }); + + pi.on("message_update", (event) => { + recorder.messageUpdate((event as any).assistantMessageEvent?.type); + }); + + pi.on("tool_execution_start", (event) => { + recorder.toolExecutionStart((event as any).toolCallId, (event as any).toolName); + }); + + pi.on("tool_call", (event) => { + recorder.toolCall((event as any).toolCallId, (event as any).toolName); + }); + + pi.on("tool_execution_update", (event) => { + recorder.toolExecutionUpdate((event as any).toolCallId, (event as any).toolName); + }); + + pi.on("tool_result", (event) => { + recorder.toolResult((event as any).toolCallId, (event as any).toolName); + }); + + pi.on("tool_execution_end", (event) => { + recorder.toolExecutionEnd((event as any).toolCallId, (event as any).toolName); + }); + + pi.on("session_shutdown", (event) => { + recorder.sessionShutdown((event as any).reason); + }); + + // Toggle expand/collapse with Ctrl+Alt+O + pi.registerShortcut("ctrl+alt+o", { + description: "Toggle subagent tools widget", + handler: (ctx) => { + expanded = !expanded; + renderWidget(ctx, null); + }, + }); + + pi.registerTool({ + name: "ask_question", + label: "ask_question", + description: + "Ask the orchestrator (the parent agent that spawned you) a single question and pause until they reply. " + + "Use this when requirements are ambiguous, a decision would materially affect your work, you're blocked, " + + "or you need information or confirmation only the orchestrator has. Prefer asking over guessing. " + + "Your session stays open while you wait — the answer arrives as your next message, then you continue. " + + "Ask exactly one question per call; make separate calls for unrelated questions.", + promptSnippet: + "Use this tool to ask the orchestrator one clarifying, missing-requirement, preference, or decision question before continuing — instead of guessing.", + promptGuidelines: [ + "Ask exactly one question per tool call.", + "If you need answers to multiple things, make separate ask_question calls instead of bundling them.", + "Prefer this tool over guessing when requirements, preferences, or implementation choices are unclear.", + "Use it when multiple valid paths exist and the right one depends on the orchestrator's intent.", + "Give enough context in the question that the orchestrator can answer without re-reading your whole task.", + "After asking, stop and wait — the reply will arrive as your next message.", + ], + parameters: Type.Object({ + question: Type.String({ + description: + "The single freeform question to ask the orchestrator. Include enough context to answer it directly.", + }), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const sessionFile = process.env.PI_SUBAGENT_SESSION; + if (!sessionFile) { + throw new Error( + "ask_question is only available in subagent contexts. " + + "PI_SUBAGENT_SESSION environment variable is not set.", + ); + } + + // Keep the session open: suppress auto-exit for this turn and park in the + // "waiting" phase. The parent's watcher picks up the `.ask` signal and + // notifies the orchestrator, who replies via subagent_message. + awaitingAnswer = true; + recorder.askQuestion(); + const askData = { + name: process.env.PI_SUBAGENT_NAME ?? "subagent", + agent: process.env.PI_SUBAGENT_AGENT ?? "", + question: params.question, + }; + writeFileSync(`${sessionFile}.ask`, JSON.stringify(askData)); + + return { + content: [ + { + type: "text", + text: + "Question sent to the orchestrator. Stop here and wait — do not continue working or " + + "assume an answer. Their reply will arrive as your next message.", + }, + ], + details: { question: params.question }, + }; + }, + + renderCall(args, theme) { + const text = + theme.fg("toolTitle", theme.bold("ask_question ")) + + theme.fg("muted", String((args as any).question ?? "")); + return new Text(text, 0, 0); + }, + }); + +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/surface.ts b/extensions/interactive-subagents/pi-extension/subagents/surface.ts new file mode 100644 index 0000000..b96fe3f --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/surface.ts @@ -0,0 +1,224 @@ +/** + * Backend-neutral terminal surface facade. + * + * Herdr is selected when Pi is running in a Herdr-managed pane, Orca when Pi + * is running in an Orca-managed terminal. tmux remains the fallback when Pi is + * running inside tmux. Selection is automatic so a process uses one backend + * consistently for its whole lifetime. + */ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import * as herdr from "./herdr.ts"; +import * as orca from "./orca.ts"; +import * as tmux from "./tmux.ts"; + +export type MuxBackend = "herdr" | "orca" | "tmux"; +type Direction = "left" | "right" | "up" | "down"; + +type SurfaceBackend = { + createSurface(name: string): string; + createSurfaceSplit(name: string, direction: Direction, fromSurface?: string): string; + sendCommand(surface: string, command: string): void; + readScreen(surface: string, lines?: number): string; + readScreenAsync(surface: string, lines?: number): Promise; + closeSurface(surface: string): void; +}; + +export function selectMuxBackend(availability: { + herdr: boolean; + orca: boolean; + tmux: boolean; +}): MuxBackend | null { + if (availability.herdr) return "herdr"; + if (availability.orca) return "orca"; + if (availability.tmux) return "tmux"; + return null; +} + +export function getMuxBackend(): MuxBackend | null { + return selectMuxBackend({ + herdr: herdr.isAvailable(), + orca: orca.isAvailable(), + tmux: tmux.isAvailable(), + }); +} + +function getBackend(): SurfaceBackend { + const backend = getMuxBackend(); + if (backend === "herdr") return herdr; + if (backend === "orca") return orca; + if (backend === "tmux") return tmux; + throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`); +} + +export function isMuxAvailable(): boolean { + return getMuxBackend() !== null; +} + +export function muxSetupHint(): string { + if (process.env.HERDR_ENV === "1") return herdr.setupHint(); + if (process.env.ORCA_TERMINAL_HANDLE) return orca.setupHint(); + if (process.env.TMUX) return tmux.setupHint(); + return "Start pi inside Herdr, Orca, or tmux."; +} + +export function createSurface(name: string): string { + return getBackend().createSurface(name); +} + +export function createSurfaceSplit( + name: string, + direction: Direction, + fromSurface?: string, +): string { + return getBackend().createSurfaceSplit(name, direction, fromSurface); +} + +export function sendCommand(surface: string, command: string): void { + getBackend().sendCommand(surface, command); +} + +export function readScreen(surface: string, lines = 50): string { + return getBackend().readScreen(surface, lines); +} + +export function readScreenAsync(surface: string, lines = 50): Promise { + return getBackend().readScreenAsync(surface, lines); +} + +export function closeSurface(surface: string): void { + getBackend().closeSurface(surface); +} + +export function shellEscape(value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'"; +} + +export function wrapWithCompletionFile(command: string, completionFile: string): string { + return ( + `${command}; exit_code=$?; ` + + `printf '%s\\n' "$exit_code" > ${shellEscape(completionFile)}; ` + + `echo "__SUBAGENT_DONE_\${exit_code}__"` + ); +} + +export function sendLongCommand( + surface: string, + command: string, + options?: { scriptPath?: string; scriptPreamble?: string }, +): string { + const scriptPath = + options?.scriptPath ?? + join( + tmpdir(), + "pi-subagent-scripts", + `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`, + ); + mkdirSync(dirname(scriptPath), { recursive: true }); + + const scriptParts = ["#!/bin/bash"]; + if (options?.scriptPreamble) scriptParts.push(options.scriptPreamble.trimEnd()); + scriptParts.push(command); + + writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 }); + sendCommand(surface, `bash ${shellEscape(scriptPath)}`); + return scriptPath; +} + +export interface PollResult { + reason: "done" | "sentinel" | "error"; + exitCode: number; + errorMessage?: string; +} + +function interpretExitSidecar(data: unknown): PollResult { + const payload = data as { type?: unknown; errorMessage?: unknown } | null; + if (payload?.type === "error") { + const errorMessage = + typeof payload.errorMessage === "string" && payload.errorMessage.trim() !== "" + ? payload.errorMessage + : "Subagent exited with stopReason=error (no errorMessage in sidecar)."; + return { reason: "error", exitCode: 1, errorMessage }; + } + return { reason: "done", exitCode: 0 }; +} + +function readCompletionFile(path: string): PollResult | null { + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf8").trim(); + const exitCode = Number.parseInt(raw, 10); + if (!/^\d+$/.test(raw) || !Number.isSafeInteger(exitCode)) return null; + rmSync(path, { force: true }); + return { reason: "sentinel", exitCode }; +} + +export const __pollForExitTest__ = { interpretExitSidecar, readCompletionFile }; + +export async function pollForExit( + surface: string, + signal: AbortSignal, + options: { + interval: number; + sessionFile?: string; + sentinelFile?: string; + completionFile?: string; + onTick?: (elapsed: number) => void; + }, +): Promise { + const start = Date.now(); + + for (;;) { + if (signal.aborted) throw new Error("Aborted while waiting for subagent to finish"); + + if (options.sessionFile) { + try { + const exitFile = `${options.sessionFile}.exit`; + if (existsSync(exitFile)) { + const result = interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8"))); + rmSync(exitFile, { force: true }); + return result; + } + } catch {} + } + + if (options.sentinelFile) { + try { + if (existsSync(options.sentinelFile)) { + return { reason: "sentinel", exitCode: 0 }; + } + } catch {} + } + + if (options.completionFile) { + try { + const result = readCompletionFile(options.completionFile); + if (result) return result; + } catch {} + } + + try { + const screen = await readScreenAsync(surface, 5); + const match = screen.match(/__SUBAGENT_DONE_(\d+)__/); + if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) }; + } catch { + // Surface reads are best-effort. Filesystem completion remains authoritative. + } + + const elapsed = Math.floor((Date.now() - start) / 1000); + options.onTick?.(elapsed); + + await new Promise((resolve, reject) => { + if (signal.aborted) return reject(new Error("Aborted")); + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, options.interval); + function onAbort() { + clearTimeout(timer); + reject(new Error("Aborted")); + } + signal.addEventListener("abort", onAbort, { once: true }); + }); + } +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/tmux.ts b/extensions/interactive-subagents/pi-extension/subagents/tmux.ts new file mode 100644 index 0000000..e0e0f51 --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/tmux.ts @@ -0,0 +1,119 @@ +/** + * tmux surface backend. + * + * This module only implements tmux-specific terminal operations. Backend + * selection, command scripts, and exit polling live in surface.ts. + */ +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const commandAvailability = new Map(); + +function hasCommand(command: string): boolean { + const cached = commandAvailability.get(command); + if (cached !== undefined) return cached; + + let available = false; + try { + execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" }); + available = true; + } catch { + available = false; + } + + commandAvailability.set(command, available); + return available; +} + +export function isAvailable(): boolean { + return !!process.env.TMUX && hasCommand("tmux"); +} + +export function setupHint(): string { + return "Start pi inside tmux (`tmux new -A -s pi 'pi'`)."; +} + +function requireTmux(): void { + if (!isAvailable()) { + throw new Error(`tmux is required for this surface. ${setupHint()}`); + } +} + +/** tmux layout used to keep sibling panes evenly sized. */ +const SUBAGENT_TMUX_LAYOUT = "even-horizontal"; +let rebalanceTimer: ReturnType | null = null; + +function rebalanceSurfaces(hintPane?: string): void { + const target = process.env.TMUX_PANE ?? hintPane; + if (!target) return; + if (rebalanceTimer) clearTimeout(rebalanceTimer); + rebalanceTimer = setTimeout(() => { + rebalanceTimer = null; + try { + execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], { + encoding: "utf8", + }); + } catch { + // Pane/window may be gone; balancing is best-effort. + } + }, 120); +} + +export function createSurface(name: string): string { + return createSurfaceSplit(name, "right", process.env.TMUX_PANE); +} + +export function createSurfaceSplit( + name: string, + direction: "left" | "right" | "up" | "down", + fromSurface?: string, +): string { + void name; + requireTmux(); + + const args = ["split-window", "-d"]; + args.push(direction === "left" || direction === "right" ? "-h" : "-v"); + if (direction === "left" || direction === "up") args.push("-b"); + if (fromSurface) args.push("-t", fromSurface); + args.push("-P", "-F", "#{pane_id}"); + + const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim(); + if (!pane.startsWith("%")) { + throw new Error(`Unexpected tmux split-window output: ${pane}`); + } + + rebalanceSurfaces(pane); + return pane; +} + +export function sendCommand(surface: string, command: string): void { + requireTmux(); + execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" }); + execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" }); +} + +export function readScreen(surface: string, lines = 50): string { + requireTmux(); + return execFileSync( + "tmux", + ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`], + { encoding: "utf8" }, + ); +} + +export async function readScreenAsync(surface: string, lines = 50): Promise { + requireTmux(); + const { stdout } = await execFileAsync( + "tmux", + ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`], + { encoding: "utf8" }, + ); + return stdout; +} + +export function closeSurface(surface: string): void { + requireTmux(); + execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" }); + rebalanceSurfaces(); +} diff --git a/extensions/interactive-subagents/pi-extension/subagents/tools/safe-bash.ts b/extensions/interactive-subagents/pi-extension/subagents/tools/safe-bash.ts new file mode 100644 index 0000000..8b6d4a6 --- /dev/null +++ b/extensions/interactive-subagents/pi-extension/subagents/tools/safe-bash.ts @@ -0,0 +1,63 @@ +/** + * Safe bash extension for the worker subagent. + * Wraps the built-in bash tool with dangerous command blocking. + * + * Loaded into a child pi process via `--extension` when an agent's `tools` + * frontmatter lists `safe_bash`. See CUSTOM_TOOL_EXTENSIONS in ../index.ts. + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createBashTool } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const DANGEROUS_PATTERNS = [ + /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/, + /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/, + /\bsudo\b/, + /\bmkfs\b/, + /\bdd\s+if=/, + /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, + />\s*\/dev\/[sh]d[a-z]/, + /\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//, + /\bchown\s+(-[a-zA-Z]+\s+)?root/, + /\bcurl\s.*\|\s*(ba)?sh/, + /\bwget\s.*\|\s*(ba)?sh/, + /\bshutdown\b/, + /\breboot\b/, + /\binit\s+0\b/, + /\bkill\s+-9\s+1\b/, + /\bkillall\b/, +]; + +function isDangerous(command: string): string | null { + const normalized = command.replace(/\\\n/g, " "); + for (const pattern of DANGEROUS_PATTERNS) { + if (pattern.test(normalized)) { + return `Command blocked by safe_bash: matches dangerous pattern ${pattern}`; + } + } + return null; +} + +export default function (pi: ExtensionAPI) { + const bashTool = createBashTool(process.cwd()); + + pi.registerTool({ + name: "safe_bash", + label: "Safe Bash", + description: + "Execute a bash command. Blocks dangerous commands (rm -rf /, sudo, mkfs, etc.).", + parameters: Type.Object({ + command: Type.String({ description: "Bash command to execute" }), + timeout: Type.Optional( + Type.Number({ description: "Timeout in seconds (optional)" }), + ), + }), + async execute(toolCallId, params, signal, onUpdate, ctx) { + const danger = isDangerous(params.command); + if (danger) { + throw new Error(danger); + } + return bashTool.execute(toolCallId, params, signal, onUpdate); + }, + }); +} diff --git a/extensions/interactive-subagents/test/integration/agents/test-echo.md b/extensions/interactive-subagents/test/integration/agents/test-echo.md new file mode 100644 index 0000000..ea31510 --- /dev/null +++ b/extensions/interactive-subagents/test/integration/agents/test-echo.md @@ -0,0 +1,13 @@ +--- +name: test-echo +description: Integration test agent — completes simple file-writing tasks +model: anthropic/claude-haiku-4-5 +tools: read, bash, write, edit +spawning: false +auto-exit: true +disable-model-invocation: true +--- + +You are a test agent. Complete the task given to you immediately. Be direct and concise. +When asked to write content to a file, do it right away using the bash tool. +Do not ask questions. Do not explain. Just execute the task. diff --git a/extensions/interactive-subagents/test/integration/agents/test-fork.md b/extensions/interactive-subagents/test/integration/agents/test-fork.md new file mode 100644 index 0000000..d55707b --- /dev/null +++ b/extensions/interactive-subagents/test/integration/agents/test-fork.md @@ -0,0 +1,13 @@ +--- +name: test-fork +description: Integration test agent — fork-mode child that inherits the parent session +model: anthropic/claude-haiku-4-5 +tools: read, bash +session-mode: fork +auto-exit: true +disable-model-invocation: true +--- + +You are a test agent. Complete the task given to you immediately. Be direct and concise. +When asked to write content to a file, do it right away using the bash tool. +Do not ask questions. Do not explain. Just execute the task. diff --git a/extensions/interactive-subagents/test/integration/agents/test-ping.md b/extensions/interactive-subagents/test/integration/agents/test-ping.md new file mode 100644 index 0000000..d2e0797 --- /dev/null +++ b/extensions/interactive-subagents/test/integration/agents/test-ping.md @@ -0,0 +1,11 @@ +--- +name: test-ping +description: Integration test agent — calls caller_ping instead of completing task +model: anthropic/claude-haiku-4-5 +tools: read, bash +spawning: false +disable-model-invocation: true +--- + +You are a test agent. When given ANY task, you must call the caller_ping tool with the message set to "PING: " followed by the task text you received. +Do NOT complete the task yourself. Do NOT use any other tools. ONLY call caller_ping. diff --git a/extensions/interactive-subagents/test/integration/harness.ts b/extensions/interactive-subagents/test/integration/harness.ts new file mode 100644 index 0000000..0ff156d --- /dev/null +++ b/extensions/interactive-subagents/test/integration/harness.ts @@ -0,0 +1,402 @@ +/** + * Integration test harness for pi-interactive-subagents. + * + * Provides utilities to: + * - Detect the active terminal-surface backend + * - Create isolated test environments with test agent definitions + * - Start real pi sessions in Herdr tabs or tmux panes + * - Poll for file creation and screen output + * - Clean up panes and temp files after tests + */ +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + cpSync, + readdirSync, + rmSync, + existsSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; +import { + getMuxBackend, + createSurface, + createSurfaceSplit, + sendCommand, + sendLongCommand, + readScreen, + readScreenAsync, + closeSurface, + pollForExit, + shellEscape, + wrapWithCompletionFile, + type MuxBackend, +} from "../../pi-extension/subagents/surface.ts"; + +// Re-export backend-neutral surface primitives for tests +export { + createSurface, + createSurfaceSplit, + sendCommand, + sendLongCommand, + readScreen, + readScreenAsync, + closeSurface, + pollForExit, + shellEscape, + wrapWithCompletionFile, +}; + +// ── Paths ── + +const HARNESS_DIR = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = resolve(HARNESS_DIR, "../.."); +const TEST_AGENTS_SRC = join(HARNESS_DIR, "agents"); + +/** + * Absolute path to the extension source in the working tree. + * + * Integration tests must exercise the code on the current branch — NOT the + * version installed as a pi-package under `~/.pi/agent/git/...` or the project + * mirror under `.pi/git/...`, which stays pinned to the last released tag. + * + * We force-load this file via `pi -ne -e ` in startPi() below so local + * edits are always the code under test, regardless of what pi-packages are + * installed on the host. + */ +const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.ts"); + +// ── Configuration ── + +/** + * Model used for integration tests. Override with PI_TEST_MODEL env var. + * + * The default must be a provider that exists under `pi -ne`: startPi() launches + * with extensions disabled, so extension-registered providers (and therefore + * the parent session's active model) may not resolve in the child. + */ +export const TEST_MODEL = process.env.PI_TEST_MODEL ?? "openai-codex/gpt-5.6-luna"; + +/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */ +export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000"); + +// ── Backend detection ── + +/** Return the one backend selected automatically for this process. */ +export function getAvailableBackends(): MuxBackend[] { + const backend = getMuxBackend(); + return backend ? [backend] : []; +} + +function parseHerdrJson(command: string, args: string[]): any { + const output = execFileSync(command, args, { encoding: "utf8" }); + return JSON.parse(output); +} + +function orcaCli(): string { + return process.env.ORCA_CLI_COMMAND?.trim() || "orca"; +} + +function parseOrcaJson(args: string[]): any { + const output = execFileSync(orcaCli(), args, { encoding: "utf8" }); + const response = JSON.parse(output); + if (response.ok === false) { + throw new Error(`Orca ${args.slice(0, 2).join(" ")} failed: ${response.error?.message}`); + } + return response; +} + +/** Depth-first search of an Orca visual-layout pane tree for the active terminal. */ +function findActiveOrcaTerminal(node: any): string | null { + if (!node || typeof node !== "object") return null; + if (node.type === "terminal") return node.active ? node.handle ?? null : null; + for (const child of Object.values(node)) { + if (Array.isArray(child)) { + for (const item of child) { + const found = findActiveOrcaTerminal(item); + if (found) return found; + } + } else if (child && typeof child === "object") { + const found = findActiveOrcaTerminal(child); + if (found) return found; + } + } + return null; +} + +export function focusSurface(surface: string): void { + if (getMuxBackend() === "orca") { + parseOrcaJson(["terminal", "switch", "--terminal", surface, "--json"]); + return; + } + if (getMuxBackend() === "herdr") { + const pane = parseHerdrJson("herdr", ["pane", "get", surface]); + const tabId = pane.result?.pane?.tab_id; + if (!tabId) throw new Error(`Herdr pane ${surface} has no tab id`); + execFileSync("herdr", ["tab", "focus", tabId], { encoding: "utf8" }); + return; + } + execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" }); +} + +export function getFocusedSurface(): string | null { + try { + if (getMuxBackend() === "orca") { + const response = parseOrcaJson(["terminal", "list", "--include-visual-layouts", "--json"]); + const layouts = response.result?.visualLayouts ?? []; + for (const layout of layouts) { + const root = layout.root; + if (!root) continue; + const activeTab = root.tabs?.find((tab: any) => tab.tabId === root.activeTabId); + const handle = findActiveOrcaTerminal(activeTab?.panes); + if (handle) return handle; + } + return null; + } + + if (getMuxBackend() === "herdr") { + const workspace = process.env.HERDR_WORKSPACE_ID; + if (!workspace) return null; + const response = parseHerdrJson("herdr", ["pane", "list", "--workspace", workspace]); + return response.result?.panes?.find((pane: any) => pane.focused)?.pane_id ?? null; + } + + const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], { + encoding: "utf8", + }); + const activeLine = panes.split("\n").find((line) => line.endsWith(" 1")); + return activeLine?.split(" ")[0] ?? null; + } catch { + return null; + } +} + +export async function waitForFocusedSurface( + surface: string, + timeout: number = PI_TIMEOUT, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (getFocusedSurface() === surface) return; + await sleep(200); + } + + throw new Error( + `Timeout (${timeout}ms) waiting for focused terminal surface ${surface}; ` + + `current focus is ${getFocusedSurface() ?? "unknown"}`, + ); +} + +// ── Test environment ── + +export interface TestEnv { + /** Temp directory serving as the test project root */ + dir: string; + /** Panes created during the test (cleaned up automatically) */ + surfaces: string[]; + /** Temp files to clean up */ + tempFiles: string[]; +} + +/** + * Create an isolated test environment with test agent definitions. + * The temp dir has `.pi/agents/` containing copies of all test agents. + */ +export function createTestEnv(): TestEnv { + const dir = mkdtempSync(join(tmpdir(), "pi-integ-")); + const agentsDir = join(dir, ".pi", "agents"); + mkdirSync(agentsDir, { recursive: true }); + + // Copy test agent definitions into the project-local agents dir + if (existsSync(TEST_AGENTS_SRC)) { + for (const file of readdirSync(TEST_AGENTS_SRC)) { + if (file.endsWith(".md")) { + const destination = join(agentsDir, file); + cpSync(join(TEST_AGENTS_SRC, file), destination); + const definition = readFileSync(destination, "utf8").replace( + /^model:\s*.+$/m, + `model: ${TEST_MODEL}`, + ); + writeFileSync(destination, definition); + } + } + } + + return { dir, surfaces: [], tempFiles: [] }; +} + +/** + * Clean up all resources created during the test. + */ +export function cleanupTestEnv(env: TestEnv): void { + for (const surface of env.surfaces) { + try { + closeSurface(surface); + } catch {} + } + for (const file of env.tempFiles) { + try { + unlinkSync(file); + } catch {} + } + try { + rmSync(env.dir, { recursive: true, force: true }); + } catch {} +} + +/** + * Create a surface and register it for automatic cleanup. + */ +export function createTrackedSurface(env: TestEnv, name: string): string { + const surface = createSurface(name); + env.surfaces.push(surface); + return surface; +} + +export function createTrackedSurfaceSplit( + env: TestEnv, + name: string, + direction: "left" | "right" | "up" | "down", + fromSurface?: string, +): string { + const surface = createSurfaceSplit(name, direction, fromSurface); + env.surfaces.push(surface); + return surface; +} + +/** + * Remove a surface from tracking (after manual close). + */ +export function untrackSurface(env: TestEnv, surface: string): void { + env.surfaces = env.surfaces.filter((s) => s !== surface); +} + +// ── Pi session management ── + +/** + * Start a pi session in a mux surface with the subagents extension loaded. + * Returns immediately — the pi process runs asynchronously in the surface. + * + * The command ends with a sentinel so we can detect when pi exits: + * `pi ...; echo '__TEST_DONE_'$?'__'` + */ +export function startPi( + surface: string, + testDir: string, + task: string, + opts?: { model?: string; extraArgs?: string }, +): void { + const model = opts?.model ?? TEST_MODEL; + const extra = opts?.extraArgs ?? ""; + + // Force pi to load the working-tree extension (not an installed pi-package + // snapshot). `-ne` disables extension auto-discovery, `-e ` loads the + // current branch's source directly. Without this, the tests silently run + // against whatever version is checked out under `~/.pi/agent/git/...`. + const cmd = [ + `cd ${shellEscape(testDir)} &&`, + `pi`, + `-ne`, + `-e ${shellEscape(EXTENSION_SOURCE)}`, + `--model ${shellEscape(model)}`, + extra, + shellEscape(task), + ] + .filter(Boolean) + .join(" "); + + // Herdr does not retain terminal output for tabs that have never been shown. + // Focus the test runner surface so waitForScreen can observe its transcript. + focusSurface(surface); + sendLongCommand(surface, `${cmd}; echo '__TEST_DONE_'$?'__'`, { + scriptPath: join(testDir, `test-launch-${Date.now()}.sh`), + }); +} + +// ── Polling helpers ── + +/** + * Poll until a regex pattern appears in the surface's screen output. + * Throws on timeout with the last screen contents for debugging. + */ +export async function waitForScreen( + surface: string, + pattern: RegExp, + timeout: number = PI_TIMEOUT, + lines: number = 200, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const screen = await readScreenAsync(surface, lines); + if (pattern.test(screen)) return screen; + } catch {} + await sleep(2000); + } + + let finalScreen = ""; + try { + finalScreen = readScreen(surface, lines); + } catch {} + throw new Error( + `Timeout (${timeout}ms) waiting for pattern ${pattern}.\nLast screen:\n${finalScreen.slice(-1000)}`, + ); +} + +/** + * Poll until a file exists and optionally matches a content pattern. + * Returns the file content on success. + */ +export async function waitForFile( + path: string, + timeout: number = PI_TIMEOUT, + contentPattern?: RegExp, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (existsSync(path)) { + const content = readFileSync(path, "utf8"); + if (!contentPattern || contentPattern.test(content)) return content; + } + await sleep(2000); + } + throw new Error( + `Timeout (${timeout}ms) waiting for file: ${path}` + + (contentPattern ? ` matching ${contentPattern}` : ""), + ); +} + +/** + * Wait for the pi process in a surface to exit (sentinel detection). + * Returns the exit code. + */ +export async function waitForPiExit( + surface: string, + timeout: number = PI_TIMEOUT, +): Promise { + const screen = await waitForScreen(surface, /__TEST_DONE_(\d+)__/, timeout); + const match = screen.match(/__TEST_DONE_(\d+)__/); + return match ? parseInt(match[1], 10) : -1; +} + +// ── Utilities ── + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function uniqueId(): string { + return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); +} + +/** + * Register a temp file for cleanup. + */ +export function trackTempFile(env: TestEnv, path: string): void { + env.tempFiles.push(path); +} diff --git a/extensions/interactive-subagents/test/integration/subagent-lifecycle.test.ts b/extensions/interactive-subagents/test/integration/subagent-lifecycle.test.ts new file mode 100644 index 0000000..7b6dcea --- /dev/null +++ b/extensions/interactive-subagents/test/integration/subagent-lifecycle.test.ts @@ -0,0 +1,378 @@ +/** + * Integration tests for the full subagent lifecycle. + * + * These tests spawn REAL pi sessions with REAL LLM calls (gpt-5.6-luna by default). + * Each test creates a terminal surface, runs pi with a task that uses the subagent + * tool, and verifies the outcome via marker files and screen output. + * + * Costs: ~$0.01-0.05 per test run. + * Duration: ~30-90s per test. + * + * Run inside Herdr, inside an Orca terminal, or inside tmux: + * tmux new 'npm run test:integration' + * + * Configuration: + * PI_TEST_MODEL — model for all pi sessions (default: openai-codex/gpt-5.6-luna) + * PI_TEST_TIMEOUT — per-test timeout in ms (default: 120000) + */ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { + getAvailableBackends, + createTestEnv, + cleanupTestEnv, + createTrackedSurface, + startPi, + waitForScreen, + waitForFile, + sleep, + uniqueId, + trackTempFile, + readScreen, + sendCommand, + PI_TIMEOUT, + type TestEnv, +} from "./harness.ts"; + +const backends = getAvailableBackends(); + +if (backends.length === 0) { + console.log("⚠️ Herdr/Orca/tmux is not available — skipping subagent lifecycle integration tests"); + console.log(" Run inside Herdr, Orca, or tmux to enable these tests."); +} + +for (const backend of backends) { + describe(`subagent-lifecycle [${backend}]`, { timeout: PI_TIMEOUT * 3 }, () => { + let env: TestEnv; + + before(() => { + env = createTestEnv(); + }); + + after(() => { + cleanupTestEnv(env); + }); + + // ── Basic spawn + completion ── + + it("spawns a subagent that writes a file and verifies the session", async () => { + const id = uniqueId(); + const markerFile = `/tmp/pi-integ-echo-${id}.txt`; + trackTempFile(env, markerFile); + + const surface = createTrackedSurface(env, `echo-${id}`); + await sleep(1000); + + const task = [ + `Call the subagent tool with these EXACT parameters:`, + ` name: "Echo-${id}"`, + ` agent: "test-echo"`, + ` task: "Run this bash command: echo 'PASS_${id}' > '${markerFile}'"`, + `Do not do anything else. Just call the subagent tool once.`, + `After you receive the subagent result, say INTEGRATION_COMPLETE.`, + ].join("\n"); + + startPi(surface, env.dir, task); + + // Verify: subagent created the marker file + const content = await waitForFile(markerFile, PI_TIMEOUT, /PASS/); + assert.ok( + content.includes(`PASS_${id}`), + `Marker file should contain PASS_${id}. Got: ${content.trim()}`, + ); + + // Verify: outer pi received the subagent result + const screen = await waitForScreen( + surface, + /INTEGRATION_COMPLETE|completed|Sub-agent.*"Echo/i, + PI_TIMEOUT, + ); + + // Verify: session file was created (shown in steer result) + const sessionMatch = screen.match(/Session:\s*(\S+\.jsonl)/); + if (sessionMatch) { + const sessionFile = sessionMatch[1]; + assert.ok(existsSync(sessionFile), `Subagent session file should exist: ${sessionFile}`); + + const lines = readFileSync(sessionFile, "utf8").trim().split("\n"); + assert.ok(lines.length >= 2, `Session should have ≥2 entries, got ${lines.length}`); + + const header = JSON.parse(lines[0]); + assert.equal(header.type, "session", "First entry should be session header"); + assert.ok(header.id, "Session header should have an id"); + } + }); + + // ── Reload preservation ── + + it("keeps a running subagent and fresh tool wrappers across parent extension reload", async () => { + const id = uniqueId(); + const startedFile = `/tmp/pi-integ-reload-start-${id}.txt`; + const completedFile = `/tmp/pi-integ-reload-done-${id}.txt`; + const followupFile = `/tmp/pi-integ-reload-followup-${id}.txt`; + trackTempFile(env, startedFile); + trackTempFile(env, completedFile); + trackTempFile(env, followupFile); + + const surface = createTrackedSurface(env, `reload-${id}`); + await sleep(1000); + + const task = [ + `Call the subagent tool once with these EXACT parameters:`, + ` name: "Reload-${id}"`, + ` agent: "test-echo"`, + ` task: "Run: echo 'START_${id}' > '${startedFile}'; sleep 10; echo 'DONE_${id}' > '${completedFile}'"`, + `Do not run the command yourself.`, + `After that subagent result arrives, call the subagent tool once more with these EXACT parameters:`, + ` name: "Reload-followup-${id}"`, + ` agent: "test-echo"`, + ` task: "Write 'FOLLOWUP_${id}' to '${followupFile}'"`, + `After the second subagent result arrives, say RELOAD_SURVIVED_${id}.`, + ].join("\n"); + + startPi(surface, env.dir, task); + await waitForFile(startedFile, PI_TIMEOUT, /START_/); + + sendCommand(surface, "/reload"); + + const completed = await waitForFile(completedFile, PI_TIMEOUT, /DONE_/); + assert.ok(completed.includes(`DONE_${id}`), "child should finish after parent reload"); + const followup = await waitForFile(followupFile, PI_TIMEOUT, /FOLLOWUP_/); + assert.ok( + followup.includes(`FOLLOWUP_${id}`), + "the result-triggered turn should spawn through the fresh tool wrapper", + ); + + const resultMarker = `RELOAD_SURVIVED_${id}`; + const responseLine = new RegExp(`^ ${resultMarker}\\s*$`, "m"); + const screen = await waitForScreen(surface, responseLine, PI_TIMEOUT, 300); + assert.equal( + screen.match(new RegExp(`^ ${resultMarker}\\s*$`, "gm"))?.length, + 1, + "the fresh runtime should receive the completion exactly once", + ); + assert.doesNotMatch(screen, /Subagent cancelled|ctx is stale after session replacement or reload/i); + }); + + // ── In-progress activity snapshots ── + + it("keeps a long active tool call from surfacing false stalled status", async () => { + const id = uniqueId(); + const startFile = `/tmp/pi-integ-status-start-${id}.txt`; + const markerFile = `/tmp/pi-integ-status-${id}.txt`; + trackTempFile(env, startFile); + trackTempFile(env, markerFile); + + const surface = createTrackedSurface(env, `status-${id}`); + await sleep(1000); + + const task = [ + `Call the subagent tool with these EXACT parameters:`, + ` name: "Status-${id}"`, + ` agent: "test-echo"`, + ` task: "Run this bash command: echo 'START_${id}' > '${startFile}'; sleep 90; echo 'STATUS_${id}' > '${markerFile}'"`, + `Do not do anything else. Just call the subagent tool once.`, + `After you receive the subagent result, say STATUS_TEST_DONE.`, + ].join("\n"); + + startPi(surface, env.dir, task); + + const activeScreen = await waitForScreen(surface, /active[\s\S]*bash|bash[\s\S]*active/i, PI_TIMEOUT, 300); + assert.doesNotMatch(activeScreen, /Subagent status[\s\S]*stalled|stalled[\s\S]*Subagent status/i); + + await waitForFile(startFile, PI_TIMEOUT, /START_/); + assert.equal(existsSync(markerFile), false, "Completion marker should not exist before the long sleep"); + await sleep(65_000); + assert.equal(existsSync(markerFile), false, "Completion marker should not exist before the watchdog assertion"); + const watchdogScreen = readScreen(surface, 300); + assert.doesNotMatch(watchdogScreen, /Subagent status[\s\S]*stalled|stalled[\s\S]*Subagent status/i); + + const content = await waitForFile(markerFile, PI_TIMEOUT, /STATUS_/); + assert.ok(content.includes(`STATUS_${id}`), `Marker file should contain STATUS_${id}`); + + const completionScreen = await waitForScreen( + surface, + /STATUS_TEST_DONE|completed|Sub-agent.*"Status-/i, + PI_TIMEOUT, + 300, + ); + assert.ok(/STATUS_TEST_DONE|completed/i.test(completionScreen)); + }); + + // ── Parallel subagent spawn ── + + it("spawns two subagents in parallel and both complete", async () => { + const id = uniqueId(); + const fileA = `/tmp/pi-integ-para-${id}-a.txt`; + const fileB = `/tmp/pi-integ-para-${id}-b.txt`; + trackTempFile(env, fileA); + trackTempFile(env, fileB); + + const surface = createTrackedSurface(env, `parallel-${id}`); + await sleep(1000); + + const task = [ + `You must call the subagent tool TWICE. Make both calls before waiting for results.`, + ``, + `First call:`, + ` name: "ParaA-${id}"`, + ` agent: "test-echo"`, + ` task: "Run: echo 'DONE_A_${id}' > '${fileA}'"`, + ``, + `Second call:`, + ` name: "ParaB-${id}"`, + ` agent: "test-echo"`, + ` task: "Run: echo 'DONE_B_${id}' > '${fileB}'"`, + ``, + `Call both subagent tools NOW, do not wait between them.`, + ].join("\n"); + + startPi(surface, env.dir, task); + + // Both marker files should appear + const [contentA, contentB] = await Promise.all([ + waitForFile(fileA, PI_TIMEOUT, /DONE_A/), + waitForFile(fileB, PI_TIMEOUT, /DONE_B/), + ]); + + assert.ok(contentA.includes(`DONE_A_${id}`), `File A should contain marker`); + assert.ok(contentB.includes(`DONE_B_${id}`), `File B should contain marker`); + }); + + // ── Fork mode ── + + it("fork mode creates a child session linked to the parent", async () => { + const id = uniqueId(); + const markerFile = `/tmp/pi-integ-fork-${id}.txt`; + trackTempFile(env, markerFile); + + const surface = createTrackedSurface(env, `fork-${id}`); + await sleep(1000); + + const task = [ + `Call the subagent tool with these EXACT parameters:`, + ` name: "Fork-${id}"`, + ` agent: "test-fork"`, + ` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`, + `Do not do anything else. Just call the subagent tool once.`, + `After you receive the result, say FORK_COMPLETE.`, + ].join("\n"); + + startPi(surface, env.dir, task); + + // Verify: forked subagent created the file + const content = await waitForFile(markerFile, PI_TIMEOUT, /FORK_OK/); + assert.ok(content.includes(`FORK_OK_${id}`), `Fork marker file should exist with content`); + + // Wait for the outer pi to show the result + const screen = await waitForScreen( + surface, + /FORK_COMPLETE|completed|Sub-agent.*"Fork/i, + PI_TIMEOUT, + ); + + // Verify: the forked session has a parent link + const sessionMatch = screen.match(/Session:\s*(\S+\.jsonl)/); + if (sessionMatch) { + const sessionFile = sessionMatch[1]; + assert.ok(existsSync(sessionFile), `Fork session file should exist: ${sessionFile}`); + + const entries = readFileSync(sessionFile, "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l)); + const header = entries[0]; + assert.equal(header.type, "session", "First entry should be session header"); + assert.ok(header.parentSession, "Fork session should have parentSession field"); + // Fork sessions include parent context (model_change entries etc.) + assert.ok(entries.length >= 2, "Fork session should have context entries beyond header"); + } + }); + + // ── caller_ping ── + + it("subagent caller_ping sends notification back to the parent", async () => { + const id = uniqueId(); + + const surface = createTrackedSurface(env, `ping-${id}`); + await sleep(1000); + + const task = [ + `Call the subagent tool with these EXACT parameters:`, + ` name: "Ping-${id}"`, + ` agent: "test-ping"`, + ` task: "PING_TEST_${id}"`, + `Just call the subagent tool once. Do not do anything else before calling it.`, + ].join("\n"); + + startPi(surface, env.dir, task); + + // The test-ping agent calls caller_ping, which steers a "needs help" message + // back to the outer pi. Look for it on screen. + const screen = await waitForScreen( + surface, + /needs help|PING|caller_ping|ping/i, + PI_TIMEOUT, + ); + + assert.ok( + /needs help|PING/i.test(screen), + `Screen should show ping notification. Got:\n${screen.slice(-800)}`, + ); + }); + + // ── Agent discovery ── + + it("subagent discovers project-local test agents", async () => { + const id = uniqueId(); + const markerFile = `/tmp/pi-integ-discovery-${id}.txt`; + trackTempFile(env, markerFile); + + const surface = createTrackedSurface(env, `discovery-${id}`); + await sleep(1000); + + // Use subagents_list to verify test agents are discoverable, + // then spawn one to prove it works end-to-end. + const task = [ + `First, call the subagents_list tool to see available agents.`, + `Then call the subagent tool:`, + ` name: "Disco-${id}"`, + ` agent: "test-echo"`, + ` task: "Run: echo 'DISCO_${id}' > '${markerFile}'"`, + `After you receive the subagent result, say DISCOVERY_DONE.`, + ].join("\n"); + + startPi(surface, env.dir, task); + + // The test-echo agent (discovered from project .pi/agents/) should work + const content = await waitForFile(markerFile, PI_TIMEOUT, /DISCO/); + assert.ok(content.includes(`DISCO_${id}`), `Discovery test marker should exist`); + }); + + // ── Subagent with custom system prompt ── + + it("passes systemPrompt to subagent", async () => { + const id = uniqueId(); + const markerFile = `/tmp/pi-integ-sysprompt-${id}.txt`; + trackTempFile(env, markerFile); + + const surface = createTrackedSurface(env, `sysprompt-${id}`); + await sleep(1000); + + const task = [ + `Call the subagent tool with these parameters:`, + ` name: "SysP-${id}"`, + ` agent: "test-echo"`, + ` systemPrompt: "Always start your response with CUSTOM_PROMPT_ACTIVE."`, + ` task: "Write 'SYSPROMPT_${id}' to ${markerFile} using bash: echo 'SYSPROMPT_${id}' > '${markerFile}'"`, + `After the subagent completes, say SYSPROMPT_TEST_DONE.`, + ].join("\n"); + + startPi(surface, env.dir, task); + + const content = await waitForFile(markerFile, PI_TIMEOUT, /SYSPROMPT/); + assert.ok(content.includes(`SYSPROMPT_${id}`), `System prompt test marker should exist`); + }); + }); +} diff --git a/extensions/interactive-subagents/test/integration/surface.test.ts b/extensions/interactive-subagents/test/integration/surface.test.ts new file mode 100644 index 0000000..f290826 --- /dev/null +++ b/extensions/interactive-subagents/test/integration/surface.test.ts @@ -0,0 +1,226 @@ +/** + * Integration tests for the selected terminal-surface backend. + * + * These tests exercise real Herdr, Orca, or tmux operations: creating surfaces, + * sending commands, reading screen output, and closing panes. + * No LLM calls — fast and free. + * + * Run inside Herdr, inside an Orca terminal, or inside tmux: + * tmux new 'npm run test:integration' + */ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { unlinkSync } from "node:fs"; +import { + getAvailableBackends, + createTestEnv, + cleanupTestEnv, + createTrackedSurface, + focusSurface, + getFocusedSurface, + waitForFocusedSurface, + untrackSurface, + sendCommand, + sendLongCommand, + readScreen, + readScreenAsync, + closeSurface, + pollForExit, + wrapWithCompletionFile, + sleep, + uniqueId, + trackTempFile, + waitForFile, + waitForScreen, + type TestEnv, +} from "./harness.ts"; + +const backends = getAvailableBackends(); +const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500"); + +if (backends.length === 0) { + console.log("⚠️ Herdr/Orca/tmux is not available — skipping surface integration tests"); + console.log(" Run inside Herdr, Orca, or tmux to enable these tests."); +} + +for (const backend of backends) { + describe(`surface [${backend}]`, { timeout: 60_000 }, () => { + let env: TestEnv; + + before(() => { + env = createTestEnv(); + }); + + after(() => { + cleanupTestEnv(env); + }); + + it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => { + const anchor = createTrackedSurface(env, "focus-anchor"); + await sleep(1000); + + focusSurface(anchor); + await waitForFocusedSurface(anchor, 10_000); + + const childA = createTrackedSurface(env, "focus-child-a"); + await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS); + assert.equal(getFocusedSurface(), anchor); + + const childB = createTrackedSurface(env, "focus-child-b"); + await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS); + assert.equal(getFocusedSurface(), anchor); + + const markerA = uniqueId(); + const markerB = uniqueId(); + focusSurface(childA); + sendCommand(childA, `echo "FOCUS_A_${markerA}"`); + await waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50); + focusSurface(childB); + sendCommand(childB, `echo "FOCUS_B_${markerB}"`); + await waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50); + focusSurface(anchor); + await waitForFocusedSurface(anchor, 10_000); + }); + + it("polls file-based completion and closes an unfocused surface", async () => { + const focusedBefore = getFocusedSurface(); + const surface = createTrackedSurface(env, "background-completion"); + await sleep(1000); + + const completionFile = `${env.dir}/background.exit-code`; + sendLongCommand(surface, wrapWithCompletionFile("false", completionFile)); + const result = await pollForExit(surface, new AbortController().signal, { + interval: 100, + completionFile, + }); + + assert.deepEqual(result, { reason: "sentinel", exitCode: 1 }); + assert.equal(getFocusedSurface(), focusedBefore); + + closeSurface(surface); + untrackSurface(env, surface); + assert.throws(() => readScreen(surface, 5)); + }); + + it("creates a surface, sends a command, reads output, and closes it", async () => { + const surface = createTrackedSurface(env, "echo-test"); + await sleep(1000); + focusSurface(surface); + + const marker = uniqueId(); + sendCommand(surface, `echo "MARKER_${marker}"`); + await sleep(1500); + + const screen = readScreen(surface, 50); + assert.ok( + screen.includes(`MARKER_${marker}`), + `Expected screen to contain MARKER_${marker}. Got:\n${screen}`, + ); + + closeSurface(surface); + untrackSurface(env, surface); + }); + + it("preserves shell special characters in echo output", async () => { + const surface = createTrackedSurface(env, "escape-test"); + await sleep(1000); + focusSurface(surface); + + const marker = uniqueId(); + // Single-quoted string — $ and " are literal inside single quotes + sendCommand(surface, `echo 'SPEC_${marker}_$HOME_"quotes"_done'`); + await sleep(1500); + + const screen = readScreen(surface, 50); + assert.ok( + screen.includes(`SPEC_${marker}`), + `Expected special-char output. Got:\n${screen}`, + ); + // $ should be literal inside single quotes + assert.ok( + screen.includes("$HOME"), + `Expected literal $HOME in output. Got:\n${screen}`, + ); + }); + + it("sends a long command via script file without truncation", async () => { + const surface = createTrackedSurface(env, "long-cmd-test"); + await sleep(1000); + focusSurface(surface); + + const marker = uniqueId(); + const longValue = "X".repeat(500); + const command = `echo "LONG_${marker}_${longValue}_END"`; + + sendLongCommand(surface, command); + await sleep(2000); + + const screen = readScreen(surface, 50); + assert.ok( + screen.includes(`LONG_${marker}`), + `Expected long command output. Got:\n${screen.slice(0, 300)}...`, + ); + assert.ok( + screen.includes("_END"), + `Expected full output (not truncated). Got:\n${screen.slice(-300)}`, + ); + }); + + it("reads screen asynchronously", async () => { + const surface = createTrackedSurface(env, "async-read-test"); + await sleep(1000); + focusSurface(surface); + + const marker = uniqueId(); + sendCommand(surface, `echo "ASYNC_${marker}"`); + await sleep(1500); + + const screen = await readScreenAsync(surface, 50); + assert.ok( + screen.includes(`ASYNC_${marker}`), + `Async read should find marker. Got:\n${screen}`, + ); + }); + + it("manages multiple surfaces concurrently", async () => { + const s1 = createTrackedSurface(env, "multi-1"); + const s2 = createTrackedSurface(env, "multi-2"); + await sleep(1500); + + const m1 = uniqueId(); + const m2 = uniqueId(); + focusSurface(s1); + sendCommand(s1, `echo "S1_${m1}"`); + await sleep(750); + focusSurface(s2); + sendCommand(s2, `echo "S2_${m2}"`); + await sleep(1500); + + const screen1 = readScreen(s1, 50); + const screen2 = readScreen(s2, 50); + + assert.ok(screen1.includes(`S1_${m1}`), `Surface 1 missing marker. Got:\n${screen1}`); + assert.ok(screen2.includes(`S2_${m2}`), `Surface 2 missing marker. Got:\n${screen2}`); + }); + + it("writes output to a file and verifies via surface", async () => { + const surface = createTrackedSurface(env, "file-test"); + await sleep(1000); + focusSurface(surface); + + const marker = uniqueId(); + const filePath = `/tmp/pi-surface-test-${marker}.txt`; + + sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`); + + await waitForScreen(surface, new RegExp(`WRITTEN_${marker}`), 10_000, 50); + const content = await waitForFile(filePath, 10_000, new RegExp(`FILE_${marker}`)); + assert.ok(content.includes(`FILE_${marker}`), `File content wrong. Got: ${content}`); + + // Clean up + try { + unlinkSync(filePath); + } catch {} + }); + }); +} diff --git a/extensions/interactive-subagents/test/system-prompt-mode.test.ts b/extensions/interactive-subagents/test/system-prompt-mode.test.ts new file mode 100644 index 0000000..6770cb8 --- /dev/null +++ b/extensions/interactive-subagents/test/system-prompt-mode.test.ts @@ -0,0 +1,163 @@ +/** + * Smoke tests for the systemPromptMode feature. + * Tests: frontmatter parsing, identity routing, CLI flag generation. + */ +import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, msg: string) { + if (condition) { + console.log(` ✅ ${msg}`); + passed++; + } else { + console.log(` ❌ ${msg}`); + failed++; + } +} + +// --- Extracted logic under test --- + +function parseFrontmatter(content: string) { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + const frontmatter = match[1]; + const get = (key: string) => { + const m = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m")); + return m ? m[1].trim() : undefined; + }; + const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim(); + const spm = get("system-prompt"); + return { + systemPromptMode: spm === "replace" ? "replace" : spm === "append" ? "append" : undefined, + body: body || undefined, + }; +} + +function simulateRouting( + agentBody: string | undefined, + systemPromptMode: "append" | "replace" | undefined, + paramSystemPrompt: string | undefined, +) { + const identity = agentBody ?? paramSystemPrompt ?? null; + const identityInSystemPrompt = systemPromptMode && identity; + const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : ""; + + let cliFlag: string | null = null; + if (identityInSystemPrompt && identity) { + cliFlag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt"; + } + + return { roleBlock, cliFlag, identityInSystemPrompt: !!identityInSystemPrompt }; +} + +// --- Fixtures --- + +const AGENT_REPLACE = `--- +model: anthropic/claude-sonnet-4-20250514 +system-prompt: replace +auto-exit: true +--- + +You are a specialized agent.`; + +const AGENT_APPEND = `--- +model: anthropic/claude-sonnet-4-20250514 +system-prompt: append +--- + +You are an appended identity.`; + +const AGENT_DEFAULT = `--- +model: anthropic/claude-sonnet-4-20250514 +--- + +You are a default agent.`; + +const AGENT_INVALID = `--- +model: anthropic/claude-sonnet-4-20250514 +system-prompt: foobar +--- + +Body here.`; + +// --- Test 1: Frontmatter parsing --- +console.log("\n🧪 Frontmatter parsing of system-prompt field"); + +const r1 = parseFrontmatter(AGENT_REPLACE)!; +assert(r1.systemPromptMode === "replace", "system-prompt: replace → mode is 'replace'"); +assert(r1.body === "You are a specialized agent.", "body extracted correctly"); + +const r2 = parseFrontmatter(AGENT_APPEND)!; +assert(r2.systemPromptMode === "append", "system-prompt: append → mode is 'append'"); + +const r3 = parseFrontmatter(AGENT_DEFAULT)!; +assert(r3.systemPromptMode === undefined, "no system-prompt field → mode is undefined"); + +const r4 = parseFrontmatter(AGENT_INVALID)!; +assert(r4.systemPromptMode === undefined, "system-prompt: foobar → mode is undefined (ignored)"); + +// --- Test 2: Identity routing --- +console.log("\n🧪 Identity routing (system prompt vs user message)"); + +const s1 = simulateRouting("You are X.", "replace", undefined); +assert(s1.roleBlock === "", "replace mode: roleBlock empty (not in task)"); +assert(s1.cliFlag === "--system-prompt", "replace mode: uses --system-prompt flag"); + +const s2 = simulateRouting("You are X.", "append", undefined); +assert(s2.roleBlock === "", "append mode: roleBlock empty (not in task)"); +assert(s2.cliFlag === "--append-system-prompt", "append mode: uses --append-system-prompt flag"); + +const s3 = simulateRouting("You are X.", undefined, undefined); +assert(s3.roleBlock === "\n\nYou are X.", "no mode: roleBlock contains identity"); +assert(s3.cliFlag === null, "no mode: no CLI flag"); + +const s4 = simulateRouting(undefined, undefined, undefined); +assert(s4.roleBlock === "", "no identity: roleBlock empty"); +assert(s4.cliFlag === null, "no identity: no CLI flag"); + +const s5 = simulateRouting(undefined, "replace", undefined); +assert(s5.roleBlock === "", "mode set but no body: roleBlock empty"); +assert(s5.cliFlag === null, "mode set but no body: no CLI flag"); + +const s6 = simulateRouting(undefined, "replace", "Param identity"); +assert(s6.cliFlag === "--system-prompt", "mode + param systemPrompt: uses CLI flag"); +assert(s6.roleBlock === "", "mode + param systemPrompt: roleBlock empty"); + +// --- Test 3: End-to-end with temp agent files --- +console.log("\n🧪 End-to-end with temp agent files"); + +const tmpDir = mkdtempSync(join(tmpdir(), "pi-test-spm-")); +const agentsDir = join(tmpDir, ".pi", "agents"); +mkdirSync(agentsDir, { recursive: true }); + +writeFileSync(join(agentsDir, "test-replace.md"), AGENT_REPLACE); +writeFileSync(join(agentsDir, "test-append.md"), AGENT_APPEND); +writeFileSync(join(agentsDir, "test-default.md"), AGENT_DEFAULT); + +function loadFromDir(name: string) { + const p = join(agentsDir, `${name}.md`); + if (!existsSync(p)) return null; + return parseFrontmatter(readFileSync(p, "utf8")); +} + +const t1 = loadFromDir("test-replace")!; +assert(t1.systemPromptMode === "replace", "file test-replace.md → replace mode"); +assert(t1.body === "You are a specialized agent.", "file test-replace.md → body correct"); + +const t2 = loadFromDir("test-append")!; +assert(t2.systemPromptMode === "append", "file test-append.md → append mode"); + +const t3 = loadFromDir("test-default")!; +assert(t3.systemPromptMode === undefined, "file test-default.md → no mode"); + +rmSync(tmpDir, { recursive: true }); + +// --- Summary --- +console.log(`\n${"=".repeat(40)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); +console.log("All tests passed! ✅\n"); diff --git a/extensions/interactive-subagents/test/test.ts b/extensions/interactive-subagents/test/test.ts new file mode 100644 index 0000000..b33020f --- /dev/null +++ b/extensions/interactive-subagents/test/test.ts @@ -0,0 +1,3253 @@ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import * as subagentsModule from "../pi-extension/subagents/index.ts"; + +import { + getLeafId, + getNewEntries, + countSessionEntryLines, + getSessionId, + readNameRegistry, + readSubagentLoadout, + registerName, + resolveNameInRegistry, + nameRegistryPath, + writeSubagentLoadout, + loadoutSidecarPath, + type SubagentLoadout, + resetSessionIndexCache, + resolveSessionFileById, + findLastAssistantMessage, + appendBranchSummary, + copySessionFile, + mergeNewEntries, + seedSubagentSessionFile, + summarizeSessionStats, +} from "../pi-extension/subagents/session.ts"; + +import { + __pollForExitTest__, + selectMuxBackend, + shellEscape, +} from "../pi-extension/subagents/surface.ts"; +import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts"; +import { + parseCreatedSplit, + parseCreatedTerminal, + parseReadTail, +} from "../pi-extension/subagents/orca.ts"; +import { + advanceStatusState, + capStatusLines, + classifyStatus, + createStatusState, + forceStatusAfterInterrupt, + formatStatusAggregate, + formatStatusLine, + formatTransitionLine, + observeStatus, + loadStatusConfig, + parseStatusConfig, +} from "../pi-extension/subagents/status.ts"; +import { + createSubagentActivityRecorder, + getSubagentActivityFile, + readSubagentActivityFile, +} from "../pi-extension/subagents/activity.ts"; +import { + shouldMarkUserTookOver, + shouldAutoExitOnAgentEnd, + findLatestAssistantError, + runningChildrenCount, +} from "../pi-extension/subagents/subagent-done.ts"; +import subagentDoneExtension from "../pi-extension/subagents/subagent-done.ts"; + +// --- Helpers --- + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), "subagents-test-")); +} + +function createSessionFile(dir: string, entries: object[]): string { + const file = join(dir, "test-session.jsonl"); + const content = entries.map((e) => JSON.stringify(e)).join("\n") + "\n"; + writeFileSync(file, content); + return file; +} + +function withTempDir(run: (dir: string) => void) { + const dir = createTestDir(); + try { + run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function createMockExtensionApi() { + const registeredTools: Array = []; + const registeredCommands: Array = []; + const registeredMessageRenderers: Array = []; + const eventHandlers = new Map any>>(); + const sentUserMessages: string[] = []; + const sentMessages: Array = []; + return { + registeredTools, + registeredCommands, + registeredMessageRenderers, + eventHandlers, + sentUserMessages, + sentMessages, + api: { + on(event: string, handler: (event: any, ctx: any) => any) { + const handlers = eventHandlers.get(event) ?? []; + handlers.push(handler); + eventHandlers.set(event, handlers); + }, + registerTool(tool: any) { + registeredTools.push(tool); + }, + registerCommand(name: string, command: any) { + registeredCommands.push({ name, ...command }); + }, + registerMessageRenderer(name: string, renderer: any) { + registeredMessageRenderers.push({ name, renderer }); + }, + registerShortcut() {}, + sendUserMessage(message: string) { + sentUserMessages.push(message); + }, + sendMessage(message: any, options?: any) { + sentMessages.push({ message, options }); + }, + getAllTools() { + return []; + }, + getActiveTools() { + return []; + }, + } as any, + }; +} + +async function emitMockEvent( + mock: ReturnType, + event: string, + payload: Record, + ctx: Record = {}, +) { + for (const handler of mock.eventHandlers.get(event) ?? []) { + await handler({ type: event, ...payload }, ctx); + } +} + +type SharedRuntimeTestState = { + runningSubagents: Map; + latestCtx: unknown; + latestPi: unknown; + widgetInterval: ReturnType | null; + statusInterval: ReturnType | null; + standaloneWidgetSyncScheduled: boolean; + pendingDeliveries: { length: number }; + deliveryFlushScheduled: boolean; + activeRuntimeToken: symbol | null; + registeredRuntimeToken: symbol | null; + statusWidgetClaim: unknown; + statusWidget?: unknown; +}; + +function resetSharedRuntimeForTest(shared: SharedRuntimeTestState): void { + if (shared.widgetInterval) clearInterval(shared.widgetInterval); + if (shared.statusInterval) clearInterval(shared.statusInterval); + shared.widgetInterval = null; + shared.statusInterval = null; + shared.standaloneWidgetSyncScheduled = false; + shared.runningSubagents.clear(); + shared.latestCtx = null; + shared.latestPi = null; + shared.statusWidgetClaim = null; + shared.pendingDeliveries.length = 0; + shared.deliveryFlushScheduled = false; + shared.activeRuntimeToken = null; + shared.registeredRuntimeToken = null; +} + +function restoreEnvVar(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +function withMockedNow(now: number, fn: () => T): T { + const originalNow = Date.now; + Date.now = () => now; + try { + return fn(); + } finally { + Date.now = originalNow; + } +} + +function writeAgentFile( + agentsDir: string, + name: string, + frontmatter: string, + body = "You are a test agent.", +) { + mkdirSync(agentsDir, { recursive: true }); + writeFileSync(join(agentsDir, `${name}.md`), `---\n${frontmatter}\n---\n\n${body}\n`); +} + +async function withIsolatedAgentEnv( + fn: (paths: { + projectDir: string; + projectAgentsDir: string; + globalDir: string; + globalAgentsDir: string; + }) => Promise | void, +) { + const root = createTestDir(); + const previousCwd = process.cwd(); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + const projectDir = join(root, "project"); + const projectAgentsDir = join(projectDir, ".pi", "agents"); + const globalDir = join(root, "global"); + const globalAgentsDir = join(globalDir, "agents"); + + mkdirSync(projectAgentsDir, { recursive: true }); + mkdirSync(globalAgentsDir, { recursive: true }); + process.chdir(projectDir); + process.env.PI_CODING_AGENT_DIR = globalDir; + + try { + await fn({ projectDir, projectAgentsDir, globalDir, globalAgentsDir }); + } finally { + process.chdir(previousCwd); + restoreEnvVar("PI_CODING_AGENT_DIR", previousAgentDir); + rmSync(root, { recursive: true, force: true }); + } +} +const SESSION_HEADER = { type: "session", id: "sess-001", version: 3 }; +const MODEL_CHANGE = { type: "model_change", id: "mc-001", parentId: null }; +const USER_MSG = { + type: "message", + id: "user-001", + parentId: "mc-001", + message: { + role: "user", + content: [{ type: "text", text: "Hello, plan something" }], + }, +}; +const ASSISTANT_MSG = { + type: "message", + id: "asst-001", + parentId: "user-001", + message: { + role: "assistant", + content: [{ type: "text", text: "Here is my plan..." }], + }, +}; +const ASSISTANT_MSG_2 = { + type: "message", + id: "asst-002", + parentId: "asst-001", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "Let me think..." }, + { type: "text", text: "Updated plan with details." }, + ], + }, +}; +const TOOL_RESULT = { + type: "message", + id: "tool-001", + parentId: "asst-001", + message: { + role: "toolResult", + toolCallId: "tc-001", + toolName: "bash", + content: [{ type: "text", text: "output here" }], + }, +}; + +// --- Tests --- + +describe("session.ts", () => { + let dir: string; + + before(() => { + dir = createTestDir(); + }); + + after(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + describe("getLeafId", () => { + it("returns last entry id", () => { + const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]); + assert.equal(getLeafId(file), "asst-001"); + }); + + it("returns null for empty file", () => { + const file = join(dir, "empty.jsonl"); + writeFileSync(file, ""); + assert.equal(getLeafId(file), null); + }); + }); + + describe("getNewEntries", () => { + it("returns entries after a given line", () => { + const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]); + const entries = getNewEntries(file, 2); + assert.equal(entries.length, 2); + assert.equal(entries[0].id, "user-001"); + assert.equal(entries[1].id, "asst-001"); + }); + + it("returns empty array when no new entries", () => { + const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE]); + const entries = getNewEntries(file, 2); + assert.equal(entries.length, 0); + }); + + it("countSessionEntryLines matches getNewEntries(0).length without parsing", () => { + const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]); + assert.equal(countSessionEntryLines(file), getNewEntries(file, 0).length); + assert.equal(countSessionEntryLines(file), 4); + }); + + it("countSessionEntryLines ignores blank lines and returns 0 for missing files", () => { + const file = join(dir, "blanks.jsonl"); + writeFileSync(file, JSON.stringify({ type: "session", id: "x" }) + "\n\n\n"); + assert.equal(countSessionEntryLines(file), 1); + assert.equal(countSessionEntryLines(join(dir, "does-not-exist.jsonl")), 0); + }); + }); + + describe("getSessionId / resolveSessionFileById", () => { + function writeSession(d: string, fname: string, id: string): string { + const p = join(d, fname); + writeFileSync(p, JSON.stringify({ type: "session", id, version: 3 }) + "\n"); + return p; + } + + // The resolver caches an id→file index per root; reset it so each test + // builds a fresh index from the current on-disk state. + beforeEach(() => { + resetSessionIndexCache(); + }); + + it("reads the header id from a session file", () => { + const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG]); + assert.equal(getSessionId(file), "sess-001"); + }); + + it("returns null for a file without a session header", () => { + const file = createSessionFile(dir, [USER_MSG]); + assert.equal(getSessionId(file), null); + }); + + it("resolves a session file by exact id under the root", () => { + const a = writeSession(dir, "a.jsonl", "019f-aaaa"); + writeSession(dir, "b.jsonl", "019f-bbbb"); + assert.equal(resolveSessionFileById("019f-aaaa", dir), a); + }); + + it("resolves a session file by id prefix", () => { + const a = writeSession(dir, "p.jsonl", "019f-prefix-match"); + assert.equal(resolveSessionFileById("019f-prefix", dir), a); + }); + + it("returns null when no session matches", () => { + writeSession(dir, "c.jsonl", "abc"); + assert.equal(resolveSessionFileById("zzz", dir), null); + }); + + it("picks up newly added sessions on repeat calls without a reset", () => { + // Prime the index (first call builds it). + writeSession(dir, "first.jsonl", "id-first"); + assert.equal(resolveSessionFileById("id-first", dir) !== null, true); + // Add a new session AFTER the index was built — no reset. The resolver's + // cheap refresh should index it. + const b = writeSession(dir, "second.jsonl", "id-second"); + assert.equal(resolveSessionFileById("id-second", dir), b); + }); + }); + + describe("subagent loadout snapshot", () => { + const sample: SubagentLoadout = { + agent: "worker", + toolAllowlist: "read,write,edit,safe_bash,web_search,subagent,ask_question", + model: "openrouter/z-ai/glm-5.2", + thinking: "medium", + systemPromptMode: "append", + identity: "You are a worker agent.", + spawnable: ["scout", "researcher"], + autoExit: true, + cwd: "/work/dir", + agentDir: "/home/u/.pi/agent", + }; + + it("writes the sidecar next to the session file", () => { + const sf = join(dir, "s1.jsonl"); + writeSubagentLoadout(sf, sample); + assert.equal(loadoutSidecarPath(sf), sf + ".loadout.json"); + assert.ok(existsSync(sf + ".loadout.json")); + }); + + it("round-trips the full loadout", () => { + const sf = join(dir, "s2.jsonl"); + writeSubagentLoadout(sf, sample); + assert.deepEqual(readSubagentLoadout(sf), sample); + }); + + it("returns null when the sidecar is absent", () => { + assert.equal(readSubagentLoadout(join(dir, "missing.jsonl")), null); + }); + + it("returns null when the sidecar is corrupt", () => { + const sf = join(dir, "s3.jsonl"); + writeFileSync(sf + ".loadout.json", "not json{", "utf8"); + assert.equal(readSubagentLoadout(sf), null); + }); + }); + + describe("subagent name registry", () => { + it("registers and resolves a name to its session file", () => { + const adir = join(dir, "art-1"); + registerName(adir, "worker", { sessionFile: "/s/worker.jsonl", sessionId: "id-worker" }); + const entry = resolveNameInRegistry(adir, "worker"); + assert.deepEqual(entry, { sessionFile: "/s/worker.jsonl", sessionId: "id-worker" }); + assert.ok(existsSync(nameRegistryPath(adir))); + }); + + it("accumulates multiple names and overwrites on re-register", () => { + const adir = join(dir, "art-2"); + registerName(adir, "scout", { sessionFile: "/s/scout.jsonl", sessionId: "id-scout" }); + registerName(adir, "scout-2", { sessionFile: "/s/scout2.jsonl", sessionId: "id-scout2" }); + const reg = readNameRegistry(adir); + assert.deepEqual(Object.keys(reg).sort(), ["scout", "scout-2"]); + // Overwrite scout with a new session file. + registerName(adir, "scout", { sessionFile: "/s/scout-new.jsonl", sessionId: "id-scout-new" }); + assert.equal(resolveNameInRegistry(adir, "scout")!.sessionFile, "/s/scout-new.jsonl"); + }); + + it("returns null for unknown names and {} for a missing/corrupt registry", () => { + const adir = join(dir, "art-3"); + assert.equal(resolveNameInRegistry(adir, "nope"), null); + assert.deepEqual(readNameRegistry(adir), {}); + mkdirSync(adir, { recursive: true }); + writeFileSync(nameRegistryPath(adir), "not json{", "utf8"); + assert.deepEqual(readNameRegistry(adir), {}); + }); + }); + + describe("findLastAssistantMessage", () => { + it("finds last assistant text", () => { + const entries = [USER_MSG, ASSISTANT_MSG, ASSISTANT_MSG_2] as any[]; + const text = findLastAssistantMessage(entries); + assert.equal(text, "Updated plan with details."); + }); + + it("skips thinking blocks, gets text only", () => { + const entries = [ASSISTANT_MSG_2] as any[]; + const text = findLastAssistantMessage(entries); + assert.equal(text, "Updated plan with details."); + }); + + it("skips tool results", () => { + const entries = [ASSISTANT_MSG, TOOL_RESULT] as any[]; + const text = findLastAssistantMessage(entries); + assert.equal(text, "Here is my plan..."); + }); + + it("returns null when no assistant messages", () => { + const entries = [USER_MSG] as any[]; + assert.equal(findLastAssistantMessage(entries), null); + }); + + it("returns null for empty array", () => { + assert.equal(findLastAssistantMessage([]), null); + }); + + it("skips empty assistant messages and returns real content above", () => { + const realMsg = { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "Real summary content." }], + }, + }; + const emptyMsg = { + type: "message", + message: { + role: "assistant", + content: [], + }, + }; + const entries = [realMsg, emptyMsg] as any[]; + assert.equal(findLastAssistantMessage(entries), "Real summary content."); + }); + + it("surfaces errorMessage when last assistant ended with stopReason=error and no text", () => { + // Reproduces the overload-exhaustion case: an earlier turn looked + // normal, then the provider went 529 and auto-retry gave up. Without + // the errorMessage fallback we'd return the stale earlier summary and + // the orchestrator would believe the subagent completed. + const earlierGood = { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "Investigating the bug..." }], + }, + }; + const overloadError = { + type: "message", + message: { + role: "assistant", + content: [], + stopReason: "error", + errorMessage: "Anthropic 529 Overloaded after 3 retries", + }, + }; + const entries = [earlierGood, overloadError] as any[]; + assert.equal( + findLastAssistantMessage(entries), + "Subagent error: Anthropic 529 Overloaded after 3 retries", + ); + }); + + it("prefers text content even when an error stopReason is set", () => { + // If the model produced text before the error (rare but possible), we + // prefer the actual content over the synthetic error fallback. + const msg = { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "Here is partial output." }], + stopReason: "error", + errorMessage: "stream interrupted", + }, + }; + assert.equal(findLastAssistantMessage([msg] as any[]), "Here is partial output."); + }); + + it("does not invent a summary for a stop=error message with no errorMessage", () => { + const msg = { + type: "message", + message: { + role: "assistant", + content: [], + stopReason: "error", + }, + }; + assert.equal(findLastAssistantMessage([msg] as any[]), null); + }); + }); + + describe("appendBranchSummary", () => { + it("appends valid branch_summary entry", () => { + const file = createSessionFile(dir, [SESSION_HEADER, USER_MSG, ASSISTANT_MSG]); + const id = appendBranchSummary(file, "user-001", "asst-001", "The plan was created."); + + assert.ok(id, "should return an id"); + assert.equal(typeof id, "string"); + + // Read back and verify + const lines = readFileSync(file, "utf8").trim().split("\n"); + assert.equal(lines.length, 4); // 3 original + 1 summary + + const summary = JSON.parse(lines[3]); + assert.equal(summary.type, "branch_summary"); + assert.equal(summary.id, id); + assert.equal(summary.parentId, "user-001"); + assert.equal(summary.fromId, "asst-001"); + assert.equal(summary.summary, "The plan was created."); + assert.ok(summary.timestamp); + }); + + it("uses branchPointId as fromId fallback", () => { + const file = createSessionFile(dir, [SESSION_HEADER]); + appendBranchSummary(file, "branch-pt", null, "summary"); + + const lines = readFileSync(file, "utf8").trim().split("\n"); + const summary = JSON.parse(lines[1]); + assert.equal(summary.fromId, "branch-pt"); + }); + }); + + describe("copySessionFile", () => { + it("creates a copy with different path", () => { + const file = createSessionFile(dir, [SESSION_HEADER, USER_MSG]); + const copyDir = join(dir, "copies"); + mkdirSync(copyDir, { recursive: true }); + const copy = copySessionFile(file, copyDir); + + assert.notEqual(copy, file); + assert.ok(copy.endsWith(".jsonl")); + assert.equal(readFileSync(copy, "utf8"), readFileSync(file, "utf8")); + }); + }); + + describe("seedSubagentSessionFile", () => { + it("creates a lineage-only child session with parent linkage and no copied turns", () => { + const parentFile = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]); + const childFile = join(dir, "lineage-child.jsonl"); + + seedSubagentSessionFile({ + mode: "lineage-only", + parentSessionFile: parentFile, + childSessionFile: childFile, + childCwd: "/tmp/child-cwd", + }); + + const lines = readFileSync(childFile, "utf8").trim().split("\n"); + assert.equal(lines.length, 1); + + const header = JSON.parse(lines[0]); + assert.equal(header.type, "session"); + assert.equal(header.parentSession, parentFile); + assert.equal(header.cwd, "/tmp/child-cwd"); + }); + + it("creates a forked child session with copied context before the triggering user turn", () => { + const parentFile = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]); + const childFile = join(dir, "fork-child.jsonl"); + + seedSubagentSessionFile({ + mode: "fork", + parentSessionFile: parentFile, + childSessionFile: childFile, + childCwd: "/tmp/fork-child-cwd", + }); + + const entries = readFileSync(childFile, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(entries.length, 2); + assert.equal(entries[0].type, "session"); + assert.equal(entries[0].parentSession, parentFile); + assert.equal(entries[0].cwd, "/tmp/fork-child-cwd"); + assert.equal(entries[1].type, "model_change"); + assert.equal(entries.some((entry) => entry.type === "session" && entry.parentSession !== parentFile), false); + assert.equal(entries.some((entry) => entry.type === "message"), false); + }); + }); + + describe("mergeNewEntries", () => { + it("appends new entries from source to target", () => { + // Source starts with same base (2 entries), then has 1 new entry + const sourceFile = join(dir, "merge-source.jsonl"); + const targetFile = join(dir, "merge-target.jsonl"); + writeFileSync( + sourceFile, + [SESSION_HEADER, USER_MSG, ASSISTANT_MSG].map((e) => JSON.stringify(e)).join("\n") + "\n", + ); + writeFileSync( + targetFile, + [SESSION_HEADER, USER_MSG].map((e) => JSON.stringify(e)).join("\n") + "\n", + ); + + // Merge entries after line 2 (the shared base) + const merged = mergeNewEntries(sourceFile, targetFile, 2); + assert.equal(merged.length, 1); + assert.equal(merged[0].id, "asst-001"); + + // Target should now have 3 entries + const targetLines = readFileSync(targetFile, "utf8").trim().split("\n"); + assert.equal(targetLines.length, 3); + }); + }); + + describe("summarizeSessionStats", () => { + const asstWithUsage = (id: string, opts: { + model?: string; + tools?: string[]; + usage?: Record; + }) => ({ + type: "message", + id, + parentId: "user-001", + message: { + role: "assistant", + ...(opts.model ? { model: opts.model } : {}), + content: [ + { type: "text", text: "ok" }, + ...(opts.tools ?? []).map((name, i) => ({ type: "toolCall", name, id: `${id}-tc${i}` })), + ], + ...(opts.usage ? { usage: opts.usage } : {}), + }, + }); + + it("aggregates tokens/cost cumulatively and tracks last context size", () => { + const file = createSessionFile(dir, [ + SESSION_HEADER, + { type: "model_change", id: "mc-001", parentId: null, modelId: "claude-sonnet-4-6" }, + USER_MSG, + asstWithUsage("a1", { + tools: ["read", "grep"], + usage: { input: 100, output: 50, cacheRead: 1000, cacheWrite: 200, totalTokens: 1350, cost: { total: 0.01 } }, + }), + asstWithUsage("a2", { + tools: ["write"], + usage: { input: 30, output: 70, cacheRead: 2000, cacheWrite: 0, totalTokens: 3500, cost: { total: 0.02 } }, + }), + ]); + const stats = summarizeSessionStats(file)!; + assert.equal(stats.model, "claude-sonnet-4-6"); + assert.equal(stats.toolCount, 3); + assert.equal(stats.inputTokens, 130); + assert.equal(stats.outputTokens, 120); + assert.equal(stats.cacheReadTokens, 3000); + assert.equal(stats.cacheWriteTokens, 200); + // contextTokens is the LAST assistant turn's totalTokens, not the sum. + assert.equal(stats.contextTokens, 3500); + assert.ok(Math.abs(stats.cost - 0.03) < 1e-9); + }); + + it("prefers per-message model over model_change", () => { + const file = createSessionFile(dir, [ + SESSION_HEADER, + { type: "model_change", id: "mc-001", parentId: null, modelId: "claude-haiku-4-5" }, + asstWithUsage("a1", { model: "claude-sonnet-4-6", usage: { totalTokens: 10, cost: { total: 0 } } }), + ]); + assert.equal(summarizeSessionStats(file)!.model, "claude-sonnet-4-6"); + }); + + it("handles missing usage gracefully", () => { + const file = createSessionFile(dir, [SESSION_HEADER, USER_MSG, ASSISTANT_MSG]); + const stats = summarizeSessionStats(file)!; + assert.equal(stats.toolCount, 0); + assert.equal(stats.inputTokens, 0); + assert.equal(stats.cost, 0); + assert.equal(stats.contextTokens, 0); + }); + + it("returns null for an unreadable file", () => { + assert.equal(summarizeSessionStats(join(dir, "does-not-exist.jsonl")), null); + }); + }); +}); + +describe("status.ts", () => { + it("parses strict config objects", () => { + const disabled = parseStatusConfig({ status: { enabled: false } }); + + assert.deepEqual(disabled, { + enabled: false, + lineLimit: 4, + }); + }); + + it("loads a valid config file", () => { + const examplePath = fileURLToPath(new URL("../config.json.example", import.meta.url)); + const config = loadStatusConfig(examplePath); + + assert.deepEqual(config, { + enabled: true, + lineLimit: 4, + }); + }); + + it("loads the shared example when local config is absent", () => { + withTempDir((dir) => { + const examplePath = join(dir, "config.json.example"); + writeFileSync( + examplePath, + JSON.stringify({ status: { enabled: true } }, null, 2) + "\n", + ); + + const config = loadStatusConfig(join(dir, "config.json"), examplePath); + + assert.deepEqual(config, { + enabled: true, + lineLimit: 4, + }); + }); + }); + + it("fails fast for invalid config shapes", () => { + assert.throws( + () => parseStatusConfig({ status: { enabled: "false" } }), + /status\.enabled must be a boolean/, + ); + assert.throws( + () => parseStatusConfig({ status: { enabled: true, defaultCadenceSeconds: 60 } }), + /status has unsupported key\(s\): defaultCadenceSeconds/, + ); + }); + + it("reports when neither local nor shared config exists", () => { + withTempDir((dir) => { + assert.throws( + () => loadStatusConfig(join(dir, "config.json"), join(dir, "config.json.example")), + /Missing subagent status config\. Expected .*config\.json.*or.*config\.json\.example/, + ); + }); + }); + + it("reports invalid JSON from the shared example path", () => { + withTempDir((dir) => { + const examplePath = join(dir, "config.json.example"); + writeFileSync(examplePath, "{\n"); + + assert.throws( + () => loadStatusConfig(join(dir, "config.json"), examplePath), + /Invalid JSON in subagent config .*config\.json\.example/, + ); + }); + }); + + it("fails on invalid local config instead of falling back to the shared example", () => { + withTempDir((dir) => { + const configPath = join(dir, "config.json"); + const examplePath = join(dir, "config.json.example"); + writeFileSync(configPath, "{\n"); + writeFileSync( + examplePath, + JSON.stringify({ status: { enabled: true } }, null, 2) + "\n", + ); + + assert.throws( + () => loadStatusConfig(configPath, examplePath), + /Invalid JSON in subagent config .*config\.json/, + ); + }); + }); + + it("keeps a missing snapshot as starting until the fixed watchdog threshold", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { snapshot: "missing" }, 1_000); + + assert.equal(classifyStatus(state, 60_999).kind, "starting"); + const stalled = classifyStatus(state, 61_000); + assert.equal(stalled.kind, "stalled"); + assert.equal(stalled.statusLabel, null); + }); + + it("classifies active snapshots without aging into stalled", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + latestEvent: "tool_execution_start", + }, 5_000); + + const snapshot = classifyStatus(state, 240_000); + assert.equal(snapshot.kind, "active"); + assert.equal(snapshot.activityLabel, "bash"); + assert.equal(snapshot.activeDurationText, "3m"); + }); + + it("classifies waiting snapshots as healthy idle without becoming stalled", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 10_000, + sequence: 1, + phase: "waiting", + waitingSince: 10_000, + latestEvent: "agent_end", + }, 10_000); + + const snapshot = classifyStatus(state, 240_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.waitingDurationText, "3m"); + }); + + it("uses elapsed-only fallback for claude-backed subagents", () => { + const state = createStatusState({ source: "claude", startTimeMs: 0 }); + const snapshot = classifyStatus(state, 125_000); + + assert.equal(snapshot.kind, "running"); + assert.equal(snapshot.elapsedText, "2m"); + }); + + it("detects stalled transitions and recovery", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { snapshot: "missing" }, 1_000); + + let advanced = advanceStatusState(state, 95_000); + assert.equal(advanced.transition, "stalled"); + assert.equal(advanced.snapshot.kind, "stalled"); + + state = observeStatus(advanced.nextState, { + snapshot: "present", + updatedAt: 96_000, + sequence: 1, + phase: "waiting", + waitingSince: 96_000, + latestEvent: "agent_end", + }, 96_000); + advanced = advanceStatusState(state, 97_000); + assert.equal(advanced.transition, "recovered"); + assert.equal(advanced.snapshot.kind, "waiting"); + }); + + it("keeps the last healthy kind during transient snapshot loss", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "streaming", + activeSince: 5_000, + }, 5_000); + state = advanceStatusState(state, 6_000).nextState; + state = observeStatus(state, { snapshot: "missing" }, 10_000); + + const snapshot = classifyStatus(state, 20_000); + assert.equal(snapshot.kind, "active"); + assert.equal(snapshot.statusLabel, null); + }); + + it("forces an active state to waiting after interrupt", () => { + const now = 20_000; + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, 5_000); + + assert.equal(classifyStatus(state, now).kind, "active"); + + const forced = forceStatusAfterInterrupt(state, now); + const snapshot = classifyStatus(forced, now); + + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.activityLabel, "interrupted"); + assert.equal(snapshot.waitingDurationText, "0s"); + assert.equal(forced.activeNow, false); + }); + + it("orders same-millisecond snapshots by sequence", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 10_000, + sequence: 2, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 10_000, + activityLabel: "bash", + }, 10_000); + + state = observeStatus(state, { + snapshot: "present", + updatedAt: 10_000, + sequence: 3, + phase: "waiting", + waitingSince: 10_000, + latestEvent: "agent_end", + }, 10_001); + + const snapshot = classifyStatus(state, 11_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.latestEvent, "agent_end"); + }); + + it("recovers from a transient snapshot read failure with the same valid snapshot", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 2, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, 5_000); + state = observeStatus(state, { snapshot: "missing" }, 10_000); + assert.equal(classifyStatus(state, 10_000).statusLabel, null); + + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 2, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, 11_000); + + const snapshot = classifyStatus(state, 11_000); + assert.equal(snapshot.kind, "active"); + assert.equal(snapshot.statusLabel, null); + }); + + it("ignores stale and exact old snapshots after interrupt and accepts newer snapshots", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, 5_000); + state = forceStatusAfterInterrupt(state, 20_000); + + const stale = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, 21_000); + let snapshot = classifyStatus(stale, 21_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.activityLabel, "interrupted"); + + const sameTimestamp = observeStatus(stale, { + snapshot: "present", + updatedAt: 20_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 20_000, + activityLabel: "bash", + }, 22_000); + snapshot = classifyStatus(sameTimestamp, 22_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.activityLabel, "interrupted"); + + const resumed = observeStatus(sameTimestamp, { + snapshot: "present", + sequence: 2, + updatedAt: 25_000, + phase: "active", + active: true, + activeScope: "streaming", + activeSince: 25_000, + activityLabel: "streaming", + }, 25_000); + snapshot = classifyStatus(resumed, 25_000); + assert.equal(snapshot.kind, "active"); + assert.equal(resumed.activeScope, "streaming"); + }); + + it("normalizes and truncates long newline-heavy names", () => { + const longName = `Worker\n\n${"very-long-name-".repeat(12)}`; + const stalledState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { snapshot: "missing" }, + 1_000, + ); + const activeState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { + snapshot: "present", + updatedAt: 299_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 299_000, + activityLabel: "write", + }, + 299_000, + ); + const line = formatStatusLine(longName, classifyStatus(stalledState, 240_000)); + const recovered = formatTransitionLine(longName, classifyStatus(activeState, 300_000), "recovered"); + + assert.doesNotMatch(line, /\n/); + assert.doesNotMatch(recovered, /\n/); + assert.ok(line.length <= 120, `expected bounded line length, got ${line.length}`); + assert.ok(recovered.length <= 120, `expected bounded line length, got ${recovered.length}`); + }); + + it("caps visible status lines and reports overflow consistently", () => { + const waitingState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { snapshot: "present", updatedAt: 180_000, sequence: 1, phase: "waiting", waitingSince: 180_000 }, + 180_000, + ); + const activeState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { + snapshot: "present", + updatedAt: 419_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 419_000, + activityLabel: "bash", + }, + 419_000, + ); + const waitingLine = formatStatusLine("Worker", classifyStatus(waitingState, 300_000)); + const recoveredLine = formatTransitionLine("Worker", classifyStatus(activeState, 420_000), "recovered"); + const lines = [waitingLine, recoveredLine, "Scout running 2m.", "Reviewer running 4m.", "Planner running 6m."]; + const capped = capStatusLines(lines, 3); + const aggregate = formatStatusAggregate(lines, 3); + + assert.equal(waitingLine, "Worker running 5m, waiting 2m."); + assert.equal(recoveredLine, "Worker running 7m, recovered; active (bash 1s)."); + assert.deepEqual(capped.visibleLines, [waitingLine, recoveredLine, "Scout running 2m."]); + assert.equal(capped.overflow, 2); + assert.match(aggregate, /^Subagent status:/); + assert.match(aggregate, /\+2 more running\./); + assert.doesNotMatch(aggregate, /\/tmp|\.jsonl/); + }); +}); + +describe("subagent discovery", () => { + const testApi = (subagentsModule as any).__test__; + + it("loads session-mode from frontmatter", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "lineage-mode-test-agent", + [ + "name: lineage-mode-test-agent", + "model: anthropic/test-lineage", + "session-mode: lineage-only", + ].join("\n"), + ); + + const loaded = testApi.loadAgentDefaults("lineage-mode-test-agent"); + assert.ok(loaded, "expected agent to load"); + assert.equal(loaded.sessionMode, "lineage-only"); + }); + }); + + it("loads explicit interactive flag from frontmatter", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "interactive-true-test-agent", + [ + "name: interactive-true-test-agent", + "model: anthropic/test-interactive-true", + "interactive: true", + ].join("\n"), + ); + writeAgentFile( + projectAgentsDir, + "interactive-false-test-agent", + [ + "name: interactive-false-test-agent", + "model: anthropic/test-interactive-false", + "interactive: false", + ].join("\n"), + ); + + const loadedTrue = testApi.loadAgentDefaults("interactive-true-test-agent"); + assert.equal(loadedTrue?.interactive, true); + + const loadedFalse = testApi.loadAgentDefaults("interactive-false-test-agent"); + assert.equal(loadedFalse?.interactive, false); + }); + }); + + it("leaves interactive undefined when not set in frontmatter", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "interactive-unset-test-agent", + [ + "name: interactive-unset-test-agent", + "model: anthropic/test-interactive-unset", + ].join("\n"), + ); + + const loaded = testApi.loadAgentDefaults("interactive-unset-test-agent"); + assert.equal(loaded?.interactive, undefined); + }); + }); + + it("resolveEffectiveInteractive defaults to the inverse of auto-exit", () => { + // Autonomous agents (auto-exit: true) are NOT interactive — parent gets stall pings. + assert.equal( + testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, { autoExit: true }), + false, + ); + // Agents without auto-exit ARE interactive — parent does not receive status transition pings. + assert.equal( + testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, { autoExit: false }), + true, + ); + assert.equal( + testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, {}), + true, + ); + // Bare spawn with no agent defs (e.g. /iterate fork) is interactive by default. + assert.equal( + testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, null), + true, + ); + }); + + it("resolveEffectiveInteractive honors explicit frontmatter over the auto-exit default", () => { + // Autonomous agent that still wants to be treated as interactive. + assert.equal( + testApi.resolveEffectiveInteractive( + { name: "A", task: "T" }, + { autoExit: true, interactive: true }, + ), + true, + ); + // Non-auto-exit agent that opts back into stall pings. + assert.equal( + testApi.resolveEffectiveInteractive( + { name: "A", task: "T" }, + { interactive: false }, + ), + false, + ); + }); + + it("bundled scout/researcher/worker use the configured model and resolve as non-interactive", () => { + for (const name of ["scout", "researcher", "worker"]) { + const defs = testApi.loadAgentDefaults(name); + assert.ok(defs, `expected bundled agent ${name} to be discoverable`); + assert.equal(defs.model, "openai-codex/gpt-5.6-luna"); + assert.equal(defs.thinking, "xhigh"); + assert.equal( + testApi.resolveEffectiveInteractive({ name, task: "" }, defs), + false, + `${name} should resolve as non-interactive (autonomous, auto-exit)`, + ); + } + }); + + it("worker is granted the spawning toolset restricted to scout and researcher", () => { + const worker = testApi.loadAgentDefaults("worker"); + assert.ok(worker, "expected bundled worker to be discoverable"); + assert.deepEqual(worker.subagentAgents, ["scout", "researcher"]); + + const allowlist = testApi.buildSubagentToolAllowlist(worker.tools, { grantSpawning: true }); + assert.ok(allowlist, "expected an allowlist"); + const tools = new Set(allowlist!.split(",")); + for (const t of ["subagent", "subagent_message", "subagents_list"]) { + assert.ok(tools.has(t), `expected spawning tool ${t} in worker allowlist`); + } + assert.ok(tools.has("bash"), "expected worker to keep bash"); + }); + + it("scout and researcher are not granted spawning tools", () => { + for (const name of ["scout", "researcher"]) { + const defs = testApi.loadAgentDefaults(name); + assert.ok(defs, `expected bundled agent ${name} to be discoverable`); + assert.equal(defs.subagentAgents, undefined, `${name} should not declare subagent_agents`); + } + }); + + it("getToolExtensionPath maps custom tools and skips built-ins", () => { + assert.equal(testApi.getToolExtensionPath("read"), undefined); + assert.equal(testApi.getToolExtensionPath("bash"), undefined); + assert.ok(testApi.getToolExtensionPath("safe_bash")?.endsWith("tools/safe-bash.ts")); + // Spawning tools are registered by this extension itself. + assert.ok(testApi.getToolExtensionPath("subagent")?.endsWith("index.ts")); + + withTempDir((dir) => { + const extensionPath = join(dir, "portable-test-tool.ts"); + writeFileSync(extensionPath, "export default function () {}\n"); + subagentsModule.registerToolExtension("portable_test_tool", extensionPath); + assert.equal(testApi.getToolExtensionPath("portable_test_tool"), extensionPath); + }); + }); + + it("ignores invalid session-mode values", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "invalid-mode-test-agent", + [ + "name: invalid-mode-test-agent", + "model: anthropic/test-invalid", + "session-mode: sideways", + ].join("\n"), + ); + + const loaded = testApi.loadAgentDefaults("invalid-mode-test-agent"); + assert.ok(loaded, "expected agent to load"); + assert.equal(loaded.sessionMode, undefined); + }); + }); + + it("resolves session mode from frontmatter (standalone default)", () => { + assert.equal(testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, null), "standalone"); + assert.equal( + testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, { sessionMode: "lineage-only" }), + "lineage-only", + ); + assert.equal( + testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, { sessionMode: "fork" }), + "fork", + ); + }); + + it("resolves launch behavior for standalone, lineage-only, and fork modes", () => { + assert.deepEqual(testApi.resolveLaunchBehavior({ name: "A", task: "T" }, null), { + sessionMode: "standalone", + seededSessionMode: null, + inheritsConversationContext: false, + taskDelivery: "artifact", + }); + assert.deepEqual( + testApi.resolveLaunchBehavior({ name: "A", task: "T" }, { sessionMode: "lineage-only" }), + { + sessionMode: "lineage-only", + seededSessionMode: "lineage-only", + inheritsConversationContext: false, + taskDelivery: "artifact", + }, + ); + assert.deepEqual( + testApi.resolveLaunchBehavior({ name: "A", task: "T" }, { sessionMode: "fork" }), + { + sessionMode: "fork", + seededSessionMode: "fork", + inheritsConversationContext: true, + taskDelivery: "direct", + }, + ); + }); + + it("buildSubagentToolAllowlist preserves requested tools and adds child control tools", () => { + assert.equal( + testApi.buildSubagentToolAllowlist("read,bash,web_search"), + "read,bash,web_search,ask_question", + ); + }); + + it("buildSubagentToolAllowlist returns null without an explicit tool restriction", () => { + assert.equal(testApi.buildSubagentToolAllowlist(undefined), null); + assert.equal(testApi.buildSubagentToolAllowlist(""), null); + }); + + it("applySandboxToParts replays model, identity, and default-deny tool restriction", () => { + withTempDir((d) => { + const parts: string[] = []; + testApi.applySandboxToParts( + parts, + { + agent: "worker", + toolAllowlist: "read,write,safe_bash", + model: "openrouter/z-ai/glm-5.2", + thinking: "medium", + systemPromptMode: "append", + identity: "You are a worker.", + spawnable: ["scout"], + autoExit: true, + cwd: null, + agentDir: null, + }, + { artifactDir: d, name: "worker" }, + ); + const joined = parts.join(" "); + // Model with thinking suffix. + assert.ok(joined.includes("--model"), "expected --model"); + assert.ok(joined.includes("openrouter/z-ai/glm-5.2:medium"), "expected model:thinking"); + // Identity written to a file and appended. + assert.ok(joined.includes("--append-system-prompt"), "expected --append-system-prompt"); + // Default-deny restriction. + assert.ok(parts.includes("--no-extensions"), "expected --no-extensions"); + const toolsIdx = parts.indexOf("--tools"); + assert.ok(toolsIdx >= 0, "expected --tools"); + // The value is shell-escaped (single-quoted) before joining. + assert.ok( + parts[toolsIdx + 1].includes("read,write,safe_bash"), + "expected the tool allowlist as the --tools value", + ); + }); + }); + + it("applySandboxToParts omits restriction flags when the loadout was unrestricted", () => { + withTempDir((d) => { + const parts: string[] = []; + testApi.applySandboxToParts( + parts, + { + agent: null, + toolAllowlist: null, + model: null, + thinking: null, + systemPromptMode: null, + identity: null, + spawnable: null, + autoExit: false, + cwd: null, + agentDir: null, + }, + { artifactDir: d, name: "fork" }, + ); + assert.deepEqual(parts, []); + }); + }); + + it("buildPiPromptArgs inserts separator for artifact-backed launches with skills", () => { + assert.deepEqual( + testApi.buildPiPromptArgs({ effectiveSkills: "review,lint", taskDelivery: "artifact", taskArg: "@artifact.md" }), + ["", "/skill:review", "/skill:lint", "@artifact.md"], + ); + }); + + it("buildPiPromptArgs omits separator for artifact-backed launches without skills", () => { + assert.deepEqual( + testApi.buildPiPromptArgs({ effectiveSkills: undefined, taskDelivery: "artifact", taskArg: "@artifact.md" }), + ["@artifact.md"], + ); + }); + + it("buildPiPromptArgs omits separator for direct launches with skills", () => { + assert.deepEqual( + testApi.buildPiPromptArgs({ effectiveSkills: "review", taskDelivery: "direct", taskArg: "do the task" }), + ["/skill:review", "do the task"], + ); + }); + + it("lists visible agents from discovery", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "visible-discovery-test-agent", + [ + "name: visible-discovery-test-agent", + "description: Visible test agent", + "model: anthropic/test-visible", + ].join("\n"), + ); + + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const tool = registeredTools.find((tool) => tool.name === "subagents_list"); + assert.ok(tool, "expected subagents_list to be registered"); + + const result = await tool.execute(); + const agents = result.details?.agents ?? []; + + assert.ok(agents.some((agent: any) => agent.name === "visible-discovery-test-agent")); + assert.match(result.content[0].text, /visible-discovery-test-agent/); + }); + }); + + it("hides disable-model-invocation agents from listings but keeps direct loading", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "hidden-discovery-test-agent", + [ + "name: hidden-discovery-test-agent", + "description: Hidden test agent", + "model: anthropic/test-hidden", + "disable-model-invocation: true", + ].join("\n"), + "You are the hidden agent.", + ); + + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const tool = registeredTools.find((tool) => tool.name === "subagents_list"); + assert.ok(tool, "expected subagents_list to be registered"); + + const result = await tool.execute(); + const agents = result.details?.agents ?? []; + + assert.equal(agents.some((agent: any) => agent.name === "hidden-discovery-test-agent"), false); + assert.doesNotMatch(result.content[0].text, /hidden-discovery-test-agent/); + + const loaded = testApi.loadAgentDefaults("hidden-discovery-test-agent"); + assert.ok(loaded, "expected hidden agent to remain directly loadable"); + assert.equal(loaded.model, "anthropic/test-hidden"); + assert.equal(loaded.body, "You are the hidden agent."); + assert.equal(loaded.disableModelInvocation, true); + }); + }); + + it("lets a hidden project agent shadow a visible global agent", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir, globalAgentsDir }) => { + writeAgentFile( + globalAgentsDir, + "shadowed-discovery-test-agent", + [ + "name: shadowed-discovery-test-agent", + "description: Global visible agent", + "model: anthropic/test-global", + ].join("\n"), + "You are the global visible agent.", + ); + writeAgentFile( + projectAgentsDir, + "shadowed-discovery-test-agent", + [ + "name: shadowed-discovery-test-agent", + "description: Project hidden agent", + "model: anthropic/test-project", + "disable-model-invocation: true", + ].join("\n"), + "You are the project hidden agent.", + ); + + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const tool = registeredTools.find((tool) => tool.name === "subagents_list"); + assert.ok(tool, "expected subagents_list to be registered"); + + const result = await tool.execute(); + const agents = result.details?.agents ?? []; + + assert.equal(agents.some((agent: any) => agent.name === "shadowed-discovery-test-agent"), false); + assert.doesNotMatch(result.content[0].text, /shadowed-discovery-test-agent/); + + const loaded = testApi.loadAgentDefaults("shadowed-discovery-test-agent"); + assert.ok(loaded, "expected project override to remain directly loadable"); + assert.equal(loaded.model, "anthropic/test-project"); + assert.equal(loaded.body, "You are the project hidden agent."); + assert.equal(loaded.disableModelInvocation, true); + }); + }); +}); +describe("subagent-done.ts", () => { + describe("shouldMarkUserTookOver", () => { + it("ignores the initial injected task before the first agent run", () => { + assert.equal(shouldMarkUserTookOver(false), false); + }); + + it("treats later input as manual takeover", () => { + assert.equal(shouldMarkUserTookOver(true), true); + }); + }); + + describe("shouldAutoExitOnAgentEnd", () => { + it("auto-exits after normal completion when there was no takeover", () => { + const messages = [{ role: "assistant", stopReason: "stop" }]; + assert.equal(shouldAutoExitOnAgentEnd(false, messages), true); + }); + + it("auto-exits after normal completion even when the user sent the prompt", () => { + const messages = [{ role: "assistant", stopReason: "stop" }]; + assert.equal(shouldAutoExitOnAgentEnd(true, messages), true); + }); + + it("stays open after Escape aborts the run", () => { + const messages = [{ role: "assistant", stopReason: "aborted" }]; + assert.equal(shouldAutoExitOnAgentEnd(false, messages), false); + }); + + it("still exits when the latest turn ended with stopReason=error", () => { + // Auto-exit subagents must shut down on retry-exhaustion errors so the + // parent is woken. The error sidecar (written separately) carries the + // failure detail; staying open would just strand the worker. + const messages = [{ role: "assistant", stopReason: "error", errorMessage: "529 overloaded" }]; + assert.equal(shouldAutoExitOnAgentEnd(false, messages), true); + }); + }); + + describe("findLatestAssistantError", () => { + it("returns the error info from a stopReason=error message", () => { + const messages = [ + { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "ok" }] }, + { role: "toolResult", content: [] }, + { role: "assistant", stopReason: "error", errorMessage: "Anthropic 529 Overloaded" }, + ]; + assert.deepEqual(findLatestAssistantError(messages), { + errorMessage: "Anthropic 529 Overloaded", + stopReason: "error", + }); + }); + + it("returns null when the latest assistant turn completed normally", () => { + const messages = [ + { role: "assistant", stopReason: "error", errorMessage: "old failure" }, + { role: "user", content: [] }, + { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "done" }] }, + ]; + assert.equal(findLatestAssistantError(messages), null); + }); + + it("returns null when the latest assistant turn was aborted by the user", () => { + const messages = [{ role: "assistant", stopReason: "aborted" }]; + assert.equal(findLatestAssistantError(messages), null); + }); + + it("falls back to a placeholder when stopReason=error has no errorMessage field", () => { + const messages = [{ role: "assistant", stopReason: "error" }]; + const info = findLatestAssistantError(messages); + assert.ok(info); + assert.equal(info!.stopReason, "error"); + assert.match(info!.errorMessage, /stopReason=error/); + }); + + it("returns null when messages is undefined or empty", () => { + assert.equal(findLatestAssistantError(undefined), null); + assert.equal(findLatestAssistantError([]), null); + }); + }); + + describe("runningChildrenCount", () => { + const KEY = Symbol.for("pi-subagents/running-children-count"); + function withGlobal(value: unknown, run: () => void) { + const prev = (globalThis as any)[KEY]; + (globalThis as any)[KEY] = value; + try { + run(); + } finally { + (globalThis as any)[KEY] = prev; + } + } + + it("returns 0 when the spawning tools aren't loaded (no global)", () => { + withGlobal(undefined, () => { + assert.equal(runningChildrenCount(), 0); + }); + }); + + it("reflects the live child count published by index.ts", () => { + withGlobal(() => 3, () => { + assert.equal(runningChildrenCount(), 3); + }); + }); + + it("treats zero/negative/non-number/throwing getters as 0", () => { + withGlobal(() => 0, () => assert.equal(runningChildrenCount(), 0)); + withGlobal(() => -1, () => assert.equal(runningChildrenCount(), 0)); + withGlobal(() => "two", () => assert.equal(runningChildrenCount(), 0)); + withGlobal(() => { throw new Error("boom"); }, () => assert.equal(runningChildrenCount(), 0)); + }); + }); + + describe("ask_question tool", () => { + function setupSubagentExtension(sessionFile: string) { + const saved = { + session: process.env.PI_SUBAGENT_SESSION, + name: process.env.PI_SUBAGENT_NAME, + agent: process.env.PI_SUBAGENT_AGENT, + autoExit: process.env.PI_SUBAGENT_AUTO_EXIT, + }; + process.env.PI_SUBAGENT_SESSION = sessionFile; + process.env.PI_SUBAGENT_NAME = "scout-2"; + process.env.PI_SUBAGENT_AGENT = "scout"; + process.env.PI_SUBAGENT_AUTO_EXIT = "1"; + const mock = createMockExtensionApi(); + subagentDoneExtension(mock.api); + const restore = () => { + restoreEnvVar("PI_SUBAGENT_SESSION", saved.session); + restoreEnvVar("PI_SUBAGENT_NAME", saved.name); + restoreEnvVar("PI_SUBAGENT_AGENT", saved.agent); + restoreEnvVar("PI_SUBAGENT_AUTO_EXIT", saved.autoExit); + }; + return { mock, restore }; + } + + it("registers ask_question (and no caller_ping) with a single freeform question param", () => { + const dir = createTestDir(); + const { mock, restore } = setupSubagentExtension(join(dir, "s.jsonl")); + try { + const names = mock.registeredTools.map((t) => t.name); + assert.ok(names.includes("ask_question")); + assert.ok(!names.includes("caller_ping")); + const tool = mock.registeredTools.find((t) => t.name === "ask_question"); + assert.deepEqual(Object.keys(tool.parameters.properties), ["question"]); + assert.match(tool.description, /orchestrator/i); + } finally { + restore(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes a .ask signal with name/agent/question and does NOT shut the session down", async () => { + const dir = createTestDir(); + const sessionFile = join(dir, "s.jsonl"); + const { mock, restore } = setupSubagentExtension(sessionFile); + try { + const tool = mock.registeredTools.find((t) => t.name === "ask_question"); + let shutdownCalled = false; + const ctx = { shutdown() { shutdownCalled = true; } } as any; + const out = await tool.execute("call-1", { question: "Which API base URL?" }, undefined, undefined, ctx); + + assert.equal(shutdownCalled, false, "ask_question must keep the session open"); + assert.match(out.content[0].text, /wait/i); + + const askFile = `${sessionFile}.ask`; + assert.ok(existsSync(askFile), ".ask signal file should be written"); + const payload = JSON.parse(readFileSync(askFile, "utf-8")); + assert.equal(payload.question, "Which API base URL?"); + assert.equal(payload.name, "scout-2"); + assert.equal(payload.agent, "scout"); + // No .exit sidecar — the session is not exiting. + assert.ok(!existsSync(`${sessionFile}.exit`)); + } finally { + restore(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + // Regression tests for the mid-run reply race: a reply steered in while the + // asking run is still open fires `input` but NOT `agent_start`, so the flag + // must be cleared on `input` or the session parks forever. + function setupCapturingExtension(sessionFile: string) { + const handlers = new Map void>>(); + const tools: any[] = []; + const api = { + on(event: string, handler: (...args: any[]) => void) { + if (!handlers.has(event)) handlers.set(event, []); + handlers.get(event)!.push(handler); + }, + registerTool(t: any) { tools.push(t); }, + registerCommand() {}, registerMessageRenderer() {}, registerShortcut() {}, + sendUserMessage() {}, sendMessage() {}, getAllTools() { return []; }, + } as any; + const saved = { + session: process.env.PI_SUBAGENT_SESSION, + name: process.env.PI_SUBAGENT_NAME, + agent: process.env.PI_SUBAGENT_AGENT, + autoExit: process.env.PI_SUBAGENT_AUTO_EXIT, + }; + process.env.PI_SUBAGENT_SESSION = sessionFile; + process.env.PI_SUBAGENT_NAME = "scout-2"; + process.env.PI_SUBAGENT_AGENT = "scout"; + process.env.PI_SUBAGENT_AUTO_EXIT = "1"; + subagentDoneExtension(api); + const emit = (event: string, ...args: any[]) => + (handlers.get(event) ?? []).forEach((h) => h(...args)); + const restore = () => { + restoreEnvVar("PI_SUBAGENT_SESSION", saved.session); + restoreEnvVar("PI_SUBAGENT_NAME", saved.name); + restoreEnvVar("PI_SUBAGENT_AGENT", saved.agent); + restoreEnvVar("PI_SUBAGENT_AUTO_EXIT", saved.autoExit); + }; + const ask = async () => { + const tool = tools.find((t) => t.name === "ask_question"); + await tool.execute("c1", { question: "v1 or v2?" }, undefined, undefined, { shutdown() {} }); + }; + return { emit, ask, restore }; + } + + it("exits (does not park) when the reply arrives mid-run via input", async () => { + const dir = createTestDir(); + const { emit, ask, restore } = setupCapturingExtension(join(dir, "s.jsonl")); + try { + emit("agent_start"); + await ask(); // sets awaitingAnswer mid-run + // Reply arrives MID-RUN as a steer: input fires, no new agent_start. + emit("input"); + let shutdown = false; + emit("agent_end", { messages: [] }, { shutdown() { shutdown = true; } }); + assert.equal(shutdown, true, "reply consumed mid-run → agent_end should exit, not park"); + } finally { + restore(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("parks as waiting at agent_end while the reply is still pending (no input yet)", async () => { + const dir = createTestDir(); + const { emit, ask, restore } = setupCapturingExtension(join(dir, "s.jsonl")); + try { + emit("agent_start"); + await ask(); + // No input yet — the orchestrator has not replied. + let shutdown = false; + emit("agent_end", { messages: [] }, { shutdown() { shutdown = true; } }); + assert.equal(shutdown, false, "pending question with no reply must park, not exit"); + } finally { + restore(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("exits when the reply arrives as a new turn (agent_start also clears the flag)", async () => { + const dir = createTestDir(); + const { emit, ask, restore } = setupCapturingExtension(join(dir, "s.jsonl")); + try { + emit("agent_start"); + await ask(); + let shutdown1 = false; + emit("agent_end", { messages: [] }, { shutdown() { shutdown1 = true; } }); + assert.equal(shutdown1, false, "parks while waiting"); + // Reply arrives as a fresh turn after the subagent had parked. + emit("input"); + emit("agent_start"); + let shutdown2 = false; + emit("agent_end", { messages: [] }, { shutdown() { shutdown2 = true; } }); + assert.equal(shutdown2, true, "after the reply turn, agent_end should exit"); + } finally { + restore(); + rmSync(dir, { recursive: true, force: true }); + } + }); + }); +}); + +describe("surface exit signals", () => { + const { interpretExitSidecar, readCompletionFile } = __pollForExitTest__; + + it("reads and removes a shell completion file", () => { + withTempDir((dir) => { + const completionFile = join(dir, "child.exit-code"); + writeFileSync(completionFile, "7\n"); + assert.deepEqual(readCompletionFile(completionFile), { + reason: "sentinel", + exitCode: 7, + }); + assert.equal(existsSync(completionFile), false); + assert.equal(readCompletionFile(completionFile), null); + }); + }); + + it("no longer decodes ping payloads (ask_question keeps the session open instead)", () => { + // ask_question writes a `.ask` signal, not a `.exit` ping sidecar, so an + // unknown `type: "ping"` payload now falls through to a clean done. + assert.deepEqual( + interpretExitSidecar({ type: "ping", name: "Worker", message: "need help" }), + { reason: "done", exitCode: 0 }, + ); + }); + + it("decodes done payloads", () => { + assert.deepEqual(interpretExitSidecar({ type: "done" }), { + reason: "done", + exitCode: 0, + }); + }); + + it("decodes error payloads and propagates the message with a non-zero exit code", () => { + assert.deepEqual( + interpretExitSidecar({ + type: "error", + errorMessage: "Anthropic 529 Overloaded after 3 retries", + stopReason: "error", + }), + { + reason: "error", + exitCode: 1, + errorMessage: "Anthropic 529 Overloaded after 3 retries", + }, + ); + }); + + it("falls back to a placeholder when error payload has no errorMessage", () => { + const result = interpretExitSidecar({ type: "error" }); + assert.equal(result.reason, "error"); + assert.equal(result.exitCode, 1); + assert.match(result.errorMessage ?? "", /no errorMessage/); + }); + + it("treats unknown payload shapes as done", () => { + assert.deepEqual(interpretExitSidecar({}), { reason: "done", exitCode: 0 }); + assert.deepEqual(interpretExitSidecar(null), { reason: "done", exitCode: 0 }); + }); +}); +describe("commands", () => { + it("/subagent emits a spawn tool call for a known agent", () => { + const { api, registeredCommands, sentUserMessages } = createMockExtensionApi(); + + (subagentsModule as any).default(api); + + const subagent = registeredCommands.find((command) => command.name === "subagent"); + assert.ok(subagent, "expected /subagent to be registered"); + + subagent.handler("scout map the auth code", { + ui: { notify() {} }, + }); + + assert.equal(sentUserMessages.length, 1); + assert.match(sentUserMessages[0], /agent: "scout"/); + assert.match(sentUserMessages[0], /map the auth code/); + }); + + it("does not register the removed /iterate or /plan commands", () => { + const { api, registeredCommands } = createMockExtensionApi(); + (subagentsModule as any).default(api); + assert.equal(registeredCommands.find((c) => c.name === "iterate"), undefined); + assert.equal(registeredCommands.find((c) => c.name === "plan"), undefined); + }); +}); + +describe("reload lifecycle", () => { + const testApi = (subagentsModule as any).__test__; + const shared = testApi.sharedRuntime as SharedRuntimeTestState; + const ctx = { hasUI: false, ui: { setWidget() {} } }; + + beforeEach(() => resetSharedRuntimeForTest(shared)); + after(() => resetSharedRuntimeForTest(shared)); + + it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => { + const oldRuntime = createMockExtensionApi(); + (subagentsModule as any).default(oldRuntime.api); + await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, ctx); + + const abortController = new AbortController(); + const running: { + id: string; + name: string; + task: string; + surface: string; + startTime: number; + sessionFile: string; + statusState: ReturnType; + interactive: boolean; + abortController: AbortController; + suppressDelivery?: boolean; + } = { + id: "reload-child", + name: "reload-child", + task: "keep running", + surface: "surface-1", + startTime: Date.now(), + sessionFile: "/tmp/reload-child.jsonl", + statusState: createStatusState({ source: "pi", startTimeMs: Date.now() }), + interactive: false, + abortController, + }; + shared.runningSubagents.set(running.id, running); + + await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, ctx); + assert.equal(abortController.signal.aborted, false, "reload must not cancel the child"); + assert.equal(shared.runningSubagents.get(running.id), running); + + testApi.sendWhenRuntimeReady((pi: any) => { + pi.sendMessage({ customType: "subagent_result", content: "done", display: true }); + }); + assert.equal(oldRuntime.sentMessages.length, 0, "the stale runtime must not receive completion"); + assert.equal(shared.pendingDeliveries.length, 1); + + const freshRuntime = createMockExtensionApi(); + (subagentsModule as any).default(freshRuntime.api); + await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, ctx); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(freshRuntime.sentMessages.length, 1); + assert.equal(freshRuntime.sentMessages[0].message.customType, "subagent_result"); + assert.equal(oldRuntime.sentMessages.length, 0); + assert.equal(shared.pendingDeliveries.length, 0); + assert.equal(shared.runningSubagents.get(running.id), running); + + testApi.flushPendingDeliveries(); + assert.equal(freshRuntime.sentMessages.length, 1, "a queued completion must flush exactly once"); + + await emitMockEvent(oldRuntime, "session_shutdown", { reason: "quit" }, ctx); + assert.equal(abortController.signal.aborted, false, "a stale runtime cannot cancel rebound state"); + assert.equal(shared.runningSubagents.get(running.id), running); + + await emitMockEvent(freshRuntime, "session_shutdown", { reason: "quit" }, ctx); + assert.equal(abortController.signal.aborted, true, "real shutdown still cancels the child"); + assert.equal(running.suppressDelivery, true); + assert.equal(shared.runningSubagents.size, 0); + }); +}); + +describe("tool registration", () => { + it("always resumes subagents as autonomous (auto-exit, non-interactive tracking)", () => { + const testApi = (subagentsModule as any).__test__; + + assert.deepEqual(testApi.resolveResumeLaunchBehavior(), { + autoExit: true, + interactive: false, + }); + }); + + + it("rejects a top-level spawn with no agent and no fork", async () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + const subagentTool = registeredTools.find((tool) => tool.name === "subagent"); + assert.ok(subagentTool, "expected subagent tool to be registered"); + + const result = await subagentTool.execute("call-1", { name: "x", task: "do it" }); + assert.equal(result.details?.error, "agent required"); + assert.match(result.content[0].text, /specify which agent/i); + }); + + it("rejects a top-level spawn naming an unknown agent", async () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + const subagentTool = registeredTools.find((tool) => tool.name === "subagent"); + assert.ok(subagentTool, "expected subagent tool to be registered"); + + const result = await subagentTool.execute("call-1", { + name: "x", + task: "do it", + agent: "wizard", + }); + assert.equal(result.details?.error, "unknown agent"); + assert.match(result.content[0].text, /not a known agent/i); + }); + + it("exposes a debloated schema: agent+task required, name/model/cwd optional, no override knobs", () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const subagentTool = registeredTools.find((tool) => tool.name === "subagent"); + assert.ok(subagentTool, "expected subagent tool to be registered"); + + const props = subagentTool.parameters.properties; + assert.deepEqual( + Object.keys(props).sort(), + ["agent", "cwd", "model", "name", "task"], + "only agent/task/name/model/cwd should remain", + ); + assert.deepEqual( + [...(subagentTool.parameters.required ?? [])].sort(), + ["agent", "task"], + "agent and task must be required", + ); + // `name` is now optional and purely cosmetic. + assert.match(props.name.description, /cosmetic/i); + // The removed override knobs must be gone. + for (const gone of ["tools", "skills", "systemPrompt", "fork", "interactive", "resumeSessionId"]) { + assert.equal(props[gone], undefined, `expected ${gone} param to be removed`); + } + }); + + it("renders partial subagent tool-call args without throwing", () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const subagentTool = registeredTools.find((tool) => tool.name === "subagent"); + assert.ok(subagentTool, "expected subagent tool to be registered"); + + const theme = { + fg(_color: string, text: string) { + return text; + }, + bold(text: string) { + return text; + }, + }; + const rendered = subagentTool.renderCall({}, theme); + const output = rendered.render(80).join("\n"); + + assert.match(output, /\(unnamed\)/); + }); + + it("registers subagent_message with name + message both required (name-only addressing)", () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const messageTool = registeredTools.find((tool) => tool.name === "subagent_message"); + assert.ok(messageTool, "expected subagent_message tool to be registered"); + + const props = messageTool.parameters.properties; + assert.deepEqual( + Object.keys(props).sort(), + ["message", "name"], + "only name/message should remain (sessionId dropped)", + ); + assert.equal(props.message.type, "string"); + assert.equal(props.name.type, "string"); + assert.deepEqual( + messageTool.parameters.required?.slice().sort(), + ["message", "name"], + "name and message should both be required", + ); + assert.equal(props.sessionId, undefined, "sessionId should be removed"); + assert.equal(props.autoExit, undefined, "autoExit knob should be removed"); + }); + + it("no longer registers subagent_interrupt or subagent_resume", () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + const names = registeredTools.map((tool) => tool.name); + assert.equal(names.includes("subagent_interrupt"), false); + assert.equal(names.includes("subagent_resume"), false); + }); +}); + +describe("subagent activity snapshots", () => { + function validActivity(overrides: Record = {}) { + return { + version: 1, + runningChildId: "child-1", + createdAt: 1_000, + updatedAt: 1_000, + sequence: 1, + latestEvent: "session_start", + phase: "starting", + agentActive: false, + turnActive: false, + providerActive: false, + toolActive: false, + ...overrides, + }; + } + + it("writes and validates activity files by running child id", () => { + withTempDir((dir) => { + const activityFile = getSubagentActivityFile(dir, "child-1"); + const recorder = createSubagentActivityRecorder({ + runningChildId: "child-1", + activityFile, + now: () => 1_000, + }); + + recorder.sessionStart(); + recorder.toolExecutionStart("tool-1", "bash"); + + const read = readSubagentActivityFile(activityFile, "child-1"); + assert.ok(read.ok); + assert.equal(read.activity.phase, "active"); + assert.equal(read.activity.activeScope, "tool"); + assert.equal(read.activity.toolName, "bash"); + + assert.deepEqual(readSubagentActivityFile(activityFile, "other-child"), { + ok: false, + reason: "wrong-id", + }); + }); + }); + + it("records waiting and final done states", () => { + withTempDir((dir) => { + let currentNow = 2_000; + const activityFile = getSubagentActivityFile(dir, "child-2"); + const recorder = createSubagentActivityRecorder({ + runningChildId: "child-2", + activityFile, + now: () => currentNow, + }); + + recorder.sessionStart(); + currentNow = 3_000; + recorder.agentEndWaiting(); + let read = readSubagentActivityFile(activityFile, "child-2"); + assert.ok(read.ok); + assert.equal(read.activity.phase, "waiting"); + assert.equal(read.activity.waitingSince, 3_000); + + currentNow = 4_000; + recorder.agentEndDone(); + read = readSubagentActivityFile(activityFile, "child-2"); + assert.ok(read.ok); + assert.equal(read.activity.phase, "done"); + assert.equal(read.activity.agentActive, false); + }); + }); + + it("rejects malformed activity fields used by classification and rendering", () => { + withTempDir((dir) => { + mkdirSync(join(dir, "subagent-activity"), { recursive: true }); + const cases = [ + { activeSince: "bad" }, + { waitingSince: "bad" }, + { activeScope: "database" }, + { latestEvent: "unknown" }, + { runningChildId: 42 }, + { toolActive: "yes" }, + { toolName: "bad\nname" }, + ]; + + for (const [index, overrides] of cases.entries()) { + const activityFile = getSubagentActivityFile(dir, `child-${index}`); + const activity = validActivity({ runningChildId: `child-${index}`, ...overrides }); + writeFileSync(activityFile, `${JSON.stringify(activity)}\n`); + + const read = readSubagentActivityFile(activityFile, `child-${index}`); + assert.equal(read.ok, false); + assert.equal((read as { ok: false; reason: string }).reason, "invalid"); + } + }); + }); + + it("does not let tool_result resurrect finished tool activity", () => { + withTempDir((dir) => { + let currentNow = 1_000; + const activityFile = getSubagentActivityFile(dir, "child-3"); + const recorder = createSubagentActivityRecorder({ + runningChildId: "child-3", + activityFile, + now: () => currentNow, + }); + + recorder.sessionStart(); + recorder.agentStart(); + recorder.turnStart(1); + currentNow = 2_000; + recorder.toolExecutionStart("tool-1", "bash"); + currentNow = 3_000; + recorder.toolExecutionEnd("tool-1", "bash"); + currentNow = 4_000; + recorder.toolResult("tool-1", "bash"); + + const read = readSubagentActivityFile(activityFile, "child-3"); + assert.ok(read.ok); + assert.equal(read.activity.toolActive, false); + assert.equal(read.activity.activeScope, "turn"); + }); + }); + + it("does not mark reload shutdown as the final done snapshot", () => { + withTempDir((dir) => { + const activityFile = getSubagentActivityFile(dir, "child-4"); + const recorder = createSubagentActivityRecorder({ + runningChildId: "child-4", + activityFile, + now: () => 1_000, + }); + + recorder.sessionStart(); + recorder.sessionShutdown("reload"); + + const read = readSubagentActivityFile(activityFile, "child-4"); + assert.ok(read.ok); + assert.equal(read.activity.phase, "starting"); + assert.equal(read.activity.latestEvent, "session_start"); + }); + }); + + it("cancels pending throttled writes on reload shutdown", async () => { + const dir = createTestDir(); + try { + await new Promise((resolve) => { + let currentNow = 1_000; + const activityFile = getSubagentActivityFile(dir, "child-5"); + const recorder = createSubagentActivityRecorder({ + runningChildId: "child-5", + activityFile, + now: () => currentNow, + }); + + recorder.sessionStart(); + currentNow = 1_100; + recorder.messageUpdate("delta"); + recorder.sessionShutdown("reload"); + + setTimeout(() => { + const read = readSubagentActivityFile(activityFile, "child-5"); + assert.ok(read.ok); + assert.equal(read.activity.phase, "starting"); + assert.equal(read.activity.latestEvent, "session_start"); + resolve(); + }, 650); + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("subagent interruption", () => { + function makeRunning(overrides: Record = {}) { + return { + id: "a1", + name: "Worker", + task: "", + surface: "pane-1", + startTime: 0, + sessionFile: "worker.jsonl", + interactive: false, + statusState: createStatusState({ source: "pi", startTimeMs: 0 }), + ...overrides, + }; + } + + it("registers subagent_message and not the old interrupt/resume tools", () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + const names = registeredTools.map((tool) => tool.name); + assert.equal(names.includes("subagent_message"), true); + assert.equal(names.includes("subagent_interrupt"), false); + assert.equal(names.includes("subagent_resume"), false); + }); + + it("resolves a running subagent by exact name and reports ambiguity", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + runningMap.clear(); + + try { + runningMap.set("a1", makeRunning({ id: "a1", name: "Worker", surface: "a1", sessionFile: "a1.jsonl" })); + runningMap.set("b2", makeRunning({ id: "b2", name: "Worker", surface: "b2", sessionFile: "b2.jsonl" })); + runningMap.set("c3", makeRunning({ id: "c3", name: "Scout", surface: "c3", sessionFile: "c3.jsonl" })); + + const byName = testApi.resolveRunningByName("Scout"); + assert.equal(byName.running.id, "c3"); + + const ambiguous = testApi.resolveRunningByName("Worker"); + assert.match(ambiguous.error, /Ambiguous subagent name/); + + const missing = testApi.resolveRunningByName("Ghost"); + assert.match(missing.error, /No running subagent named "Ghost"/); + } finally { + runningMap.clear(); + } + }); + + it("uniqueRunningName suffixes defaulted names that collide with running subagents", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + runningMap.clear(); + + try { + // No collision: base name is returned untouched. + assert.equal(testApi.uniqueRunningName("worker"), "worker"); + + runningMap.set("a1", makeRunning({ id: "a1", name: "worker", surface: "a1" })); + assert.equal(testApi.uniqueRunningName("worker"), "worker-2"); + + runningMap.set("b2", makeRunning({ id: "b2", name: "worker-2", surface: "b2" })); + assert.equal(testApi.uniqueRunningName("worker"), "worker-3"); + + // A distinct base is unaffected by the worker collisions. + assert.equal(testApi.uniqueRunningName("scout"), "scout"); + } finally { + runningMap.clear(); + } + }); + + it("uniqueRunningName also avoids names already taken in the persistent registry", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + const reserved = testApi.reservedNames as Set; + runningMap.clear(); + reserved.clear(); + + try { + // A finished subagent's name lives in the registry even though nothing is + // running — a fresh default must skip it so names stay unique session-wide. + const registryNames = new Set(["worker", "worker-2"]); + assert.equal(testApi.uniqueRunningName("worker", registryNames), "worker-3"); + // A name not in the registry (or running/reserved) is unaffected. + assert.equal(testApi.uniqueRunningName("scout", registryNames), "scout"); + // An empty registry behaves like before. + assert.equal(testApi.uniqueRunningName("worker", new Set()), "worker"); + } finally { + runningMap.clear(); + reserved.clear(); + } + }); + + it("uniqueRunningName also avoids names reserved by in-flight parallel spawns", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + const reserved = testApi.reservedNames as Set; + runningMap.clear(); + reserved.clear(); + + try { + // Simulate the first parallel spawn reserving its default name before it + // has registered in runningSubagents. + reserved.add(testApi.uniqueRunningName("scout")); // "scout" + // The second spawn, running concurrently, must not reuse it. + assert.equal(testApi.uniqueRunningName("scout"), "scout-2"); + reserved.add("scout-2"); + assert.equal(testApi.uniqueRunningName("scout"), "scout-3"); + } finally { + runningMap.clear(); + reserved.clear(); + } + }); + + it("steers a running subagent by typing into its pane (newlines flattened)", () => { + const testApi = (subagentsModule as any).__test__; + let sentSurface = ""; + let sentText = ""; + const running = makeRunning(); + + const result = testApi.steerSubagent(running, "do this\nthen that", (surface: string, text: string) => { + sentSurface = surface; + sentText = text; + }); + + assert.deepEqual(result, { ok: true }); + assert.equal(sentSurface, "pane-1"); + assert.equal(sentText, "do this then that"); + }); + + it("returns an explicit error when steering delivery fails", () => { + const testApi = (subagentsModule as any).__test__; + const running = makeRunning(); + + const result = testApi.steerSubagent(running, "hi", () => { + throw new Error("mux write failed"); + }); + + assert.match(result.error, /Failed to deliver message/); + }); + + it("delivers a steer message and forces local status waiting", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + let sentSurface = ""; + let sentText = ""; + runningMap.clear(); + + const activeState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, + 5_000, + ); + + try { + runningMap.set("a1", makeRunning({ statusState: activeState })); + + const result = withMockedNow(20_000, () => + testApi.handleSubagentSteer({ name: "Worker", message: "keep going" }, (surface: string, text: string) => { + sentSurface = surface; + sentText = text; + }), + ); + + assert.equal(sentSurface, "pane-1"); + assert.equal(sentText, "keep going"); + assert.equal(result.content[0].text.includes('Message delivered to running subagent "Worker"'), true); + assert.deepEqual(result.details, { id: "a1", name: "Worker", status: "steered" }); + const snapshot = classifyStatus(runningMap.get("a1").statusState, 20_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(runningMap.has("a1"), true); + } finally { + runningMap.clear(); + } + }); + + it("requires a message when steering", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + runningMap.clear(); + try { + runningMap.set("a1", makeRunning()); + const result = testApi.handleSubagentSteer({ name: "Worker", message: " " }, () => {}); + assert.match(result.content[0].text, /`message` is required/); + } finally { + runningMap.clear(); + } + }); + + it("leaves status unchanged when steering delivery fails in the tool path", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + runningMap.clear(); + + const activeState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, + 5_000, + ); + + try { + runningMap.set("a1", makeRunning({ statusState: activeState })); + + const result = withMockedNow(20_000, () => + testApi.handleSubagentSteer({ name: "Worker", message: "go" }, () => { + throw new Error("mux write failed"); + }), + ); + + assert.match(result.content[0].text, /Failed to deliver message/); + assert.equal(classifyStatus(runningMap.get("a1").statusState, 20_000).kind, "active"); + } finally { + runningMap.clear(); + } + }); + + it("formats exit code 130 as an ordinary failure", () => { + const testApi = (subagentsModule as any).__test__; + const presentation = testApi.resolveResultPresentation( + { + exitCode: 130, + elapsed: 61, + summary: "Sub-agent exited with code 130", + sessionFile: "/tmp/subagent.jsonl", + sessionId: "019f-abc", + }, + "Worker", + ); + + assert.match(presentation, /failed \(exit code 130\)/); + assert.doesNotMatch(presentation, /interrupted/); + // Follow-ups reference the name (not the session id). + assert.match(presentation, /subagent_message\(\{ name: "Worker"/); + assert.doesNotMatch(presentation, /Session id:/); + }); + + it("renders a clear provider/agent error when errorMessage is set", () => { + // Previously, an overload retry-exhaustion produced exitCode 0 with a + // stale summary — the orchestrator thought the subagent finished + // quickly. With the error sidecar plumbed through, the presentation + // must call out the failure, include the underlying error, and tell the + // orchestrator how to recover. + const testApi = (subagentsModule as any).__test__; + const presentation = testApi.resolveResultPresentation( + { + exitCode: 1, + elapsed: 14, + summary: "ignored when errorMessage is present", + sessionFile: "/tmp/subagent.jsonl", + sessionId: "019f-xyz", + errorMessage: "Anthropic 529 Overloaded after 3 retries", + }, + "Worker", + ); + + assert.match(presentation, /Sub-agent "Worker" failed/); + assert.match(presentation, /provider\/agent error — auto-retry exhausted/); + assert.match(presentation, /Error: Anthropic 529 Overloaded after 3 retries/); + assert.match(presentation, /subagent_message\(\{ name: "Worker"/); + assert.doesNotMatch(presentation, /Session id:/); + assert.doesNotMatch(presentation, /ignored when errorMessage is present/); + }); +}); + +describe("subagent status renderer", () => { + function createTheme() { + return { + fg(_color: string, text: string) { + return text; + }, + bg(_color: string, text: string) { + return text; + }, + bold(text: string) { + return text; + }, + }; + } + + it("renders only capped lines plus overflow", () => { + const { api, registeredMessageRenderers } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const rendererEntry = registeredMessageRenderers.find((entry) => entry.name === "subagent_status"); + assert.ok(rendererEntry, "expected subagent_status renderer to be registered"); + + const visibleLines = [ + "Worker running 5m, active (bash 2m).", + "Scout running 3m, waiting 1m.", + "Reviewer running 2m, active (streaming 30s).", + "Planner running 4m, waiting 2m.", + ]; + const rendered = rendererEntry.renderer( + { + customType: "subagent_status", + content: "Subagent status:\n• Worker running 5m, active (bash 2m).", + details: { + lines: visibleLines, + overflow: 2, + }, + }, + { expanded: true }, + createTheme(), + ); + const output = rendered.render(80).join("\n"); + + assert.match(output, /Subagent status/); + for (const line of visibleLines) { + assert.match(output, new RegExp(line.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + assert.match(output, /\+2 more running\./); + }); + + it("stays within narrow widths", () => { + const { api, registeredMessageRenderers } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const rendererEntry = registeredMessageRenderers.find((entry) => entry.name === "subagent_status"); + assert.ok(rendererEntry, "expected subagent_status renderer to be registered"); + + const rendered = rendererEntry.renderer( + { + customType: "subagent_status", + content: "Subagent status:\n• Worker running 5m, active (bash 2m).", + details: { lines: ["Worker running 5m, active (bash 2m)."], overflow: 0 }, + }, + { expanded: true }, + createTheme(), + ); + + for (const width of [4, 5, 6]) { + for (const line of rendered.render(width)) { + assert.ok( + visibleWidth(line) <= width, + `expected line width <= ${width}, got ${visibleWidth(line)} for ${JSON.stringify(line)}`, + ); + } + } + }); +}); + +describe("subagent startup delay", () => { + it("uses backend-specific defaults", () => { + const testApi = (subagentsModule as any).__test__; + assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500); + assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000); + assert.equal(testApi.defaultShellReadyDelayMs("orca"), 500); + assert.equal(testApi.defaultShellReadyDelayMs(null), 500); + }); + + it("uses PI_SUBAGENT_SHELL_READY_DELAY_MS when it is set", () => { + const testApi = (subagentsModule as any).__test__; + assert.ok(testApi, "expected subagents test helpers to be exported"); + assert.equal(typeof testApi.getShellReadyDelayMs, "function"); + + const original = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS; + process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = "2500"; + try { + assert.equal(testApi.getShellReadyDelayMs(), 2500); + } finally { + if (original == null) delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS; + else process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = original; + } + }); +}); +describe("subagents widget rendering", () => { + function createThemeSpy() { + const calls: Array<{ color: string; text: string }> = []; + return { + calls, + theme: { + fg(color: string, text: string) { + calls.push({ color, text }); + return `\x1b[38;5;${calls.length}m${text}\x1b[0m`; + }, + }, + }; + } + + function createWidgetAgent( + id: string, + kind: "active" | "running" | "stalled" | "starting" | "waiting", + ) { + const startTime = kind === "starting" ? 1_061_000 - 5_000 : 1_000_000 - 5_000; + let statusState = createStatusState({ + source: kind === "running" ? "claude" : "pi", + startTimeMs: startTime, + }); + if (kind === "active" || kind === "waiting") { + statusState = observeStatus( + statusState, + { + snapshot: "present", + updatedAt: startTime + 1_000, + sequence: 1, + phase: kind, + ...(kind === "active" + ? { + active: true, + activeScope: "tool", + activeSince: startTime + 1_000, + activityLabel: "bash", + } + : { waitingSince: startTime + 1_000 }), + }, + startTime + 1_000, + ); + } else if (kind === "stalled") { + statusState = observeStatus( + statusState, + { snapshot: "missing" }, + startTime + 1_000, + ); + } + + return { + id, + name: id, + agent: "worker", + task: "", + surface: "surface", + startTime, + sessionFile: `${id}.jsonl`, + statusState, + }; + } + + function createActiveWidgetAgent(id: string) { + return createWidgetAgent(id, "active"); + } + + it("keeps every rendered line within a very narrow width", () => { + const testApi = (subagentsModule as any).__test__; + assert.ok(testApi, "expected subagents test helpers to be exported"); + assert.equal(typeof testApi.renderSubagentWidgetLines, "function"); + + const originalNow = Date.now; + Date.now = () => 1_000_000; + try { + const lines = testApi.renderSubagentWidgetLines([ + { + id: "a1", + name: "A", + task: "", + surface: "s1", + startTime: 1_000_000 - 13_000, + sessionFile: "sess1", + statusState: createStatusState({ source: "pi", startTimeMs: 1_000_000 - 13_000 }), + }, + { + id: "a2", + name: "B", + task: "", + surface: "s2", + startTime: 1_000_000 - 21_000, + sessionFile: "sess2", + statusState: createStatusState({ source: "pi", startTimeMs: 1_000_000 - 21_000 }), + }, + { + id: "a3", + name: "C", + task: "", + surface: "s3", + startTime: 1_000_000 - 27_000, + sessionFile: "sess3", + statusState: createStatusState({ source: "pi", startTimeMs: 1_000_000 - 27_000 }), + }, + ], 16); + + assert.deepEqual( + lines.map((line: string) => visibleWidth(line)), + [16, 16, 16, 16], + ); + assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line))); + } finally { + Date.now = originalNow; + } + }); + + it("sanitizes whitespace in agent names before rendering rows", () => { + const testApi = (subagentsModule as any).__test__; + const startTime = Date.now() - 5_000; + const lines = testApi.renderSubagentWidgetLines([ + { + id: "unsafe", + name: "worker\nwith\ttabs", + task: "", + surface: "s1", + startTime, + sessionFile: "sess1", + statusState: createStatusState({ source: "pi", startTimeMs: startTime }), + }, + ], 80); + + assert.ok(lines.every((line: string) => !/[\r\n]/.test(line))); + assert.match(lines[1] ?? "", /worker with tabs/); + }); + + it("truncates the right-hand status instead of overflowing when it alone is too wide", () => { + const testApi = (subagentsModule as any).__test__; + assert.ok(testApi, "expected subagents test helpers to be exported"); + assert.equal(typeof testApi.borderLine, "function"); + + const line = testApi.borderLine(" A ", " 999 msgs (999.9KB) ", 16); + assert.equal(visibleWidth(line), 16); + }); + + it("handles ultra-narrow widths without exceeding the width contract", () => { + const testApi = (subagentsModule as any).__test__; + assert.ok(testApi, "expected subagents test helpers to be exported"); + assert.equal(typeof testApi.renderSubagentWidgetLines, "function"); + + const widths = [0, 1, 2]; + for (const width of widths) { + const startTime = Date.now() - 5_000; + const lines = testApi.renderSubagentWidgetLines([ + { + id: "a1", + name: "A", + task: "", + surface: "s1", + startTime, + sessionFile: "sess1", + statusState: createStatusState({ source: "pi", startTimeMs: startTime }), + }, + ], width); + + for (const line of lines) { + assert.ok( + visibleWidth(line) <= width, + `expected line width <= ${width}, got ${visibleWidth(line)} for ${JSON.stringify(line)}`, + ); + } + } + }); + + it("uses the standalone widget factory theme for semantic status colors", () => { + const testApi = (subagentsModule as any).__test__; + const widgetCalls: any[] = []; + const shared = testApi.sharedRuntime as SharedRuntimeTestState; + shared.latestCtx = { + hasUI: true, + ui: { setWidget: (...args: any[]) => widgetCalls.push(args) }, + }; + shared.runningSubagents.set("active", createActiveWidgetAgent("active")); + + testApi.updateWidget(); + const { theme, calls } = createThemeSpy(); + const component = widgetCalls[0][1](undefined, theme); + const lines = withMockedNow(1_000_000, () => component.render(80)); + + assert.equal(lines.length, 2, "header plus one body row"); + assert.equal(calls.find((call) => call.text === "Subagents")?.color, "accent"); + assert.equal(calls.find((call) => call.text === "1 running")?.color, "muted"); + assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active"))); + assert.equal(calls.find((call) => call.text === " (worker)")?.color, "dim"); + assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05")); + assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line))); + }); + + it("uses the optional theme passed to hosted rendering", () => { + const testApi = (subagentsModule as any).__test__; + const shared = testApi.sharedRuntime as SharedRuntimeTestState; + shared.runningSubagents.set("active", createActiveWidgetAgent("active")); + const { theme, calls } = createThemeSpy(); + + const lines = withMockedNow(1_000_000, () => testApi.statusWidget.render(80, undefined, theme)); + + assert.equal(lines.length, 2, "header plus one body row"); + assert.ok(calls.some((call) => call.color === "accent" && call.text === "Subagents")); + assert.ok(calls.some((call) => call.color === "muted" && call.text === "1 running")); + assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active"))); + assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05")); + assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line))); + }); + + it("maps every status kind to a semantic row color", () => { + const testApi = (subagentsModule as any).__test__; + const shared = testApi.sharedRuntime as SharedRuntimeTestState; + for (const [id, kind] of [ + ["active", "active"], + ["running", "running"], + ["stalled", "stalled"], + ["starting", "starting"], + ["waiting", "waiting"], + ] as const) { + shared.runningSubagents.set(id, createWidgetAgent(id, kind)); + } + const { theme, calls } = createThemeSpy(); + + withMockedNow(1_061_000, () => testApi.statusWidget.render(120, undefined, theme)); + + const expected = { + active: "warning", + running: "warning", + stalled: "error", + starting: "dim", + waiting: "accent", + } as const; + for (const [id, kind] of [ + ["active", "active"], + ["running", "running"], + ["stalled", "stalled"], + ["starting", "starting"], + ["waiting", "waiting"], + ] as const) { + assert.ok( + calls.some((call) => call.text === id && call.color === expected[kind]), + `${id} should use ${expected[kind]}`, + ); + } + }); +}); + +describe("status widget host integration", () => { + const testApi = (subagentsModule as any).__test__; + const statusWidget = (globalThis as any).__pi_interactive_subagents.statusWidget; + const shared = testApi.sharedRuntime as SharedRuntimeTestState; + + function addRunning(id: string) { + const startTime = Date.now(); + shared.runningSubagents.set(id, { + id, + name: id, + task: "", + surface: "surface", + startTime, + sessionFile: `${id}.jsonl`, + statusState: createStatusState({ source: "pi", startTimeMs: startTime }), + }); + } + + beforeEach(() => resetSharedRuntimeForTest(shared)); + after(() => resetSharedRuntimeForTest(shared)); + + it("falls back to the standalone widget when unclaimed", () => { + const widgetCalls: any[] = []; + shared.latestCtx = { + hasUI: true, + ui: { setWidget: (...args: any[]) => widgetCalls.push(args) }, + }; + addRunning("worker"); + + testApi.updateWidget(); + + assert.equal(widgetCalls.length, 1); + assert.equal(widgetCalls[0][0], "subagent-status"); + assert.equal(typeof widgetCalls[0][1], "function"); + }); + + it("suppresses, notifies, and restores the standalone widget around a claim", async () => { + const widgetCalls: any[] = []; + shared.latestCtx = { + hasUI: true, + ui: { setWidget: (...args: any[]) => widgetCalls.push(args) }, + }; + addRunning("worker"); + testApi.updateWidget(); + + let changes = 0; + const onChange = () => changes++; + const release = statusWidget.claim(onChange); + assert.equal(statusWidget.isClaimed(onChange), true); + assert.equal(statusWidget.isClaimed(() => {}), false); + assert.equal(typeof widgetCalls.at(-1)[1], "function", "claim sync is deferred"); + + await Promise.resolve(); + assert.equal(widgetCalls.at(-1)[1], undefined); + + testApi.updateWidget(); + assert.equal(changes, 1); + + release(); + assert.equal(statusWidget.isClaimed(onChange), false); + assert.equal(widgetCalls.at(-1)[1], undefined, "release sync is deferred"); + await Promise.resolve(); + assert.equal(typeof widgetCalls.at(-1)[1], "function"); + release(); + assert.equal(widgetCalls.length, 4, "release is idempotent"); + }); + + it("does not restore standalone UI between an immediate release and re-claim", async () => { + const widgetCalls: any[] = []; + shared.latestCtx = { + hasUI: true, + ui: { setWidget: (...args: any[]) => widgetCalls.push(args) }, + }; + addRunning("worker"); + testApi.updateWidget(); + + const firstChange = () => {}; + const releaseFirst = statusWidget.claim(firstChange); + releaseFirst(); + const secondChange = () => {}; + const releaseSecond = statusWidget.claim(secondChange); + await Promise.resolve(); + + assert.equal(statusWidget.isClaimed(firstChange), false); + assert.equal(statusWidget.isClaimed(secondChange), true); + assert.equal(widgetCalls.at(-1)[1], undefined); + releaseSecond(); + await Promise.resolve(); + assert.equal(typeof widgetCalls.at(-1)[1], "function"); + }); + + it("caps body rows with an honest overflow row", () => { + addRunning("worker"); + addRunning("scout"); + addRunning("reviewer"); + + const lines = statusWidget.render(40, 2); + assert.equal(lines.length, 3, "header + one agent + overflow"); + assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line))); + assert.match(lines[2], /\+2 more running\./); + assert.equal(statusWidget.getBodyRowCount(), 3); + assert.equal(statusWidget.hasContent(), true); + }); + + it("keeps capped output within narrow widths", () => { + addRunning("worker"); + addRunning("scout"); + + for (const width of [0, 1, 2, 4, 8]) { + for (const line of statusWidget.render(width, 1)) { + assert.ok( + visibleWidth(line) <= width, + `expected line width <= ${width}, got ${visibleWidth(line)}`, + ); + } + } + }); + + it("drops old claims during reload and allows a fresh runtime to claim", async () => { + const oldRuntime = createMockExtensionApi(); + (subagentsModule as any).default(oldRuntime.api); + const oldCtx = { + hasUI: true, + ui: { setWidget() {} }, + }; + await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, oldCtx); + addRunning("worker"); + + let oldChanges = 0; + const oldOnChange = () => oldChanges++; + const oldRelease = statusWidget.claim(oldOnChange); + + // Bind the replacement before the old runtime emits shutdown. The old + // shutdown must not undo the fresh binding or retain its old callback. + const freshRuntime = createMockExtensionApi(); + (subagentsModule as any).default(freshRuntime.api); + await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, oldCtx); + // A delayed old start must also be ignored once the fresh runtime is bound. + await emitMockEvent(oldRuntime, "session_start", { reason: "reload" }, oldCtx); + assert.equal(shared.latestPi, freshRuntime.api); + assert.equal(statusWidget.isClaimed(oldOnChange), false); + await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, oldCtx); + testApi.updateWidget(); + + assert.equal(oldChanges, 0); + assert.equal(shared.statusWidgetClaim, null); + assert.equal(shared.runningSubagents.size, 1); + + let freshChanges = 0; + const freshRelease = statusWidget.claim(() => freshChanges++); + oldRelease(); + testApi.updateWidget(); + assert.equal(freshChanges, 1, "a stale release cannot affect the fresh claim"); + freshRelease(); + }); + + it("replaces malformed and old widget contracts on a duplicate load", async () => { + const globalApi = (globalThis as any).__pi_interactive_subagents; + (globalThis as any).__pi_interactive_subagents = "old-api"; + shared.statusWidgetClaim = { id: 1, onChange: () => {} }; + await import(`../pi-extension/subagents/index.ts?malformed-global=${Date.now()}`); + const repairedApi = (globalThis as any).__pi_interactive_subagents; + assert.equal(typeof repairedApi.registerToolExtension, "function"); + assert.equal(repairedApi.statusWidget, globalApi.statusWidget); + + shared.statusWidgetClaim = { id: 1, onChange: () => {} }; + await import(`../pi-extension/subagents/index.ts?old-widget-claim=${Date.now()}`); + assert.equal(shared.statusWidgetClaim, null); + + const oldContract = { + hasContent: () => true, + getBodyRowCount: () => 1, + render: () => [], + claim: () => () => {}, + }; + shared.statusWidget = oldContract; + (globalThis as any).__pi_interactive_subagents.statusWidget = oldContract; + + await import(`../pi-extension/subagents/index.ts?old-widget-contract=${Date.now()}`); + + const currentApi = (globalThis as any).__pi_interactive_subagents; + const current = currentApi.statusWidget; + assert.equal(currentApi, repairedApi); + assert.notEqual(current, oldContract); + assert.equal(typeof current.hasContent, "function"); + assert.equal(typeof current.getBodyRowCount, "function"); + assert.equal(typeof current.render, "function"); + assert.equal(typeof current.claim, "function"); + assert.equal(typeof current.isClaimed, "function"); + assert.equal(shared.statusWidget, current); + }); +}); + +describe("subagent display helpers", () => { + const testApi = (subagentsModule as any).__test__; + + describe("formatTokens", () => { + it("renders raw counts below 1k, 1 decimal below 10k, rounded k above", () => { + assert.equal(testApi.formatTokens(850), "850"); + assert.equal(testApi.formatTokens(3200), "3.2k"); + assert.equal(testApi.formatTokens(45000), "45k"); + }); + }); + + describe("contextWindowFor", () => { + it("maps known model families and returns undefined otherwise", () => { + assert.equal(testApi.contextWindowFor("claude-sonnet-4-6"), 200_000); + assert.equal(testApi.contextWindowFor("gemini-2.5-pro"), 1_000_000); + assert.equal(testApi.contextWindowFor("some-unknown-model"), undefined); + assert.equal(testApi.contextWindowFor(null), undefined); + }); + }); + + describe("formatContextUsage", () => { + it("shows a percent gauge when the window is known", () => { + assert.equal(testApi.formatContextUsage(36_000, 200_000), "18.0%/200k"); + assert.equal(testApi.formatContextUsage(500_000, 1_000_000), "50.0%/1.0M"); + }); + + it("falls back to a window-less ctx label when unknown", () => { + assert.equal(testApi.formatContextUsage(37_000, undefined), "37k ctx"); + }); + }); + + describe("formatUsageSegments", () => { + it("emits arrow/cache/cost segments, skipping zero fields", () => { + const segs = testApi.formatUsageSegments({ + model: "claude-sonnet-4-6", + toolCount: 3, + inputTokens: 3200, + outputTokens: 890, + cacheReadTokens: 45000, + cacheWriteTokens: 0, + contextTokens: 7000, + cost: 0.042, + }); + assert.deepEqual(segs, ["↑3.2k", "↓890", "R45k", "$0.042"]); + }); + + it("returns an empty list when there is no usage", () => { + assert.deepEqual( + testApi.formatUsageSegments({ + model: null, + toolCount: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + contextTokens: 0, + cost: 0, + }), + [], + ); + }); + }); + + describe("widgetIcon", () => { + it("maps active/running to a glyph and waiting/starting to another", () => { + const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ""); + assert.equal(strip(testApi.widgetIcon("active")), "⟳"); + assert.equal(strip(testApi.widgetIcon("running")), "⟳"); + assert.equal(strip(testApi.widgetIcon("stalled")), "⟳"); + assert.equal(strip(testApi.widgetIcon("waiting")), "○"); + assert.equal(strip(testApi.widgetIcon("starting")), "○"); + }); + }); +}); + +describe("surface backend selection", () => { + it("prefers Herdr, then Orca, and otherwise falls back to tmux", () => { + assert.equal(selectMuxBackend({ herdr: true, orca: true, tmux: true }), "herdr"); + assert.equal(selectMuxBackend({ herdr: true, orca: false, tmux: false }), "herdr"); + assert.equal(selectMuxBackend({ herdr: false, orca: true, tmux: true }), "orca"); + assert.equal(selectMuxBackend({ herdr: false, orca: true, tmux: false }), "orca"); + assert.equal(selectMuxBackend({ herdr: false, orca: false, tmux: true }), "tmux"); + assert.equal(selectMuxBackend({ herdr: false, orca: false, tmux: false }), null); + }); + + it("parses Herdr tab and pane creation responses", () => { + assert.deepEqual( + parseCreatedTab(JSON.stringify({ + result: { + root_pane: { pane_id: "w3:p9" }, + tab: { tab_id: "w3:t6" }, + }, + })), + { paneId: "w3:p9", tabId: "w3:t6" }, + ); + assert.equal( + parseCreatedPane(JSON.stringify({ result: { pane: { pane_id: "w3:p10" } } })), + "w3:p10", + ); + assert.throws(() => parseCreatedTab("{}"), /result\.root_pane\.pane_id/); + }); + + it("parses Orca terminal create, split, and read responses", () => { + assert.deepEqual( + parseCreatedTerminal(JSON.stringify({ + ok: true, + result: { terminal: { handle: "term_abc", tabId: "tab-1" } }, + })), + { handle: "term_abc", tabId: "tab-1" }, + ); + assert.equal( + parseCreatedSplit(JSON.stringify({ ok: true, result: { split: { handle: "term_def" } } })), + "term_def", + ); + assert.equal( + parseReadTail(JSON.stringify({ + ok: true, + result: { terminal: { status: "running", tail: ["line one", "line two"] } }, + })), + "line one\nline two", + ); + assert.throws( + () => parseReadTail(JSON.stringify({ + ok: true, + result: { terminal: { status: "exited", tail: [] } }, + })), + /not running \(status: exited\)/, + ); + assert.throws(() => parseCreatedTerminal("{}"), /result\.terminal\.handle/); + assert.throws( + () => parseCreatedTerminal(JSON.stringify({ ok: false, error: { message: "nope" } })), + /terminal create failed: nope/, + ); + }); +}); + +describe("subagent completion wrapper", () => { + it("records the child exit code without losing the terminal sentinel", () => { + const wrapped = (subagentsModule as any).__test__.wrapWithCompletionFile( + "pi --session test.jsonl", + "/tmp/subagent exit", + ); + assert.match(wrapped, /^pi --session test\.jsonl; exit_code=\$\?/); + assert.match(wrapped, /printf '%s\\n' "\$exit_code" > '\/tmp\/subagent exit'/); + assert.match(wrapped, /__SUBAGENT_DONE_\$\{exit_code\}__/); + }); +}); + +describe("surface.ts", () => { + describe("shellEscape", () => { + it("wraps in single quotes", () => { + assert.equal(shellEscape("hello"), "'hello'"); + }); + + it("escapes single quotes", () => { + assert.equal(shellEscape("it's"), "'it'\\''s'"); + }); + + it("handles empty string", () => { + assert.equal(shellEscape(""), "''"); + }); + + it("handles special characters", () => { + const input = 'echo "hello $world" && rm -rf /'; + const escaped = shellEscape(input); + assert.ok(escaped.startsWith("'")); + assert.ok(escaped.endsWith("'")); + // Inside single quotes, everything is literal + assert.ok(escaped.includes("$world")); + }); + }); +}); diff --git a/extensions/interactive-subagents/tsconfig.json b/extensions/interactive-subagents/tsconfig.json new file mode 100644 index 0000000..2edecad --- /dev/null +++ b/extensions/interactive-subagents/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["pi-extension/**/*.ts", "test/**/*.ts"] +} diff --git a/extensions/lm-studio/LICENSE b/extensions/lm-studio/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/lm-studio/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/lm-studio/README.md b/extensions/lm-studio/README.md new file mode 100644 index 0000000..d6642a6 --- /dev/null +++ b/extensions/lm-studio/README.md @@ -0,0 +1,7 @@ +# @_jb/pi-lm-studio + +Pi provider extension for a local LM Studio server at `http://127.0.0.1:1234/v1`. + +```bash +pi install npm:@_jb/pi-lm-studio +``` diff --git a/extensions/lm-studio/package.json b/extensions/lm-studio/package.json new file mode 100644 index 0000000..ed8b416 --- /dev/null +++ b/extensions/lm-studio/package.json @@ -0,0 +1,22 @@ +{ + "name": "@_jb/pi-lm-studio", + "version": "0.1.0", + "description": "Pi provider registration for a local LM Studio server.", + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.19.0" }, + "keywords": ["pi-package", "pi-extension", "provider", "lm-studio"], + "files": ["index.ts", "README.md", "LICENSE"], + "pi": { "extensions": ["./index.ts"] }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer" + }, + "publishConfig": { + "registry": "https://git.jb.codes/api/packages/jb/npm/" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/lm-studio" + } +} diff --git a/extensions/native-hooks/.gitignore b/extensions/native-hooks/.gitignore new file mode 100644 index 0000000..5075a81 --- /dev/null +++ b/extensions/native-hooks/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +coverage/ +dist/ +*.log +.DS_Store +.mesh +.pi \ No newline at end of file diff --git a/extensions/native-hooks/LICENSE b/extensions/native-hooks/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/native-hooks/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/native-hooks/README.md b/extensions/native-hooks/README.md new file mode 100644 index 0000000..1f22cd2 --- /dev/null +++ b/extensions/native-hooks/README.md @@ -0,0 +1,77 @@ +# pi-native-hooks + +A native Pi extension that coordinates trusted personal policies after workspace mutations. It shares one ast-grep analysis snapshot across diagnostic policies and gates settlement until exact, current verification evidence exists or the agent records a reasoned waiver. + +## Requirements + +- Node.js 22.19 or newer +- Pi 0.84.4 or compatible +- ast-grep 0.45 or newer for diagnostics + +## Install + +Installation is a separate, user-level mutation and is **not** performed by this repository's tests: + +```bash +pi install npm:@_jb/pi-native-hooks +``` + +For monorepo development, use the curated link policy or load `extensions/native-hooks/extensions/pi-native-hooks/index.ts` directly. + +Then use: + +```text +/hooks-init --dry-run +/hooks-init --apply +/hooks-doctor +/reload +``` + +`--apply` displays every destination and asks for interactive confirmation. Existing files are preserved; newly created files are rolled back after a partial failure. + +## Trust model + +Files under `~/.config/pi-native-hooks/policies/*.ts` are trusted executable code. Importing them runs with Pi's filesystem, process, network, credentials, and outer sandbox authority. Structural validation is **not a sandbox**. The loader accepts only sorted direct-child `.ts` files and requires the config root, policies, ast-grep configuration, and rules to be owned by the current OS user, non-symlinked, regular files/directories, and not group/other writable. + +Personal policies use Node's erasable TypeScript subset: type-only syntax, interfaces, and `satisfies` work; enums, namespaces, parameter properties, JSX, and tsconfig transforms do not. Policies reload only with `/reload` or session replacement. + +## Diagnostics + +The starter policies flag TypeScript/TSX non-null assertions and dynamic imports. Both use `ast-grep:personal`, so one mutation wave runs one scan and fans the immutable snapshot to both policies. A justified exception uses the exact rule ID: + +```ts +const plugin = await import(path); // ast-grep-ignore: typescript-no-dynamic-import-ts -- runtime-selected plugin +``` + +Analyzer failures are visible and never turn a successful mutation into a tool error. Unchanged findings are not repeatedly delivered. + +## Verification gate + +Starter project policies require these exact standalone commands from the matched Git project root: + +```text +bun run test +bun run typecheck +``` + +Focused, combined, altered, wrong-directory, failed, aborted, concurrent, or stale commands do not count. A state-free session treats existing dirty files as its baseline; requirements arm only after an observed relevant mutation or restoration of armed branch state. At `agent_settled`, outstanding checks trigger another Pi turn. + +The agent may call `pi_hooks_waive_verification` with a non-empty reason. A waiver is auditable, revision-scoped, distinguishable from a pass, and expires after the next relevant mutation. + +Pi exposes foreground bash completion as `isError` rather than a numeric exit code. If a shell is externally signaled but Pi reports success, this extension cannot independently disprove completion. The settlement gate also cannot prevent a user or external client from closing Pi. + +## Commands + +- `/hooks-status` — policy digests and paths, trust/load failures, project identity, workspace revision, analyzer health, findings, verification revisions, evidence, waivers, and failures +- `/hooks-init --dry-run` — preview external config writes +- `/hooks-init --apply` — request authorization and initialize missing starter files +- `/hooks-doctor` — validate policy loading and ast-grep availability + +## Development + +```bash +bun run typecheck +bun run test +``` + +Architecture and acceptance details are in [`docs/implementation-plan.md`](docs/implementation-plan.md). Event payload and project identity assumptions are pinned in [`docs/event-contract.md`](docs/event-contract.md). diff --git a/extensions/native-hooks/docs/adr/0001-standalone-runtime-and-trusted-policies-27AUG2026.md b/extensions/native-hooks/docs/adr/0001-standalone-runtime-and-trusted-policies-27AUG2026.md new file mode 100644 index 0000000..a2f4df5 --- /dev/null +++ b/extensions/native-hooks/docs/adr/0001-standalone-runtime-and-trusted-policies-27AUG2026.md @@ -0,0 +1,9 @@ +# ADR 0001: standalone runtime and trusted policies + +**Status:** accepted, 27 August 2026 + +`pi-native-hooks` is one Pi package and extension. It loads only direct-child TypeScript policies from the current OS user's `~/.config/pi-native-hooks/policies`, after owner, mode, regular-file, and no-symlink checks. Policies are trusted executable code with Pi's authority; validation is not a sandbox. + +Analyzer execution is package-owned and coalesced by effective analyzer key. The MVP adapter is ast-grep. Verification is exact-command, revision-scoped evidence, persisted on the active session branch. Pending armed checks force an `agent_settled` follow-up; a reasoned agent waiver is visible and expires on the next relevant mutation. + +Node-native erasable TypeScript is required on Node >=22.19. Unsupported syntax fails that policy with an actionable load error; no implicit transpiler fallback is used. diff --git a/extensions/native-hooks/docs/event-contract.md b/extensions/native-hooks/docs/event-contract.md new file mode 100644 index 0000000..ca69e25 --- /dev/null +++ b/extensions/native-hooks/docs/event-contract.md @@ -0,0 +1,5 @@ +# Runtime event and identity contract + +The runtime targets Pi 0.84.4. `tool_execution_start` carries `toolCallId`, `toolName`, and `args`; all parallel sibling starts are emitted in source order before execution. A finalized execution either enters `tool_result` (with final `input`, `content`, `details`, `usage`, and `isError`) followed by `tool_execution_end`, or may enter only `tool_execution_end` when blocked/invalid. Handlers are idempotent by tool-call ID. `agent_settled` is the completion candidate used by the gate. + +Project identity is SHA-256 of the canonical git root, initial commit object (or `NO_HEAD`), and package name, NUL-delimited. The mutable HEAD and index belong to the workspace fingerprint, not identity. A different canonical root or initial commit is a different project. This avoids remote-URL instability while preventing evidence transfer to an unrelated checkout. diff --git a/extensions/native-hooks/docs/implementation-plan.md b/extensions/native-hooks/docs/implementation-plan.md new file mode 100644 index 0000000..17a73e4 --- /dev/null +++ b/extensions/native-hooks/docs/implementation-plan.md @@ -0,0 +1,656 @@ +# pi-native-hooks implementation plan + +## Status + +**Approved on 27 August 2026 and implemented as the repository baseline.** Activation remains a separate explicit user-authorized step; the package is not installed and live personal configuration is not created by implementation or tests. + +## Purpose + +Build one native Pi extension that runs trusted personal policies after relevant workspace mutations. The first policies will: + +1. detect TypeScript non-null assertions and dynamic imports with AST-backed ast-grep rules, then append concise steering diagnostics to the mutation execution wave's tool results; +2. track exact required verification commands and prevent Pi from remaining settled while their evidence is missing or stale; and +3. provide an agent-callable, reasoned waiver when verification is intentionally skipped. + +The extension will also establish a small toolkit and authoring skill for adding focused policies without duplicating Pi lifecycle, analyzer execution, state, or delivery logic. + +## Decisions already made + +- Implement a native TypeScript Pi extension with `pi.on(...)`, not `pi-yaml-hooks`, `pi-steering`, or `workflow`. +- Build one standalone repository and Pi package named `pi-native-hooks`, with one extension of the same name. +- Install the package from `/srv/data/code/jb-pi/pi-native-hooks` as a Pi local-path package during development; do not depend on harness-tools link management. +- Load personal hook policies as trusted TypeScript modules from `~/.config/pi-native-hooks/`. +- Use ast-grep rule packs for syntax diagnostics. +- Execute an analyzer once per overlapping mutation wave and analyzer configuration, then fan an immutable normalized result out to every interested policy. Individual policy modules must not spawn AST, lint, or LSP processes through the runtime interface. +- Require exact configured verification commands. A focused, filtered, combined, or otherwise altered command does not satisfy an exact requirement unless separately configured. +- Treat `agent_settled` as Pi's strongest available completion candidate. If armed verification is pending, inject a `followUp` custom message with `triggerTurn: true`. +- Arm verification only after this session observes a relevant mutation or restores an already-armed branch state. A new session treats pre-existing dirty files as its baseline rather than forcing checks during question-only work. +- Accept foreground verification when Pi reports `event.isError === false`. Document the narrow limitation that Pi's public bash result omits the exit code and can be ambiguous if the shell process itself ends through an external signal. +- Allow an agent-callable waiver tool. A waiver must contain a non-empty reason, is scoped to the requirement revisions and outstanding checks it clears, is persisted, and becomes stale after the next relevant mutation. +- Keep command sandboxing, destructive-command policy, permissions, and process isolation out of scope. nono profiles own those controls. +- Treat personal policy modules as executable in-process code with Pi's authority. Load only user-owned configuration from `~/.config/pi-native-hooks`; do not load repository-local policy code. +- Use Node's native erasable-TypeScript support on the Pi-supported Node `>=22.19` runtime. Avoid adding a TypeScript loader dependency unless the Phase 0 proof shows native loading is insufficient. +- Discover policies only at session startup or `/reload`; do not add a file watcher in the MVP. + +## Outcomes + +The first release is successful when: + +- an overlapping parallel mutation wave produces at most one ast-grep process; +- both initial TypeScript policies receive the same normalized analysis snapshot; +- diagnostics identify rule, file, line, guidance, and the supported exception form; +- repeated unchanged findings do not repeatedly consume model context; +- writes made by `write`, `edit`, `bash`, and unknown custom tools are detected through workspace state rather than command-name guesses alone; +- each armed required check is valid only after it fully completes successfully at or after its current required revision; +- a foreground exact `bash` command can provide evidence through Pi's finalized success state; +- Pi is forced into a follow-up turn while required evidence is pending, until checks pass or the agent records a waiver; +- state survives session reload/resume without treating stale evidence as current; +- analyzer failures are visible but do not corrupt successful tool results; +- armed verification uncertainty fails closed to a pending gate; +- the hook-authoring skill can create a typed personal policy and focused fixture using the shared toolkit; +- policy load errors, ownership failures, and analyzer degradation are observable without crashing Pi; and +- the local-path Pi package exposes exactly one `pi-native-hooks` extension and the `native-hook-authoring` skill. + +## Boundaries + +### In scope + +- Pi lifecycle coordination for overlapping tool execution waves, messages, settlement, and session state. +- Git-aware workspace snapshots and changed-path discovery. +- A shared analyzer planner and ast-grep adapter. +- Typed normalized findings and structurally validated diagnostic policy modules. +- Tool-result diagnostic augmentation, deduplication, status, and `/hooks-status` observability. +- Exact foreground `bash` verification evidence. +- Forced completion follow-ups and a reasoned waiver tool. +- Initial personal TypeScript diagnostic policies and verification policies for `pi-native-hooks` and `harness-tools`. +- A package-managed hook-authoring skill, policy type declarations, initializer/doctor commands, and local-path installation documentation. + +### Deferred + +- Implementations of lint and LSP adapters. The coordinator contract must support them, but the MVP implements only ast-grep. +- Async/background verification adapters. The removed `pi-background-tasks` package is not a runtime dependency or integration target. +- Repository-local or remotely downloaded executable policy modules. Only the fixed personal config root is loaded. +- Automatic inference of verification policy from package scripts. +- Semantic/type-aware TypeScript analysis beyond ast-grep's syntax tree. +- Automatic fixes. +- A universal task-completion protocol for clients other than Pi's `agent_settled` lifecycle. +- Accepting interactive `!`/`!!` commands as verification evidence. Out-of-band changes are detected by workspace snapshots, but required checks must run through an observable tool in the MVP. + +### Explicit non-goals + +- Shell command filtering or rewriting. +- Destructive-command confirmation. +- Filesystem, network, credential, or process sandboxing. +- Replacing project linters, typecheckers, test runners, or LSP servers. + +## Repository and runtime boundaries + +- **Repository:** `/srv/data/code/jb-pi/pi-native-hooks` +- **Package and extension:** `pi-native-hooks` +- **Extension entrypoint:** `extensions/pi-native-hooks/index.ts` +- **Personal policy root:** `~/.config/pi-native-hooks/` +- **Installation:** Pi local-path package from `/srv/data/code/jb-pi/pi-native-hooks` during development; registry publication is deferred. +- **Runtime code:** package-owned lifecycle, analyzer, state, delivery, validation, and UI modules. +- **Personal code:** user-owned policy modules and adjacent ast-grep rule files. Personal policies are not committed to this repository. +- **Repository fixtures:** checked-in example policies and test fixtures that mirror the personal module contract without containing the user's live policy set. + +The repository may ship an initializer that creates the personal directory, a policy-oriented `tsconfig.json`, and starter modules, but it must perform a dry run and request explicit authorization before writing outside the repository. Package install and personal policy initialization are separate operations. + +## Trusted TypeScript policy model + +Personal policy modules are arbitrary executable code. Importing one can perform side effects before its export is validated, so schema validation is not a sandbox. Documentation and `/hooks-status` must state that these files run with the same filesystem, process, network, and credential authority as Pi; nono remains responsible for the outer sandbox. + +The one-run analyzer guarantee applies to toolkit-managed analyzer keys. The runtime withholds process authority from `PolicyContext`, and the authoring skill requires pure evaluators, but a trusted module can still import `node:child_process` and bypass the toolkit. Hard prevention would require declarative or isolated policies and conflicts with the chosen trusted-TypeScript model. + +The loader must: + +1. resolve the fixed `/.config/pi-native-hooks` root from the OS user record, without accepting environment or repository overrides; +2. require the root, `sgconfig.yml`, policy files, and rule files to be user-owned regular files/directories, not symlinks, and not writable by group or others; +3. discover only sorted direct-child `.ts` files from the documented `policies/` directory; +4. load each file through native dynamic import with an mtime/content cache token on Node `>=22.19`; +5. document the erasable-TypeScript subset: type-only syntax is supported, while enums, namespaces, parameter properties, JSX, and tsconfig transforms are not; +6. validate the default export structurally after import and reject duplicate IDs; +7. isolate a throwing/invalid module so other policies still load, while surfacing the exact file and error; and +8. reload policies only when Pi recreates the extension on `/reload` or session replacement. + +The package exposes `@_jb/pi-native-hooks/policy` type declarations. An authorized initializer writes a personal `tsconfig.json` with a path mapping to the installed package so policies can use `import type` plus `satisfies` without a runtime package import. Runtime validation remains authoritative because type checking can be skipped or stale. + +Suggested personal layout: + +```text +~/.config/pi-native-hooks/ +├── tsconfig.json +├── sgconfig.yml +├── policies/ +│ ├── typescript-no-non-null-assertion.ts +│ ├── typescript-no-dynamic-import.ts +│ ├── pi-native-hooks-verification.ts +│ └── harness-tools-verification.ts +└── ast-grep/ + └── rules/ + └── typescript/*.yml +``` + +## Architecture + +```text +~/.config/pi-native-hooks/policies/*.ts + | + v + +-----------------------+ + | TrustedPolicyLoader | ownership checks + structural validation + +-----------+-----------+ + | immutable policy registry + v +Pi lifecycle events + | + v ++----------------------------+ +| ExecutionWaveCoordinator | records source order and terminal events, +| | compares pre/post workspace snapshots ++-------------+--------------+ + | MutationWave + v ++-----------------------+ one invocation per analyzer key +| AnalysisCoordinator |----------------------------------+ ++-----------+-----------+ | + | immutable AnalysisSnapshot v + | +--------------------+ + +-------------------------------> | ast-grep adapter | + | +--------------------+ + v ++-----------------------+ +| Diagnostic policies | evaluation receives data, not process authority ++-----------+-----------+ + | Diagnostics + v ++-----------------------+ +------------------------------+ +| Result delivery | | Verification tracker | +| dedupe + augmentation | | revisions/evidence/waivers | ++-----------------------+ +--------------+---------------+ + | + v + agent_settled follow-up gate +``` + +### Contract shapes + +The implementation should converge on contracts equivalent to these, without treating these names as immutable API: + +```ts +interface MutationWave { + readonly id: string; + readonly turnIndex: number; + readonly orderedToolCallIds: readonly string[]; + readonly changedPaths: readonly string[]; + readonly beforeFingerprint: string; + readonly afterFingerprint: string; + readonly workspaceRevision: number; + readonly toolResults: ReadonlyMap; +} + +interface AnalysisRequest { + readonly analyzerKey: string; + readonly paths: readonly string[]; +} + +interface AnalysisSnapshot { + readonly analyzerKey: string; + readonly workspaceRevision: number; + readonly paths: readonly string[]; + readonly findings: readonly Finding[]; + readonly durationMs: number; + readonly failure?: AnalyzerFailure; +} + +interface DiagnosticPolicy { + readonly kind: "diagnostic"; + readonly id: string; + readonly analyzerKeys: readonly string[]; + appliesTo(project: ProjectContext, paths: readonly string[]): boolean; + evaluate( + analysis: ReadonlyMap, + context: PolicyContext, + ): readonly Diagnostic[]; +} + +interface VerificationPolicy { + readonly kind: "verification"; + readonly id: string; + readonly project: ProjectMatcher; + readonly requirements: readonly VerificationRequirement[]; +} + +interface VerificationRequirement { + readonly id: string; + readonly command: string; + readonly cwd: "project-root"; + affects(path: string): boolean; +} + +type PersonalPolicy = DiagnosticPolicy | VerificationPolicy; +``` + +Freeze arrays/maps or expose readonly copies before calling policy modules. Do not expose the process runner, Pi API, session context, or mutable tracker through `PolicyContext`; this makes shared analyzer execution enforceable by construction. This limits what the runtime hands to a policy, but it does not sandbox top-level module code or imports. + +## Tool execution wave and mutation lifecycle + +Pi preflights parallel sibling calls in assistant source order and runs them concurrently, but sequential tools interleave start and completion. An executed call reaches `tool_result` before `tool_execution_end`; a blocked, invalid, or otherwise immediate call can reach `tool_execution_end` without any `tool_result`. Final tool-result messages are emitted later in assistant source order. The extension must coordinate overlapping execution waves rather than assume one batch per turn or one `tool_result` per start. + +1. On `turn_start`, reset turn-local ordering metadata without creating a barrier for the whole turn. +2. On each `tool_execution_start`, add `toolCallId`, tool name, input, and source-order position to the current overlapping wave. If no call remains open, begin a new wave. This naturally treats each sequential execution as its own wave while grouping parallel siblings. +3. Before the first potentially mutating call in a wave executes, capture one workspace baseline. Known read-only built-ins can skip this work; `write`, `edit`, `bash`, and unknown custom tools are potentially mutating. The extension's status and waiver tools are explicitly workspace-read-only. +4. On `tool_result`, record final input, content, details, and `isError`, then mark that call terminal for barrier purposes before waiting. A failed command may have partially mutated files, so failure does not skip comparison. +5. On `tool_execution_end`, mark a call terminal only when no preceding `tool_result` did so. This accounts for blocked and argument-invalid calls without waiting for an event Pi will never emit. +6. When every call in the overlapping wave is terminal, compute one post-wave workspace snapshot. A bounded fallback releases waiters if event accounting becomes inconsistent. +7. If fingerprints differ, calculate changed paths and advance the workspace revision once. For each verification requirement whose `affects(path)` predicate matches, advance its required revision and invalidate its evidence or waiver. +8. Build analyzer requests required by applicable policies. Coalesce requests by `analyzerKey`, union and sort paths, and invoke each key once. ast-grep is the only implemented key in the MVP. +9. Fan immutable snapshots to policy evaluators. Deduplicate and format their diagnostics. +10. Attach one combined diagnostic block to a deterministic carrier with a real `tool_result`, preferably the last source-order potentially mutating result. Fall back to the last source-order participating result when the preferred call completed immediately. Preserve original `content`, `details`, `isError`, and `usage`. +11. Release every waiting handler. Use `turn_end` for cleanup and inconsistency detection, not as the normal analysis point. + +An extension defect must not deadlock tool delivery. On timeout or inconsistent events, release handlers, surface a coordinator failure, and conservatively mark armed verification state unknown. + +A verification command running in a wave with any other potentially mutating call must not count, even if the final workspace fingerprint is unchanged. The check may have raced a transient mutation and must be rerun in a later stable wave. + +## Workspace snapshots + +Use repository state, not shell-command regexes, as the primary mutation signal. + +### Git workspaces + +Capture a deterministic, NUL-safe map for tracked changes, staged changes, deletions, renames, symlinks, submodule markers, and untracked non-ignored files. Include the HEAD object ID and index state. Hash current content only for dirty paths relevant to at least one active policy so a second edit to an already-dirty file changes the fingerprint without hashing the clean repository. If HEAD changes, derive affected paths between the old and new commits. Compare before and after maps to derive paths changed by the execution wave. + +Use Git plumbing/status commands through `pi.exec` with argv arrays, timeout, and `ctx.signal`, then strictly parse and cap their results. Ignored build artifacts such as `node_modules` and normal coverage output do not invalidate evidence unless a verification policy explicitly marks them relevant. + +### Non-Git or failed snapshots + +For successful `write`/`edit`, fall back to their explicit target paths. For `bash` or unknown tools in a non-Git workspace, exact changed paths cannot be proven cheaply; advance armed requirements to `unknown` and fail verification closed. Diagnostics may report that no safe path set was available rather than scanning the entire directory. + +### Out-of-band changes + +On a new branch state with no native-hooks history, capture the current workspace as an unarmed baseline even when it is already dirty. On restored armed state, compare the persisted project identity and fingerprint during `session_start`; also refresh at `agent_settled`. A later mismatch advances affected requirement revisions before completion gating. This catches user shell commands, editor changes, and background writers without forcing verification in a question-only session merely because files were dirty before it began. + +## Analyzer coordination and performance + +### One-run invariant + +For one mutation execution wave, the analyzer coordinator may execute a given `analyzerKey` zero or one time. Multiple policies, languages, or findings do not create additional processes. Tests must inject a counting fake adapter and prove one call when both initial TypeScript policies apply, including parallel writes to several files. + +An analyzer key includes the adapter and effective configuration. If future hooks genuinely require incompatible lint configurations, they use different keys and therefore separate documented invocations. + +### ast-grep adapter + +Invoke the installed executable once with all applicable rule files and changed paths, using project configuration and JSON stream output, conceptually: + +```text +ast-grep scan --config ~/.config/pi-native-hooks/sgconfig.yml \ + --json=stream --color=never --max-results -- +``` + +Normalize JSON into rule ID, severity, message, note, path, source range, and matched text hash. Convert ast-grep's zero-based lines and columns at the rendering seam. Validate every decoded record rather than casting untrusted process output. Bound runtime, stdout, file count, finding count, and rendered diagnostics. + +Keep steering rules at warning, info, or hint severity so a match does not become a process failure; the authoring skill rejects error-severity steering rules. Resolve and version-check `ast-grep` lazily once per session. A missing, incompatible, timed-out, nonzero, malformed, or killed analyzer produces an `AnalyzerFailure`; it never produces a false clean snapshot. + +### Initial personal syntax policies + +The initializer creates these policies and their ast-grep rules only after an approved dry run. The runtime itself ships contract fixtures and examples, not the user's live policy files. Both policies request the shared `ast-grep:personal` analyzer key and filter their own rule IDs from the same immutable snapshot. + +1. **`typescript-no-non-null-assertion`** + - Match TypeScript/TSX `non_null_expression` nodes. + - Guidance: prefer a guard, refinement, assertion function, or corrected type. + - Exception: use a rule-specific `ast-grep-ignore` comment with a short rationale when an invariant is intentional. + +2. **`typescript-no-dynamic-import`** + - Match dynamic `import(...)` call expressions in TypeScript/TSX. + - Guidance: prefer a static import unless lazy loading, code splitting, an optional dependency, or runtime module selection is intentional. + - Exception: use the same rule-specific suppression with a rationale. + +Use separate TypeScript and TSX ast-grep rule definitions where the analyzer requires separate language declarations, but map those rule IDs to one user-facing policy ID. ast-grep's supported form allows a trailing rationale, for example: + +```ts +const module = await import(path); // ast-grep-ignore: typescript-no-dynamic-import-ts -- runtime-selected plugin +``` + +Do not add regex-based parsing of TypeScript syntax. A later policy may validate suppression rationale formatting, but that is not required for the first slice. + +### Diagnostic deduplication + +Fingerprint a finding from policy ID, analyzer rule ID, canonical path, normalized range, and matched-text hash. Keep the last delivered set per file/content fingerprint. Deliver new or changed findings, drop resolved fingerprints, and avoid repeating an unchanged finding after unrelated tool calls. Cap rendered findings and include a count of omitted items. + +## Verification model + +### Initial personal verification policies + +The authorized initializer creates two verification policy modules: + +1. **`pi-native-hooks-verification`** matches Git root plus `package.json` name `pi-native-hooks`. +2. **`harness-tools-verification`** matches Git root plus `package.json` name `harness-tools`. + +Both initially require these standalone commands from the matched project root: + +```text +npm test +npm run typecheck +``` + +Each requirement owns a pure `affects(path)` predicate instead of introducing a glob dependency. The `pi-native-hooks` policy arms tests for behavior-bearing changes under `extensions/`, `src/`, `tests/`, `examples/`, and `skills/`, plus package and TypeScript manifests; its typecheck requirement covers TypeScript and package/TypeScript manifests. The harness-tools policy retains the repository-specific `extensions/`, `scripts/`, `services/`, `skills/`, `config/`, and manifest predicates documented in its module. Documentation-only changes outside each policy's declared surfaces do not stale evidence. + +Trim command leading/trailing whitespace only. Do not parse shell text or accept `npm test -- ...`, `npm test && npm run typecheck`, aliases, focused scripts, or commands from another working directory. Additional exact forms require an explicit personal policy. + +### Evidence lifecycle + +Maintain one monotonic workspace revision. Each requirement stores its own `requiredRevision`, which remains absent until an observed path first satisfies its `affects(path)` predicate. Irrelevant later changes may advance the workspace revision without invalidating that requirement. + +Each requirement is one of: + +- `inactive`: this branch has not observed a relevant mutation; +- `not-run`: armed, with no evidence for its required revision; +- `passed`: the exact command fully succeeded at or after its required revision; +- `failed`: the exact command completed unsuccessfully; +- `stale`: older success exists but a later relevant mutation advanced the required revision; +- `waived`: the agent recorded a reason for this required revision; or +- `unknown`: armed workspace or event state could not be proven. + +A requirement becomes valid only when all of these hold: + +- it is armed; +- command and `ctx.cwd` exactly match its configured command and repository root; +- execution begins at or after the latest required revision; +- no other potentially mutating call shares its overlapping execution wave; +- no relevant mutation occurs before completion; +- Pi emits a finalized tool result with `event.isError === false`; and +- the evidence records the same project identity and required revision. + +Output truncation does not imply partial execution; Pi finalizes the process before emitting the result. Normal nonzero exits, Esc aborts, and timeouts produce `isError: true` and do not count. Pi's public bash result omits the numeric exit code, so the MVP documents one narrow limitation: if the shell process itself ends through an external signal and Pi exposes a successful result, the extension cannot independently disprove completion. This is an operational completion gate, not a security attestation. + +### Foreground `bash` + +Capture the original command and `ctx.cwd` by `toolCallId`, then compare the finalized input after other extension handlers have run. `event.isError === false` is the success signal exposed by Pi's public finalized bash result. Record evidence only after execution-wave workspace comparison proves no relevant mutation and no potentially mutating sibling raced the check. + +## Completion gate and waiver + +### Forced follow-up + +At every `agent_settled`: + +1. refresh workspace state and advance affected requirement revisions if it changed; +2. evaluate only armed verification requirements; +3. update the TUI status; and +4. if any armed requirement is not passed or currently waived, send one custom `pi-native-hooks-verification` message with `{ deliverAs: "followUp", triggerTurn: true }`. + +The message lists exact outstanding commands and states that the agent must run them or use the waiver tool with a reason. A per-settlement delivery guard prevents duplicates from concurrent callbacks, but a later settlement with the same unresolved revisions sends another reminder. That repeated behavior is intentional: otherwise Pi could settle on the second attempt without evidence. + +A `context` handler removes older `pi-native-hooks-verification` messages from the model-facing copy, retaining only the latest live reminder while the gate is pending and none after it is satisfied. Session history remains auditable without allowing repeated forced turns to accumulate stale guidance in model context. + +Pi has no literal task-acceptance event. This mechanism prevents the normal agent loop from remaining idle, but it cannot stop a user from closing Pi or an external client from discarding the session. Document that limitation plainly. + +The gate may also fire when an agent settles after asking a question rather than claiming completion. The chosen agent-callable waiver is the escape hatch for intentionally deferred verification. + +### Waiver tool + +Register `pi_hooks_waive_verification` with one required `reason` string. Runtime validation trims and rejects an empty reason. + +The tool: + +- lists the exact outstanding requirement IDs it waives; +- records reason, timestamp, project identity, requirement IDs/revisions, and tool call in durable extension state; +- returns a visible summary that verification was skipped rather than passed; +- rejects a no-op request when no armed requirement is outstanding; +- clears the completion gate only for those requirement revisions; and +- becomes stale after the next relevant mutation. + +A waiver never creates test evidence and must remain distinguishable in `/hooks-status` and session restoration. Any later path matching the requirement's `affects(path)` predicate advances its required revision and expires that waiver. + +## State, observability, and failure policy + +### Persistence + +Append versioned `pi-native-hooks-state-v1` snapshots for meaningful transitions: policy registry digest, workspace/required revision changes, accepted evidence, failures, waivers, and coordinator uncertainty. Restore the latest valid snapshot from `ctx.sessionManager.getBranch()` on `session_start`, then compare policy digest, project identity, and live workspace fingerprint before trusting it. Reconstruct again on `session_tree` so branch navigation cannot inherit state from an abandoned branch. A previously armed policy that is now missing or fails to load remains `unknown` rather than silently clearing its gate. + +Avoid persisting every analyzer finding or reminder. Diagnostic deduplication may be session-local; verification and waiver state must be durable and branch-aware. + +### UI and commands + +- Set a compact `pi-native-hooks` status showing diagnostic count and verification summary. +- Add `/hooks-status` to show config root health, loaded policy paths/digests, load failures, project identity, workspace revision, analyzer timing, inactive or pending checks, required revisions, last success, active waiver, and last failure. +- Keep model-visible diagnostics concise. Put verbose operational detail in the status command or TUI notification. +- Clear status and release pending barriers during `session_shutdown`. + +### Failure policy + +| Failure | Behavior | +|---|---| +| Config root absent with no restored armed state | Load an empty inactive registry, show initialization guidance, and do not create files automatically. | +| Config ownership/mode check fails | Import no affected file, identify the unsafe path, and keep previously armed policy state unknown. | +| New policy throws or exports an invalid value | Skip that policy, report its file, and continue valid siblings. | +| Previously armed policy is missing or fails to load | Preserve its outstanding requirement state as unknown until restored or waived; never silently accept completion. | +| ast-grep missing, incompatible, timed out, or malformed | Preserve tool success, append one analyzer warning, show degraded status, and do not claim a clean scan. | +| Policy evaluator throws | Isolate that policy, report its ID, continue siblings, and mark its evaluation degraded. | +| Workspace snapshot cannot prove armed state | Release tool results, mark affected requirements `unknown`, and force follow-up until rerun or waiver. | +| Persistence entry malformed | Ignore that entry, use the latest earlier valid state if available, and surface a warning. | +| Barrier inconsistency or timeout | Release waiters, avoid result corruption, mark verification unknown, and surface coordinator failure. | +| Result augmentation fails | Return the original result unchanged and notify the UI; never convert a successful mutation into a tool error. | + +## Proposed repository layout + +```text +pi-native-hooks/ +├── package.json +├── tsconfig.json +├── README.md +├── extensions/ +│ └── pi-native-hooks/ +│ └── index.ts +├── src/ +│ ├── runtime.ts +│ ├── policy.ts +│ ├── policy-loader.ts +│ ├── analysis.ts +│ ├── execution-wave.ts +│ ├── diagnostics.ts +│ ├── project-context.ts +│ ├── state.ts +│ ├── verification.ts +│ ├── workspace.ts +│ └── analyzers/ +│ └── ast-grep.ts +├── tests/ +│ ├── runtime.test.ts +│ ├── policy-loader.test.ts +│ ├── execution-wave.test.ts +│ ├── ast-grep.test.ts +│ ├── verification.test.ts +│ └── fixtures/ +│ └── personal-config/ +├── examples/ +│ └── policies/ +│ ├── typescript-no-non-null-assertion.ts +│ ├── typescript-no-dynamic-import.ts +│ ├── pi-native-hooks-verification.ts +│ └── harness-tools-verification.ts +├── skills/ +│ └── native-hook-authoring/ +│ ├── SKILL.md +│ └── references/ +│ ├── contracts.md +│ └── testing.md +└── docs/ + ├── implementation-plan.md + └── adr/ +``` + +Prefer colocated focused tests when a module has substantial behavior. The exact split may be simplified during implementation if files remain single-purpose. + +## Delivery slices + +After the planning baseline is committed, use a new worktree for implementation. Assign ownership before parallel work. Shared runtime, package manifest, and policy contract files belong to the integration owner; policy authors own only example/fixture policy and rule files. No slice may write `~/.config/pi-native-hooks` or install the Pi package until the explicit activation step in Slice 4. + +### Slice 0: standalone package and trusted policy-loader proof + +**Goal:** prove the repository/package shape and executable TypeScript policy contract before building the event runtime. + +**Work:** + +- Add the ESM package manifest, Node `>=22.19` engine, Pi extension/skill declarations, strict TypeScript configuration, and focused test command. +- Define the small `PersonalPolicy` interface and runtime structural validators. +- Implement the fixed-root policy loader behind an injected filesystem/import adapter. +- Prove native dynamic import of erasable `.ts`, type-only imports, deterministic ordering, cache-busted reload, duplicate IDs, top-level throws, invalid exports, ownership/mode rejection, and symlink policy. +- Add checked-in example policies and a fixture personal-config tree. Do not create the live config directory. +- Record the standalone package and trusted-code decision in `docs/adr/`. + +**Success criteria:** + +- `npm run typecheck` and focused loader tests fully execute. +- Tests run against an isolated fake HOME and never inspect or mutate the real personal config. +- One invalid policy does not hide valid siblings, and every failure identifies its path. +- The loader executes no repository-local policy and accepts no environment override for the config root. +- The Node-native loader limitations are documented and proven on the minimum supported runtime before avoiding a loader dependency. + +### Slice 1: execution-wave runtime and shared analyzer seam + +**Goal:** establish Pi event and one-run analyzer invariants with fakes. + +**Work:** + +- Add a fake Pi event harness, execution-wave coordinator, and injected fake workspace/analyzer adapters. +- Model parallel source-order starts, reverse-completion results, sequential waves, and immediate `tool_execution_end` without `tool_result`. +- Implement request coalescing, immutable snapshot fan-out, deterministic result carrier selection, barrier timeout, context-reminder pruning, and shutdown cleanup. + +**Success criteria:** + +- Two parallel mutations and two interested policies call one fake analyzer exactly once. +- Out-of-order results produce one workspace revision and one diagnostic augmentation. +- A blocked sibling with no `tool_result` cannot deadlock the wave. +- Sequential mutators form separate waves without waiting for future starts. +- Read-only waves invoke no workspace snapshot or analyzer. +- Failure/timeout releases every handler and leaves armed verification unknown rather than deadlocking. + +### Slice 2: Git workspace, ast-grep adapter, and diagnostic examples + +**Goal:** prove real mutation detection and contextual TypeScript steering. + +**Work:** + +- Implement Git workspace maps/fingerprints and changed-path calculation. +- Implement the one-process `ast-grep:personal` adapter against the personal `sgconfig.yml` and strict JSON normalization. +- Add TypeScript/TSX example rules and policies for non-null assertions and dynamic imports. +- Add deduplication, bounded rendering, analyzer health/status, and `/hooks-status` policy diagnostics. +- Validate `ast-grep-ignore: -- ` exceptions. + +**Success criteria:** + +- Real fixture scans detect both patterns and suppress only the named rule. +- One wave touching several TS/TSX files and both policies starts one ast-grep process. +- `write`, `edit`, successful/failed `bash`, untracked, staged, deleted, renamed, symlinked, and already-dirty files change the correct fingerprint. +- Unchanged findings are not repeated after unrelated waves. +- Missing, incompatible, nonzero, timed-out, or malformed ast-grep is visible and never reported as a clean scan. + +### Slice 3: verification evidence and settlement gate + +**Goal:** keep Pi active until exact current evidence or a current waiver exists. + +**Work:** + +- Implement project matching, pure path predicates, observed-mutation activation, and example policies for `pi-native-hooks` and `harness-tools`. +- Track exact foreground bash attempts and Pi's finalized success state. +- Persist policy digest plus branch-aware workspace/requirement revisions, evidence, failures, and waivers. +- Handle changed, missing, or failed previously-armed policies without silently accepting completion. +- Implement settlement refresh, forced follow-up delivery, context pruning, status, and waiver tool. + +**Success criteria:** + +- A new state-free session remains unarmed even when relevant files were already dirty at startup. +- Exact standalone checks satisfy only their matching armed project requirements. +- Focused, altered, combined, failed, aborted, timed-out, wrong-cwd, concurrent, and stale commands do not satisfy the gate. +- A potentially mutating sibling invalidates a racing check even when final content is unchanged. +- `agent_settled` sends one follow-up per pending settlement and none after current evidence or waiver. +- A non-empty waiver is visible, branch-aware, revision-scoped, and invalidated by the next relevant mutation. +- Reload, branch navigation, policy digest change, and out-of-band edits cannot revive stale evidence. + +### Slice 4: authoring skill, activation, and independent review + +**Goal:** validate authoring and real Pi behavior before normal use. + +**Work:** + +- Create the package-managed `native-hook-authoring` skill with progressive disclosure for policy creation, revision, validation, and troubleshooting. +- Add `/hooks-init --dry-run` and `/hooks-doctor`; any command that writes personal config requires explicit interactive approval and preserves existing files. +- Run an extension integration scenario with parallel writes, one ast-grep run, diagnostics, stale verification, forced follow-up, successful checks, invalidation, and waiver. +- Run unfiltered `npm run typecheck` and `npm test`. +- Have an agent other than the implementer review standards, trust handling, event races, failure behavior, and this approved plan. +- Only after review and explicit user approval, install the local-path Pi package, initialize selected personal policies, run `/hooks-doctor`, and `/reload`. + +**Success criteria:** + +- The skill points to the package's policy contract rather than duplicating it. +- A fresh agent can create a fixture policy without adding analyzer execution to its evaluator. +- Dry runs enumerate every external file and package-setting change before mutation. +- Existing personal config is never overwritten; partial initialization rolls back newly created files. +- Tests record actual analyzer invocation counts and full command completion. +- Independent review has no unresolved blockers. +- The activated extension reports exactly one runtime, all intended personal policy digests, and a healthy analyzer after `/reload`. + +## Focused test matrix + +| Area | Required cases | +|---|---| +| Policy loader | fixed root; native TS; type-only import; sorted files; duplicate IDs; invalid export; top-level throw; owner/mode; symlink; cache-busted reload; changed digest | +| Wave ordering | one/many siblings; source-order starts; reverse completion; sequential waves; immediate end without result; read-only; failed partial mutation; missing terminal timeout; cleanup | +| Analyzer coalescing | two policies same key; many paths; no applicable paths; different future keys; adapter throw/timeout/malformed output | +| ast-grep | TS and TSX positives; static import negative; postfix `!` positive; logical `!` negative; justified suppression; Unicode ranges; result cap | +| Workspace | clean/dirty/staged/untracked/ignored/deleted/renamed/symlinked/binary/already-dirty; non-Git fallback; external edit | +| Delivery | original result fields preserved; one carrier; dedupe; resolved finding; truncation; analyzer warning | +| Foreground evidence | exact success; nonzero; abort; timeout; wrong cwd; focused flag; combined command; mutating sibling with/without net diff; documented external-signal limitation | +| Gate | pre-existing dirty baseline inactive; observed relevant mutation armed; irrelevant mutation unchanged; partial pass; failed; stale; unknown; repeated settlements; no duplicate within settlement; idle follow-up; context pruning | +| Waiver | empty rejection; no-op rejection; scoped requirement revisions; branch persistence; status visibility; later relevant invalidation | +| Session | startup without state; valid active-branch restore; malformed/latest-invalid entry; fork/reload/session-tree; project identity mismatch; fingerprint drift | +| Activation | Pi package manifest; local-path dry-run/install; fake HOME initialization; existing config preservation; partial rollback; doctor; exactly one runtime after reload | + +## Performance and safety acceptance + +- No analyzer process for a read-only execution wave or when no changed path is relevant. +- At most one invocation per analyzer key per overlapping execution wave, enforced by tests. +- Git work is proportional to dirty/untracked paths, not every clean tracked file. +- Analyzer and snapshot commands have abort signals, timeouts, and output/result caps. +- Policy evaluation exceptions cannot prevent sibling policies or original tool-result delivery. +- Policy modules receive immutable analysis data, but their top-level code remains explicitly trusted and unsandboxed inside Pi's outer sandbox. +- Diagnostics never change a successful tool result into an error. +- Verification never becomes current from incomplete, ambiguous, concurrent, or stale execution. + +## Documentation and review artifacts + +After approval, implementation should add: + +- `docs/adr/0001-standalone-runtime-and-trusted-policies-27AUG2026.md` for repository ownership, native policy loading, shared analyzers, and the completion gate; +- concise user documentation for installation, config trust, supported TypeScript syntax, status, waiver semantics, exact commands, exceptions, and known completion limitations; +- the `native-hook-authoring` skill; and +- a repository-local `docs/todos.md` entry for any reproducible defect discovered while testing the extension on itself. + +## Planning validation completed + +- Pi 0.84.3 documentation and installed source confirm parallel preflight ordering, concurrent execution, `tool_result` result augmentation, source-order final result messages, and `agent_settled` custom-message follow-ups. +- Installed Pi source also confirms the execution-wave correction: blocked or argument-invalid calls can emit `tool_execution_end` without invoking `tool_result`, while sequential tools do not expose future starts before the current result finishes. +- A throwaway ast-grep 0.45.1 fixture detected both initial TypeScript patterns in one `scan --json=stream` invocation, did not confuse logical negation with a non-null expression, and honored a rule-specific suppression with a trailing rationale. +- The ast-grep fixture confirmed warning findings return exit code zero and JSON source ranges are zero-based, both now reflected in the adapter plan. +- Node 26.7 successfully imported an erasable `.ts` policy containing interfaces, `satisfies`, and a type-only import to an unavailable runtime package. Phase 0 still must repeat the contract on the minimum supported Node 22.19 runtime. +- Planning originally created only this repository's README and documentation. Implementation subsequently added the runtime, tests, examples, and authoring skill. No live personal policy, Pi package setting, or global installation is changed without the separate activation approval. + +Independent agent review is deferred because direct subagent launches are currently being repaired. It remains the first execution prerequisite when implementation starts from this repository. + +## Approval gate + +The user accepted these consequences on 27 August 2026: + +1. Every personal `.ts` policy is trusted arbitrary code running in Pi's process; structural validation does not sandbox imports or top-level side effects, and a nonconforming policy can bypass the toolkit's one-run analyzer coordinator. +2. The MVP supports only Node's erasable TypeScript subset for personal modules and reloads policies on `/reload`, not continuously. +3. Pending verification forces a new turn at every settlement, including settlements after the agent asks a question. +4. A state-free session treats pre-existing dirty files as an unarmed baseline; only observed relevant mutations or restored armed state require checks. +5. The agent can waive its own verification requirement, but the reason remains visible and the waiver expires on a relevant mutation. +6. Exact command matching intentionally rejects convenient combined or focused commands. +7. Foreground success trusts Pi's public `isError` state and carries the documented external-signal ambiguity. +8. ast-grep failure degrades steering but does not block file mutation; armed workspace uncertainty does block verification completion. +9. Pi's settlement gate cannot prevent a user or external client from closing the session. +10. Local-path package installation and creation of `~/.config/pi-native-hooks` are separate external mutations requiring approval after tests and review. + +User approval is recorded. An independent pre-implementation review was completed before runtime changes; its blockers were addressed by `docs/event-contract.md`, the explicit project identity schema, fail-fast native TypeScript loading, and canonical repository paths. diff --git a/extensions/native-hooks/docs/independent-review-27AUG2026.md b/extensions/native-hooks/docs/independent-review-27AUG2026.md new file mode 100644 index 0000000..c7a260e --- /dev/null +++ b/extensions/native-hooks/docs/independent-review-27AUG2026.md @@ -0,0 +1,13 @@ +# Independent implementation review — 27 August 2026 + +A separate Pi agent reviewed the implementation after the full test and typecheck run. + +## Findings and resolution + +- **Tool-result patch flow:** reviewer suspected a wave map was returned to Pi. Reinspection and `tests/runtime.test.ts` confirm `ExecutionWaveCoordinator.result()` selects `.get(toolCallId)` and the runtime returns that per-call patch. No defect remained. +- **typebox packaging:** retained as a peer dependency, as required by Pi's package documentation for bundled core packages; it is also a development dependency for standalone typechecking. +- **Initializer path mapping:** fixed. The shipped template contains a placeholder and `/hooks-init --apply` substitutes the installed package root rather than copying a machine-specific path. +- **Equivalent project-root paths:** normalized with `path.resolve` before exact cwd comparison. Subdirectories remain intentionally invalid. +- **Runtime integration coverage:** added a focused registration and per-call tool-result augmentation test. + +The reviewer also confirmed the unfiltered test and typecheck commands passed. No unresolved review blocker remains. Activation and creation of live personal configuration remain separate user-authorized steps. diff --git a/extensions/native-hooks/docs/todos.md b/extensions/native-hooks/docs/todos.md new file mode 100644 index 0000000..b12523f --- /dev/null +++ b/extensions/native-hooks/docs/todos.md @@ -0,0 +1,4 @@ +# Todos + +- Validate the native TypeScript loader contract on Node 22.19 in CI (development host currently uses Node 26). +- Consider a user-configurable read-only custom-tool allowlist after MVP evidence is collected. diff --git a/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml b/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml new file mode 100644 index 0000000..c5e05b5 --- /dev/null +++ b/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml @@ -0,0 +1,6 @@ +id: typescript-no-dynamic-import-ts +language: TypeScript +severity: warning +message: Avoid dynamic imports unless runtime loading is intentional. +rule: + pattern: import($MODULE) diff --git a/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml b/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml new file mode 100644 index 0000000..71fd87b --- /dev/null +++ b/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml @@ -0,0 +1,6 @@ +id: typescript-no-dynamic-import-tsx +language: Tsx +severity: warning +message: Avoid dynamic imports unless runtime loading is intentional. +rule: + pattern: import($MODULE) diff --git a/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml b/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml new file mode 100644 index 0000000..13d92b4 --- /dev/null +++ b/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml @@ -0,0 +1,6 @@ +id: typescript-no-non-null-assertion-ts +language: TypeScript +severity: warning +message: Avoid TypeScript non-null assertions. +rule: + kind: non_null_expression diff --git a/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml b/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml new file mode 100644 index 0000000..a9326f8 --- /dev/null +++ b/extensions/native-hooks/examples/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml @@ -0,0 +1,6 @@ +id: typescript-no-non-null-assertion-tsx +language: Tsx +severity: warning +message: Avoid TypeScript non-null assertions. +rule: + kind: non_null_expression diff --git a/extensions/native-hooks/examples/personal-config/policies/harness-tools-verification.ts b/extensions/native-hooks/examples/personal-config/policies/harness-tools-verification.ts new file mode 100644 index 0000000..08d4e6c --- /dev/null +++ b/extensions/native-hooks/examples/personal-config/policies/harness-tools-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy"; +const manifests = new Set(["package.json", "bun.lock", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|scripts|services|skills|config)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "harness-tools-verification", project: { matches: (project) => project.packageName === "harness-tools" }, requirements: [ + { id: "tests", command: "bun run test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/extensions/native-hooks/examples/personal-config/policies/pi-native-hooks-verification.ts b/extensions/native-hooks/examples/personal-config/policies/pi-native-hooks-verification.ts new file mode 100644 index 0000000..5c72dab --- /dev/null +++ b/extensions/native-hooks/examples/personal-config/policies/pi-native-hooks-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy"; +const manifests = new Set(["package.json", "bun.lock", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|src|tests|examples|skills)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "pi-native-hooks-verification", project: { matches: (project) => project.packageName === "@_jb/pi-native-hooks" }, requirements: [ + { id: "tests", command: "bun run test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/extensions/native-hooks/examples/personal-config/policies/typescript-no-dynamic-import.ts b/extensions/native-hooks/examples/personal-config/policies/typescript-no-dynamic-import.ts new file mode 100644 index 0000000..7e4fbd6 --- /dev/null +++ b/extensions/native-hooks/examples/personal-config/policies/typescript-no-dynamic-import.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "@_jb/pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-dynamic-import-ts", "typescript-no-dynamic-import-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-dynamic-import", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-dynamic-import", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a static import unless lazy loading, code splitting, an optional dependency, or runtime module selection is intentional.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- for an intentional dynamic import.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/extensions/native-hooks/examples/personal-config/policies/typescript-no-non-null-assertion.ts b/extensions/native-hooks/examples/personal-config/policies/typescript-no-non-null-assertion.ts new file mode 100644 index 0000000..e108e41 --- /dev/null +++ b/extensions/native-hooks/examples/personal-config/policies/typescript-no-non-null-assertion.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "@_jb/pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-non-null-assertion-ts", "typescript-no-non-null-assertion-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-non-null-assertion", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-non-null-assertion", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a guard, refinement, assertion function, or corrected type.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- when the invariant is intentional.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/extensions/native-hooks/examples/personal-config/sgconfig.yml b/extensions/native-hooks/examples/personal-config/sgconfig.yml new file mode 100644 index 0000000..0adbca3 --- /dev/null +++ b/extensions/native-hooks/examples/personal-config/sgconfig.yml @@ -0,0 +1,2 @@ +ruleDirs: + - ast-grep/rules diff --git a/extensions/native-hooks/examples/personal-config/tsconfig.json b/extensions/native-hooks/examples/personal-config/tsconfig.json new file mode 100644 index 0000000..6670485 --- /dev/null +++ b/extensions/native-hooks/examples/personal-config/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "paths": { "@_jb/pi-native-hooks/policy": ["__PI_NATIVE_HOOKS_PACKAGE_ROOT__/src/policy.ts"] } + }, + "include": ["policies/*.ts"] +} diff --git a/extensions/native-hooks/examples/policies/harness-tools-verification.ts b/extensions/native-hooks/examples/policies/harness-tools-verification.ts new file mode 100644 index 0000000..08d4e6c --- /dev/null +++ b/extensions/native-hooks/examples/policies/harness-tools-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy"; +const manifests = new Set(["package.json", "bun.lock", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|scripts|services|skills|config)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "harness-tools-verification", project: { matches: (project) => project.packageName === "harness-tools" }, requirements: [ + { id: "tests", command: "bun run test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/extensions/native-hooks/examples/policies/pi-native-hooks-verification.ts b/extensions/native-hooks/examples/policies/pi-native-hooks-verification.ts new file mode 100644 index 0000000..5c72dab --- /dev/null +++ b/extensions/native-hooks/examples/policies/pi-native-hooks-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy"; +const manifests = new Set(["package.json", "bun.lock", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|src|tests|examples|skills)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "pi-native-hooks-verification", project: { matches: (project) => project.packageName === "@_jb/pi-native-hooks" }, requirements: [ + { id: "tests", command: "bun run test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/extensions/native-hooks/examples/policies/typescript-no-dynamic-import.ts b/extensions/native-hooks/examples/policies/typescript-no-dynamic-import.ts new file mode 100644 index 0000000..7e4fbd6 --- /dev/null +++ b/extensions/native-hooks/examples/policies/typescript-no-dynamic-import.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "@_jb/pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-dynamic-import-ts", "typescript-no-dynamic-import-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-dynamic-import", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-dynamic-import", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a static import unless lazy loading, code splitting, an optional dependency, or runtime module selection is intentional.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- for an intentional dynamic import.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/extensions/native-hooks/examples/policies/typescript-no-non-null-assertion.ts b/extensions/native-hooks/examples/policies/typescript-no-non-null-assertion.ts new file mode 100644 index 0000000..e108e41 --- /dev/null +++ b/extensions/native-hooks/examples/policies/typescript-no-non-null-assertion.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "@_jb/pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-non-null-assertion-ts", "typescript-no-non-null-assertion-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-non-null-assertion", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-non-null-assertion", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a guard, refinement, assertion function, or corrected type.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- when the invariant is intentional.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/extensions/native-hooks/extensions/pi-native-hooks/index.ts b/extensions/native-hooks/extensions/pi-native-hooks/index.ts new file mode 100644 index 0000000..130237f --- /dev/null +++ b/extensions/native-hooks/extensions/pi-native-hooks/index.ts @@ -0,0 +1,8 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { registerInitializationCommands } from "../../src/initializer.ts"; +import { NativeHooksRuntime } from "../../src/runtime.ts"; + +export default function piNativeHooks(pi: ExtensionAPI): void { + new NativeHooksRuntime(pi); + registerInitializationCommands(pi); +} diff --git a/extensions/native-hooks/package.json b/extensions/native-hooks/package.json new file mode 100644 index 0000000..f2a4243 --- /dev/null +++ b/extensions/native-hooks/package.json @@ -0,0 +1,69 @@ +{ + "name": "@_jb/pi-native-hooks", + "version": "0.1.0", + "description": "Native Pi hooks runtime for shared diagnostics and verification gates.", + "type": "module", + "license": "MIT", + "homepage": "https://git.jb.codes/jb/harness-tools/src/branch/main/extensions/native-hooks", + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/native-hooks" + }, + "bugs": { + "url": "https://git.jb.codes/jb/harness-tools/issues" + }, + "engines": { + "node": ">=22.19.0" + }, + "files": [ + "extensions/**/*", + "src/**/*", + "skills/**/*", + "examples/**/*", + "docs/**/*", + "README.md", + "LICENSE" + ], + "keywords": [ + "pi-package", + "pi-extension", + "hooks", + "diagnostics", + "verification" + ], + "exports": { + "./policy": { + "types": "./src/policy.ts", + "default": "./src/policy.ts" + } + }, + "pi": { + "extensions": [ + "./extensions/pi-native-hooks/index.ts" + ], + "skills": [ + "./skills/native-hook-authoring" + ] + }, + "scripts": { + "test": "node --import tsx --test --experimental-test-coverage tests/*.test.ts", + "typecheck": "tsc --noEmit", + "prepublishOnly": "bun run typecheck && bun run test" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "typebox": "catalog:pi-peer" + }, + "devDependencies": { + "@earendil-works/pi-coding-agent": "catalog:", + "@types/node": "catalog:", + "tsx": "catalog:", + "typebox": "catalog:", + "typescript": "catalog:" + }, + "publishConfig": { + "access": "public", + "registry": "https://git.jb.codes/api/packages/jb/npm/" + } +} diff --git a/extensions/native-hooks/skills/native-hook-authoring/SKILL.md b/extensions/native-hooks/skills/native-hook-authoring/SKILL.md new file mode 100644 index 0000000..047daf9 --- /dev/null +++ b/extensions/native-hooks/skills/native-hook-authoring/SKILL.md @@ -0,0 +1,16 @@ +--- +name: native-hook-authoring +description: Create, revise, validate, or troubleshoot trusted personal pi-native-hooks diagnostic and verification policies and ast-grep fixtures. Use when adding focused post-mutation policies without duplicating lifecycle, process execution, state, or delivery logic. +compatibility: pi-native-hooks on Node >=22.19 +--- + +# Native hook authoring + +1. Read [contracts](references/contracts.md). Import policy contracts with `import type` only. +2. Keep evaluators pure. Request analyzer keys and consume immutable snapshots; never spawn analyzers, linters, LSPs, or shell processes from a policy. +3. Add or revise a fixture under the package's `tests/fixtures/personal-config`, not the live personal root. +4. For syntax steering, create warning/info/hint ast-grep rules with language-specific IDs and a rationale-bearing `ast-grep-ignore` example. +5. Run the focused fixture, `npm run typecheck`, and `npm test` as described in [testing](references/testing.md). +6. Preview personal installation with `/hooks-init --dry-run`. Never write `~/.config/pi-native-hooks` without explicit interactive authorization. Existing files must not be overwritten. + +Personal modules are trusted arbitrary code with Pi's authority. Validation is not sandboxing. Use Node's erasable TypeScript subset only. diff --git a/extensions/native-hooks/skills/native-hook-authoring/references/contracts.md b/extensions/native-hooks/skills/native-hook-authoring/references/contracts.md new file mode 100644 index 0000000..0f6a814 --- /dev/null +++ b/extensions/native-hooks/skills/native-hook-authoring/references/contracts.md @@ -0,0 +1,16 @@ +# Policy contracts + +The authoritative declarations are exported by `@_jb/pi-native-hooks/policy` from `src/policy.ts`. Do not copy them into personal config. + +A diagnostic policy declares `kind`, a unique `id`, analyzer keys, `appliesTo`, and a pure `evaluate`. `evaluate` receives readonly normalized snapshots and a process-free policy context. Filter findings by exact rule ID and return bounded guidance plus the supported suppression form. + +A verification policy declares a project matcher and exact requirements. Each requirement owns an exact command, `cwd: "project-root"`, and a pure `affects(path)` predicate. Do not infer scripts or accept command variants. + +Use: + +```ts +import type { DiagnosticPolicy } from "@_jb/pi-native-hooks/policy"; +export default { /* ... */ } satisfies DiagnosticPolicy; +``` + +No runtime import is needed. Enums, namespaces, parameter properties, JSX, and tsconfig transforms are unsupported in personal modules. diff --git a/extensions/native-hooks/skills/native-hook-authoring/references/testing.md b/extensions/native-hooks/skills/native-hook-authoring/references/testing.md new file mode 100644 index 0000000..e632204 --- /dev/null +++ b/extensions/native-hooks/skills/native-hook-authoring/references/testing.md @@ -0,0 +1,16 @@ +# Policy testing + +Work only in repository fixtures until activation is explicitly approved. + +1. Put policy fixtures in `tests/fixtures/personal-config/policies/`. +2. Put ast-grep rules below `tests/fixtures/personal-config/ast-grep/rules/` and keep severity below `error`. +3. Cover TS and TSX positives, nearby negatives, the exact named suppression with a `-- rationale`, Unicode ranges, and malformed analyzer output where relevant. +4. Assert multiple policies sharing one analyzer key cause one adapter invocation. +5. Run: + +```bash +npm run typecheck +npm test +``` + +Use `/hooks-doctor` only after authorized initialization. Never make a policy evaluator invoke `ast-grep`, a linter, LSP, shell, or Pi API directly. diff --git a/extensions/native-hooks/src/analysis.ts b/extensions/native-hooks/src/analysis.ts new file mode 100644 index 0000000..d401930 --- /dev/null +++ b/extensions/native-hooks/src/analysis.ts @@ -0,0 +1,21 @@ +import type { AnalysisSnapshot, AnalyzerFailure } from "./policy.ts"; +export interface AnalysisRequest { readonly analyzerKey: string; readonly paths: readonly string[] } +export interface AnalyzerAdapter { analyze(paths: readonly string[], workspaceRevision: number, signal?: AbortSignal): Promise } +export class AnalysisCoordinator { + constructor(private readonly adapters: ReadonlyMap) {} + async run(requests: readonly AnalysisRequest[], revision: number, signal?: AbortSignal): Promise> { + const grouped = new Map>(); + for (const request of requests) { const paths = grouped.get(request.analyzerKey) ?? new Set(); for (const path of request.paths) paths.add(path); grouped.set(request.analyzerKey, paths); } + const entries = await Promise.all([...grouped].sort(([a], [b]) => a.localeCompare(b)).map(async ([key, paths]) => { + const adapter = this.adapters.get(key); + const sorted = [...paths].sort(); + if (!adapter) return [key, failed(key, revision, sorted, { kind: "missing", message: `No analyzer registered for ${key}` })] as const; + try { return [key, deepFreeze(await adapter.analyze(sorted, revision, signal))] as const; } + catch (error) { return [key, failed(key, revision, sorted, { kind: "internal", message: error instanceof Error ? error.message : String(error) })] as const; } + })); + return readonlyMap(entries); + } +} +function failed(key: string, workspaceRevision: number, paths: readonly string[], failure: AnalyzerFailure): AnalysisSnapshot { return Object.freeze({ analyzerKey: key, workspaceRevision, paths: Object.freeze([...paths]), findings: Object.freeze([]), durationMs: 0, failure: Object.freeze(failure) }); } +export function deepFreeze(value: T): T { if (value && typeof value === "object" && !Object.isFrozen(value)) { Object.freeze(value); for (const item of Object.values(value as Record)) deepFreeze(item); } return value; } +export function readonlyMap(entries: readonly (readonly [K, V])[]): ReadonlyMap { const map = new Map(entries); Object.freeze(map); return new Proxy(map, { get(target, prop) { if (prop === "set" || prop === "delete" || prop === "clear") return undefined; const value = Reflect.get(target, prop, target) as unknown; return typeof value === "function" ? (value as Function).bind(target) : value; } }); } diff --git a/extensions/native-hooks/src/analyzers/ast-grep.ts b/extensions/native-hooks/src/analyzers/ast-grep.ts new file mode 100644 index 0000000..acfa82a --- /dev/null +++ b/extensions/native-hooks/src/analyzers/ast-grep.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import { isAbsolute, relative, resolve } from "node:path"; +import type { AnalyzerAdapter } from "../analysis.ts"; +import type { AnalysisSnapshot, AnalyzerFailure, Finding, Severity } from "../policy.ts"; +import type { Exec } from "../project-context.ts"; + +const MAX_PATHS = 200; const MAX_FINDINGS = 200; const MAX_STDOUT = 2_000_000; +export class AstGrepAdapter implements AnalyzerAdapter { + private versionChecked?: Promise; + constructor(private readonly exec: Exec, private readonly root: string, private readonly configPath: string, private readonly executable = "ast-grep") {} + prepare(signal?: AbortSignal): Promise { return this.versionChecked ??= this.checkVersion(signal); } + async analyze(paths: readonly string[], workspaceRevision: number, signal?: AbortSignal): Promise { + const started = performance.now(); const bounded = paths.slice(0, MAX_PATHS); + const versionFailure = await this.prepare(signal); + if (versionFailure) return failure("ast-grep:personal", workspaceRevision, bounded, started, versionFailure); + const scanPaths = bounded.map((path) => isAbsolute(path) ? path : resolve(this.root, path)); + const args = ["scan", "--config", this.configPath, "--json=stream", "--color=never", "--max-results", String(MAX_FINDINGS), "--", ...scanPaths]; + const result = await this.exec(this.executable, args, { timeout: 20_000, ...(signal ? { signal } : {}) }); + if (result.killed) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "killed", message: "ast-grep was killed or timed out" }); + if (result.code !== 0) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "nonzero", message: `ast-grep exited ${result.code}: ${result.stderr.slice(0, 500)}` }); + if (Buffer.byteLength(result.stdout) > MAX_STDOUT) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "malformed", message: "ast-grep output exceeded 2MB" }); + try { + const findings = result.stdout.split("\n").filter(Boolean).slice(0, MAX_FINDINGS).map((line) => normalize(JSON.parse(line), this.root)); + return Object.freeze({ analyzerKey: "ast-grep:personal", workspaceRevision, paths: Object.freeze([...bounded]), findings: Object.freeze(findings), durationMs: Math.round(performance.now() - started) }); + } catch (error) { return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "malformed", message: error instanceof Error ? error.message : String(error) }); } + } + private async checkVersion(signal?: AbortSignal): Promise { + const result = await this.exec(this.executable, ["--version"], { timeout: 5000, ...(signal ? { signal } : {}) }); + if (result.code !== 0) return { kind: "missing", message: `ast-grep is unavailable: ${result.stderr || result.stdout}` }; + const match = result.stdout.match(/(\d+)\.(\d+)\.(\d+)/); if (!match || Number(match[1]) < 0 || Number(match[2]) < 45) return { kind: "incompatible", message: `ast-grep >=0.45 is required; got ${result.stdout.trim()}` }; + return undefined; + } +} +function normalize(value: unknown, root: string): Finding { + if (!value || typeof value !== "object") throw new Error("finding is not an object"); const row = value as Record; + const range = row.range as Record; const start = range?.start as Record; const end = range?.end as Record; + const text = typeof row.text === "string" ? row.text : ""; const rawPath = requiredString(row.file, "file"); const severity = row.severity; + if (!start || !end || !Number.isInteger(start.line) || !Number.isInteger(start.column) || !Number.isInteger(end.line) || !Number.isInteger(end.column)) throw new Error("finding has invalid range"); + if (severity !== "hint" && severity !== "info" && severity !== "warning") throw new Error(`unsupported steering severity ${String(severity)}`); + const path = isAbsolute(rawPath) ? relative(root, rawPath) : relative(root, resolve(root, rawPath)); + return Object.freeze({ ruleId: requiredString(row.ruleId, "ruleId"), severity: severity as Severity, message: requiredString(row.message, "message"), ...(typeof row.note === "string" ? { note: row.note } : {}), path, range: Object.freeze({ start: Object.freeze({ line: start.line as number, column: start.column as number }), end: Object.freeze({ line: end.line as number, column: end.column as number }) }), matchedTextHash: createHash("sha256").update(text).digest("hex") }); +} +function requiredString(value: unknown, name: string): string { if (typeof value !== "string" || !value) throw new Error(`finding has invalid ${name}`); return value; } +function failure(key: string, revision: number, paths: readonly string[], started: number, analyzerFailure: AnalyzerFailure): AnalysisSnapshot { return Object.freeze({ analyzerKey: key, workspaceRevision: revision, paths: Object.freeze([...paths]), findings: Object.freeze([]), durationMs: Math.round(performance.now() - started), failure: Object.freeze(analyzerFailure) }); } diff --git a/extensions/native-hooks/src/diagnostics.ts b/extensions/native-hooks/src/diagnostics.ts new file mode 100644 index 0000000..15e9e54 --- /dev/null +++ b/extensions/native-hooks/src/diagnostics.ts @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; +import type { Diagnostic } from "./policy.ts"; +export class DiagnosticDelivery { + private delivered = new Set(); + constructor(private readonly limit = 20) {} + select(diagnostics: readonly Diagnostic[]): readonly Diagnostic[] { + const current = new Set(diagnostics.map(fingerprint)); + const fresh = diagnostics.filter((item) => !this.delivered.has(fingerprint(item))).slice(0, this.limit); + this.delivered = current; + return fresh; + } + clear(): void { this.delivered.clear(); } +} +export function fingerprint(diagnostic: Diagnostic): string { return createHash("sha256").update([diagnostic.policyId, diagnostic.ruleId, diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.matchedTextHash].join("\0")).digest("hex"); } +export function renderDiagnostics(diagnostics: readonly Diagnostic[], total = diagnostics.length): string { + if (!diagnostics.length) return ""; + const lines = ["\n\n[pi-native-hooks diagnostics]"]; + for (const item of diagnostics) { lines.push(`- ${item.path}:${item.line + 1}:${item.column + 1} [${item.ruleId}] ${item.message}`); lines.push(` ${item.guidance}${item.exception ? ` Exception: ${item.exception}` : ""}`); } + if (total > diagnostics.length) lines.push(`- … ${total - diagnostics.length} additional finding(s) omitted`); + return lines.join("\n"); +} diff --git a/extensions/native-hooks/src/execution-wave.ts b/extensions/native-hooks/src/execution-wave.ts new file mode 100644 index 0000000..18a06c9 --- /dev/null +++ b/extensions/native-hooks/src/execution-wave.ts @@ -0,0 +1,30 @@ +export interface ObservedToolResult { readonly toolCallId: string; readonly toolName: string; readonly input: Record; readonly content: readonly unknown[]; readonly details: unknown; readonly isError: boolean; readonly usage?: unknown } +export interface WaveCall { readonly id: string; readonly toolName: string; readonly args: unknown; readonly sourceIndex: number; readonly potentiallyMutating: boolean; terminal: boolean; result?: ObservedToolResult } +export interface CompletedWave { readonly id: string; readonly turnIndex: number; readonly calls: readonly WaveCall[]; readonly carrierId?: string; readonly potentiallyMutating: boolean } +export type WavePatches = ReadonlyMap; +interface ActiveWave { id: string; turnIndex: number; calls: WaveCall[]; finalized: boolean; timer?: NodeJS.Timeout; promise: Promise; resolve(value: WavePatches): void } +const READ_ONLY = new Set(["read", "grep", "find", "ls", "pi_hooks_waive_verification"]); +export class ExecutionWaveCoordinator { + private wave?: ActiveWave; private serial = 0; private turnIndex = 0; private stopped = false; + constructor(private readonly finalize: (wave: CompletedWave) => Promise, private readonly onUncertain: (message: string) => void, private readonly timeoutMs = 30_000) {} + turnStarted(turnIndex: number): void { this.turnIndex = turnIndex; } + start(toolCallId: string, toolName: string, args: unknown): { waveId: string; isNew: boolean; potentiallyMutating: boolean } | undefined { + if (this.stopped) return undefined; const isNew = !this.wave || this.wave.calls.every((call) => call.terminal); if (isNew) this.wave = this.createWave(); + const wave = this.wave; + if (!wave) return undefined; + if (wave.calls.some((call) => call.id === toolCallId)) return { waveId: wave.id, isNew: false, potentiallyMutating: !READ_ONLY.has(toolName) }; + const potentiallyMutating = !READ_ONLY.has(toolName); + wave.calls.push({ id: toolCallId, toolName, args, sourceIndex: wave.calls.length, potentiallyMutating, terminal: false }); + return { waveId: wave.id, isNew, potentiallyMutating }; + } + async result(result: ObservedToolResult): Promise<{ readonly content?: readonly unknown[]; readonly details?: unknown; readonly isError?: boolean; readonly usage?: unknown } | undefined> { + const wave = this.wave; if (!wave || this.stopped) return undefined; const call = wave.calls.find((item) => item.id === result.toolCallId); if (!call) { this.onUncertain(`tool_result without start: ${result.toolCallId}`); return undefined; } + if (!call.result) call.result = result; call.terminal = true; this.maybeFinalize(wave); return (await wave.promise).get(result.toolCallId); + } + end(toolCallId: string): void { const wave = this.wave; if (!wave || this.stopped) return; const call = wave.calls.find((item) => item.id === toolCallId); if (!call) { this.onUncertain(`tool_execution_end without start: ${toolCallId}`); return; } if (!call.terminal) call.terminal = true; this.maybeFinalize(wave); } + turnEnded(): void { if (this.wave && !this.wave.finalized && this.wave.calls.some((call) => !call.terminal)) this.releaseUncertain(this.wave, "turn ended with unterminated tool calls"); } + shutdown(): void { this.stopped = true; if (this.wave && !this.wave.finalized) this.releaseUncertain(this.wave, "session shutdown"); } + private createWave(): ActiveWave { let resolve!: (value: WavePatches) => void; const promise = new Promise((done) => { resolve = done; }); const wave: ActiveWave = { id: `wave-${++this.serial}`, turnIndex: this.turnIndex, calls: [], finalized: false, promise, resolve }; wave.timer = setTimeout(() => this.releaseUncertain(wave, "execution-wave barrier timeout"), this.timeoutMs); wave.timer.unref?.(); return wave; } + private maybeFinalize(wave: ActiveWave): void { if (wave.finalized || wave.calls.length === 0 || wave.calls.some((call) => !call.terminal)) return; wave.finalized = true; if (wave.timer) clearTimeout(wave.timer); const mutatingResults = wave.calls.filter((call) => call.potentiallyMutating && call.result); const participating = wave.calls.filter((call) => call.result); const carrier = (mutatingResults.at(-1) ?? participating.at(-1))?.id; const completed = Object.freeze({ id: wave.id, turnIndex: wave.turnIndex, calls: Object.freeze([...wave.calls]), ...(carrier ? { carrierId: carrier } : {}), potentiallyMutating: wave.calls.some((call) => call.potentiallyMutating) }); this.finalize(completed).then(wave.resolve, (error) => { this.onUncertain(`wave finalization failed: ${error instanceof Error ? error.message : String(error)}`); wave.resolve(new Map()); }); } + private releaseUncertain(wave: ActiveWave, message: string): void { if (wave.finalized) return; wave.finalized = true; if (wave.timer) clearTimeout(wave.timer); this.onUncertain(message); wave.resolve(new Map()); } +} diff --git a/extensions/native-hooks/src/initializer.ts b/extensions/native-hooks/src/initializer.ts new file mode 100644 index 0000000..673a4c1 --- /dev/null +++ b/extensions/native-hooks/src/initializer.ts @@ -0,0 +1,21 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { chmod, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { userInfo } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadPolicies, personalConfigRoot } from "./policy-loader.ts"; +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const templateRoot = join(packageRoot, "examples", "personal-config"); +async function filesUnder(root: string, prefix = ""): Promise { const result: string[] = []; for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) { const path = join(prefix, entry.name); if (entry.isDirectory()) result.push(...await filesUnder(root, path)); else if (entry.isFile()) result.push(path); } return result.sort(); } +export function registerInitializationCommands(pi: ExtensionAPI): void { + pi.registerCommand("hooks-init", { description: "Preview or initialize trusted personal native-hook policies", handler: async (args, ctx) => { + const root = personalConfigRoot(); const files = await filesUnder(templateRoot); const plan = [`Personal config root: ${root}`, "Trusted executable files to create (existing files are preserved):", ...files.map((path) => `- ${join(root, path)}`)].join("\n"); + if (args.trim() === "--dry-run" || !args.trim()) { ctx.ui.notify(`${plan}\n\nRun /hooks-init --apply to request authorization.`, "info"); return; } + if (args.trim() !== "--apply") { ctx.ui.notify("Usage: /hooks-init --dry-run | --apply", "warning"); return; } + if (!ctx.hasUI || !await ctx.ui.confirm("Initialize pi-native-hooks?", `${plan}\n\nThese policies execute with Pi's authority. Continue?`)) { ctx.ui.notify("Initialization cancelled.", "info"); return; } + const created: string[] = []; + try { for (const path of files) { const target = join(root, path); try { await stat(target); continue; } catch {} await mkdir(dirname(target), { recursive: true, mode: 0o700 }); if (path === "tsconfig.json") { const template = await readFile(join(templateRoot, path), "utf8"); await writeFile(target, template.replaceAll("__PI_NATIVE_HOOKS_PACKAGE_ROOT__", packageRoot), { mode: 0o600 }); } else await copyFile(join(templateRoot, path), target); await chmod(target, 0o600); created.push(target); } ctx.ui.notify(`Created ${created.length} file(s); preserved ${files.length - created.length} existing file(s). Run /hooks-doctor then /reload.`, "info"); } + catch (error) { for (const path of created.reverse()) await rm(path, { force: true }).catch(() => {}); ctx.ui.notify(`Initialization failed and newly created files were rolled back: ${error instanceof Error ? error.message : String(error)}`, "error"); } + }}); + pi.registerCommand("hooks-doctor", { description: "Check native-hooks config trust and ast-grep availability", handler: async (_args, ctx) => { const registry = await loadPolicies(); const version = await pi.exec("ast-grep", ["--version"], { timeout: 5000 }); const lines = [registry.trustedCodeWarning, `Config: ${registry.root}`, `Policies: ${registry.policies.length}`, `Failures: ${registry.failures.length}`, ...registry.failures.map((failure) => `- ${failure.path}: ${failure.error}`), `ast-grep: ${version.code === 0 ? version.stdout.trim() : `unavailable (${version.stderr.trim()})`}`, `OS user: ${userInfo().username} uid ${userInfo().uid}`]; ctx.ui.notify(lines.join("\n"), registry.failures.length || version.code !== 0 ? "warning" : "info"); }}); +} diff --git a/extensions/native-hooks/src/policy-loader.ts b/extensions/native-hooks/src/policy-loader.ts new file mode 100644 index 0000000..6e7a900 --- /dev/null +++ b/extensions/native-hooks/src/policy-loader.ts @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile, lstat } from "node:fs/promises"; +import { userInfo } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { validatePolicy, type PersonalPolicy } from "./policy.ts"; + +export interface LoadedPolicy { readonly path: string; readonly digest: string; readonly policy: PersonalPolicy } +export interface PolicyLoadFailure { readonly path: string; readonly error: string } +export interface PolicyRegistry { readonly root: string; readonly policies: readonly LoadedPolicy[]; readonly failures: readonly PolicyLoadFailure[]; readonly digest: string; readonly analyzerConfigSafe: boolean; readonly trustedCodeWarning: string } +export interface LoaderIO { + lstat(path: string): Promise<{ uid: number; mode: number; isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }>; + readdir(path: string): Promise; + readFile(path: string): Promise; + importModule(url: string): Promise<{ default?: unknown }>; +} +const nativeIO: LoaderIO = { lstat, readdir: async (path) => readdir(path), readFile, importModule: (url) => import(url) }; +export const TRUST_WARNING = "Personal TypeScript policies are trusted executable code and run with Pi's filesystem, process, network, credential, and sandbox authority."; +export function personalConfigRoot(): string { return join(userInfo().homedir, ".config", "pi-native-hooks"); } + +async function safePath(io: LoaderIO, path: string, uid: number, kind: "file" | "directory"): Promise { + let stat; + try { stat = await io.lstat(path); } catch (error) { return error instanceof Error ? error.message : String(error); } + if (stat.isSymbolicLink()) return "symlinks are not allowed"; + if (stat.uid !== uid) return `must be owned by uid ${uid}`; + if ((stat.mode & 0o022) !== 0) return "must not be writable by group or others"; + if (kind === "file" ? !stat.isFile() : !stat.isDirectory()) return `must be a regular ${kind}`; + return undefined; +} +async function validateTree(io: LoaderIO, root: string, uid: number, failures: PolicyLoadFailure[]): Promise { + let safe = true; for (const name of await io.readdir(root)) { const path = join(root, name); let stat; try { stat = await io.lstat(path); } catch (error) { failures.push({ path, error: String(error) }); safe = false; continue; } + const kind = stat.isDirectory() ? "directory" : "file"; const error = await safePath(io, path, uid, kind); if (error) { failures.push({ path, error }); safe = false; continue; } if (kind === "directory" && !await validateTree(io, path, uid, failures)) safe = false; + } return safe; +} + +export async function loadPolicies(options: { root?: string; uid?: number; io?: LoaderIO } = {}): Promise { + const root = resolve(options.root ?? personalConfigRoot()); + const uid = options.uid ?? userInfo().uid; + const io = options.io ?? nativeIO; + const failures: PolicyLoadFailure[] = []; + const rootError = await safePath(io, root, uid, "directory"); + if (rootError) return Object.freeze({ root, policies: Object.freeze([]), failures: Object.freeze(rootError.includes("ENOENT") ? [] : [{ path: root, error: rootError }]), digest: createHash("sha256").update("empty").digest("hex"), analyzerConfigSafe: false, trustedCodeWarning: TRUST_WARNING }); + let analyzerConfigSafe = true; + const configPath = join(root, "sgconfig.yml"); const configError = await safePath(io, configPath, uid, "file"); + if (configError) { analyzerConfigSafe = false; failures.push({ path: configPath, error: configError }); } + for (const directory of [join(root, "ast-grep"), join(root, "ast-grep", "rules")]) { const error = await safePath(io, directory, uid, "directory"); if (error) { analyzerConfigSafe = false; failures.push({ path: directory, error }); } } + if (analyzerConfigSafe && !await validateTree(io, join(root, "ast-grep", "rules"), uid, failures)) analyzerConfigSafe = false; + const policiesDir = join(root, "policies"); + const dirError = await safePath(io, policiesDir, uid, "directory"); + if (dirError) return Object.freeze({ root, policies: Object.freeze([]), failures: Object.freeze([...failures, { path: policiesDir, error: dirError }]), digest: createHash("sha256").update("empty").digest("hex"), analyzerConfigSafe: false, trustedCodeWarning: TRUST_WARNING }); + const names = (await io.readdir(policiesDir)).filter((name) => name.endsWith(".ts") && !name.includes("/") && !name.includes("\\")).sort(); + const loaded: LoadedPolicy[] = []; + const ids = new Map(); + for (const name of names) { + const path = join(policiesDir, name); + const pathError = await safePath(io, path, uid, "file"); + if (pathError) { failures.push({ path, error: pathError }); continue; } + try { + const content = await io.readFile(path); + const digest = createHash("sha256").update(content).digest("hex"); + const module = await io.importModule(`${pathToFileURL(path).href}?native-hooks=${digest}`); + const result = validatePolicy(module.default); + if (!result.ok) { failures.push({ path, error: result.error }); continue; } + const duplicate = ids.get(result.policy.id); + if (duplicate) { failures.push({ path, error: `duplicate policy id ${result.policy.id}; first loaded from ${duplicate}` }); continue; } + ids.set(result.policy.id, path); + loaded.push(Object.freeze({ path, digest, policy: result.policy })); + } catch (error) { + failures.push({ path, error: `${error instanceof Error ? error.message : String(error)}. Policies must use Node's erasable TypeScript subset (no enums, namespaces, parameter properties, JSX, or tsconfig transforms).` }); + } + } + const digest = createHash("sha256").update(loaded.map((item) => `${item.policy.id}\0${item.digest}`).join("\0")).digest("hex"); + return Object.freeze({ root, policies: Object.freeze(loaded), failures: Object.freeze(failures), digest, analyzerConfigSafe, trustedCodeWarning: TRUST_WARNING }); +} diff --git a/extensions/native-hooks/src/policy.ts b/extensions/native-hooks/src/policy.ts new file mode 100644 index 0000000..176f5f2 --- /dev/null +++ b/extensions/native-hooks/src/policy.ts @@ -0,0 +1,44 @@ +export type Severity = "hint" | "info" | "warning"; + +export interface Finding { + readonly ruleId: string; + readonly severity: Severity; + readonly message: string; + readonly note?: string; + readonly path: string; + readonly range: Readonly<{ start: Readonly<{ line: number; column: number }>; end: Readonly<{ line: number; column: number }> }>; + readonly matchedTextHash: string; +} + +export interface AnalyzerFailure { readonly kind: "missing" | "incompatible" | "timeout" | "nonzero" | "malformed" | "killed" | "internal"; readonly message: string } +export interface AnalysisSnapshot { readonly analyzerKey: string; readonly workspaceRevision: number; readonly paths: readonly string[]; readonly findings: readonly Finding[]; readonly durationMs: number; readonly failure?: AnalyzerFailure } +export interface ProjectContext { readonly root: string; readonly packageName?: string; readonly identity: string; readonly git: boolean } +export interface PolicyContext { readonly project: ProjectContext; readonly changedPaths: readonly string[]; readonly workspaceRevision: number } +export interface Diagnostic { readonly policyId: string; readonly ruleId: string; readonly path: string; readonly line: number; readonly column: number; readonly message: string; readonly guidance: string; readonly exception?: string; readonly matchedTextHash: string } +export interface DiagnosticPolicy { readonly kind: "diagnostic"; readonly id: string; readonly analyzerKeys: readonly string[]; appliesTo(project: ProjectContext, paths: readonly string[]): boolean; evaluate(analysis: ReadonlyMap, context: PolicyContext): readonly Diagnostic[] } +export interface ProjectMatcher { matches(project: ProjectContext): boolean } +export interface VerificationRequirement { readonly id: string; readonly command: string; readonly cwd: "project-root"; affects(path: string): boolean } +export interface VerificationPolicy { readonly kind: "verification"; readonly id: string; readonly project: ProjectMatcher; readonly requirements: readonly VerificationRequirement[] } +export type PersonalPolicy = DiagnosticPolicy | VerificationPolicy; + +const isFn = (value: unknown): value is (...args: never[]) => unknown => typeof value === "function"; +export function validatePolicy(value: unknown): { ok: true; policy: PersonalPolicy } | { ok: false; error: string } { + if (!value || typeof value !== "object") return { ok: false, error: "default export must be an object" }; + const candidate = value as Record; + if (typeof candidate.id !== "string" || !candidate.id.trim()) return { ok: false, error: "policy id must be non-empty" }; + if (candidate.kind === "diagnostic") { + if (!Array.isArray(candidate.analyzerKeys) || candidate.analyzerKeys.some((key) => typeof key !== "string" || !key)) return { ok: false, error: "diagnostic analyzerKeys must be non-empty strings" }; + if (!isFn(candidate.appliesTo) || !isFn(candidate.evaluate)) return { ok: false, error: "diagnostic policy requires appliesTo and evaluate functions" }; + return { ok: true, policy: value as DiagnosticPolicy }; + } + if (candidate.kind === "verification") { + const project = candidate.project as Record | undefined; + if (!project || !isFn(project.matches) || !Array.isArray(candidate.requirements)) return { ok: false, error: "verification policy requires project.matches and requirements" }; + for (const requirement of candidate.requirements as unknown[]) { + const req = requirement as Record | undefined; + if (!req || typeof req.id !== "string" || !req.id || typeof req.command !== "string" || !req.command.trim() || req.cwd !== "project-root" || !isFn(req.affects)) return { ok: false, error: "invalid verification requirement" }; + } + return { ok: true, policy: value as VerificationPolicy }; + } + return { ok: false, error: "policy kind must be diagnostic or verification" }; +} diff --git a/extensions/native-hooks/src/project-context.ts b/extensions/native-hooks/src/project-context.ts new file mode 100644 index 0000000..7f0bd28 --- /dev/null +++ b/extensions/native-hooks/src/project-context.ts @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; +import { readFile, realpath } from "node:fs/promises"; +import type { ProjectContext } from "./policy.ts"; + +export interface ExecResult { stdout: string; stderr: string; code: number | null; killed?: boolean } +export type Exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => Promise; + +export async function discoverProject(cwd: string, exec: Exec, signal?: AbortSignal): Promise { + const top = await exec("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { timeout: 5000, ...(signal ? { signal } : {}) }); + const git = top.code === 0; + const root = await realpath(git ? top.stdout.trim() : cwd).catch(() => cwd); + let packageName: string | undefined; + try { const parsed = JSON.parse(await readFile(`${root}/package.json`, "utf8")) as { name?: unknown }; if (typeof parsed.name === "string") packageName = parsed.name; } catch {} + let origin = "NO_HEAD"; + if (git) { + const first = await exec("git", ["-C", root, "rev-list", "--max-parents=0", "HEAD"], { timeout: 5000, ...(signal ? { signal } : {}) }); + if (first.code === 0 && first.stdout.trim()) origin = first.stdout.trim().split("\n").sort()[0] ?? "NO_HEAD"; + } + const identity = createHash("sha256").update(`${root}\0${origin}\0${packageName ?? ""}`).digest("hex"); + return Object.freeze({ root, ...(packageName ? { packageName } : {}), identity, git }); +} diff --git a/extensions/native-hooks/src/runtime.ts b/extensions/native-hooks/src/runtime.ts new file mode 100644 index 0000000..d090bab --- /dev/null +++ b/extensions/native-hooks/src/runtime.ts @@ -0,0 +1,103 @@ +import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { AnalysisCoordinator } from "./analysis.ts"; +import { AstGrepAdapter } from "./analyzers/ast-grep.ts"; +import { DiagnosticDelivery, renderDiagnostics } from "./diagnostics.ts"; +import { ExecutionWaveCoordinator, type CompletedWave, type ObservedToolResult, type WavePatches } from "./execution-wave.ts"; +import { loadPolicies, type PolicyRegistry } from "./policy-loader.ts"; +import type { Diagnostic, DiagnosticPolicy, ProjectContext, VerificationPolicy } from "./policy.ts"; +import { discoverProject } from "./project-context.ts"; +import { latestState, STATE_TYPE, type PersistedStateV1 } from "./state.ts"; +import { formatGate, VerificationTracker, type CheckAttempt } from "./verification.ts"; +import { explicitMutationPaths, WorkspaceInspector, type WorkspaceSnapshot } from "./workspace.ts"; + +const MESSAGE_TYPE = "pi-native-hooks-verification"; +export class NativeHooksRuntime { + private registry?: PolicyRegistry; private project?: ProjectContext; private tracker?: VerificationTracker; private workspace?: WorkspaceSnapshot; + private revision = 0; private diagnostics = new DiagnosticDelivery(); private lastDiagnosticCount = 0; private lastAnalyzer = "never"; private settlementRunning = false; private analysis?: AnalysisCoordinator; private astGrep?: AstGrepAdapter; + private readonly baselines = new Map(); private readonly attempts = new Map(); + private readonly inspector: WorkspaceInspector; private readonly waves: ExecutionWaveCoordinator; + constructor(private readonly pi: ExtensionAPI) { + const exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => this.pi.exec(command, args, options); + this.inspector = new WorkspaceInspector(exec); + this.waves = new ExecutionWaveCoordinator((wave) => this.finalizeWave(wave), (message) => { this.tracker?.markUnknown(message); this.baselines.clear(); this.attempts.clear(); this.notify(message, "error"); }); + this.register(); + } + private register(): void { + this.pi.on("session_start", async (_event, ctx) => this.initialize(ctx)); + this.pi.on("session_tree", async (_event, ctx) => this.restoreBranch(ctx)); + this.pi.on("turn_start", (event) => this.waves.turnStarted(event.turnIndex)); + this.pi.on("tool_execution_start", async (event, ctx) => { const started = this.waves.start(event.toolCallId, event.toolName, event.args); if (started?.potentiallyMutating && !this.baselines.has(started.waveId)) this.baselines.set(started.waveId, await this.inspector.capture(ctx.cwd, (path) => this.pathRelevant(path), ctx.signal)); }); + this.pi.on("tool_call", (event, ctx) => { if (event.toolName === "bash" && this.tracker && typeof event.input.command === "string") this.attempts.set(event.toolCallId, this.tracker.beginCheck(event.toolCallId, event.input.command, ctx.cwd)); }); + this.pi.on("tool_result", async (event) => this.onToolResult(event)); + this.pi.on("tool_execution_end", (event) => this.waves.end(event.toolCallId)); + this.pi.on("turn_end", () => this.waves.turnEnded()); + this.pi.on("agent_settled", async (_event, ctx) => this.onSettled(ctx)); + this.pi.on("context", (event) => { const custom = event.messages.map((message, index) => ({ message, index })).filter(({ message }) => message.role === "custom" && message.customType === MESSAGE_TYPE); const keep = this.tracker?.outstanding().length ? custom.at(-1)?.index : undefined; return { messages: event.messages.filter((message, index) => message.role !== "custom" || message.customType !== MESSAGE_TYPE || index === keep) }; }); + this.pi.on("session_shutdown", (_event, ctx) => { this.waves.shutdown(); ctx.ui.setStatus("pi-native-hooks", undefined); }); + const thisRuntime = this; + this.pi.registerTool({ name: "pi_hooks_waive_verification", label: "Waive verification", description: "Waive all currently outstanding pi-native-hooks checks for their current revisions. A non-empty reason is required and remains auditable.", promptSnippet: "Waive pending native-hook verification with an explicit reason", parameters: Type.Object({ reason: Type.String({ minLength: 1 }) }), async execute(toolCallId, params) { return thisRuntime.waive(toolCallId, params.reason); } }); + this.pi.registerCommand("hooks-status", { description: "Show pi-native-hooks policy, analyzer, and verification status", handler: async (_args, ctx) => ctx.ui.notify(this.statusText(), "info") }); + } + private async initialize(ctx: ExtensionContext): Promise { + const exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => this.pi.exec(command, args, options); + this.registry = await loadPolicies(); this.project = await discoverProject(ctx.cwd, exec, ctx.signal); this.workspace = await this.inspector.capture(ctx.cwd, (path) => this.pathRelevant(path), ctx.signal); + this.astGrep = new AstGrepAdapter(exec, this.project.root, `${this.registry.root}/sgconfig.yml`); this.analysis = new AnalysisCoordinator(this.registry.analyzerConfigSafe ? new Map([["ast-grep:personal", this.astGrep]]) : new Map()); + if (this.registry.analyzerConfigSafe && this.diagnosticPolicies().length) { const failure = await this.astGrep.prepare(ctx.signal); if (failure) this.lastAnalyzer = `ast-grep:personal degraded (${failure.message})`; } + this.tracker = new VerificationTracker(this.project); this.tracker.configure(this.verificationPolicies()); await this.restoreBranch(ctx); this.updateStatus(ctx); + if (!this.registry.policies.length && !this.registry.failures.length) this.notify("No personal policies found. Run /hooks-init --dry-run for initialization guidance.", "info", ctx); + for (const failure of this.registry.failures) this.notify(`Policy load failed: ${failure.path}: ${failure.error}`, "error", ctx); + } + private async restoreBranch(ctx: ExtensionContext): Promise { + if (!this.registry || !this.project || !this.tracker || !this.workspace) return; const restored = latestState(ctx.sessionManager.getBranch()); + if (restored.malformed) this.notify(`Ignored ${restored.malformed} malformed native-hooks state entr${restored.malformed === 1 ? "y" : "ies"}.`, "warning", ctx); + const state = restored.state; if (!state) return; + if (state.projectIdentity !== this.project.identity) { this.tracker.restore(state.requirements.map((record) => ({ ...record, status: record.status === "inactive" ? "inactive" : "unknown" })), state.workspaceRevision); return; } + this.tracker.restore(state.requirements, state.workspaceRevision); this.revision = Math.max(this.revision, state.workspaceRevision); + if (state.policyDigest !== this.registry.digest) this.tracker.markUnknown("Policy registry changed since evidence was recorded"); + if (state.workspaceFingerprint !== this.workspace.fingerprint && this.tracker.records().some((record) => record.status !== "inactive")) this.tracker.markUnknown("Workspace changed outside the observed session"); + } + private async onToolResult(event: ToolResultEvent) { + const observed: ObservedToolResult = { toolCallId: event.toolCallId, toolName: event.toolName, input: event.input, content: event.content, details: event.details, isError: event.isError, ...(event.usage ? { usage: event.usage } : {}) }; + const patch = await this.waves.result(observed); if (!patch) return; return patch as { content?: typeof event.content; details?: unknown; isError?: boolean; usage?: typeof event.usage }; + } + private async finalizeWave(wave: CompletedWave): Promise { + if (!this.project || !this.workspace || !this.tracker || !this.registry) return new Map(); const baseline = this.baselines.get(wave.id); this.baselines.delete(wave.id); let changedPaths: readonly string[] = []; let changed = false; let provable = true; + if (wave.potentiallyMutating) { + const after = await this.inspector.capture(this.project.root, (path) => this.pathRelevant(path)); + if (baseline) { const diff = this.inspector.diff(baseline, after); changedPaths = Object.freeze([...new Set([...diff.changedPaths, ...await this.inspector.headChangedPaths(baseline, after)])].sort()); changed = diff.changed; provable = diff.provable && (baseline.head === after.head || changedPaths.length > 0); } + else { changed = true; provable = false; } + const explicit = Object.freeze(wave.calls.flatMap((call) => call.result && !call.result.isError ? explicitMutationPaths(call.toolName, call.result.input) : [])); + if (!provable && explicit.length) { changed = true; changedPaths = explicit; } + else if (changed && !changedPaths.length) changedPaths = explicit; + if (changed) { this.revision++; this.tracker.observeMutation(changedPaths, this.revision, provable || changedPaths.length > 0); } + this.workspace = after; + } + const mutatingCount = wave.calls.filter((call) => call.potentiallyMutating).length; + for (const call of wave.calls) if (call.toolName === "bash" && call.result) { const attempts = this.attempts.get(call.id) ?? []; this.attempts.delete(call.id); const finalCommand = typeof call.result.input.command === "string" ? call.result.input.command : ""; const finalized = attempts.filter((attempt) => attempt.command === finalCommand); this.tracker.finishCheck(finalized, !call.result.isError, mutatingCount === 1 && !changed); } + let diagnostics: readonly Diagnostic[] = []; + if (changedPaths.length) diagnostics = await this.evaluateDiagnostics(changedPaths); + const fresh = this.diagnostics.select(diagnostics); this.lastDiagnosticCount = diagnostics.length; + this.persist(changed ? "workspace-revision" : "verification-evidence"); + if (!fresh.length || !wave.carrierId) return new Map(); const carrier = wave.calls.find((call) => call.id === wave.carrierId)?.result; if (!carrier) return new Map(); + const block = renderDiagnostics(fresh, diagnostics.length); const content = [...carrier.content, { type: "text", text: block }]; return new Map([[wave.carrierId, { content }]]); + } + private async evaluateDiagnostics(paths: readonly string[]): Promise { + if (!this.project || !this.registry || !this.analysis) return []; const policies = this.diagnosticPolicies().filter((policy) => { try { return policy.appliesTo(this.project!, paths); } catch (error) { this.notify(`Policy ${policy.id} appliesTo failed: ${String(error)}`, "error"); return false; } }); + const requests = policies.flatMap((policy) => policy.analyzerKeys.map((analyzerKey) => ({ analyzerKey, paths }))); + const snapshots = await this.analysis.run(requests, this.revision); this.lastAnalyzer = [...snapshots.values()].map((snapshot) => snapshot.failure ? `${snapshot.analyzerKey}: degraded (${snapshot.failure.message})` : `${snapshot.analyzerKey}: ${snapshot.durationMs}ms`).join(", ") || "not applicable"; + const diagnostics: Diagnostic[] = []; + for (const snapshot of snapshots.values()) if (snapshot.failure) diagnostics.push({ policyId: "pi-native-hooks-analyzer", ruleId: snapshot.analyzerKey, path: paths[0] ?? ".", line: 0, column: 0, message: `Analyzer degraded: ${snapshot.failure.message}`, guidance: "Diagnostics could not establish a clean scan; inspect /hooks-status and /hooks-doctor.", matchedTextHash: snapshot.failure.message }); + for (const policy of policies) { try { diagnostics.push(...policy.evaluate(snapshots, Object.freeze({ project: this.project, changedPaths: Object.freeze([...paths]), workspaceRevision: this.revision }))); } catch (error) { this.notify(`Policy ${policy.id} evaluation failed: ${String(error)}`, "error"); } } + return Object.freeze(diagnostics); + } + private async onSettled(ctx: ExtensionContext): Promise { if (this.settlementRunning || !this.tracker || !this.project || !this.workspace) return; this.settlementRunning = true; try { const live = await this.inspector.capture(this.project.root, (path) => this.pathRelevant(path)); if (live.fingerprint !== this.workspace.fingerprint) { const before = this.workspace; const diff = this.inspector.diff(before, live); const changedPaths = Object.freeze([...new Set([...diff.changedPaths, ...await this.inspector.headChangedPaths(before, live)])].sort()); this.revision++; this.tracker.observeMutation(changedPaths, this.revision, diff.provable && changedPaths.length > 0); this.workspace = live; this.persist("settlement-refresh"); } const outstanding = this.tracker.outstanding(); this.updateStatus(ctx); if (outstanding.length) this.pi.sendMessage({ customType: MESSAGE_TYPE, content: formatGate(outstanding), display: true, details: { revisions: outstanding.map((record) => [record.key, record.requiredRevision]) } }, { deliverAs: "followUp", triggerTurn: true }); } finally { this.settlementRunning = false; } } + private waive(toolCallId: string, reason: string) { if (!this.tracker) throw new Error("pi-native-hooks has not initialized"); const waived = this.tracker.waive(reason, toolCallId); this.persist("waiver"); return { content: [{ type: "text" as const, text: `Verification skipped (not passed) for: ${waived.map((record) => record.key).join(", ")}. Reason: ${reason.trim()}` }], details: { waived: waived.map((record) => ({ key: record.key, revision: record.requiredRevision })), reason: reason.trim() } }; } + private persist(reason: string): void { if (!this.registry || !this.project || !this.workspace || !this.tracker) return; const state: PersistedStateV1 = { version: 1, policyDigest: this.registry.digest, projectIdentity: this.project.identity, workspaceRevision: this.revision, workspaceFingerprint: this.workspace.fingerprint, requirements: this.tracker.records(), reason, timestamp: new Date().toISOString() }; this.pi.appendEntry(STATE_TYPE, state); } + private pathRelevant(path: string): boolean { for (const policy of this.verificationPolicies()) for (const requirement of policy.requirements) { try { if (requirement.affects(path)) return true; } catch { return true; } } return /\.(?:ts|tsx)$/.test(path); } + private diagnosticPolicies(): DiagnosticPolicy[] { return this.registry?.policies.map((item) => item.policy).filter((policy): policy is DiagnosticPolicy => policy.kind === "diagnostic") ?? []; } + private verificationPolicies(): VerificationPolicy[] { return this.registry?.policies.map((item) => item.policy).filter((policy): policy is VerificationPolicy => policy.kind === "verification") ?? []; } + private statusText(): string { const records = this.tracker?.records() ?? []; return [`Config: ${this.registry?.root ?? "not initialized"}`, this.registry?.trustedCodeWarning ?? "", `Policies: ${this.registry?.policies.map((item) => `${item.policy.id} ${item.digest.slice(0, 12)} (${item.path})`).join(", ") || "none"}`, `Load failures: ${this.registry?.failures.map((failure) => `${failure.path}: ${failure.error}`).join("; ") || "none"}`, `Project: ${this.project?.identity ?? "unknown"} (${this.project?.root ?? "unknown"})`, `Workspace revision: ${this.revision}`, `Analyzer: ${this.lastAnalyzer}`, `Diagnostics: ${this.lastDiagnosticCount}`, "Verification:", ...records.map((record) => `- ${record.key}: ${record.status} r${record.requiredRevision ?? "-"}${record.waiver ? ` waived: ${record.waiver.reason}` : ""}${record.lastFailure ? ` (${record.lastFailure})` : ""}`)].filter(Boolean).join("\n"); } + private updateStatus(ctx: ExtensionContext): void { const outstanding = this.tracker?.outstanding().length ?? 0; ctx.ui.setStatus("pi-native-hooks", `hooks ${this.lastDiagnosticCount}d ${outstanding ? `${outstanding} pending` : "verified"}`); } + private notify(message: string, level: "info" | "warning" | "error", ctx?: ExtensionContext): void { if (ctx) ctx.ui.notify(message, level); else console.error(`[pi-native-hooks] ${message}`); } +} diff --git a/extensions/native-hooks/src/state.ts b/extensions/native-hooks/src/state.ts new file mode 100644 index 0000000..5a45e1a --- /dev/null +++ b/extensions/native-hooks/src/state.ts @@ -0,0 +1,14 @@ +import type { RequirementRecord } from "./verification.ts"; +export const STATE_TYPE = "pi-native-hooks-state-v1"; +export interface PersistedStateV1 { readonly version: 1; readonly policyDigest: string; readonly projectIdentity: string; readonly workspaceRevision: number; readonly workspaceFingerprint: string; readonly requirements: readonly RequirementRecord[]; readonly reason: string; readonly timestamp: string } +const statuses = new Set(["inactive", "not-run", "passed", "failed", "stale", "waived", "unknown"]); +export function isPersistedState(value: unknown): value is PersistedStateV1 { + if (!value || typeof value !== "object") return false; const state = value as Record; + if (state.version !== 1 || typeof state.policyDigest !== "string" || typeof state.projectIdentity !== "string" || !Number.isInteger(state.workspaceRevision) || typeof state.workspaceFingerprint !== "string" || !Array.isArray(state.requirements) || typeof state.reason !== "string" || typeof state.timestamp !== "string") return false; + return state.requirements.every((value) => { if (!value || typeof value !== "object") return false; const record = value as Record; return typeof record.key === "string" && typeof record.policyId === "string" && typeof record.requirementId === "string" && typeof record.command === "string" && statuses.has(record.status as string) && (record.requiredRevision === undefined || Number.isInteger(record.requiredRevision)); }); +} +export function latestState(entries: readonly unknown[]): { state?: PersistedStateV1; malformed: number } { + let state: PersistedStateV1 | undefined; let malformed = 0; + for (const entry of entries) { const row = entry as { type?: unknown; customType?: unknown; data?: unknown }; if (row?.type !== "custom" || row.customType !== STATE_TYPE) continue; if (isPersistedState(row.data)) state = row.data; else malformed++; } + return { ...(state ? { state } : {}), malformed }; +} diff --git a/extensions/native-hooks/src/verification.ts b/extensions/native-hooks/src/verification.ts new file mode 100644 index 0000000..9beb038 --- /dev/null +++ b/extensions/native-hooks/src/verification.ts @@ -0,0 +1,67 @@ +import { resolve } from "node:path"; +import type { ProjectContext, VerificationPolicy, VerificationRequirement } from "./policy.ts"; + +export type RequirementStatus = "inactive" | "not-run" | "passed" | "failed" | "stale" | "waived" | "unknown"; +export interface Evidence { readonly revision: number; readonly timestamp: string; readonly toolCallId: string; readonly projectIdentity: string } +export interface Waiver { readonly revision: number; readonly reason: string; readonly timestamp: string; readonly toolCallId: string; readonly projectIdentity: string } +export interface RequirementRecord { readonly key: string; readonly policyId: string; readonly requirementId: string; readonly command: string; readonly status: RequirementStatus; readonly requiredRevision?: number | undefined; readonly evidence?: Evidence | undefined; readonly waiver?: Waiver | undefined; readonly lastFailure?: string | undefined } +interface ActiveRequirement { policyId: string; requirement: VerificationRequirement; record: RequirementRecord } +export interface CheckAttempt { readonly key: string; readonly command: string; readonly startRevision: number; readonly toolCallId: string; readonly exact: boolean } + +export class VerificationTracker { + private readonly active = new Map(); + private orphaned: RequirementRecord[] = []; + constructor(readonly project: ProjectContext, private workspaceRevision = 0) {} + configure(policies: readonly VerificationPolicy[]): void { + const previous = new Map(this.active); + this.active.clear(); + for (const policy of policies) { + let matches = false; try { matches = policy.project.matches(this.project); } catch { continue; } if (!matches) continue; + for (const requirement of policy.requirements) { + const key = `${policy.id}:${requirement.id}`; const old = previous.get(key)?.record; previous.delete(key); + this.active.set(key, { policyId: policy.id, requirement, record: old && old.command === requirement.command.trim() ? old : { key, policyId: policy.id, requirementId: requirement.id, command: requirement.command.trim(), status: "inactive" } }); + } + } + this.orphaned = [...previous.values()].map((item) => ({ ...item.record, status: item.record.status === "inactive" ? "inactive" : "unknown", lastFailure: "Previously armed policy is missing or no longer matches" })); + } + restore(records: readonly RequirementRecord[], restoredRevision: number): void { + this.workspaceRevision = Math.max(this.workspaceRevision, restoredRevision); + const byKey = new Map(records.map((record) => [record.key, record])); + for (const active of this.active.values()) { const restored = byKey.get(active.record.key); if (restored && restored.command === active.record.command) active.record = restored; byKey.delete(active.record.key); } + this.orphaned = [...byKey.values()].filter((record) => record.status !== "inactive").map((record) => ({ ...record, status: "unknown", lastFailure: "Policy unavailable after restore" })); + } + observeMutation(paths: readonly string[], revision: number, provable = true): void { + this.workspaceRevision = Math.max(this.workspaceRevision, revision); + this.orphaned = this.orphaned.map((record) => record.status === "inactive" ? record : { ...record, status: "unknown", evidence: undefined, waiver: undefined, requiredRevision: revision, lastFailure: "Mutation observed while the original policy was unavailable" }); + for (const active of this.active.values()) { + let affected = false; try { affected = paths.some((path) => active.requirement.affects(path)); } catch { affected = true; } + if (!provable && active.record.status !== "inactive") { active.record = { ...active.record, status: "unknown", requiredRevision: revision, evidence: undefined, waiver: undefined, lastFailure: "Workspace mutation could not be proven" }; continue; } + if (!affected) continue; + const hadEvidence = active.record.evidence !== undefined; + active.record = { ...active.record, status: hadEvidence ? "stale" : "not-run", requiredRevision: revision, evidence: undefined, waiver: undefined, lastFailure: undefined }; + } + } + markUnknown(message: string): void { for (const active of this.active.values()) if (active.record.status !== "inactive") active.record = { ...active.record, status: "unknown", evidence: undefined, waiver: undefined, lastFailure: message }; } + beginCheck(toolCallId: string, command: string, cwd: string): readonly CheckAttempt[] { + return [...this.active.values()].filter((active) => active.record.status !== "inactive" && active.requirement.command.trim() === command.trim()).map((active) => ({ key: active.record.key, command, startRevision: this.workspaceRevision, toolCallId, exact: command === active.requirement.command.trim() && samePath(cwd, this.project.root) })); + } + finishCheck(attempts: readonly CheckAttempt[], success: boolean, stableWave: boolean): void { + for (const attempt of attempts) { const active = this.active.get(attempt.key); if (!active || active.record.requiredRevision === undefined) continue; + if (!attempt.exact || !stableWave || attempt.startRevision < active.record.requiredRevision || this.workspaceRevision !== attempt.startRevision) continue; + if (success) active.record = { ...active.record, status: "passed", evidence: { revision: active.record.requiredRevision, timestamp: new Date().toISOString(), toolCallId: attempt.toolCallId, projectIdentity: this.project.identity }, waiver: undefined, lastFailure: undefined }; + else active.record = { ...active.record, status: "failed", evidence: undefined, waiver: undefined, lastFailure: `Exact command failed: ${attempt.command}` }; + } + } + waive(reason: string, toolCallId: string): readonly RequirementRecord[] { + const trimmed = reason.trim(); if (!trimmed) throw new Error("Waiver reason must be non-empty"); const outstanding = this.outstanding(); if (!outstanding.length) throw new Error("No armed verification requirement is outstanding"); + const keys = new Set(outstanding.map((record) => record.key)); + for (const [key, active] of this.active) if (keys.has(key) && active.record.requiredRevision !== undefined) active.record = { ...active.record, status: "waived", waiver: { revision: active.record.requiredRevision, reason: trimmed, timestamp: new Date().toISOString(), toolCallId, projectIdentity: this.project.identity }, evidence: undefined }; + for (const record of this.orphaned) if (keys.has(record.key) && record.requiredRevision !== undefined) Object.assign(record, { status: "waived", waiver: { revision: record.requiredRevision, reason: trimmed, timestamp: new Date().toISOString(), toolCallId, projectIdentity: this.project.identity } }); + return outstanding; + } + outstanding(): RequirementRecord[] { return this.records().filter((record) => record.status !== "inactive" && record.status !== "passed" && record.status !== "waived"); } + records(): RequirementRecord[] { return [...this.active.values()].map((item) => ({ ...item.record } as RequirementRecord)).concat(this.orphaned.map((record) => ({ ...record }))); } + revision(): number { return this.workspaceRevision; } +} +function samePath(left: string, right: string): boolean { return resolve(left) === resolve(right); } +export function formatGate(records: readonly RequirementRecord[]): string { const commands = [...new Set(records.map((record) => record.command))]; return ["Required verification is pending or stale. Run each exact standalone command from the project root, or call pi_hooks_waive_verification with a non-empty reason:", ...commands.map((command) => `- ${command}`), "Outstanding: " + records.map((record) => `${record.key} (${record.status})`).join(", ")].join("\n"); } diff --git a/extensions/native-hooks/src/workspace.ts b/extensions/native-hooks/src/workspace.ts new file mode 100644 index 0000000..d982ca4 --- /dev/null +++ b/extensions/native-hooks/src/workspace.ts @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile, readlink, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import type { Exec } from "./project-context.ts"; + +export interface WorkspaceSnapshot { readonly kind: "git" | "fallback" | "unknown"; readonly root: string; readonly fingerprint: string; readonly entries: ReadonlyMap; readonly head?: string } +export interface WorkspaceDiff { readonly changedPaths: readonly string[]; readonly changed: boolean; readonly provable: boolean } +export class WorkspaceInspector { + constructor(private readonly exec: Exec) {} + async capture(cwd: string, relevant: (path: string) => boolean = () => true, signal?: AbortSignal): Promise { + const top = await this.exec("git", ["-C", cwd, "rev-parse", "--show-toplevel"], opts(signal)); + if (top.code !== 0) return Object.freeze({ kind: "unknown", root: cwd, fingerprint: "unknown", entries: new Map() }); + const root = await realpath(top.stdout.trim()).catch(() => top.stdout.trim()); + const [head, status] = await Promise.all([ + this.exec("git", ["-C", root, "rev-parse", "HEAD"], opts(signal)), + this.exec("git", ["-C", root, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"], opts(signal)), + ]); + if (status.code !== 0 || Buffer.byteLength(status.stdout) > 2_000_000) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() }); + const paths = parseStatus(status.stdout); if (paths.size > 2000) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() }); + const dirtyPaths = [...paths.keys()]; const index = dirtyPaths.length ? await this.exec("git", ["-C", root, "ls-files", "--stage", "-z", "--", ...dirtyPaths], opts(signal)) : { stdout: "", stderr: "", code: 0 }; + if (index.code !== 0) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() }); + const indexEntries = parseIndex(index.stdout); + const entries = new Map(); + for (const [path, state] of paths) { + let content = "irrelevant"; + if (relevant(path)) content = await hashPath(join(root, path)); + entries.set(path, `${state}\0${indexEntries.get(path) ?? "untracked"}\0${content}`); + } + const headValue = head.code === 0 ? head.stdout.trim() : "NO_HEAD"; + const indexHash = createHash("sha256").update(index.stdout).digest("hex"); + const material = [headValue, indexHash, ...[...entries].sort(([a], [b]) => a.localeCompare(b)).flatMap(([path, value]) => [path, value])].join("\0"); + return Object.freeze({ kind: "git", root, fingerprint: createHash("sha256").update(material).digest("hex"), entries, head: headValue }); + } + diff(before: WorkspaceSnapshot, after: WorkspaceSnapshot): WorkspaceDiff { + if (before.kind !== "git" || after.kind !== "git" || before.root !== after.root) return Object.freeze({ changedPaths: Object.freeze([]), changed: before.fingerprint !== after.fingerprint, provable: false }); + const paths = new Set([...before.entries.keys(), ...after.entries.keys()]); + const changedPaths = [...paths].filter((path) => before.entries.get(path) !== after.entries.get(path)).sort(); + return Object.freeze({ changedPaths: Object.freeze([...new Set(changedPaths)]), changed: before.fingerprint !== after.fingerprint, provable: true }); + } + async headChangedPaths(before: WorkspaceSnapshot, after: WorkspaceSnapshot, signal?: AbortSignal): Promise { + if (before.kind !== "git" || after.kind !== "git" || !before.head || !after.head || before.head === after.head) return []; + const result = await this.exec("git", ["-C", after.root, "diff", "--name-only", "-z", before.head, after.head, "--"], opts(signal)); + if (result.code !== 0 || Buffer.byteLength(result.stdout) > 2_000_000) return []; + return Object.freeze(result.stdout.split("\0").filter(Boolean).sort()); + } +} +function opts(signal?: AbortSignal) { return { timeout: 10_000, ...(signal ? { signal } : {}) }; } +function parseStatus(output: string): Map { + const tokens = output.split("\0"); const result = new Map(); + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; if (!token || token.length < 4) continue; + const state = token.slice(0, 2); const path = token.slice(3); result.set(path, state); + if (state.includes("R") || state.includes("C")) { const oldPath = tokens[++index]; if (oldPath) result.set(oldPath, `${state}:source`); } + } + return result; +} +function parseIndex(output: string): Map { const result = new Map(); for (const token of output.split("\0")) { if (!token) continue; const tab = token.indexOf("\t"); if (tab > 0) result.set(token.slice(tab + 1), token.slice(0, tab)); } return result; } +async function hashPath(path: string): Promise { + try { const stat = await lstat(path); if (stat.isSymbolicLink()) return `symlink:${await readlink(path)}`; if (!stat.isFile()) return `mode:${stat.mode}:size:${stat.size}`; return createHash("sha256").update(await readFile(path)).digest("hex"); } + catch { return "missing"; } +} +export function explicitMutationPaths(toolName: string, input: Record): readonly string[] { if ((toolName === "write" || toolName === "edit") && typeof input.path === "string") return Object.freeze([input.path]); return Object.freeze([]); } diff --git a/extensions/native-hooks/tests/analysis.test.ts b/extensions/native-hooks/tests/analysis.test.ts new file mode 100644 index 0000000..3473cd9 --- /dev/null +++ b/extensions/native-hooks/tests/analysis.test.ts @@ -0,0 +1,2 @@ +import assert from "node:assert/strict"; import { test } from "node:test"; import { AnalysisCoordinator } from "../src/analysis.ts"; +test("coalesces paths and invokes one adapter per analyzer key", async () => { let calls = 0; let received: readonly string[] = []; const coordinator = new AnalysisCoordinator(new Map([["same", { async analyze(paths: readonly string[], revision: number) { calls++; received = paths; return { analyzerKey: "same", workspaceRevision: revision, paths, findings: [], durationMs: 1 }; } }]])); const result = await coordinator.run([{ analyzerKey: "same", paths: ["b.ts", "a.ts"] }, { analyzerKey: "same", paths: ["a.ts", "c.ts"] }], 4); assert.equal(calls, 1); assert.deepEqual(received, ["a.ts", "b.ts", "c.ts"]); assert.equal(result.get("same")?.workspaceRevision, 4); assert.equal((result as Map).set, undefined); }); diff --git a/extensions/native-hooks/tests/ast-grep.test.ts b/extensions/native-hooks/tests/ast-grep.test.ts new file mode 100644 index 0000000..a360377 --- /dev/null +++ b/extensions/native-hooks/tests/ast-grep.test.ts @@ -0,0 +1,3 @@ +import assert from "node:assert/strict"; import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { AstGrepAdapter } from "../src/analyzers/ast-grep.ts"; +const run = promisify(execFile); const exec = async (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => { try { const result = await run(command, args, { encoding: "utf8", maxBuffer: 3_000_000, ...options }); return { stdout: result.stdout, stderr: result.stderr, code: 0 }; } catch (error) { const row = error as { stdout?: string; stderr?: string; code?: number; killed?: boolean }; return { stdout: row.stdout ?? "", stderr: row.stderr ?? "", code: row.code ?? 1, ...(row.killed === undefined ? {} : { killed: row.killed }) }; } }; +test("one real ast-grep scan detects both TypeScript rules and honors named suppression", async (t) => { try { await run("ast-grep", ["--version"]); } catch { t.skip("ast-grep unavailable"); return; } const root = await mkdtemp(join(tmpdir(), "native-hooks-scan-")); const path = join(root, "sample.ts"); await writeFile(path, `const value: string | undefined = "x";\nconsole.log(value!);\nconst loaded = import("./x.js"); // ast-grep-ignore: typescript-no-dynamic-import-ts -- plugin\n`); const adapter = new AstGrepAdapter(exec, root, join(process.cwd(), "examples/personal-config/sgconfig.yml")); const snapshot = await adapter.analyze([path], 1); assert.equal(snapshot.failure, undefined); assert.deepEqual(snapshot.findings.map((finding) => finding.ruleId), ["typescript-no-non-null-assertion-ts"]); }); diff --git a/extensions/native-hooks/tests/execution-wave.test.ts b/extensions/native-hooks/tests/execution-wave.test.ts new file mode 100644 index 0000000..4868d2a --- /dev/null +++ b/extensions/native-hooks/tests/execution-wave.test.ts @@ -0,0 +1,4 @@ +import assert from "node:assert/strict"; import { test } from "node:test"; import { ExecutionWaveCoordinator, type ObservedToolResult } from "../src/execution-wave.ts"; +const result = (id: string): ObservedToolResult => ({ toolCallId: id, toolName: "write", input: { path: `${id}.ts` }, content: [], details: {}, isError: false }); +test("groups parallel siblings, tolerates reverse completion, and picks source-order carrier", async () => { let finalized = 0; let carrier = ""; const waves = new ExecutionWaveCoordinator(async (wave) => { finalized++; carrier = wave.carrierId!; return new Map([[carrier, { content: [{ type: "text", text: "diagnostic" }] }]]); }, assert.fail); waves.start("first", "write", {}); waves.start("second", "write", {}); const second = waves.result(result("second")); const first = waves.result(result("first")); assert.deepEqual((await second)?.content, [{ type: "text", text: "diagnostic" }]); assert.equal((await first)?.content, undefined); assert.equal(finalized, 1); assert.equal(carrier, "second"); }); +test("immediate end without a tool result releases the wave", async () => { let calls = 0; const waves = new ExecutionWaveCoordinator(async () => { calls++; return new Map(); }, assert.fail); waves.start("blocked", "write", {}); waves.end("blocked"); await new Promise((resolve) => setImmediate(resolve)); assert.equal(calls, 1); }); diff --git a/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml b/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml new file mode 100644 index 0000000..c5e05b5 --- /dev/null +++ b/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml @@ -0,0 +1,6 @@ +id: typescript-no-dynamic-import-ts +language: TypeScript +severity: warning +message: Avoid dynamic imports unless runtime loading is intentional. +rule: + pattern: import($MODULE) diff --git a/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml b/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml new file mode 100644 index 0000000..71fd87b --- /dev/null +++ b/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml @@ -0,0 +1,6 @@ +id: typescript-no-dynamic-import-tsx +language: Tsx +severity: warning +message: Avoid dynamic imports unless runtime loading is intentional. +rule: + pattern: import($MODULE) diff --git a/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml b/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml new file mode 100644 index 0000000..13d92b4 --- /dev/null +++ b/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml @@ -0,0 +1,6 @@ +id: typescript-no-non-null-assertion-ts +language: TypeScript +severity: warning +message: Avoid TypeScript non-null assertions. +rule: + kind: non_null_expression diff --git a/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml b/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml new file mode 100644 index 0000000..a9326f8 --- /dev/null +++ b/extensions/native-hooks/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml @@ -0,0 +1,6 @@ +id: typescript-no-non-null-assertion-tsx +language: Tsx +severity: warning +message: Avoid TypeScript non-null assertions. +rule: + kind: non_null_expression diff --git a/extensions/native-hooks/tests/fixtures/personal-config/policies/harness-tools-verification.ts b/extensions/native-hooks/tests/fixtures/personal-config/policies/harness-tools-verification.ts new file mode 100644 index 0000000..08d4e6c --- /dev/null +++ b/extensions/native-hooks/tests/fixtures/personal-config/policies/harness-tools-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy"; +const manifests = new Set(["package.json", "bun.lock", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|scripts|services|skills|config)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "harness-tools-verification", project: { matches: (project) => project.packageName === "harness-tools" }, requirements: [ + { id: "tests", command: "bun run test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/extensions/native-hooks/tests/fixtures/personal-config/policies/pi-native-hooks-verification.ts b/extensions/native-hooks/tests/fixtures/personal-config/policies/pi-native-hooks-verification.ts new file mode 100644 index 0000000..5c72dab --- /dev/null +++ b/extensions/native-hooks/tests/fixtures/personal-config/policies/pi-native-hooks-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy"; +const manifests = new Set(["package.json", "bun.lock", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|src|tests|examples|skills)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "pi-native-hooks-verification", project: { matches: (project) => project.packageName === "@_jb/pi-native-hooks" }, requirements: [ + { id: "tests", command: "bun run test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/extensions/native-hooks/tests/fixtures/personal-config/policies/typescript-no-dynamic-import.ts b/extensions/native-hooks/tests/fixtures/personal-config/policies/typescript-no-dynamic-import.ts new file mode 100644 index 0000000..7e4fbd6 --- /dev/null +++ b/extensions/native-hooks/tests/fixtures/personal-config/policies/typescript-no-dynamic-import.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "@_jb/pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-dynamic-import-ts", "typescript-no-dynamic-import-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-dynamic-import", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-dynamic-import", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a static import unless lazy loading, code splitting, an optional dependency, or runtime module selection is intentional.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- for an intentional dynamic import.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/extensions/native-hooks/tests/fixtures/personal-config/policies/typescript-no-non-null-assertion.ts b/extensions/native-hooks/tests/fixtures/personal-config/policies/typescript-no-non-null-assertion.ts new file mode 100644 index 0000000..e108e41 --- /dev/null +++ b/extensions/native-hooks/tests/fixtures/personal-config/policies/typescript-no-non-null-assertion.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "@_jb/pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-non-null-assertion-ts", "typescript-no-non-null-assertion-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-non-null-assertion", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-non-null-assertion", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a guard, refinement, assertion function, or corrected type.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- when the invariant is intentional.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/extensions/native-hooks/tests/fixtures/personal-config/sgconfig.yml b/extensions/native-hooks/tests/fixtures/personal-config/sgconfig.yml new file mode 100644 index 0000000..0adbca3 --- /dev/null +++ b/extensions/native-hooks/tests/fixtures/personal-config/sgconfig.yml @@ -0,0 +1,2 @@ +ruleDirs: + - ast-grep/rules diff --git a/extensions/native-hooks/tests/fixtures/personal-config/tsconfig.json b/extensions/native-hooks/tests/fixtures/personal-config/tsconfig.json new file mode 100644 index 0000000..6670485 --- /dev/null +++ b/extensions/native-hooks/tests/fixtures/personal-config/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "paths": { "@_jb/pi-native-hooks/policy": ["__PI_NATIVE_HOOKS_PACKAGE_ROOT__/src/policy.ts"] } + }, + "include": ["policies/*.ts"] +} diff --git a/extensions/native-hooks/tests/package.test.ts b/extensions/native-hooks/tests/package.test.ts new file mode 100644 index 0000000..a758f7e --- /dev/null +++ b/extensions/native-hooks/tests/package.test.ts @@ -0,0 +1,2 @@ +import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { test } from "node:test"; +test("package exposes exactly one extension and the authoring skill", async () => { const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { pi: { extensions: string[]; skills: string[] }; engines: { node: string } }; assert.deepEqual(pkg.pi.extensions, ["./extensions/pi-native-hooks/index.ts"]); assert.deepEqual(pkg.pi.skills, ["./skills/native-hook-authoring"]); assert.equal(pkg.engines.node, ">=22.19.0"); }); diff --git a/extensions/native-hooks/tests/policy-loader.test.ts b/extensions/native-hooks/tests/policy-loader.test.ts new file mode 100644 index 0000000..184192c --- /dev/null +++ b/extensions/native-hooks/tests/policy-loader.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { chmod, cp, mkdir, symlink, writeFile } from "node:fs/promises"; +import { tmpdir, userInfo } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { mkdtemp } from "node:fs/promises"; +import { loadPolicies } from "../src/policy-loader.ts"; +const valid = `export default { kind: "diagnostic", id: "ok", analyzerKeys: [], appliesTo() { return true }, evaluate() { return [] } }`; +test("loads native erasable TypeScript deterministically and isolates failures", async () => { + const root = await mkdtemp(join(tmpdir(), "native-hooks-")); await chmod(root, 0o700); const policies = join(root, "policies"); await mkdir(policies, { mode: 0o700 }); + await writeFile(join(policies, "b.ts"), `throw new Error("boom")`); await writeFile(join(policies, "a.ts"), `interface Marker { ok: true }; import type { PersonalPolicy } from "${join(process.cwd(), "src/policy.ts")}"; ${valid} satisfies PersonalPolicy`); + await chmod(join(policies, "a.ts"), 0o600); await chmod(join(policies, "b.ts"), 0o600); + const registry = await loadPolicies({ root }); assert.deepEqual(registry.policies.map((item) => item.policy.id), ["ok"]); assert.ok(registry.failures.some((failure) => /boom/.test(failure.error))); +}); +test("rejects duplicate IDs, unsafe mode, and symlinks", async () => { + const root = await mkdtemp(join(tmpdir(), "native-hooks-")); await chmod(root, 0o700); const policies = join(root, "policies"); await mkdir(policies, { mode: 0o700 }); + await writeFile(join(policies, "a.ts"), valid); await writeFile(join(policies, "b.ts"), valid.replace('id: "ok"', 'id: "ok"')); await writeFile(join(root, "target.ts"), valid); await symlink(join(root, "target.ts"), join(policies, "c.ts")); await writeFile(join(policies, "d.ts"), valid.replace('id: "ok"', 'id: "unsafe"')); + await Promise.all(["a.ts", "b.ts"].map((name) => chmod(join(policies, name), 0o600))); await chmod(join(policies, "d.ts"), 0o622); + const registry = await loadPolicies({ root, uid: userInfo().uid }); assert.equal(registry.policies.length, 1); assert.ok(registry.failures.some((failure) => /duplicate/.test(failure.error))); assert.ok(registry.failures.some((failure) => /symlink/.test(failure.error))); assert.ok(registry.failures.some((failure) => /group or others/.test(failure.error))); +}); diff --git a/extensions/native-hooks/tests/runtime.test.ts b/extensions/native-hooks/tests/runtime.test.ts new file mode 100644 index 0000000..8156489 --- /dev/null +++ b/extensions/native-hooks/tests/runtime.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { NativeHooksRuntime } from "../src/runtime.ts"; + +test("runtime registers lifecycle hooks and returns the current call's result patch", async () => { + const handlers = new Map unknown>>(); const tools: string[] = []; const commands: string[] = []; + const pi = { + on(name: string, handler: (...args: unknown[]) => unknown) { const list = handlers.get(name) ?? []; list.push(handler); handlers.set(name, list); }, + registerTool(tool: { name: string }) { tools.push(tool.name); }, registerCommand(name: string) { commands.push(name); }, + } as unknown as ExtensionAPI; + const runtime = new NativeHooksRuntime(pi); + assert.deepEqual(tools, ["pi_hooks_waive_verification"]); assert.deepEqual(commands, ["hooks-status"]); assert.ok(handlers.has("agent_settled")); assert.ok(handlers.has("context")); + (runtime as unknown as { waves: { result(): Promise<{ content: Array<{ type: string; text: string }> }> } }).waves = { async result() { return { content: [{ type: "text", text: "augmented" }] }; } }; + const handler = handlers.get("tool_result")![0]!; const patch = await handler({ type: "tool_result", toolCallId: "call", toolName: "write", input: { path: "a.ts" }, content: [{ type: "text", text: "original" }], details: undefined, isError: false }, {}); + assert.deepEqual(patch, { content: [{ type: "text", text: "augmented" }] }); +}); diff --git a/extensions/native-hooks/tests/state.test.ts b/extensions/native-hooks/tests/state.test.ts new file mode 100644 index 0000000..151d321 --- /dev/null +++ b/extensions/native-hooks/tests/state.test.ts @@ -0,0 +1,2 @@ +import assert from "node:assert/strict"; import { test } from "node:test"; import { latestState, STATE_TYPE } from "../src/state.ts"; +test("restores latest valid branch state and ignores malformed newer entries", () => { const state = { version: 1 as const, policyDigest: "p", projectIdentity: "i", workspaceRevision: 2, workspaceFingerprint: "f", requirements: [], reason: "test", timestamp: new Date().toISOString() }; const restored = latestState([{ type: "custom", customType: STATE_TYPE, data: state }, { type: "custom", customType: STATE_TYPE, data: { version: 1 } }]); assert.deepEqual(restored.state, state); assert.equal(restored.malformed, 1); }); diff --git a/extensions/native-hooks/tests/verification.test.ts b/extensions/native-hooks/tests/verification.test.ts new file mode 100644 index 0000000..7514090 --- /dev/null +++ b/extensions/native-hooks/tests/verification.test.ts @@ -0,0 +1,4 @@ +import assert from "node:assert/strict"; import { test } from "node:test"; import type { VerificationPolicy } from "../src/policy.ts"; import { VerificationTracker } from "../src/verification.ts"; +const project = { root: "/repo", packageName: "x", identity: "identity", git: true } as const; const policy: VerificationPolicy = { kind: "verification", id: "verify", project: { matches: () => true }, requirements: [{ id: "tests", command: "npm test", cwd: "project-root", affects: (path) => path.startsWith("src/") }] }; +test("arms, accepts only exact stable evidence, stales, and waives by revision", () => { const tracker = new VerificationTracker(project); tracker.configure([policy]); assert.equal(tracker.records()[0]!.status, "inactive"); tracker.observeMutation(["src/a.ts"], 1); assert.equal(tracker.records()[0]!.status, "not-run"); const altered = tracker.beginCheck("bad", "npm test -- --focus", "/repo"); assert.equal(altered.length, 0); const attempt = tracker.beginCheck("good", "npm test", "/repo"); tracker.finishCheck(attempt, true, true); assert.equal(tracker.records()[0]!.status, "passed"); tracker.observeMutation(["src/b.ts"], 2); assert.equal(tracker.records()[0]!.status, "stale"); assert.throws(() => tracker.waive(" ", "w"), /non-empty/); tracker.waive("deferred by user", "w"); assert.equal(tracker.records()[0]!.status, "waived"); tracker.observeMutation(["src/c.ts"], 3); assert.equal(tracker.records()[0]!.status, "not-run"); }); +test("concurrent or wrong-cwd checks do not pass", () => { const tracker = new VerificationTracker(project); tracker.configure([policy]); tracker.observeMutation(["src/a.ts"], 1); tracker.finishCheck(tracker.beginCheck("wrong", "npm test", "/elsewhere"), true, true); assert.equal(tracker.records()[0]!.status, "not-run"); tracker.finishCheck(tracker.beginCheck("race", "npm test", "/repo"), true, false); assert.equal(tracker.records()[0]!.status, "not-run"); }); diff --git a/extensions/native-hooks/tests/workspace.test.ts b/extensions/native-hooks/tests/workspace.test.ts new file mode 100644 index 0000000..d6a8b29 --- /dev/null +++ b/extensions/native-hooks/tests/workspace.test.ts @@ -0,0 +1,4 @@ +import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdtemp, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { test } from "node:test"; import { WorkspaceInspector } from "../src/workspace.ts"; +const run = promisify(execFile); const exec = async (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => { try { const value = await run(command, args, { encoding: "utf8", ...options }); return { stdout: value.stdout, stderr: value.stderr, code: 0 }; } catch (error) { const row = error as { stdout?: string; stderr?: string; code?: number }; return { stdout: row.stdout ?? "", stderr: row.stderr ?? "", code: row.code ?? 1 }; } }; +async function git(root: string, ...args: string[]) { const result = await exec("git", ["-C", root, ...args]); assert.equal(result.code, 0, result.stderr); } +test("git snapshots detect second edits, staging, untracked, rename, deletion, and symlinks", async () => { const root = await mkdtemp(join(tmpdir(), "native-hooks-git-")); await git(root, "init", "-q"); await git(root, "config", "user.email", "test@example.invalid"); await git(root, "config", "user.name", "Test"); await writeFile(join(root, "a.ts"), "export const a = 1;\n"); await git(root, "add", "a.ts"); await git(root, "commit", "-qm", "initial"); const inspector = new WorkspaceInspector(exec); const clean = await inspector.capture(root); await writeFile(join(root, "a.ts"), "export const a = 2;\n"); const dirty = await inspector.capture(root); assert.deepEqual(inspector.diff(clean, dirty).changedPaths, ["a.ts"]); await writeFile(join(root, "a.ts"), "export const a = 3;\n"); const second = await inspector.capture(root); assert.notEqual(second.fingerprint, dirty.fingerprint); assert.deepEqual(inspector.diff(dirty, second).changedPaths, ["a.ts"]); await git(root, "add", "a.ts"); const staged = await inspector.capture(root); assert.deepEqual(inspector.diff(second, staged).changedPaths, ["a.ts"]); await writeFile(join(root, "new.ts"), "new\n"); const untracked = await inspector.capture(root); assert.ok(inspector.diff(staged, untracked).changedPaths.includes("new.ts")); await symlink("a.ts", join(root, "link.ts")); const linked = await inspector.capture(root); assert.ok(inspector.diff(untracked, linked).changedPaths.includes("link.ts")); await rename(join(root, "a.ts"), join(root, "moved.ts")); const renamed = await inspector.capture(root); assert.ok(inspector.diff(linked, renamed).changedPaths.includes("a.ts")); assert.ok(inspector.diff(linked, renamed).changedPaths.includes("moved.ts")); await rm(join(root, "moved.ts")); const deleted = await inspector.capture(root); assert.ok(inspector.diff(renamed, deleted).changedPaths.includes("moved.ts")); }); diff --git a/extensions/native-hooks/tsconfig.json b/extensions/native-hooks/tsconfig.json new file mode 100644 index 0000000..b7a6b3c --- /dev/null +++ b/extensions/native-hooks/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["extensions/**/*.ts", "src/**/*.ts", "tests/**/*.ts", "examples/**/*.ts"] +} diff --git a/extensions/prompt-snippets/LICENSE b/extensions/prompt-snippets/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/prompt-snippets/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/prompt-snippets/package.json b/extensions/prompt-snippets/package.json new file mode 100644 index 0000000..9cf2c96 --- /dev/null +++ b/extensions/prompt-snippets/package.json @@ -0,0 +1,23 @@ +{ + "name": "@_jb/pi-prompt-snippets", + "version": "0.1.0", + "description": "Pi extension for toggling reusable per-message prompt snippets.", + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.19.0" }, + "keywords": ["pi-package", "pi-extension", "prompts"], + "files": ["index.ts", "snippets", "README.md", "LICENSE"], + "pi": { "extensions": ["./index.ts"] }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer" + }, + "publishConfig": { + "registry": "https://git.jb.codes/api/packages/jb/npm/" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/prompt-snippets" + } +} diff --git a/extensions/shared/LICENSE b/extensions/shared/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/shared/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/shared/README.md b/extensions/shared/README.md new file mode 100644 index 0000000..016ad1d --- /dev/null +++ b/extensions/shared/README.md @@ -0,0 +1,3 @@ +# @_jb/pi-shared + +Shared runtime utilities used by the `@_jb` Pi extension packages. This is a library package and does not register a Pi extension by itself. diff --git a/extensions/shared/child-session.test.ts b/extensions/shared/child-session.test.ts index abad82e..6ae175f 100644 --- a/extensions/shared/child-session.test.ts +++ b/extensions/shared/child-session.test.ts @@ -106,7 +106,6 @@ test("child denylist keeps extension and workflow structured tools available", a "subagent_check", "subagent_list", "workflow", - "pi_workflow", "ask_user", ], ); diff --git a/extensions/shared/child-session.ts b/extensions/shared/child-session.ts index aab3296..542fa76 100644 --- a/extensions/shared/child-session.ts +++ b/extensions/shared/child-session.ts @@ -18,7 +18,6 @@ export const CHILD_EXCLUDED_TOOL_NAMES = [ "subagent_check", "subagent_list", "workflow", - "pi_workflow", "ask_user", ] as const; diff --git a/extensions/shared/package.json b/extensions/shared/package.json new file mode 100644 index 0000000..d9a4739 --- /dev/null +++ b/extensions/shared/package.json @@ -0,0 +1,28 @@ +{ + "name": "@_jb/pi-shared", + "version": "0.1.0", + "description": "Shared runtime utilities for @_jb Pi extensions.", + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.19.0" }, + "keywords": ["pi-extension", "library"], + "files": ["*.ts", "!*.test.ts", "README.md", "LICENSE"], + "exports": { + "./activity-status": "./activity-status.ts", + "./child-session": "./child-session.ts", + "./context-utilization": "./context-utilization.ts", + "./tool-call-timeout": "./tool-call-timeout.ts" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "typebox": "catalog:pi-peer" + }, + "publishConfig": { + "registry": "https://git.jb.codes/api/packages/jb/npm/" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/shared" + } +} diff --git a/extensions/subagents/LICENSE b/extensions/subagents/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/subagents/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/subagents/README.md b/extensions/subagents/README.md new file mode 100644 index 0000000..b35f6e0 --- /dev/null +++ b/extensions/subagents/README.md @@ -0,0 +1,9 @@ +# @_jb/pi-subagents + +Pi extension for background subagents using Pi, Claude Code, and Codex backends. + +```bash +pi install npm:@_jb/pi-subagents +``` + +Do not enable it alongside another extension that registers the same subagent tools. diff --git a/extensions/subagents/package.json b/extensions/subagents/package.json new file mode 100644 index 0000000..2364a64 --- /dev/null +++ b/extensions/subagents/package.json @@ -0,0 +1,30 @@ +{ + "name": "@_jb/pi-subagents", + "version": "0.1.0", + "description": "Pi extension for background subagents across Pi, Claude Code, and Codex backends.", + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.19.0" }, + "keywords": ["pi-package", "pi-extension", "subagents"], + "files": ["index.ts", "src", "docs", "README.md", "LICENSE", "!**/*.test.ts"], + "pi": { "extensions": ["./index.ts"] }, + "dependencies": { + "@_jb/pi-shared": "workspace:^", + "@anthropic-ai/claude-agent-sdk": "catalog:", + "effect": "catalog:" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "catalog:pi-peer", + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + "typebox": "catalog:pi-peer" + }, + "publishConfig": { + "registry": "https://git.jb.codes/api/packages/jb/npm/" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/subagents" + } +} diff --git a/extensions/subagents/src/backends/pi.ts b/extensions/subagents/src/backends/pi.ts index dba4857..48aa82b 100644 --- a/extensions/subagents/src/backends/pi.ts +++ b/extensions/subagents/src/backends/pi.ts @@ -37,8 +37,8 @@ import { createChildResources, shutdownAndDisposeChildSession, waitForChildOperation, -} from "../../../shared/child-session.ts"; -import { createToolCallTimeoutGuard } from "../../../shared/tool-call-timeout.ts"; +} from "@_jb/pi-shared/child-session"; +import { createToolCallTimeoutGuard } from "@_jb/pi-shared/tool-call-timeout"; // --- Model + effort resolution ----------------------------------------------- diff --git a/extensions/todo-herdr/.gitignore b/extensions/todo-herdr/.gitignore new file mode 100644 index 0000000..ff3881a --- /dev/null +++ b/extensions/todo-herdr/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.tgz +.DS_Store +.pi/ diff --git a/extensions/todo-herdr/LICENSE b/extensions/todo-herdr/LICENSE new file mode 100644 index 0000000..c38d2a0 --- /dev/null +++ b/extensions/todo-herdr/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 lesetong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/todo-herdr/README.md b/extensions/todo-herdr/README.md new file mode 100644 index 0000000..5549363 --- /dev/null +++ b/extensions/todo-herdr/README.md @@ -0,0 +1,150 @@ +## Forked from [leset0ng/pi-todo-herdr](https://github.com/leset0ng/pi-todo-herdr) + +# pi-todo-herdr + +Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic batch tools, a live task-tree widget, and optional [Herdr](https://herdr.dev) sidebar integration. + +![pi-todo-herdr task tree and Herdr sidebar](./media/screenshot.png) + +## Features + +- Five focused tools: `set_tasks`, `patch_tasks`, `rm_task`, `list_task`, and `get_task` +- Parent/child task trees with generated integer IDs and atomic append/replace modes +- Atomic batch mutations: every requested change succeeds or none of it is applied +- Bounded task input and widget output to keep sessions and terminals responsive +- Four states: `pending`, `in-progress`, `blocked`, and `done` +- Cumulative active-time tracking that pauses outside `in-progress` and survives session restore +- Session-branch persistence across reload, resume, fork, tree navigation, and context compaction +- A concise first-action workflow prompt plus a one-shot post-compaction recovery reminder +- Compact, theme-aware Pi Todo header and task tree with hierarchy, status colors, timers, and an adaptive progress bar +- Optional responsive Todo + Subagents panels when the interactive-subagents extension is present +- Optional Herdr metadata showing the current task in the agent sidebar + +## Requirements + +- Pi 0.81 or newer +- Node.js 22.19 or newer +- Herdr is optional; all task tools and the Pi widget work without it + +## Install + +Install the latest version from npm: + +```bash +pi install npm:@_jb/pi-todo-herdr +``` + +Then start Pi. If Pi is already running, use `/reload` to load the extension. + +To try the package for one run without installing it: + +```bash +pi -e npm:@_jb/pi-todo-herdr +``` + +Update or remove it with Pi's package commands: + +```bash +pi update npm:@_jb/pi-todo-herdr +pi remove npm:@_jb/pi-todo-herdr +``` + +## Usage + +For work with three or more distinct steps, the extension instructs Pi to explore enough to understand the scope, then call `set_tasks` before implementation. Smaller work remains untracked. Pi then updates task states at each real transition instead of reconstructing progress at the end. + +After automatic or manual context compaction, the extension sends one short hidden reminder only when unfinished tasks exist. Pi calls `list_task` to recover the authoritative current IDs and statuses; the full tree is not injected on every model call. + +| Tool | Parameters | Behavior | +| --- | --- | --- | +| `set_tasks` | `{ mode, tasks, force }` | `append` extends the tree with `force: false`; `replace` atomically installs a new tree or clears it with `[]`. Replacing unfinished work requires `force: true`. | +| `patch_tasks` | `{ tasks: [...] }` | Atomically patches multiple tasks. Use `null` for unchanged text/status, `parentId: -1` for unchanged or `0` to move to root, and `position: -1` to keep the current order. Completed trees may auto-clear; do not recreate cleared completed tasks. | +| `rm_task` | `{ ids: [...], reason }` | Removes only mistaken, duplicate, or canceled tasks. Removing a parent cascades through its descendants. | +| `list_task` | `{}` | Returns the full tree as compact `id/status/name` lines. | +| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent/child IDs and timing for tasks that have started. | + +Example append call: + +```json +{ + "mode": "append", + "tasks": [ + { + "name": "Implement authentication", + "description": "Add session validation and route protection.", + "status": "in-progress", + "parentId": null, + "children": [ + { + "name": "Add auth middleware", + "description": "Validate the session before protected handlers run.", + "status": "pending", + "parentId": null, + "children": [] + } + ] + } + ], + "force": false +} +``` + +Task input keys are explicit so strict tool-schema bridges do not invent values for omitted properties. Use `description: null` when no detail is needed, `status: "pending"` for the default state, `parentId: null` for root tasks and nested children, and `children: []` for leaves. `prepareArguments` still expands legacy calls that omit these fields. + +To keep model-generated input safe and bounded, a session may contain up to 200 tasks. One mutation may affect up to 50 explicitly supplied tasks. The widget header acts as a virtual root above three stored task levels: categories, items, and leaf sub-items. Names are limited to 200 Unicode characters and descriptions to 4,000 Unicode characters. Names and descriptions reject terminal controls and bidirectional override characters; descriptions may still contain line feeds for normal multi-line detail. + +IDs are not reused by append/remove operations; `replace` deliberately rebuilds IDs from `1`. Replace forbids non-null `parentId`, so new hierarchy must use nested `children`. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all descendants are also `done`. + +A task starts timing the first time it enters `in-progress`. Leaving that state records a stop and pauses the timer; entering it again resumes with a new active segment while preserving the cumulative duration. Repeated updates with the same status, as well as name, description, hierarchy, and position changes, do not reset timing. For started tasks, `get_task` includes the first `startedAt` timestamp, a `stoppedAt` timestamp while paused, and cumulative `durationMs`. Timestamps use ISO 8601. + +## Pi Widget + +The widget appears above Pi's editor while todos exist. Run `/todo` to hide or show it for the current Pi runtime. Ten seconds after every task is completed, the task tree is automatically deleted and task IDs reset to `1`; adding or reopening unfinished work during that delay cancels the clear. Its compact, unboxed header retains the themed `Todos` title, done/total count, blocked-work count when present, and an adaptive progress bar; the bar drops away first at narrow widths. Body rows retain the task tree's virtual-root connectors, hierarchy, statuses, and timers. The outline uses only the labels needed by the deepest task: one-level trees render every task as an unlabeled leaf, two-level trees use numbered parents and unlabeled leaves, and three-level trees use Roman-numeral categories, numbered items, and unlabeled leaves. Internal task IDs remain unchanged and visible in tool output. In-progress circles rotate through `◐ ◓ ◑ ◒` twice per second; other status symbols remain still. The standalone widget shows at most 12 task rows plus a compact count of hidden tasks. + +```text +Todos 2/6 · !1 ━━━━──── + ├─ I. ◐ Build auth · 12:08 + │ ├─ 1. ! Resolve token issue · 03:41 + │ └─ ○ Inspect provider response + └─ II. ✓ Define schema · 04:31 +``` + +When the optional interactive-subagents extension exposes its `statusWidget` integration, Todo claims the Subagents panel only while both extensions have content. The duck-typed widget must provide `hasContent()`, `getBodyRowCount()`, `render(width, maxBodyRows?, theme?)`, `claim(onChange)`, and `isClaimed(onChange)`. The optional `theme` is the current Pi theme object with the structural `fg(color, text)` formatter; Todo passes that exact object unchanged, while older providers may ignore the third argument. `isClaimed` must return true only for the exact callback supplied by the current runtime claim; this lets Todo recover safely after a provider reload or invalidation. Above 120 columns they render side by side with a one-cell gap and up to ten body rows per panel; the shorter panel is padded with plain spaces. At 120 columns or fewer, Todo renders first and Subagents below it with one blank line between sections. Stacked panels share ten body rows, preserving one row for each panel and reporting overflow honestly. If the extension is absent or exposes an older API, Todo falls back to its full-width Todo widget. + +Tool calls remain compact in the conversation. Expand a tool result with Pi's normal tool-output shortcut to inspect affected tasks or full descriptions. + +## Herdr Integration + +When Pi runs inside Herdr, the extension reports the most specific `in-progress` task as a `task` pane metadata token. If nothing is in progress, it falls back to a blocked task. Parallel current tasks use a separate `task_count` token so Herdr can truncate long task names responsively while keeping the remaining count visible, for example `#7 Implement auth · +2`. The `task_progress` token reports aggregate completion as `done/total` while tasks remain unfinished and is omitted once all tasks are complete. + +Add `$task`, `$task_count`, and `$task_progress` to the Herdr agent sidebar in `~/.config/herdr/config.toml`: + +```toml +[ui.sidebar.agents] +rows = [["state_icon", "workspace", "tab"], ["$ask", "$ask_count"], ["$task_progress", "$task", "$task_count"]] +``` + +Apply the change to a running Herdr server: + +```bash +herdr server reload-config +``` + +Metadata failures never block task operations. The extension does not change Herdr's agent lifecycle or notification state. + +## Development + +Development uses the monorepo's pinned Bun toolchain and shared catalog. + +```bash +bun install --frozen-lockfile +bun run --filter '@_jb/pi-todo-herdr' typecheck +bun run --filter '@_jb/pi-todo-herdr' test +(cd extensions/todo-herdr && bun pm pack --destination ../../dist) +``` + +The package has no third-party runtime dependencies. Pi's runtime packages are declared as peers and supplied by Pi. + +## License + +MIT diff --git a/extensions/todo-herdr/docs/adr/0001-compose-todo-and-subagent-widgets.md b/extensions/todo-herdr/docs/adr/0001-compose-todo-and-subagent-widgets.md new file mode 100644 index 0000000..f533c7b --- /dev/null +++ b/extensions/todo-herdr/docs/adr/0001-compose-todo-and-subagent-widgets.md @@ -0,0 +1,5 @@ +# Compose Todo and Subagent widgets through an optional lease + +Todo owns the combined Pi widget and discovers the Subagents panel through a structural, process-local interface. Subagents retains its standalone widget until Todo claims a callback-identified lease, and Todo verifies that lease before every combined render with `isClaimed(onChange)`. `isClaimed` recognizes only the exact callback for the current runtime claim, so a provider reload or invalidation causes Todo to release and reacquire instead of trusting a stale local handle. This avoids a package dependency across the two extensions, preserves either extension's standalone behavior, and lets reload invalidate stale UI callbacks without losing running subagents. + +The optional render seam is `render(width, maxBodyRows?, theme?)`. When composing, Todo passes the exact current Pi theme object as `theme`; the only structural requirement is `fg(color, text): string`, and providers may ignore the third argument for compatibility with older implementations. Todo renders a compact unboxed header and task body. Above 120 columns the panels share a row with a one-cell gap and plain-space padding for a shorter panel; at 120 columns and below they stack with one blank line between sections. Combined layouts cap body rows at a shared ten-line budget. diff --git a/extensions/todo-herdr/index.ts b/extensions/todo-herdr/index.ts new file mode 100644 index 0000000..71a7409 --- /dev/null +++ b/extensions/todo-herdr/index.ts @@ -0,0 +1,91 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { createCompletedTaskAutoClear } from "./src/auto-clear.ts"; +import { clearCurrentTask, reportCurrentTask } from "./src/herdr.ts"; +import { + COMPACTION_REMINDER, + COMPACTION_REMINDER_TYPE, + hasUnfinishedTasks, +} from "./src/prompt.ts"; +import { TASK_STATE_ENTRY_TYPE, TaskState } from "./src/state.ts"; +import { registerTaskTools } from "./src/tools.ts"; +import { clearTaskWidget, updateTaskWidget } from "./src/widget.ts"; + +export default function piTodoHerdr(pi: ExtensionAPI): void { + const state = new TaskState(); + let widgetVisible = true; + let activeContext: ExtensionContext | undefined; + + const autoClear = createCompletedTaskAutoClear(state, () => { + try { + pi.appendEntry(TASK_STATE_ENTRY_TYPE, { tasks: [], nextId: 1 }); + } catch (error) { + console.error("[pi-todo-herdr] failed to persist automatic task clear; retrying:", error); + return false; + } + + state.set("replace", [], false); + if (activeContext !== undefined) { + try { + updateTaskWidget(state, activeContext, widgetVisible); + void reportCurrentTask(state.list()); + } catch (error) { + console.error("[pi-todo-herdr] failed to refresh task UI after automatic clear:", error); + } + } + return true; + }); + + const refresh = (ctx: ExtensionContext, restartAutoClear = false) => { + activeContext = ctx; + updateTaskWidget(state, ctx, widgetVisible); + void reportCurrentTask(state.list()); + autoClear.sync(restartAutoClear); + }; + + const restore = (ctx: ExtensionContext) => { + state.loadFromSession(ctx); + refresh(ctx, true); + }; + + pi.on("session_start", async (_event, ctx) => restore(ctx)); + pi.on("session_tree", async (_event, ctx) => restore(ctx)); + pi.on("session_shutdown", async (event) => { + autoClear.cancel(); + if (activeContext !== undefined) clearTaskWidget(activeContext); + activeContext = undefined; + if (event.reason === "quit") await clearCurrentTask(); + }); + pi.on("session_compact", () => { + if (!hasUnfinishedTasks(state.list())) return; + pi.sendMessage( + { + customType: COMPACTION_REMINDER_TYPE, + content: COMPACTION_REMINDER, + display: false, + }, + { deliverAs: "steer" }, + ); + }); + + registerTaskTools(pi, state, refresh); + + pi.registerCommand("todo", { + description: "Show or hide the todo tree widget", + handler: async (_args, ctx) => { + if (state.list().length === 0) { + clearTaskWidget(ctx); + ctx.ui.notify("No todos yet.", "info"); + return; + } + + widgetVisible = !widgetVisible; + if (widgetVisible) { + updateTaskWidget(state, ctx, true); + ctx.ui.notify("Todo widget shown.", "info"); + } else { + clearTaskWidget(ctx); + ctx.ui.notify("Todo widget hidden.", "info"); + } + }, + }); +} diff --git a/extensions/todo-herdr/media/screenshot.png b/extensions/todo-herdr/media/screenshot.png new file mode 100644 index 0000000..efedea0 Binary files /dev/null and b/extensions/todo-herdr/media/screenshot.png differ diff --git a/extensions/todo-herdr/package.json b/extensions/todo-herdr/package.json new file mode 100644 index 0000000..6482065 --- /dev/null +++ b/extensions/todo-herdr/package.json @@ -0,0 +1,67 @@ +{ + "name": "@_jb/pi-todo-herdr", + "version": "0.2.5", + "description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.", + "type": "module", + "license": "MIT", + "author": { + "name": "JB", + "url": "https://git.jb.codes/jb" + }, + "homepage": "https://git.jb.codes/jb/harness-tools/src/branch/main/extensions/todo-herdr", + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/todo-herdr" + }, + "bugs": { + "url": "https://git.jb.codes/jb/harness-tools/issues" + }, + "engines": { + "node": ">=22.19.0" + }, + "main": "./index.ts", + "files": [ + "index.ts", + "src/**/*", + "media/**/*", + "README.md", + "LICENSE" + ], + "keywords": [ + "pi-package", + "pi-extension", + "todo", + "task-management", + "herdr", + "productivity" + ], + "pi": { + "extensions": [ + "./index.ts" + ], + "image": "https://git.jb.codes/jb/harness-tools/raw/branch/main/extensions/todo-herdr/media/screenshot.png" + }, + "scripts": { + "test": "node --test tests/*.test.ts", + "typecheck": "tsc --noEmit", + "prepublishOnly": "bun run typecheck && bun run test" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "catalog:pi-peer", + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + "typebox": "catalog:pi-peer" + }, + "devDependencies": { + "@earendil-works/pi-ai": "catalog:", + "@earendil-works/pi-coding-agent": "catalog:", + "@earendil-works/pi-tui": "catalog:", + "typebox": "catalog:", + "typescript": "catalog:" + }, + "publishConfig": { + "access": "public", + "registry": "https://git.jb.codes/api/packages/jb/npm/" + } +} diff --git a/extensions/todo-herdr/src/auto-clear.ts b/extensions/todo-herdr/src/auto-clear.ts new file mode 100644 index 0000000..5e9e5fa --- /dev/null +++ b/extensions/todo-herdr/src/auto-clear.ts @@ -0,0 +1,49 @@ +import type { TaskState } from "./state.ts"; + +export const TODO_AUTO_CLEAR_DELAY_MS = 10_000; + +export interface CompletedTaskAutoClear { + sync(forceRestart?: boolean): void; + cancel(): void; +} + +export function createCompletedTaskAutoClear( + state: TaskState, + tryClear: () => boolean, + delayMs = TODO_AUTO_CLEAR_DELAY_MS, +): CompletedTaskAutoClear { + let timer: ReturnType | undefined; + let scheduledKey: string | undefined; + + const completionKey = () => { + const tasks = state.list(); + if (tasks.length === 0 || tasks.some((task) => task.status !== "done")) return undefined; + return tasks.map((task) => `${task.id}:${task.status}`).join("|"); + }; + + const cancel = () => { + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + scheduledKey = undefined; + }; + + const sync = (forceRestart = false) => { + const nextKey = completionKey(); + if (nextKey === undefined) { + cancel(); + return; + } + if (!forceRestart && timer !== undefined && scheduledKey === nextKey) return; + + cancel(); + scheduledKey = nextKey; + timer = setTimeout(() => { + timer = undefined; + if (completionKey() !== scheduledKey) return; + if (!tryClear()) sync(); + }, Math.max(0, delayMs)); + timer.unref?.(); + }; + + return { sync, cancel }; +} diff --git a/extensions/todo-herdr/src/herdr.ts b/extensions/todo-herdr/src/herdr.ts new file mode 100644 index 0000000..3ce0e7e --- /dev/null +++ b/extensions/todo-herdr/src/herdr.ts @@ -0,0 +1,104 @@ +import net from "node:net"; +import type { Task } from "./types.ts"; +import { orderedTreeRows, snapshotStats } from "./ui.ts"; + +const SOURCE = "pi-todo-herdr"; +const TASK_TOKEN = "task"; +const COUNT_TOKEN = "task_count"; +const PROGRESS_TOKEN = "task_progress"; +let reportSeq = Date.now() * 1000; + +function nextSeq(): number { + reportSeq += 1; + return reportSeq; +} + +export function isHerdrEnvironment(): boolean { + return process.env.HERDR_ENV === "1" && !!process.env.HERDR_SOCKET_PATH && !!process.env.HERDR_PANE_ID; +} + +function hasDescendantWithStatus(task: Task, status: Task["status"], byId: Map): boolean { + for (const childId of task.childrenIds) { + const child = byId.get(childId); + if (!child) continue; + if (child.status === status || hasDescendantWithStatus(child, status, byId)) return true; + } + return false; +} + +export function currentTaskTokens(tasks: Task[]): Record { + const byId = new Map(tasks.map((task) => [task.id, task])); + const stats = snapshotStats(tasks); + const ordered = orderedTreeRows(tasks).map(({ task }) => task); + const inProgress = ordered.filter( + (task) => task.status === "in-progress" && !hasDescendantWithStatus(task, "in-progress", byId), + ); + const candidates = + inProgress.length > 0 + ? inProgress + : ordered.filter((task) => task.status === "blocked" && !hasDescendantWithStatus(task, "blocked", byId)); + return { + [TASK_TOKEN]: candidates.length > 0 ? `#${candidates[0]!.id} ${candidates[0]!.name}` : null, + [COUNT_TOKEN]: candidates.length > 1 ? `+${candidates.length - 1}` : null, + [PROGRESS_TOKEN]: stats.total > 0 && stats.done < stats.total ? `${stats.done}/${stats.total}` : null, + }; +} + +export function currentTaskLabel(tasks: Task[]): string | null { + const tokens = currentTaskTokens(tasks); + const task = tokens[TASK_TOKEN]; + const count = tokens[COUNT_TOKEN]; + return task ? `${task}${count ? ` ${count}` : ""}` : null; +} + +function sendAttempt(request: unknown, timeoutMs: number): Promise { + if (!isHerdrEnvironment()) return Promise.resolve(true); + const socketPath = process.env.HERDR_SOCKET_PATH!; + const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath; + + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + const socket = net.createConnection(endpoint); + const finish = (delivered: boolean) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + socket.destroy(); + resolve(delivered); + }; + socket.on("error", () => finish(false)); + socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); + socket.on("data", () => finish(true)); + socket.on("end", () => finish(false)); + timer = setTimeout(() => finish(false), timeoutMs); + timer.unref?.(); + }); +} + +async function send(request: unknown): Promise { + if (await sendAttempt(request, 500)) return; + await sendAttempt(request, 1500); +} + +async function reportTokens(tokens: Record): Promise { + if (!isHerdrEnvironment()) return; + await send({ + id: `${SOURCE}:${Date.now()}:${Math.random().toString(36).slice(2)}`, + method: "pane.report_metadata", + params: { + pane_id: process.env.HERDR_PANE_ID, + source: SOURCE, + tokens, + seq: nextSeq(), + }, + }); +} + +export function reportCurrentTask(tasks: Task[]): Promise { + return reportTokens(currentTaskTokens(tasks)); +} + +export function clearCurrentTask(): Promise { + return reportTokens({ [TASK_TOKEN]: null, [COUNT_TOKEN]: null, [PROGRESS_TOKEN]: null }); +} diff --git a/extensions/todo-herdr/src/limits.ts b/extensions/todo-herdr/src/limits.ts new file mode 100644 index 0000000..c42d506 --- /dev/null +++ b/extensions/todo-herdr/src/limits.ts @@ -0,0 +1,21 @@ +export const MAX_TASKS = 200; +export const MAX_TASKS_PER_MUTATION = 50; +export const MAX_TASK_DEPTH = 3; +export const MAX_TASK_NAME_LENGTH = 200; +export const MAX_TASK_DESCRIPTION_LENGTH = 4_000; +export const MAX_WIDGET_TASK_ROWS = 12; + +export const TASK_NAME_PATTERN = + "^[^\\u0000-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F]*$"; +export const TASK_DESCRIPTION_PATTERN = + "^[^\\u0000-\\u0009\\u000B-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F]*$"; + +const UNSAFE_TASK_TEXT = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u206F]/u; + +export function hasUnsafeTaskText(value: string): boolean { + return UNSAFE_TASK_TEXT.test(value); +} + +export function taskTextLength(value: string): number { + return [...value].length; +} diff --git a/extensions/todo-herdr/src/prompt.ts b/extensions/todo-herdr/src/prompt.ts new file mode 100644 index 0000000..524ebda --- /dev/null +++ b/extensions/todo-herdr/src/prompt.ts @@ -0,0 +1,22 @@ +import type { Task } from "./types.ts"; +import { orderedTreeRows } from "./ui.ts"; + +export const COMPACTION_REMINDER_TYPE = "pi-todo-herdr-compaction"; +export const COMPACTION_REMINDER = + "Active tasks exist. Call list_task now; treat its result as authoritative."; + +export const TASK_PROMPT_GUIDELINES = [ + "For work with 3+ distinct steps, explore enough to understand the scope, then call set_tasks before implementation; otherwise skip tracking. Start one task in-progress.", + "Use patch_tasks when tasks start, block, or finish; keep statuses accurate before the final response.", +]; + +export function formatCompactTaskTree(tasks: Task[]): string { + if (tasks.length === 0) return "No tasks."; + return orderedTreeRows(tasks) + .map(({ task, prefix }) => `${prefix}${task.id} [${task.status}] ${task.name}`) + .join("\n"); +} + +export function hasUnfinishedTasks(tasks: Task[]): boolean { + return tasks.some((task) => task.status !== "done"); +} diff --git a/extensions/todo-herdr/src/state.ts b/extensions/todo-herdr/src/state.ts new file mode 100644 index 0000000..d3a7c61 --- /dev/null +++ b/extensions/todo-herdr/src/state.ts @@ -0,0 +1,682 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { + hasUnsafeTaskText, + MAX_TASK_DEPTH, + MAX_TASK_DESCRIPTION_LENGTH, + MAX_TASK_NAME_LENGTH, + MAX_TASKS, + MAX_TASKS_PER_MUTATION, + taskTextLength, +} from "./limits.ts"; +import type { + AddTaskDraft, + Task, + TaskPatch, + TaskSnapshot, + TaskStats, + TaskStatus, + TaskTimingSnapshot, + TaskToolDetails, +} from "./types.ts"; +import { TaskValidationError } from "./types.ts"; + +const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "patch_tasks", "rm_task"]); +export const TASK_STATE_ENTRY_TYPE = "pi-todo-herdr-state"; +const VALID_STATUSES = new Set(["pending", "in-progress", "blocked", "done"]); +const MAX_DATE_MS = 8_640_000_000_000_000; + +interface TaskRecord { + id: number; + name: string; + description?: string; + status: TaskStatus; + parentId: number | null; + startedAt?: number; + activeStartedAt?: number; + stoppedAt?: number; + accumulatedMs: number; +} + +function cloneRecords(records: TaskRecord[]): TaskRecord[] { + return records.map((task) => ({ ...task })); +} + +function indexRecords(records: TaskRecord[]): Map { + return new Map(records.map((task) => [task.id, task])); +} + +function childIds(records: TaskRecord[], parentId: number | null): number[] { + return records.filter((task) => task.parentId === parentId).map((task) => task.id); +} + +function snapshotTasks(records: TaskRecord[]): Task[] { + return records.map((task) => ({ + id: task.id, + name: task.name, + ...(task.description !== undefined ? { description: task.description } : {}), + status: task.status, + parentId: task.parentId, + childrenIds: childIds(records, task.id), + })); +} + +function durationMs(task: TaskRecord, now: number): number { + const activeMs = task.activeStartedAt === undefined ? 0 : Math.max(0, now - task.activeStartedAt); + return Math.min(MAX_DATE_MS, task.accumulatedMs + activeMs); +} + +function publicTasks(records: TaskRecord[], now: number): Task[] { + const byId = indexRecords(records); + return snapshotTasks(records).map((task) => { + const record = byId.get(task.id); + if (!record || record.startedAt === undefined) return task; + return { + ...task, + startedAt: new Date(record.startedAt).toISOString(), + ...(record.stoppedAt !== undefined ? { stoppedAt: new Date(record.stoppedAt).toISOString() } : {}), + durationMs: durationMs(record, now), + }; + }); +} + +function timingSnapshots(records: TaskRecord[]): TaskTimingSnapshot[] { + const timings: TaskTimingSnapshot[] = []; + for (const task of records) { + if (task.startedAt === undefined) continue; + timings.push({ + taskId: task.id, + startedAt: task.startedAt, + ...(task.activeStartedAt !== undefined ? { activeStartedAt: task.activeStartedAt } : {}), + ...(task.stoppedAt !== undefined ? { stoppedAt: task.stoppedAt } : {}), + accumulatedMs: task.accumulatedMs, + }); + } + return timings; +} + +function startTimer(task: TaskRecord, now: number): void { + const startedAt = Math.max(now, task.stoppedAt ?? task.startedAt ?? 0); + task.startedAt ??= startedAt; + task.activeStartedAt = startedAt; + task.stoppedAt = undefined; +} + +function stopTimer(task: TaskRecord, now: number): void { + if (task.activeStartedAt === undefined) return; + const stoppedAt = Math.max(now, task.activeStartedAt); + task.accumulatedMs = Math.min(MAX_DATE_MS, task.accumulatedMs + stoppedAt - task.activeStartedAt); + task.activeStartedAt = undefined; + task.stoppedAt = stoppedAt; +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function isValidTimestamp(value: unknown): value is number { + return isNonNegativeSafeInteger(value) && value <= MAX_DATE_MS; +} + +function collectDescendantIds(records: TaskRecord[], rootId: number): number[] { + const result: number[] = []; + const visit = (id: number) => { + for (const childId of childIds(records, id)) { + result.push(childId); + visit(childId); + } + }; + visit(rootId); + return result; +} + +function validateGraph(records: TaskRecord[]): string[] { + const errors: string[] = []; + const byId = indexRecords(records); + const ids = new Set(); + + for (const task of records) { + if (!Number.isSafeInteger(task.id) || task.id < 1) { + errors.push(`task ${task.id}: id must be a positive safe integer`); + } + if (ids.has(task.id)) errors.push(`task ${task.id}: duplicate id`); + ids.add(task.id); + if (!task.name.trim()) errors.push(`task ${task.id}: name must not be empty`); + if (/[\r\n]/.test(task.name)) errors.push(`task ${task.id}: name must be a single line`); + if (hasUnsafeTaskText(task.name)) errors.push(`task ${task.id}: name contains an unsafe control character`); + if (taskTextLength(task.name) > MAX_TASK_NAME_LENGTH) { + errors.push(`task ${task.id}: name must be at most ${MAX_TASK_NAME_LENGTH} characters`); + } + if (task.description !== undefined) { + if (!task.description.trim()) errors.push(`task ${task.id}: description must not be empty`); + if (taskTextLength(task.description) > MAX_TASK_DESCRIPTION_LENGTH) { + errors.push(`task ${task.id}: description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`); + } + if (hasUnsafeTaskText(task.description)) { + errors.push(`task ${task.id}: description contains an unsafe control character`); + } + } + if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`); + if (!isNonNegativeSafeInteger(task.accumulatedMs) || task.accumulatedMs > MAX_DATE_MS) { + errors.push(`task ${task.id}: accumulatedMs must be an integer between 0 and ${MAX_DATE_MS}`); + } + if (task.startedAt === undefined) { + if (task.activeStartedAt !== undefined || task.stoppedAt !== undefined || task.accumulatedMs !== 0) { + errors.push(`task ${task.id}: timer fields require startedAt`); + } + if (task.status === "in-progress") errors.push(`task ${task.id}: in-progress task is missing timer data`); + } else { + if (!isValidTimestamp(task.startedAt)) { + errors.push(`task ${task.id}: startedAt must be a valid non-negative timestamp`); + } + if (task.activeStartedAt !== undefined && !isValidTimestamp(task.activeStartedAt)) { + errors.push(`task ${task.id}: activeStartedAt must be a valid non-negative timestamp`); + } + if (task.stoppedAt !== undefined && !isValidTimestamp(task.stoppedAt)) { + errors.push(`task ${task.id}: stoppedAt must be a valid non-negative timestamp`); + } + if (task.activeStartedAt !== undefined && task.activeStartedAt < task.startedAt) { + errors.push(`task ${task.id}: activeStartedAt must not precede startedAt`); + } + if (task.stoppedAt !== undefined && task.stoppedAt < task.startedAt) { + errors.push(`task ${task.id}: stoppedAt must not precede startedAt`); + } + if (task.status === "in-progress") { + if (task.activeStartedAt === undefined) errors.push(`task ${task.id}: active timer is missing its segment start`); + if (task.stoppedAt !== undefined) errors.push(`task ${task.id}: active timer must not have stoppedAt`); + } else { + if (task.activeStartedAt !== undefined) errors.push(`task ${task.id}: inactive task has an active timer segment`); + if (task.stoppedAt === undefined) errors.push(`task ${task.id}: paused timer is missing stoppedAt`); + } + } + if (task.parentId !== null && !byId.has(task.parentId)) { + errors.push(`task ${task.id}: parent ${task.parentId} does not exist`); + } + } + + const visiting = new Set(); + const visited = new Set(); + const visit = (id: number): boolean => { + if (visiting.has(id)) return true; + if (visited.has(id)) return false; + visiting.add(id); + const parentId = byId.get(id)?.parentId; + if (parentId !== null && parentId !== undefined && visit(parentId)) return true; + visiting.delete(id); + visited.add(id); + return false; + }; + + for (const task of records) { + if (visit(task.id)) { + errors.push(`task ${task.id}: parentId creates a cycle`); + break; + } + } + + if (errors.length > 0) return errors; + + const depths = new Map(); + const depthOf = (task: TaskRecord): number => { + const cached = depths.get(task.id); + if (cached !== undefined) return cached; + const parent = task.parentId === null ? undefined : byId.get(task.parentId); + const depth = parent === undefined ? 1 : depthOf(parent) + 1; + depths.set(task.id, depth); + return depth; + }; + for (const task of records) { + if (depthOf(task) > MAX_TASK_DEPTH) { + errors.push(`task ${task.id}: depth must not exceed ${MAX_TASK_DEPTH} levels`); + } + } + if (errors.length > 0) return errors; + + for (const task of records) { + if (task.status !== "done") continue; + const unfinished = collectDescendantIds(records, task.id).filter((id) => byId.get(id)?.status !== "done"); + if (unfinished.length > 0) { + errors.push(`task ${task.id}: done task has unfinished descendants ${unfinished.join(", ")}`); + } + } + + return errors; +} + +function insertAtSiblingPosition(records: TaskRecord[], id: number, position: number): string | undefined { + const byId = indexRecords(records); + const task = byId.get(id); + if (!task) return `task ${id}: does not exist`; + + const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id); + if (!Number.isInteger(position) || position < 0 || position > siblings.length) { + return `task ${id}: position must be between 0 and ${siblings.length}`; + } + + const recordIndex = records.findIndex((record) => record.id === id); + const [record] = records.splice(recordIndex, 1); + if (!record) return `task ${id}: does not exist`; + + if (position < siblings.length) { + const targetIndex = records.findIndex((candidate) => candidate.id === siblings[position]); + records.splice(targetIndex, 0, record); + return; + } + + if (siblings.length === 0) { + records.push(record); + return; + } + + const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]); + records.splice(lastSiblingIndex + 1, 0, record); +} + +function moveToSiblingEnd(records: TaskRecord[], id: number): void { + const task = records.find((candidate) => candidate.id === id); + if (!task) return; + const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id); + const currentIndex = records.findIndex((candidate) => candidate.id === id); + const [record] = records.splice(currentIndex, 1); + if (!record) return; + if (siblings.length === 0) { + records.push(record); + return; + } + const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]); + records.splice(lastSiblingIndex + 1, 0, record); +} + +export class TaskState { + private records: TaskRecord[] = []; + private nextId = 1; + private readonly now: () => number; + + constructor(now: () => number = Date.now) { + this.now = now; + } + + private currentTime(): number { + const now = Math.floor(this.now()); + if (!isValidTimestamp(now)) throw new Error("task clock must return a valid non-negative timestamp"); + return now; + } + + list(): Task[] { + const now = this.records.some((task) => task.startedAt !== undefined) ? this.currentTime() : 0; + return publicTasks(this.records, now); + } + + get(ids: number[]): Task[] { + const byId = new Map(this.list().map((task) => [task.id, task])); + return ids.map((id) => byId.get(id)).filter((task): task is Task => task !== undefined); + } + + snapshot(): TaskSnapshot { + const timings = timingSnapshots(this.records); + return { + tasks: snapshotTasks(this.records), + nextId: this.nextId, + ...(timings.length > 0 ? { timings } : {}), + }; + } + + stats(): TaskStats { + const tasks = this.records; + return { + total: tasks.length, + pending: tasks.filter((task) => task.status === "pending").length, + inProgress: tasks.filter((task) => task.status === "in-progress").length, + blocked: tasks.filter((task) => task.status === "blocked").length, + done: tasks.filter((task) => task.status === "done").length, + }; + } + + add(drafts: AddTaskDraft[]): Task[] { + const errors: string[] = []; + if (!Array.isArray(drafts) || drafts.length === 0) throw new TaskValidationError(["tasks must not be empty"]); + if (drafts.length > MAX_TASKS_PER_MUTATION) { + throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`]); + } + + const working = cloneRecords(this.records); + const existingIds = new Set(working.map((task) => task.id)); + const now = this.currentTime(); + let nextId = this.nextId; + const addedIds: number[] = []; + let mutationTaskCount = 0; + + const append = ( + draft: AddTaskDraft, + implicitParentId: number | null, + path: string, + nested: boolean, + depth: number, + ) => { + mutationTaskCount += 1; + if (mutationTaskCount > MAX_TASKS_PER_MUTATION) { + errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`); + return; + } + if (depth > MAX_TASK_DEPTH) { + errors.push(`${path}: depth must not exceed ${MAX_TASK_DEPTH} levels`); + return; + } + if (working.length >= MAX_TASKS) { + errors.push(`task session is limited to ${MAX_TASKS} tasks`); + return; + } + if (!draft || typeof draft !== "object") { + errors.push(`${path}: task must be an object`); + return; + } + if (typeof draft.name !== "string" || !draft.name.trim()) errors.push(`${path}.name must not be empty`); + if (typeof draft.name === "string" && /[\r\n]/.test(draft.name)) errors.push(`${path}.name must be a single line`); + if (typeof draft.name === "string" && hasUnsafeTaskText(draft.name)) { + errors.push(`${path}.name contains an unsafe control character`); + } + if (typeof draft.name === "string" && taskTextLength(draft.name.trim()) > MAX_TASK_NAME_LENGTH) { + errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`); + } + if (draft.description !== undefined && draft.description !== null) { + if (typeof draft.description !== "string" || !draft.description.trim()) { + errors.push(`${path}.description must not be empty`); + } else { + if (hasUnsafeTaskText(draft.description)) { + errors.push(`${path}.description contains an unsafe control character`); + } + if (taskTextLength(draft.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) { + errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`); + } + } + } + if (draft.status !== undefined && draft.status !== null && !VALID_STATUSES.has(draft.status)) { + errors.push(`${path}.status is invalid`); + } + if (nested && draft.parentId !== undefined && draft.parentId !== null) { + errors.push(`${path}.parentId is not allowed inside children`); + } + if (draft.children !== undefined && !Array.isArray(draft.children)) errors.push(`${path}.children must be an array`); + + let parentId = implicitParentId; + if (!nested && draft.parentId !== undefined && draft.parentId !== null) { + if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) { + errors.push(`${path}.parentId ${draft.parentId} does not exist`); + } else { + parentId = draft.parentId; + } + } + + if (nextId >= Number.MAX_SAFE_INTEGER) { + errors.push("no safe task IDs remain"); + return; + } + const id = nextId++; + const status = draft.status ?? "pending"; + addedIds.push(id); + working.push({ + id, + name: typeof draft.name === "string" ? draft.name.trim() : "", + ...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}), + status, + parentId, + accumulatedMs: 0, + ...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}), + }); + + const children = Array.isArray(draft.children) ? draft.children : []; + for (const [index, child] of children.entries()) { + if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) { + errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`); + break; + } + append(child, id, `${path}.children[${index}]`, true, depth + 1); + } + }; + + for (const [index, draft] of drafts.entries()) { + if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) { + errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`); + break; + } + append(draft, null, `tasks[${index}]`, false, 1); + } + errors.push(...validateGraph(working)); + if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]); + + this.records = working; + this.nextId = nextId; + return this.get(addedIds); + } + + set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] { + if (mode === "append") { + if (force === true) throw new TaskValidationError(["force must be false in append mode"]); + return this.add(drafts); + } + if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]); + if (!Array.isArray(drafts)) throw new TaskValidationError(["tasks must be an array"]); + + const errors: string[] = []; + if (this.records.some((task) => task.status !== "done") && force !== true) { + errors.push("replace requires force when unfinished tasks exist"); + } + drafts.forEach((draft, index) => { + if (draft?.parentId !== undefined && draft.parentId !== null) { + errors.push(`tasks[${index}].parentId is not allowed in replace mode`); + } + }); + if (errors.length > 0) throw new TaskValidationError(errors); + + const replacement = new TaskState(this.now); + const affected = drafts.length === 0 ? [] : replacement.add(drafts); + this.restore(replacement.snapshot()); + return affected; + } + + update(patches: TaskPatch[]): Task[] { + const errors: string[] = []; + if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]); + if (patches.length > MAX_TASKS_PER_MUTATION) { + throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} patches`]); + } + + const duplicateIds = patches + .map((patch) => patch.id) + .filter((id, index, ids) => ids.indexOf(id) !== index); + if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`); + + const working = cloneRecords(this.records); + const now = this.currentTime(); + for (const [index, patch] of patches.entries()) { + const path = `tasks[${index}]`; + const task = working.find((candidate) => candidate.id === patch.id); + if (!task) { + errors.push(`${path}.id ${patch.id} does not exist`); + continue; + } + + const hasNameChange = patch.name !== undefined && patch.name !== null; + const hasDescriptionChange = patch.description !== undefined && patch.description !== null; + const hasStatusChange = patch.status !== undefined && patch.status !== null; + const hasParentChange = patch.parentId !== undefined && patch.parentId !== -1; + const hasPositionChange = patch.position !== undefined && patch.position !== null && patch.position !== -1; + if (!hasNameChange && !hasDescriptionChange && !hasStatusChange && !hasParentChange && !hasPositionChange) { + errors.push(`${path} must include at least one field to update`); + } + if (hasNameChange && (typeof patch.name !== "string" || !patch.name.trim())) { + errors.push(`${path}.name must not be empty`); + } + if (typeof patch.name === "string" && /[\r\n]/.test(patch.name)) errors.push(`${path}.name must be a single line`); + if (typeof patch.name === "string" && hasUnsafeTaskText(patch.name)) { + errors.push(`${path}.name contains an unsafe control character`); + } + if (typeof patch.name === "string" && taskTextLength(patch.name.trim()) > MAX_TASK_NAME_LENGTH) { + errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`); + } + if (hasDescriptionChange && (typeof patch.description !== "string" || !patch.description.trim())) { + errors.push(`${path}.description must not be empty`); + } + if (typeof patch.description === "string") { + if (hasUnsafeTaskText(patch.description)) { + errors.push(`${path}.description contains an unsafe control character`); + } + if (taskTextLength(patch.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) { + errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`); + } + } + if (hasStatusChange && !VALID_STATUSES.has(patch.status as TaskStatus)) errors.push(`${path}.status is invalid`); + if ( + hasParentChange && + patch.parentId !== null && + patch.parentId !== 0 && + (!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId)) + ) { + errors.push(`${path}.parentId ${patch.parentId} does not exist`); + } + + if (patch.name !== undefined && patch.name !== null) task.name = patch.name.trim(); + if (patch.description !== undefined && patch.description !== null) task.description = patch.description.trim(); + if (patch.status !== undefined && patch.status !== null && patch.status !== task.status) { + if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now); + if (task.status !== "in-progress" && patch.status === "in-progress") startTimer(task, now); + task.status = patch.status; + } + if ( + patch.parentId !== undefined && + patch.parentId !== -1 && + (patch.parentId === null || + patch.parentId === 0 || + working.some((item) => item.id === patch.parentId)) + ) { + task.parentId = patch.parentId === 0 ? null : patch.parentId; + moveToSiblingEnd(working, task.id); + } + if (patch.position !== undefined && patch.position !== null && patch.position !== -1) { + const positionError = insertAtSiblingPosition(working, task.id, patch.position); + if (positionError) errors.push(`${path}.position: ${positionError}`); + } + } + + errors.push(...validateGraph(working)); + if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]); + + this.records = working; + return this.get(patches.map((patch) => patch.id)); + } + + remove(ids: number[]): Task[] { + if (!Array.isArray(ids) || ids.length === 0) throw new TaskValidationError(["ids must not be empty"]); + if (ids.length > MAX_TASKS_PER_MUTATION) { + throw new TaskValidationError([`ids must contain at most ${MAX_TASKS_PER_MUTATION} entries`]); + } + const uniqueIds = [...new Set(ids)]; + const missing = uniqueIds.filter((id) => !this.records.some((task) => task.id === id)); + if (missing.length > 0) throw new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]); + + const removedIds = new Set(); + for (const id of uniqueIds) { + removedIds.add(id); + for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId); + } + const now = this.currentTime(); + for (const task of this.records) { + if (removedIds.has(task.id) && task.status === "in-progress") stopTimer(task, now); + } + const removed = publicTasks(this.records, now).filter((task) => removedIds.has(task.id)); + this.records = this.records.filter((task) => !removedIds.has(task.id)); + return removed; + } + + restore(snapshot: TaskSnapshot): void { + if (!snapshot || !Array.isArray(snapshot.tasks)) { + throw new TaskValidationError(["invalid task snapshot"]); + } + if (!Number.isSafeInteger(snapshot.nextId) || snapshot.nextId < 1) { + throw new TaskValidationError(["nextId must be a positive safe integer"]); + } + if (snapshot.timings !== undefined && !Array.isArray(snapshot.timings)) { + throw new TaskValidationError(["invalid task snapshot timings"]); + } + if (snapshot.tasks.length > MAX_TASKS) { + throw new TaskValidationError([`task session is limited to ${MAX_TASKS} tasks`]); + } + if ((snapshot.timings?.length ?? 0) > MAX_TASKS) { + throw new TaskValidationError([`task snapshot timings are limited to ${MAX_TASKS} entries`]); + } + + const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({ + id, + name, + description, + status, + parentId, + accumulatedMs: 0, + })); + const byId = indexRecords(records); + const timingErrors: string[] = []; + const timedIds = new Set(); + + for (const [index, timing] of (snapshot.timings ?? []).entries()) { + const path = `timings[${index}]`; + if (!timing || typeof timing !== "object") { + timingErrors.push(`${path} must be an object`); + continue; + } + if (!Number.isSafeInteger(timing.taskId) || timing.taskId < 1) { + timingErrors.push(`${path}.taskId must be a positive safe integer`); + continue; + } + if (timedIds.has(timing.taskId)) { + timingErrors.push(`${path}.taskId ${timing.taskId} is duplicated`); + continue; + } + timedIds.add(timing.taskId); + const task = byId.get(timing.taskId); + if (!task) { + timingErrors.push(`${path}.taskId ${timing.taskId} does not exist`); + continue; + } + task.startedAt = timing.startedAt; + task.activeStartedAt = timing.activeStartedAt; + task.stoppedAt = timing.stoppedAt; + task.accumulatedMs = timing.accumulatedMs; + } + + if (timingErrors.length > 0) throw new TaskValidationError(timingErrors); + if (snapshot.timings === undefined && records.some((task) => task.status === "in-progress")) { + const now = this.currentTime(); + for (const task of records) { + if (task.status === "in-progress") startTimer(task, now); + } + } + + const errors = validateGraph(records); + const maxId = records.reduce((max, task) => Math.max(max, task.id), 0); + if (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`); + if (errors.length > 0) throw new TaskValidationError(errors); + this.records = cloneRecords(records); + this.nextId = snapshot.nextId; + } + + loadFromSession(ctx: ExtensionContext): void { + this.records = []; + this.nextId = 1; + for (const entry of ctx.sessionManager.getBranch()) { + let snapshot: TaskSnapshot | undefined; + if (entry.type === "custom" && entry.customType === TASK_STATE_ENTRY_TYPE) { + snapshot = entry.data as TaskSnapshot | undefined; + } else if (entry.type === "message") { + const message = entry.message; + if (message.role === "toolResult" && MUTATION_TOOLS.has(message.toolName)) { + snapshot = (message.details as TaskToolDetails | undefined)?.snapshot; + } + } + if (!snapshot) continue; + try { + this.restore(snapshot); + } catch { + // Ignore corrupt historical snapshots and keep the latest valid state. + } + } + } +} diff --git a/extensions/todo-herdr/src/tools.ts b/extensions/todo-herdr/src/tools.ts new file mode 100644 index 0000000..f71d448 --- /dev/null +++ b/extensions/todo-herdr/src/tools.ts @@ -0,0 +1,471 @@ +import { StringEnum } from "@earendil-works/pi-ai"; +import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; +import { Text } from "@earendil-works/pi-tui"; +import { Type, type Static } from "typebox"; +import { + MAX_TASK_DEPTH, + MAX_TASK_DESCRIPTION_LENGTH, + MAX_TASK_NAME_LENGTH, + MAX_TASKS_PER_MUTATION, + TASK_DESCRIPTION_PATTERN, + TASK_NAME_PATTERN, +} from "./limits.ts"; +import { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts"; +import type { TaskState } from "./state.ts"; +import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts"; +import { TaskValidationError } from "./types.ts"; +import { renderToolResult } from "./ui.ts"; + +const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, { + description: "Task status.", +}); + +const SetTaskModeSchema = StringEnum(["append", "replace"] as const); +const RemovalReasonSchema = StringEnum(["mistaken", "duplicate", "canceled"] as const); + +/** + * TypeBox's Type.Cyclic emits bare local refs ("AddTask") instead of URI + * references ("#/$defs/AddTask"). Strict JSON Schema gateways (e.g. the + * moonshot-flavored validator behind Console Go) reject those with a 400. + * Rewrite every local $ref in place to the #/$defs/ form. + */ +function normalizeLocalRefs(schema: T): T { + if (Array.isArray(schema)) { + for (const item of schema) normalizeLocalRefs(item); + return schema; + } + if (schema && typeof schema === "object") { + for (const [key, value] of Object.entries(schema)) { + if (key === "$ref" && typeof value === "string" && !value.startsWith("#") && !value.includes("://")) { + (schema as Record)[key] = `#/$defs/${value}`; + } else { + normalizeLocalRefs(value); + } + } + } + return schema; +} + +/** + * TypeBox's Type.Cyclic nests $defs inside the referenced item schema, but + * refs like "#/$defs/AddTask" resolve from the document root. Strict gateways + * (moonshot-flavored) reject a root-style ref when the definition is nested. + * Hoist every $defs entry to the schema root and drop the synthetic $id, which + * would otherwise change fragment resolution inside the definition. + */ +function hoistDefsToRoot(schema: T): T { + const defs = new Map(); + function walk(node: unknown): void { + if (Array.isArray(node)) { + for (const item of node) walk(item); + return; + } + if (!node || typeof node !== "object") return; + const obj = node as Record; + const nested = obj["$defs"]; + if (nested && typeof nested === "object") { + for (const [name, def] of Object.entries(nested)) { + if (!defs.has(name)) defs.set(name, def); + } + delete obj["$defs"]; + } + for (const value of Object.values(obj)) walk(value); + } + walk(schema); + for (const def of defs.values()) { + if (def && typeof def === "object") { + delete (def as Record)["$id"]; + } + } + if (defs.size > 0) (schema as Record)["$defs"] = Object.fromEntries(defs); + return schema; +} + +const AddTaskSchema = Type.Cyclic( + { + AddTask: Type.Object( + { + name: Type.String({ + minLength: 1, + maxLength: MAX_TASK_NAME_LENGTH, + pattern: TASK_NAME_PATTERN, + }), + description: Type.Union( + [ + Type.String({ + minLength: 1, + maxLength: MAX_TASK_DESCRIPTION_LENGTH, + pattern: TASK_DESCRIPTION_PATTERN, + }), + Type.Null(), + ], + { + description: "Detailed scope, constraints, and done criteria; use null when no detail is needed.", + }, + ), + status: TaskStatusSchema, + parentId: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], { + description: "Existing parent ID for a top-level append; use null for roots, nested children, and replace mode.", + }), + children: Type.Array(Type.Ref("AddTask"), { maxItems: MAX_TASKS_PER_MUTATION }), + }, + { additionalProperties: false }, + ), + }, + "AddTask", +); + +export const SetTaskParams = hoistDefsToRoot( + normalizeLocalRefs( + Type.Object( + { + mode: SetTaskModeSchema, + tasks: Type.Array(AddTaskSchema, { + maxItems: MAX_TASKS_PER_MUTATION, + description: "Tasks to append or install. May be empty only in replace mode.", + }), + force: Type.Boolean({ + description: "Set true only when the user explicitly abandons unfinished tasks; otherwise use false.", + }), + }, + { additionalProperties: false }, + ), + ), +); + +const TaskPatchSchema = Type.Object( + { + id: Type.Integer({ minimum: 1 }), + name: Type.Union( + [ + Type.String({ minLength: 1, maxLength: MAX_TASK_NAME_LENGTH, pattern: TASK_NAME_PATTERN }), + Type.Null(), + ], + { description: "New name, or null to leave it unchanged." }, + ), + description: Type.Union( + [ + Type.String({ + minLength: 1, + maxLength: MAX_TASK_DESCRIPTION_LENGTH, + pattern: TASK_DESCRIPTION_PATTERN, + }), + Type.Null(), + ], + { description: "New description, or null to leave it unchanged." }, + ), + status: Type.Union([TaskStatusSchema, Type.Null()], { + description: "New status, or null to leave it unchanged.", + }), + parentId: Type.Integer({ + minimum: -1, + description: "New parent ID; use -1 to leave it unchanged and 0 to move to root.", + }), + position: Type.Integer({ + minimum: -1, + description: "Zero-based position among final siblings, or -1 to leave it unchanged.", + }), + }, + { additionalProperties: false }, +); + +export const PatchTasksParams = Type.Object( + { + tasks: Type.Array(TaskPatchSchema, { + minItems: 1, + maxItems: MAX_TASKS_PER_MUTATION, + description: "Task patches applied atomically in array order.", + }), + }, + { additionalProperties: false }, +); + +const TaskIdsSchema = Type.Array(Type.Integer({ minimum: 1 }), { + minItems: 1, + maxItems: MAX_TASKS_PER_MUTATION, + description: "Task IDs to process. Duplicate IDs are deduplicated.", +}); + +export const RemoveTaskParams = Type.Object( + { + ids: TaskIdsSchema, + reason: RemovalReasonSchema, + }, + { additionalProperties: false }, +); + +export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalProperties: false }); + +export const ListTaskParams = Type.Object({}, { additionalProperties: false }); + +interface DraftPreparationState { + count: number; +} + +function prepareTaskDraft(value: unknown, depth: number, state: DraftPreparationState): unknown { + state.count += 1; + if (depth > MAX_TASK_DEPTH || state.count > MAX_TASKS_PER_MUTATION) return {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + + const input = value as Record; + const children = + input.children === undefined + ? [] + : Array.isArray(input.children) + ? input.children + .slice(0, MAX_TASKS_PER_MUTATION + 1) + .map((child) => prepareTaskDraft(child, depth + 1, state)) + : input.children; + return { + ...input, + description: input.description === undefined ? null : input.description, + status: input.status === undefined ? "pending" : input.status, + parentId: input.parentId === undefined ? null : input.parentId, + children, + }; +} + +function prepareSetTaskArguments(value: unknown): Static { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value as Static; + } + const input = value as Record; + const state: DraftPreparationState = { count: 0 }; + return { + ...input, + tasks: Array.isArray(input.tasks) + ? input.tasks + .slice(0, MAX_TASKS_PER_MUTATION + 1) + .map((task) => prepareTaskDraft(task, 1, state)) + : input.tasks, + force: input.force === undefined ? false : input.force, + } as Static; +} + +function prepareTaskPatch(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const input = value as Record; + return { + ...input, + name: input.name === undefined ? null : input.name, + description: input.description === undefined ? null : input.description, + status: input.status === undefined ? null : input.status, + parentId: input.parentId === undefined ? -1 : input.parentId === null ? 0 : input.parentId, + position: input.position === undefined || input.position === null ? -1 : input.position, + }; +} + +function preparePatchTasksArguments(value: unknown): Static { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value as Static; + } + const input = value as Record; + return { + ...input, + tasks: Array.isArray(input.tasks) + ? input.tasks.slice(0, MAX_TASKS_PER_MUTATION + 1).map(prepareTaskPatch) + : input.tasks, + } as Static; +} + +interface SetTaskInput { + mode: "append" | "replace"; + tasks: AddTaskDraft[]; + force?: boolean; +} + +interface PatchTasksInput { + tasks: TaskPatch[]; +} + +interface IdsInput { + ids: number[]; +} + +interface RemoveTaskInput extends IdsInput { + reason: "mistaken" | "duplicate" | "canceled"; +} + +type ChangeHandler = (ctx: ExtensionContext, restartAutoClear?: boolean) => void; + +function taskIds(tasks: Task[]): string { + return tasks.map((task) => task.id).join(", "); +} + +function details(action: TaskToolDetails["action"], state: TaskState, affected: Task[]): TaskToolDetails { + return { action, snapshot: state.snapshot(), affected }; +} + +function mutationText(verb: string, affected: Task[], state: TaskState): string { + const stats = state.stats(); + return `${verb} ${affected.length} task${affected.length === 1 ? "" : "s"}: ${taskIds(affected)}. ${stats.done}/${stats.total} done.`; +} + +function setTaskText(mode: SetTaskInput["mode"], affected: Task[], state: TaskState): string { + if (mode === "append") return mutationText("Added", affected, state); + if (affected.length === 0) return "Cleared task tree. Task IDs reset to 1."; + const stats = state.stats(); + return `Replaced task tree with ${affected.length} task${affected.length === 1 ? "" : "s"}. ${stats.done}/${stats.total} done. Task IDs restart at 1.`; +} + +function throwToolError(label: string, error: unknown): never { + if (error instanceof TaskValidationError) { + const count = error.errors.length; + throw new Error( + `${label} failed · ${count} validation error${count === 1 ? "" : "s"}\n${error.errors.map((item) => `- ${item}`).join("\n")}`, + ); + } + throw error; +} + +function renderCall(title: string, count: number | undefined, theme: Theme): Text { + const suffix = count === undefined ? "" : theme.fg("dim", ` (${count})`); + return new Text(theme.fg("toolTitle", theme.bold(title)) + suffix, 0, 0); +} + +function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext, restartAutoClear = false): void { + try { + onChange(ctx, restartAutoClear); + } catch (error) { + console.error("[pi-todo-herdr] failed to refresh task UI:", error); + } +} + +export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: ChangeHandler): void { + pi.registerTool({ + name: "set_tasks", + label: "Set Tasks", + description: + `Atomically append tasks or replace the task tree. Supply at most ${MAX_TASKS_PER_MUTATION} total tasks across ${MAX_TASK_DEPTH} stored levels: root categories, child items, and leaf sub-items beneath the widget's virtual root. Pass force false unless unfinished work is explicitly discarded.`, + promptGuidelines: [...TASK_PROMPT_GUIDELINES], + parameters: SetTaskParams, + prepareArguments: prepareSetTaskArguments, + async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) { + try { + const affected = state.set(params.mode, params.tasks, params.force); + notifyChange(onChange, ctx, params.mode === "replace"); + return { + content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }], + details: details(params.mode, state, affected), + }; + } catch (error) { + throwToolError("Set", error); + } + }, + renderCall(args: SetTaskInput, theme: Theme) { + return renderCall(`Set Tasks · ${args.mode}`, args.tasks?.length, theme); + }, + renderResult(result, { expanded }, theme) { + return renderToolResult(result, expanded, theme); + }, + }); + + pi.registerTool({ + name: "patch_tasks", + label: "Patch Tasks", + description: + "Atomically patch tasks. Use null for unchanged text/status, parentId -1 for unchanged or 0 for root, and position -1 for unchanged. Completed task trees may auto-clear; do not recreate cleared completed tasks.", + parameters: PatchTasksParams, + prepareArguments: preparePatchTasksArguments, + async execute(_toolCallId, params: PatchTasksInput, _signal, _onUpdate, ctx) { + try { + if (state.list().length === 0) { + throw new TaskValidationError([ + "task tree is empty and may have auto-cleared after completion; do not recreate completed tasks. Use set_tasks only for genuinely new or remaining work", + ]); + } + const affected = state.update(params.tasks); + notifyChange(onChange, ctx); + return { + content: [{ type: "text" as const, text: mutationText("Updated", affected, state) }], + details: details("update", state, affected), + }; + } catch (error) { + throwToolError("Patch", error); + } + }, + renderCall(args: PatchTasksInput, theme: Theme) { + return renderCall("Patch Tasks", args.tasks?.length, theme); + }, + renderResult(result, { expanded }, theme) { + return renderToolResult(result, expanded, theme); + }, + }); + + pi.registerTool({ + name: "rm_task", + label: "Remove Tasks", + description: + "Remove tasks only when mistaken, duplicate, or canceled. Removing a parent also removes its descendant subtree.", + parameters: RemoveTaskParams, + async execute(_toolCallId, params: RemoveTaskInput, _signal, _onUpdate, ctx) { + try { + const affected = state.remove(params.ids); + notifyChange(onChange, ctx); + return { + content: [ + { + type: "text" as const, + text: `${mutationText("Removed", affected, state)} Reason: ${params.reason}.`, + }, + ], + details: details("remove", state, affected), + }; + } catch (error) { + throwToolError("Remove", error); + } + }, + renderCall(args: RemoveTaskInput, theme: Theme) { + return renderCall(`Remove Tasks · ${args.reason}`, args.ids?.length, theme); + }, + renderResult(result, { expanded }, theme) { + return renderToolResult(result, expanded, theme); + }, + }); + + pi.registerTool({ + name: "list_task", + label: "List Tasks", + description: "List the full task tree compactly with id, name, and status.", + parameters: ListTaskParams, + async execute() { + const affected = state.list(); + return { + content: [{ type: "text" as const, text: formatCompactTaskTree(affected) }], + details: details("list", state, affected), + }; + }, + renderCall(_args, theme: Theme) { + return renderCall("List Tasks", undefined, theme); + }, + renderResult(result, { expanded }, theme) { + return renderToolResult(result, expanded, theme); + }, + }); + + pi.registerTool({ + name: "get_task", + label: "Get Tasks", + description: "Get complete details for one or more task IDs, including parent and child IDs.", + parameters: IdsParams, + async execute(_toolCallId, params: IdsInput) { + const uniqueIds = [...new Set(params.ids)]; + const affected = state.get(uniqueIds); + const found = new Set(affected.map((task) => task.id)); + const missing = uniqueIds.filter((id) => !found.has(id)); + if (missing.length > 0) { + throwToolError("Get", new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`])); + } + return { + content: [{ type: "text" as const, text: JSON.stringify(affected) }], + details: details("get", state, affected), + }; + }, + renderCall(args: IdsInput, theme: Theme) { + return renderCall("Get Tasks", args.ids?.length, theme); + }, + renderResult(result, { expanded }, theme) { + return renderToolResult(result, expanded, theme); + }, + }); +} diff --git a/extensions/todo-herdr/src/types.ts b/extensions/todo-herdr/src/types.ts new file mode 100644 index 0000000..567060d --- /dev/null +++ b/extensions/todo-herdr/src/types.ts @@ -0,0 +1,69 @@ +export type TaskStatus = "pending" | "in-progress" | "blocked" | "done"; + +export interface Task { + id: number; + name: string; + description?: string; + status: TaskStatus; + parentId: number | null; + childrenIds: number[]; + startedAt?: string; + stoppedAt?: string; + durationMs?: number; +} + +export interface TaskTimingSnapshot { + taskId: number; + startedAt: number; + activeStartedAt?: number; + stoppedAt?: number; + accumulatedMs: number; +} + +export interface TaskSnapshot { + tasks: Task[]; + nextId: number; + timings?: TaskTimingSnapshot[]; +} + +export interface AddTaskDraft { + name: string; + description?: string | null; + status?: TaskStatus | null; + parentId?: number | null; + children?: AddTaskDraft[]; +} + +export interface TaskPatch { + id: number; + name?: string | null; + description?: string | null; + status?: TaskStatus | null; + parentId?: number | null; + position?: number | null; +} + +export interface TaskStats { + total: number; + pending: number; + inProgress: number; + blocked: number; + done: number; +} + +export interface TaskToolDetails { + action: "add" | "append" | "replace" | "update" | "remove" | "list" | "get"; + snapshot: TaskSnapshot; + affected: Task[]; + errors?: string[]; +} + +export class TaskValidationError extends Error { + readonly errors: string[]; + + constructor(errors: string[]) { + super(errors.join("\n")); + this.name = "TaskValidationError"; + this.errors = errors; + } +} diff --git a/extensions/todo-herdr/src/ui.ts b/extensions/todo-herdr/src/ui.ts new file mode 100644 index 0000000..7375c1d --- /dev/null +++ b/extensions/todo-herdr/src/ui.ts @@ -0,0 +1,422 @@ +import type { Component } from "@earendil-works/pi-tui"; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { MAX_WIDGET_TASK_ROWS } from "./limits.ts"; +import type { Task, TaskStats, TaskToolDetails } from "./types.ts"; + +export const STATUS_ICONS = { + pending: "○", + "in-progress": "◉", + blocked: "!", + done: "✓", +} as const; + +const IN_PROGRESS_WIDGET_FRAMES = ["◐", "◓", "◑", "◒"] as const; + +interface DescendantStats { + done: number; + total: number; +} + +interface TreeRow { + task: Task; + prefix: string; + depth: number; + siblingIndex: number; + collapsed?: DescendantStats; +} + +function descendantStats(task: Task, byId: Map, memo: Map): DescendantStats { + const cached = memo.get(task.id); + if (cached) return cached; + + let done = 0; + let total = 0; + for (const childId of task.childrenIds) { + const child = byId.get(childId); + if (!child) continue; + + total += 1; + if (child.status === "done") done += 1; + const nested = descendantStats(child, byId, memo); + done += nested.done; + total += nested.total; + } + + const stats = { done, total }; + memo.set(task.id, stats); + return stats; +} + +function buildTreeRows(tasks: Task[], collapseCompleted: boolean, connectRoots = false): TreeRow[] { + const byId = new Map(tasks.map((task) => [task.id, task])); + const memo = new Map(); + const rows: TreeRow[] = []; + + const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => { + const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join(""); + const connector = depth === 0 && !connectRoots ? "" : isLast ? "└─ " : "├─ "; + const stats = collapseCompleted ? descendantStats(task, byId, memo) : undefined; + const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined; + rows.push({ task, prefix: `${ancestors}${connector}`, depth, siblingIndex, ...(collapsed ? { collapsed } : {}) }); + if (collapsed) return; + + const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined); + children.forEach((child, index) => { + const childAncestors = depth === 0 && !connectRoots ? [] : [...ancestorHasNext, !isLast]; + visit(child, childAncestors, index === children.length - 1, depth + 1, index); + }); + }; + + const roots = tasks.filter((task) => task.parentId === null); + roots.forEach((root, index) => visit(root, [], index === roots.length - 1, 0, index)); + return rows; +} + +export function orderedTreeRows(tasks: Task[]): TreeRow[] { + return buildTreeRows(tasks, false); +} + +function styleTaskName(task: Task, theme: Theme): string { + if (task.status === "done") return theme.fg("dim", theme.strikethrough(task.name)); + if (task.status === "in-progress") return theme.fg("warning", task.name); + return task.name; +} + +function styleStatusIcon(task: Task, theme: Theme, inProgressFrame?: number): string { + const frameIndex = inProgressFrame === undefined ? undefined : Math.abs(Math.trunc(inProgressFrame)); + const icon = + task.status === "in-progress" && frameIndex !== undefined + ? IN_PROGRESS_WIDGET_FRAMES[frameIndex % IN_PROGRESS_WIDGET_FRAMES.length] + : STATUS_ICONS[task.status]; + if (task.status === "done") return theme.fg("success", icon); + if (task.status === "in-progress") return theme.fg("warning", icon); + if (task.status === "blocked") return theme.fg("error", icon); + return theme.fg("dim", icon); +} + +export function formatDuration(durationMs: number): string { + const safeMs = Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0; + const totalSeconds = Math.floor(safeMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const minuteClock = [minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":"); + return hours === 0 ? minuteClock : `${String(hours).padStart(2, "0")}:${minuteClock}`; +} + +function renderTaskLineWithSuffix( + task: Task, + prefix: string, + theme: Theme, + width: number, + suffix?: string, + identifier = `${task.id}.`, + identifierBeforeStatus = false, + inProgressFrame?: number, +): string { + const status = styleStatusIcon(task, theme, inProgressFrame); + const styledIdentifier = identifier.length === 0 ? "" : theme.fg("accent", identifier); + const identifierFits = + !identifierBeforeStatus || + visibleWidth(prefix) + visibleWidth(styledIdentifier) + 1 + visibleWidth(status) <= width; + const visibleIdentifier = identifierFits ? styledIdentifier : ""; + const markers = identifierBeforeStatus ? [visibleIdentifier, status] : [status, visibleIdentifier]; + const leading = theme.fg("dim", prefix) + markers.filter((marker) => marker.length > 0).join(" "); + const styledName = styleTaskName(task, theme); + const renderMain = (targetWidth: number): string => { + const safeWidth = Math.max(1, targetWidth); + if (!identifierBeforeStatus) return truncateToWidth(`${leading} ${styledName}`, safeWidth); + + const availableNameWidth = safeWidth - visibleWidth(leading) - 1; + if (availableNameWidth < 1) return truncateToWidth(leading, safeWidth); + return `${leading} ${truncateToWidth(styledName, availableNameWidth)}`; + }; + const trailing = [ + suffix === undefined ? undefined : theme.fg("dim", ` · ${suffix}`), + task.durationMs === undefined ? undefined : theme.fg("dim", ` · ${formatDuration(task.durationMs)}`), + ] + .filter((part): part is string => part !== undefined) + .join(""); + if (trailing.length === 0) return renderMain(width); + + const trailingWidth = visibleWidth(trailing); + const minimumTaskWidth = Math.max(suffix === undefined ? 6 : 12, visibleWidth(leading)); + if (width <= trailingWidth + minimumTaskWidth) return renderMain(width); + const taskWidth = Math.max(1, width - trailingWidth); + return renderMain(taskWidth) + trailing; +} + +export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string { + return renderTaskLineWithSuffix(task, prefix, theme, width); +} + +const STATUS_ICON_WIDTH = 1; +const TRUNCATED_STATUS_WIDTH = STATUS_ICON_WIDTH + 3; + +function indentDeepestLeaf( + prefix: string, + depth: number, + maximumDepth: number, + width: number, + minimumContentWidth: number, +): string { + if (depth === 0 || depth !== maximumDepth) return prefix; + + const indentedPrefix = `${prefix} `; + return visibleWidth(indentedPrefix) + minimumContentWidth <= width ? indentedPrefix : prefix; +} + +export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] { + const maximumDepth = maximumTaskDepth(tasks); + return orderedTreeRows(tasks).map(({ task, prefix, depth }) => + renderTaskLine( + task, + indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, TRUNCATED_STATUS_WIDTH), + theme, + width, + ), + ); +} + +function toRomanNumeral(value: number): string { + const numerals: Array<[number, string]> = [ + [100, "C"], + [90, "XC"], + [50, "L"], + [40, "XL"], + [10, "X"], + [9, "IX"], + [5, "V"], + [4, "IV"], + [1, "I"], + ]; + let remaining = value; + let result = ""; + for (const [amount, numeral] of numerals) { + while (remaining >= amount) { + result += numeral; + remaining -= amount; + } + } + return result; +} + +function maximumTaskDepth(tasks: Task[]): number { + const byId = new Map(tasks.map((task) => [task.id, task])); + let maximumDepth = 0; + + for (const task of tasks) { + let depth = 0; + let parentId = task.parentId; + while (parentId !== null) { + depth += 1; + parentId = byId.get(parentId)?.parentId ?? null; + } + maximumDepth = Math.max(maximumDepth, depth); + } + + return maximumDepth; +} + +function widgetOutlineLabel(depth: number, siblingIndex: number, maximumDepth: number): string { + if (depth === maximumDepth) return ""; + if (maximumDepth === 2 && depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`; + return `${siblingIndex + 1}.`; +} + +export function renderWidgetTree( + tasks: Task[], + theme: Theme, + width: number, + padding = " ", + inProgressFrame?: number, + maxBodyRows?: number, +): string[] { + const rows = buildTreeRows(tasks, true, true); + const maximumDepth = maximumTaskDepth(tasks); + const outputLimit = + maxBodyRows === undefined ? MAX_WIDGET_TASK_ROWS : Math.max(1, Math.floor(Number.isFinite(maxBodyRows) ? maxBodyRows : 1)); + const hasOverflow = rows.length > outputLimit; + // The legacy, standalone widget allows twelve task rows plus one summary. + // A combined panel treats maxBodyRows as a strict total body-line budget. + const taskRowLimit = maxBodyRows === undefined ? outputLimit : hasOverflow ? Math.max(0, outputLimit - 1) : outputLimit; + const visibleRows = rows.slice(0, taskRowLimit); + const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => { + const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`; + const identifier = widgetOutlineLabel(depth, siblingIndex, maximumDepth); + return renderTaskLineWithSuffix( + task, + indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, STATUS_ICON_WIDTH), + theme, + width, + summary, + identifier, + true, + inProgressFrame, + ); + }); + if (!hasOverflow) return lines; + + const hiddenTasks = rows.slice(taskRowLimit).reduce( + (count, row) => count + 1 + (row.collapsed?.total ?? 0), + 0, + ); + const label = `${padding}… ${hiddenTasks} more task${hiddenTasks === 1 ? "" : "s"}`; + return [...lines, truncateToWidth(theme.fg("dim", label), Math.max(1, width))]; +} + +export function progressLabel(stats: TaskStats): string { + const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : ""; + return ` Todos ${stats.done}/${stats.total}${blocked}`; +} + +export function renderProgressHeader(stats: TaskStats, theme: Theme, width: number): string { + const label = progressLabel(stats); + const available = width - visibleWidth(label) - 2; + if (available < 5) return truncateToWidth(theme.fg("accent", label), Math.max(1, width)); + + const barWidth = Math.min(20, available); + const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * barWidth); + const filledColor = stats.done === stats.total ? "success" : "accent"; + const bar = + theme.fg(filledColor, theme.bold("━".repeat(filled))) + theme.fg("dim", "─".repeat(barWidth - filled)); + return truncateToWidth(`${theme.fg("accent", label)} ${bar}`, Math.max(1, width)); +} + +function compactProgressBar(stats: TaskStats, theme: Theme): string { + const width = 8; + const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * width); + const filledColor = stats.done === stats.total ? "success" : "accent"; + return ( + theme.fg(filledColor, theme.bold("━".repeat(filled))) + + theme.fg("borderMuted", "─".repeat(width - filled)) + ); +} + +/** Render the compact, unboxed Todo header and task rows. */ +export function renderTodoPanel( + tasks: Task[], + theme: Theme, + width: number, + inProgressFrame?: number, + maxBodyRows?: number, +): string[] { + if (!Number.isFinite(width) || width <= 0) return []; + const safeWidth = Math.max(1, Math.floor(width)); + const stats = snapshotStats(tasks); + const title = theme.fg("accent", "Todos"); + const count = theme.fg("muted", `${stats.done}/${stats.total}${stats.blocked > 0 ? ` · !${stats.blocked}` : ""}`); + const fullHeader = `${title} ${count} ${compactProgressBar(stats, theme)}`; + const compactHeader = `${title} ${count}`; + const header = visibleWidth(fullHeader) <= safeWidth ? fullHeader : truncateToWidth(compactHeader, safeWidth); + return [header, ...renderWidgetTree(tasks, theme, safeWidth, " ", inProgressFrame, maxBodyRows)]; +} + +class TaskResultComponent implements Component { + private readonly build: (width: number) => string[]; + + constructor(build: (width: number) => string[]) { + this.build = build; + } + + render(width: number): string[] { + return this.build(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width))); + } + + invalidate(): void {} +} + +function firstText(content: Array<{ type: string; text?: string }>): string { + return content.find((item) => item.type === "text")?.text ?? ""; +} + +function summaryFor(details: TaskToolDetails): string { + const stats = snapshotStats(details.snapshot.tasks); + const progress = `${stats.done}/${stats.total} done`; + switch (details.action) { + case "add": + case "append": + return `${details.affected.length} added · ${progress}`; + case "replace": + return stats.total === 0 ? "task tree cleared" : `${stats.total} installed · ${progress}`; + case "update": + return `${details.affected.length} updated · ${progress}`; + case "remove": + return `${details.affected.length} removed · ${progress}`; + case "list": + return `${stats.total} task${stats.total === 1 ? "" : "s"} · ${progress}`; + case "get": + return `${details.affected.length} task${details.affected.length === 1 ? "" : "s"}`; + } +} + +export function snapshotStats(tasks: Task[]): TaskStats { + return { + total: tasks.length, + pending: tasks.filter((task) => task.status === "pending").length, + inProgress: tasks.filter((task) => task.status === "in-progress").length, + blocked: tasks.filter((task) => task.status === "blocked").length, + done: tasks.filter((task) => task.status === "done").length, + }; +} + +function renderFlatTasks(tasks: Task[], theme: Theme, width: number): string[] { + return tasks.map((task) => renderTaskLine(task, " ", theme, width)); +} + +function renderTaskDetails(tasks: Task[], theme: Theme, width: number): string[] { + const lines: string[] = []; + for (const [index, task] of tasks.entries()) { + if (index > 0) lines.push(""); + lines.push(renderTaskLine(task, " ", theme, width)); + const parent = task.parentId === null ? "root" : `#${task.parentId}`; + const children = task.childrenIds.length === 0 ? "none" : task.childrenIds.map((id) => `#${id}`).join(", "); + lines.push(truncateToWidth(theme.fg("dim", ` Parent ${parent} · Children ${children}`), width)); + if (task.description) { + const prefix = " "; + const descriptionWidth = Math.max(1, width - visibleWidth(prefix)); + for (const sourceLine of task.description.split("\n")) { + const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth); + if (wrapped.length === 0) lines.push(prefix); + else lines.push(...wrapped.map((line) => `${prefix}${line}`)); + } + } + } + return lines; +} + +function isValidTaskToolDetails(details: unknown): details is TaskToolDetails { + if (!details || typeof details !== "object") return false; + const d = details as Partial; + return ( + typeof d.action === "string" && + Array.isArray(d.snapshot?.tasks) && + Array.isArray(d.affected) + ); +} + +export function renderToolResult( + result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean }, + expanded: boolean, + theme: Theme, +): Component { + const raw = firstText(result.content); + const details = result.details as TaskToolDetails | undefined; + + if (result.isError || !isValidTaskToolDetails(details)) { + const rawLines = raw.split("\n").filter(Boolean); + return new TaskResultComponent((width) => { + const lines = expanded ? rawLines : rawLines.slice(0, 1); + return lines.map((line, index) => theme.fg("error", `${index === 0 ? "✗ " : " "}${line}`)); + }); + } + + return new TaskResultComponent((width) => { + const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))]; + if (!expanded || details.affected.length === 0) return lines; + if (details.action === "list") return [...lines, ...renderTree(details.affected, theme, width)]; + if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)]; + return [...lines, ...renderFlatTasks(details.affected, theme, width)]; + }); +} diff --git a/extensions/todo-herdr/src/widget.ts b/extensions/todo-herdr/src/widget.ts new file mode 100644 index 0000000..6d20421 --- /dev/null +++ b/extensions/todo-herdr/src/widget.ts @@ -0,0 +1,469 @@ +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; +import type { TaskState } from "./state.ts"; +import { renderTodoPanel } from "./ui.ts"; + +const WIDGET_ID = "pi-todo-herdr"; +const COMBINED_WIDGET_BREAKPOINT = 120; +const COMBINED_BODY_ROWS = 10; +export const TODO_ANIMATION_INTERVAL_MS = 500; + +interface StatusWidgetTheme { + fg(color: string, text: string): string; +} + +interface StatusWidget { + readonly source: object; + readonly hasContent: () => boolean; + readonly getBodyRowCount: () => number; + readonly render: (width: number, maxBodyRows?: number, theme?: StatusWidgetTheme) => string[]; + readonly claim: (onChange: () => void) => () => void; + readonly isClaimed: (onChange: () => void) => boolean; +} + +interface InteractiveSubagentsGlobal { + statusWidget?: unknown; +} + +class OptionalIntegrationError extends Error { + constructor(message: string, cause?: unknown) { + super(message, { cause }); + this.name = "OptionalIntegrationError"; + } +} + +class TuiRenderFailure extends Error { + readonly original: unknown; + + constructor(original: unknown) { + super("TUI render failed", { cause: original }); + this.name = "TuiRenderFailure"; + this.original = original; + } +} + +function optionalIntegrationFailure(message: string, cause?: unknown): OptionalIntegrationError { + return new OptionalIntegrationError(message, cause); +} + +function callOptionalIntegration(message: string, operation: () => T): T { + try { + return operation(); + } catch (error) { + if (error instanceof TuiRenderFailure) throw error.original; + if (error instanceof OptionalIntegrationError) throw error; + throw optionalIntegrationFailure(message, error); + } +} + +interface ActiveClaim { + owner: object; + source: object; + onChange: () => void; + release: () => void; + generation: number; +} + +// Symbols make the claim and generation survive extension reloads. A newly +// loaded module can invalidate a stale component before installing its own. +const ACTIVE_CLAIM_KEY = Symbol.for("pi-todo-herdr/active-subagents-claim"); +const WIDGET_GENERATION_KEY = Symbol.for("pi-todo-herdr/widget-generation"); +const ACTIVE_RUNTIME_KEY = Symbol.for("pi-todo-herdr/active-runtime"); +const RUNTIME_TOKEN = Symbol("pi-todo-herdr-runtime"); +const previousClaim = Reflect.get(globalThis, ACTIVE_CLAIM_KEY); +Reflect.set(globalThis, ACTIVE_RUNTIME_KEY, RUNTIME_TOKEN); +if (previousClaim !== null && typeof previousClaim === "object") { + const release = (previousClaim as { release?: unknown }).release; + Reflect.deleteProperty(globalThis, ACTIVE_CLAIM_KEY); + if (typeof release === "function") { + try { + release.call(previousClaim); + } catch { + // A stale claim must not prevent the new extension runtime loading. + } + } +} + +function isCurrentRuntime(): boolean { + return Reflect.get(globalThis, ACTIVE_RUNTIME_KEY) === RUNTIME_TOKEN; +} + +function getActiveClaim(): ActiveClaim | undefined { + const claim = Reflect.get(globalThis, ACTIVE_CLAIM_KEY); + return claim !== null && typeof claim === "object" ? (claim as ActiveClaim) : undefined; +} + +function setActiveClaim(claim: ActiveClaim | undefined): void { + if (claim === undefined) Reflect.deleteProperty(globalThis, ACTIVE_CLAIM_KEY); + else Reflect.set(globalThis, ACTIVE_CLAIM_KEY, claim); +} + +function claimBelongsTo(claim: ActiveClaim | undefined, owner: object, generation: number): claim is ActiveClaim { + return claim?.owner === owner && claim.generation === generation; +} + +function nextWidgetGeneration(): number { + const current = Reflect.get(globalThis, WIDGET_GENERATION_KEY); + const generation = typeof current === "number" && Number.isSafeInteger(current) ? current + 1 : 1; + Reflect.set(globalThis, WIDGET_GENERATION_KEY, generation); + return generation; +} + +function currentWidgetGeneration(): number { + const generation = Reflect.get(globalThis, WIDGET_GENERATION_KEY); + return typeof generation === "number" && Number.isSafeInteger(generation) ? generation : 0; +} + +function releaseActiveClaim(owner?: object, generation?: number): void { + const activeClaim = getActiveClaim(); + if ( + (owner !== undefined && activeClaim?.owner !== owner) || + (generation !== undefined && activeClaim?.generation !== generation) + ) return; + setActiveClaim(undefined); + if (activeClaim === undefined) return; + try { + activeClaim.release(); + } catch { + // A broken optional integration must never prevent the Todo widget clearing. + } +} + +function getStatusWidget(): StatusWidget | undefined { + try { + const globals = globalThis as typeof globalThis & { + __pi_interactive_subagents?: unknown; + }; + const integration = globals.__pi_interactive_subagents; + if (integration === null || typeof integration !== "object") return undefined; + const statusWidget = (integration as InteractiveSubagentsGlobal).statusWidget; + if (statusWidget === null || typeof statusWidget !== "object") return undefined; + + const candidate = statusWidget as Record; + if ( + typeof candidate.hasContent !== "function" || + typeof candidate.getBodyRowCount !== "function" || + typeof candidate.render !== "function" || + typeof candidate.claim !== "function" || + typeof candidate.isClaimed !== "function" + ) { + return undefined; + } + + const source = statusWidget; + const call = (methodName: string, args: unknown[] = []): T => + callOptionalIntegration(`Subagents ${methodName} failed`, () => { + const method = candidate[methodName]; + if (typeof method !== "function") { + throw optionalIntegrationFailure(`invalid Subagents ${methodName} method`); + } + return method.call(source, ...args) as T; + }); + + return { + source, + hasContent: () => { + const content = call("hasContent"); + if (typeof content !== "boolean") throw optionalIntegrationFailure("invalid Subagents content state"); + return content; + }, + getBodyRowCount: () => { + const count = call("getBodyRowCount"); + if (typeof count !== "number" || !Number.isFinite(count)) { + throw optionalIntegrationFailure("invalid Subagents body row count"); + } + return count; + }, + render: (width, maxBodyRows, theme) => { + const lines = call("render", [width, maxBodyRows, theme]); + const hasAllEntries = + Array.isArray(lines) && + lines.length > 0 && + Array.from({ length: lines.length }, (_, index) => { + return Object.prototype.hasOwnProperty.call(lines, index) && typeof lines[index] === "string"; + }).every(Boolean); + if (!hasAllEntries) throw optionalIntegrationFailure("invalid Subagents render output"); + const validLines = lines as string[]; + if (maxBodyRows !== undefined) { + const isBordered = validLines[0]?.includes("╭") && validLines.at(-1)?.includes("╰"); + // Body rows exclude the provider's header. Legacy bordered output has + // top and bottom chrome; the current unboxed output has one header. + const headerAndChromeRows = isBordered ? 2 : 1; + const maxLines = maxBodyRows + headerAndChromeRows; + if (validLines.length > maxLines) { + throw optionalIntegrationFailure("Subagents render output exceeded its body limit"); + } + } + return validLines; + }, + claim: (onChange) => { + const release = call("claim", [onChange]); + if (typeof release !== "function") throw optionalIntegrationFailure("invalid Subagents claim release"); + return release as () => void; + }, + isClaimed: (onChange) => { + const claimed = call("isClaimed", [onChange]); + if (typeof claimed !== "boolean") throw optionalIntegrationFailure("invalid Subagents claim state"); + return claimed; + }, + }; + } catch { + return undefined; + } +} + +function claimStatusWidget( + statusWidget: StatusWidget, + owner: object, + generation: number, + tui: { requestRender(): void }, +): boolean { + const activeClaim = getActiveClaim(); + if (activeClaim?.owner !== undefined && activeClaim.owner !== owner) return false; + if (claimBelongsTo(activeClaim, owner, generation) && activeClaim.source === statusWidget.source) { + try { + if (statusWidget.isClaimed(activeClaim.onChange)) return true; + } catch (error) { + releaseActiveClaim(owner, generation); + if (error instanceof OptionalIntegrationError) return false; + throw error; + } + // The provider may have been reset by a reload or another lifecycle event. + // Drop the stale local handle before asking it for a fresh claim. + } + releaseActiveClaim(owner, generation); + + try { + const onChange = () => { + if (!isCurrentRuntime()) { + releaseActiveClaim(owner, generation); + return; + } + const currentClaim = getActiveClaim(); + if (!claimBelongsTo(currentClaim, owner, generation) || currentClaim.onChange !== onChange) return; + try { + tui.requestRender(); + } catch (error) { + // Let the provider restore its standalone widget when the TUI is gone. + releaseActiveClaim(owner, generation); + throw new TuiRenderFailure(error); + } + }; + const release = statusWidget.claim(onChange); + setActiveClaim({ owner, source: statusWidget.source, onChange, release, generation }); + return true; + } catch (error) { + if (error instanceof OptionalIntegrationError) return false; + throw error; + } +} + +function normalizeWidth(width: number): number { + return Math.max(1, Math.floor(Number.isFinite(width) ? width : 1)); +} + +function fitLine(line: string, width: number): string { + const fittedWidth = normalizeWidth(width); + const fitted = truncateToWidth(line, fittedWidth); + return `${fitted}${" ".repeat(Math.max(0, fittedWidth - visibleWidth(fitted)))}`; +} + +function renderStatusPanel( + statusWidget: StatusWidget, + width: number, + maxBodyRows: number, + theme: StatusWidgetTheme, +): string[] { + return statusWidget.render(width, maxBodyRows, theme).map((line) => fitLine(line, width)); +} + +function normalizePanelHeight(lines: string[], height: number, blankLine: string): string[] { + if (lines.length >= height) return lines; + const blanks = Array.from({ length: height - lines.length }, () => blankLine); + const bottom = lines.at(-1); + if (bottom?.includes("╰")) return [...lines.slice(0, -1), ...blanks, bottom]; + return [...lines, ...blanks]; +} + +function renderSideBySide( + tasks: ReturnType, + theme: Theme, + width: number, + inProgressFrame: number, + statusWidget: StatusWidget, +): string[] { + const leftWidth = Math.floor((width - 1) / 2); + const rightWidth = width - 1 - leftWidth; + const todo = renderTodoPanel(tasks, theme, leftWidth, inProgressFrame, COMBINED_BODY_ROWS); + const subagents = renderStatusPanel(statusWidget, rightWidth, COMBINED_BODY_ROWS, theme); + const height = Math.max(todo.length, subagents.length); + const left = normalizePanelHeight(todo, height, " ".repeat(leftWidth)).map((line) => fitLine(line, leftWidth)); + const right = normalizePanelHeight(subagents, height, " ".repeat(rightWidth)); + return left.map((line, index) => `${line} ${right[index]}`); +} + +function bodyRowsForTodo( + tasks: ReturnType, + theme: Theme, + width: number, + inProgressFrame: number, +): number { + return Math.max(1, renderTodoPanel(tasks, theme, width, inProgressFrame, COMBINED_BODY_ROWS).length - 1); +} + +function bodyRowsForSubagents(statusWidget: StatusWidget): number { + return Math.max(1, Math.min(COMBINED_BODY_ROWS, Math.floor(statusWidget.getBodyRowCount()))); +} + +function distributeBodyRows(todoNeed: number, subagentsNeed: number): { todo: number; subagents: number } { + let todo = Math.min(todoNeed, Math.floor(COMBINED_BODY_ROWS / 2)); + let subagents = Math.min(subagentsNeed, Math.floor(COMBINED_BODY_ROWS / 2)); + let remaining = COMBINED_BODY_ROWS - todo - subagents; + + const todoUnused = Math.max(0, todoNeed - todo); + const todoExtra = Math.min(remaining, todoUnused); + todo += todoExtra; + remaining -= todoExtra; + + const subagentsUnused = Math.max(0, subagentsNeed - subagents); + subagents += Math.min(remaining, subagentsUnused); + return { todo: Math.max(1, todo), subagents: Math.max(1, subagents) }; +} + +function renderStacked( + tasks: ReturnType, + theme: Theme, + width: number, + inProgressFrame: number, + statusWidget: StatusWidget, + subagentsNeed: number, +): string[] { + const todoNeed = bodyRowsForTodo(tasks, theme, width, inProgressFrame); + const rows = distributeBodyRows(todoNeed, subagentsNeed); + return [ + ...renderTodoPanel(tasks, theme, width, inProgressFrame, rows.todo), + "", + ...renderStatusPanel(statusWidget, width, rows.subagents, theme), + ]; +} + +function renderCombinedWidget( + tasks: ReturnType, + theme: Theme, + width: number, + inProgressFrame: number, + tui: { requestRender(): void }, + owner: object, + generation: number, + composeSubagents: boolean, +): string[] { + if (!Number.isFinite(width) || width <= 0 || tasks.length === 0) { + releaseActiveClaim(owner, generation); + return []; + } + + const fittedWidth = normalizeWidth(width); + const renderTodoFallback = (): string[] => { + releaseActiveClaim(owner, generation); + return renderTodoPanel(tasks, theme, fittedWidth, inProgressFrame); + }; + if (!composeSubagents) return renderTodoFallback(); + + const statusWidget = getStatusWidget(); + if (statusWidget === undefined) return renderTodoFallback(); + + try { + if (!statusWidget.hasContent()) return renderTodoFallback(); + if (!claimStatusWidget(statusWidget, owner, generation, tui)) return renderTodoFallback(); + + const subagentsNeed = bodyRowsForSubagents(statusWidget); + return fittedWidth > COMBINED_WIDGET_BREAKPOINT + ? renderSideBySide(tasks, theme, fittedWidth, inProgressFrame, statusWidget) + : renderStacked(tasks, theme, fittedWidth, inProgressFrame, statusWidget, subagentsNeed); + } catch (error) { + if (!(error instanceof OptionalIntegrationError)) { + releaseActiveClaim(owner, generation); + throw error; + } + return renderTodoFallback(); + } +} + +export function clearTaskWidget(ctx: ExtensionContext): void { + if (!isCurrentRuntime()) return; + nextWidgetGeneration(); + releaseActiveClaim(); + ctx.ui.setWidget(WIDGET_ID, undefined); +} + +export function updateTaskWidget( + state: TaskState, + ctx: ExtensionContext, + visible: boolean, + refreshMs = TODO_ANIMATION_INTERVAL_MS, +): void { + if (!isCurrentRuntime()) return; + const tasks = state.list(); + if (!visible || tasks.length === 0) { + clearTaskWidget(ctx); + return; + } + + const generation = nextWidgetGeneration(); + // Subagents owns a native widget outside this extension. Only compose it + // while Pi is rendering the terminal UI, never in RPC/print modes. + const composeSubagents = ctx.mode === "tui"; + + // Replacing a widget can happen before Pi calls its old dispose hook. Release + // the optional Subagents claim eagerly so it cannot hide a standalone widget. + releaseActiveClaim(); + const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined); + ctx.ui.setWidget(WIDGET_ID, (tui, theme) => { + let inProgressFrame = 0; + let disposed = false; + const owner = {}; + const isActive = (): boolean => + !disposed && isCurrentRuntime() && currentWidgetGeneration() === generation; + let timer: ReturnType | undefined; + if (hasActiveTimer) { + timer = setInterval(() => { + if (!isActive()) { + clearInterval(timer); + return; + } + inProgressFrame += 1; + try { + tui.requestRender(); + } catch { + disposed = true; + clearInterval(timer); + releaseActiveClaim(owner, generation); + } + }, Math.max(1, refreshMs)); + } + timer?.unref(); + + return { + render(width: number): string[] { + if (!isActive()) return []; + const currentTasks = state.list(); + return renderCombinedWidget( + currentTasks, + theme, + width, + inProgressFrame, + tui, + owner, + generation, + composeSubagents, + ); + }, + invalidate(): void {}, + dispose(): void { + disposed = true; + releaseActiveClaim(owner, generation); + if (timer !== undefined) clearInterval(timer); + }, + }; + }); +} diff --git a/extensions/todo-herdr/tests/auto-clear.test.ts b/extensions/todo-herdr/tests/auto-clear.test.ts new file mode 100644 index 0000000..7981ae8 --- /dev/null +++ b/extensions/todo-herdr/tests/auto-clear.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createCompletedTaskAutoClear, TODO_AUTO_CLEAR_DELAY_MS } from "../src/auto-clear.ts"; +import { TaskState } from "../src/state.ts"; + +function clearState(state: TaskState, onClear: () => void): boolean { + state.set("replace", [], false); + onClear(); + return true; +} + +test("clears the task tree ten seconds after every task remains done", (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + let clearCount = 0; + const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => { + clearCount += 1; + })); + + autoClear.sync(); + context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS - 1); + assert.equal(state.stats().total, 1); + + context.mock.timers.tick(1); + assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 }); + assert.equal(clearCount, 1); +}); + +test("cancels a pending clear when unfinished work appears", (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + let clearCount = 0; + const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => { + clearCount += 1; + })); + + autoClear.sync(); + state.add([{ name: "New work" }]); + autoClear.sync(); + context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS); + + assert.equal(state.stats().total, 2); + assert.equal(clearCount, 0); +}); + +test("preserves the deadline for unrelated refreshes", (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {})); + + autoClear.sync(); + context.mock.timers.tick(9_000); + state.update([{ id: 1, name: "Renamed" }]); + autoClear.sync(); + context.mock.timers.tick(1_000); + + assert.equal(state.stats().total, 0); +}); + +test("restarts the delay for changed or restored completed trees", (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {})); + + autoClear.sync(); + context.mock.timers.tick(9_000); + state.add([{ name: "Also done", status: "done" }]); + autoClear.sync(); + context.mock.timers.tick(1_000); + assert.equal(state.stats().total, 2); + + context.mock.timers.tick(8_000); + autoClear.sync(true); + context.mock.timers.tick(2_000); + assert.equal(state.stats().total, 2); + + context.mock.timers.tick(8_000); + assert.equal(state.stats().total, 0); +}); + +test("retries after a clear transaction fails and supports shutdown cancellation", (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + let attempts = 0; + const autoClear = createCompletedTaskAutoClear(state, () => { + attempts += 1; + if (attempts === 1) return false; + return clearState(state, () => {}); + }); + + autoClear.sync(); + context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS); + assert.equal(state.stats().total, 1); + context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS); + assert.equal(state.stats().total, 0); + + state.add([{ name: "Done again", status: "done" }]); + autoClear.sync(); + autoClear.cancel(); + context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS); + assert.equal(state.stats().total, 1); +}); diff --git a/extensions/todo-herdr/tests/prompt.test.ts b/extensions/todo-herdr/tests/prompt.test.ts new file mode 100644 index 0000000..9301a73 --- /dev/null +++ b/extensions/todo-herdr/tests/prompt.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import piTodoHerdr from "../index.ts"; +import { + COMPACTION_REMINDER, + COMPACTION_REMINDER_TYPE, + formatCompactTaskTree, + hasUnfinishedTasks, + TASK_PROMPT_GUIDELINES, +} from "../src/prompt.ts"; +import { TaskState } from "../src/state.ts"; + +test("registers the conflict-free /todo command", async () => { + type RegisteredCommand = { + description: string; + handler(args: string, ctx: unknown): Promise; + }; + const commands = new Map(); + const pi = { + on() {}, + registerTool() {}, + registerCommand(name: string, command: RegisteredCommand) { + commands.set(name, command); + }, + }; + + piTodoHerdr(pi as never); + + assert.deepEqual([...commands.keys()], ["todo"]); + const todo = commands.get("todo"); + assert.equal(todo?.description, "Show or hide the todo tree widget"); + + const notifications: string[] = []; + await todo?.handler("", { + ui: { + setWidget() {}, + notify(message: string) { + notifications.push(message); + }, + }, + }); + assert.deepEqual(notifications, ["No todos yet."]); +}); + +test("persists automatic clears before deletion, retries failures, and cancels on shutdown", async (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + context.mock.method(console, "error", () => {}); + const handlers = new Map unknown>(); + const tools = new Map(); + const entries: Array<{ customType: string; data: unknown }> = []; + let persistAttempts = 0; + const pi = { + on(name: string, handler: (event: any, ctx: any) => unknown) { + handlers.set(name, handler); + }, + registerTool(tool: any) { + tools.set(tool.name, tool); + }, + registerCommand() {}, + appendEntry(customType: string, data: unknown) { + persistAttempts += 1; + if (persistAttempts === 1) throw new Error("disk unavailable"); + entries.push({ customType, data }); + }, + }; + const ctx = { ui: { setWidget() {} } }; + piTodoHerdr(pi as never); + + await tools.get("set_tasks").execute( + "set", + { mode: "append", tasks: [{ name: "Done", status: "done" }], force: false }, + undefined, + undefined, + ctx, + ); + context.mock.timers.tick(10_000); + assert.equal((await tools.get("list_task").execute()).content[0].text, "1 [done] Done"); + + context.mock.timers.tick(10_000); + assert.equal((await tools.get("list_task").execute()).content[0].text, "No tasks."); + assert.deepEqual(entries, [{ customType: "pi-todo-herdr-state", data: { tasks: [], nextId: 1 } }]); + + await tools.get("set_tasks").execute( + "set", + { mode: "append", tasks: [{ name: "Done again", status: "done" }], force: false }, + undefined, + undefined, + ctx, + ); + await handlers.get("session_shutdown")?.({ reason: "reload" }, ctx); + context.mock.timers.tick(10_000); + assert.equal((await tools.get("list_task").execute()).content[0].text, "1 [done] Done again"); +}); + +test("keeps task guidance short with planning after exploration", () => { + assert.equal(TASK_PROMPT_GUIDELINES.length, 2); + const prompt = TASK_PROMPT_GUIDELINES.join("\n"); + assert.match(prompt, /work with 3\+ distinct steps/); + assert.match(prompt, /explore enough to understand the scope/); + assert.match(prompt, /then call set_tasks before implementation/); + assert.match(prompt, /otherwise skip tracking/); + assert.match(prompt, /Start one task in-progress/); + assert.match(prompt, /patch_tasks when tasks start, block, or finish/); + assert.match(prompt, /keep statuses accurate before the final response/); + assert.ok(prompt.split(/\s+/).length < 40); +}); + +test("uses a compact one-shot recovery reminder without embedding task data", () => { + assert.equal( + COMPACTION_REMINDER, + "Active tasks exist. Call list_task now; treat its result as authoritative.", + ); + assert.equal(COMPACTION_REMINDER.split(/\s+/).length, 11); + assert.doesNotMatch(COMPACTION_REMINDER, /pending|in-progress|blocked|done/); +}); + +test("reminds after compaction only while unfinished tasks exist", () => { + const state = new TaskState(); + assert.equal(hasUnfinishedTasks(state.list()), false); + + state.add([{ name: "Pending" }]); + assert.equal(hasUnfinishedTasks(state.list()), true); + + state.update([{ id: 1, status: "done" }]); + assert.equal(hasUnfinishedTasks(state.list()), false); +}); + +test("formats the compact authoritative tree without descriptions", () => { + const state = new TaskState(); + state.add([ + { + name: "Parent", + description: "Detailed context that should not appear.", + status: "in-progress", + children: [{ name: "Child" }], + }, + ]); + assert.equal( + formatCompactTaskTree(state.list()), + "1 [in-progress] Parent\n└─ 2 [pending] Child", + ); +}); + +test("session_compact sends one hidden reminder only for unfinished tasks", async () => { + const handlers = new Map unknown>(); + const tools = new Map(); + const sent: Array<{ message: any; options: any }> = []; + const pi = { + on(name: string, handler: (event: any, ctx: any) => unknown) { + handlers.set(name, handler); + }, + registerTool(tool: any) { + tools.set(tool.name, tool); + }, + registerCommand() {}, + sendMessage(message: any, options: any) { + sent.push({ message, options }); + }, + }; + piTodoHerdr(pi as never); + const ctx = { ui: { setWidget() {} } }; + + await tools.get("set_tasks").execute( + "set", + { mode: "append", tasks: [{ name: "Continue work", status: "in-progress" }] }, + undefined, + undefined, + ctx, + ); + handlers.get("session_compact")?.({}, ctx); + assert.deepEqual(sent, [ + { + message: { + customType: COMPACTION_REMINDER_TYPE, + content: COMPACTION_REMINDER, + display: false, + }, + options: { deliverAs: "steer" }, + }, + ]); + + await tools.get("patch_tasks").execute( + "patch", + { tasks: [{ id: 1, status: "done" }] }, + undefined, + undefined, + ctx, + ); + handlers.get("session_compact")?.({}, ctx); + assert.equal(sent.length, 1); +}); diff --git a/extensions/todo-herdr/tests/state.test.ts b/extensions/todo-herdr/tests/state.test.ts new file mode 100644 index 0000000..26cdfbe --- /dev/null +++ b/extensions/todo-herdr/tests/state.test.ts @@ -0,0 +1,552 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { TASK_STATE_ENTRY_TYPE, TaskState } from "../src/state.ts"; +import { TaskValidationError } from "../src/types.ts"; + +function names(state: TaskState): string[] { + return state.list().filter((task) => task.parentId === null).map((task) => task.name); +} + +test("adds a nested batch with generated preorder IDs and derived relations", () => { + const state = new TaskState(); + const added = state.add([ + { + name: "Parent", + description: "Parent description", + children: [ + { name: "First child", description: "First" }, + { name: "Second child", description: "Second", status: "blocked" }, + ], + }, + ]); + + assert.deepEqual(added.map((task) => task.id), [1, 2, 3]); + assert.deepEqual(state.get([1])[0]?.childrenIds, [2, 3]); + assert.equal(state.get([2])[0]?.parentId, 1); + assert.equal(state.get([2])[0]?.status, "pending"); + assert.equal(state.snapshot().nextId, 4); +}); + +test("rejects invalid nested parentId atomically while accepting null sentinels", () => { + const state = new TaskState(); + state.add([{ name: "Existing", description: "Existing" }]); + const before = state.snapshot(); + + assert.throws( + () => + state.add([ + { + name: "Parent", + description: "Parent", + children: [{ name: "Child", description: "Child", parentId: 1 }], + }, + ]), + TaskValidationError, + ); + assert.deepEqual(state.snapshot(), before); + + const added = state.add([ + { + name: "Nullable parent", + parentId: null, + children: [{ name: "Implicit child", parentId: null }], + }, + ]); + assert.equal(added[0]?.parentId, null); + assert.equal(added[1]?.parentId, added[0]?.id); +}); + +test("enforces done descendants against the final atomic update state", () => { + const state = new TaskState(); + state.add([{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }]); + const before = state.snapshot(); + + assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/); + assert.deepEqual(state.snapshot(), before); + + state.update([ + { id: 2, status: "done" }, + { id: 1, status: "done" }, + ]); + assert.deepEqual(state.list().map((task) => task.status), ["done", "done"]); + + assert.throws(() => state.update([{ id: 2, status: "in-progress" }]), /unfinished descendants/); + state.update([ + { id: 1, status: "in-progress" }, + { id: 2, status: "in-progress" }, + ]); + assert.deepEqual(state.list().map((task) => task.status), ["in-progress", "in-progress"]); +}); + +test("preserves and accumulates timing across repeated updates and status changes", () => { + const startedAt = Date.UTC(2026, 0, 1); + let now = startedAt; + const state = new TaskState(() => now); + + const [started] = state.add([{ name: "Timed", status: "in-progress" }]); + assert.equal(started?.startedAt, new Date(startedAt).toISOString()); + assert.equal(started?.stoppedAt, undefined); + assert.equal(started?.durationMs, 0); + + now += 5_000; + const [repeated] = state.update([{ id: 1, name: "Renamed", status: "in-progress" }]); + assert.equal(repeated?.startedAt, started?.startedAt); + assert.equal(repeated?.stoppedAt, undefined); + assert.equal(repeated?.durationMs, 5_000); + + now += 2_000; + const [paused] = state.update([{ id: 1, status: "blocked" }]); + assert.equal(paused?.startedAt, started?.startedAt); + assert.equal(paused?.stoppedAt, new Date(now).toISOString()); + assert.equal(paused?.durationMs, 7_000); + + now += 10_000; + assert.equal(state.get([1])[0]?.durationMs, 7_000); + const [resumed] = state.update([{ id: 1, status: "in-progress" }]); + assert.equal(resumed?.startedAt, started?.startedAt); + assert.equal(resumed?.stoppedAt, undefined); + assert.equal(resumed?.durationMs, 7_000); + + now += 3_000; + const [done] = state.update([{ id: 1, status: "done" }]); + assert.equal(done?.startedAt, started?.startedAt); + assert.equal(done?.stoppedAt, new Date(now).toISOString()); + assert.equal(done?.durationMs, 10_000); + assert.deepEqual(state.snapshot().timings, [ + { taskId: 1, startedAt, stoppedAt: now, accumulatedMs: 10_000 }, + ]); +}); + +test("does not stop a timer when an atomic update fails", () => { + const startedAt = Date.UTC(2026, 0, 1); + let now = startedAt; + const state = new TaskState(() => now); + state.add([ + { + name: "Parent", + status: "in-progress", + children: [{ name: "Unfinished child" }], + }, + ]); + + now += 5_000; + assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/); + const afterFailure = state.get([1])[0]; + assert.equal(afterFailure?.stoppedAt, undefined); + assert.equal(afterFailure?.durationMs, 5_000); + + now += 2_000; + assert.equal(state.get([1])[0]?.durationMs, 7_000); +}); + +test("restores timers and starts legacy in-progress snapshots at load time", () => { + const startedAt = Date.UTC(2026, 0, 1); + let now = startedAt; + const source = new TaskState(() => now); + source.add([{ name: "Persisted", status: "in-progress" }]); + now += 2_500; + source.update([{ id: 1, status: "pending" }]); + + now += 20_000; + const restored = new TaskState(() => now); + restored.restore(source.snapshot()); + assert.equal(restored.get([1])[0]?.startedAt, new Date(startedAt).toISOString()); + assert.equal(restored.get([1])[0]?.stoppedAt, new Date(startedAt + 2_500).toISOString()); + assert.equal(restored.get([1])[0]?.durationMs, 2_500); + + const legacy = new TaskState(() => now); + legacy.restore({ + tasks: [ + { + id: 1, + name: "Legacy active", + status: "in-progress", + parentId: null, + childrenIds: [], + }, + ], + nextId: 2, + }); + assert.equal(legacy.get([1])[0]?.startedAt, new Date(now).toISOString()); + assert.equal(legacy.get([1])[0]?.durationMs, 0); + assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now); +}); + +test("rejects unsafe restored identifiers and oversized timing collections", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Keep" }]); + const before = state.snapshot(); + const task = { id: 1, name: "Task", status: "pending" as const, parentId: null, childrenIds: [] }; + + assert.throws( + () => state.restore({ tasks: [{ ...task, id: Number.MAX_SAFE_INTEGER + 1 }], nextId: Number.MAX_SAFE_INTEGER }), + /id must be a positive safe integer/, + ); + assert.throws( + () => state.restore({ tasks: [task], nextId: Number.MAX_SAFE_INTEGER + 1 }), + /nextId must be a positive safe integer/, + ); + assert.throws( + () => + state.restore({ + tasks: [task], + nextId: 2, + timings: Array.from({ length: 201 }, () => ({ taskId: 1, startedAt: 0, accumulatedMs: 0 })), + }), + /timings are limited to 200 entries/, + ); + assert.throws( + () => + state.restore({ + tasks: [task], + nextId: 2, + timings: [{ taskId: 1, startedAt: 0, stoppedAt: 0, accumulatedMs: Number.MAX_SAFE_INTEGER }], + }), + /accumulatedMs must be an integer between/, + ); + assert.deepEqual(state.snapshot(), before); +}); + +test("rejects additions when no safe generated task ID remains", () => { + const state = new TaskState(() => 0); + state.restore({ tasks: [], nextId: Number.MAX_SAFE_INTEGER }); + const before = state.snapshot(); + + assert.throws(() => state.add([{ name: "Unsafe ID" }]), /no safe task IDs remain/); + assert.deepEqual(state.snapshot(), before); +}); + +test("rejects out-of-range persisted timestamps without replacing state", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Keep" }]); + const before = state.snapshot(); + + assert.throws( + () => + state.restore({ + tasks: [ + { + id: 1, + name: "Corrupt timer", + status: "in-progress", + parentId: null, + childrenIds: [], + }, + ], + nextId: 2, + timings: [ + { + taskId: 1, + startedAt: Number.MAX_SAFE_INTEGER, + activeStartedAt: Number.MAX_SAFE_INTEGER, + accumulatedMs: 0, + }, + ], + }), + /startedAt must be a valid non-negative timestamp/, + ); + assert.deepEqual(state.snapshot(), before); +}); + +test("finalizes an active timer in removed task details", () => { + let now = Date.UTC(2026, 0, 1); + const state = new TaskState(() => now); + state.add([{ name: "Canceled", status: "in-progress" }]); + now += 4_000; + + const [removed] = state.remove([1]); + assert.equal(removed?.stoppedAt, new Date(now).toISOString()); + assert.equal(removed?.durationMs, 4_000); + assert.deepEqual(state.list(), []); +}); + +test("rejects cycles and duplicate patches without partial changes", () => { + const state = new TaskState(); + state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]); + const before = state.snapshot(); + + assert.throws(() => state.update([{ id: 1, parentId: 2 }, { id: 2, name: "Changed" }]), /cycle/); + assert.deepEqual(state.snapshot(), before); + + assert.throws(() => state.update([{ id: 2, name: "One" }, { id: 2, name: "Two" }]), /duplicate task patches/); + assert.deepEqual(state.snapshot(), before); +}); + +test("reorders roots with zero-based sequential positions", () => { + const state = new TaskState(); + state.add([ + { name: "A", description: "A" }, + { name: "B", description: "B" }, + { name: "C", description: "C" }, + ]); + + state.update([{ id: 1, position: 2 }]); + assert.deepEqual(names(state), ["B", "C", "A"]); + + state.update([ + { id: 1, position: 0 }, + { id: 2, position: 2 }, + ]); + assert.deepEqual(names(state), ["A", "C", "B"]); +}); + +test("reparents and positions tasks among their final siblings", () => { + const state = new TaskState(); + state.add([ + { + name: "One", + description: "One", + children: [ + { name: "A", description: "A" }, + { name: "B", description: "B" }, + ], + }, + { name: "Two", description: "Two", children: [{ name: "C", description: "C" }] }, + ]); + + state.update([{ id: 3, parentId: 4, position: 0 }]); + assert.deepEqual(state.get([4])[0]?.childrenIds, [3, 5]); + assert.deepEqual(state.get([1])[0]?.childrenIds, [2]); +}); + +test("removes overlapping parent and child IDs as one cascade", () => { + const state = new TaskState(); + state.add([ + { + name: "Root", + description: "Root", + children: [{ name: "Child", description: "Child", children: [{ name: "Leaf", description: "Leaf" }] }], + }, + { name: "Keep", description: "Keep" }, + ]); + + const removed = state.remove([1, 2, 1]); + assert.deepEqual(removed.map((task) => task.id), [1, 2, 3]); + assert.deepEqual(state.list().map((task) => task.id), [4]); + assert.equal(state.snapshot().nextId, 5); + + const before = state.snapshot(); + assert.throws(() => state.remove([999]), /do not exist/); + assert.deepEqual(state.snapshot(), before); +}); + +test("restores snapshots and never reuses deleted IDs", () => { + const state = new TaskState(); + state.add([{ name: "One", description: "One" }, { name: "Two", description: "Two" }]); + state.remove([2]); + const snapshot = state.snapshot(); + + const restored = new TaskState(); + restored.restore(snapshot); + const added = restored.add([{ name: "Three", description: "Three" }]); + assert.equal(added[0]?.id, 3); +}); + +test("loads the latest valid mutation snapshot from a session branch", () => { + const first = new TaskState(); + first.add([{ name: "First", description: "First" }]); + const second = new TaskState(); + second.restore(first.snapshot()); + second.add([{ name: "Second", description: "Second" }]); + + const branch = [first.snapshot(), second.snapshot()].map((snapshot, index) => ({ + type: "message", + message: { + role: "toolResult", + toolName: index === 0 ? "add_task" : "patch_tasks", + details: { action: "add", snapshot, affected: [] }, + }, + })); + const state = new TaskState(); + state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never); + assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]); +}); + +test("loads legacy update_task mutation snapshots", () => { + const completed = new TaskState(); + completed.add([{ name: "Legacy completed task", status: "done" }]); + const branch = [ + { + type: "message", + message: { + role: "toolResult", + toolName: "update_task", + details: { action: "update", snapshot: completed.snapshot(), affected: [] }, + }, + }, + ]; + + const state = new TaskState(); + state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never); + assert.deepEqual(state.list().map((task) => task.name), ["Legacy completed task"]); +}); + +test("loads a persisted automatic clear after task tool snapshots", () => { + const completed = new TaskState(); + completed.add([{ name: "Completed", status: "done" }]); + const branch = [ + { + type: "message", + message: { + role: "toolResult", + toolName: "update_task", + details: { action: "update", snapshot: completed.snapshot(), affected: [] }, + }, + }, + { + type: "custom", + customType: TASK_STATE_ENTRY_TYPE, + data: { tasks: [], nextId: 1 }, + }, + ]; + + const state = new TaskState(); + state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never); + assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 }); +}); + +test("rejects terminal controls and bidirectional overrides without changing state", () => { + const state = new TaskState(); + state.add([{ name: "Safe", description: "Line one\nLine two" }]); + const before = state.snapshot(); + + for (const name of ["Escape\u001b[2J", "Tab\tname", "Arabic mark\u061cname", "Override\u202eevil"]) { + assert.throws(() => state.add([{ name }]), /unsafe control/); + assert.deepEqual(state.snapshot(), before); + } + + assert.throws(() => state.update([{ id: 1, description: "Clipboard\u009d52;c;payload" }]), /unsafe control/); + assert.deepEqual(state.snapshot(), before); +}); + +test("enforces task name and description length limits by Unicode character", () => { + const state = new TaskState(); + state.add([{ name: "😀".repeat(200), description: "d".repeat(4_000) }]); + const before = state.snapshot(); + + assert.throws(() => state.add([{ name: "n".repeat(201) }]), /name must be at most 200 characters/); + assert.throws(() => state.update([{ id: 1, description: "d".repeat(4_001) }]), /description must be at most 4000 characters/); + assert.deepEqual(state.snapshot(), before); +}); + +test("enforces per-mutation and per-session task limits atomically", () => { + const state = new TaskState(); + const batch = Array.from({ length: 50 }, (_, index) => ({ name: `Task ${index + 1}` })); + + assert.throws(() => state.add([...batch, { name: "Too many" }]), /at most 50 tasks/); + assert.equal(state.list().length, 0); + + for (let index = 0; index < 4; index += 1) state.add(batch); + assert.equal(state.list().length, 200); + const before = state.snapshot(); + assert.throws(() => state.add([{ name: "Session overflow" }]), /session is limited to 200 tasks/); + assert.deepEqual(state.snapshot(), before); + + assert.throws( + () => + new TaskState().restore({ + tasks: Array.from({ length: 201 }, (_, index) => ({ + id: index + 1, + name: `Restored ${index + 1}`, + status: "pending" as const, + parentId: null, + childrenIds: [], + })), + nextId: 202, + }), + /session is limited to 200 tasks/, + ); +}); + +test("allows three stored levels beneath the virtual root and rejects deeper task trees atomically", () => { + const nested = (depth: number) => { + let task: { name: string; children?: Array<{ name: string; children?: unknown[] }> } = { name: `Level ${depth}` }; + for (let level = depth - 1; level >= 1; level -= 1) { + task = { name: `Level ${level}`, children: [task] }; + } + return task; + }; + + const state = new TaskState(); + state.add([nested(3) as never, { name: "Other category" }]); + assert.equal(state.list().length, 4); + const before = state.snapshot(); + assert.throws(() => state.add([nested(4) as never]), /depth must not exceed 3 levels/); + assert.deepEqual(state.snapshot(), before); + assert.throws(() => state.update([{ id: 4, parentId: 3 }]), /depth must not exceed 3 levels/); + assert.deepEqual(state.snapshot(), before); + + assert.throws( + () => + state.restore({ + tasks: [ + { id: 1, name: "Category", status: "pending", parentId: null, childrenIds: [2] }, + { id: 2, name: "Item", status: "pending", parentId: 1, childrenIds: [3] }, + { id: 3, name: "Leaf", status: "pending", parentId: 2, childrenIds: [4] }, + { id: 4, name: "Too deep", status: "pending", parentId: 3, childrenIds: [] }, + ], + nextId: 5, + }), + /depth must not exceed 3 levels/, + ); + assert.deepEqual(state.snapshot(), before); +}); + +test("supports nullable descriptions and reports invalid children as validation errors", () => { + const state = new TaskState(); + assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/); + assert.throws(() => state.add([{ name: "Blank description", description: " " }]), /must not be empty/); + assert.throws( + () => state.add([{ name: "Invalid children", children: "not-an-array" } as never]), + /children must be an array/, + ); + state.add([ + { name: "No description" }, + { name: "Null description", description: null }, + { name: "Detailed", description: "Line one\nLine two" }, + ]); + assert.equal(state.list()[0]?.description, undefined); + assert.equal(state.list()[1]?.description, undefined); + assert.equal(state.list()[2]?.description, "Line one\nLine two"); +}); + +test("set append accepts false force and rejects true force", () => { + const state = new TaskState(); + assert.throws(() => state.set("append", [], false), /tasks must not be empty/); + state.set("append", [{ name: "Task" }], false); + const before = state.snapshot(); + assert.throws(() => state.set("append", [{ name: "Other" }], true), /force must be false in append mode/); + assert.deepEqual(state.snapshot(), before); +}); + +test("set replace is atomic, guarded, and resets IDs", () => { + const state = new TaskState(); + state.add([{ name: "Old", status: "in-progress" }]); + const before = state.snapshot(); + + assert.throws(() => state.set("replace", [{ name: "New" }]), /requires force/); + assert.throws( + () => state.set("replace", [{ name: "Invalid parent", parentId: 1 }], true), + /parentId is not allowed in replace mode/, + ); + assert.deepEqual(state.snapshot(), before); + + const replaced = state.set( + "replace", + [{ name: "New", parentId: null, children: [{ name: "Child", parentId: null }] }], + true, + ); + assert.deepEqual(replaced.map((task) => task.id), [1, 2]); + assert.deepEqual(state.list().map((task) => task.name), ["New", "Child"]); + assert.equal(state.snapshot().nextId, 3); + + state.set("replace", [], true); + assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 }); +}); + +test("set replace does not require force after all existing tasks are done", () => { + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + state.set("replace", [{ name: "Fresh" }]); + assert.deepEqual(state.list().map((task) => [task.id, task.name]), [[1, "Fresh"]]); +}); diff --git a/extensions/todo-herdr/tests/tools.test.ts b/extensions/todo-herdr/tests/tools.test.ts new file mode 100644 index 0000000..cdb98ad --- /dev/null +++ b/extensions/todo-herdr/tests/tools.test.ts @@ -0,0 +1,338 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { TaskState } from "../src/state.ts"; + +const { validateToolArguments } = await import( + new URL("./utils/validation.js", import.meta.resolve("@earendil-works/pi-ai")).href +); +import { registerTaskTools } from "../src/tools.ts"; + +function registeredTools(state = new TaskState()) { + const tools = new Map(); + let changes = 0; + const autoClearRestarts: boolean[] = []; + const pi = { + registerTool(tool: any) { + tools.set(tool.name, tool); + }, + }; + registerTaskTools(pi as never, state, (_ctx, restartAutoClear = false) => { + changes += 1; + autoClearRestarts.push(restartAutoClear); + }); + return { tools, state, changes: () => changes, autoClearRestarts }; +} + +async function execute(tool: any, params: unknown) { + const prepared = tool.prepareArguments?.(params) ?? params; + const validated = validateToolArguments(tool, { name: tool.name, arguments: prepared } as never); + return tool.execute("call", validated, undefined, undefined, {}); +} + +test("registers five focused tools without the old add_task alias", () => { + const { tools } = registeredTools(); + assert.deepEqual([...tools.keys()], ["set_tasks", "patch_tasks", "rm_task", "list_task", "get_task"]); + assert.deepEqual(Object.keys(tools.get("set_tasks").parameters.properties), ["mode", "tasks", "force"]); + assert.deepEqual(Object.keys(tools.get("patch_tasks").parameters.properties), ["tasks"]); + assert.deepEqual(Object.keys(tools.get("rm_task").parameters.properties), ["ids", "reason"]); + assert.deepEqual(Object.keys(tools.get("list_task").parameters.properties), []); + assert.deepEqual(Object.keys(tools.get("get_task").parameters.properties), ["ids"]); + assert.match(tools.get("patch_tasks").description, /do not recreate cleared completed tasks/); +}); + +test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => { + const { tools } = registeredTools(); + const params = tools.get("set_tasks").parameters; + const itemSchema = params.properties.tasks.items; + // $defs must live at the document root: root-style refs resolve from there, + // and moonshot-flavored validators reject nested definitions (Console Go). + assert.ok(params.$defs.AddTask, "$defs hoisted to the parameter schema root"); + assert.deepEqual(params.required, ["mode", "tasks", "force"]); + assert.deepEqual(params.$defs.AddTask.required, ["name", "description", "status", "parentId", "children"]); + assert.equal(itemSchema.$defs, undefined, "no $defs nested inside tasks items"); + assert.equal(itemSchema.$ref, "#/$defs/AddTask"); + assert.equal(params.$defs.AddTask.properties.children.items.$ref, "#/$defs/AddTask"); + assert.equal(params.$defs.AddTask.$id, undefined, "synthetic $id stripped"); + const serialized = JSON.stringify(params); + assert.ok(!/"\$ref":"(?!\#)/.test(serialized), "no bare $ref values remain"); +}); + +test("task schemas publish text and batch limits", () => { + const { tools } = registeredTools(); + const setParams = tools.get("set_tasks").parameters; + const addTask = setParams.$defs.AddTask; + const updateTask = tools.get("patch_tasks").parameters.properties.tasks.items; + + assert.equal(setParams.properties.tasks.maxItems, 50); + assert.equal(addTask.properties.children.maxItems, 50); + assert.equal(addTask.properties.name.maxLength, 200); + assert.match(addTask.properties.name.pattern, /061C/i); + assert.equal(addTask.properties.description.anyOf[0].maxLength, 4_000); + assert.match(addTask.properties.description.anyOf[0].pattern, /061C/i); + assert.equal(tools.get("patch_tasks").parameters.properties.tasks.maxItems, 50); + assert.equal(updateTask.properties.name.anyOf[0].maxLength, 200); + assert.equal(updateTask.properties.description.anyOf[0].maxLength, 4_000); + assert.equal(tools.get("rm_task").parameters.properties.ids.maxItems, 50); + assert.equal(tools.get("get_task").parameters.properties.ids.maxItems, 50); + + const setTool = tools.get("set_tasks"); + const validUnicode = setTool.prepareArguments({ + mode: "append", + tasks: [{ name: "😀".repeat(200), description: "line one\nline two" }], + }); + assert.doesNotThrow(() => + validateToolArguments(setTool, { name: setTool.name, arguments: validUnicode } as never), + ); + const unsafeText = setTool.prepareArguments({ mode: "append", tasks: [{ name: "unsafe\u061cname" }] }); + assert.throws( + () => validateToolArguments(setTool, { name: setTool.name, arguments: unsafeText } as never), + /validation failed/i, + ); +}); + +test("set_tasks preparation bounds hostile recursive input before validation", () => { + const { tools } = registeredTools(); + const setTool = tools.get("set_tasks"); + let nested: Record = { name: "Leaf" }; + for (let depth = 0; depth < 10_000; depth += 1) nested = { name: `Level ${depth}`, children: [nested] }; + + const prepared = setTool.prepareArguments({ mode: "append", tasks: [nested] }); + assert.throws( + () => validateToolArguments(setTool, { name: setTool.name, arguments: prepared } as never), + /validation failed/i, + ); + + const oversized = setTool.prepareArguments({ + mode: "append", + tasks: Array.from({ length: 1_000 }, (_, index) => ({ name: `Task ${index}` })), + }); + assert.equal(oversized.tasks.length, 51); + assert.throws( + () => validateToolArguments(setTool, { name: setTool.name, arguments: oversized } as never), + /validation failed/i, + ); + + const updateTool = tools.get("patch_tasks"); + const oversizedUpdate = updateTool.prepareArguments({ + tasks: Array.from({ length: 1_000 }, (_, index) => ({ id: index + 1 })), + }); + assert.equal(oversizedUpdate.tasks.length, 51); + assert.throws( + () => validateToolArguments(updateTool, { name: updateTool.name, arguments: oversizedUpdate } as never), + /validation failed/i, + ); +}); + +test("strict task schemas normalize legacy optional arguments", () => { + const { tools } = registeredTools(); + const setTool = tools.get("set_tasks"); + const updateTool = tools.get("patch_tasks"); + + assert.deepEqual(updateTool.parameters.properties.tasks.items.required, [ + "id", + "name", + "description", + "status", + "parentId", + "position", + ]); + assert.deepEqual(setTool.prepareArguments({ + mode: "append", + tasks: [{ name: "Parent", children: [{ name: "Child" }] }], + }), { + mode: "append", + tasks: [ + { + name: "Parent", + description: null, + status: "pending", + parentId: null, + children: [ + { + name: "Child", + description: null, + status: "pending", + parentId: null, + children: [], + }, + ], + }, + ], + force: false, + }); + assert.deepEqual(updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }), { + tasks: [ + { + id: 1, + name: null, + description: null, + status: "done", + parentId: -1, + position: -1, + }, + ], + }); + assert.equal(updateTool.prepareArguments({ tasks: [{ id: 1, parentId: null }] }).tasks[0].parentId, 0); + + const validated = validateToolArguments(updateTool, { + name: "patch_tasks", + arguments: updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }), + } as never); + assert.equal(validated.tasks[0].parentId, -1); + assert.equal(validated.tasks[0].position, -1); +}); + +test("validated update sentinels preserve order and map legacy null parent to root", async () => { + const harness = registeredTools(); + await execute(harness.tools.get("set_tasks"), { + mode: "append", + tasks: [ + { name: "Parent", children: [{ name: "Child" }] }, + { name: "Sibling" }, + ], + }); + + await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 3, status: "blocked" }] }); + assert.deepEqual( + harness.state.list().filter((task) => task.parentId === null).map((task) => task.id), + [1, 3], + ); + + await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 2, parentId: null }] }); + assert.equal(harness.state.get([2])[0]?.parentId, null); +}); + +test("patch_tasks explains an auto-cleared empty tree without recreating tasks", async () => { + const harness = registeredTools(); + + await assert.rejects( + execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 1, status: "done" }] }), + /Patch failed · 1 validation error\n- task tree is empty and may have auto-cleared after completion; do not recreate completed tasks\. Use set_tasks only for genuinely new or remaining work/, + ); + assert.deepEqual(harness.state.list(), []); + assert.equal(harness.changes(), 0); +}); + +test("set_tasks appends optional-description tasks and persists a complete snapshot", async () => { + const harness = registeredTools(); + const result = await execute(harness.tools.get("set_tasks"), { + mode: "append", + tasks: [{ name: "Parent", children: [{ name: "Child", description: "Useful detail" }] }], + }); + + assert.equal(result.content[0].text, "Added 2 tasks: 1, 2. 0/2 done."); + assert.equal(result.details.action, "append"); + assert.equal(result.details.snapshot.tasks.length, 2); + assert.equal(result.details.snapshot.tasks[0].description, undefined); + assert.equal(result.details.snapshot.nextId, 3); + assert.equal(harness.changes(), 1); + assert.deepEqual(harness.autoClearRestarts, [false]); +}); + +test("set_tasks atomically replaces or clears the tree and resets IDs", async () => { + const harness = registeredTools(); + await execute(harness.tools.get("set_tasks"), { + mode: "append", + tasks: [{ name: "Old", status: "done" }], + }); + + const replaced = await execute(harness.tools.get("set_tasks"), { + mode: "replace", + tasks: [{ name: "New", status: "in-progress" }], + }); + assert.equal(replaced.content[0].text, "Replaced task tree with 1 task. 0/1 done. Task IDs restart at 1."); + assert.deepEqual(harness.state.list().map((task) => [task.id, task.name]), [[1, "New"]]); + + const cleared = await execute(harness.tools.get("set_tasks"), { + mode: "replace", + tasks: [], + force: true, + }); + assert.equal(cleared.content[0].text, "Cleared task tree. Task IDs reset to 1."); + assert.deepEqual(harness.state.snapshot(), { tasks: [], nextId: 1 }); + assert.deepEqual(harness.autoClearRestarts, [false, true, true]); +}); + +test("tool validation errors preserve atomic state", async () => { + const harness = registeredTools(); + await execute(harness.tools.get("set_tasks"), { + mode: "append", + tasks: [{ name: "Parent", children: [{ name: "Child" }] }], + }); + const before = harness.state.snapshot(); + + await assert.rejects( + execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 1, parentId: 2 }] }), + /Patch failed · 1 validation error\n- task [12]: parentId creates a cycle/, + ); + assert.deepEqual(harness.state.snapshot(), before); + assert.equal(harness.changes(), 1); +}); + +test("rm_task requires and reports a constrained removal reason", async () => { + const harness = registeredTools(); + await execute(harness.tools.get("set_tasks"), { + mode: "append", + tasks: [{ name: "Duplicate" }], + }); + const removed = await execute(harness.tools.get("rm_task"), { ids: [1], reason: "duplicate" }); + assert.match(removed.content[0].text, /Reason: duplicate\.$/); + assert.deepEqual(harness.state.list(), []); +}); + +test("list is compact and get returns available details for multiple IDs", async () => { + const harness = registeredTools(); + await execute(harness.tools.get("set_tasks"), { + mode: "append", + tasks: [{ name: "Parent", description: "Detailed", children: [{ name: "Child" }] }], + }); + + const list = await execute(harness.tools.get("list_task"), {}); + assert.equal(list.content[0].text, "1 [pending] Parent\n└─ 2 [pending] Child"); + + const get = await execute(harness.tools.get("get_task"), { ids: [1, 2] }); + const parsed = JSON.parse(get.content[0].text); + assert.equal(parsed[0].description, "Detailed"); + assert.deepEqual(parsed[0].childrenIds, [2]); + assert.equal(parsed[1].description, undefined); + assert.equal(parsed[1].parentId, 1); +}); + +test("get_task exposes cumulative timing while list output stays compact", async () => { + const startedAt = Date.UTC(2026, 0, 1); + let now = startedAt; + const harness = registeredTools(new TaskState(() => now)); + const added = await execute(harness.tools.get("set_tasks"), { + mode: "append", + tasks: [{ name: "Timed", status: "in-progress" }], + }); + assert.equal(added.content[0].text, "Added 1 task: 1. 0/1 done."); + assert.equal(added.details.snapshot.timings[0].activeStartedAt, startedAt); + + now += 65_432; + const list = await execute(harness.tools.get("list_task"), {}); + assert.equal(list.content[0].text, "1 [in-progress] Timed"); + + const active = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text); + assert.deepEqual(active[0], { + id: 1, + name: "Timed", + status: "in-progress", + parentId: null, + childrenIds: [], + startedAt: new Date(startedAt).toISOString(), + durationMs: 65_432, + }); + + now += 568; + const updated = await execute(harness.tools.get("patch_tasks"), { + tasks: [{ id: 1, status: "blocked" }], + }); + assert.equal(updated.content[0].text, "Updated 1 task: 1. 0/1 done."); + + now += 10_000; + const paused = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text); + assert.equal(paused[0].startedAt, new Date(startedAt).toISOString()); + assert.equal(paused[0].stoppedAt, new Date(startedAt + 66_000).toISOString()); + assert.equal(paused[0].durationMs, 66_000); +}); diff --git a/extensions/todo-herdr/tests/ui.test.ts b/extensions/todo-herdr/tests/ui.test.ts new file mode 100644 index 0000000..340acd6 --- /dev/null +++ b/extensions/todo-herdr/tests/ui.test.ts @@ -0,0 +1,910 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts"; +import { TaskState } from "../src/state.ts"; +import { + formatDuration, + renderProgressHeader, + renderTaskLine, + renderToolResult, + renderTodoPanel, + renderTree, + renderWidgetTree, + snapshotStats, +} from "../src/ui.ts"; +import { clearTaskWidget, TODO_ANIMATION_INTERVAL_MS, updateTaskWidget } from "../src/widget.ts"; + +const plainTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + strikethrough: (text: string) => `~${text}~`, +}; + +type TestTui = { requestRender(): void }; +type TestWidget = { render(width: number): string[]; dispose(): void }; + +function mountWidget(widget: unknown, tui: TestTui = { requestRender() {} }, theme: unknown = plainTheme): TestWidget { + return (widget as (tui: TestTui, theme: unknown) => TestWidget)(tui, theme); +} + +function setSubagentsIntegration(integration: unknown): void { + const globals = globalThis as typeof globalThis & { __pi_interactive_subagents?: unknown }; + if (integration === undefined) delete globals.__pi_interactive_subagents; + else globals.__pi_interactive_subagents = integration; +} + +function withSubagentsIntegration(integration: unknown, callback: () => T): T { + const globals = globalThis as typeof globalThis & { __pi_interactive_subagents?: unknown }; + const previous = globals.__pi_interactive_subagents; + setSubagentsIntegration(integration); + try { + return callback(); + } finally { + setSubagentsIntegration(previous); + } +} + +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function fixture(): TaskState { + const state = new TaskState(() => 0); + state.add([ + { + name: "Build auth", + description: "Auth work", + status: "in-progress", + children: [ + { + name: "Resolve token issue", + description: "Investigate token validation and provider behavior.", + status: "blocked", + children: [{ name: "Inspect provider response", description: "Inspect response" }], + }, + { name: "Add middleware", description: "Middleware" }, + ], + }, + { name: "Define schema", description: "Schema", status: "done" }, + ]); + return state; +} + +test("renders an adaptive progress bar and stays within width", () => { + const stats = fixture().stats(); + const wide = stripAnsi(renderProgressHeader(stats, plainTheme as never, 40)); + assert.match(wide, /^ Todos 1\/5 · !1 ━{4}─{16}$/); + assert.ok(visibleWidth(wide) <= 40); + + const narrow = renderProgressHeader(stats, plainTheme as never, 12); + assert.ok(visibleWidth(narrow) <= 12); + assert.doesNotMatch(stripAnsi(narrow), /━|─/); +}); + +test("renders lightweight connectors without a detached root continuation", () => { + const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi); + assert.deepEqual(output, [ + " ◉ 1. Build auth · 00:00", + " ├─ ! 2. Resolve token issue", + " │ └─ ○ 3. Inspect provider response", + " └─ ○ 4. Add middleware", + " ✓ 5. ~Define schema~", + ]); +}); + +test("collapses completed widget subtrees while keeping list trees expanded", () => { + const state = new TaskState(() => 0); + state.add([ + { + name: "Release", + children: [ + { + name: "Completed branch", + children: [ + { name: "Done one", status: "done" }, + { name: "Done two", status: "done" }, + ], + }, + { name: "Open branch", children: [{ name: "Open leaf" }] }, + ], + }, + { + name: "Pending parent", + children: [{ name: "Finished child", status: "done" }], + }, + ]); + + const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); + assert.deepEqual(widgetLines, [ + " ├─ I. ○ Release", + " │ ├─ 1. ○ Completed branch · 2/2 done", + " │ └─ 2. ○ Open branch", + " │ └─ ○ Open leaf", + " └─ II. ○ Pending parent · 1/1 done", + ]); + + const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi); + assert.ok(fullTree.some((line) => line.includes("Done one"))); + assert.ok(fullTree.some((line) => line.includes("Finished child"))); + + state.update([{ id: 3, status: "pending" }]); + const reopened = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); + assert.ok(reopened.some((line) => line.includes("Done one"))); + assert.ok(reopened.some((line) => line.includes("Done two"))); +}); + +test("uses only the outline levels needed while leaving leaf tasks unlabeled", () => { + const flatState = new TaskState(() => 0); + flatState.add([{ name: "First leaf" }, { name: "Second leaf" }]); + assert.deepEqual(renderWidgetTree(flatState.list(), plainTheme as never, 80).map(stripAnsi), [ + " ├─ ○ First leaf", + " └─ ○ Second leaf", + ]); + + const twoLevelState = new TaskState(() => 0); + twoLevelState.add([ + { name: "First group", children: [{ name: "First nested leaf" }] }, + { name: "Second group", children: [{ name: "Second nested leaf" }] }, + ]); + assert.deepEqual(renderWidgetTree(twoLevelState.list(), plainTheme as never, 80).map(stripAnsi), [ + " ├─ 1. ○ First group", + " │ └─ ○ First nested leaf", + " └─ 2. ○ Second group", + " └─ ○ Second nested leaf", + ]); + + const state = new TaskState(() => 0); + state.add([ + { name: "Category one" }, + { name: "Category two" }, + { name: "Category three" }, + { + name: "Category four", + children: [ + { name: "First item", children: [{ name: "Leaf sub-item" }] }, + { name: "Second item" }, + ], + }, + { name: "Category five", children: [{ name: "Reset item numbering" }] }, + ]); + + const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); + assert.deepEqual(lines, [ + " ├─ I. ○ Category one", + " ├─ II. ○ Category two", + " ├─ III. ○ Category three", + " ├─ IV. ○ Category four", + " │ ├─ 1. ○ First item", + " │ │ └─ ○ Leaf sub-item", + " │ └─ 2. ○ Second item", + " └─ V. ○ Category five", + " └─ 1. ○ Reset item numbering", + ]); + + const narrow = renderWidgetTree(state.list(), plainTheme as never, 18); + assert.ok(narrow.every((line) => visibleWidth(line) <= 18)); + + const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi); + assert.match(statusPriority[3] ?? "", /○/); + const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 10).map(stripAnsi); + assert.match(childStatusPriority[4] ?? "", /○/); + const leafStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 12).map(stripAnsi); + assert.match(leafStatusPriority[5] ?? "", /○/); + const listLeafStatusPriority = renderTree(state.list(), plainTheme as never, 12).map(stripAnsi); + assert.match(listLeafStatusPriority[5] ?? "", /○/); + + const timedState = new TaskState(() => 0); + timedState.add([ + { name: "Timed category", status: "in-progress", children: [{ name: "Timed item", status: "in-progress" }] }, + ]); + const timedRoot = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 15)[0] ?? ""); + const timedChild = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 18)[1] ?? ""); + assert.match(timedRoot, /◉/); + assert.match(timedChild, /◉/); +}); + +test("animates only in-progress widget circles while preserving narrow-width status markers", () => { + const state = new TaskState(() => 0); + state.add([ + { name: "Active", status: "in-progress" }, + { name: "Pending" }, + { name: "Blocked", status: "blocked" }, + { name: "Done", status: "done" }, + ]); + + const orbitFrames = ["◐", "◓", "◑", "◒"]; + for (const [frame, icon] of orbitFrames.entries()) { + const lines = renderWidgetTree(state.list(), plainTheme as never, 80, " ", frame).map(stripAnsi); + assert.match(lines[0] ?? "", new RegExp(icon)); + assert.match(lines[1] ?? "", /○/); + assert.match(lines[2] ?? "", /!/); + assert.match(lines[3] ?? "", /✓/); + + const narrow = renderWidgetTree(state.list(), plainTheme as never, 6, " ", frame).map(stripAnsi); + assert.match(narrow[0] ?? "", new RegExp(icon)); + assert.ok(narrow.every((line) => visibleWidth(line) <= 6)); + } + + assert.match(stripAnsi(renderTree(state.list(), plainTheme as never, 80)[0] ?? ""), /◉/); +}); + +test("renumbers widget labels after task reordering and reparenting", () => { + const state = new TaskState(() => 0); + state.add([ + { name: "Category A", children: [{ name: "A1" }, { name: "A2" }] }, + { name: "Category B", children: [{ name: "B1" }] }, + ]); + state.update([ + { id: 4, position: 0 }, + { id: 3, parentId: 4, position: 0 }, + ]); + + const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); + assert.deepEqual(lines, [ + " ├─ 1. ○ Category B", + " │ ├─ ○ A2", + " │ └─ ○ B1", + " └─ 2. ○ Category A", + " └─ ○ A1", + ]); +}); + +test("caps the widget at twelve task rows with an honest overflow summary", () => { + const state = new TaskState(() => 0); + state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` }))); + + const lines = renderWidgetTree(state.list(), plainTheme as never, 60).map(stripAnsi); + assert.equal(lines.length, 13); + assert.match(lines[0] ?? "", /Task 1/); + assert.match(lines[11] ?? "", /Task 12/); + assert.equal(lines[12], " … 8 more tasks"); + assert.ok(lines.every((line) => visibleWidth(line) <= 60)); + + const collapsedOverflow = new TaskState(() => 0); + collapsedOverflow.add([ + ...Array.from({ length: 12 }, (_, index) => ({ name: `Visible ${index + 1}` })), + { + name: "Hidden completed branch", + children: [ + { name: "Hidden done one", status: "done" as const }, + { name: "Hidden done two", status: "done" as const }, + ], + }, + ]); + const overflowLines = renderWidgetTree(collapsedOverflow.list(), plainTheme as never, 60).map(stripAnsi); + assert.equal(overflowLines[12], " … 3 more tasks"); +}); + +test("hides a completed widget summary before squeezing the task name", () => { + const state = new TaskState(() => 0); + state.add([{ name: "A long parent task name", children: [{ name: "Done", status: "done" }] }]); + + const line = renderWidgetTree(state.list(), plainTheme as never, 20)[0] ?? ""; + assert.ok(visibleWidth(line) <= 20); + assert.doesNotMatch(stripAnsi(line), /done$/); +}); + +test("formats compact timers and adds hours only when needed", () => { + assert.equal(formatDuration(0), "00:00"); + assert.equal(formatDuration(65_432), "01:05"); + assert.equal(formatDuration(3_599_999), "59:59"); + assert.equal(formatDuration(3_600_000), "01:00:00"); + + let now = 0; + const state = new TaskState(() => now); + state.add([{ name: "Timed", status: "in-progress" }]); + now = 65_432; + const line = stripAnsi(renderTaskLine(state.get([1])[0]!, " ", plainTheme as never, 24)); + assert.match(line, / · 01:05$/); + assert.ok(visibleWidth(line) <= 24); +}); + +test("truncates every task row at narrow widths", () => { + const lines = renderTree(fixture().list(), plainTheme as never, 20); + assert.ok(lines.every((line) => visibleWidth(line) <= 20)); + assert.ok(lines.some((line) => stripAnsi(line).includes("..."))); +}); + +test("uses semantic icon colors and old-plugin text styling", () => { + const calls: Array<{ color: string; text: string }> = []; + const theme = { + fg: (color: string, text: string) => { + calls.push({ color, text }); + return text; + }, + bold: (text: string) => text, + strikethrough: (text: string) => `~${text}~`, + }; + const tasks = fixture().list(); + for (const task of tasks) renderTaskLine(task, "", theme as never, 80); + + assert.ok(calls.some((call) => call.color === "dim" && call.text === "○")); + assert.ok(calls.some((call) => call.color === "warning" && call.text === "◉")); + assert.ok(calls.some((call) => call.color === "error" && call.text === "!")); + assert.ok(calls.some((call) => call.color === "success" && call.text === "✓")); + assert.ok(calls.some((call) => call.color === "warning" && call.text === "Build auth")); + assert.ok(calls.some((call) => call.color === "dim" && call.text === "~Define schema~")); + assert.ok(!calls.some((call) => call.color === "error" && call.text === "Resolve token issue")); +}); + +test("selects the most specific in-progress task and compresses parallel leaves", () => { + const state = new TaskState(); + state.add([ + { + name: "Parent", + description: "Parent", + status: "in-progress", + children: [{ name: "Child", description: "Child", status: "in-progress" }], + }, + { name: "Parallel", description: "Parallel", status: "in-progress" }, + ]); + assert.equal(currentTaskLabel(state.list()), "#2 Child +1"); + assert.deepEqual(currentTaskTokens(state.list()), { + task: "#2 Child", + task_count: "+1", + task_progress: "0/3", + }); +}); + +test("uses blocked tasks only when nothing is in progress", () => { + const state = new TaskState(); + state.add([ + { name: "Blocked one", description: "One", status: "blocked" }, + { name: "Blocked two", description: "Two", status: "blocked" }, + ]); + assert.equal(currentTaskLabel(state.list()), "#1 Blocked one +1"); + assert.deepEqual(currentTaskTokens(state.list()), { + task: "#1 Blocked one", + task_count: "+1", + task_progress: "0/2", + }); + state.update([{ id: 1, status: "done" }, { id: 2, status: "done" }]); + assert.equal(currentTaskLabel(state.list()), null); + assert.deepEqual(currentTaskTokens(state.list()), { + task: null, + task_count: null, + task_progress: null, + }); +}); + +test("clears sidebar progress for an empty task tree", () => { + assert.deepEqual(currentTaskTokens([]), { + task: null, + task_count: null, + task_progress: null, + }); +}); + +test("hides an empty widget and renders a populated one", () => { + let widget: unknown = "unset"; + const ctx = { + ui: { + setWidget: (_id: string, content: unknown) => { + widget = content; + }, + }, + }; + const state = new TaskState(); + updateTaskWidget(state, ctx as never, true); + assert.equal(widget, undefined); + + state.add([{ name: "Task", description: "Task" }]); + updateTaskWidget(state, ctx as never, true); + assert.equal(typeof widget, "function"); + const component = mountWidget(widget); + const lines = component.render(30).map(stripAnsi); + assert.equal(lines.length, 2); + assert.match(lines[0] ?? "", /^Todos.*0\/1/); + assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/); + assert.ok(lines.every((line) => visibleWidth(line) <= 30)); +}); + +test("renders compact unboxed solo panels at narrow widths without overflowing", () => { + const state = new TaskState(() => 0); + state.add([{ name: "A long task name", status: "blocked" }]); + + for (const width of [80, 60]) { + const lines = renderTodoPanel(state.list(), plainTheme as never, width).map(stripAnsi); + assert.ok(lines.every((line) => visibleWidth(line) <= width)); + assert.match(lines[0] ?? "", /^Todos.*0\/1.*!1/); + assert.match(lines[0] ?? "", /━|─/); + assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/); + } + + const narrow = renderTodoPanel(state.list(), plainTheme as never, 20).map(stripAnsi); + assert.ok(narrow.every((line) => visibleWidth(line) <= 20)); + assert.doesNotMatch(narrow.join("\n"), /[╭╮╰╯]/); + assert.match(narrow[0] ?? "", /0\/1/); + + const boundary = new TaskState(() => 0); + boundary.add(Array.from({ length: 13 }, (_, index) => ({ name: `Boundary ${index + 1}` }))); + const boundaryLines = renderTodoPanel(boundary.list(), plainTheme as never, 80).map(stripAnsi); + assert.equal(boundaryLines.length, 14); + assert.match(boundaryLines[12] ?? "", /Boundary 12/); + assert.match(boundaryLines[13] ?? "", /… 1 more task/); + assert.ok(boundaryLines.every((line) => !line.includes("Boundary 13"))); +}); + +test("passes the current Pi theme unchanged to composed Subagents rendering", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Todo" }]); + const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + strikethrough: (text: string) => text, + }; + let receivedTheme: unknown; + const statusWidget = { + hasContent: () => true, + getBodyRowCount: () => 1, + isClaimed: () => true, + render: (_width: number, _maxBodyRows?: number, providerTheme?: unknown) => { + receivedTheme = providerTheme; + return ["subagents"]; + }, + claim: () => () => {}, + }; + withSubagentsIntegration({ statusWidget }, () => { + let widget: unknown; + const ctx = { + mode: "tui" as const, + ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, + }; + updateTaskWidget(state, ctx as never, true); + const component = mountWidget(widget, { requestRender() {} }, theme); + component.render(80); + assert.equal(receivedTheme, theme); + component.dispose(); + }); +}); + +test("keeps an unboxed Subagents header plus capped body rows composed at 120 columns", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Todo" }]); + let receivedMaxBodyRows: number | undefined; + const statusWidget = { + hasContent: () => true, + getBodyRowCount: () => 2, + isClaimed: () => true, + render: (_width: number, maxBodyRows?: number) => { + receivedMaxBodyRows = maxBodyRows; + return ["Subagents", "agent one", "agent two"]; + }, + claim: () => () => {}, + }; + withSubagentsIntegration({ statusWidget }, () => { + let widget: unknown; + const ctx = { + mode: "tui" as const, + ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, + }; + updateTaskWidget(state, ctx as never, true); + const component = mountWidget(widget); + const lines = component.render(120).map(stripAnsi); + assert.equal(receivedMaxBodyRows, 2); + assert.ok(lines.some((line) => line.includes("Subagents"))); + assert.ok(lines.some((line) => line.includes("agent two"))); + assert.equal(lines[2], ""); + assert.ok(lines.every((line) => visibleWidth(line) <= 120)); + assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/); + component.dispose(); + }); +}); + +test("switches from side-by-side to stacked panels at the 120-column breakpoint", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Todo" }]); + let claimCount = 0; + let releaseCount = 0; + const renderCalls: Array<[number, number | undefined]> = []; + const statusWidget = { + hasContent: () => true, + getBodyRowCount: () => 2, + isClaimed: () => true, + render: (width: number, maxBodyRows?: number) => { + renderCalls.push([width, maxBodyRows]); + return ["Subagents"]; + }, + claim: (_onChange: () => void) => { + claimCount += 1; + return () => { + releaseCount += 1; + }; + }, + }; + withSubagentsIntegration({ statusWidget }, () => { + let widget: unknown; + const ctx = { + mode: "tui" as const, + ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, + }; + const tui = { requestRender() {} }; + updateTaskWidget(state, ctx as never, true); + const component = mountWidget(widget, tui); + const side = component.render(121).map(stripAnsi); + assert.match(side[0] ?? "", /Todos.*Subagents/); + assert.ok(side.every((line) => visibleWidth(line) === 121)); + assert.equal(side[1]?.slice(61), " ".repeat(60)); + assert.doesNotMatch(side.join("\n"), /[╭╮╰╯]/); + + state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Extra ${index + 1}` }))); + const unequal = component.render(121).map(stripAnsi); + const rightPanel = unequal.map((line) => line.slice(61)); + assert.equal(rightPanel.at(-1), " ".repeat(60)); + assert.doesNotMatch(unequal.join("\n"), /[╭╮╰╯]/); + + const stacked = component.render(120); + assert.match(stripAnsi(stacked[0] ?? ""), /Todos/); + const stackedSubagentsIndex = stacked.findIndex((line) => stripAnsi(line).includes("Subagents")); + assert.ok(stackedSubagentsIndex > 0); + assert.ok(stackedSubagentsIndex < stacked.length); + assert.ok(stacked.every((line) => visibleWidth(line) <= 120)); + assert.equal(stacked[stackedSubagentsIndex - 1], ""); + assert.doesNotMatch(stacked.map(stripAnsi).join("\n"), /[╭╮╰╯]/); + assert.deepEqual(renderCalls.map(([, maxRows]) => maxRows), [10, 10, 2]); + assert.equal(claimCount, 1); + + component.dispose(); + assert.equal(releaseCount, 1); + }); +}); + +test("falls back to a full-width Todo panel for absent and old Subagents APIs", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Todo" }]); + let widget: unknown; + const ctx = { + mode: "tui" as const, + ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, + }; + const tui = { requestRender() {} }; + const empty = { + statusWidget: { + hasContent: () => true, + getBodyRowCount: () => 1, + render: () => [], + claim: () => () => {}, + isClaimed: () => true, + }, + }; + const malformed = { + statusWidget: { + hasContent: () => true, + getBodyRowCount: () => 1, + render: () => new Array(1), + claim: () => () => {}, + isClaimed: () => true, + }, + }; + for (const integration of [undefined, { statusWidget: { render: () => [] } }, empty, malformed]) { + withSubagentsIntegration(integration, () => { + updateTaskWidget(state, ctx as never, true); + const component = mountWidget(widget, tui); + const lines = component.render(80).map(stripAnsi); + assert.equal(lines.length, 2); + assert.match(lines[0] ?? "", /Todos/); + assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/); + component.dispose(); + }); + } +}); + +test("reacquires a claim after the provider invalidates its callback", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Todo" }]); + let claims = 0; + let releases = 0; + let providerCallback: (() => void) | undefined; + let providerClaimed = false; + withSubagentsIntegration({ + statusWidget: { + hasContent: () => true, + getBodyRowCount: () => 1, + render: () => ["subagents"], + isClaimed: (onChange: () => void) => providerClaimed && providerCallback === onChange, + claim: (onChange: () => void) => { + claims += 1; + providerCallback = onChange; + providerClaimed = true; + return () => { + releases += 1; + if (providerCallback === onChange) { + providerCallback = undefined; + providerClaimed = false; + } + }; + }, + }, + }, () => { + let widget: unknown; + const ctx = { + mode: "tui" as const, + ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, + }; + updateTaskWidget(state, ctx as never, true); + const component = mountWidget(widget); + component.render(80); + assert.equal(claims, 1); + component.render(80); + assert.equal(claims, 1); + + providerClaimed = false; + providerCallback = undefined; + component.render(80); + assert.equal(claims, 2); + assert.equal(releases, 1); + component.dispose(); + assert.equal(releases, 2); + }); +}); + +test("does not mask Todo rendering failures as optional integration failures", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Todo" }]); + withSubagentsIntegration({ + statusWidget: { + hasContent: () => true, + getBodyRowCount: () => 1, + render: () => ["subagents"], + isClaimed: () => true, + claim: () => () => {}, + }, + }, () => { + let widget: unknown; + const ctx = { + mode: "tui" as const, + ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, + }; + const failure = new Error("Todo renderer failed"); + const badTheme = { + fg: () => { + throw failure; + }, + bold: (text: string) => text, + strikethrough: (text: string) => text, + }; + updateTaskWidget(state, ctx as never, true); + const component = mountWidget(widget, { requestRender() {} }, badTheme); + assert.throws(() => component.render(80), (error) => error === failure); + component.dispose(); + }); +}); + +test("does not compose or claim Subagents outside the TUI mode", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Todo" }]); + let claims = 0; + withSubagentsIntegration({ + statusWidget: { + hasContent: () => true, + getBodyRowCount: () => 1, + render: () => ["Subagents"], + claim: () => { + claims += 1; + return () => {}; + }, + }, + }, () => { + let widget: unknown; + const ctx = { + mode: "rpc" as const, + ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, + }; + updateTaskWidget(state, ctx as never, true); + const component = mountWidget(widget); + const lines = component.render(80).map(stripAnsi); + assert.equal(claims, 0); + assert.ok(lines.every((line) => !line.includes("Subagents"))); + component.dispose(); + }); +}); + +test("shares ten stacked body rows and keeps Todo overflow honest", () => { + const state = new TaskState(() => 0); + state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` }))); + const calls: Array<[number, number | undefined]> = []; + const statusWidget = { + hasContent: () => true, + getBodyRowCount: () => 3, + isClaimed: () => true, + render: (width: number, maxBodyRows?: number) => { + calls.push([width, maxBodyRows]); + return ["one", "two", "three"]; + }, + claim: () => () => {}, + }; + withSubagentsIntegration({ statusWidget }, () => { + let widget: unknown; + const ctx = { + mode: "tui" as const, + ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, + }; + updateTaskWidget(state, ctx as never, true); + const component = mountWidget(widget); + const lines = component.render(120).map(stripAnsi); + assert.deepEqual(calls, [[120, 3]]); + assert.ok(lines.some((line) => line.includes("… 14 more tasks"))); + assert.ok(lines.every((line) => visibleWidth(line) <= 120)); + component.dispose(); + }); +}); + +test("releases a Subagents claim when Todos hide or clear", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Todo" }]); + let releases = 0; + withSubagentsIntegration({ + statusWidget: { + hasContent: () => true, + getBodyRowCount: () => 1, + isClaimed: () => true, + render: () => ["subagents"], + claim: () => () => { + releases += 1; + }, + }, + }, () => { + let widget: unknown; + const ctx = { + mode: "tui" as const, + ui: { setWidget: (_id: string, content: unknown) => (widget = content) }, + }; + updateTaskWidget(state, ctx as never, true); + const component = mountWidget(widget); + component.render(80); + updateTaskWidget(state, ctx as never, false); + assert.equal(releases, 1); + + updateTaskWidget(state, ctx as never, true); + const second = mountWidget(widget); + second.render(80); + state.remove([1]); + updateTaskWidget(state, ctx as never, true); + assert.equal(releases, 2); + second.dispose(); + }); +}); + +test("handles zero and one-column panel widths cleanly", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Todo" }]); + assert.deepEqual(renderTodoPanel(state.list(), plainTheme as never, 0), []); + const oneColumn = renderTodoPanel(state.list(), plainTheme as never, 1); + assert.ok(oneColumn.length > 0); + assert.ok(oneColumn.every((line) => visibleWidth(line) <= 1)); +}); + +test("ignores stale widget generations when Pi replaces the widget", () => { + const state = new TaskState(() => 0); + state.add([{ name: "Todo", status: "in-progress" }]); + let claims = 0; + let releases = 0; + withSubagentsIntegration({ + statusWidget: { + hasContent: () => true, + getBodyRowCount: () => 1, + isClaimed: () => true, + render: () => ["subagents"], + claim: () => { + claims += 1; + return () => { + releases += 1; + }; + }, + }, + }, () => { + const widgets: unknown[] = []; + const ctx = { + mode: "tui" as const, + ui: { setWidget: (_id: string, content: unknown) => widgets.push(content) }, + }; + const tui = { requestRender() {} }; + updateTaskWidget(state, ctx as never, true, 5); + const first = mountWidget(widgets[0], tui); + first.render(80); + + updateTaskWidget(state, ctx as never, true, 5); + const second = mountWidget(widgets[1], tui); + second.render(80); + assert.equal(claims, 2); + assert.equal(releases, 1); + assert.deepEqual(first.render(80), []); + + first.dispose(); + assert.equal(releases, 1); + clearTaskWidget(ctx as never); + assert.equal(releases, 2); + second.dispose(); + }); +}); + +test("animates active todos twice per second", () => { + assert.equal(TODO_ANIMATION_INTERVAL_MS, 500); +}); + +test("live widgets request rerenders and dispose their interval", async () => { + let now = 0; + const state = new TaskState(() => now); + state.add([{ name: "Timed", status: "in-progress" }]); + + let widget: unknown; + const ctx = { + ui: { + setWidget: (_id: string, content: unknown) => { + widget = content; + }, + }, + }; + updateTaskWidget(state, ctx as never, true, 5); + + let renderRequests = 0; + const observedFrames = new Set(); + let component: TestWidget; + component = mountWidget(widget, { + requestRender() { + renderRequests += 1; + const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0]; + if (frame !== undefined) observedFrames.add(frame); + }, + }); + + try { + assert.match(stripAnsi(component.render(40)[1] ?? ""), /◐.* · 00:00/); + now = 65_000; + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.ok(renderRequests > 0); + assert.ok(observedFrames.size > 0); + assert.match(stripAnsi(component.render(40)[1] ?? ""), /[◐◓◑◒].* · 01:05/); + } finally { + component.dispose(); + } + + const requestsAfterDispose = renderRequests; + await new Promise((resolve) => setTimeout(resolve, 15)); + assert.equal(renderRequests, requestsAfterDispose); +}); + +test("tool results collapse summaries and expand themed details", () => { + const state = fixture(); + const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() }; + const result = { content: [{ type: "text", text: "list" }], details }; + const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi); + assert.deepEqual(collapsed, ["✓ 5 tasks · 1/5 done"]); + + const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi); + assert.equal(expanded.length, 6); + assert.match(expanded[1] ?? "", /Build auth/); +}); + +test("tool errors show one summary line until expanded", () => { + const result = { + content: [{ type: "text", text: "Update failed · 2 validation errors\n- first\n- second" }], + isError: true, + }; + const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi); + assert.deepEqual(collapsed, ["✗ Update failed · 2 validation errors"]); + const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi); + assert.deepEqual(expanded, ["✗ Update failed · 2 validation errors", " - first", " - second"]); +}); + +test("expanded task details tolerate an omitted description", () => { + const state = new TaskState(); + state.add([{ name: "Name only" }]); + const result = { + content: [{ type: "text", text: "details" }], + details: { action: "get" as const, snapshot: state.snapshot(), affected: state.list() }, + }; + const lines = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi); + assert.deepEqual(lines, ["✓ 1 task", " ○ 1. Name only", " Parent root · Children none"]); +}); + +test("snapshot stats count every task node", () => { + assert.deepEqual(snapshotStats(fixture().list()), { + total: 5, + pending: 2, + inProgress: 1, + blocked: 1, + done: 1, + }); +}); diff --git a/extensions/todo-herdr/tsconfig.json b/extensions/todo-herdr/tsconfig.json new file mode 100644 index 0000000..47f429e --- /dev/null +++ b/extensions/todo-herdr/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["index.ts", "src/**/*.ts", "tests/**/*.ts"] +} diff --git a/extensions/update/LICENSE b/extensions/update/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/update/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/update/README.md b/extensions/update/README.md new file mode 100644 index 0000000..b5b5b9f --- /dev/null +++ b/extensions/update/README.md @@ -0,0 +1,7 @@ +# @_jb/pi-update + +Pi extension providing controlled Pi and package update commands. + +```bash +pi install npm:@_jb/pi-update +``` diff --git a/extensions/update/package.json b/extensions/update/package.json new file mode 100644 index 0000000..9d22512 --- /dev/null +++ b/extensions/update/package.json @@ -0,0 +1,22 @@ +{ + "name": "@_jb/pi-update", + "version": "0.1.0", + "description": "Pi extension for controlled Pi and package updates.", + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.19.0" }, + "keywords": ["pi-package", "pi-extension", "update"], + "files": ["index.ts", "updater.ts", "README.md", "LICENSE"], + "pi": { "extensions": ["./index.ts"] }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "catalog:pi-peer" + }, + "publishConfig": { + "registry": "https://git.jb.codes/api/packages/jb/npm/" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git", + "directory": "extensions/update" + } +} diff --git a/extensions/workflow/LICENSE b/extensions/workflow/LICENSE new file mode 100644 index 0000000..79b4dd9 --- /dev/null +++ b/extensions/workflow/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/workflow/README.md b/extensions/workflow/README.md new file mode 100644 index 0000000..902a78c --- /dev/null +++ b/extensions/workflow/README.md @@ -0,0 +1,5 @@ +# workflow + +Persisted, gated feature workflows for Pi. The private Bun workspace retains the npm package name `@_jb/pi-workflow` under the repository's scoped package convention. + +This workspace is currently private and must not be published. Its remaining runtime dependencies on the `_workflows` reference implementation must first be merged into this directory; see `docs/todos.md` at the repository root. diff --git a/extensions/pi-workflow/agents/feature-designer.md b/extensions/workflow/agents/feature-designer.md similarity index 100% rename from extensions/pi-workflow/agents/feature-designer.md rename to extensions/workflow/agents/feature-designer.md diff --git a/extensions/pi-workflow/agents/feature-implementer.md b/extensions/workflow/agents/feature-implementer.md similarity index 100% rename from extensions/pi-workflow/agents/feature-implementer.md rename to extensions/workflow/agents/feature-implementer.md diff --git a/extensions/pi-workflow/agents/feature-verifier.md b/extensions/workflow/agents/feature-verifier.md similarity index 100% rename from extensions/pi-workflow/agents/feature-verifier.md rename to extensions/workflow/agents/feature-verifier.md diff --git a/extensions/pi-workflow/agents/general-discovery.md b/extensions/workflow/agents/general-discovery.md similarity index 100% rename from extensions/pi-workflow/agents/general-discovery.md rename to extensions/workflow/agents/general-discovery.md diff --git a/extensions/pi-workflow/agents/independent-reviewer.md b/extensions/workflow/agents/independent-reviewer.md similarity index 100% rename from extensions/pi-workflow/agents/independent-reviewer.md rename to extensions/workflow/agents/independent-reviewer.md diff --git a/extensions/pi-workflow/agents/ui-prototyper.md b/extensions/workflow/agents/ui-prototyper.md similarity index 100% rename from extensions/pi-workflow/agents/ui-prototyper.md rename to extensions/workflow/agents/ui-prototyper.md diff --git a/extensions/pi-workflow/config.test.ts b/extensions/workflow/config.test.ts similarity index 84% rename from extensions/pi-workflow/config.test.ts rename to extensions/workflow/config.test.ts index 1d968f8..1069966 100644 --- a/extensions/pi-workflow/config.test.ts +++ b/extensions/workflow/config.test.ts @@ -7,7 +7,7 @@ import { loadWorkflowConfig } from "./config.ts"; import { DEFAULT_WORKFLOW_CONFIG, MAX_CONCURRENCY } from "./domain.ts"; async function withTempDir(run: (directory: string) => Promise) { - const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-config-")); + const directory = await mkdtemp(path.join(tmpdir(), "workflow-config-")); try { await run(directory); } finally { @@ -42,7 +42,7 @@ test("configuration defaults to concurrency 8 with immutable ceiling 32", async test("a lowered ceiling clamps an inherited default", async () => { await withTempDir(async (directory) => { const agentDir = path.join(directory, "agent"); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { concurrency: { ceiling: 4 }, }); const globalOnly = await loadWorkflowConfig({ @@ -52,10 +52,10 @@ test("a lowered ceiling clamps an inherited default", async () => { }); assert.deepEqual(globalOnly.concurrency, { default: 4, ceiling: 4 }); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { concurrency: { default: 6, ceiling: 16 }, }); - await writeJson(path.join(directory, ".pi", "pi-workflow.json"), { + await writeJson(path.join(directory, ".pi", "workflow.json"), { concurrency: { ceiling: 3 }, }); const project = await loadWorkflowConfig({ @@ -70,13 +70,13 @@ test("a lowered ceiling clamps an inherited default", async () => { test("trusted project configuration layers over global configuration and may lower limits", async () => { await withTempDir(async (directory) => { const agentDir = path.join(directory, "agent"); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { concurrency: { default: 6, ceiling: 16 }, maxAssignmentsPerRun: 64, analysisTools: ["read", "grep", "find"], worktrees: { root: ".worktrees/global" }, }); - await writeJson(path.join(directory, ".pi", "pi-workflow.json"), { + await writeJson(path.join(directory, ".pi", "workflow.json"), { concurrency: { default: 4, ceiling: 8 }, maxReviewCycles: 5, analysisTools: ["read", "find", "ls"], @@ -100,7 +100,7 @@ test("trusted project configuration layers over global configuration and may low test("untrusted project configuration is never applied", async () => { await withTempDir(async (directory) => { const agentDir = path.join(directory, "agent"); - await writeJson(path.join(directory, ".pi", "pi-workflow.json"), { + await writeJson(path.join(directory, ".pi", "workflow.json"), { concurrency: { default: 1, ceiling: 1 }, unknownField: true, }); @@ -116,7 +116,7 @@ test("untrusted project configuration is never applied", async () => { test("trusted configuration can replace or disable standard-agent role overlays", async () => { await withTempDir(async (directory) => { const agentDir = path.join(directory, "agent"); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { standardAgents: { roles: { "codebase-analyzer": ["discover"], @@ -134,7 +134,7 @@ test("trusted configuration can replace or disable standard-agent role overlays" worker: ["implement", "verify"], }); - await writeJson(path.join(directory, ".pi", "pi-workflow.json"), { + await writeJson(path.join(directory, ".pi", "workflow.json"), { standardAgents: { enabled: false }, }); config = await loadWorkflowConfig({ @@ -149,7 +149,7 @@ test("trusted configuration can replace or disable standard-agent role overlays" test("invalid standard-agent names and roles fail before a run starts", async () => { await withTempDir(async (directory) => { const agentDir = path.join(directory, "agent"); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { standardAgents: { roles: { Worker: ["implement"] } }, }); await assert.rejects( @@ -157,7 +157,7 @@ test("invalid standard-agent names and roles fail before a run starts", async () /lowercase kebab case/, ); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { standardAgents: { roles: { worker: ["remediate"] } }, }); await assert.rejects( @@ -170,7 +170,7 @@ test("invalid standard-agent names and roles fail before a run starts", async () test("configuration cannot raise ceilings or activate unaudited tools", async () => { await withTempDir(async (directory) => { const agentDir = path.join(directory, "agent"); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { concurrency: { ceiling: 33 }, }); await assert.rejects( @@ -178,7 +178,7 @@ test("configuration cannot raise ceilings or activate unaudited tools", async () /between 1 and 32/, ); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { analysisTools: ["read", "bash"], }); await assert.rejects( @@ -191,10 +191,10 @@ test("configuration cannot raise ceilings or activate unaudited tools", async () test("project configuration cannot raise a lowered global ceiling", async () => { await withTempDir(async (directory) => { const agentDir = path.join(directory, "agent"); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { concurrency: { default: 4, ceiling: 8 }, }); - await writeJson(path.join(directory, ".pi", "pi-workflow.json"), { + await writeJson(path.join(directory, ".pi", "workflow.json"), { concurrency: { ceiling: 16 }, }); await assert.rejects( @@ -207,7 +207,7 @@ test("project configuration cannot raise a lowered global ceiling", async () => test("invalid numeric and path values fail before a run starts", async () => { await withTempDir(async (directory) => { const agentDir = path.join(directory, "agent"); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { maxAssignmentsPerRun: 1.5, }); await assert.rejects( @@ -215,7 +215,7 @@ test("invalid numeric and path values fail before a run starts", async () => { /must be an integer/, ); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { worktrees: { root: "../outside" }, }); await assert.rejects( @@ -223,7 +223,7 @@ test("invalid numeric and path values fail before a run starts", async () => { /must stay inside the repository/, ); - await writeJson(path.join(agentDir, "pi-workflow.json"), { + await writeJson(path.join(agentDir, "workflow.json"), { concurrency: { default: 8, ceiling: 8 }, }); await assert.rejects( diff --git a/extensions/pi-workflow/config.ts b/extensions/workflow/config.ts similarity index 98% rename from extensions/pi-workflow/config.ts rename to extensions/workflow/config.ts index 9f9582f..8f963a1 100644 --- a/extensions/pi-workflow/config.ts +++ b/extensions/workflow/config.ts @@ -294,14 +294,14 @@ export async function loadWorkflowConfig( const agentDir = options.agentDir ?? getAgentDir(); let config = cloneConfig(DEFAULT_WORKFLOW_CONFIG); - const globalFile = path.join(agentDir, "pi-workflow.json"); + const globalFile = path.join(agentDir, "workflow.json"); const globalValue = await readOptionalJson(globalFile); if (globalValue !== undefined) { config = applyConfigLayer(config, globalValue, globalFile); } if (options.projectTrusted) { - const projectFile = path.join(options.cwd, ".pi", "pi-workflow.json"); + const projectFile = path.join(options.cwd, ".pi", "workflow.json"); const projectValue = await readOptionalJson(projectFile); if (projectValue !== undefined) { config = applyConfigLayer(config, projectValue, projectFile); diff --git a/extensions/pi-workflow/context-transition.test.ts b/extensions/workflow/context-transition.test.ts similarity index 99% rename from extensions/pi-workflow/context-transition.test.ts rename to extensions/workflow/context-transition.test.ts index 090fa50..b2b1f35 100644 --- a/extensions/pi-workflow/context-transition.test.ts +++ b/extensions/workflow/context-transition.test.ts @@ -18,7 +18,7 @@ import { import type { WorkflowCommand, WorkflowEngine, WorkflowView } from "./engine.ts"; async function withTempDir(run: (directory: string) => Promise) { - const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-context-")); + const directory = await mkdtemp(path.join(tmpdir(), "workflow-context-")); try { await run(directory); } finally { @@ -226,7 +226,7 @@ test("/workflow start routes the exact request through the assistant", async () }).context, ); assert.equal(registered.userMessages.length, 1); - assert.match(registered.userMessages[0]!, /invoke pi_workflow/); + assert.match(registered.userMessages[0]!, /invoke workflow/); assert.ok( registered.userMessages[0]!.endsWith("\n\nBuild searchable workflow cards"), ); @@ -606,7 +606,7 @@ test("/workflow continue invokes and fences normal compaction callbacks", async }, }); await registered.command()("continue run-1", ctx); - assert.match(compactOptions?.customInstructions ?? "", /pi-workflow run example-feature/); + assert.match(compactOptions?.customInstructions ?? "", /workflow run example-feature/); assert.match(compactOptions?.customInstructions ?? "", /Approved plan/); compactOptions?.onComplete?.({ summary: "summary", @@ -945,7 +945,7 @@ test("fresh continuation claims shutdown target and consumes only in withSession await registered.command()("continue run-1", old.context); assert.equal(sent.length, 1); const handoffMessage = sent[0] as { content: string }; - assert.match(handoffMessage.content, /Continue pi-workflow run example-feature/); + assert.match(handoffMessage.content, /Continue workflow run example-feature/); const handoff = JSON.stringify(sent[0]); assert.match(handoff, /Handoff plan/); assert.doesNotMatch(handoff, /�|😀/); diff --git a/extensions/pi-workflow/context-transition.ts b/extensions/workflow/context-transition.ts similarity index 97% rename from extensions/pi-workflow/context-transition.ts rename to extensions/workflow/context-transition.ts index 28dcb2e..40b1319 100644 --- a/extensions/pi-workflow/context-transition.ts +++ b/extensions/workflow/context-transition.ts @@ -73,7 +73,7 @@ async function boundedPlan(view: WorkflowView) { function handoffMessage(view: WorkflowView, plan: string) { return [ - `Continue pi-workflow run ${deriveRunLabel(view)}.`, + `Continue workflow run ${deriveRunLabel(view)}.`, `Worktree: ${view.workspace.root ?? "missing"}`, `Plan revision: ${view.planArtifact?.revision ?? "missing"}`, "The workflow state remains authoritative. Inspect it before taking action.", @@ -121,7 +121,7 @@ async function consumeHandoff( const targetSessionFile = sessionFile(replacementCtx); const plan = await boundedPlan(current.view); await replacementCtx.sendMessage({ - customType: "pi-workflow-handoff", + customType: "workflow-handoff", content: handoffMessage(current.view, plan), display: true, }); @@ -192,7 +192,7 @@ async function continueNormalCompaction( }; const compacting = await engine.execute(command); const instructions = [ - `Prepare context for pi-workflow run ${deriveRunLabel(view)}.`, + `Prepare context for workflow run ${deriveRunLabel(view)}.`, `Preserve the worktree path, accepted Decisions, and plan revision ${view.planArtifact?.revision}.`, "Use this approved plan as the implementation focus:", plan, @@ -352,8 +352,8 @@ async function handleStart( await ctx.waitForIdle(); await pi.sendUserMessage( [ - "Start a managed pi-workflow for the request below.", - "Use the request text verbatim as the initial workflow request, choose a descriptive lowercase kebab-case featureName, create a unique idempotencyKey, and invoke pi_workflow with action \"start\" through the normal assistant tool path.", + "Start a managed workflow for the request below.", + "Use the request text verbatim as the initial workflow request, choose a descriptive lowercase kebab-case featureName, create a unique idempotencyKey, and invoke workflow with action \"start\" through the normal assistant tool path.", "", initialRequest, ].join("\n"), @@ -430,7 +430,7 @@ export function registerWorkflowContextTransitions( ) => Promise<{ continueRunId?: string }>, ) { pi.registerCommand("workflow", { - description: "Start, stop, inspect, or continue persisted pi-workflow runs", + description: "Start, stop, inspect, or continue persisted workflow runs", handler: async (args, ctx) => { try { const input = args.trim(); diff --git a/extensions/pi-workflow/continuation.test.ts b/extensions/workflow/continuation.test.ts similarity index 100% rename from extensions/pi-workflow/continuation.test.ts rename to extensions/workflow/continuation.test.ts diff --git a/extensions/pi-workflow/continuation.ts b/extensions/workflow/continuation.ts similarity index 100% rename from extensions/pi-workflow/continuation.ts rename to extensions/workflow/continuation.ts diff --git a/extensions/pi-workflow/dashboard.test.ts b/extensions/workflow/dashboard.test.ts similarity index 98% rename from extensions/pi-workflow/dashboard.test.ts rename to extensions/workflow/dashboard.test.ts index 7005147..df00589 100644 --- a/extensions/pi-workflow/dashboard.test.ts +++ b/extensions/workflow/dashboard.test.ts @@ -33,7 +33,7 @@ function run(overrides: Partial = {}): WorkflowRun { workspace: { state: "ready", sourceRoot: "/repo", - root: "/repo/.worktrees/pi-workflow-search-page-run-1", + root: "/repo/.worktrees/workflow-search-page-run-1", branch: "feature/search-page", baseHead: "abc123", }, @@ -505,7 +505,7 @@ test("historical parent sessions receive inspection-only dashboard actions", asy }); test("dashboard verifies and opens retained report content", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-dashboard-")); + const directory = await mkdtemp(path.join(tmpdir(), "workflow-dashboard-")); try { const reportPath = path.join(directory, "report.md"); const reportContents = "# Completed report\n\nVerified."; @@ -578,7 +578,7 @@ test("retained artifact reader Escape returns through artifact and candidate sel }); test("dashboard verifies and opens Assignment transcripts", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-transcript-")); + const directory = await mkdtemp(path.join(tmpdir(), "workflow-transcript-")); try { const transcriptPath = path.join(directory, "assignment.json"); const transcriptContents = '[{"role":"assistant","text":"verified"}]\n'; diff --git a/extensions/pi-workflow/dashboard.ts b/extensions/workflow/dashboard.ts similarity index 99% rename from extensions/pi-workflow/dashboard.ts rename to extensions/workflow/dashboard.ts index 3b615e2..5abcc70 100644 --- a/extensions/pi-workflow/dashboard.ts +++ b/extensions/workflow/dashboard.ts @@ -437,7 +437,7 @@ async function actionSheet( decision.strategy === "smart-compact" ) { ctx.ui.notify( - `Workflow ${label} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`, + `Workflow ${label} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through workflow.`, "info", ); } else if ( @@ -498,7 +498,7 @@ export async function openWorkflowDashboard( if (!selectedRunId) { selectedRunId = await workflowSelect( ctx, - "pi-workflow", + "workflow", runs.map((run, index) => ({ value: run.runId, label: `${labels[index]} ${run.status.toUpperCase()} ${String(activeStage(run))}`, diff --git a/extensions/pi-workflow/domain.ts b/extensions/workflow/domain.ts similarity index 100% rename from extensions/pi-workflow/domain.ts rename to extensions/workflow/domain.ts diff --git a/extensions/pi-workflow/engine.test.ts b/extensions/workflow/engine.test.ts similarity index 99% rename from extensions/pi-workflow/engine.test.ts rename to extensions/workflow/engine.test.ts index f5f498d..a080909 100644 --- a/extensions/pi-workflow/engine.test.ts +++ b/extensions/workflow/engine.test.ts @@ -45,7 +45,7 @@ import { const execFileAsync = promisify(execFile); async function withTempDir(run: (directory: string) => Promise) { - const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-engine-")); + const directory = await mkdtemp(path.join(tmpdir(), "workflow-engine-")); try { await run(directory); } finally { @@ -126,7 +126,7 @@ class FakeWorkspace implements WorkspaceManager { worktreePath: path.join( options.sourceRoot, options.configuredRoot, - `pi-workflow-${options.featureName}-${options.runId}`, + `workflow-${options.featureName}-${options.runId}`, ), branch: `feature/${options.featureName}`, baseHead: "abc123", @@ -2794,7 +2794,7 @@ test("production execution metadata and transcripts are persisted", async () => const executor: AssignmentExecutor = { async run() { return { - kind: "pi-workflow-assignment-result" as const, + kind: "workflow-assignment-result" as const, output: discoveryWithQuestions(), metadata: { childSessionId: "child-session-1", @@ -3257,7 +3257,7 @@ test("real Git persisted readiness precedes retry preparation", async () => { const git = (args: string[], cwd = repository) => execFileAsync("git", args, { cwd, encoding: "utf8" }); await git(["init", "-b", "main"]); - await git(["config", "user.email", "pi-workflow@example.invalid"]); + await git(["config", "user.email", "workflow@example.invalid"]); await git(["config", "user.name", "Pi Workflow Test"]); await writeFile( path.join(repository, ".gitignore"), diff --git a/extensions/pi-workflow/engine.ts b/extensions/workflow/engine.ts similarity index 99% rename from extensions/pi-workflow/engine.ts rename to extensions/workflow/engine.ts index cb1669d..54519a9 100644 --- a/extensions/pi-workflow/engine.ts +++ b/extensions/workflow/engine.ts @@ -207,7 +207,7 @@ export interface AssignmentExecutionMetadata { } export interface AssignmentExecutionResult { - kind: "pi-workflow-assignment-result"; + kind: "workflow-assignment-result"; output: unknown; metadata: AssignmentExecutionMetadata; } @@ -394,7 +394,7 @@ function isAssignmentExecutionResult( ): value is AssignmentExecutionResult { return ( isRecord(value) && - value.kind === "pi-workflow-assignment-result" && + value.kind === "workflow-assignment-result" && isRecord(value.metadata) && Array.isArray(value.metadata.transcript) ); diff --git a/extensions/pi-workflow/execution.test.ts b/extensions/workflow/execution.test.ts similarity index 99% rename from extensions/pi-workflow/execution.test.ts rename to extensions/workflow/execution.test.ts index 2468c7b..9ca7909 100644 --- a/extensions/pi-workflow/execution.test.ts +++ b/extensions/workflow/execution.test.ts @@ -29,7 +29,7 @@ import { WorkflowStore } from "./store.ts"; const execFileAsync = promisify(execFile); async function withTempDir(run: (directory: string) => Promise) { - const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-execution-")); + const directory = await mkdtemp(path.join(tmpdir(), "workflow-execution-")); try { await run(directory); } finally { @@ -336,7 +336,7 @@ test("production executor resolves trust and creates fresh structured child runs ...makeRequest("run-c"), allowedReadTools: ["grep"], }); - assert.equal(first.kind, "pi-workflow-assignment-result"); + assert.equal(first.kind, "workflow-assignment-result"); assert.equal(first.metadata.childSessionId, "child-1"); assert.equal(second.metadata.childSessionId, "child-2"); assert.equal(resources, 3); diff --git a/extensions/pi-workflow/execution.ts b/extensions/workflow/execution.ts similarity index 99% rename from extensions/pi-workflow/execution.ts rename to extensions/workflow/execution.ts index 191a035..cc33c1e 100644 --- a/extensions/pi-workflow/execution.ts +++ b/extensions/workflow/execution.ts @@ -16,7 +16,7 @@ import { Type } from "typebox"; import { createChildResources, resolveStandaloneChildProjectTrust, -} from "../shared/child-session.ts"; +} from "@_jb/pi-shared/child-session"; import { runAgent, type AgentOutcome, @@ -807,7 +807,7 @@ export class PiAssignmentExecutor implements AssignmentExecutor { throw error; } return { - kind: "pi-workflow-assignment-result", + kind: "workflow-assignment-result", output: outcome.structured, metadata, }; diff --git a/extensions/pi-workflow/index.test.ts b/extensions/workflow/index.test.ts similarity index 100% rename from extensions/pi-workflow/index.test.ts rename to extensions/workflow/index.test.ts diff --git a/extensions/pi-workflow/index.ts b/extensions/workflow/index.ts similarity index 92% rename from extensions/pi-workflow/index.ts rename to extensions/workflow/index.ts index c713263..75d213a 100644 --- a/extensions/pi-workflow/index.ts +++ b/extensions/workflow/index.ts @@ -23,7 +23,7 @@ import { } from "./notifications.ts"; import { runScriptSandbox } from "./script-runtime.ts"; import { WorkflowStore } from "./store.ts"; -import { registerPiWorkflowTool } from "./tool.ts"; +import { registerWorkflowTool } from "./tool.ts"; import { refreshWorkflowProgress, registerWorkflowProgress, @@ -49,7 +49,7 @@ export function createWorkflowRuntime( options: WorkflowExtensionOptions = {}, ): ContextTransitionRuntime { const store = new WorkflowStore( - options.stateRoot ?? path.join(getAgentDir(), "pi-workflows"), + options.stateRoot ?? path.join(getAgentDir(), "workflows"), ); return { store, @@ -105,14 +105,14 @@ export function registerWorkflowRecovery( await engine.recover(); } catch (error) { ctx.ui.notify( - `pi-workflow startup recovery failed: ${error instanceof Error ? error.message : String(error)}`, + `workflow startup recovery failed: ${error instanceof Error ? error.message : String(error)}`, "error", ); } }); } -export default function piWorkflowExtension(pi: ExtensionAPI) { +export default function workflowExtension(pi: ExtensionAPI) { let runtime: ContextTransitionRuntime; runtime = createWorkflowRuntime({ afterRunPersist: async (ctx) => { @@ -125,7 +125,7 @@ export default function piWorkflowExtension(pi: ExtensionAPI) { registerWorkflowContextTransitions(pi, runtime, (args, ctx) => openWorkflowDashboard(pi, runtime, ctx, args || undefined), ); - registerPiWorkflowTool(pi, runtime); + registerWorkflowTool(pi, runtime); registerWorkflowRecovery(pi, runtime); registerWorkflowNotifications(pi, runtime); registerWorkflowProgress(pi, runtime); diff --git a/extensions/pi-workflow/notifications.test.ts b/extensions/workflow/notifications.test.ts similarity index 97% rename from extensions/pi-workflow/notifications.test.ts rename to extensions/workflow/notifications.test.ts index f3d6897..1a830a7 100644 --- a/extensions/pi-workflow/notifications.test.ts +++ b/extensions/workflow/notifications.test.ts @@ -18,7 +18,7 @@ import { import { WorkflowStore } from "./store.ts"; async function withTempDir(run: (directory: string) => Promise) { - const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-delivery-")); + const directory = await mkdtemp(path.join(tmpdir(), "workflow-delivery-")); try { await run(directory); } finally { @@ -132,7 +132,7 @@ test("delivery receipt closes the send/save crash window exactly once", async () assert.equal(harness.sent.length, 1); const message = harness.sent[0]?.message as Record; assert.equal(message.customType, workflowNotificationType); - assert.match(String(message.content), /pi-workflow search is waiting/); + assert.match(String(message.content), /workflow search is waiting/); assert.equal( (message.details as Record).runId, "run-1", @@ -265,7 +265,7 @@ test("terminal deliveries include the persisted report and settle once", async ( assert.equal(harness.sent.length, 1); assert.match( String((harness.sent[0]?.message as Record).content), - /pi-workflow search finished with status completed.*Report: \/state\/report.md/, + /workflow search finished with status completed.*Report: \/state\/report.md/, ); assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent"); }); diff --git a/extensions/pi-workflow/notifications.ts b/extensions/workflow/notifications.ts similarity index 92% rename from extensions/pi-workflow/notifications.ts rename to extensions/workflow/notifications.ts index 5b9621a..5585031 100644 --- a/extensions/pi-workflow/notifications.ts +++ b/extensions/workflow/notifications.ts @@ -7,7 +7,7 @@ import type { WorkflowRun } from "./domain.ts"; import type { ContextTransitionRuntime } from "./context-transition.ts"; import { deriveRunLabel } from "./run-label.ts"; -const NOTIFICATION_TYPE = "pi-workflow-notification"; +const NOTIFICATION_TYPE = "workflow-notification"; const DELIVERY_OWNER_TOKEN = `${process.pid}:${randomUUID()}`; const ACTIVE_DELIVERIES = new Map< string, @@ -57,13 +57,13 @@ function deliveryText(run: WorkflowRun, key: string) { .reverse() .find((candidate) => key.includes(`:${candidate.id}:`)); return gate - ? `pi-workflow ${label} is waiting at the ${gate.kind} Gate (revision ${run.revision}). Inspect the run before obtaining and recording the user's Decision.` - : `pi-workflow ${label} is waiting for a Gate Decision.`; + ? `workflow ${label} is waiting at the ${gate.kind} Gate (revision ${run.revision}). Inspect the run before obtaining and recording the user's Decision.` + : `workflow ${label} is waiting for a Gate Decision.`; } const report = run.reportArtifact?.path ? ` Report: ${run.reportArtifact.path}` : ""; - return `pi-workflow ${label} finished with status ${run.status}.${report}`; + return `workflow ${label} finished with status ${run.status}.${report}`; } function belongsToSession(run: WorkflowRun, sessionId: string) { @@ -181,7 +181,7 @@ export function registerWorkflowNotifications( await reconcileWorkflowDeliveries(pi, runtime, ctx); } catch (error) { ctx.ui.notify( - `pi-workflow delivery recovery failed: ${error instanceof Error ? error.message : String(error)}`, + `workflow delivery recovery failed: ${error instanceof Error ? error.message : String(error)}`, "error", ); } @@ -189,7 +189,7 @@ export function registerWorkflowNotifications( pi.on("session_start", async (_event, ctx) => reconcile(ctx)); pi.on("agent_settled", async (_event, ctx) => reconcile(ctx)); pi.on("tool_result", async (event, ctx) => { - if (event.toolName === "pi_workflow") await reconcile(ctx); + if (event.toolName === "workflow") await reconcile(ctx); }); pi.on("session_shutdown", async () => { const active = [...ACTIVE_DELIVERIES.entries()]; diff --git a/extensions/workflow/package.json b/extensions/workflow/package.json new file mode 100644 index 0000000..7445dcb --- /dev/null +++ b/extensions/workflow/package.json @@ -0,0 +1,20 @@ +{ + "name": "@_jb/pi-workflow", + "version": "0.1.0", + "private": true, + "description": "Private workspace for the deferred Pi workflow extension migration.", + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.19.0" }, + "keywords": ["pi-package", "pi-extension", "workflow"], + "pi": { "extensions": ["./index.ts"] }, + "dependencies": { + "@_jb/pi-shared": "workspace:^" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "catalog:pi-peer", + "@earendil-works/pi-coding-agent": "catalog:pi-peer", + "@earendil-works/pi-tui": "catalog:pi-peer", + "typebox": "catalog:pi-peer" + } +} diff --git a/extensions/pi-workflow/profiles.test.ts b/extensions/workflow/profiles.test.ts similarity index 99% rename from extensions/pi-workflow/profiles.test.ts rename to extensions/workflow/profiles.test.ts index 5703814..6f79895 100644 --- a/extensions/pi-workflow/profiles.test.ts +++ b/extensions/workflow/profiles.test.ts @@ -11,7 +11,7 @@ import { } from "./profiles.ts"; async function withTempDir(run: (directory: string) => Promise) { - const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-profiles-")); + const directory = await mkdtemp(path.join(tmpdir(), "workflow-profiles-")); try { await run(directory); } finally { diff --git a/extensions/pi-workflow/profiles.ts b/extensions/workflow/profiles.ts similarity index 100% rename from extensions/pi-workflow/profiles.ts rename to extensions/workflow/profiles.ts diff --git a/extensions/pi-workflow/progress.test.ts b/extensions/workflow/progress.test.ts similarity index 98% rename from extensions/pi-workflow/progress.test.ts rename to extensions/workflow/progress.test.ts index 539bf41..8375e50 100644 --- a/extensions/pi-workflow/progress.test.ts +++ b/extensions/workflow/progress.test.ts @@ -111,7 +111,7 @@ test("progress lifecycle refreshes from persistence and clears on terminal state sessionManager: { getSessionId: () => "session-1" }, ui: { setWidget(key: string, value: unknown) { - assert.equal(key, "pi-workflow-active-run"); + assert.equal(key, "workflow-active-run"); widget = value; }, }, diff --git a/extensions/pi-workflow/progress.ts b/extensions/workflow/progress.ts similarity index 98% rename from extensions/pi-workflow/progress.ts rename to extensions/workflow/progress.ts index c607ace..f86c333 100644 --- a/extensions/pi-workflow/progress.ts +++ b/extensions/workflow/progress.ts @@ -8,7 +8,7 @@ import type { ContextTransitionRuntime } from "./context-transition.ts"; import type { WorkflowRun } from "./domain.ts"; import { deriveRunLabels } from "./run-label.ts"; -const WIDGET_KEY = "pi-workflow-active-run"; +const WIDGET_KEY = "workflow-active-run"; const CORE_STAGES = new Set([ "discover", "design", diff --git a/extensions/pi-workflow/run-label.test.ts b/extensions/workflow/run-label.test.ts similarity index 100% rename from extensions/pi-workflow/run-label.test.ts rename to extensions/workflow/run-label.test.ts diff --git a/extensions/pi-workflow/run-label.ts b/extensions/workflow/run-label.ts similarity index 100% rename from extensions/pi-workflow/run-label.ts rename to extensions/workflow/run-label.ts diff --git a/extensions/pi-workflow/sandbox-child.cjs b/extensions/workflow/sandbox-child.cjs similarity index 98% rename from extensions/pi-workflow/sandbox-child.cjs rename to extensions/workflow/sandbox-child.cjs index 42d8571..6ddd1b2 100644 --- a/extensions/pi-workflow/sandbox-child.cjs +++ b/extensions/workflow/sandbox-child.cjs @@ -232,7 +232,7 @@ function run(source, argsJson) { }; const context = vm.createContext(sandbox, { - name: "pi-workflow", + name: "workflow", codeGeneration: { strings: false, wasm: false }, }); new vm.Script(BOOTSTRAP, { @@ -241,7 +241,7 @@ function run(source, argsJson) { const workflow = vm.compileFunction( `"use strict";\nreturn (async function workflow() {\n${source}\n})();`, ["assign", "parallel", "stage", "args"], - { filename: "pi-workflow-script.js", parsingContext: context }, + { filename: "workflow-script.js", parsingContext: context }, ); context.__workflowBody = workflow; const invoke = ` diff --git a/extensions/pi-workflow/script-runtime.test.ts b/extensions/workflow/script-runtime.test.ts similarity index 100% rename from extensions/pi-workflow/script-runtime.test.ts rename to extensions/workflow/script-runtime.test.ts diff --git a/extensions/pi-workflow/script-runtime.ts b/extensions/workflow/script-runtime.ts similarity index 100% rename from extensions/pi-workflow/script-runtime.ts rename to extensions/workflow/script-runtime.ts diff --git a/extensions/pi-workflow/store.test.ts b/extensions/workflow/store.test.ts similarity index 99% rename from extensions/pi-workflow/store.test.ts rename to extensions/workflow/store.test.ts index 0146a6f..a63c8f1 100644 --- a/extensions/pi-workflow/store.test.ts +++ b/extensions/workflow/store.test.ts @@ -14,7 +14,7 @@ import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts"; import { readVerifiedReportArtifact, WorkflowStore } from "./store.ts"; async function withTempDir(run: (directory: string) => Promise) { - const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-store-")); + const directory = await mkdtemp(path.join(tmpdir(), "workflow-store-")); try { await run(directory); } finally { @@ -138,7 +138,7 @@ test("terminal reports bound large persisted Decisions instead of blocking compl await store.createRun(run); const report = await store.saveReport(run); const contents = await readFile(report.path, "utf8"); - assert.match(contents, /^# pi-workflow build-feature/m); + assert.match(contents, /^# workflow build-feature/m); assert.match(contents, /- Run ID: run-1/); assert.match(contents, /\[truncated\]/); assert.match(contents, /report truncated to 1 MiB/); diff --git a/extensions/pi-workflow/store.ts b/extensions/workflow/store.ts similarity index 99% rename from extensions/pi-workflow/store.ts rename to extensions/workflow/store.ts index 02c23d0..abce70e 100644 --- a/extensions/pi-workflow/store.ts +++ b/extensions/workflow/store.ts @@ -345,7 +345,7 @@ export class WorkflowStore { async saveReport(run: WorkflowRun) { const lines = [ - `# pi-workflow ${deriveRunLabel(run)}`, + `# workflow ${deriveRunLabel(run)}`, "", `- Run ID: ${run.runId}`, `- Status: ${run.status}`, diff --git a/extensions/pi-workflow/tool.test.ts b/extensions/workflow/tool.test.ts similarity index 98% rename from extensions/pi-workflow/tool.test.ts rename to extensions/workflow/tool.test.ts index e34670c..b583083 100644 --- a/extensions/pi-workflow/tool.test.ts +++ b/extensions/workflow/tool.test.ts @@ -7,7 +7,7 @@ import type { import type { WorkflowCommand, WorkflowView } from "./engine.ts"; import { compactWorkflowDetails, - registerPiWorkflowTool, + registerWorkflowTool, workflowCommandFromTool, } from "./tool.ts"; import { WorkspaceValidationError } from "./workspace.ts"; @@ -218,7 +218,7 @@ test("TUI dirty-source starts distinguish omitted, refused, and approved flags", }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; - registerPiWorkflowTool(pi, { + registerWorkflowTool(pi, { store: {} as never, async engineForContext() { return { @@ -345,7 +345,7 @@ test("RPC dirty-source starts never open TUI overlays and require the flag", asy }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; - registerPiWorkflowTool(pi, { + registerWorkflowTool(pi, { store: {} as never, async engineForContext() { return { @@ -441,7 +441,7 @@ test("start tool cancellation waits for engine settlement before rejecting", asy }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; - registerPiWorkflowTool(pi, { + registerWorkflowTool(pi, { store: {} as never, async engineForContext() { return { @@ -522,7 +522,7 @@ test("cancellation during dirty-source confirmation cannot start a retry", async }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; - registerPiWorkflowTool(pi, { + registerWorkflowTool(pi, { store: { async getStart() { return { runId: "not-created" }; @@ -595,7 +595,7 @@ test("tool cancellation aborts its active Workflow Run before rejecting", async }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; - registerPiWorkflowTool(pi, { + registerWorkflowTool(pi, { store: { async loadRun() { return { runId: "run-1", revision: 4, status: "running" }; @@ -646,7 +646,7 @@ test("inspect errors retain the supplied run ID when no label can be derived", a }, getActiveTools: () => ["read"], } as unknown as ExtensionAPI; - registerPiWorkflowTool(pi, { + registerWorkflowTool(pi, { store: {} as never, async engineForContext() { return { @@ -682,7 +682,7 @@ test("registered tool executes through the context engine and injects host tools return ["read", "smart_compact"]; }, } as unknown as ExtensionAPI; - registerPiWorkflowTool(pi, { + registerWorkflowTool(pi, { store: {} as never, async engineForContext() { return { @@ -693,7 +693,7 @@ test("registered tool executes through the context engine and injects host tools } as never; }, }); - assert.equal(definition?.name, "pi_workflow"); + assert.equal(definition?.name, "workflow"); assert.ok(definition); const result = await definition.execute( "call-1", diff --git a/extensions/pi-workflow/tool.ts b/extensions/workflow/tool.ts similarity index 95% rename from extensions/pi-workflow/tool.ts rename to extensions/workflow/tool.ts index 8988099..a51c792 100644 --- a/extensions/pi-workflow/tool.ts +++ b/extensions/workflow/tool.ts @@ -73,7 +73,7 @@ const GateDecisionInput = Type.Object( { additionalProperties: false }, ); -export const PiWorkflowParameters = Type.Object( +export const WorkflowParameters = Type.Object( { action: StringEnum(ACTIONS), request: Type.Optional(Type.String()), @@ -91,7 +91,7 @@ export const PiWorkflowParameters = Type.Object( { additionalProperties: false }, ); -export type PiWorkflowInput = Static; +export type WorkflowInput = Static; function requiredString(value: unknown, label: string) { if (typeof value !== "string" || value.trim().length === 0) { @@ -110,7 +110,7 @@ function scriptArgs(value: string | undefined) { } export function workflowCommandFromTool( - input: PiWorkflowInput, + input: WorkflowInput, activeToolNames: string[], ): WorkflowCommand { if (input.action === "start") { @@ -199,7 +199,7 @@ export function workflowCommandFromTool( } export interface WorkflowToolDetails { - action: PiWorkflowInput["action"]; + action: WorkflowInput["action"]; runId: string; revision: number; status: WorkflowView["status"]; @@ -324,7 +324,7 @@ function compactGatePayload(view: WorkflowView) { } export function compactWorkflowDetails( - action: PiWorkflowInput["action"], + action: WorkflowInput["action"], view: WorkflowView, sessionId?: string, ): WorkflowToolDetails { @@ -400,7 +400,7 @@ function resultText(details: WorkflowToolDetails, label: string) { async function cancelToolWorkflow( runtime: ContextTransitionRuntime, engine: Awaited>, - input: PiWorkflowInput, + input: WorkflowInput, toolCallId: string, ) { const runId = input.runId; @@ -441,23 +441,23 @@ function dirtySourceWarning(error: WorkspaceValidationError) { ].join("\n"); } -export function registerPiWorkflowTool( +export function registerWorkflowTool( pi: ExtensionAPI, runtime: ContextTransitionRuntime, ) { pi.registerTool({ - name: "pi_workflow", + name: "workflow", label: "Pi Workflow", description: "Start, inspect, decide, or abort a persisted gated feature or read-only analysis Workflow Run. Noninteractive starts from a dirty source require allowDirtySource=true. Mutating actions require idempotency keys and expected revisions. Gate Decisions must come from the user; never infer approval.", promptSnippet: "Plan and build features through persisted Gates, worktrees, verification, and independent review", promptGuidelines: [ - "Use pi_workflow when the user asks to plan or build a non-trivial feature through the managed workflow.", - "Never use pi_workflow decide to infer a Gate Decision; obtain the user's explicit answer, plan approval, prototype acceptance, context choice, or recovery choice first.", - "Use pi_workflow inspect before every state-changing call and pass its current revision and Gate ID.", + "Use workflow when the user asks to plan or build a non-trivial feature through the managed workflow.", + "Never use workflow decide to infer a Gate Decision; obtain the user's explicit answer, plan approval, prototype acceptance, context choice, or recovery choice first.", + "Use workflow inspect before every state-changing call and pass its current revision and Gate ID.", ], - parameters: PiWorkflowParameters, + parameters: WorkflowParameters, async execute(toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) { const engine = await runtime.engineForContext(ctx); const command = workflowCommandFromTool(params, pi.getActiveTools()); @@ -465,7 +465,7 @@ export function registerPiWorkflowTool( const cancellationError = () => executionSignal.reason instanceof Error ? executionSignal.reason - : new Error("pi_workflow tool call was cancelled"); + : new Error("workflow tool call was cancelled"); const operation = (async () => { if (executionSignal.aborted) throw cancellationError(); if (command.action === "decide") { diff --git a/extensions/pi-workflow/tui.test.ts b/extensions/workflow/tui.test.ts similarity index 100% rename from extensions/pi-workflow/tui.test.ts rename to extensions/workflow/tui.test.ts diff --git a/extensions/pi-workflow/tui.ts b/extensions/workflow/tui.ts similarity index 99% rename from extensions/pi-workflow/tui.ts rename to extensions/workflow/tui.ts index d687eb9..ff82dfa 100644 --- a/extensions/pi-workflow/tui.ts +++ b/extensions/workflow/tui.ts @@ -304,7 +304,7 @@ class WorkflowEditorOverlay extends FocusedTextOverlay { function assertTui(ctx: ExtensionContext) { if (ctx.mode !== "tui") { - throw new Error("pi-workflow overlays require TUI mode"); + throw new Error("workflow overlays require TUI mode"); } } @@ -546,7 +546,7 @@ export function renderPlanReader( "", "terminal too small", "", - "pi-workflow plan needs 48x12", + "workflow plan needs 48x12", `current terminal is ${width}x${height}`, "", "resize the terminal or press esc", @@ -561,7 +561,7 @@ export function renderPlanReader( const heading = currentHeading(model, sourceLine); const position = `row ${Math.min(rows.length, topRow + 1)}/${rows.length}`; const headingLabel = width < 80 && heading ? ` · ${heading.text}` : ""; - const header = fit(` pi-workflow plan · ${model.title}${headingLabel} · ${position}`, width); + const header = fit(` workflow plan · ${model.title}${headingLabel} · ${position}`, width); const footer = fit(" ↑↓ scroll pgup/pgdn page home/end document esc back", width); const bodyHeight = readerBodyHeight(height); const paint = (token: "accent" | "dim" | "mdHeading" | "muted" | "text", value: string) => diff --git a/extensions/pi-workflow/ui-policy.test.ts b/extensions/workflow/ui-policy.test.ts similarity index 100% rename from extensions/pi-workflow/ui-policy.test.ts rename to extensions/workflow/ui-policy.test.ts diff --git a/extensions/pi-workflow/ui-policy.ts b/extensions/workflow/ui-policy.ts similarity index 100% rename from extensions/pi-workflow/ui-policy.ts rename to extensions/workflow/ui-policy.ts diff --git a/extensions/pi-workflow/workspace.test.ts b/extensions/workflow/workspace.test.ts similarity index 99% rename from extensions/pi-workflow/workspace.test.ts rename to extensions/workflow/workspace.test.ts index 6e5fa11..7fb4e1a 100644 --- a/extensions/pi-workflow/workspace.test.ts +++ b/extensions/workflow/workspace.test.ts @@ -72,7 +72,7 @@ function grayscalePng(width: number, height: number, value: number) { async function withTempDir(run: (directory: string) => Promise) { const directory = await mkdtemp( - path.join(tmpdir(), "pi-workflow-workspace-"), + path.join(tmpdir(), "workflow-workspace-"), ); try { await run(directory); @@ -614,7 +614,7 @@ test("workspace preparation validates Git state and builds the exact feature bra assert.equal(plan.branch, "feature/search-dashboard"); assert.equal( plan.worktreePath, - path.join(repository, ".worktrees", "pi-workflow-search-dashboard-run-1"), + path.join(repository, ".worktrees", "workflow-search-dashboard-run-1"), ); assert.equal(plan.baseHead, "abc123"); assert.equal(plan.branchExists, false); @@ -643,7 +643,7 @@ test("real Git creation and reconciliation preserve branch and HEAD identity", a const git = (args: string[]) => execFileAsync("git", args, { cwd: repository, encoding: "utf8" }); await git(["init", "-b", "main"]); - await git(["config", "user.email", "pi-workflow@example.invalid"]); + await git(["config", "user.email", "workflow@example.invalid"]); await git(["config", "user.name", "Pi Workflow Test"]); await writeFile( path.join(repository, ".gitignore"), @@ -749,7 +749,7 @@ test("dirty sources require approval with bounded evidence while conflicts remai test("workspace preparation rejects a symlinked worktree root", async () => { await withTempDir(async (repository) => { - const outside = await mkdtemp(path.join(tmpdir(), "pi-workflow-outside-")); + const outside = await mkdtemp(path.join(tmpdir(), "workflow-outside-")); try { await symlink(outside, path.join(repository, ".worktrees")); const manager = new GitWorkspaceManager( diff --git a/extensions/pi-workflow/workspace.ts b/extensions/workflow/workspace.ts similarity index 99% rename from extensions/pi-workflow/workspace.ts rename to extensions/workflow/workspace.ts index 0335e73..ad0b25b 100644 --- a/extensions/pi-workflow/workspace.ts +++ b/extensions/workflow/workspace.ts @@ -1138,7 +1138,7 @@ export class GitWorkspaceManager { "--quiet", "--no-index", "--", - `${relativeRoot}/.pi-workflow-ignore-check`, + `${relativeRoot}/.workflow-ignore-check`, ]); if (ignored.code !== 0) { throw new WorkspaceValidationError( @@ -1148,7 +1148,7 @@ export class GitWorkspaceManager { } const worktreePath = path.join( worktreeRoot, - `pi-workflow-${options.featureName}-${options.runId}`, + `workflow-${options.featureName}-${options.runId}`, ); try { await lstat(worktreePath); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index df762e0..0000000 --- a/package-lock.json +++ /dev/null @@ -1,5598 +0,0 @@ -{ - "name": "harness-tools", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "harness-tools", - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.218", - "@effect/platform-node": "4.0.0-beta.101", - "@effect/platform-node-shared": "4.0.0-beta.101", - "acorn": "8.18.0", - "effect": "4.0.0-beta.101", - "firecrawl": "4.30.1" - }, - "devDependencies": { - "@earendil-works/pi-ai": "0.84.2", - "@earendil-works/pi-coding-agent": "^0.84.2", - "@earendil-works/pi-tui": "0.84.2", - "@effect/tsgo": "0.24.3", - "@effect/vitest": "4.0.0-beta.101", - "@types/node": "^22.0.0", - "typebox": "1.3.17", - "typescript": "7.0.2", - "vitest": "4.1.10" - }, - "engines": { - "node": ">=22.19.0" - }, - "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" - } - }, - "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.218", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.218.tgz", - "integrity": "sha512-lbVOmgdzjBdSUCTbxElpoTxxP/u9jY8gpG/3cr5l1jSSvSY5FrANdRcze/z6gM330xo32SHH6WPGFd4+K0fMoQ==", - "license": "SEE LICENSE IN README.md", - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.218", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.218", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.218", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.218", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.218", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.218", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.218", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.218" - }, - "peerDependencies": { - "@anthropic-ai/sdk": ">=0.93.0", - "@modelcontextprotocol/sdk": "^1.29.0", - "zod": "^4.0.0" - } - }, - "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.218", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.218.tgz", - "integrity": "sha512-foTI71ua2r5lJo7sfcw/3UafBCJYjxPmOXZ98wOz1UWIJxaP/Oq9Xp4sUavBd1efFVRxPnAcRnrZ5yrmXK1xUw==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.218", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.218.tgz", - "integrity": "sha512-rAdlR2ukJd01Osfsb7nPPv31MCVJBennBDaL7ZP/bv+dednwNuY6thuPO5PMFJUM+54Gbnziqm+hqoNTgndqDg==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.218", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.218.tgz", - "integrity": "sha512-Y6vFEnz6wwd+rYpVGsPqgeXZuZP7NvQg6vjkC+N6cs1+I41LdKjfs+F+WG3daIqgt+vJBz/EB5Q0PByABkwufA==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.218", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.218.tgz", - "integrity": "sha512-qfFBwOf0uh/mAtNGEkBJlLWt1XIgFqcQeeX966g2NexasCdSJGnwfc0YWA0QOeAAU/5xk6tSCW/Nn0zHGBO/3A==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.218", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.218.tgz", - "integrity": "sha512-gCq/i7yRXeuoQXidGQynuiw5AeczQXSIifxH5Vpr9OFDyHCBNotS1mQ8qutuclZeFpLOPkJ2ic/nvEgHJvfXdg==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.218", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.218.tgz", - "integrity": "sha512-e47oi8dYWnS0wx2/F6FL3YhaD2HedMd2nhI/nududP4PBzytuXeaDE/sJ0eIqtUVkl6/bn5uBhtIEHHWoj37JQ==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.218", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.218.tgz", - "integrity": "sha512-D0UKI8jOpEsWO1A+i0DlIrgjXFKEZX3RrID2l49S4pShUlLsnSLJGMTPy4nQt6CCl5MzPqWiJDE/lCpImFTDcg==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.218", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.218.tgz", - "integrity": "sha512-pgE39WnKPPSRsiRJ2pm5NETxQgtVhrquqg7UByZxne0xQw2gNS0Qy+9ZumrsNBNlfrrnQCVDU1I2ks3ZLrEa8Q==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.120.0.tgz", - "integrity": "sha512-ZlvmNFT/iIF6JD13rxbbMWD8nvGR0RaUp6yMQnoc+4Af0YjVVe/bIdW1XSQQsoxXAtg1NaT6Vak0LKFlJ4d37Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "json-schema-to-ts": "^3.1.1", - "standardwebhooks": "^1.0.0" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", - "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-node": "^3.972.42", - "@aws-sdk/eventstream-handler-node": "^3.972.16", - "@aws-sdk/middleware-eventstream": "^3.972.12", - "@aws-sdk/middleware-websocket": "^3.972.19", - "@aws-sdk/token-providers": "3.1048.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.977.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", - "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.974.5", - "@aws-sdk/xml-builder": "^3.972.40", - "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.33.3", - "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.17.2", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.70", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", - "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.9", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.72", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", - "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.9", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/fetch-http-handler": "^5.7.2", - "@smithy/node-http-handler": "^4.11.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", - "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", - "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.9", - "@aws-sdk/credential-provider-env": "^3.972.70", - "@aws-sdk/credential-provider-http": "^3.972.72", - "@aws-sdk/credential-provider-login": "^3.972.77", - "@aws-sdk/credential-provider-process": "^3.972.70", - "@aws-sdk/credential-provider-sso": "^3.973.14", - "@aws-sdk/credential-provider-web-identity": "^3.972.76", - "@aws-sdk/nested-clients": "^3.997.44", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/credential-provider-imds": "^4.4.16", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.77", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", - "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.9", - "@aws-sdk/nested-clients": "^3.997.44", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.81", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz", - "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.70", - "@aws-sdk/credential-provider-http": "^3.972.72", - "@aws-sdk/credential-provider-ini": "^3.973.15", - "@aws-sdk/credential-provider-process": "^3.972.70", - "@aws-sdk/credential-provider-sso": "^3.973.14", - "@aws-sdk/credential-provider-web-identity": "^3.972.76", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/credential-provider-imds": "^4.4.16", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.70", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", - "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.9", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", - "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.9", - "@aws-sdk/nested-clients": "^3.997.44", - "@aws-sdk/token-providers": "3.1116.0", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1116.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", - "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.9", - "@aws-sdk/nested-clients": "^3.997.44", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.76", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", - "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.9", - "@aws-sdk/nested-clients": "^3.997.44", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", - "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", - "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", - "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.9", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/fetch-http-handler": "^5.7.2", - "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", - "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.9", - "@aws-sdk/signature-v4-multi-region": "^3.996.46", - "@aws-sdk/types": "^3.974.5", - "@smithy/core": "^3.33.3", - "@smithy/fetch-http-handler": "^5.7.2", - "@smithy/node-http-handler": "^4.11.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", - "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", - "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.974.5", - "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", - "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.974.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", - "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", - "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.40", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", - "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", - "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@earendil-works/pi-ai": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", - "integrity": "sha512-6MzsrYIYNVlE7SfpbL2yYb67Qo58p/7Q+xWG1RZvoX1P80aRCHSod2/13aFpxkow1lPO2LEh3c495J0Gwmyjig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.2", - "@google/genai": "1.52.0", - "@opentelemetry/api": "1.9.0", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.40.0", - "partial-json": "0.1.7", - "typebox": "1.3.7" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/@anthropic-ai/sdk": { - "version": "0.91.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", - "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/typebox": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", - "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.2.tgz", - "integrity": "sha512-l4E+B7hgXKWddRo8bC/eSue2aWZjEgJ9xIpf5p0Og+lq8a2TArCwJ0HCoCPCgaBP/tN4zbYH/wOwvx9pJpeLCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-client": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2", - "@earendil-works/pi-tui": "^0.84.2", - "@silvia-odwyer/photon-node": "0.3.4", - "chalk": "5.6.2", - "cross-spawn": "7.0.6", - "diff": "8.0.4", - "glob": "13.0.6", - "grok-mermaid": "0.2.2", - "highlight.js": "10.7.3", - "hosted-git-info": "9.0.3", - "ignore": "7.0.5", - "jiti": "2.7.0", - "minimatch": "10.2.5", - "proper-lockfile": "4.1.2", - "semver": "7.8.0", - "typebox": "1.3.7", - "undici": "8.9.0", - "yaml": "2.9.0" - }, - "bin": { - "pi": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - }, - "optionalDependencies": { - "@mariozechner/clipboard": "0.3.9" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.2.tgz", - "dev": true, - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-telemetry": "^0.84.2", - "diff": "8.0.4", - "ignore": "7.0.5", - "typebox": "1.3.7", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-client": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.2.tgz", - "dev": true, - "license": "MIT", - "dependencies": { - "@earendil-works/pi-protocol": "^0.84.2" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-protocol": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.2.tgz", - "dev": true, - "license": "MIT", - "dependencies": { - "typebox": "1.3.7" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", - "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.9", - "@mariozechner/clipboard-darwin-universal": "0.3.9", - "@mariozechner/clipboard-darwin-x64": "0.3.9", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-x64-musl": "0.3.9", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", - "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", - "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", - "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", - "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", - "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", - "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", - "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", - "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", - "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", - "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", - "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", - "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", - "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", - "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", - "integrity": "sha512-wg5caea7uIv1BHRBm2Y116RvFG4oSAiP5qk9tA2463PDGIr4K8M1Ceyyg5DOpF/shUUl0gk826yQJAeAcHYB9g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-tui": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", - "integrity": "sha512-ds2TLihOnM5sLJB3VpXV6y0uR5efVuHf4MN7yDpsty6hA2DUO/EDVzjp/0od0G2JslzVLMjT8T8zavtxVb+qbg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "1.6.0", - "marked": "18.0.5" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@effect/platform-node": { - "version": "4.0.0-beta.101", - "resolved": "https://registry.npmjs.org/@effect/platform-node/-/platform-node-4.0.0-beta.101.tgz", - "integrity": "sha512-pClk7dmMtHgM6Byu7CzGfrPvZ1/4BwmrRlCg2Op+iJozMkwVUxq8v4beK8b9SvxsliJjHZFznjvkVLX7LQjBqw==", - "license": "MIT", - "dependencies": { - "@effect/platform-node-shared": "^4.0.0-beta.101", - "mime": "^4.1.0", - "undici": "^8.7.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "effect": "^4.0.0-beta.101", - "ioredis": "^5.7.0" - } - }, - "node_modules/@effect/platform-node-shared": { - "version": "4.0.0-beta.101", - "resolved": "https://registry.npmjs.org/@effect/platform-node-shared/-/platform-node-shared-4.0.0-beta.101.tgz", - "integrity": "sha512-g4L7XiyJSNJLJVhlslyg2zBCQsoKQf1y1gd+Yfd+3wD9ymC+m7ymbd/5FGqnT1aXV6E2AwRr4D/R1eyRUikvWQ==", - "license": "MIT", - "dependencies": { - "@types/ws": "^8.18.1", - "ws": "^8.21.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "effect": "^4.0.0-beta.101" - } - }, - "node_modules/@effect/tsgo": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@effect/tsgo/-/tsgo-0.24.3.tgz", - "integrity": "sha512-WQxKU3MFzWzI38GbE9cf0POkVX4y0xahD8QqDjLfixW1AEValuHNfOQvyKM2MDWOLdGff4hP8VnIOeduwQt66A==", - "dev": true, - "license": "MIT", - "bin": { - "effect-tsgo": "dist/effect-tsgo.js" - }, - "optionalDependencies": { - "@effect/tsgo-darwin-arm64": "0.24.3", - "@effect/tsgo-darwin-x64": "0.24.3", - "@effect/tsgo-linux-arm": "0.24.3", - "@effect/tsgo-linux-arm64": "0.24.3", - "@effect/tsgo-linux-x64": "0.24.3", - "@effect/tsgo-win32-arm64": "0.24.3", - "@effect/tsgo-win32-x64": "0.24.3" - } - }, - "node_modules/@effect/tsgo-darwin-arm64": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@effect/tsgo-darwin-arm64/-/tsgo-darwin-arm64-0.24.3.tgz", - "integrity": "sha512-eO83D7ZmpAsocA5WJQ8SSAPnAOZ+dEduFIozg9c2SmH36PCHG1Eb++I0UiQml+2LAr4o/QE2wKdWj7LExebjZA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@effect/tsgo-darwin-x64": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@effect/tsgo-darwin-x64/-/tsgo-darwin-x64-0.24.3.tgz", - "integrity": "sha512-ksX9BdCM8289RRuN0lWhtzuvstH1frxwdxhoW08rrGW3ryER3udEFqEtgtveP0cYvK9gTlV5lXbQPNGktQ2sTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@effect/tsgo-linux-arm": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@effect/tsgo-linux-arm/-/tsgo-linux-arm-0.24.3.tgz", - "integrity": "sha512-0TzebGLMNZkHOGSK0w1B4zuvHGlA+Od4LdElWeHHP6iMPEiEWPq17If787+oc+Y67yITPJ9DZcibu69eI9mZhg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@effect/tsgo-linux-arm64": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@effect/tsgo-linux-arm64/-/tsgo-linux-arm64-0.24.3.tgz", - "integrity": "sha512-CMiThuURi14rT5Ikag5pVnUC3tx4IcmPpRR4WBQkHxAbOf5a1Lo1NUF82zY0azhLW2WszBamTSZP5Tcv4uks2A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@effect/tsgo-linux-x64": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@effect/tsgo-linux-x64/-/tsgo-linux-x64-0.24.3.tgz", - "integrity": "sha512-R4jl1JWoYEldUU1rtCG1fXWW2ywj/rgY1xaCzK8HSb8fQhQ71WRLLAyAe8ewROuseWQ9Ip+IbbfAeXQOpmpdDA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@effect/tsgo-win32-arm64": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@effect/tsgo-win32-arm64/-/tsgo-win32-arm64-0.24.3.tgz", - "integrity": "sha512-DOJ4oa8pk8qxEQwKHpJOqtjOfB9iOMuc6Cnb2ZbTG32PBfhqeRYv6YFGhFtgAojXyxfuRP2gCJVpFfjzHJh7nQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@effect/tsgo-win32-x64": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@effect/tsgo-win32-x64/-/tsgo-win32-x64-0.24.3.tgz", - "integrity": "sha512-6GpENpPn3mXldmJoM+Z7qi+8fe39xJYB2bLD5jqCFWl14vFp1AeJvYwHYQak8HRm1FiPz72Ninslv8DjHYtZgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@effect/vitest": { - "version": "4.0.0-beta.101", - "resolved": "https://registry.npmjs.org/@effect/vitest/-/vitest-4.0.0-beta.101.tgz", - "integrity": "sha512-F5Ur8pZYti0xkZFyb4hPZt8RrKQ1XBoC28ZkKxc7N1iPbhE9fWOvlWA1NC2XlN2wWG08yb5g5jR53LdOxjlhpQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "effect": "^4.0.0-beta.101", - "vitest": "^3.0.0 || ^4.0.0" - } - }, - "node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@hono/node-server": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", - "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@ioredis/commands": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", - "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", - "license": "MIT", - "peer": true - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.146.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", - "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", - "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", - "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", - "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", - "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", - "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", - "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", - "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", - "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", - "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", - "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", - "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", - "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", - "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", - "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", - "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", - "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@smithy/core": { - "version": "3.33.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", - "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", - "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.33.2", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", - "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.33.2", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", - "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.17.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", - "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/cluster-key-slot": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", - "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "peer": true, - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true - }, - "node_modules/effect": { - "version": "4.0.0-beta.101", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.101.tgz", - "integrity": "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.9.0", - "find-my-way-ts": "^0.1.6", - "ini": "^7.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^2.0.4", - "multipasta": "^0.2.8", - "toml": "^4.1.2", - "uuid": "^14.0.1", - "yaml": "^2.9.0" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "peer": true, - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.6.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", - "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-check": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", - "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true - }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense", - "peer": true - }, - "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "node_modules/firecrawl": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/firecrawl/-/firecrawl-4.30.1.tgz", - "integrity": "sha512-gW9nId70DeSNFIwW8YX8XINvr2xslCxjYLcaWe6As0NFPnTeaP0yQmKWNOA77vk/PcZaeGFrwFdR6s3eqBZiwA==", - "license": "MIT", - "dependencies": { - "axios": "1.18.0", - "typescript-event-target": "^1.1.1", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.23.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/firecrawl/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gaxios": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", - "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gaxios/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/google-auth-library": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", - "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.13.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", - "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "peer": true - }, - "node_modules/ini": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", - "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", - "license": "ISC", - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/ioredis": { - "version": "5.11.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", - "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", - "license": "MIT", - "peer": true, - "dependencies": { - "@ioredis/commands": "1.10.0", - "cluster-key-slot": "1.1.1", - "debug": "4.4.3", - "denque": "2.1.0", - "redis-errors": "1.2.0", - "redis-parser": "3.0.0", - "standard-as-callback": "2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, - "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.2.10", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", - "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause", - "peer": true - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", - "dev": true, - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", - "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", - "funding": [ - "https://github.com/sponsors/broofa" - ], - "license": "MIT", - "bin": { - "mime": "bin/cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/msgpackr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", - "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.4" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" - } - }, - "node_modules/multipasta": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", - "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "license": "MIT", - "peer": true, - "dependencies": { - "content-type": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/negotiator/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "peer": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/openai": { - "version": "6.40.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", - "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.17", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/protobufjs": { - "version": "7.6.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", - "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", - "dev": true, - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.1", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/pure-rand": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", - "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "peer": true, - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/rolldown": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", - "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.146.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.5", - "@rolldown/binding-android-arm64": "1.2.5", - "@rolldown/binding-darwin-arm64": "1.2.5", - "@rolldown/binding-darwin-x64": "1.2.5", - "@rolldown/binding-freebsd-x64": "1.2.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", - "@rolldown/binding-linux-arm64-gnu": "1.2.5", - "@rolldown/binding-linux-arm64-musl": "1.2.5", - "@rolldown/binding-linux-ppc64-gnu": "1.2.5", - "@rolldown/binding-linux-s390x-gnu": "1.2.5", - "@rolldown/binding-linux-x64-gnu": "1.2.5", - "@rolldown/binding-linux-x64-musl": "1.2.5", - "@rolldown/binding-openharmony-arm64": "1.2.5", - "@rolldown/binding-win32-arm64-msvc": "1.2.5", - "@rolldown/binding-win32-x64-msvc": "1.2.5" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "peer": true - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "peer": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC", - "peer": true - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT", - "peer": true - }, - "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/toml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", - "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "peer": true, - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/typebox": { - "version": "1.3.17", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.17.tgz", - "integrity": "sha512-20PsSaZV1pN7pIfM/YEUHZNTv8X21+1ilPo/HN+6GtFbhCaQhLrIoKCkAkcBwIva3nYI+Ao0MxM1iDj5H3SOhw==", - "dev": true, - "license": "MIT" - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/typescript-event-target": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.2.tgz", - "integrity": "sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw==", - "license": "MIT" - }, - "node_modules/undici": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", - "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/uuid": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", - "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true - }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - } - } -} diff --git a/package.json b/package.json index 00d271f..8af7ca1 100644 --- a/package.json +++ b/package.json @@ -1,59 +1,72 @@ { "name": "harness-tools", "version": "0.1.0", - "description": "Personal Pi extensions and skills.", + "private": true, + "description": "Personal Pi extensions, skills, and harness tooling.", "type": "module", "license": "MIT", - "keywords": [ - "pi-package" - ], + "packageManager": "bun@1.3.14", "engines": { "node": ">=22.19.0" }, + "workspaces": { + "packages": [ + "extensions/*", + "!extensions/_workflows" + ], + "catalog": { + "@anthropic-ai/claude-agent-sdk": "0.3.218", + "@earendil-works/pi-ai": "0.84.2", + "@earendil-works/pi-coding-agent": "^0.84.2", + "@earendil-works/pi-tui": "0.84.2", + "@effect/platform-node": "4.0.0-beta.101", + "@effect/platform-node-shared": "4.0.0-beta.101", + "@effect/tsgo": "0.24.3", + "@effect/vitest": "4.0.0-beta.101", + "@types/node": "^22.0.0", + "acorn": "8.18.0", + "effect": "4.0.0-beta.101", + "tsx": "4.21.0", + "typebox": "1.3.17", + "typescript": "7.0.2", + "vitest": "4.1.10" + }, + "catalogs": { + "pi-peer": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" + } + } + }, "scripts": { "prepare": "effect-tsgo patch", + "imports:check": "bun scripts/import-package-snapshots.mjs check all", "typecheck": "tsc --noEmit", - "test": "npm run test:runner && npm run test:artifact-presenter && npm run test:dictate-service && npm run test:links && node --test --experimental-strip-types extensions/dictate/index.test.ts extensions/firecrawl-search/index.test.ts extensions/shared/*.test.ts extensions/subagents/by-the-way.test.ts extensions/subagents/context-usage.test.ts extensions/subagents/manager.test.ts extensions/subagents/result-delivery.test.ts extensions/subagents/takeover.test.ts extensions/_workflows/*.test.ts extensions/pi-workflow/*.test.ts && vitest run extensions/file-search/index.spec.ts", + "test": "bun run test:runner && bun run test:artifact-presenter && bun run test:dictate-service && bun run test:links && bun run test:packages && bun run test:imported && bun run test:node && bun run test:file-search", "test:runner": "python -m unittest discover -s scripts -p 'test_*.py'", "test:artifact-presenter": "python -m unittest discover -s skills/document/artifact-presenter/tests -p 'test_*.py'", "test:dictate-service": "PYTHONPATH=services/dictate/src python -m unittest discover -s services/dictate/tests -p 'test_*.py'", "test:links": "node --test scripts/pi-links.test.mjs", + "test:packages": "node --test scripts/package-workspaces.test.mjs", + "test:imported": "bun run --filter '@_jb/pi-interactive-subagents' test && bun run --filter '@_jb/pi-native-hooks' test && bun run --filter '@_jb/pi-todo-herdr' test", + "test:node": "node --test --experimental-strip-types extensions/dictate/index.test.ts extensions/lm-studio/index.test.ts extensions/shared/*.test.ts extensions/subagents/by-the-way.test.ts extensions/subagents/context-usage.test.ts extensions/subagents/manager.test.ts extensions/subagents/result-delivery.test.ts extensions/subagents/takeover.test.ts extensions/_workflows/*.test.ts extensions/workflow/*.test.ts", + "test:file-search": "vitest run extensions/file-search/index.spec.ts --exclude '.worktrees/**'", "test:live": "node --test --experimental-strip-types extensions/subagents/claude.test.ts extensions/subagents/codex.test.ts" }, - "pi": { - "extensions": [ - "./extensions", - "!./extensions/_workflows", - "!./extensions/pi-workflow" - ], - "skills": [ - "./skills" - ] - }, - "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" - }, "devDependencies": { - "@earendil-works/pi-ai": "0.84.2", - "@earendil-works/pi-coding-agent": "^0.84.2", - "@earendil-works/pi-tui": "0.84.2", - "@effect/tsgo": "0.24.3", - "@effect/vitest": "4.0.0-beta.101", - "@types/node": "^22.0.0", - "typebox": "1.3.17", - "typescript": "7.0.2", - "vitest": "4.1.10" - }, - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.218", - "@effect/platform-node": "4.0.0-beta.101", - "@effect/platform-node-shared": "4.0.0-beta.101", - "acorn": "8.18.0", - "effect": "4.0.0-beta.101", - "firecrawl": "4.30.1" + "@earendil-works/pi-ai": "catalog:", + "@earendil-works/pi-coding-agent": "catalog:", + "@earendil-works/pi-tui": "catalog:", + "@effect/tsgo": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "acorn": "catalog:", + "tsx": "catalog:", + "typebox": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.101" diff --git a/scripts/import-package-snapshots.mjs b/scripts/import-package-snapshots.mjs new file mode 100644 index 0000000..08a23ce --- /dev/null +++ b/scripts/import-package-snapshots.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const inventoryPath = path.join(root, "config", "imported-packages.json"); +const inventory = JSON.parse(readFileSync(inventoryPath, "utf8")); +const [command, requestedName, ...rest] = process.argv.slice(2); + +function usage(message) { + if (message) console.error(message); + console.error( + "Usage:\n" + + " bun scripts/import-package-snapshots.mjs check [name|all]\n" + + " bun scripts/import-package-snapshots.mjs stage --output ", + ); + process.exit(2); +} + +if (!command || !["check", "stage"].includes(command)) usage(); +const selection = requestedName ?? "all"; +const packages = + selection === "all" + ? inventory.packages + : inventory.packages.filter((entry) => entry.name === selection); +if (packages.length === 0) usage(`Unknown imported package: ${selection}`); + +function remoteCommit(entry) { + const output = execFileSync( + "git", + ["ls-remote", entry.source, `refs/heads/${entry.branch}`], + { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, + ).trim(); + const [commit] = output.split(/\s+/u); + if (!/^[0-9a-f]{40}$/u.test(commit ?? "")) { + throw new Error(`No ${entry.branch} branch returned for ${entry.name}`); + } + return commit; +} + +if (command === "check") { + let stale = false; + for (const entry of packages) { + const current = remoteCommit(entry); + const status = current === entry.commit ? "current" : "update available"; + console.log(`${entry.name}: ${status} (${entry.commit.slice(0, 7)} -> ${current.slice(0, 7)})`); + stale ||= current !== entry.commit; + } + process.exitCode = stale ? 1 : 0; +} else { + const outputIndex = rest.indexOf("--output"); + const outputArg = outputIndex >= 0 ? rest[outputIndex + 1] : undefined; + if (!outputArg) usage("stage requires --output "); + const outputRoot = path.resolve(root, outputArg); + if (existsSync(outputRoot)) usage(`Output already exists: ${outputRoot}`); + mkdirSync(outputRoot, { recursive: true }); + + for (const entry of packages) { + const destination = path.join(outputRoot, entry.name); + execFileSync( + "git", + [ + "clone", + "--quiet", + "--depth", + "1", + "--single-branch", + "--branch", + entry.branch, + entry.source, + destination, + ], + { stdio: "inherit" }, + ); + const commit = execFileSync("git", ["-C", destination, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); + rmSync(path.join(destination, ".git"), { recursive: true, force: true }); + rmSync(path.join(destination, "package-lock.json"), { force: true }); + console.log(`${entry.name}: staged ${commit} at ${destination}`); + } +} diff --git a/scripts/package-workspaces.test.mjs b/scripts/package-workspaces.test.mjs new file mode 100644 index 0000000..c600d1c --- /dev/null +++ b/scripts/package-workspaces.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const extensionsRoot = path.join(root, "extensions"); +const registry = "https://git.jb.codes/api/packages/jb/npm/"; +const expectedWorkspaces = [ + "copy-all", + "dictate", + "effort-controls", + "file-search", + "interactive-subagents", + "lm-studio", + "native-hooks", + "prompt-snippets", + "shared", + "subagents", + "todo-herdr", + "update", + "workflow", +]; + +function readJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} + +function workspaceManifest(directory) { + return readJson(path.join(extensionsRoot, directory, "package.json")); +} + +function dependencyText(manifest) { + return JSON.stringify({ + dependencies: manifest.dependencies, + optionalDependencies: manifest.optionalDependencies, + peerDependencies: manifest.peerDependencies, + }); +} + +test("workspace inventory keeps reference workflows unpublished", () => { + const rootManifest = readJson(path.join(root, "package.json")); + assert.equal(rootManifest.private, true); + assert.deepEqual(rootManifest.workspaces.packages, [ + "extensions/*", + "!extensions/_workflows", + ]); + assert.equal( + readdirSync(path.join(extensionsRoot, "_workflows")).includes("package.json"), + false, + ); + + const actual = readdirSync(extensionsRoot) + .filter((directory) => { + try { + workspaceManifest(directory); + return true; + } catch { + return false; + } + }) + .sort(); + assert.deepEqual(actual, expectedWorkspaces); + assert.equal(workspaceManifest("workflow").private, true); +}); + +test("imported package snapshots retain explicit main-branch provenance", () => { + const inventory = readJson(path.join(root, "config", "imported-packages.json")); + assert.equal(inventory.version, 1); + assert.deepEqual( + inventory.packages.map(({ name, branch, target }) => ({ name, branch, target })), + [ + { + name: "pi-interactive-subagents", + branch: "main", + target: "extensions/interactive-subagents", + }, + { + name: "pi-native-hooks", + branch: "main", + target: "extensions/native-hooks", + }, + { + name: "pi-todo-herdr", + branch: "main", + target: "extensions/todo-herdr", + }, + ], + ); + for (const entry of inventory.packages) { + assert.match(entry.commit, /^[0-9a-f]{40}$/); + assert.match(entry.source, /^ssh:\/\/git@git\.jb\.codes:7722\/jb\//); + assert.match( + readJson(path.join(root, entry.target, "package.json")).name, + /^@_jb\/pi-/, + ); + } +}); + +test("publishable workspaces have Gitea-safe package contracts", () => { + for (const directory of expectedWorkspaces) { + const packageRoot = path.join(extensionsRoot, directory); + const manifest = workspaceManifest(directory); + if (manifest.private) continue; + + assert.match(manifest.name, /^@_jb\/pi-[a-z0-9-]+$/); + assert.equal(manifest.publishConfig?.registry, registry); + assert.equal(manifest.engines?.node, ">=22.19.0"); + assert.equal(readFileSync(path.join(packageRoot, "LICENSE"), "utf8").length > 0, true); + assert.equal(readFileSync(path.join(packageRoot, "README.md"), "utf8").length > 0, true); + + for (const entry of manifest.pi?.extensions ?? []) { + assert.equal(readFileSync(path.resolve(packageRoot, entry), "utf8").length > 0, true); + } + for (const entry of manifest.pi?.skills ?? []) { + assert.equal(readFileSync(path.resolve(packageRoot, entry, "SKILL.md"), "utf8").length > 0, true); + } + } +}); + +test("Bun packs concrete dependency versions and excludes tests", () => { + const outputRoot = mkdtempSync(path.join(tmpdir(), "harness-tools-pack-")); + try { + for (const directory of expectedWorkspaces) { + const manifest = workspaceManifest(directory); + if (manifest.private) continue; + const packageRoot = path.join(extensionsRoot, directory); + const before = new Set(readdirSync(outputRoot)); + execFileSync( + "bun", + ["pm", "pack", "--destination", outputRoot, "--ignore-scripts", "--quiet"], + { cwd: packageRoot, stdio: "pipe" }, + ); + const created = readdirSync(outputRoot).filter((entry) => !before.has(entry)); + assert.equal(created.length, 1, `${manifest.name} should create one tarball`); + const tarball = path.join(outputRoot, created[0]); + const packedManifest = JSON.parse( + execFileSync("tar", ["-xOf", tarball, "package/package.json"], { + encoding: "utf8", + }), + ); + assert.equal(dependencyText(packedManifest).includes("catalog:"), false); + assert.equal(dependencyText(packedManifest).includes("workspace:"), false); + const files = execFileSync("tar", ["-tzf", tarball], { encoding: "utf8" }); + assert.match(files, /^package\/LICENSE$/m); + assert.match(files, /^package\/README\.md$/m); + assert.doesNotMatch(files, /\.(?:test|spec)\.ts$/m); + } + } finally { + rmSync(outputRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/pi-links.test.mjs b/scripts/pi-links.test.mjs index 6ab5a15..4735129 100644 --- a/scripts/pi-links.test.mjs +++ b/scripts/pi-links.test.mjs @@ -246,7 +246,7 @@ test("normal exposure and package evidence retain the Slice 0 security boundary" await readFile(path.join(repositoryRoot, "config", "pi-links.json"), "utf8"), ); assert.equal(links.extensions.globalSymlinks.update, undefined); - assert.equal(links.extensions.globalSymlinks["pi-workflow"], undefined); + assert.equal(links.extensions.globalSymlinks["workflow"], undefined); const lock = JSON.parse( await readFile( diff --git a/tests/nono/capabilities/core/test_policy.py b/tests/nono/capabilities/core/test_policy.py index 0eb9407..f90b749 100644 --- a/tests/nono/capabilities/core/test_policy.py +++ b/tests/nono/capabilities/core/test_policy.py @@ -170,7 +170,7 @@ class PolicyContractTests(unittest.TestCase): with self.assertRaisesRegex(CompositionError, "reviewed lock source"): isolated_settings("lm-studio", lm_studio_extension=unreviewed) serialized = json.dumps(local).lower() - for forbidden in ("pi-workflow", "update", "background", "notify"): + for forbidden in ("workflow", "update", "background", "notify"): self.assertNotIn(forbidden, serialized) def test_lm_studio_registration_is_unique_and_uses_exact_ip(self) -> None: