codemax-cli 0.1.3 → 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/src/config.ts CHANGED
@@ -36,12 +36,22 @@ export interface Config {
36
36
  /**
37
37
  * Side agent: another agent that reviews the main agent's work after each turn and
38
38
  * leaves a note when something needs attention. `forward: "auto"` sends notes to the
39
- * main agent by itself; "ask" waits for /forward.
39
+ * main agent by itself; "ask" shows them above the prompt (1 expand · 2 discuss · 0 dismiss).
40
40
  */
41
- /** `every`: review every Nth working turn (1 = each turn, 0 = only on /review). Each review is a request on the side agent's plan. */
42
- sideAgent: { agent: AgentId | null; forward: "ask" | "auto"; model?: string; every?: number };
41
+ /** `every`: review every Nth working turn (1 = each turn, 0 = paused). Each review is a request on the side agent's plan. */
42
+ /** `samePlan`: the user chose to let the side agent review on the main agent's own plan (off unless picked explicitly). */
43
+ sideAgent: { agent: AgentId | null; forward: "ask" | "auto"; model?: string; every?: number; samePlan?: boolean };
43
44
  /** Check npm for a newer codemax at most every 12 hours and offer /update. */
44
45
  updateCheck: boolean;
46
+ /**
47
+ * Agent questions (Claude's AskUserQuestion, MCP forms): "ask" shows them and waits,
48
+ * "timeout" skips one left unanswered for 5 minutes (unattended runs), "off" never lets agents ask.
49
+ */
50
+ questions?: "ask" | "timeout" | "off";
51
+ /** Tell the terminal what codemax is doing: Warp's agent badge and status, OSC 7501 elsewhere. */
52
+ terminalStatus?: boolean;
53
+ /** Desktop notification when an agent waits for you, or a turn longer than bellAfterSeconds ends, while the terminal is not focused. */
54
+ notifications?: boolean;
45
55
  /** Colour theme: "auto" follows the terminal's background, or force "dark" / "light". */
46
56
  theme: "auto" | "dark" | "light";
47
57
  /**
@@ -52,7 +62,7 @@ export interface Config {
52
62
  }
53
63
 
54
64
  export const DEFAULT_CONFIG: Config = {
55
- order: ["claude", "codex", "opencode", "cursor", "grok", "gemini", "copilot"],
65
+ order: ["claude", "codex", "opencode", "cursor", "grok", "antigravity", "copilot"],
56
66
  hotkeys: ["ctrl+/", "ctrl+^"],
57
67
  autoSwitch: true,
58
68
  agentArgs: {},
@@ -65,6 +75,9 @@ export const DEFAULT_CONFIG: Config = {
65
75
  theme: "auto",
66
76
  awake: "off",
67
77
  updateCheck: true,
78
+ questions: "ask",
79
+ terminalStatus: true,
80
+ notifications: true,
68
81
  sideAgent: { agent: null, forward: "ask" },
69
82
  brief: { turns: 10, maxChars: 8000, turnChars: 1200, resumeChars: 24000 },
70
83
  };
package/src/core/brief.ts CHANGED
@@ -4,6 +4,7 @@ import { diffStat, headLine, recentCommits } from "./git.ts";
4
4
  import { checkpointDiff } from "./checkpoint.ts";
5
5
  import { BRIEF_MARKER, type Ledger } from "./ledger.ts";
6
6
  import type { Step, StepOf } from "./journal.ts";
7
+ import { fmtTime12 } from "./time.ts";
7
8
 
8
9
  export interface BriefInput {
9
10
  ledger: Ledger;
@@ -161,7 +162,7 @@ export async function buildCatchup(input: BriefInput): Promise<string | null> {
161
162
  function whereItStopped(steps: Step[], end: StepOf<"end"> | undefined): string[] {
162
163
  const out: string[] = [];
163
164
  if (!end) return ["The last agent had not finished its first turn."];
164
- const when = new Date(end.ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
165
+ const when = fmtTime12(end.ts);
165
166
  const why =
166
167
  end.outcome === "limit" ? `ran out of its usage limit${end.detail ? ` ("${oneLine(end.detail, 140)}")` : ""}`
167
168
  : end.outcome === "cancelled" ? "was interrupted by the user"
package/src/core/quota.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AgentId, QuotaStatus } from "../adapters/types.ts";
2
2
  import { stripAnsi } from "./terminal.ts";
3
+ import { fmtTime12 } from "./time.ts";
3
4
 
4
5
  /**
5
6
  * Vendor-neutral phrases. Deliberately narrow: "rate limit" or "429" alone
@@ -47,7 +48,7 @@ export class OutputScanner {
47
48
  export function quotaLabel(q?: QuotaStatus): string {
48
49
  if (!q) return "";
49
50
  if (isExhausted(q)) {
50
- const reset = q.resetsAt ? ` until ${new Date(q.resetsAt).toLocaleTimeString()}` : "";
51
+ const reset = q.resetsAt ? ` until ${fmtTime12(q.resetsAt)}` : "";
51
52
  return q.source === "output" ? `exhausted? (text match)${reset}` : `EXHAUSTED${reset}`;
52
53
  }
53
54
  if (typeof q.usedPercent === "number") return `${Math.round(q.usedPercent)}% used`;
@@ -0,0 +1,124 @@
1
+ import { basename } from "node:path";
2
+ import type { AgentId } from "../adapters/types.ts";
3
+
4
+ /**
5
+ * What the terminal around codemax shows about it: the tab's agent badge and
6
+ * running / waiting / done status, and a notification when you are needed.
7
+ *
8
+ * - Warp: its "cli-agent" channel (OSC 777 with the title `warp://cli-agent`
9
+ * and a JSON body). Warp only has built-in logos, so the tab shows the agent
10
+ * codemax is driving (Claude Code, Codex, OpenCode, Cursor, Copilot, and
11
+ * Antigravity under Warp's Gemini badge, Google's closest logo there);
12
+ * other agents get status without a badge. Warp raises its own notifications
13
+ * from these events, so codemax sends none of its own there.
14
+ * - Other terminals: OSC 7501 program status (working / blocked / done), which
15
+ * terminals that lack it ignore, and a desktop notification when an agent
16
+ * waits for you or a long turn ends while the terminal is not focused.
17
+ *
18
+ * Every write goes through the renderer (`TerminalSink`), never straight to
19
+ * stdout, so it can never land inside a frame.
20
+ */
21
+ export interface TerminalSink {
22
+ status(s: { state: "idle" | "working" | "blocked" | "done" | "error" | "clear" }): void;
23
+ notify(message: string, title?: string): void;
24
+ }
25
+
26
+ /** Agent names Warp has a logo for (checked against Warp 2026.09). */
27
+ export const WARP_AGENTS: Partial<Record<AgentId, string>> = { claude: "claude", codex: "codex", antigravity: "gemini", opencode: "opencode", cursor: "cursor", copilot: "copilot" };
28
+
29
+ // Warp builds that advertise the protocol but drop it (from Warp's own adapters).
30
+ const WARP_BROKEN: Record<string, string> = { stable: "v0.2026.03.25.08.24.stable_05", preview: "v0.2026.03.25.08.24.preview_05" };
31
+
32
+ export function warpSupported(env: Record<string, string | undefined> = process.env): boolean {
33
+ if (!env.WARP_CLI_AGENT_PROTOCOL_VERSION || !env.WARP_CLIENT_VERSION) return false;
34
+ const channel = Object.keys(WARP_BROKEN).find((c) => env.WARP_CLIENT_VERSION!.includes(c));
35
+ return !channel || env.WARP_CLIENT_VERSION > WARP_BROKEN[channel]!;
36
+ }
37
+
38
+ const cut = (s: string, n: number) => (s.length > n ? s.slice(0, n - 3) + "..." : s);
39
+
40
+ export class TerminalStatus {
41
+ private agent: { id: AgentId; name: string; session: string } | null = null;
42
+ private lastQuery = "";
43
+ private readonly warp: boolean;
44
+ /** True/false once the terminal reports focus; null when it never has. */
45
+ focused: boolean | null = null;
46
+
47
+ constructor(
48
+ private sink: TerminalSink,
49
+ private o: { cwd: string; env?: Record<string, string | undefined>; enabled: () => boolean; notify: () => boolean },
50
+ ) {
51
+ this.warp = warpSupported(o.env ?? process.env);
52
+ }
53
+
54
+ /** An agent's session is ready (start, switch, handoff). */
55
+ started(id: AgentId, name: string, session = "") {
56
+ this.agent = { id, name, session };
57
+ this.warpEvent("session_start");
58
+ this.status("idle");
59
+ }
60
+
61
+ prompt(text: string) {
62
+ this.lastQuery = text;
63
+ this.warpEvent("prompt_submit", { query: cut(text, 200) });
64
+ this.status("working");
65
+ }
66
+
67
+ toolDone(tool: string) {
68
+ this.warpEvent("tool_complete", { tool_name: tool });
69
+ }
70
+
71
+ /** The agent waits for you: a permission prompt or a question. */
72
+ waiting(kind: "permission" | "question", summary: string, tool?: string, input?: unknown) {
73
+ if (kind === "question") this.warpEvent("question_asked", { tool_name: tool ?? "question", summary: cut(summary, 200) });
74
+ else this.warpEvent("permission_request", { summary: cut(summary, 200), tool_name: tool ?? "tool", tool_input: input ?? {} });
75
+ this.status("blocked");
76
+ this.desktop(`${this.agent?.name ?? "The agent"} ${kind === "question" ? "has a question" : "needs your approval"}: ${cut(summary, 120)}`);
77
+ }
78
+
79
+ answered(allowed: boolean) {
80
+ if (allowed) this.warpEvent("permission_replied");
81
+ this.status("working");
82
+ }
83
+
84
+ turnEnded(o: { reply: string; ms: number; error?: boolean; longMs: number }) {
85
+ this.warpEvent("stop", { query: cut(this.lastQuery, 200), response: cut(o.reply, 200), transcript_path: "" });
86
+ this.status(o.error ? "error" : "done");
87
+ if (o.ms >= o.longMs) this.desktop(`${this.agent?.name ?? "The agent"} finished: ${cut(o.reply.replace(/\s+/g, " ").trim() || "done", 120)}`);
88
+ }
89
+
90
+ close() {
91
+ this.status("clear");
92
+ }
93
+
94
+ private status(state: Parameters<TerminalSink["status"]>[0]["state"]) {
95
+ if (!this.o.enabled()) return;
96
+ try {
97
+ this.sink.status({ state });
98
+ } catch {
99
+ /* a terminal that refuses it must never break codemax */
100
+ }
101
+ }
102
+
103
+ private warpEvent(event: string, extra: Record<string, unknown> = {}) {
104
+ const slug = this.agent && WARP_AGENTS[this.agent.id];
105
+ if (!this.warp || !slug || !this.o.enabled()) return;
106
+ const body = { v: 1, agent: slug, event, session_id: this.agent!.session, cwd: this.o.cwd, project: basename(this.o.cwd), ...extra };
107
+ try {
108
+ this.sink.notify(JSON.stringify(body), "warp://cli-agent");
109
+ } catch {
110
+ /* ignore */
111
+ }
112
+ }
113
+
114
+ /** A plain desktop notification, only when you are not looking (Warp sends its own). */
115
+ private desktop(message: string) {
116
+ if (this.focused === true || !this.o.notify()) return;
117
+ if (this.warp && this.agent && WARP_AGENTS[this.agent.id] && this.o.enabled()) return;
118
+ try {
119
+ this.sink.notify(message, "codemax");
120
+ } catch {
121
+ /* ignore */
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,9 @@
1
+ /** Every time codemax shows is 12-hour, whatever the system locale: "7:25 PM", "Oct 18, 7:25 PM". */
2
+ export function fmtTime12(ts: number): string {
3
+ return new Date(ts).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
4
+ }
5
+
6
+ export function fmtDateTime12(ts: number): string {
7
+ const d = new Date(ts);
8
+ return `${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${fmtTime12(ts)}`;
9
+ }
package/src/core/usage.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AgentId } from "../adapters/types.ts";
2
2
  import { estimateCost, fmtTokens, fmtUsd, priceFor, priceProvenance } from "./pricing.ts";
3
+ import { displayModelName } from "../chat/models.ts";
3
4
 
4
5
  /** One usage sample: a model call, a turn, or a delta reported by the agent. */
5
6
  export interface UsageRecord {
@@ -89,7 +90,7 @@ export function renderUsageTable(records: UsageRecord[], opts: { title: string;
89
90
  }
90
91
  const head = ["agent", "calls", "input", "output", "cache read", "cache write", "est. cost"];
91
92
  const data = rows.map((t) => [
92
- t.agent + (t.models.size ? ` (${[...t.models].map(shortModel).join(", ")})` : ""),
93
+ t.agent + (t.models.size ? ` (${[...new Set([...t.models].map(shortModel))].join(", ")})` : ""),
93
94
  String(t.calls),
94
95
  fmtTokens(t.input),
95
96
  fmtTokens(t.output),
@@ -111,6 +112,7 @@ export function renderUsageTable(records: UsageRecord[], opts: { title: string;
111
112
  return lines;
112
113
  }
113
114
 
115
+ /** Display name for a recorded model id ("claude-opus-5-5" → "Opus 5.5"), as everywhere else. */
114
116
  export function shortModel(m: string): string {
115
- return m.replace(/^claude-/, "").replace(/^(anthropic|openai|xai|google|opencode)\//, "");
117
+ return displayModelName(m);
116
118
  }
@@ -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, 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, Gemini CLI.
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
- sessionUpdate: ({ update }) => this.onUpdate(update as any),
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.loadSession({ sessionId: o.resume.id, cwd: o.cwd, mcpServers: [] });
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.newSession({ cwd: o.cwd, mcpServers: [] });
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.newSession({ cwd: o.cwd, mcpServers: [] });
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 proc = Bun.spawn(platformArgv(this.opts.cmd), { cwd, env: agentEnv(this.opts.env), stdin: "pipe", stdout: "pipe", stderr: "pipe" });
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":
@@ -204,7 +251,7 @@ export class AcpEngine implements Engine {
204
251
  // `used` is how full the context window is, not tokens spent: it feeds the context meter.
205
252
  // Token counts come from the prompt response (or session files) at turn end.
206
253
  if (typeof u.used === "number") this.turnContext = u.used;
207
- this.emit({ type: "usage", contextUsed: u.used, contextSize: u.size, costUsd: delta, model: this.currentModelName() });
254
+ this.emit({ type: "usage", contextUsed: u.used, contextSize: u.size, costUsd: delta, model: this.currentModelName(), planLimits: planLimitsOf(u) });
208
255
  break;
209
256
  }
210
257
  default:
@@ -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
- const reason = mapStop(res.stopReason);
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 {
@@ -339,6 +452,15 @@ function extraOptions(config: unknown): EngineOption[] {
339
452
  }));
340
453
  }
341
454
 
455
+ /** Claude's plan limits ride on usage updates as `_meta["_claude/rateLimit"]` (SDKRateLimitInfo). */
456
+ function planLimitsOf(u: any): PlanLimit[] | undefined {
457
+ const rl = u?._meta?.["_claude/rateLimit"];
458
+ if (!rl || typeof rl !== "object") return undefined;
459
+ const util = typeof rl.utilization === "number" ? (rl.utilization <= 1 ? rl.utilization * 100 : rl.utilization) : undefined;
460
+ const reset = typeof rl.resetsAt === "number" ? (rl.resetsAt < 1e12 ? rl.resetsAt * 1000 : rl.resetsAt) : undefined;
461
+ return [{ window: String(rl.rateLimitType ?? "plan"), usedPercent: util, resetsAt: reset, status: rl.status }];
462
+ }
463
+
342
464
  function mapStatus(s: string | undefined): "pending" | "in_progress" | "completed" | "failed" {
343
465
  if (s === "in_progress" || s === "completed" || s === "failed") return s;
344
466
  return "pending";
@@ -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
+ }