@opencode-cockpit/daemon 0.1.3 → 0.1.5
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/dist/core/daemon.js +193 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/logger.js +45 -0
- package/dist/core/module.js +1 -0
- package/dist/core/router.js +33 -0
- package/dist/core/server.js +211 -0
- package/dist/index.js +3 -0
- package/dist/main.js +40 -0
- package/dist/modules/index.js +5 -0
- package/dist/modules/shell/ids.js +7 -0
- package/dist/modules/shell/module.js +334 -0
- package/dist/modules/shell/output/line-log.js +76 -0
- package/dist/modules/shell/output/normalizer.js +161 -0
- package/dist/modules/shell/output/raw-ring.js +49 -0
- package/dist/modules/shell/output/screen.js +45 -0
- package/dist/modules/shell/port-probe.js +30 -0
- package/dist/modules/shell/pty.js +59 -0
- package/dist/modules/shell/registry.js +83 -0
- package/dist/modules/shell/shell.js +193 -0
- package/dist/modules/shell/wait.js +107 -0
- package/package.json +12 -5
- package/types/core/daemon.d.ts +36 -0
- package/types/core/errors.d.ts +4 -0
- package/types/core/logger.d.ts +11 -0
- package/types/core/module.d.ts +37 -0
- package/types/core/router.d.ts +11 -0
- package/types/core/server.d.ts +49 -0
- package/{src/index.ts → types/index.d.ts} +5 -5
- package/types/main.d.ts +2 -0
- package/types/modules/index.d.ts +7 -0
- package/types/modules/shell/ids.d.ts +1 -0
- package/types/modules/shell/module.d.ts +43 -0
- package/types/modules/shell/output/line-log.d.ts +36 -0
- package/types/modules/shell/output/normalizer.d.ts +32 -0
- package/types/modules/shell/output/raw-ring.d.ts +19 -0
- package/types/modules/shell/output/screen.d.ts +12 -0
- package/types/modules/shell/port-probe.d.ts +2 -0
- package/types/modules/shell/pty.d.ts +33 -0
- package/types/modules/shell/registry.d.ts +17 -0
- package/types/modules/shell/shell.d.ts +77 -0
- package/types/modules/shell/wait.d.ts +12 -0
- package/src/core/daemon.ts +0 -197
- package/src/core/errors.ts +0 -6
- package/src/core/logger.ts +0 -44
- package/src/core/module.ts +0 -45
- package/src/core/router.ts +0 -40
- package/src/core/server.ts +0 -223
- package/src/main.ts +0 -36
- package/src/modules/index.ts +0 -11
- package/src/modules/shell/ids.ts +0 -8
- package/src/modules/shell/module.ts +0 -323
- package/src/modules/shell/output/line-log.ts +0 -92
- package/src/modules/shell/output/normalizer.ts +0 -172
- package/src/modules/shell/output/raw-ring.ts +0 -46
- package/src/modules/shell/output/screen.ts +0 -44
- package/src/modules/shell/port-probe.ts +0 -30
- package/src/modules/shell/pty.ts +0 -98
- package/src/modules/shell/registry.ts +0 -86
- package/src/modules/shell/shell.ts +0 -252
- package/src/modules/shell/wait.ts +0 -91
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Streaming terminal-output normalizer (ADR 0003).
|
|
3
|
-
*
|
|
4
|
-
* Turns a PTY byte stream into committed plain-text lines, applying the parts of terminal
|
|
5
|
-
* semantics that matter for a single line: carriage return and backspace overwrite, tabs, erase
|
|
6
|
-
* in line, and horizontal cursor moves. Escape sequences are consumed and dropped. Anything that
|
|
7
|
-
* moves between lines (cursor up, scroll regions) is out of scope; the screen view handles those.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const ESC = 0x1b
|
|
11
|
-
const BEL = 0x07
|
|
12
|
-
const TAB_WIDTH = 8
|
|
13
|
-
|
|
14
|
-
enum State {
|
|
15
|
-
Ground,
|
|
16
|
-
Escape,
|
|
17
|
-
EscapeIntermediate,
|
|
18
|
-
Csi,
|
|
19
|
-
Osc,
|
|
20
|
-
OscEscape,
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface NormalizerOptions {
|
|
24
|
-
/** Force a commit when a line grows past this many characters. */
|
|
25
|
-
maxLineLength?: number
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export class OutputNormalizer {
|
|
29
|
-
private readonly decoder = new TextDecoder()
|
|
30
|
-
private readonly maxLineLength: number
|
|
31
|
-
private state = State.Ground
|
|
32
|
-
private csiParams = ""
|
|
33
|
-
private cells: string[] = []
|
|
34
|
-
private col = 0
|
|
35
|
-
|
|
36
|
-
constructor(
|
|
37
|
-
private readonly commit: (text: string) => void,
|
|
38
|
-
options: NormalizerOptions = {},
|
|
39
|
-
) {
|
|
40
|
-
this.maxLineLength = options.maxLineLength ?? 10_000
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Text of the line currently being written (not yet terminated by a newline). */
|
|
44
|
-
get partial(): string {
|
|
45
|
-
return this.render()
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
push(chunk: Uint8Array | string): void {
|
|
49
|
-
const text = typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true })
|
|
50
|
-
for (const char of text) this.step(char)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Commit the partial line, if any. Call when the stream ends. */
|
|
54
|
-
flush(): void {
|
|
55
|
-
const tail = this.render()
|
|
56
|
-
if (tail.length > 0) this.commit(tail)
|
|
57
|
-
this.cells = []
|
|
58
|
-
this.col = 0
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
private step(char: string): void {
|
|
62
|
-
const code = char.codePointAt(0) ?? 0
|
|
63
|
-
switch (this.state) {
|
|
64
|
-
case State.Ground:
|
|
65
|
-
this.ground(char, code)
|
|
66
|
-
return
|
|
67
|
-
case State.Escape:
|
|
68
|
-
if (code === 0x5b) {
|
|
69
|
-
this.state = State.Csi
|
|
70
|
-
this.csiParams = ""
|
|
71
|
-
} else if (code === 0x5d) {
|
|
72
|
-
this.state = State.Osc
|
|
73
|
-
} else if (code >= 0x20 && code <= 0x2f) {
|
|
74
|
-
this.state = State.EscapeIntermediate
|
|
75
|
-
} else {
|
|
76
|
-
this.state = State.Ground
|
|
77
|
-
}
|
|
78
|
-
return
|
|
79
|
-
case State.EscapeIntermediate:
|
|
80
|
-
if (code >= 0x30 && code <= 0x7e) this.state = State.Ground
|
|
81
|
-
return
|
|
82
|
-
case State.Csi:
|
|
83
|
-
if (code >= 0x40 && code <= 0x7e) {
|
|
84
|
-
this.csi(char, this.csiParams)
|
|
85
|
-
this.state = State.Ground
|
|
86
|
-
} else {
|
|
87
|
-
this.csiParams += char
|
|
88
|
-
}
|
|
89
|
-
return
|
|
90
|
-
case State.Osc:
|
|
91
|
-
if (code === BEL) this.state = State.Ground
|
|
92
|
-
else if (code === ESC) this.state = State.OscEscape
|
|
93
|
-
return
|
|
94
|
-
case State.OscEscape:
|
|
95
|
-
this.state = code === 0x5c ? State.Ground : State.Osc
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
private ground(char: string, code: number): void {
|
|
101
|
-
if (code === ESC) {
|
|
102
|
-
this.state = State.Escape
|
|
103
|
-
return
|
|
104
|
-
}
|
|
105
|
-
if (char === "\n") {
|
|
106
|
-
this.commit(this.render())
|
|
107
|
-
this.cells = []
|
|
108
|
-
this.col = 0
|
|
109
|
-
return
|
|
110
|
-
}
|
|
111
|
-
if (char === "\r") {
|
|
112
|
-
this.col = 0
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
if (char === "\b") {
|
|
116
|
-
this.col = Math.max(0, this.col - 1)
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
if (char === "\t") {
|
|
120
|
-
const next = (Math.floor(this.col / TAB_WIDTH) + 1) * TAB_WIDTH
|
|
121
|
-
while (this.col < next) this.put(" ")
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
if (code < 0x20 || code === 0x7f) return
|
|
125
|
-
this.put(char)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
private csi(final: string, raw: string): void {
|
|
129
|
-
const params = raw.replace(/^[?>=!]/, "")
|
|
130
|
-
const first = Number.parseInt(params.split(";")[0] ?? "", 10)
|
|
131
|
-
const n = Number.isNaN(first) ? undefined : first
|
|
132
|
-
switch (final) {
|
|
133
|
-
case "K": // erase in line
|
|
134
|
-
if (n === undefined || n === 0) this.cells.length = Math.min(this.cells.length, this.col)
|
|
135
|
-
else if (n === 1) for (let i = 0; i <= this.col && i < this.cells.length; i++) this.cells[i] = " "
|
|
136
|
-
else if (n === 2) this.cells = []
|
|
137
|
-
return
|
|
138
|
-
case "G": // cursor horizontal absolute (1-based)
|
|
139
|
-
this.col = Math.max(0, (n ?? 1) - 1)
|
|
140
|
-
return
|
|
141
|
-
case "C": // cursor forward
|
|
142
|
-
this.col += Math.max(1, n ?? 1)
|
|
143
|
-
return
|
|
144
|
-
case "D": // cursor back
|
|
145
|
-
this.col = Math.max(0, this.col - Math.max(1, n ?? 1))
|
|
146
|
-
return
|
|
147
|
-
case "J": // erase display: the current line is all this view can clear
|
|
148
|
-
if (n === 2 || n === 3) {
|
|
149
|
-
this.cells = []
|
|
150
|
-
this.col = 0
|
|
151
|
-
}
|
|
152
|
-
return
|
|
153
|
-
default:
|
|
154
|
-
return // colours (m) and everything else carry no line text
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
private put(char: string): void {
|
|
159
|
-
while (this.cells.length < this.col) this.cells.push(" ")
|
|
160
|
-
this.cells[this.col] = char
|
|
161
|
-
this.col++
|
|
162
|
-
if (this.cells.length >= this.maxLineLength) {
|
|
163
|
-
this.commit(this.render())
|
|
164
|
-
this.cells = []
|
|
165
|
-
this.col = 0
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
private render(): string {
|
|
170
|
-
return this.cells.join("").trimEnd()
|
|
171
|
-
}
|
|
172
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bounded store of raw PTY bytes addressed by absolute offset, for replaying output to UIs that
|
|
3
|
-
* attach after the fact. Evicts whole chunks from the front.
|
|
4
|
-
*/
|
|
5
|
-
export class RawRing {
|
|
6
|
-
private chunks: Uint8Array[] = []
|
|
7
|
-
private size = 0
|
|
8
|
-
private start = 0
|
|
9
|
-
|
|
10
|
-
constructor(private readonly maxBytes = 1_000_000) {}
|
|
11
|
-
|
|
12
|
-
/** Absolute offset one past the last byte ever written. */
|
|
13
|
-
get end(): number {
|
|
14
|
-
return this.start + this.size
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
append(chunk: Uint8Array): number {
|
|
18
|
-
const offset = this.end
|
|
19
|
-
this.chunks.push(chunk)
|
|
20
|
-
this.size += chunk.byteLength
|
|
21
|
-
while (this.size > this.maxBytes && this.chunks.length > 1) {
|
|
22
|
-
const dropped = this.chunks.shift() as Uint8Array
|
|
23
|
-
this.size -= dropped.byteLength
|
|
24
|
-
this.start += dropped.byteLength
|
|
25
|
-
}
|
|
26
|
-
return offset
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Bytes from `offset` (clamped to what is retained) to the end. */
|
|
30
|
-
since(offset = 0): { offset: number; bytes: Uint8Array } {
|
|
31
|
-
const from = Math.max(offset, this.start)
|
|
32
|
-
const out = new Uint8Array(this.end - from)
|
|
33
|
-
let cursor = this.start
|
|
34
|
-
let written = 0
|
|
35
|
-
for (const chunk of this.chunks) {
|
|
36
|
-
const chunkEnd = cursor + chunk.byteLength
|
|
37
|
-
if (chunkEnd > from) {
|
|
38
|
-
const slice = chunk.subarray(Math.max(0, from - cursor))
|
|
39
|
-
out.set(slice, written)
|
|
40
|
-
written += slice.byteLength
|
|
41
|
-
}
|
|
42
|
-
cursor = chunkEnd
|
|
43
|
-
}
|
|
44
|
-
return { offset: from, bytes: out }
|
|
45
|
-
}
|
|
46
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { ScreenResult } from "@opencode-cockpit/protocol/shell"
|
|
2
|
-
import { Terminal } from "@xterm/headless"
|
|
3
|
-
|
|
4
|
-
/** Full VT emulation of a shell's output: what a human would see right now (ADR 0003). */
|
|
5
|
-
export class Screen {
|
|
6
|
-
private readonly term: Terminal
|
|
7
|
-
|
|
8
|
-
constructor(cols: number, rows: number, scrollback = 2000) {
|
|
9
|
-
this.term = new Terminal({ cols, rows, scrollback, allowProposedApi: true })
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
write(chunk: Uint8Array): void {
|
|
13
|
-
this.term.write(chunk)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
resize(cols: number, rows: number): void {
|
|
17
|
-
this.term.resize(cols, rows)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
reset(): void {
|
|
21
|
-
this.term.reset()
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Waits until every pending write has been parsed, then renders the viewport. */
|
|
25
|
-
async snapshot(): Promise<ScreenResult> {
|
|
26
|
-
await new Promise<void>((resolve) => this.term.write("", resolve))
|
|
27
|
-
const buffer = this.term.buffer.active
|
|
28
|
-
const rows: string[] = []
|
|
29
|
-
for (let y = 0; y < this.term.rows; y++) {
|
|
30
|
-
rows.push(buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? "")
|
|
31
|
-
}
|
|
32
|
-
while (rows.length > 0 && rows[rows.length - 1] === "") rows.pop()
|
|
33
|
-
return {
|
|
34
|
-
text: rows.join("\n"),
|
|
35
|
-
cols: this.term.cols,
|
|
36
|
-
rows: this.term.rows,
|
|
37
|
-
cursor: { x: buffer.cursorX, y: buffer.cursorY },
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
dispose(): void {
|
|
42
|
-
this.term.dispose()
|
|
43
|
-
}
|
|
44
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/** Resolves true once something accepts TCP connections on host:port. */
|
|
2
|
-
export async function probePort(port: number, host = "127.0.0.1", timeoutMs = 500): Promise<boolean> {
|
|
3
|
-
return new Promise((resolve) => {
|
|
4
|
-
let settled = false
|
|
5
|
-
const finish = (ok: boolean) => {
|
|
6
|
-
if (settled) return
|
|
7
|
-
settled = true
|
|
8
|
-
clearTimeout(timer)
|
|
9
|
-
resolve(ok)
|
|
10
|
-
}
|
|
11
|
-
const timer = setTimeout(() => finish(false), timeoutMs)
|
|
12
|
-
Bun.connect({
|
|
13
|
-
hostname: host,
|
|
14
|
-
port,
|
|
15
|
-
socket: {
|
|
16
|
-
open(socket) {
|
|
17
|
-
socket.end()
|
|
18
|
-
finish(true)
|
|
19
|
-
},
|
|
20
|
-
data() {},
|
|
21
|
-
error() {
|
|
22
|
-
finish(false)
|
|
23
|
-
},
|
|
24
|
-
connectError() {
|
|
25
|
-
finish(false)
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
}).catch(() => finish(false))
|
|
29
|
-
})
|
|
30
|
-
}
|
package/src/modules/shell/pty.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PTY backend seam. The shell module depends only on these interfaces so the process layer can be
|
|
3
|
-
* swapped (Windows ConPTY, remote hosts, test fakes) without touching session logic.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export interface PtySpawnOptions {
|
|
7
|
-
command: string
|
|
8
|
-
args: string[]
|
|
9
|
-
cwd: string
|
|
10
|
-
env: Record<string, string>
|
|
11
|
-
cols: number
|
|
12
|
-
rows: number
|
|
13
|
-
onData: (chunk: Uint8Array) => void
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface PtyExit {
|
|
17
|
-
exitCode: number | null
|
|
18
|
-
signal: string | null
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface PtyProcess {
|
|
22
|
-
readonly pid: number
|
|
23
|
-
readonly exited: Promise<PtyExit>
|
|
24
|
-
write(data: string | Uint8Array): number
|
|
25
|
-
resize(cols: number, rows: number): void
|
|
26
|
-
/** Signal the whole process group; falls back to the leader. */
|
|
27
|
-
signal(signal: NodeJS.Signals): void
|
|
28
|
-
/** True while any member of the process group is alive. */
|
|
29
|
-
groupAlive(): boolean
|
|
30
|
-
close(): void
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface PtyBackend {
|
|
34
|
-
spawn(options: PtySpawnOptions): PtyProcess
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface BunTerminal {
|
|
38
|
-
write(data: string | Uint8Array): number
|
|
39
|
-
resize(cols: number, rows: number): void
|
|
40
|
-
close(): void
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Native PTY via `Bun.spawn({ terminal })` (Bun ≥ 1.3.5). The child leads its own session and group. */
|
|
44
|
-
export const bunPtyBackend: PtyBackend = {
|
|
45
|
-
spawn(options) {
|
|
46
|
-
const proc = Bun.spawn([options.command, ...options.args], {
|
|
47
|
-
cwd: options.cwd,
|
|
48
|
-
env: options.env,
|
|
49
|
-
terminal: {
|
|
50
|
-
cols: options.cols,
|
|
51
|
-
rows: options.rows,
|
|
52
|
-
data(_terminal: unknown, chunk: Uint8Array) {
|
|
53
|
-
// Bun reuses the buffer between callbacks.
|
|
54
|
-
options.onData(chunk.slice())
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
} as Parameters<typeof Bun.spawn>[1]) as ReturnType<typeof Bun.spawn> & { terminal: BunTerminal }
|
|
58
|
-
|
|
59
|
-
const pid = proc.pid
|
|
60
|
-
const exited = proc.exited.then(() => ({
|
|
61
|
-
exitCode: proc.signalCode ? null : proc.exitCode,
|
|
62
|
-
signal: proc.signalCode ?? null,
|
|
63
|
-
}))
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
pid,
|
|
67
|
-
exited,
|
|
68
|
-
write: (data) => proc.terminal.write(data),
|
|
69
|
-
resize: (cols, rows) => proc.terminal.resize(cols, rows),
|
|
70
|
-
signal(signal) {
|
|
71
|
-
try {
|
|
72
|
-
process.kill(-pid, signal)
|
|
73
|
-
} catch {
|
|
74
|
-
try {
|
|
75
|
-
process.kill(pid, signal)
|
|
76
|
-
} catch {
|
|
77
|
-
// already gone
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
},
|
|
81
|
-
groupAlive() {
|
|
82
|
-
try {
|
|
83
|
-
process.kill(-pid, 0)
|
|
84
|
-
return true
|
|
85
|
-
} catch {
|
|
86
|
-
return false
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
close() {
|
|
90
|
-
try {
|
|
91
|
-
proc.terminal.close()
|
|
92
|
-
} catch {
|
|
93
|
-
// already closed
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
2
|
-
import type { Logger } from "../../core/logger.ts"
|
|
3
|
-
|
|
4
|
-
interface Entry {
|
|
5
|
-
id: string
|
|
6
|
-
pid: number
|
|
7
|
-
/** `ps -o lstart` of the pid when registered; guards against pid reuse. */
|
|
8
|
-
started: string
|
|
9
|
-
command: string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* On-disk record of process groups the daemon owns. If the daemon dies without stopping its
|
|
14
|
-
* shells, the next daemon kills whatever is still alive from that list.
|
|
15
|
-
*/
|
|
16
|
-
export class ProcessRegistry {
|
|
17
|
-
private entries = new Map<string, Entry>()
|
|
18
|
-
|
|
19
|
-
constructor(
|
|
20
|
-
private readonly file: string,
|
|
21
|
-
private readonly log: Logger,
|
|
22
|
-
) {}
|
|
23
|
-
|
|
24
|
-
/** Kill leftovers from a previous daemon. Returns how many process groups were reaped. */
|
|
25
|
-
reap(): number {
|
|
26
|
-
let previous: Entry[] = []
|
|
27
|
-
try {
|
|
28
|
-
previous = JSON.parse(readFileSync(this.file, "utf8")) as Entry[]
|
|
29
|
-
} catch {
|
|
30
|
-
// no registry yet, or unreadable: nothing to reap
|
|
31
|
-
}
|
|
32
|
-
let reaped = 0
|
|
33
|
-
for (const entry of previous) {
|
|
34
|
-
const started = processStartTime(entry.pid)
|
|
35
|
-
if (!started || started !== entry.started) continue // gone, or the pid now belongs to someone else
|
|
36
|
-
signalGroup(entry.pid, "SIGKILL")
|
|
37
|
-
reaped++
|
|
38
|
-
this.log.warn("reaped orphaned shell", { id: entry.id, pid: entry.pid, command: entry.command })
|
|
39
|
-
}
|
|
40
|
-
this.entries.clear()
|
|
41
|
-
this.flush()
|
|
42
|
-
return reaped
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
add(id: string, pid: number, command: string): void {
|
|
46
|
-
const started = processStartTime(pid)
|
|
47
|
-
if (!started) return
|
|
48
|
-
this.entries.set(id, { id, pid, started, command })
|
|
49
|
-
this.flush()
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
remove(id: string): void {
|
|
53
|
-
if (this.entries.delete(id)) this.flush()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
private flush(): void {
|
|
57
|
-
const tmp = `${this.file}.tmp`
|
|
58
|
-
try {
|
|
59
|
-
writeFileSync(tmp, JSON.stringify([...this.entries.values()]), { mode: 0o600 })
|
|
60
|
-
renameSync(tmp, this.file)
|
|
61
|
-
} catch (err) {
|
|
62
|
-
this.log.warn("could not write process registry", { err: String(err) })
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function processStartTime(pid: number): string | undefined {
|
|
68
|
-
const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
|
|
69
|
-
stdout: "pipe",
|
|
70
|
-
stderr: "ignore",
|
|
71
|
-
})
|
|
72
|
-
const text = result.stdout.toString().trim()
|
|
73
|
-
return result.exitCode === 0 && text ? text : undefined
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function signalGroup(pid: number, signal: NodeJS.Signals): void {
|
|
77
|
-
try {
|
|
78
|
-
process.kill(-pid, signal)
|
|
79
|
-
} catch {
|
|
80
|
-
try {
|
|
81
|
-
process.kill(pid, signal)
|
|
82
|
-
} catch {
|
|
83
|
-
// already gone
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|