@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 +2 -1
- package/dist/modules/shell/methods.js +186 -0
- package/dist/modules/shell/module.js +62 -137
- package/dist/modules/shell/output/palette.js +19 -0
- package/dist/modules/shell/output/screen.js +60 -3
- package/dist/modules/shell/shell.js +51 -2
- 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 +12 -0
- 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,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 {
|
|
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
|
-
|
|
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
|
-
};
|
|
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
|
|
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
|
-
|
|
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(() =>
|
|
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.
|
|
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.
|
|
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 {
|
|
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;
|
|
@@ -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
|
+
}
|