@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,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
+ MemorySyncManager,
26
+ PermissionDecision,
27
+ PermissionPrompter,
28
+ PermissionRequest,
29
+ PermissionService,
30
+ } from '@promus/core'
31
+ import { applyPerms, applyYolo, newEventId } from '@promus/core'
32
+ import {
33
+ ActiveSessionTracker,
34
+ type ApprovalChoice,
35
+ type BypassCommand,
36
+ type TelegramApprovalBridge,
37
+ type TelegramDispatchInput,
38
+ type TelegramDispatchResult,
39
+ type TelegramRuntimeContext,
40
+ makeApprovalIdFactory,
41
+ parseBypassCommand,
42
+ } from '@promus/plugin-telegram'
43
+ import { summarizeApprovalSubject } from '../ui/approval-summary'
44
+
45
+ export type DispatchUserMessage = (input: TelegramDispatchInput) => Promise<TelegramDispatchResult>
46
+
47
+ /**
48
+ * Mutable callback ref. chat.tsx holds it across boot; we hand the ref into
49
+ * the plugin's runtime context via a closure that defers to the ref's current
50
+ * value at call-time.
51
+ */
52
+ export interface TelegramDispatchSlot {
53
+ current: DispatchUserMessage | null
54
+ }
55
+
56
+ export interface RowSinkRef {
57
+ current: ((text: string) => void) | null
58
+ }
59
+
60
+ export function buildTelegramRuntimeContext(opts: {
61
+ botToken: string
62
+ allowedUserIds: number[]
63
+ agentName: string
64
+ slot: TelegramDispatchSlot
65
+ systemRowSink: RowSinkRef
66
+ }): TelegramRuntimeContext {
67
+ return {
68
+ botToken: opts.botToken,
69
+ allowedUserIds: opts.allowedUserIds,
70
+ agentName: opts.agentName,
71
+ dispatchUserMessage: async input => {
72
+ const cb = opts.slot.current
73
+ if (!cb) {
74
+ return {
75
+ response: 'agent is still booting; try again in a moment.',
76
+ }
77
+ }
78
+ return cb(input)
79
+ },
80
+ onProcessingStart: async (chatId, _msgId) => {
81
+ opts.systemRowSink.current?.(`tg replying to chat ${chatId}`)
82
+ },
83
+ onProcessingEnd: async (chatId, _msgId, ok) => {
84
+ opts.systemRowSink.current?.(
85
+ ok ? `tg reply sent to chat ${chatId}` : `tg reply FAILED to chat ${chatId}`,
86
+ )
87
+ },
88
+ }
89
+ }
90
+
91
+ export interface BuildDispatchDeps {
92
+ activity: ActivityLog
93
+ sync: MemorySyncManager
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
+ }