@ericsanchezok/meta-synergy 0.0.0-dev-202603260728

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 (48) hide show
  1. package/.turbo/turbo-typecheck.log +1 -0
  2. package/dist/meta-protocol/src/bash.d.ts +89 -0
  3. package/dist/meta-protocol/src/bash.js +40 -0
  4. package/dist/meta-protocol/src/client.d.ts +9 -0
  5. package/dist/meta-protocol/src/client.js +1 -0
  6. package/dist/meta-protocol/src/env.d.ts +16 -0
  7. package/dist/meta-protocol/src/env.js +17 -0
  8. package/dist/meta-protocol/src/envelope.d.ts +50 -0
  9. package/dist/meta-protocol/src/envelope.js +23 -0
  10. package/dist/meta-protocol/src/error.d.ts +39 -0
  11. package/dist/meta-protocol/src/error.js +24 -0
  12. package/dist/meta-protocol/src/host.d.ts +90 -0
  13. package/dist/meta-protocol/src/host.js +27 -0
  14. package/dist/meta-protocol/src/index.d.ts +7 -0
  15. package/dist/meta-protocol/src/index.js +7 -0
  16. package/dist/meta-protocol/src/process.d.ts +274 -0
  17. package/dist/meta-protocol/src/process.js +89 -0
  18. package/dist/meta-synergy/src/client/holos-client.d.ts +15 -0
  19. package/dist/meta-synergy/src/client/holos-client.js +35 -0
  20. package/dist/meta-synergy/src/exec/bash-runner.d.ts +7 -0
  21. package/dist/meta-synergy/src/exec/bash-runner.js +9 -0
  22. package/dist/meta-synergy/src/exec/process-registry.d.ts +11 -0
  23. package/dist/meta-synergy/src/exec/process-registry.js +597 -0
  24. package/dist/meta-synergy/src/host.d.ts +32 -0
  25. package/dist/meta-synergy/src/host.js +27 -0
  26. package/dist/meta-synergy/src/index.d.ts +8 -0
  27. package/dist/meta-synergy/src/index.js +8 -0
  28. package/dist/meta-synergy/src/platform.d.ts +25 -0
  29. package/dist/meta-synergy/src/platform.js +230 -0
  30. package/dist/meta-synergy/src/rpc/handler.d.ts +66 -0
  31. package/dist/meta-synergy/src/rpc/handler.js +60 -0
  32. package/dist/meta-synergy/src/rpc/schema.d.ts +163 -0
  33. package/dist/meta-synergy/src/rpc/schema.js +11 -0
  34. package/dist/meta-synergy/src/types.d.ts +14 -0
  35. package/dist/meta-synergy/src/types.js +1 -0
  36. package/package.json +30 -0
  37. package/script/publish.ts +32 -0
  38. package/src/client/holos-client.ts +49 -0
  39. package/src/exec/bash-runner.ts +10 -0
  40. package/src/exec/process-registry.ts +728 -0
  41. package/src/host.ts +39 -0
  42. package/src/index.ts +8 -0
  43. package/src/platform.ts +227 -0
  44. package/src/rpc/handler.ts +76 -0
  45. package/src/rpc/schema.ts +16 -0
  46. package/src/types.ts +17 -0
  47. package/test/rpc-handler.test.ts +76 -0
  48. package/tsconfig.json +23 -0
