@asobacloud/nehanda 0.6.1 → 0.8.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.
@@ -18,6 +18,12 @@ import { fileURLToPath } from 'node:url'
18
18
  import { runUserTurn } from '../lib/orchestrate.mjs'
19
19
  import { loadInstructions } from '../lib/instructions.mjs'
20
20
  import { modelConfigFromAgent } from '../lib/modelConfig.mjs'
21
+ import { runConfigureWizard } from '../lib/configureProvider.mjs'
22
+ import {
23
+ TUI_SLASH_COMMANDS,
24
+ filterSlashAutocomplete,
25
+ parseMcpEnvParts,
26
+ } from '../lib/slashCommands.mjs'
21
27
  import { applyMcpConfig, loadMcpConfig, writeGlobalMcpConfig, readGlobalMcpConfig, globalMcpConfigPath, updateMcpServerEnv } from '../lib/mcp-config.mjs'
22
28
  import { getMcpServer, closeAllMcpServers, listMcpServers, mcpListResources } from '../lib/mcp.mjs'
23
29
  import { invalidateMcpToolCache } from '../lib/tools.mjs'
@@ -136,51 +142,7 @@ function renderMd(text) {
136
142
 
137
143
  // ── Constants ────────────────────────────────────────────────
138
144
  const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
139
- const SLASH_COMMANDS = [
140
- { name: '/model', desc: 'Change model endpoint' },
141
- { name: '/key', desc: 'Set API key (/key, /key openrouter, /key zhipu, /key openai)' },
142
- { name: '/config', desc: 'Show current config' },
143
- { name: '/mcp', desc: 'MCP server & tool management (/mcp list)' },
144
- { name: '/clear', desc: 'New conversation' },
145
- { name: '/retry', desc: 'Resend the last failed message' },
146
- { name: '/help', desc: 'Show commands' },
147
- { name: '/exit', desc: 'Quit' },
148
- ]
149
-
150
- // ── Model registry ────────────────────────────────────────────
151
- const DEFAULT_REGISTRY = {
152
- ollama_hosts: ['http://AsobaCorp-1.local:11434'],
153
- models: [
154
- { name: 'nehanda-rag-synthesis-27b', endpoint: 'https://nehanda-ml.asoba.co/v1', label: 'Nehanda 27B (EC2)', desc: 'Primary — fine-tuned Qwen3.6 27B, vLLM, af-south-1' },
155
- { name: 'deepseek-coder-v2:latest', endpoint: 'http://AsobaCorp-1.local:11434/v1', label: 'DeepSeek Coder V2 (LAN)', desc: 'Windows LAN delegate — coding specialist' },
156
- { name: 'qwen2.5:14b', endpoint: 'http://AsobaCorp-1.local:11434/v1', label: 'Qwen 2.5 14B (LAN)', desc: 'Windows LAN delegate — general reasoning' },
157
- ],
158
- }
159
- function readRegistry() {
160
- try { return JSON.parse(fs.readFileSync(path.join(AIMEE_DIR, 'model-registry.json'), 'utf8')) }
161
- catch { return DEFAULT_REGISTRY }
162
- }
163
- async function discoverOllamaModels(hosts) {
164
- const out = []
165
- await Promise.all(hosts.map(async host => {
166
- const base = host.replace(/\/$/, '')
167
- try {
168
- const res = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(2000) })
169
- if (!res.ok) return
170
- const data = await res.json()
171
- for (const m of (data.models || []))
172
- out.push({ name: m.name, endpoint: `${base}/v1`, label: `${m.name} (${new URL(base).hostname})`, desc: `Discovered — ${m.name}`, discovered: true })
173
- } catch {}
174
- }))
175
- return out
176
- }
177
- async function buildModelList() {
178
- const reg = readRegistry()
179
- const base = reg.models || []
180
- const disc = await discoverOllamaModels(reg.ollama_hosts || [])
181
- const keys = new Set(base.map(m => `${m.name}|${m.endpoint}`))
182
- return [...base, ...disc.filter(m => !keys.has(`${m.name}|${m.endpoint}`))]
183
- }
145
+ const SLASH_COMMANDS = TUI_SLASH_COMMANDS
184
146
 
185
147
  // ── Asoba lock-up (compact) ───────────────────────────────────
