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.
- package/README.md +46 -10
- package/package.json +2 -2
- 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); });
|