@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.
- package/README.md +18 -0
- package/bin/promus +33 -0
- package/package.json +51 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_inft-ref.ts +43 -0
- package/src/commands/_unlock.ts +74 -0
- package/src/commands/admin-autotopup-tick.ts +73 -0
- package/src/commands/admin.test.ts +34 -0
- package/src/commands/admin.ts +32 -0
- package/src/commands/balance.test.ts +10 -0
- package/src/commands/balance.ts +112 -0
- package/src/commands/chat-sandbox.tsx +520 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1916 -0
- package/src/commands/deploy.ts +204 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +47 -0
- package/src/commands/gateway-run.ts +54 -0
- package/src/commands/gateway-start.ts +218 -0
- package/src/commands/gateway-status.ts +88 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/init/cost.test.ts +169 -0
- package/src/commands/init/cost.ts +154 -0
- package/src/commands/init/funding-gate.ts +67 -0
- package/src/commands/init/model-picker.ts +81 -0
- package/src/commands/init/operator-picker.ts +263 -0
- package/src/commands/init/resume.ts +136 -0
- package/src/commands/init/sandbox-provision.test.ts +497 -0
- package/src/commands/init/sandbox-provision.ts +1177 -0
- package/src/commands/init/telegram-step.ts +229 -0
- package/src/commands/init/wizard-state.ts +95 -0
- package/src/commands/init.ts +612 -0
- package/src/commands/inspect.ts +529 -0
- package/src/commands/ledger.ts +176 -0
- package/src/commands/logs.ts +86 -0
- package/src/commands/migrate-keystore.ts +155 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +114 -0
- package/src/commands/pairing-clear.ts +42 -0
- package/src/commands/pairing-list.ts +58 -0
- package/src/commands/pairing-revoke.ts +52 -0
- package/src/commands/pairing.test.ts +88 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/pause.ts +99 -0
- package/src/commands/profile.ts +184 -0
- package/src/commands/restore.ts +221 -0
- package/src/commands/resume.ts +181 -0
- package/src/commands/status.ts +119 -0
- package/src/commands/sync.ts +147 -0
- package/src/commands/telegram-remove.ts +65 -0
- package/src/commands/telegram-setup.ts +74 -0
- package/src/commands/telegram-status.ts +89 -0
- package/src/commands/telegram.test.ts +50 -0
- package/src/commands/telegram.ts +44 -0
- package/src/commands/topup.ts +303 -0
- package/src/commands/transfer.test.ts +111 -0
- package/src/commands/transfer.ts +520 -0
- package/src/commands/upgrade.test.ts +137 -0
- package/src/commands/upgrade.ts +690 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.test.ts +96 -0
- package/src/config/render.ts +110 -0
- package/src/index.ts +378 -0
- package/src/sandbox/client.test.ts +251 -0
- package/src/sandbox/client.ts +424 -0
- package/src/ui/app.tsx +677 -0
- package/src/ui/approval-summary.test.ts +154 -0
- package/src/ui/approval-summary.ts +34 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.test.ts +146 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.test.ts +74 -0
- package/src/ui/state.ts +198 -0
- package/src/util/bootstrap-mode.test.ts +40 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.test.ts +190 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/brain-secrets.ts +96 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.test.ts +16 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.test.ts +86 -0
- package/src/util/gateway-spawn.ts +128 -0
- package/src/util/gateway-version.test.ts +113 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.test.ts +116 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.test.ts +60 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.test.ts +77 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.test.ts +53 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.test.ts +227 -0
- 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
|
+
}
|