@opencode-cockpit/daemon 0.1.4 → 0.2.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.
Files changed (68) 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 +41 -0
  9. package/dist/modules/index.js +5 -0
  10. package/dist/modules/shell/ids.js +7 -0
  11. package/dist/modules/shell/methods.js +186 -0
  12. package/dist/modules/shell/module.js +259 -0
  13. package/dist/modules/shell/output/line-log.js +76 -0
  14. package/dist/modules/shell/output/normalizer.js +161 -0
  15. package/dist/modules/shell/output/palette.js +19 -0
  16. package/dist/modules/shell/output/raw-ring.js +49 -0
  17. package/dist/modules/shell/output/screen.js +102 -0
  18. package/dist/modules/shell/port-probe.js +30 -0
  19. package/dist/modules/shell/pty.js +59 -0
  20. package/dist/modules/shell/registry.js +83 -0
  21. package/dist/modules/shell/shell.js +242 -0
  22. package/dist/modules/shell/wait.js +107 -0
  23. package/dist/modules/shell/watch/presets.js +299 -0
  24. package/dist/modules/shell/watch/watcher.js +82 -0
  25. package/package.json +12 -5
  26. package/types/core/daemon.d.ts +36 -0
  27. package/types/core/errors.d.ts +4 -0
  28. package/types/core/logger.d.ts +11 -0
  29. package/types/core/module.d.ts +37 -0
  30. package/types/core/router.d.ts +11 -0
  31. package/types/core/server.d.ts +49 -0
  32. package/{src/index.ts → types/index.d.ts} +5 -5
  33. package/types/main.d.ts +2 -0
  34. package/types/modules/index.d.ts +7 -0
  35. package/types/modules/shell/ids.d.ts +1 -0
  36. package/types/modules/shell/methods.d.ts +7 -0
  37. package/types/modules/shell/module.d.ts +68 -0
  38. package/types/modules/shell/output/line-log.d.ts +36 -0
  39. package/types/modules/shell/output/normalizer.d.ts +32 -0
  40. package/types/modules/shell/output/palette.d.ts +2 -0
  41. package/types/modules/shell/output/raw-ring.d.ts +19 -0
  42. package/types/modules/shell/output/screen.d.ts +12 -0
  43. package/types/modules/shell/port-probe.d.ts +2 -0
  44. package/types/modules/shell/pty.d.ts +33 -0
  45. package/types/modules/shell/registry.d.ts +17 -0
  46. package/types/modules/shell/shell.d.ts +89 -0
  47. package/types/modules/shell/wait.d.ts +12 -0
  48. package/types/modules/shell/watch/presets.d.ts +17 -0
  49. package/types/modules/shell/watch/watcher.d.ts +43 -0
  50. package/src/core/daemon.ts +0 -197
  51. package/src/core/errors.ts +0 -6
  52. package/src/core/logger.ts +0 -44
  53. package/src/core/module.ts +0 -45
  54. package/src/core/router.ts +0 -40
  55. package/src/core/server.ts +0 -223
  56. package/src/main.ts +0 -36
  57. package/src/modules/index.ts +0 -11
  58. package/src/modules/shell/ids.ts +0 -8
  59. package/src/modules/shell/module.ts +0 -323
  60. package/src/modules/shell/output/line-log.ts +0 -92
  61. package/src/modules/shell/output/normalizer.ts +0 -172
  62. package/src/modules/shell/output/raw-ring.ts +0 -46
  63. package/src/modules/shell/output/screen.ts +0 -44
  64. package/src/modules/shell/port-probe.ts +0 -30
  65. package/src/modules/shell/pty.ts +0 -98
  66. package/src/modules/shell/registry.ts +0 -86
  67. package/src/modules/shell/shell.ts +0 -252
  68. package/src/modules/shell/wait.ts +0 -91
