@holon-run/agentinbox 0.6.0 → 1.0.0-beta.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.
Files changed (34) hide show
  1. package/README.md +32 -7
  2. package/dist/src/adapters.js +61 -6
  3. package/dist/src/backend.js +4 -16
  4. package/dist/src/cli.js +307 -53
  5. package/dist/src/client.js +2 -2
  6. package/dist/src/daemon.js +36 -2
  7. package/dist/src/http.js +467 -45
  8. package/dist/src/logging.js +112 -0
  9. package/dist/src/paths.js +1 -0
  10. package/dist/src/runtime_gate.js +394 -63
  11. package/dist/src/service.js +856 -154
  12. package/dist/src/source_hosts.js +75 -0
  13. package/dist/src/source_resolution.js +20 -20
  14. package/dist/src/source_schema.js +2 -2
  15. package/dist/src/sources/feishu.js +66 -28
  16. package/dist/src/sources/github.js +188 -24
  17. package/dist/src/sources/remote.js +44 -24
  18. package/dist/src/sources/{remote_profiles.js → remote_modules.js} +143 -58
  19. package/dist/src/store.js +861 -106
  20. package/dist/src/terminal.js +33 -7
  21. package/dist/src/util.js +46 -1
  22. package/drizzle/migrations/{0000_initial.sql → 0000_v1_initial.sql} +182 -6
  23. package/drizzle/schema.ts +93 -1
  24. package/package.json +2 -1
  25. package/scripts/iterm2_cursor_probe.py +8 -3
  26. package/scripts/release_smoke.sh +123 -0
  27. package/drizzle/migrations/0001_inbox_items_source_occurred_at_idx.sql +0 -3
  28. package/drizzle/migrations/0002_subscription_cleanup_policy.sql +0 -45
  29. package/drizzle/migrations/0003_subscription_lifecycle_retirements.sql +0 -14
  30. package/drizzle/migrations/0004_source_idle_states.sql +0 -10
  31. package/drizzle/migrations/0005_timers.sql +0 -22
  32. package/drizzle/migrations/0006_terminal_runtime_pid.sql +0 -1
  33. package/drizzle/migrations/0007_activation_dispatch_last_notified_fingerprint.sql +0 -2
  34. package/drizzle/migrations/0008_inbox_item_sequence.sql +0 -21
