codemax-cli 0.1.1 → 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/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,16 +27,25 @@ 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";
34
+ import { sortModels } from "./models.ts";
35
+ import { fmtDateTime12 } from "../core/time.ts";
29
36
 
30
37
  const HOTKEY_NAMES: Record<string, string[]> = { "/": ["/", "_", "?"], "^": ["^", "6"] };
31
38
  const VERSION = pkg.version;
32
39
  const MODE_CYCLE = ["default", "acceptEdits", "plan"];
40
+ /** Most side-agent notes sent automatically per task; more wait for /forward. */
41
+ const AUTO_FORWARD_CAP = 2;
33
42
 
34
43
  interface Active {
35
44
  agent: AgentId;
36
45
  name: string;
37
46
  engine: Engine;
38
- turn: { block: AgentBlock; tools: Set<string>; startedAt: number; tokens: number; chars: number; verb: string; rec?: TurnRecorder } | null;
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;
39
49
  replay: AgentBlock | null;
40
50
  unsubscribe: () => void;
41
51
  mode?: string;
@@ -67,6 +77,7 @@ export class ChatApp {
67
77
  private input: TextareaRenderable;
68
78
  private inputWrap: BoxRenderable;
69
79
  private dialogHost: BoxRenderable;
80
+ private queueBox!: BoxRenderable;
70
81
  private footerText: TextRenderable;
71
82
  private shortcuts: BoxRenderable;
72
83
  private flash = "";
@@ -89,6 +100,21 @@ export class ChatApp {
89
100
  private switching = false;
90
101
  /** Set by quit(); async work that resumes afterwards (a handoff being built) stops. */
91
102
  private closed = false;
103
+ /** Side agent (/side): reviews the main agent's work after each turn. */
104
+ private reviewer: { agent: AgentId; name: string; engine: Engine; busy: boolean; pending: boolean; since: number; primed: boolean } | null = null;
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 }>>();
108
+ /** Working turns since the side agent last reviewed (for `sideAgent.every`). */
109
+ private turnsSinceReview = 0;
110
+ /** Notes sent automatically per task (auto mode is capped so the two agents cannot loop). */
111
+ private autoForwards = new Map<string, number>();
112
+ /** A newer codemax on npm, when the update check found one. */
113
+ private updateAvailable: string | null = null;
114
+ /** Update check (tests swap it; it never runs under `bun test`). */
115
+ updateChecker: (current: string) => Promise<string | null> = (current) => (process.env.NODE_ENV === "test" ? Promise.resolve(null) : checkForUpdate(current));
116
+ /** Keep-awake (/awake). Tests swap its system runner. */
117
+ awake = new Awake({ ...defaultAwakeDeps(), interactive: (fn) => this.withTerminal(fn) });
92
118
  private resolveDone!: () => void;
93
119
  readonly done: Promise<void>;
94
120
  /** Test hooks. */
@@ -115,6 +141,10 @@ export class ChatApp {
115
141
  root.add(this.working);
116
142
  this.dialogHost = new BoxRenderable(renderer, { id: "dialog-host", width: "100%", flexShrink: 0, flexDirection: "column" });
117
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);
118
148
  this.inputWrap = new BoxRenderable(renderer, { id: "input-wrap", width: "100%", flexShrink: 0, flexDirection: "column", border: ["top", "bottom"], borderStyle: "single", borderColor: C.accent });
119
149
  const inputRow = new BoxRenderable(renderer, { id: "input-row", width: "100%", flexDirection: "row" });
120
150
  inputRow.add(new TextRenderable(renderer, { id: "input-prompt", content: `${G.prompt} `, width: 2, fg: C.accent }));
@@ -160,12 +190,16 @@ export class ChatApp {
160
190
  });
161
191
 
162
192
  renderer.keyInput.on("keypress", this.onKey);
163
- renderer.on("resize", () => this.palette?.render());
193
+ renderer.on("resize", () => this.safely("resize", () => {
194
+ this.palette?.render();
195
+ this.renderFooter();
196
+ }));
164
197
  this.input.focus();
165
198
  this.renderFooter();
166
199
  }
167
200
 
168
- async start(agent: AgentId, fresh: boolean, o: { pick?: boolean } = {}) {
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 } = {}) {
169
203
  try {
170
204
  this.today = spendToday();
171
205
  } catch {
@@ -173,14 +207,25 @@ export class ChatApp {
173
207
  }
174
208
  await this.refreshDetect();
175
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"}`);
176
211
  const first = ok(agent) ? agent : listAdapters(this.config.order).find((a) => ok(a.id))?.id;
177
212
  if (!first) throw new Error("no agent with a protocol engine found (need claude, codex, opencode, grok or gemini on PATH)");
178
213
  const notes: string[] = [];
179
214
  if (first !== agent) notes.push(`${agent} has no protocol engine here; starting ${first}`);
215
+ for (const n of await recoverAwake({ ...defaultAwakeDeps(), interactive: (fn) => this.withTerminal(fn) }).catch(() => [])) notes.push(n);
216
+ if (this.config.awake && this.config.awake !== "off") {
217
+ const st = await this.awake.set(this.config.awake).catch(() => null);
218
+ 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);
219
+ }
180
220
  const stripped = strippedKeys();
181
221
  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 });
222
+ // Card border (2) + padding (2) + transcript padding (2): the logo must fit inside without wrapping.
223
+ this.transcript.header({ title: `codemax v${VERSION}`, subtitle: "one terminal, every coding agent", path: this.cwd, notes, logo: logoLines(this.renderer.width - 6) });
183
224
  if ((this.config as any).priceUpdates !== false) void refreshLivePricesIfStale().then((l) => l && resetPriceCache());
225
+ if (this.config.updateCheck !== false)
226
+ void this.updateChecker(VERSION)
227
+ .then((v) => v && this.announceUpdate(v))
228
+ .catch(() => {});
184
229
  if (o.pick && !fresh && this.openPicker(first)) return this.done;
