codemax-cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The codemax wordmark for the terminal: the same halftone grid the website
3
+ * uses ("codemax" in Inter Tight 800, 13 rows, 0 = empty … 9 = full), drawn
4
+ * with half blocks so two grid rows fit in one terminal line.
5
+ */
6
+ const GRID = [
7
+ "0000000000000000000000000028870000000000000000000000000000000000000000000000",
8
+ "0000000000000000000000000029970000000000000000000000000000000000000000000000",
9
+ "0000000000000000000000000029970000000000000000000000000000000000000000000000",
10
+ "0036876200003687510000477429970004787410057715773027861000267863004773005773",
11
+ "0699999930069999993006999979970079999981079969999499998104999999501998029970",
12
+ "3999559980499856998039996599970599635996079996899976999319983399910599369920",
13
+ "7994006992899300799359960049970899100799079930299800699402210189920189899600",
14
+ "8992000000999100599569940029971999999999179920199700599401578999920049999100",
15
+ "8992000000999100599569940029971999777777179920199700599418998689920049999100",
16
+ "7994006992899300799359960049970999000121079920199700599449950079920199899600",
17
+ "3998459980599846999139996599970599634998079920199700599459951399920699369930",
18
+ "0699999930079999993007999979970189999993079920199700599429999989922997029980",
19
+ "0047886200004788620000577517750015788620057720177500477303787347725772004773",
20
+ ] as const;
21
+
22
+ /** Logo lines for a terminal this wide: the full 76-column logo from 78 columns, none below. */
23
+ export function logoLines(width: number): string[] {
24
+ // Squeezed to half width the letters run together, so narrow terminals get the text title instead.
25
+ if (width < 78) return [];
26
+ const on = (r: number, c: number) => Number(GRID[r]?.[c] ?? "0") >= 4;
27
+ const lines: string[] = [];
28
+ for (let r = 0; r < GRID.length; r += 2) {
29
+ let line = "";
30
+ for (let c = 0; c < GRID[0]!.length; c++) {
31
+ const up = on(r, c), down = on(r + 1, c);
32
+ line += up && down ? "█" : up ? "▀" : down ? "▄" : " ";
33
+ }
34
+ lines.push(line.replace(/\s+$/, ""));
35
+ }
36
+ return lines;
37
+ }
@@ -70,7 +70,7 @@ export class Transcript {
70
70
  }
71
71
 
72
72
  /** Startup card: brand mark, what codemax is, the project, and any notes. */
