@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 +2 -1
- package/dist/modules/shell/methods.js +204 -0
- package/dist/modules/shell/module.js +65 -138
- package/dist/modules/shell/output/palette.js +19 -0
- package/dist/modules/shell/output/screen.js +60 -3
- package/dist/modules/shell/shell.js +66 -4
- package/dist/modules/shell/watch/presets.js +299 -0
- package/dist/modules/shell/watch/watcher.js +82 -0
- package/package.json +2 -2
- package/types/modules/shell/methods.d.ts +7 -0
- package/types/modules/shell/module.d.ts +40 -15
- package/types/modules/shell/output/palette.d.ts +2 -0
- package/types/modules/shell/shell.d.ts +23 -3
- package/types/modules/shell/watch/presets.d.ts +17 -0
- package/types/modules/shell/watch/watcher.d.ts +43 -0
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 {
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
/**
|
|
101
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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;
|
|
@@ -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
|
-
/**
|
|
68
|
-
|
|
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
|
+
}
|