@opencode-cockpit/shell 0.2.0 → 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
@@ -74,12 +85,17 @@ A watch rule is three regexes, not a parser: `done` (a run ended), `fail` and `o
74
85
  tsc, eslint, biome, prettier, mypy, ruff, vitest, jest, mocha, bun test, deno test, pytest, rspec,
75
86
  phpunit, playwright, cypress, vite, next, nuxt, astro, angular, webpack/rspack, esbuild, tsup,
76
87
  turbo, metro, storybook, cargo, go, dotnet, gradle, maven, docker compose and terraform. Anything
77
- else takes its own patterns:
88
+ else takes its own patterns, at `shell_start` or later:
78
89
 
79
90
  ```
91
+ shell_start command="./deploy.sh" description="deploy" watch={ fail: "FAILED", ok: "SUCCEEDED", idleSeconds: 5 }
80
92
  shell_watch name="deploy" rule={ fail: "FAILED", ok: "SUCCEEDED", idleSeconds: 5 }
81
93
  ```
82
94
 
95
+ When no preset fits and no rule is given — `sleep 300`, a plain script, anything that prints nothing
96
+ recognizable — watching still works: the shell is watched for **dying** (preset `exit`), which is
97
+ crash detection with no patterns to write.
98
+
83
99
  Things to ask:
84
100
 
85
101
  - *"Start the dev server in a background shell and wait until it's ready."*
@@ -91,9 +107,12 @@ Things to ask:
91
107
  | Key / command | Does |
92
108
  |---|---|
93
109
  | `ctrl+x o` · `/shells` | Toggle the shells panel under the chat |
110
+ | `s` (in the console) | This conversation only, or the whole project |
94
111
  | `ctrl+x i` · `/shell` | Open the shell console |
95
112
  | `/shell-new` | Start a shell yourself |
96
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 |
97
116
  | `/cockpit-update` | Update the plugin when a newer release exists |
98
117
  | `/shells-restart-daemon` | Restart `cockpitd` (asks first when shells are running) |
99
118
 
@@ -107,6 +126,10 @@ the program, `ctrl+]` to stop typing), `c` ctrl+c, `r` restart, `x` stop. Finish
107
126
  again, `d` remove. Always: `tab` screen or log, `/` search the log (`backspace` clears the filter),
108
127
  `?` details, `[` `]` switch shells, `D` clear finished, `esc` close.
109
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
+
110
133
  **Searching a long log.** In the console, `/` filters the scrollback to matching lines — keeping
111
134
  their original line numbers and highlighting the match — so a 40k-line dev server is one query away
112
135
  from the five lines you want. Filtering happens in the daemon, not the terminal.
@@ -147,11 +170,12 @@ working. (`XDG_CONFIG_HOME` is honoured for the global path.)
147
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. |
148
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"`. |
149
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. |
150
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`. |
151
175
  | `notify` | What may interrupt the agent — `exit`, `watch`, and `tailLines` (output lines included in an exit message). |
152
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. |
153
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`. |
154
- | `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`. |
155
179
 
156
180
  The same settings can go on the plugin entry instead, which is handy for one-offs and for machines
157
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 = () => {
@@ -48,7 +48,13 @@ export function shellStart(kit) {
48
48
  timeoutSeconds: z.number().positive().max(3600).default(120)
49
49
  }).optional().describe("Block until ready. Same conditions as shell_wait."),
50
50
  notifyOnExit: z.boolean().default(true).describe("Message you when the process exits"),
