@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.
- package/docs/examples/assistant-with-process-manager.md +84 -0
- package/docs/examples/websocket-ask-and-reply-example.md +128 -0
- package/docs/window-manager-fix.md +249 -0
- package/package.json +1 -1
- package/src/agi/features/assistant.ts +75 -13
- package/src/agi/features/docs-reader.ts +25 -1
- package/src/bootstrap/generated.ts +215 -1
- package/src/cli/build-info.ts +2 -2
- package/src/clients/websocket.ts +76 -1
- package/src/command.ts +75 -0
- package/src/commands/describe.ts +29 -1089
- package/src/container-describer.ts +1098 -0
- package/src/container.ts +11 -0
- package/src/helper.ts +29 -2
- package/src/introspection/generated.agi.ts +1315 -611
- package/src/introspection/generated.node.ts +1168 -552
- package/src/introspection/generated.web.ts +9 -1
- package/src/node/features/content-db.ts +17 -0
- package/src/node/features/fs.ts +18 -0
- package/src/node/features/ipc-socket.ts +370 -180
- package/src/node/features/process-manager.ts +316 -49
- package/src/node/features/window-manager.ts +843 -235
- package/src/scaffolds/generated.ts +1 -1
- package/src/server.ts +40 -0
- package/src/servers/express.ts +2 -0
- package/src/servers/mcp.ts +1 -0
- package/src/servers/socket.ts +89 -0
- package/src/web/clients/socket.ts +22 -6
- package/test/websocket-ask.test.ts +101 -0
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
236
|
-
if (!stdin
|
|
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
|
-
|
|
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
|