oh-my-codex 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/dist/cli/__tests__/index.test.js +13 -1
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +113 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/tmux-hook.d.ts.map +1 -1
- package/dist/cli/tmux-hook.js +52 -6
- package/dist/cli/tmux-hook.js.map +1 -1
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +12 -9
- package/dist/cli/update.js.map +1 -1
- package/dist/config/__tests__/generator-notify.test.d.ts +2 -0
- package/dist/config/__tests__/generator-notify.test.d.ts.map +1 -0
- package/dist/config/__tests__/generator-notify.test.js +45 -0
- package/dist/config/__tests__/generator-notify.test.js.map +1 -0
- package/dist/config/generator.d.ts.map +1 -1
- package/dist/config/generator.js +19 -1
- package/dist/config/generator.js.map +1 -1
- package/dist/hooks/__tests__/notify-fallback-watcher.test.d.ts +2 -0
- package/dist/hooks/__tests__/notify-fallback-watcher.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js +142 -0
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -0
- package/dist/hooks/__tests__/notify-hook-tmux-heal.test.d.ts +2 -0
- package/dist/hooks/__tests__/notify-hook-tmux-heal.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +436 -0
- package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -0
- package/dist/hooks/__tests__/tmux-hook-engine.test.js +27 -4
- package/dist/hooks/__tests__/tmux-hook-engine.test.js.map +1 -1
- package/dist/mcp/state-server.js +3 -1
- package/dist/mcp/state-server.js.map +1 -1
- package/dist/modes/__tests__/base-tmux-pane.test.d.ts +2 -0
- package/dist/modes/__tests__/base-tmux-pane.test.d.ts.map +1 -0
- package/dist/modes/__tests__/base-tmux-pane.test.js +27 -0
- package/dist/modes/__tests__/base-tmux-pane.test.js.map +1 -0
- package/dist/modes/base.d.ts.map +1 -1
- package/dist/modes/base.js +5 -2
- package/dist/modes/base.js.map +1 -1
- package/dist/state/__tests__/mode-state-context.test.d.ts +2 -0
- package/dist/state/__tests__/mode-state-context.test.d.ts.map +1 -0
- package/dist/state/__tests__/mode-state-context.test.js +35 -0
- package/dist/state/__tests__/mode-state-context.test.js.map +1 -0
- package/dist/state/mode-state-context.d.ts +12 -0
- package/dist/state/mode-state-context.d.ts.map +1 -0
- package/dist/state/mode-state-context.js +28 -0
- package/dist/state/mode-state-context.js.map +1 -0
- package/package.json +1 -1
- package/scripts/notify-fallback-watcher.js +260 -0
- package/scripts/notify-hook.js +255 -25
- package/scripts/tmux-hook-engine.js +34 -12
- package/templates/AGENTS.md +1 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { appendFile, mkdir, readFile, readdir, stat, writeFile } from 'fs/promises';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
6
|
+
import { dirname, join, resolve } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
|
|
9
|
+
function argValue(name, fallback = '') {
|
|
10
|
+
const idx = process.argv.indexOf(name);
|
|
11
|
+
if (idx < 0 || idx + 1 >= process.argv.length) return fallback;
|
|
12
|
+
return process.argv[idx + 1];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const cwd = resolve(argValue('--cwd', process.cwd()));
|
|
16
|
+
const notifyScript = resolve(argValue('--notify-script', join(cwd, 'scripts', 'notify-hook.js')));
|
|
17
|
+
const pollMs = Number(argValue('--poll-ms', '700')) || 700;
|
|
18
|
+
const runOnce = process.argv.includes('--once');
|
|
19
|
+
const startedAt = Date.now();
|
|
20
|
+
const fileWindowMs = runOnce ? 15000 : 30000;
|
|
21
|
+
|
|
22
|
+
const omxDir = join(cwd, '.omx');
|
|
23
|
+
const logsDir = join(omxDir, 'logs');
|
|
24
|
+
const stateDir = join(omxDir, 'state');
|
|
25
|
+
const statePath = join(stateDir, 'notify-fallback-state.json');
|
|
26
|
+
const logPath = join(logsDir, `notify-fallback-${new Date().toISOString().split('T')[0]}.jsonl`);
|
|
27
|
+
|
|
28
|
+
const fileState = new Map();
|
|
29
|
+
const seenTurnKeys = new Set();
|
|
30
|
+
let stopping = false;
|
|
31
|
+
|
|
32
|
+
function safeString(v) {
|
|
33
|
+
return typeof v === 'string' ? v : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function eventLog(event) {
|
|
37
|
+
return appendFile(logPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`).catch(() => {});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sessionDirs() {
|
|
41
|
+
const now = new Date();
|
|
42
|
+
const today = join(
|
|
43
|
+
homedir(),
|
|
44
|
+
'.codex',
|
|
45
|
+
'sessions',
|
|
46
|
+
String(now.getUTCFullYear()),
|
|
47
|
+
String(now.getUTCMonth() + 1).padStart(2, '0'),
|
|
48
|
+
String(now.getUTCDate()).padStart(2, '0')
|
|
49
|
+
);
|
|
50
|
+
const yesterdayDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
51
|
+
const yesterday = join(
|
|
52
|
+
homedir(),
|
|
53
|
+
'.codex',
|
|
54
|
+
'sessions',
|
|
55
|
+
String(yesterdayDate.getUTCFullYear()),
|
|
56
|
+
String(yesterdayDate.getUTCMonth() + 1).padStart(2, '0'),
|
|
57
|
+
String(yesterdayDate.getUTCDate()).padStart(2, '0')
|
|
58
|
+
);
|
|
59
|
+
return Array.from(new Set([today, yesterday]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readFirstLine(path) {
|
|
63
|
+
const content = await readFile(path, 'utf-8');
|
|
64
|
+
const idx = content.indexOf('\n');
|
|
65
|
+
return idx >= 0 ? content.slice(0, idx) : content;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function shouldTrackSessionMeta(line) {
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(line);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (!parsed || parsed.type !== 'session_meta' || !parsed.payload) return null;
|
|
76
|
+
const payload = parsed.payload;
|
|
77
|
+
if (safeString(payload.cwd) !== cwd) return null;
|
|
78
|
+
const threadId = safeString(payload.id);
|
|
79
|
+
return threadId || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function discoverRolloutFiles() {
|
|
83
|
+
const discovered = [];
|
|
84
|
+
for (const dir of sessionDirs()) {
|
|
85
|
+
if (!existsSync(dir)) continue;
|
|
86
|
+
const names = await readdir(dir).catch(() => []);
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue;
|
|
89
|
+
const path = join(dir, name);
|
|
90
|
+
const st = await stat(path).catch(() => null);
|
|
91
|
+
if (!st) continue;
|
|
92
|
+
if (st.mtimeMs < startedAt - fileWindowMs) continue;
|
|
93
|
+
discovered.push(path);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
discovered.sort();
|
|
97
|
+
return discovered;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function turnKey(threadId, turnId) {
|
|
101
|
+
return `${threadId || 'no-thread'}|${turnId || 'no-turn'}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildNotifyPayload(threadId, turnId, lastMessage) {
|
|
105
|
+
return {
|
|
106
|
+
type: 'agent-turn-complete',
|
|
107
|
+
cwd,
|
|
108
|
+
'thread-id': threadId,
|
|
109
|
+
'turn-id': turnId,
|
|
110
|
+
'input-messages': ['[notify-fallback] synthesized from rollout task_complete'],
|
|
111
|
+
'last-assistant-message': lastMessage || '',
|
|
112
|
+
source: 'notify-fallback-watcher',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function invokeNotifyHook(payload, filePath) {
|
|
117
|
+
const result = spawnSync(process.execPath, [notifyScript, JSON.stringify(payload)], {
|
|
118
|
+
cwd,
|
|
119
|
+
encoding: 'utf-8',
|
|
120
|
+
});
|
|
121
|
+
const ok = result.status === 0;
|
|
122
|
+
await eventLog({
|
|
123
|
+
type: 'fallback_notify',
|
|
124
|
+
ok,
|
|
125
|
+
thread_id: payload['thread-id'],
|
|
126
|
+
turn_id: payload['turn-id'],
|
|
127
|
+
file: filePath,
|
|
128
|
+
reason: ok ? 'sent' : 'notify_hook_failed',
|
|
129
|
+
error: ok ? undefined : (result.stderr || result.stdout || '').trim().slice(0, 240),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function processLine(meta, line, filePath) {
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(line);
|
|
137
|
+
} catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!parsed || parsed.type !== 'event_msg' || !parsed.payload) return;
|
|
142
|
+
if (parsed.payload.type !== 'task_complete') return;
|
|
143
|
+
|
|
144
|
+
const turnId = safeString(parsed.payload.turn_id);
|
|
145
|
+
if (!turnId) return;
|
|
146
|
+
|
|
147
|
+
const evtTs = Date.parse(safeString(parsed.timestamp));
|
|
148
|
+
if (Number.isFinite(evtTs) && evtTs < startedAt - 3000) return;
|
|
149
|
+
|
|
150
|
+
const key = turnKey(meta.threadId, turnId);
|
|
151
|
+
if (seenTurnKeys.has(key)) return;
|
|
152
|
+
seenTurnKeys.add(key);
|
|
153
|
+
|
|
154
|
+
const payload = buildNotifyPayload(
|
|
155
|
+
meta.threadId,
|
|
156
|
+
turnId,
|
|
157
|
+
safeString(parsed.payload.last_agent_message)
|
|
158
|
+
);
|
|
159
|
+
await invokeNotifyHook(payload, filePath);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function ensureTrackedFiles() {
|
|
163
|
+
const files = await discoverRolloutFiles();
|
|
164
|
+
for (const path of files) {
|
|
165
|
+
if (fileState.has(path)) continue;
|
|
166
|
+
const line = await readFirstLine(path).catch(() => '');
|
|
167
|
+
const threadId = shouldTrackSessionMeta(line);
|
|
168
|
+
if (!threadId) continue;
|
|
169
|
+
const size = (await stat(path).catch(() => ({ size: 0 }))).size || 0;
|
|
170
|
+
// In streaming mode, tail from current EOF to avoid replaying old events.
|
|
171
|
+
// In one-shot mode, read from start to catch just-finished turns.
|
|
172
|
+
const offset = runOnce ? 0 : size;
|
|
173
|
+
fileState.set(path, { threadId, offset, size, partial: '' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function pollFiles() {
|
|
178
|
+
for (const [path, meta] of fileState.entries()) {
|
|
179
|
+
const currentSize = (await stat(path).catch(() => ({ size: 0 }))).size || 0;
|
|
180
|
+
if (currentSize <= meta.offset) continue;
|
|
181
|
+
const content = await readFile(path, 'utf-8').catch(() => '');
|
|
182
|
+
if (!content) continue;
|
|
183
|
+
const delta = content.slice(meta.offset);
|
|
184
|
+
meta.offset = currentSize;
|
|
185
|
+
const merged = meta.partial + delta;
|
|
186
|
+
const lines = merged.split('\n');
|
|
187
|
+
meta.partial = lines.pop() || '';
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
if (!line.trim()) continue;
|
|
190
|
+
await processLine(meta, line, path);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function writeState() {
|
|
196
|
+
await mkdir(stateDir, { recursive: true }).catch(() => {});
|
|
197
|
+
const state = {
|
|
198
|
+
pid: process.pid,
|
|
199
|
+
started_at: new Date(startedAt).toISOString(),
|
|
200
|
+
cwd,
|
|
201
|
+
notify_script: notifyScript,
|
|
202
|
+
poll_ms: pollMs,
|
|
203
|
+
tracked_files: fileState.size,
|
|
204
|
+
seen_turns: seenTurnKeys.size,
|
|
205
|
+
};
|
|
206
|
+
await writeFile(statePath, JSON.stringify(state, null, 2)).catch(() => {});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function tick() {
|
|
210
|
+
if (stopping) return;
|
|
211
|
+
await ensureTrackedFiles();
|
|
212
|
+
await pollFiles();
|
|
213
|
+
await writeState();
|
|
214
|
+
setTimeout(tick, pollMs);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function shutdown(signal) {
|
|
218
|
+
stopping = true;
|
|
219
|
+
eventLog({ type: 'watcher_stop', signal }).finally(() => process.exit(0));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function main() {
|
|
223
|
+
await mkdir(logsDir, { recursive: true }).catch(() => {});
|
|
224
|
+
await mkdir(stateDir, { recursive: true }).catch(() => {});
|
|
225
|
+
if (!existsSync(notifyScript)) {
|
|
226
|
+
await eventLog({ type: 'watcher_error', reason: 'notify_script_missing', notify_script: notifyScript });
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
await eventLog({
|
|
231
|
+
type: 'watcher_start',
|
|
232
|
+
cwd,
|
|
233
|
+
notify_script: notifyScript,
|
|
234
|
+
poll_ms: pollMs,
|
|
235
|
+
once: runOnce,
|
|
236
|
+
});
|
|
237
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
238
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
239
|
+
process.on('SIGHUP', () => shutdown('SIGHUP'));
|
|
240
|
+
|
|
241
|
+
if (runOnce) {
|
|
242
|
+
await ensureTrackedFiles();
|
|
243
|
+
await pollFiles();
|
|
244
|
+
await writeState();
|
|
245
|
+
await eventLog({ type: 'watcher_once_complete', seen_turns: seenTurnKeys.size });
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await tick();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
main().catch(async (err) => {
|
|
253
|
+
await mkdir(dirname(logPath), { recursive: true }).catch(() => {});
|
|
254
|
+
await eventLog({
|
|
255
|
+
type: 'watcher_error',
|
|
256
|
+
reason: 'fatal',
|
|
257
|
+
error: err instanceof Error ? err.message : safeString(err),
|
|
258
|
+
});
|
|
259
|
+
process.exit(1);
|
|
260
|
+
});
|
package/scripts/notify-hook.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { writeFile, appendFile, mkdir, readFile } from 'fs/promises';
|
|
16
|
-
import { join } from 'path';
|
|
16
|
+
import { join, resolve as resolvePath } from 'path';
|
|
17
17
|
import { existsSync } from 'fs';
|
|
18
18
|
import { spawn } from 'child_process';
|
|
19
19
|
import {
|
|
@@ -173,6 +173,7 @@ function normalizeTmuxState(raw) {
|
|
|
173
173
|
if (!raw || typeof raw !== 'object') {
|
|
174
174
|
return {
|
|
175
175
|
total_injections: 0,
|
|
176
|
+
pane_counts: {},
|
|
176
177
|
session_counts: {},
|
|
177
178
|
recent_keys: {},
|
|
178
179
|
last_injection_ts: 0,
|
|
@@ -182,6 +183,7 @@ function normalizeTmuxState(raw) {
|
|
|
182
183
|
}
|
|
183
184
|
return {
|
|
184
185
|
total_injections: asNumber(raw.total_injections) ?? 0,
|
|
186
|
+
pane_counts: raw.pane_counts && typeof raw.pane_counts === 'object' ? raw.pane_counts : {},
|
|
185
187
|
session_counts: raw.session_counts && typeof raw.session_counts === 'object' ? raw.session_counts : {},
|
|
186
188
|
recent_keys: raw.recent_keys && typeof raw.recent_keys === 'object' ? raw.recent_keys : {},
|
|
187
189
|
last_injection_ts: asNumber(raw.last_injection_ts) ?? 0,
|
|
@@ -190,6 +192,30 @@ function normalizeTmuxState(raw) {
|
|
|
190
192
|
};
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
function normalizeNotifyState(raw) {
|
|
196
|
+
if (!raw || typeof raw !== 'object') {
|
|
197
|
+
return {
|
|
198
|
+
recent_turns: {},
|
|
199
|
+
last_event_at: '',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
recent_turns: raw.recent_turns && typeof raw.recent_turns === 'object' ? raw.recent_turns : {},
|
|
204
|
+
last_event_at: safeString(raw.last_event_at),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function pruneRecentTurns(recentTurns, now) {
|
|
209
|
+
const pruned = {};
|
|
210
|
+
const minTs = now - (24 * 60 * 60 * 1000);
|
|
211
|
+
const entries = Object.entries(recentTurns || {}).slice(-2000);
|
|
212
|
+
for (const [key, value] of entries) {
|
|
213
|
+
const ts = asNumber(value);
|
|
214
|
+
if (ts !== null && ts >= minTs) pruned[key] = ts;
|
|
215
|
+
}
|
|
216
|
+
return pruned;
|
|
217
|
+
}
|
|
218
|
+
|
|
193
219
|
function pruneRecentKeys(recentKeys, now) {
|
|
194
220
|
const pruned = {};
|
|
195
221
|
const minTs = now - (24 * 60 * 60 * 1000);
|
|
@@ -246,22 +272,140 @@ function runProcess(command, args, timeoutMs = 3000) {
|
|
|
246
272
|
});
|
|
247
273
|
}
|
|
248
274
|
|
|
249
|
-
async function
|
|
250
|
-
|
|
251
|
-
|
|
275
|
+
async function resolveSessionToPane(sessionName) {
|
|
276
|
+
const result = await runProcess('tmux', ['list-panes', '-t', sessionName, '-F', '#{pane_id} #{pane_active}']);
|
|
277
|
+
const lines = result.stdout
|
|
278
|
+
.split('\n')
|
|
279
|
+
.map(line => line.trim())
|
|
280
|
+
.filter(Boolean);
|
|
281
|
+
if (lines.length === 0) return null;
|
|
282
|
+
const active = lines.find(line => line.endsWith(' 1')) || lines[0];
|
|
283
|
+
const paneId = active.split(' ')[0];
|
|
284
|
+
return paneId || null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function resolvePaneByCwd(expectedCwd) {
|
|
288
|
+
if (!expectedCwd) return null;
|
|
289
|
+
const result = await runProcess('tmux', ['list-panes', '-a', '-F', '#{pane_id}\t#{pane_current_path}\t#{pane_active}\t#{session_name}']);
|
|
290
|
+
const lines = result.stdout
|
|
291
|
+
.split('\n')
|
|
292
|
+
.map(line => line.trim())
|
|
293
|
+
.filter(Boolean);
|
|
294
|
+
|
|
295
|
+
const expected = resolvePath(expectedCwd);
|
|
296
|
+
const candidates = [];
|
|
297
|
+
for (const line of lines) {
|
|
298
|
+
const parts = line.split('\t');
|
|
299
|
+
if (parts.length < 4) continue;
|
|
300
|
+
const [paneId, paneCwd, activeRaw, sessionName] = parts;
|
|
301
|
+
if (!paneId || !paneCwd) continue;
|
|
302
|
+
if (resolvePath(paneCwd) !== expected) continue;
|
|
303
|
+
const active = activeRaw === '1';
|
|
304
|
+
candidates.push({ paneId, paneCwd, active, sessionName: sessionName || null });
|
|
305
|
+
}
|
|
306
|
+
if (candidates.length === 0) return null;
|
|
307
|
+
|
|
308
|
+
const pick = candidates.find(c => c.active) || candidates[0];
|
|
309
|
+
return pick;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function resolvePaneTarget(target, fallbackPane, expectedCwd, modePane) {
|
|
313
|
+
if (modePane) {
|
|
314
|
+
try {
|
|
315
|
+
const modePaneResult = await runProcess('tmux', ['display-message', '-p', '-t', modePane, '#{pane_id}']);
|
|
316
|
+
const paneId = safeString(modePaneResult.stdout).trim();
|
|
317
|
+
if (paneId) {
|
|
318
|
+
if (expectedCwd) {
|
|
319
|
+
const paneCwdResult = await runProcess('tmux', ['display-message', '-p', '-t', paneId, '#{pane_current_path}']);
|
|
320
|
+
const paneCwd = safeString(paneCwdResult.stdout).trim();
|
|
321
|
+
if (!paneCwd || resolvePath(paneCwd) === resolvePath(expectedCwd)) {
|
|
322
|
+
const currentSession = await runProcess('tmux', ['display-message', '-p', '-t', paneId, '#S']);
|
|
323
|
+
const sessionName = safeString(currentSession.stdout).trim();
|
|
324
|
+
return {
|
|
325
|
+
paneTarget: paneId,
|
|
326
|
+
reason: 'fallback_mode_state_pane',
|
|
327
|
+
matched_session: sessionName || null,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
const currentSession = await runProcess('tmux', ['display-message', '-p', '-t', paneId, '#S']);
|
|
332
|
+
const sessionName = safeString(currentSession.stdout).trim();
|
|
333
|
+
return {
|
|
334
|
+
paneTarget: paneId,
|
|
335
|
+
reason: 'fallback_mode_state_pane',
|
|
336
|
+
matched_session: sessionName || null,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// Fall through to config/fallback probes
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!target) return { paneTarget: null, reason: 'invalid_target' };
|
|
346
|
+
|
|
347
|
+
if (target.type === 'pane') {
|
|
348
|
+
try {
|
|
349
|
+
const result = await runProcess('tmux', ['display-message', '-p', '-t', target.value, '#{pane_id}']);
|
|
350
|
+
const paneId = safeString(result.stdout).trim();
|
|
351
|
+
if (paneId) return { paneTarget: paneId, reason: 'ok' };
|
|
352
|
+
} catch {
|
|
353
|
+
// Fall through to fallback probe
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
try {
|
|
357
|
+
const paneId = await resolveSessionToPane(target.value);
|
|
358
|
+
if (paneId) return { paneTarget: paneId, reason: 'ok' };
|
|
359
|
+
} catch {
|
|
360
|
+
// Fall through to fallback probe
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (fallbackPane) {
|
|
365
|
+
try {
|
|
366
|
+
const currentPane = await runProcess('tmux', ['display-message', '-p', '-t', fallbackPane, '#{pane_id}']);
|
|
367
|
+
const paneId = safeString(currentPane.stdout).trim();
|
|
368
|
+
if (paneId) {
|
|
369
|
+
if (expectedCwd) {
|
|
370
|
+
const paneCwdResult = await runProcess('tmux', ['display-message', '-p', '-t', paneId, '#{pane_current_path}']);
|
|
371
|
+
const paneCwd = safeString(paneCwdResult.stdout).trim();
|
|
372
|
+
if (paneCwd && resolvePath(paneCwd) !== resolvePath(expectedCwd)) {
|
|
373
|
+
return {
|
|
374
|
+
paneTarget: null,
|
|
375
|
+
reason: 'pane_cwd_mismatch',
|
|
376
|
+
pane_cwd: paneCwd,
|
|
377
|
+
expected_cwd: expectedCwd,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const currentSession = await runProcess('tmux', ['display-message', '-p', '-t', paneId, '#S']);
|
|
383
|
+
const sessionName = safeString(currentSession.stdout).trim();
|
|
384
|
+
return {
|
|
385
|
+
paneTarget: paneId,
|
|
386
|
+
reason: 'fallback_current_pane',
|
|
387
|
+
matched_session: sessionName || null,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
// Fall through
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
252
395
|
try {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return paneId || null;
|
|
396
|
+
const match = await resolvePaneByCwd(expectedCwd);
|
|
397
|
+
if (match && match.paneId) {
|
|
398
|
+
return {
|
|
399
|
+
paneTarget: match.paneId,
|
|
400
|
+
reason: 'fallback_pane_by_cwd',
|
|
401
|
+
matched_session: match.sessionName,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
262
404
|
} catch {
|
|
263
|
-
|
|
405
|
+
// Fall through
|
|
264
406
|
}
|
|
407
|
+
|
|
408
|
+
return { paneTarget: null, reason: 'target_not_found' };
|
|
265
409
|
}
|
|
266
410
|
|
|
267
411
|
async function logTmuxHookEvent(logsDir, event) {
|
|
@@ -310,6 +454,7 @@ async function handleTmuxInjection({
|
|
|
310
454
|
state.recent_keys = pruneRecentKeys(state.recent_keys, now);
|
|
311
455
|
|
|
312
456
|
const activeModes = [];
|
|
457
|
+
const activeModeStates = {};
|
|
313
458
|
try {
|
|
314
459
|
const scopedDirs = await getScopedStateDirsForCurrentSession(stateDir);
|
|
315
460
|
for (const scopedDir of scopedDirs) {
|
|
@@ -319,7 +464,9 @@ async function handleTmuxInjection({
|
|
|
319
464
|
const path = join(scopedDir, file);
|
|
320
465
|
const parsed = JSON.parse(await readFile(path, 'utf-8'));
|
|
321
466
|
if (parsed && parsed.active) {
|
|
322
|
-
|
|
467
|
+
const modeName = file.replace('-state.json', '');
|
|
468
|
+
activeModes.push(modeName);
|
|
469
|
+
activeModeStates[modeName] = parsed;
|
|
323
470
|
}
|
|
324
471
|
}
|
|
325
472
|
}
|
|
@@ -328,7 +475,9 @@ async function handleTmuxInjection({
|
|
|
328
475
|
}
|
|
329
476
|
|
|
330
477
|
const mode = pickActiveMode(activeModes, config.allowed_modes);
|
|
331
|
-
const
|
|
478
|
+
const modeState = mode ? (activeModeStates[mode] || {}) : {};
|
|
479
|
+
const modePane = safeString(modeState.tmux_pane_id || '');
|
|
480
|
+
const preGuard = evaluateInjectionGuards({
|
|
332
481
|
config,
|
|
333
482
|
mode,
|
|
334
483
|
sourceText,
|
|
@@ -336,6 +485,7 @@ async function handleTmuxInjection({
|
|
|
336
485
|
threadId,
|
|
337
486
|
turnId,
|
|
338
487
|
sessionKey,
|
|
488
|
+
skipQuotaChecks: true,
|
|
339
489
|
now,
|
|
340
490
|
state,
|
|
341
491
|
});
|
|
@@ -344,7 +494,7 @@ async function handleTmuxInjection({
|
|
|
344
494
|
timestamp: nowIso,
|
|
345
495
|
type: 'tmux_hook',
|
|
346
496
|
mode,
|
|
347
|
-
reason:
|
|
497
|
+
reason: preGuard.reason,
|
|
348
498
|
turn_id: turnId,
|
|
349
499
|
thread_id: threadId,
|
|
350
500
|
target: config.target,
|
|
@@ -352,8 +502,8 @@ async function handleTmuxInjection({
|
|
|
352
502
|
sent: false,
|
|
353
503
|
};
|
|
354
504
|
|
|
355
|
-
if (!
|
|
356
|
-
state.last_reason =
|
|
505
|
+
if (!preGuard.allow) {
|
|
506
|
+
state.last_reason = preGuard.reason;
|
|
357
507
|
state.last_event_at = nowIso;
|
|
358
508
|
await writeFile(hookStatePath, JSON.stringify(state, null, 2)).catch(() => {});
|
|
359
509
|
if (config.enabled || config.log_level === 'debug') {
|
|
@@ -368,15 +518,65 @@ async function handleTmuxInjection({
|
|
|
368
518
|
turnId,
|
|
369
519
|
timestamp: nowIso,
|
|
370
520
|
});
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
521
|
+
const fallbackPane = safeString(process.env.TMUX_PANE || '');
|
|
522
|
+
const resolution = await resolvePaneTarget(config.target, fallbackPane, cwd, modePane);
|
|
523
|
+
if (!resolution.paneTarget) {
|
|
524
|
+
state.last_reason = resolution.reason;
|
|
525
|
+
state.last_event_at = nowIso;
|
|
526
|
+
await writeFile(hookStatePath, JSON.stringify(state, null, 2)).catch(() => {});
|
|
527
|
+
await logTmuxHookEvent(logsDir, {
|
|
528
|
+
...baseLog,
|
|
529
|
+
event: 'injection_skipped',
|
|
530
|
+
reason: resolution.reason,
|
|
531
|
+
pane_cwd: resolution.pane_cwd,
|
|
532
|
+
expected_cwd: resolution.expected_cwd,
|
|
533
|
+
});
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const paneTarget = resolution.paneTarget;
|
|
537
|
+
|
|
538
|
+
// Final guard phase: pane is canonical identity for quota/cooldown.
|
|
539
|
+
const guard = evaluateInjectionGuards({
|
|
540
|
+
config,
|
|
541
|
+
mode,
|
|
542
|
+
sourceText,
|
|
543
|
+
assistantMessage,
|
|
544
|
+
threadId,
|
|
545
|
+
turnId,
|
|
546
|
+
paneKey: paneTarget,
|
|
547
|
+
sessionKey,
|
|
548
|
+
now,
|
|
549
|
+
state,
|
|
550
|
+
});
|
|
551
|
+
if (!guard.allow) {
|
|
552
|
+
state.last_reason = guard.reason;
|
|
374
553
|
state.last_event_at = nowIso;
|
|
375
554
|
await writeFile(hookStatePath, JSON.stringify(state, null, 2)).catch(() => {});
|
|
376
|
-
await logTmuxHookEvent(logsDir, { ...baseLog, event: 'injection_skipped', reason:
|
|
555
|
+
await logTmuxHookEvent(logsDir, { ...baseLog, event: 'injection_skipped', reason: guard.reason });
|
|
377
556
|
return;
|
|
378
557
|
}
|
|
379
558
|
|
|
559
|
+
// Pane-canonical healing: persist resolved pane target so routing stops depending on session names.
|
|
560
|
+
// Legacy configs with target.type="session" remain accepted but are auto-migrated on success.
|
|
561
|
+
if (config.target && config.target.type !== 'pane') {
|
|
562
|
+
try {
|
|
563
|
+
const healed = {
|
|
564
|
+
...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}),
|
|
565
|
+
target: { type: 'pane', value: paneTarget },
|
|
566
|
+
};
|
|
567
|
+
await writeFile(configPath, JSON.stringify(healed, null, 2) + '\n');
|
|
568
|
+
await logTmuxHookEvent(logsDir, {
|
|
569
|
+
...baseLog,
|
|
570
|
+
event: 'target_healed',
|
|
571
|
+
reason: 'migrated_to_pane_target',
|
|
572
|
+
previous_target: config.target.value,
|
|
573
|
+
healed_target: paneTarget,
|
|
574
|
+
});
|
|
575
|
+
} catch {
|
|
576
|
+
// Non-fatal
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
380
580
|
const argv = buildSendKeysArgv({
|
|
381
581
|
paneTarget,
|
|
382
582
|
prompt,
|
|
@@ -390,7 +590,8 @@ async function handleTmuxInjection({
|
|
|
390
590
|
if (success) {
|
|
391
591
|
state.last_injection_ts = now;
|
|
392
592
|
state.total_injections = (asNumber(state.total_injections) ?? 0) + 1;
|
|
393
|
-
state.
|
|
593
|
+
state.pane_counts = state.pane_counts && typeof state.pane_counts === 'object' ? state.pane_counts : {};
|
|
594
|
+
state.pane_counts[paneTarget] = (asNumber(state.pane_counts[paneTarget]) ?? 0) + 1;
|
|
394
595
|
state.last_target = paneTarget;
|
|
395
596
|
state.last_prompt_preview = prompt.slice(0, 120);
|
|
396
597
|
}
|
|
@@ -410,7 +611,12 @@ async function handleTmuxInjection({
|
|
|
410
611
|
}
|
|
411
612
|
|
|
412
613
|
try {
|
|
413
|
-
await runProcess('tmux', argv, 3000);
|
|
614
|
+
await runProcess('tmux', argv.typeArgv, 3000);
|
|
615
|
+
for (const submit of argv.submitArgv) {
|
|
616
|
+
await runProcess('tmux', submit, 3000);
|
|
617
|
+
// Give the pane a moment to process the keypress; avoids occasional missed submits.
|
|
618
|
+
await new Promise(r => setTimeout(r, 25));
|
|
619
|
+
}
|
|
414
620
|
updateStateForAttempt(true, 'injection_sent');
|
|
415
621
|
await writeFile(hookStatePath, JSON.stringify(state, null, 2)).catch(() => {});
|
|
416
622
|
await logTmuxHookEvent(logsDir, {
|
|
@@ -419,6 +625,7 @@ async function handleTmuxInjection({
|
|
|
419
625
|
reason: 'ok',
|
|
420
626
|
pane_target: paneTarget,
|
|
421
627
|
sent: true,
|
|
628
|
+
argv,
|
|
422
629
|
});
|
|
423
630
|
} catch (err) {
|
|
424
631
|
updateStateForAttempt(false, 'send_failed');
|
|
@@ -518,6 +725,29 @@ async function main() {
|
|
|
518
725
|
await mkdir(logsDir, { recursive: true }).catch(() => {});
|
|
519
726
|
await mkdir(stateDir, { recursive: true }).catch(() => {});
|
|
520
727
|
|
|
728
|
+
// Turn-level dedupe prevents double-processing when native notify and fallback
|
|
729
|
+
// watcher both emit the same completed turn.
|
|
730
|
+
try {
|
|
731
|
+
const turnId = safeString(payload['turn-id'] || payload.turn_id || '');
|
|
732
|
+
if (turnId) {
|
|
733
|
+
const now = Date.now();
|
|
734
|
+
const threadId = safeString(payload['thread-id'] || payload.thread_id || '');
|
|
735
|
+
const eventType = safeString(payload.type || 'agent-turn-complete');
|
|
736
|
+
const key = `${threadId || 'no-thread'}|${turnId}|${eventType}`;
|
|
737
|
+
const dedupeStatePath = join(stateDir, 'notify-hook-state.json');
|
|
738
|
+
const dedupeState = normalizeNotifyState(await readJsonIfExists(dedupeStatePath, null));
|
|
739
|
+
dedupeState.recent_turns = pruneRecentTurns(dedupeState.recent_turns, now);
|
|
740
|
+
if (dedupeState.recent_turns[key]) {
|
|
741
|
+
process.exit(0);
|
|
742
|
+
}
|
|
743
|
+
dedupeState.recent_turns[key] = now;
|
|
744
|
+
dedupeState.last_event_at = new Date().toISOString();
|
|
745
|
+
await writeFile(dedupeStatePath, JSON.stringify(dedupeState, null, 2)).catch(() => {});
|
|
746
|
+
}
|
|
747
|
+
} catch {
|
|
748
|
+
// Non-critical
|
|
749
|
+
}
|
|
750
|
+
|
|
521
751
|
// 1. Log the turn
|
|
522
752
|
const logEntry = {
|
|
523
753
|
timestamp: new Date().toISOString(),
|