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/README.md +5 -5
- package/package.json +1 -1
- package/src/adapters/detect.ts +38 -9
- package/src/adapters/index.ts +15 -9
- package/src/adapters/simple.ts +7 -6
- package/src/adapters/types.ts +3 -1
- package/src/chat/app.ts +459 -138
- package/src/chat/models.ts +58 -0
- package/src/chat/notes.ts +101 -0
- package/src/chat/palette.ts +28 -7
- package/src/chat/question.ts +192 -0
- package/src/chat/subagents.ts +107 -0
- package/src/chat/theme.ts +3 -1
- package/src/chat/transcript.ts +5 -2
- package/src/cli.ts +8 -6
- package/src/config.ts +17 -4
- package/src/core/brief.ts +2 -1
- package/src/core/quota.ts +2 -1
- package/src/core/termstatus.ts +124 -0
- package/src/core/time.ts +9 -0
- package/src/core/usage.ts +4 -2
- package/src/engines/acp.ts +133 -11
- package/src/engines/antigravity.ts +95 -0
- package/src/engines/codex.ts +122 -32
- package/src/engines/elicitation.ts +60 -0
- package/src/engines/index.ts +6 -3
- package/src/engines/types.ts +56 -0
- package/src/tui/app.ts +13 -4
package/src/engines/codex.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { AgentId } from "../adapters/types.ts";
|
|
2
|
-
import type { Engine, EngineEvent, EngineInfo, EngineSessionRef, EngineStartOptions, PermissionRequest, StopReason, ToolStatus } from "./types.ts";
|
|
2
|
+
import type { Engine, EngineEvent, EngineInfo, EngineOption, EngineSessionRef, EngineStartOptions, PermissionRequest, StopReason, ToolStatus } from "./types.ts";
|
|
3
3
|
import { agentEnv } from "./env.ts";
|
|
4
4
|
import { platformArgv } from "../core/spawn.ts";
|
|
5
5
|
import { classifyError } from "./errors.ts";
|
|
6
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
6
7
|
|
|
7
8
|
export interface CodexEngineOptions {
|
|
8
9
|
/** Command for the app server. Default: codex app-server. */
|
|
@@ -18,6 +19,10 @@ export interface CodexEngineOptions {
|
|
|
18
19
|
* `answerPermission`.
|
|
19
20
|
*/
|
|
20
21
|
export class CodexEngine implements Engine {
|
|
22
|
+
/** Models this account can use, from `model/list`. */
|
|
23
|
+
private catalog: { id: string; name: string; efforts: string[]; defaultEffort?: string }[] = [];
|
|
24
|
+
/** Reasoning effort chosen with /effort (sent with each turn); undefined = the model's default. */
|
|
25
|
+
private effort: string | undefined;
|
|
21
26
|
readonly agent: AgentId = "codex";
|
|
22
27
|
info: EngineInfo | null = null;
|
|
23
28
|
session: EngineSessionRef | null = null;
|
|
@@ -32,7 +37,8 @@ export class CodexEngine implements Engine {
|
|
|
32
37
|
private threadId: string | null = null;
|
|
33
38
|
private cwd = "";
|
|
34
39
|
private model: string | undefined;
|
|
35
|
-
|
|
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 }>();
|
|
36
42
|
|
|
37
43
|
constructor(private opts: CodexEngineOptions = {}) {}
|
|
38
44
|
|
|
@@ -49,7 +55,7 @@ export class CodexEngine implements Engine {
|
|
|
49
55
|
this.cwd = o.cwd;
|
|
50
56
|
this.transport = this.opts.transport ?? this.spawn(o.cwd);
|
|
51
57
|
this.transport.onLine((line) => this.onLine(line));
|
|
52
|
-
const init = await this.request("initialize", { clientInfo: { name: "codemax", version:
|
|
58
|
+
const init = await this.request("initialize", { clientInfo: { name: "codemax", version: pkg.version }, capabilities: {} });
|
|
53
59
|
this.notify("initialized", {});
|
|
54
60
|
|
|
55
61
|
let thread: any;
|
|
@@ -65,6 +71,9 @@ export class CodexEngine implements Engine {
|
|
|
65
71
|
this.model = thread.model ?? undefined;
|
|
66
72
|
this.session = { agent: "codex", id: thread.id, cwd: o.cwd, startedAt: Date.now(), lastUsedAt: Date.now() };
|
|
67
73
|
const account: any = await this.request("account/read", {}).catch(() => null);
|
|
74
|
+
// The models this account can use, straight from Codex: never a list of our own.
|
|
75
|
+
// An older Codex without model/list must not hold up startup: give it a few seconds.
|
|
76
|
+
this.catalog = await Promise.race([this.listModels(), Bun.sleep(4000).then(() => [])]).catch(() => []);
|
|
68
77
|
this.info = {
|
|
69
78
|
agent: "codex",
|
|
70
79
|
name: "Codex",
|
|
@@ -74,8 +83,9 @@ export class CodexEngine implements Engine {
|
|
|
74
83
|
{ id: "never", name: "full auto (no approvals)" },
|
|
75
84
|
],
|
|
76
85
|
currentMode: "on-request",
|
|
77
|
-
models:
|
|
86
|
+
models: this.catalog.length ? this.catalog.map((m) => ({ id: m.id, name: m.name })) : this.model ? [{ id: this.model, name: this.model }] : [],
|
|
78
87
|
currentModel: this.model,
|
|
88
|
+
options: this.effortOptions(),
|
|
79
89
|
plan: account?.account?.planType ? `chatgpt ${account.account.planType}` : undefined,
|
|
80
90
|
};
|
|
81
91
|
this.emit({ type: "session", ref: this.session });
|
|
@@ -183,7 +193,63 @@ export class CodexEngine implements Engine {
|
|
|
183
193
|
this.transport!.send(JSON.stringify({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: `codemax does not handle ${method}` } }));
|
|
184
194
|
}
|
|
185
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
|
+
|
|
186
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);
|
|
187
253
|
switch (method) {
|
|
188
254
|
case "item/agentMessage/delta":
|
|
189
255
|
this.emit({ type: "text_delta", text: p.delta ?? "" });
|
|
@@ -198,6 +264,10 @@ export class CodexEngine implements Engine {
|
|
|
198
264
|
case "item/updated": {
|
|
199
265
|
const it = p.item ?? {};
|
|
200
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
|
+
}
|
|
201
271
|
const status: ToolStatus = method === "item/started" ? "in_progress" : it.status === "failed" || it.status === "declined" ? "failed" : method === "item/completed" ? "completed" : "in_progress";
|
|
202
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";
|
|
203
273
|
const detail = it.aggregatedOutput ? String(it.aggregatedOutput).slice(-800) : undefined;
|
|
@@ -217,34 +287,9 @@ export class CodexEngine implements Engine {
|
|
|
217
287
|
this.emit({ type: "plan", entries: (p.plan ?? p.entries ?? []).map((e: any) => ({ content: e.step ?? e.content, status: e.status })) });
|
|
218
288
|
break;
|
|
219
289
|
case "thread/tokenUsage/updated":
|
|
220
|
-
case "turn/tokenUsage/updated":
|
|
221
|
-
|
|
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 });
|
|
290
|
+
case "turn/tokenUsage/updated":
|
|
291
|
+
this.usageFrom(p, this.threadId ?? "main");
|
|
246
292
|
break;
|
|
247
|
-
}
|
|
248
293
|
case "turn/completed": {
|
|
249
294
|
const status = p.turn?.status;
|
|
250
295
|
const err = p.turn?.error;
|
|
@@ -281,7 +326,7 @@ export class CodexEngine implements Engine {
|
|
|
281
326
|
const done = new Promise<StopReason>((resolve) => (this.turn = { resolve }));
|
|
282
327
|
try {
|
|
283
328
|
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 });
|
|
329
|
+
const res = await this.request("turn/start", { threadId: this.threadId, cwd: this.cwd, input: [{ type: "text", text }], model: this.model, approvalPolicy, ...(this.effort ? { effort: this.effort } : {}) });
|
|
285
330
|
if (this.turn) this.turn.id = res?.turn?.id;
|
|
286
331
|
} catch (e: any) {
|
|
287
332
|
const msg = `${e?.message ?? e} ${JSON.stringify(e?.data ?? "")}`.trim();
|
|
@@ -314,6 +359,46 @@ export class CodexEngine implements Engine {
|
|
|
314
359
|
async setModel(modelId: string) {
|
|
315
360
|
this.model = modelId;
|
|
316
361
|
if (this.info) this.info.currentModel = modelId;
|
|
362
|
+
// Reasoning efforts differ per model: keep the chosen one only if the new model has it.
|
|
363
|
+
const efforts = this.catalog.find((m) => m.id === modelId)?.efforts ?? [];
|
|
364
|
+
if (this.effort && !efforts.includes(this.effort)) this.effort = undefined;
|
|
365
|
+
if (this.info) this.info.options = this.effortOptions();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async setOption(optionId: string, value: string) {
|
|
369
|
+
if (optionId !== "effort") return;
|
|
370
|
+
this.effort = value;
|
|
371
|
+
if (this.info) this.info.options = this.effortOptions();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** `model/list`, all pages, hidden models left out. */
|
|
375
|
+
private async listModels(): Promise<{ id: string; name: string; efforts: string[]; defaultEffort?: string }[]> {
|
|
376
|
+
const out: { id: string; name: string; efforts: string[]; defaultEffort?: string }[] = [];
|
|
377
|
+
let cursor: string | undefined;
|
|
378
|
+
for (let page = 0; page < 10; page++) {
|
|
379
|
+
const r: any = await this.request("model/list", cursor ? { cursor } : {});
|
|
380
|
+
for (const m of r?.data ?? []) {
|
|
381
|
+
if (m?.hidden) continue;
|
|
382
|
+
const id = String(m.model ?? m.id ?? "");
|
|
383
|
+
if (!id) continue;
|
|
384
|
+
out.push({
|
|
385
|
+
id,
|
|
386
|
+
name: String(m.displayName ?? id),
|
|
387
|
+
efforts: (m.supportedReasoningEfforts ?? []).map((e: any) => String(e?.reasoningEffort ?? e)).filter(Boolean),
|
|
388
|
+
defaultEffort: m.defaultReasoningEffort ? String(m.defaultReasoningEffort) : undefined,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
cursor = r?.nextCursor ?? undefined;
|
|
392
|
+
if (!cursor) break;
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The current model's reasoning efforts, as Codex lists them. */
|
|
398
|
+
private effortOptions(): EngineOption[] {
|
|
399
|
+
const m = this.catalog.find((x) => x.id === this.model);
|
|
400
|
+
if (!m?.efforts.length) return [];
|
|
401
|
+
return [{ id: "effort", name: "Reasoning effort", category: "thought_level", current: this.effort ?? m.defaultEffort ?? "default", values: m.efforts.map((e) => ({ value: e, name: e })) }];
|
|
317
402
|
}
|
|
318
403
|
|
|
319
404
|
async close() {
|
|
@@ -328,3 +413,8 @@ export class CodexEngine implements Engine {
|
|
|
328
413
|
function classify(msg: string) {
|
|
329
414
|
return classifyError(msg);
|
|
330
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 }[] }
|
|
@@ -27,6 +31,8 @@ export type EngineEvent =
|
|
|
27
31
|
costUsd?: number;
|
|
28
32
|
/** Rate-limit window consumption, when the agent exposes it. */
|
|
29
33
|
usedPercent?: number;
|
|
34
|
+
/** Subscription plan limits the agent reported (Claude: 5-hour, weekly…), one entry per window. */
|
|
35
|
+
planLimits?: PlanLimit[];
|
|
30
36
|
/** Context window fill, when reported (tokens used / size). */
|
|
31
37
|
contextUsed?: number;
|
|
32
38
|
contextSize?: number;
|
|
@@ -36,6 +42,15 @@ export type EngineEvent =
|
|
|
36
42
|
| { type: "error"; kind: ErrorKind; message: string }
|
|
37
43
|
| { type: "log"; level: "info" | "warn" | "error"; message: string };
|
|
38
44
|
|
|
45
|
+
export interface PlanLimit {
|
|
46
|
+
/** e.g. "five_hour", "seven_day", "seven_day_opus". */
|
|
47
|
+
window: string;
|
|
48
|
+
usedPercent?: number;
|
|
49
|
+
resetsAt?: number;
|
|
50
|
+
/** "allowed" | "allowed_warning" | "rejected" (out of this window's quota). */
|
|
51
|
+
status?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
export type ToolStatus = "pending" | "in_progress" | "completed" | "failed";
|
|
40
55
|
export type StopReason = "end_turn" | "max_tokens" | "cancelled" | "refusal" | "error" | "other";
|
|
41
56
|
export type ErrorKind = "quota" | "auth" | "protocol" | "crashed" | "other";
|
|
@@ -57,6 +72,43 @@ export interface PermissionRequest {
|
|
|
57
72
|
options: PermissionOption[];
|
|
58
73
|
}
|
|
59
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
|
+
|
|
60
112
|
export interface EngineSessionRef {
|
|
61
113
|
agent: AgentId;
|
|
62
114
|
/** Vendor session / thread id, used to resume. */
|
|
@@ -73,6 +125,8 @@ export interface EngineMode {
|
|
|
73
125
|
}
|
|
74
126
|
|
|
75
127
|
export interface EngineStartOptions {
|
|
128
|
+
/** Let the agent ask the user questions (form elicitation). Off: the agent decides on its own. */
|
|
129
|
+
questions?: boolean;
|
|
76
130
|
cwd: string;
|
|
77
131
|
/** Resume a previous session instead of starting a new one. */
|
|
78
132
|
resume?: EngineSessionRef;
|
|
@@ -115,6 +169,8 @@ export interface Engine {
|
|
|
115
169
|
/** Send one user turn. Resolves when the turn ends (turn_end has been emitted). */
|
|
116
170
|
prompt(text: string): Promise<StopReason>;
|
|
117
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;
|
|
118
174
|
cancel(): Promise<void>;
|
|
119
175
|
setMode?(modeId: string): Promise<void>;
|
|
120
176
|
setModel?(modelId: string): Promise<void>;
|
package/src/tui/app.ts
CHANGED
|
@@ -68,10 +68,15 @@ export class App {
|
|
|
68
68
|
this.setStatus("");
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/** `explicit`: the user named this agent (--agent), so never start a different one. */
|
|
72
|
+
async start(agent: AgentId, fresh: boolean, o: { explicit?: boolean } = {}) {
|
|
72
73
|
await this.refreshDetect();
|
|
73
|
-
const
|
|
74
|
+
const installed = this.detect.get(agent)?.installed;
|
|
75
|
+
if (!installed && o.explicit) throw new Error(`${agent} is not installed. Installed: ${this.installedList() || "none"}`);
|
|
76
|
+
const first = installed ? agent : this.firstInstalled();
|
|
74
77
|
if (!first) throw new Error("no supported agent CLI found on PATH");
|
|
78
|
+
// Under tests a fallback must never reach a real agent (no logins, no quota).
|
|
79
|
+
if (first !== agent && process.env.NODE_ENV === "test") throw new Error(`test asked for ${agent}, which is not available; refusing to fall back to ${first}`);
|
|
75
80
|
if (first !== agent) this.setStatus(`${agent} not found, starting ${first}`);
|
|
76
81
|
await this.launch(getAdapter(first), { fresh });
|
|
77
82
|
return this.done;
|
|
@@ -282,6 +287,10 @@ export class App {
|
|
|
282
287
|
);
|
|
283
288
|
}
|
|
284
289
|
|
|
290
|
+
private installedList(): string {
|
|
291
|
+
return [...this.detect.entries()].filter(([, d]) => d.installed).map(([id]) => id).join(", ");
|
|
292
|
+
}
|
|
293
|
+
|
|
285
294
|
private firstInstalled(): AgentId | undefined {
|
|
286
295
|
return listAdapters(this.config.order).find((a) => this.detect.get(a.id)?.installed)?.id;
|
|
287
296
|
}
|
|
@@ -360,11 +369,11 @@ export class App {
|
|
|
360
369
|
}
|
|
361
370
|
|
|
362
371
|
/** Run codemax on the real terminal until the user quits. */
|
|
363
|
-
export async function runApp(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean) {
|
|
372
|
+
export async function runApp(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean, explicit = false) {
|
|
364
373
|
const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGHUP"], useMouse: true, targetFps: 60 });
|
|
365
374
|
const app = new App(renderer, config, ledger, cwd);
|
|
366
375
|
try {
|
|
367
|
-
await app.start(agent, fresh);
|
|
376
|
+
await app.start(agent, fresh, { explicit });
|
|
368
377
|
} finally {
|
|
369
378
|
renderer.destroy();
|
|
370
379
|
try {
|