codemax-cli 0.0.0-stage → 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.
- package/LICENSE +661 -0
- package/NOTICE +27 -0
- package/README.md +163 -2
- package/TRADEMARKS.md +27 -0
- package/bin/codemax +2 -0
- package/bin/codemax.cjs +78 -0
- package/package.json +72 -4
- package/src/adapters/claude.ts +43 -0
- package/src/adapters/codex.ts +101 -0
- package/src/adapters/detect.ts +16 -0
- package/src/adapters/index.ts +21 -0
- package/src/adapters/simple.ts +89 -0
- package/src/adapters/types.ts +87 -0
- package/src/chat/app.ts +1934 -0
- package/src/chat/logo.ts +37 -0
- package/src/chat/markdown.ts +77 -0
- package/src/chat/palette.ts +179 -0
- package/src/chat/permission.ts +111 -0
- package/src/chat/theme.ts +158 -0
- package/src/chat/transcript.ts +342 -0
- package/src/cli.ts +254 -0
- package/src/config.ts +114 -0
- package/src/core/awake.ts +345 -0
- package/src/core/brief.ts +314 -0
- package/src/core/budget.ts +42 -0
- package/src/core/checkpoint.ts +139 -0
- package/src/core/doctor.ts +67 -0
- package/src/core/files.ts +62 -0
- package/src/core/git.ts +36 -0
- package/src/core/hotkey.ts +75 -0
- package/src/core/journal.ts +215 -0
- package/src/core/ledger.ts +153 -0
- package/src/core/menu.ts +134 -0
- package/src/core/orchestrator.ts +352 -0
- package/src/core/paths.ts +30 -0
- package/src/core/prices-live.ts +123 -0
- package/src/core/pricing.ts +162 -0
- package/src/core/pty.ts +99 -0
- package/src/core/quota.ts +55 -0
- package/src/core/review.ts +67 -0
- package/src/core/spawn.ts +20 -0
- package/src/core/terminal.ts +51 -0
- package/src/core/update.ts +90 -0
- package/src/core/usage.ts +116 -0
- package/src/engines/acp.ts +419 -0
- package/src/engines/codex.ts +330 -0
- package/src/engines/env.ts +17 -0
- package/src/engines/errors.ts +47 -0
- package/src/engines/index.ts +154 -0
- package/src/engines/types.ts +131 -0
- package/src/transcripts/claude-usage.ts +63 -0
- package/src/transcripts/claude.ts +69 -0
- package/src/transcripts/codex.ts +74 -0
- package/src/tui/app.ts +377 -0
- package/src/tui/menu.ts +126 -0
- package/src/tui/pane.ts +79 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { CODEMAX_HOME, ensureDir } from "./core/paths.ts";
|
|
4
|
+
import type { AgentId } from "./adapters/types.ts";
|
|
5
|
+
|
|
6
|
+
export interface Config {
|
|
7
|
+
/** Priority order used for "next agent" suggestions. */
|
|
8
|
+
order: AgentId[];
|
|
9
|
+
/**
|
|
10
|
+
* Hotkeys that open the codemax menu, as "ctrl+<key>". Each is matched in
|
|
11
|
+
* every encoding terminals use (raw byte, CSI u, modifyOtherKeys).
|
|
12
|
+
* Default: ctrl+/ and ctrl+^. Run `codemax keys` to see what yours sends.
|
|
13
|
+
*/
|
|
14
|
+
hotkeys: string[];
|
|
15
|
+
/** Offer a switch automatically when quota exhaustion is detected. */
|
|
16
|
+
autoSwitch: boolean;
|
|
17
|
+
/** Per-agent extra CLI args appended to every launch. */
|
|
18
|
+
agentArgs: Partial<Record<AgentId, string[]>>;
|
|
19
|
+
/** Ask the outgoing agent for a project summary before a switch (skipped when it is out of quota). */
|
|
20
|
+
summarizeOnSwitch: boolean;
|
|
21
|
+
/** Ring the terminal bell when a turn longer than this many seconds finishes (0 = never). */
|
|
22
|
+
bellAfterSeconds: number;
|
|
23
|
+
/** Daily API-equivalent USD limits per agent or "all"; at 80% codemax warns, at 100% it offers a handoff. */
|
|
24
|
+
budgets: Partial<Record<AgentId | "all", number>>;
|
|
25
|
+
/** Send a message to another agent when it matches: [{ "match": "^review", "agent": "codex" }]. */
|
|
26
|
+
routes: { match: string; agent: AgentId }[];
|
|
27
|
+
/** Snapshot the working tree before every turn so /undo can revert it. */
|
|
28
|
+
checkpoints: boolean;
|
|
29
|
+
/** Refresh the public price list weekly for /usage (set false to stay offline). */
|
|
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;
|
|
45
|
+
/** Colour theme: "auto" follows the terminal's background, or force "dark" / "light". */
|
|
46
|
+
theme: "auto" | "dark" | "light";
|
|
47
|
+
/**
|
|
48
|
+
* Handoff size. `resumeChars` bounds the resume packet (task, plan, steps, code diff);
|
|
49
|
+
* the others bound the older turn-summary brief used when there is no step journal.
|
|
50
|
+
*/
|
|
51
|
+
brief: { turns: number; maxChars: number; turnChars: number; resumeChars: number };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const DEFAULT_CONFIG: Config = {
|
|
55
|
+
order: ["claude", "codex", "opencode", "cursor", "grok", "gemini", "copilot"],
|
|
56
|
+
hotkeys: ["ctrl+/", "ctrl+^"],
|
|
57
|
+
autoSwitch: true,
|
|
58
|
+
agentArgs: {},
|
|
59
|
+
priceUpdates: true,
|
|
60
|
+
budgets: {},
|
|
61
|
+
routes: [],
|
|
62
|
+
checkpoints: true,
|
|
63
|
+
summarizeOnSwitch: true,
|
|
64
|
+
bellAfterSeconds: 20,
|
|
65
|
+
theme: "auto",
|
|
66
|
+
awake: "off",
|
|
67
|
+
updateCheck: true,
|
|
68
|
+
sideAgent: { agent: null, forward: "ask" },
|
|
69
|
+
brief: { turns: 10, maxChars: 8000, turnChars: 1200, resumeChars: 24000 },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const CONFIG_PATH = join(CODEMAX_HOME, "config.json");
|
|
73
|
+
|
|
74
|
+
export function loadConfig(): Config {
|
|
75
|
+
ensureDir(CODEMAX_HOME);
|
|
76
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
77
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
78
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<Config> & { hotkey?: number };
|
|
82
|
+
// v0.1.0 stored a single byte; map it onto the new list.
|
|
83
|
+
if (!raw.hotkeys && typeof raw.hotkey === "number") raw.hotkeys = [raw.hotkey === 0x1e ? "ctrl+^" : "ctrl+/"];
|
|
84
|
+
delete raw.hotkey;
|
|
85
|
+
return {
|
|
86
|
+
...DEFAULT_CONFIG,
|
|
87
|
+
...raw,
|
|
88
|
+
brief: { ...DEFAULT_CONFIG.brief, ...(raw.brief ?? {}) },
|
|
89
|
+
agentArgs: { ...(raw.agentArgs ?? {}) },
|
|
90
|
+
budgets: { ...(raw.budgets ?? {}) },
|
|
91
|
+
sideAgent: { ...DEFAULT_CONFIG.sideAgent, ...(raw.sideAgent ?? {}) },
|
|
92
|
+
routes: Array.isArray(raw.routes) ? raw.routes : [],
|
|
93
|
+
};
|
|
94
|
+
} catch {
|
|
95
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Write one setting back to config.json, leaving every other key as the user wrote it. */
|
|
100
|
+
export function saveConfigKey<K extends keyof Config>(key: K, value: Config[K]) {
|
|
101
|
+
ensureDir(CODEMAX_HOME);
|
|
102
|
+
let raw: Record<string, unknown> = {};
|
|
103
|
+
try {
|
|
104
|
+
if (existsSync(CONFIG_PATH)) raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
105
|
+
} catch {
|
|
106
|
+
return; // unreadable config: do not overwrite what the user wrote
|
|
107
|
+
}
|
|
108
|
+
raw[key] = value;
|
|
109
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2) + "\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function hotkeyLabel(hotkeys: string[]): string {
|
|
113
|
+
return hotkeys.map((h) => h.replace(/^ctrl\+/i, "Ctrl+")).join(" or ");
|
|
114
|
+
}
|
|
@@ -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
|
+
}
|