@nguyenhuumanh/vyen 0.2.23 → 0.3.0
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 +303 -56
- package/bin/vyen.mjs +12 -2
- package/dist/approval.d.ts +12 -0
- package/dist/approval.js +93 -0
- package/dist/approval.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +741 -371
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +11 -6
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +25 -2
- package/dist/context.js +197 -19
- package/dist/context.js.map +1 -1
- package/dist/files.d.ts +40 -3
- package/dist/files.js +192 -56
- package/dist/files.js.map +1 -1
- package/dist/harness.d.ts +38 -2
- package/dist/harness.js +180 -10
- package/dist/harness.js.map +1 -1
- package/dist/help.d.ts +1 -0
- package/dist/help.js +49 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +11 -0
- package/dist/mcp.js +313 -236
- package/dist/mcp.js.map +1 -1
- package/dist/process.d.ts +2 -0
- package/dist/process.js +18 -1
- package/dist/process.js.map +1 -1
- package/dist/prompts.d.ts +23 -0
- package/dist/prompts.js +69 -0
- package/dist/prompts.js.map +1 -0
- package/dist/provider.d.ts +7 -2
- package/dist/provider.js +45 -12
- package/dist/provider.js.map +1 -1
- package/dist/session.d.ts +69 -1
- package/dist/session.js +390 -31
- package/dist/session.js.map +1 -1
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +43 -21
- package/dist/setup.js.map +1 -1
- package/dist/subagents.js +9 -4
- package/dist/subagents.js.map +1 -1
- package/dist/tools.d.ts +6 -1
- package/dist/tools.js +163 -21
- package/dist/tools.js.map +1 -1
- package/dist/tui.d.ts +61 -18
- package/dist/tui.js +511 -225
- package/dist/tui.js.map +1 -1
- package/docs/AUDIT.md +54 -0
- package/docs/MCP.md +5 -3
- package/docs/SPEC.md +4 -8
- package/docs/VALIDATION.md +47 -40
- package/docs/superpowers/plans/2026-10-21-vyen-brand-rename.md +876 -0
- package/docs/superpowers/plans/2026-10-21-vyen-critical-fixes.md +987 -0
- package/docs/superpowers/plans/2026-10-22-agent-work-agreements.md +124 -0
- package/docs/superpowers/plans/2026-10-22-upstream-merge.md +256 -0
- package/npm-shrinkwrap.json +59 -95
- package/package.json +8 -20
- package/scripts/pack-release.mjs +4 -20
- package/scripts/publish-release.mjs +1 -1
- package/src/approval.ts +86 -0
- package/src/cli.ts +702 -378
- package/src/config.ts +11 -6
- package/src/context.ts +220 -18
- package/src/files.ts +194 -68
- package/src/harness.ts +178 -11
- package/src/help.ts +49 -0
- package/src/index.ts +1 -1
- package/src/mcp.ts +328 -242
- package/src/process.ts +16 -1
- package/src/prompts.ts +74 -0
- package/src/provider.ts +44 -13
- package/src/session.ts +380 -34
- package/src/setup.ts +38 -23
- package/src/subagents.ts +9 -4
- package/src/tools.ts +174 -20
- package/src/tui.ts +545 -231
package/dist/cli.js
CHANGED
|
@@ -1,76 +1,31 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { createInterface } from 'node:readline/promises';
|
|
3
3
|
import { stdin, stdout, stderr } from 'node:process';
|
|
4
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { existsSync, fstatSync, readFileSync, statSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
|
-
import { DEFAULT_GATEWAY_URL, EFFORT_LEVELS, configPath, dataDir, legacyConfigDir, legacyDataDir,
|
|
6
|
+
import { DEFAULT_GATEWAY_URL, EFFORT_LEVELS, configPath, configDir, dataDir, legacyConfigDir, legacyDataDir, loadConfig, normalizeGatewayUrl, resolveApiKey, resolveRuntime, saveConfig, } from './config.js';
|
|
7
7
|
import { safeError } from './errors.js';
|
|
8
8
|
import { VERSION } from './version.js';
|
|
9
|
-
import {
|
|
9
|
+
import { HELP } from './help.js';
|
|
10
|
+
import { InputCancelledError, terminalText as cleanTerminal, TerminalUI } from './tui.js';
|
|
10
11
|
import { EFFORT_DESCRIPTIONS, parseEffortInput, runSetupWizard } from './setup.js';
|
|
11
12
|
import { discoverModels, formatModelBadge, resolveModelCapabilities } from './provider.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
vyen doctor | sessions
|
|
19
|
-
vyen mcp list | check [name] | get <name> | add <name> ... | remove <name> | import-codex [name...]
|
|
20
|
-
|
|
21
|
-
Options:
|
|
22
|
-
-p, --prompt TEXT Run one task (or pipe a prompt on stdin)
|
|
23
|
-
-C, --cwd DIRECTORY Workspace (defaults to current directory)
|
|
24
|
-
--provider NAME openai-compatible | openai-responses | anthropic
|
|
25
|
-
--base-url URL Provider API root, usually ending in /v1
|
|
26
|
-
--model NAME Exact model ID from your provider
|
|
27
|
-
--effort LEVEL Reasoning effort: low | medium | high | xhigh | max
|
|
28
|
-
--key-env NAME Credential variable (default VYEN_API_KEY)
|
|
29
|
-
--resume ID|latest Resume a saved session in this workspace
|
|
30
|
-
--yes Approve file changes and shell commands automatically
|
|
31
|
-
--read-only Read tools and research agents; no writes or shell
|
|
32
|
-
--json Emit NDJSON events instead of terminal output
|
|
33
|
-
--no-tui Use plain readline instead of the full-screen UI
|
|
34
|
-
--no-mcp Disable configured MCP servers for this run
|
|
35
|
-
--no-subagents Disable delegation tools for this run
|
|
36
|
-
--inline-reasoning MODE leading-think (default) | off
|
|
37
|
-
--show-reasoning Separate reasoning events / labeled stderr output
|
|
38
|
-
--context-window N Actual model context window (default 128000)
|
|
39
|
-
--max-output-tokens N Per-request output limit (default 16384)
|
|
40
|
-
--run-token-budget N Run token budget (default 0 = unlimited)
|
|
41
|
-
--timeout-ms N Model request deadline (default 300000)
|
|
42
|
-
-h, --help Show help
|
|
43
|
-
-v, --version Show version
|
|
44
|
-
|
|
45
|
-
Interactive commands:
|
|
46
|
-
/help, /setup, /model, /effort, /mode, /auto, /view, /transcript, /copy, /btw,
|
|
47
|
-
/goal, /recap, /skills, /memories, /presets, /mention, /cd, /pwd, /mcp, /subagents,
|
|
48
|
-
/plan, /revert, /undo, /checkpoints, /todo, /compact, /diff, /review, /fork, /rename,
|
|
49
|
-
/delete, /export, /init, /status, /tools, /sessions, /debug-config, /warnings, /doctor, /clear, /exit, or !<cmd>.
|
|
50
|
-
Shortcuts: Shift+Tab mode, Ctrl+T transcript, Ctrl+O copy, Alt+. / Alt+, effort, Tab autocomplete, Esc Esc rewind, Ctrl+C cancel.
|
|
51
|
-
API keys are read from environment variables or ~/.config/vyen/credentials.json.
|
|
52
|
-
Shell commands run as your OS user; approvals are not an OS sandbox.
|
|
53
|
-
`;
|
|
13
|
+
import { bashApprovalPrefix, isApprovedBashPrefix } from './approval.js';
|
|
14
|
+
/**
|
|
15
|
+
* Welcome lines shown when a session opens. Plain text (it is also printed in readline mode); the
|
|
16
|
+
* TUI recognises the `◆ vyen v…` title and styles the block quietly. No box: lines wrap freely at
|
|
17
|
+
* any width, so long model IDs and paths never break an outline.
|
|
18
|
+
*/
|
|
54
19
|
export function welcomeBanner(model, provider, baseUrl, effort, cwd, sessionId, mode) {
|
|
55
|
-
const inner = 67;
|
|
56
|
-
const fit = (text, cols) => visibleLength(text) <= cols ? padEndVisible(text, cols) : padEndVisible(`${sliceVisible(text, cols - 1)}…`, cols);
|
|
57
|
-
const line = (content) => `│${fit(content, inner)}│`;
|
|
58
|
-
const field = (label, value) => line(` ${label.padEnd(10)} ${value}`);
|
|
59
|
-
const left = ` ◆ vyen v${VERSION}`;
|
|
60
|
-
const right = `${mode === 'auto' ? 'auto-approve' : mode} · effort ${effort ?? 'default'} `;
|
|
61
|
-
const gap = Math.max(1, inner - visibleLength(left) - visibleLength(right));
|
|
62
20
|
const host = baseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
|
21
|
+
const home = process.env.HOME;
|
|
22
|
+
const workspace = home && cwd.startsWith(`${home}/`) ? `~${cwd.slice(home.length)}` : cwd;
|
|
63
23
|
return [
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
field('workspace', cwd),
|
|
70
|
-
field('session', sessionId.slice(0, 8)),
|
|
71
|
-
line(''),
|
|
72
|
-
line(' /help commands · @ files · $ skills · ! shell · shift+tab mode'),
|
|
73
|
-
`╰${'─'.repeat(inner)}╯`,
|
|
24
|
+
`◆ vyen v${VERSION}`,
|
|
25
|
+
` model ${model} effort ${effort ?? 'default'} mode ${mode === 'auto' ? 'auto-approve' : mode}`,
|
|
26
|
+
` gateway ${host} (${provider})`,
|
|
27
|
+
` workspace ${workspace} session ${sessionId.slice(0, 8)}`,
|
|
28
|
+
' /help commands @ files $ skills ! shell shift+tab mode',
|
|
74
29
|
'',
|
|
75
30
|
].join('\n');
|
|
76
31
|
}
|
|
@@ -105,6 +60,13 @@ export function readGitBranch(cwd) {
|
|
|
105
60
|
}
|
|
106
61
|
return undefined;
|
|
107
62
|
}
|
|
63
|
+
/** Short folder name for lists: the last path segment (home shown as ~). */
|
|
64
|
+
function folderLabel(cwd) {
|
|
65
|
+
const clean = cwd.replace(/[\\/]+$/, '');
|
|
66
|
+
if (process.env.HOME && clean === process.env.HOME)
|
|
67
|
+
return '~';
|
|
68
|
+
return clean.split(/[\\/]/).pop() || clean;
|
|
69
|
+
}
|
|
108
70
|
function formatRelativeTime(isoDate) {
|
|
109
71
|
const ts = Date.parse(isoDate);
|
|
110
72
|
if (!Number.isFinite(ts))
|
|
@@ -162,31 +124,120 @@ export function extractCopyableFromMessages(messages, selector = '') {
|
|
|
162
124
|
}
|
|
163
125
|
return { text: assistantOutputs[assistantOutputs.length - 1], label: 'last response' };
|
|
164
126
|
}
|
|
127
|
+
/** Clipboard helpers in preference order for the current platform (Termux, Wayland, X11, macOS, Windows). */
|
|
128
|
+
export function clipboardCandidates(platform = process.platform, env = process.env) {
|
|
129
|
+
if (platform === 'win32') {
|
|
130
|
+
return [{ bin: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', '[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())'] }];
|
|
131
|
+
}
|
|
132
|
+
if (platform === 'darwin')
|
|
133
|
+
return [{ bin: 'pbcopy', args: [] }];
|
|
134
|
+
const candidates = [];
|
|
135
|
+
if (env.TERMUX_VERSION || env.PREFIX?.includes('com.termux'))
|
|
136
|
+
candidates.push({ bin: 'termux-clipboard-set', args: [] });
|
|
137
|
+
if (env.WAYLAND_DISPLAY)
|
|
138
|
+
candidates.push({ bin: 'wl-copy', args: [] });
|
|
139
|
+
candidates.push({ bin: 'xclip', args: ['-selection', 'clipboard'] }, { bin: 'xsel', args: ['--clipboard', '--input'] });
|
|
140
|
+
return candidates;
|
|
141
|
+
}
|
|
165
142
|
async function copyTextToClipboard(text) {
|
|
166
143
|
let copied = false;
|
|
167
144
|
if (stdout.isTTY && text.length <= 100_000) {
|
|
145
|
+
// OSC 52 works over SSH and in most modern terminals, even without a clipboard helper.
|
|
168
146
|
const b64 = Buffer.from(text, 'utf8').toString('base64');
|
|
169
147
|
stdout.write(`\x1b]52;c;${b64}\x07`);
|
|
170
148
|
copied = true;
|
|
171
149
|
}
|
|
150
|
+
const { spawn } = await import('node:child_process');
|
|
151
|
+
const { childEnv } = await import('./process.js');
|
|
152
|
+
for (const cmd of clipboardCandidates()) {
|
|
153
|
+
try {
|
|
154
|
+
await new Promise((resolve, reject) => {
|
|
155
|
+
const child = spawn(cmd.bin, cmd.args, { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true, env: childEnv() });
|
|
156
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error('clipboard helper timed out')); }, 3000);
|
|
157
|
+
child.on('error', error => { clearTimeout(timer); reject(error); });
|
|
158
|
+
child.on('close', code => { clearTimeout(timer); if (code === 0)
|
|
159
|
+
resolve();
|
|
160
|
+
else
|
|
161
|
+
reject(new Error(`exit ${code}`)); });
|
|
162
|
+
child.stdin.on('error', () => { });
|
|
163
|
+
child.stdin.end(text, 'utf8');
|
|
164
|
+
});
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// try the next helper
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return copied;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Piped data is read only from a real pipe or file (`cmd | vyen -p ...`, `vyen -p ... < file`).
|
|
175
|
+
* Sockets and inherited descriptors that never close (IDE/agent spawns, CI runners) are ignored so
|
|
176
|
+
* `-p` can never hang waiting for an EOF that does not come.
|
|
177
|
+
*/
|
|
178
|
+
function pipedStdin() {
|
|
179
|
+
if (stdin.isTTY)
|
|
180
|
+
return false;
|
|
172
181
|
try {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
const info = fstatSync(0);
|
|
183
|
+
return info.isFIFO() || info.isFile();
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function readStdinLimited(limit) {
|
|
190
|
+
let bytes = 0;
|
|
191
|
+
const chunks = [];
|
|
192
|
+
for await (const chunk of stdin) {
|
|
193
|
+
const buffer = Buffer.from(chunk);
|
|
194
|
+
bytes += buffer.length;
|
|
195
|
+
if (bytes > limit)
|
|
196
|
+
throw new Error(`Piped input exceeds ${Math.round(limit / 1024)} KiB.`);
|
|
197
|
+
chunks.push(buffer);
|
|
198
|
+
}
|
|
199
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
200
|
+
}
|
|
201
|
+
/** Environment problems `vyen doctor` reports in addition to its pass/fail checks. */
|
|
202
|
+
async function doctorWarnings(config, baseUrl, modelIds) {
|
|
203
|
+
const warnings = [];
|
|
204
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
205
|
+
if (major < 22 || (major === 22 && minor < 16))
|
|
206
|
+
warnings.push(`Node ${process.version} is older than the supported 22.16.`);
|
|
207
|
+
const insecure = insecureBaseUrlWarning(baseUrl);
|
|
208
|
+
if (insecure)
|
|
209
|
+
warnings.push(insecure);
|
|
210
|
+
if (config.model && modelIds.length > 0 && !modelIds.includes(config.model)) {
|
|
211
|
+
warnings.push(`Model "${config.model}" is not listed by ${baseUrl}/models.`);
|
|
212
|
+
}
|
|
213
|
+
if (process.platform !== 'win32') {
|
|
214
|
+
const { credentialsPath } = await import('./config.js');
|
|
215
|
+
try {
|
|
216
|
+
const mode = statSync(credentialsPath()).mode & 0o777;
|
|
217
|
+
if (mode & 0o077)
|
|
218
|
+
warnings.push(`${credentialsPath()} is readable by other users (mode ${mode.toString(8)}); run: chmod 600 "${credentialsPath()}"`);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// no stored credentials
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return warnings;
|
|
225
|
+
}
|
|
226
|
+
/** API keys sent over plain HTTP are readable by anyone on the network path (except loopback). */
|
|
227
|
+
export function insecureBaseUrlWarning(baseUrl) {
|
|
228
|
+
if (!baseUrl)
|
|
229
|
+
return undefined;
|
|
230
|
+
try {
|
|
231
|
+
const url = new URL(baseUrl);
|
|
232
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(url.hostname) || url.hostname.endsWith('.localhost');
|
|
233
|
+
if (url.protocol === 'http:' && !loopback) {
|
|
234
|
+
return `Warning: ${url.host} uses plain http; your API key is sent unencrypted. Use https:// unless this is a trusted local network.`;
|
|
235
|
+
}
|
|
186
236
|
}
|
|
187
237
|
catch {
|
|
188
|
-
|
|
238
|
+
// invalid URLs are rejected by config validation
|
|
189
239
|
}
|
|
240
|
+
return undefined;
|
|
190
241
|
}
|
|
191
242
|
export async function main(argv = process.argv.slice(2)) {
|
|
192
243
|
let apiKey;
|
|
@@ -263,6 +314,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
263
314
|
'inline-reasoning': { type: 'string' }, 'show-reasoning': { type: 'boolean' },
|
|
264
315
|
'context-window': { type: 'string' }, 'max-output-tokens': { type: 'string' },
|
|
265
316
|
'run-token-budget': { type: 'string' }, 'timeout-ms': { type: 'string' },
|
|
317
|
+
'output-last-message': { type: 'string', short: 'o' },
|
|
266
318
|
} });
|
|
267
319
|
jsonOutput = values.json === true;
|
|
268
320
|
if (values.help) {
|
|
@@ -292,10 +344,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
292
344
|
dataDir: dataDir(),
|
|
293
345
|
});
|
|
294
346
|
if (migration.error) {
|
|
295
|
-
stderr.write(`vyen: could not migrate the legacy goon data (${migration.error}). Copy ${legacyConfigDir()} to ${configDir()} manually
|
|
347
|
+
stderr.write(`vyen: could not migrate the legacy goon data (${migration.error}). Copy ${legacyConfigDir()} to ${configDir()} manually.\\n`);
|
|
296
348
|
}
|
|
297
349
|
else if (migration.copied.length) {
|
|
298
|
-
stderr.write(`vyen: migrated ${migration.copied.length} entries from ${legacyConfigDir()} to ${configDir()}: ${migration.copied.join(', ')}
|
|
350
|
+
stderr.write(`vyen: migrated ${migration.copied.length} entries from ${legacyConfigDir()} to ${configDir()}: ${migration.copied.join(', ')}\\n`);
|
|
299
351
|
}
|
|
300
352
|
let config = await loadConfig(overrides);
|
|
301
353
|
if (positionals[0] === 'mcp') {
|
|
@@ -383,16 +435,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
383
435
|
const discovered = await discoverModels(targetUrl, resolvedKey ?? '', config.provider);
|
|
384
436
|
const modelIds = discovered.map((m) => m.id);
|
|
385
437
|
const modelAvailable = config.model ? (modelIds.length === 0 || modelIds.includes(config.model)) : false;
|
|
438
|
+
const warnings = await doctorWarnings(config, targetUrl, modelIds);
|
|
386
439
|
stdout.write(JSON.stringify({ version: VERSION, node: process.version, platform: process.platform, arch: process.arch,
|
|
387
440
|
config: configPath(), provider: config.provider, model: config.model ?? null, effort: config.effort ?? 'default',
|
|
388
441
|
baseUrl: config.baseUrl ?? 'provider default', credentialVariable: config.keyEnv, credentialPresent: Boolean(resolvedKey),
|
|
389
|
-
bash, ripgrep: rg, searchFallback: !rg, data: dataDir(), networkTested: true,
|
|
390
|
-
discoveredModels: modelIds, modelAvailable }, null, 2) + '\n');
|
|
442
|
+
bash, ripgrep: rg, searchFallback: !rg, data: dataDir(), networkTested: true, gatewayReachable: modelIds.length > 0,
|
|
443
|
+
discoveredModels: modelIds, modelAvailable, warnings }, null, 2) + '\n');
|
|
391
444
|
if (!bash || !config.model || !resolvedKey)
|
|
392
445
|
process.exitCode = 1;
|
|
393
446
|
return;
|
|
394
447
|
}
|
|
395
|
-
const { SessionStore, sameWorkspace, retargetSessionWorkspace } = await import('./session.js');
|
|
448
|
+
const { SessionStore, sameWorkspace, GOAL_USAGE, goalStatusLabel, formatGoalUsageSummary, formatGoalStatusIndicator, formatGoalSummaryLines, parseGoalDraftArgs, retargetSessionWorkspace, } = await import('./session.js');
|
|
396
449
|
if (positionals[0] === 'sessions') {
|
|
397
450
|
const list = await SessionStore.list(join(dataDir(), 'sessions'));
|
|
398
451
|
stdout.write(values.json ? JSON.stringify(list) + '\n' : list.map(s => `${s.id} ${s.updatedAt} ${s.model}${s.title ? ` [${s.title}]` : ''} ${s.cwd}`).join('\n') + '\n');
|
|
@@ -415,6 +468,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
415
468
|
if (values.prompt && promptPositionals.length)
|
|
416
469
|
throw new Error('Use either --prompt or positional prompt text.');
|
|
417
470
|
let prompt = values.prompt ?? (promptPositionals.length ? promptPositionals.join(' ') : undefined);
|
|
471
|
+
if (prompt && pipedStdin()) {
|
|
472
|
+
// `git diff | vyen -p "review"` / `vyen -p "explain" < log`: attach piped data as context.
|
|
473
|
+
const piped = await readStdinLimited(1024 * 1024);
|
|
474
|
+
if (piped.trim())
|
|
475
|
+
prompt = `${prompt}\n\n<stdin>\n${piped.replace(/\s+$/, '')}\n</stdin>`;
|
|
476
|
+
}
|
|
418
477
|
if (!prompt && !stdin.isTTY) {
|
|
419
478
|
let bytes = 0;
|
|
420
479
|
const chunks = [];
|
|
@@ -434,24 +493,48 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
434
493
|
config = await runSetupWizard(config);
|
|
435
494
|
}
|
|
436
495
|
const interactive = !prompt;
|
|
496
|
+
const outputLastMessage = typeof values['output-last-message'] === 'string' ? values['output-last-message'] : undefined;
|
|
497
|
+
if (outputLastMessage && interactive)
|
|
498
|
+
throw new Error('--output-last-message requires a prompt (-p or piped input).');
|
|
437
499
|
let viewMode = config.viewMode ?? (config.showReasoning ? 'verbose' : 'normal');
|
|
438
500
|
if (interactive && !jsonOutput) {
|
|
439
501
|
config.showReasoning = true;
|
|
440
502
|
}
|
|
441
503
|
let runtime = await resolveRuntime(config, values.cwd ?? process.cwd());
|
|
442
504
|
apiKey = runtime.apiKey;
|
|
505
|
+
const insecureWarning = insecureBaseUrlWarning(runtime.baseUrl);
|
|
506
|
+
if (insecureWarning && !jsonOutput)
|
|
507
|
+
stderr.write(`vyen: ${insecureWarning}\n`);
|
|
443
508
|
const sessionsRootDir = join(runtime.dataDir, 'sessions');
|
|
444
509
|
if (launchResumePicker && !resumeTarget) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
510
|
+
// `vyen resume` works from any folder: with one saved session it resumes it directly,
|
|
511
|
+
// otherwise the picker opens (This folder / All projects) on a fresh, discardable session.
|
|
512
|
+
const resumable = (await SessionStore.list(sessionsRootDir)).filter(s => (s.messagesCount ?? 0) > 0 || Boolean(s.title));
|
|
513
|
+
if (resumable.length === 1) {
|
|
514
|
+
resumeTarget = resumable[0].id;
|
|
515
|
+
launchResumePicker = false;
|
|
516
|
+
}
|
|
517
|
+
else if (!resumable.length) {
|
|
518
|
+
launchResumePicker = false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (resumeTarget && resumeTarget !== 'latest' && !values.cwd) {
|
|
522
|
+
// Resuming by ID from another folder switches to that session's workspace instead of failing
|
|
523
|
+
// with "belongs to a different workspace" (an explicit --cwd still wins).
|
|
524
|
+
const all = await SessionStore.list(sessionsRootDir);
|
|
525
|
+
const wanted = resumeTarget.toLowerCase();
|
|
526
|
+
const isIndex = /^\d{1,3}$/.test(wanted);
|
|
527
|
+
const match = all.find(s => s.id.toLowerCase() === wanted)
|
|
528
|
+
?? (!isIndex && wanted.length >= 4
|
|
529
|
+
? all.find(s => sameWorkspace(s.cwd, runtime.cwd) && s.id.toLowerCase().startsWith(wanted)) ?? all.find(s => s.id.toLowerCase().startsWith(wanted))
|
|
530
|
+
: undefined);
|
|
531
|
+
if (match && !sameWorkspace(match.cwd, runtime.cwd)) {
|
|
532
|
+
const { realpath } = await import('node:fs/promises');
|
|
533
|
+
const folder = await realpath(match.cwd).catch(() => undefined);
|
|
534
|
+
if (folder) {
|
|
535
|
+
runtime = await resolveRuntime(config, folder);
|
|
536
|
+
resumeTarget = match.id;
|
|
537
|
+
}
|
|
455
538
|
}
|
|
456
539
|
}
|
|
457
540
|
let store = await SessionStore.open(sessionsRootDir, runtime, resumeTarget);
|
|
@@ -460,9 +543,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
460
543
|
let manager;
|
|
461
544
|
let subagents;
|
|
462
545
|
let controller;
|
|
463
|
-
// The MCP phase has its own abort scope. Sharing one controller let a
|
|
464
|
-
//
|
|
465
|
-
//
|
|
546
|
+
// The MCP phase has its own abort scope. Sharing one controller let a harness
|
|
547
|
+
// rebuild during a run replace the run's signal, so Ctrl+C could no longer
|
|
548
|
+
// cancel the request that was actually in flight.
|
|
466
549
|
let mcpController;
|
|
467
550
|
let denied = false, exiting = false, terminating = false, starting = true;
|
|
468
551
|
let mode = values['read-only']
|
|
@@ -474,6 +557,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
474
557
|
: (config.approvalMode ?? 'ask');
|
|
475
558
|
let prePlanMode = values.yes || config.approvalMode === 'auto' ? 'auto' : 'ask';
|
|
476
559
|
const sessionAllowedTools = new Set();
|
|
560
|
+
const sessionAllowedBashPrefixes = new Set();
|
|
561
|
+
// Terminal bell when vyen needs the user (approval, question) or a long turn finishes, so a
|
|
562
|
+
// developer who switched windows notices. Disable with VYEN_NOTIFY=0.
|
|
563
|
+
const notifyEnabled = process.env.VYEN_NOTIFY !== '0' && process.env.VYEN_NOTIFY !== 'off';
|
|
564
|
+
const notifyAttention = () => {
|
|
565
|
+
if (notifyEnabled && stdout.isTTY && !jsonOutput)
|
|
566
|
+
stdout.write('\x07');
|
|
567
|
+
};
|
|
477
568
|
const stop = () => {
|
|
478
569
|
if (starting)
|
|
479
570
|
process.exitCode = 130;
|
|
@@ -521,12 +612,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
521
612
|
else if (stdin.isTTY)
|
|
522
613
|
rl = createInterface({ input: stdin, output: stderr, terminal: true });
|
|
523
614
|
rl?.on('SIGINT', stop);
|
|
524
|
-
const { estimatedTokens } = await import('./context.js');
|
|
615
|
+
const { estimatedTokens, buildGoalContinuationPrompt, buildGoalObjectiveUpdatedPrompt, buildUserGoalContextMessage, } = await import('./context.js');
|
|
525
616
|
const syncTuiHeader = (resetContext = false) => {
|
|
526
617
|
if (!tui)
|
|
527
618
|
return;
|
|
528
619
|
tui.setWorkspace(runtime.cwd, readGitBranch(runtime.cwd));
|
|
529
620
|
tui.setContextWindow(runtime.contextWindow);
|
|
621
|
+
tui.setGoal(store.session.goal);
|
|
530
622
|
if (resetContext) {
|
|
531
623
|
tui.setContextUsage(store.session.messages.length ? estimatedTokens(store.session.messages, '') : undefined);
|
|
532
624
|
}
|
|
@@ -535,6 +627,22 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
535
627
|
const display = (text) => tui ? tui.write(text) : stderr.write(cleanTerminal(text));
|
|
536
628
|
const question = (label, signal, remember = false) => tui ? tui.question(label, signal, remember)
|
|
537
629
|
: rl ? rl.question(label, { signal }) : Promise.reject(new Error('Input requires a TTY.'));
|
|
630
|
+
// Plain readline mode: read a secret without echoing it.
|
|
631
|
+
const questionHidden = async (label) => {
|
|
632
|
+
if (!rl)
|
|
633
|
+
return question(label);
|
|
634
|
+
const writer = rl;
|
|
635
|
+
const original = writer._writeToOutput;
|
|
636
|
+
stderr.write(label);
|
|
637
|
+
writer._writeToOutput = (text) => { if (/^[\r\n]+$/.test(text))
|
|
638
|
+
original.call(rl, text); };
|
|
639
|
+
try {
|
|
640
|
+
return await rl.question('');
|
|
641
|
+
}
|
|
642
|
+
finally {
|
|
643
|
+
writer._writeToOutput = original;
|
|
644
|
+
}
|
|
645
|
+
};
|
|
538
646
|
let reasoningSource;
|
|
539
647
|
const emit = (event) => {
|
|
540
648
|
if (event.type === 'stopped')
|
|
@@ -585,6 +693,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
585
693
|
stderr.write(`\n[${event.name}]\n`);
|
|
586
694
|
else if (event.type === 'todo')
|
|
587
695
|
stderr.write(`\n[todo: ${event.todos.filter(t => t.status === 'completed').length}/${event.todos.length} completed]\n`);
|
|
696
|
+
else if (event.type === 'goal')
|
|
697
|
+
stderr.write(`\n[${event.goal ? formatGoalStatusIndicator(event.goal) : 'Goal cleared'}]\n`);
|
|
588
698
|
else if (event.type === 'plan_exit')
|
|
589
699
|
stderr.write('\n[Plan approved — exited plan mode]\n');
|
|
590
700
|
else if (event.type === 'compaction')
|
|
@@ -595,9 +705,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
595
705
|
const { ToolRegistry, formatTodos, applyPermissionMode } = await import('./tools.js');
|
|
596
706
|
const { Subagents } = await import('./subagents.js');
|
|
597
707
|
const { Harness } = await import('./harness.js');
|
|
598
|
-
const buildHarness = async (currentMode) => {
|
|
708
|
+
const buildHarness = async (currentMode, options = {}) => {
|
|
599
709
|
const reg = new ToolRegistry(runtime.cwd, async (request) => {
|
|
600
|
-
|
|
710
|
+
// Only the live mode counts. The mode captured when this harness was built must not keep
|
|
711
|
+
// auto-approving after the user switches back to ask/plan (Shift+Tab, /mode, /auto off).
|
|
712
|
+
if (mode === 'auto' || sessionAllowedTools.has(request.tool))
|
|
713
|
+
return true;
|
|
714
|
+
if (request.tool === 'bash' && isApprovedBashPrefix(request.input, sessionAllowedBashPrefixes))
|
|
601
715
|
return true;
|
|
602
716
|
if (!rl && !tui)
|
|
603
717
|
return false;
|
|
@@ -625,17 +739,28 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
625
739
|
rawPreview = JSON.stringify(request.input, null, 2);
|
|
626
740
|
}
|
|
627
741
|
const preview = cleanTerminal(rawPreview.split(runtime.apiKey).join('[REDACTED]').slice(0, 8000));
|
|
628
|
-
const
|
|
742
|
+
const prefix = request.tool === 'bash' ? bashApprovalPrefix(request.input) : undefined;
|
|
743
|
+
const fullCommand = request.input && typeof request.input === 'object' ? String(request.input.command ?? '').trim().split(/\s+/).join(' ') : '';
|
|
744
|
+
const prefixHint = !prefix ? ''
|
|
745
|
+
: prefix === fullCommand
|
|
746
|
+
? `\n p = always allow exactly "${prefix}" in this session`
|
|
747
|
+
: `\n p = always allow "${prefix} …" in this session (simple arguments inside the workspace only)`;
|
|
748
|
+
const approvalBlock = `\n▲ Allow ${request.tool}?\n${preview}${prefixHint}\n`;
|
|
749
|
+
const label = prefix ? '[y/N/p(refix)/a(lways)] ' : '[y/N/a(lways)] ';
|
|
750
|
+
notifyAttention();
|
|
629
751
|
const reply = tui
|
|
630
|
-
? (await tui.askTransient(approvalBlock,
|
|
631
|
-
: (display(approvalBlock), (await question(
|
|
752
|
+
? (await tui.askTransient(approvalBlock, label, request.signal)).trim().toLowerCase()
|
|
753
|
+
: (display(approvalBlock), (await question(label, request.signal)).trim().toLowerCase());
|
|
632
754
|
if (reply === 'a' || reply === 'always') {
|
|
633
755
|
sessionAllowedTools.add(request.tool);
|
|
634
756
|
return true;
|
|
635
757
|
}
|
|
758
|
+
if (prefix && (reply === 'p' || reply === 'prefix')) {
|
|
759
|
+
sessionAllowedBashPrefixes.add(prefix);
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
636
762
|
return reply === 'y' || reply === 'yes';
|
|
637
763
|
});
|
|
638
|
-
// One mechanism maps a permission mode onto a registry.
|
|
639
764
|
applyPermissionMode(reg, currentMode);
|
|
640
765
|
if (rl || tui) {
|
|
641
766
|
reg.askUser = async (questions, callSignal) => {
|
|
@@ -661,11 +786,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
661
786
|
return out;
|
|
662
787
|
};
|
|
663
788
|
}
|
|
664
|
-
|
|
789
|
+
// Running MCP servers are reused across harness rebuilds (effort, model, session switches);
|
|
790
|
+
// they restart only when asked to (/mcp reload) or when the workspace directory changed.
|
|
791
|
+
const reuseMcp = !values['no-mcp'] && manager?.connected && !options.reconnectMcp && manager.cwd === runtime.cwd;
|
|
792
|
+
if (reuseMcp) {
|
|
793
|
+
manager.attach(reg);
|
|
794
|
+
}
|
|
795
|
+
else if (!values['no-mcp'] && manager) {
|
|
665
796
|
await manager.close();
|
|
666
797
|
manager = undefined;
|
|
667
798
|
}
|
|
668
|
-
if (!values['no-mcp']) {
|
|
799
|
+
if (!values['no-mcp'] && !reuseMcp) {
|
|
669
800
|
const { loadMcpConfig, McpManager } = await import('./mcp.js');
|
|
670
801
|
mcpController = new AbortController();
|
|
671
802
|
try {
|
|
@@ -703,7 +834,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
703
834
|
if (nextMode === 'plan' && mode !== 'plan')
|
|
704
835
|
prePlanMode = mode;
|
|
705
836
|
mode = nextMode;
|
|
706
|
-
applyPermissionMode(tools,
|
|
837
|
+
applyPermissionMode(tools, mode);
|
|
707
838
|
tui?.setMode(mode);
|
|
708
839
|
config.approvalMode = mode;
|
|
709
840
|
void saveConfig(config).catch(() => { });
|
|
@@ -713,6 +844,41 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
713
844
|
display(`\n[Mode switched to: ${label}]\n`);
|
|
714
845
|
};
|
|
715
846
|
let { tools, harness } = await buildHarness(mode);
|
|
847
|
+
let rebuildPending = false;
|
|
848
|
+
const rebuildHarness = async (options = {}) => {
|
|
849
|
+
const built = await buildHarness(mode, options);
|
|
850
|
+
tools = built.tools;
|
|
851
|
+
harness = built.harness;
|
|
852
|
+
};
|
|
853
|
+
// Rebuilding closes the MCP servers and subagents the running turn uses, so while a turn is
|
|
854
|
+
// active the rebuild waits until it ends (the new settings apply from the next turn).
|
|
855
|
+
const requestRebuild = async () => {
|
|
856
|
+
if (controller || harness.busy) {
|
|
857
|
+
rebuildPending = true;
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
rebuildPending = false;
|
|
861
|
+
await rebuildHarness();
|
|
862
|
+
return true;
|
|
863
|
+
};
|
|
864
|
+
const applyPendingRebuild = async () => {
|
|
865
|
+
if (rebuildPending && !controller && !harness.busy) {
|
|
866
|
+
rebuildPending = false;
|
|
867
|
+
await rebuildHarness();
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
const applyEffort = async (next) => {
|
|
871
|
+
config = await loadConfig({ ...config, effort: next });
|
|
872
|
+
await saveConfig(config);
|
|
873
|
+
runtime = await resolveRuntime(config, runtime.cwd);
|
|
874
|
+
tui?.setEffort(runtime.effort);
|
|
875
|
+
syncTuiHeader();
|
|
876
|
+
const applied = await requestRebuild();
|
|
877
|
+
const suffix = applied ? '' : ' (applies from the next turn)';
|
|
878
|
+
if (tui)
|
|
879
|
+
tui.setNotice(`Reasoning effort: ${runtime.effort ?? 'default'}${suffix}`);
|
|
880
|
+
display(`\n[Reasoning effort set to: ${runtime.effort ?? 'default'}${suffix}]\n`);
|
|
881
|
+
};
|
|
716
882
|
if (tui) {
|
|
717
883
|
void tools.files.listWorkspaceFiles().then(files => tui?.setWorkspaceFiles(files)).catch(() => { });
|
|
718
884
|
tui.onCopy = async () => {
|
|
@@ -736,17 +902,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
736
902
|
const ring = [undefined, ...EFFORT_LEVELS];
|
|
737
903
|
const curIdx = ring.indexOf(runtime.effort);
|
|
738
904
|
const nextEffort = ring[(curIdx + direction + ring.length) % ring.length];
|
|
739
|
-
void (
|
|
740
|
-
config = await loadConfig({ ...config, effort: nextEffort });
|
|
741
|
-
await saveConfig(config);
|
|
742
|
-
runtime = await resolveRuntime(config, runtime.cwd);
|
|
743
|
-
tui?.setEffort(runtime.effort);
|
|
744
|
-
syncTuiHeader();
|
|
745
|
-
const built = await buildHarness(mode);
|
|
746
|
-
tools = built.tools;
|
|
747
|
-
harness = built.harness;
|
|
748
|
-
display(`\n[Reasoning effort set to: ${runtime.effort ?? 'default'}]\n`);
|
|
749
|
-
})();
|
|
905
|
+
void applyEffort(nextEffort).catch(error => display(`\n[Effort change failed: ${safeError(error, apiKey)}]\n`));
|
|
750
906
|
};
|
|
751
907
|
}
|
|
752
908
|
const renderResumedHistory = (showBanner = true) => {
|
|
@@ -762,6 +918,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
762
918
|
: Array.isArray(m.content)
|
|
763
919
|
? m.content.map(p => ('text' in p && typeof p.text === 'string' ? p.text : p.type === 'image' ? '[Image]' : '')).filter(Boolean).join(' ')
|
|
764
920
|
: '';
|
|
921
|
+
if (rawText.startsWith('<codex_internal_context source="')) {
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
765
924
|
const cleaned = rawText
|
|
766
925
|
.replace(/<referenced_file\s+path="([^"]+)"[\s\S]*?<\/referenced_file>/g, '[@$1]')
|
|
767
926
|
.replace(/\r?\n/g, ' ')
|
|
@@ -799,7 +958,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
799
958
|
return;
|
|
800
959
|
}
|
|
801
960
|
const lines = [
|
|
802
|
-
`\n
|
|
961
|
+
`\n↻ Resumed session ${store.session.id.slice(0, 8)}${store.session.title ? ` (${store.session.title})` : ''}: ${msgs.length} messages, ${store.session.checkpoints?.length ?? 0} checkpoints`,
|
|
803
962
|
];
|
|
804
963
|
const previewMsgs = msgs.filter(m => m.role === 'user' || m.role === 'assistant').slice(-6);
|
|
805
964
|
for (const m of previewMsgs) {
|
|
@@ -808,13 +967,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
808
967
|
: Array.isArray(m.content)
|
|
809
968
|
? m.content.map(p => ('text' in p && typeof p.text === 'string' ? p.text : p.type === 'tool-call' ? `[tool:${p.toolName}]` : '')).filter(Boolean).join(' ')
|
|
810
969
|
: '';
|
|
970
|
+
if (text.startsWith('<codex_internal_context source="')) {
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
811
973
|
const cleaned = text.replace(/<referenced_file[\s\S]*?<\/referenced_file>/g, '[attached file]').replace(/\s+/g, ' ').trim();
|
|
812
974
|
if (cleaned) {
|
|
813
|
-
lines.push(` ${m.role === 'user' ? '
|
|
975
|
+
lines.push(` ${m.role === 'user' ? '›' : '◆'} ${cleaned.slice(0, 140)}${cleaned.length > 140 ? '…' : ''}`);
|
|
814
976
|
}
|
|
815
977
|
}
|
|
816
|
-
lines.
|
|
817
|
-
display(lines.join('\n'));
|
|
978
|
+
display(`${lines.join('\n')}\n`);
|
|
818
979
|
};
|
|
819
980
|
starting = false;
|
|
820
981
|
if (jsonOutput)
|
|
@@ -825,20 +986,43 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
825
986
|
}
|
|
826
987
|
else
|
|
827
988
|
display(`vyen · ${runtime.model} · session ${store.session.id}\n`);
|
|
828
|
-
|
|
989
|
+
let goalContinuationDeferred = false;
|
|
990
|
+
const runTurn = async (input, opts) => {
|
|
829
991
|
if (Buffer.byteLength(input) > 1024 * 1024)
|
|
830
992
|
throw new Error('Prompt exceeds 1 MiB.');
|
|
993
|
+
await applyPendingRebuild();
|
|
831
994
|
denied = false;
|
|
995
|
+
let aborted = false;
|
|
996
|
+
let hadError = false;
|
|
997
|
+
let outText = '';
|
|
998
|
+
const turnStarted = Date.now();
|
|
832
999
|
controller = new AbortController();
|
|
833
1000
|
tui?.setStatus('Running');
|
|
834
1001
|
if (tui) {
|
|
835
1002
|
tui.markTurnStart();
|
|
836
|
-
|
|
1003
|
+
if (opts?.isGoalContinuation) {
|
|
1004
|
+
const g = store.session.goal;
|
|
1005
|
+
const stepLabel = opts.continuationStep && opts.continuationStep > 1 ? ` · turn ${opts.continuationStep}` : '';
|
|
1006
|
+
const statusInd = g ? formatGoalStatusIndicator(g) : 'Pursuing goal';
|
|
1007
|
+
tui.write(`\n◆ ${statusInd}${stepLabel}${g ? ` — ${g.objective}` : ''}\n`);
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
tui.write(`\n> ${input}\n`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
else if (opts?.isGoalContinuation && !jsonOutput) {
|
|
1014
|
+
const g = store.session.goal;
|
|
1015
|
+
const stepLabel = opts.continuationStep && opts.continuationStep > 1 ? ` · turn ${opts.continuationStep}` : '';
|
|
1016
|
+
display(`\n[◆ Pursuing goal${stepLabel}${g ? `: ${g.objective}` : ''}]\n`);
|
|
837
1017
|
}
|
|
838
1018
|
try {
|
|
839
|
-
|
|
1019
|
+
outText = await harness.run(input, controller.signal);
|
|
1020
|
+
if (outputLastMessage) {
|
|
1021
|
+
const { writeFile } = await import('node:fs/promises');
|
|
1022
|
+
await writeFile(resolve(outputLastMessage), outText.endsWith('\n') ? outText : `${outText}\n`, 'utf8');
|
|
1023
|
+
}
|
|
840
1024
|
if (jsonOutput)
|
|
841
|
-
stdout.write(JSON.stringify({ type: 'done', text, denied, sessionId: store.session.id }) + '\n');
|
|
1025
|
+
stdout.write(JSON.stringify({ type: 'done', text: outText, denied, sessionId: store.session.id }) + '\n');
|
|
842
1026
|
else if (tui)
|
|
843
1027
|
tui.write('\n');
|
|
844
1028
|
else
|
|
@@ -847,21 +1031,99 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
847
1031
|
process.exitCode = 2;
|
|
848
1032
|
}
|
|
849
1033
|
catch (error) {
|
|
1034
|
+
aborted = controller.signal.aborted;
|
|
1035
|
+
hadError = true;
|
|
850
1036
|
if (!interactive)
|
|
851
|
-
process.exitCode =
|
|
1037
|
+
process.exitCode = aborted ? terminating ? 143 : 130 : 1;
|
|
852
1038
|
const message = safeError(error, apiKey);
|
|
853
1039
|
if (jsonOutput)
|
|
854
1040
|
stdout.write(JSON.stringify({ type: 'error', message }) + '\n');
|
|
855
1041
|
else
|
|
856
1042
|
display(`\n${message}\n`);
|
|
1043
|
+
if (aborted && !exiting && !terminating && store.session.goal?.status === 'active') {
|
|
1044
|
+
goalContinuationDeferred = true;
|
|
1045
|
+
if (tui)
|
|
1046
|
+
tui.setNotice('Goal continuation paused after interrupt — run /goal resume to continue.');
|
|
1047
|
+
else
|
|
1048
|
+
display('[Goal continuation paused after interrupt — run /goal resume to continue.]\n');
|
|
1049
|
+
}
|
|
857
1050
|
}
|
|
858
1051
|
finally {
|
|
859
1052
|
controller = undefined;
|
|
1053
|
+
if (interactive && Date.now() - turnStarted >= 15_000)
|
|
1054
|
+
notifyAttention();
|
|
860
1055
|
tui?.setStatus('Ready');
|
|
861
1056
|
syncTuiHeader();
|
|
862
1057
|
if (tui)
|
|
863
1058
|
void tools.files.listWorkspaceFiles().then(files => tui?.setWorkspaceFiles(files)).catch(() => { });
|
|
864
1059
|
}
|
|
1060
|
+
return { aborted, hadError, text: outText };
|
|
1061
|
+
};
|
|
1062
|
+
const continueGoalIfIdle = async () => {
|
|
1063
|
+
if (controller || exiting || terminating || denied || tools.planMode)
|
|
1064
|
+
return;
|
|
1065
|
+
let consecutiveEmptyTurns = 0;
|
|
1066
|
+
for (let step = 1;; step++) {
|
|
1067
|
+
if (exiting || terminating || denied || tools.planMode || goalContinuationDeferred)
|
|
1068
|
+
break;
|
|
1069
|
+
if (tui?.hasQueuedPrompts())
|
|
1070
|
+
break;
|
|
1071
|
+
const g = store.session.goal;
|
|
1072
|
+
if (!g || g.status !== 'active')
|
|
1073
|
+
break;
|
|
1074
|
+
if (g.tokenBudget !== undefined && (g.tokensUsed ?? 0) >= g.tokenBudget) {
|
|
1075
|
+
g.status = 'budget_limited';
|
|
1076
|
+
g.updatedAt = new Date().toISOString();
|
|
1077
|
+
await store.save();
|
|
1078
|
+
syncTuiHeader();
|
|
1079
|
+
const summary = formatGoalStatusIndicator(g);
|
|
1080
|
+
if (tui)
|
|
1081
|
+
tui.setNotice(summary);
|
|
1082
|
+
else if (!jsonOutput)
|
|
1083
|
+
display(`\n[${summary}]\n`);
|
|
1084
|
+
break;
|
|
1085
|
+
}
|
|
1086
|
+
const contPrompt = buildGoalContinuationPrompt(g, !tools.planMode && !tools.readOnly);
|
|
1087
|
+
const res = await runTurn(contPrompt, { isGoalContinuation: true, continuationStep: step });
|
|
1088
|
+
if (res.aborted || res.hadError || denied)
|
|
1089
|
+
break;
|
|
1090
|
+
const afterGoal = store.session.goal;
|
|
1091
|
+
if (!afterGoal || afterGoal.status !== 'active') {
|
|
1092
|
+
if (afterGoal) {
|
|
1093
|
+
const summary = formatGoalStatusIndicator(afterGoal);
|
|
1094
|
+
if (tui)
|
|
1095
|
+
tui.setNotice(summary);
|
|
1096
|
+
if (!jsonOutput)
|
|
1097
|
+
display(`\n[${summary} — ${afterGoal.objective}]\n`);
|
|
1098
|
+
}
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
if (!res.text.trim()) {
|
|
1102
|
+
consecutiveEmptyTurns++;
|
|
1103
|
+
if (consecutiveEmptyTurns >= 3) {
|
|
1104
|
+
afterGoal.status = 'blocked';
|
|
1105
|
+
afterGoal.updatedAt = new Date().toISOString();
|
|
1106
|
+
await store.save();
|
|
1107
|
+
syncTuiHeader();
|
|
1108
|
+
const summary = formatGoalStatusIndicator(afterGoal);
|
|
1109
|
+
if (tui)
|
|
1110
|
+
tui.setNotice(summary);
|
|
1111
|
+
if (!jsonOutput)
|
|
1112
|
+
display(`\n[${summary} — stalled after 3 empty continuation turns]\n`);
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
else {
|
|
1117
|
+
consecutiveEmptyTurns = 0;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
const run = async (input) => {
|
|
1122
|
+
goalContinuationDeferred = false;
|
|
1123
|
+
const res = await runTurn(input);
|
|
1124
|
+
if (!res.aborted && !res.hadError && !denied && store.session.goal?.status === 'active') {
|
|
1125
|
+
await continueGoalIfIdle();
|
|
1126
|
+
}
|
|
865
1127
|
};
|
|
866
1128
|
const executeImmediateCommand = async (rawCmd) => {
|
|
867
1129
|
const cmdInput = rawCmd.trim();
|
|
@@ -886,7 +1148,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
886
1148
|
' /view [normal|verbose|transcript] Switch view: normal (hide thinking) | verbose | transcript\n' +
|
|
887
1149
|
' /copy [last|code|<N>] Copy last response, code block, or Nth response (or press Ctrl+O)\n' +
|
|
888
1150
|
' /btw <question>, /side <question> Ask an ephemeral side question without modifying history\n' +
|
|
889
|
-
' /goal [<
|
|
1151
|
+
' /goal [--budget <N>] [<obj>|edit|pause|resume|complete|clear] Autonomous goal loop (Codex /goal)\n' +
|
|
890
1152
|
' /recap Generate a live summary of current session progress\n' +
|
|
891
1153
|
' /skills [list|<name>] List or inspect SKILL.md skills (use $skill-name in prompt)\n' +
|
|
892
1154
|
' /memories [list|add <text>|rm <id>|clear] Manage persistent user memories across sessions\n' +
|
|
@@ -894,14 +1156,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
894
1156
|
' /mention <filepath> Insert @filepath mention into the composer\n' +
|
|
895
1157
|
' /cd <dir>, /pwd Change or show active workspace directory\n' +
|
|
896
1158
|
' /mode [ask|auto|plan|readonly] Switch approval mode (or press Shift+Tab)\n' +
|
|
897
|
-
' /setup, /config
|
|
1159
|
+
' /setup, /config, /provider Interactive provider, URL, key, model & effort setup\n' +
|
|
1160
|
+
' /key Replace the stored API key (input hidden)\n' +
|
|
1161
|
+
' /prompts [show <name>] List custom prompt files; run one with /prompts:<name> [args]\n' +
|
|
898
1162
|
' /model [id] [--effort=<level>] Discover/pick model from /models or set model ID\n' +
|
|
899
1163
|
' /effort [low|medium|high|xhigh|max|default] Switch reasoning effort (or Alt+./Alt+,)\n' +
|
|
900
1164
|
' /plan [off|execute|<prompt>] Enter/exit Plan Mode or execute approved plan\n' +
|
|
901
1165
|
' /revert, /undo [code|msg|both] [N] Rewind last N turns & restore prompt to composer (or press Esc Esc)\n' +
|
|
902
1166
|
' /checkpoints Interactive picker to rewind to any earlier turn checkpoint\n' +
|
|
903
1167
|
' /todo [clear] View or clear the current task checklist\n' +
|
|
904
|
-
' /resume [latest|<number>|<id>]
|
|
1168
|
+
' /resume [latest|<number>|<id>] Pick a session from this folder or all projects (←→ / Tab switch)\n' +
|
|
905
1169
|
' /new [title] Start a fresh session in this workspace\n' +
|
|
906
1170
|
' /delete [<id>|current] Delete a saved session or current session\n' +
|
|
907
1171
|
' /compact [focus instructions] Summarize & archive earlier context history\n' +
|
|
@@ -956,10 +1220,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
956
1220
|
void (async () => {
|
|
957
1221
|
try {
|
|
958
1222
|
const res = await harness.askSideQuestion(questionText, AbortSignal.timeout(runtime.timeoutMs));
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
` A: ${res.text.split('\n').join('\n ')}\n` +
|
|
962
|
-
`╰─────────────────────────────────────────────────────────────────╯\n`);
|
|
1223
|
+
// A "◇" cell with indented lines renders as one quiet block in the TUI.
|
|
1224
|
+
display(`\n◇ Side answer: ${questionText}\n${res.text.split('\n').map(line => ` ${line}`).join('\n')}\n`);
|
|
963
1225
|
if (tui)
|
|
964
1226
|
tui.setNotice('Side question (/btw) answered (history unchanged).');
|
|
965
1227
|
}
|
|
@@ -976,57 +1238,201 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
976
1238
|
if (!arg) {
|
|
977
1239
|
const g = store.session.goal;
|
|
978
1240
|
if (!g) {
|
|
979
|
-
display(
|
|
1241
|
+
display(`No active session goal.\n${GOAL_USAGE}\n`);
|
|
980
1242
|
}
|
|
981
1243
|
else {
|
|
982
|
-
|
|
983
|
-
const mins = Math.floor(elapsedSec / 60);
|
|
984
|
-
const secs = elapsedSec % 60;
|
|
985
|
-
const spentTokens = Math.max(0, store.session.tokens - (g.tokensAtStart ?? 0));
|
|
986
|
-
display(`\nSession Goal [${g.status.toUpperCase()}]:\n` +
|
|
987
|
-
` Objective : ${g.objective}\n` +
|
|
988
|
-
` Elapsed : ${mins > 0 ? `${mins}m ${secs}s` : `${secs}s`} (since ${g.createdAt.slice(11, 19)})\n` +
|
|
989
|
-
` Tokens : ${spentTokens} tokens used since goal start\n`);
|
|
1244
|
+
display(`\n${formatGoalSummaryLines(g, store.session.tokens)}\n`);
|
|
990
1245
|
}
|
|
991
1246
|
return true;
|
|
992
1247
|
}
|
|
993
1248
|
if (lower === 'clear' || lower === 'off' || lower === 'none') {
|
|
1249
|
+
if (!store.session.goal) {
|
|
1250
|
+
display('No active session goal.\n');
|
|
1251
|
+
return true;
|
|
1252
|
+
}
|
|
994
1253
|
store.session.goal = undefined;
|
|
1254
|
+
harness.addContextMessage({
|
|
1255
|
+
role: 'user',
|
|
1256
|
+
content: buildUserGoalContextMessage({ cleared: true }),
|
|
1257
|
+
});
|
|
995
1258
|
await store.save();
|
|
1259
|
+
syncTuiHeader();
|
|
996
1260
|
if (tui)
|
|
997
|
-
tui.setNotice('
|
|
998
|
-
display('
|
|
1261
|
+
tui.setNotice('Goal cleared.');
|
|
1262
|
+
display('Goal cleared.\n');
|
|
999
1263
|
return true;
|
|
1000
1264
|
}
|
|
1001
1265
|
if (lower === 'pause' || lower === 'resume' || lower === 'complete' || lower === 'done') {
|
|
1002
|
-
|
|
1003
|
-
|
|
1266
|
+
const g = store.session.goal;
|
|
1267
|
+
if (!g) {
|
|
1268
|
+
display(`No session goal set yet.\n${GOAL_USAGE}\n`);
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
const nextStatus = lower === 'pause' ? 'paused' : lower === 'resume' ? 'active' : 'complete';
|
|
1272
|
+
if (nextStatus === 'active' && g.status === 'budget_limited' && g.tokenBudget !== undefined && (g.tokensUsed ?? 0) >= g.tokenBudget) {
|
|
1273
|
+
g.tokenBudget = undefined;
|
|
1274
|
+
}
|
|
1275
|
+
g.status = nextStatus;
|
|
1276
|
+
g.updatedAt = now;
|
|
1277
|
+
if (nextStatus === 'active')
|
|
1278
|
+
goalContinuationDeferred = false;
|
|
1279
|
+
harness.addContextMessage({
|
|
1280
|
+
role: 'user',
|
|
1281
|
+
content: buildUserGoalContextMessage({ status: nextStatus }),
|
|
1282
|
+
});
|
|
1283
|
+
await store.save();
|
|
1284
|
+
syncTuiHeader();
|
|
1285
|
+
const indicator = formatGoalStatusIndicator(g);
|
|
1286
|
+
if (tui)
|
|
1287
|
+
tui.setNotice(indicator);
|
|
1288
|
+
display(`\n${formatGoalSummaryLines(g, store.session.tokens)}\n`);
|
|
1289
|
+
if (nextStatus === 'active' && !controller) {
|
|
1290
|
+
if (mode === 'plan') {
|
|
1291
|
+
display('Note: Plan mode is active. Switch to ask or auto mode (/mode ask or /auto) to pursue the goal autonomously.\n');
|
|
1292
|
+
}
|
|
1293
|
+
else {
|
|
1294
|
+
await continueGoalIfIdle();
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return true;
|
|
1298
|
+
}
|
|
1299
|
+
if (lower === 'edit' || lower.startsWith('edit ')) {
|
|
1300
|
+
const g = store.session.goal;
|
|
1301
|
+
if (!g) {
|
|
1302
|
+
display(`No session goal set yet.\n${GOAL_USAGE}\n`);
|
|
1303
|
+
return true;
|
|
1304
|
+
}
|
|
1305
|
+
const editRest = arg.slice(4).trim();
|
|
1306
|
+
if (!editRest) {
|
|
1307
|
+
if (controller) {
|
|
1308
|
+
display('Usage while working: /goal edit <objective>\n');
|
|
1309
|
+
return true;
|
|
1310
|
+
}
|
|
1311
|
+
return false;
|
|
1312
|
+
}
|
|
1313
|
+
const parsedEdit = parseGoalDraftArgs(editRest);
|
|
1314
|
+
if ('error' in parsedEdit) {
|
|
1315
|
+
display(`${parsedEdit.error}\n`);
|
|
1004
1316
|
return true;
|
|
1005
1317
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1318
|
+
if (parsedEdit.tokenBudget !== undefined) {
|
|
1319
|
+
g.tokenBudget = parsedEdit.tokenBudget;
|
|
1320
|
+
if (g.status === 'budget_limited' && (g.tokensUsed ?? 0) < parsedEdit.tokenBudget) {
|
|
1321
|
+
g.status = 'active';
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
if (parsedEdit.objective) {
|
|
1325
|
+
g.objective = parsedEdit.objective;
|
|
1326
|
+
}
|
|
1327
|
+
g.updatedAt = now;
|
|
1328
|
+
harness.addContextMessage({
|
|
1329
|
+
role: 'user',
|
|
1330
|
+
content: buildUserGoalContextMessage({
|
|
1331
|
+
...(parsedEdit.objective ? { objective: g.objective } : {}),
|
|
1332
|
+
status: g.status,
|
|
1333
|
+
}),
|
|
1334
|
+
});
|
|
1335
|
+
if (parsedEdit.objective) {
|
|
1336
|
+
harness.addContextMessage({
|
|
1337
|
+
role: 'user',
|
|
1338
|
+
content: buildGoalObjectiveUpdatedPrompt(g),
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1009
1341
|
await store.save();
|
|
1010
|
-
|
|
1342
|
+
syncTuiHeader();
|
|
1343
|
+
if (tui)
|
|
1344
|
+
tui.setNotice(formatGoalStatusIndicator(g));
|
|
1345
|
+
display(`\n${formatGoalSummaryLines(g, store.session.tokens)}\n`);
|
|
1346
|
+
if (g.status === 'active' && !controller && mode !== 'plan') {
|
|
1347
|
+
goalContinuationDeferred = false;
|
|
1348
|
+
await continueGoalIfIdle();
|
|
1349
|
+
}
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
const parsed = parseGoalDraftArgs(arg);
|
|
1353
|
+
if ('error' in parsed) {
|
|
1354
|
+
display(`${parsed.error}\n`);
|
|
1355
|
+
return true;
|
|
1356
|
+
}
|
|
1357
|
+
if (!parsed.objective) {
|
|
1358
|
+
if (parsed.tokenBudget !== undefined && store.session.goal) {
|
|
1359
|
+
const g = store.session.goal;
|
|
1360
|
+
g.tokenBudget = parsed.tokenBudget;
|
|
1361
|
+
if (g.status === 'budget_limited' && (g.tokensUsed ?? 0) < parsed.tokenBudget) {
|
|
1362
|
+
g.status = 'active';
|
|
1363
|
+
}
|
|
1364
|
+
g.updatedAt = now;
|
|
1365
|
+
await store.save();
|
|
1366
|
+
syncTuiHeader();
|
|
1367
|
+
if (tui)
|
|
1368
|
+
tui.setNotice(formatGoalStatusIndicator(g));
|
|
1369
|
+
display(`\n${formatGoalSummaryLines(g, store.session.tokens)}\n`);
|
|
1370
|
+
if (g.status === 'active' && !controller && mode !== 'plan') {
|
|
1371
|
+
goalContinuationDeferred = false;
|
|
1372
|
+
await continueGoalIfIdle();
|
|
1373
|
+
}
|
|
1374
|
+
return true;
|
|
1375
|
+
}
|
|
1376
|
+
display(`${GOAL_USAGE}\n`);
|
|
1011
1377
|
return true;
|
|
1012
1378
|
}
|
|
1379
|
+
const hadExistingGoal = Boolean(store.session.goal);
|
|
1013
1380
|
store.session.goal = {
|
|
1014
|
-
|
|
1381
|
+
goalId: `goal_${Date.now().toString(36)}`,
|
|
1382
|
+
objective: parsed.objective,
|
|
1015
1383
|
status: 'active',
|
|
1016
1384
|
createdAt: now,
|
|
1017
1385
|
updatedAt: now,
|
|
1018
1386
|
tokensAtStart: store.session.tokens,
|
|
1387
|
+
tokensUsed: 0,
|
|
1388
|
+
timeUsedSeconds: 0,
|
|
1389
|
+
...(parsed.tokenBudget !== undefined ? { tokenBudget: parsed.tokenBudget } : {}),
|
|
1019
1390
|
};
|
|
1391
|
+
goalContinuationDeferred = false;
|
|
1392
|
+
harness.addContextMessage({
|
|
1393
|
+
role: 'user',
|
|
1394
|
+
content: buildUserGoalContextMessage({ objective: parsed.objective, status: 'active' }),
|
|
1395
|
+
});
|
|
1396
|
+
if (controller && hadExistingGoal) {
|
|
1397
|
+
harness.addContextMessage({
|
|
1398
|
+
role: 'user',
|
|
1399
|
+
content: buildGoalObjectiveUpdatedPrompt(store.session.goal),
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1020
1402
|
await store.save();
|
|
1403
|
+
syncTuiHeader();
|
|
1021
1404
|
if (tui)
|
|
1022
|
-
tui.setNotice(
|
|
1023
|
-
display(
|
|
1405
|
+
tui.setNotice(formatGoalStatusIndicator(store.session.goal));
|
|
1406
|
+
display(`\n${formatGoalSummaryLines(store.session.goal, store.session.tokens)}\n`);
|
|
1407
|
+
if (mode === 'plan') {
|
|
1408
|
+
display('Note: Plan mode is active. Switch to ask or auto mode (/mode ask or /auto) to pursue the goal autonomously.\n');
|
|
1409
|
+
}
|
|
1410
|
+
else if (!controller) {
|
|
1411
|
+
await continueGoalIfIdle();
|
|
1412
|
+
}
|
|
1024
1413
|
return true;
|
|
1025
1414
|
}
|
|
1026
1415
|
if (cmdInput === '/pwd' || cmdInput === '/cwd') {
|
|
1027
1416
|
display(`Workspace directory: ${runtime.cwd}\n`);
|
|
1028
1417
|
return true;
|
|
1029
1418
|
}
|
|
1419
|
+
if (cmdInput === '/prompts' || cmdInput === '/prompts list' || cmdInput.startsWith('/prompts show ')) {
|
|
1420
|
+
const { discoverPrompts } = await import('./prompts.js');
|
|
1421
|
+
const prompts = await discoverPrompts(runtime.cwd);
|
|
1422
|
+
if (cmdInput.startsWith('/prompts show ')) {
|
|
1423
|
+
const name = cmdInput.slice('/prompts show '.length).trim().replace(/^prompts:/, '');
|
|
1424
|
+
const found = prompts.find(p => p.name.toLowerCase() === name.toLowerCase());
|
|
1425
|
+
display(found ? `\n/prompts:${found.name} (${found.path}):\n${found.body.slice(0, 4000)}\n` : `Prompt "${name}" not found. Run /prompts to list them.\n`);
|
|
1426
|
+
return true;
|
|
1427
|
+
}
|
|
1428
|
+
if (!prompts.length) {
|
|
1429
|
+
display('No prompt files found. Create e.g. .vyen/prompts/review.md or ~/.config/vyen/prompts/review.md,\nthen run /prompts:review [args] ($ARGUMENTS and $1..$9 are substituted).\n');
|
|
1430
|
+
}
|
|
1431
|
+
else {
|
|
1432
|
+
display(`Custom prompts (${prompts.length}) — run with /prompts:<name> [args]:\n${prompts.map(p => ` /prompts:${p.name.padEnd(20)} [${p.scope}] ${p.description}`).join('\n')}\n`);
|
|
1433
|
+
}
|
|
1434
|
+
return true;
|
|
1435
|
+
}
|
|
1030
1436
|
if (cmdInput === '/skills' || cmdInput.startsWith('/skills ')) {
|
|
1031
1437
|
const { discoverSkills } = await import('./context.js');
|
|
1032
1438
|
const { readFile } = await import('node:fs/promises');
|
|
@@ -1306,11 +1712,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1306
1712
|
display(`Invalid effort "${arg}". Choose from: low, medium, high, xhigh, max, default.\n`);
|
|
1307
1713
|
return true;
|
|
1308
1714
|
}
|
|
1309
|
-
|
|
1310
|
-
await
|
|
1311
|
-
runtime = await resolveRuntime(config, runtime.cwd);
|
|
1312
|
-
tui?.setEffort(runtime.effort);
|
|
1313
|
-
display(`Reasoning effort set to: ${runtime.effort ?? 'default'}\n`);
|
|
1715
|
+
// Previously only the saved config changed; the running harness kept the old effort.
|
|
1716
|
+
await applyEffort(isClear ? undefined : parsed);
|
|
1314
1717
|
return true;
|
|
1315
1718
|
}
|
|
1316
1719
|
if (cmdInput === '/todo' || cmdInput.startsWith('/todo ')) {
|
|
@@ -1436,11 +1839,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1436
1839
|
return true;
|
|
1437
1840
|
}
|
|
1438
1841
|
if (cmdInput === '/status') {
|
|
1439
|
-
const { estimatedTokens } = await import('./context.js');
|
|
1440
1842
|
const usedEst = estimatedTokens(store.session.messages, '');
|
|
1441
1843
|
const pct = Math.min(100, Math.round((usedEst / runtime.contextWindow) * 100));
|
|
1442
1844
|
const goalLine = store.session.goal
|
|
1443
|
-
? `Goal [${store.session.goal.status}] ${store.session.goal.objective}\n`
|
|
1845
|
+
? `Goal [${goalStatusLabel(store.session.goal.status)}] ${store.session.goal.objective} (${formatGoalUsageSummary(store.session.goal)})\n`
|
|
1444
1846
|
: '';
|
|
1445
1847
|
display(`Session ${store.session.id}${store.session.title ? ` (${store.session.title})` : ''}\n` +
|
|
1446
1848
|
goalLine +
|
|
@@ -1502,10 +1904,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1502
1904
|
const cmd = sanitizeBashCommand(rawShell);
|
|
1503
1905
|
const { runCommand } = await import('./process.js');
|
|
1504
1906
|
display(`\n$ ${cmd}\n`);
|
|
1907
|
+
// When idle, Ctrl+C cancels this command instead of quitting vyen.
|
|
1908
|
+
const shellController = new AbortController();
|
|
1909
|
+
const ownsController = !controller;
|
|
1910
|
+
if (ownsController)
|
|
1911
|
+
controller = shellController;
|
|
1505
1912
|
try {
|
|
1506
|
-
const res = await runCommand('bash', ['-c', cmd], { cwd: runtime.cwd, signal: AbortSignal.timeout(
|
|
1913
|
+
const res = await runCommand('bash', ['-c', cmd], { cwd: runtime.cwd, signal: AbortSignal.any([shellController.signal, AbortSignal.timeout(600_000)]), timeoutMs: 600_000 });
|
|
1507
1914
|
display(`${res.output || '(no output)'}\n[Exit code: ${res.code}]\n`);
|
|
1508
|
-
|
|
1915
|
+
harness.addContextMessage({
|
|
1509
1916
|
role: 'user',
|
|
1510
1917
|
content: `<user_shell_command>\n<command>${cmd}</command>\n<result>\nExit code: ${res.code}\n${res.output.slice(0, 8000)}\n</result>\n</user_shell_command>`,
|
|
1511
1918
|
});
|
|
@@ -1514,6 +1921,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1514
1921
|
catch (error) {
|
|
1515
1922
|
display(`Shell command failed: ${safeError(error, apiKey)}\n`);
|
|
1516
1923
|
}
|
|
1924
|
+
finally {
|
|
1925
|
+
if (ownsController && controller === shellController)
|
|
1926
|
+
controller = undefined;
|
|
1927
|
+
}
|
|
1517
1928
|
return true;
|
|
1518
1929
|
}
|
|
1519
1930
|
return false;
|
|
@@ -1526,6 +1937,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1526
1937
|
if (cmd.startsWith('/revert') || cmd.startsWith('/undo') || cmd === '/checkpoints') {
|
|
1527
1938
|
controller?.abort(new Error('Interrupted for revert.'));
|
|
1528
1939
|
}
|
|
1940
|
+
// Previously only the notice was shown and the command was silently dropped.
|
|
1941
|
+
tui?.queuePrompt(cmd);
|
|
1529
1942
|
tui?.setNotice(`Queued command "${cmd}" to run as soon as current step finishes.`);
|
|
1530
1943
|
}
|
|
1531
1944
|
};
|
|
@@ -1553,17 +1966,70 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1553
1966
|
}
|
|
1554
1967
|
if (!input)
|
|
1555
1968
|
continue;
|
|
1969
|
+
await applyPendingRebuild();
|
|
1556
1970
|
if (await executeImmediateCommand(input)) {
|
|
1557
1971
|
if (exiting)
|
|
1558
1972
|
break;
|
|
1559
1973
|
continue;
|
|
1560
1974
|
}
|
|
1561
|
-
if (input === '/
|
|
1975
|
+
if (input.toLowerCase() === '/goal edit') {
|
|
1976
|
+
const g = store.session.goal;
|
|
1977
|
+
if (!g) {
|
|
1978
|
+
display(`No session goal set yet.\n${GOAL_USAGE}\n`);
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
const promptBlock = `\n◇ Edit Goal Objective\n Current: ${g.objective}\n`;
|
|
1982
|
+
const nextObj = tui
|
|
1983
|
+
? (await tui.askTransient(promptBlock, 'New objective (Enter to cancel): ', undefined, { escHint: 'cancel' })).trim()
|
|
1984
|
+
: (display(promptBlock), (await question('New objective (Enter to cancel): ')).trim());
|
|
1985
|
+
if (!nextObj) {
|
|
1986
|
+
if (tui)
|
|
1987
|
+
tui.setNotice('Cancelled /goal edit.');
|
|
1988
|
+
else
|
|
1989
|
+
display('Cancelled /goal edit.\n');
|
|
1990
|
+
continue;
|
|
1991
|
+
}
|
|
1992
|
+
await executeImmediateCommand(`/goal edit ${nextObj}`);
|
|
1993
|
+
continue;
|
|
1994
|
+
}
|
|
1995
|
+
if (input === '/key') {
|
|
1996
|
+
const { saveCredential } = await import('./config.js');
|
|
1997
|
+
const entered = (tui
|
|
1998
|
+
? await tui.question(`New API key for ${runtime.keyEnv} (hidden, Esc to cancel): `, undefined, false, { escCancels: true, mask: true }).catch(() => '')
|
|
1999
|
+
: await questionHidden(`New API key for ${runtime.keyEnv} (hidden): `)).trim();
|
|
2000
|
+
if (!entered) {
|
|
2001
|
+
display('API key unchanged.\n');
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
await saveCredential(runtime.keyEnv, entered);
|
|
2005
|
+
if (process.env[runtime.keyEnv]?.trim()) {
|
|
2006
|
+
display(`Saved to credentials.json, but ${runtime.keyEnv} is set in the environment and takes precedence.\n`);
|
|
2007
|
+
}
|
|
2008
|
+
runtime = await resolveRuntime(config, runtime.cwd);
|
|
2009
|
+
apiKey = runtime.apiKey;
|
|
2010
|
+
await rebuildHarness();
|
|
2011
|
+
display(`API key for ${runtime.keyEnv} updated (${entered.slice(0, 4)}…${entered.slice(-2)}).\n`);
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
2014
|
+
if (input.startsWith('/prompts:')) {
|
|
2015
|
+
const { discoverPrompts, expandPromptTemplate } = await import('./prompts.js');
|
|
2016
|
+
const match = /^\/prompts:([A-Za-z0-9][A-Za-z0-9_.-]*)\s*([\s\S]*)$/.exec(input);
|
|
2017
|
+
const prompts = await discoverPrompts(runtime.cwd);
|
|
2018
|
+
const found = match ? prompts.find(p => p.name.toLowerCase() === match[1].toLowerCase()) : undefined;
|
|
2019
|
+
if (!found) {
|
|
2020
|
+
display(`Prompt "${match?.[1] ?? input.slice(9)}" not found. Run /prompts to list them.\n`);
|
|
2021
|
+
continue;
|
|
2022
|
+
}
|
|
2023
|
+
await run(expandPromptTemplate(found.body, match[2]));
|
|
2024
|
+
continue;
|
|
2025
|
+
}
|
|
2026
|
+
if (input === '/setup' || input === '/config' || input === '/provider') {
|
|
1562
2027
|
try {
|
|
1563
2028
|
config = await runSetupWizard(config, {
|
|
1564
2029
|
write: text => display(text),
|
|
1565
2030
|
// Esc cancels the whole wizard (nothing is persisted until the final step).
|
|
1566
2031
|
ask: label => (tui ? tui.question(label, undefined, false, { escCancels: true }) : question(label)),
|
|
2032
|
+
askSecret: label => (tui ? tui.question(label, undefined, false, { escCancels: true, mask: true }) : questionHidden(label)),
|
|
1567
2033
|
});
|
|
1568
2034
|
}
|
|
1569
2035
|
catch (error) {
|
|
@@ -1582,9 +2048,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1582
2048
|
tui?.setTitle(`${runtime.model} · ${store.session.id.slice(0, 8)}`);
|
|
1583
2049
|
tui?.setEffort(runtime.effort);
|
|
1584
2050
|
syncTuiHeader();
|
|
1585
|
-
|
|
1586
|
-
tools = built.tools;
|
|
1587
|
-
harness = built.harness;
|
|
2051
|
+
await rebuildHarness();
|
|
1588
2052
|
continue;
|
|
1589
2053
|
}
|
|
1590
2054
|
if (input === '/model' || input.startsWith('/model ')) {
|
|
@@ -1652,9 +2116,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1652
2116
|
tui?.setTitle(`${runtime.model} · ${store.session.id.slice(0, 8)}`);
|
|
1653
2117
|
tui?.setEffort(runtime.effort);
|
|
1654
2118
|
syncTuiHeader();
|
|
1655
|
-
|
|
1656
|
-
tools = built.tools;
|
|
1657
|
-
harness = built.harness;
|
|
2119
|
+
await rebuildHarness();
|
|
1658
2120
|
display(`Active model: ${runtime.model} (ctx: ${Math.round(runtime.contextWindow / 1024)}k · out: ${Math.round(runtime.maxOutputTokens / 1000)}k · ${caps.supportsVision === false ? 'text-only' : 'vision'} · effort: ${runtime.effort ?? 'default'})\n`);
|
|
1659
2121
|
continue;
|
|
1660
2122
|
}
|
|
@@ -1682,9 +2144,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1682
2144
|
runtime = await resolveRuntime(config, runtime.cwd);
|
|
1683
2145
|
tui?.setEffort(runtime.effort);
|
|
1684
2146
|
syncTuiHeader();
|
|
1685
|
-
|
|
1686
|
-
tools = built.tools;
|
|
1687
|
-
harness = built.harness;
|
|
2147
|
+
await rebuildHarness();
|
|
1688
2148
|
display(`Reasoning effort set to: ${runtime.effort ?? 'default'}\n`);
|
|
1689
2149
|
continue;
|
|
1690
2150
|
}
|
|
@@ -1771,9 +2231,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1771
2231
|
const parts = raw ? raw.split(/\s+/) : [];
|
|
1772
2232
|
let scope = 'both';
|
|
1773
2233
|
let steps = 1;
|
|
2234
|
+
let force = false;
|
|
1774
2235
|
for (const p of parts) {
|
|
1775
2236
|
const lp = p.toLowerCase();
|
|
1776
|
-
if (lp === '
|
|
2237
|
+
if (lp === '--force' || lp === '-f' || lp === 'force')
|
|
2238
|
+
force = true;
|
|
2239
|
+
else if (lp === 'code' || lp === 'files' || lp === 'file' || lp === 'source')
|
|
1777
2240
|
scope = 'code';
|
|
1778
2241
|
else if (lp === 'msg' || lp === 'messages' || lp === 'chat')
|
|
1779
2242
|
scope = 'messages';
|
|
@@ -1786,7 +2249,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1786
2249
|
}
|
|
1787
2250
|
}
|
|
1788
2251
|
try {
|
|
1789
|
-
const res = await harness.revert({ steps, scope });
|
|
2252
|
+
const res = await harness.revert({ steps, scope, force });
|
|
2253
|
+
// Files edited after the agent's turn are never overwritten silently.
|
|
2254
|
+
const keptNote = res.conflictFiles.length
|
|
2255
|
+
? `\n⚠ Kept ${res.conflictFiles.length} file(s) changed after that turn: ${res.conflictFiles.join(', ')} (use /revert ${steps} ${scope} --force to overwrite)\n`
|
|
2256
|
+
: '';
|
|
2257
|
+
const lostNote = res.unrecoverableFiles.length
|
|
2258
|
+
? `\n⚠ Backup pruned (session size limit), not restored: ${res.unrecoverableFiles.join(', ')}\n`
|
|
2259
|
+
: '';
|
|
2260
|
+
if (keptNote || lostNote)
|
|
2261
|
+
display(keptNote + lostNote);
|
|
1790
2262
|
if (tui) {
|
|
1791
2263
|
tui.setTokens(store.session.tokens);
|
|
1792
2264
|
syncTuiHeader(true);
|
|
@@ -1831,26 +2303,6 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1831
2303
|
}
|
|
1832
2304
|
continue;
|
|
1833
2305
|
}
|
|
1834
|
-
if (input === '/todo' || input.startsWith('/todo ')) {
|
|
1835
|
-
const arg = input.slice(5).trim().toLowerCase();
|
|
1836
|
-
if (arg === 'clear' || arg === 'reset') {
|
|
1837
|
-
tools.todos = [];
|
|
1838
|
-
store.session.todos = [];
|
|
1839
|
-
await store.save();
|
|
1840
|
-
tui?.event({ type: 'todo', todos: [] });
|
|
1841
|
-
display('Cleared todo checklist.\n');
|
|
1842
|
-
}
|
|
1843
|
-
else {
|
|
1844
|
-
display(`\n${formatTodos(tools.todos)}\n`);
|
|
1845
|
-
}
|
|
1846
|
-
continue;
|
|
1847
|
-
}
|
|
1848
|
-
if (input === '/fork' || input.startsWith('/fork ')) {
|
|
1849
|
-
const title = input.slice(5).trim() || undefined;
|
|
1850
|
-
const forkedId = await store.forkToNewFile(title);
|
|
1851
|
-
display(`Forked current session (${store.session.messages.length} messages) -> new session ID: ${forkedId}\nSwitch to it now with: /resume ${forkedId.slice(0, 8)} (or vyen --resume ${forkedId.slice(0, 8)})\n`);
|
|
1852
|
-
continue;
|
|
1853
|
-
}
|
|
1854
2306
|
if (input === '/new' || input.startsWith('/new ')) {
|
|
1855
2307
|
const newTitle = input.slice(4).trim() || undefined;
|
|
1856
2308
|
const sessionsRoot = join(runtime.dataDir, 'sessions');
|
|
@@ -1863,9 +2315,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1863
2315
|
store = newStore;
|
|
1864
2316
|
await oldStore.discardIfEmpty();
|
|
1865
2317
|
await oldStore.close();
|
|
1866
|
-
|
|
1867
|
-
tools = built.tools;
|
|
1868
|
-
harness = built.harness;
|
|
2318
|
+
await rebuildHarness();
|
|
1869
2319
|
tui?.clear();
|
|
1870
2320
|
tui?.setTitle(store.session.title ? `${runtime.model} · ${store.session.title}` : `${runtime.model} · ${store.session.id.slice(0, 8)}`);
|
|
1871
2321
|
tui?.setTokens(0);
|
|
@@ -1878,73 +2328,114 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1878
2328
|
if (input === '/resume' || input.startsWith('/resume ')) {
|
|
1879
2329
|
const sessionsRoot = join(runtime.dataDir, 'sessions');
|
|
1880
2330
|
const allList = await SessionStore.list(sessionsRoot);
|
|
1881
|
-
const
|
|
1882
|
-
const
|
|
1883
|
-
const
|
|
1884
|
-
const candidates = nonEmptyPool.length ? nonEmptyPool : rawPool;
|
|
2331
|
+
const resumable = (s) => (s.messagesCount ?? 0) > 0 || Boolean(s.title);
|
|
2332
|
+
const projectSessions = allList.filter(resumable);
|
|
2333
|
+
const folderSessions = projectSessions.filter(s => sameWorkspace(s.cwd, runtime.cwd));
|
|
1885
2334
|
let arg = input.slice(7).trim();
|
|
2335
|
+
let targetId;
|
|
1886
2336
|
if (!arg) {
|
|
1887
|
-
if (!
|
|
1888
|
-
display('No saved sessions
|
|
2337
|
+
if (!projectSessions.length) {
|
|
2338
|
+
display('No saved sessions yet. Sessions appear here after the first prompt.\n');
|
|
1889
2339
|
continue;
|
|
1890
2340
|
}
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
const
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
display('Cancelled /resume.\n');
|
|
1910
|
-
continue;
|
|
2341
|
+
if (tui) {
|
|
2342
|
+
// Two tabs so a developer who does not remember the folder can still find a session.
|
|
2343
|
+
const toItem = (s, showFolder) => ({
|
|
2344
|
+
id: s.id,
|
|
2345
|
+
label: `${s.id === store.session.id ? '(current) ' : ''}${s.title ? `${s.title}: ` : ''}${s.preview ?? '(no prompt yet)'}`,
|
|
2346
|
+
meta: [showFolder ? folderLabel(s.cwd) : '', formatRelativeTime(s.updatedAt), `${s.messagesCount ?? 0} msgs`].filter(Boolean).join(' '),
|
|
2347
|
+
detail: `${s.cwd} ${s.id.slice(0, 8)} ${s.model}`,
|
|
2348
|
+
search: `${s.cwd} ${s.model} ${s.title ?? ''}`,
|
|
2349
|
+
});
|
|
2350
|
+
const picked = await tui.pick('Resume', [
|
|
2351
|
+
{ label: 'This folder', items: folderSessions.map(s => toItem(s, false)), empty: 'No sessions in this folder yet. Press → for all projects.' },
|
|
2352
|
+
{ label: 'All projects', items: projectSessions.map(s => toItem(s, true)) },
|
|
2353
|
+
], { initialTab: folderSessions.length ? 0 : 1, action: 'resume' });
|
|
2354
|
+
if (!picked) {
|
|
2355
|
+
tui.setNotice('Resume cancelled.');
|
|
2356
|
+
continue;
|
|
2357
|
+
}
|
|
2358
|
+
targetId = picked.item.id;
|
|
1911
2359
|
}
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
2360
|
+
else {
|
|
2361
|
+
// Readline mode: a numbered list; "a" switches between this folder and all projects.
|
|
2362
|
+
let showAll = folderSessions.length === 0;
|
|
2363
|
+
for (;;) {
|
|
2364
|
+
const pool = showAll ? projectSessions : folderSessions;
|
|
2365
|
+
const rows = pool.slice(0, 20).map((s, idx) => ` ${String(idx + 1).padStart(2)} ${(s.title ?? s.preview ?? '(no prompt yet)').replace(/\s+/g, ' ').slice(0, 60)} ${showAll ? `${folderLabel(s.cwd)} ` : ''}${formatRelativeTime(s.updatedAt)}`);
|
|
2366
|
+
display(`\n${showAll ? 'All projects' : 'This folder'} (${pool.length})\n${rows.join('\n') || ' (none)'}\n`);
|
|
2367
|
+
const choice = (await question(`Resume [number, ID prefix, a = ${showAll ? 'this folder' : 'all projects'}, Enter = cancel]: `)).trim();
|
|
2368
|
+
if (choice.toLowerCase() === 'a') {
|
|
2369
|
+
showAll = !showAll;
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
const n = Number(choice);
|
|
2373
|
+
targetId = Number.isInteger(n) && n >= 1 && n <= pool.length ? pool[n - 1].id : choice || undefined;
|
|
2374
|
+
break;
|
|
2375
|
+
}
|
|
2376
|
+
if (!targetId) {
|
|
2377
|
+
display('Resume cancelled.\n');
|
|
2378
|
+
continue;
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
arg = targetId;
|
|
1917
2382
|
}
|
|
1918
2383
|
try {
|
|
1919
|
-
const
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
??
|
|
2384
|
+
const candidates = folderSessions.length ? folderSessions : projectSessions;
|
|
2385
|
+
const num = Number(arg);
|
|
2386
|
+
const prefix = arg.toLowerCase();
|
|
2387
|
+
const targetMatch = Number.isInteger(num) && num >= 1 && num <= candidates.length && !/^[0-9a-f]{4,}$/i.test(arg)
|
|
2388
|
+
? candidates[num - 1].id
|
|
2389
|
+
: arg === 'latest'
|
|
2390
|
+
? candidates.find(s => s.id !== store.session.id)?.id ?? candidates[0]?.id
|
|
2391
|
+
: allList.find(s => s.id.toLowerCase() === prefix)?.id
|
|
2392
|
+
?? folderSessions.find(s => s.id.toLowerCase().startsWith(prefix))?.id
|
|
2393
|
+
?? allList.find(s => s.id.toLowerCase().startsWith(prefix))?.id
|
|
2394
|
+
?? arg;
|
|
1926
2395
|
if (targetMatch === store.session.id) {
|
|
1927
2396
|
display(`Already in session ${store.session.id.slice(0, 8)}.\n`);
|
|
1928
2397
|
renderResumedHistory();
|
|
1929
2398
|
continue;
|
|
1930
2399
|
}
|
|
1931
|
-
|
|
2400
|
+
// A session from another project brings its workspace with it.
|
|
2401
|
+
const target = allList.find(s => s.id === targetMatch);
|
|
2402
|
+
const previousRuntime = runtime;
|
|
2403
|
+
const switching = target !== undefined && !sameWorkspace(target.cwd, runtime.cwd);
|
|
2404
|
+
if (switching) {
|
|
2405
|
+
const { realpath } = await import('node:fs/promises');
|
|
2406
|
+
const folder = await realpath(target.cwd).catch(() => undefined);
|
|
2407
|
+
if (!folder) {
|
|
2408
|
+
display(`Cannot resume: the session's folder no longer exists (${target.cwd}).\n`);
|
|
2409
|
+
continue;
|
|
2410
|
+
}
|
|
2411
|
+
runtime = await resolveRuntime(config, folder);
|
|
2412
|
+
}
|
|
2413
|
+
let newStore;
|
|
2414
|
+
try {
|
|
2415
|
+
newStore = await SessionStore.open(sessionsRoot, runtime, targetMatch, store.session.id);
|
|
2416
|
+
}
|
|
2417
|
+
catch (error) {
|
|
2418
|
+
runtime = previousRuntime;
|
|
2419
|
+
throw error;
|
|
2420
|
+
}
|
|
1932
2421
|
const oldStore = store;
|
|
1933
2422
|
store = newStore;
|
|
1934
2423
|
await oldStore.discardIfEmpty();
|
|
1935
2424
|
await oldStore.close();
|
|
1936
|
-
|
|
1937
|
-
tools = built.tools;
|
|
1938
|
-
harness = built.harness;
|
|
2425
|
+
await rebuildHarness(switching ? { reconnectMcp: true } : {});
|
|
1939
2426
|
if (tui) {
|
|
1940
2427
|
tui.clear();
|
|
1941
2428
|
display(welcomeBanner(runtime.model, runtime.provider, runtime.baseUrl, runtime.effort, runtime.cwd, store.session.id, mode));
|
|
2429
|
+
if (switching)
|
|
2430
|
+
void tools.files.listWorkspaceFiles().then(files => tui?.setWorkspaceFiles(files)).catch(() => { });
|
|
1942
2431
|
}
|
|
1943
2432
|
tui?.setTitle(store.session.title ? `${runtime.model} · ${store.session.title}` : `${runtime.model} · ${store.session.id.slice(0, 8)}`);
|
|
1944
2433
|
tui?.setTokens(store.session.tokens);
|
|
1945
2434
|
syncTuiHeader(true);
|
|
1946
2435
|
tui?.event({ type: 'todo', todos: store.session.todos ?? [] });
|
|
1947
2436
|
renderResumedHistory();
|
|
2437
|
+
if (switching)
|
|
2438
|
+
display(`Workspace switched to ${runtime.cwd}\n`);
|
|
1948
2439
|
if (!store.session.messages.length) {
|
|
1949
2440
|
display(`Resumed session ${store.session.id.slice(0, 8)} (0 messages).\n`);
|
|
1950
2441
|
}
|
|
@@ -1954,57 +2445,6 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1954
2445
|
}
|
|
1955
2446
|
continue;
|
|
1956
2447
|
}
|
|
1957
|
-
if (input.startsWith('/rename ')) {
|
|
1958
|
-
const newTitle = input.slice(8).trim();
|
|
1959
|
-
if (newTitle) {
|
|
1960
|
-
store.session.title = newTitle;
|
|
1961
|
-
await store.save();
|
|
1962
|
-
tui?.setTitle(`${runtime.model} · ${newTitle}`);
|
|
1963
|
-
display(`Session renamed to: ${newTitle}\n`);
|
|
1964
|
-
}
|
|
1965
|
-
continue;
|
|
1966
|
-
}
|
|
1967
|
-
if (input === '/export' || input.startsWith('/export ')) {
|
|
1968
|
-
const { writeFile } = await import('node:fs/promises');
|
|
1969
|
-
const outName = input.slice(7).trim() || `vyen-session-${store.session.id.slice(0, 8)}.md`;
|
|
1970
|
-
const targetFile = await tools.files.path(outName);
|
|
1971
|
-
const lines = [
|
|
1972
|
-
`# Vyen Session ${store.session.title ?? store.session.id}`,
|
|
1973
|
-
`- **Model**: ${runtime.model} (${runtime.provider})`,
|
|
1974
|
-
`- **Updated**: ${store.session.updatedAt}`,
|
|
1975
|
-
'',
|
|
1976
|
-
];
|
|
1977
|
-
for (const m of store.session.messages) {
|
|
1978
|
-
lines.push(`## ${m.role.toUpperCase()}`);
|
|
1979
|
-
lines.push(typeof m.content === 'string' ? m.content : JSON.stringify(m.content, null, 2));
|
|
1980
|
-
lines.push('');
|
|
1981
|
-
}
|
|
1982
|
-
await writeFile(targetFile, lines.join('\n'), 'utf8');
|
|
1983
|
-
display(`Exported session transcript to ${outName}\n`);
|
|
1984
|
-
continue;
|
|
1985
|
-
}
|
|
1986
|
-
if (input.startsWith('!')) {
|
|
1987
|
-
const cmd = input.slice(1).trim();
|
|
1988
|
-
if (!cmd) {
|
|
1989
|
-
display('Usage: !<command> (runs a shell command directly in workspace and records output in session context)\n');
|
|
1990
|
-
continue;
|
|
1991
|
-
}
|
|
1992
|
-
const { runCommand } = await import('./process.js');
|
|
1993
|
-
display(`\n$ ${cmd}\n`);
|
|
1994
|
-
try {
|
|
1995
|
-
const res = await runCommand('bash', ['-c', cmd], { cwd: runtime.cwd, signal: AbortSignal.timeout(60_000), timeoutMs: 60_000 });
|
|
1996
|
-
display(`${res.output || '(no output)'}\n[Exit code: ${res.code}]\n`);
|
|
1997
|
-
store.session.messages.push({
|
|
1998
|
-
role: 'user',
|
|
1999
|
-
content: `<user_shell_command>\n<command>${cmd}</command>\n<result>\nExit code: ${res.code}\n${res.output.slice(0, 8000)}\n</result>\n</user_shell_command>`,
|
|
2000
|
-
});
|
|
2001
|
-
await store.save();
|
|
2002
|
-
}
|
|
2003
|
-
catch (error) {
|
|
2004
|
-
display(`Shell command failed: ${safeError(error, apiKey)}\n`);
|
|
2005
|
-
}
|
|
2006
|
-
continue;
|
|
2007
|
-
}
|
|
2008
2448
|
if (input === '/compact' || input.startsWith('/compact ')) {
|
|
2009
2449
|
const focus = input.slice(8).trim() || undefined;
|
|
2010
2450
|
controller = new AbortController();
|
|
@@ -2027,9 +2467,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
2027
2467
|
tui?.setStatus('Running');
|
|
2028
2468
|
try {
|
|
2029
2469
|
const res = await harness.recap(controller.signal);
|
|
2030
|
-
display(`\n
|
|
2031
|
-
`${res.text}\n` +
|
|
2032
|
-
`╰─────────────────────────────────────────────────────────────────╯\n`);
|
|
2470
|
+
display(`\n◇ Session recap\n${res.text.split('\n').map(line => ` ${line}`).join('\n')}\n`);
|
|
2033
2471
|
}
|
|
2034
2472
|
catch (error) {
|
|
2035
2473
|
display(`Recap failed: ${safeError(error, apiKey)}\n`);
|
|
@@ -2056,22 +2494,20 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
2056
2494
|
}
|
|
2057
2495
|
const real = await realpath(candidate).catch(() => candidate);
|
|
2058
2496
|
runtime = { ...runtime, cwd: real };
|
|
2059
|
-
// Checkpoints hold paths relative to the workspace they were taken
|
|
2060
|
-
// in; after a workspace change they would restore another
|
|
2061
|
-
// project's files, so they are dropped.
|
|
2062
2497
|
const workspaceChanged = retargetSessionWorkspace(store, real);
|
|
2063
2498
|
await store.save();
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2499
|
+
await rebuildHarness({ reconnectMcp: true });
|
|
2500
|
+
if (workspaceChanged) {
|
|
2501
|
+
display(`Changed workspace directory to: ${real}. Turn checkpoints were cleared; /revert can no longer undo the previous workspace.\n`);
|
|
2502
|
+
}
|
|
2503
|
+
else {
|
|
2504
|
+
display(`Changed workspace directory to: ${real}\n`);
|
|
2505
|
+
}
|
|
2067
2506
|
if (tui) {
|
|
2068
2507
|
void tools.files.listWorkspaceFiles().then(files => tui?.setWorkspaceFiles(files)).catch(() => { });
|
|
2069
2508
|
syncTuiHeader();
|
|
2070
2509
|
tui.setNotice(`Changed directory to: ${real}`);
|
|
2071
2510
|
}
|
|
2072
|
-
display(workspaceChanged
|
|
2073
|
-
? `Changed workspace directory to: ${real}. Turn checkpoints were cleared; /revert can no longer undo the previous workspace.\n`
|
|
2074
|
-
: `Changed workspace directory to: ${real}\n`);
|
|
2075
2511
|
}
|
|
2076
2512
|
catch (error) {
|
|
2077
2513
|
display(`Failed to change directory: ${safeError(error, apiKey)}\n`);
|
|
@@ -2087,9 +2523,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
2087
2523
|
store = await SessionStore.open(sessionsRoot, runtime);
|
|
2088
2524
|
await oldStore.close();
|
|
2089
2525
|
await SessionStore.deleteSession(sessionsRoot, oldId);
|
|
2090
|
-
|
|
2091
|
-
tools = built.tools;
|
|
2092
|
-
harness = built.harness;
|
|
2526
|
+
await rebuildHarness();
|
|
2093
2527
|
tui?.clear();
|
|
2094
2528
|
tui?.setTitle(`${runtime.model} · ${store.session.id.slice(0, 8)}`);
|
|
2095
2529
|
tui?.setTokens(0);
|
|
@@ -2165,9 +2599,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
2165
2599
|
const sub = (subArgs[0] ?? 'list').toLowerCase();
|
|
2166
2600
|
const { loadMcpConfig, saveMcpConfig, importCodexMcpServers } = await import('./mcp.js');
|
|
2167
2601
|
if (sub === 'reload' || sub === 'reconnect') {
|
|
2168
|
-
|
|
2169
|
-
tools = built.tools;
|
|
2170
|
-
harness = built.harness;
|
|
2602
|
+
await rebuildHarness({ reconnectMcp: true });
|
|
2171
2603
|
display(`Reloaded MCP servers (${manager?.tools.length ?? 0} MCP tools active).\n`);
|
|
2172
2604
|
continue;
|
|
2173
2605
|
}
|
|
@@ -2179,9 +2611,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
2179
2611
|
}
|
|
2180
2612
|
else {
|
|
2181
2613
|
display(`Imported ${res.imported.length} MCP server(s) into ${res.path}: ${res.imported.join(', ')}\n`);
|
|
2182
|
-
|
|
2183
|
-
tools = built.tools;
|
|
2184
|
-
harness = built.harness;
|
|
2614
|
+
await rebuildHarness({ reconnectMcp: true });
|
|
2185
2615
|
display(`Active MCP tools: ${manager?.tools.length ?? 0}\n`);
|
|
2186
2616
|
}
|
|
2187
2617
|
}
|
|
@@ -2203,16 +2633,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
2203
2633
|
}
|
|
2204
2634
|
delete cfg.servers[target];
|
|
2205
2635
|
await saveMcpConfig(cfg);
|
|
2206
|
-
|
|
2207
|
-
tools = built.tools;
|
|
2208
|
-
harness = built.harness;
|
|
2636
|
+
await rebuildHarness({ reconnectMcp: true });
|
|
2209
2637
|
display(`Removed MCP server "${target}" (${manager?.tools.length ?? 0} MCP tools active).\n`);
|
|
2210
2638
|
continue;
|
|
2211
2639
|
}
|
|
2212
2640
|
if (sub === 'check') {
|
|
2213
|
-
|
|
2214
|
-
tools = built.tools;
|
|
2215
|
-
harness = built.harness;
|
|
2641
|
+
await rebuildHarness({ reconnectMcp: true });
|
|
2216
2642
|
for (const st of manager?.statuses ?? []) {
|
|
2217
2643
|
display(` ${st.name}: ${st.status}${st.status === 'ok' ? ` (${st.toolCount} tools)` : st.error ? ` — ${st.error}` : ''}\n`);
|
|
2218
2644
|
}
|
|
@@ -2243,62 +2669,6 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
2243
2669
|
await run(`Use a subagent (spawn_agent + wait_agent, or delegate_task / delegate_edit as appropriate) to execute the following task and report the result:\n\n${rest}`);
|
|
2244
2670
|
continue;
|
|
2245
2671
|
}
|
|
2246
|
-
if (input === '/tools') {
|
|
2247
|
-
const names = Object.keys(tools.schemas());
|
|
2248
|
-
display(names.map(n => ` ${tools.isReadOnly(n) ? '● [read]' : '◆ [exec]'} ${n}`).join('\n') + '\n');
|
|
2249
|
-
continue;
|
|
2250
|
-
}
|
|
2251
|
-
if (input === '/sessions') {
|
|
2252
|
-
const list = await SessionStore.list(join(runtime.dataDir, 'sessions'));
|
|
2253
|
-
display((list.length ? list.map(s => {
|
|
2254
|
-
const cur = s.id === store.session.id ? ' [current]' : '';
|
|
2255
|
-
const label = s.title ? ` [${s.title}]` : s.preview ? ` "${s.preview}"` : '';
|
|
2256
|
-
return ` ${s.id.slice(0, 8)}${cur} (${s.messagesCount ?? 0} msgs) ${s.updatedAt} ${s.model}${label} ${s.cwd}`;
|
|
2257
|
-
}).join('\n') : ' No saved sessions.') + '\n');
|
|
2258
|
-
continue;
|
|
2259
|
-
}
|
|
2260
|
-
if (input === '/doctor') {
|
|
2261
|
-
const { runCommand } = await import('./process.js');
|
|
2262
|
-
const ok = async (cmd) => {
|
|
2263
|
-
try {
|
|
2264
|
-
return (await runCommand(cmd, ['--version'], { cwd: runtime.cwd, signal: AbortSignal.timeout(5000), timeoutMs: 5000 })).code === 0;
|
|
2265
|
-
}
|
|
2266
|
-
catch {
|
|
2267
|
-
return false;
|
|
2268
|
-
}
|
|
2269
|
-
};
|
|
2270
|
-
const [b, r, discovered] = await Promise.all([
|
|
2271
|
-
ok('bash'),
|
|
2272
|
-
ok('rg'),
|
|
2273
|
-
discoverModels(runtime.baseUrl, runtime.apiKey, runtime.provider),
|
|
2274
|
-
]);
|
|
2275
|
-
const modelInList = discovered.some((m) => m.id === runtime.model);
|
|
2276
|
-
const gatewayStatus = discovered.length > 0
|
|
2277
|
-
? `OK (${discovered.length} models: ${discovered.map((m) => m.id).join(', ')} · active model ${modelInList ? 'verified' : 'NOT listed'})`
|
|
2278
|
-
: 'Unreachable or no /models response';
|
|
2279
|
-
display(`Provider: ${runtime.provider} | Model: ${runtime.model} | Effort: ${runtime.effort ?? 'default'} | BaseURL: ${runtime.baseUrl}\n` +
|
|
2280
|
-
`Bash: ${b ? 'OK' : 'Missing'} | Ripgrep: ${r ? 'OK' : 'JS fallback'} | API Key: Configured\n` +
|
|
2281
|
-
`Gateway (/models): ${gatewayStatus}\n`);
|
|
2282
|
-
continue;
|
|
2283
|
-
}
|
|
2284
|
-
if (input === '/status') {
|
|
2285
|
-
const { estimatedTokens } = await import('./context.js');
|
|
2286
|
-
const usedEst = estimatedTokens(store.session.messages, '');
|
|
2287
|
-
const pct = Math.min(100, Math.round((usedEst / runtime.contextWindow) * 100));
|
|
2288
|
-
display(`Session ${store.session.id}${store.session.title ? ` (${store.session.title})` : ''}\n` +
|
|
2289
|
-
`Provider ${runtime.provider} (${runtime.baseUrl})\n` +
|
|
2290
|
-
`Model ${runtime.model}\n` +
|
|
2291
|
-
`Effort ${runtime.effort ?? 'default'}\n` +
|
|
2292
|
-
`Mode ${mode}\n` +
|
|
2293
|
-
`View ${viewMode}\n` +
|
|
2294
|
-
`Workspace ${runtime.cwd}\n` +
|
|
2295
|
-
`Checkpoints ${store.session.checkpoints?.length ?? 0} turn(s)\n` +
|
|
2296
|
-
`Context usage ~${usedEst} / ${runtime.contextWindow} tokens (${pct}%)\n` +
|
|
2297
|
-
`Compactions ${store.session.compactions}\n` +
|
|
2298
|
-
`Session tokens ${store.session.tokens}\n` +
|
|
2299
|
-
`MCP tools ${manager?.tools.length ?? 0}\n`);
|
|
2300
|
-
continue;
|
|
2301
|
-
}
|
|
2302
2672
|
await run(input);
|
|
2303
2673
|
}
|
|
2304
2674
|
}
|