@opencode-cockpit/daemon 0.2.0 → 0.2.1

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.
@@ -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
  }
@@ -43,7 +43,9 @@ export class ShellModule {
43
43
  }
44
44
  async stop() {
45
45
  for (const id of [...this.idleTimers.keys()]) this.clearIdle(id);
46
- await Promise.all([...this.shells.values()].map(s => s.stop("SIGTERM", 2000).catch(() => {})));
46
+ await Promise.all([...this.shells.values()].map(s => s.stop("SIGTERM", 2000, {
47
+ reason: "shutdown"
48
+ }).catch(() => {})));
47
49
  for (const detach of this.attachments.values()) detach();
48
50
  for (const shell of this.shells.values()) shell.dispose();
49
51
  this.attachments.clear();
@@ -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,18 @@ 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;
142
153
  pty.signal(signal);
143
154
  const exited = await Promise.race([this.exitPromise.then(() => true), Bun.sleep(graceMs).then(() => false)]);
144
155
  if (!exited) {
@@ -174,6 +185,8 @@ export class Shell {
174
185
  if (this.exit?.signal) info.signal = this.exit.signal;
175
186
  if (this.error) info.error = this.error;
176
187
  if (this.summary) info.summary = this.summary;
188
+ if (this.stopReason) info.stopReason = this.stopReason;
189
+ if (this.stoppedBy) info.stoppedBy = this.stoppedBy;
177
190
  if (this.spec.logFile) info.logFile = this.spec.logFile;
178
191
  if (this.watcher) info.watch = this.watcher.state();
179
192
  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.1",
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.1",
45
45
  "@xterm/headless": "6.0.0"
46
46
  },
47
47
  "engines": {
@@ -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";
@@ -59,6 +59,8 @@ export declare class Shell {
59
59
  private error;
60
60
  /** Why the daemon stopped it, when it was not a user or agent request. */
61
61
  private stoppedBecause;
62
+ private stopReason;
63
+ private stoppedBy;
62
64
  private summary;
63
65
  private stopRequested;
64
66
  private timeout;
@@ -76,8 +78,14 @@ export declare class Shell {
76
78
  start(): void;
77
79
  write(data: string): number;
78
80
  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>;
81
+ /**
82
+ * Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. `cause` says who
83
+ * asked, so an exit can be reported as a stop rather than as an unexplained kill.
84
+ */
85
+ stop(signal?: NodeJS.Signals, graceMs?: number, cause?: {
86
+ reason: StopReason;
87
+ by?: string;
88
+ }): Promise<void>;
81
89
  snapshot(): Promise<ScreenResult>;
82
90
  info(): ShellInfo;
83
91
  dispose(): void;