@holon-run/agentinbox 0.5.0 → 0.7.0

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.
@@ -10,9 +10,11 @@ const node_util_1 = require("node:util");
10
10
  const node_fs_1 = __importDefault(require("node:fs"));
11
11
  const node_path_1 = __importDefault(require("node:path"));
12
12
  const node_os_1 = __importDefault(require("node:os"));
13
+ const logging_1 = require("./logging");
13
14
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
14
- const DEFAULT_ITERM2_SAMPLE_DELAY_MS = 250;
15
- const DEFAULT_TMUX_SAMPLE_DELAY_MS = 250;
15
+ const DEFAULT_ITERM2_SAMPLE_DELAY_MS = 900;
16
+ const DEFAULT_TMUX_SAMPLE_DELAY_MS = 900;
17
+ const NORMALIZED_BUFFER_TAIL_LINES = 20;
16
18
  const ACTIVE_BUFFER_MARKERS = [
17
19
  "esc to interrupt",
18
20
  "Working (",
@@ -25,22 +27,36 @@ const TYPING_PROMPT_PREFIXES = [
25
27
  class DefaultActivationGate {
26
28
  runtimeProbes;
27
29
  terminalProbes;
28
- constructor(runtimeProbes = [
29
- new CodexRuntimePresenceProbe(),
30
- new ClaudeCodeRuntimePresenceProbe(),
31
- ], terminalProbes = [
32
- new CodexTerminalStateProbe(),
33
- new Iterm2TerminalStateProbe(),
34
- new ClaudeCodeTerminalStateProbe(),
35
- new TmuxTerminalStateProbe(),
36
- ]) {
37
- this.runtimeProbes = runtimeProbes;
38
- this.terminalProbes = terminalProbes;
30
+ logger;
31
+ constructor(runtimeProbes, terminalProbes, logger = new logging_1.NoopLogger()) {
32
+ this.logger = logger.child("activation_gate");
33
+ this.runtimeProbes = runtimeProbes ?? [
34
+ new CodexRuntimePresenceProbe(undefined, undefined, this.logger.child("codex_runtime")),
35
+ new ClaudeCodeRuntimePresenceProbe(undefined, undefined, this.logger.child("claude_runtime")),
36
+ ];
37
+ this.terminalProbes = terminalProbes ?? [
38
+ new CodexTerminalStateProbe(undefined, undefined, undefined, undefined, undefined, this.logger.child("codex_terminal")),
39
+ new Iterm2TerminalStateProbe(undefined, {}, this.logger.child("iterm2_terminal")),
40
+ new ClaudeCodeTerminalStateProbe(undefined, undefined, undefined, this.logger.child("claude_terminal")),
41
+ new TmuxTerminalStateProbe(undefined, {}, this.logger.child("tmux_terminal")),
42
+ ];
39
43
  }
40
44
  async evaluate(target) {
41
45
  const runtimeProbe = this.runtimeProbes.find((probe) => probe.supports(target));
42
46
  const runtimePresence = runtimeProbe ? await runtimeProbe.check(target) : "unknown";
43
47
  if (runtimePresence === "gone") {
48
+ this.logger.debug("gate_decision", {
49
+ targetId: target.targetId,
50
+ runtimeKind: target.runtimeKind,
51
+ backend: target.backend,
52
+ runtimeProbe: runtimeProbe?.constructor?.name ?? null,
53
+ runtimePresence,
54
+ terminalProbe: null,
55
+ terminalPresence: null,
56
+ terminalBusy: null,
57
+ outcome: "offline",
58
+ reason: "runtime_gone",
59
+ });
44
60
  return { outcome: "offline", reason: "runtime_gone" };
45
61
  }
46
62
  const terminalProbe = this.terminalProbes.find((probe) => probe.supports(target));
@@ -48,14 +64,62 @@ class DefaultActivationGate {
48
64
  ? await terminalProbe.check(target)
49
65
  : { presence: "unknown", busy: "unknown" };
50
66
  if (terminalState.presence === "gone") {
67
+ this.logger.debug("gate_decision", {
68
+ targetId: target.targetId,
69
+ runtimeKind: target.runtimeKind,
70
+ backend: target.backend,
71
+ runtimeProbe: runtimeProbe?.constructor?.name ?? null,
72
+ runtimePresence,
73
+ terminalProbe: terminalProbe?.constructor?.name ?? null,
74
+ terminalPresence: terminalState.presence,
75
+ terminalBusy: terminalState.busy,
76
+ outcome: "offline",
77
+ reason: "terminal_gone",
78
+ });
51
79
  return { outcome: "offline", reason: "terminal_gone" };
52
80
  }
53
81
  if (terminalState.busy === "busy") {
82
+ this.logger.debug("gate_decision", {
83
+ targetId: target.targetId,
84
+ runtimeKind: target.runtimeKind,
85
+ backend: target.backend,
86
+ runtimeProbe: runtimeProbe?.constructor?.name ?? null,
87
+ runtimePresence,
88
+ terminalProbe: terminalProbe?.constructor?.name ?? null,
89
+ terminalPresence: terminalState.presence,
90
+ terminalBusy: terminalState.busy,
91
+ outcome: "defer",
92
+ reason: "terminal_busy",
93
+ });
54
94
  return { outcome: "defer", reason: "terminal_busy" };
55
95
  }
56
96
  if (target.runtimeKind === "codex" && terminalState.busy === "idle") {
97
+ this.logger.debug("gate_decision", {
98
+ targetId: target.targetId,
99
+ runtimeKind: target.runtimeKind,
100
+ backend: target.backend,
101
+ runtimeProbe: runtimeProbe?.constructor?.name ?? null,
102
+ runtimePresence,
103
+ terminalProbe: terminalProbe?.constructor?.name ?? null,
104
+ terminalPresence: terminalState.presence,
105
+ terminalBusy: terminalState.busy,
106
+ outcome: "defer",
107
+ reason: "terminal_recently_active",
108
+ });
57
109
  return { outcome: "defer", reason: "terminal_recently_active" };
58
110
  }
111
+ this.logger.debug("gate_decision", {
112
+ targetId: target.targetId,
113
+ runtimeKind: target.runtimeKind,
114
+ backend: target.backend,
115
+ runtimeProbe: runtimeProbe?.constructor?.name ?? null,
116
+ runtimePresence,
117
+ terminalProbe: terminalProbe?.constructor?.name ?? null,
118
+ terminalPresence: terminalState.presence,
119
+ terminalBusy: terminalState.busy,
120
+ outcome: "inject",
121
+ reason: "gate_unknown_or_idle",
122
+ });
59
123
  return { outcome: "inject", reason: "gate_unknown_or_idle" };
60
124
  }
61
125
  }
@@ -63,36 +127,45 @@ exports.DefaultActivationGate = DefaultActivationGate;
63
127
  class CodexRuntimePresenceProbe {
64
128
  killFn;
65
129
  sessionFileReader;
66
- constructor(killFn = (pid, signal) => process.kill(pid, signal), sessionFileReader) {
130
+ logger;
131
+ constructor(killFn = (pid, signal) => process.kill(pid, signal), sessionFileReader, logger = new logging_1.NoopLogger()) {
67
132
  this.killFn = killFn;
68
133
  this.sessionFileReader = sessionFileReader;
134
+ this.logger = logger;
69
135
  }
70
136
  supports(target) {
71
137
  return target.runtimeKind === "codex" && Number.isInteger(target.runtimePid);
72
138
  }
73
139
  async check(target) {
74
140
  if (!Number.isInteger(target.runtimePid)) {
141
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "unknown", reason: "missing_runtime_pid" });
75
142
  return "unknown";
76
143
  }
77
144
  try {
78
145
  this.killFn(target.runtimePid, 0);
79
146
  if (!target.runtimeSessionId) {
147
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "unknown", reason: "missing_runtime_session_id" });
80
148
  return "unknown";
81
149
  }
82
150
  const sessionInfo = await (this.sessionFileReader ?? defaultCodexSessionFileReader)(target.runtimeSessionId);
83
151
  if (sessionInfo === "unknown") {
152
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "unknown", reason: "session_file_unknown" });
84
153
  return "unknown";
85
154
  }
86
155
  if (!sessionInfo || sessionInfo.sessionId !== target.runtimeSessionId) {
156
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "gone", reason: "session_file_mismatch" });
87
157
  return "gone";
88
158
  }
