@opencode-cockpit/shell 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +100 -10
  2. package/dist/agent/plugin.js +180 -0
  3. package/dist/agent/tools/index.js +23 -0
  4. package/{src/tools/keys.ts → dist/agent/tools/keys.js} +11 -14
  5. package/dist/agent/tools/list.js +68 -0
  6. package/dist/agent/tools/read.js +59 -0
  7. package/dist/agent/tools/restart.js +44 -0
  8. package/dist/agent/tools/send.js +71 -0
  9. package/dist/agent/tools/shared.js +94 -0
  10. package/dist/agent/tools/start.js +140 -0
  11. package/dist/agent/tools/stop.js +42 -0
  12. package/dist/agent/tools/wait.js +79 -0
  13. package/dist/agent/tools/watch-args.js +12 -0
  14. package/dist/agent/tools/watch.js +74 -0
  15. package/dist/connect.js +31 -0
  16. package/dist/core/config.js +97 -0
  17. package/dist/core/find.js +69 -0
  18. package/dist/core/format.js +63 -0
  19. package/dist/core/kind.js +31 -0
  20. package/dist/server.js +2 -0
  21. package/dist/tui/components/badge.js +29 -0
  22. package/dist/tui/components/console.js +759 -0
  23. package/dist/tui/components/dock.js +270 -0
  24. package/dist/tui/components/sidebar.js +136 -0
  25. package/dist/tui/dialogs.js +163 -0
  26. package/dist/tui/index.js +205 -0
  27. package/dist/tui/lib/details.js +23 -0
  28. package/dist/tui/lib/keys.js +43 -0
  29. package/dist/tui/lib/search.js +39 -0
  30. package/dist/tui/lib/update.js +58 -0
  31. package/dist/tui/lib/view.js +191 -0
  32. package/dist/tui/state/store.js +158 -0
  33. package/package.json +21 -12
  34. package/types/agent/plugin.d.ts +12 -0
  35. package/types/agent/tools/index.d.ts +5 -0
  36. package/types/agent/tools/keys.d.ts +3 -0
  37. package/types/agent/tools/list.d.ts +3 -0
  38. package/types/agent/tools/read.d.ts +3 -0
  39. package/types/agent/tools/restart.d.ts +3 -0
  40. package/types/agent/tools/send.d.ts +3 -0
  41. package/types/agent/tools/shared.d.ts +40 -0
  42. package/types/agent/tools/start.d.ts +3 -0
  43. package/types/agent/tools/stop.d.ts +3 -0
  44. package/types/agent/tools/wait.d.ts +3 -0
  45. package/types/agent/tools/watch-args.d.ts +10 -0
  46. package/types/agent/tools/watch.d.ts +3 -0
  47. package/types/connect.d.ts +9 -0
  48. package/types/core/config.d.ts +59 -0
  49. package/types/core/find.d.ts +37 -0
  50. package/types/core/format.d.ts +8 -0
  51. package/types/core/kind.d.ts +9 -0
  52. package/types/server.d.ts +2 -0
  53. package/types/tui/components/badge.d.ts +9 -0
  54. package/types/tui/components/console.d.ts +22 -0
  55. package/types/tui/components/dock.d.ts +14 -0
  56. package/types/tui/components/sidebar.d.ts +14 -0
  57. package/types/tui/dialogs.d.ts +10 -0
  58. package/types/tui/index.d.ts +13 -0
  59. package/types/tui/lib/details.d.ts +3 -0
  60. package/types/tui/lib/keys.d.ts +8 -0
  61. package/types/tui/lib/search.d.ts +7 -0
  62. package/types/tui/lib/update.d.ts +25 -0
  63. package/types/tui/lib/view.d.ts +80 -0
  64. package/types/tui/state/store.d.ts +55 -0
  65. package/src/connect.ts +0 -24
  66. package/src/server.ts +0 -156
  67. package/src/tools/find.ts +0 -80
  68. package/src/tools/format.ts +0 -78
  69. package/src/tools/index.ts +0 -467
  70. package/src/tui/badge.tsx +0 -17
  71. package/src/tui/console.tsx +0 -438
  72. package/src/tui/dock.tsx +0 -130
  73. package/src/tui/index.tsx +0 -286
  74. package/src/tui/keys.ts +0 -52
  75. package/src/tui/sidebar.tsx +0 -44
  76. package/src/tui/store.ts +0 -185
  77. package/src/tui/view.ts +0 -161
