codemax-cli 0.0.0-stage → 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/LICENSE +661 -0
- package/NOTICE +27 -0
- package/README.md +163 -2
- package/TRADEMARKS.md +27 -0
- package/bin/codemax +2 -0
- package/bin/codemax.cjs +78 -0
- package/package.json +72 -4
- package/src/adapters/claude.ts +43 -0
- package/src/adapters/codex.ts +101 -0
- package/src/adapters/detect.ts +16 -0
- package/src/adapters/index.ts +21 -0
- package/src/adapters/simple.ts +89 -0
- package/src/adapters/types.ts +87 -0
- package/src/chat/app.ts +1934 -0
- package/src/chat/logo.ts +37 -0
- package/src/chat/markdown.ts +77 -0
- package/src/chat/palette.ts +179 -0
- package/src/chat/permission.ts +111 -0
- package/src/chat/theme.ts +158 -0
- package/src/chat/transcript.ts +342 -0
- package/src/cli.ts +254 -0
- package/src/config.ts +114 -0
- package/src/core/awake.ts +345 -0
- package/src/core/brief.ts +314 -0
- package/src/core/budget.ts +42 -0
- package/src/core/checkpoint.ts +139 -0
- package/src/core/doctor.ts +67 -0
- package/src/core/files.ts +62 -0
- package/src/core/git.ts +36 -0
- package/src/core/hotkey.ts +75 -0
- package/src/core/journal.ts +215 -0
- package/src/core/ledger.ts +153 -0
- package/src/core/menu.ts +134 -0
- package/src/core/orchestrator.ts +352 -0
- package/src/core/paths.ts +30 -0
- package/src/core/prices-live.ts +123 -0
- package/src/core/pricing.ts +162 -0
- package/src/core/pty.ts +99 -0
- package/src/core/quota.ts +55 -0
- package/src/core/review.ts +67 -0
- package/src/core/spawn.ts +20 -0
- package/src/core/terminal.ts +51 -0
- package/src/core/update.ts +90 -0
- package/src/core/usage.ts +116 -0
- package/src/engines/acp.ts +419 -0
- package/src/engines/codex.ts +330 -0
- package/src/engines/env.ts +17 -0
- package/src/engines/errors.ts +47 -0
- package/src/engines/index.ts +154 -0
- package/src/engines/types.ts +131 -0
- package/src/transcripts/claude-usage.ts +63 -0
- package/src/transcripts/claude.ts +69 -0
- package/src/transcripts/codex.ts +74 -0
- package/src/tui/app.ts +377 -0
- package/src/tui/menu.ts +126 -0
- package/src/tui/pane.ts +79 -0
package/src/chat/app.ts
ADDED
|
@@ -0,0 +1,1934 @@
|
|
|
1
|
+
import { BoxRenderable, TextRenderable, TextareaRenderable, createCliRenderer, type CliRenderer, type KeyEvent } from "@opentui/core";
|
|
2
|
+
import type { Config } from "../config.ts";
|
|
3
|
+
import { CONFIG_PATH, hotkeyLabel, saveConfigKey } from "../config.ts";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import type { AgentId, QuotaStatus, SessionRef } from "../adapters/types.ts";
|
|
6
|
+
import { listAdapters, getAdapter } from "../adapters/index.ts";
|
|
7
|
+
import { Ledger } from "../core/ledger.ts";
|
|
8
|
+
import { buildBrief, buildCatchup } from "../core/brief.ts";
|
|
9
|
+
import { diffStat } from "../core/git.ts";
|
|
10
|
+
import { isExhausted, quotaLabel } from "../core/quota.ts";
|
|
11
|
+
import { findHotkey, parseHotkeys, type HotkeySpec } from "../core/hotkey.ts";
|
|
12
|
+
import { createEngine, hasEngine, NO_PERMISSION_PROMPTS, engineFactories, grokPermissionNote } from "../engines/index.ts";
|
|
13
|
+
import type { Engine, EngineEvent, PermissionRequest } from "../engines/types.ts";
|
|
14
|
+
import { strippedKeys } from "../engines/env.ts";
|
|
15
|
+
import { blockers } from "../core/doctor.ts";
|
|
16
|
+
import { Transcript, type AgentBlock } from "./transcript.ts";
|
|
17
|
+
import { PermissionDialog } from "./permission.ts";
|
|
18
|
+
import { Palette, type PaletteItem } from "./palette.ts";
|
|
19
|
+
import { C, DONE_VERBS, G, TIPS, VERBS, applyTheme, fmtDuration, fmtTime, modeLabel, pick } from "./theme.ts";
|
|
20
|
+
import { priceRecord, recordCost, renderUsageTable, sumCost } from "../core/usage.ts";
|
|
21
|
+
import { fmtUsd, resetPriceCache } from "../core/pricing.ts";
|
|
22
|
+
import { refreshLivePricesIfStale } from "../core/prices-live.ts";
|
|
23
|
+
import { addSpend, budgetLevel, spendToday, type BudgetKey } from "../core/budget.ts";
|
|
24
|
+
import { createCheckpoint, listCheckpoints, restoreCheckpoint, checkpointDiffStat, type Checkpoint } from "../core/checkpoint.ts";
|
|
25
|
+
import { MENTION_RE, attachFiles, listProjectFiles, mentionedFiles } from "../core/files.ts";
|
|
26
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
27
|
+
import { platformArgv, shellArgv } from "../core/spawn.ts";
|
|
28
|
+
import { cleanError, isLimitNotice, parseResetTime } from "../engines/errors.ts";
|
|
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
|
+
|
|
35
|
+
const HOTKEY_NAMES: Record<string, string[]> = { "/": ["/", "_", "?"], "^": ["^", "6"] };
|
|
36
|
+
const VERSION = pkg.version;
|
|
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;
|
|
40
|
+
|
|
41
|
+
interface Active {
|
|
42
|
+
agent: AgentId;
|
|
43
|
+
name: string;
|
|
44
|
+
engine: Engine;
|
|
45
|
+
turn: { block: AgentBlock; tools: Set<string>; startedAt: number; tokens: number; chars: number; verb: string; rec?: TurnRecorder } | null;
|
|
46
|
+
replay: AgentBlock | null;
|
|
47
|
+
unsubscribe: () => void;
|
|
48
|
+
mode?: string;
|
|
49
|
+
commands: { name: string; description?: string }[];
|
|
50
|
+
/** Context window fill, when the agent reports it. */
|
|
51
|
+
context?: { used: number; size: number };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Builtin {
|
|
55
|
+
name: string;
|
|
56
|
+
description: string;
|
|
57
|
+
run: (args: string) => void | Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* codemax's chat. One engine at a time; everything on screen is codemax's:
|
|
62
|
+
* transcript, working line, prompt, footer, palette, permission dialog.
|
|
63
|
+
*/
|
|
64
|
+
export class ChatApp {
|
|
65
|
+
private active: Active | null = null;
|
|
66
|
+
private palette: Palette | null = null;
|
|
67
|
+
private permission: PermissionDialog | null = null;
|
|
68
|
+
private permissionQueue: { request: PermissionRequest; engine: Engine }[] = [];
|
|
69
|
+
private detect = new Map<AgentId, { installed: boolean; version?: string; engine: boolean }>();
|
|
70
|
+
private quota = new Map<AgentId, QuotaStatus>();
|
|
71
|
+
private hotkeys: HotkeySpec[];
|
|
72
|
+
private transcript: Transcript;
|
|
73
|
+
private working: TextRenderable;
|
|
74
|
+
private input: TextareaRenderable;
|
|
75
|
+
private inputWrap: BoxRenderable;
|
|
76
|
+
private dialogHost: BoxRenderable;
|
|
77
|
+
private footerText: TextRenderable;
|
|
78
|
+
private shortcuts: BoxRenderable;
|
|
79
|
+
private flash = "";
|
|
80
|
+
private flashTimer: ReturnType<typeof setTimeout> | null = null;
|
|
81
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
82
|
+
private spinnerFrame = 0;
|
|
83
|
+
private history: string[] = [];
|
|
84
|
+
/** API-equivalent USD spent since codemax started (all agents). */
|
|
85
|
+
private sessionCost = 0;
|
|
86
|
+
private today = new Map<BudgetKey, number>();
|
|
87
|
+
private budgetNotified = new Map<BudgetKey, "warn" | "over">();
|
|
88
|
+
private checkpoints: (Checkpoint & { agent: AgentId })[] = [];
|
|
89
|
+
private projectFiles: string[] | null = null;
|
|
90
|
+
private side = new Set<Active>();
|
|
91
|
+
private sessionTokens = 0;
|
|
92
|
+
private historyIdx = -1;
|
|
93
|
+
private draft = "";
|
|
94
|
+
private queue: string[] = [];
|
|
95
|
+
private lastCtrlC = 0;
|
|
96
|
+
private switching = false;
|
|
97
|
+
/** Set by quit(); async work that resumes afterwards (a handoff being built) stops. */
|
|
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) });
|
|
112
|
+
private resolveDone!: () => void;
|
|
113
|
+
readonly done: Promise<void>;
|
|
114
|
+
/** Test hooks. */
|
|
115
|
+
engineFactory: (agent: AgentId) => Engine = createEngine;
|
|
116
|
+
checkBlockers: (agent: AgentId) => ReturnType<typeof blockers> = blockers;
|
|
117
|
+
/** Copy-on-select target (tests swap it to keep the real clipboard untouched). */
|
|
118
|
+
copyText: (text: string) => Promise<boolean> = (text) => copyToClipboard(this.renderer, text);
|
|
119
|
+
|
|
120
|
+
constructor(
|
|
121
|
+
readonly renderer: CliRenderer,
|
|
122
|
+
private config: Config,
|
|
123
|
+
private ledger: Ledger,
|
|
124
|
+
private cwd: string,
|
|
125
|
+
) {
|
|
126
|
+
this.done = new Promise((r) => (this.resolveDone = r));
|
|
127
|
+
this.hotkeys = parseHotkeys(config.hotkeys);
|
|
128
|
+
for (const [id, q] of Object.entries(ledger.quota())) this.quota.set(id as AgentId, q);
|
|
129
|
+
|
|
130
|
+
const root = new BoxRenderable(renderer, { id: "chat-root", width: "100%", height: "100%", flexDirection: "column" });
|
|
131
|
+
this.transcript = new Transcript(renderer);
|
|
132
|
+
root.add(this.transcript.box);
|
|
133
|
+
this.working = new TextRenderable(renderer, { id: "working", content: "", fg: C.dim, height: 1, width: "100%", flexShrink: 0, wrapMode: "none", truncate: true });
|
|
134
|
+
this.working.visible = false;
|
|
135
|
+
root.add(this.working);
|
|
136
|
+
this.dialogHost = new BoxRenderable(renderer, { id: "dialog-host", width: "100%", flexShrink: 0, flexDirection: "column" });
|
|
137
|
+
root.add(this.dialogHost);
|
|
138
|
+
this.inputWrap = new BoxRenderable(renderer, { id: "input-wrap", width: "100%", flexShrink: 0, flexDirection: "column", border: ["top", "bottom"], borderStyle: "single", borderColor: C.accent });
|
|
139
|
+
const inputRow = new BoxRenderable(renderer, { id: "input-row", width: "100%", flexDirection: "row" });
|
|
140
|
+
inputRow.add(new TextRenderable(renderer, { id: "input-prompt", content: `${G.prompt} `, width: 2, fg: C.accent }));
|
|
141
|
+
this.input = new TextareaRenderable(renderer, {
|
|
142
|
+
id: "chat-input",
|
|
143
|
+
flexGrow: 1,
|
|
144
|
+
height: 1,
|
|
145
|
+
placeholder: 'Try "fix the failing test" · / for commands',
|
|
146
|
+
placeholderColor: C.dim,
|
|
147
|
+
keyBindings: [
|
|
148
|
+
{ name: "return", action: "submit" },
|
|
149
|
+
{ name: "return", shift: true, action: "newline" },
|
|
150
|
+
{ name: "j", ctrl: true, action: "newline" },
|
|
151
|
+
],
|
|
152
|
+
onSubmit: () => void this.submit(),
|
|
153
|
+
} as any);
|
|
154
|
+
this.input.onContentChange = () => this.onInputChanged();
|
|
155
|
+
inputRow.add(this.input);
|
|
156
|
+
this.inputWrap.add(inputRow);
|
|
157
|
+
root.add(this.inputWrap);
|
|
158
|
+
this.shortcuts = new BoxRenderable(renderer, { id: "shortcuts", width: "100%", flexShrink: 0, flexDirection: "column", paddingX: 2 });
|
|
159
|
+
this.shortcuts.visible = false;
|
|
160
|
+
for (const l of [
|
|
161
|
+
"/ for commands shift + tab to cycle permission mode esc to interrupt a turn",
|
|
162
|
+
"/switch to change agent ↑ for history ctrl + c twice to quit",
|
|
163
|
+
"/mode to set permissions shift + ⏎ for newline ? to toggle this help",
|
|
164
|
+
"! to run a shell command /model to pick a model /compact for a handoff summary",
|
|
165
|
+
"@ to attach a file /undo reverts the last turn's edits /ask <agent> <msg> for a one-off",
|
|
166
|
+
"ctrl + o expands output ctrl + ^ switches agent /theme dark | light | auto",
|
|
167
|
+
])
|
|
168
|
+
this.shortcuts.add(new TextRenderable(renderer, { content: l, fg: C.dim, wrapMode: "none", truncate: true }));
|
|
169
|
+
root.add(this.shortcuts);
|
|
170
|
+
const footer = new BoxRenderable(renderer, { id: "footer", width: "100%", height: 1, flexShrink: 0, paddingX: 2 });
|
|
171
|
+
this.footerText = new TextRenderable(renderer, { id: "footer-text", content: "", width: "100%", wrapMode: "none", truncate: true, fg: C.dim });
|
|
172
|
+
footer.add(this.footerText);
|
|
173
|
+
root.add(footer);
|
|
174
|
+
renderer.root.add(root);
|
|
175
|
+
// Select text with the mouse to copy it (like a terminal's copy-on-select).
|
|
176
|
+
renderer.on("selection", (sel: { getSelectedText(): string } | null) => {
|
|
177
|
+
const text = sel?.getSelectedText?.() ?? "";
|
|
178
|
+
if (!text.trim()) return;
|
|
179
|
+
void this.copyText(text).then((ok) => this.setFlash(ok ? `copied ${text.length} chars` : "could not copy: no clipboard available"));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
renderer.keyInput.on("keypress", this.onKey);
|
|
183
|
+
renderer.on("resize", () => this.safely("resize", () => {
|
|
184
|
+
this.palette?.render();
|
|
185
|
+
this.renderFooter();
|
|
186
|
+
}));
|
|
187
|
+
this.input.focus();
|
|
188
|
+
this.renderFooter();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async start(agent: AgentId, fresh: boolean, o: { pick?: boolean } = {}) {
|
|
192
|
+
try {
|
|
193
|
+
this.today = spendToday();
|
|
194
|
+
} catch {
|
|
195
|
+
/* no usage yet */
|
|
196
|
+
}
|
|
197
|
+
await this.refreshDetect();
|
|
198
|
+
const ok = (a: AgentId) => this.detect.get(a)?.installed && this.detect.get(a)?.engine;
|
|
199
|
+
const first = ok(agent) ? agent : listAdapters(this.config.order).find((a) => ok(a.id))?.id;
|
|
200
|
+
if (!first) throw new Error("no agent with a protocol engine found (need claude, codex, opencode, grok or gemini on PATH)");
|
|
201
|
+
const notes: string[] = [];
|
|
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
|
+
}
|
|
208
|
+
const stripped = strippedKeys();
|
|
209
|
+
if (stripped.length) notes.push(`${stripped.join(", ")} unset for agents · subscription logins only`);
|
|
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) });
|
|
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(() => {});
|
|
217
|
+
if (o.pick && !fresh && this.openPicker(first)) return this.done;
|
|
218
|
+
await this.launch(first, { fresh });
|
|
219
|
+
return this.done;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------- keys ----------
|
|
223
|
+
|
|
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) {
|
|
247
|
+
if (key.eventType === "release") return;
|
|
248
|
+
if (this.permission) {
|
|
249
|
+
key.stopPropagation();
|
|
250
|
+
this.permission.handleKey(key);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (this.palette) {
|
|
254
|
+
if (this.palette.handleKey(key)) {
|
|
255
|
+
key.stopPropagation();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// other keys fall through to the input; the palette re-filters on content change
|
|
259
|
+
}
|
|
260
|
+
if (this.isHotkey(key)) {
|
|
261
|
+
key.stopPropagation();
|
|
262
|
+
this.openSwitch();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (this.isShiftTab(key)) {
|
|
266
|
+
key.stopPropagation();
|
|
267
|
+
void this.cycleMode();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (key.name === "escape") {
|
|
271
|
+
if (this.active?.turn) {
|
|
272
|
+
key.stopPropagation();
|
|
273
|
+
void this.active.engine.cancel();
|
|
274
|
+
this.setFlash("interrupting…");
|
|
275
|
+
} else if (this.input.plainText) {
|
|
276
|
+
key.stopPropagation();
|
|
277
|
+
this.input.clear();
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (key.name === "o" && key.ctrl) {
|
|
282
|
+
key.stopPropagation();
|
|
283
|
+
this.setFlash(this.transcript.toggleExpanded() ? "showing full output · ctrl+o to collapse" : "output collapsed");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (key.name === "c" && key.ctrl) {
|
|
287
|
+
key.stopPropagation();
|
|
288
|
+
const now = Date.now();
|
|
289
|
+
if (this.active?.turn) {
|
|
290
|
+
void this.active.engine.cancel();
|
|
291
|
+
this.setFlash("interrupting… (ctrl+c again to quit)");
|
|
292
|
+
} else if (this.input.plainText) {
|
|
293
|
+
this.draft = this.input.plainText;
|
|
294
|
+
this.input.clear();
|
|
295
|
+
this.setFlash("prompt cleared · ↑ to restore · ctrl+c again to quit");
|
|
296
|
+
} else if (now - this.lastCtrlC < 2000) {
|
|
297
|
+
void this.quit();
|
|
298
|
+
return;
|
|
299
|
+
} else this.setFlash("ctrl+c again to quit");
|
|
300
|
+
this.lastCtrlC = now;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (key.name === "up" && !this.input.plainText.includes("\n") && (this.history.length || this.draft)) {
|
|
304
|
+
if (this.input.plainText === "" || this.historyIdx >= 0) {
|
|
305
|
+
key.stopPropagation();
|
|
306
|
+
this.recall(-1);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (key.name === "down" && this.historyIdx >= 0) {
|
|
311
|
+
key.stopPropagation();
|
|
312
|
+
this.recall(1);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (key.name === "?" && this.input.plainText === "" && !this.palette) {
|
|
316
|
+
key.stopPropagation();
|
|
317
|
+
this.shortcuts.visible = !this.shortcuts.visible;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private isHotkey(key: KeyEvent): boolean {
|
|
323
|
+
if (key.raw && findHotkey(new TextEncoder().encode(key.raw), this.hotkeys)) return true;
|
|
324
|
+
if (!key.ctrl) return false;
|
|
325
|
+
return this.hotkeys.some((h) => (HOTKEY_NAMES[h.name.slice(5)] ?? [h.name.slice(5)]).includes(key.name));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private isShiftTab(key: KeyEvent): boolean {
|
|
329
|
+
return (key.name === "tab" && key.shift) || key.name === "backtab" || key.raw === "\x1b[Z";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private recall(dir: -1 | 1) {
|
|
333
|
+
if (this.historyIdx < 0) {
|
|
334
|
+
this.draft = this.input.plainText;
|
|
335
|
+
if (dir === 1) return;
|
|
336
|
+
if (!this.history.length) {
|
|
337
|
+
if (this.draft === "" && this.lastDraft) this.input.setText(this.lastDraft);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
this.historyIdx = this.history.length - 1;
|
|
341
|
+
} else {
|
|
342
|
+
this.historyIdx += dir;
|
|
343
|
+
}
|
|
344
|
+
if (this.historyIdx >= this.history.length) {
|
|
345
|
+
this.historyIdx = -1;
|
|
346
|
+
this.input.setText(this.draft);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (this.historyIdx < 0) this.historyIdx = 0;
|
|
350
|
+
this.input.setText(this.history[this.historyIdx]!);
|
|
351
|
+
}
|
|
352
|
+
private lastDraft = "";
|
|
353
|
+
|
|
354
|
+
private onInputChanged() {
|
|
355
|
+
const text = this.input.plainText;
|
|
356
|
+
if (this.historyIdx >= 0 && text !== this.history[this.historyIdx]) this.historyIdx = -1;
|
|
357
|
+
const mention = text.match(MENTION_RE);
|
|
358
|
+
if (mention && !text.startsWith("/")) {
|
|
359
|
+
void this.openFilePalette(mention[2]!);
|
|
360
|
+
return;
|
|
361
|
+
} else if (this.paletteKind === "files") this.closePalette();
|
|
362
|
+
if (text.startsWith("/") && !text.includes("\n") && !text.includes(" ")) {
|
|
363
|
+
if (!this.palette) this.openCommandPalette(text.slice(1));
|
|
364
|
+
else this.palette.setFilter(text.slice(1));
|
|
365
|
+
} else if (this.palette && this.paletteKind === "commands") {
|
|
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;
|
|
371
|
+
}
|
|
372
|
+
if (this.shortcuts.visible && text) this.shortcuts.visible = false;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------- submit / commands ----------
|
|
376
|
+
|
|
377
|
+
private async submit() {
|
|
378
|
+
const text = this.input.plainText.trim();
|
|
379
|
+
if (!text) return;
|
|
380
|
+
if (this.palette && this.paletteKind === "commands") {
|
|
381
|
+
// Enter on an exact or highlighted command runs it; Enter on a typo does nothing dangerous.
|
|
382
|
+
const cur = this.palette.current;
|
|
383
|
+
const exact = cur && cur.label.toLowerCase() === text.toLowerCase();
|
|
384
|
+
if (cur && (exact || text.length > 1)) {
|
|
385
|
+
this.closePalette();
|
|
386
|
+
this.input.clear();
|
|
387
|
+
return this.runCommandValue(cur.value, "");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
this.closePalette();
|
|
391
|
+
this.input.clear();
|
|
392
|
+
this.pushHistory(text);
|
|
393
|
+
const busy = !!this.active?.turn;
|
|
394
|
+
const immediate = /^\/(quit|exit|q|help|status|usage|cost|diff|clear|switch|agents)\b/.test(text);
|
|
395
|
+
if (busy && !immediate && (text.startsWith("/") || text.startsWith("!"))) {
|
|
396
|
+
this.queue.push(text);
|
|
397
|
+
this.transcript.note(`queued until the turn ends: ${text.slice(0, 60)}`, C.dim);
|
|
398
|
+
this.renderFooter();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (text.startsWith("/")) return this.command(text);
|
|
402
|
+
if (text.startsWith("!")) return this.shell(text.slice(1).trim());
|
|
403
|
+
if (!this.active) {
|
|
404
|
+
this.transcript.note("no agent running — /switch to start one", C.warn);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const route = this.routeFor(text);
|
|
408
|
+
if (route && route !== this.active.agent && !this.active.turn) return this.askAgent(route, text, `routed to ${route} by rule`);
|
|
409
|
+
if (this.active.turn) {
|
|
410
|
+
this.queue.push(text);
|
|
411
|
+
this.transcript.note(`queued: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`, C.dim);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
await this.send(this.active, text);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Append @file contents and pending shell output to an outgoing message. */
|
|
418
|
+
private withAttachments(text: string): string {
|
|
419
|
+
const files = mentionedFiles(text, this.cwd);
|
|
420
|
+
if (files.length) {
|
|
421
|
+
text += attachFiles(files, this.cwd);
|
|
422
|
+
this.transcript.note(`attached ${files.join(", ")}`, C.dimmer);
|
|
423
|
+
}
|
|
424
|
+
if (this.pendingShell.length) {
|
|
425
|
+
text = `${text}\n\n[shell output the user ran in this repo]\n${this.pendingShell.join("\n\n")}`;
|
|
426
|
+
this.pendingShell = [];
|
|
427
|
+
}
|
|
428
|
+
return text;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private async checkpoint(active: Active, text: string): Promise<Checkpoint | null> {
|
|
432
|
+
if (!this.config.checkpoints) return null;
|
|
433
|
+
const cp = await createCheckpoint(this.cwd, `before ${active.agent}: ${text.slice(0, 60).replace(/\s+/g, " ")}`).catch(() => null);
|
|
434
|
+
if (cp) this.checkpoints.push({ ...cp, agent: active.agent });
|
|
435
|
+
return cp;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private async undo() {
|
|
439
|
+
if (this.active?.turn) return this.transcript.note("the agent is working; Esc first, then /undo", C.warn);
|
|
440
|
+
const cp = this.checkpoints.pop() ?? (await listCheckpoints(this.cwd, 1))[0];
|
|
441
|
+
if (!cp) return this.transcript.note("no checkpoint to restore (checkpoints need a git repo)", C.warn);
|
|
442
|
+
const stat = await checkpointDiffStat(this.cwd, cp.sha);
|
|
443
|
+
if (!stat.trim()) {
|
|
444
|
+
this.transcript.note(`nothing changed since the checkpoint "${cp.label}"`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const r = await restoreCheckpoint(this.cwd, cp.sha);
|
|
448
|
+
this.transcript.note(`reverted to the snapshot ${cp.label}`, C.warn);
|
|
449
|
+
for (const l of stat.split("\n")) this.transcript.note(l);
|
|
450
|
+
if (r.removed.length) this.transcript.note(`removed ${r.removed.length} new file(s): ${r.removed.slice(0, 5).join(", ")}${r.removed.length > 5 ? "…" : ""}`);
|
|
451
|
+
this.pendingShell.push("[codemax] The user reverted the file changes from the last turn with /undo. The working tree is back to the state before that turn.");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private async showCheckpoints() {
|
|
455
|
+
const list = await listCheckpoints(this.cwd, 10);
|
|
456
|
+
if (!list.length) return this.transcript.note("no checkpoints yet");
|
|
457
|
+
for (const c of list) this.transcript.note(`${fmtTime(c.ts)} ${c.sha.slice(0, 8)} ${c.label}`);
|
|
458
|
+
this.transcript.note("/undo restores the most recent one");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private routeFor(text: string): AgentId | null {
|
|
462
|
+
for (const r of this.config.routes ?? []) {
|
|
463
|
+
try {
|
|
464
|
+
if (new RegExp(r.match, "i").test(text) && this.detect.get(r.agent)?.engine) return r.agent;
|
|
465
|
+
} catch {
|
|
466
|
+
/* bad regex in config */
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private async ask(args: string) {
|
|
473
|
+
const [id, ...rest] = args.trim().split(/\s+/);
|
|
474
|
+
const msg = rest.join(" ");
|
|
475
|
+
if (!id || !msg) return this.transcript.note("usage: /ask <agent> <message> e.g. /ask codex review the last commit", C.warn);
|
|
476
|
+
if (!this.detect.get(id as AgentId)?.engine) return this.transcript.note(`no protocol engine for "${id}"`, C.warn);
|
|
477
|
+
await this.askAgent(id as AgentId, msg, `asked ${id}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** One message to another agent in a throwaway session; the current agent stays active. */
|
|
481
|
+
private async askAgent(agent: AgentId, text: string, why: string) {
|
|
482
|
+
const blocked = this.checkBlockers(agent);
|
|
483
|
+
if (blocked.length) return this.transcript.error(`${agent} not started: ${blocked[0]!.message}`);
|
|
484
|
+
const adapter = getAdapter(agent);
|
|
485
|
+
const engine = this.engineFactory(agent);
|
|
486
|
+
const side: Active = { agent, name: adapter.name, engine, turn: null, replay: null, unsubscribe: () => {}, commands: [] };
|
|
487
|
+
this.side.add(side);
|
|
488
|
+
side.unsubscribe = engine.onEvent((e) => this.onEngineEvent(side, e));
|
|
489
|
+
this.transcript.divider(`${why} · ${adapter.name}`);
|
|
490
|
+
this.transcript.user(text);
|
|
491
|
+
try {
|
|
492
|
+
await engine.start({ cwd: this.cwd });
|
|
493
|
+
const brief = this.ledger.turns().length ? await buildBrief({ ledger: this.ledger, cwd: this.cwd, to: agent, from: this.active?.agent, config: this.config, purpose: "context" }) : "";
|
|
494
|
+
const body = this.withAttachments(text);
|
|
495
|
+
await this.checkpoint(side, text);
|
|
496
|
+
this.ledger.addTurns([{ ts: Date.now(), agent, sessionId: engine.session?.id ?? "?", role: "user", text }]);
|
|
497
|
+
// One-off answers are not task work: they stay out of the journal (a "done" turn here
|
|
498
|
+
// would split the current task in two).
|
|
499
|
+
await this.runTurn(side, brief ? `${brief}\n\n## This message (answer it; the user stays with ${this.active?.name ?? "another agent"})\n${body}` : body, { journal: false });
|
|
500
|
+
} catch (e: any) {
|
|
501
|
+
this.transcript.error(`${adapter.name}: ${e?.message ?? e}`);
|
|
502
|
+
} finally {
|
|
503
|
+
side.unsubscribe();
|
|
504
|
+
this.side.delete(side);
|
|
505
|
+
await engine.close().catch(() => {});
|
|
506
|
+
if (this.active) this.transcript.divider(`back to ${this.active.name}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private showBudget() {
|
|
511
|
+
const b = this.config.budgets ?? {};
|
|
512
|
+
const keys = Object.keys(b) as BudgetKey[];
|
|
513
|
+
if (!keys.length) return this.transcript.note('no budgets set · add "budgets": {"claude": 20, "all": 50} (USD per day) to ~/.codemax/config.json');
|
|
514
|
+
for (const k of keys) {
|
|
515
|
+
const spent = this.today.get(k) ?? 0;
|
|
516
|
+
this.transcript.note(`${k.padEnd(8)} ${fmtUsd(spent)} of ${fmtUsd(b[k])} today (${Math.round((spent / b[k]!) * 100)}%)`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private checkBudget(agent: AgentId) {
|
|
521
|
+
const b = this.config.budgets ?? {};
|
|
522
|
+
for (const k of [agent, "all"] as BudgetKey[]) {
|
|
523
|
+
const level = budgetLevel(this.today.get(k) ?? 0, b[k]);
|
|
524
|
+
if (level === "ok" || this.budgetNotified.get(k) === level || (level === "warn" && this.budgetNotified.get(k) === "over")) continue;
|
|
525
|
+
this.budgetNotified.set(k, level);
|
|
526
|
+
const spent = fmtUsd(this.today.get(k) ?? 0);
|
|
527
|
+
const label = k === "all" ? "all agents" : getAdapter(k).name;
|
|
528
|
+
if (level === "warn") this.transcript.note(`${label} has used ${spent} of its ${fmtUsd(b[k])} daily budget (80%+)`, C.warn);
|
|
529
|
+
else this.offerHandoff(agent, `${label} reached its ${fmtUsd(b[k])} daily budget (${spent})`, k === "all");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Best agent to continue on: config order, installed with an engine, not exhausted or over budget. */
|
|
534
|
+
private nextAgent(from: AgentId): AgentId | null {
|
|
535
|
+
const b = this.config.budgets ?? {};
|
|
536
|
+
for (const a of listAdapters(this.config.order)) {
|
|
537
|
+
if (a.id === from) continue;
|
|
538
|
+
const d = this.detect.get(a.id);
|
|
539
|
+
if (!d?.installed || !d.engine) continue;
|
|
540
|
+
if (isExhausted(this.quota.get(a.id))) continue;
|
|
541
|
+
if (budgetLevel(this.today.get(a.id) ?? 0, b[a.id]) === "over") continue;
|
|
542
|
+
return a.id;
|
|
543
|
+
}
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** One-key handoff offer: Enter continues the task in the next agent. */
|
|
548
|
+
private pendingOffer: { from: AgentId; reason: string; allAgents: boolean } | null = null;
|
|
549
|
+
|
|
550
|
+
private offerHandoff(from: AgentId, reason: string, allAgents = false) {
|
|
551
|
+
// Never interrupt a running turn: show the offer when it ends.
|
|
552
|
+
if (this.active?.turn || this.permission) {
|
|
553
|
+
if (!this.pendingOffer) this.transcript.note(`${reason} · you'll be offered a handoff when this turn ends`, C.warn);
|
|
554
|
+
this.pendingOffer = { from, reason, allAgents };
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (this.paletteKind === "handoff") return;
|
|
558
|
+
const next = allAgents ? null : this.nextAgent(from);
|
|
559
|
+
this.transcript.note(reason, C.warn);
|
|
560
|
+
if (!next) {
|
|
561
|
+
this.transcript.note(allAgents ? "every agent shares that budget · raise it in ~/.codemax/config.json to continue" : "no other agent is available · /switch to pick one anyway", C.warn);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
this.closePalette();
|
|
565
|
+
const items: PaletteItem[] = [
|
|
566
|
+
{ label: `Continue in ${getAdapter(next).name}`, description: "hand over the conversation and the unfinished request", value: next },
|
|
567
|
+
...listAdapters(this.config.order)
|
|
568
|
+
.filter((a) => a.id !== next && a.id !== from && this.detect.get(a.id)?.engine)
|
|
569
|
+
.map((a) => ({ label: `Continue in ${a.name}`, description: isExhausted(this.quota.get(a.id)) ? "out of quota" : "", value: a.id, disabled: isExhausted(this.quota.get(a.id)) })),
|
|
570
|
+
{ label: `Stay on ${getAdapter(from).name}`, description: "keep going here anyway", value: "__stay" },
|
|
571
|
+
];
|
|
572
|
+
this.paletteKind = "handoff";
|
|
573
|
+
this.palette = new Palette(this.renderer, {
|
|
574
|
+
title: `${reason} · Enter to continue in ${getAdapter(next).name}`,
|
|
575
|
+
items,
|
|
576
|
+
onPick: (it) => {
|
|
577
|
+
this.closePalette();
|
|
578
|
+
if (it.value !== "__stay") void this.switchTo(it.value as AgentId, false, { resumeTask: true });
|
|
579
|
+
},
|
|
580
|
+
onClose: () => this.closePalette(),
|
|
581
|
+
}, this.paletteAnchor);
|
|
582
|
+
// keep the offered order (Continue first), not alphabetical
|
|
583
|
+
(this.palette as any).opts.items.forEach((it: PaletteItem, i: number) => (it.weight = 100 - i));
|
|
584
|
+
this.palette.setFilter("");
|
|
585
|
+
this.renderer.root.add(this.palette.box);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private async openFilePalette(filter: string) {
|
|
589
|
+
if (!this.projectFiles) this.projectFiles = await listProjectFiles(this.cwd).catch(() => []);
|
|
590
|
+
if (this.paletteKind === "files" && this.palette) return this.palette.setFilter(filter);
|
|
591
|
+
this.closePalette(false);
|
|
592
|
+
this.paletteKind = "files";
|
|
593
|
+
this.palette = new Palette(this.renderer, {
|
|
594
|
+
title: "Attach a file · Tab or Enter to insert · Esc to cancel",
|
|
595
|
+
items: this.projectFiles.map((f) => ({ label: f, value: f })),
|
|
596
|
+
filter,
|
|
597
|
+
tabAccepts: true,
|
|
598
|
+
onPick: (it) => {
|
|
599
|
+
const text = this.input.plainText.replace(MENTION_RE, (_m, pre) => `${pre}@${it.value} `);
|
|
600
|
+
this.closePalette();
|
|
601
|
+
this.input.setText(text);
|
|
602
|
+
try {
|
|
603
|
+
(this.input as any).gotoBufferEnd?.();
|
|
604
|
+
} catch {
|
|
605
|
+
/* cursor helper missing */
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
onClose: () => this.closePalette(),
|
|
609
|
+
}, this.paletteAnchor);
|
|
610
|
+
this.renderer.root.add(this.palette.box);
|
|
611
|
+
}
|
|
612
|
+
|
|
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
|
+
*/
|
|
618
|
+
private openPicker(defaultAgent: AgentId): boolean {
|
|
619
|
+
const sessions = this.ledger.sessions();
|
|
620
|
+
const turns = this.ledger.turns();
|
|
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 };
|
|
631
|
+
});
|
|
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;
|
|
635
|
+
this.paletteKind = "picker";
|
|
636
|
+
this.palette = new Palette(this.renderer, {
|
|
637
|
+
title: `Choose an agent to continue with · Enter to start · Esc for ${getAdapter(defaultAgent).name}`,
|
|
638
|
+
items,
|
|
639
|
+
onPick: (it) => {
|
|
640
|
+
this.closePalette();
|
|
641
|
+
const [kind, agent] = it.value.split(":") as ["resume" | "new", AgentId];
|
|
642
|
+
void this.launch(agent, { fresh: kind === "new" });
|
|
643
|
+
},
|
|
644
|
+
onClose: () => {
|
|
645
|
+
this.closePalette();
|
|
646
|
+
void this.launch(defaultAgent, { fresh: false });
|
|
647
|
+
},
|
|
648
|
+
}, this.paletteAnchor);
|
|
649
|
+
this.renderer.root.add(this.palette.box);
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Run one queued entry the same way as if it had just been typed. */
|
|
654
|
+
private async dispatch(active: Active, text: string) {
|
|
655
|
+
if (text.startsWith("!")) return this.shell(text.slice(1).trim());
|
|
656
|
+
if (text.startsWith("/")) return this.command(text);
|
|
657
|
+
return this.send(active, text);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private async drainQueue(active: Active) {
|
|
661
|
+
while (this.queue.length && this.active === active && !active.turn) await this.dispatch(active, this.queue.shift()!);
|
|
662
|
+
this.renderFooter();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private async send(active: Active, text: string) {
|
|
666
|
+
if (this.closed) return;
|
|
667
|
+
this.transcript.user(text);
|
|
668
|
+
text = this.withAttachments(text);
|
|
669
|
+
const cp = await this.checkpoint(active, text);
|
|
670
|
+
this.ledger.addTurns([{ ts: Date.now(), agent: active.agent, sessionId: active.engine.session?.id ?? "?", role: "user", text }]);
|
|
671
|
+
const rec = new TurnRecorder(this.ledger.journal, active.agent);
|
|
672
|
+
rec.user(text, cp?.sha);
|
|
673
|
+
await this.runTurn(active, text, { rec });
|
|
674
|
+
await this.drainQueue(active);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private pushHistory(text: string) {
|
|
678
|
+
if (this.history.at(-1) !== text) this.history.push(text);
|
|
679
|
+
if (this.history.length > 200) this.history.shift();
|
|
680
|
+
this.historyIdx = -1;
|
|
681
|
+
this.draft = "";
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private builtins(): Builtin[] {
|
|
685
|
+
return [
|
|
686
|
+
{ name: "/switch", description: "Switch to another agent (keeps the conversation)", run: () => this.openSwitch() },
|
|
687
|
+
{ name: "/mode", description: "Set how often the agent asks permission", run: (a) => this.openModes(a) },
|
|
688
|
+
{ name: "/model", description: "Pick the model for the current agent", run: (a) => this.openModels(a) },
|
|
689
|
+
{ name: "/effort", description: "Reasoning effort for the current agent, when it offers one", run: (a) => this.openEffort(a) },
|
|
690
|
+
{ name: "/task", description: "The shared task: request, plan, who did what, where it stopped, files changed", run: () => void this.showTask() },
|
|
691
|
+
{ name: "/compact", description: "Have the agent write a project summary used in every handoff", run: () => void this.compact() },
|
|
692
|
+
{ name: "/diff", description: "Uncommitted changes in this repo", run: () => void this.showDiff() },
|
|
693
|
+
{ name: "/agents", description: "Show agents, sessions and quota", run: () => this.openSwitch() },
|
|
694
|
+
{ name: "/usage", description: "Tokens and API-equivalent cost per agent (like /cost)", run: () => this.showUsage() },
|
|
695
|
+
{ name: "/cost", description: "Alias of /usage", run: () => this.showUsage() },
|
|
696
|
+
{ name: "/status", description: "Session, ledger and quota details", run: () => this.showStatus() },
|
|
697
|
+
{ name: "/clear", description: "Clear the screen (history stays in the ledger)", run: () => this.clearScreen() },
|
|
698
|
+
{ name: "/help", description: "Keyboard shortcuts and commands", run: () => void (this.shortcuts.visible = true) },
|
|
699
|
+
{ name: "/new", description: "Start a fresh session with the current agent", run: () => void (this.active && this.switchTo(this.active.agent, true)) },
|
|
700
|
+
{ name: "/undo", description: "Revert the files changed by the last agent turn", run: () => void this.undo() },
|
|
701
|
+
{ name: "/checkpoints", description: "List working-tree snapshots taken before each turn", run: () => void this.showCheckpoints() },
|
|
702
|
+
{ name: "/ask", description: "Send one message to another agent without switching: /ask codex <msg>", run: (a) => void this.ask(a) },
|
|
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) },
|
|
711
|
+
{ name: "/theme", description: "Colour theme: /theme dark | light | auto (saved; restart to apply everywhere)", run: (a) => this.setThemePref(a) },
|
|
712
|
+
{ name: "/quit", description: "Quit codemax", run: () => void this.quit() },
|
|
713
|
+
];
|
|
714
|
+
}
|
|
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
|
+
|
|
753
|
+
private setThemePref(arg: string) {
|
|
754
|
+
const v = arg.trim().toLowerCase();
|
|
755
|
+
if (v !== "dark" && v !== "light" && v !== "auto") {
|
|
756
|
+
this.transcript.note(`theme is ${this.config.theme ?? "auto"} · use /theme dark, /theme light or /theme auto`);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
this.config.theme = v;
|
|
760
|
+
try {
|
|
761
|
+
saveConfigKey("theme", v);
|
|
762
|
+
} catch {
|
|
763
|
+
/* read-only home: still applies for this run */
|
|
764
|
+
}
|
|
765
|
+
if (v !== "auto") applyTheme(v);
|
|
766
|
+
this.transcript.note(`theme set to ${v}; new lines use it now, everything after a restart`, C.accent);
|
|
767
|
+
this.renderFooter();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private async command(line: string) {
|
|
771
|
+
const [raw, ...rest] = line.split(/\s+/);
|
|
772
|
+
const name = raw!.toLowerCase();
|
|
773
|
+
const b = this.builtins().find((x) => x.name === name || x.name === name.replace(/^\/(exit|q)$/, "/quit"));
|
|
774
|
+
if (b) return b.run(rest.join(" "));
|
|
775
|
+
if (name === "/exit" || name === "/q") return this.quit();
|
|
776
|
+
if (this.active?.commands.some((c) => `/${c.name}` === name)) {
|
|
777
|
+
if (this.active.turn) {
|
|
778
|
+
this.queue.push(line);
|
|
779
|
+
this.transcript.note(`queued ${name} until the turn ends`, C.dim);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
return this.send(this.active, line);
|
|
783
|
+
}
|
|
784
|
+
this.transcript.note(`Unknown command: ${name}`, C.warn);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
private runCommandValue(value: string, args: string) {
|
|
788
|
+
if (value.startsWith("builtin:")) return this.builtins().find((b) => b.name === value.slice(8))?.run(args);
|
|
789
|
+
if (value.startsWith("agentcmd:")) return this.command(`/${value.slice(9)}${args ? " " + args : ""}`);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private pendingShell: string[] = [];
|
|
793
|
+
|
|
794
|
+
/** `!cmd`: run locally, show output, and attach it to the next prompt as context. */
|
|
795
|
+
private async shell(cmd: string) {
|
|
796
|
+
if (!cmd) return;
|
|
797
|
+
this.transcript.user(`! ${cmd}`);
|
|
798
|
+
const p = Bun.spawn(shellArgv(cmd), { cwd: this.cwd, stdout: "pipe", stderr: "pipe" });
|
|
799
|
+
const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
800
|
+
const code = await p.exited;
|
|
801
|
+
const body = (out + (err ? `\n${err}` : "")).trimEnd() || "(no output)";
|
|
802
|
+
const block = this.transcript.agent();
|
|
803
|
+
block.tool(`sh-${Date.now()}`, `$ ${cmd}${code ? ` (exit ${code})` : ""}`, code ? "failed" : "completed", body);
|
|
804
|
+
this.pendingShell.push(`$ ${cmd}\n${body.slice(0, 4000)}`);
|
|
805
|
+
this.transcript.note("output will be sent with your next message", C.dimmer);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private async showDiff() {
|
|
809
|
+
const { diffStat } = await import("../core/git.ts");
|
|
810
|
+
for (const l of (await diffStat(this.cwd)).split("\n")) this.transcript.note(l);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private openModels(arg: string) {
|
|
814
|
+
const a = this.active;
|
|
815
|
+
if (!a) return;
|
|
816
|
+
const models = a.engine.info?.models ?? [];
|
|
817
|
+
if (!a.engine.setModel || !models.length) return this.transcript.note(`${a.name} does not expose model choice over its protocol`, C.dim);
|
|
818
|
+
if (models.length === 1 && !arg) {
|
|
819
|
+
const effort = this.effortOption(a);
|
|
820
|
+
return this.transcript.note(`${a.name} offers one model on your plan: ${models[0]!.name}${effort ? ` · /effort changes its reasoning effort (now ${effort.current})` : ""}`);
|
|
821
|
+
}
|
|
822
|
+
const apply = async (id: string) => {
|
|
823
|
+
try {
|
|
824
|
+
await a.engine.setModel!(id);
|
|
825
|
+
this.transcript.note(`model: ${id}`);
|
|
826
|
+
if (a.agent === "cursor") this.transcript.note("Cursor saves this as its default model, so Cursor uses it outside codemax too", C.warn);
|
|
827
|
+
this.renderFooter();
|
|
828
|
+
} catch (e: any) {
|
|
829
|
+
this.transcript.error(`could not switch model: ${e?.message ?? e}`);
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
if (arg) return void apply(arg);
|
|
833
|
+
this.closePalette();
|
|
834
|
+
this.paletteKind = "modes";
|
|
835
|
+
this.palette = new Palette(this.renderer, {
|
|
836
|
+
title: `Model for ${a.name}`,
|
|
837
|
+
items: models.map((m) => ({ label: m.id, description: `${m.name !== m.id ? m.name : ""}${m.id === a.engine.info?.currentModel ? " (current)" : ""}`, value: m.id })),
|
|
838
|
+
onPick: (it) => {
|
|
839
|
+
this.closePalette();
|
|
840
|
+
void apply(it.value);
|
|
841
|
+
},
|
|
842
|
+
onClose: () => this.closePalette(),
|
|
843
|
+
}, this.paletteAnchor);
|
|
844
|
+
this.renderer.root.add(this.palette.box);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private effortOption(a: Active) {
|
|
848
|
+
return a.engine.info?.options?.find((o) => o.category === "thought_level" || /effort|reasoning/i.test(o.id));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** /effort: reasoning effort, for agents that expose it (Grok, Cursor variants). */
|
|
852
|
+
private openEffort(arg: string) {
|
|
853
|
+
const a = this.active;
|
|
854
|
+
if (!a) return;
|
|
855
|
+
const o = this.effortOption(a);
|
|
856
|
+
if (!o || !a.engine.setOption) return this.transcript.note(`${a.name} does not expose reasoning effort`, C.dim);
|
|
857
|
+
const apply = async (value: string) => {
|
|
858
|
+
try {
|
|
859
|
+
await a.engine.setOption!(o.id, value);
|
|
860
|
+
this.transcript.note(`${o.name.toLowerCase()}: ${value}`);
|
|
861
|
+
this.renderFooter();
|
|
862
|
+
} catch (e: any) {
|
|
863
|
+
this.transcript.error(`could not change ${o.name.toLowerCase()}: ${e?.message ?? e}`);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
const want = arg.trim().toLowerCase();
|
|
867
|
+
if (want) {
|
|
868
|
+
const v = o.values.find((x) => x.value.toLowerCase() === want || x.name.toLowerCase() === want);
|
|
869
|
+
if (v) return void apply(v.value);
|
|
870
|
+
return this.transcript.note(`unknown ${o.name.toLowerCase()} "${arg}" (${o.values.map((x) => x.value).join(", ")})`, C.warn);
|
|
871
|
+
}
|
|
872
|
+
this.closePalette();
|
|
873
|
+
this.paletteKind = "modes";
|
|
874
|
+
this.palette = new Palette(this.renderer, {
|
|
875
|
+
title: `${o.name} for ${a.name}`,
|
|
876
|
+
items: o.values.map((v) => ({ label: v.value, description: `${v.name !== v.value ? v.name : ""}${v.value === o.current ? " (current)" : ""}${v.description ? ` · ${v.description}` : ""}`, value: v.value })),
|
|
877
|
+
onPick: (it) => {
|
|
878
|
+
this.closePalette();
|
|
879
|
+
void apply(it.value);
|
|
880
|
+
},
|
|
881
|
+
onClose: () => this.closePalette(),
|
|
882
|
+
}, this.paletteAnchor);
|
|
883
|
+
this.renderer.root.add(this.palette.box);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Ask the active agent for a compact project summary and store it for every future handoff. */
|
|
887
|
+
async compact(o: { quiet?: boolean } = {}): Promise<boolean> {
|
|
888
|
+
const a = this.active;
|
|
889
|
+
if (!a) return false;
|
|
890
|
+
if (a.turn) {
|
|
891
|
+
if (!o.quiet) this.transcript.note("the agent is busy; run /compact when the turn ends", C.warn);
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
const prompt =
|
|
895
|
+
"[codemax] Write a compact project summary for another coding agent that will take over this repository. " +
|
|
896
|
+
"Max 250 words, plain markdown: goal, what is done (files touched), what is in progress, open problems, next step. " +
|
|
897
|
+
"Do not use any tools. Reply with the summary only.";
|
|
898
|
+
this.transcript.note(o.quiet ? `asking ${a.name} for a handoff summary before switching · esc to skip` : `asking ${a.name} for a handoff summary…`);
|
|
899
|
+
const block = this.transcript.agent();
|
|
900
|
+
a.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: "Summarizing" };
|
|
901
|
+
this.startSpinner(a);
|
|
902
|
+
const stop = await a.engine.prompt(prompt);
|
|
903
|
+
a.turn = null;
|
|
904
|
+
this.stopSpinner();
|
|
905
|
+
const text = block.text().trim();
|
|
906
|
+
if (stop === "end_turn" && text.length > 40) {
|
|
907
|
+
this.ledger.writeSummary(`_${a.name}, ${new Date().toISOString().slice(0, 16).replace("T", " ")}_\n\n${text}`);
|
|
908
|
+
block.footer("summary saved · used in every handoff");
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
block.footer("no summary saved");
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private async showTask() {
|
|
916
|
+
const j = this.ledger.journal;
|
|
917
|
+
const steps = j.steps();
|
|
918
|
+
const task = j.task(steps);
|
|
919
|
+
if (!task.request) return this.transcript.note("no task yet · send a request and every agent's steps are recorded here");
|
|
920
|
+
const t = (ts: number) => fmtTime(ts);
|
|
921
|
+
const lastEnd = [...task.steps].reverse().find((s) => s.kind === "end") as Extract<typeof steps[number], { kind: "end" }> | undefined;
|
|
922
|
+
const plan = [...task.steps].reverse().find((s) => s.kind === "plan") as Extract<typeof steps[number], { kind: "plan" }> | undefined;
|
|
923
|
+
const tools = task.steps.filter((s) => s.kind === "tool");
|
|
924
|
+
const byAgent = new Map<string, number>();
|
|
925
|
+
for (const s of tools) byAgent.set(s.agent, (byAgent.get(s.agent) ?? 0) + 1);
|
|
926
|
+
const status = !lastEnd ? "just started"
|
|
927
|
+
: lastEnd.outcome === "done" ? (plan?.entries.some((e) => e.status !== "completed") ? `in progress · ${lastEnd.agent} finished a turn at ${t(lastEnd.ts)}` : `done · ${lastEnd.agent} at ${t(lastEnd.ts)}`)
|
|
928
|
+
: lastEnd.outcome === "limit" ? `stopped · ${lastEnd.agent} hit its limit at ${t(lastEnd.ts)} · /switch to continue elsewhere`
|
|
929
|
+
: lastEnd.outcome === "cancelled" ? `paused · interrupted at ${t(lastEnd.ts)}`
|
|
930
|
+
: `stopped · ${lastEnd.agent} hit an error at ${t(lastEnd.ts)}`;
|
|
931
|
+
this.transcript.note(`task: "${task.request.text.replace(/\s+/g, " ").slice(0, 120)}" · started ${t(task.request.ts)}`, C.accent);
|
|
932
|
+
this.transcript.note(`status: ${status}`);
|
|
933
|
+
if (task.followUps.length) this.transcript.note(`follow-ups: ${task.followUps.length}`);
|
|
934
|
+
this.transcript.note(`steps: ${[...byAgent].map(([a, n]) => `${a} ${n}`).join(" · ") || "none yet"}`);
|
|
935
|
+
if (plan) for (const e of plan.entries) this.transcript.note(`${e.status === "completed" ? G.done_ : e.status === "in_progress" ? G.doing : G.todo} ${e.content}`, e.status === "in_progress" ? C.accent : C.dim);
|
|
936
|
+
const inFlight = (lastEnd?.inFlight ?? []).map((id) => tools.find((s) => s.id === id)).filter(Boolean) as { title: string }[];
|
|
937
|
+
for (const f of inFlight) this.transcript.note(`in flight when it stopped: ${f.title}`, C.warn);
|
|
938
|
+
const stat = task.request.checkpoint ? await checkpointDiffStat(this.cwd, task.request.checkpoint).catch(() => "") : "";
|
|
939
|
+
if (!task.request.checkpoint) this.transcript.note("file changes unknown: snapshots are off (checkpoints: false) or this is not a git repo");
|
|
940
|
+
else this.transcript.note(stat.trim() ? `changed since the task started:\n${stat.trim()}` : "no file changes since the task started");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
private showUsage() {
|
|
944
|
+
const all = this.ledger.usage();
|
|
945
|
+
for (const l of renderUsageTable(all, { title: "Usage for this project" })) this.transcript.note(l);
|
|
946
|
+
this.transcript.note(`this codemax session: ${fmtUsd(this.sessionCost)} · all time here: ${fmtUsd(sumCost(all))} · prices in ~/.codemax/prices.json`);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
private showStatus() {
|
|
950
|
+
const a = this.active;
|
|
951
|
+
const lines = [
|
|
952
|
+
`agent: ${a ? `${a.name}${a.engine.info?.currentModel ? ` · ${a.engine.info.currentModel}` : ""}` : "none"}`,
|
|
953
|
+
`session: ${a?.engine.session?.id ?? "-"} mode: ${a?.mode ?? "-"}`,
|
|
954
|
+
`ledger: ${this.ledger.dir} (${this.ledger.turns().length} turns)`,
|
|
955
|
+
];
|
|
956
|
+
const q = [...this.quota.values()].map((x) => `${x.agent} ${quotaLabel(x)}`.trim()).filter((s) => !/\s$/.test(s) && s.includes(" "));
|
|
957
|
+
if (q.length) lines.push(`quota: ${q.join(", ")}`);
|
|
958
|
+
for (const l of lines) this.transcript.note(l);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private clearScreen() {
|
|
962
|
+
const old = this.transcript;
|
|
963
|
+
const parent = old.box.parent;
|
|
964
|
+
const fresh = new Transcript(this.renderer);
|
|
965
|
+
parent?.add(fresh.box, 0);
|
|
966
|
+
old.box.destroyRecursively();
|
|
967
|
+
this.transcript = fresh;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ---------- palette ----------
|
|
971
|
+
|
|
972
|
+
private paletteKind: "commands" | "switch" | "modes" | "files" | "handoff" | "picker" | null = null;
|
|
973
|
+
|
|
974
|
+
private paletteAnchor = () => ({ top: this.inputWrap.y, width: this.renderer.width });
|
|
975
|
+
|
|
976
|
+
private openCommandPalette(filter: string) {
|
|
977
|
+
this.closePalette();
|
|
978
|
+
const builtinNames = new Set(this.builtins().map((b) => b.name));
|
|
979
|
+
const items: PaletteItem[] = [
|
|
980
|
+
...this.builtins().map((b) => ({ label: b.name, description: b.description, value: `builtin:${b.name}`, weight: 1 })),
|
|
981
|
+
...(this.active?.commands ?? [])
|
|
982
|
+
.filter((c) => !builtinNames.has(`/${c.name}`))
|
|
983
|
+
.map((c) => ({ label: `/${c.name}`, description: c.description ?? `${this.active!.name} command`, value: `agentcmd:${c.name}` })),
|
|
984
|
+
];
|
|
985
|
+
this.paletteKind = "commands";
|
|
986
|
+
this.palette = new Palette(this.renderer, {
|
|
987
|
+
items,
|
|
988
|
+
filter,
|
|
989
|
+
onPick: (it) => {
|
|
990
|
+
this.closePalette();
|
|
991
|
+
this.input.clear();
|
|
992
|
+
void this.runCommandValue(it.value, "");
|
|
993
|
+
},
|
|
994
|
+
onClose: () => this.closePalette(),
|
|
995
|
+
}, this.paletteAnchor);
|
|
996
|
+
this.renderer.root.add(this.palette.box);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
private switchItems(): PaletteItem[] {
|
|
1000
|
+
const sessions = this.ledger.sessions();
|
|
1001
|
+
return listAdapters(this.config.order).map((a) => {
|
|
1002
|
+
const d = this.detect.get(a.id);
|
|
1003
|
+
const s = sessions[a.id];
|
|
1004
|
+
const q = this.quota.get(a.id);
|
|
1005
|
+
const usable = !!d?.installed && !!d?.engine;
|
|
1006
|
+
const bits: string[] = [];
|
|
1007
|
+
if (!d?.installed) bits.push("not installed");
|
|
1008
|
+
else if (!d.engine) bits.push("no protocol · use codemax --tui");
|
|
1009
|
+
else {
|
|
1010
|
+
if (d.version) bits.push(d.version);
|
|
1011
|
+
bits.push(s ? `session ${ago(s.lastUsedAt)}` : "new session");
|
|
1012
|
+
if (q && quotaLabel(q)) bits.push(quotaLabel(q));
|
|
1013
|
+
if (NO_PERMISSION_PROMPTS.has(a.id)) bits.push("acts without asking");
|
|
1014
|
+
}
|
|
1015
|
+
return { label: a.name + (a.id === this.active?.agent ? " (current)" : ""), description: bits.join(" · "), value: a.id, disabled: !usable };
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
openSwitch() {
|
|
1020
|
+
this.closePalette();
|
|
1021
|
+
const items = this.switchItems();
|
|
1022
|
+
this.paletteKind = "switch";
|
|
1023
|
+
this.palette = new Palette(this.renderer, {
|
|
1024
|
+
title: "Switch agent · Enter to hand off the conversation · Esc to cancel",
|
|
1025
|
+
items,
|
|
1026
|
+
onPick: (it) => {
|
|
1027
|
+
this.closePalette();
|
|
1028
|
+
const id = it.value as AgentId;
|
|
1029
|
+
if (id === this.active?.agent) return;
|
|
1030
|
+
void this.switchTo(id, false);
|
|
1031
|
+
},
|
|
1032
|
+
onClose: () => this.closePalette(),
|
|
1033
|
+
}, this.paletteAnchor);
|
|
1034
|
+
this.renderer.root.add(this.palette.box);
|
|
1035
|
+
const p = this.palette;
|
|
1036
|
+
void this.refreshDetect().then(() => this.palette === p && p.setItems(this.switchItems()));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
private openModes(arg: string) {
|
|
1040
|
+
const a = this.active;
|
|
1041
|
+
if (!a) return;
|
|
1042
|
+
const modes = a.engine.info?.modes ?? [];
|
|
1043
|
+
if (!modes.length || !a.engine.setMode) return this.transcript.note(`${a.name} has no permission modes to switch`, C.dim);
|
|
1044
|
+
if (arg) {
|
|
1045
|
+
const m = modes.find((x) => x.id === arg || x.name.toLowerCase() === arg.toLowerCase());
|
|
1046
|
+
if (m) return void a.engine.setMode(m.id);
|
|
1047
|
+
return this.transcript.note(`unknown mode "${arg}" (${modes.map((x) => x.id).join(", ")})`, C.warn);
|
|
1048
|
+
}
|
|
1049
|
+
this.closePalette();
|
|
1050
|
+
this.paletteKind = "modes";
|
|
1051
|
+
this.palette = new Palette(this.renderer, {
|
|
1052
|
+
title: "Permission mode · shift+tab cycles these",
|
|
1053
|
+
items: modes.map((m) => ({ label: m.id, description: `${m.name}${m.id === a.mode ? " (current)" : ""}${m.description ? " · " + m.description : ""}`, value: m.id })),
|
|
1054
|
+
onPick: (it) => {
|
|
1055
|
+
this.closePalette();
|
|
1056
|
+
void a.engine.setMode!(it.value);
|
|
1057
|
+
},
|
|
1058
|
+
onClose: () => this.closePalette(),
|
|
1059
|
+
}, this.paletteAnchor);
|
|
1060
|
+
this.renderer.root.add(this.palette.box);
|
|
1061
|
+
}
|
|
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
|
+
|
|
1066
|
+
private closePalette(_focus = true) {
|
|
1067
|
+
this.palette?.destroy();
|
|
1068
|
+
this.palette = null;
|
|
1069
|
+
this.paletteKind = null;
|
|
1070
|
+
if (this.paletteFiltered) this.input.clear();
|
|
1071
|
+
this.paletteFiltered = false;
|
|
1072
|
+
this.input.focus();
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
private async cycleMode() {
|
|
1076
|
+
const a = this.active;
|
|
1077
|
+
if (!a?.engine.setMode) return this.setFlash("this agent has no permission modes");
|
|
1078
|
+
const ids = (a.engine.info?.modes ?? []).map((m) => m.id);
|
|
1079
|
+
const cycle = MODE_CYCLE.filter((m) => ids.includes(m));
|
|
1080
|
+
const order = cycle.length ? cycle : ids;
|
|
1081
|
+
if (!order.length) return;
|
|
1082
|
+
const next = order[(order.indexOf(a.mode ?? "") + 1) % order.length]!;
|
|
1083
|
+
await a.engine.setMode(next);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ---------- engine lifecycle ----------
|
|
1087
|
+
|
|
1088
|
+
private async launch(agent: AgentId, o: { fresh: boolean; from?: AgentId; reason?: string }) {
|
|
1089
|
+
const adapter = getAdapter(agent);
|
|
1090
|
+
const blocked = this.checkBlockers(agent);
|
|
1091
|
+
if (blocked.length) {
|
|
1092
|
+
for (const b of blocked) this.transcript.error(`${adapter.name} not started: ${b.message}${b.fix ? ` Fix: ${b.fix.description} (codemax doctor --fix)` : ""}`);
|
|
1093
|
+
this.openSwitch();
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const engine = this.engineFactory(agent);
|
|
1097
|
+
const saved = this.ledger.sessions()[agent];
|
|
1098
|
+
const resume = !o.fresh && saved ? { agent, id: saved.id, cwd: this.cwd, startedAt: saved.startedAt, lastUsedAt: saved.lastUsedAt } : undefined;
|
|
1099
|
+
const active: Active = { agent, name: adapter.name, engine, turn: null, replay: null, unsubscribe: () => {}, commands: [] };
|
|
1100
|
+
active.unsubscribe = engine.onEvent((e) => this.onEngineEvent(active, e));
|
|
1101
|
+
this.active = active;
|
|
1102
|
+
this.setWorking(`${pick(G.spinner, 0)} Starting ${adapter.name}…`);
|
|
1103
|
+
try {
|
|
1104
|
+
const info = await engine.start({ cwd: this.cwd, resume });
|
|
1105
|
+
active.mode = info.currentMode;
|
|
1106
|
+
if (engine.session) this.ledger.saveSession({ agent, id: engine.session.id, cwd: this.cwd, startedAt: engine.session.startedAt, lastUsedAt: Date.now() } as SessionRef);
|
|
1107
|
+
this.ledger.addEvent({ kind: o.from ? "switch" : "start", agent: o.from, to: agent, detail: o.reason });
|
|
1108
|
+
const resumed = !!resume && engine.session?.id === resume.id;
|
|
1109
|
+
active.replay = null;
|
|
1110
|
+
const version = this.detect.get(agent)?.version ?? info.version;
|
|
1111
|
+
const model = info.currentModel ? ` · ${info.currentModel}` : "";
|
|
1112
|
+
this.transcript.divider(`${adapter.name}${version ? ` ${version}` : ""}${model} · ${resumed ? "resumed" : "new session"}`);
|
|
1113
|
+
if (NO_PERMISSION_PROMPTS.has(agent)) this.transcript.note(grokPermissionNote() ?? `${adapter.name} acts without asking permission`, C.warn);
|
|
1114
|
+
this.setWorking(null);
|
|
1115
|
+
this.renderFooter();
|
|
1116
|
+
this.setTitle();
|
|
1117
|
+
// Only brief the agent when there is something to hand over: a brief with
|
|
1118
|
+
// nothing in it costs a turn (and quota) for no context.
|
|
1119
|
+
const hasHistory = this.ledger.turns().length > 0 || this.ledger.journal.steps().some((s) => s.kind === "user");
|
|
1120
|
+
if (hasHistory) {
|
|
1121
|
+
const input = { ledger: this.ledger, cwd: this.cwd, to: agent, from: o.from, reason: o.reason, config: this.config };
|
|
1122
|
+
// A resumed session already remembers its own work: tell it only what others did since.
|
|
1123
|
+
const catchup = resumed ? await buildCatchup(input) : null;
|
|
1124
|
+
const packet = resumed ? catchup : await buildBrief(input);
|
|
1125
|
+
if (this.closed || this.active !== active) return;
|
|
1126
|
+
if (packet) {
|
|
1127
|
+
const task = this.ledger.journal.task();
|
|
1128
|
+
const what = resumed ? "catching it up on what the other agents did" : task.request ? `handing over the task: ${task.steps.filter((s) => s.kind === "tool").length} steps, the plan and the code diff` : "handing over the conversation and the repo state";
|
|
1129
|
+
this.transcript.note(`${what} (${packet.length} chars)`);
|
|
1130
|
+
// Snapshot first: the new agent may edit files in this turn, and both /undo and a
|
|
1131
|
+
// later catch-up need the state from before it started.
|
|
1132
|
+
const cp = await this.checkpoint(active, `handoff to ${agent}`);
|
|
1133
|
+
const rec = new TurnRecorder(this.ledger.journal, agent);
|
|
1134
|
+
rec.handoff({ from: o.from, reason: o.reason, mode: resumed ? "catchup" : "packet", chars: packet.length, checkpoint: cp?.sha });
|
|
1135
|
+
await this.runTurn(active, packet, { record: false, rec });
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
await this.drainQueue(active);
|
|
1139
|
+
} catch (e: any) {
|
|
1140
|
+
this.setWorking(null);
|
|
1141
|
+
this.transcript.error(`${adapter.name} failed to start: ${e?.message ?? e}`);
|
|
1142
|
+
if (this.active === active) {
|
|
1143
|
+
active.unsubscribe();
|
|
1144
|
+
await engine.close().catch(() => {});
|
|
1145
|
+
this.active = null;
|
|
1146
|
+
this.renderFooter();
|
|
1147
|
+
this.openSwitch();
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
private async runTurn(active: Active, text: string, o: { record?: boolean; rec?: TurnRecorder; journal?: boolean } = {}) {
|
|
1153
|
+
if (this.closed) return;
|
|
1154
|
+
const block = this.transcript.agent();
|
|
1155
|
+
const rec = o.journal === false ? undefined : (o.rec ?? new TurnRecorder(this.ledger.journal, active.agent));
|
|
1156
|
+
active.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: pick(VERBS), rec };
|
|
1157
|
+
this.startSpinner(active);
|
|
1158
|
+
this.setTitle(`✳ ${active.name} working`);
|
|
1159
|
+
const stop = await active.engine.prompt(text);
|
|
1160
|
+
if (this.closed) {
|
|
1161
|
+
active.turn?.rec?.end(stop);
|
|
1162
|
+
active.turn = null;
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const turn = active.turn;
|
|
1166
|
+
active.turn = null;
|
|
1167
|
+
this.stopSpinner();
|
|
1168
|
+
this.setTitle();
|
|
1169
|
+
if (turn && this.config.bellAfterSeconds > 0 && Date.now() - turn.startedAt > this.config.bellAfterSeconds * 1000) process.stdout.write("\x07");
|
|
1170
|
+
if (turn) {
|
|
1171
|
+
const dur = fmtDuration(Date.now() - turn.startedAt);
|
|
1172
|
+
block.footer(stop === "cancelled" ? `Interrupted after ${dur}` : `${pick(DONE_VERBS, turn.startedAt)} for ${dur} · done ${fmtTime(Date.now())}`);
|
|
1173
|
+
const reply = block.text().trim();
|
|
1174
|
+
if (isLimitNotice(reply)) turn.rec?.markLimit(reply);
|
|
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
|
+
}
|
|
1184
|
+
if (isLimitNotice(reply)) {
|
|
1185
|
+
// The agent ended the turn with only a limit notice: treat it as running out, and leave
|
|
1186
|
+
// the user's request unanswered in the ledger so the next agent is asked to finish it.
|
|
1187
|
+
this.onQuota({ agent: active.agent, exhausted: true, source: "probe", detail: reply.slice(0, 120), resetsAt: parseResetTime(reply), at: Date.now() });
|
|
1188
|
+
} else if (o.record !== false) {
|
|
1189
|
+
if (reply || turn.tools.size) {
|
|
1190
|
+
this.ledger.addTurns([{ ts: Date.now(), agent: active.agent, sessionId: active.engine.session?.id ?? "?", role: "assistant", text: reply, tools: turn.tools.size ? [...turn.tools] : undefined }]);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
this.renderFooter();
|
|
1195
|
+
const offer = this.pendingOffer;
|
|
1196
|
+
if (offer && this.active === active) {
|
|
1197
|
+
this.pendingOffer = null;
|
|
1198
|
+
this.offerHandoff(offer.from, offer.reason, offer.allAgents);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
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) {
|
|
1207
|
+
if (this.active !== active && !this.side.has(active) && e.type !== "permission_request") return;
|
|
1208
|
+
const target = () => active.turn?.block ?? (active.replay ??= this.transcript.agent());
|
|
1209
|
+
switch (e.type) {
|
|
1210
|
+
case "text_delta":
|
|
1211
|
+
if (active.turn) active.turn.chars += e.text.length;
|
|
1212
|
+
active.turn?.rec?.textDelta(e.text, e.messageId);
|
|
1213
|
+
target().append(e.text, e.messageId);
|
|
1214
|
+
break;
|
|
1215
|
+
case "thought_delta":
|
|
1216
|
+
if (active.turn) {
|
|
1217
|
+
active.turn.chars += e.text.length;
|
|
1218
|
+
active.turn.verb = "Thinking";
|
|
1219
|
+
}
|
|
1220
|
+
active.turn?.rec?.thoughtDelta(e.text);
|
|
1221
|
+
target().thought(e.text, e.messageId);
|
|
1222
|
+
break;
|
|
1223
|
+
case "plan":
|
|
1224
|
+
active.turn?.rec?.plan(e.entries);
|
|
1225
|
+
target().plan(e.entries);
|
|
1226
|
+
break;
|
|
1227
|
+
case "tool_call":
|
|
1228
|
+
active.turn?.tools.add(e.title.split(/[\s(]/)[0] ?? e.title);
|
|
1229
|
+
active.turn?.rec?.tool(e.id, { title: e.title, kind: e.kind, status: e.status, input: e.input, output: e.output, files: e.files });
|
|
1230
|
+
target().tool(e.id, e.title, e.status, e.detail);
|
|
1231
|
+
break;
|
|
1232
|
+
case "tool_update":
|
|
1233
|
+
active.turn?.rec?.tool(e.id, { title: e.title, status: e.status, input: e.input, output: e.output, files: e.files });
|
|
1234
|
+
target().toolUpdate(e.id, e.status, e.title, e.detail);
|
|
1235
|
+
break;
|
|
1236
|
+
case "permission_request":
|
|
1237
|
+
this.permissionQueue.push({ request: e.request, engine: active.engine });
|
|
1238
|
+
this.showNextPermission();
|
|
1239
|
+
break;
|
|
1240
|
+
case "mode": {
|
|
1241
|
+
active.mode = e.modeId;
|
|
1242
|
+
this.renderFooter();
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
case "commands":
|
|
1246
|
+
active.commands = e.commands.filter((c) => !c.name.startsWith("__"));
|
|
1247
|
+
break;
|
|
1248
|
+
case "usage": {
|
|
1249
|
+
if (active.turn && typeof e.outputTokens === "number") active.turn.tokens += e.outputTokens;
|
|
1250
|
+
if (typeof e.contextUsed === "number" && typeof e.contextSize === "number" && e.contextSize > 0) {
|
|
1251
|
+
active.context = { used: e.contextUsed, size: e.contextSize };
|
|
1252
|
+
this.renderFooter();
|
|
1253
|
+
}
|
|
1254
|
+
if (typeof e.usedPercent === "number") this.onQuota({ agent: active.agent, usedPercent: e.usedPercent, exhausted: e.usedPercent >= 100, source: "probe", at: Date.now() });
|
|
1255
|
+
this.recordUsage(active.agent, active.engine, e);
|
|
1256
|
+
break;
|
|
1257
|
+
}
|
|
1258
|
+
case "session":
|
|
1259
|
+
if (this.side.has(active)) break; // /ask sessions are throwaway, not resumable
|
|
1260
|
+
this.ledger.saveSession({ agent: active.agent, id: e.ref.id, cwd: this.cwd, startedAt: e.ref.startedAt, lastUsedAt: Date.now() } as SessionRef);
|
|
1261
|
+
break;
|
|
1262
|
+
case "error":
|
|
1263
|
+
this.transcript.error(`${active.name}: ${e.message.slice(0, 500)}`);
|
|
1264
|
+
if (e.kind === "quota") active.turn?.rec?.markLimit(cleanError(e.message));
|
|
1265
|
+
else active.turn?.rec?.markError(cleanError(e.message));
|
|
1266
|
+
if (e.kind === "quota") this.onQuota({ agent: active.agent, exhausted: true, source: "probe", detail: e.message.slice(0, 120), resetsAt: parseResetTime(e.message), at: Date.now() });
|
|
1267
|
+
if (e.kind === "crashed" && this.active === active && !this.switching) {
|
|
1268
|
+
active.turn = null;
|
|
1269
|
+
this.stopSpinner();
|
|
1270
|
+
this.openSwitch();
|
|
1271
|
+
}
|
|
1272
|
+
break;
|
|
1273
|
+
case "log":
|
|
1274
|
+
if (e.level !== "info") this.transcript.note(`${active.name}: ${e.message}`, e.level === "error" ? C.error : C.dim);
|
|
1275
|
+
break;
|
|
1276
|
+
default:
|
|
1277
|
+
break;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
private showNextPermission() {
|
|
1282
|
+
if (this.permission || !this.permissionQueue.length) return;
|
|
1283
|
+
const { request, engine } = this.permissionQueue.shift()!;
|
|
1284
|
+
const name = this.active?.engine === engine ? this.active.name : "the agent";
|
|
1285
|
+
this.permission = new PermissionDialog(this.renderer, request, name, (choice) => {
|
|
1286
|
+
this.permission?.destroy();
|
|
1287
|
+
this.permission = null;
|
|
1288
|
+
const opt = request.options.find((o) => o.id === choice.optionId);
|
|
1289
|
+
const fallbackReject = request.options.find((o) => o.kind.startsWith("reject"))?.id ?? request.options[0]!.id;
|
|
1290
|
+
engine.answerPermission(request.id, choice.optionId ?? fallbackReject);
|
|
1291
|
+
const allowed = !!opt && opt.kind.startsWith("allow");
|
|
1292
|
+
if (!allowed) this.transcript.note(`declined: ${request.title.length > 70 ? request.title.slice(0, 67) + "…" : request.title}`, C.warn);
|
|
1293
|
+
if (choice.tellAgent) {
|
|
1294
|
+
this.input.placeholder = `Tell ${name} what to do differently…`;
|
|
1295
|
+
setTimeout(() => (this.input.placeholder = 'Try "fix the failing test" · / for commands'), 15000);
|
|
1296
|
+
}
|
|
1297
|
+
this.input.focus();
|
|
1298
|
+
this.showNextPermission();
|
|
1299
|
+
});
|
|
1300
|
+
this.dialogHost.add(this.permission.box);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private onQuota(q: QuotaStatus) {
|
|
1304
|
+
const prev = this.quota.get(q.agent);
|
|
1305
|
+
this.quota.set(q.agent, q);
|
|
1306
|
+
this.ledger.saveQuota(q);
|
|
1307
|
+
this.renderFooter();
|
|
1308
|
+
if (isExhausted(q) && !isExhausted(prev)) {
|
|
1309
|
+
this.ledger.addEvent({ kind: "quota", agent: q.agent, detail: q.detail });
|
|
1310
|
+
if (this.config.autoSwitch && (!this.active || this.active.agent === q.agent)) this.offerHandoff(q.agent, `${getAdapter(q.agent).name} is out of quota${q.resetsAt ? ` until ${fmtTime(q.resetsAt)}` : ""}`);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// ---------- working line, footer ----------
|
|
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
|
+
|
|
1703
|
+
private startSpinner(active: Active) {
|
|
1704
|
+
this.stopSpinner();
|
|
1705
|
+
this.spinnerTimer = setInterval(() => {
|
|
1706
|
+
const t = active.turn;
|
|
1707
|
+
if (!t) return this.stopSpinner();
|
|
1708
|
+
this.spinnerFrame++;
|
|
1709
|
+
const glyph = G.spinner[this.spinnerFrame % G.spinner.length];
|
|
1710
|
+
const secs = fmtDuration(Date.now() - t.startedAt);
|
|
1711
|
+
const approx = t.tokens || Math.round(t.chars / 4);
|
|
1712
|
+
const tok = approx ? ` · ↓ ${t.tokens ? "" : "~"}${approx} tokens` : "";
|
|
1713
|
+
if (this.permission) this.setWorking(`${glyph} Waiting for your approval (${secs}) · esc to say no`);
|
|
1714
|
+
else {
|
|
1715
|
+
const elapsed = Date.now() - t.startedAt;
|
|
1716
|
+
const tip = elapsed > 8000 ? ` ⎿ tip: ${pick(TIPS, Math.floor(t.startedAt / 1000))}` : "";
|
|
1717
|
+
this.setWorking(`${glyph} ${t.verb}… (${secs}${tok}) · esc to interrupt${tip}`);
|
|
1718
|
+
}
|
|
1719
|
+
}, 120);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
private setTitle(text?: string) {
|
|
1723
|
+
const base = `codemax · ${this.active?.name ?? "no agent"} · ${this.cwd.split("/").pop()}`;
|
|
1724
|
+
try {
|
|
1725
|
+
this.renderer.setTerminalTitle(text ? `${text} · ${this.cwd.split("/").pop()}` : base);
|
|
1726
|
+
} catch {
|
|
1727
|
+
/* test renderer */
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
private stopSpinner() {
|
|
1732
|
+
if (this.spinnerTimer) clearInterval(this.spinnerTimer);
|
|
1733
|
+
this.spinnerTimer = null;
|
|
1734
|
+
this.setWorking(null);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
private setWorking(text: string | null) {
|
|
1738
|
+
if (this.closed && text) return;
|
|
1739
|
+
this.working.visible = !!text;
|
|
1740
|
+
this.working.content = text ?? "";
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
private setFlash(text: string) {
|
|
1744
|
+
this.flash = text;
|
|
1745
|
+
this.renderFooter();
|
|
1746
|
+
if (this.flashTimer) clearTimeout(this.flashTimer);
|
|
1747
|
+
this.flashTimer = setTimeout(() => {
|
|
1748
|
+
this.flash = "";
|
|
1749
|
+
this.renderFooter();
|
|
1750
|
+
}, 4000);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
private renderFooter() {
|
|
1754
|
+
this.safely("footer", () => this.drawFooter());
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
private drawFooter() {
|
|
1758
|
+
const a = this.active;
|
|
1759
|
+
const width = Math.max(20, this.renderer.width - 4);
|
|
1760
|
+
let color: string = C.dim;
|
|
1761
|
+
const mode = a ? modeLabel(a.name, a.mode) : null;
|
|
1762
|
+
if (mode) color = mode.color;
|
|
1763
|
+
const canCycle = !!a?.engine.setMode && (a.engine.info?.modes.length ?? 0) > 1;
|
|
1764
|
+
// Pieces in priority order; low-priority ones are dropped until the line fits.
|
|
1765
|
+
const left: { text: string; prio: number }[] = [];
|
|
1766
|
+
if (mode) left.push({ text: `${mode.glyph} ${mode.text}`, prio: 0 });
|
|
1767
|
+
else left.push({ text: "no agent · /switch", prio: 0 });
|
|
1768
|
+
if (canCycle) left.push({ text: "shift+tab to cycle", prio: 3 });
|
|
1769
|
+
if (this.flash) left.push({ text: this.flash, prio: 1 });
|
|
1770
|
+
else if (this.queue.length) left.push({ text: `${this.queue.length} queued`, prio: 1 });
|
|
1771
|
+
else if (!this.shortcuts.visible) left.push({ text: "? for shortcuts", prio: 2 });
|
|
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 });
|
|
1777
|
+
const effort = a ? this.effortOption(a)?.current : undefined;
|
|
1778
|
+
if (a) right.push({ text: `${a.name}${a.engine.info?.currentModel ? ` · ${shortModelName(a.engine.info.currentModel)}` : ""}${effort ? ` ${effort}` : ""}`, prio: 2 });
|
|
1779
|
+
if (a?.context) right.push({ text: `ctx ${Math.min(100, Math.round((a.context.used / a.context.size) * 100))}%`, prio: 1 });
|
|
1780
|
+
const bk: BudgetKey | null = a && this.config.budgets?.[a.agent] ? a.agent : this.config.budgets?.all ? "all" : null;
|
|
1781
|
+
if (bk) right.push({ text: `${fmtUsd(this.today.get(bk) ?? 0)}/${fmtUsd(this.config.budgets[bk])} today`, prio: 1 });
|
|
1782
|
+
else if (this.sessionCost > 0 || this.sessionTokens > 0) right.push({ text: `${fmtUsd(this.sessionCost)} /usage`, prio: 1 });
|
|
1783
|
+
const quotas = [...this.quota.values()].map((q) => [q.agent, quotaLabel(q)] as const).filter(([, l]) => l).map(([ag, l]) => `${ag} ${l}`).slice(0, 2);
|
|
1784
|
+
if (quotas.length) right.push({ text: quotas.join(" · "), prio: 2 });
|
|
1785
|
+
right.push({ text: `${this.config.hotkeys.map((h) => h.toLowerCase()).join(" · ")} agents`, prio: 4 });
|
|
1786
|
+
const compose = (l: typeof left, r: typeof right) => {
|
|
1787
|
+
const ls = l.map((x) => x.text).join(" · ");
|
|
1788
|
+
const rs = r.map((x) => x.text).join(" · ");
|
|
1789
|
+
return { ls, rs, gap: width - ls.length - rs.length };
|
|
1790
|
+
};
|
|
1791
|
+
let L = left, R = right;
|
|
1792
|
+
let c = compose(L, R);
|
|
1793
|
+
for (const prio of [4, 3, 2, 1]) {
|
|
1794
|
+
if (c.gap >= 2) break;
|
|
1795
|
+
L = L.filter((x) => x.prio !== prio);
|
|
1796
|
+
R = R.filter((x) => x.prio !== prio);
|
|
1797
|
+
c = compose(L, R);
|
|
1798
|
+
}
|
|
1799
|
+
this.footerText.content = c.gap >= 2 ? c.ls + " ".repeat(c.gap) + c.rs : `${c.ls} ${c.rs}`.slice(0, width);
|
|
1800
|
+
this.footerText.fg = this.flash ? C.dim : color;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// ---------- detection / switching / quit ----------
|
|
1804
|
+
|
|
1805
|
+
private async refreshDetect() {
|
|
1806
|
+
await Promise.all(
|
|
1807
|
+
listAdapters(this.config.order).map(async (a) => {
|
|
1808
|
+
const d = await a.detect();
|
|
1809
|
+
const f = engineFactories[a.id];
|
|
1810
|
+
const engine = !!f && (await f.available().catch(() => false));
|
|
1811
|
+
this.detect.set(a.id, { installed: d.installed, version: d.version, engine });
|
|
1812
|
+
}),
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
private async switchTo(agent: AgentId, fresh: boolean, o: { resumeTask?: boolean } = {}) {
|
|
1817
|
+
if (!hasEngine(agent)) return this.transcript.note(`${agent} has no protocol engine; run codemax --tui for it`, C.warn);
|
|
1818
|
+
this.switching = true;
|
|
1819
|
+
const prev = this.active;
|
|
1820
|
+
let from: AgentId | undefined;
|
|
1821
|
+
const pq = prev ? this.quota.get(prev.agent) : undefined;
|
|
1822
|
+
const nearLimit = o.resumeTask || isExhausted(pq) || (typeof pq?.usedPercent === "number" && pq.usedPercent >= 85);
|
|
1823
|
+
// The step journal hands over the whole task; a summary turn would only spend the
|
|
1824
|
+
// outgoing agent's quota. Summaries are still written for older, journal-less ledgers.
|
|
1825
|
+
const journaled = this.ledger.journal.steps().some((s) => s.kind === "user");
|
|
1826
|
+
if (prev && !prev.turn && this.config.summarizeOnSwitch && !nearLimit && !journaled && this.ledger.turns().length >= 4) {
|
|
1827
|
+
const timer = setTimeout(() => void prev.engine.cancel(), 30_000);
|
|
1828
|
+
await this.compact({ quiet: true }).catch(() => false);
|
|
1829
|
+
clearTimeout(timer);
|
|
1830
|
+
}
|
|
1831
|
+
if (prev) {
|
|
1832
|
+
from = prev.agent;
|
|
1833
|
+
if (prev.turn) await prev.engine.cancel().catch(() => {});
|
|
1834
|
+
prev.unsubscribe();
|
|
1835
|
+
this.active = null;
|
|
1836
|
+
this.stopSpinner();
|
|
1837
|
+
await prev.engine.close().catch(() => {});
|
|
1838
|
+
this.ledger.addEvent({ kind: "note", agent: from, detail: "stopped for switch", diffStat: await diffStat(this.cwd) });
|
|
1839
|
+
}
|
|
1840
|
+
const fromQ = from ? this.quota.get(from) : undefined;
|
|
1841
|
+
const overBudget = from && budgetLevel(this.today.get(from) ?? 0, this.config.budgets?.[from]) === "over";
|
|
1842
|
+
// Only ask the next agent to finish a request when one is actually unanswered.
|
|
1843
|
+
const lastTurn = this.ledger.turns().at(-1);
|
|
1844
|
+
const pending = lastTurn?.role === "user" ? "; finish the last user request" : "";
|
|
1845
|
+
const reason = fromQ && isExhausted(fromQ) ? `${from} ran out of quota${pending ? " mid-task" : ""}${pending}` : overBudget ? `${from} hit its daily budget${pending}` : o.resumeTask ? `${from} stopped${pending}` : undefined;
|
|
1846
|
+
await this.launch(agent, { fresh, from, reason });
|
|
1847
|
+
this.switching = false;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
async quit() {
|
|
1851
|
+
this.closed = true;
|
|
1852
|
+
await this.awake.release().catch(() => []);
|
|
1853
|
+
await this.closeReviewer();
|
|
1854
|
+
this.switching = true;
|
|
1855
|
+
const a = this.active;
|
|
1856
|
+
this.closePalette();
|
|
1857
|
+
this.permission?.destroy();
|
|
1858
|
+
this.permission = null;
|
|
1859
|
+
this.stopSpinner();
|
|
1860
|
+
if (a) {
|
|
1861
|
+
this.active = null;
|
|
1862
|
+
a.unsubscribe();
|
|
1863
|
+
if (a.turn) await a.engine.cancel().catch(() => {});
|
|
1864
|
+
await a.engine.close().catch(() => {});
|
|
1865
|
+
this.ledger.addEvent({ kind: "exit", agent: a.agent, detail: "codemax quit", diffStat: await diffStat(this.cwd) });
|
|
1866
|
+
}
|
|
1867
|
+
this.renderer.keyInput.off("keypress", this.onKey);
|
|
1868
|
+
this.resolveDone();
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function shortModelName(m: string): string {
|
|
1873
|
+
// Cursor appends settings in brackets: "claude-opus-5-5[context=300k,effort=medium]".
|
|
1874
|
+
const name = m.replace(/\[.*\]$/, "").replace(/^claude-/, "").replace(/^(anthropic|openai|xai|google|opencode)\//, "");
|
|
1875
|
+
return !name || name === "default" ? "auto" : name;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function ago(ts: number): string {
|
|
1879
|
+
const m = Math.round((Date.now() - ts) / 60_000);
|
|
1880
|
+
if (m < 1) return "just now";
|
|
1881
|
+
if (m < 60) return `${m}m ago`;
|
|
1882
|
+
const h = Math.round(m / 60);
|
|
1883
|
+
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
export async function runChat(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean, pick = false) {
|
|
1887
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGHUP"], useMouse: true, targetFps: 60 });
|
|
1888
|
+
const pref = config.theme ?? "auto";
|
|
1889
|
+
applyTheme(pref === "auto" ? ((await renderer.waitForThemeMode(250).catch(() => null)) ?? "dark") : pref);
|
|
1890
|
+
const app = new ChatApp(renderer, config, ledger, cwd);
|
|
1891
|
+
process.once("exit", () => app.awake.releaseSync());
|
|
1892
|
+
try {
|
|
1893
|
+
await app.start(agent, fresh, { pick });
|
|
1894
|
+
} finally {
|
|
1895
|
+
renderer.destroy();
|
|
1896
|
+
try {
|
|
1897
|
+
await renderer.closed;
|
|
1898
|
+
} catch {
|
|
1899
|
+
/* output already gone */
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
process.stdout.write(`codemax: bye. ledger at ${ledger.dir}\n`);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/** Copy text: the OS clipboard tool when there is one, plus OSC 52 (works over SSH and in most terminals). */
|
|
1906
|
+
async function copyToClipboard(renderer: CliRenderer, text: string): Promise<boolean> {
|
|
1907
|
+
let osc = false;
|
|
1908
|
+
try {
|
|
1909
|
+
osc = renderer.copyToClipboardOSC52(text);
|
|
1910
|
+
} catch {
|
|
1911
|
+
/* terminal without OSC 52 */
|
|
1912
|
+
}
|
|
1913
|
+
const tools: string[][] =
|
|
1914
|
+
process.platform === "darwin" ? [["pbcopy"]]
|
|
1915
|
+
: process.platform === "win32" ? [["clip"]]
|
|
1916
|
+
: [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
|
|
1917
|
+
for (const cmd of tools) {
|
|
1918
|
+
if (!Bun.which(cmd[0]!)) continue;
|
|
1919
|
+
try {
|
|
1920
|
+
const p = Bun.spawn(platformArgv(cmd), { stdin: "pipe", stdout: "ignore", stderr: "ignore" });
|
|
1921
|
+
p.stdin.write(text);
|
|
1922
|
+
await p.stdin.end();
|
|
1923
|
+
if ((await p.exited) === 0) return true;
|
|
1924
|
+
} catch {
|
|
1925
|
+
/* try the next tool */
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
return osc;
|
|
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
|
+
}
|