@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,520 @@
1
+ import { spinner } from '@clack/prompts'
2
+ import {
3
+ type PromusConfig,
4
+ type PromusNetwork,
5
+ NETWORK_RPC,
6
+ type PermissionDecision,
7
+ type PermissionRequest,
8
+ SANDBOX_PROVIDER_URL_GALILEO,
9
+ SandboxProviderClient,
10
+ agentPaths,
11
+ getLedgerDetailReadOnly,
12
+ getSandboxBillingReserve,
13
+ iNFTAgentId,
14
+ } from '@promus/core'
15
+ import type { GatewayEventKind } from '@promus/gateway'
16
+ import { http, type Address, createPublicClient, formatEther } from 'viem'
17
+ import { SandboxClient } from '../sandbox/client'
18
+ import { summarizeApprovalSubject } from '../ui/approval-summary'
19
+ import { loadTelegramHandoffSecrets } from '../util/telegram-secrets'
20
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
21
+ import { resumeArchivedSandbox, unlockAgentKeystore } from './init/sandbox-provision'
22
+
23
+ /**
24
+ * Sandbox-mode chat loop. Runs when `config.deployTarget === 'sandbox'` and
25
+ * `config.sandbox.endpoint` is set. The laptop CLI is a thin client to the
26
+ * harness HTTP server: chat goes via POST /chat (signed), tool indicators +
27
+ * listener events stream via /events SSE, approval modal round-trips via
28
+ * POST /approval/:id/respond.
29
+ *
30
+ * The agent's privkey lives ONLY in the harness container. Operator never
31
+ * decrypts the keystore here — that happened during `promus init` or `promus
32
+ * deploy` when the privkey was ECIES-encrypted to the bootstrap pubkey.
33
+ */
34
+ export interface RunChatSandboxOpts {
35
+ /**
36
+ * When set, the client routes via this unix socket instead of the configured
37
+ * sandbox.endpoint TCP URL. Used for the local-gateway-daemon path
38
+ * (Phase 14): chat.tsx detects `~/.promus/agents/<id>/gateway.sock` and calls
39
+ * runChatSandbox with this opt; the sandbox-specific recovery path
40
+ * (resumeArchivedSandbox) is skipped because there's no Daytona to resume.
41
+ */
42
+ unixSocketPath?: string
43
+ }
44
+
45
+ export async function runChatSandbox(
46
+ config: PromusConfig,
47
+ opts: RunChatSandboxOpts = {},
48
+ ): Promise<void> {
49
+ if (!config.identity.iNFT || !config.identity.agent) {
50
+ console.log('Config has no iNFT or agent. Re-run `promus init`.')
51
+ process.exit(1)
52
+ }
53
+ const isLocalGateway = !!opts.unixSocketPath
54
+ if (!isLocalGateway && (!config.sandbox?.endpoint || !config.sandbox.id)) {
55
+ console.log(
56
+ 'deployTarget is sandbox but sandbox.endpoint or sandbox.id missing. Re-run `promus init`.',
57
+ )
58
+ process.exit(1)
59
+ }
60
+
61
+ const contractAddress = config.identity.iNFT.contract as Address
62
+ const tokenId = BigInt(config.identity.iNFT.tokenId)
63
+ const agentId = iNFTAgentId({ contractAddress, tokenId })
64
+ const agentAddress = config.identity.agent as Address
65
+ const sandboxEndpoint = isLocalGateway ? 'http://localhost' : (config.sandbox?.endpoint as string)
66
+ const sandboxId = isLocalGateway ? `local-${agentId.slice(0, 8)}` : (config.sandbox?.id as string)
67
+
68
+ const operator = await loadOrPickOperatorSigner({
69
+ network: config.network,
70
+ hint: config.operator,
71
+ })
72
+ if (!operator) {
73
+ console.log('No operator wallet available; cannot sign chat messages.')
74
+ process.exit(1)
75
+ }
76
+ const operatorAccount = await operator.account()
77
+
78
+ const client = new SandboxClient({
79
+ endpoint: sandboxEndpoint,
80
+ sandboxId,
81
+ operator: operatorAccount,
82
+ unixSocketPath: opts.unixSocketPath,
83
+ })
84
+
85
+ const sReady = spinner()
86
+ const probeLabel = isLocalGateway ? 'local gateway socket' : `harness ${sandboxEndpoint}`
87
+ sReady.start(`Connecting to ${probeLabel}`)
88
+ // v0.21.13: capture initial perms mode from /healthz so the TUI statusline
89
+ // reflects the gateway's actual PermissionService state (not hardcoded 'off').
90
+ let initialPermsMode: 'off' | 'prompt' | 'strict' = 'off'
91
+ try {
92
+ // Fast probe first; if the harness is healthy we skip every recovery path.
93
+ const health = await client.waitReady({ timeoutMs: 8_000, intervalMs: 1000 })
94
+ if (health.permsMode) initialPermsMode = health.permsMode
95
+ sReady.stop(
96
+ `${isLocalGateway ? 'gateway' : 'harness'} ready (uptime ${(health.uptimeMs / 1000).toFixed(0)}s)`,
97
+ )
98
+ } catch {
99
+ // Local gateway has no Daytona to resume — the daemon is either alive or
100
+ // it isn't. Tell the user to (re)start it and exit.
101
+ if (isLocalGateway) {
102
+ sReady.stop(
103
+ `gateway unreachable at ${opts.unixSocketPath} — try \`promus gateway start\` then re-run`,
104
+ )
105
+ await operator.close?.()
106
+ process.exit(1)
107
+ }
108
+ // Sandbox path: harness might be archived/stopped/error, OR it could be
109
+ // started but with a dead daemon (orphaned-harness). Both paths converge
110
+ // on `resumeArchivedSandbox`, which probes state, restores if needed,
111
+ // relaunches the harness daemon via toolbox exec, and re-handoffs the
112
+ // agent privkey. Re-handoff requires the keystore unlock that
113
+ // chat-sandbox normally skips.
114
+ sReady.message('harness unreachable; attempting auto-resume')
115
+ const provider = new SandboxProviderClient({
116
+ endpoint: SANDBOX_PROVIDER_URL_GALILEO,
117
+ operator: operatorAccount,
118
+ })
119
+ if (!config.brain.provider) {
120
+ sReady.stop('harness unreachable AND brain provider missing; run `promus model`')
121
+ await operator.close?.()
122
+ process.exit(1)
123
+ }
124
+ let agentPrivkey: `0x${string}`
125
+ try {
126
+ agentPrivkey = await unlockAgentKeystore({
127
+ operator,
128
+ network: config.network as PromusNetwork,
129
+ contractAddress,
130
+ tokenId,
131
+ agentAddress,
132
+ })
133
+ } catch (e) {
134
+ sReady.stop(`auto-resume keystore unlock failed: ${(e as Error).message.slice(0, 160)}`)
135
+ await operator.close?.()
136
+ process.exit(1)
137
+ }
138
+ const telegramSecretsPlain = await loadTelegramHandoffSecrets({
139
+ signer: operator,
140
+ agentAddress,
141
+ contractAddress,
142
+ tokenId,
143
+ onNotice: msg => sReady.message(msg),
144
+ })
145
+ try {
146
+ await resumeArchivedSandbox({
147
+ provider,
148
+ sandboxId,
149
+ sandboxEndpoint,
150
+ operatorAccount,
151
+ agentPrivkey,
152
+ agentAddress,
153
+ iNFTRef: { contract: contractAddress, tokenId },
154
+ iNFTNetwork: config.network as PromusNetwork,
155
+ brain: { provider: config.brain.provider as Address, model: config.brain.model ?? '' },
156
+ telegramSecrets: telegramSecretsPlain,
157
+ onProgress: msg => sReady.message(msg),
158
+ })
159
+ const health = await client.waitReady({ timeoutMs: 30_000, intervalMs: 1500 })
160
+ if (health.permsMode) initialPermsMode = health.permsMode
161
+ sReady.stop(
162
+ `harness back online via auto-resume (uptime ${(health.uptimeMs / 1000).toFixed(0)}s)`,
163
+ )
164
+ } catch (e) {
165
+ sReady.stop(`auto-resume failed: ${(e as Error).message.slice(0, 200)}`)
166
+ await operator.close?.()
167
+ process.exit(1)
168
+ }
169
+ }
170
+
171
+ // opentui import dance: render() runs the chat UI; clack spinners must
172
+ // finish before we hand stdin off to opentui (see comment in chat.tsx).
173
+ const { render } = await import('@opentui/solid')
174
+ const { createCliRenderer } = await import('@opentui/core')
175
+ const { createChatState } = await import('../ui/state')
176
+ const { ChatApp } = await import('../ui/app')
177
+
178
+ const state = createChatState({
179
+ // v0.24.4: branch on isLocalGateway. Local-gateway TUI talks to a daemon
180
+ // over a unix socket (`~/.promus/agents/<id>/gateway.sock`) — calling that
181
+ // "sandbox" mislead operators into believing they were paying sandbox
182
+ // billing fees and into expecting a Daytona-style endpoint. The
183
+ // standalone gateway path gets a clearer label; sandbox path keeps its
184
+ // existing "connected to sandbox X @ Y" copy.
185
+ initialSystem: isLocalGateway
186
+ ? `connected to local gateway (${agentPaths.agent(agentId).dir}/gateway.sock)`
187
+ : `connected to sandbox ${sandboxId.slice(0, 8)} @ ${sandboxEndpoint}`,
188
+ // v0.22.0: subname (if registered) + full EOA. Brain provider dropped.
189
+ identityLabel: `agent ${config.subname ?? agentId} ${agentAddress}`,
190
+ // v0.21.13: seeded from /healthz.permsMode so the statusline reflects
191
+ // the gateway's actual mode after auto-spawn / restart cycles. The
192
+ // statusline subsequently updates locally via the /yolo and /perms
193
+ // slash handlers below.
194
+ approvalsMode: initialPermsMode,
195
+ // v0.24.4: drives the statusbar gate that hides the sandbox-billing
196
+ // balance segment + drives the /help copy below. See state.ts.
197
+ isLocalGateway,
198
+ })
199
+
200
+ const renderer = await createCliRenderer({
201
+ exitOnCtrlC: false,
202
+ consoleMode: 'disabled',
203
+ openConsoleOnError: false,
204
+ })
205
+
206
+ // Pending approval id → forward to harness via signed POST. The TUI's
207
+ // existing y/s/n handler calls `pending.resolve(decision)`; our resolver
208
+ // fires off the signed POST. Local promise resolves immediately (the
209
+ // harness's ApprovalRelay handles the actual permission unblock).
210
+ const approvalIdRef: { current: string | null } = { current: null }
211
+
212
+ const renderEvent = (kind: GatewayEventKind, data: unknown): void => {
213
+ const d = data as Record<string, unknown>
214
+ switch (kind) {
215
+ case 'tool-call-start':
216
+ state.pushRow({
217
+ role: 'tool-call',
218
+ text: '',
219
+ toolName: String(d.name ?? '?'),
220
+ args: String(d.args ?? ''),
221
+ autoEscalated: d.autoEscalated === true,
222
+ })
223
+ break
224
+ case 'tool-call-end':
225
+ state.pushRow({
226
+ role: 'tool-result',
227
+ text: String(d.summary ?? (d.ok ? 'ok' : 'failed')),
228
+ failed: d.ok === false,
229
+ autoEscalated: d.autoEscalated === true,
230
+ })
231
+ break
232
+ case 'sync-flush': {
233
+ const tx = String(d.txHash ?? '')
234
+ const slots = Array.isArray(d.slots) ? (d.slots as string[]).join(', ') : ''
235
+ const explorer = String(d.explorer ?? '')
236
+ state.pushRow({
237
+ role: 'system',
238
+ text: explorer ? `synced ${slots} → ${explorer}` : `synced ${slots} (tx ${tx})`,
239
+ })
240
+ break
241
+ }
242
+ case 'context-compacted': {
243
+ const from = Number(d.from ?? 0)
244
+ const to = Number(d.to ?? 0)
245
+ const tokens = Number(d.promptTokens ?? 0)
246
+ const tokensHint = tokens > 0 ? ` (~${Math.round(tokens / 1000)}k tokens)` : ''
247
+ state.pushRow({
248
+ role: 'system',
249
+ text: `✂︎ context compacted ${from} → ${to} messages${tokensHint}`,
250
+ })
251
+ break
252
+ }
253
+ case 'auto-topup': {
254
+ const message = String(d.message ?? '')
255
+ const kind = String(d.kind ?? '')
256
+ const prefix =
257
+ kind === 'topup-fired' ? '⚡ topup' : kind === 'wallet-low' ? '⚠ wallet' : '✗ topup'
258
+ state.pushRow({ role: 'system', text: `${prefix} ${message}` })
259
+ break
260
+ }
261
+ case 'listener-event': {
262
+ const k = String(d.kind ?? '')
263
+ if (k === 'a2a-delivered') {
264
+ state.pushRow({
265
+ role: 'inbox',
266
+ text: `from ${d.fromLabel ?? d.from} · ${d.preview ?? ''}`,
267
+ })
268
+ } else if (k === 'market-job') {
269
+ state.pushRow({
270
+ role: 'market',
271
+ text: `job#${d.jobId ?? '?'} · ${d.jobKind ?? '?'} · tx ${String(d.txHash ?? '').slice(0, 10)}`,
272
+ })
273
+ } else if (k === 'a2a-notice') {
274
+ state.pushRow({
275
+ role: 'system',
276
+ text: `inbox notice: ${d.noticeKind ?? '?'} from ${d.from ?? ''}`,
277
+ })
278
+ } else if (k === 'telegram-inbound') {
279
+ const who = d.username ? `@${d.username}` : `id=${d.userId ?? '?'}`
280
+ state.pushRow({
281
+ role: 'inbox-tg',
282
+ text: `tg ${who} · ${d.preview ?? ''}`,
283
+ })
284
+ } else if (k === 'telegram-outbound') {
285
+ state.pushRow({
286
+ role: 'system',
287
+ text: `tg out → chat ${d.chatId ?? '?'} · ${d.length ?? 0} chars`,
288
+ })
289
+ } else if (k === 'telegram-processing-start') {
290
+ state.pushRow({
291
+ role: 'system',
292
+ text: `tg replying to chat ${d.chatId ?? '?'}`,
293
+ })
294
+ } else if (k === 'telegram-processing-end') {
295
+ state.pushRow({
296
+ role: 'system',
297
+ text: d.ok
298
+ ? `tg reply sent to chat ${d.chatId ?? '?'}`
299
+ : `tg reply FAILED to chat ${d.chatId ?? '?'}`,
300
+ })
301
+ }
302
+ break
303
+ }
304
+ case 'approval-needed': {
305
+ const req = (d.payload ?? {}) as PermissionRequest
306
+ const id = String(d.id ?? '')
307
+ approvalIdRef.current = id
308
+ state.pushRow({
309
+ role: 'system',
310
+ text: `[approval requested] ${req.reason}: ${summarizeApprovalSubject(req)}`,
311
+ })
312
+ state.setPendingApproval({
313
+ request: req,
314
+ resolve: (decision: PermissionDecision) => {
315
+ // Fire-and-forget: harness ApprovalRelay handles the resolve.
316
+ void client.approve(id, decision).catch(err => {
317
+ state.pushRow({
318
+ role: 'system',
319
+ text: `approval send failed: ${(err as Error).message.slice(0, 200)}`,
320
+ })
321
+ })
322
+ approvalIdRef.current = null
323
+ },
324
+ })
325
+ break
326
+ }
327
+ case 'approval-expired':
328
+ if (approvalIdRef.current === d.id) {
329
+ state.setPendingApproval(null)
330
+ approvalIdRef.current = null
331
+ }
332
+ state.pushRow({ role: 'system', text: `approval ${d.id ?? '?'} expired` })
333
+ break
334
+ case 'state-change':
335
+ if (d.state === 'ShuttingDown') {
336
+ state.pushRow({ role: 'system', text: 'harness state: ShuttingDown' })
337
+ }
338
+ break
339
+ case 'log':
340
+ // Suppressed unless verbose flag set; for v0.15.0 keep silent.
341
+ break
342
+ default:
343
+ break
344
+ }
345
+ }
346
+
347
+ const eventSignal = new AbortController()
348
+ const eventLoop = (async () => {
349
+ try {
350
+ for await (const ev of client.events({ signal: eventSignal.signal, clientKind: 'tui' })) {
351
+ renderEvent(ev.kind, ev.data)
352
+ }
353
+ } catch (err) {
354
+ if (eventSignal.signal.aborted) return
355
+ state.pushRow({
356
+ role: 'system',
357
+ text: `event stream lost: ${(err as Error).message}`,
358
+ })
359
+ }
360
+ })()
361
+
362
+ // v0.22.0: poll balances directly from chain. Sandbox-deployed agents still
363
+ // have their EOA + compute ledger on-chain (agent privkey signs from inside
364
+ // the container), and the sandbox billing reserve is read against the
365
+ // settlement contract using the operator's address. All three queries are
366
+ // read-only RPC and never touch the daemon, so they're safe at any moment.
367
+ const balanceRpcNetwork = config.network as PromusNetwork
368
+ const balancePublicClient = createPublicClient({
369
+ transport: http(NETWORK_RPC[balanceRpcNetwork]),
370
+ })
371
+ const operatorAddressForBilling = config.identity?.operator as Address | undefined
372
+ const refreshBalances = (): void => {
373
+ balancePublicClient
374
+ .getBalance({ address: agentAddress })
375
+ .then(wei => state.setEoaBalance(Number(formatEther(wei))))
376
+ .catch(() => {})
377
+ getLedgerDetailReadOnly({ network: balanceRpcNetwork, agentAddress })
378
+ .then(detail => {
379
+ if (detail) state.setBalance(Number(formatEther(detail.totalBalance)))
380
+ })
381
+ .catch(() => {})
382
+ // v0.24.4: local-gateway deploys have no Daytona billing reserve to
383
+ // surface — skip the RPC roundtrip entirely (saved 2 calls/min on the
384
+ // 30s timer) and leave sandboxBalance() as null so the statusbar Show
385
+ // gate hides the segment.
386
+ if (!isLocalGateway && operatorAddressForBilling) {
387
+ getSandboxBillingReserve({ recipient: operatorAddressForBilling })
388
+ .then(wei => state.setSandboxBalance(Number(formatEther(wei))))
389
+ .catch(() => {})
390
+ }
391
+ }
392
+ refreshBalances()
393
+ const balanceTimer = setInterval(refreshBalances, 30_000)
394
+
395
+ const handleSubmit = async (text: string): Promise<void> => {
396
+ const trimmed = text.trim()
397
+ if (trimmed.startsWith('/')) {
398
+ const handled = await handleSlash(trimmed)
399
+ if (handled) {
400
+ state.setStatus('idle')
401
+ return
402
+ }
403
+ }
404
+ state.setStatus('thinking')
405
+ state.setTurnStartedAt(Date.now())
406
+ try {
407
+ const r = await client.chat(text)
408
+ state.pushRow({ role: 'assistant', text: r.response })
409
+ state.setStatus('idle')
410
+ if (r.syncTx) {
411
+ state.pushRow({ role: 'system', text: `auto-sync → tx ${r.syncTx}` })
412
+ }
413
+ // v0.22.0: chain ops drained balances; refresh statusline.
414
+ refreshBalances()
415
+ } catch (err) {
416
+ state.pushRow({
417
+ role: 'system',
418
+ text: `chat failed: ${(err as Error).message.slice(0, 300)}`,
419
+ })
420
+ state.setStatus('error')
421
+ } finally {
422
+ state.setActiveAbort(null)
423
+ }
424
+ }
425
+
426
+ const handleSlash = async (cmd: string): Promise<boolean> => {
427
+ if (cmd === '/exit' || cmd === '/quit') {
428
+ state.pushRow({ role: 'system', text: 'goodbye.' })
429
+ handleExit()
430
+ return true
431
+ }
432
+ if (cmd === '/sync') {
433
+ state.pushRow({ role: 'system', text: 'flushing memory + activity to 0G…' })
434
+ try {
435
+ const r = await client.sync()
436
+ if (r.tx) {
437
+ state.pushRow({
438
+ role: 'system',
439
+ text: `synced ${r.slots.join(', ')} → tx ${r.tx}`,
440
+ })
441
+ } else {
442
+ state.pushRow({ role: 'system', text: 'nothing to sync' })
443
+ }
444
+ } catch (e) {
445
+ state.pushRow({
446
+ role: 'system',
447
+ text: `sync error: ${(e as Error).message.slice(0, 200)}`,
448
+ })
449
+ }
450
+ return true
451
+ }
452
+ // v0.21.13: forward bypass commands to the gateway via client.chat() (the
453
+ // gateway's dispatchBypass intercepts before brain.infer) AND optimistically
454
+ // update the local statusline. Pre-fix the gateway updated its own
455
+ // PermissionService but the TUI's hardcoded `approvalsMode: 'off'` never
456
+ // moved, leaving the statusbar stuck at 'off' even after `/perms prompt`.
457
+ if (cmd === '/yolo' || cmd === '/perms' || cmd.startsWith('/perms ')) {
458
+ try {
459
+ const r = await client.chat(cmd)
460
+ state.pushRow({ role: 'assistant', text: r.response })
461
+ // Re-read healthz for ground truth; cheap (~5ms) and immune to brain reply parsing.
462
+ const h = await client.health().catch(() => null)
463
+ const next = h?.permsMode
464
+ if (next) state.setApprovalsMode(next)
465
+ } catch (e) {
466
+ state.pushRow({
467
+ role: 'system',
468
+ text: `${cmd} failed: ${(e as Error).message.slice(0, 200)}`,
469
+ })
470
+ }
471
+ return true
472
+ }
473
+ if (cmd === '/reset') {
474
+ try {
475
+ const r = await client.chat(cmd)
476
+ state.pushRow({ role: 'assistant', text: r.response })
477
+ } catch (e) {
478
+ state.pushRow({
479
+ role: 'system',
480
+ text: `reset failed: ${(e as Error).message.slice(0, 200)}`,
481
+ })
482
+ }
483
+ return true
484
+ }
485
+ if (cmd === '/help') {
486
+ // v0.24.4: differentiate the help copy. Local gateway mode flushes
487
+ // memory directly to chain via the daemon; sandbox mode flushes via
488
+ // the remote harness sitting in Daytona. Both share the same command
489
+ // surface so the body is identical; only the prefix label differs.
490
+ const modeLabel = isLocalGateway ? 'local gateway' : 'sandbox'
491
+ const flushTarget = isLocalGateway ? 'via local gateway daemon' : 'via remote harness'
492
+ state.pushRow({
493
+ role: 'system',
494
+ text: `${modeLabel}-mode slash commands:\n /sync force memory + activity flush ${flushTarget}\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 (${isLocalGateway ? 'gateway daemon keeps running' : 'harness keeps running'})\n /help this message`,
495
+ })
496
+ return true
497
+ }
498
+ return false
499
+ }
500
+
501
+ const handleExit = (): void => {
502
+ eventSignal.abort()
503
+ clearInterval(balanceTimer)
504
+ void eventLoop.then(() => {})
505
+ try {
506
+ renderer.destroy()
507
+ } catch {}
508
+ void operator.close?.()
509
+ process.exit(0)
510
+ }
511
+
512
+ await render(
513
+ () => <ChatApp state={state} onSubmit={handleSubmit} onExit={handleExit} />,
514
+ renderer,
515
+ )
516
+
517
+ await new Promise<void>(() => {
518
+ // Block forever; only handleExit (via process.exit) escapes.
519
+ })
520
+ }