@opencode-cockpit/shell 0.1.4 → 0.2.0

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.
Files changed (77) hide show
  1. package/README.md +100 -10
  2. package/dist/agent/plugin.js +180 -0
  3. package/dist/agent/tools/index.js +23 -0
  4. package/{src/tools/keys.ts → dist/agent/tools/keys.js} +11 -14
  5. package/dist/agent/tools/list.js +68 -0
  6. package/dist/agent/tools/read.js +59 -0
  7. package/dist/agent/tools/restart.js +44 -0
  8. package/dist/agent/tools/send.js +71 -0
  9. package/dist/agent/tools/shared.js +94 -0
  10. package/dist/agent/tools/start.js +140 -0
  11. package/dist/agent/tools/stop.js +42 -0
  12. package/dist/agent/tools/wait.js +79 -0
  13. package/dist/agent/tools/watch-args.js +12 -0
  14. package/dist/agent/tools/watch.js +74 -0
  15. package/dist/connect.js +31 -0
  16. package/dist/core/config.js +97 -0
  17. package/dist/core/find.js +69 -0
  18. package/dist/core/format.js +63 -0
  19. package/dist/core/kind.js +31 -0
  20. package/dist/server.js +2 -0
  21. package/dist/tui/components/badge.js +29 -0
  22. package/dist/tui/components/console.js +759 -0
  23. package/dist/tui/components/dock.js +270 -0
  24. package/dist/tui/components/sidebar.js +136 -0
  25. package/dist/tui/dialogs.js +163 -0
  26. package/dist/tui/index.js +205 -0
  27. package/dist/tui/lib/details.js +23 -0
  28. package/dist/tui/lib/keys.js +43 -0
  29. package/dist/tui/lib/search.js +39 -0
  30. package/dist/tui/lib/update.js +58 -0
  31. package/dist/tui/lib/view.js +191 -0
  32. package/dist/tui/state/store.js +158 -0
  33. package/package.json +21 -12
  34. package/types/agent/plugin.d.ts +12 -0
  35. package/types/agent/tools/index.d.ts +5 -0
  36. package/types/agent/tools/keys.d.ts +3 -0
  37. package/types/agent/tools/list.d.ts +3 -0
  38. package/types/agent/tools/read.d.ts +3 -0
  39. package/types/agent/tools/restart.d.ts +3 -0
  40. package/types/agent/tools/send.d.ts +3 -0
  41. package/types/agent/tools/shared.d.ts +40 -0
  42. package/types/agent/tools/start.d.ts +3 -0
  43. package/types/agent/tools/stop.d.ts +3 -0
  44. package/types/agent/tools/wait.d.ts +3 -0
  45. package/types/agent/tools/watch-args.d.ts +10 -0
  46. package/types/agent/tools/watch.d.ts +3 -0
  47. package/types/connect.d.ts +9 -0
  48. package/types/core/config.d.ts +59 -0
  49. package/types/core/find.d.ts +37 -0
  50. package/types/core/format.d.ts +8 -0
  51. package/types/core/kind.d.ts +9 -0
  52. package/types/server.d.ts +2 -0
  53. package/types/tui/components/badge.d.ts +9 -0
  54. package/types/tui/components/console.d.ts +22 -0
  55. package/types/tui/components/dock.d.ts +14 -0
  56. package/types/tui/components/sidebar.d.ts +14 -0
  57. package/types/tui/dialogs.d.ts +10 -0
  58. package/types/tui/index.d.ts +13 -0
  59. package/types/tui/lib/details.d.ts +3 -0
  60. package/types/tui/lib/keys.d.ts +8 -0
  61. package/types/tui/lib/search.d.ts +7 -0
  62. package/types/tui/lib/update.d.ts +25 -0
  63. package/types/tui/lib/view.d.ts +80 -0
  64. package/types/tui/state/store.d.ts +55 -0
  65. package/src/connect.ts +0 -24
  66. package/src/server.ts +0 -156
  67. package/src/tools/find.ts +0 -80
  68. package/src/tools/format.ts +0 -78
  69. package/src/tools/index.ts +0 -467
  70. package/src/tui/badge.tsx +0 -17
  71. package/src/tui/console.tsx +0 -438
  72. package/src/tui/dock.tsx +0 -130
  73. package/src/tui/index.tsx +0 -286
  74. package/src/tui/keys.ts +0 -52
  75. package/src/tui/sidebar.tsx +0 -44
  76. package/src/tui/store.ts +0 -185
  77. package/src/tui/view.ts +0 -161
