@opencode-cockpit/shell 0.1.2

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,108 @@
1
+ # @opencode-cockpit/shell
2
+
3
+ [![npm](https://img.shields.io/npm/v/@opencode-cockpit/shell)](https://www.npmjs.com/package/@opencode-cockpit/shell)
4
+
5
+ Background terminals for [OpenCode](https://opencode.ai) that the agent starts, waits on and
6
+ drives, and that you watch and control without leaving the chat. Part of
7
+ [opencode-cockpit](https://github.com/Codestz/opencode-cockpit).
8
+
9
+ - **The agent stops sleeping and polling.** It starts a dev server and blocks until the port
10
+ opens, runs a slow test suite and gets a message when it finishes, drives REPLs and prompts.
11
+ - **Logs that don't waste tokens.** Colours are stripped, progress-bar redraws collapse to their
12
+ final frame, repeated lines fold, and reads continue from a cursor.
13
+ - **You see everything.** A docked panel under the chat, a sidebar section, and a keyboard-first
14
+ console with a real typing mode.
15
+ - **Shells outlive OpenCode.** They run in a small daemon, `cockpitd`, shared by every OpenCode
16
+ window and upgraded automatically when the plugin updates.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ opencode plugin @opencode-cockpit/shell --global
22
+ ```
23
+
24
+ Restart OpenCode. Requires OpenCode 1.18 or newer on macOS or Linux.
25
+
26
+ Shell is also included in [`opencode-cockpit`](https://www.npmjs.com/package/opencode-cockpit),
27
+ which installs every cockpit feature. Install one or the other: if both are configured, the first
28
+ one loaded is used and OpenCode shows a warning.
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/shell", {
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
+ Using `opencode-cockpit` instead? Put the same options under `"shell"`:
83
+ `["opencode-cockpit", { "shell": { "dockHeight": 16 } }]`.
84
+
85
+ | Environment variable | Default | Purpose |
86
+ |---|---|---|
87
+ | `COCKPIT_HOME` | `~/.cache/opencode-cockpit` | Socket, logs, process registry |
88
+ | `COCKPIT_IDLE_TIMEOUT_MS` | `600000` | Daemon exits after this long with no clients and no running shells |
89
+ | `COCKPIT_LOG_LEVEL` | `info` | `debug` for verbose daemon logs |
90
+
91
+ ## Troubleshooting
92
+
93
+ | Problem | Look at |
94
+ |---|---|
95
+ | Tools fail with "did not start" | `~/.cache/opencode-cockpit/cockpitd.log` |
96
+ | Plugin not loading | newest file in `~/.local/share/opencode/log/` |
97
+ | Warning: "Shell is configured twice" | Remove either `opencode-cockpit` or `@opencode-cockpit/shell` from `opencode.json` and `tui.json` |
98
+ | Panel says the daemon runs older code | `/shells-restart-daemon` once your shells are done |
99
+
100
+ ## How it works
101
+
102
+ OpenCode runs its interface and its server in separate threads, so the plugin's two halves can't
103
+ share memory. Both talk to `cockpitd`, which owns every process. Each shell's output feeds a line
104
+ normalizer (the agent's log), a headless terminal emulator (the screen you see) and a raw ring
105
+ buffer (replay for late viewers). See
106
+ [CONTRIBUTING.md](https://github.com/Codestz/opencode-cockpit/blob/main/CONTRIBUTING.md).
107
+
108
+ License: MIT
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@opencode-cockpit/shell",
3
+ "version": "0.1.2",
4
+ "description": "Background shells for OpenCode: the agent starts, waits on and drives PTYs; you watch them in 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/shell"
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
+ "./connect": "./src/connect.ts"
30
+ },
31
+ "engines": {
32
+ "opencode": ">=1.18.0",
33
+ "bun": ">=1.3.5"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@opencode-cockpit/client": "0.1.2",
45
+ "@opencode-cockpit/daemon": "0.1.2",
46
+ "@opencode-cockpit/protocol": "0.1.2",
47
+ "@opencode-ai/plugin": "1.18.31"
48
+ },
49
+ "peerDependencies": {
50
+ "@opentui/core": ">=0.4.5",
51
+ "@opentui/keymap": ">=0.4.5",
52
+ "@opentui/solid": ">=0.4.5",
53
+ "solid-js": ">=1.9"
54
+ },
55
+ "devDependencies": {
56
+ "@opentui/core": "0.4.5",
57
+ "@opentui/keymap": "0.4.5",
58
+ "@opentui/solid": "0.4.5",
59
+ "solid-js": "1.9.15",
60
+ "zod": "4.1.8"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "@opentui/core": {
64
+ "optional": true
65
+ },
66
+ "@opentui/keymap": {
67
+ "optional": true
68
+ },
69
+ "@opentui/solid": {
70
+ "optional": true
71
+ },
72
+ "solid-js": {
73
+ "optional": true
74
+ }
75
+ }
76
+ }
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,137 @@
1
+ import type { Hooks, Plugin, PluginInput, PluginModule } from "@opencode-ai/plugin"
2
+ import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client"
3
+ import type { ShellInfo } from "@opencode-cockpit/protocol/shell"
4
+ import { createClient } from "./connect.ts"
5
+ import { describeStatus, formatLines } from "./tools/format.ts"
6
+ import { createTools } from "./tools/index.ts"
7
+
8
+ const GUIDANCE = `## Background shells (opencode-cockpit)
9
+ Long-running or interactive commands (dev servers, watchers, slow builds/tests, REPLs) go in shell_start, not bash with "&".
10
+ Block with shell_wait (pattern, port, idle, exit) instead of sleeping; follow output with shell_read(after=cursor).
11
+ You are messaged when a shell you started exits.`
12
+
13
+ export const SHELL_PACKAGE = "@opencode-cockpit/shell"
14
+
15
+ export interface ShellServerOptions {
16
+ /** Package that loaded Shell, reported when a duplicate copy is skipped. */
17
+ source?: string
18
+ }
19
+
20
+ /** Shell's server half as a factory, so bundles such as `opencode-cockpit` can include it. */
21
+ export function createShellServer({ source = SHELL_PACKAGE }: ShellServerOptions = {}): Plugin {
22
+ return async (input) => {
23
+ const claim = claimFeature(input, "shell", source)
24
+ if (!claim.active) {
25
+ // Logging through the server during plugin initialisation could wait on ourselves; defer it.
26
+ setTimeout(() => {
27
+ void input.client.app
28
+ .log({
29
+ body: {
30
+ service: "opencode-cockpit",
31
+ level: "warn",
32
+ message: duplicateFeatureMessage("Shell", claim.owner, source),
33
+ },
34
+ })
35
+ .catch(() => {})
36
+ }, 0)
37
+ return {}
38
+ }
39
+ const hooks = await shellHooks(input)
40
+ const dispose = hooks.dispose
41
+ return {
42
+ ...hooks,
43
+ dispose: async () => {
44
+ claim.release()
45
+ await dispose?.()
46
+ },
47
+ }
48
+ }
49
+ }
50
+
51
+ async function shellHooks({ client: opencode, directory }: PluginInput): Promise<Hooks> {
52
+ const cockpit = createClient("opencode-cockpit/server")
53
+ const instance = crypto.randomUUID()
54
+ const quiet = new Set<string>()
55
+
56
+ const userShell =
57
+ process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash"
58
+ const env = () => {
59
+ const out: Record<string, string> = {}
60
+ for (const [k, v] of Object.entries(process.env))
61
+ if (v !== undefined && !k.startsWith("OPENCODE_")) out[k] = v
62
+ return out
63
+ }
64
+
65
+ // Wake the agent when a shell it owns ends on its own.
66
+ cockpit.on("shell.exited", (info) => {
67
+ if (info.owner.instance !== instance || !info.owner.session) return
68
+ if (quiet.delete(info.id)) return
69
+ void notifyExit(info).catch(() => {})
70
+ })
71
+
72
+ async function notifyExit(info: ShellInfo): Promise<void> {
73
+ const session = info.owner.session as string
74
+ const page = await cockpit.call("shell.read", { id: info.id, tail: 15 })
75
+ const failed =
76
+ info.status === "failed" ||
77
+ (info.status === "exited" && info.exitCode !== 0) ||
78
+ info.status === "killed"
79
+ const text = [
80
+ `<shell_exited id="${info.id}" title="${info.title}">`,
81
+ describeStatus(info),
82
+ page.lines.length > 0 ? `last output:\n${formatLines(page.lines)}` : "(no output)",
83
+ "</shell_exited>",
84
+ failed
85
+ ? `Investigate with shell_read id=${info.id} grep="error|fail" if the failure matters to the task.`
86
+ : `Full output: shell_read id=${info.id}.`,
87
+ ].join("\n")
88
+ await opencode.session.promptAsync({
89
+ path: { id: session },
90
+ body: { parts: [{ type: "text", text, synthetic: true } as never] },
91
+ })
92
+ }
93
+
94
+ return {
95
+ tool: createTools({
96
+ client: cockpit,
97
+ instance,
98
+ quiet,
99
+ env,
100
+ shellCommand: (command) => ({ command: userShell, args: ["-c", command] }),
101
+ }),
102
+
103
+ "experimental.chat.system.transform": async (_input, output) => {
104
+ output.system.push(GUIDANCE)
105
+ const running = await cockpit
106
+ .call("shell.list", { owner: { project: directory }, includeExited: false })
107
+ .catch(() => [] as ShellInfo[])
108
+ if (running.length > 0) {
109
+ output.system.push(
110
+ `Background shells currently running in this project:\n${running
111
+ .slice(0, 15)
112
+ .map((s) => `- ${s.id} ${s.title} (${describeStatus(s)})`)
113
+ .join("\n")}`,
114
+ )
115
+ }
116
+ },
117
+
118
+ event: async ({ event }) => {
119
+ if (event.type !== "session.deleted") return
120
+ const sessionID = event.properties.info.id
121
+ const owned = await cockpit
122
+ .call("shell.list", { owner: { session: sessionID } })
123
+ .catch(() => [] as ShellInfo[])
124
+ for (const shell of owned) {
125
+ quiet.add(shell.id)
126
+ await cockpit.call("shell.remove", { id: shell.id }).catch(() => {})
127
+ }
128
+ },
129
+
130
+ dispose: async () => {
131
+ cockpit.close()
132
+ },
133
+ }
134
+ }
135
+
136
+ const plugin: PluginModule & { id: string } = { id: "opencode-cockpit.shell", server: createShellServer() }
137
+ 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
+ }