opencode-cockpit 0.1.0 → 0.1.2

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
@@ -4,120 +4,83 @@
4
4
  [![npm](https://img.shields.io/npm/v/opencode-cockpit)](https://www.npmjs.com/package/opencode-cockpit)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Codestz/opencode-cockpit/blob/main/LICENSE)
6
6
 
7
- Superpowers for [OpenCode](https://opencode.ai).
7
+ Superpowers for [OpenCode](https://opencode.ai). Take all of them, or only the ones you want.
8
8
 
9
- The first one is **Shell**: background terminals that the agent starts, waits on and drives,
10
- and that you watch and control without leaving the chat.
11
-
12
- - **The agent stops sleeping and polling.** It starts a dev server and blocks until the port
13
- opens, runs a slow test suite and gets a message when it finishes, drives REPLs and prompts.
14
- - **Logs that don't waste tokens.** Colours are stripped, progress-bar redraws collapse to their
15
- final frame, repeated lines fold, and reads continue from a cursor.
16
- - **You see everything.** A docked panel under the chat, a sidebar section, and a keyboard-first
17
- console with a real typing mode.
18
- - **Shells outlive OpenCode.** They run in a small daemon, `cockpitd`, shared by every OpenCode
19
- window and restarted automatically when the plugin updates.
9
+ | Feature | What it gives you | Package |
10
+ |---|---|---|
11
+ | **Shell** | Background terminals the agent starts, waits on and drives, with a docked panel and console for you | [`@opencode-cockpit/shell`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/shell) |
12
+ | **Agents** *(coming)* | A live, keyboard-first view of every subagent, without leaving your chat | `@opencode-cockpit/agents` |
20
13
 
21
14
  ## Install
22
15
 
16
+ **Everything**
17
+
23
18
  ```sh
24
19
  opencode plugin opencode-cockpit --global
25
20
  ```
26
21
 
27
- This adds the plugin to both `opencode.json` (agent tools) and `tui.json` (interface). Restart
28
- OpenCode. Requires OpenCode 1.18 or newer on macOS or Linux.
22
+ **Only what you want**
29
23
 
30
- ## For the agent
31
-
32
- | Tool | What it does |
33
- |---|---|
34
- | `shell_start` | Run a command in a background PTY. Optional `waitFor` blocks until a port opens, a pattern prints, output goes idle, or the process exits. Rerunning the same command in the same session reuses the shell. |
35
- | `shell_wait` | Block on a condition instead of sleeping. |
36
- | `shell_read` | Clean log from a cursor, with `grep`; or `view: "screen"` for full-screen programs. |
37
- | `shell_send` | Type text or keys (`ctrl+c`, `up`, `enter`) and get the reply. |
38
- | `shell_list` · `shell_stop` · `shell_restart` | Manage shells. |
39
-
40
- The agent is messaged when a shell it started exits on its own.
41
-
42
- Things to ask:
43
-
44
- - *"Start the dev server in a background shell and wait until it's ready."*
45
- - *"Run the test suite in the background, keep working on the parser, tell me if it fails."*
46
- - *"Open a node REPL in a shell and check what `new URL('..', import.meta.url)` returns."*
24
+ ```sh
25
+ opencode plugin @opencode-cockpit/shell --global
26
+ ```
47
27
 
48
- ## For you
28
+ **Everything except some features**: switch them off on the plugin entry, in both `opencode.json`
29
+ and `tui.json`:
49
30
 
50
- | Key / command | Does |
51
- |---|---|
52
- | `ctrl+x o` · `/shells` | Toggle the shells panel under the chat |
53
- | `ctrl+x i` · `/shell` | Open the shell console |
54
- | `/shell-new` | Start a shell yourself |
55
- | `/shells-clear` | Remove finished shells |
56
- | `/shells-restart-daemon` | Restart `cockpitd` (asks first when shells are running) |
31
+ ```json
32
+ { "plugin": [["opencode-cockpit", { "features": { "shell": false } }]] }
33
+ ```
57
34
 
58
- Status reads the same everywhere: `RUN` (with a spinner), `FAIL`, `STOP`, `DONE`. Running shells
59
- and recent failures stay visible; everything else folds into `▸ N more`.
35
+ Restart OpenCode after installing. Requires OpenCode 1.18 or newer on macOS or Linux.
60
36
 
61
- **Console keys.** Running shell: `i` type (every key goes to the program, `ctrl+]` to stop typing),
62
- `c` ctrl+c, `r` restart, `x` stop. Finished shell: `r` run again, `d` remove. Always: `tab`
63
- screen or log, `?` details, `[` `]` switch, `D` clear finished, `a` show all, `esc` close.
37
+ > Install a feature **either** through `opencode-cockpit` **or** on its own, not both. If both are
38
+ > configured, the first one loaded is used and OpenCode shows a warning telling you which entry to
39
+ > remove.
64
40
 
65
- ## Configuration
41
+ ## Configuring features
66
42
 
67
- Options go on the plugin entry in `tui.json`:
43
+ Each feature's README lists its options. With `opencode-cockpit`, nest them under the feature's
44
+ name:
68
45
 
69
46
  ```json
70
47
  {
71
48
  "plugin": [
72
- ["opencode-cockpit", {
73
- "dockHeight": 16,
74
- "dockOpen": true,
75
- "historyMinutes": 60,
76
- "keybinds": { "cockpit.shells.dock": "<leader>j", "cockpit.shells.console": "<leader>k" }
77
- }]
49
+ ["opencode-cockpit", { "shell": { "dockHeight": 16, "historyMinutes": 60 } }]
78
50
  ]
79
51
  }
80
52
  ```
81
53
 
82
- | Environment variable | Default | Purpose |
83
- |---|---|---|
84
- | `COCKPIT_HOME` | `~/.cache/opencode-cockpit` | Socket, logs, process registry |
85
- | `COCKPIT_IDLE_TIMEOUT_MS` | `600000` | Daemon exits after this long with no clients and no running shells |
86
- | `COCKPIT_LOG_LEVEL` | `info` | `debug` for verbose daemon logs |
87
-
88
- ## Troubleshooting
89
-
90
- | Problem | Look at |
91
- |---|---|
92
- | Tools fail with "did not start" | `~/.cache/opencode-cockpit/cockpitd.log` |
93
- | Plugin not loading | newest file in `~/.local/share/opencode/log/` |
94
- | Panel says the daemon runs older code | `/shells-restart-daemon` once your shells are done |
54
+ With a standalone package, put them directly on its entry:
55
+ `["@opencode-cockpit/shell", { "dockHeight": 16 }]`.
95
56
 
96
57
  ## How it works
97
58
 
98
59
  ```
99
- OpenCode TUI thread ── opencode-cockpit/tui ──┐
100
- ├── unix socket, JSON-RPC ── cockpitd ── PTYs
101
- OpenCode server worker ─ opencode-cockpit/server ┘
60
+ OpenCode TUI thread ── feature plugins (tui) ──┐
61
+ ├── unix socket, JSON-RPC ── cockpitd ── processes
62
+ OpenCode server worker ─ feature plugins (server)
102
63
  ```
103
64
 
104
- OpenCode runs its interface and its server in separate threads, so the plugin's two halves can't
105
- share memory. Both talk to `cockpitd`, which owns every process. Each shell's output feeds a line
106
- normalizer (the agent's log), a headless terminal emulator (the screen you see) and a raw ring
107
- buffer (replay for late viewers). See [CONTRIBUTING.md](https://github.com/Codestz/opencode-cockpit/blob/main/CONTRIBUTING.md) for the full picture.
65
+ Each feature is a complete OpenCode plugin. Features that need long-lived processes share one
66
+ small daemon, `cockpitd`, which starts on demand, is shared by every OpenCode window, upgrades
67
+ itself when a newer plugin connects and exits when idle. See [CONTRIBUTING.md](https://github.com/Codestz/opencode-cockpit/blob/main/CONTRIBUTING.md).
108
68
 
109
69
  | Package | Role |
110
70
  |---|---|
111
- | [`opencode-cockpit`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/opencode) | The plugin: agent tools and TUI |
112
- | [`@opencode-cockpit/daemon`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/daemon) | `cockpitd` |
113
- | [`@opencode-cockpit/client`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/client) | Typed, auto-spawning client |
71
+ | [`opencode-cockpit`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/opencode) | All features in one plugin, each can be switched off |
72
+ | [`@opencode-cockpit/shell`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/shell) | Shell feature |
73
+ | [`@opencode-cockpit/daemon`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/daemon) | `cockpitd`, the shared process host |
74
+ | [`@opencode-cockpit/client`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/client) | Typed, auto-spawning client and shared plugin helpers |
114
75
  | [`@opencode-cockpit/protocol`](https://github.com/Codestz/opencode-cockpit/tree/main/packages/protocol) | Wire contracts |
115
76
 
116
77
  ## Roadmap
117
78
 
118
- - **Watchers:** `tsc`, `eslint` and `vitest` shells that report only state changes.
119
- - **Agents panel:** live subagent tree with a peek overlay.
120
- - Coloured output in the panel and console.
79
+ - **Watchers** (Shell): `tsc`, `eslint` and `vitest` shells that report only state changes.
80
+ - **Agents**: live subagent tree with a peek overlay.
81
+ - **Doctor**: one command that checks your setup (duplicate features, daemon health, config) and
82
+ tells you how to fix it.
83
+ - Coloured output in the Shell panel and console.
121
84
 
122
85
  ## License
123
86
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-cockpit",
3
- "version": "0.1.0",
4
- "description": "OpenCode superpowers: background shells the agent can start, wait on and drive, with a docked TUI panel",
3
+ "version": "0.1.2",
4
+ "description": "OpenCode superpowers, all in one: installs every opencode-cockpit feature (Shell today), each can be switched off",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Codestz",
@@ -21,11 +21,12 @@
21
21
  "shell",
22
22
  "background",
23
23
  "tui",
24
- "terminal"
24
+ "terminal",
25
+ "agents"
25
26
  ],
26
27
  "exports": {
27
28
  "./server": "./src/server.ts",
28
- "./tui": "./src/tui/index.tsx"
29
+ "./tui": "./src/tui.ts"
29
30
  },
30
31
  "engines": {
31
32
  "opencode": ">=1.18.0",
@@ -40,36 +41,8 @@
40
41
  "access": "public"
41
42
  },
42
43
  "dependencies": {
43
- "@opencode-cockpit/client": "0.0.1",
44
- "@opencode-cockpit/daemon": "0.0.1",
45
- "@opencode-cockpit/protocol": "0.0.1",
44
+ "@opencode-cockpit/client": "0.1.2",
45
+ "@opencode-cockpit/shell": "0.1.2",
46
46
  "@opencode-ai/plugin": "1.18.31"
47
- },
48
- "peerDependencies": {
49
- "@opentui/core": ">=0.4.5",
50
- "@opentui/keymap": ">=0.4.5",
51
- "@opentui/solid": ">=0.4.5",
52
- "solid-js": ">=1.9"
53
- },
54
- "devDependencies": {
55
- "@opentui/core": "0.4.5",
56
- "@opentui/keymap": "0.4.5",
57
- "@opentui/solid": "0.4.5",
58
- "solid-js": "1.9.15",
59
- "zod": "4.1.8"
60
- },
61
- "peerDependenciesMeta": {
62
- "@opentui/core": {
63
- "optional": true
64
- },
65
- "@opentui/keymap": {
66
- "optional": true
67
- },
68
- "@opentui/solid": {
69
- "optional": true
70
- },
71
- "solid-js": {
72
- "optional": true
73
- }
74
47
  }
75
48
  }
package/src/compose.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Hooks } from "@opencode-ai/plugin"
2
+
3
+ /**
4
+ * Merges the server hooks of several features into one plugin. Tool maps are unioned (a name
5
+ * clash is a bug, so it throws); function hooks run in feature order, each awaited, which is how
6
+ * OpenCode itself runs hooks from separate plugins.
7
+ */
8
+ export function composeHooks(parts: Hooks[]): Hooks {
9
+ const tools: NonNullable<Hooks["tool"]> = {}
10
+ const functions = new Map<string, ((...args: unknown[]) => unknown)[]>()
11
+ const singles: Record<string, unknown> = {}
12
+
13
+ for (const part of parts) {
14
+ for (const [key, value] of Object.entries(part)) {
15
+ if (value === undefined) continue
16
+ if (key === "tool") {
17
+ for (const [id, def] of Object.entries(value as NonNullable<Hooks["tool"]>)) {
18
+ if (id in tools) throw new Error(`tool "${id}" is registered by more than one cockpit feature`)
19
+ tools[id] = def
20
+ }
21
+ } else if (typeof value === "function") {
22
+ const list = functions.get(key) ?? []
23
+ list.push(value as (...args: unknown[]) => unknown)
24
+ functions.set(key, list)
25
+ } else {
26
+ if (key in singles) throw new Error(`hook "${key}" is provided by more than one cockpit feature`)
27
+ singles[key] = value
28
+ }
29
+ }
30
+ }
31
+
32
+ const hooks: Record<string, unknown> = { ...singles }
33
+ for (const [key, list] of functions) {
34
+ hooks[key] =
35
+ list.length === 1
36
+ ? list[0]
37
+ : async (...args: unknown[]) => {
38
+ for (const fn of list) await fn(...args)
39
+ }
40
+ }
41
+ if (Object.keys(tools).length > 0) hooks.tool = tools
42
+ return hooks as Hooks
43
+ }
@@ -0,0 +1,34 @@
1
+ /** Every feature the bundle can load. Adding one: list it here and wire it in server.ts / tui.ts. */
2
+ export const FEATURES = ["shell"] as const
3
+ export type Feature = (typeof FEATURES)[number]
4
+
5
+ export interface CockpitOptions {
6
+ /** Switch features off, e.g. `{ "shell": false }`. Everything is on by default. */
7
+ features?: Partial<Record<Feature, boolean>>
8
+ /** Options passed to a single feature, keyed by feature name. */
9
+ shell?: Record<string, unknown>
10
+ [key: string]: unknown
11
+ }
12
+
13
+ export const BUNDLE = "opencode-cockpit"
14
+
15
+ export function isEnabled(options: CockpitOptions | undefined, feature: Feature): boolean {
16
+ return options?.features?.[feature] !== false
17
+ }
18
+
19
+ /**
20
+ * Options for one feature. Shell also accepts options at the top level, which is where 0.1.x
21
+ * (when `opencode-cockpit` was Shell) read them from.
22
+ */
23
+ export function featureOptions(
24
+ options: CockpitOptions | undefined,
25
+ feature: Feature,
26
+ ): Record<string, unknown> {
27
+ const own = (options?.[feature] as Record<string, unknown> | undefined) ?? {}
28
+ if (feature !== "shell" || !options) return own
29
+ const legacy: Record<string, unknown> = {}
30
+ for (const [key, value] of Object.entries(options)) {
31
+ if (key !== "features" && !(FEATURES as readonly string[]).includes(key)) legacy[key] = value
32
+ }
33
+ return { ...legacy, ...own }
34
+ }
package/src/server.ts CHANGED
@@ -1,98 +1,16 @@
1
- import type { Plugin, PluginModule } from "@opencode-ai/plugin"
2
- import type { ShellInfo } from "@opencode-cockpit/protocol/shell"
3
- import { createClient } from "./connect.ts"
4
- import { describeStatus, formatLines } from "./tools/format.ts"
5
- import { createTools } from "./tools/index.ts"
6
-
7
- const GUIDANCE = `## Background shells (opencode-cockpit)
8
- Long-running or interactive commands (dev servers, watchers, slow builds/tests, REPLs) go in shell_start, not bash with "&".
9
- Block with shell_wait (pattern, port, idle, exit) instead of sleeping; follow output with shell_read(after=cursor).
10
- You are messaged when a shell you started exits.`
11
-
12
- const server: Plugin = async ({ client: opencode, directory }) => {
13
- const cockpit = createClient("opencode-cockpit/server")
14
- const instance = crypto.randomUUID()
15
- const quiet = new Set<string>()
16
-
17
- const userShell =
18
- process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash"
19
- const env = () => {
20
- const out: Record<string, string> = {}
21
- for (const [k, v] of Object.entries(process.env))
22
- if (v !== undefined && !k.startsWith("OPENCODE_")) out[k] = v
23
- return out
24
- }
25
-
26
- // Wake the agent when a shell it owns ends on its own.
27
- cockpit.on("shell.exited", (info) => {
28
- if (info.owner.instance !== instance || !info.owner.session) return
29
- if (quiet.delete(info.id)) return
30
- void notifyExit(info).catch(() => {})
31
- })
32
-
33
- async function notifyExit(info: ShellInfo): Promise<void> {
34
- const session = info.owner.session as string
35
- const page = await cockpit.call("shell.read", { id: info.id, tail: 15 })
36
- const failed =
37
- info.status === "failed" ||
38
- (info.status === "exited" && info.exitCode !== 0) ||
39
- info.status === "killed"
40
- const text = [
41
- `<shell_exited id="${info.id}" title="${info.title}">`,
42
- describeStatus(info),
43
- page.lines.length > 0 ? `last output:\n${formatLines(page.lines)}` : "(no output)",
44
- "</shell_exited>",
45
- failed
46
- ? `Investigate with shell_read id=${info.id} grep="error|fail" if the failure matters to the task.`
47
- : `Full output: shell_read id=${info.id}.`,
48
- ].join("\n")
49
- await opencode.session.promptAsync({
50
- path: { id: session },
51
- body: { parts: [{ type: "text", text, synthetic: true } as never] },
52
- })
53
- }
54
-
55
- return {
56
- tool: createTools({
57
- client: cockpit,
58
- instance,
59
- quiet,
60
- env,
61
- shellCommand: (command) => ({ command: userShell, args: ["-c", command] }),
62
- }),
63
-
64
- "experimental.chat.system.transform": async (_input, output) => {
65
- output.system.push(GUIDANCE)
66
- const running = await cockpit
67
- .call("shell.list", { owner: { project: directory }, includeExited: false })
68
- .catch(() => [] as ShellInfo[])
69
- if (running.length > 0) {
70
- output.system.push(
71
- `Background shells currently running in this project:\n${running
72
- .slice(0, 15)
73
- .map((s) => `- ${s.id} ${s.title} (${describeStatus(s)})`)
74
- .join("\n")}`,
75
- )
76
- }
77
- },
78
-
79
- event: async ({ event }) => {
80
- if (event.type !== "session.deleted") return
81
- const sessionID = event.properties.info.id
82
- const owned = await cockpit
83
- .call("shell.list", { owner: { session: sessionID } })
84
- .catch(() => [] as ShellInfo[])
85
- for (const shell of owned) {
86
- quiet.add(shell.id)
87
- await cockpit.call("shell.remove", { id: shell.id }).catch(() => {})
88
- }
89
- },
90
-
91
- dispose: async () => {
92
- cockpit.close()
93
- },
94
- }
1
+ import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin"
2
+ import { createShellServer } from "@opencode-cockpit/shell/server"
3
+ import { composeHooks } from "./compose.ts"
4
+ import { BUNDLE, type CockpitOptions, featureOptions, isEnabled } from "./features.ts"
5
+
6
+ const shell = createShellServer({ source: BUNDLE })
7
+
8
+ const server: Plugin = async (input, rawOptions) => {
9
+ const options = rawOptions as CockpitOptions | undefined
10
+ const parts: Hooks[] = []
11
+ if (isEnabled(options, "shell")) parts.push(await shell(input, featureOptions(options, "shell")))
12
+ return composeHooks(parts)
95
13
  }
96
14
 
97
- const plugin: PluginModule & { id: string } = { id: "opencode-cockpit", server }
15
+ const plugin: PluginModule & { id: string } = { id: BUNDLE, server }
98
16
  export default plugin
package/src/tui.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
2
+ import { createShellTui } from "@opencode-cockpit/shell/tui"
3
+ import { BUNDLE, type CockpitOptions, featureOptions, isEnabled } from "./features.ts"
4
+
5
+ const shell = createShellTui({ source: BUNDLE })
6
+
7
+ const tui: TuiPlugin = async (api, rawOptions, meta) => {
8
+ const options = rawOptions as CockpitOptions | undefined
9
+ if (isEnabled(options, "shell")) await shell(api, featureOptions(options, "shell"), meta)
10
+ }
11
+
12
+ const plugin: TuiPluginModule & { id: string } = { id: BUNDLE, tui }
13
+ export default plugin
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
- }
@@ -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
- }