185
230
  await this.launch(first, { fresh });
186
231
  return this.done;
@@ -188,7 +233,29 @@ export class ChatApp {
188
233
 
189
234
  // ---------- keys ----------
190
235
 
191
- private onKey = (key: KeyEvent) => {
236
+ /**
237
+ * The UI must never break: a failure while handling one event, key or redraw is
238
+ * shown as a short line and codemax carries on.
239
+ */
240
+ private uiErrors = 0;
241
+ private safely(where: string, fn: () => void) {
242
+ try {
243
+ fn();
244
+ } catch (e: any) {
245
+ this.uiErrors++;
246
+ if (this.uiErrors <= 5) {
247
+ try {
248
+ this.transcript.error(`display glitch in ${where}: ${String(e?.message ?? e).slice(0, 160)} (codemax keeps going)`);
249
+ } catch {
250
+ /* the screen itself is gone */
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ private onKey = (key: KeyEvent) => this.safely("key handling", () => this.handleKey(key));
257
+
258
+ private handleKey(key: KeyEvent) {
192
259
  if (key.eventType === "release") return;
193
260
  if (this.permission) {
194
261
  key.stopPropagation();
@@ -262,7 +329,7 @@ export class ChatApp {
262
329
  this.shortcuts.visible = !this.shortcuts.visible;
263
330
  return;
264
331
  }
265
- };
332
+ }
266
333
 
267
334
  private isHotkey(key: KeyEvent): boolean {
268
335
  if (key.raw && findHotkey(new TextEncoder().encode(key.raw), this.hotkeys)) return true;
@@ -309,6 +376,10 @@ export class ChatApp {
309
376
  else this.palette.setFilter(text.slice(1));
310
377
  } else if (this.palette && this.paletteKind === "commands") {
311
378
  this.closePalette();
379
+ } else if (this.palette && this.paletteKind !== "handoff" && this.paletteKind !== "files" && !text.includes("\n")) {
380
+ // Every picker filters as you type (agents, models, settings…), like Claude Code's.
381
+ this.palette.setFilter(text);
382
+ this.paletteFiltered = text.length > 0;
312
383
  }
313
384
  if (this.shortcuts.visible && text) this.shortcuts.visible = false;
314
385
  }
@@ -334,9 +405,7 @@ export class ChatApp {
334
405
  const busy = !!this.active?.turn;
335
406
  const immediate = /^\/(quit|exit|q|help|status|usage|cost|diff|clear|switch|agents)\b/.test(text);
336
407
  if (busy && !immediate && (text.startsWith("/") || text.startsWith("!"))) {
337
- this.queue.push(text);
338
- this.transcript.note(`queued until the turn ends: ${text.slice(0, 60)}`, C.dim);
339
- this.renderFooter();
408
+ this.enqueue(text);
340
409
  return;
341
410
  }
342
411
  if (text.startsWith("/")) return this.command(text);
@@ -348,8 +417,7 @@ export class ChatApp {
348
417
  const route = this.routeFor(text);
349
418
  if (route && route !== this.active.agent && !this.active.turn) return this.askAgent(route, text, `routed to ${route} by rule`);
350
419
  if (this.active.turn) {
351
- this.queue.push(text);
352
- this.transcript.note(`queued: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`, C.dim);
420
+ this.enqueue(text);
353
421
  return;
354
422
  }
355
423
  await this.send(this.active, text);
@@ -552,24 +620,30 @@ export class ChatApp {
552
620
  }
553
621
 
554
622
  /** Startup picker: resume a recent session or start fresh. Returns false when there is nothing to pick. */
623
+ /**
624
+ * Startup: choose the agent to continue with. One row per usable agent, most
625
+ * recently used first, resuming its saved session when there is one.
626
+ */
555
627
  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);
628
+ const sessions = this.ledger.sessions();
559
629
  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
- };
630
+ const usable = listAdapters(this.config.order).filter((a) => this.detect.get(a.id)?.installed && this.detect.get(a.id)?.engine);
631
+ if (!usable.length) return false;
632
+ const recent = [...usable].sort((a, b) => (sessions[b.id]?.lastUsedAt ?? 0) - (sessions[a.id]?.lastUsedAt ?? 0));
633
+ const items: PaletteItem[] = recent.map((a, i) => {
634
+ const s = sessions[a.id];
635
+ const last = [...turns].reverse().find((t) => t.agent === a.id && t.role === "user");
636
+ const q = this.quota.get(a.id);
637
+ const bits = [s ? `resume · ${ago(s.lastUsedAt)}${last ? ` · "${last.text.replace(/\s+/g, " ").slice(0, 50)}"` : ""}` : "new session"];
638
+ if (q && quotaLabel(q)) bits.push(quotaLabel(q));
639
+ return { label: a.name, description: bits.join(" · "), value: `resume:${a.id}`, weight: 100 - i };
568
640
  });
569
- items.push({ label: `New session with ${getAdapter(defaultAgent).name}`, description: "start fresh (history stays in the ledger)", value: `new:${defaultAgent}`, weight: 0 });
641
+ const fresh = recent[0]!;
642
+ items.push({ label: `Start fresh with ${fresh.name}`, description: "new session (history stays in the ledger)", value: `new:${fresh.id}`, weight: 0 });
643
+ defaultAgent = sessions[fresh.id] ? fresh.id : defaultAgent;
570
644
  this.paletteKind = "picker";
571
645
  this.palette = new Palette(this.renderer, {
572
- title: "Resume a session · Enter to continue · Esc for the most recent",
646
+ title: `Choose an agent to continue with · Enter to start · Esc for ${getAdapter(defaultAgent).name}`,
573
647
  items,
574
648
  onPick: (it) => {
575
649
  this.closePalette();
@@ -578,13 +652,33 @@ export class ChatApp {
578
652
  },
579
653
  onClose: () => {
580
654
  this.closePalette();
581
- void this.launch(sessions[0]!.agent, { fresh: false });
655
+ void this.launch(defaultAgent, { fresh: false });
582
656
  },
583
657
  }, this.paletteAnchor);
584
658
  this.renderer.root.add(this.palette.box);
585
659
  return true;
586
660
  }
587
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
+
588
682
  /** Run one queued entry the same way as if it had just been typed. */
589
683
  private async dispatch(active: Active, text: string) {
590
684
  if (text.startsWith("!")) return this.shell(text.slice(1).trim());
@@ -593,11 +687,16 @@ export class ChatApp {
593
687
  }
594
688
 
595
689
  private async drainQueue(active: Active) {
596
- while (this.queue.length && this.active === active && !active.turn) await this.dispatch(active, this.queue.shift()!);
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
+ }
597
695
  this.renderFooter();
598
696
  }
599
697
 
600
698
  private async send(active: Active, text: string) {
699
+ if (this.closed) return;
601
700
  this.transcript.user(text);
602
701
  text = this.withAttachments(text);
603
702
  const cp = await this.checkpoint(active, text);
@@ -635,11 +734,55 @@ export class ChatApp {
635
734
  { name: "/checkpoints", description: "List working-tree snapshots taken before each turn", run: () => void this.showCheckpoints() },
636
735
  { name: "/ask", description: "Send one message to another agent without switching: /ask codex <msg>", run: (a) => void this.ask(a) },
637
736
  { name: "/budget", description: "Today's spend against your daily budgets", run: () => this.showBudget() },
737
+ { name: "/update", description: "Update codemax to the latest version", run: () => void this.runUpdate() },
738
+ { name: "/config", description: "Settings: theme, keep awake, side agent and model, handoffs, checkpoints…", run: () => this.openConfig() },
739
+ { 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) },
740
+ { name: "/review", description: "Ask the side agent to review the latest work now", run: () => void this.reviewNow() },
741
+ { name: "/notes", description: "Notes the side agent left", run: () => this.showNotes() },
742
+ { 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) },
743
+ { name: "/awake", description: "Keep the machine awake while agents work: /awake on | lid | off", run: (a) => void this.setAwake(a) },
638
744
  { name: "/theme", description: "Colour theme: /theme dark | light | auto (saved; restart to apply everywhere)", run: (a) => this.setThemePref(a) },
639
745
  { name: "/quit", description: "Quit codemax", run: () => void this.quit() },
640
746
  ];
641
747
  }
642
748
 
749
+ /** Run something that needs the real terminal (a sudo password prompt): pause the screen around it. */
750
+ private async withTerminal<T>(fn: () => Promise<T>): Promise<T> {
751
+ try {
752
+ await this.renderer.suspend();
753
+ } catch {
754
+ /* test renderer */
755
+ }
756
+ try {
757
+ return await fn();
758
+ } finally {
759
+ try {
760
+ await this.renderer.resume();
761
+ } catch {
762
+ /* test renderer */
763
+ }
764
+ }
765
+ }
766
+
767
+ private async setAwake(arg: string) {
768
+ const v = arg.trim().toLowerCase();
769
+ if (v !== "on" && v !== "lid" && v !== "off") {
770
+ const s = this.awake.status;
771
+ 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`);
772
+ return;
773
+ }
774
+ const st = await this.awake.set(v);
775
+ try {
776
+ saveConfigKey("awake", st.mode);
777
+ } catch {
778
+ /* read-only home */
779
+ }
780
+ this.config.awake = st.mode;
781
+ 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);
782
+ for (const n of st.notes) this.transcript.note(n, C.warn);
783
+ this.renderFooter();
784
+ }
785
+
643
786
  private setThemePref(arg: string) {
644
787
  const v = arg.trim().toLowerCase();
645
788
  if (v !== "dark" && v !== "light" && v !== "auto") {
@@ -662,11 +805,17 @@ export class ChatApp {
662
805
  const name = raw!.toLowerCase();
663
806
  const b = this.builtins().find((x) => x.name === name || x.name === name.replace(/^\/(exit|q)$/, "/quit"));
664
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
+ }
665
815
  if (name === "/exit" || name === "/q") return this.quit();
666
816
  if (this.active?.commands.some((c) => `/${c.name}` === name)) {
667
817
  if (this.active.turn) {
668
- this.queue.push(line);
669
- this.transcript.note(`queued ${name} until the turn ends`, C.dim);
818
+ this.enqueue(line);
670
819
  return;
671
820
  }
672
821
  return this.send(this.active, line);
@@ -676,7 +825,12 @@ export class ChatApp {
676
825
 
677
826
  private runCommandValue(value: string, args: string) {
678
827
  if (value.startsWith("builtin:")) return this.builtins().find((b) => b.name === value.slice(8))?.run(args);
679
- if (value.startsWith("agentcmd:")) return this.command(`/${value.slice(9)}${args ? " " + args : ""}`);
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
+ }
680
834
  }
681
835
 
682
836
  private pendingShell: string[] = [];
@@ -724,7 +878,8 @@ export class ChatApp {
724
878
  this.paletteKind = "modes";
725
879
  this.palette = new Palette(this.renderer, {
726
880
  title: `Model for ${a.name}`,
727
- items: models.map((m) => ({ label: m.id, description: `${m.name !== m.id ? m.name : ""}${m.id === a.engine.info?.currentModel ? " (current)" : ""}`, value: m.id })),
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 })),
728
883
  onPick: (it) => {
729
884
  this.closePalette();
730
885
  void apply(it.value);
@@ -834,6 +989,13 @@ export class ChatApp {
834
989
  const all = this.ledger.usage();
835
990
  for (const l of renderUsageTable(all, { title: "Usage for this project" })) this.transcript.note(l);
836
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)");
837
999
  }
838
1000
 
839
1001
  private showStatus() {
@@ -868,9 +1030,12 @@ export class ChatApp {
868
1030
  const builtinNames = new Set(this.builtins().map((b) => b.name));
869
1031
  const items: PaletteItem[] = [
870
1032
  ...this.builtins().map((b) => ({ label: b.name, description: b.description, value: `builtin:${b.name}`, weight: 1 })),
871
- ...(this.active?.commands ?? [])
872
- .filter((c) => !builtinNames.has(`/${c.name}`))
873
- .map((c) => ({ label: `/${c.name}`, description: c.description ?? `${this.active!.name} command`, value: `agentcmd:${c.name}` })),
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
+ }),
874
1039
  ];
875
1040
  this.paletteKind = "commands";
876
1041
  this.palette = new Palette(this.renderer, {
@@ -950,10 +1115,15 @@ export class ChatApp {
950
1115
  this.renderer.root.add(this.palette.box);
951
1116
  }
952
1117
 
1118
+ /** True while text in the prompt is only filtering a picker; it is cleared when the picker closes. */
1119
+ private paletteFiltered = false;
1120
+
953
1121
  private closePalette(_focus = true) {
954
1122
  this.palette?.destroy();
955
1123
  this.palette = null;
956
1124
  this.paletteKind = null;
1125
+ if (this.paletteFiltered) this.input.clear();
1126
+ this.paletteFiltered = false;
957
1127
  this.input.focus();
958
1128
  }
959
1129
 
@@ -1035,6 +1205,7 @@ export class ChatApp {
1035
1205
  }
1036
1206
 
1037
1207
  private async runTurn(active: Active, text: string, o: { record?: boolean; rec?: TurnRecorder; journal?: boolean } = {}) {
1208
+ if (this.closed) return;
1038
1209
  const block = this.transcript.agent();
1039
1210
  const rec = o.journal === false ? undefined : (o.rec ?? new TurnRecorder(this.ledger.journal, active.agent));
1040
1211
  active.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: pick(VERBS), rec };
@@ -1053,10 +1224,19 @@ export class ChatApp {
1053
1224
  if (turn && this.config.bellAfterSeconds > 0 && Date.now() - turn.startedAt > this.config.bellAfterSeconds * 1000) process.stdout.write("\x07");
1054
1225
  if (turn) {
1055
1226
  const dur = fmtDuration(Date.now() - turn.startedAt);
1056
- block.footer(stop === "cancelled" ? `Interrupted after ${dur}` : `${pick(DONE_VERBS, turn.startedAt)} for ${dur} · done ${fmtTime(Date.now())}`);
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}`);
1057
1229
  const reply = block.text().trim();
1058
1230
  if (isLimitNotice(reply)) turn.rec?.markLimit(reply);
1059
1231
  turn.rec?.end(stop);
1232
+ // The side agent reviews main-agent turns that did work (not /ask, not summaries).
1233
+ // A turn that only answers a forwarded note is not reviewed: note → turn → review → note
1234
+ // would loop on both plans. Its work is covered by the next review after a user turn.
1235
+ if (turn.rec && this.active === active && !this.side.has(active) && !isForwardedNote(text) && (turn.tools.size > 0 || reply.length > 0)) {
1236
+ const every = this.config.sideAgent?.every ?? 1;
1237
+ this.turnsSinceReview++;
1238
+ if (every > 0 && this.turnsSinceReview >= every) void this.review();
1239
+ }
1060
1240
  if (isLimitNotice(reply)) {
1061
1241
  // The agent ended the turn with only a limit notice: treat it as running out, and leave
1062
1242
  // the user's request unanswered in the ledger so the next agent is asked to finish it.
@@ -1076,6 +1256,10 @@ export class ChatApp {
1076
1256
  }
1077
1257
 
1078
1258
  private onEngineEvent(active: Active, e: EngineEvent) {
1259
+ this.safely(`${active.name} event`, () => this.handleEngineEvent(active, e));
1260
+ }
1261
+
1262
+ private handleEngineEvent(active: Active, e: EngineEvent) {
1079
1263
  if (this.active !== active && !this.side.has(active) && e.type !== "permission_request") return;
1080
1264
  const target = () => active.turn?.block ?? (active.replay ??= this.transcript.agent());
1081
1265
  switch (e.type) {
@@ -1119,32 +1303,20 @@ export class ChatApp {
1119
1303
  break;
1120
1304
  case "usage": {
1121
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;
1122
1307
  if (typeof e.contextUsed === "number" && typeof e.contextSize === "number" && e.contextSize > 0) {
1123
1308
  active.context = { used: e.contextUsed, size: e.contextSize };
1124
1309
  this.renderFooter();
1125
1310
  }
1126
- 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);
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() });
1147
1317
  }
1318
+ if (typeof e.usedPercent === "number") this.onQuota({ agent: active.agent, usedPercent: e.usedPercent, exhausted: e.usedPercent >= 100, source: "probe", at: Date.now() });
1319
+ this.recordUsage(active.agent, active.engine, e);
1148
1320
  break;
1149
1321
  }
1150
1322
  case "session":
@@ -1205,6 +1377,399 @@ export class ChatApp {
1205
1377
 
1206
1378
  // ---------- working line, footer ----------
1207
1379
 
1380
+ /** Price and record a usage event (main, /ask and side-agent sessions alike). */
1381
+ private recordUsage(agent: AgentId, engine: Engine, e: Extract<EngineEvent, { type: "usage" }>) {
1382
+ const hasTokens = (e.inputTokens ?? 0) + (e.outputTokens ?? 0) + (e.cacheReadTokens ?? 0) + (e.cacheWriteTokens ?? 0) > 0;
1383
+ // A zero cost with no tokens (free models report cost 0 on every update) is not usage.
1384
+ if (!hasTokens && !((e.costUsd ?? 0) > 0)) return;
1385
+ const rec = priceRecord({
1386
+ ts: Date.now(),
1387
+ agent,
1388
+ sessionId: engine.session?.id ?? "?",
1389
+ model: e.model ?? engine.info?.currentModel,
1390
+ input: e.inputTokens ?? 0,
1391
+ output: e.outputTokens ?? 0,
1392
+ cacheRead: e.cacheReadTokens ?? 0,
1393
+ cacheWrite: e.cacheWriteTokens ?? 0,
1394
+ agentCostUsd: typeof e.costUsd === "number" ? e.costUsd : undefined,
1395
+ });
1396
+ this.ledger.addUsage(rec);
1397
+ this.sessionCost += recordCost(rec).usd;
1398
+ this.sessionTokens += rec.input + rec.output + rec.cacheRead + rec.cacheWrite;
1399
+ addSpend(this.today, rec);
1400
+ this.renderFooter();
1401
+ this.checkBudget(agent);
1402
+ }
1403
+
1404
+ // ---------- side agent ----------
1405
+
1406
+ /**
1407
+ * /side pick the side agent, then its model
1408
+ * /side <agent> [model] [auto|ask]
1409
+ * /side off
1410
+ */
1411
+ private async setSide(arg: string) {
1412
+ const words = arg.trim().split(/\s+/).filter(Boolean);
1413
+ const cfg = this.config.sideAgent ?? { agent: null, forward: "ask" as const };
1414
+ if (!words.length) return this.openSidePicker();
1415
+ const who = words[0]!.toLowerCase();
1416
+ if (who === "off") {
1417
+ await this.closeReviewer();
1418
+ this.saveSide({ ...cfg, agent: null, model: undefined });
1419
+ this.transcript.note("side agent off", C.dim);
1420
+ return this.renderFooter();
1421
+ }
1422
+ if (!this.detect.get(who as AgentId)?.engine) return this.transcript.note(`no protocol engine for "${who}" · /side to pick one`, C.warn);
1423
+ const rest = words.slice(1);
1424
+ const forward = rest.includes("auto") ? "auto" : rest.includes("ask") ? "ask" : cfg.agent === who ? cfg.forward : "ask";
1425
+ const model = rest.find((w) => w !== "auto" && w !== "ask");
1426
+ await this.enableSide(who as AgentId, { forward, model, pickModel: false });
1427
+ }
1428
+
1429
+ private saveSide(v: Config["sideAgent"]) {
1430
+ this.config.sideAgent = v;
1431
+ try {
1432
+ saveConfigKey("sideAgent", v);
1433
+ } catch {
1434
+ /* read-only home */
1435
+ }
1436
+ }
1437
+
1438
+ private async enableSide(agent: AgentId, o: { forward: "ask" | "auto"; model?: string; pickModel: boolean }) {
1439
+ const prev = this.config.sideAgent;
1440
+ if (prev?.agent !== agent || (o.model && o.model !== prev.model)) await this.closeReviewer();
1441
+ // Keep the user's other side-agent settings (review cadence) when switching agent.
1442
+ this.saveSide({ ...prev, agent, forward: o.forward, model: o.model ?? (prev?.agent === agent ? prev.model : undefined) });
1443
+ const name = getAdapter(agent).name;
1444
+ 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);
1445
+ 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);
1446
+ this.renderFooter();
1447
+ if (!o.pickModel) return;
1448
+ // Start the reviewer now so its real model list can be offered.
1449
+ const r = await this.ensureReviewer();
1450
+ const models = r?.engine.info?.models ?? [];
1451
+ if (!r || models.length < 2) {
1452
+ if (r && models.length === 1) this.transcript.note(`${name} offers one model on your plan: ${models[0]!.name}`);
1453
+ return;
1454
+ }
1455
+ this.closePalette();
1456
+ this.paletteKind = "modes";
1457
+ this.palette = new Palette(this.renderer, {
1458
+ title: `Model for the side agent (${name}) · Esc keeps ${shortModelName(r.engine.info?.currentModel ?? "default")}`,
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 })),
1460
+ onPick: (it) => {
1461
+ this.closePalette();
1462
+ void this.setSideModel(it.value);
1463
+ },
1464
+ onClose: () => this.closePalette(),
1465
+ }, this.paletteAnchor);
1466
+ this.renderer.root.add(this.palette.box);
1467
+ }
1468
+
1469
+ private async setSideModel(model: string) {
1470
+ const cfg = this.config.sideAgent;
1471
+ if (!cfg?.agent) return this.transcript.note("no side agent · /side to pick one", C.warn);
1472
+ this.saveSide({ ...cfg, model });
1473
+ const r = this.reviewer;
1474
+ try {
1475
+ if (r?.engine.setModel) await r.engine.setModel(model);
1476
+ this.transcript.note(`side agent model: ${model}`);
1477
+ if (cfg.agent === "cursor") this.transcript.note("Cursor saves this as its default model, so Cursor uses it outside codemax too", C.warn);
1478
+ } catch (e: any) {
1479
+ this.transcript.error(`could not set the side agent's model: ${e?.message ?? e}`);
1480
+ }
1481
+ this.renderFooter();
1482
+ }
1483
+
1484
+ // ---------- settings (/config) ----------
1485
+
1486
+ private settings(): { key: string; label: string; value: string; choices: { value: string; label?: string; description?: string }[]; apply: (v: string) => void | Promise<void> }[] {
1487
+ const c = this.config;
1488
+ const onOff = (b: boolean) => (b ? "on" : "off");
1489
+ const bool = (key: "autoSwitch" | "summarizeOnSwitch" | "checkpoints" | "priceUpdates") => (v: string) => {
1490
+ c[key] = v === "on";
1491
+ saveConfigKey(key, c[key]);
1492
+ };
1493
+ const side = c.sideAgent ?? { agent: null, forward: "ask" as const };
1494
+ const agents = listAdapters(c.order).filter((a) => this.detect.get(a.id)?.engine);
1495
+ return [
1496
+ { 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) },
1497
+ { 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) },
1498
+ { 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 })) },
1499
+ { 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 }); } },
1500
+ {
1501
+ key: "sideEvery",
1502
+ label: "Side agent reviews",
1503
+ value: reviewCadence(side.every),
1504
+ 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" }],
1505
+ apply: (v) => this.saveSide({ ...side, every: Number(v) }),
1506
+ },
1507
+ { 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" }) },
1508
+ { key: "autoSwitch", label: "Offer a handoff at limits", value: onOff(c.autoSwitch), choices: [{ value: "on" }, { value: "off" }], apply: bool("autoSwitch") },
1509
+ { 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") },
1510
+ { 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") },
1511
+ {
1512
+ key: "bell",
1513
+ label: "Bell after long turns",
1514
+ value: c.bellAfterSeconds ? `${c.bellAfterSeconds}s` : "off",
1515
+ choices: [{ value: "0", label: "off" }, { value: "10", label: "10s" }, { value: "20", label: "20s" }, { value: "60", label: "60s" }],
1516
+ apply: (v) => {
1517
+ c.bellAfterSeconds = Number(v);
1518
+ saveConfigKey("bellAfterSeconds", c.bellAfterSeconds);
1519
+ },
1520
+ },
1521
+ { 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); } },
1522
+ { key: "priceUpdates", label: "Weekly price list refresh", value: onOff(c.priceUpdates), choices: [{ value: "on" }, { value: "off", description: "stay fully offline" }], apply: bool("priceUpdates") },
1523
+ ];
1524
+ }
1525
+
1526
+ /** /config: every setting with its value; pick one to change it. */
1527
+ private openConfig() {
1528
+ const all = this.settings();
1529
+ this.closePalette();
1530
+ this.paletteKind = "modes";
1531
+ this.palette = new Palette(this.renderer, {
1532
+ title: `Settings · Enter to change · saved to ${CONFIG_PATH.replace(homedir(), "~")}`,
1533
+ // Weights keep the settings in this order (the palette otherwise sorts by label).
1534
+ items: all.map((x, i) => ({ label: x.label, description: x.value, value: x.key, disabled: !x.choices.length, weight: 100 - i })),
1535
+ maxRows: 12,
1536
+ onPick: (it) => {
1537
+ const st = all.find((x) => x.key === it.value);
1538
+ if (st) this.openSetting(st);
1539
+ },
1540
+ onClose: () => this.closePalette(),
1541
+ }, this.paletteAnchor);
1542
+ this.renderer.root.add(this.palette.box);
1543
+ }
1544
+
1545
+ private openSetting(st: ReturnType<ChatApp["settings"]>[number]) {
1546
+ this.closePalette();
1547
+ this.paletteKind = "modes";
1548
+ this.palette = new Palette(this.renderer, {
1549
+ title: `${st.label} · now ${st.value} · Esc back to settings`,
1550
+ 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 })),
1551
+ onPick: (it) => {
1552
+ this.closePalette();
1553
+ void (async () => {
1554
+ try {
1555
+ await st.apply(it.value);
1556
+ if (!["theme", "awake", "side", "sideModel"].includes(st.key)) this.transcript.note(`${st.label.toLowerCase()}: ${it.label}`);
1557
+ } catch (e: any) {
1558
+ this.transcript.error(`could not change ${st.label.toLowerCase()}: ${e?.message ?? e}`);
1559
+ }
1560
+ this.renderFooter();
1561
+ // Back to the list for the next change, unless a follow-up picker took over.
1562
+ if (!this.palette) this.openConfig();
1563
+ })();
1564
+ },
1565
+ onClose: () => this.openConfig(),
1566
+ }, this.paletteAnchor);
1567
+ this.renderer.root.add(this.palette.box);
1568
+ }
1569
+
1570
+ /** /side with no arguments: pick the agent (then its model). */
1571
+ private openSidePicker() {
1572
+ const cfg = this.config.sideAgent;
1573
+ const items: PaletteItem[] = listAdapters(this.config.order)
1574
+ .filter((a) => this.detect.get(a.id)?.engine)
1575
+ // A different subscription first: the same tool would share (and drain) the main agent's plan.
1576
+ .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 }));
1577
+ items.push({ label: "Off", description: "no side agent", value: "off" });
1578
+ this.closePalette();
1579
+ this.paletteKind = "modes";
1580
+ this.palette = new Palette(this.renderer, {
1581
+ title: "Side agent · reviews each turn and leaves a note when something needs attention",
1582
+ items,
1583
+ onPick: (it) => {
1584
+ this.closePalette();
1585
+ if (it.value === "off") return void this.setSide("off");
1586
+ void this.enableSide(it.value as AgentId, { forward: cfg?.forward ?? "ask", pickModel: true });
1587
+ },
1588
+ onClose: () => this.closePalette(),
1589
+ }, this.paletteAnchor);
1590
+ this.renderer.root.add(this.palette.box);
1591
+ }
1592
+
1593
+ /** The side agent's session, started on first use with the chosen model. */
1594
+ private async ensureReviewer() {
1595
+ const cfg = this.config.sideAgent;
1596
+ if (!cfg?.agent || this.closed) return null;
1597
+ if (this.reviewer?.agent === cfg.agent) return this.reviewer;
1598
+ await this.closeReviewer();
1599
+ if (this.checkBlockers(cfg.agent).length) return null;
1600
+ const engine = this.engineFactory(cfg.agent);
1601
+ const r = { agent: cfg.agent, name: getAdapter(cfg.agent).name, engine, busy: false, pending: false, since: this.lastReviewBaseline(), primed: false };
1602
+ this.reviewer = r;
1603
+ try {
1604
+ await engine.start({ cwd: this.cwd });
1605
+ 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));
1606
+ return r;
1607
+ } catch (e: any) {
1608
+ if (this.reviewer === r) this.reviewer = null;
1609
+ this.transcript.note(`side agent ${r.name} could not start: ${e?.message ?? e}`, C.warn);
1610
+ return null;
1611
+ }
1612
+ }
1613
+
1614
+ private async closeReviewer() {
1615
+ const r = this.reviewer;
1616
+ this.reviewer = null;
1617
+ if (r) await r.engine.close().catch(() => {});
1618
+ }
1619
+
1620
+ /** After a main turn: let the side agent review the new steps (in the background). */
1621
+ private async review() {
1622
+ const cfg = this.config.sideAgent;
1623
+ if (!cfg?.agent || this.closed) return;
1624
+ if (this.reviewer?.busy) {
1625
+ this.reviewer.pending = true;
1626
+ return;
1627
+ }
1628
+ const r = await this.ensureReviewer();
1629
+ if (!r) return;
1630
+ this.turnsSinceReview = 0;
1631
+ r.busy = true;
1632
+ this.renderFooter();
1633
+ try {
1634
+ do {
1635
+ r.pending = false;
1636
+ const steps = this.ledger.journal.steps();
1637
+ const startedAt = Date.now();
1638
+ const context = r.primed ? undefined : await buildBrief({ ledger: this.ledger, cwd: this.cwd, to: r.agent, config: this.config, purpose: "context" }).catch(() => undefined);
1639
+ const prompt = buildReviewPrompt({ steps, since: r.since, mainAgent: this.active?.name ?? "the main agent", reviewer: r.agent, context });
1640
+ if (!prompt) break;
1641
+ let reply = "";
1642
+ let limited = false;
1643
+ const engine = r.engine;
1644
+ const off = engine.onEvent((e) => {
1645
+ try {
1646
+ if (e.type === "text_delta") reply += e.text;
1647
+ else if (e.type === "permission_request") {
1648
+ // A reviewer never acts: refuse anything it asks to do.
1649
+ const no = e.request.options.find((o) => /reject|deny|decline/i.test(`${o.kind} ${o.id}`)) ?? e.request.options.at(-1);
1650
+ if (no) engine.answerPermission(e.request.id, no.id);
1651
+ } else if (e.type === "usage") this.recordUsage(r.agent, engine, e);
1652
+ else if (e.type === "error" && e.kind === "quota") limited = true;
1653
+ } catch {
1654
+ /* a bad event must never break the reviewer or the screen */
1655
+ }
1656
+ });
1657
+ await engine.prompt(prompt).catch(() => "error");
1658
+ off();
1659
+ if (this.closed) return;
1660
+ r.primed = true;
1661
+ r.since = startedAt;
1662
+ if (limited || isLimitNotice(reply)) {
1663
+ this.transcript.note(`side agent ${r.name} hit its usage limit; turning it off for this session`, C.warn);
1664
+ await this.closeReviewer();
1665
+ this.renderFooter();
1666
+ return;
1667
+ }
1668
+ const note = parseNote(reply);
1669
+ if (note && !this.closed) this.addNote(r.agent, r.name, note);
1670
+ } while (r.pending && this.reviewer === r && !this.closed);
1671
+ } finally {
1672
+ if (this.reviewer === r) r.busy = false;
1673
+ this.renderFooter();
1674
+ }
1675
+ }
1676
+
1677
+ /** Review from the start of the current task the first time. */
1678
+ private lastReviewBaseline(): number {
1679
+ const req = this.ledger.journal.task().request;
1680
+ return req ? req.ts - 1 : Date.now();
1681
+ }
1682
+
1683
+ private addNote(agent: AgentId, name: string, text: string) {
1684
+ if (this.closed) return;
1685
+ this.notes.push({ ts: Date.now(), agent, name, text });
1686
+ this.ledger.addEvent({ kind: "note", agent, detail: `side agent note: ${text.slice(0, 2000)}` });
1687
+ const main = this.active?.name ?? "the main agent";
1688
+ const auto = this.config.sideAgent?.forward === "auto";
1689
+ const task = this.ledger.journal.task().request?.id ?? "none";
1690
+ const sent = this.autoForwards.get(task) ?? 0;
1691
+ const capped = auto && sent >= AUTO_FORWARD_CAP;
1692
+ this.transcript.noteCard(
1693
+ `⚑ Note from ${name} (side agent)`,
1694
+ text,
1695
+ 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`,
1696
+ );
1697
+ if (auto && !capped) {
1698
+ this.autoForwards.set(task, sent + 1);
1699
+ this.forwardNote(this.notes.length - 1);
1700
+ }
1701
+ }
1702
+
1703
+ private forwardNote(index = this.notes.length - 1) {
1704
+ if (this.closed) return;
1705
+ const n = this.notes[index];
1706
+ const a = this.active;
1707
+ if (!n) return this.transcript.note("no side-agent notes yet");
1708
+ if (!a) return this.transcript.note("no main agent to forward to", C.warn);
1709
+ const msg = forwardMessage(n.text, n.name);
1710
+ if (a.turn || this.switching) {
1711
+ this.enqueue(msg);
1712
+ return;
1713
+ }
1714
+ void this.send(a, msg);
1715
+ }
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
+
1725
+ private announceUpdate(version: string) {
1726
+ if (this.closed) return;
1727
+ this.updateAvailable = version;
1728
+ this.transcript.note(`codemax ${version} is available (you have ${VERSION}) · run /update to install it`, C.accent);
1729
+ this.renderFooter();
1730
+ }
1731
+
1732
+ /** /update: update the way codemax was installed, with its output on the real terminal. */
1733
+ private async runUpdate() {
1734
+ const plan = updatePlan();
1735
+ if (plan.manual) return this.transcript.note(plan.manual, C.warn);
1736
+ if (this.active?.turn) return this.transcript.note("an agent is working; /update when the turn ends", C.warn);
1737
+ this.transcript.note(`updating with ${plan.label}…`);
1738
+ let ok = true;
1739
+ await this.withTerminal(async () => {
1740
+ process.stdout.write(`\ncodemax: updating with ${plan.label}\n\n`);
1741
+ for (const cmd of plan.cmd) {
1742
+ const p = Bun.spawn(platformArgv(cmd), { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: { ...process.env, ...(plan.env ?? {}) } });
1743
+ if ((await p.exited) !== 0) {
1744
+ ok = false;
1745
+ break;
1746
+ }
1747
+ }
1748
+ process.stdout.write(ok ? "\ncodemax: done. Returning to codemax…\n" : "\ncodemax: the update failed (see above). Returning to codemax…\n");
1749
+ await Bun.sleep(1200);
1750
+ });
1751
+ if (ok) {
1752
+ const to = this.updateAvailable;
1753
+ this.updateAvailable = null;
1754
+ this.transcript.note(`updated${to ? ` to ${to}` : ""} · restart codemax (/quit, then codemax) to use the new version`, C.ok);
1755
+ } else this.transcript.error(`update failed · run it yourself: ${plan.cmd.map((c) => c.join(" ")).join(" && ")}`);
1756
+ this.renderFooter();
1757
+ }
1758
+
1759
+ private async reviewNow() {
1760
+ if (!this.config.sideAgent?.agent) return this.transcript.note("no side agent · /side to pick one", C.warn);
1761
+ this.transcript.note(`asking ${getAdapter(this.config.sideAgent.agent).name} to review…`);
1762
+ const before = this.notes.length;
1763
+ await this.review();
1764
+ if (this.notes.length === before && !this.closed) this.transcript.note("the side agent found nothing worth a note");
1765
+ }
1766
+
1767
+ private showNotes() {
1768
+ 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");
1769
+ this.notes.forEach((n, i) => this.transcript.note(`${i + 1}. ${fmtTime(n.ts)} ${n.name}: ${n.text.split("\n")[0]!.slice(0, 140)}`));
1770
+ this.transcript.note("/forward <n> sends a note to the main agent");
1771
+ }
1772
+
1208
1773
  private startSpinner(active: Active) {
1209
1774
  this.stopSpinner();
1210
1775
  this.spinnerTimer = setInterval(() => {
@@ -1219,7 +1784,7 @@ export class ChatApp {
1219
1784
  else {
1220
1785
  const elapsed = Date.now() - t.startedAt;
1221
1786
  const tip = elapsed > 8000 ? ` ⎿ tip: ${pick(TIPS, Math.floor(t.startedAt / 1000))}` : "";
1222
- 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}`);
1223
1788
  }