@@ -0,0 +1,728 @@
1
+ import process from "node:process"
2
+ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
3
+ import { MetaProtocolBash, MetaProtocolEnv, MetaProtocolProcess } from "@ericsanchezok/meta-protocol"
4
+ import { MetaSynergyHost } from "../host"
5
+ import { Platform } from "../platform"
6
+
7
+ const MAX_OUTPUT_CHARS = 200_000
8
+ const TAIL_CHARS = 2_000
9
+ const DEFAULT_TTL_MS = 30 * 60 * 1000
10
+
11
+ interface ProcessRecord {
12
+ processId: MetaProtocolEnv.ProcessID
13
+ command: string
14
+ description?: string
15
+ cwd?: string
16
+ child: ChildProcess
17
+ stdin?: NodeJS.WritableStream
18
+ startedAt: number
19
+ output: string
20
+ tail: string
21
+ truncated: boolean
22
+ exitCode?: number | null
23
+ exitSignal?: NodeJS.Signals | number | null
24
+ exited: boolean
25
+ backgrounded: boolean
26
+ timedOut: boolean
27
+ timeoutTimer?: ReturnType<typeof setTimeout>
28
+ }
29
+
30
+ interface FinishedRecord {
31
+ processId: MetaProtocolEnv.ProcessID
32
+ command: string
33
+ description?: string
34
+ cwd?: string
35
+ status: MetaProtocolProcess.ProcessState
36
+ startedAt: number
37
+ endedAt: number
38
+ output: string
39
+ tail: string
40
+ truncated: boolean
41
+ exitCode?: number | null
42
+ exitSignal?: NodeJS.Signals | number | null
43
+ }
44
+
45
+ type CurrentOrFinished = {
46
+ processId: string
47
+ command: string
48
+ description?: string
49
+ output: string
50
+ tail: string
51
+ exitCode?: number | null
52
+ exitSignal?: NodeJS.Signals | number | null
53
+ status: MetaProtocolProcess.ProcessState
54
+ startedAt: number
55
+ endedAt?: number
56
+ timedOut?: boolean
57
+ }
58
+
59
+ export class ProcessRegistry {
60
+ readonly #running = new Map<MetaProtocolEnv.ProcessID, ProcessRecord>()
61
+ readonly #finished = new Map<MetaProtocolEnv.ProcessID, FinishedRecord>()
62
+ readonly #waiters = new Map<MetaProtocolEnv.ProcessID, Set<() => void>>()
63
+ readonly #ttlMs: number
64
+ readonly #host: MetaSynergyHost
65
+ #sweeper?: ReturnType<typeof setInterval>
66
+
67
+ constructor(host: MetaSynergyHost, options?: { ttlMs?: number }) {
68
+ this.#host = host
69
+ this.#ttlMs = Math.max(60_000, options?.ttlMs ?? DEFAULT_TTL_MS)
70
+ }
71
+
72
+ async executeBash(request: MetaProtocolBash.ExecutePayload, envID: string): Promise<MetaProtocolBash.Result> {
73
+ this.#host.assertEnv(envID)
74
+ const launched = this.#launchShellProcess({
75
+ command: request.command,
76
+ description: request.description,
77
+ workdir: Platform.resolveWorkdir(request.workdir),
78
+ timeoutMs: request.timeout,
79
+ })
80
+
81
+ if (request.background) {
82
+ launched.record.backgrounded = true
83
+ return this.#backgroundResult(launched.record, envID, request.description, "Background")
84
+ }
85
+
86
+ if (request.yieldMs && request.yieldMs > 0) {
87
+ const autoBackground = await Promise.race([
88
+ this.#waitForExit(launched.record.processId).then(() => false),
89
+ Platform.sleep(request.yieldMs).then(() => !launched.record.exited),
90
+ ])
91
+ if (autoBackground) {
92
+ launched.record.backgrounded = true
93
+ return this.#backgroundResult(launched.record, envID, request.description, "Auto-Background", request.yieldMs)
94
+ }
95
+ }
96
+
97
+ await this.#waitForExit(launched.record.processId)
98
+ const current = this.#getCurrentOrFinished(launched.record.processId)
99
+ const runtimeMs = this.#runtimeMs(current ?? launched.record)
100
+ const output = current?.output ?? launched.record.output
101
+ const exitCode = current?.exitCode ?? launched.record.exitCode ?? null
102
+ const timedOut = current?.timedOut ?? launched.record.timedOut
103
+
104
+ return {
105
+ title: request.description,
106
+ metadata: {
107
+ output,
108
+ description: request.description,
109
+ exit: typeof exitCode === "number" ? exitCode : null,
110
+ timedOut,
111
+ durationMs: runtimeMs,
112
+ hostSessionID: this.#host.hostSessionID,
113
+ envID,
114
+ backend: "remote",
115
+ },
116
+ output: appendRuntimeMetadata(output, runtimeMs, timedOut),
117
+ }
118
+ }
119
+
120
+ async execute(request: MetaProtocolProcess.ExecutePayload, envID: string): Promise<MetaProtocolProcess.Result> {
121
+ this.#host.assertEnv(envID)
122
+
123
+ if (request.action === "list") {
124
+ const processes = this.#listAll()
125
+ return {
126
+ title: "Process list",
127
+ metadata: {
128
+ action: "list",
129
+ processes,
130
+ hostSessionID: this.#host.hostSessionID,
131
+ envID,
132
+ backend: "remote",
133
+ },
134
+ output: processes.length > 0 ? processes.map(renderProcessLine).join("\n") : "No running or recent processes.",
135
+ }
136
+ }
137
+
138
+ const processId = request.processId
139
+ if (!processId) {
140
+ return this.#result({
141
+ action: request.action,
142
+ title: "Process not found",
143
+ output: "processId is required for this action",
144
+ status: "not_found",
145
+ envID,
146
+ })
147
+ }
148
+
149
+ switch (request.action) {
150
+ case "poll":
151
+ return this.#poll(processId, envID, request.block, request.timeout)
152
+ case "log":
153
+ return this.#log(processId, envID, request.offset, request.limit)
154
+ case "write":
155
+ return this.#write(processId, envID, request.data ?? "")
156
+ case "send-keys":
157
+ return this.#sendKeys(processId, envID, request.keys ?? [])
158
+ case "kill":
159
+ return this.#kill(processId, envID)
160
+ case "clear":
161
+ return this.#clear(processId, envID)
162
+ case "remove":
163
+ return this.#remove(processId, envID)
164
+ }
165
+ }
166
+
167
+ reset() {
168
+ for (const record of this.#running.values()) {
169
+ void Platform.killTree(record.child, () => record.exited)
170
+ }
171
+ this.#running.clear()
172
+ this.#finished.clear()
173
+ this.#waiters.clear()
174
+ if (this.#sweeper) {
175
+ clearInterval(this.#sweeper)
176
+ this.#sweeper = undefined
177
+ }
178
+ }
179
+
180
+ #launchShellProcess(input: { command: string; description?: string; workdir: string; timeoutMs?: number }) {
181
+ const launch = Platform.resolveShellLaunch(input.command)
182
+ const options: SpawnOptions = {
183
+ cwd: input.workdir,
184
+ env: Platform.normalizeEnv({ ...process.env }),
185
+ stdio: ["pipe", "pipe", "pipe"],
186
+ detached: process.platform !== "win32",
187
+ windowsHide: true,
188
+ }
189
+ const child = spawn(launch.file, launch.args, options)
190
+ const processId = crypto.randomUUID()
191
+ const record: ProcessRecord = {
192
+ processId,
193
+ command: input.command,
194
+ description: input.description,
195
+ cwd: input.workdir,
196
+ child,
197
+ stdin: child.stdin || undefined,
198
+ startedAt: Date.now(),
199
+ output: "",
200
+ tail: "",
201
+ truncated: false,
202
+ exited: false,
203
+ backgrounded: false,
204
+ timedOut: false,
205
+ }
206
+
207
+ const append = (chunk?: Uint8Array | string | null) => {
208
+ if (chunk == null) return
209
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")
210
+ const next = record.output + text
211
+ if (next.length > MAX_OUTPUT_CHARS) {
212
+ record.output = next.slice(next.length - MAX_OUTPUT_CHARS)
213
+ record.truncated = true
214
+ } else {
215
+ record.output = next
216
+ }
217
+ record.tail = record.output.slice(-TAIL_CHARS)
218
+ }
219
+
220
+ child.stdout?.on("data", append)
221
+ child.stderr?.on("data", append)
222
+
223
+ child.once("exit", (code: number | null, signal: NodeJS.Signals | null) => {
224
+ if (record.timeoutTimer) {
225
+ clearTimeout(record.timeoutTimer)
226
+ record.timeoutTimer = undefined
227
+ }
228
+ this.#markExited(record, code, signal)
229
+ })
230
+
231
+ child.once("error", (error: Error) => {
232
+ append(String(error))
233
+ if (record.timeoutTimer) {
234
+ clearTimeout(record.timeoutTimer)
235
+ record.timeoutTimer = undefined
236
+ }
237
+ this.#markExited(record, 1, null)
238
+ })
239
+
240
+ if (input.timeoutMs && input.timeoutMs > 0) {
241
+ record.timeoutTimer = setTimeout(() => {
242
+ record.timedOut = true
243
+ void Platform.killTree(child, () => record.exited)
244
+ }, input.timeoutMs)
245
+ unrefTimer(record.timeoutTimer)
246
+ }
247
+
248
+ this.#running.set(processId, record)
249
+ this.#startSweeper()
250
+ return { record }
251
+ }
252
+
253
+ async #poll(
254
+ processId: string,
255
+ envID: string,
256
+ block?: boolean,
257
+ timeoutSeconds?: number,
258
+ ): Promise<MetaProtocolProcess.Result> {
259
+ const running = this.#running.get(processId)
260
+ const finished = this.#finished.get(processId)
261
+ if (!running && !finished) {
262
+ return this.#result({
263
+ action: "poll",
264
+ title: "Process not found",
265
+ output: `No process found for ${processId}`,
266
+ processId,
267
+ status: "not_found",
268
+ envID,
269
+ })
270
+ }
271
+
272
+ if (running && block) {
273
+ await this.#waitForExit(processId, (timeoutSeconds ?? 30) * 1000)
274
+ }
275
+
276
+ const currentRunning = this.#running.get(processId)
277
+ if (currentRunning) {
278
+ return this.#result({
279
+ action: "poll",
280
+ title: `Process ${processId}`,
281
+ output: (currentRunning.tail || "(no output yet)") + "\n\nProcess still running.",
282
+ processId,
283
+ status: "running",
284
+ command: currentRunning.command,
285
+ description: currentRunning.description,
286
+ envID,
287
+ })
288
+ }
289
+
290
+ const currentFinished = this.#finished.get(processId)
291
+ if (!currentFinished) {
292
+ return this.#result({
293
+ action: "poll",
294
+ title: "Process not found",
295
+ output: `No process found for ${processId}`,
296
+ processId,
297
+ status: "not_found",
298
+ envID,
299
+ })
300
+ }
301
+
302
+ return this.#result({
303
+ action: "poll",
304
+ title: `Process ${processId}`,
305
+ output:
306
+ (currentFinished.tail || "(no output recorded)") +
307
+ `\n\nProcess exited with ${currentFinished.exitSignal ? `signal ${currentFinished.exitSignal}` : `code ${currentFinished.exitCode ?? 0}`}.`,
308
+ processId,
309
+ status: currentFinished.status,
310
+ command: currentFinished.command,
311
+ description: currentFinished.description,
312
+ exitCode: typeof currentFinished.exitCode === "number" ? currentFinished.exitCode : undefined,
313
+ envID,
314
+ })
315
+ }
316
+
317
+ async #log(processId: string, envID: string, offset = 0, limit?: number): Promise<MetaProtocolProcess.Result> {
318
+ const target = this.#getCurrentOrFinished(processId)
319
+ if (!target) {
320
+ return this.#result({
321
+ action: "log",
322
+ title: "Process not found",
323
+ output: `No process found for ${processId}`,
324
+ processId,
325
+ status: "not_found",
326
+ envID,
327
+ })
328
+ }
329
+
330
+ const lines = target.output.split("\n")
331
+ const count = limit ?? lines.length
332
+ return this.#result({
333
+ action: "log",
334
+ title: `Log: ${processId}`,
335
+ output: lines.slice(offset, offset + count).join("\n") || "(no output)",
336
+ processId,
337
+ status: target.status,
338
+ command: target.command,
339
+ description: target.description,
340
+ nextOffset: Math.min(offset + count, lines.length),
341
+ envID,
342
+ })
343
+ }
344
+
345
+ async #write(processId: string, envID: string, data: string): Promise<MetaProtocolProcess.Result> {
346
+ const record = this.#running.get(processId)
347
+ if (!record) {
348
+ return this.#result({
349
+ action: "write",
350
+ title: "Process not found",
351
+ output: `No active process found for ${processId}`,
352
+ processId,
353
+ status: "not_found",
354
+ envID,
355
+ })
356
+ }
357
+
358
+ if (!record.backgrounded) {
359
+ return this.#result({
360
+ action: "write",
361
+ title: "Process not backgrounded",
362
+ output: `Process ${processId} is not a background process.`,
363
+ processId,
364
+ status: "error",
365
+ command: record.command,
366
+ description: record.description,
367
+ envID,
368
+ })
369
+ }
370
+
371
+ const stdin = record.stdin
372
+ if (!stdin || (stdin as NodeJS.WritableStream & { destroyed?: boolean }).destroyed) {
373
+ return this.#result({
374
+ action: "write",
375
+ title: "Stdin not writable",
376
+ output: `Process ${processId} stdin is not writable.`,
377
+ processId,
378
+ status: "error",
379
+ command: record.command,
380
+ description: record.description,
381
+ envID,
382
+ })
383
+ }
384
+
385
+ await new Promise<void>((resolve, reject) => {
386
+ stdin.write(data, (error?: Error | null) => {
387
+ if (error) reject(error)
388
+ else resolve()
389
+ })
390
+ })
391
+
392
+ return this.#result({
393
+ action: "write",
394
+ title: `Wrote to ${processId}`,
395
+ output: `Wrote ${data.length} bytes to process ${processId}.`,
396
+ processId,
397
+ status: "running",
398
+ command: record.command,
399
+ description: record.description,
400
+ envID,
401
+ })
402
+ }
403
+
404
+ async #sendKeys(processId: string, envID: string, keys: string[]): Promise<MetaProtocolProcess.Result> {
405
+ if (keys.length === 0) {
406
+ return this.#result({
407
+ action: "send-keys",
408
+ title: "No keys provided",
409
+ output: "No key tokens provided for send-keys.",
410
+ processId,
411
+ status: "error",
412
+ envID,
413
+ })
414
+ }
415
+
416
+ const encoded = Platform.encodeKeySequence(keys)
417
+ const result = await this.#write(processId, envID, encoded.data)
418
+ return {
419
+ title: `Sent keys to ${processId}`,
420
+ metadata: {
421
+ ...result.metadata,
422
+ action: "send-keys",
423
+ },
424
+ output:
425
+ `Sent ${encoded.data.length} bytes to process ${processId}.` +
426
+ (encoded.warnings.length > 0 ? `\nWarnings: ${encoded.warnings.join(", ")}` : ""),
427
+ }
428
+ }
429
+
430
+ async #kill(processId: string, envID: string): Promise<MetaProtocolProcess.Result> {
431
+ const record = this.#running.get(processId)
432
+ if (!record) {
433
+ return this.#result({
434
+ action: "kill",
435
+ title: "Process not found",
436
+ output: `No active process found for ${processId}`,
437
+ processId,
438
+ status: "not_found",
439
+ envID,
440
+ })
441
+ }
442
+
443
+ await Platform.killTree(record.child, () => record.exited)
444
+ return this.#result({
445
+ action: "kill",
446
+ title: `Killed ${processId}`,
447
+ output: `Killed process ${processId}.`,
448
+ processId,
449
+ status: "killed",
450
+ command: record.command,
451
+ description: record.description,
452
+ envID,
453
+ })
454
+ }
455
+
456
+ async #clear(processId: string, envID: string): Promise<MetaProtocolProcess.Result> {
457
+ const finished = this.#finished.get(processId)
458
+ if (!finished) {
459
+ if (this.#running.has(processId)) {
460
+ return this.#result({
461
+ action: "clear",
462
+ title: "Process still running",
463
+ output: `Process ${processId} is still running. Use kill or remove instead.`,
464
+ processId,
465
+ status: "error",
466
+ envID,
467
+ })
468
+ }
469
+ return this.#result({
470
+ action: "clear",
471
+ title: "Process not found",
472
+ output: `No finished process found for ${processId}`,
473
+ processId,
474
+ status: "not_found",
475
+ envID,
476
+ })
477
+ }
478
+
479
+ this.#finished.delete(processId)
480
+ return this.#result({
481
+ action: "clear",
482
+ title: `Cleared ${processId}`,
483
+ output: `Cleared process ${processId} from history.`,
484
+ processId,
485
+ status: "cleared",
486
+ command: finished.command,
487
+ description: finished.description,
488
+ envID,
489
+ })
490
+ }
491
+
492
+ async #remove(processId: string, envID: string): Promise<MetaProtocolProcess.Result> {
493
+ const running = this.#running.get(processId)
494
+ const finished = this.#finished.get(processId)
495
+ if (running) {
496
+ await Platform.killTree(running.child, () => running.exited)
497
+ this.#running.delete(processId)
498
+ }
499
+ this.#finished.delete(processId)
500
+
501
+ return this.#result({
502
+ action: "remove",
503
+ title: `Removed ${processId}`,
504
+ output: `Removed process ${processId}.`,
505
+ processId,
506
+ status: "removed",
507
+ command: running?.command || finished?.command,
508
+ description: running?.description || finished?.description,
509
+ envID,
510
+ })
511
+ }
512
+
513
+ #result(input: {
514
+ action: MetaProtocolProcess.Action
515
+ title: string
516
+ output: string
517
+ status?: MetaProtocolProcess.ActionStatus
518
+ processId?: string
519
+ command?: string
520
+ description?: string
521
+ exitCode?: number
522
+ nextOffset?: number
523
+ envID: string
524
+ processes?: MetaProtocolProcess.ProcessInfo[]
525
+ }): MetaProtocolProcess.Result {
526
+ return {
527
+ title: input.title,
528
+ metadata: {
529
+ action: input.action,
530
+ processId: input.processId,
531
+ status: input.status,
532
+ exitCode: input.exitCode,
533
+ command: input.command,
534
+ description: input.description,
535
+ nextOffset: input.nextOffset,
536
+ hostSessionID: this.#host.hostSessionID,
537
+ envID: input.envID,
538
+ backend: "remote",
539
+ processes: input.processes,
540
+ },
541
+ output: input.output,
542
+ }
543
+ }
544
+
545
+ #backgroundResult(
546
+ record: ProcessRecord,
547
+ envID: string,
548
+ description: string,
549
+ mode: "Background" | "Auto-Background",
550
+ yieldMs?: number,
551
+ ): MetaProtocolBash.Result {
552
+ const prefix =
553
+ mode === "Auto-Background" ? `Command auto-backgrounded after ${yieldMs}ms.` : "Command started in background."
554
+ return {
555
+ title: `[${mode}] ${description}`,
556
+ metadata: {
557
+ output: record.tail,
558
+ description,
559
+ processId: record.processId,
560
+ background: true,
561
+ durationMs: this.#runtimeMs(record),
562
+ hostSessionID: this.#host.hostSessionID,
563
+ envID,
564
+ backend: "remote",
565
+ },
566
+ output:
567
+ `${prefix}\n\n` +
568
+ `Process ID: ${record.processId}\n` +
569
+ `Command: ${record.command}\n` +
570
+ `Status: running\n\n` +
571
+ `Recent output:\n${record.tail || "(no output yet)"}\n\n` +
572
+ `Use process(action: \"poll\", processId: \"${record.processId}\", envID: \"${envID}\") to check status.\n` +
573
+ `Use process(action: \"log\", processId: \"${record.processId}\", envID: \"${envID}\") to get full output.\n` +
574
+ `Use process(action: \"kill\", processId: \"${record.processId}\", envID: \"${envID}\") to terminate.`,
575
+ }
576
+ }
577
+
578
+ #markExited(record: ProcessRecord, exitCode: number | null, exitSignal: NodeJS.Signals | number | null) {
579
+ if (record.exited) return
580
+ record.exited = true
581
+ record.exitCode = exitCode
582
+ record.exitSignal = exitSignal
583
+ record.tail = record.output.slice(-TAIL_CHARS)
584
+ this.#running.delete(record.processId)
585
+
586
+ if (record.backgrounded) {
587
+ this.#finished.set(record.processId, {
588
+ processId: record.processId,
589
+ command: record.command,
590
+ description: record.description,
591
+ cwd: record.cwd,
592
+ status: classifyExit(exitCode, exitSignal),
593
+ startedAt: record.startedAt,
594
+ endedAt: Date.now(),
595
+ output: record.output,
596
+ tail: record.tail,
597
+ truncated: record.truncated,
598
+ exitCode,
599
+ exitSignal,
600
+ })
601
+ }
602
+
603
+ const waiters = this.#waiters.get(record.processId)
604
+ if (waiters) {
605
+ this.#waiters.delete(record.processId)
606
+ for (const resolve of waiters) resolve()
607
+ }
608
+ }
609
+
610
+ #waitForExit(processId: string, timeoutMs?: number): Promise<void> {
611
+ const running = this.#running.get(processId)
612
+ if (!running || running.exited) return Promise.resolve()
613
+
614
+ return new Promise((resolve) => {
615
+ const waiters = this.#waiters.get(processId) || new Set<() => void>()
616
+ const done = () => resolve()
617
+ waiters.add(done)
618
+ this.#waiters.set(processId, waiters)
619
+
620
+ if (timeoutMs && timeoutMs > 0) {
621
+ const timer = setTimeout(() => {
622
+ waiters.delete(done)
623
+ resolve()
624
+ }, timeoutMs)
625
+ unrefTimer(timer)
626
+ }
627
+ })
628
+ }
629
+
630
+ #getCurrentOrFinished(processId: string): CurrentOrFinished | undefined {
631
+ const running = this.#running.get(processId)
632
+ if (running) {
633
+ return {
634
+ processId: running.processId,
635
+ command: running.command,
636
+ description: running.description,
637
+ output: running.output,
638
+ tail: running.tail,
639
+ exitCode: running.exitCode,
640
+ exitSignal: running.exitSignal,
641
+ status: "running",
642
+ startedAt: running.startedAt,
643
+ timedOut: running.timedOut,
644
+ }
645
+ }
646
+
647
+ const finished = this.#finished.get(processId)
648
+ if (!finished) return undefined
649
+ return finished
650
+ }
651
+
652
+ #listAll(): MetaProtocolProcess.ProcessInfo[] {
653
+ const running = [...this.#running.values()]
654
+ .filter((record) => record.backgrounded)
655
+ .map((record) => ({
656
+ processId: record.processId,
657
+ status: "running" as const,
658
+ command: trimCommand(record.command),
659
+ description: record.description,
660
+ runtimeMs: this.#runtimeMs(record),
661
+ }))
662
+
663
+ const finished = [...this.#finished.values()].map((record) => ({
664
+ processId: record.processId,
665
+ status: record.status,
666
+ command: trimCommand(record.command),
667
+ description: record.description,
668
+ runtimeMs: record.endedAt - record.startedAt,
669
+ }))
670
+
671
+ return [...running, ...finished].sort((left, right) => right.runtimeMs - left.runtimeMs)
672
+ }
673
+
674
+ #runtimeMs(record: { startedAt: number; endedAt?: number }): number {
675
+ return (record.endedAt ?? Date.now()) - record.startedAt
676
+ }
677
+
678
+ #startSweeper() {
679
+ if (this.#sweeper) return
680
+ this.#sweeper = setInterval(
681
+ () => {
682
+ const cutoff = Date.now() - this.#ttlMs
683
+ for (const [processId, record] of this.#finished.entries()) {
684
+ if (record.endedAt < cutoff) this.#finished.delete(processId)
685
+ }
686
+ },
687
+ Math.max(30_000, Math.floor(this.#ttlMs / 6)),
688
+ )
689
+ unrefTimer(this.#sweeper)
690
+ }
691
+ }
692
+
693
+ function appendRuntimeMetadata(output: string, durationMs: number, timedOut: boolean): string {
694
+ const lines = [`durationMs=${durationMs}`]
695
+ if (timedOut) lines.push("timedOut=true")
696
+ return output + `\n\n<meta_runtime>\n${lines.join("\n")}\n</meta_runtime>`
697
+ }
698
+
699
+ function classifyExit(
700
+ exitCode: number | null | undefined,
701
+ exitSignal: NodeJS.Signals | number | null | undefined,
702
+ ): MetaProtocolProcess.ProcessState {
703
+ if (exitSignal === "SIGKILL" || exitSignal === "SIGTERM") return "killed"
704
+ return (exitCode ?? 0) === 0 ? "completed" : "failed"
705
+ }
706
+
707
+ function trimCommand(command: string): string {
708
+ return command.length > 80 ? `${command.slice(0, 77)}...` : command
709
+ }
710
+
711
+ function renderProcessLine(processInfo: MetaProtocolProcess.ProcessInfo): string {
712
+ const label = processInfo.description || processInfo.command
713
+ return `${processInfo.processId} ${processInfo.status.padEnd(9)} ${formatDuration(processInfo.runtimeMs)} :: ${label}`
714
+ }
715
+
716
+ function formatDuration(ms: number): string {
717
+ const seconds = Math.floor(ms / 1000)
718
+ if (seconds < 60) return `${seconds}s`.padStart(6)
719
+ const minutes = Math.floor(seconds / 60)
720
+ const remainder = seconds % 60
721
+ return `${minutes}m${remainder}s`.padStart(6)
722
+ }
723
+
724
+ function unrefTimer(timer: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>) {
725
+ if (typeof timer === "object" && timer && "unref" in timer && typeof timer.unref === "function") {
726
+ timer.unref()
727
+ }
728
+ }