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/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.palette?.render());
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
- this.transcript.header({ title: `codemax v${VERSION}`, subtitle: "one terminal, every coding agent", path: this.cwd, notes });
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
- private onKey = (key: KeyEvent) => {
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 = Object.values(this.ledger.sessions()).filter((s): s is SessionRef => !!s && !!this.detect.get(s.agent)?.engine);
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 items: PaletteItem[] = sessions.map((s, i) => {
561
- const last = [...turns].reverse().find((t) => t.agent === s.agent && t.role === "user");
562
- return {
563
- label: `Resume ${getAdapter(s.agent).name}`,
564
- description: `${ago(s.lastUsedAt)}${last ? ` · "${last.text.replace(/\s+/g, " ").slice(0, 60)}"` : ""}`,
565
- value: `resume:${s.agent}`,
566
- weight: 100 - i,
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
- items.push({ label: `New session with ${getAdapter(defaultAgent).name}`, description: "start fresh (history stays in the ledger)", value: `new:${defaultAgent}`, weight: 0 });
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: "Resume a session · Enter to continue · Esc for the most recent",
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(sessions[0]!.agent, { fresh: false });
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
- const hasTokens = (e.inputTokens ?? 0) + (e.outputTokens ?? 0) + (e.cacheReadTokens ?? 0) + (e.cacheWriteTokens ?? 0) > 0;
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
+ }