@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 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` · `shell_stop` · `shell_restart` | Manage shells. |
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.2",
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.2",
45
- "@opencode-cockpit/daemon": "0.1.2",
46
- "@opencode-cockpit/protocol": "0.1.2",
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
- "peerDependenciesMeta": {
63
- "@opentui/core": {
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 (_input, output) => {
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) => `- ${s.id} ${s.title} (${describeStatus(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
+ }
@@ -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
- const ID = z.string().describe("Shell id from shell_start or shell_list, e.g. sh_ab12cd34")
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
- id: ID,
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 info = await client.call("shell.get", { id: args.id })
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: args.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: args.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 !== undefined ? "(no new output)" : "(no output yet)")
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
- id: ID,
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 before = await client.call("shell.get", { id: args.id })
215
- await client.call("shell.write", { id: args.id, data })
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: args.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: 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)",
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
- id: ID,
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 start = await client.call("shell.get", { id: args.id })
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: args.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: args.id, tail: 80 })
272
- : await client.call("shell.read", { id: args.id, after: start.lines.last, limit: 80 })
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: "List background shells for this project with status, uptime and last output line.",
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 shells = await client.call(
366
+ const everything = await client.call(
296
367
  "shell.list",
297
368
  args.all === true ? {} : { owner: { project: ctx.directory } },
298
369
  )
299
- if (shells.length === 0) return "No background shells."
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 `${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)}` : ""}`
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
- return rows.join("\n")
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
- id: ID,
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
- deps.quiet.add(args.id)
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: args.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: args.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: { id: ID },
424
+ args: { ...TARGET },
341
425
  async execute(args, ctx) {
342
- const info = await client.call("shell.restart", { id: args.id })
343
- deps.quiet.delete(args.id)
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 `Restarted ${info.id} (run ${info.run})\n${await peek(info)}`
433
+ return `${note}Restarted ${info.id} (run ${info.run})\n${await peek(info)}`
349
434
  },
350
435
  }),
351
436
  }
@@ -73,7 +73,7 @@ export function Console(props: ConsoleProps) {
73
73
  })
74
74
  const failure = createMemo(() => {
75
75
  const s = shell()
76
- if (!s || !s.summary) return undefined
76
+ if (!s?.summary) return undefined
77
77
  const kind = kindOf(s)
78
78
  return kind === "fail" || kind === "stop" ? s.summary : undefined
79
79
  })