lyra-ai-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +39 -0
  2. package/bin/lyra +11 -0
  3. package/package.json +65 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/chat-telegram.ts +398 -0
  6. package/src/commands/chat.tsx +1220 -0
  7. package/src/commands/demo.ts +177 -0
  8. package/src/commands/gateway-logs.ts +49 -0
  9. package/src/commands/gateway-run.ts +42 -0
  10. package/src/commands/gateway-start.ts +89 -0
  11. package/src/commands/gateway-status.ts +73 -0
  12. package/src/commands/gateway-stop.ts +133 -0
  13. package/src/commands/gateway.ts +101 -0
  14. package/src/commands/init/model-picker.ts +17 -0
  15. package/src/commands/init.ts +153 -0
  16. package/src/commands/logs.ts +37 -0
  17. package/src/commands/model.ts +48 -0
  18. package/src/commands/pairing-approve.ts +65 -0
  19. package/src/commands/pairing-clear.ts +39 -0
  20. package/src/commands/pairing-list.ts +55 -0
  21. package/src/commands/pairing-revoke.ts +49 -0
  22. package/src/commands/pairing.test.ts +88 -0
  23. package/src/commands/pairing.ts +81 -0
  24. package/src/commands/status.ts +84 -0
  25. package/src/commands/telegram-remove.ts +45 -0
  26. package/src/commands/telegram-setup.ts +84 -0
  27. package/src/commands/telegram-status.ts +48 -0
  28. package/src/commands/telegram.test.ts +50 -0
  29. package/src/commands/telegram.ts +44 -0
  30. package/src/commands/whoami.ts +62 -0
  31. package/src/config/load.ts +35 -0
  32. package/src/config/render.test.ts +96 -0
  33. package/src/config/render.ts +99 -0
  34. package/src/index.ts +147 -0
  35. package/src/ui/app.tsx +719 -0
  36. package/src/ui/approval-summary.test.ts +132 -0
  37. package/src/ui/approval-summary.ts +28 -0
  38. package/src/ui/markdown-parse.ts +219 -0
  39. package/src/ui/markdown.test.ts +146 -0
  40. package/src/ui/markdown.tsx +37 -0
  41. package/src/ui/state.test.ts +74 -0
  42. package/src/ui/state.ts +176 -0
  43. package/src/util/bootstrap-mode.test.ts +40 -0
  44. package/src/util/bootstrap-mode.ts +25 -0
  45. package/src/util/bootstrap-progress-box.test.ts +190 -0
  46. package/src/util/bootstrap-progress-box.ts +378 -0
  47. package/src/util/cli-version.ts +28 -0
  48. package/src/util/format.test.ts +16 -0
  49. package/src/util/format.ts +11 -0
  50. package/src/util/gateway-spawn.test.ts +86 -0
  51. package/src/util/gateway-spawn.ts +125 -0
  52. package/src/util/gateway-version.test.ts +113 -0
  53. package/src/util/gateway-version.ts +154 -0
  54. package/src/util/github-releases.test.ts +116 -0
  55. package/src/util/github-releases.ts +79 -0
  56. package/src/util/ref-resolver.test.ts +77 -0
  57. package/src/util/ref-resolver.ts +55 -0
  58. package/src/util/silence-console.test.ts +53 -0
  59. package/src/util/silence-console.ts +40 -0
  60. package/src/util/sui-runtime.ts +74 -0
  61. package/src/util/telegram-secrets.test.ts +104 -0
  62. package/src/util/telegram-secrets.ts +99 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * `lyra demo` — walk the guarded pipeline end to end using the lyra-plugin-onchain