@@ -0,0 +1,80 @@
1
+ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui";
2
+ import type { ScreenRun, ShellInfo } from "@opencode-cockpit/protocol/shell";
3
+ /** What a human cares about, derived from status + exit code so every surface agrees. */
4
+ export type Kind = "run" | "fail" | "stop" | "done";
5
+ export declare function kindOf(s: ShellInfo): Kind;
6
+ export declare const BADGE_LABEL: Record<Kind, string>;
7
+ /** Braille spinner: single-width in every terminal font. */
8
+ export declare const SPINNER: string[];
9
+ export declare function kindColor(theme: TuiThemeCurrent, kind: Kind): import("@opentui/core").RGBA;
10
+ /** Fixed 7-column pill so lists line up: " ⠹ RUN ", " FAIL ". */
11
+ export declare function badgeText(kind: Kind, frame?: number): string;
12
+ /** Running first (oldest first, stable tabs), then failures, stopped, done (most recent first). */
13
+ export declare function order(list: readonly ShellInfo[]): ShellInfo[];
14
+ export interface PartitionOptions {
15
+ showAll: boolean;
16
+ /** Failures stay visible this long after they end. */
17
+ historyMs: number;
18
+ now: number;
19
+ /** Always visible, so the selection never disappears under the user. */
20
+ keep?: string;
21
+ }
22
+ /** Default view: running shells and recent failures. Everything else folds into a "N more" chip. */
23
+ export declare function partition(list: readonly ShellInfo[], opts: PartitionOptions): {
24
+ visible: {
25
+ id: string;
26
+ title: string;
27
+ command: string;
28
+ args: string[];
29
+ cwd: string;
30
+ owner: {
31
+ project: string;
32
+ session?: string | undefined;
33
+ instance?: string | undefined;
34
+ };
35
+ status: "exited" | "failed" | "killed" | "running";
36
+ run: number;
37
+ pid?: number | undefined;
38
+ exitCode?: number | undefined;
39
+ signal?: string | undefined;
40
+ error?: string | undefined;
41
+ summary?: string | undefined;
42
+ startedAt: number;
43
+ endedAt?: number | undefined;
44
+ cols: number;
45
+ rows: number;
46
+ lines: {
47
+ first: number;
48
+ last: number;
49
+ };
50
+ bytes: number;
51
+ watch?: {
52
+ preset?: string | undefined;
53
+ status: "fail" | "ok" | "pending" | "unknown";
54
+ summary?: string | undefined;
55
+ runs: number;
56
+ since: number;
57
+ } | undefined;
58
+ logFile?: string | undefined;
59
+ }[];
60
+ hidden: ShellInfo[];
61
+ };
62
+ export declare function statusDetail(s: ShellInfo, now: number): string;
63
+ /** Short health label for a watched shell, e.g. "tsc ✗" — empty when nothing is watching it. */
64
+ export declare function watchLabel(s: ShellInfo): string;
65
+ export declare function watchColor(theme: TuiThemeCurrent, s: ShellInfo): import("@opentui/core").RGBA;
66
+ /** Compact detail for narrow lists. */
67
+ export declare function shortDetail(s: ShellInfo, now: number): string;
68
+ /** Coarse relative time: "just now", "12s ago", "9m ago", "3h ago". */
69
+ export declare function since(ms: number): string;
70
+ /** The command as the user or agent wrote it, without the `$SHELL -c` wrapper. */
71
+ export declare function displayCommand(s: ShellInfo): string;
72
+ /** Folder relative to the project: "" at the root, "./sub" inside, "~/x" elsewhere under home. */
73
+ export declare function relativeCwd(cwd: string, project: string, home?: string): string;
74
+ /** Hard-wraps to `width`, at most `maxLines`; the last line ends with … when text was cut. */
75
+ export declare function wrapText(text: string, width: number, maxLines: number): string[];
76
+ /** Last `rows` styled rows, each cut to `cols`, so colour survives the same trimming as text. */
77
+ export declare function tailRuns(styled: ScreenRun[][] | undefined, rows: number, cols: number): ScreenRun[][];
78
+ /** Last `rows` lines of screen text, each cut to `cols`. */
79
+ export declare function tailLines(text: string | undefined, rows: number, cols: number): string;
80
+ export declare function truncate(text: string, max: number): string;
@@ -0,0 +1,55 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
2
+ import type { CockpitClient } from "@opencode-cockpit/client";
3
+ import type { ShellInfo } from "@opencode-cockpit/protocol/shell";
4
+ import { type Accessor } from "solid-js";
5
+ export interface ShellStore {
6
+ client: CockpitClient;
7
+ project: () => string;
8
+ /** Every shell in the project, unordered. */
9
+ shells: Accessor<ShellInfo[]>;
10
+ /** Ordered and folded for display: running, recent failures, plus the selection. */
11
+ visible: Accessor<ShellInfo[]>;
12
+ hidden: Accessor<ShellInfo[]>;
13
+ showAll: Accessor<boolean>;
14
+ toggleAll(): void;
15
+ connected: Accessor<boolean>;
16
+ now: Accessor<number>;
17
+ /** Spinner frame index; advances only while something is running. */
18
+ frame: Accessor<number>;
19
+ selected: Accessor<ShellInfo | undefined>;
20
+ select(id: string): void;
21
+ step(delta: number): void;
22
+ refresh(): Promise<void>;
23
+ clearFinished(): Promise<number>;
24
+ dispose(): void;
25
+ }
26
+ export interface StoreOptions {
27
+ /** Failures stay in the default view this long after they end. */
28
+ historyMinutes?: number;
29
+ }
30
+ export declare function createShellStore(api: TuiPluginApi, client: CockpitClient, options?: StoreOptions): ShellStore;
31
+ /**
32
+ * Live terminal view of one shell: attaches for change notifications and re-renders the daemon's
33
+ * emulated screen, throttled. Must be called inside a reactive owner.
34
+ */
35
+ export declare function useScreen(store: ShellStore, id: Accessor<string | undefined>): {
36
+ screen: Accessor<{
37
+ text: string;
38
+ cols: number;
39
+ rows: number;
40
+ cursor: {
41
+ x: number;
42
+ y: number;
43
+ };
44
+ styled?: {
45
+ text: string;
46
+ fg?: string | undefined;
47
+ bg?: string | undefined;
48
+ bold?: boolean | undefined;
49
+ dim?: boolean | undefined;
50
+ italic?: boolean | undefined;
51
+ underline?: boolean | undefined;
52
+ }[][] | undefined;
53
+ } | undefined>;
54
+ refetch: () => void | "" | undefined;
55
+ };
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
- }
@@ -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
- }