diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c0c4b424..c257b482 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -36,12 +36,12 @@ bun run build # Bundle + declaration emit ## Module Boundaries -| Module | Owns | Must Not | -|--------|------|----------| -| `src/tools/` | Tool definitions, schema, execute functions | Import from other tools | -| `src/runtime/` | Subprocess management, task registry, JSONL parsing, notifications | Reference OpenCode plugin/tool APIs directly | -| `src/discovery/` | Agent file discovery, description builder | Depend on runtime or tools | -| `src/index.ts` | Plugin entrypoint, wiring | Contain business logic | +| Module | Owns | Must Not | +| ---------------- | ------------------------------------------------------------------ | -------------------------------------------- | +| `src/tools/` | Tool definitions, schema, execute functions | Import from other tools | +| `src/runtime/` | Subprocess management, task registry, JSONL parsing, notifications | Reference OpenCode plugin/tool APIs directly | +| `src/discovery/` | Agent file discovery, description builder | Depend on runtime or tools | +| `src/index.ts` | Plugin entrypoint, wiring | Contain business logic | ## Persisting Architectural Memory diff --git a/.github/workflows/fro-bot.yaml b/.github/workflows/fro-bot.yaml index 68a9bd69..2eeb45e1 100644 --- a/.github/workflows/fro-bot.yaml +++ b/.github/workflows/fro-bot.yaml @@ -13,7 +13,7 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review, review_requested] schedule: - - cron: '0 16 * * *' # Daily at 16:00 UTC + - cron: "0 16 * * *" # Daily at 16:00 UTC workflow_dispatch: inputs: prompt: @@ -207,7 +207,7 @@ jobs: - name: Run Fro Bot uses: fro-bot/agent@0f9f8fb198283a12e527311d285b1a422d8076d5 # v0.130.4 env: - OPENCODE_PROMPT_ARTIFACT: 'true' + OPENCODE_PROMPT_ARTIFACT: "true" PROMPT: >- ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.prompt || '')) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f7a13169..7fd6e5f1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -67,7 +67,7 @@ jobs: GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} with: setupGitUser: false - commit: 'chore(changesets): version packages' - title: 'chore(๐Ÿฆ‹๐Ÿ“ฆ): version packages' + commit: "chore(changesets): version packages" + title: "chore(๐Ÿฆ‹๐Ÿ“ฆ): version packages" version: bun run version-changesets publish: bun run publish-changesets diff --git a/.github/workflows/update-repo-settings.yaml b/.github/workflows/update-repo-settings.yaml index 95517711..cf0dbd92 100644 --- a/.github/workflows/update-repo-settings.yaml +++ b/.github/workflows/update-repo-settings.yaml @@ -6,10 +6,10 @@ on: push: branches: [main] paths: - - '.github/settings.yml' - - '.github/workflows/update-repo-settings.yaml' + - ".github/settings.yml" + - ".github/workflows/update-repo-settings.yaml" schedule: - - cron: '55 2 * * *' + - cron: "55 2 * * *" workflow_dispatch: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c43a54d..06aaa3ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,6 @@ The new tool wraps `copilot --resume=` and integrates fully with the existing task lifecycle โ€” output, cancel, and completion notifications all work the same way as delegated tasks. Key behaviors: - - UUID targets are validated against the local Copilot session store before spawning; missing sessions return a structured error without touching the CLI. - When a prior plugin task's session ID matches the target, its workspace paths (`--add-dir` set) are reused automatically when the caller omits `addDirs`. - CLI no-match errors (`Error: No session, task, or name matched '...'`) are normalized to a clean `Session not found` response. @@ -30,7 +29,6 @@ - 217a69e: Harden the plugin entry's public surface and Node-loadability so the build artifact survives the same class of regression that bit Systematic in v2.5.0 and v2.12.1. OpenCode's plugin loader treats every named export from a plugin entry point as a separate plugin factory and invokes it with `undefined` input. That contract has bitten upstream plugins twice with hours of downtime each; this PR institutionalizes the fix in three coordinated changes: - - **Helper moved out of the entry point.** `wireRpcServerCleanup` now lives in `src/lib/rpc-cleanup.ts`. The plugin entry exports only `default` and re-imports the helper internally. No behavior change โ€” the helper's identity-once semantics and its single caller are byte-identical. - **Build target switched to Node for the plugin entry.** `scripts/build.ts` now builds `src/index.ts` with `target: 'node'` so `dist/index.js` loads under plain Node ESM. The TUI entry stays on `target: 'bun'` because `@opentui/solid` is Bun-specific. The Node-loadable build is the prerequisite for the new CI gate (next bullet). - **CI gate asserts the export shape on every PR.** A new step in `.github/workflows/ci.yaml` between `Build` and `Unit tests` runs `node --input-type=module -e "import('./dist/index.js').then(m => โ€ฆ)"` and exits non-zero if the entry exposes any export other than `default` or if `default` is not a function. The local test surface gets a matching assertion in `tests/package-exports.test.ts`. The gate's failure message references the v2.5.0/v2.12.1 regression class so future contributors can find the rationale. @@ -48,13 +46,11 @@ OpenCode 1.14.42 removed `api.command.register` in favor of the new keymap engine. 1.14.44+ restored `api.command.register` as a deprecated shim that translates to `api.keymap.registerLayer` internally. The TUI plugin's `/copilot-status` command was unconditionally calling `api.command.register`, which would silently disappear on OpenCode versions where the call path went away. The TUI entry now runtime-feature-detects: - - **OpenCode 1.14.44+** (canonical): registers via `api.keymap.registerLayer({ commands: [{ namespace: 'palette', name: 'copilot-status', title: 'Copilot Status', category: 'Copilot', run() }], bindings: [] })`. Mirrors the dual-path pattern Magic Context established in commit `5fe1c4f`. - **OpenCode 1.14.41** (the prior pinned version): falls back to `api.command.register(...)` with the original command shape, so the slash menu continues to surface `/copilot-status` exactly as before. - **Neither API present** (defensive): logs a warning and continues to load the plugin without the slash command. The status modal remains available via direct API consumers. Other surfaces: - - `devDependencies['@opencode-ai/plugin']` moves from `1.14.41` to `1.14.48` so tests run against the canonical keymap API. - `peerDependencies['@opencode-ai/plugin']` narrows from `>=1.14.0` to `>=1.14.41` to align advertised compatibility with what's actually tested. @@ -91,14 +87,12 @@ ### Minor Changes - 80b549e: Improve runtime observability and fix a silent failure in completion notifications. - - `killProcessTree` now classifies fkill failures by probing the process group with `process.kill(-pid, 0)`. ESRCH is reported as an already-gone group and the failure is suppressed; alive or unknown states preserve the original throw with a more accurate warning. The probe targets `-pid` (the process group) rather than the leader so children leaking after a leader exit are no longer misclassified as benign. - `notifyCompletion`'s fallback `client.app.log` call now uses the structured SDK shape (`{ body: { service, level, message } }`) and is wrapped in `try/catch` so synchronous SDK throws can no longer escape the documented "never throws" contract. - All runtime warnings now share the `[copilot-delegate]` prefix across `kill-tree`, `orphan-reaper`, `pid-file`, `task-registry`, and `task-status`, making operator log filtering predictable. - Internal contract documentation: `setStatus` and `writeChains` carry JSDoc covering terminal-state transitions and the per-file serialize chain. - 4f2e8b5: Harden PID file and orphan-reaper state path handling against same-user symlink attacks. - - Use no-follow PID file opens for read and truncate paths so symlinked `.pids` files are rejected instead of read or truncated. - Reject symlinked PID file parent directories before orphan reaping, cleanup, and plugin init state-directory creation so `orphans/` scans and cleanup do not follow attacker-controlled links. @@ -113,7 +107,6 @@ No configuration change is required for users with a single config source. Users with duplicate registrations will see one warning per process and a single set of tools in the catalog. - 1fc343d: Tighten orphan-reaper internals and add two new exported helpers from `src/runtime/pid-file.ts`: - - `truncatePidFile(filePath)` โ€” truncate under the per-file `serializeWrite` lock; ENOENT (file or parent missing) silently swallowed. - `unlinkPidFile(filePath)` โ€” unlink under the per-file `serializeWrite` lock; ENOENT silently swallowed. @@ -168,7 +161,6 @@ - f3f21c7: Configurable orphan-reaper timeouts and parser extraction Three medium-priority improvements to the plugin-init orphan-subprocess reaper, all from the runtime hardening backlog: - - **Configurable per-probe `ps` timeout with degradation warning**: `getPidIdentity` and the underlying `runPs` helper now accept an optional `timeoutMs` parameter (default 1000). On loaded systems where legitimate `ps` responses can exceed 1s, callers can pass a longer timeout to avoid identity-gate misses that leave orphans alive. When the timeout fires, `runPs` emits a `console.warn` so operators can detect probe degradation and tune the budget up. - **Overall `reapOrphans` timeout with cooperative cancellation**: `reapOrphans` now accepts an optional `reapTimeoutMs` parameter (default 15000). When the timeout fires, the reap aborts via `AbortSignal`: in-flight workers cooperate by skipping their next mutating step (kill, truncate, unlink) so no dangerous side effects can occur after `reapOrphans` returns. The call resolves with an empty result and emits a `console.warn`. Prevents pathological cases (NFS `readdir` hang, all probes timing out simultaneously) from blocking plugin init indefinitely, and prevents lingering reap workers from wiping current-instance pid file entries that live tasks have appended after the timeout fired. - **Parser extraction**: the comm/lstart parsing logic is lifted out of `getPidIdentity` into a new exported `parsePsIdentity(raw)` pure function with full edge-case coverage (empty input, single-token input, leading-whitespace input, the 15/16-char `comm` boundary, and multi-whitespace separator). @@ -182,7 +174,6 @@ - 7dfb029: Eliminate head-of-line blocking and halve fork/exec cost in the orphan reaper Two performance improvements to the plugin-init orphan-subprocess reaper, both surfaced in the `v0.1.x / v0.2.x runtime hardening follow-ups` tracking issue: - - **Streaming worker pool replaces chunked `Promise.all`**: under loaded conditions a single slow `ps` invocation (300-500ms is realistic) used to idle four sibling workers in the same chunk, blowing the soft init budget by 5-10x. The reaper now spawns up to `MAX_CONCURRENT_PROBES = 5` workers that drain a shared queue independently โ€” a slow probe blocks only its own worker. - **Combined `ps -p -o comm=,lstart=` query**: the two separate `comm` and `lstart` lookups (per entry, plus per task spawn) consolidate into a single `getPidIdentity(pid)` call. Halves fork/exec cost on the reap hot path, makes the concurrency-cap math match reality (5 workers โ‡’ at most 5 concurrent `ps` subprocesses, not 10), and gets an atomic kernel snapshot of both identity legs. @@ -214,7 +205,6 @@ Tests run against `opencode/minimax-m2.5` by default (override with `OPENCODE_TEST_MODEL`). The describe block is gated on either `GH_TOKEN` or `COPILOT_PAT` being set; when neither is present, the suite skips cleanly so `bun test` stays green on dev machines without a Copilot PAT. Per-test isolation uses `OPENCODE_CONFIG_DIR` pointed at an empty temp dir to skip the developer's globally-configured plugins, and `OPENCODE_CONFIG_CONTENT` to register the plugin's own `dist/index.js` as a `file://` plugin in each test session. - 79d1f09: Add JSONL parser and envelope builder for Copilot CLI output processing - - `parseJsonlLine()` normalizes Copilot CLI JSONL events into typed `ParsedEvent` objects with defensive handling of malformed input - `buildEnvelope()` folds parsed events into the structured `copilot_output` response shape with graceful degradation for missing fields - Live JSONL fixtures captured from `copilot` CLI v1.0.34 for regression testing @@ -222,7 +212,6 @@ - de20762: Document the upstream `ps`/prompt-visibility caveat, the absence of a subprocess lifetime cap, and the `0.x` versioning policy in the README. Correct the notification-injection description to match the implementation (`client.session.prompt` with `noReply`, not `promptAsync`). - a23e15f: Add automated release pipeline. A new `Release` workflow runs after the `CI` workflow succeeds on `main`, opens a "Version Packages" pull request via `changesets/action` when changesets are pending, and publishes to npm via OIDC trusted publishing once that PR merges. Adds `@changesets/cli` as a development dependency and the `version-changesets` and `publish-changesets` scripts to drive the version-bump and publish steps. - 6fd4c6d: Add subprocess wrapper, task registry, notification injection, and agent discovery - - Subprocess spawning with line-buffered JSONL parsing, auth token env precedence, and abort-based cancellation via fkill process group kills - In-memory task registry with cleanup-all lifecycle - Notification injection using noReply semantics with per-session in-flight counters diff --git a/README.md b/README.md index 61d8f768..1cd5d35c 100644 --- a/README.md +++ b/README.md @@ -59,31 +59,31 @@ The plugin logs the resolved auth source (not the token value) at delegation sta ### `copilot_delegate` -| Arg | Type | Description | -|-----|------|-------------| -| `prompt` | `string` | Required. The prompt to send to Copilot. | -| `agent` | `string?` | Optional. Copilot agent name (see tool description for available agents). | -| `model` | `string?` | Optional. Model override (e.g. `claude-haiku-4.5`). | -| `add_dir` | `string[]?` | Optional. Additional directories to allow Copilot to read. | -| `allow_tool` | `string[]?` | Optional. Tool patterns to allow. | -| `deny_tool` | `string[]?` | Optional. Tool patterns to deny. | +| Arg | Type | Description | +| ------------ | ----------- | ------------------------------------------------------------------------- | +| `prompt` | `string` | Required. The prompt to send to Copilot. | +| `agent` | `string?` | Optional. Copilot agent name (see tool description for available agents). | +| `model` | `string?` | Optional. Model override (e.g. `claude-haiku-4.5`). | +| `add_dir` | `string[]?` | Optional. Additional directories to allow Copilot to read. | +| `allow_tool` | `string[]?` | Optional. Tool patterns to allow. | +| `deny_tool` | `string[]?` | Optional. Tool patterns to deny. | Returns `{ task_id: string }` โ€” a `cpl_`-prefixed UUID. ### `copilot_output` -| Arg | Type | Description | -|-----|------|-------------| -| `task_id` | `string` | Required. Task ID from `copilot_delegate`. | -| `block` | `boolean?` | Optional. Wait for completion before returning. Default `false`. | -| `timeout_ms` | `number?` | Optional. Max wait ms when `block: true`. Default `30000`, max `120000`. | +| Arg | Type | Description | +| ------------ | ---------- | ------------------------------------------------------------------------ | +| `task_id` | `string` | Required. Task ID from `copilot_delegate`. | +| `block` | `boolean?` | Optional. Wait for completion before returning. Default `false`. | +| `timeout_ms` | `number?` | Optional. Max wait ms when `block: true`. Default `30000`, max `120000`. | Returns a structured envelope with `status`, `final_message`, `tokens`, `tool_calls_summary`, and more. ### `copilot_cancel` -| Arg | Type | Description | -|-----|------|-------------| +| Arg | Type | Description | +| --------- | -------- | ---------------------------- | | `task_id` | `string` | Required. Task ID to cancel. | Returns `{ cancelled: boolean; was_running: boolean }`. diff --git a/bun.lock b/bun.lock index 83cd5e9b..370eaae0 100644 --- a/bun.lock +++ b/bun.lock @@ -12,11 +12,11 @@ "zod": "^4.3.0", }, "devDependencies": { - "@biomejs/biome": "2.5.15", + "@biomejs/biome": "2.6.4", "@changesets/cli": "2.31.1", - "@opencode-ai/plugin": "1.19.3", + "@opencode-ai/plugin": "1.19.5", "@types/babel__core": "7.20.5", - "@types/bun": "1.4.3", + "@types/bun": "1.4.4", "@types/node": "24.19.2", "rimraf": "6.1.3", "typescript": "7.0.2", @@ -92,23 +92,27 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], - "@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], + "@biomejs/biome": ["@biomejs/biome@2.6.4", "", { "optionalDependencies": { "@biomejs/cli-android-arm64": "2.6.4", "@biomejs/cli-android-x64": "2.6.4", "@biomejs/cli-darwin-arm64": "2.6.4", "@biomejs/cli-darwin-x64": "2.6.4", "@biomejs/cli-linux-arm64": "2.6.4", "@biomejs/cli-linux-arm64-musl": "2.6.4", "@biomejs/cli-linux-x64": "2.6.4", "@biomejs/cli-linux-x64-musl": "2.6.4", "@biomejs/cli-win32-arm64": "2.6.4", "@biomejs/cli-win32-x64": "2.6.4" }, "bin": { "biome": "bin/biome" } }, "sha512-8sKKwaNW4ot22awZ3lBjwWLHnUFXgXnrBPYS2gNlBAXRbDC++7kqjK2H7dR/FgCDtPvO1xfKpgEhJkGxAKYkxA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], + "@biomejs/cli-android-arm64": ["@biomejs/cli-android-arm64@2.6.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jwrwigP4tBcHxzuV8izioX/BOUhJL/4e2rNSOE5nCjBkVC018LQAQUGiH+CNkFYvLtQ1dcZo5/xXERmaAGc7JQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], + "@biomejs/cli-android-x64": ["@biomejs/cli-android-x64@2.6.4", "", { "os": "android", "cpu": "x64" }, "sha512-HdGInPJ4nZFwHxzvEtcp2nmSBtUyJrurmtIK43upFckuS3+ZqVM05HtsxQWnqGsuy/pxckIAjyKWlEKRlBXUyA=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0VvcUz46wbg59Q4TZ59KkIjDJrxEyu4Eoe79+F4SChxoWe+W7AEyIp6F5r72C9+p6MsB6dIovnZt1D30lGM3XA=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.6.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fxmvkF81srlS3DiMyfm+aG3J0eG+6/BjaxRyjy57KbLSafnq0PO+hHfVWmtH2ty0S7nm6yDXBRwySfFeaRJKRQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.6.4", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-6+bK1Y/evmIUiJr7auertuMbiaYVicJhqA1bZyL47lCMWugv8l0gFQCqvR/gN1vZMHYm+HwoTKgeMxdNFCoQTg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.6.4", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-31ZaH/MHqPX+O+evRwRrCHQ8W5/9adY+/eMytGGWZnRx1T41QsWIF09+/WujynLBhWZUxNGS5CtsWLwgOFTAyg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.6.4", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-tbFZMdL6dP/ws4edXxYG9WhvaVa1ZDzL4G5DzahnWW7Psib9H+VTdyU4XXVxpJk5j/nYZroSbvSNyYlpW3yCIw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.6.4", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-bomSa2XdQAGGj6Cwdm4M13qE+lN9+AOlW8KtI4hdyVyRRF/Z71qPAi95T4wtdZK0QpBbkTW8hgbZZCwPi4xzIw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.6.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-eCsqt8Ma3eVap+oyJCDnJZH60SIU464B66n3y38EUTJdM1T/HfZol1FJC9vb+sHToX0ix8XzqPcwJnOzyAJz0g=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.6.4", "", { "os": "win32", "cpu": "x64" }, "sha512-pYsMy5AThiHFOZAsngg8qN4PurM6nInKBhVX7sbj3eNpRQqo1Hb0MgCwPL7VqVMrmcEEMj9Ks3qbkallR65e8g=="], "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="], @@ -176,9 +180,9 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.19.3", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.19.3", "effect": "4.0.0-beta.83", "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-FBN2OREfg9xiIvrxrxjZt6CCg6kn8WzKdwsD/6+a10R/M4hrdRTounUgg0LUR4cOXcI/XMKPeIWdfdaXBdmZEw=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.19.5", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.19.5", "effect": "4.0.0-beta.83", "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-79PY+pxcJajHReWv2OP2SYTdlx1f7zOmq2hIaReOJnovsxNaSvxVyQHx+6Yq22T5UcS4z3Qgws4KB4lQOaIbdQ=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.19.3", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-G63bZD2r6plqgjFdenWVj3HBFWmLQSVuG9CXMQuTjakzA5aL6wFLmI23Biz5ESE4lQQ08UTHcY2N4NOShJFb2w=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.19.5", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-+bqu8gHiwmq96T9q5G34uLNHWCInIMsrE3hytCNHj1cVaxjY0AGJyNCT1rkeeZwgPU7A5TMTNwN+0XxCO2vqDg=="], "@opentui/core": ["@opentui/core@0.2.7", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.7", "@opentui/core-darwin-x64": "0.2.7", "@opentui/core-linux-arm64": "0.2.7", "@opentui/core-linux-x64": "0.2.7", "@opentui/core-win32-arm64": "0.2.7", "@opentui/core-win32-x64": "0.2.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-cnN6JcaGC7SeQzobBy/CHzqUAQFtypazuw1CjQBo7WwoOiLMGubt9W5FXeF0zIrSxH2Ed6NLWhPYRg7SD4629Q=="], @@ -210,7 +214,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], "@types/node": ["@types/node@24.19.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="], @@ -282,7 +286,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], - "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], + "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], diff --git a/docs/brainstorms/2026-04-27-copilot-status-tui-requirements.md b/docs/brainstorms/2026-04-27-copilot-status-tui-requirements.md index acb95401..b208dc9c 100644 --- a/docs/brainstorms/2026-04-27-copilot-status-tui-requirements.md +++ b/docs/brainstorms/2026-04-27-copilot-status-tui-requirements.md @@ -1,35 +1,37 @@ --- + title: "/copilot-status TUI โ€” visibility platform requirements" date: 2026-04-27 status: ready-for-planning scope: standard ideation_source: docs/ideation/2026-04-27-copilot-delegate-v0.2.md (S1) related: - - docs/research/copilot-cli-capabilities-2026-04-27.md - - docs/plans/2026-04-21-copilot-delegate-plugin.md (closed) -layout_reference: +- docs/research/copilot-cli-capabilities-2026-04-27.md +- docs/plans/2026-04-21-copilot-delegate-plugin.md (closed) + layout_reference: source: OpenCode native subagent view (observed during brainstorming; not checked into this repo) shape: | - A vertically stacked panel layout, post-completion. Top: a header strip showing - `Subagent (N of N)` indicating the run is finished. Below: a main prompt panel - with a cyan / accent left border that holds the original delegated prompt. - Below that: a vertical sequence of output cards โ€” one per tool call โ€” each - collapsible with a `Click to expand` affordance on collapsed cards and showing - the full tool result when expanded. Bottom: a status bar with delegation - metadata on the left and navigation hints on the right reading - `Parent up ยท Prev left ยท Next right`. Color treatment is OpenCode-default - (cyan accent, default terminal foreground/background). -target_releases: v0.2.0 โ†’ v0.2.1 โ†’ v0.2.2 (staged; each minor bump per `0.x` policy) -handoff: ce:plan (v0.2.0 only); v0.2.1 and v0.2.2 each get their own brainstorm before planning -review_artifacts: - - 5 of 6 reviewers returned (coherence-reviewer timed out); staging decision driven by HIGH consensus across scope-guardian, adversarial, and product-lens + A vertically stacked panel layout, post-completion. Top: a header strip showing + `Subagent (N of N)` indicating the run is finished. Below: a main prompt panel + with a cyan / accent left border that holds the original delegated prompt. + Below that: a vertical sequence of output cards โ€” one per tool call โ€” each + collapsible with a `Click to expand` affordance on collapsed cards and showing + the full tool result when expanded. Bottom: a status bar with delegation + metadata on the left and navigation hints on the right reading + `Parent up ยท Prev left ยท Next right`. Color treatment is OpenCode-default + (cyan accent, default terminal foreground/background). + target_releases: v0.2.0 โ†’ v0.2.1 โ†’ v0.2.2 (staged; each minor bump per `0.x` policy) + handoff: ce:plan (v0.2.0 only); v0.2.1 and v0.2.2 each get their own brainstorm before planning + review_artifacts: +- 5 of 6 reviewers returned (coherence-reviewer timed out); staging decision driven by HIGH consensus across scope-guardian, adversarial, and product-lens + --- # `/copilot-status` TUI โ€” visibility platform requirements ## Problem -The v0.1.0 plugin makes long-running Copilot delegations possible but inconvenient to monitor. A 5-minute opus delegation looks identical to a hung subprocess from the parent OpenCode session: spawn returns a `task_id`, then nothing visible happens until the completion toast / system-reminder fires. The user *can* call `copilot_output` with blocking to inspect a running delegation, but that's a tool call away from the chat (and surfaces only the structured envelope, not a live event stream). Cancelling requires leaving the chat to call `copilot_cancel`. The v0.1.0 signature gap is **convenience and live progress**: no chat-native surface for in-flight inspection, no live tool-call rendering, no inline cancel control. +The v0.1.0 plugin makes long-running Copilot delegations possible but inconvenient to monitor. A 5-minute opus delegation looks identical to a hung subprocess from the parent OpenCode session: spawn returns a `task_id`, then nothing visible happens until the completion toast / system-reminder fires. The user _can_ call `copilot_output` with blocking to inspect a running delegation, but that's a tool call away from the chat (and surfaces only the structured envelope, not a live event stream). Cancelling requires leaving the chat to call `copilot_cancel`. The v0.1.0 signature gap is **convenience and live progress**: no chat-native surface for in-flight inspection, no live tool-call rendering, no inline cancel control. ## Goal @@ -47,11 +49,11 @@ The TUI plugin is opt-in via the user's `tui.jsonc` (same opt-in posture as Magi ## Release staging -| Release | What ships | Why this slice | -| ---------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Release | What ships | Why this slice | +| ---------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | **v0.2.0** | Foundation (TUI plugin half + RPC + build prerequisites + PID workaround) + list view + cancel + spawn toast | Ships the load-bearing architectural commit + minimum viable visibility (see what's running, kill it). | -| **v0.2.1** | Drilled fullscreen view with live event streaming | Solves "what is it doing right now?" Requires the foundation from v0.2.0 to be settled. | -| **v0.2.2** | Recent-history (post-mortem), expandable output cards, concurrent โ†/โ†’ navigation | UX richness layered on top once core flow is validated. | +| **v0.2.1** | Drilled fullscreen view with live event streaming | Solves "what is it doing right now?" Requires the foundation from v0.2.0 to be settled. | +| **v0.2.2** | Recent-history (post-mortem), expandable output cards, concurrent โ†/โ†’ navigation | UX richness layered on top once core flow is validated. | This brainstorm scopes **v0.2.0 only** in detail. v0.2.1 and v0.2.2 get their own brainstorms when their time comes, informed by what v0.2.0 reveals. @@ -108,12 +110,12 @@ The completion system-reminder remains unconditional โ€” it's the LLM-orchestrat ### Interaction model โ€” v0.2.0 keybinds (modal list view only) -| Key | Action | -| --------- | --------------------------------------------------- | -| `โ†‘` / `k` | Move focus up | -| `โ†“` / `j` | Move focus down | -| `c` | Cancel focused running delegation (confirm dialog) | -| `Esc` | Close modal, return to OpenCode session | +| Key | Action | +| --------- | -------------------------------------------------- | +| `โ†‘` / `k` | Move focus up | +| `โ†“` / `j` | Move focus down | +| `c` | Cancel focused running delegation (confirm dialog) | +| `Esc` | Close modal, return to OpenCode session | `Enter` (drill in), `r` (manual refresh), `โ†`/`โ†’` (concurrent nav), `e`/`Space` (expand), `PgUp`/`PgDn` (scroll event stream) are deferred to v0.2.1 / v0.2.2 with the surfaces they belong to. @@ -168,7 +170,7 @@ These resolve the design-lens-reviewer findings for v0.2.0. Drilled-view + concu ## v0.2.1 โ€” preview (out of scope for this brainstorm) Drilled fullscreen view with live event streaming, mirroring OpenCode's native subagent shape (see frontmatter `layout_reference` for the captured static post-completion layout). Re-brainstormed when v0.2.0 is shipped and reviewed; that brainstorm will resolve: -- Drilled view layout for the *streaming* state (the captured reference is post-completion only). +- Drilled view layout for the _streaming_ state (the captured reference is post-completion only). - Arrow-key consistency between list view (`โ†‘` = previous item) and drilled view (`โ†‘` = parent up). - Per-task event buffer caps (memory budget) for the focused delegation. (Pull-from-registry transport is already locked at prerequisite #4.) - `Enter` (drill-in), back-out, and event-stream scroll keybinds. @@ -190,7 +192,7 @@ Recent-history surface, expandable output cards, concurrent โ†/โ†’ navigation i **Compatibility**: - The plugin continues to work when the TUI plugin half is not installed: `copilot_delegate` / `copilot_output` / `copilot_cancel` and existing toasts/system-reminders continue. Only `/copilot-status` is unavailable. -- The plugin continues to work when the TUI plugin half is *installed but fails to load* (RPC unreachable, opentui mismatch, etc.): `/copilot-status` shows the spec'd RPC error state rather than throwing or blocking the session. +- The plugin continues to work when the TUI plugin half is _installed but fails to load_ (RPC unreachable, opentui mismatch, etc.): `/copilot-status` shows the spec'd RPC error state rather than throwing or blocking the session. - After a plugin reload while delegations are running, the PID workaround (see prerequisite #3) reaps the orphaned subprocesses on plugin re-init before any RPC requests are served; the modal then accurately reflects the registry state (no stale "running" rows). ### v0.2.1 / v0.2.2 @@ -208,9 +210,9 @@ These are blocking dependencies that must be resolved before implementation. Ori - For each PID still alive (POSIX `kill(pid, 0)`), invoke existing `killProcessTree(pid)` and log the reap. - On task spawn, append the subprocess PID to the file (atomic append with file lock). - On task terminal transition (`complete` / `failed` / `cancelled`), remove the PID from the file. - - **Init bound**: the reap completes synchronously during plugin init *before* the RPC server begins accepting requests; at typical sizes (โ‰ค10 entries, the existing concurrent cap) the operation should complete in well under one second. The success criterion of "before any RPC requests are served" is structural (sequencing), not a wall-clock target. + - **Init bound**: the reap completes synchronously during plugin init _before_ the RPC server begins accepting requests; at typical sizes (โ‰ค10 entries, the existing concurrent cap) the operation should complete in well under one second. The success criterion of "before any RPC requests are served" is structural (sequencing), not a wall-clock target. - Bounded scope; ~50 lines in `src/runtime/orphan-reaper.ts` plus an init call from `src/index.ts`. -4. **Push vs pull for events.** Architectural, not implementation. **Pull** โ€” the TUI requests buffered events from the server-side registry on demand (per drilled-view focus, per RPC poll). Push from the JSONL parser into the RPC layer couples the parser to transport and creates backpressure risk. The server-side `task-registry` already buffers `events` in `SpawnCopilotResult`; expose a query method (e.g., `getTaskEvents(taskId, sinceEventId?)`). v0.2.0 only needs the *list* RPC method; the per-task event method lands with v0.2.1. +4. **Push vs pull for events.** Architectural, not implementation. **Pull** โ€” the TUI requests buffered events from the server-side registry on demand (per drilled-view focus, per RPC poll). Push from the JSONL parser into the RPC layer couples the parser to transport and creates backpressure risk. The server-side `task-registry` already buffers `events` in `SpawnCopilotResult`; expose a query method (e.g., `getTaskEvents(taskId, sinceEventId?)`). v0.2.0 only needs the _list_ RPC method; the per-task event method lands with v0.2.1. ## Open questions for `ce:plan` (v0.2.0; genuinely deferable) diff --git a/docs/brainstorms/2026-05-02-copilot-session-continuity-requirements.md b/docs/brainstorms/2026-05-02-copilot-session-continuity-requirements.md index 5d5e8c98..c3bb6dc9 100644 --- a/docs/brainstorms/2026-05-02-copilot-session-continuity-requirements.md +++ b/docs/brainstorms/2026-05-02-copilot-session-continuity-requirements.md @@ -1,4 +1,5 @@ --- + title: Copilot session continuity requirements date: 2026-05-02 status: ready-for-review @@ -6,9 +7,10 @@ scope: deep topic: copilot-session-continuity ideation_source: docs/ideation/2026-04-27-copilot-delegate-v0.2.md (S2) related: - - docs/research/copilot-cli-capabilities-2026-04-27.md - - docs/brainstorms/2026-04-27-copilot-status-tui-requirements.md -handoff: ce:plan +- docs/research/copilot-cli-capabilities-2026-04-27.md +- docs/brainstorms/2026-04-27-copilot-status-tui-requirements.md + handoff: ce:plan + --- # Copilot session continuity requirements diff --git a/docs/brainstorms/2026-05-11-plugin-contract-and-tui-api-resilience-requirements.md b/docs/brainstorms/2026-05-11-plugin-contract-and-tui-api-resilience-requirements.md index 5362822e..18acb390 100644 --- a/docs/brainstorms/2026-05-11-plugin-contract-and-tui-api-resilience-requirements.md +++ b/docs/brainstorms/2026-05-11-plugin-contract-and-tui-api-resilience-requirements.md @@ -1,4 +1,5 @@ --- + date: 2026-05-11 topic: plugin-contract-and-tui-api-resilience --- @@ -72,34 +73,40 @@ After R1โ€“R6 land, the plugin must: ## Acceptance Examples ### AE1. TUI registration on OpenCode 1.14.44+ + **When** the TUI plugin loads under OpenCode 1.14.44 or later (`api.keymap.registerLayer` exists, `api.command.register` is a deprecated shim), **then** registration uses `keymap.registerLayer({ commands: [{ namespace: 'palette', name: 'copilot-status', title: 'Copilot Status', ... }], bindings: [] })`, the `/copilot-status` command appears in the slash menu, and `onSelect`/`run` invokes `openList()`. -*Covers R1.* +_Covers R1._ ### AE2. TUI registration on OpenCode 1.14.41 + **When** the TUI plugin loads under OpenCode 1.14.41 (`api.keymap.registerLayer` is undefined, `api.command.register` is the only API), **then** registration falls through to `api.command.register(() => [{ title: 'Copilot Status', value: '/copilot-status', slash: { name: 'copilot-status' }, onSelect: ... }])`, and the slash command continues to work. -*Covers R1, R2 (the lower-bound peerDependency).* +_Covers R1, R2 (the lower-bound peerDependency)._ ### AE3. CI gate catches a stowaway export + **When** a contributor adds `export const helper = ...` to `src/index.ts` and opens a PR, **then** the CI Node-ESM smoke test fails with a message that includes the actual export list (e.g., `["default", "helper"]`), references the OpenCode loader behavior, and points to `src/lib/` as the correct home for helpers. -*Covers R3, R4.* +_Covers R3, R4._ ### AE4. Build artifact loads under Node + **When** `bun scripts/build.ts` runs after R5 lands and `node --input-type=module -e "import('./dist/index.js')"` is invoked, **then** the import succeeds with exit code 0 and `Object.keys(imported).sort()` equals `['default']`. -*Covers R3, R5.* +_Covers R3, R5._ ### AE5. Duplicate factory invocation still produces empty hooks + **When** the same OpenCode process invokes the plugin factory twice (e.g., the plugin is listed in both user-level and project-level `opencode.json`), **then** the first invocation runs `initializePlugin` and returns real hooks; the second invocation triggers `onDuplicate`, returns `{}`, and emits a warning log identifying the duplicate source. -*Covers the singleton behavior that motivates R6.* +_Covers the singleton behavior that motivates R6._ ### AE6. Singleton rationale is documented + **When** a maintainer reads `src/runtime/plugin-singleton.ts` or this brainstorm doc, **then** they find a note cross-referencing Systematic PR #352 / PR #355 and explaining why `plugInOnce` stays in this repo. -*Covers R6.* +_Covers R6._ --- @@ -115,12 +122,12 @@ After R1โ€“R6 land, the plugin must: ## Scope Boundaries **In scope (one or two PRs, planner's call):** -- TUI API dual-path migration (R1, R2) โ€” *user-facing fix; can ship as its own PR ahead of the structural work if velocity matters* -- Plugin entry default-only export refactor (R3) โ€” *must ship with R4 + R5* -- CI Node-ESM export-shape smoke test (R4) โ€” *must ship with R3 + R5; requires adding a Node setup step to CI (the current pipeline is Bun-only)* -- Build target switch for plugin entry (R5) โ€” *prerequisite for R4 (Node smoke test cannot run against a Bun-only artifact)* -- Singleton design rationale recorded (R6) โ€” *implementation note; lands wherever the other R6-touching file lands, or in either PR* -- Regression-test verification (R7) โ€” *applies to whichever PR(s) ship* +- TUI API dual-path migration (R1, R2) โ€” _user-facing fix; can ship as its own PR ahead of the structural work if velocity matters_ +- Plugin entry default-only export refactor (R3) โ€” _must ship with R4 + R5_ +- CI Node-ESM export-shape smoke test (R4) โ€” _must ship with R3 + R5; requires adding a Node setup step to CI (the current pipeline is Bun-only)_ +- Build target switch for plugin entry (R5) โ€” _prerequisite for R4 (Node smoke test cannot run against a Bun-only artifact)_ +- Singleton design rationale recorded (R6) โ€” _implementation note; lands wherever the other R6-touching file lands, or in either PR_ +- Regression-test verification (R7) โ€” _applies to whichever PR(s) ship_ Dependencies: R3 โ†’ R5 (smoke test needs Node-loadable build) โ†’ R4. R1/R2 are independent. Planner may bundle all six in one PR for atomicity or split into (R1/R2) + (R3/R4/R5/R6/R7). @@ -135,12 +142,12 @@ Dependencies: R3 โ†’ R5 (smoke test needs Node-loadable build) โ†’ R4. R1/R2 are ## Key Decisions -| Decision | Choice | Rationale | -|---|---|---| +| Decision | Choice | Rationale | +| ---------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | TUI API strategy | Dual-path runtime feature-detect with pinned-to-1.14.44+ tests | Magic Context's pattern is already validated against the same gap; pinning to 1.14.44+ tests against the canonical API while the fallback supports 1.14.41 users | -| Named export fix | Move helper to `src/lib/`, add CI gate | Mirror PR #355 exactly. The CI gate is what makes the constraint enforceable, not just documented | -| Build target | Plugin = `target: 'node'`, TUI = `target: 'bun'` | Plugin has no Bun-specific deps; TUI's `@opentui/solid/preload` is Bun-specific. Different runtimes, different targets | -| Singleton design | Keep `plugInOnce` + `onDuplicate โ†’ {}` | RPC server + PID file have N-instance side effects that the marker-based per-load model can't address | +| Named export fix | Move helper to `src/lib/`, add CI gate | Mirror PR #355 exactly. The CI gate is what makes the constraint enforceable, not just documented | +| Build target | Plugin = `target: 'node'`, TUI = `target: 'bun'` | Plugin has no Bun-specific deps; TUI's `@opentui/solid/preload` is Bun-specific. Different runtimes, different targets | +| Singleton design | Keep `plugInOnce` + `onDuplicate โ†’ {}` | RPC server + PID file have N-instance side effects that the marker-based per-load model can't address | --- diff --git a/docs/ideation/2026-04-27-copilot-delegate-v0.2.md b/docs/ideation/2026-04-27-copilot-delegate-v0.2.md index 00e1beee..96764975 100644 --- a/docs/ideation/2026-04-27-copilot-delegate-v0.2.md +++ b/docs/ideation/2026-04-27-copilot-delegate-v0.2.md @@ -1,20 +1,22 @@ --- + title: opencode-copilot-delegate v0.2.x โ€” ideation date: 2026-04-27 status: ranked focus: | - Two streams: (a) plugin features seeded by docs/research/copilot-cli-capabilities-2026-04-27.md; - (b) TUI/desktop visibility for in-flight delegations โ€” render JSONL event stream like an - OpenCode subagent session, surfaced via slash commands like /copilot-status (cf. Magic - Context's /ctx-status modal). +Two streams: (a) plugin features seeded by docs/research/copilot-cli-capabilities-2026-04-27.md; +(b) TUI/desktop visibility for in-flight delegations โ€” render JSONL event stream like an +OpenCode subagent session, surfaced via slash commands like /copilot-status (cf. Magic +Context's /ctx-status modal). volume: default (~30 raw, ~22 deduped, 7 survivors) frames: pain & friction ยท inversion/automation ยท assumption-breaking ยท leverage/compounding handoff: ce:brainstorm on a single chosen survivor related: - - docs/research/copilot-cli-capabilities-2026-04-27.md - - docs/plans/2026-04-21-copilot-delegate-plugin.md (closed, all T1โ€“T11 ticked) - - docs/solutions/best-practices/reliable-cli-integration-testing-2026-04-26.md - - docs/solutions/developer-experience/opencode-debug-diagnostics-2026-04-26.md +- docs/research/copilot-cli-capabilities-2026-04-27.md +- docs/plans/2026-04-21-copilot-delegate-plugin.md (closed, all T1โ€“T11 ticked) +- docs/solutions/best-practices/reliable-cli-integration-testing-2026-04-26.md +- docs/solutions/developer-experience/opencode-debug-diagnostics-2026-04-26.md + --- # v0.2.x Ideation โ€” opencode-copilot-delegate @@ -191,21 +193,21 @@ risk preflight (rolled in as one diagnostic surface). Each rejection is one-line โ€” preserved for future reconsideration. -| Idea | Frame | Reason | -| ------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------- | -| **Hook-based push to delete `copilot_output`** (F2.1, F2.4) | F2 | Copilot user-level hooks fire for ALL spawned subprocesses, not just plugin-owned ones โ€” wrong layer for a clean push API. Roll into S7's hook awareness instead. | -| **OTel telemetry zero-instrumentation** (F4.10) | F4 | Pre-mature for a 0.x plugin with no telemetry users. Comes free if S1's event bus ships; defer the OTel subscriber. | -| **Composable delegation pipelines / DAGs** (F3.3) | F3 | Speculative; OpenCode itself orchestrates. Premature complexity. | -| **Bidirectional `--ask_user` relay** (F3.5) | F3 | Inverts the unattended-delegation value prop; introduces a deadlock-prone session-injection contract. Defer. | -| **Plan-mode โ†’ OpenCode todos integration** (F3.9) | F3 | Blurs the OpenCode-orchestrates / Copilot-delegates boundary. Niche. | -| **Hook-driven auto-delegation on session events** (F3.10) | F3 | Intrusive. Conflicts with explicit-invocation preference. | -| **Default-deny secrets + risk preflight** (F1.5, F2.9) | F1/F2 | Documenting `--secret-env-vars` in README and forwarding the flag is enough; UI preflight is niche. Roll into S7. | -| **Hook-awareness quarantine mode** (F1.6) | F1 | Hook-side-effects-on-spawn is a Copilot CLI design, not the plugin's job to "quarantine." Document; don't gate. | -| **Non-interactive safety profile** (F1.8) | F1 | The plugin already passes `--no-ask-user --allow-all-tools`. Just naming the existing default. | -| **Auto-inferred `--add-dir` for workspace** (F2.7) | F2 | Error-prone; sibling repos may pull in unrelated work, security boundary risk. Manual `--add-dir` is fine. | -| **Agent fs.watch caching** (F2.8) | F2 | Per-spawn discovery is fast (one readdir); fs.watch adds cleanup and rename/delete edge cases. Premature. | -| **Parameterized prompt macros** (F2.10) | F2 | OpenCode itself can do template expansion in user-space; not the plugin's role. | -| **Workspace continuity guard for resume** (F1.9) | F1 | Folded into S2: `CopilotProcess.resume` re-applies the original `--add-dir` set automatically. | +| Idea | Frame | Reason | +| ----------------------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Hook-based push to delete `copilot_output`** (F2.1, F2.4) | F2 | Copilot user-level hooks fire for ALL spawned subprocesses, not just plugin-owned ones โ€” wrong layer for a clean push API. Roll into S7's hook awareness instead. | +| **OTel telemetry zero-instrumentation** (F4.10) | F4 | Pre-mature for a 0.x plugin with no telemetry users. Comes free if S1's event bus ships; defer the OTel subscriber. | +| **Composable delegation pipelines / DAGs** (F3.3) | F3 | Speculative; OpenCode itself orchestrates. Premature complexity. | +| **Bidirectional `--ask_user` relay** (F3.5) | F3 | Inverts the unattended-delegation value prop; introduces a deadlock-prone session-injection contract. Defer. | +| **Plan-mode โ†’ OpenCode todos integration** (F3.9) | F3 | Blurs the OpenCode-orchestrates / Copilot-delegates boundary. Niche. | +| **Hook-driven auto-delegation on session events** (F3.10) | F3 | Intrusive. Conflicts with explicit-invocation preference. | +| **Default-deny secrets + risk preflight** (F1.5, F2.9) | F1/F2 | Documenting `--secret-env-vars` in README and forwarding the flag is enough; UI preflight is niche. Roll into S7. | +| **Hook-awareness quarantine mode** (F1.6) | F1 | Hook-side-effects-on-spawn is a Copilot CLI design, not the plugin's job to "quarantine." Document; don't gate. | +| **Non-interactive safety profile** (F1.8) | F1 | The plugin already passes `--no-ask-user --allow-all-tools`. Just naming the existing default. | +| **Auto-inferred `--add-dir` for workspace** (F2.7) | F2 | Error-prone; sibling repos may pull in unrelated work, security boundary risk. Manual `--add-dir` is fine. | +| **Agent fs.watch caching** (F2.8) | F2 | Per-spawn discovery is fast (one readdir); fs.watch adds cleanup and rename/delete edge cases. Premature. | +| **Parameterized prompt macros** (F2.10) | F2 | OpenCode itself can do template expansion in user-space; not the plugin's role. | +| **Workspace continuity guard for resume** (F1.9) | F1 | Folded into S2: `CopilotProcess.resume` re-applies the original `--add-dir` set automatically. | ## Cross-cutting note diff --git a/docs/plans/2026-04-21-copilot-delegate-plugin.md b/docs/plans/2026-04-21-copilot-delegate-plugin.md index 0057cd89..f1c60b5e 100644 --- a/docs/plans/2026-04-21-copilot-delegate-plugin.md +++ b/docs/plans/2026-04-21-copilot-delegate-plugin.md @@ -1,4 +1,5 @@ --- + title: "feat: OpenCode Copilot Delegate Plugin" type: feat status: active @@ -61,7 +62,7 @@ await client.session.promptAsync({ noReply: !allComplete, parts: [{ type: 'text', text: notification, synthetic: true }], }, -}); +}) ``` `noReply` semantics (verified): @@ -118,6 +119,7 @@ export const CopilotDelegate: Plugin = async ({ client, directory }) => ({ ``` spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], shell, env, cwd, windowsHide: true }) ``` + - Line-buffer `stdout`, parse JSONL events incrementally. - Maintain a `turnState` accumulator (extract `thread_id`, messages, `usage`, errors). - On `close` event, resolve with `{ state, stderrTail, exitCode }`. @@ -220,7 +222,7 @@ opencode-copilot-delegate/ ## High-Level Technical Design -> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* +> _This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce._ **Event flow (single delegation):** @@ -326,14 +328,14 @@ type TaskState = { finalMessage?: string errorText?: string child: ChildProcess - completionPromise: Promise // resolved on close; copilot_output block:true races this vs timeout + completionPromise: Promise // resolved on close; copilot_output block:true races this vs timeout abortController: AbortController } ``` ## Implementation Units -- [x] **T1: Repo bootstrap** *(complete)* +- [x] **T1: Repo bootstrap** _(complete)_ **Goal:** Publish an installable npm package skeleton with working build and changeset tooling. @@ -355,7 +357,7 @@ type TaskState = { --- -- [x] **T2.5: JSONL fixture capture** *(complete; landed in PR #13)* +- [x] **T2.5: JSONL fixture capture** _(complete; landed in PR #13)_ **Goal:** Capture real Copilot CLI JSONL output as test fixtures before writing the parser. @@ -385,7 +387,7 @@ type TaskState = { --- -- [x] **T2: JSONL parser + envelope builder** *(complete; landed in PR #13)* +- [x] **T2: JSONL parser + envelope builder** _(complete; landed in PR #13)_ **Goal:** Parse Copilot CLI JSONL stdout into typed events; fold events into the structured `copilot_output` envelope. @@ -424,7 +426,7 @@ type TaskState = { --- -- [x] **T3: Subprocess wrapper + registry** *(complete; landed in PR #14)* +- [x] **T3: Subprocess wrapper + registry** _(complete; landed in PR #14)_ **Goal:** Spawn the `copilot` process, line-buffer its stdout through the JSONL parser, manage `TaskState` in the registry, and support cancellation. @@ -463,7 +465,7 @@ type TaskState = { --- -- [x] **T4: Notification injection** *(complete; landed in PR #14)* +- [x] **T4: Notification injection** _(complete; landed in PR #14)_ **Goal:** Inject `` notifications into the parent session when a Copilot subprocess completes, with correct `noReply` semantics mirroring OMO. @@ -497,7 +499,7 @@ type TaskState = { --- -- [x] **T5: Agent discovery + description** *(complete; landed in PR #14)* +- [x] **T5: Agent discovery + description** _(complete; landed in PR #14)_ **Goal:** Build the `copilot_delegate` tool description by merging built-in, user, and repo Copilot agents at plugin load. @@ -534,7 +536,7 @@ type TaskState = { --- -- [x] **T6: Tool wiring** *(complete; landed in PR #16)* +- [x] **T6: Tool wiring** _(complete; landed in PR #16)_ **Goal:** Wire the three tools and plugin entrypoint; integration-test with a stubbed `PluginInput`. @@ -574,7 +576,7 @@ type TaskState = { --- -- [x] **T6.5: OpenCode server integration tests** *(complete; landed in PR #27, rebuilt around `opencode run` CLI in PR #39)* +- [x] **T6.5: OpenCode server integration tests** _(complete; landed in PR #27, rebuilt around `opencode run` CLI in PR #39)_ **Goal:** Automated integration tests that spawn a real OpenCode server subprocess with the plugin installed, send actual session prompts via the SDK client, and assert on tool execution results โ€” validating the full plugin lifecycle without manual steps. @@ -614,7 +616,7 @@ type TaskState = { --- -- [x] **T7: End-to-end manual verification** *(complete; superseded by T6.5's CI-gated LLM-driven scenarios in PR #27 โ†’ PR #39 โ€” `delegate happy path`, `output unknown task_id`, `cancel running delegation`. The remaining 3 plan scenarios โ€” concurrent delegations, plugin-reload mid-flight, TUI toast โ€” are documented as known limitations / upstream OpenCode behaviors and are not blockers for v0.1.0.)* +- [x] **T7: End-to-end manual verification** _(complete; superseded by T6.5's CI-gated LLM-driven scenarios in PR #27 โ†’ PR #39 โ€” `delegate happy path`, `output unknown task_id`, `cancel running delegation`. The remaining 3 plan scenarios โ€” concurrent delegations, plugin-reload mid-flight, TUI toast โ€” are documented as known limitations / upstream OpenCode behaviors and are not blockers for v0.1.0.)_ **Goal:** Exercise all user-facing behaviors in a real OpenCode session before tagging v0.1.0. Capture results in `VERIFICATION.md`. @@ -643,7 +645,7 @@ type TaskState = { --- -- [x] **T8: Skill update** *(complete; branch added at top of `~/.agents/skills/copilot-cli/SKILL.md` referencing `copilot_delegate`/`copilot_output`/`copilot_cancel`. Committed on dotfiles branch `skills/copilot-cli-plugin-branch` (commit `efa3298`); dotfiles HEAD restored to `main` so local file reflects pre-merge state until T10's PR lands.)* +- [x] **T8: Skill update** _(complete; branch added at top of `~/.agents/skills/copilot-cli/SKILL.md` referencing `copilot_delegate`/`copilot_output`/`copilot_cancel`. Committed on dotfiles branch `skills/copilot-cli-plugin-branch` (commit `efa3298`); dotfiles HEAD restored to `main` so local file reflects pre-merge state until T10's PR lands.)_ **Goal:** Update `~/.agents/skills/copilot-cli/SKILL.md` to branch on plugin presence. Ships with v0.1.0. @@ -669,7 +671,7 @@ type TaskState = { --- -- [x] **T9: Publish** *(complete; v0.1.0 published to npm via manual bootstrap, GitHub Release backfilled in PR #41 โ€” see `docs/solutions/workflow-issues/bootstrap-missing-github-release-2026-04-26.md`. The plan's user-level config install step (`~/.config/opencode/opencode.json`) was substituted by a project-level entry in this repo's `opencode.json` via PR #44; the plugin loads when sessions open this repo.)* +- [x] **T9: Publish** _(complete; v0.1.0 published to npm via manual bootstrap, GitHub Release backfilled in PR #41 โ€” see `docs/solutions/workflow-issues/bootstrap-missing-github-release-2026-04-26.md`. The plan's user-level config install step (`~/.config/opencode/opencode.json`) was substituted by a project-level entry in this repo's `opencode.json` via PR #44; the plugin loads when sessions open this repo.)_ **Goal:** Publish `opencode-copilot-delegate@0.1.0` to npm, tag the release, and install in Marcus's OpenCode config. @@ -699,7 +701,7 @@ type TaskState = { --- -- [x] **T10: Dotfiles PR for skill update** *(complete; PR opened at https://github.com/marcusrbrown/.dotfiles/pull/1495. Single-file change to `.agents/skills/copilot-cli/SKILL.md`. Awaiting merge.)* +- [x] **T10: Dotfiles PR for skill update** _(complete; PR opened at https://github.com/marcusrbrown/.dotfiles/pull/1495. Single-file change to `.agents/skills/copilot-cli/SKILL.md`. Awaiting merge.)_ **Goal:** Land the `copilot-cli` skill update in the `.dotfiles` repo via a PR. @@ -726,13 +728,13 @@ type TaskState = { --- -- [x] **T11: CI / quality gates** *(complete; landed in PR #29 + PR #30. `mise.toml` pins bun, opencode, copilot; `.github/workflows/ci.yaml` runs `check` always and `integration` on PRs/dispatch.)* +- [x] **T11: CI / quality gates** _(complete; landed in PR #29 + PR #30. `mise.toml` pins bun, opencode, copilot; `.github/workflows/ci.yaml` runs `check` always and `integration` on PRs/dispatch.)_ **Goal:** Add GitHub Actions CI workflow; set branch protection on `main`. **Requirements:** R6 -**Dependencies:** T11 *(gate enables after integration tests pass)* +**Dependencies:** T11 _(gate enables after integration tests pass)_ **Files:** - Create: `.github/workflows/ci.yml` @@ -770,20 +772,20 @@ type TaskState = { ## Risks & Dependencies -| Risk | Mitigation | -|------|------------| -| Plugin restart mid-delegation โ†’ orphaned `copilot` subprocess. | Document in README. v1.x: PID file under `${XDG_RUNTIME_DIR}/opencode-copilot-delegate/.pid`; plugin start reaps stale PIDs. | -| Copilot CLI changes JSONL schema. | Defensive parsing (every field optional, unknown event types stored verbatim). Snapshot real JSONL fixtures at v0.1.0 to detect drift via tests. | -| **RESOLVED:** `promptAsync` + `noReply` API compatibility. | Confirmed: use `prompt()` (not `promptAsync()`) with `noReply` for notification injection. `promptAsync` does not accept `noReply`; `opencode-pty` reference confirms `prompt` with `noReply: true`. This is a known API constraint, not a runtime verification item. | -| Description string bloats with many user agents. | Cap merged list at 20; show "โ€ฆ and N more" + ship `copilot_list_agents` in v1.x. | -| Auth misconfiguration (stale `GH_TOKEN` overrides login) โ†’ silent failures. | Plugin logs resolved auth source via `client.app.log` at delegate start (token value never logged). | -| Built-in Copilot agent names guessed wrong. | Verify via `copilot agent list` at impl time. Hardcoded list lives in one place (`src/discovery/agents.ts`). | -| `fkill` double-kill race โ€” PID recycled between cancel decision and kill execution. | Before calling `fkill`, check whether `completionPromise` has already settled. If yes, treat cancel as a no-op (process already exited cleanly). Never call `fkill` on a settled task. | -| `completionPromise` rejection unhandled โ€” non-zero CLI exit or spawn error. | Every `await completionPromise` must be try/caught. Rejection must be converted to a typed error; never swallowed. Floating unhandled rejections crash Node โ‰ฅ15. | -| Silent notification / prompt timeout โ€” parent never sees `` (v1 requirement). | Implement per-task notification timeout (default 60s). On timeout: abort subprocess, kill via `fkill`, log warning via `client.app.log`, set `status: 'failed'` with descriptive error in envelope. No silent hangs. | -| `promptAsync` or `prompt` throws on dead/expired session. | Treat any throw from the notify call as a notification failure (see System-Wide Impact). Log, store `notifyError` in `TaskState`, do not rethrow. | -| OpenCode SDK breaking changes post-publish invalidate plugin API calls. | Pin `@opencode-ai/sdk` and `@opencode-ai/plugin` to exact versions in `package.json`. Add a compatibility matrix to README. Consider runtime version check at plugin load that warns (not crashes) if SDK version is outside tested range. | -| `fkill` flakiness on macOS vs Linux. | Use `fkill` npm package (v10.0.3, actively maintained, zero dependencies, SIGTERMโ†’SIGKILL escalation with `forceTimeout`) instead of `tree-kill` (unmaintained Dec 2019). | +| Risk | Mitigation | +| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Plugin restart mid-delegation โ†’ orphaned `copilot` subprocess. | Document in README. v1.x: PID file under `${XDG_RUNTIME_DIR}/opencode-copilot-delegate/.pid`; plugin start reaps stale PIDs. | +| Copilot CLI changes JSONL schema. | Defensive parsing (every field optional, unknown event types stored verbatim). Snapshot real JSONL fixtures at v0.1.0 to detect drift via tests. | +| **RESOLVED:** `promptAsync` + `noReply` API compatibility. | Confirmed: use `prompt()` (not `promptAsync()`) with `noReply` for notification injection. `promptAsync` does not accept `noReply`; `opencode-pty` reference confirms `prompt` with `noReply: true`. This is a known API constraint, not a runtime verification item. | +| Description string bloats with many user agents. | Cap merged list at 20; show "โ€ฆ and N more" + ship `copilot_list_agents` in v1.x. | +| Auth misconfiguration (stale `GH_TOKEN` overrides login) โ†’ silent failures. | Plugin logs resolved auth source via `client.app.log` at delegate start (token value never logged). | +| Built-in Copilot agent names guessed wrong. | Verify via `copilot agent list` at impl time. Hardcoded list lives in one place (`src/discovery/agents.ts`). | +| `fkill` double-kill race โ€” PID recycled between cancel decision and kill execution. | Before calling `fkill`, check whether `completionPromise` has already settled. If yes, treat cancel as a no-op (process already exited cleanly). Never call `fkill` on a settled task. | +| `completionPromise` rejection unhandled โ€” non-zero CLI exit or spawn error. | Every `await completionPromise` must be try/caught. Rejection must be converted to a typed error; never swallowed. Floating unhandled rejections crash Node โ‰ฅ15. | +| Silent notification / prompt timeout โ€” parent never sees `` (v1 requirement). | Implement per-task notification timeout (default 60s). On timeout: abort subprocess, kill via `fkill`, log warning via `client.app.log`, set `status: 'failed'` with descriptive error in envelope. No silent hangs. | +| `promptAsync` or `prompt` throws on dead/expired session. | Treat any throw from the notify call as a notification failure (see System-Wide Impact). Log, store `notifyError` in `TaskState`, do not rethrow. | +| OpenCode SDK breaking changes post-publish invalidate plugin API calls. | Pin `@opencode-ai/sdk` and `@opencode-ai/plugin` to exact versions in `package.json`. Add a compatibility matrix to README. Consider runtime version check at plugin load that warns (not crashes) if SDK version is outside tested range. | +| `fkill` flakiness on macOS vs Linux. | Use `fkill` npm package (v10.0.3, actively maintained, zero dependencies, SIGTERMโ†’SIGKILL escalation with `forceTimeout`) instead of `tree-kill` (unmaintained Dec 2019). | ## Documentation / Operational Notes diff --git a/docs/plans/2026-04-27-001-feat-copilot-status-tui-foundation-plan.md b/docs/plans/2026-04-27-001-feat-copilot-status-tui-foundation-plan.md index 413b8bf0..83912240 100644 --- a/docs/plans/2026-04-27-001-feat-copilot-status-tui-foundation-plan.md +++ b/docs/plans/2026-04-27-001-feat-copilot-status-tui-foundation-plan.md @@ -1,4 +1,5 @@ --- + title: "feat: /copilot-status TUI foundation" type: feat status: active @@ -34,7 +35,7 @@ From the origin brainstorm โ€” foundation-phase functional requirements (R8 move - **R5** โ€” foundation-phase keybinds in the list view: `โ†‘`/`k`, `โ†“`/`j`, `c`, `Esc`. No `Enter` / drill-in / scroll keys (those belong to the drilled-view phase). The confirm-card dialog adds `โ†`/`โ†’`/`Enter`/`Space`/`Esc` within its own surface. (origin: same โ†’ "Interaction model") - **R6** โ€” UX micro-specs: focus indicator (inverse video + `โ—` prefix on running badge), empty state, loading state, RPC error state, cancel-confirm dialog flow, list scroll behavior โ€” including footer hints (`Esc close`) on empty/error states and explicit focus behavior after a cancelled row is removed. (origin: same โ†’ "v0.2.0 UX micro-specs") - **R7** โ€” TUI plugin is opt-in via user's `tui.jsonc`; server-side keeps working without it; `/copilot-status` shows a graceful RPC-error state when the TUI half fails to load or the RPC server is unreachable. (origin: ยง"Architectural commit" + success criteria) -- **R9** โ€” Pull-from-registry event delivery: server-side exposes a list query method; this foundation phase only needs the *list* endpoint (plus health and cancel) โ€” per-task event endpoint lands with the drilled-view phase. (origin: ยง"Prerequisites for ce:plan" #4) +- **R9** โ€” Pull-from-registry event delivery: server-side exposes a list query method; this foundation phase only needs the _list_ endpoint (plus health and cancel) โ€” per-task event endpoint lands with the drilled-view phase. (origin: ยง"Prerequisites for ce:plan" #4) **R8 is in the runtime hardening plan** (see `prerequisite_plan` frontmatter). This plan assumes the orphan reaper has already shipped and the runtime parser is guarded; both are independent runtime correctness fixes that don't depend on the TUI architecture. @@ -179,7 +180,7 @@ tests/ ## High-Level Technical Design -> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* +> _This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce._ ``` โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” @@ -263,7 +264,7 @@ tests/ - Existing `package.json`'s ordering and quote style (Biome normalizes). **Test scenarios:** -- *Test expectation: none โ€” pure config + dep changes; downstream units validate via typecheck/build/lint.* +- _Test expectation: none โ€” pure config + dep changes; downstream units validate via typecheck/build/lint._ **Verification:** - `bun install` succeeds; `bun run typecheck` passes (will be re-verified after Unit 5); `bun run build` produces unchanged `dist/index.js`; `bun run lint` clean. @@ -298,13 +299,13 @@ tests/ - Existing tool-input schemas in `src/tools/*.ts` use the same Zod-then-infer pattern. **Test scenarios:** -- *Happy path:* `TasksListResponseSchema.parse({ tasks: [] })` succeeds. -- *Happy path:* Round-trip a fully-populated row through `JSON.parse(JSON.stringify(parsed))` and re-validate. -- *Edge case:* `taskId` must match `cpl_*` prefix; missing prefix fails. -- *Edge case:* `elapsedMs` non-negative integer; negative or float fails. -- *Edge case:* `PortFileSchema` requires `token` to be non-empty; missing token fails (rejects servers from older versions that lacked auth). -- *Error path:* Empty `TasksCancelRequestSchema` body fails with required-field error. -- *Error path:* Strict-mode rejection of unknown extra fields on every schema. +- _Happy path:_ `TasksListResponseSchema.parse({ tasks: [] })` succeeds. +- _Happy path:_ Round-trip a fully-populated row through `JSON.parse(JSON.stringify(parsed))` and re-validate. +- _Edge case:_ `taskId` must match `cpl_*` prefix; missing prefix fails. +- _Edge case:_ `elapsedMs` non-negative integer; negative or float fails. +- _Edge case:_ `PortFileSchema` requires `token` to be non-empty; missing token fails (rejects servers from older versions that lacked auth). +- _Error path:_ Empty `TasksCancelRequestSchema` body fails with required-field error. +- _Error path:_ Strict-mode rejection of unknown extra fields on every schema. **Verification:** - All scenarios pass via `bun test tests/rpc-contract.test.ts`. @@ -345,22 +346,22 @@ tests/ - Magic Context's `node_modules/@cortexkit/opencode-magic-context/src/server.js` for server lifecycle + port-file pattern. Adapt to TS + auth + per-session path. **Test scenarios:** -- *Happy path:* `startRpcServer` returns a port > 0 and a non-empty token; port file exists at `//server-port.json` with `mode === 0o600` (verify via `fs.stat`). -- *Happy path:* `GET /health` returns 200 with `{ ok: true, version }` and does NOT require auth. -- *Happy path:* `POST /tasks/list` with correct `Authorization: Bearer ` returns the running tasks; completed/failed tasks are filtered out (server uses `listRunning()` projection). -- *Happy path:* `POST /tasks/cancel { taskId }` with auth invokes the injected `cancelTaskById` and returns `{ cancelled: true }`. Idempotent: second call returns the same. -- *Edge case:* `POST /tasks/cancel { taskId: "cpl_unknown" }` returns 404. -- *Edge case:* Two `startRpcServer` calls with different discriminators succeed concurrently; each writes to its own port file; tokens differ; cross-token requests fail with 401. -- *Error path:* `POST /tasks/cancel` without `Authorization` header returns 401 (no `timingSafeEqual` invocation โ€” verify auth middleware short-circuits before the compare call). -- *Error path:* `POST /tasks/cancel` with `Authorization: Basic abc` (wrong scheme) returns 401. -- *Error path:* `POST /tasks/cancel` with `Authorization: Bearer` (no token value) returns 401. -- *Error path:* `POST /tasks/cancel` with `Authorization: Bearer short` (token shorter than server's) returns 401 without throwing (length-mismatch path). -- *Error path:* `POST /tasks/cancel` with `Authorization: Bearer <43-char-but-wrong-token>` returns 401 (verify `timingSafeEqual` is reached and returns false; same response time as a correctly-formed-wrong-value). -- *Error path:* `POST /tasks/cancel` with malformed JSON body returns 400. -- *Error path:* `POST /tasks/cancel` with `{ taskId: 123 }` returns 400 with Zod validation message. -- *Error path:* Request body that streams past 64KB returns 413 mid-stream (verify the server destroys the request before the full body arrives โ€” send a body slowly, observe the response fires before the final chunk). -- *Lifecycle:* `close()` stops the server, unlinks the port file; subsequent `fetch` to the old port fails with `ECONNREFUSED`. -- *Integration:* Spawn an actual `copilot` subprocess via `taskRegistry`, list it via `/tasks/list`, cancel it via `/tasks/cancel`, observe the abort-listener triggers `killProcessTree`. +- _Happy path:_ `startRpcServer` returns a port > 0 and a non-empty token; port file exists at `//server-port.json` with `mode === 0o600` (verify via `fs.stat`). +- _Happy path:_ `GET /health` returns 200 with `{ ok: true, version }` and does NOT require auth. +- _Happy path:_ `POST /tasks/list` with correct `Authorization: Bearer ` returns the running tasks; completed/failed tasks are filtered out (server uses `listRunning()` projection). +- _Happy path:_ `POST /tasks/cancel { taskId }` with auth invokes the injected `cancelTaskById` and returns `{ cancelled: true }`. Idempotent: second call returns the same. +- _Edge case:_ `POST /tasks/cancel { taskId: "cpl_unknown" }` returns 404. +- _Edge case:_ Two `startRpcServer` calls with different discriminators succeed concurrently; each writes to its own port file; tokens differ; cross-token requests fail with 401. +- _Error path:_ `POST /tasks/cancel` without `Authorization` header returns 401 (no `timingSafeEqual` invocation โ€” verify auth middleware short-circuits before the compare call). +- _Error path:_ `POST /tasks/cancel` with `Authorization: Basic abc` (wrong scheme) returns 401. +- _Error path:_ `POST /tasks/cancel` with `Authorization: Bearer` (no token value) returns 401. +- _Error path:_ `POST /tasks/cancel` with `Authorization: Bearer short` (token shorter than server's) returns 401 without throwing (length-mismatch path). +- _Error path:_ `POST /tasks/cancel` with `Authorization: Bearer <43-char-but-wrong-token>` returns 401 (verify `timingSafeEqual` is reached and returns false; same response time as a correctly-formed-wrong-value). +- _Error path:_ `POST /tasks/cancel` with malformed JSON body returns 400. +- _Error path:_ `POST /tasks/cancel` with `{ taskId: 123 }` returns 400 with Zod validation message. +- _Error path:_ Request body that streams past 64KB returns 413 mid-stream (verify the server destroys the request before the full body arrives โ€” send a body slowly, observe the response fires before the final chunk). +- _Lifecycle:_ `close()` stops the server, unlinks the port file; subsequent `fetch` to the old port fails with `ECONNREFUSED`. +- _Integration:_ Spawn an actual `copilot` subprocess via `taskRegistry`, list it via `/tasks/list`, cancel it via `/tasks/cancel`, observe the abort-listener triggers `killProcessTree`. **Verification:** - All scenarios pass; no port leaks between tests (each test uses a fresh temp dir for port file); zero zombie processes after the integration scenario. @@ -388,7 +389,7 @@ tests/ **Approach:** - `src/runtime/cancel-helper.ts`: export `cancelTaskById(taskRegistry, taskId): { cancelled: boolean, error?: string }`. Implementation: look up task; if missing, return `{ cancelled: false, error: "no such task" }`; if found, call `task.abortController.abort()` and return `{ cancelled: true }`. The existing abort listener in `subprocess.ts` already handles the kill โ€” no changes there. - Refactor `src/tools/cancel.ts` `createCancelTool` to delegate to `cancelHelper.cancelTaskById`. Behavior and return shape unchanged from the tool's perspective. -- `src/runtime/notify.ts`: add `notifySpawn(client, taskId)` next to `notifyCompletion`. Body: `await client.tui.showToast({ body: { message: \`Copilot delegation ${taskId} started\`, variant: 'info' } })`. **Important: use the `body:` wrapper** โ€” the existing `NotifyClient` API requires it. Same try/catch swallow on failure. +- `src/runtime/notify.ts`: add `notifySpawn(client, taskId)` next to `notifyCompletion`. Body: `await client.tui.showToast({ body: { message: \`Copilot delegation ${taskId} started\`, variant: 'info' } })`. **Important: use the `body:`wrapper** โ€” the existing`NotifyClient` API requires it. Same try/catch swallow on failure. - `src/tools/delegate.ts`: after `createTask` returns, before returning `{ task_id }`, call `notifySpawn(client, taskState.taskId).catch(() => {})`. Fire-and-forget. - `src/index.ts`: at plugin init, `await startRpcServer({ taskRegistry, cancelTaskById, ... })`. Store the close handle. Wire `process.on('beforeExit', () => server.close())` and `process.on('SIGTERM', () => server.close())`. Document this as a best-effort fallback; the pre-TUI hardening plan's orphan reaper covers the gap. @@ -399,13 +400,13 @@ tests/ - Existing `createDelegateTool` post-spawn flow in `src/tools/delegate.ts`. **Test scenarios:** -- *Happy path (cancel-helper):* Known running task โ†’ `task.abortController.abort()` invoked; returns `{ cancelled: true }`. -- *Edge case (cancel-helper):* Unknown task ID โ†’ returns `{ cancelled: false, error: "no such task" }` without throwing. -- *Happy path (notifySpawn):* Stub client's `tui.showToast` called once with `{ body: { message: 'Copilot delegation cpl_abc started', variant: 'info' } }`. -- *Error path (notifySpawn):* `client.tui.showToast` throws โ†’ `notifySpawn` swallows; does not propagate. -- *Integration (delegate):* `createDelegateTool` execute, on success, fires the spawn toast (verify via stub client recording the call) AND returns `{ task_id }` โ€” toast failure does not block the return. -- *Integration (cancel parity):* `createCancelTool` and the RPC handler both invoke `cancelTaskById` for the same `taskId`; observable behavior (status transition, killProcessTree call) is identical. -- *Lifecycle:* Plugin init starts the RPC server; `process.on('beforeExit', ...)` calls `server.close()` (verify via spy). +- _Happy path (cancel-helper):_ Known running task โ†’ `task.abortController.abort()` invoked; returns `{ cancelled: true }`. +- _Edge case (cancel-helper):_ Unknown task ID โ†’ returns `{ cancelled: false, error: "no such task" }` without throwing. +- _Happy path (notifySpawn):_ Stub client's `tui.showToast` called once with `{ body: { message: 'Copilot delegation cpl_abc started', variant: 'info' } }`. +- _Error path (notifySpawn):_ `client.tui.showToast` throws โ†’ `notifySpawn` swallows; does not propagate. +- _Integration (delegate):_ `createDelegateTool` execute, on success, fires the spawn toast (verify via stub client recording the call) AND returns `{ task_id }` โ€” toast failure does not block the return. +- _Integration (cancel parity):_ `createCancelTool` and the RPC handler both invoke `cancelTaskById` for the same `taskId`; observable behavior (status transition, killProcessTree call) is identical. +- _Lifecycle:_ Plugin init starts the RPC server; `process.on('beforeExit', ...)` calls `server.close()` (verify via spy). **Verification:** - All scenarios pass; existing 90+ unit tests still pass; `bun run typecheck` + `bun run lint` + `bun run build` clean. @@ -438,15 +439,15 @@ tests/ - `node_modules/@cortexkit/opencode-magic-context-tui/src/api.ts` for the RPC-client shape (without the notification poller). **Test scenarios:** -- *Happy path:* `tasksList()` against a stub HTTP server returns parsed `{ tasks: [...] }`; the request includes `Authorization: Bearer `. -- *Happy path:* `tasksCancel('cpl_x')` against a stub returning `{ cancelled: true }` resolves with `cancelled === true`. -- *Edge case:* Per-session port file does not exist โ†’ `health()` rejects with `RpcUnreachableError`. -- *Edge case:* Port file present but lacks `token` field โ†’ `RpcValidationError` (rejects servers from older versions). -- *Edge case:* Server returns 401 โ†’ `RpcAuthError` (typed distinctly from generic 5xx). -- *Edge case:* Server returns 500 โ†’ `RpcServerError` with status code. -- *Edge case:* Server returns body that fails Zod validation โ†’ `RpcValidationError` with the issues attached. -- *Lifecycle:* `dispose` aborts an in-flight `fetch`. -- *Integration:* Slash command registers with the verified field name (`trigger` or `value`). Stub the API and assert exactly one registration call. +- _Happy path:_ `tasksList()` against a stub HTTP server returns parsed `{ tasks: [...] }`; the request includes `Authorization: Bearer `. +- _Happy path:_ `tasksCancel('cpl_x')` against a stub returning `{ cancelled: true }` resolves with `cancelled === true`. +- _Edge case:_ Per-session port file does not exist โ†’ `health()` rejects with `RpcUnreachableError`. +- _Edge case:_ Port file present but lacks `token` field โ†’ `RpcValidationError` (rejects servers from older versions). +- _Edge case:_ Server returns 401 โ†’ `RpcAuthError` (typed distinctly from generic 5xx). +- _Edge case:_ Server returns 500 โ†’ `RpcServerError` with status code. +- _Edge case:_ Server returns body that fails Zod validation โ†’ `RpcValidationError` with the issues attached. +- _Lifecycle:_ `dispose` aborts an in-flight `fetch`. +- _Integration:_ Slash command registers with the verified field name (`trigger` or `value`). Stub the API and assert exactly one registration call. **Verification:** - All scenarios pass; `bun run typecheck` clean (verifies tsconfig JSX setup works); the TUI plugin loads without crashing when imported from a Node REPL via the source file. @@ -495,19 +496,19 @@ tests/ - Magic Context's TUI components for SolidJS-on-opentui idioms. **Test scenarios:** -- *Happy path:* Mount with 3 running tasks โ†’ 3 rows render; first row is focused (inverse video applied); header reads `3 running ยท 0 recent`; footer reads `โ†‘โ†“ navigate ยท c cancel ยท Esc close`. -- *Happy path:* `โ†“` keypress moves focus to row 2. -- *Happy path:* `โ†‘` from row 0 wraps to last row. -- *Happy path:* `Esc` invokes the dismiss callback. -- *Edge case:* Mount with empty registry โ†’ empty-state body rendered; header shows `0 running ยท 0 recent`; footer shows `Esc close`. -- *Edge case:* Mount + RPC resolves slowly โ†’ loading-state header `Loading delegationsโ€ฆ` visible during the gap; transitions to populated/empty when it resolves. -- *Error path:* RPC throws `RpcUnreachableError` โ†’ error-state header `Status unavailable.`; spec'd two-line body; footer `Esc close`. -- *Error path:* RPC throws `RpcAuthError` โ†’ error-state renders (treated as unreachable from the user's perspective). -- *Integration:* `c` keypress with focused running task triggers `dialog.replace` with the confirm card containing the focused row's `taskId`. -- *Integration:* After `Cancel Task` confirms via the card, the modal-list refetches; row appears with `cancelling` status; on the next refetch the row is removed and focus index is clamped. -- *Integration:* Live elapsed updates: at t=0 row reads `0s`, after a 1.1s tick reads `1s` (use fake timers). -- *Edge case:* 10 concurrent tasks (`MAX_CONCURRENT` cap) โ†’ all 10 rows render; arrow keys cycle through all 10 with wrap. -- *Edge case (last-row cancel โ†’ empty):* List has exactly 1 running task. Cancel via `c` โ†’ confirm โ†’ row reaches `cancelled` and is removed by the running-only filter โ†’ list transitions to the empty-state component (header `0 running ยท 0 recent`, empty-state body, footer `Esc close`). Verify focus index does not crash on the empty list. +- _Happy path:_ Mount with 3 running tasks โ†’ 3 rows render; first row is focused (inverse video applied); header reads `3 running ยท 0 recent`; footer reads `โ†‘โ†“ navigate ยท c cancel ยท Esc close`. +- _Happy path:_ `โ†“` keypress moves focus to row 2. +- _Happy path:_ `โ†‘` from row 0 wraps to last row. +- _Happy path:_ `Esc` invokes the dismiss callback. +- _Edge case:_ Mount with empty registry โ†’ empty-state body rendered; header shows `0 running ยท 0 recent`; footer shows `Esc close`. +- _Edge case:_ Mount + RPC resolves slowly โ†’ loading-state header `Loading delegationsโ€ฆ` visible during the gap; transitions to populated/empty when it resolves. +- _Error path:_ RPC throws `RpcUnreachableError` โ†’ error-state header `Status unavailable.`; spec'd two-line body; footer `Esc close`. +- _Error path:_ RPC throws `RpcAuthError` โ†’ error-state renders (treated as unreachable from the user's perspective). +- _Integration:_ `c` keypress with focused running task triggers `dialog.replace` with the confirm card containing the focused row's `taskId`. +- _Integration:_ After `Cancel Task` confirms via the card, the modal-list refetches; row appears with `cancelling` status; on the next refetch the row is removed and focus index is clamped. +- _Integration:_ Live elapsed updates: at t=0 row reads `0s`, after a 1.1s tick reads `1s` (use fake timers). +- _Edge case:_ 10 concurrent tasks (`MAX_CONCURRENT` cap) โ†’ all 10 rows render; arrow keys cycle through all 10 with wrap. +- _Edge case (last-row cancel โ†’ empty):_ List has exactly 1 running task. Cancel via `c` โ†’ confirm โ†’ row reaches `cancelled` and is removed by the running-only filter โ†’ list transitions to the empty-state component (header `0 running ยท 0 recent`, empty-state body, footer `Esc close`). Verify focus index does not crash on the empty list. **Verification:** - All scenarios pass; component `tsc --noEmit` clean; visual smoke (manual at implementation time) shows the modal opens via `/copilot-status` against a session with running delegations. @@ -544,14 +545,14 @@ tests/ - Magic Context's two-button dialog patterns where applicable; otherwise base on opentui's `` examples. **Test scenarios:** -- *Happy path:* Mount with `taskId='cpl_abc'` โ†’ header reads `Cancel Copilot delegation cpl_abc?`; default focus on `Cancel Task`. -- *Happy path:* `Enter` on `Cancel Task` invokes `rpc.tasksCancel('cpl_abc')` and fires `onConfirm`. -- *Happy path:* `โ†’` then `Enter` fires `onCancel` (focus moved to `Keep Running`). -- *Happy path:* `Esc` fires `onCancel` regardless of current focus. -- *Edge case (in-flight guard):* User presses `Enter` rapidly twice โ†’ `rpc.tasksCancel` invoked exactly once. -- *Error path (inline error):* `rpc.tasksCancel` throws `RpcUnreachableError` โ†’ card transitions to error state: button row replaced with error message + single `Dismiss` button; `Enter` activates `Dismiss`; `onDismissAfterError` fires. -- *Error path (inline error):* Same flow for `RpcServerError` and `RpcAuthError`. -- *Integration:* From modal-list, `c` keypress on focused row mounts the card; `Enter` confirms; `onConfirm` triggers the modal-list refetch. +- _Happy path:_ Mount with `taskId='cpl_abc'` โ†’ header reads `Cancel Copilot delegation cpl_abc?`; default focus on `Cancel Task`. +- _Happy path:_ `Enter` on `Cancel Task` invokes `rpc.tasksCancel('cpl_abc')` and fires `onConfirm`. +- _Happy path:_ `โ†’` then `Enter` fires `onCancel` (focus moved to `Keep Running`). +- _Happy path:_ `Esc` fires `onCancel` regardless of current focus. +- _Edge case (in-flight guard):_ User presses `Enter` rapidly twice โ†’ `rpc.tasksCancel` invoked exactly once. +- _Error path (inline error):_ `rpc.tasksCancel` throws `RpcUnreachableError` โ†’ card transitions to error state: button row replaced with error message + single `Dismiss` button; `Enter` activates `Dismiss`; `onDismissAfterError` fires. +- _Error path (inline error):_ Same flow for `RpcServerError` and `RpcAuthError`. +- _Integration:_ From modal-list, `c` keypress on focused row mounts the card; `Enter` confirms; `onConfirm` triggers the modal-list refetch. **Verification:** - All scenarios pass; manual smoke at implementation time: in a real session with a running delegation, `/copilot-status` โ†’ `c` on the row โ†’ `Enter` โ†’ row transitions to `cancelling` then disappears; `copilot_output cpl_xxx` confirms `cancelled` status from the server side. @@ -579,21 +580,21 @@ These updates ride with the foundation implementation PR but are not numbered un ## Risks & Dependencies -| Risk | Mitigation | -|------|------------| +| Risk | Mitigation | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@opencode-ai/plugin/tui` API undocumented beyond Magic Context. Field shape (`trigger` vs `value`) and behavior may have shifted since Magic Context's last pin | Pin the peer to a known-good version; verify the field name at implementation time (Unit 5); document the chosen field in `AGENTS.md`. Implementation-time spike if the API has materially changed. | -| `@opentui/*` is pre-1.0; SolidJS-on-opentui surface may shift | Pin to `^0.1.40` matching Magic Context's working baseline; do not auto-bump via Renovate without manual verification. | -| Raw `.tsx` export assumes OpenCode's TUI runtime compiles JSX on-the-fly | Verified by precedent (Magic Context does the same). Implementation-time gate in Unit 5: if loading fails, ship a build artifact. | -| `tsc --noEmit` may not resolve JSX element types under `jsx: "preserve"` + per-file pragma | Implementation-time gate in Unit 1: fall back to `jsx: "react-jsx"` + tsconfig `jsxImportSource` if needed. | -| `127.0.0.1` binding may fail on networks with IPv6-only loopback or strict firewall rules | Document the constraint in README. The TUI surfaces this as the spec'd RPC error state, not a crash. | -| `dialog.replace` may capture keybinds intended for the underlying list | Verified by precedent (Magic Context uses the same overlay pattern). Implementation-time gate in Unit 6/7. | -| RPC port collision with another local listener | Bind to ephemeral port (`0`); each call to `startRpcServer` gets a fresh port. Port-file race resolved by per-session path. | -| Cross-session token theft if port-file 0o600 perms regress | Test asserts `mode === 0o600` after write (Unit 3). Auth token defense-in-depth: even if perms regress, an attacker still needs the token to make calls. | -| Spawn-toast queue saturation at 10 concurrent delegations | Unverified; OpenCode's toast implementation may rate-limit. Implementation-time observation: if toasts drop, batch via a debounce. | -| Transitive dep conflicts from `@opentui/*` + `solid-js` | Verify at `bun add` time (Unit 1). If conflicts, escalate before downstream units begin. | -| Cancel-from-UI race with stdout buffer (post-kill JSONL events appended to registry) | Closed by the parser-status guard in the runtime hardening plan; this plan assumes that's already shipped. | -| Modal interferes with active OpenCode work | Modal does not block input to the underlying session; Esc returns instantly. | -| OpenCode `Hooks` interface lacks dispose; cleanup is best-effort | `process.on('beforeExit'/'SIGTERM')` covers normal exits. Reaper covers SIGKILL leftovers. Upstream-issue follow-up to retire the workaround when a dispose hook lands. | +| `@opentui/*` is pre-1.0; SolidJS-on-opentui surface may shift | Pin to `^0.1.40` matching Magic Context's working baseline; do not auto-bump via Renovate without manual verification. | +| Raw `.tsx` export assumes OpenCode's TUI runtime compiles JSX on-the-fly | Verified by precedent (Magic Context does the same). Implementation-time gate in Unit 5: if loading fails, ship a build artifact. | +| `tsc --noEmit` may not resolve JSX element types under `jsx: "preserve"` + per-file pragma | Implementation-time gate in Unit 1: fall back to `jsx: "react-jsx"` + tsconfig `jsxImportSource` if needed. | +| `127.0.0.1` binding may fail on networks with IPv6-only loopback or strict firewall rules | Document the constraint in README. The TUI surfaces this as the spec'd RPC error state, not a crash. | +| `dialog.replace` may capture keybinds intended for the underlying list | Verified by precedent (Magic Context uses the same overlay pattern). Implementation-time gate in Unit 6/7. | +| RPC port collision with another local listener | Bind to ephemeral port (`0`); each call to `startRpcServer` gets a fresh port. Port-file race resolved by per-session path. | +| Cross-session token theft if port-file 0o600 perms regress | Test asserts `mode === 0o600` after write (Unit 3). Auth token defense-in-depth: even if perms regress, an attacker still needs the token to make calls. | +| Spawn-toast queue saturation at 10 concurrent delegations | Unverified; OpenCode's toast implementation may rate-limit. Implementation-time observation: if toasts drop, batch via a debounce. | +| Transitive dep conflicts from `@opentui/*` + `solid-js` | Verify at `bun add` time (Unit 1). If conflicts, escalate before downstream units begin. | +| Cancel-from-UI race with stdout buffer (post-kill JSONL events appended to registry) | Closed by the parser-status guard in the runtime hardening plan; this plan assumes that's already shipped. | +| Modal interferes with active OpenCode work | Modal does not block input to the underlying session; Esc returns instantly. | +| OpenCode `Hooks` interface lacks dispose; cleanup is best-effort | `process.on('beforeExit'/'SIGTERM')` covers normal exits. Reaper covers SIGKILL leftovers. Upstream-issue follow-up to retire the workaround when a dispose hook lands. | ## Documentation / Operational Notes diff --git a/docs/plans/2026-04-27-002-fix-runtime-hardening-plan.md b/docs/plans/2026-04-27-002-fix-runtime-hardening-plan.md index 5cdd6ab8..94ee145b 100644 --- a/docs/plans/2026-04-27-002-fix-runtime-hardening-plan.md +++ b/docs/plans/2026-04-27-002-fix-runtime-hardening-plan.md @@ -1,4 +1,5 @@ --- + title: "fix: runtime hardening (orphan reaper + cancel-race parser guard)" type: fix status: completed @@ -71,7 +72,7 @@ Both bugs predate the TUI work. They were identified during planning for that pl - **Alive** (call succeeds) or **`EPERM`** (process exists but we lack signal permission โ€” still counts as alive): skip the entire file. The owning plugin is still tracking those subprocesses; killing them would break a running session. The identity gate alone is insufficient for foreign files: a live foreign instance's subprocesses would pass the `comm`+`lstart` gate (they ARE legitimate copilot subprocesses) and be wrongly killed. - **Dead** (`ESRCH`): proceed with per-entry reap. After processing, **delete the file** โ€” the owning plugin is gone, and bounded directory growth is the desirable hygiene property here. - **Current instance's own file** (filename matches `String(process.pid)`): processed unconditionally; truncated/recreated post-reap. We know our own plugin is alive; we want to clean entries from subprocesses that died while the plugin was loaded but before reap fired. -- **Parser guard placement**: In `spawnCopilot`, at the top of the existing inline `child.stdout?.on('data', (chunk: string) => { ... })` handler. Inside the `for (const line of lines)` loop, the guard goes **before** `parseJsonlLine(line)` (not before `events.push(event)`) so a cancelled task does not pay the parse cost on already-buffered lines: `if (task.status !== 'running') break;`. The partial-line buffer update (`lines.pop()`) runs *before* the loop, so `break` and `return` would behave identically here โ€” `break` is preferred for clarity. Single-threaded JS guarantees the guard is checked synchronously with every iteration, with no interleaving possible. +- **Parser guard placement**: In `spawnCopilot`, at the top of the existing inline `child.stdout?.on('data', (chunk: string) => { ... })` handler. Inside the `for (const line of lines)` loop, the guard goes **before** `parseJsonlLine(line)` (not before `events.push(event)`) so a cancelled task does not pay the parse cost on already-buffered lines: `if (task.status !== 'running') break;`. The partial-line buffer update (`lines.pop()`) runs _before_ the loop, so `break` and `return` would behave identically here โ€” `break` is preferred for clarity. Single-threaded JS guarantees the guard is checked synchronously with every iteration, with no interleaving possible. - **Status-mutation refactor (terminal-state-preserving)**: The current `src/runtime/subprocess.ts` mutates `task.status` inline at three sites โ€” line 59 (`finalizeTask`, on subprocess `close` event), line 143 (abort listener), and line 153 (spawn `error` event). This plan introduces a `setStatus(task, newStatus, options?)` helper that wraps the assignment and the `removePidEntry` cleanup as a single atomic-from-the-caller's-perspective operation. **The helper is idempotent on terminal state**: if `task.status` is already terminal (`complete | failed | cancelled`) and `newStatus` is also terminal, `setStatus` no-ops and returns. This preserves the existing `finalizeTask` invariant `if (task.status !== 'cancelled') task.status = ...` automatically: when the subprocess `close` event fires after the abort listener has already set `cancelled`, `setStatus(task, 'complete' | 'failed', ...)` is a no-op. Future contributors don't need to remember the guard. The three sites become `setStatus(task, 'complete' | 'failed' | 'cancelled', { pidFilePath })` calls. Centralizes lifecycle plumbing, prevents the cleanup hook from being forgotten, and gives Unit 4's parser guard a stable invariant to read. ## Open Questions @@ -141,15 +142,15 @@ tests/ **Execution note:** Test-first per AGENTS.md mandate. **Test scenarios:** -- *Happy path:* `appendPidEntry` to a fresh path โ†’ file exists with one tab-separated `\t\t` line; mode is `0o600`; parent dir mode is `0o700`. -- *Happy path:* `appendPidEntry` 3 times sequentially โ†’ file has 3 lines in spawn order. -- *Happy path:* `removePidEntry(pid)` on a file with 3 entries removes only the matching one; remaining order preserved. -- *Edge case:* `appendPidEntry` to a non-existent parent dir โ†’ dir created with `0o700`; file written. -- *Edge case:* `removePidEntry` for a PID not in the file โ†’ no-op, no error. -- *Edge case:* `removePidEntry` on a missing file โ†’ no-op, no error. -- *Concurrency:* `appendPidEntry` invoked 10x concurrently โ†’ file ends with **exactly 10 entries**, all well-formed (the in-process `Promise`-chain serialization queue orders the writes so no append is lost). This is a stronger guarantee than the v0.1 plan's 1..N expectation โ€” verified test scenario. -- *Path resolution:* `resolveInstancePidFilePath()` returns `/orphans/.pids` regardless of environment (plugin PID is the only key). -- *Mode:* After both append and remove, file mode remains `0o600`. +- _Happy path:_ `appendPidEntry` to a fresh path โ†’ file exists with one tab-separated `\t\t` line; mode is `0o600`; parent dir mode is `0o700`. +- _Happy path:_ `appendPidEntry` 3 times sequentially โ†’ file has 3 lines in spawn order. +- _Happy path:_ `removePidEntry(pid)` on a file with 3 entries removes only the matching one; remaining order preserved. +- _Edge case:_ `appendPidEntry` to a non-existent parent dir โ†’ dir created with `0o700`; file written. +- _Edge case:_ `removePidEntry` for a PID not in the file โ†’ no-op, no error. +- _Edge case:_ `removePidEntry` on a missing file โ†’ no-op, no error. +- _Concurrency:_ `appendPidEntry` invoked 10x concurrently โ†’ file ends with **exactly 10 entries**, all well-formed (the in-process `Promise`-chain serialization queue orders the writes so no append is lost). This is a stronger guarantee than the v0.1 plan's 1..N expectation โ€” verified test scenario. +- _Path resolution:_ `resolveInstancePidFilePath()` returns `/orphans/.pids` regardless of environment (plugin PID is the only key). +- _Mode:_ After both append and remove, file mode remains `0o600`. **Verification:** All scenarios pass; `bun run typecheck` + `bun run lint` clean. @@ -191,25 +192,25 @@ tests/ **Execution note:** Test-first per AGENTS.md mandate. **Test scenarios:** -- *Happy path:* Empty `pidFileDir` โ†’ `{ reaped: 0, skipped: 0, scannedFiles: 0, deletedFiles: 0 }`; no error. -- *Happy path:* Current instance's file with one alive PID whose live `comm` and `lstart` match recorded โ†’ `killProcessTree` called once; `{ reaped: 1, skipped: 0, scannedFiles: 1, deletedFiles: 0 }`; current-instance file truncated. -- *Happy path (foreign file, dead spawner):* `/.pids` (current, empty) + `/9999.pids` (foreign, plugin PID 9999 dead per `isPluginAlive`, 1 entry: alive matching child). Reap kills the orphaned child; foreign file is **deleted** post-reap; `{ reaped: 1, skipped: 0, scannedFiles: 2, deletedFiles: 1 }`. -- *Happy path (foreign file, live spawner):* `/.pids` + `/8888.pids` (foreign, plugin PID 8888 alive per `isPluginAlive`, 2 entries with live matching children). Reap **skips** the foreign file entirely; `killProcessTree` NOT called for those children; foreign file left intact; `{ reaped: 0, skipped: 0, scannedFiles: 2, deletedFiles: 0 }` (foreign-skip does not count as scanned-and-processed). -- *Edge case (foreign file, EPERM on probe):* `process.kill(, 0)` throws `EPERM` (process exists, we lack permission) โ†’ treated as alive; foreign file skipped. -- *Edge case (foreign filename non-numeric):* `/garbage.pids` โ†’ skipped + logged warning; not deleted, not processed. -- *Edge case (foreign file, dead spawner, all entries dead):* foreign file with 3 entries all `process.kill(pid, 0)` throwing โ†’ no kills; file deleted post-reap. -- *Edge case (foreign file, dead spawner, mismatched identity):* foreign file with alive PID whose `comm` differs โ†’ not killed (identity gate fail); skipped; file deleted (all entries processed, dead spawner). -- *Edge case (mixed):* current file (1 alive-match, 1 dead) + foreign-alive file (skipped) + foreign-dead file (1 alive-match killed) โ†’ `{ reaped: 2, skipped: 0, scannedFiles: 3, deletedFiles: 1 }`. -- *Edge case (PID dead):* `process.kill(pid, 0)` throws โ†’ not killed; counted as skipped; current file truncated post-reap. -- *Edge case (`comm` mismatch):* Alive PID, but live `comm` differs from recorded โ†’ not killed (PID reuse or unrelated process); skipped + warned. -- *Edge case (`lstart` mismatch):* Alive PID, matching `comm`, but live `lstart` differs from recorded โ†’ not killed (PID reuse); skipped + warned. -- *Edge case (`args` substring red herring):* Alive PID whose `args` *contains* `copilot` (e.g., `cat /tmp/copilot-output.log`) but whose `comm` is `cat`, not `copilot` โ†’ not killed (defends the false-positive class). Verified by spawning a stub process with a tailored `argv[0]`/comm distinction. -- *Concurrency:* 5 worker-pool cap respected: at 10 PIDs, no more than 5 concurrent `ps` shellouts in flight at any moment (assert via spy on the `ps` shellout counter). -- *Latency:* Total reap with 10 alive-but-skipped entries completes in under 200ms on the dev host (soft target; not a hard assertion). -- *Error path:* Malformed line โ†’ skipped silently; valid lines still processed. -- *Error path:* `killProcessTree` throws (injected) โ†’ caught, counted as skipped; loop continues across remaining entries. -- *Error path:* `getPidStartTime` returns `null` for an alive PID (race) โ†’ skipped, no kill. -- *Error path:* `fs.readdir(pidFileDir)` throws (dir missing) โ†’ returns `{ reaped: 0, skipped: 0, scannedFiles: 0 }`; no error propagation. +- _Happy path:_ Empty `pidFileDir` โ†’ `{ reaped: 0, skipped: 0, scannedFiles: 0, deletedFiles: 0 }`; no error. +- _Happy path:_ Current instance's file with one alive PID whose live `comm` and `lstart` match recorded โ†’ `killProcessTree` called once; `{ reaped: 1, skipped: 0, scannedFiles: 1, deletedFiles: 0 }`; current-instance file truncated. +- _Happy path (foreign file, dead spawner):_ `/.pids` (current, empty) + `/9999.pids` (foreign, plugin PID 9999 dead per `isPluginAlive`, 1 entry: alive matching child). Reap kills the orphaned child; foreign file is **deleted** post-reap; `{ reaped: 1, skipped: 0, scannedFiles: 2, deletedFiles: 1 }`. +- _Happy path (foreign file, live spawner):_ `/.pids` + `/8888.pids` (foreign, plugin PID 8888 alive per `isPluginAlive`, 2 entries with live matching children). Reap **skips** the foreign file entirely; `killProcessTree` NOT called for those children; foreign file left intact; `{ reaped: 0, skipped: 0, scannedFiles: 2, deletedFiles: 0 }` (foreign-skip does not count as scanned-and-processed). +- _Edge case (foreign file, EPERM on probe):_ `process.kill(, 0)` throws `EPERM` (process exists, we lack permission) โ†’ treated as alive; foreign file skipped. +- _Edge case (foreign filename non-numeric):_ `/garbage.pids` โ†’ skipped + logged warning; not deleted, not processed. +- _Edge case (foreign file, dead spawner, all entries dead):_ foreign file with 3 entries all `process.kill(pid, 0)` throwing โ†’ no kills; file deleted post-reap. +- _Edge case (foreign file, dead spawner, mismatched identity):_ foreign file with alive PID whose `comm` differs โ†’ not killed (identity gate fail); skipped; file deleted (all entries processed, dead spawner). +- _Edge case (mixed):_ current file (1 alive-match, 1 dead) + foreign-alive file (skipped) + foreign-dead file (1 alive-match killed) โ†’ `{ reaped: 2, skipped: 0, scannedFiles: 3, deletedFiles: 1 }`. +- _Edge case (PID dead):_ `process.kill(pid, 0)` throws โ†’ not killed; counted as skipped; current file truncated post-reap. +- _Edge case (`comm` mismatch):_ Alive PID, but live `comm` differs from recorded โ†’ not killed (PID reuse or unrelated process); skipped + warned. +- _Edge case (`lstart` mismatch):_ Alive PID, matching `comm`, but live `lstart` differs from recorded โ†’ not killed (PID reuse); skipped + warned. +- _Edge case (`args` substring red herring):_ Alive PID whose `args` _contains_ `copilot` (e.g., `cat /tmp/copilot-output.log`) but whose `comm` is `cat`, not `copilot` โ†’ not killed (defends the false-positive class). Verified by spawning a stub process with a tailored `argv[0]`/comm distinction. +- _Concurrency:_ 5 worker-pool cap respected: at 10 PIDs, no more than 5 concurrent `ps` shellouts in flight at any moment (assert via spy on the `ps` shellout counter). +- _Latency:_ Total reap with 10 alive-but-skipped entries completes in under 200ms on the dev host (soft target; not a hard assertion). +- _Error path:_ Malformed line โ†’ skipped silently; valid lines still processed. +- _Error path:_ `killProcessTree` throws (injected) โ†’ caught, counted as skipped; loop continues across remaining entries. +- _Error path:_ `getPidStartTime` returns `null` for an alive PID (race) โ†’ skipped, no kill. +- _Error path:_ `fs.readdir(pidFileDir)` throws (dir missing) โ†’ returns `{ reaped: 0, skipped: 0, scannedFiles: 0 }`; no error propagation. **Verification:** All scenarios pass; manual smoke on macOS dev host: spawn `sleep 60`, write its PID + recorded start time, simulate plugin re-init, confirm sleep is killed. Smoke on Linux dev host: same. @@ -244,6 +245,7 @@ tests/ - Line 153 (`child.once('error', ...)`): `task.status = 'failed'` becomes `setStatus(task, 'failed', { pidFilePath })`. - Path injection: `pidFilePath` is constructed once in `src/index.ts` via `resolveInstancePidFilePath()` (Unit 1) and passed through to the registry / subprocess wiring. - In `src/index.ts`, before the plugin factory returns its `Hooks` object: + ``` const pidFileDir = path.dirname(resolveInstancePidFilePath()) const currentInstancePath = resolveInstancePidFilePath() @@ -255,25 +257,26 @@ tests/ } return { ...hooks } ``` + (Pseudo-code only; actual import/path style follows existing code conventions.) **Execution note:** Test-first per AGENTS.md mandate. **Test scenarios:** -- *Unit (`setStatus`):* `setStatus(task, 'complete', { pidFilePath })` from running state โ†’ `task.status === 'complete'`; `removePidEntry` invoked with `task.pid`. -- *Unit (`setStatus` terminal-state guard):* `task.status === 'cancelled'`; call `setStatus(task, 'complete', { pidFilePath })` โ†’ `task.status` remains `'cancelled'`; `removePidEntry` NOT invoked. **This is the exact invariant the existing `finalizeTask` guard protects** โ€” directly verifies the cancel survives the close-after-abort sequence. -- *Unit (`setStatus` terminal-state guard):* `task.status === 'failed'`; call `setStatus(task, 'cancelled')` โ†’ `task.status` remains `'failed'` (terminal-state idempotent in both directions). -- *Unit (`setStatus`):* `setStatus(task, 'running')` (non-terminal new status) โ†’ `task.status === 'running'`; `removePidEntry` NOT invoked. -- *Unit (`setStatus`):* `setStatus(task, 'failed')` without `options` โ†’ status set from running; `removePidEntry` NOT invoked (path optional). -- *Unit (`setStatus`):* `removePidEntry` rejection is swallowed; no propagation. -- *Integration:* Spawn a task via `createTask` โ†’ PID file contains one entry with `\t\t`; `comm` matches the spawned binary's name. -- *Integration:* Task `close` (exit 0) โ†’ `setStatus(task, 'complete')` called from line 59; PID file no longer contains the entry. -- *Integration:* Abort signal โ†’ `setStatus(task, 'cancelled')` called from line 143; PID file no longer contains the entry. -- *Integration:* Spawn `error` โ†’ `setStatus(task, 'failed')` called from line 153; PID file no longer contains the entry. -- *Integration (cancel-then-close ordering):* Abort signal fires first (line 143 โ†’ `setStatus` sets `cancelled`); subsequent subprocess `close` event fires (line 59 โ†’ `setStatus(task, 'complete' | 'failed', ...)`); final `task.status` is `'cancelled'`, not `'complete'`/`'failed'`. Verifies the terminal-state guard end-to-end at the call sites, not just at the helper level. PID file removal happens once (on the cancellation), idempotent on the `close` event. -- *Edge case:* PID-file write throws (injected mock) โ†’ task creation still succeeds; status transition still happens; no exception propagates. -- *Lifecycle:* Plugin init invokes `reapOrphans` exactly once before returning Hooks; reap throw is caught. -- *Lifecycle:* `pidFileDir` is created with `mode: 0o700` if it doesn't exist. +- _Unit (`setStatus`):_ `setStatus(task, 'complete', { pidFilePath })` from running state โ†’ `task.status === 'complete'`; `removePidEntry` invoked with `task.pid`. +- _Unit (`setStatus` terminal-state guard):_ `task.status === 'cancelled'`; call `setStatus(task, 'complete', { pidFilePath })` โ†’ `task.status` remains `'cancelled'`; `removePidEntry` NOT invoked. **This is the exact invariant the existing `finalizeTask` guard protects** โ€” directly verifies the cancel survives the close-after-abort sequence. +- _Unit (`setStatus` terminal-state guard):_ `task.status === 'failed'`; call `setStatus(task, 'cancelled')` โ†’ `task.status` remains `'failed'` (terminal-state idempotent in both directions). +- _Unit (`setStatus`):_ `setStatus(task, 'running')` (non-terminal new status) โ†’ `task.status === 'running'`; `removePidEntry` NOT invoked. +- _Unit (`setStatus`):_ `setStatus(task, 'failed')` without `options` โ†’ status set from running; `removePidEntry` NOT invoked (path optional). +- _Unit (`setStatus`):_ `removePidEntry` rejection is swallowed; no propagation. +- _Integration:_ Spawn a task via `createTask` โ†’ PID file contains one entry with `\t\t`; `comm` matches the spawned binary's name. +- _Integration:_ Task `close` (exit 0) โ†’ `setStatus(task, 'complete')` called from line 59; PID file no longer contains the entry. +- _Integration:_ Abort signal โ†’ `setStatus(task, 'cancelled')` called from line 143; PID file no longer contains the entry. +- _Integration:_ Spawn `error` โ†’ `setStatus(task, 'failed')` called from line 153; PID file no longer contains the entry. +- _Integration (cancel-then-close ordering):_ Abort signal fires first (line 143 โ†’ `setStatus` sets `cancelled`); subsequent subprocess `close` event fires (line 59 โ†’ `setStatus(task, 'complete' | 'failed', ...)`); final `task.status` is `'cancelled'`, not `'complete'`/`'failed'`. Verifies the terminal-state guard end-to-end at the call sites, not just at the helper level. PID file removal happens once (on the cancellation), idempotent on the `close` event. +- _Edge case:_ PID-file write throws (injected mock) โ†’ task creation still succeeds; status transition still happens; no exception propagates. +- _Lifecycle:_ Plugin init invokes `reapOrphans` exactly once before returning Hooks; reap throw is caught. +- _Lifecycle:_ `pidFileDir` is created with `mode: 0o700` if it doesn't exist. **Verification:** All scenarios pass; existing task-registry and subprocess tests still pass. @@ -293,16 +296,16 @@ tests/ **Approach:** - Locate the existing `child.stdout?.on('data', (chunk: string) => { ... })` callback inside `spawnCopilot`. Inside the `for (const line of lines)` loop, place the guard **before** `parseJsonlLine(line)` (not before `events.push`) so a cancelled task does not pay the parse cost on buffered lines: `if (task.status !== 'running') break;`. -- The partial-line buffer update (`lines.pop()`) runs *before* the loop body, so `break` and `return` are equivalent at this point โ€” `break` is the natural choice for an in-loop early-exit. +- The partial-line buffer update (`lines.pop()`) runs _before_ the loop body, so `break` and `return` are equivalent at this point โ€” `break` is the natural choice for an in-loop early-exit. - Single-threaded JS guarantees: the guard is checked synchronously with every iteration; once `task.status` flips, no subsequent loop iteration in this or future `data` ticks will parse or append. - This is a 1-2 line change; the test scenario carries the weight. **Execution note:** Test-first per AGENTS.md mandate. **Test scenarios:** -- *Edge case (cancel race):* Spawn a fake subprocess that emits 5 JSONL lines slowly. After line 2 is parsed, set `task.status = 'cancelled'` (simulating the abort listener). Allow the remaining 3 lines to drain through the parser. Assert: `task.events.length === 2`; the 3 post-cancel events are NOT appended. -- *Happy path (no regression):* Without cancel, all 5 events parse and append normally. -- *Edge case (cancel before any events):* Set `status = 'cancelled'` before the first `data` callback fires. Assert: `task.events.length === 0`. +- _Edge case (cancel race):_ Spawn a fake subprocess that emits 5 JSONL lines slowly. After line 2 is parsed, set `task.status = 'cancelled'` (simulating the abort listener). Allow the remaining 3 lines to drain through the parser. Assert: `task.events.length === 2`; the 3 post-cancel events are NOT appended. +- _Happy path (no regression):_ Without cancel, all 5 events parse and append normally. +- _Edge case (cancel before any events):_ Set `status = 'cancelled'` before the first `data` callback fires. Assert: `task.events.length === 0`. **Verification:** All scenarios pass; existing `subprocess.test.ts` scenarios still pass; no regressions in `tests/tools.test.ts`. @@ -325,17 +328,17 @@ tests/ ## Risks & Dependencies -| Risk | Mitigation | -|------|------------| -| `ps -p -o {comm,lstart}=` portability across BSD vs GNU `ps`, including minimal images (Alpine busybox) | Implementation-time smoke test on dev hosts. `comm` and `lstart` are both widely supported on mainstream macOS / Linux. If unavailable, the reaper logs and skips โ€” does not crash. Document POSIX `ps` dependency in README. | -| PID file write fails on disk-full or signal mid-rename | Atomic rename ensures no partial reads. Worst case: missed-remove โ†’ extra reap attempt of an already-dead PID, which is tested and idempotent. | -| Malicious PID file injection on shared machines | File perms `0o600` + `0o700` parent dir as primary defense. Identity gate combines exact `comm` match AND exact `lstart` match AND PID liveness โ€” forging requires write access to the protected file. | -| Parser guard's `break` placement subtle (must not skip the partial-line buffer update) | Implementation reads the actual handler structure first; the `lines.pop()` partial-line buffer update happens *before* the loop body, so `break` and `return` are equivalent. Test scenario asserts post-cancel events are not appended and the buffer is preserved. | -| Reap blocks plugin init at `MAX_CONCURRENT = 10` historical entries | Parallel per-PID probes with a worker-pool concurrency cap of 5. Each PID's check is two `ps` shellouts (`comm` + `lstart`) plus a liveness probe. Soft target: total reap time under 200ms on a typical dev host. Verified by Unit 2's latency test scenario. | -| Cross-instance file clobbering (shared single PID file would silently drop live PIDs across concurrent OpenCode sessions) | **Resolved by per-instance file design**: each instance writes to `/orphans/.pids` and only truncates its own file. Reap reads the entire directory so foreign-instance survivors are still recovered. | -| Cross-instance kill of a live foreign instance's child (identity gate alone is insufficient โ€” a live foreign instance's subprocesses pass `comm`+`lstart` match) | **Resolved by spawner-liveness gate**: each foreign file's owning plugin PID is parsed from the filename and probed via `process.kill(, 0)`. Alive (or `EPERM`) โ†’ skip the entire file. Dead โ†’ reap entries and delete the file. | -| `/orphans/` directory grows without bound across many sessions | **Resolved by post-reap deletion**: after a successful reap of a foreign file (dead spawner), the file itself is deleted. Long-lived dev hosts see one transient file per active plugin instance, not historical accumulation. | -| `setStatus` refactor introduces regression in existing 3 status-mutation sites | Three sites are explicit and enumerated by line number (subprocess.ts:59, :143, :153). Helper enforces terminal-state idempotency (preserves `cancelled` if `close` fires after abort) so the existing `finalizeTask` `if (task.status !== 'cancelled')` invariant is preserved automatically rather than being re-implemented at the call site. Test scenarios per site verify both the status transition and the cleanup hook fire; an explicit `cancelled โ†’ complete` no-op test directly verifies the invariant. Existing subprocess tests continue passing as a regression gate. | +| Risk | Mitigation | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ps -p -o {comm,lstart}=` portability across BSD vs GNU `ps`, including minimal images (Alpine busybox) | Implementation-time smoke test on dev hosts. `comm` and `lstart` are both widely supported on mainstream macOS / Linux. If unavailable, the reaper logs and skips โ€” does not crash. Document POSIX `ps` dependency in README. | +| PID file write fails on disk-full or signal mid-rename | Atomic rename ensures no partial reads. Worst case: missed-remove โ†’ extra reap attempt of an already-dead PID, which is tested and idempotent. | +| Malicious PID file injection on shared machines | File perms `0o600` + `0o700` parent dir as primary defense. Identity gate combines exact `comm` match AND exact `lstart` match AND PID liveness โ€” forging requires write access to the protected file. | +| Parser guard's `break` placement subtle (must not skip the partial-line buffer update) | Implementation reads the actual handler structure first; the `lines.pop()` partial-line buffer update happens _before_ the loop body, so `break` and `return` are equivalent. Test scenario asserts post-cancel events are not appended and the buffer is preserved. | +| Reap blocks plugin init at `MAX_CONCURRENT = 10` historical entries | Parallel per-PID probes with a worker-pool concurrency cap of 5. Each PID's check is two `ps` shellouts (`comm` + `lstart`) plus a liveness probe. Soft target: total reap time under 200ms on a typical dev host. Verified by Unit 2's latency test scenario. | +| Cross-instance file clobbering (shared single PID file would silently drop live PIDs across concurrent OpenCode sessions) | **Resolved by per-instance file design**: each instance writes to `/orphans/.pids` and only truncates its own file. Reap reads the entire directory so foreign-instance survivors are still recovered. | +| Cross-instance kill of a live foreign instance's child (identity gate alone is insufficient โ€” a live foreign instance's subprocesses pass `comm`+`lstart` match) | **Resolved by spawner-liveness gate**: each foreign file's owning plugin PID is parsed from the filename and probed via `process.kill(, 0)`. Alive (or `EPERM`) โ†’ skip the entire file. Dead โ†’ reap entries and delete the file. | +| `/orphans/` directory grows without bound across many sessions | **Resolved by post-reap deletion**: after a successful reap of a foreign file (dead spawner), the file itself is deleted. Long-lived dev hosts see one transient file per active plugin instance, not historical accumulation. | +| `setStatus` refactor introduces regression in existing 3 status-mutation sites | Three sites are explicit and enumerated by line number (subprocess.ts:59, :143, :153). Helper enforces terminal-state idempotency (preserves `cancelled` if `close` fires after abort) so the existing `finalizeTask` `if (task.status !== 'cancelled')` invariant is preserved automatically rather than being re-implemented at the call site. Test scenarios per site verify both the status transition and the cleanup hook fire; an explicit `cancelled โ†’ complete` no-op test directly verifies the invariant. Existing subprocess tests continue passing as a regression gate. | ## Documentation / Operational Notes diff --git a/docs/plans/2026-05-02-001-feat-copilot-session-continuity-resume-connect-plan.md b/docs/plans/2026-05-02-001-feat-copilot-session-continuity-resume-connect-plan.md index a35f6ef8..1dd19df8 100644 --- a/docs/plans/2026-05-02-001-feat-copilot-session-continuity-resume-connect-plan.md +++ b/docs/plans/2026-05-02-001-feat-copilot-session-continuity-resume-connect-plan.md @@ -1,4 +1,5 @@ --- + title: Copilot session continuity โ€” resume + connect type: feat status: active @@ -30,21 +31,21 @@ Both `--connect= --no-remote` (the original plan argv) and `--connect=` **Read each subsequent section with this lens:** -| Section | Treatment under the amendment | -|---|---| -| Requirements R1, R2, R6, R8, R10, R11, R12, R13, R14 | Where they say "and connect", read as "(connect deferred)". R9 connect-specific clauses become moot (no validation strategy needed for a tool we don't ship). | -| Key Technical Decisions: "Two dedicated tools, not one parameterized" | Read as "one new tool (`copilot_resume`); `copilot_connect` deferred to follow-up slice once an upstream signal exists". | -| Key Technical Decisions: "`--connect` validation timing is empirical-first" | Empirically determined: no validation strategy works; connect deferred. The decision itself was correct (empirical-first), the outcome was deferral. | -| Key Technical Decisions: "Borrowed sessions are un-reapable" | Type-level discriminator stays for forward compat; no connect-origin tasks are constructed, so the rule has no current consumer. | -| Unit 1 | Implement as written: `origin` discriminator includes `'connect'` for forward compatibility. `psIdentity` field stays in `TaskState` for the same reason. | -| Unit 2 | Implement resume + spawn paths as written. Connect-specific branches (identity gate in cancel, confirm-card connect variant, `notifySpawn` connect warning, `disconnected` cancel response) are NOT implemented in this slice. | -| Unit 3a | Already complete (PR #94). No further capture work needed. Skip in `ce:work`. | -| Unit 3b | Implement as written; pre-flight helpers serve resume only. | -| Unit 3c | Implement `copilot_resume` only; do NOT create `src/tools/connect.ts` or `tests/connect.test.ts`. Tool catalog assertion in `tests/tools.test.ts` extends from 3 to 4, not 5. | -| Unit 4 | Implement `/tasks/resume` + `/tasks/recent-sessions` only; do NOT add `/tasks/connect` route or `TasksConnectRequestSchema`. | -| Unit 5 | Implement modal-list focus model + Recent Sessions view + raw-ID input form for resume only. Drop the `C` keyboard hint and the connect-mode form variant; confirm-card stays at one variant (existing cancel copy). | -| Unit 6 | Document resume only. Update tool catalog count to 4. Add a brief "Connect deferred" subsection in the AGENTS.md `Borrowed sessions` block linking to PR #94 and this amendment. | -| System-Wide Impact / Risks / Security & Input Validation | Drop connect-specific rows; the validation framework applies to resume identically. The borrowed-process risk row becomes vestigial (no borrowed processes constructed). | +| Section | Treatment under the amendment | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Requirements R1, R2, R6, R8, R10, R11, R12, R13, R14 | Where they say "and connect", read as "(connect deferred)". R9 connect-specific clauses become moot (no validation strategy needed for a tool we don't ship). | +| Key Technical Decisions: "Two dedicated tools, not one parameterized" | Read as "one new tool (`copilot_resume`); `copilot_connect` deferred to follow-up slice once an upstream signal exists". | +| Key Technical Decisions: "`--connect` validation timing is empirical-first" | Empirically determined: no validation strategy works; connect deferred. The decision itself was correct (empirical-first), the outcome was deferral. | +| Key Technical Decisions: "Borrowed sessions are un-reapable" | Type-level discriminator stays for forward compat; no connect-origin tasks are constructed, so the rule has no current consumer. | +| Unit 1 | Implement as written: `origin` discriminator includes `'connect'` for forward compatibility. `psIdentity` field stays in `TaskState` for the same reason. | +| Unit 2 | Implement resume + spawn paths as written. Connect-specific branches (identity gate in cancel, confirm-card connect variant, `notifySpawn` connect warning, `disconnected` cancel response) are NOT implemented in this slice. | +| Unit 3a | Already complete (PR #94). No further capture work needed. Skip in `ce:work`. | +| Unit 3b | Implement as written; pre-flight helpers serve resume only. | +| Unit 3c | Implement `copilot_resume` only; do NOT create `src/tools/connect.ts` or `tests/connect.test.ts`. Tool catalog assertion in `tests/tools.test.ts` extends from 3 to 4, not 5. | +| Unit 4 | Implement `/tasks/resume` + `/tasks/recent-sessions` only; do NOT add `/tasks/connect` route or `TasksConnectRequestSchema`. | +| Unit 5 | Implement modal-list focus model + Recent Sessions view + raw-ID input form for resume only. Drop the `C` keyboard hint and the connect-mode form variant; confirm-card stays at one variant (existing cancel copy). | +| Unit 6 | Document resume only. Update tool catalog count to 4. Add a brief "Connect deferred" subsection in the AGENTS.md `Borrowed sessions` block linking to PR #94 and this amendment. | +| System-Wide Impact / Risks / Security & Input Validation | Drop connect-specific rows; the validation framework applies to resume identically. The borrowed-process risk row becomes vestigial (no borrowed processes constructed). | When `ce:work` starts a unit, the implementing subagent brings the per-section corrections inline as part of that unit's PR (rename `connect`-specific test scenarios to `resume`, drop connect.ts file references in Unit 3c, etc.). This amendment serves as the interpretive layer; per-unit surgical updates land alongside the matching implementation. @@ -61,7 +62,7 @@ The plugin is strong at spawning fresh delegations and weak at picking useful wo - R1. Resume and connect ship as first-class continuity actions, not hidden flags on `copilot_delegate`. (Units 3c, 5) - R2. Raw Copilot session identities are first-class targets alongside plugin `cpl_*` task IDs. (Units 1, 3b, 3c, 5) - R3. When a known plugin task matches the target, prefer it as the default lookup path while still allowing direct raw-ID targeting. (Units 1, 3c, 5) -- R4. Resume and connect via explicit raw Copilot session identity remain available after plugin or OpenCode restart. The plugin accepts user-provided raw IDs and does not retain a plugin-owned session catalog across restarts. The Recent Sessions view (Unit 5) is a *view onto Copilot CLI's own filesystem catalog*, not a plugin-owned cache. (Units 3c, 5, 6) +- R4. Resume and connect via explicit raw Copilot session identity remain available after plugin or OpenCode restart. The plugin accepts user-provided raw IDs and does not retain a plugin-owned session catalog across restarts. The Recent Sessions view (Unit 5) is a _view onto Copilot CLI's own filesystem catalog_, not a plugin-owned cache. (Units 3c, 5, 6) - R5. ACP is deferred. (Out of scope.) - R6. Continuity actions re-apply known prior workspace context (`cwd` and `--add-dir` set). (Units 1, 3c, 5) - R7. When workspace metadata is unavailable, the flow makes the gap explicit. TUI launches default to current `directory`; tool-path callers (`copilot_resume`, `copilot_connect`) accept explicit `cwd` / `addDirs` arguments for power-user customization. (Units 3c, 5) @@ -131,15 +132,15 @@ The plugin is strong at spawning fresh delegations and weak at picking useful wo ## Key Technical Decisions - **Origin discriminator on `TaskState`**, not a separate borrowed-task type. Single registry, single envelope shape, one branching point per consumer (notify text, cancel response, output envelope). Rationale: minimizes refactor surface; consumers that don't care about origin keep working unchanged. (R11) -- **Borrowed sessions are un-reapable; the local connector PID is owned.** `origin === 'connect'` tasks bind the local plugin process to a *remote Copilot session* the plugin instance did not originate. The local connector PID IS owned by this plugin instance and is killed on cancel; the remote session continues independently. Connect tasks do NOT write to the orphan PID ledger because the ledger's spawner-liveness gate concerns the *remote session's lifecycle*, not the connector's. Cancel response wording reflects this truthfully (`disconnected: true` for connect; `cancelled: true` for spawn/resume). Throughout this plan, "borrowed" refers to the remote Copilot session; the local connector PID is always owned and killable. +- **Borrowed sessions are un-reapable; the local connector PID is owned.** `origin === 'connect'` tasks bind the local plugin process to a _remote Copilot session_ the plugin instance did not originate. The local connector PID IS owned by this plugin instance and is killed on cancel; the remote session continues independently. Connect tasks do NOT write to the orphan PID ledger because the ledger's spawner-liveness gate concerns the _remote session's lifecycle_, not the connector's. Cancel response wording reflects this truthfully (`disconnected: true` for connect; `cancelled: true` for spawn/resume). Throughout this plan, "borrowed" refers to the remote Copilot session; the local connector PID is always owned and killable. - **No new internal `disconnected` status.** Connect-disconnect uses the existing `cancelled` terminal state; the envelope and notification text disambiguate via the `origin` field rather than via a new status. Rationale: keeps the terminal-state set bounded and avoids ripple changes through `setStatus`, `TERMINAL_STATUSES`, the RPC `TaskStatusSchema`, and `outputStatus`. - **Pre-flight UUID-existence check for `--resume`**, config-dir aware. When the target is a UUID, stat `${configDir ?? ~/.copilot}/session-state//session.db` and short-circuit with a structured error if absent. `configDir` is read in priority order: explicit tool argument โ†’ `COPILOT_CONFIG_DIR` env var โ†’ default `~/.copilot/`. Empirical Copilot CLI 1.0.40 finding: passing a valid-format-but-unknown UUID to `--resume` silently creates a new session at exit 0; the pre-flight closes that hole for default-config users and config-dir users equally. -- **`--connect` validation timing is empirical-first.** The plan does NOT assume `result.sessionId` comparison works as an attach-time mismatch detector. The Copilot CLI `result` event is a session-end summary, not a handshake event โ€” by the time it arrives, a spurious new session has already done potentially-destructive work. Unit 3's first sub-step is an empirical capture of a real `--connect` JSONL stream against a known-bad ID to identify whether any *early* session-identity event exists during the connect handshake. The validation strategy crystallizes from that capture; default fallback is honest-deferral (loud warning in tool description; no architectural validation claim) until evidence supports a stronger guarantee. +- **`--connect` validation timing is empirical-first.** The plan does NOT assume `result.sessionId` comparison works as an attach-time mismatch detector. The Copilot CLI `result` event is a session-end summary, not a handshake event โ€” by the time it arrives, a spurious new session has already done potentially-destructive work. Unit 3's first sub-step is an empirical capture of a real `--connect` JSONL stream against a known-bad ID to identify whether any _early_ session-identity event exists during the connect handshake. The validation strategy crystallizes from that capture; default fallback is honest-deferral (loud warning in tool description; no architectural validation claim) until evidence supports a stronger guarantee. - **Stderr regex normalization for the no-match path.** Copilot CLI 1.0.40 emits a freeform English `Error: No session, task, or name matched ''` to stderr at exit 1 with no JSONL output. A `normalizeContinuityError(stderr)` helper detects this and returns a clean error message; all other exit-1 paths are passed through verbatim. R9 is acknowledged as Copilot CLI 1.0.40-conditional in scope (the regex is empirical, not a documented contract); a runtime canary on plugin init logs a warning when `copilot --version` differs from the tested range. - **Resume does NOT take a new prompt argument.** `copilot_resume` continues the resumed session from its last state without injecting a new turn. The semantic of "resume + new prompt" is fork; shipping fork mechanically inside resume would lock in ergonomics the explicit fork tool would need to undo in a follow-up slice. Enforced at the schema level: `copilot_resume` accepts only `targetId`, `cwd?`, `addDirs?`, `configDir?`. Fork remains explicitly deferred (see Scope Boundaries). - **Two dedicated tools, not one parameterized tool.** `copilot_resume` and `copilot_connect` rather than a single `copilot_continuity { action }`. Rationale: distinct semantics merit distinct tool descriptions; agents discovering tools via tool catalog see two clearly-labeled options with action-specific argument shapes. Tool-catalog cost is acknowledged: this slice grows the catalog from 3 to 5 tools, each consuming agent context-token budget on every turn; consolidation back to one tool would be a deprecation-cycle commitment we accept in exchange for argument-shape clarity. - **TUI affordances inline in the existing modal, no new slash commands.** Per R13. Two distinct flows: - - **Resume primary path โ€” `Recent Sessions` view**. Reads `${configDir ?? ~/.copilot}/session-state/` as a directory listing; surfaces selectable rows with mtime + best-effort metadata (project name, first user message snippet, tool-call count) extracted from each session's `events.jsonl`. This is a *view onto Copilot CLI's own catalog*, not a plugin-owned catalog โ€” R4 stays intact. User picks a row โ†’ launches resume against that session ID. + - **Resume primary path โ€” `Recent Sessions` view**. Reads `${configDir ?? ~/.copilot}/session-state/` as a directory listing; surfaces selectable rows with mtime + best-effort metadata (project name, first user message snippet, tool-call count) extracted from each session's `events.jsonl`. This is a _view onto Copilot CLI's own catalog_, not a plugin-owned catalog โ€” R4 stays intact. User picks a row โ†’ launches resume against that session ID. - **Resume fallback / Connect primary โ€” raw-ID input form**. Used when the user wants to resume a session not in the local list, or to connect to a remote/shared session ID (always raw-ID first because connect targets are typically external). - Dialog swaps use `api.ui.dialog.replace`, never `clear` + `replace` (per re-entrant dialog close learning). - **Shared `launchResume` / `launchConnect` helpers exported directly from `src/tools/resume.ts` and `src/tools/connect.ts`**, imported by the RPC routes. No separate `launch-helpers.ts` file โ€” cancel-helper.ts is 12 lines of trivial logic; launch is the entire body of the tools. If a third caller appears (fork in a follow-up slice), extract then. @@ -172,7 +173,7 @@ The plugin is strong at spawning fresh delegations and weak at picking useful wo ## High-Level Technical Design -> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* +> _This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce._ ``` โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” @@ -223,11 +224,11 @@ The plugin is strong at spawning fresh delegations and weak at picking useful wo โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` -| Origin | local PID | orphan ledger | cancel response | notify completion text | -|---|---|---|---|---| -| `spawn` | owned | written | `cancelled: true` | "COPILOT DELEGATION COMPLETED" | -| `resume` | owned (new process resuming a CLI session) | written | `cancelled: true` | "COPILOT RESUME COMPLETED" | -| `connect` | owned connector (remote session continues independently) | NOT written | `disconnected: true` | "COPILOT CONNECTOR DISCONNECTED" | +| Origin | local PID | orphan ledger | cancel response | notify completion text | +| --------- | -------------------------------------------------------- | ------------- | -------------------- | -------------------------------- | +| `spawn` | owned | written | `cancelled: true` | "COPILOT DELEGATION COMPLETED" | +| `resume` | owned (new process resuming a CLI session) | written | `cancelled: true` | "COPILOT RESUME COMPLETED" | +| `connect` | owned connector (remote session continues independently) | NOT written | `disconnected: true` | "COPILOT CONNECTOR DISCONNECTED" | ## Implementation Units @@ -323,7 +324,7 @@ The plugin is strong at spawning fresh delegations and weak at picking useful wo - [ ] **Unit 3a: Empirical pre-flight โ€” capture real `--connect` JSONL behavior** -**Goal:** Before any connect-validation design lands, capture an actual `copilot --connect=` JSONL stream and identify whether any *early* session-identity event arrives during the connect handshake (before tool calls or model output). +**Goal:** Before any connect-validation design lands, capture an actual `copilot --connect=` JSONL stream and identify whether any _early_ session-identity event arrives during the connect handshake (before tool calls or model output). **Requirements:** R9 (validation strategy depends on empirical evidence) @@ -340,11 +341,11 @@ The plugin is strong at spawning fresh delegations and weak at picking useful wo - Document findings in the capabilities reference doc; pick the validation strategy for Unit 3c based on what the capture shows. **Decision tree based on findings:** -| Finding | Validation strategy for Unit 3c | -|---|---| -| Early event carries `sessionId` | Validate against that event; abort connect on mismatch BEFORE any tool calls fire | -| No early event; only `result` carries it | Honest-deferral posture: ship `--connect` with loud warning in tool description and notification toast; do NOT claim attach-time validation; document upstream gap | -| Connect against unknown ID surfaces clean stderr error at exit โ‰  0 | Use stderr regex normalization (extend `normalizeContinuityError`); no JSONL validation needed | +| Finding | Validation strategy for Unit 3c | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Early event carries `sessionId` | Validate against that event; abort connect on mismatch BEFORE any tool calls fire | +| No early event; only `result` carries it | Honest-deferral posture: ship `--connect` with loud warning in tool description and notification toast; do NOT claim attach-time validation; document upstream gap | +| Connect against unknown ID surfaces clean stderr error at exit โ‰  0 | Use stderr regex normalization (extend `normalizeContinuityError`); no JSONL validation needed | **Test scenarios:** none (this unit is research; outputs are fixtures + a doc update). @@ -631,12 +632,12 @@ The new RPC routes accept user-supplied target IDs and workspace paths and forwa **Input contracts (all enforced in `src/runtime/continuity-validation.ts`, called by both tool factories and RPC routes):** -| Input | Validation | Failure mode | -|---|---|---| -| `targetId` | Discriminated union: UUID (`/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i`) OR safe-name (`/^[A-Za-z0-9._-]{1,128}$/`). Length capped at 128. | Return `{ error: 'invalid target ID format' }` without spawning. | -| `cwd` | Must be absolute path; must not begin with `--`; must `path.resolve` under one of `allowedRoots` (active workspace + `directory` plugin option, by default). | Return `{ error: 'cwd outside allowed roots' }` without spawning. | -| `addDirs` | Per entry: must be absolute path; must not begin with `--`; must `path.resolve` under one of `allowedRoots`. Array length capped at 32. | Return `{ error: 'argv-injection-shaped value in addDirs' }` or `{ error: 'path outside allowed roots' }` without spawning. | -| `configDir` | If provided: must be absolute path; must `path.resolve` under user home directory or an explicitly-allowed override list. | Return `{ error: 'configDir outside allowed roots' }` without spawning. | +| Input | Validation | Failure mode | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `targetId` | Discriminated union: UUID (`/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i`) OR safe-name (`/^[A-Za-z0-9._-]{1,128}$/`). Length capped at 128. | Return `{ error: 'invalid target ID format' }` without spawning. | +| `cwd` | Must be absolute path; must not begin with `--`; must `path.resolve` under one of `allowedRoots` (active workspace + `directory` plugin option, by default). | Return `{ error: 'cwd outside allowed roots' }` without spawning. | +| `addDirs` | Per entry: must be absolute path; must not begin with `--`; must `path.resolve` under one of `allowedRoots`. Array length capped at 32. | Return `{ error: 'argv-injection-shaped value in addDirs' }` or `{ error: 'path outside allowed roots' }` without spawning. | +| `configDir` | If provided: must be absolute path; must `path.resolve` under user home directory or an explicitly-allowed override list. | Return `{ error: 'configDir outside allowed roots' }` without spawning. | **`psIdentity` capture timing**: at spawn time (synchronously with PID assignment in `subprocess.ts`), NOT after the first JSONL event. Captured `psIdentity` is verified before any later destructive action (cancel of connect-origin task) by re-calling `getPidIdentity(task.pid)` and comparing. Mismatch โ†’ log warning, skip kill, still transition to terminal state. @@ -649,16 +650,16 @@ The new RPC routes accept user-supplied target IDs and workspace paths and forwa ## Risks & Dependencies -| Risk | Mitigation | -|------|------------| -| Copilot CLI changes `--resume` / `--connect` argv shape, error text, or session-state filesystem layout between 1.0.40 and a future version | Pin Copilot CLI in `mise.toml` (already done); plugin init logs a runtime canary warning when `copilot --version` differs from tested 1.0.40; document R9 as version-conditional in tool descriptions and AGENTS.md; CI regression test exercises the empirical fixture as a leading indicator. | -| `--connect` silent-fallback (CLI silently starts fresh session against unknown ID) does damage before any plugin-side validation can fire | Empirical-first design: Unit 3a captures real connect JSONL and identifies whether any early identity event exists; if not, ship with loud warning in tool description + spawn toast ("invalid IDs may silently start a fresh session"); do not claim attach-time validation we cannot deliver. | -| Connect-origin task cancel kills the wrong process if connector PID is OS-reused | `psIdentity` captured at spawn time; `getPidIdentity()` verifies before kill; mismatch logs warning and skips kill; task still transitions to terminal state. Residual risk: `lstart` is wall-clock-second granular; same-second PID reuse with same `comm` would pass the gate (extremely rare on a busy system; documented). | -| Recursive Copilot sessions: a resumed session internally spawns child copilot processes (via MCP, hooks, subagents) that escape the origin model and orphan-ledger tracking | Out of scope for this slice; documented as a known gap in AGENTS.md and tracked as a follow-up open question. Resume-mode orphan tracking only covers the directly-spawned PID. | -| External consumers reading `status: 'cancelled'` cannot distinguish user-cancel from connect-disconnect | Tool description for `copilot_output` updated to mention the `origin` field and the asymmetry (Unit 6). External consumers (`output.ts`) keep the existing status surface; no new `disconnected` status. Acceptable cost given the simpler internal type system. | -| TUI form regressions re-introduce the re-entrant dialog freeze | Explicit integration test asserts no `clear()`-then-`replace()` sequence in the new code paths; PR review verifies by inspection. | -| The known-task lookup (R3) misleads users into thinking a resumed session shares state with the known task even after restart | Documentation in README and tool descriptions makes the after-restart raw-ID contract explicit (no plugin-side catalog persists across restarts; recovery relies on the upstream Copilot CLI session storage AND the Recent Sessions view, which is a *view onto Copilot CLI's catalog*, not a plugin-owned one). | -| Recent Sessions view performs filesystem reads and `events.jsonl` parsing on every modal open, slowing modal load | Capped at N most recent (default 20); per-session metadata extraction is best-effort with a hard timeout; failures degrade gracefully (UUID + mtime only, no project name / event count). | +| Risk | Mitigation | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Copilot CLI changes `--resume` / `--connect` argv shape, error text, or session-state filesystem layout between 1.0.40 and a future version | Pin Copilot CLI in `mise.toml` (already done); plugin init logs a runtime canary warning when `copilot --version` differs from tested 1.0.40; document R9 as version-conditional in tool descriptions and AGENTS.md; CI regression test exercises the empirical fixture as a leading indicator. | +| `--connect` silent-fallback (CLI silently starts fresh session against unknown ID) does damage before any plugin-side validation can fire | Empirical-first design: Unit 3a captures real connect JSONL and identifies whether any early identity event exists; if not, ship with loud warning in tool description + spawn toast ("invalid IDs may silently start a fresh session"); do not claim attach-time validation we cannot deliver. | +| Connect-origin task cancel kills the wrong process if connector PID is OS-reused | `psIdentity` captured at spawn time; `getPidIdentity()` verifies before kill; mismatch logs warning and skips kill; task still transitions to terminal state. Residual risk: `lstart` is wall-clock-second granular; same-second PID reuse with same `comm` would pass the gate (extremely rare on a busy system; documented). | +| Recursive Copilot sessions: a resumed session internally spawns child copilot processes (via MCP, hooks, subagents) that escape the origin model and orphan-ledger tracking | Out of scope for this slice; documented as a known gap in AGENTS.md and tracked as a follow-up open question. Resume-mode orphan tracking only covers the directly-spawned PID. | +| External consumers reading `status: 'cancelled'` cannot distinguish user-cancel from connect-disconnect | Tool description for `copilot_output` updated to mention the `origin` field and the asymmetry (Unit 6). External consumers (`output.ts`) keep the existing status surface; no new `disconnected` status. Acceptable cost given the simpler internal type system. | +| TUI form regressions re-introduce the re-entrant dialog freeze | Explicit integration test asserts no `clear()`-then-`replace()` sequence in the new code paths; PR review verifies by inspection. | +| The known-task lookup (R3) misleads users into thinking a resumed session shares state with the known task even after restart | Documentation in README and tool descriptions makes the after-restart raw-ID contract explicit (no plugin-side catalog persists across restarts; recovery relies on the upstream Copilot CLI session storage AND the Recent Sessions view, which is a _view onto Copilot CLI's catalog_, not a plugin-owned one). | +| Recent Sessions view performs filesystem reads and `events.jsonl` parsing on every modal open, slowing modal load | Capped at N most recent (default 20); per-session metadata extraction is best-effort with a hard timeout; failures degrade gracefully (UUID + mtime only, no project name / event count). | ## Documentation / Operational Notes diff --git a/docs/plans/2026-05-11-001-feat-plugin-contract-and-tui-api-resilience-plan.md b/docs/plans/2026-05-11-001-feat-plugin-contract-and-tui-api-resilience-plan.md index 83321d8c..e6943b5b 100644 --- a/docs/plans/2026-05-11-001-feat-plugin-contract-and-tui-api-resilience-plan.md +++ b/docs/plans/2026-05-11-001-feat-plugin-contract-and-tui-api-resilience-plan.md @@ -1,4 +1,5 @@ --- + title: 'feat: Plugin contract and TUI API resilience' type: feat status: completed @@ -79,15 +80,15 @@ Three independent fragilities in the plugin's contract with OpenCode, plus one d ## Key Technical Decisions -| Decision | Rationale | -|---|---| -| TUI dual-path runtime feature-detect | Magic Context already validated this pattern across the 1.14.41/.42/.44 gap; lowest risk | -| Bump devDep pin to `>= 1.14.44`, narrow peerDep to `>=1.14.41` | Tests run against the canonical API; fallback supports the prior pinned version | -| Move `wireRpcServerCleanup` to `src/lib/rpc-cleanup.ts` | Matches Systematic PR #355's `src/lib/bootstrap.ts` relocation; clear `lib/` naming convention | -| CI Node-ESM smoke test asserts default-only | Mirrors Systematic's gate verbatim; consistency across both repos matters more than marginal flexibility | -| Plugin entry `target: 'node'`, TUI entry `target: 'bun'` | Plugin has no Bun-specific deps; TUI's `@opentui/solid/preload` is Bun-specific. Different runtimes, different targets | -| Keep `plugInOnce` + `onDuplicate โ†’ {}` | RPC server + PID file have N-instance side effects unaddressable by Systematic's per-load model. Returning `{}` (not real-hooks-clone) preserves the original PR #335 reasoning: prevents duplicate tool registration in the host | -| Split into 2 PRs | U1 (TUI) is user-facing and independent; PR-2 carries U2โ†’U3โ†’U4 as the structural dependency chain, with U5 riding along to document the singleton rationale and verify the full set. Separating lets the TUI fix ship faster without waiting on the structural set | +| Decision | Rationale | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| TUI dual-path runtime feature-detect | Magic Context already validated this pattern across the 1.14.41/.42/.44 gap; lowest risk | +| Bump devDep pin to `>= 1.14.44`, narrow peerDep to `>=1.14.41` | Tests run against the canonical API; fallback supports the prior pinned version | +| Move `wireRpcServerCleanup` to `src/lib/rpc-cleanup.ts` | Matches Systematic PR #355's `src/lib/bootstrap.ts` relocation; clear `lib/` naming convention | +| CI Node-ESM smoke test asserts default-only | Mirrors Systematic's gate verbatim; consistency across both repos matters more than marginal flexibility | +| Plugin entry `target: 'node'`, TUI entry `target: 'bun'` | Plugin has no Bun-specific deps; TUI's `@opentui/solid/preload` is Bun-specific. Different runtimes, different targets | +| Keep `plugInOnce` + `onDuplicate โ†’ {}` | RPC server + PID file have N-instance side effects unaddressable by Systematic's per-load model. Returning `{}` (not real-hooks-clone) preserves the original PR #335 reasoning: prevents duplicate tool registration in the host | +| Split into 2 PRs | U1 (TUI) is user-facing and independent; PR-2 carries U2โ†’U3โ†’U4 as the structural dependency chain, with U5 riding along to document the singleton rationale and verify the full set. Separating lets the TUI fix ship faster without waiting on the structural set | ## High-Level Technical Design @@ -324,12 +325,12 @@ None โ€” every decision resolves at planning time. ## Risks & Dependencies -| Risk | Mitigation | -|------|------------| -| `target: 'node'` build introduces a regression in a transitive dep that uses Bun-specific resolution | U3's pre-change grep audit (`grep -rE "import.meta.(require|main)|Bun\."`) catches direct usage. For transitive deps, U3's verification step (run the built artifact under both Node and Bun) confirms compatibility. If a regression surfaces, U3 can revert to `target: 'bun'` and U4's gate becomes wait-only-on-default-export-shape (Node load deferred to a separate follow-up) | -| Magic Context's `keymap.registerLayer` command shape (with `namespace: 'palette'`) is not exactly what OpenCode 1.14.44+ wants | Feasibility-reviewer verified the shape against the current `@opencode-ai/plugin/tui` types. If types diverge by 1.14.50+, U1's test extension catches the mismatch before merge | -| The narrowed peerDep range (`>=1.14.41`) breaks downstream consumers pinned to `1.14.0`โ€“`1.14.40` | The codebase has never tested against those versions; the narrowing aligns advertised support with actual support. If a real consumer surfaces, they upgrade their pin (a trivial change for them) | -| `wireRpcServerCleanup` relocation breaks a hidden import we haven't grepped | U2's verification step grep ensures exactly two refs to the symbol remain after the move. `bun run typecheck` catches any orphaned imports | +| Risk | Mitigation | +| ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `target: 'node'` build introduces a regression in a transitive dep that uses Bun-specific resolution | U3's pre-change grep audit (`grep -rE "import.meta.(require | main) | Bun\."`) catches direct usage. For transitive deps, U3's verification step (run the built artifact under both Node and Bun) confirms compatibility. If a regression surfaces, U3 can revert to `target: 'bun'` and U4's gate becomes wait-only-on-default-export-shape (Node load deferred to a separate follow-up) | +| Magic Context's `keymap.registerLayer` command shape (with `namespace: 'palette'`) is not exactly what OpenCode 1.14.44+ wants | Feasibility-reviewer verified the shape against the current `@opencode-ai/plugin/tui` types. If types diverge by 1.14.50+, U1's test extension catches the mismatch before merge | +| The narrowed peerDep range (`>=1.14.41`) breaks downstream consumers pinned to `1.14.0`โ€“`1.14.40` | The codebase has never tested against those versions; the narrowing aligns advertised support with actual support. If a real consumer surfaces, they upgrade their pin (a trivial change for them) | +| `wireRpcServerCleanup` relocation breaks a hidden import we haven't grepped | U2's verification step grep ensures exactly two refs to the symbol remain after the move. `bun run typecheck` catches any orphaned imports | ## Documentation / Operational Notes diff --git a/docs/research/copilot-cli-capabilities-2026-04-27.md b/docs/research/copilot-cli-capabilities-2026-04-27.md index 4db3b324..4c289512 100644 --- a/docs/research/copilot-cli-capabilities-2026-04-27.md +++ b/docs/research/copilot-cli-capabilities-2026-04-27.md @@ -1,4 +1,5 @@ --- + title: Copilot CLI Capabilities โ€” Programmatic Delegation Reference date: 2026-04-27 copilot_version: 1.0.36 @@ -8,7 +9,8 @@ runtime_minutes: 5 premium_requests: 7.5 purpose: Seed ideation for opencode-copilot-delegate v0.2.x feature work. errata: - - "ยง9 'Prompts visible in ps': the suggested temp-file wrapper does not address argv leakage. Copilot CLI 1.0.36 has no documented stdin or @file prompt mode; treat that bullet as 'no known workaround in 1.0.36' until upstream exposes one." +- "ยง9 'Prompts visible in ps': the suggested temp-file wrapper does not address argv leakage. Copilot CLI 1.0.36 has no documented stdin or @file prompt mode; treat that bullet as 'no known workaround in 1.0.36' until upstream exposes one." + --- # Copilot CLI Capabilities โ€” Programmatic Delegation Reference @@ -23,47 +25,47 @@ errata: Grouped table of `-p`-relevant flags. Source key: **H** = `copilot help`, **HP** = `copilot help permissions`, **HE** = `copilot help environment`, **HC** = `copilot help config`, **D** = public docs. -| Category | Flag | Notes / Source | -|---|---|---| -| Invocation | `-p, --prompt ` | Non-interactive run; exits on completion. **H** | -| Invocation | `-i, --interactive ` | Interactive but seeded with prompt. **H** | -| Invocation | `--continue` / `--resume[=id\|name\|prefix]` | Resume by id, task id, name (case-insensitive exact), or 7+ hex prefix. **H** | -| Invocation | `--connect[=sessionId]` | Connect to a remote session/task. **H** | -| Invocation | `-n, --name ` | Name new session; recoverable via `--resume=`. **H** | -| Invocation | `--mode `, `--plan`, `--autopilot`, `--max-autopilot-continues ` | Initial mode. **H** | -| Invocation | `--acp` | Run as Agent Client Protocol server (stdin/stdout). **H** | -| Permissions | `--allow-all` / `--yolo` | Equivalent: `--allow-all-tools --allow-all-paths --allow-all-urls`. **HP** | -| Permissions | `--allow-all-tools` (env `COPILOT_ALLOW_ALL=true`) | Required for `-p` non-interactive. **H/HP** | -| Permissions | `--allow-tool[=tools...]` / `--deny-tool[=tools...]` | Pattern: `kind(arg)`; deny wins. Kinds: `shell`, `write`, `read`*, `url`, `memory`*, ``. *`read`/`memory` only documented in **D**, not in `help permissions`. | -| Permissions | `--available-tools[=tools...]` / `--excluded-tools[=tools...]` | Hard model-visibility filter (independent of allow/deny). **HP** | -| Permissions | `--allow-all-paths` / `--add-dir ` (repeatable) / `--disallow-temp-dir` | Path sandbox. CWD + temp default; `--add-dir` extends. **HP** | -| Permissions | `--allow-all-urls` / `--allow-url[=urls...]` / `--deny-url[=urls...]` | Protocol-aware; deny wins. **HP** | -| Permissions | `--no-ask-user` | Disable `ask_user` tool โ€” required for fully unattended runs. **H** | -| Permissions | `--secret-env-vars[=vars...]` | Strip vars from shell/MCP env and redact in output. `GITHUB_TOKEN` and `COPILOT_GITHUB_TOKEN` redacted by default. **H/D** | -| Auth | `--config-dir ` | Override `~/.copilot`. **H** | -| Auth (env) | `COPILOT_GITHUB_TOKEN` > `GH_TOKEN` > `GITHUB_TOKEN` | First match wins; classic `ghp_` PATs **not supported**; v2 fine-grained PATs need `Copilot Requests` permission. `copilot login --help` | -| Auth (env) | `COPILOT_GH_HOST` / `GH_HOST`, `--host` | GHE.com data residency. **HE** | -| Models | `--model ` (env `COPILOT_MODEL`) | See ยง2 for full list. **H/HC** | -| Models | `--effort, --reasoning-effort ` | OpenAI/Anthropic reasoning models; settings key `effortLevel`. **H/D** | -| Models | `--enable-reasoning-summaries` | Request reasoning summaries (OpenAI). **H** | -| Models (BYOK) | `COPILOT_PROVIDER_BASE_URL`, `COPILOT_PROVIDER_TYPE` (`openai`/`azure`/`anthropic`), `โ€ฆ_API_KEY`, `โ€ฆ_BEARER_TOKEN`, `โ€ฆ_WIRE_API` (`completions`/`responses`), `โ€ฆ_MODEL_ID`, `โ€ฆ_WIRE_MODEL`, `โ€ฆ_MAX_PROMPT_TOKENS`, `โ€ฆ_MAX_OUTPUT_TOKENS`, `COPILOT_OFFLINE` | `copilot help providers` | -| Output | `--output-format ` | `json` = JSONL (one obj/line). **H** | -| Output | `-s, --silent` | Suppresses stats/decoration; agent reply only. Pair with `-p`. **H/D** | -| Output | `--stream ` | Streaming reply mode. **H** | -| Output | `--no-color`, `--plain-diff` (env `PLAIN_DIFF=true`) | TTY/diff rendering. **H/HE** | -| Sharing | `--share[=path]` | Markdown transcript after `-p`. Default `./copilot-session-.md`. **H/D** | -| Sharing | `--share-gist` | Secret gist after `-p`. **H/D** | -| Agents | `--agent ` | Resolve from `.github/agents/.agent.md` then `~/.copilot/agents/.agent.md`. **H/D** โ€” see ยง3. | -| MCP | `--additional-mcp-config ` (repeatable) | Augments `~/.copilot/mcp-config.json` for this session. **H** | -| MCP | `--disable-builtin-mcps` / `--disable-mcp-server ` | Currently the only built-in is `github-mcp-server`. **H** | -| MCP (built-in github) | `--add-github-mcp-tool ` (`*` = all), `--add-github-mcp-toolset ` (`all`), `--enable-all-github-mcp-tools` | Default exposes only a CLI subset; these expand it. The `*`/`all` flags **override** the toolset/tool flags. **H** | -| Plugins | `--plugin-dir ` (repeatable) | Load plugin from local dir. **H** | -| Lifecycle | `--no-custom-instructions` | Disable AGENTS.md / `copilot-instructions.md` loading. **H** | -| Lifecycle | `--bash-env` / `--no-bash-env` | Persists to settings. **H/HC** | -| Lifecycle | `--remote` / `--no-remote` | Allow GitHub web/mobile to drive the session. **H** | -| Lifecycle | `--log-dir `, `--log-level ` | **H/HL** | -| Lifecycle | `--experimental` / `--no-experimental` | Toggles experimental features. **H** | -| Lifecycle | `--no-auto-update` (env `COPILOT_AUTO_UPDATE=false`) | Auto-disabled in CI (`CI`, `BUILD_NUMBER`, `RUN_ID`, `SYSTEM_COLLECTIONURI`). **H/HE** | +| Category | Flag | Notes / Source | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Invocation | `-p, --prompt ` | Non-interactive run; exits on completion. **H** | +| Invocation | `-i, --interactive ` | Interactive but seeded with prompt. **H** | +| Invocation | `--continue` / `--resume[=id\|name\|prefix]` | Resume by id, task id, name (case-insensitive exact), or 7+ hex prefix. **H** | +| Invocation | `--connect[=sessionId]` | Connect to a remote session/task. **H** | +| Invocation | `-n, --name ` | Name new session; recoverable via `--resume=`. **H** | +| Invocation | `--mode `, `--plan`, `--autopilot`, `--max-autopilot-continues ` | Initial mode. **H** | +| Invocation | `--acp` | Run as Agent Client Protocol server (stdin/stdout). **H** | +| Permissions | `--allow-all` / `--yolo` | Equivalent: `--allow-all-tools --allow-all-paths --allow-all-urls`. **HP** | +| Permissions | `--allow-all-tools` (env `COPILOT_ALLOW_ALL=true`) | Required for `-p` non-interactive. **H/HP** | +| Permissions | `--allow-tool[=tools...]` / `--deny-tool[=tools...]` | Pattern: `kind(arg)`; deny wins. Kinds: `shell`, `write`, `read`_, `url`, `memory`_, ``. *`read`/`memory` only documented in **D**, not in `help permissions`. | +| Permissions | `--available-tools[=tools...]` / `--excluded-tools[=tools...]` | Hard model-visibility filter (independent of allow/deny). **HP** | +| Permissions | `--allow-all-paths` / `--add-dir ` (repeatable) / `--disallow-temp-dir` | Path sandbox. CWD + temp default; `--add-dir` extends. **HP** | +| Permissions | `--allow-all-urls` / `--allow-url[=urls...]` / `--deny-url[=urls...]` | Protocol-aware; deny wins. **HP** | +| Permissions | `--no-ask-user` | Disable `ask_user` tool โ€” required for fully unattended runs. **H** | +| Permissions | `--secret-env-vars[=vars...]` | Strip vars from shell/MCP env and redact in output. `GITHUB_TOKEN` and `COPILOT_GITHUB_TOKEN` redacted by default. **H/D** | +| Auth | `--config-dir ` | Override `~/.copilot`. **H** | +| Auth (env) | `COPILOT_GITHUB_TOKEN` > `GH_TOKEN` > `GITHUB_TOKEN` | First match wins; classic `ghp_` PATs **not supported**; v2 fine-grained PATs need `Copilot Requests` permission. `copilot login --help` | +| Auth (env) | `COPILOT_GH_HOST` / `GH_HOST`, `--host` | GHE.com data residency. **HE** | +| Models | `--model ` (env `COPILOT_MODEL`) | See ยง2 for full list. **H/HC** | +| Models | `--effort, --reasoning-effort ` | OpenAI/Anthropic reasoning models; settings key `effortLevel`. **H/D** | +| Models | `--enable-reasoning-summaries` | Request reasoning summaries (OpenAI). **H** | +| Models (BYOK) | `COPILOT_PROVIDER_BASE_URL`, `COPILOT_PROVIDER_TYPE` (`openai`/`azure`/`anthropic`), `โ€ฆ_API_KEY`, `โ€ฆ_BEARER_TOKEN`, `โ€ฆ_WIRE_API` (`completions`/`responses`), `โ€ฆ_MODEL_ID`, `โ€ฆ_WIRE_MODEL`, `โ€ฆ_MAX_PROMPT_TOKENS`, `โ€ฆ_MAX_OUTPUT_TOKENS`, `COPILOT_OFFLINE` | `copilot help providers` | +| Output | `--output-format ` | `json` = JSONL (one obj/line). **H** | +| Output | `-s, --silent` | Suppresses stats/decoration; agent reply only. Pair with `-p`. **H/D** | +| Output | `--stream ` | Streaming reply mode. **H** | +| Output | `--no-color`, `--plain-diff` (env `PLAIN_DIFF=true`) | TTY/diff rendering. **H/HE** | +| Sharing | `--share[=path]` | Markdown transcript after `-p`. Default `./copilot-session-.md`. **H/D** | +| Sharing | `--share-gist` | Secret gist after `-p`. **H/D** | +| Agents | `--agent ` | Resolve from `.github/agents/.agent.md` then `~/.copilot/agents/.agent.md`. **H/D** โ€” see ยง3. | +| MCP | `--additional-mcp-config ` (repeatable) | Augments `~/.copilot/mcp-config.json` for this session. **H** | +| MCP | `--disable-builtin-mcps` / `--disable-mcp-server ` | Currently the only built-in is `github-mcp-server`. **H** | +| MCP (built-in github) | `--add-github-mcp-tool ` (`*` = all), `--add-github-mcp-toolset ` (`all`), `--enable-all-github-mcp-tools` | Default exposes only a CLI subset; these expand it. The `*`/`all` flags **override** the toolset/tool flags. **H** | +| Plugins | `--plugin-dir ` (repeatable) | Load plugin from local dir. **H** | +| Lifecycle | `--no-custom-instructions` | Disable AGENTS.md / `copilot-instructions.md` loading. **H** | +| Lifecycle | `--bash-env` / `--no-bash-env` | Persists to settings. **H/HC** | +| Lifecycle | `--remote` / `--no-remote` | Allow GitHub web/mobile to drive the session. **H** | +| Lifecycle | `--log-dir `, `--log-level ` | **H/HL** | +| Lifecycle | `--experimental` / `--no-experimental` | Toggles experimental features. **H** | +| Lifecycle | `--no-auto-update` (env `COPILOT_AUTO_UPDATE=false`) | Auto-disabled in CI (`CI`, `BUILD_NUMBER`, `RUN_ID`, `SYSTEM_COLLECTIONURI`). **H/HE** | **Help vs docs divergence:** docs list `read` and `memory` as `--allow-tool` kinds; `copilot help permissions` does not. Treat help as authoritative for syntax (`shell`, `write`, `url`, ``); use docs as a hint that `read` and `memory` may be accepted in 1.0.36 but are undocumented in CLI help. @@ -86,12 +88,12 @@ These are bare names โ€” **not** the `github-copilot/` namespace OpenCode **Cost / latency / quality bands (qualitative, derived from family names โ€” not from a published rate card):** -| Tier | Models | Use for | -|---|---|---| -| Fast/cheap | `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini`, `gpt-4.1` | Summaries, code explanation, doc generation, classifications, single-file edits | -| Standard | `claude-sonnet-4`, `claude-sonnet-4.5`, `claude-sonnet-4.6`, `gpt-5.1`, `gpt-5.2`, `gpt-5.4` | General delegation default | -| Codex specialist | `gpt-5.2-codex`, `gpt-5.3-codex` | Multi-file refactors, debugging, race-condition hunting (per docs example) | -| Premium reasoning | `claude-opus-4.5/4.6/4.6-fast/4.7`, `gpt-5.5` | Architecture decisions, deep research, cross-repo migrations | +| Tier | Models | Use for | +| ----------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Fast/cheap | `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini`, `gpt-4.1` | Summaries, code explanation, doc generation, classifications, single-file edits | +| Standard | `claude-sonnet-4`, `claude-sonnet-4.5`, `claude-sonnet-4.6`, `gpt-5.1`, `gpt-5.2`, `gpt-5.4` | General delegation default | +| Codex specialist | `gpt-5.2-codex`, `gpt-5.3-codex` | Multi-file refactors, debugging, race-condition hunting (per docs example) | +| Premium reasoning | `claude-opus-4.5/4.6/4.6-fast/4.7`, `gpt-5.5` | Architecture decisions, deep research, cross-repo migrations | **`--effort` levels** (`low`/`medium`/`high`/`xhigh`, default `medium` via `effortLevel`): apply to extended-thinking models. Empirically the GPT-5 series and the Claude Opus / Sonnet 4.x reasoning variants honor it; lower-end models (`gpt-4.1`, mini variants, haiku) ignore it. `--enable-reasoning-summaries` is OpenAI-only per help text. @@ -105,19 +107,21 @@ These are bare names โ€” **not** the `github-copilot/` namespace OpenCode ```markdown --- + name: security-auditor description: Security review specialist. Triggers on "seccheck" or audit requests. -tools: ['read', 'search', 'shell', 'github/create_issue'] # omit = all tools; [] = none -model: claude-opus-4.7 # optional override +tools: ['read', 'search', 'shell', 'github/create_issue'] # omit = all tools; [] = none +model: claude-opus-4.7 # optional override mcp-servers: - custom-mcp: - type: local - command: my-mcp - args: ['--flag'] - tools: ['*'] - env: - API_KEY: ${{ secrets.MY_KEY }} +custom-mcp: +type: local +command: my-mcp +args: ['--flag'] +tools: ['*'] +env: +API_KEY: ${{ secrets.MY_KEY }} --- + You are a security auditor. Identify exposed secrets, XSS, SQLi, vulnerable deps, and auth bypasses. For each finding, file a single GitHub issue with risk level and recommended fix. @@ -136,6 +140,7 @@ GitHub issue with risk level and recommended fix. Hooks **do exist**. Two surfaces: 1. **`~/.copilot/hooks/`** (user-level scripts) and **`.github/hooks/.json`** (repo-level, loaded from CWD for the CLI), schema: + ```json { "version": 1, @@ -147,6 +152,7 @@ Hooks **do exist**. Two surfaces: } } ``` + Each entry: `{ "type": "command", "bash": "...", "powershell": "...", "cwd": ".", "timeoutSec": 10, "env": {...} }`. Hooks receive a JSON event on stdin (e.g. `{"timestamp":...,"cwd":...,"toolName":...,"toolArgs":"..."}`). 2. **Inline** under `hooks` key in `~/.copilot/settings.json` (user) or `.github/copilot/settings.json` (repo). Same schema. `disableAllHooks: true` kills both. @@ -184,23 +190,24 @@ Default exposed surface is a curated CLI subset; opt-in beyond it. 3. `--allow-all` / `--yolo` โ€” superset (also enables tools and URLs). **Cross-repo patterns:** -- *Coordinated dependency bump across N repos:* +- _Coordinated dependency bump across N repos:_ + ```bash copilot -p "Bump @scope/lib to 2.0 in package.json, run codemods, update tests" \ --add-dir /repos/web --add-dir /repos/api --add-dir /repos/shared \ --allow-all-tools --no-ask-user -s --output-format json ``` -- *Extract shared utility into a sibling library:* `--add-dir` source repo + target repo + monorepo root; pin `--model gpt-5.3-codex`. +- _Extract shared utility into a sibling library:_ `--add-dir` source repo + target repo + monorepo root; pin `--model gpt-5.3-codex`. --- ## 7. Output formats and parsing -| Mode | Stdout | Best for | -|---|---|---| -| Default text | Pretty-rendered turns + stats footer + ANSI | Human use only | -| `-s, --silent` | Final assistant reply only, no stats | Quick scripting (`var=$(copilot -p 'โ€ฆ' -s)`) | -| `--output-format json` | **JSONL** โ€” one JSON object per line | Programmatic consumption (the plugin's path) | +| Mode | Stdout | Best for | +| ---------------------- | ------------------------------------------- | -------------------------------------------- | +| Default text | Pretty-rendered turns + stats footer + ANSI | Human use only | +| `-s, --silent` | Final assistant reply only, no stats | Quick scripting (`var=$(copilot -p 'โ€ฆ' -s)`) | +| `--output-format json` | **JSONL** โ€” one JSON object per line | Programmatic consumption (the plugin's path) | **JSONL event schema** (observed in `~/.copilot/session-state//events.jsonl`; same shape exits to stdout under `--output-format json`): @@ -252,7 +259,7 @@ COPILOT_GITHUB_TOKEN="$FG_PAT" copilot \ - **Classic `ghp_` PAT** โ†’ rejected with a generic auth error that doesn't mention the token-type restriction. - **Working dir outside `--add-dir` set** โ†’ file edits fail with "path not allowed" but the agent often retries the same path 3+ times before giving up, burning tokens. - **Auto-update mid-CI** โ†’ outside CI sentinels (custom CI?), `copilot` may download an update on first run; use `--no-auto-update`. -- **`--allow-tool='shell'` (no parens)** โ†’ matches *all* shell. To restrict you need `shell(git:*)` etc. Accidentally permissive. +- **`--allow-tool='shell'` (no parens)** โ†’ matches _all_ shell. To restrict you need `shell(git:*)` etc. Accidentally permissive. - **Deny rules silently overridden by nothing** โ€” deny always wins; if a tool seems blocked despite `--allow-all-tools`, check for an inherited `--deny-tool` from settings/agent file. - **`--agent ` typo** โ†’ exits with an error ("agent not found") rather than falling back to default. There are no built-in agent names to fall back to (see ยง3). - **`--add-github-mcp-toolset all` overridden by `--enable-all-github-mcp-tools`** โ€” last-flag-wins is **not** the rule; `--enable-all-github-mcp-tools` overrides per `copilot help`. Easy to misconfigure. @@ -267,43 +274,55 @@ COPILOT_GITHUB_TOKEN="$FG_PAT" copilot \ Pattern matrix, each tuned for spawning from inside another agent (e.g. OpenCode primary): ### 10.1 Sandboxed targeted refactor -*When:* primary agent identified the change but wants isolation. *Prompt:* "Refactor `src/foo.ts::bar` to take a Result type; update call sites and tests; do not touch unrelated files." -*Settings:* `--model gpt-5.3-codex --add-dir --allow-tool='shell(git:*),shell(npm test),write,read' --no-ask-user --output-format json -s`. *Runtime:* 1โ€“4 min. *Consume:* tail JSONL for `assistant.message` final + `git diff`. + +_When:_ primary agent identified the change but wants isolation. _Prompt:_ "Refactor `src/foo.ts::bar` to take a Result type; update call sites and tests; do not touch unrelated files." +_Settings:_ `--model gpt-5.3-codex --add-dir --allow-tool='shell(git:*),shell(npm test),write,read' --no-ask-user --output-format json -s`. _Runtime:_ 1โ€“4 min. _Consume:_ tail JSONL for `assistant.message` final + `git diff`. ### 10.2 Parallel test fixing -*When:* primary is implementing feature A; tests in module B are red. Delegate B-fix in parallel. -*Settings:* `--model gpt-5.2 --add-dir --allow-tool='shell(npm:*),shell(jest),write,read' --no-ask-user`. *Runtime:* 2โ€“10 min. *Consume:* poll via `copilot_output`; merge result branch. + +_When:_ primary is implementing feature A; tests in module B are red. Delegate B-fix in parallel. +_Settings:_ `--model gpt-5.2 --add-dir --allow-tool='shell(npm:*),shell(jest),write,read' --no-ask-user`. _Runtime:_ 2โ€“10 min. _Consume:_ poll via `copilot_output`; merge result branch. ### 10.3 Multi-repo coordinated change -*When:* shared lib bump across services. *Settings:* repeated `--add-dir`; `--model gpt-5.3-codex`; allow `shell(git:*)`, `shell(pnpm:*)`. *Cost:* high โ€” premium model, broad scope. *Consume:* per-repo summary blocks in final assistant turn. + +_When:_ shared lib bump across services. _Settings:_ repeated `--add-dir`; `--model gpt-5.3-codex`; allow `shell(git:*)`, `shell(pnpm:*)`. _Cost:_ high โ€” premium model, broad scope. _Consume:_ per-repo summary blocks in final assistant turn. ### 10.4 Diff code review -*Prompt:* "Review the diff between `origin/main` and HEAD; surface only correctness, security, and concurrency issues; ignore style." *Settings:* `--agent code-review` if a custom agent exists, else `--model claude-opus-4.7 --effort high --allow-tool='shell(git diff),shell(git log),read'`. *Runtime:* 30โ€“120 s. *Consume:* a single markdown report; no writes expected โ€” set no `write` permission as a hard guard. + +_Prompt:_ "Review the diff between `origin/main` and HEAD; surface only correctness, security, and concurrency issues; ignore style." _Settings:_ `--agent code-review` if a custom agent exists, else `--model claude-opus-4.7 --effort high --allow-tool='shell(git diff),shell(git log),read'`. _Runtime:_ 30โ€“120 s. _Consume:_ a single markdown report; no writes expected โ€” set no `write` permission as a hard guard. ### 10.5 Doc generation from a module -*Prompt:* "Write a TSDoc-style overview of `src/runtime/`; include public surface table." *Settings:* `--model claude-haiku-4.5 --allow-tool='read,search,write(docs/runtime.md)' --no-ask-user -s`. *Runtime:* 20โ€“60 s, low cost. *Consume:* the new file; primary agent can lint it. + +_Prompt:_ "Write a TSDoc-style overview of `src/runtime/`; include public surface table." _Settings:_ `--model claude-haiku-4.5 --allow-tool='read,search,write(docs/runtime.md)' --no-ask-user -s`. _Runtime:_ 20โ€“60 s, low cost. _Consume:_ the new file; primary agent can lint it. ### 10.6 Dependency / supply-chain audit -*Prompt:* "Audit `package.json` and `bun.lock` for advisories; cross-check via `gh api` and OSV." *Settings:* `--model gpt-5.4 --allow-tool='shell(npm audit),shell(bun:*),shell(gh api),url(api.github.com),url(api.osv.dev),read'`. Allow built-in github MCP for issue creation: `--add-github-mcp-tool create_issue`. *Runtime:* 1โ€“3 min. + +_Prompt:_ "Audit `package.json` and `bun.lock` for advisories; cross-check via `gh api` and OSV." _Settings:_ `--model gpt-5.4 --allow-tool='shell(npm audit),shell(bun:*),shell(gh api),url(api.github.com),url(api.osv.dev),read'`. Allow built-in github MCP for issue creation: `--add-github-mcp-tool create_issue`. _Runtime:_ 1โ€“3 min. ### 10.7 Migration / codemod authoring -*Prompt:* "Generate a jscodeshift transform that replaces `useState(null)` with a custom `useNullable` hook; include tests." *Settings:* `--model gpt-5.3-codex --effort high --add-dir --allow-tool='write,read,shell(node),shell(npx jscodeshift)'`. *Consume:* the transform script; primary applies it on demand. + +_Prompt:_ "Generate a jscodeshift transform that replaces `useState(null)` with a custom `useNullable` hook; include tests." _Settings:_ `--model gpt-5.3-codex --effort high --add-dir --allow-tool='write,read,shell(node),shell(npx jscodeshift)'`. _Consume:_ the transform script; primary applies it on demand. ### 10.8 Custom-agent specialist workflow -*When:* repo defines `.github/agents/security-auditor.agent.md`. *Settings:* `--agent security-auditor --add-dir --allow-tool='read,github(create_issue)' --deny-tool='write'`. *Consume:* GitHub issue URLs scraped from final turn. + +_When:_ repo defines `.github/agents/security-auditor.agent.md`. _Settings:_ `--agent security-auditor --add-dir --allow-tool='read,github(create_issue)' --deny-tool='write'`. _Consume:_ GitHub issue URLs scraped from final turn. ### 10.9 Long-running codebase indexing -*When:* primary needs a structured map of an unfamiliar large repo and shouldn't block. -*Prompt:* "Produce `docs/architecture/index.md` describing every package, public symbol, and inter-module dependency." *Settings:* `--model claude-sonnet-4.6 --add-dir --allow-tool='read,search,write(docs/architecture/*)'`. Spawn detached; primary polls with `copilot_output`. *Runtime:* 5โ€“30 min. + +_When:_ primary needs a structured map of an unfamiliar large repo and shouldn't block. +_Prompt:_ "Produce `docs/architecture/index.md` describing every package, public symbol, and inter-module dependency." _Settings:_ `--model claude-sonnet-4.6 --add-dir --allow-tool='read,search,write(docs/architecture/*)'`. Spawn detached; primary polls with `copilot_output`. _Runtime:_ 5โ€“30 min. ### 10.10 Plan-only delegation (no edits) -*When:* primary wants a second opinion before acting. *Settings:* `--mode plan --model claude-opus-4.7 --effort xhigh --allow-tool='read,search,shell(git log),shell(git status)' --deny-tool='write'`. Returns plan markdown; primary executes. + +_When:_ primary wants a second opinion before acting. _Settings:_ `--mode plan --model claude-opus-4.7 --effort xhigh --allow-tool='read,search,shell(git log),shell(git status)' --deny-tool='write'`. Returns plan markdown; primary executes. ### 10.11 ACP-mediated long session -*When:* multi-turn delegation where primary wants to feed follow-up prompts. Spawn `copilot --acp โ€ฆ` and speak Agent Client Protocol over stdio rather than relaunching `copilot -p` per turn. Lower cold-start cost; preserves context window across turns. + +_When:_ multi-turn delegation where primary wants to feed follow-up prompts. Spawn `copilot --acp โ€ฆ` and speak Agent Client Protocol over stdio rather than relaunching `copilot -p` per turn. Lower cold-start cost; preserves context window across turns. ### 10.12 Resume / fork pattern -*When:* prior `cpl_*` task was promising but interrupted. New delegation with `--resume=` + new prompt. Or `--connect=` to attach to a still-running task elsewhere. Useful for "continue what failed CI started." + +_When:_ prior `cpl_*` task was promising but interrupted. New delegation with `--resume=` + new prompt. Or `--connect=` to attach to a still-running task elsewhere. Useful for "continue what failed CI started." #### 10.12a Empirical capture against CLI 1.0.40 (continuity research, 2026-05-02) @@ -311,23 +330,23 @@ Captured for the v0.2.x continuity feature. Fixtures live at `tests/fixtures/con **`--resume=` behavior โ€” surprising, by design.** -Per CLI help: *"Start a new session with a specific UUID."* Confirmed empirically: +Per CLI help: _"Start a new session with a specific UUID."_ Confirmed empirically: -| Target | Result | `result.sessionId` | -|---|---|---| -| Known local UUID | True continuation of prior session | Matches requested | -| **Unknown UUID** | **New session created using requested UUID as its ID** | **Matches requested (exactly)** | -| Bare `--resume` (no value) | Fresh continuation with new UUID; does NOT pick up the most-recent session in our test | New UUID | +| Target | Result | `result.sessionId` | +| -------------------------- | -------------------------------------------------------------------------------------- | ------------------------------- | +| Known local UUID | True continuation of prior session | Matches requested | +| **Unknown UUID** | **New session created using requested UUID as its ID** | **Matches requested (exactly)** | +| Bare `--resume` (no value) | Fresh continuation with new UUID; does NOT pick up the most-recent session in our test | New UUID | Implication: `--resume=` is NOT a "silent fallback" โ€” it's an undocumented session-creation pathway that lets callers seed a session with a chosen UUID. A pre-flight `stat ~/.copilot/session-state//session.db` distinguishes "this is true continuity" from "this is a new session at the user-supplied UUID" โ€” both succeed at the CLI level, but only the former is what users typically mean. **`--connect=` behavior with `--no-remote` โ€” degenerate.** -`--connect` is a remote-session feature (per CLI help: *"Connect directly to a remote session"*). Combined with `--no-remote`: +`--connect` is a remote-session feature (per CLI help: _"Connect directly to a remote session"_). Combined with `--no-remote`: -| Target | Result | `result.sessionId` | -|---|---|---| -| Unknown UUID, `--no-remote` | Silent fallback โ€” fresh local session, no error | New UUID (โ‰  requested) | +| Target | Result | `result.sessionId` | +| ------------------------------ | ---------------------------------------------------- | ---------------------- | +| Unknown UUID, `--no-remote` | Silent fallback โ€” fresh local session, no error | New UUID (โ‰  requested) | | Real local UUID, `--no-remote` | Same silent fallback โ€” the connect target is ignored | New UUID (โ‰  requested) | The connect target is silently ignored under `--no-remote`. There is no error, no stderr, no JSONL event indicating the mismatch โ€” only `result.sessionId` at session end reveals the divergence. **17 events fire before `result`** in both cases, including a full `assistant.message` round-trip with whatever the prompt requested. @@ -347,12 +366,12 @@ Across all 5 captures (5 different connect/resume permutations), checked every n Tested whether dropping `--no-remote` from connect's argv surfaces a clean attach signal at any layer: -| Target | Argv | Result | `result.sessionId` | Stderr | Exit | -|---|---|---|---|---|---| -| Recently-completed local session | no `--no-remote` | Silent fallback, new UUID assigned | New UUID (โ‰  requested) | empty | 0 | -| Unknown UUID `00000000-...` | no `--no-remote` | Silent fallback, new UUID assigned | New UUID (โ‰  requested) | empty | 0 | +| Target | Argv | Result | `result.sessionId` | Stderr | Exit | +| -------------------------------- | ---------------- | ---------------------------------- | ---------------------- | ------ | ---- | +| Recently-completed local session | no `--no-remote` | Silent fallback, new UUID assigned | New UUID (โ‰  requested) | empty | 0 | +| Unknown UUID `00000000-...` | no `--no-remote` | Silent fallback, new UUID assigned | New UUID (โ‰  requested) | empty | 0 | -Dropping `--no-remote` does **nothing observable**. In both captures the CLI silently allocates a fresh UUID and completes a normal session at exit 0 with empty stderr. No error event in the JSONL stream, no early signal, no diagnostic. When asked the prompt *"Did this attach work? Reply with only the word YES or NO."*, the model itself replied "NO" โ€” contextual inference, not a protocol-level signal, and not reliable as a programmatic guard. +Dropping `--no-remote` does **nothing observable**. In both captures the CLI silently allocates a fresh UUID and completes a normal session at exit 0 with empty stderr. No error event in the JSONL stream, no early signal, no diagnostic. When asked the prompt _"Did this attach work? Reply with only the word YES or NO."_, the model itself replied "NO" โ€” contextual inference, not a protocol-level signal, and not reliable as a programmatic guard. The remote-control mechanism behind `--connect` appears to require a session that is **actively running and listening** (two concurrent processes, one providing a connection-ready session). This cannot be replicated in the plugin's one-shot `-p` execution model. diff --git a/docs/research/copilot-memory-experiment-2026-05-03.md b/docs/research/copilot-memory-experiment-2026-05-03.md index 5b00eeb4..665ff6cd 100644 --- a/docs/research/copilot-memory-experiment-2026-05-03.md +++ b/docs/research/copilot-memory-experiment-2026-05-03.md @@ -1,10 +1,12 @@ --- + title: Copilot CLI memory experiment โ€” `store_memory` tool surface type: research date: 2026-05-03 related: - - docs/research/copilot-cli-capabilities-2026-04-27.md -status: empirical +- docs/research/copilot-cli-capabilities-2026-04-27.md + status: empirical + --- # Copilot CLI memory experiment โ€” `store_memory` tool surface @@ -13,15 +15,15 @@ Empirical investigation of GitHub Copilot's [agentic memory feature](https://doc ## Executive summary -| Question | Answer | -|---|---| -| Is there a callable `memory` tool in `copilot -p` mode? | **Yes โ€” `store_memory`.** First-class tool, available by default without `--allow-tool=memory`. | -| Does it require structured citations? | Yes (freeform string), and a `reason` โ‰ฅ 2โ€“3 sentences. | -| Is `fact` length-limited? | **Yes โ€” < 200 characters.** The binding constraint for memory authoring. | -| Does it return a memory ID? | No. Returns the literal string `"Memory stored successfully."` | -| Is there a `list_memories` / `get_memories` tool? | **No.** Verification requires the GitHub web UI. | -| Is `--allow-tool=memory` required? | No. The flag is documented but not enforced as a gate in 1.0.40. | -| Are memories the same surface as `~/.copilot/session-state/`? | No. Memories are repository-scoped server-side state, not local session-state files. | +| Question | Answer | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Is there a callable `memory` tool in `copilot -p` mode? | **Yes โ€” `store_memory`.** First-class tool, available by default without `--allow-tool=memory`. | +| Does it require structured citations? | Yes (freeform string), and a `reason` โ‰ฅ 2โ€“3 sentences. | +| Is `fact` length-limited? | **Yes โ€” < 200 characters.** The binding constraint for memory authoring. | +| Does it return a memory ID? | No. Returns the literal string `"Memory stored successfully."` | +| Is there a `list_memories` / `get_memories` tool? | **No.** Verification requires the GitHub web UI. | +| Is `--allow-tool=memory` required? | No. The flag is documented but not enforced as a gate in 1.0.40. | +| Are memories the same surface as `~/.copilot/session-state/`? | No. Memories are repository-scoped server-side state, not local session-state files. | The `memory` kind in [`docs/research/copilot-cli-capabilities-2026-04-27.md`](./copilot-cli-capabilities-2026-04-27.md) ยง1, ยง11 turns out to refer to a real callable tool, not just a permission category. The discrepancy with `copilot help permissions` (which omits `memory`) reflects an under-documented surface, not an absent one. @@ -122,11 +124,11 @@ Persistence verified via the GitHub web UI at `https://github.com/marcusrbrown/o The Memory page rendered: -| Subject | Stored exactly as in ยง3 above | -|---|---| -| `orphan reaper` | โœ… | -| `DUAL pattern` | โœ… | -| `task lifecycle` | โœ… | +| Subject | Stored exactly as in ยง3 above | +| ---------------- | ----------------------------- | +| `orphan reaper` | โœ… | +| `DUAL pattern` | โœ… | +| `task lifecycle` | โœ… | This closes the experimental loop: `"Memory stored successfully."` from `store_memory` corresponds to actual server-side persistence visible in the repo's Copilot Memory page. The success string is therefore a trustworthy persistence signal in 1.0.40, not just a syntactic acknowledgement. @@ -170,11 +172,11 @@ A second `copilot_delegate` (claude-sonnet-4.6, 71.7s, 1 premium request, 892 ev Three `store_memory` calls were made with intentionally-broken citations: -| # | Subject | Citation defect | Response | -|---|---|---|---| -| 1 | `memtest-stale-file` | File does not exist (`src/runtime/totally-nonexistent-fake-file.ts:1-10`) | `"Memory stored successfully."` | -| 2 | `memtest-bad-range` | Real file, lines past EOF (`src/index.ts:99999-100000`) | `"Memory stored successfully."` | -| 3 | `memtest-mismatch` | Real file/lines, fact is fabricated (claim about `processOrders` against the import block at `src/index.ts:1-5`) | `"Memory stored successfully."` | +| # | Subject | Citation defect | Response | +| --- | -------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| 1 | `memtest-stale-file` | File does not exist (`src/runtime/totally-nonexistent-fake-file.ts:1-10`) | `"Memory stored successfully."` | +| 2 | `memtest-bad-range` | Real file, lines past EOF (`src/index.ts:99999-100000`) | `"Memory stored successfully."` | +| 3 | `memtest-mismatch` | Real file/lines, fact is fabricated (claim about `processOrders` against the import block at `src/index.ts:1-5`) | `"Memory stored successfully."` | **Verdict:** `store_memory` performs **zero write-time validation**. File existence, line range validity, and content/citation alignment are all unverified. The API is a pure write-through that accepts anything well-formed. @@ -184,7 +186,7 @@ Upstream docs claim that memories with stale citations are "silently dropped" on ### Q2 โ€” Memory reuse signal -The same delegation asked itself: *"What are the two reaping safety gates in the orphan reaper, and where in source are they implemented?"* The accurate answer is recorded in the `orphan reaper` memory from ยง"Memory 3" above. +The same delegation asked itself: _"What are the two reaping safety gates in the orphan reaper, and where in source are they implemented?"_ The accurate answer is recorded in the `orphan reaper` memory from ยง"Memory 3" above. **Result:** The model produced an answer with citations matching the stored memory **exactly** โ€” `src/runtime/orphan-reaper.ts:48-55`, `:377`, `:171-178`, `:219-231` โ€” without making any tool call related to memory retrieval. @@ -254,10 +256,10 @@ Called `store_memory` with subject `memtest-stale-file` (matching an existing po Two parallel `copilot -p` calls asked the same question, both with `--deny-tool=read --deny-tool=shell` to block source inspection. The accurate answer requires recalling the `orphan reaper` memory's exact line ranges (`:48-55`, `:171-178`, `:377`, `:219-231`). -| Mode | Custom instructions | Wall-clock | `:48-55`? | `:171-178`? | `:377`? | `:219-231`? | Notable | -|---|---|---|---|---|---|---|---| -| **E1** baseline | ON (default) | ~26s | โœ… | โœ… | โœ… | โœ… | Model explicitly stated *"falling back to the verified repo memory"* | -| **E2** stripped | OFF (`--no-custom-instructions`) | 115s | โŒ (`45-52`) | โŒ (`169-175`) | โš  (`375-377`) | โŒ (`216-228`) | Model escalated to GitHub MCP `get_file_contents` to fetch source; produced independently-derived ranges | +| Mode | Custom instructions | Wall-clock | `:48-55`? | `:171-178`? | `:377`? | `:219-231`? | Notable | +| --------------- | -------------------------------- | ---------- | ------------ | -------------- | ------------- | -------------- | -------------------------------------------------------------------------------------------------------- | +| **E1** baseline | ON (default) | ~26s | โœ… | โœ… | โœ… | โœ… | Model explicitly stated _"falling back to the verified repo memory"_ | +| **E2** stripped | OFF (`--no-custom-instructions`) | 115s | โŒ (`45-52`) | โŒ (`169-175`) | โš  (`375-377`) | โŒ (`216-228`) | Model escalated to GitHub MCP `get_file_contents` to fetch source; produced independently-derived ranges | **Verdict:** Memories load as part of the custom-instructions injection path, not as an independent always-on system. `--no-custom-instructions` strips memories alongside `AGENTS.md` and `copilot-instructions.md`. E2's 4.4ร— slowdown plus citation divergence โ€” combined with the absence of a `` block in its system prompt โ€” confirm the loading bus is shared. @@ -288,13 +290,13 @@ Still untested: ### Updated pollution status (cumulative across both follow-up runs) -| Subject | Source | Status | -|---|---|---| -| `memtest-stale-file` | Q1 (Test A1) | Pollution โ€” delete from UI | -| `memtest-bad-range` | Q1 (Test A2) | Pollution โ€” delete from UI | -| `memtest-mismatch` | Q1 (Test A3) | Pollution โ€” delete from UI | -| `memtest-stale-file` (collision) | Test D | Either replaced original or coexists with it โ€” delete on inspection | -| `orphan reaper` (duplicate) | Test C | Either dedup'd by backend or coexists with original real memory โ€” keep one canonical entry | +| Subject | Source | Status | +| -------------------------------- | ------------ | ------------------------------------------------------------------------------------------ | +| `memtest-stale-file` | Q1 (Test A1) | Pollution โ€” delete from UI | +| `memtest-bad-range` | Q1 (Test A2) | Pollution โ€” delete from UI | +| `memtest-mismatch` | Q1 (Test A3) | Pollution โ€” delete from UI | +| `memtest-stale-file` (collision) | Test D | Either replaced original or coexists with it โ€” delete on inspection | +| `orphan reaper` (duplicate) | Test C | Either dedup'd by backend or coexists with original real memory โ€” keep one canonical entry | **Manual cleanup actions for the repo Memory page** (`https://github.com/marcusrbrown/opencode-copilot-delegate/settings` โ†’ Copilot โ†’ Memory): diff --git a/docs/solutions/best-practices/atomic-file-append-remove-o-excl-temp-file-2026-04-28.md b/docs/solutions/best-practices/atomic-file-append-remove-o-excl-temp-file-2026-04-28.md index fb5f2977..9dabb1ca 100644 --- a/docs/solutions/best-practices/atomic-file-append-remove-o-excl-temp-file-2026-04-28.md +++ b/docs/solutions/best-practices/atomic-file-append-remove-o-excl-temp-file-2026-04-28.md @@ -1,4 +1,5 @@ --- + title: Atomic File Append/Remove with O_EXCL Temp-File and In-Process Serialization Queue date: 2026-04-28 category: best-practices @@ -7,17 +8,18 @@ problem_type: best_practice component: development_workflow severity: medium applies_when: - - Implementing atomic file mutations - - Handling concurrent appends to shared files - - Ensuring crash-safe PID file writes - - Avoiding race conditions without locks -tags: - - atomic-writes - - file-serialization - - temp-files - - o-excl - - crash-safety - - in-process-queue +- Implementing atomic file mutations +- Handling concurrent appends to shared files +- Ensuring crash-safe PID file writes +- Avoiding race conditions without locks + tags: +- atomic-writes +- file-serialization +- temp-files +- o-excl +- crash-safety +- in-process-queue + --- # Atomic File Append/Remove with O_EXCL Temp-File and In-Process Serialization Queue @@ -184,7 +186,12 @@ A naive implementation treats each spawn event as "just append one line." That l // before: illustrative naive pattern import { appendFile } from 'node:fs/promises' -async function recordSpawn(filePath: string, pid: number, comm: string, lstart: string) { +async function recordSpawn( + filePath: string, + pid: number, + comm: string, + lstart: string, +) { const line = `${pid}\t${comm}\t${lstart}\n` await appendFile(filePath, line, 'utf8') } diff --git a/docs/solutions/best-practices/bounded-concurrency-worker-pool-chunked-promise-all-2026-04-28.md b/docs/solutions/best-practices/bounded-concurrency-worker-pool-chunked-promise-all-2026-04-28.md index 4c878033..25d2503a 100644 --- a/docs/solutions/best-practices/bounded-concurrency-worker-pool-chunked-promise-all-2026-04-28.md +++ b/docs/solutions/best-practices/bounded-concurrency-worker-pool-chunked-promise-all-2026-04-28.md @@ -1,4 +1,5 @@ --- + title: Bounded-Concurrency Worker Pool via Chunked Promise.all date: 2026-04-28 category: best-practices @@ -7,17 +8,18 @@ problem_type: best_practice component: development_workflow severity: medium applies_when: - - Implementing bounded parallel processing - - Processing lists with concurrent tasks - - Trading simplicity for head-of-line blocking - - When individual tasks have bounded latency -tags: - - bounded-concurrency - - promise-all - - chunking - - worker-pool - - parallel-processing - - head-of-line-blocking +- Implementing bounded parallel processing +- Processing lists with concurrent tasks +- Trading simplicity for head-of-line blocking +- When individual tasks have bounded latency + tags: +- bounded-concurrency +- promise-all +- chunking +- worker-pool +- parallel-processing +- head-of-line-blocking + --- # Bounded-Concurrency Worker Pool via Chunked Promise.all diff --git a/docs/solutions/best-practices/centralized-terminal-state-idempotency-task-lifecycle-2026-04-28.md b/docs/solutions/best-practices/centralized-terminal-state-idempotency-task-lifecycle-2026-04-28.md index f9bb4321..86f1dcc4 100644 --- a/docs/solutions/best-practices/centralized-terminal-state-idempotency-task-lifecycle-2026-04-28.md +++ b/docs/solutions/best-practices/centralized-terminal-state-idempotency-task-lifecycle-2026-04-28.md @@ -1,4 +1,5 @@ --- + title: Centralized Terminal-State Idempotency for Task Lifecycle date: 2026-04-28 category: best-practices @@ -7,17 +8,18 @@ problem_type: best_practice component: development_workflow severity: medium applies_when: - - Managing task status transitions - - Ensuring idempotent terminal state changes - - Coupling task state with cleanup hooks - - Avoiding duplicate status mutations -tags: - - task-lifecycle - - idempotency - - status-management - - terminal-states - - cleanup-hooks - - state-guards +- Managing task status transitions +- Ensuring idempotent terminal state changes +- Coupling task state with cleanup hooks +- Avoiding duplicate status mutations + tags: +- task-lifecycle +- idempotency +- status-management +- terminal-states +- cleanup-hooks +- state-guards + --- # Centralized Terminal-State Idempotency for Task Lifecycle @@ -204,11 +206,11 @@ That layout is brittle: one callsite remembered the guard, two did not, and a fo **Close path โ€” `src/runtime/subprocess.ts:65-70`** ```ts - flushBufferedStdout(task) - task.endedAt = Date.now() - task.exitCode = exitCode ?? undefined +flushBufferedStdout(task) +task.endedAt = Date.now() +task.exitCode = exitCode ?? undefined - setStatus(task, exitCode === 0 ? 'complete' : 'failed', { pidFilePath }) +setStatus(task, exitCode === 0 ? 'complete' : 'failed', { pidFilePath }) ``` **Abort path โ€” `src/runtime/subprocess.ts:149-154`** diff --git a/docs/solutions/best-practices/per-instance-pid-files-spawner-liveness-gating-2026-04-28.md b/docs/solutions/best-practices/per-instance-pid-files-spawner-liveness-gating-2026-04-28.md index 87266772..0bbb1d1a 100644 --- a/docs/solutions/best-practices/per-instance-pid-files-spawner-liveness-gating-2026-04-28.md +++ b/docs/solutions/best-practices/per-instance-pid-files-spawner-liveness-gating-2026-04-28.md @@ -1,4 +1,5 @@ --- + title: Per-Instance PID Files with Spawner-Liveness Gating date: 2026-04-28 category: best-practices @@ -7,17 +8,18 @@ problem_type: best_practice component: development_workflow severity: high applies_when: - - Managing subprocess lifecycles in plugins - - Preventing PID reuse of unrelated processes - - Handling cross-instance orphan cleanup - - Ensuring spawner liveness before reaping -tags: - - pid-files - - subprocess-management - - liveness-probing - - orphan-reaping - - process-identity - - comm-lstart-gate +- Managing subprocess lifecycles in plugins +- Preventing PID reuse of unrelated processes +- Handling cross-instance orphan cleanup +- Ensuring spawner liveness before reaping + tags: +- pid-files +- subprocess-management +- liveness-probing +- orphan-reaping +- process-identity +- comm-lstart-gate + --- # Per-Instance PID Files with Spawner-Liveness Gating diff --git a/docs/solutions/best-practices/reliable-cli-integration-testing-2026-04-26.md b/docs/solutions/best-practices/reliable-cli-integration-testing-2026-04-26.md index 270269dc..ffef73cc 100644 --- a/docs/solutions/best-practices/reliable-cli-integration-testing-2026-04-26.md +++ b/docs/solutions/best-practices/reliable-cli-integration-testing-2026-04-26.md @@ -1,4 +1,5 @@ --- + title: Reliable integration testing for OpenCode plugins via CLI invocations date: 2026-04-26 category: best-practices @@ -7,18 +8,19 @@ problem_type: best_practice component: testing_framework severity: medium applies_when: - - Building integration tests for an OpenCode plugin that exercises tool execution end-to-end - - Choosing between an SDK harness (`opencode serve` + `createOpencodeClient`) and a CLI subprocess - - Diagnosing test timeouts that look like plugin-loading or tool-calling regressions - - Selecting a default model for a CI test suite -tags: - - opencode - - integration-testing - - cli-invocations - - rate-limits - - isolation - - spawn-sync - - model-selection +- Building integration tests for an OpenCode plugin that exercises tool execution end-to-end +- Choosing between an SDK harness (`opencode serve` + `createOpencodeClient`) and a CLI subprocess +- Diagnosing test timeouts that look like plugin-loading or tool-calling regressions +- Selecting a default model for a CI test suite + tags: +- opencode +- integration-testing +- cli-invocations +- rate-limits +- isolation +- spawn-sync +- model-selection + --- # Reliable integration testing for OpenCode plugins via CLI invocations @@ -54,8 +56,8 @@ The SDK rabbit hole burned roughly six hours and many tokens chasing isolation, ### Minimal test helper ```ts -import {spawnSync, type SpawnSyncReturns} from 'node:child_process' -import {dirSync} from 'tmp' +import { spawnSync, type SpawnSyncReturns } from 'node:child_process' +import { dirSync } from 'tmp' interface RunArgs { model?: string @@ -65,12 +67,13 @@ interface RunArgs { } export function runOpencode(args: RunArgs): SpawnSyncReturns { - const model = args.model ?? process.env.OPENCODE_TEST_MODEL ?? 'opencode/minimax-m2.5' - const configDir = dirSync({unsafeCleanup: true}).name + const model = + args.model ?? process.env.OPENCODE_TEST_MODEL ?? 'opencode/minimax-m2.5' + const configDir = dirSync({ unsafeCleanup: true }).name const env: NodeJS.ProcessEnv = { ...process.env, OPENCODE_CONFIG_DIR: configDir, - OPENCODE_CONFIG_CONTENT: JSON.stringify({plugin: [args.pluginUrl]}), + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: [args.pluginUrl] }), } // copilotPat is forwarded as GH_TOKEN โ€” see auth precedence in README // (COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN > ~/.copilot/auth). @@ -85,7 +88,9 @@ export function runOpencode(args: RunArgs): SpawnSyncReturns { export function assertOk(result: SpawnSyncReturns): void { if (result.status !== 0) { - throw new Error(`opencode run exited ${result.status}; stderr tail:\n${result.stderr.slice(-2_000)}`) + throw new Error( + `opencode run exited ${result.status}; stderr tail:\n${result.stderr.slice(-2_000)}`, + ) } } ``` @@ -95,11 +100,14 @@ export function assertOk(result: SpawnSyncReturns): void { Before (SDK harness, simplified): ```ts -const server = await startServer({port: 0, isolation: deepIsolation()}) +const server = await startServer({ port: 0, isolation: deepIsolation() }) const client = makeClient(server.baseUrl) const session = await client.session.create() -await client.session.prompt({path: {id: session.id}, body: {parts: [{type: 'text', text: 'PING'}]}}) -const messages = await client.session.messages({path: {id: session.id}}) +await client.session.prompt({ + path: { id: session.id }, + body: { parts: [{ type: 'text', text: 'PING' }] }, +}) +const messages = await client.session.messages({ path: { id: session.id } }) expect(extractText(messages)).toMatch(/PONG/) await server.stop() ``` @@ -107,7 +115,10 @@ await server.stop() After (CLI subprocess): ```ts -const result = runOpencode({prompt: 'Reply with exactly PONG.', pluginUrl: `file://${distPath}/index.js`}) +const result = runOpencode({ + prompt: 'Reply with exactly PONG.', + pluginUrl: `file://${distPath}/index.js`, +}) assertOk(result) expect(result.stdout).toMatch(/PONG/) ``` diff --git a/docs/solutions/best-practices/secure-process-introspection-array-spawn-ps-comm-field-2026-04-28.md b/docs/solutions/best-practices/secure-process-introspection-array-spawn-ps-comm-field-2026-04-28.md index 226a5d21..343dc509 100644 --- a/docs/solutions/best-practices/secure-process-introspection-array-spawn-ps-comm-field-2026-04-28.md +++ b/docs/solutions/best-practices/secure-process-introspection-array-spawn-ps-comm-field-2026-04-28.md @@ -1,4 +1,5 @@ --- + title: Secure Process Introspection with Array-Spawn ps and Comm-Field Identity Gate date: 2026-04-28 category: best-practices @@ -7,18 +8,19 @@ problem_type: best_practice component: development_workflow severity: high applies_when: - - Verifying process ownership securely - - Preventing PID reuse and tampering - - Using kernel-tracked process identity - - Safely spawning system commands -tags: - - process-introspection - - ps-command - - comm-field - - lstart-matching - - secure-spawning - - injection-prevention - - kernel-truth +- Verifying process ownership securely +- Preventing PID reuse and tampering +- Using kernel-tracked process identity +- Safely spawning system commands + tags: +- process-introspection +- ps-command +- comm-field +- lstart-matching +- secure-spawning +- injection-prevention +- kernel-truth + --- # Secure Process Introspection with Array-Spawn ps and Comm-Field Identity Gate @@ -60,11 +62,11 @@ That is the important spawn pattern: no shell, no string interpolation into a sh The second detail is easy to miss and worth preserving as part of the pattern, not a throwaway implementation note: ```ts - child.stdout?.on('data', (chunk: Buffer) => { - stdout += chunk.toString('utf-8') - }) - // Drain stderr so the OS pipe buffer never fills under backpressure. - child.stderr?.resume() +child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf-8') +}) +// Drain stderr so the OS pipe buffer never fills under backpressure. +child.stderr?.resume() ``` If a subprocess writes to `stderr` and the parent never drains it, the kernel pipe can fill. Once full, the child blocks on write, which means it may never exit, which means your seemingly harmless introspection helper can hang the system in exactly the path that is supposed to make cleanup safer. @@ -72,20 +74,20 @@ If a subprocess writes to `stderr` and the parent never drains it, the kernel pi The wrapper also normalizes failure to a safe default: ```ts - child.on('close', (code) => { - clearTimeout(timeout) - if (timedOut || code !== 0) { - resolve(null) - } else { - const trimmed = stdout.trim() - resolve(trimmed || null) - } - }) - - child.on('error', () => { - clearTimeout(timeout) - resolve(null) - }) +child.on('close', (code) => { + clearTimeout(timeout) + if (timedOut || code !== 0) { + resolve(null) + } else { + const trimmed = stdout.trim() + resolve(trimmed || null) + } +}) + +child.on('error', () => { + clearTimeout(timeout) + resolve(null) +}) ``` Timeout, spawn error, non-zero exit, and empty output all become `null`. That is fine here because the caller only needs one answer: "can I still prove this is the same process?" If not, skip the kill. diff --git a/docs/solutions/developer-experience/opencode-debug-diagnostics-2026-04-26.md b/docs/solutions/developer-experience/opencode-debug-diagnostics-2026-04-26.md index 77f7f566..c7c386a2 100644 --- a/docs/solutions/developer-experience/opencode-debug-diagnostics-2026-04-26.md +++ b/docs/solutions/developer-experience/opencode-debug-diagnostics-2026-04-26.md @@ -1,4 +1,5 @@ --- + title: Diagnosing OpenCode failures with debug logs date: 2026-04-26 category: developer-experience @@ -7,18 +8,19 @@ problem_type: developer_experience component: development_workflow severity: medium applies_when: - - An opencode test or invocation hangs or times out without an obvious local cause - - "build ยท model" stalls without progress, or `assistant[]` comes back empty - - You suspect an upstream regression, plugin-loading bug, or isolation problem - - Anytime you find yourself forming a theory before reading logs -tags: - - opencode - - debugging - - print-logs - - log-level - - rate-limit - - api-errors - - troubleshooting +- An opencode test or invocation hangs or times out without an obvious local cause +- "build ยท model" stalls without progress, or `assistant[]` comes back empty +- You suspect an upstream regression, plugin-loading bug, or isolation problem +- Anytime you find yourself forming a theory before reading logs + tags: +- opencode +- debugging +- print-logs +- log-level +- rate-limit +- api-errors +- troubleshooting + --- # Diagnosing OpenCode failures with debug logs diff --git a/docs/solutions/integration-issues/two-entrypoint-rpc-tui-hardening-2026-05-01.md b/docs/solutions/integration-issues/two-entrypoint-rpc-tui-hardening-2026-05-01.md index 2b964213..cff3d51b 100644 --- a/docs/solutions/integration-issues/two-entrypoint-rpc-tui-hardening-2026-05-01.md +++ b/docs/solutions/integration-issues/two-entrypoint-rpc-tui-hardening-2026-05-01.md @@ -1,4 +1,5 @@ --- + title: Hardening Two-Entrypoint RPC/TUI Plugin Integration date: 2026-05-01 category: integration-issues @@ -6,17 +7,18 @@ module: copilot-status-tui-foundation problem_type: integration_issue component: tooling symptoms: - - server tools could fail to register when the TUI RPC server could not start - - the packaged plugin could expose a broken ./tui entrypoint or omit the built TUI bundle - - the TUI could render stale async results after cleanup or hold stale task selection during cancel flows - - cancellation progress was not visible through RPC/TUI while a cancel was in flight -root_cause: incomplete_setup -resolution_type: code_fix -severity: high -related_components: - - development_workflow - - testing_framework -tags: [copilot-status, tui, rpc, two-entrypoint, packaging, cancellation, localhost-auth, port-file] +- server tools could fail to register when the TUI RPC server could not start +- the packaged plugin could expose a broken ./tui entrypoint or omit the built TUI bundle +- the TUI could render stale async results after cleanup or hold stale task selection during cancel flows +- cancellation progress was not visible through RPC/TUI while a cancel was in flight + root_cause: incomplete_setup + resolution_type: code_fix + severity: high + related_components: +- development_workflow +- testing_framework + tags: [copilot-status, tui, rpc, two-entrypoint, packaging, cancellation, localhost-auth, port-file] + --- # Hardening Two-Entrypoint RPC/TUI Plugin Integration @@ -104,10 +106,13 @@ Startup cleanup should delete only stale port-file artifacts, not the session di ```ts const stalePortFiles = entries.filter( - (entry) => entry === 'server-port.json' || entry.startsWith('server-port.json.tmp.'), + (entry) => + entry === 'server-port.json' || entry.startsWith('server-port.json.tmp.'), ) -await Promise.all(stalePortFiles.map((entry) => rm(join(portFileDir, entry), { force: true }))) +await Promise.all( + stalePortFiles.map((entry) => rm(join(portFileDir, entry), { force: true })), +) ``` Cancellation uses an internal transition state. RPC/TUI can show `cancelling`, while the public `copilot_output` envelope keeps the existing `cancelled` contract: diff --git a/docs/solutions/ui-bugs/re-entrant-dialog-close-froze-copilot-status-on-escape-2026-05-02.md b/docs/solutions/ui-bugs/re-entrant-dialog-close-froze-copilot-status-on-escape-2026-05-02.md index 37a18a16..88e69d34 100644 --- a/docs/solutions/ui-bugs/re-entrant-dialog-close-froze-copilot-status-on-escape-2026-05-02.md +++ b/docs/solutions/ui-bugs/re-entrant-dialog-close-froze-copilot-status-on-escape-2026-05-02.md @@ -1,4 +1,5 @@ --- + title: Re-entrant dialog close froze /copilot-status on Escape date: 2026-05-02 category: ui-bugs @@ -6,15 +7,16 @@ module: tui problem_type: ui_bug component: tooling symptoms: - - Pressing Escape in `/copilot-status` froze the entire OpenCode TUI instead of closing the modal. - - The freeze also affected the modal load-error path while switching to the unavailable alert. - - Layout and input rewrites changed the presentation but did not remove the freeze. -root_cause: wrong_api -resolution_type: code_fix -severity: high -related_components: - - testing_framework -tags: [tui, copilot-status, dialog-lifecycle, escape-key, modal, re-entrant-close] +- Pressing Escape in `/copilot-status` froze the entire OpenCode TUI instead of closing the modal. +- The freeze also affected the modal load-error path while switching to the unavailable alert. +- Layout and input rewrites changed the presentation but did not remove the freeze. + root_cause: wrong_api + resolution_type: code_fix + severity: high + related_components: +- testing_framework + tags: [tui, copilot-status, dialog-lifecycle, escape-key, modal, re-entrant-close] + --- # Re-entrant dialog close froze /copilot-status on Escape diff --git a/docs/solutions/workflow-issues/bootstrap-missing-github-release-2026-04-26.md b/docs/solutions/workflow-issues/bootstrap-missing-github-release-2026-04-26.md index 64a9b4fe..d4fc9fe1 100644 --- a/docs/solutions/workflow-issues/bootstrap-missing-github-release-2026-04-26.md +++ b/docs/solutions/workflow-issues/bootstrap-missing-github-release-2026-04-26.md @@ -1,4 +1,5 @@ --- + title: Missing GitHub Release for already-published npm package date: 2026-04-26 category: workflow-issues @@ -7,16 +8,17 @@ problem_type: workflow_issue component: development_workflow severity: medium applies_when: - - Bootstrapping the first release of a new npm package using changesets/action and Trusted Publishing - - A version is manually published to npm before changesets/action runs for that version - - Auditing a release pipeline that skipped a version's GitHub Release or git tag -tags: - - changesets-action - - github-release - - npm-publish - - trusted-publishing - - workflow-run - - release-pipeline +- Bootstrapping the first release of a new npm package using changesets/action and Trusted Publishing +- A version is manually published to npm before changesets/action runs for that version +- Auditing a release pipeline that skipped a version's GitHub Release or git tag + tags: +- changesets-action +- github-release +- npm-publish +- trusted-publishing +- workflow-run +- release-pipeline + --- # Missing GitHub Release for already-published npm package diff --git a/mise.toml b/mise.toml index ae45e062..2c8150fd 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ [tools] bun = "1.4.4" -"npm:opencode-ai" = "1.19.3" -"npm:@github/copilot" = "1.0.101" +"npm:opencode-ai" = "1.19.5" +"npm:@github/copilot" = "1.0.105" diff --git a/package.json b/package.json index 95027dff..fd4aa3fa 100644 --- a/package.json +++ b/package.json @@ -55,11 +55,11 @@ "@opencode-ai/plugin": ">=1.14.41" }, "devDependencies": { - "@biomejs/biome": "2.5.15", + "@biomejs/biome": "2.6.4", "@changesets/cli": "2.31.1", - "@opencode-ai/plugin": "1.19.3", + "@opencode-ai/plugin": "1.19.5", "@types/babel__core": "7.20.5", - "@types/bun": "1.4.3", + "@types/bun": "1.4.4", "@types/node": "24.19.2", "rimraf": "6.1.3", "typescript": "7.0.2" diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index fe2084bc..05fa7176 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -4,10 +4,10 @@ Captured from live `copilot` CLI output for testing the JSONL parser. ## Provenance -| Fixture | CLI version | Capture date | Notes | -|---|---|---|---| -| `happy-path.jsonl`, `multi-tool.jsonl`, `model-error.jsonl` | 1.0.34 | 2026-04-23 | Original parser-test set; `--model` switching variants | -| `connect-mismatch.jsonl`, `resume-mismatch.jsonl` | 1.0.40 | 2026-05-02 | Continuity-feature research captures (Unit 3a) | +| Fixture | CLI version | Capture date | Notes | +| ----------------------------------------------------------- | ----------- | ------------ | ------------------------------------------------------ | +| `happy-path.jsonl`, `multi-tool.jsonl`, `model-error.jsonl` | 1.0.34 | 2026-04-23 | Original parser-test set; `--model` switching variants | +| `connect-mismatch.jsonl`, `resume-mismatch.jsonl` | 1.0.40 | 2026-05-02 | Continuity-feature research captures (Unit 3a) | **Command patterns:** - Original (1.0.34): `copilot -p "" --output-format json -s --allow-all-tools --no-ask-user --model ` @@ -44,19 +44,19 @@ Command: `copilot --resume=00000000-... --no-remote -p "noop" --output-format js ## Event Types Observed -| Type | Description | Ephemeral | -|------|-------------|-----------| -| `session.mcp_server_status_changed` | MCP server connected | Yes | -| `session.mcp_servers_loaded` | All MCP servers loaded | Yes | -| `session.skills_loaded` | Skills discovered | Yes | -| `session.tools_updated` | Tools updated with model | Yes | -| `user.message` | User prompt with transformed content | No | -| `assistant.turn_start` | Turn begins | No | -| `assistant.reasoning_delta` | Streaming reasoning chunk | Yes | -| `assistant.reasoning` | Full reasoning content | Yes | -| `assistant.message_delta` | Streaming message chunk | Yes | -| `assistant.message` | Complete message with tool requests | No | -| `tool.execution_start` | Tool invocation begins | No | -| `tool.execution_complete` | Tool result returned | No | -| `assistant.turn_end` | Turn ends | No | -| `result` | Session summary with usage stats | No | +| Type | Description | Ephemeral | +| ----------------------------------- | ------------------------------------ | --------- | +| `session.mcp_server_status_changed` | MCP server connected | Yes | +| `session.mcp_servers_loaded` | All MCP servers loaded | Yes | +| `session.skills_loaded` | Skills discovered | Yes | +| `session.tools_updated` | Tools updated with model | Yes | +| `user.message` | User prompt with transformed content | No | +| `assistant.turn_start` | Turn begins | No | +| `assistant.reasoning_delta` | Streaming reasoning chunk | Yes | +| `assistant.reasoning` | Full reasoning content | Yes | +| `assistant.message_delta` | Streaming message chunk | Yes | +| `assistant.message` | Complete message with tool requests | No | +| `tool.execution_start` | Tool invocation begins | No | +| `tool.execution_complete` | Tool result returned | No | +| `assistant.turn_end` | Turn ends | No | +| `result` | Session summary with usage stats | No | diff --git a/tests/fixtures/agents/repo/default.md b/tests/fixtures/agents/repo/default.md index dde591a3..a497aa0d 100644 --- a/tests/fixtures/agents/repo/default.md +++ b/tests/fixtures/agents/repo/default.md @@ -1,2 +1,3 @@ # Default (repo override attempt) + Should be filtered out โ€” builtins cannot be overridden. diff --git a/tests/fixtures/agents/user/default.md b/tests/fixtures/agents/user/default.md index 59475a11..1d315813 100644 --- a/tests/fixtures/agents/user/default.md +++ b/tests/fixtures/agents/user/default.md @@ -1,2 +1,3 @@ # Default (user override attempt) + Should be filtered out โ€” builtins cannot be overridden.