opencode-cockpit 0.1.1 → 0.1.3

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.
@@ -1,382 +0,0 @@
1
- import { type ToolContext, type ToolDefinition, tool } from "@opencode-ai/plugin"
2
- import type { CockpitClient } from "@opencode-cockpit/client"
3
- import { RpcError } from "@opencode-cockpit/protocol"
4
- import type { ShellInfo } from "@opencode-cockpit/protocol/shell"
5
- import { describeStatus, formatLines, formatRead, formatWait, header } from "./format.ts"
6
- import { encodeKey, KEY_NAMES } from "./keys.ts"
7
-
8
- export interface ToolDeps {
9
- client: CockpitClient
10
- /** Identifies this OpenCode instance so only it notifies the owning session. */
11
- instance: string
12
- /** Shells whose exit should not message the agent (it stopped them itself, or opted out). */
13
- quiet: Set<string>
14
- shellCommand(command: string): { command: string; args: string[] }
15
- env(): Record<string, string>
16
- }
17
-
18
- const z = tool.schema
19
- const ID = z.string().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34")
20
-
21
- const START = `Start a command in a background terminal (PTY) that keeps running while you continue working.
22
-
23
- Use this instead of bash for anything long-running or interactive:
24
- - dev servers, watchers (tsc --watch, vitest), local APIs, databases, tunnels
25
- - builds or test suites that take more than ~30 seconds
26
- - REPLs and prompts that need input later (use shell_send)
27
-
28
- Do not append "&" or use nohup; the shell already runs in the background.
29
-
30
- Readiness: pass waitFor to block until the process is actually ready, for example
31
- waitFor={ port: 3000 } for a dev server or waitFor={ pattern: "compiled successfully" }.
32
- Without waitFor the call returns after the first moment of quiet with the initial output.
33
-
34
- You are notified automatically when the process exits (disable with notifyOnExit=false). Never
35
- sleep and poll: use shell_wait to block on a condition, and shell_read(after=cursor) for new output.`
36
-
37
- const READ = `Read a background shell's output.
38
-
39
- - Default: the last lines of the log (colours removed, progress-bar redraws collapsed).
40
- - after=<cursor>: only lines newer than a cursor returned by a previous call. Use this to follow output.
41
- - grep=<regex>: only matching lines (e.g. "error|warn").
42
- - view="screen": what the terminal shows right now. Use for full-screen programs (htop, vitest UI, prompts that redraw).`
43
-
44
- const SEND = `Send input to a running background shell, then return the output it produced.
45
-
46
- - text: literal characters. Set submit=true to press enter afterwards.
47
- - keys: named keys pressed in order, e.g. ["ctrl+c"], ["down", "enter"]. Supported: ${KEY_NAMES.join(", ")}.`
48
-
49
- const WAIT = `Block until a condition holds in a background shell. This is the only correct way to wait:
50
- never sleep and poll.
51
-
52
- Conditions (combine freely; the first to happen wins, and the process exiting always ends the wait):
53
- - pattern: regex matched against output lines (also matches an unfinished prompt line)
54
- - port: something accepts TCP connections on this port
55
- - idleSeconds: no output for this long (often means waiting for input or finished a step)
56
- - exit: the process ends
57
-
58
- Pattern matching includes output produced before this call in the current run, so "wait until ready"
59
- succeeds immediately if it is already ready.`
60
-
61
- export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
62
- const { client } = deps
63
-
64
- const peek = async (info: ShellInfo, tail = 30) => {
65
- const current = await client.call("shell.get", { id: info.id })
66
- const page = await client.call("shell.read", { id: info.id, tail })
67
- return formatRead(current, page)
68
- }
69
-
70
- return {
71
- shell_start: tool({
72
- description: START,
73
- args: {
74
- command: z.string().min(1).describe("Command line, run by your shell (pipes, && and env vars work)"),
75
- description: z
76
- .string()
77
- .min(3)
78
- .describe("What this shell is for, 3-8 words, e.g. 'Next.js dev server'"),
79
- workdir: z.string().optional().describe("Working directory; defaults to the project directory"),
80
- env: z.record(z.string(), z.string()).optional().describe("Extra environment variables"),
81
- waitFor: z
82
- .object({
83
- pattern: z.string().optional(),
84
- port: z.number().int().min(1).max(65535).optional(),
85
- idleSeconds: z.number().positive().optional(),
86
- exit: z.boolean().optional(),
87
- timeoutSeconds: z.number().positive().max(3600).default(120),
88
- })
89
- .optional()
90
- .describe("Block until ready. Same conditions as shell_wait."),
91
- notifyOnExit: z.boolean().default(true).describe("Message you when the process exits"),
92
- timeoutSeconds: z
93
- .number()
94
- .int()
95
- .positive()
96
- .optional()
97
- .describe("Stop the process after this long. Only for commands expected to finish."),
98
- },
99
- async execute(args, ctx) {
100
- await askPermission(ctx, args.command)
101
- const shell = deps.shellCommand(args.command)
102
- const info = await client.call("shell.start", {
103
- command: shell.command,
104
- args: shell.args,
105
- cwd: args.workdir || ctx.directory,
106
- env: { ...deps.env(), ...args.env },
107
- title: args.description,
108
- owner: { project: ctx.directory, session: ctx.sessionID, instance: deps.instance },
109
- timeoutMs: args.timeoutSeconds ? Math.round(args.timeoutSeconds * 1000) : undefined,
110
- reuse: true,
111
- })
112
- if (args.notifyOnExit === false) deps.quiet.add(info.id)
113
- else deps.quiet.delete(info.id)
114
- ctx.metadata({ title: args.description, metadata: { shellId: info.id, command: args.command } })
115
- if (info.status === "failed") return `${header(info)}\n${describeStatus(info)}\n</shell>`
116
-
117
- const lines = [
118
- info.run > 1
119
- ? `Restarted ${info.id} (run ${info.run}): same command as an earlier finished shell in this session. Earlier output is above line ${info.lines.last}.`
120
- : `Started ${info.id}: ${args.command}`,
121
- ]
122
- if (args.waitFor) {
123
- const { timeoutSeconds, idleSeconds, ...rest } = args.waitFor
124
- const result = await abortable(
125
- ctx,
126
- client.call("shell.wait", {
127
- id: info.id,
128
- until: {
129
- pattern: rest.pattern ?? undefined,
130
- port: rest.port ?? undefined,
131
- exit: rest.exit ?? undefined,
132
- idleMs: idleSeconds ? Math.round(idleSeconds * 1000) : undefined,
133
- },
134
- timeoutMs: Math.round((timeoutSeconds ?? 120) * 1000),
135
- }),
136
- ).catch((err) => {
137
- lines.push(
138
- `wait failed: ${err instanceof Error ? err.message : String(err)} (the shell is still running)`,
139
- )
140
- return undefined
141
- })
142
- if (result) lines.push(formatWait(result, timeoutSeconds ?? 120))
143
- } else {
144
- await abortable(
145
- ctx,
146
- client.call("shell.wait", { id: info.id, until: { idleMs: 700, exit: true }, timeoutMs: 2500 }),
147
- )
148
- }
149
- lines.push(await peek(info))
150
- return lines.join("\n")
151
- },
152
- }),
153
-
154
- shell_read: tool({
155
- description: READ,
156
- args: {
157
- id: ID,
158
- view: z.enum(["log", "screen"]).default("log"),
159
- after: z
160
- .number()
161
- .int()
162
- .min(0)
163
- .optional()
164
- .describe("Cursor from a previous result; returns only newer lines"),
165
- tail: z
166
- .number()
167
- .int()
168
- .positive()
169
- .max(2000)
170
- .default(60)
171
- .describe("Lines from the end when no cursor is given"),
172
- grep: z.string().optional().describe("Regex filter"),
173
- ignoreCase: z.boolean().default(false),
174
- limit: z.number().int().positive().max(2000).default(300),
175
- },
176
- async execute(args) {
177
- const info = await client.call("shell.get", { id: args.id })
178
- if (args.view === "screen") {
179
- const screen = await client.call("shell.screen", { id: args.id })
180
- return [
181
- header(info),
182
- `status: ${describeStatus(info)}`,
183
- `screen ${screen.cols}x${screen.rows}:`,
184
- screen.text || "(blank)",
185
- "</shell>",
186
- ].join("\n")
187
- }
188
- const page = await client.call("shell.read", {
189
- id: args.id,
190
- after: args.after ?? undefined,
191
- tail: args.tail ?? 60,
192
- grep: args.grep ?? undefined,
193
- ignoreCase: args.ignoreCase ?? false,
194
- limit: args.limit ?? 300,
195
- })
196
- return formatRead(info, page, args.after !== undefined ? "(no new output)" : "(no output yet)")
197
- },
198
- }),
199
-
200
- shell_send: tool({
201
- description: SEND,
202
- args: {
203
- id: ID,
204
- text: z.string().optional(),
205
- keys: z.array(z.string()).optional(),
206
- submit: z.boolean().default(false).describe("Press enter after text"),
207
- waitSeconds: z.number().min(0).max(30).default(1).describe("Max time to collect the response"),
208
- },
209
- async execute(args, ctx) {
210
- if (!args.text && !args.keys?.length) throw new Error("provide text and/or keys")
211
- let data = args.text ?? ""
212
- for (const key of args.keys ?? []) data += encodeKey(key)
213
- if (args.submit === true) data += "\r"
214
- const before = await client.call("shell.get", { id: args.id })
215
- await client.call("shell.write", { id: args.id, data })
216
- const waitSeconds = args.waitSeconds ?? 1
217
- if (waitSeconds > 0) {
218
- await abortable(
219
- ctx,
220
- client.call("shell.wait", {
221
- id: args.id,
222
- until: { idleMs: 400, exit: true },
223
- timeoutMs: Math.round(waitSeconds * 1000),
224
- after: before.lines.last,
225
- }),
226
- )
227
- }
228
- const info = await client.call("shell.get", { id: args.id })
229
- const page = await client.call("shell.read", { id: args.id, after: before.lines.last, limit: 300 })
230
- return formatRead(
231
- info,
232
- page,
233
- "(no new output lines; if this is a full-screen program use shell_read view=screen)",
234
- )
235
- },
236
- }),
237
-
238
- shell_wait: tool({
239
- description: WAIT,
240
- args: {
241
- id: ID,
242
- pattern: z.string().optional(),
243
- ignoreCase: z.boolean().optional(),
244
- port: z.number().int().min(1).max(65535).optional(),
245
- host: z.string().optional(),
246
- idleSeconds: z.number().positive().optional(),
247
- exit: z.boolean().optional(),
248
- timeoutSeconds: z.number().positive().max(3600).default(300),
249
- },
250
- async execute(args, ctx) {
251
- const start = await client.call("shell.get", { id: args.id })
252
- const result = await abortable(
253
- ctx,
254
- client.call("shell.wait", {
255
- id: args.id,
256
- until: {
257
- pattern: args.pattern ?? undefined,
258
- ignoreCase: args.ignoreCase ?? undefined,
259
- port: args.port ?? undefined,
260
- host: args.host ?? undefined,
261
- exit: args.exit ?? undefined,
262
- idleMs: args.idleSeconds ? Math.round(args.idleSeconds * 1000) : undefined,
263
- },
264
- timeoutMs: Math.round((args.timeoutSeconds ?? 300) * 1000),
265
- }),
266
- )
267
- if (!result) return "wait cancelled"
268
- const newLines = result.info.lines.last - start.lines.last
269
- const page =
270
- newLines > 80
271
- ? await client.call("shell.read", { id: args.id, tail: 80 })
272
- : await client.call("shell.read", { id: args.id, after: start.lines.last, limit: 80 })
273
- const recent = page.lines
274
- if (newLines > 80)
275
- recent.unshift({
276
- n: start.lines.last,
277
- text: `… ${newLines - 80} earlier lines omitted (shell_read after=${start.lines.last})`,
278
- })
279
- return [
280
- formatWait(result, args.timeoutSeconds ?? 300),
281
- header(result.info),
282
- recent.length > 0 ? formatLines(recent) : "(no new output during the wait)",
283
- "</shell>",
284
- `cursor: ${result.info.lines.last}`,
285
- ].join("\n")
286
- },
287
- }),
288
-
289
- shell_list: tool({
290
- description: "List background shells for this project with status, uptime and last output line.",
291
- args: {
292
- all: z.boolean().default(false).describe("Include shells from other projects"),
293
- },
294
- async execute(args, ctx) {
295
- const shells = await client.call(
296
- "shell.list",
297
- args.all === true ? {} : { owner: { project: ctx.directory } },
298
- )
299
- if (shells.length === 0) return "No background shells."
300
- const rows = await Promise.all(
301
- shells.map(async (s) => {
302
- const last = await client.call("shell.read", { id: s.id, tail: 1 }).catch(() => undefined)
303
- const tail = last?.lines[0]?.text ?? ""
304
- const command =
305
- s.args[0] === "-c" && s.args.length === 2
306
- ? (s.args[1] as string)
307
- : [s.command, ...s.args].join(" ")
308
- const failure =
309
- s.summary && s.status !== "running" ? `\n summary: ${s.summary.slice(0, 200)}` : ""
310
- return `${s.id} ${s.status.padEnd(7)} ${s.title}${s.run > 1 ? ` (run ${s.run})` : ""}\n $ ${command.slice(0, 200)}${failure}\n ${describeStatus(s)}${tail ? `\n last: ${tail.slice(0, 200)}` : ""}`
311
- }),
312
- )
313
- return rows.join("\n")
314
- },
315
- }),
316
-
317
- shell_stop: tool({
318
- description:
319
- "Stop a background shell (SIGTERM to its whole process group, then SIGKILL after a grace period). Set remove=true to also forget it.",
320
- args: {
321
- id: ID,
322
- remove: z.boolean().default(false),
323
- force: z.boolean().default(false).describe("Send SIGKILL immediately"),
324
- },
325
- async execute(args) {
326
- deps.quiet.add(args.id)
327
- const info = await client.call("shell.stop", {
328
- id: args.id,
329
- signal: args.force === true ? "SIGKILL" : "SIGTERM",
330
- graceMs: 3000,
331
- })
332
- if (args.remove === true) await client.call("shell.remove", { id: args.id })
333
- return `${info.id} ${describeStatus(info)}${args.remove === true ? " and removed" : ""}`
334
- },
335
- }),
336
-
337
- shell_restart: tool({
338
- description:
339
- "Restart a background shell with the same command. Keeps the id; output continues after a restart marker.",
340
- args: { id: ID },
341
- async execute(args, ctx) {
342
- const info = await client.call("shell.restart", { id: args.id })
343
- deps.quiet.delete(args.id)
344
- await abortable(
345
- ctx,
346
- client.call("shell.wait", { id: info.id, until: { idleMs: 700, exit: true }, timeoutMs: 2500 }),
347
- )
348
- return `Restarted ${info.id} (run ${info.run})\n${await peek(info)}`
349
- },
350
- }),
351
- }
352
- }
353
-
354
- async function askPermission(ctx: ToolContext, command: string): Promise<void> {
355
- const words = command.trim().split(/\s+/)
356
- const prefix = words.slice(0, Math.min(2, words.length)).join(" ")
357
- await ctx.ask({
358
- permission: "bash",
359
- patterns: [command],
360
- always: [`${prefix} *`],
361
- metadata: { command, description: "background shell" },
362
- })
363
- }
364
-
365
- /** Resolves undefined when the tool call is aborted; the daemon keeps running the shell. */
366
- function abortable<T>(ctx: ToolContext, promise: Promise<T>): Promise<T | undefined> {
367
- if (ctx.abort.aborted) return Promise.resolve(undefined)
368
- return new Promise((resolve, reject) => {
369
- const onAbort = () => resolve(undefined)
370
- ctx.abort.addEventListener("abort", onAbort, { once: true })
371
- promise.then(
372
- (v) => {
373
- ctx.abort.removeEventListener("abort", onAbort)
374
- resolve(v)
375
- },
376
- (err) => {
377
- ctx.abort.removeEventListener("abort", onAbort)
378
- reject(err instanceof RpcError ? new Error(err.message) : err)
379
- },
380
- )
381
- })
382
- }
package/src/tools/keys.ts DELETED
@@ -1,33 +0,0 @@
1
- const NAMED: Record<string, string> = {
2
- enter: "\r",
3
- return: "\r",
4
- tab: "\t",
5
- "shift+tab": "\x1b[Z",
6
- escape: "\x1b",
7
- esc: "\x1b",
8
- backspace: "\x7f",
9
- delete: "\x1b[3~",
10
- space: " ",
11
- up: "\x1b[A",
12
- down: "\x1b[B",
13
- right: "\x1b[C",
14
- left: "\x1b[D",
15
- home: "\x1b[H",
16
- end: "\x1b[F",
17
- pageup: "\x1b[5~",
18
- pagedown: "\x1b[6~",
19
- }
20
-
21
- /** Translates named keys (`enter`, `ctrl+c`, `up`) into the bytes a terminal would send. */
22
- export function encodeKey(name: string): string {
23
- const key = name.trim().toLowerCase()
24
- const named = NAMED[key]
25
- if (named !== undefined) return named
26
- const ctrl = /^(?:ctrl|control|c)[+-]([a-z@[\\\]^_])$/.exec(key)
27
- if (ctrl?.[1]) return String.fromCharCode(ctrl[1].toUpperCase().charCodeAt(0) - 64)
28
- throw new Error(
29
- `unknown key "${name}". Use text for literal input, or one of: ${[...Object.keys(NAMED), "ctrl+<letter>"].join(", ")}`,
30
- )
31
- }
32
-
33
- export const KEY_NAMES = [...Object.keys(NAMED), "ctrl+<letter>"]
package/src/tui/badge.tsx DELETED
@@ -1,17 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
3
- import type { ShellInfo } from "@opencode-cockpit/protocol/shell"
4
- import { badgeText, kindColor, kindOf } from "./view.ts"
5
-
6
- /** Status pill: label on a coloured background, readable in any font and colour scheme. */
7
- export function Badge(props: { api: TuiPluginApi; shell: ShellInfo; frame: number }) {
8
- const theme = () => props.api.theme.current
9
- const kind = () => kindOf(props.shell)
10
- return (
11
- <text flexShrink={0} wrapMode="none">
12
- <span style={{ fg: theme().background, bg: kindColor(theme(), kind()) }}>
13
- <b>{badgeText(kind(), props.frame)}</b>
14
- </span>
15
- </text>
16
- )
17
- }