186
148
  function AsobaMark() {
@@ -302,15 +264,20 @@ function Spinner({ label }) {
302
264
  return e(Box, { marginLeft: 1 }, e(Text, { color: BRAND.accent }, SPINNER_FRAMES[frame % SPINNER_FRAMES.length] + ' '), e(Text, { dimColor: true }, verb + '…'))
303
265
  }
304
266
  function SlashMenu({ filter }) {
305
- const matches = SLASH_COMMANDS.filter(c => c.name.startsWith(filter) || c.name.includes(filter.slice(1)))
267
+ const matches = filterSlashAutocomplete(filter)
306
268
  if (!matches.length) return null
269
+ const pad = Math.min(36, Math.max(...matches.map(c => c.name.length), 12) + 1)
307
270
  return e(Box, { flexDirection: 'column', marginLeft: 2 },
308
271
  ...matches.map((c, i) => e(Box, { key: String(i) },
309
- e(Text, { color: BRAND.accent }, c.name.padEnd(12)), e(Text, { dimColor: true }, c.desc))))
272
+ e(Text, { color: BRAND.accent }, c.name.padEnd(pad)), e(Text, { dimColor: true }, c.desc))))
310
273
  }
311
274
  function InputArea({ onSubmit, isLoading }) {
312
275
  const [value, setValue] = React.useState('')
313
- const showMenu = value.startsWith('/') && !value.includes(' ')
276
+ // Show slash menu for top-level (`/m`) and while typing known parents (`/mcp `, `/key o`)
277
+ const showMenu = value.startsWith('/') && (
278
+ !value.includes(' ')
279
+ || /^\/(mcp|key)(\s|$)/i.test(value)
280
+ )
314
281
  const handleSubmit = React.useCallback(v => { if (!v.trim()) return; setValue(''); onSubmit(v.trim()) }, [onSubmit])
315
282
  if (isLoading) return e(Box, { paddingX: 1 }, e(Text, { dimColor: true }, 'esc to cancel'))
316
283
  return e(Box, { flexDirection: 'column' },
@@ -390,6 +357,7 @@ function BlockConfirmMenu({ payload, onDecide }) {
390
357
  }
391
358
 
392
359
  function TokenFooter({ calls, model }) {
360
+ const cols = process.stdout.columns || 80
393
361
  return e(Box, { flexDirection: 'column' },
394
362
  e(Text, { dimColor: true }, '─'.repeat(cols)),
395
363
  e(Box, { flexDirection: 'row', paddingX: 1, justifyContent: 'space-between' },
@@ -517,26 +485,84 @@ function stripThink(text) {
517
485
  .trim()
518
486
  }
519
487
 
488
+ /**
489
+ * UI adapter for Goose-style configure wizard (#8).
490
+ * Persists agents.json + runtimeSettings.model_config atomically at the end.
491
+ */
492
+ async function runUiConfigureWizard(bridge, opts = {}) {
493
+ try {
494
+ const result = await runConfigureWizard({
495
+ ask: q => bridge.ask(q),
496
+ select: (title, items) => bridge.select(title, items),
497
+ notify: msg => bridge.addMessage({ role: 'system', text: ` ${msg}` }),
498
+ resolveExistingKey: async (preset) => {
499
+ if (preset.keySlot === 'nehanda') return readApiKey()
500
+ if (preset.keySlot === 'zhipu') return resolveZhipuApiKey()
501
+ if (preset.keySlot === 'openai_compat') return resolveOpenAiCompatKey()
502
+ return ''
503
+ },
504
+ saveKey: async ({ slot, key }) => {
505
+ if (slot === 'nehanda') writeApiKey(key)
506
+ else if (slot === 'zhipu') saveSecureCredentials({ zhipuApiKey: key })
507
+ else if (slot === 'openai_compat') saveOpenAiCompatKey(key)
508
+ },
509
+ persist: async (cfg) => {
510
+ const data = readAgents()
511
+ const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
512
+ if (ag) {
513
+ ag.model = cfg.model
514
+ ag.endpoint = cfg.endpoint
515
+ if (cfg.provider === 'nehanda' && cfg.apiKey) ag.api_key = cfg.apiKey
516
+ }
517
+ fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
518
+ runtimeSettings.model_config = cfg.model_config || modelConfigFromAgent(
519
+ { model: cfg.model, endpoint: cfg.endpoint },
520
+ { apiKey: cfg.apiKey || readApiKey() },
521
+ )
522
+ },
523
+ }, opts)
524
+
525
+ if (!result) {
526
+ bridge.addMessage({ role: 'system', text: ' Configure cancelled.' })
527
+ return
528
+ }
529
+ bridge.refreshModel()
530
+ bridge.addMessage({ role: 'system', text: [
531
+ ` ${chalk.green('✓')} Provider → ${chalk.bold(result.provider)}`,
532
+ ` ${chalk.green('✓')} Host → ${chalk.dim(result.endpoint)}`,
533
+ ` ${chalk.green('✓')} Model → ${chalk.bold(result.model)}`,
534
+ ].join('\n') })
535
+ } catch (err) {
536
+ bridge.addMessage({ role: 'error', text: 'Configure failed: ' + err.message })
537
+ }
538
+ }
539
+
520
540
  // ── Command handler ───────────────────────────────────────────
521
541
  async function handleCommand(cmd, bridge) {
522
542
  const [name] = cmd.split(/\s+/)
523
543
 
524
544
  if (name === '/help') {
525
- bridge.addMessage({ role: 'system', text: '\nCommands:\n' +
526
- SLASH_COMMANDS.map(c => ` ${chalk.hex(BRAND.accent)(c.name.padEnd(12))} ${chalk.dim(c.desc)}`).join('\n') + '\n' })
545
+ const pad = Math.max(...TUI_SLASH_COMMANDS.map(c => c.name.length), 12) + 2
546
+ const body = TUI_SLASH_COMMANDS.map(c =>
547
+ ` ${chalk.hex(BRAND.accent)(c.name.padEnd(pad))}${chalk.dim(c.desc)}`
548
+ ).join('\n')
549
+ bridge.addMessage({ role: 'system', text: `\nCommands:\n\n${body}\n` })
527
550
  return
528
551
  }
529
552
  if (name === '/config') {
530
553
  const data = readAgents()
531
554
  const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
555
+ const mc = runtimeSettings?.model_config
532
556
  const compatKeySet = !!resolveOpenAiCompatKey()
533
557
  const zhipuKeySet = !!resolveZhipuApiKey()
534
558
  bridge.addMessage({ role: 'system', text: [
535
- '', ` Model: ${chalk.hex(BRAND.accent)(ag?.model || '—')}`,
536
- ` Endpoint: ${chalk.dim(ag?.endpoint || '—')}`,
559
+ '', ` Provider: ${chalk.hex(BRAND.accent)(mc?.provider || '—')}`,
560
+ ` Model: ${chalk.hex(BRAND.accent)(ag?.model || mc?.custom_model_name || '—')}`,
561
+ ` Endpoint: ${chalk.dim(ag?.endpoint || mc?.base_url || '—')}`,
537
562
  ` Nehanda key: ${readApiKey() ? chalk.green('set') : chalk.hex(BRAND.red)('not set')}`,
538
563
  ` OpenAI-compat key: ${compatKeySet ? chalk.green('set') : chalk.dim('not set')}`,
539
- ` Zhipu key: ${zhipuKeySet ? chalk.green('set') : chalk.dim('not set')}`, '',
564
+ ` Zhipu key: ${zhipuKeySet ? chalk.green('set') : chalk.dim('not set')}`,
565
+ ` ${chalk.dim('Use /model to configure provider, key, host, and model in one flow.')}`, '',
540
566
  ].join('\n') })
541
567
  return
542
568
  }
@@ -552,74 +578,43 @@ async function handleCommand(cmd, bridge) {
552
578
  }
553
579
  if (name === '/exit' || name === '/quit') { bridge.exit(); process.exit(0) }
554
580
  if (name === '/key') {
555
- // Parse optional provider subcommand: /key [openrouter|zhipu|openai] [value]
581
+ // /key [openrouter|zhipu|openai|nehanda] [value] — save key then continue configure wizard
556
582
  const parts = cmd.trim().split(/\s+/)
557
- const sub = parts[1] || ''
558
- const KNOWN_COMPAT_PROVIDERS = ['openrouter', 'openai', 'zhipu']
583
+ const sub = (parts[1] || '').toLowerCase()
584
+ const KEY_TO_PRESET = {
585
+ openrouter: 'openrouter',
586
+ openai: 'openai_compatible',
587
+ zhipu: 'zhipu',
588
+ nehanda: 'nehanda',
589
+ }
559
590
 
560
- if (!sub || !KNOWN_COMPAT_PROVIDERS.includes(sub.toLowerCase())) {
561
- // Default: Nehanda API key (existing behaviour, unchanged)
591
+ if (!sub || !KEY_TO_PRESET[sub]) {
562
592
  const answer = await bridge.ask('New Nehanda API key:')
563
593
  if (!answer) return
564
- try { writeApiKey(answer); bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Nehanda API key updated.` }) }
565
- catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
594
+ try {
595
+ writeApiKey(answer)
596
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Nehanda API key saved — continuing configure…` })
597
+ await runUiConfigureWizard(bridge, { presetId: 'nehanda', prefilledKey: answer })
598
+ } catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
566
599
  return
567
600
  }
568
601
 
569
- const provider = sub.toLowerCase()
570
- // Value may be provided inline (/key openrouter sk-...) or prompted
571
- const inlineValue = parts.slice(2).join('').trim()
572
- const answer = inlineValue || await bridge.ask(`New ${provider} API key:`)
602
+ const presetId = KEY_TO_PRESET[sub]
603
+ const inlineValue = parts.slice(2).join(' ').trim()
604
+ const answer = inlineValue || await bridge.ask(`New ${sub} API key:`)
573
605
  if (!answer) return
574
606
 
575
607
  try {
576
- if (provider === 'zhipu') {
577
- saveSecureCredentials({ zhipuApiKey: answer })
578
- bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Zhipu API key saved to secure storage.` })
579
- } else {
580
- // openrouter or openai — both use the openaiCompatApiKey slot
581
- saveOpenAiCompatKey(answer)
582
- if (provider === 'openrouter') {
583
- // Auto-configure base_url and provider for OpenRouter
584
- runtimeSettings.model_config = {
585
- ...runtimeSettings.model_config,
586
- base_url: 'https://openrouter.ai/api/v1',
587
- provider: 'openai_compatible',
588
- }
589
- bridge.addMessage({ role: 'system', text: [
590
- ` ${chalk.green('✓')} OpenRouter API key saved to secure storage.`,
591
- ` ${chalk.green('✓')} Base URL set to ${chalk.dim('https://openrouter.ai/api/v1')}`,
592
- ` ${chalk.dim('Use /model to select an OpenRouter model (e.g. openai_compatible/anthropic/claude-3.5-sonnet)')}`,
593
- ].join('\n') })
594
- } else {
595
- bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} OpenAI-compat API key saved to secure storage.` })
596
- }
597
- }
608
+ if (sub === 'zhipu') saveSecureCredentials({ zhipuApiKey: answer })
609
+ else if (sub === 'nehanda') writeApiKey(answer)
610
+ else saveOpenAiCompatKey(answer)
611
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} ${sub} API key saved — continuing configure…` })
612
+ await runUiConfigureWizard(bridge, { presetId, prefilledKey: answer })
598
613
  } catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
599
614
  return
600
615
  }
601
616
  if (name === '/model') {
602
- bridge.addMessage({ role: 'system', text: ' Discovering models…' })
603
- const all = await buildModelList()
604
- const data0 = readAgents()
605
- const curAg = data0.agents?.find(a => a.name === data0.default_agent) || data0.agents?.[0]
606
- const cur = curAg?.model || ''
607
- const items = all.map(m => ({ ...m, label: m.name === cur ? `${m.label} ✓` : m.label }))
608
- const sel = await bridge.select('Select model', items)
609
- if (!sel) return
610
- try {
611
- const data = readAgents()
612
- const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
613
- if (ag) { ag.model = sel.name; ag.endpoint = sel.endpoint }
614
- fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
615
- // Keep in-session model_config in sync so the next turn uses the selected wire name
616
- runtimeSettings.model_config = modelConfigFromAgent(
617
- ag || { model: sel.name, endpoint: sel.endpoint },
618
- { apiKey: readApiKey() },
619
- )
620
- bridge.refreshModel()
621
- bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Model → ${chalk.bold(sel.name)}` })
622
- } catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
617
+ await runUiConfigureWizard(bridge)
623
618
  return
