@opencode-cockpit/daemon 0.2.0 → 0.2.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
@@ -1,11 +1,132 @@
1
1
  # @opencode-cockpit/daemon
2
2
 
3
- `cockpitd`, the long-lived process host. Owns PTY shells (each its own process group), normalizes output into clean logs, emulates the screen, implements wait conditions, reaps orphans after crashes and shuts down when idle. Capabilities are modules with a namespace, a method table and a lifecycle.
3
+ **`cockpitd` the process host that everything else in Cockpit talks to.**
4
4
 
5
- Part of [opencode-cockpit](https://github.com/Codestz/opencode-cockpit). Install the plugin, not this package, unless you are building your own front end:
5
+ OpenCode runs its interface and its server in separate threads that cannot share memory, and neither
6
+ survives a restart. Anything long-lived — a process, a watcher, a subscription — has nowhere to
7
+ live. This package is that home.
8
+
9
+ One daemon per machine, shared by every OpenCode window, started on demand and gone when idle.
10
+
11
+ > Installing the plugin is what you usually want. This package matters if you are building your own
12
+ > front end, writing a capability, or debugging the daemon itself.
13
+ >
14
+ > ```sh
15
+ > opencode plugin opencode-cockpit --global
16
+ > ```
17
+
18
+ ---
19
+
20
+ ## What it does
21
+
22
+ | | |
23
+ | --- | --- |
24
+ | **Owns processes** | Every shell runs in a real PTY as its own process group, so a stop kills the children too |
25
+ | **Normalizes output** | Carriage returns, progress bars and cursor moves collapse into clean numbered lines |
26
+ | **Emulates a screen** | A headless terminal keeps what a human would see, for the interface |
27
+ | **Answers conditions** | Wait for a port, a pattern, silence or exit — without polling |
28
+ | **Watches health** | Rules turn an endless log into transitions: ok → fail, and back |
29
+ | **Cleans up** | Process groups are recorded, so a daemon that starts after a crash reaps what was left |
30
+ | **Gets out of the way** | Exits after ten idle minutes with no clients and nothing running |
31
+
32
+ ## Running it
33
+
34
+ Normally the client spawns it for you. To run it by hand:
6
35
 
7
36
  ```sh
8
- opencode plugin opencode-cockpit --global
37
+ bun packages/daemon/src/main.ts
38
+ # or, from an installed plugin, on OpenCode's embedded runtime:
39
+ BUN_BE_BUN=1 "$(which opencode)" node_modules/@opencode-cockpit/daemon/dist/main.js
9
40
  ```
10
41
 
11
- Requires Bun 1.3.5 (OpenCode's embedded runtime qualifies). License: MIT.
42
+ | Variable | Default | Purpose |
43
+ | --- | --- | --- |
44
+ | `COCKPIT_HOME` | `~/.cache/opencode-cockpit` | Socket, logs, process registry, per-shell log files |
45
+ | `COCKPIT_IDLE_TIMEOUT_MS` | `600000` | Time with no clients and nothing running before it exits; `0` never exits |
46
+ | `COCKPIT_LOG_LEVEL` | `info` | `debug` for verbose logs |
47
+
48
+ The socket lives at `$COCKPIT_HOME/cockpitd.sock`, mode `0600`, inside a `0700` directory. Logs go to
49
+ `$COCKPIT_HOME/cockpitd.log` as one JSON object per line.
50
+
51
+ ## How a capability plugs in
52
+
53
+ A capability is a **module**: a namespace, a typed method table, and a lifecycle. The daemon owns
54
+ connections, framing, validation, subscriptions and shutdown; the module owns its own state.
55
+
56
+ ```ts
57
+ import type { Module, ModuleContext, MethodTable } from "@opencode-cockpit/daemon"
58
+
59
+ export class ClockModule implements Module<"clock"> {
60
+ readonly name = "clock" as const
61
+ private timer: ReturnType<typeof setInterval> | undefined
62
+ private emit: ModuleContext["emit"] = () => {}
63
+
64
+ readonly methods: MethodTable<"clock"> = {
65
+ // Params are already parsed and typed from the protocol contract.
66
+ now: () => ({ iso: new Date().toISOString() }),
67
+ }
68
+
69
+ async start(ctx: ModuleContext) {
70
+ this.emit = ctx.emit
71
+ this.timer = setInterval(() => this.emit("clock.tick", { at: Date.now() }), 1000)
72
+ ctx.log.info("clock started")
73
+ }
74
+
75
+ async stop() {
76
+ clearInterval(this.timer)
77
+ }
78
+
79
+ /** While this is true the daemon refuses to shut down for idleness. */
80
+ busy() {
81
+ return false
82
+ }
83
+ }
84
+ ```
85
+
86
+ Methods are named `<namespace>.<method>` and their shapes come from
87
+ [`@opencode-cockpit/protocol`](../protocol) — add the contract there first and the handler is typed
88
+ for you. Events are broadcast to peers that subscribed to the topic.
89
+
90
+ ## Transport
91
+
92
+ JSON-RPC 2.0 over NDJSON on a unix socket. One line per message, requests and responses correlated
93
+ by id, plus server-initiated notifications for events.
94
+
95
+ Each build has an id derived from the daemon's own code. When a client connects carrying a **newer**
96
+ build than the running daemon, and nothing is busy, the daemon shuts down so the newer one can take
97
+ over. If work is in flight it stays, and the client is told — nothing is killed behind your back.
98
+ Only forwards: an older client never replaces a newer daemon.
99
+
100
+ ## Public API
101
+
102
+ | Export | Use |
103
+ | --- | --- |
104
+ | `Daemon`, `DaemonOptions` | Construct and run a daemon with your own module set |
105
+ | `createModules`, `ModuleOptions` | The modules that ship with Cockpit |
106
+ | `Module`, `ModuleContext`, `MethodTable`, `CallContext`, `Peer` | Types for writing one |
107
+ | `ShellModule`, `ShellModuleOptions` | The shell capability, if you want it on its own |
108
+ | `PtyBackend`, `PtyProcess`, `PtySpawnOptions` | Swap the PTY implementation, e.g. in tests |
109
+
110
+ ```ts
111
+ import { Daemon, createModules } from "@opencode-cockpit/daemon"
112
+ import { resolvePaths } from "@opencode-cockpit/protocol"
113
+
114
+ const daemon = new Daemon({
115
+ paths: resolvePaths(),
116
+ modules: createModules({ shell: { maxFinished: 50 } }),
117
+ idleTimeoutMs: 600_000,
118
+ })
119
+ await daemon.start()
120
+ ```
121
+
122
+ ## Requirements
123
+
124
+ Bun ≥ 1.3.5 — OpenCode's embedded runtime qualifies, so no separate install. macOS and Linux.
125
+
126
+ ## More
127
+
128
+ [Architecture](https://codestz.github.io/opencode-cockpit/platform/architecture/) ·
129
+ [Repository](https://github.com/Codestz/opencode-cockpit) ·
130
+ [Contributing](https://github.com/Codestz/opencode-cockpit/blob/main/CONTRIBUTING.md)
131
+
132
+ MIT
@@ -21,7 +21,12 @@ export class Daemon {
21
21
  this.log = createLogger(options.logToFile === false ? undefined : options.paths.logFile, options.logLevel);
22
22
  this.server = new RpcServer(this.router, {
23
23
  onConnect: () => this.refreshIdle(),
24
- onDisconnect: () => this.refreshIdle()
24
+ onDisconnect: (_count, peer) => {
25
+ // A window may keep working through its other half, so modules are told who is left.
26
+ const remaining = this.server.instances;
27
+ for (const module of this.options.modules) module.peerClosed?.(peer, remaining);
28
+ this.refreshIdle();
29
+ }
25
30
  }, this.log.child("rpc"));
26
31
  this.registerCore();
27
32
  for (const module of options.modules) this.router.addModule(module);
@@ -43,7 +48,8 @@ export class Daemon {
43
48
  this.server.broadcast(topic, data);
44
49
  // Module state changes (a shell exiting) can make the daemon idle.
45
50
  this.refreshIdle();
46
- }
51
+ },
52
+ instances: () => this.server.instances
47
53
  });
48
54
  }
49
55
  this.server.listen(paths.socket);
@@ -142,7 +148,7 @@ export class Daemon {
142
148
  busy: this.busy()
143
149
  });
144
150
  }
