codemax-cli 0.1.1 → 0.1.4
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/README.md +14 -1
- package/bin/codemax.cjs +3 -2
- package/package.json +13 -4
- package/src/adapters/index.ts +14 -8
- package/src/chat/app.ts +641 -59
- package/src/chat/logo.ts +37 -0
- package/src/chat/models.ts +33 -0
- package/src/chat/palette.ts +14 -6
- package/src/chat/theme.ts +3 -1
- package/src/chat/transcript.ts +27 -6
- package/src/cli.ts +52 -6
- package/src/config.ts +18 -0
- package/src/core/awake.ts +345 -0
- package/src/core/brief.ts +3 -2
- package/src/core/journal.ts +4 -2
- package/src/core/quota.ts +2 -1
- package/src/core/review.ts +67 -0
- package/src/core/time.ts +9 -0
- package/src/core/update.ts +90 -0
- package/src/engines/acp.ts +11 -2
- package/src/engines/codex.ts +53 -4
- package/src/engines/index.ts +6 -6
- package/src/engines/types.ts +11 -0
- package/src/tui/app.ts +13 -4
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { CODEMAX_HOME, ensureDir } from "./paths.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keep the machine awake while codemax runs, so agents keep working.
|
|
7
|
+
*
|
|
8
|
+
* on no idle/system sleep (and on Linux, no shutdown) while codemax runs
|
|
9
|
+
* lid the same, and keep working with the laptop lid closed
|
|
10
|
+
*
|
|
11
|
+
* How, per platform:
|
|
12
|
+
* macOS `caffeinate -ims -w <pid>`; lid: `pmset -a disablesleep 1` (admin
|
|
13
|
+
* password once). A root watchdog started with the same password sets
|
|
14
|
+
* it and puts it back within seconds of codemax exiting for any reason
|
|
15
|
+
* (quit, crash, kill -9, closed terminal). macOS cannot block shutdown.
|
|
16
|
+
* Linux `systemd-inhibit --mode=block` on idle, sleep and shutdown, plus
|
|
17
|
+
* handle-lid-switch for lid mode. No admin needed on a desktop session.
|
|
18
|
+
* Windows SetThreadExecutionState(ES_CONTINUOUS|ES_SYSTEM_REQUIRED) from a
|
|
19
|
+
* PowerShell helper; lid: lid action set to "do nothing" on the current
|
|
20
|
+
* power plan, restored afterwards. Windows cannot block shutdown.
|
|
21
|
+
*
|
|
22
|
+
* Safety: every holder process watches codemax's pid and exits with it, so a
|
|
23
|
+
* crash never leaves the machine unable to sleep. Settings that outlive a
|
|
24
|
+
* process (pmset, powercfg) are written to a restore record first and put back
|
|
25
|
+
* on release, on exit, or at the next start if codemax died.
|
|
26
|
+
*/
|
|
27
|
+
export type AwakeMode = "off" | "on" | "lid";
|
|
28
|
+
|
|
29
|
+
export interface RunResult {
|
|
30
|
+
code: number;
|
|
31
|
+
out: string;
|
|
32
|
+
err: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AwakeDeps {
|
|
36
|
+
platform: NodeJS.Platform;
|
|
37
|
+
pid: number;
|
|
38
|
+
/** Run a command to completion. `interactive` lets it use the terminal (a sudo prompt). */
|
|
39
|
+
run: (cmd: string[], o?: { interactive?: boolean }) => Promise<RunResult>;
|
|
40
|
+
/** Start a long-running holder process. */
|
|
41
|
+
spawn: (cmd: string[]) => { kill(): void };
|
|
42
|
+
which: (bin: string) => string | null;
|
|
43
|
+
recordPath: string;
|
|
44
|
+
/** Wraps an interactive command (the chat pauses its screen around it). */
|
|
45
|
+
interactive?: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RestoreRecord {
|
|
49
|
+
platform: NodeJS.Platform;
|
|
50
|
+
pid: number;
|
|
51
|
+
at: number;
|
|
52
|
+
mac?: { disablesleep: number };
|
|
53
|
+
win?: { ac: number; dc: number };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AwakeStatus {
|
|
57
|
+
mode: AwakeMode;
|
|
58
|
+
/** True when lid-close is actually covered right now. */
|
|
59
|
+
lid: boolean;
|
|
60
|
+
notes: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function defaultAwakeDeps(): AwakeDeps {
|
|
64
|
+
return {
|
|
65
|
+
platform: process.platform,
|
|
66
|
+
pid: process.pid,
|
|
67
|
+
which: (b) => Bun.which(b),
|
|
68
|
+
recordPath: join(CODEMAX_HOME, "awake-restore.json"),
|
|
69
|
+
run: async (cmd, o) => {
|
|
70
|
+
const p = Bun.spawn(cmd, { stdin: o?.interactive ? "inherit" : "ignore", stdout: o?.interactive ? "inherit" : "pipe", stderr: o?.interactive ? "inherit" : "pipe" });
|
|
71
|
+
const [out, err] = o?.interactive ? ["", ""] : await Promise.all([new Response(p.stdout as ReadableStream).text(), new Response(p.stderr as ReadableStream).text()]);
|
|
72
|
+
return { code: await p.exited, out: out.trim(), err: err.trim() };
|
|
73
|
+
},
|
|
74
|
+
spawn: (cmd) => {
|
|
75
|
+
const p = Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
76
|
+
return { kill: () => p.kill() };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const WHY = "A coding agent is working (codemax /awake)";
|
|
82
|
+
// ES_CONTINUOUS | ES_SYSTEM_REQUIRED (0x80000001), written so PowerShell keeps it unsigned:
|
|
83
|
+
// a bare 0x80000001 is read as a negative Int32 and the call fails.
|
|
84
|
+
const ES_CONTINUOUS_SYSTEM_REQUIRED = "[uint32]2147483649";
|
|
85
|
+
|
|
86
|
+
export class Awake {
|
|
87
|
+
mode: AwakeMode = "off";
|
|
88
|
+
private holder: { kill(): void } | null = null;
|
|
89
|
+
private lidActive = false;
|
|
90
|
+
/** macOS lid mode: the root watchdog and the flag file that keeps it holding. */
|
|
91
|
+
private macWatch: { kill(): void } | null = null;
|
|
92
|
+
private macFlag: string | null = null;
|
|
93
|
+
|
|
94
|
+
constructor(private d: AwakeDeps = defaultAwakeDeps()) {}
|
|
95
|
+
|
|
96
|
+
get status(): AwakeStatus {
|
|
97
|
+
return { mode: this.mode, lid: this.lidActive, notes: [] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Switch modes. Returns what is in effect and anything the user should know. */
|
|
101
|
+
async set(mode: AwakeMode): Promise<AwakeStatus> {
|
|
102
|
+
const notes: string[] = [];
|
|
103
|
+
if (mode === this.mode) return { mode, lid: this.lidActive, notes };
|
|
104
|
+
await this.release(notes);
|
|
105
|
+
if (mode === "off") return { mode: "off", lid: false, notes };
|
|
106
|
+
|
|
107
|
+
// 1. The sleep hold, tied to codemax's lifetime.
|
|
108
|
+
let hold = this.holdCommand(mode === "lid");
|
|
109
|
+
if (hold && this.d.platform === "linux") hold = await this.linuxHold(mode === "lid", notes);
|
|
110
|
+
if (hold) this.holder = this.d.spawn(hold);
|
|
111
|
+
else notes.push(this.d.platform === "linux" ? "systemd-inhibit was not found, so codemax cannot keep this machine awake. Install systemd or use your desktop's 'caffeine' setting." : `keeping awake is not supported on ${this.d.platform}`);
|
|
112
|
+
|
|
113
|
+
// 2. Lid-close, where it needs a setting changed.
|
|
114
|
+
if (mode === "lid") {
|
|
115
|
+
if (this.d.platform === "darwin") this.lidActive = await this.macLidOn(notes);
|
|
116
|
+
else if (this.d.platform === "win32") this.lidActive = await this.winLidOn(notes);
|
|
117
|
+
else this.lidActive = !!hold && hold.some((a) => a.includes("handle-lid-switch")); // Linux: lid-switch inhibitor
|
|
118
|
+
}
|
|
119
|
+
if (this.d.platform === "linux" && hold && !hold.some((a) => a.includes("shutdown"))) notes.push("This session is not allowed to block shutdown; only sleep is prevented.");
|
|
120
|
+
if (this.d.platform !== "linux") notes.push(`${this.d.platform === "darwin" ? "macOS" : "Windows"} does not let apps block a shutdown; only sleep is prevented.`);
|
|
121
|
+
if (this.lidActive) notes.push("With the lid closed, keep the laptop somewhere it can breathe: not in a bag.");
|
|
122
|
+
this.mode = hold || this.lidActive ? mode : "off";
|
|
123
|
+
return { mode: this.mode, lid: this.lidActive, notes };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Undo everything: stop the holder and put changed settings back. */
|
|
127
|
+
async release(notes: string[] = []): Promise<string[]> {
|
|
128
|
+
this.holder?.kill();
|
|
129
|
+
this.holder = null;
|
|
130
|
+
if (this.macFlag) {
|
|
131
|
+
// The watchdog sees the flag gone and turns sleep back on as root.
|
|
132
|
+
rmSync(this.macFlag, { force: true });
|
|
133
|
+
this.macFlag = null;
|
|
134
|
+
const back = await this.waitSleepDisabled(false, 8000);
|
|
135
|
+
this.macWatch = null;
|
|
136
|
+
if (back) rmSync(this.d.recordPath, { force: true });
|
|
137
|
+
}
|
|
138
|
+
if (this.lidActive) await restoreFromRecord(this.d, notes, { interactive: true });
|
|
139
|
+
this.lidActive = false;
|
|
140
|
+
this.mode = "off";
|
|
141
|
+
return notes;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Best effort, synchronous: for process exit, where nothing can be awaited. */
|
|
145
|
+
releaseSync() {
|
|
146
|
+
this.holder?.kill();
|
|
147
|
+
// macOS: removing the flag is enough; the root watchdog restores sleep on its own.
|
|
148
|
+
if (this.macFlag) {
|
|
149
|
+
rmSync(this.macFlag, { force: true });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!this.lidActive) return;
|
|
153
|
+
const rec = readRecord(this.d.recordPath);
|
|
154
|
+
if (!rec) return;
|
|
155
|
+
const cmds = restoreCommands(rec, { nonInteractive: true });
|
|
156
|
+
let ok = true;
|
|
157
|
+
for (const c of cmds) ok = Bun.spawnSync(c, { stdout: "ignore", stderr: "ignore" }).exitCode === 0 && ok;
|
|
158
|
+
if (ok) rmSync(this.d.recordPath, { force: true });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Linux: the system's policy decides which locks a session may take (a remote or
|
|
163
|
+
* headless session often may not block shutdown or the lid switch). Probe with an
|
|
164
|
+
* instant command, keep the widest set that is allowed, and say what was refused.
|
|
165
|
+
*/
|
|
166
|
+
private async linuxHold(lid: boolean, notes: string[]): Promise<string[] | null> {
|
|
167
|
+
const sets = lid ? [["idle", "sleep", "shutdown", "handle-lid-switch"], ["idle", "sleep", "handle-lid-switch"], ["idle", "sleep"]] : [["idle", "sleep", "shutdown"], ["idle", "sleep"]];
|
|
168
|
+
let lastErr = "";
|
|
169
|
+
for (const set of sets) {
|
|
170
|
+
const r = await this.d.run(["systemd-inhibit", `--what=${set.join(":")}`, "--who=codemax", `--why=${WHY}`, "--mode=block", "true"]);
|
|
171
|
+
if (r.code === 0) {
|
|
172
|
+
if (lid && !set.includes("handle-lid-switch")) notes.push("This session is not allowed to hold the lid switch, so closing the lid may still suspend.");
|
|
173
|
+
return this.linuxCommand(set);
|
|
174
|
+
}
|
|
175
|
+
lastErr = r.err || r.out;
|
|
176
|
+
}
|
|
177
|
+
notes.push(`The system refused to let codemax keep it awake${lastErr ? ` (${lastErr.split("\n")[0]!.slice(0, 160)})` : ""}. This usually means a remote or non-desktop session.`);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private linuxCommand(what: string[]): string[] {
|
|
182
|
+
return ["systemd-inhibit", `--what=${what.join(":")}`, "--who=codemax", `--why=${WHY}`, "--mode=block", "sh", "-c", `while kill -0 ${this.d.pid} 2>/dev/null; do sleep 5; done`];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
holdCommand(lid: boolean): string[] | null {
|
|
186
|
+
const pid = String(this.d.pid);
|
|
187
|
+
switch (this.d.platform) {
|
|
188
|
+
case "darwin":
|
|
189
|
+
// -i idle sleep, -m disk sleep, -s system sleep (on AC). The display may still sleep.
|
|
190
|
+
return this.d.which("caffeinate") ? ["caffeinate", "-ims", "-w", pid] : null;
|
|
191
|
+
case "linux": {
|
|
192
|
+
if (!this.d.which("systemd-inhibit")) return null;
|
|
193
|
+
return this.linuxCommand(["idle", "sleep", "shutdown", ...(lid ? ["handle-lid-switch"] : [])]);
|
|
194
|
+
}
|
|
195
|
+
case "win32":
|
|
196
|
+
return [
|
|
197
|
+
"powershell",
|
|
198
|
+
"-NoProfile",
|
|
199
|
+
"-NonInteractive",
|
|
200
|
+
"-Command",
|
|
201
|
+
`$k = Add-Type -MemberDefinition '[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint f);' -Name K -Namespace CodemaxAwake -PassThru; ` +
|
|
202
|
+
`[void]$k::SetThreadExecutionState(${ES_CONTINUOUS_SYSTEM_REQUIRED}); ` +
|
|
203
|
+
`while (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 5 }`,
|
|
204
|
+
];
|
|
205
|
+
default:
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async macLidOn(notes: string[]): Promise<boolean> {
|
|
211
|
+
const cur = await this.d.run(["pmset", "-g"]);
|
|
212
|
+
const disabled = /SleepDisabled\s+1/.test(cur.out);
|
|
213
|
+
if (disabled && !readRecord(this.d.recordPath)) {
|
|
214
|
+
notes.push("Sleep is already disabled on this Mac (pmset disablesleep 1), so the lid can close; codemax will leave that setting as it found it.");
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
writeRecord(this.d.recordPath, { platform: "darwin", pid: this.d.pid, at: Date.now(), mac: { disablesleep: 0 } });
|
|
218
|
+
let auth = await this.d.run(["sudo", "-n", "-v"]);
|
|
219
|
+
if (auth.code !== 0 && this.d.interactive) {
|
|
220
|
+
notes.push("macOS needs your admin password to keep running with the lid closed.");
|
|
221
|
+
auth = await this.d.interactive(() => this.d.run(["sudo", "-v"], { interactive: true }));
|
|
222
|
+
}
|
|
223
|
+
if (auth.code !== 0) {
|
|
224
|
+
rmSync(this.d.recordPath, { force: true });
|
|
225
|
+
notes.push("Lid-close needs admin rights on macOS and was not enabled; the Mac still will not idle-sleep. Run `codemax awake lid` in a terminal to enter your password.");
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
// A root watchdog sets disablesleep and restores it the moment codemax is gone (or the flag
|
|
229
|
+
// is removed). It ignores INT/HUP/TERM so closing the terminal cannot kill it before it restores.
|
|
230
|
+
const flag = join(dirname(this.d.recordPath), `awake-${this.d.pid}.flag`);
|
|
231
|
+
writeFileSync(flag, "");
|
|
232
|
+
this.macFlag = flag;
|
|
233
|
+
this.macWatch = this.d.spawn(["sudo", "-n", "sh", "-c", macWatchdog(this.d.pid, flag, 0)]);
|
|
234
|
+
if (await this.waitSleepDisabled(true, 5000)) return true;
|
|
235
|
+
rmSync(flag, { force: true });
|
|
236
|
+
this.macFlag = null;
|
|
237
|
+
this.macWatch.kill();
|
|
238
|
+
this.macWatch = null;
|
|
239
|
+
await restoreFromRecord(this.d, notes, {});
|
|
240
|
+
notes.push("Could not switch off sleep (pmset), so lid-close was not enabled.");
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Poll `pmset -g` until SleepDisabled matches. */
|
|
245
|
+
private async waitSleepDisabled(want: boolean, ms: number): Promise<boolean> {
|
|
246
|
+
for (let t = 0; t <= ms; t += 250) {
|
|
247
|
+
const cur = await this.d.run(["pmset", "-g"]);
|
|
248
|
+
if (/SleepDisabled\s+1/.test(cur.out) === want) return true;
|
|
249
|
+
await Bun.sleep(250);
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async winLidOn(notes: string[]): Promise<boolean> {
|
|
255
|
+
const q = await this.d.run(["powercfg", "/q", "SCHEME_CURRENT", "SUB_BUTTONS", "LIDACTION"]);
|
|
256
|
+
const ac = parsePowercfg(q.out, "AC"), dc = parsePowercfg(q.out, "DC");
|
|
257
|
+
if (ac === undefined || dc === undefined) {
|
|
258
|
+
notes.push("This PC reports no lid setting (a desktop or server has none), so only sleep is prevented.");
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
writeRecord(this.d.recordPath, { platform: "win32", pid: this.d.pid, at: Date.now(), win: { ac, dc } });
|
|
262
|
+
const cmds = [
|
|
263
|
+
["powercfg", "/setacvalueindex", "SCHEME_CURRENT", "SUB_BUTTONS", "LIDACTION", "0"],
|
|
264
|
+
["powercfg", "/setdcvalueindex", "SCHEME_CURRENT", "SUB_BUTTONS", "LIDACTION", "0"],
|
|
265
|
+
["powercfg", "/setactive", "SCHEME_CURRENT"],
|
|
266
|
+
];
|
|
267
|
+
for (const c of cmds) {
|
|
268
|
+
if ((await this.d.run(c)).code !== 0) {
|
|
269
|
+
await restoreFromRecord(this.d, notes, {});
|
|
270
|
+
notes.push("Could not change the lid setting (powercfg), so lid-close was not enabled.");
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The root watchdog script: hold disablesleep while codemax lives and the flag exists, then restore. */
|
|
279
|
+
export function macWatchdog(pid: number, flag: string, restoreTo: number): string {
|
|
280
|
+
const f = flag.replace(/'/g, `'\\''`);
|
|
281
|
+
return `trap '' INT HUP TERM; pmset -a disablesleep 1; while kill -0 ${pid} 2>/dev/null && [ -e '${f}' ]; do sleep 2; done; pmset -a disablesleep ${restoreTo}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** At startup: if a previous codemax died with a lid setting changed, put it back. */
|
|
285
|
+
export async function recoverAwake(d: AwakeDeps = defaultAwakeDeps()): Promise<string[]> {
|
|
286
|
+
const rec = readRecord(d.recordPath);
|
|
287
|
+
if (!rec || rec.platform !== d.platform) return [];
|
|
288
|
+
if (rec.pid !== d.pid && pidAlive(rec.pid)) return []; // another codemax still holds it
|
|
289
|
+
const notes: string[] = [];
|
|
290
|
+
await restoreFromRecord(d, notes, { interactive: !!d.interactive });
|
|
291
|
+
if (!readRecord(d.recordPath)) notes.unshift("Restored the lid/sleep setting a previous codemax session changed.");
|
|
292
|
+
return notes;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function restoreFromRecord(d: AwakeDeps, notes: string[], o: { interactive?: boolean }) {
|
|
296
|
+
const rec = readRecord(d.recordPath);
|
|
297
|
+
if (!rec) return;
|
|
298
|
+
let ok = true;
|
|
299
|
+
for (const c of restoreCommands(rec, { nonInteractive: true })) ok = (await d.run(c)).code === 0 && ok;
|
|
300
|
+
if (!ok && rec.mac && o.interactive && d.interactive) {
|
|
301
|
+
const r = await d.interactive(() => d.run(["sudo", "pmset", "-a", "disablesleep", String(rec.mac!.disablesleep)], { interactive: true }));
|
|
302
|
+
ok = r.code === 0;
|
|
303
|
+
}
|
|
304
|
+
if (ok) rmSync(d.recordPath, { force: true });
|
|
305
|
+
else notes.push(rec.mac ? "Could not turn sleep back on. Run: sudo pmset -a disablesleep 0" : "Could not restore the lid setting. Run `codemax awake off` to retry.");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function restoreCommands(rec: RestoreRecord, o: { nonInteractive: boolean }): string[][] {
|
|
309
|
+
if (rec.mac) return [["sudo", ...(o.nonInteractive ? ["-n"] : []), "pmset", "-a", "disablesleep", String(rec.mac.disablesleep)]];
|
|
310
|
+
if (rec.win)
|
|
311
|
+
return [
|
|
312
|
+
["powercfg", "/setacvalueindex", "SCHEME_CURRENT", "SUB_BUTTONS", "LIDACTION", String(rec.win.ac)],
|
|
313
|
+
["powercfg", "/setdcvalueindex", "SCHEME_CURRENT", "SUB_BUTTONS", "LIDACTION", String(rec.win.dc)],
|
|
314
|
+
["powercfg", "/setactive", "SCHEME_CURRENT"],
|
|
315
|
+
];
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** "Current AC Power Setting Index: 0x00000001" → 1 */
|
|
320
|
+
export function parsePowercfg(out: string, which: "AC" | "DC"): number | undefined {
|
|
321
|
+
const m = out.match(new RegExp(`Current ${which} Power Setting Index:\\s*0x([0-9a-f]+)`, "i"));
|
|
322
|
+
return m ? parseInt(m[1]!, 16) : undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function readRecord(path: string): RestoreRecord | null {
|
|
326
|
+
try {
|
|
327
|
+
return existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as RestoreRecord) : null;
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function writeRecord(path: string, rec: RestoreRecord) {
|
|
334
|
+
ensureDir(join(path, ".."));
|
|
335
|
+
writeFileSync(path, JSON.stringify(rec, null, 2) + "\n");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function pidAlive(pid: number): boolean {
|
|
339
|
+
try {
|
|
340
|
+
process.kill(pid, 0);
|
|
341
|
+
return true;
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
package/src/core/brief.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { diffStat, headLine, recentCommits } from "./git.ts";
|
|
|
4
4
|
import { checkpointDiff } from "./checkpoint.ts";
|
|
5
5
|
import { BRIEF_MARKER, type Ledger } from "./ledger.ts";
|
|
6
6
|
import type { Step, StepOf } from "./journal.ts";
|
|
7
|
+
import { fmtTime12 } from "./time.ts";
|
|
7
8
|
|
|
8
9
|
export interface BriefInput {
|
|
9
10
|
ledger: Ledger;
|
|
@@ -161,7 +162,7 @@ export async function buildCatchup(input: BriefInput): Promise<string | null> {
|
|
|
161
162
|
function whereItStopped(steps: Step[], end: StepOf<"end"> | undefined): string[] {
|
|
162
163
|
const out: string[] = [];
|
|
163
164
|
if (!end) return ["The last agent had not finished its first turn."];
|
|
164
|
-
const when =
|
|
165
|
+
const when = fmtTime12(end.ts);
|
|
165
166
|
const why =
|
|
166
167
|
end.outcome === "limit" ? `ran out of its usage limit${end.detail ? ` ("${oneLine(end.detail, 140)}")` : ""}`
|
|
167
168
|
: end.outcome === "cancelled" ? "was interrupted by the user"
|
|
@@ -182,7 +183,7 @@ function whereItStopped(steps: Step[], end: StepOf<"end"> | undefined): string[]
|
|
|
182
183
|
return out;
|
|
183
184
|
}
|
|
184
185
|
|
|
185
|
-
function formatStep(s: Step): string | null {
|
|
186
|
+
export function formatStep(s: Step): string | null {
|
|
186
187
|
switch (s.kind) {
|
|
187
188
|
case "user":
|
|
188
189
|
return `- USER: ${oneLine(s.text, 600)}`;
|
package/src/core/journal.ts
CHANGED
|
@@ -79,7 +79,8 @@ export class Journal {
|
|
|
79
79
|
* follow-ups. Earlier steps are history.
|
|
80
80
|
*/
|
|
81
81
|
task(steps = this.steps()): { request?: StepOf<"user">; followUps: StepOf<"user">[]; steps: Step[]; earlier: Step[] } {
|
|
82
|
-
|
|
82
|
+
// A forwarded side-agent note continues the current task; it never starts one.
|
|
83
|
+
const users = steps.filter((s): s is StepOf<"user"> => s.kind === "user" && !s.text.startsWith("A side agent ("));
|
|
83
84
|
if (!users.length) return { followUps: [], steps: [], earlier: steps };
|
|
84
85
|
// A task begins at a user message that follows a turn which ended "done";
|
|
85
86
|
// messages sent while the work was cut short continue the same task.
|
|
@@ -91,7 +92,8 @@ export class Journal {
|
|
|
91
92
|
}
|
|
92
93
|
const task = steps.slice(startIdx);
|
|
93
94
|
const taskUsers = task.filter((s): s is StepOf<"user"> => s.kind === "user");
|
|
94
|
-
|
|
95
|
+
const request = taskUsers.find((s) => !s.text.startsWith("A side agent ("));
|
|
96
|
+
return { request, followUps: taskUsers.filter((s) => s !== request), steps: task, earlier: steps.slice(0, startIdx) };
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
/** When this agent last did any work (not counting being briefed). */
|
package/src/core/quota.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentId, QuotaStatus } from "../adapters/types.ts";
|
|
2
2
|
import { stripAnsi } from "./terminal.ts";
|
|
3
|
+
import { fmtTime12 } from "./time.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Vendor-neutral phrases. Deliberately narrow: "rate limit" or "429" alone
|
|
@@ -47,7 +48,7 @@ export class OutputScanner {
|
|
|
47
48
|
export function quotaLabel(q?: QuotaStatus): string {
|
|
48
49
|
if (!q) return "";
|
|
49
50
|
if (isExhausted(q)) {
|
|
50
|
-
const reset = q.resetsAt ? ` until ${
|
|
51
|
+
const reset = q.resetsAt ? ` until ${fmtTime12(q.resetsAt)}` : "";
|
|
51
52
|
return q.source === "output" ? `exhausted? (text match)${reset}` : `EXHAUSTED${reset}`;
|
|
52
53
|
}
|
|
53
54
|
if (typeof q.usedPercent === "number") return `${Math.round(q.usedPercent)}% used`;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { AgentId } from "../adapters/types.ts";
|
|
2
|
+
import type { Step, StepOf } from "./journal.ts";
|
|
3
|
+
import { formatStep } from "./brief.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The side agent: a second agent (any harness) that reviews the main agent's
|
|
7
|
+
* work after each turn and speaks up only when something deserves the user's
|
|
8
|
+
* attention: drift from the request or plan, claims without evidence, risky
|
|
9
|
+
* assumptions, missing tests. Its notes can be forwarded to the main agent so
|
|
10
|
+
* it stays aligned with the plan, whichever harness it runs on.
|
|
11
|
+
*/
|
|
12
|
+
export const NO_NOTE = "NO_NOTE";
|
|
13
|
+
|
|
14
|
+
export function reviewInstructions(mainAgent: string): string {
|
|
15
|
+
return [
|
|
16
|
+
`You are a side agent reviewing the work of another coding agent (${mainAgent}) for the user. You do not do the work yourself.`,
|
|
17
|
+
"Do not use any tools and do not change any files. Read only what is below.",
|
|
18
|
+
"Check the latest work against the user's request and the plan. Look for: drift from what the user asked; conclusions or claims the steps do not actually support; decisions resting on thin evidence (one run, one sample, a check that barely passed); risky assumptions; tests or edge cases that were skipped; work that will be wasted if an untested assumption is wrong.",
|
|
19
|
+
`If nothing is worth interrupting the user for, reply with exactly ${NO_NOTE} and nothing else. Most turns deserve ${NO_NOTE}; do not nitpick style.`,
|
|
20
|
+
"Otherwise reply with one note in exactly this shape, under 130 words, plain language, concrete facts from the work:",
|
|
21
|
+
"",
|
|
22
|
+
"Heads up · <one sentence: what could go wrong>",
|
|
23
|
+
"",
|
|
24
|
+
"**<short title>**",
|
|
25
|
+
"- <fact from the work>",
|
|
26
|
+
"- <why it matters for the user's goal>",
|
|
27
|
+
"- **Consider <one concrete, cheap action>.**",
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What the reviewer sees: the request, the plan, and the main agent's steps since its last review. */
|
|
32
|
+
export function buildReviewPrompt(o: { steps: Step[]; since: number; mainAgent: string; reviewer: AgentId; context?: string }): string | null {
|
|
33
|
+
// The reviewer's own turns are never journaled, so every step here is the main agent's,
|
|
34
|
+
// even when both run on the same harness (Claude reviewing Claude).
|
|
35
|
+
const recent = o.steps.filter((s) => s.ts > o.since && s.kind !== "handoff" && s.kind !== "thought");
|
|
36
|
+
const work = recent.filter((s) => s.kind === "tool" || s.kind === "text" || s.kind === "plan");
|
|
37
|
+
if (!work.length) return null;
|
|
38
|
+
const users = o.steps.filter((s): s is StepOf<"user"> => s.kind === "user");
|
|
39
|
+
const request = users.at(-1);
|
|
40
|
+
const plan = [...o.steps].reverse().find((s): s is StepOf<"plan"> => s.kind === "plan");
|
|
41
|
+
const out: string[] = [reviewInstructions(o.mainAgent), ""];
|
|
42
|
+
if (o.context) out.push("## Context (the task so far, recorded by codemax)", o.context, "");
|
|
43
|
+
if (request) out.push("## The user's latest request", request.text.trim().split("\n").map((l) => `> ${l}`).join("\n"), "");
|
|
44
|
+
if (plan) out.push("## Plan", ...plan.entries.map((e) => `${e.status === "completed" ? "[x]" : e.status === "in_progress" ? "[~]" : "[ ]"} ${e.content}`), "");
|
|
45
|
+
out.push("## Latest work to review (oldest first)", ...recent.map((s) => formatStep(s)).filter((l): l is string => !!l));
|
|
46
|
+
return out.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The note, or null when the reviewer had nothing to say. */
|
|
50
|
+
export function parseNote(reply: string): string | null {
|
|
51
|
+
const t = reply.trim();
|
|
52
|
+
if (!t || t.includes(NO_NOTE) || t.length < 40) return null;
|
|
53
|
+
const at = t.indexOf("Heads up");
|
|
54
|
+
return (at > 0 ? t.slice(at) : t).trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Forwarded notes start with this; they are follow-ups, never a new task, and never reviewed. */
|
|
58
|
+
export const NOTE_PREFIX = "A side agent (";
|
|
59
|
+
|
|
60
|
+
export function isForwardedNote(text: string): boolean {
|
|
61
|
+
return text.startsWith(NOTE_PREFIX);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The message that hands a note to the main agent. */
|
|
65
|
+
export function forwardMessage(note: string, reviewerName: string): string {
|
|
66
|
+
return `${NOTE_PREFIX}${reviewerName}) reviewing your work left this note. Weigh it against the user's request and plan: say in a sentence whether you agree, and adjust your work if it is right.\n\n${note}`;
|
|
67
|
+
}
|
package/src/core/time.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Every time codemax shows is 12-hour, whatever the system locale: "7:25 PM", "Oct 18, 7:25 PM". */
|
|
2
|
+
export function fmtTime12(ts: number): string {
|
|
3
|
+
return new Date(ts).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function fmtDateTime12(ts: number): string {
|
|
7
|
+
const d = new Date(ts);
|
|
8
|
+
return `${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${fmtTime12(ts)}`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { CODEMAX_HOME, ensureDir } from "./paths.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Update checks and self-update. At most every 12 hours (3 s timeout, never
|
|
7
|
+
* blocking startup) codemax asks for the newest version from the place its own
|
|
8
|
+
* update comes from: npm for npm installs, GitHub releases for the installer,
|
|
9
|
+
* source checkouts and standalone builds. So a release is only offered where
|
|
10
|
+
* `/update` can actually fetch it. `/update` / `codemax update` then updates
|
|
11
|
+
* the way codemax was installed.
|
|
12
|
+
*/
|
|
13
|
+
const REGISTRY = "https://registry.npmjs.org/codemax-cli/latest";
|
|
14
|
+
const RELEASES = "https://api.github.com/repos/SkidGod4444/codemax-cli/releases/latest";
|
|
15
|
+
const CHECK_EVERY_MS = 12 * 60 * 60 * 1000;
|
|
16
|
+
const CACHE = () => join(CODEMAX_HOME, "update-check.json");
|
|
17
|
+
|
|
18
|
+
export type InstallKind = "npm" | "script" | "source" | "binary";
|
|
19
|
+
|
|
20
|
+
/** How this codemax was installed, judged from where its code lives. */
|
|
21
|
+
export function installKind(root = resolve(import.meta.dir, "..", "..")): { kind: InstallKind; root: string } {
|
|
22
|
+
if (root.includes("$bunfs") || root.startsWith("/$bunfs") || root.startsWith("B:\\~BUN")) return { kind: "binary", root };
|
|
23
|
+
if (root.includes(`${sep}node_modules${sep}codemax-cli`) || root.includes("/node_modules/codemax-cli")) return { kind: "npm", root };
|
|
24
|
+
if (existsSync(join(root, ".git")) && /[\\/]\.codemax[\\/]cli$/.test(root)) return { kind: "script", root };
|
|
25
|
+
if (existsSync(join(root, ".git"))) return { kind: "source", root };
|
|
26
|
+
return { kind: "npm", root };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** a > b for x.y.z versions (pre-release suffixes ignored). */
|
|
30
|
+
export function isNewer(a: string, b: string): boolean {
|
|
31
|
+
const p = (v: string) => v.replace(/^v/, "").split("-")[0]!.split(".").map((n) => Number(n) || 0);
|
|
32
|
+
const [x, y] = [p(a), p(b)];
|
|
33
|
+
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function latestVersion(source: "npm" | "github" = "npm", fetchImpl: typeof fetch = fetch, timeoutMs = 3000): Promise<string | null> {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetchImpl(source === "npm" ? REGISTRY : RELEASES, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: source === "npm" ? "application/json" : "application/vnd.github+json" } });
|
|
40
|
+
if (!res.ok) return null;
|
|
41
|
+
const body = (await res.json()) as { version?: unknown; tag_name?: unknown };
|
|
42
|
+
const v = String((source === "npm" ? body.version : body.tag_name) ?? "").replace(/^v/, "");
|
|
43
|
+
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Where this install's updates come from. */
|
|
50
|
+
export function updateSource(kind: InstallKind = installKind().kind): "npm" | "github" {
|
|
51
|
+
return kind === "npm" ? "npm" : "github";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The latest version when it is newer than `current`; null otherwise. Cached for 12 hours. */
|
|
55
|
+
export async function checkForUpdate(current: string, o: { force?: boolean; fetchImpl?: typeof fetch; source?: "npm" | "github" } = {}): Promise<string | null> {
|
|
56
|
+
const source = o.source ?? updateSource();
|
|
57
|
+
let cached: { at: number; latest: string | null; source?: string } | null = null;
|
|
58
|
+
try {
|
|
59
|
+
cached = existsSync(CACHE()) ? JSON.parse(readFileSync(CACHE(), "utf8")) : null;
|
|
60
|
+
} catch {
|
|
61
|
+
cached = null;
|
|
62
|
+
}
|
|
63
|
+
let latest = cached?.latest ?? null;
|
|
64
|
+
if (o.force || !cached || cached.source !== source || Date.now() - cached.at > CHECK_EVERY_MS) {
|
|
65
|
+
latest = await latestVersion(source, o.fetchImpl);
|
|
66
|
+
try {
|
|
67
|
+
ensureDir(CODEMAX_HOME);
|
|
68
|
+
writeFileSync(CACHE(), JSON.stringify({ at: Date.now(), latest, source }) + "\n");
|
|
69
|
+
} catch {
|
|
70
|
+
/* read-only home: check again next time */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return latest && isNewer(latest, current) ? latest : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The command that updates this install, or null with an explanation when it cannot update itself. */
|
|
77
|
+
export function updatePlan(install = installKind(), platform = process.platform): { cmd: string[][]; env?: Record<string, string>; manual?: string; label: string } {
|
|
78
|
+
switch (install.kind) {
|
|
79
|
+
case "npm":
|
|
80
|
+
return { cmd: [["npm", "install", "-g", "codemax-cli@latest"]], label: "npm install -g codemax-cli@latest" };
|
|
81
|
+
case "script":
|
|
82
|
+
return platform === "win32"
|
|
83
|
+
? { cmd: [["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(install.root, "install.ps1")]], env: { CODEMAX_DIR: install.root }, label: "the codemax installer (newest release)" }
|
|
84
|
+
: { cmd: [["bash", join(install.root, "install.sh")]], env: { CODEMAX_DIR: install.root }, label: "the codemax installer (newest release)" };
|
|
85
|
+
case "source":
|
|
86
|
+
return { cmd: [["git", "-C", install.root, "pull", "--ff-only"], ["bun", "install", "--cwd", install.root]], label: `git pull in ${install.root}` };
|
|
87
|
+
case "binary":
|
|
88
|
+
return { cmd: [], manual: "This is a standalone build; download the new release from https://github.com/SkidGod4444/codemax-cli/releases or switch to `npm install -g codemax-cli`.", label: "manual download" };
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/engines/acp.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ClientSideConnection, ndJsonStream, type Client, type Stream } from "@agentclientprotocol/sdk";
|
|
2
2
|
import type { AgentId } from "../adapters/types.ts";
|
|
3
|
-
import type { Engine, EngineEvent, EngineInfo, EngineOption, EngineSessionRef, EngineStartOptions, PermissionRequest, StopReason } from "./types.ts";
|
|
3
|
+
import type { Engine, EngineEvent, EngineInfo, EngineOption, EngineSessionRef, EngineStartOptions, PermissionRequest, PlanLimit, StopReason } from "./types.ts";
|
|
4
4
|
import { agentEnv } from "./env.ts";
|
|
5
5
|
import { platformArgv } from "../core/spawn.ts";
|
|
6
6
|
import { classifyError } from "./errors.ts";
|
|
@@ -204,7 +204,7 @@ export class AcpEngine implements Engine {
|
|
|
204
204
|
// `used` is how full the context window is, not tokens spent: it feeds the context meter.
|
|
205
205
|
// Token counts come from the prompt response (or session files) at turn end.
|
|
206
206
|
if (typeof u.used === "number") this.turnContext = u.used;
|
|
207
|
-
this.emit({ type: "usage", contextUsed: u.used, contextSize: u.size, costUsd: delta, model: this.currentModelName() });
|
|
207
|
+
this.emit({ type: "usage", contextUsed: u.used, contextSize: u.size, costUsd: delta, model: this.currentModelName(), planLimits: planLimitsOf(u) });
|
|
208
208
|
break;
|
|
209
209
|
}
|
|
210
210
|
default:
|
|
@@ -339,6 +339,15 @@ function extraOptions(config: unknown): EngineOption[] {
|
|
|
339
339
|
}));
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/** Claude's plan limits ride on usage updates as `_meta["_claude/rateLimit"]` (SDKRateLimitInfo). */
|
|
343
|
+
function planLimitsOf(u: any): PlanLimit[] | undefined {
|
|
344
|
+
const rl = u?._meta?.["_claude/rateLimit"];
|
|
345
|
+
if (!rl || typeof rl !== "object") return undefined;
|
|
346
|
+
const util = typeof rl.utilization === "number" ? (rl.utilization <= 1 ? rl.utilization * 100 : rl.utilization) : undefined;
|
|
347
|
+
const reset = typeof rl.resetsAt === "number" ? (rl.resetsAt < 1e12 ? rl.resetsAt * 1000 : rl.resetsAt) : undefined;
|
|
348
|
+
return [{ window: String(rl.rateLimitType ?? "plan"), usedPercent: util, resetsAt: reset, status: rl.status }];
|
|
349
|
+
}
|
|
350
|
+
|
|
342
351
|
function mapStatus(s: string | undefined): "pending" | "in_progress" | "completed" | "failed" {
|
|
343
352
|
if (s === "in_progress" || s === "completed" || s === "failed") return s;
|
|
344
353
|
return "pending";
|