@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,612 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import {
5
+ cancel,
6
+ confirm,
7
+ intro,
8
+ isCancel,
9
+ log,
10
+ note,
11
+ outro,
12
+ password,
13
+ select,
14
+ spinner,
15
+ text,
16
+ } from '@clack/prompts'
17
+ import {
18
+ type PromusNetwork,
19
+ NETWORK_CHAIN_ID,
20
+ NETWORK_CURRENCY,
21
+ NETWORK_RPC,
22
+ OPERATOR_BLOB_SCOPES,
23
+ type OperatorSessionKeys,
24
+ agentPaths,
25
+ buildOperatorSession,
26
+ defineConfig,
27
+ explorerTokenUrl,
28
+ explorerTxUrl,
29
+ generateAgentWallet,
30
+ getGasPriceWithFloor,
31
+ iNFTAgentId,
32
+ mintAgent,
33
+ placeholderAgentId,
34
+ precomputeAllScopes,
35
+ saveKeystoreLocally,
36
+ uploadAndAnchorKeystore,
37
+ waitForReceiptResilient,
38
+ writeOperatorSession,
39
+ } from '@promus/core'
40
+ import { type Address, type Hex, formatEther, hexToBytes, parseEther } from 'viem'
41
+ import { writeConfigTs } from '../config/render'
42
+ import { withSilencedConsole } from '../util/silence-console'
43
+ import { saveBrainSecrets } from '../util/brain-secrets'
44
+ import { loadTelegramHandoffSecrets } from '../util/telegram-secrets'
45
+ import { estimateCosts, renderCostSummary } from './init/cost'
46
+ import { fundingGate } from './init/funding-gate'
47
+ import { pickOperatorSigner } from './init/operator-picker'
48
+ import { initialWizardState, updateWizardState, writeWizardState } from './init/wizard-state'
49
+
50
+ export async function runInit(opts?: { cwd?: string; resume?: boolean }): Promise<void> {
51
+ const configPath = agentPaths.config
52
+
53
+ intro('promus init')
54
+
55
+ if (existsSync(configPath) && !opts?.resume) {
56
+ const choice = (await select({
57
+ message: `${configPath} exists`,
58
+ options: [
59
+ { value: 'overwrite', label: 'Start fresh (overwrite)' },
60
+ { value: 'cancel', label: 'Cancel' },
61
+ ],
62
+ initialValue: 'cancel',
63
+ })) as 'overwrite' | 'cancel' | symbol
64
+ if (isCancel(choice) || choice === 'cancel') {
65
+ cancel('Aborted.')
66
+ return
67
+ }
68
+ }
69
+
70
+ // ─── Phase A: local prompts (no chain, no wallet) ───────────────────────
71
+
72
+ const network = (await select({
73
+ message: 'Which network?',
74
+ options: [
75
+ { value: 'arbitrum-sepolia' as PromusNetwork, label: 'Arbitrum Sepolia (421614)' },
76
+ { value: 'robinhood-testnet' as PromusNetwork, label: 'Robinhood Chain testnet (46630)' },
77
+ ],
78
+ initialValue: 'arbitrum-sepolia' as PromusNetwork,
79
+ })) as PromusNetwork
80
+ if (isCancel(network)) {
81
+ cancel('Aborted.')
82
+ return
83
+ }
84
+
85
+ // Deploy target is always local on the Arbitrum path.
86
+ const deployTarget = 'local' as const
87
+ const requestedSubname = ''
88
+
89
+ // ─── Brain provider + model picker ──────────────────────────────────────
90
+ const brainProvider = (await select({
91
+ message: 'Which AI provider?',
92
+ options: [
93
+ { value: 'anthropic' as const, label: 'Anthropic (Claude)' },
94
+ { value: 'openai' as const, label: 'OpenAI (GPT)' },
95
+ { value: 'google' as const, label: 'Google (Gemini)' },
96
+ ],
97
+ initialValue: 'anthropic' as const,
98
+ })) as 'anthropic' | 'openai' | 'google' | symbol
99
+ if (isCancel(brainProvider)) {
100
+ cancel('Aborted.')
101
+ return
102
+ }
103
+
104
+ const modelPick = { provider: brainProvider, model: null as string | null }
105
+
106
+ // ─── Brain API key (encrypted at rest) ──────────────────────────────────
107
+ const providerLabel = brainProvider === 'anthropic' ? 'Anthropic' : brainProvider === 'openai' ? 'OpenAI' : 'Google'
108
+ const apiKeyPrompt = (await password({
109
+ message: `${providerLabel} API key (stored encrypted, never in .env)`,
110
+ mask: '*',
111
+ })) as string | symbol
112
+ if (isCancel(apiKeyPrompt) || !apiKeyPrompt) {
113
+ cancel('Aborted.')
114
+ return
115
+ }
116
+
117
+ // ─── Storage backend picker ─────────────────────────────────────────────
118
+ const storageBackend = (await select({
119
+ message: 'Storage backend?',
120
+ options: [
121
+ { value: 'ipfs' as const, label: 'IPFS (Kubo local node)' },
122
+ ],
123
+ initialValue: 'ipfs' as const,
124
+ })) as 'ipfs' | symbol
125
+ if (isCancel(storageBackend)) {
126
+ cancel('Aborted.')
127
+ return
128
+ }
129
+
130
+ let ipfsApiUrl = 'http://127.0.0.1:5001'
131
+ let ipfsGateway = 'http://127.0.0.1:8080/ipfs'
132
+ if (storageBackend === 'ipfs') {
133
+ const customUrl = (await text({
134
+ message: 'IPFS API URL',
135
+ initialValue: 'http://127.0.0.1:5001',
136
+ })) as string | symbol
137
+ if (isCancel(customUrl)) { cancel('Aborted.'); return }
138
+ ipfsApiUrl = customUrl || 'http://127.0.0.1:5001'
139
+
140
+ const customGw = (await text({
141
+ message: 'IPFS gateway URL (trailing /ipfs)',
142
+ initialValue: 'http://127.0.0.1:8080/ipfs',
143
+ })) as string | symbol
144
+ if (isCancel(customGw)) { cancel('Aborted.'); return }
145
+ ipfsGateway = customGw || 'http://127.0.0.1:8080/ipfs'
146
+ }
147
+
148
+ // ─── Phase B: wallet gate ────────────────────────────────────────────────
149
+
150
+ const picked = await pickOperatorSigner({ network })
151
+ if (!picked) return
152
+ const { signer: operator, hint: operatorHint } = picked
153
+
154
+ const sConnect = spinner()
155
+ sConnect.start(`Connecting via ${operator.source}`)
156
+ let operatorAddress: Address
157
+ try {
158
+ operatorAddress = await operator.address()
159
+ sConnect.stop(`operator: ${operatorAddress}`)
160
+ } catch (e) {
161
+ sConnect.stop(`connection failed: ${(e as Error).message.slice(0, 140)}`)
162
+ await operator.close?.()
163
+ return
164
+ }
165
+
166
+ const costs = estimateCosts({
167
+ ledgerSizeOg: 0,
168
+ withSubname: false,
169
+ deployTarget: 'local',
170
+ network,
171
+ })
172
+ note(renderCostSummary(costs), `cost summary (${costs.currency} L2 gas)`)
173
+
174
+ const publicClient = await operator.publicClient(network)
175
+ const operatorBalance = await publicClient.getBalance({ address: operatorAddress })
176
+
177
+ if (operatorBalance < costs.totalOperator) {
178
+ const need = costs.totalOperator - operatorBalance
179
+ note(
180
+ `Operator balance ${formatEther(operatorBalance)} ${costs.currency}, need ${formatEther(need)} ${costs.currency} more.`,
181
+ 'insufficient funds',
182
+ )
183
+ const gate = await fundingGate({
184
+ publicClient,
185
+ operatorAddress,
186
+ requiredOg: costs.totalOperator,
187
+ currency: costs.currency,
188
+ })
189
+ if (gate.kind === 'cancel') {
190
+ await operator.close?.()
191
+ return
192
+ }
193
+ }
194
+
195
+ const proceed = await confirm({ message: 'Proceed?', initialValue: true })
196
+ if (isCancel(proceed) || !proceed) {
197
+ cancel('Aborted.')
198
+ await operator.close?.()
199
+ return
200
+ }
201
+
202
+ // ─── Phase C: execute with Pattern B state tracking ─────────────────────
203
+
204
+ const agent = generateAgentWallet()
205
+ const provisionalAgentId = placeholderAgentId(agent.address)
206
+ const provisional = agentPaths.agent(provisionalAgentId)
207
+ await mkdir(provisional.dir, { recursive: true })
208
+
209
+ await writeWizardState(provisional.dir, {
210
+ ...initialWizardState(agent.address, network),
211
+ })
212
+
213
+ let mintedTokenId: bigint | null = null
214
+ let contractAddress: Address | null = null
215
+
216
+ const sMint = spinner()
217
+ sMint.start(`Minting iNFT on ${network} (keystore slot left as bootstrap until upload)`)
218
+ try {
219
+ const { result, contractAddress: c } = await withSilencedConsole(() =>
220
+ mintAgent({
221
+ network,
222
+ operator,
223
+ agentAddress: agent.address as Address,
224
+ }),
225
+ )
226
+ mintedTokenId = result.tokenId
227
+ contractAddress = c
228
+ await updateWizardState(provisional.dir, draft => {
229
+ draft.steps.mintedTokenId = result.tokenId.toString()
230
+ draft.steps.mintedContract = c
231
+ draft.steps.mintTx = result.txHash
232
+ })
233
+ sMint.stop(
234
+ `iNFT #${result.tokenId.toString()} minted to ${operatorAddress} → ${explorerTxUrl(network, result.txHash)}`,
235
+ )
236
+ } catch (e) {
237
+ sMint.stop(`mint failed: ${(e as Error).message}`)
238
+ await updateWizardState(provisional.dir, draft => {
239
+ draft.lastError = `mint failed: ${(e as Error).message}`
240
+ })
241
+ await operator.close?.()
242
+ return
243
+ }
244
+
245
+ const finalAgentId = iNFTAgentId({ contractAddress: contractAddress!, tokenId: mintedTokenId! })
246
+ const targetDir = agentPaths.agent(finalAgentId).dir
247
+ if (provisional.dir !== targetDir) {
248
+ try {
249
+ await rename(provisional.dir, targetDir)
250
+ } catch (e) {
251
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
252
+ }
253
+ }
254
+ const paths = agentPaths.agent(finalAgentId)
255
+
256
+ // v0.23.1: derive BOTH operator-scope keys (keystore + profile) in parallel
257
+ // up front, then reuse them everywhere. This is the single "two signatures
258
+ // back to back" moment in the wizard: keystore scope (for the encrypted
259
+ // privkey blob) + profile scope (for the operator-private user-partition
260
+ // memory slot). Folding profile derivation into init removes the v0.23.0
261
+ // need for `promus profile init` as a follow-up command.
262
+ const sKeys = spinner()
263
+ sKeys.start('Deriving operator scope keys (may prompt twice: keystore + profile)')
264
+ let operatorKeys: OperatorSessionKeys
265
+ let keystoreKeyBuf: Buffer
266
+ let profileScopeKeyHex: `0x${string}` | undefined
267
+ let brainScopeKeyHex: `0x${string}` | undefined
268
+ try {
269
+ operatorKeys = await precomputeAllScopes(operator, agent.address as Address, [
270
+ OPERATOR_BLOB_SCOPES.PROFILE,
271
+ OPERATOR_BLOB_SCOPES.BRAIN,
272
+ ])
273
+ keystoreKeyBuf = Buffer.from(hexToBytes(operatorKeys.keystore))
274
+ const profileHex = operatorKeys[OPERATOR_BLOB_SCOPES.PROFILE]
275
+ profileScopeKeyHex = profileHex as `0x${string}` | undefined
276
+ brainScopeKeyHex = operatorKeys[OPERATOR_BLOB_SCOPES.BRAIN] as `0x${string}` | undefined
277
+ sKeys.stop('scope keys derived')
278
+ } catch (e) {
279
+ sKeys.stop(`scope key derive failed: ${(e as Error).message.slice(0, 160)}`)
280
+ cancel('Aborted (operator signature required for keystore + profile scopes).')
281
+ await operator.close?.()
282
+ return
283
+ }
284
+
285
+ // Pass the already-derived keystoreKey so saveKeystoreLocally skips
286
+ // signing again. Save BEFORE funding the agent EOA per
287
+ // `feedback-init-must-save-keystore-before-funding.md`.
288
+ const sLocal = spinner()
289
+ sLocal.start('Encrypting agent keystore to operator wallet (local insurance)')
290
+ let encryptedBytes: Uint8Array
291
+ try {
292
+ const saved = await saveKeystoreLocally({
293
+ agentAddress: agent.address as Address,
294
+ agentPrivkey: agent.privkeyHex as Hex,
295
+ cachePath: paths.keystore,
296
+ precomputedKey: keystoreKeyBuf,
297
+ })
298
+ encryptedBytes = saved.bytes
299
+ await updateWizardState(paths.dir, draft => {
300
+ draft.steps.keystoreSaved = true
301
+ })
302
+ sLocal.stop(`keystore saved locally at ${paths.keystore}`)
303
+ } catch (e) {
304
+ sLocal.stop(`local keystore save failed: ${(e as Error).message.slice(0, 120)}`)
305
+ cancel('Aborted before funding (keystore encryption failed).')
306
+ await operator.close?.()
307
+ return
308
+ }
309
+
310
+ // ─── Save encrypted brain secrets ────────────────────────────────────────
311
+ const sBrain = spinner()
312
+ sBrain.start('Encrypting brain secrets (API key + storage config)')
313
+ try {
314
+ const brainKeyBuf = brainScopeKeyHex
315
+ ? Buffer.from(hexToBytes(brainScopeKeyHex))
316
+ : undefined
317
+ await saveBrainSecrets({
318
+ signer: operator,
319
+ agentAddress: agent.address as Address,
320
+ agentId: finalAgentId,
321
+ plaintext: {
322
+ provider: brainProvider,
323
+ apiKey: apiKeyPrompt,
324
+ model: modelPick.model ?? undefined,
325
+ ipfsApiUrl,
326
+ ipfsGateway,
327
+ },
328
+ precomputedKey: brainKeyBuf,
329
+ })
330
+ sBrain.stop('brain secrets encrypted')
331
+ } catch (e) {
332
+ sBrain.stop(`brain secrets save failed: ${(e as Error).message.slice(0, 120)}`)
333
+ // Non-fatal: user can re-run init or set .env as fallback
334
+ }
335
+
336
+ const sFund = spinner()
337
+ const fundingAmount = costs.agentFloat
338
+ sFund.start(`Funding agent ${agent.address} with ${formatEther(fundingAmount)} ${costs.currency}`)
339
+ try {
340
+ const opWc = await operator.walletClient(network)
341
+ const opAccount = opWc.account
342
+ if (!opAccount) throw new Error('walletClient is missing default account')
343
+ const fundGasPrice = await getGasPriceWithFloor(publicClient)
344
+ const fundTx = await withSilencedConsole(() =>
345
+ opWc.sendTransaction({
346
+ to: agent.address as Address,
347
+ value: fundingAmount,
348
+ chain: operator.chain(network),
349
+ account: opAccount,
350
+ maxFeePerGas: fundGasPrice,
351
+ maxPriorityFeePerGas: fundGasPrice,
352
+ }),
353
+ )
354
+ await waitForReceiptResilient(publicClient, fundTx)
355
+ await updateWizardState(paths.dir, draft => {
356
+ draft.steps.agentFundedTx = fundTx
357
+ })
358
+ sFund.stop(`funded (tx ${fundTx})`)
359
+ } catch (e) {
360
+ sFund.stop(`fund failed: ${(e as Error).message}`)
361
+ await operator.close?.()
362
+ return
363
+ }
364
+
365
+ const sPersist = spinner()
366
+ sPersist.start(
367
+ `Uploading keystore to IPFS + anchoring on chain`,
368
+ )
369
+ let keystorePersisted = false
370
+ try {
371
+ const { rootHash, updateTx } = await withSilencedConsole(() =>
372
+ uploadAndAnchorKeystore({
373
+ network,
374
+ agentPrivkey: agent.privkeyHex as Hex,
375
+ tokenId: mintedTokenId!,
376
+ contractAddress: contractAddress!,
377
+ bytes: encryptedBytes,
378
+ }),
379
+ )
380
+ await updateWizardState(paths.dir, draft => {
381
+ draft.steps.keystorePersistedTx = updateTx
382
+ draft.steps.keystoreRootHash = rootHash
383
+ })
384
+ keystorePersisted = true
385
+ sPersist.stop(`keystore anchored (root ${rootHash.slice(0, 12)}…)`)
386
+ } catch (e) {
387
+ sPersist.stop(`keystore upload/anchor failed: ${(e as Error).message.slice(0, 120)}`)
388
+ }
389
+
390
+ if (!keystorePersisted) {
391
+ note(
392
+ [
393
+ `iNFT #${mintedTokenId!.toString()} is minted, agent EOA is funded with ${formatEther(fundingAmount)} ${costs.currency},`,
394
+ `and the encrypted keystore is on disk at ${paths.keystore}.`,
395
+ '',
396
+ `The IPFS upload + chain anchor failed, so this machine has`,
397
+ 'a working agent but no on-chain recovery path yet. The funds at',
398
+ `${agent.address} are NOT stranded; operator wallet ${operatorAddress}`,
399
+ 'can decrypt the local keystore and resume the agent.',
400
+ '',
401
+ 'Re-run `promus init --resume` to retry the storage upload and anchor,',
402
+ 'or proceed with chat using the local keystore (sync will retry on',
403
+ 'every chat turn anyway).',
404
+ ].join('\n'),
405
+ 'storage anchor failed (recoverable)',
406
+ )
407
+ cancel('Aborted before writing config (storage anchor pending).')
408
+ await operator.close?.()
409
+ return
410
+ }
411
+
412
+ // v0.23.1: cache the operator scope keys to `.operator-session` so:
413
+ // - First `promus` chat does NOT re-prompt Touch ID (`gateway-start` will
414
+ // find both keystore + profile scopes already cached and skip
415
+ // re-derivation).
416
+ // - First sync after init can encrypt + anchor the PROFILE slot
417
+ // transparently — operator never needs to run `promus profile init`.
418
+ // requiredScopesForAgent now returns ['keystore', 'promus-profile-v1']
419
+ // because seedStarterMemoryFiles just wrote user/profile.md.
420
+ try {
421
+ const sess = buildOperatorSession({ agent: agent.address as Address, keys: operatorKeys })
422
+ writeOperatorSession(finalAgentId, sess)
423
+ } catch (e) {
424
+ console.warn(`operator-session write skipped: ${(e as Error).message.slice(0, 160)}`)
425
+ }
426
+
427
+ // Seed canonical memory starter files.
428
+ await seedStarterMemoryFiles({
429
+ paths,
430
+ network,
431
+ contractAddress: contractAddress!,
432
+ tokenId: mintedTokenId!,
433
+ agentAddress: agent.address as Address,
434
+ operatorAddress,
435
+ brainProvider: modelPick?.provider ?? null,
436
+ brainModel: modelPick?.model ?? null,
437
+ })
438
+
439
+ // v0.24.4: Phase E (Telegram bot setup) MUST run before Phase 11 (sandbox
440
+ // provision) so the sandbox handoff envelope can ship `telegram-secrets`
441
+ // and the listener boots active. Previously Phase E ran AFTER provision and
442
+ // the sandbox booted with `listeners.telegram: disabled`, forcing the
443
+ // operator to `promus upgrade --in-place` post-init to re-ship secrets.
444
+ let telegramConfigured: { botUsername: string; mode: string } | null = null
445
+ if (mintedTokenId !== null && contractAddress) {
446
+ const tgChoice = await confirm({
447
+ message: 'Configure a Telegram bot for this agent now? (recommended)',
448
+ initialValue: true,
449
+ })
450
+ if (!isCancel(tgChoice) && tgChoice === true) {
451
+ try {
452
+ const { runTelegramStep } = await import('./init/telegram-step')
453
+ const tgResult = await runTelegramStep({
454
+ signer: operator,
455
+ agentId: finalAgentId,
456
+ agentAddress: agent.address as Address,
457
+ configPath,
458
+ // Synthetic partial cfg — caller writes the final cfg below. Pass
459
+ // skipConfigWrite=true so telegram-step doesn't touch disk.
460
+ config: { plugins: [] } as never,
461
+ network,
462
+ skipConfigWrite: true,
463
+ })
464
+ if (tgResult.configured && tgResult.botUsername && tgResult.modeUsed) {
465
+ telegramConfigured = {
466
+ botUsername: tgResult.botUsername,
467
+ mode: tgResult.modeUsed,
468
+ }
469
+ // v0.24.3: append TELEGRAM key to `.operator-session` so the gateway
470
+ // daemon auto-spawns on first chat without re-prompting Touch ID.
471
+ if (tgResult.telegramScopeKeyHex) {
472
+ try {
473
+ const sess = buildOperatorSession({
474
+ agent: agent.address as Address,
475
+ keys: {
476
+ ...operatorKeys,
477
+ [OPERATOR_BLOB_SCOPES.TELEGRAM]: tgResult.telegramScopeKeyHex,
478
+ },
479
+ })
480
+ writeOperatorSession(finalAgentId, sess)
481
+ } catch (e) {
482
+ note(
483
+ `operator-session rewrite skipped: ${(e as Error).message.slice(0, 160)}\nRun \`promus telegram setup\` later to re-derive the TG scope key.`,
484
+ 'telegram (non-fatal)',
485
+ )
486
+ }
487
+ }
488
+ }
489
+ } catch (e) {
490
+ note(
491
+ `Telegram step failed: ${(e as Error).message.slice(0, 200)}\nIdentity + iNFT are safe. Re-run \`promus telegram setup\` later.`,
492
+ 'non-fatal',
493
+ )
494
+ }
495
+ }
496
+ }
497
+
498
+ // Load TG handoff secrets into memory for the sandbox envelope. Skipped if
499
+ // TG wasn't configured this run. The shape is exactly what the harness
500
+ // expects inside the secondary ECIES envelope (botToken + allowedUserIds +
501
+ // optional pairingApproved). Errors are non-fatal: TG is opt-in.
502
+ let telegramHandoff: Awaited<ReturnType<typeof loadTelegramHandoffSecrets>> = undefined
503
+ if (telegramConfigured && mintedTokenId !== null && contractAddress) {
504
+ telegramHandoff = await loadTelegramHandoffSecrets({
505
+ signer: operator,
506
+ agentAddress: agent.address as Address,
507
+ contractAddress,
508
+ tokenId: mintedTokenId,
509
+ onNotice: msg => note(msg, 'telegram handoff (non-fatal)'),
510
+ })
511
+ }
512
+
513
+ // ─── Write final config ─────────────────────────────────────────────────
514
+
515
+ const cfg = defineConfig({
516
+ identity: {
517
+ iNFT:
518
+ mintedTokenId !== null && contractAddress
519
+ ? {
520
+ contract: contractAddress,
521
+ tokenId: mintedTokenId.toString(),
522
+ network,
523
+ }
524
+ : null,
525
+ operator: operatorAddress,
526
+ agent: agent.address,
527
+ },
528
+ network,
529
+ storage: { network },
530
+ brain: {
531
+ provider: modelPick?.provider ?? null,
532
+ model: modelPick?.model ?? null,
533
+ },
534
+ plugins: telegramConfigured
535
+ ? ['onchain', 'comms', 'system', 'telegram']
536
+ : ['onchain', 'comms', 'system'],
537
+ tools: {},
538
+ imports: { claudeCode: true },
539
+ operator: operatorHint,
540
+ deployTarget: 'local' as const,
541
+ })
542
+ await writeConfigTs(configPath, cfg, {
543
+ header: '// Regenerated by `promus init`. Edit freely; type-safe.',
544
+ })
545
+
546
+ await operator.close?.()
547
+
548
+ // ─── Phase D: summary ───────────────────────────────────────────────────
549
+
550
+ const lines = [
551
+ '',
552
+ ` agent id ${finalAgentId}`,
553
+ ` agent EOA ${agent.address}`,
554
+ ` operator ${operatorAddress} (source: ${operatorHint.source})`,
555
+ ` network ${network} (${NETWORK_RPC[network]})`,
556
+ ` chain id ${NETWORK_CHAIN_ID[network]}`,
557
+ ` config ${configPath}`,
558
+ ` keystore on IPFS (cached at ${paths.keystore})`,
559
+ ]
560
+ if (mintedTokenId !== null && contractAddress) {
561
+ lines.push(` iNFT #${mintedTokenId.toString()} at ${contractAddress}`)
562
+ lines.push(` ${explorerTokenUrl(network, contractAddress, mintedTokenId)}`)
563
+ }
564
+ if (modelPick) lines.push(` brain ${modelPick.model ?? modelPick.provider}`)
565
+ if (telegramConfigured) {
566
+ lines.push(` bot @${telegramConfigured.botUsername} (mode: ${telegramConfigured.mode})`)
567
+ }
568
+ const nextSteps = telegramConfigured
569
+ ? 'Next: `promus` to chat · DM the bot on Telegram · `promus status` for health'
570
+ : 'Next: `promus` to chat · `promus telegram setup` for the bot · `promus status` for health'
571
+ lines.push('', nextSteps)
572
+ outro(lines.join('\n'))
573
+ }
574
+
575
+ interface SeedStarterOpts {
576
+ paths: ReturnType<typeof agentPaths.agent>
577
+ network: PromusNetwork
578
+ contractAddress: Address
579
+ tokenId: bigint
580
+ agentAddress: Address
581
+ operatorAddress: Address
582
+ brainProvider: string | null
583
+ brainModel: string | null
584
+ }
585
+
586
+ /**
587
+ * Seed `MEMORY.md`, `/agent/identity.md`, `/agent/persona.md`, and
588
+ * `/user/profile.md` immediately after mint so the per-turn sync manager
589
+ * has real content for the identity / persona / memory-index slots on the
590
+ * first chat turn.
591
+ */
592
+ async function seedStarterMemoryFiles(opts: SeedStarterOpts): Promise<void> {
593
+ const memDir = opts.paths.memoryDir
594
+ const agentMem = `${memDir}/agent`
595
+ const userMem = `${memDir}/user`
596
+ await mkdir(agentMem, { recursive: true })
597
+ await mkdir(userMem, { recursive: true })
598
+
599
+ const now = new Date().toISOString().slice(0, 10)
600
+ const identity = `---\nname: identity\ndescription: Auto-written agent identity facts.\ntype: agent-identity\n---\n# Promus identity\n\n- Name: promus\n- iNFT: #${opts.tokenId.toString()} at ${opts.contractAddress} (${opts.network})\n- Agent EOA: ${opts.agentAddress}\n- Operator: ${opts.operatorAddress}\n- Minted: ${now}\n${opts.brainProvider ? `- Brain provider: ${opts.brainProvider}\n` : ''}${opts.brainModel ? `- Brain model: ${opts.brainModel}\n` : ''}`
601
+ const persona = `---\nname: persona\ndescription: Voice + behavior style.\ntype: agent-persona\n---\n# Persona\n\nI am Promus, a sovereign on-chain agent on Arbitrum. I anchor my state on chain every turn, decrypt my keystore via my operator wallet at session start, and reason with my configured AI provider. I am direct, concise, and factual.\n`
602
+ const profile =
603
+ '---\nname: profile\ndescription: User profile (operator-scoped, never anchored on chain).\ntype: user\n---\n# User profile\n\n(empty, fills as we chat)\n'
604
+
605
+ await writeFile(join(agentMem, 'identity.md'), identity, 'utf8')
606
+ await writeFile(join(agentMem, 'persona.md'), persona, 'utf8')
607
+ await writeFile(join(userMem, 'profile.md'), profile, 'utf8')
608
+
609
+ // Seed an empty MEMORY.md so per-turn sync has something to anchor and the
610
+ // brain's first turn sees a parseable index.
611
+ await writeFile(opts.paths.memoryIndex, '# Promus Memory Index\n\n', 'utf8')
612
+ }