@holon-run/agentinbox 0.2.0 → 0.3.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.
@@ -0,0 +1,464 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ClaudeCodeTerminalStateProbe = exports.ClaudeCodeRuntimePresenceProbe = exports.TmuxTerminalStateProbe = exports.Iterm2TerminalStateProbe = exports.CodexRuntimePresenceProbe = exports.DefaultActivationGate = void 0;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_util_1 = require("node:util");
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const node_os_1 = __importDefault(require("node:os"));
12
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
13
+ const DEFAULT_ITERM2_SAMPLE_DELAY_MS = 250;
14
+ const DEFAULT_TMUX_SAMPLE_DELAY_MS = 250;
15
+ const ACTIVE_BUFFER_MARKERS = [
16
+ "esc to interrupt",
17
+ "Working (",
18
+ "⠼ ",
19
+ ];
20
+ const TYPING_PROMPT_PREFIXES = [
21
+ "› ",
22
+ "> ",
23
+ ];
24
+ class DefaultActivationGate {
25
+ runtimeProbes;
26
+ terminalProbes;
27
+ constructor(runtimeProbes = [
28
+ new CodexRuntimePresenceProbe(),
29
+ new ClaudeCodeRuntimePresenceProbe(),
30
+ ], terminalProbes = [
31
+ new Iterm2TerminalStateProbe(),
32
+ new ClaudeCodeTerminalStateProbe(),
33
+ new TmuxTerminalStateProbe(),
34
+ ]) {
35
+ this.runtimeProbes = runtimeProbes;
36
+ this.terminalProbes = terminalProbes;
37
+ }
38
+ async evaluate(target) {
39
+ const runtimeProbe = this.runtimeProbes.find((probe) => probe.supports(target));
40
+ const runtimePresence = runtimeProbe ? await runtimeProbe.check(target) : "unknown";
41
+ const terminalProbe = this.terminalProbes.find((probe) => probe.supports(target));
42
+ const terminalState = terminalProbe
43
+ ? await terminalProbe.check(target)
44
+ : { presence: "unknown", busy: "unknown" };
45
+ if (runtimePresence === "gone") {
46
+ return { outcome: "offline", reason: "runtime_gone" };
47
+ }
48
+ if (terminalState.presence === "gone") {
49
+ return { outcome: "offline", reason: "terminal_gone" };
50
+ }
51
+ if (terminalState.busy === "busy") {
52
+ return { outcome: "defer", reason: "terminal_busy" };
53
+ }
54
+ return { outcome: "inject", reason: "gate_unknown_or_idle" };
55
+ }
56
+ }
57
+ exports.DefaultActivationGate = DefaultActivationGate;
58
+ class CodexRuntimePresenceProbe {
59
+ killFn;
60
+ constructor(killFn = (pid, signal) => process.kill(pid, signal)) {
61
+ this.killFn = killFn;
62
+ }
63
+ supports(target) {
64
+ return target.runtimeKind === "codex" && Number.isInteger(target.runtimePid);
65
+ }
66
+ async check(target) {
67
+ if (!Number.isInteger(target.runtimePid)) {
68
+ return "unknown";
69
+ }
70
+ try {
71
+ this.killFn(target.runtimePid, 0);
72
+ return "alive";
73
+ }
74
+ catch (error) {
75
+ const code = error instanceof Error && "code" in error ? String(error.code ?? "") : "";
76
+ if (code === "ESRCH") {
77
+ return "gone";
78
+ }
79
+ return "unknown";
80
+ }
81
+ }
82
+ }
83
+ exports.CodexRuntimePresenceProbe = CodexRuntimePresenceProbe;
84
+ class Iterm2TerminalStateProbe {
85
+ execAsync;
86
+ options;
87
+ constructor(execAsync = execFileAsync, options = {}) {
88
+ this.execAsync = execAsync;
89
+ this.options = options;
90
+ }
91
+ supports(target) {
92
+ // Only support iTerm2 targets that are NOT Claude Code
93
+ // Claude Code targets are handled by ClaudeCodeTerminalStateProbe
94
+ return (target.backend === "iterm2" &&
95
+ target.runtimeKind !== "claude_code" &&
96
+ typeof target.itermSessionId === "string" &&
97
+ target.itermSessionId.length > 0);
98
+ }
99
+ async check(target) {
100
+ const sessionId = target.itermSessionId?.trim();
101
+ if (!sessionId) {
102
+ return { presence: "unknown", busy: "unknown" };
103
+ }
104
+ let it2api;
105
+ try {
106
+ it2api = resolveIterm2ApiPath(this.options.iterm2ApiPath);
107
+ }
108
+ catch {
109
+ return { presence: "unknown", busy: "unknown" };
110
+ }
111
+ const presence = await this.checkSessionPresence(it2api, sessionId);
112
+ if (presence !== "available") {
113
+ return { presence, busy: "unknown" };
114
+ }
115
+ await this.tryReadPrompt(it2api, sessionId);
116
+ const first = await this.readBufferTail(it2api, sessionId);
117
+ if (!first) {
118
+ return { presence: "available", busy: "unknown" };
119
+ }
120
+ await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? DEFAULT_ITERM2_SAMPLE_DELAY_MS);
121
+ const second = await this.readBufferTail(it2api, sessionId);
122
+ if (!second) {
123
+ return { presence: "available", busy: "unknown" };
124
+ }
125
+ if (first !== second) {
126
+ return { presence: "available", busy: "busy" };
127
+ }
128
+ if (containsActiveBufferMarker(second)) {
129
+ return { presence: "available", busy: "busy" };
130
+ }
131
+ return { presence: "available", busy: "unknown" };
132
+ }
133
+ async checkSessionPresence(it2api, sessionId) {
134
+ try {
135
+ const result = await this.execAsync(it2api, ["list-sessions"]);
136
+ const sessions = result.stdout
137
+ .split(/\r?\n/)
138
+ .map((line) => line.trim())
139
+ .filter((line) => line.length > 0);
140
+ return sessions.includes(sessionId) ? "available" : "gone";
141
+ }
142
+ catch {
143
+ return "unknown";
144
+ }
145
+ }
146
+ async tryReadPrompt(it2api, sessionId) {
147
+ try {
148
+ await this.execAsync(it2api, ["get-prompt", sessionId]);
149
+ }
150
+ catch {
151
+ return;
152
+ }
153
+ }
154
+ async readBufferTail(it2api, sessionId) {
155
+ try {
156
+ const result = await this.execAsync(it2api, ["get-buffer", sessionId]);
157
+ return normalizeBufferTail(result.stdout);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ }
164
+ exports.Iterm2TerminalStateProbe = Iterm2TerminalStateProbe;
165
+ class TmuxTerminalStateProbe {
166
+ execAsync;
167
+ options;
168
+ constructor(execAsync = execFileAsync, options = {}) {
169
+ this.execAsync = execAsync;
170
+ this.options = options;
171
+ }
172
+ supports(target) {
173
+ return target.backend === "tmux" && typeof target.tmuxPaneId === "string" && target.tmuxPaneId.trim().length > 0;
174
+ }
175
+ async check(target) {
176
+ const paneId = target.tmuxPaneId?.trim();
177
+ if (!paneId) {
178
+ return { presence: "unknown", busy: "unknown" };
179
+ }
180
+ const paneState = await this.readPaneState(paneId);
181
+ if (paneState === "missing") {
182
+ return { presence: "gone", busy: "unknown" };
183
+ }
184
+ if (paneState === "unknown") {
185
+ return { presence: "unknown", busy: "unknown" };
186
+ }
187
+ if (paneState.dead) {
188
+ return { presence: "gone", busy: "unknown" };
189
+ }
190
+ const first = await this.readBufferTail(paneId);
191
+ if (!first) {
192
+ return { presence: "available", busy: "unknown" };
193
+ }
194
+ await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? DEFAULT_TMUX_SAMPLE_DELAY_MS);
195
+ const second = await this.readBufferTail(paneId);
196
+ if (!second) {
197
+ return { presence: "available", busy: "unknown" };
198
+ }
199
+ if (first !== second) {
200
+ return { presence: "available", busy: "busy" };
201
+ }
202
+ if (containsActiveBufferMarker(second)) {
203
+ return { presence: "available", busy: "busy" };
204
+ }
205
+ if (paneState.active && hasVisibleTypingPrompt(second)) {
206
+ return { presence: "available", busy: "busy" };
207
+ }
208
+ return { presence: "available", busy: "unknown" };
209
+ }
210
+ async readPaneState(paneId) {
211
+ try {
212
+ const result = await this.execAsync("tmux", ["display-message", "-p", "-t", paneId, "#{pane_active} #{pane_dead}"]);
213
+ const [active, dead] = result.stdout.trim().split(/\s+/, 2);
214
+ if (!active || !dead) {
215
+ return "unknown";
216
+ }
217
+ return {
218
+ active: active === "1",
219
+ dead: dead === "1",
220
+ };
221
+ }
222
+ catch (error) {
223
+ if (isTmuxMissingPaneError(error)) {
224
+ return "missing";
225
+ }
226
+ return "unknown";
227
+ }
228
+ }
229
+ async readBufferTail(paneId) {
230
+ try {
231
+ const result = await this.execAsync("tmux", ["capture-pane", "-p", "-t", paneId, "-S", "-20"]);
232
+ return normalizeBufferTail(result.stdout);
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ }
239
+ exports.TmuxTerminalStateProbe = TmuxTerminalStateProbe;
240
+ function containsActiveBufferMarker(bufferTail) {
241
+ return ACTIVE_BUFFER_MARKERS.some((marker) => bufferTail.includes(marker));
242
+ }
243
+ function hasVisibleTypingPrompt(bufferTail) {
244
+ const lines = bufferTail
245
+ .split("\n")
246
+ .map((line) => line.trimEnd())
247
+ .filter((line) => line.length > 0);
248
+ const lastLine = lines.at(-1);
249
+ if (!lastLine) {
250
+ return false;
251
+ }
252
+ for (const prefix of TYPING_PROMPT_PREFIXES) {
253
+ if (lastLine.startsWith(prefix) && lastLine.slice(prefix.length).trim().length > 0) {
254
+ return true;
255
+ }
256
+ }
257
+ return false;
258
+ }
259
+ function normalizeBufferTail(buffer) {
260
+ const normalized = buffer
261
+ .replace(/\r/g, "")
262
+ .split("\n")
263
+ .map((line) => line.trimEnd())
264
+ .slice(-20)
265
+ .join("\n")
266
+ .trim();
267
+ return normalized.length > 0 ? normalized : null;
268
+ }
269
+ function resolveIterm2ApiPath(override) {
270
+ if (override) {
271
+ return override;
272
+ }
273
+ const candidates = [
274
+ "/Applications/iTerm.app/Contents/Resources/it2api",
275
+ "/Applications/iTerm.app/Contents/Resources/utilities/it2api",
276
+ ];
277
+ for (const candidate of candidates) {
278
+ if (node_fs_1.default.existsSync(candidate)) {
279
+ return candidate;
280
+ }
281
+ }
282
+ throw new Error("unable to locate iTerm2 it2api helper");
283
+ }
284
+ async function sleep(ms) {
285
+ await new Promise((resolve) => setTimeout(resolve, ms));
286
+ }
287
+ function isTmuxMissingPaneError(error) {
288
+ if (!(error instanceof Error)) {
289
+ return false;
290
+ }
291
+ const candidate = `${error.message} ${"stderr" in error ? String(error.stderr ?? "") : ""}`;
292
+ return candidate.includes("can't find pane");
293
+ }
294
+ class ClaudeCodeRuntimePresenceProbe {
295
+ killFn;
296
+ sessionFileReader;
297
+ constructor(killFn = (pid, signal) => process.kill(pid, signal), sessionFileReader) {
298
+ this.killFn = killFn;
299
+ this.sessionFileReader = sessionFileReader;
300
+ }
301
+ supports(target) {
302
+ return target.runtimeKind === "claude_code" && Number.isInteger(target.runtimePid);
303
+ }
304
+ async check(target) {
305
+ if (!Number.isInteger(target.runtimePid)) {
306
+ return "unknown";
307
+ }
308
+ try {
309
+ // 1. Check if process exists
310
+ this.killFn(target.runtimePid, 0);
311
+ // 2. Verify session file exists and is valid
312
+ const sessionInfo = await (this.sessionFileReader ?? this.defaultSessionFileReader)(target.runtimePid);
313
+ if (!sessionInfo) {
314
+ return "gone"; // Process exists but session file is invalid/missing
315
+ }
316
+ // 3. Verify session ID matches (prevent PID reuse)
317
+ if (target.runtimeSessionId && sessionInfo.sessionId !== target.runtimeSessionId) {
318
+ return "gone";
319
+ }
320
+ return "alive";
321
+ }
322
+ catch (error) {
323
+ const code = error instanceof Error && "code" in error ? String(error.code ?? "") : "";
324
+ if (code === "ESRCH") {
325
+ return "gone"; // Process doesn't exist
326
+ }
327
+ return "unknown";
328
+ }
329
+ }
330
+ defaultSessionFileReader = async (pid) => {
331
+ const sessionFile = node_path_1.default.join(node_os_1.default.homedir(), ".claude", "sessions", `${pid}.json`);
332
+ try {
333
+ const content = await node_fs_1.default.promises.readFile(sessionFile, "utf8");
334
+ const session = JSON.parse(content);
335
+ if (!session.sessionId || typeof session.sessionId !== "string") {
336
+ return null;
337
+ }
338
+ return {
339
+ sessionId: session.sessionId,
340
+ cwd: session.cwd,
341
+ };
342
+ }
343
+ catch {
344
+ return null;
345
+ }
346
+ };
347
+ }
348
+ exports.ClaudeCodeRuntimePresenceProbe = ClaudeCodeRuntimePresenceProbe;
349
+ class ClaudeCodeTerminalStateProbe {
350
+ sessionFileReader;
351
+ logFileStatReader;
352
+ sleepFn;
353
+ constructor(sessionFileReader, logFileStatReader, sleepFn = sleep) {
354
+ this.sessionFileReader = sessionFileReader;
355
+ this.logFileStatReader = logFileStatReader;
356
+ this.sleepFn = sleepFn;
357
+ }
358
+ supports(target) {
359
+ return target.runtimeKind === "claude_code";
360
+ }
361
+ async check(target) {
362
+ if (!target.runtimePid || !target.runtimeSessionId) {
363
+ return { presence: "unknown", busy: "unknown" };
364
+ }
365
+ try {
366
+ // 1. Read session file to get cwd
367
+ const sessionData = await (this.sessionFileReader ?? this.defaultSessionFileReader)(target.runtimePid);
368
+ if (!sessionData) {
369
+ return { presence: "gone", busy: "unknown" };
370
+ }
371
+ // 2. Verify session ID matches
372
+ if (sessionData.sessionId !== target.runtimeSessionId) {
373
+ return { presence: "gone", busy: "unknown" };
374
+ }
375
+ // 3. Construct log file path using Claude Code's encoding rules
376
+ const sanitizedProjectPath = sessionData.cwd.replace(/[^a-zA-Z0-9]/g, '-');
377
+ const logFile = node_path_1.default.join(node_os_1.default.homedir(), ".claude", "projects", sanitizedProjectPath, `${target.runtimeSessionId}.jsonl`);
378
+ // 4. Check log file activity
379
+ const busy = await this.checkLogFileActivity(logFile);
380
+ // 5. Check iTerm2 session presence if available
381
+ const presence = target.backend === "iterm2" && target.itermSessionId
382
+ ? await this.checkIterm2Presence(target.itermSessionId)
383
+ : "available";
384
+ return { presence, busy };
385
+ }
386
+ catch (error) {
387
+ console.warn('Claude Code state check failed:', error);
388
+ return { presence: "unknown", busy: "unknown" };
389
+ }
390
+ }
391
+ defaultSessionFileReader = async (pid) => {
392
+ const sessionFile = node_path_1.default.join(node_os_1.default.homedir(), ".claude", "sessions", `${pid}.json`);
393
+ try {
394
+ const content = await node_fs_1.default.promises.readFile(sessionFile, "utf8");
395
+ const session = JSON.parse(content);
396
+ if (!session.sessionId || typeof session.sessionId !== "string") {
397
+ return null;
398
+ }
399
+ return {
400
+ sessionId: session.sessionId,
401
+ cwd: session.cwd,
402
+ };
403
+ }
404
+ catch {
405
+ return null;
406
+ }
407
+ };
408
+ defaultLogFileStatReader = async (filePath) => {
409
+ try {
410
+ const stats = await node_fs_1.default.promises.stat(filePath);
411
+ return { mtimeMs: stats.mtimeMs };
412
+ }
413
+ catch {
414
+ return null;
415
+ }
416
+ };
417
+ async checkLogFileActivity(logFile) {
418
+ try {
419
+ const statResult = await (this.logFileStatReader ?? this.defaultLogFileStatReader)(logFile);
420
+ if (!statResult) {
421
+ return "unknown";
422
+ }
423
+ const timeSinceLastActivity = Date.now() - statResult.mtimeMs;
424
+ // Activity within last 5 seconds = busy
425
+ if (timeSinceLastActivity < 5000) {
426
+ return "busy";
427
+ }
428
+ // Secondary sampling to confirm (avoid false positives)
429
+ await this.sleepFn(250);
430
+ const statResult2 = await (this.logFileStatReader ?? this.defaultLogFileStatReader)(logFile);
431
+ if (!statResult2) {
432
+ return "unknown";
433
+ }
434
+ const timeSinceLastActivity2 = Date.now() - statResult2.mtimeMs;
435
+ if (statResult2.mtimeMs > statResult.mtimeMs && timeSinceLastActivity2 < 5000) {
436
+ return "busy";
437
+ }
438
+ // Activity within last 30 seconds = idle but recently active
439
+ return timeSinceLastActivity2 < 30000 ? "idle" : "unknown";
440
+ }
441
+ catch {
442
+ return "unknown";
443
+ }
444
+ }
445
+ async checkIterm2Presence(itermSessionId) {
446
+ try {
447
+ const it2api = resolveIterm2ApiPath();
448
+ const result = await execFileAsync(it2api, ["list-sessions"]);
449
+ const lines = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
450
+ // Parse session IDs from iTerm2 output format: "Session \"...\" id=<UUID> ..."
451
+ const sessionIds = lines
452
+ .map((line) => {
453
+ const match = line.match(/id=([A-F0-9-]+)/);
454
+ return match ? match[1] : null;
455
+ })
456
+ .filter((id) => id !== null);
457
+ return sessionIds.includes(itermSessionId) ? "available" : "gone";
458
+ }
459
+ catch {
460
+ return "unknown";
461
+ }
462
+ }
463
+ }
464
+ exports.ClaudeCodeTerminalStateProbe = ClaudeCodeTerminalStateProbe;