Scope headless detection to the path that opens a browser

The new self-detection ran before mode dispatch, so it also caught --wait,
--stop, and --schema. CI failed on the start/wait cycle test: --wait
returned 2 (no browser) where the documented poll loop expects 3
(WAITING). Under CI=1 the suite went 4 pass / 2 fail; it is 6 / 0 now.

Two of those modes were user-facing bugs, not just test breakage. --stop
exited 2 without killing the daemon it was asked to kill, leaking a
server process (verified: one daemon running, CI=1 --stop, still one).
--schema only prints a payload example, and new-work.md tells the agent
to read it before building a payload.

Detection can only tell whether this process can auto-open a browser, not
whether the user has one: SSH with a forwarded port and a harness with an
in-app browser both have a browser and no DISPLAY. The file already
treats serve-without-opening as first class, since --start spawns its own
daemon with --no-open. So the check now gates acquiring a session, not
managing or ending one. The blocking serve path still exits 2 on a
headless box, with a test pinning that.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-21 22:06:55 -07:00
co-authored by Claude
parent 5575a027dc
commit ea68bb4722
2 changed files with 53 additions and 3 deletions
+8 -3
View File
@@ -75,9 +75,14 @@ if (process.env.IMPECCABLE_QUESTION_DISABLED) {
console.log('serve-question: disabled in this session (no browser); use the structured question tool instead.');
process.exit(2);
}
// Headless self-detection: --no-open means the caller opens the URL itself,
// so only environments that would need a browser opened for them are checked.
if (!process.argv.includes('--no-open') && !process.env.IMPECCABLE_QUESTION_FORCE) {
// Headless self-detection, applied only where a browser is actually wanted.
// --no-open means the caller opens the URL itself, and --wait / --stop /
// --schema never open anything: --wait polls a daemon whose browser question
// was already settled at --start, --stop kills one, --schema prints text. A
// spurious exit 2 from those breaks the documented loop, which polls --wait
// while it exits 3 and reads --schema before building a payload.
const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema');
if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) {
const headless =
process.env.CI ||
(process.env.SSH_CONNECTION && !process.env.DISPLAY) ||
+45
View File
@@ -90,6 +90,51 @@ describe('serve-question', () => {
assert.match(collected.out, /"optionId":"assigned"/);
});
it('headless detection spares the modes that never open a browser', async () => {
// Only the blocking serve path auto-opens a URL. --wait polls a daemon
// that is already running, --stop kills one, --schema just prints text,
// so a headless environment must not turn any of them into exit 2: the
// documented flow polls --wait while it exits 3, and new-work.md tells
// the agent to read --schema first.
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const headlessEnv = { ...process.env, CI: '1' };
delete headlessEnv.IMPECCABLE_QUESTION_FORCE;
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, env: headlessEnv, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const schema = await run(['--schema']);
assert.equal(schema.code, 0, `--schema under CI must print, got ${schema.code}: ${schema.out}`);
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'hk']);
assert.equal(started.code, 0, started.out);
try {
const waiting = await run(['--wait', '--key', 'hk', '--poll', '1']);
assert.equal(waiting.code, 3, `--wait under CI must report WAITING, got ${waiting.code}: ${waiting.out}`);
} finally {
const stopped = await run(['--stop', '--key', 'hk']);
assert.equal(stopped.code, 0, `--stop under CI must kill the daemon, got ${stopped.code}: ${stopped.out}`);
}
});
it('headless detection still blocks the path that would open a browser', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const headlessEnv = { ...process.env, CI: '1' };
delete headlessEnv.IMPECCABLE_QUESTION_FORCE;
const code = await new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, '--payload', payloadPath], { cwd: dir, env: headlessEnv, stdio: 'ignore' });
child.on('exit', resolve);
});
assert.equal(code, 2);
});
it('rejects an empty payload', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');