@opencode-cockpit/daemon 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.
@@ -0,0 +1,30 @@
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
+ }
@@ -0,0 +1,98 @@
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
+ }
@@ -0,0 +1,86 @@
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
+ }
@@ -0,0 +1,247 @@
1
+ import type { LogLine, Owner, ScreenResult, ShellInfo, ShellStatus } from "@opencode-cockpit/protocol/shell"
2
+ import { LineLog } from "./output/line-log.ts"
3
+ import { OutputNormalizer } from "./output/normalizer.ts"
4
+ import { RawRing } from "./output/raw-ring.ts"
5
+ import { Screen } from "./output/screen.ts"
6
+ import type { PtyBackend, PtyExit, PtyProcess } from "./pty.ts"
7
+
8
+ const ERROR_LINE = /\b(error|err!|failed|failure|fatal|panic|exception|traceback)\b|✗|✖/i
9
+
10
+ export interface ShellSpec {
11
+ id: string
12
+ command: string
13
+ args: string[]
14
+ cwd: string
15
+ env: Record<string, string>
16
+ title: string
17
+ cols: number
18
+ rows: number
19
+ owner: Owner
20
+ timeoutMs?: number
21
+ }
22
+
23
+ export interface ShellLimits {
24
+ logChars: number
25
+ rawBytes: number
26
+ scrollback: number
27
+ }
28
+
29
+ export interface ShellListener {
30
+ data?(offset: number, chunk: Uint8Array): void
31
+ line?(line: LogLine): void
32
+ /** The in-progress line changed (prompts that never end in a newline). */
33
+ partial?(text: string): void
34
+ exit?(info: ShellInfo): void
35
+ }
36
+
37
+ /**
38
+ * One shell: a command, its PTY, and the three output views (ADR 0003).
39
+ * Survives restarts: `run` increments and output views continue, separated by a marker line.
40
+ */
41
+ export class Shell {
42
+ readonly log: LineLog
43
+ readonly raw: RawRing
44
+ readonly screen: Screen
45
+ status: ShellStatus = "running"
46
+ run = 0
47
+ /** First log line number belonging to the current run. */
48
+ runStartLine = 1
49
+ lastOutputAt = Date.now()
50
+
51
+ private pty: PtyProcess | undefined
52
+ private normalizer: OutputNormalizer
53
+ private listeners = new Set<ShellListener>()
54
+ private startedAt = 0
55
+ private endedAt: number | undefined
56
+ private exit: PtyExit | undefined
57
+ private error: string | undefined
58
+ private summary: string | undefined
59
+ private stopRequested = false
60
+ private timeout: ReturnType<typeof setTimeout> | undefined
61
+ private exitPromise: Promise<void> = Promise.resolve()
62
+
63
+ constructor(
64
+ readonly spec: ShellSpec,
65
+ private readonly backend: PtyBackend,
66
+ limits: ShellLimits,
67
+ ) {
68
+ this.log = new LineLog(limits.logChars)
69
+ this.raw = new RawRing(limits.rawBytes)
70
+ this.screen = new Screen(spec.cols, spec.rows, limits.scrollback)
71
+ this.normalizer = this.createNormalizer()
72
+ }
73
+
74
+ get id(): string {
75
+ return this.spec.id
76
+ }
77
+
78
+ get running(): boolean {
79
+ return this.status === "running"
80
+ }
81
+
82
+ /** Resolves when the current run has fully exited and been accounted for. */
83
+ get exited(): Promise<void> {
84
+ return this.exitPromise
85
+ }
86
+
87
+ subscribe(listener: ShellListener): () => void {
88
+ this.listeners.add(listener)
89
+ return () => this.listeners.delete(listener)
90
+ }
91
+
92
+ start(): void {
93
+ if (this.running && this.pty) throw new Error("shell is already running")
94
+ this.run++
95
+ if (this.run > 1) {
96
+ this.normalizer.flush()
97
+ this.log.append(`──── restart (run ${this.run}) ────`)
98
+ this.screen.reset()
99
+ }
100
+ this.runStartLine = this.log.lastLine + 1
101
+ this.status = "running"
102
+ this.startedAt = Date.now()
103
+ this.lastOutputAt = this.startedAt
104
+ this.endedAt = undefined
105
+ this.exit = undefined
106
+ this.error = undefined
107
+ this.summary = undefined
108
+ this.stopRequested = false
109
+
110
+ try {
111
+ this.pty = this.backend.spawn({
112
+ command: this.spec.command,
113
+ args: this.spec.args,
114
+ cwd: this.spec.cwd,
115
+ env: this.spec.env,
116
+ cols: this.spec.cols,
117
+ rows: this.spec.rows,
118
+ onData: (chunk) => this.onData(chunk),
119
+ })
120
+ } catch (err) {
121
+ this.pty = undefined
122
+ this.status = "failed"
123
+ this.error = err instanceof Error ? err.message : String(err)
124
+ this.endedAt = Date.now()
125
+ this.log.append(`[cockpit] failed to start: ${this.error}`)
126
+ throw err
127
+ }
128
+
129
+ const pty = this.pty
130
+ this.exitPromise = pty.exited.then((exit) => this.onExit(pty, exit))
131
+ if (this.spec.timeoutMs) {
132
+ this.timeout = setTimeout(() => void this.stop("SIGTERM", 3000), this.spec.timeoutMs)
133
+ }
134
+ }
135
+
136
+ write(data: string): number {
137
+ if (!this.running || !this.pty) throw new Error(`shell is ${this.status}`)
138
+ return this.pty.write(data)
139
+ }
140
+
141
+ resize(cols: number, rows: number): void {
142
+ this.spec.cols = cols
143
+ this.spec.rows = rows
144
+ this.screen.resize(cols, rows)
145
+ if (this.running) this.pty?.resize(cols, rows)
146
+ }
147
+
148
+ /** Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. */
149
+ async stop(signal: NodeJS.Signals = "SIGTERM", graceMs = 3000): Promise<void> {
150
+ const pty = this.pty
151
+ if (!pty || !this.running) return
152
+ this.stopRequested = true
153
+ pty.signal(signal)
154
+ const exited = await Promise.race([
155
+ this.exitPromise.then(() => true),
156
+ Bun.sleep(graceMs).then(() => false),
157
+ ])
158
+ if (!exited) {
159
+ pty.signal("SIGKILL")
160
+ await this.exitPromise
161
+ }
162
+ if (pty.groupAlive()) pty.signal("SIGKILL")
163
+ }
164
+
165
+ async snapshot(): Promise<ScreenResult> {
166
+ return this.screen.snapshot()
167
+ }
168
+
169
+ info(): ShellInfo {
170
+ const info: ShellInfo = {
171
+ id: this.spec.id,
172
+ title: this.spec.title,
173
+ command: this.spec.command,
174
+ args: this.spec.args,
175
+ cwd: this.spec.cwd,
176
+ owner: this.spec.owner,
177
+ status: this.status,
178
+ run: this.run,
179
+ startedAt: this.startedAt,
180
+ cols: this.spec.cols,
181
+ rows: this.spec.rows,
182
+ lines: { first: this.log.firstLine, last: this.log.lastLine },
183
+ bytes: this.raw.end,
184
+ }
185
+ if (this.pty) info.pid = this.pty.pid
186
+ if (this.exit?.exitCode != null) info.exitCode = this.exit.exitCode
187
+ if (this.exit?.signal) info.signal = this.exit.signal
188
+ if (this.error) info.error = this.error
189
+ if (this.summary) info.summary = this.summary
190
+ if (this.endedAt) info.endedAt = this.endedAt
191
+ return info
192
+ }
193
+
194
+ dispose(): void {
195
+ clearTimeout(this.timeout)
196
+ this.listeners.clear()
197
+ this.pty?.close()
198
+ this.screen.dispose()
199
+ }
200
+
201
+ /** Last error-looking line of the current run, else its last non-empty line. */
202
+ private summarize(): string | undefined {
203
+ const from = Math.max(this.runStartLine, this.log.lastLine - 200 + 1)
204
+ let last: string | undefined
205
+ for (let n = this.log.lastLine; n >= from; n--) {
206
+ const text = this.log.get(n)?.trim()
207
+ if (!text) continue
208
+ last ??= text
209
+ if (ERROR_LINE.test(text)) return text.slice(0, 300)
210
+ }
211
+ return last?.slice(0, 300)
212
+ }
213
+
214
+ private createNormalizer(): OutputNormalizer {
215
+ return new OutputNormalizer((text) => {
216
+ const line = this.log.append(text)
217
+ for (const l of this.listeners) l.line?.(line)
218
+ })
219
+ }
220
+
221
+ private onData(chunk: Uint8Array): void {
222
+ this.lastOutputAt = Date.now()
223
+ const offset = this.raw.append(chunk)
224
+ this.screen.write(chunk)
225
+ this.normalizer.push(chunk)
226
+ const partial = this.normalizer.partial
227
+ for (const l of this.listeners) {
228
+ l.data?.(offset, chunk)
229
+ if (partial) l.partial?.(partial)
230
+ }
231
+ }
232
+
233
+ private onExit(pty: PtyProcess, exit: PtyExit): void {
234
+ if (this.pty !== pty) return // a newer run replaced this one
235
+ clearTimeout(this.timeout)
236
+ this.normalizer.flush()
237
+ this.exit = exit
238
+ this.endedAt = Date.now()
239
+ this.status = this.stopRequested || exit.signal ? "killed" : "exited"
240
+ this.summary = this.summarize()
241
+ // Session leader is gone; make sure nothing it left behind keeps running.
242
+ if (pty.groupAlive()) pty.signal("SIGHUP")
243
+ pty.close()
244
+ const info = this.info()
245
+ for (const l of this.listeners) l.exit?.(info)
246
+ }
247
+ }
@@ -0,0 +1,86 @@
1
+ import type { LogLine, WaitParams, WaitReason } from "@opencode-cockpit/protocol/shell"
2
+ import { probePort } from "./port-probe.ts"
3
+ import type { Shell } from "./shell.ts"
4
+
5
+ export interface WaitOutcome {
6
+ reason: WaitReason
7
+ match?: LogLine
8
+ }
9
+
10
+ export const PORT_POLL_MS = 250
11
+
12
+ /**
13
+ * Races every condition in `until` plus the timeout. Exit always ends a wait: once the process is
14
+ * gone no pattern, port or idle condition can still become true.
15
+ */
16
+ export function waitFor(
17
+ shell: Shell,
18
+ params: WaitParams,
19
+ compile: (p: string, i: boolean) => RegExp,
20
+ ): Promise<WaitOutcome> {
21
+ const { until, timeoutMs } = params
22
+
23
+ return new Promise<WaitOutcome>((resolve) => {
24
+ const cleanups: (() => void)[] = []
25
+ let done = false
26
+ const finish = (outcome: WaitOutcome) => {
27
+ if (done) return
28
+ done = true
29
+ for (const cleanup of cleanups) cleanup()
30
+ resolve(outcome)
31
+ }
32
+
33
+ const regex = until.pattern !== undefined ? compile(until.pattern, until.ignoreCase ?? false) : undefined
34
+
35
+ // Lines already written count: "wait until ready" must succeed if it is ready already.
36
+ if (regex) {
37
+ const after = params.after ?? shell.runStartLine - 1
38
+ const existing = shell.log.read({ after, tail: 0, limit: Number.MAX_SAFE_INTEGER, grep: regex })
39
+ const first = existing.lines[0]
40
+ if (first) return finish({ reason: "pattern", match: first })
41
+ }
42
+ if (!shell.running) return finish({ reason: "exit" })
43
+
44
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
45
+ const armIdle = () => {
46
+ if (until.idleMs === undefined) return
47
+ clearTimeout(idleTimer)
48
+ const remaining = Math.max(0, until.idleMs - (Date.now() - shell.lastOutputAt))
49
+ idleTimer = setTimeout(() => finish({ reason: "idle" }), remaining)
50
+ }
51
+ armIdle()
52
+ cleanups.push(() => clearTimeout(idleTimer))
53
+
54
+ cleanups.push(
55
+ shell.subscribe({
56
+ line(line) {
57
+ if (regex?.test(line.text)) finish({ reason: "pattern", match: line })
58
+ },
59
+ partial(text) {
60
+ if (regex?.test(text)) finish({ reason: "pattern", match: { n: shell.log.lastLine + 1, text } })
61
+ },
62
+ data: () => armIdle(),
63
+ exit: () => finish({ reason: "exit" }),
64
+ }),
65
+ )
66
+
67
+ if (until.port !== undefined) {
68
+ const port = until.port
69
+ const host = until.host ?? "127.0.0.1"
70
+ let polling = true
71
+ const poll = async () => {
72
+ while (polling && !done) {
73
+ if (await probePort(port, host)) return finish({ reason: "port" })
74
+ await Bun.sleep(PORT_POLL_MS)
75
+ }
76
+ }
77
+ void poll()
78
+ cleanups.push(() => {
79
+ polling = false
80
+ })
81
+ }
82
+
83
+ const timer = setTimeout(() => finish({ reason: "timeout" }), timeoutMs)
84
+ cleanups.push(() => clearTimeout(timer))
85
+ })
86
+ }