@@ -10,37 +10,55 @@ 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 = 900;
15
- const DEFAULT_TMUX_SAMPLE_DELAY_MS = 900;
16
- const ACTIVE_BUFFER_MARKERS = [
17
- "esc to interrupt",
18
- "Working (",
19
- "⠼ ",
20
- ];
21
- const TYPING_PROMPT_PREFIXES = [
22
- "› ",
23
- "> ",
24
- ];
15
+ const DEFAULT_GATING_POLICY = {
16
+ iTerm2SampleDelayMs: 900,
17
+ tmuxSampleDelayMs: 900,
18
+ normalizedBufferTailLines: 20,
19
+ activeBufferMarkers: [
20
+ "esc to interrupt",
21
+ "Working (",
22
+ "⠼ ",
23
+ ],
24
+ typingPromptPrefixes: [
25
+ "› ",
26
+ "> ",
27
+ ],
28
+ };
25
29
  class DefaultActivationGate {
26
30
  runtimeProbes;
27
31
  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;
32
+ logger;
33
+ constructor(runtimeProbes, terminalProbes, logger = new logging_1.NoopLogger(), gatingPolicy = DEFAULT_GATING_POLICY) {
34
+ this.logger = logger.child("activation_gate");
35
+ this.runtimeProbes = runtimeProbes ?? [
36
+ new CodexRuntimePresenceProbe(undefined, undefined, this.logger.child("codex_runtime")),
37
+ new ClaudeCodeRuntimePresenceProbe(undefined, undefined, this.logger.child("claude_runtime")),
38
+ ];
39
+ this.terminalProbes = terminalProbes ?? [
40
+ new CodexTerminalStateProbe(undefined, undefined, undefined, undefined, undefined, this.logger.child("codex_terminal"), gatingPolicy),
41
+ new Iterm2TerminalStateProbe(undefined, {}, this.logger.child("iterm2_terminal"), gatingPolicy),
42
+ new ClaudeCodeTerminalStateProbe(undefined, undefined, undefined, this.logger.child("claude_terminal")),
43
+ new TmuxTerminalStateProbe(undefined, {}, this.logger.child("tmux_terminal"), gatingPolicy),
44
+ ];
39
45
  }
40
46
  async evaluate(target) {
41
47
  const runtimeProbe = this.runtimeProbes.find((probe) => probe.supports(target));
42
48
  const runtimePresence = runtimeProbe ? await runtimeProbe.check(target) : "unknown";
43
49
  if (runtimePresence === "gone") {
50
+ this.logger.debug("gate_decision", {
51
+ targetId: target.targetId,
52
+ runtimeKind: target.runtimeKind,
53
+ backend: target.backend,
54
+ runtimeProbe: runtimeProbe?.constructor?.name ?? null,
55
+ runtimePresence,
56
+ terminalProbe: null,
57
+ terminalPresence: null,
58
+ terminalBusy: null,
59
+ outcome: "offline",
60
+ reason: "runtime_gone",
61
+ });
44
62
  return { outcome: "offline", reason: "runtime_gone" };
45
63
  }
46
64
  const terminalProbe = this.terminalProbes.find((probe) => probe.supports(target));
@@ -48,14 +66,62 @@ class DefaultActivationGate {
48
66
  ? await terminalProbe.check(target)
49
67
  : { presence: "unknown", busy: "unknown" };
50
68
  if (terminalState.presence === "gone") {
69
+ this.logger.debug("gate_decision", {
70
+ targetId: target.targetId,
71
+ runtimeKind: target.runtimeKind,
72
+ backend: target.backend,
73
+ runtimeProbe: runtimeProbe?.constructor?.name ?? null,
74
+ runtimePresence,
75
+ terminalProbe: terminalProbe?.constructor?.name ?? null,
76
+ terminalPresence: terminalState.presence,
77
+ terminalBusy: terminalState.busy,
78
+ outcome: "offline",
79
+ reason: "terminal_gone",
80
+ });
51
81
  return { outcome: "offline", reason: "terminal_gone" };
52
82
  }
53
83
  if (terminalState.busy === "busy") {
84
+ this.logger.debug("gate_decision", {
85
+ targetId: target.targetId,
86
+ runtimeKind: target.runtimeKind,
87
+ backend: target.backend,
88
+ runtimeProbe: runtimeProbe?.constructor?.name ?? null,
89
+ runtimePresence,
90
+ terminalProbe: terminalProbe?.constructor?.name ?? null,
91
+ terminalPresence: terminalState.presence,
92
+ terminalBusy: terminalState.busy,
93
+ outcome: "defer",
94
+ reason: "terminal_busy",
95
+ });
54
96
  return { outcome: "defer", reason: "terminal_busy" };
55
97
  }
56
98
  if (target.runtimeKind === "codex" && terminalState.busy === "idle") {
99
+ this.logger.debug("gate_decision", {
100
+ targetId: target.targetId,
101
+ runtimeKind: target.runtimeKind,
102
+ backend: target.backend,
103
+ runtimeProbe: runtimeProbe?.constructor?.name ?? null,
104
+ runtimePresence,
105
+ terminalProbe: terminalProbe?.constructor?.name ?? null,
106
+ terminalPresence: terminalState.presence,
107
+ terminalBusy: terminalState.busy,
108
+ outcome: "defer",
109
+ reason: "terminal_recently_active",
110
+ });
57
111
  return { outcome: "defer", reason: "terminal_recently_active" };
58
112
  }
113
+ this.logger.debug("gate_decision", {
114
+ targetId: target.targetId,
115
+ runtimeKind: target.runtimeKind,
116
+ backend: target.backend,
117
+ runtimeProbe: runtimeProbe?.constructor?.name ?? null,
118
+ runtimePresence,
119
+ terminalProbe: terminalProbe?.constructor?.name ?? null,
120
+ terminalPresence: terminalState.presence,
121
+ terminalBusy: terminalState.busy,
122
+ outcome: "inject",
123
+ reason: "gate_unknown_or_idle",
124
+ });
59
125
  return { outcome: "inject", reason: "gate_unknown_or_idle" };
60
126
  }
61
127
  }
@@ -63,36 +129,45 @@ exports.DefaultActivationGate = DefaultActivationGate;
63
129
  class CodexRuntimePresenceProbe {
64
130
  killFn;
65
131
  sessionFileReader;
66
- constructor(killFn = (pid, signal) => process.kill(pid, signal), sessionFileReader) {
132
+ logger;
133
+ constructor(killFn = (pid, signal) => process.kill(pid, signal), sessionFileReader, logger = new logging_1.NoopLogger()) {
67
134
  this.killFn = killFn;
68
135
  this.sessionFileReader = sessionFileReader;
136
+ this.logger = logger;
69
137
  }
70
138
  supports(target) {
71
139
  return target.runtimeKind === "codex" && Number.isInteger(target.runtimePid);
72
140
  }
73
141
  async check(target) {
74
142
  if (!Number.isInteger(target.runtimePid)) {
143
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "unknown", reason: "missing_runtime_pid" });
75
144
  return "unknown";
76
145
  }
77
146
  try {
78
147
  this.killFn(target.runtimePid, 0);
79
148
  if (!target.runtimeSessionId) {
149
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "unknown", reason: "missing_runtime_session_id" });
80
150
  return "unknown";
81
151
  }
82
152
  const sessionInfo = await (this.sessionFileReader ?? defaultCodexSessionFileReader)(target.runtimeSessionId);
83
153
  if (sessionInfo === "unknown") {
154
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "unknown", reason: "session_file_unknown" });
84
155
  return "unknown";
85
156
  }
86
157
  if (!sessionInfo || sessionInfo.sessionId !== target.runtimeSessionId) {
158
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "gone", reason: "session_file_mismatch" });
87
159
  return "gone";
88
160
  }
161
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "alive" });
89
162
  return "alive";
90
163
  }
91
164
  catch (error) {
92
165
  const code = error instanceof Error && "code" in error ? String(error.code ?? "") : "";
93
166
  if (code === "ESRCH") {
167
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "gone", reason: "process_missing" });
94
168
  return "gone";
95
169
  }
