agent-manager-template / server /src /input-required.js
Agent Manager
Bound stale input request markers
126e54c
Raw
History Blame Contribute Delete
5.55 kB
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 };
}