@opencode-cockpit/shell 0.1.2 → 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.
- package/README.md +11 -1
- package/package.json +8 -28
- package/src/server.ts +21 -2
- package/src/tools/find.ts +80 -0
- package/src/tools/index.ts +128 -43
- package/src/tui/console.tsx +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,17 @@ one loaded is used and OpenCode shows a warning.
|
|
|
35
35
|
| `shell_wait` | Block on a condition instead of sleeping. |
|
|
36
36
|
| `shell_read` | Clean log from a cursor, with `grep`; or `view: "screen"` for full-screen programs. |
|
|
37
37
|
| `shell_send` | Type text or keys (`ctrl+c`, `up`, `enter`) and get the reply. |
|
|
38
|
-
| `shell_list`
|
|
38
|
+
| `shell_list` | Find shells: filter by text (`query`), `status` (running, failed, finished) and `session` (this, others). Each shows which session started it. |
|
|
39
|
+
| `shell_stop` · `shell_restart` | Manage shells. |
|
|
40
|
+
|
|
41
|
+
Every tool that acts on a shell takes its `id` **or its name** (the description it was started
|
|
42
|
+
with), so you can ask about shells naturally, including ones started in other sessions:
|
|
43
|
+
|
|
44
|
+
- *"How is DB Monitoring doing?"* → `shell_read name="DB Monitoring"`
|
|
45
|
+
- *"Did any shell from my other session fail?"* → `shell_list status="failed" session="others"`
|
|
46
|
+
|
|
47
|
+
Names match ignoring case, then partially on name or command. If a name fits several shells the
|
|
48
|
+
agent gets the candidates instead of a guess.
|
|
39
49
|
|
|
40
50
|
The agent is messaged when a shell it started exits on its own.
|
|
41
51
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencode-cockpit/shell",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Background shells for OpenCode: the agent starts, waits on and drives PTYs; you watch them in a docked TUI panel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,36 +41,16 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@opencode-cockpit/client": "0.1.
|
|
45
|
-
"@opencode-cockpit/daemon": "0.1.
|
|
46
|
-
"@opencode-cockpit/protocol": "0.1.
|
|
47
|
-
"@opencode-ai/plugin": "1.18.31"
|
|
48
|
-
},
|
|
49
|
-
"peerDependencies": {
|
|
50
|
-
"@opentui/core": ">=0.4.5",
|
|
51
|
-
"@opentui/keymap": ">=0.4.5",
|
|
52
|
-
"@opentui/solid": ">=0.4.5",
|
|
53
|
-
"solid-js": ">=1.9"
|
|
54
|
-
},
|
|
55
|
-
"devDependencies": {
|
|
44
|
+
"@opencode-cockpit/client": "0.1.3",
|
|
45
|
+
"@opencode-cockpit/daemon": "0.1.3",
|
|
46
|
+
"@opencode-cockpit/protocol": "0.1.3",
|
|
47
|
+
"@opencode-ai/plugin": "1.18.31",
|
|
56
48
|
"@opentui/core": "0.4.5",
|
|
57
49
|
"@opentui/keymap": "0.4.5",
|
|
58
50
|
"@opentui/solid": "0.4.5",
|
|
59
|
-
"solid-js": "1.9.15"
|
|
60
|
-
"zod": "4.1.8"
|
|
51
|
+
"solid-js": "1.9.15"
|
|
61
52
|
},
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"optional": true
|
|
65
|
-
},
|
|
66
|
-
"@opentui/keymap": {
|
|
67
|
-
"optional": true
|
|
68
|
-
},
|
|
69
|
-
"@opentui/solid": {
|
|
70
|
-
"optional": true
|
|
71
|
-
},
|
|
72
|
-
"solid-js": {
|
|
73
|
-
"optional": true
|
|
74
|
-
}
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"zod": "4.6.5"
|
|
75
55
|
}
|
|
76
56
|
}
|
package/src/server.ts
CHANGED
|
@@ -62,6 +62,17 @@ async function shellHooks({ client: opencode, directory }: PluginInput): Promise
|
|
|
62
62
|
return out
|
|
63
63
|
}
|
|
64
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
|
+
|
|
65
76
|
// Wake the agent when a shell it owns ends on its own.
|
|
66
77
|
cockpit.on("shell.exited", (info) => {
|
|
67
78
|
if (info.owner.instance !== instance || !info.owner.session) return
|
|
@@ -97,10 +108,11 @@ async function shellHooks({ client: opencode, directory }: PluginInput): Promise
|
|
|
97
108
|
instance,
|
|
98
109
|
quiet,
|
|
99
110
|
env,
|
|
111
|
+
sessionTitle,
|
|
100
112
|
shellCommand: (command) => ({ command: userShell, args: ["-c", command] }),
|
|
101
113
|
}),
|
|
102
114
|
|
|
103
|
-
"experimental.chat.system.transform": async (
|
|
115
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
104
116
|
output.system.push(GUIDANCE)
|
|
105
117
|
const running = await cockpit
|
|
106
118
|
.call("shell.list", { owner: { project: directory }, includeExited: false })
|
|
@@ -109,7 +121,14 @@ async function shellHooks({ client: opencode, directory }: PluginInput): Promise
|
|
|
109
121
|
output.system.push(
|
|
110
122
|
`Background shells currently running in this project:\n${running
|
|
111
123
|
.slice(0, 15)
|
|
112
|
-
.map((s) =>
|
|
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
|
+
})
|
|
113
132
|
.join("\n")}`,
|
|
114
133
|
)
|
|
115
134
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type ToolContext, type ToolDefinition, tool } from "@opencode-ai/plugin
|
|
|
2
2
|
import type { CockpitClient } from "@opencode-cockpit/client"
|
|
3
3
|
import { RpcError } from "@opencode-cockpit/protocol"
|
|
4
4
|
import type { ShellInfo } from "@opencode-cockpit/protocol/shell"
|
|
5
|
+
import { commandOf, filterShells, matchByName, type SessionFilter, type StatusFilter } from "./find.ts"
|
|
5
6
|
import { describeStatus, formatLines, formatRead, formatWait, header } from "./format.ts"
|
|
6
7
|
import { encodeKey, KEY_NAMES } from "./keys.ts"
|
|
7
8
|
|
|
@@ -13,10 +14,21 @@ export interface ToolDeps {
|
|
|
13
14
|
quiet: Set<string>
|
|
14
15
|
shellCommand(command: string): { command: string; args: string[] }
|
|
15
16
|
env(): Record<string, string>
|
|
17
|
+
/** Human title of an OpenCode session, for telling agents which session started a shell. */
|
|
18
|
+
sessionTitle?(sessionID: string): Promise<string | undefined>
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
const z = tool.schema
|
|
19
|
-
|
|
22
|
+
/** Every per-shell tool takes either an id or a name. */
|
|
23
|
+
const TARGET = {
|
|
24
|
+
id: z.string().optional().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34"),
|
|
25
|
+
name: z
|
|
26
|
+
.string()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe(
|
|
29
|
+
'Instead of id: the shell\'s name (the description it was started with), e.g. "DB Monitoring". Partial names and command text also match.',
|
|
30
|
+
),
|
|
31
|
+
}
|
|
20
32
|
|
|
21
33
|
const START = `Start a command in a background terminal (PTY) that keeps running while you continue working.
|
|
22
34
|
|
|
@@ -67,6 +79,51 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
67
79
|
return formatRead(current, page)
|
|
68
80
|
}
|
|
69
81
|
|
|
82
|
+
const sessionLabel = async (s: ShellInfo, ctx: ToolContext): Promise<string> => {
|
|
83
|
+
const session = s.owner.session
|
|
84
|
+
if (!session) return "started by the user"
|
|
85
|
+
if (session === ctx.sessionID) return "this session"
|
|
86
|
+
const title = await deps.sessionTitle?.(session).catch(() => undefined)
|
|
87
|
+
return title ? `session "${title}"` : `another session (${session})`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Turns `{ id }` or `{ name }` into a shell id, or explains why it cannot. */
|
|
91
|
+
const resolve = async (
|
|
92
|
+
args: { id?: string | null; name?: string | null },
|
|
93
|
+
ctx: ToolContext,
|
|
94
|
+
): Promise<{ id: string; note: string }> => {
|
|
95
|
+
if (args.id) return { id: args.id, note: "" }
|
|
96
|
+
if (!args.name) throw new Error("pass the shell's id or name")
|
|
97
|
+
const shells = await client.call("shell.list", { owner: { project: ctx.directory } })
|
|
98
|
+
const match = matchByName(shells, args.name)
|
|
99
|
+
const describe = async (list: ShellInfo[]) =>
|
|
100
|
+
(
|
|
101
|
+
await Promise.all(
|
|
102
|
+
list.map(
|
|
103
|
+
async (s) =>
|
|
104
|
+
`- ${s.id} "${s.title}" · ${s.status} · ${await sessionLabel(s, ctx)} · $ ${commandOf(s).slice(0, 80)}`,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
).join("\n")
|
|
108
|
+
if (match.kind === "found") {
|
|
109
|
+
const note =
|
|
110
|
+
match.alsoMatched.length > 0
|
|
111
|
+
? `(name "${args.name}" also matched ${match.alsoMatched.length} finished shell${match.alsoMatched.length === 1 ? "" : "s"}; using the running one, ${match.shell.id})\n`
|
|
112
|
+
: ""
|
|
113
|
+
return { id: match.shell.id, note }
|
|
114
|
+
}
|
|
115
|
+
if (match.kind === "ambiguous") {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`"${args.name}" matches several shells; pass one of these ids:\n${await describe(match.candidates)}`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
match.available.length === 0
|
|
122
|
+
? `no shell matches "${args.name}": there are no shells in this project`
|
|
123
|
+
: `no shell matches "${args.name}". Shells in this project:\n${await describe(match.available.slice(0, 15))}`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
70
127
|
return {
|
|
71
128
|
shell_start: tool({
|
|
72
129
|
description: START,
|
|
@@ -154,7 +211,7 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
154
211
|
shell_read: tool({
|
|
155
212
|
description: READ,
|
|
156
213
|
args: {
|
|
157
|
-
|
|
214
|
+
...TARGET,
|
|
158
215
|
view: z.enum(["log", "screen"]).default("log"),
|
|
159
216
|
after: z
|
|
160
217
|
.number()
|
|
@@ -173,12 +230,13 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
173
230
|
ignoreCase: z.boolean().default(false),
|
|
174
231
|
limit: z.number().int().positive().max(2000).default(300),
|
|
175
232
|
},
|
|
176
|
-
async execute(args) {
|
|
177
|
-
const
|
|
233
|
+
async execute(args, ctx) {
|
|
234
|
+
const { id, note } = await resolve(args, ctx)
|
|
235
|
+
const info = await client.call("shell.get", { id })
|
|
178
236
|
if (args.view === "screen") {
|
|
179
|
-
const screen = await client.call("shell.screen", { id
|
|
237
|
+
const screen = await client.call("shell.screen", { id })
|
|
180
238
|
return [
|
|
181
|
-
header(info)
|
|
239
|
+
`${note}${header(info)}`,
|
|
182
240
|
`status: ${describeStatus(info)}`,
|
|
183
241
|
`screen ${screen.cols}x${screen.rows}:`,
|
|
184
242
|
screen.text || "(blank)",
|
|
@@ -186,21 +244,21 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
186
244
|
].join("\n")
|
|
187
245
|
}
|
|
188
246
|
const page = await client.call("shell.read", {
|
|
189
|
-
id
|
|
247
|
+
id,
|
|
190
248
|
after: args.after ?? undefined,
|
|
191
249
|
tail: args.tail ?? 60,
|
|
192
250
|
grep: args.grep ?? undefined,
|
|
193
251
|
ignoreCase: args.ignoreCase ?? false,
|
|
194
252
|
limit: args.limit ?? 300,
|
|
195
253
|
})
|
|
196
|
-
return formatRead(info, page, args.after
|
|
254
|
+
return note + formatRead(info, page, args.after != null ? "(no new output)" : "(no output yet)")
|
|
197
255
|
},
|
|
198
256
|
}),
|
|
199
257
|
|
|
200
258
|
shell_send: tool({
|
|
201
259
|
description: SEND,
|
|
202
260
|
args: {
|
|
203
|
-
|
|
261
|
+
...TARGET,
|
|
204
262
|
text: z.string().optional(),
|
|
205
263
|
keys: z.array(z.string()).optional(),
|
|
206
264
|
submit: z.boolean().default(false).describe("Press enter after text"),
|
|
@@ -211,26 +269,30 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
211
269
|
let data = args.text ?? ""
|
|
212
270
|
for (const key of args.keys ?? []) data += encodeKey(key)
|
|
213
271
|
if (args.submit === true) data += "\r"
|
|
214
|
-
const
|
|
215
|
-
await client.call("shell.
|
|
272
|
+
const { id, note } = await resolve(args, ctx)
|
|
273
|
+
const before = await client.call("shell.get", { id })
|
|
274
|
+
await client.call("shell.write", { id, data })
|
|
216
275
|
const waitSeconds = args.waitSeconds ?? 1
|
|
217
276
|
if (waitSeconds > 0) {
|
|
218
277
|
await abortable(
|
|
219
278
|
ctx,
|
|
220
279
|
client.call("shell.wait", {
|
|
221
|
-
id
|
|
280
|
+
id,
|
|
222
281
|
until: { idleMs: 400, exit: true },
|
|
223
282
|
timeoutMs: Math.round(waitSeconds * 1000),
|
|
224
283
|
after: before.lines.last,
|
|
225
284
|
}),
|
|
226
285
|
)
|
|
227
286
|
}
|
|
228
|
-
const info = await client.call("shell.get", { id
|
|
229
|
-
const page = await client.call("shell.read", { id
|
|
230
|
-
return
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
287
|
+
const info = await client.call("shell.get", { id })
|
|
288
|
+
const page = await client.call("shell.read", { id, after: before.lines.last, limit: 300 })
|
|
289
|
+
return (
|
|
290
|
+
note +
|
|
291
|
+
formatRead(
|
|
292
|
+
info,
|
|
293
|
+
page,
|
|
294
|
+
"(no new output lines; if this is a full-screen program use shell_read view=screen)",
|
|
295
|
+
)
|
|
234
296
|
)
|
|
235
297
|
},
|
|
236
298
|
}),
|
|
@@ -238,7 +300,7 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
238
300
|
shell_wait: tool({
|
|
239
301
|
description: WAIT,
|
|
240
302
|
args: {
|
|
241
|
-
|
|
303
|
+
...TARGET,
|
|
242
304
|
pattern: z.string().optional(),
|
|
243
305
|
ignoreCase: z.boolean().optional(),
|
|
244
306
|
port: z.number().int().min(1).max(65535).optional(),
|
|
@@ -248,11 +310,12 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
248
310
|
timeoutSeconds: z.number().positive().max(3600).default(300),
|
|
249
311
|
},
|
|
250
312
|
async execute(args, ctx) {
|
|
251
|
-
const
|
|
313
|
+
const { id, note } = await resolve(args, ctx)
|
|
314
|
+
const start = await client.call("shell.get", { id })
|
|
252
315
|
const result = await abortable(
|
|
253
316
|
ctx,
|
|
254
317
|
client.call("shell.wait", {
|
|
255
|
-
id
|
|
318
|
+
id,
|
|
256
319
|
until: {
|
|
257
320
|
pattern: args.pattern ?? undefined,
|
|
258
321
|
ignoreCase: args.ignoreCase ?? undefined,
|
|
@@ -268,8 +331,8 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
268
331
|
const newLines = result.info.lines.last - start.lines.last
|
|
269
332
|
const page =
|
|
270
333
|
newLines > 80
|
|
271
|
-
? await client.call("shell.read", { id
|
|
272
|
-
: await client.call("shell.read", { id
|
|
334
|
+
? await client.call("shell.read", { id, tail: 80 })
|
|
335
|
+
: await client.call("shell.read", { id, after: start.lines.last, limit: 80 })
|
|
273
336
|
const recent = page.lines
|
|
274
337
|
if (newLines > 80)
|
|
275
338
|
recent.unshift({
|
|
@@ -277,7 +340,7 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
277
340
|
text: `… ${newLines - 80} earlier lines omitted (shell_read after=${start.lines.last})`,
|
|
278
341
|
})
|
|
279
342
|
return [
|
|
280
|
-
formatWait(result, args.timeoutSeconds ?? 300),
|
|
343
|
+
note + formatWait(result, args.timeoutSeconds ?? 300),
|
|
281
344
|
header(result.info),
|
|
282
345
|
recent.length > 0 ? formatLines(recent) : "(no new output during the wait)",
|
|
283
346
|
"</shell>",
|
|
@@ -287,30 +350,50 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
287
350
|
}),
|
|
288
351
|
|
|
289
352
|
shell_list: tool({
|
|
290
|
-
description:
|
|
353
|
+
description: `List background shells in this project: name, status, which session started it, and the last output line.
|
|
354
|
+
|
|
355
|
+
Filter to find the one you need instead of reading them all:
|
|
356
|
+
- query: text in the name or command, e.g. "db" or "vitest"
|
|
357
|
+
- status: running, failed, finished
|
|
358
|
+
- session: this (started by you in this session), others (other sessions or the user)`,
|
|
291
359
|
args: {
|
|
360
|
+
query: z.string().optional().describe("Case-insensitive text in the shell name or command"),
|
|
361
|
+
status: z.enum(["running", "failed", "finished", "any"]).default("any"),
|
|
362
|
+
session: z.enum(["this", "others", "any"]).default("any"),
|
|
292
363
|
all: z.boolean().default(false).describe("Include shells from other projects"),
|
|
293
364
|
},
|
|
294
365
|
async execute(args, ctx) {
|
|
295
|
-
const
|
|
366
|
+
const everything = await client.call(
|
|
296
367
|
"shell.list",
|
|
297
368
|
args.all === true ? {} : { owner: { project: ctx.directory } },
|
|
298
369
|
)
|
|
299
|
-
|
|
370
|
+
const shells = filterShells(everything, {
|
|
371
|
+
query: args.query ?? undefined,
|
|
372
|
+
status: (args.status ?? "any") as StatusFilter,
|
|
373
|
+
session: (args.session ?? "any") as SessionFilter,
|
|
374
|
+
currentSession: ctx.sessionID,
|
|
375
|
+
})
|
|
376
|
+
if (everything.length === 0) return "No background shells."
|
|
377
|
+
if (shells.length === 0)
|
|
378
|
+
return `No shells match those filters (${everything.length} shell${everything.length === 1 ? "" : "s"} in total).`
|
|
300
379
|
const rows = await Promise.all(
|
|
301
380
|
shells.map(async (s) => {
|
|
302
381
|
const last = await client.call("shell.read", { id: s.id, tail: 1 }).catch(() => undefined)
|
|
303
382
|
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
383
|
const failure =
|
|
309
384
|
s.summary && s.status !== "running" ? `\n summary: ${s.summary.slice(0, 200)}` : ""
|
|
310
|
-
return
|
|
385
|
+
return [
|
|
386
|
+
`${s.id} ${s.status.padEnd(7)} "${s.title}"${s.run > 1 ? ` (run ${s.run})` : ""} · ${await sessionLabel(s, ctx)}`,
|
|
387
|
+
` $ ${commandOf(s).slice(0, 200)}${failure}`,
|
|
388
|
+
` ${describeStatus(s)}${tail ? `\n last: ${tail.slice(0, 200)}` : ""}`,
|
|
389
|
+
].join("\n")
|
|
311
390
|
}),
|
|
312
391
|
)
|
|
313
|
-
|
|
392
|
+
const hidden = everything.length - shells.length
|
|
393
|
+
return (
|
|
394
|
+
rows.join("\n") +
|
|
395
|
+
(hidden > 0 ? `\n(${hidden} more shell${hidden === 1 ? "" : "s"} hidden by filters)` : "")
|
|
396
|
+
)
|
|
314
397
|
},
|
|
315
398
|
}),
|
|
316
399
|
|
|
@@ -318,34 +401,36 @@ export function createTools(deps: ToolDeps): Record<string, ToolDefinition> {
|
|
|
318
401
|
description:
|
|
319
402
|
"Stop a background shell (SIGTERM to its whole process group, then SIGKILL after a grace period). Set remove=true to also forget it.",
|
|
320
403
|
args: {
|
|
321
|
-
|
|
404
|
+
...TARGET,
|
|
322
405
|
remove: z.boolean().default(false),
|
|
323
406
|
force: z.boolean().default(false).describe("Send SIGKILL immediately"),
|
|
324
407
|
},
|
|
325
|
-
async execute(args) {
|
|
326
|
-
|
|
408
|
+
async execute(args, ctx) {
|
|
409
|
+
const { id, note } = await resolve(args, ctx)
|
|
410
|
+
deps.quiet.add(id)
|
|
327
411
|
const info = await client.call("shell.stop", {
|
|
328
|
-
id
|
|
412
|
+
id,
|
|
329
413
|
signal: args.force === true ? "SIGKILL" : "SIGTERM",
|
|
330
414
|
graceMs: 3000,
|
|
331
415
|
})
|
|
332
|
-
if (args.remove === true) await client.call("shell.remove", { id
|
|
333
|
-
return `${info.id} ${describeStatus(info)}${args.remove === true ? " and removed" : ""}`
|
|
416
|
+
if (args.remove === true) await client.call("shell.remove", { id })
|
|
417
|
+
return `${note}${info.id} ${describeStatus(info)}${args.remove === true ? " and removed" : ""}`
|
|
334
418
|
},
|
|
335
419
|
}),
|
|
336
420
|
|
|
337
421
|
shell_restart: tool({
|
|
338
422
|
description:
|
|
339
423
|
"Restart a background shell with the same command. Keeps the id; output continues after a restart marker.",
|
|
340
|
-
args: {
|
|
424
|
+
args: { ...TARGET },
|
|
341
425
|
async execute(args, ctx) {
|
|
342
|
-
const
|
|
343
|
-
|
|
426
|
+
const { id, note } = await resolve(args, ctx)
|
|
427
|
+
const info = await client.call("shell.restart", { id })
|
|
428
|
+
deps.quiet.delete(id)
|
|
344
429
|
await abortable(
|
|
345
430
|
ctx,
|
|
346
431
|
client.call("shell.wait", { id: info.id, until: { idleMs: 700, exit: true }, timeoutMs: 2500 }),
|
|
347
432
|
)
|
|
348
|
-
return
|
|
433
|
+
return `${note}Restarted ${info.id} (run ${info.run})\n${await peek(info)}`
|
|
349
434
|
},
|
|
350
435
|
}),
|
|
351
436
|
}
|
package/src/tui/console.tsx
CHANGED
|
@@ -73,7 +73,7 @@ export function Console(props: ConsoleProps) {
|
|
|
73
73
|
})
|
|
74
74
|
const failure = createMemo(() => {
|
|
75
75
|
const s = shell()
|
|
76
|
-
if (!s
|
|
76
|
+
if (!s?.summary) return undefined
|
|
77
77
|
const kind = kindOf(s)
|
|
78
78
|
return kind === "fail" || kind === "stop" ? s.summary : undefined
|
|
79
79
|
})
|