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,378 @@
1
+ /**
2
+ * Multi-line ANSI progress box for sandbox bootstrap. Replaces the single-line
3
+ * clack spinner across the launch + poll + /healthz window:
4
+ *
5
+ * ╭─ bootstrap progress ────────────────────────╮
6
+ * │ [00:00] launchScript uploaded to Daytona │
7
+ * │ [00:12] apt update ✓ │
8
+ * │ [00:38] system deps installed ✓ │
9
+ * │ [01:04] bun runtime installed ✓ │
10
+ * │ [01:22] lyra 0.24.7 installed ✓ │
11
+ * │ [01:45] browser deps installed ✓ │
12
+ * │ [02:08] harness daemon spawned ✓ │
13
+ * │ [02:11] /healthz Ready ✓ │
14
+ * ╰─────────────────────────────────────────────╯
15
+ *
16
+ * Rendering uses `\x1b[NA` (cursor up) + `\x1b[0J` (clear to end) to redraw
17
+ * the same N+2 lines in place. Falls back to per-transition lines (no ANSI)
18
+ * when stdout is not a TTY (CI, piped output).
19
+ */
20
+
21
+ import { BOOTSTRAP_STAGE_MARKERS } from 'lyra-gateway'
22
+
23
+ const TIME_SLOT_WIDTH = 7
24
+ const LABEL_WIDTH = 32
25
+ const CONTENT_WIDTH = 45
26
+ const FRAME_WIDTH = CONTENT_WIDTH + 2
27
+
28
+ const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'] as const
29
+
30
+ export type BootstrapStageId =
31
+ | 'launch-upload'
32
+ | 'apt-update'
33
+ | 'system-deps'
34
+ | 'bun-install'
35
+ | 'lyra-install'
36
+ | 'browser-deps'
37
+ | 'harness-spawn'
38
+ | 'healthz-ready'
39
+
40
+ export type BootstrapStageStatus = 'pending' | 'running' | 'done' | 'failed'
41
+
42
+ const STAGE_ORDER: readonly BootstrapStageId[] = [
43
+ 'launch-upload',
44
+ 'apt-update',
45
+ 'system-deps',
46
+ 'bun-install',
47
+ 'lyra-install',
48
+ 'browser-deps',
49
+ 'harness-spawn',
50
+ 'healthz-ready',
51
+ ] as const
52
+
53
+ const DEFAULT_LABELS: Record<BootstrapStageId, string> = {
54
+ 'launch-upload': 'launchScript uploaded to Daytona',
55
+ 'apt-update': 'apt update',
56
+ 'system-deps': 'system deps installed',
57
+ 'bun-install': 'bun runtime installed',
58
+ 'lyra-install': 'lyra installed',
59
+ 'browser-deps': 'browser deps installed',
60
+ 'harness-spawn': 'harness daemon spawned',
61
+ 'healthz-ready': '/healthz Ready',
62
+ }
63
+
64
+ interface StageState {
65
+ id: BootstrapStageId
66
+ label: string
67
+ status: BootstrapStageStatus
68
+ /** Seconds since box start when status transitioned to running. */
69
+ startedSec?: number
70
+ /** Seconds since box start when status transitioned to done/failed. */
71
+ endedSec?: number
72
+ }
73
+
74
+ export interface BootstrapProgressBoxOpts {
75
+ /** Box title. Defaults to "bootstrap progress". */
76
+ title?: string
77
+ /** Per-stage label override. Useful for injecting the version into lyra-install. */
78
+ labels?: Partial<Record<BootstrapStageId, string>>
79
+ /** Stream to write to. Defaults to process.stdout. */
80
+ out?: NodeJS.WritableStream & { isTTY?: boolean }
81
+ }
82
+
83
+ /**
84
+ * Map a raw STAGE marker (emitted by the gateway bootstrap script as
85
+ * `STAGE: <body>` and extracted by sandbox-provision's poll loop) to a stage
86
+ * id. Returns null for unknown markers; callers should treat unknown markers
87
+ * as informational, not as state transitions. Marker prefixes come from
88
+ * `BOOTSTRAP_STAGE_MARKERS` in the gateway package so renames stay in lockstep.
89
+ */
90
+ export function mapBootstrapMarkerToStage(marker: string): BootstrapStageId | null {
91
+ const m = marker.trim()
92
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.aptUpdate)) return 'apt-update'
93
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.systemDeps)) return 'system-deps'
94
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.bunInstall)) return 'bun-install'
95
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.lyraInstall)) return 'lyra-install'
96
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.browserDeps)) return 'browser-deps'
97
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.harnessSpawn)) return 'harness-spawn'
98
+ if (m.startsWith(BOOTSTRAP_STAGE_MARKERS.harnessReady)) return 'harness-spawn'
99
+ return null
100
+ }
101
+
102
+ export class BootstrapProgressBox {
103
+ private readonly title: string
104
+ private readonly out: NodeJS.WritableStream & { isTTY?: boolean }
105
+ private readonly useAnsi: boolean
106
+ private readonly stages: StageState[]
107
+ private startMs = 0
108
+ private tickIdx = 0
109
+ /** Number of lines we wrote the LAST time render() ran (so we know how
110
+ * many to clear before the next render). Zero before the first render. */
111
+ private renderedLines = 0
112
+ /** Cleared on render — tracks last-printed status per stage in non-TTY mode. */
113
+ private readonly nonTtyLastPrinted = new Map<BootstrapStageId, BootstrapStageStatus>()
114
+
115
+ constructor(opts: BootstrapProgressBoxOpts = {}) {
116
+ this.title = opts.title ?? 'bootstrap progress'
117
+ this.out = opts.out ?? process.stdout
118
+ this.useAnsi = this.out.isTTY === true
119
+ this.stages = STAGE_ORDER.map(id => ({
120
+ id,
121
+ label: opts.labels?.[id] ?? DEFAULT_LABELS[id],
122
+ status: 'pending' as BootstrapStageStatus,
123
+ }))
124
+ }
125
+
126
+ start(): void {
127
+ this.startMs = Date.now()
128
+ if (!this.useAnsi) return
129
+ this.render()
130
+ }
131
+
132
+ /**
133
+ * Update one stage. When `status === 'running'`, every previously-running
134
+ * stage that hasn't transitioned to done/failed is auto-completed (the
135
+ * bash script is sequential, so a new stage starting implies the prior
136
+ * ones finished). Stages before the activated one that are still pending
137
+ * are also auto-completed — this handles conditional stages (e.g.
138
+ * bun-install gets skipped when bun is already in PATH).
139
+ */
140
+ markStage(id: BootstrapStageId, status: BootstrapStageStatus): void {
141
+ const sec = this.elapsedSec()
142
+ const idx = this.stages.findIndex(s => s.id === id)
143
+ if (idx < 0) return
144
+ if (status === 'running') {
145
+ for (let i = 0; i < idx; i++) {
146
+ const s = this.stages[i]!
147
+ if (s.status !== 'done' && s.status !== 'failed') {
148
+ s.status = 'done'
149
+ s.endedSec = sec
150
+ }
151
+ }
152
+ const target = this.stages[idx]!
153
+ if (target.status === 'pending') target.startedSec = sec
154
+ target.status = 'running'
155
+ } else {
156
+ const target = this.stages[idx]!
157
+ target.status = status
158
+ target.endedSec = sec
159
+ if (target.startedSec === undefined) target.startedSec = sec
160
+ }
161
+ this.tickIdx += 1
162
+ this.render()
163
+ }
164
+
165
+ /**
166
+ * Bump the spinner glyph + elapsed counter. Call every 1-5s while a
167
+ * running stage is in-flight to keep the visual alive even when no STAGE
168
+ * marker has arrived. No-op when the box hasn't started yet or when no
169
+ * stage is currently running.
170
+ */
171
+ tick(): void {
172
+ if (this.renderedLines === 0 && this.useAnsi) {
173
+ this.render()
174
+ return
175
+ }
176
+ this.tickIdx += 1
177
+ this.render()
178
+ }
179
+
180
+ /**
181
+ * Finalize the box. Marks any still-running stages as done (assumed
182
+ * complete; the upstream success signal is what triggered stop()), draws
183
+ * one final frame, and emits the trailing newline so subsequent CLI
184
+ * output (e.g. final spinner success line) starts cleanly below.
185
+ */
186
+ stop(): void {
187
+ const sec = this.elapsedSec()
188
+ for (const s of this.stages) {
189
+ if (s.status === 'running') {
190
+ s.status = 'done'
191
+ s.endedSec = sec
192
+ }
193
+ }
194
+ this.render()
195
+ if (this.useAnsi) this.out.write('\n')
196
+ }
197
+
198
+ /**
199
+ * Mark the box as aborted. Any running stage becomes failed; pending
200
+ * stages stay pending so the operator sees where bootstrap got stuck.
201
+ */
202
+ fail(): void {
203
+ const sec = this.elapsedSec()
204
+ for (const s of this.stages) {
205
+ if (s.status === 'running') {
206
+ s.status = 'failed'
207
+ s.endedSec = sec
208
+ }
209
+ }
210
+ this.render()
211
+ if (this.useAnsi) this.out.write('\n')
212
+ }
213
+
214
+ private elapsedSec(): number {
215
+ return Math.max(0, Math.round((Date.now() - this.startMs) / 1000))
216
+ }
217
+
218
+ private render(): void {
219
+ if (!this.useAnsi) {
220
+ this.renderNonTty()
221
+ return
222
+ }
223
+ const lines = this.buildLines()
224
+ if (this.renderedLines > 0) {
225
+ this.out.write(`\x1b[${this.renderedLines}A\x1b[0J`)
226
+ }
227
+ for (const line of lines) this.out.write(`${line}\n`)
228
+ this.renderedLines = lines.length
229
+ }
230
+
231
+ /**
232
+ * Non-TTY fallback: print one line per state change instead of a redrawn
233
+ * box. Tracks last-printed status per stage so re-renders don't spam.
234
+ */
235
+ private renderNonTty(): void {
236
+ for (const s of this.stages) {
237
+ const prev = this.nonTtyLastPrinted.get(s.id)
238
+ if (prev === s.status) continue
239
+ if (s.status === 'pending') continue
240
+ const tag =
241
+ s.status === 'done'
242
+ ? '[ok]'
243
+ : s.status === 'failed'
244
+ ? '[fail]'
245
+ : s.status === 'running'
246
+ ? '[..]'
247
+ : ''
248
+ const time =
249
+ s.status === 'running'
250
+ ? formatTime(s.startedSec ?? 0)
251
+ : formatTime(s.endedSec ?? this.elapsedSec())
252
+ this.out.write(`${tag} [${time}] ${s.label}\n`)
253
+ this.nonTtyLastPrinted.set(s.id, s.status)
254
+ }
255
+ }
256
+
257
+ private buildLines(): string[] {
258
+ const header = `╭─ ${this.title} ${'─'.repeat(FRAME_WIDTH - this.title.length - 5)}╮`
259
+ const footer = `╰${'─'.repeat(FRAME_WIDTH - 2)}╯`
260
+ const rows = this.stages.map(s => this.formatRow(s))
261
+ return [header, ...rows, footer]
262
+ }
263
+
264
+ private formatRow(s: StageState): string {
265
+ const timeText = pickTimeText(s)
266
+ const timeCol =
267
+ timeText === null ? ' '.repeat(TIME_SLOT_WIDTH) : `[${timeText}]`.padEnd(TIME_SLOT_WIDTH)
268
+ const labelText = truncate(s.label, LABEL_WIDTH).padEnd(LABEL_WIDTH)
269
+ const glyph = pickGlyph(s, this.tickIdx)
270
+ return `│ ${timeCol} ${labelText} ${glyph} │`
271
+ }
272
+ }
273
+
274
+ function pickTimeText(s: StageState): string | null {
275
+ if (s.status === 'pending') return null
276
+ const sec = s.status === 'running' ? (s.startedSec ?? 0) : (s.endedSec ?? s.startedSec ?? 0)
277
+ return formatTime(sec)
278
+ }
279
+
280
+ function formatTime(sec: number): string {
281
+ const m = Math.floor(sec / 60)
282
+ const s = sec % 60
283
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
284
+ }
285
+
286
+ function pickGlyph(s: StageState, tickIdx: number): string {
287
+ if (s.status === 'done') return '✓'
288
+ if (s.status === 'failed') return '✗'
289
+ if (s.status === 'running') return SPINNER_FRAMES[tickIdx % SPINNER_FRAMES.length]!
290
+ return ' '
291
+ }
292
+
293
+ function truncate(s: string, max: number): string {
294
+ if (s.length <= max) return s
295
+ return `${s.slice(0, max - 1)}…`
296
+ }
297
+
298
+ export const __testing = {
299
+ STAGE_ORDER,
300
+ DEFAULT_LABELS,
301
+ SPINNER_FRAMES,
302
+ formatTime,
303
+ truncate,
304
+ }
305
+
306
+ /**
307
+ * Lifecycle owner for the bootstrap progress UX. Wraps the clack spinner
308
+ * (which renders the pre-bootstrap phase: deposit + createSandbox) and a
309
+ * lazily-created `BootstrapProgressBox` (which renders the actual bootstrap
310
+ * stages). Encapsulates the takeover handoff so `init.ts` and `upgrade.ts`
311
+ * don't repeat the same `let box / spinnerStopped` dance.
312
+ */
313
+ export interface ClackSpinnerLike {
314
+ message: (msg: string) => void
315
+ stop: (msg?: string, code?: number) => void
316
+ }
317
+
318
+ export interface BootstrapProgressControllerOpts {
319
+ spinner: ClackSpinnerLike
320
+ cliVersion: string
321
+ /** Text shown when the spinner stops + the box takes over (e.g. "sandbox started, running bootstrap"). */
322
+ startedMsg: string
323
+ }
324
+
325
+ export class BootstrapProgressController {
326
+ private box: BootstrapProgressBox | null = null
327
+ private spinnerStopped = false
328
+ private readonly spinner: ClackSpinnerLike
329
+ private readonly cliVersion: string
330
+ private readonly startedMsg: string
331
+
332
+ constructor(opts: BootstrapProgressControllerOpts) {
333
+ this.spinner = opts.spinner
334
+ this.cliVersion = opts.cliVersion
335
+ this.startedMsg = opts.startedMsg
336
+ }
337
+
338
+ onProgress = (msg: string): void => {
339
+ if (this.box) return
340
+ this.spinner.message(msg)
341
+ }
342
+
343
+ onStageEvent = (stage: BootstrapStageId, status: BootstrapStageStatus): void => {
344
+ if (!this.box) {
345
+ this.spinner.stop(this.startedMsg)
346
+ this.spinnerStopped = true
347
+ this.box = new BootstrapProgressBox({
348
+ labels: { 'lyra-install': `lyra ${this.cliVersion} installed` },
349
+ })
350
+ this.box.start()
351
+ }
352
+ this.box.markStage(stage, status)
353
+ }
354
+
355
+ onTick = (): void => {
356
+ this.box?.tick()
357
+ }
358
+
359
+ /** Box closes itself; success line printed via the caller's `emit` (typically `log.step`). */
360
+ finalize(successLine: string, emit: (msg: string) => void): void {
361
+ if (this.box) {
362
+ this.box.stop()
363
+ emit(successLine)
364
+ } else {
365
+ this.spinner.stop(successLine)
366
+ }
367
+ }
368
+
369
+ /** Box marks running stage as failed; error line printed via caller's `emit` (typically `log.error`). */
370
+ fail(errLine: string, emit: (msg: string) => void): void {
371
+ if (this.box) {
372
+ this.box.fail()
373
+ emit(errLine)
374
+ } else if (!this.spinnerStopped) {
375
+ this.spinner.stop(errLine)
376
+ }
377
+ }
378
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Resolve the CLI package's own version. Used by `lyra --version` and to pin
3
+ * the gateway version installed in sandbox containers (mode=npm) so the
4
+ * gateway matches the CLI.
5
+ *
6
+ * Reads package.json via a path relative to this module so it works in every
7
+ * install layout: monorepo workspace (where bare-specifier resolution of
8
+ * `lyra-ai-cli` doesn't include /package.json without an exports entry),
9
+ * `bun add -g` global install, and Bun's per-project content store.
10
+ */
11
+ import { readFile } from 'node:fs/promises'
12
+ import { resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ export async function resolveCliVersion(): Promise<string> {
16
+ const here = fileURLToPath(import.meta.url)
17
+ const pkgPath = resolve(here, '../../../package.json')
18
+ try {
19
+ const raw = await readFile(pkgPath, 'utf-8')
20
+ const pkg = JSON.parse(raw) as { version?: unknown }
21
+ if (typeof pkg.version !== 'string') {
22
+ throw new Error('package.json missing version field')
23
+ }
24
+ return pkg.version
25
+ } catch (e) {
26
+ throw new Error(`cannot read CLI version from ${pkgPath}: ${(e as Error).message}`)
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { shortAddr } from './format'
3
+
4
+ describe('shortAddr', () => {
5
+ it('returns ? for missing input', () => {
6
+ expect(shortAddr(undefined)).toBe('?')
7
+ expect(shortAddr('')).toBe('?')
8
+ })
9
+ it('passes through short / non-0x values unchanged', () => {
10
+ expect(shortAddr('alice.0g')).toBe('alice.0g')
11
+ expect(shortAddr('0xabc')).toBe('0xabc')
12
+ })
13
+ it('truncates a 0x EVM address to first 6 + last 4', () => {
14
+ expect(shortAddr('0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec')).toBe('0xC635…87Ec')
15
+ })
16
+ })
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Truncate an Sui 0x-address to first 6 + last 4 (e.g. 0x1234…abcd) for
3
+ * compact UI rendering. Returns the input unchanged for short or non-0x
4
+ * values (e.g. an `.0g` name, `'?'`, or empty), so callers can pass any
5
+ * identifier without checking type first.
6
+ */
7
+ export function shortAddr(addr?: string | null): string {
8
+ if (!addr) return '?'
9
+ if (!addr.startsWith('0x') || addr.length <= 12) return addr
10
+ return `${addr.slice(0, 6)}…${addr.slice(-4)}`
11
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Bundle B unit tests: spawnGatewayDaemon path. Uses a tiny shell-stub bin
3
+ * that "binds" the socket by simply touching the path then sleeping. We
4
+ * avoid spinning up the real `bun packages/gateway/bin/lyra-gateway-local`
5
+ * because that pulls in the full Sui runtime + plugin stack.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
9
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { spawnGatewayDaemon } from './gateway-spawn'
13
+
14
+ let workDir: string
15
+
16
+ beforeEach(() => {
17
+ workDir = mkdtempSync(join(tmpdir(), 'lyra-spawn-test-'))
18
+ })
19
+
20
+ afterEach(() => {
21
+ rmSync(workDir, { recursive: true, force: true })
22
+ })
23
+
24
+ describe('spawnGatewayDaemon', () => {
25
+ it('reports pre-existing when sock already present', async () => {
26
+ const sock = join(workDir, 'gateway.sock')
27
+ writeFileSync(sock, '')
28
+ const r = await spawnGatewayDaemon({
29
+ agentId: 'a1',
30
+ configPath: 'cfg',
31
+ socketPath: sock,
32
+ binPath: join(workDir, 'unused-bin'),
33
+ timeoutMs: 100,
34
+ })
35
+ expect(r.ready).toBe(false)
36
+ expect(r.reason).toBe('pre-existing')
37
+ })
38
+
39
+ it('reports timeout when bin does not bind the sock', async () => {
40
+ const sock = join(workDir, 'gateway.sock')
41
+ const stub = join(workDir, 'noop.ts')
42
+ writeFileSync(stub, 'setTimeout(() => process.exit(0), 50)\n')
43
+ const r = await spawnGatewayDaemon({
44
+ agentId: 'a3',
45
+ configPath: 'cfg',
46
+ socketPath: sock,
47
+ binPath: stub,
48
+ timeoutMs: 600,
49
+ stdio: 'ignore',
50
+ })
51
+ expect(r.ready).toBe(false)
52
+ expect(r.reason).toBe('timeout')
53
+ expect(existsSync(sock)).toBe(false)
54
+ })
55
+
56
+ it('returns ready=true when bin binds the sock + passes env', async () => {
57
+ const sock = join(workDir, 'gateway.sock')
58
+ const envOut = join(workDir, 'env.out')
59
+ const stub = join(workDir, 'env-stub.ts')
60
+ writeFileSync(
61
+ stub,
62
+ `import { writeFileSync } from 'node:fs'
63
+ writeFileSync(${JSON.stringify(envOut)}, JSON.stringify({
64
+ agent: process.env.LYRA_AGENT_ID,
65
+ config: process.env.LYRA_CONFIG,
66
+ }))
67
+ writeFileSync(${JSON.stringify(sock)}, '')
68
+ setTimeout(() => process.exit(0), 200)
69
+ `,
70
+ )
71
+ const r = await spawnGatewayDaemon({
72
+ agentId: 'agent-EXPECTED',
73
+ configPath: '/path/to/cfg.ts',
74
+ socketPath: sock,
75
+ binPath: stub,
76
+ timeoutMs: 8_000,
77
+ stdio: 'ignore',
78
+ })
79
+ expect(r.ready).toBe(true)
80
+ expect(r.pid).toBeNumber()
81
+ const { readFileSync } = await import('node:fs')
82
+ const captured = JSON.parse(readFileSync(envOut, 'utf8'))
83
+ expect(captured.agent).toBe('agent-EXPECTED')
84
+ expect(captured.config).toBe('/path/to/cfg.ts')
85
+ })
86
+ })
@@ -0,0 +1,125 @@
1
+ /**
2
+ * v0.21.5 Bundle B: spawn-and-wait helper for the local gateway daemon.
3
+ *
4
+ * Two callers share this:
5
+ * - `lyra gateway start` (interactive Touch ID flow → spawn detached)
6
+ * - `lyra` chat fallback when no sock is present (auto-spawn before
7
+ * embedded TUI fallthrough — see Bundle C / chat.tsx)
8
+ *
9
+ * The helper does NOT perform operator-session unlock. Callers that need a
10
+ * fresh session must run that path before invoking this. If the daemon dies
11
+ * during boot because no session exists, the sock never appears and we
12
+ * surface the failure as `{ ready: false, reason: 'timeout' }`.
13
+ */
14
+
15
+ import { type ChildProcess, spawn } from 'node:child_process'
16
+ import { existsSync, mkdirSync, openSync } from 'node:fs'
17
+ import { dirname, join } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { agentPaths } from 'lyra-core'
20
+
21
+ export interface SpawnGatewayDaemonOpts {
22
+ agentId: string
23
+ configPath: string
24
+ socketPath: string
25
+ /** Max ms to wait for the unix sock to appear. Default 10_000. */
26
+ timeoutMs?: number
27
+ /**
28
+ * Where to send daemon stdout/stderr. Default 'log-file' which redirects
29
+ * to `~/.lyra/agents/<id>/gateway.log` (truncated on each boot) so
30
+ * detached daemon diagnostics survive the parent's exit. 'inherit' keeps
31
+ * the legacy behavior where output goes to the parent's tty (and vanishes
32
+ * on detach). 'ignore' drops everything.
33
+ */
34
+ stdio?: 'inherit' | 'ignore' | 'log-file'
35
+ /** Override the bin resolution (tests). */
36
+ binPath?: string
37
+ /** Override env (tests). */
38
+ env?: NodeJS.ProcessEnv
39
+ }
40
+
41
+ export interface SpawnGatewayDaemonResult {
42
+ ready: boolean
43
+ /** Detached child PID iff spawn succeeded (regardless of readiness). */
44
+ pid?: number
45
+ /** Reason populated on failure: 'spawn-failed' | 'timeout' | 'pre-existing'. */
46
+ reason?: 'spawn-failed' | 'timeout' | 'pre-existing'
47
+ /** First-line error message when spawn failed. */
48
+ error?: string
49
+ }
50
+
51
+ export function resolveLocalBin(): string {
52
+ const pkgUrl = import.meta.resolve('lyra-gateway/package.json')
53
+ const pkgRoot = dirname(fileURLToPath(pkgUrl))
54
+ return join(pkgRoot, 'bin', 'lyra-gateway-local')
55
+ }
56
+
57
+ export async function spawnGatewayDaemon(
58
+ opts: SpawnGatewayDaemonOpts,
59
+ ): Promise<SpawnGatewayDaemonResult> {
60
+ if (existsSync(opts.socketPath)) {
61
+ return { ready: false, reason: 'pre-existing' }
62
+ }
63
+
64
+ const bin = opts.binPath ?? resolveLocalBin()
65
+ const env: NodeJS.ProcessEnv = {
66
+ ...(opts.env ?? process.env),
67
+ LYRA_AGENT_ID: opts.agentId,
68
+ LYRA_CONFIG: opts.configPath,
69
+ }
70
+ const stdioMode = opts.stdio ?? 'log-file'
71
+
72
+ // v0.21.12: when stdio is 'log-file' redirect daemon stdout+stderr to
73
+ // ~/.lyra/agents/<id>/gateway.log (truncate-on-restart). Pre-fix this
74
+ // helper used 'inherit' which sent output to the parent's tty; once the
75
+ // parent CLI returned, those handles vanished and operators couldn't see
76
+ // why the daemon misbehaved. Truncation is fine because operators rarely
77
+ // reboot the daemon mid-session and `lyra gateway logs -f` only follows
78
+ // the current invocation.
79
+ let stdioCfg: ['ignore', 'inherit' | 'ignore' | number, 'inherit' | 'ignore' | number]
80
+ if (stdioMode === 'log-file') {
81
+ const logPath = join(agentPaths.agent(opts.agentId).dir, 'gateway.log')
82
+ try {
83
+ mkdirSync(dirname(logPath), { recursive: true })
84
+ const fd = openSync(logPath, 'w') // truncate on each boot
85
+ stdioCfg = ['ignore', fd, fd]
86
+ } catch {
87
+ // If we can't open the log file (perm, disk), fall back to ignore so
88
+ // we still spawn cleanly. Operators lose diagnostics but the daemon
89
+ // boots.
90
+ stdioCfg = ['ignore', 'ignore', 'ignore']
91
+ }
92
+ } else {
93
+ stdioCfg = ['ignore', stdioMode, stdioMode]
94
+ }
95
+
96
+ let proc: ChildProcess
97
+ try {
98
+ proc = spawn('bun', [bin], {
99
+ env,
100
+ detached: true,
101
+ stdio: stdioCfg,
102
+ })
103
+ proc.unref()
104
+ } catch (err) {
105
+ return {
106
+ ready: false,
107
+ reason: 'spawn-failed',
108
+ error: (err as Error).message?.slice(0, 200),
109
+ }
110
+ }
111
+
112
+ const timeoutMs = opts.timeoutMs ?? 10_000
113
+ const start = Date.now()
114
+ while (Date.now() - start < timeoutMs) {
115
+ if (existsSync(opts.socketPath)) {
116
+ return { ready: true, pid: proc.pid }
117
+ }
118
+ await new Promise(r => setTimeout(r, 200))
119
+ }
120
+ return {
121
+ ready: false,
122
+ pid: proc.pid,
123
+ reason: 'timeout',
124
+ }
125
+ }