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,330 @@
|
|
|
1
|
+
import type { AgentId } from "../adapters/types.ts";
|
|
2
|
+
import type { Engine, EngineEvent, EngineInfo, EngineSessionRef, EngineStartOptions, PermissionRequest, StopReason, ToolStatus } from "./types.ts";
|
|
3
|
+
import { agentEnv } from "./env.ts";
|
|
4
|
+
import { platformArgv } from "../core/spawn.ts";
|
|
5
|
+
import { classifyError } from "./errors.ts";
|
|
6
|
+
|
|
7
|
+
export interface CodexEngineOptions {
|
|
8
|
+
/** Command for the app server. Default: codex app-server. */
|
|
9
|
+
cmd?: string[];
|
|
10
|
+
/** Tests inject a transport instead of spawning. */
|
|
11
|
+
transport?: { send(line: string): void; onLine(cb: (line: string) => void): void; close(): Promise<void> };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Codex engine over the native `codex app-server` JSON-RPC protocol (the one
|
|
16
|
+
* Codex's own IDE extension uses). Works with ChatGPT-plan login. Approvals
|
|
17
|
+
* arrive as server→client requests and are answered through
|
|
18
|
+
* `answerPermission`.
|
|
19
|
+
*/
|
|
20
|
+
export class CodexEngine implements Engine {
|
|
21
|
+
readonly agent: AgentId = "codex";
|
|
22
|
+
info: EngineInfo | null = null;
|
|
23
|
+
session: EngineSessionRef | null = null;
|
|
24
|
+
private listeners = new Set<(e: EngineEvent) => void>();
|
|
25
|
+
private proc: Bun.Subprocess | null = null;
|
|
26
|
+
private transport: NonNullable<CodexEngineOptions["transport"]> | null = null;
|
|
27
|
+
private nextId = 1;
|
|
28
|
+
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
|
29
|
+
private pendingApprovals = new Map<string, { rpcId: number | string; method: string }>();
|
|
30
|
+
private permSeq = 0;
|
|
31
|
+
private turn: { resolve: (r: StopReason) => void; id?: string } | null = null;
|
|
32
|
+
private threadId: string | null = null;
|
|
33
|
+
private cwd = "";
|
|
34
|
+
private model: string | undefined;
|
|
35
|
+
private lastTotals = { inputTokens: 0, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 0 };
|
|
36
|
+
|
|
37
|
+
constructor(private opts: CodexEngineOptions = {}) {}
|
|
38
|
+
|
|
39
|
+
onEvent(listener: (e: EngineEvent) => void) {
|
|
40
|
+
this.listeners.add(listener);
|
|
41
|
+
return () => void this.listeners.delete(listener);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private emit(e: EngineEvent) {
|
|
45
|
+
for (const l of this.listeners) l(e);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async start(o: EngineStartOptions): Promise<EngineInfo> {
|
|
49
|
+
this.cwd = o.cwd;
|
|
50
|
+
this.transport = this.opts.transport ?? this.spawn(o.cwd);
|
|
51
|
+
this.transport.onLine((line) => this.onLine(line));
|
|
52
|
+
const init = await this.request("initialize", { clientInfo: { name: "codemax", version: "0.3.0" }, capabilities: {} });
|
|
53
|
+
this.notify("initialized", {});
|
|
54
|
+
|
|
55
|
+
let thread: any;
|
|
56
|
+
if (o.resume) {
|
|
57
|
+
try {
|
|
58
|
+
thread = (await this.request("thread/resume", { threadId: o.resume.id, cwd: o.cwd, approvalPolicy: "on-request" })).thread;
|
|
59
|
+
} catch (e: any) {
|
|
60
|
+
this.emit({ type: "log", level: "warn", message: `resume failed (${e?.message ?? e}); starting a new thread` });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!thread) thread = (await this.request("thread/start", { cwd: o.cwd, approvalPolicy: "on-request" })).thread;
|
|
64
|
+
this.threadId = thread.id;
|
|
65
|
+
this.model = thread.model ?? undefined;
|
|
66
|
+
this.session = { agent: "codex", id: thread.id, cwd: o.cwd, startedAt: Date.now(), lastUsedAt: Date.now() };
|
|
67
|
+
const account: any = await this.request("account/read", {}).catch(() => null);
|
|
68
|
+
this.info = {
|
|
69
|
+
agent: "codex",
|
|
70
|
+
name: "Codex",
|
|
71
|
+
version: String(init?.userAgent ?? "").match(/\/(\d+\.\d+\.\d+)/)?.[1],
|
|
72
|
+
modes: [
|
|
73
|
+
{ id: "on-request", name: "ask before risky actions" },
|
|
74
|
+
{ id: "never", name: "full auto (no approvals)" },
|
|
75
|
+
],
|
|
76
|
+
currentMode: "on-request",
|
|
77
|
+
models: [...new Set([this.model, "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"].filter(Boolean))].map((id) => ({ id: id!, name: id! })),
|
|
78
|
+
currentModel: this.model,
|
|
79
|
+
plan: account?.account?.planType ? `chatgpt ${account.account.planType}` : undefined,
|
|
80
|
+
};
|
|
81
|
+
this.emit({ type: "session", ref: this.session });
|
|
82
|
+
return this.info;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private spawn(cwd: string) {
|
|
86
|
+
const proc = Bun.spawn(platformArgv(this.opts.cmd ?? ["codex", "app-server"]), { cwd, env: agentEnv(), stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
87
|
+
this.proc = proc;
|
|
88
|
+
void (async () => {
|
|
89
|
+
for await (const chunk of proc.stderr as ReadableStream<Uint8Array>) {
|
|
90
|
+
const s = new TextDecoder().decode(chunk).trim();
|
|
91
|
+
if (s && !/rmcp::transport|models_manager/.test(s)) this.emit({ type: "log", level: "info", message: s.slice(0, 500) });
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
void proc.exited.then((code) => {
|
|
95
|
+
if (this.transport) this.emit({ type: "error", kind: "crashed", message: `codex app-server exited with code ${code}` });
|
|
96
|
+
if (this.turn) this.turn.resolve("error");
|
|
97
|
+
});
|
|
98
|
+
const sink: { cb: ((line: string) => void) | null } = { cb: null };
|
|
99
|
+
void (async () => {
|
|
100
|
+
let buf = "";
|
|
101
|
+
for await (const chunk of proc.stdout as ReadableStream<Uint8Array>) {
|
|
102
|
+
buf += new TextDecoder().decode(chunk);
|
|
103
|
+
let i: number;
|
|
104
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
105
|
+
const line = buf.slice(0, i);
|
|
106
|
+
buf = buf.slice(i + 1);
|
|
107
|
+
if (line.trim()) sink.cb?.(line);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
return {
|
|
112
|
+
send: (line: string) => {
|
|
113
|
+
(proc.stdin as any).write(line + "\n");
|
|
114
|
+
(proc.stdin as any).flush?.();
|
|
115
|
+
},
|
|
116
|
+
onLine: (f: (line: string) => void) => void (sink.cb = f),
|
|
117
|
+
close: async () => {
|
|
118
|
+
try {
|
|
119
|
+
proc.kill();
|
|
120
|
+
} catch {
|
|
121
|
+
/* gone */
|
|
122
|
+
}
|
|
123
|
+
await Promise.race([proc.exited, Bun.sleep(1500)]);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private request(method: string, params: unknown): Promise<any> {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const id = this.nextId++;
|
|
131
|
+
this.pending.set(id, { resolve, reject });
|
|
132
|
+
this.transport!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private notify(method: string, params: unknown) {
|
|
137
|
+
this.transport!.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private onLine(line: string) {
|
|
141
|
+
let m: any;
|
|
142
|
+
try {
|
|
143
|
+
m = JSON.parse(line);
|
|
144
|
+
} catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (m.id !== undefined && m.method === undefined) {
|
|
148
|
+
const p = this.pending.get(m.id);
|
|
149
|
+
if (!p) return;
|
|
150
|
+
this.pending.delete(m.id);
|
|
151
|
+
if (m.error) p.reject(Object.assign(new Error(m.error.message ?? JSON.stringify(m.error)), { data: m.error }));
|
|
152
|
+
else p.resolve(m.result);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (m.id !== undefined && m.method) return this.onServerRequest(m);
|
|
156
|
+
if (m.method) this.onNotification(m.method, m.params ?? {});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private onServerRequest(m: any) {
|
|
160
|
+
const method: string = m.method;
|
|
161
|
+
if (/requestApproval|Approval/.test(method)) {
|
|
162
|
+
const id = `perm-${++this.permSeq}`;
|
|
163
|
+
this.pendingApprovals.set(id, { rpcId: m.id, method });
|
|
164
|
+
const p = m.params ?? {};
|
|
165
|
+
const what = method.includes("commandExecution") ? "Run command" : method.includes("fileChange") ? "Edit files" : method.replace(/^item\//, "").replace(/\/requestApproval$/, "");
|
|
166
|
+
const detail = p.command ? (Array.isArray(p.command) ? p.command.join(" ") : String(p.command)) : p.reason ?? (p.changes ? JSON.stringify(p.changes).slice(0, 1500) : undefined);
|
|
167
|
+
const request: PermissionRequest = {
|
|
168
|
+
id,
|
|
169
|
+
toolCallId: p.itemId,
|
|
170
|
+
kind: method.includes("commandExecution") ? "execute" : method.includes("fileChange") ? "edit" : "other",
|
|
171
|
+
title: what,
|
|
172
|
+
detail,
|
|
173
|
+
options: [
|
|
174
|
+
{ id: "accept", label: "Allow once", kind: "allow_once" },
|
|
175
|
+
{ id: "acceptForSession", label: "Allow for this session", kind: "allow_always" },
|
|
176
|
+
{ id: "decline", label: "Deny", kind: "reject_once" },
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
this.emit({ type: "permission_request", request });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Unknown server request: decline politely so the agent does not hang.
|
|
183
|
+
this.transport!.send(JSON.stringify({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: `codemax does not handle ${method}` } }));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private onNotification(method: string, p: any) {
|
|
187
|
+
switch (method) {
|
|
188
|
+
case "item/agentMessage/delta":
|
|
189
|
+
this.emit({ type: "text_delta", text: p.delta ?? "" });
|
|
190
|
+
break;
|
|
191
|
+
case "item/reasoning/delta":
|
|
192
|
+
case "item/reasoning/summaryDelta":
|
|
193
|
+
case "item/reasoning/textDelta":
|
|
194
|
+
this.emit({ type: "thought_delta", text: p.delta ?? "" });
|
|
195
|
+
break;
|
|
196
|
+
case "item/started":
|
|
197
|
+
case "item/completed":
|
|
198
|
+
case "item/updated": {
|
|
199
|
+
const it = p.item ?? {};
|
|
200
|
+
if (it.type === "agentMessage" || it.type === "userMessage" || it.type === "reasoning") break;
|
|
201
|
+
const status: ToolStatus = method === "item/started" ? "in_progress" : it.status === "failed" || it.status === "declined" ? "failed" : method === "item/completed" ? "completed" : "in_progress";
|
|
202
|
+
const title = it.type === "commandExecution" ? `$ ${Array.isArray(it.command) ? it.command.join(" ") : it.command ?? ""}` : it.type === "fileChange" ? `edit ${(it.changes ?? []).map((c: any) => c.path).join(", ")}` : it.type === "mcpToolCall" ? `${it.server}/${it.tool}` : it.type ?? "tool";
|
|
203
|
+
const detail = it.aggregatedOutput ? String(it.aggregatedOutput).slice(-800) : undefined;
|
|
204
|
+
const files = it.type === "fileChange" ? (it.changes ?? []).map((c: any) => c.path).filter(Boolean) : undefined;
|
|
205
|
+
const input = it.type === "commandExecution" ? { command: Array.isArray(it.command) ? it.command.join(" ") : it.command, cwd: it.cwd } : it.type === "mcpToolCall" ? it.arguments : undefined;
|
|
206
|
+
const changeDiffs = it.type === "fileChange" ? (it.changes ?? []).map((c: any) => c.diff ?? c.unified_diff ?? "").filter(Boolean).join("\n") : "";
|
|
207
|
+
const output = it.aggregatedOutput ? String(it.aggregatedOutput) : changeDiffs || undefined;
|
|
208
|
+
if (method === "item/started") this.emit({ type: "tool_call", id: it.id, title, kind: it.type, status, detail, input, files });
|
|
209
|
+
else this.emit({ type: "tool_update", id: it.id, status, title, detail, input, files, output });
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "item/commandExecution/outputDelta":
|
|
213
|
+
this.emit({ type: "tool_update", id: p.itemId, detail: p.delta });
|
|
214
|
+
break;
|
|
215
|
+
case "turn/plan/updated":
|
|
216
|
+
case "item/plan/updated":
|
|
217
|
+
this.emit({ type: "plan", entries: (p.plan ?? p.entries ?? []).map((e: any) => ({ content: e.step ?? e.content, status: e.status })) });
|
|
218
|
+
break;
|
|
219
|
+
case "thread/tokenUsage/updated":
|
|
220
|
+
case "turn/tokenUsage/updated": {
|
|
221
|
+
const rl = p.rateLimits ?? p.tokenUsage?.rateLimits;
|
|
222
|
+
const used = rl?.primary?.usedPercent ?? rl?.primary?.used_percent;
|
|
223
|
+
const t = p.tokenUsage?.total;
|
|
224
|
+
if (t) {
|
|
225
|
+
// Totals are cumulative for the thread; emit the delta. Codex's inputTokens include cached ones.
|
|
226
|
+
const d = {
|
|
227
|
+
inputTokens: Math.max(0, (t.inputTokens ?? 0) - this.lastTotals.inputTokens),
|
|
228
|
+
cachedInputTokens: Math.max(0, (t.cachedInputTokens ?? 0) - this.lastTotals.cachedInputTokens),
|
|
229
|
+
cacheWriteInputTokens: Math.max(0, (t.cacheWriteInputTokens ?? 0) - this.lastTotals.cacheWriteInputTokens),
|
|
230
|
+
outputTokens: Math.max(0, (t.outputTokens ?? 0) - this.lastTotals.outputTokens),
|
|
231
|
+
};
|
|
232
|
+
this.lastTotals = { inputTokens: t.inputTokens ?? 0, cachedInputTokens: t.cachedInputTokens ?? 0, cacheWriteInputTokens: t.cacheWriteInputTokens ?? 0, outputTokens: t.outputTokens ?? 0 };
|
|
233
|
+
if (d.inputTokens || d.outputTokens) {
|
|
234
|
+
this.emit({
|
|
235
|
+
type: "usage",
|
|
236
|
+
inputTokens: Math.max(0, d.inputTokens - d.cachedInputTokens),
|
|
237
|
+
cacheReadTokens: d.cachedInputTokens,
|
|
238
|
+
cacheWriteTokens: d.cacheWriteInputTokens,
|
|
239
|
+
outputTokens: d.outputTokens,
|
|
240
|
+
model: this.model,
|
|
241
|
+
usedPercent: typeof used === "number" ? used : undefined,
|
|
242
|
+
contextSize: p.tokenUsage?.modelContextWindow,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
} else if (typeof used === "number") this.emit({ type: "usage", usedPercent: used });
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "turn/completed": {
|
|
249
|
+
const status = p.turn?.status;
|
|
250
|
+
const err = p.turn?.error;
|
|
251
|
+
if (err) this.emit({ type: "error", kind: classify(JSON.stringify(err)), message: err.message ?? JSON.stringify(err) });
|
|
252
|
+
const reason: StopReason = status === "completed" ? "end_turn" : status === "interrupted" || status === "cancelled" ? "cancelled" : err ? "error" : "other";
|
|
253
|
+
this.finishTurn(reason);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case "turn/error":
|
|
257
|
+
case "error": {
|
|
258
|
+
const msg = p.error?.message ?? p.message ?? JSON.stringify(p);
|
|
259
|
+
this.emit({ type: "error", kind: classify(msg), message: msg });
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case "serverRequest/resolved": {
|
|
263
|
+
for (const [id, a] of this.pendingApprovals) if (String(a.rpcId) === String(p.requestId)) this.pendingApprovals.delete(id);
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
default:
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private finishTurn(reason: StopReason) {
|
|
272
|
+
const t = this.turn;
|
|
273
|
+
this.turn = null;
|
|
274
|
+
if (this.session) this.session.lastUsedAt = Date.now();
|
|
275
|
+
this.emit({ type: "turn_end", stopReason: reason });
|
|
276
|
+
t?.resolve(reason);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async prompt(text: string): Promise<StopReason> {
|
|
280
|
+
if (!this.transport || !this.threadId) throw new Error("engine not started");
|
|
281
|
+
const done = new Promise<StopReason>((resolve) => (this.turn = { resolve }));
|
|
282
|
+
try {
|
|
283
|
+
const approvalPolicy = this.info?.currentMode === "never" ? "never" : "on-request";
|
|
284
|
+
const res = await this.request("turn/start", { threadId: this.threadId, cwd: this.cwd, input: [{ type: "text", text }], model: this.model, approvalPolicy });
|
|
285
|
+
if (this.turn) this.turn.id = res?.turn?.id;
|
|
286
|
+
} catch (e: any) {
|
|
287
|
+
const msg = `${e?.message ?? e} ${JSON.stringify(e?.data ?? "")}`.trim();
|
|
288
|
+
this.emit({ type: "error", kind: classify(msg), message: msg });
|
|
289
|
+
this.finishTurn("error");
|
|
290
|
+
}
|
|
291
|
+
return done;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
answerPermission(requestId: string, optionId: string) {
|
|
295
|
+
const a = this.pendingApprovals.get(requestId);
|
|
296
|
+
if (!a) return;
|
|
297
|
+
this.pendingApprovals.delete(requestId);
|
|
298
|
+
this.transport!.send(JSON.stringify({ jsonrpc: "2.0", id: a.rpcId, result: { decision: optionId } }));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async cancel() {
|
|
302
|
+
if (!this.transport || !this.threadId || !this.turn) return;
|
|
303
|
+
for (const [id] of this.pendingApprovals) this.answerPermission(id, "cancel");
|
|
304
|
+
await this.request("turn/interrupt", { threadId: this.threadId, turnId: this.turn.id }).catch(() => {});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async setMode(modeId: string) {
|
|
308
|
+
// Applied on the next turn via approvalPolicy; kept for parity with ACP modes.
|
|
309
|
+
if (this.info) this.info.currentMode = modeId;
|
|
310
|
+
this.emit({ type: "mode", modeId });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Codex takes the model per turn; it applies from the next prompt. */
|
|
314
|
+
async setModel(modelId: string) {
|
|
315
|
+
this.model = modelId;
|
|
316
|
+
if (this.info) this.info.currentModel = modelId;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async close() {
|
|
320
|
+
const t = this.transport;
|
|
321
|
+
this.transport = null;
|
|
322
|
+
for (const [id] of this.pendingApprovals) this.answerPermission(id, "cancel");
|
|
323
|
+
if (this.turn) this.finishTurn("cancelled");
|
|
324
|
+
await t?.close();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function classify(msg: string) {
|
|
329
|
+
return classifyError(msg);
|
|
330
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment for agent subprocesses. Subscriptions only: vendor API keys are
|
|
3
|
+
* removed so each agent uses its login. Nested-session markers from a parent
|
|
4
|
+
* Claude Code / Codex session are removed too. CLAUDE_CODE_EXECUTABLE is kept
|
|
5
|
+
* on purpose: the Claude ACP adapter uses it to run the user's own `claude`.
|
|
6
|
+
*/
|
|
7
|
+
const STRIP = /^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|OPENAI_API_KEY|CURSOR_API_KEY|XAI_API_KEY|GROK_API_KEY|GEMINI_API_KEY|GOOGLE_API_KEY|CLAUDECODE|CLAUDE_PID|CLAUDE_EFFORT|CODEX_SANDBOX|CODEX_THREAD_ID)$|^CLAUDE_CODE_(?!EXECUTABLE$)/;
|
|
8
|
+
|
|
9
|
+
export function agentEnv(extra: Record<string, string> = {}): Record<string, string> {
|
|
10
|
+
const env: Record<string, string> = {};
|
|
11
|
+
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !STRIP.test(k)) env[k] = v;
|
|
12
|
+
return { ...env, ...extra };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function strippedKeys(): string[] {
|
|
16
|
+
return Object.keys(process.env).filter((k) => /API_KEY|AUTH_TOKEN/.test(k) && STRIP.test(k));
|
|
17
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ErrorKind } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wording agents use when a subscription runs out. Claude Code says "You've hit
|
|
5
|
+
* your session limit · resets 3pm" or "Claude AI usage limit reached"; Codex
|
|
6
|
+
* "You've hit your usage limit"; Grok "free-usage-exhausted" / 429.
|
|
7
|
+
*/
|
|
8
|
+
const QUOTA = /usage limit|session limit|weekly limit|daily limit|monthly limit|hit your limit|limit reached|limit · resets|out of (credits|quota)|quota|rate.?limit|too many requests|\b429\b|insufficient|usage[-_ ]exhausted|credits? (exhausted|depleted)/i;
|
|
9
|
+
const AUTH = /\bauth|login|unauthori[sz]ed|\b401\b|not logged in/i;
|
|
10
|
+
|
|
11
|
+
/** The human part of an agent error: drops "Internal error" and unwraps {"details": "..."} / {"message": "..."}. */
|
|
12
|
+
export function cleanError(text: string): string {
|
|
13
|
+
const m = text.match(/"(?:details|message)"\s*:\s*"((?:[^"\\]|\\.)+)"/);
|
|
14
|
+
const inner = m ? m[1]!.replace(/\\"/g, '"') : text;
|
|
15
|
+
return inner.replace(/^\s*(internal error|error)[:\s]*/i, "").trim() || text.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function classifyError(text: string): ErrorKind {
|
|
19
|
+
if (QUOTA.test(text)) return "quota";
|
|
20
|
+
if (AUTH.test(text)) return "auth";
|
|
21
|
+
return "protocol";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A whole reply that is only a limit notice (some agents end the turn normally
|
|
26
|
+
* and put the notice in the message instead of raising an error).
|
|
27
|
+
*/
|
|
28
|
+
export function isLimitNotice(reply: string): boolean {
|
|
29
|
+
const t = reply.trim();
|
|
30
|
+
return t.length > 0 && t.length < 400 && /^(you've|you have) (hit|reached) your (session|usage|weekly|daily|monthly)? ?limit|^claude ai usage limit reached|^(session|usage|weekly) limit (reached|hit)/i.test(t);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** "resets 3pm", "resets 6:30 pm", "resets at 15:00" → next such time, as epoch ms. */
|
|
34
|
+
export function parseResetTime(text: string, now = new Date()): number | undefined {
|
|
35
|
+
const m = text.match(/resets?(?: at)? (\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
|
|
36
|
+
if (!m) return undefined;
|
|
37
|
+
let h = Number(m[1]);
|
|
38
|
+
const min = Number(m[2] ?? 0);
|
|
39
|
+
const ap = m[3]?.toLowerCase();
|
|
40
|
+
if (ap === "pm" && h < 12) h += 12;
|
|
41
|
+
if (ap === "am" && h === 12) h = 0;
|
|
42
|
+
if (h > 23 || min > 59) return undefined;
|
|
43
|
+
const d = new Date(now);
|
|
44
|
+
d.setHours(h, min, 0, 0);
|
|
45
|
+
if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1);
|
|
46
|
+
return d.getTime();
|
|
47
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import type { AgentId } from "../adapters/types.ts";
|
|
5
|
+
import type { Engine, EngineFactory } from "./types.ts";
|
|
6
|
+
import { AcpEngine, type UsageSample } from "./acp.ts";
|
|
7
|
+
import { CodexEngine } from "./codex.ts";
|
|
8
|
+
import { ClaudeUsageTail } from "../transcripts/claude-usage.ts";
|
|
9
|
+
|
|
10
|
+
/** Grok writes ~/.grok/sessions/<encoded cwd>/<session>/usage.json with per-turn totals. */
|
|
11
|
+
function grokUsageTail(cwd: string, sessionId: string): { read(): UsageSample[] } {
|
|
12
|
+
const path = join(homedir(), ".grok", "sessions", encodeURIComponent(cwd), sessionId, "usage.json");
|
|
13
|
+
let seen = 0;
|
|
14
|
+
return {
|
|
15
|
+
read() {
|
|
16
|
+
if (!existsSync(path)) return [];
|
|
17
|
+
let j: any;
|
|
18
|
+
try {
|
|
19
|
+
j = JSON.parse(readFileSync(path, "utf8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const turns: any[] = j.turns ?? [];
|
|
24
|
+
const fresh = turns.slice(seen);
|
|
25
|
+
seen = turns.length;
|
|
26
|
+
return fresh.map((t) => ({
|
|
27
|
+
model: t.primaryModelId ?? j.session?.primaryModelId,
|
|
28
|
+
input: Math.max(0, (t.inputTokens ?? 0) - (t.cachedReadTokens ?? 0)),
|
|
29
|
+
cacheRead: t.cachedReadTokens ?? 0,
|
|
30
|
+
cacheWrite: t.cacheCreationTokens ?? 0,
|
|
31
|
+
output: t.outputTokens ?? 0,
|
|
32
|
+
costUsd: typeof t.costUsdTicks === "number" ? t.costUsdTicks / 1e10 : undefined,
|
|
33
|
+
}));
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CLAUDE_ADAPTER_PKG = "@agentclientprotocol/claude-agent-acp@0.93.0";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Command for the Claude ACP adapter (it runs the user's own `claude`).
|
|
42
|
+
* From a source checkout it is in node_modules; from the compiled binary it is
|
|
43
|
+
* fetched once by bunx and cached.
|
|
44
|
+
*/
|
|
45
|
+
function claudeAdapterCmd(): string[] | null {
|
|
46
|
+
const runningOnBun = basename(process.execPath).toLowerCase().startsWith("bun");
|
|
47
|
+
if (process.platform !== "win32") {
|
|
48
|
+
const candidates = [
|
|
49
|
+
join(import.meta.dir, "..", "..", "node_modules", ".bin", "claude-agent-acp"),
|
|
50
|
+
join(process.cwd(), "node_modules", ".bin", "claude-agent-acp"),
|
|
51
|
+
];
|
|
52
|
+
for (const c of candidates) if (existsSync(c)) return [c];
|
|
53
|
+
}
|
|
54
|
+
// The installed package's own entry, run with this Bun: works where .bin shims
|
|
55
|
+
// do not (Windows) and when no `bun` is on PATH (installed through npm).
|
|
56
|
+
if (runningOnBun) {
|
|
57
|
+
try {
|
|
58
|
+
const pkgJson = Bun.resolveSync("@agentclientprotocol/claude-agent-acp/package.json", import.meta.dir);
|
|
59
|
+
const bin = JSON.parse(readFileSync(pkgJson, "utf8")).bin;
|
|
60
|
+
const entry = typeof bin === "string" ? bin : bin?.["claude-agent-acp"];
|
|
61
|
+
if (entry && existsSync(join(dirname(pkgJson), entry))) return [process.execPath, join(dirname(pkgJson), entry)];
|
|
62
|
+
} catch {
|
|
63
|
+
/* not installed next to codemax */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const onPath = Bun.which("claude-agent-acp");
|
|
67
|
+
if (onPath) return [onPath];
|
|
68
|
+
const bun = Bun.which("bun") ?? (runningOnBun ? process.execPath : null);
|
|
69
|
+
const bunx = Bun.which("bunx") ? [Bun.which("bunx")!] : bun ? [bun, "x"] : null;
|
|
70
|
+
return bunx ? [...bunx, CLAUDE_ADAPTER_PKG] : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const factories: EngineFactory[] = [
|
|
74
|
+
{
|
|
75
|
+
agent: "claude",
|
|
76
|
+
available: async () => !!Bun.which("claude") && !!claudeAdapterCmd(),
|
|
77
|
+
create: () =>
|
|
78
|
+
new AcpEngine({
|
|
79
|
+
agent: "claude",
|
|
80
|
+
name: "Claude Code",
|
|
81
|
+
cmd: claudeAdapterCmd()!,
|
|
82
|
+
env: { CLAUDE_CODE_EXECUTABLE: Bun.which("claude")! },
|
|
83
|
+
usageTail: (s) => new ClaudeUsageTail(s.cwd, s.id),
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
agent: "codex",
|
|
88
|
+
available: async () => !!Bun.which("codex"),
|
|
89
|
+
create: () => new CodexEngine(),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
agent: "opencode",
|
|
93
|
+
available: async () => !!Bun.which("opencode"),
|
|
94
|
+
// OpenCode's defaults allow edits and shell without asking. Over ACP it honours an inline
|
|
95
|
+
// config, so codemax makes it ask for anything that changes state; reads stay silent.
|
|
96
|
+
create: () =>
|
|
97
|
+
new AcpEngine({
|
|
98
|
+
agent: "opencode",
|
|
99
|
+
name: "OpenCode",
|
|
100
|
+
cmd: ["opencode", "acp"],
|
|
101
|
+
env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ permission: { "*": "ask", read: "allow", glob: "allow", grep: "allow", list: "allow", todowrite: "allow", todoread: "allow", webfetch: "ask" } }) },
|
|
102
|
+
}),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
agent: "grok",
|
|
106
|
+
available: async () => !!Bun.which("grok"),
|
|
107
|
+
// Grok takes its permission mode from the user's Claude Code settings; see grokPermissionNote().
|
|
108
|
+
create: () => new AcpEngine({ agent: "grok", name: "Grok", cmd: ["grok", "agent", "stdio"], permissionPrompts: !grokPermissionNote(), usageTail: (s) => grokUsageTail(s.cwd, s.id) }),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
agent: "cursor",
|
|
112
|
+
available: async () => !!Bun.which("cursor-agent"),
|
|
113
|
+
// `cursor-agent acp` (Cursor Agent 2026.10+) asks before shell commands and edits, and
|
|
114
|
+
// exposes its modes (agent / plan / ask) and models as ACP config options.
|
|
115
|
+
create: () => new AcpEngine({ agent: "cursor", name: "Cursor Agent", cmd: ["cursor-agent", "acp"] }),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
agent: "gemini",
|
|
119
|
+
available: async () => !!Bun.which("gemini"),
|
|
120
|
+
create: () => new AcpEngine({ agent: "gemini", name: "Gemini CLI", cmd: ["gemini", "--experimental-acp"] }),
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Grok applies Claude Code's settings files as its permission source (seen in
|
|
126
|
+
* `grok inspect`). With `permissions.defaultMode: "auto"` there it rarely asks.
|
|
127
|
+
* Returns a note for the user when that is the case, else null.
|
|
128
|
+
*/
|
|
129
|
+
export function grokPermissionNote(): string | null {
|
|
130
|
+
for (const f of [join(homedir(), ".claude", "settings.local.json"), join(homedir(), ".claude", "settings.json")]) {
|
|
131
|
+
try {
|
|
132
|
+
const mode = JSON.parse(readFileSync(f, "utf8"))?.permissions?.defaultMode;
|
|
133
|
+
if (mode && mode !== "default") return `Grok follows your Claude Code settings (${f}): defaultMode is "${mode}", so it will act without asking. Set it to "default" there to get prompts.`;
|
|
134
|
+
} catch {
|
|
135
|
+
/* missing or unreadable */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Agents whose protocol never asks before acting on this machine; shown as a warning in the menu. */
|
|
142
|
+
export const NO_PERMISSION_PROMPTS: ReadonlySet<AgentId> = new Set(grokPermissionNote() ? ["grok"] : []);
|
|
143
|
+
|
|
144
|
+
export const engineFactories: Partial<Record<AgentId, EngineFactory>> = Object.fromEntries(factories.map((f) => [f.agent, f]));
|
|
145
|
+
|
|
146
|
+
export function hasEngine(agent: AgentId): boolean {
|
|
147
|
+
return !!engineFactories[agent];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createEngine(agent: AgentId): Engine {
|
|
151
|
+
const f = engineFactories[agent];
|
|
152
|
+
if (!f) throw new Error(`no protocol engine for ${agent}`);
|
|
153
|
+
return f.create();
|
|
154
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { AgentId } from "../adapters/types.ts";
|
|
2
|
+
|
|
3
|
+
/** One normalized stream that every protocol (ACP, Codex app-server, …) is mapped onto. */
|
|
4
|
+
export type EngineEvent =
|
|
5
|
+
/** `messageId` changes between separate agent messages (e.g. before and after a tool call). */
|
|
6
|
+
| { type: "text_delta"; text: string; messageId?: string }
|
|
7
|
+
| { type: "thought_delta"; text: string; messageId?: string }
|
|
8
|
+
/**
|
|
9
|
+
* `detail` is a short display string. `input` (the tool's raw arguments), `output`
|
|
10
|
+
* (its full text result) and `files` are carried uncut for the journal, which caps them.
|
|
11
|
+
*/
|
|
12
|
+
| { type: "tool_call"; id: string; title: string; kind?: string; status: ToolStatus; detail?: string; input?: unknown; output?: string; files?: string[] }
|
|
13
|
+
| { type: "tool_update"; id: string; status?: ToolStatus; title?: string; detail?: string; input?: unknown; output?: string; files?: string[] }
|
|
14
|
+
| { type: "permission_request"; request: PermissionRequest }
|
|
15
|
+
| { type: "plan"; entries: { content: string; status: string; priority?: string }[] }
|
|
16
|
+
| { type: "mode"; modeId: string }
|
|
17
|
+
| { type: "commands"; commands: { name: string; description?: string }[] }
|
|
18
|
+
| {
|
|
19
|
+
type: "usage";
|
|
20
|
+
/** Incremental token counts for the work just done (never cumulative). */
|
|
21
|
+
inputTokens?: number;
|
|
22
|
+
outputTokens?: number;
|
|
23
|
+
cacheReadTokens?: number;
|
|
24
|
+
cacheWriteTokens?: number;
|
|
25
|
+
model?: string;
|
|
26
|
+
/** Incremental cost the agent itself reported, in USD. */
|
|
27
|
+
costUsd?: number;
|
|
28
|
+
/** Rate-limit window consumption, when the agent exposes it. */
|
|
29
|
+
usedPercent?: number;
|
|
30
|
+
/** Context window fill, when reported (tokens used / size). */
|
|
31
|
+
contextUsed?: number;
|
|
32
|
+
contextSize?: number;
|
|
33
|
+
}
|
|
34
|
+
| { type: "session"; ref: EngineSessionRef }
|
|
35
|
+
| { type: "turn_end"; stopReason: StopReason }
|
|
36
|
+
| { type: "error"; kind: ErrorKind; message: string }
|
|
37
|
+
| { type: "log"; level: "info" | "warn" | "error"; message: string };
|
|
38
|
+
|
|
39
|
+
export type ToolStatus = "pending" | "in_progress" | "completed" | "failed";
|
|
40
|
+
export type StopReason = "end_turn" | "max_tokens" | "cancelled" | "refusal" | "error" | "other";
|
|
41
|
+
export type ErrorKind = "quota" | "auth" | "protocol" | "crashed" | "other";
|
|
42
|
+
|
|
43
|
+
export interface PermissionOption {
|
|
44
|
+
id: string;
|
|
45
|
+
label: string;
|
|
46
|
+
/** allow_once | allow_always | reject_once | reject_always */
|
|
47
|
+
kind: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PermissionRequest {
|
|
51
|
+
id: string;
|
|
52
|
+
toolCallId?: string;
|
|
53
|
+
/** ACP tool kind when known: read | edit | delete | move | search | execute | think | fetch | other */
|
|
54
|
+
kind?: string;
|
|
55
|
+
title: string;
|
|
56
|
+
detail?: string;
|
|
57
|
+
options: PermissionOption[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface EngineSessionRef {
|
|
61
|
+
agent: AgentId;
|
|
62
|
+
/** Vendor session / thread id, used to resume. */
|
|
63
|
+
id: string;
|
|
64
|
+
cwd: string;
|
|
65
|
+
startedAt: number;
|
|
66
|
+
lastUsedAt: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EngineMode {
|
|
70
|
+
id: string;
|
|
71
|
+
name: string;
|
|
72
|
+
description?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface EngineStartOptions {
|
|
76
|
+
cwd: string;
|
|
77
|
+
/** Resume a previous session instead of starting a new one. */
|
|
78
|
+
resume?: EngineSessionRef;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface EngineInfo {
|
|
82
|
+
agent: AgentId;
|
|
83
|
+
name: string;
|
|
84
|
+
version?: string;
|
|
85
|
+
modes: EngineMode[];
|
|
86
|
+
currentMode?: string;
|
|
87
|
+
models?: { id: string; name: string }[];
|
|
88
|
+
currentModel?: string;
|
|
89
|
+
/** Subscription plan name when the agent reports one (e.g. "chatgpt pro"). */
|
|
90
|
+
plan?: string;
|
|
91
|
+
/** Other settings the agent exposes as selects, e.g. Grok's reasoning effort. */
|
|
92
|
+
options?: EngineOption[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface EngineOption {
|
|
96
|
+
id: string;
|
|
97
|
+
name: string;
|
|
98
|
+
/** ACP category, e.g. "thought_level" for reasoning effort. */
|
|
99
|
+
category?: string;
|
|
100
|
+
current?: string;
|
|
101
|
+
values: { value: string; name: string; description?: string }[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A running agent process spoken to over a protocol. One engine = one agent
|
|
106
|
+
* session. Events arrive through `onEvent`; permission requests must be
|
|
107
|
+
* answered with `answerPermission` or the agent stays blocked.
|
|
108
|
+
*/
|
|
109
|
+
export interface Engine {
|
|
110
|
+
readonly agent: AgentId;
|
|
111
|
+
readonly info: EngineInfo | null;
|
|
112
|
+
readonly session: EngineSessionRef | null;
|
|
113
|
+
onEvent(listener: (e: EngineEvent) => void): () => void;
|
|
114
|
+
start(opts: EngineStartOptions): Promise<EngineInfo>;
|
|
115
|
+
/** Send one user turn. Resolves when the turn ends (turn_end has been emitted). */
|
|
116
|
+
prompt(text: string): Promise<StopReason>;
|
|
117
|
+
answerPermission(requestId: string, optionId: string): void;
|
|
118
|
+
cancel(): Promise<void>;
|
|
119
|
+
setMode?(modeId: string): Promise<void>;
|
|
120
|
+
setModel?(modelId: string): Promise<void>;
|
|
121
|
+
/** Change one of `info.options` (e.g. reasoning effort). */
|
|
122
|
+
setOption?(optionId: string, value: string): Promise<void>;
|
|
123
|
+
close(): Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface EngineFactory {
|
|
127
|
+
agent: AgentId;
|
|
128
|
+
/** True when the binary (or adapter) this engine needs is available. */
|
|
129
|
+
available(): Promise<boolean>;
|
|
130
|
+
create(): Engine;
|
|
131
|
+
}
|