@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.
Files changed (81) hide show
  1. package/README.md +303 -56
  2. package/bin/vyen.mjs +12 -2
  3. package/dist/approval.d.ts +12 -0
  4. package/dist/approval.js +93 -0
  5. package/dist/approval.js.map +1 -0
  6. package/dist/cli.d.ts +12 -0
  7. package/dist/cli.js +741 -371
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.d.ts +3 -1
  10. package/dist/config.js +11 -6
  11. package/dist/config.js.map +1 -1
  12. package/dist/context.d.ts +25 -2
  13. package/dist/context.js +197 -19
  14. package/dist/context.js.map +1 -1
  15. package/dist/files.d.ts +40 -3
  16. package/dist/files.js +192 -56
  17. package/dist/files.js.map +1 -1
  18. package/dist/harness.d.ts +38 -2
  19. package/dist/harness.js +180 -10
  20. package/dist/harness.js.map +1 -1
  21. package/dist/help.d.ts +1 -0
  22. package/dist/help.js +49 -0
  23. package/dist/help.js.map +1 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/mcp.d.ts +11 -0
  28. package/dist/mcp.js +313 -236
  29. package/dist/mcp.js.map +1 -1
  30. package/dist/process.d.ts +2 -0
  31. package/dist/process.js +18 -1
  32. package/dist/process.js.map +1 -1
  33. package/dist/prompts.d.ts +23 -0
  34. package/dist/prompts.js +69 -0
  35. package/dist/prompts.js.map +1 -0
  36. package/dist/provider.d.ts +7 -2
  37. package/dist/provider.js +45 -12
  38. package/dist/provider.js.map +1 -1
  39. package/dist/session.d.ts +69 -1
  40. package/dist/session.js +390 -31
  41. package/dist/session.js.map +1 -1
  42. package/dist/setup.d.ts +2 -0
  43. package/dist/setup.js +43 -21
  44. package/dist/setup.js.map +1 -1
  45. package/dist/subagents.js +9 -4
  46. package/dist/subagents.js.map +1 -1
  47. package/dist/tools.d.ts +6 -1
  48. package/dist/tools.js +163 -21
  49. package/dist/tools.js.map +1 -1
  50. package/dist/tui.d.ts +61 -18
  51. package/dist/tui.js +511 -225
  52. package/dist/tui.js.map +1 -1
  53. package/docs/AUDIT.md +54 -0
  54. package/docs/MCP.md +5 -3
  55. package/docs/SPEC.md +4 -8
  56. package/docs/VALIDATION.md +47 -40
  57. package/docs/superpowers/plans/2026-10-21-vyen-brand-rename.md +876 -0
  58. package/docs/superpowers/plans/2026-10-21-vyen-critical-fixes.md +987 -0
  59. package/docs/superpowers/plans/2026-10-22-agent-work-agreements.md +124 -0
  60. package/docs/superpowers/plans/2026-10-22-upstream-merge.md +256 -0
  61. package/npm-shrinkwrap.json +59 -95
  62. package/package.json +8 -20
  63. package/scripts/pack-release.mjs +4 -20
  64. package/scripts/publish-release.mjs +1 -1
  65. package/src/approval.ts +86 -0
  66. package/src/cli.ts +702 -378
  67. package/src/config.ts +11 -6
  68. package/src/context.ts +220 -18
  69. package/src/files.ts +194 -68
  70. package/src/harness.ts +178 -11
  71. package/src/help.ts +49 -0
  72. package/src/index.ts +1 -1
  73. package/src/mcp.ts +328 -242
  74. package/src/process.ts +16 -1
  75. package/src/prompts.ts +74 -0
  76. package/src/provider.ts +44 -13
  77. package/src/session.ts +380 -34
  78. package/src/setup.ts +38 -23
  79. package/src/subagents.ts +9 -4
  80. package/src/tools.ts +174 -20
  81. 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, configDir, loadConfig, normalizeGatewayUrl, resolveApiKey, resolveRuntime, saveConfig, } from './config.js';
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 { InputCancelledError, padEndVisible, sliceVisible, terminalText as cleanTerminal, TerminalUI, visibleLength } from './tui.js';
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
- const HELP = `vyen cli ${VERSION} — independent coding harness
13
-
14
- Usage: vyen [options] [prompt]
15
- vyen resume [ID|latest|number]
16
- vyen setup
17
- vyen init --provider openai-compatible --base-url URL --model MODEL [--effort LEVEL]
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
- `╭${'─'.repeat(inner)}╮`,
65
- line(`${left}${' '.repeat(gap)}${right}`),
66
- line(''),
67
- field('model', model),
68
- field('gateway', `${host} · ${provider}`),
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 { spawn } = await import('node:child_process');
174
- const cmd = process.platform === 'win32'
175
- ? { bin: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', '[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())'] }
176
- : process.platform === 'darwin'
177
- ? { bin: 'pbcopy', args: [] }
178
- : { bin: 'xclip', args: ['-selection', 'clipboard'] };
179
- await new Promise((resolve, reject) => {
180
- const child = spawn(cmd.bin, cmd.args, { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true });
181
- child.on('error', reject);
182
- child.on('close', code => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))));
183
- child.stdin.end(text, 'utf8');
184
- });
185
- return true;
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
- return copied;
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.\n`);
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(', ')}\n`);
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
- const existingList = await SessionStore.list(sessionsRootDir);
446
- const inWorkspace = existingList.filter(s => sameWorkspace(s.cwd, runtime.cwd));
447
- const nonEmpty = inWorkspace.filter(s => (s.messagesCount ?? 0) > 0);
448
- // Never auto-select a session from another workspace: SessionStore.open
449
- // rejects it, which would turn "vyen resume" into a hard failure.
450
- const best = nonEmpty[0] ?? inWorkspace[0];
451
- if (best) {
452
- resumeTarget = best.id;
453
- if (inWorkspace.length <= 1)
454
- launchResumePicker = false;
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
- // harness rebuild during a run replace the run's signal, so Ctrl+C could
465
- // no longer cancel the request that was actually in flight.
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
- if (mode === 'auto' || currentMode === 'auto' || sessionAllowedTools.has(request.tool))
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 approvalBlock = `\n▲ Allow ${request.tool}?\n${preview}\n`;
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, '[y/N/a(lways)] ', request.signal)).trim().toLowerCase()
631
- : (display(approvalBlock), (await question('[y/N/a(lways)] ', request.signal)).trim().toLowerCase());
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
- if (!values['no-mcp'] && manager) {
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, nextMode);
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 (async () => {
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╭─── Resumed Session ${store.session.id.slice(0, 8)}${store.session.title ? ` (${store.session.title})` : ''} · ${msgs.length} messages · ${store.session.checkpoints?.length ?? 0} checkpoints ───╮`,
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' ? '▸ You' : '◆ Vyen'}: ${cleaned.slice(0, 140)}${cleaned.length > 140 ? '…' : ''}`);
975
+ lines.push(` ${m.role === 'user' ? '›' : '◆'} ${cleaned.slice(0, 140)}${cleaned.length > 140 ? '…' : ''}`);
814
976
  }
