import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const SAFE_ID = /^[A-Za-z0-9_-]+$/; const KINDS = new Set(['permission', 'question', 'confirmation']); const MARKER_SOURCES = new Map([ ['claude', new Set(['claude-notification'])], ['gemini', new Set(['gemini-notification'])], ['opencode', new Set(['opencode-event'])], ]); const configuredMaxAge = Number(process.env.AM_INPUT_REQUIRED_MAX_AGE_MS); const ONE_SHOT_MAX_AGE_MS = Number.isFinite(configuredMaxAge) && configuredMaxAge > 0 ? configuredMaxAge : 30 * 60_000; function markerDirectory() { return process.env.AM_INPUT_REQUIRED_DIR || path.join(os.tmpdir(), 'am-input-required'); } function markerFile(id) { return SAFE_ID.test(id || '') ? path.join(markerDirectory(), `${id}.json`) : null; } function readMarker(id, runId, cli, now) { const file = markerFile(id); if (!file) return null; try { const value = JSON.parse(fs.readFileSync(file, 'utf8')); const at = Number(value?.at); const source = typeof value?.source === 'string' ? value.source : ''; if (value?.amId !== id || value?.runId !== runId || value?.cli !== cli || !KINDS.has(value?.kind) || !MARKER_SOURCES.get(cli)?.has(source) || !Number.isFinite(at) || at <= 0 || at > now + 60_000) return null; return { file, at, kind: value.kind, source, requestId: typeof value.requestId === 'string' ? value.requestId : '', }; } catch { return null; } } function removeMatchingMarker(id, runId, cli, { oneShotOnly = false } = {}) { const marker = readMarker(id, runId, cli, Date.now()); if (!marker || (oneShotOnly && marker.source === 'opencode-event')) return; removeMarker(marker); } function removeMarker(marker) { try { fs.unlinkSync(marker.file); } catch {} } function publicState(current) { if (!current) return null; return { kind: current.kind, cli: current.cli, confidence: 'high', detectedAt: new Date(current.at).toISOString(), }; } /** * Native interactive-dialog signals for one PTY launch. * * This intentionally has no screen-text or process-idle fallback. An agent can * print any prompt-looking text, and an event-loop TUI polls stdin while both * thinking and waiting. Only CLI lifecycle events enter this tracker. */ export function createInputRequiredTracker({ id, runId, cli, now = () => Date.now() }) { let current = null; let osc = ''; const set = (kind, source, at, transport, token = '') => { current = { kind, source, at, transport, token, cli }; }; const get = () => { const time = now(); const marker = readMarker(id, runId, cli, time); if (marker) { if (time - marker.at > ONE_SHOT_MAX_AGE_MS) { removeMarker(marker); if (current?.transport === 'marker') current = null; } else { const token = `${marker.source}:${marker.at}:${marker.requestId}:${marker.kind}`; if (current?.transport !== 'marker' || current.token !== token) { set(marker.kind, marker.source, marker.at, 'marker', token); } } } else if (current?.transport === 'marker') { current = null; } if (current?.transport === 'terminal' && time - current.at > ONE_SHOT_MAX_AGE_MS) current = null; return publicState(current); }; const observeOutput = (chunk) => { if ((cli !== 'codex' && cli !== 'gemini') || !chunk) return; osc += String(chunk); // Codex emits these OSC 9 messages only after its TUI has installed the // corresponding approval/question view. Invocation-local config forces // the exact backend and enables only these two notification classes. const re = /\x1b\](9;|777;notify;)([^\x07\x1b]{1,2048})(?:\x07|\x1b\\)/g; let match; let consumed = 0; while ((match = re.exec(osc))) { consumed = re.lastIndex; const message = match[2]; if (cli === 'codex') { if (message.startsWith('Plan mode prompt:')) { set('question', 'codex-notification', now(), 'terminal'); } else if (message.startsWith('Approval requested:') || message.startsWith('Codex wants to edit ') || message.startsWith('Approval requested by ')) { set('permission', 'codex-notification', now(), 'terminal'); } } else if (message.startsWith('Gemini CLI needs your attention')) { set(message.includes('Answer requested by agent') ? 'question' : 'confirmation', 'gemini-notification', now(), 'terminal'); } else if (message.startsWith('Gemini CLI session complete')) { removeMatchingMarker(id, runId, cli, { oneShotOnly: true }); current = null; } } if (consumed) osc = osc.slice(consumed); if (osc.length > 4096) osc = osc.slice(-4096); }; const observeInput = () => { // OpenCode has paired asked/replied events. Cursor movement in its menu is // still input but does not resolve the request, so only the paired event or // the finite safety ceiling clears it. The other CLIs expose an exact open // signal but no exact close; any operator key clears them conservatively (a // false negative is safer). const marker = readMarker(id, runId, cli, now()); if (current?.source === 'opencode-event' || marker?.source === 'opencode-event') return; if (marker) removeMarker(marker); current = null; }; const close = () => { removeMatchingMarker(id, runId, cli); current = null; osc = ''; }; return { get, observeOutput, observeInput, close }; }