159
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "alive" });
89
160
  return "alive";
90
161
  }
91
162
  catch (error) {
92
163
  const code = error instanceof Error && "code" in error ? String(error.code ?? "") : "";
93
164
  if (code === "ESRCH") {
165
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "gone", reason: "process_missing" });
94
166
  return "gone";
95
167
  }
168
+ this.logger.debug("probe_error", { targetId: target.targetId, error, result: "unknown" });
96
169
  return "unknown";
97
170
  }
98
171
  }
@@ -104,12 +177,14 @@ class CodexTerminalStateProbe {
104
177
  sessionFileReader;
105
178
  statReader;
106
179
  nowFn;
107
- constructor(iTermProbe = new Iterm2TerminalStateProbe(), tmuxProbe = new TmuxTerminalStateProbe(), sessionFileReader, statReader, nowFn = () => Date.now()) {
180
+ logger;
181
+ constructor(iTermProbe = new Iterm2TerminalStateProbe(), tmuxProbe = new TmuxTerminalStateProbe(), sessionFileReader, statReader, nowFn = () => Date.now(), logger = new logging_1.NoopLogger()) {
108
182
  this.iTermProbe = iTermProbe;
109
183
  this.tmuxProbe = tmuxProbe;
110
184
  this.sessionFileReader = sessionFileReader;
111
185
  this.statReader = statReader;
112
186
  this.nowFn = nowFn;
187
+ this.logger = logger;
113
188
  }
114
189
  supports(target) {
115
190
  return target.runtimeKind === "codex";
@@ -117,26 +192,66 @@ class CodexTerminalStateProbe {
117
192
  async check(target) {
118
193
  const terminalState = await this.readTerminalState(target);
119
194
  if (!target.runtimeSessionId) {
195
+ this.logger.trace("probe_result", {
196
+ targetId: target.targetId,
197
+ result: terminalState,
198
+ reason: "missing_runtime_session_id",
199
+ });
120
200
  return terminalState;
121
201
  }
122
202
  const sessionInfo = await (this.sessionFileReader ?? defaultCodexSessionFileReader)(target.runtimeSessionId);
123
203
  if (sessionInfo === "unknown" || !sessionInfo || sessionInfo.sessionId !== target.runtimeSessionId) {
204
+ this.logger.trace("probe_result", {
205
+ targetId: target.targetId,
206
+ result: terminalState,
207
+ reason: "session_file_unavailable",
208
+ });
124
209
  return terminalState;
125
210
  }
126
211
  const stat = await (this.statReader ?? defaultFileStatReader)(sessionInfo.filePath);
127
212
  if (!stat) {
213
+ this.logger.trace("probe_result", {
214
+ targetId: target.targetId,
215
+ result: terminalState,
216
+ reason: "session_file_stat_missing",
217
+ });
128
218
  return terminalState;
129
219
  }
130
220
  if (terminalState.busy === "busy" || terminalState.presence === "gone") {
221
+ this.logger.trace("probe_result", {
222
+ targetId: target.targetId,
223
+ result: terminalState,
224
+ reason: "terminal_probe_decisive",
225
+ });
131
226
  return terminalState;
132
227
  }
133
228
  const ageMs = this.nowFn() - stat.mtimeMs;
134
229
  if (ageMs < 5000) {
135
- return { presence: terminalState.presence, busy: "busy" };
230
+ const result = { presence: terminalState.presence, busy: "busy" };
231
+ this.logger.trace("probe_result", {
232
+ targetId: target.targetId,
233
+ result,
234
+ reason: "session_file_recent_activity",
235
+ ageMs,
236
+ });
237
+ return result;
136
238
  }
137
239
  if (ageMs < 30000) {
138
- return { presence: terminalState.presence, busy: "idle" };
139
- }
240
+ const result = { presence: terminalState.presence, busy: "idle" };
241
+ this.logger.trace("probe_result", {
242
+ targetId: target.targetId,
243
+ result,
244
+ reason: "session_file_recently_active",
245
+ ageMs,
246
+ });
247
+ return result;
248
+ }
249
+ this.logger.trace("probe_result", {
250
+ targetId: target.targetId,
251
+ result: terminalState,
252
+ reason: "session_file_stale",
253
+ ageMs,
254
+ });
140
255
  return terminalState;
141
256
  }
142
257
  async readTerminalState(target) {
@@ -153,9 +268,11 @@ exports.CodexTerminalStateProbe = CodexTerminalStateProbe;
153
268
  class Iterm2TerminalStateProbe {
154
269
  execAsync;
155
270
  options;
156
- constructor(execAsync = execFileAsync, options = {}) {
271
+ logger;
272
+ constructor(execAsync = execFileAsync, options = {}, logger = new logging_1.NoopLogger()) {
157
273
  this.execAsync = execAsync;
158
274
  this.options = options;
275
+ this.logger = logger;
159
276
  }
160
277
  supports(target) {
161
278
  // Only support iTerm2 targets that are NOT Claude Code
@@ -168,13 +285,203 @@ class Iterm2TerminalStateProbe {
168
285
  async check(target) {
169
286
  const sessionId = target.itermSessionId?.trim();
170
287
  if (!sessionId) {
288
+ this.logger.trace("probe_result", {
289
+ targetId: target.targetId,
290
+ result: { presence: "unknown", busy: "unknown" },
291
+ reason: "missing_iterm_session_id",
292
+ });
171
293
  return { presence: "unknown", busy: "unknown" };
172
294
  }
295
+ // Try Python API probe first (if enabled)
296
+ if (this.options.enablePythonProbe !== false) {
297
+ const pythonResult = await this.tryPythonApiProbe(sessionId, target.runtimeKind);
298
+ if (pythonResult && pythonResult.presence !== "unknown") {
299
+ this.logger.debug("probe_result", {
300
+ targetId: target.targetId,
301
+ path: "python",
302
+ result: pythonResult,
303
+ });
304
+ return pythonResult;
305
+ }
306
+ this.logger.trace("probe_fallback", {
307
+ targetId: target.targetId,
308
+ from: "python",
309
+ to: "cli",
310
+ reason: pythonResult ? "python_presence_unknown" : "python_probe_unavailable",
311
+ });
312
+ }
313
+ // Fallback to command-line tools
314
+ const result = await this.checkWithCommandLineTools(sessionId, target.targetId);
315
+ this.logger.debug("probe_result", {
316
+ targetId: target.targetId,
317
+ path: "cli",
318
+ result,
319
+ });
320
+ return result;
321
+ }
322
+ async tryPythonApiProbe(sessionId, runtimeKind) {
323
+ try {
324
+ const scriptPath = this.options.pythonScriptPath ?? resolvePythonProbeScript();
325
+ const result = await this.execAsync("python3", [scriptPath, sessionId], {
326
+ env: { ...process.env, PYTHONIOENCODING: "utf-8" }
327
+ });
328
+ const data = JSON.parse(result.stdout);
329
+ if (data.status === "gone") {
330
+ this.logger.trace("probe_python_result", {
331
+ sessionId,
332
+ result: { presence: "gone", busy: "unknown" },
333
+ reason: "session_gone",
334
+ });
335
+ return { presence: "gone", busy: "unknown" };
336
+ }
337
+ if (data.status === "available") {
338
+ // If we have cursor and lines data, use cursor-aware detection
339
+ if (data.cursor && Array.isArray(data.lines)) {
340
+ const bufferTail = normalizeProbeTailFromLines(data.lines);
341
+ if (!bufferTail) {
342
+ this.logger.trace("probe_python_result", {
343
+ sessionId,
344
+ result: { presence: "available", busy: "unknown" },
345
+ reason: "empty_normalized_buffer",
346
+ });
347
+ return { presence: "available", busy: "unknown" };
348
+ }
349
+ const cursorPosition = { x: data.cursor.x, y: data.cursor.y };
350
+ const screenHeight = data.screen_height;
351
+ const startLine = data.start_line ?? 0;
352
+ // Perform cursor-aware typing detection
353
+ const busyStatus = evaluatePythonCursorAwareTypingPrompt(bufferTail, cursorPosition, screenHeight, startLine, runtimeKind);
354
+ // If cursor-aware detection indicates busy, return immediately
355
+ if (busyStatus === "busy") {
356
+ this.logger.trace("probe_python_result", {
357
+ sessionId,
358
+ result: { presence: "available", busy: "busy" },
359
+ reason: "cursor_aware_busy",
360
+ });
361
+ return { presence: "available", busy: "busy" };
362
+ }
363
+ // If cursor-aware detection is certain it's not busy, don't apply prompt heuristics
364
+ if (busyStatus === "not_busy") {
365
+ // Still check for buffer changes and active markers
366
+ await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? DEFAULT_ITERM2_SAMPLE_DELAY_MS);
367
+ const secondResult = await this.execAsync("python3", [scriptPath, sessionId], {
368
+ env: { ...process.env, PYTHONIOENCODING: "utf-8" }
369
+ });
370
+ const secondData = JSON.parse(secondResult.stdout);
371
+ if (secondData.status === "available" && Array.isArray(secondData.lines)) {
372
+ const secondBufferTail = normalizeProbeTailFromLines(secondData.lines);
373
+ if (secondBufferTail && bufferTail !== secondBufferTail) {
374
+ this.logger.trace("probe_python_result", {
375
+ sessionId,
376
+ result: { presence: "available", busy: "busy" },
377
+ reason: "buffer_changed",
378
+ });
379
+ return { presence: "available", busy: "busy" };
380
+ }
381
+ }
382
+ // Check for active buffer markers
383
+ if (containsActiveBufferMarker(bufferTail)) {
384
+ this.logger.trace("probe_python_result", {
385
+ sessionId,
386
+ result: { presence: "available", busy: "busy" },
387
+ reason: "active_buffer_marker",
388
+ });
389
+ return { presence: "available", busy: "busy" };
390
+ }
391
+ this.logger.trace("probe_python_result", {
392
+ sessionId,
393
+ result: { presence: "available", busy: "unknown" },
394
+ reason: "cursor_aware_not_busy",
395
+ });
396
+ return { presence: "available", busy: "unknown" };
397
+ }
398
+ // If cursor-aware detection is uncertain, fall back to buffer change detection only
399
+ // Do NOT use generic typing prompts to avoid false positives (see #116)
400
+ if (busyStatus === "unknown") {
401
+ await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? DEFAULT_ITERM2_SAMPLE_DELAY_MS);
402
+ const secondResult = await this.execAsync("python3", [scriptPath, sessionId], {
403
+ env: { ...process.env, PYTHONIOENCODING: "utf-8" }
404
+ });
405
+ const secondData = JSON.parse(secondResult.stdout);
406
+ if (secondData.status === "available" && Array.isArray(secondData.lines)) {
407
+ const secondBufferTail = normalizeProbeTailFromLines(secondData.lines);
408
+ if (secondBufferTail && bufferTail !== secondBufferTail) {
409
+ this.logger.trace("probe_python_result", {
410
+ sessionId,
411
+ result: { presence: "available", busy: "busy" },
412
+ reason: "buffer_changed_after_unknown_cursor_state",
413
+ });
414
+ return { presence: "available", busy: "busy" };
415
+ }
416
+ }
417
+ // Check for active buffer markers
418
+ if (containsActiveBufferMarker(bufferTail)) {
419
+ this.logger.trace("probe_python_result", {
420
+ sessionId,
421
+ result: { presence: "available", busy: "busy" },
422
+ reason: "active_buffer_marker_after_unknown_cursor_state",
423
+ });
424
+ return { presence: "available", busy: "busy" };
425
+ }
426
+ // Return unknown when cursor-aware detection is uncertain
427
+ // Do NOT fall back to generic typing prompts to avoid false positives
428
+ this.logger.trace("probe_python_result", {
429
+ sessionId,
430
+ result: { presence: "available", busy: "unknown" },
431
+ reason: "cursor_aware_unknown",
432
+ });
433
+ return { presence: "available", busy: "unknown" };
434
+ }
435
+ // This should never be reached as all cases are handled above
436
+ this.logger.trace("probe_python_result", {
437
+ sessionId,
438
+ result: { presence: "available", busy: "unknown" },
439
+ reason: "cursor_probe_fell_through",
440
+ });
441
+ return { presence: "available", busy: "unknown" };
442
+ }
443
+ // If we don't have cursor data, return unknown to trigger fallback
444
+ this.logger.trace("probe_python_result", {
445
+ sessionId,
446
+ result: null,
447
+ reason: "missing_cursor_data",
448
+ });
449
+ return null;
450
+ }
451
+ // Any other status means we should fall back
452
+ this.logger.trace("probe_python_result", {
453
+ sessionId,
454
+ result: null,
455
+ reason: "unexpected_python_status",
456
+ status: data.status,
457
+ });
458
+ return null;
459
+ }
460
+ catch (error) {
461
+ // Python API probe failed, fall back to command-line tools
462
+ this.logger.trace("probe_fallback", {
463
+ sessionId,
464
+ from: "python",
465
+ to: "cli",
466
+ reason: "python_probe_error",
467
+ error,
468
+ });
469
+ return null;
470
+ }
471
+ }
472
+ async checkWithCommandLineTools(sessionId, targetId) {
173
473
  let it2api;
174
474
  try {
175
475
  it2api = resolveIterm2ApiPath(this.options.iterm2ApiPath);
176
476
  }
177
- catch {
477
+ catch (error) {
478
+ this.logger.trace("probe_fallback", {
479
+ targetId,
480
+ from: "cli",
481
+ to: null,
482
+ reason: "it2api_unavailable",
483
+ error,
484
+ });
178
485
  return { presence: "unknown", busy: "unknown" };
179
486
  }
180
487
  const presence = await this.checkSessionPresence(it2api, sessionId);
@@ -192,14 +499,27 @@ class Iterm2TerminalStateProbe {
192
499
  return { presence: "available", busy: "unknown" };
193
500
  }
194
501
  if (first !== second) {
502
+ this.logger.trace("probe_cli_result", {
503
+ sessionId,
504
+ result: { presence: "available", busy: "busy" },
505
+ reason: "buffer_changed",
506
+ });
195
507
  return { presence: "available", busy: "busy" };
196
508
  }
197
509
  if (containsActiveBufferMarker(second)) {
510
+ this.logger.trace("probe_cli_result", {
511
+ sessionId,
512
+ result: { presence: "available", busy: "busy" },
513
+ reason: "active_buffer_marker",
514
+ });
198
515
  return { presence: "available", busy: "busy" };
199
516
  }
200
- if (hasVisibleTypingPrompt(second)) {
201
- return { presence: "available", busy: "busy" };
202
- }
517
+ // Do NOT use generic typing prompts to avoid false positives (see #116)
518
+ this.logger.trace("probe_cli_result", {
519
+ sessionId,
520
+ result: { presence: "available", busy: "unknown" },
521
+ reason: "stable_quiet_buffer",
522
+ });
203
523
  return { presence: "available", busy: "unknown" };
204
524
  }
205
525
  async checkSessionPresence(it2api, sessionId) {
@@ -208,7 +528,13 @@ class Iterm2TerminalStateProbe {
208
528
  const sessionIds = parseIterm2SessionIds(result.stdout);
209
529
  return sessionIds.includes(sessionId) ? "available" : "gone";
210
530
  }
211
- catch {
531
+ catch (error) {
532
+ this.logger.trace("probe_error", {
533
+ sessionId,
534
+ step: "list_sessions",
535
+ error,
536
+ result: "unknown",
537
+ });
212
538
  return "unknown";
213
539
  }
214
540
  }
@@ -234,9 +560,11 @@ exports.Iterm2TerminalStateProbe = Iterm2TerminalStateProbe;
234
560
  class TmuxTerminalStateProbe {
235
561
  execAsync;
236
562
  options;
237
- constructor(execAsync = execFileAsync, options = {}) {
563
+ logger;
564
+ constructor(execAsync = execFileAsync, options = {}, logger = new logging_1.NoopLogger()) {
238
565
  this.execAsync = execAsync;
239
566
  this.options = options;
567
+ this.logger = logger;
240
568
  }
241
569
  supports(target) {
242
570
  return target.backend === "tmux" && typeof target.tmuxPaneId === "string" && target.tmuxPaneId.trim().length > 0;
@@ -244,16 +572,39 @@ class TmuxTerminalStateProbe {
244
572
  async check(target) {
245
573
  const paneId = target.tmuxPaneId?.trim();
246
574
  if (!paneId) {
575
+ this.logger.trace("probe_result", {
576
+ targetId: target.targetId,
577
+ result: { presence: "unknown", busy: "unknown" },
578
+ reason: "missing_tmux_pane_id",
579
+ });
247
580
  return { presence: "unknown", busy: "unknown" };
248
581
  }
249
582
  const paneState = await this.readPaneState(paneId);
250
583
  if (paneState === "missing") {
584
+ this.logger.trace("probe_result", {
585
+ targetId: target.targetId,
586
+ paneId,
587
+ result: { presence: "gone", busy: "unknown" },
588
+ reason: "pane_missing",
589
+ });
251
590
  return { presence: "gone", busy: "unknown" };
252
591
  }
253
592
  if (paneState === "unknown") {
593
+ this.logger.trace("probe_result", {
594
+ targetId: target.targetId,
595
+ paneId,
596
+ result: { presence: "unknown", busy: "unknown" },
597
+ reason: "pane_state_unknown",
598
+ });
254
599
  return { presence: "unknown", busy: "unknown" };
255
600
  }
256
601
  if (paneState.dead) {
602
+ this.logger.trace("probe_result", {
603
+ targetId: target.targetId,
604
+ paneId,
605
+ result: { presence: "gone", busy: "unknown" },
606
+ reason: "pane_dead",
607
+ });
257
608
  return { presence: "gone", busy: "unknown" };
258
609
  }
259
610
  const first = await this.readBufferTail(paneId);
@@ -266,26 +617,62 @@ class TmuxTerminalStateProbe {
266
617
  return { presence: "available", busy: "unknown" };
267
618
  }
268
619
  if (first !== second) {
620
+ this.logger.trace("probe_result", {
621
+ targetId: target.targetId,
622
+ paneId,
623
+ result: { presence: "available", busy: "busy" },
624
+ reason: "buffer_changed",
625
+ });
269
626
  return { presence: "available", busy: "busy" };
270
627
  }
271
628
  if (containsActiveBufferMarker(second)) {
629
+ this.logger.trace("probe_result", {
630
+ targetId: target.targetId,
631
+ paneId,
632
+ result: { presence: "available", busy: "busy" },
633
+ reason: "active_buffer_marker",
634
+ });
272
635
  return { presence: "available", busy: "busy" };
273
636
  }
274
- if (paneState.active && hasVisibleTypingPrompt(second)) {
637
+ // Use cursor-aware detection when available
638
+ const cursorHint = evaluateCursorAwareTypingPrompt(target, paneState, second);
639
+ if (cursorHint === "busy") {
640
+ this.logger.trace("probe_result", {
641
+ targetId: target.targetId,
642
+ paneId,
643
+ result: { presence: "available", busy: "busy" },
644
+ reason: "cursor_aware_busy",
645
+ });
275
646
  return { presence: "available", busy: "busy" };
276
647
  }
648
+ // Do NOT fall back to generic typing prompts when cursorHint is unknown to avoid false positives (see #116)
649
+ this.logger.trace("probe_result", {
650
+ targetId: target.targetId,
651
+ paneId,
652
+ result: { presence: "available", busy: "unknown" },
653
+ reason: cursorHint === "not_busy" ? "cursor_aware_not_busy" : "cursor_aware_unknown",
654
+ });
277
655
  return { presence: "available", busy: "unknown" };
278
656
  }
279
657
  async readPaneState(paneId) {
280
658
  try {
281
- const result = await this.execAsync("tmux", ["display-message", "-p", "-t", paneId, "#{pane_active} #{pane_dead}"]);
282
- const [active, dead] = result.stdout.trim().split(/\s+/, 2);
659
+ const result = await this.execAsync("tmux", [
660
+ "display-message",
661
+ "-p",
662
+ "-t",
663
+ paneId,
664
+ "#{pane_active} #{pane_dead} #{cursor_x} #{cursor_y} #{pane_height}",
665
+ ]);
666
+ const [active, dead, cursorX, cursorY, height] = result.stdout.trim().split(/\s+/, 5);
283
667
  if (!active || !dead) {
284
668
  return "unknown";
285
669
  }
286
670
  return {
287
671
  active: active === "1",
288
672
  dead: dead === "1",
673
+ cursorX: parseOptionalInt(cursorX),
674
+ cursorY: parseOptionalInt(cursorY),
675
+ height: parseOptionalInt(height),
289
676
  };
290
677
  }
291
678
  catch (error) {
@@ -297,7 +684,7 @@ class TmuxTerminalStateProbe {
297
684
  }
298
685
  async readBufferTail(paneId) {
299
686
  try {
300
- const result = await this.execAsync("tmux", ["capture-pane", "-p", "-t", paneId, "-S", "-20"]);
687
+ const result = await this.execAsync("tmux", ["capture-pane", "-p", "-t", paneId, "-S", `-${NORMALIZED_BUFFER_TAIL_LINES}`]);
301
688
  return normalizeBufferTail(result.stdout);
302
689
  }
303
690
  catch {
@@ -309,7 +696,7 @@ exports.TmuxTerminalStateProbe = TmuxTerminalStateProbe;
309
696
  function containsActiveBufferMarker(bufferTail) {
310
697
  return ACTIVE_BUFFER_MARKERS.some((marker) => bufferTail.includes(marker));
311
698
  }
312
- function hasVisibleTypingPrompt(bufferTail) {
699
+ function hasVisibleTypingPrompt(bufferTail, prefixes = TYPING_PROMPT_PREFIXES) {
313
700
  const lines = bufferTail
314
701
  .split("\n")
315
702
  .map((line) => line.trimEnd())
@@ -318,23 +705,77 @@ function hasVisibleTypingPrompt(bufferTail) {
318
705
  if (!lastLine) {
319
706
  return false;
320
707
  }
321
- for (const prefix of TYPING_PROMPT_PREFIXES) {
708
+ for (const prefix of prefixes) {
322
709
  if (lastLine.startsWith(prefix) && lastLine.slice(prefix.length).trim().length > 0) {
323
710
  return true;
324
711
  }
325
712
  }
326
713
  return false;
327
714
  }
715
+ function evaluateCursorAwareTypingPrompt(target, paneState, bufferTail) {
716
+ const prefix = runtimeTypingPromptPrefix(target.runtimeKind);
717
+ if (!prefix) {
718
+ return "unknown";
719
+ }
720
+ if (paneState.cursorX == null || paneState.cursorY == null || paneState.height == null) {
721
+ return "unknown";
722
+ }
723
+ const lines = bufferTail
724
+ .replace(/\r/g, "")
725
+ .split("\n")
726
+ .map((line) => line.trimEnd());
727
+ if (lines.length === 0) {
728
+ return "unknown";
729
+ }
730
+ const captureStartRow = Math.max(0, paneState.height - lines.length);
731
+ const lineIndex = paneState.cursorY - captureStartRow;
732
+ if (lineIndex < 0 || lineIndex >= lines.length) {
733
+ return "unknown";
734
+ }
735
+ const line = lines[lineIndex] ?? "";
736
+ if (!line.startsWith(prefix)) {
737
+ return "not_busy";
738
+ }
739
+ return paneState.cursorX > prefix.length ? "busy" : "not_busy";
740
+ }
741
+ function runtimeTypingPromptPrefix(runtimeKind) {
742
+ if (runtimeKind === "codex") {
743
+ return "› ";
744
+ }
745
+ if (runtimeKind === "claude_code") {
746
+ return "❯ ";
747
+ }
748
+ return null;
749
+ }
750
+ function typingPromptPrefixesForRuntime(runtimeKind) {
751
+ if (runtimeKind === "codex") {
752
+ return ["› "];
753
+ }
754
+ if (runtimeKind === "claude_code") {
755
+ return ["❯ ", "› "];
756
+ }
757
+ return TYPING_PROMPT_PREFIXES;
758
+ }
759
+ function parseOptionalInt(value) {
760
+ if (!value) {
761
+ return null;
762
+ }
763
+ const parsed = Number.parseInt(value, 10);
764
+ return Number.isFinite(parsed) ? parsed : null;
765
+ }
328
766
  function normalizeBufferTail(buffer) {
329
767
  const normalized = buffer
330
768
  .replace(/\r/g, "")
331
769
  .split("\n")
332
770
  .map((line) => line.trimEnd())
333
- .slice(-20)
771
+ .slice(-NORMALIZED_BUFFER_TAIL_LINES)
334
772
  .join("\n")
335
773
  .trim();
336
774
  return normalized.length > 0 ? normalized : null;
337
775
  }
776
+ function normalizeProbeTailFromLines(lines) {
777
+ return normalizeBufferTail(lines.join("\n"));
778
+ }
338
779
  function resolveIterm2ApiPath(override) {
339
780
  if (override) {
340
781
  return override;
@@ -471,15 +912,18 @@ function isTmuxMissingPaneError(error) {
471
912
  class ClaudeCodeRuntimePresenceProbe {
472
913
  killFn;
473
914
  sessionFileReader;
474
- constructor(killFn = (pid, signal) => process.kill(pid, signal), sessionFileReader) {
915
+ logger;
916
+ constructor(killFn = (pid, signal) => process.kill(pid, signal), sessionFileReader, logger = new logging_1.NoopLogger()) {
475
917
  this.killFn = killFn;
476
918
  this.sessionFileReader = sessionFileReader;
919
+ this.logger = logger;
477
920
  }
478
921
  supports(target) {
479
922
  return target.runtimeKind === "claude_code" && Number.isInteger(target.runtimePid);
480
923
  }
481
924
  async check(target) {
482
925
  if (!Number.isInteger(target.runtimePid)) {
926
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "unknown", reason: "missing_runtime_pid" });
483
927
  return "unknown";
484
928
  }
485
929
  try {
@@ -488,19 +932,24 @@ class ClaudeCodeRuntimePresenceProbe {
488
932
  // 2. Verify session file exists and is valid
489
933
  const sessionInfo = await (this.sessionFileReader ?? this.defaultSessionFileReader)(target.runtimePid);
490
934
  if (!sessionInfo) {
935
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "gone", reason: "session_file_missing" });
491
936
  return "gone"; // Process exists but session file is invalid/missing
492
937
  }
493
938
  // 3. Verify session ID matches (prevent PID reuse)
494
939
  if (target.runtimeSessionId && sessionInfo.sessionId !== target.runtimeSessionId) {
940
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "gone", reason: "session_id_mismatch" });
495
941
  return "gone";
496
942
  }
943
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "alive" });
497
944
  return "alive";
498
945
  }
499
946
  catch (error) {
500
947
  const code = error instanceof Error && "code" in error ? String(error.code ?? "") : "";
501
948
  if (code === "ESRCH") {
949
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "gone", reason: "process_missing" });
502
950
  return "gone"; // Process doesn't exist
503
951
  }
952
+ this.logger.debug("probe_error", { targetId: target.targetId, error, result: "unknown" });
504
953
  return "unknown";
505
954
  }
506
955
  }
@@ -527,26 +976,43 @@ class ClaudeCodeTerminalStateProbe {
527
976
  sessionFileReader;
528
977
  logFileStatReader;
529
978
  sleepFn;
530
- constructor(sessionFileReader, logFileStatReader, sleepFn = sleep) {
979
+ logger;
980
+ constructor(sessionFileReader, logFileStatReader, sleepFn = sleep, logger = new logging_1.NoopLogger()) {
531
981
  this.sessionFileReader = sessionFileReader;
532
982
  this.logFileStatReader = logFileStatReader;
533
983
  this.sleepFn = sleepFn;
984
+ this.logger = logger;
534
985
  }
535
986
  supports(target) {
536
987
  return target.runtimeKind === "claude_code";
537
988
  }
538
989
  async check(target) {
539
990
  if (!target.runtimePid || !target.runtimeSessionId) {
991
+ this.logger.trace("probe_result", {
992
+ targetId: target.targetId,
993
+ result: { presence: "unknown", busy: "unknown" },
994
+ reason: "missing_runtime_identity",
995
+ });
540
996
  return { presence: "unknown", busy: "unknown" };
541
997
  }
542
998
  try {
543
999
  // 1. Read session file to get cwd
544
1000
  const sessionData = await (this.sessionFileReader ?? this.defaultSessionFileReader)(target.runtimePid);
545
1001
  if (!sessionData) {
1002
+ this.logger.trace("probe_result", {
1003
+ targetId: target.targetId,
1004
+ result: { presence: "gone", busy: "unknown" },
1005
+ reason: "session_file_missing",
1006
+ });
546
1007
  return { presence: "gone", busy: "unknown" };
547
1008
  }
548
1009
  // 2. Verify session ID matches
549
1010
  if (sessionData.sessionId !== target.runtimeSessionId) {
1011
+ this.logger.trace("probe_result", {
1012
+ targetId: target.targetId,
1013
+ result: { presence: "gone", busy: "unknown" },
1014
+ reason: "session_id_mismatch",
1015
+ });
550
1016
  return { presence: "gone", busy: "unknown" };
551
1017
  }
552
1018
  // 3. Construct log file path using Claude Code's encoding rules
@@ -558,10 +1024,18 @@ class ClaudeCodeTerminalStateProbe {
558
1024
  const presence = target.backend === "iterm2" && target.itermSessionId
559
1025
  ? await this.checkIterm2Presence(target.itermSessionId)
560
1026
  : "available";
1027
+ this.logger.trace("probe_result", {
1028
+ targetId: target.targetId,
1029
+ result: { presence, busy },
1030
+ });
561
1031
  return { presence, busy };
562
1032
  }
563
1033
  catch (error) {
564
- console.warn('Claude Code state check failed:', error);
1034
+ this.logger.debug("probe_error", {
1035
+ targetId: target.targetId,
1036
+ error,
1037
+ result: { presence: "unknown", busy: "unknown" },
1038
+ });
565
1039
  return { presence: "unknown", busy: "unknown" };
566
1040
  }
567
1041
  }
@@ -643,3 +1117,79 @@ function parseIterm2SessionIds(stdout) {
643
1117
  })
644
1118
  .filter((id) => id !== null);
645
1119
  }
1120
+ function resolvePythonProbeScript(override) {
1121
+ if (override) {
1122
+ return override;
1123
+ }
1124
+ const scriptRelativePath = node_path_1.default.join("scripts", "iterm2_cursor_probe.py");
1125
+ const seen = new Set();
1126
+ const candidates = [];
1127
+ const addCandidate = (candidate) => {
1128
+ const normalized = node_path_1.default.normalize(candidate);
1129
+ if (!seen.has(normalized)) {
1130
+ seen.add(normalized);
1131
+ candidates.push(normalized);
1132
+ }
1133
+ };
1134
+ // Search upward from the current module's directory
1135
+ let currentDir = __dirname;
1136
+ for (let i = 0; i < 5; i++) { // Limit upward search depth
1137
+ addCandidate(node_path_1.default.join(currentDir, scriptRelativePath));
1138
+ addCandidate(node_path_1.default.join(currentDir, "dist", scriptRelativePath));
1139
+ addCandidate(node_path_1.default.join(currentDir, "dist", "src", scriptRelativePath));
1140
+ const parentDir = node_path_1.default.dirname(currentDir);
1141
+ if (parentDir === currentDir) {
1142
+ break;
1143
+ }
1144
+ currentDir = parentDir;
1145
+ }
1146
+ // Check process working directory and its dist paths
1147
+ addCandidate(node_path_1.default.join(process.cwd(), scriptRelativePath));
1148
+ addCandidate(node_path_1.default.join(process.cwd(), "dist", scriptRelativePath));
1149
+ addCandidate(node_path_1.default.join(process.cwd(), "dist", "src", scriptRelativePath));
1150
+ // Check all candidates
1151
+ for (const candidate of candidates) {
1152
+ if (node_fs_1.default.existsSync(candidate)) {
1153
+ return candidate;
1154
+ }
1155
+ }
1156
+ throw new Error(`Unable to locate iterm2_cursor_probe.py. Checked: ${candidates.join(", ")}. ` +
1157
+ "Ensure scripts/iterm2_cursor_probe.py is shipped with the package or copied into dist/ during build.");
1158
+ }
1159
+ function evaluatePythonCursorAwareTypingPrompt(bufferTail, cursorPosition, screenHeight, startLine, runtimeKind) {
1160
+ const lines = bufferTail.split("\n");
1161
+ const lastLineIndex = lines.length - 1;
1162
+ // Cursor coordinates are in full-screen rows, while bufferTail only contains
1163
+ // the lines from startLine to end of screen. First ensure the cursor is on the
1164
+ // terminal's last visible row, then map that row into the captured window.
1165
+ if (cursorPosition.y !== screenHeight - 1) {
1166
+ return "not_busy";
1167
+ }
1168
+ // Map the cursor position into the captured buffer window using startLine
1169
+ const cursorRowInBuffer = cursorPosition.y - startLine;
1170
+ // If the last visible screen row is not represented by the captured tail,
1171
+ // or does not map to the last captured line, we cannot evaluate reliably.
1172
+ if (cursorRowInBuffer < 0 || cursorRowInBuffer > lastLineIndex) {
1173
+ return "unknown";
1174
+ }
1175
+ if (cursorRowInBuffer !== lastLineIndex) {
1176
+ return "not_busy";
1177
+ }
1178
+ const lastLine = lines[lastLineIndex] || "";
1179
+ const promptPrefix = runtimeTypingPromptPrefix(runtimeKind);
1180
+ if (!promptPrefix) {
1181
+ return "unknown";
1182
+ }
1183
+ // Check if the last line starts with the runtime-specific prompt prefix
1184
+ if (lastLine.startsWith(promptPrefix)) {
1185
+ // Check if cursor is after the prompt prefix
1186
+ if (cursorPosition.x > promptPrefix.length) {
1187
+ const afterPrompt = lastLine.slice(promptPrefix.length).trim();
1188
+ // If there's content after the prompt, user is typing
1189
+ if (afterPrompt.length > 0) {
1190
+ return "busy";
1191
+ }
1192
+ }
1193
+ }
1194
+ return "not_busy";
1195
+ }