ax-agents 0.0.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/ax.js +3684 -0
- package/package.json +46 -0
package/ax.js
ADDED
|
@@ -0,0 +1,3684 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ax.js - CLI for interacting with AI agents (Codex, Claude) via tmux
|
|
4
|
+
//
|
|
5
|
+
// Exit codes:
|
|
6
|
+
// 0 - success / ready
|
|
7
|
+
// 1 - error
|
|
8
|
+
// 2 - rate limited
|
|
9
|
+
// 3 - awaiting confirmation
|
|
10
|
+
// 4 - thinking
|
|
11
|
+
//
|
|
12
|
+
// Usage: ./ax.js --help
|
|
13
|
+
// ./axcodex.js --help (symlink)
|
|
14
|
+
// ./axclaude.js --help (symlink)
|
|
15
|
+
|
|
16
|
+
import { execSync, spawnSync, spawn } from "node:child_process";
|
|
17
|
+
import { fstatSync, statSync, readFileSync, readdirSync, existsSync, appendFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, watch } from "node:fs";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {'claude' | 'codex'} ToolName
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} ParsedSession
|
|
29
|
+
* @property {string} tool
|
|
30
|
+
* @property {string} [daemonName]
|
|
31
|
+
* @property {string} [uuid]
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {Object} DaemonConfig
|
|
36
|
+
* @property {string} name
|
|
37
|
+
* @property {ToolName} tool
|
|
38
|
+
* @property {string[]} watch
|
|
39
|
+
* @property {number} interval
|
|
40
|
+
* @property {string} prompt
|
|
41
|
+
* @property {string} [path]
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {Object} MailboxEntry
|
|
46
|
+
* @property {string} timestamp
|
|
47
|
+
* @property {string} type
|
|
48
|
+
* @property {MailboxPayload} payload
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} MailboxPayload
|
|
53
|
+
* @property {string} agent
|
|
54
|
+
* @property {string} session
|
|
55
|
+
* @property {string} branch
|
|
56
|
+
* @property {string} commit
|
|
57
|
+
* @property {string[]} files
|
|
58
|
+
* @property {string} [summary]
|
|
59
|
+
* @property {string} [message]
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {Object} AgentInterface
|
|
64
|
+
* @property {string} name
|
|
65
|
+
* @property {string} envVar
|
|
66
|
+
* @property {string} startCommand
|
|
67
|
+
* @property {string} approveKey
|
|
68
|
+
* @property {string} rejectKey
|
|
69
|
+
* @property {Record<string, string> | null} [reviewOptions]
|
|
70
|
+
* @property {string} [safeAllowedTools]
|
|
71
|
+
* @property {() => string} getDefaultSession
|
|
72
|
+
* @property {(screen: string) => string | null} getState
|
|
73
|
+
* @property {(screen: string) => ActionInfo | null} parseAction
|
|
74
|
+
* @property {(screen: string) => ResponseInfo[]} extractResponses
|
|
75
|
+
* @property {(yolo?: boolean) => string} buildStartCommand
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {Object} ActionInfo
|
|
80
|
+
* @property {string} tool
|
|
81
|
+
* @property {string} action
|
|
82
|
+
* @property {string} [file]
|
|
83
|
+
* @property {string} [command]
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @typedef {Object} ResponseInfo
|
|
88
|
+
* @property {'assistant' | 'user' | 'thinking'} type
|
|
89
|
+
* @property {string} text
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @typedef {Object} FileEditContext
|
|
94
|
+
* @property {string} intent
|
|
95
|
+
* @property {{name: string, input?: any, id?: string}} toolCall
|
|
96
|
+
* @property {number} editSequence
|
|
97
|
+
* @property {string[]} subsequentErrors
|
|
98
|
+
* @property {string[]} readsBefore
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @typedef {Object} ParentSession
|
|
103
|
+
* @property {string | null} session
|
|
104
|
+
* @property {string} uuid
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @typedef {Object} ClaudeSettings
|
|
109
|
+
* @property {{UserPromptSubmit?: Array<{matcher: string, hooks: Array<{type: string, command: string, timeout?: number}>}>}} [hooks]
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
const DEBUG = process.env.AX_DEBUG === "1";
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} context
|
|
116
|
+
* @param {unknown} err
|
|
117
|
+
*/
|
|
118
|
+
function debugError(context, err) {
|
|
119
|
+
if (DEBUG) console.error(`[debug:${context}]`, err instanceof Error ? err.message : err);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// =============================================================================
|
|
123
|
+
// Project root detection (walk up to find .ai/ directory)
|
|
124
|
+
// =============================================================================
|
|
125
|
+
|
|
126
|
+
function findProjectRoot(startDir = process.cwd()) {
|
|
127
|
+
let dir = startDir;
|
|
128
|
+
while (dir !== path.dirname(dir)) {
|
|
129
|
+
if (existsSync(path.join(dir, ".ai"))) {
|
|
130
|
+
return dir;
|
|
131
|
+
}
|
|
132
|
+
dir = path.dirname(dir);
|
|
133
|
+
}
|
|
134
|
+
// Fallback to cwd if no .ai/ found (will be created on first use)
|
|
135
|
+
return startDir;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const PROJECT_ROOT = findProjectRoot();
|
|
139
|
+
const AI_DIR = path.join(PROJECT_ROOT, ".ai");
|
|
140
|
+
const AGENTS_DIR = path.join(AI_DIR, "agents");
|
|
141
|
+
const HOOKS_DIR = path.join(AI_DIR, "hooks");
|
|
142
|
+
|
|
143
|
+
// =============================================================================
|
|
144
|
+
// Helpers - tmux
|
|
145
|
+
// =============================================================================
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string[]} args
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
function tmux(args) {
|
|
152
|
+
const result = spawnSync("tmux", args, { encoding: "utf-8" });
|
|
153
|
+
if (result.status !== 0) throw new Error(result.stderr || "tmux error");
|
|
154
|
+
return result.stdout;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {string} session
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
function tmuxHasSession(session) {
|
|
162
|
+
try {
|
|
163
|
+
tmux(["has-session", "-t", session]);
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {string} session
|
|
172
|
+
* @param {number} [scrollback]
|
|
173
|
+
* @returns {string}
|
|
174
|
+
*/
|
|
175
|
+
function tmuxCapture(session, scrollback = 0) {
|
|
176
|
+
try {
|
|
177
|
+
const args = ["capture-pane", "-t", session, "-p"];
|
|
178
|
+
if (scrollback) args.push("-S", String(-scrollback));
|
|
179
|
+
return tmux(args);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
debugError("tmuxCapture", err);
|
|
182
|
+
return "";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {string} session
|
|
188
|
+
* @param {string} keys
|
|
189
|
+
*/
|
|
190
|
+
function tmuxSend(session, keys) {
|
|
191
|
+
tmux(["send-keys", "-t", session, keys]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {string} session
|
|
196
|
+
* @param {string} text
|
|
197
|
+
*/
|
|
198
|
+
function tmuxSendLiteral(session, text) {
|
|
199
|
+
tmux(["send-keys", "-t", session, "-l", text]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {string} session
|
|
204
|
+
*/
|
|
205
|
+
function tmuxKill(session) {
|
|
206
|
+
try {
|
|
207
|
+
tmux(["kill-session", "-t", session]);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
debugError("tmuxKill", err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {string} session
|
|
215
|
+
* @param {string} command
|
|
216
|
+
*/
|
|
217
|
+
function tmuxNewSession(session, command) {
|
|
218
|
+
// Use spawnSync to avoid command injection via session/command
|
|
219
|
+
const result = spawnSync("tmux", ["new-session", "-d", "-s", session, command], { encoding: "utf-8" });
|
|
220
|
+
if (result.status !== 0) throw new Error(result.stderr || "tmux new-session failed");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @returns {string | null}
|
|
225
|
+
*/
|
|
226
|
+
function tmuxCurrentSession() {
|
|
227
|
+
if (!process.env.TMUX) return null;
|
|
228
|
+
const result = spawnSync("tmux", ["display-message", "-p", "#S"], { encoding: "utf-8" });
|
|
229
|
+
if (result.status !== 0) return null;
|
|
230
|
+
return result.stdout.trim();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Check if a session was started in yolo mode by inspecting the pane's start command.
|
|
235
|
+
* @param {string} session
|
|
236
|
+
* @returns {boolean}
|
|
237
|
+
*/
|
|
238
|
+
function isYoloSession(session) {
|
|
239
|
+
try {
|
|
240
|
+
const result = spawnSync("tmux", ["display-message", "-t", session, "-p", "#{pane_start_command}"], {
|
|
241
|
+
encoding: "utf-8",
|
|
242
|
+
});
|
|
243
|
+
if (result.status !== 0) return false;
|
|
244
|
+
const cmd = result.stdout.trim();
|
|
245
|
+
return cmd.includes("--dangerously-");
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// =============================================================================
|
|
252
|
+
// Helpers - timing
|
|
253
|
+
// =============================================================================
|
|
254
|
+
|
|
255
|
+
/** @param {number} ms */
|
|
256
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
257
|
+
|
|
258
|
+
const POLL_MS = parseInt(process.env.AX_POLL_MS || "200", 10);
|
|
259
|
+
const DEFAULT_TIMEOUT_MS = parseInt(process.env.AX_TIMEOUT_MS || "120000", 10);
|
|
260
|
+
const REVIEW_TIMEOUT_MS = parseInt(process.env.AX_REVIEW_TIMEOUT_MS || "900000", 10); // 15 minutes
|
|
261
|
+
const STARTUP_TIMEOUT_MS = parseInt(process.env.AX_STARTUP_TIMEOUT_MS || "30000", 10);
|
|
262
|
+
const DAEMON_STARTUP_TIMEOUT_MS = parseInt(process.env.AX_DAEMON_STARTUP_TIMEOUT_MS || "60000", 10);
|
|
263
|
+
const DAEMON_RESPONSE_TIMEOUT_MS = parseInt(process.env.AX_DAEMON_RESPONSE_TIMEOUT_MS || "300000", 10); // 5 minutes
|
|
264
|
+
const DAEMON_HEALTH_CHECK_MS = parseInt(process.env.AX_DAEMON_HEALTH_CHECK_MS || "30000", 10);
|
|
265
|
+
const STABLE_MS = parseInt(process.env.AX_STABLE_MS || "1000", 10);
|
|
266
|
+
const APPROVE_DELAY_MS = parseInt(process.env.AX_APPROVE_DELAY_MS || "100", 10);
|
|
267
|
+
const MAILBOX_MAX_AGE_MS = parseInt(process.env.AX_MAILBOX_MAX_AGE_MS || "3600000", 10); // 1 hour
|
|
268
|
+
const CLAUDE_CONFIG_DIR = process.env.AX_CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
269
|
+
const CODEX_CONFIG_DIR = process.env.AX_CODEX_CONFIG_DIR || path.join(os.homedir(), ".codex");
|
|
270
|
+
const TRUNCATE_USER_LEN = 500;
|
|
271
|
+
const TRUNCATE_THINKING_LEN = 300;
|
|
272
|
+
const DAEMON_GIT_CONTEXT_HOURS = 4;
|
|
273
|
+
const DAEMON_GIT_CONTEXT_MAX_LINES = 200;
|
|
274
|
+
const DAEMON_PARENT_CONTEXT_ENTRIES = 10;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {string} session
|
|
278
|
+
* @param {(screen: string) => boolean} predicate
|
|
279
|
+
* @param {number} [timeoutMs]
|
|
280
|
+
* @returns {Promise<string>}
|
|
281
|
+
*/
|
|
282
|
+
async function waitFor(session, predicate, timeoutMs = STARTUP_TIMEOUT_MS) {
|
|
283
|
+
const start = Date.now();
|
|
284
|
+
while (Date.now() - start < timeoutMs) {
|
|
285
|
+
const screen = tmuxCapture(session);
|
|
286
|
+
if (predicate(screen)) return screen;
|
|
287
|
+
await sleep(POLL_MS);
|
|
288
|
+
}
|
|
289
|
+
throw new Error("timeout");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// =============================================================================
|
|
293
|
+
// Helpers - process
|
|
294
|
+
// =============================================================================
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @returns {number | null}
|
|
298
|
+
*/
|
|
299
|
+
function findCallerPid() {
|
|
300
|
+
let pid = process.ppid;
|
|
301
|
+
while (pid > 1) {
|
|
302
|
+
const result = spawnSync("ps", ["-p", pid.toString(), "-o", "ppid=,comm="], { encoding: "utf-8" });
|
|
303
|
+
if (result.status !== 0) break;
|
|
304
|
+
const parts = result.stdout.trim().split(/\s+/);
|
|
305
|
+
const ppid = parseInt(parts[0], 10);
|
|
306
|
+
const cmd = parts.slice(1).join(" ");
|
|
307
|
+
if (cmd.includes("claude") || cmd.includes("codex")) {
|
|
308
|
+
return pid;
|
|
309
|
+
}
|
|
310
|
+
pid = ppid;
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// =============================================================================
|
|
316
|
+
// Helpers - stdin
|
|
317
|
+
// =============================================================================
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @returns {boolean}
|
|
321
|
+
*/
|
|
322
|
+
function hasStdinData() {
|
|
323
|
+
try {
|
|
324
|
+
const stat = fstatSync(0);
|
|
325
|
+
return stat.isFIFO() || stat.isFile();
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @returns {Promise<string>}
|
|
333
|
+
*/
|
|
334
|
+
async function readStdin() {
|
|
335
|
+
return new Promise((resolve) => {
|
|
336
|
+
let data = "";
|
|
337
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
338
|
+
process.stdin.on("end", () => resolve(data.trim()));
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// =============================================================================
|
|
343
|
+
// Helpers - session tracking
|
|
344
|
+
// =============================================================================
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* @param {string} session
|
|
348
|
+
* @returns {ParsedSession | null}
|
|
349
|
+
*/
|
|
350
|
+
function parseSessionName(session) {
|
|
351
|
+
const match = session.match(/^(claude|codex)-(.+)$/i);
|
|
352
|
+
if (!match) return null;
|
|
353
|
+
|
|
354
|
+
const tool = match[1].toLowerCase();
|
|
355
|
+
const rest = match[2];
|
|
356
|
+
|
|
357
|
+
// Daemon: {tool}-daemon-{name}-{uuid}
|
|
358
|
+
const daemonMatch = rest.match(/^daemon-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
|
|
359
|
+
if (daemonMatch) {
|
|
360
|
+
return { tool, daemonName: daemonMatch[1], uuid: daemonMatch[2] };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Partner: {tool}-partner-{uuid}
|
|
364
|
+
const partnerMatch = rest.match(/^partner-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
|
|
365
|
+
if (partnerMatch) {
|
|
366
|
+
return { tool, uuid: partnerMatch[1] };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Anything else
|
|
370
|
+
return { tool };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* @param {string} tool
|
|
375
|
+
* @returns {string}
|
|
376
|
+
*/
|
|
377
|
+
function generateSessionName(tool) {
|
|
378
|
+
return `${tool}-partner-${randomUUID()}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @param {string} cwd
|
|
383
|
+
* @returns {string}
|
|
384
|
+
*/
|
|
385
|
+
function getClaudeProjectPath(cwd) {
|
|
386
|
+
// Claude encodes project paths by replacing / with -
|
|
387
|
+
// e.g., /Users/sebinsua/dev/gruf -> -Users-sebinsua-dev-gruf
|
|
388
|
+
return cwd.replace(/\//g, "-");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @param {string} sessionName
|
|
393
|
+
* @returns {string | null}
|
|
394
|
+
*/
|
|
395
|
+
function getTmuxSessionCwd(sessionName) {
|
|
396
|
+
try {
|
|
397
|
+
const result = spawnSync("tmux", ["display-message", "-t", sessionName, "-p", "#{pane_current_path}"], {
|
|
398
|
+
encoding: "utf-8",
|
|
399
|
+
});
|
|
400
|
+
if (result.status === 0) return result.stdout.trim();
|
|
401
|
+
} catch (err) {
|
|
402
|
+
debugError("getTmuxSessionCwd", err);
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* @param {string} sessionId
|
|
409
|
+
* @param {string | null} sessionName
|
|
410
|
+
* @returns {string | null}
|
|
411
|
+
*/
|
|
412
|
+
function findClaudeLogPath(sessionId, sessionName) {
|
|
413
|
+
// Get cwd from tmux session, fall back to process.cwd()
|
|
414
|
+
const cwd = (sessionName && getTmuxSessionCwd(sessionName)) || process.cwd();
|
|
415
|
+
const projectPath = getClaudeProjectPath(cwd);
|
|
416
|
+
const claudeProjectDir = path.join(CLAUDE_CONFIG_DIR, "projects", projectPath);
|
|
417
|
+
|
|
418
|
+
// Check sessions-index.json first
|
|
419
|
+
const indexPath = path.join(claudeProjectDir, "sessions-index.json");
|
|
420
|
+
if (existsSync(indexPath)) {
|
|
421
|
+
try {
|
|
422
|
+
const index = JSON.parse(readFileSync(indexPath, "utf-8"));
|
|
423
|
+
const entry = index.entries?.find(/** @param {{sessionId: string, fullPath?: string}} e */ (e) => e.sessionId === sessionId);
|
|
424
|
+
if (entry?.fullPath) return entry.fullPath;
|
|
425
|
+
} catch (err) {
|
|
426
|
+
debugError("findClaudeLogPath", err);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Fallback: direct path
|
|
431
|
+
const directPath = path.join(claudeProjectDir, `${sessionId}.jsonl`);
|
|
432
|
+
if (existsSync(directPath)) return directPath;
|
|
433
|
+
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* @param {string} sessionName
|
|
439
|
+
* @returns {string | null}
|
|
440
|
+
*/
|
|
441
|
+
function findCodexLogPath(sessionName) {
|
|
442
|
+
// For Codex, we need to match by timing since we can't control the session ID
|
|
443
|
+
// Get tmux session creation time
|
|
444
|
+
try {
|
|
445
|
+
const result = spawnSync("tmux", ["display-message", "-t", sessionName, "-p", "#{session_created}"], {
|
|
446
|
+
encoding: "utf-8",
|
|
447
|
+
});
|
|
448
|
+
if (result.status !== 0) return null;
|
|
449
|
+
const createdTs = parseInt(result.stdout.trim(), 10) * 1000; // tmux gives seconds, we need ms
|
|
450
|
+
if (isNaN(createdTs)) return null;
|
|
451
|
+
|
|
452
|
+
// Codex stores sessions in ~/.codex/sessions/YYYY/MM/DD/rollout-TIMESTAMP-UUID.jsonl
|
|
453
|
+
const sessionsDir = path.join(CODEX_CONFIG_DIR, "sessions");
|
|
454
|
+
if (!existsSync(sessionsDir)) return null;
|
|
455
|
+
|
|
456
|
+
const startDate = new Date(createdTs);
|
|
457
|
+
const year = startDate.getFullYear().toString();
|
|
458
|
+
const month = String(startDate.getMonth() + 1).padStart(2, "0");
|
|
459
|
+
const day = String(startDate.getDate()).padStart(2, "0");
|
|
460
|
+
|
|
461
|
+
const dayDir = path.join(sessionsDir, year, month, day);
|
|
462
|
+
if (!existsSync(dayDir)) return null;
|
|
463
|
+
|
|
464
|
+
// Find the closest log file created after the tmux session started
|
|
465
|
+
// Use 60-second window to handle slow startups (model download, first run, heavy load)
|
|
466
|
+
const files = readdirSync(dayDir).filter((f) => f.endsWith(".jsonl"));
|
|
467
|
+
const candidates = [];
|
|
468
|
+
|
|
469
|
+
for (const file of files) {
|
|
470
|
+
// Parse timestamp from filename: rollout-2026-01-22T13-05-15-UUID.jsonl
|
|
471
|
+
const match = file.match(/^rollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-/);
|
|
472
|
+
if (!match) continue;
|
|
473
|
+
|
|
474
|
+
const [, y, mo, d, h, mi, s] = match;
|
|
475
|
+
const fileTime = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`).getTime();
|
|
476
|
+
const diff = fileTime - createdTs;
|
|
477
|
+
|
|
478
|
+
// Log file should be created shortly after session start
|
|
479
|
+
// Allow small negative diff (-2s) for clock skew, up to 60s for slow starts
|
|
480
|
+
if (diff >= -2000 && diff < 60000) {
|
|
481
|
+
candidates.push({ file, diff: Math.abs(diff), path: path.join(dayDir, file) });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (candidates.length === 0) return null;
|
|
486
|
+
// Return the closest match
|
|
487
|
+
candidates.sort((a, b) => a.diff - b.diff);
|
|
488
|
+
return candidates[0].path;
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Extract assistant text responses from a JSONL log file.
|
|
497
|
+
* This provides clean text without screen-scraped artifacts.
|
|
498
|
+
* @param {string} logPath - Path to the JSONL log file
|
|
499
|
+
* @param {number} [index=0] - 0 = last response, -1 = second-to-last, etc.
|
|
500
|
+
* @returns {string | null} The assistant text or null if not found
|
|
501
|
+
*/
|
|
502
|
+
function getAssistantText(logPath, index = 0) {
|
|
503
|
+
if (!logPath || !existsSync(logPath)) return null;
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
const content = readFileSync(logPath, "utf-8");
|
|
507
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
508
|
+
|
|
509
|
+
// Collect all assistant entries with text (from end, for efficiency)
|
|
510
|
+
const assistantTexts = [];
|
|
511
|
+
const needed = Math.abs(index) + 1;
|
|
512
|
+
|
|
513
|
+
for (let i = lines.length - 1; i >= 0 && assistantTexts.length < needed; i--) {
|
|
514
|
+
try {
|
|
515
|
+
const entry = JSON.parse(lines[i]);
|
|
516
|
+
if (entry.type === "assistant") {
|
|
517
|
+
/** @type {{type: string, text?: string}[]} */
|
|
518
|
+
const parts = entry.message?.content || [];
|
|
519
|
+
const text = parts
|
|
520
|
+
.filter((p) => p.type === "text")
|
|
521
|
+
.map((p) => p.text || "")
|
|
522
|
+
.join("\n")
|
|
523
|
+
.trim();
|
|
524
|
+
if (text) assistantTexts.push(text);
|
|
525
|
+
}
|
|
526
|
+
} catch (err) {
|
|
527
|
+
debugError("getAssistantText:parse", err);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// index=0 means last (assistantTexts[0]), index=-1 means second-to-last (assistantTexts[1])
|
|
532
|
+
const targetIndex = Math.abs(index);
|
|
533
|
+
return assistantTexts[targetIndex] ?? null;
|
|
534
|
+
} catch (err) {
|
|
535
|
+
debugError("getAssistantText", err);
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* @returns {string[]}
|
|
542
|
+
*/
|
|
543
|
+
function tmuxListSessions() {
|
|
544
|
+
try {
|
|
545
|
+
const output = tmux(["list-sessions", "-F", "#{session_name}"]);
|
|
546
|
+
return output.trim().split("\n").filter(Boolean);
|
|
547
|
+
} catch {
|
|
548
|
+
return [];
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* @param {string} partial
|
|
554
|
+
* @returns {string | null}
|
|
555
|
+
*/
|
|
556
|
+
function resolveSessionName(partial) {
|
|
557
|
+
if (!partial) return null;
|
|
558
|
+
|
|
559
|
+
const sessions = tmuxListSessions();
|
|
560
|
+
const agentSessions = sessions.filter((s) => parseSessionName(s));
|
|
561
|
+
|
|
562
|
+
// Exact match
|
|
563
|
+
if (agentSessions.includes(partial)) return partial;
|
|
564
|
+
|
|
565
|
+
// Daemon name match (e.g., "reviewer" matches "claude-daemon-reviewer-uuid")
|
|
566
|
+
const daemonMatches = agentSessions.filter((s) => {
|
|
567
|
+
const parsed = parseSessionName(s);
|
|
568
|
+
return parsed?.daemonName === partial;
|
|
569
|
+
});
|
|
570
|
+
if (daemonMatches.length === 1) return daemonMatches[0];
|
|
571
|
+
if (daemonMatches.length > 1) {
|
|
572
|
+
console.log("ERROR: ambiguous daemon name. Matches:");
|
|
573
|
+
for (const m of daemonMatches) console.log(` ${m}`);
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Prefix match
|
|
578
|
+
const matches = agentSessions.filter((s) => s.startsWith(partial));
|
|
579
|
+
if (matches.length === 1) return matches[0];
|
|
580
|
+
if (matches.length > 1) {
|
|
581
|
+
console.log("ERROR: ambiguous session prefix. Matches:");
|
|
582
|
+
for (const m of matches) console.log(` ${m}`);
|
|
583
|
+
process.exit(1);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Partial UUID match (e.g., "33fe38" matches "claude-partner-33fe38b1-...")
|
|
587
|
+
const uuidMatches = agentSessions.filter((s) => {
|
|
588
|
+
const parsed = parseSessionName(s);
|
|
589
|
+
return parsed?.uuid?.startsWith(partial);
|
|
590
|
+
});
|
|
591
|
+
if (uuidMatches.length === 1) return uuidMatches[0];
|
|
592
|
+
if (uuidMatches.length > 1) {
|
|
593
|
+
console.log("ERROR: ambiguous UUID prefix. Matches:");
|
|
594
|
+
for (const m of uuidMatches) console.log(` ${m}`);
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return partial; // Return as-is, let caller handle not found
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// =============================================================================
|
|
602
|
+
// Helpers - daemon agents
|
|
603
|
+
// =============================================================================
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* @returns {DaemonConfig[]}
|
|
607
|
+
*/
|
|
608
|
+
function loadAgentConfigs() {
|
|
609
|
+
const agentsDir = AGENTS_DIR;
|
|
610
|
+
if (!existsSync(agentsDir)) return [];
|
|
611
|
+
|
|
612
|
+
const files = readdirSync(agentsDir).filter((f) => f.endsWith(".md"));
|
|
613
|
+
/** @type {DaemonConfig[]} */
|
|
614
|
+
const configs = [];
|
|
615
|
+
|
|
616
|
+
for (const file of files) {
|
|
617
|
+
try {
|
|
618
|
+
const content = readFileSync(path.join(agentsDir, file), "utf-8");
|
|
619
|
+
const config = parseAgentConfig(file, content);
|
|
620
|
+
if (config && 'error' in config) {
|
|
621
|
+
console.error(`ERROR: ${file}: ${config.error}`);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (config) configs.push(config);
|
|
625
|
+
} catch (err) {
|
|
626
|
+
console.error(`ERROR: Failed to read ${file}: ${err instanceof Error ? err.message : err}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return configs;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* @param {string} filename
|
|
635
|
+
* @param {string} content
|
|
636
|
+
* @returns {DaemonConfig | {error: string} | null}
|
|
637
|
+
*/
|
|
638
|
+
function parseAgentConfig(filename, content) {
|
|
639
|
+
const name = filename.replace(/\.md$/, "");
|
|
640
|
+
|
|
641
|
+
// Normalize line endings (handle Windows CRLF)
|
|
642
|
+
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
643
|
+
|
|
644
|
+
// Parse frontmatter
|
|
645
|
+
const frontmatterMatch = normalized.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
646
|
+
if (!frontmatterMatch) {
|
|
647
|
+
if (!normalized.startsWith("---")) {
|
|
648
|
+
return { error: `Missing frontmatter. File must start with '---'` };
|
|
649
|
+
}
|
|
650
|
+
if (!normalized.includes("\n---\n")) {
|
|
651
|
+
return { error: `Frontmatter not closed. Add '---' on its own line after the YAML block` };
|
|
652
|
+
}
|
|
653
|
+
return { error: `Invalid frontmatter format` };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const frontmatter = frontmatterMatch[1];
|
|
657
|
+
const prompt = frontmatterMatch[2].trim();
|
|
658
|
+
|
|
659
|
+
if (!prompt) {
|
|
660
|
+
return { error: `Missing prompt content after frontmatter` };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Known fields
|
|
664
|
+
const knownFields = ["tool", "interval", "watch"];
|
|
665
|
+
|
|
666
|
+
// Check for unknown fields (likely typos)
|
|
667
|
+
const fieldLines = frontmatter.split("\n").filter((line) => /^\w+:/.test(line.trim()));
|
|
668
|
+
for (const line of fieldLines) {
|
|
669
|
+
const fieldName = line.trim().match(/^(\w+):/)?.[1];
|
|
670
|
+
if (fieldName && !knownFields.includes(fieldName)) {
|
|
671
|
+
// Suggest closest match
|
|
672
|
+
const suggestions = knownFields.filter((f) => f[0] === fieldName[0] || fieldName.includes(f.slice(0, 3)));
|
|
673
|
+
const hint = suggestions.length > 0 ? ` Did you mean '${suggestions[0]}'?` : "";
|
|
674
|
+
return { error: `Unknown field '${fieldName}'.${hint} Valid fields: ${knownFields.join(", ")}` };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Parse tool
|
|
679
|
+
const toolMatch = frontmatter.match(/^tool:\s*(\S+)/m);
|
|
680
|
+
const tool = toolMatch?.[1] || "codex";
|
|
681
|
+
if (tool !== "claude" && tool !== "codex") {
|
|
682
|
+
return { error: `Invalid tool '${tool}'. Must be 'claude' or 'codex'` };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Parse interval
|
|
686
|
+
const intervalMatch = frontmatter.match(/^interval:\s*(.+)$/m);
|
|
687
|
+
let interval = 60;
|
|
688
|
+
if (intervalMatch) {
|
|
689
|
+
const rawValue = intervalMatch[1].trim();
|
|
690
|
+
const parsed = parseInt(rawValue, 10);
|
|
691
|
+
if (isNaN(parsed)) {
|
|
692
|
+
return { error: `Invalid interval '${rawValue}'. Must be a number (seconds)` };
|
|
693
|
+
}
|
|
694
|
+
interval = Math.max(10, Math.min(3600, parsed)); // Clamp to 10s - 1hr
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Parse watch patterns
|
|
698
|
+
const watchLine = frontmatter.match(/^watch:\s*(.+)$/m);
|
|
699
|
+
let watchPatterns = ["**/*"];
|
|
700
|
+
if (watchLine) {
|
|
701
|
+
const rawWatch = watchLine[1].trim();
|
|
702
|
+
// Must be array format
|
|
703
|
+
if (!rawWatch.startsWith("[") || !rawWatch.endsWith("]")) {
|
|
704
|
+
return { error: `Invalid watch format. Must be an array: watch: ["src/**/*.ts"]` };
|
|
705
|
+
}
|
|
706
|
+
const inner = rawWatch.slice(1, -1).trim();
|
|
707
|
+
if (!inner) {
|
|
708
|
+
return { error: `Empty watch array. Add at least one pattern: watch: ["**/*"]` };
|
|
709
|
+
}
|
|
710
|
+
watchPatterns = inner.split(",").map((p) => p.trim().replace(/^["']|["']$/g, ""));
|
|
711
|
+
// Validate patterns aren't empty
|
|
712
|
+
if (watchPatterns.some((p) => !p)) {
|
|
713
|
+
return { error: `Invalid watch pattern. Check for trailing commas or empty values` };
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return { name, tool, watch: watchPatterns, interval, prompt };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* @param {DaemonConfig} config
|
|
722
|
+
* @returns {string}
|
|
723
|
+
*/
|
|
724
|
+
function getDaemonSessionPattern(config) {
|
|
725
|
+
return `${config.tool}-daemon-${config.name}`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// =============================================================================
|
|
729
|
+
// Helpers - mailbox
|
|
730
|
+
// =============================================================================
|
|
731
|
+
|
|
732
|
+
const MAILBOX_PATH = path.join(AI_DIR, "mailbox.jsonl");
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* @returns {void}
|
|
736
|
+
*/
|
|
737
|
+
function ensureMailboxDir() {
|
|
738
|
+
const dir = path.dirname(MAILBOX_PATH);
|
|
739
|
+
if (!existsSync(dir)) {
|
|
740
|
+
mkdirSync(dir, { recursive: true });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* @param {MailboxPayload} payload
|
|
746
|
+
* @returns {void}
|
|
747
|
+
*/
|
|
748
|
+
function writeToMailbox(payload) {
|
|
749
|
+
ensureMailboxDir();
|
|
750
|
+
const entry = {
|
|
751
|
+
timestamp: new Date().toISOString(),
|
|
752
|
+
type: "observation",
|
|
753
|
+
payload,
|
|
754
|
+
};
|
|
755
|
+
appendFileSync(MAILBOX_PATH, JSON.stringify(entry) + "\n");
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* @param {Object} [options]
|
|
760
|
+
* @param {number} [options.maxAge]
|
|
761
|
+
* @param {string | null} [options.branch]
|
|
762
|
+
* @param {number} [options.limit]
|
|
763
|
+
* @returns {MailboxEntry[]}
|
|
764
|
+
*/
|
|
765
|
+
function readMailbox({ maxAge = MAILBOX_MAX_AGE_MS, branch = null, limit = 10 } = {}) {
|
|
766
|
+
if (!existsSync(MAILBOX_PATH)) return [];
|
|
767
|
+
|
|
768
|
+
const now = Date.now();
|
|
769
|
+
const lines = readFileSync(MAILBOX_PATH, "utf-8").trim().split("\n").filter(Boolean);
|
|
770
|
+
/** @type {MailboxEntry[]} */
|
|
771
|
+
const entries = [];
|
|
772
|
+
|
|
773
|
+
for (const line of lines) {
|
|
774
|
+
try {
|
|
775
|
+
const entry = JSON.parse(line);
|
|
776
|
+
const age = now - new Date(entry.timestamp).getTime();
|
|
777
|
+
|
|
778
|
+
// Filter by age
|
|
779
|
+
if (age > maxAge) continue;
|
|
780
|
+
|
|
781
|
+
// Filter by branch if specified
|
|
782
|
+
if (branch && entry.payload?.branch !== branch) continue;
|
|
783
|
+
|
|
784
|
+
entries.push(entry);
|
|
785
|
+
} catch (err) {
|
|
786
|
+
debugError("readMailbox", err);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Return most recent entries
|
|
791
|
+
return entries.slice(-limit);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* @param {number} [maxAgeHours]
|
|
796
|
+
* @returns {void}
|
|
797
|
+
*/
|
|
798
|
+
function gcMailbox(maxAgeHours = 24) {
|
|
799
|
+
if (!existsSync(MAILBOX_PATH)) return;
|
|
800
|
+
|
|
801
|
+
const now = Date.now();
|
|
802
|
+
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
|
|
803
|
+
const lines = readFileSync(MAILBOX_PATH, "utf-8").trim().split("\n").filter(Boolean);
|
|
804
|
+
const kept = [];
|
|
805
|
+
|
|
806
|
+
for (const line of lines) {
|
|
807
|
+
try {
|
|
808
|
+
const entry = JSON.parse(line);
|
|
809
|
+
const age = now - new Date(entry.timestamp).getTime();
|
|
810
|
+
if (age < maxAgeMs) kept.push(line);
|
|
811
|
+
} catch {
|
|
812
|
+
// Skip invalid lines
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Atomic write: write to temp file then rename
|
|
817
|
+
const tmpPath = MAILBOX_PATH + ".tmp";
|
|
818
|
+
writeFileSync(tmpPath, kept.join("\n") + (kept.length ? "\n" : ""));
|
|
819
|
+
renameSync(tmpPath, MAILBOX_PATH);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// =============================================================================
|
|
823
|
+
// Helpers - git
|
|
824
|
+
// =============================================================================
|
|
825
|
+
|
|
826
|
+
/** @returns {string} */
|
|
827
|
+
function getCurrentBranch() {
|
|
828
|
+
try {
|
|
829
|
+
return execSync("git branch --show-current 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
830
|
+
} catch {
|
|
831
|
+
return "unknown";
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** @returns {string} */
|
|
836
|
+
function getCurrentCommit() {
|
|
837
|
+
try {
|
|
838
|
+
return execSync("git rev-parse --short HEAD 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
839
|
+
} catch {
|
|
840
|
+
return "unknown";
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** @returns {string} */
|
|
845
|
+
function getMainBranch() {
|
|
846
|
+
try {
|
|
847
|
+
execSync("git rev-parse --verify main 2>/dev/null");
|
|
848
|
+
return "main";
|
|
849
|
+
} catch {
|
|
850
|
+
try {
|
|
851
|
+
execSync("git rev-parse --verify master 2>/dev/null");
|
|
852
|
+
return "master";
|
|
853
|
+
} catch {
|
|
854
|
+
return "main";
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** @returns {string} */
|
|
860
|
+
function getStagedDiff() {
|
|
861
|
+
try {
|
|
862
|
+
return execSync("git diff --cached 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
863
|
+
} catch {
|
|
864
|
+
return "";
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** @returns {string} */
|
|
869
|
+
function getUncommittedDiff() {
|
|
870
|
+
try {
|
|
871
|
+
return execSync("git diff 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
872
|
+
} catch {
|
|
873
|
+
return "";
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* @param {number} [hoursAgo]
|
|
879
|
+
* @returns {string}
|
|
880
|
+
*/
|
|
881
|
+
function getRecentCommitsDiff(hoursAgo = 4) {
|
|
882
|
+
try {
|
|
883
|
+
const mainBranch = getMainBranch();
|
|
884
|
+
const since = `--since="${hoursAgo} hours ago"`;
|
|
885
|
+
|
|
886
|
+
// Get list of commits in range
|
|
887
|
+
const commits = execSync(
|
|
888
|
+
`git log ${mainBranch}..HEAD ${since} --oneline 2>/dev/null`,
|
|
889
|
+
{ encoding: "utf-8" }
|
|
890
|
+
).trim();
|
|
891
|
+
|
|
892
|
+
if (!commits) return "";
|
|
893
|
+
|
|
894
|
+
// Get diff for those commits
|
|
895
|
+
const firstCommit = commits.split("\n").filter(Boolean).pop()?.split(" ")[0];
|
|
896
|
+
if (!firstCommit) return "";
|
|
897
|
+
return execSync(
|
|
898
|
+
`git diff ${firstCommit}^..HEAD 2>/dev/null`,
|
|
899
|
+
{ encoding: "utf-8" }
|
|
900
|
+
).trim();
|
|
901
|
+
} catch {
|
|
902
|
+
return "";
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* @param {string} diff
|
|
908
|
+
* @param {number} [maxLines]
|
|
909
|
+
* @returns {string}
|
|
910
|
+
*/
|
|
911
|
+
function truncateDiff(diff, maxLines = 200) {
|
|
912
|
+
if (!diff) return "";
|
|
913
|
+
const lines = diff.split("\n");
|
|
914
|
+
if (lines.length <= maxLines) return diff;
|
|
915
|
+
return lines.slice(0, maxLines).join("\n") + `\n\n... (truncated, ${lines.length - maxLines} more lines)`;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* @param {number} [hoursAgo]
|
|
920
|
+
* @param {number} [maxLinesPerSection]
|
|
921
|
+
* @returns {string}
|
|
922
|
+
*/
|
|
923
|
+
function buildGitContext(hoursAgo = 4, maxLinesPerSection = 200) {
|
|
924
|
+
const sections = [];
|
|
925
|
+
|
|
926
|
+
const staged = truncateDiff(getStagedDiff(), maxLinesPerSection);
|
|
927
|
+
if (staged) {
|
|
928
|
+
sections.push("## Staged Changes (about to be committed)\n```diff\n" + staged + "\n```");
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const uncommitted = truncateDiff(getUncommittedDiff(), maxLinesPerSection);
|
|
932
|
+
if (uncommitted) {
|
|
933
|
+
sections.push("## Uncommitted Changes (work in progress)\n```diff\n" + uncommitted + "\n```");
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const recent = truncateDiff(getRecentCommitsDiff(hoursAgo), maxLinesPerSection);
|
|
937
|
+
if (recent) {
|
|
938
|
+
sections.push(`## Recent Commits (last ${hoursAgo} hours)\n\`\`\`diff\n` + recent + "\n```");
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return sections.join("\n\n");
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// =============================================================================
|
|
945
|
+
// Helpers - parent session context
|
|
946
|
+
// =============================================================================
|
|
947
|
+
|
|
948
|
+
// Environment variables used to pass parent session info to daemons
|
|
949
|
+
const AX_DAEMON_PARENT_SESSION_ENV = "AX_DAEMON_PARENT_SESSION";
|
|
950
|
+
const AX_DAEMON_PARENT_UUID_ENV = "AX_DAEMON_PARENT_UUID";
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* @returns {ParentSession | null}
|
|
954
|
+
*/
|
|
955
|
+
function findCurrentClaudeSession() {
|
|
956
|
+
// If we're inside a tmux session, check if it's a Claude session
|
|
957
|
+
const current = tmuxCurrentSession();
|
|
958
|
+
if (current) {
|
|
959
|
+
const parsed = parseSessionName(current);
|
|
960
|
+
if (parsed?.tool === "claude" && !parsed.daemonName && parsed.uuid) {
|
|
961
|
+
return { session: current, uuid: parsed.uuid };
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// We might be running from Claude but not inside tmux (e.g., VSCode, Cursor)
|
|
966
|
+
// Find Claude sessions in the same cwd and pick the most recently active one
|
|
967
|
+
const callerPid = findCallerPid();
|
|
968
|
+
if (!callerPid) return null; // Not running from Claude
|
|
969
|
+
|
|
970
|
+
const cwd = process.cwd();
|
|
971
|
+
const sessions = tmuxListSessions();
|
|
972
|
+
const candidates = [];
|
|
973
|
+
|
|
974
|
+
for (const session of sessions) {
|
|
975
|
+
const parsed = parseSessionName(session);
|
|
976
|
+
if (!parsed || parsed.tool !== "claude") continue;
|
|
977
|
+
if (parsed.daemonName) continue;
|
|
978
|
+
if (!parsed.uuid) continue;
|
|
979
|
+
|
|
980
|
+
const sessionCwd = getTmuxSessionCwd(session);
|
|
981
|
+
if (sessionCwd !== cwd) continue;
|
|
982
|
+
|
|
983
|
+
// Check log file modification time
|
|
984
|
+
const logPath = findClaudeLogPath(parsed.uuid, session);
|
|
985
|
+
if (logPath && existsSync(logPath)) {
|
|
986
|
+
try {
|
|
987
|
+
const stat = statSync(logPath);
|
|
988
|
+
candidates.push({ session, uuid: parsed.uuid, mtime: stat.mtimeMs });
|
|
989
|
+
} catch (err) {
|
|
990
|
+
debugError("findCurrentClaudeSession:stat", err);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Also check non-tmux Claude sessions by scanning the project's log directory
|
|
996
|
+
const projectPath = getClaudeProjectPath(cwd);
|
|
997
|
+
const claudeProjectDir = path.join(CLAUDE_CONFIG_DIR, "projects", projectPath);
|
|
998
|
+
if (existsSync(claudeProjectDir)) {
|
|
999
|
+
try {
|
|
1000
|
+
const files = readdirSync(claudeProjectDir).filter(f => f.endsWith(".jsonl"));
|
|
1001
|
+
for (const file of files) {
|
|
1002
|
+
const uuid = file.replace(".jsonl", "");
|
|
1003
|
+
// Skip if we already have this from tmux sessions
|
|
1004
|
+
if (candidates.some(c => c.uuid === uuid)) continue;
|
|
1005
|
+
|
|
1006
|
+
const logPath = path.join(claudeProjectDir, file);
|
|
1007
|
+
try {
|
|
1008
|
+
const stat = statSync(logPath);
|
|
1009
|
+
// Only consider logs modified in the last hour (active sessions)
|
|
1010
|
+
if (Date.now() - stat.mtimeMs < MAILBOX_MAX_AGE_MS) {
|
|
1011
|
+
candidates.push({ session: null, uuid, mtime: stat.mtimeMs, logPath });
|
|
1012
|
+
}
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
debugError("findCurrentClaudeSession:logStat", err);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
debugError("findCurrentClaudeSession:readdir", err);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (candidates.length === 0) return null;
|
|
1023
|
+
|
|
1024
|
+
// Return the most recently active session
|
|
1025
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
1026
|
+
return { session: candidates[0].session, uuid: candidates[0].uuid };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* @returns {ParentSession | null}
|
|
1031
|
+
*/
|
|
1032
|
+
function findParentSession() {
|
|
1033
|
+
// First check if parent session was passed via environment (for daemons)
|
|
1034
|
+
const envUuid = process.env[AX_DAEMON_PARENT_UUID_ENV];
|
|
1035
|
+
if (envUuid) {
|
|
1036
|
+
// Session name is optional (may be null for non-tmux sessions)
|
|
1037
|
+
const envSession = process.env[AX_DAEMON_PARENT_SESSION_ENV] || null;
|
|
1038
|
+
return { session: envSession, uuid: envUuid };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Fallback to detecting current session (shouldn't be needed for daemons)
|
|
1042
|
+
return findCurrentClaudeSession();
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* @param {number} [maxEntries]
|
|
1047
|
+
* @returns {string}
|
|
1048
|
+
*/
|
|
1049
|
+
function getParentSessionContext(maxEntries = 20) {
|
|
1050
|
+
const parent = findParentSession();
|
|
1051
|
+
if (!parent) return "";
|
|
1052
|
+
|
|
1053
|
+
const logPath = findClaudeLogPath(parent.uuid, parent.session);
|
|
1054
|
+
if (!logPath || !existsSync(logPath)) return "";
|
|
1055
|
+
|
|
1056
|
+
try {
|
|
1057
|
+
const content = readFileSync(logPath, "utf-8");
|
|
1058
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
1059
|
+
|
|
1060
|
+
// Go back further to find meaningful entries (not just tool uses)
|
|
1061
|
+
const recent = lines.slice(-maxEntries * 10);
|
|
1062
|
+
/** @type {{type: string, text: string}[]} */
|
|
1063
|
+
const entries = [];
|
|
1064
|
+
/** @type {string | null} */
|
|
1065
|
+
let planPath = null;
|
|
1066
|
+
|
|
1067
|
+
for (const line of recent) {
|
|
1068
|
+
try {
|
|
1069
|
+
const entry = JSON.parse(line);
|
|
1070
|
+
|
|
1071
|
+
// Look for plan file path in the log content
|
|
1072
|
+
if (!planPath) {
|
|
1073
|
+
const planMatch = line.match(/\/Users\/[^"]+\/\.claude\/plans\/[^"]+\.md/);
|
|
1074
|
+
if (planMatch) planPath = planMatch[0];
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (entry.type === "user") {
|
|
1078
|
+
const c = entry.message?.content;
|
|
1079
|
+
// Only include user messages with actual text (not just tool results)
|
|
1080
|
+
if (typeof c === "string" && c.length > 10) {
|
|
1081
|
+
entries.push({ type: "user", text: c });
|
|
1082
|
+
} else if (Array.isArray(c)) {
|
|
1083
|
+
const text = c.find(/** @param {{type: string, text?: string}} x */ (x) => x.type === "text")?.text;
|
|
1084
|
+
if (text && text.length > 10) {
|
|
1085
|
+
entries.push({ type: "user", text });
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
} else if (entry.type === "assistant") {
|
|
1089
|
+
/** @type {{type: string, text?: string}[]} */
|
|
1090
|
+
const parts = entry.message?.content || [];
|
|
1091
|
+
const text = parts.filter((p) => p.type === "text").map((p) => p.text || "").join("\n");
|
|
1092
|
+
// Only include assistant responses with meaningful text
|
|
1093
|
+
if (text && text.length > 20) {
|
|
1094
|
+
entries.push({ type: "assistant", text });
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
debugError("getParentSessionContext:parseLine", err);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Format recent conversation
|
|
1103
|
+
const formatted = entries.slice(-maxEntries).map(e => {
|
|
1104
|
+
const preview = e.text.slice(0, 500).replace(/\n/g, " ");
|
|
1105
|
+
return `**${e.type === "user" ? "User" : "Assistant"}**: ${preview}`;
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
let result = formatted.join("\n\n");
|
|
1109
|
+
|
|
1110
|
+
// If we found a plan file, include its contents
|
|
1111
|
+
if (planPath && existsSync(planPath)) {
|
|
1112
|
+
try {
|
|
1113
|
+
const planContent = readFileSync(planPath, "utf-8").trim();
|
|
1114
|
+
if (planContent) {
|
|
1115
|
+
result += "\n\n## Current Plan\n\n" + planContent.slice(0, 2000);
|
|
1116
|
+
}
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
debugError("getParentSessionContext:readPlan", err);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
return result;
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
debugError("getParentSessionContext", err);
|
|
1125
|
+
return "";
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// =============================================================================
|
|
1130
|
+
// JSONL extraction for intent matching
|
|
1131
|
+
// =============================================================================
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* @param {string | null} logPath
|
|
1135
|
+
* @param {string} filePath
|
|
1136
|
+
* @returns {FileEditContext | null}
|
|
1137
|
+
*/
|
|
1138
|
+
function extractFileEditContext(logPath, filePath) {
|
|
1139
|
+
if (!logPath || !existsSync(logPath)) return null;
|
|
1140
|
+
|
|
1141
|
+
const content = readFileSync(logPath, "utf-8");
|
|
1142
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
1143
|
+
|
|
1144
|
+
// Parse all entries
|
|
1145
|
+
/** @type {any[]} */
|
|
1146
|
+
const entries = lines.map((line, idx) => {
|
|
1147
|
+
try { return { idx, ...JSON.parse(line) }; }
|
|
1148
|
+
catch (err) { debugError("extractFileEditContext:parse", err); return null; }
|
|
1149
|
+
}).filter(Boolean);
|
|
1150
|
+
|
|
1151
|
+
// Find Write/Edit tool calls for this file (scan backwards, want most recent)
|
|
1152
|
+
/** @type {any} */
|
|
1153
|
+
let editEntry = null;
|
|
1154
|
+
let editIdx = -1;
|
|
1155
|
+
|
|
1156
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1157
|
+
const entry = entries[i];
|
|
1158
|
+
if (entry.type !== "assistant") continue;
|
|
1159
|
+
|
|
1160
|
+
/** @type {any[]} */
|
|
1161
|
+
const msgContent = entry.message?.content || [];
|
|
1162
|
+
const toolCalls = msgContent.filter((/** @type {any} */ c) =>
|
|
1163
|
+
(c.type === "tool_use" || c.type === "tool_call") &&
|
|
1164
|
+
(c.name === "Write" || c.name === "Edit")
|
|
1165
|
+
);
|
|
1166
|
+
|
|
1167
|
+
for (const tc of toolCalls) {
|
|
1168
|
+
const input = tc.input || tc.arguments || {};
|
|
1169
|
+
if (input.file_path === filePath || input.file_path?.endsWith("/" + filePath)) {
|
|
1170
|
+
editEntry = { entry, toolCall: tc, content: msgContent };
|
|
1171
|
+
editIdx = i;
|
|
1172
|
+
break;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (editEntry) break;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (!editEntry) return null;
|
|
1179
|
+
|
|
1180
|
+
// Extract intent: text blocks from same assistant message
|
|
1181
|
+
const intent = editEntry.content
|
|
1182
|
+
.filter((/** @type {any} */ c) => c.type === "text")
|
|
1183
|
+
.map((/** @type {any} */ c) => c.text)
|
|
1184
|
+
.join("\n")
|
|
1185
|
+
.trim();
|
|
1186
|
+
|
|
1187
|
+
// Look forward for Bash errors
|
|
1188
|
+
/** @type {string[]} */
|
|
1189
|
+
const subsequentErrors = [];
|
|
1190
|
+
for (let i = editIdx + 1; i < entries.length && i < editIdx + 10; i++) {
|
|
1191
|
+
const entry = entries[i];
|
|
1192
|
+
// Check user messages for tool_result with errors
|
|
1193
|
+
if (entry.type === "user") {
|
|
1194
|
+
/** @type {any[]} */
|
|
1195
|
+
const msgContent = entry.message?.content || [];
|
|
1196
|
+
if (Array.isArray(msgContent)) {
|
|
1197
|
+
for (const c of msgContent) {
|
|
1198
|
+
if (c.type === "tool_result" && c.is_error) {
|
|
1199
|
+
subsequentErrors.push(c.content?.slice(0, 500) || "error");
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Look backward for Read calls (what context did agent have?)
|
|
1207
|
+
/** @type {string[]} */
|
|
1208
|
+
const readsBefore = [];
|
|
1209
|
+
for (let i = editIdx - 1; i >= 0 && i > editIdx - 20; i--) {
|
|
1210
|
+
const entry = entries[i];
|
|
1211
|
+
if (entry.type !== "assistant") continue;
|
|
1212
|
+
|
|
1213
|
+
/** @type {any[]} */
|
|
1214
|
+
const msgContent = entry.message?.content || [];
|
|
1215
|
+
const readCalls = msgContent.filter((/** @type {any} */ c) =>
|
|
1216
|
+
(c.type === "tool_use" || c.type === "tool_call") && c.name === "Read"
|
|
1217
|
+
);
|
|
1218
|
+
|
|
1219
|
+
for (const rc of readCalls) {
|
|
1220
|
+
const input = rc.input || rc.arguments || {};
|
|
1221
|
+
if (input.file_path) readsBefore.push(input.file_path);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Count edit sequence (how many times was this file edited?)
|
|
1226
|
+
let editSequence = 0;
|
|
1227
|
+
for (const entry of entries) {
|
|
1228
|
+
if (entry.type !== "assistant") continue;
|
|
1229
|
+
/** @type {any[]} */
|
|
1230
|
+
const msgContent = entry.message?.content || [];
|
|
1231
|
+
const edits = msgContent.filter((/** @type {any} */ c) =>
|
|
1232
|
+
(c.type === "tool_use" || c.type === "tool_call") &&
|
|
1233
|
+
(c.name === "Write" || c.name === "Edit")
|
|
1234
|
+
);
|
|
1235
|
+
for (const e of edits) {
|
|
1236
|
+
const input = e.input || e.arguments || {};
|
|
1237
|
+
if (input.file_path === filePath || input.file_path?.endsWith("/" + filePath)) {
|
|
1238
|
+
editSequence++;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
return {
|
|
1244
|
+
intent,
|
|
1245
|
+
toolCall: {
|
|
1246
|
+
name: editEntry.toolCall.name,
|
|
1247
|
+
input: editEntry.toolCall.input || editEntry.toolCall.arguments,
|
|
1248
|
+
id: editEntry.toolCall.id
|
|
1249
|
+
},
|
|
1250
|
+
subsequentErrors,
|
|
1251
|
+
readsBefore: [...new Set(readsBefore)].slice(0, 10),
|
|
1252
|
+
editSequence
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// =============================================================================
|
|
1257
|
+
// Helpers - file watching
|
|
1258
|
+
// =============================================================================
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* @param {string} pattern
|
|
1262
|
+
* @returns {string}
|
|
1263
|
+
*/
|
|
1264
|
+
function getBaseDir(pattern) {
|
|
1265
|
+
// Extract base directory from glob pattern
|
|
1266
|
+
// e.g., "src/**/*.ts" -> "src"
|
|
1267
|
+
// e.g., "**/*.ts" -> "."
|
|
1268
|
+
const parts = pattern.split("/");
|
|
1269
|
+
/** @type {string[]} */
|
|
1270
|
+
const baseParts = [];
|
|
1271
|
+
for (const part of parts) {
|
|
1272
|
+
if (part.includes("*") || part.includes("?") || part.includes("[")) break;
|
|
1273
|
+
baseParts.push(part);
|
|
1274
|
+
}
|
|
1275
|
+
return baseParts.length > 0 ? baseParts.join("/") : ".";
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* @param {string} filename
|
|
1280
|
+
* @param {string} pattern
|
|
1281
|
+
* @returns {boolean}
|
|
1282
|
+
*/
|
|
1283
|
+
function matchesPattern(filename, pattern) {
|
|
1284
|
+
return path.matchesGlob(filename, pattern);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Default exclusions - always applied
|
|
1288
|
+
const DEFAULT_EXCLUDE_PATTERNS = [
|
|
1289
|
+
"**/node_modules/**",
|
|
1290
|
+
"**/.git/**",
|
|
1291
|
+
"**/dist/**",
|
|
1292
|
+
"**/build/**",
|
|
1293
|
+
"**/.next/**",
|
|
1294
|
+
"**/coverage/**",
|
|
1295
|
+
];
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* @param {string[]} patterns
|
|
1299
|
+
* @param {(filePath: string) => void} callback
|
|
1300
|
+
* @returns {() => void}
|
|
1301
|
+
*/
|
|
1302
|
+
function watchForChanges(patterns, callback) {
|
|
1303
|
+
// Separate include and exclude patterns
|
|
1304
|
+
const includePatterns = patterns.filter((p) => !p.startsWith("!"));
|
|
1305
|
+
const userExcludePatterns = patterns.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
|
|
1306
|
+
const excludePatterns = [...DEFAULT_EXCLUDE_PATTERNS, ...userExcludePatterns];
|
|
1307
|
+
|
|
1308
|
+
/** @type {import('node:fs').FSWatcher[]} */
|
|
1309
|
+
const watchers = [];
|
|
1310
|
+
/** @type {Set<string>} */
|
|
1311
|
+
const watchedDirs = new Set();
|
|
1312
|
+
|
|
1313
|
+
for (const pattern of includePatterns) {
|
|
1314
|
+
const dir = getBaseDir(pattern);
|
|
1315
|
+
if (watchedDirs.has(dir)) continue;
|
|
1316
|
+
if (!existsSync(dir)) continue;
|
|
1317
|
+
|
|
1318
|
+
watchedDirs.add(dir);
|
|
1319
|
+
|
|
1320
|
+
try {
|
|
1321
|
+
const watcher = watch(dir, { recursive: true }, (_eventType, filename) => {
|
|
1322
|
+
if (!filename) return;
|
|
1323
|
+
const fullPath = path.join(dir, filename);
|
|
1324
|
+
|
|
1325
|
+
// Check exclusions first
|
|
1326
|
+
for (const ex of excludePatterns) {
|
|
1327
|
+
if (matchesPattern(fullPath, ex) || matchesPattern(filename, ex)) {
|
|
1328
|
+
return; // Excluded
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// Check if this file matches any include pattern
|
|
1333
|
+
for (const p of includePatterns) {
|
|
1334
|
+
if (matchesPattern(fullPath, p) || matchesPattern(filename, p)) {
|
|
1335
|
+
callback(fullPath);
|
|
1336
|
+
break;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
watchers.push(watcher);
|
|
1341
|
+
} catch (err) {
|
|
1342
|
+
console.error(`Warning: Failed to watch ${dir}: ${err instanceof Error ? err.message : err}`);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
return () => { for (const w of watchers) w.close(); };
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
// =============================================================================
|
|
1351
|
+
// State
|
|
1352
|
+
// =============================================================================
|
|
1353
|
+
|
|
1354
|
+
const State = {
|
|
1355
|
+
NO_SESSION: "no_session",
|
|
1356
|
+
STARTING: "starting",
|
|
1357
|
+
UPDATE_PROMPT: "update_prompt",
|
|
1358
|
+
READY: "ready",
|
|
1359
|
+
THINKING: "thinking",
|
|
1360
|
+
CONFIRMING: "confirming",
|
|
1361
|
+
RATE_LIMITED: "rate_limited",
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Pure function to detect agent state from screen content.
|
|
1366
|
+
* @param {string} screen - The screen content to analyze
|
|
1367
|
+
* @param {Object} config - Agent configuration for pattern matching
|
|
1368
|
+
* @param {string} config.promptSymbol - Symbol indicating ready state
|
|
1369
|
+
* @param {string[]} [config.spinners] - Spinner characters indicating thinking
|
|
1370
|
+
* @param {RegExp} [config.rateLimitPattern] - Pattern for rate limit detection
|
|
1371
|
+
* @param {string[]} [config.thinkingPatterns] - Text patterns indicating thinking
|
|
1372
|
+
* @param {(string | ((lines: string) => boolean))[]} [config.confirmPatterns] - Patterns for confirmation dialogs
|
|
1373
|
+
* @param {{screen: string[], lastLines: string[]} | null} [config.updatePromptPatterns] - Patterns for update prompts
|
|
1374
|
+
* @returns {string} The detected state
|
|
1375
|
+
*/
|
|
1376
|
+
function detectState(screen, config) {
|
|
1377
|
+
if (!screen) return State.STARTING;
|
|
1378
|
+
|
|
1379
|
+
const lines = screen.trim().split("\n");
|
|
1380
|
+
const lastLines = lines.slice(-8).join("\n");
|
|
1381
|
+
// Larger range for confirmation detection (catches dialogs that scrolled slightly)
|
|
1382
|
+
const recentLines = lines.slice(-15).join("\n");
|
|
1383
|
+
|
|
1384
|
+
// Rate limited - check full screen (rate limit messages can appear anywhere)
|
|
1385
|
+
if (config.rateLimitPattern && config.rateLimitPattern.test(screen)) {
|
|
1386
|
+
return State.RATE_LIMITED;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Thinking - spinners (full screen, they're unique UI elements)
|
|
1390
|
+
const spinners = config.spinners || [];
|
|
1391
|
+
if (spinners.some((s) => screen.includes(s))) {
|
|
1392
|
+
return State.THINKING;
|
|
1393
|
+
}
|
|
1394
|
+
// Thinking - text patterns (last lines)
|
|
1395
|
+
const thinkingPatterns = config.thinkingPatterns || [];
|
|
1396
|
+
if (thinkingPatterns.some((p) => lastLines.includes(p))) {
|
|
1397
|
+
return State.THINKING;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Update prompt
|
|
1401
|
+
if (config.updatePromptPatterns) {
|
|
1402
|
+
const { screen: sp, lastLines: lp } = config.updatePromptPatterns;
|
|
1403
|
+
if (sp && sp.some((p) => screen.includes(p)) && lp && lp.some((p) => lastLines.includes(p))) {
|
|
1404
|
+
return State.UPDATE_PROMPT;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Confirming - check recent lines (not full screen to avoid history false positives)
|
|
1409
|
+
const confirmPatterns = config.confirmPatterns || [];
|
|
1410
|
+
for (const pattern of confirmPatterns) {
|
|
1411
|
+
if (typeof pattern === "function") {
|
|
1412
|
+
// Functions check lastLines first (most specific), then recentLines
|
|
1413
|
+
if (pattern(lastLines)) return State.CONFIRMING;
|
|
1414
|
+
if (pattern(recentLines)) return State.CONFIRMING;
|
|
1415
|
+
} else {
|
|
1416
|
+
// String patterns check recentLines (bounded range)
|
|
1417
|
+
if (recentLines.includes(pattern)) return State.CONFIRMING;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// Ready - only if prompt symbol is visible AND not followed by pasted content
|
|
1422
|
+
// "[Pasted text" indicates user has pasted content and Claude is still processing
|
|
1423
|
+
if (lastLines.includes(config.promptSymbol)) {
|
|
1424
|
+
// Check if any line has the prompt followed by pasted content indicator
|
|
1425
|
+
const linesArray = lastLines.split("\n");
|
|
1426
|
+
const promptWithPaste = linesArray.some(
|
|
1427
|
+
(l) => l.includes(config.promptSymbol) && l.includes("[Pasted text")
|
|
1428
|
+
);
|
|
1429
|
+
if (!promptWithPaste) {
|
|
1430
|
+
return State.READY;
|
|
1431
|
+
}
|
|
1432
|
+
// If prompt has pasted content, Claude is still processing - not ready yet
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
return State.STARTING;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// =============================================================================
|
|
1439
|
+
// Agent base class
|
|
1440
|
+
// =============================================================================
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* @typedef {string | ((lines: string) => boolean)} ConfirmPattern
|
|
1444
|
+
*/
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* @typedef {Object} UpdatePromptPatterns
|
|
1448
|
+
* @property {string[]} screen
|
|
1449
|
+
* @property {string[]} lastLines
|
|
1450
|
+
*/
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* @typedef {Object} AgentConfigInput
|
|
1454
|
+
* @property {string} name
|
|
1455
|
+
* @property {string} startCommand
|
|
1456
|
+
* @property {string} yoloCommand
|
|
1457
|
+
* @property {string} promptSymbol
|
|
1458
|
+
* @property {string[]} [spinners]
|
|
1459
|
+
* @property {RegExp} [rateLimitPattern]
|
|
1460
|
+
* @property {string[]} [thinkingPatterns]
|
|
1461
|
+
* @property {ConfirmPattern[]} [confirmPatterns]
|
|
1462
|
+
* @property {UpdatePromptPatterns | null} [updatePromptPatterns]
|
|
1463
|
+
* @property {string[]} [responseMarkers]
|
|
1464
|
+
* @property {string[]} [chromePatterns]
|
|
1465
|
+
* @property {Record<string, string> | null} [reviewOptions]
|
|
1466
|
+
* @property {string} envVar
|
|
1467
|
+
* @property {string} [approveKey]
|
|
1468
|
+
* @property {string} [rejectKey]
|
|
1469
|
+
* @property {string} [safeAllowedTools]
|
|
1470
|
+
*/
|
|
1471
|
+
|
|
1472
|
+
class Agent {
|
|
1473
|
+
/**
|
|
1474
|
+
* @param {AgentConfigInput} config
|
|
1475
|
+
*/
|
|
1476
|
+
constructor(config) {
|
|
1477
|
+
/** @type {string} */
|
|
1478
|
+
this.name = config.name;
|
|
1479
|
+
/** @type {string} */
|
|
1480
|
+
this.startCommand = config.startCommand;
|
|
1481
|
+
/** @type {string} */
|
|
1482
|
+
this.yoloCommand = config.yoloCommand;
|
|
1483
|
+
/** @type {string} */
|
|
1484
|
+
this.promptSymbol = config.promptSymbol;
|
|
1485
|
+
/** @type {string[]} */
|
|
1486
|
+
this.spinners = config.spinners || [];
|
|
1487
|
+
/** @type {RegExp | undefined} */
|
|
1488
|
+
this.rateLimitPattern = config.rateLimitPattern;
|
|
1489
|
+
/** @type {string[]} */
|
|
1490
|
+
this.thinkingPatterns = config.thinkingPatterns || [];
|
|
1491
|
+
/** @type {ConfirmPattern[]} */
|
|
1492
|
+
this.confirmPatterns = config.confirmPatterns || [];
|
|
1493
|
+
/** @type {UpdatePromptPatterns | null} */
|
|
1494
|
+
this.updatePromptPatterns = config.updatePromptPatterns || null;
|
|
1495
|
+
/** @type {string[]} */
|
|
1496
|
+
this.responseMarkers = config.responseMarkers || [];
|
|
1497
|
+
/** @type {string[]} */
|
|
1498
|
+
this.chromePatterns = config.chromePatterns || [];
|
|
1499
|
+
/** @type {Record<string, string> | null | undefined} */
|
|
1500
|
+
this.reviewOptions = config.reviewOptions ?? null;
|
|
1501
|
+
/** @type {string} */
|
|
1502
|
+
this.envVar = config.envVar;
|
|
1503
|
+
/** @type {string} */
|
|
1504
|
+
this.approveKey = config.approveKey || "y";
|
|
1505
|
+
/** @type {string} */
|
|
1506
|
+
this.rejectKey = config.rejectKey || "n";
|
|
1507
|
+
/** @type {string | undefined} */
|
|
1508
|
+
this.safeAllowedTools = config.safeAllowedTools;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* @param {boolean} [yolo]
|
|
1513
|
+
* @param {string | null} [sessionName]
|
|
1514
|
+
* @returns {string}
|
|
1515
|
+
*/
|
|
1516
|
+
getCommand(yolo, sessionName = null) {
|
|
1517
|
+
let base;
|
|
1518
|
+
if (yolo) {
|
|
1519
|
+
base = this.yoloCommand;
|
|
1520
|
+
} else if (this.safeAllowedTools) {
|
|
1521
|
+
// Default: auto-approve safe read-only operations
|
|
1522
|
+
base = `${this.startCommand} --allowedTools "${this.safeAllowedTools}"`;
|
|
1523
|
+
} else {
|
|
1524
|
+
base = this.startCommand;
|
|
1525
|
+
}
|
|
1526
|
+
// Claude supports --session-id for deterministic session tracking
|
|
1527
|
+
if (this.name === "claude" && sessionName) {
|
|
1528
|
+
const parsed = parseSessionName(sessionName);
|
|
1529
|
+
if (parsed?.uuid) {
|
|
1530
|
+
return `${base} --session-id ${parsed.uuid}`;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return base;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
getDefaultSession() {
|
|
1537
|
+
// Check env var for explicit session
|
|
1538
|
+
if (this.envVar && process.env[this.envVar]) {
|
|
1539
|
+
return process.env[this.envVar];
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const cwd = process.cwd();
|
|
1543
|
+
const childPattern = new RegExp(`^${this.name}-[0-9a-f-]{36}$`, "i");
|
|
1544
|
+
|
|
1545
|
+
// If inside tmux, look for existing agent session in same cwd
|
|
1546
|
+
const current = tmuxCurrentSession();
|
|
1547
|
+
if (current) {
|
|
1548
|
+
const sessions = tmuxListSessions();
|
|
1549
|
+
const existing = sessions.find((s) => {
|
|
1550
|
+
if (!childPattern.test(s)) return false;
|
|
1551
|
+
const sessionCwd = getTmuxSessionCwd(s);
|
|
1552
|
+
return sessionCwd === cwd;
|
|
1553
|
+
});
|
|
1554
|
+
if (existing) return existing;
|
|
1555
|
+
// No existing session in this cwd - will generate new one in cmdStart
|
|
1556
|
+
return null;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Walk up to find claude/codex ancestor and reuse its session (must match cwd)
|
|
1560
|
+
const callerPid = findCallerPid();
|
|
1561
|
+
if (callerPid) {
|
|
1562
|
+
const sessions = tmuxListSessions();
|
|
1563
|
+
const existing = sessions.find((s) => {
|
|
1564
|
+
if (!childPattern.test(s)) return false;
|
|
1565
|
+
const sessionCwd = getTmuxSessionCwd(s);
|
|
1566
|
+
return sessionCwd === cwd;
|
|
1567
|
+
});
|
|
1568
|
+
if (existing) return existing;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// No existing session found
|
|
1572
|
+
return null;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* @returns {string}
|
|
1577
|
+
*/
|
|
1578
|
+
generateSession() {
|
|
1579
|
+
return generateSessionName(this.name);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Find the log file path for a session.
|
|
1584
|
+
* @param {string} sessionName
|
|
1585
|
+
* @returns {string | null}
|
|
1586
|
+
*/
|
|
1587
|
+
findLogPath(sessionName) {
|
|
1588
|
+
const parsed = parseSessionName(sessionName);
|
|
1589
|
+
if (this.name === "claude") {
|
|
1590
|
+
const uuid = parsed?.uuid;
|
|
1591
|
+
if (uuid) return findClaudeLogPath(uuid, sessionName);
|
|
1592
|
+
}
|
|
1593
|
+
if (this.name === "codex") {
|
|
1594
|
+
return findCodexLogPath(sessionName);
|
|
1595
|
+
}
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* @param {string} screen
|
|
1601
|
+
* @returns {string}
|
|
1602
|
+
*/
|
|
1603
|
+
getState(screen) {
|
|
1604
|
+
return detectState(screen, {
|
|
1605
|
+
promptSymbol: this.promptSymbol,
|
|
1606
|
+
spinners: this.spinners,
|
|
1607
|
+
rateLimitPattern: this.rateLimitPattern,
|
|
1608
|
+
thinkingPatterns: this.thinkingPatterns,
|
|
1609
|
+
confirmPatterns: this.confirmPatterns,
|
|
1610
|
+
updatePromptPatterns: this.updatePromptPatterns,
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* @param {string} screen
|
|
1616
|
+
* @returns {string}
|
|
1617
|
+
*/
|
|
1618
|
+
parseRetryTime(screen) {
|
|
1619
|
+
const match = screen.match(/try again at ([0-9]{1,2}:[0-9]{2}\s*[AP]M)/i);
|
|
1620
|
+
return match ? match[1] : "unknown";
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* @param {string} screen
|
|
1625
|
+
* @returns {string}
|
|
1626
|
+
*/
|
|
1627
|
+
parseAction(screen) {
|
|
1628
|
+
/** @param {string} s */
|
|
1629
|
+
// eslint-disable-next-line no-control-regex
|
|
1630
|
+
const clean = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").trim();
|
|
1631
|
+
const lines = screen.split("\n").map(clean);
|
|
1632
|
+
|
|
1633
|
+
for (const line of lines) {
|
|
1634
|
+
if (line.startsWith("$") || line.startsWith(">")) return line;
|
|
1635
|
+
if (/^(run|execute|create|delete|modify|write)/i.test(line)) return line;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
return lines.filter((l) => l && !l.match(/^[╭╮╰╯│─]+$/)).slice(0, 2).join(" | ") || "action";
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* @param {string} line
|
|
1643
|
+
* @returns {boolean}
|
|
1644
|
+
*/
|
|
1645
|
+
isChromeLine(line) {
|
|
1646
|
+
const trimmed = line.trim();
|
|
1647
|
+
if (!trimmed) return true;
|
|
1648
|
+
// Box drawing characters only
|
|
1649
|
+
if (/^[╭╮╰╯│─┌┐└┘├┤┬┴┼\s]+$/.test(line)) return true;
|
|
1650
|
+
// Horizontal separators
|
|
1651
|
+
if (/^─{3,}$/.test(trimmed)) return true;
|
|
1652
|
+
// Status bar indicators (shortcuts help, connection status)
|
|
1653
|
+
if (/^\s*[?⧉◯●]\s/.test(line)) return true;
|
|
1654
|
+
// Logo/branding characters (block drawing)
|
|
1655
|
+
if (/[▐▛▜▌▝▘█▀▄]/.test(trimmed) && trimmed.length < 50) return true;
|
|
1656
|
+
// Version strings, model info
|
|
1657
|
+
if (/^(Claude Code|OpenAI Codex|Opus|gpt-|model:|directory:|cwd:)/i.test(trimmed)) return true;
|
|
1658
|
+
// Path-only lines (working directory display)
|
|
1659
|
+
if (/^~\/[^\s]*$/.test(trimmed)) return true;
|
|
1660
|
+
// Explicit chrome patterns from agent config
|
|
1661
|
+
if (this.chromePatterns.some((p) => trimmed.includes(p))) return true;
|
|
1662
|
+
return false;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* @param {string} screen
|
|
1667
|
+
* @returns {string[]}
|
|
1668
|
+
*/
|
|
1669
|
+
extractResponses(screen) {
|
|
1670
|
+
// eslint-disable-next-line no-control-regex
|
|
1671
|
+
const clean = screen.replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
|
|
1672
|
+
const lines = clean.split("\n");
|
|
1673
|
+
/** @type {string[]} */
|
|
1674
|
+
const responses = [];
|
|
1675
|
+
/** @type {string[]} */
|
|
1676
|
+
let current = [];
|
|
1677
|
+
let inResponse = false;
|
|
1678
|
+
|
|
1679
|
+
for (const line of lines) {
|
|
1680
|
+
// Skip chrome lines
|
|
1681
|
+
if (this.isChromeLine(line)) continue;
|
|
1682
|
+
|
|
1683
|
+
// User prompt marks end of previous response
|
|
1684
|
+
if (line.startsWith(this.promptSymbol)) {
|
|
1685
|
+
if (current.length) {
|
|
1686
|
+
responses.push(current.join("\n").trim());
|
|
1687
|
+
current = [];
|
|
1688
|
+
}
|
|
1689
|
+
inResponse = false;
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// Response markers
|
|
1694
|
+
const isMarker = this.responseMarkers.some((m) => line.startsWith(m));
|
|
1695
|
+
if (isMarker) {
|
|
1696
|
+
if (!inResponse && current.length) {
|
|
1697
|
+
responses.push(current.join("\n").trim());
|
|
1698
|
+
current = [];
|
|
1699
|
+
}
|
|
1700
|
+
inResponse = true;
|
|
1701
|
+
current.push(line);
|
|
1702
|
+
} else if (inResponse && (line.startsWith(" ") || line.trim() === "")) {
|
|
1703
|
+
current.push(line);
|
|
1704
|
+
} else if (inResponse && line.trim()) {
|
|
1705
|
+
current.push(line);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
if (current.length) responses.push(current.join("\n").trim());
|
|
1709
|
+
|
|
1710
|
+
const filtered = responses.filter((r) => r.length > 0);
|
|
1711
|
+
|
|
1712
|
+
// Fallback: extract after last prompt
|
|
1713
|
+
if (filtered.length === 0) {
|
|
1714
|
+
const lastPromptIdx = lines.findLastIndex((/** @type {string} */ l) => l.startsWith(this.promptSymbol));
|
|
1715
|
+
if (lastPromptIdx >= 0 && lastPromptIdx < lines.length - 1) {
|
|
1716
|
+
const afterPrompt = lines
|
|
1717
|
+
.slice(lastPromptIdx + 1)
|
|
1718
|
+
.filter((/** @type {string} */ l) => !this.isChromeLine(l))
|
|
1719
|
+
.join("\n")
|
|
1720
|
+
.trim();
|
|
1721
|
+
if (afterPrompt) return [afterPrompt];
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Second fallback: if the last prompt is empty (just ❯), look BEFORE it
|
|
1725
|
+
// This handles the case where Claude finished and shows a new empty prompt
|
|
1726
|
+
if (lastPromptIdx >= 0) {
|
|
1727
|
+
const lastPromptLine = lines[lastPromptIdx];
|
|
1728
|
+
const isEmptyPrompt = lastPromptLine.trim() === this.promptSymbol ||
|
|
1729
|
+
lastPromptLine.match(/^❯\s*$/);
|
|
1730
|
+
if (isEmptyPrompt) {
|
|
1731
|
+
// Find the previous prompt (user's input) and extract content between
|
|
1732
|
+
// Note: [Pasted text is Claude's truncated output indicator, NOT a prompt
|
|
1733
|
+
const prevPromptIdx = lines.slice(0, lastPromptIdx).findLastIndex(
|
|
1734
|
+
(/** @type {string} */ l) => l.startsWith(this.promptSymbol)
|
|
1735
|
+
);
|
|
1736
|
+
if (prevPromptIdx >= 0) {
|
|
1737
|
+
const betweenPrompts = lines
|
|
1738
|
+
.slice(prevPromptIdx + 1, lastPromptIdx)
|
|
1739
|
+
.filter((/** @type {string} */ l) => !this.isChromeLine(l))
|
|
1740
|
+
.join("\n")
|
|
1741
|
+
.trim();
|
|
1742
|
+
if (betweenPrompts) return [betweenPrompts];
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
return filtered;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* @param {string} response
|
|
1753
|
+
* @returns {string}
|
|
1754
|
+
*/
|
|
1755
|
+
cleanResponse(response) {
|
|
1756
|
+
return response
|
|
1757
|
+
// Remove tool call lines (Search, Read, Grep, etc.)
|
|
1758
|
+
.replace(/^[⏺•]\s*(Search|Read|Grep|Glob|Write|Edit|Bash)\([^)]*\).*$/gm, "")
|
|
1759
|
+
// Remove tool result lines
|
|
1760
|
+
.replace(/^⎿\s+.*$/gm, "")
|
|
1761
|
+
// Remove "Sautéed for Xs" timing lines
|
|
1762
|
+
.replace(/^✻\s+Sautéed for.*$/gm, "")
|
|
1763
|
+
// Remove expand hints
|
|
1764
|
+
.replace(/\(ctrl\+o to expand\)/g, "")
|
|
1765
|
+
// Clean up multiple blank lines
|
|
1766
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1767
|
+
// Original cleanup
|
|
1768
|
+
.replace(/^[•⏺-]\s*/, "")
|
|
1769
|
+
.replace(/^\*\*(.+)\*\*/, "$1")
|
|
1770
|
+
.replace(/\n /g, "\n")
|
|
1771
|
+
.replace(/─+\s*$/, "")
|
|
1772
|
+
.trim();
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/**
|
|
1776
|
+
* Get assistant response text, preferring JSONL log over screen scraping.
|
|
1777
|
+
* @param {string} session - tmux session name
|
|
1778
|
+
* @param {string} screen - captured screen content (fallback)
|
|
1779
|
+
* @param {number} [index=0] - 0 = last response, -1 = second-to-last, etc.
|
|
1780
|
+
* @returns {string | null}
|
|
1781
|
+
*/
|
|
1782
|
+
getResponse(session, screen, index = 0) {
|
|
1783
|
+
// Try JSONL first (clean, no screen artifacts)
|
|
1784
|
+
const logPath = this.findLogPath(session);
|
|
1785
|
+
const jsonlText = logPath ? getAssistantText(logPath, index) : null;
|
|
1786
|
+
if (jsonlText) return jsonlText;
|
|
1787
|
+
|
|
1788
|
+
// Fallback to screen scraping
|
|
1789
|
+
const responses = this.extractResponses(screen);
|
|
1790
|
+
const i = responses.length - 1 + index;
|
|
1791
|
+
const response = responses[i];
|
|
1792
|
+
return response ? this.cleanResponse(response) : null;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* @param {string} session
|
|
1797
|
+
*/
|
|
1798
|
+
async handleUpdatePrompt(session) {
|
|
1799
|
+
// Default: skip update (send "2" then Enter)
|
|
1800
|
+
tmuxSend(session, "2");
|
|
1801
|
+
await sleep(300);
|
|
1802
|
+
tmuxSend(session, "Enter");
|
|
1803
|
+
await sleep(500);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// =============================================================================
|
|
1808
|
+
// CodexAgent
|
|
1809
|
+
// =============================================================================
|
|
1810
|
+
|
|
1811
|
+
const CodexAgent = new Agent({
|
|
1812
|
+
name: "codex",
|
|
1813
|
+
startCommand: "codex --sandbox read-only",
|
|
1814
|
+
yoloCommand: "codex --dangerously-bypass-approvals-and-sandbox",
|
|
1815
|
+
promptSymbol: "›",
|
|
1816
|
+
spinners: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
1817
|
+
rateLimitPattern: /■.*(?:usage limit|rate limit|try again at)/i,
|
|
1818
|
+
thinkingPatterns: ["Thinking…", "Thinking..."],
|
|
1819
|
+
confirmPatterns: [
|
|
1820
|
+
(lines) => lines.includes("[y]") && lines.includes("[n]"),
|
|
1821
|
+
"Run command?",
|
|
1822
|
+
(lines) => lines.includes("Allow") && lines.includes("Deny"),
|
|
1823
|
+
],
|
|
1824
|
+
updatePromptPatterns: {
|
|
1825
|
+
screen: ["Update available"],
|
|
1826
|
+
lastLines: ["Skip"],
|
|
1827
|
+
},
|
|
1828
|
+
responseMarkers: ["•", "- ", "**"],
|
|
1829
|
+
chromePatterns: ["context left", "for shortcuts"],
|
|
1830
|
+
reviewOptions: { pr: "1", uncommitted: "2", commit: "3", custom: "4" },
|
|
1831
|
+
envVar: "AX_SESSION",
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
// =============================================================================
|
|
1835
|
+
// ClaudeAgent
|
|
1836
|
+
// =============================================================================
|
|
1837
|
+
|
|
1838
|
+
const ClaudeAgent = new Agent({
|
|
1839
|
+
name: "claude",
|
|
1840
|
+
startCommand: "claude",
|
|
1841
|
+
yoloCommand: "claude --dangerously-skip-permissions",
|
|
1842
|
+
promptSymbol: "❯",
|
|
1843
|
+
spinners: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
1844
|
+
rateLimitPattern: /rate.?limit/i,
|
|
1845
|
+
thinkingPatterns: ["Thinking"],
|
|
1846
|
+
confirmPatterns: [
|
|
1847
|
+
"Do you want to make this edit",
|
|
1848
|
+
"Do you want to run this command",
|
|
1849
|
+
"Do you want to proceed",
|
|
1850
|
+
// Active menu: numbered options with Yes/No/Allow/Deny
|
|
1851
|
+
(lines) => /\d+\.\s*(Yes|No|Allow|Deny)/i.test(lines),
|
|
1852
|
+
],
|
|
1853
|
+
updatePromptPatterns: null,
|
|
1854
|
+
responseMarkers: ["⏺", "•", "- ", "**"],
|
|
1855
|
+
chromePatterns: ["↵ send", "Esc to cancel", "shortcuts", "for more options", "docs.anthropic.com", "⏵⏵", "bypass permissions", "shift+Tab to cycle"],
|
|
1856
|
+
reviewOptions: null,
|
|
1857
|
+
safeAllowedTools: "Bash(git:*) Read Glob Grep", // Default: auto-approve read-only tools
|
|
1858
|
+
envVar: "AX_SESSION",
|
|
1859
|
+
approveKey: "1",
|
|
1860
|
+
rejectKey: "Escape",
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
// =============================================================================
|
|
1864
|
+
// Commands
|
|
1865
|
+
// =============================================================================
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* Wait until agent reaches a terminal state (ready, confirming, or rate limited).
|
|
1869
|
+
* Returns immediately if already in a terminal state.
|
|
1870
|
+
* @param {Agent} agent
|
|
1871
|
+
* @param {string} session
|
|
1872
|
+
* @param {number} [timeoutMs]
|
|
1873
|
+
* @returns {Promise<{state: string, screen: string}>}
|
|
1874
|
+
*/
|
|
1875
|
+
async function waitUntilReady(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1876
|
+
const start = Date.now();
|
|
1877
|
+
const initialScreen = tmuxCapture(session);
|
|
1878
|
+
const initialState = agent.getState(initialScreen);
|
|
1879
|
+
|
|
1880
|
+
// Already in terminal state
|
|
1881
|
+
if (initialState === State.RATE_LIMITED || initialState === State.CONFIRMING || initialState === State.READY) {
|
|
1882
|
+
return { state: initialState, screen: initialScreen };
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
while (Date.now() - start < timeoutMs) {
|
|
1886
|
+
await sleep(POLL_MS);
|
|
1887
|
+
const screen = tmuxCapture(session);
|
|
1888
|
+
const state = agent.getState(screen);
|
|
1889
|
+
|
|
1890
|
+
if (state === State.RATE_LIMITED || state === State.CONFIRMING || state === State.READY) {
|
|
1891
|
+
return { state, screen };
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
throw new Error("timeout");
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Wait for agent to process a new message and respond.
|
|
1899
|
+
* Waits for screen activity before considering the response complete.
|
|
1900
|
+
* @param {Agent} agent
|
|
1901
|
+
* @param {string} session
|
|
1902
|
+
* @param {number} [timeoutMs]
|
|
1903
|
+
* @returns {Promise<{state: string, screen: string}>}
|
|
1904
|
+
*/
|
|
1905
|
+
async function waitForResponse(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1906
|
+
const start = Date.now();
|
|
1907
|
+
const initialScreen = tmuxCapture(session);
|
|
1908
|
+
|
|
1909
|
+
let lastScreen = initialScreen;
|
|
1910
|
+
let stableAt = null;
|
|
1911
|
+
let sawActivity = false;
|
|
1912
|
+
|
|
1913
|
+
while (Date.now() - start < timeoutMs) {
|
|
1914
|
+
await sleep(POLL_MS);
|
|
1915
|
+
const screen = tmuxCapture(session);
|
|
1916
|
+
const state = agent.getState(screen);
|
|
1917
|
+
|
|
1918
|
+
if (state === State.RATE_LIMITED || state === State.CONFIRMING) {
|
|
1919
|
+
return { state, screen };
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
if (screen !== lastScreen) {
|
|
1923
|
+
lastScreen = screen;
|
|
1924
|
+
stableAt = Date.now();
|
|
1925
|
+
if (screen !== initialScreen) {
|
|
1926
|
+
sawActivity = true;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
if (sawActivity && stableAt && Date.now() - stableAt >= STABLE_MS) {
|
|
1931
|
+
if (state === State.READY) {
|
|
1932
|
+
return { state, screen };
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
if (state === State.THINKING) {
|
|
1937
|
+
sawActivity = true;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
throw new Error("timeout");
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Auto-approve loop that keeps approving confirmations until the agent is ready or rate limited.
|
|
1945
|
+
* Used by callers to implement yolo mode on sessions not started with native --yolo.
|
|
1946
|
+
* @param {Agent} agent
|
|
1947
|
+
* @param {string} session
|
|
1948
|
+
* @param {number} [timeoutMs]
|
|
1949
|
+
* @returns {Promise<{state: string, screen: string}>}
|
|
1950
|
+
*/
|
|
1951
|
+
async function autoApproveLoop(agent, session, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1952
|
+
const deadline = Date.now() + timeoutMs;
|
|
1953
|
+
|
|
1954
|
+
while (Date.now() < deadline) {
|
|
1955
|
+
const remaining = deadline - Date.now();
|
|
1956
|
+
if (remaining <= 0) break;
|
|
1957
|
+
|
|
1958
|
+
const { state, screen } = await waitForResponse(agent, session, remaining);
|
|
1959
|
+
|
|
1960
|
+
if (state === State.RATE_LIMITED || state === State.READY) {
|
|
1961
|
+
return { state, screen };
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
if (state === State.CONFIRMING) {
|
|
1965
|
+
tmuxSend(session, agent.approveKey);
|
|
1966
|
+
await sleep(APPROVE_DELAY_MS);
|
|
1967
|
+
continue;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// Unexpected state - log and continue polling
|
|
1971
|
+
debugError("autoApproveLoop", new Error(`unexpected state: ${state}`));
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
throw new Error("timeout");
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* @param {Agent} agent
|
|
1979
|
+
* @param {string | null | undefined} session
|
|
1980
|
+
* @param {Object} [options]
|
|
1981
|
+
* @param {boolean} [options.yolo]
|
|
1982
|
+
* @returns {Promise<string>}
|
|
1983
|
+
*/
|
|
1984
|
+
async function cmdStart(agent, session, { yolo = false } = {}) {
|
|
1985
|
+
// Generate session name if not provided
|
|
1986
|
+
if (!session) {
|
|
1987
|
+
session = agent.generateSession();
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
if (tmuxHasSession(session)) return session;
|
|
1991
|
+
|
|
1992
|
+
// Check agent CLI is installed before trying to start
|
|
1993
|
+
const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
|
|
1994
|
+
if (cliCheck.status !== 0) {
|
|
1995
|
+
console.error(`ERROR: ${agent.name} CLI is not installed or not in PATH`);
|
|
1996
|
+
process.exit(1);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
const command = agent.getCommand(yolo, session);
|
|
2000
|
+
tmuxNewSession(session, command);
|
|
2001
|
+
|
|
2002
|
+
const start = Date.now();
|
|
2003
|
+
while (Date.now() - start < STARTUP_TIMEOUT_MS) {
|
|
2004
|
+
const screen = tmuxCapture(session);
|
|
2005
|
+
const state = agent.getState(screen);
|
|
2006
|
+
|
|
2007
|
+
if (state === State.UPDATE_PROMPT) {
|
|
2008
|
+
await agent.handleUpdatePrompt(session);
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
if (state === State.READY) return session;
|
|
2013
|
+
|
|
2014
|
+
await sleep(POLL_MS);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
console.log("ERROR: timeout");
|
|
2018
|
+
process.exit(1);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// =============================================================================
|
|
2022
|
+
// Command: agents
|
|
2023
|
+
// =============================================================================
|
|
2024
|
+
|
|
2025
|
+
function cmdAgents() {
|
|
2026
|
+
const allSessions = tmuxListSessions();
|
|
2027
|
+
|
|
2028
|
+
// Filter to agent sessions (claude-uuid or codex-uuid format)
|
|
2029
|
+
const agentSessions = allSessions.filter((s) => parseSessionName(s));
|
|
2030
|
+
|
|
2031
|
+
if (agentSessions.length === 0) {
|
|
2032
|
+
console.log("No agents running");
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// Get info for each agent
|
|
2037
|
+
const agents = agentSessions.map((session) => {
|
|
2038
|
+
const parsed = /** @type {ParsedSession} */ (parseSessionName(session));
|
|
2039
|
+
const agent = parsed.tool === "claude" ? ClaudeAgent : CodexAgent;
|
|
2040
|
+
const screen = tmuxCapture(session);
|
|
2041
|
+
const state = agent.getState(screen);
|
|
2042
|
+
const logPath = agent.findLogPath(session);
|
|
2043
|
+
const type = parsed.daemonName ? "daemon" : "-";
|
|
2044
|
+
|
|
2045
|
+
return {
|
|
2046
|
+
session,
|
|
2047
|
+
tool: parsed.tool,
|
|
2048
|
+
state: state || "unknown",
|
|
2049
|
+
type,
|
|
2050
|
+
log: logPath || "-",
|
|
2051
|
+
};
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
// Print table
|
|
2055
|
+
const maxSession = Math.max(7, ...agents.map((a) => a.session.length));
|
|
2056
|
+
const maxTool = Math.max(4, ...agents.map((a) => a.tool.length));
|
|
2057
|
+
const maxState = Math.max(5, ...agents.map((a) => a.state.length));
|
|
2058
|
+
const maxType = Math.max(4, ...agents.map((a) => a.type.length));
|
|
2059
|
+
|
|
2060
|
+
console.log(
|
|
2061
|
+
`${"SESSION".padEnd(maxSession)} ${"TOOL".padEnd(maxTool)} ${"STATE".padEnd(maxState)} ${"TYPE".padEnd(maxType)} LOG`
|
|
2062
|
+
);
|
|
2063
|
+
for (const a of agents) {
|
|
2064
|
+
console.log(
|
|
2065
|
+
`${a.session.padEnd(maxSession)} ${a.tool.padEnd(maxTool)} ${a.state.padEnd(maxState)} ${a.type.padEnd(maxType)} ${a.log}`
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// =============================================================================
|
|
2071
|
+
// Command: daemons
|
|
2072
|
+
// =============================================================================
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* @param {string} pattern
|
|
2076
|
+
* @returns {string | undefined}
|
|
2077
|
+
*/
|
|
2078
|
+
function findDaemonSession(pattern) {
|
|
2079
|
+
const sessions = tmuxListSessions();
|
|
2080
|
+
return sessions.find((s) => s.startsWith(pattern));
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
/**
|
|
2084
|
+
* @param {DaemonConfig} config
|
|
2085
|
+
* @returns {string}
|
|
2086
|
+
*/
|
|
2087
|
+
function generateDaemonSessionName(config) {
|
|
2088
|
+
return `${config.tool}-daemon-${config.name}-${randomUUID()}`;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/**
|
|
2092
|
+
* @param {DaemonConfig} config
|
|
2093
|
+
* @param {ParentSession | null} [parentSession]
|
|
2094
|
+
*/
|
|
2095
|
+
function startDaemonAgent(config, parentSession = null) {
|
|
2096
|
+
// Build environment with parent session info if available
|
|
2097
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
2098
|
+
const env = { ...process.env };
|
|
2099
|
+
if (parentSession?.uuid) {
|
|
2100
|
+
// Session name may be null for non-tmux sessions, but uuid is required
|
|
2101
|
+
if (parentSession.session) {
|
|
2102
|
+
env[AX_DAEMON_PARENT_SESSION_ENV] = parentSession.session;
|
|
2103
|
+
}
|
|
2104
|
+
env[AX_DAEMON_PARENT_UUID_ENV] = parentSession.uuid;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// Spawn ax.js daemon <name> as a detached background process
|
|
2108
|
+
const child = spawn("node", [process.argv[1], "daemon", config.name], {
|
|
2109
|
+
detached: true,
|
|
2110
|
+
stdio: "ignore",
|
|
2111
|
+
cwd: process.cwd(),
|
|
2112
|
+
env,
|
|
2113
|
+
});
|
|
2114
|
+
child.unref();
|
|
2115
|
+
console.log(`Starting daemon: ${config.name} (pid ${child.pid})${parentSession ? ` [parent: ${parentSession.session}]` : ""}`);
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// =============================================================================
|
|
2119
|
+
// Command: daemon (runs as the daemon process itself)
|
|
2120
|
+
// =============================================================================
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
2123
|
+
* @param {string | undefined} agentName
|
|
2124
|
+
*/
|
|
2125
|
+
async function cmdDaemon(agentName) {
|
|
2126
|
+
if (!agentName) {
|
|
2127
|
+
console.error("Usage: ./ax.js daemon <name>");
|
|
2128
|
+
process.exit(1);
|
|
2129
|
+
}
|
|
2130
|
+
// Load agent config
|
|
2131
|
+
const configPath = path.join(AGENTS_DIR, `${agentName}.md`);
|
|
2132
|
+
if (!existsSync(configPath)) {
|
|
2133
|
+
console.error(`[daemon:${agentName}] Config not found: ${configPath}`);
|
|
2134
|
+
process.exit(1);
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
const content = readFileSync(configPath, "utf-8");
|
|
2138
|
+
const configResult = parseAgentConfig(`${agentName}.md`, content);
|
|
2139
|
+
if (!configResult || "error" in configResult) {
|
|
2140
|
+
console.error(`[daemon:${agentName}] Invalid config`);
|
|
2141
|
+
process.exit(1);
|
|
2142
|
+
}
|
|
2143
|
+
const config = configResult;
|
|
2144
|
+
|
|
2145
|
+
const agent = config.tool === "claude" ? ClaudeAgent : CodexAgent;
|
|
2146
|
+
const sessionName = generateDaemonSessionName(config);
|
|
2147
|
+
|
|
2148
|
+
// Check agent CLI is installed before trying to start
|
|
2149
|
+
const cliCheck = spawnSync("which", [agent.name], { encoding: "utf-8" });
|
|
2150
|
+
if (cliCheck.status !== 0) {
|
|
2151
|
+
console.error(`[daemon:${agentName}] ERROR: ${agent.name} CLI is not installed or not in PATH`);
|
|
2152
|
+
process.exit(1);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
// Start the agent session with safe defaults (auto-approve read-only operations)
|
|
2156
|
+
const command = agent.getCommand(false, sessionName);
|
|
2157
|
+
tmuxNewSession(sessionName, command);
|
|
2158
|
+
|
|
2159
|
+
// Wait for agent to be ready
|
|
2160
|
+
const start = Date.now();
|
|
2161
|
+
while (Date.now() - start < DAEMON_STARTUP_TIMEOUT_MS) {
|
|
2162
|
+
const screen = tmuxCapture(sessionName);
|
|
2163
|
+
const state = agent.getState(screen);
|
|
2164
|
+
|
|
2165
|
+
if (state === State.UPDATE_PROMPT) {
|
|
2166
|
+
await agent.handleUpdatePrompt(sessionName);
|
|
2167
|
+
continue;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// Handle bypass permissions confirmation dialog (Claude Code shows this for --dangerously-skip-permissions)
|
|
2171
|
+
if (screen.includes("Bypass Permissions mode") && screen.includes("Yes, I accept")) {
|
|
2172
|
+
console.log(`[daemon:${agentName}] Accepting bypass permissions dialog`);
|
|
2173
|
+
tmuxSend(sessionName, "2"); // Select "Yes, I accept"
|
|
2174
|
+
await sleep(300);
|
|
2175
|
+
tmuxSend(sessionName, "Enter");
|
|
2176
|
+
await sleep(500);
|
|
2177
|
+
continue;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
if (state === State.READY) {
|
|
2181
|
+
console.log(`[daemon:${agentName}] Started session: ${sessionName}`);
|
|
2182
|
+
break;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
await sleep(POLL_MS);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
// Load the base prompt from config
|
|
2189
|
+
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2190
|
+
const promptMatch = normalized.match(/^---[\s\S]*?---\n([\s\S]*)$/);
|
|
2191
|
+
const basePrompt = promptMatch ? promptMatch[1].trim() : "Review for issues.";
|
|
2192
|
+
|
|
2193
|
+
// File watching state
|
|
2194
|
+
/** @type {Set<string>} */
|
|
2195
|
+
let changedFiles = new Set();
|
|
2196
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
2197
|
+
let debounceTimer = undefined;
|
|
2198
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
2199
|
+
let maxWaitTimer = undefined;
|
|
2200
|
+
let isProcessing = false;
|
|
2201
|
+
const intervalMs = config.interval * 1000;
|
|
2202
|
+
|
|
2203
|
+
async function processChanges() {
|
|
2204
|
+
clearTimeout(debounceTimer);
|
|
2205
|
+
clearTimeout(maxWaitTimer);
|
|
2206
|
+
debounceTimer = undefined;
|
|
2207
|
+
maxWaitTimer = undefined;
|
|
2208
|
+
|
|
2209
|
+
if (changedFiles.size === 0 || isProcessing) return;
|
|
2210
|
+
isProcessing = true;
|
|
2211
|
+
|
|
2212
|
+
const files = [...changedFiles];
|
|
2213
|
+
changedFiles = new Set(); // atomic swap to avoid losing changes during processing
|
|
2214
|
+
|
|
2215
|
+
try {
|
|
2216
|
+
// Get parent session log path for JSONL extraction
|
|
2217
|
+
const parent = findParentSession();
|
|
2218
|
+
const logPath = parent ? findClaudeLogPath(parent.uuid, parent.session) : null;
|
|
2219
|
+
|
|
2220
|
+
// Build file-specific context from JSONL
|
|
2221
|
+
const fileContexts = [];
|
|
2222
|
+
for (const file of files.slice(0, 5)) { // Limit to 5 files
|
|
2223
|
+
const ctx = extractFileEditContext(logPath, file);
|
|
2224
|
+
if (ctx) {
|
|
2225
|
+
fileContexts.push({ file, ...ctx });
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// Build the prompt
|
|
2230
|
+
let prompt = basePrompt;
|
|
2231
|
+
|
|
2232
|
+
if (fileContexts.length > 0) {
|
|
2233
|
+
prompt += "\n\n## Recent Edits (from parent session)\n";
|
|
2234
|
+
|
|
2235
|
+
for (const ctx of fileContexts) {
|
|
2236
|
+
prompt += `\n### ${ctx.file}\n`;
|
|
2237
|
+
prompt += `**Intent:** ${ctx.intent.slice(0, 500)}\n`;
|
|
2238
|
+
prompt += `**Action:** ${ctx.toolCall.name}\n`;
|
|
2239
|
+
|
|
2240
|
+
if (ctx.editSequence > 1) {
|
|
2241
|
+
prompt += `**Note:** This is edit #${ctx.editSequence} to this file (refinement)\n`;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
if (ctx.subsequentErrors.length > 0) {
|
|
2245
|
+
prompt += `**Errors after:** ${ctx.subsequentErrors[0].slice(0, 200)}\n`;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
if (ctx.readsBefore.length > 0) {
|
|
2249
|
+
const reads = ctx.readsBefore.map(f => f.split("/").pop()).join(", ");
|
|
2250
|
+
prompt += `**Files read before:** ${reads}\n`;
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
|
|
2255
|
+
|
|
2256
|
+
const gitContext = buildGitContext(DAEMON_GIT_CONTEXT_HOURS, DAEMON_GIT_CONTEXT_MAX_LINES);
|
|
2257
|
+
if (gitContext) {
|
|
2258
|
+
prompt += "\n\n## Git Context\n\n" + gitContext;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
prompt += '\n\nReview these changes in the context of what the user is working on. Report any issues found. Keep your response concise.\nIf there are no significant issues, respond with just "No issues found."';
|
|
2262
|
+
} else {
|
|
2263
|
+
// Fallback: no JSONL context available, use conversation + git context
|
|
2264
|
+
const parentContext = getParentSessionContext(DAEMON_PARENT_CONTEXT_ENTRIES);
|
|
2265
|
+
const gitContext = buildGitContext(DAEMON_GIT_CONTEXT_HOURS, DAEMON_GIT_CONTEXT_MAX_LINES);
|
|
2266
|
+
|
|
2267
|
+
if (parentContext) {
|
|
2268
|
+
prompt += "\n\n## Main Session Context\n\nThe user is currently working on:\n\n" + parentContext;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
prompt += "\n\n## Files Changed\n - " + files.slice(0, 10).join("\n - ");
|
|
2272
|
+
|
|
2273
|
+
if (gitContext) {
|
|
2274
|
+
prompt += "\n\n## Git Context\n\n" + gitContext;
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
prompt += '\n\nReview these changes in the context of what the user is working on. Report any issues found. Keep your response concise.\nIf there are no significant issues, respond with just "No issues found."';
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
// Check session still exists
|
|
2282
|
+
if (!tmuxHasSession(sessionName)) {
|
|
2283
|
+
console.log(`[daemon:${agentName}] Session gone, exiting`);
|
|
2284
|
+
process.exit(0);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// Wait for ready
|
|
2288
|
+
const screen = tmuxCapture(sessionName);
|
|
2289
|
+
const state = agent.getState(screen);
|
|
2290
|
+
|
|
2291
|
+
if (state === State.RATE_LIMITED) {
|
|
2292
|
+
console.error(`[daemon:${agentName}] Rate limited - stopping`);
|
|
2293
|
+
process.exit(2);
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
if (state !== State.READY) {
|
|
2297
|
+
console.log(`[daemon:${agentName}] Agent not ready (${state}), skipping`);
|
|
2298
|
+
isProcessing = false;
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// Send prompt
|
|
2303
|
+
tmuxSendLiteral(sessionName, prompt);
|
|
2304
|
+
await sleep(200); // Allow time for large prompts to be processed
|
|
2305
|
+
tmuxSend(sessionName, "Enter");
|
|
2306
|
+
await sleep(100); // Ensure Enter is processed
|
|
2307
|
+
|
|
2308
|
+
// Wait for response
|
|
2309
|
+
const { state: endState, screen: afterScreen } = await waitForResponse(agent, sessionName, DAEMON_RESPONSE_TIMEOUT_MS);
|
|
2310
|
+
|
|
2311
|
+
if (endState === State.RATE_LIMITED) {
|
|
2312
|
+
console.error(`[daemon:${agentName}] Rate limited - stopping`);
|
|
2313
|
+
process.exit(2);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
const cleanedResponse = agent.getResponse(sessionName, afterScreen) || "";
|
|
2318
|
+
|
|
2319
|
+
// Sanity check: skip garbage responses (screen scraping artifacts)
|
|
2320
|
+
const isGarbage = cleanedResponse.includes("[Pasted text") ||
|
|
2321
|
+
cleanedResponse.match(/^\+\d+ lines\]/) ||
|
|
2322
|
+
cleanedResponse.length < 20;
|
|
2323
|
+
|
|
2324
|
+
if (cleanedResponse && !isGarbage && !cleanedResponse.toLowerCase().includes("no issues found")) {
|
|
2325
|
+
writeToMailbox({
|
|
2326
|
+
agent: /** @type {string} */ (agentName),
|
|
2327
|
+
session: sessionName,
|
|
2328
|
+
branch: getCurrentBranch(),
|
|
2329
|
+
commit: getCurrentCommit(),
|
|
2330
|
+
files,
|
|
2331
|
+
message: cleanedResponse.slice(0, 1000),
|
|
2332
|
+
});
|
|
2333
|
+
console.log(`[daemon:${agentName}] Wrote observation for ${files.length} file(s)`);
|
|
2334
|
+
} else if (isGarbage) {
|
|
2335
|
+
console.log(`[daemon:${agentName}] Skipped garbage response`);
|
|
2336
|
+
}
|
|
2337
|
+
} catch (err) {
|
|
2338
|
+
console.error(`[daemon:${agentName}] Error:`, err instanceof Error ? err.message : err);
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
isProcessing = false;
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
function scheduleProcessChanges() {
|
|
2345
|
+
processChanges().catch((err) => {
|
|
2346
|
+
console.error(`[daemon:${agentName}] Unhandled error:`, err instanceof Error ? err.message : err);
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// Set up file watching
|
|
2351
|
+
const stopWatching = watchForChanges(config.watch, (filePath) => {
|
|
2352
|
+
changedFiles.add(filePath);
|
|
2353
|
+
|
|
2354
|
+
// Debounce: reset timer on each change
|
|
2355
|
+
clearTimeout(debounceTimer);
|
|
2356
|
+
debounceTimer = setTimeout(scheduleProcessChanges, intervalMs);
|
|
2357
|
+
|
|
2358
|
+
// Max wait: force trigger after 5x interval to prevent starvation
|
|
2359
|
+
if (!maxWaitTimer) {
|
|
2360
|
+
maxWaitTimer = setTimeout(scheduleProcessChanges, intervalMs * 5);
|
|
2361
|
+
}
|
|
2362
|
+
});
|
|
2363
|
+
|
|
2364
|
+
// Check if session still exists periodically
|
|
2365
|
+
const sessionCheck = setInterval(() => {
|
|
2366
|
+
if (!tmuxHasSession(sessionName)) {
|
|
2367
|
+
console.log(`[daemon:${agentName}] Session gone, exiting`);
|
|
2368
|
+
stopWatching();
|
|
2369
|
+
clearInterval(sessionCheck);
|
|
2370
|
+
process.exit(0);
|
|
2371
|
+
}
|
|
2372
|
+
}, DAEMON_HEALTH_CHECK_MS);
|
|
2373
|
+
|
|
2374
|
+
// Handle graceful shutdown
|
|
2375
|
+
process.on("SIGTERM", () => {
|
|
2376
|
+
console.log(`[daemon:${agentName}] Received SIGTERM, shutting down`);
|
|
2377
|
+
stopWatching();
|
|
2378
|
+
clearInterval(sessionCheck);
|
|
2379
|
+
tmuxSend(sessionName, "C-c");
|
|
2380
|
+
setTimeout(() => {
|
|
2381
|
+
tmuxKill(sessionName);
|
|
2382
|
+
process.exit(0);
|
|
2383
|
+
}, 500);
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
process.on("SIGINT", () => {
|
|
2387
|
+
console.log(`[daemon:${agentName}] Received SIGINT, shutting down`);
|
|
2388
|
+
stopWatching();
|
|
2389
|
+
clearInterval(sessionCheck);
|
|
2390
|
+
tmuxSend(sessionName, "C-c");
|
|
2391
|
+
setTimeout(() => {
|
|
2392
|
+
tmuxKill(sessionName);
|
|
2393
|
+
process.exit(0);
|
|
2394
|
+
}, 500);
|
|
2395
|
+
});
|
|
2396
|
+
|
|
2397
|
+
console.log(`[daemon:${agentName}] Watching: ${config.watch.join(", ")}`);
|
|
2398
|
+
|
|
2399
|
+
// Keep the process alive
|
|
2400
|
+
await new Promise(() => {});
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
/**
|
|
2404
|
+
* @param {string} action
|
|
2405
|
+
* @param {string | null} [daemonName]
|
|
2406
|
+
*/
|
|
2407
|
+
async function cmdDaemons(action, daemonName = null) {
|
|
2408
|
+
if (action !== "start" && action !== "stop" && action !== "init") {
|
|
2409
|
+
console.log("Usage: ./ax.js daemons <start|stop|init> [name]");
|
|
2410
|
+
process.exit(1);
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// Handle init action separately
|
|
2414
|
+
if (action === "init") {
|
|
2415
|
+
if (!daemonName) {
|
|
2416
|
+
console.log("Usage: ./ax.js daemons init <name>");
|
|
2417
|
+
console.log("Example: ./ax.js daemons init reviewer");
|
|
2418
|
+
process.exit(1);
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
// Validate name (alphanumeric, dashes, underscores only)
|
|
2422
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(daemonName)) {
|
|
2423
|
+
console.log("ERROR: Daemon name must contain only letters, numbers, dashes, and underscores");
|
|
2424
|
+
process.exit(1);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
const agentPath = path.join(AGENTS_DIR, `${daemonName}.md`);
|
|
2428
|
+
if (existsSync(agentPath)) {
|
|
2429
|
+
console.log(`ERROR: Agent config already exists: ${agentPath}`);
|
|
2430
|
+
process.exit(1);
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// Create agents directory if needed
|
|
2434
|
+
if (!existsSync(AGENTS_DIR)) {
|
|
2435
|
+
mkdirSync(AGENTS_DIR, { recursive: true });
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
const template = `---
|
|
2439
|
+
tool: claude
|
|
2440
|
+
watch: ["**/*.{ts,tsx,js,jsx,mjs,mts}"]
|
|
2441
|
+
interval: 30
|
|
2442
|
+
---
|
|
2443
|
+
|
|
2444
|
+
Review changed files for bugs, type errors, and edge cases.
|
|
2445
|
+
`;
|
|
2446
|
+
|
|
2447
|
+
writeFileSync(agentPath, template);
|
|
2448
|
+
console.log(`Created agent config: ${agentPath}`);
|
|
2449
|
+
console.log(`Edit the file to customize the daemon, then run: ./ax.js daemons start ${daemonName}`);
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
const configs = loadAgentConfigs();
|
|
2454
|
+
|
|
2455
|
+
if (configs.length === 0) {
|
|
2456
|
+
console.log(`No agent configs found in ${AGENTS_DIR}/`);
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// Filter to specific daemon if name provided
|
|
2461
|
+
const targetConfigs = daemonName
|
|
2462
|
+
? configs.filter((c) => c.name === daemonName)
|
|
2463
|
+
: configs;
|
|
2464
|
+
|
|
2465
|
+
if (daemonName && targetConfigs.length === 0) {
|
|
2466
|
+
console.log(`ERROR: daemon '${daemonName}' not found in ${AGENTS_DIR}/`);
|
|
2467
|
+
process.exit(1);
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// Ensure hook script exists on start
|
|
2471
|
+
if (action === "start") {
|
|
2472
|
+
ensureMailboxHookScript();
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// Find current Claude session to pass as parent (if we're inside one)
|
|
2476
|
+
const parentSession = action === "start" ? findCurrentClaudeSession() : null;
|
|
2477
|
+
if (action === "start") {
|
|
2478
|
+
if (parentSession) {
|
|
2479
|
+
console.log(`Parent session: ${parentSession.session || "(non-tmux)"} [${parentSession.uuid}]`);
|
|
2480
|
+
} else {
|
|
2481
|
+
console.log("Parent session: null (not running from Claude or no active sessions)");
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
for (const config of targetConfigs) {
|
|
2486
|
+
const sessionPattern = getDaemonSessionPattern(config);
|
|
2487
|
+
const existing = findDaemonSession(sessionPattern);
|
|
2488
|
+
|
|
2489
|
+
if (action === "stop") {
|
|
2490
|
+
if (existing) {
|
|
2491
|
+
tmuxSend(existing, "C-c");
|
|
2492
|
+
await sleep(300);
|
|
2493
|
+
tmuxKill(existing);
|
|
2494
|
+
console.log(`Stopped daemon: ${config.name} (${existing})`);
|
|
2495
|
+
} else {
|
|
2496
|
+
console.log(`Daemon not running: ${config.name}`);
|
|
2497
|
+
}
|
|
2498
|
+
} else if (action === "start") {
|
|
2499
|
+
if (!existing) {
|
|
2500
|
+
startDaemonAgent(config, parentSession);
|
|
2501
|
+
} else {
|
|
2502
|
+
console.log(`Daemon already running: ${config.name} (${existing})`);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// GC mailbox on start
|
|
2508
|
+
if (action === "start") {
|
|
2509
|
+
gcMailbox(24);
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
// Version of the hook script template - bump when making changes
|
|
2514
|
+
const HOOK_SCRIPT_VERSION = "2";
|
|
2515
|
+
|
|
2516
|
+
function ensureMailboxHookScript() {
|
|
2517
|
+
const hooksDir = HOOKS_DIR;
|
|
2518
|
+
const scriptPath = path.join(hooksDir, "mailbox-inject.js");
|
|
2519
|
+
const versionMarker = `// VERSION: ${HOOK_SCRIPT_VERSION}`;
|
|
2520
|
+
|
|
2521
|
+
// Check if script exists and is current version
|
|
2522
|
+
if (existsSync(scriptPath)) {
|
|
2523
|
+
const existing = readFileSync(scriptPath, "utf-8");
|
|
2524
|
+
if (existing.includes(versionMarker)) return;
|
|
2525
|
+
// Outdated version, regenerate
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
if (!existsSync(hooksDir)) {
|
|
2529
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// Inject absolute paths into the generated script
|
|
2533
|
+
const mailboxPath = path.join(AI_DIR, "mailbox.jsonl");
|
|
2534
|
+
const lastSeenPath = path.join(AI_DIR, "mailbox-last-seen");
|
|
2535
|
+
|
|
2536
|
+
const hookCode = `#!/usr/bin/env node
|
|
2537
|
+
${versionMarker}
|
|
2538
|
+
// Auto-generated hook script - do not edit manually
|
|
2539
|
+
|
|
2540
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2541
|
+
|
|
2542
|
+
const DEBUG = process.env.AX_DEBUG === "1";
|
|
2543
|
+
const MAILBOX = "${mailboxPath}";
|
|
2544
|
+
const LAST_SEEN = "${lastSeenPath}";
|
|
2545
|
+
const MAX_AGE_MS = 60 * 60 * 1000; // 1 hour (matches MAILBOX_MAX_AGE_MS)
|
|
2546
|
+
|
|
2547
|
+
if (!existsSync(MAILBOX)) process.exit(0);
|
|
2548
|
+
|
|
2549
|
+
// Note: commit filtering removed - age + lastSeen is sufficient
|
|
2550
|
+
|
|
2551
|
+
// Read last seen timestamp
|
|
2552
|
+
let lastSeen = 0;
|
|
2553
|
+
try {
|
|
2554
|
+
if (existsSync(LAST_SEEN)) {
|
|
2555
|
+
lastSeen = parseInt(readFileSync(LAST_SEEN, "utf-8").trim(), 10) || 0;
|
|
2556
|
+
}
|
|
2557
|
+
} catch (err) {
|
|
2558
|
+
if (DEBUG) console.error("[hook] readLastSeen:", err.message);
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
const now = Date.now();
|
|
2562
|
+
const lines = readFileSync(MAILBOX, "utf-8").trim().split("\\n").filter(Boolean);
|
|
2563
|
+
const relevant = [];
|
|
2564
|
+
|
|
2565
|
+
for (const line of lines) {
|
|
2566
|
+
try {
|
|
2567
|
+
const entry = JSON.parse(line);
|
|
2568
|
+
const ts = new Date(entry.timestamp).getTime();
|
|
2569
|
+
const age = now - ts;
|
|
2570
|
+
|
|
2571
|
+
// Only show observations within max age and not yet seen
|
|
2572
|
+
// (removed commit filter - too strict when HEAD moves during a session)
|
|
2573
|
+
if (age < MAX_AGE_MS && ts > lastSeen) {
|
|
2574
|
+
// Extract session prefix (without UUID) for shorter log command
|
|
2575
|
+
const session = entry.payload.session || "";
|
|
2576
|
+
const sessionPrefix = session.replace(/-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "");
|
|
2577
|
+
relevant.push({ agent: entry.payload.agent, sessionPrefix, message: entry.payload.message });
|
|
2578
|
+
}
|
|
2579
|
+
} catch (err) {
|
|
2580
|
+
if (DEBUG) console.error("[hook] parseLine:", err.message);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
if (relevant.length > 0) {
|
|
2585
|
+
console.log("## Background Agents");
|
|
2586
|
+
console.log("");
|
|
2587
|
+
console.log("Background agents watching your files found:");
|
|
2588
|
+
console.log("");
|
|
2589
|
+
const sessionPrefixes = new Set();
|
|
2590
|
+
for (const { agent, sessionPrefix, message } of relevant) {
|
|
2591
|
+
if (sessionPrefix) sessionPrefixes.add(sessionPrefix);
|
|
2592
|
+
console.log("**[" + agent + "]**");
|
|
2593
|
+
console.log("");
|
|
2594
|
+
console.log(message);
|
|
2595
|
+
console.log("");
|
|
2596
|
+
}
|
|
2597
|
+
const sessionList = [...sessionPrefixes].map(s => "\\\`./ax.js log " + s + "\\\`").join(" or ");
|
|
2598
|
+
console.log("> For more context: \\\`./ax.js mailbox\\\`" + (sessionList ? " or " + sessionList : ""));
|
|
2599
|
+
// Update last seen timestamp
|
|
2600
|
+
writeFileSync(LAST_SEEN, now.toString());
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
process.exit(0);
|
|
2604
|
+
`;
|
|
2605
|
+
|
|
2606
|
+
writeFileSync(scriptPath, hookCode);
|
|
2607
|
+
console.log(`Generated hook script: ${scriptPath}`);
|
|
2608
|
+
|
|
2609
|
+
// Configure the hook in .claude/settings.json at the same time
|
|
2610
|
+
const configuredHook = ensureClaudeHookConfig();
|
|
2611
|
+
if (!configuredHook) {
|
|
2612
|
+
const hookScriptPath = path.join(HOOKS_DIR, "mailbox-inject.js");
|
|
2613
|
+
console.log(`\nTo enable manually, add to .claude/settings.json:\n`);
|
|
2614
|
+
console.log(`{
|
|
2615
|
+
"hooks": {
|
|
2616
|
+
"UserPromptSubmit": [
|
|
2617
|
+
{
|
|
2618
|
+
"matcher": "",
|
|
2619
|
+
"hooks": [
|
|
2620
|
+
{
|
|
2621
|
+
"type": "command",
|
|
2622
|
+
"command": "node ${hookScriptPath}",
|
|
2623
|
+
"timeout": 5
|
|
2624
|
+
}
|
|
2625
|
+
]
|
|
2626
|
+
}
|
|
2627
|
+
]
|
|
2628
|
+
}
|
|
2629
|
+
}`);
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
function ensureClaudeHookConfig() {
|
|
2634
|
+
const settingsDir = ".claude";
|
|
2635
|
+
const settingsPath = path.join(settingsDir, "settings.json");
|
|
2636
|
+
const hookScriptPath = path.join(HOOKS_DIR, "mailbox-inject.js");
|
|
2637
|
+
const hookCommand = `node ${hookScriptPath}`;
|
|
2638
|
+
|
|
2639
|
+
try {
|
|
2640
|
+
/** @type {ClaudeSettings} */
|
|
2641
|
+
let settings = {};
|
|
2642
|
+
|
|
2643
|
+
// Load existing settings if present
|
|
2644
|
+
if (existsSync(settingsPath)) {
|
|
2645
|
+
const content = readFileSync(settingsPath, "utf-8");
|
|
2646
|
+
settings = JSON.parse(content);
|
|
2647
|
+
} else {
|
|
2648
|
+
// Create directory if needed
|
|
2649
|
+
if (!existsSync(settingsDir)) {
|
|
2650
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// Ensure hooks structure exists
|
|
2655
|
+
if (!settings.hooks) settings.hooks = {};
|
|
2656
|
+
if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
|
|
2657
|
+
|
|
2658
|
+
// Check if our hook is already configured
|
|
2659
|
+
const hookExists = settings.hooks.UserPromptSubmit.some(
|
|
2660
|
+
/** @param {{hooks?: Array<{command: string}>}} entry */
|
|
2661
|
+
(entry) => entry.hooks?.some(/** @param {{command: string}} h */ (h) => h.command === hookCommand)
|
|
2662
|
+
);
|
|
2663
|
+
|
|
2664
|
+
if (hookExists) {
|
|
2665
|
+
return true; // Already configured
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
// Add the hook
|
|
2669
|
+
settings.hooks.UserPromptSubmit.push({
|
|
2670
|
+
matcher: "",
|
|
2671
|
+
hooks: [
|
|
2672
|
+
{
|
|
2673
|
+
type: "command",
|
|
2674
|
+
command: hookCommand,
|
|
2675
|
+
timeout: 5,
|
|
2676
|
+
},
|
|
2677
|
+
],
|
|
2678
|
+
});
|
|
2679
|
+
|
|
2680
|
+
// Write settings
|
|
2681
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
2682
|
+
console.log(`Configured hook in: ${settingsPath}`);
|
|
2683
|
+
return true;
|
|
2684
|
+
} catch {
|
|
2685
|
+
// If we can't configure automatically, return false so manual instructions are shown
|
|
2686
|
+
return false;
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
/**
|
|
2691
|
+
* @param {string | null | undefined} session
|
|
2692
|
+
* @param {{all?: boolean}} [options]
|
|
2693
|
+
*/
|
|
2694
|
+
function cmdKill(session, { all = false } = {}) {
|
|
2695
|
+
// If specific session provided, kill just that one
|
|
2696
|
+
if (session) {
|
|
2697
|
+
if (!tmuxHasSession(session)) {
|
|
2698
|
+
console.log("ERROR: session not found");
|
|
2699
|
+
process.exit(1);
|
|
2700
|
+
}
|
|
2701
|
+
tmuxKill(session);
|
|
2702
|
+
console.log(`Killed: ${session}`);
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
const allSessions = tmuxListSessions();
|
|
2707
|
+
const agentSessions = allSessions.filter((s) => parseSessionName(s));
|
|
2708
|
+
|
|
2709
|
+
if (agentSessions.length === 0) {
|
|
2710
|
+
console.log("No agents running");
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// Filter to current project unless --all specified
|
|
2715
|
+
let sessionsToKill = agentSessions;
|
|
2716
|
+
if (!all) {
|
|
2717
|
+
const currentProject = PROJECT_ROOT;
|
|
2718
|
+
sessionsToKill = agentSessions.filter((s) => {
|
|
2719
|
+
const cwd = getTmuxSessionCwd(s);
|
|
2720
|
+
return cwd && cwd.startsWith(currentProject);
|
|
2721
|
+
});
|
|
2722
|
+
|
|
2723
|
+
if (sessionsToKill.length === 0) {
|
|
2724
|
+
console.log(`No agents running in ${currentProject}`);
|
|
2725
|
+
console.log(`(Use --all to kill all ${agentSessions.length} agent(s) across all projects)`);
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
for (const s of sessionsToKill) {
|
|
2731
|
+
tmuxKill(s);
|
|
2732
|
+
console.log(`Killed: ${s}`);
|
|
2733
|
+
}
|
|
2734
|
+
console.log(`Killed ${sessionsToKill.length} agent(s)`);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
/**
|
|
2738
|
+
* @param {string | null | undefined} session
|
|
2739
|
+
*/
|
|
2740
|
+
function cmdAttach(session) {
|
|
2741
|
+
if (!session) {
|
|
2742
|
+
console.log("ERROR: no session specified. Run 'agents' to list sessions.");
|
|
2743
|
+
process.exit(1);
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// Resolve partial session name
|
|
2747
|
+
const resolved = resolveSessionName(session);
|
|
2748
|
+
if (!resolved || !tmuxHasSession(resolved)) {
|
|
2749
|
+
console.log("ERROR: session not found");
|
|
2750
|
+
process.exit(1);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
// Hand over to tmux attach
|
|
2754
|
+
const result = spawnSync("tmux", ["attach", "-t", resolved], { stdio: "inherit" });
|
|
2755
|
+
process.exit(result.status || 0);
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/**
|
|
2759
|
+
* @param {string | null | undefined} sessionName
|
|
2760
|
+
* @param {{tail?: number, reasoning?: boolean, follow?: boolean}} [options]
|
|
2761
|
+
*/
|
|
2762
|
+
function cmdLog(sessionName, { tail = 50, reasoning = false, follow = false } = {}) {
|
|
2763
|
+
if (!sessionName) {
|
|
2764
|
+
console.log("ERROR: no session specified. Run 'agents' to list sessions.");
|
|
2765
|
+
process.exit(1);
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
// Resolve partial session name
|
|
2769
|
+
const resolved = resolveSessionName(sessionName);
|
|
2770
|
+
if (!resolved) {
|
|
2771
|
+
console.log("ERROR: session not found");
|
|
2772
|
+
process.exit(1);
|
|
2773
|
+
}
|
|
2774
|
+
const parsed = parseSessionName(resolved);
|
|
2775
|
+
if (!parsed) {
|
|
2776
|
+
console.log("ERROR: invalid session name");
|
|
2777
|
+
process.exit(1);
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
const agent = parsed.tool === "claude" ? ClaudeAgent : CodexAgent;
|
|
2781
|
+
const logPath = agent.findLogPath(resolved);
|
|
2782
|
+
if (!logPath || !existsSync(logPath)) {
|
|
2783
|
+
console.log("ERROR: log file not found");
|
|
2784
|
+
process.exit(1);
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
const displayName = resolved;
|
|
2788
|
+
|
|
2789
|
+
// Print initial content
|
|
2790
|
+
let lastLineCount = 0;
|
|
2791
|
+
/** @type {string | null} */
|
|
2792
|
+
let lastTimestamp = null;
|
|
2793
|
+
|
|
2794
|
+
/**
|
|
2795
|
+
* @param {boolean} [isInitial]
|
|
2796
|
+
*/
|
|
2797
|
+
function printLog(isInitial = false) {
|
|
2798
|
+
const content = readFileSync(/** @type {string} */ (logPath), "utf-8");
|
|
2799
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
2800
|
+
|
|
2801
|
+
// Handle log rotation: if file was truncated, reset our position
|
|
2802
|
+
if (lines.length < lastLineCount) {
|
|
2803
|
+
lastLineCount = 0;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
// For initial print, take last N. For follow, take only new lines.
|
|
2807
|
+
const startIdx = isInitial ? Math.max(0, lines.length - tail) : lastLineCount;
|
|
2808
|
+
const newLines = lines.slice(startIdx);
|
|
2809
|
+
lastLineCount = lines.length;
|
|
2810
|
+
|
|
2811
|
+
if (newLines.length === 0) return;
|
|
2812
|
+
|
|
2813
|
+
const entries = newLines.map((line) => {
|
|
2814
|
+
try {
|
|
2815
|
+
return JSON.parse(line);
|
|
2816
|
+
} catch {
|
|
2817
|
+
return null;
|
|
2818
|
+
}
|
|
2819
|
+
}).filter(Boolean);
|
|
2820
|
+
|
|
2821
|
+
const output = [];
|
|
2822
|
+
if (isInitial) {
|
|
2823
|
+
output.push(`## ${displayName}\n`);
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
for (const entry of /** @type {any[]} */ (entries)) {
|
|
2827
|
+
const formatted = formatLogEntry(entry, { reasoning });
|
|
2828
|
+
if (formatted) {
|
|
2829
|
+
const ts = entry.timestamp || entry.ts || entry.createdAt;
|
|
2830
|
+
if (ts && ts !== lastTimestamp) {
|
|
2831
|
+
const date = new Date(ts);
|
|
2832
|
+
const timeStr = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
|
2833
|
+
if (formatted.isUserMessage) {
|
|
2834
|
+
output.push(`\n### ${timeStr}\n`);
|
|
2835
|
+
}
|
|
2836
|
+
lastTimestamp = ts;
|
|
2837
|
+
}
|
|
2838
|
+
output.push(formatted.text);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
if (output.length > 0) {
|
|
2843
|
+
console.log(output.join("\n"));
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
// Print initial content
|
|
2848
|
+
printLog(true);
|
|
2849
|
+
|
|
2850
|
+
if (!follow) return;
|
|
2851
|
+
|
|
2852
|
+
// Watch for changes
|
|
2853
|
+
const watcher = watch(logPath, () => {
|
|
2854
|
+
printLog(false);
|
|
2855
|
+
});
|
|
2856
|
+
|
|
2857
|
+
// Handle exit
|
|
2858
|
+
process.on("SIGINT", () => {
|
|
2859
|
+
watcher.close();
|
|
2860
|
+
process.exit(0);
|
|
2861
|
+
});
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
/**
|
|
2865
|
+
* @param {any} entry
|
|
2866
|
+
* @param {{reasoning?: boolean}} [options]
|
|
2867
|
+
* @returns {{text: string, isUserMessage: boolean} | null}
|
|
2868
|
+
*/
|
|
2869
|
+
function formatLogEntry(entry, { reasoning = false } = {}) {
|
|
2870
|
+
// Handle different log formats (Claude, Codex)
|
|
2871
|
+
|
|
2872
|
+
// Claude Code format: { type: "user" | "assistant", message: { content: ... } }
|
|
2873
|
+
// Codex format: { role: "user" | "assistant", content: ... }
|
|
2874
|
+
|
|
2875
|
+
const type = entry.type || entry.role;
|
|
2876
|
+
const message = entry.message || entry;
|
|
2877
|
+
const content = message.content;
|
|
2878
|
+
|
|
2879
|
+
if (type === "user" || type === "human") {
|
|
2880
|
+
const text = extractTextContent(content);
|
|
2881
|
+
if (text) {
|
|
2882
|
+
return { text: `**User**: ${truncate(text, TRUNCATE_USER_LEN)}\n`, isUserMessage: true };
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
if (type === "assistant") {
|
|
2887
|
+
const parts = [];
|
|
2888
|
+
|
|
2889
|
+
// Extract text response
|
|
2890
|
+
const text = extractTextContent(content);
|
|
2891
|
+
if (text) {
|
|
2892
|
+
parts.push(`**Assistant**: ${text}\n`);
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// Extract tool calls (compressed)
|
|
2896
|
+
const tools = extractToolCalls(content);
|
|
2897
|
+
if (tools.length > 0) {
|
|
2898
|
+
const toolSummary = tools.map((t) => {
|
|
2899
|
+
if (t.error) return `${t.name}(${t.target}) ✗`;
|
|
2900
|
+
return `${t.name}(${t.target})`;
|
|
2901
|
+
}).join(", ");
|
|
2902
|
+
parts.push(`> ${toolSummary}\n`);
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
// Extract thinking/reasoning if requested
|
|
2906
|
+
if (reasoning) {
|
|
2907
|
+
const thinking = extractThinking(content);
|
|
2908
|
+
if (thinking) {
|
|
2909
|
+
parts.push(`> *Thinking*: ${truncate(thinking, TRUNCATE_THINKING_LEN)}\n`);
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
if (parts.length > 0) {
|
|
2914
|
+
return { text: parts.join(""), isUserMessage: false };
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// Handle tool results with errors
|
|
2919
|
+
if (type === "tool_result" || type === "tool") {
|
|
2920
|
+
const error = entry.error || entry.is_error;
|
|
2921
|
+
if (error) {
|
|
2922
|
+
const name = entry.tool_name || entry.name || "tool";
|
|
2923
|
+
return { text: `> ${name} ✗ (${truncate(String(error), 100)})\n`, isUserMessage: false };
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
return null;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
/**
|
|
2931
|
+
* @param {any} content
|
|
2932
|
+
* @returns {string | null}
|
|
2933
|
+
*/
|
|
2934
|
+
function extractTextContent(content) {
|
|
2935
|
+
if (typeof content === "string") return content;
|
|
2936
|
+
if (Array.isArray(content)) {
|
|
2937
|
+
const textParts = content
|
|
2938
|
+
.filter((c) => c.type === "text")
|
|
2939
|
+
.map((c) => c.text)
|
|
2940
|
+
.filter(Boolean);
|
|
2941
|
+
return textParts.join("\n");
|
|
2942
|
+
}
|
|
2943
|
+
if (content?.text) return content.text;
|
|
2944
|
+
return null;
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
/**
|
|
2948
|
+
* @param {any} content
|
|
2949
|
+
* @returns {{name: string, target: string, error?: any}[]}
|
|
2950
|
+
*/
|
|
2951
|
+
function extractToolCalls(content) {
|
|
2952
|
+
if (!Array.isArray(content)) return [];
|
|
2953
|
+
|
|
2954
|
+
return content
|
|
2955
|
+
.filter((c) => c.type === "tool_use" || c.type === "tool_call")
|
|
2956
|
+
.map((c) => {
|
|
2957
|
+
const name = c.name || c.tool || "tool";
|
|
2958
|
+
const input = c.input || c.arguments || {};
|
|
2959
|
+
// Extract a reasonable target from the input
|
|
2960
|
+
const target = input.file_path || input.path || input.command?.slice(0, 30) || input.pattern || "";
|
|
2961
|
+
const shortTarget = target.split("/").pop() || target.slice(0, 20);
|
|
2962
|
+
return { name, target: shortTarget, error: c.error };
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
/**
|
|
2967
|
+
* @param {any} content
|
|
2968
|
+
* @returns {string | null}
|
|
2969
|
+
*/
|
|
2970
|
+
function extractThinking(content) {
|
|
2971
|
+
if (Array.isArray(content)) {
|
|
2972
|
+
const thinking = content.find((c) => c.type === "thinking");
|
|
2973
|
+
if (thinking) return thinking.thinking || thinking.text;
|
|
2974
|
+
}
|
|
2975
|
+
return null;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
/**
|
|
2979
|
+
* @param {string} str
|
|
2980
|
+
* @param {number} maxLen
|
|
2981
|
+
* @returns {string}
|
|
2982
|
+
*/
|
|
2983
|
+
function truncate(str, maxLen) {
|
|
2984
|
+
if (!str) return "";
|
|
2985
|
+
if (str.length <= maxLen) return str;
|
|
2986
|
+
return str.slice(0, maxLen) + "...";
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* @param {{limit?: number, branch?: string | null, all?: boolean}} [options]
|
|
2991
|
+
*/
|
|
2992
|
+
function cmdMailbox({ limit = 20, branch = null, all = false } = {}) {
|
|
2993
|
+
const maxAge = all ? Infinity : MAILBOX_MAX_AGE_MS;
|
|
2994
|
+
const entries = readMailbox({ maxAge, branch, limit });
|
|
2995
|
+
|
|
2996
|
+
if (entries.length === 0) {
|
|
2997
|
+
console.log("No mailbox entries" + (branch ? ` for branch '${branch}'` : ""));
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
console.log("## Mailbox\n");
|
|
3002
|
+
|
|
3003
|
+
for (const entry of entries) {
|
|
3004
|
+
const ts = new Date(entry.timestamp);
|
|
3005
|
+
const timeStr = ts.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
|
3006
|
+
const dateStr = ts.toLocaleDateString("en-GB", { month: "short", day: "numeric" });
|
|
3007
|
+
const p = entry.payload || {};
|
|
3008
|
+
|
|
3009
|
+
console.log(`### [${p.agent || "unknown"}] ${dateStr} ${timeStr}\n`);
|
|
3010
|
+
|
|
3011
|
+
if (p.branch || p.commit) {
|
|
3012
|
+
console.log(`**Branch**: ${p.branch || "?"} @ ${p.commit || "?"}\n`);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
if (p.message) {
|
|
3016
|
+
console.log(`**Assistant**: ${p.message}\n`);
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
if (p.files?.length > 0) {
|
|
3020
|
+
const fileList = p.files.map((f) => f.split("/").pop()).join(", ");
|
|
3021
|
+
const more = p.files.length > 5 ? ` (+${p.files.length - 5} more)` : "";
|
|
3022
|
+
console.log(`> Read(${fileList}${more})\n`);
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
console.log("---\n");
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
/**
|
|
3030
|
+
* @param {Agent} agent
|
|
3031
|
+
* @param {string | null | undefined} session
|
|
3032
|
+
* @param {string} message
|
|
3033
|
+
* @param {{noWait?: boolean, yolo?: boolean, timeoutMs?: number}} [options]
|
|
3034
|
+
*/
|
|
3035
|
+
async function cmdAsk(agent, session, message, { noWait = false, yolo = false, timeoutMs } = {}) {
|
|
3036
|
+
const sessionExists = session != null && tmuxHasSession(session);
|
|
3037
|
+
const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
|
|
3038
|
+
|
|
3039
|
+
// Cannot use --yolo --no-wait on a safe session: we need to stay and auto-approve
|
|
3040
|
+
if (yolo && noWait && sessionExists && !nativeYolo) {
|
|
3041
|
+
console.log("ERROR: --yolo requires waiting on a session not started with --yolo");
|
|
3042
|
+
console.log("Restart the session with --yolo, or allow waiting for auto-approval");
|
|
3043
|
+
process.exit(1);
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
/** @type {string} */
|
|
3047
|
+
const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
|
|
3048
|
+
|
|
3049
|
+
tmuxSendLiteral(activeSession, message);
|
|
3050
|
+
await sleep(50);
|
|
3051
|
+
tmuxSend(activeSession, "Enter");
|
|
3052
|
+
|
|
3053
|
+
if (noWait) return;
|
|
3054
|
+
|
|
3055
|
+
// Yolo mode on a safe session: auto-approve until done
|
|
3056
|
+
const useAutoApprove = yolo && !nativeYolo;
|
|
3057
|
+
|
|
3058
|
+
const { state, screen } = useAutoApprove
|
|
3059
|
+
? await autoApproveLoop(agent, activeSession, timeoutMs)
|
|
3060
|
+
: await waitForResponse(agent, activeSession, timeoutMs);
|
|
3061
|
+
|
|
3062
|
+
if (state === State.RATE_LIMITED) {
|
|
3063
|
+
console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
|
|
3064
|
+
process.exit(2);
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
if (state === State.CONFIRMING) {
|
|
3068
|
+
console.log(`CONFIRM: ${agent.parseAction(screen)}`);
|
|
3069
|
+
process.exit(3);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
const output = agent.getResponse(activeSession, screen);
|
|
3073
|
+
if (output) {
|
|
3074
|
+
console.log(output);
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
/**
|
|
3079
|
+
* @param {Agent} agent
|
|
3080
|
+
* @param {string | null | undefined} session
|
|
3081
|
+
* @param {{wait?: boolean, timeoutMs?: number}} [options]
|
|
3082
|
+
*/
|
|
3083
|
+
async function cmdApprove(agent, session, { wait = false, timeoutMs } = {}) {
|
|
3084
|
+
if (!session || !tmuxHasSession(session)) {
|
|
3085
|
+
console.log("ERROR: no session");
|
|
3086
|
+
process.exit(1);
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
const before = tmuxCapture(session);
|
|
3090
|
+
if (agent.getState(before) !== State.CONFIRMING) {
|
|
3091
|
+
console.log("ERROR: not confirming");
|
|
3092
|
+
process.exit(1);
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
tmuxSend(session, agent.approveKey);
|
|
3096
|
+
|
|
3097
|
+
if (!wait) return;
|
|
3098
|
+
|
|
3099
|
+
const { state, screen } = await waitForResponse(agent, session, timeoutMs);
|
|
3100
|
+
|
|
3101
|
+
if (state === State.RATE_LIMITED) {
|
|
3102
|
+
console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
|
|
3103
|
+
process.exit(2);
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
if (state === State.CONFIRMING) {
|
|
3107
|
+
console.log(`CONFIRM: ${agent.parseAction(screen)}`);
|
|
3108
|
+
process.exit(3);
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
const response = agent.getResponse(session, screen);
|
|
3112
|
+
console.log(response || "");
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
/**
|
|
3116
|
+
* @param {Agent} agent
|
|
3117
|
+
* @param {string | null | undefined} session
|
|
3118
|
+
* @param {{wait?: boolean, timeoutMs?: number}} [options]
|
|
3119
|
+
*/
|
|
3120
|
+
async function cmdReject(agent, session, { wait = false, timeoutMs } = {}) {
|
|
3121
|
+
if (!session || !tmuxHasSession(session)) {
|
|
3122
|
+
console.log("ERROR: no session");
|
|
3123
|
+
process.exit(1);
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
tmuxSend(session, agent.rejectKey);
|
|
3127
|
+
|
|
3128
|
+
if (!wait) return;
|
|
3129
|
+
|
|
3130
|
+
const { state, screen } = await waitForResponse(agent, session, timeoutMs);
|
|
3131
|
+
|
|
3132
|
+
if (state === State.RATE_LIMITED) {
|
|
3133
|
+
console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
|
|
3134
|
+
process.exit(2);
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
const response = agent.getResponse(session, screen);
|
|
3138
|
+
console.log(response || "");
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
/**
|
|
3142
|
+
* @param {Agent} agent
|
|
3143
|
+
* @param {string | null | undefined} session
|
|
3144
|
+
* @param {string | null | undefined} option
|
|
3145
|
+
* @param {string | null | undefined} customInstructions
|
|
3146
|
+
* @param {{wait?: boolean, yolo?: boolean, fresh?: boolean, timeoutMs?: number}} [options]
|
|
3147
|
+
*/
|
|
3148
|
+
async function cmdReview(agent, session, option, customInstructions, { wait = true, yolo = true, fresh = false, timeoutMs = REVIEW_TIMEOUT_MS } = {}) {
|
|
3149
|
+
const sessionExists = session != null && tmuxHasSession(session);
|
|
3150
|
+
|
|
3151
|
+
// Reset conversation if --fresh and session exists
|
|
3152
|
+
if (fresh && sessionExists) {
|
|
3153
|
+
tmuxSendLiteral(/** @type {string} */ (session), "/new");
|
|
3154
|
+
await sleep(50);
|
|
3155
|
+
tmuxSend(/** @type {string} */ (session), "Enter");
|
|
3156
|
+
await waitUntilReady(agent, /** @type {string} */ (session), STARTUP_TIMEOUT_MS);
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
// Claude: use prompt-based review (no /review command)
|
|
3160
|
+
if (!agent.reviewOptions) {
|
|
3161
|
+
/** @type {Record<string, string>} */
|
|
3162
|
+
const reviewPrompts = {
|
|
3163
|
+
pr: "Review the current PR.",
|
|
3164
|
+
uncommitted: "Review uncommitted changes.",
|
|
3165
|
+
commit: "Review the most recent git commit.",
|
|
3166
|
+
custom: customInstructions || "Review the code.",
|
|
3167
|
+
};
|
|
3168
|
+
const prompt = (option && reviewPrompts[option]) || reviewPrompts.commit;
|
|
3169
|
+
return cmdAsk(agent, session, prompt, { noWait: !wait, yolo, timeoutMs });
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
// AX_REVIEW_MODE=exec: bypass /review command, send instructions directly
|
|
3173
|
+
if (process.env.AX_REVIEW_MODE === "exec" && option === "custom" && customInstructions) {
|
|
3174
|
+
return cmdAsk(agent, session, customInstructions, { noWait: !wait, yolo, timeoutMs });
|
|
3175
|
+
}
|
|
3176
|
+
const nativeYolo = sessionExists && isYoloSession(/** @type {string} */ (session));
|
|
3177
|
+
|
|
3178
|
+
// Cannot use --yolo without --wait on a safe session: we need to stay and auto-approve
|
|
3179
|
+
if (yolo && !wait && sessionExists && !nativeYolo) {
|
|
3180
|
+
console.log("ERROR: --yolo requires waiting on a session not started with --yolo");
|
|
3181
|
+
console.log("Restart the session with --yolo, or allow waiting for auto-approval");
|
|
3182
|
+
process.exit(1);
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
/** @type {string} */
|
|
3186
|
+
const activeSession = sessionExists ? /** @type {string} */ (session) : await cmdStart(agent, session, { yolo });
|
|
3187
|
+
|
|
3188
|
+
tmuxSendLiteral(activeSession, "/review");
|
|
3189
|
+
await sleep(50);
|
|
3190
|
+
tmuxSend(activeSession, "Enter");
|
|
3191
|
+
|
|
3192
|
+
await waitFor(activeSession, (s) => s.includes("Select a review preset") || s.includes("review"));
|
|
3193
|
+
|
|
3194
|
+
if (option) {
|
|
3195
|
+
const key = agent.reviewOptions[option] || option;
|
|
3196
|
+
tmuxSend(activeSession, key);
|
|
3197
|
+
|
|
3198
|
+
if (option === "custom" && customInstructions) {
|
|
3199
|
+
await waitFor(activeSession, (s) => s.includes("custom") || s.includes("instructions"));
|
|
3200
|
+
tmuxSendLiteral(activeSession, customInstructions);
|
|
3201
|
+
await sleep(50);
|
|
3202
|
+
tmuxSend(activeSession, "Enter");
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
if (!wait) return;
|
|
3207
|
+
|
|
3208
|
+
// Yolo mode on a safe session: auto-approve until done
|
|
3209
|
+
const useAutoApprove = yolo && !nativeYolo;
|
|
3210
|
+
|
|
3211
|
+
const { state, screen } = useAutoApprove
|
|
3212
|
+
? await autoApproveLoop(agent, activeSession, timeoutMs)
|
|
3213
|
+
: await waitForResponse(agent, activeSession, timeoutMs);
|
|
3214
|
+
|
|
3215
|
+
if (state === State.RATE_LIMITED) {
|
|
3216
|
+
console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
|
|
3217
|
+
process.exit(2);
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
if (state === State.CONFIRMING) {
|
|
3221
|
+
console.log(`CONFIRM: ${agent.parseAction(screen)}`);
|
|
3222
|
+
process.exit(3);
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
const response = agent.getResponse(activeSession, screen);
|
|
3226
|
+
console.log(response || "");
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
/**
|
|
3230
|
+
* @param {Agent} agent
|
|
3231
|
+
* @param {string | null | undefined} session
|
|
3232
|
+
* @param {number} [index]
|
|
3233
|
+
* @param {{wait?: boolean, timeoutMs?: number}} [options]
|
|
3234
|
+
*/
|
|
3235
|
+
async function cmdOutput(agent, session, index = 0, { wait = false, timeoutMs } = {}) {
|
|
3236
|
+
if (!session || !tmuxHasSession(session)) {
|
|
3237
|
+
console.log("ERROR: no session");
|
|
3238
|
+
process.exit(1);
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
let screen;
|
|
3242
|
+
if (wait) {
|
|
3243
|
+
const result = await waitUntilReady(agent, session, timeoutMs);
|
|
3244
|
+
screen = result.screen;
|
|
3245
|
+
} else {
|
|
3246
|
+
screen = tmuxCapture(session, 500);
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
const state = agent.getState(screen);
|
|
3250
|
+
|
|
3251
|
+
if (state === State.RATE_LIMITED) {
|
|
3252
|
+
console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
|
|
3253
|
+
process.exit(2);
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
if (state === State.CONFIRMING) {
|
|
3257
|
+
console.log(`CONFIRM: ${agent.parseAction(screen)}`);
|
|
3258
|
+
process.exit(3);
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
if (state === State.THINKING) {
|
|
3262
|
+
console.log("THINKING");
|
|
3263
|
+
process.exit(4);
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
const output = agent.getResponse(session, screen, index);
|
|
3267
|
+
if (output) {
|
|
3268
|
+
console.log(output);
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
/**
|
|
3273
|
+
* @param {Agent} agent
|
|
3274
|
+
* @param {string | null | undefined} session
|
|
3275
|
+
*/
|
|
3276
|
+
function cmdStatus(agent, session) {
|
|
3277
|
+
if (!session || !tmuxHasSession(session)) {
|
|
3278
|
+
console.log("NO_SESSION");
|
|
3279
|
+
process.exit(1);
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
const screen = tmuxCapture(session);
|
|
3283
|
+
const state = agent.getState(screen);
|
|
3284
|
+
|
|
3285
|
+
if (state === State.RATE_LIMITED) {
|
|
3286
|
+
console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
|
|
3287
|
+
process.exit(2);
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
if (state === State.CONFIRMING) {
|
|
3291
|
+
console.log(`CONFIRM: ${agent.parseAction(screen)}`);
|
|
3292
|
+
process.exit(3);
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
if (state === State.THINKING) {
|
|
3296
|
+
console.log("THINKING");
|
|
3297
|
+
process.exit(4);
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
/**
|
|
3302
|
+
* @param {Agent} agent
|
|
3303
|
+
* @param {string | null | undefined} session
|
|
3304
|
+
* @param {{scrollback?: number}} [options]
|
|
3305
|
+
*/
|
|
3306
|
+
function cmdDebug(agent, session, { scrollback = 0 } = {}) {
|
|
3307
|
+
if (!session || !tmuxHasSession(session)) {
|
|
3308
|
+
console.log("ERROR: no session");
|
|
3309
|
+
process.exit(1);
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
const screen = tmuxCapture(session, scrollback);
|
|
3313
|
+
const state = agent.getState(screen);
|
|
3314
|
+
|
|
3315
|
+
console.log(`=== Session: ${session} ===`);
|
|
3316
|
+
console.log(`=== State: ${state} ===`);
|
|
3317
|
+
console.log(`=== Screen ===`);
|
|
3318
|
+
console.log(screen);
|
|
3319
|
+
}
|
|
3320
|
+
/**
|
|
3321
|
+
* @param {string} input
|
|
3322
|
+
* @returns {{type: 'literal' | 'key', value: string}[]}
|
|
3323
|
+
*/
|
|
3324
|
+
function parseKeySequence(input) {
|
|
3325
|
+
// Parse a string like "1[Enter]" or "[Escape]hello[C-c]" into a sequence of actions
|
|
3326
|
+
// [Enter], [Escape], [Up], [Down], [Tab], [C-c], etc. are special keys
|
|
3327
|
+
// Everything else is literal text
|
|
3328
|
+
/** @type {{type: 'literal' | 'key', value: string}[]} */
|
|
3329
|
+
const parts = [];
|
|
3330
|
+
let i = 0;
|
|
3331
|
+
let literal = "";
|
|
3332
|
+
|
|
3333
|
+
while (i < input.length) {
|
|
3334
|
+
if (input[i] === "[") {
|
|
3335
|
+
// Flush any accumulated literal text
|
|
3336
|
+
if (literal) {
|
|
3337
|
+
parts.push({ type: "literal", value: literal });
|
|
3338
|
+
literal = "";
|
|
3339
|
+
}
|
|
3340
|
+
// Find the closing bracket
|
|
3341
|
+
const end = input.indexOf("]", i);
|
|
3342
|
+
if (end === -1) {
|
|
3343
|
+
// No closing bracket, treat as literal
|
|
3344
|
+
literal += input[i];
|
|
3345
|
+
i++;
|
|
3346
|
+
} else {
|
|
3347
|
+
const key = input.slice(i + 1, end);
|
|
3348
|
+
// Skip empty brackets, but allow any non-empty key (let tmux validate)
|
|
3349
|
+
if (key) {
|
|
3350
|
+
parts.push({ type: "key", value: key });
|
|
3351
|
+
}
|
|
3352
|
+
i = end + 1;
|
|
3353
|
+
}
|
|
3354
|
+
} else {
|
|
3355
|
+
literal += input[i];
|
|
3356
|
+
i++;
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
// Flush remaining literal text
|
|
3361
|
+
if (literal) {
|
|
3362
|
+
parts.push({ type: "literal", value: literal });
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
return parts;
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
/**
|
|
3369
|
+
* @param {string | null | undefined} session
|
|
3370
|
+
* @param {string} input
|
|
3371
|
+
*/
|
|
3372
|
+
function cmdSend(session, input) {
|
|
3373
|
+
if (!session || !tmuxHasSession(session)) {
|
|
3374
|
+
console.log("ERROR: no session");
|
|
3375
|
+
process.exit(1);
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
const parts = parseKeySequence(input);
|
|
3379
|
+
for (const part of parts) {
|
|
3380
|
+
if (part.type === "literal") {
|
|
3381
|
+
tmuxSendLiteral(session, part.value);
|
|
3382
|
+
} else {
|
|
3383
|
+
tmuxSend(session, part.value);
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
/**
|
|
3389
|
+
* @param {Agent} agent
|
|
3390
|
+
* @param {string | null | undefined} session
|
|
3391
|
+
* @param {string | number} n
|
|
3392
|
+
* @param {{wait?: boolean, timeoutMs?: number}} [options]
|
|
3393
|
+
*/
|
|
3394
|
+
async function cmdSelect(agent, session, n, { wait = false, timeoutMs } = {}) {
|
|
3395
|
+
if (!session || !tmuxHasSession(session)) {
|
|
3396
|
+
console.log("ERROR: no session");
|
|
3397
|
+
process.exit(1);
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
tmuxSend(session, n.toString());
|
|
3401
|
+
|
|
3402
|
+
if (!wait) return;
|
|
3403
|
+
|
|
3404
|
+
const { state, screen } = await waitForResponse(agent, session, timeoutMs);
|
|
3405
|
+
|
|
3406
|
+
if (state === State.RATE_LIMITED) {
|
|
3407
|
+
console.log(`RATE_LIMITED: ${agent.parseRetryTime(screen)}`);
|
|
3408
|
+
process.exit(2);
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
if (state === State.CONFIRMING) {
|
|
3412
|
+
console.log(`CONFIRM: ${agent.parseAction(screen)}`);
|
|
3413
|
+
process.exit(3);
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
const response = agent.getResponse(session, screen);
|
|
3417
|
+
console.log(response || "");
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
// =============================================================================
|
|
3421
|
+
// CLI
|
|
3422
|
+
// =============================================================================
|
|
3423
|
+
|
|
3424
|
+
/**
|
|
3425
|
+
* @returns {Agent}
|
|
3426
|
+
*/
|
|
3427
|
+
function getAgentFromInvocation() {
|
|
3428
|
+
const invoked = path.basename(process.argv[1], ".js");
|
|
3429
|
+
if (invoked === "axclaude" || invoked === "claude") return ClaudeAgent;
|
|
3430
|
+
if (invoked === "axcodex" || invoked === "codex") return CodexAgent;
|
|
3431
|
+
|
|
3432
|
+
// Default based on AX_DEFAULT_TOOL env var, or codex if not set
|
|
3433
|
+
const defaultTool = process.env.AX_DEFAULT_TOOL;
|
|
3434
|
+
if (defaultTool === "claude") return ClaudeAgent;
|
|
3435
|
+
if (defaultTool === "codex" || !defaultTool) return CodexAgent;
|
|
3436
|
+
|
|
3437
|
+
console.error(`WARNING: invalid AX_DEFAULT_TOOL="${defaultTool}", using codex`);
|
|
3438
|
+
return CodexAgent;
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
/**
|
|
3442
|
+
* @param {Agent} agent
|
|
3443
|
+
* @param {string} cliName
|
|
3444
|
+
*/
|
|
3445
|
+
function printHelp(agent, cliName) {
|
|
3446
|
+
const name = cliName;
|
|
3447
|
+
const backendName = agent.name === "codex" ? "OpenAI Codex" : "Claude";
|
|
3448
|
+
const hasReview = !!agent.reviewOptions;
|
|
3449
|
+
|
|
3450
|
+
console.log(`${name}.js - agentic assistant CLI (${backendName})
|
|
3451
|
+
|
|
3452
|
+
Commands:
|
|
3453
|
+
agents List all running agents with state and log paths
|
|
3454
|
+
attach [SESSION] Attach to agent session interactively
|
|
3455
|
+
log SESSION View conversation log (--tail=N, --follow, --reasoning)
|
|
3456
|
+
mailbox View daemon observations (--limit=N, --branch=X, --all)
|
|
3457
|
+
daemons start [name] Start daemon agents (all, or by name)
|
|
3458
|
+
daemons stop [name] Stop daemon agents (all, or by name)
|
|
3459
|
+
kill Kill sessions in current project (--all for all, --session=NAME for one)
|
|
3460
|
+
status Check state (exit: 0=ready, 2=rate_limited, 3=confirming, 4=thinking)
|
|
3461
|
+
output [-N] Show response (0=last, -1=prev, -2=older)
|
|
3462
|
+
debug Show raw screen output and detected state${hasReview ? `
|
|
3463
|
+
review [TYPE] Review code: pr, uncommitted, commit, custom` : ""}
|
|
3464
|
+
select N Select menu option N
|
|
3465
|
+
approve Approve pending action (send 'y')
|
|
3466
|
+
reject Reject pending action (send 'n')
|
|
3467
|
+
send KEYS Send key sequence (e.g. "1[Enter]", "[Escape]")
|
|
3468
|
+
compact Summarize conversation (when context is full)
|
|
3469
|
+
reset Start fresh conversation
|
|
3470
|
+
<message> Send message to ${name}
|
|
3471
|
+
|
|
3472
|
+
Flags:
|
|
3473
|
+
--tool=NAME Use specific agent (codex, claude)
|
|
3474
|
+
--session=NAME Target session by name, daemon name, or UUID prefix (self = current)
|
|
3475
|
+
--wait Wait for response (for review, approve, etc)
|
|
3476
|
+
--no-wait Don't wait (for messages, which wait by default)
|
|
3477
|
+
--timeout=N Set timeout in seconds (default: 120)
|
|
3478
|
+
--yolo Skip all confirmations (dangerous)
|
|
3479
|
+
--fresh Reset conversation before review
|
|
3480
|
+
|
|
3481
|
+
Environment:
|
|
3482
|
+
AX_DEFAULT_TOOL Default agent when using 'ax' (claude or codex, default: codex)
|
|
3483
|
+
${agent.envVar} Override default session name
|
|
3484
|
+
AX_CLAUDE_CONFIG_DIR Override Claude config directory (default: ~/.claude)
|
|
3485
|
+
AX_CODEX_CONFIG_DIR Override Codex config directory (default: ~/.codex)
|
|
3486
|
+
AX_REVIEW_MODE=exec Bypass /review, send instructions directly (codex only)
|
|
3487
|
+
AX_DEBUG=1 Enable debug logging
|
|
3488
|
+
|
|
3489
|
+
Examples:
|
|
3490
|
+
./${name}.js "explain this codebase"
|
|
3491
|
+
./${name}.js "please review the error handling" # Auto custom review
|
|
3492
|
+
./${name}.js review uncommitted --wait
|
|
3493
|
+
./${name}.js approve --wait
|
|
3494
|
+
./${name}.js kill # Kill agents in current project
|
|
3495
|
+
./${name}.js kill --all # Kill all agents across all projects
|
|
3496
|
+
./${name}.js kill --session=NAME # Kill specific session
|
|
3497
|
+
./${name}.js send "1[Enter]" # Recovery: select option 1 and press Enter
|
|
3498
|
+
./${name}.js send "[Escape][Escape]" # Recovery: escape out of a dialog
|
|
3499
|
+
|
|
3500
|
+
Daemon Agents:
|
|
3501
|
+
./${name}.js daemons start # Start all daemons from .ai/agents/*.md
|
|
3502
|
+
./${name}.js daemons stop # Stop all daemons
|
|
3503
|
+
./${name}.js daemons init <name> # Create new daemon config
|
|
3504
|
+
./${name}.js agents # List all agents (shows TYPE=daemon)`);
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
async function main() {
|
|
3508
|
+
// Check tmux is installed
|
|
3509
|
+
const tmuxCheck = spawnSync("tmux", ["-V"], { encoding: "utf-8" });
|
|
3510
|
+
if (tmuxCheck.error || tmuxCheck.status !== 0) {
|
|
3511
|
+
console.error("ERROR: tmux is not installed or not in PATH");
|
|
3512
|
+
console.error("Install with: brew install tmux (macOS) or apt install tmux (Linux)");
|
|
3513
|
+
process.exit(1);
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
const args = process.argv.slice(2);
|
|
3517
|
+
const cliName = path.basename(process.argv[1], ".js");
|
|
3518
|
+
|
|
3519
|
+
// Parse flags
|
|
3520
|
+
const wait = args.includes("--wait");
|
|
3521
|
+
const noWait = args.includes("--no-wait");
|
|
3522
|
+
const yolo = args.includes("--yolo");
|
|
3523
|
+
const fresh = args.includes("--fresh");
|
|
3524
|
+
const reasoning = args.includes("--reasoning");
|
|
3525
|
+
const follow = args.includes("--follow") || args.includes("-f");
|
|
3526
|
+
|
|
3527
|
+
// Agent selection
|
|
3528
|
+
let agent = getAgentFromInvocation();
|
|
3529
|
+
const toolArg = args.find((a) => a.startsWith("--tool="));
|
|
3530
|
+
if (toolArg) {
|
|
3531
|
+
const tool = toolArg.split("=")[1];
|
|
3532
|
+
if (tool === "claude") agent = ClaudeAgent;
|
|
3533
|
+
else if (tool === "codex") agent = CodexAgent;
|
|
3534
|
+
else {
|
|
3535
|
+
console.log(`ERROR: unknown tool '${tool}'`);
|
|
3536
|
+
process.exit(1);
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
// Session resolution
|
|
3541
|
+
let session = agent.getDefaultSession();
|
|
3542
|
+
const sessionArg = args.find((a) => a.startsWith("--session="));
|
|
3543
|
+
if (sessionArg) {
|
|
3544
|
+
const val = sessionArg.split("=")[1];
|
|
3545
|
+
if (val === "self") {
|
|
3546
|
+
const current = tmuxCurrentSession();
|
|
3547
|
+
if (!current) {
|
|
3548
|
+
console.log("ERROR: --session=self requires running inside tmux");
|
|
3549
|
+
process.exit(1);
|
|
3550
|
+
}
|
|
3551
|
+
session = current;
|
|
3552
|
+
} else {
|
|
3553
|
+
// Resolve partial names, daemon names, and UUID prefixes
|
|
3554
|
+
session = resolveSessionName(val);
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
// Timeout
|
|
3559
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
3560
|
+
const timeoutArg = args.find((a) => a.startsWith("--timeout="));
|
|
3561
|
+
if (timeoutArg) {
|
|
3562
|
+
const val = parseInt(timeoutArg.split("=")[1], 10);
|
|
3563
|
+
if (isNaN(val) || val <= 0) {
|
|
3564
|
+
console.log("ERROR: invalid timeout");
|
|
3565
|
+
process.exit(1);
|
|
3566
|
+
}
|
|
3567
|
+
timeoutMs = val * 1000;
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
// Tail (for log command)
|
|
3571
|
+
let tail = 50;
|
|
3572
|
+
const tailArg = args.find((a) => a.startsWith("--tail="));
|
|
3573
|
+
if (tailArg) {
|
|
3574
|
+
tail = parseInt(tailArg.split("=")[1], 10) || 50;
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
// Limit (for mailbox command)
|
|
3578
|
+
let limit = 20;
|
|
3579
|
+
const limitArg = args.find((a) => a.startsWith("--limit="));
|
|
3580
|
+
if (limitArg) {
|
|
3581
|
+
limit = parseInt(limitArg.split("=")[1], 10) || 20;
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
// Branch filter (for mailbox command)
|
|
3585
|
+
let branch = null;
|
|
3586
|
+
const branchArg = args.find((a) => a.startsWith("--branch="));
|
|
3587
|
+
if (branchArg) {
|
|
3588
|
+
branch = branchArg.split("=")[1] || null;
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
// All flag (for mailbox command - show all regardless of age)
|
|
3592
|
+
const all = args.includes("--all");
|
|
3593
|
+
|
|
3594
|
+
// Filter out flags
|
|
3595
|
+
const filteredArgs = args.filter(
|
|
3596
|
+
(a) =>
|
|
3597
|
+
!["--wait", "--no-wait", "--yolo", "--reasoning", "--follow", "-f", "--all"].includes(a) &&
|
|
3598
|
+
!a.startsWith("--timeout") &&
|
|
3599
|
+
!a.startsWith("--session") &&
|
|
3600
|
+
!a.startsWith("--tool") &&
|
|
3601
|
+
!a.startsWith("--tail") &&
|
|
3602
|
+
!a.startsWith("--limit") &&
|
|
3603
|
+
!a.startsWith("--branch")
|
|
3604
|
+
);
|
|
3605
|
+
const cmd = filteredArgs[0];
|
|
3606
|
+
|
|
3607
|
+
// Dispatch commands
|
|
3608
|
+
if (cmd === "agents") return cmdAgents();
|
|
3609
|
+
if (cmd === "daemons") return cmdDaemons(filteredArgs[1], filteredArgs[2]);
|
|
3610
|
+
if (cmd === "daemon") return cmdDaemon(filteredArgs[1]);
|
|
3611
|
+
if (cmd === "kill") return cmdKill(session, { all });
|
|
3612
|
+
if (cmd === "attach") return cmdAttach(filteredArgs[1] || session);
|
|
3613
|
+
if (cmd === "log") return cmdLog(filteredArgs[1] || session, { tail, reasoning, follow });
|
|
3614
|
+
if (cmd === "mailbox") return cmdMailbox({ limit, branch, all });
|
|
3615
|
+
if (cmd === "approve") return cmdApprove(agent, session, { wait, timeoutMs });
|
|
3616
|
+
if (cmd === "reject") return cmdReject(agent, session, { wait, timeoutMs });
|
|
3617
|
+
if (cmd === "review") return cmdReview(agent, session, filteredArgs[1], filteredArgs[2], { wait, yolo, fresh, timeoutMs });
|
|
3618
|
+
if (cmd === "status") return cmdStatus(agent, session);
|
|
3619
|
+
if (cmd === "debug") return cmdDebug(agent, session);
|
|
3620
|
+
if (cmd === "output") {
|
|
3621
|
+
const indexArg = filteredArgs[1];
|
|
3622
|
+
const index = indexArg?.startsWith("-") ? parseInt(indexArg, 10) : 0;
|
|
3623
|
+
return cmdOutput(agent, session, index, { wait, timeoutMs });
|
|
3624
|
+
}
|
|
3625
|
+
if (cmd === "send" && filteredArgs.length > 1) return cmdSend(session, filteredArgs.slice(1).join(" "));
|
|
3626
|
+
if (cmd === "compact") return cmdAsk(agent, session, "/compact", { noWait: true, timeoutMs });
|
|
3627
|
+
if (cmd === "reset") return cmdAsk(agent, session, "/new", { noWait: true, timeoutMs });
|
|
3628
|
+
if (cmd === "select" && filteredArgs[1]) return cmdSelect(agent, session, filteredArgs[1], { wait, timeoutMs });
|
|
3629
|
+
|
|
3630
|
+
// Default: send message
|
|
3631
|
+
let message = filteredArgs.join(" ");
|
|
3632
|
+
if (!message && hasStdinData()) {
|
|
3633
|
+
message = await readStdin();
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
if (!message || cmd === "--help" || cmd === "-h") {
|
|
3637
|
+
printHelp(agent, cliName);
|
|
3638
|
+
process.exit(0);
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
// Detect "please review" and route to custom review mode
|
|
3642
|
+
const reviewMatch = message.match(/^please review\s*(.*)/i);
|
|
3643
|
+
if (reviewMatch && agent.reviewOptions) {
|
|
3644
|
+
const customInstructions = reviewMatch[1].trim() || null;
|
|
3645
|
+
return cmdReview(agent, session, "custom", customInstructions, { wait: !noWait, yolo, timeoutMs });
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
return cmdAsk(agent, session, message, { noWait, yolo, timeoutMs });
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
// Run main() only when executed directly (not when imported for testing)
|
|
3652
|
+
// Use realpathSync to handle symlinks (e.g., axclaude, axcodex bin entries)
|
|
3653
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
3654
|
+
const isDirectRun = process.argv[1] && (() => {
|
|
3655
|
+
try {
|
|
3656
|
+
return realpathSync(process.argv[1]) === __filename;
|
|
3657
|
+
} catch {
|
|
3658
|
+
return false;
|
|
3659
|
+
}
|
|
3660
|
+
})();
|
|
3661
|
+
if (isDirectRun) {
|
|
3662
|
+
main().catch((err) => {
|
|
3663
|
+
console.log(`ERROR: ${err.message}`);
|
|
3664
|
+
process.exit(1);
|
|
3665
|
+
});
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
// Exports for testing (pure functions only)
|
|
3669
|
+
export {
|
|
3670
|
+
parseSessionName,
|
|
3671
|
+
parseAgentConfig,
|
|
3672
|
+
parseKeySequence,
|
|
3673
|
+
getClaudeProjectPath,
|
|
3674
|
+
matchesPattern,
|
|
3675
|
+
getBaseDir,
|
|
3676
|
+
truncate,
|
|
3677
|
+
truncateDiff,
|
|
3678
|
+
extractTextContent,
|
|
3679
|
+
extractToolCalls,
|
|
3680
|
+
extractThinking,
|
|
3681
|
+
detectState,
|
|
3682
|
+
State,
|
|
3683
|
+
};
|
|
3684
|
+
|