170
+ this.logger.debug("probe_error", { targetId: target.targetId, error, result: "unknown" });
96
171
  return "unknown";
97
172
  }
98
173
  }
@@ -104,12 +179,16 @@ class CodexTerminalStateProbe {
104
179
  sessionFileReader;
105
180
  statReader;
106
181
  nowFn;
107
- constructor(iTermProbe = new Iterm2TerminalStateProbe(), tmuxProbe = new TmuxTerminalStateProbe(), sessionFileReader, statReader, nowFn = () => Date.now()) {
182
+ logger;
183
+ gatingPolicy;
184
+ constructor(iTermProbe = new Iterm2TerminalStateProbe(undefined, {}, new logging_1.NoopLogger(), DEFAULT_GATING_POLICY), tmuxProbe = new TmuxTerminalStateProbe(undefined, {}, new logging_1.NoopLogger(), DEFAULT_GATING_POLICY), sessionFileReader, statReader, nowFn = () => Date.now(), logger = new logging_1.NoopLogger(), gatingPolicy = DEFAULT_GATING_POLICY) {
108
185
  this.iTermProbe = iTermProbe;
109
186
  this.tmuxProbe = tmuxProbe;
110
187
  this.sessionFileReader = sessionFileReader;
111
188
  this.statReader = statReader;
112
189
  this.nowFn = nowFn;
190
+ this.logger = logger;
191
+ this.gatingPolicy = gatingPolicy;
113
192
  }
114
193
  supports(target) {
115
194
  return target.runtimeKind === "codex";
@@ -117,26 +196,66 @@ class CodexTerminalStateProbe {
117
196
  async check(target) {
118
197
  const terminalState = await this.readTerminalState(target);
119
198
  if (!target.runtimeSessionId) {
199
+ this.logger.trace("probe_result", {
200
+ targetId: target.targetId,
201
+ result: terminalState,
202
+ reason: "missing_runtime_session_id",
203
+ });
120
204
  return terminalState;
121
205
  }
122
206
  const sessionInfo = await (this.sessionFileReader ?? defaultCodexSessionFileReader)(target.runtimeSessionId);
123
207
  if (sessionInfo === "unknown" || !sessionInfo || sessionInfo.sessionId !== target.runtimeSessionId) {
208
+ this.logger.trace("probe_result", {
209
+ targetId: target.targetId,
210
+ result: terminalState,
211
+ reason: "session_file_unavailable",
212
+ });
124
213
  return terminalState;
125
214
  }
126
215
  const stat = await (this.statReader ?? defaultFileStatReader)(sessionInfo.filePath);
127
216
  if (!stat) {
217
+ this.logger.trace("probe_result", {
218
+ targetId: target.targetId,
219
+ result: terminalState,
220
+ reason: "session_file_stat_missing",
221
+ });
128
222
  return terminalState;
129
223
  }
130
224
  if (terminalState.busy === "busy" || terminalState.presence === "gone") {
225
+ this.logger.trace("probe_result", {
226
+ targetId: target.targetId,
227
+ result: terminalState,
228
+ reason: "terminal_probe_decisive",
229
+ });
131
230
  return terminalState;
132
231
  }
133
232
  const ageMs = this.nowFn() - stat.mtimeMs;
134
233
  if (ageMs < 5000) {
135
- return { presence: terminalState.presence, busy: "busy" };
234
+ const result = { presence: terminalState.presence, busy: "busy" };
235
+ this.logger.trace("probe_result", {
236
+ targetId: target.targetId,
237
+ result,
238
+ reason: "session_file_recent_activity",
239
+ ageMs,
240
+ });
241
+ return result;
136
242
  }
137
243
  if (ageMs < 30000) {
138
- return { presence: terminalState.presence, busy: "idle" };
139
- }
244
+ const result = { presence: terminalState.presence, busy: "idle" };
245
+ this.logger.trace("probe_result", {
246
+ targetId: target.targetId,
247
+ result,
248
+ reason: "session_file_recently_active",
249
+ ageMs,
250
+ });
251
+ return result;
252
+ }
253
+ this.logger.trace("probe_result", {
254
+ targetId: target.targetId,
255
+ result: terminalState,
256
+ reason: "session_file_stale",
257
+ ageMs,
258
+ });
140
259
  return terminalState;
141
260
  }
142
261
  async readTerminalState(target) {
@@ -153,9 +272,13 @@ exports.CodexTerminalStateProbe = CodexTerminalStateProbe;
153
272
  class Iterm2TerminalStateProbe {
154
273
  execAsync;
155
274
  options;
156
- constructor(execAsync = execFileAsync, options = {}) {
275
+ logger;
276
+ gatingPolicy;
277
+ constructor(execAsync = execFileAsync, options = {}, logger = new logging_1.NoopLogger(), gatingPolicy = DEFAULT_GATING_POLICY) {
157
278
  this.execAsync = execAsync;
158
279
  this.options = options;
280
+ this.logger = logger;
281
+ this.gatingPolicy = gatingPolicy;
159
282
  }
160
283
  supports(target) {
161
284
  // Only support iTerm2 targets that are NOT Claude Code
@@ -168,17 +291,39 @@ class Iterm2TerminalStateProbe {
168
291
  async check(target) {
169
292
  const sessionId = target.itermSessionId?.trim();
170
293
  if (!sessionId) {
294
+ this.logger.trace("probe_result", {
295
+ targetId: target.targetId,
296
+ result: { presence: "unknown", busy: "unknown" },
297
+ reason: "missing_iterm_session_id",
298
+ });
171
299
  return { presence: "unknown", busy: "unknown" };
172
300
  }
173
301
  // Try Python API probe first (if enabled)
174
302
  if (this.options.enablePythonProbe !== false) {
175
303
  const pythonResult = await this.tryPythonApiProbe(sessionId, target.runtimeKind);
176
304
  if (pythonResult && pythonResult.presence !== "unknown") {
305
+ this.logger.debug("probe_result", {
306
+ targetId: target.targetId,
307
+ path: "python",
308
+ result: pythonResult,
309
+ });
177
310
  return pythonResult;
178
311
  }
312
+ this.logger.trace("probe_fallback", {
313
+ targetId: target.targetId,
314
+ from: "python",
315
+ to: "cli",
316
+ reason: pythonResult ? "python_presence_unknown" : "python_probe_unavailable",
317
+ });
179
318
  }
180
319
  // Fallback to command-line tools
181
- return this.checkWithCommandLineTools(sessionId);
320
+ const result = await this.checkWithCommandLineTools(sessionId, target.targetId);
321
+ this.logger.debug("probe_result", {
322
+ targetId: target.targetId,
323
+ path: "cli",
324
+ result,
325
+ });
326
+ return result;
182
327
  }
183
328
  async tryPythonApiProbe(sessionId, runtimeKind) {
184
329
  try {
@@ -188,12 +333,25 @@ class Iterm2TerminalStateProbe {
188
333
  });
189
334
  const data = JSON.parse(result.stdout);
190
335
  if (data.status === "gone") {
336
+ this.logger.trace("probe_python_result", {
337
+ sessionId,
338
+ result: { presence: "gone", busy: "unknown" },
339
+ reason: "session_gone",
340
+ });
191
341
  return { presence: "gone", busy: "unknown" };
192
342
  }
193
343
  if (data.status === "available") {
194
344
  // If we have cursor and lines data, use cursor-aware detection
195
- if (data.cursor && data.lines) {
196
- const bufferTail = data.lines.join("\n");
345
+ if (data.cursor && Array.isArray(data.lines)) {
346
+ const bufferTail = normalizeProbeTailFromLines(data.lines, this.gatingPolicy.normalizedBufferTailLines);
347
+ if (!bufferTail) {
348
+ this.logger.trace("probe_python_result", {
349
+ sessionId,
350
+ result: { presence: "available", busy: "unknown" },
351
+ reason: "empty_normalized_buffer",
352
+ });
353
+ return { presence: "available", busy: "unknown" };
354
+ }
197
355
  const cursorPosition = { x: data.cursor.x, y: data.cursor.y };
198
356
  const screenHeight = data.screen_height;
199
357
  const startLine = data.start_line ?? 0;
@@ -201,70 +359,135 @@ class Iterm2TerminalStateProbe {
201
359
  const busyStatus = evaluatePythonCursorAwareTypingPrompt(bufferTail, cursorPosition, screenHeight, startLine, runtimeKind);
202
360
  // If cursor-aware detection indicates busy, return immediately
203
361
  if (busyStatus === "busy") {
362
+ this.logger.trace("probe_python_result", {
363
+ sessionId,
364
+ result: { presence: "available", busy: "busy" },
365
+ reason: "cursor_aware_busy",
366
+ });
204
367
  return { presence: "available", busy: "busy" };
205
368
  }
206
369
  // If cursor-aware detection is certain it's not busy, don't apply prompt heuristics
207
370
  if (busyStatus === "not_busy") {
208
371
  // Still check for buffer changes and active markers
209
- await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? DEFAULT_ITERM2_SAMPLE_DELAY_MS);
372
+ await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? this.gatingPolicy.iTerm2SampleDelayMs);
210
373
  const secondResult = await this.execAsync("python3", [scriptPath, sessionId], {
211
374
  env: { ...process.env, PYTHONIOENCODING: "utf-8" }
212
375
  });
213
376
  const secondData = JSON.parse(secondResult.stdout);
214
- if (secondData.status === "available" && secondData.lines) {
215
- const secondBufferTail = secondData.lines.join("\n");
216
- if (bufferTail !== secondBufferTail) {
377
+ if (secondData.status === "available" && Array.isArray(secondData.lines)) {
378
+ const secondBufferTail = normalizeProbeTailFromLines(secondData.lines, this.gatingPolicy.normalizedBufferTailLines);
379
+ if (secondBufferTail && bufferTail !== secondBufferTail) {
380
+ this.logger.trace("probe_python_result", {
381
+ sessionId,
382
+ result: { presence: "available", busy: "busy" },
383
+ reason: "buffer_changed",
384
+ });
217
385
  return { presence: "available", busy: "busy" };
218
386
  }
219
387
  }
220
388
  // Check for active buffer markers
221
- if (containsActiveBufferMarker(bufferTail)) {
389
+ if (containsActiveBufferMarker(bufferTail, this.gatingPolicy.activeBufferMarkers)) {
390
+ this.logger.trace("probe_python_result", {
391
+ sessionId,
392
+ result: { presence: "available", busy: "busy" },
393
+ reason: "active_buffer_marker",
394
+ });
222
395
  return { presence: "available", busy: "busy" };
223
396
  }
224
- return { presence: "available", busy: "idle" };
397
+ this.logger.trace("probe_python_result", {
398
+ sessionId,
399
+ result: { presence: "available", busy: "unknown" },
400
+ reason: "cursor_aware_not_busy",
401
+ });
402
+ return { presence: "available", busy: "unknown" };
225
403
  }
226
404
  // If cursor-aware detection is uncertain, fall back to buffer change detection only
227
405
  // Do NOT use generic typing prompts to avoid false positives (see #116)
228
406
  if (busyStatus === "unknown") {
229
- await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? DEFAULT_ITERM2_SAMPLE_DELAY_MS);
407
+ await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? this.gatingPolicy.iTerm2SampleDelayMs);
230
408
  const secondResult = await this.execAsync("python3", [scriptPath, sessionId], {
231
409
  env: { ...process.env, PYTHONIOENCODING: "utf-8" }
232
410
  });
233
411
  const secondData = JSON.parse(secondResult.stdout);
234
- if (secondData.status === "available" && secondData.lines) {
235
- const secondBufferTail = secondData.lines.join("\n");
236
- if (bufferTail !== secondBufferTail) {
412
+ if (secondData.status === "available" && Array.isArray(secondData.lines)) {
413
+ const secondBufferTail = normalizeProbeTailFromLines(secondData.lines, this.gatingPolicy.normalizedBufferTailLines);
414
+ if (secondBufferTail && bufferTail !== secondBufferTail) {
415
+ this.logger.trace("probe_python_result", {
416
+ sessionId,
417
+ result: { presence: "available", busy: "busy" },
418
+ reason: "buffer_changed_after_unknown_cursor_state",
419
+ });
237
420
  return { presence: "available", busy: "busy" };
238
421
  }
239
422
  }
240
423
  // Check for active buffer markers
241
- if (containsActiveBufferMarker(bufferTail)) {
424
+ if (containsActiveBufferMarker(bufferTail, this.gatingPolicy.activeBufferMarkers)) {
425
+ this.logger.trace("probe_python_result", {
426
+ sessionId,
427
+ result: { presence: "available", busy: "busy" },
428
+ reason: "active_buffer_marker_after_unknown_cursor_state",
429
+ });
242
430
  return { presence: "available", busy: "busy" };
243
431
  }
244
432
  // Return unknown when cursor-aware detection is uncertain
245
433
  // Do NOT fall back to generic typing prompts to avoid false positives
434
+ this.logger.trace("probe_python_result", {
435
+ sessionId,
436
+ result: { presence: "available", busy: "unknown" },
437
+ reason: "cursor_aware_unknown",
438
+ });
246
439
  return { presence: "available", busy: "unknown" };
247
440
  }
248
441
  // This should never be reached as all cases are handled above
442
+ this.logger.trace("probe_python_result", {
443
+ sessionId,
444
+ result: { presence: "available", busy: "unknown" },
445
+ reason: "cursor_probe_fell_through",
446
+ });
249
447
  return { presence: "available", busy: "unknown" };
250
448
  }
251
449
  // If we don't have cursor data, return unknown to trigger fallback
450
+ this.logger.trace("probe_python_result", {
451
+ sessionId,
452
+ result: null,
453
+ reason: "missing_cursor_data",
454
+ });
252
455
  return null;
253
456
  }
254
457
  // Any other status means we should fall back
458
+ this.logger.trace("probe_python_result", {
459
+ sessionId,
460
+ result: null,
461
+ reason: "unexpected_python_status",
462
+ status: data.status,
463
+ });
255
464
  return null;
256
465
  }
257
- catch {
466
+ catch (error) {
258
467
  // Python API probe failed, fall back to command-line tools
468
+ this.logger.trace("probe_fallback", {
469
+ sessionId,
470
+ from: "python",
471
+ to: "cli",
472
+ reason: "python_probe_error",
473
+ error,
474
+ });
259
475
  return null;
260
476
  }
261
477
  }
262
- async checkWithCommandLineTools(sessionId) {
478
+ async checkWithCommandLineTools(sessionId, targetId) {
263
479
  let it2api;
264
480
  try {
265
481
  it2api = resolveIterm2ApiPath(this.options.iterm2ApiPath);
266
482
  }
267
- catch {
483
+ catch (error) {
484
+ this.logger.trace("probe_fallback", {
485
+ targetId,
486
+ from: "cli",
487
+ to: null,
488
+ reason: "it2api_unavailable",
489
+ error,
490
+ });
268
491
  return { presence: "unknown", busy: "unknown" };
269
492
  }
270
493
  const presence = await this.checkSessionPresence(it2api, sessionId);
@@ -276,18 +499,33 @@ class Iterm2TerminalStateProbe {
276
499
  if (!first) {
277
500
  return { presence: "available", busy: "unknown" };
278
501
  }
279
- await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? DEFAULT_ITERM2_SAMPLE_DELAY_MS);
502
+ await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? this.gatingPolicy.iTerm2SampleDelayMs);
280
503
  const second = await this.readBufferTail(it2api, sessionId);
281
504
  if (!second) {
282
505
  return { presence: "available", busy: "unknown" };
283
506
  }
284
507
  if (first !== second) {
508
+ this.logger.trace("probe_cli_result", {
509
+ sessionId,
510
+ result: { presence: "available", busy: "busy" },
511
+ reason: "buffer_changed",
512
+ });
285
513
  return { presence: "available", busy: "busy" };
286
514
  }
287
- if (containsActiveBufferMarker(second)) {
515
+ if (containsActiveBufferMarker(second, this.gatingPolicy.activeBufferMarkers)) {
516
+ this.logger.trace("probe_cli_result", {
517
+ sessionId,
518
+ result: { presence: "available", busy: "busy" },
519
+ reason: "active_buffer_marker",
520
+ });
288
521
  return { presence: "available", busy: "busy" };
289
522
  }
290
523
  // Do NOT use generic typing prompts to avoid false positives (see #116)
524
+ this.logger.trace("probe_cli_result", {
525
+ sessionId,
526
+ result: { presence: "available", busy: "unknown" },
527
+ reason: "stable_quiet_buffer",
528
+ });
291
529
  return { presence: "available", busy: "unknown" };
292
530
  }
293
531
  async checkSessionPresence(it2api, sessionId) {
@@ -296,7 +534,13 @@ class Iterm2TerminalStateProbe {
296
534
  const sessionIds = parseIterm2SessionIds(result.stdout);
297
535
  return sessionIds.includes(sessionId) ? "available" : "gone";
298
536
  }
299
- catch {
537
+ catch (error) {
538
+ this.logger.trace("probe_error", {
539
+ sessionId,
540
+ step: "list_sessions",
541
+ error,
542
+ result: "unknown",
543
+ });
300
544
  return "unknown";
301
545
  }
302
546
  }
@@ -311,7 +555,7 @@ class Iterm2TerminalStateProbe {
311
555
  async readBufferTail(it2api, sessionId) {
312
556
  try {
313
557
  const result = await this.execAsync(it2api, ["get-buffer", sessionId]);
314
- return normalizeBufferTail(result.stdout);
558
+ return normalizeBufferTail(result.stdout, this.gatingPolicy.normalizedBufferTailLines);
315
559
  }
316
560
  catch {
317
561
  return null;
@@ -322,9 +566,13 @@ exports.Iterm2TerminalStateProbe = Iterm2TerminalStateProbe;
322
566
  class TmuxTerminalStateProbe {
323
567
  execAsync;
324
568
  options;
325
- constructor(execAsync = execFileAsync, options = {}) {
569
+ logger;
570
+ gatingPolicy;
571
+ constructor(execAsync = execFileAsync, options = {}, logger = new logging_1.NoopLogger(), gatingPolicy = DEFAULT_GATING_POLICY) {
326
572
  this.execAsync = execAsync;
327
573
  this.options = options;
574
+ this.logger = logger;
575
+ this.gatingPolicy = gatingPolicy;
328
576
  }
329
577
  supports(target) {
330
578
  return target.backend === "tmux" && typeof target.tmuxPaneId === "string" && target.tmuxPaneId.trim().length > 0;
@@ -332,39 +580,86 @@ class TmuxTerminalStateProbe {
332
580
  async check(target) {
333
581
  const paneId = target.tmuxPaneId?.trim();
334
582
  if (!paneId) {
583
+ this.logger.trace("probe_result", {
584
+ targetId: target.targetId,
585
+ result: { presence: "unknown", busy: "unknown" },
586
+ reason: "missing_tmux_pane_id",
587
+ });
335
588
  return { presence: "unknown", busy: "unknown" };
336
589
  }
337
590
  const paneState = await this.readPaneState(paneId);
338
591
  if (paneState === "missing") {
592
+ this.logger.trace("probe_result", {
593
+ targetId: target.targetId,
594
+ paneId,
595
+ result: { presence: "gone", busy: "unknown" },
596
+ reason: "pane_missing",
597
+ });
339
598
  return { presence: "gone", busy: "unknown" };
340
599
  }
341
600
  if (paneState === "unknown") {
601
+ this.logger.trace("probe_result", {
602
+ targetId: target.targetId,
603
+ paneId,
604
+ result: { presence: "unknown", busy: "unknown" },
605
+ reason: "pane_state_unknown",
606
+ });
342
607
  return { presence: "unknown", busy: "unknown" };
343
608
  }
344
609
  if (paneState.dead) {
610
+ this.logger.trace("probe_result", {
611
+ targetId: target.targetId,
612
+ paneId,
613
+ result: { presence: "gone", busy: "unknown" },
614
+ reason: "pane_dead",
615
+ });
345
616
  return { presence: "gone", busy: "unknown" };
346
617
  }
347
618
  const first = await this.readBufferTail(paneId);
348
619
  if (!first) {
349
620
  return { presence: "available", busy: "unknown" };
350
621
  }
351
- await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? DEFAULT_TMUX_SAMPLE_DELAY_MS);
622
+ await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? this.gatingPolicy.tmuxSampleDelayMs);
352
623
  const second = await this.readBufferTail(paneId);
353
624
  if (!second) {
354
625
  return { presence: "available", busy: "unknown" };
355
626
  }
356
627
  if (first !== second) {
628
+ this.logger.trace("probe_result", {
629
+ targetId: target.targetId,
630
+ paneId,
631
+ result: { presence: "available", busy: "busy" },
632
+ reason: "buffer_changed",
633
+ });
357
634
  return { presence: "available", busy: "busy" };
358
635
  }
359
- if (containsActiveBufferMarker(second)) {
636
+ if (containsActiveBufferMarker(second, this.gatingPolicy.activeBufferMarkers)) {
637
+ this.logger.trace("probe_result", {
638
+ targetId: target.targetId,
639
+ paneId,
640
+ result: { presence: "available", busy: "busy" },
641
+ reason: "active_buffer_marker",
642
+ });
360
643
  return { presence: "available", busy: "busy" };
361
644
  }
362
645
  // Use cursor-aware detection when available
363
646
  const cursorHint = evaluateCursorAwareTypingPrompt(target, paneState, second);
364
647
  if (cursorHint === "busy") {
648
+ this.logger.trace("probe_result", {
649
+ targetId: target.targetId,
650
+ paneId,
651
+ result: { presence: "available", busy: "busy" },
652
+ reason: "cursor_aware_busy",
653
+ });
365
654
  return { presence: "available", busy: "busy" };
366
655
  }
367
656
  // Do NOT fall back to generic typing prompts when cursorHint is unknown to avoid false positives (see #116)
657
+ this.logger.trace("probe_result", {
658
+ targetId: target.targetId,
659
+ paneId,
660
+ result: { presence: "available", busy: "unknown" },
661
+ reason: cursorHint === "not_busy" ? "cursor_aware_not_busy" : "cursor_aware_unknown",
662
+ });
368
663
  return { presence: "available", busy: "unknown" };
369
664
  }
370
665
  async readPaneState(paneId) {
@@ -397,8 +692,8 @@ class TmuxTerminalStateProbe {
397
692
  }
398
693
  async readBufferTail(paneId) {
399
694
  try {
400
- const result = await this.execAsync("tmux", ["capture-pane", "-p", "-t", paneId, "-S", "-20"]);
401
- return normalizeBufferTail(result.stdout);
695
+ const result = await this.execAsync("tmux", ["capture-pane", "-p", "-t", paneId, "-S", `-${this.gatingPolicy.normalizedBufferTailLines}`]);
696
+ return normalizeBufferTail(result.stdout, this.gatingPolicy.normalizedBufferTailLines);
402
697
  }
403
698
  catch {
404
699
  return null;
@@ -406,10 +701,10 @@ class TmuxTerminalStateProbe {
406
701
  }
407
702
  }
408
703
  exports.TmuxTerminalStateProbe = TmuxTerminalStateProbe;
409
- function containsActiveBufferMarker(bufferTail) {
410
- return ACTIVE_BUFFER_MARKERS.some((marker) => bufferTail.includes(marker));
704
+ function containsActiveBufferMarker(bufferTail, markers = DEFAULT_GATING_POLICY.activeBufferMarkers) {
705
+ return markers.some((marker) => bufferTail.includes(marker));
411
706
  }
412
- function hasVisibleTypingPrompt(bufferTail, prefixes = TYPING_PROMPT_PREFIXES) {
707
+ function hasVisibleTypingPrompt(bufferTail, prefixes = DEFAULT_GATING_POLICY.typingPromptPrefixes) {
413
708
  const lines = bufferTail
414
709
  .split("\n")
415
710
  .map((line) => line.trimEnd())
@@ -467,7 +762,7 @@ function typingPromptPrefixesForRuntime(runtimeKind) {
467
762
  if (runtimeKind === "claude_code") {
468
763
  return ["❯ ", "› "];
469
764
  }
470
- return TYPING_PROMPT_PREFIXES;
765
+ return DEFAULT_GATING_POLICY.typingPromptPrefixes;
471
766
  }
472
767
  function parseOptionalInt(value) {
473
768
  if (!value) {
@@ -476,16 +771,19 @@ function parseOptionalInt(value) {
476
771
  const parsed = Number.parseInt(value, 10);
477
772
  return Number.isFinite(parsed) ? parsed : null;
478
773
  }
479
- function normalizeBufferTail(buffer) {
774
+ function normalizeBufferTail(buffer, tailLines = DEFAULT_GATING_POLICY.normalizedBufferTailLines) {
480
775
  const normalized = buffer
481
776
  .replace(/\r/g, "")
482
777
  .split("\n")
483
778
  .map((line) => line.trimEnd())
484
- .slice(-20)
779
+ .slice(-tailLines)
485
780
  .join("\n")
486
781
  .trim();
487
782
  return normalized.length > 0 ? normalized : null;
488
783
  }
784
+ function normalizeProbeTailFromLines(lines, tailLines = DEFAULT_GATING_POLICY.normalizedBufferTailLines) {
785
+ return normalizeBufferTail(lines.join("\n"), tailLines);
786
+ }
489
787
  function resolveIterm2ApiPath(override) {
490
788
  if (override) {
491
789
  return override;
@@ -622,15 +920,18 @@ function isTmuxMissingPaneError(error) {
622
920
  class ClaudeCodeRuntimePresenceProbe {
623
921
  killFn;
624
922
  sessionFileReader;
625
- constructor(killFn = (pid, signal) => process.kill(pid, signal), sessionFileReader) {
923
+ logger;
924
+ constructor(killFn = (pid, signal) => process.kill(pid, signal), sessionFileReader, logger = new logging_1.NoopLogger()) {
626
925
  this.killFn = killFn;
627
926
  this.sessionFileReader = sessionFileReader;
927
+ this.logger = logger;
628
928
  }
629
929
  supports(target) {
630
930
  return target.runtimeKind === "claude_code" && Number.isInteger(target.runtimePid);
631
931
  }
632
932
  async check(target) {
633
933
  if (!Number.isInteger(target.runtimePid)) {
934
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "unknown", reason: "missing_runtime_pid" });
634
935
  return "unknown";
635
936
  }
636
937
  try {
@@ -639,19 +940,24 @@ class ClaudeCodeRuntimePresenceProbe {
639
940
  // 2. Verify session file exists and is valid
640
941
  const sessionInfo = await (this.sessionFileReader ?? this.defaultSessionFileReader)(target.runtimePid);
641
942
  if (!sessionInfo) {
943
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "gone", reason: "session_file_missing" });
642
944
  return "gone"; // Process exists but session file is invalid/missing
643
945
  }
644
946
  // 3. Verify session ID matches (prevent PID reuse)
645
947
  if (target.runtimeSessionId && sessionInfo.sessionId !== target.runtimeSessionId) {
948
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "gone", reason: "session_id_mismatch" });
646
949
  return "gone";
647
950
  }
951
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "alive" });
648
952
  return "alive";
649
953
  }
650
954
  catch (error) {
651
955
  const code = error instanceof Error && "code" in error ? String(error.code ?? "") : "";
652
956
  if (code === "ESRCH") {
957
+ this.logger.trace("probe_result", { targetId: target.targetId, result: "gone", reason: "process_missing" });
653
958
  return "gone"; // Process doesn't exist
654
959
  }
960
+ this.logger.debug("probe_error", { targetId: target.targetId, error, result: "unknown" });
655
961
  return "unknown";
656
962
  }
657
963
  }
@@ -678,26 +984,43 @@ class ClaudeCodeTerminalStateProbe {
678
984
  sessionFileReader;
679
985
  logFileStatReader;
680
986
  sleepFn;
681
- constructor(sessionFileReader, logFileStatReader, sleepFn = sleep) {
987
+ logger;
988
+ constructor(sessionFileReader, logFileStatReader, sleepFn = sleep, logger = new logging_1.NoopLogger()) {
682
989
  this.sessionFileReader = sessionFileReader;
683
990
  this.logFileStatReader = logFileStatReader;
684
991
  this.sleepFn = sleepFn;
992
+ this.logger = logger;
685
993
  }
686
994
  supports(target) {
687
995
  return target.runtimeKind === "claude_code";
688
996
  }
689
997
  async check(target) {
690
998
  if (!target.runtimePid || !target.runtimeSessionId) {
999
+ this.logger.trace("probe_result", {
1000
+ targetId: target.targetId,
1001
+ result: { presence: "unknown", busy: "unknown" },
1002
+ reason: "missing_runtime_identity",
1003
+ });
691
1004
  return { presence: "unknown", busy: "unknown" };
692
1005
  }
693
1006
  try {
694
1007
  // 1. Read session file to get cwd
695
1008
  const sessionData = await (this.sessionFileReader ?? this.defaultSessionFileReader)(target.runtimePid);
696
1009
  if (!sessionData) {
1010
+ this.logger.trace("probe_result", {
1011
+ targetId: target.targetId,
1012
+ result: { presence: "gone", busy: "unknown" },
1013
+ reason: "session_file_missing",
1014
+ });
697
1015
  return { presence: "gone", busy: "unknown" };
698
1016
  }
699
1017
  // 2. Verify session ID matches
700
1018
  if (sessionData.sessionId !== target.runtimeSessionId) {
1019
+ this.logger.trace("probe_result", {
1020
+ targetId: target.targetId,
1021
+ result: { presence: "gone", busy: "unknown" },
1022
+ reason: "session_id_mismatch",
1023
+ });
701
1024
  return { presence: "gone", busy: "unknown" };
702
1025
  }
703
1026
  // 3. Construct log file path using Claude Code's encoding rules
@@ -709,10 +1032,18 @@ class ClaudeCodeTerminalStateProbe {
709
1032
  const presence = target.backend === "iterm2" && target.itermSessionId
710
1033
  ? await this.checkIterm2Presence(target.itermSessionId)
711
1034
  : "available";
1035
+ this.logger.trace("probe_result", {
1036
+ targetId: target.targetId,
1037
+ result: { presence, busy },
1038
+ });
712
1039
  return { presence, busy };
713
1040
  }
714
1041
  catch (error) {
715
- console.warn('Claude Code state check failed:', error);
1042
+ this.logger.debug("probe_error", {
1043
+ targetId: target.targetId,
1044
+ error,
1045
+ result: { presence: "unknown", busy: "unknown" },
1046
+ });
716
1047
  return { presence: "unknown", busy: "unknown" };
717
1048
  }
718
1049
  }