@opencode-cockpit/daemon 0.2.1 → 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;
@@ -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,6 +49,7 @@ 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
54
  await Promise.all([...this.shells.values()].map(s => s.stop("SIGTERM", 2000, {
47
55
  reason: "shutdown"
@@ -51,6 +59,51 @@ export class ShellModule {
51
59
  this.attachments.clear();
52
60
  this.shells.clear();
53
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
+ }
54
107
  busy() {
55
108
  for (const shell of this.shells.values()) if (shell.running) return true;
56
109
  return false;
@@ -66,7 +119,9 @@ export class ShellModule {
66
119
  env: this.environment(params.env),
67
120
  title: params.title ?? previous.spec.title,
68
121
  timeoutMs: params.timeoutMs,
69
- owner: params.owner
122
+ owner: params.owner,
123
+ stopOnExit: params.stopOnExit,
124
+ orphanAfterMs: params.orphanAfterMs
70
125
  });
71
126
  this.spawn(previous);
72
127
  return previous.info();
@@ -85,7 +140,9 @@ export class ShellModule {
85
140
  owner: params.owner,
86
141
  timeoutMs: params.timeoutMs,
87
142
  idleTimeoutMs: params.idleTimeoutMs,
88
- 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
89
146
  }, this.backend, this.limits);
90
147
  this.shells.set(shell.id, shell);
91
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;
@@ -150,6 +150,7 @@ export class Shell {
150
150
  this.stopRequested = true;
151
151
  this.stopReason ??= cause.reason;
152
152
  this.stoppedBy ??= cause.by;
153
+ this.stoppedBecause ??= cause.because;
153
154
  pty.signal(signal);
154
155
  const exited = await Promise.race([this.exitPromise.then(() => true), Bun.sleep(graceMs).then(() => false)]);
155
156
  if (!exited) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-cockpit/daemon",
3
- "version": "0.2.1",
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.1",
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;
@@ -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;
@@ -85,6 +89,7 @@ export declare class Shell {
85
89
  stop(signal?: NodeJS.Signals, graceMs?: number, cause?: {
86
90
  reason: StopReason;
87
91
  by?: string;
92
+ because?: string;
88
93
  }): Promise<void>;
89
94
  snapshot(): Promise<ScreenResult>;
90
95
  info(): ShellInfo;