lyra-ai-agent 0.1.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 (62) hide show
  1. package/README.md +39 -0
  2. package/bin/lyra +11 -0
  3. package/package.json +65 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/chat-telegram.ts +398 -0
  6. package/src/commands/chat.tsx +1220 -0
  7. package/src/commands/demo.ts +177 -0
  8. package/src/commands/gateway-logs.ts +49 -0
  9. package/src/commands/gateway-run.ts +42 -0
  10. package/src/commands/gateway-start.ts +89 -0
  11. package/src/commands/gateway-status.ts +73 -0
  12. package/src/commands/gateway-stop.ts +133 -0
  13. package/src/commands/gateway.ts +101 -0
  14. package/src/commands/init/model-picker.ts +17 -0
  15. package/src/commands/init.ts +153 -0
  16. package/src/commands/logs.ts +37 -0
  17. package/src/commands/model.ts +48 -0
  18. package/src/commands/pairing-approve.ts +65 -0
  19. package/src/commands/pairing-clear.ts +39 -0
  20. package/src/commands/pairing-list.ts +55 -0
  21. package/src/commands/pairing-revoke.ts +49 -0
  22. package/src/commands/pairing.test.ts +88 -0
  23. package/src/commands/pairing.ts +81 -0
  24. package/src/commands/status.ts +84 -0
  25. package/src/commands/telegram-remove.ts +45 -0
  26. package/src/commands/telegram-setup.ts +84 -0
  27. package/src/commands/telegram-status.ts +48 -0
  28. package/src/commands/telegram.test.ts +50 -0
  29. package/src/commands/telegram.ts +44 -0
  30. package/src/commands/whoami.ts +62 -0
  31. package/src/config/load.ts +35 -0
  32. package/src/config/render.test.ts +96 -0
  33. package/src/config/render.ts +99 -0
  34. package/src/index.ts +147 -0
  35. package/src/ui/app.tsx +719 -0
  36. package/src/ui/approval-summary.test.ts +132 -0
  37. package/src/ui/approval-summary.ts +28 -0
  38. package/src/ui/markdown-parse.ts +219 -0
  39. package/src/ui/markdown.test.ts +146 -0
  40. package/src/ui/markdown.tsx +37 -0
  41. package/src/ui/state.test.ts +74 -0
  42. package/src/ui/state.ts +176 -0
  43. package/src/util/bootstrap-mode.test.ts +40 -0
  44. package/src/util/bootstrap-mode.ts +25 -0
  45. package/src/util/bootstrap-progress-box.test.ts +190 -0
  46. package/src/util/bootstrap-progress-box.ts +378 -0
  47. package/src/util/cli-version.ts +28 -0
  48. package/src/util/format.test.ts +16 -0
  49. package/src/util/format.ts +11 -0
  50. package/src/util/gateway-spawn.test.ts +86 -0
  51. package/src/util/gateway-spawn.ts +125 -0
  52. package/src/util/gateway-version.test.ts +113 -0
  53. package/src/util/gateway-version.ts +154 -0
  54. package/src/util/github-releases.test.ts +116 -0
  55. package/src/util/github-releases.ts +79 -0
  56. package/src/util/ref-resolver.test.ts +77 -0
  57. package/src/util/ref-resolver.ts +55 -0
  58. package/src/util/silence-console.test.ts +53 -0
  59. package/src/util/silence-console.ts +40 -0
  60. package/src/util/sui-runtime.ts +74 -0
  61. package/src/util/telegram-secrets.test.ts +104 -0
  62. package/src/util/telegram-secrets.ts +99 -0
