@soederpop/luca 0.0.25 → 0.0.28

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.
@@ -3,6 +3,87 @@ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '.
3
3
  import { Feature } from '../feature.js'
4
4
  import { State } from '../../state.js'
5
5
  import { Bus, type EventMap } from '../../bus.js'
6
+ import type { ChildProcess } from './proc.js'
7
+
8
+ // ─── Output Buffer ─────────────────────────────────────────────────────────
9
+
10
+ const HEAD_LINES = 20
11
+ const TAIL_LINES = 50
12
+
13
+ /**
14
+ * A memory-efficient output buffer that keeps the first N lines (head)
15
+ * and the last M lines (tail), discarding everything in between.
16
+ */
17
+ class OutputBuffer {
18
+ private _head: string[] = []
19
+ private _tail: string[] = []
20
+ private _totalLines = 0
21
+ private _partial = ''
22
+ private _headFull = false
23
+
24
+ constructor(
25
+ private _headLimit = HEAD_LINES,
26
+ private _tailLimit = TAIL_LINES
27
+ ) {}
28
+
29
+ /** Append a chunk of output (may contain partial lines) */
30
+ append(chunk: string): void {
31
+ const text = this._partial + chunk
32
+ const lines = text.split('\n')
33
+
34
+ // Last element is a partial line (no trailing newline) — hold it
35
+ this._partial = lines.pop()!
36
+
37
+ for (const line of lines) {
38
+ this._pushLine(line)
39
+ }
40
+ }
41
+
42
+ /** Flush any remaining partial line */
43
+ flush(): void {
44
+ if (this._partial) {
45
+ this._pushLine(this._partial)
46
+ this._partial = ''
47
+ }
48
+ }
49
+
50
+ get totalLines() { return this._totalLines }
51
+
52
+ /** Get the head lines (first N lines of output) */
53
+ get head(): string[] { return this._head }
54
+
55
+ /** Get the tail lines (last M lines of output) */
56
+ get tail(): string[] { return this._tail }
57
+
58
+ /** Number of lines dropped between head and tail */
59
+ get droppedLines(): number {
60
+ return Math.max(0, this._totalLines - this._head.length - this._tail.length)
61
+ }
62
+
63
+ /** Format the buffer for display */
64
+ toString(): string {
65
+ const parts: string[] = []
66
+ if (this._head.length) parts.push(this._head.join('\n'))
67
+ if (this.droppedLines > 0) parts.push(`\n... (${this.droppedLines} lines omitted) ...\n`)
68
+ if (this._tail.length && this._totalLines > this._headLimit) parts.push(this._tail.join('\n'))
69
+ return parts.join('\n')
70
+ }
71
+
72
+ private _pushLine(line: string): void {
73
+ this._totalLines++
74
+
75
+ if (!this._headFull) {
76
+ this._head.push(line)
77
+ if (this._head.length >= this._headLimit) this._headFull = true
78
+ return
79
+ }
80
+
81
+ this._tail.push(line)
82
+ if (this._tail.length > this._tailLimit) {
83
+ this._tail.shift()
84
+ }
85
+ }
86
+ }
6
87
 
7
88
  // ─── Schemas ────────────────────────────────────────────────────────────────
8
89
 
