@asobacloud/nehanda 0.0.0-stage → 0.2.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +57 -0
  3. package/README.md +404 -2
  4. package/bin/agent.mjs +1260 -0
  5. package/bin/nehanda-ui.mjs +824 -0
  6. package/lib/anthropicHeaders.mjs +8 -0
  7. package/lib/app.mjs +313 -0
  8. package/lib/auth.mjs +255 -0
  9. package/lib/bashguard.mjs +159 -0
  10. package/lib/compact.mjs +306 -0
  11. package/lib/energy-middleware.mjs +318 -0
  12. package/lib/hookShapeOrder.mjs +30 -0
  13. package/lib/hookplane.mjs +228 -0
  14. package/lib/instructions.mjs +23 -0
  15. package/lib/jev_read.mjs +216 -0
  16. package/lib/jev_write.mjs +253 -0
  17. package/lib/laya.mjs +296 -0
  18. package/lib/laya_sidecar.mjs +198 -0
  19. package/lib/mcp-config.mjs +189 -0
  20. package/lib/mcp.mjs +169 -0
  21. package/lib/modelConfig.mjs +152 -0
  22. package/lib/modelDiscovery.mjs +205 -0
  23. package/lib/openaiCompat.mjs +147 -0
  24. package/lib/orchestrate.mjs +1163 -0
  25. package/lib/paths.mjs +20 -0
  26. package/lib/permissions.mjs +44 -0
  27. package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
  28. package/lib/scripts/audit-code-integrity.py +437 -0
  29. package/lib/scripts/ensure-laya-env.sh +55 -0
  30. package/lib/scripts/js-safety-checker.cjs +308 -0
  31. package/lib/scripts/laya_worker.py +171 -0
  32. package/lib/scripts/odse-transform.py +97 -0
  33. package/lib/scripts/python-safety-checker.py +197 -0
  34. package/lib/scripts/shell-safety-checker.sh +306 -0
  35. package/lib/settings.mjs +147 -0
  36. package/lib/store.mjs +278 -0
  37. package/lib/team.mjs +69 -0
  38. package/lib/testgen.mjs +146 -0
  39. package/lib/tools/AuditCodeIntegrity.json +30 -0
  40. package/lib/tools/JsSafetyChecker.json +29 -0
  41. package/lib/tools/PythonSafetyChecker.json +29 -0
  42. package/lib/tools/ShellSafetyChecker.json +29 -0
  43. package/lib/tools.mjs +907 -0
  44. package/lib/transcript.mjs +223 -0
  45. package/lib/ui.mjs +286 -0
  46. package/lib/workflow.mjs +91 -0
  47. package/lib/worktree.mjs +56 -0
  48. package/package.json +50 -4
  49. package/schema.sql +181 -0
  50. package/scripts/postinstall.mjs +62 -0
  51. package/scripts/sdlc-acceptance.sh +477 -0
  52. package/scripts/verify-sdlc-hook-order.mjs +26 -0
  53. package/templates/test_e2e.sh +32 -0
  54. package/templates/test_file.sh +14 -0
  55. package/templates/test_hook.sh +27 -0
  56. package/templates/test_http.sh +17 -0
  57. package/templates/test_phase.sh +23 -0
  58. package/templates/test_process.sh +14 -0
  59. package/templates/test_tool.sh +24 -0
  60. package/templates/validate_test.sh +55 -0