51
- watch: z.union([z.boolean(), z.string()]).optional().describe('Watch this shell\'s health and message you only when it changes: true or "auto" picks a preset from the command, or name one (tsc, vitest, cargo…). For processes that never exit.'),
51
+ watch: z.union([z.boolean(), z.string(), z.object({
52
+ done: z.string().optional().describe("A run finished, e.g. 'Found \\d+ errors'"),
53
+ fail: z.string().optional(),
54
+ ok: z.string().optional(),
55
+ ignoreCase: z.boolean().optional(),
56
+ idleSeconds: z.number().positive().optional()
57
+ })]).optional().describe('Watch this shell\'s health and message you only when it changes. true or "auto" picks a preset from the command (tsc, vitest, cargo…) and falls back to reporting the process dying; a name picks that preset; an object is your own rule, e.g. { done: "\\d+ (passed|failed)", fail: "\\d+ failed" }.'),
52
58
  timeoutSeconds: z.number().int().positive().optional().describe("Stop the process after this long, busy or not. Good for bounded jobs and probes."),
53
59
  idleTimeoutSeconds: z.number().int().positive().optional().describe("Stop the process after this much silence. Never use it for dev servers, which are idle when healthy."),
54
60
  logFile: z.boolean().default(false).describe("Also write the clean log to a file, so old lines survive the in-memory buffer")