1224
1789
  }, 120);
1225
1790
  }
@@ -1240,6 +1805,7 @@ export class ChatApp {
1240
1805
  }
1241
1806
 
1242
1807
  private setWorking(text: string | null) {
1808
+ if (this.closed && text) return;
1243
1809
  this.working.visible = !!text;
1244
1810
  this.working.content = text ?? "";
1245
1811
  }
@@ -1255,6 +1821,10 @@ export class ChatApp {
1255
1821
  }
1256
1822
 
1257
1823
  private renderFooter() {
1824
+ this.safely("footer", () => this.drawFooter());
1825
+ }
1826
+
1827
+ private drawFooter() {
1258
1828
  const a = this.active;
1259
1829
  const width = Math.max(20, this.renderer.width - 4);
1260
1830
  let color: string = C.dim;
@@ -1270,6 +1840,10 @@ export class ChatApp {
1270
1840
  else if (this.queue.length) left.push({ text: `${this.queue.length} queued`, prio: 1 });
1271
1841
  else if (!this.shortcuts.visible) left.push({ text: "? for shortcuts", prio: 2 });
1272
1842
  const right: { text: string; prio: number }[] = [];
1843
+ if (this.updateAvailable) left.push({ text: `↑ ${this.updateAvailable} available · /update`, prio: 1 });
1844
+ 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 });
1845
+ // Narrow glyph: the footer is measured in characters, and an emoji takes two columns.
1846
+ if (this.awake.mode !== "off") left.push({ text: `● awake${this.awake.status.lid ? " · lid" : ""}`, prio: 1 });
1273
1847
  const effort = a ? this.effortOption(a)?.current : undefined;
