@opencode-cockpit/shell 0.1.5 → 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.
- package/README.md +100 -10
- package/dist/agent/plugin.js +180 -0
- package/dist/agent/tools/index.js +23 -0
- package/dist/agent/tools/list.js +68 -0
- package/dist/agent/tools/read.js +59 -0
- package/dist/agent/tools/restart.js +44 -0
- package/dist/agent/tools/send.js +71 -0
- package/dist/agent/tools/shared.js +94 -0
- package/dist/agent/tools/start.js +140 -0
- package/dist/agent/tools/stop.js +42 -0
- package/dist/agent/tools/wait.js +79 -0
- package/dist/agent/tools/watch-args.js +12 -0
- package/dist/agent/tools/watch.js +74 -0
- package/dist/core/config.js +97 -0
- package/dist/{tools → core}/find.js +3 -0
- package/dist/core/kind.js +31 -0
- package/dist/server.js +2 -152
- package/dist/tui/{badge.js → components/badge.js} +1 -1
- package/dist/tui/{console.js → components/console.js} +270 -139
- package/dist/tui/{dock.js → components/dock.js} +75 -19
- package/dist/tui/{sidebar.js → components/sidebar.js} +16 -1
- package/dist/tui/dialogs.js +163 -0
- package/dist/tui/index.js +35 -94
- package/dist/tui/lib/details.js +23 -0
- package/dist/tui/lib/search.js +39 -0
- package/dist/tui/lib/update.js +58 -0
- package/dist/tui/{view.js → lib/view.js} +38 -1
- package/dist/tui/{store.js → state/store.js} +4 -2
- package/package.json +4 -5
- package/types/agent/plugin.d.ts +12 -0
- package/types/agent/tools/index.d.ts +5 -0
- package/types/agent/tools/list.d.ts +3 -0
- package/types/agent/tools/read.d.ts +3 -0
- package/types/agent/tools/restart.d.ts +3 -0
- package/types/agent/tools/send.d.ts +3 -0
- package/types/agent/tools/shared.d.ts +40 -0
- package/types/agent/tools/start.d.ts +3 -0
- package/types/agent/tools/stop.d.ts +3 -0
- package/types/agent/tools/wait.d.ts +3 -0
- package/types/agent/tools/watch-args.d.ts +10 -0
- package/types/agent/tools/watch.d.ts +3 -0
- package/types/core/config.d.ts +59 -0
- package/types/{tools → core}/find.d.ts +5 -0
- package/types/core/kind.d.ts +9 -0
- package/types/server.d.ts +2 -12
- package/types/tui/{console.d.ts → components/console.d.ts} +7 -1
- package/types/tui/{dock.d.ts → components/dock.d.ts} +3 -1
- package/types/tui/{sidebar.d.ts → components/sidebar.d.ts} +1 -1
- package/types/tui/dialogs.d.ts +10 -0
- package/types/tui/index.d.ts +3 -9
- package/types/tui/lib/details.d.ts +3 -0
- package/types/tui/lib/search.d.ts +7 -0
- package/types/tui/lib/update.d.ts +25 -0
- package/types/tui/{view.d.ts → lib/view.d.ts} +14 -1
- package/types/tui/{store.d.ts → state/store.d.ts} +9 -0
- package/dist/tools/index.js +0 -433
- package/types/tools/index.d.ts +0 -17
- /package/dist/{tools → agent/tools}/keys.js +0 -0
- /package/dist/{tools → core}/format.js +0 -0
- /package/dist/tui/{keys.js → lib/keys.js} +0 -0
- /package/types/{tools → agent/tools}/keys.d.ts +0 -0
- /package/types/{tools → core}/format.d.ts +0 -0
- /package/types/tui/{badge.d.ts → components/badge.d.ts} +0 -0
- /package/types/tui/{keys.d.ts → lib/keys.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -35,7 +35,8 @@ one loaded is used and OpenCode shows a warning.
|
|
|
35
35
|
| `shell_wait` | Block on a condition instead of sleeping. |
|
|
36
36
|
| `shell_read` | Clean log from a cursor, with `grep`; or `view: "screen"` for full-screen programs. |
|
|
37
37
|
| `shell_send` | Type text or keys (`ctrl+c`, `up`, `enter`) and get the reply. |
|
|
38
|
-
| `shell_list` | Find shells: filter by text (`query`), `status`
|
|
38
|
+
| `shell_list` | Find shells: filter by text (`query`), `status`, `session` and `kind` (server, tests, build, watcher, task — derived from the command). |
|
|
39
|
+
| `shell_watch` | Watch a never-ending process and be told **only when its health changes**: "tsc: ok → 3 errors". |
|
|
39
40
|
| `shell_stop` · `shell_restart` | Manage shells. |
|
|
40
41
|
|
|
41
42
|
Every tool that acts on a shell takes its `id` **or its name** (the description it was started
|
|
@@ -43,12 +44,42 @@ with), so you can ask about shells naturally, including ones started in other se
|
|
|
43
44
|
|
|
44
45
|
- *"How is DB Monitoring doing?"* → `shell_read name="DB Monitoring"`
|
|
45
46
|
- *"Did any shell from my other session fail?"* → `shell_list status="failed" session="others"`
|
|
47
|
+
- *"Which dev servers are up?"* → `shell_list kind="server" status="running"`
|
|
46
48
|
|
|
47
49
|
Names match ignoring case, then partially on name or command. If a name fits several shells the
|
|
48
50
|
agent gets the candidates instead of a guess.
|
|
49
51
|
|
|
50
52
|
The agent is messaged when a shell it started exits on its own.
|
|
51
53
|
|
|
54
|
+
### Watching health
|
|
55
|
+
|
|
56
|
+
`tsc --watch` and friends never exit, so re-reading their logs is the only way to know how they are
|
|
57
|
+
doing — and it costs tokens every time. A watcher reads the log instead and reports transitions:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
shell_start command="tsc --watch --noEmit" description="type checker" watch=true
|
|
61
|
+
→ watching health (tsc)
|
|
62
|
+
|
|
63
|
+
… later, on its own:
|
|
64
|
+
<shell_health id="sh_9wq2f1ab" title="type checker" status="fail">
|
|
65
|
+
tsc: ok → fail
|
|
66
|
+
src/auth.ts(42,3): error TS2339: Property 'id' does not exist on type 'User'.
|
|
67
|
+
</shell_health>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Nothing is sent while a run keeps producing the same result, and a **watched process that dies is
|
|
71
|
+
reported as a failure**, so a crashed dev server no longer goes unnoticed.
|
|
72
|
+
|
|
73
|
+
A watch rule is three regexes, not a parser: `done` (a run ended), `fail` and `ok`. Presets ship for
|
|
74
|
+
tsc, eslint, biome, prettier, mypy, ruff, vitest, jest, mocha, bun test, deno test, pytest, rspec,
|
|
75
|
+
phpunit, playwright, cypress, vite, next, nuxt, astro, angular, webpack/rspack, esbuild, tsup,
|
|
76
|
+
turbo, metro, storybook, cargo, go, dotnet, gradle, maven, docker compose and terraform. Anything
|
|
77
|
+
else takes its own patterns:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
shell_watch name="deploy" rule={ fail: "FAILED", ok: "SUCCEEDED", idleSeconds: 5 }
|
|
81
|
+
```
|
|
82
|
+
|
|
52
83
|
Things to ask:
|
|
53
84
|
|
|
54
85
|
- *"Start the dev server in a background shell and wait until it's ready."*
|
|
@@ -63,34 +94,84 @@ Things to ask:
|
|
|
63
94
|
| `ctrl+x i` · `/shell` | Open the shell console |
|
|
64
95
|
| `/shell-new` | Start a shell yourself |
|
|
65
96
|
| `/shells-clear` | Remove finished shells |
|
|
97
|
+
| `/cockpit-update` | Update the plugin when a newer release exists |
|
|
66
98
|
| `/shells-restart-daemon` | Restart `cockpitd` (asks first when shells are running) |
|
|
67
99
|
|
|
100
|
+
Watched shells also show their health (`tsc ✓`, `vitest ✗`) in the panel, sidebar and console.
|
|
101
|
+
|
|
68
102
|
Status reads the same everywhere: `RUN` (with a spinner), `FAIL`, `STOP`, `DONE`. Running shells
|
|
69
103
|
and recent failures stay visible; everything else folds into `▸ N more`.
|
|
70
104
|
|
|
71
|
-
**Console keys
|
|
72
|
-
`c` ctrl+c, `r` restart, `x` stop. Finished shell: `r` run
|
|
73
|
-
|
|
105
|
+
**Console keys**, and only the ones that apply right now. Running shell: `i` type (every key goes to
|
|
106
|
+
the program, `ctrl+]` to stop typing), `c` ctrl+c, `r` restart, `x` stop. Finished shell: `r` run
|
|
107
|
+
again, `d` remove. Always: `tab` screen or log, `/` search the log (`backspace` clears the filter),
|
|
108
|
+
`?` details, `[` `]` switch shells, `D` clear finished, `esc` close.
|
|
109
|
+
|
|
110
|
+
**Searching a long log.** In the console, `/` filters the scrollback to matching lines — keeping
|
|
111
|
+
their original line numbers and highlighting the match — so a 40k-line dev server is one query away
|
|
112
|
+
from the five lines you want. Filtering happens in the daemon, not the terminal.
|
|
113
|
+
|
|
114
|
+
**Output keeps its colours.** The panel and console paint what the program actually printed, so
|
|
115
|
+
`vitest`, `eslint` and friends read the way they do in a terminal.
|
|
74
116
|
|
|
75
117
|
## Configuration
|
|
76
118
|
|
|
77
|
-
|
|
119
|
+
Everything is optional. Settings can live in a **config file**, which is read once and applies to
|
|
120
|
+
both halves of the plugin — the agent's tools and the interface:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
~/.config/opencode-cockpit/config.json → <project>/.cockpit.json → plugin-entry options
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Later sources win key by key, so a project can override one setting without restating the rest. An
|
|
127
|
+
unreadable or invalid file is ignored rather than fatal: a typo should never stop shells from
|
|
128
|
+
working. (`XDG_CONFIG_HOME` is honoured for the global path.)
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"kinds": { "e2e": "playwright|cypress", "infra": "^(terraform|pulumi)\\b" },
|
|
133
|
+
"watch": {
|
|
134
|
+
"auto": false,
|
|
135
|
+
"presets": { "e2e": { "done": "\\d+ passed", "fail": "\\d+ failed", "ignoreCase": true } }
|
|
136
|
+
},
|
|
137
|
+
"defaults": { "logFile": true, "timeoutSeconds": 900 },
|
|
138
|
+
"notify": { "exit": true, "watch": true, "tailLines": 15 },
|
|
139
|
+
"guidance": true,
|
|
140
|
+
"listRunningShells": 15,
|
|
141
|
+
"ui": { "dockHeight": 16, "dockOpen": true, "historyMinutes": 60, "colors": true }
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Section | Meaning |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `kinds` | Extra shell categories, or overrides, as name → regex matched against the command. Drives the badges in the sidebar, panel and `shell_list`, so you can group your own stack (`e2e`, `infra`, `worker`) instead of the built-ins. |
|
|
148
|
+
| `watch.presets` | Your own watch rules, or replacements for built-ins, keyed by name. A rule is `{ done?, fail?, ok?, idleSeconds?, ignoreCase? }` — patterns are regular expressions matched against each output line. The agent can then ask for `watch: "e2e"`. |
|
|
149
|
+
| `watch.auto` | Attach a matching preset to every new shell without being asked. **Off by default**: watching is useful, but silently attaching rules to commands you didn't opt in surprises people. Turn it on once you trust your presets. |
|
|
150
|
+
| `defaults` | Applied to every shell the agent starts unless the call says otherwise: `watch` (`true` for a matching preset, or a preset name), `logFile`, `idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit`. |
|
|
151
|
+
| `notify` | What may interrupt the agent — `exit`, `watch`, and `tailLines` (output lines included in an exit message). |
|
|
152
|
+
| `guidance` | The system-prompt paragraph that teaches the agent when to use shells. `false` saves ~120 tokens per request, at the cost of a model that uses shells less well. |
|
|
153
|
+
| `listRunningShells` | How many running shells are listed in the system prompt each turn (~20 tokens each). `0` disables it; the agent can still call `shell_list`. |
|
|
154
|
+
| `ui` | Interface only: `dockHeight`, `dockOpen`, `sidebarRows`, `historyMinutes`, `colors`, `defaultView` (`"screen"` or `"log"`), `keybinds`, `updateCheck`. |
|
|
155
|
+
|
|
156
|
+
The same settings can go on the plugin entry instead, which is handy for one-offs and for machines
|
|
157
|
+
where you'd rather keep everything in `tui.json`/`opencode.json`:
|
|
78
158
|
|
|
79
159
|
```json
|
|
80
160
|
{
|
|
81
161
|
"plugin": [
|
|
82
162
|
["@opencode-cockpit/shell", {
|
|
83
|
-
"dockHeight": 16,
|
|
84
|
-
"dockOpen": true,
|
|
85
|
-
"historyMinutes": 60,
|
|
163
|
+
"ui": { "dockHeight": 16 },
|
|
86
164
|
"keybinds": { "cockpit.shells.dock": "<leader>j", "cockpit.shells.console": "<leader>k" }
|
|
87
165
|
}]
|
|
88
166
|
]
|
|
89
167
|
}
|
|
90
168
|
```
|
|
91
169
|
|
|
92
|
-
|
|
93
|
-
`
|
|
170
|
+
`ui` keys also work spelled flat at the top level (`{ "dockHeight": 16 }`), as they did before the
|
|
171
|
+
config file existed. Using `opencode-cockpit` instead of the standalone package? Put the same
|
|
172
|
+
object under `"shell"`: `["opencode-cockpit", { "shell": { "ui": { "dockHeight": 16 } } }]`.
|
|
173
|
+
Interface settings must be reachable from `tui.json`, agent settings from `opencode.json` — which
|
|
174
|
+
is exactly why the config file exists.
|
|
94
175
|
|
|
95
176
|
| Environment variable | Default | Purpose |
|
|
96
177
|
|---|---|---|
|
|
@@ -98,6 +179,15 @@ Using `opencode-cockpit` instead? Put the same options under `"shell"`:
|
|
|
98
179
|
| `COCKPIT_IDLE_TIMEOUT_MS` | `600000` | Daemon exits after this long with no clients and no running shells |
|
|
99
180
|
| `COCKPIT_LOG_LEVEL` | `info` | `debug` for verbose daemon logs |
|
|
100
181
|
|
|
182
|
+
**Limits and log files** (per shell, set by the agent, or by `defaults` above):
|
|
183
|
+
|
|
184
|
+
- `timeoutSeconds` — stop it after this long, busy or not. Good for probes: *"watch the DB for two
|
|
185
|
+
minutes"*.
|
|
186
|
+
- `idleTimeoutSeconds` — stop it after this much silence. Never use it for dev servers, which are
|
|
187
|
+
idle when healthy.
|
|
188
|
+
- `logFile` — also write the clean log to `~/.cache/opencode-cockpit/logs/<id>.log`, so history
|
|
189
|
+
survives the in-memory buffer. The path shows in the console's details view.
|
|
190
|
+
|
|
101
191
|
## Troubleshooting
|
|
102
192
|
|
|
103
193
|
| Problem | Look at |
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client";
|
|
2
|
+
import { createClient } from "../connect.js";
|
|
3
|
+
import { loadConfig } from "../core/config.js";
|
|
4
|
+
import { describeStatus, formatLines } from "../core/format.js";
|
|
5
|
+
import { createTools } from "./tools/index.js";
|
|
6
|
+
const GUIDANCE = `## Background shells (opencode-cockpit)
|
|
7
|
+
Long-running or interactive commands (dev servers, watchers, slow builds/tests, REPLs) go in shell_start, not bash with "&".
|
|
8
|
+
Block with shell_wait (pattern, port, idle, exit) instead of sleeping; follow output with shell_read(after=cursor).
|
|
9
|
+
You are messaged when a shell you started exits.
|
|
10
|
+
For processes that never exit (tsc --watch, vitest --watch, dev servers), shell_watch reports only when their health changes — use it instead of re-reading their logs.`;
|
|
11
|
+
export const SHELL_PACKAGE = "@opencode-cockpit/shell";
|
|
12
|
+
/** Shell's server half as a factory, so bundles such as `opencode-cockpit` can include it. */
|
|
13
|
+
export function createShellServer({
|
|
14
|
+
source = SHELL_PACKAGE
|
|
15
|
+
} = {}) {
|
|
16
|
+
return async (input, options) => {
|
|
17
|
+
const claim = claimFeature(input, "shell", source);
|
|
18
|
+
if (!claim.active) {
|
|
19
|
+
// Logging through the server during plugin initialisation could wait on ourselves; defer it.
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
void input.client.app.log({
|
|
22
|
+
body: {
|
|
23
|
+
service: "opencode-cockpit",
|
|
24
|
+
level: "warn",
|
|
25
|
+
message: duplicateFeatureMessage("Shell", claim.owner, source)
|
|
26
|
+
}
|
|
27
|
+
}).catch(() => {});
|
|
28
|
+
}, 0);
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
const hooks = await shellHooks(input, options);
|
|
32
|
+
const dispose = hooks.dispose;
|
|
33
|
+
return {
|
|
34
|
+
...hooks,
|
|
35
|
+
dispose: async () => {
|
|
36
|
+
claim.release();
|
|
37
|
+
await dispose?.();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function shellHooks({
|
|
43
|
+
client: opencode,
|
|
44
|
+
directory
|
|
45
|
+
}, options) {
|
|
46
|
+
const config = loadConfig(directory, options);
|
|
47
|
+
const cockpit = createClient("opencode-cockpit/server");
|
|
48
|
+
const instance = crypto.randomUUID();
|
|
49
|
+
const quiet = new Set();
|
|
50
|
+
const userShell = process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash";
|
|
51
|
+
const env = () => {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !k.startsWith("OPENCODE_")) out[k] = v;
|
|
54
|
+
return out;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Session titles rarely change; cache them so listing shells stays one round trip.
|
|
58
|
+
const titles = new Map();
|
|
59
|
+
const sessionTitle = async sessionID => {
|
|
60
|
+
const hit = titles.get(sessionID);
|
|
61
|
+
if (hit && Date.now() - hit.at < 60_000) return hit.title;
|
|
62
|
+
const result = await opencode.session.get({
|
|
63
|
+
path: {
|
|
64
|
+
id: sessionID
|
|
65
|
+
}
|
|
66
|
+
}).catch(() => undefined);
|
|
67
|
+
const title = result?.data?.title;
|
|
68
|
+
titles.set(sessionID, {
|
|
69
|
+
title,
|
|
70
|
+
at: Date.now()
|
|
71
|
+
});
|
|
72
|
+
return title;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Wake the agent when a shell it owns ends on its own.
|
|
76
|
+
cockpit.on("shell.exited", info => {
|
|
77
|
+
if (info.owner.instance !== instance || !info.owner.session) return;
|
|
78
|
+
if (quiet.delete(info.id) || config.notify?.exit === false) return;
|
|
79
|
+
void notifyExit(info).catch(() => {});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// A watcher's health changed. This is the whole point of watching: one message per change, never
|
|
83
|
+
// per line, so a thousand identical recompiles cost nothing.
|
|
84
|
+
cockpit.on("shell.watch", event => {
|
|
85
|
+
const info = event.info;
|
|
86
|
+
if (config.notify?.watch === false) return;
|
|
87
|
+
if (info.owner.instance !== instance || !info.owner.session || event.current === "pending") return;
|
|
88
|
+
const text = [`<shell_health id="${info.id}" title="${info.title}" status="${event.current}">`, `${info.watch?.preset ?? "watch"}: ${event.previous} → ${event.current}`, event.summary ?? "", "</shell_health>", event.current === "ok" ? "Previously reported problems in this shell are resolved." : `Investigate with shell_read id=${info.id} if this affects your current task.`].filter(Boolean).join("\n");
|
|
89
|
+
void opencode.session.promptAsync({
|
|
90
|
+
path: {
|
|
91
|
+
id: info.owner.session
|
|
92
|
+
},
|
|
93
|
+
body: {
|
|
94
|
+
parts: [{
|
|
95
|
+
type: "text",
|
|
96
|
+
text,
|
|
97
|
+
synthetic: true
|
|
98
|
+
}]
|
|
99
|
+
}
|
|
100
|
+
}).catch(() => {});
|
|
101
|
+
});
|
|
102
|
+
async function notifyExit(info) {
|
|
103
|
+
const session = info.owner.session;
|
|
104
|
+
const page = await cockpit.call("shell.read", {
|
|
105
|
+
id: info.id,
|
|
106
|
+
tail: config.notify?.tailLines ?? 15
|
|
107
|
+
});
|
|
108
|
+
const failed = info.status === "failed" || info.status === "exited" && info.exitCode !== 0 || info.status === "killed";
|
|
109
|
+
const text = [`<shell_exited id="${info.id}" title="${info.title}">`, describeStatus(info), page.lines.length > 0 ? `last output:\n${formatLines(page.lines)}` : "(no output)", "</shell_exited>", failed ? `Investigate with shell_read id=${info.id} grep="error|fail" if the failure matters to the task.` : `Full output: shell_read id=${info.id}.`].join("\n");
|
|
110
|
+
await opencode.session.promptAsync({
|
|
111
|
+
path: {
|
|
112
|
+
id: session
|
|
113
|
+
},
|
|
114
|
+
body: {
|
|
115
|
+
parts: [{
|
|
116
|
+
type: "text",
|
|
117
|
+
text,
|
|
118
|
+
synthetic: true
|
|
119
|
+
}]
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
tool: createTools({
|
|
125
|
+
client: cockpit,
|
|
126
|
+
instance,
|
|
127
|
+
quiet,
|
|
128
|
+
env,
|
|
129
|
+
config,
|
|
130
|
+
sessionTitle,
|
|
131
|
+
shellCommand: command => ({
|
|
132
|
+
command: userShell,
|
|
133
|
+
args: ["-c", command]
|
|
134
|
+
})
|
|
135
|
+
}),
|
|
136
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
137
|
+
if (config.guidance !== false) output.system.push(GUIDANCE);
|
|
138
|
+
const listLimit = config.listRunningShells ?? 15;
|
|
139
|
+
if (listLimit <= 0) return;
|
|
140
|
+
const running = await cockpit.call("shell.list", {
|
|
141
|
+
owner: {
|
|
142
|
+
project: directory
|
|
143
|
+
},
|
|
144
|
+
includeExited: false
|
|
145
|
+
}).catch(() => []);
|
|
146
|
+
if (running.length > 0) {
|
|
147
|
+
output.system.push(`Background shells currently running in this project:\n${running.slice(0, listLimit).map(s => {
|
|
148
|
+
const from = !s.owner.session ? ", started by the user" : s.owner.session === input.sessionID ? "" : ", another session";
|
|
149
|
+
const health = s.watch ? `, ${s.watch.preset ?? "watch"}: ${s.watch.status}` : "";
|
|
150
|
+
return `- ${s.id} "${s.title}" (${describeStatus(s)}${from}${health})`;
|
|
151
|
+
}).join("\n")}`);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
event: async ({
|
|
155
|
+
event
|
|
156
|
+
}) => {
|
|
157
|
+
if (event.type !== "session.deleted") return;
|
|
158
|
+
const sessionID = event.properties.info.id;
|
|
159
|
+
const owned = await cockpit.call("shell.list", {
|
|
160
|
+
owner: {
|
|
161
|
+
session: sessionID
|
|
162
|
+
}
|
|
163
|
+
}).catch(() => []);
|
|
164
|
+
for (const shell of owned) {
|
|
165
|
+
quiet.add(shell.id);
|
|
166
|
+
await cockpit.call("shell.remove", {
|
|
167
|
+
id: shell.id
|
|
168
|
+
}).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
dispose: async () => {
|
|
172
|
+
cockpit.close();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const plugin = {
|
|
177
|
+
id: "opencode-cockpit.shell",
|
|
178
|
+
server: createShellServer()
|
|
179
|
+
};
|
|
180
|
+
export default plugin;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { shellList } from "./list.js";
|
|
2
|
+
import { shellRead } from "./read.js";
|
|
3
|
+
import { shellRestart } from "./restart.js";
|
|
4
|
+
import { shellSend } from "./send.js";
|
|
5
|
+
import { createToolKit } from "./shared.js";
|
|
6
|
+
import { shellStart } from "./start.js";
|
|
7
|
+
import { shellStop } from "./stop.js";
|
|
8
|
+
import { shellWait } from "./wait.js";
|
|
9
|
+
import { shellWatch } from "./watch.js";
|
|
10
|
+
/** The shell tools an agent sees. One file per tool; `shared.ts` holds what they have in common. */
|
|
11
|
+
export function createTools(deps) {
|
|
12
|
+
const kit = createToolKit(deps);
|
|
13
|
+
return {
|
|
14
|
+
shell_start: shellStart(kit),
|
|
15
|
+
shell_read: shellRead(kit),
|
|
16
|
+
shell_send: shellSend(kit),
|
|
17
|
+
shell_wait: shellWait(kit),
|
|
18
|
+
shell_watch: shellWatch(kit),
|
|
19
|
+
shell_list: shellList(kit),
|
|
20
|
+
shell_stop: shellStop(kit),
|
|
21
|
+
shell_restart: shellRestart(kit)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { commandOf, filterShells } from "../../core/find.js";
|
|
3
|
+
import { describeStatus } from "../../core/format.js";
|
|
4
|
+
import { kindOfShell } from "../../core/kind.js";
|
|
5
|
+
const z = tool.schema;
|
|
6
|
+
/**
|
|
7
|
+
* Every per-shell tool takes an id or a name. Declared per tool file because exported zod schemas
|
|
8
|
+
* cannot be named portably in generated declarations.
|
|
9
|
+
*/
|
|
10
|
+
const _TARGET = {
|
|
11
|
+
id: z.string().optional().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34"),
|
|
12
|
+
name: z.string().optional().describe('Instead of id: the shell\'s name (the description it was started with), e.g. "DB Monitoring". Partial names and command text also match.')
|
|
13
|
+
};
|
|
14
|
+
export function shellList(kit) {
|
|
15
|
+
const {
|
|
16
|
+
client,
|
|
17
|
+
config,
|
|
18
|
+
sessionLabel
|
|
19
|
+
} = kit;
|
|
20
|
+
// Kinds from config are filterable too, or classifying a command as "e2e" would be a dead end.
|
|
21
|
+
const kinds = ["server", "tests", "build", "watcher", "task", ...Object.keys(config.kinds ?? {})];
|
|
22
|
+
const kindValues = ["any", ...kinds];
|
|
23
|
+
return tool({
|
|
24
|
+
description: `List background shells in this project: name, status, which session started it, and the last output line.
|
|
25
|
+
|
|
26
|
+
Filter to find the one you need instead of reading them all:
|
|
27
|
+
- query: text in the name or command, e.g. "db" or "vitest"
|
|
28
|
+
- status: running, failed, finished
|
|
29
|
+
- session: this (started by you in this session), others (other sessions or the user)
|
|
30
|
+
- kind: ${kinds.join(", ")} — derived from the command, so "which servers are up?" is one call`,
|
|
31
|
+
args: {
|
|
32
|
+
query: z.string().optional().describe("Case-insensitive text in the shell name or command"),
|
|
33
|
+
status: z.enum(["running", "failed", "finished", "any"]).default("any"),
|
|
34
|
+
session: z.enum(["this", "others", "any"]).default("any"),
|
|
35
|
+
kind: z.enum(kindValues).default("any").describe("What the shell is, derived from its command"),
|
|
36
|
+
all: z.boolean().default(false).describe("Include shells from other projects")
|
|
37
|
+
},
|
|
38
|
+
async execute(args, ctx) {
|
|
39
|
+
const everything = await client.call("shell.list", args.all === true ? {} : {
|
|
40
|
+
owner: {
|
|
41
|
+
project: ctx.directory
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const shells = filterShells(everything, {
|
|
45
|
+
kind: args.kind ?? "any",
|
|
46
|
+
kinds: config.kinds,
|
|
47
|
+
query: args.query ?? undefined,
|
|
48
|
+
status: args.status ?? "any",
|
|
49
|
+
session: args.session ?? "any",
|
|
50
|
+
currentSession: ctx.sessionID
|
|
51
|
+
});
|
|
52
|
+
if (everything.length === 0) return "No background shells.";
|
|
53
|
+
if (shells.length === 0) return `No shells match those filters (${everything.length} shell${everything.length === 1 ? "" : "s"} in total).`;
|
|
54
|
+
const rows = await Promise.all(shells.map(async s => {
|
|
55
|
+
const last = await client.call("shell.read", {
|
|
56
|
+
id: s.id,
|
|
57
|
+
tail: 1
|
|
58
|
+
}).catch(() => undefined);
|
|
59
|
+
const tail = last?.lines[0]?.text ?? "";
|
|
60
|
+
const failure = s.summary && s.status !== "running" ? `\n summary: ${s.summary.slice(0, 200)}` : "";
|
|
61
|
+
const watch = s.watch ? `\n watch: ${s.watch.preset ?? "custom"} · ${s.watch.status}${s.watch.summary ? ` · ${s.watch.summary.slice(0, 120)}` : ""}` : "";
|
|
62
|
+
return [`${s.id} ${s.status.padEnd(7)} "${s.title}"${s.run > 1 ? ` (run ${s.run})` : ""} · ${kindOfShell(s, config.kinds)} · ${await sessionLabel(s, ctx)}`, ` $ ${commandOf(s).slice(0, 200)}${failure}`, ` ${describeStatus(s)}${watch}${tail ? `\n last: ${tail.slice(0, 200)}` : ""}`].join("\n");
|
|
63
|
+
}));
|
|
64
|
+
const hidden = everything.length - shells.length;
|
|
65
|
+
return rows.join("\n") + (hidden > 0 ? `\n(${hidden} more shell${hidden === 1 ? "" : "s"} hidden by filters)` : "");
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { describeStatus, formatRead, header } from "../../core/format.js";
|
|
3
|
+
const READ = `Read a background shell's output.
|
|
4
|
+
|
|
5
|
+
- Default: the last lines of the log (colours removed, progress-bar redraws collapsed).
|
|
6
|
+
- after=<cursor>: only lines newer than a cursor returned by a previous call. Use this to follow output.
|
|
7
|
+
- grep=<regex>: only matching lines (e.g. "error|warn").
|
|
8
|
+
- view="screen": what the terminal shows right now. Use for full-screen programs (htop, vitest UI, prompts that redraw).`;
|
|
9
|
+
const z = tool.schema;
|
|
10
|
+
/**
|
|
11
|
+
* Every per-shell tool takes an id or a name. Declared per tool file because exported zod schemas
|
|
12
|
+
* cannot be named portably in generated declarations.
|
|
13
|
+
*/
|
|
14
|
+
const TARGET = {
|
|
15
|
+
id: z.string().optional().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34"),
|
|
16
|
+
name: z.string().optional().describe('Instead of id: the shell\'s name (the description it was started with), e.g. "DB Monitoring". Partial names and command text also match.')
|
|
17
|
+
};
|
|
18
|
+
export function shellRead(kit) {
|
|
19
|
+
const {
|
|
20
|
+
client,
|
|
21
|
+
resolve
|
|
22
|
+
} = kit;
|
|
23
|
+
return tool({
|
|
24
|
+
description: READ,
|
|
25
|
+
args: {
|
|
26
|
+
...TARGET,
|
|
27
|
+
view: z.enum(["log", "screen"]).default("log"),
|
|
28
|
+
after: z.number().int().min(0).optional().describe("Cursor from a previous result; returns only newer lines"),
|
|
29
|
+
tail: z.number().int().positive().max(2000).default(60).describe("Lines from the end when no cursor is given"),
|
|
30
|
+
grep: z.string().optional().describe("Regex filter"),
|
|
31
|
+
ignoreCase: z.boolean().default(false),
|
|
32
|
+
limit: z.number().int().positive().max(2000).default(300)
|
|
33
|
+
},
|
|
34
|
+
async execute(args, ctx) {
|
|
35
|
+
const {
|
|
36
|
+
id,
|
|
37
|
+
note
|
|
38
|
+
} = await resolve(args, ctx);
|
|
39
|
+
const info = await client.call("shell.get", {
|
|
40
|
+
id
|
|
41
|
+
});
|
|
42
|
+
if (args.view === "screen") {
|
|
43
|
+
const screen = await client.call("shell.screen", {
|
|
44
|
+
id
|
|
45
|
+
});
|
|
46
|
+
return [`${note}${header(info)}`, `status: ${describeStatus(info)}`, `screen ${screen.cols}x${screen.rows}:`, screen.text || "(blank)", "</shell>"].join("\n");
|
|
47
|
+
}
|
|
48
|
+
const page = await client.call("shell.read", {
|
|
49
|
+
id,
|
|
50
|
+
after: args.after ?? undefined,
|
|
51
|
+
tail: args.tail ?? 60,
|
|
52
|
+
grep: args.grep ?? undefined,
|
|
53
|
+
ignoreCase: args.ignoreCase ?? false,
|
|
54
|
+
limit: args.limit ?? 300
|
|
55
|
+
});
|
|
56
|
+
return note + formatRead(info, page, args.after != null ? "(no new output)" : "(no output yet)");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { abortable } from "./shared.js";
|
|
3
|
+
const z = tool.schema;
|
|
4
|
+
/**
|
|
5
|
+
* Every per-shell tool takes an id or a name. Declared per tool file because exported zod schemas
|
|
6
|
+
* cannot be named portably in generated declarations.
|
|
7
|
+
*/
|
|
8
|
+
const TARGET = {
|
|
9
|
+
id: z.string().optional().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34"),
|
|
10
|
+
name: z.string().optional().describe('Instead of id: the shell\'s name (the description it was started with), e.g. "DB Monitoring". Partial names and command text also match.')
|
|
11
|
+
};
|
|
12
|
+
export function shellRestart(kit) {
|
|
13
|
+
const {
|
|
14
|
+
client,
|
|
15
|
+
deps,
|
|
16
|
+
peek,
|
|
17
|
+
resolve
|
|
18
|
+
} = kit;
|
|
19
|
+
return tool({
|
|
20
|
+
description: "Restart a background shell with the same command. Keeps the id; output continues after a restart marker.",
|
|
21
|
+
args: {
|
|
22
|
+
...TARGET
|
|
23
|
+
},
|
|
24
|
+
async execute(args, ctx) {
|
|
25
|
+
const {
|
|
26
|
+
id,
|
|
27
|
+
note
|
|
28
|
+
} = await resolve(args, ctx);
|
|
29
|
+
const info = await client.call("shell.restart", {
|
|
30
|
+
id
|
|
31
|
+
});
|
|
32
|
+
deps.quiet.delete(id);
|
|
33
|
+
await abortable(ctx, client.call("shell.wait", {
|
|
34
|
+
id: info.id,
|
|
35
|
+
until: {
|
|
36
|
+
idleMs: 700,
|
|
37
|
+
exit: true
|
|
38
|
+
},
|
|
39
|
+
timeoutMs: 2500
|
|
40
|
+
}));
|
|
41
|
+
return `${note}Restarted ${info.id} (run ${info.run})\n${await peek(info)}`;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { formatRead } from "../../core/format.js";
|
|
3
|
+
import { encodeKey, KEY_NAMES } from "./keys.js";
|
|
4
|
+
import { abortable } from "./shared.js";
|
|
5
|
+
const SEND = `Send input to a running background shell, then return the output it produced.
|
|
6
|
+
|
|
7
|
+
- text: literal characters. Set submit=true to press enter afterwards.
|
|
8
|
+
- keys: named keys pressed in order, e.g. ["ctrl+c"], ["down", "enter"]. Supported: ${KEY_NAMES.join(", ")}.`;
|
|
9
|
+
const z = tool.schema;
|
|
10
|
+
/**
|
|
11
|
+
* Every per-shell tool takes an id or a name. Declared per tool file because exported zod schemas
|
|
12
|
+
* cannot be named portably in generated declarations.
|
|
13
|
+
*/
|
|
14
|
+
const TARGET = {
|
|
15
|
+
id: z.string().optional().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34"),
|
|
16
|
+
name: z.string().optional().describe('Instead of id: the shell\'s name (the description it was started with), e.g. "DB Monitoring". Partial names and command text also match.')
|
|
17
|
+
};
|
|
18
|
+
export function shellSend(kit) {
|
|
19
|
+
const {
|
|
20
|
+
client,
|
|
21
|
+
resolve
|
|
22
|
+
} = kit;
|
|
23
|
+
return tool({
|
|
24
|
+
description: SEND,
|
|
25
|
+
args: {
|
|
26
|
+
...TARGET,
|
|
27
|
+
text: z.string().optional(),
|
|
28
|
+
keys: z.array(z.string()).optional(),
|
|
29
|
+
submit: z.boolean().default(false).describe("Press enter after text"),
|
|
30
|
+
waitSeconds: z.number().min(0).max(30).default(1).describe("Max time to collect the response")
|
|
31
|
+
},
|
|
32
|
+
async execute(args, ctx) {
|
|
33
|
+
if (!args.text && !args.keys?.length) throw new Error("provide text and/or keys");
|
|
34
|
+
let data = args.text ?? "";
|
|
35
|
+
for (const key of args.keys ?? []) data += encodeKey(key);
|
|
36
|
+
if (args.submit === true) data += "\r";
|
|
37
|
+
const {
|
|
38
|
+
id,
|
|
39
|
+
note
|
|
40
|
+
} = await resolve(args, ctx);
|
|
41
|
+
const before = await client.call("shell.get", {
|
|
42
|
+
id
|
|
43
|
+
});
|
|
44
|
+
await client.call("shell.write", {
|
|
45
|
+
id,
|
|
46
|
+
data
|
|
47
|
+
});
|
|
48
|
+
const waitSeconds = args.waitSeconds ?? 1;
|
|
49
|
+
if (waitSeconds > 0) {
|
|
50
|
+
await abortable(ctx, client.call("shell.wait", {
|
|
51
|
+
id,
|
|
52
|
+
until: {
|
|
53
|
+
idleMs: 400,
|
|
54
|
+
exit: true
|
|
55
|
+
},
|
|
56
|
+
timeoutMs: Math.round(waitSeconds * 1000),
|
|
57
|
+
after: before.lines.last
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
const info = await client.call("shell.get", {
|
|
61
|
+
id
|
|
62
|
+
});
|
|
63
|
+
const page = await client.call("shell.read", {
|
|
64
|
+
id,
|
|
65
|
+
after: before.lines.last,
|
|
66
|
+
limit: 300
|
|
67
|
+
});
|
|
68
|
+
return note + formatRead(info, page, "(no new output lines; if this is a full-screen program use shell_read view=screen)");
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|