diff --git a/CHANGELOG.md b/CHANGELOG.md index efbdc9a13..cc74df9bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.35.0] - 2026-10-07 + +### Added +- Choose separate model tiers for planning and implementation. Independent plan reviews default to Fable 5.1 or GPT-6 Astra; implementation handoffs recommend Opus 5.5 or GPT-6.1 Sol without switching your session. Explicit model choices still win, and `plan_review_tier smart` or `host` provides an alternative to the frontier default. +- Inspect effective models, their sources and copyable pin/reset commands with `gstack-models`. Six settings use the existing configuration store, with fail-closed validation and a notice before the first affected review. See the [model-policy guide](docs/model-policy.md). +- Get weekly advisory checks of official model recommendations and retirement notices. One tracking issue retains source evidence and lifecycle history; upgrades remain human-reviewed, with no automatic model changes or paid benchmarking. + +### Fixed +- Codex model probes retain up to sixteen selections, serialize concurrent misses and preserve newer entries when another probe finishes later. Plan-review readiness and execution share one deadline, and failed model choices point to the setting that actually selected them. +- `/review` and `/ship` explicitly require prerequisite QA instructions to be read in earlier responses before a probe, including its evidence capture. + ## [1.91.34.0] - 2026-10-07 **/autoplan works again on current Claude Code, including long sessions and `claude --bg`.** diff --git a/VERSION b/VERSION index 4101e586d..da072c22f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.34.0 +1.91.35.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index b8eaa0b15..a695963ab 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.34.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.35.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/codex/SKILL.md b/codex/SKILL.md index 8e796fa16..7e9f2ecf3 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -851,11 +851,12 @@ If token count is not available, display: `Tokens: unknown` None of these is an auth or network failure, and the auth probe cannot catch them. Recovery, in order: 1. Read the `CODEX_MODEL:` line: it names the model and where it came from. - 2. Fix that source: name another model for this request, update - `GSTACK_CODEX_MODEL`, or change `model` (or `review_model`) in the Codex - `config.toml`. With none of these set, gstack uses `gpt-6-astra`; set any of - them to a model the account can use. - 3. If Codex printed `[notice.model_migrations]`, use that replacement model. + 2. Fix that source and relay its HINT/Repair lines. With `--role plan-review`, + tier pins outrank native settings; native `model`/`review_model` applies + only in `host` mode. Without a role, request, `GSTACK_CODEX_MODEL`, then + native settings choose the model, falling back to `gpt-6-astra`. + 3. If Codex printed `[notice.model_migrations]`, use that replacement for + no-role calls. For a role, offer an explicit override; do not silently substitute. Never present this as a model stall or a PASS — it is a fail-closed gate result. - **`VERDICT: unavailable`:** the shared validator found the run did not execute (for example `Codex's sandbox could not start here`). Relay its line and fix verbatim; it is diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl index 1bec2d1ee..167bafc3f 100644 --- a/codex/SKILL.md.tmpl +++ b/codex/SKILL.md.tmpl @@ -317,11 +317,12 @@ If token count is not available, display: `Tokens: unknown` None of these is an auth or network failure, and the auth probe cannot catch them. Recovery, in order: 1. Read the `CODEX_MODEL:` line: it names the model and where it came from. - 2. Fix that source: name another model for this request, update - `GSTACK_CODEX_MODEL`, or change `model` (or `review_model`) in the Codex - `config.toml`. With none of these set, gstack uses `gpt-6-astra`; set any of - them to a model the account can use. - 3. If Codex printed `[notice.model_migrations]`, use that replacement model. + 2. Fix that source and relay its HINT/Repair lines. With `--role plan-review`, + tier pins outrank native settings; native `model`/`review_model` applies + only in `host` mode. Without a role, request, `GSTACK_CODEX_MODEL`, then + native settings choose the model, falling back to `gpt-6-astra`. + 3. If Codex printed `[notice.model_migrations]`, use that replacement for + no-role calls. For a role, offer an explicit override; do not silently substitute. Never present this as a model stall or a PASS — it is a fail-closed gate result. - **`VERDICT: unavailable`:** the shared validator found the run did not execute (for example `Codex's sandbox could not start here`). Relay its line and fix verbatim; it is diff --git a/docs/skills.md b/docs/skills.md index 7c332e51a..91a91ad95 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -1148,10 +1148,10 @@ This is my **second opinion mode**. `/codex` brings OpenAI Codex CLI to review the same diff independently. It is available on every harness except Codex itself. External harnesses install it as `/gstack-codex`. Compare its findings with the native review to distinguish corroborated findings from issues only one reviewer caught. -gstack-owned Codex calls default to `gpt-6-astra`, including resumed consult -sessions. Set `GSTACK_CODEX_MODEL=` to change the default, or name a -model in your request to override it for that invocation. Generated commands -pass the selection through `-c model=...`, overriding the CLI's configured model. +Without a role, gstack-owned Codex calls select a request-specific model, then +`GSTACK_CODEX_MODEL`, then Codex native settings, falling back to `gpt-6-astra`. +This also applies to resumed consultations. Generated commands pass the selection +through `-c model=...`, overriding the CLI's configured model for that call. Native review also sets `-c review_model=...` to that selection, overriding any separate review-model pin. @@ -1161,6 +1161,10 @@ review, challenge, and consult calls preserve Claude's configured model. sessions; a model named in your request takes precedence. Harness routing is independent of model selection. +An explicit `--role plan-review` uses the configured plan-review tier instead; +request and environment overrides still win, and `host` mode restores native +selection. See [model policy](model-policy.md) for pins, precedence and recovery. + ### Three modes **Review** — run `codex review` against the current diff. Codex reads every changed file, classifies findings by severity (P1 critical, P2 high, P3 medium), and returns a PASS/FAIL verdict. Any P1 finding = FAIL. The review is fully independent — Codex doesn't see Claude's review. @@ -1200,7 +1204,7 @@ Claude Code provides the outside reviewer when gstack runs in Codex. Other non-C **Review** supplies the branch diff for a read-only pass/fail review. **Challenge** asks Claude Code to find concrete failure cases in the same diff. **Consult** supports read-only repository exploration and resumes the session saved in `.context/claude-session-id`. Review and challenge receive context from the parent workflow and run without tools; consultation can read and search files. -The Claude Code CLI must be installed and authenticated. Its existing model configuration and `GSTACK_CLAUDE_BIN` / `GSTACK_CLAUDE_BIN_ARGS` executable overrides are honored. Errors, timeouts, and invalid responses report missing outside coverage instead of a clean review. Automatic reviews start fresh; consult session continuity is explicit. +The Claude Code CLI must be installed and authenticated. Without a role, its existing model configuration is honored; explicit `--role plan-review` follows the [model policy](model-policy.md). `GSTACK_CLAUDE_BIN` / `GSTACK_CLAUDE_BIN_ARGS` executable overrides remain in force. Errors, timeouts, and invalid responses report missing outside coverage instead of a clean review. Automatic reviews start fresh; consult session continuity is explicit. Outside-review routing follows the harness, independently of the configured model. Generic second-opinion requests choose `/claude-code` on Codex and `/codex` elsewhere; explicit provider requests keep that provider. The existing `codex_reviews` setting controls the selected automatic reviewer in workflows that already use that setting. Existing opt-in and skip controls still apply in office hours, design, and spec workflows. diff --git a/package.json b/package.json index 61274078b..51766d755 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.34", + "version": "1.91.35", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/harness-version.json b/scripts/harness-version.json index 532284af0..bd49eaf04 100644 --- a/scripts/harness-version.json +++ b/scripts/harness-version.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 3, "patterns": [ "test/helpers/agent-sdk-runner.ts", "test/helpers/codex-session-runner.ts", @@ -10,44 +10,44 @@ "test/helpers/llm-judge.ts" ], "files": { - "scripts/lib/paid-cases.ts": {"blob":"9bacd52446192a7ecf3dfaae901ea443cf9c9ae2","since":2}, - "scripts/lib/paid-plan.ts": {"blob":"45721ce128a112666a0caed6c13c66637b74ca54","since":2}, - "scripts/lib/paid-report.ts": {"blob":"3fda96a8b1f7f3d55797934847353f254cc4d054","since":2}, - "scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":2}, - "scripts/lib/shard-engine.ts": {"blob":"3cd0b1aaa84018b00bf9ea0ce48093f3c8492f66","since":2}, - "scripts/test-paid-shards.ts": {"blob":"8ddee5f11ed7856876135520dd8025ab989e2db9","since":2}, - "scripts/test-pr-profile.ts": {"blob":"88ecbce2b71c1e388c4dbca3f7013c71013bf8d3","since":2,"nonBehavioral":"test-pr-profile classifies the dispatch-only measure-journal-read workflow as a free-lane path; selection of paid cases and every verdict are unchanged"}, - "scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":2}, - "test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":2}, - "test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":2}, - "test/helpers/claude-pty-runner.ts": {"blob":"20cc5894532873aa6145fc6617fdeec8cef94a2d","since":2}, - "test/helpers/codex-session-runner.ts": {"blob":"4eea35931a35a0afb006363402e3d4191e379737","since":2}, - "test/helpers/e2e-gate.ts": {"blob":"efb7e197dd5f66c3ad0e34074b14e640754f7a45","since":2}, - "test/helpers/e2e-helpers.ts": {"blob":"2d4a2a53063cfc1f003db366d2c861ab1e379e6e","since":2}, - "test/helpers/eval-budgets.ts": {"blob":"667d0e09b20fe5d7366f57b77b5fb9b04baa37ef","since":2}, - "test/helpers/eval-store.ts": {"blob":"609c6d099108ff2696eb014b47aa318a618dd2af","since":2}, - "test/helpers/hermetic-env.ts": {"blob":"6473f78285384f4420f48cbe5928658919078e0b","since":2}, - "test/helpers/llm-judge.ts": {"blob":"54e3306b494fb5d7f132ccdaaae5c680465028f9","since":2}, - "test/helpers/paid-test-set.ts": {"blob":"ff9bfe43c43ec2bdd11114f7b8ec7c5736116f75","since":2}, - "test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":2}, - "test/helpers/pty/auq.ts": {"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":2}, - "test/helpers/pty/binary.ts": {"blob":"404d7515076d5c00804f89e23ea69296312c00e1","since":2}, - "test/helpers/pty/boundaries.ts": {"blob":"53eb76e57c0558c00254366d6334a1c833126575","since":2}, - "test/helpers/pty/classify.ts": {"blob":"3f2daa12ff6484f96b8d28d910a50231c4bb3ae8","since":2}, - "test/helpers/pty/fake-session.ts": {"blob":"554e064ef259b8444280eddfe9b0db93c66a8202","since":2}, - "test/helpers/pty/judge.ts": {"blob":"81c5bb4c48ab928aca2f186a1ade9df7169d7fbe","since":2}, - "test/helpers/pty/launch.ts": {"blob":"4ef0bcb7b5e7c35d2aac0cbffd7f8a303440b6fe","since":2}, - "test/helpers/pty/plan-native.ts": {"blob":"ddba9cb68c7c0da935cff7c1cea258dee2dff740","since":2}, - "test/helpers/pty/runners/counting.ts": {"blob":"8c5c05e54abf961259d34d9c3d77db67138d7794","since":2}, - "test/helpers/pty/runners/floor.ts": {"blob":"465f01641fb3a18ae1c9d2613a9c2b26f8fb503e","since":2}, - "test/helpers/pty/runners/observation.ts": {"blob":"1c2b75fbb984708725abaa8939125f7cab969f9f","since":2}, - "test/helpers/pty/screen.ts": {"blob":"ee005f36ffa48617e6832ccbeb35e8e96873df9b","since":2}, - "test/helpers/pty/session.ts": {"blob":"58533cea9d81cc87bb3b499bdfe0db8e3a8709c5","since":2}, - "test/helpers/session-drain-policy.ts": {"blob":"6211239dd0485e6495a700b824a7e0e61d1bcd97","since":2}, - "test/helpers/session-ledger.ts": {"blob":"6e3c9e837e46f98addba2d12eaebff802da320bb","since":2}, - "test/helpers/session-runner.ts": {"blob":"d40eb29e8b8fdc0675b511693e4a90823b5963fc","since":2}, - "test/helpers/skill-fixture.ts": {"blob":"b188064594eb4201a54a80ba1bc6f28e31d7f49e","since":2}, - "test/helpers/test-selection.ts": {"blob":"51655f89f66be7a5ec487e2abfe4744300ebd5f6","since":2}, - "test/helpers/touchfiles.ts": {"blob":"15f369056d41b9dc42dcd86f81f60c878074704c","since":2} + "scripts/lib/paid-cases.ts": {"blob":"9bacd52446192a7ecf3dfaae901ea443cf9c9ae2","since":3}, + "scripts/lib/paid-plan.ts": {"blob":"45721ce128a112666a0caed6c13c66637b74ca54","since":3}, + "scripts/lib/paid-report.ts": {"blob":"3fda96a8b1f7f3d55797934847353f254cc4d054","since":3}, + "scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":3}, + "scripts/lib/shard-engine.ts": {"blob":"3cd0b1aaa84018b00bf9ea0ce48093f3c8492f66","since":3}, + "scripts/test-paid-shards.ts": {"blob":"8ddee5f11ed7856876135520dd8025ab989e2db9","since":3}, + "scripts/test-pr-profile.ts": {"blob":"54363c3d3aad9743b9e79219da7ec8a0f4ab0832","since":3}, + "scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":3}, + "test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":3}, + "test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":3}, + "test/helpers/claude-pty-runner.ts": {"blob":"20cc5894532873aa6145fc6617fdeec8cef94a2d","since":3}, + "test/helpers/codex-session-runner.ts": {"blob":"4eea35931a35a0afb006363402e3d4191e379737","since":3}, + "test/helpers/e2e-gate.ts": {"blob":"efb7e197dd5f66c3ad0e34074b14e640754f7a45","since":3}, + "test/helpers/e2e-helpers.ts": {"blob":"2d4a2a53063cfc1f003db366d2c861ab1e379e6e","since":3}, + "test/helpers/eval-budgets.ts": {"blob":"667d0e09b20fe5d7366f57b77b5fb9b04baa37ef","since":3}, + "test/helpers/eval-store.ts": {"blob":"609c6d099108ff2696eb014b47aa318a618dd2af","since":3}, + "test/helpers/hermetic-env.ts": {"blob":"6473f78285384f4420f48cbe5928658919078e0b","since":3}, + "test/helpers/llm-judge.ts": {"blob":"54e3306b494fb5d7f132ccdaaae5c680465028f9","since":3}, + "test/helpers/paid-test-set.ts": {"blob":"ff9bfe43c43ec2bdd11114f7b8ec7c5736116f75","since":3}, + "test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":3}, + "test/helpers/pty/auq.ts": {"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":3}, + "test/helpers/pty/binary.ts": {"blob":"404d7515076d5c00804f89e23ea69296312c00e1","since":3}, + "test/helpers/pty/boundaries.ts": {"blob":"53eb76e57c0558c00254366d6334a1c833126575","since":3}, + "test/helpers/pty/classify.ts": {"blob":"3f2daa12ff6484f96b8d28d910a50231c4bb3ae8","since":3}, + "test/helpers/pty/fake-session.ts": {"blob":"554e064ef259b8444280eddfe9b0db93c66a8202","since":3}, + "test/helpers/pty/judge.ts": {"blob":"81c5bb4c48ab928aca2f186a1ade9df7169d7fbe","since":3}, + "test/helpers/pty/launch.ts": {"blob":"ca874247a5b6bf6076d3cd34da01af9c8cc292b7","since":3}, + "test/helpers/pty/plan-native.ts": {"blob":"ddba9cb68c7c0da935cff7c1cea258dee2dff740","since":3}, + "test/helpers/pty/runners/counting.ts": {"blob":"8c5c05e54abf961259d34d9c3d77db67138d7794","since":3}, + "test/helpers/pty/runners/floor.ts": {"blob":"465f01641fb3a18ae1c9d2613a9c2b26f8fb503e","since":3}, + "test/helpers/pty/runners/observation.ts": {"blob":"1c2b75fbb984708725abaa8939125f7cab969f9f","since":3}, + "test/helpers/pty/screen.ts": {"blob":"ee005f36ffa48617e6832ccbeb35e8e96873df9b","since":3}, + "test/helpers/pty/session.ts": {"blob":"58533cea9d81cc87bb3b499bdfe0db8e3a8709c5","since":3}, + "test/helpers/session-drain-policy.ts": {"blob":"6211239dd0485e6495a700b824a7e0e61d1bcd97","since":3}, + "test/helpers/session-ledger.ts": {"blob":"6e3c9e837e46f98addba2d12eaebff802da320bb","since":3}, + "test/helpers/session-runner.ts": {"blob":"d40eb29e8b8fdc0675b511693e4a90823b5963fc","since":3}, + "test/helpers/skill-fixture.ts": {"blob":"b188064594eb4201a54a80ba1bc6f28e31d7f49e","since":3}, + "test/helpers/test-selection.ts": {"blob":"51655f89f66be7a5ec487e2abfe4744300ebd5f6","since":3}, + "test/helpers/touchfiles.ts": {"blob":"15f369056d41b9dc42dcd86f81f60c878074704c","since":3} } } diff --git a/test/codex-hardening.test.ts b/test/codex-hardening.test.ts index 3c27e83df..174154f81 100644 --- a/test/codex-hardening.test.ts +++ b/test/codex-hardening.test.ts @@ -947,7 +947,7 @@ describe('codex broken-install detection (#2742)', () => { expect(src).toContain('echo "CODEX_MODE: $_CODEX_MODE"'); } const role = fs.readFileSync(PROBE, 'utf-8').match(/_gstack_codex_role_ready\(\) \{[\s\S]*?\n\}/)![0]; - expect(role.trim().split('\n').at(-2)!.trim()).toBe('_gstack_codex_model_probe "$1"'); + expect(role).toContain('_gstack_codex_model_probe "$1" || return $?'); const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-role-broken-')); try { const bin = path.join(home, 'bin'); diff --git a/test/helpers/pty/launch.ts b/test/helpers/pty/launch.ts index 4ef0bcb7b..ca874247a 100644 --- a/test/helpers/pty/launch.ts +++ b/test/helpers/pty/launch.ts @@ -493,6 +493,7 @@ async function closePty(pty: PtyProcess, wallTimer: ReturnType describe('manual /codex entry: explicit role only, paid probe only for the dispatched model', () => { const skill = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md.tmpl'), 'utf8'); + test('manual model recovery distinguishes role pins from native no-role settings', () => { + const recovery = skill.slice(skill.indexOf('Recovery, in order:'), skill.indexOf('- **`VERDICT: unavailable`:')); + expect(recovery).toContain('With `--role plan-review`'); + expect(recovery).toContain('tier pins outrank native settings'); + expect(recovery).toContain('only in `host` mode'); + expect(recovery).toContain('Without a role'); + expect(recovery).toContain('do not silently substitute'); + }); const preflight = skill.match(/```bash\n(_CODEX_PROBE=[^\n]*\n_CODEX_ROLE=''[\s\S]*?)\n```/)![1]! .replace('{{OUTSIDE_SELF_GUARD:codex}}', RESOLVERS.OUTSIDE_SELF_GUARD(ctxFor('codex', 'claude', HOST_PATHS.claude), ['codex'])); const withRole = (text: string, role: string) => text.replaceAll("_CODEX_ROLE=''", `_CODEX_ROLE='${role}'`); diff --git a/test/pty-output-wake.test.ts b/test/pty-output-wake.test.ts index 038bb5fc9..a5f1e7c94 100644 --- a/test/pty-output-wake.test.ts +++ b/test/pty-output-wake.test.ts @@ -4,6 +4,78 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; import { launchClaudePty, runPlanSkillCounting, type ClaudePtySession } from './helpers/claude-pty-runner'; + +test.each(['exited', 'running', 'unresponsive'])('closes the owned terminal once after %s child cleanup', async mode => { + const originalBinary = process.env.BROWSE_TERMINAL_BINARY; + const events: string[] = []; + let resolveExit!: (code: number) => void; + const exited = new Promise(resolve => { resolveExit = resolve; }); + if (mode === 'exited') resolveExit(0); + const spawn = Bun.spawn; + const spawnSpy = spyOn(Bun, 'spawn').mockImplementation(((...args: any[]) => + args[1]?.env?.GSTACK_TEST_TERMINAL_CLEANUP === mode ? { + exited, + kill(signal: string) { + events.push(signal); + if (mode === 'running') resolveExit(0); + }, + terminal: { write() {}, close() { events.push('close'); } }, + } : (spawn as any)(...args)) as typeof Bun.spawn); + let session: ClaudePtySession | undefined; + try { + process.env.BROWSE_TERMINAL_BINARY = process.execPath; + session = await launchClaudePty({ timeoutMs: 200, sessionLedger: false, + env: { GSTACK_TEST_TERMINAL_CLEANUP: mode } }); + await Promise.all([session.close(), session.close()]); + await session.close(); + expect(events.filter(event => event === 'close')).toHaveLength(1); + expect(events.at(-1)).toBe('close'); + if (mode === 'exited') expect(events).toEqual(['close']); + if (mode === 'running') expect(events).toEqual(['SIGINT', 'close']); + if (mode === 'unresponsive') expect(events).toContain('SIGKILL'); + } finally { + try { await session?.close(); } + finally { + spawnSpy.mockRestore(); + if (originalBinary === undefined) delete process.env.BROWSE_TERMINAL_BINARY; + else process.env.BROWSE_TERMINAL_BINARY = originalBinary; + } + } +}); + +test.skipIf(process.platform !== 'linux')('closes the owned terminal without retaining native PTY descriptors', async () => { + const descriptors = () => fs.readdirSync('/proc/self/fd').filter(fd => { + try { return /\/dev\/(?:pts\/)?ptmx$/.test(fs.readlinkSync(`/proc/self/fd/${fd}`)); } + catch { return false; } + }).sort(); + const before = descriptors(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pty-close-')); + const fake = path.join(dir, 'fake-claude'); + fs.writeFileSync(fake, `#!${process.execPath} +process.on('SIGINT', () => process.exit(0)); +process.stdin.resume(); +process.stdout.write('TERMINAL_READY'); +`, { mode: 0o755 }); + const originalBinary = process.env.BROWSE_TERMINAL_BINARY; + let session: ClaudePtySession | undefined; + try { + process.env.BROWSE_TERMINAL_BINARY = fake; + session = await launchClaudePty({ cwd: dir, model: 'fixture', timeoutMs: 5000, sessionLedger: false }); + await session.waitFor('TERMINAL_READY', { timeoutMs: 3000 }); + expect(descriptors().length).toBeGreaterThan(before.length); + await session.close(); + expect(session.exited()).toBe(true); + expect(descriptors()).toEqual(before); + } finally { + try { await session?.close(); } + finally { + if (originalBinary === undefined) delete process.env.BROWSE_TERMINAL_BINARY; + else process.env.BROWSE_TERMINAL_BINARY = originalBinary; + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}, 10000); + test.skipIf(process.platform === 'win32')('PTY output and exit wake observers without leaving deadline timers', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pty-output-')); const fake = path.join(dir, 'fake-claude'); diff --git a/test/setup-claude-code-migration.test.ts b/test/setup-claude-code-migration.test.ts index a4bb2344b..c75e51a98 100644 --- a/test/setup-claude-code-migration.test.ts +++ b/test/setup-claude-code-migration.test.ts @@ -30,7 +30,7 @@ function fixture(copy: boolean) { put(path.join(root, 'VERSION'), '2.0.0.0\n'); put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n'); put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } })); - for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh']) { + for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-models', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh']) { const target = path.join(root, 'bin', bin); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(path.join(ROOT, 'bin', bin), target);