codemax-cli 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/adapters/detect.ts +38 -9
- package/src/adapters/index.ts +2 -2
- package/src/adapters/simple.ts +7 -6
- package/src/adapters/types.ts +3 -1
- package/src/chat/app.ts +374 -123
- package/src/chat/models.ts +25 -0
- package/src/chat/notes.ts +101 -0
- package/src/chat/palette.ts +14 -1
- package/src/chat/question.ts +192 -0
- package/src/chat/subagents.ts +107 -0
- package/src/cli.ts +2 -1
- package/src/config.ts +17 -4
- package/src/core/termstatus.ts +124 -0
- package/src/core/usage.ts +4 -2
- package/src/engines/acp.ts +123 -10
- package/src/engines/antigravity.ts +95 -0
- package/src/engines/codex.ts +69 -28
- package/src/engines/elicitation.ts +60 -0
- package/src/engines/index.ts +6 -3
- package/src/engines/types.ts +45 -0
package/src/engines/acp.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { ClientSideConnection, ndJsonStream, type Client, type Stream } from "@agentclientprotocol/sdk";
|
|
2
2
|
import type { AgentId } from "../adapters/types.ts";
|
|
3
|
-
import type { Engine, EngineEvent, EngineInfo, EngineOption, EngineSessionRef, EngineStartOptions, PermissionRequest, PlanLimit, StopReason } from "./types.ts";
|
|
3
|
+
import type { Engine, EngineEvent, EngineInfo, EngineOption, EngineSessionRef, EngineStartOptions, PermissionRequest, PlanLimit, QuestionAnswer, StopReason } from "./types.ts";
|
|
4
|
+
import { elicitationResponse, parseElicitation } from "./elicitation.ts";
|
|
4
5
|
import { agentEnv } from "./env.ts";
|
|
5
|
-
import { platformArgv } from "../core/spawn.ts";
|
|
6
|
+
import { IS_WINDOWS, platformArgv } from "../core/spawn.ts";
|
|
6
7
|
import { classifyError } from "./errors.ts";
|
|
7
8
|
|
|
8
9
|
export interface UsageSample {
|
|
@@ -22,6 +23,16 @@ export interface AcpEngineOptions {
|
|
|
22
23
|
env?: Record<string, string>;
|
|
23
24
|
/** Pre-built stream (tests inject an in-process agent). When set, `cmd` is not spawned. */
|
|
24
25
|
stream?: Stream;
|
|
26
|
+
/**
|
|
27
|
+
* Run the agent behind plain pipes (`cat | agent | cat`, macOS/Linux). Bun gives a child
|
|
28
|
+
* socket stdio; Antigravity's Python ACP server never answers on a socket.
|
|
29
|
+
*/
|
|
30
|
+
plainPipes?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Sign-in methods codemax may use when the agent says "Authentication required" (its
|
|
33
|
+
* subscription login only, never an API-key method). A browser may open the first time.
|
|
34
|
+
*/
|
|
35
|
+
signIn?: string[];
|
|
25
36
|
/** False when the agent is known to auto-approve tools over ACP (surfaced to the user). */
|
|
26
37
|
permissionPrompts?: boolean;
|
|
27
38
|
/**
|
|
@@ -33,7 +44,7 @@ export interface AcpEngineOptions {
|
|
|
33
44
|
|
|
34
45
|
/**
|
|
35
46
|
* Generic Agent Client Protocol engine. Works for any agent that speaks ACP
|
|
36
|
-
* over stdio: Claude Code (via the Zed adapter), OpenCode, Grok,
|
|
47
|
+
* over stdio: Claude Code (via the Zed adapter), OpenCode, Grok, Cursor, Antigravity.
|
|
37
48
|
*/
|
|
38
49
|
export class AcpEngine implements Engine {
|
|
39
50
|
readonly agent: AgentId;
|
|
@@ -43,6 +54,9 @@ export class AcpEngine implements Engine {
|
|
|
43
54
|
private conn: ClientSideConnection | null = null;
|
|
44
55
|
private proc: Bun.Subprocess | null = null;
|
|
45
56
|
private pendingPermissions = new Map<string, (optionId: string | null) => void>();
|
|
57
|
+
private pendingQuestions = new Map<string, (answer: QuestionAnswer) => void>();
|
|
58
|
+
/** cancel() was called during the current turn. */
|
|
59
|
+
private cancelled = false;
|
|
46
60
|
private permSeq = 0;
|
|
47
61
|
private turnActive = false;
|
|
48
62
|
private tail: { read(): UsageSample[] } | null = null;
|
|
@@ -69,7 +83,18 @@ export class AcpEngine implements Engine {
|
|
|
69
83
|
const stream = this.opts.stream ?? this.spawn(o.cwd);
|
|
70
84
|
const client: Client = {
|
|
71
85
|
requestPermission: (p) => this.onPermission(p),
|
|
72
|
-
|
|
86
|
+
// Form questions only: codemax shows them in its own dialog. URL elicitations are not advertised.
|
|
87
|
+
createElicitation: (p) => this.onElicitation(p) as any,
|
|
88
|
+
sessionUpdate: ({ sessionId, update }) => {
|
|
89
|
+
// Another session on this connection is a spawned agent (Cursor, Grok): its steps
|
|
90
|
+
// are its activity, never part of the main transcript.
|
|
91
|
+
if (sessionId && this.session && sessionId !== this.session.id) return this.onChildSession(sessionId, update as any);
|
|
92
|
+
this.onUpdate(update as any);
|
|
93
|
+
},
|
|
94
|
+
// Cursor reports a finished Task subagent with its own notification.
|
|
95
|
+
extNotification: async (method: string, params: any) => {
|
|
96
|
+
if (method === "cursor/task" && params?.toolCallId) this.emit({ type: "subagent", id: params.toolCallId, name: params.subagentType, task: params.description, status: "done" });
|
|
97
|
+
},
|
|
73
98
|
readTextFile: async (p) => ({ content: await Bun.file(p.path).text() }),
|
|
74
99
|
writeTextFile: async (p) => {
|
|
75
100
|
await Bun.write(p.path, p.content);
|
|
@@ -77,7 +102,7 @@ export class AcpEngine implements Engine {
|
|
|
77
102
|
},
|
|
78
103
|
};
|
|
79
104
|
this.conn = new ClientSideConnection(() => client, stream);
|
|
80
|
-
const init = await this.conn.initialize({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false } });
|
|
105
|
+
const init = await this.conn.initialize({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false, ...(o.questions === false ? {} : { elicitation: { form: {} } }) } });
|
|
81
106
|
|
|
82
107
|
let sessionId: string;
|
|
83
108
|
let modes: any;
|
|
@@ -85,21 +110,21 @@ export class AcpEngine implements Engine {
|
|
|
85
110
|
let config: any;
|
|
86
111
|
if (o.resume) {
|
|
87
112
|
try {
|
|
88
|
-
const r: any = await this.conn
|
|
113
|
+
const r: any = await this.signedIn(init, () => this.conn!.loadSession({ sessionId: o.resume!.id, cwd: o.cwd, mcpServers: [] }));
|
|
89
114
|
sessionId = o.resume.id;
|
|
90
115
|
modes = r?.modes;
|
|
91
116
|
models = r?.models;
|
|
92
117
|
config = r?.configOptions;
|
|
93
118
|
} catch (e: any) {
|
|
94
119
|
this.emit({ type: "log", level: "warn", message: `resume failed (${e?.message ?? e}); starting a new session` });
|
|
95
|
-
const s: any = await this.conn
|
|
120
|
+
const s: any = await this.signedIn(init, () => this.conn!.newSession({ cwd: o.cwd, mcpServers: [] }));
|
|
96
121
|
sessionId = s.sessionId;
|
|
97
122
|
config = s.configOptions;
|
|
98
123
|
modes = s.modes;
|
|
99
124
|
models = s.models;
|
|
100
125
|
}
|
|
101
126
|
} else {
|
|
102
|
-
const s: any = await this.conn
|
|
127
|
+
const s: any = await this.signedIn(init, () => this.conn!.newSession({ cwd: o.cwd, mcpServers: [] }));
|
|
103
128
|
sessionId = s.sessionId;
|
|
104
129
|
modes = s.modes;
|
|
105
130
|
models = s.models;
|
|
@@ -134,8 +159,28 @@ export class AcpEngine implements Engine {
|
|
|
134
159
|
return this.info;
|
|
135
160
|
}
|
|
136
161
|
|
|
162
|
+
/** Run `fn`; if the agent wants a sign-in first, sign in with an allowed method and run it again. */
|
|
163
|
+
private async signedIn<T>(init: any, fn: () => Promise<T>): Promise<T> {
|
|
164
|
+
try {
|
|
165
|
+
return await fn();
|
|
166
|
+
} catch (e: any) {
|
|
167
|
+
// -32000 is ACP's "auth required"; some agents send it as an internal error with the reason in data.
|
|
168
|
+
const needsAuth = e?.code === -32000 || /authenticat|auth required|sign.?in|log.?in/i.test(`${e?.message ?? ""} ${JSON.stringify(e?.data ?? "")}`);
|
|
169
|
+
const method = (init?.authMethods ?? []).map((m: any) => m.id).find((id: string) => this.opts.signIn?.includes(id));
|
|
170
|
+
if (!needsAuth || !this.conn) throw e;
|
|
171
|
+
if (!method) {
|
|
172
|
+
const offered = (init?.authMethods ?? []).map((m: any) => m.name ?? m.id).join(", ") || "none";
|
|
173
|
+
throw new Error(`${this.opts.name} needs you to sign in, and offers no subscription sign-in codemax may use (offered: ${offered}). codemax never uses API keys.`);
|
|
174
|
+
}
|
|
175
|
+
this.emit({ type: "log", level: "warn", message: `signing in to ${this.opts.name} with your subscription (${method}); a browser window may open the first time` });
|
|
176
|
+
await this.conn.authenticate({ methodId: method } as any);
|
|
177
|
+
return fn();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
137
181
|
private spawn(cwd: string): Stream {
|
|
138
|
-
const
|
|
182
|
+
const argv = this.opts.plainPipes && !IS_WINDOWS ? ["/bin/sh", "-c", 'cat | "$@" | cat', "sh", ...this.opts.cmd] : platformArgv(this.opts.cmd);
|
|
183
|
+
const proc = Bun.spawn(argv, { cwd, env: agentEnv(this.opts.env), stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
139
184
|
this.proc = proc;
|
|
140
185
|
void (async () => {
|
|
141
186
|
for await (const chunk of proc.stderr as ReadableStream<Uint8Array>) {
|
|
@@ -182,9 +227,11 @@ export class AcpEngine implements Engine {
|
|
|
182
227
|
if (u.content?.type === "text") this.emit({ type: "thought_delta", text: u.content.text, messageId: u.messageId });
|
|
183
228
|
break;
|
|
184
229
|
case "tool_call":
|
|
230
|
+
this.trackSubagent(u, true);
|
|
185
231
|
this.emit({ type: "tool_call", id: u.toolCallId, title: u.title ?? u.toolCallId, kind: u.kind, status: mapStatus(u.status), detail: summarize(u), input: u.rawInput, files: paths(u), ...full(u) });
|
|
186
232
|
break;
|
|
187
233
|
case "tool_call_update":
|
|
234
|
+
this.trackSubagent(u, false);
|
|
188
235
|
this.emit({ type: "tool_update", id: u.toolCallId, status: u.status ? mapStatus(u.status) : undefined, title: u.title, detail: summarize(u), input: u.rawInput, files: paths(u), ...full(u) });
|
|
189
236
|
break;
|
|
190
237
|
case "plan":
|
|
@@ -215,13 +262,15 @@ export class AcpEngine implements Engine {
|
|
|
215
262
|
async prompt(text: string): Promise<StopReason> {
|
|
216
263
|
if (!this.conn || !this.session) throw new Error("engine not started");
|
|
217
264
|
this.turnActive = true;
|
|
265
|
+
this.cancelled = false;
|
|
218
266
|
this.turnContext = 0;
|
|
219
267
|
try {
|
|
220
268
|
const res: any = await this.conn.prompt({ sessionId: this.session.id, prompt: [{ type: "text", text }] });
|
|
221
269
|
this.session.lastUsedAt = Date.now();
|
|
222
270
|
this.flushUsage();
|
|
223
271
|
this.emitTurnUsage(res?.usage);
|
|
224
|
-
|
|
272
|
+
// Some agents (Antigravity) end an interrupted turn with "end_turn": it was still cancelled.
|
|
273
|
+
const reason = this.cancelled ? "cancelled" : mapStop(res.stopReason);
|
|
225
274
|
this.emit({ type: "turn_end", stopReason: reason });
|
|
226
275
|
return reason;
|
|
227
276
|
} catch (e: any) {
|
|
@@ -240,6 +289,61 @@ export class AcpEngine implements Engine {
|
|
|
240
289
|
this.pendingPermissions.get(requestId)?.(optionId);
|
|
241
290
|
}
|
|
242
291
|
|
|
292
|
+
answerQuestion(requestId: string, answer: QuestionAnswer) {
|
|
293
|
+
this.pendingQuestions.get(requestId)?.(answer);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Launch tool call id → whether it runs in the background (its "completed" only means launched). */
|
|
297
|
+
private subagentLaunches = new Map<string, { background: boolean }>();
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Subagents from tool calls. A launch is Claude's Agent/Task tool, OpenCode's task tool or
|
|
301
|
+
* Cursor's "Task: …" (all carry subagent_type / description / prompt). Claude stamps each of
|
|
302
|
+
* the subagent's own tool calls with `_meta.claudeCode.parentToolUseId`: that is its activity.
|
|
303
|
+
*/
|
|
304
|
+
private trackSubagent(u: any, isNew: boolean) {
|
|
305
|
+
const id = u.toolCallId;
|
|
306
|
+
const meta = u._meta?.claudeCode ?? {};
|
|
307
|
+
const parent = meta.parentToolUseId;
|
|
308
|
+
if (parent && this.subagentLaunches.has(parent)) {
|
|
309
|
+
if (isNew && u.title) this.emit({ type: "subagent", id: parent, activity: String(u.title) });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const launch = this.subagentLaunches.get(id);
|
|
313
|
+
if (launch) {
|
|
314
|
+
const st = u.status;
|
|
315
|
+
if (st === "failed") this.emit({ type: "subagent", id, status: "failed" });
|
|
316
|
+
else if (st === "completed" && !launch.background) this.emit({ type: "subagent", id, status: "done" });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (!isNew) return;
|
|
320
|
+
const input = u.rawInput ?? {};
|
|
321
|
+
const tool = String(meta.toolName ?? input._toolName ?? "");
|
|
322
|
+
const type = input.subagent_type ?? input.subagentType;
|
|
323
|
+
const isLaunch = /^(agent|task)$/i.test(tool) || typeof type === "string" || /^Task: /.test(String(u.title ?? ""));
|
|
324
|
+
if (!isLaunch || !(input.prompt || input.description || type)) return;
|
|
325
|
+
this.subagentLaunches.set(id, { background: !!(input.run_in_background ?? input.background) });
|
|
326
|
+
this.emit({ type: "subagent", id, name: typeof type === "string" && type ? type : input.name ?? "agent", task: input.description ?? String(u.title ?? "").replace(/^Task: /, ""), status: "running" });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A spawned agent's own session (agents that run subagents as sessions). */
|
|
330
|
+
private onChildSession(sessionId: string, u: any) {
|
|
331
|
+
if (u?.sessionUpdate === "tool_call" && u.title) this.emit({ type: "subagent", id: sessionId, activity: String(u.title) });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async onElicitation(p: any) {
|
|
335
|
+
if (p?.mode && p.mode !== "form") return { action: "decline" };
|
|
336
|
+
const id = `ask-${++this.permSeq}`;
|
|
337
|
+
const request = parseElicitation(id, p);
|
|
338
|
+
if (!request.fields.length) return { action: "decline" };
|
|
339
|
+
const answer = await new Promise<QuestionAnswer>((resolve) => {
|
|
340
|
+
this.pendingQuestions.set(id, resolve);
|
|
341
|
+
this.emit({ type: "question_request", request });
|
|
342
|
+
});
|
|
343
|
+
this.pendingQuestions.delete(id);
|
|
344
|
+
return elicitationResponse(answer);
|
|
345
|
+
}
|
|
346
|
+
|
|
243
347
|
private currentModelName(): string {
|
|
244
348
|
const id = this.info?.currentModel;
|
|
245
349
|
return id ?? `${this.opts.name.toLowerCase()} default model`;
|
|
@@ -278,7 +382,9 @@ export class AcpEngine implements Engine {
|
|
|
278
382
|
|
|
279
383
|
async cancel() {
|
|
280
384
|
if (!this.conn || !this.session || !this.turnActive) return;
|
|
385
|
+
this.cancelled = true;
|
|
281
386
|
for (const resolve of this.pendingPermissions.values()) resolve(null);
|
|
387
|
+
for (const resolve of this.pendingQuestions.values()) resolve({ action: "cancel" });
|
|
282
388
|
await this.conn.cancel({ sessionId: this.session.id }).catch(() => {});
|
|
283
389
|
}
|
|
284
390
|
|
|
@@ -312,8 +418,15 @@ export class AcpEngine implements Engine {
|
|
|
312
418
|
const conn = this.conn;
|
|
313
419
|
this.conn = null;
|
|
314
420
|
for (const resolve of this.pendingPermissions.values()) resolve(null);
|
|
421
|
+
for (const resolve of this.pendingQuestions.values()) resolve({ action: "cancel" });
|
|
315
422
|
if (conn && this.session) await conn.closeSession?.({ sessionId: this.session.id }).catch(() => {});
|
|
316
423
|
if (this.proc) {
|
|
424
|
+
try {
|
|
425
|
+
// End of input first: an agent behind plain pipes (and its `cat`s) exits on EOF.
|
|
426
|
+
(this.proc.stdin as any)?.end?.();
|
|
427
|
+
} catch {
|
|
428
|
+
/* already closed */
|
|
429
|
+
}
|
|
317
430
|
try {
|
|
318
431
|
this.proc.kill();
|
|
319
432
|
} catch {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CODEMAX_HOME } from "../core/paths.ts";
|
|
4
|
+
import { IS_WINDOWS } from "../core/spawn.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Antigravity CLI (`agy`, Google's successor to Gemini CLI) has no ACP mode of
|
|
8
|
+
* its own. Google publishes a separate ACP server for it in the ACP agent
|
|
9
|
+
* registry (the one Zed installs): `agy_acp_server`. codemax drives that, over
|
|
10
|
+
* plain pipes, signed in with the user's Google account (`oauth-personal`).
|
|
11
|
+
*
|
|
12
|
+
* The server is not bundled (it is proprietary and ~110 MB): codemax offers to
|
|
13
|
+
* download it from the registry's own link, only from dl.google.com, into
|
|
14
|
+
* ~/.codemax/agents/antigravity-acp/<version>/.
|
|
15
|
+
*/
|
|
16
|
+
export const REGISTRY_URL = "https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json";
|
|
17
|
+
const DIR = () => join(CODEMAX_HOME, "agents", "antigravity-acp");
|
|
18
|
+
const BIN = IS_WINDOWS ? "agy_acp_server.exe" : "agy_acp_server.par";
|
|
19
|
+
|
|
20
|
+
export interface ServerSpec {
|
|
21
|
+
cmd: string[];
|
|
22
|
+
version: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The installed ACP server (newest version first), or one on PATH; null when none. */
|
|
26
|
+
export function antigravityServer(): ServerSpec | null {
|
|
27
|
+
const onPath = Bun.which("agy_acp_server") ?? Bun.which("agy-acp-server");
|
|
28
|
+
if (onPath) return { cmd: [onPath, ...linuxArgs()], version: "PATH" };
|
|
29
|
+
const dir = DIR();
|
|
30
|
+
if (!existsSync(dir)) return null;
|
|
31
|
+
const versions = readdirSync(dir)
|
|
32
|
+
.filter((v) => existsSync(join(dir, v, BIN)))
|
|
33
|
+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
34
|
+
return versions[0] ? { cmd: [join(dir, versions[0], BIN), ...linuxArgs()], version: versions[0] } : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The registry asks Linux builds to run with `--uid=`. */
|
|
38
|
+
function linuxArgs(): string[] {
|
|
39
|
+
return process.platform === "linux" ? ["--uid="] : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The registry key for this machine ("darwin-aarch64", "windows-x86_64"…). */
|
|
43
|
+
export function platformKey(platform = process.platform, arch = process.arch): string {
|
|
44
|
+
const os = platform === "win32" ? "windows" : platform;
|
|
45
|
+
return `${os}-${arch === "arm64" ? "aarch64" : arch === "x64" ? "x86_64" : arch}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface InstallPlan {
|
|
49
|
+
version: string;
|
|
50
|
+
url: string;
|
|
51
|
+
bytes?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What would be downloaded: the registry's latest version for this machine, and its size. */
|
|
55
|
+
export async function antigravityInstallPlan(fetchImpl: typeof fetch = fetch): Promise<InstallPlan> {
|
|
56
|
+
const res = await fetchImpl(REGISTRY_URL, { signal: AbortSignal.timeout(10_000) });
|
|
57
|
+
if (!res.ok) throw new Error(`the ACP registry answered ${res.status}`);
|
|
58
|
+
const entry: any = await res.json();
|
|
59
|
+
const bin = entry?.distribution?.binary?.[platformKey()];
|
|
60
|
+
if (!bin?.archive) throw new Error(`Google publishes no Antigravity ACP server for ${platformKey()}`);
|
|
61
|
+
const url = String(bin.archive);
|
|
62
|
+
// Only Google's own download host: a changed registry entry can't point codemax elsewhere.
|
|
63
|
+
if (!/^https:\/\/dl\.google\.com\//.test(url)) throw new Error(`refusing to download the ACP server from ${new URL(url).host} (expected dl.google.com)`);
|
|
64
|
+
let bytes: number | undefined;
|
|
65
|
+
try {
|
|
66
|
+
const head = await fetchImpl(url, { method: "HEAD", signal: AbortSignal.timeout(10_000) });
|
|
67
|
+
bytes = Number(head.headers.get("content-length")) || undefined;
|
|
68
|
+
} catch {
|
|
69
|
+
/* size unknown */
|
|
70
|
+
}
|
|
71
|
+
return { version: String(entry.version ?? "latest"), url, bytes };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Download and unpack the server. Returns its command. */
|
|
75
|
+
export async function installAntigravityServer(plan: InstallPlan, onProgress: (msg: string) => void = () => {}): Promise<ServerSpec> {
|
|
76
|
+
const dest = join(DIR(), plan.version);
|
|
77
|
+
const zip = join(DIR(), `agy-acp-${plan.version}.zip`);
|
|
78
|
+
mkdirSync(dest, { recursive: true });
|
|
79
|
+
onProgress(`downloading ${plan.url}${plan.bytes ? ` (${Math.round(plan.bytes / 1e6)} MB)` : ""}`);
|
|
80
|
+
const res = await fetch(plan.url);
|
|
81
|
+
if (!res.ok || !res.body) throw new Error(`download failed: ${res.status}`);
|
|
82
|
+
await Bun.write(zip, res);
|
|
83
|
+
onProgress("unpacking");
|
|
84
|
+
const unzip = IS_WINDOWS
|
|
85
|
+
? ["powershell", "-NoProfile", "-Command", `Expand-Archive -LiteralPath '${zip.replace(/'/g, "''")}' -DestinationPath '${dest.replace(/'/g, "''")}' -Force`]
|
|
86
|
+
: ["unzip", "-q", "-o", zip, "-d", dest];
|
|
87
|
+
const p = Bun.spawn(unzip, { stdout: "pipe", stderr: "pipe" });
|
|
88
|
+
const code = await p.exited;
|
|
89
|
+
rmSync(zip, { force: true });
|
|
90
|
+
if (code !== 0) throw new Error(`could not unpack the archive: ${(await new Response(p.stderr).text()).trim().slice(0, 200)}`);
|
|
91
|
+
const bin = join(dest, BIN);
|
|
92
|
+
if (!existsSync(bin)) throw new Error(`the archive has no ${BIN}`);
|
|
93
|
+
if (!IS_WINDOWS) chmodSync(bin, 0o755);
|
|
94
|
+
return { cmd: [bin, ...linuxArgs()], version: plan.version };
|
|
95
|
+
}
|
package/src/engines/codex.ts
CHANGED
|
@@ -37,7 +37,8 @@ export class CodexEngine implements Engine {
|
|
|
37
37
|
private threadId: string | null = null;
|
|
38
38
|
private cwd = "";
|
|
39
39
|
private model: string | undefined;
|
|
40
|
-
|
|
40
|
+
/** Cumulative token totals per thread (the main one and each spawned agent's), to emit deltas. */
|
|
41
|
+
private lastTotals = new Map<string, { inputTokens: number; cachedInputTokens: number; cacheWriteInputTokens: number; outputTokens: number }>();
|
|
41
42
|
|
|
42
43
|
constructor(private opts: CodexEngineOptions = {}) {}
|
|
43
44
|
|
|
@@ -192,7 +193,63 @@ export class CodexEngine implements Engine {
|
|
|
192
193
|
this.transport!.send(JSON.stringify({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: `codemax does not handle ${method}` } }));
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Token usage of one thread. Totals are cumulative per thread; the delta is emitted. A spawned
|
|
198
|
+
* agent's thread counts too: it runs on the same Codex plan, so /usage and budgets include it.
|
|
199
|
+
* Codex's inputTokens include cached ones.
|
|
200
|
+
*/
|
|
201
|
+
private usageFrom(p: any, thread: string) {
|
|
202
|
+
const rl = p.rateLimits ?? p.tokenUsage?.rateLimits;
|
|
203
|
+
const used = rl?.primary?.usedPercent ?? rl?.primary?.used_percent;
|
|
204
|
+
const t = p.tokenUsage?.total;
|
|
205
|
+
const main = thread === (this.threadId ?? "main");
|
|
206
|
+
if (!t) {
|
|
207
|
+
if (typeof used === "number") this.emit({ type: "usage", usedPercent: used });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const last = this.lastTotals.get(thread) ?? { inputTokens: 0, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 0 };
|
|
211
|
+
const d = {
|
|
212
|
+
inputTokens: Math.max(0, (t.inputTokens ?? 0) - last.inputTokens),
|
|
213
|
+
cachedInputTokens: Math.max(0, (t.cachedInputTokens ?? 0) - last.cachedInputTokens),
|
|
214
|
+
cacheWriteInputTokens: Math.max(0, (t.cacheWriteInputTokens ?? 0) - last.cacheWriteInputTokens),
|
|
215
|
+
outputTokens: Math.max(0, (t.outputTokens ?? 0) - last.outputTokens),
|
|
216
|
+
};
|
|
217
|
+
this.lastTotals.set(thread, { inputTokens: t.inputTokens ?? 0, cachedInputTokens: t.cachedInputTokens ?? 0, cacheWriteInputTokens: t.cacheWriteInputTokens ?? 0, outputTokens: t.outputTokens ?? 0 });
|
|
218
|
+
if (!d.inputTokens && !d.outputTokens) return;
|
|
219
|
+
this.emit({
|
|
220
|
+
type: "usage",
|
|
221
|
+
inputTokens: Math.max(0, d.inputTokens - d.cachedInputTokens),
|
|
222
|
+
cacheReadTokens: d.cachedInputTokens,
|
|
223
|
+
cacheWriteTokens: d.cacheWriteInputTokens,
|
|
224
|
+
outputTokens: d.outputTokens,
|
|
225
|
+
model: main ? this.model : (p.tokenUsage?.model ?? this.model),
|
|
226
|
+
usedPercent: typeof used === "number" ? used : undefined,
|
|
227
|
+
// Context fill is the main thread's; a helper's window says nothing about it.
|
|
228
|
+
contextSize: main ? p.tokenUsage?.modelContextWindow : undefined,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Notifications from a spawned agent's thread: start, latest step, usage, end. */
|
|
233
|
+
private onChildThread(method: string, p: any, id: string) {
|
|
234
|
+
if (method === "thread/tokenUsage/updated" || method === "turn/tokenUsage/updated") return this.usageFrom(p, id);
|
|
235
|
+
if (method === "thread/started") {
|
|
236
|
+
const t = p.thread ?? {};
|
|
237
|
+
this.emit({ type: "subagent", id, name: t.agentRole ?? t.agentNickname, task: t.agentNickname && t.agentRole ? t.agentNickname : undefined, status: "running" });
|
|
238
|
+
} else if (method === "item/started") {
|
|
239
|
+
const it = p.item ?? {};
|
|
240
|
+
const step = 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}` : undefined;
|
|
241
|
+
if (step) this.emit({ type: "subagent", id, activity: step });
|
|
242
|
+
} else if (method === "turn/completed") {
|
|
243
|
+
const status = p.turn?.status;
|
|
244
|
+
this.emit({ type: "subagent", id, status: status === "failed" || p.turn?.error ? "failed" : "done" });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
195
248
|
private onNotification(method: string, p: any) {
|
|
249
|
+
// A spawned agent runs on its own thread over this connection: its messages, tools and
|
|
250
|
+
// turn end must never land in (or end) the main turn. They become subagent activity.
|
|
251
|
+
const thread = p?.threadId ?? p?.turn?.threadId ?? (method === "thread/started" ? p?.thread?.id : undefined);
|
|
252
|
+
if (thread && this.threadId && thread !== this.threadId) return this.onChildThread(method, p, thread);
|
|
196
253
|
switch (method) {
|
|
197
254
|
case "item/agentMessage/delta":
|
|
198
255
|
this.emit({ type: "text_delta", text: p.delta ?? "" });
|
|
@@ -207,6 +264,10 @@ export class CodexEngine implements Engine {
|
|
|
207
264
|
case "item/updated": {
|
|
208
265
|
const it = p.item ?? {};
|
|
209
266
|
if (it.type === "agentMessage" || it.type === "userMessage" || it.type === "reasoning") break;
|
|
267
|
+
if (it.type === "subAgentActivity") {
|
|
268
|
+
if (it.agentThreadId) this.emit({ type: "subagent", id: it.agentThreadId, name: agentName(it.agentPath), status: it.kind === "completed" ? "done" : it.kind === "failed" ? "failed" : "running" });
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
210
271
|
const status: ToolStatus = method === "item/started" ? "in_progress" : it.status === "failed" || it.status === "declined" ? "failed" : method === "item/completed" ? "completed" : "in_progress";
|
|
211
272
|
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";
|
|
212
273
|
const detail = it.aggregatedOutput ? String(it.aggregatedOutput).slice(-800) : undefined;
|
|
@@ -226,34 +287,9 @@ export class CodexEngine implements Engine {
|
|
|
226
287
|
this.emit({ type: "plan", entries: (p.plan ?? p.entries ?? []).map((e: any) => ({ content: e.step ?? e.content, status: e.status })) });
|
|
227
288
|
break;
|
|
228
289
|
case "thread/tokenUsage/updated":
|
|
229
|
-
case "turn/tokenUsage/updated":
|
|
230
|
-
|
|
231
|
-
const used = rl?.primary?.usedPercent ?? rl?.primary?.used_percent;
|
|
232
|
-
const t = p.tokenUsage?.total;
|
|
233
|
-
if (t) {
|
|
234
|
-
// Totals are cumulative for the thread; emit the delta. Codex's inputTokens include cached ones.
|
|
235
|
-
const d = {
|
|
236
|
-
inputTokens: Math.max(0, (t.inputTokens ?? 0) - this.lastTotals.inputTokens),
|
|
237
|
-
cachedInputTokens: Math.max(0, (t.cachedInputTokens ?? 0) - this.lastTotals.cachedInputTokens),
|
|
238
|
-
cacheWriteInputTokens: Math.max(0, (t.cacheWriteInputTokens ?? 0) - this.lastTotals.cacheWriteInputTokens),
|
|
239
|
-
outputTokens: Math.max(0, (t.outputTokens ?? 0) - this.lastTotals.outputTokens),
|
|
240
|
-
};
|
|
241
|
-
this.lastTotals = { inputTokens: t.inputTokens ?? 0, cachedInputTokens: t.cachedInputTokens ?? 0, cacheWriteInputTokens: t.cacheWriteInputTokens ?? 0, outputTokens: t.outputTokens ?? 0 };
|
|
242
|
-
if (d.inputTokens || d.outputTokens) {
|
|
243
|
-
this.emit({
|
|
244
|
-
type: "usage",
|
|
245
|
-
inputTokens: Math.max(0, d.inputTokens - d.cachedInputTokens),
|
|
246
|
-
cacheReadTokens: d.cachedInputTokens,
|
|
247
|
-
cacheWriteTokens: d.cacheWriteInputTokens,
|
|
248
|
-
outputTokens: d.outputTokens,
|
|
249
|
-
model: this.model,
|
|
250
|
-
usedPercent: typeof used === "number" ? used : undefined,
|
|
251
|
-
contextSize: p.tokenUsage?.modelContextWindow,
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
} else if (typeof used === "number") this.emit({ type: "usage", usedPercent: used });
|
|
290
|
+
case "turn/tokenUsage/updated":
|
|
291
|
+
this.usageFrom(p, this.threadId ?? "main");
|
|
255
292
|
break;
|
|
256
|
-
}
|
|
257
293
|
case "turn/completed": {
|
|
258
294
|
const status = p.turn?.status;
|
|
259
295
|
const err = p.turn?.error;
|
|
@@ -377,3 +413,8 @@ export class CodexEngine implements Engine {
|
|
|
377
413
|
function classify(msg: string) {
|
|
378
414
|
return classifyError(msg);
|
|
379
415
|
}
|
|
416
|
+
|
|
417
|
+
/** "/root/explorer" → "explorer". */
|
|
418
|
+
function agentName(path: unknown): string | undefined {
|
|
419
|
+
return typeof path === "string" && path ? path.split("/").filter(Boolean).pop() : undefined;
|
|
420
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { QuestionAnswer, QuestionField, QuestionRequest } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ACP form elicitations → questions codemax can ask in its own dialog.
|
|
5
|
+
*
|
|
6
|
+
* Agents ask through a JSON-schema form (`requestedSchema`). The Claude
|
|
7
|
+
* adapter uses it for AskUserQuestion: one `question_<n>` field per question
|
|
8
|
+
* (a `oneOf` pick, or an array of `anyOf` picks for multi-select) followed by
|
|
9
|
+
* a free-text `question_<n>_custom` "Other" field. MCP servers and other
|
|
10
|
+
* agents may send any flat schema, so every property type is handled; an
|
|
11
|
+
* unknown one is asked as text.
|
|
12
|
+
*/
|
|
13
|
+
export function parseElicitation(id: string, p: any): QuestionRequest {
|
|
14
|
+
const schema = p?.requestedSchema ?? {};
|
|
15
|
+
const props: Record<string, any> = schema.properties ?? {};
|
|
16
|
+
const required = new Set<string>(Array.isArray(schema.required) ? schema.required : []);
|
|
17
|
+
const keys = Object.keys(props);
|
|
18
|
+
const isSelect = (k: string) => !!props[k] && (opts(props[k]).length > 0 || props[k].type === "boolean");
|
|
19
|
+
// `<key>_custom` (the adapter's per-question "Other" box) folds into its question.
|
|
20
|
+
const companion = (k: string) => k.endsWith("_custom") && isSelect(k.slice(0, -"_custom".length)) && (props[k]?.type ?? "string") === "string";
|
|
21
|
+
const fields: QuestionField[] = [];
|
|
22
|
+
for (const key of keys) {
|
|
23
|
+
if (companion(key)) continue;
|
|
24
|
+
const s = props[key] ?? {};
|
|
25
|
+
const options = opts(s);
|
|
26
|
+
const kind: QuestionField["kind"] = s.type === "array" ? "multi" : s.type === "boolean" ? "boolean" : options.length ? "single" : s.type === "number" || s.type === "integer" ? "number" : "text";
|
|
27
|
+
const other = keys.find((k) => k === `${key}_custom`);
|
|
28
|
+
fields.push({
|
|
29
|
+
key,
|
|
30
|
+
title: str(s.title),
|
|
31
|
+
question: str(s.description) ?? (keys.filter((k) => !companion(k)).length === 1 ? String(p?.message ?? "") : str(s.title) ?? key),
|
|
32
|
+
kind,
|
|
33
|
+
options: kind === "boolean" ? [{ value: "true", label: "Yes" }, { value: "false", label: "No" }] : options,
|
|
34
|
+
otherKey: other && kind !== "boolean" ? other : undefined,
|
|
35
|
+
required: required.has(key),
|
|
36
|
+
integer: s.type === "integer" || undefined,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return { id, message: String(p?.message ?? ""), fields, toolCallId: p?.toolCallId };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The ACP response for an answer (`content` keys are the schema's property names). */
|
|
43
|
+
export function elicitationResponse(a: QuestionAnswer): { action: string; content?: Record<string, unknown> } {
|
|
44
|
+
return a.action === "accept" ? { action: "accept", content: a.content } : { action: a.action };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function opts(s: any): { value: string; label: string; description?: string }[] {
|
|
48
|
+
const list = s?.oneOf ?? s?.anyOf ?? s?.items?.oneOf ?? s?.items?.anyOf;
|
|
49
|
+
if (Array.isArray(list)) return list.filter((o) => o && o.const !== undefined).map((o) => ({ value: String(o.const), label: str(o.title) ?? String(o.const), description: str(o.description) }));
|
|
50
|
+
const en = s?.enum ?? s?.items?.enum;
|
|
51
|
+
if (Array.isArray(en)) {
|
|
52
|
+
const names = s?.enumNames ?? s?.items?.enumNames;
|
|
53
|
+
return en.map((v: unknown, i: number) => ({ value: String(v), label: Array.isArray(names) && names[i] ? String(names[i]) : String(v) }));
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function str(v: unknown): string | undefined {
|
|
59
|
+
return typeof v === "string" && v.trim() ? v : undefined;
|
|
60
|
+
}
|
package/src/engines/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Engine, EngineFactory } from "./types.ts";
|
|
|
6
6
|
import { AcpEngine, type UsageSample } from "./acp.ts";
|
|
7
7
|
import { CodexEngine } from "./codex.ts";
|
|
8
8
|
import { ClaudeUsageTail } from "../transcripts/claude-usage.ts";
|
|
9
|
+
import { antigravityServer } from "./antigravity.ts";
|
|
9
10
|
|
|
10
11
|
/** Grok writes ~/.grok/sessions/<encoded cwd>/<session>/usage.json with per-turn totals. */
|
|
11
12
|
function grokUsageTail(cwd: string, sessionId: string): { read(): UsageSample[] } {
|
|
@@ -115,9 +116,11 @@ const factories: EngineFactory[] = [
|
|
|
115
116
|
create: () => new AcpEngine({ agent: "cursor", name: "Cursor Agent", cmd: ["cursor-agent", "acp"] }),
|
|
116
117
|
},
|
|
117
118
|
{
|
|
118
|
-
agent: "
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
agent: "antigravity",
|
|
120
|
+
// Google's ACP server for Antigravity (installed on request, see antigravity.ts), driven over
|
|
121
|
+
// plain pipes and signed in with the user's Google account only, never an API key.
|
|
122
|
+
available: async () => !!Bun.which("agy") && !!antigravityServer(),
|
|
123
|
+
create: () => new AcpEngine({ agent: "antigravity", name: "Antigravity CLI", cmd: antigravityServer()!.cmd, plainPipes: true, signIn: ["oauth-personal", "oauth-business"] }),
|
|
121
124
|
},
|
|
122
125
|
];
|
|
123
126
|
|
package/src/engines/types.ts
CHANGED
|
@@ -12,6 +12,10 @@ export type EngineEvent =
|
|
|
12
12
|
| { type: "tool_call"; id: string; title: string; kind?: string; status: ToolStatus; detail?: string; input?: unknown; output?: string; files?: string[] }
|
|
13
13
|
| { type: "tool_update"; id: string; status?: ToolStatus; title?: string; detail?: string; input?: unknown; output?: string; files?: string[] }
|
|
14
14
|
| { type: "permission_request"; request: PermissionRequest }
|
|
15
|
+
/** A subagent the main agent spawned (Task/Agent tool…): started, doing something, or finished. */
|
|
16
|
+
| SubagentEvent
|
|
17
|
+
/** The agent asks the user something (Claude's AskUserQuestion, MCP forms). Answer with `answerQuestion`. */
|
|
18
|
+
| { type: "question_request"; request: QuestionRequest }
|
|
15
19
|
| { type: "plan"; entries: { content: string; status: string; priority?: string }[] }
|
|
16
20
|
| { type: "mode"; modeId: string }
|
|
17
21
|
| { type: "commands"; commands: { name: string; description?: string }[] }
|
|
@@ -68,6 +72,43 @@ export interface PermissionRequest {
|
|
|
68
72
|
options: PermissionOption[];
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
export interface SubagentEvent {
|
|
76
|
+
type: "subagent";
|
|
77
|
+
/** Stable id (the launching tool call's id). */
|
|
78
|
+
id: string;
|
|
79
|
+
/** Agent type or name ("Explore", "general-purpose"). */
|
|
80
|
+
name?: string;
|
|
81
|
+
/** What it was asked to do (short description). */
|
|
82
|
+
task?: string;
|
|
83
|
+
/** Its latest step ("Grep(requireAuth)"). */
|
|
84
|
+
activity?: string;
|
|
85
|
+
status?: "running" | "done" | "failed";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface QuestionField {
|
|
89
|
+
/** Property name in the agent's form; the answer is keyed by it. */
|
|
90
|
+
key: string;
|
|
91
|
+
/** Short label ("Database"), when the agent gives one. */
|
|
92
|
+
title?: string;
|
|
93
|
+
question: string;
|
|
94
|
+
kind: "single" | "multi" | "text" | "number" | "boolean";
|
|
95
|
+
options: { value: string; label: string; description?: string }[];
|
|
96
|
+
/** Companion free-text field the agent accepts as "Other". */
|
|
97
|
+
otherKey?: string;
|
|
98
|
+
required?: boolean;
|
|
99
|
+
integer?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface QuestionRequest {
|
|
103
|
+
id: string;
|
|
104
|
+
message: string;
|
|
105
|
+
fields: QuestionField[];
|
|
106
|
+
toolCallId?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** accept = answered; decline = the user skipped (the agent carries on); cancel = abort the request. */
|
|
110
|
+
export type QuestionAnswer = { action: "accept"; content: Record<string, string | number | boolean | string[]> } | { action: "decline" } | { action: "cancel" };
|
|
111
|
+
|
|
71
112
|
export interface EngineSessionRef {
|
|
72
113
|
agent: AgentId;
|
|
73
114
|
/** Vendor session / thread id, used to resume. */
|
|
@@ -84,6 +125,8 @@ export interface EngineMode {
|
|
|
84
125
|
}
|
|
85
126
|
|
|
86
127
|
export interface EngineStartOptions {
|
|
128
|
+
/** Let the agent ask the user questions (form elicitation). Off: the agent decides on its own. */
|
|
129
|
+
questions?: boolean;
|
|
87
130
|
cwd: string;
|
|
88
131
|
/** Resume a previous session instead of starting a new one. */
|
|
89
132
|
resume?: EngineSessionRef;
|
|
@@ -126,6 +169,8 @@ export interface Engine {
|
|
|
126
169
|
/** Send one user turn. Resolves when the turn ends (turn_end has been emitted). */
|
|
127
170
|
prompt(text: string): Promise<StopReason>;
|
|
128
171
|
answerPermission(requestId: string, optionId: string): void;
|
|
172
|
+
/** Engines that can ask the user questions (`question_request`) answer them here. */
|
|
173
|
+
answerQuestion?(requestId: string, answer: QuestionAnswer): void;
|
|
129
174
|
cancel(): Promise<void>;
|
|
130
175
|
setMode?(modeId: string): Promise<void>;
|
|
131
176
|
setModel?(modelId: string): Promise<void>;
|