@@ -1,223 +0,0 @@
1
- import {
2
- ErrorCode,
3
- EVENT_METHOD,
4
- encodeFrame,
5
- LineDecoder,
6
- RpcError,
7
- type RpcMessage,
8
- type RpcRequest,
9
- } from "@opencode-cockpit/protocol"
10
- import type { Socket, UnixSocketListener } from "bun"
11
- import type { Logger } from "./logger.ts"
12
- import type { Peer } from "./module.ts"
13
- import type { Router } from "./router.ts"
14
-
15
- const MAX_QUEUED_BYTES = 32 * 1024 * 1024
16
-
17
- interface ConnState {
18
- peer: PeerImpl
19
- }
20
-
21
- class PeerImpl implements Peer {
22
- name = "unknown"
23
- greeted = false
24
- readonly topics = new Set<string>()
25
- private readonly closers: (() => void)[] = []
26
- private queue: Uint8Array[] = []
27
- private queued = 0
28
- closed = false
29
-
30
- constructor(
31
- readonly id: number,
32
- private readonly socket: Socket<ConnState>,
33
- private readonly log: Logger,
34
- ) {}
35
-
36
- greet(name: string): void {
37
- this.name = name
38
- this.greeted = true
39
- }
40
-
41
- onClose(fn: () => void): void {
42
- if (this.closed) fn()
43
- else this.closers.push(fn)
44
- }
45
-
46
- send(topic: string, data: unknown): void {
47
- this.write({ jsonrpc: "2.0", method: EVENT_METHOD, params: { topic, data } })
48
- }
49
-
50
- subscribed(topic: string): boolean {
51
- if (this.topics.has(topic) || this.topics.has("*")) return true
52
- const dot = topic.indexOf(".")
53
- return dot > 0 && this.topics.has(`${topic.slice(0, dot)}.*`)
54
- }
55
-
56
- write(message: unknown): void {
57
- if (this.closed) return
58
- const frame = encodeFrame(message)
59
- if (this.queue.length > 0) {
60
- this.enqueue(frame)
61
- return
62
- }
63
- const written = this.socket.write(frame)
64
- if (written < frame.byteLength) this.enqueue(frame.subarray(Math.max(0, written)))
65
- }
66
-
67
- drain(): void {
68
- while (this.queue.length > 0) {
69
- const head = this.queue[0] as Uint8Array
70
- const written = this.socket.write(head)
71
- if (written < head.byteLength) {
72
- this.queue[0] = head.subarray(Math.max(0, written))
73
- this.queued -= Math.max(0, written)
74
- return
75
- }
76
- this.queue.shift()
77
- this.queued -= head.byteLength
78
- }
79
- }
80
-
81
- close(): void {
82
- if (this.closed) return
83
- this.closed = true
84
- this.queue = []
85
- for (const fn of this.closers.splice(0)) {
86
- try {
87
- fn()
88
- } catch (err) {
89
- this.log.warn("peer close hook failed", { err: String(err) })
90
- }
91
- }
92
- }
93
-
94
- private enqueue(frame: Uint8Array): void {
95
- this.queue.push(frame)
96
- this.queued += frame.byteLength
97
- if (this.queued > MAX_QUEUED_BYTES) {
98
- this.log.warn("peer too slow, disconnecting", { peer: this.id, queued: this.queued })
99
- this.socket.end()
100
- }
101
- }
102
- }
103
-
104
- export interface RpcServerHooks {
105
- onConnect(count: number): void
106
- onDisconnect(count: number): void
107
- }
108
-
109
- export class RpcServer {
110
- private listener: UnixSocketListener<ConnState> | undefined
111
- private readonly peers = new Set<PeerImpl>()
112
- private nextId = 1
113
-
114
- constructor(
115
- private readonly router: Router,
116
- private readonly hooks: RpcServerHooks,
117
- private readonly log: Logger,
118
- ) {}
119
-
120
- get clientCount(): number {
121
- return this.peers.size
122
- }
123
-
124
- listen(path: string): void {
125
- const decoders = new WeakMap<PeerImpl, LineDecoder>()
126
- this.listener = Bun.listen<ConnState>({
127
- unix: path,
128
- socket: {
129
- open: (socket) => {
130
- const peer = new PeerImpl(this.nextId++, socket, this.log)
131
- socket.data = { peer }
132
- decoders.set(peer, new LineDecoder())
133
- this.peers.add(peer)
134
- this.hooks.onConnect(this.peers.size)
135
- },
136
- data: (socket, chunk) => {
137
- const peer = socket.data.peer
138
- let lines: string[]
139
- try {
140
- lines = (decoders.get(peer) as LineDecoder).push(chunk)
141
- } catch (err) {
142
- peer.write({
143
- jsonrpc: "2.0",
144
- id: null,
145
- error: { code: ErrorCode.ParseError, message: String(err) },
146
- })
147
- socket.end()
148
- return
149
- }
150
- for (const line of lines) void this.handleLine(peer, line)
151
- },
152
- drain: (socket) => socket.data.peer.drain(),
153
- close: (socket) => this.drop(socket.data.peer),
154
- error: (socket, err) => {
155
- this.log.warn("socket error", { err: String(err) })
156
- this.drop(socket.data.peer)
157
- },
158
- },
159
- })
160
- }
161
-
162
- broadcast(topic: string, data: unknown): void {
163
- for (const peer of this.peers) if (peer.subscribed(topic)) peer.send(topic, data)
164
- }
165
-
166
- stop(): void {
167
- this.listener?.stop(true)
168
- for (const peer of this.peers) peer.close()
169
- this.peers.clear()
170
- }
171
-
172
- private drop(peer: PeerImpl): void {
173
- if (!this.peers.delete(peer)) return
174
- peer.close()
175
- this.hooks.onDisconnect(this.peers.size)
176
- }
177
-
178
- private async handleLine(peer: PeerImpl, line: string): Promise<void> {
179
- let message: RpcMessage
180
- try {
181
- message = JSON.parse(line)
182
- } catch {
183
- peer.write({ jsonrpc: "2.0", id: null, error: { code: ErrorCode.ParseError, message: "invalid JSON" } })
184
- return
185
- }
186
- if (
187
- typeof message !== "object" ||
188
- message === null ||
189
- !("method" in message) ||
190
- typeof message.method !== "string"
191
- ) {
192
- peer.write({
193
- jsonrpc: "2.0",
194
- id: null,
195
- error: { code: ErrorCode.InvalidRequest, message: "expected a request" },
196
- })
197
- return
198
- }
199
- const hasId = "id" in message && (typeof message.id === "number" || typeof message.id === "string")
200
- const request = message as RpcRequest
201
- try {
202
- const result = await this.invoke(peer, request)
203
- if (hasId) peer.write({ jsonrpc: "2.0", id: request.id, result: result ?? {} })
204
- } catch (err) {
205
- const rpc =
206
- err instanceof RpcError
207
- ? err
208
- : new RpcError(ErrorCode.InternalError, err instanceof Error ? err.message : String(err))
209
- if (!(err instanceof RpcError))
210
- this.log.error("handler crashed", { method: request.method, err: String(err) })
211
- if (hasId) peer.write({ jsonrpc: "2.0", id: request.id, error: rpc.toShape() })
212
- }
213
- }
214
-
215
- private async invoke(peer: PeerImpl, request: RpcRequest): Promise<unknown> {
216
- if (!peer.greeted && request.method !== "daemon.hello") {
217
- throw new RpcError(ErrorCode.InvalidRequest, "call daemon.hello first")
218
- }
219
- return this.router.dispatch(request.method, request.params, { peer })
220
- }
221
- }
222
-
223
- export type { PeerImpl }
package/src/main.ts DELETED
@@ -1,36 +0,0 @@
1
- #!/usr/bin/env bun
2
- /** cockpitd entry point. Started detached by clients (ADR 0001); safe to run by hand for debugging. */
3
- import { join } from "node:path"
4
- import { resolvePaths } from "@opencode-cockpit/protocol"
5
- import { Daemon } from "./core/daemon.ts"
6
- import type { Level } from "./core/logger.ts"
7
- import { createModules } from "./modules/index.ts"
8
-
9
- const env = process.env
10
- const foreground = process.argv.includes("--foreground")
11
-
12
- const paths = resolvePaths(env)
13
- const daemon = new Daemon({
14
- paths,
15
- modules: createModules({ shell: { registryFile: join(paths.home, "shells.json") } }),
16
- idleTimeoutMs: Number(env.COCKPIT_IDLE_TIMEOUT_MS ?? 10 * 60_000),
17
- logLevel: (env.COCKPIT_LOG_LEVEL as Level | undefined) ?? "info",
18
- logToFile: !foreground,
19
- })
20
-
21
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
22
- process.on(signal, () => void daemon.stop(signal))
23
- }
24
- process.on("uncaughtException", (err) =>
25
- daemon.log.error("uncaught exception", { err: String(err), stack: err.stack }),
26
- )
27
- process.on("unhandledRejection", (err) => daemon.log.error("unhandled rejection", { err: String(err) }))
28
-
29
- try {
30
- await daemon.start()
31
- } catch (err) {
32
- daemon.log.error("failed to start", { err: String(err) })
33
- process.exit(1)
34
- }
35
- await daemon.stopped
36
- process.exit(0)
@@ -1,11 +0,0 @@
1
- import type { Module } from "../core/module.ts"
2
- import { ShellModule, type ShellModuleOptions } from "./shell/module.ts"
3
-
4
- export interface ModuleOptions {
5
- shell?: ShellModuleOptions
6
- }
7
-
8
- /** Every capability the daemon hosts. Add new modules here. */
9
- export function createModules(options: ModuleOptions = {}): Module[] {
10
- return [new ShellModule(options.shell)]
11
- }
@@ -1,8 +0,0 @@
1
- const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
2
-
3
- export function newShellId(): string {
4
- const bytes = crypto.getRandomValues(new Uint8Array(8))
5
- let id = "sh_"
6
- for (const byte of bytes) id += ALPHABET[byte % 32]
7
- return id
8
- }
@@ -1,323 +0,0 @@
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
- }
@@ -1,92 +0,0 @@
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
- }