@opencode-cockpit/shell 0.2.1 → 0.2.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/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @opencode-cockpit/shell
2
2
 
3
+ **Bay 01 of [Cockpit](https://github.com/Codestz/opencode-cockpit).**
4
+ [Docs →](https://codestz.github.io/opencode-cockpit/shell/overview/) ·
5
+ [Agent tools](https://codestz.github.io/opencode-cockpit/shell/tools/) ·
6
+ [Watching health](https://codestz.github.io/opencode-cockpit/shell/watching/) ·
7
+ [Panel and keys](https://codestz.github.io/opencode-cockpit/shell/interface/) ·
8
+ [Configuration](https://codestz.github.io/opencode-cockpit/configuration/)
9
+
3
10
  [![npm](https://img.shields.io/npm/v/@opencode-cockpit/shell)](https://www.npmjs.com/package/@opencode-cockpit/shell)
4
11
 
5
12
  Background terminals for [OpenCode](https://opencode.ai) that the agent starts, waits on and
@@ -15,6 +22,10 @@ drives, and that you watch and control without leaving the chat. Part of
15
22
  - **Shells outlive OpenCode.** They run in a small daemon, `cockpitd`, shared by every OpenCode
16
23
  window and upgraded automatically when the plugin updates.
17
24
 
25
+ ![A dev server running in the shells panel](https://raw.githubusercontent.com/Codestz/opencode-cockpit/main/media/dock.gif)
26
+
27
+ *Recorded from a real session, re-recorded on every release.*
28
+
18
29
  ## Install
19
30
 
20
31
  ```sh
@@ -96,9 +107,12 @@ Things to ask:
96
107
  | Key / command | Does |
97
108
  |---|---|
98
109
  | `ctrl+x o` · `/shells` | Toggle the shells panel under the chat |
110
+ | `s` (in the console) | This conversation only, or the whole project |
99
111
  | `ctrl+x i` · `/shell` | Open the shell console |
100
112
  | `/shell-new` | Start a shell yourself |
101
113
  | `/shells-clear` | Remove finished shells |
114
+ | `/shells-stop` | Stop the shells in view — this conversation, or the project |
115
+ | `/shells-stop-all` | Stop every shell in the project, asking first about the ones you cannot see |
102
116
  | `/cockpit-update` | Update the plugin when a newer release exists |
103
117
  | `/shells-restart-daemon` | Restart `cockpitd` (asks first when shells are running) |
104
118
 
@@ -112,6 +126,10 @@ the program, `ctrl+]` to stop typing), `c` ctrl+c, `r` restart, `x` stop. Finish
112
126
  again, `d` remove. Always: `tab` screen or log, `/` search the log (`backspace` clears the filter),
113
127
  `?` details, `[` `]` switch shells, `D` clear finished, `esc` close.
114
128
 
129
+ **One conversation at a time.** The panel lists the shells of the session you are in, because a
130
+ project's shells mixed together is how it stops making sense. `s` in the console widens it to the
131
+ whole project and back, and `shell_list` takes `session: "this" | "others" | "any"`.
132
+
115
133
  **Searching a long log.** In the console, `/` filters the scrollback to matching lines — keeping
116
134
  their original line numbers and highlighting the match — so a 40k-line dev server is one query away
117
135
  from the five lines you want. Filtering happens in the daemon, not the terminal.
@@ -152,11 +170,12 @@ working. (`XDG_CONFIG_HOME` is honoured for the global path.)
152
170
  | `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. |
153
171
  | `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"`. |
154
172
  | `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. |
173
+ | `lifecycle` | When shells end by themselves. `onExit` (`stopMine` by default) stops this window's shells when it closes; `keep` leaves them for the next window. `orphanAfterMinutes` (60) stops a shell that no window has claimed for that long — the answer to shells running for a week. |
155
174
  | `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`. |
156
175
  | `notify` | What may interrupt the agent — `exit`, `watch`, and `tailLines` (output lines included in an exit message). |
157
176
  | `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. |
158
177
  | `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`. |
159
- | `ui` | Interface only: `dockHeight`, `dockOpen`, `sidebarRows`, `historyMinutes`, `colors`, `defaultView` (`"screen"` or `"log"`), `keybinds`, `updateCheck`. |
178
+ | `ui` | Interface only: `dockHeight`, `dockOpen` (set it and the panel always starts that way; leave it out and it starts as you last left it), `sidebarRows`, `historyMinutes`, `colors`, `defaultView` (`"screen"` or `"log"`), `keybinds`, `updateCheck`. |
160
179
 
161
180
  The same settings can go on the plugin entry instead, which is handy for one-offs and for machines
162
181
  where you'd rather keep everything in `tui.json`/`opencode.json`:
@@ -44,8 +44,9 @@ async function shellHooks({
44
44
  directory
45
45
  }, options) {
46
46
  const config = loadConfig(directory, options);
47
- const cockpit = createClient("opencode-cockpit/server");
47
+ // Identifies this OpenCode window to the daemon, so shells can end with it.
48
48
  const instance = crypto.randomUUID();
49
+ const cockpit = createClient("opencode-cockpit/server", instance);
49
50
  const quiet = new Set();
50
51
  const userShell = process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash";
51
52
  const env = () => {
@@ -66,6 +66,9 @@ export function shellStart(kit) {
66
66
  const logFile = args.logFile ?? defaults.logFile ?? false;
67
67
  const notifyOnExit = args.notifyOnExit ?? defaults.notifyOnExit ?? true;
68
68
  const watch = args.watch ?? defaults.watch ?? (config.watch?.auto ? "auto" : undefined);
69
+ // Nothing should outlive the window that asked for it by more than the user allows.
70
+ const lifecycle = config.lifecycle ?? {};
71
+ const orphanMinutes = lifecycle.orphanAfterMinutes ?? 60;
69
72
  const shell = deps.shellCommand(args.command);
70
73
  const info = await client.call("shell.start", {
71
74
  command: shell.command,
@@ -84,6 +87,8 @@ export function shellStart(kit) {
84
87
  timeoutMs: seconds(args.timeoutSeconds ?? defaults.timeoutSeconds),
85
88
  idleTimeoutMs: seconds(args.idleTimeoutSeconds ?? defaults.idleTimeoutSeconds),
86
89
  logFile: logFile === true,
90
+ stopOnExit: (lifecycle.onExit ?? "stopMine") === "stopMine",
91
+ orphanAfterMs: orphanMinutes > 0 ? orphanMinutes * 60_000 : undefined,
87
92
  reuse: true
88
93
  });
89
94
  if (notifyOnExit === false) deps.quiet.add(info.id);else deps.quiet.delete(info.id);
package/dist/connect.js CHANGED
@@ -14,13 +14,14 @@ export function daemonEntry() {
14
14
  * BUN_BE_BUN=1 so it runs on OpenCode's embedded Bun (ADR 0001). The expected build lets the
15
15
  * client replace a daemon left running from older plugin code.
16
16
  */
17
- export function createClient(name) {
17
+ export function createClient(name, instance) {
18
18
  const entry = daemonEntry();
19
19
  return new CockpitClient({
20
20
  client: {
21
21
  name,
22
22
  version: pkg.version,
23
- pid: process.pid
23
+ pid: process.pid,
24
+ instance
24
25
  },
25
26
  spawn: {
26
27
  entry,
@@ -53,6 +53,10 @@ export function mergeConfig(base, over) {
53
53
  ...base.defaults,
54
54
  ...over.defaults
55
55
  },
56
+ lifecycle: {
57
+ ...base.lifecycle,
58
+ ...over.lifecycle
59
+ },
56
60
  notify: {
57
61
  ...base.notify,
58
62
  ...over.notify
@@ -76,7 +80,7 @@ function asConfig(input) {
76
80
  if (!input || typeof input !== "object") return {};
77
81
  const raw = input;
78
82
  const config = {};
79
- for (const key of ["watch", "kinds", "defaults", "notify", "ui"]) {
83
+ for (const key of ["watch", "kinds", "defaults", "lifecycle", "notify", "ui"]) {
80
84
  const value = raw[key];
81
85
  if (value && typeof value === "object") Object.assign(config, {
82
86
  [key]: value
@@ -271,6 +271,10 @@ export function Console(props) {
271
271
  name: "cockpit.console.top",
272
272
  title: "Scroll to top",
273
273
  run: () => scroll?.scrollTo(0)
274
+ }, {
275
+ name: "cockpit.console.scope",
276
+ title: "This session / whole project",
277
+ run: () => props.store.toggleScope()
274
278
  }, {
275
279
  name: "cockpit.console.close",
276
280
  title: "Close console",
@@ -328,6 +332,10 @@ export function Console(props) {
328
332
  key: "n",
329
333
  cmd: "cockpit.console.new",
330
334
  desc: "New"
335
+ }, {
336
+ key: "s",
337
+ cmd: "cockpit.console.scope",
338
+ desc: "Scope"
331
339
  }, {
332
340
  key: "j,down",
333
341
  cmd: "cockpit.console.down",
@@ -380,6 +388,7 @@ export function Console(props) {
380
388
  if (view() !== "details") keys.push(label("/ search log", "/"));
381
389
  keys.push(label("? details", "?"));
382
390
  if (props.store.shells().length > 1) keys.push(label("[ ] switch", "[ ]"));
391
+ keys.push(props.store.scope() === "session" ? label("s whole project", "s project") : label("s this session", "s session"));
383
392
  if (finished() > 0) keys.push(label("D clear done", "D"));
384
393
  keys.push(label("n new", "n"), "esc");
385
394
  return keys.join(" · ");
@@ -1,7 +1,7 @@
1
1
  import { effect as _$effect } from "@opentui/solid";
2
+ import { createComponent as _$createComponent } from "@opentui/solid";
2
3
  import { insert as _$insert } from "@opentui/solid";
3
4
  import { memo as _$memo } from "@opentui/solid";
4
- import { createComponent as _$createComponent } from "@opentui/solid";
5
5
  import { createTextNode as _$createTextNode } from "@opentui/solid";
6
6
  import { insertNode as _$insertNode } from "@opentui/solid";
7
7
  import { setProp as _$setProp } from "@opentui/solid";
@@ -33,15 +33,17 @@ export function Dock(props) {
33
33
  _el$2 = _$createElement("box"),
34
34
  _el$3 = _$createElement("text"),
35
35
  _el$4 = _$createElement("b"),
36
- _el$7 = _$createElement("box"),
37
- _el$8 = _$createElement("text");
36
+ _el$6 = _$createElement("text"),
37
+ _el$8 = _$createElement("box"),
38
+ _el$9 = _$createElement("text");
38
39
  _$insertNode(_el$, _el$2);
39
40
  _$setProp(_el$, "flexShrink", 0);
40
41
  _$setProp(_el$, "flexDirection", "column");
41
42
  _$setProp(_el$, "border", ["top"]);
42
43
  _$insertNode(_el$2, _el$3);
43
- _$insertNode(_el$2, _el$7);
44
+ _$insertNode(_el$2, _el$6);
44
45
  _$insertNode(_el$2, _el$8);
46
+ _$insertNode(_el$2, _el$9);
45
47
  _$setProp(_el$2, "flexDirection", "row");
46
48
  _$setProp(_el$2, "gap", 1);
47
49
  _$setProp(_el$2, "paddingLeft", 1);
@@ -52,17 +54,19 @@ export function Dock(props) {
52
54
  _$insertNode(_el$3, _el$4);
53
55
  _$setProp(_el$3, "flexShrink", 0);
54
56
  _$insertNode(_el$4, _$createTextNode(`Shells`));
57
+ _$setProp(_el$6, "flexShrink", 0);
58
+ _$insert(_el$6, () => props.store.scope() === "session" ? "this session" : "project");
55
59
  _$insert(_el$2, _$createComponent(Show, {
56
60
  get when() {
57
61
  return props.store.shells().length > 0;
58
62
  },
59
63
  get fallback() {
60
64
  return (() => {
61
- var _el$9 = _$createElement("text");
62
- _$insertNode(_el$9, _$createTextNode(`none yet · the agent starts them, or /shell-new`));
63
- _$setProp(_el$9, "wrapMode", "none");
64
- _$effect(_$p => _$setProp(_el$9, "fg", theme().textMuted, _$p));
65
- return _el$9;
65
+ var _el$0 = _$createElement("text");
66
+ _$insertNode(_el$0, _$createTextNode(`none yet · the agent starts them, or /shell-new`));
67
+ _$setProp(_el$0, "wrapMode", "none");
68
+ _$effect(_$p => _$setProp(_el$0, "fg", theme().textMuted, _$p));
69
+ return _el$0;
66
70
  })();
67
71
  },
68
72
  get children() {
@@ -73,15 +77,15 @@ export function Dock(props) {
73
77
  children: shell => {
74
78
  const active = () => props.store.selected()?.id === shell.id;
75
79
  return (() => {
76
- var _el$1 = _$createElement("box"),
77
- _el$10 = _$createElement("text"),
78
- _el$11 = _$createTextNode(` `);
79
- _$insertNode(_el$1, _el$10);
80
- _$setProp(_el$1, "flexDirection", "row");
81
- _$setProp(_el$1, "flexShrink", 0);
82
- _$setProp(_el$1, "paddingRight", 1);
83
- _$setProp(_el$1, "onMouseUp", () => active() ? props.onOpenConsole(shell.id) : props.store.select(shell.id));
84
- _$insert(_el$1, _$createComponent(Badge, {
80
+ var _el$10 = _$createElement("box"),
81
+ _el$11 = _$createElement("text"),
82
+ _el$12 = _$createTextNode(` `);
83
+ _$insertNode(_el$10, _el$11);
84
+ _$setProp(_el$10, "flexDirection", "row");
85
+ _$setProp(_el$10, "flexShrink", 0);
86
+ _$setProp(_el$10, "paddingRight", 1);
87
+ _$setProp(_el$10, "onMouseUp", () => active() ? props.onOpenConsole(shell.id) : props.store.select(shell.id));
88
+ _$insert(_el$10, _$createComponent(Badge, {
85
89
  get api() {
86
90
  return props.api;
87
91
  },
@@ -89,28 +93,28 @@ export function Dock(props) {
89
93
  get frame() {
90
94
  return props.store.frame();
91
95
  }
92
- }), _el$10);
93
- _$insertNode(_el$10, _el$11);
94
- _$setProp(_el$10, "wrapMode", "none");
95
- _$insert(_el$10, (() => {
96
+ }), _el$11);
97
+ _$insertNode(_el$11, _el$12);
98
+ _$setProp(_el$11, "wrapMode", "none");
99
+ _$insert(_el$11, (() => {
96
100
  var _c$2 = _$memo(() => !!active());
97
101
  return () => _c$2() ? (() => {
98
- var _el$12 = _$createElement("b");
99
- _$insert(_el$12, () => truncate(shell.title, 22));
100
- return _el$12;
102
+ var _el$13 = _$createElement("b");
103
+ _$insert(_el$13, () => truncate(shell.title, 22));
104
+ return _el$13;
101
105
  })() : truncate(shell.title, 22);
102
106
  })(), null);
103
107
  _$effect(_p$ => {
104
- var _v$6 = active() ? theme().backgroundElement : theme().backgroundPanel,
105
- _v$7 = active() ? theme().text : theme().textMuted;
106
- _v$6 !== _p$.e && (_p$.e = _$setProp(_el$1, "backgroundColor", _v$6, _p$.e));
107
- _v$7 !== _p$.t && (_p$.t = _$setProp(_el$10, "fg", _v$7, _p$.t));
108
+ var _v$7 = active() ? theme().backgroundElement : theme().backgroundPanel,
109
+ _v$8 = active() ? theme().text : theme().textMuted;
110
+ _v$7 !== _p$.e && (_p$.e = _$setProp(_el$10, "backgroundColor", _v$7, _p$.e));
111
+ _v$8 !== _p$.t && (_p$.t = _$setProp(_el$11, "fg", _v$8, _p$.t));
108
112
  return _p$;
109
113
  }, {
110
114
  e: undefined,
111
115
  t: undefined
112
116
  });
113
- return _el$1;
117
+ return _el$10;
114
118
  })();
115
119
  }
116
120
  }), _$createComponent(Show, {
@@ -118,132 +122,132 @@ export function Dock(props) {
118
122
  return overflow() > 0 || props.store.showAll();
119
123
  },
120
124
  get children() {
121
- var _el$6 = _$createElement("text");
122
- _$setProp(_el$6, "wrapMode", "none");
123
- _$setProp(_el$6, "flexShrink", 0);
124
- _$setProp(_el$6, "onMouseUp", () => props.store.toggleAll());
125
- _$insert(_el$6, (() => {
125
+ var _el$7 = _$createElement("text");
126
+ _$setProp(_el$7, "wrapMode", "none");
127
+ _$setProp(_el$7, "flexShrink", 0);
128
+ _$setProp(_el$7, "onMouseUp", () => props.store.toggleAll());
129
+ _$insert(_el$7, (() => {
126
130
  var _c$ = _$memo(() => !!props.store.showAll());
127
131
  return () => _c$() ? "▾ fewer" : `▸ ${overflow()} more`;
128
132
  })());
129
- _$effect(_$p => _$setProp(_el$6, "fg", theme().textMuted, _$p));
130
- return _el$6;
133
+ _$effect(_$p => _$setProp(_el$7, "fg", theme().textMuted, _$p));
134
+ return _el$7;
131
135
  }
132
136
  })];
133
137
  }
134
- }), _el$7);
135
- _$setProp(_el$7, "flexGrow", 1);
136
- _$setProp(_el$8, "wrapMode", "none");
137
- _$setProp(_el$8, "flexShrink", 0);
138
- _$insert(_el$8, () => props.hint());
138
+ }), _el$8);
139
+ _$setProp(_el$8, "flexGrow", 1);
140
+ _$setProp(_el$9, "wrapMode", "none");
141
+ _$setProp(_el$9, "flexShrink", 0);
142
+ _$insert(_el$9, () => props.hint());
139
143
  _$insert(_el$, _$createComponent(Show, {
140
144
  get when() {
141
145
  return props.store.selected();
142
146
  },
143
147
  children: shell => [(() => {
144
- var _el$13 = _$createElement("box");
145
- _$setProp(_el$13, "flexGrow", 1);
146
- _$setProp(_el$13, "paddingLeft", 2);
147
- _$setProp(_el$13, "paddingRight", 1);
148
- _$setProp(_el$13, "minHeight", 0);
149
- _$setProp(_el$13, "overflow", "hidden");
150
- _$setProp(_el$13, "onMouseUp", () => props.onOpenConsole(shell().id));
151
- _$insert(_el$13, _$createComponent(Show, {
148
+ var _el$14 = _$createElement("box");
149
+ _$setProp(_el$14, "flexGrow", 1);
150
+ _$setProp(_el$14, "paddingLeft", 2);
151
+ _$setProp(_el$14, "paddingRight", 1);
152
+ _$setProp(_el$14, "minHeight", 0);
153
+ _$setProp(_el$14, "overflow", "hidden");
154
+ _$setProp(_el$14, "onMouseUp", () => props.onOpenConsole(shell().id));
155
+ _$insert(_el$14, _$createComponent(Show, {
152
156
  get when() {
153
157
  return bodyRuns().length > 0;
154
158
  },
155
159
  get fallback() {
156
160
  return (() => {
157
- var _el$19 = _$createElement("text");
158
- _$setProp(_el$19, "wrapMode", "none");
159
- _$insert(_el$19, () => body() || " ");
160
- _$effect(_$p => _$setProp(_el$19, "fg", theme().text, _$p));
161
- return _el$19;
161
+ var _el$20 = _$createElement("text");
162
+ _$setProp(_el$20, "wrapMode", "none");
163
+ _$insert(_el$20, () => body() || " ");
164
+ _$effect(_$p => _$setProp(_el$20, "fg", theme().text, _$p));
165
+ return _el$20;
162
166
  })();
163
167
  },
164
168
  get children() {
165
- var _el$14 = _$createElement("box");
166
- _$setProp(_el$14, "flexDirection", "column");
167
- _$insert(_el$14, _$createComponent(For, {
169
+ var _el$15 = _$createElement("box");
170
+ _$setProp(_el$15, "flexDirection", "column");
171
+ _$insert(_el$15, _$createComponent(For, {
168
172
  get each() {
169
173
  return bodyRuns();
170
174
  },
171
175
  children: row => (() => {
172
- var _el$20 = _$createElement("text"),
173
- _el$21 = _$createTextNode(` `);
174
- _$insertNode(_el$20, _el$21);
175
- _$setProp(_el$20, "wrapMode", "none");
176
- _$insert(_el$20, _$createComponent(For, {
176
+ var _el$21 = _$createElement("text"),
177
+ _el$22 = _$createTextNode(` `);
178
+ _$insertNode(_el$21, _el$22);
179
+ _$setProp(_el$21, "wrapMode", "none");
180
+ _$insert(_el$21, _$createComponent(For, {
177
181
  each: row,
178
182
  children: run => (() => {
179
- var _el$22 = _$createElement("span");
180
- _$insert(_el$22, () => run.text);
181
- _$effect(_$p => _$setProp(_el$22, "style", {
183
+ var _el$23 = _$createElement("span");
184
+ _$insert(_el$23, () => run.text);
185
+ _$effect(_$p => _$setProp(_el$23, "style", {
182
186
  fg: run.fg,
183
187
  bg: run.bg,
184
188
  bold: run.bold,
185
189
  italic: run.italic
186
190
  }, _$p));
187
- return _el$22;
191
+ return _el$23;
188
192
  })()
189
- }), _el$21);
190
- _$effect(_$p => _$setProp(_el$20, "fg", theme().text, _$p));
191
- return _el$20;
193
+ }), _el$22);
194
+ _$effect(_$p => _$setProp(_el$21, "fg", theme().text, _$p));
195
+ return _el$21;
192
196
  })()
193
197
  }));
194
- return _el$14;
198
+ return _el$15;
195
199
  }
196
200
  }));
197
- return _el$13;
201
+ return _el$14;
198
202
  })(), (() => {
199
- var _el$15 = _$createElement("box"),
200
- _el$16 = _$createElement("text");
201
- _$insertNode(_el$15, _el$16);
202
- _$setProp(_el$15, "flexDirection", "row");
203
- _$setProp(_el$15, "gap", 1);
204
- _$setProp(_el$15, "paddingLeft", 2);
205
- _$setProp(_el$15, "flexShrink", 0);
206
- _$setProp(_el$15, "height", 1);
207
- _$setProp(_el$15, "overflow", "hidden");
208
- _$setProp(_el$16, "wrapMode", "none");
203
+ var _el$16 = _$createElement("box"),
204
+ _el$17 = _$createElement("text");
205
+ _$insertNode(_el$16, _el$17);
206
+ _$setProp(_el$16, "flexDirection", "row");
207
+ _$setProp(_el$16, "gap", 1);
208
+ _$setProp(_el$16, "paddingLeft", 2);
209
209
  _$setProp(_el$16, "flexShrink", 0);
210
- _$insert(_el$16, () => statusDetail(shell(), props.store.now()));
211
- _$insert(_el$15, _$createComponent(Show, {
210
+ _$setProp(_el$16, "height", 1);
211
+ _$setProp(_el$16, "overflow", "hidden");
212
+ _$setProp(_el$17, "wrapMode", "none");
213
+ _$setProp(_el$17, "flexShrink", 0);
214
+ _$insert(_el$17, () => statusDetail(shell(), props.store.now()));
215
+ _$insert(_el$16, _$createComponent(Show, {
212
216
  get when() {
213
217
  return watchLabel(shell());
214
218
  },
215
219
  get children() {
216
- var _el$17 = _$createElement("text");
217
- _$setProp(_el$17, "wrapMode", "none");
218
- _$setProp(_el$17, "flexShrink", 0);
219
- _$insert(_el$17, () => watchLabel(shell()));
220
- _$effect(_$p => _$setProp(_el$17, "fg", watchColor(theme(), shell()), _$p));
221
- return _el$17;
220
+ var _el$18 = _$createElement("text");
221
+ _$setProp(_el$18, "wrapMode", "none");
222
+ _$setProp(_el$18, "flexShrink", 0);
223
+ _$insert(_el$18, () => watchLabel(shell()));
224
+ _$effect(_$p => _$setProp(_el$18, "fg", watchColor(theme(), shell()), _$p));
225
+ return _el$18;
222
226
  }
223
227
  }), null);
224
- _$insert(_el$15, _$createComponent(Show, {
228
+ _$insert(_el$16, _$createComponent(Show, {
225
229
  get when() {
226
230
  return _$memo(() => kindOf(shell()) === "fail")() && shell().summary;
227
231
  },
228
232
  get fallback() {
229
233
  return (() => {
230
- var _el$23 = _$createElement("text");
231
- _$setProp(_el$23, "wrapMode", "none");
232
- _$insert(_el$23, () => truncate(`$ ${displayCommand(shell())}`, Math.max(20, dims().width - 40)));
233
- _$effect(_$p => _$setProp(_el$23, "fg", theme().textMuted, _$p));
234
- return _el$23;
234
+ var _el$24 = _$createElement("text");
235
+ _$setProp(_el$24, "wrapMode", "none");
236
+ _$insert(_el$24, () => truncate(`$ ${displayCommand(shell())}`, Math.max(20, dims().width - 40)));
237
+ _$effect(_$p => _$setProp(_el$24, "fg", theme().textMuted, _$p));
238
+ return _el$24;
235
239
  })();
236
240
  },
237
241
  get children() {
238
- var _el$18 = _$createElement("text");
239
- _$setProp(_el$18, "wrapMode", "none");
240
- _$insert(_el$18, () => truncate(`${shell().summary}`, Math.max(20, dims().width - 40)));
241
- _$effect(_$p => _$setProp(_el$18, "fg", theme().error, _$p));
242
- return _el$18;
242
+ var _el$19 = _$createElement("text");
243
+ _$setProp(_el$19, "wrapMode", "none");
244
+ _$insert(_el$19, () => truncate(`${shell().summary}`, Math.max(20, dims().width - 40)));
245
+ _$effect(_$p => _$setProp(_el$19, "fg", theme().error, _$p));
246
+ return _el$19;
243
247
  }
244
248
  }), null);
245
- _$effect(_$p => _$setProp(_el$16, "fg", kindColor(theme(), kindOf(shell())), _$p));
246
- return _el$15;
249
+ _$effect(_$p => _$setProp(_el$17, "fg", kindColor(theme(), kindOf(shell())), _$p));
250
+ return _el$16;
247
251
  })()]
248
252
  }), null);
249
253
  _$effect(_p$ => {
@@ -251,19 +255,22 @@ export function Dock(props) {
251
255
  _v$2 = theme().border,
252
256
  _v$3 = theme().backgroundPanel,
253
257
  _v$4 = theme().text,
254
- _v$5 = theme().textMuted;
258
+ _v$5 = theme().textMuted,
259
+ _v$6 = theme().textMuted;
255
260
  _v$ !== _p$.e && (_p$.e = _$setProp(_el$, "height", _v$, _p$.e));
256
261
  _v$2 !== _p$.t && (_p$.t = _$setProp(_el$, "borderColor", _v$2, _p$.t));
257
262
  _v$3 !== _p$.a && (_p$.a = _$setProp(_el$, "backgroundColor", _v$3, _p$.a));
258
263
  _v$4 !== _p$.o && (_p$.o = _$setProp(_el$3, "fg", _v$4, _p$.o));
259
- _v$5 !== _p$.i && (_p$.i = _$setProp(_el$8, "fg", _v$5, _p$.i));
264
+ _v$5 !== _p$.i && (_p$.i = _$setProp(_el$6, "fg", _v$5, _p$.i));
265
+ _v$6 !== _p$.n && (_p$.n = _$setProp(_el$9, "fg", _v$6, _p$.n));
260
266
  return _p$;
261
267
  }, {
262
268
  e: undefined,
263
269
  t: undefined,
264
270
  a: undefined,
265
271
  o: undefined,
266
- i: undefined
272
+ i: undefined,
273
+ n: undefined
267
274
  });
268
275
  return _el$;
269
276
  })();
@@ -89,7 +89,8 @@ export function newShell(api, store, open) {
89
89
  cwd: store.project(),
90
90
  title: command.slice(0, 60),
91
91
  owner: {
92
- project: store.project()
92
+ project: store.project(),
93
+ session: store.session()
93
94
  },
94
95
  reuse: true
95
96
  }).then(info => {
@@ -135,6 +136,52 @@ export function restartDaemon(api, store) {
135
136
  onCancel: () => api.ui.dialog.clear()
136
137
  }));
137
138
  }
139
+
140
+ /**
141
+ * Stopping shells in bulk. Two commands rather than one, because "everything I can see" and
142
+ * "everything, including what I cannot" are different intentions — and the second one asks first.
143
+ */
144
+ export function stopShells(api, store, reach) {
145
+ const list = (reach === "view" ? store.shells() : store.all()).filter(s => s.status === "running");
146
+ if (list.length === 0) {
147
+ api.ui.toast({
148
+ title: "Shells",
149
+ message: "Nothing is running.",
150
+ duration: 3000
151
+ });
152
+ return;
153
+ }
154
+ const stop = () => {
155
+ api.ui.dialog.clear();
156
+ void Promise.all(list.map(shell => store.client.call("shell.stop", {
157
+ id: shell.id,
158
+ graceMs: 2000
159
+ }).catch(() => {}))).then(() => {
160
+ void store.refresh();
161
+ api.ui.toast({
162
+ title: "Shells",
163
+ message: `Stopped ${list.length} shell${list.length === 1 ? "" : "s"}.`,
164
+ duration: 4000
165
+ });
166
+ });
167
+ };
168
+
169
+ // Shells from other conversations are the ones you are not looking at; say so before killing them.
170
+ const elsewhere = reach === "project" ? list.length - store.shells().filter(s => s.status === "running").length : 0;
171
+ if (elsewhere <= 0) {
172
+ stop();
173
+ return;
174
+ }
175
+ const DialogConfirm = api.ui.DialogConfirm;
176
+ api.ui.dialog.replace(() => _$createComponent(DialogConfirm, {
177
+ title: "Stop every shell in this project?",
178
+ get message() {
179
+ return `${list.length} running · ${elsewhere} from other conversations.`;
180
+ },
181
+ onConfirm: stop,
182
+ onCancel: () => api.ui.dialog.clear()
183
+ }));
184
+ }
138
185
  export function pickShell(api, store, open) {
139
186
  const DialogSelect = api.ui.DialogSelect;
140
187
  const shells = order(store.shells());
package/dist/tui/index.js CHANGED
@@ -11,7 +11,7 @@ import { loadConfig } from "../core/config.js";
11
11
  import { Console } from "./components/console.js";
12
12
  import { Dock } from "./components/dock.js";
13
13
  import { SidebarShells } from "./components/sidebar.js";
14
- import { announceUpdate, newShell, offerUpdate, pickShell, restartDaemon } from "./dialogs.js";
14
+ import { announceUpdate, newShell, offerUpdate, pickShell, restartDaemon, stopShells } from "./dialogs.js";
15
15
  import { createShellStore } from "./state/store.js";
16
16
  const DEFAULT_KEYS = {
17
17
  "cockpit.shells.dock": "<leader>o",
@@ -55,7 +55,10 @@ const shellTui = async (api, rawOptions, meta) => {
55
55
  ...DEFAULT_KEYS,
56
56
  ...options.keybinds
57
57
  });
58
- const [dockOpen, setDockOpen] = createSignal(api.kv.get("cockpit.dock.open", options.dockOpen ?? false));
58
+
59
+ // An explicit `ui.dockOpen` says how the panel should start; without one, whatever you last left
60
+ // it as. Remembered state that overrides a written setting is a setting that appears to do nothing.
61
+ const [dockOpen, setDockOpen] = createSignal(options.dockOpen ?? api.kv.get("cockpit.dock.open", false));
59
62
  const toggleDock = () => {
60
63
  const next = !dockOpen();
61
64
  setDockOpen(next);
@@ -107,6 +110,20 @@ const shellTui = async (api, rawOptions, meta) => {
107
110
  namespace: "palette",
108
111
  slashName: "shell-new",
109
112
  run: () => newShell(api, store, openConsole)
113
+ }, {
114
+ name: "cockpit.shells.stop",
115
+ title: "Stop the shells in view",
116
+ category: "Shells",
117
+ namespace: "palette",
118
+ slashName: "shells-stop",
119
+ run: () => stopShells(api, store, "view")
120
+ }, {
121
+ name: "cockpit.shells.stopAll",
122
+ title: "Stop every shell in this project",
123
+ category: "Shells",
124
+ namespace: "palette",
125
+ slashName: "shells-stop-all",
126
+ run: () => stopShells(api, store, "project")
110
127
  }, {
111
128
  name: "cockpit.shells.clear",
112
129
  title: "Clear finished shells",
@@ -2,6 +2,9 @@ import { existsSync } from "node:fs";
2
2
  import { createEffect, createMemo, createRoot, createSignal, on, onCleanup } from "solid-js";
3
3
  import { createStore, reconcile } from "solid-js/store";
4
4
  import { order, partition } from "../lib/view.js";
5
+
6
+ /** A project's shells outnumber a conversation's, and mixing them is how the panel gets confusing. */
7
+
5
8
  export function createShellStore(api, client, options = {}) {
6
9
  return createRoot(dispose => {
7
10
  const [state, setState] = createStore({
@@ -12,7 +15,12 @@ export function createShellStore(api, client, options = {}) {
12
15
  const [frame, setFrame] = createSignal(0);
13
16
  const [selectedId, setSelectedId] = createSignal();
14
17
  const [showAll, setShowAll] = createSignal(api.kv.get("cockpit.shells.showAll", false));
18
+ const [scope, setScope] = createSignal(api.kv.get("cockpit.shells.scope", options.scope ?? "session"));
15
19
  const project = () => api.state.path.directory;
20
+ const session = () => {
21
+ const route = api.route.current;
22
+ return route.name === "session" ? route.params.sessionID : undefined;
23
+ };
16
24
  const historyMs = (options.historyMinutes ?? 30) * 60_000;
17
25
  const refresh = async () => {
18
26
  try {
@@ -53,7 +61,13 @@ export function createShellStore(api, client, options = {}) {
53
61
  const ordered = order(state.list);
54
62
  return ordered.find(s => s.id === selectedId()) ?? ordered[0];
55
63
  });
56
- const folded = createMemo(() => partition(state.list, {
64
+ /** Everything the daemon knows about this project, narrowed to what the panel is about. */
65
+ const inScope = createMemo(() => {
66
+ const current = session();
67
+ if (scope() === "project" || !current) return state.list;
68
+ return state.list.filter(shell => shell.owner.session === current);
69
+ });
70
+ const folded = createMemo(() => partition(inScope(), {
57
71
  showAll: showAll(),
58
72
  historyMs,
59
73
  now: now(),
@@ -62,7 +76,15 @@ export function createShellStore(api, client, options = {}) {
62
76
  return {
63
77
  client,
64
78
  project,
65
- shells: () => state.list,
79
+ session,
80
+ scope,
81
+ toggleScope() {
82
+ const next = scope() === "session" ? "project" : "session";
83
+ setScope(next);
84
+ api.kv.set("cockpit.shells.scope", next);
85
+ },
86
+ shells: () => inScope(),
87
+ all: () => state.list,
66
88
  visible: () => folded().visible,
67
89
  hidden: () => folded().hidden,
68
90
  showAll,
@@ -79,7 +101,7 @@ export function createShellStore(api, client, options = {}) {
79
101
  // Cycles every shell in the project: folding only exists to keep the sidebar short, and a
80
102
  // shell you cannot reach from the console would be a trap.
81
103
  step(delta) {
82
- const list = order(state.list);
104
+ const list = order(inScope());
83
105
  if (list.length === 0) return;
84
106
  const index = Math.max(0, list.findIndex(s => s.id === pick()?.id));
85
107
  const next = list[(index + delta + list.length) % list.length];
@@ -135,7 +157,9 @@ export function useScreen(store, id) {
135
157
  current = next;
136
158
  setScreen(undefined);
137
159
  if (!next) return;
138
- const info = store.shells().find(s => s.id === next);
160
+ // Not store.shells(): that is narrowed to the current conversation, and the selected shell may
161
+ // sit outside it. A missing offset here is what used to ask the daemon for bytes past the end.
162
+ const info = store.all().find(s => s.id === next);
139
163
  void store.client.call("shell.attach", {
140
164
  id: next,
141
165
  fromOffset: info?.bytes ?? Number.MAX_SAFE_INTEGER
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-cockpit/shell",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Background shells for OpenCode: the agent starts, waits on and drives PTYs; you watch them in a docked TUI panel",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,9 +51,9 @@
51
51
  "access": "public"
52
52
  },
53
53
  "dependencies": {
54
- "@opencode-cockpit/client": "0.2.1",
55
- "@opencode-cockpit/daemon": "0.2.1",
56
- "@opencode-cockpit/protocol": "0.2.1",
54
+ "@opencode-cockpit/client": "0.2.2",
55
+ "@opencode-cockpit/daemon": "0.2.2",
56
+ "@opencode-cockpit/protocol": "0.2.2",
57
57
  "@opencode-ai/plugin": "1.18.31"
58
58
  },
59
59
  "devDependencies": {
@@ -6,4 +6,4 @@ export declare function daemonEntry(): string;
6
6
  * BUN_BE_BUN=1 so it runs on OpenCode's embedded Bun (ADR 0001). The expected build lets the
7
7
  * client replace a daemon left running from older plugin code.
8
8
  */
9
- export declare function createClient(name: string): CockpitClient;
9
+ export declare function createClient(name: string, instance?: string): CockpitClient;
@@ -27,6 +27,16 @@ export interface CockpitConfig {
27
27
  timeoutSeconds?: number;
28
28
  notifyOnExit?: boolean;
29
29
  };
30
+ /** When shells end by themselves. */
31
+ lifecycle?: {
32
+ /**
33
+ * What happens to this window's shells when it closes. `stopMine` stops them, `keep` leaves
34
+ * them running for the next window — which is how shells survive an OpenCode restart.
35
+ */
36
+ onExit?: "stopMine" | "keep";
37
+ /** Stop a shell after this long with no window of its own connected. `0` never does. */
38
+ orphanAfterMinutes?: number;
39
+ };
30
40
  /** What is allowed to interrupt the agent. */
31
41
  notify?: {
32
42
  exit?: boolean;
@@ -7,4 +7,9 @@ export declare function announceUpdate(api: TuiPluginApi, meta: TuiPluginMeta, c
7
7
  export declare function offerUpdate(api: TuiPluginApi, meta: TuiPluginMeta, current: string): void;
8
8
  export declare function newShell(api: TuiPluginApi, store: ShellStore, open: (id?: string) => void): void;
9
9
  export declare function restartDaemon(api: TuiPluginApi, store: ShellStore): void;
10
+ /**
11
+ * Stopping shells in bulk. Two commands rather than one, because "everything I can see" and
12
+ * "everything, including what I cannot" are different intentions — and the second one asks first.
13
+ */
14
+ export declare function stopShells(api: TuiPluginApi, store: ShellStore, reach: "view" | "project"): void;
10
15
  export declare function pickShell(api: TuiPluginApi, store: ShellStore, open: (id?: string) => void): void;
@@ -5,13 +5,20 @@ import { type Accessor } from "solid-js";
5
5
  export interface ShellStore {
6
6
  client: CockpitClient;
7
7
  project: () => string;
8
- /** Every shell in the project, unordered. */
8
+ /** Shells the panel is about: the current conversation, or the project. */
9
9
  shells: Accessor<ShellInfo[]>;
10
+ /** Every shell in the project, whatever the panel is showing. */
11
+ all: Accessor<ShellInfo[]>;
10
12
  /** Ordered and folded for display: running, recent failures, plus the selection. */
11
13
  visible: Accessor<ShellInfo[]>;
12
14
  hidden: Accessor<ShellInfo[]>;
13
15
  showAll: Accessor<boolean>;
14
16
  toggleAll(): void;
17
+ /** Which shells the panel is about: the conversation you are in, or the whole project. */
18
+ scope: Accessor<Scope>;
19
+ toggleScope(): void;
20
+ /** The session the interface is currently showing, when it is showing one. */
21
+ session: Accessor<string | undefined>;
15
22
  connected: Accessor<boolean>;
16
23
  now: Accessor<number>;
17
24
  /** Spinner frame index; advances only while something is running. */
@@ -23,9 +30,13 @@ export interface ShellStore {
23
30
  clearFinished(): Promise<number>;
24
31
  dispose(): void;
25
32
  }
33
+ /** A project's shells outnumber a conversation's, and mixing them is how the panel gets confusing. */
34
+ export type Scope = "session" | "project";
26
35
  export interface StoreOptions {
27
36
  /** Failures stay in the default view this long after they end. */
28
37
  historyMinutes?: number;
38
+ /** What the panel lists by default. */
39
+ scope?: Scope;
29
40
  }
30
41
  export declare function createShellStore(api: TuiPluginApi, client: CockpitClient, options?: StoreOptions): ShellStore;
31
42
  /**