@opencode-cockpit/shell 0.1.5 → 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 +105 -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 +152 -0
- package/dist/agent/tools/stop.js +42 -0
- package/dist/agent/tools/wait.js +79 -0
- package/dist/agent/tools/watch-args.js +60 -0
- package/dist/agent/tools/watch.js +72 -0
- package/dist/core/config.js +97 -0
- package/dist/{tools → core}/find.js +3 -0
- package/dist/{tools → core}/format.js +27 -2
- 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} +55 -2
- 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 +18 -0
- package/types/agent/tools/watch.d.ts +3 -0
- package/types/core/config.d.ts +60 -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} +16 -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/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,152 @@
|
|
|
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(), 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" }.'),
|
|
58
|
+
timeoutSeconds: z.number().int().positive().optional().describe("Stop the process after this long, busy or not. Good for bounded jobs and probes."),
|
|
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."),
|
|
60
|
+
logFile: z.boolean().default(false).describe("Also write the clean log to a file, so old lines survive the in-memory buffer")
|
|
61
|
+
},
|
|
62
|
+
async execute(args, ctx) {
|
|
63
|
+
await askPermission(ctx, args.command);
|
|
64
|
+
// Config supplies what the call left out; an explicit argument always wins.
|
|
65
|
+
const defaults = config.defaults ?? {};
|
|
66
|
+
const logFile = args.logFile ?? defaults.logFile ?? false;
|
|
67
|
+
const notifyOnExit = args.notifyOnExit ?? defaults.notifyOnExit ?? true;
|
|
68
|
+
const watch = args.watch ?? defaults.watch ?? (config.watch?.auto ? "auto" : undefined);
|
|
69
|
+
const shell = deps.shellCommand(args.command);
|
|
70
|
+
const info = await client.call("shell.start", {
|
|
71
|
+
command: shell.command,
|
|
72
|
+
args: shell.args,
|
|
73
|
+
cwd: args.workdir || ctx.directory,
|
|
74
|
+
env: {
|
|
75
|
+
...deps.env(),
|
|
76
|
+
...args.env
|
|
77
|
+
},
|
|
78
|
+
title: args.description,
|
|
79
|
+
owner: {
|
|
80
|
+
project: ctx.directory,
|
|
81
|
+
session: ctx.sessionID,
|
|
82
|
+
instance: deps.instance
|
|
83
|
+
},
|
|
84
|
+
timeoutMs: seconds(args.timeoutSeconds ?? defaults.timeoutSeconds),
|
|
85
|
+
idleTimeoutMs: seconds(args.idleTimeoutSeconds ?? defaults.idleTimeoutSeconds),
|
|
86
|
+
logFile: logFile === true,
|
|
87
|
+
reuse: true
|
|
88
|
+
});
|
|
89
|
+
if (notifyOnExit === false) deps.quiet.add(info.id);else deps.quiet.delete(info.id);
|
|
90
|
+
ctx.metadata({
|
|
91
|
+
title: args.description,
|
|
92
|
+
metadata: {
|
|
93
|
+
shellId: info.id,
|
|
94
|
+
command: args.command
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (info.status === "failed") return `${header(info)}\n${describeStatus(info)}\n</shell>`;
|
|
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}`];
|
|
99
|
+
if (watch) {
|
|
100
|
+
await client.call("shell.watch", {
|
|
101
|
+
id: info.id,
|
|
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)}`));
|
|
104
|
+
}
|
|
105
|
+
if (args.waitFor) {
|
|
106
|
+
const {
|
|
107
|
+
timeoutSeconds,
|
|
108
|
+
idleSeconds,
|
|
109
|
+
...rest
|
|
110
|
+
} = args.waitFor;
|
|
111
|
+
const result = await abortable(ctx, client.call("shell.wait", {
|
|
112
|
+
id: info.id,
|
|
113
|
+
until: {
|
|
114
|
+
pattern: rest.pattern ?? undefined,
|
|
115
|
+
port: rest.port ?? undefined,
|
|
116
|
+
exit: rest.exit ?? undefined,
|
|
117
|
+
idleMs: idleSeconds ? Math.round(idleSeconds * 1000) : undefined
|
|
118
|
+
},
|
|
119
|
+
timeoutMs: Math.round((timeoutSeconds ?? 120) * 1000)
|
|
120
|
+
})).catch(err => {
|
|
121
|
+
lines.push(`wait failed: ${err instanceof Error ? err.message : String(err)} (the shell is still running)`);
|
|
122
|
+
return undefined;
|
|
123
|
+
});
|
|
124
|
+
if (result) lines.push(formatWait(result, timeoutSeconds ?? 120));
|
|
125
|
+
} else {
|
|
126
|
+
await abortable(ctx, client.call("shell.wait", {
|
|
127
|
+
id: info.id,
|
|
128
|
+
until: {
|
|
129
|
+
idleMs: 700,
|
|
130
|
+
exit: true
|
|
131
|
+
},
|
|
132
|
+
timeoutMs: 2500
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
if (info.logFile) lines.push(`log file: ${info.logFile}`);
|
|
136
|
+
lines.push(await peek(info));
|
|
137
|
+
return lines.join("\n");
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
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
|
+
|
|
149
|
+
/** Milliseconds from an option in seconds, or undefined when unset. */
|
|
150
|
+
function seconds(value) {
|
|
151
|
+
return value ? Math.round(value * 1000) : undefined;
|
|
152
|
+
}
|
|
@@ -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,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
|
+
|
|
4
|
+
/**
|
|
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.
|
|
8
|
+
*/
|
|
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);
|
|
39
|
+
const custom = config.watch?.presets?.[preset];
|
|
40
|
+
return custom ? {
|
|
41
|
+
rule: custom
|
|
42
|
+
} : {
|
|
43
|
+
preset
|
|
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;
|
|
60
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { asWatchRule, 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, 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.
|
|
13
|
+
- rule: your own patterns when no preset fits: done (a run ended), fail, ok, idleSeconds.
|
|
14
|
+
- off: stop watching.
|
|
15
|
+
|
|
16
|
+
A watched process that dies is reported as a failure, so a crashed dev server no longer goes
|
|
17
|
+
unnoticed.`;
|
|
18
|
+
const z = tool.schema;
|
|
19
|
+
/**
|
|
20
|
+
* Every per-shell tool takes an id or a name. Declared per tool file because exported zod schemas
|
|
21
|
+
* cannot be named portably in generated declarations.
|
|
22
|
+
*/
|
|
23
|
+
const TARGET = {
|
|
24
|
+
id: z.string().optional().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34"),
|
|
25
|
+
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.')
|
|
26
|
+
};
|
|
27
|
+
export function shellWatch(kit) {
|
|
28
|
+
const {
|
|
29
|
+
client,
|
|
30
|
+
config,
|
|
31
|
+
resolve
|
|
32
|
+
} = kit;
|
|
33
|
+
// Presets from config are worth advertising: the agent cannot guess a name it has never seen.
|
|
34
|
+
const named = Object.keys(config.watch?.presets ?? {});
|
|
35
|
+
return tool({
|
|
36
|
+
description: named.length > 0 ? `${WATCH}\n\nPresets from this project: ${named.join(", ")}.` : WATCH,
|
|
37
|
+
args: {
|
|
38
|
+
...TARGET,
|
|
39
|
+
preset: z.string().optional().describe('Preset name, or "auto" to pick one from the command'),
|
|
40
|
+
rule: z.object({
|
|
41
|
+
done: z.string().optional().describe("A run finished, e.g. 'Found \\d+ errors'"),
|
|
42
|
+
fail: z.string().optional(),
|
|
43
|
+
ok: z.string().optional(),
|
|
44
|
+
ignoreCase: z.boolean().optional(),
|
|
45
|
+
idleSeconds: z.number().positive().optional().describe("Without `done`: treat this much silence as the end of a run")
|
|
46
|
+
}).optional().describe("Custom patterns; use when no preset fits"),
|
|
47
|
+
off: z.boolean().default(false).describe("Stop watching this shell")
|
|
48
|
+
},
|
|
49
|
+
async execute(args, ctx) {
|
|
50
|
+
const {
|
|
51
|
+
id,
|
|
52
|
+
note
|
|
53
|
+
} = await resolve(args, ctx);
|
|
54
|
+
if (args.off === true) {
|
|
55
|
+
const stopped = await client.call("shell.unwatch", {
|
|
56
|
+
id
|
|
57
|
+
});
|
|
58
|
+
return `${note}stopped watching ${stopped.id}`;
|
|
59
|
+
}
|
|
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.
|
|
62
|
+
const chosen = args.preset ? watchArgs(args.preset, config) : {};
|
|
63
|
+
const rule = asWatchRule(args.rule) ?? chosen.rule;
|
|
64
|
+
const info = await client.call("shell.watch", {
|
|
65
|
+
id,
|
|
66
|
+
preset: rule ? undefined : chosen.preset,
|
|
67
|
+
rule
|
|
68
|
+
});
|
|
69
|
+
return `${note}watching ${info.id} (${info.watch?.preset ?? "custom rule"}). You will be messaged when its health changes; no need to poll.`;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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 ${
|
|
24
|
+
return info.exitCode === 0 ? `exited cleanly after ${ran()}` : `crashed with exit code ${info.exitCode ?? "?"} after ${ran()}`;
|
|
24
25
|
case "killed":
|
|
25
|
-
return
|
|
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}>`;
|