815
977
  }
816
- lines.push('╰──────────────────────────────────────────────────────────────────────────────────╯\n');
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
- const run = async (input) => {
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
- tui.write(`\n> ${input}\n`);
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
- const text = await harness.run(input, controller.signal);
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 = controller.signal.aborted ? terminating ? 143 : 130 : 1;
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 [<objective>|pause|resume|complete|clear] Set or inspect session goal & token usage\n' +
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 Interactive provider, URL, key, model & effort setup\n' +
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>] Interactive session picker or hot-swap to saved session\n' +
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
- display(`\n╭─── Side Answer (/btw) ──────────────────────────────────────────╮\n` +
960
- ` Q: ${questionText}\n` +
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('No active session goal. Set one with: /goal <objective>\n');
1241
+ display(`No active session goal.\n${GOAL_USAGE}\n`);
980
1242
  }
981
1243
  else {
982
- const elapsedSec = Math.max(0, Math.floor((Date.now() - Date.parse(g.createdAt)) / 1000));
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('Cleared session goal.');
998
- display('Cleared session goal.\n');
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
- if (!store.session.goal) {
1003
- display('No session goal set yet. Use: /goal <objective>\n');
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
- store.session.goal.status =
1007
- lower === 'pause' ? 'paused' : lower === 'resume' ? 'active' : 'complete';
1008
- store.session.goal.updatedAt = now;
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
- display(`Session goal status updated to: ${store.session.goal.status} (${store.session.goal.objective})\n`);
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
- objective: arg,
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(`Goal active: ${arg.slice(0, 60)}`);
1023
- display(`Active session goal set: ${arg}\n`);
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
- config = await loadConfig({ ...config, effort: isClear ? undefined : parsed });
1310
- await saveConfig(config);
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(60_000), timeoutMs: 60_000 });
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
- store.session.messages.push({
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 === '/setup' || input === '/config') {
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
- const built = await buildHarness(mode);
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
- const built = await buildHarness(mode);
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
- const built = await buildHarness(mode);
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 === 'code' || lp === 'files' || lp === 'file' || lp === 'source')
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
- const built = await buildHarness(mode);
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 wsList = allList.filter(s => sameWorkspace(s.cwd, runtime.cwd));
1882
- const rawPool = wsList.length ? wsList : allList;
1883
- const nonEmptyPool = rawPool.filter(s => (s.messagesCount ?? 0) > 0 || Boolean(s.title));
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 (!candidates.length) {
1888
- display('No saved sessions found.\n');
2337
+ if (!projectSessions.length) {
2338
+ display('No saved sessions yet. Sessions appear here after the first prompt.\n');
1889
2339
  continue;
1890
2340
  }
1891
- const rows = candidates.slice(0, 15).map((s, idx) => {
1892
- const numStr = String(idx + 1).padStart(2, ' ');
1893
- const currentTag = s.id === store.session.id ? ' • current' : '';
1894
- const msgTag = `${s.messagesCount ?? 0} msgs`;
1895
- const ageTag = formatRelativeTime(s.updatedAt);
1896
- const rawLabel = s.title ? `[${s.title}] ${s.preview ?? ''}`.trim() : s.preview ? s.preview : '(empty session)';
1897
- const label = rawLabel.replace(/\s+/g, ' ').slice(0, 64);
1898
- return ` ${numStr}. ${s.id.slice(0, 8)} · ${ageTag.padEnd(8)} · ${msgTag.padEnd(8)} · ${label}${currentTag}`;
1899
- });
1900
- const pickerBlock = `\n◇ Resume saved session · ${candidates.length} in workspace\n${rows.join('\n')}\n`;
1901
- const promptText = `Resume [1-${Math.min(15, candidates.length)}, latest, ID prefix, or Esc/q to cancel]: `;
1902
- const choice = tui
1903
- ? (await tui.askTransient(pickerBlock, promptText, undefined, { escHint: 'cancel' })).trim()
1904
- : (display(pickerBlock), (await question(promptText)).trim());
1905
- if (!choice || choice.toLowerCase() === 'q' || choice.toLowerCase() === 'cancel' || choice === '0') {
1906
- if (tui)
1907
- tui.setNotice('Cancelled /resume.');
1908
- else
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
- arg = choice;
1913
- }
1914
- const num = Number(arg);
1915
- if (Number.isInteger(num) && num >= 1 && num <= candidates.length) {
1916
- arg = candidates[num - 1].id;
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 targetMatch = arg === 'latest'
1920
- ? candidates.find(s => s.id !== store.session.id && (s.messagesCount ?? 0) > 0)?.id
1921
- ?? candidates.find(s => s.id !== store.session.id)?.id
1922
- ?? candidates[0]?.id
1923
- : candidates.find(s => s.id === arg || s.id.toLowerCase().startsWith(arg.toLowerCase()))?.id
1924
- ?? allList.find(s => s.id === arg || s.id.toLowerCase().startsWith(arg.toLowerCase()))?.id
1925
- ?? arg;
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
- const newStore = await SessionStore.open(sessionsRoot, runtime, targetMatch, store.session.id);
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
- const built = await buildHarness(mode);
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╭─── Session Recap (/recap) ──────────────────────────────────────╮\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
- const built = await buildHarness(mode);
2065
- tools = built.tools;
2066
- harness = built.harness;
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
- const built = await buildHarness(mode);
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
- const built = await buildHarness(mode);
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
- const built = await buildHarness(mode);
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
- const built = await buildHarness(mode);
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
- const built = await buildHarness(mode);
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
  }