@@ -60,6 +66,9 @@ export function shellStart(kit) {
60
66
  const logFile = args.logFile ?? defaults.logFile ?? false;
61
67
  const notifyOnExit = args.notifyOnExit ?? defaults.notifyOnExit ?? true;
62
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;
63
72
  const shell = deps.shellCommand(args.command);
64
73
  const info = await client.call("shell.start", {
65
74
  command: shell.command,
@@ -78,6 +87,8 @@ export function shellStart(kit) {
78
87
  timeoutMs: seconds(args.timeoutSeconds ?? defaults.timeoutSeconds),
79
88
  idleTimeoutMs: seconds(args.idleTimeoutSeconds ?? defaults.idleTimeoutSeconds),
80
89
  logFile: logFile === true,
90
+ stopOnExit: (lifecycle.onExit ?? "stopMine") === "stopMine",
91
+ orphanAfterMs: orphanMinutes > 0 ? orphanMinutes * 60_000 : undefined,
81
92
  reuse: true
82
93
  });
83
94
  if (notifyOnExit === false) deps.quiet.add(info.id);else deps.quiet.delete(info.id);
@@ -91,11 +102,10 @@ export function shellStart(kit) {
91
102
  if (info.status === "failed") return `${header(info)}\n${describeStatus(info)}\n</shell>`;
92
103
  const lines = [info.run > 1 ? `Restarted ${info.id} (run ${info.run}): same command as an earlier finished shell in this session. Earlier output is above line ${info.lines.last}.` : `Started ${info.id}: ${args.command}`];
93
104
  if (watch) {
94
- const preset = typeof watch === "string" ? watch : "auto";
95
105
  await client.call("shell.watch", {
96
106
  id: info.id,
97
- ...watchArgs(preset, config)
98
- }).then(watched => lines.push(`watching health (${watched.watch?.preset ?? "custom rule"}); changes will be messaged to you`)).catch(err => lines.push(`could not watch: ${err instanceof Error ? err.message : String(err)}`));
107
+ ...watchArgs(watch, config)
108
+ }).then(watched => lines.push(describeWatch(watched))).catch(err => lines.push(`could not watch: ${err instanceof Error ? err.message : String(err)}`));
99
109
  }
100
110
  if (args.waitFor) {
101
111
  const {
@@ -134,6 +144,13 @@ export function shellStart(kit) {
134
144
  });
135
145
  }
136
146
 
147
+ /** What was actually attached: a preset, your own rule, or plain crash reporting. */
148
+ function describeWatch(info) {
149
+ const preset = info.watch?.preset;
150
+ if (preset === "exit") return "watching: no health patterns fit this command, so you will be messaged if it dies";
151
+ return `watching health (${preset ?? "custom rule"}); changes will be messaged to you`;
152
+ }
153
+
137
154
  /** Milliseconds from an option in seconds, or undefined when unset. */
138
155
  function seconds(value) {
139
156
  return value ? Math.round(value * 1000) : undefined;
@@ -1,12 +1,60 @@
1
+ /** A backslash that JSON does not allow — `\d`, `\s`, `\(` — i.e. someone wrote a regex in here. */
2
+ const LONE_ESCAPE = /\\(?!["\\/bfnrtu])/g;
3
+
1
4
  /**
2
- * Turns a preset name into what `shell.watch` needs. A preset defined in config is sent as an
3
- * explicit rule, so users can add tools or correct a built-in without touching the daemon.
5
+ * Rules are regexes, and a regex written inside JSON text is usually under-escaped (`"\d+ passed"`
6
+ * is not valid JSON). Parse it as written first, then again with those backslashes escaped, so a
7
+ * model's JSON does not have to be perfect for its patterns to survive.
4
8
  */
5
- export function watchArgs(preset, config) {
9
+ function parseLoosely(text) {
10
+ try {
11
+ return JSON.parse(text);
12
+ } catch {
13
+ try {
14
+ return JSON.parse(text.replace(LONE_ESCAPE, "\\\\"));
15
+ } catch {
16
+ return undefined; // not JSON at all: treated as a preset name, which says so downstream
17
+ }
18
+ }
19
+ }
20
+
21
+ /** The keys a rule may carry; anything else in an object is not a rule. */
22
+ const RULE_KEYS = new Set(["done", "fail", "ok", "ignoreCase", "idleSeconds"]);
23
+
24
+ /**
25
+ * Turns whatever the model passed for `watch` into what `shell.watch` needs.
26
+ *
27
+ * `true`/`"auto"` asks the daemon to pick a preset, a name picks that preset — and a preset defined
28
+ * in config travels as an explicit rule, so users can add tools without touching the daemon. Rule
29
+ * objects arrive as JSON *strings* often enough (models write one, and tool args are not always
30
+ * parsed the way the schema says) that a string which looks like an object is parsed rather than
31
+ * handed on as a preset name nobody has.
32
+ */
33
+ export function watchArgs(watch, config) {
34
+ const rule = asWatchRule(watch);
35
+ if (rule) return {
36
+ rule
37
+ };
38
+ const preset = watch === true || watch == null ? "auto" : String(watch);
6
39
  const custom = config.watch?.presets?.[preset];
7
40
  return custom ? {
8
41
  rule: custom
9
42
  } : {
10
43
  preset
11
44
  };
45
+ }
46
+
47
+ /** A rule from an object, or from the JSON string a model sometimes writes instead. */
48
+ export function asWatchRule(value) {
49
+ let candidate = value;
50
+ if (typeof candidate === "string") {
51
+ const text = candidate.trim();
52
+ if (!text.startsWith("{")) return undefined;
53
+ const parsed = parseLoosely(text);
54
+ if (parsed === undefined) return undefined;
55
+ candidate = parsed;
56
+ }
57
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return undefined;
58
+ const entries = Object.entries(candidate).filter(([k, v]) => RULE_KEYS.has(k) && v != null);
59
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
12
60
  }
@@ -1,13 +1,15 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
- import { watchArgs } from "./watch-args.js";
2
+ import { asWatchRule, watchArgs } from "./watch-args.js";
3
3
  const WATCH = `Keep an eye on a long-running shell and be told only when its health changes.
4
4
 
5
5
  For processes that never exit (tsc --watch, vitest --watch, dev servers) this replaces re-reading
6
6
  the log: you get one message when it breaks, and one when it is fixed, and nothing while it repeats
7
7
  the same result.
8
8
 
9
- - preset: a named rule ("auto" picks one from the command). Presets exist for tsc, vitest, jest,
10
- eslint, biome, cargo, go, gradle, pytest, vite, next, docker-compose and more.
9
+ - preset: a named rule ("auto" picks one from the command, and falls back to reporting the process
10
+ dying when no patterns fit). Presets exist for tsc, vitest, jest, eslint, biome, cargo, go,
11
+ gradle, pytest, vite, next, docker-compose and more; "exit" watches only for the process dying,
12
+ which is how you get crash detection for a command that prints nothing useful.
11
13
  - rule: your own patterns when no preset fits: done (a run ended), fail, ok, idleSeconds.
12
14
  - off: stop watching.
13
15
 
@@ -56,17 +58,13 @@ export function shellWatch(kit) {
56
58
  return `${note}stopped watching ${stopped.id}`;
57
59
  }
58
60
  // A preset defined in config travels as an explicit rule; the daemon knows only the built-ins.
61
+ // Both arguments absorb a rule written as JSON, rather than failing on a preset name nobody has.
59
62
  const chosen = args.preset ? watchArgs(args.preset, config) : {};
63
+ const rule = asWatchRule(args.rule) ?? chosen.rule;
60
64
  const info = await client.call("shell.watch", {
61
65
  id,
62
- preset: chosen.preset,
63
- rule: args.rule ? {
64
- done: args.rule.done ?? undefined,
65
- fail: args.rule.fail ?? undefined,
66
- ok: args.rule.ok ?? undefined,
67
- ignoreCase: args.rule.ignoreCase ?? undefined,
68
- idleSeconds: args.rule.idleSeconds ?? undefined
69
- } : chosen.rule
66
+ preset: rule ? undefined : chosen.preset,
67
+ rule
70
68
  });
71
69
  return `${note}watching ${info.id} (${info.watch?.preset ?? "custom rule"}). You will be messaged when its health changes; no need to poll.`;
72
70
  }
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
@@ -16,17 +16,42 @@ export function formatLines(lines) {
16
16
  return out.join("\n");
17
17
  }
18
18
  export function describeStatus(info) {
19
+ const ran = () => duration((info.endedAt ?? Date.now()) - info.startedAt);
19
20
  switch (info.status) {
20
21
  case "running":
21
22
  return `running (pid ${info.pid}, up ${duration(Date.now() - info.startedAt)})`;
22
23
  case "exited":
23
- return `exited with code ${info.exitCode ?? "?"} after ${duration((info.endedAt ?? Date.now()) - info.startedAt)}`;
24
+ return info.exitCode === 0 ? `exited cleanly after ${ran()}` : `crashed with exit code ${info.exitCode ?? "?"} after ${ran()}`;
24
25
  case "killed":
25
- return `killed${info.signal ? ` by ${info.signal}` : ""} after ${duration((info.endedAt ?? Date.now()) - info.startedAt)}`;
26
+ return `${stopPhrase(info)} after ${ran()}`;
26
27
  case "failed":
27
28
  return `failed to start: ${info.error ?? "unknown error"}`;
28
29
  }
29
30
  }
31
+
32
+ /** Who ended a shell, and why — "killed by SIGTERM" alone never said which of us did it. */
33
+ function stopPhrase(info) {
34
+ switch (info.stopReason) {
35
+ case "timeout":
36
+ return "stopped: hit its time limit";
37
+ case "idle":
38
+ return "stopped: no output for its idle limit";
39
+ case "shutdown":
40
+ return "stopped because the shell daemon shut down";
41
+ case "request":
42
+ return `stopped by ${actor(info.stoppedBy)}`;
43
+ default:
44
+ return info.exitCode != null && info.exitCode !== 0 ? `crashed (exit code ${info.exitCode})` : `killed${info.signal ? ` by ${info.signal}` : ""} from outside`;
45
+ }
46
+ }
47
+
48
+ /** Client names are wire identifiers; say them the way a person would. */
49
+ function actor(client) {
50
+ if (!client) return "a request";
51
+ if (client.includes("tui")) return "you, from the shells panel";
52
+ if (client.includes("server")) return "the agent";
53
+ return client;
54
+ }
30
55
  export function header(info) {
31
56
  const run = info.run > 1 ? ` run=${info.run}` : "";
32
57
  return `<shell id="${info.id}" title="${info.title.replaceAll('"', "'")}" status="${info.status}"${run}>`;
@@ -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",
@@ -81,13 +81,29 @@ export function statusDetail(s, now) {
81
81
  case "done":
82
82
  return `took ${ran} · ${ago}`;
83
83
  case "stop":
84
- return `stopped after ${ran} · ${ago}`;
84
+ return `${stopWord(s)} after ${ran} · ${ago}`;
85
85
  case "fail":
86
86
  if (s.status === "failed") return "could not start";
87
87
  return `exit ${s.exitCode ?? "?"} after ${ran} · ${ago}`;
88
88
  }
89
89
  }
90
90
 
91
+ /** Why it stopped, in one word, because the panel has room for exactly that. */
92
+ function stopWord(s) {
93
+ switch (s.stopReason) {
94
+ case "timeout":
95
+ return "timed out";
96
+ case "idle":
97
+ return "idle-stopped";
98
+ case "shutdown":
99
+ return "daemon stopped it";
100
+ case "request":
101
+ return s.stoppedBy?.includes("tui") ? "you stopped it" : "agent stopped it";
102
+ default:
103
+ return "stopped";
104
+ }
105
+ }
106
+
91
107
  /** Short health label for a watched shell, e.g. "tsc ✗" — empty when nothing is watching it. */
92
108
  export function watchLabel(s) {
93
109
  const watch = s.watch;
@@ -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.0",
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.0",
55
- "@opencode-cockpit/daemon": "0.2.0",
56
- "@opencode-cockpit/protocol": "0.2.0",
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": {
@@ -1,10 +1,18 @@
1
1
  import type { WatchRule } from "@opencode-cockpit/protocol/shell";
2
2
  import type { CockpitConfig } from "../../core/config.ts";
3
- /**
4
- * Turns a preset name into what `shell.watch` needs. A preset defined in config is sent as an
5
- * explicit rule, so users can add tools or correct a built-in without touching the daemon.
6
- */
7
- export declare function watchArgs(preset: string, config: CockpitConfig): {
3
+ export type WatchRequest = {
8
4
  preset?: string;
9
5
  rule?: WatchRule;
10
6
  };
7
+ /**
8
+ * Turns whatever the model passed for `watch` into what `shell.watch` needs.
9
+ *
10
+ * `true`/`"auto"` asks the daemon to pick a preset, a name picks that preset — and a preset defined
11
+ * in config travels as an explicit rule, so users can add tools without touching the daemon. Rule
12
+ * objects arrive as JSON *strings* often enough (models write one, and tool args are not always
13
+ * parsed the way the schema says) that a string which looks like an object is parsed rather than
14
+ * handed on as a preset name nobody has.
15
+ */
16
+ export declare function watchArgs(watch: unknown, config: CockpitConfig): WatchRequest;
17
+ /** A rule from an object, or from the JSON string a model sometimes writes instead. */
18
+ export declare function asWatchRule(value: unknown): WatchRule | undefined;
@@ -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;
@@ -20,12 +20,23 @@ export interface CockpitConfig {
20
20
  kinds?: Record<string, string>;
21
21
  /** Applied to every shell the agent starts, unless the call says otherwise. */
22
22
  defaults?: {
23
- watch?: boolean | string;
23
+ /** true/"auto", a preset name, or your own rule. */
24
+ watch?: boolean | string | WatchRule;
24
25
  logFile?: boolean;
25
26
  idleTimeoutSeconds?: number;
26
27
  timeoutSeconds?: number;
27
28
  notifyOnExit?: boolean;
28
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
+ };
29
40
  /** What is allowed to interrupt the agent. */
30
41
  notify?: {
31
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;
@@ -39,6 +39,8 @@ export declare function partition(list: readonly ShellInfo[], opts: PartitionOpt
39
39
  signal?: string | undefined;
40
40
  error?: string | undefined;
41
41
  summary?: string | undefined;
42
+ stopReason?: "idle" | "request" | "shutdown" | "timeout" | undefined;
43
+ stoppedBy?: string | undefined;
42
44
  startedAt: number;
43
45
  endedAt?: number | undefined;
44
46
  cols: number;
@@ -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
  /**