@@ -0,0 +1,94 @@
1
+ import { RpcError } from "@opencode-cockpit/protocol";
2
+ import { commandOf, matchByName } from "../../core/find.js";
3
+ import { formatRead } from "../../core/format.js";
4
+
5
+ /** What every tool shares: the client, name resolution, permission prompts and abort handling. */
6
+
7
+ export function createToolKit(deps) {
8
+ const {
9
+ client
10
+ } = deps;
11
+ const peek = async (info, tail = 30) => {
12
+ const current = await client.call("shell.get", {
13
+ id: info.id
14
+ });
15
+ const page = await client.call("shell.read", {
16
+ id: info.id,
17
+ tail
18
+ });
19
+ return formatRead(current, page);
20
+ };
21
+ const sessionLabel = async (s, ctx) => {
22
+ const session = s.owner.session;
23
+ if (!session) return "started by the user";
24
+ if (session === ctx.sessionID) return "this session";
25
+ const title = await deps.sessionTitle?.(session).catch(() => undefined);
26
+ return title ? `session "${title}"` : `another session (${session})`;
27
+ };
28
+
29
+ /** Turns `{ id }` or `{ name }` into a shell id, or explains why it cannot. */
30
+ const resolve = async (args, ctx) => {
31
+ if (args.id) return {
32
+ id: args.id,
33
+ note: ""
34
+ };
35
+ if (!args.name) throw new Error("pass the shell's id or name");
36
+ const shells = await client.call("shell.list", {
37
+ owner: {
38
+ project: ctx.directory
39
+ }
40
+ });
41
+ const match = matchByName(shells, args.name);
42
+ const describe = async list => (await Promise.all(list.map(async s => `- ${s.id} "${s.title}" · ${s.status} · ${await sessionLabel(s, ctx)} · $ ${commandOf(s).slice(0, 80)}`))).join("\n");
43
+ if (match.kind === "found") {
44
+ const note = match.alsoMatched.length > 0 ? `(name "${args.name}" also matched ${match.alsoMatched.length} finished shell${match.alsoMatched.length === 1 ? "" : "s"}; using the running one, ${match.shell.id})\n` : "";
45
+ return {
46
+ id: match.shell.id,
47
+ note
48
+ };
49
+ }
50
+ if (match.kind === "ambiguous") {
51
+ throw new Error(`"${args.name}" matches several shells; pass one of these ids:\n${await describe(match.candidates)}`);
52
+ }
53
+ throw new Error(match.available.length === 0 ? `no shell matches "${args.name}": there are no shells in this project` : `no shell matches "${args.name}". Shells in this project:\n${await describe(match.available.slice(0, 15))}`);
54
+ };
55
+ return {
56
+ deps,
57
+ config: deps.config ?? {},
58
+ client,
59
+ peek,
60
+ sessionLabel,
61
+ resolve
62
+ };
63
+ }
64
+ export async function askPermission(ctx, command) {
65
+ const words = command.trim().split(/\s+/);
66
+ const prefix = words.slice(0, Math.min(2, words.length)).join(" ");
67
+ await ctx.ask({
68
+ permission: "bash",
69
+ patterns: [command],
70
+ always: [`${prefix} *`],
71
+ metadata: {
72
+ command,
73
+ description: "background shell"
74
+ }
75
+ });
76
+ }
77
+
78
+ /** Resolves undefined when the tool call is aborted; the daemon keeps running the shell. */
79
+ export function abortable(ctx, promise) {
80
+ if (ctx.abort.aborted) return Promise.resolve(undefined);
81
+ return new Promise((resolve, reject) => {
82
+ const onAbort = () => resolve(undefined);
83
+ ctx.abort.addEventListener("abort", onAbort, {
84
+ once: true
85
+ });
86
+ promise.then(v => {
87
+ ctx.abort.removeEventListener("abort", onAbort);
88
+ resolve(v);
89
+ }, err => {
90
+ ctx.abort.removeEventListener("abort", onAbort);
91
+ reject(err instanceof RpcError ? new Error(err.message) : err);
92
+ });
93
+ });
94
+ }
@@ -0,0 +1,140 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { describeStatus, formatWait, header } from "../../core/format.js";
3
+ import { abortable, askPermission } from "./shared.js";
4
+ import { watchArgs } from "./watch-args.js";
5
+ const START = `Start a command in a background terminal (PTY) that keeps running while you continue working.
6
+
7
+ Use this instead of bash for anything long-running or interactive:
8
+ - dev servers, watchers (tsc --watch, vitest), local APIs, databases, tunnels
9
+ - builds or test suites that take more than ~30 seconds
10
+ - REPLs and prompts that need input later (use shell_send)
11
+
12
+ Do not append "&" or use nohup; the shell already runs in the background.
13
+
14
+ Readiness: pass waitFor to block until the process is actually ready, for example
15
+ waitFor={ port: 3000 } for a dev server or waitFor={ pattern: "compiled successfully" }.
16
+ Without waitFor the call returns after the first moment of quiet with the initial output.
17
+
18
+ You are notified automatically when the process exits (disable with notifyOnExit=false). Never
19
+ sleep and poll: use shell_wait to block on a condition, and shell_read(after=cursor) for new output.`;
20
+ const z = tool.schema;
21
+ /**
22
+ * Every per-shell tool takes an id or a name. Declared per tool file because exported zod schemas
23
+ * cannot be named portably in generated declarations.
24
+ */
25
+ const _TARGET = {
26
+ id: z.string().optional().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34"),
27
+ name: z.string().optional().describe('Instead of id: the shell\'s name (the description it was started with), e.g. "DB Monitoring". Partial names and command text also match.')
28
+ };
29
+ export function shellStart(kit) {
30
+ const {
31
+ client,
32
+ config,
33
+ deps,
34
+ peek
35
+ } = kit;
36
+ return tool({
37
+ description: START,
38
+ args: {
39
+ command: z.string().min(1).describe("Command line, run by your shell (pipes, && and env vars work)"),
40
+ description: z.string().min(3).describe("What this shell is for, 3-8 words, e.g. 'Next.js dev server'"),
41
+ workdir: z.string().optional().describe("Working directory; defaults to the project directory"),
42
+ env: z.record(z.string(), z.string()).optional().describe("Extra environment variables"),
43
+ waitFor: z.object({
44
+ pattern: z.string().optional(),
45
+ port: z.number().int().min(1).max(65535).optional(),
46
+ idleSeconds: z.number().positive().optional(),
47
+ exit: z.boolean().optional(),
48
+ timeoutSeconds: z.number().positive().max(3600).default(120)
49
+ }).optional().describe("Block until ready. Same conditions as shell_wait."),
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.'),
52
+ timeoutSeconds: z.number().int().positive().optional().describe("Stop the process after this long, busy or not. Good for bounded jobs and probes."),
53
+ 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
+ logFile: z.boolean().default(false).describe("Also write the clean log to a file, so old lines survive the in-memory buffer")
55
+ },
56
+ async execute(args, ctx) {
57
+ await askPermission(ctx, args.command);
58
+ // Config supplies what the call left out; an explicit argument always wins.
59
+ const defaults = config.defaults ?? {};
60
+ const logFile = args.logFile ?? defaults.logFile ?? false;
61
+ const notifyOnExit = args.notifyOnExit ?? defaults.notifyOnExit ?? true;
62
+ const watch = args.watch ?? defaults.watch ?? (config.watch?.auto ? "auto" : undefined);
63
+ const shell = deps.shellCommand(args.command);
64
+ const info = await client.call("shell.start", {
65
+ command: shell.command,
66
+ args: shell.args,
67
+ cwd: args.workdir || ctx.directory,
68
+ env: {
69
+ ...deps.env(),
70
+ ...args.env
71
+ },
72
+ title: args.description,
73
+ owner: {
74
+ project: ctx.directory,
75
+ session: ctx.sessionID,
76
+ instance: deps.instance
77
+ },
78
+ timeoutMs: seconds(args.timeoutSeconds ?? defaults.timeoutSeconds),
79
+ idleTimeoutMs: seconds(args.idleTimeoutSeconds ?? defaults.idleTimeoutSeconds),
80
+ logFile: logFile === true,
81
+ reuse: true
82
+ });
83
+ if (notifyOnExit === false) deps.quiet.add(info.id);else deps.quiet.delete(info.id);
84
+ ctx.metadata({
85
+ title: args.description,
86
+ metadata: {
87
+ shellId: info.id,
88
+ command: args.command
89
+ }
90
+ });
91
+ if (info.status === "failed") return `${header(info)}\n${describeStatus(info)}\n</shell>`;
92
+ 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
+ if (watch) {
94
+ const preset = typeof watch === "string" ? watch : "auto";
95
+ await client.call("shell.watch", {
96
+ 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)}`));
99
+ }
100
+ if (args.waitFor) {
101
+ const {
102
+ timeoutSeconds,
103
+ idleSeconds,
104
+ ...rest
105
+ } = args.waitFor;
106
+ const result = await abortable(ctx, client.call("shell.wait", {
107
+ id: info.id,
108
+ until: {
109
+ pattern: rest.pattern ?? undefined,
110
+ port: rest.port ?? undefined,
111
+ exit: rest.exit ?? undefined,
112
+ idleMs: idleSeconds ? Math.round(idleSeconds * 1000) : undefined
113
+ },
114
+ timeoutMs: Math.round((timeoutSeconds ?? 120) * 1000)
115
+ })).catch(err => {
116
+ lines.push(`wait failed: ${err instanceof Error ? err.message : String(err)} (the shell is still running)`);
117
+ return undefined;
118
+ });
119
+ if (result) lines.push(formatWait(result, timeoutSeconds ?? 120));
120
+ } else {
121
+ await abortable(ctx, client.call("shell.wait", {
122
+ id: info.id,
123
+ until: {
124
+ idleMs: 700,
125
+ exit: true
126
+ },
127
+ timeoutMs: 2500
128
+ }));
129
+ }
130
+ if (info.logFile) lines.push(`log file: ${info.logFile}`);
131
+ lines.push(await peek(info));
132
+ return lines.join("\n");
133
+ }
134
+ });
135
+ }
136
+
137
+ /** Milliseconds from an option in seconds, or undefined when unset. */
138
+ function seconds(value) {
139
+ return value ? Math.round(value * 1000) : undefined;
140
+ }
@@ -0,0 +1,42 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { describeStatus } from "../../core/format.js";
3
+ const z = tool.schema;
4
+ /**
5
+ * Every per-shell tool takes an id or a name. Declared per tool file because exported zod schemas
6
+ * cannot be named portably in generated declarations.
7
+ */
8
+ const TARGET = {
9
+ id: z.string().optional().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34"),
10
+ name: z.string().optional().describe('Instead of id: the shell\'s name (the description it was started with), e.g. "DB Monitoring". Partial names and command text also match.')
11
+ };
12
+ export function shellStop(kit) {
13
+ const {
14
+ client,
15
+ deps,
16
+ resolve
17
+ } = kit;
18
+ return tool({
19
+ description: "Stop a background shell (SIGTERM to its whole process group, then SIGKILL after a grace period). Set remove=true to also forget it.",
20
+ args: {
21
+ ...TARGET,
22
+ remove: z.boolean().default(false),
23
+ force: z.boolean().default(false).describe("Send SIGKILL immediately")
24
+ },
25
+ async execute(args, ctx) {
26
+ const {
27
+ id,
28
+ note
29
+ } = await resolve(args, ctx);
30
+ deps.quiet.add(id);
31
+ const info = await client.call("shell.stop", {
32
+ id,
33
+ signal: args.force === true ? "SIGKILL" : "SIGTERM",
34
+ graceMs: 3000
35
+ });
36
+ if (args.remove === true) await client.call("shell.remove", {
37
+ id
38
+ });
39
+ return `${note}${info.id} ${describeStatus(info)}${args.remove === true ? " and removed" : ""}`;
40
+ }
41
+ });
42
+ }
@@ -0,0 +1,79 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { formatLines, formatWait, header } from "../../core/format.js";
3
+ import { abortable } from "./shared.js";
4
+ const WAIT = `Block until a condition holds in a background shell. This is the only correct way to wait:
5
+ never sleep and poll.
6
+
7
+ Conditions (combine freely; the first to happen wins, and the process exiting always ends the wait):
8
+ - pattern: regex matched against output lines (also matches an unfinished prompt line)
9
+ - port: something accepts TCP connections on this port
10
+ - idleSeconds: no output for this long (often means waiting for input or finished a step)
11
+ - exit: the process ends
12
+
13
+ Pattern matching includes output produced before this call in the current run, so "wait until ready"
14
+ succeeds immediately if it is already ready.`;
15
+ const z = tool.schema;
16
+ /**
17
+ * Every per-shell tool takes an id or a name. Declared per tool file because exported zod schemas
18
+ * cannot be named portably in generated declarations.
19
+ */
20
+ const TARGET = {
21
+ id: z.string().optional().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34"),
22
+ name: z.string().optional().describe('Instead of id: the shell\'s name (the description it was started with), e.g. "DB Monitoring". Partial names and command text also match.')
23
+ };
24
+ export function shellWait(kit) {
25
+ const {
26
+ client,
27
+ resolve
28
+ } = kit;
29
+ return tool({
30
+ description: WAIT,
31
+ args: {
32
+ ...TARGET,
33
+ pattern: z.string().optional(),
34
+ ignoreCase: z.boolean().optional(),
35
+ port: z.number().int().min(1).max(65535).optional(),
36
+ host: z.string().optional(),
37
+ idleSeconds: z.number().positive().optional(),
38
+ exit: z.boolean().optional(),
39
+ timeoutSeconds: z.number().positive().max(3600).default(300)
40
+ },
41
+ async execute(args, ctx) {
42
+ const {
43
+ id,
44
+ note
45
+ } = await resolve(args, ctx);
46
+ const start = await client.call("shell.get", {
47
+ id
48
+ });
49
+ const result = await abortable(ctx, client.call("shell.wait", {
50
+ id,
51
+ until: {
52
+ pattern: args.pattern ?? undefined,
53
+ ignoreCase: args.ignoreCase ?? undefined,
54
+ port: args.port ?? undefined,
55
+ host: args.host ?? undefined,
56
+ exit: args.exit ?? undefined,
57
+ idleMs: args.idleSeconds ? Math.round(args.idleSeconds * 1000) : undefined
58
+ },
59
+ timeoutMs: Math.round((args.timeoutSeconds ?? 300) * 1000)
60
+ }));
61
+ if (!result) return "wait cancelled";
62
+ const newLines = result.info.lines.last - start.lines.last;
63
+ const page = newLines > 80 ? await client.call("shell.read", {
64
+ id,
65
+ tail: 80
66
+ }) : await client.call("shell.read", {
67
+ id,
68
+ after: start.lines.last,
69
+ limit: 80
70
+ });
71
+ const recent = page.lines;
72
+ if (newLines > 80) recent.unshift({
73
+ n: start.lines.last,
74
+ text: `… ${newLines - 80} earlier lines omitted (shell_read after=${start.lines.last})`
75
+ });
76
+ return [note + formatWait(result, args.timeoutSeconds ?? 300), header(result.info), recent.length > 0 ? formatLines(recent) : "(no new output during the wait)", "</shell>", `cursor: ${result.info.lines.last}`].join("\n");
77
+ }
78
+ });
79
+ }
@@ -0,0 +1,12 @@
1
+ /**
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.
4
+ */
5
+ export function watchArgs(preset, config) {
6
+ const custom = config.watch?.presets?.[preset];
7
+ return custom ? {
8
+ rule: custom
9
+ } : {
10
+ preset
11
+ };
12
+ }
@@ -0,0 +1,74 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { watchArgs } from "./watch-args.js";
3
+ const WATCH = `Keep an eye on a long-running shell and be told only when its health changes.
4
+
5
+ For processes that never exit (tsc --watch, vitest --watch, dev servers) this replaces re-reading
6
+ the log: you get one message when it breaks, and one when it is fixed, and nothing while it repeats
7
+ the same result.
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.
11
+ - rule: your own patterns when no preset fits: done (a run ended), fail, ok, idleSeconds.
12
+ - off: stop watching.
13
+
14
+ A watched process that dies is reported as a failure, so a crashed dev server no longer goes
15
+ unnoticed.`;
16
+ const z = tool.schema;
17
+ /**
18
+ * Every per-shell tool takes an id or a name. Declared per tool file because exported zod schemas
19
+ * cannot be named portably in generated declarations.
20
+ */
21
+ const TARGET = {
22
+ id: z.string().optional().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34"),
23
+ name: z.string().optional().describe('Instead of id: the shell\'s name (the description it was started with), e.g. "DB Monitoring". Partial names and command text also match.')
24
+ };
25
+ export function shellWatch(kit) {
26
+ const {
27
+ client,
28
+ config,
29
+ resolve
30
+ } = kit;
31
+ // Presets from config are worth advertising: the agent cannot guess a name it has never seen.
32
+ const named = Object.keys(config.watch?.presets ?? {});
33
+ return tool({
34
+ description: named.length > 0 ? `${WATCH}\n\nPresets from this project: ${named.join(", ")}.` : WATCH,
35
+ args: {
36
+ ...TARGET,
37
+ preset: z.string().optional().describe('Preset name, or "auto" to pick one from the command'),
38
+ rule: z.object({
39
+ done: z.string().optional().describe("A run finished, e.g. 'Found \\d+ errors'"),
40
+ fail: z.string().optional(),
41
+ ok: z.string().optional(),
42
+ ignoreCase: z.boolean().optional(),
43
+ idleSeconds: z.number().positive().optional().describe("Without `done`: treat this much silence as the end of a run")
44
+ }).optional().describe("Custom patterns; use when no preset fits"),
45
+ off: z.boolean().default(false).describe("Stop watching this shell")
46
+ },
47
+ async execute(args, ctx) {
48
+ const {
49
+ id,
50
+ note
51
+ } = await resolve(args, ctx);
52
+ if (args.off === true) {
53
+ const stopped = await client.call("shell.unwatch", {
54
+ id
55
+ });
56
+ return `${note}stopped watching ${stopped.id}`;
57
+ }
58
+ // A preset defined in config travels as an explicit rule; the daemon knows only the built-ins.
59
+ const chosen = args.preset ? watchArgs(args.preset, config) : {};
60
+ const info = await client.call("shell.watch", {
61
+ 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
70
+ });
71
+ return `${note}watching ${info.id} (${info.watch?.preset ?? "custom rule"}). You will be messaged when its health changes; no need to poll.`;
72
+ }
73
+ });
74
+ }
@@ -0,0 +1,31 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { CockpitClient } from "@opencode-cockpit/client";
3
+ import daemonPkg from "@opencode-cockpit/daemon/package.json" with { type: "json" };
4
+ import { daemonBuildId } from "@opencode-cockpit/protocol";
5
+ import pkg from "../package.json" with { type: "json" };
6
+
7
+ /** Resolves the daemon entry shipped with this package. */
8
+ export function daemonEntry() {
9
+ return fileURLToPath(import.meta.resolve("@opencode-cockpit/daemon/main"));
10
+ }
11
+
12
+ /**
13
+ * Inside OpenCode `process.execPath` is the OpenCode binary; the client starts the daemon with
14
+ * BUN_BE_BUN=1 so it runs on OpenCode's embedded Bun (ADR 0001). The expected build lets the
15
+ * client replace a daemon left running from older plugin code.
16
+ */
17
+ export function createClient(name) {
18
+ const entry = daemonEntry();
19
+ return new CockpitClient({
20
+ client: {
21
+ name,
22
+ version: pkg.version,
23
+ pid: process.pid
24
+ },
25
+ spawn: {
26
+ entry,
27
+ execPath: process.execPath
28
+ },
29
+ expectedBuild: daemonBuildId(entry, daemonPkg.version)
30
+ });
31
+ }
@@ -0,0 +1,97 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Settings, read from one file so they are written once instead of twice (OpenCode keeps agent and
7
+ * TUI plugins in separate configs). Precedence, lowest first:
8
+ *
9
+ * ~/.config/opencode-cockpit/config.json → <project>/.cockpit.json → plugin-entry options
10
+ *
11
+ * Everything is optional, and an unreadable or invalid file is ignored rather than fatal: a typo in
12
+ * a config should never stop shells from working.
13
+ */
14
+
15
+ export const CONFIG_FILE = "config.json";
16
+ export const PROJECT_FILE = ".cockpit.json";
17
+ export function globalConfigPath(env = process.env) {
18
+ const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? homedir(), ".config");
19
+ return join(base, "opencode-cockpit", CONFIG_FILE);
20
+ }
21
+
22
+ /** Reads and merges every source. `options` is the plugin entry's own options object. */
23
+ export function loadConfig(directory, options, env = process.env) {
24
+ return mergeConfig(mergeConfig(readConfigFile(globalConfigPath(env)), readConfigFile(join(directory, PROJECT_FILE))), asConfig(options));
25
+ }
26
+ export function readConfigFile(path) {
27
+ if (!existsSync(path)) return {};
28
+ try {
29
+ return asConfig(JSON.parse(readFileSync(path, "utf8")));
30
+ } catch {
31
+ return {}; // a broken config must not take shells down with it
32
+ }
33
+ }
34
+
35
+ /** Section-wise merge: later sources win key by key, and never lose a whole section. */
36
+ export function mergeConfig(base, over) {
37
+ return {
38
+ ...base,
39
+ ...over,
40
+ watch: {
41
+ ...base.watch,
42
+ ...over.watch,
43
+ presets: {
44
+ ...base.watch?.presets,
45
+ ...over.watch?.presets
46
+ }
47
+ },
48
+ kinds: {
49
+ ...base.kinds,
50
+ ...over.kinds
51
+ },
52
+ defaults: {
53
+ ...base.defaults,
54
+ ...over.defaults
55
+ },
56
+ notify: {
57
+ ...base.notify,
58
+ ...over.notify
59
+ },
60
+ ui: {
61
+ ...base.ui,
62
+ ...over.ui,
63
+ keybinds: {
64
+ ...base.ui?.keybinds,
65
+ ...over.ui?.keybinds
66
+ }
67
+ }
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Plugin-entry options were flat before the config file existed (`{ dockHeight: 16 }`), so those
73
+ * keys still work and are read as `ui`.
74
+ */
75
+ function asConfig(input) {
76
+ if (!input || typeof input !== "object") return {};
77
+ const raw = input;
78
+ const config = {};
79
+ for (const key of ["watch", "kinds", "defaults", "notify", "ui"]) {
80
+ const value = raw[key];
81
+ if (value && typeof value === "object") Object.assign(config, {
82
+ [key]: value
83
+ });
84
+ }
85
+ if (typeof raw.guidance === "boolean") config.guidance = raw.guidance;
86
+ if (typeof raw.listRunningShells === "number") config.listRunningShells = raw.listRunningShells;
87
+ const legacy = {};
88
+ for (const key of ["dockHeight", "dockOpen", "sidebarRows", "historyMinutes", "updateCheck"]) {
89
+ if (raw[key] !== undefined) Object.assign(legacy, {
90
+ [key]: raw[key]
91
+ });
92
+ }
93
+ if (raw.keybinds && typeof raw.keybinds === "object") legacy.keybinds = raw.keybinds;
94
+ return Object.keys(legacy).length > 0 ? mergeConfig(config, {
95
+ ui: legacy
96
+ }) : config;
97
+ }
@@ -0,0 +1,69 @@
1
+ import { kindOfShell } from "./kind.js";
2
+
3
+ /** The command as written, without the `$SHELL -c` wrapper. */
4
+ export function commandOf(s) {
5
+ return s.args.length === 2 && s.args[0] === "-c" ? s.args[1] : [s.command, ...s.args].join(" ");
6
+ }
7
+ export function isFailed(s) {
8
+ return s.status === "failed" || s.status === "exited" && s.exitCode !== 0;
9
+ }
10
+ export function filterShells(list, filter) {
11
+ const query = filter.query?.trim().toLowerCase();
12
+ return list.filter(s => {
13
+ if (filter.kind && filter.kind !== "any" && kindOfShell(s, filter.kinds) !== filter.kind) return false;
14
+ if (query && !s.title.toLowerCase().includes(query) && !commandOf(s).toLowerCase().includes(query)) {
15
+ return false;
16
+ }
17
+ switch (filter.status ?? "any") {
18
+ case "running":
19
+ if (s.status !== "running") return false;
20
+ break;
21
+ case "failed":
22
+ if (!isFailed(s)) return false;
23
+ break;
24
+ case "finished":
25
+ if (s.status === "running") return false;
26
+ break;
27
+ }
28
+ switch (filter.session ?? "any") {
29
+ case "this":
30
+ return s.owner.session === filter.currentSession;
31
+ case "others":
32
+ return s.owner.session !== filter.currentSession;
33
+ default:
34
+ return true;
35
+ }
36
+ });
37
+ }
38
+ /**
39
+ * Finds the shell a name refers to. Exact names (ignoring case) beat partial matches on name or
40
+ * command. When several match, a single running shell is the obvious intent (earlier finished
41
+ * shells with the same name are history); otherwise the caller must choose.
42
+ */
43
+ export function matchByName(list, name) {
44
+ const wanted = name.trim().toLowerCase();
45
+ const exact = list.filter(s => s.title.trim().toLowerCase() === wanted);
46
+ const matches = exact.length > 0 ? exact : list.filter(s => s.title.toLowerCase().includes(wanted) || commandOf(s).toLowerCase().includes(wanted));
47
+ if (matches.length === 0) return {
48
+ kind: "none",
49
+ available: [...list]
50
+ };
51
+ if (matches.length === 1) return {
52
+ kind: "found",
53
+ shell: matches[0],
54
+ alsoMatched: []
55
+ };
56
+ const running = matches.filter(s => s.status === "running");
57
+ if (running.length === 1) {
58
+ const shell = running[0];
59
+ return {
60
+ kind: "found",
61
+ shell,
62
+ alsoMatched: matches.filter(s => s !== shell)
63
+ };
64
+ }
65
+ return {
66
+ kind: "ambiguous",
67
+ candidates: matches
68
+ };
69
+ }