codemax-cli 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/bin/codemax.cjs +3 -2
- package/package.json +13 -4
- package/src/chat/app.ts +552 -40
- package/src/chat/logo.ts +37 -0
- package/src/chat/transcript.ts +22 -4
- package/src/cli.ts +46 -1
- package/src/config.ts +18 -0
- package/src/core/awake.ts +345 -0
- package/src/core/brief.ts +1 -1
- package/src/core/journal.ts +4 -2
- package/src/core/review.ts +67 -0
- package/src/core/update.ts +90 -0
- package/src/engines/index.ts +6 -6
package/src/chat/app.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BoxRenderable, TextRenderable, TextareaRenderable, createCliRenderer, type CliRenderer, type KeyEvent } from "@opentui/core";
|
|
2
2
|
import type { Config } from "../config.ts";
|
|
3
|
-
import { hotkeyLabel, saveConfigKey } from "../config.ts";
|
|
3
|
+
import { CONFIG_PATH, hotkeyLabel, saveConfigKey } from "../config.ts";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import type { AgentId, QuotaStatus, SessionRef } from "../adapters/types.ts";
|
|
5
6
|
import { listAdapters, getAdapter } from "../adapters/index.ts";
|
|
6
7
|
import { Ledger } from "../core/ledger.ts";
|
|
@@ -26,10 +27,16 @@ import pkg from "../../package.json" with { type: "json" };
|
|
|
26
27
|
import { platformArgv, shellArgv } from "../core/spawn.ts";
|
|
27
28
|
import { cleanError, isLimitNotice, parseResetTime } from "../engines/errors.ts";
|
|
28
29
|
import { TurnRecorder } from "../core/journal.ts";
|
|
30
|
+
import { Awake, defaultAwakeDeps, recoverAwake } from "../core/awake.ts";
|
|
31
|
+
import { buildReviewPrompt, forwardMessage, isForwardedNote, parseNote } from "../core/review.ts";
|
|
32
|
+
import { logoLines } from "./logo.ts";
|
|
33
|
+
import { checkForUpdate, updatePlan } from "../core/update.ts";
|
|
29
34
|
|
|
30
35
|
const HOTKEY_NAMES: Record<string, string[]> = { "/": ["/", "_", "?"], "^": ["^", "6"] };
|
|
31
36
|
const VERSION = pkg.version;
|
|
32
37
|
const MODE_CYCLE = ["default", "acceptEdits", "plan"];
|
|
38
|
+
/** Most side-agent notes sent automatically per task; more wait for /forward. */
|
|
39
|
+
const AUTO_FORWARD_CAP = 2;
|
|
33
40
|
|
|
34
41
|
interface Active {
|
|
35
42
|
agent: AgentId;
|
|
@@ -89,6 +96,19 @@ export class ChatApp {
|
|
|
89
96
|
private switching = false;
|
|
90
97
|
/** Set by quit(); async work that resumes afterwards (a handoff being built) stops. */
|
|
91
98
|
private closed = false;
|
|
99
|
+
/** Side agent (/side): reviews the main agent's work after each turn. */
|
|
100
|
+
private reviewer: { agent: AgentId; name: string; engine: Engine; busy: boolean; pending: boolean; since: number; primed: boolean } | null = null;
|
|
101
|
+
private notes: { ts: number; agent: AgentId; name: string; text: string }[] = [];
|
|
102
|
+
/** Working turns since the side agent last reviewed (for `sideAgent.every`). */
|
|
103
|
+
private turnsSinceReview = 0;
|
|
104
|
+
/** Notes sent automatically per task (auto mode is capped so the two agents cannot loop). */
|
|
105
|
+
private autoForwards = new Map<string, number>();
|
|
106
|
+
/** A newer codemax on npm, when the update check found one. */
|
|
107
|
+
private updateAvailable: string | null = null;
|
|
108
|
+
/** Update check (tests swap it; it never runs under `bun test`). */
|
|
109
|
+
updateChecker: (current: string) => Promise<string | null> = (current) => (process.env.NODE_ENV === "test" ? Promise.resolve(null) : checkForUpdate(current));
|
|
110
|
+
/** Keep-awake (/awake). Tests swap its system runner. */
|
|
111
|
+
awake = new Awake({ ...defaultAwakeDeps(), interactive: (fn) => this.withTerminal(fn) });
|
|
92
112
|
private resolveDone!: () => void;
|
|
93
113
|
readonly done: Promise<void>;
|
|
94
114
|
/** Test hooks. */
|
|
@@ -160,7 +180,10 @@ export class ChatApp {
|
|
|
160
180
|
});
|
|
161
181
|
|
|
162
182
|
renderer.keyInput.on("keypress", this.onKey);
|
|
163
|
-
renderer.on("resize", () => this.
|
|
183
|
+
renderer.on("resize", () => this.safely("resize", () => {
|
|
184
|
+
this.palette?.render();
|
|
185
|
+
this.renderFooter();
|
|
186
|
+
}));
|
|
164
187
|
this.input.focus();
|
|
165
188
|
this.renderFooter();
|
|
166
189
|
}
|
|
@@ -177,10 +200,20 @@ export class ChatApp {
|
|
|
177
200
|
if (!first) throw new Error("no agent with a protocol engine found (need claude, codex, opencode, grok or gemini on PATH)");
|
|
178
201
|
const notes: string[] = [];
|
|
179
202
|
if (first !== agent) notes.push(`${agent} has no protocol engine here; starting ${first}`);
|
|
203
|
+
for (const n of await recoverAwake({ ...defaultAwakeDeps(), interactive: (fn) => this.withTerminal(fn) }).catch(() => [])) notes.push(n);
|
|
204
|
+
if (this.config.awake && this.config.awake !== "off") {
|
|
205
|
+
const st = await this.awake.set(this.config.awake).catch(() => null);
|
|
206
|
+
if (st) notes.push(st.mode === "off" ? "keep-awake could not be turned on" : `keeping this machine awake${st.lid ? ", lid closed included" : ""} (/awake off to stop)`, ...st.notes);
|
|
207
|
+
}
|
|
180
208
|
const stripped = strippedKeys();
|
|
181
209
|
if (stripped.length) notes.push(`${stripped.join(", ")} unset for agents · subscription logins only`);
|
|
182
|
-
|
|
210
|
+
// Card border (2) + padding (2) + transcript padding (2): the logo must fit inside without wrapping.
|
|
211
|
+
this.transcript.header({ title: `codemax v${VERSION}`, subtitle: "one terminal, every coding agent", path: this.cwd, notes, logo: logoLines(this.renderer.width - 6) });
|
|
183
212
|
if ((this.config as any).priceUpdates !== false) void refreshLivePricesIfStale().then((l) => l && resetPriceCache());
|
|
213
|
+
if (this.config.updateCheck !== false)
|
|
214
|
+
void this.updateChecker(VERSION)
|
|
215
|
+
.then((v) => v && this.announceUpdate(v))
|
|
216
|
+
.catch(() => {});
|
|
184
217
|
if (o.pick && !fresh && this.openPicker(first)) return this.done;
|
|
185
218
|
await this.launch(first, { fresh });
|
|
186
219
|
return this.done;
|
|
@@ -188,7 +221,29 @@ export class ChatApp {
|
|
|
188
221
|
|
|
189
222
|
// ---------- keys ----------
|
|
190
223
|
|
|
191
|
-
|
|
224
|
+
/**
|
|
225
|
+
* The UI must never break: a failure while handling one event, key or redraw is
|
|
226
|
+
* shown as a short line and codemax carries on.
|
|
227
|
+
*/
|
|
228
|
+
private uiErrors = 0;
|
|
229
|
+
private safely(where: string, fn: () => void) {
|
|
230
|
+
try {
|
|
231
|
+
fn();
|
|
232
|
+
} catch (e: any) {
|
|
233
|
+
this.uiErrors++;
|
|
234
|
+
if (this.uiErrors <= 5) {
|
|
235
|
+
try {
|
|
236
|
+
this.transcript.error(`display glitch in ${where}: ${String(e?.message ?? e).slice(0, 160)} (codemax keeps going)`);
|
|
237
|
+
} catch {
|
|
238
|
+
/* the screen itself is gone */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private onKey = (key: KeyEvent) => this.safely("key handling", () => this.handleKey(key));
|
|
245
|
+
|
|
246
|
+
private handleKey(key: KeyEvent) {
|
|
192
247
|
if (key.eventType === "release") return;
|
|
193
248
|
if (this.permission) {
|
|
194
249
|
key.stopPropagation();
|
|
@@ -262,7 +317,7 @@ export class ChatApp {
|
|
|
262
317
|
this.shortcuts.visible = !this.shortcuts.visible;
|
|
263
318
|
return;
|
|
264
319
|
}
|
|
265
|
-
}
|
|
320
|
+
}
|
|
266
321
|
|
|
267
322
|
private isHotkey(key: KeyEvent): boolean {
|
|
268
323
|
if (key.raw && findHotkey(new TextEncoder().encode(key.raw), this.hotkeys)) return true;
|
|
@@ -309,6 +364,10 @@ export class ChatApp {
|
|
|
309
364
|
else this.palette.setFilter(text.slice(1));
|
|
310
365
|
} else if (this.palette && this.paletteKind === "commands") {
|
|
311
366
|
this.closePalette();
|
|
367
|
+
} else if (this.palette && this.paletteKind !== "handoff" && this.paletteKind !== "files" && !text.includes("\n")) {
|
|
368
|
+
// Every picker filters as you type (agents, models, settings…), like Claude Code's.
|
|
369
|
+
this.palette.setFilter(text);
|
|
370
|
+
this.paletteFiltered = text.length > 0;
|
|
312
371
|
}
|
|
313
372
|
if (this.shortcuts.visible && text) this.shortcuts.visible = false;
|
|
314
373
|
}
|
|
@@ -552,24 +611,30 @@ export class ChatApp {
|
|
|
552
611
|
}
|
|
553
612
|
|
|
554
613
|
/** Startup picker: resume a recent session or start fresh. Returns false when there is nothing to pick. */
|
|
614
|
+
/**
|
|
615
|
+
* Startup: choose the agent to continue with. One row per usable agent, most
|
|
616
|
+
* recently used first, resuming its saved session when there is one.
|
|
617
|
+
*/
|
|
555
618
|
private openPicker(defaultAgent: AgentId): boolean {
|
|
556
|
-
const sessions =
|
|
557
|
-
if (!sessions.length) return false;
|
|
558
|
-
sessions.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
619
|
+
const sessions = this.ledger.sessions();
|
|
559
620
|
const turns = this.ledger.turns();
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
};
|
|
621
|
+
const usable = listAdapters(this.config.order).filter((a) => this.detect.get(a.id)?.installed && this.detect.get(a.id)?.engine);
|
|
622
|
+
if (!usable.length) return false;
|
|
623
|
+
const recent = [...usable].sort((a, b) => (sessions[b.id]?.lastUsedAt ?? 0) - (sessions[a.id]?.lastUsedAt ?? 0));
|
|
624
|
+
const items: PaletteItem[] = recent.map((a, i) => {
|
|
625
|
+
const s = sessions[a.id];
|
|
626
|
+
const last = [...turns].reverse().find((t) => t.agent === a.id && t.role === "user");
|
|
627
|
+
const q = this.quota.get(a.id);
|
|
628
|
+
const bits = [s ? `resume · ${ago(s.lastUsedAt)}${last ? ` · "${last.text.replace(/\s+/g, " ").slice(0, 50)}"` : ""}` : "new session"];
|
|
629
|
+
if (q && quotaLabel(q)) bits.push(quotaLabel(q));
|
|
630
|
+
return { label: a.name, description: bits.join(" · "), value: `resume:${a.id}`, weight: 100 - i };
|
|
568
631
|
});
|
|
569
|
-
|
|
632
|
+
const fresh = recent[0]!;
|
|
633
|
+
items.push({ label: `Start fresh with ${fresh.name}`, description: "new session (history stays in the ledger)", value: `new:${fresh.id}`, weight: 0 });
|
|
634
|
+
defaultAgent = sessions[fresh.id] ? fresh.id : defaultAgent;
|
|
570
635
|
this.paletteKind = "picker";
|
|
571
636
|
this.palette = new Palette(this.renderer, {
|
|
572
|
-
title:
|
|
637
|
+
title: `Choose an agent to continue with · Enter to start · Esc for ${getAdapter(defaultAgent).name}`,
|
|
573
638
|
items,
|
|
574
639
|
onPick: (it) => {
|
|
575
640
|
this.closePalette();
|
|
@@ -578,7 +643,7 @@ export class ChatApp {
|
|
|
578
643
|
},
|
|
579
644
|
onClose: () => {
|
|
580
645
|
this.closePalette();
|
|
581
|
-
void this.launch(
|
|
646
|
+
void this.launch(defaultAgent, { fresh: false });
|
|
582
647
|
},
|
|
583
648
|
}, this.paletteAnchor);
|
|
584
649
|
this.renderer.root.add(this.palette.box);
|
|
@@ -598,6 +663,7 @@ export class ChatApp {
|
|
|
598
663
|
}
|
|
599
664
|
|
|
600
665
|
private async send(active: Active, text: string) {
|
|
666
|
+
if (this.closed) return;
|
|
601
667
|
this.transcript.user(text);
|
|
602
668
|
text = this.withAttachments(text);
|
|
603
669
|
const cp = await this.checkpoint(active, text);
|
|
@@ -635,11 +701,55 @@ export class ChatApp {
|
|
|
635
701
|
{ name: "/checkpoints", description: "List working-tree snapshots taken before each turn", run: () => void this.showCheckpoints() },
|
|
636
702
|
{ name: "/ask", description: "Send one message to another agent without switching: /ask codex <msg>", run: (a) => void this.ask(a) },
|
|
637
703
|
{ name: "/budget", description: "Today's spend against your daily budgets", run: () => this.showBudget() },
|
|
704
|
+
{ name: "/update", description: "Update codemax to the latest version", run: () => void this.runUpdate() },
|
|
705
|
+
{ name: "/config", description: "Settings: theme, keep awake, side agent and model, handoffs, checkpoints…", run: () => this.openConfig() },
|
|
706
|
+
{ name: "/side", description: "Side agent that reviews each turn: /side (pick agent and model) · /side codex <model> [auto] · /side off", run: (a) => void this.setSide(a) },
|
|
707
|
+
{ name: "/review", description: "Ask the side agent to review the latest work now", run: () => void this.reviewNow() },
|
|
708
|
+
{ name: "/notes", description: "Notes the side agent left", run: () => this.showNotes() },
|
|
709
|
+
{ name: "/forward", description: "Send the side agent's latest note (or /forward <n>) to the main agent", run: (a) => this.forwardNote(a.trim() ? Number(a) - 1 : undefined) },
|
|
710
|
+
{ name: "/awake", description: "Keep the machine awake while agents work: /awake on | lid | off", run: (a) => void this.setAwake(a) },
|
|
638
711
|
{ name: "/theme", description: "Colour theme: /theme dark | light | auto (saved; restart to apply everywhere)", run: (a) => this.setThemePref(a) },
|
|
639
712
|
{ name: "/quit", description: "Quit codemax", run: () => void this.quit() },
|
|
640
713
|
];
|
|
641
714
|
}
|
|
642
715
|
|
|
716
|
+
/** Run something that needs the real terminal (a sudo password prompt): pause the screen around it. */
|
|
717
|
+
private async withTerminal<T>(fn: () => Promise<T>): Promise<T> {
|
|
718
|
+
try {
|
|
719
|
+
await this.renderer.suspend();
|
|
720
|
+
} catch {
|
|
721
|
+
/* test renderer */
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
return await fn();
|
|
725
|
+
} finally {
|
|
726
|
+
try {
|
|
727
|
+
await this.renderer.resume();
|
|
728
|
+
} catch {
|
|
729
|
+
/* test renderer */
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private async setAwake(arg: string) {
|
|
735
|
+
const v = arg.trim().toLowerCase();
|
|
736
|
+
if (v !== "on" && v !== "lid" && v !== "off") {
|
|
737
|
+
const s = this.awake.status;
|
|
738
|
+
this.transcript.note(`awake: ${s.mode}${s.lid ? " (lid closed included)" : ""} · /awake on keeps the machine from sleeping · /awake lid also keeps working with the lid closed · /awake off`);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const st = await this.awake.set(v);
|
|
742
|
+
try {
|
|
743
|
+
saveConfigKey("awake", st.mode);
|
|
744
|
+
} catch {
|
|
745
|
+
/* read-only home */
|
|
746
|
+
}
|
|
747
|
+
this.config.awake = st.mode;
|
|
748
|
+
this.transcript.note(st.mode === "off" ? (v === "off" ? "awake off: the machine can sleep normally again" : "keep-awake could not be turned on") : `awake ${st.mode}: this machine will not sleep while codemax runs${st.lid ? ", even with the lid closed" : ""}`, st.mode === "off" ? C.dim : C.accent);
|
|
749
|
+
for (const n of st.notes) this.transcript.note(n, C.warn);
|
|
750
|
+
this.renderFooter();
|
|
751
|
+
}
|
|
752
|
+
|
|
643
753
|
private setThemePref(arg: string) {
|
|
644
754
|
const v = arg.trim().toLowerCase();
|
|
645
755
|
if (v !== "dark" && v !== "light" && v !== "auto") {
|
|
@@ -950,10 +1060,15 @@ export class ChatApp {
|
|
|
950
1060
|
this.renderer.root.add(this.palette.box);
|
|
951
1061
|
}
|
|
952
1062
|
|
|
1063
|
+
/** True while text in the prompt is only filtering a picker; it is cleared when the picker closes. */
|
|
1064
|
+
private paletteFiltered = false;
|
|
1065
|
+
|
|
953
1066
|
private closePalette(_focus = true) {
|
|
954
1067
|
this.palette?.destroy();
|
|
955
1068
|
this.palette = null;
|
|
956
1069
|
this.paletteKind = null;
|
|
1070
|
+
if (this.paletteFiltered) this.input.clear();
|
|
1071
|
+
this.paletteFiltered = false;
|
|
957
1072
|
this.input.focus();
|
|
958
1073
|
}
|
|
959
1074
|
|
|
@@ -1035,6 +1150,7 @@ export class ChatApp {
|
|
|
1035
1150
|
}
|
|
1036
1151
|
|
|
1037
1152
|
private async runTurn(active: Active, text: string, o: { record?: boolean; rec?: TurnRecorder; journal?: boolean } = {}) {
|
|
1153
|
+
if (this.closed) return;
|
|
1038
1154
|
const block = this.transcript.agent();
|
|
1039
1155
|
const rec = o.journal === false ? undefined : (o.rec ?? new TurnRecorder(this.ledger.journal, active.agent));
|
|
1040
1156
|
active.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: pick(VERBS), rec };
|
|
@@ -1057,6 +1173,14 @@ export class ChatApp {
|
|
|
1057
1173
|
const reply = block.text().trim();
|
|
1058
1174
|
if (isLimitNotice(reply)) turn.rec?.markLimit(reply);
|
|
1059
1175
|
turn.rec?.end(stop);
|
|
1176
|
+
// The side agent reviews main-agent turns that did work (not /ask, not summaries).
|
|
1177
|
+
// A turn that only answers a forwarded note is not reviewed: note → turn → review → note
|
|
1178
|
+
// would loop on both plans. Its work is covered by the next review after a user turn.
|
|
1179
|
+
if (turn.rec && this.active === active && !this.side.has(active) && !isForwardedNote(text) && (turn.tools.size > 0 || reply.length > 0)) {
|
|
1180
|
+
const every = this.config.sideAgent?.every ?? 1;
|
|
1181
|
+
this.turnsSinceReview++;
|
|
1182
|
+
if (every > 0 && this.turnsSinceReview >= every) void this.review();
|
|
1183
|
+
}
|
|
1060
1184
|
if (isLimitNotice(reply)) {
|
|
1061
1185
|
// The agent ended the turn with only a limit notice: treat it as running out, and leave
|
|
1062
1186
|
// the user's request unanswered in the ledger so the next agent is asked to finish it.
|
|
@@ -1076,6 +1200,10 @@ export class ChatApp {
|
|
|
1076
1200
|
}
|
|
1077
1201
|
|
|
1078
1202
|
private onEngineEvent(active: Active, e: EngineEvent) {
|
|
1203
|
+
this.safely(`${active.name} event`, () => this.handleEngineEvent(active, e));
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
private handleEngineEvent(active: Active, e: EngineEvent) {
|
|
1079
1207
|
if (this.active !== active && !this.side.has(active) && e.type !== "permission_request") return;
|
|
1080
1208
|
const target = () => active.turn?.block ?? (active.replay ??= this.transcript.agent());
|
|
1081
1209
|
switch (e.type) {
|
|
@@ -1124,27 +1252,7 @@ export class ChatApp {
|
|
|
1124
1252
|
this.renderFooter();
|
|
1125
1253
|
}
|
|
1126
1254
|
if (typeof e.usedPercent === "number") this.onQuota({ agent: active.agent, usedPercent: e.usedPercent, exhausted: e.usedPercent >= 100, source: "probe", at: Date.now() });
|
|
1127
|
-
|
|
1128
|
-
// A zero cost with no tokens (free models report cost 0 on every update) is not usage.
|
|
1129
|
-
if (hasTokens || (e.costUsd ?? 0) > 0) {
|
|
1130
|
-
const rec = priceRecord({
|
|
1131
|
-
ts: Date.now(),
|
|
1132
|
-
agent: active.agent,
|
|
1133
|
-
sessionId: active.engine.session?.id ?? "?",
|
|
1134
|
-
model: e.model ?? active.engine.info?.currentModel,
|
|
1135
|
-
input: e.inputTokens ?? 0,
|
|
1136
|
-
output: e.outputTokens ?? 0,
|
|
1137
|
-
cacheRead: e.cacheReadTokens ?? 0,
|
|
1138
|
-
cacheWrite: e.cacheWriteTokens ?? 0,
|
|
1139
|
-
agentCostUsd: typeof e.costUsd === "number" ? e.costUsd : undefined,
|
|
1140
|
-
});
|
|
1141
|
-
this.ledger.addUsage(rec);
|
|
1142
|
-
this.sessionCost += recordCost(rec).usd;
|
|
1143
|
-
this.sessionTokens += rec.input + rec.output + rec.cacheRead + rec.cacheWrite;
|
|
1144
|
-
addSpend(this.today, rec);
|
|
1145
|
-
this.renderFooter();
|
|
1146
|
-
this.checkBudget(active.agent);
|
|
1147
|
-
}
|
|
1255
|
+
this.recordUsage(active.agent, active.engine, e);
|
|
1148
1256
|
break;
|
|
1149
1257
|
}
|
|
1150
1258
|
case "session":
|
|
@@ -1205,6 +1313,393 @@ export class ChatApp {
|
|
|
1205
1313
|
|
|
1206
1314
|
// ---------- working line, footer ----------
|
|
1207
1315
|
|
|
1316
|
+
/** Price and record a usage event (main, /ask and side-agent sessions alike). */
|
|
1317
|
+
private recordUsage(agent: AgentId, engine: Engine, e: Extract<EngineEvent, { type: "usage" }>) {
|
|
1318
|
+
const hasTokens = (e.inputTokens ?? 0) + (e.outputTokens ?? 0) + (e.cacheReadTokens ?? 0) + (e.cacheWriteTokens ?? 0) > 0;
|
|
1319
|
+
// A zero cost with no tokens (free models report cost 0 on every update) is not usage.
|
|
1320
|
+
if (!hasTokens && !((e.costUsd ?? 0) > 0)) return;
|
|
1321
|
+
const rec = priceRecord({
|
|
1322
|
+
ts: Date.now(),
|
|
1323
|
+
agent,
|
|
1324
|
+
sessionId: engine.session?.id ?? "?",
|
|
1325
|
+
model: e.model ?? engine.info?.currentModel,
|
|
1326
|
+
input: e.inputTokens ?? 0,
|
|
1327
|
+
output: e.outputTokens ?? 0,
|
|
1328
|
+
cacheRead: e.cacheReadTokens ?? 0,
|
|
1329
|
+
cacheWrite: e.cacheWriteTokens ?? 0,
|
|
1330
|
+
agentCostUsd: typeof e.costUsd === "number" ? e.costUsd : undefined,
|
|
1331
|
+
});
|
|
1332
|
+
this.ledger.addUsage(rec);
|
|
1333
|
+
this.sessionCost += recordCost(rec).usd;
|
|
1334
|
+
this.sessionTokens += rec.input + rec.output + rec.cacheRead + rec.cacheWrite;
|
|
1335
|
+
addSpend(this.today, rec);
|
|
1336
|
+
this.renderFooter();
|
|
1337
|
+
this.checkBudget(agent);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// ---------- side agent ----------
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* /side pick the side agent, then its model
|
|
1344
|
+
* /side <agent> [model] [auto|ask]
|
|
1345
|
+
* /side off
|
|
1346
|
+
*/
|
|
1347
|
+
private async setSide(arg: string) {
|
|
1348
|
+
const words = arg.trim().split(/\s+/).filter(Boolean);
|
|
1349
|
+
const cfg = this.config.sideAgent ?? { agent: null, forward: "ask" as const };
|
|
1350
|
+
if (!words.length) return this.openSidePicker();
|
|
1351
|
+
const who = words[0]!.toLowerCase();
|
|
1352
|
+
if (who === "off") {
|
|
1353
|
+
await this.closeReviewer();
|
|
1354
|
+
this.saveSide({ ...cfg, agent: null, model: undefined });
|
|
1355
|
+
this.transcript.note("side agent off", C.dim);
|
|
1356
|
+
return this.renderFooter();
|
|
1357
|
+
}
|
|
1358
|
+
if (!this.detect.get(who as AgentId)?.engine) return this.transcript.note(`no protocol engine for "${who}" · /side to pick one`, C.warn);
|
|
1359
|
+
const rest = words.slice(1);
|
|
1360
|
+
const forward = rest.includes("auto") ? "auto" : rest.includes("ask") ? "ask" : cfg.agent === who ? cfg.forward : "ask";
|
|
1361
|
+
const model = rest.find((w) => w !== "auto" && w !== "ask");
|
|
1362
|
+
await this.enableSide(who as AgentId, { forward, model, pickModel: false });
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
private saveSide(v: Config["sideAgent"]) {
|
|
1366
|
+
this.config.sideAgent = v;
|
|
1367
|
+
try {
|
|
1368
|
+
saveConfigKey("sideAgent", v);
|
|
1369
|
+
} catch {
|
|
1370
|
+
/* read-only home */
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
private async enableSide(agent: AgentId, o: { forward: "ask" | "auto"; model?: string; pickModel: boolean }) {
|
|
1375
|
+
const prev = this.config.sideAgent;
|
|
1376
|
+
if (prev?.agent !== agent || (o.model && o.model !== prev.model)) await this.closeReviewer();
|
|
1377
|
+
// Keep the user's other side-agent settings (review cadence) when switching agent.
|
|
1378
|
+
this.saveSide({ ...prev, agent, forward: o.forward, model: o.model ?? (prev?.agent === agent ? prev.model : undefined) });
|
|
1379
|
+
const name = getAdapter(agent).name;
|
|
1380
|
+
if (agent === this.active?.agent) this.transcript.note(`${name} is also the main agent: both use one ${name} plan, so each review brings the main agent's limit closer. A different subscription (or a cheaper model: /side ${agent} <model>, or fewer reviews in /config) keeps it from running out sooner.`, C.warn);
|
|
1381
|
+
this.transcript.note(`side agent: ${name}${this.config.sideAgent.model ? ` (${this.config.sideAgent.model})` : ""} reviews ${reviewCadence(this.config.sideAgent.every)} and speaks up only when something needs attention${o.forward === "auto" ? "; its notes go to the main agent automatically" : " · /forward sends a note to the main agent"}`, C.accent);
|
|
1382
|
+
this.renderFooter();
|
|
1383
|
+
if (!o.pickModel) return;
|
|
1384
|
+
// Start the reviewer now so its real model list can be offered.
|
|
1385
|
+
const r = await this.ensureReviewer();
|
|
1386
|
+
const models = r?.engine.info?.models ?? [];
|
|
1387
|
+
if (!r || models.length < 2) {
|
|
1388
|
+
if (r && models.length === 1) this.transcript.note(`${name} offers one model on your plan: ${models[0]!.name}`);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
this.closePalette();
|
|
1392
|
+
this.paletteKind = "modes";
|
|
1393
|
+
this.palette = new Palette(this.renderer, {
|
|
1394
|
+
title: `Model for the side agent (${name}) · Esc keeps ${shortModelName(r.engine.info?.currentModel ?? "default")}`,
|
|
1395
|
+
items: models.map((m) => ({ label: m.id, description: `${m.name !== m.id ? m.name : ""}${m.id === r.engine.info?.currentModel ? " (current)" : ""}`, value: m.id })),
|
|
1396
|
+
onPick: (it) => {
|
|
1397
|
+
this.closePalette();
|
|
1398
|
+
void this.setSideModel(it.value);
|
|
1399
|
+
},
|
|
1400
|
+
onClose: () => this.closePalette(),
|
|
1401
|
+
}, this.paletteAnchor);
|
|
1402
|
+
this.renderer.root.add(this.palette.box);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
private async setSideModel(model: string) {
|
|
1406
|
+
const cfg = this.config.sideAgent;
|
|
1407
|
+
if (!cfg?.agent) return this.transcript.note("no side agent · /side to pick one", C.warn);
|
|
1408
|
+
this.saveSide({ ...cfg, model });
|
|
1409
|
+
const r = this.reviewer;
|
|
1410
|
+
try {
|
|
1411
|
+
if (r?.engine.setModel) await r.engine.setModel(model);
|
|
1412
|
+
this.transcript.note(`side agent model: ${model}`);
|
|
1413
|
+
if (cfg.agent === "cursor") this.transcript.note("Cursor saves this as its default model, so Cursor uses it outside codemax too", C.warn);
|
|
1414
|
+
} catch (e: any) {
|
|
1415
|
+
this.transcript.error(`could not set the side agent's model: ${e?.message ?? e}`);
|
|
1416
|
+
}
|
|
1417
|
+
this.renderFooter();
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// ---------- settings (/config) ----------
|
|
1421
|
+
|
|
1422
|
+
private settings(): { key: string; label: string; value: string; choices: { value: string; label?: string; description?: string }[]; apply: (v: string) => void | Promise<void> }[] {
|
|
1423
|
+
const c = this.config;
|
|
1424
|
+
const onOff = (b: boolean) => (b ? "on" : "off");
|
|
1425
|
+
const bool = (key: "autoSwitch" | "summarizeOnSwitch" | "checkpoints" | "priceUpdates") => (v: string) => {
|
|
1426
|
+
c[key] = v === "on";
|
|
1427
|
+
saveConfigKey(key, c[key]);
|
|
1428
|
+
};
|
|
1429
|
+
const side = c.sideAgent ?? { agent: null, forward: "ask" as const };
|
|
1430
|
+
const agents = listAdapters(c.order).filter((a) => this.detect.get(a.id)?.engine);
|
|
1431
|
+
return [
|
|
1432
|
+
{ key: "theme", label: "Theme", value: c.theme ?? "auto", choices: [{ value: "auto", description: "follow the terminal's background" }, { value: "dark" }, { value: "light" }], apply: (v) => this.setThemePref(v) },
|
|
1433
|
+
{ key: "awake", label: "Keep awake", value: c.awake ?? "off", choices: [{ value: "off", description: "sleep normally" }, { value: "on", description: "no sleep while codemax runs" }, { value: "lid", description: "also keep working with the lid closed" }], apply: (v) => this.setAwake(v) },
|
|
1434
|
+
{ key: "side", label: "Side agent", value: side.agent ?? "off", choices: [{ value: "off" }, ...agents.map((a) => ({ value: a.id, label: a.name, description: "pick its model next" }))], apply: (v) => (v === "off" ? this.setSide("off") : this.enableSide(v as AgentId, { forward: side.forward, pickModel: true })) },
|
|
1435
|
+
{ key: "sideModel", label: "Side agent model", value: side.agent ? (side.model ?? "default") : "(no side agent)", choices: side.agent ? [{ value: "pick", label: "Choose from its models", description: "starts the side agent to list them" }] : [], apply: async () => { if (side.agent) await this.enableSide(side.agent, { forward: side.forward, pickModel: true }); } },
|
|
1436
|
+
{
|
|
1437
|
+
key: "sideEvery",
|
|
1438
|
+
label: "Side agent reviews",
|
|
1439
|
+
value: reviewCadence(side.every),
|
|
1440
|
+
choices: [{ value: "1", label: "every turn" }, { value: "2", label: "every 2nd turn" }, { value: "3", label: "every 3rd turn" }, { value: "0", label: "only on /review", description: "no automatic reviews" }],
|
|
1441
|
+
apply: (v) => this.saveSide({ ...side, every: Number(v) }),
|
|
1442
|
+
},
|
|
1443
|
+
{ key: "sideForward", label: "Side agent notes", value: side.forward, choices: [{ value: "ask", description: "wait for /forward" }, { value: "auto", description: "send each note to the main agent" }], apply: (v) => this.saveSide({ ...side, forward: v === "auto" ? "auto" : "ask" }) },
|
|
1444
|
+
{ key: "autoSwitch", label: "Offer a handoff at limits", value: onOff(c.autoSwitch), choices: [{ value: "on" }, { value: "off" }], apply: bool("autoSwitch") },
|
|
1445
|
+
{ key: "summarizeOnSwitch", label: "Summary before switching", value: onOff(c.summarizeOnSwitch), choices: [{ value: "on", description: "only for history without a step journal" }, { value: "off" }], apply: bool("summarizeOnSwitch") },
|
|
1446
|
+
{ key: "checkpoints", label: "Snapshots for /undo", value: onOff(c.checkpoints), choices: [{ value: "on" }, { value: "off", description: "also turns off code diffs in handoffs" }], apply: bool("checkpoints") },
|
|
1447
|
+
{
|
|
1448
|
+
key: "bell",
|
|
1449
|
+
label: "Bell after long turns",
|
|
1450
|
+
value: c.bellAfterSeconds ? `${c.bellAfterSeconds}s` : "off",
|
|
1451
|
+
choices: [{ value: "0", label: "off" }, { value: "10", label: "10s" }, { value: "20", label: "20s" }, { value: "60", label: "60s" }],
|
|
1452
|
+
apply: (v) => {
|
|
1453
|
+
c.bellAfterSeconds = Number(v);
|
|
1454
|
+
saveConfigKey("bellAfterSeconds", c.bellAfterSeconds);
|
|
1455
|
+
},
|
|
1456
|
+
},
|
|
1457
|
+
{ key: "updateCheck", label: "Check for codemax updates", value: c.updateCheck === false ? "off" : "on", choices: [{ value: "on", description: "asks npm at most every 12 hours" }, { value: "off" }], apply: (v) => { c.updateCheck = v === "on"; saveConfigKey("updateCheck", c.updateCheck); } },
|
|
1458
|
+
{ key: "priceUpdates", label: "Weekly price list refresh", value: onOff(c.priceUpdates), choices: [{ value: "on" }, { value: "off", description: "stay fully offline" }], apply: bool("priceUpdates") },
|
|
1459
|
+
];
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** /config: every setting with its value; pick one to change it. */
|
|
1463
|
+
private openConfig() {
|
|
1464
|
+
const all = this.settings();
|
|
1465
|
+
this.closePalette();
|
|
1466
|
+
this.paletteKind = "modes";
|
|
1467
|
+
this.palette = new Palette(this.renderer, {
|
|
1468
|
+
title: `Settings · Enter to change · saved to ${CONFIG_PATH.replace(homedir(), "~")}`,
|
|
1469
|
+
// Weights keep the settings in this order (the palette otherwise sorts by label).
|
|
1470
|
+
items: all.map((x, i) => ({ label: x.label, description: x.value, value: x.key, disabled: !x.choices.length, weight: 100 - i })),
|
|
1471
|
+
maxRows: 12,
|
|
1472
|
+
onPick: (it) => {
|
|
1473
|
+
const st = all.find((x) => x.key === it.value);
|
|
1474
|
+
if (st) this.openSetting(st);
|
|
1475
|
+
},
|
|
1476
|
+
onClose: () => this.closePalette(),
|
|
1477
|
+
}, this.paletteAnchor);
|
|
1478
|
+
this.renderer.root.add(this.palette.box);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
private openSetting(st: ReturnType<ChatApp["settings"]>[number]) {
|
|
1482
|
+
this.closePalette();
|
|
1483
|
+
this.paletteKind = "modes";
|
|
1484
|
+
this.palette = new Palette(this.renderer, {
|
|
1485
|
+
title: `${st.label} · now ${st.value} · Esc back to settings`,
|
|
1486
|
+
items: st.choices.map((ch, i) => ({ label: ch.label ?? ch.value, description: `${ch.description ?? ""}${(ch.label ?? ch.value) === st.value || ch.value === st.value ? " (current)" : ""}`, value: ch.value, weight: 100 - i })),
|
|
1487
|
+
onPick: (it) => {
|
|
1488
|
+
this.closePalette();
|
|
1489
|
+
void (async () => {
|
|
1490
|
+
try {
|
|
1491
|
+
await st.apply(it.value);
|
|
1492
|
+
if (!["theme", "awake", "side", "sideModel"].includes(st.key)) this.transcript.note(`${st.label.toLowerCase()}: ${it.label}`);
|
|
1493
|
+
} catch (e: any) {
|
|
1494
|
+
this.transcript.error(`could not change ${st.label.toLowerCase()}: ${e?.message ?? e}`);
|
|
1495
|
+
}
|
|
1496
|
+
this.renderFooter();
|
|
1497
|
+
// Back to the list for the next change, unless a follow-up picker took over.
|
|
1498
|
+
if (!this.palette) this.openConfig();
|
|
1499
|
+
})();
|
|
1500
|
+
},
|
|
1501
|
+
onClose: () => this.openConfig(),
|
|
1502
|
+
}, this.paletteAnchor);
|
|
1503
|
+
this.renderer.root.add(this.palette.box);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/** /side with no arguments: pick the agent (then its model). */
|
|
1507
|
+
private openSidePicker() {
|
|
1508
|
+
const cfg = this.config.sideAgent;
|
|
1509
|
+
const items: PaletteItem[] = listAdapters(this.config.order)
|
|
1510
|
+
.filter((a) => this.detect.get(a.id)?.engine)
|
|
1511
|
+
// A different subscription first: the same tool would share (and drain) the main agent's plan.
|
|
1512
|
+
.map((a) => ({ label: a.name + (a.id === cfg?.agent ? " (current)" : ""), description: a.id === this.active?.agent ? "same plan as the main agent: reviews count against its limit" : "its own subscription: reviews do not touch the main agent's limit", value: a.id, weight: a.id === this.active?.agent ? 0 : 10 }));
|
|
1513
|
+
items.push({ label: "Off", description: "no side agent", value: "off" });
|
|
1514
|
+
this.closePalette();
|
|
1515
|
+
this.paletteKind = "modes";
|
|
1516
|
+
this.palette = new Palette(this.renderer, {
|
|
1517
|
+
title: "Side agent · reviews each turn and leaves a note when something needs attention",
|
|
1518
|
+
items,
|
|
1519
|
+
onPick: (it) => {
|
|
1520
|
+
this.closePalette();
|
|
1521
|
+
if (it.value === "off") return void this.setSide("off");
|
|
1522
|
+
void this.enableSide(it.value as AgentId, { forward: cfg?.forward ?? "ask", pickModel: true });
|
|
1523
|
+
},
|
|
1524
|
+
onClose: () => this.closePalette(),
|
|
1525
|
+
}, this.paletteAnchor);
|
|
1526
|
+
this.renderer.root.add(this.palette.box);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** The side agent's session, started on first use with the chosen model. */
|
|
1530
|
+
private async ensureReviewer() {
|
|
1531
|
+
const cfg = this.config.sideAgent;
|
|
1532
|
+
if (!cfg?.agent || this.closed) return null;
|
|
1533
|
+
if (this.reviewer?.agent === cfg.agent) return this.reviewer;
|
|
1534
|
+
await this.closeReviewer();
|
|
1535
|
+
if (this.checkBlockers(cfg.agent).length) return null;
|
|
1536
|
+
const engine = this.engineFactory(cfg.agent);
|
|
1537
|
+
const r = { agent: cfg.agent, name: getAdapter(cfg.agent).name, engine, busy: false, pending: false, since: this.lastReviewBaseline(), primed: false };
|
|
1538
|
+
this.reviewer = r;
|
|
1539
|
+
try {
|
|
1540
|
+
await engine.start({ cwd: this.cwd });
|
|
1541
|
+
if (cfg.model && engine.setModel && engine.info?.currentModel !== cfg.model) await engine.setModel(cfg.model).catch((e: any) => this.transcript.note(`side agent: could not use model ${cfg.model} (${e?.message ?? e}); using ${engine.info?.currentModel ?? "its default"}`, C.warn));
|
|
1542
|
+
return r;
|
|
1543
|
+
} catch (e: any) {
|
|
1544
|
+
if (this.reviewer === r) this.reviewer = null;
|
|
1545
|
+
this.transcript.note(`side agent ${r.name} could not start: ${e?.message ?? e}`, C.warn);
|
|
1546
|
+
return null;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
private async closeReviewer() {
|
|
1551
|
+
const r = this.reviewer;
|
|
1552
|
+
this.reviewer = null;
|
|
1553
|
+
if (r) await r.engine.close().catch(() => {});
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** After a main turn: let the side agent review the new steps (in the background). */
|
|
1557
|
+
private async review() {
|
|
1558
|
+
const cfg = this.config.sideAgent;
|
|
1559
|
+
if (!cfg?.agent || this.closed) return;
|
|
1560
|
+
if (this.reviewer?.busy) {
|
|
1561
|
+
this.reviewer.pending = true;
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
const r = await this.ensureReviewer();
|
|
1565
|
+
if (!r) return;
|
|
1566
|
+
this.turnsSinceReview = 0;
|
|
1567
|
+
r.busy = true;
|
|
1568
|
+
this.renderFooter();
|
|
1569
|
+
try {
|
|
1570
|
+
do {
|
|
1571
|
+
r.pending = false;
|
|
1572
|
+
const steps = this.ledger.journal.steps();
|
|
1573
|
+
const startedAt = Date.now();
|
|
1574
|
+
const context = r.primed ? undefined : await buildBrief({ ledger: this.ledger, cwd: this.cwd, to: r.agent, config: this.config, purpose: "context" }).catch(() => undefined);
|
|
1575
|
+
const prompt = buildReviewPrompt({ steps, since: r.since, mainAgent: this.active?.name ?? "the main agent", reviewer: r.agent, context });
|
|
1576
|
+
if (!prompt) break;
|
|
1577
|
+
let reply = "";
|
|
1578
|
+
let limited = false;
|
|
1579
|
+
const engine = r.engine;
|
|
1580
|
+
const off = engine.onEvent((e) => {
|
|
1581
|
+
try {
|
|
1582
|
+
if (e.type === "text_delta") reply += e.text;
|
|
1583
|
+
else if (e.type === "permission_request") {
|
|
1584
|
+
// A reviewer never acts: refuse anything it asks to do.
|
|
1585
|
+
const no = e.request.options.find((o) => /reject|deny|decline/i.test(`${o.kind} ${o.id}`)) ?? e.request.options.at(-1);
|
|
1586
|
+
if (no) engine.answerPermission(e.request.id, no.id);
|
|
1587
|
+
} else if (e.type === "usage") this.recordUsage(r.agent, engine, e);
|
|
1588
|
+
else if (e.type === "error" && e.kind === "quota") limited = true;
|
|
1589
|
+
} catch {
|
|
1590
|
+
/* a bad event must never break the reviewer or the screen */
|
|
1591
|
+
}
|
|
1592
|
+
});
|
|
1593
|
+
await engine.prompt(prompt).catch(() => "error");
|
|
1594
|
+
off();
|
|
1595
|
+
if (this.closed) return;
|
|
1596
|
+
r.primed = true;
|
|
1597
|
+
r.since = startedAt;
|
|
1598
|
+
if (limited || isLimitNotice(reply)) {
|
|
1599
|
+
this.transcript.note(`side agent ${r.name} hit its usage limit; turning it off for this session`, C.warn);
|
|
1600
|
+
await this.closeReviewer();
|
|
1601
|
+
this.renderFooter();
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
const note = parseNote(reply);
|
|
1605
|
+
if (note && !this.closed) this.addNote(r.agent, r.name, note);
|
|
1606
|
+
} while (r.pending && this.reviewer === r && !this.closed);
|
|
1607
|
+
} finally {
|
|
1608
|
+
if (this.reviewer === r) r.busy = false;
|
|
1609
|
+
this.renderFooter();
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/** Review from the start of the current task the first time. */
|
|
1614
|
+
private lastReviewBaseline(): number {
|
|
1615
|
+
const req = this.ledger.journal.task().request;
|
|
1616
|
+
return req ? req.ts - 1 : Date.now();
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
private addNote(agent: AgentId, name: string, text: string) {
|
|
1620
|
+
if (this.closed) return;
|
|
1621
|
+
this.notes.push({ ts: Date.now(), agent, name, text });
|
|
1622
|
+
this.ledger.addEvent({ kind: "note", agent, detail: `side agent note: ${text.slice(0, 2000)}` });
|
|
1623
|
+
const main = this.active?.name ?? "the main agent";
|
|
1624
|
+
const auto = this.config.sideAgent?.forward === "auto";
|
|
1625
|
+
const task = this.ledger.journal.task().request?.id ?? "none";
|
|
1626
|
+
const sent = this.autoForwards.get(task) ?? 0;
|
|
1627
|
+
const capped = auto && sent >= AUTO_FORWARD_CAP;
|
|
1628
|
+
this.transcript.noteCard(
|
|
1629
|
+
`⚑ Note from ${name} (side agent)`,
|
|
1630
|
+
text,
|
|
1631
|
+
auto && !capped ? `sent to ${main} automatically` : capped ? `auto-forward paused: ${AUTO_FORWARD_CAP} notes already sent for this task · /forward to send this one` : `/forward sends this to ${main} · /notes lists all notes`,
|
|
1632
|
+
);
|
|
1633
|
+
if (auto && !capped) {
|
|
1634
|
+
this.autoForwards.set(task, sent + 1);
|
|
1635
|
+
this.forwardNote(this.notes.length - 1);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
private forwardNote(index = this.notes.length - 1) {
|
|
1640
|
+
if (this.closed) return;
|
|
1641
|
+
const n = this.notes[index];
|
|
1642
|
+
const a = this.active;
|
|
1643
|
+
if (!n) return this.transcript.note("no side-agent notes yet");
|
|
1644
|
+
if (!a) return this.transcript.note("no main agent to forward to", C.warn);
|
|
1645
|
+
const msg = forwardMessage(n.text, n.name);
|
|
1646
|
+
if (a.turn || this.switching) {
|
|
1647
|
+
this.queue.push(msg);
|
|
1648
|
+
this.transcript.note(`note queued for ${a.name} until its turn ends`);
|
|
1649
|
+
this.renderFooter();
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
void this.send(a, msg);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
private announceUpdate(version: string) {
|
|
1656
|
+
if (this.closed) return;
|
|
1657
|
+
this.updateAvailable = version;
|
|
1658
|
+
this.transcript.note(`codemax ${version} is available (you have ${VERSION}) · run /update to install it`, C.accent);
|
|
1659
|
+
this.renderFooter();
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/** /update: update the way codemax was installed, with its output on the real terminal. */
|
|
1663
|
+
private async runUpdate() {
|
|
1664
|
+
const plan = updatePlan();
|
|
1665
|
+
if (plan.manual) return this.transcript.note(plan.manual, C.warn);
|
|
1666
|
+
if (this.active?.turn) return this.transcript.note("an agent is working; /update when the turn ends", C.warn);
|
|
1667
|
+
this.transcript.note(`updating with ${plan.label}…`);
|
|
1668
|
+
let ok = true;
|
|
1669
|
+
await this.withTerminal(async () => {
|
|
1670
|
+
process.stdout.write(`\ncodemax: updating with ${plan.label}\n\n`);
|
|
1671
|
+
for (const cmd of plan.cmd) {
|
|
1672
|
+
const p = Bun.spawn(platformArgv(cmd), { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: { ...process.env, ...(plan.env ?? {}) } });
|
|
1673
|
+
if ((await p.exited) !== 0) {
|
|
1674
|
+
ok = false;
|
|
1675
|
+
break;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
process.stdout.write(ok ? "\ncodemax: done. Returning to codemax…\n" : "\ncodemax: the update failed (see above). Returning to codemax…\n");
|
|
1679
|
+
await Bun.sleep(1200);
|
|
1680
|
+
});
|
|
1681
|
+
if (ok) {
|
|
1682
|
+
const to = this.updateAvailable;
|
|
1683
|
+
this.updateAvailable = null;
|
|
1684
|
+
this.transcript.note(`updated${to ? ` to ${to}` : ""} · restart codemax (/quit, then codemax) to use the new version`, C.ok);
|
|
1685
|
+
} else this.transcript.error(`update failed · run it yourself: ${plan.cmd.map((c) => c.join(" ")).join(" && ")}`);
|
|
1686
|
+
this.renderFooter();
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
private async reviewNow() {
|
|
1690
|
+
if (!this.config.sideAgent?.agent) return this.transcript.note("no side agent · /side to pick one", C.warn);
|
|
1691
|
+
this.transcript.note(`asking ${getAdapter(this.config.sideAgent.agent).name} to review…`);
|
|
1692
|
+
const before = this.notes.length;
|
|
1693
|
+
await this.review();
|
|
1694
|
+
if (this.notes.length === before && !this.closed) this.transcript.note("the side agent found nothing worth a note");
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
private showNotes() {
|
|
1698
|
+
if (!this.notes.length) return this.transcript.note(this.config.sideAgent?.agent ? "no notes yet: the side agent only speaks up when something needs attention" : "no side agent · /side codex adds one");
|
|
1699
|
+
this.notes.forEach((n, i) => this.transcript.note(`${i + 1}. ${fmtTime(n.ts)} ${n.name}: ${n.text.split("\n")[0]!.slice(0, 140)}`));
|
|
1700
|
+
this.transcript.note("/forward <n> sends a note to the main agent");
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1208
1703
|
private startSpinner(active: Active) {
|
|
1209
1704
|
this.stopSpinner();
|
|
1210
1705
|
this.spinnerTimer = setInterval(() => {
|
|
@@ -1240,6 +1735,7 @@ export class ChatApp {
|
|
|
1240
1735
|
}
|
|
1241
1736
|
|
|
1242
1737
|
private setWorking(text: string | null) {
|
|
1738
|
+
if (this.closed && text) return;
|
|
1243
1739
|
this.working.visible = !!text;
|
|
1244
1740
|
this.working.content = text ?? "";
|
|
1245
1741
|
}
|
|
@@ -1255,6 +1751,10 @@ export class ChatApp {
|
|
|
1255
1751
|
}
|
|
1256
1752
|
|
|
1257
1753
|
private renderFooter() {
|
|
1754
|
+
this.safely("footer", () => this.drawFooter());
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
private drawFooter() {
|
|
1258
1758
|
const a = this.active;
|
|
1259
1759
|
const width = Math.max(20, this.renderer.width - 4);
|
|
1260
1760
|
let color: string = C.dim;
|
|
@@ -1270,6 +1770,10 @@ export class ChatApp {
|
|
|
1270
1770
|
else if (this.queue.length) left.push({ text: `${this.queue.length} queued`, prio: 1 });
|
|
1271
1771
|
else if (!this.shortcuts.visible) left.push({ text: "? for shortcuts", prio: 2 });
|
|
1272
1772
|
const right: { text: string; prio: number }[] = [];
|
|
1773
|
+
if (this.updateAvailable) left.push({ text: `↑ ${this.updateAvailable} available · /update`, prio: 1 });
|
|
1774
|
+
if (this.config.sideAgent?.agent) left.push({ text: `◆ side ${this.config.sideAgent.agent}${this.config.sideAgent.model ? ` ${shortModelName(this.config.sideAgent.model)}` : ""}${this.reviewer?.busy ? " reviewing…" : ""}`, prio: 2 });
|
|
1775
|
+
// Narrow glyph: the footer is measured in characters, and an emoji takes two columns.
|
|
1776
|
+
if (this.awake.mode !== "off") left.push({ text: `● awake${this.awake.status.lid ? " · lid" : ""}`, prio: 1 });
|
|
1273
1777
|
const effort = a ? this.effortOption(a)?.current : undefined;
|
|
1274
1778
|
if (a) right.push({ text: `${a.name}${a.engine.info?.currentModel ? ` · ${shortModelName(a.engine.info.currentModel)}` : ""}${effort ? ` ${effort}` : ""}`, prio: 2 });
|
|
1275
1779
|
if (a?.context) right.push({ text: `ctx ${Math.min(100, Math.round((a.context.used / a.context.size) * 100))}%`, prio: 1 });
|
|
@@ -1345,6 +1849,8 @@ export class ChatApp {
|
|
|
1345
1849
|
|
|
1346
1850
|
async quit() {
|
|
1347
1851
|
this.closed = true;
|
|
1852
|
+
await this.awake.release().catch(() => []);
|
|
1853
|
+
await this.closeReviewer();
|
|
1348
1854
|
this.switching = true;
|
|
1349
1855
|
const a = this.active;
|
|
1350
1856
|
this.closePalette();
|
|
@@ -1382,6 +1888,7 @@ export async function runChat(config: Config, ledger: Ledger, cwd: string, agent
|
|
|
1382
1888
|
const pref = config.theme ?? "auto";
|
|
1383
1889
|
applyTheme(pref === "auto" ? ((await renderer.waitForThemeMode(250).catch(() => null)) ?? "dark") : pref);
|
|
1384
1890
|
const app = new ChatApp(renderer, config, ledger, cwd);
|
|
1891
|
+
process.once("exit", () => app.awake.releaseSync());
|
|
1385
1892
|
try {
|
|
1386
1893
|
await app.start(agent, fresh, { pick });
|
|
1387
1894
|
} finally {
|
|
@@ -1420,3 +1927,8 @@ async function copyToClipboard(renderer: CliRenderer, text: string): Promise<boo
|
|
|
1420
1927
|
}
|
|
1421
1928
|
return osc;
|
|
1422
1929
|
}
|
|
1930
|
+
|
|
1931
|
+
function reviewCadence(every: number | undefined): string {
|
|
1932
|
+
const n = every ?? 1;
|
|
1933
|
+
return n === 0 ? "only on /review" : n === 1 ? "every turn" : n === 2 ? "every 2nd turn" : n === 3 ? "every 3rd turn" : `every ${n}th turn`;
|
|
1934
|
+
}
|