@opencode-cockpit/daemon 0.1.5 → 0.2.0

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,186 @@
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
+ /**
8
+ * The `shell.*` methods, kept apart from the module's lifecycle and bookkeeping so each file has
9
+ * one job: this one maps protocol calls onto the module, `module.ts` owns the shells.
10
+ */
11
+ export function shellMethods(module) {
12
+ return {
13
+ start: params => module.startShell(params),
14
+ list: params => {
15
+ const owner = params.owner;
16
+ 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());
17
+ },
18
+ get: ({
19
+ id
20
+ }) => module.require(id).info(),
21
+ read: ({
22
+ id,
23
+ after,
24
+ tail,
25
+ limit,
26
+ grep,
27
+ ignoreCase
28
+ }) => {
29
+ const shell = module.require(id);
30
+ const page = shell.log.read({
31
+ after,
32
+ tail,
33
+ limit,
34
+ grep: grep === undefined ? undefined : compilePattern(grep, ignoreCase)
35
+ });
36
+ return {
37
+ ...page,
38
+ status: shell.status
39
+ };
40
+ },
41
+ screen: ({
42
+ id
43
+ }) => module.require(id).snapshot(),
44
+ write: ({
45
+ id,
46
+ data
47
+ }) => {
48
+ const shell = module.require(id);
49
+ if (!shell.running) throw invalidState(`shell ${id} is ${shell.status}`);
50
+ return {
51
+ bytes: shell.write(data)
52
+ };
53
+ },
54
+ resize: ({
55
+ id,
56
+ cols,
57
+ rows
58
+ }) => {
59
+ module.require(id).resize(cols, rows);
60
+ return {};
61
+ },
62
+ wait: async params => {
63
+ const shell = module.require(params.id);
64
+ const outcome = await waitFor(shell, params, compilePattern);
65
+ return {
66
+ ...outcome,
67
+ info: shell.info()
68
+ };
69
+ },
70
+ stop: async ({
71
+ id,
72
+ signal,
73
+ graceMs
74
+ }) => {
75
+ const shell = module.require(id);
76
+ await shell.stop(signal, graceMs);
77
+ await shell.exited;
78
+ return shell.info();
79
+ },
80
+ restart: async ({
81
+ id
82
+ }) => {
83
+ const shell = module.require(id);
84
+ if (shell.running) {
85
+ await shell.stop("SIGTERM", 3000);
86
+ await shell.exited;
87
+ }
88
+ module.spawn(shell);
89
+ return shell.info();
90
+ },
91
+ remove: async ({
92
+ id
93
+ }) => {
94
+ const shell = module.require(id);
95
+ if (shell.running) {
96
+ await shell.stop("SIGTERM", 3000);
97
+ await shell.exited;
98
+ }
99
+ module.forget(shell);
100
+ return {};
101
+ },
102
+ attach: ({
103
+ id,
104
+ fromOffset
105
+ }, {
106
+ peer
107
+ }) => {
108
+ const shell = module.require(id);
109
+ module.detach(peer, id);
110
+ const replay = shell.raw.since(fromOffset ?? 0);
111
+ module.attachStream(peer, shell);
112
+ return {
113
+ offset: replay.offset,
114
+ replay: Buffer.from(replay.bytes).toString("base64")
115
+ };
116
+ },
117
+ clear: ({
118
+ owner,
119
+ finishedBeforeMs
120
+ }) => {
121
+ const cutoff = Date.now() - (finishedBeforeMs ?? 0);
122
+ const removed = [];
123
+ for (const shell of [...module.shells.values()]) {
124
+ if (shell.running) continue;
125
+ const info = shell.info();
126
+ if (owner?.project && info.owner.project !== owner.project) continue;
127
+ if (owner?.session && info.owner.session !== owner.session) continue;
128
+ if ((info.endedAt ?? 0) > cutoff) continue;
129
+ module.forget(shell);
130
+ removed.push(info.id);
131
+ }
132
+ return {
133
+ removed
134
+ };
135
+ },
136
+ watch: ({
137
+ id,
138
+ preset,
139
+ rule
140
+ }) => {
141
+ const shell = module.require(id);
142
+ 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);
144
+ 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
+ }
149
+ try {
150
+ shell.watcher = new Watcher(compileRule(watchRule), chosen?.name);
151
+ } catch (err) {
152
+ throw invalidParams(`invalid watch pattern: ${err instanceof Error ? err.message : String(err)}`);
153
+ }
154
+ shell.onWatchChange = change => {
155
+ module.emit("shell.watch", {
156
+ info: shell.info(),
157
+ ...change
158
+ });
159
+ };
160
+ module.armIdle(shell);
161
+ return shell.info();
162
+ },
163
+ unwatch: ({
164
+ id
165
+ }) => {
166
+ const shell = module.require(id);
167
+ shell.watcher = undefined;
168
+ shell.onWatchChange = undefined;
169
+ module.clearIdle(id);
170
+ return shell.info();
171
+ },
172
+ presets: () => PRESETS.map(preset => ({
173
+ name: preset.name,
174
+ match: preset.match,
175
+ rule: preset.rule
176
+ })),
177
+ detach: ({
178
+ id
179
+ }, {
180
+ peer
181
+ }) => {
182
+ module.detach(peer, id);
183
+ return {};
184
+ }
185
+ };
186
+ }
@@ -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,6 +42,7 @@ export class ShellModule {
37
42
  }