@@ -33,11 +114,11 @@ export const ProcessManagerOptionsSchema = FeatureOptionsSchema.extend({
33
114
  export type ProcessManagerOptions = z.infer<typeof ProcessManagerOptionsSchema>
34
115
 
35
116
  export const ProcessManagerEventsSchema = FeatureEventsSchema.extend({
36
- spawned: z.tuple([z.string().describe('process ID'), z.any().describe('process metadata')])
117
+ spawned: z.tuple([z.string().describe('process ID'), z.string().describe('process metadata')])
37
118
  .describe('Emitted when a new process is spawned'),
38
119
  exited: z.tuple([z.string().describe('process ID'), z.number().describe('exit code')])
39
120
  .describe('Emitted when a process exits normally'),
40
- crashed: z.tuple([z.string().describe('process ID'), z.number().describe('exit code'), z.any().describe('error info')])
121
+ crashed: z.tuple([z.string().describe('process ID'), z.number().describe('exit code'), z.string().describe('error info')])
41
122
  .describe('Emitted when a process exits with non-zero code'),
42
123
  killed: z.tuple([z.string().describe('process ID')])
43
124
  .describe('Emitted when a process is killed'),
@@ -87,7 +168,8 @@ export interface SpawnOptions {
87
168
  *
88
169
  * Provides observable state, events, and methods to interact with
89
170
  * the running process. Returned immediately from `ProcessManager.spawn()`
90
- * without blocking.
171
+ * without blocking. Maintains a memory-efficient output buffer that keeps
172
+ * the first 20 lines and last 50 lines of stdout/stderr.
91
173
  *
92
174
  * @example
93
175
  * ```ts
@@ -100,8 +182,10 @@ export interface SpawnOptions {
100
182
  export class SpawnHandler {
101
183
  readonly state: State<SpawnHandlerState>
102
184
  readonly events = new Bus<SpawnHandlerEvents>()
185
+ readonly stdout = new OutputBuffer(HEAD_LINES, TAIL_LINES)
186
+ readonly stderr = new OutputBuffer(HEAD_LINES, TAIL_LINES)
103
187
 
104
- private _process!: ReturnType<typeof Bun.spawn>
188
+ private _childProcess: any = null
105
189
  private _manager: ProcessManager
106
190
  private _exitPromise: Promise<number> | null = null
107
191
  private _exitResolve: ((code: number) => void) | null = null
@@ -155,36 +239,44 @@ export class SpawnHandler {
155
239
  get exitCode() { return this.state.get('exitCode') }
156
240
 
157
241
  /**
158
- * Start the process. Called internally by `ProcessManager.spawn()`.
242
+ * Start the process using proc.spawnAndCapture. Called internally by `ProcessManager.spawn()`.
159
243
  */
160
244
  _start(spawnOptions: SpawnOptions = {}): void {
161
245
  const command = this.state.get('command')!
162
246
  const args = this.state.get('args')!
163
-
164
- const proc = Bun.spawn([command, ...args], {
165
- cwd: spawnOptions.cwd ?? this._manager.container.cwd,
166
- env: { ...process.env, ...spawnOptions.env },
167
- stdin: spawnOptions.stdin ?? 'ignore',
168
- stdout: spawnOptions.stdout ?? 'pipe',
169
- stderr: spawnOptions.stderr ?? 'pipe',
170
- })
171
-
172
- this._process = proc
173
- this.state.set('pid', proc.pid)
174
-
175
- // Stream stdout
176
- if (proc.stdout && typeof proc.stdout === 'object' && 'getReader' in proc.stdout) {
177
- this._readStream(proc.stdout as ReadableStream<Uint8Array>, 'stdout')
178
- }
179
-
180
- // Stream stderr
181
- if (proc.stderr && typeof proc.stderr === 'object' && 'getReader' in proc.stderr) {
182
- this._readStream(proc.stderr as ReadableStream<Uint8Array>, 'stderr')
183
- }
184
-
185
- // Wait for exit
186
- proc.exited.then((code: number) => {
187
- this._onExit(code)
247
+ const proc = this._manager.container.feature('proc') as ChildProcess
248
+
249
+ const cwd = spawnOptions.cwd ?? this._manager.container.cwd
250
+
251
+ // Use proc.spawnAndCapture with hooks for real-time streaming
252
+ proc.spawnAndCapture(command, args, {
253
+ cwd,
254
+ onStart: (childProcess: any) => {
255
+ this._childProcess = childProcess
256
+ if (childProcess.pid) {
257
+ this.state.set('pid', childProcess.pid)
258
+ }
259
+ },
260
+ onOutput: (data: string) => {
261
+ this.stdout.append(data)
262
+ this.events.emit('stdout', data)
263
+ },
264
+ onError: (data: string) => {
265
+ this.stderr.append(data)
266
+ this.events.emit('stderr', data)
267
+ },
268
+ onExit: (code: number) => {
269
+ this.stdout.flush()
270
+ this.stderr.flush()
271
+ this._onExit(code)
272
+ },
273
+ }).catch((err: any) => {
274
+ // spawnAndCapture rejected — treat as crash
275
+ this.stdout.flush()
276
+ this.stderr.flush()
277
+ if (!this.isDone) {
278
+ this._onExit(err?.code ?? 1)
279
+ }
188
280
  })
189
281
  }
190
282
 
@@ -205,6 +297,8 @@ export class SpawnHandler {
205
297
  if (err.code !== 'ESRCH') throw err
206
298
  }
207
299
 
300
+ this.stdout.flush()
301
+ this.stderr.flush()
208
302
  this.state.set('status', 'killed')
209
303
  this.state.set('endedAt', Date.now())
210
304
  this.events.emit('killed')
@@ -232,11 +326,25 @@ export class SpawnHandler {
232
326
  * @param data - String or Uint8Array to write
233
327
  */
234
328
  write(data: string | Uint8Array): void {
235
- const stdin = this._process?.stdin
236
- if (!stdin || typeof stdin === 'number') {
329
+ const stdin = this._childProcess?.stdin
330
+ if (!stdin) {
237
331
  throw new Error('stdin is not piped — pass { stdin: "pipe" } in spawn options')
238
332
  }
239
- ;(stdin as import('bun').FileSink).write(data)
333
+ stdin.write(data)
334
+ }
335
+
336
+ /**
337
+ * Peek at the buffered output. Returns head (first 20 lines), tail (last 50 lines),
338
+ * and metadata about how much was dropped.
339
+ */
340
+ peek(stream: 'stdout' | 'stderr' = 'stdout'): { head: string[]; tail: string[]; totalLines: number; droppedLines: number } {
341
+ const buf = stream === 'stderr' ? this.stderr : this.stdout
342
+ return {
343
+ head: buf.head,
344
+ tail: buf.tail,
345
+ totalLines: buf.totalLines,
346
+ droppedLines: buf.droppedLines,
347
+ }
240
348
  }
241
349
 
242
350
  /** Subscribe to handler events */
@@ -259,22 +367,6 @@ export class SpawnHandler {
259
367
 
260
368
  // ─── Internal ─────────────────────────────────────────────────────────────
261
369
 
262
- private async _readStream(stream: ReadableStream<Uint8Array>, type: 'stdout' | 'stderr') {
263
- const reader = stream.getReader()
264
- const decoder = new TextDecoder()
265
-
266
- try {
267
- while (true) {
268
- const { done, value } = await reader.read()
269
- if (done) break
270
- const text = decoder.decode(value, { stream: true })
271
- this.events.emit(type, text)
272
- }
273
- } catch {
274
- // Stream closed — process is ending
275
- }
276
- }
277
-
278
370
  private _onExit(code: number): void {
279
371
  if (this.isDone) return
280
372
 
@@ -309,6 +401,9 @@ export class SpawnHandler {
309
401
  * state, events, and lifecycle methods. The feature tracks all spawned processes,
310
402
  * maintains observable state, and can automatically kill them on parent exit.
311
403
  *
404
+ * Each handler maintains a memory-efficient output buffer: the first 20 lines (head)
405
+ * and last 50 lines (tail) of stdout/stderr are kept, everything in between is discarded.
406
+ *
312
407
  * @example
313
408
  * ```typescript
314
409
  * const pm = container.feature('processManager', { enable: true })
@@ -317,6 +412,9 @@ export class SpawnHandler {
317
412
  * server.on('stdout', (data) => console.log('[api]', data))
318
413
  * server.on('crash', (code) => console.error('API crashed:', code))
319
414
  *
415
+ * // Peek at buffered output
416
+ * const { head, tail } = server.peek()
417
+ *
320
418
  * // Kill one
321
419
  * server.kill()
322
420
  *
@@ -337,10 +435,166 @@ export class ProcessManager extends Feature {
337
435
  static override eventsSchema = ProcessManagerEventsSchema
338
436
  static { Feature.register(this, 'processManager') }
339
437
 
438
+ /** Tools that an assistant can use to spawn and manage processes. */
439
+ static tools: Record<string, { schema: z.ZodType; handler?: Function }> = {
440
+ spawnProcess: {
441
+ schema: z.object({
442
+ command: z.string().describe('The command to execute (e.g. "node", "bun", "python")'),
443
+ args: z.string().optional().describe('Space-separated arguments to pass to the command'),
444
+ tag: z.string().optional().describe('A label for this process so you can find it later'),
445
+ cwd: z.string().optional().describe('Working directory for the process'),
446
+ }).describe(
447
+ 'Spawn a long-running process (server, watcher, daemon) that runs in the background. Returns immediately with a process ID you can use to check status or kill it later.'
448
+ ),
449
+ },
450
+ runCommand: {
451
+ schema: z.object({
452
+ command: z.string().describe('The command to execute (e.g. "npm install", "bun test")'),
453
+ cwd: z.string().optional().describe('Working directory for the command'),
454
+ }).describe(
455
+ 'Run a command and wait for it to complete. Returns the full stdout/stderr output and exit code. Use this for commands you expect to finish (builds, installs, tests).'
456
+ ),
457
+ },
458
+ listProcesses: {
459
+ schema: z.object({}).describe(
460
+ 'List all tracked processes with their status, PID, command, uptime, and a preview of recent output.'
461
+ ),
462
+ },
463
+ getProcessOutput: {
464
+ schema: z.object({
465
+ id: z.string().optional().describe('The process ID to get output for'),
466
+ tag: z.string().optional().describe('The tag of the process to get output for'),
467
+ stream: z.string().optional().describe('Which stream to read: "stdout" (default) or "stderr"'),
468
+ }).describe(
469
+ 'Peek at a process\'s buffered output — shows the first 20 lines and last 50 lines of stdout or stderr.'
470
+ ),
471
+ },
472
+ killProcess: {
473
+ schema: z.object({
474
+ id: z.string().optional().describe('The process ID to kill'),
475
+ tag: z.string().optional().describe('The tag of the process to kill'),
476
+ signal: z.string().optional().describe('Signal to send: "SIGTERM" (default, graceful) or "SIGKILL" (force)'),
477
+ }).describe(
478
+ 'Kill a running process by ID or tag.'
479
+ ),
480
+ },
481
+ }
482
+
340
483
  private _handlers = new Map<string, SpawnHandler>()
341
484
  private _cleanupRegistered = false
342
485
  private _cleanupHandlers: Array<() => void> = []
343
486
 
487
+ // ─── Tool Handlers ──────────────────────────────────────────────────────
488
+
489
+ /**
490
+ * Tool handler: spawn a long-running background process.
491
+ */
492
+ async spawnProcess(args: { command: string; args?: string; tag?: string; cwd?: string }) {
493
+ const cmdArgs = args.args ? args.args.split(/\s+/) : []
494
+ const handler = this.spawn(args.command, cmdArgs, {
495
+ tag: args.tag,
496
+ cwd: args.cwd,
497
+ })
498
+
499
+ return {
500
+ id: handler.id,
501
+ pid: handler.pid,
502
+ tag: handler.tag,
503
+ command: `${args.command} ${args.args ?? ''}`.trim(),
504
+ status: 'running',
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Tool handler: run a command to completion and return its output.
510
+ */
511
+ async runCommand(args: { command: string; cwd?: string }) {
512
+ const proc = this.container.feature('proc') as ChildProcess
513
+ const result = await proc.spawnAndCapture('sh', ['-c', args.command], {
514
+ cwd: args.cwd ?? this.container.cwd,
515
+ })
516
+
517
+ return {
518
+ exitCode: result.exitCode,
519
+ stdout: result.stdout,
520
+ stderr: result.stderr,
521
+ success: result.exitCode === 0,
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Tool handler: list all tracked processes.
527
+ */
528
+ async listProcesses() {
529
+ const handlers = this.list()
530
+ if (handlers.length === 0) return { processes: [], message: 'No tracked processes.' }
531
+
532
+ return {
533
+ processes: handlers.map(h => {
534
+ const now = Date.now()
535
+ const startedAt = h.state.get('startedAt')!
536
+ const endedAt = h.state.get('endedAt')
537
+ const duration = (endedAt ?? now) - startedAt
538
+ const lastStdout = h.stdout.tail.length ? h.stdout.tail.slice(-3) : h.stdout.head.slice(-3)
539
+
540
+ return {
541
+ id: h.id,
542
+ tag: h.tag,
543
+ pid: h.pid,
544
+ command: `${h.state.get('command')} ${(h.state.get('args') ?? []).join(' ')}`.trim(),
545
+ status: h.status,
546
+ exitCode: h.exitCode,
547
+ uptimeMs: duration,
548
+ uptime: formatDuration(duration),
549
+ outputLines: h.stdout.totalLines,
550
+ errorLines: h.stderr.totalLines,
551
+ recentOutput: lastStdout,
552
+ }
553
+ }),
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Tool handler: peek at a process's buffered output.
559
+ */
560
+ async getProcessOutput(args: { id?: string; tag?: string; stream?: string }) {
561
+ const handler = args.id ? this.get(args.id) : args.tag ? this.getByTag(args.tag) : undefined
562
+ if (!handler) return { error: `Process not found. Provide a valid id or tag.` }
563
+
564
+ const streamName = (args.stream === 'stderr' ? 'stderr' : 'stdout') as 'stdout' | 'stderr'
565
+ const peek = handler.peek(streamName)
566
+
567
+ return {
568
+ id: handler.id,
569
+ tag: handler.tag,
570
+ status: handler.status,
571
+ stream: streamName,
572
+ totalLines: peek.totalLines,
573
+ droppedLines: peek.droppedLines,
574
+ head: peek.head,
575
+ tail: peek.tail,
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Tool handler: kill a process by ID or tag.
581
+ */
582
+ async killProcess(args: { id?: string; tag?: string; signal?: string }) {
583
+ const handler = args.id ? this.get(args.id) : args.tag ? this.getByTag(args.tag) : undefined
584
+ if (!handler) return { error: `Process not found. Provide a valid id or tag.` }
585
+
586
+ if (handler.isDone) {
587
+ return { id: handler.id, status: handler.status, message: 'Process already finished.' }
588
+ }
589
+
590
+ const signal = (args.signal ?? 'SIGTERM') as NodeJS.Signals
591
+ handler.kill(signal)
592
+
593
+ return { id: handler.id, status: handler.status, signal, message: 'Process killed.' }
594
+ }
595
+
596
+ // ─── Core API ───────────────────────────────────────────────────────────
597
+
344
598
  /**
345
599
  * Spawn a long-running process and return a handle immediately.
346
600
  *
@@ -540,4 +794,17 @@ export class ProcessManager extends Feature {
540
794
  }
541
795
  }
542
796
 
797
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
798
+
799
+ function formatDuration(ms: number): string {
800
+ const seconds = Math.floor(ms / 1000)
801
+ if (seconds < 60) return `${seconds}s`
802
+ const minutes = Math.floor(seconds / 60)
803
+ const secs = seconds % 60
804
+ if (minutes < 60) return `${minutes}m ${secs}s`
805
+ const hours = Math.floor(minutes / 60)
806
+ const mins = minutes % 60
807
+ return `${hours}h ${mins}m`
808
+ }
809
+
543
810
  export default ProcessManager