From 28461ee7af2c6a3c5778e751435ea1f95dd29347 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 5 Sep 2026 00:52:34 +0800 Subject: [PATCH 01/25] chore: record delivery binding for stable-release --- .specgit.yaml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 76c3984ba..e8b58978d 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,12 +1,10 @@ version: 1 -delivery: native-tool-settlement +delivery: stable-release context: kind: branch - branch: fix/538-native-tool-settlement + branch: chore/543-stable-release issues: - - 538 - - 540 + - 543 issueKinds: - - issue: 538 - kind: kind::fix -pr: 539 + - issue: 543 + kind: kind::chore From 3100332646a5f97e2ad20905e2a0c2655e3160ed Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 5 Sep 2026 00:52:55 +0800 Subject: [PATCH 02/25] chore: record delivery binding for stable-release --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index e8b58978d..f4e219ff2 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -8,3 +8,4 @@ issues: issueKinds: - issue: 543 kind: kind::chore +pr: 544 From 0e6c95ce387db1d84809f6811fa6d2173bb7b594 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 5 Sep 2026 00:54:57 +0800 Subject: [PATCH 03/25] docs(release): record accepted v1.0.40 evidence --- .github/releases/v1.0.40.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/releases/v1.0.40.md b/.github/releases/v1.0.40.md index b863bc2f3..2df486a0b 100644 --- a/.github/releases/v1.0.40.md +++ b/.github/releases/v1.0.40.md @@ -30,9 +30,9 @@ ### 🧪 Test Summary ``` -Integration CI baseline (dev 8060765fcc): +Release integration CI (PR #539, 00695dab86): core: 1225 pass, 6 skip, 0 fail -opencode: 4426 pass, 23 skip, 1 todo, 0 fail +opencode: 4429 pass, 23 skip, 1 todo, 0 fail HttpAPI coverage / auth / effect: 230 pass each, no failures or missing routes Generated client and SDK freshness: passed Typecheck, DAG core gate, Linux and Windows E2E: passed @@ -46,7 +46,7 @@ Merged opencode package typecheck: passed ### 🔍 Verification -The slow-tool regression was observed failing before the fix and passing afterward through the real session processor and a local HTTP model endpoint. Additional cases cover parallel local tools and explicit cancellation. Independent Standards and Spec reviews found no code blockers. The integration statistics above come from [dev CI](https://github.com/LeXwDeX/OpenCode-GraphAgent/actions/runs/33868550950); they identify the tested baseline and do not substitute for the final release PR's Typecheck, Linux unit, Linux/Windows E2E and SpecGit acceptance gates. Reported model usage in the regression is deterministic test input; no live model context limit is inferred from it. +The slow-tool regression was observed failing before the fix and passing afterward through the real session processor and a local HTTP model endpoint. Additional cases cover parallel local tools and explicit cancellation. Independent Standards, Spec and merge reviews found no code blockers. The integration statistics above come from the accepted [PR #539 CI](https://github.com/LeXwDeX/OpenCode-GraphAgent/actions/runs/33894718562), including generated client/SDK freshness and all three HttpAPI modes. [Typecheck, lint and DAG core](https://github.com/LeXwDeX/OpenCode-GraphAgent/actions/runs/33894718548), both E2E platforms and SpecGit 1.10.1 acceptance also passed before merge to dev. The release branch preserves that accepted runtime tree; [release PR #544](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/544) carries its own binding and main-targeted gates. Reported model usage in the regression is deterministic test input; no live model context limit is inferred from it. --- From 9fa793fb3525de83cdb1cb962d16db1fb687b679 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 5 Sep 2026 08:35:25 +0800 Subject: [PATCH 04/25] chore: record delivery binding for dag-release --- .specgit.yaml | 32 +++++--------------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 01e731c77..6f4ab4466 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,33 +1,11 @@ version: 1 -delivery: dag-audit-reliability +delivery: dag-release context: kind: worktree - label: opencode-dag-delivery-2026-09-05 - branch: fix/545-dag-audit-reliability + label: opencode-dag-main-release-2026-09-05 + branch: chore/554-dag-release issues: - - 545 - - 546 - - 547 - - 548 - - 549 - - 550 - - 551 - - 552 + - 554 issueKinds: - - issue: 545 - kind: kind::fix - - issue: 546 - kind: kind::fix - - issue: 547 - kind: kind::fix - - issue: 548 - kind: kind::fix - - issue: 549 - kind: kind::fix - - issue: 550 - kind: kind::fix - - issue: 551 - kind: kind::fix - - issue: 552 + - issue: 554 kind: kind::chore -pr: 553 From 72cc75ff9e02a7b68b41a555f37c61ce90d620a6 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 5 Sep 2026 08:35:53 +0800 Subject: [PATCH 05/25] chore: record delivery binding for dag-release --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 6f4ab4466..a61e3f2c4 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -9,3 +9,4 @@ issues: issueKinds: - issue: 554 kind: kind::chore +pr: 555 From 243481f407e88d3090d281e2f40fd67da2a595e2 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 5 Sep 2026 09:28:43 +0800 Subject: [PATCH 06/25] chore: record delivery binding for dag-release --- .specgit.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index a61e3f2c4..69e622331 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,11 +1,11 @@ version: 1 delivery: dag-release context: - kind: worktree - label: opencode-dag-main-release-2026-09-05 + kind: branch branch: chore/554-dag-release issues: - 554 + - 556 issueKinds: - issue: 554 kind: kind::chore From 62fa870e19c629c7513fde7de1c16556d06efeed Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 7 Sep 2026 11:13:11 +0800 Subject: [PATCH 07/25] chore: record delivery binding for hooks-runtime-reliability --- .specgit.yaml | 41 ++++++++++++++++++++++++++++++++++------- spec_git/policy.yaml | 3 +++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index f4e219ff2..1471fb80e 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,11 +1,38 @@ version: 1 -delivery: stable-release +delivery: hooks-runtime-reliability context: - kind: branch - branch: chore/543-stable-release + kind: worktree + label: opencode-hooks-fix + branch: fix/557-hooks-runtime-reliability issues: - - 543 + - 557 + - 558 + - 559 + - 560 + - 561 + - 562 + - 563 + - 564 + - 565 + - 566 issueKinds: - - issue: 543 - kind: kind::chore -pr: 544 + - issue: 557 + kind: kind::fix + - issue: 558 + kind: kind::fix + - issue: 559 + kind: kind::fix + - issue: 560 + kind: kind::fix + - issue: 561 + kind: kind::fix + - issue: 562 + kind: kind::fix + - issue: 563 + kind: kind::fix + - issue: 564 + kind: kind::fix + - issue: 565 + kind: kind::fix + - issue: 566 + kind: kind::fix diff --git a/spec_git/policy.yaml b/spec_git/policy.yaml index fe3768c27..3d6b9f65a 100644 --- a/spec_git/policy.yaml +++ b/spec_git/policy.yaml @@ -2,3 +2,6 @@ version: 1 required_checks: - Typecheck - Unit Tests (linux) +automation: + merge: false + close_issues: false From deb8b7e6290120fe1361f42f2789eefcb3b7f5d9 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 7 Sep 2026 11:13:58 +0800 Subject: [PATCH 08/25] chore: record delivery binding for hooks-runtime-reliability --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 1471fb80e..ce102a001 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -36,3 +36,4 @@ issueKinds: kind: kind::fix - issue: 566 kind: kind::fix +pr: 567 From eba6221ca5e93d1ed0e5da47acfc9e7941d8e4c8 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 7 Sep 2026 12:07:37 +0800 Subject: [PATCH 09/25] chore: refresh SpecGit 1.14 delivery harness --- .github/workflows/specgit-accept.yml | 183 +++++++++++++++++++----- .gitignore | 13 ++ .opencode/hooks/specgit-merge-guard.sh | 190 +++++++++++++++++++++++-- AGENTS.md | 117 ++++++++++----- 4 files changed, 419 insertions(+), 84 deletions(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 93d6a6166..2d8ce8d51 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -2,22 +2,19 @@ name: SpecGit Acceptance on: pull_request: - branches: [main] + branches: ["main"] # A draft PR fails the verdict (pr_draft), so the draft→ready # transition must re-verdict. Listing types replaces the defaults, - # so the default activity types are listed alongside. - types: [opened, synchronize, reopened, ready_for_review] - # No workflow_dispatch (local specialization): dispatch is the privileged - # context that fires CodeQL's cache-poisoning taint rule on the head_ref - # checkout (false positive: no cache use, read-only token, - # persist-credentials: false), and on dispatch events head_ref is empty so - # the verdict would evaluate the default branch — the wrong tree. Delivery - # here always goes through a PR. + # so the default activity types are listed alongside. Title and body + # edits change live acceptance evidence even when the head is unchanged. + types: [opened, synchronize, reopened, ready_for_review, edited] + # Local specialization: acceptance runs only against a bound pull request. permissions: contents: read issues: read pull-requests: read + actions: read # One verdict per head at a time (#319): a newer trigger event (a push # after the draft opened, then ready_for_review) supersedes the older @@ -35,33 +32,46 @@ jobs: # project's own toolchain (package manager, lockfile, build, layout) # is never assumed and never invoked. runs-on: ubuntu-latest - # Local specialization: must exceed the slowest required sibling - # (Unit Tests (linux) runs ~28min on PRs) — the verdict waits for every - # policy check to reach a terminal state before evaluating. + # Unit Tests (linux) can take about 28 minutes on pull requests. timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # Check out the PR head branch by name so HEAD is on the branch - # (not the detached merge ref): the execution context gate reads - # live git. Falls back to the default ref on non-PR events. - ref: ${{ github.head_ref || github.ref }} + # Pin execution to this event; a newer branch push must not change + # the code tested by an older run. Manual dispatch uses its own SHA. + ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 persist-credentials: false + - name: Restore the event branch + if: github.event_name == 'pull_request' || github.ref_type == 'branch' + env: + SPECGIT_BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + git check-ref-format --branch "$SPECGIT_BRANCH" >/dev/null + git switch --create "$SPECGIT_BRANCH" + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' + package-manager-cache: false - name: Install pinned SpecGit CLI - # Local specialization — install GLOBALLY, not `npm install --no-save - # specgit@X`: a workspace-local install reads this bun workspace's - # package.json and dies on the `catalog:` protocol (EUNSUPPORTEDPROTOCOL, - # #434/#459). Exact pin on purpose: the gate must evaluate with the - # same CLI generation that wrote the binding (1.10.1 re-init). - run: npm install -g --no-audit --no-fund specgit@1.10.1 + # Exact version on purpose (no ^): the gate must evaluate with the + # same CLI generation that wrote the binding; upgrades are a + # deliberate re-init. An isolated prefix avoids installing the + # adopting project's dependencies or running its lifecycle scripts. + run: npm install --prefix "$RUNNER_TEMP/specgit-cli" --no-save --no-audit --no-fund specgit@1.14.0 + + - name: Prepare approved policy for acceptance + env: + GH_TOKEN: ${{ github.token }} + SPECGIT_WAIT_POLICY: ${{ runner.temp }}/specgit-policy.yaml + run: | + gh auth setup-git + node "$RUNNER_TEMP/specgit-cli/node_modules/specgit/dist/automation/workflow-policy.js" - name: Wait for sibling checks # The verdict must see the OTHER required checks in a terminal @@ -78,21 +88,23 @@ jobs: WAIT_REPO: ${{ github.repository }} WAIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} WAIT_PR: ${{ github.event.pull_request.number || '' }} + WAIT_POLICY: ${{ runner.temp }}/specgit-policy.yaml + SPECGIT_CLI_DIR: ${{ runner.temp }}/specgit-cli run: | node --input-type=module <<'EOF' import { existsSync, readFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; - if (!existsSync('spec_git/policy.yaml')) { + import { createRequire } from 'node:module'; + const { parse } = process.env.SPECGIT_CLI_DIR + ? createRequire(process.env.SPECGIT_CLI_DIR + '/node_modules/specgit/package.json')('yaml') + : await import('yaml'); + const policyPath = process.env.WAIT_POLICY || 'spec_git/policy.yaml'; + if (!existsSync(policyPath)) { console.error('spec_git/policy.yaml is absent at this head — an adoption PR carries no binding commit yet (expected once; merge it before enabling branch protection), and a delivery PR must carry it via specgit issue.'); process.exit(1); } - // Local specialization — minimal hand parse of policy.yaml's - // required_checks block: this bun-based repo does not expose a - // root-reachable `yaml` package (workspace catalog isolation), so - // `import { parse } from 'yaml'` would fail to resolve here. - const policy = readFileSync('spec_git/policy.yaml', 'utf8'); - const section = policy.slice(policy.indexOf('required_checks:')); - const required = [...section.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => m[1].trim()); + const policy = parse(readFileSync(policyPath, 'utf8')); + const required = policy.required_checks ?? []; // gh.cmd needs a shell on Windows; POSIX execs the binary // directly (shell stays off where it is not needed). The @@ -169,6 +181,109 @@ jobs: } } }; + /** @param {unknown} value @returns {value is number} */ + function positiveIdentity(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; + } + + /** @param {unknown} app */ + export function isGithubActionsApp(app) { + return typeof app === 'object' && app !== null && + (('slug' in app && app.slug === 'github-actions') || ('id' in app && app.id === 15368)); + } + + /** + * One ownership decision for the provider and embedded wait program. + * Callers prove the head and list completeness before passing normalized rows. + * Pending owners stay pending; callers decide which jobs must wait for them. + * @template {import('./actions-ownership.mjs').ActionsWorkflow} T + * @param {readonly T[]} workflows + * @returns {import('./actions-ownership.mjs').ActionsOwnership} + */ + export function createActionsOwnership(workflows) { + /** @type {Map} */ + const latest = new Map(); + /** @type {Map} */ + const owners = new Map(); + const ids = new Set(); + for (const workflow of workflows) { + const check = workflow.check; + if (typeof workflow.key !== 'string' || !workflow.key || + !positiveIdentity(check.id) || !positiveIdentity(workflow.checkSuiteId) || + !positiveIdentity(workflow.runAttempt) || typeof check.startedAt !== 'string' || + !Number.isFinite(Date.parse(check.startedAt)) || owners.has(workflow.checkSuiteId) || + ids.has(check.id) || !['queued', 'in_progress', 'completed', 'waiting', 'pending', 'requested'].includes(check.status)) { + throw new Error('GitHub returned incomplete or ambiguous Actions workflow ownership.'); + } + owners.set(workflow.checkSuiteId, workflow); + ids.add(check.id); + const previous = latest.get(workflow.key); + const started = Date.parse(check.startedAt); + if (!previous || started > Date.parse(previous.check.startedAt ?? '') || + (started === Date.parse(previous.check.startedAt ?? '') && check.id > previous.check.id)) { + latest.set(workflow.key, workflow); + } + } + return { + latest: [...latest.values()], + currentFor(checkSuiteId) { + if (!positiveIdentity(checkSuiteId)) { + throw new Error('GitHub returned an Actions check without a check-suite identity.'); + } + const owner = owners.get(checkSuiteId); + if (!owner) throw new Error('The Actions check has no proven owning workflow run.'); + return latest.get(owner.key)?.check.id === owner.check.id ? owner : null; + }, + }; + } + const isActions = (check) => isGithubActionsApp(check.app); + const listWorkflowsWithRetry = async (page) => { + for (let attempt = 1; ; attempt++) { + try { + return JSON.parse(execFileSync('gh', [ + 'api', 'repos/' + process.env.WAIT_REPO + '/actions/runs', '--method', 'GET', + '--field', 'head_sha=' + process.env.WAIT_SHA, '--field', 'per_page=' + PER_PAGE, '--field', 'page=' + page, + ], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' })); + } catch (error) { + const text = String(error) + ' ' + String(error && error.stderr ? error.stderr : ''); + if (attempt >= 5 || !/HTTP 5\d\d|HTTP 429|ETIMEDOUT|ECONNRESET|ENOTFOUND|timed out/i.test(text)) throw error; + await new Promise((resolve) => setTimeout(resolve, Math.min(30000, 2000 * 2 ** (attempt - 1)))); + } + } + }; + const currentExecutionChecks = async (checks) => { + if (!checks.some(isActions)) return checks; + const workflows = []; + const positive = (value) => Number.isSafeInteger(value) && value > 0; + let total; + for (let page = 1; page <= 10; page++) { + const payload = await listWorkflowsWithRetry(page); + if (!Array.isArray(payload?.workflow_runs) || payload.workflow_runs.length > PER_PAGE + || !Number.isSafeInteger(payload.total_count) || payload.total_count < 0) throw new Error('GitHub Actions workflow evidence is malformed.'); + if (payload.total_count > 1000) throw new Error('GitHub Actions workflow evidence exceeds the 1000-run API limit.'); + if (total !== undefined && total !== payload.total_count) throw new Error('GitHub Actions workflow evidence changed during pagination.'); + total = payload.total_count; + for (const run of payload.workflow_runs) { + if (!positive(run?.workflow_id) || run.head_sha !== process.env.WAIT_SHA + || typeof run.event !== 'string' || run.event.length === 0) throw new Error('GitHub Actions workflow ownership is missing, invalid, or ambiguous.'); + workflows.push({ key: JSON.stringify([run.workflow_id, run.event]), checkSuiteId: run.check_suite_id, + runAttempt: run.run_attempt, check: { id: run.id, startedAt: run.run_started_at, status: run.status } }); + } + if (payload.workflow_runs.length < PER_PAGE) break; + if (page === 10) throw new Error('GitHub Actions workflow evidence reached the pagination limit.'); + } + if (workflows.length !== total) throw new Error('GitHub Actions workflow evidence is incomplete.'); + const ownership = createActionsOwnership(workflows); + return checks.flatMap((check) => { + if (!isActions(check)) return [check]; + let owner; + try { owner = ownership.currentFor(check.check_suite?.id); } + catch { throw new Error('A GitHub Actions check has no verified workflow owner.'); } + if (owner === null) return []; + // A rerun can retain completed jobs from its prior attempt until new jobs register. + return [owner.check.status === 'completed' ? check : { ...check, status: 'in_progress', conclusion: null }]; + }); + }; const terminal = new Set(['completed']); const PER_PAGE = 100; // #300: page the listing to exhaustion — a head with more than @@ -227,10 +342,6 @@ jobs: // being killed by the job timeout mid-line. - // Local specialization: 40min because the slowest required - - // sibling (Unit Tests (linux)) runs ~28min on PRs. - const deadline = Date.now() + 40 * 60 * 1000; while (Date.now() < deadline) { // #315: re-read the anchor every cycle — the transition @@ -247,7 +358,7 @@ jobs: + (error && error.message ? error.message : String(error))); process.exit(1); } - const runs = await fetchAllCheckRuns(); + const runs = await currentExecutionChecks(await fetchAllCheckRuns()); // #119: re-runs keep every same-name run; terminality is // decided on the truth run — latest started_at, ties broken // by the higher check-run id (docs/reference.md) — never on @@ -304,6 +415,6 @@ jobs: - name: specgit finish - run: specgit finish --json + run: '"$RUNNER_TEMP/specgit-cli/node_modules/.bin/specgit" finish --json' env: GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index f2caa4f7c..56e3391c7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,17 @@ tsconfig.tsbuildinfo # >>> specgit: local delivery assets (managed by specgit init) >>> /.specgit.yaml /spec_git/ +/.opencode/hooks/specgit-merge-guard.sh +/.opencode/command/specgit-issue.md +/.agents/skills/specgit-issue/SKILL.md +/.opencode/command/specgit-finish.md +/.agents/skills/specgit-finish/SKILL.md +/.opencode/command/specgit-doctor.md +/.agents/skills/specgit-doctor/SKILL.md +/.opencode/command/specgit-pr.md +/.agents/skills/specgit-pr/SKILL.md +/.opencode/command/specgit-status.md +/.agents/skills/specgit-status/SKILL.md +/.local/state/ +/.local/cache/ # <<< specgit: local delivery assets (managed by specgit init) <<< diff --git a/.opencode/hooks/specgit-merge-guard.sh b/.opencode/hooks/specgit-merge-guard.sh index 01981b809..49e34c573 100755 --- a/.opencode/hooks/specgit-merge-guard.sh +++ b/.opencode/hooks/specgit-merge-guard.sh @@ -17,7 +17,7 @@ case "$tool" in # (branch "feat/1-a" must never satisfy a record for "feat/1-a2"). branch=$(git branch --show-current 2>/dev/null) if [ -z "$branch" ] || [ ! -f .specgit.yaml ] || ! grep -qFx " branch: $branch" .specgit.yaml; then - echo "specgit: start gate - this branch has no delivery binding. Start the delivery first: specgit issue \": \", then fill each issue body from the discussion, then edit files." >&2 + echo "specgit: start gate - this branch has no delivery binding. Start the delivery first according to the managed guidance: prepare any required body files, then run specgit issue \"<type>: <title>\" with them before editing files." >&2 exit 2 fi exit 0 @@ -25,19 +25,161 @@ case "$tool" in esac command=$(printf '%s' "$payload" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_input&&j.tool_input.command)||'')}catch{process.stdout.write('')}})") -case "$command" in - gh\ pr\ merge*|glab\ mr\ merge*) +# Classify statically visible forge-merge commands without executing or +# expanding shell input. The lexer understands quoting, command separators, +# environment assignments, env/command/exec wrappers, and forge-global repo +# selectors. It deliberately inspects only each simple command's executable +# and leading global options, so quoted prose such as echo "gh pr merge" does +# not trigger the gate. +merge_command=$(printf '%s' "$command" | node -e ' + const fs = require("fs"); + const source = fs.readFileSync(0, "utf8"); + const SQ = String.fromCharCode(39); + const DQ = String.fromCharCode(34); + const BS = String.fromCharCode(92); + + function tokenize(text) { + const tokens = []; + let word = ""; + let active = false; + let quote = 0; + const flush = () => { + if (!active) return; + tokens.push({ kind: "word", value: word }); + word = ""; + active = false; + }; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (quote === 39) { + if (char === SQ) quote = 0; + else word += char; + continue; + } + if (quote === 34) { + if (char === DQ) { + quote = 0; + } else if (char === BS) { + if (index + 1 >= text.length) return null; + word += text[++index]; + } else { + word += char; + } + continue; + } + if (char === SQ || char === DQ) { + quote = char === SQ ? 39 : 34; + active = true; + } else if (char === BS) { + if (index + 1 >= text.length) return null; + const next = text[++index]; + if (next !== "\n") { + word += next; + active = true; + } + } else if (char === " " || char === "\t" || char === "\r") { + flush(); + } else if (char === "\n" || char === ";" || char === "|" || + char === "&" || char === "(" || char === ")") { + flush(); + if ((char === "|" || char === "&") && text[index + 1] === char) index += 1; + tokens.push({ kind: "boundary" }); + } else if (char === "#" && !active) { + while (index + 1 < text.length && text[index + 1] !== "\n") index += 1; + } else { + word += char; + active = true; + } + } + if (quote !== 0) return null; + flush(); + return tokens; + } + + const executable = (word) => { + const base = word.replace(/\\/g, "/").split("/").pop() || ""; + return base.toLowerCase().replace(/\.exe$/, ""); + }; + const assignment = /^[A-Za-z_][A-Za-z0-9_]*=/; + + function unwrap(words) { + let index = 0; + while (assignment.test(words[index] || "")) index += 1; + if (executable(words[index] || "") === "env") { + index += 1; + while (assignment.test(words[index] || "")) index += 1; + } + while (["command", "exec"].includes(executable(words[index] || ""))) { + index += 1; + } + return words.slice(index); + } + + function isForgeMerge(segment) { + const words = unwrap(segment); + const forge = executable(words[0] || ""); + if (forge !== "gh" && forge !== "glab") return false; + let index = 1; + while (index < words.length) { + const option = words[index]; + if (["-R", "--repo", "--hostname"].includes(option)) { + if (index + 1 >= words.length) return false; + index += 2; + } else if (option === "--" || option.startsWith("--repo=") || + option.startsWith("--hostname=") || + (option.startsWith("-R") && option.length > 2)) { + index += 1; + if (option === "--") break; + } else { + break; + } + } + return forge === "gh" + ? words[index] === "pr" && words[index + 1] === "merge" + : words[index] === "mr" && words[index + 1] === "merge"; + } + + const tokens = tokenize(source); + if (tokens === null) { + process.stdout.write("indeterminate"); + } else { + let segment = []; + for (const token of [...tokens, { kind: "boundary" }]) { + if (token.kind === "word") { + segment.push(token.value); + } else { + if (isForgeMerge(segment)) { + process.stdout.write("merge"); + process.exit(0); + } + segment = []; + } + } + } +') +classifier_status=$? +if [ "$classifier_status" -ne 0 ]; then + merge_command=indeterminate +fi + +case "$merge_command" in + indeterminate) + echo "specgit: command blocked - the merge guard could not safely classify the shell input. Retry with a direct gh pr merge or glab mr merge command." >&2 + exit 2 + ;; + merge) exec node -e ' const { spawn } = require("child_process"); const fs = require("fs"); const path = require("path"); - const ghMsRaw = parseInt(process.env.SPECGIT_GH_TIMEOUT_MS || "", 10); - const ghMs = Number.isFinite(ghMsRaw) && ghMsRaw > 0 ? ghMsRaw : 15000; - const ghS = Math.max(1, Math.floor(ghMs / 1000)); - let budgetS = Math.max(60, ghS * 8); + const timeoutMs = ["SPECGIT_GH_TIMEOUT_MS", "SPECGIT_GLAB_TIMEOUT_MS"] + .map((name) => parseInt(process.env[name] || "", 10)) + .map((value) => Number.isFinite(value) && value > 0 ? value : 15000); + const providerS = Math.max(1, Math.ceil(Math.max(...timeoutMs) / 1000)); + let budgetS = Math.max(60, providerS * 8); const overrideRaw = parseInt(process.env.SPECGIT_GUARD_BUDGET_S || "", 10); if (Number.isFinite(overrideRaw) && overrideRaw > 0) { - budgetS = Math.max(overrideRaw, ghS); + budgetS = Math.max(overrideRaw, providerS); } // The hook runner kills long hooks; surface the mismatch instead of // being cut off mid-verdict. @@ -135,7 +277,7 @@ case "$command" in ); } else { lines.push( - "specgit: merge blocked - no verdict possible (evidence incomplete, exit " + code + "). This is not a rejection: fix evidence gathering (network, gh auth), then retry." + "specgit: merge blocked - no verdict possible (evidence incomplete, exit " + code + "). This is not a rejection: follow errors[].fix in the specgit finish --json result first. Run specgit doctor --json only for git, repository, origin, configured provider CLI/auth, or policy probes, then retry." ); } if (pending.length > 0) { @@ -167,9 +309,33 @@ case "$command" in }); ' ;; - git\ push\ origin\ main*|git\ push\ origin\ +main*|git\ push\ origin\ HEAD:main*) - echo "specgit: direct push to main is not the delivery path. Deliveries go: specgit issue -> PR -> CI -> specgit finish (exit 0) -> merge." >&2 - exit 2 +esac +unset classifier_status merge_command + +specgit_default_ref() { + specgit_ref=$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null) || return 1 + case "$specgit_ref" in + refs/remotes/origin/HEAD) return 1 ;; + refs/remotes/origin/?*) ;; + *) return 1 ;; + esac + git rev-parse --verify "$specgit_ref^{commit}" >/dev/null 2>&1 || return 1 + printf '%s' "$specgit_ref" +} + +case "$command" in + git\ push\ origin\ *) + default_ref=$(specgit_default_ref) || { + echo "specgit: cannot prove origin/HEAD. Run git fetch origin and git remote set-head origin -a before pushing." >&2 + exit 2 + } + default_branch=${default_ref#refs/remotes/origin/} + case "$command" in + git\ push\ origin\ "$default_branch"|git\ push\ origin\ "$default_branch"\ *|git\ push\ origin\ +"$default_branch"|git\ push\ origin\ +"$default_branch"\ *|git\ push\ origin\ HEAD:"$default_branch"|git\ push\ origin\ HEAD:"$default_branch"\ *) + echo "specgit: direct push to $default_branch is not the delivery path. Deliveries go: specgit issue -> PR/MR -> CI -> specgit finish (exit 0) -> merge." >&2 + exit 2 + ;; + esac ;; esac exit 0 diff --git a/AGENTS.md b/AGENTS.md index 34724ac48..9e32af2d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -292,42 +292,55 @@ already exists); keep manual guidance outside them. ### The delivery story - Start with `specgit issue <title-or-number>...`: it creates or reuses - the issues, branches, opens the draft pull request pre-filled with a - deterministic scaffold (the `Closes #n` line for every bound issue, - then Why / What changed / Evidence / Checklist sections), and writes - `.specgit.yaml`. Re-running resumes; it is idempotent. -- Issue bodies are filled at bootstrap, from the conversation: right after - `specgit issue` succeeds, edit each issue it created (`gh issue edit <n>`) - with the discussed Why / Scope / Approach / Acceptance, then implement. - The PR scaffold's placeholders are advisory — fill those sections in as - you deliver; the closing references are the only body gate. The PR body - is written once at creation; no SpecGit command edits an existing PR - body, and the repository's own pull-request template is never read. -- A draft pull request always fails the verdict (`pr_draft`): before + the issues, writes and pushes the initial binding on the delivery branch, + opens the draft pull or merge request with the supplied body, selected policy + template, or built-in scaffold, then records and pushes its number. Re-running + resumes; it is idempotent. +- Use the issue and PR/MR templates explicitly selected by policy. With + `validation.bodies` or `required_sections`, prepare complete content from + the discussion before bootstrap and supply `--body-file <path>` per new + title and `--pr-body-file <path>`. Without enforced body rules, the selected + policy template or built-in scaffold can be filled after creation. Preserve + every `Closes #n`; enabled body + rules apply at creation and acceptance. Resume keeps existing remote bodies + and user edits. Unselected repository templates are not silently loaded. +- A draft PR/MR always fails the verdict (`pr_draft`): before `specgit finish`, mark it ready for review — `gh pr ready <number>` on GitHub, `glab mr update <number> --ready` on GitLab. -- Finish with `specgit finish`: the verdict, derived from real git, PR, - and CI evidence. Exit code 0 is the only "done". +- `specgit finish` is read-only: its verdict comes from real git, PR/MR, + and CI evidence; exit 0 means accepted. With automation enabled, the trusted + remote workflow continues after CI without another confirmation. + `specgit pr --merge --json` is the recovery path: it verifies the approved + `target_branch`, fresh acceptance, and all current-head CI, then confirms + the merge and every bound issue closure before reporting completed. + A failed closure remains recoverable and is never reported as completed. ### Issue tags +- Follow the project's `language` for issues and PRs/MRs. Enabled `validation` + rules check titles and labels before creation and during `finish`. + `kind` mode requires one catalog kind and only declared extras; + `project` mode selects only policy `tags`. Users choose rule changes with + `specgit init --force --configure-rules`. - Every bootstrap applies the title's `kind::<type>` member automatically; pass `--tags <a,b>` to choose the full set explicitly. - Selection is pool-first: existing on-spec labels win verbatim; anything missing is seeded from the built-in `kind::` catalog or the policy's `tags:` declarations. Unknown vocabulary exits 2 naming the universe. -- Choose with restraint: at most one label per axis, none when unsure — +- Choose at most one label per axis; omit uncertain optional labels and + keep every label required by the selected policy. Existing pool labels + cannot override that policy — off-spec pool labels are reported (`tag_pool_dirty` warnings are for humans) and never renamed by SpecGit. ### Repair and diagnostics -- `specgit pr` repairs the pull-request binding: with no arguments it - auto-discovers the pull request for this head branch, errors with a fix +- `specgit pr` repairs the PR/MR binding: with no arguments it + auto-discovers the request for this head branch, errors with a fix when none is found, and refuses with a list when several match. - `specgit status` shows local evidence only: record, state, drift, - origin. `specgit doctor` probes git, repository, origin, gh, and - policy. + origin. `specgit doctor` probes git, repository, origin, the configured + provider CLI (`gh`, or `glab` for a declared GitLab host), and policy. ### The command surface @@ -337,14 +350,23 @@ already exists); keep manual guidance outside them. - `specgit setup` installs the agent entry points (commands for opencode, portable skills for other tools); `specgit bind`, `specgit unbind`, and `specgit accept` are automation aliases for scripts and CI. +- Automation defaults to off (`--automation no`). For a fresh policy, only + when the user personally chooses yes may they enable it with + `specgit init --automation yes --merge-target <branch>`. To change an + existing policy, use + `specgit init --force --automation yes --merge-target <branch>`; plain + `init --force` preserves its current choice and target. An agent must not + answer yes for the user. ### Before creating an issue, check for duplicates - Before running `specgit issue` with a new title, search the tracker for - similar open work: `gh issue list` with keywords from the title - (state, labels, and search terms via `gh search issues`). -- Open and read every plausible candidate (`gh issue view <n>`) — compare - the WHY, not just the wording. + similar open work through the authenticated session: on GitHub use + `gh issue list --state open --search "<keywords>"`; on GitLab use + `glab issue list --search "<keywords>" --in title`. Narrow + further with labels when useful. +- Open and read every plausible candidate with `gh issue view <n>` on GitHub + or `glab issue view <n>` on GitLab — compare the WHY, not just the wording. - If a candidate covers the same WHY, continue that issue instead of creating a new one; if it is close but different, say how they differ. - When unsure, ask the requester to decide between continuing the existing @@ -366,28 +388,51 @@ verified on its own evidence, split it before binding. ### Agent contract essentials -- **SpecGit is the default way of working here.** Any non-trivial - task — a feature, a fix, a refactor, a docs change — is a delivery: +- **SpecGit is the default delivery workflow here.** An intended tracked + change — a feature, a fix, a refactor, a docs change, or shared rules — is a delivery: work items live in this tracker as issues, never in private task lists or conversational checklists. The trigger is the decision to start: the moment the conversation settles and you begin turning the plan into changes, the FIRST action is - `specgit issue <type>: <title>...` — before any file edit. + `specgit issue <type>: <title>...` — before tracked implementation edits. + Preparing temporary body files for bootstrap is part of this first step. Working without a binding is a contract violation, not a style - choice. Immediately after bootstrap, fill each issue body - (Why / Scope / Approach / Acceptance) from the discussion with - `gh issue edit`, then implement. Mid-conversation inventories + choice. After bootstrap, verify each issue contains the discussed + Why / Scope / Approach / Acceptance and fill only missing content with + `gh issue edit` or `glab issue update`, + then implement. Mid-conversation inventories ("let me list everything to do") become issues, not chat artifacts. Trivial replies and read-only questions need none of this. -- The one rule: a delivery is done if and only if `specgit finish` - exits `0`. Never declare completion from task lists, file states, or - test runs you performed yourself. +- Local maintenance: installing or upgrading the CLI and running `init` / + `setup` to refresh local configuration and entry points need no issue, PR/MR, + product build, or release when no product or shared-rule change is intended + for commit. After a package upgrade, a human may run plain `specgit init` + and approve its guided refresh when it proves drift; non-interactive agents + run `specgit init --force --no-protect`, then `specgit setup --tool all`, + then verify `specgit status --json`. Append `--no-ignore` to init when + authoritative delivery files are intentionally tracked without the managed + ignore block; setup preserves that proven choice. Review tracked diffs before choosing what to share; ignore rules + are never CI exemptions. Follow the host project's verification policy for + the actual changed inputs; documentation may itself be a product input. + Publishing requires explicit release intent within existing user authorization; + local maintenance and merging do not imply publication. +- `specgit finish` exit `0` means accepted. Report completed only after + the configured target merge and every bound issue closure are confirmed. + Never declare completion from task lists, file states, or tests alone. + Track a failed PR/MR with a new repair issue; repeated causes reuse an open + repair issue and do not require abandoning the original PR/MR. +- Use existing user authorization to complete issue bodies, the PR/MR body + and ready transition, CI repairs or retries, acceptance, and the authorized + merge. When user authorization or platform permission is missing, present + the prepared result and name the specific gap. Documentation and entry + points do not grant permission themselves. - Branch on exit codes, not phrasing: `1` = evidence complete, fix what - the gates named; `3` = evidence missing, fix the environment first - (`specgit doctor`). Never present exit `3` as success. -- Keep the `Closes #n` references in the PR body intact; after changing - the PR body, head branch, or CI, re-run `specgit finish`. Never + the gates named; `3` = evidence missing, so follow `errors[].fix` first. + Run `specgit doctor --json` only for git, repository, origin, configured + provider CLI/auth, or policy probes. Never present exit `3` as success. +- Keep the `Closes #n` references in the PR/MR body intact; after changing + the PR/MR body, head branch, or CI, re-run `specgit finish`. Never bypass or reconfig a required check to make acceptance pass. - Forge evidence flows through the user's authenticated CLI session only (`gh` / `glab`): never read, log, or pass around tokens. From 07e8787d7c8bcdcd04961c73c5264e79997d9101 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 12:08:41 +0800 Subject: [PATCH 10/25] fix(hooks): repair execution, decisions and lifecycle --- .../core/src/plugin/skill/configure-hooks.md | 63 ++- packages/opencode/src/hook/agent-tools.ts | 160 ++---- .../src/hook/extensions/hot-reload.ts | 16 +- packages/opencode/src/hook/file-changes.ts | 37 ++ .../opencode/src/hook/readonly-command.ts | 161 ++++++ packages/opencode/src/hook/rewake.ts | 14 + packages/opencode/src/hook/schema.ts | 54 ++ packages/opencode/src/hook/session-hooks.ts | 57 +- packages/opencode/src/hook/settings.ts | 492 ++++++++++-------- packages/opencode/src/hook/trigger-result.ts | 21 + packages/opencode/src/permission/index.ts | 65 +-- .../routes/instance/httpapi/groups/session.ts | 5 + .../instance/httpapi/handlers/session.ts | 14 +- packages/opencode/src/session/prompt.ts | 296 +++++++---- packages/opencode/src/session/tools.ts | 374 +++++++------ packages/opencode/src/tool/task.ts | 2 +- .../test/hook/handler-cancellation.test.ts | 159 ++++++ .../opencode/test/hook/http-handler.test.ts | 45 ++ .../test/hook/prompt-admission.test.ts | 386 ++++++++++++++ .../test/hook/readonly-command.test.ts | 57 ++ .../test/hook/runtime-boundaries.test.ts | 292 +++++++++++ .../test/hook/tool-boundaries.test.ts | 236 +++++++++ .../test/hook/warn-unsupported.test.ts | 35 +- .../test/server/session-hooks-api.test.ts | 27 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 7 + packages/sdk/js/src/v2/gen/types.gen.ts | 14 + 26 files changed, 2352 insertions(+), 737 deletions(-) create mode 100644 packages/opencode/src/hook/file-changes.ts create mode 100644 packages/opencode/src/hook/readonly-command.ts create mode 100644 packages/opencode/src/hook/schema.ts create mode 100644 packages/opencode/test/hook/handler-cancellation.test.ts create mode 100644 packages/opencode/test/hook/prompt-admission.test.ts create mode 100644 packages/opencode/test/hook/readonly-command.test.ts create mode 100644 packages/opencode/test/hook/runtime-boundaries.test.ts create mode 100644 packages/opencode/test/hook/tool-boundaries.test.ts diff --git a/packages/core/src/plugin/skill/configure-hooks.md b/packages/core/src/plugin/skill/configure-hooks.md index 7718f6489..cf806c94f 100644 --- a/packages/core/src/plugin/skill/configure-hooks.md +++ b/packages/core/src/plugin/skill/configure-hooks.md @@ -14,11 +14,11 @@ Config lives in dedicated `hooks.json` files (NOT `opencode.json`, NOT ## Where files live -| Scope | Path | Hot-reloaded? | -| ------- | ------------------------------------ | --------------------------------- | -| Global | `~/.config/opencode/hooks.json` | No — requires restart | -| Project | `.opencode/hooks.json` | Yes — polled every ~2s | -| Worktree| `<worktree>/.opencode/hooks.json` | Yes (when worktree ≠ project dir) | +| Scope | Path | Hot-reloaded? | +| -------- | --------------------------------- | --------------------------------- | +| Global | `~/.config/opencode/hooks.json` | Yes — polled every ~2s | +| Project | `.opencode/hooks.json` | Yes — polled every ~2s | +| Worktree | `<worktree>/.opencode/hooks.json` | Yes (when worktree ≠ project dir) | Layers concat-append (do NOT override by key): global hooks run, then project hooks are appended after, in file order. A single event can have hooks from @@ -86,25 +86,42 @@ this skill is a map, not the full schema. ## Hook types (all 5 implemented) -| `type` | What it does | -| --------- | ----------------------------------------------------------------------------- | +| `type` | What it does | +| --------- | ---------------------------------------------------------------------------------------------- | | `command` | Runs a shell command. Event data is piped to stdin as JSON; stdout/exit code drive the result. | -| `mcp` | Invokes an MCP tool, addressed as `mcp__<server>__<tool>`. | -| `http` | POSTs the event envelope to `url`; response body is parsed as JSON. | -| `prompt` | Sends the event to an LLM, constrained to structured JSON output. | -| `agent` | Runs an autonomous sub-agent loop (bash/read_file/list_dir/grep) to react to the event. | +| `mcp` | Invokes an MCP tool, addressed as `mcp__<server>__<tool>`. | +| `http` | POSTs the event envelope to `url`; response body is parsed as JSON. | +| `prompt` | Sends the event to an LLM, constrained to structured JSON output. | +| `agent` | Runs an autonomous sub-agent loop (bash/read_file/list_dir/grep) to react to the event. | ### `command` protocol - stdin: JSON envelope with event data - exit code `0`: success, stdout optionally parsed as `HookJSONOutput` JSON - exit code `2`: **block** — stderr becomes the block reason, shown to the agent -- any other exit code, or timeout: logged as a warning, does NOT abort the flow +- any other exit code, or timeout: logged as a warning; stdout control fields are ignored - `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` expand to the directory the hook was declared in / its data dir — usable in `command` - `options` (fork-only field, no CC equivalent): exported as `CLAUDE_PLUGIN_OPTION_<KEY>` env vars in the subprocess +### Handler options and agent tools + +- `timeout`: positive seconds; cancellation reaches the running process, model request or MCP tool. +- `shell`: command hooks can explicitly select `bash` or `powershell`; that interpreter must be installed. Omit it for `/bin/sh` on POSIX or `cmd.exe` on Windows. +- `allowedEnvVars`: when supplied, HTTP hooks expand `$NAME` and `${NAME}` in headers only for names in this list. Unlisted or unset variables expand to an empty string. When omitted, headers remain literal. +- `statusMessage`: recorded in the hook execution log before dispatch; it does not create a UI progress indicator. +- Command-level `once`: runs once per session for the current loaded configuration entry, including concurrent/async triggers. Reloading the file creates fresh entries. Session registration-level `once` atomically claims the whole matching group; unmatched conditions leave it available. +- Dynamic session registration supports the same command fields, including `options`. + +Agent hooks provide `read_file`, `list_dir`, `grep`, and a restricted `bash` tool. +The latter directly executes installed POSIX system utilities with validated arguments; +it does not invoke a shell. It supports common read-only options for file inspection, +`find` predicates and `git status/log/diff/show`. `sed` is limited to +`-n '<line>[,<line>]p' <files>`. Interpreters such as `awk`, shell composition, +output-file options, external Git diff drivers and commands found through a +project-controlled PATH are unavailable. Use the three file tools on Windows. + ### Common output fields (`HookJSONOutput`, applies across types) ```json @@ -119,14 +136,26 @@ this skill is a map, not the full schema. } ``` -Not every field applies to every event — `hookSpecificOutput` shape varies per -event (see `HookSpecificOutput` in `settings.ts` for the exact per-event union). +`continue: false` stops prompt admission and wins over a Stop hook's request to +continue. Permission hooks can reject using `permissionDecision: "deny"`, a block +decision, or exit code 2. Post-tool block reasons and contexts are appended to +model-facing tool feedback after execution; they cannot undo a completed write. +`FileChanged` carries an absolute path and `add`, `change` or `delete`; patch +renames report both the removed path and the added destination. + +Some fields are retained only for schema compatibility. `initialUserMessage`, +`watchPaths`, `updatedMCPToolOutput`, `displayMessage`, `compactSummary` and +`customSummary` emit unsupported-output warnings and do not alter runtime state. +Use `additionalContext` for model feedback. `suppressOutput` remains a no-op: +hook stdout is not displayed directly in the UI. Prompt/agent hooks using OpenAI +OAuth are currently skipped with a warning; use a supported API-key provider. +Invalid output shapes are logged and ignored before aggregation. ## Applying changes -Global `hooks.json` loads once at startup — **restart required**. Project and -worktree `hooks.json` are polled and take effect within a few seconds without -a restart. +Global, project and worktree `hooks.json` files are polled every ~2 seconds, +with a 500 ms debounce. Changes take effect within a few seconds. Invalid +matcher/command entries are logged and skipped while valid siblings remain active. ## Migrating from Claude Code diff --git a/packages/opencode/src/hook/agent-tools.ts b/packages/opencode/src/hook/agent-tools.ts index 3a7f98a0f..c4f2d1ca5 100644 --- a/packages/opencode/src/hook/agent-tools.ts +++ b/packages/opencode/src/hook/agent-tools.ts @@ -1,22 +1,4 @@ -/** - * WP-4D micro-WP-1 — agent-handler tool set (LLM-facing). - * - * Builds the 5-tool palette consumed by the WP-4D-2 agent loop: - * read_file / list_dir / grep / bash / synthetic_output - * - * Design contract: - * - All tools are pure ai-SDK `Tool` values; no Effect dependencies on the - * LLM-side execute path. Effect Services (spawner / fs) are pre-resolved - * by the caller and captured via closure. - * - Every `execute` is wrapped in try/catch. Errors return - * `{ output: "Error: <message>" }` and **never throw** — the agent loop - * must be able to keep running and let the model decide whether to retry. - * - bash uses a strict read-only whitelist. The token list and forbidden - * metachar regex are the v1 contract; expanding either requires a - * deliberate WP, not a one-off addition. - * - synthetic_output writes into the caller-owned `captured.value` slot; - * the loop polls it after each turn to decide termination. - */ +/** Read-only tools for model-driven hooks. Every operation observes the hook abort signal. */ import path from "path" import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" @@ -24,59 +6,10 @@ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSp import { type Tool, tool, jsonSchema } from "ai" import { FSUtil } from "@opencode-ai/core/fs-util" import type { HookJSONOutput } from "./settings" +import { HookOutputSchema } from "./schema" +import { FORBIDDEN_META, parseReadonlyCommand, readonlyExecutable, whitelistReject } from "./readonly-command" -// ── bash whitelist (read-only, POSIX-only) ────────────────────── - -const BASH_WHITELIST_SINGLE = new Set([ - "ls", - "cat", - "grep", - "find", - "test", - "wc", - "head", - "tail", - "sort", - "uniq", - "awk", - "echo", - "pwd", - "which", - "file", - "stat", -]) - -const BASH_WHITELIST_PAIR = new Set([ - "git status", - "git log", - "git diff", - "git show", - "sed -n", - "du -sh", -]) - -/** - * Reject metacharacters that enable composition / redirection / substitution. - * v1 only allows a single command invocation — no pipes, chains, redirects, - * background, command substitution, or backticks. - */ -const FORBIDDEN_META = /[|;&`$<>]|\$\(|\)\s*$/ - -function whitelistReject(cmd: string): string | null { - const trimmed = cmd.trim() - if (!trimmed) return "empty command" - if (FORBIDDEN_META.test(trimmed)) - return `compound/redirect not allowed in v1: ${trimmed.slice(0, 60)}` - const tokens = trimmed.split(/\s+/) - const first = tokens[0] - const pair = tokens.length >= 2 ? `${tokens[0]} ${tokens[1]}` : "" - if (BASH_WHITELIST_SINGLE.has(first)) return null - if (pair && BASH_WHITELIST_PAIR.has(pair)) return null - return `command "${first}" not in read-only whitelist` -} - -// Exported for unit tests only — not part of the runtime surface. -export const __test__ = { whitelistReject, BASH_WHITELIST_SINGLE, BASH_WHITELIST_PAIR, FORBIDDEN_META } +export const __test__ = { whitelistReject, FORBIDDEN_META } // ── helpers ───────────────────────────────────────────────────── @@ -88,38 +21,6 @@ const MAX_BASH_OUTPUT = 8000 const MAX_GREP_RESULTS_DEFAULT = 100 const MAX_READ_LINES_DEFAULT = 2000 -// ── synthetic_output schema (mirrors HookJSONOutput) ──────────── - -const HOOK_OUTPUT_SCHEMA = { - type: "object", - properties: { - continue: { type: "boolean" }, - stopReason: { type: "string" }, - suppressOutput: { type: "boolean" }, - systemMessage: { type: "string" }, - decision: { type: "string", enum: ["approve", "block"] }, - reason: { type: "string" }, - hookSpecificOutput: { - type: "object", - properties: { - hookEventName: { type: "string" }, - permissionDecision: { type: "string", enum: ["allow", "deny", "ask"] }, - permissionDecisionReason: { type: "string" }, - updatedInput: { type: "object" }, - additionalContext: { type: "string" }, - initialUserMessage: { type: "string" }, - updatedMCPToolOutput: {}, - }, - }, - }, -} as const - -// Compile-time guard: the synthetic_output schema must remain a structural -// subset of HookJSONOutput. If HookJSONOutput grows a required field, this -// assignment has to be updated alongside HOOK_OUTPUT_SCHEMA above. -const _schemaTypeCheck: (a: HookJSONOutput) => HookJSONOutput = (a) => a -void _schemaTypeCheck - // ── factory ───────────────────────────────────────────────────── export interface BuildAgentToolsDeps { @@ -148,7 +49,7 @@ export function buildAgentTools(deps: BuildAgentToolsDeps): Record<string, Tool> execute: async (args: any) => { try { const resolved = resolvePath(String(args.path), cwd) - const text = await Effect.runPromise(fs.readFileString(resolved) as Effect.Effect<string, unknown>) + const text = await Effect.runPromise(fs.readFileString(resolved), { signal }) const offset = typeof args.offset === "number" && args.offset > 0 ? args.offset - 1 : 0 const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : MAX_READ_LINES_DEFAULT const lines = text.split("\n").slice(offset, offset + limit) @@ -176,8 +77,9 @@ export function buildAgentTools(deps: BuildAgentToolsDeps): Record<string, Tool> const lines: string[] = [] const walk = async (dir: string, rel: string): Promise<void> => { - const entries = await Effect.runPromise(fs.readDirectoryEntries(dir) as Effect.Effect<FSUtil.DirEntry[], unknown>) + const entries = await Effect.runPromise(fs.readDirectoryEntries(dir), { signal }) for (const e of entries) { + signal.throwIfAborted() const display = (rel ? rel + "/" : "") + e.name + (e.type === "directory" ? "/" : "") lines.push(display) if (recursive && e.type === "directory") { @@ -211,7 +113,8 @@ export function buildAgentTools(deps: BuildAgentToolsDeps): Record<string, Tool> try { const re = new RegExp(String(args.pattern)) const root = resolvePath(String(args.path), cwd) - const max = typeof args.max_results === "number" && args.max_results > 0 ? args.max_results : MAX_GREP_RESULTS_DEFAULT + const max = + typeof args.max_results === "number" && args.max_results > 0 ? args.max_results : MAX_GREP_RESULTS_DEFAULT // Treat include as a suffix filter only — minimatch is not in the // hook subsystem's dep set and grep is best-effort here. Strip a // leading '*' so '*.ts' and '.ts' both work. @@ -225,8 +128,9 @@ export function buildAgentTools(deps: BuildAgentToolsDeps): Record<string, Tool> if (suffix && !filepath.endsWith(suffix)) return let content: string try { - content = await Effect.runPromise(fs.readFileString(filepath) as Effect.Effect<string, unknown>) + content = await Effect.runPromise(fs.readFileString(filepath), { signal }) } catch { + signal.throwIfAborted() return } const lines = content.split("\n") @@ -239,15 +143,16 @@ export function buildAgentTools(deps: BuildAgentToolsDeps): Record<string, Tool> } const walk = async (dir: string): Promise<void> => { - const entries = await Effect.runPromise(fs.readDirectoryEntries(dir) as Effect.Effect<FSUtil.DirEntry[], unknown>) + const entries = await Effect.runPromise(fs.readDirectoryEntries(dir), { signal }) for (const e of entries) { + signal.throwIfAborted() const child = path.join(dir, e.name) if (e.type === "directory") await walk(child) else if (e.type === "file") await scanFile(child) } } - const isDir = await Effect.runPromise(fs.isDir(root)) + const isDir = await Effect.runPromise(fs.isDir(root), { signal }) if (isDir) await walk(root) else await scanFile(root) @@ -262,7 +167,7 @@ export function buildAgentTools(deps: BuildAgentToolsDeps): Record<string, Tool> const bash = tool({ description: - "Run a single read-only shell command (whitelist enforced: ls/cat/grep/find/git status/git log/git diff/git show/sed -n/test/wc/head/tail/sort/uniq/awk/echo/pwd/which/file/stat/du -sh). No pipes, redirects, or substitution.", + "Run one POSIX read-only command with restricted options: ls/cat/grep/find/git status/log/diff/show/sed -n/test/wc/head/tail/sort/uniq/echo/pwd/which/file/stat/du. Quotes are supported; shell syntax, interpreters and output-file options are rejected.", inputSchema: jsonSchema({ type: "object", properties: { command: { type: "string" } }, @@ -270,22 +175,24 @@ export function buildAgentTools(deps: BuildAgentToolsDeps): Record<string, Tool> }), execute: async (args: any) => { const command = String(args?.command ?? "") - const reject = whitelistReject(command) - if (reject) return { output: `Error: ${reject}` } - - // Align with settings.ts execShell: use cmd.exe on Windows (no `sh`), - // POSIX `sh -c` elsewhere. Previously this bailed on win32 while command - // hooks ran fine via cmd.exe — asymmetric behavior across handler types. - const isWin = process.platform === "win32" - try { + signal.throwIfAborted() + const parsed = parseReadonlyCommand(command) + const executable = readonlyExecutable(parsed.name) const result = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const handle = yield* spawner.spawn( - ChildProcess.make(isWin ? "cmd.exe" : "sh", isWin ? ["/c", command] : ["-c", command], { + ChildProcess.make(executable, parsed.args, { cwd, extendEnv: true, + env: { + GIT_OPTIONAL_LOCKS: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_NO_LAZY_FETCH: "1", + GIT_TERMINAL_PROMPT: "0", + }, stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -301,7 +208,8 @@ export function buildAgentTools(deps: BuildAgentToolsDeps): Record<string, Tool> ) return { stdout, stderr, code } }), - ) as Effect.Effect<{ stdout: string; stderr: string; code: number }, unknown>, + ), + { signal }, ) const body = `exit=${result.code}\n${result.stdout}` + (result.stderr ? `\n[stderr]\n${result.stderr}` : "") @@ -314,10 +222,11 @@ export function buildAgentTools(deps: BuildAgentToolsDeps): Record<string, Tool> const synthetic_output = tool({ description: "Emit the final hook decision and stop. Call this exactly once when ready to terminate.", - inputSchema: jsonSchema(HOOK_OUTPUT_SCHEMA as Record<string, unknown>), + inputSchema: HookOutputSchema, execute: async (args: any) => { try { - captured.value = args as HookJSONOutput + signal.throwIfAborted() + captured.value = HookOutputSchema.parse(args) as HookJSONOutput return { output: "ok" } } catch (e: any) { return { output: `Error: ${e?.message ?? String(e)}` } @@ -325,13 +234,6 @@ export function buildAgentTools(deps: BuildAgentToolsDeps): Record<string, Tool> }, }) - // signal is captured for the loop's transport-level cancellation; the - // tool execute paths above don't directly consume it (Effect.scoped on - // the bash spawn unwinds child handles when the runtime is interrupted - // by the outer agent loop). Reference here is intentional to keep the - // dep contract honest without a noisy unused-param warning. - void signal - return { read_file, list_dir, grep, bash, synthetic_output } } diff --git a/packages/opencode/src/hook/extensions/hot-reload.ts b/packages/opencode/src/hook/extensions/hot-reload.ts index 48b0eebe9..c9ded4fbc 100644 --- a/packages/opencode/src/hook/extensions/hot-reload.ts +++ b/packages/opencode/src/hook/extensions/hot-reload.ts @@ -36,11 +36,7 @@ const POLL_INTERVAL_MS = 2000 * is never read. Global is included so editing `~/.config/opencode/hooks.json` * takes effect without a restart (previously startup-only). */ -function watchedFiles( - projectDir: string, - worktree: string | undefined, - opencodeGlobalConfig?: string, -): string[] { +function watchedFiles(projectDir: string, worktree: string | undefined, opencodeGlobalConfig?: string): string[] { const files: string[] = [] if (opencodeGlobalConfig) files.push(path.join(opencodeGlobalConfig, "hooks.json")) files.push(path.join(projectDir, ".opencode", "hooks.json")) @@ -111,13 +107,13 @@ export function watchSettings( const fireReload = (changedFile: string) => { log.info("hooks.json changed, reloading", { file: changedFile }) // Fire-and-forget: reload errors are logged but never crash - Effect.runPromise(reload()).then( - (settings) => { + Effect.runPromise(Effect.suspend(reload)) + .then((settings) => { + if (closed) return log.info("hooks hot-reloaded", { file: changedFile, hookCount: countHooks(settings) }) onReload(settings, changedFile) - }, - (err) => log.warn("hooks reload failed", { file: changedFile, error: String(err) }), - ) + }) + .catch((err) => log.warn("hooks reload failed", { file: changedFile, error: String(err) })) } // Debounce: 500ms. Min 1s between reloads. On min-interval block, reschedule diff --git a/packages/opencode/src/hook/file-changes.ts b/packages/opencode/src/hook/file-changes.ts new file mode 100644 index 000000000..8d675e052 --- /dev/null +++ b/packages/opencode/src/hook/file-changes.ts @@ -0,0 +1,37 @@ +import path from "node:path" +import { isRecord } from "@/util/record" + +const FILE_TOOLS = new Set(["write", "edit", "apply_patch", "multiedit", "patch"]) + +/** Prefer actual result metadata; input paths are a fallback for compatible tools. */ +export function toolFileChanges(tool: string, args: Record<string, unknown>, metadata: unknown, cwd: string) { + const changes = new Map<string, { path: string; changeType: "add" | "change" | "delete" }>() + if (!FILE_TOOLS.has(tool)) return [] + const add = (file: unknown, changeType: "add" | "change" | "delete") => { + if (typeof file !== "string" || !file.trim()) return + const resolved = path.resolve(cwd, file) + changes.set(resolved, { path: resolved, changeType }) + } + const data = isRecord(metadata) ? metadata : {} + if (Array.isArray(data.files)) { + for (const file of data.files) { + if (!isRecord(file)) continue + if (file.type === "move" && typeof file.movePath === "string") { + add(file.filePath, "delete") + add(file.movePath, "add") + } else { + add(file.filePath, file.type === "add" ? "add" : file.type === "delete" ? "delete" : "change") + } + } + } else { + const diff = isRecord(data.filediff) ? data.filediff : {} + const file = data.filepath ?? diff.file ?? args.filePath ?? args.file_path ?? args.path + add(file, tool === "write" && data.exists === false ? "add" : "change") + if (tool === "multiedit" && Array.isArray(args.edits)) { + for (const edit of args.edits) { + if (isRecord(edit)) add(edit.filePath ?? edit.file_path ?? edit.path, "change") + } + } + } + return [...changes.values()] +} diff --git a/packages/opencode/src/hook/readonly-command.ts b/packages/opencode/src/hook/readonly-command.ts new file mode 100644 index 000000000..11cd7ca1a --- /dev/null +++ b/packages/opencode/src/hook/readonly-command.ts @@ -0,0 +1,161 @@ +import { existsSync } from "node:fs" + +// This is an argument parser, never a shell. Expansion and command composition +// are deliberately unavailable, even inside quotes. +export const FORBIDDEN_META = /[\x00-\x1f\x7f|;&`$<>]/ + +function tokens(command: string): string[] { + if (FORBIDDEN_META.test(command)) throw new Error("shell syntax and control characters are not allowed") + const result: string[] = [] + let token = "" + let quote = "" + let started = false + for (let i = 0; i < command.length; i++) { + const c = command[i] + if (c === "\\" && quote !== "'") { + if (++i === command.length) throw new Error("unfinished escape") + token += command[i] + started = true + } else if (quote) { + if (c === quote) quote = "" + else token += c + } else if (c === "'" || c === '"') { + quote = c + started = true + } else if (c === " ") { + if (started) result.push(token) + token = "" + started = false + } else { + token += c + started = true + } + } + if (quote) throw new Error("unfinished quote") + if (started) result.push(token) + if (!result.length) throw new Error("empty command") + return result +} + +// Only known read-only options are accepted. A command-name allowlist alone +// admits output files, interpreters, external diff drivers and find actions. +const flags: Record<string, RegExp> = { + ls: /^(-[aAbBcCdDfFgGhHiIlLmMnNoOpPqQrRsStTuUvVwWxX1]+|--(all|almost-all|directory|human-readable|recursive))$/, + cat: /^(-[benstuvET]+|--(number|number-nonblank|show-ends|show-tabs|squeeze-blank))$/, + grep: /^(-[EFGivwxcLlnHhroqsaIR]+|--(line-number|ignore-case|files-with-matches|files-without-match|fixed-strings|extended-regexp))$/, + wc: /^(-[clmwL]+|--(bytes|chars|lines|words|max-line-length))$/, + head: /^(-[qv]+|-[0-9]+)$/, + tail: /^(-[qvfF]+|-[0-9]+)$/, + sort: /^(-[bdfghinMrsuV]+|--(numeric-sort|reverse|unique|ignore-case|stable|check))$/, + uniq: /^(-[cdiu]+|--(count|repeated|unique|ignore-case))$/, + echo: /^-[neE]+$/, + pwd: /^-[LP]+$/, + which: /^-a$/, + file: /^(-[bhiL]+|--(brief|mime|mime-type|mime-encoding))$/, + stat: /^(-[Lf]+|--(dereference|file-system|terse))$/, + du: /^(-[achHkLmsx]+|--(summarize|human-readable|total))$/, + test: /^(-[abcdefghkLmnoprSstuvwxzOGN]+|-eq|-ne|-gt|-ge|-lt|-le)$/, +} + +function simple(name: string, args: string[]) { + const pattern = flags[name] + if (!pattern) throw new Error(`command "${name}" not in read-only whitelist`) + let positional = 0 + let literal = false + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (literal || !arg.startsWith("-") || arg === "-") { + positional++ + continue + } + if (arg === "--") { + literal = true + continue + } + if ((name === "head" || name === "tail") && /^-(n|c)$/.test(arg)) { + if (!/^[+-]?\d+$/.test(args[++i] ?? "")) throw new Error("line/byte count must be numeric") + continue + } + if (!pattern.test(arg)) throw new Error(`option "${arg}" is not allowed for ${name}`) + } + // uniq's SECOND positional argument is an output file. + if (name === "uniq" && positional > 1) throw new Error("uniq output files are not allowed") +} + +function find(args: string[]) { + const predicates = new Set(["-name", "-iname", "-path", "-ipath", "-type", "-maxdepth", "-mindepth"]) + const operators = new Set(["-print", "-print0", "-empty", "-not", "!", "-a", "-and", "-o", "-or", "(", ")"]) + let expression = false + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (predicates.has(arg)) { + expression = true + if (args[++i] === undefined) throw new Error(`missing argument for ${arg}`) + } else if (operators.has(arg)) { + expression = true + } else if (expression || arg.startsWith("-")) { + throw new Error(`find action "${arg}" is not allowed`) + } + } +} + +function git(args: string[]) { + const sub = args[0] + if (!["status", "log", "diff", "show"].includes(sub)) throw new Error("git subcommand is not read-only") + const safe = + /^(--(short|branch|porcelain(?:=v?[12])?|oneline|stat|numstat|shortstat|summary|name-only|name-status|check|cached|staged|no-color|no-renames|no-patch|patch|reverse|all|first-parent|no-merges|merges|follow|graph|abbrev-commit|date-order|topo-order|full-history|relative|binary)|-[sbpwu]|-U\d+|-\d+|--(max-count|skip|unified)=\d+|--(format|pretty|date|since|until|author|grep|untracked-files|ignore-submodules)=[^-].*)$/ + let literal = false + for (let i = 1; i < args.length; i++) { + const arg = args[i] + if (literal) continue + if (arg === "--") { + literal = true + continue + } + if (arg === "-n") { + if (!/^\d+$/.test(args[++i] ?? "")) throw new Error("git count must be numeric") + continue + } + if (arg.startsWith("-") && !safe.test(arg)) throw new Error(`git option "${arg}" is not allowed`) + } + return [ + "--no-pager", + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + sub, + ...(sub === "status" ? [] : ["--no-ext-diff", "--no-textconv"]), + ...args.slice(1), + ] +} + +export function parseReadonlyCommand(command: string): { name: string; args: string[] } { + const [name, ...args] = tokens(command) + if (name === "git") return { name, args: git(args) } + if (name === "find") find(args) + else if (name === "sed") { + // Arbitrary sed programs can write files or execute commands on GNU sed. + if (args[0] !== "-n" || !/^\d+(,\d+)?p$/.test(args[1] ?? "") || args.slice(2).some((s) => s.startsWith("-"))) + throw new Error("sed only supports -n '<line>[,<line>]p' <files>") + } else simple(name, args) + return { name, args } +} + +export function readonlyExecutable(name: string): string { + if (process.platform === "win32") + throw new Error("Use read_file, list_dir or grep on Windows; bash requires POSIX utilities") + // Never resolve an executable from a project-controlled PATH entry. + const executable = [`/usr/bin/${name}`, `/bin/${name}`].find(existsSync) + if (!executable) throw new Error(`system utility ${name} is unavailable`) + return executable +} + +export function whitelistReject(command: string): string | null { + try { + parseReadonlyCommand(command) + return null + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} diff --git a/packages/opencode/src/hook/rewake.ts b/packages/opencode/src/hook/rewake.ts index fbee871d5..df4c2e7e2 100644 --- a/packages/opencode/src/hook/rewake.ts +++ b/packages/opencode/src/hook/rewake.ts @@ -20,4 +20,18 @@ export interface Interface { export class Service extends Context.Service<Service, Interface>()("@opencode/HookRewake") {} +export function bind( + submit: (input: { sessionID: SessionID; text: string }) => Effect.Effect<unknown, unknown>, +): Interface { + return { + rewake: (input) => + submit(input).pipe( + Effect.catch((error) => + Effect.logWarning("hook rewake prompt failed", { sessionID: input.sessionID, error: String(error) }), + ), + Effect.asVoid, + ), + } +} + export * as HookRewake from "./rewake" diff --git a/packages/opencode/src/hook/schema.ts b/packages/opencode/src/hook/schema.ts new file mode 100644 index 000000000..34470cebb --- /dev/null +++ b/packages/opencode/src/hook/schema.ts @@ -0,0 +1,54 @@ +import z from "zod" + +export const HookCommandSchema = z + .object({ + type: z.enum(["command", "mcp", "http", "prompt", "agent"]), + command: z.string().optional(), + url: z.string().optional(), + prompt: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + allowedEnvVars: z.array(z.string()).optional(), + timeout: z.number().positive().finite().optional(), + statusMessage: z.string().optional(), + once: z.boolean().optional(), + shell: z.enum(["bash", "powershell"]).optional(), + if: z.string().optional(), + async: z.boolean().optional(), + asyncRewake: z.boolean().optional(), + options: z.record(z.string(), z.unknown()).optional(), + __sourceDir: z.string().optional(), + }) + .superRefine((entry, ctx) => { + const value = + entry.type === "http" + ? (entry.url ?? entry.command) + : entry.type === "prompt" || entry.type === "agent" + ? (entry.prompt ?? entry.command) + : entry.command + if (!value?.trim()) + ctx.addIssue({ code: "custom", message: `${entry.type} hook requires a nonempty command, url or prompt` }) + }) + +export const HookSpecificOutputSchema = z.object({ + hookEventName: z.string().optional(), + permissionDecision: z.enum(["allow", "deny", "ask"]).optional(), + permissionDecisionReason: z.string().optional(), + updatedInput: z.record(z.string(), z.unknown()).optional(), + additionalContext: z.string().optional(), + initialUserMessage: z.string().optional(), + updatedMCPToolOutput: z.unknown().optional(), + watchPaths: z.array(z.string()).optional(), + displayMessage: z.string().optional(), + compactSummary: z.string().optional(), + customSummary: z.string().optional(), +}) + +export const HookOutputSchema = z.object({ + continue: z.boolean().optional(), + stopReason: z.string().optional(), + suppressOutput: z.boolean().optional(), + systemMessage: z.string().optional(), + decision: z.enum(["approve", "block"]).optional(), + reason: z.string().optional(), + hookSpecificOutput: HookSpecificOutputSchema.optional(), +}) diff --git a/packages/opencode/src/hook/session-hooks.ts b/packages/opencode/src/hook/session-hooks.ts index 0e5189a1d..07789f96e 100644 --- a/packages/opencode/src/hook/session-hooks.ts +++ b/packages/opencode/src/hook/session-hooks.ts @@ -20,34 +20,18 @@ * Storage is process-local memory — entries do NOT survive a restart; users * wanting persistent hooks should use the on-disk hooks.json chain. */ -import { Context, Effect, Layer } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionID } from "@/session/schema" import { InstanceState } from "@/effect/instance-state" -import type { HookEvent, HookJSONOutput } from "./settings" - -// Shape of the inner hooks array on a session entry. Mirrors the `hooks[]` -// array nested under each HookMatcher in the settings file format. We re-declare -// here rather than import HookCommand to avoid a settings.ts → session-hooks.ts -// import cycle (settings.ts already depends on session-hooks for the trigger merge). -export interface SessionHookCommand { - type: "command" | "mcp" | "http" | "prompt" | "agent" - command?: string - /** Claude Code `type:"http"` endpoint. Legacy configs may still use `command`. */ - url?: string - /** Claude Code `type:"prompt" | "agent"` prompt. Legacy configs may still use `command`. */ - prompt?: string - headers?: Record<string, string> - timeout?: number - shell?: "bash" | "powershell" - if?: string - /** Background execution — see HookCommand.async in settings.ts. */ - async?: boolean - /** Deliver async result to agent — see HookCommand.asyncRewake in settings.ts. */ - asyncRewake?: boolean - options?: Record<string, unknown> - __sourceDir?: string -} +import type { HookEvent, HookJSONOutput, HookCommand } from "./settings" +import { HookCommandSchema } from "./schema" + +export type SessionHookCommand = HookCommand + +export class InvalidHookError extends Schema.TaggedErrorClass<InvalidHookError>()("InvalidHookError", { + message: Schema.String, +}) {} export interface SessionHookEntryInput { event: HookEvent @@ -64,7 +48,9 @@ export interface SessionHookEntry extends SessionHookEntryInput { } export interface Interface { - readonly add: (sessionID: SessionID, entry: SessionHookEntryInput) => Effect.Effect<string> + readonly add: (sessionID: SessionID, entry: SessionHookEntryInput) => Effect.Effect<string, InvalidHookError> + /** Atomically remove a registration if still present; only one trigger can claim it. */ + readonly claim: (sessionID: SessionID, id: string) => Effect.Effect<boolean> readonly remove: (sessionID: SessionID, id: string) => Effect.Effect<void> readonly list: (sessionID: SessionID, event: HookEvent) => Effect.Effect<readonly SessionHookEntry[]> /** All entries for a session across every event (backs the HTTP GET endpoint). */ @@ -88,23 +74,32 @@ export const layer = Layer.effect( ) const add = Effect.fn("SessionHooks.add")(function* (sessionID: SessionID, entry: SessionHookEntryInput) { + const parsed = HookCommandSchema.array().min(1).safeParse(entry.hooks) + if (!parsed.success) return yield* new InvalidHookError({ message: parsed.error.message }) + if (entry.matcher !== undefined && typeof entry.matcher !== "string") + return yield* new InvalidHookError({ message: "matcher must be a string" }) const data = yield* InstanceState.get(state) const list = data.get(sessionID) ?? [] const id = crypto.randomUUID() - list.push({ id, ...entry }) + list.push({ ...entry, id, hooks: parsed.data }) data.set(sessionID, list) return id }) - const remove = Effect.fn("SessionHooks.remove")(function* (sessionID: SessionID, id: string) { + const claim = Effect.fn("SessionHooks.claim")(function* (sessionID: SessionID, id: string) { const data = yield* InstanceState.get(state) const list = data.get(sessionID) - if (!list) return - const next = list.filter((e) => e.id !== id) + if (!list?.some((entry) => entry.id === id)) return false + const next = list.filter((entry) => entry.id !== id) if (next.length === 0) data.delete(sessionID) else data.set(sessionID, next) + return true }) + const remove = Effect.fn("SessionHooks.remove")((sessionID: SessionID, id: string) => + claim(sessionID, id).pipe(Effect.asVoid), + ) + const list = Effect.fn("SessionHooks.list")(function* (sessionID: SessionID, event: HookEvent) { const data = yield* InstanceState.get(state) const arr = data.get(sessionID) ?? [] @@ -128,7 +123,7 @@ export const layer = Layer.effect( data.delete(sessionID) }) - return Service.of({ add, remove, list, listAll, hasForEvent, clear }) + return Service.of({ add, claim, remove, list, listAll, hasForEvent, clear }) }), ) diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index c9851b257..5edd32900 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -1,5 +1,5 @@ /** - * Settings-based hook system — Claude Code protocol-level 1:1 compatible. + * Settings-based hooks with Claude Code-compatible envelopes and explicit runtime support. * * Reads hooks from a dedicated hooks.json chain (later layers concat-append on * top of earlier ones, mirroring Claude Code's merge semantics — hooks @@ -46,7 +46,7 @@ import { Effect, Layer, Context, Option, Scope, Exit } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import z from "zod" +import { HookCommandSchema, HookOutputSchema } from "./schema" import { generateObject, generateText, type ModelMessage } from "ai" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -69,7 +69,7 @@ import { isTrusted, trustFilePath } from "./workspace-trust" // [FORK:hook-ext] const log = Log.create({ service: "hook.settings" }) -// ── Types (Claude Code 1:1) ───────────────────────────────────── +// ── Hook protocol types ──────────────────────────────────────── export type HookEvent = | "PreToolUse" @@ -150,8 +150,8 @@ export interface HookCommand { statusMessage?: string once?: boolean /** - * Shell selector for `type:"command"`. CC honors `bash` (default on POSIX) and `powershell` - * (default on Windows). Currently a schema placeholder — execShell still picks based on platform. + * Explicit interpreter for command hooks. When omitted, preserve the platform + * default (/bin/sh on POSIX, cmd.exe on Windows). The interpreter must be installed. */ shell?: "bash" | "powershell" /** @@ -255,34 +255,6 @@ export interface HookJSONOutput { hookSpecificOutput?: HookSpecificOutput } -// Loose flat zod schema mirroring HookJSONOutput — used by the prompt handler to -// constrain LLM structured output. Intentionally NOT `.strict()`: lets the model -// emit unknown fields without failing parse. Single source of truth lives next -// to the HookJSONOutput interface; not exported (settings.ts internal only). -const HookSpecificOutputZodSchema = z.object({ - hookEventName: z.string().optional(), - permissionDecision: z.enum(["allow", "deny", "ask"]).optional(), - permissionDecisionReason: z.string().optional(), - updatedInput: z.record(z.string(), z.unknown()).optional(), - additionalContext: z.string().optional(), - initialUserMessage: z.string().optional(), - updatedMCPToolOutput: z.unknown().optional(), - watchPaths: z.array(z.string()).optional(), - displayMessage: z.string().optional(), - compactSummary: z.string().optional(), - customSummary: z.string().optional(), -}) - -const HookJSONOutputZodSchema = z.object({ - continue: z.boolean().optional(), - stopReason: z.string().optional(), - suppressOutput: z.boolean().optional(), - systemMessage: z.string().optional(), - decision: z.enum(["approve", "block"]).optional(), - reason: z.string().optional(), - hookSpecificOutput: HookSpecificOutputZodSchema.optional(), -}) - // ── Async rewake (hook-async-rewake) ─────────────────────────── // Sentinel prefix for rewake prompts. UserPromptSubmit hook processing skips // prompts whose text starts with this prefix, preventing hook → rewake → hook @@ -514,11 +486,7 @@ export interface ForkHooks { * INTENT: Centralized pre-dispatch filtering for condition-filter, * future rate-limiting, logging, etc. */ - readonly beforeRunEntry?: ( - entry: HookCommand, - envelope: Record<string, unknown>, - event: HookEvent, - ) => boolean + readonly beforeRunEntry?: (entry: HookCommand, envelope: Record<string, unknown>, event: HookEvent) => boolean /** * Called AFTER runEntry() for each executed hook entry. @@ -593,6 +561,20 @@ function httpUrl(entry: HookCommand): string { return entry.url ?? entry.command ?? "" } +function httpHeaders(entry: HookCommand): Record<string, string> { + if (entry.allowedEnvVars === undefined) return entry.headers ?? {} + const allowed = new Set(entry.allowedEnvVars ?? []) + return Object.fromEntries( + Object.entries(entry.headers ?? {}).map(([name, value]) => [ + name, + value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => { + const key = braced ?? bare + return allowed.has(key) ? (process.env[key] ?? "") : "" + }), + ]), + ) +} + function promptText(entry: HookCommand): string { return entry.prompt ?? entry.command ?? "" } @@ -659,35 +641,44 @@ export function readJSON(filepath: string): Settings | null { // hooks.json uses top-level event keys; a legacy {"hooks": {...}} wrapper is // tolerated (D1 graceful degradation). The wrapper wins when present. const obj = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record<string, unknown>) - : undefined - const rawHooks = obj && obj.hooks && typeof obj.hooks === "object" && !Array.isArray(obj.hooks) - ? obj.hooks as Record<string, unknown> - : obj - - // Filter to only valid HookEvent keys with array values (defends against - // non-event keys like "$schema" being treated as matchers) + parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined + const rawHooks = + obj && obj.hooks && typeof obj.hooks === "object" && !Array.isArray(obj.hooks) + ? (obj.hooks as Record<string, unknown>) + : obj + const hooks: Settings["hooks"] = {} - if (rawHooks && typeof rawHooks === "object") { + const sourceDir = path.dirname(filepath) + if (rawHooks) { for (const [key, value] of Object.entries(rawHooks)) { - if (VALID_HOOK_EVENTS.has(key) && Array.isArray(value)) { - hooks[key as HookEvent] = value + if (!VALID_HOOK_EVENTS.has(key)) continue + if (!Array.isArray(value)) { + log.warn("invalid hook event configuration", { path: filepath, event: key }) + continue } - } - } - - // Stamp every HookCommand with the directory of the hooks.json file that - // declared it. execShell uses this to populate CLAUDE_PLUGIN_ROOT / - // CLAUDE_PLUGIN_DATA — now resolves to .opencode/ or ~/.config/opencode/ - // rather than .claude/. - const sourceDir = path.dirname(filepath) - if (hooks) { - for (const matchers of Object.values(hooks)) { - if (!matchers) continue - for (const m of matchers) { - for (const h of m.hooks ?? []) h.__sourceDir = sourceDir + const groups: HookMatcher[] = [] + for (const group of value) { + if ( + !group || + typeof group !== "object" || + !Array.isArray(group.hooks) || + (group.matcher !== undefined && typeof group.matcher !== "string") + ) { + log.warn("invalid hook matcher skipped", { path: filepath, event: key }) + continue + } + const commands: HookCommand[] = [] + for (const candidate of group.hooks) { + const parsed = HookCommandSchema.safeParse(candidate) + if (!parsed.success) { + log.warn("invalid hook command skipped", { path: filepath, event: key, error: parsed.error.message }) + continue + } + commands.push({ ...parsed.data, __sourceDir: sourceDir }) + } + if (commands.length) groups.push({ matcher: group.matcher, hooks: commands }) } + hooks[key as HookEvent] = groups } } log.info("loaded hook settings", { @@ -952,35 +943,18 @@ export function __resetDeprecatedWarnings(): void { warnedDeprecatedHooks.clear() } -/** - * Pure detection of HookCommand fields the fork has not yet implemented (`shell`). - * Exported for unit testing. `if` is fully implemented via condition-filter - * (`extensions/condition-filter.ts` evaluates it in `ForkHooks.beforeRunEntry`), - * and `async` / `asyncRewake` are fully implemented (hook-async-execution); all - * three are therefore excluded. `shell` has no runtime handler and MUST be flagged. - */ +/** Diagnose fields that have no effect for the selected handler type. */ export function detectUnsupportedFields( hooks: Settings["hooks"], ): Array<{ field: string; value: unknown; eventName: string }> { - if (!hooks) return [] const unsupported: Array<{ field: string; value: unknown; eventName: string }> = [] - for (const [eventName, matchers] of Object.entries(hooks)) { - if (!matchers) continue - for (const m of matchers) { - for (const h of m.hooks ?? []) { - if (h.shell !== undefined) unsupported.push({ field: "shell", value: h.shell, eventName }) - // issue #286 — schema-accepted but executor-dropped fields. Surfaced - // here instead of silently swallowed: allowedEnvVars/statusMessage have - // zero consumers anywhere; per-command `once` is never read (only the - // entry-level _sessionEntry?.once is consumed). `timeout` is NOT - // flagged — every handler type applies it (incl. prompt). - if (h.allowedEnvVars !== undefined) - unsupported.push({ field: "allowedEnvVars", value: h.allowedEnvVars, eventName }) - if (h.statusMessage !== undefined) - unsupported.push({ field: "statusMessage", value: h.statusMessage, eventName }) - if (h.once !== undefined) unsupported.push({ field: "once", value: h.once, eventName }) - // `if` is implemented (condition-filter); async/asyncRewake implemented. - // All 5 known types now have handlers; type-level unsupported set is empty by design. + for (const [eventName, matchers] of Object.entries(hooks ?? {})) { + for (const matcher of matchers ?? []) { + for (const entry of matcher.hooks) { + if (entry.shell !== undefined && entry.type !== "command") + unsupported.push({ field: "shell", value: entry.shell, eventName }) + if (entry.allowedEnvVars !== undefined && entry.type !== "http") + unsupported.push({ field: "allowedEnvVars", value: entry.allowedEnvVars, eventName }) } } } @@ -988,18 +962,12 @@ export function detectUnsupportedFields( } /** - * Internal: scan loaded settings for HookCommand fields the fork has not yet implemented - * (`shell`) and emit a single `log.warn` per settings file. Runtime still proceeds — - * this field is silently ignored. Exported for unit testing only; not part of the public - * surface. `if` / `async` / `asyncRewake` are fully implemented and excluded. + * Warn about fields supplied to a handler type that cannot use them. */ -export function warnUnsupportedFields( - hooks: Settings["hooks"], - sourceDir: string, -): void { +export function warnUnsupportedFields(hooks: Settings["hooks"], sourceDir: string): void { const unsupported = detectUnsupportedFields(hooks) if (unsupported.length > 0) { - log.warn("hook settings contains unsupported fields (will be ignored or fail at runtime)", { + log.warn("hook settings contains fields ignored by this handler type", { sourceDir, unsupported, }) @@ -1020,10 +988,26 @@ function execShell( entry: HookCommand, stdinJSON: string, cwd: string, + signal: AbortSignal, ): Promise<{ exitCode: number | null; stdout: string; stderr: string; spawnError?: string }> { return new Promise((resolve) => { + if (signal.aborted) { + resolve({ exitCode: null, stdout: "", stderr: "" }) + return + } const timeoutMs = entry.timeout ? entry.timeout * 1000 : DEFAULT_TIMEOUT_MS - const shell = process.platform === "win32" ? true : "/bin/sh" + const shell = + entry.shell === "powershell" + ? process.platform === "win32" + ? "powershell.exe" + : "pwsh" + : entry.shell === "bash" + ? process.platform === "win32" + ? "bash.exe" + : "/bin/bash" + : process.platform === "win32" + ? true + : "/bin/sh" const expandedCommand = expandCommand(entry) const command = commandText(entry) @@ -1066,7 +1050,6 @@ function execShell( shell, env: { ...process.env, ...extraEnv }, stdio: ["pipe", "pipe", "pipe"], - timeout: timeoutMs, detached: process.platform !== "win32", }) @@ -1095,6 +1078,7 @@ function execShell( // awaited as independent conditions, with a process-group SIGKILL as the // fallback that guarantees resolution. let exitCode: number | null = null + let timedOut = false let settled = false const timers = new Set<NodeJS.Timeout>() const arm = (fire: () => void, ms: number) => { @@ -1122,6 +1106,7 @@ function execShell( const finish = (spawnError?: string) => { if (settled) return settled = true + signal.removeEventListener("abort", afterKill) for (const timer of timers) clearTimeout(timer) timers.clear() child.stdout.destroy() @@ -1132,7 +1117,10 @@ function execShell( stdoutLen: stdout.length, stderrLen: stderr.length, }) - resolve(spawnError === undefined ? { exitCode, stdout, stderr } : { exitCode, stdout, stderr, spawnError }) + const code = timedOut || signal.aborted ? null : exitCode + resolve( + spawnError === undefined ? { exitCode: code, stdout, stderr } : { exitCode: code, stdout, stderr, spawnError }, + ) } child.on("error", (err) => { @@ -1154,6 +1142,9 @@ function execShell( void Promise.all([exited, Promise.race([streamsDone, drained])]).then(() => finish()) } + signal.addEventListener("abort", afterKill, { once: true }) + if (signal.aborted) afterKill() + void Promise.all([exited, streamsDone]).then(() => finish()) // Child exited but pipes are still open (grandchild holds them): kill the @@ -1162,7 +1153,14 @@ function execShell( // Child ignored the spawn-timeout SIGTERM and never exited: kill the group // at the absolute deadline, wait for the reap, then resolve. - arm(afterKill, timeoutMs + KILL_GRACE_MS) + arm(() => { + timedOut = true + if (child.pid !== undefined) + void Process.killGroupPid(child.pid, "SIGTERM").catch((error) => { + log.warn("hook process-group termination failed", { error: String(error) }) + }) + arm(afterKill, KILL_GRACE_MS) + }, timeoutMs) }) } @@ -1173,7 +1171,12 @@ function parseStdout(stdout: string, command: string): HookJSONOutput | undefine return undefined } try { - return JSON.parse(trimmed) as HookJSONOutput + const parsed = HookOutputSchema.safeParse(JSON.parse(trimmed)) + if (!parsed.success) { + log.warn("hook returned invalid output shape", { command, error: parsed.error.message }) + return undefined + } + return parsed.data as HookJSONOutput } catch { log.warn("hook returned invalid JSON", { command, output: trimmed.slice(0, 200) }) return undefined @@ -1250,18 +1253,14 @@ function buildStdinEnvelope(payload: HookPayload, ctx: TriggerContext, cwd: stri return { ...base, stop_hook_active: payload.stopHookActive, - ...(payload.lastAssistantMessage !== undefined - ? { last_assistant_message: payload.lastAssistantMessage } - : {}), + ...(payload.lastAssistantMessage !== undefined ? { last_assistant_message: payload.lastAssistantMessage } : {}), } case "StopFailure": return { ...base, stop_hook_active: payload.stopHookActive, error: payload.error, - ...(payload.lastAssistantMessage !== undefined - ? { last_assistant_message: payload.lastAssistantMessage } - : {}), + ...(payload.lastAssistantMessage !== undefined ? { last_assistant_message: payload.lastAssistantMessage } : {}), } case "SubagentStart": return { ...base, agent_id: payload.agentID, agent_type: payload.agentType } @@ -1270,13 +1269,9 @@ function buildStdinEnvelope(payload: HookPayload, ctx: TriggerContext, cwd: stri ...base, stop_hook_active: payload.stopHookActive, ...(payload.agentID !== undefined ? { agent_id: payload.agentID } : {}), - ...(payload.agentTranscriptPath !== undefined - ? { agent_transcript_path: payload.agentTranscriptPath } - : {}), + ...(payload.agentTranscriptPath !== undefined ? { agent_transcript_path: payload.agentTranscriptPath } : {}), ...(payload.agentType !== undefined ? { agent_type: payload.agentType } : {}), - ...(payload.lastAssistantMessage !== undefined - ? { last_assistant_message: payload.lastAssistantMessage } - : {}), + ...(payload.lastAssistantMessage !== undefined ? { last_assistant_message: payload.lastAssistantMessage } : {}), } case "PreCompact": return { @@ -1289,9 +1284,7 @@ function buildStdinEnvelope(payload: HookPayload, ctx: TriggerContext, cwd: stri ...base, ...(payload.trigger !== undefined ? { trigger: payload.trigger } : {}), ...(payload.compactSummary !== undefined ? { compact_summary: payload.compactSummary } : {}), - ...(payload.customInstructions !== undefined - ? { custom_instructions: payload.customInstructions } - : {}), + ...(payload.customInstructions !== undefined ? { custom_instructions: payload.customInstructions } : {}), } case "SessionStart": return { @@ -1396,6 +1389,7 @@ interface State { * the "" bucket, preserving the prior global-dedup behavior for those. */ seen: Map<string, Set<string>> + once: Map<string, WeakSet<HookCommand>> /** * Scope-tagged summaries of the currently-effective hooks, computed by * `summarizeChain` alongside `settings` (same closure, same hot-reload @@ -1405,10 +1399,7 @@ interface State { } export interface Interface { - readonly trigger: ( - payload: HookPayload, - ctx: TriggerContext, - ) => Effect.Effect<TriggerResult> + readonly trigger: (payload: HookPayload, ctx: TriggerContext) => Effect.Effect<TriggerResult> /** * Read-only view of the currently-effective hooks (merged global + project + * worktree chain), one entry per hook command tagged with its source layer. @@ -1450,8 +1441,8 @@ const commandHandler: HookHandler = { type: "command", run: Effect.fn("SettingsHook.handler.command")(function* (entry, envelope, cwd, _inHook) { const stdinJSON = JSON.stringify(envelope) - const { exitCode, stdout, stderr, spawnError } = yield* Effect.promise(() => - execShell(entry, stdinJSON, cwd), + const { exitCode, stdout, stderr, spawnError } = yield* Effect.promise((signal) => + execShell(entry, stdinJSON, cwd, signal), ) if (spawnError) { @@ -1461,7 +1452,7 @@ const commandHandler: HookHandler = { // Exit-code 2: block + stderr-as-reason (CC contract) if (exitCode === 2) { const reason = stderr.trim() || "Hook blocked execution" - return { json: parseStdout(stdout, commandText(entry)), exitBlock: reason, rawStdout: stdout, exitCode } + return { exitBlock: reason, exitCode } } // Other non-zero exits: log and continue (do not abort main flow) @@ -1483,7 +1474,9 @@ const commandHandler: HookHandler = { // trigger aggregator can inject it as additionalContext for // UserPromptSubmit / SessionStart (CC protocol). JSON stdout still parses // normally via parseStdout; rawStdout is only consumed when json is null. - return { json: parseStdout(stdout, commandText(entry)), exitBlock: undefined, rawStdout: stdout, exitCode } + return exitCode === 0 + ? { json: parseStdout(stdout, commandText(entry)), rawStdout: stdout, exitCode } + : { exitCode } }), } @@ -1496,10 +1489,7 @@ const mcpHandler: HookHandler = { return { json: undefined, exitBlock: undefined } } const timeoutMs = entry.timeout ? entry.timeout * 1000 : DEFAULT_TIMEOUT_MS - const exit = yield* invokeMcpHook(mcpSvc, commandText(entry), envelope).pipe( - Effect.timeout(timeoutMs), - Effect.exit, - ) + const exit = yield* invokeMcpHook(mcpSvc, commandText(entry), envelope).pipe(Effect.timeout(timeoutMs), Effect.exit) if (exit._tag === "Failure") { log.warn("mcp hook timed out or failed (non-blocking)", { command: commandText(entry), @@ -1536,7 +1526,7 @@ const httpHandler: HookHandler = { const url = httpUrl(entry) const exit = yield* HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeaders(entry.headers ?? {}), + HttpClientRequest.setHeaders(httpHeaders(entry)), HttpClientRequest.bodyJson(envelope), Effect.flatMap((req) => httpRead.execute(req)), Effect.flatMap((res) => @@ -1571,7 +1561,7 @@ const httpHandler: HookHandler = { * * `entry.command` is interpreted as the system prompt template; the stdin envelope * (already shaped by buildStdinEnvelope) is JSON-stringified into the user message. - * The model returns structured output matching HookJSONOutputZodSchema (loose flat + * The model returns structured output matching HookOutputSchema (loose flat * shape; see definition near HookJSONOutput). * * Failure policy is **silent allow** — mirrors httpHandler's network-error path: @@ -1615,7 +1605,7 @@ const promptHandler: HookHandler = { { role: "system", content: prompt } as ModelMessage, { role: "user", content: JSON.stringify(envelope) } as ModelMessage, ], - schema: HookJSONOutputZodSchema, + schema: HookOutputSchema, } satisfies Parameters<typeof generateObject>[0] // issue #286 — the header doc promises `timeout` for every hook type, @@ -1625,12 +1615,9 @@ const promptHandler: HookHandler = { const timeoutMs = entry.timeout ? entry.timeout * 1000 : DEFAULT_TIMEOUT_MS const llmExit = yield* Effect.tryPromise({ - try: () => generateObject(params).then((r) => r.object), + try: (abortSignal) => generateObject({ ...params, abortSignal }).then((r) => r.object), catch: (e) => e, - }).pipe( - Effect.timeout(timeoutMs), - Effect.exit, - ) + }).pipe(Effect.timeout(timeoutMs), Effect.exit) if (llmExit._tag === "Failure") { log.warn("prompt hook failed (non-blocking)", { error: String(llmExit.cause) }) @@ -1690,45 +1677,40 @@ const agentHandler: HookHandler = { } const captured: { value: HookJSONOutput | null } = { value: null } - const ac = new AbortController() const timeoutMs = entry.timeout ? entry.timeout * 1000 : DEFAULT_AGENT_TIMEOUT_MS - const timer = setTimeout(() => ac.abort(), timeoutMs) const loopExit = yield* Effect.tryPromise({ - try: async () => { - try { - const tools = buildAgentTools({ spawner, fs, signal: ac.signal, cwd, captured }) - const messages: ModelMessage[] = [ - { role: "system", content: prompt }, - { role: "user", content: JSON.stringify(envelope) }, - ] - for (let turn = 0; turn < MAX_AGENT_TURNS; turn++) { - const result = await generateText({ - model: language, - messages, - tools, - toolChoice: "auto", - abortSignal: ac.signal, - maxOutputTokens: 4096, - allowSystemInMessages: true, - } as any) - if (captured.value) return captured.value - messages.push(...result.response.messages) - if ( - result.finishReason === "stop" || - result.finishReason === "length" || - result.finishReason === "content-filter" - ) - break - if (result.toolCalls.length === 0) break - } - return null - } finally { - clearTimeout(timer) + try: async (signal) => { + const tools = buildAgentTools({ spawner, fs, signal, cwd, captured }) + const messages: ModelMessage[] = [ + { role: "system", content: prompt }, + { role: "user", content: JSON.stringify(envelope) }, + ] + for (let turn = 0; turn < MAX_AGENT_TURNS; turn++) { + signal.throwIfAborted() + const result = await generateText({ + model: language, + messages, + tools, + toolChoice: "auto", + abortSignal: signal, + maxOutputTokens: 4096, + allowSystemInMessages: true, + } as any) + if (captured.value) return captured.value + messages.push(...result.response.messages) + if ( + result.finishReason === "stop" || + result.finishReason === "length" || + result.finishReason === "content-filter" + ) + break + if (result.toolCalls.length === 0) break } + return null }, catch: (e) => e, - }).pipe(Effect.exit) + }).pipe(Effect.timeout(timeoutMs), Effect.exit) if (loopExit._tag === "Failure") { const cause = String(loopExit.cause) @@ -1804,6 +1786,14 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const sessionHooks = yield* SessionHooks.Service + const handlerContext = Context.pick( + MCP.Service, + Provider.Service, + Auth.Service, + FSUtil.Service, + ChildProcessSpawner, + HttpClient.HttpClient, + )(yield* Effect.context<never>()) const state = yield* InstanceState.make( Effect.fn("SettingsHook.state")(function* (instCtx) { @@ -1813,6 +1803,7 @@ export const layer = Layer.effect( hooksList: chain.summaries, cwd: instCtx.directory, seen: new Map<string, Set<string>>(), + once: new Map<string, WeakSet<HookCommand>>(), } satisfies State // [FORK:hook-ext] Hot-reload settings files at runtime. The watcher @@ -1833,11 +1824,12 @@ export const layer = Layer.effect( const handle = watchSettings( instCtx.directory, instCtx.worktree, - () => Effect.sync(() => { - const reloaded = loadChainWithSummaries(instCtx.directory, instCtx.worktree) - lastSummaries = reloaded.summaries - return reloaded.settings - }), + () => + Effect.sync(() => { + const reloaded = loadChainWithSummaries(instCtx.directory, instCtx.worktree) + lastSummaries = reloaded.summaries + return reloaded.settings + }), (newSettings, changedFile) => { stateObj.settings = newSettings stateObj.hooksList = lastSummaries @@ -1870,9 +1862,7 @@ export const layer = Layer.effect( // [FORK:hook-ext] Assemble fork middleware — wired from hook/extensions/index.ts // When undefined, trigger() behaves identically to upstream. - const forkHooks: ForkHooks | undefined = buildForkHooks - ? buildForkHooks({ sessionHooks }) - : undefined + const forkHooks: ForkHooks | undefined = buildForkHooks ? buildForkHooks({ sessionHooks }) : undefined /** * Execute a single hook entry. Never throws. Returns the parsed JSON @@ -1898,7 +1888,23 @@ export const layer = Layer.effect( exitCode: undefined as number | null | undefined, } } - return yield* handler.run(entry as never, envelope, cwd, inHook) + const result = yield* handler + .run(entry as never, envelope, cwd, inHook) + .pipe(Effect.updateContext((current: Context.Context<never>) => Context.merge(handlerContext, current))) + const hso = result.json?.hookSpecificOutput + if (hso) { + for (const field of [ + "initialUserMessage", + "watchPaths", + "updatedMCPToolOutput", + "displayMessage", + "compactSummary", + "customSummary", + ] as const) { + if (field in hso) log.warn("hook output field is not supported", { event: envelope.hook_event_name, field }) + } + } + return result }) // Background scope for async hooks. Lives as long as the SettingsHook service @@ -1934,7 +1940,9 @@ export const layer = Layer.effect( return } if (!hookRewake) { - log.warn("async hook rewake skipped: HookRewake.Service unavailable", { command: commandText(entry).slice(0, 80) }) + log.warn("async hook rewake skipped: HookRewake.Service unavailable", { + command: commandText(entry).slice(0, 80), + }) return } @@ -1955,7 +1963,7 @@ export const layer = Layer.effect( (event === "UserPromptSubmit" || event === "SessionStart") ) { const text = result.rawStdout.trim() - if (text) parts.push(text) + if (text && !text.startsWith("{")) parts.push(text) } if (parts.length === 0) { log.debug("async hook completed: nothing rewake-worthy", { command: commandText(entry).slice(0, 80) }) @@ -1965,16 +1973,16 @@ export const layer = Layer.effect( const text = buildRewakePrompt(entry, event, parts.join("\n")) yield* hookRewake.rewake({ sessionID: SessionID.make(sessionID), text }).pipe( Effect.catchDefect((defect) => { - log.warn("async hook rewake defect swallowed", { command: commandText(entry).slice(0, 80), error: String(defect) }) + log.warn("async hook rewake defect swallowed", { + command: commandText(entry).slice(0, 80), + error: String(defect), + }) return Effect.void }), ) }) - const trigger = Effect.fn("SettingsHook.trigger")(function* ( - payload: HookPayload, - ctx: TriggerContext, - ) { + const trigger = Effect.fn("SettingsHook.trigger")(function* (payload: HookPayload, ctx: TriggerContext) { using _ = log.time("trigger", { event: payload.event, sessionID: ctx.sessionID }) const s = yield* InstanceState.get(state) const result: TriggerResult = { additionalContexts: [], systemMessages: [] } @@ -1995,6 +2003,7 @@ export const layer = Layer.effect( // session, which is harmless. if (payload.event === "SessionEnd" && ctx.sessionID) { s.seen.delete(ctx.sessionID) + s.once.delete(ctx.sessionID) // NOTE: sessionHooks.clear is deferred to after hook execution // (before each return point below) — clearing here would remove // session-registered SessionEnd hooks before the matcher can see them. @@ -2007,8 +2016,7 @@ export const layer = Layer.effect( // s.settings is already cached on the InstanceState, so the file-side // probe is a property access. The session probe is O(1) (Map.get + // .some over the session's own array, typically empty). - const sessionEvent: HookEvent = - ctx.isSubAgent && payload.event === "Stop" ? "SubagentStop" : payload.event + const sessionEvent: HookEvent = ctx.isSubAgent && payload.event === "Stop" ? "SubagentStop" : payload.event const hasFile = (s.settings.hooks?.[payload.event]?.length ?? 0) > 0 const hasSession = ctx.sessionID ? yield* sessionHooks.hasForEvent(SessionID.make(ctx.sessionID), sessionEvent) @@ -2017,6 +2025,8 @@ export const layer = Layer.effect( log.info("trigger short-circuit", { event: payload.event, reason: "no_matchers", hasFile, hasSession }) if (payload.event === "SessionEnd" && ctx.sessionID) { yield* sessionHooks.clear(SessionID.make(ctx.sessionID)) + s.seen.delete(ctx.sessionID) + s.once.delete(ctx.sessionID) } return result } @@ -2029,8 +2039,7 @@ export const layer = Layer.effect( // A layer may declare `allowUntrusted: true` to opt out of the gate. NEVER // deny / throw — a trust gate that throws becomes a denial vector. Default // (enforcement off) is byte-for-byte the prior behavior (zero gate). - const requireTrust = - s.settings.requireTrust === true || process.env.OPENCODE_HOOKS_REQUIRE_TRUST === "1" + const requireTrust = s.settings.requireTrust === true || process.env.OPENCODE_HOOKS_REQUIRE_TRUST === "1" if (requireTrust && !isTrusted(s.cwd) && s.settings.allowUntrusted !== true) { log.warn("hooks skipped: workspace not trusted", { cwd: s.cwd, @@ -2043,6 +2052,8 @@ export const layer = Layer.effect( // leaked in memory for the process lifetime. if (payload.event === "SessionEnd" && ctx.sessionID) { yield* sessionHooks.clear(SessionID.make(ctx.sessionID)) + s.seen.delete(ctx.sessionID) + s.once.delete(ctx.sessionID) } return result } @@ -2075,6 +2086,8 @@ export const layer = Layer.effect( log.info("trigger short-circuit", { event: payload.event, reason: "empty_matchers" }) if (payload.event === "SessionEnd" && ctx.sessionID) { yield* sessionHooks.clear(SessionID.make(ctx.sessionID)) + s.seen.delete(ctx.sessionID) + s.once.delete(ctx.sessionID) } return result } @@ -2085,6 +2098,7 @@ export const layer = Layer.effect( for (const group of matchers) { if (!matches(group.matcher, target)) continue + let claimed = false for (const entry of group.hooks) { // Forward-compat: skip truly unknown types so future schema additions don't crash // older handlers. Known types (command/mcp/http/prompt/agent) all flow into runEntry. @@ -2100,15 +2114,25 @@ export const layer = Layer.effect( // [FORK:hook-ext] Pre-dispatch filter — skip entry if condition not met if (forkHooks?.beforeRunEntry && !forkHooks.beforeRunEntry(entry, envelope, payload.event)) continue + const onceBucket = s.once.get(ctx.sessionID) ?? new WeakSet<HookCommand>() + if (entry.once && onceBucket.has(entry)) continue + + if (group._sessionEntry?.once && ctx.sessionID && !claimed) { + if (!(yield* sessionHooks.claim(SessionID.make(ctx.sessionID), group._sessionEntry.id))) break + claimed = true + } + if (entry.once) { + onceBucket.add(entry) + s.once.set(ctx.sessionID, onceBucket) + } + if (entry.statusMessage) log.info("hook status", { event: payload.event, message: entry.statusMessage }) + // ── Async fork (hook-async-rewake) ────────────────────── // async:true entries are forked into the background and do NOT // participate in the current TriggerResult aggregation. Their output // (when asyncRewake:true) is delivered back via onAsyncComplete → // Session.rewake once the background fiber settles. if (entry.async) { - if (group._sessionEntry?.once && ctx.sessionID) { - yield* sessionHooks.remove(SessionID.make(ctx.sessionID), group._sessionEntry.id) - } const hookRewake = Option.getOrUndefined(yield* Effect.serviceOption(HookRewake.Service)) const capturedEvent = payload.event const capturedSessionID = ctx.sessionID @@ -2152,7 +2176,12 @@ export const layer = Layer.effect( command: commandText(entry), error: String(defect), }) - return Effect.succeed({ json: undefined, exitBlock: undefined, rawStdout: undefined, exitCode: undefined }) + return Effect.succeed({ + json: undefined, + exitBlock: undefined, + rawStdout: undefined, + exitCode: undefined, + }) }), ) @@ -2176,14 +2205,10 @@ export const layer = Layer.effect( (payload.event === "UserPromptSubmit" || payload.event === "SessionStart") ) { const text = rawStdout.trim() - if (text && addSeen(s.seen, ctx.sessionID, text)) { + if (text && !text.startsWith("{") && addSeen(s.seen, ctx.sessionID, text)) { result.additionalContexts.push(text) } } - // once: true entries are cleared after running, regardless of result. - if (group._sessionEntry?.once && ctx.sessionID) { - yield* sessionHooks.remove(SessionID.make(ctx.sessionID), group._sessionEntry.id) - } continue } @@ -2217,9 +2242,7 @@ export const layer = Layer.effect( // Most-restrictive-wins: deny > ask > allow. A later hook cannot // relax an earlier hook's deny (Claude Code permission semantics). const moreRestrictive = - current === undefined || - incoming === "deny" || - (incoming === "ask" && current === "allow") + current === undefined || incoming === "deny" || (incoming === "ask" && current === "allow") if (moreRestrictive) { result.permissionDecision = incoming result.permissionDecisionReason = @@ -2230,12 +2253,6 @@ export const layer = Layer.effect( result.updatedInput = hso.updatedInput } - // once: true cleanup — runs after aggregating this entry's json so - // additionalContext etc. still surface on the first (and only) firing. - if (group._sessionEntry?.once && ctx.sessionID) { - yield* sessionHooks.remove(SessionID.make(ctx.sessionID), group._sessionEntry.id) - } - // CC contract: continue=false short-circuits remaining hooks in this // matcher (and below, in subsequent matchers). Aggregation for the // current entry's json has already happened above — break only after. @@ -2246,6 +2263,8 @@ export const layer = Layer.effect( if (payload.event === "SessionEnd" && ctx.sessionID) { yield* sessionHooks.clear(SessionID.make(ctx.sessionID)) + s.seen.delete(ctx.sessionID) + s.once.delete(ctx.sessionID) } return result }) @@ -2259,12 +2278,16 @@ export const layer = Layer.effect( }), ) -// Only provide deps needed at layer construction (SessionHooks — the sole -// service yielded in the layer body). Handler deps (MCP/Provider/Auth/FSUtil/ -// HttpClient/CrossSpawnSpawner/HookRewake) are resolved lazily at trigger time -// from whatever ambient context the Effect runs in. -export const defaultLayer = layer.pipe( - Layer.provide(SessionHooks.defaultLayer), +export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(SessionHooks.defaultLayer), + Layer.provide(MCP.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(FetchHttpClient.layer), + ), ) // ── type:"mcp" hook execution ─────────────────────────────────── @@ -2285,11 +2308,7 @@ export const defaultLayer = layer.pipe( * tool with the hook envelope as `arguments`. Parses the first text content * item as JSON to obtain the standard hook control output. */ -function invokeMcpHook( - mcpSvc: MCP.Interface, - command: string, - envelope: Record<string, unknown>, -) { +function invokeMcpHook(mcpSvc: MCP.Interface, command: string, envelope: Record<string, unknown>) { return Effect.gen(function* () { if (!command.startsWith("mcp__")) { log.warn("mcp hook command must start with mcp__", { command }) @@ -2321,13 +2340,16 @@ function invokeMcpHook( return undefined } - const result = yield* Effect.promise(() => + const result = yield* Effect.promise((signal) => Promise.resolve( - tool.execute!(envelope as never, { - toolCallId: `hook-${Date.now()}`, - messages: [], - abortSignal: new AbortController().signal, - } as never), + tool.execute!( + envelope as never, + { + toolCallId: `hook-${Date.now()}`, + messages: [], + abortSignal: signal, + } as never, + ), ).catch((err) => { log.warn("mcp hook execution threw", { command, error: String(err) }) return undefined @@ -2337,13 +2359,29 @@ function invokeMcpHook( if (!result || typeof result !== "object" || !("content" in result)) return undefined const content = (result as { content: Array<{ type: string; text?: string }> }).content - const firstText = content.find((c) => c.type === "text" && typeof c.text === "string")?.text + if (!Array.isArray(content)) return undefined + const firstText = content.find((c) => c && c.type === "text" && typeof c.text === "string")?.text if (!firstText) return undefined return parseStdout(firstText, command) }) } -export const node = LayerNode.make(layer, [SessionHooks.node]) +// Resolve module references after initialization; provider/plugin imports can +// otherwise encounter this module while their own node exports are in the TDZ. +export const node = { + ...LayerNode.make(layer, [SessionHooks.node]), + get dependencies() { + return [ + SessionHooks.node, + MCP.node, + Provider.node, + Auth.node, + FSUtil.node, + CrossSpawnSpawner.node, + LayerNode.make(FetchHttpClient.layer, []), + ] + }, +} export * as SettingsHook from "./settings" diff --git a/packages/opencode/src/hook/trigger-result.ts b/packages/opencode/src/hook/trigger-result.ts index dae152bda..b5eef5c7a 100644 --- a/packages/opencode/src/hook/trigger-result.ts +++ b/packages/opencode/src/hook/trigger-result.ts @@ -33,6 +33,27 @@ export interface TriggerResult { updatedInput?: Record<string, unknown> } +/** Post hooks run after the side effect; report validation feedback to the model. */ +export function postHookFeedback(result: TriggerResult): string { + return [ + ...(result.additionalContexts ?? []), + ...(result.systemMessages ?? []), + ...(result.blocked ? [`[Post-tool hook blocked] ${result.blocked.reason}`] : []), + ...(result.preventContinuation ? [`[Hook stopped] ${result.stopReason ?? "Hook requested stop"}`] : []), + ].join("\n\n") +} + +export function withHookFeedback(output: string, result: TriggerResult): string { + const feedback = postHookFeedback(result) + return feedback ? `${output}\n\n${feedback}` : output +} + +export function withHookFailure(error: unknown, result: TriggerResult): unknown { + const feedback = postHookFeedback(result) + if (!feedback) return error + return new Error(`${error instanceof Error ? error.message : String(error)}\n\n${feedback}`, { cause: error }) +} + /** * Land a TriggerResult's `systemMessages` so they are never silently dropped. * diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index fa066d5a3..48afecf29 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,6 +7,7 @@ import * as Option from "effect/Option" import os from "os" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { EventV2Bridge } from "@/event-v2-bridge" +import { type TriggerResult } from "@/hook/trigger-result" import { SettingsHook } from "@/hook/settings" import { Notification } from "@/notification" import { PermissionV1Event } from "@opencode-ai/schema/permission-v1" @@ -121,40 +122,34 @@ export const layer = Layer.effect( } yield* Effect.logInfo("asking", { id, permission: info.permission, patterns: info.patterns }) - const deferred = yield* Deferred.make<void, PermissionV1.RejectedError | PermissionV1.CorrectedError>() - pending.set(id, { info, deferred }) - let hookAutoDecided = false if (settingsHook) { - const hookResult = yield* settingsHook.trigger( - { - event: "PermissionRequest", - toolName: request.permission, - toolInput: { - permission: request.permission, - patterns: request.patterns, - metadata: request.metadata, - always: request.always, - }, - toolUseID: id, - } as any, - { sessionID: request.sessionID ?? "", transcriptPath: "" }, - ).pipe(Effect.catch(() => Effect.succeed({ permissionDecision: undefined, additionalContexts: [], systemMessages: [] } as any))) + const hookResult = yield* settingsHook + .trigger( + { + event: "PermissionRequest", + toolName: request.permission, + toolInput: { + permission: request.permission, + patterns: request.patterns, + metadata: request.metadata, + always: request.always, + }, + toolUseID: id, + } as any, + { sessionID: request.sessionID ?? "", transcriptPath: "" }, + ) + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) yield* SettingsHook.landSystemMessages(hookResult as any, { sessionID: request.sessionID ?? "" }) - // Auto-approve/deny based on hook decision - if ((hookResult as any).permissionDecision === "allow") { - hookAutoDecided = true - pending.delete(id) - yield* events.publish(Event.Asked, info) - yield* Deferred.succeed(deferred, undefined) - } else if ((hookResult as any).permissionDecision === "deny") { - hookAutoDecided = true - pending.delete(id) - yield* events.publish(Event.Asked, info) - yield* Deferred.fail(deferred, new PermissionV1.RejectedError({})) + if (hookResult.blocked || hookResult.preventContinuation || hookResult.permissionDecision === "deny") { + const reason = hookResult.blocked?.reason ?? hookResult.stopReason ?? hookResult.permissionDecisionReason + if (reason) return yield* new PermissionV1.CorrectedError({ feedback: `Permission hook: ${reason}` }) + return yield* new PermissionV1.RejectedError({}) } + if (hookResult.permissionDecision === "allow") return } - // Only publish Event.Asked if hook didn't already handle the decision - if (!hookAutoDecided) { + const deferred = yield* Deferred.make<void, PermissionV1.RejectedError | PermissionV1.CorrectedError>() + pending.set(id, { info, deferred }) + return yield* Effect.gen(function* () { yield* events.publish(Event.Asked, info) // Notification emitter — routes "agent needs attention" through the single // choke point (which fires the Notification hook). Resolved at call time so @@ -171,13 +166,8 @@ export const layer = Layer.effect( }) .pipe(Effect.ignore, Effect.forkIn(scope), Effect.asVoid) } - } - return yield* Effect.ensuring( - Deferred.await(deferred), - Effect.sync(() => { - pending.delete(id) - }), - ) + return yield* Deferred.await(deferred) + }).pipe(Effect.ensuring(Effect.sync(() => pending.delete(id)))) }) const reply = Effect.fn("Permission.reply")(function* (input: PermissionV1.ReplyInput) { @@ -289,6 +279,7 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st export const defaultLayer = layer.pipe( Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(SettingsHook.defaultLayer), ) export const node = LayerNode.make(layer, [EventV2Bridge.node, SettingsHook.node, Notification.node]) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 2e15bf35a..904da3293 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -92,6 +92,11 @@ export const SessionHookCommandPayload = Schema.Struct({ if: Schema.optional(Schema.String), async: Schema.optional(Schema.Boolean), asyncRewake: Schema.optional(Schema.Boolean), + shell: Schema.optional(Schema.Literals(["bash", "powershell"])), + allowedEnvVars: Schema.optional(Schema.Array(Schema.String)), + statusMessage: Schema.optional(Schema.String), + once: Schema.optional(Schema.Boolean), + options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) export const SessionHookAddPayload = Schema.Struct({ event: HookEventParam, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 3c7cb06b0..3e22950f5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -491,12 +491,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (hook.timeout !== undefined && hook.timeout <= 0), ) if (invalidEntry) return yield* new HttpApiError.BadRequest({}) - const id = yield* sessionHooks.add(ctx.params.sessionID, { - event: ctx.payload.event as HookEvent, - matcher: ctx.payload.matcher, - hooks: ctx.payload.hooks as SessionHookCommand[], - once: ctx.payload.once, - }) + const id = yield* sessionHooks + .add(ctx.params.sessionID, { + event: ctx.payload.event as HookEvent, + matcher: ctx.payload.matcher, + hooks: ctx.payload.hooks as SessionHookCommand[], + once: ctx.payload.once, + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) return { id } }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9074c71f2..540b9f301 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,3 +1,4 @@ +import { withHookFeedback } from "@/hook/trigger-result" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import path from "path" @@ -67,6 +68,7 @@ import { SettingsHook, HOOK_REWAKE_SENTINEL, type TriggerResult } from "@/hook/s import { applyPreHookDecision } from "@/hook/pre-hook-decision" import { dispatchTrust } from "@/hook/workspace-trust" import { HookStartContext } from "@/hook/start-context" +import { HookRewake } from "@/hook/rewake" import { Goal } from "@/goal/goal" import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex" import { Memory } from "@/memory/memory" @@ -165,7 +167,18 @@ export const layer = Layer.effect( const flags = yield* RuntimeFlags.Service const database = yield* Database.Service const { db } = database - const settingsHook = Option.getOrUndefined(yield* Effect.serviceOption(SettingsHook.Service)) + const rawSettingsHook = Option.getOrUndefined(yield* Effect.serviceOption(SettingsHook.Service)) + const rewake = Context.make( + HookRewake.Service, + HookRewake.bind(({ sessionID, text }) => prompt({ sessionID, parts: [{ type: "text", text }] })), + ) + const settingsHook: SettingsHook.Interface | undefined = rawSettingsHook && { + ...rawSettingsHook, + trigger: (payload, context) => + rawSettingsHook + .trigger(payload, context) + .pipe(Effect.updateContext((current: Context.Context<never>) => Context.merge(rewake, current))), + } const startContext = Option.getOrUndefined(yield* Effect.serviceOption(HookStartContext.Service)) const goal = Option.getOrUndefined(yield* Effect.serviceOption(Goal.Service)) const promptLocks = KeyedMutex.makeUnsafe<SessionID>() @@ -350,24 +363,48 @@ export const layer = Layer.effect( // SettingsHook: PreToolUse for task tool if (settingsHook) { const preResult = yield* settingsHook - .trigger( - { event: "PreToolUse", toolName: TaskTool.id, toolInput: taskArgs, toolUseID: part.callID } as any, - { sessionID, transcriptPath: "" }, + .trigger({ event: "PreToolUse", toolName: TaskTool.id, toolInput: taskArgs, toolUseID: part.callID } as any, { + sessionID, + transcriptPath: "", + }) + .pipe( + Effect.catch(() => + Effect.succeed({ + blocked: undefined, + permissionDecision: undefined as "allow" | "deny" | "ask" | undefined, + permissionDecisionReason: undefined as string | undefined, + } as any), + ), ) - .pipe(Effect.catch(() => Effect.succeed({ blocked: undefined, permissionDecision: undefined as "allow" | "deny" | "ask" | undefined, permissionDecisionReason: undefined as string | undefined } as any))) yield* SettingsHook.landSystemMessages(preResult as TriggerResult, { sessionID }) const decision = applyPreHookDecision(taskArgs, preResult as any) // deny / blocked → error part if (decision.deniedReason) { if (part.state.status === "running") { - part = yield* sessions.updatePart({ ...part, state: { ...part.state, status: "error", error: decision.deniedReason, time: { ...part.state.time, end: Date.now() } } } satisfies SessionV1.ToolPart) + part = yield* sessions.updatePart({ + ...part, + state: { + ...part.state, + status: "error", + error: decision.deniedReason, + time: { ...part.state.time, end: Date.now() }, + }, + } satisfies SessionV1.ToolPart) } return { info: assistantMessage, parts: [part] } } // preventContinuation → stop; reflect the stop message as the part output so the agent sees it if (decision.stopReason) { if (part.state.status === "running") { - part = yield* sessions.updatePart({ ...part, state: { ...part.state, status: "error", error: `[Hook stopped] ${decision.stopReason}`, time: { ...part.state.time, end: Date.now() } } } satisfies SessionV1.ToolPart) + part = yield* sessions.updatePart({ + ...part, + state: { + ...part.state, + status: "error", + error: `[Hook stopped] ${decision.stopReason}`, + time: { ...part.state.time, end: Date.now() }, + }, + } satisfies SessionV1.ToolPart) } return { info: assistantMessage, parts: [part] } } @@ -380,7 +417,10 @@ export const layer = Layer.effect( hookReason: preResult.permissionDecisionReason, }) if (part.state.status === "running") { - part = yield* sessions.updatePart({ ...part, state: { ...part.state, status: "error", error: reason, time: { ...part.state.time, end: Date.now() } } } satisfies SessionV1.ToolPart) + part = yield* sessions.updatePart({ + ...part, + state: { ...part.state, status: "error", error: reason, time: { ...part.state.time, end: Date.now() } }, + } satisfies SessionV1.ToolPart) } return { info: assistantMessage, parts: [part] } } @@ -476,16 +516,18 @@ export const layer = Layer.effect( if (settingsHook) { const postResult: any = yield* settingsHook .trigger( - { event: "PostToolUse", toolName: TaskTool.id, toolInput: taskArgs, toolResponse: result?.output ?? "", toolUseID: part.callID } as any, + { + event: "PostToolUse", + toolName: TaskTool.id, + toolInput: taskArgs, + toolResponse: result?.output ?? "", + toolUseID: part.callID, + } as any, { sessionID, transcriptPath: "" }, ) .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) yield* SettingsHook.landSystemMessages(postResult as TriggerResult, { sessionID }) - // PostToolUse preventContinuation: tool already executed, annotate its output. - if (postResult?.preventContinuation && result) { - const stopReason = postResult.stopReason ?? "Hook requested stop" - ;(result as any).output = `${result.output ?? ""}\n\n[Hook stopped] ${stopReason}` - } + if (result) result.output = withHookFeedback(result.output ?? "", postResult) } assistantMessage.finish = "tool-calls" @@ -680,10 +722,23 @@ export const layer = Layer.effect( if (settingsHook) { const preResult = yield* settingsHook .trigger( - { event: "PreToolUse", toolName: "bash", toolInput: { command: input.command }, toolUseID: mutablePart.callID } as any, + { + event: "PreToolUse", + toolName: "bash", + toolInput: { command: input.command }, + toolUseID: mutablePart.callID, + } as any, { sessionID: input.sessionID, transcriptPath: "" }, ) - .pipe(Effect.catch(() => Effect.succeed({ blocked: undefined, permissionDecision: undefined as "allow" | "deny" | "ask" | undefined, permissionDecisionReason: undefined as string | undefined } as any))) + .pipe( + Effect.catch(() => + Effect.succeed({ + blocked: undefined, + permissionDecision: undefined as "allow" | "deny" | "ask" | undefined, + permissionDecisionReason: undefined as string | undefined, + } as any), + ), + ) yield* SettingsHook.landSystemMessages(preResult as TriggerResult, { sessionID: input.sessionID }) const decision = applyPreHookDecision({ command: input.command }, preResult as any) // deny / blocked / stop / ask-degrade all skip execution; each surfaces its own message. @@ -704,9 +759,20 @@ export const layer = Layer.effect( }) } if (skipReason !== undefined) { - const errorState = { status: "error" as const, error: skipReason, time: { start: (mutablePart.state as any).time?.start ?? Date.now(), end: Date.now() }, input: mutablePart.state.input } - mutablePart = yield* sessions.updatePart({ ...mutablePart, state: errorState as any } satisfies SessionV1.ToolPart) - } else if (decision.effectiveArgs.command !== undefined && decision.effectiveArgs.command !== input.command) { + const errorState = { + status: "error" as const, + error: skipReason, + time: { start: (mutablePart.state as any).time?.start ?? Date.now(), end: Date.now() }, + input: mutablePart.state.input, + } + mutablePart = yield* sessions.updatePart({ + ...mutablePart, + state: errorState as any, + } satisfies SessionV1.ToolPart) + } else if ( + decision.effectiveArgs.command !== undefined && + decision.effectiveArgs.command !== input.command + ) { // updatedInput rewrote the command — sync execution (args), TUI display (part.state.input), // and PostToolUse toolInput (which reads effectiveCommand below). `!== undefined` (not // truthiness) so a hook that clears the command to "" is honored rather than ignored. @@ -717,24 +783,24 @@ export const layer = Layer.effect( } } if (!shellHookDenied) { - const cmd = ChildProcess.make(sh, args, { - cwd, - extendEnv: true, - env: { ...shellEnv.env, TERM: "dumb" }, - stdin: "ignore", - forceKillAfter: "3 seconds", - }) - const handle = yield* spawner.spawn(cmd) - yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) => - Effect.gen(function* () { - output += chunk - if (mutablePart.state.status === "running") { - mutablePart.state.metadata = { output } - yield* sessions.updatePart(mutablePart) - } - }), - ) - yield* handle.exitCode + const cmd = ChildProcess.make(sh, args, { + cwd, + extendEnv: true, + env: { ...shellEnv.env, TERM: "dumb" }, + stdin: "ignore", + forceKillAfter: "3 seconds", + }) + const handle = yield* spawner.spawn(cmd) + yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) => + Effect.gen(function* () { + output += chunk + if (mutablePart.state.status === "running") { + mutablePart.state.metadata = { output } + yield* sessions.updatePart(mutablePart) + } + }), + ) + yield* handle.exitCode } // end if (!shellHookDenied) }).pipe(Effect.scoped, Effect.orDie), ).pipe(Effect.exit) @@ -748,17 +814,22 @@ export const layer = Layer.effect( if (settingsHook && !shellHookDenied) { const postResult: any = yield* settingsHook .trigger( - { event: "PostToolUse", toolName: "bash", toolInput: { command: effectiveCommand }, toolResponse: output, toolUseID: mutablePart.callID } as any, + { + event: "PostToolUse", + toolName: "bash", + toolInput: { command: effectiveCommand }, + toolResponse: output, + toolUseID: mutablePart.callID, + } as any, { sessionID: input.sessionID, transcriptPath: "" }, ) .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) yield* SettingsHook.landSystemMessages(postResult as TriggerResult, { sessionID: input.sessionID }) - // PostToolUse preventContinuation: annotate output (command already ran). - if (postResult?.preventContinuation) { - const stopReason = postResult.stopReason ?? "Hook requested stop" - output += `\n\n[Hook stopped] ${stopReason}` + const annotated = withHookFeedback(output, postResult) + if (annotated !== output) { + output = annotated if (mutablePart.state.status === "completed") { - mutablePart.state = { ...mutablePart.state, output, metadata: { output } } as any + mutablePart.state = { ...mutablePart.state, output, metadata: { output } } yield* sessions.updatePart(mutablePart) } } @@ -1332,7 +1403,7 @@ export const layer = Layer.effect( // Loop guard (hook-async-rewake): skip hooks for rewake prompts (those whose // text starts with HOOK_REWAKE_SENTINEL) to prevent hook → rewake → hook loops. let hookAdditionalContexts: string[] = [] - const promptText = input.parts.map((p: any) => p.type === "text" ? p.text : "").join("\n") + const promptText = input.parts.map((p: any) => (p.type === "text" ? p.text : "")).join("\n") const isRewake = promptText.startsWith(HOOK_REWAKE_SENTINEL) if (settingsHook && !isRewake) { const hookResult = yield* settingsHook @@ -1346,19 +1417,32 @@ export const layer = Layer.effect( // parts when the turn proceeds so the model sees them — no silent drop). yield* SettingsHook.landSystemMessages(hookResult, { sessionID: input.sessionID, - inject: hookResult.blocked - ? undefined - : (text) => - sessions.updatePart({ - id: PartID.ascending(), - messageID: message.info.id, - sessionID: input.sessionID, - type: "text", - text, - synthetic: true, - } satisfies SessionV1.TextPart), + inject: + hookResult.blocked || hookResult.preventContinuation + ? undefined + : (text) => + sessions.updatePart({ + id: PartID.ascending(), + messageID: message.info.id, + sessionID: input.sessionID, + type: "text", + text, + synthetic: true, + } satisfies SessionV1.TextPart), }) - if (hookResult.blocked) return { message, run: false as const } + if (hookResult.blocked || hookResult.preventContinuation) { + const reason = hookResult.stopReason ?? hookResult.blocked?.reason ?? "Hook requested stop" + const part = yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: message.info.id, + sessionID: input.sessionID, + type: "text", + text: `[Hook stopped] ${reason}`, + synthetic: true, + } satisfies SessionV1.TextPart) + message.parts.push(part) + return { message, run: false as const } + } } // SettingsHook: drain HookStartContext queued by SessionStart hooks (only if not blocked) @@ -1407,55 +1491,55 @@ export const layer = Layer.effect( return yield* wait }) - const prepareIfIdle: Interface["prepareIfIdle"] = Effect.fn("SessionPrompt.prepareIfIdle")( - function* (input: PromptInput) { - return yield* promptLocks.withLock(input.sessionID)( - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const activation = yield* Deferred.make<void>() - const admission = yield* Deferred.make< + const prepareIfIdle: Interface["prepareIfIdle"] = Effect.fn("SessionPrompt.prepareIfIdle")(function* ( + input: PromptInput, + ) { + return yield* promptLocks.withLock(input.sessionID)( + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const activation = yield* Deferred.make<void>() + const admission = + yield* Deferred.make< Exit.Exit<{ readonly message: SessionV1.WithParts; readonly run: boolean }, Image.Error> >() - const wait = yield* state.startIfIdle( - input.sessionID, - lastAssistant(input.sessionID), - Effect.gen(function* () { - yield* Deferred.await(activation) - const admitted = yield* Deferred.await(admission) - if (Exit.isFailure(admitted)) return yield* Effect.failCause(admitted.cause) - if (!admitted.value.run) return admitted.value.message - return yield* runLoop(input.sessionID) - }).pipe(Effect.orDie), - ) - if (Option.isNone(wait)) return Option.none<IdleAdmission>() - - const admitted = yield* restore(admitPrompt(input)).pipe(Effect.exit) - yield* Deferred.succeed(admission, admitted) - if (Exit.isFailure(admitted)) { - yield* Deferred.succeed(activation, undefined) - return yield* Effect.failCause(admitted.cause) - } - return Option.some({ - activate: Deferred.succeed(activation, undefined).pipe(Effect.asVoid), - result: wait.value, - abort: state.cancel(input.sessionID), - }) - }), - ), - ) - }, - ) + const wait = yield* state.startIfIdle( + input.sessionID, + lastAssistant(input.sessionID), + Effect.gen(function* () { + yield* Deferred.await(activation) + const admitted = yield* Deferred.await(admission) + if (Exit.isFailure(admitted)) return yield* Effect.failCause(admitted.cause) + if (!admitted.value.run) return admitted.value.message + return yield* runLoop(input.sessionID) + }).pipe(Effect.orDie), + ) + if (Option.isNone(wait)) return Option.none<IdleAdmission>() - const promptIfIdle: Interface["promptIfIdle"] = Effect.fn("SessionPrompt.promptIfIdle")( - (input: PromptInput) => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const prepared = yield* restore(prepareIfIdle(input)) - if (Option.isNone(prepared)) return Option.none() - yield* prepared.value.activate.pipe(Effect.onError(() => prepared.value.abort)) - return Option.some(yield* restore(prepared.value.result)) + const admitted = yield* restore(admitPrompt(input)).pipe(Effect.exit) + yield* Deferred.succeed(admission, admitted) + if (Exit.isFailure(admitted)) { + yield* Deferred.succeed(activation, undefined) + return yield* Effect.failCause(admitted.cause) + } + return Option.some({ + activate: Deferred.succeed(activation, undefined).pipe(Effect.asVoid), + result: wait.value, + abort: state.cancel(input.sessionID), + }) }), ), + ) + }) + + const promptIfIdle: Interface["promptIfIdle"] = Effect.fn("SessionPrompt.promptIfIdle")((input: PromptInput) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const prepared = yield* restore(prepareIfIdle(input)) + if (Option.isNone(prepared)) return Option.none() + yield* prepared.value.activate.pipe(Effect.onError(() => prepared.value.abort)) + return Option.some(yield* restore(prepared.value.result)) + }), + ), ) const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) { @@ -1566,9 +1650,7 @@ export const layer = Layer.effect( const stopResult = yield* settingsHook .trigger(stopPayload, { sessionID, transcriptPath: "" }) .pipe( - Effect.catch(() => - Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult), - ), + Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult)), ) // Consume Stop-hook outputs so nothing is silently dropped: inject // additionalContexts as synthetic text parts (model-visible on the @@ -1600,7 +1682,7 @@ export const layer = Layer.effect( // stop_hook_active=true so a well-behaved hook stops blocking (anti-loop). // Capped by MAX_STOP_CONTINUATIONS so a hook that ignores the signal // can't loop forever; at the limit we log.warn and force a normal exit. - if (!turnError && stopResult.blocked) { + if (!turnError && stopResult.blocked && !stopResult.preventContinuation) { if (stopContinuationCount < SettingsHook.MAX_STOP_CONTINUATIONS) { stopContinuationCount++ stopHookBlocked = true @@ -1743,6 +1825,7 @@ export const layer = Layer.effect( bypassAgentCheck, messages: msgs, promptOps, + hooks: settingsHook, }).pipe( Effect.provideService(Plugin.Service, plugin), Effect.provideService(Permission.Service, permission), @@ -2270,6 +2353,7 @@ export const defaultLayer = Layer.suspend(() => RuntimeFlags.defaultLayer, EventV2Bridge.defaultLayer, HookStartContext.defaultLayer, + SettingsHook.defaultLayer, Todo.defaultLayer, ), ), @@ -2427,7 +2511,9 @@ export const node = LayerNode.make(layer, [ Database.node, Memory.node, Todo.node, - HookStartContext.node, SettingsHook.node, Goal.node, + HookStartContext.node, + SettingsHook.node, + Goal.node, ]) export function admitIfIdle( diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index c0aa7494d..ed2f1d640 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -14,18 +14,18 @@ import { Truncate } from "@/tool/truncate" import { Plugin } from "@/plugin" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SettingsHook, type TriggerResult } from "@/hook/settings" +import { withHookFeedback, withHookFailure } from "@/hook/trigger-result" +import { toolFileChanges } from "@/hook/file-changes" import { applyPreHookDecision, classifyPermissionAsk } from "@/hook/pre-hook-decision" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" import { Effect } from "effect" import * as Option from "effect/Option" -import { MessageV2 } from "./message-v2" import { Session } from "./session" import { SessionProcessor } from "./processor" import { PartID } from "./schema" import { TodoReminders } from "./todo-reminders" import { EffectBridge } from "@/effect/bridge" import { SessionContext } from "@/effect/session-context" -import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" @@ -42,8 +42,6 @@ const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([ "image/png", "image/webp", ]) -// Tools that modify files on disk — trigger FileChanged hook after execution -const FILE_CHANGING_TOOLS = new Set(["edit", "write", "apply_patch", "multiedit", "patch"]) const ROOT_ONLY_TOOLS = new Set([MemorySearch.MemorySearchTool.id, TaskTool.id, "workflow"]) export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { @@ -54,6 +52,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { bypassAgentCheck: boolean messages: SessionV1.WithParts[] promptOps: TaskPromptOps + hooks?: SettingsHook.Interface }) { const tools: Record<string, AITool> = {} const run = yield* EffectBridge.make() @@ -62,7 +61,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const registry = yield* ToolRegistry.Service const mcp = yield* MCP.Service const truncate = yield* Truncate.Service - const settingsHook = Option.getOrUndefined(yield* Effect.serviceOption(SettingsHook.Service)) + const hooks = input.hooks ?? Option.getOrUndefined(yield* Effect.serviceOption(SettingsHook.Service)) const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({ sessionID: input.session.id, @@ -116,147 +115,180 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { description: item.description, inputSchema: jsonSchema(schema), execute(args, options) { + const settingsHook = withHookCancellation(hooks, options.abortSignal) return run.promise( // Set the active session for server-initiated MCP reverse requests // (elicitation) so the handler can route the Question to this session. SessionContext.run(context(args, options).sessionID, () => Effect.gen(function* () { const ctx = context(args, options) - yield* plugin.trigger( - "tool.execute.before", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, - { args }, - ) - // SettingsHook PreToolUse - let preContexts: string[] = [] - // Native todo surfacing (#429): once per assistant turn, before - // any non-todowrite tool result, re-show the uncompleted list. - const todoReminder = yield* TodoReminders.preToolCall({ - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - tool: item.id, - }) - if (settingsHook) { - const preResult = yield* settingsHook - .trigger( - { event: "PreToolUse", toolName: item.id, toolInput: toRecord(args), toolUseID: ctx.callID }, - { sessionID: ctx.sessionID, transcriptPath: "" }, - ) - .pipe(Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] }))) - yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) - const decision = applyPreHookDecision(toRecord(args), preResult) - if (decision.deniedReason) { - return { output: `[Tool denied by hook] ${decision.deniedReason}`, attachments: [], metadata: { hookDenied: true } } as any - } - if (decision.stopReason) { - return { output: `[Hook stopped] ${decision.stopReason}`, attachments: [], metadata: { hookStopped: true } } as any - } - // permissionDecision:"ask" — invoke the confirmation dialog. We call - // permission.ask directly (NOT the orDie-piped ctx.ask) and classify the - // outcome: typed rejections become a denied result, while interrupts - // (session abort mid-dialog) and defects propagate instead of being - // masked as a denial. - if (preResult.permissionDecision === "ask") { - const askReason = preResult.permissionDecisionReason - const verdict = yield* permission - .ask({ - permission: item.id, - sessionID: ctx.sessionID, - patterns: [item.id], - always: [], - metadata: { hookAsk: true, ...(askReason ? { reason: askReason } : {}) }, - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: [], - }) - .pipe(Effect.exit) - const outcome = classifyPermissionAsk(verdict) - if (outcome !== "approved" && outcome !== "denied") return yield* Effect.failCause(outcome.propagate as never) - if (outcome === "denied") { - const reason = askReason ?? "Denied by user in hook confirmation" - return { output: `[Tool denied by hook] ${reason}`, attachments: [], metadata: { hookDenied: true } } as any - } - } - preContexts = preResult.additionalContexts ?? [] - // effectiveArgs reflects any PreToolUse updatedInput rewrite (shallow merge). - args = decision.effectiveArgs - } - const result = yield* Effect.suspend(() => { - const cleanup = setActiveElicitationSession(ctx.sessionID) - return item.execute(args, ctx).pipe(Effect.ensuring(Effect.sync(cleanup))) - }) - const output = { - ...result, - attachments: result.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), + yield* plugin.trigger( + "tool.execute.before", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, + { args }, + ) + // SettingsHook PreToolUse + let preContexts: string[] = [] + // Native todo surfacing (#429): once per assistant turn, before + // any non-todowrite tool result, re-show the uncompleted list. + const todoReminder = yield* TodoReminders.preToolCall({ sessionID: ctx.sessionID, messageID: input.processor.message.id, - })), - } - // PreToolUse additionalContexts: prepend so the model sees any hook-injected - // gate/reminder before the tool result (mirrors PostToolUse surfacing below). - const preLines = [todoReminder, ...preContexts].filter((line): line is string => Boolean(line)) - if (preLines.length) { - output.output = `${preLines.join("\n\n")}\n\n${output.output ?? ""}` - } - yield* plugin.trigger( - "tool.execute.after", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, - output, - ) - // SettingsHook PostToolUse - if (settingsHook) { - const postResult = yield* settingsHook - .trigger( - { event: "PostToolUse", toolName: item.id, toolInput: toRecord(args), toolResponse: output.output, toolUseID: ctx.callID } as any, - { sessionID: ctx.sessionID, transcriptPath: "" }, - ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [] as string[] } as any))) - yield* SettingsHook.landSystemMessages(postResult as TriggerResult, { sessionID: ctx.sessionID }) - // Inject additionalContext into tool output so model sees it - if ((postResult as any).additionalContexts?.length) { - output.output += "\n\n" + (postResult as any).additionalContexts.join("\n") + tool: item.id, + }) + if (settingsHook) { + const preResult = yield* settingsHook + .trigger( + { event: "PreToolUse", toolName: item.id, toolInput: toRecord(args), toolUseID: ctx.callID }, + { sessionID: ctx.sessionID, transcriptPath: "" }, + ) + .pipe( + Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] })), + ) + yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) + const decision = applyPreHookDecision(toRecord(args), preResult) + if (decision.deniedReason) { + return { + output: `[Tool denied by hook] ${decision.deniedReason}`, + attachments: [], + metadata: { hookDenied: true }, + } as any + } + if (decision.stopReason) { + return { + output: `[Hook stopped] ${decision.stopReason}`, + attachments: [], + metadata: { hookStopped: true }, + } as any + } + // permissionDecision:"ask" — invoke the confirmation dialog. We call + // permission.ask directly (NOT the orDie-piped ctx.ask) and classify the + // outcome: typed rejections become a denied result, while interrupts + // (session abort mid-dialog) and defects propagate instead of being + // masked as a denial. + if (preResult.permissionDecision === "ask") { + const askReason = preResult.permissionDecisionReason + const verdict = yield* permission + .ask({ + permission: item.id, + sessionID: ctx.sessionID, + patterns: [item.id], + always: [], + metadata: { hookAsk: true, ...(askReason ? { reason: askReason } : {}) }, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + ruleset: [], + }) + .pipe(Effect.exit) + const outcome = classifyPermissionAsk(verdict) + if (outcome !== "approved" && outcome !== "denied") + return yield* Effect.failCause(outcome.propagate as never) + if (outcome === "denied") { + const reason = askReason ?? "Denied by user in hook confirmation" + return { + output: `[Tool denied by hook] ${reason}`, + attachments: [], + metadata: { hookDenied: true }, + } as any + } + } + preContexts = preResult.additionalContexts ?? [] + // effectiveArgs reflects any PreToolUse updatedInput rewrite (shallow merge). + args = decision.effectiveArgs } - // PostToolUse preventContinuation: tool already executed, so annotate - // the output rather than skipping. Soft signal, mirrors CC semantics. - if ((postResult as any).preventContinuation) { - const stopReason = (postResult as any).stopReason ?? "Hook requested stop" - output.output += `\n\n[Hook stopped] ${stopReason}` + if (options.abortSignal?.aborted) return yield* Effect.interrupt + const result = yield* Effect.suspend(() => { + const cleanup = setActiveElicitationSession(ctx.sessionID) + return item.execute(args, ctx).pipe(Effect.ensuring(Effect.sync(cleanup))) + }) + const output = { + ...result, + attachments: result.attachments?.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), } - } - // SettingsHook FileChanged for file-modifying tools - if (settingsHook && FILE_CHANGING_TOOLS.has(item.id)) { - const fileResult = yield* settingsHook - .trigger( - { event: "FileChanged", path: (toRecord(args))["file_path"] ?? (toRecord(args))["path"], changeType: item.id } as any, - { sessionID: ctx.sessionID, transcriptPath: "" }, - ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) - yield* SettingsHook.landSystemMessages(fileResult, { sessionID: ctx.sessionID }) - } - if (options.abortSignal?.aborted) { - yield* input.processor.completeToolCall(options.toolCallId, output) - } - return output - }).pipe( - Effect.catch((error: unknown) => - Effect.gen(function* () { - // SettingsHook PostToolUseFailure + // PreToolUse additionalContexts: prepend so the model sees any hook-injected + // gate/reminder before the tool result (mirrors PostToolUse surfacing below). + const preLines = [todoReminder, ...preContexts].filter((line): line is string => Boolean(line)) + if (preLines.length) { + output.output = `${preLines.join("\n\n")}\n\n${output.output ?? ""}` + } + yield* plugin.trigger( + "tool.execute.after", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, + output, + ) + // SettingsHook PostToolUse if (settingsHook) { - const failResult = yield* settingsHook + const postResult = yield* settingsHook .trigger( - { event: "PostToolUseFailure", toolName: item.id, toolInput: toRecord(args), error: String(error), toolUseID: options.toolCallId } as any, - { sessionID: input.session.id, transcriptPath: "" }, + { + event: "PostToolUse", + toolName: item.id, + toolInput: toRecord(args), + toolResponse: output.output, + toolUseID: ctx.callID, + }, + { sessionID: ctx.sessionID, transcriptPath: "" }, ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) - yield* SettingsHook.landSystemMessages(failResult, { sessionID: input.session.id }) + .pipe( + Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] })), + ) + yield* SettingsHook.landSystemMessages(postResult, { sessionID: ctx.sessionID }) + output.output = withHookFeedback(output.output ?? "", postResult) + } + if (settingsHook) { + for (const change of toolFileChanges( + item.id, + toRecord(args), + result.metadata, + input.session.directory, + )) { + const fileResult = yield* settingsHook + .trigger({ event: "FileChanged", ...change }, { sessionID: ctx.sessionID, transcriptPath: "" }) + .pipe( + Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] })), + ) + yield* SettingsHook.landSystemMessages(fileResult, { sessionID: ctx.sessionID }) + output.output = withHookFeedback(output.output ?? "", fileResult) + } + } + if (options.abortSignal?.aborted) { + yield* input.processor.completeToolCall(options.toolCallId, output) } - return yield* Effect.fail(error) - }), + return output + }).pipe( + Effect.catch((error: unknown) => + Effect.gen(function* () { + // SettingsHook PostToolUseFailure + if (settingsHook) { + const failResult = yield* settingsHook + .trigger( + { + event: "PostToolUseFailure", + toolName: item.id, + toolInput: toRecord(args), + error: String(error), + toolUseID: options.toolCallId, + }, + { sessionID: input.session.id, transcriptPath: "" }, + ) + .pipe( + Effect.catch(() => + Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult), + ), + ) + yield* SettingsHook.landSystemMessages(failResult, { sessionID: input.session.id }) + error = withHookFailure(error, failResult) + } + return yield* Effect.fail(error) + }), + ), ), ), - ), - ) + ) }, }) } @@ -520,8 +552,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema)) const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} }) item.inputSchema = jsonSchema(transformed) - item.execute = (args, opts) => - run.promise( + item.execute = (args, opts) => { + const settingsHook = withHookCancellation(hooks, opts.abortSignal) + return run.promise( Effect.gen(function* () { const ctx = context(args, opts) yield* plugin.trigger( @@ -543,12 +576,12 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { { event: "PreToolUse", toolName: key, toolInput: toRecord(args), toolUseID: opts.toolCallId }, { sessionID: ctx.sessionID, transcriptPath: "" }, ) - .pipe(Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] }))) - yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) - const decision = applyPreHookDecision(toRecord(args), preResult) - if (decision.deniedReason) { - return { content: [{ type: "text", text: `[Tool denied by hook] ${decision.deniedReason}` }] } as any - } + .pipe(Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] }))) + yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) + const decision = applyPreHookDecision(toRecord(args), preResult) + if (decision.deniedReason) { + return { content: [{ type: "text", text: `[Tool denied by hook] ${decision.deniedReason}` }] } as any + } if (decision.stopReason) { return { content: [{ type: "text", text: `[Hook stopped] ${decision.stopReason}` }] } as any } @@ -568,7 +601,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) .pipe(Effect.exit) const outcome = classifyPermissionAsk(verdict) - if (outcome !== "approved" && outcome !== "denied") return yield* Effect.failCause(outcome.propagate as never) + if (outcome !== "approved" && outcome !== "denied") + return yield* Effect.failCause(outcome.propagate as never) if (outcome === "denied") { const reason = askReason ?? "Denied by user in hook confirmation" return { content: [{ type: "text", text: `[Tool denied by hook] ${reason}` }] } as any @@ -577,6 +611,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { preContexts = preResult.additionalContexts ?? [] args = decision.effectiveArgs } + if (opts.abortSignal?.aborted) return yield* Effect.interrupt const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () { yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) return yield* Effect.suspend(() => { @@ -666,19 +701,18 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (settingsHook) { const postResult = yield* settingsHook .trigger( - { event: "PostToolUse", toolName: key, toolInput: toRecord(args), toolResponse: output.output, toolUseID: opts.toolCallId } as any, + { + event: "PostToolUse", + toolName: key, + toolInput: toRecord(args), + toolResponse: output.output, + toolUseID: opts.toolCallId, + }, { sessionID: ctx.sessionID, transcriptPath: "" }, ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [] as string[] } as any))) - yield* SettingsHook.landSystemMessages(postResult as TriggerResult, { sessionID: ctx.sessionID }) - if ((postResult as any).additionalContexts?.length) { - output.output += "\n\n" + (postResult as any).additionalContexts.join("\n") - } - // PostToolUse preventContinuation: annotate output (tool already ran). - if ((postResult as any).preventContinuation) { - const stopReason = (postResult as any).stopReason ?? "Hook requested stop" - output.output += `\n\n[Hook stopped] ${stopReason}` - } + .pipe(Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] }))) + yield* SettingsHook.landSystemMessages(postResult, { sessionID: ctx.sessionID }) + output.output = withHookFeedback(output.output ?? "", postResult) } if (opts.abortSignal?.aborted) { yield* input.processor.completeToolCall(opts.toolCallId, output) @@ -687,26 +721,58 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }).pipe( Effect.catch((error: unknown) => Effect.gen(function* () { - // SettingsHook PostToolUseFailure if (settingsHook) { - yield* settingsHook + const failResult = yield* settingsHook .trigger( - { event: "PostToolUseFailure", toolName: key, toolInput: toRecord(args), error: String(error), toolUseID: opts.toolCallId } as any, + { + event: "PostToolUseFailure", + toolName: key, + toolInput: toRecord(args), + error: String(error), + toolUseID: opts.toolCallId, + }, { sessionID: input.session.id, transcriptPath: "" }, ) - .pipe(Effect.catch(() => Effect.succeed(undefined as any))) + .pipe( + Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] })), + ) + yield* SettingsHook.landSystemMessages(failResult, { sessionID: input.session.id }) + error = withHookFailure(error, failResult) } return yield* Effect.fail(error) }), ), ), ) + } tools[key] = item } return tools }) +// Cancel hooks independently so tools that finalize partial output on abort can +// still persist that output through completeToolCall. +function withHookCancellation(hooks: SettingsHook.Interface | undefined, signal: AbortSignal | undefined) { + if (!hooks || !signal) return hooks + return { + ...hooks, + trigger: (payload: SettingsHook.HookPayload, ctx: SettingsHook.TriggerContext) => { + const empty = Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] }) + if (signal.aborted) return empty + return Effect.raceFirst( + hooks.trigger(payload, ctx), + Effect.callback<TriggerResult>((resume) => { + const abort = () => resume(empty) + signal.addEventListener("abort", abort, { once: true }) + if (signal.aborted) abort() + return Effect.sync(() => signal.removeEventListener("abort", abort)) + }), + ) + }, + } satisfies SettingsHook.Interface +} + function toRecord(value: unknown) { if (isRecord(value)) return value return {} diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index d08a5edef..2d76b5518 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -404,7 +404,7 @@ export const TaskTool = Tool.define( .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) // Land any hook systemMessages so they're never silently dropped. yield* SettingsHook.landSystemMessages(stopResult, { sessionID: ctx.sessionID }) - if (!stopResult.blocked) { + if (!stopResult.blocked || stopResult.preventContinuation) { lastStillBlocked = false break } diff --git a/packages/opencode/test/hook/handler-cancellation.test.ts b/packages/opencode/test/hook/handler-cancellation.test.ts new file mode 100644 index 000000000..b3a4e707a --- /dev/null +++ b/packages/opencode/test/hook/handler-cancellation.test.ts @@ -0,0 +1,159 @@ +import { expect, describe } from "bun:test" +import { Effect, Layer } from "effect" +import { MockLanguageModelV3 } from "ai/test" +import { SettingsHook, type HookCommand } from "@/hook/settings" +import { SessionHooks } from "@/hook/session-hooks" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Database } from "@opencode-ai/core/database/database" +import { SessionID } from "@/session/schema" +import { Provider } from "@/provider/provider" +import { ProviderTest } from "../fake/provider" +import { Auth } from "@/auth" +import { MCP } from "@/mcp" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { testEffect } from "../lib/effect" + +const base = SettingsHook.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provideMerge(SessionHooks.defaultLayer), +) +const it = testEffect(Layer.mergeAll(base, CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer)) +const run = (entry: HookCommand) => + Effect.gen(function* () { + const store = yield* SessionHooks.Service + const settings = yield* SettingsHook.Service + const id = SessionID.descending() + yield* store.add(id, { event: "PreToolUse", hooks: [entry] }) + return yield* settings.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + { sessionID: id, transcriptPath: "" }, + ) + }) +const usage = { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, +} +const generated = (text: string) => ({ + content: [{ type: "text" as const, text }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage, + warnings: [], +}) +const providerLayer = (language: any) => + Layer.mergeAll( + Layer.mock(Provider.Service, { + defaultModel: () => Effect.succeed({ providerID: "audit" as any, modelID: "test" as any }), + getModel: () => Effect.succeed(ProviderTest.model()), + getLanguage: () => Effect.succeed(language), + }), + Layer.mock(Auth.Service, { get: () => Effect.succeed({ type: "api", key: "test-only" } as any) }), + ) + +describe("hook handlers through real AI SDK and MCP invocation adapter", () => { + it.instance("prompt handler accepts a model-produced block decision", () => + Effect.gen(function* () { + const model = new MockLanguageModelV3({ + doGenerate: generated(JSON.stringify({ decision: "block", reason: "prompt-real-handler" })), + }) + const result = yield* run({ type: "prompt", prompt: "audit decision" }).pipe(Effect.provide(providerLayer(model))) + expect(result.blocked?.reason).toBe("prompt-real-handler") + }), + ) + + it.instance("agent handler runs synthetic_output and accepts the result", () => + Effect.gen(function* () { + const model = new MockLanguageModelV3({ + doGenerate: { + content: [ + { + type: "tool-call", + toolCallId: "audit-output", + toolName: "synthetic_output", + input: JSON.stringify({ decision: "block", reason: "agent-real-handler" }), + }, + ], + finishReason: { unified: "tool-calls", raw: "tool_calls" }, + usage, + warnings: [], + }, + }) + const result = yield* run({ type: "agent", prompt: "emit audit decision" }).pipe( + Effect.provide(providerLayer(model)), + ) + expect(result.blocked?.reason).toBe("agent-real-handler") + }), + ) + + it.instance("mcp handler maps its tool name and accepts the decision", () => + Effect.gen(function* () { + let envelope: any + const mcp = Layer.mock(MCP.Service, { + tools: () => + Effect.succeed({ + audit_check: { + execute: async (input: any) => { + envelope = input + return { + content: [{ type: "text", text: JSON.stringify({ decision: "block", reason: "mcp-real-handler" }) }], + } + }, + }, + } as any), + }) + const result = yield* run({ type: "mcp", command: "mcp__audit__check" }).pipe(Effect.provide(mcp)) + expect(envelope.hook_event_name).toBe("PreToolUse") + expect(result.blocked?.reason).toBe("mcp-real-handler") + }), + ) + + it.instance("prompt timeout must abort the underlying model request", () => + Effect.gen(function* () { + let release!: () => void + let signal: AbortSignal | undefined + const gate = new Promise<void>((resolve) => (release = resolve)) + const model = new MockLanguageModelV3({ + doGenerate: async (input) => { + signal = input.abortSignal + await gate + return generated("{}") + }, + }) + const result = yield* run({ type: "prompt", prompt: "audit timeout", timeout: 0.03 }).pipe( + Effect.provide(providerLayer(model)), + ) + const aborted = signal?.aborted ?? false + release() + expect(result.blocked).toBeUndefined() + expect(model.doGenerateCalls.length).toBe(1) + expect(aborted).toBe(true) + }), + ) + + it.instance("mcp timeout must abort the underlying tool request", () => + Effect.gen(function* () { + let release!: () => void + let signal: AbortSignal | undefined + const gate = new Promise<void>((resolve) => (release = resolve)) + const mcp = Layer.mock(MCP.Service, { + tools: () => + Effect.succeed({ + audit_check: { + execute: async (_input: any, options: any) => { + signal = options.abortSignal + await gate + return { content: [] } + }, + }, + } as any), + }) + const result = yield* run({ type: "mcp", command: "mcp__audit__check", timeout: 0.03 }).pipe(Effect.provide(mcp)) + const aborted = signal?.aborted ?? false + release() + expect(result.blocked).toBeUndefined() + expect(signal).toBeDefined() + expect(aborted).toBe(true) + }), + ) +}) diff --git a/packages/opencode/test/hook/http-handler.test.ts b/packages/opencode/test/hook/http-handler.test.ts index ade67bdcb..7d0882d2b 100644 --- a/packages/opencode/test/hook/http-handler.test.ts +++ b/packages/opencode/test/hook/http-handler.test.ts @@ -32,6 +32,51 @@ const withFetch = <A, E, R>( ) describe("SettingsHook http handler", () => { + it.instance("expands only allowed header environment variables on the wire", () => + Effect.gen(function* () { + const store = yield* SessionHooks.Service + const hook = yield* SettingsHook.Service + const id = SessionID.descending() + const previous = process.env.OPENCODE_HOOK_HEADER_TEST + process.env.OPENCODE_HOOK_HEADER_TEST = "fixture-value" + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_HOOK_HEADER_TEST + else process.env.OPENCODE_HOOK_HEADER_TEST = previous + }), + ) + let seen: Headers | undefined + yield* withFetch( + (request) => { + seen = request.headers + return Response.json({}) + }, + (url) => + Effect.gen(function* () { + yield* store.add(id, { + event: "UserPromptSubmit", + hooks: [ + { + type: "http", + url, + allowedEnvVars: ["OPENCODE_HOOK_HEADER_TEST"], + headers: { + "x-allowed": "Bearer ${OPENCODE_HOOK_HEADER_TEST}", + "x-bare": "$OPENCODE_HOOK_HEADER_TEST", + "x-denied": "value:${HOME}", + }, + }, + ], + }) + yield* hook.trigger({ event: "UserPromptSubmit", prompt: "hello" }, { sessionID: id, transcriptPath: "" }) + expect(seen?.get("x-allowed")).toBe("Bearer fixture-value") + expect(seen?.get("x-bare")).toBe("fixture-value") + expect(seen?.get("x-denied")).toBe("value:") + }), + ) + }), + ) + it.instance("applies configured entry.headers to the outbound POST", () => Effect.gen(function* () { const sessionHooks = yield* SessionHooks.Service diff --git a/packages/opencode/test/hook/prompt-admission.test.ts b/packages/opencode/test/hook/prompt-admission.test.ts new file mode 100644 index 000000000..a0ff22fce --- /dev/null +++ b/packages/opencode/test/hook/prompt-admission.test.ts @@ -0,0 +1,386 @@ +import { SessionHooks } from "@/hook/session-hooks" +import { expect } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Effect, Exit, Fiber, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" +import { SessionSummary } from "@/session/summary" +import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { provideTmpdirServer } from "../fixture/fixture" +import { testEffect, pollWithTimeout } from "../lib/effect" +import { TestLLMServer } from "../lib/llm-server" + +import { LSP } from "@/lsp/lsp" +import { MCP } from "@/mcp" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" + +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + instructions: () => Effect.succeed([]), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + resourceTemplates: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth"), + authenticate: () => Effect.die("unexpected MCP auth"), + finishAuth: () => Effect.die("unexpected MCP auth"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) + +const root = LayerNode.group([ + SessionPrompt.node, + SessionHooks.node, + Session.node, + SessionProjector.node, + SessionSummary.node, + Database.node, + CrossSpawnSpawner.node, + LayerNode.make(TestLLMServer.layer, []), +]) +const it = testEffect( + LayerNode.buildLayer(root, { + replacements: [ + LayerNode.replace(MCP.node, mcp), + LayerNode.replace(LSP.node, lsp), + LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })), + ], + }), +) + +const providerCfg = (url: string) => ({ + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, +}) + +for (const variant of ["block", "continue-false"] as const) { + it.live(`UserPromptSubmit ${variant} must stop before invoking the model`, () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const hooks = yield* SessionHooks.Service + const session = yield* sessions.create({ title: "Audit prompt hook control", permission: [] }) + const output = + variant === "block" + ? { decision: "block", reason: "audit-block" } + : { continue: false, stopReason: "audit-stop" } + yield* hooks.add(session.id, { + event: "UserPromptSubmit", + hooks: [{ type: "command", command: "printf '%s' '" + JSON.stringify(output) + "'" }], + }) + yield* llm.text("audit-model-was-called") + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "audit user prompt stop signal" }], + }) + const calls = yield* llm.calls + expect(calls).toBe(0) + expect(result.info.role).toBe("user") + }), + { git: true, config: providerCfg }, + ), + ) +} + +it.live("async hooks re-enter the real session after their caller has completed", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm, dir }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const hooks = yield* SessionHooks.Service + const session = yield* sessions.create({ title: "Async hook callback", permission: [] }) + const release = path.join(dir, "release") + const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'" + yield* hooks.add(session.id, { + event: "UserPromptSubmit", + hooks: [ + { + type: "command", + async: true, + asyncRewake: true, + once: true, + command: `while [ ! -f ${quote(release)} ]; do sleep 0.01; done; printf '%s' '{"systemMessage":"async finished"}'`, + }, + ], + }) + yield* llm.text("first response") + yield* llm.text("rewake response") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "start async hook" }], + }) + expect(yield* llm.calls).toBe(1) + yield* Effect.promise(() => fs.writeFile(release, "go")) + yield* pollWithTimeout( + llm.calls.pipe(Effect.map((calls) => (calls === 2 ? true : undefined))), + "async hook did not invoke the model again", + ) + const messages = yield* sessions.messages({ sessionID: session.id }) + expect( + messages.some((message) => + message.parts.some((part) => part.type === "text" && part.text.includes("Async hook completed")), + ), + ).toBe(true) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("session cancellation aborts an executing pre-tool hook", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm, dir }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const hooks = yield* SessionHooks.Service + const session = yield* sessions.create({ + title: "Cancel running hook", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const ready = path.join(dir, "hook.pid") + const target = path.join(dir, "must-not-write.txt") + const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'" + yield* hooks.add(session.id, { + event: "PreToolUse", + hooks: [{ type: "command", command: `echo $$ > ${quote(ready)}; exec sleep 30` }], + }) + yield* llm.tool("write", { filePath: target, content: "should be cancelled" }) + yield* prompt + .prompt({ sessionID: session.id, agent: "build", parts: [{ type: "text", text: "cancel this operation" }] }) + .pipe(Effect.forkChild) + const pid = yield* pollWithTimeout( + Effect.promise(async () => { + try { + return Number(await fs.readFile(ready, "utf8")) || undefined + } catch { + return undefined + } + }), + "pre-tool hook never started", + ) + yield* prompt.cancel(session.id) + yield* pollWithTimeout( + Effect.sync(() => { + try { + process.kill(pid, 0) + return undefined + } catch { + return true + } + }), + "pre-tool hook survived session cancellation", + 2000, + ) + expect( + yield* Effect.promise(() => + fs.access(target).then( + () => true, + () => false, + ), + ), + ).toBe(false) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("session cancellation aborts a post-tool hook and preserves the completed tool", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm, dir }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const hooks = yield* SessionHooks.Service + const session = yield* sessions.create({ + title: "Cancel post-tool hook", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const ready = path.join(dir, "post-hook.pid") + const target = path.join(dir, "written.txt") + const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'" + yield* hooks.add(session.id, { + event: "PostToolUse", + hooks: [{ type: "command", command: `echo $$ > ${quote(ready)}; exec sleep 30` }], + }) + yield* llm.tool("write", { filePath: target, content: "completed before cancellation" }) + const running = yield* prompt + .prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "write the file" }], + }) + .pipe(Effect.forkChild) + const pid = yield* pollWithTimeout( + Effect.promise(async () => { + try { + return Number(await fs.readFile(ready, "utf8")) || undefined + } catch { + return undefined + } + }), + "post-tool hook never started", + ) + yield* prompt.cancel(session.id) + yield* pollWithTimeout( + Effect.sync(() => { + try { + process.kill(pid, 0) + return undefined + } catch { + return true + } + }), + "post-tool hook survived cancellation", + 2000, + ) + const result = yield* Fiber.await(running) + expect(Exit.isSuccess(result)).toBe(true) + if (Exit.isFailure(result)) return + const tool = result.value.parts.find((part) => part.type === "tool") + expect(tool?.state.status).toBe("completed") + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("completed before cancellation") + }), + { git: true, config: providerCfg }, + ), +) + +it.live("native write, edit and multi-file patch emit actual FileChanged paths", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm, dir }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const hooks = yield* SessionHooks.Service + const session = yield* sessions.create({ + title: "Hook file events", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const capture = path.join(dir, "events.jsonl") + const original = path.join(dir, "source.txt") + const moved = path.join(dir, "moved.txt") + const second = path.join(dir, "second.txt") + const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'" + yield* hooks.add(session.id, { + event: "FileChanged", + hooks: [{ type: "command", command: "cat >> " + quote(capture) }], + }) + yield* llm.tool("write", { filePath: original, content: "before\n" }) + yield* llm.tool("edit", { filePath: original, oldString: "before", newString: "edited" }) + yield* llm.text("edited") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test-model") }, + parts: [{ type: "text", text: "exercise write and edit" }], + }) + expect(yield* Effect.promise(() => fs.readFile(original, "utf8"))).toBe("edited\n") + yield* llm.tool("apply_patch", { + patchText: `*** Begin Patch\n*** Update File: ${original}\n*** Move to: ${moved}\n@@\n-edited\n+after\n*** Add File: ${second}\n+second\n*** End Patch`, + }) + yield* llm.text("done") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("gpt-hook-test") }, + parts: [{ type: "text", text: "exercise patch" }], + }) + expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("after\n") + expect(yield* Effect.promise(() => fs.readFile(second, "utf8"))).toBe("second\n") + expect( + yield* Effect.promise(() => + fs.access(original).then( + () => true, + () => false, + ), + ), + ).toBe(false) + const records = (yield* Effect.promise(() => fs.readFile(capture, "utf8"))) + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + expect(records.map((record) => [record.path, record.change_type])).toEqual([ + [original, "add"], + [original, "change"], + [original, "delete"], + [moved, "add"], + [second, "add"], + ]) + }), + { + git: true, + config: (url) => { + const config = providerCfg(url) + const model = config.provider.test.models["test-model"] + return { + provider: { + test: { + ...config.provider.test, + models: { ...config.provider.test.models, "gpt-hook-test": { ...model, id: "gpt-hook-test" } }, + }, + }, + } + }, + }, + ), +) diff --git a/packages/opencode/test/hook/readonly-command.test.ts b/packages/opencode/test/hook/readonly-command.test.ts new file mode 100644 index 000000000..0f290af3d --- /dev/null +++ b/packages/opencode/test/hook/readonly-command.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { parseReadonlyCommand, whitelistReject } from "@/hook/readonly-command" + +describe("agent read-only command boundary", () => { + for (const command of [ + "find . -delete", + "find . -exec touch marker", + "find . -fprint marker", + "sort input -o marker", + "sort --output=marker input", + "sort --compress-program=sh input", + "file -z archive.gz", + "uniq input marker", + "uniq -- input marker", + "git diff --output=marker", + "git diff --ext-diff", + "git show --textconv", + "git -c alias.status=evil status", + "git log --format=x --output=marker", + "sed -n '1w marker' input", + "sed -n '1e touch marker' input", + "awk 'BEGIN {system(\"touch marker\")}'", + "echo safe\ntouch marker", + "echo safe\rtouch marker", + "echo safe; touch marker", + "echo $(touch marker)", + "echo `touch marker`", + "cat input > marker", + "/tmp/cat input", + "./git status", + "echo 'unterminated", + ]) { + test(`rejects ${JSON.stringify(command)}`, () => expect(whitelistReject(command)).not.toBeNull()) + } + + for (const command of [ + "ls -la", + "cat 'file with spaces.txt'", + "grep -n needle src/file.ts", + "find . -name '*.ts' -type f", + "git status --short", + "git log -n 2 --oneline", + "git diff --stat", + "git show HEAD -- src/file.ts", + "head -n 10 input", + "tail -f input", + "sort -nu input", + "uniq -c input", + "sed -n '1,10p' input", + ]) { + test(`accepts ${command}`, () => expect(whitelistReject(command)).toBeNull()) + } + + test("preserves quoted arguments without shell expansion", () => { + expect(parseReadonlyCommand("cat 'a b' \"c d\" e\\ f")).toEqual({ name: "cat", args: ["a b", "c d", "e f"] }) + }) +}) diff --git a/packages/opencode/test/hook/runtime-boundaries.test.ts b/packages/opencode/test/hook/runtime-boundaries.test.ts new file mode 100644 index 000000000..9c0e109d3 --- /dev/null +++ b/packages/opencode/test/hook/runtime-boundaries.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer, Fiber } from "effect" +import fs from "node:fs/promises" +import path from "node:path" +import { SettingsHook, type HookCommand } from "@/hook/settings" +import { SessionHooks } from "@/hook/session-hooks" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Database } from "@opencode-ai/core/database/database" +import { SessionID } from "@/session/schema" +import { testEffect, pollWithTimeout } from "../lib/effect" +import { TestInstance } from "../fixture/fixture" +import { __test__ as agentToolsTest } from "@/hook/agent-tools" + +const it = testEffect( + SettingsHook.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provideMerge(SessionHooks.defaultLayer), + ), +) +const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'" +const command = (json: unknown, exit = 0) => `printf '%s' ${quote(JSON.stringify(json))}; exit ${exit}` +const runHook = (hook: HookCommand) => + Effect.gen(function* () { + const store = yield* SessionHooks.Service + const settings = yield* SettingsHook.Service + const id = SessionID.descending() + yield* store.add(id, { event: "PreToolUse", hooks: [hook] }) + return yield* settings.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: { command: "echo safe" } }, + { sessionID: id, transcriptPath: "" }, + ) + }) + +describe("hooks audit - actual runtime boundary", () => { + it.instance("valid siblings survive invalid matcher and command shapes", () => + Effect.gen(function* () { + const instance = yield* TestInstance + yield* Effect.promise(async () => { + await fs.mkdir(path.join(instance.directory, ".opencode"), { recursive: true }) + await fs.writeFile( + path.join(instance.directory, ".opencode/hooks.json"), + JSON.stringify({ + PreToolUse: [ + null, + { matcher: 42, hooks: [] }, + { + hooks: [ + null, + { type: "http" }, + { type: "command", command: command({ decision: "block", reason: "valid sibling" }) }, + ], + }, + ], + }), + ) + }) + const settings = yield* SettingsHook.Service + const result = yield* settings.trigger( + { event: "PreToolUse", toolName: "write", toolInput: {} }, + { sessionID: SessionID.descending(), transcriptPath: "" }, + ) + expect(result.blocked?.reason).toBe("valid sibling") + }), + ) + + it.instance("invalid JSON-shaped output cannot become prompt context", () => + Effect.gen(function* () { + const settings = yield* SettingsHook.Service + const store = yield* SessionHooks.Service + const id = SessionID.descending() + yield* store.add(id, { + event: "UserPromptSubmit", + hooks: [{ type: "command", command: command({ hookSpecificOutput: "broken" }) }], + }) + const result = yield* settings.trigger( + { event: "UserPromptSubmit", prompt: "hello" }, + { sessionID: id, transcriptPath: "" }, + ) + expect(result.additionalContexts).toEqual([]) + }), + ) + + it.instance("explicit bash interpreter handles bash syntax", () => + Effect.gen(function* () { + const result = yield* runHook({ + type: "command", + shell: "bash", + command: "[[ -n $BASH_VERSION ]] && " + command({ decision: "block", reason: "bash selected" }), + }) + expect(result.blocked?.reason).toBe("bash selected") + }), + ) + + it.instance("timeout ignores JSON even when a TERM handler exits successfully", () => + Effect.gen(function* () { + const result = yield* runHook({ + type: "command", + timeout: 0.15, + command: + "trap 'exit 0' TERM; " + + command({ decision: "block", reason: "expired" }).replace("; exit 0", "") + + "; while :; do sleep 1; done", + }) + expect(result.blocked).toBeUndefined() + }), + ) + + it.instance("caller interruption kills a command hook process", () => + Effect.gen(function* () { + const instance = yield* TestInstance + const pidFile = path.join(instance.directory, "hook.pid") + const fiber = yield* runHook({ type: "command", command: `echo $$ > ${quote(pidFile)}; exec sleep 30` }).pipe( + Effect.forkChild, + ) + const pid = yield* pollWithTimeout( + Effect.promise(async () => { + try { + return Number(await fs.readFile(pidFile, "utf8")) || undefined + } catch { + return undefined + } + }), + "hook never started", + ) + yield* Fiber.interrupt(fiber) + yield* pollWithTimeout( + Effect.sync(() => { + try { + process.kill(pid, 0) + return undefined + } catch { + return true + } + }), + "hook process survived caller cancellation", + ) + }), + ) + + it.instance("a once group stays eligible until a condition matches, then all commands run once", () => + Effect.gen(function* () { + const instance = yield* TestInstance + const store = yield* SessionHooks.Service + const settings = yield* SettingsHook.Service + const id = SessionID.descending() + const output = path.join(instance.directory, "group-once.txt") + yield* store.add(id, { + event: "PreToolUse", + once: true, + hooks: ["first", "second"].map((label) => ({ + type: "command", + if: "Bash(match*)", + command: `printf '${label}\\n' >> ${quote(output)}`, + })), + }) + const trigger = (value: string) => + settings.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: { command: value } }, + { sessionID: id, transcriptPath: "" }, + ) + yield* trigger("unmatched") + expect(yield* store.listAll(id)).toHaveLength(1) + yield* Effect.all([trigger("match1"), trigger("match2")], { concurrency: "unbounded" }) + expect((yield* Effect.promise(() => fs.readFile(output, "utf8"))).trim().split("\n")).toEqual(["first", "second"]) + expect(yield* store.listAll(id)).toHaveLength(0) + }), + ) + + for (const async of [false, true]) { + it.instance(`command once is atomic with async=${async}`, () => + Effect.gen(function* () { + const instance = yield* TestInstance + const store = yield* SessionHooks.Service + const settings = yield* SettingsHook.Service + const id = SessionID.descending() + const output = path.join(instance.directory, "command-once.txt") + yield* store.add(id, { + event: "PreToolUse", + hooks: [{ type: "command", once: true, async, command: `printf 'hit\\n' >> ${quote(output)}` }], + }) + const trigger = () => + settings.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + { sessionID: id, transcriptPath: "" }, + ) + yield* Effect.all([trigger(), trigger(), trigger()], { concurrency: "unbounded" }) + const content = yield* pollWithTimeout( + Effect.promise(() => fs.readFile(output, "utf8").catch(() => undefined)), + "once command did not finish", + ) + expect(content).toBe("hit\n") + yield* trigger() + expect(yield* Effect.promise(() => fs.readFile(output, "utf8"))).toBe("hit\n") + }), + ) + } + + it.instance("control: exit 0 accepts block output", () => + Effect.gen(function* () { + const result = yield* runHook({ type: "command", command: command({ decision: "block", reason: "audit-block" }) }) + expect(result.blocked?.reason).toBe("audit-block") + }), + ) + + it.instance("control: exit 2 blocks using stderr", () => + Effect.gen(function* () { + const result = yield* runHook({ type: "command", command: "printf '%s' 'audit-exit2' >&2; exit 2" }) + expect(result.blocked?.reason).toBe("audit-exit2") + }), + ) + + it.instance("exit 1 must not apply stdout control decisions", () => + Effect.gen(function* () { + const result = yield* runHook({ + type: "command", + command: command({ decision: "block", reason: "audit-invalid-exit1" }, 1), + }) + expect(result.blocked).toBeUndefined() + }), + ) + + it.instance("malformed hookSpecificOutput must not crash the trigger", () => + Effect.gen(function* () { + const exit = yield* runHook({ + type: "command", + command: command({ hookSpecificOutput: "bad-output-type" }), + }).pipe(Effect.exit) + expect(exit._tag).toBe("Success") + }), + ) + + it.instance("malformed matcher group must not crash the trigger", () => + Effect.gen(function* () { + const instance = yield* TestInstance + yield* Effect.promise(async () => { + await fs.mkdir(path.join(instance.directory, ".opencode"), { recursive: true }) + await fs.writeFile( + path.join(instance.directory, ".opencode/hooks.json"), + JSON.stringify({ PreToolUse: [{ matcher: "Bash" }] }), + ) + }) + const settings = yield* SettingsHook.Service + const exit = yield* settings + .trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + { sessionID: SessionID.descending(), transcriptPath: "" }, + ) + .pipe(Effect.exit) + expect(exit._tag).toBe("Success") + }), + ) + + it.instance("once hook must execute only once across concurrent triggers", () => + Effect.gen(function* () { + const instance = yield* TestInstance + const store = yield* SessionHooks.Service + const settings = yield* SettingsHook.Service + const id = SessionID.descending() + const output = path.join(instance.directory, "once-runs.txt") + yield* store.add(id, { + event: "PreToolUse", + once: true, + hooks: [{ type: "command", command: `sleep 0.1; printf 'hit\n' >> ${quote(output)}` }], + }) + yield* Effect.all( + [1, 2].map(() => + settings.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + { sessionID: id, transcriptPath: "" }, + ), + ), + { concurrency: "unbounded" }, + ) + const lines = (yield* Effect.promise(() => fs.readFile(output, "utf8"))).trim().split("\n") + expect(lines).toHaveLength(1) + }), + ) + + for (const candidate of [ + "find . -delete", + "sort /dev/null -o audit-output", + "git diff --output=audit-output", + "awk 'BEGIN {system(\"touch audit-output\")}'", + "echo safe\ntouch audit-output", + ]) { + test(`read-only agent whitelist must reject: ${candidate}`, () => { + const rejection = agentToolsTest.whitelistReject(candidate) + expect(rejection).not.toBeNull() + }) + } +}) diff --git a/packages/opencode/test/hook/tool-boundaries.test.ts b/packages/opencode/test/hook/tool-boundaries.test.ts new file mode 100644 index 000000000..f61718164 --- /dev/null +++ b/packages/opencode/test/hook/tool-boundaries.test.ts @@ -0,0 +1,236 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema, Cause } from "effect" +import fs from "node:fs/promises" +import path from "node:path" +import { SessionTools } from "@/session/tools" +import { SessionHooks } from "@/hook/session-hooks" +import { SettingsHook } from "@/hook/settings" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Database } from "@opencode-ai/core/database/database" +import { ToolRegistry } from "@/tool/registry" +import { Plugin } from "@/plugin" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" +import { Truncate } from "@/tool/truncate" +import { SessionID, MessageID } from "@/session/schema" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { ProviderTest } from "../fake/provider" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { buildAgentTools } from "@/hook/agent-tools" + +const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'" +const jsonCommand = (json: unknown) => "printf '%s' " + quote(JSON.stringify(json)) +const hookLayer = SettingsHook.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provideMerge(SessionHooks.defaultLayer), +) +const writeDefinition = { + id: "write", + description: "Fixture leaf writer with the real write tool parameter shape", + parameters: Schema.Struct({ filePath: Schema.String, content: Schema.String }), + execute: (args: { filePath: string; content: string }) => + Effect.promise(async () => { + await fs.writeFile(args.filePath, args.content) + return { title: "written", output: "WRITE_COMPLETE", metadata: {} } + }), +} +const layers = Layer.mergeAll( + hookLayer, + Layer.mock(Plugin.Service, { trigger: (_name, _input, output) => Effect.succeed(output) }), + Layer.mock(Permission.Service, { ask: () => Effect.void }), + Layer.mock(MCP.Service, { clients: () => Effect.succeed({}), tools: () => Effect.succeed({}) }), + Layer.mock(Truncate.Service, {}), + Layer.mock(ToolRegistry.Service, { tools: () => Effect.succeed([writeDefinition]) }), +) +const it = testEffect(layers) +const resolve = (id: any, dir: string) => + SessionTools.resolve({ + agent: { name: "build", permission: [], options: {} } as any, + model: ProviderTest.model(), + session: { id, directory: dir, permission: [] } as any, + processor: { + message: { id: MessageID.ascending(), sessionID: id }, + updateToolCall: () => Effect.succeed(undefined), + completeToolCall: () => Effect.void, + } as any, + bypassAgentCheck: false, + messages: [], + promptOps: {} as any, + }) +const execute = (tools: any, filePath: string) => + Effect.promise(() => + tools.write.execute( + { filePath, content: "audit-safe-fixture" }, + { toolCallId: "audit-write", messages: [], abortSignal: new AbortController().signal }, + ), + ) + +describe("real SessionTools call path", () => { + it.instance("FileChanged envelope must contain native filePath", () => + Effect.gen(function* () { + const instance = yield* TestInstance + const store = yield* SessionHooks.Service + const id = SessionID.descending() + const capture = path.join(instance.directory, "filechanged.json") + const target = path.join(instance.directory, "changed.txt") + yield* store.add(id, { event: "FileChanged", hooks: [{ type: "command", command: "cat > " + quote(capture) }] }) + const tools = yield* resolve(id, instance.directory) + yield* execute(tools, target) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("audit-safe-fixture") + const envelope = JSON.parse(yield* Effect.promise(() => fs.readFile(capture, "utf8"))) + expect(envelope.path).toBe(target) + }), + ) + + it.instance("PostToolUse block reason must be visible to the model", () => + Effect.gen(function* () { + const instance = yield* TestInstance + const store = yield* SessionHooks.Service + const id = SessionID.descending() + yield* store.add(id, { + event: "PostToolUse", + hooks: [{ type: "command", command: jsonCommand({ decision: "block", reason: "AUDIT_POST_REJECT" }) }], + }) + const tools = yield* resolve(id, instance.directory) + const result = yield* execute(tools, path.join(instance.directory, "changed.txt")) + expect(JSON.stringify(result)).toContain("AUDIT_POST_REJECT") + }), + ) + + it.instance("malformed hook output must not fail the tool execution", () => + Effect.gen(function* () { + const instance = yield* TestInstance + const store = yield* SessionHooks.Service + const id = SessionID.descending() + yield* store.add(id, { + event: "PreToolUse", + hooks: [{ type: "command", command: jsonCommand({ hookSpecificOutput: "broken" }) }], + }) + const tools = yield* resolve(id, instance.directory) + const result = yield* execute(tools, path.join(instance.directory, "changed.txt")).pipe(Effect.exit) + expect(result._tag).toBe("Success") + }), + ) +}) + +const agent = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer)) +describe("agent tool runtime boundary", () => { + agent.instance("read-only agent bash must not create a file using a newline", () => + Effect.gen(function* () { + const instance = yield* TestInstance + const spawner = yield* ChildProcessSpawner + const filesystem = yield* FSUtil.Service + const tools = buildAgentTools({ + spawner, + fs: filesystem, + cwd: instance.directory, + signal: new AbortController().signal, + captured: { value: null }, + }) + const marker = path.join(instance.directory, "unauthorized-marker") + const result = yield* Effect.promise(() => + Promise.resolve( + tools.bash.execute!({ command: "echo harmless\ntouch " + quote(marker) }, { + toolCallId: "audit", + messages: [], + } as any), + ), + ) + const exists = yield* Effect.promise(() => + fs.access(marker).then( + () => true, + () => false, + ), + ) + expect(JSON.stringify(result)).toContain("Error:") + expect(exists).toBe(false) + }), + ) + + agent.instance("agent bash must stop after its abort signal", () => + Effect.gen(function* () { + const instance = yield* TestInstance + const spawner = yield* ChildProcessSpawner + const filesystem = yield* FSUtil.Service + const ac = new AbortController() + const input = path.join(instance.directory, "follow.txt") + yield* Effect.promise(() => fs.writeFile(input, "ready\n")) + let pid: number | undefined + let ready!: () => void + const started = new Promise<void>((resolve) => { + ready = resolve + }) + const observed = { + ...spawner, + spawn: (...args: Parameters<typeof spawner.spawn>) => + spawner.spawn(...args).pipe( + Effect.tap((handle) => + Effect.sync(() => { + pid = Number(handle.pid) + ready() + }), + ), + ), + } + const tools = buildAgentTools({ + spawner: observed, + fs: filesystem, + cwd: instance.directory, + signal: ac.signal, + captured: { value: null }, + }) + yield* Effect.addFinalizer(() => Effect.sync(() => ac.abort())) + const task = Promise.resolve( + tools.bash.execute!( + { command: "tail -f " + quote(input) }, + { toolCallId: "cancel", messages: [], abortSignal: ac.signal }, + ), + ) + yield* Effect.promise(() => started).pipe(Effect.timeout(2000)) + ac.abort() + const result = yield* Effect.promise(() => task).pipe(Effect.timeout(3000)) + expect(JSON.stringify(result)).toContain("Error:") + expect(pid).toBeDefined() + expect(() => process.kill(pid!, 0)).toThrow() + }), + ) +}) + +const permission = testEffect( + Permission.layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provideMerge(hookLayer)), +) +describe("permission hook call path", () => { + for (const variant of ["deny", "exit2"] as const) { + permission.instance(`PermissionRequest ${variant} should resolve rejection without user input`, () => + Effect.gen(function* () { + const store = yield* SessionHooks.Service + const permissions = yield* Permission.Service + const id = SessionID.descending() + const command = + variant === "deny" + ? jsonCommand({ hookSpecificOutput: { hookEventName: "PermissionRequest", permissionDecision: "deny" } }) + : "printf '%s' 'AUDIT_PERMISSION_BLOCK' >&2; exit 2" + yield* store.add(id, { event: "PermissionRequest", hooks: [{ type: "command", command }] }) + const exit = yield* permissions + .ask({ + sessionID: id, + permission: "bash", + patterns: ["echo test"], + always: [], + metadata: {}, + ruleset: [], + }) + .pipe(Effect.timeout(150), Effect.exit) + const error = exit._tag === "Failure" ? Cause.pretty(exit.cause) : "success" + expect(error).not.toContain("Timeout") + expect(exit._tag).toBe("Failure") + expect(yield* permissions.list()).toHaveLength(0) + if (variant === "exit2") expect(error).toContain("AUDIT_PERMISSION_BLOCK") + }), + ) + } +}) diff --git a/packages/opencode/test/hook/warn-unsupported.test.ts b/packages/opencode/test/hook/warn-unsupported.test.ts index 23ea58787..360ef7852 100644 --- a/packages/opencode/test/hook/warn-unsupported.test.ts +++ b/packages/opencode/test/hook/warn-unsupported.test.ts @@ -1,11 +1,6 @@ import { describe, expect, test } from "bun:test" import { detectUnsupportedFields, type Settings } from "@/hook/settings" -// hooks-api-fidelity: async / asyncRewake / `if` are all fully implemented -// (hook-async-execution + condition-filter) and MUST NOT be flagged as -// unsupported. Only `shell` remains a runtime placeholder and MUST still be -// flagged so users know it is inert. - const hooks = (hook: Record<string, unknown>): Settings["hooks"] => ({ SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "true", ...hook }] }], }) @@ -21,17 +16,16 @@ describe("detectUnsupportedFields", () => { expect(unsupported).toEqual([]) }) - test("shell is still flagged (placeholder)", () => { + test("shell is supported by command hooks", () => { const unsupported = detectUnsupportedFields(hooks({ shell: "powershell" })) - expect(unsupported).toHaveLength(1) - expect(unsupported[0]).toMatchObject({ field: "shell", value: "powershell", eventName: "SessionStart" }) + expect(unsupported).toEqual([]) }) - test("only shell is flagged when if+shell+async all present", () => { + test("command options compose without unsupported-field warnings", () => { const unsupported = detectUnsupportedFields( hooks({ if: "Edit(*.ts)", shell: "bash", async: true, asyncRewake: true }), ) - expect(unsupported.map((u) => u.field).sort()).toEqual(["shell"]) + expect(unsupported).toEqual([]) }) test("undefined / empty hooks yield no flags", () => { @@ -39,15 +33,16 @@ describe("detectUnsupportedFields", () => { expect(detectUnsupportedFields({})).toEqual([]) }) - // GOAL-FP/issue #286: HookCommand fields accepted by the schema but dropped - // by every executor must be surfaced, not silently swallowed. `timeout` for - // type "prompt" is implemented (excluded here); allowedEnvVars/statusMessage - // have zero consumers anywhere, and per-command `once` is never read (only - // the entry-level _sessionEntry?.once is consumed). - test("allowedEnvVars / statusMessage / per-command once are flagged (dropped by executors)", () => { - const unsupported = detectUnsupportedFields( - hooks({ allowedEnvVars: ["FOO"], statusMessage: "hi", once: true }), - ) - expect(unsupported.map((u) => u.field).sort()).toEqual(["allowedEnvVars", "once", "statusMessage"]) + test("allowedEnvVars is restricted to HTTP; statusMessage and once are supported", () => { + const unsupported = detectUnsupportedFields(hooks({ allowedEnvVars: ["FOO"], statusMessage: "hi", once: true })) + expect(unsupported.map((u) => u.field).sort()).toEqual(["allowedEnvVars"]) + }) + + test("HTTP accepts environment interpolation and diagnoses an irrelevant shell", () => { + expect( + detectUnsupportedFields( + hooks({ type: "http", url: "http://localhost", allowedEnvVars: ["TEST"], shell: "bash" }), + ), + ).toEqual([{ field: "shell", value: "bash", eventName: "SessionStart" }]) }) }) diff --git a/packages/opencode/test/server/session-hooks-api.test.ts b/packages/opencode/test/server/session-hooks-api.test.ts index 9aaf012f1..c54918c32 100644 --- a/packages/opencode/test/server/session-hooks-api.test.ts +++ b/packages/opencode/test/server/session-hooks-api.test.ts @@ -18,6 +18,33 @@ function addHook(directory: string, sessionID: string, hook: Record<string, unkn } describe("session hook add validation", () => { + it.instance( + "rejects missing handler descriptors and round-trips supported command fields", + () => + Effect.gen(function* () { + const instance = yield* TestInstance + const session = yield* Session.use.create({}) + for (const type of ["mcp", "http", "prompt", "agent"]) { + const response = yield* addHook(instance.directory, session.id, { type }) + expect(response.status).toBe(400) + } + const input = { + type: "command", + command: "true", + shell: "bash", + once: true, + statusMessage: "checking", + options: { mode: "check" }, + } + const added = yield* addHook(instance.directory, session.id, input) + expect(added.status).toBe(200) + const listed = yield* requestInDirectory(`/session/${session.id}/hook`, instance.directory) + expect(listed.status).toBe(200) + expect(yield* listed.json).toMatchObject([{ hooks: [input] }]) + }), + { git: true }, + ) + it.instance( "rejects command-type hooks with a missing or blank command", () => diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f376c2222..f07f68686 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3461,6 +3461,13 @@ export class Hook extends HeyApiClient { if?: string async?: boolean asyncRewake?: boolean + shell?: "bash" | "powershell" + allowedEnvVars?: Array<string> + statusMessage?: string + once?: boolean + options?: { + [key: string]: unknown + } }> once?: boolean }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 10d0a3a90..c18e600a2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -10125,6 +10125,13 @@ export type SessionHookListResponses = { if?: string async?: boolean asyncRewake?: boolean + shell?: "bash" | "powershell" + allowedEnvVars?: Array<string> + statusMessage?: string + once?: boolean + options?: { + [key: string]: unknown + } }> once?: boolean }> @@ -10174,6 +10181,13 @@ export type SessionHookAddData = { if?: string async?: boolean asyncRewake?: boolean + shell?: "bash" | "powershell" + allowedEnvVars?: Array<string> + statusMessage?: string + once?: boolean + options?: { + [key: string]: unknown + } }> once?: boolean } From 98a558829873174af53c8c0dd324872daae9156b Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 12:18:30 +0800 Subject: [PATCH 11/25] chore: record delivery binding for hooks-runtime-reliability --- .specgit.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index ce102a001..2995f8957 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 delivery: hooks-runtime-reliability context: - kind: worktree - label: opencode-hooks-fix + kind: branch branch: fix/557-hooks-runtime-reliability issues: - 557 From b56d931b28dfa0d2a97ff3f151d1ece2bac61d59 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 12:29:08 +0800 Subject: [PATCH 12/25] fix(hooks): deliver feedback for tool defects and rejected MCP calls --- packages/opencode/src/session/tools.ts | 21 ++++++--- .../test/hook/prompt-admission.test.ts | 39 +++++++++++++++ .../test/hook/tool-boundaries.test.ts | 47 +++++++++++++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index ed2f1d640..3e31a5474 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -18,7 +18,7 @@ import { withHookFeedback, withHookFailure } from "@/hook/trigger-result" import { toolFileChanges } from "@/hook/file-changes" import { applyPreHookDecision, classifyPermissionAsk } from "@/hook/pre-hook-decision" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" -import { Effect } from "effect" +import { Cause, Effect } from "effect" import * as Option from "effect/Option" import { Session } from "./session" import { SessionProcessor } from "./processor" @@ -260,8 +260,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } return output }).pipe( - Effect.catch((error: unknown) => + Effect.catchCause((cause) => Effect.gen(function* () { + if (Cause.hasInterrupts(cause)) return yield* Effect.failCause(cause) + const error = Cause.squash(cause) // SettingsHook PostToolUseFailure if (settingsHook) { const failResult = yield* settingsHook @@ -281,9 +283,11 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { ), ) yield* SettingsHook.landSystemMessages(failResult, { sessionID: input.session.id }) - error = withHookFailure(error, failResult) + const failure = withHookFailure(error, failResult) + if (failure !== error) + return yield* Cause.hasDies(cause) ? Effect.die(failure) : Effect.fail(failure) } - return yield* Effect.fail(error) + return yield* Effect.failCause(cause) }), ), ), @@ -719,8 +723,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } return output }).pipe( - Effect.catch((error: unknown) => + Effect.catchCause((cause) => Effect.gen(function* () { + if (Cause.hasInterrupts(cause)) return yield* Effect.failCause(cause) + const error = Cause.squash(cause) if (settingsHook) { const failResult = yield* settingsHook .trigger( @@ -737,9 +743,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { Effect.catch(() => Effect.succeed<TriggerResult>({ additionalContexts: [], systemMessages: [] })), ) yield* SettingsHook.landSystemMessages(failResult, { sessionID: input.session.id }) - error = withHookFailure(error, failResult) + const failure = withHookFailure(error, failResult) + if (failure !== error) return yield* Cause.hasDies(cause) ? Effect.die(failure) : Effect.fail(failure) } - return yield* Effect.fail(error) + return yield* Effect.failCause(cause) }), ), ), diff --git a/packages/opencode/test/hook/prompt-admission.test.ts b/packages/opencode/test/hook/prompt-admission.test.ts index a0ff22fce..7dd470806 100644 --- a/packages/opencode/test/hook/prompt-admission.test.ts +++ b/packages/opencode/test/hook/prompt-admission.test.ts @@ -306,6 +306,45 @@ it.live("session cancellation aborts a post-tool hook and preserves the complete ), ) +it.live("native tool failures run failure hooks and preserve their feedback", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm, dir }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const hooks = yield* SessionHooks.Service + const session = yield* sessions.create({ + title: "Native failure hook", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* hooks.add(session.id, { + event: "PostToolUseFailure", + hooks: [ + { + type: "command", + command: `printf '%s' '{"hookSpecificOutput":{"additionalContext":"failure-feedback: retry an existing file"}}'`, + }, + ], + }) + yield* llm.tool("read", { filePath: path.join(dir, "missing.txt") }) + yield* llm.text("I will retry a file that exists.") + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "read the missing file" }], + }) + const parts = (yield* sessions.messages({ sessionID: session.id })).flatMap((message) => message.parts) + const failed = parts.find((part) => part.type === "tool" && part.tool === "read") + expect(failed?.type).toBe("tool") + if (failed?.type !== "tool") return + expect(failed?.state.status).toBe("error") + if (failed?.state.status !== "error") return + expect(failed.state.error).toContain("failure-feedback") + expect(JSON.stringify(yield* llm.inputs)).toContain("failure-feedback") + }), + { git: true, config: providerCfg }, + ), +) + it.live("native write, edit and multi-file patch emit actual FileChanged paths", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm, dir }) { diff --git a/packages/opencode/test/hook/tool-boundaries.test.ts b/packages/opencode/test/hook/tool-boundaries.test.ts index f61718164..385655b57 100644 --- a/packages/opencode/test/hook/tool-boundaries.test.ts +++ b/packages/opencode/test/hook/tool-boundaries.test.ts @@ -20,6 +20,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { buildAgentTools } from "@/hook/agent-tools" +import { jsonSchema, tool } from "ai" const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'" const jsonCommand = (json: unknown) => "printf '%s' " + quote(JSON.stringify(json)) @@ -115,6 +116,52 @@ describe("real SessionTools call path", () => { expect(result._tag).toBe("Success") }), ) + + it.instance("rejected MCP calls run failure hooks and keep feedback", () => + Effect.gen(function* () { + const instance = yield* TestInstance + const store = yield* SessionHooks.Service + const mcp = yield* MCP.Service + const id = SessionID.descending() + yield* store.add(id, { + event: "PostToolUseFailure", + hooks: [{ type: "command", command: jsonCommand({ decision: "block", reason: "MCP_FAILURE_FEEDBACK" }) }], + }) + const tools = yield* resolve(id, instance.directory).pipe( + Effect.provideService(MCP.Service, { + ...mcp, + tools: () => + Effect.succeed({ + audit_failure: tool({ + inputSchema: jsonSchema({ type: "object", properties: {} }), + execute: async (): Promise<{ content: { type: "text"; text: string }[] }> => { + throw new Error("MCP transport rejected") + }, + }), + }), + }), + ) + const result = yield* Effect.tryPromise({ + try: () => + Promise.resolve( + tools.audit_failure.execute!( + {}, + { + toolCallId: "audit-failure", + messages: [], + abortSignal: new AbortController().signal, + }, + ), + ), + catch: (error) => error, + }).pipe(Effect.exit) + expect(result._tag).toBe("Failure") + if (result._tag !== "Failure") return + const failure = String(Cause.squash(result.cause)) + expect(failure).toContain("MCP transport rejected") + expect(failure).toContain("MCP_FAILURE_FEEDBACK") + }), + ) }) const agent = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer)) From 949ff5fda9be4b7ce364abd84defc6cd286633af Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 13:30:12 +0800 Subject: [PATCH 13/25] chore: record delivery binding for goal-reliability --- .specgit.yaml | 43 ++++++++++++++----------------------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 2995f8957..34303558d 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,38 +1,23 @@ version: 1 -delivery: hooks-runtime-reliability +delivery: goal-reliability context: - kind: branch - branch: fix/557-hooks-runtime-reliability + kind: worktree + label: opencode-goal-fixes + branch: fix/568-goal-reliability issues: - - 557 - - 558 - - 559 - - 560 - - 561 - - 562 - - 563 - - 564 - - 565 - - 566 + - 568 + - 569 + - 570 + - 571 + - 572 issueKinds: - - issue: 557 + - issue: 568 kind: kind::fix - - issue: 558 + - issue: 569 kind: kind::fix - - issue: 559 + - issue: 570 kind: kind::fix - - issue: 560 + - issue: 571 kind: kind::fix - - issue: 561 + - issue: 572 kind: kind::fix - - issue: 562 - kind: kind::fix - - issue: 563 - kind: kind::fix - - issue: 564 - kind: kind::fix - - issue: 565 - kind: kind::fix - - issue: 566 - kind: kind::fix -pr: 567 From 0aa501bbf79f9a34b70c4b71636c8afa847c96cc Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 13:30:54 +0800 Subject: [PATCH 14/25] chore: record delivery binding for goal-reliability --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 34303558d..c74010694 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -21,3 +21,4 @@ issueKinds: kind: kind::fix - issue: 572 kind: kind::fix +pr: 573 From f0e51eb5395e1d4db0d55d55922ce809ef8b2797 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 14:10:15 +0800 Subject: [PATCH 15/25] chore: record delivery binding for goal-reliability --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index c74010694..989599f26 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -10,6 +10,7 @@ issues: - 570 - 571 - 572 + - 574 issueKinds: - issue: 568 kind: kind::fix From 80689e3272165901e2aa84cf27774e965fa039df Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 14:21:47 +0800 Subject: [PATCH 16/25] chore: record delivery binding for goal-reliability --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 989599f26..5a11e2382 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -11,6 +11,7 @@ issues: - 571 - 572 - 574 + - 575 issueKinds: - issue: 568 kind: kind::fix From 9a651339b1df92e0739a9653e7f08f4c807f7a58 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 14:22:51 +0800 Subject: [PATCH 17/25] fix(goal): preserve execution boundaries and production wiring --- README.md | 1 + README.zh.md | 1 + packages/opencode/src/command/index.ts | 2 +- packages/opencode/src/effect/runner.ts | 15 +- packages/opencode/src/goal/goal.ts | 36 ++- packages/opencode/src/goal/loop.ts | 174 ++++++------ packages/opencode/src/session/message-v2.ts | 12 + packages/opencode/src/session/prompt.ts | 41 ++- packages/opencode/src/session/prompt/goal.txt | 4 +- packages/opencode/src/session/run-state.ts | 36 ++- packages/opencode/src/tool/question.ts | 12 +- .../test/dag/dag-goal-wake-retrigger.test.ts | 26 +- packages/opencode/test/effect/runner.test.ts | 15 + .../test/goal/bootstrap-wiring.test.ts | 55 ++++ packages/opencode/test/goal/e2e-loop.test.ts | 114 ++------ packages/opencode/test/goal/goal.test.ts | 48 ++++ packages/opencode/test/lib/session-prompt.ts | 8 + .../test/server/httpapi-v2-pty.test.ts | 6 +- packages/opencode/test/session/prompt.test.ts | 256 +++++++++++++++++- packages/opencode/test/tool/question.test.ts | 17 ++ packages/tui/src/context/sync.tsx | 6 +- .../tui/test/cli/cmd/tui/sync-goal.test.tsx | 43 +++ 22 files changed, 708 insertions(+), 220 deletions(-) diff --git a/README.md b/README.md index 5dba3b738..5935c94fc 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ its single-session complement: one durable goal that the agent works toward autonomously across turns of the current session. - Commands: `/goal <text>` sets a goal and starts the loop; `/goal status|pause|resume|done|clear|stop` controls it; `/subgoal <text>|list|remove <n>|clear` manages subgoals attached to the active goal. +- Budget: `/goal --max-turns 30 <text>` sets a positive integer total budget (default 20). `/goal resume --max-turns 50` changes the total budget without resetting used turns; the new total must exceed used turns. Plain `/goal resume` retains the budget and permits one more execution when exhausted. - Judge loop: after each turn an external judge evaluates progress — `done` clears the goal, `continue` injects the next continuation turn against a configurable turn budget (budget exhaustion pauses the goal; it stays resumable). The agent can self-declare completion with the `goal(action: "complete")` tool, which bypasses the judge; `goal(action: "status")` inspects state. - Visibility: while a goal is active or paused, the system prompt carries a live goal block (text, status, turns used/remaining, subgoals, last judge verdict); the TUI sidebar shows a compact goal widget; `GET /session/:sessionID/goal` exposes the state (`404` when no goal is set). - Durability: goal state is persisted per session (`goal_state`), survives restarts, and is cleared automatically when the session is deleted. diff --git a/README.zh.md b/README.zh.md index 5e2e0482f..d41422a9e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -228,6 +228,7 @@ DAG 相关的东西都放在 `.opencode/` 下,在 opencode 配置目录(`OPE 图编排把任务拆给多个子会话;目标循环是它的单会话互补形态:一个持久目标,智能体在当前会话里跨回合自主推进。 - **命令**:`/goal <文本>` 设定目标并启动循环;`/goal status|pause|resume|done|clear|stop` 控制;`/subgoal <文本>|list|remove <n>|clear` 管理挂在当前目标下的子目标。 +- **轮次预算**:`/goal --max-turns 30 <文本>` 指定正整数总轮数(默认 20)。`/goal resume --max-turns 50` 恢复并调整总轮数,保留已用轮数,新总数必须大于已用轮数。不带参数的 `/goal resume` 保留预算,耗尽后仍可再执行一轮。 - **评审循环**:每回合结束后由外部评审判定进展——`done` 清除目标,`continue` 注入下一轮续跑提示,受可配置的回合预算约束(预算耗尽转暂停,可随时恢复)。智能体也可以用 `goal(action: "complete")` 工具自我宣告完成(绕过评审);`goal(action: "status")` 查询状态。 - **可见性**:目标激活或暂停期间,系统提示里带实时目标块(目标文本、状态、已用/总回合、子目标、最近一次评审判定);TUI 侧边栏有简洁的目标组件;`GET /session/:sessionID/goal` 暴露状态(未设目标时返回 `404`)。 - **持久化**:目标状态按会话持久化(`goal_state`),重启不丢,会话删除时自动清除。 diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 4e4b50e02..bc109df45 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -96,7 +96,7 @@ export const layer = Layer.effect( } commands[Default.GOAL] = { name: Default.GOAL, - description: "设定持久目标,自动循环执行直到完成 [status|pause|resume|done|clear|stop]", + description: "设定持久目标 [--max-turns N] <目标>;控制 [status|pause|resume [--max-turns N]|done|clear|stop]", source: "command", template: "", hints: ["$ARGUMENTS"], diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 3caf56cff..b242a4c98 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -6,6 +6,10 @@ export interface Runner<A, E = never> { readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E> readonly ensureRunningHandle: (work: Effect.Effect<A, E>) => Effect.Effect<Effect.Effect<A, E>> readonly startIfIdle: (work: Effect.Effect<A, E>) => Effect.Effect<Option.Option<Effect.Effect<A, E>>> + readonly startShellHandle: ( + work: Effect.Effect<A, E>, + ready?: Latch.Latch, + ) => Effect.Effect<Effect.Effect<A, E | Busy>> readonly startShell: (work: Effect.Effect<A, E>, ready?: Latch.Latch) => Effect.Effect<A, E | Busy> readonly cancel: Effect.Effect<void> } @@ -154,7 +158,10 @@ export const make = <A, E = never>( }), ) - const startShell = (work: Effect.Effect<A, E>, ready?: Latch.Latch): Effect.Effect<A, E | Busy> => + const startShellHandle = ( + work: Effect.Effect<A, E>, + ready?: Latch.Latch, + ): Effect.Effect<Effect.Effect<A, E | Busy>> => SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { @@ -183,7 +190,10 @@ export const make = <A, E = never>( { _tag: "Shell", shell }, ] as const }), - ).pipe(Effect.flatten) + ) + + const startShell = (work: Effect.Effect<A, E>, ready?: Latch.Latch) => + startShellHandle(work, ready).pipe(Effect.flatten) const cancel = SynchronizedRef.modify(ref, (st) => { switch (st._tag) { @@ -229,6 +239,7 @@ export const make = <A, E = never>( ensureRunningHandle, startIfIdle, startShell, + startShellHandle, cancel, } } diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index ea46ddf37..d8f198490 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -56,7 +56,7 @@ export interface Interface { readonly lastOutcome: (sessionID: SessionID) => Effect.Effect<GoalState.Info | undefined> readonly set: (sessionID: SessionID, goal: string, maxTurns?: number) => Effect.Effect<GoalState.Info> readonly pause: (sessionID: SessionID, reason: string) => Effect.Effect<GoalState.Info | undefined> - readonly resume: (sessionID: SessionID) => Effect.Effect<GoalState.Info | undefined> + readonly resume: (sessionID: SessionID, maxTurns?: number) => Effect.Effect<GoalState.Info | undefined> readonly clear: (sessionID: SessionID) => Effect.Effect<void> /** Session-deletion cleanup: remove goal_state AND all goal_outcome rows. */ readonly purgeSession: (sessionID: SessionID) => Effect.Effect<void> @@ -596,11 +596,14 @@ const serviceLayer = Layer.effect( }) }) - const resume = Effect.fn("Goal.resume")(function* (sessionID: SessionID) { + const resume = Effect.fn("Goal.resume")(function* (sessionID: SessionID, maxTurns?: number) { const updated = yield* transition(sessionID, (state) => { if (!state || state.status !== "paused") return { tag: "noop", value: undefined } + if (maxTurns !== undefined && (!Number.isSafeInteger(maxTurns) || maxTurns <= state.turns_used)) + return { tag: "noop", value: undefined } const updated = GoalState.advance(state, { status: "active", + max_turns: GoalState.nni(maxTurns ?? state.max_turns), consecutive_parse_failures: GoalState.nni(0), paused_reason: undefined, last_turn_at: Date.now(), @@ -814,7 +817,22 @@ const serviceLayer = Layer.effect( const dispatch = Effect.fn("Goal.dispatch")(function* (sessionID: SessionID, args: string) { const trimmed = args.trim() - const lower = trimmed.toLowerCase() + const budget = /^(?:(resume)\s+)?--max-turns(?:\s+(\S+))?(?:\s+([\s\S]*))?$/i.exec(trimmed) + const maxTurns = budget ? Number(budget[2]) : undefined + const invalidBudget = + budget && + (!/^\d+$/.test(budget[2] ?? "") || + !Number.isSafeInteger(maxTurns) || + (maxTurns ?? 0) < 1 || + (budget[1] ? !!budget[3] : !budget[3]?.trim())) + if (invalidBudget || (!budget && /^(?:resume\s+)?--/i.test(trimmed))) { + return { + type: "message" as const, + text: "轮预算必须是正整数。用法:/goal --max-turns <N> <目标> 或 /goal resume --max-turns <N>。", + } + } + const text = budget && !budget[1] ? (budget[3] ?? "").trim() : trimmed + const lower = budget ? (budget[1] ? "resume" : undefined) : trimmed.toLowerCase() const isControlCommand = lower === "" || @@ -867,7 +885,12 @@ const serviceLayer = Layer.effect( text: "Session 正在执行中。请先 /stop 中断后再 /goal resume。", } } - const result = yield* resume(sessionID) + if (maxTurns !== undefined) { + const current = yield* loadState(sessionID) + if (current?.status === "paused" && maxTurns <= current.turns_used) + return { type: "message" as const, text: `总轮预算必须大于已用的 ${current.turns_used} 轮。` } + } + const result = yield* resume(sessionID, maxTurns) if (!result) return { type: "message" as const, text: "没有已暂停的目标可以恢复。" } // Warning UX for budget-exhaustion pauses: we kept turns_used intact // (see resume()), so a goal paused because turns >= max will resume @@ -876,7 +899,7 @@ const serviceLayer = Layer.effect( // text a second later, which looks like resume didn't work. const announceMsg = result.turns_used >= result.max_turns - ? `⚠ 目标已恢复,但轮预算已耗尽(${result.turns_used}/${result.max_turns} 轮)。resume 会重启一整轮执行:本轮内任务完成才会计为达成,否则 judge 会再次暂停。建议 /goal clear 后用更大的 maxTurns 重新设定。` + ? `⚠ 目标已恢复,但轮预算已耗尽(${result.turns_used}/${result.max_turns} 轮)。resume 会重启一整轮执行:本轮内任务完成才会计为达成,否则 judge 会再次暂停。再次暂停后可用 /goal resume --max-turns ${result.turns_used + GoalPrompts.DEFAULT_MAX_TURNS} 增加总轮预算。` : undefined return { type: "kick" as const, @@ -915,8 +938,7 @@ const serviceLayer = Layer.effect( // done row leftover (loop.ts usually auto-clears; defensive guard) yield* clear(sessionID) } - const maxTurns = GoalPrompts.DEFAULT_MAX_TURNS - const state = yield* set(sessionID, trimmed, maxTurns) + const state = yield* set(sessionID, text, maxTurns ?? GoalPrompts.DEFAULT_MAX_TURNS) return { type: "kick" as const, text: state.goal, diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index fc8b8c704..cb9b68ef7 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -7,6 +7,7 @@ import { InstanceState } from "@/effect/instance-state" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionStatus } from "@/session/status" import { Session } from "@/session/session" +import { MessageV2 } from "@/session/message-v2" import { SessionPrompt } from "@/session/prompt" import { Provider } from "@/provider/provider" import { Goal } from "./goal" @@ -59,11 +60,15 @@ export class GoalLoopJudgeLLM extends Context.Service<GoalLoopJudgeLLM, GoalLoop * Operates on MessageV2 shape (`info.time.created`). */ export function shouldPreempt( - msgs: ReadonlyArray<{ info: { role: "user" | "assistant"; time: { created: number } } }>, + msgs: ReadonlyArray<{ + info: { role: "user" | "assistant"; time: { created: number } } + parts?: ReadonlyArray<{ type: string; ignored?: boolean }> + }>, ): boolean { let lastUserAt = -1 let lastAsstAt = -1 for (const m of msgs) { + if (MessageV2.isIgnoredUser(m)) continue const t = m.info.time?.created if (typeof t !== "number") continue if (m.info.role === "user" && t > lastUserAt) lastUserAt = t @@ -118,6 +123,16 @@ const serviceLayer = Layer.effect( const status = yield* SessionStatus.Service const automation = yield* SessionAutomationLease.Service + const workMessages = Effect.fn("GoalLoop.workMessages")(function* (sessionID: SessionID, limit = 20) { + let count = limit + for (;;) { + const messages = yield* sessions.messages({ sessionID, limit: count }) + const work = messages.filter((message) => !MessageV2.isIgnoredUser(message)) + if (work.length >= limit || messages.length < count) return work.slice(-limit) + count *= 2 + } + }) + const pauseGoal = Effect.fnUntraced(function* (sessionID: SessionID, reason: string) { const paused = yield* goal.pauseAndPublish(sessionID, reason) if (paused) @@ -240,6 +255,7 @@ const serviceLayer = Layer.effect( // at the same terminal points where afterIdle unregisters the goal // automation. const evaluatedRevisions = new Map<SessionID, number>() + const inFlight = new Map<SessionID, string>() const afterIdle = Effect.fn("GoalLoop.afterIdle")(function* (sessionID: SessionID, scanResume?: boolean) { // GOAL-TURN-SCOPE: the goal-driven turn that produced this idle has @@ -257,37 +273,17 @@ const serviceLayer = Layer.effect( // newer revision). If this process already evaluated the CURRENT // revision, the scan trigger is stale — skip. if (scanResume && evaluatedRevisions.get(sessionID) === (goalState.revision ?? 0)) return - // issue #285 — durable boundary gate (scan path only). The - // evaluatedRevisions map above is process-local and dies with the - // process; the goal row's last_judged_msg is the crash-surviving record - // of which boundary was already judged and committed. While the session - // window still ends on that same message, no new progress has landed — - // re-judging would inflate turns_used. Live idle events are never gated - // here: every dispatched continuation produces a fresh assistant - // message, so the live path always judges a new boundary. - // - // GOAL-01: the gate suppresses RE-JUDGMENT, never the drive. The old - // behavior `return`ed here, which permanently stranded goals whose - // committed continue evaluation lost its continuation to a crash - // (process died after the commit, before the next assistant message): - // every boot scan re-hit this gate, nothing ever dispatched another - // turn, and last_judged_msg (only written by judge commits) never - // advanced. Now the gate sets suppressJudge and falls through — the - // judge call and its updateAfterJudge commit below are skipped (the - // boundary is already judged; re-judging is what would inflate - // turns_used), but the shared continuation dispatch still runs and - // restores the driver. A second crash repeats this safely: a fresh - // process starts with an empty evaluatedRevisions map and an unchanged - // last_judged_msg, so the gate fires and re-dispatches again. + // A committed work boundary survives restart and control-command idle events. + // Reuse its verdict without charging another turn, but restore a continuation + // lost to a crash or invalidated by a subgoal edit. let suppressJudge = false - if (scanResume && goalState.last_judged_msg) { - const win = yield* sessions - .messages({ sessionID, limit: 20 }) - .pipe( - Effect.catchIf((e) => NotFoundError.isInstance(e), () => - Effect.succeed([] as SessionV1.WithParts[]), - ), - ) + if (goalState.last_judged_msg) { + const win = yield* workMessages(sessionID).pipe( + Effect.catchIf( + (e) => NotFoundError.isInstance(e), + () => Effect.succeed([] as SessionV1.WithParts[]), + ), + ) const lastSeen = [...win].reverse().find((m) => m.info.role === "assistant") if (lastSeen && lastSeen.info.id === goalState.last_judged_msg) suppressJudge = true } @@ -310,17 +306,13 @@ const serviceLayer = Layer.effect( // fires. Uses pauseAndPublish (fiber-safe) — NOT goal.pause — because // we ARE the loop fiber tracked in the fibers map (same self-interrupt // hazard discipline as the done / shouldPreempt branches below). - if ( - goalState.turns_used === 0 && - Date.now() - goalState.created_at > GoalPrompts.FRESHNESS_THRESHOLD - ) { - const probeMsgs = yield* sessions - .messages({ sessionID, limit: 1 }) - .pipe( - Effect.catchIf((e) => NotFoundError.isInstance(e), () => - Effect.succeed([] as SessionV1.WithParts[]), - ), - ) + if (goalState.turns_used === 0 && Date.now() - goalState.created_at > GoalPrompts.FRESHNESS_THRESHOLD) { + const probeMsgs = yield* workMessages(sessionID, 1).pipe( + Effect.catchIf( + (e) => NotFoundError.isInstance(e), + () => Effect.succeed([] as SessionV1.WithParts[]), + ), + ) const hasAssistant = probeMsgs.some((m) => m.info.role === "assistant") if (isStaleZombie(goalState, hasAssistant)) { yield* pauseGoal( @@ -336,13 +328,12 @@ const serviceLayer = Layer.effect( // (same pattern as MessageV2.stream) so the no-lastAssistant branch // below pauses visibly instead of this typed failure escaping and // leaving the goal permanently "active". - const msgs = yield* sessions - .messages({ sessionID, limit: 20 }) - .pipe( - Effect.catchIf((e) => NotFoundError.isInstance(e), () => - Effect.succeed([] as SessionV1.WithParts[]), - ), - ) + const msgs = yield* workMessages(sessionID).pipe( + Effect.catchIf( + (e) => NotFoundError.isInstance(e), + () => Effect.succeed([] as SessionV1.WithParts[]), + ), + ) const lastAssistant = [...msgs].reverse().find((m) => m.info.role === "assistant") if (!lastAssistant) { // No assistant message in the last 20 — the conversation may have @@ -405,20 +396,34 @@ const serviceLayer = Layer.effect( : { verdict: "continue" as const, reason: "上一轮无文本输出(纯工具调用),跳过判定直接继续", parseFailed: false } const updateResult = Option.getOrUndefined( - yield* automation.use( - observedLease, - goal.updateAfterJudge( - sessionID, - verdict.verdict, - verdict.reason, - verdict.parseFailed, - { - goalID: goalState.goal_id ?? "legacy", - revision: goalState.revision ?? 0, - }, - lastAssistant.info.id, + yield* automation + .use( + observedLease, + promptSvc + .withIdle( + sessionID, + Effect.gen(function* () { + const current = yield* workMessages(sessionID) + const latest = [...current].reverse().find((m) => m.info.role === "assistant") + if (latest?.info.id !== lastAssistant.info.id || shouldPreempt(current)) return undefined + return yield* goal.updateAfterJudge( + sessionID, + verdict.verdict, + verdict.reason, + verdict.parseFailed, + { goalID: goalState.goal_id ?? "legacy", revision: goalState.revision ?? 0 }, + lastAssistant.info.id, + ) + }), + ) + .pipe(Effect.map(Option.getOrUndefined)), + ) + .pipe( + Effect.catchIf( + (error) => NotFoundError.isInstance(error), + () => pauseGoal(sessionID, "评审期间会话消息已不可用,目标已暂停").pipe(Effect.as(Option.none())), + ), ), - ), ) if (!updateResult) return @@ -483,22 +488,7 @@ const serviceLayer = Layer.effect( } const currentStatus = yield* status.get(sessionID) - if (currentStatus.type !== "idle") { - // Session is no longer idle by the time dispatch resumes — it flipped - // during the judge call (5-30s latency), or between the gate and here - // on the GOAL-01 judge-less fall-through. - // Previously this was a bare `return` that left the goal silently - // "active" with no continuation. Pause with a visible reason so the - // user knows the loop was interrupted by a status change. - // Neutral wording on purpose: this pause is reachable both after a - // real judge call AND via the GOAL-01 gate-hit fall-through, where - // the judge was suppressed — the user-visible reason must not claim - // a judge was running. - const pauseMsg = `会话状态变化(${currentStatus.type}),目标已暂停` - yield* pauseGoal(sessionID, pauseMsg).pipe(Effect.ignore) - yield* promptSvc.prompt({ sessionID, noReply: true, parts: [{ type: "text", text: `⏸ 目标已暂停 — ${pauseMsg}` }] }).pipe(Effect.ignore) - return - } + if (currentStatus.type !== "idle") return // Reload messages before dispatch — the pre-judge snapshot may be stale // (the user can send messages during the 5-30s judge latency, or during @@ -506,13 +496,12 @@ const serviceLayer = Layer.effect( // Same vanished-session tolerance as the pre-judge window: NotFoundError // becomes an empty window (shouldPreempt is defensively false for it), // never a typed failure escaping the fork. - const freshMsgs = yield* sessions - .messages({ sessionID, limit: 20 }) - .pipe( - Effect.catchIf((e) => NotFoundError.isInstance(e), () => - Effect.succeed([] as SessionV1.WithParts[]), - ), - ) + const freshMsgs = yield* workMessages(sessionID).pipe( + Effect.catchIf( + (e) => NotFoundError.isInstance(e), + () => Effect.succeed([] as SessionV1.WithParts[]), + ), + ) if (shouldPreempt(freshMsgs)) { // Same self-interrupt hazard as the done branch above: we ARE the @@ -651,6 +640,15 @@ const serviceLayer = Layer.effect( const scope = yield* Scope.Scope const goalState = yield* goal.load(sessionID) if (!goalState || goalState.status !== "active") return + const messages = yield* workMessages(sessionID).pipe( + Effect.catchIf( + (e) => NotFoundError.isInstance(e), + () => Effect.succeed([] as SessionV1.WithParts[]), + ), + ) + const last = [...messages].reverse().find((m) => m.info.role === "assistant") + const boundary = `${goalState.goal_id}:${goalState.revision}:${last?.info.id}` + if (inFlight.get(sessionID) === boundary) return // D-4 gate (scan path only): skip when this process already evaluated // the CURRENT revision — the boot snapshot went stale after a // legitimate evaluation (e.g. the session's own idle event ran before @@ -669,7 +667,13 @@ const serviceLayer = Layer.effect( // zero logs — an invisible stall. Interrupts (fiber replacement by a // newer idle, scope disposal) stay silent: they are the normal // overwrite path, same F1 discipline as the continuation catch below. + inFlight.set(sessionID, boundary) const fiber = yield* afterIdle(sessionID, scanResume).pipe( + Effect.ensuring( + Effect.sync(() => { + if (inFlight.get(sessionID) === boundary) inFlight.delete(sessionID) + }), + ), Effect.catchCause((cause) => Cause.hasInterrupts(cause) ? Effect.void diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 4ae3957a2..392c7b208 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -577,6 +577,18 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses return filterCompacted(yield* stream(sessionID)) }) +/** Presentation-only user messages do not create a new model work boundary. */ +export function isIgnoredUser(message: { + info: { role: string } + parts?: ReadonlyArray<{ type: string; ignored?: boolean }> +}) { + return ( + message.info.role === "user" && + !!message.parts?.length && + message.parts.every((part) => part.type === "text" && part.ignored) + ) +} + // filterCompacted reorders messages for model consumption // ([compaction-user, summary, ...retained tail..., continue-user]), so array // position is not chronological. Derive each binding by max (time.created, id) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 540b9f301..de1995152 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -119,6 +119,11 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect<void> readonly prompt: (input: PromptInput) => Effect.Effect<SessionV1.WithParts, Image.Error> + /** Run a short commit while idle, serialized with prompt admission. Never start or await a turn here. */ + readonly withIdle: <A, E, R>( + sessionID: SessionID, + work: Effect.Effect<A, E, R>, + ) => Effect.Effect<Option.Option<A>, E, R> readonly prepareIfIdle: (input: PromptInput) => Effect.Effect<Option.Option<IdleAdmission>, Image.Error> readonly promptIfIdle: (input: PromptInput) => Effect.Effect<Option.Option<SessionV1.WithParts>, Image.Error> readonly loop: (input: LoopInput) => Effect.Effect<SessionV1.WithParts> @@ -1479,6 +1484,7 @@ export const layer = Layer.effect( )(function* (input: PromptInput) { const wait = yield* promptLocks.withLock(input.sessionID)( Effect.gen(function* () { + if (goal && input.noReply !== true) yield* goal.clearTurnDriven(input.sessionID) const admitted = yield* admitPrompt(input) if (!admitted.run) return Effect.succeed(admitted.message) return yield* state.ensureRunningHandle( @@ -1491,6 +1497,16 @@ export const layer = Layer.effect( return yield* wait }) + const withIdle: Interface["withIdle"] = Effect.fn("SessionPrompt.withIdle")( + <A, E, R>(sessionID: SessionID, work: Effect.Effect<A, E, R>) => + promptLocks.withLock(sessionID)( + Effect.gen(function* () { + if ((yield* status.get(sessionID)).type !== "idle") return Option.none() + return Option.some(yield* work) + }), + ), + ) + const prepareIfIdle: Interface["prepareIfIdle"] = Effect.fn("SessionPrompt.prepareIfIdle")(function* ( input: PromptInput, ) { @@ -1584,6 +1600,7 @@ export const layer = Layer.effect( yield* Effect.logInfo("loop", { "session.id": sessionID, step }) let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe( + Effect.map((messages) => messages.filter((message) => !MessageV2.isIgnoredUser(message))), Effect.provideService(Database.Service, database), ) @@ -1952,14 +1969,20 @@ export const layer = Layer.effect( const loop: (input: LoopInput) => Effect.Effect<SessionV1.WithParts> = Effect.fn("SessionPrompt.loop")(function* ( input: LoopInput, ) { - return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) + const result = yield* promptLocks.withLock(input.sessionID)( + state.ensureRunningHandle(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)), + ) + return yield* result }) const shell: (input: ShellInput) => Effect.Effect<SessionV1.WithParts, Session.BusyError> = Effect.fn( "SessionPrompt.shell", )(function* (input: ShellInput) { const ready = yield* Latch.make() - return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input, ready), ready) + const result = yield* promptLocks.withLock(input.sessionID)( + state.startShellHandle(input.sessionID, lastAssistant(input.sessionID), shellImpl(input, ready), ready), + ) + return yield* result }) // #409: early-return command dispatches (/memory, /trust, /goal non-kick) @@ -1968,8 +1991,12 @@ export const layer = Layer.effect( // startIfIdle keeps today's inline semantics while another turn is running // (no queueing, no second idle); the in-flight turn re-emits idle itself. const commandTurn = Effect.fnUntraced(function* (sessionID: SessionID, work: Effect.Effect<SessionV1.WithParts>) { - const handle = yield* state.startIfIdle(sessionID, lastAssistant(sessionID), work) - return yield* Option.getOrElse(handle, () => work) + return yield* promptLocks.withLock(sessionID)( + Effect.gen(function* () { + const handle = yield* state.startIfIdle(sessionID, lastAssistant(sessionID), work) + return yield* Option.getOrElse(handle, () => work) + }), + ) }) const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { @@ -2165,6 +2192,7 @@ export const layer = Layer.effect( messageID: userMsg.id, sessionID: input.sessionID, type: "text", + ignored: true, text: `⚠️ /${input.command} 执行失败,请检查日志。`, synthetic: true, time: { start: now, end: now }, @@ -2179,6 +2207,7 @@ export const layer = Layer.effect( messageID: userMsg.id, sessionID: input.sessionID, type: "text", + ignored: true, text: `/${input.command} ${input.arguments}`.trim(), } yield* sessions.updatePart(cmdText) @@ -2193,6 +2222,7 @@ export const layer = Layer.effect( messageID: userMsg.id, sessionID: input.sessionID, type: "text", + ignored: true, text: dispatchText, time: { start: now, end: now }, } @@ -2312,6 +2342,7 @@ export const layer = Layer.effect( return Service.of({ cancel, prompt, + withIdle, prepareIfIdle, promptIfIdle, loop, @@ -2324,7 +2355,7 @@ export const layer = Layer.effect( export const defaultLayer = Layer.suspend(() => layer.pipe( - Layer.provide(SessionRunState.defaultLayer), + Layer.provide(Layer.mergeAll(Goal.defaultLayer, SessionRunState.defaultLayer)), Layer.provide(SessionStatus.defaultLayer), Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), diff --git a/packages/opencode/src/session/prompt/goal.txt b/packages/opencode/src/session/prompt/goal.txt index d3a59883d..5d709c7c5 100644 --- a/packages/opencode/src/session/prompt/goal.txt +++ b/packages/opencode/src/session/prompt/goal.txt @@ -9,9 +9,11 @@ A `goal` **tool** is also available. Use `goal(action: "complete")` to self-decl ## Commands (user-facing; only you or the user can issue these) - `/goal <text>` — Set a new autonomous goal. The agent will work in a loop until the goal is achieved or the turn budget is exhausted. +- `/goal --max-turns <N> <text>` — Set a goal with a positive integer total turn budget (default 20). - `/goal status` — Show the current goal state (active/paused/achieved, turns used/total). - `/goal pause` — Pause the current goal. Use `/goal resume` to continue. -- `/goal resume` — Resume a paused goal. +- `/goal resume` — Resume a paused goal without resetting used turns or changing the budget. If exhausted, this permits one additional execution before the judge may pause it again. +- `/goal resume --max-turns <N>` — Resume with a new total budget greater than the turns already used. - `/goal clear` or `/goal stop` — Clear the current goal and stop the loop. - `/goal done` — Explicitly mark the goal as finished (same as the `goal` tool with `action=complete`). - `/subgoal <text>` — Add a subgoal to the current goal. diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index e88fdd299..a8ffc422f 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -26,6 +26,12 @@ export interface Interface { onInterrupt: Effect.Effect<SessionV1.WithParts>, work: Effect.Effect<SessionV1.WithParts>, ) => Effect.Effect<Option.Option<Effect.Effect<SessionV1.WithParts>>> + readonly startShellHandle: ( + sessionID: SessionID, + onInterrupt: Effect.Effect<SessionV1.WithParts>, + work: Effect.Effect<SessionV1.WithParts>, + ready?: Latch.Latch, + ) => Effect.Effect<Effect.Effect<SessionV1.WithParts, Session.BusyError>> readonly startShell: ( sessionID: SessionID, onInterrupt: Effect.Effect<SessionV1.WithParts>, @@ -119,18 +125,26 @@ export const layer = Layer.effect( return yield* (yield* runner(sessionID, onInterrupt)).startIfIdle(work) }) - const startShell = Effect.fn("SessionRunState.startShell")(function* ( - sessionID: SessionID, - onInterrupt: Effect.Effect<SessionV1.WithParts>, - work: Effect.Effect<SessionV1.WithParts>, - ready?: Latch.Latch, - ) { - return yield* (yield* runner(sessionID, onInterrupt)) - .startShell(work, ready) - .pipe(Effect.catchTag("RunnerBusy", () => Effect.fail(busyError(sessionID)))) - }) + const startShellHandle: Interface["startShellHandle"] = Effect.fn("SessionRunState.startShellHandle")( + function* (sessionID, onInterrupt, work, ready) { + const result = yield* (yield* runner(sessionID, onInterrupt)).startShellHandle(work, ready) + return result.pipe(Effect.catchTag("RunnerBusy", () => Effect.fail(busyError(sessionID)))) + }, + ) - return Service.of({ assertNotBusy, cancel, ensureRunning, ensureRunningHandle, startIfIdle, startShell }) + const startShell: Interface["startShell"] = Effect.fn("SessionRunState.startShell")((...args) => + startShellHandle(...args).pipe(Effect.flatten), + ) + + return Service.of({ + assertNotBusy, + cancel, + ensureRunning, + ensureRunningHandle, + startIfIdle, + startShell, + startShellHandle, + }) }), ) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 51f1e71e2..b73747cea 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,6 +1,7 @@ -import { Effect, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import * as Tool from "./tool" import { Question } from "../question" +import { Goal } from "../goal/goal" import DESCRIPTION from "./question.txt" export const Parameters = Schema.Struct({ @@ -21,6 +22,15 @@ export const QuestionTool = Tool.define<typeof Parameters, Metadata, Question.Se parameters: Parameters, execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) => Effect.gen(function* () { + const goal = Option.getOrUndefined(yield* Effect.serviceOption(Goal.Service)) + if (goal && (yield* goal.isTurnDriven(ctx.sessionID))) { + return { + title: "Autonomous turn: question unavailable", + output: + "Interactive questions are disabled during Goal execution. Make a reasonable decision and continue. If user input is essential, explain the blocker in your final response so the goal can pause.", + metadata: { answers: [] }, + } + } const answers = yield* question.ask({ sessionID: ctx.sessionID, questions: params.questions, diff --git a/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts b/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts index 245676836..2ced7b0ce 100644 --- a/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts +++ b/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts @@ -154,6 +154,7 @@ function goalWakeLayer(input: { childPrompts: Queue.Queue<ChildPromptGate>; fail const base = Layer.mergeAll(database, events, bridge, store, projector, dag, goal, status) const childTitles = new Map<string, string>() const created: string[] = [] + let lastAssistant = mkAssistant() const session = Layer.mock(Session.Service, { get: (_sessionID) => Effect.succeed({ @@ -183,7 +184,7 @@ function goalWakeLayer(input: { childPrompts: Queue.Queue<ChildPromptGate>; fail // GoalLoop.afterIdle reads the last-20 message window: an assistant // message must exist so the judge is reached (no stale-zombie / no-assistant // early pauses). - messages: () => Effect.succeed([mkAssistant()]), + messages: () => Effect.succeed([lastAssistant]), }) const deliver = Effect.fn("test.goalWake.SessionPrompt.deliver")(function* (value: SessionPrompt.PromptInput) { const sessionID = value.sessionID as string @@ -203,6 +204,7 @@ function goalWakeLayer(input: { childPrompts: Queue.Queue<ChildPromptGate>; fail // dag — the blocked claim the unregister re-trigger exists to retry // (GOAL-FP-01-02 / R1). Later parent prompts are goal continuations and // must not re-emit (the mock has no real runner turn). + lastAssistant = reply(sessionID, "parent turn") if (parentPromptCalls === 0) { parentPromptCalls += 1 yield* Effect.serviceOption(EventV2Bridge.Service).pipe( @@ -216,7 +218,7 @@ function goalWakeLayer(input: { childPrompts: Queue.Queue<ChildPromptGate>; fail ), ) } - return reply(sessionID, "parent turn") + return lastAssistant } const release = yield* Deferred.make<string>() yield* Queue.offer(input.childPrompts, { title: childTitles.get(sessionID) ?? sessionID, release }) @@ -387,13 +389,21 @@ describe("DagLoop final wake delivery re-triggers the goal (GOAL-FP-01-02)", () "wake was never reported", ) - // Public contract: with NO further idle events, the dag release must - // itself re-trigger the goal evaluation. judgeCalls > 0 proves - // GoalLoop.afterIdle ran a full cycle (lease claimed → judge → - // updateAfterJudge → continuation dispatch). + // Observe the committed Goal state and continuation, not merely entry + // into the judge: the durable commit is asynchronous after that call. yield* pollWithTimeout( - Effect.sync(() => (judgeCalls >= 1 ? true : undefined)), - "goal was not re-evaluated after the dag lease release (GOAL-FP-01-02)", + goal + .load(sid) + .pipe( + Effect.map((state) => + state && + state.turns_used >= 1 && + promptCalls.some((p) => !p.noReply && p.text.includes("ship the feature")) + ? state + : undefined, + ), + ), + "goal did not progress after the dag lease release (GOAL-FP-01-02)", "5 seconds", ) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index afa76cc6d..3c9882651 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -130,6 +130,21 @@ describe("Runner", () => { }), ) + it.live( + "startShellHandle reserves the runner before its result is awaited", + Effect.gen(function* () { + const scope = yield* Scope.Scope + const runner = Runner.make<string>(scope) + const release = yield* Deferred.make<void>() + const result = yield* runner.startShellHandle(Deferred.await(release).pipe(Effect.as("shell-done"))) + expect(runner.busy).toBe(true) + expect(runner.state._tag).toBe("Shell") + yield* Deferred.succeed(release, undefined) + expect(yield* result).toBe("shell-done") + expect(runner.busy).toBe(false) + }), + ) + it.live( "startIfIdle atomically rejects replacement work while the first run is active", Effect.gen(function* () { diff --git a/packages/opencode/test/goal/bootstrap-wiring.test.ts b/packages/opencode/test/goal/bootstrap-wiring.test.ts index 99d1c9534..3c0152250 100644 --- a/packages/opencode/test/goal/bootstrap-wiring.test.ts +++ b/packages/opencode/test/goal/bootstrap-wiring.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" import { Goal } from "@/goal/goal" import { SessionStatus } from "@/session/status" import { SessionID } from "@/session/schema" @@ -68,3 +70,56 @@ describe("GoalLoop production wiring — idle must drive afterIdle", () => { 20_000, ) }) + +it.live( + "production AppLayer dispatches Goal controls without invoking the model", + () => + provideTmpdirInstance((directory) => + Effect.promise(async () => { + await Bun.write( + `${directory}/opencode.json`, + JSON.stringify({ + model: "test/test-model", + formatter: false, + lsp: false, + provider: { + test: { + npm: "@ai-sdk/openai-compatible", + models: { "test-model": { name: "Test Model" } }, + options: { apiKey: "test", baseURL: "http://127.0.0.1:1/v1" }, + }, + }, + }), + ) + const { AppRuntime } = await import("@/effect/app-runtime") + await AppRuntime.runPromise( + Effect.gen(function* () { + const store = yield* InstanceStore.Service + yield* store.provide( + { directory }, + Effect.gen(function* () { + const session = yield* (yield* Session.Service).create({ title: "Goal wiring" }) + const goal = yield* Goal.Service + yield* goal.set(session.id, "production Goal wiring", 7) + yield* goal.pause(session.id, "smoke test") + const result = yield* (yield* SessionPrompt.Service).command({ + sessionID: session.id, + command: "goal", + arguments: "status", + }) + const text = result.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + expect(text).toContain("production Goal wiring") + expect(text).toContain("0/7") + expect((yield* goal.load(session.id))?.status).toBe("paused") + yield* goal.clear(session.id) + }), + ) + }), + ) + }), + ), + 20_000, +) diff --git a/packages/opencode/test/goal/e2e-loop.test.ts b/packages/opencode/test/goal/e2e-loop.test.ts index 5635ac8da..6547d10cc 100644 --- a/packages/opencode/test/goal/e2e-loop.test.ts +++ b/packages/opencode/test/goal/e2e-loop.test.ts @@ -759,8 +759,8 @@ describe("GoalLoop — real SessionRunState admission seam (GOAL-FP-01-13)", () }) const promptMock = Layer.mock(SessionPrompt.Service, { prompt: () => Effect.die("the direct prompt path is not exercised in this scenario"), + ...withIdleAdmission({ promptIfIdle }), prepareIfIdle, - promptIfIdle, }) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, @@ -983,84 +983,6 @@ describe("GoalLoop — empty assistant text → synthetic continue, no stall (br ) }) -// Branch 3 (loop.ts): after the judge call returns, the session status is no -// longer idle (5-30s of judge latency). The loop now pauses visibly instead of -// bare-returning. Status is pre-set to busy so afterIdle's post-judge status -// check observes a non-idle state; the raw idle-event publish drives afterIdle -// without clearing the stored busy entry. -describe("GoalLoop — status changed during judge → visible pause (branch 3)", () => { - let judgeCalls = 0 - const promptCalls: { noReply?: boolean; text: string }[] = [] - const reset = () => { - judgeCalls = 0 - promptCalls.length = 0 - } - - const sessionMock = Layer.succeed(Session.Service, { - messages: () => Effect.succeed([mkAssistant()]), - } as never) - const providerMock = Layer.succeed(Provider.Service, {} as never) - const judgeMock = Layer.succeed( - GoalLoopJudgeLLM, - GoalLoopJudgeLLM.of({ - call: () => - Effect.sync(() => { - judgeCalls += 1 - return JSON.stringify({ done: false, reason: "more steps" }) - }), - }), - ) - - const branchLayer = GoalLoop.layer.pipe( - Layer.provide(sessionMock), - Layer.provide(recordingPrompt(promptCalls)), - Layer.provide(providerMock), - Layer.provide(judgeMock), - Layer.provideMerge(Goal.defaultLayer), - // provideMerge (not provide): the test body yields SessionStatus.Service to - // pre-set busy, and afterIdle must read that SAME instance — a consumed - // (non-merged) SessionStatus would be invisible to the test body AND could - // diverge from the one afterIdle uses. - Layer.provideMerge(SessionStatus.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - ) - const it = testEffect(branchLayer) - - it.instance("judge 期间 status 变非 idle → goal paused + 可见提示", () => - Effect.gen(function* () { - reset() - const loop = yield* GoalLoop.Service - const goal = yield* Goal.Service - const status = yield* SessionStatus.Service - const events = yield* EventV2Bridge.Service - yield* loop.init() - const sid = SessionID.descending() - yield* goal.set(sid, "ship the feature", 10) - // Make the session non-idle so afterIdle's post-judge status check sees - // busy. The raw idle-event publish below drives afterIdle WITHOUT - // touching the status map, so the busy entry persists. - yield* status.set(sid, { type: "busy" }) - yield* Effect.yieldNow - - yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) - yield* pollWithTimeout( - Effect.gen(function* () { - const g = yield* goal.load(sid) - return g?.status === "paused" ? true : undefined - }), - "branch 3 never paused the goal", - "5 seconds", - ) - - expect(judgeCalls).toBeGreaterThanOrEqual(1) - const paused = yield* goal.load(sid) - expect(paused?.status).toBe("paused") - expect(String(paused?.paused_reason)).toContain("状态变化") - expect(promptCalls.some((p) => p.noReply)).toBe(true) - }), - ) -}) - // Branch 4 (loop.ts): the continuation dispatch fails with an INTERRUPT cause // (user pressed ESC mid-dispatch). The loop logs and returns WITHOUT pausing, // relying on the session always re-emitting idle (SessionStatus.set publishes @@ -1583,11 +1505,10 @@ describe("GoalLoop — startup scan scoping and hardening (GOAL-FP-01-04 follow- "the scan's evaluation never reached the judge", "5 seconds", ) - // Now a second evaluation races it: the idle event drives an - // independent trigger for the SAME turn boundary. The fiber map's - // interrupt-on-replace kills the parked scan evaluation, and exactly - // ONE commit for the boundary must land. + // A second idle for the same boundary must share the pending judge. + // Releasing that judge must commit the boundary exactly once. yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) + yield* Deferred.succeed(judgeRelease, undefined) yield* pollWithTimeout( Effect.gen(function* () { @@ -1597,8 +1518,7 @@ describe("GoalLoop — startup scan scoping and hardening (GOAL-FP-01-04 follow- "no racing evaluation committed", "5 seconds", ) - yield* Effect.sleep("50 millis") - yield* Deferred.succeed(judgeRelease, undefined) + expect(judgeCalls).toBe(1) const g = yield* goal.load(sid) expect(g?.status).toBe("active") // The single-writer commit point (matchesExpected + record gate) must @@ -1681,24 +1601,29 @@ describe("GoalLoop — NotFoundError messages window pauses instead of stalling // continuation branch (shouldPreempt is defensively false on an empty window). describe("GoalLoop — NotFoundError on the post-judge reload must not stall (GOAL-FP-01-18)", () => { let messageCall = 0 + let vanished = false const sessionMock = Layer.mock(Session.Service, { messages: () => Effect.suspend(() => { messageCall += 1 - return messageCall === 1 + return !vanished ? Effect.succeed([mkAssistant()]) : Effect.fail(new NotFoundError({ message: "Session not found" })) }), }) const promptMock = Layer.mock(SessionPrompt.Service, { prompt: () => Effect.die(new Error("unreachable - paused branch not expected")), - prepareIfIdle: () => Effect.succeed(Option.none()), + ...withIdleAdmission({ promptIfIdle: () => Effect.succeed(Option.none()) }), }) const providerMock = Layer.mock(Provider.Service, {}) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, GoalLoopJudgeLLM.of({ - call: () => Effect.succeed(JSON.stringify({ verdict: "continue", reason: "more work" })), + call: () => + Effect.sync(() => { + vanished = true + return JSON.stringify({ verdict: "continue", reason: "more work" }) + }), }), ) @@ -1713,9 +1638,10 @@ describe("GoalLoop — NotFoundError on the post-judge reload must not stall (GO ) const it = testEffect(reloadLayer) - it.instance("a vanished session during judge still commits the turn", () => + it.instance("a vanished session during judge pauses without charging the turn", () => Effect.gen(function* () { messageCall = 0 + vanished = false const loop = yield* GoalLoop.Service const goal = yield* Goal.Service const events = yield* EventV2Bridge.Service @@ -1730,13 +1656,13 @@ describe("GoalLoop — NotFoundError on the post-judge reload must not stall (GO const committed = yield* pollWithTimeout( Effect.gen(function* () { const g = yield* goal.load(sid) - return g && g.turns_used >= 1 ? g : undefined + return g && g.status === "paused" ? g : undefined }), - "turn never committed — the post-judge reload failure escaped", + "missing boundary did not pause", "5 seconds", ) - expect(committed.turns_used).toBe(1) - expect(committed.status).toBe("active") + expect(committed.turns_used).toBe(0) + expect(committed.status).toBe("paused") expect(messageCall).toBeGreaterThanOrEqual(2) }), ) @@ -1757,7 +1683,7 @@ describe("GoalLoop — judge-chain defect degrades into the parse budget (GOAL-F }) const promptMock = Layer.mock(SessionPrompt.Service, { prompt: () => Effect.die(new Error("unreachable - paused branch not expected")), - prepareIfIdle: () => Effect.succeed(Option.none()), + ...withIdleAdmission({ promptIfIdle: () => Effect.succeed(Option.none()) }), }) const providerMock = Layer.mock(Provider.Service, {}) const judgeMock = Layer.succeed( diff --git a/packages/opencode/test/goal/goal.test.ts b/packages/opencode/test/goal/goal.test.ts index 876070ee0..e3de77c09 100644 --- a/packages/opencode/test/goal/goal.test.ts +++ b/packages/opencode/test/goal/goal.test.ts @@ -1025,3 +1025,51 @@ describe("Goal.dispatch resume — busy guard (D5)", () => { }), ) }) + +it.instance("Goal commands configure and extend a total turn budget", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const id = SessionID.descending() + expect((yield* goal.dispatch(id, "--max-turns 3 write docs")).type).toBe("kick") + const state = yield* goal.load(id) + expect(state).toMatchObject({ goal: "write docs", max_turns: 3 }) + yield* goal.updateAfterJudge(id, "continue", "more", false, { goalID: state!.goal_id!, revision: state!.revision! }) + yield* goal.pause(id, "user-paused") + expect((yield* goal.dispatch(id, "resume --max-turns 6")).type).toBe("kick") + expect(yield* goal.load(id)).toMatchObject({ status: "active", max_turns: 6, turns_used: 1 }) + }), +) + +for (const value of ["0", "-1", "1.5", "abc", "9007199254740992", ""]) { + it.instance(`Goal rejects invalid budget ${JSON.stringify(value)} without creating state`, () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const id = SessionID.descending() + expect((yield* goal.dispatch(id, `--max-turns ${value} write docs`)).type).toBe("message") + expect(yield* goal.load(id)).toBeUndefined() + }), + ) +} + +it.instance("Goal invalid resume budgets preserve the entire paused state", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const id = SessionID.descending() + const initial = yield* goal.set(id, "write docs", 3) + yield* goal.updateAfterJudge(id, "continue", "more work", false, { + goalID: initial.goal_id ?? "legacy", + revision: initial.revision ?? 0, + }) + const paused = yield* goal.pause(id, "user-paused") + for (const argument of [ + "resume --max-turns 1", + "resume --max-turns 0", + "resume --max-turns abc", + "resume --max-turns 5 extra", + "resume --max-turns=5", + ]) { + expect((yield* goal.dispatch(id, argument)).type).toBe("message") + expect(yield* goal.load(id)).toEqual(paused) + } + }), +) diff --git a/packages/opencode/test/lib/session-prompt.ts b/packages/opencode/test/lib/session-prompt.ts index 88dd287b2..22182a9ce 100644 --- a/packages/opencode/test/lib/session-prompt.ts +++ b/packages/opencode/test/lib/session-prompt.ts @@ -1,4 +1,6 @@ import { Cause, Effect, Option } from "effect" +import { SessionStatus } from "@/session/status" +import { SessionID } from "@/session/schema" import { SessionPrompt } from "@/session/prompt" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -11,6 +13,12 @@ export function withIdleAdmission<Error, Value extends object>( ) { return { ...service, + withIdle: <A, E, R>(sessionID: SessionID, work: Effect.Effect<A, E, R>) => + Effect.gen(function* () { + const status = yield* Effect.serviceOption(SessionStatus.Service) + if (Option.isSome(status) && (yield* status.value.get(sessionID)).type !== "idle") return Option.none<A>() + return Option.some(yield* work) + }), prepareIfIdle: (input: SessionPrompt.PromptInput) => Effect.succeed( Option.some({ diff --git a/packages/opencode/test/server/httpapi-v2-pty.test.ts b/packages/opencode/test/server/httpapi-v2-pty.test.ts index ea4b02dc8..1ef6bdaf8 100644 --- a/packages/opencode/test/server/httpapi-v2-pty.test.ts +++ b/packages/opencode/test/server/httpapi-v2-pty.test.ts @@ -209,7 +209,10 @@ describe("v2 pty HttpApi", () => { directoryHeader(dir), HttpClientRequest.bodyJson({ command: "/bin/sh", - args: ["-c", 'printf "%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$HOOK_CWD"; sleep 5'], + args: [ + "-c", + 'printf "%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$HOOK_CWD"; read -r release', + ], cwd, env: { CALLER: "caller", SHARED: "caller", TERM: "caller" }, }), @@ -243,6 +246,7 @@ describe("v2 pty HttpApi", () => { expect(yield* takeUntil(`caller|plugin|plugin|xterm-256color|${cwd}`)).toContain( `caller|plugin|plugin|xterm-256color|${cwd}`, ) + yield* write("done\n") yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(directoryHeader(dir), HttpClient.execute) }), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 411fdb2fb..476d10cf7 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -6,7 +6,7 @@ import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" -import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" +import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Option, Stream } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" import { NamedError } from "@opencode-ai/core/util/error" @@ -48,6 +48,7 @@ import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "@/tool/registry" import { Dag } from "@/dag/dag" import { Goal } from "@/goal/goal" +import { GoalLoop, GoalLoopJudgeLLM } from "@/goal/loop" import { Truncate } from "@/tool/truncate" import { SettingsHook, type HookPayload } from "@/hook/settings" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -235,13 +236,16 @@ type PromptLayerOptions = { mcpInstructions?: MCP.ServerInstructions[] processor?: "blocking" goal?: boolean + goalLayer?: Layer.Layer<Goal.Service> memoryContext?: string[] } function makePrompt(input?: PromptLayerOptions) { // goal: false exercises the Goal-absent degradation path (serviceOption None) const goalLayer: Layer.Layer<Goal.Service> = - input?.goal === false ? (Layer.empty as unknown as Layer.Layer<Goal.Service>) : Goal.defaultLayer + input?.goal === false + ? (Layer.empty as unknown as Layer.Layer<Goal.Service>) + : (input?.goalLayer ?? Goal.defaultLayer) const memoryLayer = Layer.mock(Memory.Service, { init: () => Effect.void, prepare: () => Effect.void, @@ -3166,3 +3170,251 @@ noLLMServer.instance( }), 30_000, ) + +function goalRuntime(call: GoalLoopJudgeLLM["Service"]["call"], onSettled = () => {}) { + const goalLayer = Layer.effect( + Goal.Service, + Effect.gen(function* () { + const goal = yield* Goal.Service + return Goal.Service.of({ + ...goal, + clearLoopFiberIf: (sessionID, fiber) => + goal.clearLoopFiberIf(sessionID, fiber).pipe(Effect.tap(() => Effect.sync(onSettled))), + }) + }), + ).pipe(Layer.provide(Goal.defaultLayer)) + return GoalLoop.layer.pipe( + Layer.provideMerge(makeHttp({ goalLayer })), + Layer.provideMerge(Layer.succeed(GoalLoopJudgeLLM, { call })), + ) +} + +for (const verdict of ["done", "blocked", "continue"] as const) { + let started = false + let settled = false + let release: Deferred.Deferred<void> + const race = testEffect( + goalRuntime( + () => + Effect.gen(function* () { + started = true + yield* Deferred.await(release) + return JSON.stringify({ verdict, reason: "old response" }) + }), + () => { + settled = true + }, + ), + ) + race.instance(`Goal rejects stale ${verdict} while a new human turn is running`, () => + Effect.gen(function* () { + started = false + settled = false + release = yield* Deferred.make<void>() + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + const goal = yield* Goal.Service + yield* seed(chat.id, { finish: "stop" }) + yield* goal.set(chat.id, "write the docs") + yield* (yield* GoalLoop.Service).init() + yield* pollWithTimeout( + Effect.sync(() => started || undefined), + "judge never started", + ) + yield* llm.hang + const work = yield* prompt + .prompt({ sessionID: chat.id, parts: [{ type: "text", text: "Add the missing section first." }] }) + .pipe(Effect.forkChild) + yield* llm.wait(1) + yield* waitForBusy(chat.id) + yield* Deferred.succeed(release, undefined) + yield* pollWithTimeout( + Effect.sync(() => settled || undefined), + "judge never settled", + ) + expect(yield* goal.lastOutcome(chat.id)).toBeUndefined() + expect(yield* goal.load(chat.id)).toMatchObject({ status: "active", turns_used: 0 }) + yield* prompt.cancel(chat.id) + yield* Fiber.await(work) + }), + ) +} + +for (const command of [ + { command: "goal", arguments: "status" }, + { command: "subgoal", arguments: "additional criterion" }, +]) { + let calls: string[] = [] + let release: Deferred.Deferred<void> + const controls = testEffect( + goalRuntime((opts) => + Effect.gen(function* () { + calls.push(opts.user) + yield* Deferred.await(release) + return JSON.stringify({ verdict: "continue", reason: "more work remains" }) + }), + ), + ) + controls.instance(`Goal /${command.command} control preserves the pending work boundary`, () => + Effect.gen(function* () { + calls = [] + release = yield* Deferred.make<void>() + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + const goal = yield* Goal.Service + yield* seed(chat.id, { finish: "stop" }) + yield* goal.set(chat.id, "write the docs") + yield* (yield* GoalLoop.Service).init() + yield* pollWithTimeout( + Effect.sync(() => calls.length || undefined), + "judge never started", + ) + for (let i = 0; i < (command.command === "goal" ? 25 : 1); i++) { + yield* prompt.command({ sessionID: chat.id, ...command }) + } + if (command.command === "subgoal") { + yield* pollWithTimeout( + Effect.sync(() => calls.some((x) => x.includes("additional criterion")) || undefined), + "new subgoal was not judged", + ) + } + yield* llm.hang + yield* Deferred.succeed(release, undefined) + yield* awaitWithTimeout(llm.wait(1), "control prevented continuation") + expect(yield* goal.load(chat.id)).toMatchObject({ status: "active", turns_used: 1 }) + if (command.command === "goal") expect(calls).toHaveLength(1) + const inputs = JSON.stringify( + (yield* llm.inputs).map((input) => + Array.isArray(input.messages) ? input.messages.filter((m: { role: string }) => m.role === "user") : [], + ), + ) + expect(inputs).not.toContain("/goal status") + if (command.command === "subgoal") expect(inputs).toContain("additional criterion") + yield* prompt.cancel(chat.id) + }), + ) +} + +let goalJudges = 0 +const autonomous = testEffect( + goalRuntime(() => + Effect.sync(() => + JSON.stringify({ + verdict: ++goalJudges === 1 ? "continue" : "done", + reason: "scripted progress", + }), + ), + ), +) +autonomous.instance("Goal question cannot leave an autonomous continuation waiting for input", () => + Effect.gen(function* () { + goalJudges = 0 + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + const goal = yield* Goal.Service + const questions = yield* Question.Service + yield* (yield* GoalLoop.Service).init() + yield* llm.text("First step done.") + yield* llm.tool("question", { + questions: [ + { + question: "Which option?", + header: "Choice", + options: [ + { label: "A", description: "First" }, + { label: "B", description: "Second" }, + ], + }, + ], + }) + yield* llm.text("I chose A and completed the work.") + yield* prompt.command({ sessionID: chat.id, command: "goal", arguments: "write the docs" }) + const outcome = yield* pollWithTimeout(goal.lastOutcome(chat.id), "Goal stuck waiting for a question", "5 seconds") + expect(outcome.status).toBe("done") + expect(yield* questions.list()).toHaveLength(0) + expect((yield* llm.inputs).length).toBe(3) + }), +) + +for (const entry of ["prompt", "loop", "shell"] as const) { + it.instance(`Goal idle commit serializes ${entry} admission without holding the execution`, () => + withSh(() => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + yield* user(chat.id, "start work") + const entered = yield* Deferred.make<void>() + const attempted = yield* Deferred.make<void>() + const release = yield* Deferred.make<void>() + const order: string[] = [] + const events = yield* EventV2Bridge.Service + yield* events.subscribe(SessionStatus.Event.Status).pipe( + Stream.runForEach((event) => + Effect.sync(() => { + if (event.data.sessionID === chat.id && event.data.status.type === "busy") order.push("busy") + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + const commit = yield* prompt + .withIdle( + chat.id, + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + order.push("commit") + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(entered) + yield* llm.hang + const input = + entry === "shell" + ? prompt.shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }).pipe(Effect.orDie) + : entry === "loop" + ? prompt.loop({ sessionID: chat.id }) + : prompt.prompt({ sessionID: chat.id, parts: [{ type: "text", text: "continue" }] }).pipe(Effect.orDie) + const work = yield* Deferred.succeed(attempted, undefined).pipe(Effect.andThen(input), Effect.forkChild) + yield* Deferred.await(attempted) + yield* Effect.yieldNow + expect((yield* (yield* SessionStatus.Service).get(chat.id)).type).toBe("idle") + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(commit) + yield* waitForBusy(chat.id) + expect(order[0]).toBe("commit") + // The commit lock is released before the provider/shell finishes. + expect(Option.isNone(yield* prompt.withIdle(chat.id, Effect.void))).toBe(true) + yield* prompt.cancel(chat.id) + yield* Fiber.await(work) + }), + ), + ) +} + +it.instance("human prompt retains question interaction with an active Goal", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + const goal = yield* Goal.Service + const questions = yield* Question.Service + yield* goal.set(chat.id, "write docs") + yield* goal.markTurnDriven(chat.id) + yield* llm.tool("question", { + questions: [ + { question: "Which section?", header: "Section", options: [{ label: "API", description: "Document the API" }] }, + ], + }) + yield* llm.text("Documented the API.") + const work = yield* prompt + .prompt({ sessionID: chat.id, parts: [{ type: "text", text: "Ask me which section to document." }] }) + .pipe(Effect.forkChild) + const pending = yield* pollWithTimeout( + questions.list().pipe(Effect.map((items) => items[0])), + "human question was suppressed", + ) + expect(yield* goal.isTurnDriven(chat.id)).toBe(false) + yield* questions.reply({ requestID: pending.id, answers: [["API"]] }) + yield* Fiber.join(work) + expect(yield* questions.list()).toHaveLength(0) + }), +) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 0bbc58d44..1ac770863 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Queue } from "effect" +import { Goal } from "@/goal/goal" import { QuestionTool } from "../../src/tool/question" import { Question } from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" @@ -136,3 +137,19 @@ describe("tool.question", () => { // } // }) }) + +it.instance("question reads Goal from the execution context, not registry construction", () => + Effect.gen(function* () { + // This test layer intentionally has no Goal at tool construction time. + const tool = yield* (yield* QuestionTool).init() + const questions = yield* Question.Service + const result = yield* tool + .execute({ questions: [] }, ctx) + .pipe( + Effect.provide(Layer.mock(Goal.Service, { isTurnDriven: () => Effect.succeed(true) })), + Effect.timeout("2 seconds"), + ) + expect(result.output).toContain("Interactive questions are disabled") + expect(yield* questions.list()).toHaveLength(0) + }), +) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 83b38dd38..7554b88b1 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -636,9 +636,11 @@ export const { if (sessionIDs.length === 0) return Promise.resolve() return Promise.all( sessionIDs.map((sessionID) => - sdk.client.session.goal({ sessionID }, { throwOnError: false }) + sdk.client.session + .goal({ sessionID }, { throwOnError: false }) .then((response) => { - setStore("goal", sessionID, response.data ?? undefined) + if (response.data) setStore("goal", sessionID, response.data) + else if (response.response?.status === 404) setStore("goal", sessionID, undefined) }) .catch(() => {}), ), diff --git a/packages/tui/test/cli/cmd/tui/sync-goal.test.tsx b/packages/tui/test/cli/cmd/tui/sync-goal.test.tsx index 9fcd5a460..1a7179b3f 100644 --- a/packages/tui/test/cli/cmd/tui/sync-goal.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-goal.test.tsx @@ -98,3 +98,46 @@ describe("tui sync goal slice", () => { } }) }) + +for (const status of [200, 404, 500]) { + test(`Goal reconnect handles HTTP ${status}`, async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let reads = 0 + const { app, emit, reconnect, sync } = await mount((url) => { + if (!url.pathname.endsWith("/goal")) return + reads++ + return Response.json( + status === 200 + ? { + goal: "updated goal", + status: "paused", + turnsUsed: 3, + maxTurns: 20, + subgoals: [], + } + : { message: "failed" }, + { status }, + ) + }, tmp.path) + try { + emit(goalUpdated()) + await wait(() => sync.data.goal[sid] !== undefined) + reconnect() + await wait(() => reads === 1) + if (status === 404) { + await wait(() => sync.data.goal[sid] === undefined) + expect(sync.data.goal[sid]).toBeUndefined() + } else { + // Starting another request proves the first reconnect finished. + await wait(() => { + reconnect() + return reads >= 2 + }) + expect(sync.data.goal[sid]?.goal).toBe(status === 200 ? "updated goal" : "ship the feature") + } + } finally { + app.renderer.destroy() + } + }) +} From cf80d58e5d64bcea9cd31bf2eeb94720c5732741 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 14:41:25 +0800 Subject: [PATCH 18/25] chore: record delivery binding for stable-release-1042 --- .specgit.yaml | 41 +++++++---------------------------------- 1 file changed, 7 insertions(+), 34 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 2995f8957..2081dd7b2 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,38 +1,11 @@ version: 1 -delivery: hooks-runtime-reliability +delivery: stable-release-1042 context: - kind: branch - branch: fix/557-hooks-runtime-reliability + kind: worktree + label: opencode-release-1042 + branch: chore/576-stable-release-1042 issues: - - 557 - - 558 - - 559 - - 560 - - 561 - - 562 - - 563 - - 564 - - 565 - - 566 + - 576 issueKinds: - - issue: 557 - kind: kind::fix - - issue: 558 - kind: kind::fix - - issue: 559 - kind: kind::fix - - issue: 560 - kind: kind::fix - - issue: 561 - kind: kind::fix - - issue: 562 - kind: kind::fix - - issue: 563 - kind: kind::fix - - issue: 564 - kind: kind::fix - - issue: 565 - kind: kind::fix - - issue: 566 - kind: kind::fix -pr: 567 + - issue: 576 + kind: kind::chore From 1327c6973617986f6efb3a1fa28b2694afb0ee57 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 14:41:53 +0800 Subject: [PATCH 19/25] chore: record delivery binding for stable-release-1042 --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 2081dd7b2..4f9c76962 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -9,3 +9,4 @@ issues: issueKinds: - issue: 576 kind: kind::chore +pr: 577 From f5ea4fffdf8dcaad061b00beb375a909a727584e Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 14:54:18 +0800 Subject: [PATCH 20/25] docs(release): prepare v1.0.42 and record branch reconciliation --- .github/releases/v1.0.42-branch-audit.md | 133 +++++++++++++++++++++++ .github/releases/v1.0.42.md | 52 +++++++++ 2 files changed, 185 insertions(+) create mode 100644 .github/releases/v1.0.42-branch-audit.md create mode 100644 .github/releases/v1.0.42.md diff --git a/.github/releases/v1.0.42-branch-audit.md b/.github/releases/v1.0.42-branch-audit.md new file mode 100644 index 000000000..e24099f5d --- /dev/null +++ b/.github/releases/v1.0.42-branch-audit.md @@ -0,0 +1,133 @@ +# v1.0.42 branch reconciliation + +Snapshot: 2026-09-07, before creating the release preparation branches. Scope: all 100 local branches and seven `origin` branches of `LeXwDeX/OpenCode-GraphAgent`. Upstream development branches belong to the upstream project and are outside this fork release. + +The original main worktree contains uncommitted local documentation and SpecGit maintenance changes. Those were preserved and are not treated as branch commits. No branch or worktree was deleted. + +## Required integration + +- `origin/dev` was `345e5c13e7bdce2c739bace6327a896ebed88075`, 17 commits behind `origin/main` (`b37b3368accb2d0cc6aecc4f576581f97900d97b`). The main history includes accepted Hooks fixes and v1.0.40/v1.0.41 release records. +- Goal source `9a651339b1df92e0739a9653e7f08f4c807f7a58` adds the Goal runtime, concurrency, command, budget, reconnect, and PTY regression repairs tracked by PR #573. +- Candidate PR #577 preserves that source commit as an ancestor, includes the main history, and retains all seven Goal issue closing references plus #576. Product content after the integration merge is identical to the verified Goal source; subsequent candidate changes are release documentation. + +## Historical differences + +- `c417e1ee374c31d210dd101a973115f65ce61630` occurs on six stale test-created `opencode/*` branches. Main already contains replacement `0c46896bfe`; teardown implementation and regression tests are byte-identical. The replacement additionally asserts DAG graph revision. Reapplying the old commit would add no missing behavior. +- `ci/519-auto-close-linked`, `feat/475-sdk-openapi-race`, `fix/440-assemble-google-key`, and `origin/feat/473-git-head-watcher` have only old `.specgit.yaml` binding commits outside main. Their implementation ancestors are already contained. +- `docs/460-release-notes-v1-0-35` has one old commit untracking the delivery record. Current main explicitly carries delivery records for CI and already ignores local assets; this superseded metadata policy is not reapplied. + +## Full inventory + +| Branch | Outside dev | Outside main | Disposition | +| --- | ---: | ---: | --- | +| `chore/476-sync-main-to-dev-before-repair-wave` | 0 | 0 | Already contained in dev and main | +| `chore/517-sync-v1-0-39` | 0 | 0 | Already contained in dev and main | +| `chore/543-stable-release` | 3 | 0 | Contained in main; restored to dev through main ancestry | +| `chore/554-dag-release` | 7 | 0 | Contained in main; restored to dev through main ancestry | +| `chore/refresh-specgit-harness` | 0 | 0 | Already contained in dev and main | +| `chore/sync-main-to-dev` | 0 | 0 | Already contained in dev and main | +| `chore/sync-main-to-dev-before-repair-wave` | 0 | 0 | Already contained in dev and main | +| `ci/519-auto-close-linked` | 1 | 1 | Historical binding only; no missing implementation | +| `codex/dag-audit-base` | 0 | 0 | Already contained in dev and main | +| `codex/dag-release-staging` | 0 | 0 | Already contained in dev and main | +| `codex/hooks-runtime-reliability` | 4 | 0 | Contained in main; restored to dev through main ancestry | +| `dev` | 0 | 0 | Already contained in dev and main | +| `docs/460-release-notes-v1-0-35` | 1 | 1 | Superseded delivery-record tracking policy | +| `docs/470-release-v1-0-37-notes` | 0 | 0 | Already contained in dev and main | +| `docs/477-remove-legacy-artifacts` | 0 | 0 | Already contained in dev and main | +| `docs/510-release-notes-v1-0-38` | 0 | 0 | Already contained in dev and main | +| `docs/release-notes-v1.0.34` | 0 | 0 | Already contained in dev and main | +| `docs/release-notes-v1.0.35` | 0 | 0 | Already contained in dev and main | +| `feat/433-issue433` | 0 | 0 | Already contained in dev and main | +| `feat/433-shell-silence-guard` | 0 | 0 | Already contained in dev and main | +| `feat/435-dag-project-discovery` | 0 | 0 | Already contained in dev and main | +| `feat/458-giant-summary-guard` | 0 | 0 | Already contained in dev and main | +| `feat/468-replan-topology-refresh` | 0 | 0 | Already contained in dev and main | +| `feat/472-issue472` | 0 | 0 | Already contained in dev and main | +| `feat/475-sdk-openapi-race` | 1 | 1 | Historical binding only; no missing implementation | +| `feat/477-issue477` | 0 | 0 | Already contained in dev and main | +| `feat/498-macos-integrity-boundary` | 0 | 0 | Already contained in dev and main | +| `feat/519-auto-close-linked` | 0 | 0 | Already contained in dev and main | +| `feat/521-specgit-bootstrap-wrapper` | 0 | 0 | Already contained in dev and main | +| `feat/524-event-retention-reclamation` | 0 | 0 | Already contained in dev and main | +| `feat/528-specgit-pr-base-dev` | 0 | 0 | Already contained in dev and main | +| `feat/529-specgit-branch-type-preflight` | 0 | 0 | Already contained in dev and main | +| `feat/530-specgit-bootstrap-rollback` | 0 | 0 | Already contained in dev and main | +| `feat/541-preserve-npm-lock` | 0 | 0 | Already contained in dev and main | +| `feat/dag-project-discovery` | 0 | 0 | Already contained in dev and main | +| `feat/shell-silence-guard` | 0 | 0 | Already contained in dev and main | +| `feat/sync-upstream` | 0 | 0 | Already contained in dev and main | +| `feat/sync-upstream-dev` | 0 | 0 | Already contained in dev and main | +| `fix/440-assemble-google-key` | 2 | 2 | Historical binding only; no missing implementation | +| `fix/465-tui-crashes-on` | 0 | 0 | Already contained in dev and main | +| `fix/474-stabilize-dirty-worktree` | 0 | 0 | Already contained in dev and main | +| `fix/475-sdk-openapi-race` | 0 | 0 | Already contained in dev and main | +| `fix/500-hook-command-grandchildren` | 0 | 0 | Already contained in dev and main | +| `fix/506-accept-builtin-spec` | 0 | 0 | Already contained in dev and main | +| `fix/538-native-tool-settlement` | 0 | 0 | Already contained in dev and main | +| `fix/545-dag-audit-reliability` | 0 | 0 | Already contained in dev and main | +| `fix/557-hooks-runtime-reliability` | 14 | 0 | Contained in main; restored to dev through main ancestry | +| `fix/568-goal-reliability` | 22 | 5 | Integrate Goal source and its main ancestry | +| `fix/dirty-worktree-classify` | 0 | 0 | Already contained in dev and main | +| `fix/goal-reliability` | 17 | 0 | Contained in main; restored to dev through main ancestry | +| `fix/httpapi-composite-exit` | 0 | 0 | Already contained in dev and main | +| `fix/sdk-openapi-race` | 0 | 0 | Already contained in dev and main | +| `fix/specgit-harness-assets` | 0 | 0 | Already contained in dev and main | +| `fix/tui-jsx-runtime` | 0 | 0 | Already contained in dev and main | +| `main` | 4 | 0 | Contained in main; restored to dev through main ancestry | +| `merge-scratch/probe-upstream-dev` | 0 | 0 | Already contained in dev and main | +| `opencode/brave-panda` | 0 | 0 | Already contained in dev and main | +| `opencode/calm-falcon` | 0 | 0 | Already contained in dev and main | +| `opencode/calm-island` | 0 | 0 | Already contained in dev and main | +| `opencode/cosmic-eagle` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/crisp-comet` | 0 | 0 | Already contained in dev and main | +| `opencode/eager-orchid` | 0 | 0 | Already contained in dev and main | +| `opencode/gentle-wizard` | 0 | 0 | Already contained in dev and main | +| `opencode/glowing-meadow` | 0 | 0 | Already contained in dev and main | +| `opencode/happy-engine` | 0 | 0 | Already contained in dev and main | +| `opencode/happy-otter` | 0 | 0 | Already contained in dev and main | +| `opencode/hidden-engine` | 0 | 0 | Already contained in dev and main | +| `opencode/hidden-garden` | 0 | 0 | Already contained in dev and main | +| `opencode/hidden-river` | 0 | 0 | Already contained in dev and main | +| `opencode/kind-mountain` | 0 | 0 | Already contained in dev and main | +| `opencode/kind-pixel` | 0 | 0 | Already contained in dev and main | +| `opencode/misty-moon` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/neon-garden` | 0 | 0 | Already contained in dev and main | +| `opencode/neon-moon` | 0 | 0 | Already contained in dev and main | +| `opencode/nimble-otter` | 0 | 0 | Already contained in dev and main | +| `opencode/nimble-panda` | 0 | 0 | Already contained in dev and main | +| `opencode/playful-knight` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/playful-nebula` | 0 | 0 | Already contained in dev and main | +| `opencode/playful-planet` | 0 | 0 | Already contained in dev and main | +| `opencode/proud-cabin` | 0 | 0 | Already contained in dev and main | +| `opencode/proud-pixel` | 0 | 0 | Already contained in dev and main | +| `opencode/quick-canyon` | 0 | 0 | Already contained in dev and main | +| `opencode/quiet-panda` | 0 | 0 | Already contained in dev and main | +| `opencode/quiet-pixel` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/quiet-river` | 0 | 0 | Already contained in dev and main | +| `opencode/silent-otter` | 0 | 0 | Already contained in dev and main | +| `opencode/silent-tiger` | 0 | 0 | Already contained in dev and main | +| `opencode/stellar-circuit` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/sunny-canyon` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/sunny-comet` | 0 | 0 | Already contained in dev and main | +| `opencode/sunny-garden` | 0 | 0 | Already contained in dev and main | +| `opencode/swift-forest` | 0 | 0 | Already contained in dev and main | +| `opencode/swift-island` | 0 | 0 | Already contained in dev and main | +| `opencode/swift-otter` | 0 | 0 | Already contained in dev and main | +| `opencode/tidy-panda` | 0 | 0 | Already contained in dev and main | +| `opencode/witty-forest` | 0 | 0 | Already contained in dev and main | +| `test/512-leftover-hardening` | 0 | 0 | Already contained in dev and main | +| `test/git-head-watcher` | 0 | 0 | Already contained in dev and main | +| `tmp/lane-473-base-pointer` | 0 | 0 | Already contained in dev and main | +| `origin` | 17 | 0 | Contained in main; restored to dev through main ancestry | +| `origin/dev` | 0 | 0 | Already contained in dev and main | +| `origin/feat/473-git-head-watcher` | 2 | 2 | Historical binding only; no missing implementation | +| `origin/feat/475-sdk-openapi-race` | 0 | 0 | Already contained in dev and main | +| `origin/fix/423-open-security-alerts` | 0 | 0 | Already contained in dev and main | +| `origin/fix/440-assemble-google-key` | 2 | 2 | Historical binding only; no missing implementation | +| `origin/fix/568-goal-reliability` | 21 | 4 | Integrate Goal source and its main ancestry | +| `origin/main` | 17 | 0 | Contained in main; restored to dev through main ancestry | + +## Verification + +Before merging, verify candidate ancestry for `9a651339b1`, `b37b3368ac`, `b56d931b28`, and `0c46896bfe`; require current-head CI and SpecGit acceptance. After dev integration and main promotion, verify those same required commits are ancestors of both remote branches. The actual published tag and assets are verified after the official release workflow finishes. diff --git a/.github/releases/v1.0.42.md b/.github/releases/v1.0.42.md new file mode 100644 index 000000000..9b0f1d0ab --- /dev/null +++ b/.github/releases/v1.0.42.md @@ -0,0 +1,52 @@ +## opencode {VERSION} + +{Prerelease/Stable} release from `{branch}` branch. Goal execution now preserves user intent across concurrent turns, and Hooks enforce their execution, decision, and cancellation contracts. + +--- + +### 🎯 Features + +- **Goal turn budgets**: use `/goal --max-turns N <objective>` to set a total budget and `/goal resume --max-turns N` to increase it while preserving turns already used ([#572](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/572)). + +--- + +### 🐛 Bug Fixes + +- **Goal verdict races**: serialize judgment commits with user, loop, shell, and control-command admission; recheck the current message boundary, Goal revision, and automation lease before accepting a delayed result ([#568](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/568)). +- **Uninterrupted Goal progress**: autonomous questions return without waiting for interactive input; ordinary user turns keep normal questions. Repeated status queries and subgoal edits no longer replace work boundaries, consume extra turns, or strand a continuation ([#569](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/569), [#570](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/570)). +- **Production Goal commands and reconnects**: explicitly provide Goal to the production prompt runtime. Preserve the last known Goal after a reconnect error, clearing it only on a confirmed 404 ([#571](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/571), [#575](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/575)). +- **Hooks enforcement and feedback**: honor prompt stop/block and permission denials, retain valid siblings after malformed configuration, preserve feedback from failed tools and MCP calls, and claim one-shot hooks atomically ([PR #567](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/567)). +- **Hooks lifecycle**: constrain inspection commands to validated read-only operations, propagate timeout/cancellation to child work, preserve interrupted Bash output, and carry file-change events and asynchronous rewake across runtime boundaries ([PR #567](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/567)). + +--- + +### ⚙️ CI / Engineering + +- **PTY environment verification**: wait for the output assertion before allowing the shell to exit, removing the fixed five-second connection window. A six-second delayed connection passes with the new handshake ([#574](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/574)). +- **Branch reconciliation**: review all 100 existing local branches and seven origin branches. Restore the 17 main-only commits to dev alongside the Goal delivery. Remaining historical differences are obsolete delivery metadata or an already-integrated HttpAPI fix; see the candidate branch audit ([#576](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/576)). + +--- + +### 🧪 Test Summary + +``` +opencode suite: 4571 pass, 0 fail, 23 existing skips, 1 existing todo +other workspaces: 1760 pass, 0 fail +Goal TUI reconnect: 6 pass, 0 fail +DAG coverage gate: 820 pass, all existing per-file floors met +typecheck: 29/29 packages green +lint: 4830 warnings, 0 errors, unchanged 4850 cap +HttpAPI composite: 3 x 230 pass, 0 fail, 0 skip, 0 missing +``` + +--- + +### 🔍 Verification + +Local verification used Bun 1.3.14. Real AppRuntime and SessionPrompt/GoalLoop integration tests cover production dependency wiring, delayed done/blocked/continue verdicts, 25 consecutive status queries, subgoal edits, concurrent user/loop/shell admission, and both autonomous and interactive questions. The final dependency grouping was separately rechecked with 88 passing production/prompt tests and the workspace typechecks. Standards and Spec reviews found no remaining blockers. + +The release candidate includes the already-accepted Hooks delivery from [PR #567](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/567). Each integration and promotion is gated by current-head CI and SpecGit acceptance. The official release workflow validates the reference templates against the releasing runtime, builds Linux/macOS/Windows archives, verifies macOS installation behavior, and publishes SHA256SUMS with the release assets. Live external model providers and MCP services are not claimed as validated. + +--- + +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) From 8a6340825a68cfd9ce3895deb6057bf2a644dd93 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 15:03:46 +0800 Subject: [PATCH 21/25] chore: record delivery binding for stable-release-1042 --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 604859f4c..47dcd2911 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -13,4 +13,5 @@ issues: - 574 - 575 - 576 + - 578 pr: 577 From 35f178118046b9aa9d89922871172a6da79d48d6 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 15:18:01 +0800 Subject: [PATCH 22/25] ci: reuse platform-verified content across SpecGit record updates --- .../actions/record-verification/action.yml | 26 +++++ .github/actions/verified-content/action.yml | 54 ++++++++++ .github/releases/v1.0.42-branch-audit.md | 2 +- .github/releases/v1.0.42.md | 2 + .github/workflows/ci-test.yml | 58 ++++++++-- .github/workflows/ci-typecheck.yml | 24 +++++ script/ci-evidence.mjs | 102 ++++++++++++++++++ script/ci-evidence.test.mjs | 88 +++++++++++++++ script/ci-fingerprint.mjs | 35 ++++++ script/ci-fingerprint.test.mjs | 82 ++++++++++++++ 10 files changed, 464 insertions(+), 9 deletions(-) create mode 100644 .github/actions/record-verification/action.yml create mode 100644 .github/actions/verified-content/action.yml create mode 100644 script/ci-evidence.mjs create mode 100644 script/ci-evidence.test.mjs create mode 100644 script/ci-fingerprint.mjs create mode 100644 script/ci-fingerprint.test.mjs diff --git a/.github/actions/record-verification/action.yml b/.github/actions/record-verification/action.yml new file mode 100644 index 000000000..4f2a2588b --- /dev/null +++ b/.github/actions/record-verification/action.yml @@ -0,0 +1,26 @@ +name: Record successful verification +description: Save evidence after every verification step in the caller has succeeded +inputs: + key: + description: Exact evidence key from verified-content + required: true + path: + description: Evidence path from verified-content + required: true + job-name: + description: Exact GitHub job name used to locate the authoritative result + required: true +runs: + using: composite + steps: + - name: Record verification source + shell: bash + env: + EVIDENCE: ${{ inputs.path }} + CHECK_JOB: ${{ inputs.job-name }} + run: node script/ci-evidence.mjs record + - name: Save successful verification + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ inputs.path }} + key: ${{ inputs.key }} diff --git a/.github/actions/verified-content/action.yml b/.github/actions/verified-content/action.yml new file mode 100644 index 000000000..5539a8a21 --- /dev/null +++ b/.github/actions/verified-content/action.yml @@ -0,0 +1,54 @@ +name: Previously verified content +description: Restore exact successful verification evidence, excluding only the SpecGit delivery record +inputs: + check: + description: Stable job identifier, separate for every verification suite + required: true + job-name: + description: Exact GitHub job name whose successful conclusion must be confirmed + required: true +outputs: + reused: + value: ${{ steps.verify.outputs.reused }} + description: Whether this exact content has already passed this suite today + key: + value: ${{ steps.key.outputs.value }} + description: Exact evidence key + path: + value: ${{ steps.key.outputs.path }} + description: Evidence file path +runs: + using: composite + steps: + - name: Fingerprint verification inputs + id: content + shell: bash + run: node script/ci-fingerprint.mjs + - name: Identify verification evidence + id: key + shell: bash + env: + CHECK: ${{ inputs.check }} + FINGERPRINT: ${{ steps.content.outputs.fingerprint }} + IMAGE: ${{ steps.content.outputs.image }} + DAY: ${{ steps.content.outputs.day }} + run: | + echo "value=verified-v2-${RUNNER_OS}-${RUNNER_ARCH}-${IMAGE}-${DAY}-${CHECK}-${FINGERPRINT}" >> "$GITHUB_OUTPUT" + echo "path=${RUNNER_TEMP}/verified-${CHECK}.txt" >> "$GITHUB_OUTPUT" + - name: Restore successful verification + id: restore + if: github.event_name != 'workflow_dispatch' + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ steps.key.outputs.path }} + key: ${{ steps.key.outputs.value }} + - name: Verify the source job and product content + id: verify + if: steps.restore.outputs.cache-hit == 'true' + shell: bash + env: + EVIDENCE: ${{ steps.key.outputs.path }} + CHECK_JOB: ${{ inputs.job-name }} + PRODUCT_FINGERPRINT: ${{ steps.content.outputs.fingerprint }} + GH_TOKEN: ${{ github.token }} + run: node script/ci-evidence.mjs verify diff --git a/.github/releases/v1.0.42-branch-audit.md b/.github/releases/v1.0.42-branch-audit.md index e24099f5d..895bf84ab 100644 --- a/.github/releases/v1.0.42-branch-audit.md +++ b/.github/releases/v1.0.42-branch-audit.md @@ -8,7 +8,7 @@ The original main worktree contains uncommitted local documentation and SpecGit - `origin/dev` was `345e5c13e7bdce2c739bace6327a896ebed88075`, 17 commits behind `origin/main` (`b37b3368accb2d0cc6aecc4f576581f97900d97b`). The main history includes accepted Hooks fixes and v1.0.40/v1.0.41 release records. - Goal source `9a651339b1df92e0739a9653e7f08f4c807f7a58` adds the Goal runtime, concurrency, command, budget, reconnect, and PTY regression repairs tracked by PR #573. -- Candidate PR #577 preserves that source commit as an ancestor, includes the main history, and retains all seven Goal issue closing references plus #576. Product content after the integration merge is identical to the verified Goal source; subsequent candidate changes are release documentation. +- Candidate PR #577 preserves that source commit as an ancestor, includes the main history, and retains all seven Goal issue closing references plus #576 and the requested CI optimization #578. Product content after the integration merge is identical to the verified Goal source; subsequent candidate changes add release documentation and exact-content CI evidence reuse for SpecGit record updates. ## Historical differences diff --git a/.github/releases/v1.0.42.md b/.github/releases/v1.0.42.md index 9b0f1d0ab..b9f35d815 100644 --- a/.github/releases/v1.0.42.md +++ b/.github/releases/v1.0.42.md @@ -22,6 +22,8 @@ ### ⚙️ CI / Engineering +- **SpecGit record updates**: reuse successful verification for identical product content, suite, platform, runner image, and UTC day. Only the root `.specgit.yaml` record is excluded; source, policy, workflow, dependency, and documentation changes remain inputs. Cache misses and manual runs execute the full checks ([#578](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/578)). + - **PTY environment verification**: wait for the output assertion before allowing the shell to exit, removing the fixed five-second connection window. A six-second delayed connection passes with the new handshake ([#574](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/574)). - **Branch reconciliation**: review all 100 existing local branches and seven origin branches. Restore the 17 main-only commits to dev alongside the Goal delivery. Remaining historical differences are obsolete delivery metadata or an already-integrated HttpAPI fix; see the candidate branch audit ([#576](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/576)). diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index d801a2193..bc7945675 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -42,6 +42,7 @@ concurrency: permissions: contents: read + actions: read checks: write env: @@ -72,19 +73,28 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Check previously verified content + id: evidence + uses: ./.github/actions/verified-content + with: + check: unit + job-name: Unit Tests (${{ matrix.settings.name }}) + - name: Setup Node + if: steps.evidence.outputs.reused != 'true' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" - name: Setup Go - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: config_assistant/go.mod cache-dependency-path: config_assistant/go.sum - name: Setup Bun + if: steps.evidence.outputs.reused != 'true' uses: ./.github/actions/setup-bun with: # ci-typecheck.yml's Linux job is the designated Linux cache @@ -93,17 +103,20 @@ jobs: save-cache: false - name: Configure Git Identity + if: steps.evidence.outputs.reused != 'true' run: | git config --global user.email "bot@opencode.ai" git config --global user.name "opencode" - name: Install ripgrep + if: steps.evidence.outputs.reused != 'true' # tool.glob tests hit ripgrep; without the system binary, binary.ts # downloads rg from GitHub releases every run (temp XDG per preload), # gambling on network stability — ECONNRESET fails the test. run: sudo apt-get update && sudo apt-get install -y ripgrep - name: Cache Turbo + if: steps.evidence.outputs.reused != 'true' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: node_modules/.cache/turbo @@ -113,6 +126,7 @@ jobs: turbo-${{ runner.os }}- - name: Run unit tests + if: steps.evidence.outputs.reused != 'true' # opencode:test alone (244 files / 3048 tests, many spawning real CLI # subprocesses via cliIt) measured at 803s / 13m24s locally, and this # job has been seen at 16-17m in CI. turbo buffers a concurrent @@ -126,22 +140,22 @@ jobs: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} - name: Run config assistant tests - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') working-directory: config_assistant run: go test ./... - name: Check generated client - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') working-directory: packages/client run: bun run check:generated - name: Check generated SDK - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') working-directory: packages/sdk/js run: bun run check:generated - name: Run HttpAPI Exerciser Gates - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') working-directory: packages/opencode # The exerciser aggregates its report and prints it in one flush, so # this step is silent while it runs. A latent uninterruptible hang @@ -159,6 +173,14 @@ jobs: timeout-minutes: 15 run: bun run test:httpapi:ci + - name: Record successful verification + if: success() && steps.evidence.outputs.reused != 'true' + uses: ./.github/actions/record-verification + with: + key: ${{ steps.evidence.outputs.key }} + path: ${{ steps.evidence.outputs.path }} + job-name: Unit Tests (${{ matrix.settings.name }}) + e2e-tests: name: E2E Tests (${{ matrix.settings.name }}) strategy: @@ -181,13 +203,22 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Check previously verified content + id: evidence + uses: ./.github/actions/verified-content + with: + check: e2e + job-name: E2E Tests (${{ matrix.settings.name }}) + - name: Setup Node + if: steps.evidence.outputs.reused != 'true' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: # Playwright 1.59 hangs while extracting Chromium with Node 24.16. node-version: "24.15" - name: Setup Bun + if: steps.evidence.outputs.reused != 'true' uses: ./.github/actions/setup-bun with: # Only Windows saves (sole Windows job). Linux e2e skips — @@ -196,12 +227,14 @@ jobs: save-cache: ${{ matrix.settings.name == 'windows' }} - name: Read Playwright version + if: steps.evidence.outputs.reused != 'true' id: playwright-version run: | version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])') echo "version=$version" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers + if: steps.evidence.outputs.reused != 'true' id: playwright-cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: @@ -209,23 +242,24 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium - name: Install Playwright system dependencies - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') working-directory: packages/app run: bunx playwright install-deps chromium - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' + if: steps.evidence.outputs.reused != 'true' && (steps.playwright-cache.outputs.cache-hit != 'true') working-directory: packages/app run: bunx playwright install chromium - name: Run app e2e tests + if: steps.evidence.outputs.reused != 'true' run: bun --cwd packages/app test:e2e:local env: CI: true timeout-minutes: 30 - name: Upload Playwright artifacts - if: always() + if: steps.evidence.outputs.reused != 'true' && (always()) uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }} @@ -234,3 +268,11 @@ jobs: path: | packages/app/e2e/test-results packages/app/e2e/playwright-report + + - name: Record successful verification + if: success() && steps.evidence.outputs.reused != 'true' + uses: ./.github/actions/record-verification + with: + key: ${{ steps.evidence.outputs.key }} + path: ${{ steps.evidence.outputs.path }} + job-name: E2E Tests (${{ matrix.settings.name }}) diff --git a/.github/workflows/ci-typecheck.yml b/.github/workflows/ci-typecheck.yml index 5ac01932b..2302cd191 100644 --- a/.github/workflows/ci-typecheck.yml +++ b/.github/workflows/ci-typecheck.yml @@ -32,6 +32,7 @@ on: permissions: contents: read + actions: read jobs: typecheck: @@ -41,16 +42,30 @@ jobs: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Check previously verified content + id: evidence + uses: ./.github/actions/verified-content + with: + check: typecheck + job-name: Typecheck + - name: Setup Bun + if: steps.evidence.outputs.reused != 'true' uses: ./.github/actions/setup-bun + - name: Test CI verification fingerprint + run: node --test script/ci-fingerprint.test.mjs script/ci-evidence.test.mjs + - name: Run lint + if: steps.evidence.outputs.reused != 'true' run: bun run lint - name: Run typecheck + if: steps.evidence.outputs.reused != 'true' run: bun typecheck - name: Run DAG core behavior and coverage gate + if: steps.evidence.outputs.reused != 'true' working-directory: packages/opencode run: bun run test:dag-core timeout-minutes: 10 @@ -59,5 +74,14 @@ jobs: # must be verified before extraction and fail closed on mismatch. # Zero network (stub curl); portable across bash hosts. - name: Run oc install boundary tests + if: steps.evidence.outputs.reused != 'true' run: bash script/oc-install-boundary.test.sh timeout-minutes: 5 + + - name: Record successful verification + if: success() && steps.evidence.outputs.reused != 'true' + uses: ./.github/actions/record-verification + with: + key: ${{ steps.evidence.outputs.key }} + path: ${{ steps.evidence.outputs.path }} + job-name: Typecheck diff --git a/script/ci-evidence.mjs b/script/ci-evidence.mjs new file mode 100644 index 000000000..15e154057 --- /dev/null +++ b/script/ci-evidence.mjs @@ -0,0 +1,102 @@ +import { execFileSync } from "node:child_process" +import { appendFileSync, readFileSync, writeFileSync } from "node:fs" +import { pathToFileURL } from "node:url" +import { fingerprint } from "./ci-fingerprint.mjs" + +export function verifyEvidence(locator, expected, source) { + if (!Number.isSafeInteger(locator.run) || locator.run <= 0) return false + if (!Number.isSafeInteger(locator.attempt) || locator.attempt <= 0) return false + if (locator.job !== expected.job) return false + const run = source.run(locator.run, locator.attempt) + if (run.id !== locator.run || run.run_attempt !== locator.attempt || run.path !== expected.workflow) return false + if (!/^[a-f0-9]{40}$/.test(run.head_sha)) return false + if (!["push", "pull_request", "workflow_dispatch"].includes(run.event)) return false + const jobs = source.jobs(locator.run, locator.attempt).filter((job) => job.name === expected.job) + if (jobs.length !== 1 || jobs[0].status !== "completed" || jobs[0].conclusion !== "success") return false + if (run.event === "pull_request") { + // A divergent PR tests a synthetic merge tree. Without immutable evidence + // of that tree, run the suite again instead of attributing it to its head. + if (run.pull_requests?.length !== 1) return false + const pr = run.pull_requests[0] + if (pr.head?.sha !== run.head_sha || !/^[a-f0-9]{40}$/.test(pr.base?.sha)) return false + if (!source.contains(run.head_sha, pr.base.sha)) return false + } + return source.fingerprint(run.head_sha) === expected.fingerprint +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const path = process.env.EVIDENCE + if (process.argv[2] === "record") { + writeFileSync( + path, + JSON.stringify({ + run: Number(process.env.GITHUB_RUN_ID), + attempt: Number(process.env.GITHUB_RUN_ATTEMPT), + job: process.env.CHECK_JOB, + }), + ) + } else { + let reused = false + try { + const repo = process.env.GITHUB_REPOSITORY + const api = (path) => + JSON.parse( + execFileSync("gh", ["api", `repos/${repo}/${path}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }), + ) + const fetched = new Set() + const fetch = (sha) => { + if (fetched.has(sha)) return + execFileSync("git", ["fetch", "--no-tags", "--depth=256", "origin", sha], { + stdio: ["ignore", "pipe", "pipe"], + }) + fetched.add(sha) + } + reused = verifyEvidence( + JSON.parse(readFileSync(path, "utf8")), + { + job: process.env.CHECK_JOB, + workflow: process.env.GITHUB_WORKFLOW_REF.split("@")[0].slice(repo.length + 1), + fingerprint: process.env.PRODUCT_FINGERPRINT, + }, + { + run: (run, attempt) => api(`actions/runs/${run}/attempts/${attempt}`), + jobs: (run, attempt) => { + const jobs = [] + for (let page = 1; ; page++) { + const result = api(`actions/runs/${run}/attempts/${attempt}/jobs?per_page=100&page=${page}`) + jobs.push(...result.jobs) + if (result.jobs.length < 100) return jobs + } + }, + contains: (head, base) => { + fetch(head) + fetch(base) + try { + execFileSync("git", ["merge-base", "--is-ancestor", base, head], { stdio: "pipe" }) + return true + } catch { + return false + } + }, + fingerprint: (sha) => { + fetch(sha) + return fingerprint(sha) + }, + }, + ) + } catch { + console.log("Previous verification could not be proven; running the full suite.") + } + appendFileSync(process.env.GITHUB_OUTPUT, `reused=${reused}\n`) + if (reused) { + const locator = JSON.parse(readFileSync(path, "utf8")) + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `### Reused successful product verification\n\nGitHub confirms the source job completed successfully and its product tree matches this checkout. Only the ordinary root SpecGit record is excluded. Suite, platform, runner image, and UTC day match.\n\nSource: ${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${locator.run}/attempts/${locator.attempt}\n`, + ) + } + } +} diff --git a/script/ci-evidence.test.mjs b/script/ci-evidence.test.mjs new file mode 100644 index 000000000..c6669b53a --- /dev/null +++ b/script/ci-evidence.test.mjs @@ -0,0 +1,88 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { verifyEvidence } from "./ci-evidence.mjs" + +const head = "a".repeat(40) +const base = "b".repeat(40) +function evidence() { + const locator = { run: 12, attempt: 2, job: "Unit Tests (linux)" } + const expected = { job: locator.job, workflow: ".github/workflows/ci-test.yml", fingerprint: "tree-b" } + const run = { id: 12, run_attempt: 2, path: expected.workflow, head_sha: head, event: "push" } + const jobs = [{ name: locator.job, status: "completed", conclusion: "success" }] + const source = { + run: () => run, + jobs: () => jobs, + contains: () => true, + fingerprint: (sha) => { + assert.equal(sha, head) + return "tree-b" + }, + } + return { locator, expected, run, jobs, source } +} + +test("exact successful job and platform-associated source can be reused", () => { + const e = evidence() + assert.equal(verifyEvidence(e.locator, e.expected, e.source), true) +}) + +for (const conclusion of ["failure", "cancelled", "timed_out", "skipped", null]) { + test(`source conclusion ${conclusion} cannot provide success evidence`, () => { + const e = evidence() + e.jobs[0].conclusion = conclusion + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + }) +} + +test("a still-running job cannot lend its early saved locator", () => { + const e = evidence() + e.jobs[0].status = "in_progress" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("a forged cache key for B pointing to a successful A tree is rejected", () => { + const e = evidence() + e.source.fingerprint = () => "tree-a" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("workflow and attempt identities cannot be borrowed", () => { + const e = evidence() + e.run.path = ".github/workflows/other.yml" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.run.path = e.expected.workflow + e.run.run_attempt = 1 + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("missing, ambiguous, or mismatched jobs are rejected", () => { + const e = evidence() + e.locator.job = "Typecheck" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.locator.job = e.expected.job + e.jobs.push({ ...e.jobs[0] }) + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.jobs.length = 0 + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("PR evidence requires proof that its tested merge tree equals its head tree", () => { + const e = evidence() + e.run.event = "pull_request" + e.run.pull_requests = [{ head: { sha: head }, base: { sha: base } }] + assert.equal(verifyEvidence(e.locator, e.expected, e.source), true) + e.source.contains = () => false + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.source.contains = () => true + e.run.pull_requests[0].head.sha = base + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("malformed source identities fail closed", () => { + const e = evidence() + e.locator.run = "12" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.locator.run = 12 + e.run.head_sha = "untrusted-revision" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) diff --git a/script/ci-fingerprint.mjs b/script/ci-fingerprint.mjs new file mode 100644 index 000000000..f9a69bdb7 --- /dev/null +++ b/script/ci-fingerprint.mjs @@ -0,0 +1,35 @@ +import { execFileSync } from "node:child_process" +import { createHash } from "node:crypto" +import { appendFileSync } from "node:fs" +import { pathToFileURL } from "node:url" + +// Hash Git objects, including modes and submodules, without checkout or newline +// normalization. Only the ordinary root delivery record is not a product input. +export function fingerprint(ref = "HEAD", cwd = process.cwd()) { + const tree = execFileSync("git", ["ls-tree", "-rz", "--full-tree", ref], { cwd }) + const hash = createHash("sha256") + let start = 0 + for (let end = 0; end < tree.length; end++) { + if (tree[end] !== 0) continue + const entry = tree.subarray(start, end) + const tab = entry.indexOf(9) + if (tab < 0) throw new Error("Invalid Git tree entry") + const record = entry.subarray(tab + 1).equals(Buffer.from(".specgit.yaml")) + if (!record || !entry.subarray(0, 12).equals(Buffer.from("100644 blob "))) { + hash.update(entry) + hash.update(Buffer.from([0])) + } + start = end + 1 + } + return hash.digest("hex") +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const value = fingerprint() + const day = new Date().toISOString().slice(0, 10) + const image = [process.env.ImageOS, process.env.ImageVersion].filter(Boolean).join("-") || "unknown" + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `fingerprint=${value}\nimage=${image}\nday=${day}\n`) + } + console.log(value) +} diff --git a/script/ci-fingerprint.test.mjs b/script/ci-fingerprint.test.mjs new file mode 100644 index 000000000..dd5b3c26c --- /dev/null +++ b/script/ci-fingerprint.test.mjs @@ -0,0 +1,82 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fingerprint } from "./ci-fingerprint.mjs" + +function repository(t) { + const cwd = mkdtempSync(join(tmpdir(), "ci-fingerprint-")) + t.after(() => rmSync(cwd, { recursive: true, force: true })) + const git = (...args) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim() + git("init", "--quiet") + const write = (path, content) => { + mkdirSync(join(cwd, path, ".."), { recursive: true }) + writeFileSync(join(cwd, path), content) + } + const snapshot = () => { + git("add", "--all") + return fingerprint(git("write-tree"), cwd) + } + write("product.ts", "export const answer = 42\n") + return { cwd, git, write, snapshot } +} + +test("record creation, updates, and deletion reuse unchanged product evidence", (t) => { + const repo = repository(t) + const baseline = repo.snapshot() + repo.write(".specgit.yaml", "issues: [1]\n") + assert.equal(repo.snapshot(), baseline) + repo.write(".specgit.yaml", "issues: [2, 3]\n") + assert.equal(repo.snapshot(), baseline) + rmSync(join(repo.cwd, ".specgit.yaml")) + assert.equal(repo.snapshot(), baseline) +}) + +for (const path of [ + "product.ts", + "bun.lock", + "spec_git/policy.yaml", + ".github/workflows/ci-test.yml", + "README.md", + "nested/.specgit.yaml", + "file\twith\nwhitespace", +]) { + test(`${JSON.stringify(path)} changes invalidate verification`, (t) => { + const repo = repository(t) + const before = repo.snapshot() + repo.write(path, "changed\n") + repo.write(".specgit.yaml", "issues: [4]\n") + assert.notEqual(repo.snapshot(), before) + }) +} + +test("file modes, symlinks, and submodule revisions remain inputs", (t) => { + const repo = repository(t) + const baseline = repo.snapshot() + repo.git("update-index", "--chmod=+x", "product.ts") + assert.notEqual(fingerprint(repo.git("write-tree"), repo.cwd), baseline) + const first = repo.git("hash-object", "-w", "product.ts") + repo.write("other.ts", "other content\n") + const second = repo.git("hash-object", "-w", "other.ts") + for (const mode of ["120000", "160000"]) { + repo.git("update-index", "--add", "--cacheinfo", `${mode},${first},linked`) + const before = fingerprint(repo.git("write-tree"), repo.cwd) + repo.git("update-index", "--cacheinfo", `${mode},${second},linked`) + assert.notEqual(fingerprint(repo.git("write-tree"), repo.cwd), before) + } +}) + +test("a delivery record with executable mode is not exempt", (t) => { + const repo = repository(t) + repo.write(".specgit.yaml", "issues: [1]\n") + const baseline = repo.snapshot() + repo.git("update-index", "--chmod=+x", ".specgit.yaml") + assert.notEqual(fingerprint(repo.git("write-tree"), repo.cwd), baseline) +}) + +test("missing revision fails instead of manufacturing reusable evidence", (t) => { + const repo = repository(t) + assert.throws(() => fingerprint("missing-revision", repo.cwd)) +}) From 6d14e9a95326cb3f6004fe8e64611937983f55f0 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 15:20:16 +0800 Subject: [PATCH 23/25] ci: verify source job date and runner before reusing evidence --- .github/actions/verified-content/action.yml | 7 +++-- .github/releases/v1.0.42.md | 4 +-- .github/workflows/ci-test.yml | 2 ++ .github/workflows/ci-typecheck.yml | 1 + script/ci-evidence.mjs | 6 ++++- script/ci-evidence.test.mjs | 30 +++++++++++++++++++-- script/ci-fingerprint.mjs | 3 +-- 7 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.github/actions/verified-content/action.yml b/.github/actions/verified-content/action.yml index 5539a8a21..b4584b1a9 100644 --- a/.github/actions/verified-content/action.yml +++ b/.github/actions/verified-content/action.yml @@ -7,6 +7,9 @@ inputs: job-name: description: Exact GitHub job name whose successful conclusion must be confirmed required: true + runner-label: + description: Runner label which must appear in the source job's platform evidence + required: true outputs: reused: value: ${{ steps.verify.outputs.reused }} @@ -30,10 +33,9 @@ runs: env: CHECK: ${{ inputs.check }} FINGERPRINT: ${{ steps.content.outputs.fingerprint }} - IMAGE: ${{ steps.content.outputs.image }} DAY: ${{ steps.content.outputs.day }} run: | - echo "value=verified-v2-${RUNNER_OS}-${RUNNER_ARCH}-${IMAGE}-${DAY}-${CHECK}-${FINGERPRINT}" >> "$GITHUB_OUTPUT" + echo "value=verified-v3-${RUNNER_OS}-${RUNNER_ARCH}-${DAY}-${CHECK}-${FINGERPRINT}" >> "$GITHUB_OUTPUT" echo "path=${RUNNER_TEMP}/verified-${CHECK}.txt" >> "$GITHUB_OUTPUT" - name: Restore successful verification id: restore @@ -49,6 +51,7 @@ runs: env: EVIDENCE: ${{ steps.key.outputs.path }} CHECK_JOB: ${{ inputs.job-name }} + CHECK_RUNNER: ${{ inputs.runner-label }} PRODUCT_FINGERPRINT: ${{ steps.content.outputs.fingerprint }} GH_TOKEN: ${{ github.token }} run: node script/ci-evidence.mjs verify diff --git a/.github/releases/v1.0.42.md b/.github/releases/v1.0.42.md index b9f35d815..2b127c47a 100644 --- a/.github/releases/v1.0.42.md +++ b/.github/releases/v1.0.42.md @@ -22,7 +22,7 @@ ### ⚙️ CI / Engineering -- **SpecGit record updates**: reuse successful verification for identical product content, suite, platform, runner image, and UTC day. Only the root `.specgit.yaml` record is excluded; source, policy, workflow, dependency, and documentation changes remain inputs. Cache misses and manual runs execute the full checks ([#578](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/578)). +- **SpecGit record updates**: reuse successful verification for identical product content, suite, platform, runner label, and UTC day. Only the root `.specgit.yaml` record is excluded; source, policy, workflow, dependency, and documentation changes remain inputs. Cache misses and manual runs execute the full checks ([#578](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/578)). - **PTY environment verification**: wait for the output assertion before allowing the shell to exit, removing the fixed five-second connection window. A six-second delayed connection passes with the new handshake ([#574](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/574)). - **Branch reconciliation**: review all 100 existing local branches and seven origin branches. Restore the 17 main-only commits to dev alongside the Goal delivery. Remaining historical differences are obsolete delivery metadata or an already-integrated HttpAPI fix; see the candidate branch audit ([#576](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/576)). @@ -37,7 +37,7 @@ other workspaces: 1760 pass, 0 fail Goal TUI reconnect: 6 pass, 0 fail DAG coverage gate: 820 pass, all existing per-file floors met typecheck: 29/29 packages green -lint: 4830 warnings, 0 errors, unchanged 4850 cap +lint: 4842 warnings, 0 errors, unchanged 4850 cap HttpAPI composite: 3 x 230 pass, 0 fail, 0 skip, 0 missing ``` diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index bc7945675..eb7e7df73 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -79,6 +79,7 @@ jobs: with: check: unit job-name: Unit Tests (${{ matrix.settings.name }}) + runner-label: ${{ matrix.settings.host }} - name: Setup Node if: steps.evidence.outputs.reused != 'true' @@ -209,6 +210,7 @@ jobs: with: check: e2e job-name: E2E Tests (${{ matrix.settings.name }}) + runner-label: ${{ matrix.settings.host }} - name: Setup Node if: steps.evidence.outputs.reused != 'true' diff --git a/.github/workflows/ci-typecheck.yml b/.github/workflows/ci-typecheck.yml index 2302cd191..2c426d05c 100644 --- a/.github/workflows/ci-typecheck.yml +++ b/.github/workflows/ci-typecheck.yml @@ -48,6 +48,7 @@ jobs: with: check: typecheck job-name: Typecheck + runner-label: ubuntu-latest - name: Setup Bun if: steps.evidence.outputs.reused != 'true' diff --git a/script/ci-evidence.mjs b/script/ci-evidence.mjs index 15e154057..9c543753c 100644 --- a/script/ci-evidence.mjs +++ b/script/ci-evidence.mjs @@ -13,6 +13,8 @@ export function verifyEvidence(locator, expected, source) { if (!["push", "pull_request", "workflow_dispatch"].includes(run.event)) return false const jobs = source.jobs(locator.run, locator.attempt).filter((job) => job.name === expected.job) if (jobs.length !== 1 || jobs[0].status !== "completed" || jobs[0].conclusion !== "success") return false + if (jobs[0].started_at?.slice(0, 10) !== expected.day) return false + if (!jobs[0].labels?.includes(expected.runner)) return false if (run.event === "pull_request") { // A divergent PR tests a synthetic merge tree. Without immutable evidence // of that tree, run the suite again instead of attributing it to its head. @@ -58,6 +60,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) JSON.parse(readFileSync(path, "utf8")), { job: process.env.CHECK_JOB, + runner: process.env.CHECK_RUNNER, + day: new Date().toISOString().slice(0, 10), workflow: process.env.GITHUB_WORKFLOW_REF.split("@")[0].slice(repo.length + 1), fingerprint: process.env.PRODUCT_FINGERPRINT, }, @@ -95,7 +99,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const locator = JSON.parse(readFileSync(path, "utf8")) appendFileSync( process.env.GITHUB_STEP_SUMMARY, - `### Reused successful product verification\n\nGitHub confirms the source job completed successfully and its product tree matches this checkout. Only the ordinary root SpecGit record is excluded. Suite, platform, runner image, and UTC day match.\n\nSource: ${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${locator.run}/attempts/${locator.attempt}\n`, + `### Reused successful product verification\n\nGitHub confirms the source job completed successfully today on the requested runner label, and its product tree matches this checkout. Only the ordinary root SpecGit record is excluded.\n\nSource: ${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${locator.run}/attempts/${locator.attempt}\n`, ) } } diff --git a/script/ci-evidence.test.mjs b/script/ci-evidence.test.mjs index c6669b53a..16c700f76 100644 --- a/script/ci-evidence.test.mjs +++ b/script/ci-evidence.test.mjs @@ -6,9 +6,23 @@ const head = "a".repeat(40) const base = "b".repeat(40) function evidence() { const locator = { run: 12, attempt: 2, job: "Unit Tests (linux)" } - const expected = { job: locator.job, workflow: ".github/workflows/ci-test.yml", fingerprint: "tree-b" } + const expected = { + job: locator.job, + workflow: ".github/workflows/ci-test.yml", + fingerprint: "tree-b", + day: "2026-09-07", + runner: "ubuntu-latest", + } const run = { id: 12, run_attempt: 2, path: expected.workflow, head_sha: head, event: "push" } - const jobs = [{ name: locator.job, status: "completed", conclusion: "success" }] + const jobs = [ + { + name: locator.job, + status: "completed", + conclusion: "success", + started_at: "2026-09-07T01:00:00Z", + labels: ["ubuntu-latest"], + }, + ] const source = { run: () => run, jobs: () => jobs, @@ -86,3 +100,15 @@ test("malformed source identities fail closed", () => { e.run.head_sha = "untrusted-revision" assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) }) + +test("a forged current-day key cannot reuse an old source job", () => { + const e = evidence() + e.jobs[0].started_at = "2026-09-06T01:00:00Z" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("the source job must use the expected runner label", () => { + const e = evidence() + e.jobs[0].labels = ["windows-latest"] + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) diff --git a/script/ci-fingerprint.mjs b/script/ci-fingerprint.mjs index f9a69bdb7..699e2d3a4 100644 --- a/script/ci-fingerprint.mjs +++ b/script/ci-fingerprint.mjs @@ -27,9 +27,8 @@ export function fingerprint(ref = "HEAD", cwd = process.cwd()) { if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const value = fingerprint() const day = new Date().toISOString().slice(0, 10) - const image = [process.env.ImageOS, process.env.ImageVersion].filter(Boolean).join("-") || "unknown" if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `fingerprint=${value}\nimage=${image}\nday=${day}\n`) + appendFileSync(process.env.GITHUB_OUTPUT, `fingerprint=${value}\nday=${day}\n`) } console.log(value) } From 9cdf8eb4d5ff1383496fbf0ac4c6c732c2f40130 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 15:39:31 +0800 Subject: [PATCH 24/25] ci: bind reusable verification to immutable Actions artifacts --- .../actions/record-verification/action.yml | 14 ++++ .github/actions/verified-content/action.yml | 2 +- script/ci-evidence.mjs | 66 +++++++++++++------ script/ci-evidence.test.mjs | 45 ++++++++++--- 4 files changed, 96 insertions(+), 31 deletions(-) diff --git a/.github/actions/record-verification/action.yml b/.github/actions/record-verification/action.yml index 4f2a2588b..9737fa58a 100644 --- a/.github/actions/record-verification/action.yml +++ b/.github/actions/record-verification/action.yml @@ -13,11 +13,25 @@ inputs: runs: using: composite steps: + - name: Record tested product content + shell: bash + env: + EVIDENCE: ${{ inputs.path }} + run: node script/ci-evidence.mjs proof + - name: Upload immutable verification proof + id: proof + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ci-verification-${{ github.job }}-${{ runner.os }}-${{ runner.arch }}-${{ github.run_attempt }} + path: ${{ inputs.path }}.artifact/verification.json + retention-days: 2 + if-no-files-found: error - name: Record verification source shell: bash env: EVIDENCE: ${{ inputs.path }} CHECK_JOB: ${{ inputs.job-name }} + ARTIFACT_ID: ${{ steps.proof.outputs.artifact-id }} run: node script/ci-evidence.mjs record - name: Save successful verification uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 diff --git a/.github/actions/verified-content/action.yml b/.github/actions/verified-content/action.yml index b4584b1a9..a0f1ddbe3 100644 --- a/.github/actions/verified-content/action.yml +++ b/.github/actions/verified-content/action.yml @@ -35,7 +35,7 @@ runs: FINGERPRINT: ${{ steps.content.outputs.fingerprint }} DAY: ${{ steps.content.outputs.day }} run: | - echo "value=verified-v3-${RUNNER_OS}-${RUNNER_ARCH}-${DAY}-${CHECK}-${FINGERPRINT}" >> "$GITHUB_OUTPUT" + echo "value=verified-v4-${RUNNER_OS}-${RUNNER_ARCH}-${DAY}-${CHECK}-${FINGERPRINT}" >> "$GITHUB_OUTPUT" echo "path=${RUNNER_TEMP}/verified-${CHECK}.txt" >> "$GITHUB_OUTPUT" - name: Restore successful verification id: restore diff --git a/script/ci-evidence.mjs b/script/ci-evidence.mjs index 9c543753c..ac04a020a 100644 --- a/script/ci-evidence.mjs +++ b/script/ci-evidence.mjs @@ -1,11 +1,12 @@ import { execFileSync } from "node:child_process" -import { appendFileSync, readFileSync, writeFileSync } from "node:fs" +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import { pathToFileURL } from "node:url" import { fingerprint } from "./ci-fingerprint.mjs" export function verifyEvidence(locator, expected, source) { if (!Number.isSafeInteger(locator.run) || locator.run <= 0) return false if (!Number.isSafeInteger(locator.attempt) || locator.attempt <= 0) return false + if (!Number.isSafeInteger(locator.artifact) || locator.artifact <= 0) return false if (locator.job !== expected.job) return false const run = source.run(locator.run, locator.attempt) if (run.id !== locator.run || run.run_attempt !== locator.attempt || run.path !== expected.workflow) return false @@ -15,26 +16,31 @@ export function verifyEvidence(locator, expected, source) { if (jobs.length !== 1 || jobs[0].status !== "completed" || jobs[0].conclusion !== "success") return false if (jobs[0].started_at?.slice(0, 10) !== expected.day) return false if (!jobs[0].labels?.includes(expected.runner)) return false - if (run.event === "pull_request") { - // A divergent PR tests a synthetic merge tree. Without immutable evidence - // of that tree, run the suite again instead of attributing it to its head. - if (run.pull_requests?.length !== 1) return false - const pr = run.pull_requests[0] - if (pr.head?.sha !== run.head_sha || !/^[a-f0-9]{40}$/.test(pr.base?.sha)) return false - if (!source.contains(run.head_sha, pr.base.sha)) return false - } - return source.fingerprint(run.head_sha) === expected.fingerprint + const artifact = source.artifact(locator.artifact) + if (artifact.expired || artifact.size_in_bytes > 10000) return false + if (artifact.name !== `${expected.artifactPrefix}-${locator.attempt}`) return false + if (artifact.workflow_run?.id !== run.id || artifact.workflow_run.head_sha !== run.head_sha) return false + // API run.head_sha is immutable; run.pull_requests is live PR data and must + // never stand in for the tree that an earlier run actually checked out. + // Check the trusted workflow/source tree, then its immutable uploaded proof + // of the tested merge tree. A forged cache can only point to these records. + if (source.fingerprint(run.head_sha) !== expected.fingerprint) return false + return source.proof(locator.artifact).fingerprint === expected.fingerprint } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const path = process.env.EVIDENCE - if (process.argv[2] === "record") { + if (process.argv[2] === "proof") { + mkdirSync(`${path}.artifact`, { recursive: true }) + writeFileSync(`${path}.artifact/verification.json`, JSON.stringify({ fingerprint: fingerprint() })) + } else if (process.argv[2] === "record") { writeFileSync( path, JSON.stringify({ run: Number(process.env.GITHUB_RUN_ID), attempt: Number(process.env.GITHUB_RUN_ATTEMPT), job: process.env.CHECK_JOB, + artifact: Number(process.env.ARTIFACT_ID), }), ) } else { @@ -46,13 +52,15 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) execFileSync("gh", ["api", `repos/${repo}/${path}`], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + timeout: 30000, }), ) const fetched = new Set() const fetch = (sha) => { if (fetched.has(sha)) return - execFileSync("git", ["fetch", "--no-tags", "--depth=256", "origin", sha], { + execFileSync("git", ["fetch", "--no-tags", "--depth=1", "origin", sha], { stdio: ["ignore", "pipe", "pipe"], + timeout: 30000, }) fetched.add(sha) } @@ -64,6 +72,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) day: new Date().toISOString().slice(0, 10), workflow: process.env.GITHUB_WORKFLOW_REF.split("@")[0].slice(repo.length + 1), fingerprint: process.env.PRODUCT_FINGERPRINT, + artifactPrefix: `ci-verification-${process.env.GITHUB_JOB}-${process.env.RUNNER_OS}-${process.env.RUNNER_ARCH}`, }, { run: (run, attempt) => api(`actions/runs/${run}/attempts/${attempt}`), @@ -75,15 +84,30 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) if (result.jobs.length < 100) return jobs } }, - contains: (head, base) => { - fetch(head) - fetch(base) - try { - execFileSync("git", ["merge-base", "--is-ancestor", base, head], { stdio: "pipe" }) - return true - } catch { - return false - } + artifact: (id) => api(`actions/artifacts/${id}`), + proof: (id) => { + const zip = execFileSync("gh", ["api", `repos/${repo}/actions/artifacts/${id}/zip`], { + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 65536, + timeout: 30000, + }) + // Python is already part of the hosted runners used by these jobs. + // Read one bounded JSON member in memory; never extract archive paths. + const json = execFileSync( + "python3", + [ + "-c", + "import io,sys,zipfile; z=zipfile.ZipFile(io.BytesIO(sys.stdin.buffer.read())); i=z.getinfo('verification.json'); assert i.file_size <= 1024; sys.stdout.buffer.write(z.read(i))", + ], + { + input: zip, + encoding: "utf8", + maxBuffer: 2048, + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + }, + ) + return JSON.parse(json) }, fingerprint: (sha) => { fetch(sha) diff --git a/script/ci-evidence.test.mjs b/script/ci-evidence.test.mjs index 16c700f76..93e4c87de 100644 --- a/script/ci-evidence.test.mjs +++ b/script/ci-evidence.test.mjs @@ -5,13 +5,14 @@ import { verifyEvidence } from "./ci-evidence.mjs" const head = "a".repeat(40) const base = "b".repeat(40) function evidence() { - const locator = { run: 12, attempt: 2, job: "Unit Tests (linux)" } + const locator = { run: 12, attempt: 2, job: "Unit Tests (linux)", artifact: 34 } const expected = { job: locator.job, workflow: ".github/workflows/ci-test.yml", fingerprint: "tree-b", day: "2026-09-07", runner: "ubuntu-latest", + artifactPrefix: "ci-verification-unit-tests-Linux-X64", } const run = { id: 12, run_attempt: 2, path: expected.workflow, head_sha: head, event: "push" } const jobs = [ @@ -23,16 +24,23 @@ function evidence() { labels: ["ubuntu-latest"], }, ] + const artifact = { + name: `${expected.artifactPrefix}-2`, + size_in_bytes: 180, + expired: false, + workflow_run: { id: 12, head_sha: head }, + } const source = { run: () => run, jobs: () => jobs, - contains: () => true, + artifact: () => artifact, + proof: () => ({ fingerprint: "tree-b" }), fingerprint: (sha) => { assert.equal(sha, head) return "tree-b" }, } - return { locator, expected, run, jobs, source } + return { locator, expected, run, jobs, source, artifact } } test("exact successful job and platform-associated source can be reused", () => { @@ -80,15 +88,16 @@ test("missing, ambiguous, or mismatched jobs are rejected", () => { assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) }) -test("PR evidence requires proof that its tested merge tree equals its head tree", () => { +test("live PR head updates do not overwrite the source run's immutable proof", () => { const e = evidence() e.run.event = "pull_request" - e.run.pull_requests = [{ head: { sha: head }, base: { sha: base } }] + e.run.pull_requests = [{ head: { sha: base }, base: { sha: base } }] assert.equal(verifyEvidence(e.locator, e.expected, e.source), true) - e.source.contains = () => false - assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) - e.source.contains = () => true - e.run.pull_requests[0].head.sha = base +}) + +test("a formerly tested merge tree cannot lend evidence to different current content", () => { + const e = evidence() + e.source.proof = () => ({ fingerprint: "different-merge-tree" }) assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) }) @@ -112,3 +121,21 @@ test("the source job must use the expected runner label", () => { e.jobs[0].labels = ["windows-latest"] assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) }) + +test("an artifact from another run or job cannot be borrowed", () => { + const e = evidence() + e.artifact.workflow_run.id = 99 + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.artifact.workflow_run.id = 12 + e.artifact.name = "ci-verification-e2e-tests-Linux-X64-2" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("expired or unexpectedly large artifacts fall back to full verification", () => { + const e = evidence() + e.artifact.expired = true + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.artifact.expired = false + e.artifact.size_in_bytes = 10001 + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) From de0d9201c5858c9d192b76126c2339a4529770f4 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Mon, 7 Sep 2026 16:12:07 +0800 Subject: [PATCH 25/25] chore: record delivery binding for stable-release-1042 --- .specgit.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 47dcd2911..0d2250db7 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 delivery: stable-release-1042 context: - kind: worktree - label: opencode-release-1042 + kind: branch branch: chore/576-stable-release-1042 issues: - 568