624
619
  }
625
620
  if (name === '/mcp') {
@@ -629,14 +624,19 @@ async function handleCommand(cmd, bridge) {
629
624
  const serverNames = Object.keys(servers)
630
625
 
631
626
  if (!sub || sub === 'help') {
627
+ const pad = 40
628
+ const row = (n, d) => ` ${chalk.hex(BRAND.accent)(n.padEnd(pad))}${chalk.dim(d)}`
632
629
  const lines = [
633
630
  '',
634
631
  ` ${chalk.bold('MCP Server & Tool Commands:')}`,
635
- ` ${chalk.hex(BRAND.accent)('/mcp list'.padEnd(28))} ${chalk.dim('Discover and list all available MCP tools')}`,
636
- ` ${chalk.hex(BRAND.accent)('/mcp status'.padEnd(28))} ${chalk.dim('Show configured servers and command paths')}`,
637
- ` ${chalk.hex(BRAND.accent)('/mcp reload [server]'.padEnd(28))} ${chalk.dim('Reload mcp.json configs into session')}`,
638
- ` ${chalk.hex(BRAND.accent)('/mcp add <name> <cmd> […]'.padEnd(28))} ${chalk.dim('Register a new MCP server')}`,
639
- ` ${chalk.hex(BRAND.accent)('/mcp env [server] [K] [V]'.padEnd(28))} ${chalk.dim('View or set server environment variables')}`,
632
+ row('/mcp list', 'List tools from configured MCP servers'),
633
+ row('/mcp status', 'Show configured servers'),
634
+ row('/mcp reload [server]', 'Reload mcp.json into this session'),
635
+ row('/mcp add <name> <command> [args…]', 'Register a server'),
636
+ row('/mcp env', 'Show env vars for every server (values masked)'),
637
+ row('/mcp env <server>', 'Show env vars for one server'),
638
+ row('/mcp env <server> <NAME> <VALUE>', 'Set env var e.g. /mcp env jupyter JUPYTER_TOKEN sk-…'),
639
+ row('/mcp env unset <server> <NAME>', 'Remove an env var'),
640
640
  '',
641
641
  ` ${chalk.dim(`Configured servers: ${serverNames.length ? serverNames.join(', ') : 'none'}`)}`,
642
642
  ` ${chalk.dim('Tip: Run /mcp list to inspect tools injected into the assistant.')}`,
@@ -726,24 +726,26 @@ async function handleCommand(cmd, bridge) {
726
726
  }
727
727
 
728
728
  if (sub === 'env') {
729
- const targetServer = parts[2]
730
- const envKey = parts[3]
731
- const envVal = parts[4]
729
+ const parsed = parseMcpEnvParts(parts.slice(1))
730
+
731
+ if (parsed.action === 'usage_error') {
732
+ bridge.addMessage({ role: 'system', text: ' ' + parsed.error.replace(/\n/g, '\n ') })
733
+ return
734
+ }
732
735
 
733
- if (!targetServer) {
734
- // Show env for all servers
736
+ if (parsed.action === 'list_all') {
735
737
  if (!serverNames.length) {
736
738
  bridge.addMessage({ role: 'system', text: ' No MCP servers configured.' })
737
739
  return
738
740
  }
739
741
  const lines = ['\n MCP Server Environment Variables:']
740
- for (const name of serverNames) {
741
- const cfg = servers[name]
742
+ for (const srv of serverNames) {
743
+ const cfg = servers[srv]
742
744
  const envKeys = Object.keys(cfg.env || {})
743
745
  if (!envKeys.length) {
744
- lines.push(` ${chalk.hex(BRAND.accent)(name)}: (none)`)
746
+ lines.push(` ${chalk.hex(BRAND.accent)(srv)}: (none)`)
745
747
  } else {
746
- lines.push(` ${chalk.hex(BRAND.accent)(name)}:`)
748
+ lines.push(` ${chalk.hex(BRAND.accent)(srv)}:`)
747
749
  for (const k of envKeys) {
748
750
  const v = cfg.env[k]
749
751
  const masked = v.length > 10 ? v.slice(0, 6) + '...' + v.slice(-4) : '********'
@@ -755,18 +757,17 @@ async function handleCommand(cmd, bridge) {
755
757
  return
756
758
  }
757
759
 
758
- if (!envKey) {
759
- // Show env for a specific server
760
- const cfg = servers[targetServer]
760
+ if (parsed.action === 'list_server') {
761
+ const cfg = servers[parsed.server]
761
762
  if (!cfg) {
762
- bridge.addMessage({ role: 'system', text: ` Server "${targetServer}" not found.` })
763
+ bridge.addMessage({ role: 'system', text: ` Server "${parsed.server}" not found.` })
763
764
  return
764
765
  }
765
766
  const envKeys = Object.keys(cfg.env || {})
766
767
  if (!envKeys.length) {
767
- bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.accent)(targetServer)}: (no env vars set)` })
768
+ bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.accent)(parsed.server)}: (no env vars set)\n Set one with: /mcp env ${parsed.server} <NAME> <VALUE>` })
768
769
  } else {
769
- const lines = [` ${chalk.hex(BRAND.accent)(targetServer)}:`]
770
+ const lines = [` ${chalk.hex(BRAND.accent)(parsed.server)}:`]
770
771
  for (const k of envKeys) {
771
772
  const v = cfg.env[k]
772
773
  const masked = v.length > 10 ? v.slice(0, 6) + '...' + v.slice(-4) : '********'
@@ -777,25 +778,26 @@ async function handleCommand(cmd, bridge) {
777
778
  return
778
779
  }
779
780
 
780
- // Set or clear env var
781
- const result = updateMcpServerEnv(targetServer, envKey, envVal || '', process.cwd())
782
- if (!result.ok) {
783
- bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.red)('✗')} ${result.error}` })
781
+ if (parsed.action === 'set' || parsed.action === 'unset') {
782
+ const value = parsed.action === 'unset' ? '' : parsed.value
783
+ const result = updateMcpServerEnv(parsed.server, parsed.name, value, process.cwd())
784
+ if (!result.ok) {
785
+ bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.red)('✗')} ${result.error}` })
786
+ return
787
+ }
788
+ runtimeSettings.mcp_servers = {}
789
+ applyMcpConfig(runtimeSettings, process.cwd())
790
+ invalidateMcpToolCache()
791
+ if (parsed.action === 'set') {
792
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Set ${chalk.bold(parsed.name)} = ******** on ${chalk.bold(parsed.server)}\n Config file: ${result.path}` })
793
+ } else {
794
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Unset ${chalk.bold(parsed.name)} on ${chalk.bold(parsed.server)}\n Config file: ${result.path}` })
795
+ }
784
796
  return
785
797
  }
786
- // Reload runtime settings and bust cache
787
- runtimeSettings.mcp_servers = {}
788
- applyMcpConfig(runtimeSettings, process.cwd())
789
- invalidateMcpToolCache()
790
- if (result.set) {
791
- bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Set ${chalk.bold(envKey)} = ${'********'} on ${chalk.bold(targetServer)}\n Config file: ${result.path}` })
792
- } else {
793
- bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Cleared ${chalk.bold(envKey)} on ${chalk.bold(targetServer)}\n Config file: ${result.path}` })
794
- }
795
- return
796
798
  }
797
799
 
798
- bridge.addMessage({ role: 'system', text: ' /mcp sub-commands: status · list · reload [server] · add <name> <command> [args…] · env [server] [KEY [VALUE]]' })
800
+ bridge.addMessage({ role: 'system', text: ' /mcp sub-commands: status · list · reload · add · env · env unset — type /help for full list' })
799
801
  return
800
802
  }
801
803
 
@@ -862,6 +864,8 @@ bridge.onSubmit = async (text) => {
862
864
  // field belongs to aimee and was silently reintroducing conflicting tool-call
863
865
  // syntax (<tool_call><name>...) that fights orchestrate.mjs's [TOOL_CALL] format.
864
866
  onaInstructions: onaInstructionsContent || null,
867
+ // Esc/Ctrl+C abort — must reach fetch / Anthropic stream (issue #7).
868
+ signal: controller.signal,
865
869
  settings: {
866
870
  ...runtimeSettings,
867
871
  model_config: modelConfig,
@@ -914,7 +918,7 @@ bridge.onSubmit = async (text) => {
914
918
  bridge.lastFailedText = null
915
919
 
916
920
  } catch (err) {
917
- if (err.name === 'AbortError') {
921
+ if (err.name === 'AbortError' || err.name === 'APIUserAbortError') {
918
922
  bridge.endAssistant({ role: 'assistant', text: lastPartial, cancelled: true })
919
923
  } else {
920
924
  bridge.discardAssistant()