@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.
Files changed (60) hide show
  1. package/dist/core/daemon.js +193 -0
  2. package/dist/core/errors.js +4 -0
  3. package/dist/core/logger.js +45 -0
  4. package/dist/core/module.js +1 -0
  5. package/dist/core/router.js +33 -0
  6. package/dist/core/server.js +211 -0
  7. package/dist/index.js +3 -0
  8. package/dist/main.js +40 -0
  9. package/dist/modules/index.js +5 -0
  10. package/dist/modules/shell/ids.js +7 -0
  11. package/dist/modules/shell/module.js +334 -0
  12. package/dist/modules/shell/output/line-log.js +76 -0
  13. package/dist/modules/shell/output/normalizer.js +161 -0
  14. package/dist/modules/shell/output/raw-ring.js +49 -0
  15. package/dist/modules/shell/output/screen.js +45 -0
  16. package/dist/modules/shell/port-probe.js +30 -0
  17. package/dist/modules/shell/pty.js +59 -0
  18. package/dist/modules/shell/registry.js +83 -0
  19. package/dist/modules/shell/shell.js +193 -0
  20. package/dist/modules/shell/wait.js +107 -0
  21. package/package.json +12 -5
  22. package/types/core/daemon.d.ts +36 -0
  23. package/types/core/errors.d.ts +4 -0
  24. package/types/core/logger.d.ts +11 -0
  25. package/types/core/module.d.ts +37 -0
  26. package/types/core/router.d.ts +11 -0
  27. package/types/core/server.d.ts +49 -0
  28. package/{src/index.ts → types/index.d.ts} +5 -5
  29. package/types/main.d.ts +2 -0
  30. package/types/modules/index.d.ts +7 -0
  31. package/types/modules/shell/ids.d.ts +1 -0
  32. package/types/modules/shell/module.d.ts +43 -0
  33. package/types/modules/shell/output/line-log.d.ts +36 -0
  34. package/types/modules/shell/output/normalizer.d.ts +32 -0
  35. package/types/modules/shell/output/raw-ring.d.ts +19 -0
  36. package/types/modules/shell/output/screen.d.ts +12 -0
  37. package/types/modules/shell/port-probe.d.ts +2 -0
  38. package/types/modules/shell/pty.d.ts +33 -0
  39. package/types/modules/shell/registry.d.ts +17 -0
  40. package/types/modules/shell/shell.d.ts +77 -0
  41. package/types/modules/shell/wait.d.ts +12 -0
  42. package/src/core/daemon.ts +0 -197
  43. package/src/core/errors.ts +0 -6
  44. package/src/core/logger.ts +0 -44
  45. package/src/core/module.ts +0 -45
  46. package/src/core/router.ts +0 -40
  47. package/src/core/server.ts +0 -223
  48. package/src/main.ts +0 -36
  49. package/src/modules/index.ts +0 -11
  50. package/src/modules/shell/ids.ts +0 -8
  51. package/src/modules/shell/module.ts +0 -323
  52. package/src/modules/shell/output/line-log.ts +0 -92
  53. package/src/modules/shell/output/normalizer.ts +0 -172
  54. package/src/modules/shell/output/raw-ring.ts +0 -46
  55. package/src/modules/shell/output/screen.ts +0 -44
  56. package/src/modules/shell/port-probe.ts +0 -30
  57. package/src/modules/shell/pty.ts +0 -98
  58. package/src/modules/shell/registry.ts +0 -86
  59. package/src/modules/shell/shell.ts +0 -252
  60. 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
- }
@@ -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
- }