@@ -0,0 +1,824 @@
1
+ #!/usr/bin/env node
2
+ // nehanda-ui — Ink terminal UI for ona-code
3
+ // Connects directly to nehanda-ml.asoba.co via the in-process ona-code engine.
4
+ // No aimee-server, no HTTP daemon, no cross-repo resolution.
5
+
6
+ if (process.stdout.isTTY) {
7
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H')
8
+ }
9
+
10
+ import fs from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { execFile } from 'node:child_process'
14
+ import { promisify } from 'node:util'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ // ── In-process engine imports (direct, single-repo) ──────────
18
+ import { runUserTurn } from '../lib/orchestrate.mjs'
19
+ import { loadInstructions } from '../lib/instructions.mjs'
20
+ import { modelConfigFromAgent } from '../lib/modelConfig.mjs'
21
+ import { applyMcpConfig, loadMcpConfig, writeGlobalMcpConfig, readGlobalMcpConfig, globalMcpConfigPath, updateMcpServerEnv } from '../lib/mcp-config.mjs'
22
+ import { getMcpServer, closeAllMcpServers, listMcpServers, mcpListResources } from '../lib/mcp.mjs'
23
+ import { invalidateMcpToolCache } from '../lib/tools.mjs'
24
+ import Database from 'better-sqlite3'
25
+ import { ensureLayaReady } from '../lib/laya_sidecar.mjs'
26
+
27
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
28
+ const REPO_ROOT = path.resolve(__dirname, '..')
29
+
30
+ // All remaining UI deps live in ona-code's own node_modules
31
+ const { default: React } = await import(`${REPO_ROOT}/node_modules/react/index.js`)
32
+ const { render, Box, Text, useApp, useInput, Static }
33
+ = await import(`${REPO_ROOT}/node_modules/ink/build/index.js`)
34
+ const { default: TextInput } = await import(`${REPO_ROOT}/node_modules/ink-text-input/build/index.js`)
35
+ const { default: chalk } = await import(`${REPO_ROOT}/node_modules/chalk/source/index.js`)
36
+ const { marked } = await import(`${REPO_ROOT}/node_modules/marked/lib/marked.esm.js`)
37
+ const { default: TerminalRenderer }= await import(`${REPO_ROOT}/node_modules/marked-terminal/index.js`)
38
+
39
+ const e = React.createElement
40
+
41
+ // ── Asoba 3 Visual Identity ───────────────────────────────────
42
+ const BRAND = {
43
+ accent: '#455BF1',
44
+ lavender: '#9D93D6',
45
+ coral: '#F2AEAC',
46
+ red: '#E20419',
47
+ dim: 'gray',
48
+ }
49
+
50
+ // ── Config paths ─────────────────────────────────────────────
51
+ const NEHANDA_DIR = path.join(os.homedir(), '.config', 'nehanda')
52
+ const NEHANDA_DB = path.join(NEHANDA_DIR, 'ona-session.db')
53
+ const AIMEE_DIR = path.join(os.homedir(), '.config', 'aimee')
54
+ const AGENTS_JSON = path.join(AIMEE_DIR, 'agents.json')
55
+ const SPLASH_SEEN = path.join(NEHANDA_DIR, '.splash-seen')
56
+
57
+ function hasSeenSplash() { try { return fs.existsSync(SPLASH_SEEN) } catch { return false } }
58
+ function markSplashSeen() {
59
+ try { fs.mkdirSync(NEHANDA_DIR, { recursive: true }); fs.writeFileSync(SPLASH_SEEN, new Date().toISOString()) }
60
+ catch {}
61
+ }
62
+
63
+ // ── Config helpers ────────────────────────────────────────────
64
+ function readAgents() {
65
+ try { return JSON.parse(fs.readFileSync(AGENTS_JSON, 'utf8')) }
66
+ catch { return { agents: [], default_agent: '' } }
67
+ }
68
+
69
+ /** Nehanda API key: stored in agents.json api_key or NEHANDA_API_KEY env var. */
70
+ function readApiKey() {
71
+ const data = readAgents()
72
+ const agent = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
73
+ return agent?.api_key || process.env.NEHANDA_API_KEY || ''
74
+ }
75
+
76
+ function writeApiKey(key) {
77
+ const data = readAgents()
78
+ const agent = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
79
+ if (agent) { agent.api_key = key; fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8') }
80
+ }
81
+
82
+ // ── Session DB bootstrap ─────────────────────────────────────
83
+ fs.mkdirSync(NEHANDA_DIR, { recursive: true })
84
+ const sessionDb = new Database(NEHANDA_DB)
85
+ const { content: onaInstructionsContent } = loadInstructions(process.cwd())
86
+ sessionDb.exec(`
87
+ CREATE TABLE IF NOT EXISTS conversations(
88
+ id TEXT PRIMARY KEY, project_dir TEXT,
89
+ phase TEXT DEFAULT 'idle',
90
+ last_active TEXT DEFAULT (datetime('now'))
91
+ );
92
+ CREATE TABLE IF NOT EXISTS transcript_entries(
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ session_id TEXT NOT NULL, sequence INTEGER NOT NULL,
95
+ entry_type TEXT NOT NULL, payload_json TEXT NOT NULL,
96
+ tool_use_id TEXT, created_at TEXT DEFAULT (datetime('now'))
97
+ );
98
+ CREATE TABLE IF NOT EXISTS plans(
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ conversation_id TEXT NOT NULL, content TEXT,
101
+ hash TEXT, status TEXT DEFAULT 'draft', approved_at TEXT
102
+ );
103
+ CREATE TABLE IF NOT EXISTS events(
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ conversation_id TEXT NOT NULL,
106
+ event_type TEXT NOT NULL, detail TEXT,
107
+ created_at TEXT DEFAULT (datetime('now'))
108
+ );
109
+ CREATE TABLE IF NOT EXISTS summaries(
110
+ conversation_id TEXT PRIMARY KEY,
111
+ content TEXT, word_count INTEGER,
112
+ updated_at TEXT DEFAULT (datetime('now'))
113
+ );
114
+ `)
115
+
116
+ // ── Markdown ─────────────────────────────────────────────────
117
+ marked.setOptions({
118
+ renderer: new TerminalRenderer({
119
+ code: chalk.hex(BRAND.lavender),
120
+ codespan: chalk.hex(BRAND.lavender),
121
+ strong: chalk.bold,
122
+ em: chalk.italic,
123
+ heading: chalk.bold.hex(BRAND.accent),
124
+ hr: () => chalk.dim('─'.repeat(50)),
125
+ paragraph: text => text + '\n',
126
+ link: (href, _t, text) => `${text} ${chalk.dim.underline(href)}`,
127
+ })
128
+ })
129
+ function renderMd(text) {
130
+ if (!text) return ''
131
+ try { return marked(text).replace(/\n{3,}/g, '\n\n').trimEnd() }
132
+ catch { return text }
133
+ }
134
+
135
+ // ── Constants ────────────────────────────────────────────────
136
+ const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
137
+ const SLASH_COMMANDS = [
138
+ { name: '/model', desc: 'Change model endpoint' },
139
+ { name: '/key', desc: 'Set Nehanda API key' },
140
+ { name: '/config', desc: 'Show current config' },
141
+ { name: '/mcp', desc: 'MCP server & tool management (/mcp list)' },
142
+ { name: '/clear', desc: 'New conversation' },
143
+ { name: '/retry', desc: 'Resend the last failed message' },
144
+ { name: '/help', desc: 'Show commands' },
145
+ { name: '/exit', desc: 'Quit' },
146
+ ]
147
+
148
+ // ── Model registry ────────────────────────────────────────────
149
+ const DEFAULT_REGISTRY = {
150
+ ollama_hosts: ['http://AsobaCorp-1.local:11434'],
151
+ models: [
152
+ { 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' },
153
+ { name: 'deepseek-coder-v2:latest', endpoint: 'http://AsobaCorp-1.local:11434/v1', label: 'DeepSeek Coder V2 (LAN)', desc: 'Windows LAN delegate — coding specialist' },
154
+ { name: 'qwen2.5:14b', endpoint: 'http://AsobaCorp-1.local:11434/v1', label: 'Qwen 2.5 14B (LAN)', desc: 'Windows LAN delegate — general reasoning' },
155
+ ],
156
+ }
157
+ function readRegistry() {
158
+ try { return JSON.parse(fs.readFileSync(path.join(AIMEE_DIR, 'model-registry.json'), 'utf8')) }
159
+ catch { return DEFAULT_REGISTRY }
160
+ }
161
+ async function discoverOllamaModels(hosts) {
162
+ const out = []
163
+ await Promise.all(hosts.map(async host => {
164
+ const base = host.replace(/\/$/, '')
165
+ try {
166
+ const res = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(2000) })
167
+ if (!res.ok) return
168
+ const data = await res.json()
169
+ for (const m of (data.models || []))
170
+ out.push({ name: m.name, endpoint: `${base}/v1`, label: `${m.name} (${new URL(base).hostname})`, desc: `Discovered — ${m.name}`, discovered: true })
171
+ } catch {}
172
+ }))
173
+ return out
174
+ }
175
+ async function buildModelList() {
176
+ const reg = readRegistry()
177
+ const base = reg.models || []
178
+ const disc = await discoverOllamaModels(reg.ollama_hosts || [])
179
+ const keys = new Set(base.map(m => `${m.name}|${m.endpoint}`))
180
+ return [...base, ...disc.filter(m => !keys.has(`${m.name}|${m.endpoint}`))]
181
+ }
182
+
183
+ // ── Asoba lock-up (compact) ───────────────────────────────────
184
+ function AsobaMark() {
185
+ return e(Box, { flexDirection: 'column', alignItems: 'center' },
186
+ e(Text, {}, e(Text, { color: BRAND.accent }, '▐▐▐ ')),
187
+ e(Text, { bold: true }, 'N E H A N D A'),
188
+ e(Text, { dimColor: true }, 'by Asoba'),
189
+ )
190
+ }
191
+
192
+ const ASOBA_BADGE = `
193
+ ............'.
194
+ '....................'
195
+ ............................
196
+ ................................
197
+ ....................................
198
+ ......................................
199
+ ........................................
200
+ \`.................;(LwwU{,.................
201
+ ...............I&$$$$$$$$$$a^...............
202
+ .............."W$$$$@$@@$@$$$$Y...............
203
+ .............,@$$$$$$$$$$$@$@$$O..............
204
+ '.............W$$$$$$t...^M$$$$$@;.............
205
+ '.............$$$@$$/.....'@$$$$@{.............
206
+ ..............@$$$$@u......@$$@$$).............
207
+ ..............k$$$$@@q:..:.B$$$$$).............
208
+ .............\`8$$$@$$$$$W'B$$$$$).............
209
+ ..............'q$@$$$$$$W'B$@$$$).............
210
+ ...............\`d$@$$@$W'B@$$$$(............
211
+ ..................'I]|[l....................
212
+ .........................................'
213
+ .......................................'
214
+ .....................................
215
+ ..................................
216
+ ..............................
217
+ ........................
218
+ ..................
219
+ \`\``
220
+
221
+ const BADGE_DOT = new Set([' ','.', "'", '`',',',':',';','^'])
222
+ function BadgeLine({ line }) {
223
+ const runs = []
224
+ for (const ch of line) {
225
+ const cls = BADGE_DOT.has(ch) ? 'dot' : 'glyph'
226
+ const last = runs[runs.length - 1]
227
+ if (last && last.cls === cls) last.text += ch
228
+ else runs.push({ cls, text: ch })
229
+ }
230
+ return e(Text, {}, ...runs.map((r, i) => e(Text, {
231
+ key: String(i), color: r.cls === 'glyph' ? BRAND.accent : undefined,
232
+ bold: r.cls === 'glyph', dimColor: r.cls === 'dot',
233
+ }, r.text)))
234
+ }
235
+ function AsobaBadge() {
236
+ const lines = ASOBA_BADGE.split('\n')
237
+ return e(Box, { flexDirection: 'column', alignItems: 'center' },
238
+ ...lines.map((l, i) => e(BadgeLine, { key: String(i), line: l })))
239
+ }
240
+ function SplashScreen({ onDone }) {
241
+ useInput(() => onDone())
242
+ React.useEffect(() => { const t = setTimeout(onDone, 1400); return () => clearTimeout(t) }, [onDone])
243
+ let cols = 80; try { cols = process.stdout.columns || 80 } catch {}
244
+ const bw = Math.max(...ASOBA_BADGE.split('\n').map(l => l.length))
245
+ return e(Box, { flexDirection: 'column', alignItems: 'center', marginY: 1 },
246
+ cols >= bw + 4 ? e(AsobaBadge, {}) : e(AsobaMark, {}),
247
+ e(Box, { marginTop: 1 }, e(Text, { bold: true, color: BRAND.accent }, 'NEHANDA')),
248
+ e(Text, { dimColor: true }, 'by Asoba'),
249
+ e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, 'press any key to continue…')),
250
+ )
251
+ }
252
+ function WelcomeBanner({ model, endpoint }) {
253
+ let cols = 80; try { cols = process.stdout.columns || 80 } catch {}
254
+ const bw = Math.min(cols - 2, 80)
255
+ const lw = Math.floor(bw * 0.42), rw = bw - lw - 3
256
+ return e(Box, { flexDirection: 'column', borderStyle: 'single', borderColor: BRAND.accent, width: bw, marginBottom: 1 },
257
+ e(Box, { flexDirection: 'row', paddingX: 1 },
258
+ e(Box, { flexDirection: 'column', width: lw, alignItems: 'center', paddingY: 1 },
259
+ e(AsobaMark, {}), e(Text, { dimColor: true }, model || '—')),
260
+ e(Box, { width: 1, borderStyle: 'single', borderColor: BRAND.accent, borderTop: false, borderBottom: false, borderRight: false, borderLeft: true }),
261
+ e(Box, { flexDirection: 'column', width: rw, paddingLeft: 1, paddingY: 1 },
262
+ e(Text, { bold: true, color: BRAND.accent }, 'Configuration'),
263
+ e(Box, { marginTop: 1, flexDirection: 'column' },
264
+ e(Text, {}, e(Text, { dimColor: true }, 'Model: '), e(Text, { color: BRAND.lavender, bold: true }, model || '—')),
265
+ e(Text, {}, e(Text, { dimColor: true }, 'Endpoint: '), e(Text, { dimColor: true }, endpoint || '—')),
266
+ ),
267
+ e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, 'Type /help for commands')),
268
+ ),
269
+ ),
270
+ )
271
+ }
272
+
273
+ // ── Message view ─────────────────────────────────────────────
274
+ function MessageView({ msg }) {
275
+ if (msg.role === 'user')
276
+ return e(Box, { flexDirection: 'row' }, e(Text, { bold: true, color: BRAND.accent }, '❯ '),
277
+ e(Box, { flexShrink: 1 }, e(Text, { wrap: 'wrap' }, msg.text)))
278
+ if (msg.role === 'assistant') {
279
+ if (!msg.text) return msg.cancelled
280
+ ? e(Box, { flexDirection: 'row' }, e(Text, { dimColor: true }, '● '), e(Text, { dimColor: true, italic: true }, '(cancelled)'))
281
+ : null
282
+ return e(Box, { flexDirection: 'row' }, e(Text, { dimColor: true }, '● '),
283
+ e(Box, { flexDirection: 'column', flexShrink: 1 },
284
+ e(Text, {}, renderMd(msg.text)),
285
+ msg.cancelled ? e(Text, { dimColor: true, italic: true }, '⏹ cancelled') : null))
286
+ }
287
+ if (msg.role === 'system')
288
+ return e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, msg.text))
289
+ if (msg.role === 'error')
290
+ return e(Box, { marginLeft: 1 }, e(Text, { color: BRAND.red }, '✗ ' + msg.text))
291
+ return null
292
+ }
293
+ function Spinner({ label }) {
294
+ const [frame, setFrame] = React.useState(0)
295
+ React.useEffect(() => { const t = setInterval(() => setFrame(f => (f + 1) % SPINNER_FRAMES.length), 80); return () => clearInterval(t) }, [])
296
+ return e(Box, { marginLeft: 1 }, e(Text, { color: BRAND.accent }, SPINNER_FRAMES[frame] + ' '), e(Text, { dimColor: true }, label || 'Thinking…'))
297
+ }
298
+ function SlashMenu({ filter }) {
299
+ const matches = SLASH_COMMANDS.filter(c => c.name.startsWith(filter) || c.name.includes(filter.slice(1)))
300
+ if (!matches.length) return null
301
+ return e(Box, { flexDirection: 'column', marginLeft: 2 },
302
+ ...matches.map((c, i) => e(Box, { key: String(i) },
303
+ e(Text, { color: BRAND.accent }, c.name.padEnd(12)), e(Text, { dimColor: true }, c.desc))))
304
+ }
305
+ function InputArea({ onSubmit, isLoading }) {
306
+ const [value, setValue] = React.useState('')
307
+ const showMenu = value.startsWith('/') && !value.includes(' ')
308
+ const handleSubmit = React.useCallback(v => { if (!v.trim()) return; setValue(''); onSubmit(v.trim()) }, [onSubmit])
309
+ if (isLoading) return e(Box, { paddingX: 1 }, e(Text, { dimColor: true }, 'esc to cancel'))
310
+ return e(Box, { flexDirection: 'column' },
311
+ showMenu ? e(SlashMenu, { filter: value }) : null,
312
+ e(Box, { paddingX: 1 }, e(Text, { bold: true, color: BRAND.accent }, '❯ '),
313
+ e(TextInput, { value, onChange: setValue, onSubmit: handleSubmit })))
314
+ }
315
+ function AskPrompt({ question, onAnswer, onCancel }) {
316
+ const [value, setValue] = React.useState('')
317
+ useInput((_input, key) => { if (key.escape) onCancel() })
318
+ return e(Box, { flexDirection: 'column' },
319
+ e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, question)),
320
+ e(Box, { paddingX: 1 }, e(Text, { color: BRAND.accent }, '❯ '),
321
+ e(TextInput, { value, onChange: setValue, onSubmit: React.useCallback(v => onAnswer(v.trim()), [onAnswer]) })),
322
+ e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, 'Esc cancel')))
323
+ }
324
+ function SelectMenu({ title, items, onSelect, onCancel }) {
325
+ const [idx, setIdx] = React.useState(0)
326
+ useInput((input, key) => {
327
+ if (key.escape) { onCancel(); return }
328
+ if (key.upArrow) { setIdx(i => (i - 1 + items.length) % items.length); return }
329
+ if (key.downArrow) { setIdx(i => (i + 1) % items.length); return }
330
+ if (key.return) { onSelect(items[idx]); return }
331
+ const n = parseInt(input, 10)
332
+ if (!isNaN(n) && n >= 1 && n <= items.length) onSelect(items[n - 1])
333
+ })
334
+ return e(Box, { flexDirection: 'column', marginLeft: 1 },
335
+ e(Text, { bold: true, color: BRAND.accent }, title),
336
+ e(Box, { marginTop: 1, flexDirection: 'column' },
337
+ ...items.map((item, i) => {
338
+ const active = i === idx
339
+ return e(Box, { key: String(i), flexDirection: 'column' },
340
+ e(Box, {}, e(Text, { color: active ? BRAND.accent : undefined }, active ? '❯ ' : ' '),
341
+ e(Text, { color: BRAND.lavender }, `${i + 1}. `),
342
+ e(Text, { bold: true, color: active ? BRAND.accent : undefined }, item.label)),
343
+ e(Box, { marginLeft: 5 }, e(Text, { dimColor: true }, item.desc)))
344
+ })),
345
+ e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
346
+ '↑↓ navigate · Enter select' + (items.length <= 9 ? ` · 1–${items.length} jump` : '') + ' · Esc cancel')))
347
+ }
348
+ function TokenFooter({ calls, model }) {
349
+ let cols = 80; try { cols = process.stdout.columns || 80 } catch {}
350
+ return e(Box, { flexDirection: 'column' },
351
+ e(Text, { dimColor: true }, '─'.repeat(cols)),
352
+ e(Box, { flexDirection: 'row', paddingX: 1, justifyContent: 'space-between' },
353
+ e(Text, { dimColor: true }, `calls ${calls}`),
354
+ e(Text, {}, e(Text, { dimColor: true }, 'model: '), e(Text, { color: BRAND.lavender }, model || '—')),
355
+ e(Text, { dimColor: true }, '? /help ^C exit')))
356
+ }
357
+
358
+ // ── Main App ──────────────────────────────────────────────────
359
+ function App({ bridge }) {
360
+ const { exit } = useApp()
361
+ const [showSplash, setShowSplash] = React.useState(() => !hasSeenSplash())
362
+ const [finalized, setFinalized] = React.useState([])
363
+ const [streaming, setStreaming] = React.useState(null)
364
+ const [clearGen, setClearGen] = React.useState(0)
365
+ const [isLoading, setIsLoading] = React.useState(false)
366
+ const [askState, setAskState] = React.useState(null)
367
+ const [selectState, setSelectState] = React.useState(null)
368
+ const [callCount, setCallCount] = React.useState(0)
369
+ const [modelInfo, setModelInfo] = React.useState(() => {
370
+ const data = readAgents()
371
+ const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
372
+ return { model: ag?.model || 'nehanda-rag-synthesis-27b', endpoint: ag?.endpoint || 'https://nehanda-ml.asoba.co/v1' }
373
+ })
374
+ const lastCtrlCRef = React.useRef(0)
375
+ const bannerRef = React.useRef({ kind: 'banner' })
376
+ const historyItems = React.useMemo(() => [bannerRef.current, ...finalized], [finalized])
377
+
378
+ React.useEffect(() => {
379
+ bridge.addMessage = msg => setFinalized(p => [...p, msg])
380
+ bridge.beginAssistant = () => setStreaming({ text: '' })
381
+ bridge.updateAssistant= text => setStreaming({ text })
382
+ bridge.discardAssistant=() => setStreaming(null)
383
+ bridge.endAssistant = msg => { setFinalized(p => [...p, msg]); setStreaming(null); setCallCount(c => c + 1) }
384
+ bridge.clearMessages = () => { setFinalized([]); setStreaming(null); setClearGen(g => g + 1) }
385
+ bridge.setLoading = v => setIsLoading(v)
386
+ bridge.ask = q => new Promise(resolve => setAskState({ question: q, resolve }))
387
+ bridge.select = (t,i) => new Promise(resolve => setSelectState({ title: t, items: i, resolve }))
388
+ bridge.exit = () => { exit() }
389
+ bridge.refreshModel = () => {
390
+ const data = readAgents()
391
+ const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
392
+ setModelInfo({ model: ag?.model || 'nehanda-rag-synthesis-27b', endpoint: ag?.endpoint || 'https://nehanda-ml.asoba.co/v1' })
393
+ }
394
+ }, [bridge, exit])
395
+
396
+ const handleAnswer = React.useCallback(answer => {
397
+ if (askState?.resolve) { setFinalized(p => [...p, { role: 'system', text: askState.question + ' ' + answer }]); askState.resolve(answer) }
398
+ setAskState(null)
399
+ }, [askState])
400
+ const handleAskCancel = React.useCallback(() => { askState?.resolve(null); setAskState(null) }, [askState])
401
+ const handleSelect = React.useCallback(item => {
402
+ if (selectState?.resolve) { setFinalized(p => [...p, { role: 'system', text: ` Selected: ${item.label}` }]); selectState.resolve(item) }
403
+ setSelectState(null)
404
+ }, [selectState])
405
+ const handleSelectCancel = React.useCallback(() => { selectState?.resolve(null); setSelectState(null) }, [selectState])
406
+
407
+ useInput((input, key) => {
408
+ if (key.escape) { if (isLoading && bridge.abortCurrent) bridge.abortCurrent(); return }
409
+ if (key.ctrl && input === 'c') {
410
+ if (isLoading && bridge.abortCurrent) { bridge.abortCurrent(); return }
411
+ const now = Date.now()
412
+ if (now - lastCtrlCRef.current < 1200) { exit(); process.exit(0) }
413
+ lastCtrlCRef.current = now
414
+ bridge.addMessage({ role: 'system', text: ' (press Ctrl+C again to exit)' })
415
+ }
416
+ })
417
+
418
+ if (showSplash)
419
+ return e(SplashScreen, { onDone: () => { markSplashSeen(); setShowSplash(false) } })
420
+
421
+ return e(Box, { flexDirection: 'column' },
422
+ e(Static, { items: historyItems, key: clearGen },
423
+ (item, i) => item.kind === 'banner'
424
+ ? e(WelcomeBanner, { key: 'banner', model: modelInfo.model, endpoint: modelInfo.endpoint })
425
+ : e(MessageView, { key: String(i), msg: item })),
426
+ streaming ? e(MessageView, { msg: { role: 'assistant', ...streaming } }) : null,
427
+ isLoading && (!streaming || !streaming.text) ? e(Spinner, {}) : null,
428
+ askState
429
+ ? e(AskPrompt, { question: askState.question, onAnswer: handleAnswer, onCancel: handleAskCancel })
430
+ : selectState
431
+ ? e(SelectMenu, { title: selectState.title, items: selectState.items, onSelect: handleSelect, onCancel: handleSelectCancel })
432
+ : e(InputArea, { onSubmit: bridge.onSubmit, isLoading }),
433
+ e(TokenFooter, { calls: callCount, model: modelInfo.model }))
434
+ }
435
+
436
+ // ── Diff executor ─────────────────────────────────────────────
437
+ function applyDiffBlocks(text, bridge) {
438
+ const re = /^(.+?\.(?:\w+))\s*\n<<<<<<< SEARCH\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/gm
439
+ let match
440
+ while ((match = re.exec(text)) !== null) {
441
+ const [, filepath, search, replace] = match
442
+ try {
443
+ const abs = path.isAbsolute(filepath) ? filepath : path.resolve(process.cwd(), filepath)
444
+ if (!fs.existsSync(abs)) { bridge.addMessage({ role: 'error', text: `✗ File not found: ${filepath}` }); continue }
445
+ const content = fs.readFileSync(abs, 'utf-8')
446
+ if (!content.includes(search)) { bridge.addMessage({ role: 'error', text: `✗ Search block not found: ${filepath}` }); continue }
447
+ fs.writeFileSync(abs, content.replace(search, replace), 'utf-8')
448
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Applied diff to ${chalk.bold(filepath)}` })
449
+ } catch (err) {
450
+ bridge.addMessage({ role: 'error', text: `✗ ${filepath}: ${err.message}` })
451
+ }
452
+ }
453
+ }
454
+
455
+ // ── Thinking trace stripper ───────────────────────────────────
456
+ function stripThink(text) {
457
+ return (text || '')
458
+ .replace(/<think>[\s\S]*?<\/think>/g, '')
459
+ .replace(/<think>[\s\S]*/g, '')
460
+ .trim()
461
+ }
462
+
463
+ // ── Command handler ───────────────────────────────────────────
464
+ async function handleCommand(cmd, bridge) {
465
+ const [name] = cmd.split(/\s+/)
466
+
467
+ if (name === '/help') {
468
+ bridge.addMessage({ role: 'system', text: '\nCommands:\n' +
469
+ SLASH_COMMANDS.map(c => ` ${chalk.hex(BRAND.accent)(c.name.padEnd(12))} ${chalk.dim(c.desc)}`).join('\n') + '\n' })
470
+ return
471
+ }
472
+ if (name === '/config') {
473
+ const data = readAgents()
474
+ const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
475
+ bridge.addMessage({ role: 'system', text: [
476
+ '', ` Model: ${chalk.hex(BRAND.accent)(ag?.model || '—')}`,
477
+ ` Endpoint: ${chalk.dim(ag?.endpoint || '—')}`,
478
+ ` API key: ${readApiKey() ? chalk.green('set') : chalk.hex(BRAND.red)('not set')}`, '',
479
+ ].join('\n') })
480
+ return
481
+ }
482
+ if (name === '/clear') {
483
+ bridge.clearMessages()
484
+ bridge.addMessage({ role: 'system', text: ' Conversation cleared.' })
485
+ bridge.convId = `nehanda-${Date.now()}` // new conversation ID resets transcript
486
+ return
487
+ }
488
+ if (name === '/retry') {
489
+ if (!bridge.lastFailedText) { bridge.addMessage({ role: 'system', text: ' Nothing to retry.' }); return }
490
+ const t = bridge.lastFailedText; bridge.lastFailedText = null; await bridge.onSubmit(t); return
491
+ }
492
+ if (name === '/exit' || name === '/quit') { bridge.exit(); process.exit(0) }
493
+ if (name === '/key') {
494
+ const answer = await bridge.ask('New Nehanda API key:')
495
+ if (!answer) return
496
+ try { writeApiKey(answer); bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} API key updated.` }) }
497
+ catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
498
+ return
499
+ }
500
+ if (name === '/model') {
501
+ bridge.addMessage({ role: 'system', text: ' Discovering models…' })
502
+ const all = await buildModelList()
503
+ const data0 = readAgents()
504
+ const curAg = data0.agents?.find(a => a.name === data0.default_agent) || data0.agents?.[0]
505
+ const cur = curAg?.model || ''
506
+ const items = all.map(m => ({ ...m, label: m.name === cur ? `${m.label} ✓` : m.label }))
507
+ const sel = await bridge.select('Select model', items)
508
+ if (!sel) return
509
+ try {
510
+ const data = readAgents()
511
+ const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
512
+ if (ag) { ag.model = sel.name; ag.endpoint = sel.endpoint }
513
+ fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
514
+ // Keep in-session model_config in sync so the next turn uses the selected wire name
515
+ runtimeSettings.model_config = modelConfigFromAgent(
516
+ ag || { model: sel.name, endpoint: sel.endpoint },
517
+ { apiKey: readApiKey() },
518
+ )
519
+ bridge.refreshModel()
520
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Model → ${chalk.bold(sel.name)}` })
521
+ } catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
522
+ return
523
+ }
524
+ if (name === '/mcp') {
525
+ const parts = cmd.trim().split(/\s+/)
526
+ const sub = parts[1] || ''
527
+ const servers = runtimeSettings.mcp_servers || {}
528
+ const serverNames = Object.keys(servers)
529
+
530
+ if (!sub || sub === 'help') {
531
+ const lines = [
532
+ '',
533
+ ` ${chalk.bold('MCP Server & Tool Commands:')}`,
534
+ ` ${chalk.hex(BRAND.accent)('/mcp list'.padEnd(28))} ${chalk.dim('Discover and list all available MCP tools')}`,
535
+ ` ${chalk.hex(BRAND.accent)('/mcp status'.padEnd(28))} ${chalk.dim('Show configured servers and command paths')}`,
536
+ ` ${chalk.hex(BRAND.accent)('/mcp reload [server]'.padEnd(28))} ${chalk.dim('Reload mcp.json configs into session')}`,
537
+ ` ${chalk.hex(BRAND.accent)('/mcp add <name> <cmd> […]'.padEnd(28))} ${chalk.dim('Register a new MCP server')}`,
538
+ ` ${chalk.hex(BRAND.accent)('/mcp env [server] [K] [V]'.padEnd(28))} ${chalk.dim('View or set server environment variables')}`,
539
+ '',
540
+ ` ${chalk.dim(`Configured servers: ${serverNames.length ? serverNames.join(', ') : 'none'}`)}`,
541
+ ` ${chalk.dim('Tip: Run /mcp list to inspect tools injected into the assistant.')}`,
542
+ '',
543
+ ]
544
+ bridge.addMessage({ role: 'system', text: lines.join('\n') })
545
+ return
546
+ }
547
+
548
+ if (sub === 'status') {
549
+ if (!serverNames.length) {
550
+ bridge.addMessage({ role: 'system', text: ' No MCP servers configured.\n Add servers to mcp.json in your project root or ~/.config/nehanda/mcp.json' })
551
+ return
552
+ }
553
+ const lines = ['\n MCP Servers:']
554
+ for (const name of serverNames) {
555
+ const cfg = servers[name]
556
+ lines.push(` ${chalk.hex(BRAND.accent)(name.padEnd(20))} ${chalk.dim(cfg.command + ' ' + (cfg.args || []).join(' '))}`)
557
+ }
558
+ lines.push('')
559
+ lines.push(` ${chalk.dim('Tip: Run /mcp list to view tools provided by these servers.')}\n`)
560
+ bridge.addMessage({ role: 'system', text: lines.join('\n') })
561
+ return
562
+ }
563
+
564
+ if (sub === 'list') {
565
+ if (!serverNames.length) {
566
+ bridge.addMessage({ role: 'system', text: ' No MCP servers configured.' })
567
+ return
568
+ }
569
+ bridge.addMessage({ role: 'system', text: ' Discovering MCP tools…' })
570
+ const lines = []
571
+ for (const srvName of serverNames) {
572
+ const cfg = servers[srvName]
573
+ try {
574
+ const srv = getMcpServer(srvName, cfg)
575
+ const resp = await srv.request('tools/list', {})
576
+ const tools = resp.result?.tools || []
577
+ if (resp.error) {
578
+ lines.push(` ${chalk.hex(BRAND.red)('✗')} ${srvName}: ${resp.error.message}`)
579
+ } else if (!tools.length) {
580
+ lines.push(` ${chalk.dim(srvName)}: (no tools)`)
581
+ } else {
582
+ lines.push(` ${chalk.hex(BRAND.accent)(srvName)} (${tools.length} tool${tools.length === 1 ? '' : 's'}):`)
583
+ for (const t of tools) {
584
+ lines.push(` ${chalk.hex(BRAND.lavender)(t.name.padEnd(30))} ${chalk.dim(t.description || '')}`)
585
+ }
586
+ }
587
+ } catch (err) {
588
+ lines.push(` ${chalk.hex(BRAND.red)('✗')} ${srvName}: ${err.message}`)
589
+ }
590
+ }
591
+ bridge.addMessage({ role: 'system', text: '\n' + lines.join('\n') + '\n' })
592
+ return
593
+ }
594
+
595
+ if (sub === 'reload') {
596
+ const targetServer = parts[2] || null
597
+ // Re-read mcp.json into runtimeSettings
598
+ runtimeSettings.mcp_servers = {}
599
+ applyMcpConfig(runtimeSettings, process.cwd())
600
+ // Bust the tool discovery cache so next turn re-fetches
601
+ invalidateMcpToolCache(targetServer || undefined)
602
+ const count = Object.keys(runtimeSettings.mcp_servers).length
603
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} MCP config reloaded. ${count} server${count === 1 ? '' : 's'} configured.` })
604
+ return
605
+ }
606
+
607
+ if (sub === 'add') {
608
+ // /mcp add <name> <command> [args...]
609
+ const srvName = parts[2]
610
+ const command = parts[3]
611
+ const args = parts.slice(4)
612
+ if (!srvName || !command) {
613
+ bridge.addMessage({ role: 'system', text: ' Usage: /mcp add <name> <command> [args…]\n Example: /mcp add filesystem npx -y @modelcontextprotocol/server-filesystem /path' })
614
+ return
615
+ }
616
+ const existing = readGlobalMcpConfig()
617
+ existing[srvName] = { command, args, env: {} }
618
+ writeGlobalMcpConfig(existing)
619
+ // Reload immediately
620
+ runtimeSettings.mcp_servers = {}
621
+ applyMcpConfig(runtimeSettings, process.cwd())
622
+ invalidateMcpToolCache()
623
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Added ${chalk.bold(srvName)} → ${command} ${args.join(' ')}\n Saved to ${globalMcpConfigPath()}` })
624
+ return
625
+ }
626
+
627
+ if (sub === 'env') {
628
+ const targetServer = parts[2]
629
+ const envKey = parts[3]
630
+ const envVal = parts[4]
631
+
632
+ if (!targetServer) {
633
+ // Show env for all servers
634
+ if (!serverNames.length) {
635
+ bridge.addMessage({ role: 'system', text: ' No MCP servers configured.' })
636
+ return
637
+ }
638
+ const lines = ['\n MCP Server Environment Variables:']
639
+ for (const name of serverNames) {
640
+ const cfg = servers[name]
641
+ const envKeys = Object.keys(cfg.env || {})
642
+ if (!envKeys.length) {
643
+ lines.push(` ${chalk.hex(BRAND.accent)(name)}: (none)`)
644
+ } else {
645
+ lines.push(` ${chalk.hex(BRAND.accent)(name)}:`)
646
+ for (const k of envKeys) {
647
+ const v = cfg.env[k]
648
+ const masked = v.length > 10 ? v.slice(0, 6) + '...' + v.slice(-4) : '********'
649
+ lines.push(` ${k} = ${chalk.dim(masked)}`)
650
+ }
651
+ }
652
+ }
653
+ bridge.addMessage({ role: 'system', text: lines.join('\n') + '\n' })
654
+ return
655
+ }
656
+
657
+ if (!envKey) {
658
+ // Show env for a specific server
659
+ const cfg = servers[targetServer]
660
+ if (!cfg) {
661
+ bridge.addMessage({ role: 'system', text: ` Server "${targetServer}" not found.` })
662
+ return
663
+ }
664
+ const envKeys = Object.keys(cfg.env || {})
665
+ if (!envKeys.length) {
666
+ bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.accent)(targetServer)}: (no env vars set)` })
667
+ } else {
668
+ const lines = [` ${chalk.hex(BRAND.accent)(targetServer)}:`]
669
+ for (const k of envKeys) {
670
+ const v = cfg.env[k]
671
+ const masked = v.length > 10 ? v.slice(0, 6) + '...' + v.slice(-4) : '********'
672
+ lines.push(` ${k} = ${chalk.dim(masked)}`)
673
+ }
674
+ bridge.addMessage({ role: 'system', text: lines.join('\n') + '\n' })
675
+ }
676
+ return
677
+ }
678
+
679
+ // Set or clear env var
680
+ const result = updateMcpServerEnv(targetServer, envKey, envVal || '', process.cwd())
681
+ if (!result.ok) {
682
+ bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.red)('✗')} ${result.error}` })
683
+ return
684
+ }
685
+ // Reload runtime settings and bust cache
686
+ runtimeSettings.mcp_servers = {}
687
+ applyMcpConfig(runtimeSettings, process.cwd())
688
+ invalidateMcpToolCache()
689
+ if (result.set) {
690
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Set ${chalk.bold(envKey)} = ${'********'} on ${chalk.bold(targetServer)}\n Config file: ${result.path}` })
691
+ } else {
692
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Cleared ${chalk.bold(envKey)} on ${chalk.bold(targetServer)}\n Config file: ${result.path}` })
693
+ }
694
+ return
695
+ }
696
+
697
+ bridge.addMessage({ role: 'system', text: ' /mcp sub-commands: status · list · reload [server] · add <name> <command> [args…] · env [server] [KEY [VALUE]]' })
698
+ return
699
+ }
700
+
701
+ bridge.addMessage({ role: 'system', text: ` Unknown command: ${name}. Type /help.` })
702
+ }
703
+
704
+ // ── Entry point ───────────────────────────────────────────────
705
+ const bridge = {
706
+ convId: `nehanda-${Date.now()}`,
707
+ lastFailedText: null,
708
+ }
709
+
710
+ // ── Runtime settings — shared across turns ───────────────────
711
+ // Built once here; applyMcpConfig merges mcp.json servers in.
712
+ // Each turn rebuilds model_config from agents.json via modelConfigFromAgent
713
+ // so /model selection changes the wire model (not just the endpoint).
714
+ const _bootAgent = (() => {
715
+ const data = readAgents()
716
+ return data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
717
+ })()
718
+ const runtimeSettings = {
719
+ model_config: modelConfigFromAgent(_bootAgent, {
720
+ apiKey: readApiKey(),
721
+ defaultEndpoint: process.env.NEHANDA_BASE_URL || 'https://nehanda-ml.asoba.co/v1',
722
+ }),
723
+ permissions: { defaultMode: 'default' },
724
+ mcp_servers: {},
725
+ }
726
+ applyMcpConfig(runtimeSettings, process.cwd())
727
+
728
+ bridge.onSubmit = async (text) => {
729
+ bridge.addMessage({ role: 'user', text })
730
+ if (text.startsWith('/')) { await handleCommand(text, bridge); return }
731
+
732
+ bridge.setLoading(true)
733
+ const controller = new AbortController()
734
+ bridge.abortCurrent = () => controller.abort()
735
+ let lastPartial = ''
736
+
737
+ try {
738
+ bridge.beginAssistant()
739
+
740
+ const agentsData = readAgents()
741
+ const agent = agentsData.agents?.find(a => a.name === agentsData.default_agent) || agentsData.agents?.[0]
742
+ const modelConfig = modelConfigFromAgent(agent, {
743
+ apiKey: readApiKey(),
744
+ defaultEndpoint: process.env.NEHANDA_BASE_URL || 'https://nehanda-ml.asoba.co/v1',
745
+ })
746
+ runtimeSettings.model_config = modelConfig
747
+
748
+ // Ensure conversation row exists for this convId
749
+ sessionDb.prepare(
750
+ `INSERT OR IGNORE INTO conversations(id, project_dir, phase) VALUES (?, ?, 'idle')`
751
+ ).run(bridge.convId, process.cwd())
752
+
753
+ const rt = {
754
+ sessionId: bridge.convId,
755
+ conversationId: bridge.convId,
756
+ cwd: process.cwd(),
757
+ runtimeDbPath: null,
758
+ bareMode: false,
759
+ // Project instructions sourced from nehanda's own loadInstructions() (Ona.md),
760
+ // matching bin/agent.mjs. Does NOT read agents.json exec_system_prompt — that
761
+ // field belongs to aimee and was silently reintroducing conflicting tool-call
762
+ // syntax (<tool_call><name>...) that fights orchestrate.mjs's [TOOL_CALL] format.
763
+ onaInstructions: onaInstructionsContent || null,
764
+ settings: {
765
+ ...runtimeSettings,
766
+ model_config: modelConfig,
767
+ },
768
+ }
769
+
770
+ let fullText = '', aborted = false
771
+ controller.signal.addEventListener('abort', () => { aborted = true })
772
+
773
+ const io = {
774
+ write(chunk) {
775
+ if (aborted) return
776
+ fullText += chunk
777
+ lastPartial = stripThink(fullText).trimStart()
778
+ bridge.updateAssistant(lastPartial)
779
+ },
780
+ println(msg) { if (!aborted) bridge.addMessage({ role: 'system', text: ' ' + msg }) },
781
+ spinner: { start() {}, stop() {} },
782
+ ask: bridge.ask,
783
+ onToolStart(name) { if (!aborted) bridge.addMessage({ role: 'system', text: ` ⚙ ${name}` }) },
784
+ onToolResult(name, c, err) {
785
+ if (aborted) return
786
+ const preview = String(c || '').slice(0, 120).replace(/\n/g, ' ')
787
+ bridge.addMessage({ role: 'system', text: ` ${err ? '✗' : '✓'} ${name}: ${preview}` })
788
+ },
789
+ }
790
+
791
+ await runUserTurn(sessionDb, rt, text, io)
792
+
793
+ if (aborted) { const e = new Error('abort'); e.name = 'AbortError'; throw e }
794
+
795
+ const reply = stripThink(fullText)
796
+ bridge.endAssistant({ role: 'assistant', text: reply })
797
+ applyDiffBlocks(reply, bridge)
798
+ bridge.lastFailedText = null
799
+
800
+ } catch (err) {
801
+ if (err.name === 'AbortError') {
802
+ bridge.endAssistant({ role: 'assistant', text: lastPartial, cancelled: true })
803
+ } else {
804
+ bridge.discardAssistant()
805
+ bridge.addMessage({ role: 'error', text: err.message + '\n Type /retry to resend.' })
806
+ bridge.lastFailedText = text
807
+ }
808
+ } finally {
809
+ bridge.setLoading(false)
810
+ bridge.abortCurrent = null
811
+ }
812
+ }
813
+
814
+ // Provision Laya venv and warm the sidecar before the first turn.
815
+ // This is mandatory — if it fails, abort with a clear error.
816
+ try {
817
+ await ensureLayaReady()
818
+ } catch (e) {
819
+ process.stderr.write(`[startup] Laya provisioning failed: ${e.message}\n`)
820
+ process.stderr.write('[startup] Cannot start: Laya (System-1) is required. Fix the error above and retry.\n')
821
+ process.exit(1)
822
+ }
823
+
824
+ render(e(App, { bridge }), { exitOnCtrlC: false })