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.
Files changed (52) hide show
  1. package/README.md +11 -1
  2. package/dist/cli/__tests__/index.test.js +13 -1
  3. package/dist/cli/__tests__/index.test.js.map +1 -1
  4. package/dist/cli/index.d.ts +1 -0
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +113 -3
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/tmux-hook.d.ts.map +1 -1
  9. package/dist/cli/tmux-hook.js +52 -6
  10. package/dist/cli/tmux-hook.js.map +1 -1
  11. package/dist/cli/update.d.ts.map +1 -1
  12. package/dist/cli/update.js +12 -9
  13. package/dist/cli/update.js.map +1 -1
  14. package/dist/config/__tests__/generator-notify.test.d.ts +2 -0
  15. package/dist/config/__tests__/generator-notify.test.d.ts.map +1 -0
  16. package/dist/config/__tests__/generator-notify.test.js +45 -0
  17. package/dist/config/__tests__/generator-notify.test.js.map +1 -0
  18. package/dist/config/generator.d.ts.map +1 -1
  19. package/dist/config/generator.js +19 -1
  20. package/dist/config/generator.js.map +1 -1
  21. package/dist/hooks/__tests__/notify-fallback-watcher.test.d.ts +2 -0
  22. package/dist/hooks/__tests__/notify-fallback-watcher.test.d.ts.map +1 -0
  23. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +142 -0
  24. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -0
  25. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.d.ts +2 -0
  26. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.d.ts.map +1 -0
  27. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +436 -0
  28. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -0
  29. package/dist/hooks/__tests__/tmux-hook-engine.test.js +27 -4
  30. package/dist/hooks/__tests__/tmux-hook-engine.test.js.map +1 -1
  31. package/dist/mcp/state-server.js +3 -1
  32. package/dist/mcp/state-server.js.map +1 -1
  33. package/dist/modes/__tests__/base-tmux-pane.test.d.ts +2 -0
  34. package/dist/modes/__tests__/base-tmux-pane.test.d.ts.map +1 -0
  35. package/dist/modes/__tests__/base-tmux-pane.test.js +27 -0
  36. package/dist/modes/__tests__/base-tmux-pane.test.js.map +1 -0
  37. package/dist/modes/base.d.ts.map +1 -1
  38. package/dist/modes/base.js +5 -2
  39. package/dist/modes/base.js.map +1 -1
  40. package/dist/state/__tests__/mode-state-context.test.d.ts +2 -0
  41. package/dist/state/__tests__/mode-state-context.test.d.ts.map +1 -0
  42. package/dist/state/__tests__/mode-state-context.test.js +35 -0
  43. package/dist/state/__tests__/mode-state-context.test.js.map +1 -0
  44. package/dist/state/mode-state-context.d.ts +12 -0
  45. package/dist/state/mode-state-context.d.ts.map +1 -0
  46. package/dist/state/mode-state-context.js +28 -0
  47. package/dist/state/mode-state-context.js.map +1 -0
  48. package/package.json +1 -1
  49. package/scripts/notify-fallback-watcher.js +260 -0
  50. package/scripts/notify-hook.js +255 -25
  51. package/scripts/tmux-hook-engine.js +34 -12
  52. 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
+ });
@@ -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 resolvePaneTarget(target) {
250
- if (!target) return null;
251
- if (target.type === 'pane') return target.value;
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 result = await runProcess('tmux', ['list-panes', '-t', target.value, '-F', '#{pane_id} #{pane_active}']);
254
- const lines = result.stdout
255
- .split('\n')
256
- .map(line => line.trim())
257
- .filter(Boolean);
258
- if (lines.length === 0) return null;
259
- const active = lines.find(line => line.endsWith(' 1')) || lines[0];
260
- const paneId = active.split(' ')[0];
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
- return null;
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
- activeModes.push(file.replace('-state.json', ''));
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 guard = evaluateInjectionGuards({
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: guard.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 (!guard.allow) {
356
- state.last_reason = guard.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 paneTarget = await resolvePaneTarget(config.target);
372
- if (!paneTarget) {
373
- state.last_reason = 'target_not_found';
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: 'target_not_found' });
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.session_counts[sessionKey] = (asNumber(state.session_counts[sessionKey]) ?? 0) + 1;
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(),