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.
- package/README.md +39 -0
- package/bin/nebula +11 -0
- package/package.json +65 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_unlock.ts +66 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1293 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +49 -0
- package/src/commands/gateway-run.ts +42 -0
- package/src/commands/gateway-start.ts +216 -0
- package/src/commands/gateway-status.ts +90 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/identity.ts +178 -0
- package/src/commands/init/cost.ts +40 -0
- package/src/commands/init/funding-gate.ts +64 -0
- package/src/commands/init/model-picker.ts +25 -0
- package/src/commands/init/operator-picker.ts +233 -0
- package/src/commands/init/telegram-step.ts +245 -0
- package/src/commands/init/wizard-state.ts +94 -0
- package/src/commands/init.ts +439 -0
- package/src/commands/logs.ts +37 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +65 -0
- package/src/commands/pairing-clear.ts +39 -0
- package/src/commands/pairing-list.ts +55 -0
- package/src/commands/pairing-revoke.ts +49 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/status.ts +44 -0
- package/src/commands/telegram-remove.ts +62 -0
- package/src/commands/telegram-setup.ts +64 -0
- package/src/commands/telegram-status.ts +87 -0
- package/src/commands/telegram.ts +44 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.ts +99 -0
- package/src/index.ts +153 -0
- package/src/ui/app.tsx +673 -0
- package/src/ui/approval-summary.ts +32 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.ts +181 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.ts +125 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.ts +40 -0
- 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
|
+
}
|