@opencode-cockpit/shell 0.2.0 → 0.2.1

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
@@ -74,12 +74,17 @@ A watch rule is three regexes, not a parser: `done` (a run ended), `fail` and `o
74
74
  tsc, eslint, biome, prettier, mypy, ruff, vitest, jest, mocha, bun test, deno test, pytest, rspec,
75
75
  phpunit, playwright, cypress, vite, next, nuxt, astro, angular, webpack/rspack, esbuild, tsup,
76
76
  turbo, metro, storybook, cargo, go, dotnet, gradle, maven, docker compose and terraform. Anything
77
- else takes its own patterns:
77
+ else takes its own patterns, at `shell_start` or later:
78
78
 
79
79
  ```
80
+ shell_start command="./deploy.sh" description="deploy" watch={ fail: "FAILED", ok: "SUCCEEDED", idleSeconds: 5 }
80
81
  shell_watch name="deploy" rule={ fail: "FAILED", ok: "SUCCEEDED", idleSeconds: 5 }
81
82
  ```
82
83
 
84
+ When no preset fits and no rule is given — `sleep 300`, a plain script, anything that prints nothing
85
+ recognizable — watching still works: the shell is watched for **dying** (preset `exit`), which is
86
+ crash detection with no patterns to write.
87
+
83
88
  Things to ask:
84
89
 
85
90
  - *"Start the dev server in a background shell and wait until it's ready."*
@@ -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")
@@ -91,11 +97,10 @@ export function shellStart(kit) {
91
97
  if (info.status === "failed") return `${header(info)}\n${describeStatus(info)}\n</shell>`;
92
98
  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
99
  if (watch) {
94
- const preset = typeof watch === "string" ? watch : "auto";
95
100
  await client.call("shell.watch", {
96
101
  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)}`));
102
+ ...watchArgs(watch, config)
103
+ }).then(watched => lines.push(describeWatch(watched))).catch(err => lines.push(`could not watch: ${err instanceof Error ? err.message : String(err)}`));
99
104
  }
100
105
  if (args.waitFor) {
101
106
  const {
@@ -134,6 +139,13 @@ export function shellStart(kit) {
134
139
  });
135
140
  }
136
141
 
142
+ /** What was actually attached: a preset, your own rule, or plain crash reporting. */
143
+ function describeWatch(info) {
144
+ const preset = info.watch?.preset;
145
+ if (preset === "exit") return "watching: no health patterns fit this command, so you will be messaged if it dies";
146
+ return `watching health (${preset ?? "custom rule"}); changes will be messaged to you`;
147
+ }
148
+
137
149
  /** Milliseconds from an option in seconds, or undefined when unset. */
138
150
  function seconds(value) {
139
151
  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
  }
@@ -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}>`;
@@ -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;
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.1",
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.1",
55
+ "@opencode-cockpit/daemon": "0.2.1",
56
+ "@opencode-cockpit/protocol": "0.2.1",
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;
@@ -20,7 +20,8 @@ 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;
@@ -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;