145
- peer.greet(params.client.name);
151
+ peer.greet(params.client.name, params.client.instance);
146
152
  return {
147
153
  daemonVersion: DAEMON_VERSION,
148
154
  build: DAEMON_BUILD,
@@ -2,6 +2,8 @@ import { ErrorCode, EVENT_METHOD, encodeFrame, LineDecoder, RpcError } from "@op
2
2
  const MAX_QUEUED_BYTES = 32 * 1024 * 1024;
3
3
  class PeerImpl {
4
4
  name = "unknown";
5
+ /** The OpenCode window this connection belongs to; both halves of a plugin share one. */
6
+
5
7
  greeted = false;
6
8
  topics = new Set();
7
9
  closers = [];
@@ -13,8 +15,9 @@ class PeerImpl {
13
15
  this.socket = socket;
14
16
  this.log = log;
15
17
  }
16
- greet(name) {
18
+ greet(name, instance) {
17
19
  this.name = name;
20
+ this.instance = instance;
18
21
  this.greeted = true;
19
22
  }
20
23
  onClose(fn) {
@@ -95,6 +98,13 @@ export class RpcServer {
95
98
  get clientCount() {
96
99
  return this.peers.size;
97
100
  }
101
+
102
+ /** The OpenCode windows currently connected, by instance id. */
103
+ get instances() {
104
+ const ids = new Set();
105
+ for (const peer of this.peers) if (peer.instance) ids.add(peer.instance);
106
+ return ids;
107
+ }
98
108
  listen(path) {
99
109
  const decoders = new WeakMap();
100
110
  this.listener = Bun.listen({
@@ -150,7 +160,7 @@ export class RpcServer {
150
160
  drop(peer) {
151
161
  if (!this.peers.delete(peer)) return;
152
162
  peer.close();
153
- this.hooks.onDisconnect(this.peers.size);
163
+ this.hooks.onDisconnect(this.peers.size, peer);
154
164
  }
155
165
  async handleLine(peer, line) {
156
166
  let message;
@@ -4,6 +4,9 @@ import { waitFor } from "./wait.js";
4
4
  import { PRESETS, presetByName, presetForCommand } from "./watch/presets.js";
5
5
  import { compileRule, Watcher } from "./watch/watcher.js";
6
6
 
7
+ /** Preset name for a watcher with no patterns: it only reports the process dying. */
8
+ const EXIT_ONLY = "exit";
9
+
7
10
  /**
8
11
  * The `shell.*` methods, kept apart from the module's lifecycle and bookkeeping so each file has
9
12
  * one job: this one maps protocol calls onto the module, `module.ts` owns the shells.
@@ -71,18 +74,28 @@ export function shellMethods(module) {
71
74
  id,
72
75
  signal,
73
76
  graceMs
77
+ }, {
78
+ peer
74
79
  }) => {
75
80
  const shell = module.require(id);
76
- await shell.stop(signal, graceMs);
81
+ await shell.stop(signal, graceMs, {
82
+ reason: "request",
83
+ by: peer.name
84
+ });
77
85
  await shell.exited;
78
86
  return shell.info();
79
87
  },
80
88
  restart: async ({
81
89
  id
90
+ }, {
91
+ peer
82
92
  }) => {
83
93
  const shell = module.require(id);
84
94
  if (shell.running) {
85
- await shell.stop("SIGTERM", 3000);
95
+ await shell.stop("SIGTERM", 3000, {
96
+ reason: "request",
97
+ by: peer.name
98
+ });
86
99
  await shell.exited;
87
100
  }
88
101
  module.spawn(shell);
@@ -90,10 +103,15 @@ export function shellMethods(module) {
90
103
  },
91
104
  remove: async ({
92
105
  id
106
+ }, {
107
+ peer
93
108
  }) => {
94
109
  const shell = module.require(id);
95
110
  if (shell.running) {
96
- await shell.stop("SIGTERM", 3000);
111
+ await shell.stop("SIGTERM", 3000, {
112
+ reason: "request",
113
+ by: peer.name
114
+ });
97
115
  await shell.exited;
98
116
  }
99
117
  module.forget(shell);
@@ -140,14 +158,14 @@ export function shellMethods(module) {
140
158
  }) => {
141
159
  const shell = module.require(id);
142
160
  const command = [shell.spec.command, ...shell.spec.args].join(" ");
143
- const chosen = rule ? undefined : preset && preset !== "auto" ? presetByName(preset) ?? invalidParams(`unknown preset "${preset}"; call shell.presets for the list`) : presetForCommand(command);
161
+ const named = preset && preset !== "auto" && preset !== EXIT_ONLY;
162
+ const chosen = rule ? undefined : named ? presetByName(preset) ?? invalidParams(`no watch preset named "${preset}". Call shell.presets for the list, or pass your own rule (done/fail/ok patterns).`) : preset === EXIT_ONLY ? undefined : presetForCommand(command);
144
163
  if (chosen instanceof Error) throw chosen;
145
- const watchRule = rule ?? chosen?.rule;
146
- if (!watchRule) {
147
- throw invalidParams(`no watch preset matches "${command.slice(0, 80)}"; pass a rule (done/fail/ok patterns) or a preset name`);
148
- }
164
+ // No pattern fits a command like `sleep 300`, and that is still worth watching: an empty rule
165
+ // reports nothing until the process dies, which is exactly crash detection.
166
+ const watchRule = rule ?? chosen?.rule ?? {};
149
167
  try {
150
- shell.watcher = new Watcher(compileRule(watchRule), chosen?.name);
168
+ shell.watcher = new Watcher(compileRule(watchRule), chosen?.name ?? (rule ? undefined : EXIT_ONLY));
151
169
  } catch (err) {
152
170
  throw invalidParams(`invalid watch pattern: ${err instanceof Error ? err.message : String(err)}`);
153
171
  }
@@ -19,6 +19,9 @@ export class ShellModule {
19
19
  attachments = new Map(); // `${peer.id}:${shellId}` → detach
20
20
 
21
21
  log = silentLogger;
22
+ /** When each absent window was last seen, for the orphan sweep. */
23
+ goneSince = new Map();
24
+ connected = () => new Set();
22
25
  idleTimers = new Map();
23
26
  /** @internal */
24
27
  emit = () => {};
@@ -33,6 +36,10 @@ export class ShellModule {
33
36
  async start(ctx) {
34
37
  this.log = ctx.log;
35
38
  this.emit = ctx.emit;
39
+ this.connected = ctx.instances;
40
+ // A shell whose window never comes back should not outlive the day. Checked rarely: the
41
+ // decision is a timestamp comparison, and only shells that asked for it are considered.
42
+ this.sweepTimer = setInterval(() => this.sweepOrphans(), this.options.orphanSweepMs ?? 60_000);
36
43
  if (this.options.registryFile) {
37
44
  this.registry = new ProcessRegistry(this.options.registryFile, ctx.log);
38
45
  const reaped = this.registry.reap();
@@ -42,13 +49,61 @@ export class ShellModule {
42
49
  }
43
50
  }
44
51
  async stop() {
52
+ clearInterval(this.sweepTimer);
45
53
  for (const id of [...this.idleTimers.keys()]) this.clearIdle(id);
46
- await Promise.all([...this.shells.values()].map(s => s.stop("SIGTERM", 2000).catch(() => {})));
54
+ await Promise.all([...this.shells.values()].map(s => s.stop("SIGTERM", 2000, {
55
+ reason: "shutdown"
56
+ }).catch(() => {})));
47
57
  for (const detach of this.attachments.values()) detach();
48
58
  for (const shell of this.shells.values()) shell.dispose();
49
59
  this.attachments.clear();
50
60
  this.shells.clear();
51
61
  }
62
+
63
+ /**
64
+ * An OpenCode window disconnected. Both halves of a plugin share an instance id, so this only
65
+ * counts as "the window is gone" once neither half is connected any more.
66
+ */
67
+ peerClosed(peer, remaining) {
68
+ const instance = peer.instance;
69
+ if (!instance || remaining.has(instance)) return;
70
+ this.goneSince.set(instance, Date.now());
71
+ for (const shell of this.shells.values()) {
72
+ if (!shell.spec.stopOnExit || shell.spec.owner.instance !== instance || !shell.running) continue;
73
+ this.log.info("stopping shell with its window", {
74
+ id: shell.id,
75
+ instance
76
+ });
77
+ void shell.stop("SIGTERM", 2000, {
78
+ reason: "shutdown",
79
+ because: "the OpenCode window that started it closed"
80
+ }).catch(() => {});
81
+ }
82
+ }
83
+
84
+ /** Shells whose window has been gone longer than they allow. */
85
+ sweepOrphans() {
86
+ const live = this.connected();
87
+ for (const instance of [...this.goneSince.keys()]) if (live.has(instance)) this.goneSince.delete(instance);
88
+ const now = Date.now();
89
+ for (const shell of this.shells.values()) {
90
+ const limit = shell.spec.orphanAfterMs;
91
+ const instance = shell.spec.owner.instance;
92
+ if (!limit || !instance || !shell.running || live.has(instance)) continue;
93
+ const gone = this.goneSince.get(instance) ?? now;
94
+ this.goneSince.set(instance, gone);
95
+ if (now - gone < limit) continue;
96
+ this.log.info("stopping orphaned shell", {
97
+ id: shell.id,
98
+ instance,
99
+ afterMs: now - gone
100
+ });
101
+ void shell.stop("SIGTERM", 2000, {
102
+ reason: "shutdown",
103
+ because: `nothing watched it for ${Math.round((now - gone) / 60_000)} minutes`
104
+ }).catch(() => {});
105
+ }
106
+ }
52
107
  busy() {
53
108
  for (const shell of this.shells.values()) if (shell.running) return true;
54
109
  return false;
@@ -64,7 +119,9 @@ export class ShellModule {
64
119
  env: this.environment(params.env),
65
120
  title: params.title ?? previous.spec.title,
66
121
  timeoutMs: params.timeoutMs,
67
- owner: params.owner
122
+ owner: params.owner,
123
+ stopOnExit: params.stopOnExit,
124
+ orphanAfterMs: params.orphanAfterMs
68
125
  });
69
126
  this.spawn(previous);
70
127
  return previous.info();
@@ -83,7 +140,9 @@ export class ShellModule {
83
140
  owner: params.owner,
84
141
  timeoutMs: params.timeoutMs,
85
142
  idleTimeoutMs: params.idleTimeoutMs,
86
- logFile: params.logFile ? join(this.options.logDir ?? "/tmp", `${id}.log`) : undefined
143
+ logFile: params.logFile ? join(this.options.logDir ?? "/tmp", `${id}.log`) : undefined,
144
+ stopOnExit: params.stopOnExit,
145
+ orphanAfterMs: params.orphanAfterMs
87
146
  }, this.backend, this.limits);
88
147
  this.shells.set(shell.id, shell);
89
148
  shell.subscribe({
@@ -26,9 +26,12 @@ export class RawRing {
26
26
  return offset;
27
27
  }
28
28
 
29
- /** Bytes from `offset` (clamped to what is retained) to the end. */
29
+ /**
30
+ * Bytes from `offset` to the end, clamped to what is retained. An offset past the end asks for
31
+ * nothing but future output — a UI that only wants what comes next — and must not be an error.
32
+ */
30
33
  since(offset = 0) {
31
- const from = Math.max(offset, this.start);
34
+ const from = Math.min(Math.max(offset, this.start), this.end);
32
35
  const out = new Uint8Array(this.end - from);
33
36
  let cursor = this.start;
34
37
  let written = 0;
@@ -64,6 +64,8 @@ export class Shell {
64
64
  }
65
65
  this.runStartLine = this.log.lastLine + 1;
66
66
  this.stoppedBecause = undefined;
67
+ this.stopReason = undefined;
68
+ this.stoppedBy = undefined;
67
69
  if (this.spec.logFile && !this.logWriter) {
68
70
  mkdirSync(dirname(this.spec.logFile), {
69
71
  recursive: true
@@ -111,6 +113,7 @@ export class Shell {
111
113
  if (this.spec.timeoutMs) {
112
114
  this.timeout = setTimeout(() => {
113
115
  this.stoppedBecause = `reached its ${Math.round((this.spec.timeoutMs ?? 0) / 1000)}s time limit`;
116
+ this.stopReason = "timeout";
114
117
  void this.stop("SIGTERM", 3000);
115
118
  }, this.spec.timeoutMs);
116
119
  }
@@ -119,6 +122,7 @@ export class Shell {
119
122
  this.idleTimer = setInterval(() => {
120
123
  if (!this.running || Date.now() - this.lastOutputAt < idleMs) return;
121
124
  this.stoppedBecause = `produced no output for ${Math.round(idleMs / 1000)}s`;
125
+ this.stopReason = "idle";
122
126
  void this.stop("SIGTERM", 3000);
123
127
  }, Math.max(500, Math.floor(idleMs / 4)));
124
128
  }
@@ -134,11 +138,19 @@ export class Shell {
134
138
  if (this.running) this.pty?.resize(cols, rows);
135
139
  }
136
140
 
137
- /** Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. */
138
- async stop(signal = "SIGTERM", graceMs = 3000) {
141
+ /**
142
+ * Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. `cause` says who
143
+ * asked, so an exit can be reported as a stop rather than as an unexplained kill.
144
+ */
145
+ async stop(signal = "SIGTERM", graceMs = 3000, cause = {
146
+ reason: "request"
147
+ }) {
139
148
  const pty = this.pty;
140
149
  if (!pty || !this.running) return;
141
150
  this.stopRequested = true;
151
+ this.stopReason ??= cause.reason;
152
+ this.stoppedBy ??= cause.by;
153
+ this.stoppedBecause ??= cause.because;
142
154
  pty.signal(signal);
143
155
  const exited = await Promise.race([this.exitPromise.then(() => true), Bun.sleep(graceMs).then(() => false)]);
144
156
  if (!exited) {
@@ -174,6 +186,8 @@ export class Shell {
174
186
  if (this.exit?.signal) info.signal = this.exit.signal;
175
187
  if (this.error) info.error = this.error;
176
188
  if (this.summary) info.summary = this.summary;
189
+ if (this.stopReason) info.stopReason = this.stopReason;
190
+ if (this.stoppedBy) info.stoppedBy = this.stoppedBy;
177
191
  if (this.spec.logFile) info.logFile = this.spec.logFile;
178
192
  if (this.watcher) info.watch = this.watcher.state();
179
193
  if (this.endedAt) info.endedAt = this.endedAt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-cockpit/daemon",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "cockpitd: the process host behind opencode-cockpit (PTY shells, clean logs, wait conditions)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@opencode-cockpit/protocol": "0.2.0",
44
+ "@opencode-cockpit/protocol": "0.2.2",
45
45
  "@xterm/headless": "6.0.0"
46
46
  },
47
47
  "engines": {
@@ -4,13 +4,15 @@ import type { Logger } from "./logger.ts";
4
4
  export interface Peer {
5
5
  readonly id: number;
6
6
  readonly name: string;
7
+ /** The OpenCode window this connection belongs to, when the client said. */
8
+ readonly instance: string | undefined;
7
9
  /** Send an event to this peer only, regardless of its subscriptions. */
8
10
  send(topic: string, data: unknown): void;
9
11
  /** Run when the peer disconnects. */
10
12
  onClose(fn: () => void): void;
11
13
  /** Topic patterns: exact (`shell.exited`), namespace (`shell.*`) or everything (`*`). */
12
14
  readonly topics: Set<string>;
13
- greet(name: string): void;
15
+ greet(name: string, instance?: string): void;
14
16
  }
15
17
  export interface CallContext {
16
18
  peer: Peer;
@@ -19,6 +21,8 @@ export interface ModuleContext {
19
21
  log: Logger;
20
22
  /** Broadcast to every peer subscribed to `topic`. */
21
23
  emit(topic: string, data: unknown): void;
24
+ /** OpenCode windows currently connected, by instance id. */
25
+ instances(): Set<string>;
22
26
  }
23
27
  type Handler<M extends MethodName> = (params: ParsedParamsOf<Methods, M>, call: CallContext) => Promise<ResultOf<Methods, M>> | ResultOf<Methods, M>;
24
28
  /** Handlers for the methods under one namespace, typed from the protocol contract. */
@@ -31,6 +35,8 @@ export interface Module<NS extends string = string> {
31
35
  readonly methods: string extends NS ? object : MethodTable<NS>;
32
36
  start(ctx: ModuleContext): Promise<void>;
33
37
  stop(): Promise<void>;
38
+ /** A connection went away. The window may still be here through its other half. */
39
+ peerClosed?(peer: Peer, remaining: Set<string>): void;
34
40
  /** While true the daemon will not shut down for idleness. */
35
41
  busy(): boolean;
36
42
  }
@@ -10,6 +10,8 @@ declare class PeerImpl implements Peer {
10
10
  private readonly socket;
11
11
  private readonly log;
12
12
  name: string;
13
+ /** The OpenCode window this connection belongs to; both halves of a plugin share one. */
14
+ instance: string | undefined;
13
15
  greeted: boolean;
14
16
  readonly topics: Set<string>;
15
17
  private readonly closers;
@@ -17,7 +19,7 @@ declare class PeerImpl implements Peer {
17
19
  private queued;
18
20
  closed: boolean;
19
21
  constructor(id: number, socket: Socket<ConnState>, log: Logger);
20
- greet(name: string): void;
22
+ greet(name: string, instance?: string): void;
21
23
  onClose(fn: () => void): void;
22
24
  send(topic: string, data: unknown): void;
23
25
  subscribed(topic: string): boolean;
@@ -28,7 +30,7 @@ declare class PeerImpl implements Peer {
28
30
  }
29
31
  export interface RpcServerHooks {
30
32
  onConnect(count: number): void;
31
- onDisconnect(count: number): void;
33
+ onDisconnect(count: number, peer: Peer): void;
32
34
  }
33
35
  export declare class RpcServer {
34
36
  private readonly router;
@@ -39,6 +41,8 @@ export declare class RpcServer {
39
41
  private nextId;
40
42
  constructor(router: Router, hooks: RpcServerHooks, log: Logger);
41
43
  get clientCount(): number;
44
+ /** The OpenCode windows currently connected, by instance id. */
45
+ get instances(): Set<string>;
42
46
  listen(path: string): void;
43
47
  broadcast(topic: string, data: unknown): void;
44
48
  stop(): void;
@@ -15,6 +15,8 @@ export interface ShellModuleOptions {
15
15
  registryFile?: string;
16
16
  /** Directory for per-shell log files, when a caller asks for one. */
17
17
  logDir?: string;
18
+ /** How often to look for shells whose window is gone for good. */
19
+ orphanSweepMs?: number;
18
20
  }
19
21
  export declare class ShellModule implements Module<"shell"> {
20
22
  private readonly options;
@@ -26,6 +28,10 @@ export declare class ShellModule implements Module<"shell"> {
26
28
  private readonly backend;
27
29
  private readonly limits;
28
30
  private log;
31
+ /** When each absent window was last seen, for the orphan sweep. */
32
+ private readonly goneSince;
33
+ private sweepTimer;
34
+ private connected;
29
35
  private registry;
30
36
  private readonly idleTimers;
31
37
  /** @internal */
@@ -33,6 +39,15 @@ export declare class ShellModule implements Module<"shell"> {
33
39
  constructor(options?: ShellModuleOptions);
34
40
  start(ctx: ModuleContext): Promise<void>;
35
41
  stop(): Promise<void>;
42
+ /**
43
+ * An OpenCode window disconnected. Both halves of a plugin share an instance id, so this only
44
+ * counts as "the window is gone" once neither half is connected any more.
45
+ */
46
+ peerClosed(peer: {
47
+ instance?: string;
48
+ }, remaining: Set<string>): void;
49
+ /** Shells whose window has been gone longer than they allow. */
50
+ private sweepOrphans;
36
51
  busy(): boolean;
37
52
  readonly methods: MethodTable<"shell">;
38
53
  /** @internal used by methods.ts */
@@ -11,7 +11,10 @@ export declare class RawRing {
11
11
  /** Absolute offset one past the last byte ever written. */
12
12
  get end(): number;
13
13
  append(chunk: Uint8Array): number;
14
- /** Bytes from `offset` (clamped to what is retained) to the end. */
14
+ /**
15
+ * Bytes from `offset` to the end, clamped to what is retained. An offset past the end asks for
16
+ * nothing but future output — a UI that only wants what comes next — and must not be an error.
17
+ */
15
18
  since(offset?: number): {
16
19
  offset: number;
17
20
  bytes: Uint8Array;
@@ -1,4 +1,4 @@
1
- import type { LogLine, Owner, ScreenResult, ShellInfo, ShellStatus } from "@opencode-cockpit/protocol/shell";
1
+ import type { LogLine, Owner, ScreenResult, ShellInfo, ShellStatus, StopReason } from "@opencode-cockpit/protocol/shell";
2
2
  import { LineLog } from "./output/line-log.ts";
3
3
  import { RawRing } from "./output/raw-ring.ts";
4
4
  import { Screen } from "./output/screen.ts";
@@ -18,6 +18,10 @@ export interface ShellSpec {
18
18
  idleTimeoutMs?: number;
19
19
  /** Absolute path the clean log is appended to, when logging was requested. */
20
20
  logFile?: string;
21
+ /** Stop when the OpenCode window that started it goes away. */
22
+ stopOnExit?: boolean;
23
+ /** Stop after this long with that window gone. */
24
+ orphanAfterMs?: number;
21
25
  }
22
26
  export interface ShellLimits {
23
27
  logChars: number;
@@ -59,6 +63,8 @@ export declare class Shell {
59
63
  private error;
60
64
  /** Why the daemon stopped it, when it was not a user or agent request. */
61
65
  private stoppedBecause;
66
+ private stopReason;
67
+ private stoppedBy;
62
68
  private summary;
63
69
  private stopRequested;
64
70
  private timeout;
@@ -76,8 +82,15 @@ export declare class Shell {
76
82
  start(): void;
77
83
  write(data: string): number;
78
84
  resize(cols: number, rows: number): void;
79
- /** Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. */
80
- stop(signal?: NodeJS.Signals, graceMs?: number): Promise<void>;
85
+ /**
86
+ * Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. `cause` says who
87
+ * asked, so an exit can be reported as a stop rather than as an unexplained kill.
88
+ */
89
+ stop(signal?: NodeJS.Signals, graceMs?: number, cause?: {
90
+ reason: StopReason;
91
+ by?: string;
92
+ because?: string;
93
+ }): Promise<void>;
81
94
  snapshot(): Promise<ScreenResult>;
82
95
  info(): ShellInfo;
83
96
  dispose(): void;