codemax-cli 0.0.0-stage → 0.1.1
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 +150 -2
- package/TRADEMARKS.md +27 -0
- package/bin/codemax +2 -0
- package/bin/codemax.cjs +77 -0
- package/package.json +63 -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 +1422 -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 +324 -0
- package/src/cli.ts +209 -0
- package/src/config.ts +96 -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 +213 -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/spawn.ts +20 -0
- package/src/core/terminal.ts +51 -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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hotkeys arrive in different encodings depending on the terminal and on
|
|
3
|
+
* whether the agent TUI enabled an enhanced keyboard protocol:
|
|
4
|
+
* legacy single control byte (Ctrl+/ → 0x1f)
|
|
5
|
+
* CSI u ESC [ <codepoint> ; <mods> u (kitty protocol; Warp, Ghostty, Kitty, WezTerm, iTerm2)
|
|
6
|
+
* modifyOtherKeys ESC [ 27 ; <mods> ; <codepoint> ~ (xterm)
|
|
7
|
+
* A hotkey name like "ctrl+/" expands to every encoding we know for it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CTRL = 5; // modifier bitmask value for ctrl (1 + 4)
|
|
11
|
+
const CTRL_SHIFT = 6;
|
|
12
|
+
|
|
13
|
+
const LEGACY: Record<string, number> = {
|
|
14
|
+
"/": 0x1f, _: 0x1f, "^": 0x1e, "]": 0x1d, "\\": 0x1c, "[": 0x1b, " ": 0x00, "@": 0x00,
|
|
15
|
+
};
|
|
16
|
+
/** Keys reached with shift on US layouts: the terminal may report the unshifted key + shift. */
|
|
17
|
+
const SHIFTED: Record<string, string> = { _: "-", "^": "6", "@": "2", "?": "/" };
|
|
18
|
+
|
|
19
|
+
export interface HotkeySpec {
|
|
20
|
+
name: string;
|
|
21
|
+
patterns: Uint8Array[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseHotkey(name: string): HotkeySpec {
|
|
25
|
+
const n = name.trim().toLowerCase();
|
|
26
|
+
const m = n.match(/^ctrl\+(.)$/);
|
|
27
|
+
if (!m) throw new Error(`unsupported hotkey "${name}" (use ctrl+<key>, e.g. ctrl+/ or ctrl+^)`);
|
|
28
|
+
const key = m[1]!;
|
|
29
|
+
const enc = new TextEncoder();
|
|
30
|
+
const pats: Uint8Array[] = [];
|
|
31
|
+
const legacy = key in LEGACY ? LEGACY[key] : key >= "a" && key <= "z" ? key.charCodeAt(0) - 96 : undefined;
|
|
32
|
+
if (legacy !== undefined) pats.push(new Uint8Array([legacy]));
|
|
33
|
+
const cp = key.charCodeAt(0);
|
|
34
|
+
pats.push(enc.encode(`\x1b[${cp};${CTRL}u`), enc.encode(`\x1b[27;${CTRL};${cp}~`));
|
|
35
|
+
const base = SHIFTED[key];
|
|
36
|
+
if (base) {
|
|
37
|
+
const b = base.charCodeAt(0);
|
|
38
|
+
pats.push(enc.encode(`\x1b[${b};${CTRL_SHIFT}u`), enc.encode(`\x1b[27;${CTRL_SHIFT};${b}~`), enc.encode(`\x1b[${cp};${CTRL_SHIFT}u`));
|
|
39
|
+
}
|
|
40
|
+
return { name: `ctrl+${key}`, patterns: pats };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseHotkeys(names: string[]): HotkeySpec[] {
|
|
44
|
+
return names.map(parseHotkey);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Find the first hotkey occurrence in an input chunk. */
|
|
48
|
+
export function findHotkey(bytes: Uint8Array, specs: HotkeySpec[]): { index: number; length: number; name: string } | null {
|
|
49
|
+
let best: { index: number; length: number; name: string } | null = null;
|
|
50
|
+
for (const s of specs) {
|
|
51
|
+
for (const p of s.patterns) {
|
|
52
|
+
const i = indexOfSeq(bytes, p);
|
|
53
|
+
if (i >= 0 && (!best || i < best.index || (i === best.index && p.length > best.length))) best = { index: i, length: p.length, name: s.name };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return best;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function indexOfSeq(hay: Uint8Array, needle: Uint8Array): number {
|
|
60
|
+
if (needle.length === 1) return hay.indexOf(needle[0]!);
|
|
61
|
+
outer: for (let i = 0; i + needle.length <= hay.length; i++) {
|
|
62
|
+
for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
|
|
63
|
+
return i;
|
|
64
|
+
}
|
|
65
|
+
return -1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Human-readable dump of raw input bytes, for `codemax keys`. */
|
|
69
|
+
export function describeBytes(bytes: Uint8Array): string {
|
|
70
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(" ");
|
|
71
|
+
const printable = [...bytes]
|
|
72
|
+
.map((b) => (b === 0x1b ? "ESC" : b < 0x20 ? `^${String.fromCharCode(b + 64)}` : b === 0x7f ? "DEL" : String.fromCharCode(b)))
|
|
73
|
+
.join(" ");
|
|
74
|
+
return `${hex.padEnd(24)} ${printable}`;
|
|
75
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import type { AgentId } from "../adapters/types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The step journal: everything every agent did, as it happened, in
|
|
7
|
+
* ~/.codemax/projects/<project>/journal.jsonl. This is what makes handoffs a
|
|
8
|
+
* resume rather than a summary: the next agent sees the task, the plan, each
|
|
9
|
+
* tool call with its input and result, the step that was in flight when the
|
|
10
|
+
* last agent stopped, and why it stopped.
|
|
11
|
+
*
|
|
12
|
+
* Steps are appended as they happen (a turn cut off by a limit still leaves
|
|
13
|
+
* its trail). A step that changes (a tool finishing) is appended again with
|
|
14
|
+
* the same id; readers keep the last copy.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type Outcome = "done" | "cancelled" | "limit" | "error";
|
|
18
|
+
|
|
19
|
+
interface Base {
|
|
20
|
+
id: string;
|
|
21
|
+
ts: number;
|
|
22
|
+
agent: AgentId;
|
|
23
|
+
/** Groups the steps of one agent turn. */
|
|
24
|
+
turn: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Step = Base &
|
|
28
|
+
(
|
|
29
|
+
| { kind: "user"; text: string; checkpoint?: string }
|
|
30
|
+
| { kind: "text"; text: string }
|
|
31
|
+
| { kind: "thought"; text: string }
|
|
32
|
+
| { kind: "tool"; title: string; toolKind?: string; status: string; input?: string; output?: string; files?: string[] }
|
|
33
|
+
| { kind: "plan"; entries: { content: string; status: string }[] }
|
|
34
|
+
| { kind: "end"; outcome: Outcome; detail?: string; inFlight?: string[] }
|
|
35
|
+
| { kind: "handoff"; from?: AgentId; reason?: string; mode: "packet" | "catchup"; chars: number; checkpoint?: string }
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export type StepOf<K extends Step["kind"]> = Extract<Step, { kind: K }>;
|
|
39
|
+
|
|
40
|
+
/** Caps applied when a step is written: enough to resume from, small enough to keep the file sane. */
|
|
41
|
+
export const CAP = { text: 6000, thought: 2000, input: 2000, output: 4000 } as const;
|
|
42
|
+
|
|
43
|
+
export function clip(s: string | undefined, max: number): string | undefined {
|
|
44
|
+
if (s === undefined) return undefined;
|
|
45
|
+
return s.length <= max ? s : s.slice(0, Math.floor(max * 0.6)) + `\n…[${s.length - max} chars cut]…\n` + s.slice(-Math.floor(max * 0.4));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class Journal {
|
|
49
|
+
private path: string;
|
|
50
|
+
|
|
51
|
+
constructor(dir: string) {
|
|
52
|
+
this.path = join(dir, "journal.jsonl");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
append(step: Step) {
|
|
56
|
+
appendFileSync(this.path, JSON.stringify(step) + "\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** All steps in order, each at its latest version. */
|
|
60
|
+
steps(): Step[] {
|
|
61
|
+
if (!existsSync(this.path)) return [];
|
|
62
|
+
const byId = new Map<string, Step>();
|
|
63
|
+
const order: string[] = [];
|
|
64
|
+
for (const line of readFileSync(this.path, "utf8").split("\n")) {
|
|
65
|
+
if (!line.trim()) continue;
|
|
66
|
+
try {
|
|
67
|
+
const s = JSON.parse(line) as Step;
|
|
68
|
+
if (!byId.has(s.id)) order.push(s.id);
|
|
69
|
+
byId.set(s.id, s);
|
|
70
|
+
} catch {
|
|
71
|
+
/* skip a torn line */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return order.map((id) => byId.get(id)!);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The current task: everything since the user's latest request, plus the
|
|
79
|
+
* follow-ups. Earlier steps are history.
|
|
80
|
+
*/
|
|
81
|
+
task(steps = this.steps()): { request?: StepOf<"user">; followUps: StepOf<"user">[]; steps: Step[]; earlier: Step[] } {
|
|
82
|
+
const users = steps.filter((s): s is StepOf<"user"> => s.kind === "user");
|
|
83
|
+
if (!users.length) return { followUps: [], steps: [], earlier: steps };
|
|
84
|
+
// A task begins at a user message that follows a turn which ended "done";
|
|
85
|
+
// messages sent while the work was cut short continue the same task.
|
|
86
|
+
let startIdx = steps.indexOf(users[users.length - 1]!);
|
|
87
|
+
for (let i = users.length - 1; i > 0; i--) {
|
|
88
|
+
const prevEnd = [...steps.slice(0, steps.indexOf(users[i]!))].reverse().find((s) => s.kind === "end") as StepOf<"end"> | undefined;
|
|
89
|
+
if (prevEnd && prevEnd.outcome === "done") break;
|
|
90
|
+
startIdx = steps.indexOf(users[i - 1]!);
|
|
91
|
+
}
|
|
92
|
+
const task = steps.slice(startIdx);
|
|
93
|
+
const taskUsers = task.filter((s): s is StepOf<"user"> => s.kind === "user");
|
|
94
|
+
return { request: taskUsers[0], followUps: taskUsers.slice(1), steps: task, earlier: steps.slice(0, startIdx) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** When this agent last did any work (not counting being briefed). */
|
|
98
|
+
lastSeen(agent: AgentId, steps = this.steps()): number | undefined {
|
|
99
|
+
for (let i = steps.length - 1; i >= 0; i--) {
|
|
100
|
+
const s = steps[i]!;
|
|
101
|
+
if (s.agent === agent && s.kind !== "handoff" && s.kind !== "user") return s.ts;
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Builds journal steps for one agent turn from the engine's event stream. */
|
|
108
|
+
export class TurnRecorder {
|
|
109
|
+
readonly turn: string;
|
|
110
|
+
private seq = 0;
|
|
111
|
+
private text: { id: string; messageId?: string; text: string } | null = null;
|
|
112
|
+
private thought: { id: string; text: string } | null = null;
|
|
113
|
+
private tools = new Map<string, StepOf<"tool">>();
|
|
114
|
+
private dirtyTools = new Set<string>();
|
|
115
|
+
private limitHit = false;
|
|
116
|
+
private lastError?: string;
|
|
117
|
+
|
|
118
|
+
constructor(
|
|
119
|
+
private journal: Journal,
|
|
120
|
+
readonly agent: AgentId,
|
|
121
|
+
) {
|
|
122
|
+
this.turn = `${agent}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private nextId(kind: string) {
|
|
126
|
+
return `${this.turn}:${kind}:${++this.seq}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private base() {
|
|
130
|
+
return { ts: Date.now(), agent: this.agent, turn: this.turn };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
user(text: string, checkpoint?: string) {
|
|
134
|
+
this.journal.append({ ...this.base(), id: this.nextId("user"), kind: "user", text: clip(text, CAP.text)!, checkpoint });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
handoff(o: { from?: AgentId; reason?: string; mode: "packet" | "catchup"; chars: number; checkpoint?: string }) {
|
|
138
|
+
this.journal.append({ ...this.base(), id: this.nextId("handoff"), kind: "handoff", ...o });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
textDelta(t: string, messageId?: string) {
|
|
142
|
+
if (this.text && messageId && this.text.messageId && messageId !== this.text.messageId) this.flushText();
|
|
143
|
+
this.flushThought();
|
|
144
|
+
this.text ??= { id: this.nextId("text"), messageId, text: "" };
|
|
145
|
+
this.text.text += t;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
thoughtDelta(t: string) {
|
|
149
|
+
this.thought ??= { id: this.nextId("thought"), text: "" };
|
|
150
|
+
this.thought.text += t;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
tool(callId: string, o: { title?: string; kind?: string; status?: string; input?: unknown; output?: string; files?: string[] }) {
|
|
154
|
+
this.flushText();
|
|
155
|
+
this.flushThought();
|
|
156
|
+
let s = this.tools.get(callId);
|
|
157
|
+
const first = !s;
|
|
158
|
+
s ??= { ...this.base(), id: this.nextId("tool"), kind: "tool", title: o.title ?? callId, status: o.status ?? "pending" };
|
|
159
|
+
if (o.title) s.title = o.title;
|
|
160
|
+
if (o.kind) s.toolKind = o.kind;
|
|
161
|
+
if (o.input !== undefined) s.input = clip(typeof o.input === "string" ? o.input : JSON.stringify(o.input), CAP.input);
|
|
162
|
+
if (o.output !== undefined) s.output = clip(o.output, CAP.output);
|
|
163
|
+
if (o.files?.length) s.files = [...new Set([...(s.files ?? []), ...o.files])];
|
|
164
|
+
const statusChanged = !!o.status && o.status !== s.status;
|
|
165
|
+
if (o.status) s.status = o.status;
|
|
166
|
+
this.tools.set(callId, s);
|
|
167
|
+
// Write at the start and at each status change; streamed output waits for the next one.
|
|
168
|
+
if (first || statusChanged) this.writeTool(callId);
|
|
169
|
+
else this.dirtyTools.add(callId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
plan(entries: { content: string; status: string }[]) {
|
|
173
|
+
if (!entries.length) return;
|
|
174
|
+
this.journal.append({ ...this.base(), id: `${this.turn}:plan`, kind: "plan", entries });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The agent reported running out (error or a limit-notice reply). */
|
|
178
|
+
markLimit(detail?: string) {
|
|
179
|
+
this.limitHit = true;
|
|
180
|
+
if (detail) this.lastError = detail;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
markError(detail: string) {
|
|
184
|
+
this.lastError = detail;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Close the turn: flush buffered steps and record how it ended and what was still running. */
|
|
188
|
+
end(stopReason: string, detail?: string) {
|
|
189
|
+
this.flushText();
|
|
190
|
+
this.flushThought();
|
|
191
|
+
for (const id of this.dirtyTools) this.writeTool(id);
|
|
192
|
+
this.dirtyTools.clear();
|
|
193
|
+
const inFlight = [...this.tools.values()].filter((t) => t.status === "pending" || t.status === "in_progress").map((t) => t.id);
|
|
194
|
+
const outcome: Outcome = this.limitHit ? "limit" : stopReason === "cancelled" ? "cancelled" : stopReason === "error" ? "error" : "done";
|
|
195
|
+
this.journal.append({ ...this.base(), id: `${this.turn}:end`, kind: "end", outcome, detail: clip(detail ?? this.lastError, 400), inFlight: inFlight.length ? inFlight : undefined });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private writeTool(callId: string) {
|
|
199
|
+
const s = this.tools.get(callId);
|
|
200
|
+
if (s) this.journal.append({ ...s, ts: Date.now() });
|
|
201
|
+
this.dirtyTools.delete(callId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private flushText() {
|
|
205
|
+
if (this.text?.text.trim()) this.journal.append({ ...this.base(), id: this.text.id, kind: "text", text: clip(this.text.text.trim(), CAP.text)! });
|
|
206
|
+
this.text = null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private flushThought() {
|
|
210
|
+
if (this.thought?.text.trim()) this.journal.append({ ...this.base(), id: this.thought.id, kind: "thought", text: clip(this.thought.text.trim(), CAP.thought)! });
|
|
211
|
+
this.thought = null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { ensureDir, projectDir } from "./paths.ts";
|
|
4
|
+
import type { AgentId, QuotaStatus, SessionRef, Turn } from "../adapters/types.ts";
|
|
5
|
+
import type { UsageRecord } from "./usage.ts";
|
|
6
|
+
import { Journal } from "./journal.ts";
|
|
7
|
+
|
|
8
|
+
export interface LedgerEvent {
|
|
9
|
+
ts: number;
|
|
10
|
+
kind: "start" | "switch" | "exit" | "quota" | "note";
|
|
11
|
+
agent?: AgentId;
|
|
12
|
+
to?: AgentId;
|
|
13
|
+
detail?: string;
|
|
14
|
+
diffStat?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Project ledger in ~/.codemax/projects/<escaped-cwd>/.
|
|
19
|
+
* - turns.jsonl normalized conversation turns from every agent
|
|
20
|
+
* - sessions.json latest vendor session per agent (for native resume)
|
|
21
|
+
* - events.jsonl switches, quota detections, notes
|
|
22
|
+
* - summary.md optional hand- or agent-written compacted summary
|
|
23
|
+
* - journal.jsonl every step every agent took (see journal.ts); handoffs resume from it
|
|
24
|
+
*/
|
|
25
|
+
export class Ledger {
|
|
26
|
+
readonly dir: string;
|
|
27
|
+
readonly journal: Journal;
|
|
28
|
+
private seen = new Set<string>();
|
|
29
|
+
|
|
30
|
+
constructor(readonly cwd: string, dir?: string) {
|
|
31
|
+
this.dir = ensureDir(dir ?? projectDir(cwd));
|
|
32
|
+
this.journal = new Journal(this.dir);
|
|
33
|
+
for (const t of this.turns()) this.seen.add(turnKey(t));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private file(name: string) {
|
|
37
|
+
return join(this.dir, name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Recorded turns. A streaming assistant turn is appended again each time it
|
|
42
|
+
* grows (its key includes the text length), so collapse by identity keeping
|
|
43
|
+
* the last, longest copy.
|
|
44
|
+
*/
|
|
45
|
+
turns(): Turn[] {
|
|
46
|
+
const byId = new Map<string, Turn>();
|
|
47
|
+
for (const t of readJsonl<Turn>(this.file("turns.jsonl"))) byId.set(turnId(t), t);
|
|
48
|
+
return [...byId.values()].sort((a, b) => a.ts - b.ts);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Append turns not already recorded. Returns how many were new. */
|
|
52
|
+
addTurns(turns: Turn[]): number {
|
|
53
|
+
let n = 0;
|
|
54
|
+
for (const t of turns) {
|
|
55
|
+
// The handoff brief shows up as a user message in the next agent's
|
|
56
|
+
// transcript; recording it would nest briefs inside later briefs.
|
|
57
|
+
if (t.role === "user" && t.text.startsWith(BRIEF_MARKER)) continue;
|
|
58
|
+
const k = turnKey(t);
|
|
59
|
+
if (this.seen.has(k)) continue;
|
|
60
|
+
this.seen.add(k);
|
|
61
|
+
appendFileSync(this.file("turns.jsonl"), JSON.stringify(t) + "\n");
|
|
62
|
+
n++;
|
|
63
|
+
}
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
events(): LedgerEvent[] {
|
|
68
|
+
return readJsonl<LedgerEvent>(this.file("events.jsonl"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
addEvent(e: Omit<LedgerEvent, "ts"> & { ts?: number }) {
|
|
72
|
+
appendFileSync(this.file("events.jsonl"), JSON.stringify({ ts: Date.now(), ...e }) + "\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
sessions(): Partial<Record<AgentId, SessionRef>> {
|
|
76
|
+
const p = this.file("sessions.json");
|
|
77
|
+
if (!existsSync(p)) return {};
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
80
|
+
} catch {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
saveSession(ref: SessionRef) {
|
|
86
|
+
const all = this.sessions();
|
|
87
|
+
all[ref.agent] = ref;
|
|
88
|
+
writeFileSync(this.file("sessions.json"), JSON.stringify(all, null, 2) + "\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
summary(): string {
|
|
92
|
+
const p = this.file("summary.md");
|
|
93
|
+
return existsSync(p) ? readFileSync(p, "utf8").trim() : "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
writeSummary(text: string) {
|
|
97
|
+
writeFileSync(this.file("summary.md"), text.trimEnd() + "\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
usage(): UsageRecord[] {
|
|
101
|
+
return readJsonl<UsageRecord>(this.file("usage.jsonl"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
addUsage(r: UsageRecord) {
|
|
105
|
+
appendFileSync(this.file("usage.jsonl"), JSON.stringify(r) + "\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
quota(): Partial<Record<AgentId, QuotaStatus>> {
|
|
109
|
+
const p = this.file("quota.json");
|
|
110
|
+
if (!existsSync(p)) return {};
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
113
|
+
} catch {
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
saveQuota(q: QuotaStatus) {
|
|
119
|
+
const all = this.quota();
|
|
120
|
+
all[q.agent] = q;
|
|
121
|
+
writeFileSync(this.file("quota.json"), JSON.stringify(all, null, 2) + "\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
clearQuota(agent?: AgentId) {
|
|
125
|
+
const all = agent ? this.quota() : {};
|
|
126
|
+
if (agent) delete all[agent];
|
|
127
|
+
writeFileSync(this.file("quota.json"), JSON.stringify(all, null, 2) + "\n");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const BRIEF_MARKER = "[codemax handoff]";
|
|
132
|
+
|
|
133
|
+
function turnId(t: Turn): string {
|
|
134
|
+
return `${t.agent}:${t.sessionId}:${t.role}:${t.ts}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function turnKey(t: Turn): string {
|
|
138
|
+
return `${turnId(t)}:${t.text.length}:${t.tools?.length ?? 0}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readJsonl<T>(path: string): T[] {
|
|
142
|
+
if (!existsSync(path)) return [];
|
|
143
|
+
const out: T[] = [];
|
|
144
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
145
|
+
if (!line.trim()) continue;
|
|
146
|
+
try {
|
|
147
|
+
out.push(JSON.parse(line));
|
|
148
|
+
} catch {
|
|
149
|
+
/* skip corrupt line */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
package/src/core/menu.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Adapter, AgentId, QuotaStatus, SessionRef } from "../adapters/types.ts";
|
|
2
|
+
import { ALT_OFF, ALT_ON, CSI, out, termSize } from "./terminal.ts";
|
|
3
|
+
import { isExhausted, quotaLabel } from "./quota.ts";
|
|
4
|
+
import { hotkeyLabel } from "../config.ts";
|
|
5
|
+
|
|
6
|
+
export interface MenuRow {
|
|
7
|
+
adapter: Adapter;
|
|
8
|
+
installed: boolean;
|
|
9
|
+
version?: string;
|
|
10
|
+
session?: SessionRef;
|
|
11
|
+
quota?: QuotaStatus;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type MenuAction =
|
|
15
|
+
| { kind: "switch"; agent: AgentId; fresh: boolean }
|
|
16
|
+
| { kind: "close" }
|
|
17
|
+
| { kind: "quit" };
|
|
18
|
+
|
|
19
|
+
export interface MenuState {
|
|
20
|
+
title: string;
|
|
21
|
+
rows: MenuRow[];
|
|
22
|
+
current?: AgentId;
|
|
23
|
+
notice?: string;
|
|
24
|
+
/** When the current agent has exited, "close" is not offered. */
|
|
25
|
+
canClose: boolean;
|
|
26
|
+
hotkeys: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const dim = (s: string) => `${CSI}2m${s}${CSI}22m`;
|
|
30
|
+
const bold = (s: string) => `${CSI}1m${s}${CSI}22m`;
|
|
31
|
+
const red = (s: string) => `${CSI}31m${s}${CSI}39m`;
|
|
32
|
+
const green = (s: string) => `${CSI}32m${s}${CSI}39m`;
|
|
33
|
+
const yellow = (s: string) => `${CSI}33m${s}${CSI}39m`;
|
|
34
|
+
const cyan = (s: string) => `${CSI}36m${s}${CSI}39m`;
|
|
35
|
+
|
|
36
|
+
/** Full-screen codemax menu drawn on the alternate screen. */
|
|
37
|
+
export class Menu {
|
|
38
|
+
private fresh = false;
|
|
39
|
+
private cursor = 0;
|
|
40
|
+
constructor(private state: MenuState) {
|
|
41
|
+
const i = state.rows.findIndex((r) => r.adapter.id !== state.current && r.installed && !isExhausted(r.quota));
|
|
42
|
+
this.cursor = i >= 0 ? i : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `useAltScreen` false: the agent is already on the alternate screen, so just draw over it. */
|
|
46
|
+
open(useAltScreen = true) {
|
|
47
|
+
out(useAltScreen ? ALT_ON : `${CSI}?25l${CSI}H${CSI}2J`);
|
|
48
|
+
this.render();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
close(useAltScreen = true) {
|
|
52
|
+
out(useAltScreen ? ALT_OFF : `${CSI}H${CSI}2J${CSI}?25h`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
update(patch: Partial<MenuState>) {
|
|
56
|
+
Object.assign(this.state, patch);
|
|
57
|
+
this.render();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
render() {
|
|
61
|
+
const { cols } = termSize();
|
|
62
|
+
const w = Math.min(cols - 2, 96);
|
|
63
|
+
const L: string[] = [];
|
|
64
|
+
L.push(bold(cyan(" codemax ")) + dim(`— ${this.state.title}`));
|
|
65
|
+
L.push(dim("─".repeat(w)));
|
|
66
|
+
if (this.state.notice) L.push(yellow(" ! " + this.state.notice), "");
|
|
67
|
+
this.state.rows.forEach((r, i) => {
|
|
68
|
+
const sel = i === this.cursor ? cyan("›") : " ";
|
|
69
|
+
const num = dim(`${i + 1}.`);
|
|
70
|
+
const name = r.adapter.id === this.state.current ? bold(r.adapter.name) + dim(" (current)") : r.adapter.name;
|
|
71
|
+
const inst = r.installed ? green("installed") + (r.version ? dim(` ${r.version}`) : "") : red("not found");
|
|
72
|
+
const sess = r.session ? dim(`session ${r.session.id.slice(0, 8)}… ${ago(r.session.lastUsedAt)}`) : dim("no session");
|
|
73
|
+
const q = r.quota ? (isExhausted(r.quota) ? red(quotaLabel(r.quota)) : yellow(quotaLabel(r.quota))) : "";
|
|
74
|
+
L.push(` ${sel} ${num} ${pad(name, 22)} ${pad(inst, 28)} ${pad(sess, 34)} ${q}`);
|
|
75
|
+
});
|
|
76
|
+
L.push("");
|
|
77
|
+
const mode = this.fresh ? yellow("fresh session") : green("resume if a session exists");
|
|
78
|
+
L.push(dim(" ↑/↓ or 1-9 pick · Enter switch · f toggle: ") + mode);
|
|
79
|
+
L.push(dim(` ${this.state.canClose ? "Esc/q back to agent · " : ""}x quit codemax · hotkey ${hotkeyLabel(this.state.hotkeys)}`));
|
|
80
|
+
out(`${CSI}H${CSI}2J` + L.map((l) => l + `${CSI}K`).join("\r\n"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Returns an action when the key completes one; null otherwise. */
|
|
84
|
+
handleKey(bytes: Uint8Array): MenuAction | null {
|
|
85
|
+
const s = Buffer.from(bytes).toString("utf8");
|
|
86
|
+
// A chunk may hold several keys typed quickly; escape sequences stay whole.
|
|
87
|
+
if (s.length > 1 && !s.startsWith("\x1b")) {
|
|
88
|
+
for (const ch of s) {
|
|
89
|
+
const a = this.handleKey(Buffer.from(ch));
|
|
90
|
+
if (a) return a;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (s === "\x1b[A" || s === "k") this.move(-1);
|
|
95
|
+
else if (s === "\x1b[B" || s === "j") this.move(1);
|
|
96
|
+
else if (s === "f") {
|
|
97
|
+
this.fresh = !this.fresh;
|
|
98
|
+
this.render();
|
|
99
|
+
} else if (s === "\r" || s === "\n") return this.pick(this.cursor);
|
|
100
|
+
else if (/^[1-9]$/.test(s)) return this.pick(Number(s) - 1);
|
|
101
|
+
else if (s === "x" || s === "\x03") return { kind: "quit" };
|
|
102
|
+
else if ((s === "\x1b" || s === "q") && this.state.canClose) return { kind: "close" };
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private move(d: number) {
|
|
107
|
+
const n = this.state.rows.length;
|
|
108
|
+
this.cursor = (this.cursor + d + n) % n;
|
|
109
|
+
this.render();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private pick(i: number): MenuAction | null {
|
|
113
|
+
const r = this.state.rows[i];
|
|
114
|
+
if (!r) return null;
|
|
115
|
+
if (!r.installed) {
|
|
116
|
+
this.update({ notice: `${r.adapter.name} is not installed (looked for "${r.adapter.bin}" on PATH).` });
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return { kind: "switch", agent: r.adapter.id, fresh: this.fresh };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function pad(s: string, n: number): string {
|
|
124
|
+
const visible = s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
125
|
+
return visible >= n ? s : s + " ".repeat(n - visible);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ago(ts: number): string {
|
|
129
|
+
const m = Math.round((Date.now() - ts) / 60_000);
|
|
130
|
+
if (m < 1) return "just now";
|
|
131
|
+
if (m < 60) return `${m}m ago`;
|
|
132
|
+
const h = Math.round(m / 60);
|
|
133
|
+
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
134
|
+
}
|