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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, openSync, readSync, closeSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { HOME, escapeCwd } from "../core/paths.ts";
|
|
4
|
+
|
|
5
|
+
export interface UsageDelta {
|
|
6
|
+
model?: string;
|
|
7
|
+
input: number;
|
|
8
|
+
output: number;
|
|
9
|
+
cacheRead: number;
|
|
10
|
+
cacheWrite: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Claude Code writes per-message `usage` into its session transcript. The ACP
|
|
15
|
+
* adapter does not relay it, so codemax tails the file: every call returns the
|
|
16
|
+
* assistant records appended since the previous call for this session.
|
|
17
|
+
*/
|
|
18
|
+
export class ClaudeUsageTail {
|
|
19
|
+
private offset = 0;
|
|
20
|
+
private partial = "";
|
|
21
|
+
readonly path: string;
|
|
22
|
+
|
|
23
|
+
constructor(cwd: string, sessionId: string) {
|
|
24
|
+
this.path = join(HOME, ".claude", "projects", escapeCwd(cwd), `${sessionId}.jsonl`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
read(): UsageDelta[] {
|
|
28
|
+
if (!existsSync(this.path)) return [];
|
|
29
|
+
const size = statSync(this.path).size;
|
|
30
|
+
if (size <= this.offset) return [];
|
|
31
|
+
const fd = openSync(this.path, "r");
|
|
32
|
+
let text: string;
|
|
33
|
+
try {
|
|
34
|
+
const buf = Buffer.alloc(size - this.offset);
|
|
35
|
+
readSync(fd, buf, 0, buf.length, this.offset);
|
|
36
|
+
text = this.partial + buf.toString("utf8");
|
|
37
|
+
} finally {
|
|
38
|
+
closeSync(fd);
|
|
39
|
+
}
|
|
40
|
+
this.offset = size;
|
|
41
|
+
const lines = text.split("\n");
|
|
42
|
+
this.partial = lines.pop() ?? "";
|
|
43
|
+
const out: UsageDelta[] = [];
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (!line.includes('"usage"')) continue;
|
|
46
|
+
try {
|
|
47
|
+
const rec = JSON.parse(line);
|
|
48
|
+
if (rec.type !== "assistant" || !rec.message?.usage) continue;
|
|
49
|
+
const u = rec.message.usage;
|
|
50
|
+
out.push({
|
|
51
|
+
model: rec.message.model,
|
|
52
|
+
input: u.input_tokens ?? 0,
|
|
53
|
+
output: u.output_tokens ?? 0,
|
|
54
|
+
cacheRead: u.cache_read_input_tokens ?? 0,
|
|
55
|
+
cacheWrite: u.cache_creation_input_tokens ?? 0,
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
/* skip */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import type { Turn } from "../adapters/types.ts";
|
|
3
|
+
|
|
4
|
+
type Block = { type: string; text?: string; name?: string; content?: unknown };
|
|
5
|
+
|
|
6
|
+
/** Parse a Claude Code ~/.claude/projects/<p>/<session>.jsonl into turns. */
|
|
7
|
+
export function parseClaudeTranscript(path: string, sessionId: string): Turn[] {
|
|
8
|
+
if (!existsSync(path)) return [];
|
|
9
|
+
const turns: Turn[] = [];
|
|
10
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
11
|
+
if (!line.trim()) continue;
|
|
12
|
+
let rec: any;
|
|
13
|
+
try {
|
|
14
|
+
rec = JSON.parse(line);
|
|
15
|
+
} catch {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (rec.type !== "user" && rec.type !== "assistant") continue;
|
|
19
|
+
if (rec.isSidechain || rec.isMeta) continue;
|
|
20
|
+
const msg = rec.message;
|
|
21
|
+
if (!msg) continue;
|
|
22
|
+
const ts = Date.parse(rec.timestamp ?? "") || Date.now();
|
|
23
|
+
const content: string | Block[] = msg.content;
|
|
24
|
+
|
|
25
|
+
if (rec.type === "user") {
|
|
26
|
+
const text = userText(content);
|
|
27
|
+
if (!text) continue; // tool_result-only or system-reminder-only lines
|
|
28
|
+
turns.push({ ts, agent: "claude", sessionId, role: "user", text });
|
|
29
|
+
} else {
|
|
30
|
+
const { text, tools } = assistantText(content);
|
|
31
|
+
if (!text && !tools.length) continue;
|
|
32
|
+
// Merge consecutive assistant chunks (streaming writes several records per turn).
|
|
33
|
+
const prev = turns[turns.length - 1];
|
|
34
|
+
if (prev && prev.role === "assistant" && ts - prev.ts < 120_000) {
|
|
35
|
+
if (text) prev.text = (prev.text ? prev.text + "\n" : "") + text;
|
|
36
|
+
if (tools.length) prev.tools = [...new Set([...(prev.tools ?? []), ...tools])];
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
turns.push({ ts, agent: "claude", sessionId, role: "assistant", text, tools: tools.length ? tools : undefined });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return turns;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function userText(content: string | Block[]): string {
|
|
46
|
+
const raw = typeof content === "string" ? content : content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
|
|
47
|
+
return cleanUser(raw);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function cleanUser(s: string): string {
|
|
51
|
+
return s
|
|
52
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
|
|
53
|
+
.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, "")
|
|
54
|
+
.replace(/<command-name>[\s\S]*?<\/local-command-stdout>/g, "")
|
|
55
|
+
.replace(/<command-name>[\s\S]*?<\/command-name>/g, "")
|
|
56
|
+
.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g, "")
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assistantText(content: string | Block[]): { text: string; tools: string[] } {
|
|
61
|
+
if (typeof content === "string") return { text: content.trim(), tools: [] };
|
|
62
|
+
const text = content
|
|
63
|
+
.filter((b) => b.type === "text")
|
|
64
|
+
.map((b) => (b.text ?? "").trim())
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join("\n");
|
|
67
|
+
const tools = content.filter((b) => b.type === "tool_use" && b.name).map((b) => b.name as string);
|
|
68
|
+
return { text, tools: [...new Set(tools)] };
|
|
69
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import type { QuotaStatus, Turn } from "../adapters/types.ts";
|
|
3
|
+
|
|
4
|
+
/** Parse a Codex ~/.codex/sessions/.../rollout-*.jsonl into turns. */
|
|
5
|
+
export function parseCodexTranscript(path: string, sessionId: string): Turn[] {
|
|
6
|
+
if (!existsSync(path)) return [];
|
|
7
|
+
const turns: Turn[] = [];
|
|
8
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
9
|
+
if (!line.trim()) continue;
|
|
10
|
+
let rec: any;
|
|
11
|
+
try {
|
|
12
|
+
rec = JSON.parse(line);
|
|
13
|
+
} catch {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (rec.type !== "response_item") continue;
|
|
17
|
+
const p = rec.payload ?? {};
|
|
18
|
+
const ts = Date.parse(rec.timestamp ?? "") || Date.now();
|
|
19
|
+
if (p.type === "message") {
|
|
20
|
+
const text = (p.content ?? [])
|
|
21
|
+
.map((c: any) => c.text ?? "")
|
|
22
|
+
.join("\n")
|
|
23
|
+
.trim();
|
|
24
|
+
if (!text) continue;
|
|
25
|
+
if (p.role === "user") {
|
|
26
|
+
if (/^<(environment_context|user_instructions|permissions|turn_aborted|app_context)/.test(text)) continue;
|
|
27
|
+
turns.push({ ts, agent: "codex", sessionId, role: "user", text });
|
|
28
|
+
} else if (p.role === "assistant") {
|
|
29
|
+
const prev = turns[turns.length - 1];
|
|
30
|
+
if (prev && prev.role === "assistant" && ts - prev.ts < 120_000) {
|
|
31
|
+
prev.text = (prev.text ? prev.text + "\n" : "") + text;
|
|
32
|
+
} else turns.push({ ts, agent: "codex", sessionId, role: "assistant", text });
|
|
33
|
+
}
|
|
34
|
+
} else if (p.type === "function_call" || p.type === "custom_tool_call" || p.type === "local_shell_call") {
|
|
35
|
+
const name: string = p.name ?? p.type;
|
|
36
|
+
const prev = turns[turns.length - 1];
|
|
37
|
+
if (prev && prev.role === "assistant") prev.tools = [...new Set([...(prev.tools ?? []), name])];
|
|
38
|
+
else turns.push({ ts, agent: "codex", sessionId, role: "assistant", text: "", tools: [name] });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return turns.filter((t) => t.text || t.tools?.length);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read the most recent rate_limits block Codex wrote into the rollout. */
|
|
45
|
+
export function codexQuotaFromRollout(path: string): QuotaStatus | null {
|
|
46
|
+
if (!existsSync(path)) return null;
|
|
47
|
+
let last: any = null;
|
|
48
|
+
let lastTs = 0;
|
|
49
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
50
|
+
if (!line.includes('"rate_limits"')) continue;
|
|
51
|
+
try {
|
|
52
|
+
const rec = JSON.parse(line);
|
|
53
|
+
const rl = rec.payload?.rate_limits;
|
|
54
|
+
if (rl) {
|
|
55
|
+
last = rl;
|
|
56
|
+
lastTs = Date.parse(rec.timestamp ?? "") || Date.now();
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!last) return null;
|
|
63
|
+
const used = last.primary?.used_percent ?? last.secondary?.used_percent;
|
|
64
|
+
const reached = !!last.rate_limit_reached_type;
|
|
65
|
+
return {
|
|
66
|
+
agent: "codex",
|
|
67
|
+
usedPercent: typeof used === "number" ? used : undefined,
|
|
68
|
+
resetsAt: last.primary?.resets_at ? last.primary.resets_at * 1000 : undefined,
|
|
69
|
+
exhausted: reached || (typeof used === "number" && used >= 100),
|
|
70
|
+
source: "probe",
|
|
71
|
+
detail: last.plan_type ? `plan ${last.plan_type}` : undefined,
|
|
72
|
+
at: lastTs,
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/tui/app.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { BoxRenderable, TextRenderable, createCliRenderer, type CliRenderer, type KeyEvent } from "@opentui/core";
|
|
2
|
+
import type { Config } from "../config.ts";
|
|
3
|
+
import { hotkeyLabel } from "../config.ts";
|
|
4
|
+
import type { Adapter, AgentId, QuotaStatus, SessionRef } from "../adapters/types.ts";
|
|
5
|
+
import { getAdapter, listAdapters } from "../adapters/index.ts";
|
|
6
|
+
import { Ledger } from "../core/ledger.ts";
|
|
7
|
+
import { buildBrief } from "../core/brief.ts";
|
|
8
|
+
import { diffStat } from "../core/git.ts";
|
|
9
|
+
import { OutputScanner, isExhausted, quotaLabel } from "../core/quota.ts";
|
|
10
|
+
import { findHotkey, parseHotkeys, type HotkeySpec } from "../core/hotkey.ts";
|
|
11
|
+
import type { MenuRow } from "../core/menu.ts";
|
|
12
|
+
import { AgentPane } from "./pane.ts";
|
|
13
|
+
import { MenuOverlay } from "./menu.ts";
|
|
14
|
+
|
|
15
|
+
interface Running {
|
|
16
|
+
adapter: Adapter;
|
|
17
|
+
pane: AgentPane;
|
|
18
|
+
ref: SessionRef | null;
|
|
19
|
+
scanner: OutputScanner;
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
pendingPrompt?: string;
|
|
22
|
+
lastOutputAt: number;
|
|
23
|
+
firstOutputAt: number;
|
|
24
|
+
timers: ReturnType<typeof setInterval>[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HOTKEY_NAMES: Record<string, string[]> = { "/": ["/", "_", "?"], "^": ["^", "6"] };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* codemax's own screen: an agent pane (embedded virtual terminal) above a
|
|
31
|
+
* one-line status bar, with the agent menu as an overlay. Owns one agent at a
|
|
32
|
+
* time and performs switches with handoff briefs.
|
|
33
|
+
*/
|
|
34
|
+
export class App {
|
|
35
|
+
private running: Running | null = null;
|
|
36
|
+
private menu: MenuOverlay | null = null;
|
|
37
|
+
private detect = new Map<AgentId, { installed: boolean; version?: string }>();
|
|
38
|
+
private quota = new Map<AgentId, QuotaStatus>();
|
|
39
|
+
private hotkeys: HotkeySpec[];
|
|
40
|
+
private paneHost: BoxRenderable;
|
|
41
|
+
private statusText: TextRenderable;
|
|
42
|
+
private statusMessage = "";
|
|
43
|
+
private switching = false;
|
|
44
|
+
private resolveDone!: () => void;
|
|
45
|
+
readonly done: Promise<void>;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
readonly renderer: CliRenderer,
|
|
49
|
+
private config: Config,
|
|
50
|
+
private ledger: Ledger,
|
|
51
|
+
private cwd: string,
|
|
52
|
+
) {
|
|
53
|
+
this.done = new Promise((r) => (this.resolveDone = r));
|
|
54
|
+
this.hotkeys = parseHotkeys(config.hotkeys);
|
|
55
|
+
for (const [id, q] of Object.entries(ledger.quota())) this.quota.set(id as AgentId, q);
|
|
56
|
+
|
|
57
|
+
const root = new BoxRenderable(renderer, { id: "codemax-root", width: "100%", height: "100%", flexDirection: "column" });
|
|
58
|
+
this.paneHost = new BoxRenderable(renderer, { id: "codemax-pane-host", width: "100%", flexGrow: 1, flexShrink: 1, flexDirection: "column" });
|
|
59
|
+
const bar = new BoxRenderable(renderer, { id: "codemax-status", width: "100%", height: 1, backgroundColor: "#3a0d25", paddingX: 1 });
|
|
60
|
+
this.statusText = new TextRenderable(renderer, { id: "codemax-status-text", content: "", fg: "#ffffff" });
|
|
61
|
+
bar.add(this.statusText);
|
|
62
|
+
root.add(this.paneHost);
|
|
63
|
+
root.add(bar);
|
|
64
|
+
renderer.root.add(root);
|
|
65
|
+
|
|
66
|
+
renderer.keyInput.on("keypress", this.onKey);
|
|
67
|
+
renderer.on("resize", () => this.menu?.render());
|
|
68
|
+
this.setStatus("");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async start(agent: AgentId, fresh: boolean) {
|
|
72
|
+
await this.refreshDetect();
|
|
73
|
+
const first = this.detect.get(agent)?.installed ? agent : this.firstInstalled();
|
|
74
|
+
if (!first) throw new Error("no supported agent CLI found on PATH");
|
|
75
|
+
if (first !== agent) this.setStatus(`${agent} not found, starting ${first}`);
|
|
76
|
+
await this.launch(getAdapter(first), { fresh });
|
|
77
|
+
return this.done;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------- input ----------
|
|
81
|
+
|
|
82
|
+
private onKey = (key: KeyEvent) => {
|
|
83
|
+
if (this.menu) {
|
|
84
|
+
const action = this.menu.handleKey(key);
|
|
85
|
+
key.stopPropagation();
|
|
86
|
+
if (action) void this.onMenuAction(action);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (this.isHotkey(key)) {
|
|
90
|
+
key.stopPropagation();
|
|
91
|
+
this.openMenu("switch agent", true);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
private isHotkey(key: KeyEvent): boolean {
|
|
96
|
+
if (key.eventType === "release") return false;
|
|
97
|
+
if (key.raw && findHotkey(new TextEncoder().encode(key.raw), this.hotkeys)) return true;
|
|
98
|
+
if (!key.ctrl) return false;
|
|
99
|
+
return this.hotkeys.some((h) => {
|
|
100
|
+
const k = h.name.slice("ctrl+".length);
|
|
101
|
+
return (HOTKEY_NAMES[k] ?? [k]).includes(key.name);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------- status bar ----------
|
|
106
|
+
|
|
107
|
+
private setStatus(message: string) {
|
|
108
|
+
this.statusMessage = message;
|
|
109
|
+
this.renderStatus();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private renderStatus() {
|
|
113
|
+
const r = this.running;
|
|
114
|
+
const parts: string[] = ["codemax"];
|
|
115
|
+
if (r) {
|
|
116
|
+
const sess = r.ref ? ` · session ${r.ref.id.slice(0, 8)}` : "";
|
|
117
|
+
parts.push(`${r.adapter.name}${sess}`);
|
|
118
|
+
}
|
|
119
|
+
const quotas = [...this.quota.values()]
|
|
120
|
+
.map((q) => `${q.agent} ${quotaLabel(q)}`.trim())
|
|
121
|
+
.filter((s) => !s.endsWith(" "))
|
|
122
|
+
.filter((s) => quotaLabel(this.quota.get(s.split(" ")[0] as AgentId)))
|
|
123
|
+
.slice(0, 3);
|
|
124
|
+
if (quotas.length) parts.push(quotas.join(", "));
|
|
125
|
+
if (this.statusMessage) parts.push(this.statusMessage);
|
|
126
|
+
parts.push(`${hotkeyLabel(this.config.hotkeys)} menu`);
|
|
127
|
+
this.statusText.content = parts.join(" │ ");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------- agent lifecycle ----------
|
|
131
|
+
|
|
132
|
+
private paneSize() {
|
|
133
|
+
return { cols: Math.max(20, this.renderer.width), rows: Math.max(5, this.renderer.height - 1) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async launch(adapter: Adapter, o: { fresh: boolean; reason?: string; from?: AgentId }) {
|
|
137
|
+
const stale = this.quota.get(adapter.id);
|
|
138
|
+
if (stale?.source === "output") {
|
|
139
|
+
this.quota.delete(adapter.id);
|
|
140
|
+
this.ledger.clearQuota(adapter.id);
|
|
141
|
+
}
|
|
142
|
+
const saved = this.ledger.sessions()[adapter.id];
|
|
143
|
+
const resume = !o.fresh && saved ? saved : undefined;
|
|
144
|
+
const sessionId = resume ? undefined : adapter.newSessionId?.();
|
|
145
|
+
const hasHistory = this.ledger.turns().length > 0 || !!o.from;
|
|
146
|
+
const brief = hasHistory ? await buildBrief({ ledger: this.ledger, cwd: this.cwd, to: adapter.id, from: o.from, reason: o.reason, config: this.config }) : undefined;
|
|
147
|
+
const spec = adapter.launch({ cwd: this.cwd, resume, sessionId, prompt: brief, extraArgs: this.config.agentArgs[adapter.id] });
|
|
148
|
+
|
|
149
|
+
// Subscriptions only: an API key in the environment is never passed to an agent.
|
|
150
|
+
const dropEnv = adapter.apiKeyEnv.filter((k) => process.env[k]);
|
|
151
|
+
const scanner = new OutputScanner(adapter.id, adapter.quotaPatterns, (q) => this.onQuota(q));
|
|
152
|
+
const { cols, rows } = this.paneSize();
|
|
153
|
+
const run: Running = {
|
|
154
|
+
adapter,
|
|
155
|
+
ref: resume ?? null,
|
|
156
|
+
scanner,
|
|
157
|
+
sessionId,
|
|
158
|
+
pendingPrompt: spec.promptConsumed ? undefined : brief,
|
|
159
|
+
lastOutputAt: 0,
|
|
160
|
+
firstOutputAt: 0,
|
|
161
|
+
timers: [],
|
|
162
|
+
pane: null as unknown as AgentPane,
|
|
163
|
+
};
|
|
164
|
+
run.pane = new AgentPane(this.renderer, {
|
|
165
|
+
id: `pane-${adapter.id}-${Date.now()}`,
|
|
166
|
+
cmd: spec.cmd,
|
|
167
|
+
cwd: this.cwd,
|
|
168
|
+
env: spec.env,
|
|
169
|
+
dropEnv,
|
|
170
|
+
cols,
|
|
171
|
+
rows,
|
|
172
|
+
onOutput: (text) => {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
if (!run.firstOutputAt) run.firstOutputAt = now;
|
|
175
|
+
run.lastOutputAt = now;
|
|
176
|
+
scanner.feed(text);
|
|
177
|
+
},
|
|
178
|
+
onExit: (code) => void this.onAgentExit(run, code),
|
|
179
|
+
});
|
|
180
|
+
this.paneHost.add(run.pane.terminal);
|
|
181
|
+
run.pane.terminal.focus();
|
|
182
|
+
this.running = run;
|
|
183
|
+
this.ledger.addEvent({ kind: o.from ? "switch" : "start", agent: o.from, to: adapter.id, detail: o.reason });
|
|
184
|
+
this.setStatus(dropEnv.length ? `unset ${dropEnv.join(", ")} (subscription login only)` : "");
|
|
185
|
+
|
|
186
|
+
run.timers.push(setInterval(() => void this.sync(run), 2000));
|
|
187
|
+
if (run.pendingPrompt) run.timers.push(setInterval(() => this.maybeTypePrompt(run), 250));
|
|
188
|
+
void this.locate(run);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async locate(run: Running) {
|
|
192
|
+
if (run.ref) return;
|
|
193
|
+
while (this.running === run && !run.pane.exited) {
|
|
194
|
+
const ref = await run.adapter.locateSession({ cwd: this.cwd, startedAt: run.pane.startedAt, sessionId: run.sessionId });
|
|
195
|
+
if (ref) {
|
|
196
|
+
run.ref = ref;
|
|
197
|
+
this.ledger.saveSession(ref);
|
|
198
|
+
this.renderStatus();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
await Bun.sleep(1000);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async sync(run: Running) {
|
|
206
|
+
if (!run.ref) return;
|
|
207
|
+
try {
|
|
208
|
+
if (run.adapter.readTranscript) {
|
|
209
|
+
const turns = await run.adapter.readTranscript(run.ref);
|
|
210
|
+
if (this.ledger.addTurns(turns) > 0) {
|
|
211
|
+
run.ref.lastUsedAt = Date.now();
|
|
212
|
+
this.ledger.saveSession(run.ref);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (run.adapter.probeQuota) {
|
|
216
|
+
const q = await run.adapter.probeQuota(run.ref);
|
|
217
|
+
if (q) this.onQuota(q, true);
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
/* transcript mid-write; retry next tick */
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private maybeTypePrompt(run: Running) {
|
|
225
|
+
if (!run.pendingPrompt || !run.firstOutputAt) return;
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
const quiet = now - run.lastOutputAt > 700;
|
|
228
|
+
const old = now - run.pane.startedAt > 1500;
|
|
229
|
+
const timeout = now - run.pane.startedAt > 15_000;
|
|
230
|
+
if ((quiet && old) || timeout) {
|
|
231
|
+
const text = run.pendingPrompt;
|
|
232
|
+
run.pendingPrompt = undefined;
|
|
233
|
+
run.pane.paste(text);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private onQuota(q: QuotaStatus, silent = false) {
|
|
238
|
+
const prev = this.quota.get(q.agent);
|
|
239
|
+
this.quota.set(q.agent, q);
|
|
240
|
+
this.ledger.saveQuota(q);
|
|
241
|
+
this.renderStatus();
|
|
242
|
+
if (isExhausted(q) && !isExhausted(prev)) {
|
|
243
|
+
this.ledger.addEvent({ kind: "quota", agent: q.agent, detail: q.detail });
|
|
244
|
+
if (this.config.autoSwitch && !silent) this.offerSwitchWhenIdle(q);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private offerSwitchWhenIdle(q: QuotaStatus) {
|
|
249
|
+
const run = this.running;
|
|
250
|
+
const tick = setInterval(() => {
|
|
251
|
+
if (this.running !== run || this.menu) return clearInterval(tick);
|
|
252
|
+
if (Date.now() - (run?.lastOutputAt ?? 0) < 2000) return;
|
|
253
|
+
clearInterval(tick);
|
|
254
|
+
const guess = q.source === "output";
|
|
255
|
+
this.openMenu(
|
|
256
|
+
guess ? `${q.agent} may be out of quota — pick the next agent, or Esc if this is a false alarm` : `${q.agent} is out of quota — pick the next agent`,
|
|
257
|
+
true,
|
|
258
|
+
`${q.agent}: ${q.detail ?? "limit reached"}${guess ? " (screen text, not confirmed; forgotten in 30 min or when you relaunch it)" : ""}`,
|
|
259
|
+
);
|
|
260
|
+
}, 500);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async onAgentExit(run: Running, code: number | null) {
|
|
264
|
+
run.timers.forEach(clearInterval);
|
|
265
|
+
if (this.running !== run) return;
|
|
266
|
+
await this.sync(run);
|
|
267
|
+
this.ledger.addEvent({ kind: "exit", agent: run.adapter.id, detail: `exit ${code}`, diffStat: await diffStat(this.cwd) });
|
|
268
|
+
this.running = null;
|
|
269
|
+
this.renderStatus();
|
|
270
|
+
if (this.switching) return;
|
|
271
|
+
this.openMenu(`${run.adapter.name} exited (${code}) — switch agent or quit`, false);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------- menu ----------
|
|
275
|
+
|
|
276
|
+
private async refreshDetect() {
|
|
277
|
+
await Promise.all(
|
|
278
|
+
listAdapters(this.config.order).map(async (a) => {
|
|
279
|
+
const d = await a.detect();
|
|
280
|
+
this.detect.set(a.id, { installed: d.installed, version: d.version });
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private firstInstalled(): AgentId | undefined {
|
|
286
|
+
return listAdapters(this.config.order).find((a) => this.detect.get(a.id)?.installed)?.id;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private rows(): MenuRow[] {
|
|
290
|
+
const sessions = this.ledger.sessions();
|
|
291
|
+
return listAdapters(this.config.order).map((a) => ({
|
|
292
|
+
adapter: a,
|
|
293
|
+
installed: this.detect.get(a.id)?.installed ?? false,
|
|
294
|
+
version: this.detect.get(a.id)?.version,
|
|
295
|
+
session: sessions[a.id],
|
|
296
|
+
quota: this.quota.get(a.id),
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private openMenu(title: string, canClose: boolean, notice?: string) {
|
|
301
|
+
if (this.menu) return;
|
|
302
|
+
this.menu = new MenuOverlay(this.renderer, { title, rows: this.rows(), current: this.running?.adapter.id, notice, canClose, hotkeys: this.config.hotkeys });
|
|
303
|
+
this.renderer.root.add(this.menu.box);
|
|
304
|
+
void this.refreshDetect().then(() => this.menu?.update({ rows: this.rows() }));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private closeMenu() {
|
|
308
|
+
this.menu?.destroy();
|
|
309
|
+
this.menu = null;
|
|
310
|
+
this.running?.pane.terminal.focus();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async onMenuAction(action: { kind: string; agent?: AgentId; fresh?: boolean }) {
|
|
314
|
+
if (action.kind === "close") return this.closeMenu();
|
|
315
|
+
if (action.kind === "quit") return this.quit();
|
|
316
|
+
if (action.kind === "switch" && action.agent) {
|
|
317
|
+
const target = getAdapter(action.agent);
|
|
318
|
+
if (this.running && this.running.adapter.id === target.id && !action.fresh) return this.closeMenu();
|
|
319
|
+
this.menu?.update({ notice: `switching to ${target.name}…` });
|
|
320
|
+
await this.switchTo(target, !!action.fresh);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private async switchTo(target: Adapter, fresh: boolean) {
|
|
325
|
+
this.switching = true;
|
|
326
|
+
const prev = this.running;
|
|
327
|
+
let from: AgentId | undefined;
|
|
328
|
+
if (prev) {
|
|
329
|
+
from = prev.adapter.id;
|
|
330
|
+
prev.timers.forEach(clearInterval);
|
|
331
|
+
this.running = null;
|
|
332
|
+
await prev.pane.destroy();
|
|
333
|
+
await this.sync(prev);
|
|
334
|
+
this.ledger.addEvent({ kind: "note", agent: from, detail: "stopped for switch", diffStat: await diffStat(this.cwd) });
|
|
335
|
+
}
|
|
336
|
+
this.menu?.destroy();
|
|
337
|
+
this.menu = null;
|
|
338
|
+
this.setStatus(`handing off to ${target.name}…`);
|
|
339
|
+
const fromQ = from ? this.quota.get(from) : undefined;
|
|
340
|
+
const reason = fromQ && isExhausted(fromQ) ? (fromQ.source === "output" ? `${from} appeared to hit its usage limit` : `${from} ran out of quota`) : undefined;
|
|
341
|
+
await this.launch(target, { fresh, from, reason });
|
|
342
|
+
this.switching = false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async quit() {
|
|
346
|
+
this.switching = true;
|
|
347
|
+
const run = this.running;
|
|
348
|
+
this.menu?.destroy();
|
|
349
|
+
this.menu = null;
|
|
350
|
+
if (run) {
|
|
351
|
+
run.timers.forEach(clearInterval);
|
|
352
|
+
this.running = null;
|
|
353
|
+
await run.pane.destroy();
|
|
354
|
+
await this.sync(run);
|
|
355
|
+
this.ledger.addEvent({ kind: "exit", agent: run.adapter.id, detail: "codemax quit", diffStat: await diffStat(this.cwd) });
|
|
356
|
+
}
|
|
357
|
+
this.renderer.keyInput.off("keypress", this.onKey);
|
|
358
|
+
this.resolveDone();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Run codemax on the real terminal until the user quits. */
|
|
363
|
+
export async function runApp(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean) {
|
|
364
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGHUP"], useMouse: true, targetFps: 60 });
|
|
365
|
+
const app = new App(renderer, config, ledger, cwd);
|
|
366
|
+
try {
|
|
367
|
+
await app.start(agent, fresh);
|
|
368
|
+
} finally {
|
|
369
|
+
renderer.destroy();
|
|
370
|
+
try {
|
|
371
|
+
await renderer.closed;
|
|
372
|
+
} catch {
|
|
373
|
+
/* output already gone */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
process.stdout.write(`codemax: bye. ledger at ${ledger.dir}\n`);
|
|
377
|
+
}
|