diff --git a/.github/branch-protection-main.json b/.github/branch-protection-main.json index 2a8320c..fc0d977 100644 --- a/.github/branch-protection-main.json +++ b/.github/branch-protection-main.json @@ -1,7 +1,7 @@ { "required_status_checks": { "strict": true, - "contexts": ["ci (ubuntu-latest)", "ci (macos-14)"] + "contexts": ["ci"] }, "enforce_admins": true, "required_pull_request_reviews": { diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 715a283..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,107 +0,0 @@ -version: 2 -updates: - - package-ecosystem: npm - directory: / - schedule: - interval: weekly - day: monday - time: "09:00" - timezone: America/Chicago - open-pull-requests-limit: 3 - labels: - - dependencies - commit-message: - prefix: build - include: scope - ignore: - # Pi-mono is the vendored engine. The release line is the meaningful - # boundary (0.69.x -> 0.70.x), so alert on minor and major. Patches on - # the same release line are noise we absorb in periodic manual sweeps. - - dependency-name: "@mariozechner/pi-agent-core" - update-types: ["version-update:semver-patch"] - - dependency-name: "@mariozechner/pi-ai" - update-types: ["version-update:semver-patch"] - - dependency-name: "@mariozechner/pi-tui" - update-types: ["version-update:semver-patch"] - # @types/node tracks the CI Node version. Bumped manually when the CI - # runner moves; dependabot stays out of it entirely. - - dependency-name: "@types/node" - # Every other dep: alert on major bumps only. Patches and minors are - # absorbed in periodic proactive sweeps so dependabot stays quiet. - - dependency-name: "@anthropic-ai/claude-agent-sdk" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "@biomejs/biome" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "@lmstudio/sdk" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "@types/diff" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "chalk" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "diff" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "esbuild" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "node-pty" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "ollama" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "tsup" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "tsx" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "typebox" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "typescript" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "undici" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "uuid" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - - dependency-name: "yaml" - update-types: ["version-update:semver-patch", "version-update:semver-minor"] - groups: - # Pi-mono minors+majors land in one PR so the boundary review is a single - # batch when the engine moves. - pi-mono: - patterns: - - "@mariozechner/pi-*" - update-types: - - minor - - major - # Other production majors share a PR. - production-majors: - dependency-type: production - update-types: - - major - # Dev-tool majors share a PR. - dev-majors: - dependency-type: development - update-types: - - major - - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly - day: monday - time: "09:30" - timezone: America/Chicago - open-pull-requests-limit: 1 - labels: - - dependencies - commit-message: - prefix: ci - include: scope - ignore: - # Actions: alert on major only. Patches and minors are absorbed manually. - - dependency-name: "*" - update-types: - - "version-update:semver-patch" - - "version-update:semver-minor" - groups: - github-actions-majors: - patterns: - - "*" - update-types: - - major diff --git a/.github/workflows/ci-cross-platform.yml b/.github/workflows/ci-cross-platform.yml deleted file mode 100644 index b349276..0000000 --- a/.github/workflows/ci-cross-platform.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: ci-cross-platform - -on: - push: - branches: [main] - schedule: - - cron: '0 3 * * *' - workflow_dispatch: - -permissions: - contents: read - issues: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - ci-macos: - name: ci (macos-14) - runs-on: macos-14 - timeout-minutes: 15 - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: npm - - run: npm ci --prefer-offline --no-audit --no-fund - - run: npm run ci - - - name: file or update flake-tracking issue on failure - if: failure() - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - SHA: ${{ github.sha }} - run: | - set -e - existing=$(gh issue list --state open --search 'in:title "ci-cross-platform failure tracking"' --json number --jq '.[0].number') - ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) - body="Failed on \`$SHA\` ($ts): $RUN_URL" - if [ -n "$existing" ]; then - gh issue comment "$existing" --body "$body" - else - gh issue create --title "ci-cross-platform failure tracking" --body "$body" - fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcab399..65fc7a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,6 @@ on: branches: [main] pull_request: branches: [main] - types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: permissions: @@ -16,45 +15,8 @@ concurrency: cancel-in-progress: true jobs: - lint: - if: github.event.pull_request.draft != true - name: lint + ci: runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: npm - - run: npm ci --prefer-offline --no-audit --no-fund - - run: npm run typecheck - - run: npm run lint - - test: - if: github.event.pull_request.draft != true - name: test - runs-on: ubuntu-latest - needs: lint - timeout-minutes: 5 - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: npm - - run: npm ci --prefer-offline --no-audit --no-fund - - run: npm run test - - e2e: - if: github.event.pull_request.draft != true - name: e2e - runs-on: ubuntu-latest - needs: test timeout-minutes: 10 steps: - uses: actions/checkout@v6 @@ -65,4 +27,4 @@ jobs: node-version: 24 cache: npm - run: npm ci --prefer-offline --no-audit --no-fund - - run: npm run test:e2e + - run: npm run ci diff --git a/biome.json b/biome.json index f8c19cc..bfe0086 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, "formatter": { "enabled": true, diff --git a/package-lock.json b/package-lock.json index 6cc31f2..57ea12f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,33 +7,34 @@ "": { "name": "@iowarp/clio-coder", "version": "0.1.2", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.120", + "@anthropic-ai/claude-agent-sdk": "0.2.120", "@lmstudio/sdk": "1.5.0", "@mariozechner/pi-agent-core": "0.70.2", - "@mariozechner/pi-ai": "0.69.0", - "@mariozechner/pi-tui": "0.69.0", - "chalk": "^5.3.0", - "diff": "^9.0.0", - "esbuild": "^0.28.0", + "@mariozechner/pi-ai": "0.70.2", + "@mariozechner/pi-tui": "0.70.2", + "chalk": "5.6.2", + "diff": "9.0.0", + "esbuild": "0.28.0", "ollama": "0.6.3", - "typebox": "^1.1.33", - "undici": "^8.1.0", - "uuid": "^14.0.0", - "yaml": "^2.6.0" + "typebox": "1.1.33", + "undici": "8.1.0", + "uuid": "14.0.0", + "yaml": "2.8.3" }, "bin": { "clio": "dist/cli/index.js" }, "devDependencies": { - "@biomejs/biome": "^2.4.13", - "@types/diff": "^8.0.0", - "@types/node": "^24.12.2", - "node-pty": "^1.1.0", - "tsup": "^8.3.0", - "tsx": "^4.19.0", - "typescript": "^6.0.3" + "@biomejs/biome": "2.4.13", + "@types/diff": "8.0.0", + "@types/node": "24.12.2", + "node-pty": "1.1.0", + "tsup": "8.5.1", + "tsx": "4.21.0", + "typescript": "6.0.3" }, "engines": { "node": ">=22.0.0" @@ -1682,7 +1683,7 @@ "node": ">=20.0.0" } }, - "node_modules/@mariozechner/pi-agent-core/node_modules/@mariozechner/pi-ai": { + "node_modules/@mariozechner/pi-ai": { "version": "0.70.2", "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.70.2.tgz", "integrity": "sha512-+30LRPjXsXF+oI96DvGWMbdPGeqoLJvadh6UPev7wx2DzhC9FEqXkQcoMZ0usbCm7E9pl8ua8a9s/pQ5ikaUbg==", @@ -1707,40 +1708,6 @@ "node": ">=20.0.0" } }, - "node_modules/@mariozechner/pi-agent-core/node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/@mariozechner/pi-ai": { - "version": "0.69.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.69.0.tgz", - "integrity": "sha512-bl838sr57zx/apkiTeNPFQgbBkGXN4HHhm2oghzSRohJIBrR+H001bTeco7Osuke+EZTxO3V5Aev2qlZdUa2xw==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "^0.90.0", - "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@google/genai": "^1.40.0", - "@mistralai/mistralai": "^2.2.0", - "chalk": "^5.6.2", - "openai": "6.26.0", - "partial-json": "^0.1.7", - "proxy-agent": "^6.5.0", - "typebox": "^1.1.24", - "undici": "^7.19.1", - "zod-to-json-schema": "^3.24.6" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@mariozechner/pi-ai/node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -1751,9 +1718,9 @@ } }, "node_modules/@mariozechner/pi-tui": { - "version": "0.69.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.69.0.tgz", - "integrity": "sha512-/82SOMzCA1srivwfcSO8r9XLdAgJNcgpWzNZY33IqqvGev8Q4iWY/hA1wAkLB0l7PKJJ4Dh57xS4IGHbyVM7lA==", + "version": "0.70.2", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.70.2.tgz", + "integrity": "sha512-PtKC0NepnrYcqMx6MXkWTrBzC9tI62KeC6w940oT46lCbfvgmfqXciR15+9BZpxxc1H4jd3CMrKsmOPVeUqZ0A==", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", diff --git a/package.json b/package.json index 4a8325c..5882b36 100644 --- a/package.json +++ b/package.json @@ -69,30 +69,31 @@ "test:e2e": "npm run build && node --import tsx --test 'tests/e2e/**/*.test.ts'", "ci": "npm run typecheck && npm run lint && npm run test && npm run build && npm run test:e2e", "hooks:install": "bash scripts/install-hooks.sh", + "postinstall": "node scripts/fix-node-pty-permissions.mjs", "prepublishOnly": "npm run typecheck && npm run lint && npm run build && node scripts/check-dist.mjs" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.120", + "@anthropic-ai/claude-agent-sdk": "0.2.120", "@lmstudio/sdk": "1.5.0", "@mariozechner/pi-agent-core": "0.70.2", - "@mariozechner/pi-ai": "0.69.0", - "@mariozechner/pi-tui": "0.69.0", - "chalk": "^5.3.0", - "diff": "^9.0.0", - "esbuild": "^0.28.0", + "@mariozechner/pi-ai": "0.70.2", + "@mariozechner/pi-tui": "0.70.2", + "chalk": "5.6.2", + "diff": "9.0.0", + "esbuild": "0.28.0", "ollama": "0.6.3", - "typebox": "^1.1.33", - "undici": "^8.1.0", - "uuid": "^14.0.0", - "yaml": "^2.6.0" + "typebox": "1.1.33", + "undici": "8.1.0", + "uuid": "14.0.0", + "yaml": "2.8.3" }, "devDependencies": { - "@biomejs/biome": "^2.4.13", - "@types/diff": "^8.0.0", - "@types/node": "^24.12.2", - "node-pty": "^1.1.0", - "tsup": "^8.3.0", - "tsx": "^4.19.0", - "typescript": "^6.0.3" + "@biomejs/biome": "2.4.13", + "@types/diff": "8.0.0", + "@types/node": "24.12.2", + "node-pty": "1.1.0", + "tsup": "8.5.1", + "tsx": "4.21.0", + "typescript": "6.0.3" } } diff --git a/scripts/fix-node-pty-permissions.mjs b/scripts/fix-node-pty-permissions.mjs new file mode 100644 index 0000000..96be1c0 --- /dev/null +++ b/scripts/fix-node-pty-permissions.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// node-pty 1.1.0's published tarball ships prebuilds/-/spawn-helper +// without the executable bit on macOS targets. Without exec, posix_spawnp from +// the native binding fails with "posix_spawnp failed" the first time the pty +// harness tries to fork a subprocess. Linux dodges this because no Linux +// prebuild exists; node-gyp recompiles the helper, and the compiler emits an +// executable. Windows uses winpty/conpty and never touches spawn-helper. +// +// Idempotent and defensive: we never let postinstall fail. +import { chmodSync, existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +const require = createRequire(import.meta.url); + +let ptyDir; +try { + ptyDir = dirname(require.resolve("node-pty/package.json")); +} catch { + process.exit(0); +} + +const candidates = [ + ["darwin", "arm64"], + ["darwin", "x64"], + ["linux", "x64"], + ["linux", "arm64"], +]; + +for (const [platform, arch] of candidates) { + const helper = join(ptyDir, "prebuilds", `${platform}-${arch}`, "spawn-helper"); + if (existsSync(helper)) { + try { + chmodSync(helper, 0o755); + } catch { + // ignore; postinstall must not fail. + } + } +} diff --git a/tests/unit/bash-tool-env.test.ts b/tests/unit/bash-tool-env.test.ts index bb644a6..b6cbf1e 100644 --- a/tests/unit/bash-tool-env.test.ts +++ b/tests/unit/bash-tool-env.test.ts @@ -83,22 +83,27 @@ describe("bash tool environment", () => { it("escalates aborted commands that ignore sigterm", async () => { const controller = new AbortController(); + // The shell runs with `-l`, which on cold CI runners can spend a few + // hundred ms reading /etc/profile before reaching the user command. If + // SIGTERM arrives before the trap is installed, bash dies at the + // default disposition and the escalation path never runs. Wait long + // enough for the trap to take effect on the slowest runners we see. const startedAt = Date.now(); const started = bashTool.run( { - command: 'trap "" TERM; end=$((SECONDS + 9)); while [ "$SECONDS" -lt "$end" ]; do sleep 1; done', - timeout_ms: 12_000, + command: 'trap "" TERM; end=$((SECONDS + 12)); while [ "$SECONDS" -lt "$end" ]; do sleep 1; done', + timeout_ms: 16_000, }, { signal: controller.signal }, ); - setTimeout(() => controller.abort(), 250); + setTimeout(() => controller.abort(), 1500); const result = await started; const elapsedMs = Date.now() - startedAt; strictEqual(result.kind, "error"); if (result.kind === "error") strictEqual(result.message, "bash: command aborted"); - ok(elapsedMs >= 5000, `expected abort escalation after the grace period, got ${elapsedMs}ms`); - ok(elapsedMs < 8000, `expected abort escalation within 8s, got ${elapsedMs}ms`); + ok(elapsedMs >= 1500 + 4_500, `expected abort escalation after the grace period, got ${elapsedMs}ms`); + ok(elapsedMs < 1500 + 9_000, `expected abort escalation within window, got ${elapsedMs}ms`); }); it("reports output cap exits explicitly", async () => {