@opencode-cockpit/protocol 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Codestz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @opencode-cockpit/protocol
2
+
3
+ Wire protocol for cockpitd: JSON-RPC 2.0 over NDJSON on a unix socket. Zod schemas for every method and event, error codes, filesystem layout and the daemon build id. No runtime I/O beyond path and hash helpers.
4
+
5
+ Part of [opencode-cockpit](https://github.com/Codestz/opencode-cockpit). Install the plugin, not this package, unless you are building your own front end:
6
+
7
+ ```sh
8
+ opencode plugin opencode-cockpit --global
9
+ ```
10
+
11
+ Requires Bun ≥ 1.3.5 (OpenCode's embedded runtime qualifies). License: MIT.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@opencode-cockpit/protocol",
3
+ "version": "0.1.0",
4
+ "description": "Wire protocol, method and event contracts for cockpitd (opencode-cockpit)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Codestz",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Codestz/opencode-cockpit.git",
11
+ "directory": "packages/protocol"
12
+ },
13
+ "homepage": "https://github.com/Codestz/opencode-cockpit#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/Codestz/opencode-cockpit/issues"
16
+ },
17
+ "keywords": [
18
+ "opencode",
19
+ "json-rpc",
20
+ "protocol"
21
+ ],
22
+ "exports": {
23
+ ".": "./src/index.ts",
24
+ "./shell": "./src/shell.ts"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "zod": "4.1.8"
36
+ },
37
+ "engines": {
38
+ "bun": ">=1.3.5"
39
+ }
40
+ }
package/src/build.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { createHash } from "node:crypto"
2
+ import { readdirSync, readFileSync, statSync } from "node:fs"
3
+ import { dirname, join, relative } from "node:path"
4
+
5
+ const SOURCE = /\.(ts|tsx|js|mjs|json)$/
6
+ const SKIP = new Set(["node_modules", "test", "dist"])
7
+
8
+ /**
9
+ * Identity of the daemon code that `entry` would run: a hash of every source file in the entry's
10
+ * package (its `src` directory, or the entry file itself when bundled). Daemon and client compute
11
+ * it the same way, so a client can tell when a running daemon was started from different code.
12
+ */
13
+ export function daemonBuildId(entry: string, version: string): string {
14
+ const hash = createHash("sha256").update(version)
15
+ const root = dirname(entry)
16
+ const files = /[\\/]src$/.test(root) ? walk(root) : [entry]
17
+ for (const file of files.sort()) {
18
+ hash.update(relative(root, file)).update("\0").update(readFileSync(file)).update("\0")
19
+ }
20
+ return `${version}+${hash.digest("hex").slice(0, 12)}`
21
+ }
22
+
23
+ function walk(dir: string): string[] {
24
+ const out: string[] = []
25
+ for (const name of readdirSync(dir)) {
26
+ if (SKIP.has(name)) continue
27
+ const path = join(dir, name)
28
+ if (statSync(path).isDirectory()) out.push(...walk(path))
29
+ else if (SOURCE.test(name)) out.push(path)
30
+ }
31
+ return out
32
+ }
@@ -0,0 +1,20 @@
1
+ import type { z } from "zod"
2
+
3
+ export interface MethodSpec<P extends z.ZodType = z.ZodType, R extends z.ZodType = z.ZodType> {
4
+ params: P
5
+ result: R
6
+ }
7
+
8
+ export const method = <P extends z.ZodType, R extends z.ZodType>(params: P, result: R): MethodSpec<P, R> => ({
9
+ params,
10
+ result,
11
+ })
12
+
13
+ export type Contract = Record<string, MethodSpec>
14
+ export type EventContract = Record<string, z.ZodType>
15
+
16
+ export type ParamsOf<C extends Contract, M extends keyof C> = z.input<C[M]["params"]>
17
+ export type ResultOf<C extends Contract, M extends keyof C> = z.output<C[M]["result"]>
18
+ export type EventOf<E extends EventContract, T extends keyof E> = z.output<E[T]>
19
+ /** Params after schema parsing (defaults applied): what handlers receive. */
20
+ export type ParsedParamsOf<C extends Contract, M extends keyof C> = z.output<C[M]["params"]>
package/src/daemon.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { z } from "zod"
2
+ import { method } from "./contract.ts"
3
+
4
+ export const ClientInfo = z.object({
5
+ name: z.string().min(1),
6
+ version: z.string().min(1),
7
+ pid: z.number().int().optional(),
8
+ })
9
+
10
+ export const Version = z.object({ major: z.number().int(), minor: z.number().int() })
11
+
12
+ export const HelloResult = z.object({
13
+ daemonVersion: z.string(),
14
+ /** Content identity of the running daemon code (see daemonBuildId). */
15
+ build: z.string().optional(),
16
+ protocol: Version,
17
+ modules: z.array(z.string()),
18
+ pid: z.number().int(),
19
+ startedAt: z.number(),
20
+ })
21
+
22
+ export const StatusResult = z.object({
23
+ pid: z.number().int(),
24
+ uptimeMs: z.number(),
25
+ clients: z.number().int(),
26
+ modules: z.array(z.object({ name: z.string(), busy: z.boolean() })),
27
+ })
28
+
29
+ export const daemonContract = {
30
+ "daemon.hello": method(z.object({ client: ClientInfo, protocol: Version }), HelloResult),
31
+ "daemon.status": method(z.object({}).optional(), StatusResult),
32
+ "daemon.shutdown": method(
33
+ z.object({ force: z.boolean().optional() }).optional(),
34
+ z.object({ accepted: z.boolean() }),
35
+ ),
36
+ "events.subscribe": method(
37
+ z.object({ topics: z.array(z.string().min(1)).min(1) }),
38
+ z.object({ topics: z.array(z.string()) }),
39
+ ),
40
+ "events.unsubscribe": method(
41
+ z.object({ topics: z.array(z.string().min(1)).min(1) }),
42
+ z.object({ topics: z.array(z.string()) }),
43
+ ),
44
+ }
45
+
46
+ export type HelloResult = z.output<typeof HelloResult>
47
+ export type StatusResult = z.output<typeof StatusResult>
48
+ export type ClientInfo = z.output<typeof ClientInfo>
package/src/framing.ts ADDED
@@ -0,0 +1,26 @@
1
+ /** NDJSON framing shared by daemon and client. */
2
+
3
+ const encoder = new TextEncoder()
4
+
5
+ export function encodeFrame(message: unknown): Uint8Array {
6
+ return encoder.encode(`${JSON.stringify(message)}\n`)
7
+ }
8
+
9
+ /** Accumulates chunks and yields complete lines. Bounded to protect against runaway peers. */
10
+ export class LineDecoder {
11
+ private readonly decoder = new TextDecoder()
12
+ private pending = ""
13
+
14
+ constructor(private readonly maxLineBytes = 16 * 1024 * 1024) {}
15
+
16
+ push(chunk: Uint8Array): string[] {
17
+ this.pending += this.decoder.decode(chunk, { stream: true })
18
+ const parts = this.pending.split("\n")
19
+ this.pending = parts.pop() ?? ""
20
+ if (this.pending.length > this.maxLineBytes) {
21
+ this.pending = ""
22
+ throw new Error(`frame exceeds ${this.maxLineBytes} bytes`)
23
+ }
24
+ return parts.filter((line) => line.length > 0)
25
+ }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { EventContract } from "./contract.ts"
2
+ import { daemonContract } from "./daemon.ts"
3
+ import { shellContract, shellEvents } from "./shell.ts"
4
+
5
+ export * from "./build.ts"
6
+ export * from "./contract.ts"
7
+ export * from "./daemon.ts"
8
+ export * from "./framing.ts"
9
+ export * from "./paths.ts"
10
+ export * from "./rpc.ts"
11
+ export * as shell from "./shell.ts"
12
+
13
+ /** Every method the daemon serves. Adding a module means spreading its contract here. */
14
+ export const contract = { ...daemonContract, ...shellContract }
15
+ export type Methods = typeof contract
16
+ export type MethodName = keyof Methods
17
+
18
+ /** Every event topic the daemon emits. */
19
+ export const events = { ...shellEvents } satisfies EventContract
20
+ export type Events = typeof events
21
+ export type Topic = keyof Events
package/src/paths.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+
4
+ export interface CockpitPaths {
5
+ home: string
6
+ socket: string
7
+ pidFile: string
8
+ lockFile: string
9
+ logFile: string
10
+ }
11
+
12
+ /**
13
+ * Filesystem layout shared by daemon and clients. Pure: creates nothing.
14
+ * Kept short because unix socket paths are limited to 104 bytes on macOS.
15
+ */
16
+ export function resolvePaths(env: Record<string, string | undefined> = process.env): CockpitPaths {
17
+ const home = env.COCKPIT_HOME ?? join(env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "opencode-cockpit")
18
+ return {
19
+ home,
20
+ socket: join(home, "cockpitd.sock"),
21
+ pidFile: join(home, "cockpitd.pid"),
22
+ lockFile: join(home, "spawn.lock"),
23
+ logFile: join(home, "cockpitd.log"),
24
+ }
25
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,96 @@
1
+ /** Wire envelope: JSON-RPC 2.0, one JSON object per line (ADR 0002). */
2
+
3
+ /** Bump MAJOR on breaking changes to methods, events or framing. */
4
+ export const PROTOCOL_VERSION = { major: 1, minor: 1 } as const
5
+
6
+ export type RequestId = number | string
7
+
8
+ export interface RpcRequest {
9
+ jsonrpc: "2.0"
10
+ id: RequestId
11
+ method: string
12
+ params?: unknown
13
+ }
14
+
15
+ export interface RpcNotification {
16
+ jsonrpc: "2.0"
17
+ method: string
18
+ params?: unknown
19
+ }
20
+
21
+ export interface RpcSuccess {
22
+ jsonrpc: "2.0"
23
+ id: RequestId
24
+ result: unknown
25
+ }
26
+
27
+ export interface RpcFailure {
28
+ jsonrpc: "2.0"
29
+ id: RequestId | null
30
+ error: RpcErrorShape
31
+ }
32
+
33
+ export type RpcMessage = RpcRequest | RpcNotification | RpcSuccess | RpcFailure
34
+
35
+ export interface RpcErrorShape {
36
+ code: number
37
+ message: string
38
+ data?: unknown
39
+ }
40
+
41
+ export const ErrorCode = {
42
+ ParseError: -32700,
43
+ InvalidRequest: -32600,
44
+ MethodNotFound: -32601,
45
+ InvalidParams: -32602,
46
+ InternalError: -32603,
47
+ // Application range
48
+ NotFound: -32001,
49
+ InvalidState: -32002,
50
+ ProtocolMismatch: -32003,
51
+ SpawnFailed: -32004,
52
+ ShuttingDown: -32005,
53
+ } as const
54
+
55
+ export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]
56
+
57
+ export class RpcError extends Error {
58
+ constructor(
59
+ readonly code: number,
60
+ message: string,
61
+ readonly data?: unknown,
62
+ ) {
63
+ super(message)
64
+ this.name = "RpcError"
65
+ }
66
+
67
+ toShape(): RpcErrorShape {
68
+ return this.data === undefined
69
+ ? { code: this.code, message: this.message }
70
+ : { code: this.code, message: this.message, data: this.data }
71
+ }
72
+
73
+ static from(shape: RpcErrorShape): RpcError {
74
+ return new RpcError(shape.code, shape.message, shape.data)
75
+ }
76
+ }
77
+
78
+ /** Method name for daemon → client notifications. */
79
+ export const EVENT_METHOD = "event"
80
+
81
+ export interface EventEnvelope<T extends string = string, D = unknown> {
82
+ topic: T
83
+ data: D
84
+ }
85
+
86
+ export function isRequest(msg: RpcMessage): msg is RpcRequest {
87
+ return "method" in msg && "id" in msg && msg.id !== undefined
88
+ }
89
+
90
+ export function isNotification(msg: RpcMessage): msg is RpcNotification {
91
+ return "method" in msg && !("id" in msg)
92
+ }
93
+
94
+ export function isResponse(msg: RpcMessage): msg is RpcSuccess | RpcFailure {
95
+ return !("method" in msg) && ("result" in msg || "error" in msg)
96
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,187 @@
1
+ import { z } from "zod"
2
+ import { method } from "./contract.ts"
3
+
4
+ export const ShellId = z.string().regex(/^sh_[a-z2-7]{8}$/, "expected sh_ followed by 8 base32 chars")
5
+
6
+ export const Owner = z.object({
7
+ /** Absolute project directory the shell belongs to. */
8
+ project: z.string().min(1),
9
+ /** OpenCode session that started it, when started by an agent. */
10
+ session: z.string().min(1).optional(),
11
+ /** Opaque id of the client instance that started it; used to route notifications to one place. */
12
+ instance: z.string().min(1).optional(),
13
+ })
14
+
15
+ export const ShellStatus = z.enum(["running", "exited", "killed", "failed"])
16
+
17
+ export const ShellInfo = z.object({
18
+ id: ShellId,
19
+ title: z.string(),
20
+ command: z.string(),
21
+ args: z.array(z.string()),
22
+ cwd: z.string(),
23
+ owner: Owner,
24
+ status: ShellStatus,
25
+ run: z.number().int().positive(),
26
+ pid: z.number().int().optional(),
27
+ exitCode: z.number().int().optional(),
28
+ signal: z.string().optional(),
29
+ error: z.string().optional(),
30
+ /** Set when a run ends: the last error-looking line of the run, else its last line. */
31
+ summary: z.string().optional(),
32
+ startedAt: z.number(),
33
+ endedAt: z.number().optional(),
34
+ cols: z.number().int(),
35
+ rows: z.number().int(),
36
+ lines: z.object({ first: z.number().int(), last: z.number().int() }),
37
+ /** Absolute raw byte offset written so far (for UI attach/replay). */
38
+ bytes: z.number().int(),
39
+ })
40
+
41
+ const Dimension = z.number().int().min(2).max(1000)
42
+
43
+ export const StartParams = z.object({
44
+ command: z.string().min(1),
45
+ args: z.array(z.string()).default([]),
46
+ cwd: z.string().min(1),
47
+ env: z.record(z.string(), z.string()).optional(),
48
+ title: z.string().min(1).max(200).optional(),
49
+ cols: Dimension.default(120),
50
+ rows: Dimension.default(32),
51
+ owner: Owner,
52
+ /** Stop the shell automatically after this long. */
53
+ timeoutMs: z.number().int().positive().optional(),
54
+ /**
55
+ * Restart a finished shell with the same command, args, cwd, project and session instead of
56
+ * creating a new one. Repeated runs then share one id and one log.
57
+ */
58
+ reuse: z.boolean().default(false),
59
+ })
60
+
61
+ export const ClearParams = z
62
+ .object({
63
+ owner: Owner.partial().optional(),
64
+ /** Only remove shells that finished at least this long ago. */
65
+ finishedBeforeMs: z.number().int().min(0).optional(),
66
+ })
67
+ .default({})
68
+
69
+ export const ListParams = z
70
+ .object({
71
+ owner: Owner.partial().optional(),
72
+ includeExited: z.boolean().default(true),
73
+ })
74
+ .default({ includeExited: true })
75
+
76
+ export const IdParams = z.object({ id: ShellId })
77
+
78
+ export const LogLine = z.object({ n: z.number().int(), text: z.string() })
79
+
80
+ export const ReadParams = z.object({
81
+ id: ShellId,
82
+ /** Cursor: return only lines numbered strictly greater than this. */
83
+ after: z.number().int().min(0).optional(),
84
+ /** When no cursor is given, return the last N lines. */
85
+ tail: z.number().int().positive().max(10_000).default(100),
86
+ limit: z.number().int().positive().max(10_000).default(500),
87
+ grep: z.string().min(1).max(500).optional(),
88
+ ignoreCase: z.boolean().default(false),
89
+ })
90
+
91
+ export const ReadResult = z.object({
92
+ lines: z.array(LogLine),
93
+ firstLine: z.number().int(),
94
+ lastLine: z.number().int(),
95
+ /** Pass as `after` to continue. */
96
+ nextCursor: z.number().int(),
97
+ /** True when requested lines were already evicted. */
98
+ truncated: z.boolean(),
99
+ /** True when more lines exist beyond `limit`. */
100
+ hasMore: z.boolean(),
101
+ status: ShellStatus,
102
+ })
103
+
104
+ export const ScreenResult = z.object({
105
+ text: z.string(),
106
+ cols: z.number().int(),
107
+ rows: z.number().int(),
108
+ cursor: z.object({ x: z.number().int(), y: z.number().int() }),
109
+ })
110
+
111
+ export const WriteParams = z.object({ id: ShellId, data: z.string().max(1_000_000) })
112
+
113
+ export const ResizeParams = z.object({ id: ShellId, cols: Dimension, rows: Dimension })
114
+
115
+ export const WaitUntil = z
116
+ .object({
117
+ pattern: z.string().min(1).max(500).optional(),
118
+ ignoreCase: z.boolean().optional(),
119
+ exit: z.boolean().optional(),
120
+ idleMs: z.number().int().positive().optional(),
121
+ port: z.number().int().min(1).max(65_535).optional(),
122
+ host: z.string().optional(),
123
+ })
124
+ .refine((u) => u.pattern !== undefined || u.exit || u.idleMs !== undefined || u.port !== undefined, {
125
+ message: "until needs at least one of pattern, exit, idleMs, port",
126
+ })
127
+
128
+ export const WaitParams = z.object({
129
+ id: ShellId,
130
+ until: WaitUntil,
131
+ timeoutMs: z.number().int().positive().max(3_600_000),
132
+ /** Only consider lines after this cursor for `pattern`. Defaults to the current last line. */
133
+ after: z.number().int().min(0).optional(),
134
+ })
135
+
136
+ export const WaitReason = z.enum(["pattern", "exit", "idle", "port", "timeout"])
137
+
138
+ export const WaitResult = z.object({
139
+ reason: WaitReason,
140
+ match: LogLine.optional(),
141
+ info: ShellInfo,
142
+ })
143
+
144
+ export const StopParams = z.object({
145
+ id: ShellId,
146
+ signal: z.enum(["SIGTERM", "SIGINT", "SIGHUP", "SIGKILL"]).default("SIGTERM"),
147
+ graceMs: z.number().int().min(0).max(60_000).default(3000),
148
+ })
149
+
150
+ export const AttachParams = z.object({ id: ShellId, fromOffset: z.number().int().min(0).optional() })
151
+
152
+ export const shellContract = {
153
+ "shell.start": method(StartParams, ShellInfo),
154
+ "shell.list": method(ListParams, z.array(ShellInfo)),
155
+ "shell.get": method(IdParams, ShellInfo),
156
+ "shell.read": method(ReadParams, ReadResult),
157
+ "shell.screen": method(IdParams, ScreenResult),
158
+ "shell.write": method(WriteParams, z.object({ bytes: z.number().int() })),
159
+ "shell.resize": method(ResizeParams, z.object({})),
160
+ "shell.wait": method(WaitParams, WaitResult),
161
+ "shell.stop": method(StopParams, ShellInfo),
162
+ "shell.restart": method(IdParams, ShellInfo),
163
+ "shell.remove": method(IdParams, z.object({})),
164
+ "shell.clear": method(ClearParams, z.object({ removed: z.array(ShellId) })),
165
+ "shell.attach": method(AttachParams, z.object({ offset: z.number().int(), replay: z.string() })),
166
+ "shell.detach": method(IdParams, z.object({})),
167
+ }
168
+
169
+ export const shellEvents = {
170
+ "shell.started": ShellInfo,
171
+ "shell.exited": ShellInfo,
172
+ "shell.removed": z.object({ id: ShellId }),
173
+ "shell.output": z.object({ id: ShellId, offset: z.number().int(), data: z.string() }),
174
+ }
175
+
176
+ export type Owner = z.output<typeof Owner>
177
+ export type ShellStatus = z.output<typeof ShellStatus>
178
+ export type ShellInfo = z.output<typeof ShellInfo>
179
+ export type StartParams = z.output<typeof StartParams>
180
+ export type ReadParams = z.output<typeof ReadParams>
181
+ export type ReadResult = z.output<typeof ReadResult>
182
+ export type ScreenResult = z.output<typeof ScreenResult>
183
+ export type WaitParams = z.output<typeof WaitParams>
184
+ export type WaitResult = z.output<typeof WaitResult>
185
+ export type WaitReason = z.output<typeof WaitReason>
186
+ export type StopParams = z.output<typeof StopParams>
187
+ export type LogLine = z.output<typeof LogLine>