@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.
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/package.json +43 -0
- package/src/core/daemon.ts +197 -0
- package/src/core/errors.ts +6 -0
- package/src/core/logger.ts +44 -0
- package/src/core/module.ts +45 -0
- package/src/core/router.ts +40 -0
- package/src/core/server.ts +223 -0
- package/src/index.ts +5 -0
- package/src/main.ts +36 -0
- package/src/modules/index.ts +11 -0
- package/src/modules/shell/ids.ts +8 -0
- package/src/modules/shell/module.ts +323 -0
- package/src/modules/shell/output/line-log.ts +92 -0
- package/src/modules/shell/output/normalizer.ts +172 -0
- package/src/modules/shell/output/raw-ring.ts +46 -0
- package/src/modules/shell/output/screen.ts +44 -0
- package/src/modules/shell/port-probe.ts +30 -0
- package/src/modules/shell/pty.ts +98 -0
- package/src/modules/shell/registry.ts +86 -0
- package/src/modules/shell/shell.ts +247 -0
- package/src/modules/shell/wait.ts +86 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { ShellInfo, StartParams } from "@opencode-cockpit/protocol/shell"
|
|
2
|
+
import { invalidParams, invalidState, notFound } from "../../core/errors.ts"
|
|
3
|
+
import type { Logger } from "../../core/logger.ts"
|
|
4
|
+
import { silentLogger } from "../../core/logger.ts"
|
|
5
|
+
import type { MethodTable, Module, ModuleContext, Peer } from "../../core/module.ts"
|
|
6
|
+
import { newShellId } from "./ids.ts"
|
|
7
|
+
import { bunPtyBackend, type PtyBackend } from "./pty.ts"
|
|
8
|
+
import { ProcessRegistry } from "./registry.ts"
|
|
9
|
+
import { Shell, type ShellLimits } from "./shell.ts"
|
|
10
|
+
import { waitFor } from "./wait.ts"
|
|
11
|
+
|
|
12
|
+
export interface ShellModuleOptions {
|
|
13
|
+
backend?: PtyBackend
|
|
14
|
+
limits?: Partial<ShellLimits>
|
|
15
|
+
/** Oldest finished shells are forgotten beyond this many. */
|
|
16
|
+
maxFinished?: number
|
|
17
|
+
/** Base environment for spawned processes (defaults to the daemon's). */
|
|
18
|
+
baseEnv?: Record<string, string | undefined>
|
|
19
|
+
/** Coalesce output events per attached peer for this long. */
|
|
20
|
+
outputFlushMs?: number
|
|
21
|
+
/** Where to record owned process groups so a restarted daemon can reap orphans. */
|
|
22
|
+
registryFile?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_LIMITS: ShellLimits = { logChars: 4_000_000, rawBytes: 1_000_000, scrollback: 2000 }
|
|
26
|
+
|
|
27
|
+
export class ShellModule implements Module<"shell"> {
|
|
28
|
+
readonly name = "shell" as const
|
|
29
|
+
private readonly shells = new Map<string, Shell>()
|
|
30
|
+
private readonly attachments = new Map<string, () => void>() // `${peer.id}:${shellId}` → detach
|
|
31
|
+
private readonly backend: PtyBackend
|
|
32
|
+
private readonly limits: ShellLimits
|
|
33
|
+
private log: Logger = silentLogger
|
|
34
|
+
private registry: ProcessRegistry | undefined
|
|
35
|
+
private emit: ModuleContext["emit"] = () => {}
|
|
36
|
+
|
|
37
|
+
constructor(private readonly options: ShellModuleOptions = {}) {
|
|
38
|
+
this.backend = options.backend ?? bunPtyBackend
|
|
39
|
+
this.limits = { ...DEFAULT_LIMITS, ...options.limits }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async start(ctx: ModuleContext): Promise<void> {
|
|
43
|
+
this.log = ctx.log
|
|
44
|
+
this.emit = ctx.emit
|
|
45
|
+
if (this.options.registryFile) {
|
|
46
|
+
this.registry = new ProcessRegistry(this.options.registryFile, ctx.log)
|
|
47
|
+
const reaped = this.registry.reap()
|
|
48
|
+
if (reaped > 0) ctx.log.warn("cleaned up shells left by a previous daemon", { reaped })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async stop(): Promise<void> {
|
|
53
|
+
await Promise.all([...this.shells.values()].map((s) => s.stop("SIGTERM", 2000).catch(() => {})))
|
|
54
|
+
for (const detach of this.attachments.values()) detach()
|
|
55
|
+
for (const shell of this.shells.values()) shell.dispose()
|
|
56
|
+
this.attachments.clear()
|
|
57
|
+
this.shells.clear()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
busy(): boolean {
|
|
61
|
+
for (const shell of this.shells.values()) if (shell.running) return true
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
readonly methods: MethodTable<"shell"> = {
|
|
66
|
+
start: (params) => this.startShell(params),
|
|
67
|
+
|
|
68
|
+
list: (params) => {
|
|
69
|
+
const owner = params.owner
|
|
70
|
+
return [...this.shells.values()]
|
|
71
|
+
.filter((s) => params.includeExited || s.running)
|
|
72
|
+
.filter((s) => !owner?.project || s.spec.owner.project === owner.project)
|
|
73
|
+
.filter((s) => !owner?.session || s.spec.owner.session === owner.session)
|
|
74
|
+
.map((s) => s.info())
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
get: ({ id }) => this.require(id).info(),
|
|
78
|
+
|
|
79
|
+
read: ({ id, after, tail, limit, grep, ignoreCase }) => {
|
|
80
|
+
const shell = this.require(id)
|
|
81
|
+
const page = shell.log.read({
|
|
82
|
+
after,
|
|
83
|
+
tail,
|
|
84
|
+
limit,
|
|
85
|
+
grep: grep === undefined ? undefined : compilePattern(grep, ignoreCase),
|
|
86
|
+
})
|
|
87
|
+
return { ...page, status: shell.status }
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
screen: ({ id }) => this.require(id).snapshot(),
|
|
91
|
+
|
|
92
|
+
write: ({ id, data }) => {
|
|
93
|
+
const shell = this.require(id)
|
|
94
|
+
if (!shell.running) throw invalidState(`shell ${id} is ${shell.status}`)
|
|
95
|
+
return { bytes: shell.write(data) }
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
resize: ({ id, cols, rows }) => {
|
|
99
|
+
this.require(id).resize(cols, rows)
|
|
100
|
+
return {}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
wait: async (params) => {
|
|
104
|
+
const shell = this.require(params.id)
|
|
105
|
+
const outcome = await waitFor(shell, params, compilePattern)
|
|
106
|
+
return { ...outcome, info: shell.info() }
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
stop: async ({ id, signal, graceMs }) => {
|
|
110
|
+
const shell = this.require(id)
|
|
111
|
+
await shell.stop(signal, graceMs)
|
|
112
|
+
await shell.exited
|
|
113
|
+
return shell.info()
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
restart: async ({ id }) => {
|
|
117
|
+
const shell = this.require(id)
|
|
118
|
+
if (shell.running) {
|
|
119
|
+
await shell.stop("SIGTERM", 3000)
|
|
120
|
+
await shell.exited
|
|
121
|
+
}
|
|
122
|
+
this.spawn(shell)
|
|
123
|
+
return shell.info()
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
remove: async ({ id }) => {
|
|
127
|
+
const shell = this.require(id)
|
|
128
|
+
if (shell.running) {
|
|
129
|
+
await shell.stop("SIGTERM", 3000)
|
|
130
|
+
await shell.exited
|
|
131
|
+
}
|
|
132
|
+
this.forget(shell)
|
|
133
|
+
return {}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
attach: ({ id, fromOffset }, { peer }) => {
|
|
137
|
+
const shell = this.require(id)
|
|
138
|
+
this.detach(peer, id)
|
|
139
|
+
const replay = shell.raw.since(fromOffset ?? 0)
|
|
140
|
+
this.attachStream(peer, shell)
|
|
141
|
+
return { offset: replay.offset, replay: Buffer.from(replay.bytes).toString("base64") }
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
clear: ({ owner, finishedBeforeMs }) => {
|
|
145
|
+
const cutoff = Date.now() - (finishedBeforeMs ?? 0)
|
|
146
|
+
const removed: string[] = []
|
|
147
|
+
for (const shell of [...this.shells.values()]) {
|
|
148
|
+
if (shell.running) continue
|
|
149
|
+
const info = shell.info()
|
|
150
|
+
if (owner?.project && info.owner.project !== owner.project) continue
|
|
151
|
+
if (owner?.session && info.owner.session !== owner.session) continue
|
|
152
|
+
if ((info.endedAt ?? 0) > cutoff) continue
|
|
153
|
+
this.forget(shell)
|
|
154
|
+
removed.push(info.id)
|
|
155
|
+
}
|
|
156
|
+
return { removed }
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
detach: ({ id }, { peer }) => {
|
|
160
|
+
this.detach(peer, id)
|
|
161
|
+
return {}
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private startShell(params: StartParams): ShellInfo {
|
|
166
|
+
if (params.reuse) {
|
|
167
|
+
const previous = this.findReusable(params)
|
|
168
|
+
if (previous) {
|
|
169
|
+
Object.assign(previous.spec, {
|
|
170
|
+
env: this.environment(params.env),
|
|
171
|
+
title: params.title ?? previous.spec.title,
|
|
172
|
+
timeoutMs: params.timeoutMs,
|
|
173
|
+
owner: params.owner,
|
|
174
|
+
})
|
|
175
|
+
this.spawn(previous)
|
|
176
|
+
return previous.info()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const shell = new Shell(
|
|
180
|
+
{
|
|
181
|
+
id: this.uniqueId(),
|
|
182
|
+
command: params.command,
|
|
183
|
+
args: params.args,
|
|
184
|
+
cwd: params.cwd,
|
|
185
|
+
env: this.environment(params.env),
|
|
186
|
+
title: params.title ?? [params.command, ...params.args].join(" ").slice(0, 200),
|
|
187
|
+
cols: params.cols,
|
|
188
|
+
rows: params.rows,
|
|
189
|
+
owner: params.owner,
|
|
190
|
+
timeoutMs: params.timeoutMs,
|
|
191
|
+
},
|
|
192
|
+
this.backend,
|
|
193
|
+
this.limits,
|
|
194
|
+
)
|
|
195
|
+
this.shells.set(shell.id, shell)
|
|
196
|
+
shell.subscribe({ exit: (info) => this.onExit(info) })
|
|
197
|
+
this.spawn(shell)
|
|
198
|
+
this.pruneFinished()
|
|
199
|
+
return shell.info()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private findReusable(params: StartParams): Shell | undefined {
|
|
203
|
+
const args = JSON.stringify(params.args)
|
|
204
|
+
let match: Shell | undefined
|
|
205
|
+
for (const shell of this.shells.values()) {
|
|
206
|
+
const spec = shell.spec
|
|
207
|
+
if (
|
|
208
|
+
!shell.running &&
|
|
209
|
+
spec.command === params.command &&
|
|
210
|
+
JSON.stringify(spec.args) === args &&
|
|
211
|
+
spec.cwd === params.cwd &&
|
|
212
|
+
spec.owner.project === params.owner.project &&
|
|
213
|
+
spec.owner.session === params.owner.session &&
|
|
214
|
+
(!match || shell.info().startedAt > match.info().startedAt)
|
|
215
|
+
) {
|
|
216
|
+
match = shell
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return match
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private spawn(shell: Shell): void {
|
|
223
|
+
try {
|
|
224
|
+
shell.start()
|
|
225
|
+
} catch (err) {
|
|
226
|
+
const info = shell.info()
|
|
227
|
+
this.log.warn("spawn failed", { id: shell.id, command: shell.spec.command, err: String(err) })
|
|
228
|
+
this.emit("shell.exited", info)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const pid = shell.info().pid
|
|
232
|
+
if (pid) this.registry?.add(shell.id, pid, shell.spec.command)
|
|
233
|
+
this.log.info("shell started", { id: shell.id, command: shell.spec.command, pid })
|
|
234
|
+
this.emit("shell.started", shell.info())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private onExit(info: ShellInfo): void {
|
|
238
|
+
this.registry?.remove(info.id)
|
|
239
|
+
this.log.info("shell ended", {
|
|
240
|
+
id: info.id,
|
|
241
|
+
status: info.status,
|
|
242
|
+
exitCode: info.exitCode,
|
|
243
|
+
signal: info.signal,
|
|
244
|
+
})
|
|
245
|
+
this.emit("shell.exited", info)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private attachStream(peer: Peer, shell: Shell): void {
|
|
249
|
+
const key = `${peer.id}:${shell.id}`
|
|
250
|
+
const flushMs = this.options.outputFlushMs ?? 16
|
|
251
|
+
let pending: Uint8Array[] = []
|
|
252
|
+
let pendingOffset = 0
|
|
253
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
254
|
+
const flush = () => {
|
|
255
|
+
timer = undefined
|
|
256
|
+
if (pending.length === 0) return
|
|
257
|
+
const data = Buffer.concat(pending).toString("base64")
|
|
258
|
+
peer.send("shell.output", { id: shell.id, offset: pendingOffset, data })
|
|
259
|
+
pending = []
|
|
260
|
+
}
|
|
261
|
+
const unsubscribe = shell.subscribe({
|
|
262
|
+
data(offset, chunk) {
|
|
263
|
+
if (pending.length === 0) pendingOffset = offset
|
|
264
|
+
pending.push(chunk)
|
|
265
|
+
timer ??= setTimeout(flush, flushMs)
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
const detach = () => {
|
|
269
|
+
clearTimeout(timer)
|
|
270
|
+
flush()
|
|
271
|
+
unsubscribe()
|
|
272
|
+
this.attachments.delete(key)
|
|
273
|
+
}
|
|
274
|
+
this.attachments.set(key, detach)
|
|
275
|
+
peer.onClose(detach)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private detach(peer: Peer, id: string): void {
|
|
279
|
+
this.attachments.get(`${peer.id}:${id}`)?.()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private forget(shell: Shell): void {
|
|
283
|
+
for (const [key, detach] of this.attachments) if (key.endsWith(`:${shell.id}`)) detach()
|
|
284
|
+
shell.dispose()
|
|
285
|
+
this.shells.delete(shell.id)
|
|
286
|
+
this.emit("shell.removed", { id: shell.id })
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private pruneFinished(): void {
|
|
290
|
+
const max = this.options.maxFinished ?? 50
|
|
291
|
+
const finished = [...this.shells.values()].filter((s) => !s.running)
|
|
292
|
+
for (const shell of finished.slice(0, Math.max(0, finished.length - max))) this.forget(shell)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private require(id: string): Shell {
|
|
296
|
+
const shell = this.shells.get(id)
|
|
297
|
+
if (!shell) throw notFound(`shell ${id}`)
|
|
298
|
+
return shell
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private uniqueId(): string {
|
|
302
|
+
let id = newShellId()
|
|
303
|
+
while (this.shells.has(id)) id = newShellId()
|
|
304
|
+
return id
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private environment(extra: Record<string, string> | undefined): Record<string, string> {
|
|
308
|
+
const env: Record<string, string> = {}
|
|
309
|
+
for (const [k, v] of Object.entries(this.options.baseEnv ?? process.env)) if (v !== undefined) env[k] = v
|
|
310
|
+
env.TERM ??= "xterm-256color"
|
|
311
|
+
env.COLORTERM ??= "truecolor"
|
|
312
|
+
Object.assign(env, extra)
|
|
313
|
+
return env
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function compilePattern(pattern: string, ignoreCase: boolean): RegExp {
|
|
318
|
+
try {
|
|
319
|
+
return new RegExp(pattern, ignoreCase ? "i" : "")
|
|
320
|
+
} catch (err) {
|
|
321
|
+
throw invalidParams(`invalid pattern: ${err instanceof Error ? err.message : String(err)}`)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { LogLine } from "@opencode-cockpit/protocol/shell"
|
|
2
|
+
|
|
3
|
+
export interface ReadQuery {
|
|
4
|
+
after?: number
|
|
5
|
+
tail: number
|
|
6
|
+
limit: number
|
|
7
|
+
grep?: RegExp
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ReadPage {
|
|
11
|
+
lines: LogLine[]
|
|
12
|
+
firstLine: number
|
|
13
|
+
lastLine: number
|
|
14
|
+
nextCursor: number
|
|
15
|
+
truncated: boolean
|
|
16
|
+
hasMore: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Committed lines with monotonic numbering (1-based) and a character budget.
|
|
21
|
+
* Eviction drops the oldest lines and advances `firstLine`; numbers are never reused, so cursors
|
|
22
|
+
* held by clients stay meaningful after eviction.
|
|
23
|
+
*/
|
|
24
|
+
export class LineLog {
|
|
25
|
+
private lines: string[] = []
|
|
26
|
+
private head = 0
|
|
27
|
+
private chars = 0
|
|
28
|
+
private first = 1
|
|
29
|
+
|
|
30
|
+
constructor(private readonly maxChars = 4_000_000) {}
|
|
31
|
+
|
|
32
|
+
/** Number of the oldest retained line (equals `lastLine + 1` when empty). */
|
|
33
|
+
get firstLine(): number {
|
|
34
|
+
return this.first
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Number of the newest line, or `firstLine - 1` when empty. */
|
|
38
|
+
get lastLine(): number {
|
|
39
|
+
return this.first + (this.lines.length - this.head) - 1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
append(text: string): LogLine {
|
|
43
|
+
this.lines.push(text)
|
|
44
|
+
this.chars += text.length + 1
|
|
45
|
+
const line = { n: this.lastLine, text }
|
|
46
|
+
this.evict()
|
|
47
|
+
return line
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get(n: number): string | undefined {
|
|
51
|
+
if (n < this.first || n > this.lastLine) return undefined
|
|
52
|
+
return this.lines[this.head + (n - this.first)]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
read(query: ReadQuery): ReadPage {
|
|
56
|
+
const last = this.lastLine
|
|
57
|
+
const requestedStart = query.after !== undefined ? query.after + 1 : Math.max(1, last - query.tail + 1)
|
|
58
|
+
const start = Math.max(requestedStart, this.first)
|
|
59
|
+
const truncated = requestedStart < this.first && last >= requestedStart
|
|
60
|
+
|
|
61
|
+
const out: LogLine[] = []
|
|
62
|
+
let n = start
|
|
63
|
+
for (; n <= last && out.length < query.limit; n++) {
|
|
64
|
+
const text = this.lines[this.head + (n - this.first)] as string
|
|
65
|
+
if (query.grep && !query.grep.test(text)) continue
|
|
66
|
+
out.push({ n, text })
|
|
67
|
+
}
|
|
68
|
+
const scannedTo = n - 1
|
|
69
|
+
return {
|
|
70
|
+
lines: out,
|
|
71
|
+
firstLine: this.first,
|
|
72
|
+
lastLine: last,
|
|
73
|
+
nextCursor: Math.max(scannedTo, start - 1),
|
|
74
|
+
truncated,
|
|
75
|
+
hasMore: scannedTo < last,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private evict(): void {
|
|
80
|
+
while (this.chars > this.maxChars && this.head < this.lines.length - 1) {
|
|
81
|
+
const dropped = this.lines[this.head] as string
|
|
82
|
+
this.chars -= dropped.length + 1
|
|
83
|
+
this.head++
|
|
84
|
+
this.first++
|
|
85
|
+
}
|
|
86
|
+
// Compact occasionally so the backing array does not grow without bound.
|
|
87
|
+
if (this.head > 4096 && this.head * 2 > this.lines.length) {
|
|
88
|
+
this.lines = this.lines.slice(this.head)
|
|
89
|
+
this.head = 0
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
}
|