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 };
}