@opencode-cockpit/shell 0.1.3 → 0.1.5
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/dist/connect.js +31 -0
- package/dist/server.js +152 -0
- package/dist/tools/find.js +66 -0
- package/dist/tools/format.js +63 -0
- package/dist/tools/index.js +433 -0
- package/{src/tools/keys.ts → dist/tools/keys.js} +11 -14
- package/dist/tui/badge.js +29 -0
- package/dist/tui/console.js +628 -0
- package/dist/tui/dock.js +214 -0
- package/dist/tui/index.js +264 -0
- package/dist/tui/keys.js +43 -0
- package/dist/tui/sidebar.js +121 -0
- package/dist/tui/store.js +156 -0
- package/dist/tui/view.js +154 -0
- package/package.json +23 -13
- package/types/connect.d.ts +9 -0
- package/types/server.d.ts +12 -0
- package/types/tools/find.d.ts +32 -0
- package/types/tools/format.d.ts +8 -0
- package/types/tools/index.d.ts +17 -0
- package/types/tools/keys.d.ts +3 -0
- package/types/tui/badge.d.ts +9 -0
- package/types/tui/console.d.ts +16 -0
- package/types/tui/dock.d.ts +12 -0
- package/types/tui/index.d.ts +19 -0
- package/types/tui/keys.d.ts +8 -0
- package/types/tui/sidebar.d.ts +14 -0
- package/types/tui/store.d.ts +46 -0
- package/types/tui/view.d.ts +67 -0
- package/src/connect.ts +0 -24
- package/src/server.ts +0 -156
- package/src/tools/find.ts +0 -80
- package/src/tools/format.ts +0 -78
- package/src/tools/index.ts +0 -467
- package/src/tui/badge.tsx +0 -17
- package/src/tui/console.tsx +0 -438
- package/src/tui/dock.tsx +0 -130
- package/src/tui/index.tsx +0 -286
- package/src/tui/keys.ts +0 -52
- package/src/tui/sidebar.tsx +0 -44
- package/src/tui/store.ts +0 -185
- package/src/tui/view.ts +0 -161
package/src/connect.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
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(): string {
|
|
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: string): CockpitClient {
|
|
18
|
-
const entry = daemonEntry()
|
|
19
|
-
return new CockpitClient({
|
|
20
|
-
client: { name, version: pkg.version, pid: process.pid },
|
|
21
|
-
spawn: { entry, execPath: process.execPath },
|
|
22
|
-
expectedBuild: daemonBuildId(entry, daemonPkg.version),
|
|
23
|
-
})
|
|
24
|
-
}
|
package/src/server.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import type { Hooks, Plugin, PluginInput, PluginModule } from "@opencode-ai/plugin"
|
|
2
|
-
import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client"
|
|
3
|
-
import type { ShellInfo } from "@opencode-cockpit/protocol/shell"
|
|
4
|
-
import { createClient } from "./connect.ts"
|
|
5
|
-
import { describeStatus, formatLines } from "./tools/format.ts"
|
|
6
|
-
import { createTools } from "./tools/index.ts"
|
|
7
|
-
|
|
8
|
-
const GUIDANCE = `## Background shells (opencode-cockpit)
|
|
9
|
-
Long-running or interactive commands (dev servers, watchers, slow builds/tests, REPLs) go in shell_start, not bash with "&".
|
|
10
|
-
Block with shell_wait (pattern, port, idle, exit) instead of sleeping; follow output with shell_read(after=cursor).
|
|
11
|
-
You are messaged when a shell you started exits.`
|
|
12
|
-
|
|
13
|
-
export const SHELL_PACKAGE = "@opencode-cockpit/shell"
|
|
14
|
-
|
|
15
|
-
export interface ShellServerOptions {
|
|
16
|
-
/** Package that loaded Shell, reported when a duplicate copy is skipped. */
|
|
17
|
-
source?: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Shell's server half as a factory, so bundles such as `opencode-cockpit` can include it. */
|
|
21
|
-
export function createShellServer({ source = SHELL_PACKAGE }: ShellServerOptions = {}): Plugin {
|
|
22
|
-
return async (input) => {
|
|
23
|
-
const claim = claimFeature(input, "shell", source)
|
|
24
|
-
if (!claim.active) {
|
|
25
|
-
// Logging through the server during plugin initialisation could wait on ourselves; defer it.
|
|
26
|
-
setTimeout(() => {
|
|
27
|
-
void input.client.app
|
|
28
|
-
.log({
|
|
29
|
-
body: {
|
|
30
|
-
service: "opencode-cockpit",
|
|
31
|
-
level: "warn",
|
|
32
|
-
message: duplicateFeatureMessage("Shell", claim.owner, source),
|
|
33
|
-
},
|
|
34
|
-
})
|
|
35
|
-
.catch(() => {})
|
|
36
|
-
}, 0)
|
|
37
|
-
return {}
|
|
38
|
-
}
|
|
39
|
-
const hooks = await shellHooks(input)
|
|
40
|
-
const dispose = hooks.dispose
|
|
41
|
-
return {
|
|
42
|
-
...hooks,
|
|
43
|
-
dispose: async () => {
|
|
44
|
-
claim.release()
|
|
45
|
-
await dispose?.()
|
|
46
|
-
},
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function shellHooks({ client: opencode, directory }: PluginInput): Promise<Hooks> {
|
|
52
|
-
const cockpit = createClient("opencode-cockpit/server")
|
|
53
|
-
const instance = crypto.randomUUID()
|
|
54
|
-
const quiet = new Set<string>()
|
|
55
|
-
|
|
56
|
-
const userShell =
|
|
57
|
-
process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash"
|
|
58
|
-
const env = () => {
|
|
59
|
-
const out: Record<string, string> = {}
|
|
60
|
-
for (const [k, v] of Object.entries(process.env))
|
|
61
|
-
if (v !== undefined && !k.startsWith("OPENCODE_")) out[k] = v
|
|
62
|
-
return out
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Session titles rarely change; cache them so listing shells stays one round trip.
|
|
66
|
-
const titles = new Map<string, { title: string | undefined; at: number }>()
|
|
67
|
-
const sessionTitle = async (sessionID: string): Promise<string | undefined> => {
|
|
68
|
-
const hit = titles.get(sessionID)
|
|
69
|
-
if (hit && Date.now() - hit.at < 60_000) return hit.title
|
|
70
|
-
const result = await opencode.session.get({ path: { id: sessionID } }).catch(() => undefined)
|
|
71
|
-
const title = (result?.data as { title?: string } | undefined)?.title
|
|
72
|
-
titles.set(sessionID, { title, at: Date.now() })
|
|
73
|
-
return title
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Wake the agent when a shell it owns ends on its own.
|
|
77
|
-
cockpit.on("shell.exited", (info) => {
|
|
78
|
-
if (info.owner.instance !== instance || !info.owner.session) return
|
|
79
|
-
if (quiet.delete(info.id)) return
|
|
80
|
-
void notifyExit(info).catch(() => {})
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
async function notifyExit(info: ShellInfo): Promise<void> {
|
|
84
|
-
const session = info.owner.session as string
|
|
85
|
-
const page = await cockpit.call("shell.read", { id: info.id, tail: 15 })
|
|
86
|
-
const failed =
|
|
87
|
-
info.status === "failed" ||
|
|
88
|
-
(info.status === "exited" && info.exitCode !== 0) ||
|
|
89
|
-
info.status === "killed"
|
|
90
|
-
const text = [
|
|
91
|
-
`<shell_exited id="${info.id}" title="${info.title}">`,
|
|
92
|
-
describeStatus(info),
|
|
93
|
-
page.lines.length > 0 ? `last output:\n${formatLines(page.lines)}` : "(no output)",
|
|
94
|
-
"</shell_exited>",
|
|
95
|
-
failed
|
|
96
|
-
? `Investigate with shell_read id=${info.id} grep="error|fail" if the failure matters to the task.`
|
|
97
|
-
: `Full output: shell_read id=${info.id}.`,
|
|
98
|
-
].join("\n")
|
|
99
|
-
await opencode.session.promptAsync({
|
|
100
|
-
path: { id: session },
|
|
101
|
-
body: { parts: [{ type: "text", text, synthetic: true } as never] },
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
tool: createTools({
|
|
107
|
-
client: cockpit,
|
|
108
|
-
instance,
|
|
109
|
-
quiet,
|
|
110
|
-
env,
|
|
111
|
-
sessionTitle,
|
|
112
|
-
shellCommand: (command) => ({ command: userShell, args: ["-c", command] }),
|
|
113
|
-
}),
|
|
114
|
-
|
|
115
|
-
"experimental.chat.system.transform": async (input, output) => {
|
|
116
|
-
output.system.push(GUIDANCE)
|
|
117
|
-
const running = await cockpit
|
|
118
|
-
.call("shell.list", { owner: { project: directory }, includeExited: false })
|
|
119
|
-
.catch(() => [] as ShellInfo[])
|
|
120
|
-
if (running.length > 0) {
|
|
121
|
-
output.system.push(
|
|
122
|
-
`Background shells currently running in this project:\n${running
|
|
123
|
-
.slice(0, 15)
|
|
124
|
-
.map((s) => {
|
|
125
|
-
const from = !s.owner.session
|
|
126
|
-
? ", started by the user"
|
|
127
|
-
: s.owner.session === input.sessionID
|
|
128
|
-
? ""
|
|
129
|
-
: ", another session"
|
|
130
|
-
return `- ${s.id} "${s.title}" (${describeStatus(s)}${from})`
|
|
131
|
-
})
|
|
132
|
-
.join("\n")}`,
|
|
133
|
-
)
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
|
|
137
|
-
event: async ({ event }) => {
|
|
138
|
-
if (event.type !== "session.deleted") return
|
|
139
|
-
const sessionID = event.properties.info.id
|
|
140
|
-
const owned = await cockpit
|
|
141
|
-
.call("shell.list", { owner: { session: sessionID } })
|
|
142
|
-
.catch(() => [] as ShellInfo[])
|
|
143
|
-
for (const shell of owned) {
|
|
144
|
-
quiet.add(shell.id)
|
|
145
|
-
await cockpit.call("shell.remove", { id: shell.id }).catch(() => {})
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
|
|
149
|
-
dispose: async () => {
|
|
150
|
-
cockpit.close()
|
|
151
|
-
},
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const plugin: PluginModule & { id: string } = { id: "opencode-cockpit.shell", server: createShellServer() }
|
|
156
|
-
export default plugin
|
package/src/tools/find.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import type { ShellInfo } from "@opencode-cockpit/protocol/shell"
|
|
2
|
-
|
|
3
|
-
/** The command as written, without the `$SHELL -c` wrapper. */
|
|
4
|
-
export function commandOf(s: ShellInfo): string {
|
|
5
|
-
return s.args.length === 2 && s.args[0] === "-c" ? (s.args[1] as string) : [s.command, ...s.args].join(" ")
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export type StatusFilter = "running" | "failed" | "finished" | "any"
|
|
9
|
-
export type SessionFilter = "this" | "others" | "any"
|
|
10
|
-
|
|
11
|
-
export interface ShellFilter {
|
|
12
|
-
/** Case-insensitive text found in the name or the command. */
|
|
13
|
-
query?: string
|
|
14
|
-
status?: StatusFilter
|
|
15
|
-
session?: SessionFilter
|
|
16
|
-
/** The asking agent's session, for `session` filtering. */
|
|
17
|
-
currentSession?: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function isFailed(s: ShellInfo): boolean {
|
|
21
|
-
return s.status === "failed" || (s.status === "exited" && s.exitCode !== 0)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function filterShells(list: readonly ShellInfo[], filter: ShellFilter): ShellInfo[] {
|
|
25
|
-
const query = filter.query?.trim().toLowerCase()
|
|
26
|
-
return list.filter((s) => {
|
|
27
|
-
if (query && !s.title.toLowerCase().includes(query) && !commandOf(s).toLowerCase().includes(query)) {
|
|
28
|
-
return false
|
|
29
|
-
}
|
|
30
|
-
switch (filter.status ?? "any") {
|
|
31
|
-
case "running":
|
|
32
|
-
if (s.status !== "running") return false
|
|
33
|
-
break
|
|
34
|
-
case "failed":
|
|
35
|
-
if (!isFailed(s)) return false
|
|
36
|
-
break
|
|
37
|
-
case "finished":
|
|
38
|
-
if (s.status === "running") return false
|
|
39
|
-
break
|
|
40
|
-
}
|
|
41
|
-
switch (filter.session ?? "any") {
|
|
42
|
-
case "this":
|
|
43
|
-
return s.owner.session === filter.currentSession
|
|
44
|
-
case "others":
|
|
45
|
-
return s.owner.session !== filter.currentSession
|
|
46
|
-
default:
|
|
47
|
-
return true
|
|
48
|
-
}
|
|
49
|
-
})
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export type NameMatch =
|
|
53
|
-
| { kind: "found"; shell: ShellInfo; alsoMatched: ShellInfo[] }
|
|
54
|
-
| { kind: "ambiguous"; candidates: ShellInfo[] }
|
|
55
|
-
| { kind: "none"; available: ShellInfo[] }
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Finds the shell a name refers to. Exact names (ignoring case) beat partial matches on name or
|
|
59
|
-
* command. When several match, a single running shell is the obvious intent (earlier finished
|
|
60
|
-
* shells with the same name are history); otherwise the caller must choose.
|
|
61
|
-
*/
|
|
62
|
-
export function matchByName(list: readonly ShellInfo[], name: string): NameMatch {
|
|
63
|
-
const wanted = name.trim().toLowerCase()
|
|
64
|
-
const exact = list.filter((s) => s.title.trim().toLowerCase() === wanted)
|
|
65
|
-
const matches =
|
|
66
|
-
exact.length > 0
|
|
67
|
-
? exact
|
|
68
|
-
: list.filter(
|
|
69
|
-
(s) => s.title.toLowerCase().includes(wanted) || commandOf(s).toLowerCase().includes(wanted),
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
if (matches.length === 0) return { kind: "none", available: [...list] }
|
|
73
|
-
if (matches.length === 1) return { kind: "found", shell: matches[0] as ShellInfo, alsoMatched: [] }
|
|
74
|
-
const running = matches.filter((s) => s.status === "running")
|
|
75
|
-
if (running.length === 1) {
|
|
76
|
-
const shell = running[0] as ShellInfo
|
|
77
|
-
return { kind: "found", shell, alsoMatched: matches.filter((s) => s !== shell) }
|
|
78
|
-
}
|
|
79
|
-
return { kind: "ambiguous", candidates: matches }
|
|
80
|
-
}
|
package/src/tools/format.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import type { LogLine, ReadResult, ShellInfo, WaitResult } from "@opencode-cockpit/protocol/shell"
|
|
2
|
-
|
|
3
|
-
const MAX_LINE = 2000
|
|
4
|
-
|
|
5
|
-
/** Compact log lines for a model: numbered, long lines cut, consecutive repeats collapsed. */
|
|
6
|
-
export function formatLines(lines: LogLine[]): string {
|
|
7
|
-
const out: string[] = []
|
|
8
|
-
let i = 0
|
|
9
|
-
while (i < lines.length) {
|
|
10
|
-
const line = lines[i] as LogLine
|
|
11
|
-
let j = i + 1
|
|
12
|
-
while (j < lines.length && (lines[j] as LogLine).text === line.text) j++
|
|
13
|
-
const repeats = j - i
|
|
14
|
-
const text =
|
|
15
|
-
line.text.length > MAX_LINE
|
|
16
|
-
? `${line.text.slice(0, MAX_LINE)}… [${line.text.length - MAX_LINE} chars cut]`
|
|
17
|
-
: line.text
|
|
18
|
-
out.push(
|
|
19
|
-
repeats > 1
|
|
20
|
-
? `${line.n}| ${text} (×${repeats}, lines ${line.n}-${line.n + repeats - 1})`
|
|
21
|
-
: `${line.n}| ${text}`,
|
|
22
|
-
)
|
|
23
|
-
i = j
|
|
24
|
-
}
|
|
25
|
-
return out.join("\n")
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function describeStatus(info: ShellInfo): string {
|
|
29
|
-
switch (info.status) {
|
|
30
|
-
case "running":
|
|
31
|
-
return `running (pid ${info.pid}, up ${duration(Date.now() - info.startedAt)})`
|
|
32
|
-
case "exited":
|
|
33
|
-
return `exited with code ${info.exitCode ?? "?"} after ${duration((info.endedAt ?? Date.now()) - info.startedAt)}`
|
|
34
|
-
case "killed":
|
|
35
|
-
return `killed${info.signal ? ` by ${info.signal}` : ""} after ${duration((info.endedAt ?? Date.now()) - info.startedAt)}`
|
|
36
|
-
case "failed":
|
|
37
|
-
return `failed to start: ${info.error ?? "unknown error"}`
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function header(info: ShellInfo): string {
|
|
42
|
-
const run = info.run > 1 ? ` run=${info.run}` : ""
|
|
43
|
-
return `<shell id="${info.id}" title="${info.title.replaceAll('"', "'")}" status="${info.status}"${run}>`
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function formatRead(info: ShellInfo, page: ReadResult, empty = "(no output yet)"): string {
|
|
47
|
-
const parts = [header(info), `status: ${describeStatus(info)}`]
|
|
48
|
-
if (page.truncated)
|
|
49
|
-
parts.push(`(older lines were dropped from the buffer; oldest kept is ${page.firstLine})`)
|
|
50
|
-
parts.push(page.lines.length > 0 ? formatLines(page.lines) : empty)
|
|
51
|
-
if (page.hasMore) parts.push(`(more lines available: call shell_read with after=${page.nextCursor})`)
|
|
52
|
-
parts.push("</shell>", `cursor: ${page.nextCursor}`)
|
|
53
|
-
return parts.join("\n")
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function formatWait(result: WaitResult, timeoutSeconds: number): string {
|
|
57
|
-
switch (result.reason) {
|
|
58
|
-
case "pattern":
|
|
59
|
-
return `condition met: pattern matched on line ${result.match?.n}: ${result.match?.text}`
|
|
60
|
-
case "port":
|
|
61
|
-
return "condition met: port is accepting connections"
|
|
62
|
-
case "idle":
|
|
63
|
-
return "condition met: no output for the idle window (the program may be waiting for input)"
|
|
64
|
-
case "exit":
|
|
65
|
-
return `process ended: ${describeStatus(result.info)}`
|
|
66
|
-
case "timeout":
|
|
67
|
-
return `timed out after ${timeoutSeconds}s without the condition being met; the shell is still ${result.info.status}`
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function duration(ms: number): string {
|
|
72
|
-
const s = Math.max(0, Math.round(ms / 1000))
|
|
73
|
-
if (s < 60) return `${s}s`
|
|
74
|
-
const m = Math.floor(s / 60)
|
|
75
|
-
if (m < 60) return `${m}m${s % 60 ? `${s % 60}s` : ""}`
|
|
76
|
-
const h = Math.floor(m / 60)
|
|
77
|
-
return `${h}h${m % 60 ? `${m % 60}m` : ""}`
|
|
78
|
-
}
|