3
+ * tools and the deterministic policy engine.
4
+ *
5
+ * Steps:
6
+ * 1. policy.show — print the active fund-control policy.
7
+ * 2. blocked over-cap send — evaluatePolicy on an amount above the per-tx cap;
8
+ * demonstrates a BLOCKED unsafe action (no network).
9
+ * 3. policy.create — publish an on-chain lyra::policy AgentPolicy (--yes)
10
+ * 4. sui.send — a small in-cap transfer to self (--yes)
11
+ * 5. walrus.store — store the run receipt durably on Walrus (--yes)
12
+ *
13
+ * Steps 3–5 move value / write on-chain, so they run ONLY with `--yes`. The
14
+ * default run is read-only + deterministic: it proves the policy blocks an
15
+ * over-cap action and explains what the write path would do.
16
+ */
17
+
18
+ import { formatSui, getSuiBalanceMist } from 'lyra-core'
19
+ import onchainPlugin, {
20
+ type OnchainRuntimeContext,
21
+ type SuiPolicy,
22
+ evaluatePolicy,
23
+ makeSuiClient,
24
+ policyFromEnv,
25
+ suiToMist,
26
+ } from 'lyra-plugin-onchain'
27
+ import { findAndLoadConfig } from '../config/load'
28
+ import { buildOnchainContext, loadAgentFromEnv } from '../util/sui-runtime'
29
+
30
+ const SUI_TYPE = '0x2::sui::SUI'
31
+
32
+ export interface DemoOpts {
33
+ yes?: boolean
34
+ }
35
+
36
+ interface DemoTool {
37
+ name: string
38
+ handler: (args: unknown) => Promise<unknown>
39
+ }
40
+
41
+ /**
42
+ * Drive the plugin's own `register` against a minimal collector so we get the
43
+ * exact ToolDefs the chat agent uses — without reaching past the public API.
44
+ */
45
+ function collectOnchainTools(onchain: OnchainRuntimeContext): Map<string, DemoTool> {
46
+ const tools = new Map<string, DemoTool>()
47
+ const ctx = {
48
+ onchain,
49
+ registerTool: (t: DemoTool) => tools.set(t.name, t),
50
+ }
51
+ onchainPlugin.register(ctx as unknown as Parameters<typeof onchainPlugin.register>[0])
52
+ return tools
53
+ }
54
+
55
+ export async function runDemo(opts: DemoOpts = {}): Promise<void> {
56
+ const found = await findAndLoadConfig()
57
+ if (!found) {
58
+ console.log('No lyra.config.ts found. Run `lyra init` first.')
59
+ process.exit(1)
60
+ }
61
+ const { config } = found
62
+
63
+ const agent = loadAgentFromEnv()
64
+ if (!agent) {
65
+ console.log('No LYRA_AGENT_KEY set. Run `lyra init` first.')
66
+ process.exit(1)
67
+ }
68
+
69
+ console.log(`lyra demo — agent ${agent.address} on ${config.network}\n`)
70
+
71
+ const onchain = buildOnchainContext({
72
+ agent,
73
+ network: config.network,
74
+ agentDir: found.path,
75
+ brainProvider: config.brain.provider,
76
+ brainModel: config.brain.model,
77
+ })
78
+ const tools = collectOnchainTools(onchain)
79
+
80
+ // ── Step 1: policy.show ────────────────────────────────────────────────
81
+ console.log('1) policy.show')
82
+ const policy = policyFromEnv()
83
+ if (!policy) {
84
+ console.log(
85
+ ' (no LYRA_POLICY_* configured — set LYRA_POLICY_MAX_PER_TX_SUI to bound the agent)\n',
86
+ )
87
+ } else {
88
+ console.log(` ${describePolicy(policy)}\n`)
89
+ }
90
+
91
+ // ── Step 2: blocked over-cap send (deterministic, no network) ──────────
92
+ console.log('2) blocked over-cap send (policy enforcement)')
93
+ if (policy?.maxMistPerTx !== undefined) {
94
+ const overCap = policy.maxMistPerTx + (suiToMist('1') ?? 0n) // 1 SUI over the cap
95
+ const verdict = evaluatePolicy(
96
+ {
97
+ kind: 'transfer',
98
+ coinType: SUI_TYPE,
99
+ amountMist: overCap,
100
+ to: agent.address,
101
+ protocol: 'transfer',
102
+ },
103
+ policy,
104
+ )
105
+ if (!verdict.allowed) {
106
+ console.log(
107
+ ` send ${formatSui(overCap)} SUI → BLOCKED ✓ (${verdict.violations.join('; ')})\n`,
108
+ )
109
+ } else {
110
+ console.log(` send ${formatSui(overCap)} SUI was allowed (cap not enforced?)\n`)
111
+ }
112
+ } else {
113
+ console.log(' skipped: no per-tx cap configured (set LYRA_POLICY_MAX_PER_TX_SUI)\n')
114
+ }
115
+
116
+ // ── Live balance (read-only) ────────────────────────────────────────────
117
+ try {
118
+ const client = makeSuiClient(config.network)
119
+ const mist = await getSuiBalanceMist(client, agent.address)
120
+ console.log(` agent SUI balance: ${formatSui(mist)} SUI\n`)
121
+ } catch {
122
+ // RPC unavailable — fine, the rest is descriptive.
123
+ }
124
+
125
+ // ── Steps 3–5: write path ────────────────────────────────────────────────
126
+ if (!opts.yes) {
127
+ console.log('3-5) write path (policy.create → sui.send → walrus.store)')
128
+ console.log(' dry run. Re-run `lyra demo --yes` to execute the on-chain steps:')
129
+ console.log(' • policy.create — publish a shared lyra::policy AgentPolicy')
130
+ console.log(
131
+ ' • sui.send — a small in-cap transfer to self (policy + simulate + execute)',
132
+ )
133
+ console.log(' • walrus.store — store the run receipt durably on Walrus')
134
+ return
135
+ }
136
+
137
+ console.log('3) policy.create')
138
+ await runTool(tools, 'policy.create', { budgetSui: '1', maxPerTxSui: '0.1', maxSlippageBps: 100 })
139
+
140
+ console.log('4) sui.send (in-cap, to self)')
141
+ await runTool(tools, 'sui.send', { to: agent.address, amount: '0.001' })
142
+
143
+ console.log('5) walrus.store (run receipt)')
144
+ await runTool(tools, 'walrus.store', {
145
+ content: JSON.stringify({ demo: 'lyra', agent: agent.address, ts: Date.now() }),
146
+ })
147
+ }
148
+
149
+ async function runTool(
150
+ tools: Map<string, DemoTool>,
151
+ name: string,
152
+ args: Record<string, unknown>,
153
+ ): Promise<void> {
154
+ const tool = tools.get(name)
155
+ if (!tool) {
156
+ console.log(` tool ${name} not registered; skipped`)
157
+ console.log('')
158
+ return
159
+ }
160
+ try {
161
+ const res = (await tool.handler(args)) as { ok?: boolean; error?: string; data?: unknown }
162
+ if (res.ok === false) console.log(` ${name} failed: ${res.error}`)
163
+ else console.log(` ${name} ok: ${JSON.stringify(res.data)}`)
164
+ } catch (e) {
165
+ console.log(` ${name} threw: ${(e as Error).message.slice(0, 200)}`)
166
+ }
167
+ console.log('')
168
+ }
169
+
170
+ function describePolicy(p: SuiPolicy): string {
171
+ const parts: string[] = []
172
+ if (p.maxMistPerTx !== undefined) parts.push(`maxPerTx=${formatSui(p.maxMistPerTx)} SUI`)
173
+ if (p.autoMaxMistPerTx !== undefined) parts.push(`autoMax=${formatSui(p.autoMaxMistPerTx)} SUI`)
174
+ if (p.autonomy) parts.push(`autonomy=${p.autonomy}`)
175
+ if (p.readOnly) parts.push('READ-ONLY')
176
+ return parts.length ? parts.join(', ') : '(no caps set)'
177
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `lyra gateway logs [--tail N] [-f]` — tail the gateway log.
3
+ *
4
+ * v0.19.x: gateway daemon logs to stdout/stderr only (inherited by `gateway run`
5
+ * or backgrounded by `gateway start`). v0.19.3 wires a log file at
6
+ * `~/.lyra/agents/<id>/gateway.log` for tailing. Until then, this command
7
+ * informs the user where to look.
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import { existsSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { agentPaths, placeholderAgentId } from 'lyra-core'
14
+ import { findAndLoadConfig } from '../config/load'
15
+
16
+ export interface GatewayLogsOpts {
17
+ agentId?: string
18
+ tail: number
19
+ follow: boolean
20
+ }
21
+
22
+ export async function runGatewayLogs(opts: GatewayLogsOpts): Promise<void> {
23
+ let agentId = opts.agentId
24
+ if (!agentId) {
25
+ const found = await findAndLoadConfig()
26
+ if (!found?.config) {
27
+ console.error('lyra gateway logs: no lyra.config.ts and no --agent provided')
28
+ process.exit(1)
29
+ }
30
+ const agentEoa = found.config.identity.agent
31
+ if (!agentEoa) {
32
+ console.error('lyra gateway logs: config has no agent EOA; run `lyra init` first')
33
+ process.exit(1)
34
+ }
35
+ agentId = placeholderAgentId(agentEoa)
36
+ }
37
+ const logFile = join(agentPaths.agent(agentId).dir, 'gateway.log')
38
+ if (!existsSync(logFile)) {
39
+ console.log(`gateway log not found at ${logFile}`)
40
+ console.log('v0.19.x: gateway daemon logs to stdout when run via `lyra gateway run`.')
41
+ console.log(
42
+ 'Background it with: nohup bun packages/gateway/bin/lyra-gateway-local > ~/lyra-logs/gateway.log 2>&1 &',
43
+ )
44
+ return
45
+ }
46
+ const args = ['-n', String(opts.tail), ...(opts.follow ? ['-f'] : []), logFile]
47
+ const proc = spawn('tail', args, { stdio: 'inherit' })
48
+ proc.on('exit', code => process.exit(code ?? 0))
49
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `lyra gateway run` — foreground daemon (blocks; Ctrl+C to stop).
3
+ *
4
+ * Spawns `lyra-gateway-local` (the bin in lyra-gateway) with
5
+ * inherit stdio so the user sees logs live. Reads operator-session for the
6
+ * cached AES keys; fails loud if no session exists ("run lyra gateway start
7
+ * first").
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import { join } from 'node:path'
12
+ import { resolveLocalBin } from '../util/gateway-spawn'
13
+
14
+ export interface GatewayRunOpts {
15
+ agentId?: string
16
+ }
17
+
18
+ export async function runGatewayForeground(opts: GatewayRunOpts): Promise<void> {
19
+ const env = { ...process.env }
20
+ if (opts.agentId) env.LYRA_AGENT_ID = opts.agentId
21
+ // Default LYRA_CONFIG to ~/.lyra/config.ts if not already set.
22
+ if (!env.LYRA_CONFIG) {
23
+ env.LYRA_CONFIG = join(env.HOME ?? '', '.lyra', 'config.ts')
24
+ }
25
+
26
+ const localBin = resolveLocalBin()
27
+ const proc = spawn('bun', [localBin], {
28
+ env,
29
+ stdio: 'inherit',
30
+ })
31
+ proc.on('exit', code => process.exit(code ?? 0))
32
+ proc.on('error', err => {
33
+ console.error(`lyra gateway run: spawn failed — ${err.message}`)
34
+ process.exit(1)
35
+ })
36
+
37
+ const forwardSignal = (sig: NodeJS.Signals): void => {
38
+ if (!proc.killed) proc.kill(sig)
39
+ }
40
+ process.on('SIGINT', () => forwardSignal('SIGINT'))
41
+ process.on('SIGTERM', () => forwardSignal('SIGTERM'))
42
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `lyra gateway start` — fork the gateway daemon detached.
3
+ *
4
+ * On Sui the agent key comes from the `LYRA_AGENT_KEY` env var, so there is no
5
+ * operator wallet, Touch ID, or operator-session to derive. The daemon reads
6
+ * the same env the CLI runs in.
7
+ *
8
+ * Flow:
9
+ * 1. Load config from ~/.lyra/config.ts
10
+ * 2. Resolve agentId (override via --agent or derived from LYRA_AGENT_KEY)
11
+ * 3. Check if gateway already running (socket present). If yes, error.
12
+ * 4. Spawn the gateway daemon detached + wait for socket to bind.
13
+ * 5. Print pid + socket path.
14
+ */
15
+
16
+ import { existsSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { spinner } from '@clack/prompts'
19
+ import { agentPaths, placeholderAgentId } from 'lyra-core'
20
+ import { findAndLoadConfig } from '../config/load'
21
+ import { spawnGatewayDaemon } from '../util/gateway-spawn'
22
+ import { loadAgentFromEnv } from '../util/sui-runtime'
23
+
24
+ export interface GatewayStartOpts {
25
+ agentId?: string
26
+ }
27
+
28
+ export async function runGatewayStart(opts: GatewayStartOpts): Promise<void> {
29
+ const found = await findAndLoadConfig()
30
+ if (!found?.config) {
31
+ console.error('lyra gateway start: no lyra.config.ts found in cwd or ~/.lyra/')
32
+ process.exit(1)
33
+ }
34
+ const config = found.config
35
+
36
+ const agent = loadAgentFromEnv()
37
+ const agentAddress = agent?.address ?? config.identity.agent
38
+ if (!agentAddress) {
39
+ console.error(
40
+ 'lyra gateway start: no LYRA_AGENT_KEY set and no agent in config. Run `lyra init`.',
41
+ )
42
+ process.exit(1)
43
+ }
44
+ const agentId = opts.agentId ?? placeholderAgentId(agentAddress)
45
+ const paths = agentPaths.agent(agentId)
46
+ const socketPath = join(paths.dir, 'gateway.sock')
47
+
48
+ // If the socket exists, check for version drift; auto-restart on mismatch so
49
+ // operators don't have to `lyra gateway restart` after upgrading the binary.
50
+ if (existsSync(socketPath)) {
51
+ const { createHash } = await import('node:crypto')
52
+ const { homedir } = await import('node:os')
53
+ const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
54
+ const lockFile = join(homedir(), '.lyra', 'locks', `lyra-gateway-${identityHash}.lock`)
55
+ const { ensureGatewayVersionMatchesCli } = await import('../util/gateway-version')
56
+ const drift = await ensureGatewayVersionMatchesCli({ socketPath, lockFile })
57
+ if (drift.action === 'ok' || drift.action === 'no-cli-version') {
58
+ console.error(
59
+ `lyra gateway start: socket already exists at ${socketPath} — gateway may be running (version ${drift.daemonVersion ?? 'unknown'}). Try \`lyra gateway stop\` first.`,
60
+ )
61
+ process.exit(1)
62
+ }
63
+ console.log(`note: ${drift.note}`)
64
+ }
65
+
66
+ // Spawn gateway daemon detached. The daemon reads LYRA_AGENT_KEY + LYRA_*
67
+ // from the inherited environment; no key derivation happens here.
68
+ const sBoot = spinner()
69
+ sBoot.start(`Spawning gateway daemon (agent=${agentId.slice(0, 8)}…)`)
70
+
71
+ const result = await spawnGatewayDaemon({
72
+ agentId,
73
+ configPath: found.path ?? '',
74
+ socketPath,
75
+ timeoutMs: 10_000,
76
+ })
77
+ if (result.ready) {
78
+ sBoot.stop(`gateway running pid=${result.pid} socket=${socketPath}`)
79
+ console.log('stop with: lyra gateway stop')
80
+ console.log('logs: lyra gateway logs -f')
81
+ } else {
82
+ const reason = result.reason ?? 'unknown'
83
+ const detail = result.error ? `: ${result.error}` : ''
84
+ sBoot.stop(
85
+ `gateway did not bind socket within 10s (reason=${reason} pid=${result.pid ?? '?'})${detail}; check above output`,
86
+ )
87
+ process.exit(1)
88
+ }
89
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `lyra gateway status` — show PID, uptime, socket path, lock state,
3
+ * operator-session freshness.
4
+ */
5
+
6
+ import { createHash } from 'node:crypto'
7
+ import { existsSync, readFileSync, statSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { agentPaths, placeholderAgentId } from 'lyra-core'
11
+ import { findAndLoadConfig } from '../config/load'
12
+
13
+ export interface GatewayStatusOpts {
14
+ agentId?: string
15
+ }
16
+
17
+ function fmtAge(ms: number): string {
18
+ const s = Math.floor(ms / 1000)
19
+ if (s < 60) return `${s}s`
20
+ const m = Math.floor(s / 60)
21
+ if (m < 60) return `${m}m`
22
+ const h = Math.floor(m / 60)
23
+ return `${h}h${m % 60}m`
24
+ }
25
+
26
+ export async function runGatewayStatus(opts: GatewayStatusOpts): Promise<void> {
27
+ let agentId = opts.agentId
28
+ if (!agentId) {
29
+ const found = await findAndLoadConfig()
30
+ if (!found?.config) {
31
+ console.error('lyra gateway status: no lyra.config.ts and no --agent provided')
32
+ process.exit(1)
33
+ }
34
+ const agentEoa = found.config.identity.agent
35
+ if (!agentEoa) {
36
+ console.error('lyra gateway status: config has no agent EOA; run `lyra init` first')
37
+ process.exit(1)
38
+ }
39
+ agentId = placeholderAgentId(agentEoa)
40
+ }
41
+ const paths = agentPaths.agent(agentId)
42
+ const socketPath = join(paths.dir, 'gateway.sock')
43
+ const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
44
+ const lockFile = join(homedir(), '.lyra', 'locks', `lyra-gateway-${identityHash}.lock`)
45
+
46
+ console.log(`agent: ${agentId}`)
47
+ console.log(`socket: ${socketPath} ${existsSync(socketPath) ? '(present)' : '(absent)'}`)
48
+ console.log(`lock: ${lockFile} ${existsSync(lockFile) ? '(present)' : '(absent)'}`)
49
+
50
+ // PID + uptime via lock file.
51
+ if (existsSync(lockFile)) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(lockFile, 'utf8')) as { pid?: number }
54
+ if (typeof parsed.pid === 'number') {
55
+ let alive = false
56
+ try {
57
+ process.kill(parsed.pid, 0)
58
+ alive = true
59
+ } catch {
60
+ /* dead */
61
+ }
62
+ const stat = statSync(lockFile)
63
+ const ageMs = Date.now() - stat.mtimeMs
64
+ console.log(`pid: ${parsed.pid} ${alive ? '(alive)' : '(dead — stale lock)'}`)
65
+ console.log(`lock-age: ${fmtAge(ageMs)}`)
66
+ }
67
+ } catch {
68
+ console.log('pid: (lock file unreadable)')
69
+ }
70
+ } else {
71
+ console.log('pid: (not running)')
72
+ }
73
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `lyra gateway stop` — SIGTERM the running gateway daemon via the lock
3
+ * file's PID. Falls through to SIGKILL after a 5s grace period.
4
+ */
5
+
6
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { agentPaths, placeholderAgentId } from 'lyra-core'
10
+ import { findAndLoadConfig } from '../config/load'
11
+
12
+ export interface GatewayStopOpts {
13
+ agentId?: string
14
+ }
15
+
16
+ function lockPath(_agentId: string): string {
17
+ // Mirror packages/core/src/locks.ts — `~/.lyra/locks/<scope>-<sha256(identity).slice(0,16)>.lock`
18
+ // For 'lyra-gateway' scope. We compute the same hash as the lock module.
19
+ // Easiest: read all lock files and find one matching the agent.
20
+ return join(homedir(), '.lyra', 'locks')
21
+ }
22
+
23
+ function findGatewayLock(agentId: string): string | null {
24
+ // The lock filename embeds sha256(agentId).slice(0, 16). Compute it.
25
+ const { createHash } = require('node:crypto')
26
+ const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
27
+ const lockFile = join(lockPath(agentId), `lyra-gateway-${identityHash}.lock`)
28
+ return existsSync(lockFile) ? lockFile : null
29
+ }
30
+
31
+ export async function runGatewayStop(opts: GatewayStopOpts): Promise<void> {
32
+ let agentId = opts.agentId
33
+ if (!agentId) {
34
+ const found = await findAndLoadConfig()
35
+ if (!found?.config) {
36
+ console.error('lyra gateway stop: no lyra.config.ts and no --agent provided')
37
+ process.exit(1)
38
+ }
39
+ const agentEoa = found.config.identity?.agent ?? null
40
+ if (!agentEoa) {
41
+ console.error('lyra gateway stop: config has no agent EOA; run `lyra init` first')
42
+ process.exit(1)
43
+ }
44
+ agentId = placeholderAgentId(agentEoa)
45
+ const label = `agent ${agentId.slice(0, 8)}…`
46
+ const eoaLabel = ` (EOA ${agentEoa.slice(0, 6)}…${agentEoa.slice(-4)})`
47
+ const configPath = found.path ?? '<unknown>'
48
+ console.log(`lyra gateway stop → ${label}${eoaLabel}`)
49
+ console.log(` config: ${configPath}`)
50
+ console.log(
51
+ ' if this is not the agent you meant, set LYRA_ROOT or pass --agent <id> before re-running.',
52
+ )
53
+ }
54
+ const lockFile = findGatewayLock(agentId)
55
+ if (!lockFile) {
56
+ console.log(`gateway not running (no lock at ${lockPath(agentId)})`)
57
+ return
58
+ }
59
+ let pid: number
60
+ try {
61
+ const raw = readFileSync(lockFile, 'utf8').trim()
62
+ // Lock files are JSON with shape `{pid, scope, identityHash, expiresAt}`.
63
+ const parsed = JSON.parse(raw) as { pid?: number }
64
+ if (typeof parsed.pid !== 'number') {
65
+ console.error('lyra gateway stop: lock file has no pid field')
66
+ process.exit(1)
67
+ }
68
+ pid = parsed.pid
69
+ } catch (e) {
70
+ console.error(`lyra gateway stop: lock file unreadable — ${(e as Error).message}`)
71
+ process.exit(1)
72
+ }
73
+
74
+ // Verify the PID is alive.
75
+ try {
76
+ process.kill(pid, 0)
77
+ } catch {
78
+ console.log(`gateway not running (stale lock pid=${pid}); cleaning up`)
79
+ try {
80
+ unlinkSync(lockFile)
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ return
85
+ }
86
+
87
+ // Send SIGTERM, wait up to 5s, then SIGKILL.
88
+ console.log(`stopping gateway pid=${pid} ...`)
89
+ try {
90
+ process.kill(pid, 'SIGTERM')
91
+ } catch (e) {
92
+ console.error(`lyra gateway stop: SIGTERM failed — ${(e as Error).message}`)
93
+ process.exit(1)
94
+ }
95
+
96
+ const start = Date.now()
97
+ while (Date.now() - start < 5_000) {
98
+ try {
99
+ process.kill(pid, 0)
100
+ } catch {
101
+ console.log(`gateway stopped pid=${pid}`)
102
+ // Lock file is auto-removed by daemon's shutdown handler. Belt + suspenders:
103
+ try {
104
+ if (existsSync(lockFile)) unlinkSync(lockFile)
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ // Also clean up the socket file in case the daemon didn't.
109
+ const socketPath = join(agentPaths.agent(agentId).dir, 'gateway.sock')
110
+ try {
111
+ if (existsSync(socketPath)) unlinkSync(socketPath)
112
+ } catch {
113
+ /* ignore */
114
+ }
115
+ return
116
+ }
117
+ await new Promise(r => setTimeout(r, 200))
118
+ }
119
+
120
+ console.log('gateway did not exit in 5s; sending SIGKILL')
121
+ try {
122
+ process.kill(pid, 'SIGKILL')
123
+ } catch (e) {
124
+ console.error(`lyra gateway stop: SIGKILL failed — ${(e as Error).message}`)
125
+ process.exit(1)
126
+ }
127
+ try {
128
+ if (existsSync(lockFile)) unlinkSync(lockFile)
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ console.log(`gateway force-killed pid=${pid}`)
133
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `lyra gateway <sub>` argv dispatcher.
3
+ *
4
+ * Subs:
5
+ * run — foreground daemon (blocks; Ctrl+C to stop). Uses operator-session.
6
+ * start — interactive: prompt Touch ID, write operator-session, fork daemon.
7
+ * stop — SIGTERM the running daemon via the lock file's PID.
8
+ * restart — stop + start.
9
+ * status — show PID, uptime, socket path, lock state.
10
+ * logs — tail the gateway log (--tail N, --follow).
11
+ *
12
+ * v0.19.x scope: run + start + stop + status. restart/logs/install/setup ship
13
+ * in v0.19.3 alongside launchd plist generator.
14
+ */
15
+
16
+ export type GatewaySub = 'run' | 'start' | 'stop' | 'restart' | 'status' | 'logs'
17
+
18
+ export interface ParsedGatewayArgs {
19
+ sub: GatewaySub
20
+ /** Optional --agent <id> override; defaults to config-derived. */
21
+ agentId?: string
22
+ /** --tail N for logs; default 100. */
23
+ tail?: number
24
+ /** --follow / -f for logs. */
25
+ follow?: boolean
26
+ }
27
+
28
+ export type ParseResult = ParsedGatewayArgs | { error: string }
29
+
30
+ export function parseGatewayArgs(argv: string[]): ParseResult {
31
+ const sub = argv[0]
32
+ if (!sub) {
33
+ return { error: 'usage: lyra gateway <run | start | stop | restart | status | logs>' }
34
+ }
35
+ if (!['run', 'start', 'stop', 'restart', 'status', 'logs'].includes(sub)) {
36
+ return { error: `unknown gateway sub: ${sub}` }
37
+ }
38
+ let agentId: string | undefined
39
+ let tail: number | undefined
40
+ let follow = false
41
+ for (let i = 1; i < argv.length; i++) {
42
+ const a = argv[i]
43
+ if (a === '--agent') {
44
+ const v = argv[++i]
45
+ if (!v) return { error: '--agent requires a value' }
46
+ agentId = v
47
+ } else if (a === '--tail') {
48
+ const v = argv[++i]
49
+ if (!v) return { error: '--tail requires a value' }
50
+ const n = Number.parseInt(v, 10)
51
+ if (!Number.isFinite(n) || n < 0) return { error: '--tail must be a positive integer' }
52
+ tail = n
53
+ } else if (a === '--follow' || a === '-f') {
54
+ follow = true
55
+ } else {
56
+ return { error: `unknown flag: ${a}` }
57
+ }
58
+ }
59
+ return { sub: sub as GatewaySub, agentId, tail, follow }
60
+ }
61
+
62
+ export async function runGateway(parsed: ParsedGatewayArgs): Promise<void> {
63
+ switch (parsed.sub) {
64
+ case 'run': {
65
+ const { runGatewayForeground } = await import('./gateway-run')
66
+ await runGatewayForeground({ agentId: parsed.agentId })
67
+ return
68
+ }
69
+ case 'start': {
70
+ const { runGatewayStart } = await import('./gateway-start')
71
+ await runGatewayStart({ agentId: parsed.agentId })
72
+ return
73
+ }
74
+ case 'stop': {
75
+ const { runGatewayStop } = await import('./gateway-stop')
76
+ await runGatewayStop({ agentId: parsed.agentId })
77
+ return
78
+ }
79
+ case 'restart': {
80
+ const { runGatewayStop } = await import('./gateway-stop')
81
+ const { runGatewayStart } = await import('./gateway-start')
82
+ await runGatewayStop({ agentId: parsed.agentId })
83
+ await runGatewayStart({ agentId: parsed.agentId })
84
+ return
85
+ }
86
+ case 'status': {
87
+ const { runGatewayStatus } = await import('./gateway-status')
88
+ await runGatewayStatus({ agentId: parsed.agentId })
89
+ return
90
+ }
91
+ case 'logs': {
92
+ const { runGatewayLogs } = await import('./gateway-logs')
93
+ await runGatewayLogs({
94
+ agentId: parsed.agentId,
95
+ tail: parsed.tail ?? 100,
96
+ follow: parsed.follow ?? false,
97
+ })
98
+ return
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,17 @@
1
+ export interface ModelPick {
2
+ provider: string
3
+ model: string | null
4
+ }
5
+
6
+ /**
7
+ * Lyra uses a fixed OpenAI-compatible model configured via env
8
+ * (`LYRA_LLM_MODEL` / `LYRA_LLM_BASE_URL` / `OPENAI_API_KEY`), so there's
9
+ * no live provider catalog to pick from. Return the configured default so
10
+ * `init` / `model` proceed without prompting.
11
+ */
12
+ export async function pickBrainModel(): Promise<ModelPick | null> {
13
+ return {
14
+ provider: 'openai-compatible',
15
+ model: process.env.LYRA_LLM_MODEL ?? 'gpt-4o-mini',
16
+ }
17
+ }