@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
package/src/core/server.ts
DELETED
|
@@ -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)
|
package/src/modules/index.ts
DELETED
|
@@ -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
|
-
}
|
package/src/modules/shell/ids.ts
DELETED
|
@@ -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
|
-
}
|