73
- header(lines: { title: string; subtitle: string; path: string; notes: string[] }) {
73
+ header(lines: { title: string; subtitle: string; path: string; notes: string[]; logo?: string[] }) {
74
74
  if (this.gone) return;
75
75
  const card = new BoxRenderable(this.renderer, {
76
76
  id: `header-${++this.n}`,
@@ -82,15 +82,33 @@ export class Transcript {
82
82
  paddingX: 1,
83
83
  marginTop: 1,
84
84
  });
85
- const title = new StyledText([fg(C.accent)("✻ "), fg(C.accent)(lines.title)]);
86
- card.add(new TextRenderable(this.renderer, { id: `h-t-${this.n}`, content: title, attributes: 1 } as any));
87
- card.add(new TextRenderable(this.renderer, { id: `h-s-${this.n}`, content: lines.subtitle, fg: C.dim }));
85
+ if (lines.logo?.length) {
86
+ // The wordmark: one line per two rows of the website's halftone grid, never wrapped.
87
+ for (const [i, l] of lines.logo.entries()) card.add(new TextRenderable(this.renderer, { id: `h-l-${this.n}-${i}`, content: l, fg: C.accent, wrapMode: "none" } as any));
88
+ card.add(new TextRenderable(this.renderer, { id: `h-s-${this.n}`, content: `${lines.title} · ${lines.subtitle}`, fg: C.dim }));
89
+ } else {
90
+ const title = new StyledText([fg(C.accent)("✻ "), fg(C.accent)(lines.title)]);
91
+ card.add(new TextRenderable(this.renderer, { id: `h-t-${this.n}`, content: title, attributes: 1 } as any));
92
+ card.add(new TextRenderable(this.renderer, { id: `h-s-${this.n}`, content: lines.subtitle, fg: C.dim }));
93
+ }
88
94
  card.add(new TextRenderable(this.renderer, { id: `h-gap-${this.n}`, content: "", height: 1 }));
89
95
  card.add(new TextRenderable(this.renderer, { id: `h-p-${this.n}`, content: `cwd: ${lines.path}`, fg: C.dim, wrapMode: "char" } as any));
90
96
  for (const [i, note] of lines.notes.entries()) card.add(new TextRenderable(this.renderer, { id: `h-n-${this.n}-${i}`, content: note, fg: C.dim, wrapMode: "word" } as any));
91
97
  this.box.add(card);
92
98
  }
93
99
 
100
+ /** A side agent's note: a bordered card that stands apart from the conversation. */
101
+ noteCard(title: string, body: string, hint: string) {
102
+ if (this.gone) return;
103
+ this.gap();
104
+ const card = new BoxRenderable(this.renderer, { id: `note-${++this.n}`, width: "100%", flexDirection: "column", border: true, borderStyle: "rounded", borderColor: C.warn, paddingX: 1 });
105
+ card.add(new TextRenderable(this.renderer, { id: `nt-${this.n}`, content: title, fg: C.warn, attributes: 1 } as any));
106
+ card.add(new TextRenderable(this.renderer, { id: `nb-${this.n}`, content: styleMarkdown(body), wrapMode: "word", width: "100%" } as any));
107
+ card.add(new TextRenderable(this.renderer, { id: `nh-${this.n}`, content: hint, fg: C.dim, wrapMode: "word" } as any));
108
+ this.box.add(card);
109
+ this.scrollToEnd();
110
+ }
111
+
94
112
  user(text: string) {
95
113
  this.gap();
96
114
  this.line(`${G.prompt} `, text, { fg: undefined, glyphFg: C.accent });
package/src/cli.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { loadConfig, CONFIG_PATH, hotkeyLabel } from "./config.ts";
1
+ import { loadConfig, CONFIG_PATH, hotkeyLabel, saveConfigKey } from "./config.ts";
2
+ import { defaultAwakeDeps, recoverAwake } from "./core/awake.ts";
3
+ import { checkForUpdate, installKind, updatePlan } from "./core/update.ts";
4
+ import { platformArgv } from "./core/spawn.ts";
5
+ import pkg from "../package.json" with { type: "json" };
2
6
  import { canonicalCwd } from "./core/paths.ts";
3
7
  import { Ledger } from "./core/ledger.ts";
4
8
  import { Orchestrator } from "./core/orchestrator.ts";
@@ -27,6 +31,8 @@ usage:
27
31
  codemax brief [--to <id>] print the handoff brief that would be injected
28
32
  codemax config print config path and values
29
33
  codemax keys show the raw bytes your terminal sends per keypress (hotkey debugging)
34
+ codemax update [--check] update codemax the way it was installed (--check only reports)
35
+ codemax awake [on|lid|off|status] keep the machine awake while codemax runs (lid: also with the lid closed)
30
36
  codemax quota [clear [<id>]] show or clear remembered quota state for this project
31
37
  codemax doctor [--fix] check agent auth/permission setup (API keys that would be billed, silent modes)
32
38
  codemax usage [--all] tokens and API-equivalent cost per agent for this project (or every project)
@@ -164,6 +170,45 @@ async function main(argv: string[]) {
164
170
  }
165
171
  return;
166
172
  }
173
+ case "update": {
174
+ const latest = await checkForUpdate(pkg.version, { force: true });
175
+ console.log(latest ? `codemax ${latest} is available (you have ${pkg.version})` : `codemax ${pkg.version} is the latest`);
176
+ if (args.includes("--check") || !latest) return;
177
+ const plan = updatePlan();
178
+ if (plan.manual) return console.log(plan.manual);
179
+ console.log(`updating with ${plan.label} (${installKind().kind} install)\n`);
180
+ for (const cmd of plan.cmd) {
181
+ const p = Bun.spawn(platformArgv(cmd), { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: { ...process.env, ...(plan.env ?? {}) } });
182
+ if ((await p.exited) !== 0) {
183
+ console.error(`\nupdate failed · run it yourself: ${plan.cmd.map((c) => c.join(" ")).join(" && ")}`);
184
+ process.exit(1);
185
+ }
186
+ }
187
+ console.log("\ncodemax updated.");
188
+ return;
189
+ }
190
+ case "awake": {
191
+ const v = (args[0] ?? "status").toLowerCase();
192
+ const support = process.platform === "linux" ? "sleep, lid close and shutdown are blocked (systemd-inhibit)" : process.platform === "darwin" ? "sleep is blocked; lid close needs your admin password once; macOS cannot block shutdown" : process.platform === "win32" ? "sleep is blocked; lid close switches the lid action to 'do nothing' and back; Windows cannot block shutdown" : "not supported on this platform";
193
+ if (v === "status") {
194
+ console.log(`awake: ${config.awake ?? "off"} (applies while codemax runs)\n${process.platform}: ${support}`);
195
+ return;
196
+ }
197
+ if (v !== "on" && v !== "lid" && v !== "off") {
198
+ console.error("usage: codemax awake [on|lid|off|status]");
199
+ process.exit(2);
200
+ }
201
+ if (v === "off") for (const n of await recoverAwake({ ...defaultAwakeDeps(), pid: -1, interactive: (fn) => fn() })) console.log(n);
202
+ if (v === "lid" && process.platform === "darwin") {
203
+ console.log("macOS needs your admin password to keep working with the lid closed (pmset disablesleep).");
204
+ const ok = Bun.spawnSync(["sudo", "-v"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }).exitCode === 0;
205
+ if (!ok) console.log("no admin rights: lid-close will not be enabled; the Mac still will not idle-sleep.");
206
+ }
207
+ saveConfigKey("awake", v);
208
+ console.log(v === "off" ? "awake off: the machine sleeps normally" : `awake ${v}: applies whenever codemax is running · ${support}`);
209
+ if (v === "lid") console.log("With the lid closed, keep the laptop somewhere it can breathe: not in a bag.");
210
+ return;
211
+ }
167
212
  case "quota": {
168
213
  const ledger = new Ledger(cwd);
169
214
  if (args[0] === "clear") {
package/src/config.ts CHANGED
@@ -28,6 +28,20 @@ export interface Config {
28
28
  checkpoints: boolean;
29
29
  /** Refresh the public price list weekly for /usage (set false to stay offline). */
30
30
  priceUpdates: boolean;
31
+ /**
32
+ * Keep the machine awake while codemax runs: "on" prevents sleep, "lid" also keeps
33
+ * working with the laptop lid closed. See src/core/awake.ts for each platform.
34
+ */
35
+ awake: "off" | "on" | "lid";
36
+ /**
37
+ * Side agent: another agent that reviews the main agent's work after each turn and
38
+ * leaves a note when something needs attention. `forward: "auto"` sends notes to the
39
+ * main agent by itself; "ask" waits for /forward.
40
+ */
41
+ /** `every`: review every Nth working turn (1 = each turn, 0 = only on /review). Each review is a request on the side agent's plan. */
42
+ sideAgent: { agent: AgentId | null; forward: "ask" | "auto"; model?: string; every?: number };
43
+ /** Check npm for a newer codemax at most every 12 hours and offer /update. */
44
+ updateCheck: boolean;
31
45
  /** Colour theme: "auto" follows the terminal's background, or force "dark" / "light". */
32
46
  theme: "auto" | "dark" | "light";
33
47
  /**
@@ -49,6 +63,9 @@ export const DEFAULT_CONFIG: Config = {
49
63
  summarizeOnSwitch: true,
50
64
  bellAfterSeconds: 20,
51
65
  theme: "auto",
66
+ awake: "off",
67
+ updateCheck: true,
68
+ sideAgent: { agent: null, forward: "ask" },
52
69
  brief: { turns: 10, maxChars: 8000, turnChars: 1200, resumeChars: 24000 },
53
70
  };
54
71
 
@@ -71,6 +88,7 @@ export function loadConfig(): Config {
71
88
  brief: { ...DEFAULT_CONFIG.brief, ...(raw.brief ?? {}) },
72
89
  agentArgs: { ...(raw.agentArgs ?? {}) },
73
90
  budgets: { ...(raw.budgets ?? {}) },
91
+ sideAgent: { ...DEFAULT_CONFIG.sideAgent, ...(raw.sideAgent ?? {}) },
74
92
  routes: Array.isArray(raw.routes) ? raw.routes : [],
75
93
  };
76
94
  } catch {
@@ -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
@@ -182,7 +182,7 @@ function whereItStopped(steps: Step[], end: StepOf<"end"> | undefined): string[]
182
182
  return out;
183
183
  }
184
184
 
185
- function formatStep(s: Step): string | null {
185
+ export function formatStep(s: Step): string | null {
186
186
  switch (s.kind) {
187
187
  case "user":
188
188
  return `- USER: ${oneLine(s.text, 600)}`;
@@ -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
- const users = steps.filter((s): s is StepOf<"user"> => s.kind === "user");
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
- return { request: taskUsers[0], followUps: taskUsers.slice(1), steps: task, earlier: steps.slice(0, startIdx) };
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). */
@@ -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
+ }