diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a16d477 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.9" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build V1 and V2 + run: npm run build:all + + - name: Verify V1 package + run: npm pack --dry-run + + - name: Verify V2 package + run: npm pack --dry-run + working-directory: packages/v2 + + - name: Test packed package in OpenCode 2 + run: npm run test:v2:e2e diff --git a/.github/workflows/release-v2.yml b/.github/workflows/release-v2.yml new file mode 100644 index 0000000..78ab029 --- /dev/null +++ b/.github/workflows/release-v2.yml @@ -0,0 +1,92 @@ +name: release-v2 + +on: + push: + tags: + - "v2-*" + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +permissions: + contents: read + id-token: write + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.9" + + - name: Validate tag and package version + run: | + node -e ' + const fs = require("node:fs"); + const tag = process.env.GITHUB_REF_NAME || ""; + const version = JSON.parse(fs.readFileSync("packages/v2/package.json", "utf8")).version; + if (tag !== `v2-${version}`) { + throw new Error(`tag/version mismatch: tag=${tag} package=${version}`); + } + ' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Verify repository + run: npm run lint && npm run typecheck && npm test + + - name: Build and pack V2 + run: npm --prefix ../.. run build:v2 && npm pack --dry-run + working-directory: packages/v2 + + - name: Test packed package in OpenCode 2 + run: npm run test:v2:e2e + + publish: + needs: verify + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.9" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Publish V2 beta + run: npm publish --access public --provenance --tag next + working-directory: packages/v2 + + - name: Create GitHub prerelease + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + echo "GitHub release already exists: $GITHUB_REF_NAME" + else + gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "$GITHUB_REF_NAME" --prerelease --latest=false + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9624fd9..1ee9d70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,7 +67,7 @@ jobs: needs: verify runs-on: ubuntu-latest permissions: - contents: read + contents: write id-token: write steps: - name: Checkout @@ -89,3 +89,13 @@ jobs: - name: Publish to npm run: npm publish --access public --provenance + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + echo "GitHub release already exists: $GITHUB_REF_NAME" + else + gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "$GITHUB_REF_NAME" + fi diff --git a/OPENCODE_V2_IMPACT_REPORT.md b/OPENCODE_V2_IMPACT_REPORT.md new file mode 100644 index 0000000..7af9bb8 --- /dev/null +++ b/OPENCODE_V2_IMPACT_REPORT.md @@ -0,0 +1,617 @@ +# OpenCode 2 Impact and Migration Report + +Status: Implemented beta prototype +Last updated: 2026-07-20 +Doc Class: report +Doc Type: research +Report Type: engineering migration +Decision Status: accepted for opt-in beta +Authority: advisory +Canonical Source: `OPENCODE_V2_IMPACT_REPORT.md` +Report topic: OpenCode 2 plugin and client migration + +## Summary + +OpenCode 2 is currently a beta distributed separately from stable OpenCode 1. The upstream project explicitly warns that its APIs, configuration, data, and plugin APIs may still change. It also explicitly states that OpenCode 1 plugins do not work in OpenCode 2. + +`opencode-command-hooks` will not load in OpenCode 2 in its current form. The package exports an OpenCode 1 plugin function that returns a hooks object. OpenCode 2 requires a default plugin descriptor with a unique `id` and `setup` or `effect` function, and hooks are registered imperatively through the new context. + +The current OpenCode 2 beta has enough API surface to prototype most core behavior: + +- `ctx.tool.hook("execute.before", ...)` and `ctx.tool.hook("execute.after", ...)` can replace the v1 tool hooks. +- `ctx.event.subscribe()` exposes the public event stream for session lifecycle handling. +- `ctx.session.synthetic()` appears to be the intended replacement for injecting context without creating a normal user prompt. +- Existing v1-shaped config and agent Markdown locations are intended to be translated in memory. + +However, a production migration should not begin yet. Important contracts remain unstable or incomplete: + +- The beta guide and current `v2` branch disagree on at least one hook name: the guide uses `ctx.session.hook("request")`, while source currently defines `ctx.session.hook("context")`. +- The guide and source also differ on some tool context field names. +- Server plugins have no public equivalent to v1 `client.tui.showToast()` or `client.app.log()`. +- Plugin reload isolation and config-directory reload have open defects. +- The package must determine the correct location/workspace rather than use `process.cwd()` in the long-lived v2 service. + +The recommended release strategy is to preserve `opencode-command-hooks` as the v1 line and publish any beta port under a separate package name, such as `opencode-command-hooks-v2`, using exact prerelease versions. Do not move the existing package's npm `latest` tag to a v2-only artifact while a large v1 population remains. + +## Implementation Addendum + +The opt-in beta adapter is now implemented in this repository: + +- `src/v2/plugin.ts` owns the V2 Promise adapter and keeps callback failures non-blocking. +- `packages/v2` produces the separate `opencode-command-hooks-v2` package without changing the V1 package identity or release tag. +- The package pins `@opencode-ai/plugin@0.0.0-next-15853` exactly. +- Tool hooks use direct V2 event input, and `subagent`/`agent` are normalized to the existing `task`/`subagent_type` config vocabulary. +- Session lookup supplies the authoritative project directory and agent for config discovery and command execution. +- Injection uses `ctx.session.synthetic()` with `resume: false`; toast requests produce one explicit unsupported diagnostic. +- The event consumer maps compatibility and durable execution events to `session.start`/`session.idle`, deduplicates event IDs and session starts, and is closed during plugin cleanup. +- Unit, V1 regression, packed-artifact, and gated pinned-host E2E tests cover the adapter. +- A dedicated V2 release workflow requires the pinned host E2E to pass before npm publication. + +The first one-shot standalone E2E queried the plugin list before asynchronous plugin activation completed. The corrected test starts an isolated persistent server, polls the location-scoped plugin endpoint until setup finishes, and passes against `0.0.0-next-15853`. It uses temporary HOME/XDG directories and a test-only server password, so it does not connect to or modify the normal OpenCode service. + +## Research Question + +- How will OpenCode 2 affect `opencode-command-hooks`? +- Which features can be migrated now, which require redesign, and which are blocked? +- How should v1 and v2 support coexist while users upgrade at different times? + +## Scope + +### In Scope + +- OpenCode 2 beta plugin loader and authoring API. +- Tool and session lifecycle contracts. +- Session context injection, notifications, logging, location, and configuration. +- Direct impact on the current repository. +- Package compatibility, release strategy, testing, rollout, and rollback. + +### Out of Scope + +- Implementing the v2 adapter. +- Changing the package, source, tests, release workflow, or npm metadata. +- Promising compatibility with an API that upstream still labels beta. + +## Method + +- Inspected this repository's package metadata, source, tests, generated `dist`, and release workflow. +- Used Firecrawl for current OpenCode 1 docs, OpenCode 2 beta docs, upstream issues, pull requests, and source pages. +- Used Context7 for the current OpenCode plugin API documentation and examples. +- Audited upstream Git branches at these snapshots: + - `dev`: `45cd8d76920839e4a7b6b931c4e26b52e1495636`, 2026-07-17. + - `v2`: `987242b3e83ff7ed870b376b94394d068c7d8790`, 2026-07-17. +- Queried npm metadata for current dist-tags. At research time, stable was `1.18.3`; beta/next/dev remained prerelease channels. +- Ran five parallel focused research tracks covering the v2 API, local impact, lifecycle contracts, coexistence strategy, and rollout risks. + +## Current State + +### Stable and beta are separate + +OpenCode 1 remains the stable line. The OpenCode 2 beta is installed from `@opencode-ai/cli@next` and runs as `opencode2`, allowing side-by-side evaluation with the stable `opencode` executable. + +Upstream's migration documentation says: + +- Plugins use a new API. +- Server APIs and clients use new contracts. +- V1 plugins will not work in V2. +- Detailed plugin migration guidance will be published after the API is finalized. +- Existing v1 server config, agent definitions, commands, skills, and `.opencode` files are intended to remain compatible through in-memory translation. + +### Two upstream development surfaces matter + +The default `dev` branch and the dedicated `v2` branch currently expose different v2 plugin capabilities. + +- `dev` has early v2 package exports but lacks tool, event, and session domains in its public context. +- `v2`, which backs the beta docs, has tool hooks, event subscription, session actions, and broader client capabilities. + +This branch divergence is a strong signal not to infer a stable contract from package paths alone. Migration work should pin and test a specific OpenCode beta build and matching `@opencode-ai/plugin` package. + +## Impact Matrix + +| Surface | Current implementation | OpenCode 2 impact | Severity | Proposed treatment | +| --- | --- | --- | --- | --- | +| Plugin export | `src/index.ts:253-405` exports a v1 `Plugin` function returning hooks | V2 loader accepts only a default `{ id, setup }` or `{ id, effect }` descriptor | Critical | New v2 entrypoint/package | +| Hook registration | Returned keys such as `tool.execute.before`, `tool.execute.after`, `event`, `chat.params` | V2 setup registers hooks imperatively through `ctx.tool.hook`, `ctx.event.subscribe`, and other domains | Critical | V2 host adapter | +| Before-tool payload | Separate `input` and mutable `output.args` | One event object with mutable `event.input`; IDs and agent are included directly | High | Normalize into shared internal context | +| After-tool payload | Args cached from before hook; `tool.result` fallback and dedupe | V2 after event includes `input`, `result`, `output`, and `outputPaths`; no cache should be necessary | High | Remove v1 cache/fallback from v2 adapter | +| Session lifecycle | Generic v1 `event` handling for `session.created`, `session.idle`, and `tool.result` | V2 has a typed event stream and durable execution events; envelope uses `data`, not v1 `properties` | High | Event normalization and dedupe | +| Session injection | `client.session.promptAsync({ path, body })` | New client shape is flat; `ctx.session.synthetic({ sessionID, text, ... })` is the likely context-only primitive | High | Injection port with explicit semantics tests | +| Toast | `client.tui.showToast({ body })` | No public server-plugin toast capability in current v2 context | High for feature parity | Defer, omit with diagnostics, or add a future TUI companion | +| Logging | `client.app.log({ body })` | No public v2 server-plugin logging facade found | Medium | Internal logger port; use supported diagnostics when finalized | +| Runtime directory | `process.cwd()` drives config discovery and command execution | V2 is a long-lived, multi-location service; ambient cwd is not a reliable project location | Critical | Capture plugin location and pass cwd explicitly | +| Agent Markdown | Directly reads `.opencode/agents` and legacy `.opencode/agent`; custom `hooks` frontmatter | V2 intends to keep all v1 agent directories compatible, but custom-field handling and agent identity must be verified | High | Preserve parser; replace discovery/context adapter | +| Provider option stripping | `chat.params` deletes `hooks` and `command_hooks` | No direct v2 equivalent is documented; v2 config decoding may ignore extras, but this must be tested | Medium | Contract test before removing behavior | +| Plugin config key | README tells users to use v1 `plugin` | Native v2 key is `plugins`; v1-shaped files are translated in memory | Medium | Do not force config migration for trial users; document native form separately | +| Shell execution | Global `Bun.$` with ambient cwd | V2 does not inject v1 `$`; Bun remains available but cwd must be explicit | High | Command runner port accepting cwd | +| Error semantics | Plugin catches failures so hooks remain non-blocking | V2 docs state an uncaught runtime-hook failure fails the intercepted operation | Critical | Keep a catch-all inside every v2 callback | +| Dependencies | `@opencode-ai/plugin` and SDK are broad v1 ranges | V2 docs require matching plugin package and host versions during beta | High | Exact prerelease pins in the v2 package | +| Test harness | E2E writes v1 `plugin` config and invokes `opencode` | Needs separate v1 stable and pinned `opencode2` suites | High | Versioned contract matrix | + +## Detailed Findings + +### 1. The current package cannot load in v2 + +The package's root export is a function: + +```ts +export const CommandHooksPlugin: Plugin = async ({ client }) => { + return { + event: async (...) => {}, + "tool.execute.before": async (...) => {}, + "tool.execute.after": async (...) => {}, + } +} +``` + +The v2 loader imports the module and validates its default export as one of: + +```ts +{ id: string, setup: function } +{ id: string, effect: function } +``` + +Invalid modules are skipped and logged. There is no automatic adapter for a v1 function returning hooks. + +Confidence: high. + +### 2. Tool hooks are migratable, but their contract is different + +The current v2 beta source exposes: + +```ts +await ctx.tool.hook("execute.before", async (event) => { + event.input = updatedInput +}) + +await ctx.tool.hook("execute.after", async (event) => { + // event.input, event.result, event.output, event.outputPaths +}) +``` + +Useful differences from v1: + +- `event.agent` is present directly. +- `event.input` is available to the after hook. +- The v1 `toolCallArgsCache` should not be needed in a v2 adapter. +- The undocumented `tool.result` fallback should not be carried into v2. +- Hook names inside the plugin context omit the `tool.` prefix. + +Risks: + +- Tool input is typed as `unknown` and must be narrowed. +- Built-in tool IDs and subagent input shapes must be observed in an actual beta build. The current plugin assumes a tool named `task` with `subagent_type`; v2 terminology and permission actions use `subagent`. +- The v2 guide says uncaught hook failures fail the intercepted operation. This plugin's non-blocking promise depends on handling every expected and unexpected error internally. + +Confidence: high for API shape, medium for built-in tool identities. + +### 3. Session lifecycle needs an adapter, not direct string substitution + +The v2 public event stream includes durable events such as: + +- `session.execution.started` +- `session.execution.succeeded` +- `session.execution.failed` +- `session.execution.interrupted` +- `session.input.admitted` +- `session.input.promoted` + +Compatibility events including `session.created` and `session.idle` also appear in generated client types, but the long-term event contract remains in motion. V2 events use an envelope with fields such as `type`, `data`, `durable`, and `location`; the current code expects v1 `event.properties`. + +Recommended normalization: + +- Preserve the public command-hooks config vocabulary initially: `session.start`, `session.created`, and `session.idle`. +- Normalize v2 `session.created` to start. +- Decide whether idle means compatibility `session.idle` or durable `session.execution.succeeded`; test both and dedupe by event/session/sequence. +- Resolve agent identity with the session client if the lifecycle event does not carry it. +- Do not add a v2 `session.end` mapping until upstream defines one. The current repository advertises `session.end` in `src/types/hooks.ts:173-178` but does not implement it in `src/index.ts`; that is an existing gap, not a v2 regression. + +The v2 Promise event stream is an `AsyncIterable`. Setup must start a cancellable background consumer and return cleanup. It must not await an infinite subscription inside `setup`. + +Confidence: medium-high. Event names are implemented, but upstream explicitly warns they may change. + +### 4. `session.synthetic` is the strongest injection candidate + +The current plugin injects text with v1 `session.promptAsync`. The v2 session API provides both normal prompts and synthetic messages. Synthetic messages carry text, optional description/metadata, and delivery policy, and are represented as synthetic context in the model-facing history. + +This maps closely to the plugin's intent: add deterministic hook output to context without impersonating a normal user prompt. + +Proposed v2 mapping: + +```ts +await ctx.session.synthetic({ + sessionID, + text: message, + description: "opencode-command-hooks", + metadata: { hookId }, +}) +``` + +Before adoption, tests must establish: + +- Whether default delivery queues or steers an active execution. +- Whether injection after a tool is visible at the intended continuation boundary. +- Whether synthetic insertion can cause recursion or an unwanted new model turn. +- Ordering when several hooks inject sequentially. +- Behavior after failed/interrupted executions. + +Confidence: medium. The API exists and its data model fits, but exact operational semantics need runtime proof. + +### 5. Toast parity is currently blocked + +The v2 server plugin context does not expose a TUI client or toast method. A separate TUI plugin surface has UI toast capabilities, but a server plugin cannot currently call them through the documented v2 context. + +Options, in order: + +1. Keep `toast` in the shared config but report it as unsupported in the v2 beta package. +2. Wait for an upstream server notification capability. +3. Build a separate TUI companion only if upstream provides a supported bridge between server and TUI plugin surfaces. + +Do not silently claim toast success in v2. Injection and command execution should continue even when toast is unavailable. + +Confidence: high. + +### 6. Ambient cwd is unsafe in the v2 service + +The current implementation searches from and executes in `process.cwd()`: + +- `src/config/global.ts:268-274` +- `src/config/agent.ts:74-81` +- `src/execution/shell.ts:177-196` + +OpenCode 2 uses a background service with location-scoped plugin hosts. One process may serve multiple directories/workspaces, so process cwd is not a valid location contract. + +The v2 client returns location metadata on several calls. A beta adapter may be able to capture the host location from a location-aware client response such as `ctx.agent.list()`, but upstream should ideally expose location directly in plugin context. The adapter should not be released until it has a deterministic directory source. + +The shared command engine should receive cwd as an explicit argument. Configuration search and agent-file resolution should receive the same location rather than reading global process state. + +Confidence: high for the risk, medium for the final upstream location API. + +### 7. Existing config can coexist, but native v2 config differs + +V2 intends to translate v1 config in memory. Existing users can keep: + +```jsonc +{ + "plugin": ["opencode-command-hooks"] +} +``` + +Native v2 configuration uses: + +```jsonc +{ + "plugins": [ + "opencode-command-hooks-v2@" + ] +} +``` + +Tuple package options become `{ "package": "...", "options": { ... } }`. + +Do not advise users to convert shared config to native v2 while they still run v1 against the same file. Upstream explicitly recommends keeping v1-shaped config during side-by-side evaluation. + +The plugin's own `command-hooks.jsonc` contract is independent of OpenCode's root config and can remain stable. Its location resolution must still become host-location-aware. + +Confidence: high. + +### 8. The v2 beta is not stable enough for a production default + +Evidence of active churn and risk includes: + +- Official warning that plugin entrypoints, hooks, draft shapes, and configuration may change before stable release. +- Guide/source disagreement over `session.hook("request")` versus `session.hook("context")`. +- Guide/source disagreement over some tool context field names. +- An open issue where a bad plugin tool can poison all sessions in a location and replacement is not atomic. +- An open issue where changes inside config directories fail to trigger command, agent, and plugin reloads. +- An open issue where self-update can orphan the v2 background service and hang clients. +- A same-day fix restoring `opencode2 plugin list`, whose discussion reiterates intentional v1 incompatibility. + +Confidence: high. + +### 9. The current release artifact needs a clean baseline before branching + +`package.json` publishes `dist/index.js`, but the checked-in `dist` does not match current source behavior. For example, the source contains `chat.params` stripping and newer after-hook dedupe behavior that are absent from the built artifact inspected during this research. + +This is not caused by v2, but it matters for migration: + +- A v2 port should not fork from behavior that exists only in source and not in the shipped artifact. +- Tests should exercise the packed artifact, not only `src`. +- Release CI builds before publish, but local E2E and repository inspection can still test stale `dist`. + +Establish and document the intended v1 behavior before extracting shared code. + +Confidence: high. + +## Recommended Compatibility Strategy + +### Decision + +Keep the existing npm package as the v1 product line. Publish the v2 beta port under a separate package name and exact versions. + +Suggested naming: + +- V1: `opencode-command-hooks` +- V2 beta: `opencode-command-hooks-v2` + +Why this is safest: + +- Existing bare v1 specs continue resolving to a v1-compatible artifact. +- Moving npm `latest` cannot accidentally break all unpinned v1 users. +- V2 users opt in explicitly and can roll back by removing one package and restoring the other. +- Each package can use independent dependencies, entrypoints, tests, and release cadence while the v2 API churns. +- Support communication is unambiguous. + +### Alternatives considered + +| Option | Benefit | Risk | Recommendation | +| --- | --- | --- | --- | +| Separate v2 package | Strongest isolation and rollback | Temporary package-name fragmentation | Recommended during beta and transition | +| Same package, v2 on `next`/`v2` dist-tag | Preserves one package name | A future `latest` change can break v1 users; mutable tags complicate reproducibility | Acceptable only with exact versions and strict release discipline | +| Same package, v2 major on `latest` | Conventional semver | OpenCode auto-installs bare specs; many users do not pin plugin versions | Do not do while v1 remains common | +| One dual-runtime root export | One artifact | Hosts do not select a major-specific export; loader contracts differ; older v1 loaders may reject descriptors | Do not use as the primary strategy | + +### Version policy + +- Keep npm `latest` for `opencode-command-hooks` on the supported v1 line. +- Publish v2 beta releases only as exact versions of the separate package. +- Pin `@opencode-ai/plugin` and other v2 packages to the exact matching beta build. +- Do not depend on `next`, `beta`, or `latest` ranges inside a production plugin release. +- Add host compatibility metadata when upstream confirms enforcement for the target host, but treat it as a guardrail rather than the compatibility mechanism. +- Never unpublish v1 versions needed for rollback. + +## Target Architecture + +Do not duplicate the entire plugin. Keep host-independent behavior shared and isolate host contracts. + +### Shared core + +Retain or extract only behavior that does not know about OpenCode: + +- Zod schemas and config validation. +- Global/project merge semantics. +- Hook matching. +- Template interpolation. +- Sequential command execution and output truncation. +- Non-blocking execution policy. + +### V1 adapter + +Own: + +- Legacy returned hook object. +- V1 event payload normalization. +- V1 SDK nested `path`/`body` calls. +- V1 toast and structured logging. +- Existing tool-args cache and fallback only as long as supported versions need them. + +### V2 adapter + +Own: + +- `Plugin.define({ id, setup })` default export. +- Imperative hook registration and cleanup. +- V2 tool event normalization. +- Event-stream background consumer and dedupe. +- Session lookup for missing agent context. +- Synthetic context injection. +- Explicit location/workspace and command cwd. +- Explicit unsupported-toast diagnostics until parity exists. + +Use the Promise v2 API unless Effect provides a concrete capability the plugin needs. The current codebase is Promise-based, and choosing Effect only for conformity would increase migration scope without improving behavior. + +## Draft Migration Plan + +### Gate 0: Watch upstream and freeze assumptions + +Actions: + +- Track the v2 plugin guide, `v2` branch, package exports, and migration page. +- Record one exact OpenCode 2 build and matching plugin/client package versions for every spike. +- Open or follow upstream questions for location access, server-side notifications/logging, synthetic delivery semantics, and the `request`/`context` hook-name mismatch. + +Exit criteria: + +- Tool before/after, event subscription, session synthetic, and location contracts are documented consistently with source for a pinned build. + +### Gate 1: Stabilize the v1 baseline + +Actions: + +- Rebuild `dist` and verify the packed package matches source. +- Add packed-artifact tests for current v1 behavior. +- Decide and document the actual support status of `session.end`, slash-command filters, and `tool.result` fallback. +- Pass runtime directory explicitly through the internal command/config APIs without changing v1 behavior. + +Exit criteria: + +- One reproducible v1 contract suite passes against the oldest and newest supported OpenCode 1 releases. +- `npm pack` contents are the artifact used by tests. + +### Gate 2: Introduce host ports + +Actions: + +- Define narrow internal ports for lifecycle input, location, injection, notification, and logging. +- Keep schemas, matching, templates, execution, and merge logic host-neutral. +- Move v1-specific caches and payload aliases behind the v1 adapter. + +Exit criteria: + +- Existing v1 behavior passes unchanged through the adapter boundary. +- No v2 dependency is required by the v1 package. + +### Gate 3: Build a pinned v2 prototype + +Actions: + +- Create a default v2 Promise plugin descriptor with a stable plugin ID. +- Register `execute.before` and `execute.after` hooks. +- Consume session events in a cancellable background task. +- Use direct v2 `event.input` in both phases; do not carry the v1 args cache. +- Resolve the plugin location deterministically and run commands with explicit cwd. +- Inject with `ctx.session.synthetic()` behind an adapter. +- Treat toast as unsupported with one clear diagnostic, not as a silent no-op. +- Catch all callback failures to preserve non-blocking tool execution. + +Exit criteria: + +- Prototype passes against one exact beta build. +- Before/after hooks, tool-argument filtering, session start/idle, injection, config reload, and cleanup have runtime evidence. +- A hook command failure never fails the intercepted OpenCode tool. + +### Gate 4: Add a dual-host contract matrix + +Minimum matrix: + +| Dimension | V1 | V2 beta | +| --- | --- | --- | +| Host executable | `opencode` | `opencode2` | +| Plugin install | packed v1 package | packed exact v2 package | +| Config shape | v1 `plugin` | v1 translated and native v2 `plugins` | +| Tool phases | before and after | before and after | +| Tool filters | name and args | name and args | +| Agent hooks | project/global, singular/plural dirs | translated legacy and preferred v2 dirs | +| Session lifecycle | created and idle | created and selected execution/idle mapping | +| Injection | v1 async prompt behavior | v2 synthetic behavior | +| Toast | supported | explicit unsupported status or supported replacement | +| Location | single project and nested cwd | two simultaneous locations/workspaces | +| Failures | nonzero shell, timeout, malformed config | same plus hook callback exception | +| Reload | config and plugin updates | config and plugin updates, cleanup, no duplicate hooks | + +Exit criteria: + +- No unexplained behavioral divergence. +- All expected degradations are documented and surfaced to users. +- Open v2 reload defects are fixed upstream or mitigated and tested. + +### Gate 5: Publish an opt-in v2 beta package + +Actions: + +- Publish `opencode-command-hooks-v2@` without changing the v1 package's `latest` tag. +- State the exact supported OpenCode 2 beta build. +- Provide side-by-side install, verification, and rollback steps. +- Require users to verify active plugin IDs with `opencode2 api get /api/plugin`. +- Collect issue reports with host version, plugin version, event type, and server logs. + +Exit criteria: + +- At least 20 representative successful canary runs across multiple projects. +- Zero tool-blocking plugin failures. +- No unresolved injection recursion, location, or duplicate-event defects. + +### Gate 6: Decide the stable package future + +After OpenCode 2 and its plugin API are stable, choose one: + +- Keep permanent `-v2` package separation while v1 remains maintained. +- Move v2 to a new major of `opencode-command-hooks`, but keep npm `latest` on the v1 line until users have clear pinning/migration guidance. +- Eventually move `latest` to v2 only after a declared migration window and adoption threshold. + +Do not make this decision during the upstream beta. + +## User Migration and Rollback + +### Side-by-side trial + +1. Keep `opencode` and `opencode-command-hooks` unchanged. +2. Install a pinned `opencode2` beta separately. +3. Use v1-shaped shared config initially so both hosts can read it. +4. Add the exact v2 plugin package only in an isolated v2 test config or disposable environment. +5. Verify plugin loading, model/provider credentials, agents, permissions, and hooks before regular use. + +### Production migration + +1. Record the exact working v1 OpenCode and plugin versions. +2. Remove the v1 plugin entry and add the exact v2 package entry in one controlled config change. +3. Verify active plugin ID, before/after execution, session injection, and project cwd. +4. Keep the old v1 config and package version available through at least one stable OpenCode 2 release cycle. + +### Rollback + +1. Stop using `opencode2` for the affected workflow. +2. Restore the exact prior v1 plugin spec and v1 config. +3. Restart OpenCode so cached plugin state and registrations are cleared. +4. Diagnose cache only if an exact package does not reload; cache deletion should not be the normal rollback mechanism. + +Rollback triggers: + +- A hook exception blocks a tool. +- Hook commands execute in the wrong directory. +- Injection triggers an unintended model turn or recursion. +- Duplicate lifecycle events execute hooks twice. +- Required toast behavior is silently lost. +- Plugin reload breaks unrelated sessions or tools. +- A beta service update causes hangs or unbounded retries. + +## Recommendations + +1. Do not port the production package until the v2 API is documented consistently with the beta source. +2. Keep the current npm package and `latest` tag v1-compatible. +3. Use a separate v2 beta package with exact OpenCode/plugin dependency pins. +4. Stabilize the packed v1 artifact before extracting shared code. +5. Separate host adapters from the schema/matching/execution core. +6. Use v2 tool hooks directly and avoid carrying v1 cache/fallback complexity forward. +7. Prototype injection with `session.synthetic`, but require runtime proof of delivery and recursion semantics. +8. Treat toast and structured logging as explicit parity gaps, not silent no-ops. +9. Make project location and shell cwd explicit before supporting the v2 background service. +10. Maintain a dual-host contract test matrix and a documented exact-version rollback path. + +## Open Questions + +- Will the stable hook be named `session.request`, `session.context`, or something else? +- What is the stable way for a server plugin to obtain its location and workspace directly? +- Will server plugins receive a supported logging and user-notification capability? +- What synthetic delivery policy is correct for injection during an active tool execution? +- Which v2 event should define the user-facing `session.idle` compatibility behavior? +- What are the stable built-in tool IDs and subagent input shapes? +- Are custom agent-frontmatter fields ignored, preserved, or forwarded into provider request options? +- Will plugin replacement become atomic and retain the last known good generation? +- What semver or compatibility guarantee will apply to the stable v2 plugin API? + +## Evidence and References + +### Primary current sources + +- [OpenCode 2 beta introduction](https://v2.opencode.ai/) - Beta status, warnings, `@next` installation, and `opencode2`. Retrieved with Firecrawl. +- [Migrate from V1](https://v2.opencode.ai/migrate-v1) - Intentional breaking changes, v1 config translation, plugin incompatibility, side-by-side guidance, and deferred migration details. Retrieved with Firecrawl. +- [OpenCode 2 plugin guide](https://v2.opencode.ai/build/plugins) - Current beta loader, setup model, context, transforms, tool hooks, version matching, and publication guidance. Retrieved with Firecrawl and corroborated with Context7. +- [OpenCode 1 plugin guide](https://opencode.ai/docs/plugins/) - Current stable returned-hooks API, injected client, event list, toast/logging, and install behavior. Retrieved with Firecrawl and Context7. +- [V2 plugin loader source](https://github.com/anomalyco/opencode/blob/v2/packages/core/src/plugin/supervisor.ts) - Default export validation, package loading, ordering, discovery, and reload flow. Retrieved with Firecrawl; verified in the upstream clone. +- [V2 Promise plugin contract](https://github.com/anomalyco/opencode/blob/v2/packages/plugin/src/v2/promise/plugin.ts) - `{ id, setup }`, context domains, and cleanup contract. Verified in the upstream clone; indexed by Context7. +- [V2 tool contract](https://github.com/anomalyco/opencode/blob/v2/packages/plugin/src/v2/effect/tool.ts) - Mutable before/after event fields and hook names. Retrieved with Firecrawl; verified in the upstream clone. +- [V2 session contract](https://github.com/anomalyco/opencode/blob/v2/packages/plugin/src/v2/effect/session.ts) - Session client subset and session hook surface. Verified in the upstream clone. +- [V2 host wiring](https://github.com/anomalyco/opencode/blob/v2/packages/core/src/plugin/host.ts) - Implemented event, tool, session, and location-scoped behavior. Verified in the upstream clone. +- [V2 session event schema](https://github.com/anomalyco/opencode/blob/v2/packages/schema/src/session-event.ts) - Durable execution and synthetic event contracts. Verified in the upstream clone. +- [V2 API design map](https://github.com/anomalyco/opencode/blob/dev/specs/v2/api.html) - Revised client/server operation model and session-pinned context. Retrieved with Firecrawl. +- [V2 core instructions](https://github.com/anomalyco/opencode/blob/dev/specs/v2/instructions.md) - Architectural direction toward typed, domain-oriented, sequential plugin hooks. Retrieved with Firecrawl. + +### Stability and risk sources + +- [PR #37540: restore plugin list diagnostics](https://github.com/anomalyco/opencode/pull/37540) - Reiterates intentional v1 plugin incompatibility and shows same-day beta repair. Retrieved with Firecrawl. +- [Issue #37533: v2 plugin list and loading report](https://github.com/anomalyco/opencode/issues/37533) - Maintainer comment that v1 plugins are incompatible with v2. Retrieved through GitHub CLI after Firecrawl discovery. +- [Issue #35963: isolate invalid plugin tools during reload](https://github.com/anomalyco/opencode/issues/35963) - Open plugin validation, atomic replacement, and location-wide failure risk. Retrieved with Firecrawl. +- [Issue #37429: config directory changes do not trigger reload](https://github.com/anomalyco/opencode/issues/37429) - Open command/agent/plugin reload defect. Retrieved with Firecrawl. +- [Issue #37521: self-update can orphan the service](https://github.com/anomalyco/opencode/issues/37521) - Open background-service hang and recovery risk. Retrieved with Firecrawl. +- [Issue #34832: v2 pre-beta event/schema audit](https://github.com/anomalyco/opencode/issues/34832) - Explicit concern about durable event names and migration-sensitive contracts. Retrieved with Firecrawl. +- [Issue #35016: execution/retry/error event redesign](https://github.com/anomalyco/opencode/issues/35016) - Direction replacing ambiguous status with typed execution lifecycle events. Retrieved with Firecrawl. +- [Issue #33896: v2 plugin skill discovery](https://github.com/anomalyco/opencode/issues/33896) - Evidence that published v2 plugin capabilities are still receiving compatibility fixes. Retrieved with Firecrawl and GitHub CLI. +- [Issue #7641: plugin and v2 SDK type mismatch](https://github.com/anomalyco/opencode/issues/7641) - Historical evidence of nested v1 versus flattened v2 client types. Retrieved through GitHub CLI after Firecrawl discovery. +- [OpenCode releases](https://github.com/anomalyco/opencode/releases) - Stable release status. Retrieved with Firecrawl and GitHub CLI. +- [npm registry: `@opencode-ai/plugin`](https://www.npmjs.com/package/@opencode-ai/plugin) - Stable and prerelease package channels. Queried with npm after Firecrawl discovery. + +## Limitations + +- OpenCode 2 is changing daily; this report is a point-in-time analysis for 2026-07-17. +- Some upstream beta documentation already differs from the current `v2` branch source. +- A V2 adapter, packed package test, and isolated pinned-host E2E loading test are now present and passing. +- No claim here should override a later stable OpenCode 2 migration guide or package contract. + +## Follow-ups + +- [x] Recheck the beta guide, exact published package types, and `v2` source before implementation. +- [ ] Obtain upstream clarification on session hook naming, location access, toast/logging, and synthetic delivery. +- [x] Add packed V2 artifact verification while preserving the V1 regression suite. +- [x] Use `opencode-command-hooks-v2` as the isolated beta package name. +- [ ] Confirm npm ownership before publishing any prerelease. +- [x] Enable the isolated pinned-host E2E loading gate. +- [ ] Expand to a dual-host runtime matrix when the blocking host defects are resolved. diff --git a/README.md b/README.md index d60b03b..841094a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ hooks: - [Features](#features) - [Installation](#installation) +- [OpenCode 2 Beta](#opencode-2-beta) - [Configuration](#configuration) - [Examples](#examples) - [Template Placeholders](#template-placeholders) @@ -170,6 +171,24 @@ Add to your `opencode.json`: } ``` +## OpenCode 2 Beta + +OpenCode 2 uses a different plugin API, so the V1 package above does not load there. The opt-in beta artifact is built separately and pins the exact OpenCode 2 plugin contract it supports. Once the beta package is published, its config is: + +```jsonc +{ + "plugins": ["opencode-command-hooks-v2@0.1.0-beta.0"] +} +``` + +Version `0.1.0-beta.0` targets `@opencode-ai/cli@0.0.0-next-15853`. Keep versions exact while the upstream API is beta, and verify the plugin ID with `opencode2 api get /api/plugin` after the host finishes activating plugins. + +The V2 adapter supports tool before/after hooks, `toolArgs` filters, agent frontmatter hooks, session start/idle hooks, explicit project-directory execution, and injection through synthetic context. Existing V1 config vocabulary remains valid: the V2 `subagent` tool and its `agent` argument are normalized to `task` and `subagent_type` for matching. + +V2 server plugins cannot currently show TUI toasts. When a matched hook requests one, the adapter emits one diagnostic and continues command execution and injection. Synthetic injections use `resume: false` so an idle hook does not create an unsolicited model turn. + +Run the pinned host smoke test with `npm run test:v2:e2e`. Keep the V1 package available for rollback while OpenCode 2 remains beta. + ## Configuration ### Config Locations @@ -372,13 +391,17 @@ Tool-arg matching is exact. This example runs only when the tool arg `path` equa }, { "id": "session-idle", - "when": { "event": "session.idle" }, - "run": ["echo 'Session idle'"], + "when": { "event": "session.idle", "excludeSubagentWait": true }, + "run": ["notify-user.sh 'Waiting for input'"], }, ], } ``` +`excludeSubagentWait` is opt-in. When true, that `session.idle` hook waits until all +active `task` subagent calls for the session have completed. Other idle hooks keep +their existing behavior and still run while the parent is waiting on a subagent. + --- ## Template Placeholders diff --git a/bun.lock b/bun.lock index 22fbbd9..7cd66e6 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@opencode-ai/plugin-v2": "npm:@opencode-ai/plugin@0.0.0-next-15853", "@types/bun": "latest", "@types/js-yaml": "^4.0.5", "eslint": "^9.39.1", @@ -24,6 +25,14 @@ }, }, "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@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/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -50,10 +59,52 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@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=="], + + "@opencode-ai/ai": ["@opencode-ai/ai@0.0.0-next-15853", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-next-15853", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-beta.98", "google-auth-library": "10.5.0" } }, "sha512-JK1haXMl++KxwwbxlzNOZv+s4OjJc1Qftgg5d3SS6x0iAqlLtxyDtsepYvo7C1WN0BjRxCqZQI+6aSBGQ87TCQ=="], + + "@opencode-ai/client": ["@opencode-ai/client@0.0.0-next-15853", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-next-15853", "@opencode-ai/schema": "0.0.0-next-15853" }, "peerDependencies": { "effect": "4.0.0-beta.98" }, "optionalPeers": ["effect"] }, "sha512-jQp4QiMNZlPaqtmNwPRR2Esp4IMlavtMtnhr2WZyYfm41TgM+ziNC8lo9cvd+v1nu5vlGJLJNff+VW80w8zUoA=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.218", "", { "dependencies": { "@opencode-ai/sdk": "1.0.218", "zod": "4.1.8" } }, "sha512-7fsCx2SIu7TRNn3cdHiaIU7r1/XgnVTBufGnlezH+OZwHP+gILBjqtuMn+1L0e1npnl12CvS3FRNvg/3jr6yhA=="], + "@opencode-ai/plugin-v2": ["@opencode-ai/plugin@0.0.0-next-15853", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/ai": "0.0.0-next-15853", "@opencode-ai/client": "0.0.0-next-15853", "@opencode-ai/schema": "0.0.0-next-15853", "@opencode-ai/sdk": "0.0.0-next-15853", "@standard-schema/spec": "^1.1.0", "effect": "4.0.0-beta.98", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-J1e/qQPm7AYj8ftl0rIHKNHAtvE4fkG4Cc8jOjDKzUBTns9Hk8YwXonVC7EAHurnXPkEU5yZublWI6Owrp2sYg=="], + + "@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-next-15853", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-next-15853", "effect": "4.0.0-beta.98" } }, "sha512-NRPuAIFAw6LgT5Z8wxvgYY52Yz14OFMwHJHt9AXYxm/2TOAcd+F3S9VWOoLS3GzO10tFqffJx4HJd/aDrQXVog=="], + + "@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-next-15853", "", { "dependencies": { "effect": "4.0.0-beta.98" } }, "sha512-sw5rJrNgdQL3TIK7kvZG5NCvixbhHNopz3/le3cR5zpM3pC1E9eYuqGFuakEuCX1tGEwyVVHCeE19ar7QH7VSA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.218", "", {}, "sha512-c6ss6UPAMskSVQUecuhNvPLFngyVh2Os9o0kpVjoqJJ16HXhzjVSk5axgh3ueQrfP5aZfg5o6l6srmjuCTPNnQ=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@smithy/core": ["@smithy/core@3.29.5", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ=="], + + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.4.10", "", { "dependencies": { "@smithy/core": "^3.29.5", "tslib": "^2.6.2" } }, "sha512-DcPJ8L79nxi/6QA29F9m+OcIR9/KIXN80i5YMQEVy3fZUUgsE9d5HRj5WVKt9id9XucnnxlSpChg8qNkRApPPg=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.4.10", "", { "dependencies": { "@smithy/core": "^3.29.5", "tslib": "^2.6.2" } }, "sha512-1LrWRBgKOEsMIFVPpAHr1aCj0f+C4hyMynVu4fkWvfvA/CHw6lny74Y6Ep9YJDt4XM837jUDi9pLAV2R5ebGWg=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -88,16 +139,28 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -112,10 +175,22 @@ "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=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "effect": ["effect@4.0.0-beta.98", "", { "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-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], @@ -134,6 +209,10 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "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-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -142,92 +221,170 @@ "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=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "gaxios": ["gaxios@7.2.0", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg=="], + + "gcp-metadata": ["gcp-metadata@8.1.3", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-ziTrzUhhpL9Zk5k0HHzgP/KIpWDJT0VMBC/ynt/QIBvTW+UUcSivQRl6VlwTf/EilDxtSWklHoRsKy1c4k+59w=="], + + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.1.4", "", {}, "sha512-LXxE6ND+JHhDPYtPFt3oeWrjxFPrnRZCZ4At6lpbi1ZDSAN7bmGET9s53vvARbxT93p+xpeDUbc/nCR4C+7vcw=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "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=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="], + + "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=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "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=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "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=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], + "ts-api-utils": ["ts-api-utils@2.3.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -238,24 +395,68 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + + "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=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@aws-crypto/util/@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=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "@opencode-ai/plugin-v2/@opencode-ai/sdk": ["@opencode-ai/sdk@0.0.0-next-15853", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-BkmSNsRGzQyNJkWU85McFABAQe81qfCQ2mwVEJGVcBqCsJx817lh2u+8ElEArWo5jnkCb+DALDJ6612iLjovrA=="], + + "@opencode-ai/plugin-v2/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@aws-crypto/util/@smithy/util-utf8/@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=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index 142a23c..56415c8 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,12 @@ "types" ], "scripts": { - "build": "tsc", + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "build:v2": "bun build src/v2.ts --outfile packages/v2/dist/index.js --target=bun --external @opencode-ai/plugin/v2", + "build:all": "npm run build && npm run build:v2", "test": "bun test", + "test:v2": "bun test tests/v2.plugin.test.ts tests/v2.package.test.ts", + "test:v2:e2e": "OPENCODE2_E2E=1 bun test tests/e2e.v2.test.ts", "lint": "eslint src tests --ext .ts", "typecheck": "tsc --noEmit", "ci:check": "npm run lint && npm run typecheck && (command -v bun >/dev/null 2>&1 && bun test || echo \"bun not found; skipping tests\")", @@ -55,6 +59,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@opencode-ai/plugin-v2": "npm:@opencode-ai/plugin@0.0.0-next-15853", "@types/bun": "latest", "@types/js-yaml": "^4.0.5", "eslint": "^9.39.1", diff --git a/packages/v2/LICENSE b/packages/v2/LICENSE new file mode 100644 index 0000000..18b4ee6 --- /dev/null +++ b/packages/v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Shane Bishop + +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/packages/v2/README.md b/packages/v2/README.md new file mode 100644 index 0000000..ed8bca9 --- /dev/null +++ b/packages/v2/README.md @@ -0,0 +1,17 @@ +# opencode-command-hooks-v2 + +Beta package for OpenCode 2. It targets `@opencode-ai/cli@0.0.0-next-15853` and uses the same `.opencode/command-hooks.jsonc` and agent frontmatter configuration as the V1 package. + +```jsonc +{ + "plugins": ["opencode-command-hooks-v2@0.1.0-beta.0"] +} +``` + +Tool before/after hooks, argument filters, agent hooks, session start/idle hooks, shell execution, and synthetic context injection are supported. Commands run in the session's project directory. + +Set `"excludeSubagentWait": true` on a `session.idle` hook to suppress that hook while the session has active V2 `subagent` tool calls. The option is disabled by default. + +OpenCode 2 server plugins cannot currently show TUI toasts. A toast setting produces one diagnostic while command execution and injection continue. + +Keep the V1 package and configuration available for rollback while OpenCode 2 remains beta. Verify loading with `opencode2 api get /api/plugin`. diff --git a/packages/v2/package.json b/packages/v2/package.json new file mode 100644 index 0000000..b593155 --- /dev/null +++ b/packages/v2/package.json @@ -0,0 +1,40 @@ +{ + "name": "opencode-command-hooks-v2", + "version": "0.1.0-beta.0", + "description": "OpenCode 2 beta adapter for declarative command hooks", + "type": "module", + "main": "dist/index.js", + "exports": "./dist/index.js", + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "scripts": { + "prepack": "npm --prefix ../.. run build:v2" + }, + "keywords": [ + "opencode", + "opencode2", + "plugin", + "hooks", + "shell" + ], + "author": "Shane Bishop <71288697+shanebishop1@users.noreply.github.com>", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/shanebishop1/opencode-command-hooks.git", + "directory": "packages/v2" + }, + "bugs": { + "url": "https://github.com/shanebishop1/opencode-command-hooks/issues" + }, + "homepage": "https://github.com/shanebishop1/opencode-command-hooks#opencode-2-beta", + "dependencies": { + "@opencode-ai/plugin": "0.0.0-next-15853" + }, + "engines": { + "bun": ">=1.0.0" + } +} diff --git a/src/config/agent.ts b/src/config/agent.ts index cd5b041..0b6128e 100644 --- a/src/config/agent.ts +++ b/src/config/agent.ts @@ -62,7 +62,10 @@ const projectDirs = (startDir: string): string[] => { * // Or: null (if neither exists) * ``` */ -export async function resolveAgentPath(agentName: string): Promise { +export async function resolveAgentPath( + agentName: string, + startDir = process.cwd(), +): Promise { // Validate agent name to prevent directory traversal if (!agentName || agentName.includes("/") || agentName.includes("..")) { logger.debug(`Invalid agent name: ${agentName}`); @@ -72,7 +75,7 @@ export async function resolveAgentPath(agentName: string): Promise { +export async function loadAgentConfig( + agentName: string, + startDir = process.cwd(), +): Promise { // Resolve the agent path - const agentPath = await resolveAgentPath(agentName); + const agentPath = await resolveAgentPath(agentName, startDir); if (!agentPath) { logger.debug(`No agent file found for: ${agentName}, returning empty config`); diff --git a/src/config/global.ts b/src/config/global.ts index 2d7bd65..1fcf762 100644 --- a/src/config/global.ts +++ b/src/config/global.ts @@ -15,7 +15,8 @@ import { mergeConfigs } from "./merge.js"; import { join, dirname } from "path"; import { homedir } from "os"; import { stat } from "fs/promises"; -import JsoncParser, { type ParseError } from "jsonc-parser"; +import * as JsoncParser from "jsonc-parser"; +import type { ParseError } from "jsonc-parser"; import { logger } from "../logging.js"; /** @@ -265,12 +266,12 @@ const loadConfigFromPath = async ( * * @returns Promise resolving to GlobalConfigResult */ -export const loadGlobalConfig = async (): Promise => { +export const loadGlobalConfig = async (startDir = process.cwd()): Promise => { try { - logger.debug(`loadGlobalConfig: starting search from: ${process.cwd()}`); + logger.debug(`loadGlobalConfig: starting search from: ${startDir}`); // Step 1: Load project config first (to check ignoreGlobalConfig flag) - const projectConfigPath = await findProjectConfigFile(process.cwd()); + const projectConfigPath = await findProjectConfigFile(startDir); const projectResult = projectConfigPath ? await loadConfigFromPath(projectConfigPath, "project") : { config: emptyConfig(), error: null }; diff --git a/src/execution/shell.ts b/src/execution/shell.ts index 9740f99..030bcb7 100644 --- a/src/execution/shell.ts +++ b/src/execution/shell.ts @@ -48,7 +48,7 @@ const truncateText = (text: string | undefined, limit: number): string => { */ export async function executeCommand( command: string, - options?: { truncateOutput?: number } + options?: { truncateOutput?: number; cwd?: string } ): Promise { const truncateLimit = options?.truncateOutput ?? DEFAULT_TRUNCATE_LIMIT const hookId = "command" // Will be set by caller @@ -60,7 +60,7 @@ export async function executeCommand( try { // Execute command using Bun's $ template literal with nothrow to prevent throwing on non-zero exit // We need to use dynamic template literal evaluation - const result = await executeShellCommand(command) + const result = await executeShellCommand(command, options?.cwd) const stdout = truncateText(result.stdout, truncateLimit) const stderr = truncateText(result.stderr, truncateLimit) @@ -114,7 +114,7 @@ export async function executeCommand( export async function executeCommands( commands: string | string[], hookId: string, - options?: { truncateOutput?: number } + options?: { truncateOutput?: number; cwd?: string } ): Promise { const truncateLimit = options?.truncateOutput ?? DEFAULT_TRUNCATE_LIMIT const commandArray = Array.isArray(commands) ? commands : [commands] @@ -131,7 +131,7 @@ export async function executeCommands( logger.debug(`[${hookId}] Executing: ${command}`) } - const result = await executeShellCommand(command) + const result = await executeShellCommand(command, options?.cwd) const stdout = truncateText(result.stdout, truncateLimit) const stderr = truncateText(result.stderr, truncateLimit) @@ -175,13 +175,16 @@ export async function executeCommands( * @returns Object with stdout, stderr, and exitCode */ const executeShellCommand = async ( - command: string + command: string, + cwd?: string, ): Promise<{ stdout: string; stderr: string; exitCode: number }> => { try { // Use Bun's $ template literal to execute the command // The nothrow() method prevents throwing on non-zero exit codes // The quiet() method suppresses output and returns Buffers - const result = await Bun.$`${{ raw: command }}`.nothrow().quiet() + const shell = Bun.$`${{ raw: command }}` + if (cwd) shell.cwd(cwd) + const result = await shell.nothrow().quiet() // Extract stdout and stderr as text // result.stdout and result.stderr are Buffers, convert to string diff --git a/src/executor.ts b/src/executor.ts index d25d5b7..f9739e6 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -15,7 +15,6 @@ * Errors are logged and optionally injected into the session, but never thrown. */ -import type { OpencodeClient } from "@opencode-ai/sdk" import type { ToolHook, SessionHook, @@ -26,6 +25,19 @@ import { executeCommands } from "./execution/shell.js" import { interpolateTemplate } from "./execution/template.js" import { logger } from "./logging.js" +export interface HookToast { + title?: string + message: string + variant?: "info" | "success" | "warning" | "error" + duration?: number +} + +export interface HookHost { + cwd: string + inject: (sessionId: string, message: string, hookId: string) => Promise + toast: (toast: HookToast) => Promise +} + /** * Check if a value matches a pattern (string, array of strings, or wildcard) */ @@ -41,7 +53,7 @@ export const matches = (pattern: string | string[] | undefined, value: string | */ export const filterSessionHooks = ( hooks: SessionHook[], - criteria: { event: string; agent: string | undefined } + criteria: { event: string; agent: string | undefined; hasActiveSubagents?: boolean } ): SessionHook[] => { return hooks.filter((hook) => { // Normalize session.start to session.created @@ -51,6 +63,11 @@ export const filterSessionHooks = ( } if (normalizedEvent !== criteria.event) return false + if ( + normalizedEvent === "session.idle" && + hook.when.excludeSubagentWait && + criteria.hasActiveSubagents + ) return false if (hook.when.agent && !criteria.agent) { logger.debug( @@ -138,7 +155,7 @@ const formatErrorMessage = (hookId: string, error: unknown): string => { * @returns Promise that resolves when toast is shown */ const showToast = async ( - client: OpencodeClient, + host: HookHost, title: string | undefined, message: string, variant: "info" | "success" | "warning" | "error" = "info", @@ -148,14 +165,7 @@ const showToast = async ( const finalTitle = title || "OpenCode Command Hook" logger.debug(`Showing toast: title="${finalTitle}", message="${message.substring(0, 50)}...", variant="${variant}", duration=${duration}`) - await client.tui.showToast({ - body: { - title: finalTitle, - message, - variant, - duration, - }, - }) + await host.toast({ title: finalTitle, message, variant, duration }) logger.info(`[toast] ${finalTitle}: ${message}`) } catch (error) { @@ -178,19 +188,15 @@ const showToast = async ( * @returns Promise that resolves when message is injected */ const injectMessage = async ( - client: OpencodeClient, + host: HookHost, sessionId: string, - message: string + message: string, + hookId: string, ): Promise => { try { logger.debug(`Injecting message into session ${sessionId}`) - await client.session.promptAsync({ - path: { id: sessionId }, - body: { - parts: [{ type: "text", text: message }], - }, - }) + await host.inject(sessionId, message, hookId) logger.info(`[inject] Message injected into session ${sessionId}: ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}`) } catch (error) { @@ -217,7 +223,7 @@ const injectMessage = async ( const executeHook = async ( hook: ToolHook | SessionHook, context: HookExecutionContext, - client: OpencodeClient, + host: HookHost, truncationLimit?: number ): Promise => { const hookType = isToolHook(hook) ? "tool" : "session" @@ -237,7 +243,7 @@ const executeHook = async ( ? await executeCommands( hook.run, hook.id, - truncationLimit !== undefined ? { truncateOutput: truncationLimit } : undefined + { truncateOutput: truncationLimit, cwd: host.cwd } ) : [] @@ -260,7 +266,7 @@ const executeHook = async ( // If inject is configured, prepare and inject the message if (hook.inject) { const message = interpolateTemplate(hook.inject, templateContext) - await injectMessage(client, context.sessionId, message) + await injectMessage(host, context.sessionId, message, hook.id) } // If toast is configured, interpolate and show toast notification @@ -269,7 +275,7 @@ const executeHook = async ( const toastMessage = interpolateTemplate(hook.toast.message, templateContext) await showToast( - client, + host, toastTitle, toastMessage, hook.toast.variant || "info", @@ -281,7 +287,7 @@ const executeHook = async ( logger.error(errorMessage) try { - await injectMessage(client, context.sessionId, errorMessage) + await injectMessage(host, context.sessionId, errorMessage, hook.id) } catch (injectionError) { const injectionErrorMsg = injectionError instanceof Error @@ -342,7 +348,7 @@ const isToolHook = (hook: ToolHook | SessionHook): hook is ToolHook => { export async function executeHooks( hooks: (ToolHook | SessionHook)[], context: HookExecutionContext, - client: OpencodeClient, + host: HookHost, truncationLimit?: number ): Promise { try { @@ -353,7 +359,7 @@ export async function executeHooks( // Execute each hook for (const hook of hooks) { try { - await executeHook(hook, context, client, truncationLimit) + await executeHook(hook, context, host, truncationLimit) } catch (error) { // Catch errors from individual hook execution and continue // (errors are already logged and injected by the hook execution functions) diff --git a/src/index.ts b/src/index.ts index da99b86..1e7619a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,12 @@ import type { Plugin } from "@opencode-ai/plugin" import type { Config, OpencodeClient } from "@opencode-ai/sdk" import type { CommandHooksConfig, HookExecutionContext } from "./types/hooks.js" import { createLogger, setGlobalLogger, logger } from "./logging.js" -import { executeHooks, filterSessionHooks, filterToolHooks } from "./executor.js" +import { executeHooks, filterSessionHooks, filterToolHooks, type HookHost } from "./executor.js" import { normalizeString } from "./utils.js" import { loadGlobalConfig } from "./config/global.js" import { loadAgentConfig } from "./config/agent.js" import { mergeConfigs } from "./config/merge.js" +import { createActiveSubagentTracker } from "./subagent-tracker.js" const notifyConfigError = async ( configError: string | null, @@ -131,7 +132,9 @@ const handleSessionEvent = async ( eventType: "session.created" | "session.idle", sessionId: string | undefined, agent: string | undefined, - client: OpencodeClient + client: OpencodeClient, + host: HookHost, + hasActiveSubagents: boolean, ): Promise => { if (!sessionId) { logger.debug(`${eventType} event missing session ID`) @@ -143,7 +146,7 @@ const handleSessionEvent = async ( } try { - const { config: globalConfig, error: globalConfigError } = await loadGlobalConfig() + const { config: globalConfig, error: globalConfigError } = await loadGlobalConfig(host.cwd) await notifyConfigError(globalConfigError, sessionId, client) const markdownConfig = { tool: [], session: [] } @@ -152,6 +155,7 @@ const handleSessionEvent = async ( const matchedHooks = filterSessionHooks(mergedConfig.session || [], { event: eventType, agent, + hasActiveSubagents, }) logger.debug( @@ -163,7 +167,7 @@ const handleSessionEvent = async ( agent: agent || "unknown", } - await executeHooks(matchedHooks, context, client, mergedConfig.truncationLimit) + await executeHooks(matchedHooks, context, host, mergedConfig.truncationLimit) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logger.error(`Error handling ${eventType} event: ${errorMessage}`) @@ -177,7 +181,8 @@ const handleToolExecutionHook = async ( phase: "before" | "after", input: { tool: string; sessionID: string; callID?: string }, toolArgs: Record | undefined, - client: OpencodeClient + client: OpencodeClient, + host: HookHost, ): Promise => { if (phase === "before") { storeToolArgs(input.callID, toolArgs) @@ -188,7 +193,7 @@ const handleToolExecutionHook = async ( } try { - const { config: globalConfig, error: globalConfigError } = await loadGlobalConfig() + const { config: globalConfig, error: globalConfigError } = await loadGlobalConfig(host.cwd) await notifyConfigError(globalConfigError, input.sessionID, client) let agentConfig: CommandHooksConfig = { tool: [], session: [] } @@ -198,7 +203,7 @@ const handleToolExecutionHook = async ( subagentType = normalizeString(toolArgs.subagent_type) || undefined if (subagentType) { logger.debug(`Detected task tool call with subagent_type: ${subagentType}`) - agentConfig = await loadAgentConfig(subagentType) + agentConfig = await loadAgentConfig(subagentType, host.cwd) } } @@ -222,7 +227,7 @@ const handleToolExecutionHook = async ( toolArgs, } - await executeHooks(matchedHooks, context, client, mergedConfig.truncationLimit) + await executeHooks(matchedHooks, context, host, mergedConfig.truncationLimit) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logger.error(`Error handling tool.execute.${phase}: ${errorMessage}`) @@ -253,6 +258,22 @@ const handleToolExecutionHook = async ( export const CommandHooksPlugin: Plugin = async ({ client }) => { const clientLogger = createLogger(client) setGlobalLogger(clientLogger) + const typedClient = client as OpencodeClient + const host: HookHost = { + cwd: process.cwd(), + inject: async (sessionId, message) => { + await typedClient.session.promptAsync({ + path: { id: sessionId }, + body: { parts: [{ type: "text", text: message }] }, + }) + }, + toast: async ({ title, message, variant, duration }) => { + await typedClient.tui.showToast({ + body: { title, message, variant: variant ?? "info", duration }, + }) + }, + } + const activeSubagents = createActiveSubagentTracker() try { logger.info("Initializing OpenCode Command Hooks plugin...") @@ -295,17 +316,37 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => { const sessionId = info?.id ? normalizeString(info.id) : undefined const agent = normalizeString(event.properties?.agent) - await handleSessionEvent("session.created", sessionId, agent, client as OpencodeClient) - } - - // Handle session.idle event + await handleSessionEvent( + "session.created", + sessionId, + agent, + typedClient, + host, + sessionId ? activeSubagents.hasActive(sessionId) : false, + ) + } + + if (event.type === "session.deleted") { + const info = event.properties?.info as { id?: string } | undefined + const sessionId = info?.id ? normalizeString(info.id) : undefined + if (sessionId) activeSubagents.clear(sessionId) + } + + // Handle session.idle event if (event.type === "session.idle") { logger.debug("Received session.idle event") const sessionId = normalizeString(event.properties?.sessionID) const agent = normalizeString(event.properties?.agent) - await handleSessionEvent("session.idle", sessionId, agent, client as OpencodeClient) + await handleSessionEvent( + "session.idle", + sessionId, + agent, + typedClient, + host, + sessionId ? activeSubagents.hasActive(sessionId) : false, + ) } // Backward-compat fallback for older OpenCode event streams. @@ -323,14 +364,16 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => { event.properties?.callID ?? event.properties?.callId ) - if (!sessionId || !toolName) { + if (!sessionId || !toolName) { logger.debug( "tool.result event missing sessionID or tool name" ) - return - } + return + } + + if (toolName === "task") activeSubagents.end(sessionId, callId) - if (wasAfterHookProcessed(callId)) { + if (wasAfterHookProcessed(callId)) { logger.debug(`Skipping duplicate after-hook execution for callID: ${callId}`) deleteToolArgs(callId) return @@ -341,7 +384,8 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => { "after", { tool: toolName, sessionID: sessionId, callID: callId }, storedToolArgs, - client as OpencodeClient, + typedClient, + host, ) } }, @@ -361,7 +405,9 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => { `Tool args: ${JSON.stringify(output.args)}` ) - await handleToolExecutionHook("before", input, output.args, client as OpencodeClient) + if (input.tool === "task") activeSubagents.begin(input.sessionID, input.callID) + + await handleToolExecutionHook("before", input, output.args, typedClient, host) }, /** @@ -376,14 +422,16 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => { `Received tool.execute.after for tool: ${input.tool}` ) - if (!toolOutput) { + if (!toolOutput) { logger.debug( `tool.execute.after for ${input.tool} has no output payload; running hooks with cached args only` - ) - } + ) + } + + if (input.tool === "task") activeSubagents.end(input.sessionID, input.callID) - const storedToolArgs = getToolArgs(input.callID) - await handleToolExecutionHook("after", input, storedToolArgs, client as OpencodeClient) + const storedToolArgs = getToolArgs(input.callID) + await handleToolExecutionHook("after", input, storedToolArgs, typedClient, host) }, } diff --git a/src/schemas.ts b/src/schemas.ts index 65959b3..82542bc 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -97,6 +97,7 @@ export const ToolHookSchema = z.object({ const SessionHookWhenSchema = z.object({ event: SessionEventSchema, agent: StringOrArray.optional(), + excludeSubagentWait: z.boolean().optional(), }); /** diff --git a/src/subagent-tracker.ts b/src/subagent-tracker.ts new file mode 100644 index 0000000..6400239 --- /dev/null +++ b/src/subagent-tracker.ts @@ -0,0 +1,45 @@ +export type ActiveSubagentTracker = { + begin: (sessionId: string, callId?: string) => void + end: (sessionId: string, callId?: string) => void + hasActive: (sessionId: string) => boolean + clear: (sessionId: string) => void +} + +type ActiveCalls = { + ids: Set + anonymous: number +} + +export const createActiveSubagentTracker = (): ActiveSubagentTracker => { + const sessions = new Map() + + const state = (sessionId: string): ActiveCalls => { + const existing = sessions.get(sessionId) + if (existing) return existing + const created = { ids: new Set(), anonymous: 0 } + sessions.set(sessionId, created) + return created + } + + return { + begin: (sessionId, callId) => { + const active = state(sessionId) + if (callId) active.ids.add(callId) + else active.anonymous += 1 + }, + end: (sessionId, callId) => { + const active = sessions.get(sessionId) + if (!active) return + if (callId) active.ids.delete(callId) + else active.anonymous = Math.max(0, active.anonymous - 1) + if (active.ids.size === 0 && active.anonymous === 0) sessions.delete(sessionId) + }, + hasActive: sessionId => { + const active = sessions.get(sessionId) + return active !== undefined && (active.ids.size > 0 || active.anonymous > 0) + }, + clear: sessionId => { + sessions.delete(sessionId) + }, + } +} diff --git a/src/types/hooks.ts b/src/types/hooks.ts index ee51200..2050a2c 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -182,6 +182,9 @@ export interface SessionHookWhen { * Omitted: defaults to "*" in global config, "this agent" in markdown. */ agent?: string | string[] + + /** Skip this idle hook while the session has active subagent tool calls. */ + excludeSubagentWait?: boolean } /** diff --git a/src/v2.ts b/src/v2.ts new file mode 100644 index 0000000..adbe61e --- /dev/null +++ b/src/v2.ts @@ -0,0 +1,4 @@ +import { Plugin } from "@opencode-ai/plugin/v2" +import { createV2Plugin } from "./v2/plugin.js" + +export default Plugin.define(createV2Plugin()) diff --git a/src/v2/plugin.ts b/src/v2/plugin.ts new file mode 100644 index 0000000..912aaf5 --- /dev/null +++ b/src/v2/plugin.ts @@ -0,0 +1,274 @@ +import type { CommandHooksConfig, HookExecutionContext } from "../types/hooks.js" +import { loadGlobalConfig } from "../config/global.js" +import { loadAgentConfig } from "../config/agent.js" +import { mergeConfigs } from "../config/merge.js" +import { executeHooks, filterSessionHooks, filterToolHooks, type HookHost } from "../executor.js" +import { normalizeString } from "../utils.js" +import { createActiveSubagentTracker } from "../subagent-tracker.js" + +type V2ToolEvent = { + tool: string + sessionID: string + callID?: string + agent?: string + input: unknown +} + +type V2Event = { + id?: string + type: string + data?: Record + location?: { directory?: string; workspaceID?: string } +} + +type V2SessionInfo = { + id: string + agent?: string + location: { directory: string; workspaceID?: string } +} + +export interface V2Context { + tool: { + hook: ( + name: "execute.before" | "execute.after", + callback: (event: V2ToolEvent) => Promise | void, + ) => Promise<{ dispose: () => Promise }> + } + event: { + subscribe: () => AsyncIterable + } + session: { + get: (input: { sessionID: string }) => Promise + synthetic: (input: { + sessionID: string + text: string + description?: string + metadata?: Record + resume?: boolean + }) => Promise + } +} + +export interface V2Plugin { + id: string + setup: (context: V2Context) => Promise<(() => Promise) | void> +} + +const emptyConfig = (): CommandHooksConfig => ({ tool: [], session: [] }) + +const asRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined + +const toolContext = (event: V2ToolEvent): { + toolName: string + toolArgs: Record | undefined + callingAgent: string | undefined + agentConfigName: string | undefined +} => { + const originalArgs = asRecord(event.input) + if (event.tool !== "subagent") { + return { + toolName: event.tool, + toolArgs: originalArgs, + callingAgent: normalizeString(event.agent) || undefined, + agentConfigName: undefined, + } + } + + const agent = normalizeString(originalArgs?.agent) || undefined + return { + // Keep the public command-hooks vocabulary compatible with V1. + toolName: "task", + toolArgs: originalArgs ? { ...originalArgs, subagent_type: agent } : originalArgs, + callingAgent: agent, + agentConfigName: agent, + } +} + +const diagnostic = (message: string, error?: unknown): void => { + const detail = error instanceof Error ? error.message : error === undefined ? "" : String(error) + console.error(`[opencode-command-hooks-v2] ${message}${detail ? `: ${detail}` : ""}`) +} + +export const createV2Plugin = (): V2Plugin => ({ + id: "opencode-command-hooks.v2", + setup: async (ctx) => { + const notifiedConfigErrors = new Set() + const handledEvents = new Set() + const startedSessions = new Set() + const activeSubagents = createActiveSubagentTracker() + let warnedToastUnsupported = false + let stopped = false + + const notifyConfigError = (error: string | null, directory: string): void => { + if (!error) return + const key = `${directory}:${error}` + if (notifiedConfigErrors.has(key)) return + notifiedConfigErrors.add(key) + diagnostic(error) + } + + const createHost = (directory: string): HookHost => ({ + cwd: directory, + inject: async (sessionId, message, hookId) => { + await ctx.session.synthetic({ + sessionID: sessionId, + text: message, + description: "opencode-command-hooks", + metadata: { hookId }, + resume: false, + }) + }, + toast: async () => { + if (warnedToastUnsupported) return + warnedToastUnsupported = true + console.warn( + "[opencode-command-hooks-v2] Toast notifications are not available to V2 server plugins; hook execution and injection will continue.", + ) + }, + }) + + const sessionInfo = async ( + sessionID: string, + fallback?: { directory?: string; agent?: string }, + ): Promise<{ directory: string; agent: string | undefined }> => { + try { + const session = await ctx.session.get({ sessionID }) + return { + directory: session.location.directory, + agent: normalizeString(session.agent) || normalizeString(fallback?.agent) || undefined, + } + } catch (error) { + if (fallback?.directory) { + return { + directory: fallback.directory, + agent: normalizeString(fallback.agent) || undefined, + } + } + throw error + } + } + + const handleTool = async (phase: "before" | "after", event: V2ToolEvent): Promise => { + const normalized = toolContext(event) + if (normalized.toolName === "task") { + if (phase === "before") activeSubagents.begin(event.sessionID, event.callID) + else activeSubagents.end(event.sessionID, event.callID) + } + + try { + const resolved = await sessionInfo(event.sessionID, { agent: event.agent }) + const { config: globalConfig, error } = await loadGlobalConfig(resolved.directory) + notifyConfigError(error, resolved.directory) + + let agentConfig = emptyConfig() + if (normalized.agentConfigName) { + agentConfig = await loadAgentConfig(normalized.agentConfigName, resolved.directory) + } + + const { config } = mergeConfigs(globalConfig, agentConfig) + const hooks = filterToolHooks(config.tool ?? [], { + phase, + toolName: normalized.toolName, + callingAgent: normalized.callingAgent, + slashCommand: undefined, + toolArgs: normalized.toolArgs, + }) + const context: HookExecutionContext = { + sessionId: event.sessionID, + agent: normalized.callingAgent ?? resolved.agent ?? "unknown", + tool: normalized.toolName, + callId: event.callID, + toolArgs: normalized.toolArgs, + } + await executeHooks(hooks, context, createHost(resolved.directory), config.truncationLimit) + } catch (error) { + diagnostic(`Failed to handle tool.execute.${phase}`, error) + } + } + + const handleSession = async (event: V2Event): Promise => { + if (event.type === "session.deleted") { + const sessionID = normalizeString(event.data?.sessionID) + if (sessionID) activeSubagents.clear(sessionID) + return + } + + const eventType = event.type === "session.created" || event.type === "session.execution.started" + ? "session.created" + : event.type === "session.idle" || + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ? "session.idle" + : undefined + if (!eventType) return + if (event.id && handledEvents.has(event.id)) return + if (event.id) { + handledEvents.add(event.id) + if (handledEvents.size > 1000) { + const oldest = handledEvents.values().next().value + if (oldest) handledEvents.delete(oldest) + } + } + + const sessionID = normalizeString(event.data?.sessionID) + if (!sessionID) return + if (eventType === "session.created") { + if (startedSessions.has(sessionID)) return + startedSessions.add(sessionID) + } + + try { + const resolved = await sessionInfo(sessionID, { + directory: normalizeString(event.location?.directory) || undefined, + agent: normalizeString(event.data?.agent) || undefined, + }) + const { config, error } = await loadGlobalConfig(resolved.directory) + notifyConfigError(error, resolved.directory) + const hooks = filterSessionHooks(config.session ?? [], { + event: eventType, + agent: resolved.agent, + hasActiveSubagents: activeSubagents.hasActive(sessionID), + }) + await executeHooks( + hooks, + { sessionId: sessionID, agent: resolved.agent ?? "unknown" }, + createHost(resolved.directory), + config.truncationLimit, + ) + } catch (error) { + diagnostic(`Failed to handle ${event.type}`, error) + } + } + + const registrations = [ + await ctx.tool.hook("execute.before", event => handleTool("before", event)), + await ctx.tool.hook("execute.after", event => handleTool("after", event)), + ] + + let iterator: AsyncIterator | undefined + const eventTask = (async () => { + try { + iterator = ctx.event.subscribe()[Symbol.asyncIterator]() + while (!stopped) { + const next = await iterator.next() + if (next.done) break + await handleSession(next.value) + } + } catch (error) { + if (!stopped) diagnostic("Event subscription failed", error) + } + })() + + return async () => { + stopped = true + const disposed = Promise.allSettled(registrations.map(registration => registration.dispose())) + await iterator?.return?.() + await eventTask + await disposed + } + }, +}) diff --git a/tests/agent.config.test.ts b/tests/agent.config.test.ts index 89b7c78..c112998 100644 --- a/tests/agent.config.test.ts +++ b/tests/agent.config.test.ts @@ -76,6 +76,15 @@ describe("Agent Configuration", () => { } }); + it("should resolve from an explicit project directory", async () => { + const agentPath = join(testProjectDir, ".opencode", "agents", "located-agent.md"); + await writeFile(agentPath, "---\ndescription: Located agent\n---\n# Located agent content"); + + const result = await resolveAgentPath("located-agent", testProjectDir); + + expect(realpathSync(result!)).toBe(realpathSync(agentPath)); + }); + it("should prioritize project-level plural over project-level singular", async () => { const pluralPath = join(testProjectDir, ".opencode", "agents", "project-priority.md"); const singularPath = join(testProjectDir, ".opencode", "agent", "project-priority.md"); diff --git a/tests/config.global.test.ts b/tests/config.global.test.ts index 8c8bd04..7301d1b 100644 --- a/tests/config.global.test.ts +++ b/tests/config.global.test.ts @@ -243,6 +243,23 @@ describe("Global Configuration", () => { }); describe("Project config discovery", () => { + it("uses an explicit project directory without changing process cwd", async () => { + await writeProjectConfig({ + tool: [ + { + id: "explicit-directory-hook", + when: { phase: "before", tool: "bash" }, + run: "pwd", + }, + ], + }); + + const result = await loadGlobalConfig(testProjectDir); + + expect(process.cwd()).toBe(originalCwd); + expect(result.config.tool?.map(hook => hook.id)).toEqual(["explicit-directory-hook"]); + }); + it("should find project config in parent directory", async () => { const nestedDir = join(testProjectDir, "src", "components"); await mkdir(nestedDir, { recursive: true }); diff --git a/tests/e2e.v2.test.ts b/tests/e2e.v2.test.ts new file mode 100644 index 0000000..908f320 --- /dev/null +++ b/tests/e2e.v2.test.ts @@ -0,0 +1,157 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test" +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" +import { join, resolve } from "path" +import { $ } from "bun" + +const enabled = process.env.OPENCODE2_E2E === "1" +const cliVersion = "0.0.0-next-15853" +let projectDirectory = "" + +describe("OpenCode V2 package E2E", () => { + beforeAll(async () => { + if (!enabled) return + + projectDirectory = await mkdtemp(join(tmpdir(), "opencode-hooks-v2-e2e-")) + await $`git init -q`.cwd(projectDirectory) + await $`npm run build:v2`.quiet() + await $`npm pack --ignore-scripts --pack-destination ${projectDirectory}`.cwd("packages/v2").quiet() + await writeFile( + join(projectDirectory, "package.json"), + JSON.stringify({ private: true, type: "module" }), + ) + await $`npm install ${join(projectDirectory, "opencode-command-hooks-v2-0.1.0-beta.0.tgz")} @opencode-ai/cli@${cliVersion}` + .cwd(projectDirectory) + .quiet() + await mkdir(join(projectDirectory, ".opencode"), { recursive: true }) + await writeFile( + join(projectDirectory, "opencode.jsonc"), + JSON.stringify({ + plugins: [join(projectDirectory, "node_modules", "opencode-command-hooks-v2", "dist", "index.js")], + }), + ) + await writeFile( + join(projectDirectory, ".opencode", "command-hooks.jsonc"), + JSON.stringify({ tool: [], session: [] }), + ) + }, 120_000) + + afterAll(async () => { + if (projectDirectory) { + await rm(projectDirectory, { recursive: true, force: true }) + } + }) + + it("loads the packed plugin in the pinned OpenCode 2 host", async () => { + if (!enabled) { + console.log("Skipping V2 E2E: set OPENCODE2_E2E=1 to run") + return + } + + const binary = resolve(projectDirectory, "node_modules", ".bin", "opencode2") + const config = join(projectDirectory, "opencode.jsonc") + const home = join(projectDirectory, "home") + await mkdir(home, { recursive: true }) + const environment = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + XDG_DATA_HOME: join(home, ".local", "share"), + XDG_CACHE_HOME: join(home, ".cache"), + OPENCODE_CONFIG: config, + OPENCODE_LOG_LEVEL: "trace", + OPENCODE_PASSWORD: "v2-e2e-password", + OPENCODE_SERVER_PASSWORD: "v2-e2e-password", + } + const server = Bun.spawn([ + binary, + "serve", + "--hostname", + "127.0.0.1", + "--port", + "0", + ], { + cwd: projectDirectory, + env: environment, + stdout: "pipe", + stderr: "pipe", + }) + let serverOutput = "" + let baseUrl = "" + const serverStdout = (async () => { + const reader = server.stdout.getReader() + const decoder = new TextDecoder() + while (true) { + const { done, value } = await reader.read() + if (done) break + serverOutput += decoder.decode(value, { stream: true }) + baseUrl ||= serverOutput.match(/server listening on (http:\/\/\S+)/)?.[1] ?? "" + } + serverOutput += decoder.decode() + })() + const serverStderr = new Response(server.stderr).text() + const stopServer = async (): Promise => { + if (server.exitCode === null) server.kill(9) + await Promise.race([ + server.exited.then(() => undefined), + new Promise(resolve => setTimeout(resolve, 5_000)), + ]) + } + const headers = { + "x-opencode-directory": projectDirectory, + authorization: `Basic ${Buffer.from("opencode:v2-e2e-password").toString("base64")}`, + } + const logDirectory = join(home, ".local", "share", "opencode", "log") + const readLogs = async (): Promise => { + try { + const files = await readdir(logDirectory) + return (await Promise.all(files.map(file => readFile(join(logDirectory, file), "utf8")))).join("\n") + } catch { + return "" + } + } + let pluginResponse = "" + + try { + const serverDeadline = Date.now() + 20_000 + while (!baseUrl && server.exitCode === null && Date.now() < serverDeadline) { + await new Promise(resolve => setTimeout(resolve, 25)) + } + if (!baseUrl) { + await stopServer() + await serverStdout + throw new Error( + `V2 server did not become ready (exit=${server.exitCode})\nstdout:\n${serverOutput}\nstderr:\n${await serverStderr}`, + ) + } + + const pluginDeadline = Date.now() + 30_000 + while (Date.now() < pluginDeadline) { + try { + const response = await fetch(`${baseUrl}/api/plugin`, { headers }) + pluginResponse = await response.text() + if (response.ok && pluginResponse.includes("opencode-command-hooks.v2")) break + } catch { + // The server is still starting. + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + + if (!pluginResponse.includes("opencode-command-hooks.v2")) { + await stopServer() + await serverStdout + throw new Error( + `V2 plugin did not load (server exit=${server.exitCode}): ${pluginResponse}\nstdout:\n${serverOutput}\nstderr:\n${await serverStderr}\n${await readLogs()}`, + ) + } + + } finally { + await stopServer() + await serverStdout + } + + const logs = await readLogs() + + expect(pluginResponse, logs).toContain("opencode-command-hooks.v2") + }, 90_000) +}) diff --git a/tests/execution.shell.test.ts b/tests/execution.shell.test.ts index a0b50b3..cc9b0d3 100644 --- a/tests/execution.shell.test.ts +++ b/tests/execution.shell.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect } from "bun:test" +import { mkdtemp, rm } from "fs/promises" +import { tmpdir } from "os" +import { join } from "path" import { executeCommand, executeCommands } from "../src/execution/shell.js" describe("Shell command execution", () => { @@ -64,6 +67,19 @@ describe("Shell command execution", () => { expect(result.success).toBe(true) expect(result.stdout).toContain("hello") }) + + it("executes in an explicit working directory", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-hooks-cwd-")) + + try { + const result = await executeCommand("pwd", { cwd: directory }) + + expect(result.success).toBe(true) + expect(result.stdout?.trim()).toBe(directory) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) }) describe("executeCommands", () => { diff --git a/tests/executor.host.test.ts b/tests/executor.host.test.ts new file mode 100644 index 0000000..818316c --- /dev/null +++ b/tests/executor.host.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test" +import { executeHooks, type HookHost } from "../src/executor.js" +import type { ToolHook } from "../src/types/hooks.js" + +describe("host-neutral hook execution", () => { + it("delegates injection and toast delivery to the host", async () => { + const injections: Array<{ sessionId: string; message: string; hookId: string }> = [] + const toasts: Array<{ title?: string; message: string }> = [] + const host: HookHost = { + cwd: process.cwd(), + inject: async (sessionId, message, hookId) => { + injections.push({ sessionId, message, hookId }) + }, + toast: async (toast) => { + toasts.push(toast) + }, + } + const hook: ToolHook = { + id: "host-port", + when: { phase: "after", tool: "bash" }, + inject: "hook {id} for {tool}", + toast: { title: "Hook", message: "finished {id}" }, + } + + await executeHooks( + [hook], + { sessionId: "session-1", agent: "build", tool: "bash" }, + host, + ) + + expect(injections).toEqual([ + { sessionId: "session-1", message: "hook host-port for bash", hookId: "host-port" }, + ]) + expect(toasts).toEqual([ + { title: "Hook", message: "finished host-port", variant: "info", duration: undefined }, + ]) + }) +}) diff --git a/tests/session.subagent-wait.test.ts b/tests/session.subagent-wait.test.ts new file mode 100644 index 0000000..3bd34e7 --- /dev/null +++ b/tests/session.subagent-wait.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import { parseSessionHook } from "../src/schemas" + +const originalCwd = process.cwd() + +describe("session idle subagent waits", () => { + let directory: string + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "opencode-hooks-subagent-wait-")) + mkdirSync(join(directory, ".opencode"), { recursive: true }) + writeFileSync( + join(directory, ".opencode", "command-hooks.jsonc"), + JSON.stringify({ + session: [ + { id: "always", when: { event: "session.idle" }, inject: "always" }, + { + id: "after-subagents", + when: { event: "session.idle", excludeSubagentWait: true }, + inject: "after-subagents", + }, + ], + }), + ) + process.chdir(directory) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(directory, { recursive: true, force: true }) + }) + + it("accepts excludeSubagentWait as an opt-in session hook condition", () => { + const hook = parseSessionHook({ + id: "notify", + when: { event: "session.idle", excludeSubagentWait: true }, + inject: "notify", + }) + + expect(hook?.when.excludeSubagentWait).toBe(true) + }) + + it("suppresses only opted-in idle hooks until every task call finishes", async () => { + const injected: string[] = [] + const client = { + session: { + promptAsync: async ({ body }: { body: { parts: Array<{ text: string }> } }) => { + injected.push(body.parts[0].text) + return {} + }, + }, + tui: { showToast: async () => ({}) }, + } + const { CommandHooksPlugin } = await import("../src/index.js") + const plugin = await CommandHooksPlugin({ client } as never) + const idle = () => plugin.event?.({ + event: { type: "session.idle", properties: { sessionID: "parent" } }, + } as never) + + await plugin["tool.execute.before"]?.( + { tool: "task", sessionID: "parent", callID: "task-1" }, + { args: { subagent_type: "worker" } }, + ) + await plugin["tool.execute.before"]?.( + { tool: "task", sessionID: "parent", callID: "task-2" }, + { args: { subagent_type: "worker" } }, + ) + await idle() + + await plugin["tool.execute.after"]?.( + { tool: "task", sessionID: "parent", callID: "task-1" }, + undefined as never, + ) + await idle() + + await plugin["tool.execute.after"]?.( + { tool: "task", sessionID: "parent", callID: "task-2" }, + undefined as never, + ) + await idle() + + expect(injected).toEqual(["always", "always", "always", "after-subagents"]) + }) + + it("clears active subagent state when the session is deleted", async () => { + const injected: string[] = [] + const client = { + session: { + promptAsync: async ({ body }: { body: { parts: Array<{ text: string }> } }) => { + injected.push(body.parts[0].text) + return {} + }, + }, + tui: { showToast: async () => ({}) }, + } + const { CommandHooksPlugin } = await import("../src/index.js") + const plugin = await CommandHooksPlugin({ client } as never) + + await plugin["tool.execute.before"]?.( + { tool: "task", sessionID: "deleted", callID: "task-deleted" }, + { args: { subagent_type: "worker" } }, + ) + await plugin.event?.({ + event: { type: "session.deleted", properties: { info: { id: "deleted" } } }, + } as never) + await plugin.event?.({ + event: { type: "session.idle", properties: { sessionID: "deleted" } }, + } as never) + + expect(injected).toEqual(["always", "after-subagents"]) + }) +}) diff --git a/tests/v2.package.test.ts b/tests/v2.package.test.ts new file mode 100644 index 0000000..460d3d8 --- /dev/null +++ b/tests/v2.package.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test" +import { $ } from "bun" + +describe("V2 package artifact", () => { + it("builds and packs the isolated beta package with exact host dependencies", async () => { + await $`npm run build:v2`.quiet() + const output = await $`npm pack --dry-run --json --ignore-scripts`.cwd("packages/v2").text() + const [pack] = JSON.parse(output) as Array<{ + id: string + files: Array<{ path: string }> + }> + const manifest = await Bun.file("packages/v2/package.json").json() as { + dependencies: Record + } + + expect(pack.id).toStartWith("opencode-command-hooks-v2@0.1.0-beta.") + expect(pack.files.map(file => file.path)).toContain("dist/index.js") + expect(manifest.dependencies["@opencode-ai/plugin"]).toBe("0.0.0-next-15853") + }) +}) diff --git a/tests/v2.plugin.test.ts b/tests/v2.plugin.test.ts new file mode 100644 index 0000000..6fc3c83 --- /dev/null +++ b/tests/v2.plugin.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" +import { join } from "path" +import { createV2Plugin, type V2Context } from "../src/v2/plugin.js" + +type ToolCallback = (event: Record) => Promise | void + +const temporaryDirectories: string[] = [] + +const createProject = async (config: Record): Promise => { + const directory = await mkdtemp(join(tmpdir(), "opencode-hooks-v2-")) + temporaryDirectories.push(directory) + await mkdir(join(directory, ".opencode"), { recursive: true }) + await writeFile( + join(directory, ".opencode", "command-hooks.jsonc"), + JSON.stringify(config), + ) + return directory +} + +const createContext = ( + directory: string, + events: Record[] = [], +) => { + const callbacks = new Map() + const disposed: string[] = [] + const syntheticCalls: Array> = [] + const context: V2Context = { + tool: { + hook: async (name, callback) => { + callbacks.set(name, callback as ToolCallback) + return { dispose: async () => { disposed.push(name) } } + }, + }, + event: { + subscribe: () => (async function* () { + for (const event of events) yield event + })(), + }, + session: { + get: async ({ sessionID }) => ({ + id: sessionID, + agent: "build", + location: { directory }, + }), + synthetic: async (input) => { + syntheticCalls.push(input) + }, + }, + } + + return { context, callbacks, disposed, syntheticCalls } +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true })), + ) +}) + +describe("OpenCode V2 plugin", () => { + it("registers both tool phases and uses after-event input without a V1 cache", async () => { + const directory = await createProject({ + tool: [ + { + id: "v2-after", + when: { phase: "after", tool: "bash", toolArgs: { command: "pwd" } }, + run: "pwd", + inject: "cwd={stdout}", + }, + ], + }) + const { context, callbacks, disposed, syntheticCalls } = createContext(directory) + const plugin = createV2Plugin() + + const cleanup = await plugin.setup(context) + + expect(plugin.id).toBe("opencode-command-hooks.v2") + expect([...callbacks.keys()]).toEqual(["execute.before", "execute.after"]) + + await callbacks.get("execute.after")?.({ + tool: "bash", + sessionID: "session-1", + callID: "call-1", + agent: "build", + input: { command: "pwd" }, + result: {}, + }) + + expect(syntheticCalls).toHaveLength(1) + expect(syntheticCalls[0]).toMatchObject({ + sessionID: "session-1", + text: `cwd=${directory}\n`, + description: "opencode-command-hooks", + metadata: { hookId: "v2-after" }, + resume: false, + }) + + await cleanup?.() + expect(disposed).toEqual(["execute.before", "execute.after"]) + }) + + it("maps created and idle events once and keeps callback failures non-blocking", async () => { + const directory = await createProject({ + session: [ + { id: "start", when: { event: "session.start" }, inject: "started" }, + { id: "idle", when: { event: "session.idle" }, inject: "idle" }, + ], + }) + const events = [ + { + id: "event-created", + type: "session.created", + location: { directory }, + data: { sessionID: "session-2" }, + }, + { + id: "event-idle", + type: "session.idle", + location: { directory }, + data: { sessionID: "session-2" }, + }, + { + id: "event-idle", + type: "session.idle", + location: { directory }, + data: { sessionID: "session-2" }, + }, + ] + const { context, syntheticCalls } = createContext(directory, events) + const cleanup = await createV2Plugin().setup(context) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(syntheticCalls.map(call => call.text)).toEqual(["started", "idle"]) + expect(syntheticCalls.every(call => call.resume === false)).toBe(true) + await cleanup?.() + + const failing = createContext(directory) + failing.context.session.get = async () => { + throw new Error("session lookup failed") + } + const errorSpy = spyOn(console, "error").mockImplementation(() => {}) + const failingCleanup = await createV2Plugin().setup(failing.context) + + await expect( + failing.callbacks.get("execute.before")?.({ + tool: "bash", + sessionID: "missing", + callID: "call-2", + agent: "build", + input: {}, + }), + ).resolves.toBeUndefined() + expect(errorSpy).toHaveBeenCalled() + + errorSpy.mockRestore() + await failingCleanup?.() + }) + + it("maps durable execution lifecycle events to start and idle hooks", async () => { + const directory = await createProject({ + session: [ + { id: "durable-start", when: { event: "session.start" }, inject: "durable started" }, + { id: "durable-idle", when: { event: "session.idle" }, inject: "durable idle" }, + ], + }) + const events = [ + { + id: "execution-started", + type: "session.execution.started", + location: { directory }, + data: { sessionID: "session-durable" }, + }, + { + id: "execution-succeeded", + type: "session.execution.succeeded", + location: { directory }, + data: { sessionID: "session-durable" }, + }, + ] + const { context, syntheticCalls } = createContext(directory, events) + const cleanup = await createV2Plugin().setup(context) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(syntheticCalls.map(call => call.text)).toEqual(["durable started", "durable idle"]) + await cleanup?.() + }) + + it("suppresses opted-in idle hooks while a V2 subagent tool is active", async () => { + const directory = await createProject({ + session: [ + { id: "always", when: { event: "session.idle" }, inject: "always" }, + { + id: "after-subagents", + when: { event: "session.idle", excludeSubagentWait: true }, + inject: "after-subagents", + }, + ], + }) + const { context, callbacks, syntheticCalls } = createContext(directory) + let releaseFirst!: () => void + let releaseSecond!: () => void + let waitingForSecond!: () => void + let eventsFinished!: () => void + const first = new Promise(resolve => { releaseFirst = resolve }) + const second = new Promise(resolve => { releaseSecond = resolve }) + const firstProcessed = new Promise(resolve => { waitingForSecond = resolve }) + const complete = new Promise(resolve => { eventsFinished = resolve }) + context.event.subscribe = () => (async function* () { + await first + yield { + id: "idle-active", + type: "session.idle", + location: { directory }, + data: { sessionID: "parent" }, + } + waitingForSecond() + await second + yield { + id: "idle-complete", + type: "session.idle", + location: { directory }, + data: { sessionID: "parent" }, + } + eventsFinished() + })() + + const cleanup = await createV2Plugin().setup(context) + const subagent = { + tool: "subagent", + sessionID: "parent", + agent: "build", + input: { agent: "worker" }, + } + await callbacks.get("execute.before")?.(subagent) + releaseFirst() + await firstProcessed + + expect(syntheticCalls.map(call => call.text)).toEqual(["always"]) + + await callbacks.get("execute.after")?.(subagent) + releaseSecond() + await complete + + expect(syntheticCalls.map(call => call.text)).toEqual([ + "always", + "always", + "after-subagents", + ]) + await cleanup?.() + }) + + it("clears active V2 subagent state when the session is deleted", async () => { + const directory = await createProject({ + session: [ + { + id: "after-subagents", + when: { event: "session.idle", excludeSubagentWait: true }, + inject: "after-subagents", + }, + ], + }) + const { context, callbacks, syntheticCalls } = createContext(directory) + let releaseEvents!: () => void + let eventsFinished!: () => void + const release = new Promise(resolve => { releaseEvents = resolve }) + const complete = new Promise(resolve => { eventsFinished = resolve }) + context.event.subscribe = () => (async function* () { + await release + yield { id: "deleted", type: "session.deleted", data: { sessionID: "parent" } } + yield { + id: "idle-after-delete", + type: "session.idle", + location: { directory }, + data: { sessionID: "parent" }, + } + eventsFinished() + })() + + const cleanup = await createV2Plugin().setup(context) + await callbacks.get("execute.before")?.({ + tool: "subagent", + sessionID: "parent", + agent: "build", + input: { agent: "worker" }, + }) + releaseEvents() + await complete + + expect(syntheticCalls.map(call => call.text)).toEqual(["after-subagents"]) + await cleanup?.() + }) + + it("reports V2 toast degradation once without blocking injection", async () => { + const directory = await createProject({ + tool: [ + { + id: "toast-gap", + when: { phase: "before", tool: "bash" }, + inject: "still injected", + toast: { message: "not available" }, + }, + ], + }) + const { context, callbacks, syntheticCalls } = createContext(directory) + const warningSpy = spyOn(console, "warn").mockImplementation(() => {}) + const cleanup = await createV2Plugin().setup(context) + const event = { + tool: "bash", + sessionID: "session-3", + callID: "call-3", + agent: "build", + input: {}, + } + + await callbacks.get("execute.before")?.(event) + await callbacks.get("execute.before")?.(event) + + expect(syntheticCalls).toHaveLength(2) + expect(warningSpy).toHaveBeenCalledTimes(1) + + warningSpy.mockRestore() + await cleanup?.() + }) +}) diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..90df575 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["src/v2.ts", "src/v2/**", "node_modules", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json index 1666494..9e2d338 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,10 @@ "module": "ESNext", "lib": ["ES2020"], "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@opencode-ai/plugin/v2": ["node_modules/@opencode-ai/plugin-v2/dist/v2/promise/index.d.ts"] + }, "declaration": true, "declarationMap": true, "sourceMap": true,