Spaces:
Running
Running
File size: 5,546 Bytes
df112f9 126e54c df112f9 126e54c df112f9 126e54c df112f9 126e54c df112f9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | 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 };
}
|