nebula-treasury 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +39 -0
  2. package/bin/nebula +11 -0
  3. package/package.json +65 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_unlock.ts +66 -0
  6. package/src/commands/chat-telegram.ts +398 -0
  7. package/src/commands/chat.tsx +1293 -0
  8. package/src/commands/drain.ts +90 -0
  9. package/src/commands/gateway-logs.ts +49 -0
  10. package/src/commands/gateway-run.ts +42 -0
  11. package/src/commands/gateway-start.ts +216 -0
  12. package/src/commands/gateway-status.ts +90 -0
  13. package/src/commands/gateway-stop.ts +133 -0
  14. package/src/commands/gateway.ts +101 -0
  15. package/src/commands/identity.ts +178 -0
  16. package/src/commands/init/cost.ts +40 -0
  17. package/src/commands/init/funding-gate.ts +64 -0
  18. package/src/commands/init/model-picker.ts +25 -0
  19. package/src/commands/init/operator-picker.ts +233 -0
  20. package/src/commands/init/telegram-step.ts +245 -0
  21. package/src/commands/init/wizard-state.ts +94 -0
  22. package/src/commands/init.ts +439 -0
  23. package/src/commands/logs.ts +37 -0
  24. package/src/commands/model.ts +48 -0
  25. package/src/commands/pairing-approve.ts +65 -0
  26. package/src/commands/pairing-clear.ts +39 -0
  27. package/src/commands/pairing-list.ts +55 -0
  28. package/src/commands/pairing-revoke.ts +49 -0
  29. package/src/commands/pairing.ts +81 -0
  30. package/src/commands/status.ts +44 -0
  31. package/src/commands/telegram-remove.ts +62 -0
  32. package/src/commands/telegram-setup.ts +64 -0
  33. package/src/commands/telegram-status.ts +87 -0
  34. package/src/commands/telegram.ts +44 -0
  35. package/src/config/load.ts +35 -0
  36. package/src/config/render.ts +99 -0
  37. package/src/index.ts +153 -0
  38. package/src/ui/app.tsx +673 -0
  39. package/src/ui/approval-summary.ts +32 -0
  40. package/src/ui/markdown-parse.ts +219 -0
  41. package/src/ui/markdown.tsx +37 -0
  42. package/src/ui/state.ts +181 -0
  43. package/src/util/bootstrap-mode.ts +25 -0
  44. package/src/util/bootstrap-progress-box.ts +378 -0
  45. package/src/util/cli-version.ts +28 -0
  46. package/src/util/format.ts +11 -0
  47. package/src/util/gateway-spawn.ts +125 -0
  48. package/src/util/gateway-version.ts +154 -0
  49. package/src/util/github-releases.ts +79 -0
  50. package/src/util/profile-key.ts +25 -0
  51. package/src/util/ref-resolver.ts +55 -0
  52. package/src/util/silence-console.ts +40 -0
  53. package/src/util/telegram-secrets.ts +218 -0
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # nebula-treasury
2
+
3
+ The `nebula` CLI — a **Mantle-native, policy-aware AI treasury assistant**. Real
4
+ on-chain work on Mantle (balances, transfers, swaps, wrap/unwrap, Aave lending,
5
+ yield discovery, ERC-8004 identity) from your terminal, where every value-moving
6
+ action is checked against a deterministic policy, dry-run simulated, and held for
7
+ approval before broadcast. The model proposes; code disposes.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ bun add -g nebula-treasury
13
+ nebula init # bootstrap an agent (plain-EOA identity, local encrypted keystore)
14
+ nebula # chat with your agent
15
+ ```
16
+
17
+ Requires [bun](https://bun.sh) — the CLI shebangs `bun`.
18
+
19
+ ## Commands
20
+
21
+ ```
22
+ nebula init bootstrap a new agent identity + local keystore
23
+ nebula [--yolo] interactive chat (default; --yolo skips approvals)
24
+ nebula status agent + wallet + config state
25
+ nebula logs tail the activity log
26
+ nebula drain --to <addr> sweep the agent EOA balance
27
+ nebula model re-pick the brain model
28
+ nebula identity <sub> ERC-8004 agent identity (card | register | show)
29
+ nebula telegram <sub> phone-DM gateway (setup | status | remove)
30
+ nebula pairing <sub> DM pairing approvals (list | approve | revoke | clear-pending)
31
+ nebula gateway <sub> always-on daemon (run | start | stop | restart | status | logs)
32
+ ```
33
+
34
+ Configure the brain with `OPENAI_API_KEY` (or any OpenAI-compatible `NEBULA_LLM_*`),
35
+ set `NEBULA_POLICY_*` fund-control limits, and fund the agent EOA with a little MNT
36
+ for gas. Material-risk actions pause for your approval.
37
+
38
+ See the [root README](https://github.com/rstfulzz/nebula#readme) for architecture
39
+ and the full reference.
package/bin/nebula ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ // Register the @opentui/solid JSX transform plugin BEFORE any .tsx file
3
+ // loads. bunfig.toml's `preload` only fires when bun discovers bunfig.toml
4
+ // in the cwd lookup chain — running `nebula` from outside the repo (e.g.
5
+ // `cd ~ && nebula`, or as an installed npm bin) skips it entirely, leaving
6
+ // JSX compiled as React.createElement and the chat TUI rendering blank.
7
+ // Importing the preload module here registers the plugin regardless of
8
+ // cwd. The plugin is idempotent so this is a no-op if bunfig.toml ALSO
9
+ // loaded it.
10
+ import '@opentui/solid/preload'
11
+ import '../src/index'
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "nebula-treasury",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "nebula CLI: a Mantle-native, policy-aware AI treasury assistant. Real on-chain work gated by policy, simulation, and approval",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/rstfulzz/nebula",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/rstfulzz/nebula.git",
11
+ "directory": "packages/cli"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/rstfulzz/nebula/issues"
15
+ },
16
+ "keywords": [
17
+ "nebula",
18
+ "ai",
19
+ "agent",
20
+ "cli",
21
+ "tui",
22
+ "mantle",
23
+ "treasury",
24
+ "defi",
25
+ "erc-8004"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "engines": {
31
+ "bun": ">=1.1"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "!src/**/*.test.ts",
36
+ "bin",
37
+ "README.md"
38
+ ],
39
+ "bin": {
40
+ "nebula": "bin/nebula"
41
+ },
42
+ "main": "./src/index.ts",
43
+ "types": "./src/index.ts",
44
+ "scripts": {
45
+ "build": "tsc -b",
46
+ "test": "bun test"
47
+ },
48
+ "dependencies": {
49
+ "@clack/prompts": "^0.8.2",
50
+ "@opentui/core": "^0.1.97",
51
+ "@opentui/solid": "^0.1.97",
52
+ "nebula-ai-core": "0.1.0",
53
+ "nebula-ai-gateway": "0.1.0",
54
+ "nebula-ai-plugin-onchain": "0.1.0",
55
+ "nebula-ai-plugin-system": "0.1.0",
56
+ "nebula-ai-plugin-telegram": "0.1.0",
57
+ "picocolors": "^1.1.1",
58
+ "qrcode-terminal": "^0.12.0",
59
+ "solid-js": "^1.9.12",
60
+ "viem": "^2.21.55"
61
+ },
62
+ "devDependencies": {
63
+ "@types/qrcode-terminal": "^0.12.2"
64
+ }
65
+ }
@@ -0,0 +1,14 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readdir } from 'node:fs/promises'
3
+ import { agentPaths } from 'nebula-ai-core'
4
+
5
+ export async function listAgentIds(): Promise<string[]> {
6
+ if (!existsSync(agentPaths.agentsDir)) return []
7
+ const entries = await readdir(agentPaths.agentsDir, { withFileTypes: true })
8
+ return entries.filter(e => e.isDirectory()).map(e => e.name)
9
+ }
10
+
11
+ export async function pickDefaultAgent(): Promise<string | null> {
12
+ const ids = await listAgentIds()
13
+ return ids[0] ?? null
14
+ }
@@ -0,0 +1,66 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { spinner } from '@clack/prompts'
3
+ import {
4
+ type NebulaConfig,
5
+ type NebulaNetwork,
6
+ agentPaths,
7
+ decodeKeystoreBytes,
8
+ decryptAgentKey,
9
+ placeholderAgentId,
10
+ } from 'nebula-ai-core'
11
+ import type { Address, Hex } from 'viem'
12
+ import { withSilencedConsole } from '../util/silence-console'
13
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
14
+
15
+ export interface UnlockedAgent {
16
+ agentPrivkey: Hex
17
+ agentAddress: Address
18
+ network: NebulaNetwork
19
+ close: () => Promise<void>
20
+ }
21
+
22
+ /**
23
+ * Shared operator-unlock dance for any command that needs the agent privkey:
24
+ * 1. pick the operator signer (keystore / WC / keychain) per config hint
25
+ * 2. read the local encrypted keystore cache
26
+ * 3. decrypt via operator signature
27
+ *
28
+ * Returns null if the operator picker is cancelled or the keystore can't be
29
+ * decrypted; caller should bail out early on null.
30
+ *
31
+ * Caller MUST call `close()` once done with the privkey, even on success, to
32
+ * release WC sessions / keystore tmpfiles.
33
+ */
34
+ export async function unlockAgentSigner(
35
+ config: NebulaConfig,
36
+ spinnerLabel = 'Decrypting agent keystore via operator wallet',
37
+ ): Promise<UnlockedAgent | null> {
38
+ if (!config.identity.agent) return null
39
+ const network = config.network
40
+ const agentAddress = config.identity.agent as Address
41
+ const agentId = placeholderAgentId(agentAddress)
42
+ const paths = agentPaths.agent(agentId)
43
+
44
+ const operator = await loadOrPickOperatorSigner({ network, hint: config.operator })
45
+ if (!operator) return null
46
+
47
+ const close = async () => {
48
+ await operator.close?.()
49
+ }
50
+
51
+ const s = spinner()
52
+ s.start(spinnerLabel)
53
+ try {
54
+ const agentPrivkey = await withSilencedConsole(async (): Promise<Hex> => {
55
+ const raw = await readFile(paths.keystore, 'utf8')
56
+ const keystore = decodeKeystoreBytes(new TextEncoder().encode(raw))
57
+ return (await decryptAgentKey({ signer: operator, agentAddress, keystore })) as Hex
58
+ })
59
+ s.stop('unlocked (keystore source: local)')
60
+ return { agentPrivkey, agentAddress, network, close }
61
+ } catch (e) {
62
+ s.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
63
+ await close()
64
+ return null
65
+ }
66
+ }
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Local-mode telegram dispatch wiring for chat.tsx.
3
+ *
4
+ * Two pieces:
5
+ *
6
+ * 1. `buildTelegramRuntimeContext`: composes the side-band context the plugin
7
+ * consumes via `(ctx as any).telegram`. The context's `dispatchUserMessage`
8
+ * points at a *deferred* callback ref; chat.tsx populates the ref AFTER
9
+ * brain init but BEFORE any inbound TG message can race.
10
+ *
11
+ * 2. `buildTelegramDispatch`: factory for the deferred callback itself.
12
+ * Returns a handle with `{ dispatch, drainQueue, getQueueSize }`. chat.tsx
13
+ * wires the dispatch into the slot AND subscribes to status idle so it
14
+ * can call drainQueue to wake any messages that arrived during a stdin
15
+ * turn (closes G4 starvation).
16
+ *
17
+ * Bypass commands (parseBypassCommand) skip the queue + busy gate. `/stop`
18
+ * aborts the active brain turn; `/status` reports thinking/idle; the rest
19
+ * are placeholders for future B5 inline-keyboard approvals.
20
+ */
21
+ import type {
22
+ ActivityLog,
23
+ Brain,
24
+ FrozenPrefix,
25
+ PermissionDecision,
26
+ PermissionPrompter,
27
+ PermissionRequest,
28
+ PermissionService,
29
+ } from 'nebula-ai-core'
30
+ import { applyPerms, applyYolo, newEventId } from 'nebula-ai-core'
31
+ import {
32
+ ActiveSessionTracker,
33
+ type ApprovalChoice,
34
+ type BypassCommand,
35
+ type TelegramApprovalBridge,
36
+ type TelegramDispatchInput,
37
+ type TelegramDispatchResult,
38
+ type TelegramRuntimeContext,
39
+ makeApprovalIdFactory,
40
+ parseBypassCommand,
41
+ } from 'nebula-ai-plugin-telegram'
42
+ import { summarizeApprovalSubject } from '../ui/approval-summary'
43
+
44
+ export type DispatchUserMessage = (input: TelegramDispatchInput) => Promise<TelegramDispatchResult>
45
+
46
+ /**
47
+ * Mutable callback ref. chat.tsx holds it across boot; we hand the ref into
48
+ * the plugin's runtime context via a closure that defers to the ref's current
49
+ * value at call-time.
50
+ */
51
+ export interface TelegramDispatchSlot {
52
+ current: DispatchUserMessage | null
53
+ }
54
+
55
+ export interface RowSinkRef {
56
+ current: ((text: string) => void) | null
57
+ }
58
+
59
+ export function buildTelegramRuntimeContext(opts: {
60
+ botToken: string
61
+ allowedUserIds: number[]
62
+ agentName: string
63
+ slot: TelegramDispatchSlot
64
+ systemRowSink: RowSinkRef
65
+ }): TelegramRuntimeContext {
66
+ return {
67
+ botToken: opts.botToken,
68
+ allowedUserIds: opts.allowedUserIds,
69
+ agentName: opts.agentName,
70
+ dispatchUserMessage: async input => {
71
+ const cb = opts.slot.current
72
+ if (!cb) {
73
+ return {
74
+ response: 'agent is still booting; try again in a moment.',
75
+ }
76
+ }
77
+ return cb(input)
78
+ },
79
+ onProcessingStart: async (chatId, _msgId) => {
80
+ opts.systemRowSink.current?.(`tg replying to chat ${chatId}`)
81
+ },
82
+ onProcessingEnd: async (chatId, _msgId, ok) => {
83
+ opts.systemRowSink.current?.(
84
+ ok ? `tg reply sent to chat ${chatId}` : `tg reply FAILED to chat ${chatId}`,
85
+ )
86
+ },
87
+ }
88
+ }
89
+
90
+ export interface BuildDispatchDeps {
91
+ activity: ActivityLog
92
+ /** Local-only memory persists via the memory.* tools; flushTurn is a no-op. */
93
+ sync: { flushTurn: () => Promise<{ txHash: string | null; changedSlots: string[] }> }
94
+ permission: PermissionService
95
+ pushAssistantRow: (text: string) => void
96
+ pushInboundRow: (preview: string) => void
97
+ /** Returns true if the brain is currently busy on another turn. */
98
+ isBusy: () => boolean
99
+ buildPrefix: () => Promise<FrozenPrefix>
100
+ brain: Brain & { refreshUserContext: (prefix: FrozenPrefix) => void }
101
+ /** Mark the brain as "thinking" / idle in the TUI state. */
102
+ setThinking: (on: boolean) => void
103
+ setActiveAbort: (ctrl: AbortController | null) => void
104
+ refreshBalances: () => void
105
+ formatInboundPreview: (input: TelegramDispatchInput) => string
106
+ /**
107
+ * Optional approval bridge from the listener. When present, dispatch swaps
108
+ * permission.setPrompter to a TG-aware prompter for the turn duration so
109
+ * the operator can approve tool calls from their phone via inline keyboard.
110
+ */
111
+ approvalBridge?: TelegramApprovalBridge
112
+ }
113
+
114
+ export interface TelegramDispatchHandle {
115
+ dispatch: DispatchUserMessage
116
+ /** Re-run the queue. Called by chat.tsx when stdin turn ends (closes G4). */
117
+ drainQueue: () => void
118
+ getQueueSize: () => number
119
+ }
120
+
121
+ /**
122
+ * Build the deferred dispatch callback. Caller assigns `handle.dispatch` into
123
+ * `slot.current` once brain.init resolves, and wires `handle.drainQueue` into
124
+ * a status-change effect.
125
+ */
126
+ export function buildTelegramDispatch(deps: BuildDispatchDeps): TelegramDispatchHandle {
127
+ const queue: { input: TelegramDispatchInput; resolve: (r: TelegramDispatchResult) => void }[] = []
128
+ let draining = false
129
+ const tracker = new ActiveSessionTracker()
130
+ const pendingApprovals = new Map<string, (choice: ApprovalChoice) => void>()
131
+ const approvalIdFactory = makeApprovalIdFactory()
132
+ let callbackInstalled = false
133
+ const ensureCallbackInstalled = (): void => {
134
+ if (callbackInstalled) return
135
+ const install = deps.approvalBridge?.installCallbackHandler.current
136
+ if (!install) return
137
+ install((approvalId, choice, _fromUserId) => {
138
+ const r = pendingApprovals.get(approvalId)
139
+ if (r) {
140
+ pendingApprovals.delete(approvalId)
141
+ r(choice)
142
+ }
143
+ })
144
+ callbackInstalled = true
145
+ }
146
+
147
+ const drain = async (): Promise<void> => {
148
+ if (draining) return
149
+ draining = true
150
+ try {
151
+ while (queue.length > 0) {
152
+ if (deps.isBusy()) return
153
+ const item = queue.shift()!
154
+ ensureCallbackInstalled()
155
+ try {
156
+ const r = await runOne(item.input, deps, tracker, {
157
+ pendingApprovals,
158
+ approvalIdFactory,
159
+ })
160
+ item.resolve(r)
161
+ } catch (err) {
162
+ item.resolve({
163
+ response: `error processing your message: ${(err as Error).message.slice(0, 200)}`,
164
+ })
165
+ }
166
+ }
167
+ } finally {
168
+ draining = false
169
+ }
170
+ }
171
+
172
+ return {
173
+ dispatch: (input: TelegramDispatchInput) =>
174
+ new Promise<TelegramDispatchResult>(resolve => {
175
+ deps.pushInboundRow(deps.formatInboundPreview(input))
176
+
177
+ // Bypass commands skip the queue + busy gate entirely.
178
+ const bypass = parseBypassCommand(input.text)
179
+ if (bypass) {
180
+ void Promise.resolve(handleBypass(bypass, input, deps, tracker)).then(resolve)
181
+ return
182
+ }
183
+
184
+ queue.push({ input, resolve })
185
+ void drain()
186
+ }),
187
+ drainQueue: () => {
188
+ void drain()
189
+ },
190
+ getQueueSize: () => queue.length,
191
+ }
192
+ }
193
+
194
+ async function handleBypass(
195
+ bypass: { command: BypassCommand; args: string[] },
196
+ input: TelegramDispatchInput,
197
+ deps: BuildDispatchDeps,
198
+ tracker: ActiveSessionTracker,
199
+ ): Promise<TelegramDispatchResult> {
200
+ const { command: cmd, args } = bypass
201
+ switch (cmd) {
202
+ case '/stop': {
203
+ const aborted = tracker.abortActive(input.sessionKey)
204
+ if (!aborted && deps.isBusy()) {
205
+ return { response: 'no active turn to stop here, but the agent is busy on stdin.' }
206
+ }
207
+ return {
208
+ response: aborted ? 'stopped the current turn.' : 'no active turn to stop.',
209
+ }
210
+ }
211
+ case '/new':
212
+ case '/reset': {
213
+ // v0.20.0: real reset clears this channel's history. Falls back to a
214
+ // friendly note when the brain doesn't expose channel ops (StubBrain).
215
+ if (typeof deps.brain.clearChannel === 'function') {
216
+ await deps.brain.clearChannel(input.sessionKey)
217
+ return { response: "conversation reset (this chat's history cleared)." }
218
+ }
219
+ return { response: 'this brain does not support reset.' }
220
+ }
221
+ case '/status': {
222
+ const busy = deps.isBusy()
223
+ const qs = '' // queue size could be read via closure; keep terse here
224
+ return {
225
+ response: busy ? `currently thinking on another turn${qs}.` : `idle${qs}.`,
226
+ }
227
+ }
228
+ case '/approve':
229
+ case '/deny': {
230
+ return {
231
+ response: 'inline-keyboard approval is not yet wired in this build (B5 ships in v0.18.1).',
232
+ }
233
+ }
234
+ case '/yolo': {
235
+ const r = applyYolo(deps.permission)
236
+ return { response: r.message }
237
+ }
238
+ case '/perms': {
239
+ const r = applyPerms(deps.permission, args[0])
240
+ return { response: r.message }
241
+ }
242
+ case '/background':
243
+ case '/restart': {
244
+ return { response: `${cmd} is reserved for a future bundle.` }
245
+ }
246
+ }
247
+ }
248
+
249
+ interface RunOneOpts {
250
+ pendingApprovals: Map<string, (c: ApprovalChoice) => void>
251
+ approvalIdFactory: () => string
252
+ }
253
+
254
+ async function runOne(
255
+ input: TelegramDispatchInput,
256
+ deps: BuildDispatchDeps,
257
+ tracker: ActiveSessionTracker,
258
+ opts: RunOneOpts,
259
+ ): Promise<TelegramDispatchResult> {
260
+ // If the listener filled the approval bridge, swap the permission prompter
261
+ // to the TG-aware one for the turn duration. The brain will issue an
262
+ // inline-keyboard approval message; the operator clicks from their phone;
263
+ // the callback resolves the prompter's Promise. Permission resolves go
264
+ // through the normal PermissionService.resolve path: 'off' bypass, 'strict'
265
+ // deny, 'prompt' consults the prompter. We use 'prompt' for TG turns so
266
+ // the bridge is exercised; chat-telegram previously forced 'off' to bypass
267
+ // the TUI modal entirely.
268
+ const previousPrompter = (deps.permission as unknown as { prompter: PermissionPrompter }).prompter
269
+ const bridgeReady =
270
+ !!deps.approvalBridge?.sendApproval.current &&
271
+ !!deps.approvalBridge?.installCallbackHandler.current
272
+ const previousMode = deps.permission.getMode()
273
+ if (bridgeReady) {
274
+ const tgPrompter = buildTelegramPrompter({
275
+ chatId: input.chatId,
276
+ bridge: deps.approvalBridge!,
277
+ pendingApprovals: opts.pendingApprovals,
278
+ approvalIdFactory: opts.approvalIdFactory,
279
+ })
280
+ deps.permission.setPrompter(tgPrompter)
281
+ // Use 'prompt' so dangerous patterns + value-moving txs route through the
282
+ // TG prompter. Tools without prompts (e.g. fs.read) still pass.
283
+ deps.permission.setMode('prompt')
284
+ } else {
285
+ // No bridge: fall back to YOLO so brain doesn't deadlock on a TUI modal
286
+ // the phone-side operator can't reach.
287
+ deps.permission.setMode('off')
288
+ }
289
+ deps.setThinking(true)
290
+ const abortCtrl = new AbortController()
291
+ deps.setActiveAbort(abortCtrl)
292
+ // Synchronous mark-active BEFORE any await closes the race window per
293
+ // hermes base.py:1471. Two messages in the same tick now see the lock.
294
+ tracker.markActive(input.sessionKey, abortCtrl)
295
+ try {
296
+ const refreshed = await deps.buildPrefix()
297
+ deps.brain.refreshUserContext(refreshed)
298
+ await deps.activity.append({
299
+ ts: Date.now(),
300
+ kind: 'wake',
301
+ data: { source: 'telegram', chatId: input.chatId, userId: input.userId },
302
+ })
303
+ const turn = await deps.brain.infer({
304
+ event: {
305
+ id: newEventId(),
306
+ source: 'telegram',
307
+ payload: { label: 'telegram-message', data: input.text },
308
+ ts: Date.now(),
309
+ },
310
+ channelKey: input.sessionKey,
311
+ signal: abortCtrl.signal,
312
+ // Forward per-turn tool-call observer to the brain. The listener
313
+ // attaches a ProgressTracker on every dispatch; dropping it here
314
+ // would silently disable TG's live progress message.
315
+ onToolEvent: input.onToolEvent
316
+ ? ev => {
317
+ input.onToolEvent?.({
318
+ kind: ev.kind,
319
+ tool: ev.tool,
320
+ callId: ev.callId,
321
+ argsPreview: ev.argsPreview,
322
+ ok: ev.ok,
323
+ })
324
+ }
325
+ : undefined,
326
+ })
327
+ await deps.activity.append({
328
+ ts: Date.now(),
329
+ kind: 'brain-response',
330
+ data: {
331
+ content: turn.content,
332
+ toolCalls: turn.toolCalls.length,
333
+ finishReason: turn.finishReason,
334
+ usage: turn.usage,
335
+ source: 'telegram',
336
+ },
337
+ })
338
+ const response = (turn.content ?? '').trim()
339
+ if (response.length > 0) deps.pushAssistantRow(response)
340
+ deps.refreshBalances()
341
+ let syncTx: string | undefined
342
+ try {
343
+ const res = await deps.sync.flushTurn()
344
+ if (res.txHash) syncTx = res.txHash
345
+ } catch {
346
+ // sync errors stay in the activity log; not surfaced to TG.
347
+ }
348
+ return { response: response.length === 0 ? '(no reply)' : response, syncTx }
349
+ } finally {
350
+ deps.setThinking(false)
351
+ deps.setActiveAbort(null)
352
+ tracker.markIdle(input.sessionKey)
353
+ deps.permission.setMode(previousMode)
354
+ if (bridgeReady && previousPrompter) {
355
+ deps.permission.setPrompter(previousPrompter)
356
+ }
357
+ }
358
+ }
359
+
360
+ const APPROVAL_TIMEOUT_MS = 5 * 60_000
361
+
362
+ function buildTelegramPrompter(opts: {
363
+ chatId: number
364
+ bridge: TelegramApprovalBridge
365
+ pendingApprovals: Map<string, (c: ApprovalChoice) => void>
366
+ approvalIdFactory: () => string
367
+ }): PermissionPrompter {
368
+ return async (req: PermissionRequest) => {
369
+ const send = opts.bridge.sendApproval.current
370
+ if (!send) return 'deny'
371
+ const approvalId = opts.approvalIdFactory()
372
+ const body = formatApprovalBody(req)
373
+ return new Promise<PermissionDecision>(resolve => {
374
+ const timer = setTimeout(() => {
375
+ if (opts.pendingApprovals.delete(approvalId)) resolve('deny')
376
+ }, APPROVAL_TIMEOUT_MS)
377
+ opts.pendingApprovals.set(approvalId, choice => {
378
+ clearTimeout(timer)
379
+ resolve(mapChoiceToDecision(choice))
380
+ })
381
+ void send(opts.chatId, body, approvalId).catch(() => {
382
+ clearTimeout(timer)
383
+ if (opts.pendingApprovals.delete(approvalId)) resolve('deny')
384
+ })
385
+ })
386
+ }
387
+ }
388
+
389
+ function mapChoiceToDecision(choice: ApprovalChoice): PermissionDecision {
390
+ if (choice === 'once') return 'allow-once'
391
+ if (choice === 'session' || choice === 'always') return 'allow-session'
392
+ return 'deny'
393
+ }
394
+
395
+ function formatApprovalBody(req: PermissionRequest): string {
396
+ const subject = summarizeApprovalSubject(req)
397
+ return `🔐 Approval needed for ${req.kind}\n\n${subject}\n\nReason: ${req.reason}`
398
+ }