@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,252 +0,0 @@
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
- /** The line currently being written (e.g. a prompt awaiting input); empty when none. */
79
- get partialLine(): string {
80
- return this.normalizer.partial
81
- }
82
-
83
- get running(): boolean {
84
- return this.status === "running"
85
- }
86
-
87
- /** Resolves when the current run has fully exited and been accounted for. */
88
- get exited(): Promise<void> {
89
- return this.exitPromise
90
- }
91
-
92
- subscribe(listener: ShellListener): () => void {
93
- this.listeners.add(listener)
94
- return () => this.listeners.delete(listener)
95
- }
96
-
97
- start(): void {
98
- if (this.running && this.pty) throw new Error("shell is already running")
99
- this.run++
100
- if (this.run > 1) {
101
- this.normalizer.flush()
102
- this.log.append(`──── restart (run ${this.run}) ────`)
103
- this.screen.reset()
104
- }
105
- this.runStartLine = this.log.lastLine + 1
106
- this.status = "running"
107
- this.startedAt = Date.now()
108
- this.lastOutputAt = this.startedAt
109
- this.endedAt = undefined
110
- this.exit = undefined
111
- this.error = undefined
112
- this.summary = undefined
113
- this.stopRequested = false
114
-
115
- try {
116
- this.pty = this.backend.spawn({
117
- command: this.spec.command,
118
- args: this.spec.args,
119
- cwd: this.spec.cwd,
120
- env: this.spec.env,
121
- cols: this.spec.cols,
122
- rows: this.spec.rows,
123
- onData: (chunk) => this.onData(chunk),
124
- })
125
- } catch (err) {
126
- this.pty = undefined
127
- this.status = "failed"
128
- this.error = err instanceof Error ? err.message : String(err)
129
- this.endedAt = Date.now()
130
- this.log.append(`[cockpit] failed to start: ${this.error}`)
131
- throw err
132
- }
133
-
134
- const pty = this.pty
135
- this.exitPromise = pty.exited.then((exit) => this.onExit(pty, exit))
136
- if (this.spec.timeoutMs) {
137
- this.timeout = setTimeout(() => void this.stop("SIGTERM", 3000), this.spec.timeoutMs)
138
- }
139
- }
140
-
141
- write(data: string): number {
142
- if (!this.running || !this.pty) throw new Error(`shell is ${this.status}`)
143
- return this.pty.write(data)
144
- }
145
-
146
- resize(cols: number, rows: number): void {
147
- this.spec.cols = cols
148
- this.spec.rows = rows
149
- this.screen.resize(cols, rows)
150
- if (this.running) this.pty?.resize(cols, rows)
151
- }
152
-
153
- /** Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. */
154
- async stop(signal: NodeJS.Signals = "SIGTERM", graceMs = 3000): Promise<void> {
155
- const pty = this.pty
156
- if (!pty || !this.running) return
157
- this.stopRequested = true
158
- pty.signal(signal)
159
- const exited = await Promise.race([
160
- this.exitPromise.then(() => true),
161
- Bun.sleep(graceMs).then(() => false),
162
- ])
163
- if (!exited) {
164
- pty.signal("SIGKILL")
165
- await this.exitPromise
166
- }
167
- if (pty.groupAlive()) pty.signal("SIGKILL")
168
- }
169
-
170
- async snapshot(): Promise<ScreenResult> {
171
- return this.screen.snapshot()
172
- }
173
-
174
- info(): ShellInfo {
175
- const info: ShellInfo = {
176
- id: this.spec.id,
177
- title: this.spec.title,
178
- command: this.spec.command,
179
- args: this.spec.args,
180
- cwd: this.spec.cwd,
181
- owner: this.spec.owner,
182
- status: this.status,
183
- run: this.run,
184
- startedAt: this.startedAt,
185
- cols: this.spec.cols,
186
- rows: this.spec.rows,
187
- lines: { first: this.log.firstLine, last: this.log.lastLine },
188
- bytes: this.raw.end,
189
- }
190
- if (this.pty) info.pid = this.pty.pid
191
- if (this.exit?.exitCode != null) info.exitCode = this.exit.exitCode
192
- if (this.exit?.signal) info.signal = this.exit.signal
193
- if (this.error) info.error = this.error
194
- if (this.summary) info.summary = this.summary
195
- if (this.endedAt) info.endedAt = this.endedAt
196
- return info
197
- }
198
-
199
- dispose(): void {
200
- clearTimeout(this.timeout)
201
- this.listeners.clear()
202
- this.pty?.close()
203
- this.screen.dispose()
204
- }
205
-
206
- /** Last error-looking line of the current run, else its last non-empty line. */
207
- private summarize(): string | undefined {
208
- const from = Math.max(this.runStartLine, this.log.lastLine - 200 + 1)
209
- let last: string | undefined
210
- for (let n = this.log.lastLine; n >= from; n--) {
211
- const text = this.log.get(n)?.trim()
212
- if (!text) continue
213
- last ??= text
214
- if (ERROR_LINE.test(text)) return text.slice(0, 300)
215
- }
216
- return last?.slice(0, 300)
217
- }
218
-
219
- private createNormalizer(): OutputNormalizer {
220
- return new OutputNormalizer((text) => {
221
- const line = this.log.append(text)
222
- for (const l of this.listeners) l.line?.(line)
223
- })
224
- }
225
-
226
- private onData(chunk: Uint8Array): void {
227
- this.lastOutputAt = Date.now()
228
- const offset = this.raw.append(chunk)
229
- this.screen.write(chunk)
230
- this.normalizer.push(chunk)
231
- const partial = this.normalizer.partial
232
- for (const l of this.listeners) {
233
- l.data?.(offset, chunk)
234
- if (partial) l.partial?.(partial)
235
- }
236
- }
237
-
238
- private onExit(pty: PtyProcess, exit: PtyExit): void {
239
- if (this.pty !== pty) return // a newer run replaced this one
240
- clearTimeout(this.timeout)
241
- this.normalizer.flush()
242
- this.exit = exit
243
- this.endedAt = Date.now()
244
- this.status = this.stopRequested || exit.signal ? "killed" : "exited"
245
- this.summary = this.summarize()
246
- // Session leader is gone; make sure nothing it left behind keeps running.
247
- if (pty.groupAlive()) pty.signal("SIGHUP")
248
- pty.close()
249
- const info = this.info()
250
- for (const l of this.listeners) l.exit?.(info)
251
- }
252
- }
@@ -1,91 +0,0 @@
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
- // A prompt already on screen has no newline yet, so it is not in the log.
42
- const partial = shell.partialLine
43
- if (partial && regex.test(partial)) {
44
- return finish({ reason: "pattern", match: { n: shell.log.lastLine + 1, text: partial } })
45
- }
46
- }
47
- if (!shell.running) return finish({ reason: "exit" })
48
-
49
- let idleTimer: ReturnType<typeof setTimeout> | undefined
50
- const armIdle = () => {
51
- if (until.idleMs === undefined) return
52
- clearTimeout(idleTimer)
53
- const remaining = Math.max(0, until.idleMs - (Date.now() - shell.lastOutputAt))
54
- idleTimer = setTimeout(() => finish({ reason: "idle" }), remaining)
55
- }
56
- armIdle()
57
- cleanups.push(() => clearTimeout(idleTimer))
58
-
59
- cleanups.push(
60
- shell.subscribe({
61
- line(line) {
62
- if (regex?.test(line.text)) finish({ reason: "pattern", match: line })
63
- },
64
- partial(text) {
65
- if (regex?.test(text)) finish({ reason: "pattern", match: { n: shell.log.lastLine + 1, text } })
66
- },
67
- data: () => armIdle(),
68
- exit: () => finish({ reason: "exit" }),
69
- }),
70
- )
71
-
72
- if (until.port !== undefined) {
73
- const port = until.port
74
- const host = until.host ?? "127.0.0.1"
75
- let polling = true
76
- const poll = async () => {
77
- while (polling && !done) {
78
- if (await probePort(port, host)) return finish({ reason: "port" })
79
- await Bun.sleep(PORT_POLL_MS)
80
- }
81
- }
82
- void poll()
83
- cleanups.push(() => {
84
- polling = false
85
- })
86
- }
87
-
88
- const timer = setTimeout(() => finish({ reason: "timeout" }), timeoutMs)
89
- cleanups.push(() => clearTimeout(timer))
90
- })
91
- }