agent.libx.js 0.93.29 → 0.93.30

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/cli/cli.ts CHANGED
@@ -24,7 +24,7 @@ import { existsSync, readFileSync, appendFileSync, mkdirSync, writeFileSync, rea
24
24
  import { homedir, tmpdir } from 'node:os';
25
25
  import { grabClipboardImage } from './clipboard';
26
26
  import { join, resolve, basename, extname, dirname } from 'node:path';
27
- import { AIClient, listModels, listProviders, getProviderFromModel, getModelInfo, resolveModel, isModelSupported } from 'ai.libx.js';
27
+ import { AIClient, listModels, listProviders, getProviderFromModel, getModelInfo, resolveModel, isModelSupported, disposeCursorSessions } from 'ai.libx.js';
28
28
  import {
29
29
  PermissionPolicy, loadCommands, expandCommand, loadSkills, forComponent, composeHooks, reflectOnRun,
30
30
  AgentOptions, exitSessionTool,
@@ -33,7 +33,7 @@ import {
33
33
  } from '../src/index';
34
34
  import { mountMcpServer, mountMcpServers, type MountedMcp, type McpServerConfig } from '../src/mcp.client';
35
35
  import { McpOAuth } from './mcpOAuth';
36
- import { buildAgent, summarizeCall, type CliOptions } from './core';
36
+ import { buildAgent, summarizeCall, cursorProviderOptions, type CliOptions } from './core';
37
37
  import { DuplexAgent } from '../src/duplex';
38
38
  import { VoiceIO } from './voice';
39
39
  import { loadConfig, type AgentConfig } from './config';
@@ -95,7 +95,7 @@ interface Args {
95
95
  duplex: boolean; voiceModel?: string; thinkModel?: string | false; voice: boolean;
96
96
  cont: boolean; resume?: string; sessionId?: string; fork?: boolean; outputFormat: 'text' | 'json' | 'stream-json';
97
97
  allowedTools?: string[]; disallowedTools?: string[]; appendSystemPrompt?: string; addDirs?: string[];
98
- print?: boolean; debug?: boolean;
98
+ print?: boolean; debug?: boolean; scratch?: boolean;
99
99
  }
100
100
  /** Parse a numeric flag, failing fast on a missing/NaN/negative value so a typo can't silently disable a kill-switch. */
101
101
  function numFlag(raw: string | undefined, flag: string): number {
@@ -141,6 +141,7 @@ export function parseArgs(argv: string[]): Args {
141
141
  else if (x === '--ask') a.ask = true;
142
142
  else if (x === '--yes' || x === '-y') a.yes = true;
143
143
  else if (x === '--vfs' || x === '--sandbox') a.vfs = true;
144
+ else if (x === '--scratch') a.scratch = true;
144
145
  else if (x === '--boddb') a.boddb = val(++i, x);
145
146
  else if (x === '--seed') a.seed = true;
146
147
  else if (x === '--shell') a.shell = true;
@@ -194,6 +195,7 @@ Flags:
194
195
  --no-stream disable token streaming
195
196
  (default: disk mode — full real filesystem access, like Claude Code)
196
197
  --vfs, --sandbox sandbox mode: work over an in-memory copy of cwd — real disk is NEVER modified
198
+ --scratch spill big web outputs to scratch files (kept out of context; peek via Grep/Ask)
197
199
  --boddb <dir> database-backed workspace: files live in a persistent bod-db store at <dir>,
198
200
  surviving across runs — real disk is NEVER modified (DB-native; add --seed below)
199
201
  --seed with --boddb: hydrate the store from cwd on the first run (empty DB) only
@@ -707,6 +709,7 @@ function optsFor(args: Args, ai: ChatLike, cfg: Partial<AgentConfig> = {}, extra
707
709
  boddb: args.boddb,
708
710
  seed: args.seed,
709
711
  realShell: args.shell, // undefined → core.ts defaults (on for disk, off for sandbox/boddb)
712
+ scratch: args.scratch,
710
713
  appendSystemPrompt: args.appendSystemPrompt,
711
714
  addDirs: args.addDirs,
712
715
  stream: args.stream,
@@ -1099,7 +1102,10 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1099
1102
  if (duplex) {
1100
1103
  // Workers must not stream into the voice channel — strip the host/stream seam (display hooks stay:
1101
1104
  // worker tool activity prints as dim stderr chrome). `signal` is per-task (duplex.ts owns it).
1102
- const { host: _host, stream: _stream, signal: _signal, ...wo } = agent.options;
1105
+ // Drop providerOptions too: they're computed for the MAIN agent's model (e.g. cursor's cwd/cursorSession)
1106
+ // and must never leak onto a worker whose model differs — DuplexAgent recomputes them per worker model
1107
+ // via providerOptionsFor below. (A cursor cursorSession leaking onto an anthropic worker is a hard 400.)
1108
+ const { host: _host, stream: _stream, signal: _signal, providerOptions: _po, ...wo } = agent.options;
1103
1109
  workerOptions = wo as AgentOptions;
1104
1110
  if (workerOptions.permissions)
1105
1111
  workerOptions.permissions = new PermissionPolicy({ ...workerOptions.permissions.options, host: undefined, ask: duplexAsk });
@@ -1194,6 +1200,7 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1194
1200
  ...((args.voiceModel ?? cfg.reflexModel) ? { reflexModel: resolveModelOrNewest((args.voiceModel ?? cfg.reflexModel)!) } : {}),
1195
1201
  actModel: agent.options.model,
1196
1202
  actOptions: workerOptions,
1203
+ providerOptionsFor: (m) => cursorProviderOptions(m, cwd, cfg.mcpServers),
1197
1204
  ...((args.thinkModel ?? cfg.thinkModel) !== undefined ? { thinkModel: (args.thinkModel ?? cfg.thinkModel) === false ? false : resolveModelOrNewest(String(args.thinkModel ?? cfg.thinkModel)) } : {}),
1198
1205
  host,
1199
1206
  ...(args.voice ? { voiceStyle: 'conversational' as const, progressUpdates: true, askRelay: true } : {}), // voice: progress asides + worker questions relayed through the conversation
@@ -1278,11 +1285,22 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1278
1285
  const img = grabClipboardImage(dir, String(Date.now()));
1279
1286
  return img ? { display: 'Image', ref: '@' + img.path, path: img.path } : null;
1280
1287
  };
1281
- // Ctrl-C: cancel the in-flight turn if one's running; otherwise clean up MCP children and exit.
1288
+ // Hard teardown used by the SIGINT path and the keypress force-quit escalation. Never let the
1289
+ // user get trapped: even if a turn's abort() fails to unwind (wedged stream/tool), this exits.
1290
+ const forceQuit = (code = 130) => {
1291
+ try { voiceIO?.stop(); } catch { /* mic/player may already be down */ }
1292
+ try { disposeCursorSessions(); } catch { /* reap warm cursor helpers, best-effort */ }
1293
+ void closeMcp(mounted);
1294
+ process.exit(code);
1295
+ };
1296
+ // Ctrl-C: cancel the in-flight turn if one's running; a second Ctrl-C while already cancelling
1297
+ // (i.e. the abort didn't unwind) force-quits. No active turn → clean up MCP children and exit.
1282
1298
  process.on('SIGINT', () => {
1283
- if (activeTurn) { activeTurn.abort(); voiceIO?.interrupt(); return; }
1284
- voiceIO?.stop(); // kill mic/player children they outlive the process otherwise
1285
- void closeMcp(mounted); process.exit(130);
1299
+ if (activeTurn) {
1300
+ if (aborting) { err(red('\n ⏻ force-quit\n')); forceQuit(); return; } // already cancelling hard escape
1301
+ activeTurn.abort(); voiceIO?.interrupt(); return;
1302
+ }
1303
+ forceQuit();
1286
1304
  });
1287
1305
  installCancelGuards(mounted);
1288
1306
  const store = new SessionStore(cwd);
@@ -1749,7 +1767,7 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1749
1767
  commands: { desc: 'pick a custom slash command to run (./.agent/commands)', run: () => pickAndRun('command') },
1750
1768
  skills: { desc: 'pick a skill to run (./.agent/skills)', run: () => pickAndRun('skill') },
1751
1769
  mcp: {
1752
- desc: 'manage MCP servers — /mcp [add <name> <cmd|url>] [login <name>] [remove <name>] [resources [name]]',
1770
+ desc: 'manage MCP servers — /mcp [add <name> <cmd|url>] [login <name>] [remove <name>] [tools [name]] [resources [name]]',
1753
1771
  run: async (a) => {
1754
1772
  const sub = a[0]?.toLowerCase();
1755
1773
  if (sub === 'login') {
@@ -1793,6 +1811,16 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1793
1811
  err(dim(` removed "${name}"\n`));
1794
1812
  return;
1795
1813
  }
1814
+ if (sub === 'tools') {
1815
+ const filter = a[1];
1816
+ const targets = filter ? mounted.filter((m) => m.name === filter) : mounted;
1817
+ if (!targets.length) { err(dim(` (no ${filter ? `MCP server "${filter}"` : 'MCP servers'} found)\n`)); return; }
1818
+ for (const m of targets) {
1819
+ err(` ${cyan(m.name)} ${dim(`(${m.tools.length} tools)`)}\n`);
1820
+ for (const t of m.tools) err(dim(` - ${t.name.replace(`mcp__${m.name}__`, '')}${t.description ? ' — ' + t.description.split('\n')[0] : ''}\n`));
1821
+ }
1822
+ return;
1823
+ }
1796
1824
  if (sub === 'resources') {
1797
1825
  const filter = a[1];
1798
1826
  const targets = filter ? mounted.filter((m) => m.name === filter) : mounted;
@@ -1812,14 +1840,15 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1812
1840
  if (!mounted.length) { err(dim(' (no MCP servers mounted)\n')); return; }
1813
1841
  for (const m of mounted) {
1814
1842
  const ver = m.serverInfo?.name ? dim(` · ${m.serverInfo.name}${m.serverInfo.version ? ' v' + m.serverInfo.version : ''}`) : '';
1815
- err(` ${cyan(m.name)}${ver} ${dim(`(${m.tools.length} tools)`)}\n`);
1816
- for (const t of m.tools) err(dim(` - ${t.name.replace(`mcp__${m.name}__`, '')}${t.description ? ' — ' + t.description.split('\n')[0] : ''}\n`));
1843
+ err(` ${green('✓')} ${cyan(m.name)}${ver} ${dim(`(${m.tools.length} tools)`)}\n`);
1817
1844
  }
1845
+ err(dim(' /mcp tools [name] to list tools · /mcp resources [name] for resources\n'));
1818
1846
  };
1819
1847
  const items: SelectItem[] = [
1820
1848
  { label: 'list', value: 'list', desc: `show mounted servers (${mounted.length})` },
1821
1849
  { label: 'add', value: 'add', desc: 'mount a new MCP server' },
1822
1850
  ...(mounted.length ? [
1851
+ { label: 'tools', value: 'tools', desc: 'list a server\'s tools' },
1823
1852
  { label: 'remove', value: 'remove', desc: 'unmount an MCP server' },
1824
1853
  { label: 'resources', value: 'resources', desc: 'list server resources' },
1825
1854
  ] : []),
@@ -1841,12 +1870,12 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1841
1870
  const rv = await selectMenu(process.stderr, { title: 'remove server', items: mounted.map((m) => ({ label: m.name, value: m.name })) });
1842
1871
  if (!rv) return;
1843
1872
  a = ['remove', rv];
1844
- } else if (picked === 'resources') {
1845
- if (mounted.length === 1) { a = ['resources', mounted[0].name]; }
1873
+ } else if (picked === 'tools' || picked === 'resources') {
1874
+ if (mounted.length === 1) { a = [picked, mounted[0].name]; }
1846
1875
  else {
1847
- const rv = await selectMenu(process.stderr, { title: 'server resources', items: [{ label: '(all)', value: '' }, ...mounted.map((m) => ({ label: m.name, value: m.name }))] });
1876
+ const rv = await selectMenu(process.stderr, { title: `server ${picked}`, items: [{ label: '(all)', value: '' }, ...mounted.map((m) => ({ label: m.name, value: m.name }))] });
1848
1877
  if (rv === null) return;
1849
- a = rv ? ['resources', rv] : ['resources'];
1878
+ a = rv ? [picked, rv] : [picked];
1850
1879
  }
1851
1880
  }
1852
1881
  // fall through to re-run with the assembled args
@@ -1953,8 +1982,16 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1953
1982
  const cancel = k === 'escape' || (key?.ctrl && k === 'c');
1954
1983
  if (cancel) {
1955
1984
  if (stashBuf) { stashBuf = ''; err('\r\x1b[K'); return; } // first Esc clears the stash line
1956
- if (!aborting) { aborting = true; activeTurn.abort(); voiceIO?.interrupt(); err(yellow('\n ⎋ cancelling…\n')); }
1957
- else if (k === 'escape' && !pendingRewind) { pendingRewind = true; err(dim(' ⎋⎋ jumping back to edit…\n')); }
1985
+ if (!aborting) {
1986
+ aborting = true; activeTurn.abort(); voiceIO?.interrupt();
1987
+ err(yellow('\n ⎋ cancelling…') + dim(' (Ctrl-C again to force-quit)\n'));
1988
+ // Watchdog: if the turn hasn't unwound shortly, the abort is wedged — surface the escape hatch.
1989
+ setTimeout(() => {
1990
+ if (activeTurn) err(red(' ⚠ still cancelling — press Ctrl-C to force-quit\n'));
1991
+ }, 4000).unref?.();
1992
+ } else if (key?.ctrl && k === 'c') {
1993
+ err(red('\n ⏻ force-quit\n')); forceQuit(); // already cancelling + another Ctrl-C → never trapped
1994
+ } else if (k === 'escape' && !pendingRewind) { pendingRewind = true; err(dim(' ⎋⎋ jumping back to edit…\n')); }
1958
1995
  return;
1959
1996
  }
1960
1997
  // Stash input: type ahead while the turn runs
@@ -2193,6 +2230,7 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
2193
2230
  // (voice children / sockets / MCP handles can keep the process alive otherwise).
2194
2231
  voiceIO?.stop();
2195
2232
  releaseStdin();
2233
+ disposeCursorSessions();
2196
2234
  await closeMcp(mounted);
2197
2235
  process.exit(130);
2198
2236
  }
@@ -2201,6 +2239,7 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
2201
2239
  }
2202
2240
  }
2203
2241
  releaseStdin();
2242
+ disposeCursorSessions(); // reap any warm cursor helper (its stdout pipe would otherwise keep the loop alive → hang on exit)
2204
2243
  await closeMcp(mounted);
2205
2244
  }
2206
2245
 
package/dist/cli.d.ts CHANGED
@@ -89,6 +89,7 @@ interface Args {
89
89
  addDirs?: string[];
90
90
  print?: boolean;
91
91
  debug?: boolean;
92
+ scratch?: boolean;
92
93
  }
93
94
  declare function parseArgs(argv: string[]): Args;
94
95
  /** Render a resumed conversation (like CC) so the user sees the context they're continuing: user