@opencode-cockpit/daemon 0.1.5 → 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.
package/dist/main.js CHANGED
@@ -11,7 +11,8 @@ const daemon = new Daemon({
11
11
  paths,
12
12
  modules: createModules({
13
13
  shell: {
14
- registryFile: join(paths.home, "shells.json")
14
+ registryFile: join(paths.home, "shells.json"),
15
+ logDir: join(paths.home, "logs")
15
16
  }
16
17
  }),
17
18
  idleTimeoutMs: Number(env.COCKPIT_IDLE_TIMEOUT_MS ?? 10 * 60_000),
@@ -0,0 +1,204 @@
1
+ import { invalidParams, invalidState } from "../../core/errors.js";
2
+ import { compilePattern } from "./module.js";
3
+ import { waitFor } from "./wait.js";
4
+ import { PRESETS, presetByName, presetForCommand } from "./watch/presets.js";
5
+ import { compileRule, Watcher } from "./watch/watcher.js";
6
+
7
+ /** Preset name for a watcher with no patterns: it only reports the process dying. */
8
+ const EXIT_ONLY = "exit";
9
+
10
+ /**
11
+ * The `shell.*` methods, kept apart from the module's lifecycle and bookkeeping so each file has
12
+ * one job: this one maps protocol calls onto the module, `module.ts` owns the shells.
13
+ */
14
+ export function shellMethods(module) {
15
+ return {
16
+ start: params => module.startShell(params),
17
+ list: params => {
18
+ const owner = params.owner;
19
+ return [...module.shells.values()].filter(s => params.includeExited || s.running).filter(s => !owner?.project || s.spec.owner.project === owner.project).filter(s => !owner?.session || s.spec.owner.session === owner.session).map(s => s.info());
20
+ },
21
+ get: ({
22
+ id
23
+ }) => module.require(id).info(),
24
+ read: ({
25
+ id,
26
+ after,
27
+ tail,
28
+ limit,
29
+ grep,
30
+ ignoreCase
31
+ }) => {
32
+ const shell = module.require(id);
33
+ const page = shell.log.read({
34
+ after,
35
+ tail,
36
+ limit,
37
+ grep: grep === undefined ? undefined : compilePattern(grep, ignoreCase)
38
+ });
39
+ return {
40
+ ...page,
41
+ status: shell.status
42
+ };
43
+ },
44
+ screen: ({
45
+ id
46
+ }) => module.require(id).snapshot(),
47
+ write: ({
48
+ id,
49
+ data
50
+ }) => {
51
+ const shell = module.require(id);
52
+ if (!shell.running) throw invalidState(`shell ${id} is ${shell.status}`);
53
+ return {
54
+ bytes: shell.write(data)
55
+ };
56
+ },
57
+ resize: ({
58
+ id,
59
+ cols,
60
+ rows
61
+ }) => {
62
+ module.require(id).resize(cols, rows);
63
+ return {};
64
+ },
65
+ wait: async params => {
66
+ const shell = module.require(params.id);
67
+ const outcome = await waitFor(shell, params, compilePattern);
68
+ return {
69
+ ...outcome,
70
+ info: shell.info()
71
+ };
72
+ },
73
+ stop: async ({
74
+ id,
75
+ signal,
76
+ graceMs
77
+ }, {
78
+ peer
79
+ }) => {
80
+ const shell = module.require(id);
81
+ await shell.stop(signal, graceMs, {
82
+ reason: "request",
83
+ by: peer.name
84
+ });
85
+ await shell.exited;
86
+ return shell.info();
87
+ },
88
+ restart: async ({
89
+ id
90
+ }, {
91
+ peer
92
+ }) => {
93
+ const shell = module.require(id);
94
+ if (shell.running) {
95
+ await shell.stop("SIGTERM", 3000, {
96
+ reason: "request",
97
+ by: peer.name
98
+ });
99
+ await shell.exited;
100
+ }
101
+ module.spawn(shell);
102
+ return shell.info();
103
+ },
104
+ remove: async ({
105
+ id
106
+ }, {
107
+ peer
108
+ }) => {
109
+ const shell = module.require(id);
110
+ if (shell.running) {
111
+ await shell.stop("SIGTERM", 3000, {
112
+ reason: "request",
113
+ by: peer.name
114
+ });
115
+ await shell.exited;
116
+ }
117
+ module.forget(shell);
118
+ return {};
119
+ },
120
+ attach: ({
121
+ id,
122
+ fromOffset
123
+ }, {
124
+ peer
125
+ }) => {
126
+ const shell = module.require(id);
127
+ module.detach(peer, id);
128
+ const replay = shell.raw.since(fromOffset ?? 0);
129
+ module.attachStream(peer, shell);
130
+ return {
131
+ offset: replay.offset,
132
+ replay: Buffer.from(replay.bytes).toString("base64")
133
+ };
134
+ },
135
+ clear: ({
136
+ owner,
137
+ finishedBeforeMs
138
+ }) => {
139
+ const cutoff = Date.now() - (finishedBeforeMs ?? 0);
140
+ const removed = [];
141
+ for (const shell of [...module.shells.values()]) {
142
+ if (shell.running) continue;
143
+ const info = shell.info();
144
+ if (owner?.project && info.owner.project !== owner.project) continue;
145
+ if (owner?.session && info.owner.session !== owner.session) continue;
146
+ if ((info.endedAt ?? 0) > cutoff) continue;
147
+ module.forget(shell);
148
+ removed.push(info.id);
149
+ }
150
+ return {
151
+ removed
152
+ };
153
+ },
154
+ watch: ({
155
+ id,
156
+ preset,
157
+ rule
158
+ }) => {
159
+ const shell = module.require(id);
160
+ const command = [shell.spec.command, ...shell.spec.args].join(" ");
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);
163
+ if (chosen instanceof Error) throw chosen;
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 ?? {};
167
+ try {
168
+ shell.watcher = new Watcher(compileRule(watchRule), chosen?.name ?? (rule ? undefined : EXIT_ONLY));
169
+ } catch (err) {
170
+ throw invalidParams(`invalid watch pattern: ${err instanceof Error ? err.message : String(err)}`);
171
+ }
172
+ shell.onWatchChange = change => {
173
+ module.emit("shell.watch", {
174
+ info: shell.info(),
175
+ ...change
176
+ });
177
+ };
178
+ module.armIdle(shell);
179
+ return shell.info();
180
+ },
181
+ unwatch: ({
182
+ id
183
+ }) => {
184
+ const shell = module.require(id);
185
+ shell.watcher = undefined;
186
+ shell.onWatchChange = undefined;
187
+ module.clearIdle(id);
188
+ return shell.info();
189
+ },
190
+ presets: () => PRESETS.map(preset => ({
191
+ name: preset.name,
192
+ match: preset.match,
193
+ rule: preset.rule
194
+ })),
195
+ detach: ({
196
+ id
197
+ }, {
198
+ peer
199
+ }) => {
200
+ module.detach(peer, id);
201
+ return {};
202
+ }
203
+ };
204
+ }
@@ -1,10 +1,11 @@
1
- import { invalidParams, invalidState, notFound } from "../../core/errors.js";
1
+ import { join } from "node:path";
2
+ import { invalidParams, notFound } from "../../core/errors.js";
2
3
  import { silentLogger } from "../../core/logger.js";
3
4
  import { newShellId } from "./ids.js";
5
+ import { shellMethods } from "./methods.js";
4
6
  import { bunPtyBackend } from "./pty.js";
5
7
  import { ProcessRegistry } from "./registry.js";
6
8
  import { Shell } from "./shell.js";
7
- import { waitFor } from "./wait.js";
8
9
  const DEFAULT_LIMITS = {
9
10
  logChars: 4_000_000,
10
11
  rawBytes: 1_000_000,
@@ -12,10 +13,14 @@ const DEFAULT_LIMITS = {
12
13
  };
13
14
  export class ShellModule {
14
15
  name = "shell";
16
+ /** @internal */
15
17
  shells = new Map();
18
+ /** @internal */
16
19
  attachments = new Map(); // `${peer.id}:${shellId}` → detach
17
20
 
18
21
  log = silentLogger;
22
+ idleTimers = new Map();
23
+ /** @internal */
19
24
  emit = () => {};
20
25
  constructor(options = {}) {
21
26
  this.options = options;
@@ -37,7 +42,10 @@ export class ShellModule {
37
42
  }
38
43
  }
39
44
  async stop() {
40
- await Promise.all([...this.shells.values()].map(s => s.stop("SIGTERM", 2000).catch(() => {})));
45
+ for (const id of [...this.idleTimers.keys()]) this.clearIdle(id);
46
+ await Promise.all([...this.shells.values()].map(s => s.stop("SIGTERM", 2000, {
47
+ reason: "shutdown"
48
+ }).catch(() => {})));
41
49
  for (const detach of this.attachments.values()) detach();
42
50
  for (const shell of this.shells.values()) shell.dispose();
43
51
  this.attachments.clear();
@@ -47,139 +55,9 @@ export class ShellModule {
47
55
  for (const shell of this.shells.values()) if (shell.running) return true;
48
56
  return false;
49
57
  }
50
- methods = {
51
- start: params => this.startShell(params),
52
- list: params => {
53
- const owner = params.owner;
54
- return [...this.shells.values()].filter(s => params.includeExited || s.running).filter(s => !owner?.project || s.spec.owner.project === owner.project).filter(s => !owner?.session || s.spec.owner.session === owner.session).map(s => s.info());
55
- },
56
- get: ({
57
- id
58
- }) => this.require(id).info(),
59
- read: ({
60
- id,
61
- after,
62
- tail,
63
- limit,
64
- grep,
65
- ignoreCase
66
- }) => {
67
- const shell = this.require(id);
68
- const page = shell.log.read({
69
- after,
70
- tail,
71
- limit,
72
- grep: grep === undefined ? undefined : compilePattern(grep, ignoreCase)
73
- });
74
- return {
75
- ...page,
76
- status: shell.status
77
- };
78
- },
79
- screen: ({
80
- id
81
- }) => this.require(id).snapshot(),
82
- write: ({
83
- id,
84
- data
85
- }) => {
86
- const shell = this.require(id);
87
- if (!shell.running) throw invalidState(`shell ${id} is ${shell.status}`);
88
- return {
89
- bytes: shell.write(data)
90
- };
91
- },
92
- resize: ({
93
- id,
94
- cols,
95
- rows
96
- }) => {
97
- this.require(id).resize(cols, rows);
98
- return {};
99
- },
100
- wait: async params => {
101
- const shell = this.require(params.id);
102
- const outcome = await waitFor(shell, params, compilePattern);
103
- return {
104
- ...outcome,
105
- info: shell.info()
106
- };
107
- },
108
- stop: async ({
109
- id,
110
- signal,
111
- graceMs
112
- }) => {
113
- const shell = this.require(id);
114
- await shell.stop(signal, graceMs);
115
- await shell.exited;
116
- return shell.info();
117
- },
118
- restart: async ({
119
- id
120
- }) => {
121
- const shell = this.require(id);
122
- if (shell.running) {
123
- await shell.stop("SIGTERM", 3000);
124
- await shell.exited;
125
- }
126
- this.spawn(shell);
127
- return shell.info();
128
- },
129
- remove: async ({
130
- id
131
- }) => {
132
- const shell = this.require(id);
133
- if (shell.running) {
134
- await shell.stop("SIGTERM", 3000);
135
- await shell.exited;
136
- }
137
- this.forget(shell);
138
- return {};
139
- },
140
- attach: ({
141
- id,
142
- fromOffset
143
- }, {
144
- peer
145
- }) => {
146
- const shell = this.require(id);
147
- this.detach(peer, id);
148
- const replay = shell.raw.since(fromOffset ?? 0);
149
- this.attachStream(peer, shell);
150
- return {
151
- offset: replay.offset,
152
- replay: Buffer.from(replay.bytes).toString("base64")
153
- };
154
- },
155
- clear: ({
156
- owner,
157
- finishedBeforeMs
158
- }) => {
159
- const cutoff = Date.now() - (finishedBeforeMs ?? 0);
160
- const removed = [];
161
- for (const shell of [...this.shells.values()]) {
162
- if (shell.running) continue;
163
- const info = shell.info();
164
- if (owner?.project && info.owner.project !== owner.project) continue;
165
- if (owner?.session && info.owner.session !== owner.session) continue;
166
- if ((info.endedAt ?? 0) > cutoff) continue;
167
- this.forget(shell);
168
- removed.push(info.id);
169
- }
170
- return {
171
- removed
172
- };
173
- },
174
- detach: ({
175
- id
176
- }, {
177
- peer
178
- }) => {
179
- this.detach(peer, id);
180
- return {};
181
- }
182
- };
58
+ methods = shellMethods(this);
59
+
60
+ /** @internal used by methods.ts */
183
61
  startShell(params) {
184
62
  if (params.reuse) {
185
63
  const previous = this.findReusable(params);
@@ -194,8 +72,9 @@ export class ShellModule {
194
72
  return previous.info();
195
73
  }
196
74
  }
75
+ const id = this.uniqueId();
197
76
  const shell = new Shell({
198
- id: this.uniqueId(),
77
+ id,
199
78
  command: params.command,
200
79
  args: params.args,
201
80
  cwd: params.cwd,
@@ -204,7 +83,9 @@ export class ShellModule {
204
83
  cols: params.cols,
205
84
  rows: params.rows,
206
85
  owner: params.owner,
207
- timeoutMs: params.timeoutMs
86
+ timeoutMs: params.timeoutMs,
87
+ idleTimeoutMs: params.idleTimeoutMs,
88
+ logFile: params.logFile ? join(this.options.logDir ?? "/tmp", `${id}.log`) : undefined
208
89
  }, this.backend, this.limits);
209
90
  this.shells.set(shell.id, shell);
210
91
  shell.subscribe({
@@ -214,6 +95,8 @@ export class ShellModule {
214
95
  this.pruneFinished();
215
96
  return shell.info();
216
97
  }
98
+
99
+ /** @internal used by methods.ts */
217
100
  findReusable(params) {
218
101
  const args = JSON.stringify(params.args);
219
102
  let match;
@@ -225,6 +108,8 @@ export class ShellModule {
225
108
  }
226
109
  return match;
227
110
  }
111
+
112
+ /** @internal used by methods.ts */
228
113
  spawn(shell) {
229
114
  try {
230
115
  shell.start();
@@ -257,6 +142,8 @@ export class ShellModule {
257
142
  });
258
143
  this.emit("shell.exited", info);
259
144
  }
145
+
146
+ /** @internal used by methods.ts */
260
147
  attachStream(peer, shell) {
261
148
  const key = `${peer.id}:${shell.id}`;
262
149
  const flushMs = this.options.outputFlushMs ?? 16;
@@ -290,10 +177,42 @@ export class ShellModule {
290
177
  this.attachments.set(key, detach);
291
178
  peer.onClose(detach);
292
179
  }
180
+
181
+ /** @internal used by methods.ts */
293
182
  detach(peer, id) {
294
183
  this.attachments.get(`${peer.id}:${id}`)?.();
295
184
  }
185
+
186
+ /**
187
+ * Rules without a `done` pattern end a run on silence, so poll those watchers; the check is a
188
+ * timestamp comparison, and only shells that need it are polled.
189
+ */
190
+ /** @internal used by methods.ts */
191
+ armIdle(shell) {
192
+ this.clearIdle(shell.id);
193
+ const idleMs = shell.watcher?.idleMs;
194
+ if (!idleMs) return;
195
+ const timer = setInterval(() => {
196
+ if (!shell.watcher || Date.now() - shell.lastOutputAt < idleMs) return;
197
+ const change = shell.watcher.idle();
198
+ if (change) this.emit("shell.watch", {
199
+ info: shell.info(),
200
+ ...change
201
+ });
202
+ }, Math.max(500, Math.floor(idleMs / 2)));
203
+ this.idleTimers.set(shell.id, timer);
204
+ }
205
+
206
+ /** @internal used by methods.ts */
207
+ clearIdle(id) {
208
+ const timer = this.idleTimers.get(id);
209
+ if (timer) clearInterval(timer);
210
+ this.idleTimers.delete(id);
211
+ }
212
+
213
+ /** @internal used by methods.ts */
296
214
  forget(shell) {
215
+ this.clearIdle(shell.id);
297
216
  for (const [key, detach] of this.attachments) if (key.endsWith(`:${shell.id}`)) detach();
298
217
  shell.dispose();
299
218
  this.shells.delete(shell.id);
@@ -301,21 +220,29 @@ export class ShellModule {
301
220
  id: shell.id
302
221
  });
303
222
  }
223
+
224
+ /** @internal used by methods.ts */
304
225
  pruneFinished() {
305
226
  const max = this.options.maxFinished ?? 50;
306
227
  const finished = [...this.shells.values()].filter(s => !s.running);
307
228
  for (const shell of finished.slice(0, Math.max(0, finished.length - max))) this.forget(shell);
308
229
  }
230
+
231
+ /** @internal used by methods.ts */
309
232
  require(id) {
310
233
  const shell = this.shells.get(id);
311
234
  if (!shell) throw notFound(`shell ${id}`);
312
235
  return shell;
313
236
  }
237
+
238
+ /** @internal used by methods.ts */
314
239
  uniqueId() {
315
240
  let id = newShellId();
316
241
  while (this.shells.has(id)) id = newShellId();
317
242
  return id;
318
243
  }
244
+
245
+ /** @internal used by methods.ts */
319
246
  environment(extra) {
320
247
  const env = {};
321
248
  for (const [k, v] of Object.entries(this.options.baseEnv ?? process.env)) if (v !== undefined) env[k] = v;
@@ -0,0 +1,19 @@
1
+ /** Standard xterm palette, so the daemon can hand UIs resolved colours instead of escape codes. */
2
+ const BASE = ["#000000", "#cd0000", "#00cd00", "#cdcd00", "#0000ee", "#cd00cd", "#00cdcd", "#e5e5e5", "#7f7f7f", "#ff0000", "#00ff00", "#ffff00", "#5c5cff", "#ff00ff", "#00ffff", "#ffffff"];
3
+ const STEPS = [0, 95, 135, 175, 215, 255];
4
+ const hex = n => n.toString(16).padStart(2, "0");
5
+ export function paletteColor(index) {
6
+ if (index < 16) return BASE[index] ?? "#ffffff";
7
+ if (index < 232) {
8
+ const value = index - 16;
9
+ const r = STEPS[Math.floor(value / 36) % 6] ?? 0;
10
+ const g = STEPS[Math.floor(value / 6) % 6] ?? 0;
11
+ const b = STEPS[value % 6] ?? 0;
12
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
13
+ }
14
+ const grey = 8 + (index - 232) * 10;
15
+ return `#${hex(grey)}${hex(grey)}${hex(grey)}`;
16
+ }
17
+ export function rgbColor(value) {
18
+ return `#${hex(value >> 16 & 0xff)}${hex(value >> 8 & 0xff)}${hex(value & 0xff)}`;
19
+ }
@@ -1,4 +1,5 @@
1
1
  import { Terminal } from "@xterm/headless";
2
+ import { paletteColor, rgbColor } from "./palette.js";
2
3
 
3
4
  /** Full VT emulation of a shell's output: what a human would see right now (ADR 0003). */
4
5
  export class Screen {
@@ -25,10 +26,16 @@ export class Screen {
25
26
  await new Promise(resolve => this.term.write("", resolve));
26
27
  const buffer = this.term.buffer.active;
27
28
  const rows = [];
29
+ const styled = [];
28
30
  for (let y = 0; y < this.term.rows; y++) {
29
- rows.push(buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? "");
31
+ const line = buffer.getLine(buffer.baseY + y);
32
+ rows.push(line?.translateToString(true) ?? "");
33
+ styled.push(line ? styleRuns(line, this.term.cols) : []);
34
+ }
35
+ while (rows.length > 0 && rows[rows.length - 1] === "") {
36
+ rows.pop();
37
+ styled.pop();
30
38
  }
31
- while (rows.length > 0 && rows[rows.length - 1] === "") rows.pop();
32
39
  return {
33
40
  text: rows.join("\n"),
34
41
  cols: this.term.cols,
@@ -36,10 +43,60 @@ export class Screen {
36
43
  cursor: {
37
44
  x: buffer.cursorX,
38
45
  y: buffer.cursorY
39
- }
46
+ },
47
+ styled
40
48
  };
41
49
  }
42
50
  dispose() {
43
51
  this.term.dispose();
44
52
  }
53
+ }
54
+ /** Groups a row's cells into runs of identical style, trimming the trailing blank. */
55
+ function styleRuns(line, cols) {
56
+ const runs = [];
57
+ let current;
58
+ for (let x = 0; x < cols; x++) {
59
+ const cell = line.getCell(x);
60
+ if (!cell || cell.getWidth() === 0) continue;
61
+ const chars = cell.getChars() || " ";
62
+ const style = cellStyle(cell);
63
+ if (current && sameStyle(current, style)) current.text += chars;else {
64
+ current = {
65
+ ...style,
66
+ text: chars
67
+ };
68
+ runs.push(current);
69
+ }
70
+ }
71
+ // Drop trailing spaces so a mostly empty row costs nothing to send or paint.
72
+ while (runs.length > 0) {
73
+ const last = runs[runs.length - 1];
74
+ last.text = last.text.replace(/\s+$/, "");
75
+ if (last.text.length > 0) break;
76
+ runs.pop();
77
+ }
78
+ return runs;
79
+ }
80
+ function cellStyle(cell) {
81
+ const inverse = cell.isInverse() !== 0;
82
+ const fg = colorOf(cell, inverse ? "bg" : "fg");
83
+ const bg = colorOf(cell, inverse ? "fg" : "bg");
84
+ const style = {};
85
+ if (fg) style.fg = fg;
86
+ if (bg) style.bg = bg;
87
+ if (cell.isBold()) style.bold = true;
88
+ if (cell.isDim()) style.dim = true;
89
+ if (cell.isItalic()) style.italic = true;
90
+ if (cell.isUnderline()) style.underline = true;
91
+ return style;
92
+ }
93
+ function colorOf(cell, which) {
94
+ const isDefault = which === "fg" ? cell.isFgDefault() : cell.isBgDefault();
95
+ if (isDefault) return undefined;
96
+ const rgb = which === "fg" ? cell.isFgRGB() : cell.isBgRGB();
97
+ const value = which === "fg" ? cell.getFgColor() : cell.getBgColor();
98
+ return rgb ? rgbColor(value) : paletteColor(value);
99
+ }
100
+ function sameStyle(a, b) {
101
+ return a.fg === b.fg && a.bg === b.bg && a.bold === b.bold && a.dim === b.dim && a.italic === b.italic && a.underline === b.underline;
45
102
  }
@@ -1,3 +1,5 @@
1
+ import { createWriteStream, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
1
3
  import { LineLog } from "./output/line-log.js";
2
4
  import { OutputNormalizer } from "./output/normalizer.js";
3
5
  import { RawRing } from "./output/raw-ring.js";
@@ -13,8 +15,15 @@ export class Shell {
13
15
  /** First log line number belonging to the current run. */
14
16
  runStartLine = 1;
15
17
  lastOutputAt = Date.now();
18
+ /** Health rule attached to this shell, if any (see watch/watcher.ts). */
19
+
20
+ /** Called when the watcher's reported status changes; never per line. */
21
+
16
22
  listeners = new Set();
17
23
  startedAt = 0;
24
+
25
+ /** Why the daemon stopped it, when it was not a user or agent request. */
26
+
18
27
  stopRequested = false;
19
28
  exitPromise = Promise.resolve();
20
29
  constructor(spec, backend, limits) {
@@ -54,6 +63,25 @@ export class Shell {
54
63
  this.screen.reset();
55
64
  }
56
65
  this.runStartLine = this.log.lastLine + 1;
66
+ this.stoppedBecause = undefined;
67
+ this.stopReason = undefined;
68
+ this.stoppedBy = undefined;
69
+ if (this.spec.logFile && !this.logWriter) {
70
+ mkdirSync(dirname(this.spec.logFile), {
71
+ recursive: true
72
+ });
73
+ const file = createWriteStream(this.spec.logFile, {
74
+ flags: "a",
75
+ mode: 0o600
76
+ });
77
+ file.on("error", () => {
78
+ this.logWriter = undefined;
79
+ });
80
+ this.logWriter = {
81
+ write: text => file.write(text),
82
+ end: () => file.end()
83
+ };
84
+ }
57
85
  this.status = "running";
58
86
  this.startedAt = Date.now();
59
87
  this.lastOutputAt = this.startedAt;
@@ -83,7 +111,20 @@ export class Shell {
83
111
  const pty = this.pty;
84
112
  this.exitPromise = pty.exited.then(exit => this.onExit(pty, exit));
85
113
  if (this.spec.timeoutMs) {
86
- this.timeout = setTimeout(() => void this.stop("SIGTERM", 3000), this.spec.timeoutMs);
114
+ this.timeout = setTimeout(() => {
115
+ this.stoppedBecause = `reached its ${Math.round((this.spec.timeoutMs ?? 0) / 1000)}s time limit`;
116
+ this.stopReason = "timeout";
117
+ void this.stop("SIGTERM", 3000);
118
+ }, this.spec.timeoutMs);
119
+ }
120
+ if (this.spec.idleTimeoutMs) {
121
+ const idleMs = this.spec.idleTimeoutMs;
122
+ this.idleTimer = setInterval(() => {
123
+ if (!this.running || Date.now() - this.lastOutputAt < idleMs) return;
124
+ this.stoppedBecause = `produced no output for ${Math.round(idleMs / 1000)}s`;
125
+ this.stopReason = "idle";
126
+ void this.stop("SIGTERM", 3000);
127
+ }, Math.max(500, Math.floor(idleMs / 4)));
87
128
  }
88
129
  }
89
130
  write(data) {
@@ -97,11 +138,18 @@ export class Shell {
97
138
  if (this.running) this.pty?.resize(cols, rows);
98
139
  }
99
140
 
100
- /** Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. */
101
- 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
+ }) {
102
148
  const pty = this.pty;
103
149
  if (!pty || !this.running) return;
104
150
  this.stopRequested = true;
151
+ this.stopReason ??= cause.reason;
152
+ this.stoppedBy ??= cause.by;
105
153
  pty.signal(signal);
106
154
  const exited = await Promise.race([this.exitPromise.then(() => true), Bun.sleep(graceMs).then(() => false)]);
107
155
  if (!exited) {
@@ -137,11 +185,17 @@ export class Shell {
137
185
  if (this.exit?.signal) info.signal = this.exit.signal;
138
186
  if (this.error) info.error = this.error;
139
187
  if (this.summary) info.summary = this.summary;
188
+ if (this.stopReason) info.stopReason = this.stopReason;
189
+ if (this.stoppedBy) info.stoppedBy = this.stoppedBy;
190
+ if (this.spec.logFile) info.logFile = this.spec.logFile;
191
+ if (this.watcher) info.watch = this.watcher.state();
140
192
  if (this.endedAt) info.endedAt = this.endedAt;
141
193
  return info;
142
194
  }
143
195
  dispose() {
144
196
  clearTimeout(this.timeout);
197
+ clearInterval(this.idleTimer);
198
+ this.logWriter?.end();
145
199
  this.listeners.clear();
146
200
  this.pty?.close();
147
201
  this.screen.dispose();
@@ -162,6 +216,9 @@ export class Shell {
162
216
  createNormalizer() {
163
217
  return new OutputNormalizer(text => {
164
218
  const line = this.log.append(text);
219
+ this.logWriter?.write(`${text}\n`);
220
+ const change = this.watcher?.line(text);
221
+ if (change) this.onWatchChange?.(change);
165
222
  for (const l of this.listeners) l.line?.(line);
166
223
  });
167
224
  }
@@ -179,11 +236,16 @@ export class Shell {
179
236
  onExit(pty, exit) {
180
237
  if (this.pty !== pty) return; // a newer run replaced this one
181
238
  clearTimeout(this.timeout);
239
+ clearInterval(this.idleTimer);
240
+ this.logWriter?.end();
241
+ this.logWriter = undefined;
182
242
  this.normalizer.flush();
183
243
  this.exit = exit;
184
244
  this.endedAt = Date.now();
185
245
  this.status = this.stopRequested || exit.signal ? "killed" : "exited";
186
- this.summary = this.summarize();
246
+ this.summary = this.stoppedBecause ? `stopped: ${this.stoppedBecause}` : this.summarize();
247
+ const ended = this.watcher?.exited(exit.exitCode ?? undefined, exit.signal ?? undefined);
248
+ if (ended) this.onWatchChange?.(ended);
187
249
  // Session leader is gone; make sure nothing it left behind keeps running.
188
250
  if (pty.groupAlive()) pty.signal("SIGHUP");
189
251
  pty.close();
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Watch rules for common tools, as data rather than parsers: `done` marks the end of a run, `fail`
3
+ * and `ok` say how it went. They are deliberately conservative — matching a tool's summary line,
4
+ * not its full output — and a caller can always pass its own rule instead. Adding a tool is a row
5
+ * here, and a wrong guess costs a status, never a crash.
6
+ */
7
+ export const PRESETS = [
8
+ // TypeScript and linting
9
+ {
10
+ name: "tsc",
11
+ match: "\\btsc\\b",
12
+ rule: {
13
+ done: "Found \\d+ errors?|Watching for file changes",
14
+ fail: "error TS\\d+",
15
+ ok: "Found 0 errors"
16
+ }
17
+ }, {
18
+ name: "eslint",
19
+ match: "\\beslint\\b",
20
+ rule: {
21
+ done: "\\d+ problems?|Done in |^\\s*$",
22
+ fail: "\\d+ problems? \\(\\d*[1-9]\\d* error",
23
+ ok: "0 problems"
24
+ }
25
+ }, {
26
+ name: "biome",
27
+ match: "\\bbiome\\b",
28
+ rule: {
29
+ done: "Checked \\d+ file|Found \\d+ (?:error|warning)",
30
+ fail: "Found \\d+ errors?",
31
+ ok: "No fixes applied|Checked \\d+ files?"
32
+ }
33
+ }, {
34
+ name: "prettier",
35
+ match: "\\bprettier\\b",
36
+ rule: {
37
+ done: "\\d+ms$",
38
+ fail: "\\[error\\]",
39
+ ok: "\\(unchanged\\)"
40
+ }
41
+ }, {
42
+ name: "mypy",
43
+ match: "\\bmypy\\b",
44
+ rule: {
45
+ done: "Success: no issues|Found \\d+ errors?",
46
+ fail: "Found \\d+ errors?",
47
+ ok: "Success: no issues"
48
+ }
49
+ }, {
50
+ name: "ruff",
51
+ match: "\\bruff\\b",
52
+ rule: {
53
+ done: "Found \\d+ error|All checks passed",
54
+ fail: "Found \\d+ errors?",
55
+ ok: "All checks passed"
56
+ }
57
+ },
58
+ // Test runners
59
+ {
60
+ name: "vitest",
61
+ match: "\\bvitest\\b",
62
+ rule: {
63
+ done: "Test Files\\s+\\d|Test Files\\s+\\w",
64
+ fail: "Test Files.*failed",
65
+ ok: "Test Files.*passed"
66
+ }
67
+ }, {
68
+ name: "jest",
69
+ match: "\\bjest\\b",
70
+ rule: {
71
+ done: "^Tests:\\s",
72
+ fail: "Tests:.*\\d+ failed",
73
+ ok: "Tests:.*passed"
74
+ }
75
+ }, {
76
+ name: "mocha",
77
+ match: "\\bmocha\\b",
78
+ rule: {
79
+ done: "\\d+ (?:passing|failing)",
80
+ fail: "[1-9]\\d* failing",
81
+ ok: "\\d+ passing"
82
+ }
83
+ }, {
84
+ name: "bun-test",
85
+ match: "bun\\s+(?:--\\S+\\s+)*test\\b",
86
+ rule: {
87
+ done: "Ran \\d+ tests?",
88
+ fail: "[1-9]\\d* fail",
89
+ ok: "\\b0 fail"
90
+ }
91
+ }, {
92
+ name: "deno-test",
93
+ match: "deno\\s+test\\b",
94
+ rule: {
95
+ done: "test result:",
96
+ fail: "FAILED",
97
+ ok: "test result: ok"
98
+ }
99
+ }, {
100
+ name: "pytest",
101
+ match: "\\bpytest\\b",
102
+ rule: {
103
+ done: "=+ .*(?:passed|failed|error|no tests ran).* =+",
104
+ fail: "\\d+ (?:failed|error)",
105
+ ok: "\\d+ passed"
106
+ }
107
+ }, {
108
+ name: "rspec",
109
+ match: "\\brspec\\b",
110
+ rule: {
111
+ done: "\\d+ examples?, \\d+ failures?",
112
+ fail: "[1-9]\\d* failures?",
113
+ ok: " 0 failures"
114
+ }
115
+ }, {
116
+ name: "phpunit",
117
+ match: "phpunit",
118
+ rule: {
119
+ done: "^OK \\(|FAILURES!|ERRORS!",
120
+ fail: "FAILURES!|ERRORS!",
121
+ ok: "^OK \\("
122
+ }
123
+ }, {
124
+ name: "playwright",
125
+ match: "playwright\\s+test",
126
+ rule: {
127
+ done: "\\d+ (?:passed|failed)",
128
+ fail: "[1-9]\\d* failed",
129
+ ok: "\\d+ passed"
130
+ }
131
+ }, {
132
+ name: "cypress",
133
+ match: "\\bcypress\\b",
134
+ rule: {
135
+ done: "All specs passed|\\d+ of \\d+ failed",
136
+ fail: "\\d+ of \\d+ failed",
137
+ ok: "All specs passed"
138
+ }
139
+ },
140
+ // Dev servers and bundlers
141
+ {
142
+ name: "vite",
143
+ match: "\\bvite\\b",
144
+ rule: {
145
+ done: "ready in|page reload|hmr update|built in",
146
+ fail: "Internal server error|Rollup failed|error during build",
147
+ ok: "ready in|built in"
148
+ }
149
+ }, {
150
+ name: "next",
151
+ match: "\\bnext\\s+(?:dev|build|start)",
152
+ rule: {
153
+ done: "Compiled|Ready in|Creating an optimized",
154
+ fail: "Failed to compile|⨯",
155
+ ok: "Compiled successfully|✓ Compiled|Ready in"
156
+ }
157
+ }, {
158
+ name: "nuxt",
159
+ match: "\\bnuxt\\b",
160
+ rule: {
161
+ done: "Nuxt .*ready|built in|✔ (?:Client|Server)",
162
+ fail: "ERROR|✖",
163
+ ok: "ready in|built in"
164
+ }
165
+ }, {
166
+ name: "astro",
167
+ match: "\\bastro\\b",
168
+ rule: {
169
+ done: "watching for file changes|Complete!|ready in",
170
+ fail: "error",
171
+ ok: "Complete!|ready in",
172
+ ignoreCase: true
173
+ }
174
+ }, {
175
+ name: "angular",
176
+ match: "\\bng\\s+(?:serve|build|test)",
177
+ rule: {
178
+ done: "Application bundle generation complete|Compiled successfully|Build at",
179
+ fail: "Error:|ERROR",
180
+ ok: "Compiled successfully|generation complete"
181
+ }
182
+ }, {
183
+ name: "webpack",
184
+ match: "\\b(?:webpack|rspack)\\b",
185
+ rule: {
186
+ done: "compiled|webpack \\d",
187
+ fail: "ERROR in|compiled with \\d+ error",
188
+ ok: "compiled successfully"
189
+ }
190
+ }, {
191
+ name: "esbuild",
192
+ match: "\\besbuild\\b",
193
+ rule: {
194
+ done: "build finished|Done in",
195
+ fail: "✘ \\[ERROR\\]",
196
+ ok: "build finished"
197
+ }
198
+ }, {
199
+ name: "tsup",
200
+ match: "\\btsup\\b",
201
+ rule: {
202
+ done: "Build success|⚡️ Build",
203
+ fail: "error",
204
+ ok: "Build success",
205
+ ignoreCase: true
206
+ }
207
+ }, {
208
+ name: "turbo",
209
+ match: "\\bturbo\\b",
210
+ rule: {
211
+ done: "Tasks:\\s+\\d+ successful",
212
+ fail: "ERROR run failed|Tasks:.*\\d+ failed",
213
+ ok: "Tasks:\\s+\\d+ successful"
214
+ }
215
+ }, {
216
+ name: "metro",
217
+ match: "expo\\s+start|react-native\\s+start|\\bmetro\\b",
218
+ rule: {
219
+ done: "Bundled|BUNDLE",
220
+ fail: "error:|Failed building",
221
+ ok: "Bundled",
222
+ ignoreCase: true
223
+ }
224
+ }, {
225
+ name: "storybook",
226
+ match: "storybook",
227
+ rule: {
228
+ done: "started|built",
229
+ fail: "ERR!|Error:",
230
+ ok: "started|built"
231
+ }
232
+ },
233
+ // Compiled languages and infrastructure
234
+ {
235
+ name: "cargo",
236
+ match: "\\bcargo\\b",
237
+ rule: {
238
+ done: "Finished|error\\[|error:|test result:",
239
+ fail: "^error(?:\\[|:)|test result: FAILED",
240
+ ok: "Finished|test result: ok"
241
+ }
242
+ }, {
243
+ name: "go",
244
+ match: "\\bgo\\s+(?:build|test|run|vet)",
245
+ rule: {
246
+ done: "^(?:ok|FAIL|PASS|\\?)\\s",
247
+ fail: "^FAIL|\\.go:\\d+:",
248
+ ok: "^ok\\s"
249
+ }
250
+ }, {
251
+ name: "dotnet",
252
+ match: "\\bdotnet\\s+(?:build|watch|test|run)",
253
+ rule: {
254
+ done: "Build succeeded|Build FAILED|Passed!|Failed!",
255
+ fail: "Build FAILED|Failed!|error [A-Z]+\\d+",
256
+ ok: "Build succeeded|Passed!"
257
+ }
258
+ }, {
259
+ name: "gradle",
260
+ match: "\\bgradlew?\\b",
261
+ rule: {
262
+ done: "BUILD SUCCESSFUL|BUILD FAILED",
263
+ fail: "BUILD FAILED",
264
+ ok: "BUILD SUCCESSFUL"
265
+ }
266
+ }, {
267
+ name: "maven",
268
+ match: "\\bmvn\\b",
269
+ rule: {
270
+ done: "BUILD SUCCESS|BUILD FAILURE",
271
+ fail: "BUILD FAILURE",
272
+ ok: "BUILD SUCCESS"
273
+ }
274
+ }, {
275
+ name: "docker-compose",
276
+ match: "docker[\\s-]compose",
277
+ rule: {
278
+ fail: "ERROR|exited with code [1-9]",
279
+ ok: "Started|healthy",
280
+ idleSeconds: 5
281
+ }
282
+ }, {
283
+ name: "terraform",
284
+ match: "\\bterraform\\b",
285
+ rule: {
286
+ done: "Apply complete|Plan:|Error:",
287
+ fail: "Error:",
288
+ ok: "Apply complete|No changes"
289
+ }
290
+ }];
291
+ const byName = new Map(PRESETS.map(preset => [preset.name, preset]));
292
+ export function presetByName(name) {
293
+ return byName.get(name);
294
+ }
295
+
296
+ /** First preset whose `match` fits the command, e.g. `npm run dev` → nothing, `tsc --watch` → tsc. */
297
+ export function presetForCommand(command) {
298
+ return PRESETS.find(preset => preset.match && new RegExp(preset.match, "i").test(command));
299
+ }
@@ -0,0 +1,82 @@
1
+ export function compileRule(rule) {
2
+ const flags = rule.ignoreCase ? "i" : "";
3
+ const compiled = {};
4
+ if (rule.done) compiled.done = new RegExp(rule.done, flags);
5
+ if (rule.fail) compiled.fail = new RegExp(rule.fail, flags);
6
+ if (rule.ok) compiled.ok = new RegExp(rule.ok, flags);
7
+ if (rule.idleSeconds) compiled.idleMs = Math.round(rule.idleSeconds * 1000);
8
+ return compiled;
9
+ }
10
+ /**
11
+ * Tracks the health of one shell from its log.
12
+ *
13
+ * A run ends when `done` matches (or, without it, after `idleSeconds` of silence); the run's status
14
+ * is then `fail` if anything matched `fail`, `ok` if anything matched `ok`, and otherwise whatever
15
+ * it was. Only status changes are reported — a watcher that recompiles a thousand times says
16
+ * nothing until something actually breaks or gets fixed. A new failing line during an already
17
+ * failing run is reported too, since it is different news.
18
+ */
19
+ export class Watcher {
20
+ status = "pending";
21
+ runs = 0;
22
+ since = Date.now();
23
+ constructor(rule, preset) {
24
+ this.rule = rule;
25
+ this.preset = preset;
26
+ }
27
+ get idleMs() {
28
+ return this.rule.done ? undefined : this.rule.idleMs;
29
+ }
30
+ state() {
31
+ const state = {
32
+ status: this.status,
33
+ runs: this.runs,
34
+ since: this.since
35
+ };
36
+ if (this.preset) state.preset = this.preset;
37
+ if (this.summaryText) state.summary = this.summaryText;
38
+ return state;
39
+ }
40
+
41
+ /** Feeds one committed log line. Returns a change when the reported status or failure changed. */
42
+ line(text, now = Date.now()) {
43
+ if (this.rule.fail?.test(text)) this.sawFail = text.trim();else if (this.rule.ok?.test(text)) this.sawOk = text.trim();
44
+ if (!this.rule.done?.test(text)) return undefined;
45
+ return this.settle(now);
46
+ }
47
+
48
+ /** Ends the current run because output went quiet (rules without a `done` pattern). */
49
+ idle(now = Date.now()) {
50
+ if (this.rule.done) return undefined;
51
+ return this.settle(now);
52
+ }
53
+
54
+ /** The process ended: a watched program that stops is a failure unless it exited cleanly. */
55
+ exited(exitCode, signal, now = Date.now()) {
56
+ const clean = exitCode === 0 && !signal;
57
+ const summary = clean ? this.summaryText : `process ended ${signal ? `on ${signal}` : `with exit code ${exitCode ?? "?"}`}`;
58
+ return this.apply(clean ? this.sawFail ? "fail" : this.status === "pending" ? "ok" : this.status : "fail", summary, now);
59
+ }
60
+ settle(now) {
61
+ if (!this.sawFail && !this.sawOk && this.status === "pending") return undefined;
62
+ this.runs++;
63
+ const status = this.sawFail ? "fail" : this.sawOk ? "ok" : this.status;
64
+ const summary = this.sawFail ?? this.sawOk ?? this.summaryText;
65
+ this.sawFail = undefined;
66
+ this.sawOk = undefined;
67
+ return this.apply(status === "pending" ? "unknown" : status, summary, now);
68
+ }
69
+ apply(status, summary, now) {
70
+ const changed = status !== this.status || status === "fail" && summary !== this.summaryText;
71
+ const previous = this.status;
72
+ this.status = status;
73
+ this.summaryText = summary;
74
+ if (!changed) return undefined;
75
+ this.since = now;
76
+ return {
77
+ previous,
78
+ current: status,
79
+ summary
80
+ };
81
+ }
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-cockpit/daemon",
3
- "version": "0.1.5",
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.1.5",
44
+ "@opencode-cockpit/protocol": "0.2.1",
45
45
  "@xterm/headless": "6.0.0"
46
46
  },
47
47
  "engines": {
@@ -0,0 +1,7 @@
1
+ import type { MethodTable } from "../../core/module.ts";
2
+ import type { ShellModule } from "./module.ts";
3
+ /**
4
+ * The `shell.*` methods, kept apart from the module's lifecycle and bookkeeping so each file has
5
+ * one job: this one maps protocol calls onto the module, `module.ts` owns the shells.
6
+ */
7
+ export declare function shellMethods(module: ShellModule): MethodTable<"shell">;
@@ -1,6 +1,7 @@
1
- import type { MethodTable, Module, ModuleContext } from "../../core/module.ts";
1
+ import type { ShellInfo, StartParams } from "@opencode-cockpit/protocol/shell";
2
+ import type { MethodTable, Module, ModuleContext, Peer } from "../../core/module.ts";
2
3
  import { type PtyBackend } from "./pty.ts";
3
- import { type ShellLimits } from "./shell.ts";
4
+ import { Shell, type ShellLimits } from "./shell.ts";
4
5
  export interface ShellModuleOptions {
5
6
  backend?: PtyBackend;
6
7
  limits?: Partial<ShellLimits>;
@@ -12,32 +13,56 @@ export interface ShellModuleOptions {
12
13
  outputFlushMs?: number;
13
14
  /** Where to record owned process groups so a restarted daemon can reap orphans. */
14
15
  registryFile?: string;
16
+ /** Directory for per-shell log files, when a caller asks for one. */
17
+ logDir?: string;
15
18
  }
16
19
  export declare class ShellModule implements Module<"shell"> {
17
20
  private readonly options;
18
21
  readonly name: "shell";
19
- private readonly shells;
20
- private readonly attachments;
22
+ /** @internal */
23
+ readonly shells: Map<string, Shell>;
24
+ /** @internal */
25
+ readonly attachments: Map<string, () => void>;
21
26
  private readonly backend;
22
27
  private readonly limits;
23
28
  private log;
24
29
  private registry;
25
- private emit;
30
+ private readonly idleTimers;
31
+ /** @internal */
32
+ emit: ModuleContext["emit"];
26
33
  constructor(options?: ShellModuleOptions);
27
34
  start(ctx: ModuleContext): Promise<void>;
28
35
  stop(): Promise<void>;
29
36
  busy(): boolean;
30
37
  readonly methods: MethodTable<"shell">;
31
- private startShell;
32
- private findReusable;
33
- private spawn;
38
+ /** @internal used by methods.ts */
39
+ startShell(params: StartParams): ShellInfo;
40
+ /** @internal used by methods.ts */
41
+ findReusable(params: StartParams): Shell | undefined;
42
+ /** @internal used by methods.ts */
43
+ spawn(shell: Shell): void;
34
44
  private onExit;
35
- private attachStream;
36
- private detach;
37
- private forget;
38
- private pruneFinished;
39
- private require;
40
- private uniqueId;
41
- private environment;
45
+ /** @internal used by methods.ts */
46
+ attachStream(peer: Peer, shell: Shell): void;
47
+ /** @internal used by methods.ts */
48
+ detach(peer: Peer, id: string): void;
49
+ /**
50
+ * Rules without a `done` pattern end a run on silence, so poll those watchers; the check is a
51
+ * timestamp comparison, and only shells that need it are polled.
52
+ */
53
+ /** @internal used by methods.ts */
54
+ armIdle(shell: Shell): void;
55
+ /** @internal used by methods.ts */
56
+ clearIdle(id: string): void;
57
+ /** @internal used by methods.ts */
58
+ forget(shell: Shell): void;
59
+ /** @internal used by methods.ts */
60
+ pruneFinished(): void;
61
+ /** @internal used by methods.ts */
62
+ require(id: string): Shell;
63
+ /** @internal used by methods.ts */
64
+ uniqueId(): string;
65
+ /** @internal used by methods.ts */
66
+ environment(extra: Record<string, string> | undefined): Record<string, string>;
42
67
  }
43
68
  export declare function compilePattern(pattern: string, ignoreCase: boolean): RegExp;
@@ -0,0 +1,2 @@
1
+ export declare function paletteColor(index: number): string;
2
+ export declare function rgbColor(value: number): string;
@@ -1,8 +1,9 @@
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";
5
5
  import type { PtyBackend } from "./pty.ts";
6
+ import type { WatchChange, Watcher } from "./watch/watcher.ts";
6
7
  export interface ShellSpec {
7
8
  id: string;
8
9
  command: string;
@@ -14,6 +15,9 @@ export interface ShellSpec {
14
15
  rows: number;
15
16
  owner: Owner;
16
17
  timeoutMs?: number;
18
+ idleTimeoutMs?: number;
19
+ /** Absolute path the clean log is appended to, when logging was requested. */
20
+ logFile?: string;
17
21
  }
18
22
  export interface ShellLimits {
19
23
  logChars: number;
@@ -42,6 +46,10 @@ export declare class Shell {
42
46
  /** First log line number belonging to the current run. */
43
47
  runStartLine: number;
44
48
  lastOutputAt: number;
49
+ /** Health rule attached to this shell, if any (see watch/watcher.ts). */
50
+ watcher: Watcher | undefined;
51
+ /** Called when the watcher's reported status changes; never per line. */
52
+ onWatchChange: ((change: WatchChange) => void) | undefined;
45
53
  private pty;
46
54
  private normalizer;
47
55
  private listeners;
@@ -49,9 +57,15 @@ export declare class Shell {
49
57
  private endedAt;
50
58
  private exit;
51
59
  private error;
60
+ /** Why the daemon stopped it, when it was not a user or agent request. */
61
+ private stoppedBecause;
62
+ private stopReason;
63
+ private stoppedBy;
52
64
  private summary;
53
65
  private stopRequested;
54
66
  private timeout;
67
+ private idleTimer;
68
+ private logWriter;
55
69
  private exitPromise;
56
70
  constructor(spec: ShellSpec, backend: PtyBackend, limits: ShellLimits);
57
71
  get id(): string;
@@ -64,8 +78,14 @@ export declare class Shell {
64
78
  start(): void;
65
79
  write(data: string): number;
66
80
  resize(cols: number, rows: number): void;
67
- /** Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. */
68
- 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>;
69
89
  snapshot(): Promise<ScreenResult>;
70
90
  info(): ShellInfo;
71
91
  dispose(): void;
@@ -0,0 +1,17 @@
1
+ import type { WatchRule } from "@opencode-cockpit/protocol/shell";
2
+ export interface Preset {
3
+ name: string;
4
+ /** Matched against the command line to pick a preset automatically. */
5
+ match?: string;
6
+ rule: WatchRule;
7
+ }
8
+ /**
9
+ * Watch rules for common tools, as data rather than parsers: `done` marks the end of a run, `fail`
10
+ * and `ok` say how it went. They are deliberately conservative — matching a tool's summary line,
11
+ * not its full output — and a caller can always pass its own rule instead. Adding a tool is a row
12
+ * here, and a wrong guess costs a status, never a crash.
13
+ */
14
+ export declare const PRESETS: Preset[];
15
+ export declare function presetByName(name: string): Preset | undefined;
16
+ /** First preset whose `match` fits the command, e.g. `npm run dev` → nothing, `tsc --watch` → tsc. */
17
+ export declare function presetForCommand(command: string): Preset | undefined;
@@ -0,0 +1,43 @@
1
+ import type { WatchRule, WatchState, WatchStatus } from "@opencode-cockpit/protocol/shell";
2
+ export interface CompiledRule {
3
+ done?: RegExp;
4
+ fail?: RegExp;
5
+ ok?: RegExp;
6
+ idleMs?: number;
7
+ }
8
+ export declare function compileRule(rule: WatchRule): CompiledRule;
9
+ export interface WatchChange {
10
+ previous: WatchStatus;
11
+ current: WatchStatus;
12
+ summary?: string;
13
+ }
14
+ /**
15
+ * Tracks the health of one shell from its log.
16
+ *
17
+ * A run ends when `done` matches (or, without it, after `idleSeconds` of silence); the run's status
18
+ * is then `fail` if anything matched `fail`, `ok` if anything matched `ok`, and otherwise whatever
19
+ * it was. Only status changes are reported — a watcher that recompiles a thousand times says
20
+ * nothing until something actually breaks or gets fixed. A new failing line during an already
21
+ * failing run is reported too, since it is different news.
22
+ */
23
+ export declare class Watcher {
24
+ private readonly rule;
25
+ private readonly preset?;
26
+ private status;
27
+ private summaryText;
28
+ private runs;
29
+ private since;
30
+ private sawFail;
31
+ private sawOk;
32
+ constructor(rule: CompiledRule, preset?: string | undefined);
33
+ get idleMs(): number | undefined;
34
+ state(): WatchState;
35
+ /** Feeds one committed log line. Returns a change when the reported status or failure changed. */
36
+ line(text: string, now?: number): WatchChange | undefined;
37
+ /** Ends the current run because output went quiet (rules without a `done` pattern). */
38
+ idle(now?: number): WatchChange | undefined;
39
+ /** The process ended: a watched program that stops is a failure unless it exited cleanly. */
40
+ exited(exitCode: number | undefined, signal: string | undefined, now?: number): WatchChange | undefined;
41
+ private settle;
42
+ private apply;
43
+ }