@opencode-cockpit/shell 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -10
- package/dist/agent/plugin.js +180 -0
- package/dist/agent/tools/index.js +23 -0
- package/dist/agent/tools/list.js +68 -0
- package/dist/agent/tools/read.js +59 -0
- package/dist/agent/tools/restart.js +44 -0
- package/dist/agent/tools/send.js +71 -0
- package/dist/agent/tools/shared.js +94 -0
- package/dist/agent/tools/start.js +140 -0
- package/dist/agent/tools/stop.js +42 -0
- package/dist/agent/tools/wait.js +79 -0
- package/dist/agent/tools/watch-args.js +12 -0
- package/dist/agent/tools/watch.js +74 -0
- package/dist/core/config.js +97 -0
- package/dist/{tools → core}/find.js +3 -0
- package/dist/core/kind.js +31 -0
- package/dist/server.js +2 -152
- package/dist/tui/{badge.js → components/badge.js} +1 -1
- package/dist/tui/{console.js → components/console.js} +270 -139
- package/dist/tui/{dock.js → components/dock.js} +75 -19
- package/dist/tui/{sidebar.js → components/sidebar.js} +16 -1
- package/dist/tui/dialogs.js +163 -0
- package/dist/tui/index.js +35 -94
- package/dist/tui/lib/details.js +23 -0
- package/dist/tui/lib/search.js +39 -0
- package/dist/tui/lib/update.js +58 -0
- package/dist/tui/{view.js → lib/view.js} +38 -1
- package/dist/tui/{store.js → state/store.js} +4 -2
- package/package.json +4 -5
- package/types/agent/plugin.d.ts +12 -0
- package/types/agent/tools/index.d.ts +5 -0
- package/types/agent/tools/list.d.ts +3 -0
- package/types/agent/tools/read.d.ts +3 -0
- package/types/agent/tools/restart.d.ts +3 -0
- package/types/agent/tools/send.d.ts +3 -0
- package/types/agent/tools/shared.d.ts +40 -0
- package/types/agent/tools/start.d.ts +3 -0
- package/types/agent/tools/stop.d.ts +3 -0
- package/types/agent/tools/wait.d.ts +3 -0
- package/types/agent/tools/watch-args.d.ts +10 -0
- package/types/agent/tools/watch.d.ts +3 -0
- package/types/core/config.d.ts +59 -0
- package/types/{tools → core}/find.d.ts +5 -0
- package/types/core/kind.d.ts +9 -0
- package/types/server.d.ts +2 -12
- package/types/tui/{console.d.ts → components/console.d.ts} +7 -1
- package/types/tui/{dock.d.ts → components/dock.d.ts} +3 -1
- package/types/tui/{sidebar.d.ts → components/sidebar.d.ts} +1 -1
- package/types/tui/dialogs.d.ts +10 -0
- package/types/tui/index.d.ts +3 -9
- package/types/tui/lib/details.d.ts +3 -0
- package/types/tui/lib/search.d.ts +7 -0
- package/types/tui/lib/update.d.ts +25 -0
- package/types/tui/{view.d.ts → lib/view.d.ts} +14 -1
- package/types/tui/{store.d.ts → state/store.d.ts} +9 -0
- package/dist/tools/index.js +0 -433
- package/types/tools/index.d.ts +0 -17
- /package/dist/{tools → agent/tools}/keys.js +0 -0
- /package/dist/{tools → core}/format.js +0 -0
- /package/dist/tui/{keys.js → lib/keys.js} +0 -0
- /package/types/{tools → agent/tools}/keys.d.ts +0 -0
- /package/types/{tools → core}/format.d.ts +0 -0
- /package/types/tui/{badge.d.ts → components/badge.d.ts} +0 -0
- /package/types/tui/{keys.d.ts → lib/keys.d.ts} +0 -0
|
@@ -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,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
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { kindOfShell } from "./kind.js";
|
|
2
|
+
|
|
1
3
|
/** The command as written, without the `$SHELL -c` wrapper. */
|
|
2
4
|
export function commandOf(s) {
|
|
3
5
|
return s.args.length === 2 && s.args[0] === "-c" ? s.args[1] : [s.command, ...s.args].join(" ");
|
|
@@ -8,6 +10,7 @@ export function isFailed(s) {
|
|
|
8
10
|
export function filterShells(list, filter) {
|
|
9
11
|
const query = filter.query?.trim().toLowerCase();
|
|
10
12
|
return list.filter(s => {
|
|
13
|
+
if (filter.kind && filter.kind !== "any" && kindOfShell(s, filter.kinds) !== filter.kind) return false;
|
|
11
14
|
if (query && !s.title.toLowerCase().includes(query) && !commandOf(s).toLowerCase().includes(query)) {
|
|
12
15
|
return false;
|
|
13
16
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { commandOf } from "./find.js";
|
|
2
|
+
|
|
3
|
+
/** What a shell is, derived from its command — never a label anyone has to maintain by hand. */
|
|
4
|
+
|
|
5
|
+
const TESTS = /\b(test|tests|vitest|jest|mocha|pytest|rspec|phpunit|playwright|cypress|karma)\b/i;
|
|
6
|
+
const SERVER = /\b(dev|serve|server|start|preview|nodemon|uvicorn|gunicorn|rails s|php -S|docker[\s-]compose\s+up|ngrok|tunnel)\b/i;
|
|
7
|
+
const WATCHER = /(--watch|\bwatch\b|watchexec|fswatch|chokidar|\btsc\s+-w\b|--hot|\bentr\b)/i;
|
|
8
|
+
const BUILD = /\b(build|assemble|compile|bundle|tsc|webpack|rollup|esbuild|tsup|make|gradlew?|mvn|cargo build)\b/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Order matters: a test runner in watch mode is still about tests, and `vite build` is a build even
|
|
12
|
+
* though `vite` usually serves.
|
|
13
|
+
*/
|
|
14
|
+
export function classify(command, custom) {
|
|
15
|
+
// Config patterns win: only the user knows their own toolchain.
|
|
16
|
+
for (const [name, pattern] of Object.entries(custom ?? {})) {
|
|
17
|
+
try {
|
|
18
|
+
if (new RegExp(pattern, "i").test(command)) return name;
|
|
19
|
+
} catch {
|
|
20
|
+
// a bad pattern in config is ignored, like any other unusable setting
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (TESTS.test(command)) return "tests";
|
|
24
|
+
if (BUILD.test(command) && !SERVER.test(command)) return "build";
|
|
25
|
+
if (SERVER.test(command)) return "server";
|
|
26
|
+
if (WATCHER.test(command)) return "watcher";
|
|
27
|
+
return "task";
|
|
28
|
+
}
|
|
29
|
+
export function kindOfShell(s, custom) {
|
|
30
|
+
return classify(commandOf(s), custom);
|
|
31
|
+
}
|