opencode-cockpit 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,124 @@
1
+ # opencode-cockpit
2
+
3
+ [![CI](https://github.com/Codestz/opencode-cockpit/actions/workflows/ci.yml/badge.svg)](https://github.com/Codestz/opencode-cockpit/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/opencode-cockpit)](https://www.npmjs.com/package/opencode-cockpit)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Codestz/opencode-cockpit/blob/main/LICENSE)
6
+
7
+ Superpowers for [OpenCode](https://opencode.ai).
8
+
9
+ The first one is **Shell**: background terminals that the agent starts, waits on and drives,
10
+ and that you watch and control without leaving the chat.
11
+
12
+ - **The agent stops sleeping and polling.** It starts a dev server and blocks until the port
13
+ opens, runs a slow test suite and gets a message when it finishes, drives REPLs and prompts.
14
+ - **Logs that don't waste tokens.** Colours are stripped, progress-bar redraws collapse to their
15
+ final frame, repeated lines fold, and reads continue from a cursor.
16
+ - **You see everything.** A docked panel under the chat, a sidebar section, and a keyboard-first
17
+ console with a real typing mode.
18
+ - **Shells outlive OpenCode.** They run in a small daemon, `cockpitd`, shared by every OpenCode
19
+ window and restarted automatically when the plugin updates.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ opencode plugin opencode-cockpit --global
25
+ ```
26
+
27
+ This adds the plugin to both `opencode.json` (agent tools) and `tui.json` (interface). Restart
28
+ OpenCode. Requires OpenCode 1.18 or newer on macOS or Linux.
29
+
30
+ ## For the agent
31
+
32
+ | Tool | What it does |
33
+ |---|---|
34
+ | `shell_start` | Run a command in a background PTY. Optional `waitFor` blocks until a port opens, a pattern prints, output goes idle, or the process exits. Rerunning the same command in the same session reuses the shell. |
35
+ | `shell_wait` | Block on a condition instead of sleeping. |
36
+ | `shell_read` | Clean log from a cursor, with `grep`; or `view: "screen"` for full-screen programs. |
37
+ | `shell_send` | Type text or keys (`ctrl+c`, `up`, `enter`) and get the reply. |
38
+ | `shell_list` · `shell_stop` · `shell_restart` | Manage shells. |
39
+
40
+ The agent is messaged when a shell it started exits on its own.
41
+
42
+ Things to ask:
43
+
44
+ - *"Start the dev server in a background shell and wait until it's ready."*
45
+ - *"Run the test suite in the background, keep working on the parser, tell me if it fails."*
46
+ - *"Open a node REPL in a shell and check what `new URL('..', import.meta.url)` returns."*
47
+
48
+ ## For you
49
+
50
+ | Key / command | Does |
51
+ |---|---|
52
+ | `ctrl+x o` · `/shells` | Toggle the shells panel under the chat |
53
+ | `ctrl+x i` · `/shell` | Open the shell console |
54
+ | `/shell-new` | Start a shell yourself |
55
+ | `/shells-clear` | Remove finished shells |
56
+ | `/shells-restart-daemon` | Restart `cockpitd` (asks first when shells are running) |
57
+
58
+ Status reads the same everywhere: `RUN` (with a spinner), `FAIL`, `STOP`, `DONE`. Running shells
59
+ and recent failures stay visible; everything else folds into `▸ N more`.
60
+
61
+ **Console keys.** Running shell: `i` type (every key goes to the program, `ctrl+]` to stop typing),
62
+ `c` ctrl+c, `r` restart, `x` stop. Finished shell: `r` run again, `d` remove. Always: `tab`
63
+ screen or log, `?` details, `[` `]` switch, `D` clear finished, `a` show all, `esc` close.
64
+
65
+ ## Configuration
66
+
67
+ Options go on the plugin entry in `tui.json`:
68
+
69
+ ```json
70
+ {
71
+ "plugin": [
72
+ ["opencode-cockpit", {
73
+ "dockHeight": 16,
74
+ "dockOpen": true,
75
+ "historyMinutes": 60,
76
+ "keybinds": { "cockpit.shells.dock": "<leader>j", "cockpit.shells.console": "<leader>k" }
77
+ }]
78
+ ]
79
+ }
80
+ ```
81
+
82
+ | Environment variable | Default | Purpose |
83
+ |---|---|---|
84
+ | `COCKPIT_HOME` | `~/.cache/opencode-cockpit` | Socket, logs, process registry |
85
+ | `COCKPIT_IDLE_TIMEOUT_MS` | `600000` | Daemon exits after this long with no clients and no running shells |
86
+ | `COCKPIT_LOG_LEVEL` | `info` | `debug` for verbose daemon logs |
87
+
88
+ ## Troubleshooting
89
+
90
+ | Problem | Look at |
91
+ |---|---|
92
+ | Tools fail with "did not start" | `~/.cache/opencode-cockpit/cockpitd.log` |
93
+ | Plugin not loading | newest file in `~/.local/share/opencode/log/` |
94
+ | Panel says the daemon runs older code | `/shells-restart-daemon` once your shells are done |
95
+
96
+ ## How it works
97
+
98
+ ```
99
+ OpenCode TUI thread ── opencode-cockpit/tui ──┐
100
+ ├── unix socket, JSON-RPC ── cockpitd ── PTYs
101
+ OpenCode server worker ─ opencode-cockpit/server ┘
102
+ ```
103
+
104
+ OpenCode runs its interface and its server in separate threads, so the plugin's two halves can't
105
+ share memory. Both talk to `cockpitd`, which owns every process. Each shell's output feeds a line
106
+ normalizer (the agent's log), a headless terminal emulator (the screen you see) and a raw ring
107
+ buffer (replay for late viewers). See [CONTRIBUTING.md](https://github.com/Codestz/opencode-cockpit/blob/main/CONTRIBUTING.md) for the full picture.
108
+
109
+ | Package | Role |
110
+ |---|---|
111
+ | [`opencode-cockpit`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/opencode) | The plugin: agent tools and TUI |
112
+ | [`@opencode-cockpit/daemon`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/daemon) | `cockpitd` |
113
+ | [`@opencode-cockpit/client`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/client) | Typed, auto-spawning client |
114
+ | [`@opencode-cockpit/protocol`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/protocol) | Wire contracts |
115
+
116
+ ## Roadmap
117
+
118
+ - **Watchers:** `tsc`, `eslint` and `vitest` shells that report only state changes.
119
+ - **Agents panel:** live subagent tree with a peek overlay.
120
+ - Coloured output in the panel and console.
121
+
122
+ ## License
123
+
124
+ [MIT](https://github.com/Codestz/opencode-cockpit/blob/main/LICENSE)
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "opencode-cockpit",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode superpowers: background shells the agent can start, wait on and drive, with a docked TUI panel",
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/opencode"
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
+ "opencode-plugin",
20
+ "pty",
21
+ "shell",
22
+ "background",
23
+ "tui",
24
+ "terminal"
25
+ ],
26
+ "exports": {
27
+ "./server": "./src/server.ts",
28
+ "./tui": "./src/tui/index.tsx"
29
+ },
30
+ "engines": {
31
+ "opencode": ">=1.18.0",
32
+ "bun": ">=1.3.5"
33
+ },
34
+ "files": [
35
+ "src",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@opencode-cockpit/client": "0.0.1",
44
+ "@opencode-cockpit/daemon": "0.0.1",
45
+ "@opencode-cockpit/protocol": "0.0.1",
46
+ "@opencode-ai/plugin": "1.18.31"
47
+ },
48
+ "peerDependencies": {
49
+ "@opentui/core": ">=0.4.5",
50
+ "@opentui/keymap": ">=0.4.5",
51
+ "@opentui/solid": ">=0.4.5",
52
+ "solid-js": ">=1.9"
53
+ },
54
+ "devDependencies": {
55
+ "@opentui/core": "0.4.5",
56
+ "@opentui/keymap": "0.4.5",
57
+ "@opentui/solid": "0.4.5",
58
+ "solid-js": "1.9.15",
59
+ "zod": "4.1.8"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "@opentui/core": {
63
+ "optional": true
64
+ },
65
+ "@opentui/keymap": {
66
+ "optional": true
67
+ },
68
+ "@opentui/solid": {
69
+ "optional": true
70
+ },
71
+ "solid-js": {
72
+ "optional": true
73
+ }
74
+ }
75
+ }
package/src/connect.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { fileURLToPath } from "node:url"
2
+ import { CockpitClient } from "@opencode-cockpit/client"
3
+ import daemonPkg from "@opencode-cockpit/daemon/package.json" with { type: "json" }
4
+ import { daemonBuildId } from "@opencode-cockpit/protocol"
5
+ import pkg from "../package.json" with { type: "json" }
6
+
7
+ /** Resolves the daemon entry shipped with this package. */
8
+ export function daemonEntry(): string {
9
+ return fileURLToPath(import.meta.resolve("@opencode-cockpit/daemon/main"))
10
+ }
11
+
12
+ /**
13
+ * Inside OpenCode `process.execPath` is the OpenCode binary; the client starts the daemon with
14
+ * BUN_BE_BUN=1 so it runs on OpenCode's embedded Bun (ADR 0001). The expected build lets the
15
+ * client replace a daemon left running from older plugin code.
16
+ */
17
+ export function createClient(name: string): CockpitClient {
18
+ const entry = daemonEntry()
19
+ return new CockpitClient({
20
+ client: { name, version: pkg.version, pid: process.pid },
21
+ spawn: { entry, execPath: process.execPath },
22
+ expectedBuild: daemonBuildId(entry, daemonPkg.version),
23
+ })
24
+ }
package/src/server.ts ADDED
@@ -0,0 +1,98 @@
1
+ import type { Plugin, PluginModule } from "@opencode-ai/plugin"
2
+ import type { ShellInfo } from "@opencode-cockpit/protocol/shell"
3
+ import { createClient } from "./connect.ts"
4
+ import { describeStatus, formatLines } from "./tools/format.ts"
5
+ import { createTools } from "./tools/index.ts"
6
+
7
+ const GUIDANCE = `## Background shells (opencode-cockpit)
8
+ Long-running or interactive commands (dev servers, watchers, slow builds/tests, REPLs) go in shell_start, not bash with "&".
9
+ Block with shell_wait (pattern, port, idle, exit) instead of sleeping; follow output with shell_read(after=cursor).
10
+ You are messaged when a shell you started exits.`
11
+
12
+ const server: Plugin = async ({ client: opencode, directory }) => {
13
+ const cockpit = createClient("opencode-cockpit/server")
14
+ const instance = crypto.randomUUID()
15
+ const quiet = new Set<string>()
16
+
17
+ const userShell =
18
+ process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash"
19
+ const env = () => {
20
+ const out: Record<string, string> = {}
21
+ for (const [k, v] of Object.entries(process.env))
22
+ if (v !== undefined && !k.startsWith("OPENCODE_")) out[k] = v
23
+ return out
24
+ }
25
+
26
+ // Wake the agent when a shell it owns ends on its own.
27
+ cockpit.on("shell.exited", (info) => {
28
+ if (info.owner.instance !== instance || !info.owner.session) return
29
+ if (quiet.delete(info.id)) return
30
+ void notifyExit(info).catch(() => {})
31
+ })
32
+
33
+ async function notifyExit(info: ShellInfo): Promise<void> {
34
+ const session = info.owner.session as string
35
+ const page = await cockpit.call("shell.read", { id: info.id, tail: 15 })
36
+ const failed =
37
+ info.status === "failed" ||
38
+ (info.status === "exited" && info.exitCode !== 0) ||
39
+ info.status === "killed"
40
+ const text = [
41
+ `<shell_exited id="${info.id}" title="${info.title}">`,
42
+ describeStatus(info),
43
+ page.lines.length > 0 ? `last output:\n${formatLines(page.lines)}` : "(no output)",
44
+ "</shell_exited>",
45
+ failed
46
+ ? `Investigate with shell_read id=${info.id} grep="error|fail" if the failure matters to the task.`
47
+ : `Full output: shell_read id=${info.id}.`,
48
+ ].join("\n")
49
+ await opencode.session.promptAsync({
50
+ path: { id: session },
51
+ body: { parts: [{ type: "text", text, synthetic: true } as never] },
52
+ })
53
+ }
54
+
55
+ return {
56
+ tool: createTools({
57
+ client: cockpit,
58
+ instance,
59
+ quiet,
60
+ env,
61
+ shellCommand: (command) => ({ command: userShell, args: ["-c", command] }),
62
+ }),
63
+
64
+ "experimental.chat.system.transform": async (_input, output) => {
65
+ output.system.push(GUIDANCE)
66
+ const running = await cockpit
67
+ .call("shell.list", { owner: { project: directory }, includeExited: false })
68
+ .catch(() => [] as ShellInfo[])
69
+ if (running.length > 0) {
70
+ output.system.push(
71
+ `Background shells currently running in this project:\n${running
72
+ .slice(0, 15)
73
+ .map((s) => `- ${s.id} ${s.title} (${describeStatus(s)})`)
74
+ .join("\n")}`,
75
+ )
76
+ }
77
+ },
78
+
79
+ event: async ({ event }) => {
80
+ if (event.type !== "session.deleted") return
81
+ const sessionID = event.properties.info.id
82
+ const owned = await cockpit
83
+ .call("shell.list", { owner: { session: sessionID } })
84
+ .catch(() => [] as ShellInfo[])
85
+ for (const shell of owned) {
86
+ quiet.add(shell.id)
87
+ await cockpit.call("shell.remove", { id: shell.id }).catch(() => {})
88
+ }
89
+ },
90
+
91
+ dispose: async () => {
92
+ cockpit.close()
93
+ },
94
+ }
95
+ }
96
+
97
+ const plugin: PluginModule & { id: string } = { id: "opencode-cockpit", server }
98
+ export default plugin
@@ -0,0 +1,78 @@
1
+ import type { LogLine, ReadResult, ShellInfo, WaitResult } from "@opencode-cockpit/protocol/shell"
2
+
3
+ const MAX_LINE = 2000
4
+
5
+ /** Compact log lines for a model: numbered, long lines cut, consecutive repeats collapsed. */
6
+ export function formatLines(lines: LogLine[]): string {
7
+ const out: string[] = []
8
+ let i = 0
9
+ while (i < lines.length) {
10
+ const line = lines[i] as LogLine
11
+ let j = i + 1
12
+ while (j < lines.length && (lines[j] as LogLine).text === line.text) j++
13
+ const repeats = j - i
14
+ const text =
15
+ line.text.length > MAX_LINE
16
+ ? `${line.text.slice(0, MAX_LINE)}… [${line.text.length - MAX_LINE} chars cut]`
17
+ : line.text
18
+ out.push(
19
+ repeats > 1
20
+ ? `${line.n}| ${text} (×${repeats}, lines ${line.n}-${line.n + repeats - 1})`
21
+ : `${line.n}| ${text}`,
22
+ )
23
+ i = j
24
+ }
25
+ return out.join("\n")
26
+ }
27
+
28
+ export function describeStatus(info: ShellInfo): string {
29
+ switch (info.status) {
30
+ case "running":
31
+ return `running (pid ${info.pid}, up ${duration(Date.now() - info.startedAt)})`
32
+ case "exited":
33
+ return `exited with code ${info.exitCode ?? "?"} after ${duration((info.endedAt ?? Date.now()) - info.startedAt)}`
34
+ case "killed":
35
+ return `killed${info.signal ? ` by ${info.signal}` : ""} after ${duration((info.endedAt ?? Date.now()) - info.startedAt)}`
36
+ case "failed":
37
+ return `failed to start: ${info.error ?? "unknown error"}`
38
+ }
39
+ }
40
+
41
+ export function header(info: ShellInfo): string {
42
+ const run = info.run > 1 ? ` run=${info.run}` : ""
43
+ return `<shell id="${info.id}" title="${info.title.replaceAll('"', "'")}" status="${info.status}"${run}>`
44
+ }
45
+
46
+ export function formatRead(info: ShellInfo, page: ReadResult, empty = "(no output yet)"): string {
47
+ const parts = [header(info), `status: ${describeStatus(info)}`]
48
+ if (page.truncated)
49
+ parts.push(`(older lines were dropped from the buffer; oldest kept is ${page.firstLine})`)
50
+ parts.push(page.lines.length > 0 ? formatLines(page.lines) : empty)
51
+ if (page.hasMore) parts.push(`(more lines available: call shell_read with after=${page.nextCursor})`)
52
+ parts.push("</shell>", `cursor: ${page.nextCursor}`)
53
+ return parts.join("\n")
54
+ }
55
+
56
+ export function formatWait(result: WaitResult, timeoutSeconds: number): string {
57
+ switch (result.reason) {
58
+ case "pattern":
59
+ return `condition met: pattern matched on line ${result.match?.n}: ${result.match?.text}`
60
+ case "port":
61
+ return "condition met: port is accepting connections"
62
+ case "idle":
63
+ return "condition met: no output for the idle window (the program may be waiting for input)"
64
+ case "exit":
65
+ return `process ended: ${describeStatus(result.info)}`
66
+ case "timeout":
67
+ return `timed out after ${timeoutSeconds}s without the condition being met; the shell is still ${result.info.status}`
68
+ }
69
+ }
70
+
71
+ export function duration(ms: number): string {
72
+ const s = Math.max(0, Math.round(ms / 1000))
73
+ if (s < 60) return `${s}s`
74
+ const m = Math.floor(s / 60)
75
+ if (m < 60) return `${m}m${s % 60 ? `${s % 60}s` : ""}`
76
+ const h = Math.floor(m / 60)
77
+ return `${h}h${m % 60 ? `${m % 60}m` : ""}`
78
+ }