@promus/cli 0.24.17

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 (96) hide show
  1. package/README.md +18 -0
  2. package/bin/promus +33 -0
  3. package/package.json +51 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_inft-ref.ts +43 -0
  6. package/src/commands/_unlock.ts +74 -0
  7. package/src/commands/admin-autotopup-tick.ts +73 -0
  8. package/src/commands/admin.test.ts +34 -0
  9. package/src/commands/admin.ts +32 -0
  10. package/src/commands/balance.test.ts +10 -0
  11. package/src/commands/balance.ts +112 -0
  12. package/src/commands/chat-sandbox.tsx +520 -0
  13. package/src/commands/chat-telegram.ts +398 -0
  14. package/src/commands/chat.tsx +1916 -0
  15. package/src/commands/deploy.ts +204 -0
  16. package/src/commands/drain.ts +90 -0
  17. package/src/commands/gateway-logs.ts +47 -0
  18. package/src/commands/gateway-run.ts +54 -0
  19. package/src/commands/gateway-start.ts +218 -0
  20. package/src/commands/gateway-status.ts +88 -0
  21. package/src/commands/gateway-stop.ts +133 -0
  22. package/src/commands/gateway.ts +101 -0
  23. package/src/commands/init/cost.test.ts +169 -0
  24. package/src/commands/init/cost.ts +154 -0
  25. package/src/commands/init/funding-gate.ts +67 -0
  26. package/src/commands/init/model-picker.ts +81 -0
  27. package/src/commands/init/operator-picker.ts +263 -0
  28. package/src/commands/init/resume.ts +136 -0
  29. package/src/commands/init/sandbox-provision.test.ts +497 -0
  30. package/src/commands/init/sandbox-provision.ts +1177 -0
  31. package/src/commands/init/telegram-step.ts +229 -0
  32. package/src/commands/init/wizard-state.ts +95 -0
  33. package/src/commands/init.ts +612 -0
  34. package/src/commands/inspect.ts +529 -0
  35. package/src/commands/ledger.ts +176 -0
  36. package/src/commands/logs.ts +86 -0
  37. package/src/commands/migrate-keystore.ts +155 -0
  38. package/src/commands/model.ts +48 -0
  39. package/src/commands/pairing-approve.ts +114 -0
  40. package/src/commands/pairing-clear.ts +42 -0
  41. package/src/commands/pairing-list.ts +58 -0
  42. package/src/commands/pairing-revoke.ts +52 -0
  43. package/src/commands/pairing.test.ts +88 -0
  44. package/src/commands/pairing.ts +81 -0
  45. package/src/commands/pause.ts +99 -0
  46. package/src/commands/profile.ts +184 -0
  47. package/src/commands/restore.ts +221 -0
  48. package/src/commands/resume.ts +181 -0
  49. package/src/commands/status.ts +119 -0
  50. package/src/commands/sync.ts +147 -0
  51. package/src/commands/telegram-remove.ts +65 -0
  52. package/src/commands/telegram-setup.ts +74 -0
  53. package/src/commands/telegram-status.ts +89 -0
  54. package/src/commands/telegram.test.ts +50 -0
  55. package/src/commands/telegram.ts +44 -0
  56. package/src/commands/topup.ts +303 -0
  57. package/src/commands/transfer.test.ts +111 -0
  58. package/src/commands/transfer.ts +520 -0
  59. package/src/commands/upgrade.test.ts +137 -0
  60. package/src/commands/upgrade.ts +690 -0
  61. package/src/config/load.ts +35 -0
  62. package/src/config/render.test.ts +96 -0
  63. package/src/config/render.ts +110 -0
  64. package/src/index.ts +378 -0
  65. package/src/sandbox/client.test.ts +251 -0
  66. package/src/sandbox/client.ts +424 -0
  67. package/src/ui/app.tsx +677 -0
  68. package/src/ui/approval-summary.test.ts +154 -0
  69. package/src/ui/approval-summary.ts +34 -0
  70. package/src/ui/markdown-parse.ts +219 -0
  71. package/src/ui/markdown.test.ts +146 -0
  72. package/src/ui/markdown.tsx +37 -0
  73. package/src/ui/state.test.ts +74 -0
  74. package/src/ui/state.ts +198 -0
  75. package/src/util/bootstrap-mode.test.ts +40 -0
  76. package/src/util/bootstrap-mode.ts +25 -0
  77. package/src/util/bootstrap-progress-box.test.ts +190 -0
  78. package/src/util/bootstrap-progress-box.ts +378 -0
  79. package/src/util/brain-secrets.ts +96 -0
  80. package/src/util/cli-version.ts +28 -0
  81. package/src/util/format.test.ts +16 -0
  82. package/src/util/format.ts +11 -0
  83. package/src/util/gateway-spawn.test.ts +86 -0
  84. package/src/util/gateway-spawn.ts +128 -0
  85. package/src/util/gateway-version.test.ts +113 -0
  86. package/src/util/gateway-version.ts +154 -0
  87. package/src/util/github-releases.test.ts +116 -0
  88. package/src/util/github-releases.ts +79 -0
  89. package/src/util/profile-key.test.ts +60 -0
  90. package/src/util/profile-key.ts +25 -0
  91. package/src/util/ref-resolver.test.ts +77 -0
  92. package/src/util/ref-resolver.ts +55 -0
  93. package/src/util/silence-console.test.ts +53 -0
  94. package/src/util/silence-console.ts +40 -0
  95. package/src/util/telegram-secrets.test.ts +227 -0
  96. package/src/util/telegram-secrets.ts +223 -0
