agent-auto-resume 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CODE_OF_CONDUCT.md +65 -0
- package/CONTRIBUTING.md +46 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/SECURITY.md +17 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1641 -0
- package/dist/cli.js.map +1 -0
- package/package.json +58 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1641 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/core/session-store.ts
|
|
7
|
+
import os from "os";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import fs from "fs-extra";
|
|
10
|
+
import { nanoid } from "nanoid";
|
|
11
|
+
var DEFAULT_BASE_DIR = path.join(os.homedir(), ".agent-auto-resume");
|
|
12
|
+
var BASE_DIR = process.env.AAR_BASE_DIR || DEFAULT_BASE_DIR;
|
|
13
|
+
var SESSIONS_DIR = path.join(BASE_DIR, "sessions");
|
|
14
|
+
var EVENTS_DIR = path.join(BASE_DIR, "events");
|
|
15
|
+
var SHIMS_DIR = path.join(BASE_DIR, "shims");
|
|
16
|
+
var CONFIG_FILE = path.join(BASE_DIR, "config.json");
|
|
17
|
+
function resolveHome(filepath) {
|
|
18
|
+
if (filepath.startsWith("~")) {
|
|
19
|
+
return path.join(os.homedir(), filepath.slice(1));
|
|
20
|
+
}
|
|
21
|
+
return filepath;
|
|
22
|
+
}
|
|
23
|
+
async function ensureDirs() {
|
|
24
|
+
await fs.ensureDir(BASE_DIR);
|
|
25
|
+
await fs.ensureDir(SESSIONS_DIR);
|
|
26
|
+
await fs.ensureDir(EVENTS_DIR);
|
|
27
|
+
await fs.ensureDir(SHIMS_DIR);
|
|
28
|
+
}
|
|
29
|
+
var DEFAULT_CONFIG = {
|
|
30
|
+
version: 1,
|
|
31
|
+
bufferSeconds: 120,
|
|
32
|
+
maxAttempts: 5,
|
|
33
|
+
providers: {
|
|
34
|
+
claude: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
command: "claude",
|
|
37
|
+
watchTranscripts: true,
|
|
38
|
+
transcriptDirs: ["~/.claude/projects"]
|
|
39
|
+
},
|
|
40
|
+
codex: {
|
|
41
|
+
enabled: true,
|
|
42
|
+
command: "codex",
|
|
43
|
+
watchTranscripts: true,
|
|
44
|
+
transcriptDirs: ["~/.codex/sessions"]
|
|
45
|
+
},
|
|
46
|
+
antigravity: {
|
|
47
|
+
enabled: true,
|
|
48
|
+
command: "agy",
|
|
49
|
+
experimental: true,
|
|
50
|
+
watchTranscripts: false,
|
|
51
|
+
transcriptDirs: []
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
tmux: {
|
|
55
|
+
enabled: false,
|
|
56
|
+
pollIntervalMs: 5e3
|
|
57
|
+
},
|
|
58
|
+
daemon: {
|
|
59
|
+
pollIntervalMs: 5e3
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
async function loadConfig() {
|
|
63
|
+
await ensureDirs();
|
|
64
|
+
if (await fs.pathExists(CONFIG_FILE)) {
|
|
65
|
+
try {
|
|
66
|
+
const data = await fs.readJson(CONFIG_FILE);
|
|
67
|
+
return {
|
|
68
|
+
...DEFAULT_CONFIG,
|
|
69
|
+
...data,
|
|
70
|
+
providers: {
|
|
71
|
+
claude: { ...DEFAULT_CONFIG.providers.claude, ...data.providers?.claude },
|
|
72
|
+
codex: { ...DEFAULT_CONFIG.providers.codex, ...data.providers?.codex },
|
|
73
|
+
antigravity: { ...DEFAULT_CONFIG.providers.antigravity, ...data.providers?.antigravity }
|
|
74
|
+
},
|
|
75
|
+
tmux: { ...DEFAULT_CONFIG.tmux, ...data.tmux },
|
|
76
|
+
daemon: { ...DEFAULT_CONFIG.daemon, ...data.daemon }
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
return DEFAULT_CONFIG;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await fs.writeJson(CONFIG_FILE, DEFAULT_CONFIG, { spaces: 2 });
|
|
83
|
+
return DEFAULT_CONFIG;
|
|
84
|
+
}
|
|
85
|
+
async function createSession(state) {
|
|
86
|
+
await ensureDirs();
|
|
87
|
+
const id = state.id || nanoid(10);
|
|
88
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
89
|
+
const session = {
|
|
90
|
+
...state,
|
|
91
|
+
id,
|
|
92
|
+
createdAt: now,
|
|
93
|
+
updatedAt: now
|
|
94
|
+
};
|
|
95
|
+
const sessionPath = path.join(SESSIONS_DIR, `${id}.json`);
|
|
96
|
+
await fs.writeJson(sessionPath, session, { spaces: 2 });
|
|
97
|
+
return session;
|
|
98
|
+
}
|
|
99
|
+
async function updateSession(id, updates) {
|
|
100
|
+
await ensureDirs();
|
|
101
|
+
const session = await getSession(id);
|
|
102
|
+
if (!session) {
|
|
103
|
+
throw new Error(`Session ${id} not found`);
|
|
104
|
+
}
|
|
105
|
+
const updated = {
|
|
106
|
+
...session,
|
|
107
|
+
...updates,
|
|
108
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
109
|
+
};
|
|
110
|
+
const sessionPath = path.join(SESSIONS_DIR, `${id}.json`);
|
|
111
|
+
await fs.writeJson(sessionPath, updated, { spaces: 2 });
|
|
112
|
+
return updated;
|
|
113
|
+
}
|
|
114
|
+
async function getSession(id) {
|
|
115
|
+
await ensureDirs();
|
|
116
|
+
const sessionPath = path.join(SESSIONS_DIR, `${id}.json`);
|
|
117
|
+
if (!await fs.pathExists(sessionPath)) {
|
|
118
|
+
return void 0;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return await fs.readJson(sessionPath);
|
|
122
|
+
} catch {
|
|
123
|
+
return void 0;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function listSessions() {
|
|
127
|
+
await ensureDirs();
|
|
128
|
+
const files = await fs.readdir(SESSIONS_DIR);
|
|
129
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
130
|
+
const sessions = [];
|
|
131
|
+
for (const file of jsonFiles) {
|
|
132
|
+
try {
|
|
133
|
+
const session = await fs.readJson(path.join(SESSIONS_DIR, file));
|
|
134
|
+
sessions.push(session);
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
139
|
+
}
|
|
140
|
+
async function getLastSession() {
|
|
141
|
+
const sessions = await listSessions();
|
|
142
|
+
return sessions[0];
|
|
143
|
+
}
|
|
144
|
+
async function getRecoverableSessions() {
|
|
145
|
+
const sessions = await listSessions();
|
|
146
|
+
const recoverableStatuses = ["waiting_limit_reset", "ready_to_resume", "failed", "resuming"];
|
|
147
|
+
return sessions.filter((s) => recoverableStatuses.includes(s.status));
|
|
148
|
+
}
|
|
149
|
+
async function getWaitingSessions() {
|
|
150
|
+
const sessions = await listSessions();
|
|
151
|
+
return sessions.filter((s) => s.status === "waiting_limit_reset");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/core/shell-setup.ts
|
|
155
|
+
import path2 from "path";
|
|
156
|
+
import fs2 from "fs-extra";
|
|
157
|
+
function detectShell() {
|
|
158
|
+
const shellEnv = process.env.SHELL || "";
|
|
159
|
+
if (shellEnv.includes("zsh")) {
|
|
160
|
+
return "zsh";
|
|
161
|
+
}
|
|
162
|
+
if (shellEnv.includes("fish")) {
|
|
163
|
+
return "fish";
|
|
164
|
+
}
|
|
165
|
+
return "bash";
|
|
166
|
+
}
|
|
167
|
+
function getShellRcPath(shell) {
|
|
168
|
+
if (shell === "zsh") {
|
|
169
|
+
return resolveHome("~/.zshrc");
|
|
170
|
+
}
|
|
171
|
+
if (shell === "fish") {
|
|
172
|
+
return resolveHome("~/.config/fish/config.fish");
|
|
173
|
+
}
|
|
174
|
+
return resolveHome("~/.bashrc");
|
|
175
|
+
}
|
|
176
|
+
var ZSH_BASH_SNIPPET = `
|
|
177
|
+
# agent-auto-resume
|
|
178
|
+
if command -v aar >/dev/null 2>&1; then
|
|
179
|
+
claude() { aar managed claude -- claude "$@"; }
|
|
180
|
+
codex() { aar managed codex -- codex "$@"; }
|
|
181
|
+
agy() { aar managed antigravity -- agy "$@"; }
|
|
182
|
+
fi
|
|
183
|
+
`;
|
|
184
|
+
var FISH_SNIPPET = `
|
|
185
|
+
# agent-auto-resume
|
|
186
|
+
function claude
|
|
187
|
+
aar managed claude -- claude $argv
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
function codex
|
|
191
|
+
aar managed codex -- codex $argv
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
function agy
|
|
195
|
+
aar managed antigravity -- agy $argv
|
|
196
|
+
end
|
|
197
|
+
`;
|
|
198
|
+
function getSnippet(shell) {
|
|
199
|
+
return shell === "fish" ? FISH_SNIPPET.trim() : ZSH_BASH_SNIPPET.trim();
|
|
200
|
+
}
|
|
201
|
+
async function setupShell(shell, noModify = false) {
|
|
202
|
+
const targetShell = shell || detectShell();
|
|
203
|
+
const rcPath = getShellRcPath(targetShell);
|
|
204
|
+
const snippet = getSnippet(targetShell);
|
|
205
|
+
if (noModify) {
|
|
206
|
+
return `Please manually append the following snippet to your shell config file (${rcPath}):
|
|
207
|
+
|
|
208
|
+
${snippet}`;
|
|
209
|
+
}
|
|
210
|
+
await fs2.ensureDir(path2.dirname(rcPath));
|
|
211
|
+
let exists = false;
|
|
212
|
+
let content = "";
|
|
213
|
+
if (await fs2.pathExists(rcPath)) {
|
|
214
|
+
content = await fs2.readFile(rcPath, "utf-8");
|
|
215
|
+
if (content.includes("agent-auto-resume") || content.includes("aar managed")) {
|
|
216
|
+
exists = true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (exists) {
|
|
220
|
+
return `Shell integration snippet already exists in ${rcPath}. Skipping configuration.`;
|
|
221
|
+
}
|
|
222
|
+
const newContent = content ? `${content.trimEnd()}
|
|
223
|
+
|
|
224
|
+
${snippet}
|
|
225
|
+
` : `${snippet}
|
|
226
|
+
`;
|
|
227
|
+
await fs2.writeFile(rcPath, newContent, "utf-8");
|
|
228
|
+
return `Successfully updated shell configuration at ${rcPath}.
|
|
229
|
+
Restart your terminal or run: source ${rcPath}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/commands/setup.ts
|
|
233
|
+
import { execSync } from "child_process";
|
|
234
|
+
import chalk from "chalk";
|
|
235
|
+
async function runSetup(options) {
|
|
236
|
+
console.log(chalk.bold("Starting agent-auto-resume setup...\n"));
|
|
237
|
+
await ensureDirs();
|
|
238
|
+
console.log(chalk.green("\u2713 Initialized state directory at ~/.agent-auto-resume/"));
|
|
239
|
+
await loadConfig();
|
|
240
|
+
console.log(chalk.green("\u2713 Created configuration file config.json"));
|
|
241
|
+
const providers = [
|
|
242
|
+
{ name: "Claude Code", cmd: "claude" },
|
|
243
|
+
{ name: "OpenAI Codex CLI", cmd: "codex" },
|
|
244
|
+
{ name: "Google Antigravity CLI", cmd: "agy" }
|
|
245
|
+
];
|
|
246
|
+
console.log("\nChecking provider CLI commands:");
|
|
247
|
+
for (const p of providers) {
|
|
248
|
+
try {
|
|
249
|
+
execSync(`which ${p.cmd}`, { stdio: "ignore" });
|
|
250
|
+
console.log(chalk.green(` \u2713 ${p.name} (${p.cmd}) is installed.`));
|
|
251
|
+
} catch {
|
|
252
|
+
console.log(chalk.yellow(` \u26A0 ${p.name} (${p.cmd}) was not found in your PATH.`));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (options.printShellSnippet) {
|
|
256
|
+
const shell = options.shell || "zsh";
|
|
257
|
+
console.log(`
|
|
258
|
+
--- Shell Snippet for ${shell} ---`);
|
|
259
|
+
console.log(getSnippet(shell));
|
|
260
|
+
console.log("---------------------------------");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
console.log("");
|
|
264
|
+
const resultMessage = await setupShell(options.shell, options.noShellModify);
|
|
265
|
+
console.log(resultMessage);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/core/daemon-client.ts
|
|
269
|
+
import { spawn } from "child_process";
|
|
270
|
+
import fs5 from "fs-extra";
|
|
271
|
+
|
|
272
|
+
// src/core/daemon-ipc.ts
|
|
273
|
+
import path3 from "path";
|
|
274
|
+
import fs3 from "fs-extra";
|
|
275
|
+
var PID_FILE = path3.join(BASE_DIR, "daemon.pid");
|
|
276
|
+
async function writePid(pid) {
|
|
277
|
+
await ensureDirs();
|
|
278
|
+
await fs3.writeFile(PID_FILE, pid.toString(), "utf-8");
|
|
279
|
+
}
|
|
280
|
+
async function readPid() {
|
|
281
|
+
if (!await fs3.pathExists(PID_FILE)) {
|
|
282
|
+
return void 0;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const content = await fs3.readFile(PID_FILE, "utf-8");
|
|
286
|
+
const pid = parseInt(content.trim(), 10);
|
|
287
|
+
return isNaN(pid) ? void 0 : pid;
|
|
288
|
+
} catch {
|
|
289
|
+
return void 0;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function clearPid() {
|
|
293
|
+
if (await fs3.pathExists(PID_FILE)) {
|
|
294
|
+
await fs3.remove(PID_FILE);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function isDaemonRunning() {
|
|
298
|
+
const pid = await readPid();
|
|
299
|
+
if (!pid) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
process.kill(pid, 0);
|
|
304
|
+
return true;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (err.code === "ESRCH") {
|
|
307
|
+
await clearPid();
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
return err.code === "EPERM";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/core/logger.ts
|
|
315
|
+
import path4 from "path";
|
|
316
|
+
import fs4 from "fs-extra";
|
|
317
|
+
import chalk2 from "chalk";
|
|
318
|
+
var LOG_FILE = path4.join(BASE_DIR, "daemon.log");
|
|
319
|
+
var currentLogLevel = 1 /* INFO */;
|
|
320
|
+
async function writeToFile(message) {
|
|
321
|
+
try {
|
|
322
|
+
await ensureDirs();
|
|
323
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
324
|
+
const cleanMsg = message.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "");
|
|
325
|
+
await fs4.appendFile(LOG_FILE, `[${ts}] ${cleanMsg}
|
|
326
|
+
`, "utf-8");
|
|
327
|
+
} catch {
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function formatMessage(prefix, message, colorFn) {
|
|
331
|
+
const cleanPrefix = prefix ? `[${prefix}] ` : "";
|
|
332
|
+
const formatted = `${cleanPrefix}${message}`;
|
|
333
|
+
return colorFn ? colorFn(formatted) : formatted;
|
|
334
|
+
}
|
|
335
|
+
var logger = {
|
|
336
|
+
debug(message, prefix = "aar") {
|
|
337
|
+
if (currentLogLevel <= 0 /* DEBUG */) {
|
|
338
|
+
const msg = formatMessage(prefix, message, chalk2.gray);
|
|
339
|
+
console.log(msg);
|
|
340
|
+
writeToFile(msg);
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
info(message, prefix = "aar") {
|
|
344
|
+
if (currentLogLevel <= 1 /* INFO */) {
|
|
345
|
+
const msg = formatMessage(prefix, message, chalk2.blue);
|
|
346
|
+
console.log(msg);
|
|
347
|
+
writeToFile(msg);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
warn(message, prefix = "aar") {
|
|
351
|
+
if (currentLogLevel <= 2 /* WARN */) {
|
|
352
|
+
const msg = formatMessage(prefix, message, chalk2.yellow);
|
|
353
|
+
console.warn(msg);
|
|
354
|
+
writeToFile(msg);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
error(message, prefix = "aar") {
|
|
358
|
+
if (currentLogLevel <= 3 /* ERROR */) {
|
|
359
|
+
const msg = formatMessage(prefix, message, chalk2.red);
|
|
360
|
+
console.error(msg);
|
|
361
|
+
writeToFile(msg);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// src/core/daemon-client.ts
|
|
367
|
+
async function startDaemonProcess(options = {}) {
|
|
368
|
+
if (await isDaemonRunning()) {
|
|
369
|
+
const pid = await readPid();
|
|
370
|
+
console.log(`Daemon is already running (PID: ${pid}).`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
await ensureDirs();
|
|
374
|
+
const nodeBin = process.argv[0];
|
|
375
|
+
const originalArgs = process.argv.slice(1);
|
|
376
|
+
const args = originalArgs.map((arg) => {
|
|
377
|
+
if (arg === "start") return "run";
|
|
378
|
+
return arg;
|
|
379
|
+
});
|
|
380
|
+
if (!args.includes("run")) {
|
|
381
|
+
const daemonIdx = args.indexOf("daemon");
|
|
382
|
+
if (daemonIdx !== -1) {
|
|
383
|
+
args.splice(daemonIdx + 1, 0, "run");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const cleanArgs = args.filter((a) => a !== "start" && a !== "restart");
|
|
387
|
+
if (options.tmux && !cleanArgs.includes("--tmux")) {
|
|
388
|
+
cleanArgs.push("--tmux");
|
|
389
|
+
}
|
|
390
|
+
console.log("Starting agent-auto-resume daemon in background...");
|
|
391
|
+
const outFd = fs5.openSync(LOG_FILE, "a");
|
|
392
|
+
const errFd = fs5.openSync(LOG_FILE, "a");
|
|
393
|
+
const child = spawn(nodeBin, cleanArgs, {
|
|
394
|
+
detached: true,
|
|
395
|
+
stdio: ["ignore", outFd, errFd],
|
|
396
|
+
env: {
|
|
397
|
+
...process.env
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
child.unref();
|
|
401
|
+
for (let i = 0; i < 4; i++) {
|
|
402
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
403
|
+
if (await isDaemonRunning()) {
|
|
404
|
+
const pid = await readPid();
|
|
405
|
+
console.log(`Daemon started successfully (PID: ${pid}). Log file: ${LOG_FILE}`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
console.error("Failed to start daemon. Please check the log file for errors:");
|
|
410
|
+
console.error(LOG_FILE);
|
|
411
|
+
}
|
|
412
|
+
async function stopDaemonProcess() {
|
|
413
|
+
const pid = await readPid();
|
|
414
|
+
if (!pid || !await isDaemonRunning()) {
|
|
415
|
+
console.log("Daemon is not running.");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
console.log(`Stopping daemon (PID: ${pid})...`);
|
|
419
|
+
try {
|
|
420
|
+
process.kill(pid, "SIGTERM");
|
|
421
|
+
for (let i = 0; i < 10; i++) {
|
|
422
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
423
|
+
if (!await isDaemonRunning()) {
|
|
424
|
+
console.log("Daemon stopped successfully.");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
console.warn("Daemon did not respond to SIGTERM. Force killing...");
|
|
429
|
+
process.kill(pid, "SIGKILL");
|
|
430
|
+
console.log("Daemon force killed.");
|
|
431
|
+
} catch (err) {
|
|
432
|
+
console.error(`Failed to stop daemon: ${err.message}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/core/transcript-watcher.ts
|
|
437
|
+
import chokidar from "chokidar";
|
|
438
|
+
import path5 from "path";
|
|
439
|
+
import fs6 from "fs-extra";
|
|
440
|
+
|
|
441
|
+
// src/core/time-parser.ts
|
|
442
|
+
function parseTimeString(str, referenceDate = /* @__PURE__ */ new Date()) {
|
|
443
|
+
const retryAfterRegex = /(?:retry_after|retry after)["'\s]*:?\s*(\d+)/i;
|
|
444
|
+
const retryAfterMatch = str.match(retryAfterRegex);
|
|
445
|
+
if (retryAfterMatch) {
|
|
446
|
+
const seconds = parseInt(retryAfterMatch[1], 10);
|
|
447
|
+
if (!isNaN(seconds)) {
|
|
448
|
+
return new Date(referenceDate.getTime() + seconds * 1e3);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const isoRegex = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i;
|
|
452
|
+
const isoMatch = str.match(isoRegex);
|
|
453
|
+
if (isoMatch) {
|
|
454
|
+
const parsed = Date.parse(isoMatch[1]);
|
|
455
|
+
if (!isNaN(parsed)) {
|
|
456
|
+
return new Date(parsed);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const dateStrRegex = /(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?/;
|
|
460
|
+
const dateStrMatch = str.match(dateStrRegex);
|
|
461
|
+
if (dateStrMatch) {
|
|
462
|
+
const year = parseInt(dateStrMatch[1], 10);
|
|
463
|
+
const month = parseInt(dateStrMatch[2], 10) - 1;
|
|
464
|
+
const day = parseInt(dateStrMatch[3], 10);
|
|
465
|
+
const hour = parseInt(dateStrMatch[4], 10);
|
|
466
|
+
const minute = parseInt(dateStrMatch[5], 10);
|
|
467
|
+
const second = dateStrMatch[6] ? parseInt(dateStrMatch[6], 10) : 0;
|
|
468
|
+
const date = new Date(year, month, day, hour, minute, second);
|
|
469
|
+
if (!isNaN(date.getTime())) {
|
|
470
|
+
return date;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const keywordTimeRegex = /(?:reset|resets|resets_at|try again|try\s+again\s+at|at|resets\s+at|will\s+reset\s+at)\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i;
|
|
474
|
+
const keywordTimeMatch = str.match(keywordTimeRegex);
|
|
475
|
+
let hourStr = "";
|
|
476
|
+
let minStr = "";
|
|
477
|
+
let ampmStr = "";
|
|
478
|
+
if (keywordTimeMatch) {
|
|
479
|
+
hourStr = keywordTimeMatch[1];
|
|
480
|
+
minStr = keywordTimeMatch[2] || "0";
|
|
481
|
+
ampmStr = keywordTimeMatch[3] || "";
|
|
482
|
+
} else {
|
|
483
|
+
const exactTimeRegex = /^\s*(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$/i;
|
|
484
|
+
const exactMatch = str.match(exactTimeRegex);
|
|
485
|
+
if (exactMatch) {
|
|
486
|
+
hourStr = exactMatch[1];
|
|
487
|
+
minStr = exactMatch[2] || "0";
|
|
488
|
+
ampmStr = exactMatch[3] || "";
|
|
489
|
+
} else {
|
|
490
|
+
const partialTimeRegex = /\b(\d{1,2}):(\d{2})\s*(am|pm)?\b|\b(\d{1,2})\s*(am|pm)\b/i;
|
|
491
|
+
const partialMatch = str.match(partialTimeRegex);
|
|
492
|
+
if (partialMatch) {
|
|
493
|
+
if (partialMatch[1] !== void 0) {
|
|
494
|
+
hourStr = partialMatch[1];
|
|
495
|
+
minStr = partialMatch[2];
|
|
496
|
+
ampmStr = partialMatch[3] || "";
|
|
497
|
+
} else {
|
|
498
|
+
hourStr = partialMatch[4];
|
|
499
|
+
minStr = "0";
|
|
500
|
+
ampmStr = partialMatch[5];
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (hourStr) {
|
|
506
|
+
let hour = parseInt(hourStr, 10);
|
|
507
|
+
const minute = parseInt(minStr, 10);
|
|
508
|
+
const ampm = ampmStr.toLowerCase();
|
|
509
|
+
if (ampm === "pm" && hour < 12) {
|
|
510
|
+
hour += 12;
|
|
511
|
+
} else if (ampm === "am" && hour === 12) {
|
|
512
|
+
hour = 0;
|
|
513
|
+
}
|
|
514
|
+
if (hour >= 0 && hour < 24 && minute >= 0 && minute < 60) {
|
|
515
|
+
const date = new Date(referenceDate);
|
|
516
|
+
date.setHours(hour, minute, 0, 0);
|
|
517
|
+
if (date.getTime() <= referenceDate.getTime()) {
|
|
518
|
+
date.setDate(date.getDate() + 1);
|
|
519
|
+
}
|
|
520
|
+
return date;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return void 0;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/providers/claude.ts
|
|
527
|
+
var claudeProvider = {
|
|
528
|
+
name: "claude",
|
|
529
|
+
displayName: "Claude Code",
|
|
530
|
+
defaultCommand: ["claude"],
|
|
531
|
+
detectLimit(output) {
|
|
532
|
+
const lines = output.split(/\r?\n/);
|
|
533
|
+
const patterns = [
|
|
534
|
+
/5-hour limit reached/i,
|
|
535
|
+
/usage limit reached/i,
|
|
536
|
+
/rate limit reached/i,
|
|
537
|
+
/limit will reset at/i
|
|
538
|
+
];
|
|
539
|
+
for (const line of lines) {
|
|
540
|
+
const matched = patterns.some((p) => p.test(line));
|
|
541
|
+
if (matched) {
|
|
542
|
+
const resetAt = parseTimeString(line);
|
|
543
|
+
return {
|
|
544
|
+
matched: true,
|
|
545
|
+
provider: "claude",
|
|
546
|
+
reason: line.trim(),
|
|
547
|
+
resetAt,
|
|
548
|
+
raw: line
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return { matched: false, provider: "claude" };
|
|
553
|
+
},
|
|
554
|
+
getResumeCommand(state) {
|
|
555
|
+
return ["claude", "--continue"];
|
|
556
|
+
},
|
|
557
|
+
getResumeInput(state) {
|
|
558
|
+
return "continue\n";
|
|
559
|
+
},
|
|
560
|
+
getTranscriptDirs() {
|
|
561
|
+
return ["~/.claude/projects"];
|
|
562
|
+
},
|
|
563
|
+
parseTranscriptEvent(line) {
|
|
564
|
+
try {
|
|
565
|
+
const data = JSON.parse(line);
|
|
566
|
+
if (data && typeof data === "object") {
|
|
567
|
+
let text = data.text || data.message || data.content;
|
|
568
|
+
if (!text && data.input && typeof data.input === "object") {
|
|
569
|
+
text = data.input.text;
|
|
570
|
+
}
|
|
571
|
+
if (!text && data.output && typeof data.output === "object") {
|
|
572
|
+
text = data.output.text;
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
text: typeof text === "string" ? text : void 0,
|
|
576
|
+
cwd: typeof data.cwd === "string" ? data.cwd : void 0,
|
|
577
|
+
sessionId: typeof data.sessionId === "string" ? data.sessionId : typeof data.uuid === "string" ? data.uuid : void 0,
|
|
578
|
+
timestamp: typeof data.timestamp === "string" ? data.timestamp : typeof data.createdAt === "string" ? data.createdAt : void 0
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
}
|
|
583
|
+
return void 0;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// src/prompts/safe-resume.ts
|
|
588
|
+
var CODEX_SAFE_RESUME_PROMPT = `
|
|
589
|
+
Continue the previous task from where it stopped.
|
|
590
|
+
|
|
591
|
+
Before making changes:
|
|
592
|
+
1. Inspect the current repository state.
|
|
593
|
+
2. Run git status.
|
|
594
|
+
3. Inspect git diff.
|
|
595
|
+
4. Read AGENT_PROGRESS.md if it exists.
|
|
596
|
+
5. Identify what was already completed and what remains.
|
|
597
|
+
6. Do not overwrite user changes.
|
|
598
|
+
7. Continue only the remaining work.
|
|
599
|
+
8. Run relevant tests if available.
|
|
600
|
+
9. Summarize what changed and what remains.
|
|
601
|
+
`;
|
|
602
|
+
var ANTIGRAVITY_SAFE_RESUME_PROMPT = `
|
|
603
|
+
Continue the previous task from where it stopped.
|
|
604
|
+
|
|
605
|
+
Before making changes:
|
|
606
|
+
1. Inspect the current workspace state.
|
|
607
|
+
2. Run git status.
|
|
608
|
+
3. Inspect git diff.
|
|
609
|
+
4. Read AGENT_PROGRESS.md if it exists.
|
|
610
|
+
5. Identify what was already completed and what remains.
|
|
611
|
+
6. Do not overwrite user changes.
|
|
612
|
+
7. Continue only the remaining work.
|
|
613
|
+
8. Ask for confirmation before destructive operations.
|
|
614
|
+
9. Run relevant tests if available.
|
|
615
|
+
10. Summarize what changed and what remains.
|
|
616
|
+
`;
|
|
617
|
+
|
|
618
|
+
// src/providers/codex.ts
|
|
619
|
+
var codexProvider = {
|
|
620
|
+
name: "codex",
|
|
621
|
+
displayName: "OpenAI Codex CLI",
|
|
622
|
+
defaultCommand: ["codex"],
|
|
623
|
+
detectLimit(output) {
|
|
624
|
+
const lines = output.split(/\r?\n/);
|
|
625
|
+
const patterns = [
|
|
626
|
+
/usage limit reached/i,
|
|
627
|
+
/rate limit reached/i,
|
|
628
|
+
/try again at/i,
|
|
629
|
+
/resets_at/i,
|
|
630
|
+
/usage_limit_reached/i
|
|
631
|
+
];
|
|
632
|
+
for (const line of lines) {
|
|
633
|
+
const matched = patterns.some((p) => p.test(line));
|
|
634
|
+
if (matched) {
|
|
635
|
+
const resetAt = parseTimeString(line);
|
|
636
|
+
return {
|
|
637
|
+
matched: true,
|
|
638
|
+
provider: "codex",
|
|
639
|
+
reason: line.trim(),
|
|
640
|
+
resetAt,
|
|
641
|
+
raw: line
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return { matched: false, provider: "codex" };
|
|
646
|
+
},
|
|
647
|
+
getResumeCommand(state) {
|
|
648
|
+
return [
|
|
649
|
+
"codex",
|
|
650
|
+
"exec",
|
|
651
|
+
"resume",
|
|
652
|
+
"--last",
|
|
653
|
+
CODEX_SAFE_RESUME_PROMPT.trim()
|
|
654
|
+
];
|
|
655
|
+
},
|
|
656
|
+
getTranscriptDirs() {
|
|
657
|
+
return ["~/.codex/sessions"];
|
|
658
|
+
},
|
|
659
|
+
parseTranscriptEvent(line) {
|
|
660
|
+
try {
|
|
661
|
+
const data = JSON.parse(line);
|
|
662
|
+
if (data && typeof data === "object") {
|
|
663
|
+
const text = data.text || data.message || data.content;
|
|
664
|
+
return {
|
|
665
|
+
text: typeof text === "string" ? text : void 0,
|
|
666
|
+
cwd: typeof data.cwd === "string" ? data.cwd : void 0,
|
|
667
|
+
sessionId: typeof data.sessionId === "string" ? data.sessionId : void 0,
|
|
668
|
+
timestamp: typeof data.timestamp === "string" ? data.timestamp : void 0
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
} catch {
|
|
672
|
+
}
|
|
673
|
+
return void 0;
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// src/providers/antigravity.ts
|
|
678
|
+
import { execSync as execSync2 } from "child_process";
|
|
679
|
+
var antigravityProvider = {
|
|
680
|
+
name: "antigravity",
|
|
681
|
+
displayName: "Google Antigravity CLI",
|
|
682
|
+
defaultCommand: ["agy"],
|
|
683
|
+
detectLimit(output) {
|
|
684
|
+
const lines = output.split(/\r?\n/);
|
|
685
|
+
const patterns = [
|
|
686
|
+
/usage limit reached/i,
|
|
687
|
+
/rate limit reached/i,
|
|
688
|
+
/quota exceeded/i,
|
|
689
|
+
/quota exhausted/i,
|
|
690
|
+
/daily limit reached/i,
|
|
691
|
+
/5-hour limit reached/i,
|
|
692
|
+
/reached your Antigravity limit/i,
|
|
693
|
+
/Antigravity usage limit reached/i,
|
|
694
|
+
/try again at/i,
|
|
695
|
+
/reset at/i,
|
|
696
|
+
/resets at/i,
|
|
697
|
+
/resets_at/i,
|
|
698
|
+
/retry after/i,
|
|
699
|
+
/retry_after/i
|
|
700
|
+
];
|
|
701
|
+
for (const line of lines) {
|
|
702
|
+
const matched = patterns.some((p) => p.test(line));
|
|
703
|
+
if (matched) {
|
|
704
|
+
const resetAt = parseTimeString(line);
|
|
705
|
+
return {
|
|
706
|
+
matched: true,
|
|
707
|
+
provider: "antigravity",
|
|
708
|
+
reason: line.trim(),
|
|
709
|
+
resetAt,
|
|
710
|
+
raw: line
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return { matched: false, provider: "antigravity" };
|
|
715
|
+
},
|
|
716
|
+
async getResumeCommand(state) {
|
|
717
|
+
let helpOutput = "";
|
|
718
|
+
try {
|
|
719
|
+
helpOutput = execSync2("agy --help", {
|
|
720
|
+
cwd: state.cwd,
|
|
721
|
+
encoding: "utf-8",
|
|
722
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
723
|
+
});
|
|
724
|
+
} catch {
|
|
725
|
+
}
|
|
726
|
+
if (helpOutput) {
|
|
727
|
+
if (helpOutput.includes("resume") && helpOutput.includes("--last")) {
|
|
728
|
+
return ["agy", "resume", "--last"];
|
|
729
|
+
}
|
|
730
|
+
if (helpOutput.includes("continue")) {
|
|
731
|
+
return ["agy", "continue"];
|
|
732
|
+
}
|
|
733
|
+
const lines = helpOutput.split("\n");
|
|
734
|
+
const hasC = lines.some((line) => {
|
|
735
|
+
const trimmed = line.trim();
|
|
736
|
+
return trimmed.startsWith("c ") || trimmed.startsWith("c ");
|
|
737
|
+
});
|
|
738
|
+
if (hasC) {
|
|
739
|
+
return ["agy", "c"];
|
|
740
|
+
}
|
|
741
|
+
if (helpOutput.includes("conversation") && helpOutput.includes("--last")) {
|
|
742
|
+
return ["agy", "conversation", "--last"];
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return ["agy"];
|
|
746
|
+
},
|
|
747
|
+
getResumeInput(state) {
|
|
748
|
+
if (state.resumeCommand && state.resumeCommand.length === 1 && state.resumeCommand[0] === "agy") {
|
|
749
|
+
return ANTIGRAVITY_SAFE_RESUME_PROMPT.trim() + "\n";
|
|
750
|
+
}
|
|
751
|
+
return void 0;
|
|
752
|
+
},
|
|
753
|
+
getTranscriptDirs() {
|
|
754
|
+
return [];
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
// src/providers/index.ts
|
|
759
|
+
function getProvider(name) {
|
|
760
|
+
const normalized = name.toLowerCase();
|
|
761
|
+
if (normalized === "agy" || normalized === "antigravity") {
|
|
762
|
+
return antigravityProvider;
|
|
763
|
+
}
|
|
764
|
+
if (normalized === "claude") {
|
|
765
|
+
return claudeProvider;
|
|
766
|
+
}
|
|
767
|
+
if (normalized === "codex") {
|
|
768
|
+
return codexProvider;
|
|
769
|
+
}
|
|
770
|
+
throw new Error(`Unsupported provider: ${name}`);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/core/detector.ts
|
|
774
|
+
function detectLimit(output, providerName) {
|
|
775
|
+
if (providerName) {
|
|
776
|
+
try {
|
|
777
|
+
const provider = getProvider(providerName);
|
|
778
|
+
return provider.detectLimit(output);
|
|
779
|
+
} catch {
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const allProviders = ["claude", "codex", "antigravity"];
|
|
783
|
+
for (const name of allProviders) {
|
|
784
|
+
const provider = getProvider(name);
|
|
785
|
+
const detection = provider.detectLimit(output);
|
|
786
|
+
if (detection.matched) {
|
|
787
|
+
return detection;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return { matched: false, provider: providerName || "claude" };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/core/transcript-watcher.ts
|
|
794
|
+
var fileCursors = /* @__PURE__ */ new Map();
|
|
795
|
+
async function startTranscriptWatcher(abortSignal) {
|
|
796
|
+
const config = await loadConfig();
|
|
797
|
+
const watchers = [];
|
|
798
|
+
const providersToWatch = ["claude", "codex"];
|
|
799
|
+
for (const providerName of providersToWatch) {
|
|
800
|
+
const providerConfig = config.providers[providerName];
|
|
801
|
+
if (!providerConfig || !providerConfig.enabled || !providerConfig.watchTranscripts) {
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
const provider = getProvider(providerName);
|
|
805
|
+
if (!provider.parseTranscriptEvent || !provider.getTranscriptDirs) {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
const dirs = providerConfig.transcriptDirs.map(resolveHome);
|
|
809
|
+
for (const dir of dirs) {
|
|
810
|
+
if (!await fs6.pathExists(dir)) {
|
|
811
|
+
logger.debug(`Transcript watch directory does not exist: ${dir}, skipping`, "aar");
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
logger.info(`Starting transcript watcher for ${provider.displayName} at ${dir}`, "aar");
|
|
815
|
+
const watcher = chokidar.watch(dir, {
|
|
816
|
+
persistent: true,
|
|
817
|
+
ignoreInitial: false
|
|
818
|
+
});
|
|
819
|
+
watcher.on("add", (filePath) => handleFileChange(filePath, providerName));
|
|
820
|
+
watcher.on("change", (filePath) => handleFileChange(filePath, providerName));
|
|
821
|
+
watchers.push(watcher);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
abortSignal?.addEventListener("abort", () => {
|
|
825
|
+
for (const w of watchers) {
|
|
826
|
+
w.close();
|
|
827
|
+
}
|
|
828
|
+
logger.info("Transcript watchers stopped.", "aar");
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async function handleFileChange(filePath, providerName) {
|
|
832
|
+
const ext = path5.extname(filePath).toLowerCase();
|
|
833
|
+
if (ext !== ".json" && ext !== ".jsonl" && ext !== "") {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
const stat = await fs6.stat(filePath);
|
|
838
|
+
const startCursor = fileCursors.get(filePath) || 0;
|
|
839
|
+
if (stat.size <= startCursor) {
|
|
840
|
+
fileCursors.set(filePath, stat.size);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const fd = await fs6.open(filePath, "r");
|
|
844
|
+
const buffer = Buffer.alloc(stat.size - startCursor);
|
|
845
|
+
await fs6.read(fd, buffer, 0, buffer.length, startCursor);
|
|
846
|
+
await fs6.close(fd);
|
|
847
|
+
fileCursors.set(filePath, stat.size);
|
|
848
|
+
const content = buffer.toString("utf-8");
|
|
849
|
+
const lines = content.split(/\r?\n/);
|
|
850
|
+
const provider = getProvider(providerName);
|
|
851
|
+
for (const line of lines) {
|
|
852
|
+
if (!line.trim()) continue;
|
|
853
|
+
const event = provider.parseTranscriptEvent(line);
|
|
854
|
+
if (event && event.text) {
|
|
855
|
+
const detection = detectLimit(event.text, providerName);
|
|
856
|
+
if (detection.matched) {
|
|
857
|
+
logger.warn(`Limit detected via transcript watcher in file ${filePath}: ${detection.reason}`, "aar");
|
|
858
|
+
const sessions = await listSessions();
|
|
859
|
+
const matchedSession = sessions.find(
|
|
860
|
+
(s) => s.status === "running" && s.provider === providerName && (event.cwd ? s.cwd === event.cwd : true)
|
|
861
|
+
);
|
|
862
|
+
const resetAtStr = detection.resetAt ? detection.resetAt.toISOString() : void 0;
|
|
863
|
+
if (matchedSession) {
|
|
864
|
+
if (matchedSession.status !== "waiting_limit_reset") {
|
|
865
|
+
await updateSession(matchedSession.id, {
|
|
866
|
+
status: "waiting_limit_reset",
|
|
867
|
+
lastLimitDetectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
868
|
+
resetAt: resetAtStr,
|
|
869
|
+
lastOutputSnippet: event.text.slice(-1e3),
|
|
870
|
+
transcriptPath: filePath
|
|
871
|
+
});
|
|
872
|
+
logger.info(`Updated existing session ${matchedSession.id} to waiting_limit_reset`, "aar");
|
|
873
|
+
}
|
|
874
|
+
} else {
|
|
875
|
+
const config = await loadConfig();
|
|
876
|
+
const newSession = await createSession({
|
|
877
|
+
provider: providerName,
|
|
878
|
+
cwd: event.cwd || process.cwd(),
|
|
879
|
+
originalCommand: provider.defaultCommand,
|
|
880
|
+
resumeStrategy: providerName === "claude" ? "pty-input" : "command",
|
|
881
|
+
status: "waiting_limit_reset",
|
|
882
|
+
lastLimitDetectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
883
|
+
resetAt: resetAtStr,
|
|
884
|
+
attempts: 0,
|
|
885
|
+
maxAttempts: config.maxAttempts,
|
|
886
|
+
bufferSeconds: config.bufferSeconds,
|
|
887
|
+
lastOutputSnippet: event.text.slice(-1e3),
|
|
888
|
+
managedByAar: false,
|
|
889
|
+
source: "transcript-watcher",
|
|
890
|
+
transcriptPath: filePath
|
|
891
|
+
});
|
|
892
|
+
logger.info(`Created new session ${newSession.id} via transcript-watcher`, "aar");
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
} catch (err) {
|
|
898
|
+
logger.debug(`Error reading transcript file ${filePath}: ${err.message}`, "aar");
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/core/tmux-watcher.ts
|
|
903
|
+
import { execSync as execSync3 } from "child_process";
|
|
904
|
+
var tmuxTimer;
|
|
905
|
+
async function startTmuxWatcher(abortSignal) {
|
|
906
|
+
const config = await loadConfig();
|
|
907
|
+
if (!config.tmux.enabled) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
execSync3("tmux -V", { stdio: "ignore" });
|
|
912
|
+
} catch {
|
|
913
|
+
logger.warn("tmux is enabled in config, but 'tmux' command was not found. tmux watcher is disabled.", "aar");
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
logger.info("Starting tmux watcher (experimental)...", "aar");
|
|
917
|
+
const poll = async () => {
|
|
918
|
+
if (abortSignal?.aborted) return;
|
|
919
|
+
try {
|
|
920
|
+
await checkTmuxPanes();
|
|
921
|
+
} catch (err) {
|
|
922
|
+
logger.debug(`Error checking tmux panes: ${err.message}`, "aar");
|
|
923
|
+
}
|
|
924
|
+
tmuxTimer = setTimeout(poll, config.tmux.pollIntervalMs || 5e3);
|
|
925
|
+
};
|
|
926
|
+
poll();
|
|
927
|
+
abortSignal?.addEventListener("abort", () => {
|
|
928
|
+
if (tmuxTimer) clearTimeout(tmuxTimer);
|
|
929
|
+
logger.info("Tmux watcher stopped.", "aar");
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
async function checkTmuxPanes() {
|
|
933
|
+
let listOutput = "";
|
|
934
|
+
try {
|
|
935
|
+
listOutput = execSync3("tmux list-panes -a -F '#{pane_id}|#{pane_pid}|#{pane_current_path}|#{pane_current_command}'", {
|
|
936
|
+
encoding: "utf-8",
|
|
937
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
938
|
+
});
|
|
939
|
+
} catch {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const panes = listOutput.split("\n").filter(Boolean).map((line) => {
|
|
943
|
+
const [paneId, panePid, paneCwd, paneCmd] = line.split("|");
|
|
944
|
+
return { paneId, panePid, paneCwd, paneCmd };
|
|
945
|
+
});
|
|
946
|
+
const providers = [
|
|
947
|
+
{ name: "claude", cmdKeywords: ["claude", "claude-code"] },
|
|
948
|
+
{ name: "codex", cmdKeywords: ["codex"] },
|
|
949
|
+
{ name: "antigravity", cmdKeywords: ["agy", "antigravity"] }
|
|
950
|
+
];
|
|
951
|
+
for (const pane of panes) {
|
|
952
|
+
const matchedProvider = providers.find(
|
|
953
|
+
(p) => p.cmdKeywords.some((keyword) => pane.paneCmd.toLowerCase().includes(keyword))
|
|
954
|
+
);
|
|
955
|
+
if (!matchedProvider) {
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
let paneOutput = "";
|
|
959
|
+
try {
|
|
960
|
+
paneOutput = execSync3(`tmux capture-pane -p -t ${pane.paneId}`, {
|
|
961
|
+
encoding: "utf-8",
|
|
962
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
963
|
+
});
|
|
964
|
+
} catch {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const detection = detectLimit(paneOutput, matchedProvider.name);
|
|
968
|
+
if (detection.matched) {
|
|
969
|
+
const sessions = await listSessions();
|
|
970
|
+
let matchedSession = sessions.find((s) => s.tmuxPaneId === pane.paneId && s.status === "waiting_limit_reset");
|
|
971
|
+
const resetAtStr = detection.resetAt ? detection.resetAt.toISOString() : void 0;
|
|
972
|
+
if (!matchedSession) {
|
|
973
|
+
matchedSession = sessions.find((s) => s.tmuxPaneId === pane.paneId && s.status === "running");
|
|
974
|
+
if (matchedSession) {
|
|
975
|
+
await updateSession(matchedSession.id, {
|
|
976
|
+
status: "waiting_limit_reset",
|
|
977
|
+
lastLimitDetectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
978
|
+
resetAt: resetAtStr,
|
|
979
|
+
lastOutputSnippet: paneOutput.slice(-1e3)
|
|
980
|
+
});
|
|
981
|
+
logger.info(`Updated tmux session ${matchedSession.id} to waiting_limit_reset`, "aar");
|
|
982
|
+
} else {
|
|
983
|
+
const config = await loadConfig();
|
|
984
|
+
const provider = getProvider(matchedProvider.name);
|
|
985
|
+
const newSession = await createSession({
|
|
986
|
+
provider: matchedProvider.name,
|
|
987
|
+
cwd: pane.paneCwd || process.cwd(),
|
|
988
|
+
originalCommand: provider.defaultCommand,
|
|
989
|
+
resumeStrategy: "pty-input",
|
|
990
|
+
status: "waiting_limit_reset",
|
|
991
|
+
lastLimitDetectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
992
|
+
resetAt: resetAtStr,
|
|
993
|
+
attempts: 0,
|
|
994
|
+
maxAttempts: config.maxAttempts,
|
|
995
|
+
bufferSeconds: config.bufferSeconds,
|
|
996
|
+
lastOutputSnippet: paneOutput.slice(-1e3),
|
|
997
|
+
managedByAar: false,
|
|
998
|
+
source: "tmux-watcher",
|
|
999
|
+
tmuxPaneId: pane.paneId
|
|
1000
|
+
});
|
|
1001
|
+
logger.info(`Created new session ${newSession.id} via tmux-watcher (pane: ${pane.paneId})`, "aar");
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
function sendKeysToTmux(paneId, keys) {
|
|
1008
|
+
try {
|
|
1009
|
+
execSync3(`tmux send-keys -t ${paneId} "${keys.replace(/"/g, '\\"')}"`, { stdio: "ignore" });
|
|
1010
|
+
return true;
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
logger.error(`Failed to send keys to tmux pane ${paneId}: ${err.message}`, "aar");
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/core/scheduler.ts
|
|
1018
|
+
function getWaitMs(state) {
|
|
1019
|
+
if (!state.resetAt) {
|
|
1020
|
+
return 0;
|
|
1021
|
+
}
|
|
1022
|
+
const resetTime = new Date(state.resetAt).getTime();
|
|
1023
|
+
const bufferMs = (state.bufferSeconds ?? 120) * 1e3;
|
|
1024
|
+
const targetTime = resetTime + bufferMs;
|
|
1025
|
+
const now = Date.now();
|
|
1026
|
+
return targetTime - now;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// src/core/process-manager.ts
|
|
1030
|
+
import pty from "node-pty";
|
|
1031
|
+
async function resumeSessionInBackground(state) {
|
|
1032
|
+
const provider = getProvider(state.provider);
|
|
1033
|
+
const resumeCommand = await provider.getResumeCommand(state);
|
|
1034
|
+
const resumeInput = provider.getResumeInput ? await provider.getResumeInput(state) : void 0;
|
|
1035
|
+
logger.info(`Resuming session ${state.id} with command: ${resumeCommand.join(" ")}`, "aar");
|
|
1036
|
+
await updateSession(state.id, {
|
|
1037
|
+
status: "resuming",
|
|
1038
|
+
resumeCommand,
|
|
1039
|
+
resumeInput,
|
|
1040
|
+
attempts: state.attempts + 1
|
|
1041
|
+
});
|
|
1042
|
+
const cmd = resumeCommand[0];
|
|
1043
|
+
const args = resumeCommand.slice(1);
|
|
1044
|
+
let ptyProcess;
|
|
1045
|
+
try {
|
|
1046
|
+
ptyProcess = pty.spawn(cmd, args, {
|
|
1047
|
+
name: "xterm-color",
|
|
1048
|
+
cols: 80,
|
|
1049
|
+
rows: 24,
|
|
1050
|
+
cwd: state.cwd,
|
|
1051
|
+
env: {
|
|
1052
|
+
...process.env,
|
|
1053
|
+
AAR_SESSION_ID: state.id
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
logger.error(`Failed to spawn resume command: ${err.message}`, "aar");
|
|
1058
|
+
await updateSession(state.id, { status: "failed" });
|
|
1059
|
+
return false;
|
|
1060
|
+
}
|
|
1061
|
+
return new Promise((resolve) => {
|
|
1062
|
+
let limitDetected = false;
|
|
1063
|
+
let accumulatedOutput = "";
|
|
1064
|
+
if (resumeInput) {
|
|
1065
|
+
setTimeout(() => {
|
|
1066
|
+
try {
|
|
1067
|
+
ptyProcess.write(resumeInput);
|
|
1068
|
+
logger.info(`Sent resume input to session ${state.id}: ${JSON.stringify(resumeInput)}`, "aar");
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
logger.error(`Failed to write resume input to PTY: ${err.message}`, "aar");
|
|
1071
|
+
}
|
|
1072
|
+
}, 2e3);
|
|
1073
|
+
}
|
|
1074
|
+
ptyProcess.onData(async (data) => {
|
|
1075
|
+
accumulatedOutput += data;
|
|
1076
|
+
if (accumulatedOutput.length > 8192) {
|
|
1077
|
+
accumulatedOutput = accumulatedOutput.slice(-4096);
|
|
1078
|
+
}
|
|
1079
|
+
logger.debug(`[PTY Output ${state.id}] ${data.trim()}`, "aar");
|
|
1080
|
+
if (!limitDetected) {
|
|
1081
|
+
const detection = detectLimit(accumulatedOutput, state.provider);
|
|
1082
|
+
if (detection.matched) {
|
|
1083
|
+
limitDetected = true;
|
|
1084
|
+
logger.warn(`Limit re-detected during resume for session ${state.id}`, "aar");
|
|
1085
|
+
const resetAtStr = detection.resetAt ? detection.resetAt.toISOString() : void 0;
|
|
1086
|
+
await updateSession(state.id, {
|
|
1087
|
+
status: "waiting_limit_reset",
|
|
1088
|
+
lastLimitDetectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1089
|
+
resetAt: resetAtStr,
|
|
1090
|
+
lastOutputSnippet: accumulatedOutput.slice(-1e3)
|
|
1091
|
+
});
|
|
1092
|
+
try {
|
|
1093
|
+
ptyProcess.kill();
|
|
1094
|
+
} catch {
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
ptyProcess.onExit(async (res) => {
|
|
1100
|
+
const current = await getSession(state.id);
|
|
1101
|
+
if (!current) {
|
|
1102
|
+
return resolve(false);
|
|
1103
|
+
}
|
|
1104
|
+
if (current.status === "resuming") {
|
|
1105
|
+
if (res.exitCode === 0) {
|
|
1106
|
+
logger.info(`Session ${state.id} completed successfully.`, "aar");
|
|
1107
|
+
await updateSession(state.id, { status: "completed" });
|
|
1108
|
+
resolve(true);
|
|
1109
|
+
} else {
|
|
1110
|
+
logger.info(`Session ${state.id} exited with code ${res.exitCode}.`, "aar");
|
|
1111
|
+
await updateSession(state.id, { status: "failed" });
|
|
1112
|
+
resolve(false);
|
|
1113
|
+
}
|
|
1114
|
+
} else if (current.status === "waiting_limit_reset") {
|
|
1115
|
+
resolve(false);
|
|
1116
|
+
} else {
|
|
1117
|
+
resolve(false);
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// src/core/daemon.ts
|
|
1124
|
+
var AarDaemon = class {
|
|
1125
|
+
abortController = null;
|
|
1126
|
+
timer = null;
|
|
1127
|
+
isProcessing = false;
|
|
1128
|
+
async start(options = {}) {
|
|
1129
|
+
if (await isDaemonRunning()) {
|
|
1130
|
+
logger.error("Daemon is already running.", "aar");
|
|
1131
|
+
process.exit(1);
|
|
1132
|
+
}
|
|
1133
|
+
await ensureDirs();
|
|
1134
|
+
await writePid(process.pid);
|
|
1135
|
+
logger.info(`Daemon started with PID ${process.pid}`, "aar");
|
|
1136
|
+
this.abortController = new AbortController();
|
|
1137
|
+
const config = await loadConfig();
|
|
1138
|
+
if (options.tmux !== void 0) {
|
|
1139
|
+
config.tmux.enabled = options.tmux;
|
|
1140
|
+
}
|
|
1141
|
+
await startTranscriptWatcher(this.abortController.signal);
|
|
1142
|
+
if (config.tmux.enabled) {
|
|
1143
|
+
await startTmuxWatcher(this.abortController.signal);
|
|
1144
|
+
}
|
|
1145
|
+
const shutdown = async () => {
|
|
1146
|
+
logger.info("Daemon shutting down...", "aar");
|
|
1147
|
+
if (this.timer) {
|
|
1148
|
+
clearTimeout(this.timer);
|
|
1149
|
+
}
|
|
1150
|
+
this.abortController?.abort();
|
|
1151
|
+
await clearPid();
|
|
1152
|
+
logger.info("Daemon stopped.", "aar");
|
|
1153
|
+
process.exit(0);
|
|
1154
|
+
};
|
|
1155
|
+
process.on("SIGINT", shutdown);
|
|
1156
|
+
process.on("SIGTERM", shutdown);
|
|
1157
|
+
const loop = async () => {
|
|
1158
|
+
if (this.abortController?.signal.aborted) {
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
if (!this.isProcessing) {
|
|
1162
|
+
this.isProcessing = true;
|
|
1163
|
+
try {
|
|
1164
|
+
await this.processWaitingSessions();
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
logger.error(`Error in daemon loop: ${err.message}`, "aar");
|
|
1167
|
+
} finally {
|
|
1168
|
+
this.isProcessing = false;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
this.timer = setTimeout(loop, config.daemon.pollIntervalMs || 5e3);
|
|
1172
|
+
};
|
|
1173
|
+
loop();
|
|
1174
|
+
}
|
|
1175
|
+
async processWaitingSessions() {
|
|
1176
|
+
const waiting = await getWaitingSessions();
|
|
1177
|
+
for (const session of waiting) {
|
|
1178
|
+
if (!session.resetAt) {
|
|
1179
|
+
logger.warn(`Session ${session.id} reset time is unknown. Mark as failed.`, "aar");
|
|
1180
|
+
await updateSession(session.id, {
|
|
1181
|
+
status: "failed"
|
|
1182
|
+
});
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
const waitMsLeft = getWaitMs(session);
|
|
1186
|
+
if (waitMsLeft <= 0) {
|
|
1187
|
+
logger.info(`Session ${session.id} is ready to resume (Wait completed).`, "aar");
|
|
1188
|
+
await updateSession(session.id, {
|
|
1189
|
+
status: "ready_to_resume"
|
|
1190
|
+
});
|
|
1191
|
+
if (session.attempts >= session.maxAttempts) {
|
|
1192
|
+
logger.warn(`Session ${session.id} exceeded max resume attempts (${session.maxAttempts}). Mark as failed.`, "aar");
|
|
1193
|
+
await updateSession(session.id, {
|
|
1194
|
+
status: "failed"
|
|
1195
|
+
});
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (session.source === "tmux-watcher" && session.tmuxPaneId) {
|
|
1199
|
+
const provider = getProvider(session.provider);
|
|
1200
|
+
const resumeInput = provider.getResumeInput ? await provider.getResumeInput(session) : void 0;
|
|
1201
|
+
if (resumeInput) {
|
|
1202
|
+
logger.info(`Resuming tmux session ${session.id} in pane ${session.tmuxPaneId}`, "aar");
|
|
1203
|
+
await updateSession(session.id, {
|
|
1204
|
+
status: "resuming",
|
|
1205
|
+
attempts: session.attempts + 1
|
|
1206
|
+
});
|
|
1207
|
+
const sent = sendKeysToTmux(session.tmuxPaneId, resumeInput);
|
|
1208
|
+
if (sent) {
|
|
1209
|
+
await updateSession(session.id, {
|
|
1210
|
+
status: "running"
|
|
1211
|
+
});
|
|
1212
|
+
logger.info(`Sent resume input to tmux pane successfully.`, "aar");
|
|
1213
|
+
} else {
|
|
1214
|
+
await updateSession(session.id, {
|
|
1215
|
+
status: "failed"
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
} else {
|
|
1219
|
+
await updateSession(session.id, {
|
|
1220
|
+
status: "failed"
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
} else {
|
|
1224
|
+
try {
|
|
1225
|
+
const success = await resumeSessionInBackground(session);
|
|
1226
|
+
if (success) {
|
|
1227
|
+
logger.info(`Session ${session.id} resumed and completed successfully.`, "aar");
|
|
1228
|
+
} else {
|
|
1229
|
+
logger.warn(`Session ${session.id} resume failed or hit limit again.`, "aar");
|
|
1230
|
+
}
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
logger.error(`Failed to resume session ${session.id}: ${err.message}`, "aar");
|
|
1233
|
+
await updateSession(session.id, {
|
|
1234
|
+
status: "failed"
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
} else {
|
|
1239
|
+
const secondsLeft = Math.ceil(waitMsLeft / 1e3);
|
|
1240
|
+
logger.debug(`Session ${session.id} waiting... ${secondsLeft}s left`, "aar");
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
// src/commands/daemon.ts
|
|
1247
|
+
import fs7 from "fs-extra";
|
|
1248
|
+
import chalk3 from "chalk";
|
|
1249
|
+
async function handleDaemon(action, options) {
|
|
1250
|
+
const cleanAction = action.toLowerCase();
|
|
1251
|
+
if (cleanAction === "start") {
|
|
1252
|
+
await startDaemonProcess(options);
|
|
1253
|
+
} else if (cleanAction === "stop") {
|
|
1254
|
+
await stopDaemonProcess();
|
|
1255
|
+
} else if (cleanAction === "restart") {
|
|
1256
|
+
console.log("Restarting daemon...");
|
|
1257
|
+
await stopDaemonProcess();
|
|
1258
|
+
await startDaemonProcess(options);
|
|
1259
|
+
} else if (cleanAction === "status") {
|
|
1260
|
+
const running = await isDaemonRunning();
|
|
1261
|
+
if (running) {
|
|
1262
|
+
const pid = await readPid();
|
|
1263
|
+
console.log(chalk3.green(`Daemon is RUNNING (PID: ${pid})`));
|
|
1264
|
+
} else {
|
|
1265
|
+
console.log(chalk3.red("Daemon is STOPPED"));
|
|
1266
|
+
}
|
|
1267
|
+
} else if (cleanAction === "logs") {
|
|
1268
|
+
if (await fs7.pathExists(LOG_FILE)) {
|
|
1269
|
+
const content = await fs7.readFile(LOG_FILE, "utf-8");
|
|
1270
|
+
console.log(content);
|
|
1271
|
+
} else {
|
|
1272
|
+
console.log("No log file found.");
|
|
1273
|
+
}
|
|
1274
|
+
} else if (cleanAction === "run") {
|
|
1275
|
+
const daemon = new AarDaemon();
|
|
1276
|
+
await daemon.start(options);
|
|
1277
|
+
} else {
|
|
1278
|
+
console.error(`Unknown daemon action: ${action}`);
|
|
1279
|
+
process.exit(1);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// src/core/pty-runner.ts
|
|
1284
|
+
import pty2 from "node-pty";
|
|
1285
|
+
async function runInPty(options) {
|
|
1286
|
+
const { providerName, command, args, cwd = process.cwd(), sessionId } = options;
|
|
1287
|
+
const config = await loadConfig();
|
|
1288
|
+
const provider = getProvider(providerName);
|
|
1289
|
+
let session;
|
|
1290
|
+
if (sessionId) {
|
|
1291
|
+
const existing = await getSession(sessionId);
|
|
1292
|
+
if (existing) {
|
|
1293
|
+
session = existing;
|
|
1294
|
+
} else {
|
|
1295
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
1296
|
+
}
|
|
1297
|
+
} else {
|
|
1298
|
+
session = await createSession({
|
|
1299
|
+
provider: providerName,
|
|
1300
|
+
cwd,
|
|
1301
|
+
originalCommand: [command, ...args],
|
|
1302
|
+
resumeStrategy: providerName === "claude" ? "pty-input" : "command",
|
|
1303
|
+
status: "running",
|
|
1304
|
+
attempts: 0,
|
|
1305
|
+
maxAttempts: config.maxAttempts,
|
|
1306
|
+
bufferSeconds: config.bufferSeconds,
|
|
1307
|
+
managedByAar: true,
|
|
1308
|
+
source: "managed-pty"
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
logger.info(`Managed session started: ${session.id}`, "aar");
|
|
1312
|
+
const ptyProcess = pty2.spawn(command, args, {
|
|
1313
|
+
name: "xterm-color",
|
|
1314
|
+
cols: process.stdout.columns || 80,
|
|
1315
|
+
rows: process.stdout.rows || 24,
|
|
1316
|
+
cwd,
|
|
1317
|
+
env: {
|
|
1318
|
+
...process.env,
|
|
1319
|
+
AAR_SESSION_ID: session.id
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
await updateSession(session.id, { pid: ptyProcess.pid, status: "running" });
|
|
1323
|
+
let limitDetected = false;
|
|
1324
|
+
let accumulatedOutput = "";
|
|
1325
|
+
const resizeHandler = () => {
|
|
1326
|
+
try {
|
|
1327
|
+
ptyProcess.resize(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
1328
|
+
} catch {
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
process.stdout.on("resize", resizeHandler);
|
|
1332
|
+
const stdinHandler = (data) => {
|
|
1333
|
+
ptyProcess.write(data.toString());
|
|
1334
|
+
};
|
|
1335
|
+
if (process.stdin.isTTY) {
|
|
1336
|
+
process.stdin.setRawMode(true);
|
|
1337
|
+
}
|
|
1338
|
+
process.stdin.resume();
|
|
1339
|
+
process.stdin.on("data", stdinHandler);
|
|
1340
|
+
ptyProcess.onData(async (data) => {
|
|
1341
|
+
process.stdout.write(data);
|
|
1342
|
+
accumulatedOutput += data;
|
|
1343
|
+
if (accumulatedOutput.length > 8192) {
|
|
1344
|
+
accumulatedOutput = accumulatedOutput.slice(-4096);
|
|
1345
|
+
}
|
|
1346
|
+
if (!limitDetected) {
|
|
1347
|
+
const detection = detectLimit(accumulatedOutput, providerName);
|
|
1348
|
+
if (detection.matched) {
|
|
1349
|
+
limitDetected = true;
|
|
1350
|
+
const resetAtStr = detection.resetAt ? detection.resetAt.toISOString() : void 0;
|
|
1351
|
+
logger.warn(`Usage limit detected for ${provider.displayName}!`, "aar");
|
|
1352
|
+
if (resetAtStr) {
|
|
1353
|
+
logger.warn(`Resets at: ${detection.resetAt?.toLocaleString()}`, "aar");
|
|
1354
|
+
} else {
|
|
1355
|
+
logger.warn("Reset time not specified. Auto-resume will fail without manual intervention or retry-now.", "aar");
|
|
1356
|
+
}
|
|
1357
|
+
await updateSession(session.id, {
|
|
1358
|
+
status: "waiting_limit_reset",
|
|
1359
|
+
lastLimitDetectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1360
|
+
resetAt: resetAtStr,
|
|
1361
|
+
lastOutputSnippet: accumulatedOutput.slice(-1e3)
|
|
1362
|
+
});
|
|
1363
|
+
console.log(`
|
|
1364
|
+
|
|
1365
|
+
\x1B[33m[aar] Usage limit detected. Setting session to waiting state...\x1B[0m`);
|
|
1366
|
+
console.log(`\x1B[33m[aar] Session ID: ${session.id}\x1B[0m`);
|
|
1367
|
+
if (resetAtStr) {
|
|
1368
|
+
console.log(`\x1B[33m[aar] Scheduled to resume after reset time + buffer seconds.\x1B[0m`);
|
|
1369
|
+
} else {
|
|
1370
|
+
console.log(`\x1B[31m[aar] Warning: Reset time unknown. Run 'aar retry-now --id ${session.id}' manually if needed.\x1B[0m`);
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
ptyProcess.kill();
|
|
1374
|
+
} catch {
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
ptyProcess.onExit(async (res) => {
|
|
1380
|
+
process.stdout.off("resize", resizeHandler);
|
|
1381
|
+
process.stdin.off("data", stdinHandler);
|
|
1382
|
+
try {
|
|
1383
|
+
if (process.stdin.isTTY) {
|
|
1384
|
+
process.stdin.setRawMode(false);
|
|
1385
|
+
}
|
|
1386
|
+
process.stdin.pause();
|
|
1387
|
+
} catch {
|
|
1388
|
+
}
|
|
1389
|
+
const currentSession = await getSession(session.id);
|
|
1390
|
+
if (currentSession) {
|
|
1391
|
+
if (currentSession.status === "running") {
|
|
1392
|
+
await updateSession(session.id, {
|
|
1393
|
+
status: res.exitCode === 0 ? "completed" : "failed",
|
|
1394
|
+
pid: void 0
|
|
1395
|
+
});
|
|
1396
|
+
} else if (currentSession.status === "waiting_limit_reset") {
|
|
1397
|
+
await updateSession(session.id, {
|
|
1398
|
+
pid: void 0
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
process.exit(res.exitCode);
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// src/commands/managed.ts
|
|
1407
|
+
async function runManaged(providerNameStr, args) {
|
|
1408
|
+
let providerName;
|
|
1409
|
+
try {
|
|
1410
|
+
const prov = getProvider(providerNameStr);
|
|
1411
|
+
providerName = prov.name;
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
console.error(err.message);
|
|
1414
|
+
process.exit(1);
|
|
1415
|
+
}
|
|
1416
|
+
if (args.length === 0) {
|
|
1417
|
+
console.error("No command specified. Usage: aar managed <provider> -- <command...>");
|
|
1418
|
+
process.exit(1);
|
|
1419
|
+
}
|
|
1420
|
+
const command = args[0];
|
|
1421
|
+
const cmdArgs = args.slice(1);
|
|
1422
|
+
await runInPty({
|
|
1423
|
+
providerName,
|
|
1424
|
+
command,
|
|
1425
|
+
args: cmdArgs,
|
|
1426
|
+
cwd: process.cwd()
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// src/commands/run.ts
|
|
1431
|
+
async function runGeneric(options, args) {
|
|
1432
|
+
if (!options.provider) {
|
|
1433
|
+
console.error("Provider must be specified via --provider <provider>");
|
|
1434
|
+
process.exit(1);
|
|
1435
|
+
}
|
|
1436
|
+
await runManaged(options.provider, args);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// src/commands/status.ts
|
|
1440
|
+
import chalk4 from "chalk";
|
|
1441
|
+
async function runStatus(options) {
|
|
1442
|
+
const daemonRunning = await isDaemonRunning();
|
|
1443
|
+
const daemonPid = await readPid();
|
|
1444
|
+
const waitingSessions = await getWaitingSessions();
|
|
1445
|
+
const statusData = {
|
|
1446
|
+
daemon: {
|
|
1447
|
+
running: daemonRunning,
|
|
1448
|
+
pid: daemonPid
|
|
1449
|
+
},
|
|
1450
|
+
waitingSessions: waitingSessions.map((s) => {
|
|
1451
|
+
const waitMs = getWaitMs(s);
|
|
1452
|
+
const nextResume = s.resetAt ? new Date(new Date(s.resetAt).getTime() + (s.bufferSeconds ?? 120) * 1e3) : null;
|
|
1453
|
+
return {
|
|
1454
|
+
id: s.id,
|
|
1455
|
+
provider: s.provider,
|
|
1456
|
+
cwd: s.cwd,
|
|
1457
|
+
attempts: s.attempts,
|
|
1458
|
+
resetAt: s.resetAt,
|
|
1459
|
+
nextResumeAt: nextResume ? nextResume.toISOString() : null,
|
|
1460
|
+
secondsLeft: waitMs > 0 ? Math.ceil(waitMs / 1e3) : 0
|
|
1461
|
+
};
|
|
1462
|
+
})
|
|
1463
|
+
};
|
|
1464
|
+
if (options.json) {
|
|
1465
|
+
console.log(JSON.stringify(statusData, null, 2));
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
console.log(chalk4.bold("--- agent-auto-resume status ---"));
|
|
1469
|
+
if (daemonRunning) {
|
|
1470
|
+
console.log(`Daemon Status: ${chalk4.green("RUNNING")} (PID: ${daemonPid})`);
|
|
1471
|
+
} else {
|
|
1472
|
+
console.log(`Daemon Status: ${chalk4.red("STOPPED")}`);
|
|
1473
|
+
}
|
|
1474
|
+
console.log(`
|
|
1475
|
+
Waiting Sessions (${waitingSessions.length}):`);
|
|
1476
|
+
if (waitingSessions.length === 0) {
|
|
1477
|
+
console.log(" No waiting sessions.");
|
|
1478
|
+
} else {
|
|
1479
|
+
for (const s of statusData.waitingSessions) {
|
|
1480
|
+
console.log(`
|
|
1481
|
+
Session ID: ${chalk4.cyan(s.id)} [${s.provider}]`);
|
|
1482
|
+
console.log(` CWD: ${s.cwd}`);
|
|
1483
|
+
console.log(` Attempts: ${s.attempts}`);
|
|
1484
|
+
if (s.resetAt) {
|
|
1485
|
+
console.log(` Limit reset time: ${new Date(s.resetAt).toLocaleString()}`);
|
|
1486
|
+
console.log(` Auto-resume scheduled: ${s.nextResumeAt ? new Date(s.nextResumeAt).toLocaleString() : "Unknown"}`);
|
|
1487
|
+
console.log(` Time remaining: ${s.secondsLeft > 0 ? `${s.secondsLeft}s` : "Ready to resume"}`);
|
|
1488
|
+
} else {
|
|
1489
|
+
console.log(` ${chalk4.yellow("Warning: Reset time unknown. Auto-resume will not happen automatically.")}`);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// src/commands/sessions.ts
|
|
1496
|
+
import chalk5 from "chalk";
|
|
1497
|
+
async function runSessions(options) {
|
|
1498
|
+
const sessions = await listSessions();
|
|
1499
|
+
if (options.json) {
|
|
1500
|
+
console.log(JSON.stringify(sessions, null, 2));
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
console.log(chalk5.bold(`--- agent-auto-resume sessions (${sessions.length}) ---`));
|
|
1504
|
+
if (sessions.length === 0) {
|
|
1505
|
+
console.log("No sessions found.");
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
for (const s of sessions) {
|
|
1509
|
+
const statusColor = s.status === "completed" ? chalk5.green : s.status === "waiting_limit_reset" ? chalk5.yellow : s.status === "failed" ? chalk5.red : s.status === "running" ? chalk5.blue : chalk5.gray;
|
|
1510
|
+
console.log(`
|
|
1511
|
+
Session ID: ${chalk5.cyan(s.id)} [${s.provider}]`);
|
|
1512
|
+
console.log(` Status: ${statusColor(s.status)}`);
|
|
1513
|
+
console.log(` CWD: ${s.cwd}`);
|
|
1514
|
+
console.log(` Created: ${new Date(s.createdAt).toLocaleString()}`);
|
|
1515
|
+
console.log(` Command: ${s.originalCommand.join(" ")}`);
|
|
1516
|
+
if (s.resetAt) {
|
|
1517
|
+
console.log(` Reset time: ${new Date(s.resetAt).toLocaleString()}`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/commands/recover.ts
|
|
1523
|
+
import chalk6 from "chalk";
|
|
1524
|
+
async function runRecover(options) {
|
|
1525
|
+
let session;
|
|
1526
|
+
if (options.id) {
|
|
1527
|
+
session = await getSession(options.id);
|
|
1528
|
+
if (!session) {
|
|
1529
|
+
console.error(chalk6.red(`Error: Session with ID ${options.id} not found.`));
|
|
1530
|
+
process.exit(1);
|
|
1531
|
+
}
|
|
1532
|
+
} else if (options.last) {
|
|
1533
|
+
session = await getLastSession();
|
|
1534
|
+
if (!session) {
|
|
1535
|
+
console.error(chalk6.red("Error: No sessions found."));
|
|
1536
|
+
process.exit(1);
|
|
1537
|
+
}
|
|
1538
|
+
} else {
|
|
1539
|
+
const recoverable = await getRecoverableSessions();
|
|
1540
|
+
if (recoverable.length === 0) {
|
|
1541
|
+
console.log("No recoverable sessions found.");
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
console.log(chalk6.bold("Recoverable sessions:"));
|
|
1545
|
+
for (const r of recoverable) {
|
|
1546
|
+
console.log(` - ID: ${chalk6.cyan(r.id)} [${r.provider}] Status: ${r.status} (${r.originalCommand.join(" ")})`);
|
|
1547
|
+
}
|
|
1548
|
+
console.log("\nUse 'aar recover --id <session-id>' or 'aar recover --last' to recover.");
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
const recoverableStatuses = ["waiting_limit_reset", "ready_to_resume", "failed", "resuming", "cancelled"];
|
|
1552
|
+
if (!recoverableStatuses.includes(session.status)) {
|
|
1553
|
+
console.warn(chalk6.yellow(`Warning: Session ${session.id} status is '${session.status}', which might not need recovery.`));
|
|
1554
|
+
}
|
|
1555
|
+
console.log(`Attempting to recover session ${chalk6.cyan(session.id)}...`);
|
|
1556
|
+
await updateSession(session.id, {
|
|
1557
|
+
source: "manual-recover"
|
|
1558
|
+
});
|
|
1559
|
+
const success = await resumeSessionInBackground(session);
|
|
1560
|
+
if (success) {
|
|
1561
|
+
console.log(chalk6.green(`\u2713 Session ${session.id} recovered and completed successfully.`));
|
|
1562
|
+
} else {
|
|
1563
|
+
console.log(chalk6.red(`\u2717 Recovery failed or session hit limit again. Status is currently saved.`));
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/commands/retry-now.ts
|
|
1568
|
+
import chalk7 from "chalk";
|
|
1569
|
+
async function runRetryNow(options) {
|
|
1570
|
+
let session;
|
|
1571
|
+
if (options.id) {
|
|
1572
|
+
session = await getSession(options.id);
|
|
1573
|
+
if (!session) {
|
|
1574
|
+
console.error(chalk7.red(`Error: Session with ID ${options.id} not found.`));
|
|
1575
|
+
process.exit(1);
|
|
1576
|
+
}
|
|
1577
|
+
} else if (options.last) {
|
|
1578
|
+
session = await getLastSession();
|
|
1579
|
+
if (!session) {
|
|
1580
|
+
console.error(chalk7.red("Error: No sessions found."));
|
|
1581
|
+
process.exit(1);
|
|
1582
|
+
}
|
|
1583
|
+
} else {
|
|
1584
|
+
console.error(chalk7.red("Error: Must specify either --id <session-id> or --last."));
|
|
1585
|
+
process.exit(1);
|
|
1586
|
+
}
|
|
1587
|
+
const waitMs = getWaitMs(session);
|
|
1588
|
+
if (waitMs > 0 && !options.force) {
|
|
1589
|
+
const secondsLeft = Math.ceil(waitMs / 1e3);
|
|
1590
|
+
console.log(chalk7.yellow(`Warning: Limit reset time has not yet passed for session ${session.id}.`));
|
|
1591
|
+
console.log(`Time remaining: ${secondsLeft} seconds.`);
|
|
1592
|
+
console.log("Run with '--force' to retry immediately (not recommended).");
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
console.log(`Starting immediate retry for session ${chalk7.cyan(session.id)}...`);
|
|
1596
|
+
await updateSession(session.id, {
|
|
1597
|
+
status: "ready_to_resume"
|
|
1598
|
+
});
|
|
1599
|
+
const success = await resumeSessionInBackground(session);
|
|
1600
|
+
if (success) {
|
|
1601
|
+
console.log(chalk7.green(`\u2713 Session ${session.id} resumed and completed successfully.`));
|
|
1602
|
+
} else {
|
|
1603
|
+
console.log(chalk7.red(`\u2717 Retry failed or hit limit again.`));
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// src/cli.ts
|
|
1608
|
+
var program = new Command();
|
|
1609
|
+
program.name("aar").description("Daemon-based auto-resume CLI tool for Claude Code, Codex, and Antigravity").version("0.1.0");
|
|
1610
|
+
program.command("setup").description("Setup state directories, configuration, and shell integration").option("--shell <type>", "Specify shell type (zsh, bash, fish)").option("--no-shell-modify", "Do not modify shell configuration files automatically").option("--print-shell-snippet", "Only print the shell snippet to stdout").action(runSetup);
|
|
1611
|
+
program.command("daemon <action>").description("Manage the agent-auto-resume daemon (actions: start, stop, restart, status, logs, run)").option("--tmux", "Enable experimental tmux watcher").action((action, options) => handleDaemon(action, options));
|
|
1612
|
+
program.command("managed <provider>").description("Run a CLI tool in managed mode under PTY").argument("[command...]", "Command to execute (e.g. claude, codex, agy)").action((provider, args) => runManaged(provider, args));
|
|
1613
|
+
program.command("run").description("Generic run command to wrap any agent CLI under a provider").option("--provider <provider>", "Specify provider (claude, codex, antigravity)").argument("[command...]", "Command to execute").action((args, options) => {
|
|
1614
|
+
runGeneric(options, args);
|
|
1615
|
+
});
|
|
1616
|
+
program.command("status").description("Show daemon status, waiting sessions, and next resume schedules").option("--json", "Format output as JSON").action(runStatus);
|
|
1617
|
+
program.command("sessions").description("List all saved sessions and their statuses").option("--json", "Format output as JSON").action(runSessions);
|
|
1618
|
+
program.command("recover").description("Manually recover waiting, failed, or cancelled sessions").option("--last", "Recover the most recent session").option("--id <session-id>", "Recover session by ID").action(runRecover);
|
|
1619
|
+
program.command("retry-now").description("Retry waiting sessions immediately").option("--id <session-id>", "Session ID to retry").option("--last", "Retry the most recent waiting session").option("--force", "Force retry even if reset time has not passed").action(runRetryNow);
|
|
1620
|
+
program.command("claude").description("One-shot wrapper to run Claude Code in managed mode").allowUnknownOption().action(() => {
|
|
1621
|
+
const idx = process.argv.indexOf("claude");
|
|
1622
|
+
const forwardedArgs = process.argv.slice(idx + 1);
|
|
1623
|
+
runManaged("claude", ["claude", ...forwardedArgs]);
|
|
1624
|
+
});
|
|
1625
|
+
program.command("codex").description("One-shot wrapper to run Codex CLI in managed mode").allowUnknownOption().action(() => {
|
|
1626
|
+
const idx = process.argv.indexOf("codex");
|
|
1627
|
+
const forwardedArgs = process.argv.slice(idx + 1);
|
|
1628
|
+
runManaged("codex", ["codex", ...forwardedArgs]);
|
|
1629
|
+
});
|
|
1630
|
+
program.command("antigravity").description("One-shot wrapper to run Antigravity CLI in managed mode").allowUnknownOption().action(() => {
|
|
1631
|
+
const idx = process.argv.indexOf("antigravity");
|
|
1632
|
+
const forwardedArgs = process.argv.slice(idx + 1);
|
|
1633
|
+
runManaged("antigravity", ["agy", ...forwardedArgs]);
|
|
1634
|
+
});
|
|
1635
|
+
program.command("agy").description("Alias for antigravity command").allowUnknownOption().action(() => {
|
|
1636
|
+
const idx = process.argv.indexOf("agy");
|
|
1637
|
+
const forwardedArgs = process.argv.slice(idx + 1);
|
|
1638
|
+
runManaged("antigravity", ["agy", ...forwardedArgs]);
|
|
1639
|
+
});
|
|
1640
|
+
program.parse(process.argv);
|
|
1641
|
+
//# sourceMappingURL=cli.js.map
|