1274
1848
  if (a) right.push({ text: `${a.name}${a.engine.info?.currentModel ? ` · ${shortModelName(a.engine.info.currentModel)}` : ""}${effort ? ` ${effort}` : ""}`, prio: 2 });
1275
1849
  if (a?.context) right.push({ text: `ctx ${Math.min(100, Math.round((a.context.used / a.context.size) * 100))}%`, prio: 1 });
@@ -1345,6 +1919,8 @@ export class ChatApp {
1345
1919
 
1346
1920
  async quit() {
1347
1921
  this.closed = true;
1922
+ await this.awake.release().catch(() => []);
1923
+ await this.closeReviewer();
1348
1924
  this.switching = true;
1349
1925
  const a = this.active;
1350
1926
  this.closePalette();
@@ -1377,13 +1953,14 @@ function ago(ts: number): string {
1377
1953
  return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
1378
1954
  }
1379
1955
 
1380
- 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) {
1381
1957
  const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGHUP"], useMouse: true, targetFps: 60 });
1382
1958
  const pref = config.theme ?? "auto";
1383
1959
  applyTheme(pref === "auto" ? ((await renderer.waitForThemeMode(250).catch(() => null)) ?? "dark") : pref);
1384
1960
  const app = new ChatApp(renderer, config, ledger, cwd);
1961
+ process.once("exit", () => app.awake.releaseSync());
1385
1962
  try {
1386
- await app.start(agent, fresh, { pick });
1963
+ await app.start(agent, fresh, { pick, explicit });
1387
1964
  } finally {
1388
1965
  renderer.destroy();
1389
1966
  try {
@@ -1420,3 +1997,8 @@ async function copyToClipboard(renderer: CliRenderer, text: string): Promise<boo
1420
1997
  }
1421
1998
  return osc;
1422
1999
  }
2000
+
2001
+ function reviewCadence(every: number | undefined): string {
2002
+ const n = every ?? 1;
2003
+ return n === 0 ? "only on /review" : n === 1 ? "every turn" : n === 2 ? "every 2nd turn" : n === 3 ? "every 3rd turn" : `every ${n}th turn`;
2004
+ }