codemax-cli 0.1.3 → 0.1.4
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/package.json +1 -1
- package/src/adapters/index.ts +14 -8
- package/src/chat/app.ts +93 -23
- package/src/chat/models.ts +33 -0
- package/src/chat/palette.ts +14 -6
- package/src/chat/theme.ts +3 -1
- package/src/chat/transcript.ts +5 -2
- package/src/cli.ts +6 -5
- package/src/core/brief.ts +2 -1
- package/src/core/quota.ts +2 -1
- package/src/core/time.ts +9 -0
- package/src/engines/acp.ts +11 -2
- package/src/engines/codex.ts +53 -4
- package/src/engines/types.ts +11 -0
- package/src/tui/app.ts +13 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codemax-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "One terminal for every coding agent you pay for: drive Claude Code, Codex, OpenCode, Grok and Gemini CLI from one chat, on your subscriptions, with context that survives switching agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"acp",
|
package/src/adapters/index.ts
CHANGED
|
@@ -3,19 +3,25 @@ import { claude } from "./claude.ts";
|
|
|
3
3
|
import { codex } from "./codex.ts";
|
|
4
4
|
import { opencode, cursor, grok, gemini, copilot, fake } from "./simple.ts";
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
if (process.env.CODEMAX_FAKE_AGENT) all.push(fake);
|
|
6
|
+
const real: Adapter[] = [claude, codex, opencode, cursor, grok, gemini, copilot];
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The adapters, read at call time: tests set CODEMAX_FAKE_AGENT after another
|
|
10
|
+
* test file may already have imported this module (bun runs all files in one
|
|
11
|
+
* process), so the fake agent must not be decided once at import.
|
|
12
|
+
*/
|
|
13
|
+
function all(): Adapter[] {
|
|
14
|
+
return process.env.CODEMAX_FAKE_AGENT ? [...real, fake] : real;
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
export function getAdapter(id: string): Adapter {
|
|
12
|
-
const a =
|
|
13
|
-
if (!a) throw new Error(`unknown agent "${id}". Known: ${
|
|
18
|
+
const a = all().find((x) => x.id === id);
|
|
19
|
+
if (!a) throw new Error(`unknown agent "${id}". Known: ${all().map((x) => x.id).join(", ")}`);
|
|
14
20
|
return a;
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
export function listAdapters(order: AgentId[]): Adapter[] {
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
return [...ranked, ...
|
|
24
|
+
const list = all();
|
|
25
|
+
const ranked = order.map((id) => list.find((a) => a.id === id)).filter((a): a is Adapter => !!a);
|
|
26
|
+
return [...ranked, ...list.filter((a) => !ranked.includes(a))];
|
|
21
27
|
}
|
package/src/chat/app.ts
CHANGED
|
@@ -31,6 +31,8 @@ import { Awake, defaultAwakeDeps, recoverAwake } from "../core/awake.ts";
|
|
|
31
31
|
import { buildReviewPrompt, forwardMessage, isForwardedNote, parseNote } from "../core/review.ts";
|
|
32
32
|
import { logoLines } from "./logo.ts";
|
|
33
33
|
import { checkForUpdate, updatePlan } from "../core/update.ts";
|
|
34
|
+
import { sortModels } from "./models.ts";
|
|
35
|
+
import { fmtDateTime12 } from "../core/time.ts";
|
|
34
36
|
|
|
35
37
|
const HOTKEY_NAMES: Record<string, string[]> = { "/": ["/", "_", "?"], "^": ["^", "6"] };
|
|
36
38
|
const VERSION = pkg.version;
|
|
@@ -42,7 +44,8 @@ interface Active {
|
|
|
42
44
|
agent: AgentId;
|
|
43
45
|
name: string;
|
|
44
46
|
engine: Engine;
|
|
45
|
-
|
|
47
|
+
/** `model`: the model the agent reported for this turn (usage), when it does. */
|
|
48
|
+
turn: { block: AgentBlock; tools: Set<string>; startedAt: number; tokens: number; chars: number; verb: string; rec?: TurnRecorder; model?: string } | null;
|
|
46
49
|
replay: AgentBlock | null;
|
|
47
50
|
unsubscribe: () => void;
|
|
48
51
|
mode?: string;
|
|
@@ -74,6 +77,7 @@ export class ChatApp {
|
|
|
74
77
|
private input: TextareaRenderable;
|
|
75
78
|
private inputWrap: BoxRenderable;
|
|
76
79
|
private dialogHost: BoxRenderable;
|
|
80
|
+
private queueBox!: BoxRenderable;
|
|
77
81
|
private footerText: TextRenderable;
|
|
78
82
|
private shortcuts: BoxRenderable;
|
|
79
83
|
private flash = "";
|
|
@@ -99,6 +103,8 @@ export class ChatApp {
|
|
|
99
103
|
/** Side agent (/side): reviews the main agent's work after each turn. */
|
|
100
104
|
private reviewer: { agent: AgentId; name: string; engine: Engine; busy: boolean; pending: boolean; since: number; primed: boolean } | null = null;
|
|
101
105
|
private notes: { ts: number; agent: AgentId; name: string; text: string }[] = [];
|
|
106
|
+
/** Plan limits each agent reported, by window ("five_hour", "seven_day"…). */
|
|
107
|
+
private planLimits = new Map<AgentId, Map<string, { usedPercent?: number; resetsAt?: number; status?: string; at: number }>>();
|
|
102
108
|
/** Working turns since the side agent last reviewed (for `sideAgent.every`). */
|
|
103
109
|
private turnsSinceReview = 0;
|
|
104
110
|
/** Notes sent automatically per task (auto mode is capped so the two agents cannot loop). */
|
|
@@ -135,6 +141,10 @@ export class ChatApp {
|
|
|
135
141
|
root.add(this.working);
|
|
136
142
|
this.dialogHost = new BoxRenderable(renderer, { id: "dialog-host", width: "100%", flexShrink: 0, flexDirection: "column" });
|
|
137
143
|
root.add(this.dialogHost);
|
|
144
|
+
// Queued messages wait here, pinned just above the prompt, until they are sent.
|
|
145
|
+
this.queueBox = new BoxRenderable(renderer, { id: "queue", width: "100%", flexShrink: 0, flexDirection: "column", paddingX: 1 });
|
|
146
|
+
this.queueBox.visible = false;
|
|
147
|
+
root.add(this.queueBox);
|
|
138
148
|
this.inputWrap = new BoxRenderable(renderer, { id: "input-wrap", width: "100%", flexShrink: 0, flexDirection: "column", border: ["top", "bottom"], borderStyle: "single", borderColor: C.accent });
|
|
139
149
|
const inputRow = new BoxRenderable(renderer, { id: "input-row", width: "100%", flexDirection: "row" });
|
|
140
150
|
inputRow.add(new TextRenderable(renderer, { id: "input-prompt", content: `${G.prompt} `, width: 2, fg: C.accent }));
|
|
@@ -188,7 +198,8 @@ export class ChatApp {
|
|
|
188
198
|
this.renderFooter();
|
|
189
199
|
}
|
|
190
200
|
|
|
191
|
-
|
|
201
|
+
/** `explicit`: the user named this agent (--agent), so never start a different one. */
|
|
202
|
+
async start(agent: AgentId, fresh: boolean, o: { pick?: boolean; explicit?: boolean } = {}) {
|
|
192
203
|
try {
|
|
193
204
|
this.today = spendToday();
|
|
194
205
|
} catch {
|
|
@@ -196,6 +207,7 @@ export class ChatApp {
|
|
|
196
207
|
}
|
|
197
208
|
await this.refreshDetect();
|
|
198
209
|
const ok = (a: AgentId) => this.detect.get(a)?.installed && this.detect.get(a)?.engine;
|
|
210
|
+
if (!ok(agent) && o.explicit) throw new Error(`${agent} is ${this.detect.get(agent)?.installed ? "installed but has no protocol engine (try codemax --tui --agent " + agent + ")" : "not installed"}. Available: ${listAdapters(this.config.order).filter((a) => ok(a.id)).map((a) => a.id).join(", ") || "none"}`);
|
|
199
211
|
const first = ok(agent) ? agent : listAdapters(this.config.order).find((a) => ok(a.id))?.id;
|
|
200
212
|
if (!first) throw new Error("no agent with a protocol engine found (need claude, codex, opencode, grok or gemini on PATH)");
|
|
201
213
|
const notes: string[] = [];
|
|
@@ -393,9 +405,7 @@ export class ChatApp {
|
|
|
393
405
|
const busy = !!this.active?.turn;
|
|
394
406
|
const immediate = /^\/(quit|exit|q|help|status|usage|cost|diff|clear|switch|agents)\b/.test(text);
|
|
395
407
|
if (busy && !immediate && (text.startsWith("/") || text.startsWith("!"))) {
|
|
396
|
-
this.
|
|
397
|
-
this.transcript.note(`queued until the turn ends: ${text.slice(0, 60)}`, C.dim);
|
|
398
|
-
this.renderFooter();
|
|
408
|
+
this.enqueue(text);
|
|
399
409
|
return;
|
|
400
410
|
}
|
|
401
411
|
if (text.startsWith("/")) return this.command(text);
|
|
@@ -407,8 +417,7 @@ export class ChatApp {
|
|
|
407
417
|
const route = this.routeFor(text);
|
|
408
418
|
if (route && route !== this.active.agent && !this.active.turn) return this.askAgent(route, text, `routed to ${route} by rule`);
|
|
409
419
|
if (this.active.turn) {
|
|
410
|
-
this.
|
|
411
|
-
this.transcript.note(`queued: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`, C.dim);
|
|
420
|
+
this.enqueue(text);
|
|
412
421
|
return;
|
|
413
422
|
}
|
|
414
423
|
await this.send(this.active, text);
|
|
@@ -650,6 +659,26 @@ export class ChatApp {
|
|
|
650
659
|
return true;
|
|
651
660
|
}
|
|
652
661
|
|
|
662
|
+
/** Queue a message (or command) until the agent's turn ends; it shows above the prompt. */
|
|
663
|
+
private enqueue(text: string) {
|
|
664
|
+
this.queue.push(text);
|
|
665
|
+
this.renderQueue();
|
|
666
|
+
this.renderFooter();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private renderQueue() {
|
|
670
|
+
this.safely("queue", () => {
|
|
671
|
+
for (const child of this.queueBox.getChildren()) child.destroyRecursively();
|
|
672
|
+
const show = this.queue.slice(0, 3);
|
|
673
|
+
for (const [i, q] of show.entries()) {
|
|
674
|
+
const line = q.startsWith("A side agent (") ? "side-agent note for the main agent" : q.replace(/\s+/g, " ");
|
|
675
|
+
this.queueBox.add(new TextRenderable(this.renderer, { id: `queue-${Date.now()}-${i}`, content: `${G.prompt} ${line}`, fg: C.dim, wrapMode: "none", truncate: true, width: "100%" } as any));
|
|
676
|
+
}
|
|
677
|
+
if (this.queue.length > show.length) this.queueBox.add(new TextRenderable(this.renderer, { id: `queue-more-${Date.now()}`, content: ` + ${this.queue.length - show.length} more queued`, fg: C.dimmer }));
|
|
678
|
+
this.queueBox.visible = this.queue.length > 0;
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
653
682
|
/** Run one queued entry the same way as if it had just been typed. */
|
|
654
683
|
private async dispatch(active: Active, text: string) {
|
|
655
684
|
if (text.startsWith("!")) return this.shell(text.slice(1).trim());
|
|
@@ -658,7 +687,11 @@ export class ChatApp {
|
|
|
658
687
|
}
|
|
659
688
|
|
|
660
689
|
private async drainQueue(active: Active) {
|
|
661
|
-
while (this.queue.length && this.active === active && !active.turn)
|
|
690
|
+
while (this.queue.length && this.active === active && !active.turn) {
|
|
691
|
+
const next = this.queue.shift()!;
|
|
692
|
+
this.renderQueue();
|
|
693
|
+
await this.dispatch(active, next);
|
|
694
|
+
}
|
|
662
695
|
this.renderFooter();
|
|
663
696
|
}
|
|
664
697
|
|
|
@@ -772,11 +805,17 @@ export class ChatApp {
|
|
|
772
805
|
const name = raw!.toLowerCase();
|
|
773
806
|
const b = this.builtins().find((x) => x.name === name || x.name === name.replace(/^\/(exit|q)$/, "/quit"));
|
|
774
807
|
if (b) return b.run(rest.join(" "));
|
|
808
|
+
// /<agent>:<cmd> runs the active agent's own command even when codemax has one of that name.
|
|
809
|
+
const ns = name.match(/^\/([a-z]+):(.+)$/);
|
|
810
|
+
if (ns && this.active && ns[1] === this.active.agent && this.active.commands.some((c) => c.name === ns[2])) {
|
|
811
|
+
const forward = `/${ns[2]}${rest.length ? " " + rest.join(" ") : ""}`;
|
|
812
|
+
if (this.active.turn) return this.enqueue(forward);
|
|
813
|
+
return this.send(this.active, forward);
|
|
814
|
+
}
|
|
775
815
|
if (name === "/exit" || name === "/q") return this.quit();
|
|
776
816
|
if (this.active?.commands.some((c) => `/${c.name}` === name)) {
|
|
777
817
|
if (this.active.turn) {
|
|
778
|
-
this.
|
|
779
|
-
this.transcript.note(`queued ${name} until the turn ends`, C.dim);
|
|
818
|
+
this.enqueue(line);
|
|
780
819
|
return;
|
|
781
820
|
}
|
|
782
821
|
return this.send(this.active, line);
|
|
@@ -786,7 +825,12 @@ export class ChatApp {
|
|
|
786
825
|
|
|
787
826
|
private runCommandValue(value: string, args: string) {
|
|
788
827
|
if (value.startsWith("builtin:")) return this.builtins().find((b) => b.name === value.slice(8))?.run(args);
|
|
789
|
-
if (value.startsWith("agentcmd:"))
|
|
828
|
+
if (value.startsWith("agentcmd:")) {
|
|
829
|
+
const name = value.slice(9);
|
|
830
|
+
// A name codemax also uses goes through /<agent>:<name> so the agent's own command runs.
|
|
831
|
+
const clash = this.builtins().some((b) => b.name === `/${name}`) && this.active;
|
|
832
|
+
return this.command(`/${clash ? `${this.active!.agent}:` : ""}${name}${args ? " " + args : ""}`);
|
|
833
|
+
}
|
|
790
834
|
}
|
|
791
835
|
|
|
792
836
|
private pendingShell: string[] = [];
|
|
@@ -834,7 +878,8 @@ export class ChatApp {
|
|
|
834
878
|
this.paletteKind = "modes";
|
|
835
879
|
this.palette = new Palette(this.renderer, {
|
|
836
880
|
title: `Model for ${a.name}`,
|
|
837
|
-
|
|
881
|
+
// The agent's own list, newest first, by its display name (e.g. "Opus 5.5" for the id "opus").
|
|
882
|
+
items: sortModels(models).map((m, i) => ({ label: m.name, description: `${m.name !== m.id ? m.id : ""}${m.id === a.engine.info?.currentModel ? " (current)" : ""}`, value: m.id, weight: 100 - i })),
|
|
838
883
|
onPick: (it) => {
|
|
839
884
|
this.closePalette();
|
|
840
885
|
void apply(it.value);
|
|
@@ -944,6 +989,13 @@ export class ChatApp {
|
|
|
944
989
|
const all = this.ledger.usage();
|
|
945
990
|
for (const l of renderUsageTable(all, { title: "Usage for this project" })) this.transcript.note(l);
|
|
946
991
|
this.transcript.note(`this codemax session: ${fmtUsd(this.sessionCost)} · all time here: ${fmtUsd(sumCost(all))} · prices in ~/.codemax/prices.json`);
|
|
992
|
+
// Plan limits, as the agents report them (Claude Code's own /usage is terminal-only, so this is where it shows).
|
|
993
|
+
const label: Record<string, string> = { five_hour: "5-hour", seven_day: "weekly", seven_day_opus: "weekly Opus", seven_day_sonnet: "weekly Sonnet", overage: "extra usage", seven_day_overage_included: "weekly incl. extra" };
|
|
994
|
+
for (const [agent, m] of this.planLimits) {
|
|
995
|
+
const parts = [...m].map(([w, v]) => `${label[w] ?? w} ${typeof v.usedPercent === "number" ? `${Math.round(v.usedPercent)}%` : v.status ?? "?"}${v.status === "rejected" ? " (used up)" : ""}${v.resetsAt ? ` · resets ${fmtDateTime12(v.resetsAt)}` : ""}`);
|
|
996
|
+
this.transcript.note(`${getAdapter(agent).name} plan: ${parts.join("; ")}`, C.accent);
|
|
997
|
+
}
|
|
998
|
+
if (!this.planLimits.size) this.transcript.note("plan limits appear here once an agent reports them (Claude Code does after a turn)");
|
|
947
999
|
}
|
|
948
1000
|
|
|
949
1001
|
private showStatus() {
|
|
@@ -978,9 +1030,12 @@ export class ChatApp {
|
|
|
978
1030
|
const builtinNames = new Set(this.builtins().map((b) => b.name));
|
|
979
1031
|
const items: PaletteItem[] = [
|
|
980
1032
|
...this.builtins().map((b) => ({ label: b.name, description: b.description, value: `builtin:${b.name}`, weight: 1 })),
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1033
|
+
// An agent command that shares a built-in's name stays reachable as /<agent>:<name>.
|
|
1034
|
+
...(this.active?.commands ?? []).map((c) => {
|
|
1035
|
+
const clash = builtinNames.has(`/${c.name}`);
|
|
1036
|
+
const label = clash ? `/${this.active!.agent}:${c.name}` : `/${c.name}`;
|
|
1037
|
+
return { label, description: `${c.description ?? `${this.active!.name} command`}${clash ? ` (${this.active!.name}'s own /${c.name})` : ""}`, value: `agentcmd:${c.name}` };
|
|
1038
|
+
}),
|
|
984
1039
|
];
|
|
985
1040
|
this.paletteKind = "commands";
|
|
986
1041
|
this.palette = new Palette(this.renderer, {
|
|
@@ -1169,7 +1224,8 @@ export class ChatApp {
|
|
|
1169
1224
|
if (turn && this.config.bellAfterSeconds > 0 && Date.now() - turn.startedAt > this.config.bellAfterSeconds * 1000) process.stdout.write("\x07");
|
|
1170
1225
|
if (turn) {
|
|
1171
1226
|
const dur = fmtDuration(Date.now() - turn.startedAt);
|
|
1172
|
-
|
|
1227
|
+
const who = `${active.name} · ${this.modelName(active, turn.model)}`;
|
|
1228
|
+
block.footer(stop === "cancelled" ? `Interrupted after ${dur} · ${who}` : `${pick(DONE_VERBS, turn.startedAt)} for ${dur} · done ${fmtTime(Date.now())} · ${who}`);
|
|
1173
1229
|
const reply = block.text().trim();
|
|
1174
1230
|
if (isLimitNotice(reply)) turn.rec?.markLimit(reply);
|
|
1175
1231
|
turn.rec?.end(stop);
|
|
@@ -1247,10 +1303,18 @@ export class ChatApp {
|
|
|
1247
1303
|
break;
|
|
1248
1304
|
case "usage": {
|
|
1249
1305
|
if (active.turn && typeof e.outputTokens === "number") active.turn.tokens += e.outputTokens;
|
|
1306
|
+
if (active.turn && e.model && !/default model$/.test(e.model)) active.turn.model = e.model;
|
|
1250
1307
|
if (typeof e.contextUsed === "number" && typeof e.contextSize === "number" && e.contextSize > 0) {
|
|
1251
1308
|
active.context = { used: e.contextUsed, size: e.contextSize };
|
|
1252
1309
|
this.renderFooter();
|
|
1253
1310
|
}
|
|
1311
|
+
for (const pl of e.planLimits ?? []) {
|
|
1312
|
+
const m = this.planLimits.get(active.agent) ?? new Map();
|
|
1313
|
+
m.set(pl.window, { usedPercent: pl.usedPercent, resetsAt: pl.resetsAt, status: pl.status, at: Date.now() });
|
|
1314
|
+
this.planLimits.set(active.agent, m);
|
|
1315
|
+
// A refused window is the plan running out: the handoff offer follows from onQuota.
|
|
1316
|
+
if (pl.status === "rejected" || pl.window === "five_hour") this.onQuota({ agent: active.agent, usedPercent: pl.usedPercent, exhausted: pl.status === "rejected", resetsAt: pl.resetsAt, source: "probe", at: Date.now() });
|
|
1317
|
+
}
|
|
1254
1318
|
if (typeof e.usedPercent === "number") this.onQuota({ agent: active.agent, usedPercent: e.usedPercent, exhausted: e.usedPercent >= 100, source: "probe", at: Date.now() });
|
|
1255
1319
|
this.recordUsage(active.agent, active.engine, e);
|
|
1256
1320
|
break;
|
|
@@ -1392,7 +1456,7 @@ export class ChatApp {
|
|
|
1392
1456
|
this.paletteKind = "modes";
|
|
1393
1457
|
this.palette = new Palette(this.renderer, {
|
|
1394
1458
|
title: `Model for the side agent (${name}) · Esc keeps ${shortModelName(r.engine.info?.currentModel ?? "default")}`,
|
|
1395
|
-
items: models.map((m) => ({ label: m.
|
|
1459
|
+
items: sortModels(models).map((m, i) => ({ label: m.name, description: `${m.name !== m.id ? m.id : ""}${m.id === r.engine.info?.currentModel ? " (current)" : ""}`, value: m.id, weight: 100 - i })),
|
|
1396
1460
|
onPick: (it) => {
|
|
1397
1461
|
this.closePalette();
|
|
1398
1462
|
void this.setSideModel(it.value);
|
|
@@ -1644,14 +1708,20 @@ export class ChatApp {
|
|
|
1644
1708
|
if (!a) return this.transcript.note("no main agent to forward to", C.warn);
|
|
1645
1709
|
const msg = forwardMessage(n.text, n.name);
|
|
1646
1710
|
if (a.turn || this.switching) {
|
|
1647
|
-
this.
|
|
1648
|
-
this.transcript.note(`note queued for ${a.name} until its turn ends`);
|
|
1649
|
-
this.renderFooter();
|
|
1711
|
+
this.enqueue(msg);
|
|
1650
1712
|
return;
|
|
1651
1713
|
}
|
|
1652
1714
|
void this.send(a, msg);
|
|
1653
1715
|
}
|
|
1654
1716
|
|
|
1717
|
+
/** The model's display name as the agent lists it ("Opus 5.5"), else a short id. */
|
|
1718
|
+
private modelName(a: Active, id?: string): string {
|
|
1719
|
+
const mid = id ?? a.engine.info?.currentModel;
|
|
1720
|
+
if (!mid) return "default model";
|
|
1721
|
+
const listed = a.engine.info?.models?.find((m) => m.id === mid);
|
|
1722
|
+
return listed && listed.name !== listed.id ? listed.name : shortModelName(mid);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1655
1725
|
private announceUpdate(version: string) {
|
|
1656
1726
|
if (this.closed) return;
|
|
1657
1727
|
this.updateAvailable = version;
|
|
@@ -1714,7 +1784,7 @@ export class ChatApp {
|
|
|
1714
1784
|
else {
|
|
1715
1785
|
const elapsed = Date.now() - t.startedAt;
|
|
1716
1786
|
const tip = elapsed > 8000 ? ` ⎿ tip: ${pick(TIPS, Math.floor(t.startedAt / 1000))}` : "";
|
|
1717
|
-
this.setWorking(`${glyph} ${t.verb}… (${secs}${tok}) · esc to interrupt${tip}`);
|
|
1787
|
+
this.setWorking(`${glyph} ${t.verb}… (${secs}${tok}) · ${active.name} · ${this.modelName(active, t.model)} · esc to interrupt${tip}`);
|
|
1718
1788
|
}
|
|
1719
1789
|
}, 120);
|
|
1720
1790
|
}
|
|
@@ -1883,14 +1953,14 @@ function ago(ts: number): string {
|
|
|
1883
1953
|
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
1884
1954
|
}
|
|
1885
1955
|
|
|
1886
|
-
export async function runChat(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean, pick = false) {
|
|
1956
|
+
export async function runChat(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean, pick = false, explicit = false) {
|
|
1887
1957
|
const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGHUP"], useMouse: true, targetFps: 60 });
|
|
1888
1958
|
const pref = config.theme ?? "auto";
|
|
1889
1959
|
applyTheme(pref === "auto" ? ((await renderer.waitForThemeMode(250).catch(() => null)) ?? "dark") : pref);
|
|
1890
1960
|
const app = new ChatApp(renderer, config, ledger, cwd);
|
|
1891
1961
|
process.once("exit", () => app.awake.releaseSync());
|
|
1892
1962
|
try {
|
|
1893
|
-
await app.start(agent, fresh, { pick });
|
|
1963
|
+
await app.start(agent, fresh, { pick, explicit });
|
|
1894
1964
|
} finally {
|
|
1895
1965
|
renderer.destroy();
|
|
1896
1966
|
try {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model menus list exactly what the agent offers, newest first: "Default" /
|
|
3
|
+
* "Auto" stay on top, then models by the version in their name ("Opus 5.5"
|
|
4
|
+
* before "Opus 4.6", "GPT-6.1" before "GPT-5.6"); equal versions keep the
|
|
5
|
+
* agent's own order.
|
|
6
|
+
*/
|
|
7
|
+
export interface ModelEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PINNED = /^(default|auto)\b/i;
|
|
13
|
+
|
|
14
|
+
/** "Opus 5.5" → 5.5, "GPT-6.1-Sol" → 6.1, "claude-opus-4-7" → 4.7, "Sonnet 5" → 5; none → undefined. */
|
|
15
|
+
export function modelVersion(m: ModelEntry): number | undefined {
|
|
16
|
+
for (const s of [m.name, m.id]) {
|
|
17
|
+
// Skip date stamps like 20251001; a version is a small major with an optional minor.
|
|
18
|
+
const hit = s.match(/(?:^|[^\d])(\d{1,2})(?:[.-](\d{1,2}))?(?=$|[^\d])/);
|
|
19
|
+
if (hit) return Number(hit[1]) + (hit[2] ? Number(hit[2]) / 100 : 0);
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sortModels<T extends ModelEntry>(models: T[]): T[] {
|
|
25
|
+
return models
|
|
26
|
+
.map((m, i) => ({ m, i, pinned: PINNED.test(m.name) || PINNED.test(m.id), v: modelVersion(m) }))
|
|
27
|
+
.sort((a, b) => {
|
|
28
|
+
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
|
29
|
+
if (a.v !== b.v) return (b.v ?? -1) - (a.v ?? -1);
|
|
30
|
+
return a.i - b.i;
|
|
31
|
+
})
|
|
32
|
+
.map((x) => x.m);
|
|
33
|
+
}
|
package/src/chat/palette.ts
CHANGED
|
@@ -37,6 +37,8 @@ export class Palette {
|
|
|
37
37
|
private rows: TextRenderable[] = [];
|
|
38
38
|
private titleRow: TextRenderable;
|
|
39
39
|
private cursor = 0;
|
|
40
|
+
/** First visible row: the window scrolls so every item can be reached with the arrows. */
|
|
41
|
+
private offset = 0;
|
|
40
42
|
private filter: string;
|
|
41
43
|
private matches: PaletteItem[] = [];
|
|
42
44
|
private maxRows: number;
|
|
@@ -90,11 +92,16 @@ export class Palette {
|
|
|
90
92
|
|
|
91
93
|
render() {
|
|
92
94
|
const { top, width } = this.anchor();
|
|
93
|
-
|
|
95
|
+
// Keep the highlighted row inside the visible window.
|
|
96
|
+
if (this.cursor < this.offset) this.offset = this.cursor;
|
|
97
|
+
if (this.cursor >= this.offset + this.maxRows) this.offset = this.cursor - this.maxRows + 1;
|
|
98
|
+
this.offset = Math.max(0, Math.min(this.offset, this.matches.length - this.maxRows));
|
|
99
|
+
const visible = this.matches.slice(this.offset, this.offset + this.maxRows);
|
|
94
100
|
const shown = visible.length;
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
this.titleRow.
|
|
101
|
+
const more = this.matches.length > this.maxRows;
|
|
102
|
+
const titleLines = this.opts.title || more ? 1 : 0;
|
|
103
|
+
this.titleRow.visible = titleLines > 0;
|
|
104
|
+
this.titleRow.content = `${this.opts.title ?? ""}${more ? `${this.opts.title ? " · " : ""}${this.cursor + 1}/${this.matches.length} · ↑↓ to scroll` : ""}`;
|
|
98
105
|
this.box.height = shown + titleLines + (shown === 0 ? 1 : 0);
|
|
99
106
|
this.box.top = Math.max(0, top - this.box.height);
|
|
100
107
|
const labelW = Math.min(Math.max(28, Math.floor(width * 0.35)), Math.max(10, ...visible.map((v) => v.label.length + 2)));
|
|
@@ -112,7 +119,7 @@ export class Palette {
|
|
|
112
119
|
return;
|
|
113
120
|
}
|
|
114
121
|
row.visible = true;
|
|
115
|
-
const sel = i === this.cursor;
|
|
122
|
+
const sel = this.offset + i === this.cursor;
|
|
116
123
|
const label = pad(it.label, labelW);
|
|
117
124
|
const desc = it.description ?? "";
|
|
118
125
|
const hint = it.hint ? ` ${it.hint}` : "";
|
|
@@ -150,7 +157,8 @@ export class Palette {
|
|
|
150
157
|
}
|
|
151
158
|
|
|
152
159
|
private move(d: number): boolean {
|
|
153
|
-
|
|
160
|
+
// Every match is reachable; the window in render() scrolls to follow.
|
|
161
|
+
const n = this.matches.length;
|
|
154
162
|
if (!n) return true;
|
|
155
163
|
this.cursor = (this.cursor + d + n) % n;
|
|
156
164
|
this.render();
|
package/src/chat/theme.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fmtTime12 } from "../core/time.ts";
|
|
1
2
|
/**
|
|
2
3
|
* Glyphs and colours. The codemax brand theme: pink is the accent and nothing
|
|
3
4
|
* else, plum is the one dark ink, and warnings, errors and success keep their
|
|
@@ -153,6 +154,7 @@ export function fmtDuration(ms: number): string {
|
|
|
153
154
|
return `${m}m ${s - m * 60}s`;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/** 12-hour, always ("7:25 PM"). */
|
|
156
158
|
export function fmtTime(ts: number): string {
|
|
157
|
-
return
|
|
159
|
+
return fmtTime12(ts);
|
|
158
160
|
}
|
package/src/chat/transcript.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BoxRenderable, ScrollBoxRenderable, StyledText, TextRenderable, fg, italic, strikethrough, stringToStyledText, type CliRenderer, type TextChunk } from "@opentui/core";
|
|
2
2
|
import { C, G } from "./theme.ts";
|
|
3
3
|
import { styleMarkdown, styleToolOutput } from "./markdown.ts";
|
|
4
|
+
import { fmtTime12 } from "../core/time.ts";
|
|
4
5
|
|
|
5
6
|
export interface PlanEntry {
|
|
6
7
|
content: string;
|
|
@@ -109,9 +110,11 @@ export class Transcript {
|
|
|
109
110
|
this.scrollToEnd();
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
/** Your message, with the time it was sent ("· 7:25 PM", 12-hour). */
|
|
114
|
+
user(text: string, ts = Date.now()) {
|
|
113
115
|
this.gap();
|
|
114
|
-
this.line(`${G.prompt} `, text, { fg: undefined, glyphFg: C.accent });
|
|
116
|
+
const node = this.line(`${G.prompt} `, text, { fg: undefined, glyphFg: C.accent });
|
|
117
|
+
node.content = new StyledText([...stringToStyledText(text).chunks, fg(C.dimmer)(` · ${fmtTime12(ts)}`)]);
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
note(text: string, fg_: string = C.dim) {
|
package/src/cli.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { diagnose } from "./core/doctor.ts";
|
|
|
18
18
|
import { renderUsageTable, sumCost } from "./core/usage.ts";
|
|
19
19
|
import { fmtUsd, priceFor, priceProvenance, resetPriceCache } from "./core/pricing.ts";
|
|
20
20
|
import { fetchLivePrices, loadLivePrices, LIVE_PRICES_PATH } from "./core/prices-live.ts";
|
|
21
|
+
import { fmtDateTime12 } from "./core/time.ts";
|
|
21
22
|
|
|
22
23
|
const HELP = `codemax — one terminal for all your coding agents
|
|
23
24
|
|
|
@@ -73,9 +74,9 @@ async function main(argv: string[]) {
|
|
|
73
74
|
const orch = new Orchestrator(config, ledger, cwd);
|
|
74
75
|
await orch.start(agent, args.includes("--fresh"));
|
|
75
76
|
} else if (args.includes("--tui") || !hasEngine(agent)) {
|
|
76
|
-
await runApp(config, ledger, cwd, agent, args.includes("--fresh"));
|
|
77
|
+
await runApp(config, ledger, cwd, agent, args.includes("--fresh"), !!agentFlag);
|
|
77
78
|
} else {
|
|
78
|
-
await runChat(config, ledger, cwd, agent, args.includes("--fresh"), !agentFlag);
|
|
79
|
+
await runChat(config, ledger, cwd, agent, args.includes("--fresh"), !agentFlag, !!agentFlag);
|
|
79
80
|
}
|
|
80
81
|
process.exit(0);
|
|
81
82
|
}
|
|
@@ -105,7 +106,7 @@ async function main(argv: string[]) {
|
|
|
105
106
|
const last = events.slice(-5);
|
|
106
107
|
if (last.length) {
|
|
107
108
|
console.log("recent events:");
|
|
108
|
-
for (const e of last) console.log(` ${
|
|
109
|
+
for (const e of last) console.log(` ${fmtDateTime12(e.ts)} ${e.kind} ${e.agent ?? ""}${e.to ? ` → ${e.to}` : ""} ${e.detail ?? ""}`);
|
|
109
110
|
}
|
|
110
111
|
return;
|
|
111
112
|
}
|
|
@@ -114,7 +115,7 @@ async function main(argv: string[]) {
|
|
|
114
115
|
const turns = new Ledger(cwd).turns().slice(-n);
|
|
115
116
|
for (const t of turns) {
|
|
116
117
|
const who = t.role === "user" ? "USER" : `${t.agent.toUpperCase()}`;
|
|
117
|
-
console.log(`\n[${
|
|
118
|
+
console.log(`\n[${fmtDateTime12(t.ts)}] ${who}${t.tools?.length ? ` (tools: ${t.tools.join(", ")})` : ""}\n${t.text.slice(0, 600)}`);
|
|
118
119
|
}
|
|
119
120
|
return;
|
|
120
121
|
}
|
|
@@ -218,7 +219,7 @@ async function main(argv: string[]) {
|
|
|
218
219
|
}
|
|
219
220
|
const all = ledger.quota();
|
|
220
221
|
if (!Object.keys(all).length) return console.log("no quota state recorded");
|
|
221
|
-
for (const q of Object.values(all)) console.log(`${q.agent.padEnd(9)} ${quotaLabel(q) || "ok"} (${q.source}, ${
|
|
222
|
+
for (const q of Object.values(all)) console.log(`${q.agent.padEnd(9)} ${quotaLabel(q) || "ok"} (${q.source}, ${fmtDateTime12(q.at)}${q.detail ? `, ${q.detail}` : ""})`);
|
|
222
223
|
return;
|
|
223
224
|
}
|
|
224
225
|
case "keys": {
|
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 =
|
|
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 ${
|
|
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`;
|
package/src/core/time.ts
ADDED
|
@@ -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/engines/acp.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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, StopReason } from "./types.ts";
|
|
4
4
|
import { agentEnv } from "./env.ts";
|
|
5
5
|
import { platformArgv } from "../core/spawn.ts";
|
|
6
6
|
import { classifyError } from "./errors.ts";
|
|
@@ -204,7 +204,7 @@ export class AcpEngine implements Engine {
|
|
|
204
204
|
// `used` is how full the context window is, not tokens spent: it feeds the context meter.
|
|
205
205
|
// Token counts come from the prompt response (or session files) at turn end.
|
|
206
206
|
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() });
|
|
207
|
+
this.emit({ type: "usage", contextUsed: u.used, contextSize: u.size, costUsd: delta, model: this.currentModelName(), planLimits: planLimitsOf(u) });
|
|
208
208
|
break;
|
|
209
209
|
}
|
|
210
210
|
default:
|
|
@@ -339,6 +339,15 @@ function extraOptions(config: unknown): EngineOption[] {
|
|
|
339
339
|
}));
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/** Claude's plan limits ride on usage updates as `_meta["_claude/rateLimit"]` (SDKRateLimitInfo). */
|
|
343
|
+
function planLimitsOf(u: any): PlanLimit[] | undefined {
|
|
344
|
+
const rl = u?._meta?.["_claude/rateLimit"];
|
|
345
|
+
if (!rl || typeof rl !== "object") return undefined;
|
|
346
|
+
const util = typeof rl.utilization === "number" ? (rl.utilization <= 1 ? rl.utilization * 100 : rl.utilization) : undefined;
|
|
347
|
+
const reset = typeof rl.resetsAt === "number" ? (rl.resetsAt < 1e12 ? rl.resetsAt * 1000 : rl.resetsAt) : undefined;
|
|
348
|
+
return [{ window: String(rl.rateLimitType ?? "plan"), usedPercent: util, resetsAt: reset, status: rl.status }];
|
|
349
|
+
}
|
|
350
|
+
|
|
342
351
|
function mapStatus(s: string | undefined): "pending" | "in_progress" | "completed" | "failed" {
|
|
343
352
|
if (s === "in_progress" || s === "completed" || s === "failed") return s;
|
|
344
353
|
return "pending";
|
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;
|
|
@@ -49,7 +54,7 @@ export class CodexEngine implements Engine {
|
|
|
49
54
|
this.cwd = o.cwd;
|
|
50
55
|
this.transport = this.opts.transport ?? this.spawn(o.cwd);
|
|
51
56
|
this.transport.onLine((line) => this.onLine(line));
|
|
52
|
-
const init = await this.request("initialize", { clientInfo: { name: "codemax", version:
|
|
57
|
+
const init = await this.request("initialize", { clientInfo: { name: "codemax", version: pkg.version }, capabilities: {} });
|
|
53
58
|
this.notify("initialized", {});
|
|
54
59
|
|
|
55
60
|
let thread: any;
|
|
@@ -65,6 +70,9 @@ export class CodexEngine implements Engine {
|
|
|
65
70
|
this.model = thread.model ?? undefined;
|
|
66
71
|
this.session = { agent: "codex", id: thread.id, cwd: o.cwd, startedAt: Date.now(), lastUsedAt: Date.now() };
|
|
67
72
|
const account: any = await this.request("account/read", {}).catch(() => null);
|
|
73
|
+
// The models this account can use, straight from Codex: never a list of our own.
|
|
74
|
+
// An older Codex without model/list must not hold up startup: give it a few seconds.
|
|
75
|
+
this.catalog = await Promise.race([this.listModels(), Bun.sleep(4000).then(() => [])]).catch(() => []);
|
|
68
76
|
this.info = {
|
|
69
77
|
agent: "codex",
|
|
70
78
|
name: "Codex",
|
|
@@ -74,8 +82,9 @@ export class CodexEngine implements Engine {
|
|
|
74
82
|
{ id: "never", name: "full auto (no approvals)" },
|
|
75
83
|
],
|
|
76
84
|
currentMode: "on-request",
|
|
77
|
-
models:
|
|
85
|
+
models: this.catalog.length ? this.catalog.map((m) => ({ id: m.id, name: m.name })) : this.model ? [{ id: this.model, name: this.model }] : [],
|
|
78
86
|
currentModel: this.model,
|
|
87
|
+
options: this.effortOptions(),
|
|
79
88
|
plan: account?.account?.planType ? `chatgpt ${account.account.planType}` : undefined,
|
|
80
89
|
};
|
|
81
90
|
this.emit({ type: "session", ref: this.session });
|
|
@@ -281,7 +290,7 @@ export class CodexEngine implements Engine {
|
|
|
281
290
|
const done = new Promise<StopReason>((resolve) => (this.turn = { resolve }));
|
|
282
291
|
try {
|
|
283
292
|
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 });
|
|
293
|
+
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
294
|
if (this.turn) this.turn.id = res?.turn?.id;
|
|
286
295
|
} catch (e: any) {
|
|
287
296
|
const msg = `${e?.message ?? e} ${JSON.stringify(e?.data ?? "")}`.trim();
|
|
@@ -314,6 +323,46 @@ export class CodexEngine implements Engine {
|
|
|
314
323
|
async setModel(modelId: string) {
|
|
315
324
|
this.model = modelId;
|
|
316
325
|
if (this.info) this.info.currentModel = modelId;
|
|
326
|
+
// Reasoning efforts differ per model: keep the chosen one only if the new model has it.
|
|
327
|
+
const efforts = this.catalog.find((m) => m.id === modelId)?.efforts ?? [];
|
|
328
|
+
if (this.effort && !efforts.includes(this.effort)) this.effort = undefined;
|
|
329
|
+
if (this.info) this.info.options = this.effortOptions();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async setOption(optionId: string, value: string) {
|
|
333
|
+
if (optionId !== "effort") return;
|
|
334
|
+
this.effort = value;
|
|
335
|
+
if (this.info) this.info.options = this.effortOptions();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** `model/list`, all pages, hidden models left out. */
|
|
339
|
+
private async listModels(): Promise<{ id: string; name: string; efforts: string[]; defaultEffort?: string }[]> {
|
|
340
|
+
const out: { id: string; name: string; efforts: string[]; defaultEffort?: string }[] = [];
|
|
341
|
+
let cursor: string | undefined;
|
|
342
|
+
for (let page = 0; page < 10; page++) {
|
|
343
|
+
const r: any = await this.request("model/list", cursor ? { cursor } : {});
|
|
344
|
+
for (const m of r?.data ?? []) {
|
|
345
|
+
if (m?.hidden) continue;
|
|
346
|
+
const id = String(m.model ?? m.id ?? "");
|
|
347
|
+
if (!id) continue;
|
|
348
|
+
out.push({
|
|
349
|
+
id,
|
|
350
|
+
name: String(m.displayName ?? id),
|
|
351
|
+
efforts: (m.supportedReasoningEfforts ?? []).map((e: any) => String(e?.reasoningEffort ?? e)).filter(Boolean),
|
|
352
|
+
defaultEffort: m.defaultReasoningEffort ? String(m.defaultReasoningEffort) : undefined,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
cursor = r?.nextCursor ?? undefined;
|
|
356
|
+
if (!cursor) break;
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The current model's reasoning efforts, as Codex lists them. */
|
|
362
|
+
private effortOptions(): EngineOption[] {
|
|
363
|
+
const m = this.catalog.find((x) => x.id === this.model);
|
|
364
|
+
if (!m?.efforts.length) return [];
|
|
365
|
+
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
366
|
}
|
|
318
367
|
|
|
319
368
|
async close() {
|
package/src/engines/types.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type EngineEvent =
|
|
|
27
27
|
costUsd?: number;
|
|
28
28
|
/** Rate-limit window consumption, when the agent exposes it. */
|
|
29
29
|
usedPercent?: number;
|
|
30
|
+
/** Subscription plan limits the agent reported (Claude: 5-hour, weekly…), one entry per window. */
|
|
31
|
+
planLimits?: PlanLimit[];
|
|
30
32
|
/** Context window fill, when reported (tokens used / size). */
|
|
31
33
|
contextUsed?: number;
|
|
32
34
|
contextSize?: number;
|
|
@@ -36,6 +38,15 @@ export type EngineEvent =
|
|
|
36
38
|
| { type: "error"; kind: ErrorKind; message: string }
|
|
37
39
|
| { type: "log"; level: "info" | "warn" | "error"; message: string };
|
|
38
40
|
|
|
41
|
+
export interface PlanLimit {
|
|
42
|
+
/** e.g. "five_hour", "seven_day", "seven_day_opus". */
|
|
43
|
+
window: string;
|
|
44
|
+
usedPercent?: number;
|
|
45
|
+
resetsAt?: number;
|
|
46
|
+
/** "allowed" | "allowed_warning" | "rejected" (out of this window's quota). */
|
|
47
|
+
status?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
export type ToolStatus = "pending" | "in_progress" | "completed" | "failed";
|
|
40
51
|
export type StopReason = "end_turn" | "max_tokens" | "cancelled" | "refusal" | "error" | "other";
|
|
41
52
|
export type ErrorKind = "quota" | "auth" | "protocol" | "crashed" | "other";
|
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 {
|