@@ -0,0 +1,1220 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { homedir } from 'node:os'
3
+ import { spinner } from '@clack/prompts'
4
+ import {
5
+ ActivityLog,
6
+ type BrainMessage,
7
+ type ClaudeAgent,
8
+ type ClaudeCommand,
9
+ DEMO_LLM_BASE_URL,
10
+ DEMO_LLM_TOKEN,
11
+ HookBus,
12
+ type Listener,
13
+ LocalBackend,
14
+ type LyraConfig,
15
+ McpManager,
16
+ OpenAIBrain,
17
+ type PermissionDecision,
18
+ type PermissionMode,
19
+ type PermissionRequest,
20
+ PermissionService,
21
+ type PostToolCallContext,
22
+ type PreToolCallContext,
23
+ type PreToolCallResult,
24
+ type SandboxBackend,
25
+ type SkillRef,
26
+ ToolRegistry,
27
+ type VisionInferFn,
28
+ agentPaths,
29
+ applyPerms,
30
+ applyYolo,
31
+ buildFrozenPrefix,
32
+ createFsHistoryPersist,
33
+ detectFetchEscalation,
34
+ discoverClaudeExtras,
35
+ discoverMcpServers,
36
+ formatSui,
37
+ getSuiBalanceMist,
38
+ loadPlugins,
39
+ makeMemoryListTool,
40
+ makeMemoryReadTool,
41
+ makeMemorySaveTool,
42
+ makeSandboxBackend,
43
+ makeToolSearchTool,
44
+ matchSkillTriggers,
45
+ newEventId,
46
+ placeholderAgentId,
47
+ readIndexFile,
48
+ runEscalation,
49
+ scanSkills,
50
+ } from 'lyra-core'
51
+ import { ONCHAIN_GUIDANCE, policyFromEnv, policyRequiresApprovalForCall } from 'lyra-plugin-onchain'
52
+ import {
53
+ TELEGRAM_GUIDANCE,
54
+ type TelegramApprovalBridge,
55
+ type TelegramRuntimeContext,
56
+ formatInboundPreview as formatTelegramInboundPreview,
57
+ } from 'lyra-plugin-telegram'
58
+ import { findAndLoadConfig } from '../config/load'
59
+ import { writeConfigTs } from '../config/render'
60
+ import { shortAddr } from '../util/format'
61
+ import { buildOnchainContext, loadAgentFromEnv } from '../util/sui-runtime'
62
+ import {
63
+ type TelegramDispatchSlot,
64
+ buildTelegramDispatch,
65
+ buildTelegramRuntimeContext,
66
+ } from './chat-telegram'
67
+
68
+ export async function runChat(opts?: { cwd?: string; yolo?: boolean }): Promise<void> {
69
+ const found = await findAndLoadConfig(opts?.cwd)
70
+ if (!found) {
71
+ console.log('No lyra.config.ts found. Run `lyra init` first.')
72
+ process.exit(1)
73
+ }
74
+ let { config } = found
75
+ const configPath = found.path
76
+
77
+ // On Sui the agent IS the signer: one Ed25519 keypair, sourced from the
78
+ // `LYRA_AGENT_KEY` env (`suiprivkey1…`). No operator wallet, keystore
79
+ // decrypt, or a Sui wallet dance. The deterministic policy (mirrored
80
+ // on-chain by `lyra::policy`) bounds what the key may do.
81
+ const agent = loadAgentFromEnv()
82
+ if (!agent) {
83
+ console.log('No LYRA_AGENT_KEY set. Run `lyra init` first (or export a suiprivkey1… key).')
84
+ process.exit(1)
85
+ }
86
+ const agentAddress = agent.address
87
+ // Keep the config in sync if the address derived from the key differs from
88
+ // what `lyra init` last wrote (e.g. the operator rotated keys).
89
+ if (config.identity.agent && config.identity.agent !== agentAddress) {
90
+ console.log(
91
+ `warning: LYRA_AGENT_KEY resolves to ${agentAddress}, but config has ${config.identity.agent}.`,
92
+ )
93
+ }
94
+ const agentId = placeholderAgentId(agentAddress)
95
+ const paths = agentPaths.agent(agentId)
96
+
97
+ // Telegram secrets are env-driven on Sui (no operator-encrypted blob).
98
+ // TELEGRAM_BOT_TOKEN enables the phone-DM gateway; TELEGRAM_CHAT_ID
99
+ // (optional) is the sole allowed DM user (blank = open access).
100
+ let telegramSecrets: {
101
+ botToken: string
102
+ botUsername?: string
103
+ allowedUserIds: number[]
104
+ } | null = null
105
+ const envTgToken = process.env.TELEGRAM_BOT_TOKEN
106
+ if (envTgToken) {
107
+ const envChatId = process.env.TELEGRAM_CHAT_ID
108
+ telegramSecrets = {
109
+ botToken: envTgToken,
110
+ botUsername: process.env.TELEGRAM_USERNAME,
111
+ allowedUserIds: envChatId ? [Number(envChatId)] : [],
112
+ }
113
+ if (!(config.plugins ?? []).includes('telegram')) {
114
+ config = { ...config, plugins: [...(config.plugins ?? []), 'telegram'] }
115
+ }
116
+ }
117
+
118
+ if (!config.brain.provider) {
119
+ const updated = await runModelPicker(config, configPath)
120
+ if (!updated) process.exit(1)
121
+ config = updated
122
+ }
123
+
124
+ const tools = new ToolRegistry(config.tools)
125
+ tools.register(makeMemorySaveTool({ agentId }) as Parameters<typeof tools.register>[0])
126
+ tools.register(makeMemoryReadTool({ agentId }) as Parameters<typeof tools.register>[0])
127
+ tools.register(makeMemoryListTool({ agentId }) as Parameters<typeof tools.register>[0])
128
+ tools.register(makeToolSearchTool(tools) as Parameters<typeof tools.register>[0])
129
+
130
+ const initialMode: PermissionMode = opts?.yolo ? 'off' : (config.approvals?.mode ?? 'prompt')
131
+ const permission = new PermissionService({ mode: initialMode })
132
+ const hooks = new HookBus()
133
+
134
+ // Plugin failures are reported but do not abort startup; the brain still has
135
+ // memory tools.
136
+ //
137
+ // The dynamic `import()` MUST happen from the CLI package context: that's
138
+ // where the workspace deps `lyra-ai-plugin-*` live. Passing this
139
+ // resolver pins the import site to chat.tsx so bun's resolver finds them.
140
+ // Claude Code extras (commands + agents) discovery happens BEFORE plugin
141
+ // load so delegate.task can surface agents.
142
+ let claudeCommands: ClaudeCommand[] = []
143
+ let claudeAgents: ClaudeAgent[] = []
144
+ try {
145
+ const extras = await discoverClaudeExtras({
146
+ importsClaudeCode: config.imports?.claudeCode ?? true,
147
+ })
148
+ claudeCommands = extras.commands
149
+ claudeAgents = extras.agents
150
+ } catch {
151
+ // Discovery failed; continue without commands/agents.
152
+ }
153
+ const commandIndex = new Map<string, ClaudeCommand>()
154
+ for (const cmd of claudeCommands) {
155
+ if (!commandIndex.has(cmd.name)) commandIndex.set(cmd.name, cmd)
156
+ if (!commandIndex.has(cmd.id)) commandIndex.set(cmd.id, cmd)
157
+ }
158
+
159
+ // OpenAI-compatible LLM config (env-driven; default gpt-4o-mini, swappable to Z.AI/Tencent).
160
+ const userLlmKey = process.env.OPENAI_API_KEY ?? process.env.LYRA_LLM_API_KEY
161
+ // No personal key set → fall back to the hosted demo proxy so lyra runs keyless.
162
+ const llmApiKey = userLlmKey ?? DEMO_LLM_TOKEN
163
+ const llmBaseUrl = process.env.LYRA_LLM_BASE_URL ?? (userLlmKey ? undefined : DEMO_LLM_BASE_URL)
164
+ const llmModel = process.env.LYRA_LLM_MODEL ?? config.brain?.model ?? 'gpt-4o-mini'
165
+
166
+ // Sub-brain factory for delegate.task (Phase 9.3). The factory creates a
167
+ // fresh OpenAIBrain with a custom system prompt. Tools default to none for
168
+ // delegated work; the parent calls delegate.task only when isolation matters.
169
+ const delegateFactory: import('lyra-core').DelegateBrainFactory = async ({
170
+ systemPrompt,
171
+ tools: subTools,
172
+ }) => {
173
+ const subBrain = new OpenAIBrain({
174
+ apiKey: llmApiKey,
175
+ baseUrl: llmBaseUrl,
176
+ model: llmModel,
177
+ tools: subTools,
178
+ prefix: buildFrozenPrefix({
179
+ systemPrompt,
180
+ memoryIndex: null,
181
+ identity: null,
182
+ persona: null,
183
+ loadedToolNames: [],
184
+ skills: [],
185
+ timestamp: null,
186
+ }),
187
+ })
188
+ await subBrain.init()
189
+ return subBrain as unknown as import('lyra-core').DelegateBrainHandle
190
+ }
191
+
192
+ // Phase 9.5: build sandbox backend BEFORE plugins load. Tools that spawn
193
+ // subprocesses (shell.run, code.execute, shell.process_start) wrap their
194
+ // spawn argv through this backend. LYRA_SANDBOX_MODE env var wins over
195
+ // config (matches hermes' TERMINAL_ENV pattern — per-launch override
196
+ // without editing config).
197
+ const envOverride = process.env.LYRA_SANDBOX_MODE
198
+ const sandboxMode: 'none' | 'os' | 'docker' =
199
+ envOverride === 'none' || envOverride === 'os' || envOverride === 'docker'
200
+ ? envOverride
201
+ : (config.sandbox?.mode ?? 'none')
202
+ let sandbox: SandboxBackend
203
+ try {
204
+ sandbox = makeSandboxBackend({
205
+ mode: sandboxMode,
206
+ agentDir: paths.dir,
207
+ workspaceRoot: process.cwd(),
208
+ homedir: homedir(),
209
+ dockerImage: config.sandbox?.dockerImage,
210
+ dockerMountWorkspace: config.sandbox?.dockerMountWorkspace,
211
+ dockerRuntimePath: config.sandbox?.dockerRuntimePath,
212
+ dockerCpu: config.sandbox?.dockerCpu,
213
+ dockerMemoryMb: config.sandbox?.dockerMemoryMb,
214
+ dockerDiskMb: config.sandbox?.dockerDiskMb,
215
+ dockerNoNetwork: config.sandbox?.dockerNoNetwork,
216
+ })
217
+ } catch (err) {
218
+ process.stderr.write(
219
+ `lyra: sandbox init failed (${(err as Error).message}), continuing without sandbox\n`,
220
+ )
221
+ sandbox = new LocalBackend()
222
+ }
223
+ if (sandbox.mode === 'os') {
224
+ process.stderr.write(
225
+ `lyra: sandbox active [${sandbox.label}] — limb spawns gated to agentDir + cwd + /tmp/lyra-* + /var/folders; reads of ~/.ssh ~/.aws ~/Library/Keychains ~/.config/gcloud denied\n`,
226
+ )
227
+ } else if (sandbox.mode === 'docker') {
228
+ process.stderr.write(
229
+ `lyra: container sandbox active [${sandbox.label}] — every shell-class spawn runs inside the container; host fs invisible to those tools${config.sandbox?.dockerMountWorkspace ? ' except mounted /workspace' : ''}\n`,
230
+ )
231
+ }
232
+ // Register dispose hook so docker containers don't leak when lyra exits.
233
+ // Signal handlers MUST await dispose before exiting; sync `process.exit(0)`
234
+ // would discard the dispose promise and leave the container orphaned.
235
+ if (sandbox.dispose) {
236
+ const disposeOnce = (() => {
237
+ let done = false
238
+ return async () => {
239
+ if (done) return
240
+ done = true
241
+ await sandbox.dispose?.().catch(() => {})
242
+ }
243
+ })()
244
+ process.once('SIGINT', () => {
245
+ void disposeOnce().then(() => process.exit(0))
246
+ })
247
+ process.once('SIGTERM', () => {
248
+ void disposeOnce().then(() => process.exit(0))
249
+ })
250
+ }
251
+
252
+ // Vision routing via the OpenAI-compatible brain is a follow-up; disabled for now.
253
+ const visionInfer: VisionInferFn | null = null
254
+
255
+ // Plugin filter: system + onchain ship; telegram is opt-in via
256
+ // `lyra telegram setup` which writes ~/.lyra/agents/<id>/telegram-secrets.encrypted
257
+ // and adds 'telegram' to config.plugins.
258
+ const pluginNames = (config.plugins ?? []).filter(
259
+ p => p === 'system' || p === 'onchain' || p === 'telegram',
260
+ )
261
+ // Onchain side-band ctx: the Sui client + agent keypair drive every PTB, and
262
+ // the deterministic policy (mirrored on-chain by lyra::policy) bounds writes.
263
+ let onchain: ReturnType<typeof buildOnchainContext> | undefined
264
+ if (pluginNames.includes('onchain')) {
265
+ onchain = buildOnchainContext({
266
+ agent,
267
+ network: config.network,
268
+ agentDir: paths.dir,
269
+ brainProvider: config.brain.provider,
270
+ brainModel: config.brain.model,
271
+ })
272
+ }
273
+ // Standalone Sui client for the statusbar balance refresher, so the segment
274
+ // works regardless of which plugins loaded.
275
+ const balanceClient =
276
+ onchain?.client ??
277
+ buildOnchainContext({
278
+ agent,
279
+ network: config.network,
280
+ agentDir: paths.dir,
281
+ }).client
282
+ // Phase 12: telegram side-band ctx. We build the runtime context now (before
283
+ // brain.init) so the plugin can register its listener via ctx.registerListener,
284
+ // but the dispatch callback is deferred — the slot's `.current` is null until
285
+ // brain.init resolves and we wire it below. Same for the system-row sink:
286
+ // populated once state exists.
287
+ const telegramSlot: TelegramDispatchSlot = { current: null }
288
+ const telegramSystemRowSink: { current: ((text: string) => void) | null } = { current: null }
289
+ const telegramInboundRowSink: { current: ((text: string) => void) | null } = { current: null }
290
+ const telegramAssistantRowSink: { current: ((text: string) => void) | null } = { current: null }
291
+ // Bridge for inline-keyboard approval. Listener fills the inner refs on
292
+ // start; chat-telegram's runOne reads them at turn time.
293
+ const telegramApprovalBridge: TelegramApprovalBridge = {
294
+ sendApproval: { current: null },
295
+ installCallbackHandler: { current: null },
296
+ }
297
+ let telegram: TelegramRuntimeContext | undefined
298
+ if (telegramSecrets && pluginNames.includes('telegram')) {
299
+ telegram = buildTelegramRuntimeContext({
300
+ botToken: telegramSecrets.botToken,
301
+ allowedUserIds: telegramSecrets.allowedUserIds,
302
+ agentName: `agent-${agentId.slice(0, 8)}`,
303
+ slot: telegramSlot,
304
+ systemRowSink: telegramSystemRowSink,
305
+ })
306
+ telegram.approvalBridge = telegramApprovalBridge
307
+ }
308
+ // Local listener registry: plugins register listeners via ctx.registerListener
309
+ // (e.g. telegram's inbound poller); we collect them here so chat can start them
310
+ // once brain init is done.
311
+ const collectedListeners: Listener[] = []
312
+ const skillsDisabled = { current: [...(config.skills?.disabled ?? [])] }
313
+ const loadResult = await loadPlugins(pluginNames, {
314
+ tools,
315
+ hooks,
316
+ listeners: {
317
+ register: l => {
318
+ collectedListeners.push(l)
319
+ },
320
+ },
321
+ agentDir: paths.dir,
322
+ agentId,
323
+ network: config.network,
324
+ configPath,
325
+ imports: { claudeCode: config.imports?.claudeCode ?? true },
326
+ skillsDisabled,
327
+ activityLogPath: paths.activityLog,
328
+ workspaceRoot: process.cwd(),
329
+ delegateFactory,
330
+ claudeAgents,
331
+ brainSupportsVision: false,
332
+ brainModelLabel: config.brain.model ?? config.brain.provider,
333
+ visionInfer,
334
+ sandbox,
335
+ onchain,
336
+ telegram,
337
+ resolve: async name => {
338
+ switch (name) {
339
+ case 'system':
340
+ return await import('lyra-plugin-system')
341
+ case 'onchain':
342
+ return await import('lyra-plugin-onchain')
343
+ case 'telegram':
344
+ return await import('lyra-plugin-telegram')
345
+ default:
346
+ throw new Error(`unknown first-party plugin: ${name}`)
347
+ }
348
+ },
349
+ })
350
+ if (loadResult.errors.length > 0 || process.env.LYRA_DEBUG_PLUGINS) {
351
+ const { writeFile } = await import('node:fs/promises')
352
+ const { join } = await import('node:path')
353
+ await writeFile(
354
+ join(paths.dir, 'plugin-debug.log'),
355
+ JSON.stringify(
356
+ {
357
+ ts: Date.now(),
358
+ pluginNames,
359
+ loadResult,
360
+ registeredTools: tools.list().map(t => t.name),
361
+ },
362
+ null,
363
+ 2,
364
+ ),
365
+ ).catch(() => {})
366
+ }
367
+
368
+ // MCP discovery: scan ~/.lyra/.mcp.json + ~/.claude/.mcp.json + plugin
369
+ // cache, spawn each stdio server, register tools as deferred. Failures are
370
+ // logged but never block startup.
371
+ let mcpManager: McpManager | null = null
372
+ try {
373
+ const { servers } = await discoverMcpServers({
374
+ importsClaudeCode: config.imports?.claudeCode ?? true,
375
+ })
376
+ if (servers.length > 0) {
377
+ mcpManager = new McpManager(servers)
378
+ const mcpResult = await mcpManager.registerAll(def =>
379
+ tools.register(def as Parameters<typeof tools.register>[0]),
380
+ )
381
+ if (mcpResult.failed.length > 0 || process.env.LYRA_DEBUG_PLUGINS) {
382
+ const { writeFile } = await import('node:fs/promises')
383
+ const { join } = await import('node:path')
384
+ await writeFile(
385
+ join(paths.dir, 'mcp-debug.log'),
386
+ JSON.stringify(
387
+ { ts: Date.now(), servers: servers.map(s => s.name), result: mcpResult },
388
+ null,
389
+ 2,
390
+ ),
391
+ ).catch(() => {})
392
+ }
393
+ }
394
+ } catch {
395
+ // Discovery itself failed (probably I/O); proceed without MCP.
396
+ }
397
+
398
+ // Memory is local-only; durable receipts/memory go to Walrus via the
399
+ // walrus.store tool, not a per-turn anchor. This no-op preserves the
400
+ // per-turn flush call sites; memory persists as files via the memory.* tools.
401
+ const sync = {
402
+ flushTurn: async (): Promise<{ txHash: string | null; changedSlots: string[] }> => ({
403
+ txHash: null,
404
+ changedSlots: [],
405
+ }),
406
+ flushAll: async (): Promise<{ txHash: string | null; changedSlots: string[] }> => ({
407
+ txHash: null,
408
+ changedSlots: [],
409
+ }),
410
+ }
411
+
412
+ await mkdir(paths.memoryDir, { recursive: true })
413
+ const [memoryIndex, identityText, personaText, scannedSkills] = await Promise.all([
414
+ readIndexFile(paths.memoryIndex).catch(() => null),
415
+ readMemoryFileOrNull(`${paths.memoryDir}/agent/identity.md`),
416
+ readMemoryFileOrNull(`${paths.memoryDir}/agent/persona.md`),
417
+ scanSkills({ importsClaudeCode: config.imports?.claudeCode ?? true }).catch(
418
+ () => [] as SkillRef[],
419
+ ),
420
+ ])
421
+ // Use tools.list() (includes deferred) for guidance lookup — guidance
422
+ // fires per-tool-namespace, not per-prompt-schema. tools.schemas() is the
423
+ // separate set the brain SEES in its prompt; deferred tools stay hidden
424
+ // there until tool.search loads them. But the brain still needs to know
425
+ // they EXIST via guidance, otherwise it never thinks to search.
426
+ const loadedToolNames = tools.list().map(t => t.name)
427
+ const disabledSkillSet = new Set(skillsDisabled.current)
428
+ const skillsRef: { current: SkillRef[] } = {
429
+ current: scannedSkills.filter(s => !disabledSkillSet.has(s.id)),
430
+ }
431
+ const promptAppend = config.prompt?.append ?? null
432
+ // Surface sandbox awareness so the brain doesn't have to empirically discover
433
+ // its container/profile via pwd + ls + uname round-trips. Without it,
434
+ // qwen3.6-plus would hit fs.read('/workspace/X') → ENOENT (fs.* runs on host),
435
+ // sed -i '' (BSD) → fails on Linux GNU sed, and answer "where am I?" only
436
+ // after probing. Each wasted call costs latency + tokens.
437
+ const envInfo = {
438
+ cwd: process.cwd(),
439
+ platform: process.platform,
440
+ sandbox: sandbox.envHint?.() ?? null,
441
+ }
442
+ // Plugin-contributed prompt sections.
443
+ const extraGuidance: string[] = []
444
+ if (onchain) extraGuidance.push(ONCHAIN_GUIDANCE)
445
+ if (telegram) extraGuidance.push(TELEGRAM_GUIDANCE)
446
+
447
+ const buildPrefix = async () => {
448
+ const idx = await readIndexFile(paths.memoryIndex).catch(() => null)
449
+ return buildFrozenPrefix({
450
+ memoryIndex: idx,
451
+ identity: identityText,
452
+ persona: personaText,
453
+ loadedToolNames,
454
+ skills: skillsRef.current,
455
+ promptAppend,
456
+ envInfo,
457
+ extraGuidance,
458
+ })
459
+ }
460
+ const prefix = buildFrozenPrefix({
461
+ memoryIndex,
462
+ identity: identityText,
463
+ persona: personaText,
464
+ loadedToolNames,
465
+ skills: skillsRef.current,
466
+ promptAppend,
467
+ envInfo,
468
+ extraGuidance,
469
+ })
470
+ const activity = new ActivityLog(paths.activityLog)
471
+
472
+ // Brain init must happen BEFORE createCliRenderer. clack/prompts spinner
473
+ // calls setRawMode(false) + stdin.pause() on stop, which undoes the
474
+ // stdin.resume() that opentui's setupTerminal sets up. If brain init
475
+ // (and its spinner) ran AFTER createCliRenderer, the stop would flip
476
+ // stdin back into a state where opentui can't read keypresses, AND the
477
+ // event loop would empty (no stdin keepalive) so the process exits.
478
+ // The fix: every clack interaction finishes before opentui takes the wheel.
479
+ const { render } = await import('@opentui/solid')
480
+ const { createCliRenderer } = await import('@opentui/core')
481
+ const { createChatState } = await import('../ui/state')
482
+ const { ChatApp } = await import('../ui/app')
483
+
484
+ const state = createChatState({
485
+ initialSystem: opts?.yolo
486
+ ? 'connected. YOLO mode: approval prompts disabled.'
487
+ : 'connected. type messages and press enter.',
488
+ // Show the configured agent name when set, else the 16-char agent ID hash.
489
+ // Use the FULL agent EOA (no shortAddr) so operators see the complete
490
+ // address — useful for chain explorers.
491
+ identityLabel: `agent ${agentId} ${agentAddress}`,
492
+ approvalsMode: initialMode,
493
+ // v0.24.4: embedded chat runs in-process on the operator's machine — by
494
+ // definition local. Tag it so the statusbar hides the sandbox-billing
495
+ // segment, matching the standalone-local-gateway path.
496
+ isLocalGateway: true,
497
+ })
498
+
499
+ // Phase 12: now that state exists, point the telegram row sinks at it. The
500
+ // dispatch slot stays null until brain.init resolves below.
501
+ if (telegram) {
502
+ telegramSystemRowSink.current = (text: string) => state.pushRow({ role: 'system', text })
503
+ telegramInboundRowSink.current = (text: string) => state.pushRow({ role: 'inbox-tg', text })
504
+ telegramAssistantRowSink.current = (text: string) =>
505
+ state.pushRow({ role: 'telegram-assistant', text })
506
+ }
507
+
508
+ // Statusline balance refreshers; fired at boot and post-turn. The agent
509
+ // keypair pays gas for every PTB, so its SUI balance is the one that matters.
510
+ const refreshEoaBalance = () => {
511
+ getSuiBalanceMist(balanceClient, agentAddress)
512
+ .then(mist => state.setEoaBalance(Number(formatSui(mist))))
513
+ .catch(() => {})
514
+ }
515
+ const refreshBalances = () => {
516
+ refreshEoaBalance()
517
+ }
518
+
519
+ permission.setPrompter(req => {
520
+ return new Promise<PermissionDecision>(resolve => {
521
+ // Value-moving onchain ops carry amount/recipient/token so we render a
522
+ // friendlier "send 0.05 SUI to 0xC635...87Ec" instead of a raw command.
523
+ const detail =
524
+ req.amount !== undefined
525
+ ? `${req.amount}${req.token ? ` ${req.token}` : ''}${req.recipient ? ` to ${req.recipient}` : ''}`
526
+ : (req.command ?? req.path ?? '(?)')
527
+ state.pushRow({
528
+ role: 'system',
529
+ text: `[approval requested] ${req.reason}: ${detail}`,
530
+ })
531
+ state.setPendingApproval({ request: req, resolve })
532
+ })
533
+ })
534
+
535
+ hooks.add<PreToolCallContext, PreToolCallResult>('pre_tool_call', async ({ call }) => {
536
+ const checks = describePermissionCheck(call)
537
+ if (!checks) return undefined
538
+ // Deterministic policy floor: escalate to approval beneath the session mode
539
+ // (even YOLO) when the on-chain policy flags this call as material-risk.
540
+ if (
541
+ !checks.force &&
542
+ policyRequiresApprovalForCall(
543
+ call.name,
544
+ (call.args ?? {}) as Record<string, unknown>,
545
+ policyFromEnv(),
546
+ )
547
+ ) {
548
+ checks.force = true
549
+ }
550
+ const result = await permission.resolve(checks)
551
+ if (result.allowed) return undefined
552
+ return {
553
+ short: {
554
+ ok: false,
555
+ error: `Denied: ${result.reason ?? 'permission check failed'} (mode=${permission.getMode()}). Operator rejected this call. Do NOT retry, instruct another tool, or claim the transaction is queued. Surface the rejection to the operator and ask whether to proceed differently.`,
556
+ },
557
+ }
558
+ })
559
+
560
+ // Skills auto-trigger: when a tool call matches a skill's filePattern or
561
+ // bashPattern, surface a system row so the operator sees the auto-load AND
562
+ // queue the SKILL.md body for next-turn injection via brain.injectContext().
563
+ const pendingSkillInjections = new Set<string>()
564
+ hooks.add<PostToolCallContext, void>('post_tool_call', async ({ call, result }) => {
565
+ if (result.ok === false) return
566
+ const matches = matchSkillTriggers({ name: call.name, args: call.args }, skillsRef.current)
567
+ for (const match of matches) {
568
+ if (pendingSkillInjections.has(match.skill.id)) continue
569
+ pendingSkillInjections.add(match.skill.id)
570
+ state.pushRow({
571
+ role: 'system',
572
+ text: `↳ skill auto-loaded: ${match.skill.id} (matched ${match.reason}). use skills.view to read body.`,
573
+ })
574
+ }
575
+ })
576
+
577
+ const bootSpinner = spinner()
578
+ bootSpinner.start(`Connecting to model ${llmModel}`)
579
+ const persistConversations = config.brain?.persistConversations !== false
580
+ const brain = new OpenAIBrain({
581
+ apiKey: llmApiKey,
582
+ baseUrl: llmBaseUrl,
583
+ model: llmModel,
584
+ tools: tools.schemas(),
585
+ prefix,
586
+ maxOutputTokens: config.brain?.maxOutputTokens,
587
+ compaction:
588
+ config.brain?.compaction === null
589
+ ? null
590
+ : {
591
+ threshold: config.brain?.compaction?.threshold ?? 0.5,
592
+ contextWindow: config.brain?.contextWindow ?? 1_000_000,
593
+ keepRecent: config.brain?.compaction?.keepRecent ?? 8,
594
+ },
595
+ persist: persistConversations
596
+ ? createFsHistoryPersist({ dir: `${paths.dir}/conversations` })
597
+ : undefined,
598
+ onToolCall: async call => {
599
+ state.pushRow({
600
+ role: 'tool-call',
601
+ text: '',
602
+ toolName: call.name,
603
+ args: summarizeArgs(call.args),
604
+ })
605
+ const pre = await hooks.runPreToolCall({ call })
606
+ if (pre.short) {
607
+ await activity.append({
608
+ ts: Date.now(),
609
+ kind: 'tool-call',
610
+ data: { call, result: pre.short, blocked: true },
611
+ })
612
+ state.pushRow({
613
+ role: 'tool-result',
614
+ text: summarizeToolResult(pre.short),
615
+ failed: pre.short.ok === false,
616
+ })
617
+ return { role: 'tool', content: JSON.stringify(pre.short) } as BrainMessage
618
+ }
619
+ const effectiveCall = pre.call ?? call
620
+ const result = await tools.dispatch(effectiveCall)
621
+ await hooks.runPostToolCall({ call: effectiveCall, result })
622
+ await activity.append({
623
+ ts: Date.now(),
624
+ kind: 'tool-call',
625
+ data: { call: effectiveCall, result },
626
+ })
627
+ state.pushRow({
628
+ role: 'tool-result',
629
+ text: summarizeToolResult(result),
630
+ failed: result.ok === false,
631
+ })
632
+ // v0.21.2 R1: deterministic browser.navigate retry when web.fetch hits
633
+ // a bot-block. Mirror block in build-runtime.ts; both share orchestration
634
+ // via runEscalation so any future change lands in one place. Sinks differ:
635
+ // TUI pushes rows here, gateway publishes SSE events.
636
+ const escalation = detectFetchEscalation(effectiveCall, result)
637
+ if (escalation.needed) {
638
+ const merged = await runEscalation(escalation, result, {
639
+ runPreCall: c => hooks.runPreToolCall({ call: c }),
640
+ runPostCall: (c, r) => hooks.runPostToolCall({ call: c, result: r }),
641
+ dispatch: c => tools.dispatch(c),
642
+ appendActivity: (c, r) =>
643
+ activity.append({
644
+ ts: Date.now(),
645
+ kind: 'tool-call',
646
+ data: { call: c, result: r, autoEscalated: true },
647
+ }),
648
+ onStart: c =>
649
+ state.pushRow({
650
+ role: 'tool-call',
651
+ text: '',
652
+ toolName: c.name,
653
+ args: summarizeArgs(c.args),
654
+ autoEscalated: true,
655
+ }),
656
+ onEnd: (_c, r) =>
657
+ state.pushRow({
658
+ role: 'tool-result',
659
+ text: summarizeToolResult(r),
660
+ failed: r.ok === false,
661
+ autoEscalated: true,
662
+ }),
663
+ })
664
+ return { role: 'tool', content: JSON.stringify(merged) } as BrainMessage
665
+ }
666
+ return {
667
+ role: 'tool',
668
+ content: JSON.stringify(result),
669
+ } as BrainMessage
670
+ },
671
+ })
672
+ try {
673
+ await brain.init()
674
+ bootSpinner.stop('Connected')
675
+ } catch (e) {
676
+ bootSpinner.stop(`Connection failed: ${(e as Error).message.slice(0, 120)}`)
677
+ process.exit(1)
678
+ }
679
+
680
+ // Phase 12: brain is up. Wire the deferred TG dispatch slot so any inbound
681
+ // TG message that lands once collectedListeners[i].start() fires below
682
+ // routes through brain.infer with source=telegram.
683
+ if (telegram) {
684
+ const handle = buildTelegramDispatch({
685
+ activity,
686
+ sync,
687
+ permission,
688
+ pushAssistantRow: text => telegramAssistantRowSink.current?.(text),
689
+ pushInboundRow: text => telegramInboundRowSink.current?.(text),
690
+ isBusy: () => state.status() === 'thinking',
691
+ buildPrefix,
692
+ brain,
693
+ setThinking: on => state.setStatus(on ? 'thinking' : 'idle'),
694
+ setActiveAbort: ctrl => state.setActiveAbort(ctrl),
695
+ refreshBalances,
696
+ formatInboundPreview: input =>
697
+ formatTelegramInboundPreview({
698
+ chatId: input.chatId,
699
+ username: input.username,
700
+ displayName: input.displayName,
701
+ text: input.text.replace(/^<channel[^>]*>([\s\S]*)<\/channel>$/, '$1'),
702
+ }),
703
+ approvalBridge: telegramApprovalBridge,
704
+ })
705
+ telegramSlot.current = handle.dispatch
706
+ // Drain queued TG messages whenever the brain returns to idle (closes G4
707
+ // starvation: a stdin turn ending while a TG message was queued used to
708
+ // leave it stuck until the next inbound).
709
+ state.onStatusChange(next => {
710
+ if (next === 'idle' && handle.getQueueSize() > 0) handle.drainQueue()
711
+ })
712
+ }
713
+
714
+ // Initial balances for the status bar (best-effort, never blocks boot).
715
+ refreshBalances()
716
+
717
+ // Redirect noisy SDK chatter (Sui RPC + Walrus progress logs) to a
718
+ // log file so it doesn't fall through opentui's alt-screen and pollute the
719
+ // chat UI. Keep process.stdout intact - opentui itself needs to write there.
720
+ const { createWriteStream } = await import('node:fs')
721
+ const chatLog = createWriteStream(`${paths.dir}/chat.log`, { flags: 'a' })
722
+ const stringifyArg = (a: unknown): string => {
723
+ if (typeof a === 'string') return a
724
+ if (a instanceof Error) return a.stack ?? a.message
725
+ try {
726
+ return JSON.stringify(a, (_k, v) => (typeof v === 'bigint' ? `${v}n` : v))
727
+ } catch {
728
+ return String(a)
729
+ }
730
+ }
731
+ const logTo =
732
+ (level: string) =>
733
+ (...args: unknown[]) => {
734
+ const line = args.map(stringifyArg).join(' ')
735
+ chatLog.write(`[${new Date().toISOString()}] [${level}] ${line}\n`)
736
+ }
737
+ console.log = logTo('log') as typeof console.log
738
+ console.warn = logTo('warn') as typeof console.warn
739
+ console.error = logTo('error') as typeof console.error
740
+ console.info = logTo('info') as typeof console.info
741
+ console.debug = logTo('debug') as typeof console.debug
742
+ process.on('unhandledRejection', err => {
743
+ chatLog.write(`[unhandled] ${(err as Error)?.stack ?? String(err)}\n`)
744
+ })
745
+
746
+ const renderer = await createCliRenderer({
747
+ exitOnCtrlC: false,
748
+ consoleMode: 'disabled',
749
+ openConsoleOnError: false,
750
+ })
751
+
752
+ // Listener catch-up + WS subscribe runs in the background. `start` only
753
+ // resolves after catch-up finishes, which can be slow on long-restored
754
+ // agents; awaiting it would block the chat from accepting input.
755
+ for (const l of collectedListeners) {
756
+ l.start(undefined as never).catch(e => {
757
+ state.pushRow({
758
+ role: 'system',
759
+ text: `listener ${l.name} failed to start: ${(e as Error).message.slice(0, 160)}`,
760
+ })
761
+ })
762
+ }
763
+
764
+ const handleSubmit = async (text: string): Promise<void> => {
765
+ const trimmed = text.trim()
766
+ if (trimmed.startsWith('/')) {
767
+ const handled = await handleSlash(trimmed)
768
+ if (handled) {
769
+ // Slash commands skip brain.infer; reset thinking → idle so the
770
+ // spinner row stops. (The keyboard handler in app.tsx flips
771
+ // status='thinking' on every Enter, regardless of payload.)
772
+ state.setStatus('idle')
773
+ return
774
+ }
775
+ }
776
+ // Per-turn AbortController. Esc in the TUI calls .abort() on this.
777
+ // Stored on state so the keyboard handler can reach it from app.tsx.
778
+ const abortCtrl = new AbortController()
779
+ state.setActiveAbort(abortCtrl)
780
+ try {
781
+ // Refresh per-turn user-context (MEMORY.md may have grown last turn).
782
+ // The system prefix stays cached; only the user-msg context updates.
783
+ const refreshed = await buildPrefix()
784
+ brain.refreshUserContext(refreshed)
785
+ await activity.append({
786
+ ts: Date.now(),
787
+ kind: 'wake',
788
+ data: { source: 'stdin', text },
789
+ })
790
+ const turn = await brain.infer({
791
+ event: {
792
+ id: newEventId(),
793
+ source: 'stdin',
794
+ payload: { label: 'user-message', data: text },
795
+ ts: Date.now(),
796
+ },
797
+ channelKey: 'tui:stdin',
798
+ signal: abortCtrl.signal,
799
+ onCompactionEvent: ev => {
800
+ state.pushRow({
801
+ role: 'system',
802
+ text: `✂︎ context compacted (${ev.from} → ${ev.to} messages, ~${Math.round(ev.promptTokens / 1000)}K tokens)`,
803
+ })
804
+ },
805
+ })
806
+ await activity.append({
807
+ ts: Date.now(),
808
+ kind: 'brain-response',
809
+ data: {
810
+ content: turn.content,
811
+ toolCalls: turn.toolCalls.length,
812
+ finishReason: turn.finishReason,
813
+ usage: turn.usage,
814
+ },
815
+ })
816
+ state.pushRow({ role: 'assistant', text: turn.content ?? '(no content)' })
817
+ state.setStatus('idle')
818
+ // Compute ledger drains via inference; agent EOA via tool chain writes.
819
+ refreshBalances()
820
+ if (turn.usage) {
821
+ state.setUsage({
822
+ total: turn.usage.totalTokens,
823
+ cached: turn.usage.cachedTokens,
824
+ })
825
+ }
826
+ // Per-turn flush. Memory persists locally; durable copies go to Walrus
827
+ // via walrus.store. The no-op flush keeps the call site for future
828
+ // anchoring work. Fire-and-forget; chat doesn't wait.
829
+ sync
830
+ .flushTurn()
831
+ .then(res => {
832
+ if (res.txHash && res.changedSlots.length > 0) {
833
+ state.pushRow({
834
+ role: 'system',
835
+ text: `synced ${res.changedSlots.join(', ')} (${res.txHash})`,
836
+ })
837
+ }
838
+ })
839
+ .catch(e => {
840
+ state.pushRow({
841
+ role: 'system',
842
+ text: `sync error: ${summarizeError(e)}`,
843
+ })
844
+ })
845
+ } catch (e) {
846
+ // AbortError = operator pressed Esc; render as a clean sys row, NOT an
847
+ // error. The activity log gets a paired entry so the post-mortem reflects
848
+ // operator intent, not a real fault.
849
+ if ((e instanceof Error && e.name === 'AbortError') || abortCtrl.signal.aborted) {
850
+ state.pushRow({
851
+ role: 'system',
852
+ text: 'turn interrupted (esc). brain stopped at the last completed step.',
853
+ })
854
+ await activity.append({
855
+ ts: Date.now(),
856
+ kind: 'brain-response',
857
+ data: { content: '(aborted by operator)', toolCalls: 0, finishReason: 'aborted' },
858
+ })
859
+ state.setStatus('idle')
860
+ return
861
+ }
862
+ // Mirror real errors to chat.log too — render-layer bugs can swallow the
863
+ // sys row before it hits the screen, and chat.log is the only artifact
864
+ // the operator can read post-mortem.
865
+ const errMsg = e instanceof Error ? e.message : String(e ?? 'unknown error')
866
+ const dumped = e instanceof Error ? (e.stack ?? e.message) : errMsg
867
+ console.error('[handleSubmit] error:', dumped)
868
+ state.pushRow({ role: 'system', text: `error: ${errMsg.slice(0, 300)}` })
869
+ state.setStatus('error')
870
+ } finally {
871
+ state.setActiveAbort(null)
872
+ }
873
+ }
874
+
875
+ const handleSlash = async (cmd: string): Promise<boolean> => {
876
+ if (cmd === '/exit' || cmd === '/quit') {
877
+ state.pushRow({ role: 'system', text: 'goodbye.' })
878
+ handleExit()
879
+ return true
880
+ }
881
+ if (cmd === '/model') {
882
+ state.pushRow({
883
+ role: 'system',
884
+ text: 'Switching brain. (Quit chat first; run `lyra model` to pick a new brain, then re-launch `lyra`.)',
885
+ })
886
+ return true
887
+ }
888
+ if (cmd === '/sync') {
889
+ state.pushRow({ role: 'system', text: 'flushing memory + activity…' })
890
+ try {
891
+ const res = await sync.flushAll()
892
+ if (res.txHash) {
893
+ state.pushRow({
894
+ role: 'system',
895
+ text: `synced ${res.changedSlots.join(', ')} (${res.txHash})`,
896
+ })
897
+ refreshEoaBalance()
898
+ } else {
899
+ state.pushRow({ role: 'system', text: 'nothing to sync (everything up to date)' })
900
+ }
901
+ } catch (e) {
902
+ state.pushRow({ role: 'system', text: `sync error: ${summarizeError(e)}` })
903
+ }
904
+ return true
905
+ }
906
+ if (cmd === '/yolo') {
907
+ const result = applyYolo(permission)
908
+ state.setApprovalsMode(result.mode)
909
+ state.pushRow({ role: 'system', text: result.message })
910
+ return true
911
+ }
912
+ if (cmd === '/perms' || cmd.startsWith('/perms ')) {
913
+ const arg = cmd.split(/\s+/)[1]
914
+ const result = applyPerms(permission, arg)
915
+ state.setApprovalsMode(result.mode)
916
+ state.pushRow({ role: 'system', text: result.message })
917
+ return true
918
+ }
919
+ if (cmd === '/reset') {
920
+ try {
921
+ await brain.clearChannel('tui:stdin')
922
+ state.pushRow({ role: 'system', text: 'conversation reset (TUI channel cleared)' })
923
+ } catch (e) {
924
+ state.pushRow({ role: 'system', text: `reset error: ${summarizeError(e)}` })
925
+ }
926
+ return true
927
+ }
928
+ if (cmd === '/jobs') {
929
+ const tool = tools.find('market.listMyJobs')
930
+ if (!tool) {
931
+ state.pushRow({
932
+ role: 'system',
933
+ text: 'market plugin not loaded; cannot list jobs.',
934
+ })
935
+ return true
936
+ }
937
+ state.pushRow({ role: 'system', text: 'fetching active jobs…' })
938
+ try {
939
+ const res = await tool.handler({ status: 'active', limit: 20 } as never)
940
+ const data = (res as { ok: boolean; data?: { jobs: unknown[] } }).data
941
+ const jobs = (data?.jobs ?? []) as Array<{
942
+ jobId: string
943
+ role: string
944
+ counterparty: string | null
945
+ amount0g: string
946
+ status: string
947
+ }>
948
+ if (jobs.length === 0) {
949
+ state.pushRow({ role: 'system', text: 'no active escrow jobs.' })
950
+ } else {
951
+ const lines = jobs.map(
952
+ j =>
953
+ ` job#${j.jobId} · ${j.role}${j.counterparty ? ` w/ ${shortAddr(j.counterparty)}` : ''} · ${j.amount0g} SUI · ${j.status}`,
954
+ )
955
+ state.pushRow({
956
+ role: 'system',
957
+ text: `active jobs (${jobs.length}):\n${lines.join('\n')}`,
958
+ })
959
+ }
960
+ } catch (e) {
961
+ state.pushRow({ role: 'system', text: `jobs error: ${summarizeError(e)}` })
962
+ }
963
+ return true
964
+ }
965
+ if (cmd === '/help') {
966
+ const builtins =
967
+ " /sync flush memory + activity locally\n /model switch brain (run lyra model after exiting)\n /yolo toggle approval prompts off/on for this session\n /perms <mode> set permission mode (off|prompt|strict); no arg shows current\n /reset clear this channel's conversation history\n /exit quit lyra\n /help this message"
968
+ const claudeBlock =
969
+ commandIndex.size === 0
970
+ ? ''
971
+ : `\n\nClaude Code commands (auto-loaded):\n${[
972
+ ...new Set([...commandIndex.values()].map(c => c.name)),
973
+ ]
974
+ .sort()
975
+ .map(name => {
976
+ const c = commandIndex.get(name)!
977
+ return ` /${c.name} ${c.description.slice(0, 80)}`
978
+ })
979
+ .join('\n')}`
980
+ state.pushRow({
981
+ role: 'system',
982
+ text: `slash commands:\n${builtins}${claudeBlock}`,
983
+ })
984
+ return true
985
+ }
986
+ // Claude Code command match. Strip leading `/`, take first whitespace
987
+ // segment as the command name, treat the rest as the user-supplied args.
988
+ if (cmd.startsWith('/')) {
989
+ const rest = cmd.slice(1).trim()
990
+ if (!rest) return false
991
+ const space = rest.indexOf(' ')
992
+ const name = space === -1 ? rest : rest.slice(0, space)
993
+ const args = space === -1 ? '' : rest.slice(space + 1).trim()
994
+ const command = commandIndex.get(name)
995
+ if (!command) return false
996
+ const trimmedBody = command.body.trim()
997
+ const inlined = args
998
+ ? `# Command: /${command.name}${command.argumentHint ? ` (${command.argumentHint})` : ''}\n# User args: ${args}\n\n${trimmedBody}`
999
+ : `# Command: /${command.name}\n\n${trimmedBody}`
1000
+ state.pushRow({
1001
+ role: 'system',
1002
+ text: `↳ command: /${command.name} (${command.id}, ${command.body.length} bytes inlined as user message)`,
1003
+ })
1004
+ // Send the command body as a user message so the brain executes it.
1005
+ try {
1006
+ const refreshed = await buildPrefix()
1007
+ brain.refreshUserContext(refreshed)
1008
+ const turn = await brain.infer({
1009
+ event: {
1010
+ id: newEventId(),
1011
+ source: 'stdin',
1012
+ payload: { label: 'user-message', data: inlined },
1013
+ ts: Date.now(),
1014
+ },
1015
+ channelKey: 'tui:stdin',
1016
+ })
1017
+ state.pushRow({ role: 'assistant', text: turn.content ?? '(no content)' })
1018
+ state.setStatus('idle')
1019
+ } catch (e) {
1020
+ state.pushRow({
1021
+ role: 'system',
1022
+ text: `command error: ${(e as Error).message.slice(0, 200)}`,
1023
+ })
1024
+ }
1025
+ return true
1026
+ }
1027
+ return false
1028
+ }
1029
+
1030
+ // @opentui/solid's render() resolves once the component mounts; it does not
1031
+ // block. On macOS the renderer's animation loop runs in a worker thread, so
1032
+ // the main thread has no JS task keeping the event loop alive after render
1033
+ // returns. Anchor: a never-resolving promise after render(); handleExit is
1034
+ // the only escape via process.exit.
1035
+ const handleExit = (): void => {
1036
+ try {
1037
+ renderer.destroy()
1038
+ } catch {}
1039
+ try {
1040
+ mcpManager?.closeAll()
1041
+ } catch {}
1042
+ // Best-effort: kill any background processes registered via shell.process.
1043
+ try {
1044
+ const { killAllProcesses } = require('lyra-plugin-system') as {
1045
+ killAllProcesses: () => void
1046
+ }
1047
+ killAllProcesses()
1048
+ } catch {}
1049
+ // Best-effort drain: if a flush is mid-flight, await it. Caps at 30s so
1050
+ // we never hang the CLI on a wedged RPC.
1051
+ Promise.race([sync.flushTurn(), new Promise(r => setTimeout(r, 30_000))]).finally(() =>
1052
+ process.exit(0),
1053
+ )
1054
+ }
1055
+
1056
+ // Map Claude Code commands into SlashCommand shape so the slash
1057
+ // autocomplete popup lists them alongside the bundled registry.
1058
+ const extraSlashCommands = [...new Set([...commandIndex.values()].map(c => c.name))].map(name => {
1059
+ const c = commandIndex.get(name)!
1060
+ return {
1061
+ name: c.name.toLowerCase(),
1062
+ description: c.description ?? `Claude Code command (${c.id})`,
1063
+ surfaces: ['tui'] as ('tui' | 'tg')[],
1064
+ scope: 'local' as const,
1065
+ bypassesBrain: false,
1066
+ argHint: c.argumentHint,
1067
+ }
1068
+ })
1069
+
1070
+ await render(
1071
+ () => (
1072
+ <ChatApp
1073
+ state={state}
1074
+ onSubmit={handleSubmit}
1075
+ onExit={handleExit}
1076
+ extraSlashCommands={extraSlashCommands}
1077
+ />
1078
+ ),
1079
+ renderer,
1080
+ )
1081
+
1082
+ await new Promise<void>(() => {
1083
+ // Block forever; only handleExit (via process.exit) escapes this.
1084
+ })
1085
+ }
1086
+
1087
+ async function runModelPicker(config: LyraConfig, configPath: string): Promise<LyraConfig | null> {
1088
+ // Lyra uses a fixed OpenAI-compatible model (env-configured); no live catalog.
1089
+ const model = process.env.LYRA_LLM_MODEL ?? config.brain?.model ?? 'gpt-4o-mini'
1090
+ const updated: LyraConfig = {
1091
+ ...config,
1092
+ brain: { provider: 'openai-compatible', model },
1093
+ }
1094
+ await writeConfigTs(configPath, updated)
1095
+ return updated
1096
+ }
1097
+
1098
+ /**
1099
+ * Squash a ToolResult down to a single-line summary for the chat row. The TUI
1100
+ * adds the `⎿` indent + color from the role, so this returns just the content:
1101
+ * - failed → the error message (truncated)
1102
+ * - ok+path → the file path the tool acted on
1103
+ * - ok+data → "ok"
1104
+ * - done → "done" (legacy: pre-ok results)
1105
+ */
1106
+ function summarizeToolResult(result: unknown): string {
1107
+ const r = result as { ok?: boolean; error?: string; data?: { path?: string } } | null | undefined
1108
+ if (!r || r.ok === undefined) return 'done'
1109
+ if (r.ok === false) return (r.error ?? 'failed').slice(0, 200)
1110
+ const path = typeof r.data?.path === 'string' ? r.data.path : null
1111
+ return path ? path : 'ok'
1112
+ }
1113
+
1114
+ /**
1115
+ * Squash an Error into a single-line, length-capped string for the TUI.
1116
+ * Sui SDK multi-line stack traces blow up the chat UX otherwise.
1117
+ * Strategy: collapse whitespace, drop everything after the first ` (action=`
1118
+ * marker (where some SDKs append transaction blobs), cap at 90 chars so the
1119
+ * row stays on one terminal line in any reasonably-sized pane.
1120
+ */
1121
+ function summarizeError(e: unknown): string {
1122
+ const raw = e instanceof Error ? e.message : String(e)
1123
+ let s = raw.replace(/\s+/g, ' ').trim()
1124
+ const annotIdx = s.indexOf(' (action=')
1125
+ if (annotIdx >= 0) s = s.slice(0, annotIdx)
1126
+ return s.length > 90 ? `${s.slice(0, 87)}...` : s
1127
+ }
1128
+
1129
+ type PermArgs = Record<string, unknown>
1130
+ const _str = (v: unknown): string => (typeof v === 'string' ? v : '')
1131
+ const _strOpt = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
1132
+
1133
+ const PERMISSION_DESCRIBERS: Record<string, (a: PermArgs) => PermissionRequest | null> = {
1134
+ 'shell.run': a => ({
1135
+ kind: 'shell.run',
1136
+ command: _str(a.command),
1137
+ reason: 'shell command execution',
1138
+ }),
1139
+ 'code.execute': a => ({
1140
+ kind: 'code.execute',
1141
+ command: `[${_str(a.language) || '?'}] ${_str(a.code)}`,
1142
+ reason: 'arbitrary code execution',
1143
+ }),
1144
+ 'shell.process_start': a => ({
1145
+ kind: 'shell.process',
1146
+ command: _str(a.command),
1147
+ reason: 'background process start',
1148
+ }),
1149
+ 'shell.process_output': () => null,
1150
+ 'shell.process_list': () => null,
1151
+ 'shell.process_kill': () => null,
1152
+ 'fs.write': a => ({ kind: 'fs.write', path: _str(a.path), reason: 'fs.write request' }),
1153
+ 'fs.patch': a => ({ kind: 'fs.patch', path: _str(a.path), reason: 'fs.patch request' }),
1154
+ // Value-moving Sui tools. Pre-fill amount/recipient/token so the modal
1155
+ // renders "send 0.05 SUI to 0xC635…" not a raw command.
1156
+ 'sui.send': a => ({
1157
+ kind: 'chain.send',
1158
+ amount: _strOpt(a.amount) ?? '?',
1159
+ recipient: _strOpt(a.to) ?? '?',
1160
+ token: 'SUI',
1161
+ reason: 'SUI transfer',
1162
+ }),
1163
+ 'policy.create': a => ({
1164
+ kind: 'chain.write',
1165
+ command: `policy.create budget=${_strOpt(a.budgetSui) ?? '?'} maxPerTx=${_strOpt(a.maxPerTxSui) ?? '?'} SUI`,
1166
+ reason: 'publish an on-chain lyra::policy AgentPolicy',
1167
+ }),
1168
+ 'scallop.supply': a => ({
1169
+ kind: 'chain.write',
1170
+ command: `scallop.supply ${_strOpt(a.amount) ?? '?'} SUI`,
1171
+ reason: 'supply SUI to Scallop',
1172
+ }),
1173
+ 'scallop.withdraw': a => ({
1174
+ kind: 'chain.write',
1175
+ command: `scallop.withdraw ${_strOpt(a.amount) ?? '?'} SUI`,
1176
+ reason: 'withdraw SUI from Scallop',
1177
+ }),
1178
+ 'navi.supply': a => ({
1179
+ kind: 'chain.write',
1180
+ command: `navi.supply ${_strOpt(a.amount) ?? '?'} SUI`,
1181
+ reason: 'supply SUI to NAVI',
1182
+ }),
1183
+ 'navi.withdraw': a => ({
1184
+ kind: 'chain.write',
1185
+ command: `navi.withdraw ${_strOpt(a.amount) ?? '?'} SUI`,
1186
+ reason: 'withdraw SUI from NAVI',
1187
+ }),
1188
+ 'walrus.store': a => ({
1189
+ kind: 'chain.write',
1190
+ command: `walrus.store ${_strOpt(a.label) ?? '(blob)'}`,
1191
+ reason: 'store a durable blob on Walrus',
1192
+ }),
1193
+ }
1194
+
1195
+ function describePermissionCheck(call: { name: string; args: unknown }): PermissionRequest | null {
1196
+ const fn = PERMISSION_DESCRIBERS[call.name]
1197
+ return fn ? fn((call.args ?? {}) as PermArgs) : null
1198
+ }
1199
+
1200
+ function summarizeArgs(args: unknown): string {
1201
+ if (typeof args !== 'object' || args === null) return String(args ?? '').slice(0, 60)
1202
+ const entries = Object.entries(args as Record<string, unknown>)
1203
+ return entries
1204
+ .map(([k, v]) => {
1205
+ const s = typeof v === 'string' ? v : JSON.stringify(v)
1206
+ return `${k}=${s.length > 40 ? `${s.slice(0, 40)}…` : s}`
1207
+ })
1208
+ .slice(0, 3)
1209
+ .join(', ')
1210
+ }
1211
+
1212
+ async function readMemoryFileOrNull(path: string): Promise<string | null> {
1213
+ try {
1214
+ const { readFile } = await import('node:fs/promises')
1215
+ return await readFile(path, 'utf8')
1216
+ } catch (e) {
1217
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null
1218
+ throw e
1219
+ }
1220
+ }