@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,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
|
-
}
|