38
43
  }
39
44
  async stop() {
45
+ for (const id of [...this.idleTimers.keys()]) this.clearIdle(id);
40
46
  await Promise.all([...this.shells.values()].map(s => s.stop("SIGTERM", 2000).catch(() => {})));
41
47
  for (const detach of this.attachments.values()) detach();
42
48
  for (const shell of this.shells.values()) shell.dispose();
@@ -47,139 +53,9 @@ export class ShellModule {
47
53
  for (const shell of this.shells.values()) if (shell.running) return true;
48
54
  return false;
49
55
  }
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
- };
56
+ methods = shellMethods(this);
57
+
58
+ /** @internal used by methods.ts */
183
59
  startShell(params) {
184
60
  if (params.reuse) {
185
61
  const previous = this.findReusable(params);
@@ -194,8 +70,9 @@ export class ShellModule {
194
70
  return previous.info();
195
71
  }
196
72
  }
73
+ const id = this.uniqueId();
197
74
  const shell = new Shell({
198
- id: this.uniqueId(),
75
+ id,
199
76
  command: params.command,
200
77
  args: params.args,
201
78
  cwd: params.cwd,
@@ -204,7 +81,9 @@ export class ShellModule {
204
81
  cols: params.cols,
205
82
  rows: params.rows,
206
83
  owner: params.owner,
207
- timeoutMs: params.timeoutMs
84
+ timeoutMs: params.timeoutMs,
85
+ idleTimeoutMs: params.idleTimeoutMs,
86
+ logFile: params.logFile ? join(this.options.logDir ?? "/tmp", `${id}.log`) : undefined
208
87
  }, this.backend, this.limits);
209
88
  this.shells.set(shell.id, shell);
210
89
  shell.subscribe({
@@ -214,6 +93,8 @@ export class ShellModule {
214
93
  this.pruneFinished();
215
94
  return shell.info();
216
95
  }
96
+
97
+ /** @internal used by methods.ts */
217
98
  findReusable(params) {
218
99
  const args = JSON.stringify(params.args);
219
100
  let match;
@@ -225,6 +106,8 @@ export class ShellModule {
225
106
  }
226
107
  return match;
227
108
  }
109
+
110
+ /** @internal used by methods.ts */
228
111
  spawn(shell) {
229
112
  try {
230
113
  shell.start();
@@ -257,6 +140,8 @@ export class ShellModule {
257
140
  });
258
141
  this.emit("shell.exited", info);
259
142
  }
143
+
144
+ /** @internal used by methods.ts */
260
145
  attachStream(peer, shell) {
261
146
  const key = `${peer.id}:${shell.id}`;
262
147
  const flushMs = this.options.outputFlushMs ?? 16;
@@ -290,10 +175,42 @@ export class ShellModule {
290
175
  this.attachments.set(key, detach);
291
176
  peer.onClose(detach);
292
177
  }
178
+
179
+ /** @internal used by methods.ts */
293
180
  detach(peer, id) {
294
181
  this.attachments.get(`${peer.id}:${id}`)?.();
295
182
  }
183
+
184
+ /**
185
+ * Rules without a `done` pattern end a run on silence, so poll those watchers; the check is a
186
+ * timestamp comparison, and only shells that need it are polled.
187
+ */
188
+ /** @internal used by methods.ts */
189
+ armIdle(shell) {
190
+ this.clearIdle(shell.id);
191
+ const idleMs = shell.watcher?.idleMs;
192
+ if (!idleMs) return;
193
+ const timer = setInterval(() => {
194
+ if (!shell.watcher || Date.now() - shell.lastOutputAt < idleMs) return;
195
+ const change = shell.watcher.idle();
196
+ if (change) this.emit("shell.watch", {
197
+ info: shell.info(),
198
+ ...change
199
+ });
200
+ }, Math.max(500, Math.floor(idleMs / 2)));
201
+ this.idleTimers.set(shell.id, timer);
202
+ }
203
+
204
+ /** @internal used by methods.ts */
205
+ clearIdle(id) {
206
+ const timer = this.idleTimers.get(id);
207
+ if (timer) clearInterval(timer);
208
+ this.idleTimers.delete(id);
209
+ }
210
+
211
+ /** @internal used by methods.ts */
296
212
  forget(shell) {
213
+ this.clearIdle(shell.id);
297
214
  for (const [key, detach] of this.attachments) if (key.endsWith(`:${shell.id}`)) detach();
298
215
  shell.dispose();
299
216
  this.shells.delete(shell.id);
@@ -301,21 +218,29 @@ export class ShellModule {
301
218
  id: shell.id
302
219
  });
303
220
  }
221
+
222
+ /** @internal used by methods.ts */
304
223
  pruneFinished() {
305
224
  const max = this.options.maxFinished ?? 50;
306
225
  const finished = [...this.shells.values()].filter(s => !s.running);
307
226
  for (const shell of finished.slice(0, Math.max(0, finished.length - max))) this.forget(shell);
308
227
  }
228
+
229
+ /** @internal used by methods.ts */
309
230
  require(id) {
310
231
  const shell = this.shells.get(id);
311
232
  if (!shell) throw notFound(`shell ${id}`);
312
233
  return shell;
313
234
  }
235
+
236
+ /** @internal used by methods.ts */
314
237
  uniqueId() {
315
238
  let id = newShellId();
316
239
  while (this.shells.has(id)) id = newShellId();
317
240
  return id;
318
241
  }
242
+
243
+ /** @internal used by methods.ts */
319
244
  environment(extra) {
320
245
  const env = {};
321
246
  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,23 @@ export class Shell {
54
63
  this.screen.reset();
55
64
  }
56
65
  this.runStartLine = this.log.lastLine + 1;
66
+ this.stoppedBecause = undefined;
67
+ if (this.spec.logFile && !this.logWriter) {
68
+ mkdirSync(dirname(this.spec.logFile), {
69
+ recursive: true
70
+ });
71
+ const file = createWriteStream(this.spec.logFile, {
72
+ flags: "a",
73
+ mode: 0o600
74
+ });
75
+ file.on("error", () => {
76
+ this.logWriter = undefined;
77
+ });
78
+ this.logWriter = {
79
+ write: text => file.write(text),
80
+ end: () => file.end()
81
+ };
82
+ }
57
83
  this.status = "running";
58
84
  this.startedAt = Date.now();
59
85
  this.lastOutputAt = this.startedAt;
@@ -83,7 +109,18 @@ export class Shell {
83
109
  const pty = this.pty;
84
110
  this.exitPromise = pty.exited.then(exit => this.onExit(pty, exit));
85
111
  if (this.spec.timeoutMs) {
86
- this.timeout = setTimeout(() => void this.stop("SIGTERM", 3000), this.spec.timeoutMs);
112
+ this.timeout = setTimeout(() => {
113
+ this.stoppedBecause = `reached its ${Math.round((this.spec.timeoutMs ?? 0) / 1000)}s time limit`;
114
+ void this.stop("SIGTERM", 3000);
115
+ }, this.spec.timeoutMs);
116
+ }
117
+ if (this.spec.idleTimeoutMs) {
118
+ const idleMs = this.spec.idleTimeoutMs;
119
+ this.idleTimer = setInterval(() => {
120
+ if (!this.running || Date.now() - this.lastOutputAt < idleMs) return;
121
+ this.stoppedBecause = `produced no output for ${Math.round(idleMs / 1000)}s`;
122
+ void this.stop("SIGTERM", 3000);
123
+ }, Math.max(500, Math.floor(idleMs / 4)));
87
124
  }
88
125
  }
89
126
  write(data) {
@@ -137,11 +174,15 @@ export class Shell {
137
174
  if (this.exit?.signal) info.signal = this.exit.signal;
138
175
  if (this.error) info.error = this.error;
139
176
  if (this.summary) info.summary = this.summary;
177
+ if (this.spec.logFile) info.logFile = this.spec.logFile;
178
+ if (this.watcher) info.watch = this.watcher.state();
140
179
  if (this.endedAt) info.endedAt = this.endedAt;
141
180
  return info;
142
181
  }
143
182
  dispose() {
144
183
  clearTimeout(this.timeout);
184
+ clearInterval(this.idleTimer);
185
+ this.logWriter?.end();
145
186
  this.listeners.clear();
146
187
  this.pty?.close();
147
188
  this.screen.dispose();
@@ -162,6 +203,9 @@ export class Shell {
162
203
  createNormalizer() {
163
204
  return new OutputNormalizer(text => {
164
205
  const line = this.log.append(text);
206
+ this.logWriter?.write(`${text}\n`);
207
+ const change = this.watcher?.line(text);
208
+ if (change) this.onWatchChange?.(change);
165
209
  for (const l of this.listeners) l.line?.(line);
166
210
  });
167
211
  }
@@ -179,11 +223,16 @@ export class Shell {
179
223
  onExit(pty, exit) {
180
224
  if (this.pty !== pty) return; // a newer run replaced this one
181
225
  clearTimeout(this.timeout);
226
+ clearInterval(this.idleTimer);
227
+ this.logWriter?.end();
228
+ this.logWriter = undefined;
182
229
  this.normalizer.flush();
183
230
  this.exit = exit;
184
231
  this.endedAt = Date.now();
185
232
  this.status = this.stopRequested || exit.signal ? "killed" : "exited";
186
- this.summary = this.summarize();
233
+ this.summary = this.stoppedBecause ? `stopped: ${this.stoppedBecause}` : this.summarize();
234
+ const ended = this.watcher?.exited(exit.exitCode ?? undefined, exit.signal ?? undefined);
235
+ if (ended) this.onWatchChange?.(ended);
187
236
  // Session leader is gone; make sure nothing it left behind keeps running.
188
237
  if (pty.groupAlive()) pty.signal("SIGHUP");
189
238
  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.0",
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.0",
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;
@@ -3,6 +3,7 @@ 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,13 @@ 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;
52
62
  private summary;
53
63
  private stopRequested;
54
64
  private timeout;
65
+ private idleTimer;
66
+ private logWriter;
55
67
  private exitPromise;
56
68
  constructor(spec: ShellSpec, backend: PtyBackend, limits: ShellLimits);
57
69
  get id(): string;
@@ -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
+ }