@@ -0,0 +1,1916 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { isCancel, select, spinner } from '@clack/prompts'
6
+ import { brainSecretsExist, loadBrainSecrets } from '../util/brain-secrets'
7
+ import {
8
+ PROMUS_INBOX_ADDRESS,
9
+ PROMUS_MARKET_ADDRESS,
10
+ ActivityLog,
11
+ type PromusConfig,
12
+ type BrainMessage,
13
+ BrokerPool,
14
+ type ClaudeAgent,
15
+ type ClaudeCommand,
16
+ HookBus,
17
+ type Listener,
18
+ LocalBackend,
19
+ McpManager,
20
+ MemorySyncManager,
21
+ NETWORK_CURRENCY,
22
+ NETWORK_RPC,
23
+ OGComputeBrain,
24
+ AnthropicBrain,
25
+ createStorage,
26
+ type PermissionDecision,
27
+ type PermissionMode,
28
+ type PermissionRequest,
29
+ PermissionService,
30
+ type PostToolCallContext,
31
+ type PreToolCallContext,
32
+ type PreToolCallResult,
33
+ type SandboxBackend,
34
+ SannClient,
35
+ type SkillRef,
36
+ ToolRegistry,
37
+ VISION_PROVIDER_DEFAULTS,
38
+ type VisionInferFn,
39
+ agentPaths,
40
+ applyPerms,
41
+ applyYolo,
42
+ buildFrozenPrefix,
43
+ createFsHistoryPersist,
44
+ detectFetchEscalation,
45
+ discoverClaudeExtras,
46
+ discoverMcpServers,
47
+ explorerTxUrl,
48
+ fetchAndDecryptKeystore,
49
+ iNFTAgentId,
50
+ isOperatorSessionComplete,
51
+ isOperatorSessionFresh,
52
+ loadPlugins,
53
+ makeMemoryListTool,
54
+ makeMemoryReadTool,
55
+ makeMemorySaveTool,
56
+ makeSandboxBackend,
57
+ makeToolSearchTool,
58
+ makeViemClients,
59
+ matchSkillTriggers,
60
+ newEventId,
61
+ readIndexFile,
62
+ requiredScopesForAgent,
63
+ runEscalation,
64
+ scanSkills,
65
+ } from '@promus/core'
66
+ import {
67
+ type CommsRuntimeContext,
68
+ type DeliveredMessage,
69
+ type JobEvent,
70
+ MARKETPLACE_GUIDANCE,
71
+ type OperatorNotice,
72
+ ensureOwnPubkeyPublished,
73
+ formatJobEvent,
74
+ formatJobEventForBrain,
75
+ isParticipant,
76
+ jobEventShouldWakeBrain,
77
+ } from '@promus/plugin-comms'
78
+ import {
79
+ ONCHAIN_GUIDANCE,
80
+ type OnchainRuntimeContext,
81
+ discoverMintBlock,
82
+ } from '@promus/plugin-onchain'
83
+ import {
84
+ TELEGRAM_GUIDANCE,
85
+ type TelegramApprovalBridge,
86
+ type TelegramRuntimeContext,
87
+ formatInboundPreview as formatTelegramInboundPreview,
88
+ } from '@promus/plugin-telegram'
89
+ import { type Address, type Hex, formatEther } from 'viem'
90
+ import { findAndLoadConfig } from '../config/load'
91
+ import { writeConfigTs } from '../config/render'
92
+ import { shortAddr } from '../util/format'
93
+ import { loadTelegramSecrets, telegramSecretsExist } from '../util/telegram-secrets'
94
+ import {
95
+ type TelegramDispatchSlot,
96
+ buildTelegramDispatch,
97
+ buildTelegramRuntimeContext,
98
+ } from './chat-telegram'
99
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
100
+
101
+ export async function runChat(opts?: { cwd?: string; yolo?: boolean; resume?: string }): Promise<void> {
102
+ const found = await findAndLoadConfig(opts?.cwd)
103
+ if (!found) {
104
+ console.log('No promus.config.ts found. Run `promus init` first.')
105
+ process.exit(1)
106
+ }
107
+ let { config } = found
108
+ const configPath = found.path
109
+
110
+ if (!config.identity.iNFT || !config.identity.agent) {
111
+ console.log('Config has no iNFT or agent yet. Re-run `promus init`.')
112
+ process.exit(1)
113
+ }
114
+ // Phase 11: deployTarget=sandbox routes the chat loop to a thin client of
115
+ // the harness HTTP server. The agent's privkey lives only inside the
116
+ // container, so we skip keystore decrypt here.
117
+ if (config.deployTarget === 'sandbox' && config.sandbox?.endpoint) {
118
+ const { runChatSandbox } = await import('./chat-sandbox')
119
+ return runChatSandbox(config)
120
+ }
121
+ // Phase 14: if a local gateway daemon is running for this agent (socket
122
+ // present at ~/.promus/agents/<id>/gateway.sock), route to the same thin
123
+ // client over a unix socket. The TUI no longer holds the runtime — the
124
+ // gateway daemon does. Closing the TUI doesn't stop the listeners.
125
+ //
126
+ // v0.21.5: when no daemon is running but an operator session is fresh,
127
+ // AUTO-SPAWN the daemon as a child process and attach as thin-client.
128
+ // Without this, embedded TUI fallthrough silently disables (a) Telegram
129
+ // pairing-store wiring (no inbound delivery) and (b) AutoTopupManager
130
+ // polling. PROMUS_FORCE_EMBEDDED=1 escape hatch keeps the legacy path
131
+ // available for tests / debugging.
132
+ {
133
+ const _contractAddr = config.identity.iNFT.contract as Address
134
+ const _tokId = BigInt(config.identity.iNFT.tokenId)
135
+ const _aid = iNFTAgentId({ contractAddress: _contractAddr, tokenId: _tokId })
136
+ const _gatewaySock = join(agentPaths.agent(_aid).dir, 'gateway.sock')
137
+ const forceEmbedded = process.env.PROMUS_FORCE_EMBEDDED === '1'
138
+ let _socketExisted = existsSync(_gatewaySock)
139
+ if (_socketExisted) {
140
+ // v0.23.2: if the running daemon's version differs from the on-disk
141
+ // CLI binary's version, the operator just ran `bun add -g promus@N`
142
+ // and expects the new behavior. Auto-restart the daemon so resume always
143
+ // resolves to the latest version.
144
+ const { ensureGatewayVersionMatchesCli } = await import('../util/gateway-version')
145
+ const { createHash } = await import('node:crypto')
146
+ const _identityHash = createHash('sha256').update(_aid).digest('hex').slice(0, 16)
147
+ const _lockFile = join(homedir(), '.promus', 'locks', `@promus/gateway-${_identityHash}.lock`)
148
+ const drift = await ensureGatewayVersionMatchesCli({
149
+ socketPath: _gatewaySock,
150
+ lockFile: _lockFile,
151
+ })
152
+ if (drift.action === 'ok' || drift.action === 'no-cli-version') {
153
+ const { runChatSandbox } = await import('./chat-sandbox')
154
+ return runChatSandbox(config, { unixSocketPath: _gatewaySock })
155
+ }
156
+ console.log(`note: ${drift.note}`)
157
+ _socketExisted = false
158
+ }
159
+ if (!_socketExisted && !forceEmbedded) {
160
+ // v0.21.12: only auto-spawn the gateway daemon when the cached session
161
+ // contains every scope key the daemon will need. A "fresh by ts" session
162
+ // missing the TELEGRAM scope causes the daemon to silently drop all
163
+ // inbound TG (the regression we shipped this fix to close). When
164
+ // incomplete, fall through to the embedded path with a hint to run
165
+ // `promus gateway start` interactively.
166
+ const required = requiredScopesForAgent(_aid)
167
+ if (isOperatorSessionComplete(_aid, required)) {
168
+ const { spawnGatewayDaemon } = await import('../util/gateway-spawn')
169
+ const sBoot = spinner()
170
+ sBoot.start('Starting gateway daemon (auto-spawn)')
171
+ try {
172
+ const result = await spawnGatewayDaemon({
173
+ agentId: _aid,
174
+ configPath: configPath ?? '',
175
+ socketPath: _gatewaySock,
176
+ timeoutMs: 5_000,
177
+ })
178
+ if (result.ready) {
179
+ sBoot.stop(`gateway running pid=${result.pid}`)
180
+ const { runChatSandbox } = await import('./chat-sandbox')
181
+ return runChatSandbox(config, { unixSocketPath: _gatewaySock })
182
+ }
183
+ const reason = result.reason ?? 'unknown'
184
+ const detail = result.error ? `: ${result.error}` : ''
185
+ sBoot.stop(
186
+ `gateway skipped (${reason}${detail}); running embedded`,
187
+ )
188
+ } catch (err) {
189
+ sBoot.stop(
190
+ `gateway skipped: ${(err as Error).message?.slice(0, 160)}; running embedded`,
191
+ )
192
+ }
193
+ } else if (isOperatorSessionFresh(_aid)) {
194
+ // Session timestamp fresh but missing a required scope key (e.g.
195
+ // telegram-secrets.encrypted exists on disk but the cached session
196
+ // was written without TELEGRAM). Auto-spawning would produce a
197
+ // daemon that silently drops TG. Make the operator re-run gateway
198
+ // start interactively for full Touch ID derivation.
199
+ const missing = required.filter(
200
+ s =>
201
+ !isOperatorSessionComplete(_aid, [
202
+ s as ReturnType<typeof requiredScopesForAgent>[number],
203
+ ]),
204
+ )
205
+ console.log(
206
+ `note: cached operator-session is missing scope key(s) [${missing.join(', ')}] — run \`promus gateway start\` to re-derive via Touch ID. Continuing in embedded mode.`,
207
+ )
208
+ } else {
209
+ // No session at all → operator must run `promus gateway start` for the
210
+ // full daemon path (Touch ID + scope-key derivation). Print a hint.
211
+ console.log(
212
+ 'note: gateway daemon would unlock TG + auto-topup; run `promus gateway start` to enable. Continuing in embedded mode.',
213
+ )
214
+ }
215
+ }
216
+ }
217
+ const contractAddress = config.identity.iNFT.contract as Address
218
+ const tokenId = BigInt(config.identity.iNFT.tokenId)
219
+ const agentId = iNFTAgentId({ contractAddress, tokenId })
220
+ const paths = agentPaths.agent(agentId)
221
+ const agentAddress = config.identity.agent as Address
222
+
223
+ // Generate a short session ID for resume support (e.g. "a3f2-k9m1")
224
+ const sessionId = opts?.resume ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
225
+ const sessionDir = `${paths.dir}/sessions`
226
+ const sessionFile = `${sessionDir}/${sessionId}.json`
227
+
228
+ const operator = await loadOrPickOperatorSigner({
229
+ network: config.network,
230
+ hint: config.operator,
231
+ })
232
+ if (!operator) {
233
+ console.log('No operator wallet available; cannot decrypt keystore.')
234
+ process.exit(1)
235
+ }
236
+
237
+ const sUnlock = spinner()
238
+ sUnlock.start('Fetching encrypted keystore + decrypting via operator wallet')
239
+ let agentPrivkey: Hex
240
+ try {
241
+ const decrypted = await fetchAndDecryptKeystore({
242
+ network: config.network,
243
+ contractAddress,
244
+ tokenId,
245
+ signer: operator,
246
+ agentAddress,
247
+ cachePath: paths.keystore,
248
+ })
249
+ agentPrivkey = decrypted.privkeyHex
250
+ sUnlock.stop(`unlocked (keystore source: ${decrypted.source})`)
251
+ } catch (e) {
252
+ sUnlock.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
253
+ await operator.close?.()
254
+ process.exit(1)
255
+ }
256
+
257
+ // Phase 12: decrypt brain-secrets blob (API key + storage config) using the
258
+ // SAME operator signer we already have unlocked.
259
+ let brainApiKey: string | undefined
260
+ if (brainSecretsExist(agentId)) {
261
+ const sBrain = spinner()
262
+ sBrain.start('Decrypting brain secrets')
263
+ try {
264
+ const brainSecrets = await loadBrainSecrets({ signer: operator, agentAddress, agentId })
265
+ if (brainSecrets) {
266
+ brainApiKey = brainSecrets.apiKey
267
+ // Set env so AnthropicBrain reads it (it checks process.env.ANTHROPIC_API_KEY)
268
+ if (brainSecrets.provider === 'anthropic') {
269
+ process.env.ANTHROPIC_API_KEY = brainSecrets.apiKey
270
+ }
271
+ // Also set IPFS env vars from brain secrets
272
+ if (brainSecrets.ipfsApiUrl) process.env.PROMUS_IPFS_API_URL = brainSecrets.ipfsApiUrl
273
+ if (brainSecrets.ipfsGateway) process.env.PROMUS_IPFS_GATEWAY = brainSecrets.ipfsGateway
274
+ sBrain.stop(`brain secrets loaded (${brainSecrets.provider})`)
275
+ }
276
+ } catch (e) {
277
+ sBrain.stop(`brain secrets decrypt failed: ${(e as Error).message.slice(0, 160)}`)
278
+ }
279
+ }
280
+
281
+ // Phase 12: decrypt telegram-secrets blob (if any) using the SAME operator
282
+ // signer we already have unlocked. Avoids a second keychain prompt later.
283
+ // We only attempt this if the operator opted in via `promus telegram setup`
284
+ // (presence of the encrypted blob); the plugin opt-in is independent and
285
+ // checked again below at plugin filter time.
286
+ let telegramSecrets: Awaited<ReturnType<typeof loadTelegramSecrets>> = null
287
+ if (telegramSecretsExist(agentId) && (config.plugins ?? []).includes('telegram')) {
288
+ const sTg = spinner()
289
+ sTg.start('Decrypting telegram secrets')
290
+ try {
291
+ telegramSecrets = await loadTelegramSecrets({ signer: operator, agentAddress, agentId })
292
+ sTg.stop(`telegram unlocked (bot @${telegramSecrets?.botUsername ?? '?'})`)
293
+ } catch (e) {
294
+ sTg.stop(`telegram decrypt failed: ${(e as Error).message.slice(0, 160)}`)
295
+ // Soft-fail: telegram is opt-in. Boot continues without it.
296
+ }
297
+ }
298
+
299
+ await operator.close?.()
300
+
301
+ if (!config.brain.provider) {
302
+ const updated = await runModelPicker(config, agentPrivkey, configPath)
303
+ if (!updated) process.exit(1)
304
+ config = updated
305
+ }
306
+
307
+ const tools = new ToolRegistry(config.tools)
308
+ tools.register(makeMemorySaveTool({ agentId }) as Parameters<typeof tools.register>[0])
309
+ tools.register(makeMemoryReadTool({ agentId }) as Parameters<typeof tools.register>[0])
310
+ if (config.identity.iNFT) {
311
+ tools.register(
312
+ makeMemoryListTool({
313
+ agentId,
314
+ network: config.network,
315
+ contractAddress: config.identity.iNFT.contract as `0x${string}`,
316
+ tokenId: BigInt(config.identity.iNFT.tokenId),
317
+ }) as Parameters<typeof tools.register>[0],
318
+ )
319
+ }
320
+ tools.register(makeToolSearchTool(tools) as Parameters<typeof tools.register>[0])
321
+
322
+ const initialMode: PermissionMode = opts?.yolo ? 'off' : (config.approvals?.mode ?? 'prompt')
323
+ const permission = new PermissionService({ mode: initialMode })
324
+ const hooks = new HookBus()
325
+
326
+ // Plugin failures are reported but do not abort startup; the brain still has
327
+ // memory tools.
328
+ //
329
+ // The dynamic `import()` MUST happen from the CLI package context: that's
330
+ // where the workspace deps `promus-plugin-*` live. Passing this
331
+ // resolver pins the import site to chat.tsx so bun's resolver finds them.
332
+ // Claude Code extras (commands + agents) discovery happens BEFORE plugin
333
+ // load so delegate.task can surface agents.
334
+ let claudeCommands: ClaudeCommand[] = []
335
+ let claudeAgents: ClaudeAgent[] = []
336
+ try {
337
+ const extras = await discoverClaudeExtras({
338
+ importsClaudeCode: config.imports?.claudeCode ?? true,
339
+ })
340
+ claudeCommands = extras.commands
341
+ claudeAgents = extras.agents
342
+ } catch {
343
+ // Discovery failed; continue without commands/agents.
344
+ }
345
+ const commandIndex = new Map<string, ClaudeCommand>()
346
+ for (const cmd of claudeCommands) {
347
+ if (!commandIndex.has(cmd.name)) commandIndex.set(cmd.name, cmd)
348
+ if (!commandIndex.has(cmd.id)) commandIndex.set(cmd.id, cmd)
349
+ }
350
+
351
+ // Sub-brain factory for delegate.task (Phase 9.3). The factory creates a
352
+ // fresh OGComputeBrain on the SAME provider/model with a custom system
353
+ // prompt. Tools default to none for delegated work; the parent calls
354
+ // delegate.task only when isolation matters.
355
+ // Brain backend: Claude when ANTHROPIC_API_KEY is set, else Promus Brain.
356
+ const useAnthropic = !!process.env.ANTHROPIC_API_KEY
357
+ const delegateFactory: import('@promus/core').DelegateBrainFactory = async ({
358
+ systemPrompt,
359
+ tools: subTools,
360
+ }) => {
361
+ const subPrefix = buildFrozenPrefix({
362
+ systemPrompt,
363
+ memoryIndex: null,
364
+ identity: null,
365
+ persona: null,
366
+ loadedToolNames: [],
367
+ skills: [],
368
+ timestamp: null,
369
+ })
370
+ const subBrain: OGComputeBrain | AnthropicBrain = useAnthropic
371
+ ? new AnthropicBrain({ model: config.brain?.model, tools: subTools, prefix: subPrefix })
372
+ : new OGComputeBrain({
373
+ privkeyHex: agentPrivkey,
374
+ rpcUrl: NETWORK_RPC[config.network],
375
+ providerAddress: config.brain.provider!,
376
+ tools: subTools,
377
+ prefix: subPrefix,
378
+ })
379
+ await subBrain.init()
380
+ return subBrain as unknown as import('@promus/core').DelegateBrainHandle
381
+ }
382
+
383
+ // Phase 9.5: build sandbox backend BEFORE plugins load. Tools that spawn
384
+ // subprocesses (shell.run, code.execute, shell.process_start) wrap their
385
+ // spawn argv through this backend. PROMUS_SANDBOX_MODE env var wins over
386
+ // config (matches hermes' TERMINAL_ENV pattern — per-launch override
387
+ // without editing config).
388
+ const envOverride = process.env.PROMUS_SANDBOX_MODE
389
+ const sandboxMode: 'none' | 'os' | 'docker' =
390
+ envOverride === 'none' || envOverride === 'os' || envOverride === 'docker'
391
+ ? envOverride
392
+ : (config.sandbox?.mode ?? 'none')
393
+ let sandbox: SandboxBackend
394
+ try {
395
+ sandbox = makeSandboxBackend({
396
+ mode: sandboxMode,
397
+ agentDir: paths.dir,
398
+ workspaceRoot: process.cwd(),
399
+ homedir: homedir(),
400
+ dockerImage: config.sandbox?.dockerImage,
401
+ dockerMountWorkspace: config.sandbox?.dockerMountWorkspace,
402
+ dockerRuntimePath: config.sandbox?.dockerRuntimePath,
403
+ dockerCpu: config.sandbox?.dockerCpu,
404
+ dockerMemoryMb: config.sandbox?.dockerMemoryMb,
405
+ dockerDiskMb: config.sandbox?.dockerDiskMb,
406
+ dockerNoNetwork: config.sandbox?.dockerNoNetwork,
407
+ })
408
+ } catch (err) {
409
+ process.stderr.write(
410
+ `promus: sandbox init failed (${(err as Error).message}), continuing without sandbox\n`,
411
+ )
412
+ sandbox = new LocalBackend()
413
+ }
414
+ if (sandbox.mode === 'os') {
415
+ process.stderr.write(
416
+ `promus: sandbox active [${sandbox.label}] — limb spawns gated to agentDir + cwd + /tmp/promus-* + /var/folders; reads of ~/.ssh ~/.aws ~/Library/Keychains ~/.config/gcloud denied\n`,
417
+ )
418
+ } else if (sandbox.mode === 'docker') {
419
+ process.stderr.write(
420
+ `promus: 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`,
421
+ )
422
+ }
423
+ // Register dispose hook so docker containers don't leak when promus exits.
424
+ // Signal handlers MUST await dispose before exiting; sync `process.exit(0)`
425
+ // would discard the dispose promise and leave the container orphaned.
426
+ if (sandbox.dispose) {
427
+ const disposeOnce = (() => {
428
+ let done = false
429
+ return async () => {
430
+ if (done) return
431
+ done = true
432
+ await sandbox.dispose?.().catch(() => {})
433
+ }
434
+ })()
435
+ process.once('SIGINT', () => {
436
+ void disposeOnce().then(() => process.exit(0))
437
+ })
438
+ process.once('SIGTERM', () => {
439
+ void disposeOnce().then(() => process.exit(0))
440
+ })
441
+ }
442
+
443
+ const brokerPool = new BrokerPool({
444
+ privkeyHex: agentPrivkey,
445
+ rpcUrl: NETWORK_RPC[config.network],
446
+ })
447
+ const visionProviderRaw = config.vision?.provider
448
+ const visionProvider =
449
+ visionProviderRaw === null
450
+ ? null
451
+ : (visionProviderRaw ?? VISION_PROVIDER_DEFAULTS[config.network])
452
+ const visionInfer: VisionInferFn | null = visionProvider
453
+ ? brokerPool.visionInferFor(visionProvider)
454
+ : null
455
+
456
+ // Plugin filter: system + comms + onchain all ship; telegram is opt-in via
457
+ // `promus telegram setup` which writes ~/.promus/agents/<id>/telegram-secrets.encrypted
458
+ // and adds 'telegram' to config.plugins.
459
+ const pluginNames = (config.plugins ?? []).filter(
460
+ p => p === 'system' || p === 'comms' || p === 'onchain' || p === 'telegram',
461
+ )
462
+ // viem clients live above the comms gate so the agent-EOA balance refresher
463
+ // works regardless of whether the comms plugin is loaded.
464
+ const viemClients = makeViemClients({ network: config.network, privkeyHex: agentPrivkey })
465
+ // Phase 7 comms side-band ctx: viem clients + OGStorage adapter + SannClient +
466
+ // PromusInbox singleton + listener delivery callbacks. Skipped when 'comms'
467
+ // isn't in the plugins list to avoid the eager construction cost.
468
+ // onDeliver/onOperatorNotice are forward-declared as mutable cells so the ctx
469
+ // can be built before state + brain exist; they get wired further below.
470
+ const inboundQueue: DeliveredMessage[] = []
471
+ let onInboundDeliver: (m: DeliveredMessage) => void = m => {
472
+ inboundQueue.push(m)
473
+ }
474
+ let onInboundNotice: (n: OperatorNotice) => void = () => {}
475
+ // Phase 8: market events buffered the same way until UI mounts.
476
+ const jobEventQueue: JobEvent[] = []
477
+ let onMarketJobEvent: (e: JobEvent) => void = e => {
478
+ jobEventQueue.push(e)
479
+ }
480
+ // Phase 10 onchain side-band ctx: viem clients (already built above) +
481
+ // agent EOA + iNFT mint block (used as Transfer-event scan floor). Pre-
482
+ // Phase-10 configs lack `mintBlock`; we backfill at chat boot by querying
483
+ // the iNFT contract's ERC-721 Transfer logs for `tokenId` from `0x0` and
484
+ // persist the value back to ~/.promus/config.ts so subsequent runs skip it.
485
+ let onchain: OnchainRuntimeContext | undefined
486
+ if (pluginNames.includes('onchain')) {
487
+ const iNFT = config.identity.iNFT
488
+ if (!iNFT) {
489
+ throw new Error('plugin-onchain requires identity.iNFT in config')
490
+ }
491
+ let mintBlock = iNFT.mintBlock ? BigInt(iNFT.mintBlock) : null
492
+ if (mintBlock === null) {
493
+ mintBlock = await discoverMintBlock(viemClients.publicClient, contractAddress, tokenId)
494
+ if (mintBlock !== null) {
495
+ const updated: typeof config = {
496
+ ...config,
497
+ identity: {
498
+ ...config.identity,
499
+ iNFT: { ...iNFT, mintBlock: mintBlock.toString() },
500
+ },
501
+ }
502
+ await writeConfigTs(configPath, updated, { subname: config.subname })
503
+ config = updated
504
+ }
505
+ }
506
+ onchain = {
507
+ agentEoa: agentAddress,
508
+ network: config.network,
509
+ publicClient: viemClients.publicClient,
510
+ walletClient: viemClients.walletClient,
511
+ agentDir: paths.dir,
512
+ mintBlock: mintBlock ?? 0n,
513
+ iNFT: { contract: contractAddress, tokenId },
514
+ brainProvider: config.brain.provider,
515
+ brainModel: config.brain.model,
516
+ // v0.21.9: account.balance reads these to surface sandbox billing reserve
517
+ // for sandbox-deployed agents. Local mode just keeps deployTarget='local'
518
+ // and skips the sandbox billing reserve section.
519
+ deployTarget: (config.deployTarget ?? 'local') as 'local' | 'sandbox',
520
+ operatorAddress: config.identity.operator as Address | undefined,
521
+ }
522
+ }
523
+ let comms: CommsRuntimeContext | undefined
524
+ let sann: SannClient | undefined
525
+ if (pluginNames.includes('comms')) {
526
+ const inboxAddress = PROMUS_INBOX_ADDRESS[config.network] as Address | undefined
527
+ if (!inboxAddress) {
528
+ throw new Error(
529
+ `PromusInbox address missing for network=${config.network}; check core/identity/deployments.ts`,
530
+ )
531
+ }
532
+ const marketAddress = PROMUS_MARKET_ADDRESS[config.network] as Address | undefined
533
+ const ogStorage = createStorage({ network: config.network, privkeyHex: agentPrivkey })
534
+ sann = new SannClient({ privkeyHex: agentPrivkey })
535
+ // Listener.catchUp fetches getBlockNumber itself; passing 0n here just
536
+ // seeds an unset cursor so the first catch-up scans from chain head.
537
+ const sannRead = sann
538
+ comms = {
539
+ agentEoa: agentAddress,
540
+ agentPrivkeyHex: agentPrivkey,
541
+ publicClient: viemClients.publicClient,
542
+ walletClient: viemClients.walletClient,
543
+ sann: { readText: (node, key) => sannRead.readText(node, key) },
544
+ storage: {
545
+ put: async bytes => (await ogStorage.putBlob(bytes)) as Hex,
546
+ get: async dataHash => {
547
+ const blob = await ogStorage.getBlob(dataHash)
548
+ if (!blob) throw new Error(`storage: blob ${dataHash} not found`)
549
+ return blob
550
+ },
551
+ },
552
+ inboxAddress,
553
+ startBlock: 0n,
554
+ onDeliver: m => onInboundDeliver(m),
555
+ onOperatorNotice: n => onInboundNotice(n),
556
+ ...(marketAddress
557
+ ? {
558
+ marketAddress,
559
+ onJobEvent: (e: JobEvent) => onMarketJobEvent(e),
560
+ }
561
+ : {}),
562
+ }
563
+ }
564
+
565
+ // Phase 12: telegram side-band ctx. We build the runtime context now (before
566
+ // brain.init) so the plugin can register its listener via ctx.registerListener,
567
+ // but the dispatch callback is deferred — the slot's `.current` is null until
568
+ // brain.init resolves and we wire it below. Same for the system-row sink:
569
+ // populated once state exists.
570
+ const telegramSlot: TelegramDispatchSlot = { current: null }
571
+ const telegramSystemRowSink: { current: ((text: string) => void) | null } = { current: null }
572
+ const telegramInboundRowSink: { current: ((text: string) => void) | null } = { current: null }
573
+ const telegramAssistantRowSink: { current: ((text: string) => void) | null } = { current: null }
574
+ // Bridge for inline-keyboard approval. Listener fills the inner refs on
575
+ // start; chat-telegram's runOne reads them at turn time.
576
+ const telegramApprovalBridge: TelegramApprovalBridge = {
577
+ sendApproval: { current: null },
578
+ installCallbackHandler: { current: null },
579
+ }
580
+ let telegram: TelegramRuntimeContext | undefined
581
+ if (telegramSecrets && pluginNames.includes('telegram')) {
582
+ telegram = buildTelegramRuntimeContext({
583
+ botToken: telegramSecrets.botToken,
584
+ allowedUserIds: telegramSecrets.allowedUserIds,
585
+ agentName: config.subname ?? `agent-${agentId.slice(0, 8)}`,
586
+ slot: telegramSlot,
587
+ systemRowSink: telegramSystemRowSink,
588
+ })
589
+ telegram.approvalBridge = telegramApprovalBridge
590
+ }
591
+ // Local listener registry: plugin-comms registers a single 'a2a-inbox'
592
+ // listener via ctx.registerListener; we collect them here so chat can
593
+ // start them once brain init is done. Other plugins may register listeners
594
+ // too — same path.
595
+ const collectedListeners: Listener[] = []
596
+ const skillsDisabled = { current: [...(config.skills?.disabled ?? [])] }
597
+ const loadResult = await loadPlugins(pluginNames, {
598
+ tools,
599
+ hooks,
600
+ listeners: {
601
+ register: l => {
602
+ collectedListeners.push(l)
603
+ },
604
+ },
605
+ agentDir: paths.dir,
606
+ agentId,
607
+ network: config.network,
608
+ configPath,
609
+ imports: { claudeCode: config.imports?.claudeCode ?? true },
610
+ skillsDisabled,
611
+ activityLogPath: paths.activityLog,
612
+ workspaceRoot: process.cwd(),
613
+ delegateFactory,
614
+ claudeAgents,
615
+ brainSupportsVision: false,
616
+ brainModelLabel: config.brain.model ?? config.brain.provider,
617
+ visionInfer,
618
+ sandbox,
619
+ comms,
620
+ onchain,
621
+ telegram,
622
+ resolve: async name => {
623
+ switch (name) {
624
+ case 'system':
625
+ return await import('@promus/plugin-system')
626
+ case 'comms':
627
+ return await import('@promus/plugin-comms')
628
+ case 'onchain':
629
+ return await import('@promus/plugin-onchain')
630
+ case 'telegram':
631
+ return await import('@promus/plugin-telegram')
632
+ default:
633
+ throw new Error(`unknown first-party plugin: ${name}`)
634
+ }
635
+ },
636
+ })
637
+ if (loadResult.errors.length > 0 || process.env.PROMUS_DEBUG_PLUGINS) {
638
+ const { writeFile } = await import('node:fs/promises')
639
+ const { join } = await import('node:path')
640
+ await writeFile(
641
+ join(paths.dir, 'plugin-debug.log'),
642
+ JSON.stringify(
643
+ {
644
+ ts: Date.now(),
645
+ pluginNames,
646
+ loadResult,
647
+ registeredTools: tools.list().map(t => t.name),
648
+ },
649
+ null,
650
+ 2,
651
+ ),
652
+ ).catch(() => {})
653
+ }
654
+
655
+ // MCP discovery: scan ~/.promus/.mcp.json + ~/.claude/.mcp.json + plugin
656
+ // cache, spawn each stdio server, register tools as deferred. Failures are
657
+ // logged but never block startup.
658
+ let mcpManager: McpManager | null = null
659
+ try {
660
+ const { servers } = await discoverMcpServers({
661
+ importsClaudeCode: config.imports?.claudeCode ?? true,
662
+ })
663
+ if (servers.length > 0) {
664
+ mcpManager = new McpManager(servers)
665
+ const mcpResult = await mcpManager.registerAll(def =>
666
+ tools.register(def as Parameters<typeof tools.register>[0]),
667
+ )
668
+ if (mcpResult.failed.length > 0 || process.env.PROMUS_DEBUG_PLUGINS) {
669
+ const { writeFile } = await import('node:fs/promises')
670
+ const { join } = await import('node:path')
671
+ await writeFile(
672
+ join(paths.dir, 'mcp-debug.log'),
673
+ JSON.stringify(
674
+ { ts: Date.now(), servers: servers.map(s => s.name), result: mcpResult },
675
+ null,
676
+ 2,
677
+ ),
678
+ ).catch(() => {})
679
+ }
680
+ }
681
+ } catch {
682
+ // Discovery itself failed (probably I/O); proceed without MCP.
683
+ }
684
+
685
+ const sync = new MemorySyncManager({
686
+ network: config.network,
687
+ agentId,
688
+ agentPrivkey,
689
+ agentAddress,
690
+ contractAddress,
691
+ tokenId,
692
+ })
693
+ // We deliberately skip `sync.init()`: it would seed lastPlaintextHash with
694
+ // on-chain CIPHERTEXT hashes which never equal local plaintext hashes, so
695
+ // the first flush would re-upload everything anyway. Letting plaintextHash
696
+ // start empty produces the same one-time re-anchor on first flush, then
697
+ // steady-state diffing kicks in without a wasted RPC call.
698
+
699
+ await mkdir(paths.memoryDir, { recursive: true })
700
+ const [memoryIndex, identityText, personaText, scannedSkills] = await Promise.all([
701
+ readIndexFile(paths.memoryIndex).catch(() => null),
702
+ readMemoryFileOrNull(`${paths.memoryDir}/agent/identity.md`),
703
+ readMemoryFileOrNull(`${paths.memoryDir}/agent/persona.md`),
704
+ scanSkills({ importsClaudeCode: config.imports?.claudeCode ?? true }).catch(
705
+ () => [] as SkillRef[],
706
+ ),
707
+ ])
708
+ // Use tools.list() (includes deferred) for guidance lookup — guidance
709
+ // fires per-tool-namespace, not per-prompt-schema. tools.schemas() is the
710
+ // separate set the brain SEES in its prompt; deferred tools stay hidden
711
+ // there until tool.search loads them. But the brain still needs to know
712
+ // they EXIST via guidance, otherwise it never thinks to search.
713
+ const loadedToolNames = tools.list().map(t => t.name)
714
+ const disabledSkillSet = new Set(skillsDisabled.current)
715
+ const skillsRef: { current: SkillRef[] } = {
716
+ current: scannedSkills.filter(s => !disabledSkillSet.has(s.id)),
717
+ }
718
+ const promptAppend = config.prompt?.append ?? null
719
+ // Surface sandbox awareness so the brain doesn't have to empirically discover
720
+ // its container/profile via pwd + ls + uname round-trips. Without it,
721
+ // qwen3.6-plus would hit fs.read('/workspace/X') → ENOENT (fs.* runs on host),
722
+ // sed -i '' (BSD) → fails on Linux GNU sed, and answer "where am I?" only
723
+ // after probing. Each wasted call costs latency + tokens.
724
+ const envInfo = {
725
+ cwd: process.cwd(),
726
+ platform: process.platform,
727
+ sandbox: sandbox.envHint?.() ?? null,
728
+ }
729
+ // Plugin-contributed prompt sections. plugin-comms ships marketplace
730
+ // guidance only when PromusMarket is actually wired (marketAddress set);
731
+ // gating on `comms?.marketAddress` keeps the prefix lean for non-market
732
+ // sessions and avoids paying tokens for unreachable behavior.
733
+ const extraGuidance: string[] = []
734
+ if (comms?.marketAddress) extraGuidance.push(MARKETPLACE_GUIDANCE)
735
+ if (onchain) extraGuidance.push(ONCHAIN_GUIDANCE)
736
+ if (telegram) extraGuidance.push(TELEGRAM_GUIDANCE)
737
+
738
+ const buildPrefix = async () => {
739
+ const idx = await readIndexFile(paths.memoryIndex).catch(() => null)
740
+ return buildFrozenPrefix({
741
+ memoryIndex: idx,
742
+ identity: identityText,
743
+ persona: personaText,
744
+ loadedToolNames,
745
+ skills: skillsRef.current,
746
+ promptAppend,
747
+ envInfo,
748
+ extraGuidance,
749
+ })
750
+ }
751
+ const prefix = buildFrozenPrefix({
752
+ memoryIndex,
753
+ identity: identityText,
754
+ persona: personaText,
755
+ loadedToolNames,
756
+ skills: skillsRef.current,
757
+ promptAppend,
758
+ envInfo,
759
+ extraGuidance,
760
+ })
761
+ const activity = new ActivityLog(paths.activityLog)
762
+
763
+ // Brain init must happen BEFORE createCliRenderer. clack/prompts spinner
764
+ // calls setRawMode(false) + stdin.pause() on stop, which undoes the
765
+ // stdin.resume() that opentui's setupTerminal sets up. If brain init
766
+ // (and its spinner) ran AFTER createCliRenderer, the stop would flip
767
+ // stdin back into a state where opentui can't read keypresses, AND the
768
+ // event loop would empty (no stdin keepalive) so the process exits.
769
+ // The fix: every clack interaction finishes before opentui takes the wheel.
770
+ const { render } = await import('@opentui/solid')
771
+ const { createCliRenderer } = await import('@opentui/core')
772
+ const { createChatState } = await import('../ui/state')
773
+ const { ChatApp } = await import('../ui/app')
774
+
775
+ const state = createChatState({
776
+ initialSystem: opts?.yolo
777
+ ? 'connected. YOLO mode: approval prompts disabled.'
778
+ : 'connected. type messages and press enter.',
779
+ // v0.22.0: show .0g subname when registered, fall back to the 16-char
780
+ // agent ID hash. Use the FULL agent EOA (no shortAddr) so operators see
781
+ // the complete address — useful for chain explorers + auto-topup audits.
782
+ // Brain provider address dropped from statusline entirely; it had been
783
+ // visual noise nobody acted on. Brain identity surfaces via singletons
784
+ // in the frozen prefix and /healthz.brainProvider for operators.
785
+ identityLabel: `agent ${config.subname ?? agentId} ${agentAddress}`,
786
+ approvalsMode: initialMode,
787
+ // v0.24.4: embedded chat runs in-process on the operator's machine — by
788
+ // definition local. Tag it so the statusbar hides the sandbox-billing
789
+ // segment, matching the standalone-local-gateway path.
790
+ isLocalGateway: true,
791
+ currency: NETWORK_CURRENCY[config.network],
792
+ })
793
+
794
+ // Phase 12: now that state exists, point the telegram row sinks at it. The
795
+ // dispatch slot stays null until brain.init resolves below.
796
+ if (telegram) {
797
+ telegramSystemRowSink.current = (text: string) => state.pushRow({ role: 'system', text })
798
+ telegramInboundRowSink.current = (text: string) => state.pushRow({ role: 'inbox-tg', text })
799
+ telegramAssistantRowSink.current = (text: string) =>
800
+ state.pushRow({ role: 'telegram-assistant', text })
801
+ }
802
+
803
+ // Statusline balance refreshers; fired at boot, post-turn, and post-/sync.
804
+ const refreshEoaBalance = () => {
805
+ viemClients.publicClient
806
+ .getBalance({ address: agentAddress })
807
+ .then(wei => state.setEoaBalance(Number(formatEther(wei))))
808
+ .catch(() => {})
809
+ }
810
+ const refreshBalances = () => {
811
+ // Compute-ledger balance is brain-specific; the Anthropic brain bills
812
+ // off-chain, so there's no on-chain ledger to display.
813
+ if (brain instanceof OGComputeBrain) {
814
+ brain
815
+ .getLedgerBalance()
816
+ .then(b => {
817
+ if (b != null) state.setBalance(b)
818
+ })
819
+ .catch(() => {})
820
+ }
821
+ refreshEoaBalance()
822
+ }
823
+
824
+ permission.setPrompter(req => {
825
+ return new Promise<PermissionDecision>(resolve => {
826
+ // Value-moving onchain ops carry amount/recipient/token so we render a
827
+ // friendlier "send 0.05 ETH to 0xC635...87Ec" instead of a raw command.
828
+ const detail =
829
+ req.amount !== undefined
830
+ ? `${req.amount}${req.token ? ` ${req.token}` : ''}${req.recipient ? ` to ${req.recipient}` : ''}`
831
+ : (req.command ?? req.path ?? '(?)')
832
+ state.pushRow({
833
+ role: 'system',
834
+ text: `[approval requested] ${req.reason}: ${detail}`,
835
+ })
836
+ state.setPendingApproval({ request: req, resolve })
837
+ })
838
+ })
839
+
840
+ hooks.add<PreToolCallContext, PreToolCallResult>('pre_tool_call', async ({ call }) => {
841
+ const checks = describePermissionCheck(call)
842
+ if (!checks) return undefined
843
+ const result = await permission.resolve(checks)
844
+ if (result.allowed) return undefined
845
+ return {
846
+ short: {
847
+ ok: false,
848
+ 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.`,
849
+ },
850
+ }
851
+ })
852
+
853
+ // Skills auto-trigger: when a tool call matches a skill's filePattern or
854
+ // bashPattern, surface a system row so the operator sees the auto-load AND
855
+ // queue the SKILL.md body for next-turn injection via brain.injectContext().
856
+ const pendingSkillInjections = new Set<string>()
857
+ hooks.add<PostToolCallContext, void>('post_tool_call', async ({ call, result }) => {
858
+ if (result.ok === false) return
859
+ const matches = matchSkillTriggers({ name: call.name, args: call.args }, skillsRef.current)
860
+ for (const match of matches) {
861
+ if (pendingSkillInjections.has(match.skill.id)) continue
862
+ pendingSkillInjections.add(match.skill.id)
863
+ state.pushRow({
864
+ role: 'system',
865
+ text: `↳ skill auto-loaded: ${match.skill.id} (matched ${match.reason}). use skills.view to read body.`,
866
+ })
867
+ }
868
+ })
869
+
870
+ const bootSpinner = spinner()
871
+ bootSpinner.start(
872
+ useAnthropic
873
+ ? `Connecting to Claude (${config.brain?.model ?? 'claude-opus-4-8'})`
874
+ : `Connecting to brain (${shortAddr(config.brain.provider!)})`,
875
+ )
876
+ const persistConversations = config.brain?.persistConversations !== false
877
+ const brainOpts = {
878
+ tools: tools.schemas(),
879
+ prefix,
880
+ maxOutputTokens: config.brain?.maxOutputTokens,
881
+ compaction:
882
+ config.brain?.compaction === null
883
+ ? null
884
+ : {
885
+ threshold: config.brain?.compaction?.threshold ?? 0.5,
886
+ contextWindow: config.brain?.contextWindow ?? 1_000_000,
887
+ keepRecent: config.brain?.compaction?.keepRecent ?? 8,
888
+ },
889
+ persist: persistConversations
890
+ ? createFsHistoryPersist({ dir: `${paths.dir}/conversations` })
891
+ : undefined,
892
+ onToolCall: async (call: { id: string; name: string; args: unknown }) => {
893
+ state.pushRow({
894
+ role: 'tool-call',
895
+ text: '',
896
+ toolName: call.name,
897
+ args: summarizeArgs(call.args),
898
+ })
899
+ const pre = await hooks.runPreToolCall({ call })
900
+ if (pre.short) {
901
+ await activity.append({
902
+ ts: Date.now(),
903
+ kind: 'tool-call',
904
+ data: { call, result: pre.short, blocked: true },
905
+ })
906
+ state.pushRow({
907
+ role: 'tool-result',
908
+ text: summarizeToolResult(pre.short),
909
+ failed: pre.short.ok === false,
910
+ })
911
+ return { role: 'tool', content: JSON.stringify(pre.short) } as BrainMessage
912
+ }
913
+ const effectiveCall = pre.call ?? call
914
+ const result = await tools.dispatch(effectiveCall)
915
+ await hooks.runPostToolCall({ call: effectiveCall, result })
916
+ await activity.append({
917
+ ts: Date.now(),
918
+ kind: 'tool-call',
919
+ data: { call: effectiveCall, result },
920
+ })
921
+ state.pushRow({
922
+ role: 'tool-result',
923
+ text: summarizeToolResult(result),
924
+ failed: result.ok === false,
925
+ })
926
+ // v0.21.2 R1: deterministic browser.navigate retry when web.fetch hits
927
+ // a bot-block. Mirror block in build-runtime.ts; both share orchestration
928
+ // via runEscalation so any future change lands in one place. Sinks differ:
929
+ // TUI pushes rows here, gateway publishes SSE events.
930
+ const escalation = detectFetchEscalation(effectiveCall, result)
931
+ if (escalation.needed) {
932
+ const merged = await runEscalation(escalation, result, {
933
+ runPreCall: c => hooks.runPreToolCall({ call: c }),
934
+ runPostCall: (c, r) => hooks.runPostToolCall({ call: c, result: r }),
935
+ dispatch: c => tools.dispatch(c),
936
+ appendActivity: (c, r) =>
937
+ activity.append({
938
+ ts: Date.now(),
939
+ kind: 'tool-call',
940
+ data: { call: c, result: r, autoEscalated: true },
941
+ }),
942
+ onStart: c =>
943
+ state.pushRow({
944
+ role: 'tool-call',
945
+ text: '',
946
+ toolName: c.name,
947
+ args: summarizeArgs(c.args),
948
+ autoEscalated: true,
949
+ }),
950
+ onEnd: (_c, r) =>
951
+ state.pushRow({
952
+ role: 'tool-result',
953
+ text: summarizeToolResult(r),
954
+ failed: r.ok === false,
955
+ autoEscalated: true,
956
+ }),
957
+ })
958
+ return { role: 'tool', content: JSON.stringify(merged) } as BrainMessage
959
+ }
960
+ return {
961
+ role: 'tool',
962
+ content: JSON.stringify(result),
963
+ } as BrainMessage
964
+ },
965
+ }
966
+ const brain: OGComputeBrain | AnthropicBrain = useAnthropic
967
+ ? new AnthropicBrain({ ...brainOpts, model: config.brain?.model })
968
+ : new OGComputeBrain({
969
+ ...brainOpts,
970
+ privkeyHex: agentPrivkey,
971
+ rpcUrl: NETWORK_RPC[config.network],
972
+ providerAddress: config.brain.provider!,
973
+ })
974
+ try {
975
+ await brain.init()
976
+ bootSpinner.stop('Connected')
977
+ } catch (e) {
978
+ bootSpinner.stop(`Connection failed: ${(e as Error).message.slice(0, 120)}`)
979
+ process.exit(1)
980
+ }
981
+
982
+ // Phase 12: brain is up. Wire the deferred TG dispatch slot so any inbound
983
+ // TG message that lands once collectedListeners[i].start() fires below
984
+ // routes through brain.infer with source=telegram.
985
+ if (telegram) {
986
+ const handle = buildTelegramDispatch({
987
+ activity,
988
+ sync,
989
+ permission,
990
+ pushAssistantRow: text => telegramAssistantRowSink.current?.(text),
991
+ pushInboundRow: text => telegramInboundRowSink.current?.(text),
992
+ isBusy: () => state.status() === 'thinking',
993
+ buildPrefix,
994
+ brain,
995
+ setThinking: on => state.setStatus(on ? 'thinking' : 'idle'),
996
+ setActiveAbort: ctrl => state.setActiveAbort(ctrl),
997
+ refreshBalances,
998
+ formatInboundPreview: input =>
999
+ formatTelegramInboundPreview({
1000
+ chatId: input.chatId,
1001
+ username: input.username,
1002
+ displayName: input.displayName,
1003
+ text: input.text.replace(/^<channel[^>]*>([\s\S]*)<\/channel>$/, '$1'),
1004
+ }),
1005
+ approvalBridge: telegramApprovalBridge,
1006
+ })
1007
+ telegramSlot.current = handle.dispatch
1008
+ // Drain queued TG messages whenever the brain returns to idle (closes G4
1009
+ // starvation: a stdin turn ending while a TG message was queued used to
1010
+ // leave it stuck until the next inbound).
1011
+ state.onStatusChange(next => {
1012
+ if (next === 'idle' && handle.getQueueSize() > 0) handle.drainQueue()
1013
+ })
1014
+ }
1015
+
1016
+ // Initial balances for the status bar (best-effort, never blocks boot).
1017
+ refreshBalances()
1018
+
1019
+ // Redirect noisy SDK chatter (IPFS storage progress, ethers RPC errors) to a
1020
+ // log file so it doesn't fall through opentui's alt-screen and pollute the
1021
+ // chat UI. Keep process.stdout intact - opentui itself needs to write there.
1022
+ const { createWriteStream } = await import('node:fs')
1023
+ const chatLog = createWriteStream(`${paths.dir}/chat.log`, { flags: 'a' })
1024
+ const stringifyArg = (a: unknown): string => {
1025
+ if (typeof a === 'string') return a
1026
+ if (a instanceof Error) return a.stack ?? a.message
1027
+ try {
1028
+ return JSON.stringify(a, (_k, v) => (typeof v === 'bigint' ? `${v}n` : v))
1029
+ } catch {
1030
+ return String(a)
1031
+ }
1032
+ }
1033
+ const logTo =
1034
+ (level: string) =>
1035
+ (...args: unknown[]) => {
1036
+ const line = args.map(stringifyArg).join(' ')
1037
+ chatLog.write(`[${new Date().toISOString()}] [${level}] ${line}\n`)
1038
+ }
1039
+ console.log = logTo('log') as typeof console.log
1040
+ console.warn = logTo('warn') as typeof console.warn
1041
+ console.error = logTo('error') as typeof console.error
1042
+ console.info = logTo('info') as typeof console.info
1043
+ console.debug = logTo('debug') as typeof console.debug
1044
+ process.on('unhandledRejection', err => {
1045
+ chatLog.write(`[unhandled] ${(err as Error)?.stack ?? String(err)}\n`)
1046
+ })
1047
+
1048
+ const renderer = await createCliRenderer({
1049
+ exitOnCtrlC: false,
1050
+ consoleMode: 'disabled',
1051
+ openConsoleOnError: false,
1052
+ })
1053
+
1054
+ // ─── Inbound A2A queue + drain ────────────────────────────────────────────
1055
+ // Inbound messages arrive via plugin-comms's listener. We can't fire brain
1056
+ // turns concurrently with operator-typed prompts (single-flight gate), so
1057
+ // queue them and drain whenever status flips back to idle.
1058
+ // ─── Market job-event drain (Phase 8) ─────────────────────────────────────
1059
+ // Mirrors drainInbound but for PromusMarket events. Same single-flight gate.
1060
+ let drainingMarket = false
1061
+ const drainMarketEvents = async () => {
1062
+ if (drainingMarket) return
1063
+ if (marketBrainQueue.length === 0) return
1064
+ if (state.status() === 'thinking') return
1065
+ drainingMarket = true
1066
+ try {
1067
+ while (marketBrainQueue.length > 0) {
1068
+ const e = marketBrainQueue.shift()!
1069
+ const channelText = formatJobEventForBrain(e)
1070
+ state.setStatus('thinking')
1071
+ const abortCtrl = new AbortController()
1072
+ state.setActiveAbort(abortCtrl)
1073
+ try {
1074
+ const refreshed = await buildPrefix()
1075
+ brain.refreshUserContext(refreshed)
1076
+ await activity.append({
1077
+ ts: Date.now(),
1078
+ kind: 'wake',
1079
+ data: { source: 'market', kind: e.kind, jobId: e.jobId.toString(), txHash: e.txHash },
1080
+ })
1081
+ const turn = await brain.infer({
1082
+ event: {
1083
+ id: newEventId(),
1084
+ source: 'marketplace',
1085
+ payload: { label: `market:${e.kind}`, data: channelText },
1086
+ ts: Date.now(),
1087
+ },
1088
+ channelKey: 'marketplace',
1089
+ signal: abortCtrl.signal,
1090
+ })
1091
+ await activity.append({
1092
+ ts: Date.now(),
1093
+ kind: 'brain-response',
1094
+ data: {
1095
+ content: turn.content,
1096
+ toolCalls: turn.toolCalls.length,
1097
+ finishReason: turn.finishReason,
1098
+ usage: turn.usage,
1099
+ },
1100
+ })
1101
+ state.pushRow({ role: 'assistant', text: turn.content ?? '(no content)' })
1102
+ state.setStatus('idle')
1103
+ refreshBalances()
1104
+ sync
1105
+ .flushTurn()
1106
+ .then(res => {
1107
+ if (res.txHash && res.changedSlots.length > 0) {
1108
+ state.pushRow({
1109
+ role: 'system',
1110
+ text: `synced ${res.changedSlots.join(', ')} → ${explorerTxUrl(config.network, res.txHash)}`,
1111
+ })
1112
+ }
1113
+ })
1114
+ .catch(() => {})
1115
+ } catch (err) {
1116
+ if ((err instanceof Error && err.name === 'AbortError') || abortCtrl.signal.aborted) {
1117
+ state.pushRow({ role: 'system', text: 'market turn interrupted (esc).' })
1118
+ state.setStatus('idle')
1119
+ } else {
1120
+ state.pushRow({
1121
+ role: 'system',
1122
+ text: `market turn error: ${(err as Error).message.slice(0, 200)}`,
1123
+ })
1124
+ state.setStatus('idle')
1125
+ }
1126
+ } finally {
1127
+ state.setActiveAbort(null)
1128
+ }
1129
+ }
1130
+ } finally {
1131
+ drainingMarket = false
1132
+ }
1133
+ }
1134
+
1135
+ let drainingInbound = false
1136
+ const drainInbound = async () => {
1137
+ if (drainingInbound) return
1138
+ if (inboundQueue.length === 0) return
1139
+ if (state.status() === 'thinking') return
1140
+ drainingInbound = true
1141
+ try {
1142
+ while (inboundQueue.length > 0) {
1143
+ const m = inboundQueue.shift()!
1144
+ const channelText = formatA2AChannel(m)
1145
+ // Inbox row is rendered at delivery time in `onInboundDeliver`; the
1146
+ // listener can fire mid-turn, so display ≠ brain wake-up. Here we just
1147
+ // wake the brain on the message that's been queued.
1148
+ state.setStatus('thinking')
1149
+ const abortCtrl = new AbortController()
1150
+ state.setActiveAbort(abortCtrl)
1151
+ try {
1152
+ const refreshed = await buildPrefix()
1153
+ brain.refreshUserContext(refreshed)
1154
+ await activity.append({
1155
+ ts: Date.now(),
1156
+ kind: 'wake',
1157
+ data: { source: 'a2a', from: m.from, txHash: m.txHash },
1158
+ })
1159
+ const turn = await brain.infer({
1160
+ event: {
1161
+ id: newEventId(),
1162
+ source: 'a2a',
1163
+ payload: { label: 'inbound-message', data: channelText, peer: m.from },
1164
+ ts: Date.now(),
1165
+ },
1166
+ channelKey: `a2a:${m.from}`,
1167
+ signal: abortCtrl.signal,
1168
+ })
1169
+ await activity.append({
1170
+ ts: Date.now(),
1171
+ kind: 'brain-response',
1172
+ data: {
1173
+ content: turn.content,
1174
+ toolCalls: turn.toolCalls.length,
1175
+ finishReason: turn.finishReason,
1176
+ usage: turn.usage,
1177
+ },
1178
+ })
1179
+ state.pushRow({ role: 'assistant', text: turn.content ?? '(no content)' })
1180
+ state.setStatus('idle')
1181
+ refreshBalances()
1182
+ sync
1183
+ .flushTurn()
1184
+ .then(res => {
1185
+ if (res.txHash && res.changedSlots.length > 0) {
1186
+ state.pushRow({
1187
+ role: 'system',
1188
+ text: `synced ${res.changedSlots.join(', ')} → ${explorerTxUrl(config.network, res.txHash)}`,
1189
+ })
1190
+ }
1191
+ })
1192
+ .catch(() => {})
1193
+ } catch (e) {
1194
+ if ((e instanceof Error && e.name === 'AbortError') || abortCtrl.signal.aborted) {
1195
+ state.pushRow({
1196
+ role: 'system',
1197
+ text: 'inbound a2a turn interrupted (esc).',
1198
+ })
1199
+ await activity.append({
1200
+ ts: Date.now(),
1201
+ kind: 'brain-response',
1202
+ data: { content: '(aborted by operator)', toolCalls: 0, finishReason: 'aborted' },
1203
+ })
1204
+ state.setStatus('idle')
1205
+ } else {
1206
+ state.pushRow({
1207
+ role: 'system',
1208
+ text: `inbound error: ${(e as Error).message.slice(0, 200)}`,
1209
+ })
1210
+ state.setStatus('idle')
1211
+ }
1212
+ } finally {
1213
+ state.setActiveAbort(null)
1214
+ }
1215
+ }
1216
+ } finally {
1217
+ drainingInbound = false
1218
+ }
1219
+ }
1220
+ // Wire forward-declared callbacks now that state + brain exist. Bound queue
1221
+ // (drops oldest with a system-row notice) prevents memory growth if a brain
1222
+ // turn wedges and inbound traffic spikes.
1223
+ const INBOUND_QUEUE_CAP = 100
1224
+ onInboundDeliver = m => {
1225
+ inboundQueue.push(m)
1226
+ // Render the inbox row at delivery time, regardless of brain state.
1227
+ // Display is independent of the single-flight brain wake-up below: a
1228
+ // listener event during a long thinking turn must still appear in the
1229
+ // operator's transcript, even if the brain wakeup waits its turn.
1230
+ state.pushRow({ role: 'inbox', text: formatInboxPreview(m) })
1231
+ if (inboundQueue.length > INBOUND_QUEUE_CAP) {
1232
+ const dropped = inboundQueue.shift()!
1233
+ state.pushRow({
1234
+ role: 'system',
1235
+ text: `inbound queue full (${INBOUND_QUEUE_CAP}); dropped oldest from ${shortAddr(dropped.from)}`,
1236
+ })
1237
+ }
1238
+ void drainInbound()
1239
+ }
1240
+ onInboundNotice = notice => {
1241
+ const msg = describeOperatorNotice(notice)
1242
+ if (msg) state.pushRow({ role: 'system', text: msg })
1243
+ }
1244
+ // Phase 8: every market event for a job we're a party to renders a system
1245
+ // row. Wake fires for every event we can react to except when we're the
1246
+ // identifiable actor (already saw the tool response). String's pattern at
1247
+ // `string/plugin/src/server.ts:887-958` is the reference.
1248
+ const marketBrainQueue: JobEvent[] = []
1249
+ const knownJobs = new Map<string, { buyer: Address; provider: Address }>()
1250
+ const handleJobEvent = (e: JobEvent) => {
1251
+ if (e.kind === 'created') {
1252
+ knownJobs.set(e.jobId.toString(), { buyer: e.buyer, provider: e.provider })
1253
+ }
1254
+ const job = knownJobs.get(e.jobId.toString()) ?? null
1255
+ if (!isParticipant(agentAddress, e, job)) return
1256
+ state.bumpActiveJobs(e)
1257
+ state.pushRow({ role: 'market', text: formatJobEvent(e) })
1258
+ if (jobEventShouldWakeBrain(e, agentAddress, job)) {
1259
+ marketBrainQueue.push(e)
1260
+ void drainMarketEvents()
1261
+ }
1262
+ }
1263
+ onMarketJobEvent = handleJobEvent
1264
+ // Drain queued job events (catch-up may have fired them before UI mounted).
1265
+ while (jobEventQueue.length > 0) {
1266
+ handleJobEvent(jobEventQueue.shift()!)
1267
+ }
1268
+ // Listener catch-up + WS subscribe runs in the background. `start` only
1269
+ // resolves after catch-up finishes, which can be slow on long-restored
1270
+ // agents; awaiting it would block the chat from accepting input.
1271
+ for (const l of collectedListeners) {
1272
+ l.start(undefined as never).catch(e => {
1273
+ state.pushRow({
1274
+ role: 'system',
1275
+ text: `listener ${l.name} failed to start: ${(e as Error).message.slice(0, 160)}`,
1276
+ })
1277
+ })
1278
+ }
1279
+ // Drain anything queued during boot.
1280
+ void drainInbound()
1281
+
1282
+ // Phase 7 auto-publish: idempotent backfill of `<subname>.promus.0g pubkey`
1283
+ // text record. Fire-and-forget; failures don't block chat. Skipped without
1284
+ // comms (no SannClient) or without a configured subname.
1285
+ if (config.subname && sann) {
1286
+ const sannPub = sann
1287
+ ensureOwnPubkeyPublished({
1288
+ privkeyHex: agentPrivkey,
1289
+ subname: `${config.subname}.promus.0g`,
1290
+ sann: sannPub,
1291
+ })
1292
+ .then(res => {
1293
+ if (res.txHash) {
1294
+ state.pushRow({
1295
+ role: 'system',
1296
+ text: `pubkey published on ${config.subname}.promus.0g → ${explorerTxUrl(config.network, res.txHash)}`,
1297
+ })
1298
+ }
1299
+ })
1300
+ .catch(() => {})
1301
+ }
1302
+
1303
+ const handleSubmit = async (text: string): Promise<void> => {
1304
+ const trimmed = text.trim()
1305
+ if (trimmed.startsWith('/')) {
1306
+ const handled = await handleSlash(trimmed)
1307
+ if (handled) {
1308
+ // Slash commands skip brain.infer; reset thinking → idle so the
1309
+ // spinner row stops. (The keyboard handler in app.tsx flips
1310
+ // status='thinking' on every Enter, regardless of payload.)
1311
+ state.setStatus('idle')
1312
+ return
1313
+ }
1314
+ }
1315
+ // Per-turn AbortController. Esc in the TUI calls .abort() on this.
1316
+ // Stored on state so the keyboard handler can reach it from app.tsx.
1317
+ const abortCtrl = new AbortController()
1318
+ state.setActiveAbort(abortCtrl)
1319
+ try {
1320
+ // Refresh per-turn user-context (MEMORY.md may have grown last turn).
1321
+ // The system prefix stays cached; only the user-msg context updates.
1322
+ const refreshed = await buildPrefix()
1323
+ brain.refreshUserContext(refreshed)
1324
+ await activity.append({
1325
+ ts: Date.now(),
1326
+ kind: 'wake',
1327
+ data: { source: 'stdin', text },
1328
+ })
1329
+ const turn = await brain.infer({
1330
+ event: {
1331
+ id: newEventId(),
1332
+ source: 'stdin',
1333
+ payload: { label: 'user-message', data: text },
1334
+ ts: Date.now(),
1335
+ },
1336
+ channelKey: 'tui:stdin',
1337
+ signal: abortCtrl.signal,
1338
+ onCompactionEvent: ev => {
1339
+ state.pushRow({
1340
+ role: 'system',
1341
+ text: `✂︎ context compacted (${ev.from} → ${ev.to} messages, ~${Math.round(ev.promptTokens / 1000)}K tokens)`,
1342
+ })
1343
+ },
1344
+ })
1345
+ await activity.append({
1346
+ ts: Date.now(),
1347
+ kind: 'brain-response',
1348
+ data: {
1349
+ content: turn.content,
1350
+ toolCalls: turn.toolCalls.length,
1351
+ finishReason: turn.finishReason,
1352
+ usage: turn.usage,
1353
+ },
1354
+ })
1355
+ state.pushRow({ role: 'assistant', text: turn.content ?? '(no content)' })
1356
+ state.setStatus('idle')
1357
+ // Compute ledger drains via inference; agent EOA via tool chain writes.
1358
+ refreshBalances()
1359
+ if (turn.usage) {
1360
+ state.setUsage({
1361
+ total: turn.usage.totalTokens,
1362
+ cached: turn.usage.cachedTokens,
1363
+ })
1364
+ }
1365
+ // Per-turn auto-sync: upload changed memory + activity-log to IPFS,
1366
+ // anchor in iNFT. Fire-and-forget; chat doesn't wait. Errors surface
1367
+ // as a system row every turn — repetition is the signal that a real
1368
+ // upstream issue persists, not noise to suppress.
1369
+ sync
1370
+ .flushTurn()
1371
+ .then(res => {
1372
+ if (res.txHash && res.changedSlots.length > 0) {
1373
+ state.pushRow({
1374
+ role: 'system',
1375
+ text: `synced ${res.changedSlots.join(', ')} → ${explorerTxUrl(config.network, res.txHash)}`,
1376
+ })
1377
+ }
1378
+ })
1379
+ .catch(e => {
1380
+ state.pushRow({
1381
+ role: 'system',
1382
+ text: `sync error: ${summarizeError(e)}`,
1383
+ })
1384
+ })
1385
+ } catch (e) {
1386
+ // AbortError = operator pressed Esc; render as a clean sys row, NOT an
1387
+ // error. The activity log gets a paired entry so the post-mortem reflects
1388
+ // operator intent, not a real fault.
1389
+ if ((e instanceof Error && e.name === 'AbortError') || abortCtrl.signal.aborted) {
1390
+ state.pushRow({
1391
+ role: 'system',
1392
+ text: 'turn interrupted (esc). brain stopped at the last completed step.',
1393
+ })
1394
+ await activity.append({
1395
+ ts: Date.now(),
1396
+ kind: 'brain-response',
1397
+ data: { content: '(aborted by operator)', toolCalls: 0, finishReason: 'aborted' },
1398
+ })
1399
+ state.setStatus('idle')
1400
+ return
1401
+ }
1402
+ // Mirror real errors to chat.log too — render-layer bugs can swallow the
1403
+ // sys row before it hits the screen, and chat.log is the only artifact
1404
+ // the operator can read post-mortem.
1405
+ const errMsg = e instanceof Error ? e.message : String(e ?? 'unknown error')
1406
+ const dumped = e instanceof Error ? (e.stack ?? e.message) : errMsg
1407
+ console.error('[handleSubmit] error:', dumped)
1408
+ state.pushRow({ role: 'system', text: `error: ${errMsg.slice(0, 300)}` })
1409
+ state.setStatus('error')
1410
+ } finally {
1411
+ state.setActiveAbort(null)
1412
+ // Inbound A2A events that arrived during this turn waited in the queue.
1413
+ // Drain once status flips back to idle.
1414
+ void drainInbound()
1415
+ }
1416
+ }
1417
+
1418
+ const handleSlash = async (cmd: string): Promise<boolean> => {
1419
+ if (cmd === '/exit' || cmd === '/quit') {
1420
+ state.pushRow({ role: 'system', text: 'goodbye.' })
1421
+ handleExit()
1422
+ return true
1423
+ }
1424
+ if (cmd === '/model') {
1425
+ state.pushRow({
1426
+ role: 'system',
1427
+ text: 'Switching brain. (Quit chat first; run `promus model` to pick a new brain, then re-launch `promus`.)',
1428
+ })
1429
+ return true
1430
+ }
1431
+ if (cmd === '/sync') {
1432
+ state.pushRow({ role: 'system', text: 'force-syncing memory + activity to IPFS…' })
1433
+ try {
1434
+ const res = await sync.flushAll()
1435
+ if (res.txHash) {
1436
+ state.pushRow({
1437
+ role: 'system',
1438
+ text: `synced ${res.changedSlots.join(', ')} → ${explorerTxUrl(config.network, res.txHash)}`,
1439
+ })
1440
+ refreshEoaBalance()
1441
+ } else {
1442
+ state.pushRow({ role: 'system', text: 'nothing to sync (everything up to date)' })
1443
+ }
1444
+ } catch (e) {
1445
+ state.pushRow({ role: 'system', text: `sync error: ${summarizeError(e)}` })
1446
+ }
1447
+ return true
1448
+ }
1449
+ if (cmd === '/yolo') {
1450
+ const result = applyYolo(permission)
1451
+ state.setApprovalsMode(result.mode)
1452
+ state.pushRow({ role: 'system', text: result.message })
1453
+ return true
1454
+ }
1455
+ if (cmd === '/perms' || cmd.startsWith('/perms ')) {
1456
+ const arg = cmd.split(/\s+/)[1]
1457
+ const result = applyPerms(permission, arg)
1458
+ state.setApprovalsMode(result.mode)
1459
+ state.pushRow({ role: 'system', text: result.message })
1460
+ return true
1461
+ }
1462
+ if (cmd === '/reset') {
1463
+ try {
1464
+ await brain.clearChannel('tui:stdin')
1465
+ state.pushRow({ role: 'system', text: 'conversation reset (TUI channel cleared)' })
1466
+ } catch (e) {
1467
+ state.pushRow({ role: 'system', text: `reset error: ${summarizeError(e)}` })
1468
+ }
1469
+ return true
1470
+ }
1471
+ if (cmd === '/jobs') {
1472
+ const tool = tools.find('market.listMyJobs')
1473
+ if (!tool) {
1474
+ state.pushRow({
1475
+ role: 'system',
1476
+ text: 'market plugin not loaded; cannot list jobs.',
1477
+ })
1478
+ return true
1479
+ }
1480
+ state.pushRow({ role: 'system', text: 'fetching active jobs…' })
1481
+ try {
1482
+ const res = await tool.handler({ status: 'active', limit: 20 } as never)
1483
+ const data = (res as { ok: boolean; data?: { jobs: unknown[] } }).data
1484
+ const jobs = (data?.jobs ?? []) as Array<{
1485
+ jobId: string
1486
+ role: string
1487
+ counterparty: string | null
1488
+ amount0g: string
1489
+ status: string
1490
+ }>
1491
+ if (jobs.length === 0) {
1492
+ state.pushRow({ role: 'system', text: 'no active escrow jobs.' })
1493
+ } else {
1494
+ const lines = jobs.map(
1495
+ j =>
1496
+ ` job#${j.jobId} · ${j.role}${j.counterparty ? ` w/ ${shortAddr(j.counterparty)}` : ''} · ${j.amount0g} ETH · ${j.status}`,
1497
+ )
1498
+ state.pushRow({
1499
+ role: 'system',
1500
+ text: `active jobs (${jobs.length}):\n${lines.join('\n')}`,
1501
+ })
1502
+ }
1503
+ } catch (e) {
1504
+ state.pushRow({ role: 'system', text: `jobs error: ${summarizeError(e)}` })
1505
+ }
1506
+ return true
1507
+ }
1508
+ if (cmd === '/sessions') {
1509
+ try {
1510
+ const { readdirSync, readFileSync } = require('node:fs')
1511
+ if (!existsSync(sessionDir)) {
1512
+ state.pushRow({ role: 'system', text: 'no sessions found.' })
1513
+ } else {
1514
+ const files = readdirSync(sessionDir)
1515
+ .filter((f: string) => f.endsWith('.json'))
1516
+ .sort()
1517
+ .reverse()
1518
+ .slice(0, 10)
1519
+ if (files.length === 0) {
1520
+ state.pushRow({ role: 'system', text: 'no sessions found.' })
1521
+ } else {
1522
+ const sessions = files.map((f: string) => {
1523
+ try {
1524
+ const data = JSON.parse(readFileSync(join(sessionDir, f), 'utf8'))
1525
+ return ` ${data.sessionId} ${data.brainProvider ?? '?'} ${data.startedAt ?? '?'}`
1526
+ } catch {
1527
+ return ` ${f.replace('.json', '')} (corrupt)`
1528
+ }
1529
+ })
1530
+ state.pushRow({
1531
+ role: 'system',
1532
+ text: `recent sessions:\n${sessions.join('\n')}\n\nresume: promus chat --resume <session-id>`,
1533
+ })
1534
+ }
1535
+ }
1536
+ } catch (e) {
1537
+ state.pushRow({ role: 'system', text: `sessions error: ${(e as Error).message}` })
1538
+ }
1539
+ return true
1540
+ }
1541
+ if (cmd === '/help') {
1542
+ const builtins =
1543
+ " /sync force memory + activity flush to IPFS\n /jobs list active escrow jobs\n /model switch brain (run promus 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 /sessions list recent sessions (for resume)\n /exit quit promus (drains IPFS storage flush, releases process)\n /help this message"
1544
+ const claudeBlock =
1545
+ commandIndex.size === 0
1546
+ ? ''
1547
+ : `\n\nClaude Code commands (auto-loaded):\n${[
1548
+ ...new Set([...commandIndex.values()].map(c => c.name)),
1549
+ ]
1550
+ .sort()
1551
+ .map(name => {
1552
+ const c = commandIndex.get(name)!
1553
+ return ` /${c.name} ${c.description.slice(0, 80)}`
1554
+ })
1555
+ .join('\n')}`
1556
+ state.pushRow({
1557
+ role: 'system',
1558
+ text: `slash commands:\n${builtins}${claudeBlock}`,
1559
+ })
1560
+ return true
1561
+ }
1562
+ // Claude Code command match. Strip leading `/`, take first whitespace
1563
+ // segment as the command name, treat the rest as the user-supplied args.
1564
+ if (cmd.startsWith('/')) {
1565
+ const rest = cmd.slice(1).trim()
1566
+ if (!rest) return false
1567
+ const space = rest.indexOf(' ')
1568
+ const name = space === -1 ? rest : rest.slice(0, space)
1569
+ const args = space === -1 ? '' : rest.slice(space + 1).trim()
1570
+ const command = commandIndex.get(name)
1571
+ if (!command) return false
1572
+ const trimmedBody = command.body.trim()
1573
+ const inlined = args
1574
+ ? `# Command: /${command.name}${command.argumentHint ? ` (${command.argumentHint})` : ''}\n# User args: ${args}\n\n${trimmedBody}`
1575
+ : `# Command: /${command.name}\n\n${trimmedBody}`
1576
+ state.pushRow({
1577
+ role: 'system',
1578
+ text: `↳ command: /${command.name} (${command.id}, ${command.body.length} bytes inlined as user message)`,
1579
+ })
1580
+ // Send the command body as a user message so the brain executes it.
1581
+ try {
1582
+ const refreshed = await buildPrefix()
1583
+ brain.refreshUserContext(refreshed)
1584
+ const turn = await brain.infer({
1585
+ event: {
1586
+ id: newEventId(),
1587
+ source: 'stdin',
1588
+ payload: { label: 'user-message', data: inlined },
1589
+ ts: Date.now(),
1590
+ },
1591
+ channelKey: 'tui:stdin',
1592
+ })
1593
+ state.pushRow({ role: 'assistant', text: turn.content ?? '(no content)' })
1594
+ state.setStatus('idle')
1595
+ } catch (e) {
1596
+ state.pushRow({
1597
+ role: 'system',
1598
+ text: `command error: ${(e as Error).message.slice(0, 200)}`,
1599
+ })
1600
+ }
1601
+ return true
1602
+ }
1603
+ return false
1604
+ }
1605
+
1606
+ // @opentui/solid's render() resolves once the component mounts; it does not
1607
+ // block. On macOS the renderer's animation loop runs in a worker thread, so
1608
+ // the main thread has no JS task keeping the event loop alive after render
1609
+ // returns. Anchor: a never-resolving promise after render(); handleExit is
1610
+ // the only escape via process.exit.
1611
+ const handleExit = (): void => {
1612
+ // Save session metadata for resume support
1613
+ try {
1614
+ const { mkdirSync, writeFileSync } = require('node:fs')
1615
+ mkdirSync(sessionDir, { recursive: true })
1616
+ writeFileSync(sessionFile, JSON.stringify({
1617
+ sessionId,
1618
+ agentId,
1619
+ agentAddress,
1620
+ network: config.network,
1621
+ brainProvider: config.brain?.provider,
1622
+ brainModel: config.brain?.model,
1623
+ startedAt: new Date().toISOString(),
1624
+ }, null, 2))
1625
+ } catch {}
1626
+ try {
1627
+ renderer.destroy()
1628
+ } catch {}
1629
+ try {
1630
+ mcpManager?.closeAll()
1631
+ } catch {}
1632
+ // Print session info AFTER renderer destroy so it appears on the normal terminal
1633
+ process.stderr.write(`\n session: ${sessionId} (resume with: promus chat --resume ${sessionId})\n\n`)
1634
+ // Best-effort: kill any background processes registered via shell.process.
1635
+ try {
1636
+ const { killAllProcesses } = require('@promus/plugin-system') as {
1637
+ killAllProcesses: () => void
1638
+ }
1639
+ killAllProcesses()
1640
+ } catch {}
1641
+ // Best-effort drain: if a flush is mid-flight, await it. Caps at 30s so
1642
+ // we never hang the CLI on a wedged RPC.
1643
+ Promise.race([sync.flushTurn(), new Promise(r => setTimeout(r, 30_000))]).finally(() =>
1644
+ process.exit(0),
1645
+ )
1646
+ }
1647
+ // Catch SIGINT (Ctrl+C) so session info is saved and displayed
1648
+ process.once('SIGINT', () => {
1649
+ handleExit()
1650
+ })
1651
+
1652
+ // Map Claude Code commands into SlashCommand shape so the slash
1653
+ // autocomplete popup lists them alongside the bundled registry.
1654
+ const extraSlashCommands = [...new Set([...commandIndex.values()].map(c => c.name))].map(name => {
1655
+ const c = commandIndex.get(name)!
1656
+ return {
1657
+ name: c.name.toLowerCase(),
1658
+ description: c.description ?? `Claude Code command (${c.id})`,
1659
+ surfaces: ['tui'] as ('tui' | 'tg')[],
1660
+ scope: 'local' as const,
1661
+ bypassesBrain: false,
1662
+ argHint: c.argumentHint,
1663
+ }
1664
+ })
1665
+
1666
+ await render(
1667
+ () => (
1668
+ <ChatApp
1669
+ state={state}
1670
+ onSubmit={handleSubmit}
1671
+ onExit={handleExit}
1672
+ extraSlashCommands={extraSlashCommands}
1673
+ />
1674
+ ),
1675
+ renderer,
1676
+ )
1677
+
1678
+ await new Promise<void>(() => {
1679
+ // Block forever; only handleExit (via process.exit) escapes this.
1680
+ })
1681
+ }
1682
+
1683
+ async function runModelPicker(
1684
+ config: PromusConfig,
1685
+ agentPrivkey: Hex,
1686
+ configPath: string,
1687
+ ): Promise<PromusConfig | null> {
1688
+ const s = spinner()
1689
+ s.start('Fetching live brain catalog')
1690
+ let services: Awaited<ReturnType<typeof OGComputeBrain.listServicesFor>> = []
1691
+ try {
1692
+ services = await OGComputeBrain.listServicesFor({
1693
+ privkeyHex: agentPrivkey,
1694
+ rpcUrl: NETWORK_RPC[config.network],
1695
+ })
1696
+ s.stop(`Fetched ${services.length} services`)
1697
+ } catch (e) {
1698
+ s.stop(`Catalog fetch failed: ${(e as Error).message.slice(0, 120)}`)
1699
+ return null
1700
+ }
1701
+ if (services.length === 0) return null
1702
+
1703
+ const picked = await select({
1704
+ message: 'Pick a brain (model)',
1705
+ options: services.map(svc => ({
1706
+ value: svc.provider,
1707
+ label: `${svc.model ?? '?'} ${svc.serviceType ? `[${svc.serviceType}]` : ''} ${shortAddr(svc.provider)}`,
1708
+ hint: svc.inputPrice
1709
+ ? `in ${formatEther(BigInt(svc.inputPrice))}/tok · out ${formatEther(BigInt(svc.outputPrice ?? 0n))}/tok`
1710
+ : undefined,
1711
+ })),
1712
+ })
1713
+ if (isCancel(picked) || typeof picked !== 'string') return null
1714
+
1715
+ const model = services.find(s => s.provider === picked)?.model ?? null
1716
+ const updated: PromusConfig = {
1717
+ ...config,
1718
+ brain: { provider: picked, model },
1719
+ }
1720
+ await writeConfigTs(configPath, updated)
1721
+ return updated
1722
+ }
1723
+
1724
+ /**
1725
+ * Squash a ToolResult down to a single-line summary for the chat row. The TUI
1726
+ * adds the `⎿` indent + color from the role, so this returns just the content:
1727
+ * - failed → the error message (truncated)
1728
+ * - ok+path → the file path the tool acted on
1729
+ * - ok+data → "ok"
1730
+ * - done → "done" (legacy: pre-ok results)
1731
+ */
1732
+ function summarizeToolResult(result: unknown): string {
1733
+ const r = result as { ok?: boolean; error?: string; data?: { path?: string } } | null | undefined
1734
+ if (!r || r.ok === undefined) return 'done'
1735
+ if (r.ok === false) return (r.error ?? 'failed').slice(0, 200)
1736
+ const path = typeof r.data?.path === 'string' ? r.data.path : null
1737
+ return path ? path : 'ok'
1738
+ }
1739
+
1740
+ /**
1741
+ * Squash an Error into a single-line, length-capped string for the TUI.
1742
+ * ethers / viem multi-line stack traces blow up the chat UX otherwise.
1743
+ * Strategy: collapse whitespace, drop everything after the first ` (action=`
1744
+ * marker (where ethers appends transaction blobs), cap at 90 chars so the
1745
+ * row stays on one terminal line in any reasonably-sized pane.
1746
+ */
1747
+ function summarizeError(e: unknown): string {
1748
+ const raw = e instanceof Error ? e.message : String(e)
1749
+ let s = raw.replace(/\s+/g, ' ').trim()
1750
+ const annotIdx = s.indexOf(' (action=')
1751
+ if (annotIdx >= 0) s = s.slice(0, annotIdx)
1752
+ return s.length > 90 ? `${s.slice(0, 87)}...` : s
1753
+ }
1754
+
1755
+ type PermArgs = Record<string, unknown>
1756
+ const _str = (v: unknown): string => (typeof v === 'string' ? v : '')
1757
+ const _strOpt = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
1758
+
1759
+ const PERMISSION_DESCRIBERS: Record<string, (a: PermArgs) => PermissionRequest | null> = {
1760
+ 'shell.run': a => ({
1761
+ kind: 'shell.run',
1762
+ command: _str(a.command),
1763
+ reason: 'shell command execution',
1764
+ }),
1765
+ 'code.execute': a => ({
1766
+ kind: 'code.execute',
1767
+ command: `[${_str(a.language) || '?'}] ${_str(a.code)}`,
1768
+ reason: 'arbitrary code execution',
1769
+ }),
1770
+ 'shell.process_start': a => ({
1771
+ kind: 'shell.process',
1772
+ command: _str(a.command),
1773
+ reason: 'background process start',
1774
+ }),
1775
+ 'shell.process_output': () => null,
1776
+ 'shell.process_list': () => null,
1777
+ 'shell.process_kill': () => null,
1778
+ 'fs.write': a => ({ kind: 'fs.write', path: _str(a.path), reason: 'fs.write request' }),
1779
+ 'fs.patch': a => ({ kind: 'fs.patch', path: _str(a.path), reason: 'fs.patch request' }),
1780
+ // Phase 10: value-moving on-chain tools. Pre-fill amount/recipient/token
1781
+ // so the modal renders "send 0.05 ETH to 0xC635..." not a raw command.
1782
+ 'chain.send': a => ({
1783
+ kind: 'chain.send',
1784
+ amount: _strOpt(a.amount) ?? '?',
1785
+ recipient: _strOpt(a.to) ?? '?',
1786
+ token: _strOpt(a.token) ?? 'ETH',
1787
+ reason: 'native/ERC-20 transfer',
1788
+ }),
1789
+ 'swap.execute': a => ({
1790
+ kind: 'chain.swap',
1791
+ amount: _strOpt(a.amountIn) ?? '?',
1792
+ token: `${_strOpt(a.tokenIn) ?? '?'}→${_strOpt(a.tokenOut) ?? '?'}`,
1793
+ reason: 'JAINE swap execution',
1794
+ }),
1795
+ 'chain.wrap': a => ({
1796
+ kind: 'chain.send',
1797
+ amount: _strOpt(a.amount) ?? '?',
1798
+ token: 'ETH→WETH',
1799
+ reason: 'wrap native to WETH',
1800
+ }),
1801
+ 'chain.unwrap': a => ({
1802
+ kind: 'chain.send',
1803
+ amount: _strOpt(a.amount) ?? '?',
1804
+ token: 'WETH→ETH',
1805
+ reason: 'unwrap WETH to native',
1806
+ }),
1807
+ 'stake.stake': a => ({
1808
+ kind: 'chain.stake',
1809
+ amount: _strOpt(a.amount) ?? '',
1810
+ token: 'ETH→stOG',
1811
+ reason: 'Gimo stake',
1812
+ }),
1813
+ 'stake.unstake': a => ({
1814
+ kind: 'chain.stake',
1815
+ amount: _strOpt(a.amountStog) ?? '',
1816
+ token: 'stOG→ETH (queued)',
1817
+ reason: 'Gimo unstake',
1818
+ }),
1819
+ 'stake.claim': () => ({
1820
+ kind: 'chain.stake',
1821
+ token: 'claim queued ETH',
1822
+ reason: 'Gimo claim',
1823
+ }),
1824
+ 'chain.write': a => ({
1825
+ kind: 'chain.write',
1826
+ recipient: _strOpt(a.to) ?? '?',
1827
+ command: _strOpt(a.signature) ?? '?',
1828
+ amount: _strOpt(a.value) ? `${_strOpt(a.value)} wei` : undefined,
1829
+ reason: 'arbitrary state-changing call',
1830
+ }),
1831
+ }
1832
+
1833
+ function describePermissionCheck(call: { name: string; args: unknown }): PermissionRequest | null {
1834
+ const fn = PERMISSION_DESCRIBERS[call.name]
1835
+ return fn ? fn((call.args ?? {}) as PermArgs) : null
1836
+ }
1837
+
1838
+ function summarizeArgs(args: unknown): string {
1839
+ if (typeof args !== 'object' || args === null) return String(args ?? '').slice(0, 60)
1840
+ const entries = Object.entries(args as Record<string, unknown>)
1841
+ return entries
1842
+ .map(([k, v]) => {
1843
+ const s = typeof v === 'string' ? v : JSON.stringify(v)
1844
+ return `${k}=${s.length > 40 ? `${s.slice(0, 40)}…` : s}`
1845
+ })
1846
+ .slice(0, 3)
1847
+ .join(', ')
1848
+ }
1849
+
1850
+ async function readMemoryFileOrNull(path: string): Promise<string | null> {
1851
+ try {
1852
+ const { readFile } = await import('node:fs/promises')
1853
+ return await readFile(path, 'utf8')
1854
+ } catch (e) {
1855
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null
1856
+ throw e
1857
+ }
1858
+ }
1859
+
1860
+ /**
1861
+ * Render an inbound A2A delivery as a `<channel>` block the brain treats as
1862
+ * untrusted external input (mirrors how attn/string surface remote agent
1863
+ * messages). Body content varies by envelope type: 'msg' carries the text,
1864
+ * 'file' carries filename + caption + a hint to call agent.fetchFile.
1865
+ */
1866
+ /**
1867
+ * Single-line inbox preview shown to the operator when a new A2A message
1868
+ * arrives. Distinct from formatA2AChannel (which is the brain-facing block).
1869
+ * Format: `from short-addr · "first 80 chars of content"`.
1870
+ */
1871
+ function formatInboxPreview(m: DeliveredMessage): string {
1872
+ const env = m.envelope
1873
+ const body =
1874
+ env.type === 'msg'
1875
+ ? env.content.replace(/\s+/g, ' ').trim()
1876
+ : `[file] ${env.filename} (${env.size} bytes)`
1877
+ const trimmed = body.length > 90 ? `${body.slice(0, 87)}...` : body
1878
+ return `from ${m.fromLabel ?? shortAddr(m.from)} · "${trimmed}"`
1879
+ }
1880
+
1881
+ function formatA2AChannel(m: DeliveredMessage): string {
1882
+ const env = m.envelope
1883
+ // Prefer the .promus.0g name (or contact label) over the raw address so the
1884
+ // brain can use it directly with `agent.message`. Address only as fallback
1885
+ // for unknown senders.
1886
+ const fromDisplay = m.fromLabel ?? m.from
1887
+ const head = `<channel source="promus.inbox" from="${fromDisplay}" address="${m.from}" txHash="${m.txHash}">`
1888
+ const body =
1889
+ env.type === 'msg'
1890
+ ? env.content
1891
+ : `[file] ${env.filename} (${env.mime}, ${env.size} bytes)${
1892
+ env.caption ? `\ncaption: ${env.caption}` : ''
1893
+ }\nfetch via agent.fetchFile data_hash=${m.dataHash}`
1894
+ const inReplyHint = env.inReplyTo ? `\n(reply to ${env.inReplyTo})` : ''
1895
+ return `${head}\n${body}${inReplyHint}\n</channel>`
1896
+ }
1897
+
1898
+ /**
1899
+ * Translate a listener OperatorNotice into a one-line system row. Used for
1900
+ * pending-contact requests, rate-limit drops, and decrypt failures. Returns
1901
+ * null when the notice should be silently dropped from the UI.
1902
+ */
1903
+ function describeOperatorNotice(n: OperatorNotice): string | null {
1904
+ switch (n.kind) {
1905
+ case 'pending-request':
1906
+ return `inbound a2a from ${shortAddr(n.from)} (not in contacts) — call agent.contact_add to approve, agent.block to refuse.`
1907
+ case 'rate-limit-drop':
1908
+ return `dropped repeated a2a from ${shortAddr(n.from)} (rate limit exceeded for non-contact).`
1909
+ case 'decrypt-failed':
1910
+ return `a2a decrypt failed from ${shortAddr(n.from)}: ${n.reason}`
1911
+ case 'fetch-failed':
1912
+ return `a2a storage fetch failed from ${shortAddr(n.from)}: ${n.reason}`
1913
+ default:
1914
+ return null
1915
+ }
1916
+ }