agent.libx.js 0.93.32 → 0.93.34

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.
Files changed (3) hide show
  1. package/README.md +46 -10
  2. package/package.json +2 -2
  3. package/cli/cli.ts +0 -2362
package/cli/cli.ts DELETED
@@ -1,2362 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * agentx — a Claude-Code-style CLI for the agent.libx.js runtime.
4
- *
5
- * agentx # interactive REPL in the current dir
6
- * agentx "fix the bug in src/x" # headless: run once, print, exit
7
- * agentx -p "…" # explicit headless (pipeable: text→stdout, logs→stderr)
8
- * agentx -c "keep going" # continue the most recent session
9
- * agentx -r # resume — interactive session picker (or -r <id> for a specific one)
10
- * agentx -m openai/gpt-… -C ./pkg --plan "refactor auth"
11
- *
12
- * Flags: -m/--model, -C/--cwd, -p/--print, -c/--continue, --resume <id>, --no-stream,
13
- * --vfs/--sandbox, --shell, --plan, --ask, --yes, --subagents, --max-steps N, --max-tokens N, --timeout S, -h/--help.
14
- * Needs at least one provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY /
15
- * GROQ_API_KEY; bun auto-loads .env).
16
- * Filesystem modes: real disk by default (jailed so secrets stay invisible); `--vfs` runs over an
17
- * in-memory copy (disk untouched); `--shell` adds a real /bin/sh. Mutations prompt for approval when
18
- * interactive, auto-approve (with a notice) when unattended — `--ask`/`--yes` force the choice.
19
- * Auto-loads ./.agent/{skills,commands,memory}, ./AGENTS.md|CLAUDE.md, and persists the
20
- * conversation to ./.agent/sessions/<id>.json so it can be resumed.
21
- */
22
- import { createInterface } from 'node:readline/promises';
23
- import { existsSync, readFileSync, appendFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs';
24
- import { homedir, tmpdir } from 'node:os';
25
- import { grabClipboardImage } from './clipboard';
26
- import { join, resolve, basename, extname, dirname } from 'node:path';
27
- import { AIClient, listModels, listProviders, getProviderFromModel, getModelInfo, resolveModel, isModelSupported, disposeCursorSessions } from 'ai.libx.js';
28
- import {
29
- PermissionPolicy, loadCommands, expandCommand, loadSkills, forComponent, composeHooks, reflectOnRun,
30
- AgentOptions, exitSessionTool,
31
- CommandExecutor, registerHeadlessCommands, mkdirp, contentText, imagePart,
32
- type Agent, type ChatLike, type HostBridge, type Hooks, type IFilesystem, type CommandInfo, type SkillInfo, type RunResult, type AgentTool, type ToolUse, type Message, type ContentPart, type MessageContent, type ReasoningEffort,
33
- } from '../src/index';
34
- import { mountMcpServer, mountMcpServers, type MountedMcp, type McpServerConfig } from '../src/mcp.client';
35
- import { McpOAuth } from './mcpOAuth';
36
- import { buildAgent, summarizeCall, cursorProviderOptions, type CliOptions } from './core';
37
- import { DuplexAgent } from '../src/duplex';
38
- import { VoiceIO } from './voice';
39
- import { loadConfig, type AgentConfig } from './config';
40
- import { hooksFromConfig } from './hooks-config';
41
- import { diffLines, statOf, formatDiff } from './diff';
42
- import { SessionStore, titleOf, type SessionData, type TurnEvent } from './session';
43
- import { CheckpointStack, type Checkpoints } from './checkpoints';
44
- import { GitCheckpoints } from './gitCheckpoints';
45
- import { parsePermRules, describeRule, loadPersistedRules, loadClaudeSettings, persistRule, mergePerms, isTrusted, trustDir } from './permissions';
46
- import { completeLine, type DirLister } from './complete';
47
- import { createLineEditor, selectMenu, REWIND, isPrintable, type SelectItem, type PasteClassifier, type LineEditor } from './lineEditor';
48
- import { MarkdownStream } from './markdown';
49
-
50
- // ---- tiny ANSI (auto-off when piped; honors NO_COLOR / FORCE_COLOR) ----
51
- // Precedence: explicit FORCE_COLOR wins (the CI case — force color through a pipe); else NO_COLOR forces
52
- // off (accessibility, https://no-color.org); else fall back to TTY detection.
53
- const forceColor = process.env.FORCE_COLOR != null && process.env.FORCE_COLOR !== '' && process.env.FORCE_COLOR !== '0';
54
- const useColor = forceColor || (!process.env.NO_COLOR && !!process.stdout.isTTY && !!process.stderr.isTTY);
55
- const tty = process.stdout.isTTY && process.stderr.isTTY;
56
- const C = (n: string) => (s: string) => (useColor ? `\x1b[${n}m${s}\x1b[0m` : String(s));
57
- const dim = C('2'), cyan = C('36'), green = C('32'), red = C('31'), bold = C('1'), yellow = C('33');
58
- const err = (s: string) => process.stderr.write(s);
59
- const log = forComponent('cli');
60
-
61
- /** This CLI's version, read from package.json (so /version and the banner can't drift from the package). */
62
- const VERSION: string = (() => {
63
- try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? '?'; }
64
- catch { return '?'; }
65
- })();
66
-
67
- // ---- thinking spinner (TTY only; cleared the instant real output arrives) ----
68
- const spinner = (() => {
69
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
70
- let timer: ReturnType<typeof setInterval> | undefined;
71
- let i = 0;
72
- return {
73
- start(label = 'thinking…') {
74
- if (!tty || timer) return; // no-op when piped or already spinning
75
- timer = setInterval(() => err('\r\x1b[2K' + dim(` ${frames[i = (i + 1) % frames.length]} ${label}`)), 90);
76
- },
77
- stop() { if (timer) { clearInterval(timer); timer = undefined; err('\r\x1b[2K'); } },
78
- };
79
- })();
80
-
81
- // The in-flight turn's aborter (Esc / Ctrl-C / SIGINT call .abort()); null when idle at the prompt.
82
- let activeTurn: AbortController | null = null;
83
- // Set by the ExitSession tool when the model decides the user wants to quit (voice: "ok bye").
84
- let exitRequested = false;
85
-
86
- // Input stash: type-ahead queue for input entered while a turn is running.
87
- const inputStash: string[] = [];
88
- let stashBuf = '';
89
-
90
- // ---- arg parsing ----
91
- interface Args {
92
- task?: string; model?: string; cwd?: string; stream: boolean; plan: boolean; ask: boolean; yes: boolean;
93
- vfs: boolean; shell: boolean | undefined; boddb?: string; seed: boolean;
94
- subagents: boolean; maxSteps?: number; maxTokens?: number; timeoutMs?: number; reasoning?: ReasoningEffort; help: boolean; version: boolean;
95
- duplex: boolean; voiceModel?: string; thinkModel?: string | false; voice: boolean;
96
- cont: boolean; resume?: string; sessionId?: string; fork?: boolean; outputFormat: 'text' | 'json' | 'stream-json';
97
- allowedTools?: string[]; disallowedTools?: string[]; appendSystemPrompt?: string; addDirs?: string[];
98
- print?: boolean; debug?: boolean; scratch?: boolean;
99
- }
100
- /** Parse a numeric flag, failing fast on a missing/NaN/negative value so a typo can't silently disable a kill-switch. */
101
- function numFlag(raw: string | undefined, flag: string): number {
102
- const n = Number(raw);
103
- if (!Number.isFinite(n) || n < 0) throw new Error(`invalid ${flag}: ${raw ?? '(missing value)'}`);
104
- return n;
105
- }
106
-
107
- /** Parse `--reasoning` value: off|low|medium|high, or a raw token budget. */
108
- function parseReasoning(raw: string): ReasoningEffort {
109
- if (raw === 'off' || raw === 'low' || raw === 'medium' || raw === 'high') return raw;
110
- const n = Number(raw);
111
- if (Number.isFinite(n) && n > 0) return n;
112
- throw new Error(`invalid --reasoning: ${raw} (use off|low|medium|high or a token budget)`);
113
- }
114
-
115
- export function parseArgs(argv: string[]): Args {
116
- const a: Args = { stream: true, plan: false, ask: false, yes: false, vfs: false, shell: undefined, seed: false, subagents: false, help: false, version: false, cont: false, outputFormat: 'text', duplex: false, voice: false, scratch: true };
117
- const rest: string[] = [];
118
- // read the value that follows a flag, failing loudly if it's missing (instead of a surprise default)
119
- const val = (i: number, flag: string): string => { const v = argv[i]; if (v === undefined) throw new Error(`${flag} requires a value`); return v; };
120
- for (let i = 0; i < argv.length; i++) {
121
- const x = argv[i];
122
- if (x === '-h' || x === '--help') a.help = true;
123
- else if (x === '-v' || x === '--version') a.version = true;
124
- else if (x === '-m' || x === '--model') a.model = val(++i, x);
125
- else if (x === '-C' || x === '--cwd') a.cwd = val(++i, x);
126
- else if (x === '-p' || x === '--print') {
127
- // CC-style: `-p "task"` uses the inline value; bare `-p` (piped) reads the prompt from stdin.
128
- const nxt = argv[i + 1];
129
- if (nxt !== undefined && !nxt.startsWith('-')) a.task = argv[++i];
130
- a.print = true;
131
- }
132
- else if (x === '--debug' || x === '--verbose') a.debug = true;
133
- else if (x === '-c' || x === '--continue') a.cont = true;
134
- else if (x === '--resume' || x === '-r') {
135
- // CC-style: `-r <id>` resumes that session; bare `-r` (no id / next is a flag) opens the picker.
136
- const nxt = argv[i + 1];
137
- a.resume = nxt !== undefined && !nxt.startsWith('-') ? argv[++i] : ''; // '' = sentinel → interactive pick
138
- }
139
- else if (x === '--no-stream') a.stream = false;
140
- else if (x === '--plan') a.plan = true;
141
- else if (x === '--ask') a.ask = true;
142
- else if (x === '--yes' || x === '-y') a.yes = true;
143
- else if (x === '--vfs' || x === '--sandbox') a.vfs = true;
144
- else if (x === '--scratch') a.scratch = true;
145
- else if (x === '--no-scratch') a.scratch = false;
146
- else if (x === '--boddb') a.boddb = val(++i, x);
147
- else if (x === '--seed') a.seed = true;
148
- else if (x === '--shell') a.shell = true;
149
- else if (x === '--no-shell') a.shell = false;
150
- else if (x === '--subagents') a.subagents = true;
151
- else if (x === '--duplex') a.duplex = true;
152
- else if (x === '--conversational' || x === '--convo' || x === '--voice') { a.voice = true; a.duplex = true; } // duplex + human conversational register (--convo/--voice = aliases)
153
- else if (x === '--voice-model') a.voiceModel = val(++i, x);
154
- else if (x === '--think-model') a.thinkModel = val(++i, x);
155
- else if (x === '--no-think') a.thinkModel = false;
156
- else if (x === '--allowedTools' || x === '--allowed-tools') a.allowedTools = val(++i, x).split(',').map((s) => s.trim()).filter(Boolean);
157
- else if (x === '--disallowedTools' || x === '--disallowed-tools') a.disallowedTools = val(++i, x).split(',').map((s) => s.trim()).filter(Boolean);
158
- else if (x === '--append-system-prompt') a.appendSystemPrompt = val(++i, x);
159
- else if (x === '--add-dir') (a.addDirs ??= []).push(val(++i, x));
160
- else if (x === '--reasoning') a.reasoning = parseReasoning(val(++i, x));
161
- else if (x === '--session-id') a.sessionId = val(++i, x);
162
- else if (x === '--fork-session') a.fork = true;
163
- else if (x === '--max-steps') a.maxSteps = numFlag(argv[++i], '--max-steps');
164
- else if (x === '--max-tokens') a.maxTokens = numFlag(argv[++i], '--max-tokens');
165
- else if (x === '--timeout') a.timeoutMs = numFlag(argv[++i], '--timeout') * 1000; // seconds → ms
166
- else if (x === '--output-format') {
167
- const f = argv[++i];
168
- if (f !== 'text' && f !== 'json' && f !== 'stream-json') throw new Error(`invalid --output-format: ${f ?? '(missing)'} (use text|json|stream-json)`);
169
- a.outputFormat = f;
170
- }
171
- else rest.push(x);
172
- }
173
- if (!a.task && rest.length) a.task = rest.join(' ');
174
- if (a.boddb && a.vfs) throw new Error('--boddb and --sandbox are mutually exclusive (both are non-disk filesystems; pick one)');
175
- if (a.seed && !a.boddb) throw new Error('--seed only applies with --boddb (it seeds the database from cwd on first run)');
176
- if (a.duplex && (a.task || a.print)) throw new Error('--duplex is interactive-only (a conversational mode) — drop the task/-p');
177
- if (a.duplex && a.plan) throw new Error('--plan is not supported in --duplex (workers are non-interactive; a plan could never be approved)');
178
- if (a.voiceModel && !a.duplex) throw new Error('--voice-model only applies with --duplex');
179
- if (a.thinkModel !== undefined && !a.duplex) throw new Error('--think-model/--no-think only apply with --duplex');
180
- return a;
181
- }
182
-
183
- const HELP = `agentx — agent.libx.js CLI
184
-
185
- agentx interactive REPL (current dir)
186
- agentx "<task>" run once and exit
187
- agentx -p "<task>" headless (stdout=answer, stderr=activity)
188
- echo "<task>" | agentx -p headless, prompt read from stdin
189
- agentx -c "<task>" continue the most recent session
190
-
191
- Flags:
192
- -m, --model <id> model (default anthropic/claude-sonnet-4-6)
193
- -C, --cwd <dir> working dir / project root (default .)
194
- -c, --continue resume the most recent session in this dir
195
- -r, --resume [id] resume a session — with an id, or bare for an interactive picker
196
- --no-stream disable token streaming
197
- (default: disk mode — full real filesystem access, like Claude Code)
198
- --vfs, --sandbox sandbox mode: work over an in-memory copy of cwd — real disk is NEVER modified
199
- --no-scratch disable scratch (on by default): paginate oversized tool output → recoverable
200
- scratch files (peek via Grep/Read/Ask) instead of a lossy crop
201
- --boddb <dir> database-backed workspace: files live in a persistent bod-db store at <dir>,
202
- surviving across runs — real disk is NEVER modified (DB-native; add --seed below)
203
- --seed with --boddb: hydrate the store from cwd on the first run (empty DB) only
204
- --shell force real /bin/sh on (default in disk mode); --no-shell to use VFS bash only
205
- --plan plan mode: edits blocked until you approve a plan
206
- --ask confirm each mutating tool (bash/Shell/Write/Edit/…)
207
- --yes, -y auto-approve mutating tools (no prompts) — for trusted/unattended runs
208
- --verbose, --debug verbose logs (sets DEBUG=* — tool args, hook decisions, retries)
209
- --allowedTools <l> comma-list of tools to allow w/o asking, e.g. "Edit,Shell(git *)"
210
- --disallowedTools <l> comma-list of tools to deny outright (wins over allow), e.g. "Shell(rm *)"
211
- --append-system-prompt <t> extra instructions appended to the system prompt for this run
212
- --duplex duplex mode: a fast voice model replies instantly and delegates real work
213
- to a background worker agent (-m model); results are re-voiced when ready
214
- --conversational duplex with a conversation-native register — short fast turns, fillers,
215
- impulsive reactions, human pacing (implies --duplex; aliases: --convo, --voice)
216
- with SONIOX_API_KEY + CARTESIA_API_KEY(+VOICE_ID) set: real voice I/O — mic in,
217
- spoken replies out (echo-cancelled; speak over it to interrupt)
218
- --voice-model <id> with --duplex: the fast voice model (default groq/openai/gpt-oss-120b)
219
- --think-model <id> with --duplex: the premium deep-reasoning model (default anthropic/claude-opus-4-6)
220
- --no-think with --duplex: disable the Think tier (Act handles everything)
221
- --add-dir <path> mount another directory into the workspace (repeatable; disk mode only)
222
- --subagents allow the Task tool (spawn child agents)
223
- --reasoning <e> extended thinking: off|low|medium|high or a token budget (anthropic/openai)
224
- --session-id <id> use this id for the session (instead of an auto-generated one)
225
- --fork-session with -r/-c: branch the resumed session into a new id (source left untouched)
226
- --max-steps <n> step budget (default 30)
227
- --max-tokens <n> token budget kill-switch (default 200000)
228
- --timeout <sec> wall-clock kill-switch (default 120)
229
- --output-format <f> headless output: text (default) | json (one result object) | stream-json (NDJSON events: {type:text|thinking}… then {type:result})
230
- -v, --version print version and exit
231
- -h, --help
232
-
233
- Prompts may reference files with @path (e.g. "explain @src/Agent.ts") — they're inlined.
234
-
235
- Providers: set any of ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY / GROQ_API_KEY.
236
- Config: ./.agent/config.{ts,js,json} (project) or ~/.agent/config.* (user).
237
- export default { model, maxSteps, reasoning, permissionMode, editorMode, tools, apiKeys, baseUrls, hooks, permissions, mcpServers, maxTokens,
238
- timeoutMs, maxRepeats, maxToolCalls, keepToolOutputs, maxContextTokens,
239
- learnFromMistakes, reflectOnFailure, budget: {…} }
240
- hooks: { preToolUse|postToolUse|onStop: [{ tool?, command, block? }] } — shell hooks
241
- permissions: { allow|ask|deny: ["Tool(glob)", …] } — persisted rules (deny>allow>ask; deny applies even with --yes)
242
- mcpServers: { name: { command, args, env } | { url, headers } } — auto-mounted MCP servers
243
- learnFromMistakes: capture recurring tool failures as memory lessons (needs .agent/memory)
244
- reflectOnFailure: on a bad headless run, reflect once via the model + persist a novel lesson
245
- Precedence: CLI flags > project config > user config > defaults.
246
- Project instructions: ./AGENTS.md or ./CLAUDE.md are auto-loaded (scaffold with /init).
247
- Auto-loaded from ./.agent/: commands/, skills/, memory/, agents/.
248
-
249
- REPL shortcuts: !<cmd> runs a shell command inline · #<note> saves a memory · @path inlines a file
250
- REPL slash commands: /help /version /tools /permissions /status /cost /context /cwd /model /reasoning /config /rename /compact /rewind /undo /clear /sessions /resume /commands /skills /mcp /init /export /paste /goal /exit (duplex: /act /think /voice /voice-model /think-model)
251
- REPL completion: type / (commands+skills) or @ (files) for a LIVE menu — ↑/↓ select, ⏎/Tab accept, Esc dismiss.
252
- REPL multi-line: Option/Alt+Enter inserts a newline, or end a line with \\ to continue. Esc cancels a running turn / clears the input line; double-Esc jumps back to edit a previous message.
253
- REPL shortcuts: Shift+Tab cycles permission posture (ask → accept-edits → plan) · Alt+T toggles reasoning · Alt+P switches model · Ctrl+O toggles verbose tool output · → or Tab accepts the dim history ghost-suggestion · Alt+S/Ctrl+S stash/unstash.
254
- REPL stash: type while a turn is running → Enter queues it (auto-submits when the turn finishes). Alt+S (or Ctrl+S) with text stashes it; on an empty prompt pops the next entry for editing.
255
- REPL editing (emacs/readline): Ctrl-A/E line start/end · Ctrl-B/F char · Alt-B/F or Alt/Ctrl-←/→ word · Ctrl-W kill word · Ctrl-U/K kill to start/end · Ctrl-Y yank · Alt-D kill word fwd · Ctrl-L clear screen. Set editorMode:'vim' (or /config) for modal vim editing.
256
- REPL paste: large/multi-line pastes collapse to a [Pasted text +N lines] preview (expands on send); a pasted image/file path attaches as [Image]/[File]; /paste grabs a clipboard image (macOS).`;
257
-
258
- // ---- provider keys (env + config; env wins) ----
259
- /** Newest supported model by releaseDate — the fallback when a requested model is unknown/dropped. */
260
- function newestModel(): string {
261
- return listModels().slice().sort((a, b) => (getModelInfo(b)?.releaseDate ?? '').localeCompare(getModelInfo(a)?.releaseDate ?? ''))[0] ?? '';
262
- }
263
-
264
- /** Resolve a requested model to a supported id; if it's gone from ai.libx.js (stale flag/config OR an
265
- * outdated built-in default), warn + fall back to the newest available so the first call never hard-errors. */
266
- function resolveModelOrNewest(model: string | undefined): string {
267
- const want = model ?? new AgentOptions().model; // single source of truth for the default
268
- const resolved = resolveModel(want);
269
- if (isModelSupported(resolved)) return resolved;
270
- const fallback = newestModel();
271
- if (!fallback) return want; // no catalog at all → pass through, let the API surface it
272
- err(yellow(` ⚠ model "${want}" not available — using ${fallback}\n`));
273
- return fallback;
274
- }
275
-
276
- /** Extra env-var names accepted per provider beyond the `${PROVIDER}_API_KEY` convention. */
277
- const ENV_KEY_ALIASES: Record<string, string[]> = { google: ['GEMINI_API_KEY'] };
278
-
279
- /** Fallback .env loader: Bun auto-loads .env from the CWD, but when agentx is launched from an unrelated
280
- * directory those keys are missing. So also read .env / .env.local from the agentx install/source repo
281
- * (the dir holding this file's package.json). Never overrides an existing var — CWD + shell still win. */
282
- function loadInstallEnv(): void {
283
- let dir = dirname(import.meta.path); // .../agent.libx.js/cli → walk up to the package root
284
- for (let i = 0; i < 5 && !existsSync(join(dir, 'package.json')); i++) dir = dirname(dir);
285
- for (const name of ['.env', '.env.local']) {
286
- const file = join(dir, name);
287
- if (!existsSync(file)) continue;
288
- for (const line of readFileSync(file, 'utf8').split('\n')) {
289
- const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
290
- if (!m || m[1] in process.env) continue;
291
- let val = m[2].trim();
292
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
293
- else val = val.replace(/\s+#.*$/, '').trim(); // strip inline comment only on unquoted values
294
- process.env[m[1]] = val;
295
- }
296
- }
297
- }
298
-
299
- /** Map env vars → provider api keys. DYNAMIC over `listProviders()` (convention `${PROVIDER}_API_KEY` +
300
- * aliases), so a provider newly added to ai.libx.js is picked up automatically — no edit needed here. */
301
- function apiKeysFromEnv(): Record<string, string> {
302
- const e = process.env, keys: Record<string, string> = {};
303
- for (const provider of listProviders()) {
304
- const names = [`${provider.toUpperCase()}_API_KEY`, ...(ENV_KEY_ALIASES[provider] ?? [])];
305
- const val = names.map((n) => e[n]).find(Boolean);
306
- if (val) keys[provider] = val;
307
- }
308
- return keys;
309
- }
310
-
311
- // ---- terminal host (human-in-the-loop prompts) ----
312
- function makeHost(format: 'text' | 'json' | 'stream-json' = 'text', opts?: { stream?: boolean }): HostBridge & { flushText: () => void } {
313
- const streamJson = format === 'stream-json'; // emit NDJSON events on stdout as they arrive
314
- const cleanStdout = format === 'json'; // keep stdout for the single final result object
315
- // Render markdown only for the STREAMING host on a color-capable TTY; piped / NO_COLOR / json stay raw.
316
- // Gated on `opts.stream` so the throwaway confirm/permission hosts don't allocate a dead MarkdownStream
317
- // (and, below, don't each leak a process-lifetime SIGWINCH listener).
318
- const md = format === 'text' && useColor && opts?.stream ? new MarkdownStream({ bold, dim, cyan }, process.stdout.columns ?? 80) : null;
319
- if (md) process.on('SIGWINCH', () => md.resize(process.stdout.columns ?? 80)); // reflow streamed markdown on resize
320
- const flushText = () => { if (md && md.pending()) process.stdout.write(md.flush()); };
321
- // `thinking_delta` streams to stderr WITHOUT a trailing newline (a continuous dimmed reasoning flow).
322
- // Any other output that follows (the answer on stdout, a `· notice`, the next step header) must first
323
- // close that open line — else it collides mid-word into the reasoning tail (`· step 2` → `step ya`).
324
- let openReasonLine = false;
325
- const closeReasonLine = () => { if (openReasonLine) { process.stderr.write('\n'); openReasonLine = false; } };
326
- return {
327
- flushText,
328
- notify(e) {
329
- spinner.stop(); // real output arriving → clear the spinner first
330
- if (e.kind === 'text_delta') {
331
- if (streamJson) process.stdout.write(JSON.stringify({ type: 'text', text: e.message }) + '\n');
332
- else if (md) { closeReasonLine(); process.stdout.write(md.feed(e.message)); } // line-buffered markdown render
333
- else { if (!cleanStdout) closeReasonLine(); (cleanStdout ? process.stderr : process.stdout).write(e.message); } // json keeps stdout clean → stderr
334
- return;
335
- }
336
- // extended-thinking (reasoning-native models): a dimmed stderr flow in text mode; a typed event in
337
- // stream-json; never on stdout in plain json (can't pollute the single result object).
338
- if (e.kind === 'thinking_delta') {
339
- if (streamJson) process.stdout.write(JSON.stringify({ type: 'thinking', text: e.message }) + '\n');
340
- else if (!cleanStdout) {
341
- // flush any buffered answer (stdout) before the dimmed thinking (stderr) so the two streams
342
- // stay in order — otherwise late-flushed markdown lands after the thinking it preceded.
343
- if (md && md.pending()) process.stdout.write(md.flush() + '\n');
344
- process.stderr.write(dim(e.message));
345
- openReasonLine = true; // unterminated reasoning line — next output must close it
346
- }
347
- return;
348
- }
349
- // Structural events (tool_use/tool_result/tool_result_image/turn_start) have dedicated renderers
350
- // (displayHooks ⚙ chrome, the step header) — never dump their raw `kind` as a `· notice`, or the
351
- // result preview gets sandwiched between junk `· tool_use`/`· tool_result` labels.
352
- if (!('message' in e)) return;
353
- closeReasonLine(); // close an open reasoning line so the notice starts fresh (not glued mid-word)
354
- if (md && md.pending()) process.stdout.write(md.flush() + '\n'); // finish the in-progress line before a notice
355
- err(dim(` · ${e.message}\n`)); // tool-activity notices stay on stderr (human chrome) for every format
356
- },
357
- async confirm(prompt) {
358
- // arrow-select on a TTY; fall back to a typed y/N when piped (selectMenu → null)
359
- const v = await selectMenu(process.stderr, { title: `? ${prompt}`, items: [{ label: 'Yes', value: 'y' }, { label: 'No', value: 'n' }], current: 'n' });
360
- if (v !== null) return v === 'y';
361
- const io = createInterface({ input: process.stdin, output: process.stderr });
362
- try { return /^y(es)?$/i.test((await io.question(yellow(` ? ${prompt} [y/N] `))).trim()); } finally { io.close(); }
363
- },
364
- async ask(q) {
365
- const title = `? ${q.header ? '[' + q.header + '] ' : ''}${q.question}`;
366
- const v = await selectMenu(process.stderr, { title, items: q.options.map((o) => ({ label: o.label, value: o.label, desc: o.description })) });
367
- if (v !== null) return v;
368
- const io = createInterface({ input: process.stdin, output: process.stderr });
369
- try {
370
- const lines = [yellow(' ' + title)];
371
- q.options.forEach((o, i) => lines.push(` ${i + 1}) ${o.label}${o.description ? dim(' — ' + o.description) : ''}`));
372
- const ans = (await io.question(lines.join('\n') + '\n > ')).trim();
373
- const n = Number(ans);
374
- return Number.isInteger(n) && q.options[n - 1] ? q.options[n - 1].label : ans;
375
- } finally { io.close(); }
376
- },
377
- };
378
- }
379
-
380
- /** Hooks that render tool activity to stderr: a preToolUse header, and for edits a colorized diff.
381
- * `opts.background` marks the agent as a BACKGROUND one (duplex workers): its chrome lands async on
382
- * top of a live prompt, so it must never drive the foreground spinner (the 'agentx › '/'⠹ thinking…'
383
- * flicker), clears the prompt line before printing, and repaints via the callback after. `opts.gate`
384
- * is evaluated per call — false renders nothing (minimal mode: task events only). */
385
- function displayHooks(fs?: IFilesystem, opts?: { gate?: () => boolean; background?: () => void }): Hooks {
386
- const EDIT = new Set(['Edit', 'MultiEdit', 'Write']);
387
- const before = new Map<string, string>();
388
- const MAX = 64 * 1024; // skip diffing very large files
389
- const bg = opts?.background;
390
- const on = () => !opts?.gate || opts.gate();
391
- const read = async (p: unknown): Promise<string> => {
392
- if (!fs || typeof p !== 'string') return '';
393
- try { return await fs.readFile(p); } catch { return ''; }
394
- };
395
- return {
396
- async preToolUse(call) {
397
- if (!on()) return;
398
- if (bg) err('\r\x1b[0J'); else spinner.stop(); // foreground: a tool is about to run → stop "thinking…"
399
- err(cyan(`\n ⚙ ${call.name}`) + dim(' ' + summarizeCall(call.name, call.args)) + '\n');
400
- if (EDIT.has(call.name)) before.set(String(call.args?.path), await read(call.args?.path));
401
- bg?.();
402
- },
403
- onToolOutput(_call, chunk) {
404
- if (!verboseOutput || !on()) return; // Ctrl+O verbose: live-tail streaming tool output (default chrome stays calm)
405
- if (bg) err('\r\x1b[0J');
406
- for (const ln of String(chunk).split('\n')) if (ln.trim()) err(dim(` ⋮ ${ln.length > 200 ? ln.slice(0, 200) + '…' : ln}\n`));
407
- bg?.();
408
- },
409
- async postToolUse(call, result) {
410
- if (!on()) return;
411
- if (bg) err('\r\x1b[0J'); else spinner.stop();
412
- try {
413
- if (EDIT.has(call.name)) {
414
- const path = String(call.args?.path);
415
- const b = before.get(path) ?? '';
416
- const a = await read(path);
417
- before.delete(path);
418
- if (a !== b && b.length < MAX && a.length < MAX) {
419
- const ops = diffLines(b, a); // diff once → reuse for both the header stat and the body
420
- const { added, removed } = statOf(ops);
421
- err(dim(` ⎿ ${path} `) + green(`+${added}`) + ' ' + red(`-${removed}`) + '\n');
422
- const body = formatDiff(ops, { add: green, del: red, dim, context: 2, maxLines: 80 });
423
- if (body) err(body + '\n');
424
- return;
425
- }
426
- }
427
- const text = String(result).replace(/\s+$/, '');
428
- if (text && !/^Edited|^Wrote|^Applied/.test(text)) {
429
- const lines = text.split('\n');
430
- const shown = lines.slice(0, previewLines());
431
- for (const ln of shown) err(dim(` ${ln.length > 200 ? ln.slice(0, 200) + '…' : ln}\n`));
432
- const more = lines.length - shown.length;
433
- if (more > 0) err(dim(` … (+${more} more line${more > 1 ? 's' : ''})\n`));
434
- } else if (!text) {
435
- err(dim(' ⎿ (no output)\n')); // empty result → confirm completion so it doesn't look hung
436
- }
437
- } finally {
438
- if (bg) bg(); // background: repaint the live prompt below the chrome — NEVER the spinner
439
- else spinner.start(); // foreground: tool done → the model is thinking about the next step
440
- }
441
- },
442
- };
443
- }
444
-
445
- /** Render a resumed conversation (like CC) so the user sees the context they're continuing: user
446
- * prompts, assistant narration, and a condensed line per tool action. Tool *results* are omitted
447
- * (verbose) and inlined @-mention blocks are stripped from prompts. Pure → unit-testable. */
448
- export function formatHistory(messages: Message[]): string {
449
- const shown = messages.filter((m) => m.role !== 'system');
450
- if (!shown.length) return '';
451
- const out: string[] = [dim('\n ── prior conversation ──────────────────────\n')];
452
- for (const m of shown) {
453
- if (m.role === 'user') {
454
- const t = contentText(m.content).split('\n\n--- @')[0].replace(/\n+/g, ' ').trim(); // drop inlined @file blocks
455
- if (t) out.push('\n' + bold(cyan(' › ')) + (t.length > 1500 ? t.slice(0, 1500) + dim(' …') : t) + '\n');
456
- } else if (m.role === 'assistant') {
457
- const at = contentText(m.content);
458
- if (at.trim()) out.push(dim(' ') + at.trim() + '\n');
459
- for (const tc of m.tool_calls ?? []) {
460
- let args: any = {};
461
- try { args = JSON.parse(tc.function.arguments || '{}'); } catch { /* unparsed args */ }
462
- out.push(cyan(' ⚙ ') + tc.function.name + dim(' ' + summarizeCall(tc.function.name, args)) + '\n');
463
- }
464
- }
465
- }
466
- out.push(dim(' ────────────────────────────────────────────\n'));
467
- return out.join('');
468
- }
469
-
470
- /** Render a transcript to portable Markdown (no ANSI) for `/export`. Same shape as formatHistory —
471
- * user prompts + assistant narration + one quoted line per tool action; tool results omitted. Pure → unit-testable. */
472
- export function exportMarkdown(
473
- meta: { id: string; title?: string; model?: string; turns?: number; created?: number; tokens?: number; costUsd?: number; costEstimated?: boolean },
474
- messages: Message[],
475
- ): string {
476
- const shown = messages.filter((m) => m.role !== 'system');
477
- const stamp = meta.created ? new Date(meta.created).toISOString() : '';
478
- const cost = meta.costUsd ? ` · ${meta.costEstimated ? '~' : ''}${fmtUsd(meta.costUsd)}` : '';
479
- const head = [
480
- `# ${meta.title || 'Conversation'}`,
481
- '',
482
- `- **Session:** \`${meta.id}\``,
483
- ...(meta.model ? [`- **Model:** ${meta.model}`] : []),
484
- ...(stamp ? [`- **Date:** ${stamp}`] : []),
485
- ...(meta.turns != null ? [`- **Turns:** ${meta.turns}`] : []),
486
- ...(meta.tokens ? [`- **Usage:** ${(meta.tokens / 1000).toFixed(1)}k tok${cost}`] : []),
487
- '', '---', '',
488
- ];
489
- const body: string[] = [];
490
- for (const m of shown) {
491
- if (m.role === 'user') {
492
- const t = contentText(m.content).split('\n\n--- @')[0].trim(); // drop inlined @file blocks
493
- if (t) body.push('## 👤 User', '', t, '');
494
- } else if (m.role === 'assistant') {
495
- const parts: string[] = [];
496
- const at = contentText(m.content).trim();
497
- if (at) parts.push(at);
498
- for (const tc of m.tool_calls ?? []) {
499
- let args: any = {};
500
- try { args = JSON.parse(tc.function.arguments || '{}'); } catch { /* unparsed args */ }
501
- parts.push(`> 🔧 \`${tc.function.name}\` — ${summarizeCall(tc.function.name, args)}`.trimEnd());
502
- }
503
- if (parts.length) body.push('## 🤖 Assistant', '', parts.join('\n\n'), '');
504
- }
505
- }
506
- return head.concat(body).join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
507
- }
508
-
509
- /** Replay a resumed conversation to stderr. */
510
- function printHistory(messages: Message[]): void {
511
- const s = formatHistory(messages);
512
- if (s) err(s);
513
- }
514
-
515
- /** Cache-read/write price multipliers over the input rate, by provider (derived from the model
516
- * prefix). Anthropic: write 1.25x / read 0.1x. OpenAI & Gemini auto-cache (no write surcharge),
517
- * reads 0.5x / 0.25x. DeepSeek read 0.1x. Unknown → no discount (1x/1x, safe over-estimate). */
518
- export function cacheMultipliers(model?: string): { read: number; write: number } {
519
- const p = (model ?? '').split('/')[0];
520
- switch (p) {
521
- case 'anthropic': return { read: 0.1, write: 1.25 };
522
- case 'openai': return { read: 0.5, write: 1 };
523
- case 'google': return { read: 0.25, write: 1 };
524
- case 'deepseek': return { read: 0.1, write: 1 };
525
- default: return { read: 1, write: 1 };
526
- }
527
- }
528
-
529
- /** USD cost from a model's per-1K pricing (ai.libx.js ModelPricing) + token usage. 0 if unpriced.
530
- * Cache-aware: promptTokens includes cache reads/writes — priced at the provider's real multipliers
531
- * (via `model`) so cached runs aren't overstated. Omitting `model` falls back to Anthropic's rates. */
532
- export function costOf(
533
- pricing: { inputCostPer1K: number; outputCostPer1K: number } | undefined,
534
- promptTokens = 0, completionTokens = 0, cacheCreationTokens = 0, cacheReadTokens = 0, model?: string,
535
- ): number {
536
- if (!pricing) return 0;
537
- const mult = model ? cacheMultipliers(model) : { read: 0.1, write: 1.25 };
538
- const fresh = Math.max(0, promptTokens - cacheCreationTokens - cacheReadTokens);
539
- return (fresh / 1000) * pricing.inputCostPer1K
540
- + (cacheCreationTokens / 1000) * pricing.inputCostPer1K * mult.write
541
- + (cacheReadTokens / 1000) * pricing.inputCostPer1K * mult.read
542
- + (completionTokens / 1000) * pricing.outputCostPer1K;
543
- }
544
-
545
- /** Cost of one turn at `model`'s rate (looks up ai.libx.js pricing). */
546
- function turnCost(model: string, usage?: { promptTokens?: number; completionTokens?: number; cacheCreationTokens?: number; cacheReadTokens?: number }): number {
547
- return costOf(getModelInfo(model)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0, usage?.cacheCreationTokens ?? 0, usage?.cacheReadTokens ?? 0, model);
548
- }
549
-
550
- /** Evaluate whether a goal condition has been met, based on recent transcript. */
551
- async function evaluateGoal(ai: ChatLike, condition: string, transcript: Message[], log: (s: string) => void): Promise<{ met: boolean; reason: string }> {
552
- const recent = transcript
553
- .filter((m) => m.role === 'assistant')
554
- .slice(-8)
555
- .map((m) => {
556
- const text = typeof m.content === 'string' ? m.content : (m.content as ContentPart[]).filter((p: any) => p.type === 'text').map((p: any) => p.text).join(' ');
557
- return text.slice(0, 600);
558
- })
559
- .join('\n---\n');
560
- try {
561
- const r = await ai.chat({
562
- model: 'anthropic/claude-haiku-4-5',
563
- stream: false,
564
- messages: [
565
- { role: 'system', content: 'You judge whether a goal condition has been met based on conversation transcript. Respond ONLY with JSON: {"met": boolean, "reason": "one sentence"}. Be strict — only met:true if there is clear evidence in the transcript.' },
566
- { role: 'user', content: `Goal condition: ${condition}\n\nRecent assistant messages:\n${recent}` },
567
- ],
568
- }) as { content: string };
569
- const match = r.content.match(/\{[\s\S]*\}/);
570
- if (match) return JSON.parse(match[0]);
571
- } catch (e: any) { log(dim(` (goal evaluator error: ${e?.message ?? e})\n`)); }
572
- return { met: false, reason: 'evaluation unclear' };
573
- }
574
-
575
- /** Normalize a thrown/returned error into the persisted forensic shape. */
576
- function errInfo(e: any): { message: string; statusCode?: number; code?: string } {
577
- return { message: String(e?.message ?? e), statusCode: e?.statusCode, code: e?.code };
578
- }
579
-
580
- /** Format a USD amount: 2 decimals at $1+, 4 below (agent turns are sub-cent). */
581
- export function fmtUsd(n: number): string {
582
- return n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(4)}`;
583
- }
584
-
585
- /** ~4 chars/token estimate over a transcript (matches the Agent's context-budget heuristic). */
586
- export function estimateTranscriptTokens(messages: Message[]): number {
587
- let chars = 0;
588
- for (const m of messages) chars += (m.content?.length ?? 0) + (m.tool_calls ? JSON.stringify(m.tool_calls).length : 0);
589
- return Math.ceil(chars / 4);
590
- }
591
-
592
- /** One-screen session status (model, dir, fs-mode, tools, permission posture, turns/tokens). Pure. */
593
- export function formatStatus(s: {
594
- model: string; cwd: string; mode: string; tools: string[]; permissions: string; turns: number; tokens: number; sessionId: string;
595
- /** Whether the token count is estimated (any turn streamed without provider usage). Defaults to estimated. */
596
- estimated?: boolean;
597
- }): string {
598
- const L = (k: string, v: string) => ` ${k.padEnd(12)} ${v}\n`;
599
- const m = (s.estimated ?? true) ? '~' : '';
600
- return (
601
- L('model', s.model) + L('directory', s.cwd) + L('fs mode', s.mode) +
602
- L('permissions', s.permissions) + L('tools', `${s.tools.length} — ${s.tools.join(', ')}`) +
603
- L('session', `${s.sessionId} · ${s.turns} turn${s.turns === 1 ? '' : 's'} · ${m}${(s.tokens / 1000).toFixed(1)}k tok`)
604
- );
605
- }
606
-
607
- /** Run a `!cmd` line over the VFS (works on real disk in disk mode) and return its output — no model
608
- * call. Mirrors the bash tool's contract so `!ls`/`!git status` behave like the agent's own bash. */
609
- export async function runShellLine(fs: IFilesystem, cmd: string): Promise<string> {
610
- const exec = new CommandExecutor(fs);
611
- registerHeadlessCommands(exec);
612
- const r = await exec.execute(cmd);
613
- const out = (r.output ?? '').replace(/\n+$/, '');
614
- if (r.exitCode !== 0) return `[exit ${r.exitCode}]${r.error ? ' ' + r.error.trim() : ''}${out ? '\n' + out : ''}`;
615
- return out || '(no output)';
616
- }
617
-
618
- /** Resolve a memoryDir (string or string[]) to the primary (write) dir. */
619
- function primaryMemDir(dir: string | string[] | undefined, fallback: string): string {
620
- return (Array.isArray(dir) ? dir[0] : dir) || fallback;
621
- }
622
-
623
- /** Append a `#note` to the memory index (creating the dir/file if needed). Returns the file path. */
624
- export async function appendMemoryNote(fs: IFilesystem, dir: string, text: string): Promise<string> {
625
- await mkdirp(fs, dir);
626
- const idx = `${dir}/MEMORY.md`;
627
- let cur = '';
628
- try { cur = await fs.readFile(idx); } catch { /* new file */ }
629
- const head = cur ? cur.replace(/\n*$/, '\n') : '# Memory\n\n';
630
- await fs.writeFile(idx, head + `- ${text.replace(/\s+/g, ' ').trim()}\n`);
631
- return idx;
632
- }
633
-
634
- const ASK_MUTATING = ['bash', 'Shell', 'Write', 'Edit', 'MultiEdit', 'deleteFile'];
635
- const RESULT_PREVIEW_LINES = 6; // how many lines of a tool result to echo (vs the old single line)
636
- // Ctrl+O (CC parity): toggle full tool-result output vs the N-line preview. Module-level so both
637
- // REPLs, displayHooks and the duplex host read the live value.
638
- let verboseOutput = false;
639
- const previewLines = () => (verboseOutput ? Number.MAX_SAFE_INTEGER : RESULT_PREVIEW_LINES);
640
- const toggleVerbose = (): string => {
641
- verboseOutput = !verboseOutput;
642
- err(dim(` ⌃O verbose output ${verboseOutput ? 'on — full tool results' : 'off — previews'}\n`));
643
- return verboseOutput ? 'verbose' : 'preview';
644
- };
645
-
646
- /** Can we put an interactive prompt in front of a human? (TTY in + err; stdout may be piped). */
647
- const canPrompt = !!(process.stdin.isTTY && process.stderr.isTTY);
648
-
649
- export interface PermMode { gate: 'ask' | 'allow'; notice?: string }
650
-
651
- /**
652
- * Default permission posture — CC-inspired: **ask when a human can answer, allow when unattended.**
653
- * Pure + exported so the decision is unit-testable without a TTY. Precedence:
654
- * --yes → allow (explicit trust, no prompts)
655
- * --ask → ask (explicit gate, even headless)
656
- * interactive (TTY & not json) → ask (the safe default)
657
- * else (headless/piped) → allow, with a one-line notice so it's never silent.
658
- */
659
- export function resolvePermMode(args: { yes?: boolean; ask?: boolean; outputFormat?: string }, interactiveCapable: boolean): PermMode {
660
- if (args.yes) return { gate: 'allow' };
661
- if (args.ask) return { gate: 'ask' };
662
- if (interactiveCapable && args.outputFormat === 'text') return { gate: 'ask' }; // json/stream-json ⇒ headless
663
- return { gate: 'allow', notice: 'unattended: mutating tools auto-approved — pass --ask to gate, --vfs to sandbox, --yes to silence' };
664
- }
665
-
666
- /** Interactive permission resolver: Yes / No / Always-allow / Always-deny. "Always" persists a rule
667
- * to `.agent/permissions.json` (and the policy remembers it for the session). Falls back to a
668
- * typed y/N when piped. */
669
- function makeAskResolver(cwd: string) {
670
- return async (call: ToolUse): Promise<{ decision: 'allow' | 'deny'; remember?: boolean } | undefined> => {
671
- const tgt = call.args?.path ? ` on ${call.args.path}` : (call.args?.command ? `: ${String(call.args.command).slice(0, 60)}` : '');
672
- const v = await selectMenu(process.stderr, {
673
- title: `? Allow ${call.name}${tgt}?`,
674
- items: [
675
- { label: 'Yes (once)', value: 'allow' },
676
- { label: 'No', value: 'deny' },
677
- { label: `Always allow ${call.name}`, value: 'always-allow' },
678
- { label: `Always deny ${call.name}`, value: 'always-deny' },
679
- ],
680
- current: 'allow',
681
- });
682
- if (v === null) { // not a TTY → typed fallback
683
- const io = createInterface({ input: process.stdin, output: process.stderr });
684
- try { return { decision: /^y(es)?$/i.test((await io.question(yellow(` ? Allow ${call.name}${tgt}? [y/N] `))).trim()) ? 'allow' : 'deny' }; }
685
- finally { io.close(); }
686
- }
687
- if (v === 'always-allow') { persistRule(cwd, 'allow', call.name); err(dim(` ✓ remembered: always allow ${call.name}\n`)); return { decision: 'allow', remember: true }; }
688
- if (v === 'always-deny') { persistRule(cwd, 'deny', call.name); err(dim(` ✓ remembered: always deny ${call.name}\n`)); return { decision: 'deny', remember: true }; }
689
- return { decision: v as 'allow' | 'deny' };
690
- };
691
- }
692
-
693
- function optsFor(args: Args, ai: ChatLike, cfg: Partial<AgentConfig> = {}, extraTools: AgentTool[] = []): CliOptions {
694
- const perm = resolvePermMode(args, canPrompt);
695
- if (perm.notice) err(dim(` ⚠ ${perm.notice}\n`));
696
- // Persisted config rules (deny>allow>ask) take precedence; the run's ask-default fills the rest.
697
- // Config rules apply even under --yes, so a `deny Read(./.env)` can't be flag-bypassed.
698
- const configRules = parsePermRules(cfg.permissions);
699
- // --allowedTools / --disallowedTools: per-run grants, highest precedence (deny wins, then allow).
700
- const flagRules = [...parsePermRules({ deny: args.disallowedTools }), ...parsePermRules({ allow: args.allowedTools })];
701
- const askRules = perm.gate === 'ask' ? ASK_MUTATING.map((t) => ({ tool: t, decision: 'ask' as const })) : [];
702
- const rules = [...flagRules, ...configRules, ...askRules];
703
- if (configRules.length) err(dim(` ⊕ ${configRules.length} permission rule(s) from config\n`));
704
- return {
705
- ai,
706
- model: resolveModelOrNewest(args.model ?? cfg.model),
707
- cwd: args.cwd,
708
- tools: cfg.tools,
709
- extraTools,
710
- sandbox: args.vfs,
711
- boddb: args.boddb,
712
- seed: args.seed,
713
- realShell: args.shell, // undefined → core.ts defaults (on for disk, off for sandbox/boddb)
714
- scratch: args.scratch,
715
- appendSystemPrompt: args.appendSystemPrompt,
716
- addDirs: args.addDirs,
717
- stream: args.stream,
718
- host: makeHost(args.outputFormat, { stream: true }),
719
- planMode: args.plan || cfg.planMode || false,
720
- permissions: rules.length
721
- ? new PermissionPolicy({ rules, default: 'allow', host: makeHost(), ask: makeAskResolver(resolve(args.cwd ?? process.cwd())) })
722
- : undefined,
723
- subagents: args.subagents || cfg.subagents || false,
724
- maxSteps: args.maxSteps ?? cfg.maxSteps,
725
- reasoning: args.reasoning ?? cfg.reasoning,
726
- // kill-switches: CLI flag > config > Agent default
727
- maxTokens: args.maxTokens ?? cfg.maxTokens,
728
- timeoutMs: args.timeoutMs ?? cfg.timeoutMs,
729
- maxRepeats: cfg.maxRepeats,
730
- maxToolCalls: cfg.maxToolCalls,
731
- keepToolOutputs: cfg.keepToolOutputs,
732
- maxContextTokens: cfg.maxContextTokens,
733
- learnFromMistakes: cfg.learnFromMistakes,
734
- // Forwarded to cursor/* delegations for environment parity (chat-model providers ignore it).
735
- // Raw config (pre-OAuth): unresolved-oauth http servers are skipped by the cursor mapper.
736
- mcpServers: cfg.mcpServers,
737
- };
738
- }
739
-
740
- /** Build the agent and attach fs-aware display hooks (+ any config-declared shell hooks). */
741
- async function makeAgent(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, extraTools: AgentTool[] = []): Promise<Agent> {
742
- const virtual = args.vfs || !!args.boddb;
743
- if (virtual && args.shell) err(yellow(` ⚠ --shell ignored in ${args.boddb ? '--boddb' : '--vfs sandbox'} (a real shell would escape to disk)\n`));
744
- if (virtual && args.addDirs?.length) err(yellow(` ⚠ --add-dir ignored in ${args.boddb ? '--boddb' : '--vfs sandbox'} (mounted real dirs would escape isolation)\n`));
745
- if (args.addDirs?.length && !virtual) err(dim(` + mounted ${args.addDirs.length} extra dir(s): ${args.addDirs.join(', ')}\n`));
746
- if (args.vfs) err(dim(' ⊝ sandbox (VFS): operating on an in-memory copy — the real disk will not be modified\n'));
747
- else if (args.boddb) err(dim(` ⊝ boddb: files live in a database at ${args.boddb}${args.seed ? ' (seeded from cwd on first run)' : ''} — the real disk will not be modified\n`));
748
- else if (args.shell === false) err(dim(' ⊖ --no-shell: VFS bash only (no real /bin/sh)\n'));
749
- const agent = await buildAgent(optsFor(args, ai, cfg, extraTools));
750
- const display = displayHooks(agent.options.fs);
751
- agent.options.hooks = cfg.hooks ? composeHooks(display, hooksFromConfig(cfg.hooks)) : display;
752
- return agent;
753
- }
754
-
755
- /** For each `auth:'oauth'` http server, inject a cached bearer token (refreshing if needed). A server whose
756
- * token can't be resolved is left as-is — it'll fail mount with a needs-auth hint pointing at `/mcp login`. */
757
- async function resolveOAuth(servers: Record<string, McpServerConfig> = {}, oauth: McpOAuth): Promise<Record<string, McpServerConfig>> {
758
- const out: Record<string, McpServerConfig> = {};
759
- for (const [name, cfg] of Object.entries(servers)) {
760
- if (cfg?.auth === 'oauth' && cfg.url && !cfg.bearerToken) {
761
- try { out[name] = { ...cfg, bearerToken: await oauth.tokenFor(cfg.url) }; }
762
- catch { err(yellow(` MCP "${name}" needs auth — run /mcp login ${name}\n`)); out[name] = cfg; }
763
- } else out[name] = cfg;
764
- }
765
- return out;
766
- }
767
-
768
- /** Mount configured MCP servers (one-line summary to stderr); returns the mounted servers. */
769
- async function mountMcp(cfg: Partial<AgentConfig>, oauth?: McpOAuth): Promise<MountedMcp[]> {
770
- const servers = oauth ? await resolveOAuth(cfg.mcpServers, oauth) : cfg.mcpServers;
771
- const mounted = await mountMcpServers(servers);
772
- const n = mounted.reduce((s, m) => s + m.tools.length, 0);
773
- if (n) err(dim(` + ${n} MCP tool(s) from ${mounted.map((m) => m.name).join(', ')}\n`));
774
- return mounted;
775
- }
776
-
777
- /** Best-effort shutdown of mounted MCP servers (kills stdio children). */
778
- async function closeMcp(mounted: MountedMcp[]): Promise<void> {
779
- await Promise.all(mounted.map((m) => m.client.close().catch((e) => log.debug('mcp close failed', e))));
780
- }
781
-
782
- const IMG_EXT: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
783
-
784
- /** Read `@image.png` mentions from `line` as base64 image content parts (real files under cwd; binary,
785
- * so read via node fs — the VFS readFile is utf8). Unreadable/missing images are skipped silently
786
- * (expandMentions reports the missing @ref separately). */
787
- export function readImageParts(cwd: string, line: string): ContentPart[] {
788
- const refs = [...line.matchAll(/(?:^|\s)@(\S+)/g)].map((m) => m[1].replace(/[?!.,;:)\]}'">]+$/, '')).filter(Boolean);
789
- const parts: ContentPart[] = [];
790
- for (const ref of refs) {
791
- const mime = IMG_EXT[extname(ref).toLowerCase()];
792
- if (!mime) continue;
793
- try { parts.push(imagePart(`data:${mime};base64,${readFileSync(resolve(cwd, ref)).toString('base64')}`)); }
794
- catch { /* missing/unreadable — skip */ }
795
- }
796
- return parts;
797
- }
798
-
799
- /** Classify a pasted blob as a file attachment when it's a drag-dropped/typed path to an existing file.
800
- * Returns an `@<abs>` ref the existing mention pipeline attaches (images) or inlines (other files); the
801
- * in-buffer placeholder shows `[Image #N]` / `[File name #N]`. Returns null for anything not a clear single
802
- * path (so ordinary text pastes are unaffected). Paths with spaces are skipped — the `@\S+` pipeline can't carry them. */
803
- export function pastePathClassifier(cwd: string): PasteClassifier {
804
- return (text: string) => {
805
- let t = text.trim();
806
- if (!t || t.includes('\n')) return null;
807
- t = t.replace(/\\ /g, ' ').replace(/^['"]|['"]$/g, ''); // unescape drag-drop spaces / strip quotes
808
- if (/\s/.test(t)) return null; // spaced path: the @\S+ pipeline can't carry it
809
- if (!/^(\/|~\/|\.\/|\.\.\/)/.test(t)) return null; // only obvious path-like pastes
810
- const abs = t.startsWith('~/') ? join(homedir(), t.slice(2)) : resolve(cwd, t);
811
- try { if (!statSync(abs).isFile()) return null; } catch { return null; }
812
- const isImg = !!IMG_EXT[extname(abs).toLowerCase()];
813
- return { display: isImg ? 'Image' : `File ${basename(abs)}`, ref: '@' + abs };
814
- };
815
- }
816
-
817
- /** Inline `@path` file mentions: append referenced files' contents so the model sees them (missing → warn, leave as-is). */
818
- export async function expandMentions(fs: IFilesystem, line: string): Promise<{ text: string; loaded: string[]; missing: string[] }> {
819
- // strip trailing sentence punctuation glued to the mention (e.g. "@a.ts." / "@a.ts?")
820
- const refs = [...line.matchAll(/(?:^|\s)@(\S+)/g)].map((m) => m[1].replace(/[?!.,;:)\]}'">]+$/, '')).filter(Boolean);
821
- if (!refs.length) return { text: line, loaded: [], missing: [] };
822
- const loaded: string[] = [], missing: string[] = [], blocks: string[] = [];
823
- for (const ref of refs) {
824
- if (IMG_EXT[extname(ref).toLowerCase()]) continue; // images are attached as content parts, not inlined as text
825
- if (loaded.includes(ref) || missing.includes(ref)) continue; // dedupe
826
- try {
827
- if (await fs.exists(ref)) { blocks.push(`--- @${ref} ---\n${await fs.readFile(ref)}`); loaded.push(ref); }
828
- else missing.push(ref);
829
- } catch { missing.push(ref); }
830
- }
831
- return { text: blocks.length ? `${line}\n\n${blocks.join('\n\n')}` : line, loaded, missing };
832
- }
833
-
834
- /** The headless `--output-format json` result object for a turn. */
835
- export function jsonResult(res: RunResult, session: SessionData) {
836
- const lastUser = res.messages.map((m) => m.role).lastIndexOf('user');
837
- return {
838
- ok: res.finishReason === 'stop',
839
- finishReason: res.finishReason,
840
- text: res.text,
841
- steps: res.steps,
842
- tools: res.messages.slice(lastUser).filter((m) => m.role === 'tool').length,
843
- usage: res.usage,
844
- sessionId: session.meta.id,
845
- ...(res.finishReason === 'error' && res.error ? { error: (res.error as any)?.message ?? String(res.error) } : {}),
846
- };
847
- }
848
-
849
- /**
850
- * Read one logical input, supporting `\`-continuation for multi-line prompts: a line ending in
851
- * a backslash drops the `\` and keeps reading; the parts are joined with newlines. `readLine`
852
- * is injected (the REPL passes a readline `question`; tests pass a scripted reader).
853
- */
854
- export async function readMultiline(readLine: (continuing: boolean) => Promise<string | null>): Promise<string | null> {
855
- const parts: string[] = [];
856
- for (;;) {
857
- const part = await readLine(parts.length > 0);
858
- if (part == null) return null; // EOF (Ctrl-D) → exit the REPL
859
- if (part.endsWith('\\')) { parts.push(part.slice(0, -1)); continue; } // trailing \ → keep going
860
- parts.push(part);
861
- return parts.join('\n');
862
- }
863
- }
864
-
865
- /** Send one turn (expanding @file mentions), print the footer, persist the session.
866
- * `sendFn` overrides how the turn is dispatched (duplex routes through dx.send's mutex); `agent`
867
- * stays the transcript-owning agent (the voice, in duplex) — mentions/fs/signal/cost read off it. */
868
- async function runTurn(agent: Agent, store: SessionStore, session: SessionData, task: string, cp?: Checkpoints, cwd = process.cwd(), sendFn?: (content: MessageContent) => Promise<RunResult>): Promise<{ ok: boolean; res?: RunResult }> {
869
- const t0 = Date.now();
870
- await cp?.begin(task); // open a checkpoint frame so this turn's edits can be /rewound
871
- const { text, loaded, missing } = await expandMentions(agent.options.fs!, task);
872
- if (loaded.length) err(dim(` + inlined ${loaded.map((f) => '@' + f).join(' ')}\n`));
873
- if (missing.length) err(yellow(` ⚠ not found: ${missing.map((f) => '@' + f).join(' ')}\n`));
874
- // @image.png mentions → attach as multimodal content parts (needs a vision model).
875
- const images = readImageParts(cwd, task);
876
- if (images.length) err(dim(` + attached ${images.length} image(s)\n`));
877
- // Don't waste a model call when the input was ONLY unresolved @-mentions (e.g. "@asd").
878
- if (missing.length && !loaded.length && !task.replace(/(?:^|\s)@\S+/g, '').trim()) {
879
- err(dim(' (nothing to send — that file doesn\'t exist)\n'));
880
- return { ok: false };
881
- }
882
- // per-turn aborter: Esc / Ctrl-C / SIGINT flips agent.options.signal so the loop stops between steps.
883
- const ctrl = new AbortController();
884
- activeTurn = ctrl;
885
- agent.options.signal = ctrl.signal;
886
- // Raw mode stays on for the whole REPL session (the line editor owns it), so the Esc/Ctrl-C
887
- // keypress is delivered live during the turn — no per-turn stdin juggling needed here.
888
- const content = images.length ? [{ type: 'text' as const, text }, ...images] : text;
889
- let res: RunResult;
890
- spinner.start(sendFn ? 'voice…' : undefined);
891
- try {
892
- res = await (sendFn ? sendFn(content) : agent.send(content));
893
- } catch (e: any) {
894
- spinner.stop();
895
- err(red(` error: ${e?.message ?? e}\n`));
896
- // record the hard-throw turn too (this path used to persist nothing → no forensic trace)
897
- (session.meta.events ??= []).push({ ts: t0, durationMs: Date.now() - t0, model: agent.options.model, finishReason: 'error', steps: 0, tools: 0, error: errInfo(e) });
898
- session.meta.updated = Date.now();
899
- try { store.save(session); } catch { /* non-fatal */ }
900
- return { ok: false };
901
- } finally {
902
- spinner.stop();
903
- activeTurn = null;
904
- agent.options.signal = undefined;
905
- }
906
- (agent.options.host as { flushText?: () => void } | undefined)?.flushText?.(); // emit any buffered markdown tail
907
- const secs = ((Date.now() - t0) / 1000).toFixed(1);
908
- const cost = turnCost(agent.options.model, res.usage);
909
- const m = res.usageEstimated ? '~' : ''; // exact usage (provider-reported) drops the tilde
910
- const tok = res.usage?.totalTokens ? `${m}${(res.usage.totalTokens / 1000).toFixed(1)}k tok · ${cost > 0 ? m + fmtUsd(cost) + ' · ' : ''}` : '';
911
- const lastUser = res.messages.map((m) => m.role).lastIndexOf('user');
912
- const tools = res.messages.slice(lastUser).filter((m) => m.role === 'tool').length;
913
- const ok = res.finishReason === 'stop';
914
- const shortId = session.meta.id.slice(-10); // HHMMSS-mmm — quotable tag to reference this session later
915
- // clear the (multi-line) ticking footer first — otherwise its tail glues onto this line ("· 3.5s · e; keep chatting")
916
- // newline FIRST, then clear-below: the cursor may sit at the end of a streamed text line (voice
917
- // mode) — clearing from it would erase the reply's last line; clearing from a fresh line only
918
- // removes footer residue below.
919
- err('\n' + (process.stderr.isTTY ? '\r\x1b[0J' : '') + (ok ? green(' ✓ done') : red(` ✗ ${res.finishReason}`)) + dim(` · ${res.steps} steps · ${tools} tools · ${tok}${secs}s · ${shortId}\n`));
920
- // on a failed turn, show the provider's message so a billing/quota 400 isn't mistaken for a bad request
921
- if (res.finishReason === 'error' && res.error) {
922
- const e = res.error as any;
923
- err(red(` ${e?.message ?? e}`) + (e?.statusCode ? dim(` (${e.statusCode}${e.code ? ' ' + e.code : ''})`) : '') + '\n');
924
- }
925
- // persist (non-fatal on failure)
926
- session.messages = agent.transcript;
927
- session.meta.turns += 1;
928
- session.meta.tokens = (session.meta.tokens ?? 0) + (res.usage?.totalTokens ?? 0); // cumulative for /cost
929
- session.meta.costUsd = (session.meta.costUsd ?? 0) + cost; // cumulative USD (per-turn model rate)
930
- if (res.usageEstimated) session.meta.costEstimated = true; // sticky: any estimated turn → session total is estimated
931
- session.meta.updated = Date.now();
932
- session.meta.model = agent.options.model;
933
- const ev: TurnEvent = { ts: t0, durationMs: Date.now() - t0, model: agent.options.model, finishReason: res.finishReason, steps: res.steps, tools, tokens: res.usage?.totalTokens, costUsd: cost, estimated: res.usageEstimated };
934
- if (res.finishReason === 'error' && res.error) ev.error = errInfo(res.error);
935
- (session.meta.events ??= []).push(ev);
936
- if (!session.meta.title) session.meta.title = titleOf(agent.transcript);
937
- try { store.save(session); } catch (e: any) { err(dim(` (session not saved: ${e?.message ?? e})\n`)); }
938
- return { ok, res };
939
- }
940
-
941
- /** Resolve the starting session: resume an existing one (seeding the transcript) or start fresh. */
942
- function startSession(args: Args, store: SessionStore, agent: Agent, cwd: string): SessionData {
943
- if (args.resume || args.cont) {
944
- const data = args.resume ? store.load(args.resume) : store.latestData();
945
- if (data) {
946
- agent.transcript = data.messages;
947
- if (args.fork) { // --fork-session: branch into a NEW id so the source session is left untouched
948
- const now = Date.now();
949
- const forked: SessionData = { meta: { ...data.meta, id: args.sessionId ?? store.newId(now), created: now, updated: now, turns: data.meta.turns }, messages: data.messages };
950
- err(dim(` forked ${data.meta.id} → ${forked.meta.id} (${data.meta.turns} turns)\n`));
951
- if (!args.task) printHistory(data.messages);
952
- return forked;
953
- }
954
- err(dim(` resumed session ${data.meta.id} (${data.meta.turns} turns) — ${data.meta.title}\n`));
955
- if (!args.task) printHistory(data.messages); // replay the convo for an interactive resume (skip for headless -c "task")
956
- return data;
957
- }
958
- err(yellow(` no session to resume — starting fresh\n`));
959
- }
960
- const now = Date.now();
961
- const id = args.sessionId ?? store.newId(now);
962
- if (!args.task) err(dim(` session ${id}\n`)); // surface the id up front so it's quotable when reporting an issue
963
- return { meta: { id, created: now, updated: now, cwd, model: agent.options.model, turns: 0, title: '' }, messages: [] };
964
- }
965
-
966
- const AGENTS_MD_TEMPLATE = `# ${'${name}'}
967
-
968
- ## Overview
969
- <!-- What this project is, in 1-2 sentences. -->
970
-
971
- ## Conventions
972
- - <!-- e.g. package manager, code style, module layout -->
973
-
974
- ## Commands
975
- - build:
976
- - test:
977
- - run:
978
-
979
- ## Notes
980
- <!-- Anything the agent should always keep in mind. -->
981
- `;
982
-
983
- /** Scaffold ./AGENTS.md if no project-instruction file exists yet. */
984
- function initInstructions(cwd: string): void {
985
- for (const f of ['AGENTS.md', 'CLAUDE.md']) {
986
- if (existsSync(join(cwd, f))) { err(yellow(` ${f} already exists — leaving it as-is\n`)); return; }
987
- }
988
- const path = join(cwd, 'AGENTS.md');
989
- writeFileSync(path, AGENTS_MD_TEMPLATE.replace('${name}', basename(cwd)));
990
- err(green(` created ${path}\n`) + dim(' edit it, then it auto-loads into every run.\n'));
991
- }
992
-
993
- /** Persist a setting to ./.agent/settings.json so it survives restarts (loadConfig reads it back). */
994
- function persistSetting(cwd: string, key: string, value: unknown): void {
995
- const path = join(cwd, '.agent', 'settings.json');
996
- try {
997
- const obj = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
998
- if (obj[key] === value) return;
999
- obj[key] = value;
1000
- mkdirSync(dirname(path), { recursive: true });
1001
- writeFileSync(path, JSON.stringify(obj, null, 2) + '\n');
1002
- } catch (e: any) {
1003
- err(yellow(` ⚠ couldn't persist ${key} to ${path} — ${e?.message ?? e}\n`));
1004
- }
1005
- }
1006
- const persistModel = (cwd: string, model: string) => persistSetting(cwd, 'model', model);
1007
-
1008
- // The @cursor/sdk provider streams over ConnectRPC/HTTP-2 (connect-node). Aborting a run surfaces as a
1009
- // *background* rejection that's never thrown into our for-await, so it can't be caught at the call site —
1010
- // either a clean cancel (ConnectError [canceled] "operation was aborted", cause DOMException ABORT_ERR)
1011
- // or, on a racing teardown, NGHTTP2_FRAME_SIZE_ERROR. Both are benign: they only happen because WE
1012
- // cancelled. Swallow exactly those; surface anything else.
1013
- const isCancelTeardown = (e: any): boolean => {
1014
- if (!e) return false;
1015
- if (e.code === 20 || e.cause?.code === 20) return true; // DOMException ABORT_ERR
1016
- const blob = `${e.code ?? ''} ${e.name ?? ''} ${e.cause?.name ?? ''} ${e.rawMessage ?? ''} ${e.message ?? ''}`;
1017
- return /NGHTTP2_FRAME_SIZE_ERROR|ERR_HTTP2_STREAM_ERROR|operation was aborted|\bAbortError\b/i.test(blob);
1018
- };
1019
- /** Process-level guards shared by BOTH REPLs (normal + duplex — a duplex CancelTask on a cursor worker
1020
- * fires the same background rejection): a rejection keeps the session alive, only a true uncaught
1021
- * exception is fatal, and the benign cursor cancel-teardown is dropped to a debug line. */
1022
- function installCancelGuards(mounted: MountedMcp[]): void {
1023
- process.on('unhandledRejection', (e) => {
1024
- if (isCancelTeardown(e)) { log.debug('suppressed unhandledRejection (cursor stream cancel)', e); return; }
1025
- log.error('unhandledRejection', e);
1026
- });
1027
- process.on('uncaughtException', (e) => {
1028
- if (isCancelTeardown(e)) { log.debug('suppressed uncaughtException (cursor stream cancel)', e); return; }
1029
- console.error(e); void closeMcp(mounted); process.exit(1);
1030
- });
1031
- }
1032
-
1033
- async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: string) {
1034
- const oauth = new McpOAuth({ storePath: join(cwd, '.agent', 'mcp-auth.json') });
1035
- const mounted = await mountMcp(cfg, oauth);
1036
- // In duplex mode this agent is the WORKER TEMPLATE — it never sends; its options (fs mode,
1037
- // permissions, hooks, MCP tools) become the per-task worker options below.
1038
- const agent = await makeAgent(args, ai, cfg, mounted.flatMap((m) => m.tools));
1039
-
1040
- // Non-duplex voice: let the model exit the session when the user says goodbye.
1041
- // Duplex voice gets ExitSession via reflexOptions.tools (the agent here is the worker template — workers don't need it).
1042
- if (args.voice && !args.duplex) agent.options.tools = [...(agent.options.tools ?? []), exitSessionTool(() => { exitRequested = true; })];
1043
-
1044
- // ── Duplex mode (`--duplex`): the REPL runs unchanged, but turns go through a fast VOICE agent
1045
- // that answers instantly and delegates real work to background workers (re-voiced when done).
1046
- // `face` = the transcript-owning agent the REPL drives (sessions, footer, Esc-abort, /compact);
1047
- // `work` = the options that mean "the working agent" (/model, /reasoning, /tools, permissions —
1048
- // in duplex these are the WORKERS' options; workers are constructed fresh per Act/Think).
1049
- const duplex = args.duplex;
1050
- let dx: DuplexAgent | undefined;
1051
- let voiceIO: VoiceIO | undefined; // real voice I/O (--voice + keys): mic→STT in, text_delta→TTS out
1052
- let toggleVoice: (() => Promise<void>) | undefined; // bound below (duplex + TTY): /voice flips mic on/off live
1053
- let editorRef: LineEditor | undefined; // bound once the line editor exists — async chrome repaints the prompt via it
1054
- // During a turn the user's type-ahead lives on a "stash ›" line (no active editor to own it). Async
1055
- // chrome (streamed deltas, task events) lands on top of it — repaint the stash below, so it survives.
1056
- let repaintStash: () => void = () => {};
1057
- let workerOptions: AgentOptions | undefined;
1058
- // Worker UI verbosity: 'full' = ⚙ tool chrome per worker step; 'minimal' = task events only
1059
- // (started/progress/⦿ done). Voice defaults minimal (chrome is noise next to speech); /workers toggles live.
1060
- let workerChrome: 'full' | 'minimal' = 'full';
1061
- let duplexPersist: () => void = () => {}; // bound once the session exists (re-voice fires async)
1062
- let duplexAccount: (data: any) => void = () => {}; // worker cost → session meta (bound below)
1063
- // Workers are non-interactive: a permission 'ask' can't pop a menu mid-conversation (it would fight
1064
- // the line editor for raw stdin). VOICE mode relays the ask through the conversation (park →
1065
- // '[task asks]' re-voice → spoken yes/no via AnswerTask; timeout → deny). Text duplex (and the
1066
- // relay's timeout path) auto-denies with a narratable reason — the worker adapts or reports back.
1067
- let permSeq = 0;
1068
- const duplexAsk = async (call: ToolUse): Promise<{ decision: 'allow' | 'deny' }> => {
1069
- if (args.voice && dx) {
1070
- const hint = summarizeCall(call.name, call.args).slice(0, 80);
1071
- // Default: approve like a normal session — suspend the editor, pop an interactive picker
1072
- // (Allow once / always / Deny). Set `voiceAskUi: 'relay'` to opt into the spoken park/relay flow.
1073
- if ((cfg as any).voiceAskUi !== 'relay') {
1074
- editorRef?.suspend();
1075
- const v = await selectMenu(process.stderr, {
1076
- title: `? background worker asks to run ${call.name} ${hint}`,
1077
- items: [{ label: 'Allow once', value: 'y' }, { label: 'Allow always', value: 'a' }, { label: 'Deny', value: 'n' }],
1078
- current: 'y',
1079
- });
1080
- editorRef?.resume();
1081
- editorRef?.redrawNow();
1082
- if (v === 'a') {
1083
- // Remember a command-scoped allow: a live session rule (wins next ask; glob has no `*`
1084
- // → exact-command match) + persist to .agent/permissions.json for future sessions.
1085
- const cmd = typeof call.args?.command === 'string' ? call.args.command : null;
1086
- work.permissions?.options.rules.unshift(cmd ? { tool: call.name, pathGlob: cmd, decision: 'allow' } : { tool: call.name, decision: 'allow' });
1087
- persistRule(cwd, 'allow', cmd ? `${call.name}(${cmd})` : call.name);
1088
- }
1089
- return { decision: v === 'y' || v === 'a' ? 'allow' : 'deny' };
1090
- }
1091
- // NB: perm asks are keyed perm-N (PermissionPolicy.ask carries no task identity), so a
1092
- // cancelled task can't clean its parked perm question — bounded by askTimeoutMs → deny.
1093
- // (Chrome prints once via the task_ask notify below — no extra line here.)
1094
- const id = `perm-${++permSeq}`;
1095
- const a = await dx.parkQuestion(id, `Permission: may the background worker run ${call.name}${hint ? ` (${hint})` : ''}? Answer yes or no (you can also type it).`);
1096
- const allow = /^\s*(y(es|ep|eah)?|sure|ok(ay)?|allow|go|approved?|do it)\b/i.test(a);
1097
- err('\r\x1b[0J' + (allow ? green(` ✓ allowed ${call.name}`) : yellow(` ⊘ denied ${call.name}`)) + dim(` (${a.trim() || 'no answer'})\n`));
1098
- editorRef?.redrawNow();
1099
- return { decision: allow ? 'allow' : 'deny' };
1100
- }
1101
- err('\r\x1b[0J' + yellow(` ⊘ worker asked to run ${call.name} — auto-denied (no interactive approval in duplex; use --yes or --allowedTools)\n`));
1102
- editorRef?.redrawNow(); // background event at a live prompt — repaint below the notice
1103
- return { decision: 'deny' };
1104
- };
1105
- if (duplex) {
1106
- // Workers must not stream into the voice channel — strip the host/stream seam (display hooks stay:
1107
- // worker tool activity prints as dim stderr chrome). `signal` is per-task (duplex.ts owns it).
1108
- // Drop providerOptions too: they're computed for the MAIN agent's model (e.g. cursor's cwd/cursorSession)
1109
- // and must never leak onto a worker whose model differs — DuplexAgent recomputes them per worker model
1110
- // via providerOptionsFor below. (A cursor cursorSession leaking onto an anthropic worker is a hard 400.)
1111
- const { host: _host, stream: _stream, signal: _signal, providerOptions: _po, ...wo } = agent.options;
1112
- workerOptions = wo as AgentOptions;
1113
- if (workerOptions.permissions)
1114
- workerOptions.permissions = new PermissionPolicy({ ...workerOptions.permissions.options, host: undefined, ask: duplexAsk });
1115
- workerOptions.planMode = false; // a worker's plan could never be approved (no host) — it would stall to maxSteps
1116
- // Workers are BACKGROUND agents: rebuild their display hooks so chrome never drives the foreground
1117
- // spinner (the 'agentx › '/'⠹ thinking…' flicker) and repaints the live prompt after each print.
1118
- workerChrome = args.voice ? 'minimal' : (cfg.workerChrome ?? 'full');
1119
- const workerDisplay = displayHooks(agent.options.fs, { gate: () => workerChrome === 'full', background: () => editorRef?.redrawNow() });
1120
- workerOptions.hooks = cfg.hooks ? composeHooks(workerDisplay, hooksFromConfig(cfg.hooks)) : workerDisplay;
1121
- // The single voice: markdown-rendered deltas on stdout = the SPOKEN channel. Everything else
1122
- // (⚙ tool chrome, task events, worker results) is VISUAL chrome on stderr — when wired to a
1123
- // real voice API, only text_delta becomes speech; the rest feeds the screen.
1124
- const base = makeHost('text', { stream: true });
1125
- const host: typeof base = {
1126
- ...base,
1127
- notify(e: any) {
1128
- // SPOKEN channel tap (mind/10-duplex.md): only the voice's text_delta becomes speech.
1129
- // Streaming text to stdout while the line editor is LIVE corrupts the terminal (the editor's
1130
- // climb-and-clear redraws fire mid-paragraph and erase it) — so the editor is SUSPENDED for
1131
- // the duration of a streaming voice turn and resumed (fresh repaint) at turn end.
1132
- if (e.kind === 'text_delta' && voiceIO) {
1133
- voiceIO.speakDelta(e.message);
1134
- editorRef?.suspend(); // no-op when already suspended
1135
- } else if (e.kind === 'text_delta' && stashBuf) {
1136
- // TEXT mode with type-ahead pending: lift the sacred stash line, let the markdown stream
1137
- // land, then repaint the stash below it (else the streamed ack overwrites what's being typed).
1138
- process.stdout.write('\r\x1b[K');
1139
- base.notify!(e);
1140
- repaintStash();
1141
- return;
1142
- }
1143
- if (e.kind === 'hold_filler' && voiceIO) {
1144
- voiceIO.speakFiller(e.message);
1145
- return;
1146
- }
1147
- if (e.kind === 'revoice_done') { // a re-voice turn ended outside runTurn's flush — drain the markdown tail now
1148
- base.flushText();
1149
- process.stdout.write('\n');
1150
- voiceIO?.endSpeech(); // a re-voice turn is spoken too — close its TTS context (idempotent)
1151
- duplexPersist(); // a re-voice turn changed the transcript with no send() in flight — save it too
1152
- editorRef?.resume(); // repaint the prompt block below the streamed text
1153
- editorRef?.redrawNow();
1154
- return;
1155
- }
1156
- if (e.kind === 'task_done' && e.data?.text) { // show the worker's full result visually (the voice only summarizes it)
1157
- const lines = String(e.data.text).split('\n');
1158
- const shown = lines.slice(0, previewLines());
1159
- // This lands ASYNC, often on top of the live prompt/footer — clear the current line first
1160
- // (else footer residue glues onto the ⦿ header) and repaint the prompt block after.
1161
- // \x1b[0J (clear to end of screen): the ticking footer is MULTI-line — clearing one line
1162
- // leaves "…working in background…" residue glued to this header; redrawNow repaints below.
1163
- err('\r\x1b[0J\n' + dim(` ⦿ ${e.message}\n`) + shown.map((l) => dim(` ${l}\n`)).join(''));
1164
- if (lines.length > shown.length) err(dim(` … (+${lines.length - shown.length} more lines)\n`));
1165
- duplexAccount(e.data); // worker tokens/cost are real spend — fold into /cost
1166
- editorRef?.redrawNow();
1167
- repaintStash(); // type-ahead survives the async ⦿ landing
1168
- return;
1169
- }
1170
- // Remaining task_* events (started/progress/cancelled/error) land ASYNC at a live prompt —
1171
- // same treatment as task_done: clear the prompt line, print, repaint (bare err() leaves
1172
- // residue glued to 'agentx › '). task_done returned above; everything else falls through.
1173
- if (typeof e.kind === 'string' && e.kind.startsWith('task_')) {
1174
- spinner.stop();
1175
- // asks are decisions, not chatter — make them stand out (the voice also speaks them)
1176
- err('\r\x1b[0J' + (e.kind === 'task_ask' ? yellow(` ? ${e.message} — answer by voice or type yes/no\n`) : dim(` · ${e.message}\n`)));
1177
- editorRef?.redrawNow();
1178
- repaintStash(); // type-ahead survives the async task event landing
1179
- return;
1180
- }
1181
- base.notify!(e); // makeHost always provides notify (the HostBridge type just marks it optional)
1182
- },
1183
- };
1184
- // Conversational undo: the voice can roll back per-task checkpoint frames ("undo that").
1185
- const rewindFilesTool: AgentTool = {
1186
- name: 'RewindFiles',
1187
- description: 'Undo file changes made by Act/Think tasks: roll back the last N task checkpoints (default 1). Use when the user asks to undo/revert what a task changed.',
1188
- parameters: { type: 'object', properties: { steps: { type: 'number', description: 'how many task checkpoints to undo (default 1)' } } },
1189
- run: async ({ steps }) => {
1190
- if (!checkpoints.size) return 'No file checkpoints to rewind yet.';
1191
- if ([...(dx?.tasks.values() ?? [])].some((t) => t.status === 'running'))
1192
- return 'A task is still running — cancel it first (CancelTask), then rewind.';
1193
- const n = Math.min(Math.max(1, Number(steps ?? 1)), checkpoints.size);
1194
- const r = await checkpoints.rewindTo(checkpoints.size - n);
1195
- return `Rewound ${n} task checkpoint(s): restored ${r.restored} file(s), deleted ${r.deleted}.`;
1196
- },
1197
- };
1198
- dx = new DuplexAgent({
1199
- ai,
1200
- fs: agent.options.fs,
1201
- memoryDir: agent.options.memoryDir,
1202
- memoryUserDir: agent.options.memoryUserDir,
1203
- ...((args.voiceModel ?? cfg.reflexModel) ? { reflexModel: resolveModelOrNewest((args.voiceModel ?? cfg.reflexModel)!) } : {}),
1204
- actModel: agent.options.model,
1205
- actOptions: workerOptions,
1206
- providerOptionsFor: (m) => cursorProviderOptions(m, cwd, cfg.mcpServers),
1207
- ...((args.thinkModel ?? cfg.thinkModel) !== undefined ? { thinkModel: (args.thinkModel ?? cfg.thinkModel) === false ? false : resolveModelOrNewest(String(args.thinkModel ?? cfg.thinkModel)) } : {}),
1208
- host,
1209
- ...(args.voice ? { voiceStyle: 'conversational' as const, progressUpdates: true, askRelay: true } : {}), // voice: progress asides + worker questions relayed through the conversation
1210
- // Per-TASK checkpoint frames (the natural undo unit in duplex = one delegation): opened BEFORE
1211
- // the worker spawns (post-spawn would race its first edits). `checkpoints` is bound below.
1212
- onTaskStart: async (_id, label) => { await checkpoints.begin(label); },
1213
- // The jail deny-lists .git/** (VCS internals can carry credentials), so the engine's fs-based
1214
- // 'branch' lookup can't see it — supply it host-side (one safe read-only file).
1215
- quickLook: {
1216
- branch: () => {
1217
- try {
1218
- const head = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim();
1219
- return head.startsWith('ref: refs/heads/') ? `branch: ${head.slice('ref: refs/heads/'.length)}` : `detached HEAD at ${head.slice(0, 12)}`;
1220
- } catch { return 'not a git repository'; }
1221
- },
1222
- memory: async () => {
1223
- const _adot = (s: string) => `${(agent.options.fs!.getCwd() === '/' ? '' : agent.options.fs!.getCwd())}/.agent/${s}`;
1224
- const dirs = Array.isArray(agent.options.memoryDir) ? agent.options.memoryDir : [agent.options.memoryDir || _adot('memory')];
1225
- const parts: string[] = [];
1226
- for (const d of dirs) {
1227
- try { const idx = await fs.readFile(`${d}/MEMORY.md`); if (idx.trim()) parts.push(idx.trim()); } catch { /* dir doesn't exist yet */ }
1228
- }
1229
- return parts.length ? parts.join('\n').slice(0, 2000) : '(no memory yet)';
1230
- },
1231
- },
1232
- // The voice runs on the REAL fs (it has no fs tools — harmless) so @mentions, !cmd and #note
1233
- // resolve against the project; + CC-parity chrome for its own tool calls (⚙ Act …).
1234
- reflexOptions: { fs: agent.options.fs, hooks: displayHooks(agent.options.fs), tools: [rewindFilesTool, exitSessionTool(() => { exitRequested = true; })] },
1235
- });
1236
- }
1237
- const face: Agent = dx ? dx.voice : agent; // the transcript-owning agent the REPL drives
1238
- const work: AgentOptions = workerOptions ?? agent.options; // "the working agent" options
1239
- const sendVia = dx ? (c: MessageContent) => dx!.send(c) : undefined; // runTurn dispatch override
1240
-
1241
- // ── Permission posture (Shift+Tab cycles it live; CC parity). default=ask · acceptEdits=ask only shell · plan ──
1242
- // Re-read persisted rules each rebuild so "Always allow/deny" choices made earlier THIS session survive a cycle.
1243
- const baseRules = () => [
1244
- ...parsePermRules({ deny: args.disallowedTools }),
1245
- ...parsePermRules({ allow: args.allowedTools }),
1246
- ...parsePermRules(mergePerms(loadPersistedRules(cwd), cfg.permissions)),
1247
- ];
1248
- const askFor = (tools: string[]) => tools.map((t) => ({ tool: t, decision: 'ask' as const }));
1249
- // No 'plan' in duplex — workers are non-interactive, an ExitPlanMode could never be approved.
1250
- const POSTURES: readonly Posture[] = duplex ? ['default', 'acceptEdits'] : ['default', 'acceptEdits', 'plan'];
1251
- type Posture = 'default' | 'acceptEdits' | 'plan';
1252
- let posture: Posture = (!duplex && (args.plan || cfg.permissionMode === 'plan')) ? 'plan' : cfg.permissionMode === 'acceptEdits' ? 'acceptEdits' : 'default';
1253
- const postureLabel = () => posture === 'default' ? 'ask (default)' : posture === 'acceptEdits' ? 'accept edits' : 'plan mode';
1254
- const applyPosture = (p: Posture) => {
1255
- posture = p;
1256
- const ask = p === 'acceptEdits' ? askFor(['bash', 'Shell']) : askFor(ASK_MUTATING);
1257
- // In duplex this rebuilds the WORKERS' policy (work = workerOptions; workers spawn fresh per task)
1258
- // with the auto-deny ask; in normal mode it's the live agent's policy with the interactive menu.
1259
- work.permissions = new PermissionPolicy({ rules: [...baseRules(), ...ask], default: 'allow', host: duplex ? undefined : makeHost(), ask: duplex ? duplexAsk : makeAskResolver(cwd) });
1260
- if (!duplex) {
1261
- agent.options.planMode = p === 'plan';
1262
- agent.reprepare(); // rebuild prompt/tools/plan-mode/permission hooks on the next send
1263
- }
1264
- };
1265
- const cyclePosture = (): string => { applyPosture(POSTURES[(POSTURES.indexOf(posture) + 1) % POSTURES.length]); err(dim(` ⇥ ${postureLabel()}\n`)); return postureLabel(); };
1266
- if (!args.yes) applyPosture(posture); // --yes keeps makeAgent's allow-all (don't impose asks); Shift+Tab still opts in
1267
-
1268
- // ── Quick reasoning toggle (Alt+T): off → low → medium → high → off ── (duplex: the workers' reasoning)
1269
- const REASONING_CYCLE = ['off', 'low', 'medium', 'high'] as const;
1270
- const toggleReasoning = (): string => {
1271
- const cur = String(work.reasoning ?? 'off');
1272
- const next = REASONING_CYCLE[(Math.max(0, REASONING_CYCLE.indexOf(cur as any)) + 1) % REASONING_CYCLE.length];
1273
- work.reasoning = next;
1274
- err(dim(` ~ reasoning → ${next}\n`));
1275
- return next;
1276
- };
1277
- // Model switching targets the WORKER in duplex (the voice model is a --voice-model startup choice).
1278
- const setModel = (m: string) => { work.model = m; if (dx) dx.options.actModel = m; persistModel(cwd, m); err(dim(' model → ' + m + '\n')); };
1279
- // Tool mutations (/mcp add|remove|login) — duplex workers are constructed per spawn from work.tools.
1280
- const addWorkTools = (ts: AgentTool[]) => { if (duplex) work.tools = [...(work.tools ?? []), ...ts]; else agent.addTools(ts); };
1281
- const removeWorkTools = (names: string[]) => { if (duplex) work.tools = (work.tools ?? []).filter((t) => !names.includes(t.name)); else agent.removeTools(names); };
1282
-
1283
- const pendingImages: string[] = []; // clipboard images grabbed via /paste, attached to the next message
1284
- // Grab an image off the OS clipboard → temp file → an `@<abs>` attachment ref. Shared by /paste and Cmd-V.
1285
- const grabClipboardAttachment = (): { display: string; ref: string; path: string } | null => {
1286
- const dir = join(tmpdir(), 'agentx-pasted');
1287
- try { mkdirSync(dir, { recursive: true }); } catch { /* best-effort */ }
1288
- const img = grabClipboardImage(dir, String(Date.now()));
1289
- return img ? { display: 'Image', ref: '@' + img.path, path: img.path } : null;
1290
- };
1291
- // Hard teardown — used by the SIGINT path and the keypress force-quit escalation. Never let the
1292
- // user get trapped: even if a turn's abort() fails to unwind (wedged stream/tool), this exits.
1293
- const forceQuit = (code = 130) => {
1294
- try { voiceIO?.stop(); } catch { /* mic/player may already be down */ }
1295
- try { disposeCursorSessions(); } catch { /* reap warm cursor helpers, best-effort */ }
1296
- void closeMcp(mounted);
1297
- process.exit(code);
1298
- };
1299
- // Ctrl-C: cancel the in-flight turn if one's running; a second Ctrl-C while already cancelling
1300
- // (i.e. the abort didn't unwind) force-quits. No active turn → clean up MCP children and exit.
1301
- process.on('SIGINT', () => {
1302
- if (activeTurn) {
1303
- if (aborting) { err(red('\n ⏻ force-quit\n')); forceQuit(); return; } // already cancelling → hard escape
1304
- activeTurn.abort(); voiceIO?.interrupt(); return;
1305
- }
1306
- forceQuit();
1307
- });
1308
- installCancelGuards(mounted);
1309
- const store = new SessionStore(cwd);
1310
- let session = startSession(args, store, face, cwd);
1311
-
1312
- // File checkpoint/rewind — per TURN normally, per TASK in duplex (frames open via onTaskStart).
1313
- // Disk mode → durable git-backed (whole tree incl. bash edits, survives sessions, CC parity).
1314
- // Virtual modes (sandbox / boddb) → in-VFS stack over the same backing filesystem.
1315
- const checkpoints: Checkpoints = (args.vfs || args.boddb)
1316
- ? new CheckpointStack(agent.options.fs!)
1317
- : new GitCheckpoints({ workTree: cwd, gitDir: join(cwd, '.agent', 'checkpoints.git'), addDirs: args.addDirs, sessionId: session.meta.id });
1318
- const cpHooks = checkpoints.hooks?.();
1319
- if (cpHooks) work.hooks = composeHooks(work.hooks, cpHooks); // duplex: the workers make the edits — their hooks feed the frames
1320
- duplexPersist = () => {
1321
- session.messages = face.transcript;
1322
- session.meta.updated = Date.now();
1323
- if (!session.meta.title) session.meta.title = titleOf(face.transcript);
1324
- try { store.save(session); } catch (e: any) { err(dim(` (session not saved: ${e?.message ?? e})\n`)); }
1325
- };
1326
- duplexAccount = (data) => { // worker runs never pass through runTurn — account their usage here
1327
- if (!data?.usage?.totalTokens) return;
1328
- session.meta.tokens = (session.meta.tokens ?? 0) + data.usage.totalTokens;
1329
- session.meta.costUsd = (session.meta.costUsd ?? 0) + turnCost(work.model, data.usage);
1330
- if (data.usageEstimated) session.meta.costEstimated = true;
1331
- try { store.save(session); } catch { /* non-fatal */ }
1332
- };
1333
-
1334
- // custom slash commands (same registry the model's SlashCommand tool uses) + skills.
1335
- // Anchored at the FS working dir — in CC-parity disk mode root '/' is the real machine, so
1336
- // .agent lives under the launch dir, not '/' (which would be the real filesystem root).
1337
- const fs = agent.options.fs!; // the CLI always builds the agent with a concrete fs (buildAgent)
1338
- const fsBase = fs.getCwd() === '/' ? '' : fs.getCwd();
1339
- const adot = (sub: string) => `${fsBase}/.agent/${sub}`;
1340
- // Resolution order (first wins on name collision) mirrors buildAgent's `dots()`: project .agent →
1341
- // project .claude → user ~/.agent → user ~/.claude. Including the home dirs is what makes a GLOBAL
1342
- // skill/command (e.g. ~/.claude/skills/browser) tab-complete as /browser and show in /skills.
1343
- const adots = (sub: string) => [adot(sub), `${fsBase}/.claude/${sub}`, `${homedir()}/.agent/${sub}`, `${homedir()}/.claude/${sub}`];
1344
- const cmds: CommandInfo[] = (await loadCommands(fs, adots('commands'))).commands;
1345
- const skills: SkillInfo[] = (await loadSkills(fs, adots('skills'))).skills;
1346
-
1347
- // persisted input history (best-effort)
1348
- const histPath = join(cwd, '.agent', 'history');
1349
- const history = existsSync(histPath)
1350
- ? readFileSync(histPath, 'utf8').split('\n').filter(Boolean).reverse().slice(0, 500)
1351
- : [];
1352
- const remember = (line: string) => {
1353
- try { mkdirSync(join(cwd, '.agent'), { recursive: true }); appendFileSync(histPath, line + '\n'); }
1354
- catch (e) { log.debug('history write failed', e); } // best-effort; never blocks the prompt
1355
- };
1356
-
1357
- // ---- interactive helpers (reuse the selectMenu picker) ----
1358
- const ago = (t: number) => {
1359
- const s = Math.max(0, (Date.now() - t) / 1000);
1360
- return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)}m ago` : s < 86400 ? `${Math.floor(s / 3600)}h ago` : `${Math.floor(s / 86400)}d ago`;
1361
- };
1362
- const resumeInto = (data: SessionData) => {
1363
- face.transcript = data.messages; session = data; checkpoints.use?.(data.meta.id);
1364
- const m = data.meta as any;
1365
- goalCondition = m.goalCondition; goalTurns = m.goalTurns ?? 0; goalTokens = m.goalTokens ?? 0; goalLastReason = m.goalLastReason;
1366
- err(dim(` resumed ${data.meta.id} (${data.meta.turns} turns)${data.meta.title ? ' — ' + data.meta.title : ''}\n`));
1367
- if (goalCondition) err(dim(` ◎ goal active: ${goalCondition} (${goalTurns} turns)\n`));
1368
- printHistory(data.messages);
1369
- };
1370
- // Double-Esc rewind (CC parity): pick an earlier user message, then choose what to restore —
1371
- // conversation, code, or both. Returns the message text to pre-fill the prompt (when the conversation
1372
- // is rewound) for editing + resend, or undefined (cancelled, or code-only).
1373
- const rewindToMessage = async (): Promise<string | undefined> => {
1374
- const users = face.transcript.map((m, i) => ({ m, i })).filter((x) => x.m.role === 'user');
1375
- if (!users.length) { err(dim(' (no earlier messages to jump back to)\n')); return undefined; }
1376
- await checkpoints.refresh?.(); // sync size/list with the durable backend before mapping turns → frames
1377
- // Newest-first (CC parity + consistent with /rewind). `value` = position among user turns.
1378
- const items: SelectItem[] = users
1379
- .map(({ m }, p) => {
1380
- const t = contentText(m.content).split('\n\n--- @')[0].replace(/\n+/g, ' ').trim(); // drop inlined @file blocks
1381
- return { label: t.length > 60 ? t.slice(0, 59) + '…' : t || '(empty)', value: String(p) };
1382
- })
1383
- .reverse();
1384
- const pick = await selectMenu(process.stderr, { title: 'Jump back to a message', items });
1385
- if (pick == null) return undefined;
1386
- const p = Number(pick);
1387
- const idx = users[p].i;
1388
- // Map a user-turn position to a checkpoint frame index. Frames may cover only the most recent turns
1389
- // (in-memory cap, or pruned git history), so offset by however many of the oldest turns lack one.
1390
- const frame = p - (users.length - checkpoints.size);
1391
- // Offer what to restore — only surface the code options when this turn still has a file checkpoint.
1392
- // Duplex: conversation-only — frames are per TASK, not per turn, so the turn↔frame mapping is invalid
1393
- // (code rollback is /rewind, /undo, or saying "undo that" to the voice).
1394
- let mode = 'convo';
1395
- if (!duplex && frame >= 0 && frame < checkpoints.size) {
1396
- const m = await selectMenu(process.stderr, { title: 'Restore…', items: [
1397
- { label: 'Conversation and code', value: 'both' },
1398
- { label: 'Conversation only', value: 'convo' },
1399
- { label: 'Code only', value: 'code' },
1400
- ] });
1401
- if (m == null) return undefined;
1402
- mode = m;
1403
- }
1404
- const text = contentText(face.transcript[idx].content).split('\n\n--- @')[0].trim();
1405
- if (mode === 'code' || mode === 'both') {
1406
- try {
1407
- const { restored, deleted } = await checkpoints.rewindTo(frame);
1408
- err(green(' ⟲ code restored') + dim(` — ${restored} file(s)${deleted ? `, removed ${deleted} new file(s)` : ''}\n`));
1409
- } catch (e: any) { err(red(` ${e?.message ?? e}\n`)); }
1410
- }
1411
- if (mode === 'convo' || mode === 'both') {
1412
- face.transcript = face.transcript.slice(0, idx); // rewind the conversation to before that message
1413
- session.messages = face.transcript;
1414
- try { store.save(session); } catch (e) { log.debug('session save after rewind failed', e); }
1415
- err(green(' ⟲ jumped back') + dim(` — ${face.transcript.length} message(s) kept; edit + resend\n`));
1416
- return text;
1417
- }
1418
- return undefined; // code-only: nothing to pre-fill
1419
- };
1420
- const pickSession = async () => {
1421
- const list = store.list();
1422
- if (!list.length) { err(dim(' (no saved sessions yet)\n')); return; }
1423
- const items: SelectItem[] = list.slice(0, 50).map((m) => ({ label: `${m.id} ${m.title || '(untitled)'}`, value: m.id, desc: `${ago(m.updated)} · ${m.turns} turn${m.turns === 1 ? '' : 's'}` }));
1424
- const id = await selectMenu(process.stderr, { title: 'Resume a session', items, current: session.meta.id });
1425
- if (!id) return;
1426
- const data = store.load(id);
1427
- if (data) resumeInto(data); else err(red(' no such session\n'));
1428
- };
1429
- const announcedTasks = new Set<string>(); // task ids whose ◔ in-flow marker already printed (once each)
1430
- // One seam for every interactive turn: duplex dispatches through dx.send (mutex + delegation) and
1431
- // skips per-turn checkpoints (frames are per task, opened by the engine's onTaskStart).
1432
- const turn = async (task: string) => {
1433
- const r = await runTurn(face, store, session, task, duplex ? undefined : checkpoints, cwd, sendVia);
1434
- if (voiceIO) { process.stdout.write('\n'); editorRef?.resume(); } // streamed under a suspended editor — repaint below
1435
- voiceIO?.endSpeech(); // close the spoken turn's TTS context (idempotent; audio drains out)
1436
- // Duplex: a turn can END while work continues — mark it IN THE FLOW once per task (the "✓ done"
1437
- // footer reads as "turn over"); after that the stacked ticking footer line carries the signal.
1438
- if (dx) {
1439
- const fresh = [...dx.tasks.values()].filter((t) => t.status === 'running' && !announcedTasks.has(t.id));
1440
- fresh.forEach((t) => announcedTasks.add(t.id));
1441
- if (fresh.length) err('\r\x1b[0J' + cyan(` ◔ ${fresh.length === 1 ? `task ${fresh[0].id} (${fresh[0].label})` : `${fresh.length} tasks`} working in the background`) + dim(' — the result will appear here; keep chatting meanwhile\n'));
1442
- }
1443
- return r;
1444
- };
1445
- const runSkill = async (sk: SkillInfo, extra = '') => {
1446
- try { const body = await fs.readFile(sk.path); await turn(extra ? `${body}\n\n${extra}` : body); }
1447
- catch (e: any) { err(red(` couldn't load skill ${sk.name}: ${e?.message ?? e}\n`)); }
1448
- };
1449
- const runCommand = async (c: CommandInfo, extra = '') => { await turn(await expandCommand(fs, c, extra)); };
1450
- const pickAndRun = async (kind: 'skill' | 'command') => {
1451
- const pool = kind === 'skill' ? skills : cmds;
1452
- if (!pool.length) { err(dim(` (none — add ./.agent/${kind === 'skill' ? 'skills/<name>/SKILL.md' : 'commands/<name>.md'})\n`)); return; }
1453
- const items: SelectItem[] = pool.map((p) => ({ label: p.name, value: p.name, desc: (p as { description?: string }).description }));
1454
- const name = await selectMenu(process.stderr, { title: `Run a ${kind}`, items });
1455
- if (!name) return;
1456
- if (kind === 'skill') await runSkill(skills.find((s) => s.name === name)!);
1457
- else await runCommand(cmds.find((c) => c.name === name)!);
1458
- };
1459
-
1460
- const pickModel = async (current: string): Promise<string | null> => {
1461
- const all = listModels();
1462
- // group: provider → model line (by displayName prefix, e.g. "Claude Opus") → versions newest-first
1463
- const byProvider = new Map<string, Map<string, { id: string; date: string; name: string }[]>>();
1464
- for (const id of all) {
1465
- const provider = getProviderFromModel(id) ?? 'other';
1466
- const info = getModelInfo(id);
1467
- const name = info?.displayName ?? id.slice(provider.length + 1);
1468
- const line = name.replace(/\s+\d[\d.]*$/, '') || name;
1469
- if (!byProvider.has(provider)) byProvider.set(provider, new Map());
1470
- const lines = byProvider.get(provider)!;
1471
- if (!lines.has(line)) lines.set(line, []);
1472
- lines.get(line)!.push({ id, date: info?.releaseDate ?? '', name });
1473
- }
1474
- const providerItems: SelectItem[] = [];
1475
- for (const [provider, lines] of Array.from(byProvider.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
1476
- const lineItems: SelectItem[] = [];
1477
- for (const [, versions] of Array.from(lines.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
1478
- versions.sort((a, b) => b.date.localeCompare(a.date));
1479
- const head = versions[0];
1480
- if (versions.length === 1) {
1481
- lineItems.push({ label: head.name, value: head.id });
1482
- } else {
1483
- const children = versions.slice(1).map((v) => ({ label: v.id.split('/').pop()!, value: v.id, desc: v.date || undefined }));
1484
- lineItems.push({ label: head.name, value: head.id, desc: `+${children.length} older`, children });
1485
- }
1486
- }
1487
- const dateOf = (it: SelectItem) => getModelInfo(it.value)?.releaseDate ?? '0';
1488
- lineItems.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
1489
- const total = Array.from(lines.values()).reduce((n, v) => n + v.length, 0);
1490
- providerItems.push({ label: provider, value: '__provider__', desc: `${total} models`, children: lineItems });
1491
- }
1492
- const picked = await selectMenu(process.stderr, { title: `Select a model · current: ${current}`, items: providerItems, current, filterable: true });
1493
- return picked && !picked.startsWith('__') ? picked : null;
1494
- };
1495
-
1496
- let goalCondition: string | undefined = (session.meta as any).goalCondition;
1497
- let goalTurns: number = (session.meta as any).goalTurns ?? 0;
1498
- let goalTokens: number = (session.meta as any).goalTokens ?? 0;
1499
- let goalLastReason: string | undefined = (session.meta as any).goalLastReason;
1500
- const GOAL_MAX_TURNS = 50;
1501
- const persistGoal = () => {
1502
- const m = session.meta as any;
1503
- m.goalCondition = goalCondition; m.goalTurns = goalTurns; m.goalTokens = goalTokens; m.goalLastReason = goalLastReason;
1504
- };
1505
-
1506
- // ---- /goal: autonomous loop with a halting condition ----
1507
- const goalLoop = async () => {
1508
- while (goalCondition && !aborting && !exitRequested && goalTurns < GOAL_MAX_TURNS) {
1509
- const result = await evaluateGoal(ai, goalCondition, face.transcript, err);
1510
- goalLastReason = result.reason;
1511
- if (result.met) {
1512
- err(green(` ✓ goal met: ${result.reason}\n`));
1513
- goalCondition = undefined; goalLastReason = undefined;
1514
- persistGoal();
1515
- return;
1516
- }
1517
- err(dim(` ◎ not yet (${result.reason}) — turn ${goalTurns + 1}\n`));
1518
- aborting = false;
1519
- const tokensBefore = session.meta.tokens ?? 0;
1520
- await turn(`Continue working toward the goal: ${goalCondition}`);
1521
- goalTokens += (session.meta.tokens ?? 0) - tokensBefore;
1522
- goalTurns++;
1523
- persistGoal();
1524
- if (exitRequested) return;
1525
- }
1526
- if (goalTurns >= GOAL_MAX_TURNS) {
1527
- err(yellow(` ⚠ goal reached ${GOAL_MAX_TURNS} turns — pausing. /goal to check status, /goal clear to cancel\n`));
1528
- }
1529
- };
1530
-
1531
- // ---- slash builtins: name → handler. Returns true to exit the REPL. ----
1532
- const builtins: Record<string, { desc: string; run: (a: string[]) => boolean | void | Promise<boolean | void> }> = {
1533
- help: { desc: 'show this help', run: () => { err(HELP + '\n'); } },
1534
- version: {
1535
- desc: 'show CLI version + runtime',
1536
- run: () => {
1537
- const rt = (process.versions as any).bun ? `bun ${(process.versions as any).bun}` : `node ${process.versions.node}`;
1538
- err(` ${bold('agent.libx.js')} ${cyan('v' + VERSION)}${dim(` · ${duplex ? `reflex ${dx!.options.reflexModel} · act ${work.model}${dx!.options.thinkModel !== false ? ` · think ${dx!.options.thinkModel}` : ''}` : work.model} · ${rt}`)}\n`);
1539
- },
1540
- },
1541
- tools: {
1542
- desc: 'list available tools',
1543
- run: () => {
1544
- if (duplex) err(dim(' voice: ' + face.options.tools.map((t) => t.name).join(', ') + '\n worker: ' + (work.tools ?? []).map((t) => t.name).join(', ') + '\n'));
1545
- else err(dim(' ' + agent.options.tools.map((t) => t.name).join(', ') + '\n'));
1546
- },
1547
- },
1548
- permissions: {
1549
- desc: 'show the active permission rules + default posture',
1550
- run: () => {
1551
- const pol = work.permissions;
1552
- const rules = pol?.options.rules ?? [];
1553
- if (!rules.length) err(dim(' (no rules — default: ' + (pol?.options.default ?? 'allow') + ')\n'));
1554
- else { err(dim(' default: ' + (pol!.options.default) + '\n')); for (const r of rules) err(dim(' · ' + describeRule(r) + '\n')); }
1555
- err(dim(' add persisted rules under .agent/config → permissions: { allow|ask|deny: ["Tool(glob)"] }\n'));
1556
- },
1557
- },
1558
- status: {
1559
- desc: 'session status — model, dir, fs-mode, permissions, tools, usage',
1560
- run: () => {
1561
- const mode = args.vfs ? 'sandbox (VFS — disk untouched)' : args.boddb ? `boddb (database workspace at ${args.boddb} — disk untouched)` : args.shell ? 'disk + real /bin/sh' : 'disk (full real FS, like Claude Code)';
1562
- const pol = work.permissions;
1563
- const perm = !pol ? 'allow all (unattended)' : `${pol.options.rules.length} rule(s), default ${pol.options.default}`;
1564
- const model = duplex ? `reflex ${dx!.options.reflexModel} · act ${work.model}${dx!.options.thinkModel !== false ? ` · think ${dx!.options.thinkModel}` : ''}` : work.model;
1565
- err(formatStatus({ model, cwd, mode, tools: (duplex ? work.tools ?? [] : agent.options.tools).map((t) => t.name), permissions: perm, turns: session.meta.turns, tokens: session.meta.tokens ?? 0, sessionId: session.meta.id, estimated: session.meta.costEstimated ?? false }));
1566
- if (duplex && dx!.tasks.size) err(dim(` tasks: ${[...dx!.tasks.values()].map((t) => `${t.id}:${t.status}`).join(' ')}\n`));
1567
- },
1568
- },
1569
- cost: {
1570
- desc: 'cumulative cost + token usage for this session',
1571
- run: () => {
1572
- const t = session.meta.tokens ?? 0, usd = session.meta.costUsd ?? 0;
1573
- const priced = getModelInfo(work.model)?.pricing;
1574
- const est = session.meta.costEstimated ?? false; // false once every turn reported exact usage
1575
- const m = est ? '~' : '';
1576
- const note = priced ? (est ? ' (estimated — some turns streamed without usage)' : ' (exact — provider-reported usage)') : ` (no pricing for ${work.model})`;
1577
- err(dim(` ${usd > 0 ? m + fmtUsd(usd) + ' · ' : ''}${m}${(t / 1000).toFixed(1)}k tokens across ${session.meta.turns} turn(s)${note}\n`));
1578
- },
1579
- },
1580
- context: {
1581
- desc: 'context-window usage (messages + estimated tokens)',
1582
- run: () => {
1583
- const est = estimateTranscriptTokens(face.transcript);
1584
- const cap = face.options.maxTokens || 200_000;
1585
- err(dim(` ${face.transcript.length} message(s) · ~${(est / 1000).toFixed(1)}k tokens (~${Math.round((est / cap) * 100)}% of ${Math.round(cap / 1000)}k budget)\n`));
1586
- },
1587
- },
1588
- cwd: {
1589
- desc: 'print the working directory (to switch, relaunch with -C <dir>)',
1590
- run: (a) => {
1591
- if (a[0]) { err(yellow(` mid-session dir change isn't supported (fs + MCP are bound at startup) — relaunch:\n`) + dim(` agentx -C ${a[0]}\n`)); return; }
1592
- err(dim(' ' + cwd + '\n'));
1593
- },
1594
- },
1595
- sandbox: {
1596
- desc: 'show filesystem access mode (disk = full real FS like Claude Code · sandbox = in-memory copy); to switch, relaunch with/without --sandbox',
1597
- run: () => {
1598
- const access = args.boddb
1599
- ? `boddb — files live in a database at ${args.boddb} (persists across runs), the real disk is never modified`
1600
- : args.vfs
1601
- ? 'sandbox — in-memory copy of cwd, the real disk is never modified'
1602
- : 'disk — full real filesystem (root / = machine root), like Claude Code';
1603
- const virtual = args.vfs || !!args.boddb;
1604
- err(dim(` fs access: ${access}\n`));
1605
- err(dim(` checkpoints: ${virtual ? (args.boddb ? 'in-database (persists)' : 'in-memory (per session)') : 'durable git-backed — whole tree incl. bash edits, survives sessions'} · /rewind /undo\n`));
1606
- err(yellow(` switching mid-session isn't supported (the filesystem is bound at startup) — relaunch:\n`) + dim(` agentx ${virtual ? '' : '--sandbox '}…\n`));
1607
- },
1608
- },
1609
- model: {
1610
- desc: 'switch model — /model <id>, or /model alone for an interactive picker (duplex: the worker model)',
1611
- run: async (a) => {
1612
- if (a[0]) { setModel(a[0]); return; }
1613
- const picked = await pickModel(work.model);
1614
- if (picked) setModel(picked);
1615
- else err(dim(' ' + (duplex ? `reflex ${dx!.options.reflexModel} · act ${work.model}${dx!.options.thinkModel !== false ? ` · think ${dx!.options.thinkModel}` : ''}` : work.model) + '\n'));
1616
- },
1617
- },
1618
- ...(duplex ? { workers: {
1619
- desc: 'duplex worker chrome — /workers <full|minimal>: per-step ⚙ tool activity vs task events only',
1620
- run: async (a: string[]) => {
1621
- if (a[0] === 'full' || a[0] === 'minimal') { workerChrome = a[0]; err(green(` ✓ worker chrome → ${a[0]}\n`)); return; }
1622
- err(dim(` worker chrome: ${workerChrome} (use /workers full|minimal)\n`));
1623
- },
1624
- }, voice: {
1625
- desc: 'toggle live voice I/O on/off mid-session (needs SONIOX/CARTESIA keys + a TTY)',
1626
- run: async () => {
1627
- if (!toggleVoice) { err(dim(' (voice needs --duplex on a TTY)\n')); return; }
1628
- await toggleVoice();
1629
- },
1630
- }, 'voice-model': {
1631
- desc: 'switch the reflex (voice) model — /voice-model <id>, or alone for a picker',
1632
- run: async (a: string[]) => {
1633
- const apply = (id: string) => {
1634
- const m = resolveModelOrNewest(id);
1635
- dx!.options.reflexModel = m;
1636
- dx!.voice.options.model = m;
1637
- err(green(` ✓ reflex model → ${m}\n`));
1638
- };
1639
- if (a[0]) { apply(a[0]); return; }
1640
- const picked = await pickModel(dx!.options.reflexModel);
1641
- if (picked) apply(picked);
1642
- else err(dim(` reflex ${dx!.options.reflexModel}\n`));
1643
- },
1644
- }, 'think-model': {
1645
- desc: 'switch the think (premium) model, or /think-model off to disable',
1646
- run: async (a: string[]) => {
1647
- if (a[0] === 'off' || a[0] === 'false') {
1648
- dx!.setThinkModel(false); // live: removes the Think tool from the voice agent
1649
- err(green(` ✓ think tier disabled\n`));
1650
- return;
1651
- }
1652
- const apply = (id: string) => {
1653
- const m = resolveModelOrNewest(id);
1654
- dx!.setThinkModel(m); // live: adds the Think tool if it was disabled
1655
- err(green(` ✓ think model → ${m}\n`));
1656
- };
1657
- if (a[0]) { apply(a[0]); return; }
1658
- const current = dx!.options.thinkModel === false ? undefined : dx!.options.thinkModel;
1659
- const picked = await pickModel(current ?? 'anthropic/claude-opus-4-6');
1660
- if (picked) apply(picked);
1661
- else err(dim(` think ${dx!.options.thinkModel === false ? 'off' : dx!.options.thinkModel}\n`));
1662
- },
1663
- }, act: {
1664
- desc: 'spawn a standard worker — /act <brief>',
1665
- run: async (a: string[]) => {
1666
- if (!a.length) { err(dim(' usage: /act <what to do>\n')); return; }
1667
- const id = await dx!.dispatch(a.join(' '), 'act');
1668
- err(dim(` → task ${id} started\n`));
1669
- },
1670
- }, think: {
1671
- desc: 'spawn a deep-reasoning worker — /think <question>',
1672
- run: async (a: string[]) => {
1673
- if (!a.length) { err(dim(' usage: /think <what to reason about>\n')); return; }
1674
- const off = dx!.options.thinkModel === false; // dispatch silently downgrades — tell the user
1675
- const id = await dx!.dispatch(a.join(' '), 'think');
1676
- err(dim(` → task ${id} ${off ? '(think tier off — running as act)' : '(think)'} started\n`));
1677
- },
1678
- } } : {}),
1679
- reasoning: {
1680
- desc: 'extended thinking — /reasoning <off|low|medium|high|tokens>, or alone for an interactive picker (duplex: the workers\')',
1681
- run: async (a) => {
1682
- const current = String(work.reasoning ?? 'off');
1683
- let next: ReasoningEffort;
1684
- if (a[0]) {
1685
- try { next = parseReasoning(a[0]); } catch (e: any) { err(yellow(' ' + (e?.message ?? e) + '\n')); return; }
1686
- } else {
1687
- const items: SelectItem[] = [
1688
- { label: 'off', value: 'off', desc: 'no extended thinking' },
1689
- { label: 'low', value: 'low', desc: 'minimal reasoning (~2k tokens)' },
1690
- { label: 'medium', value: 'medium', desc: 'balanced (~8k tokens)' },
1691
- { label: 'high', value: 'high', desc: 'maximal reasoning (~24k tokens)' },
1692
- ];
1693
- const picked = await selectMenu(process.stderr, { title: `Reasoning effort · current: ${current}`, items, current });
1694
- if (!picked) { err(dim(' ' + current + '\n')); return; }
1695
- next = picked as ReasoningEffort;
1696
- }
1697
- work.reasoning = next;
1698
- if (next !== 'off' && getModelInfo(work.model)?.reasoning === false)
1699
- err(yellow(` note: ${work.model} has no reasoning capability — setting may be ignored\n`));
1700
- err(dim(' reasoning → ' + next + '\n'));
1701
- },
1702
- },
1703
- config: {
1704
- desc: 'view/change settings — model, reasoning, permission posture, streaming, editor mode',
1705
- run: async () => {
1706
- for (;;) {
1707
- const items: SelectItem[] = [
1708
- { label: 'model', value: 'model', desc: work.model },
1709
- { label: 'reasoning', value: 'reasoning', desc: String(work.reasoning ?? 'off') },
1710
- { label: 'permission posture', value: 'posture', desc: postureLabel() + ' (Shift+Tab)' },
1711
- // streaming is the voice's lifeblood in duplex (always on) — only a normal-mode knob
1712
- ...(duplex ? [] : [{ label: 'streaming', value: 'stream', desc: agent.options.stream ? 'on' : 'off' }]),
1713
- { label: 'editor mode', value: 'editor', desc: cfg.editorMode === 'vim' ? 'vim' : 'normal' },
1714
- ];
1715
- const pick = await selectMenu(process.stderr, { title: 'Settings · ↵ change · esc close', items });
1716
- if (!pick) return;
1717
- if (pick === 'model') { const m = await pickModel(work.model); if (m) setModel(m); }
1718
- else if (pick === 'reasoning') { await builtins.reasoning.run([]); persistSetting(cwd, 'reasoning', work.reasoning ?? 'off'); }
1719
- else if (pick === 'posture') { cyclePosture(); persistSetting(cwd, 'permissionMode', posture); }
1720
- else if (pick === 'stream') { agent.options.stream = !agent.options.stream; persistSetting(cwd, 'stream', agent.options.stream); err(dim(' streaming → ' + (agent.options.stream ? 'on' : 'off') + '\n')); }
1721
- else if (pick === 'editor') { cfg.editorMode = cfg.editorMode === 'vim' ? 'normal' : 'vim'; persistSetting(cwd, 'editorMode', cfg.editorMode); err(dim(' editor → ' + cfg.editorMode + '\n')); }
1722
- }
1723
- },
1724
- },
1725
- rename: {
1726
- desc: 'rename the current session — /rename <title>',
1727
- run: (a) => {
1728
- const t = a.join(' ').trim();
1729
- if (!t) { err(dim(' title: ' + (session.meta.title || '(none)') + '\n')); return; }
1730
- session.meta.title = t;
1731
- try { store.save(session); } catch { /* non-fatal */ }
1732
- err(dim(' renamed → ' + t + '\n'));
1733
- },
1734
- },
1735
- compact: {
1736
- desc: 'summarize older context to free up the window',
1737
- run: () => { const n = face.compactNow(); session.messages = face.transcript; try { store.save(session); } catch { /* ignore */ } err(dim(` compacted — folded ${n} message(s)\n`)); },
1738
- },
1739
- rewind: {
1740
- desc: 'undo file edits back to before an earlier turn (interactive)',
1741
- run: async () => {
1742
- await checkpoints.refresh?.();
1743
- const list = checkpoints.list();
1744
- if (!list.length) { err(dim(' (no checkpoints yet — edits are captured per turn)\n')); return; }
1745
- const items: SelectItem[] = list.map((c) => ({ label: c.label, value: String(c.index), desc: ago(c.at) + (c.files ? ` · ${c.files} file${c.files === 1 ? '' : 's'}` : '') }));
1746
- const pick = await selectMenu(process.stderr, { title: 'Rewind to before…', items });
1747
- if (pick == null) return;
1748
- try {
1749
- const { restored, deleted } = await checkpoints.rewindTo(Number(pick));
1750
- err(green(` ⟲ rewound`) + dim(` — restored ${restored} file(s)${deleted ? `, removed ${deleted} new file(s)` : ''}\n`) + dim(' (files only; the conversation is unchanged)\n'));
1751
- } catch (e: any) { err(red(` ${e?.message ?? e}\n`)); }
1752
- },
1753
- },
1754
- undo: {
1755
- desc: 'undo the file edits from the most recent turn',
1756
- run: async () => {
1757
- await checkpoints.refresh?.();
1758
- if (!checkpoints.size) { err(dim(' (nothing to undo)\n')); return; }
1759
- const { restored, deleted } = await checkpoints.rewindTo(checkpoints.size - 1);
1760
- err(green(` ⟲ undid last turn`) + dim(` — restored ${restored} file(s)${deleted ? `, removed ${deleted} new file(s)` : ''}\n`));
1761
- },
1762
- },
1763
- clear: {
1764
- desc: 'start a fresh conversation (and clear the screen)',
1765
- run: () => { face.transcript = []; session = startSession({ ...args, cont: false, resume: undefined }, store, face, cwd); err('\x1bc'); },
1766
- },
1767
- sessions: { desc: 'pick a saved session to resume (interactive)', run: () => pickSession() },
1768
- resume: {
1769
- desc: 'resume a session — /resume <id>, or /resume alone to pick from a list',
1770
- run: async (a) => {
1771
- if (!a[0]) { await pickSession(); return; }
1772
- const data = store.load(a[0]);
1773
- if (data) resumeInto(data); else err(red(` no such session\n`));
1774
- },
1775
- },
1776
- commands: { desc: 'pick a custom slash command to run (./.agent/commands)', run: () => pickAndRun('command') },
1777
- skills: { desc: 'pick a skill to run (./.agent/skills)', run: () => pickAndRun('skill') },
1778
- mcp: {
1779
- desc: 'manage MCP servers — /mcp [add <name> <cmd|url>] [login <name>] [remove <name>] [tools [name]] [resources [name]]',
1780
- run: async (a) => {
1781
- const sub = a[0]?.toLowerCase();
1782
- if (sub === 'login') {
1783
- const name = a[1];
1784
- const target = name ? cfg.mcpServers?.[name] : undefined;
1785
- if (!name || !target?.url) { err(yellow(' usage: /mcp login <name> (name must be a configured http server)\n')); return; }
1786
- try {
1787
- err(dim(` opening browser to authorize "${name}"…\n`));
1788
- await oauth.register(target.url);
1789
- err(green(` ✓ authorized "${name}"`) + dim(' — remounting with the new token\n'));
1790
- // remount this server with the freshly-minted bearer token
1791
- const idx = mounted.findIndex((m) => m.name === name);
1792
- if (idx >= 0) { removeWorkTools(mounted[idx].tools.map((t) => t.name)); await mounted.splice(idx, 1)[0].client.close().catch(() => {}); }
1793
- const m = await mountMcpServer(name, { ...target, bearerToken: await oauth.tokenFor(target.url) });
1794
- mounted.push(m); addWorkTools(m.tools);
1795
- err(green(` ✓ ${m.name}`) + dim(` — ${m.tools.length} tool(s)\n`));
1796
- } catch (e: any) { err(red(` login failed: ${e?.message ?? e}\n`)); }
1797
- return;
1798
- }
1799
- if (sub === 'add') {
1800
- const name = a[1]; const target = a.slice(2).join(' ');
1801
- if (!name || !target) { err(yellow(' usage: /mcp add <name> <command ...> | <url>\n')); return; }
1802
- if (mounted.find((m) => m.name === name)) { err(yellow(` MCP "${name}" already mounted\n`)); return; }
1803
- const cfg: McpServerConfig = target.startsWith('http://') || target.startsWith('https://') ? { url: target } : { command: target.split(' ')[0], args: target.split(' ').slice(1) };
1804
- try {
1805
- const m = await mountMcpServer(name, cfg);
1806
- mounted.push(m);
1807
- addWorkTools(m.tools);
1808
- err(green(` ✓ ${m.name}`) + dim(` — ${m.tools.length} tool(s)${m.serverInfo?.name ? ` from ${m.serverInfo.name}` : ''}\n`));
1809
- } catch (e: any) { err(red(` failed to mount "${name}": ${e?.message ?? e}\n`)); }
1810
- return;
1811
- }
1812
- if (sub === 'remove') {
1813
- const name = a[1];
1814
- if (!name) { err(yellow(' usage: /mcp remove <name>\n')); return; }
1815
- const idx = mounted.findIndex((m) => m.name === name);
1816
- if (idx < 0) { err(yellow(` MCP "${name}" not found\n`)); return; }
1817
- const m = mounted.splice(idx, 1)[0];
1818
- removeWorkTools(m.tools.map((t) => t.name));
1819
- await m.client.close().catch((e) => log.debug('mcp close failed', e));
1820
- err(dim(` removed "${name}"\n`));
1821
- return;
1822
- }
1823
- if (sub === 'tools') {
1824
- const filter = a[1];
1825
- const targets = filter ? mounted.filter((m) => m.name === filter) : mounted;
1826
- if (!targets.length) { err(dim(` (no ${filter ? `MCP server "${filter}"` : 'MCP servers'} found)\n`)); return; }
1827
- for (const m of targets) {
1828
- err(` ${cyan(m.name)} ${dim(`(${m.tools.length} tools)`)}\n`);
1829
- for (const t of m.tools) err(dim(` - ${t.name.replace(`mcp__${m.name}__`, '')}${t.description ? ' — ' + t.description.split('\n')[0] : ''}\n`));
1830
- }
1831
- return;
1832
- }
1833
- if (sub === 'resources') {
1834
- const filter = a[1];
1835
- const targets = filter ? mounted.filter((m) => m.name === filter) : mounted;
1836
- if (!targets.length) { err(dim(` (no ${filter ? `MCP server "${filter}"` : 'MCP servers'} found)\n`)); return; }
1837
- for (const m of targets) {
1838
- try {
1839
- const resources = await m.client.listResources();
1840
- if (!resources.length) { err(dim(` ${m.name}: (no resources)\n`)); continue; }
1841
- err(` ${cyan(m.name)} ${dim(`(${resources.length} resources)`)}\n`);
1842
- for (const r of resources) err(dim(` - ${r.uri}${r.name ? ' — ' + r.name : ''}${r.mimeType ? ' [' + r.mimeType + ']' : ''}\n`));
1843
- } catch (e: any) { err(dim(` ${m.name}: resources not supported (${e?.message})\n`)); }
1844
- }
1845
- return;
1846
- }
1847
- // default: interactive menu
1848
- const listServers = () => {
1849
- if (!mounted.length) { err(dim(' (no MCP servers mounted)\n')); return; }
1850
- for (const m of mounted) {
1851
- const ver = m.serverInfo?.name ? dim(` · ${m.serverInfo.name}${m.serverInfo.version ? ' v' + m.serverInfo.version : ''}`) : '';
1852
- err(` ${green('✓')} ${cyan(m.name)}${ver} ${dim(`(${m.tools.length} tools)`)}\n`);
1853
- }
1854
- err(dim(' /mcp tools [name] to list tools · /mcp resources [name] for resources\n'));
1855
- };
1856
- const items: SelectItem[] = [
1857
- { label: 'list', value: 'list', desc: `show mounted servers (${mounted.length})` },
1858
- { label: 'add', value: 'add', desc: 'mount a new MCP server' },
1859
- ...(mounted.length ? [
1860
- { label: 'tools', value: 'tools', desc: 'list a server\'s tools' },
1861
- { label: 'remove', value: 'remove', desc: 'unmount an MCP server' },
1862
- { label: 'resources', value: 'resources', desc: 'list server resources' },
1863
- ] : []),
1864
- ];
1865
- const picked = await selectMenu(process.stderr, { title: 'MCP servers', items });
1866
- if (!picked) return;
1867
- if (picked === 'list') { listServers(); return; }
1868
- // re-dispatch to the subcommand handler
1869
- a = [picked];
1870
- if (picked === 'add') {
1871
- const io = createInterface({ input: process.stdin, output: process.stderr });
1872
- try {
1873
- const name = (await io.question(yellow(' name: '))).trim();
1874
- const target = (await io.question(yellow(' command or url: '))).trim();
1875
- if (!name || !target) { err(yellow(' cancelled\n')); return; }
1876
- a = ['add', name, ...target.split(' ')];
1877
- } finally { io.close(); }
1878
- } else if (picked === 'remove') {
1879
- const rv = await selectMenu(process.stderr, { title: 'remove server', items: mounted.map((m) => ({ label: m.name, value: m.name })) });
1880
- if (!rv) return;
1881
- a = ['remove', rv];
1882
- } else if (picked === 'tools' || picked === 'resources') {
1883
- if (mounted.length === 1) { a = [picked, mounted[0].name]; }
1884
- else {
1885
- const rv = await selectMenu(process.stderr, { title: `server ${picked}`, items: [{ label: '(all)', value: '' }, ...mounted.map((m) => ({ label: m.name, value: m.name }))] });
1886
- if (rv === null) return;
1887
- a = rv ? [picked, rv] : [picked];
1888
- }
1889
- }
1890
- // fall through to re-run with the assembled args
1891
- return builtins.mcp.run(a);
1892
- },
1893
- },
1894
- init: { desc: 'scaffold ./AGENTS.md project instructions', run: () => initInstructions(cwd) },
1895
- paste: {
1896
- desc: 'attach an image from the clipboard (macOS) — /paste [message] sends now, /paste alone attaches to your next message',
1897
- run: async (a) => {
1898
- const att = grabClipboardAttachment();
1899
- if (!att) { err(yellow(' no image on the clipboard') + dim(' — copy or screenshot one, then /paste\n')); return; }
1900
- const msg = a.join(' ').trim();
1901
- if (msg) await turn(`${msg} ${att.ref}`); // grab + send in one go
1902
- else { pendingImages.push(att.path); err(green(` ✓ image attached (#${pendingImages.length})`) + dim(' — type your message to send it\n')); }
1903
- },
1904
- },
1905
- export: {
1906
- desc: 'save this conversation to Markdown — /export [path] (default ./.agent/exports/<id>.md)',
1907
- run: (a) => {
1908
- const shown = face.transcript.filter((m) => m.role !== 'system');
1909
- if (!shown.length) { err(dim(' (nothing to export yet)\n')); return; }
1910
- const md = exportMarkdown(session.meta, shown);
1911
- // Write to REAL disk (node fs) so the file is findable even in --vfs sandbox mode.
1912
- const name = a[0] ? (extname(a[0]) ? a[0] : a[0] + '.md') : join('.agent', 'exports', `${session.meta.id}.md`);
1913
- const path = resolve(cwd, name);
1914
- try {
1915
- mkdirSync(dirname(path), { recursive: true });
1916
- writeFileSync(path, md);
1917
- err(green(` ✓ exported → ${path}\n`) + dim(` ${shown.length} message(s) · ${md.length} chars\n`));
1918
- } catch (e: any) { err(red(` export failed: ${e?.message ?? e}\n`)); }
1919
- },
1920
- },
1921
- goal: {
1922
- desc: 'autonomous loop — /goal <condition> | /goal (status) | /goal clear',
1923
- run: async (a) => {
1924
- if (!a.length) {
1925
- if (!goalCondition) { err(dim(' no active goal\n')); return; }
1926
- const tokStr = goalTokens > 1000 ? `${(goalTokens / 1000).toFixed(1)}k` : String(goalTokens);
1927
- err(` ${bold('◎ goal:')} ${goalCondition}\n` + dim(` ${goalTurns} turn${goalTurns === 1 ? '' : 's'} · ${tokStr} tokens${goalLastReason ? ` · last: ${goalLastReason}` : ''}\n`));
1928
- return;
1929
- }
1930
- if (a[0] === 'clear') {
1931
- if (!goalCondition) { err(dim(' no active goal\n')); return; }
1932
- goalCondition = undefined; goalTurns = 0; goalTokens = 0; goalLastReason = undefined;
1933
- persistGoal();
1934
- err(green(' ✓ goal cleared\n'));
1935
- return;
1936
- }
1937
- goalCondition = a.join(' ');
1938
- goalTurns = 0; goalTokens = 0; goalLastReason = undefined;
1939
- persistGoal();
1940
- err(green(` ◎ goal set: ${goalCondition}\n`) + dim(' working… (Esc to pause)\n'));
1941
- const tokensBefore = session.meta.tokens ?? 0;
1942
- await turn(goalCondition);
1943
- goalTokens += (session.meta.tokens ?? 0) - tokensBefore;
1944
- goalTurns++;
1945
- persistGoal();
1946
- if (!exitRequested) await goalLoop();
1947
- if (exitRequested) return true;
1948
- },
1949
- },
1950
- exit: { desc: 'quit', run: () => true },
1951
- quit: { desc: 'quit', run: () => true },
1952
- };
1953
-
1954
- err(bold('agent.libx.js') + cyan(' v' + VERSION) + dim(` — ${work.model} · ${cwd}\n`));
1955
- err(dim('Type a task, or /help. Type / or @ for live suggestions (↑/↓ ⏎). Esc cancels/clears; double-Esc jumps back; Ctrl-D exits.\n'));
1956
- if (dx) err(dim(`◑ duplex — reflex: ${dx.options.reflexModel} · act: ${work.model}${dx.options.thinkModel !== false ? ` · think: ${dx.options.thinkModel}` : ''} (real work runs in background tasks, re-voiced when done)\n`));
1957
- // Live suggestions: file/dir entries from the real cwd; command/skill descriptions for the menu.
1958
- const listDir: DirLister = (absDir) => {
1959
- try {
1960
- return readdirSync(join(cwd, absDir.replace(/^\/+/, '')), { withFileTypes: true })
1961
- .map((d) => ({ name: d.name, dir: d.isDirectory() }));
1962
- } catch (e) { log.debug('completion readdir failed', absDir, e); return null; } // not a dir / unreadable
1963
- };
1964
- const commandNames = () => [...Object.keys(builtins).filter((k) => k !== 'quit'), ...cmds.map((c) => c.name), ...skills.map((s) => s.name)];
1965
- const describe = (hit: string): string | undefined =>
1966
- hit.startsWith('/') ? (builtins[hit.slice(1)]?.desc ?? cmds.find((c) => c.name === hit.slice(1))?.description ?? skills.find((s) => s.name === hit.slice(1))?.description) : undefined;
1967
- const suggest = (lineToCursor: string) => {
1968
- const [hits, token] = completeLine(lineToCursor, { commands: commandNames(), listDir });
1969
- return { hits, token, describe };
1970
- };
1971
-
1972
- const editor = createLineEditor(process.stderr);
1973
- editorRef = editor; // async chrome (⦿ results, re-voice) repaints the live prompt through this
1974
- let aborting = false; // first Esc of a turn aborts; a second Esc means "…and jump back to edit"
1975
- let pendingRewind = false; // set by double-Esc during a run → REPL opens the jump-back picker once the turn unwinds
1976
- // Esc / Ctrl-C cancel a RUNNING turn (raw mode stays on between prompts, so the key arrives live).
1977
- // Double-Esc while running = cancel + jump back to edit a message (parity with double-Esc at the prompt).
1978
- if (process.stdin.isTTY) {
1979
- const renderStashBuf = () => {
1980
- if (!stashBuf) return;
1981
- const q = inputStash.length ? dim(` [${inputStash.length} queued]`) : '';
1982
- err(`\r\x1b[K${dim(' stash › ')}${stashBuf}${q}`);
1983
- };
1984
- repaintStash = renderStashBuf; // async chrome repaints the type-ahead line through this
1985
-
1986
- process.stdin.on('keypress', (_s, key) => {
1987
- if (!activeTurn) return;
1988
- if (key?.ctrl && key?.name === 'o') { toggleVerbose(); return; }
1989
- const k = key?.name;
1990
- // Cancel turn: Esc / Ctrl-C (clears stash buffer too)
1991
- const cancel = k === 'escape' || (key?.ctrl && k === 'c');
1992
- if (cancel) {
1993
- if (stashBuf) { stashBuf = ''; err('\r\x1b[K'); return; } // first Esc clears the stash line
1994
- if (!aborting) {
1995
- aborting = true; activeTurn.abort(); voiceIO?.interrupt();
1996
- err(yellow('\n ⎋ cancelling…') + dim(' (Ctrl-C again to force-quit)\n'));
1997
- // Watchdog: if the turn hasn't unwound shortly, the abort is wedged — surface the escape hatch.
1998
- setTimeout(() => {
1999
- if (activeTurn) err(red(' ⚠ still cancelling — press Ctrl-C to force-quit\n'));
2000
- }, 4000).unref?.();
2001
- } else if (key?.ctrl && k === 'c') {
2002
- err(red('\n ⏻ force-quit\n')); forceQuit(); // already cancelling + another Ctrl-C → never trapped
2003
- } else if (k === 'escape' && !pendingRewind) { pendingRewind = true; err(dim(' ⎋⎋ jumping back to edit…\n')); }
2004
- return;
2005
- }
2006
- // Stash input: type ahead while the turn runs
2007
- if (k === 'return' || k === 'enter') {
2008
- if (stashBuf.trim()) {
2009
- inputStash.push(stashBuf.trim());
2010
- err(`\r\x1b[K${green(' ✓ stashed')} ${dim(`#${inputStash.length}: ${stashBuf.trim().slice(0, 50)}${stashBuf.trim().length > 50 ? '…' : ''}`)}\n`);
2011
- }
2012
- stashBuf = '';
2013
- return;
2014
- }
2015
- if (k === 'backspace') { if (stashBuf.length) { stashBuf = stashBuf.slice(0, -1); if (stashBuf) renderStashBuf(); else err('\r\x1b[K'); } return; }
2016
- if (!key?.ctrl && !key?.meta && isPrintable(_s)) { stashBuf += _s; renderStashBuf(); return; }
2017
- });
2018
- }
2019
- const promptStr = bold(cyan('agentx › '));
2020
- const contPrompt = dim(' … › '); // `\`-continued lines
2021
- const classifyPaste = pastePathClassifier(cwd); // drag-dropped/pasted file paths → image/file attachments
2022
- const releaseStdin = () => { if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch { /* not a tty */ } } process.stdin.pause(); };
2023
-
2024
- let prefill: string | undefined; // set by double-Esc jump-back → pre-fills the next prompt
2025
- let tick = 0; // footer spinner frame counter (advances on each ticked re-render)
2026
- /** One dispatch seam for a submitted line — typed (REPL loop) and spoken (voiceIO.onUtterance)
2027
- * share it, so voice gets !cmd/#note//commands/mentions/persistence identically. Returns 'quit'
2028
- * when a builtin asked to exit. */
2029
- const dispatchLine = async (line: string): Promise<'quit' | void> => {
2030
- history.unshift(line.replace(/\n+/g, ' ⏎ ')); // make this turn ↑-recallable in-session
2031
- remember(line.replace(/\n+/g, ' ⏎ ')); // persist (one entry per line)
2032
-
2033
- // `!cmd` — run a shell command inline (no model call), like CC's bash mode.
2034
- if (line.startsWith('!')) {
2035
- const cmd = line.slice(1).trim();
2036
- if (cmd) { err(dim((await runShellLine(agent.options.fs!, cmd)) + '\n')); }
2037
- return;
2038
- }
2039
- // `#note` — jot a memory, like CC. Writes to the memory index (created if absent).
2040
- if (line.startsWith('#')) {
2041
- const note = line.slice(1).trim();
2042
- if (note) { const where = await appendMemoryNote(agent.options.fs!, primaryMemDir(agent.options.memoryDir, adot('memory')), note); err(green(` ✎ remembered → ${where}\n`)); }
2043
- return;
2044
- }
2045
-
2046
- if (line.startsWith('/')) {
2047
- const [name, ...a] = line.slice(1).split(/\s+/);
2048
- if (!name) { err(red(' / needs a command name\n') + dim(' (try /help)\n')); return; } // bare "/" or "/ "
2049
- const b = builtins[name];
2050
- if (b) { if (await b.run(a)) return 'quit'; return; }
2051
- // not a builtin → user-defined custom command?
2052
- const c = cmds.find((x) => x.name === name);
2053
- if (c) { await runCommand(c, a.join(' ')); return; }
2054
- // …or a skill? invoke it by feeding its SKILL.md (plus any args) as the turn's prompt.
2055
- const sk = skills.find((x) => x.name === name);
2056
- if (sk) { await runSkill(sk, a.join(' ')); return; }
2057
- // unknown → enumerate what IS available
2058
- const known = Object.keys(builtins).filter((k) => k !== 'quit').map((k) => '/' + k);
2059
- const custom = [...cmds.map((x) => x.name), ...skills.map((x) => x.name)].map((n) => '/' + n);
2060
- err(red(` unknown command /${name}\n`) + dim(' builtins: ' + known.join(' ') + '\n') + (custom.length ? dim(' custom: ' + custom.join(' ') + '\n') : '') + dim(' (or /help)\n'));
2061
- return;
2062
- }
2063
-
2064
- // Attach any clipboard images grabbed via /paste to this message (reuses the @ref pipeline).
2065
- const task = pendingImages.length ? `${line} ${pendingImages.map((p) => '@' + p).join(' ')}` : line;
2066
- pendingImages.length = 0;
2067
- await turn(task);
2068
- if (goalCondition && !aborting && !exitRequested) { goalTurns++; persistGoal(); await goalLoop(); }
2069
- if (exitRequested) return 'quit';
2070
- };
2071
-
2072
- // ── Voice I/O (`--voice` + SONIOX/CARTESIA keys on a TTY): spoken utterances enter the SAME
2073
- // dispatch as typed lines (the dx.send mutex serializes); the voice's text_delta stream is
2074
- // spoken via the host tap above. Missing keys → conversational text mode, one-line note.
2075
- let voicePartial = ''; // live partial transcript, rendered in the prompt footer
2076
- let partialRedraw: ReturnType<typeof setTimeout> | null = null;
2077
- // Spin VoiceIO up live (launch with --voice, or /voice mid-session). `greet` opens with a spoken
2078
- // greeting turn (launch only); a manual toggle just turns the mic on quietly. Returns true if voice
2079
- // is now live. Duplex + TTY only — bound to `toggleVoice` below so /voice can flip it off again.
2080
- const startVoice = async (greet: boolean): Promise<boolean> => {
2081
- if (voiceIO) return true;
2082
- if (!duplex || !process.stdin.isTTY) { err(dim(' (voice needs --duplex on a TTY)\n')); return false; }
2083
- if (!VoiceIO.available()) {
2084
- err(dim(' (voice I/O off — set SONIOX_API_KEY, CARTESIA_API_KEY, CARTESIA_VOICE_ID to talk)\n'));
2085
- return false;
2086
- }
2087
- voiceIO = new VoiceIO({
2088
- // No ack phrase by default: a fixed "Mm-hm," every turn reads robotic, Haiku's TTFT doesn't
2089
- // need masking (~0.7-1.2s full turns), and the conversational register already opens with a
2090
- // natural reaction. The mechanism (+ echo-leak guard) stays for slower voice models.
2091
- onState: () => editorRef?.redrawNow(),
2092
- // Throttled: each redraw clears the screen below the prompt — a partial-per-token storm
2093
- // (fast speech, or echo bleed if AEC degrades) would continuously erase streamed text.
2094
- onPartial: (text) => {
2095
- if (text === voicePartial) return; // Soniox emits frequent no-change partials — repainting on them flickers the idle prompt
2096
- voicePartial = text;
2097
- if (!partialRedraw) partialRedraw = setTimeout(() => { partialRedraw = null; editorRef?.redrawNow(); }, 250);
2098
- },
2099
- onBargeIn: (phase) => { activeTurn?.abort(); if (phase === 'speaking') err(yellow('\n ✋ interrupted\n')); },
2100
- onUtterance: (text) => {
2101
- voicePartial = '';
2102
- if (!text.trim()) return;
2103
- // Barge-in context: the cut-off reply is in the transcript but the user never HEARD its
2104
- // tail — tell the model, so it can recap what was missed instead of assuming it landed.
2105
- const cut = voiceIO!.takeInterruptedReply();
2106
- const note = cut && cut.full.length - cut.heard.length > 40
2107
- ? `\n[the user interrupted you mid-speech — they only heard up to: "…${cut.heard.slice(-80)}". Work any unheard essentials into your reply naturally, only if still relevant.]`
2108
- : '';
2109
- // Only model-bound turns open a TTS context — a `!`/`#`/`/` line produces no deltas and
2110
- // would leak an open context (state stuck in 'thinking', ack spoken into silence).
2111
- if (!/^[!#/]/.test(text.trim())) voiceIO!.beginSpeech(true); // context + micro-ack NOW (LLM deltas continue it)
2112
- err(`\r\x1b[K ${bold(cyan('🎤 ›'))} ${text}\n`);
2113
- void dispatchLine(text + note).then(async (r) => { if (r === 'quit') { await voiceIO?.awaitIdle(); editorRef?.abort(); } }).finally(() => editorRef?.redrawNow());
2114
- },
2115
- });
2116
- try {
2117
- await voiceIO.start();
2118
- err(dim(` 🎤 voice on (${voiceIO.usingAec ? 'echo-cancelled' : 'heuristic echo — headphones recommended'}) — just talk; speak over it to interrupt\n`));
2119
- if (greet) {
2120
- // Greeting: the agent makes the first turn — spoken, personalized from what it can see.
2121
- // Straight to turn() (not dispatchLine): the synthetic prompt must not enter ↑-history.
2122
- const where = cwd.split('/').pop();
2123
- const resumed = session.messages.length > 0;
2124
- void turn(
2125
- `[session started] First call QuickLook with what:"memory" — if it knows the user's name or preferences, use them. ` +
2126
- `Then greet the user warmly in one or two short sentences, as the opener of a live voice conversation. ` +
2127
- `Context: working directory "${where}"${resumed ? '; this resumes an earlier conversation — glance at it and pick up naturally' : ''}. ` +
2128
- `Personalize from whatever you learned (memory, prior conversation). Then ask what they'd like to do.`,
2129
- ).finally(() => editorRef?.redrawNow());
2130
- }
2131
- return true;
2132
- } catch (e: any) {
2133
- err(yellow(` ⚠ voice I/O failed to start: ${e?.message ?? e} — continuing text-only\n`));
2134
- voiceIO = undefined;
2135
- return false;
2136
- }
2137
- };
2138
- // Child cleanup, registered ONCE (not per start — toggling on/off must not stack listeners). They
2139
- // close over the live `voiceIO`, so they cover whichever instance is up. SIGHUP/SIGTERM (terminal
2140
- // closed, kill) bypass 'exit' handlers by default — without these the mic/player children outlive
2141
- // the CLI and hold the microphone (verified leak in PTY testing).
2142
- if (duplex && process.stdin.isTTY) {
2143
- process.on('exit', () => voiceIO?.stop());
2144
- for (const sig of ['SIGHUP', 'SIGTERM'] as const) process.on(sig, () => { voiceIO?.stop(); process.exit(0); });
2145
- }
2146
- // /voice toggle: flip the mic on or off without leaving the session (kills STT/TTS children on off).
2147
- if (duplex && process.stdin.isTTY) toggleVoice = async () => {
2148
- if (voiceIO) { voiceIO.stop(); voiceIO = undefined; voicePartial = ''; err(dim(' 🔇 voice off\n')); editorRef?.redrawNow(); return; }
2149
- await startVoice(false);
2150
- editorRef?.redrawNow();
2151
- };
2152
- // Launch with --voice: start now, with the spoken greeting.
2153
- if (args.voice && duplex && process.stdin.isTTY) await startVoice(true);
2154
-
2155
- while (true) {
2156
- // Double-Esc fired during the just-finished turn → open the jump-back picker now (turn has unwound).
2157
- if (pendingRewind) { pendingRewind = false; const t = await rewindToMessage(); if (t !== undefined) prefill = t; }
2158
- aborting = false;
2159
- const carry = stashBuf; stashBuf = ''; // type-ahead typed (not Enter'd) during the turn → carry it forward
2160
- err('\n'); // blank line before the prompt (the editor renders on one line)
2161
- // Consume the pending jump-back text (once); else fall back to un-submitted type-ahead so a line
2162
- // typed while the turn ran isn't lost — it lands editable in the fresh prompt (CC parity).
2163
- const initial = prefill ?? (carry || undefined); prefill = undefined;
2164
- // Dim status footer under the prompt (context% · cost). Constant while typing one line (transcript is
2165
- // fixed until submit), so compute once per iteration. Hidden on a fresh REPL (no usage yet) to stay clean.
2166
- const ctxTok = estimateTranscriptTokens(face.transcript); // expensive: compute once per iteration (not per keystroke)
2167
- const ctxCap = face.options.maxTokens || 200_000;
2168
- const usd = session.meta.costUsd ?? 0;
2169
- // Cheap, mutable bits (posture/reasoning/tasks) are read LIVE so Shift+Tab/Alt+T update the footer instantly.
2170
- const computeFooter = () => {
2171
- const parts: string[] = [];
2172
- if (voiceIO) {
2173
- const glyph = { listening: '🎤', thinking: '💭', speaking: '🔊', idle: '·' }[voiceIO.state];
2174
- parts.push(voicePartial && voiceIO.state === 'listening' ? `🎤 ${voicePartial.slice(-60)}` : `${glyph} ${voiceIO.state}`);
2175
- }
2176
- if (ctxTok > 400) parts.push(`${Math.round((ctxTok / ctxCap) * 100)}% ctx (~${(ctxTok / 1000).toFixed(1)}k/${Math.round(ctxCap / 1000)}k)`);
2177
- if (usd > 0) parts.push(`${session.meta.costEstimated ? '~' : ''}${fmtUsd(usd)}`);
2178
- if (posture !== 'default') parts.push(postureLabel());
2179
- const r = work.reasoning; if (r && r !== 'off') parts.push(`reasoning:${r}`);
2180
- if (verboseOutput) parts.push('verbose');
2181
- if (goalCondition) parts.push(`◎ goal (${goalTurns} turns)`);
2182
- if (inputStash.length) parts.push(`${inputStash.length} stashed (⌃S to pop)`);
2183
- // Running background tasks: one STACKED line each, pinned to the prompt block, with an animated
2184
- // spinner frame (statusTickMs re-renders this footer every second) — a slow worker is visibly
2185
- // alive and visibly THERE. Finished tasks drop out (their ⦿ result lands in the flow).
2186
- const taskLines: string[] = [];
2187
- if (dx) {
2188
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2189
- for (const t of dx.tasks.values()) if (t.status === 'running') taskLines.push(`◔ ${frames[tick % frames.length]} ${t.id} ${t.label} — working in background…`);
2190
- if (taskLines.length) tick++;
2191
- }
2192
- return [...taskLines, parts.join(' · ')].filter(Boolean).join('\n');
2193
- };
2194
- // LIVE prompt: while a worker question is pending, the prompt itself becomes the question
2195
- // (CC-parity for permission asks) — duplexAsk's redrawNow() flips it the moment an ask parks.
2196
- const livePrompt = () => {
2197
- const ask = dx?.pendingAsks.size ? dx.pendingAsks.values().next().value : undefined;
2198
- if (!ask) return promptStr;
2199
- const q = ask.question.replace(/\s+/g, ' ').slice(0, 64);
2200
- return bold(yellow(`? ${q}${ask.question.length > 64 ? '…' : ''} ‹yes/no› `));
2201
- };
2202
- const result = await readMultiline((cont) => editor.readLine({
2203
- prompt: cont ? contPrompt : livePrompt, suggest, history, classifyPaste, onEmptyPaste: grabClipboardAttachment,
2204
- initial: cont ? undefined : initial, status: computeFooter, vimMode: cfg.editorMode === 'vim',
2205
- statusTickMs: dx ? 1000 : undefined, // duplex: animate the running-task footer while idle at the prompt
2206
- onCyclePosture: cyclePosture,
2207
- onToggleThinking: toggleReasoning,
2208
- onToggleVerbose: toggleVerbose,
2209
- onPickModel: async () => { const picked = await pickModel(work.model); if (picked) setModel(picked); return picked; },
2210
- onStash: (text) => { inputStash.push(text); err(`${green(' ✓ stashed')} ${dim(`#${inputStash.length}: ${text.slice(0, 50)}${text.length > 50 ? '…' : ''}`)}\n`); },
2211
- onUnstash: () => { if (!inputStash.length) return undefined; const t = inputStash.pop()!; err(dim(` ↑ unstashed${inputStash.length ? ` (${inputStash.length} left)` : ''}\n`)); return t; },
2212
- }));
2213
- if (result === null) break; // Ctrl-D / Ctrl-C at an empty prompt → exit
2214
- if (result === REWIND) { prefill = await rewindToMessage(); continue; } // double-Esc → jump-back picker
2215
- const line = result.trim();
2216
- if (!line) continue;
2217
- // Typed answer to a pending worker question: resolve it DIRECTLY (deterministic, no LLM hop) —
2218
- // same UX as approving in a normal session. Anything else falls through to the conversation.
2219
- if (dx?.pendingAsks.size && /^(y(es|ep|eah)?|n(o|ope)?|sure|ok(ay)?|allow|deny|go( ahead)?)[.!]?$/i.test(line)) {
2220
- const [id, ask] = dx.pendingAsks.entries().next().value!;
2221
- ask.resolve(line);
2222
- err(dim(` ↳ answered ${id}: ${line}\n`));
2223
- continue;
2224
- }
2225
- let quit = await dispatchLine(line) === 'quit';
2226
- // Drain stashed input (typed while the turn was running)
2227
- while (!quit && inputStash.length) {
2228
- const next = inputStash.shift()!;
2229
- err(dim(` ⏎ stashed › ${next.slice(0, 60)}${next.length > 60 ? '…' : ''}\n`));
2230
- quit = await dispatchLine(next) === 'quit';
2231
- }
2232
- if (quit) break;
2233
- }
2234
- voiceIO?.stop(); // kill mic/player children + close STT/TTS sockets (they outlive the process otherwise)
2235
- // Duplex: let in-flight workers finish (their re-voice persists the transcript) before tearing down.
2236
- // ExitSession: the user said goodbye — kill workers immediately instead of draining.
2237
- if (dx) {
2238
- const running = [...dx.tasks.values()].filter((t) => t.status === 'running');
2239
- if (exitRequested && running.length) {
2240
- for (const t of running) { t.status = 'cancelled'; t.controller.abort(); }
2241
- err(dim(` … cancelled ${running.length} background task(s)\n`));
2242
- } else if (running.length) {
2243
- err(dim(` … waiting for ${running.length} background task(s) (Ctrl-C to force quit)\n`));
2244
- // stdin is still in raw mode here, so Ctrl-C arrives as a 0x03 byte (no SIGINT).
2245
- // Race the drain against a raw Ctrl-C: on press, abort all workers and bail.
2246
- let forced = false;
2247
- let onCtrlC = () => {};
2248
- const onByte = (b: Buffer) => {
2249
- if (!b.includes(0x03)) return; // Ctrl-C
2250
- forced = true;
2251
- for (const t of running) { t.status = 'cancelled'; t.controller.abort(); }
2252
- err(dim(`\n … force-quit — cancelled ${running.length} background task(s)\n`));
2253
- onCtrlC();
2254
- };
2255
- process.stdin.on('data', onByte);
2256
- await Promise.race([dx.idle(), new Promise<void>((res) => { onCtrlC = res; })]);
2257
- process.stdin.off('data', onByte);
2258
- if (forced) {
2259
- // User force-quit: tear down and hard-exit — don't trust the event loop to drain
2260
- // (voice children / sockets / MCP handles can keep the process alive otherwise).
2261
- voiceIO?.stop();
2262
- releaseStdin();
2263
- disposeCursorSessions();
2264
- await closeMcp(mounted);
2265
- process.exit(130);
2266
- }
2267
- (face.options.host as { flushText?: () => void } | undefined)?.flushText?.();
2268
- duplexPersist();
2269
- }
2270
- }
2271
- releaseStdin();
2272
- disposeCursorSessions(); // reap any warm cursor helper (its stdout pipe would otherwise keep the loop alive → hang on exit)
2273
- await closeMcp(mounted);
2274
- }
2275
-
2276
- /** Read all of stdin to EOF (for `echo "prompt" | agentx -p`). */
2277
- function readAllStdin(): Promise<string> {
2278
- return new Promise((res) => {
2279
- let data = '';
2280
- process.stdin.setEncoding('utf8');
2281
- process.stdin.on('data', (c) => (data += c));
2282
- process.stdin.on('end', () => res(data));
2283
- process.stdin.resume();
2284
- });
2285
- }
2286
-
2287
- async function main() {
2288
- const args = parseArgs(process.argv.slice(2));
2289
- if (args.version) { console.log('agentx ' + VERSION); return; }
2290
- if (args.help) { console.log(HELP); return; }
2291
- if (args.debug) process.env.DEBUG ||= '*'; // --debug/--verbose → enable gated debug/verbose logs (libx forComponent)
2292
- // bare `-p` with piped input → read the whole prompt from stdin (CC parity for `echo "…" | agentx -p`)
2293
- if (args.print && !args.task && !process.stdin.isTTY) args.task = (await readAllStdin()).trim();
2294
- const cwd = resolve(args.cwd ?? process.cwd());
2295
- const cfg = await loadConfig(cwd);
2296
- // fold in rules the user "Always allowed/denied" in earlier sessions (.agent/permissions.json)
2297
- // fold in CC-compatible .claude/settings.json rules, our persisted "Always" rules, and the config block
2298
- // (all merged — deny>allow>ask is decided per-call, not per-source, so concatenation is correct).
2299
- cfg.permissions = mergePerms(loadClaudeSettings(cwd), mergePerms(loadPersistedRules(cwd), cfg.permissions));
2300
- // directory-trust gate (CC-style): on first interactive use of a new dir, confirm once.
2301
- if (canPrompt && !args.yes && !isTrusted(cwd)) {
2302
- const ok = await makeHost().confirm?.(`Trust this directory? agent.libx will read and edit files in:\n ${cwd}`);
2303
- if (!ok) { console.error(red(' not trusted — exiting (re-run with --yes to skip this prompt).')); process.exit(1); }
2304
- trustDir(cwd);
2305
- }
2306
- // bare `--resume`/`-r` (no id) → interactive session picker, like CC (needs a TTY + an id when piped).
2307
- if (args.resume === '') {
2308
- const picker = new SessionStore(cwd).list();
2309
- if (!picker.length) { console.error(yellow(' no saved sessions in this directory.')); process.exit(1); }
2310
- if (!canPrompt) { console.error(red(' --resume needs an id when not interactive: --resume <id> (latest: ' + picker[0].id + ')')); process.exit(1); }
2311
- const id = await selectMenu(process.stderr, { title: 'Resume a session', items: picker.slice(0, 50).map((m) => ({ label: `${m.id} ${m.title || '(untitled)'}`, value: m.id, desc: `${m.turns} turn${m.turns === 1 ? '' : 's'}` })) });
2312
- if (!id) process.exit(0); // cancelled
2313
- args.resume = id;
2314
- }
2315
- loadInstallEnv(); // fallback: pick up keys from agentx's own .env when launched elsewhere (won't override CWD/shell)
2316
- const apiKeys = { ...cfg.apiKeys, ...apiKeysFromEnv() }; // env wins
2317
- if (!Object.keys(apiKeys).length) {
2318
- console.error(red('No provider key found. Set ANTHROPIC_API_KEY (or OPENAI_API_KEY / GOOGLE_API_KEY / GROQ_API_KEY), e.g. in .env.'));
2319
- process.exit(1);
2320
- }
2321
- // AIClient is the concrete impl of the runtime's `ChatLike` seam (the CLI only needs that seam).
2322
- // retry: transient 429/5xx blips auto-recover with backoff; surface it so a pause isn't a mystery.
2323
- const ai = new AIClient({
2324
- apiKeys,
2325
- ...(cfg.baseUrls ? { baseUrls: cfg.baseUrls } : {}),
2326
- retry: {
2327
- onRetry: ({ attempt, delayMs, error }: { attempt: number; delayMs: number; error: any }) => {
2328
- const why = error?.name === 'RateLimitError' || error?.status === 429 ? 'rate-limited' : 'service busy';
2329
- err(yellow(`\n ⟳ ${why} — retrying (#${attempt}) in ~${Math.max(1, Math.round(delayMs / 1000))}s…\n`));
2330
- },
2331
- },
2332
- }) as unknown as ChatLike;
2333
-
2334
- if (args.task) {
2335
- // one-shot / headless — still persisted so it can be --continue'd later. OAuth refresh works here
2336
- // (no browser); only attended registration needs the REPL `/mcp login`.
2337
- const mounted = await mountMcp(cfg, new McpOAuth({ storePath: join(cwd, '.agent', 'mcp-auth.json') }));
2338
- const agent = await makeAgent(args, ai, cfg, mounted.flatMap((m) => m.tools));
2339
- const store = new SessionStore(cwd);
2340
- const session = startSession(args, store, agent, cwd);
2341
- process.once('SIGINT', () => activeTurn?.abort()); // Ctrl-C cancels the run; it still persists + exits cleanly
2342
- const { ok, res } = await runTurn(agent, store, session, args.task, undefined, cwd);
2343
- // opt-in: on a bad outcome, reflect once and persist a novel lesson for next session
2344
- if (cfg.reflectOnFailure && !ok && res && agent.options.memoryDir) {
2345
- const _fsBase = agent.options.fs!.getCwd() === '/' ? '' : agent.options.fs!.getCwd();
2346
- const slug = await reflectOnRun({ ai, model: agent.options.model, fs: agent.options.fs!, dir: primaryMemDir(agent.options.memoryDir, `${_fsBase}/.agent/memory`), result: res });
2347
- if (slug) err(dim(` ✎ learned a lesson → ${slug}\n`));
2348
- }
2349
- await closeMcp(mounted); // kill MCP child processes after the run (and any reflection)
2350
- if (args.outputFormat === 'json' || args.outputFormat === 'stream-json') {
2351
- const obj = res ? jsonResult(res, session) : { ok: false, finishReason: 'error', sessionId: session.meta.id };
2352
- // json → the single result object; stream-json → a final {type:'result',…} line closing the NDJSON stream
2353
- process.stdout.write(JSON.stringify(args.outputFormat === 'stream-json' ? { type: 'result', ...obj } : obj) + '\n');
2354
- }
2355
- process.exit(ok ? 0 : 1);
2356
- }
2357
-
2358
- await repl(args, ai, cfg, cwd); // --duplex is a mode of the same REPL (fast voice + delegated workers)
2359
- }
2360
-
2361
- // Only auto-run when invoked as the entry point — keeps the module importable in tests.
2362
- if (import.meta.main) main().catch((e) => { console.error(e?.message ?? e); process.exit(1); });