@openscout/scout 0.2.72 → 0.2.73
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/LICENSE +202 -0
- package/README.md +41 -7
- package/bin/scout +34 -0
- package/bin/scout.mjs +195 -18
- package/bin/scoutd +0 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/RepoDiffViewer-D8gg36W3.js +56 -0
- package/dist/client/assets/{addon-fit-C5ueiijZ.js → addon-fit-D4KdY-pj.js} +1 -1
- package/dist/client/assets/{addon-webgl-Cm7sl8RO.js → addon-webgl-Bo_tmKBp.js} +1 -1
- package/dist/client/assets/arc.es-D0gujIKy.js +188 -0
- package/dist/client/assets/embed-entry-CUVNwyB_.js +1 -0
- package/dist/client/assets/index-CRkjiso5.js +257 -0
- package/dist/client/assets/index-D_RX2cRb.css +1 -0
- package/dist/client/assets/{xterm-FFux-PdF.js → xterm-RfflDFod.js} +1 -1
- package/dist/client/favicon-16.png +0 -0
- package/dist/client/favicon-32.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/favicon.svg +48 -0
- package/dist/client/index.html +8 -2
- package/dist/client/openscout-icon.png +0 -0
- package/dist/client/site.webmanifest +19 -0
- package/dist/client/web-app-icon-192.png +0 -0
- package/dist/client/web-app-icon-512.png +0 -0
- package/dist/drizzle/0000_curly_iron_monger.sql +592 -0
- package/dist/drizzle/0001_invocation-status-columns.sql +7 -0
- package/dist/drizzle/0002_invocation-flight-metadata.sql +21 -0
- package/dist/drizzle/README.md +81 -0
- package/dist/drizzle/meta/0000_snapshot.json +4575 -0
- package/dist/drizzle/meta/0001_snapshot.json +4624 -0
- package/dist/drizzle/meta/0002_snapshot.json +4631 -0
- package/dist/drizzle/meta/_journal.json +27 -0
- package/dist/main.mjs +72583 -60970
- package/dist/node/main.mjs +7607 -0
- package/dist/openscout-terminal-relay.mjs +3819 -158
- package/dist/{pair-supervisor.mjs → pairing-runtime-controller.mjs} +51525 -46801
- package/dist/runtime/base-daemon.mjs +2153 -502
- package/dist/runtime/broker-daemon.mjs +34730 -27036
- package/dist/runtime/broker-process-manager.mjs +2038 -421
- package/dist/runtime/mesh-discover.mjs +2002 -420
- package/dist/scout-control-plane-web.mjs +54522 -39921
- package/dist/scout-web-server.mjs +54522 -39921
- package/dist/statusline.mjs +287 -0
- package/package.json +7 -3
- package/dist/client/assets/RepoDiffViewer-DRzXs-TP.js +0 -1
- package/dist/client/assets/index-CYs6TkOv.js +0 -191
- package/dist/client/assets/index-n_fpvwq8.css +0 -1
|
@@ -2,21 +2,3246 @@
|
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { join as
|
|
5
|
+
import { join as join6 } from "node:path";
|
|
6
6
|
import { createRequire as createRequire2 } from "node:module";
|
|
7
7
|
|
|
8
|
+
// packages/web/server/relay.ts
|
|
9
|
+
import { basename, join } from "node:path";
|
|
10
|
+
function sanitizeUploadName(name) {
|
|
11
|
+
const base = basename(name).trim();
|
|
12
|
+
if (!base || base === "." || base === ".." || base.includes("/") || base.includes("\\")) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return base;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// packages/web/server/terminal-relay-session.ts
|
|
19
|
+
import { createRequire } from "module";
|
|
20
|
+
|
|
21
|
+
// packages/runtime/dist/system-probes/registry.js
|
|
22
|
+
var DEFAULT_MIN_MAX_STALE_MS = 2 * 60000;
|
|
23
|
+
var PROBE_RUN_OUTPUT = Symbol("openscout.probeRunOutput");
|
|
24
|
+
var registeredProbes = [];
|
|
25
|
+
|
|
26
|
+
class ProbeBackendError extends Error {
|
|
27
|
+
backend;
|
|
28
|
+
fallbackSince;
|
|
29
|
+
fallbackReason;
|
|
30
|
+
code;
|
|
31
|
+
timedOut;
|
|
32
|
+
constructor(message, metadata, cause) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "ProbeBackendError";
|
|
35
|
+
this.backend = metadata.backend;
|
|
36
|
+
this.fallbackSince = metadata.fallbackSince;
|
|
37
|
+
this.fallbackReason = metadata.fallbackReason;
|
|
38
|
+
const details = probeErrorDetails(cause);
|
|
39
|
+
if (details.code) {
|
|
40
|
+
this.code = details.code;
|
|
41
|
+
}
|
|
42
|
+
if (details.timedOut !== undefined) {
|
|
43
|
+
this.timedOut = details.timedOut;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function probeRunOutput(value, metadata) {
|
|
48
|
+
return {
|
|
49
|
+
[PROBE_RUN_OUTPUT]: true,
|
|
50
|
+
value,
|
|
51
|
+
...metadata
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function isProbeRunOutput(value) {
|
|
55
|
+
return typeof value === "object" && value !== null && value[PROBE_RUN_OUTPUT] === true;
|
|
56
|
+
}
|
|
57
|
+
function backendMetadataFromError(error) {
|
|
58
|
+
if (error instanceof ProbeBackendError) {
|
|
59
|
+
return {
|
|
60
|
+
backend: error.backend,
|
|
61
|
+
fallbackSince: error.fallbackSince,
|
|
62
|
+
fallbackReason: error.fallbackReason
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (typeof error === "object" && error !== null) {
|
|
66
|
+
const record = error;
|
|
67
|
+
if (record.backend === "local" || record.backend === "scoutd" || record.backend === "local-fallback") {
|
|
68
|
+
return {
|
|
69
|
+
backend: record.backend,
|
|
70
|
+
fallbackSince: typeof record.fallbackSince === "number" ? record.fallbackSince : undefined,
|
|
71
|
+
fallbackReason: typeof record.fallbackReason === "string" ? record.fallbackReason : undefined
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function probeErrorDetails(error) {
|
|
78
|
+
if (typeof error !== "object" || error === null) {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
const record = error;
|
|
82
|
+
const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : undefined;
|
|
83
|
+
const timedOut = record.timedOut === true || code === "timeout" ? true : record.timedOut === false ? false : undefined;
|
|
84
|
+
return { code, timedOut };
|
|
85
|
+
}
|
|
86
|
+
function assertProbeSpec(spec) {
|
|
87
|
+
if (!spec.id.trim()) {
|
|
88
|
+
throw new Error("Probe id is required");
|
|
89
|
+
}
|
|
90
|
+
if (!Number.isFinite(spec.ttlMs) || spec.ttlMs <= 0) {
|
|
91
|
+
throw new Error(`Probe ${spec.id} must declare a positive ttlMs`);
|
|
92
|
+
}
|
|
93
|
+
if (!Number.isFinite(spec.timeoutMs) || spec.timeoutMs <= 0) {
|
|
94
|
+
throw new Error(`Probe ${spec.id} must declare a positive timeoutMs`);
|
|
95
|
+
}
|
|
96
|
+
if (spec.maxStaleMs !== undefined && (!Number.isFinite(spec.maxStaleMs) || spec.maxStaleMs <= 0)) {
|
|
97
|
+
throw new Error(`Probe ${spec.id} maxStaleMs must be positive when set`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function maxStaleMsFor(spec) {
|
|
101
|
+
return spec.maxStaleMs ?? Math.max(DEFAULT_MIN_MAX_STALE_MS, spec.ttlMs * 10);
|
|
102
|
+
}
|
|
103
|
+
function failureBackoffMs(consecutiveFailures) {
|
|
104
|
+
if (consecutiveFailures <= 0) {
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
return Math.min(30000, 1000 * 2 ** Math.min(consecutiveFailures - 1, 5));
|
|
108
|
+
}
|
|
109
|
+
function probeErrorFromUnknown(error, at, timedOut = false) {
|
|
110
|
+
if (typeof error === "object" && error !== null) {
|
|
111
|
+
const record = error;
|
|
112
|
+
const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : timedOut ? "timeout" : typeof record.name === "string" && record.name.trim() ? record.name.trim() : "error";
|
|
113
|
+
const message = typeof record.message === "string" && record.message.trim() ? record.message : String(error);
|
|
114
|
+
return { code, message, at, ...timedOut ? { timedOut: true } : {} };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
code: timedOut ? "timeout" : "error",
|
|
118
|
+
message: String(error),
|
|
119
|
+
at,
|
|
120
|
+
...timedOut ? { timedOut: true } : {}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function timeoutError(probeId, timeoutMs) {
|
|
124
|
+
return {
|
|
125
|
+
code: "timeout",
|
|
126
|
+
message: `Probe ${probeId} timed out after ${timeoutMs}ms`,
|
|
127
|
+
at: Date.now(),
|
|
128
|
+
timedOut: true
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function staleTooLongError(probeId, ageMs, maxStaleMs) {
|
|
132
|
+
return {
|
|
133
|
+
code: "max_stale_exceeded",
|
|
134
|
+
message: `Probe ${probeId} last good snapshot is ${ageMs}ms old, exceeding maxStaleMs ${maxStaleMs}`,
|
|
135
|
+
at: Date.now()
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function isFreshEnough(state, maxAgeMs) {
|
|
139
|
+
if (state.at === null || state.invalidated) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
return Date.now() - state.at <= maxAgeMs;
|
|
143
|
+
}
|
|
144
|
+
function logBackendTransition(input) {
|
|
145
|
+
if (input.from === input.to) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const keySuffix = input.key === undefined ? "" : ` key=${JSON.stringify(input.key)}`;
|
|
149
|
+
const reasonSuffix = input.reason ? ` (${input.reason})` : "";
|
|
150
|
+
console.warn(`[openscout] system probe ${input.id}${keySuffix} backend ${input.from} -> ${input.to}${reasonSuffix}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class ProbeInstance {
|
|
154
|
+
spec;
|
|
155
|
+
key;
|
|
156
|
+
runProbe;
|
|
157
|
+
scheduleRun;
|
|
158
|
+
state = {
|
|
159
|
+
value: null,
|
|
160
|
+
at: null,
|
|
161
|
+
error: null,
|
|
162
|
+
consecutiveFailures: 0,
|
|
163
|
+
inFlight: null,
|
|
164
|
+
invalidated: false,
|
|
165
|
+
invalidationReason: null,
|
|
166
|
+
invalidationSerial: 0,
|
|
167
|
+
nextRetryAt: 0,
|
|
168
|
+
backend: "local",
|
|
169
|
+
fallbackSince: null,
|
|
170
|
+
fallbackReason: null
|
|
171
|
+
};
|
|
172
|
+
metricState;
|
|
173
|
+
lastAccessAt = Date.now();
|
|
174
|
+
constructor(options) {
|
|
175
|
+
this.spec = options.spec;
|
|
176
|
+
this.key = options.key;
|
|
177
|
+
this.runProbe = options.run;
|
|
178
|
+
this.scheduleRun = options.scheduleRun ?? ((task) => task());
|
|
179
|
+
this.metricState = {
|
|
180
|
+
id: options.spec.id,
|
|
181
|
+
...options.key !== undefined ? { key: options.key } : {},
|
|
182
|
+
backend: "local",
|
|
183
|
+
runCount: 0,
|
|
184
|
+
failureCount: 0,
|
|
185
|
+
timeoutCount: 0,
|
|
186
|
+
staleServedCount: 0,
|
|
187
|
+
lastRunAt: null,
|
|
188
|
+
lastDurationMs: null,
|
|
189
|
+
lastSuccessAt: null
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
read() {
|
|
193
|
+
this.touch();
|
|
194
|
+
if (this.shouldRefreshForRead(Date.now())) {
|
|
195
|
+
this.ensureRefresh(false, this.spec.ttlMs);
|
|
196
|
+
}
|
|
197
|
+
const snap = this.snapshot();
|
|
198
|
+
if (snap.status === "stale" || snap.status === "failed" && snap.at !== null) {
|
|
199
|
+
this.metricState.staleServedCount += 1;
|
|
200
|
+
}
|
|
201
|
+
return snap;
|
|
202
|
+
}
|
|
203
|
+
async fresh(options = {}) {
|
|
204
|
+
this.touch();
|
|
205
|
+
const maxAgeMs = options.maxAgeMs ?? this.spec.ttlMs;
|
|
206
|
+
for (let attempt = 0;attempt < 3; attempt += 1) {
|
|
207
|
+
if (isFreshEnough(this.state, maxAgeMs)) {
|
|
208
|
+
return this.snapshot();
|
|
209
|
+
}
|
|
210
|
+
await this.ensureRefresh(true, maxAgeMs);
|
|
211
|
+
if (!this.state.invalidated) {
|
|
212
|
+
return this.snapshot();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return this.snapshot();
|
|
216
|
+
}
|
|
217
|
+
snapshot() {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const ageMs = this.state.at === null ? null : Math.max(0, now - this.state.at);
|
|
220
|
+
const maxStaleMs = maxStaleMsFor(this.spec);
|
|
221
|
+
const exceededMaxStale = ageMs !== null && ageMs > maxStaleMs;
|
|
222
|
+
const fresh = ageMs !== null && !this.state.invalidated && ageMs <= this.spec.ttlMs;
|
|
223
|
+
const status = this.state.at === null ? this.state.error ? "failed" : "empty" : exceededMaxStale ? "failed" : fresh ? "fresh" : "stale";
|
|
224
|
+
const error = exceededMaxStale ? this.state.error ?? staleTooLongError(this.spec.id, ageMs ?? 0, maxStaleMs) : this.state.error;
|
|
225
|
+
return {
|
|
226
|
+
id: this.spec.id,
|
|
227
|
+
...this.key !== undefined ? { key: this.key } : {},
|
|
228
|
+
value: status === "failed" && exceededMaxStale ? null : this.state.value,
|
|
229
|
+
at: this.state.at,
|
|
230
|
+
ageMs,
|
|
231
|
+
stale: status === "stale" || status === "failed" && this.state.at !== null,
|
|
232
|
+
refreshing: this.state.inFlight !== null,
|
|
233
|
+
status,
|
|
234
|
+
error,
|
|
235
|
+
consecutiveFailures: this.state.consecutiveFailures,
|
|
236
|
+
backend: this.state.backend,
|
|
237
|
+
...this.state.fallbackSince !== null ? { fallbackSince: this.state.fallbackSince } : {},
|
|
238
|
+
...this.state.fallbackReason !== null ? { fallbackReason: this.state.fallbackReason } : {}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
invalidate(reason) {
|
|
242
|
+
this.touch();
|
|
243
|
+
this.state.invalidated = true;
|
|
244
|
+
this.state.invalidationReason = reason ?? null;
|
|
245
|
+
this.state.invalidationSerial += 1;
|
|
246
|
+
this.state.nextRetryAt = 0;
|
|
247
|
+
}
|
|
248
|
+
metrics() {
|
|
249
|
+
return {
|
|
250
|
+
...this.metricState,
|
|
251
|
+
consecutiveFailures: this.state.consecutiveFailures,
|
|
252
|
+
inFlight: this.state.inFlight !== null,
|
|
253
|
+
...this.state.fallbackSince !== null ? { fallbackSince: this.state.fallbackSince } : {},
|
|
254
|
+
...this.state.fallbackReason !== null ? { fallbackReason: this.state.fallbackReason } : {}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
isRefreshing() {
|
|
258
|
+
return this.state.inFlight !== null;
|
|
259
|
+
}
|
|
260
|
+
touch() {
|
|
261
|
+
this.lastAccessAt = Date.now();
|
|
262
|
+
}
|
|
263
|
+
shouldRefreshForRead(now) {
|
|
264
|
+
if (this.state.inFlight !== null) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
if (this.state.nextRetryAt > now) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
if (this.state.at === null) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
if (this.state.invalidated) {
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
return now - this.state.at > this.spec.ttlMs;
|
|
277
|
+
}
|
|
278
|
+
ensureRefresh(force, maxAgeMs) {
|
|
279
|
+
if (this.state.inFlight) {
|
|
280
|
+
return this.state.inFlight;
|
|
281
|
+
}
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
if (!force && this.state.nextRetryAt > now) {
|
|
284
|
+
return Promise.resolve();
|
|
285
|
+
}
|
|
286
|
+
const inFlight = this.scheduleRun(() => this.executeRun(maxAgeMs)).finally(() => {
|
|
287
|
+
if (this.state.inFlight === inFlight) {
|
|
288
|
+
this.state.inFlight = null;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
this.state.inFlight = inFlight;
|
|
292
|
+
return inFlight;
|
|
293
|
+
}
|
|
294
|
+
async executeRun(maxAgeMs) {
|
|
295
|
+
const startedAt = Date.now();
|
|
296
|
+
const previousBackend = this.state.backend;
|
|
297
|
+
const invalidationSerialAtStart = this.state.invalidationSerial;
|
|
298
|
+
const controller = new AbortController;
|
|
299
|
+
let timeout = null;
|
|
300
|
+
this.metricState.runCount += 1;
|
|
301
|
+
this.metricState.lastRunAt = startedAt;
|
|
302
|
+
const ctx = {
|
|
303
|
+
probeId: this.spec.id,
|
|
304
|
+
...this.key !== undefined ? { key: this.key } : {},
|
|
305
|
+
signal: controller.signal,
|
|
306
|
+
timeoutMs: this.spec.timeoutMs,
|
|
307
|
+
maxAgeMs,
|
|
308
|
+
startedAt
|
|
309
|
+
};
|
|
310
|
+
const timeoutProbeError = timeoutError(this.spec.id, this.spec.timeoutMs);
|
|
311
|
+
const runPromise = this.runProbe(ctx);
|
|
312
|
+
runPromise.catch(() => {
|
|
313
|
+
return;
|
|
314
|
+
});
|
|
315
|
+
try {
|
|
316
|
+
const result = await new Promise((resolve, reject) => {
|
|
317
|
+
timeout = setTimeout(() => {
|
|
318
|
+
controller.abort(timeoutProbeError);
|
|
319
|
+
reject(timeoutProbeError);
|
|
320
|
+
}, this.spec.timeoutMs);
|
|
321
|
+
runPromise.then(resolve, reject);
|
|
322
|
+
});
|
|
323
|
+
const output = isProbeRunOutput(result) ? result : probeRunOutput(result, { backend: "local" });
|
|
324
|
+
this.state.value = output.value;
|
|
325
|
+
this.state.at = output.generatedAt ?? Date.now();
|
|
326
|
+
this.state.error = null;
|
|
327
|
+
this.state.consecutiveFailures = 0;
|
|
328
|
+
if (this.state.invalidationSerial === invalidationSerialAtStart) {
|
|
329
|
+
this.state.invalidated = false;
|
|
330
|
+
this.state.invalidationReason = null;
|
|
331
|
+
}
|
|
332
|
+
this.state.nextRetryAt = 0;
|
|
333
|
+
this.state.backend = output.backend;
|
|
334
|
+
this.state.fallbackSince = output.fallbackSince ?? null;
|
|
335
|
+
this.state.fallbackReason = output.fallbackReason ?? null;
|
|
336
|
+
this.metricState.backend = output.backend;
|
|
337
|
+
this.metricState.lastSuccessAt = this.state.at;
|
|
338
|
+
logBackendTransition({
|
|
339
|
+
id: this.spec.id,
|
|
340
|
+
key: this.key,
|
|
341
|
+
from: previousBackend,
|
|
342
|
+
to: output.backend,
|
|
343
|
+
reason: output.fallbackReason
|
|
344
|
+
});
|
|
345
|
+
} catch (error) {
|
|
346
|
+
const timedOut = error === timeoutProbeError || typeof error === "object" && error !== null && (error.code === "timeout" || error.timedOut === true);
|
|
347
|
+
const at = Date.now();
|
|
348
|
+
this.state.error = error === timeoutProbeError ? timeoutProbeError : probeErrorFromUnknown(error, at, timedOut);
|
|
349
|
+
this.state.consecutiveFailures += 1;
|
|
350
|
+
this.state.nextRetryAt = at + failureBackoffMs(this.state.consecutiveFailures);
|
|
351
|
+
const backendMetadata = backendMetadataFromError(error);
|
|
352
|
+
if (backendMetadata) {
|
|
353
|
+
this.state.backend = backendMetadata.backend;
|
|
354
|
+
this.state.fallbackSince = backendMetadata.fallbackSince ?? null;
|
|
355
|
+
this.state.fallbackReason = backendMetadata.fallbackReason ?? null;
|
|
356
|
+
this.metricState.backend = backendMetadata.backend;
|
|
357
|
+
logBackendTransition({
|
|
358
|
+
id: this.spec.id,
|
|
359
|
+
key: this.key,
|
|
360
|
+
from: previousBackend,
|
|
361
|
+
to: backendMetadata.backend,
|
|
362
|
+
reason: backendMetadata.fallbackReason
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
this.metricState.failureCount += 1;
|
|
366
|
+
if (this.state.error.timedOut || this.state.error.code === "timeout") {
|
|
367
|
+
this.metricState.timeoutCount += 1;
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
if (timeout) {
|
|
371
|
+
clearTimeout(timeout);
|
|
372
|
+
}
|
|
373
|
+
this.metricState.lastDurationMs = Date.now() - startedAt;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
class FamilyLimiter {
|
|
379
|
+
maxConcurrent;
|
|
380
|
+
active = 0;
|
|
381
|
+
queue = [];
|
|
382
|
+
constructor(maxConcurrent) {
|
|
383
|
+
this.maxConcurrent = maxConcurrent;
|
|
384
|
+
}
|
|
385
|
+
queued() {
|
|
386
|
+
return this.queue.length;
|
|
387
|
+
}
|
|
388
|
+
async run(task) {
|
|
389
|
+
if (this.active >= this.maxConcurrent) {
|
|
390
|
+
await new Promise((resolve) => this.queue.push(resolve));
|
|
391
|
+
}
|
|
392
|
+
this.active += 1;
|
|
393
|
+
try {
|
|
394
|
+
return await task();
|
|
395
|
+
} finally {
|
|
396
|
+
this.active -= 1;
|
|
397
|
+
const next = this.queue.shift();
|
|
398
|
+
if (next) {
|
|
399
|
+
next();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
class ProbeFamily {
|
|
406
|
+
spec;
|
|
407
|
+
entries = new Map;
|
|
408
|
+
limiter;
|
|
409
|
+
constructor(spec) {
|
|
410
|
+
this.spec = spec;
|
|
411
|
+
assertProbeSpec(spec);
|
|
412
|
+
if (!Number.isInteger(spec.maxKeys) || spec.maxKeys <= 0) {
|
|
413
|
+
throw new Error(`Probe family ${spec.id} must declare a positive maxKeys`);
|
|
414
|
+
}
|
|
415
|
+
if (!Number.isFinite(spec.idleKeyTtlMs) || spec.idleKeyTtlMs <= 0) {
|
|
416
|
+
throw new Error(`Probe family ${spec.id} must declare a positive idleKeyTtlMs`);
|
|
417
|
+
}
|
|
418
|
+
if (!Number.isInteger(spec.maxConcurrentKeys) || spec.maxConcurrentKeys <= 0) {
|
|
419
|
+
throw new Error(`Probe family ${spec.id} must declare a positive maxConcurrentKeys`);
|
|
420
|
+
}
|
|
421
|
+
this.limiter = new FamilyLimiter(spec.maxConcurrentKeys);
|
|
422
|
+
}
|
|
423
|
+
for(rawKey) {
|
|
424
|
+
const key = this.normalize(rawKey);
|
|
425
|
+
this.cleanupIdle(Date.now());
|
|
426
|
+
let entry = this.entries.get(key);
|
|
427
|
+
if (!entry) {
|
|
428
|
+
entry = new ProbeInstance({
|
|
429
|
+
spec: this.spec,
|
|
430
|
+
key,
|
|
431
|
+
run: (ctx) => this.spec.run(key, ctx),
|
|
432
|
+
scheduleRun: (task) => this.limiter.run(task)
|
|
433
|
+
});
|
|
434
|
+
this.entries.set(key, entry);
|
|
435
|
+
}
|
|
436
|
+
entry.lastAccessAt = Date.now();
|
|
437
|
+
this.evictLruIfNeeded();
|
|
438
|
+
return entry;
|
|
439
|
+
}
|
|
440
|
+
snapshot(key) {
|
|
441
|
+
return this.for(key).snapshot();
|
|
442
|
+
}
|
|
443
|
+
invalidate(key, reason) {
|
|
444
|
+
this.for(key).invalidate(reason);
|
|
445
|
+
}
|
|
446
|
+
metrics() {
|
|
447
|
+
this.cleanupIdle(Date.now());
|
|
448
|
+
return {
|
|
449
|
+
id: this.spec.id,
|
|
450
|
+
keyCount: this.entries.size,
|
|
451
|
+
maxKeys: this.spec.maxKeys,
|
|
452
|
+
activeRuns: this.limiter.active,
|
|
453
|
+
queuedRuns: this.limiter.queued(),
|
|
454
|
+
keys: Array.from(this.entries.values(), (entry) => entry.metrics())
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
keys() {
|
|
458
|
+
this.cleanupIdle(Date.now());
|
|
459
|
+
return Array.from(this.entries.keys());
|
|
460
|
+
}
|
|
461
|
+
normalize(rawKey) {
|
|
462
|
+
const key = this.spec.normalizeKey(rawKey).trim();
|
|
463
|
+
if (!key) {
|
|
464
|
+
throw new Error(`Probe family ${this.spec.id} normalized an empty key`);
|
|
465
|
+
}
|
|
466
|
+
return key;
|
|
467
|
+
}
|
|
468
|
+
cleanupIdle(now) {
|
|
469
|
+
for (const [key, entry] of this.entries) {
|
|
470
|
+
if (!entry.isRefreshing() && now - entry.lastAccessAt > this.spec.idleKeyTtlMs) {
|
|
471
|
+
this.entries.delete(key);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
evictLruIfNeeded() {
|
|
476
|
+
while (this.entries.size > this.spec.maxKeys) {
|
|
477
|
+
let oldestKey = null;
|
|
478
|
+
let oldestAccess = Number.POSITIVE_INFINITY;
|
|
479
|
+
for (const [key, entry] of this.entries) {
|
|
480
|
+
if (entry.isRefreshing()) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (entry.lastAccessAt < oldestAccess) {
|
|
484
|
+
oldestAccess = entry.lastAccessAt;
|
|
485
|
+
oldestKey = key;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (!oldestKey) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
this.entries.delete(oldestKey);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function defineProbe(spec) {
|
|
496
|
+
assertProbeSpec(spec);
|
|
497
|
+
const handle = new ProbeInstance({
|
|
498
|
+
spec,
|
|
499
|
+
run: spec.run
|
|
500
|
+
});
|
|
501
|
+
registeredProbes.push({
|
|
502
|
+
kind: "probe",
|
|
503
|
+
id: spec.id,
|
|
504
|
+
handle
|
|
505
|
+
});
|
|
506
|
+
return handle;
|
|
507
|
+
}
|
|
508
|
+
function defineProbeFamily(spec) {
|
|
509
|
+
const family = new ProbeFamily(spec);
|
|
510
|
+
registeredProbes.push({
|
|
511
|
+
kind: "family",
|
|
512
|
+
id: spec.id,
|
|
513
|
+
family
|
|
514
|
+
});
|
|
515
|
+
return family;
|
|
516
|
+
}
|
|
517
|
+
// packages/runtime/dist/system-probes/exec.js
|
|
518
|
+
import { spawn } from "node:child_process";
|
|
519
|
+
|
|
520
|
+
// packages/runtime/dist/system-probes/scoutd-client.js
|
|
521
|
+
import { existsSync } from "node:fs";
|
|
522
|
+
import { homedir } from "node:os";
|
|
523
|
+
import { join as join2 } from "node:path";
|
|
524
|
+
import { Socket } from "node:net";
|
|
525
|
+
|
|
526
|
+
// packages/runtime/dist/system-probes/scout-host-catalog.js
|
|
527
|
+
var SCOUT_HOST_PROBE_SCHEMA_VERSIONS = {
|
|
528
|
+
"tailscale.status": 1,
|
|
529
|
+
"git.buildInfo": 1,
|
|
530
|
+
"git.revParse": 1,
|
|
531
|
+
"git.diffShortstat": 1,
|
|
532
|
+
"git.statusPorcelain": 1,
|
|
533
|
+
"git.mergeBase": 1,
|
|
534
|
+
"git.logLastCommitUnix": 1,
|
|
535
|
+
"git.worktreeListPorcelain": 1,
|
|
536
|
+
"tmux.sessions": 1,
|
|
537
|
+
"tmux.panes": 1,
|
|
538
|
+
"zellij.sessions": 1,
|
|
539
|
+
"ps.runtime": 1,
|
|
540
|
+
"ps.discovery": 1,
|
|
541
|
+
"ps.cwd": 1,
|
|
542
|
+
"net.listeners": 1,
|
|
543
|
+
"repo.scan": 1,
|
|
544
|
+
"repo.diff": 1
|
|
545
|
+
};
|
|
546
|
+
var SCOUT_HOST_EXEC_VERB_SCHEMA_VERSIONS = {
|
|
547
|
+
"tmux.sendKeys": 1,
|
|
548
|
+
"tmux.sendKeysLiteral": 1,
|
|
549
|
+
"tmux.loadBuffer": 1,
|
|
550
|
+
"tmux.pasteBuffer": 1,
|
|
551
|
+
"tmux.deleteBuffer": 1,
|
|
552
|
+
"tmux.killSession": 1,
|
|
553
|
+
"tmux.newSession": 1,
|
|
554
|
+
"tmux.detachClient": 1,
|
|
555
|
+
"tailscale.cert": 1,
|
|
556
|
+
"reveal.open": 1
|
|
557
|
+
};
|
|
558
|
+
function expectedScoutHostProbeSchemaVersion(probeId) {
|
|
559
|
+
return Object.hasOwn(SCOUT_HOST_PROBE_SCHEMA_VERSIONS, probeId) ? SCOUT_HOST_PROBE_SCHEMA_VERSIONS[probeId] : null;
|
|
560
|
+
}
|
|
561
|
+
function expectedScoutHostExecVerbSchemaVersion(verb) {
|
|
562
|
+
return Object.hasOwn(SCOUT_HOST_EXEC_VERB_SCHEMA_VERSIONS, verb) ? SCOUT_HOST_EXEC_VERB_SCHEMA_VERSIONS[verb] : null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// packages/runtime/dist/system-probes/scoutd-client.js
|
|
566
|
+
var CAPABILITIES_SCHEMA = "openscout.probe.capabilities/v1";
|
|
567
|
+
var REQUEST_SCHEMA = "openscout.probe.request/v1";
|
|
568
|
+
var SNAPSHOT_SCHEMA = "openscout.probe.snapshot/v1";
|
|
569
|
+
var ERROR_SCHEMA = "openscout.probe.error/v1";
|
|
570
|
+
var EXEC_REQUEST_SCHEMA = "openscout.exec.request/v1";
|
|
571
|
+
var EXEC_RESPONSE_SCHEMA = "openscout.exec.response/v1";
|
|
572
|
+
var CAPABILITY_RECHECK_MS = 1e4;
|
|
573
|
+
var SOCKET_TIMEOUT_MS = 900;
|
|
574
|
+
var MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
575
|
+
function readString(value) {
|
|
576
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
577
|
+
}
|
|
578
|
+
function readNumber(value) {
|
|
579
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
580
|
+
}
|
|
581
|
+
function isRecord(value) {
|
|
582
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
583
|
+
}
|
|
584
|
+
function fallbackMessage(error) {
|
|
585
|
+
if (error instanceof Error && error.message.trim()) {
|
|
586
|
+
return error.message;
|
|
587
|
+
}
|
|
588
|
+
return String(error);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
class ScoutdExecResponseError extends Error {
|
|
592
|
+
code;
|
|
593
|
+
timedOut;
|
|
594
|
+
constructor(message, options = {}) {
|
|
595
|
+
super(message);
|
|
596
|
+
this.name = "ScoutdExecResponseError";
|
|
597
|
+
this.code = options.code ?? "exec_error";
|
|
598
|
+
this.timedOut = options.timedOut ?? false;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
class ScoutdProbeResponseError extends Error {
|
|
603
|
+
code;
|
|
604
|
+
timedOut;
|
|
605
|
+
constructor(message, options = {}) {
|
|
606
|
+
super(message);
|
|
607
|
+
this.name = "ScoutdProbeResponseError";
|
|
608
|
+
this.code = options.code ?? "probe_error";
|
|
609
|
+
this.timedOut = options.timedOut ?? false;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function resolveScoutdProbesSocketPath(env = process.env) {
|
|
613
|
+
const explicit = env.OPENSCOUT_PROBES_SOCKET?.trim();
|
|
614
|
+
if (explicit) {
|
|
615
|
+
return explicit;
|
|
616
|
+
}
|
|
617
|
+
const openScoutHome = env.OPENSCOUT_HOME?.trim() || join2(homedir(), ".openscout");
|
|
618
|
+
return join2(openScoutHome, "run", "scoutd-probes.sock");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
class ScoutdProbeClient {
|
|
622
|
+
env;
|
|
623
|
+
capabilities = null;
|
|
624
|
+
lastCapabilityCheckAt = null;
|
|
625
|
+
lastError = null;
|
|
626
|
+
daemonObserved = false;
|
|
627
|
+
fallbackByProbe = new Map;
|
|
628
|
+
fallbackByExec = new Map;
|
|
629
|
+
constructor(env = process.env) {
|
|
630
|
+
this.env = env;
|
|
631
|
+
}
|
|
632
|
+
async requestProbe(input) {
|
|
633
|
+
const socketPath = resolveScoutdProbesSocketPath(this.env);
|
|
634
|
+
const socketTimeoutMs = probeSocketTimeoutMs(input.timeoutMs);
|
|
635
|
+
const socketExists = existsSync(socketPath);
|
|
636
|
+
if (!socketExists) {
|
|
637
|
+
this.capabilities = null;
|
|
638
|
+
this.lastError = null;
|
|
639
|
+
return this.daemonObserved ? this.fallback(input.probeId, input.key, `probe socket is missing: ${socketPath}`) : { state: "local" };
|
|
640
|
+
}
|
|
641
|
+
this.daemonObserved = true;
|
|
642
|
+
const capabilities = await this.ensureCapabilities(socketPath, socketTimeoutMs);
|
|
643
|
+
if (!capabilities) {
|
|
644
|
+
return this.fallback(input.probeId, input.key, this.lastError ?? "probe capabilities unavailable");
|
|
645
|
+
}
|
|
646
|
+
const capability = capabilities.families.get(input.probeId);
|
|
647
|
+
if (!capability) {
|
|
648
|
+
return this.fallback(input.probeId, input.key, `scoutd does not serve ${input.probeId}`);
|
|
649
|
+
}
|
|
650
|
+
const expectedSchemaVersion = expectedScoutHostProbeSchemaVersion(input.probeId);
|
|
651
|
+
if (expectedSchemaVersion === null) {
|
|
652
|
+
return this.fallback(input.probeId, input.key, `client has no compiled schema version for ${input.probeId}`);
|
|
653
|
+
}
|
|
654
|
+
if (capability.schemaVersion !== expectedSchemaVersion) {
|
|
655
|
+
return this.fallback(input.probeId, input.key, `scoutd serves ${input.probeId} schema v${capability.schemaVersion}, expected v${expectedSchemaVersion}`);
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
const response = await requestJson(socketPath, {
|
|
659
|
+
schema: REQUEST_SCHEMA,
|
|
660
|
+
schemaVersion: expectedSchemaVersion,
|
|
661
|
+
probeId: input.probeId,
|
|
662
|
+
key: input.key ?? null,
|
|
663
|
+
maxAgeMs: input.maxAgeMs,
|
|
664
|
+
opTimeoutMs: requestOpTimeoutMs(input.timeoutMs)
|
|
665
|
+
}, { timeoutMs: socketTimeoutMs });
|
|
666
|
+
if (!isRecord(response) || response.schema !== SNAPSHOT_SCHEMA) {
|
|
667
|
+
throw new Error("scoutd returned an invalid probe snapshot envelope");
|
|
668
|
+
}
|
|
669
|
+
if (response.error) {
|
|
670
|
+
const error = isRecord(response.error) ? response.error : {};
|
|
671
|
+
const code = readString(error.code) ?? "probe_error";
|
|
672
|
+
const message = readString(error.message) ?? code;
|
|
673
|
+
const timedOut = error.timedOut === true || error.timed_out === true;
|
|
674
|
+
throw new ScoutdProbeResponseError(message, { code, timedOut });
|
|
675
|
+
}
|
|
676
|
+
this.fallbackByProbe.delete(fallbackKey(input.probeId, input.key));
|
|
677
|
+
this.lastError = null;
|
|
678
|
+
return {
|
|
679
|
+
state: "scoutd",
|
|
680
|
+
value: response.value,
|
|
681
|
+
generatedAt: readNumber(response.generatedAt) ?? Date.now(),
|
|
682
|
+
daemonVersion: readString(response.daemonVersion) ?? capabilities.daemonVersion
|
|
683
|
+
};
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (!(error instanceof ScoutdProbeResponseError)) {
|
|
686
|
+
this.capabilities = null;
|
|
687
|
+
this.lastCapabilityCheckAt = null;
|
|
688
|
+
}
|
|
689
|
+
this.lastError = fallbackMessage(error);
|
|
690
|
+
return this.fallback(input.probeId, input.key, this.lastError);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
async requestExecVerb(input) {
|
|
694
|
+
const socketPath = resolveScoutdProbesSocketPath(this.env);
|
|
695
|
+
const socketTimeoutMs = execSocketTimeoutMs(input.args);
|
|
696
|
+
const socketExists = existsSync(socketPath);
|
|
697
|
+
if (!socketExists) {
|
|
698
|
+
this.capabilities = null;
|
|
699
|
+
this.lastError = null;
|
|
700
|
+
return this.daemonObserved ? this.execFallback(input.verb, `probe socket is missing: ${socketPath}`) : { state: "local" };
|
|
701
|
+
}
|
|
702
|
+
this.daemonObserved = true;
|
|
703
|
+
const capabilities = await this.ensureCapabilities(socketPath, socketTimeoutMs);
|
|
704
|
+
if (!capabilities) {
|
|
705
|
+
return this.execFallback(input.verb, this.lastError ?? "probe capabilities unavailable");
|
|
706
|
+
}
|
|
707
|
+
const capability = capabilities.verbs.get(input.verb);
|
|
708
|
+
if (!capability) {
|
|
709
|
+
return this.execFallback(input.verb, `scoutd does not serve ${input.verb}`);
|
|
710
|
+
}
|
|
711
|
+
const expectedSchemaVersion = expectedScoutHostExecVerbSchemaVersion(input.verb);
|
|
712
|
+
if (expectedSchemaVersion === null) {
|
|
713
|
+
return this.execFallback(input.verb, `client has no compiled schema version for ${input.verb}`);
|
|
714
|
+
}
|
|
715
|
+
if (capability.schemaVersion !== expectedSchemaVersion) {
|
|
716
|
+
return this.execFallback(input.verb, `scoutd serves ${input.verb} schema v${capability.schemaVersion}, expected v${expectedSchemaVersion}`);
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
const response = await requestJson(socketPath, {
|
|
720
|
+
schema: EXEC_REQUEST_SCHEMA,
|
|
721
|
+
schemaVersion: expectedSchemaVersion,
|
|
722
|
+
verb: input.verb,
|
|
723
|
+
args: input.args
|
|
724
|
+
}, { timeoutMs: socketTimeoutMs });
|
|
725
|
+
if (!isRecord(response) || response.schema !== EXEC_RESPONSE_SCHEMA) {
|
|
726
|
+
throw new Error("scoutd returned an invalid exec response envelope");
|
|
727
|
+
}
|
|
728
|
+
if (!response.ok) {
|
|
729
|
+
const error = isRecord(response.error) ? response.error : {};
|
|
730
|
+
const code = readString(error.code) ?? "exec_error";
|
|
731
|
+
const message = readString(error.message) ?? code;
|
|
732
|
+
const timedOut = error.timedOut === true || error.timed_out === true;
|
|
733
|
+
throw new ScoutdExecResponseError(message, { code, timedOut });
|
|
734
|
+
}
|
|
735
|
+
this.fallbackByExec.delete(input.verb);
|
|
736
|
+
this.lastError = null;
|
|
737
|
+
return {
|
|
738
|
+
state: "scoutd",
|
|
739
|
+
value: response.value,
|
|
740
|
+
daemonVersion: readString(response.daemonVersion) ?? capabilities.daemonVersion
|
|
741
|
+
};
|
|
742
|
+
} catch (error) {
|
|
743
|
+
if (error instanceof ScoutdExecResponseError) {
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
this.capabilities = null;
|
|
747
|
+
this.lastCapabilityCheckAt = null;
|
|
748
|
+
this.lastError = fallbackMessage(error);
|
|
749
|
+
return this.execFallback(input.verb, this.lastError);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
diagnostics() {
|
|
753
|
+
const socketPath = resolveScoutdProbesSocketPath(this.env);
|
|
754
|
+
return {
|
|
755
|
+
socketPath,
|
|
756
|
+
socketExists: existsSync(socketPath),
|
|
757
|
+
daemonObserved: this.daemonObserved,
|
|
758
|
+
daemonVersion: this.capabilities?.daemonVersion ?? null,
|
|
759
|
+
supportedProbeIds: this.capabilities ? [...this.capabilities.families.keys()].sort() : [],
|
|
760
|
+
supportedExecVerbs: this.capabilities ? [...this.capabilities.verbs.keys()].sort() : [],
|
|
761
|
+
lastCapabilityCheckAt: this.lastCapabilityCheckAt,
|
|
762
|
+
lastError: this.lastError
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
resetForTests() {
|
|
766
|
+
this.capabilities = null;
|
|
767
|
+
this.lastCapabilityCheckAt = null;
|
|
768
|
+
this.lastError = null;
|
|
769
|
+
this.daemonObserved = false;
|
|
770
|
+
this.fallbackByProbe.clear();
|
|
771
|
+
this.fallbackByExec.clear();
|
|
772
|
+
}
|
|
773
|
+
async ensureCapabilities(socketPath, timeoutMs = SOCKET_TIMEOUT_MS) {
|
|
774
|
+
const now = Date.now();
|
|
775
|
+
if (this.capabilities && this.lastCapabilityCheckAt !== null && now - this.lastCapabilityCheckAt < CAPABILITY_RECHECK_MS) {
|
|
776
|
+
return this.capabilities;
|
|
777
|
+
}
|
|
778
|
+
this.lastCapabilityCheckAt = now;
|
|
779
|
+
try {
|
|
780
|
+
const response = await requestJson(socketPath, { schema: CAPABILITIES_SCHEMA }, { timeoutMs });
|
|
781
|
+
if (!isRecord(response) || response.schema !== CAPABILITIES_SCHEMA) {
|
|
782
|
+
throw new Error("scoutd returned an invalid capabilities envelope");
|
|
783
|
+
}
|
|
784
|
+
const daemonVersion = readString(response.daemonVersion) ?? "unknown";
|
|
785
|
+
const families = new Map;
|
|
786
|
+
if (Array.isArray(response.families)) {
|
|
787
|
+
for (const entry of response.families) {
|
|
788
|
+
if (!isRecord(entry))
|
|
789
|
+
continue;
|
|
790
|
+
const probeId = readString(entry.probeId);
|
|
791
|
+
const schemaVersion = readNumber(entry.schemaVersion);
|
|
792
|
+
const ttlMs = readNumber(entry.ttlMs);
|
|
793
|
+
if (probeId && schemaVersion !== null && ttlMs !== null) {
|
|
794
|
+
families.set(probeId, { probeId, schemaVersion, ttlMs });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const verbs = new Map;
|
|
799
|
+
if (Array.isArray(response.verbs)) {
|
|
800
|
+
for (const entry of response.verbs) {
|
|
801
|
+
if (!isRecord(entry))
|
|
802
|
+
continue;
|
|
803
|
+
const verb = readString(entry.verb);
|
|
804
|
+
const schemaVersion = readNumber(entry.schemaVersion);
|
|
805
|
+
if (verb && schemaVersion !== null) {
|
|
806
|
+
verbs.set(verb, { verb, schemaVersion });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
this.capabilities = { daemonVersion, families, verbs };
|
|
811
|
+
this.daemonObserved = true;
|
|
812
|
+
this.lastError = null;
|
|
813
|
+
return this.capabilities;
|
|
814
|
+
} catch (error) {
|
|
815
|
+
this.capabilities = null;
|
|
816
|
+
this.lastError = fallbackMessage(error);
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
fallback(probeId, key, reason) {
|
|
821
|
+
const id = fallbackKey(probeId, key);
|
|
822
|
+
let state = this.fallbackByProbe.get(id);
|
|
823
|
+
if (!state || state.reason !== reason) {
|
|
824
|
+
state = { since: Date.now(), reason };
|
|
825
|
+
this.fallbackByProbe.set(id, state);
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
state: "local",
|
|
829
|
+
fallbackSince: state.since,
|
|
830
|
+
fallbackReason: state.reason
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
execFallback(verb, reason) {
|
|
834
|
+
let state = this.fallbackByExec.get(verb);
|
|
835
|
+
if (!state || state.reason !== reason) {
|
|
836
|
+
state = { since: Date.now(), reason };
|
|
837
|
+
this.fallbackByExec.set(verb, state);
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
state: "local",
|
|
841
|
+
fallbackSince: state.since,
|
|
842
|
+
fallbackReason: state.reason
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
var singletonClient = null;
|
|
847
|
+
function getScoutdProbeClient() {
|
|
848
|
+
singletonClient ??= new ScoutdProbeClient;
|
|
849
|
+
return singletonClient;
|
|
850
|
+
}
|
|
851
|
+
async function runWithScoutdFallback(input) {
|
|
852
|
+
const client = getScoutdProbeClient();
|
|
853
|
+
const scoutd = await client.requestProbe({
|
|
854
|
+
probeId: input.probeId,
|
|
855
|
+
key: input.key,
|
|
856
|
+
maxAgeMs: input.ctx.maxAgeMs,
|
|
857
|
+
timeoutMs: input.ctx.timeoutMs
|
|
858
|
+
});
|
|
859
|
+
if (scoutd.state === "scoutd") {
|
|
860
|
+
return probeRunOutput(scoutd.value, {
|
|
861
|
+
backend: "scoutd",
|
|
862
|
+
generatedAt: scoutd.generatedAt
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
const metadata = scoutd.fallbackSince ? {
|
|
866
|
+
backend: "local-fallback",
|
|
867
|
+
fallbackSince: scoutd.fallbackSince,
|
|
868
|
+
fallbackReason: scoutd.fallbackReason ?? "scoutd unavailable"
|
|
869
|
+
} : { backend: "local" };
|
|
870
|
+
try {
|
|
871
|
+
const local = await input.local();
|
|
872
|
+
return probeRunOutput(local, metadata);
|
|
873
|
+
} catch (error) {
|
|
874
|
+
throw new ProbeBackendError(error instanceof Error ? error.message : String(error), metadata, error);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
function probeSocketTimeoutMs(timeoutMs) {
|
|
878
|
+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) {
|
|
879
|
+
return SOCKET_TIMEOUT_MS;
|
|
880
|
+
}
|
|
881
|
+
return Math.max(SOCKET_TIMEOUT_MS, Math.min(Math.max(0, timeoutMs) + 1000, 31000));
|
|
882
|
+
}
|
|
883
|
+
function requestOpTimeoutMs(timeoutMs) {
|
|
884
|
+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
return Math.max(0, Math.floor(timeoutMs));
|
|
888
|
+
}
|
|
889
|
+
function execSocketTimeoutMs(args) {
|
|
890
|
+
const timeoutMs = typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs) ? Math.max(0, args.timeoutMs) : 5000;
|
|
891
|
+
return Math.max(SOCKET_TIMEOUT_MS, Math.min(timeoutMs + 1000, 31000));
|
|
892
|
+
}
|
|
893
|
+
async function requestJson(socketPath, payload, options = {}) {
|
|
894
|
+
const bun = globalThis.Bun;
|
|
895
|
+
if (bun?.connect) {
|
|
896
|
+
return await requestJsonWithBun(bun.connect, socketPath, payload, options);
|
|
897
|
+
}
|
|
898
|
+
return await new Promise((resolve, reject) => {
|
|
899
|
+
const socket = new Socket;
|
|
900
|
+
let response = "";
|
|
901
|
+
let settled = false;
|
|
902
|
+
const finish = (error, value) => {
|
|
903
|
+
if (settled)
|
|
904
|
+
return;
|
|
905
|
+
settled = true;
|
|
906
|
+
clearTimeout(timer);
|
|
907
|
+
socket.removeAllListeners();
|
|
908
|
+
socket.destroy();
|
|
909
|
+
if (error) {
|
|
910
|
+
reject(error);
|
|
911
|
+
} else {
|
|
912
|
+
resolve(value);
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
const timeoutMs = options.timeoutMs ?? SOCKET_TIMEOUT_MS;
|
|
916
|
+
const timer = setTimeout(() => {
|
|
917
|
+
finish(new Error(`scoutd probe socket timed out after ${timeoutMs}ms`));
|
|
918
|
+
}, timeoutMs);
|
|
919
|
+
timer.unref?.();
|
|
920
|
+
const finishFromResponse = () => {
|
|
921
|
+
if (settled)
|
|
922
|
+
return;
|
|
923
|
+
try {
|
|
924
|
+
const parsed = JSON.parse(response);
|
|
925
|
+
if (isRecord(parsed) && parsed.schema === ERROR_SCHEMA) {
|
|
926
|
+
const error = isRecord(parsed.error) ? parsed.error : {};
|
|
927
|
+
const message = readString(error.message) ?? readString(error.code) ?? "scoutd probe request failed";
|
|
928
|
+
finish(new Error(message));
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
finish(null, parsed);
|
|
932
|
+
} catch (error) {
|
|
933
|
+
finish(new Error(`scoutd probe response was not JSON: ${fallbackMessage(error)}`));
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
socket.setEncoding("utf8");
|
|
937
|
+
socket.on("connect", () => {
|
|
938
|
+
socket.write(`${JSON.stringify(payload)}
|
|
939
|
+
`);
|
|
940
|
+
});
|
|
941
|
+
socket.on("data", (chunk) => {
|
|
942
|
+
response += chunk;
|
|
943
|
+
if (Buffer.byteLength(response, "utf8") > MAX_RESPONSE_BYTES) {
|
|
944
|
+
finish(new Error("scoutd probe response exceeded output limit"));
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
socket.on("error", (error) => finish(error));
|
|
948
|
+
socket.on("end", finishFromResponse);
|
|
949
|
+
socket.on("close", () => {
|
|
950
|
+
if (!settled) {
|
|
951
|
+
if (response.length === 0) {
|
|
952
|
+
finish(new Error("scoutd probe socket closed without a response"));
|
|
953
|
+
} else {
|
|
954
|
+
finishFromResponse();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
socket.connect(socketPath);
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
async function requestJsonWithBun(connect, socketPath, payload, options = {}) {
|
|
962
|
+
return await new Promise((resolve, reject) => {
|
|
963
|
+
let response = "";
|
|
964
|
+
let settled = false;
|
|
965
|
+
let socketRef = null;
|
|
966
|
+
const decoder = new TextDecoder;
|
|
967
|
+
const finish = (error, value) => {
|
|
968
|
+
if (settled)
|
|
969
|
+
return;
|
|
970
|
+
settled = true;
|
|
971
|
+
clearTimeout(timer);
|
|
972
|
+
try {
|
|
973
|
+
socketRef?.end?.();
|
|
974
|
+
} catch {}
|
|
975
|
+
if (error) {
|
|
976
|
+
reject(error);
|
|
977
|
+
} else {
|
|
978
|
+
resolve(value);
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
const finishFromResponse = () => {
|
|
982
|
+
if (settled)
|
|
983
|
+
return;
|
|
984
|
+
try {
|
|
985
|
+
const parsed = JSON.parse(response);
|
|
986
|
+
if (isRecord(parsed) && parsed.schema === ERROR_SCHEMA) {
|
|
987
|
+
const error = isRecord(parsed.error) ? parsed.error : {};
|
|
988
|
+
const message = readString(error.message) ?? readString(error.code) ?? "scoutd probe request failed";
|
|
989
|
+
finish(new Error(message));
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
finish(null, parsed);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
finish(new Error(`scoutd probe response was not JSON: ${fallbackMessage(error)}`));
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
const timeoutMs = options.timeoutMs ?? SOCKET_TIMEOUT_MS;
|
|
998
|
+
const timer = setTimeout(() => {
|
|
999
|
+
finish(new Error(`scoutd probe socket timed out after ${timeoutMs}ms`));
|
|
1000
|
+
}, timeoutMs);
|
|
1001
|
+
timer.unref?.();
|
|
1002
|
+
connect({
|
|
1003
|
+
unix: socketPath,
|
|
1004
|
+
socket: {
|
|
1005
|
+
open(socket) {
|
|
1006
|
+
socketRef = socket;
|
|
1007
|
+
socket.write(`${JSON.stringify(payload)}
|
|
1008
|
+
`);
|
|
1009
|
+
},
|
|
1010
|
+
data(_socket, data) {
|
|
1011
|
+
response += decoder.decode(data, { stream: true });
|
|
1012
|
+
if (Buffer.byteLength(response, "utf8") > MAX_RESPONSE_BYTES) {
|
|
1013
|
+
finish(new Error("scoutd probe response exceeded output limit"));
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
1016
|
+
close() {
|
|
1017
|
+
if (response.length === 0) {
|
|
1018
|
+
finish(new Error("scoutd probe socket closed without a response"));
|
|
1019
|
+
} else {
|
|
1020
|
+
response += decoder.decode();
|
|
1021
|
+
finishFromResponse();
|
|
1022
|
+
}
|
|
1023
|
+
},
|
|
1024
|
+
error(_socket, error) {
|
|
1025
|
+
finish(error);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}).then((socket) => {
|
|
1029
|
+
socketRef = socket;
|
|
1030
|
+
}, (error) => {
|
|
1031
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
function fallbackKey(probeId, key) {
|
|
1036
|
+
return `${probeId}\x00${key ?? ""}`;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// packages/runtime/dist/system-probes/exec.js
|
|
1040
|
+
class ProbeCommandError extends Error {
|
|
1041
|
+
code;
|
|
1042
|
+
exitCode;
|
|
1043
|
+
signal;
|
|
1044
|
+
constructor(message, options) {
|
|
1045
|
+
super(message);
|
|
1046
|
+
this.name = "ProbeCommandError";
|
|
1047
|
+
this.code = options.code;
|
|
1048
|
+
this.exitCode = options.exitCode;
|
|
1049
|
+
this.signal = options.signal;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
var DEFAULT_STDOUT_CAP_BYTES = 1024 * 1024;
|
|
1053
|
+
var DEFAULT_STDERR_CAP_BYTES = 128 * 1024;
|
|
1054
|
+
var SIGKILL_DELAY_MS = 500;
|
|
1055
|
+
var EXEC_SYSTEM_TRANSPORT_METADATA = Symbol.for("openscout.execSystem.transport");
|
|
1056
|
+
var spawnProcess = spawn;
|
|
1057
|
+
function attachExecTransportMetadata(output, metadata) {
|
|
1058
|
+
Object.defineProperty(output, EXEC_SYSTEM_TRANSPORT_METADATA, {
|
|
1059
|
+
value: metadata,
|
|
1060
|
+
enumerable: false,
|
|
1061
|
+
configurable: true
|
|
1062
|
+
});
|
|
1063
|
+
return output;
|
|
1064
|
+
}
|
|
1065
|
+
function bufferByteLength(chunks) {
|
|
1066
|
+
return chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
1067
|
+
}
|
|
1068
|
+
function abortMessage(ctx) {
|
|
1069
|
+
const reason = ctx.signal.reason;
|
|
1070
|
+
if (typeof reason === "object" && reason !== null && "message" in reason) {
|
|
1071
|
+
const message = reason.message;
|
|
1072
|
+
if (typeof message === "string" && message.trim()) {
|
|
1073
|
+
return message;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return `Probe ${ctx.probeId} aborted`;
|
|
1077
|
+
}
|
|
1078
|
+
async function execProbeFile(ctx, file, args, options = {}) {
|
|
1079
|
+
const maxStdoutBytes = options.maxStdoutBytes ?? DEFAULT_STDOUT_CAP_BYTES;
|
|
1080
|
+
const maxStderrBytes = options.maxStderrBytes ?? DEFAULT_STDERR_CAP_BYTES;
|
|
1081
|
+
return await new Promise((resolve, reject) => {
|
|
1082
|
+
if (ctx.signal.aborted) {
|
|
1083
|
+
reject(new ProbeCommandError(abortMessage(ctx), { code: "aborted" }));
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const stdoutChunks = [];
|
|
1087
|
+
const stderrChunks = [];
|
|
1088
|
+
let settled = false;
|
|
1089
|
+
let killTimer = null;
|
|
1090
|
+
const child = spawnProcess(file, [...args], {
|
|
1091
|
+
cwd: options.cwd,
|
|
1092
|
+
env: options.env,
|
|
1093
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
1094
|
+
windowsHide: true
|
|
1095
|
+
});
|
|
1096
|
+
const cleanup = () => {
|
|
1097
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
1098
|
+
if (killTimer) {
|
|
1099
|
+
clearTimeout(killTimer);
|
|
1100
|
+
killTimer = null;
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
const fail = (error) => {
|
|
1104
|
+
if (settled) {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
settled = true;
|
|
1108
|
+
cleanup();
|
|
1109
|
+
if (!child.killed) {
|
|
1110
|
+
child.kill("SIGTERM");
|
|
1111
|
+
}
|
|
1112
|
+
reject(error);
|
|
1113
|
+
};
|
|
1114
|
+
const onAbort = () => {
|
|
1115
|
+
if (settled) {
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
settled = true;
|
|
1119
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
1120
|
+
child.kill("SIGTERM");
|
|
1121
|
+
killTimer = setTimeout(() => {
|
|
1122
|
+
child.kill("SIGKILL");
|
|
1123
|
+
}, SIGKILL_DELAY_MS);
|
|
1124
|
+
reject(new ProbeCommandError(abortMessage(ctx), { code: "timeout" }));
|
|
1125
|
+
};
|
|
1126
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
1127
|
+
if (options.input !== undefined) {
|
|
1128
|
+
child.stdin?.end(options.input);
|
|
1129
|
+
}
|
|
1130
|
+
child.stdout?.on("data", (chunk) => {
|
|
1131
|
+
if (settled) {
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
stdoutChunks.push(chunk);
|
|
1135
|
+
if (bufferByteLength(stdoutChunks) > maxStdoutBytes) {
|
|
1136
|
+
fail(new ProbeCommandError(`Probe ${ctx.probeId} stdout exceeded ${maxStdoutBytes} bytes`, { code: "output_cap" }));
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
child.stderr?.on("data", (chunk) => {
|
|
1140
|
+
if (settled) {
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
stderrChunks.push(chunk);
|
|
1144
|
+
if (bufferByteLength(stderrChunks) > maxStderrBytes) {
|
|
1145
|
+
fail(new ProbeCommandError(`Probe ${ctx.probeId} stderr exceeded ${maxStderrBytes} bytes`, { code: "output_cap" }));
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
child.once("error", (error) => {
|
|
1149
|
+
if (settled) {
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
settled = true;
|
|
1153
|
+
cleanup();
|
|
1154
|
+
const code = typeof error.code === "string" ? String(error.code) : "spawn";
|
|
1155
|
+
reject(new ProbeCommandError(error.message, { code }));
|
|
1156
|
+
});
|
|
1157
|
+
child.once("close", (exitCode, signal) => {
|
|
1158
|
+
if (settled) {
|
|
1159
|
+
cleanup();
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
settled = true;
|
|
1163
|
+
cleanup();
|
|
1164
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
1165
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
1166
|
+
if (exitCode === 0) {
|
|
1167
|
+
resolve({ stdout, stderr, exitCode });
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
reject(new ProbeCommandError(`${file} exited with ${exitCode ?? signal ?? "unknown status"}`, { code: "exit", exitCode, signal }));
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
async function execSystemFile(file, args, options) {
|
|
1175
|
+
const execVerb = mapExecSystemVerb(file, args, options);
|
|
1176
|
+
if (execVerb) {
|
|
1177
|
+
let scoutd;
|
|
1178
|
+
try {
|
|
1179
|
+
scoutd = await getScoutdProbeClient().requestExecVerb({
|
|
1180
|
+
verb: execVerb.verb,
|
|
1181
|
+
args: execVerb.args
|
|
1182
|
+
});
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
if (error instanceof ScoutdExecResponseError) {
|
|
1185
|
+
throw new ProbeCommandError(error.message, { code: error.code });
|
|
1186
|
+
}
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
1189
|
+
if (scoutd.state === "scoutd") {
|
|
1190
|
+
return attachExecTransportMetadata(normalizeExecResult(scoutd.value), {
|
|
1191
|
+
backend: "scoutd",
|
|
1192
|
+
daemonVersion: scoutd.daemonVersion,
|
|
1193
|
+
verb: execVerb.verb
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
const local2 = await execSystemFileLocal(file, args, options);
|
|
1198
|
+
return attachExecTransportMetadata(local2, scoutd.fallbackSince ? {
|
|
1199
|
+
backend: "local-fallback",
|
|
1200
|
+
fallbackSince: scoutd.fallbackSince,
|
|
1201
|
+
fallbackReason: scoutd.fallbackReason ?? "scoutd exec unavailable",
|
|
1202
|
+
verb: execVerb.verb
|
|
1203
|
+
} : {
|
|
1204
|
+
backend: "local",
|
|
1205
|
+
verb: execVerb.verb
|
|
1206
|
+
});
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
throw error;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const local = await execSystemFileLocal(file, args, options);
|
|
1212
|
+
return attachExecTransportMetadata(local, { backend: "local" });
|
|
1213
|
+
}
|
|
1214
|
+
async function execSystemFileLocal(file, args, options) {
|
|
1215
|
+
const controller = new AbortController;
|
|
1216
|
+
const timeout = setTimeout(() => {
|
|
1217
|
+
controller.abort(new ProbeCommandError(`Command ${file} timed out after ${options.timeoutMs}ms`, { code: "timeout" }));
|
|
1218
|
+
}, options.timeoutMs);
|
|
1219
|
+
try {
|
|
1220
|
+
return await execProbeFile({
|
|
1221
|
+
probeId: options.probeId ?? `imperative.${file}`,
|
|
1222
|
+
signal: controller.signal,
|
|
1223
|
+
timeoutMs: options.timeoutMs,
|
|
1224
|
+
startedAt: Date.now()
|
|
1225
|
+
}, file, args, options);
|
|
1226
|
+
} finally {
|
|
1227
|
+
clearTimeout(timeout);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
function normalizeExecResult(value) {
|
|
1231
|
+
if (!value || typeof value !== "object") {
|
|
1232
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
1233
|
+
}
|
|
1234
|
+
return {
|
|
1235
|
+
stdout: typeof value.stdout === "string" ? value.stdout : "",
|
|
1236
|
+
stderr: typeof value.stderr === "string" ? value.stderr : "",
|
|
1237
|
+
exitCode: typeof value.exitCode === "number" && Number.isFinite(value.exitCode) ? value.exitCode : 0
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
function mapExecSystemVerb(file, args, options) {
|
|
1241
|
+
const command = commandBasename(file);
|
|
1242
|
+
if (command === "tmux")
|
|
1243
|
+
return mapTmuxVerb(args, options);
|
|
1244
|
+
if (command === "tailscale")
|
|
1245
|
+
return mapTailscaleVerb(args, options);
|
|
1246
|
+
if (command === "open" || file === "/usr/bin/open")
|
|
1247
|
+
return mapDarwinRevealVerb(args, options);
|
|
1248
|
+
if (command === "xdg-open")
|
|
1249
|
+
return mapXdgRevealVerb(args, options);
|
|
1250
|
+
if (command === "explorer.exe")
|
|
1251
|
+
return mapWindowsRevealVerb(args, options);
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
function commandBasename(file) {
|
|
1255
|
+
const normalized = file.trim();
|
|
1256
|
+
const slash = normalized.lastIndexOf("/");
|
|
1257
|
+
return slash >= 0 ? normalized.slice(slash + 1) : normalized;
|
|
1258
|
+
}
|
|
1259
|
+
function baseVerbArgs(options) {
|
|
1260
|
+
return { timeoutMs: options.timeoutMs };
|
|
1261
|
+
}
|
|
1262
|
+
function mapTmuxVerb(rawArgs, options) {
|
|
1263
|
+
const { socketPath, args } = stripTmuxSocketArgs(rawArgs);
|
|
1264
|
+
const command = args[0];
|
|
1265
|
+
const base = { ...baseVerbArgs(options), ...socketPath ? { socketPath } : {} };
|
|
1266
|
+
if (command === "send-keys") {
|
|
1267
|
+
const parsed = parseTmuxSendKeys(args.slice(1));
|
|
1268
|
+
if (!parsed)
|
|
1269
|
+
return null;
|
|
1270
|
+
if (parsed.literal !== null) {
|
|
1271
|
+
return {
|
|
1272
|
+
verb: "tmux.sendKeysLiteral",
|
|
1273
|
+
args: { ...base, target: parsed.target, text: parsed.literal }
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
return {
|
|
1277
|
+
verb: "tmux.sendKeys",
|
|
1278
|
+
args: { ...base, target: parsed.target, keys: parsed.keys }
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
if (command === "load-buffer") {
|
|
1282
|
+
const bufferName = optionValue(args.slice(1), "-b");
|
|
1283
|
+
if (!bufferName || args[args.length - 1] !== "-" || options.input === undefined)
|
|
1284
|
+
return null;
|
|
1285
|
+
return {
|
|
1286
|
+
verb: "tmux.loadBuffer",
|
|
1287
|
+
args: {
|
|
1288
|
+
...base,
|
|
1289
|
+
bufferName,
|
|
1290
|
+
content: typeof options.input === "string" ? options.input : Buffer.from(options.input).toString("utf8")
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
if (command === "paste-buffer") {
|
|
1295
|
+
const flags = args[1];
|
|
1296
|
+
const bufferName = optionValue(args.slice(1), "-b");
|
|
1297
|
+
const target = optionValue(args.slice(1), "-t");
|
|
1298
|
+
if (!flags || !bufferName || !target)
|
|
1299
|
+
return null;
|
|
1300
|
+
return {
|
|
1301
|
+
verb: "tmux.pasteBuffer",
|
|
1302
|
+
args: { ...base, flags, bufferName, target }
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
if (command === "delete-buffer") {
|
|
1306
|
+
const bufferName = optionValue(args.slice(1), "-b");
|
|
1307
|
+
if (!bufferName)
|
|
1308
|
+
return null;
|
|
1309
|
+
return { verb: "tmux.deleteBuffer", args: { ...base, bufferName } };
|
|
1310
|
+
}
|
|
1311
|
+
if (command === "kill-session") {
|
|
1312
|
+
const target = optionValue(args.slice(1), "-t");
|
|
1313
|
+
if (!target)
|
|
1314
|
+
return null;
|
|
1315
|
+
return { verb: "tmux.killSession", args: { ...base, target } };
|
|
1316
|
+
}
|
|
1317
|
+
if (command === "detach-client") {
|
|
1318
|
+
const sessionName = optionValue(args.slice(1), "-s");
|
|
1319
|
+
if (!sessionName)
|
|
1320
|
+
return null;
|
|
1321
|
+
return { verb: "tmux.detachClient", args: { ...base, sessionName } };
|
|
1322
|
+
}
|
|
1323
|
+
if (command === "new-session") {
|
|
1324
|
+
const parsed = parseTmuxNewSession(args.slice(1));
|
|
1325
|
+
if (!parsed)
|
|
1326
|
+
return null;
|
|
1327
|
+
return { verb: "tmux.newSession", args: { ...base, ...parsed } };
|
|
1328
|
+
}
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
function stripTmuxSocketArgs(rawArgs) {
|
|
1332
|
+
if (rawArgs[0] === "-S" && typeof rawArgs[1] === "string" && rawArgs[1].trim()) {
|
|
1333
|
+
return { socketPath: rawArgs[1], args: [...rawArgs.slice(2)] };
|
|
1334
|
+
}
|
|
1335
|
+
return { socketPath: null, args: [...rawArgs] };
|
|
1336
|
+
}
|
|
1337
|
+
function parseTmuxSendKeys(args) {
|
|
1338
|
+
const target = optionValue(args, "-t");
|
|
1339
|
+
if (!target)
|
|
1340
|
+
return null;
|
|
1341
|
+
const keys = [];
|
|
1342
|
+
let literal = null;
|
|
1343
|
+
for (let index = 0;index < args.length; index += 1) {
|
|
1344
|
+
const arg = args[index];
|
|
1345
|
+
if (arg === "-t") {
|
|
1346
|
+
index += 1;
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
if (arg === "-l") {
|
|
1350
|
+
literal = args[index + 1] ?? "";
|
|
1351
|
+
index += 1;
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
keys.push(arg);
|
|
1355
|
+
}
|
|
1356
|
+
const optionLikeKey = keys.find((key) => key.startsWith("-"));
|
|
1357
|
+
if (optionLikeKey) {
|
|
1358
|
+
throw new ProbeCommandError(`tmux key must not start with '-': ${optionLikeKey}`, { code: "invalid_request" });
|
|
1359
|
+
}
|
|
1360
|
+
if (literal !== null)
|
|
1361
|
+
return literal ? { target, keys: [], literal } : null;
|
|
1362
|
+
return keys.length > 0 ? { target, keys, literal: null } : null;
|
|
1363
|
+
}
|
|
1364
|
+
function parseTmuxNewSession(args) {
|
|
1365
|
+
const out = { detached: false, printPane: false };
|
|
1366
|
+
let index = 0;
|
|
1367
|
+
while (index < args.length) {
|
|
1368
|
+
const arg = args[index];
|
|
1369
|
+
if (arg === "-d") {
|
|
1370
|
+
out.detached = true;
|
|
1371
|
+
index += 1;
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
if (arg === "-P") {
|
|
1375
|
+
out.printPane = true;
|
|
1376
|
+
index += 1;
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
if (arg === "-dP" || arg === "-Pd") {
|
|
1380
|
+
out.detached = true;
|
|
1381
|
+
out.printPane = true;
|
|
1382
|
+
index += 1;
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
if (arg === "-s" || arg === "-n" || arg === "-c" || arg === "-F" || arg === "-x" || arg === "-y") {
|
|
1386
|
+
const value = args[index + 1];
|
|
1387
|
+
if (!value)
|
|
1388
|
+
return null;
|
|
1389
|
+
if (arg === "-s")
|
|
1390
|
+
out.sessionName = value;
|
|
1391
|
+
if (arg === "-n")
|
|
1392
|
+
out.windowName = value;
|
|
1393
|
+
if (arg === "-c")
|
|
1394
|
+
out.cwd = value;
|
|
1395
|
+
if (arg === "-F")
|
|
1396
|
+
out.format = value;
|
|
1397
|
+
if (arg === "-x" || arg === "-y") {
|
|
1398
|
+
const parsed = positiveInteger(value);
|
|
1399
|
+
if (parsed === null)
|
|
1400
|
+
return null;
|
|
1401
|
+
if (arg === "-x")
|
|
1402
|
+
out.columns = parsed;
|
|
1403
|
+
if (arg === "-y")
|
|
1404
|
+
out.rows = parsed;
|
|
1405
|
+
}
|
|
1406
|
+
index += 2;
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
out.command = args.slice(index).join(" ");
|
|
1410
|
+
break;
|
|
1411
|
+
}
|
|
1412
|
+
if (typeof out.sessionName !== "string" || typeof out.command !== "string" || !out.command.trim()) {
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
return out;
|
|
1416
|
+
}
|
|
1417
|
+
function positiveInteger(value) {
|
|
1418
|
+
const parsed = Number.parseInt(value, 10);
|
|
1419
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
1420
|
+
}
|
|
1421
|
+
function optionValue(args, option) {
|
|
1422
|
+
const index = args.indexOf(option);
|
|
1423
|
+
const value = index >= 0 ? args[index + 1] : null;
|
|
1424
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1425
|
+
}
|
|
1426
|
+
function mapTailscaleVerb(args, options) {
|
|
1427
|
+
if (args[0] !== "cert")
|
|
1428
|
+
return null;
|
|
1429
|
+
const certFile = optionValue(args.slice(1), "--cert-file");
|
|
1430
|
+
const keyFile = optionValue(args.slice(1), "--key-file");
|
|
1431
|
+
const hostname = args[args.length - 1];
|
|
1432
|
+
if (!certFile || !keyFile || !hostname || hostname.startsWith("-"))
|
|
1433
|
+
return null;
|
|
1434
|
+
return {
|
|
1435
|
+
verb: "tailscale.cert",
|
|
1436
|
+
args: { ...baseVerbArgs(options), certFile, keyFile, hostname }
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
function mapDarwinRevealVerb(args, options) {
|
|
1440
|
+
if (args.length === 2 && args[0] === "-R" && args[1]) {
|
|
1441
|
+
return {
|
|
1442
|
+
verb: "reveal.open",
|
|
1443
|
+
args: { ...baseVerbArgs(options), mode: "darwinReveal", targetPath: args[1] }
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
if (args.length === 1 && args[0]) {
|
|
1447
|
+
return {
|
|
1448
|
+
verb: "reveal.open",
|
|
1449
|
+
args: { ...baseVerbArgs(options), mode: "darwinOpen", targetPath: args[0] }
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
return null;
|
|
1453
|
+
}
|
|
1454
|
+
function mapXdgRevealVerb(args, options) {
|
|
1455
|
+
if (args.length !== 1 || !args[0])
|
|
1456
|
+
return null;
|
|
1457
|
+
return {
|
|
1458
|
+
verb: "reveal.open",
|
|
1459
|
+
args: { ...baseVerbArgs(options), mode: "xdgOpen", targetPath: args[0] }
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
function mapWindowsRevealVerb(args, options) {
|
|
1463
|
+
if (args.length !== 1 || !args[0]?.startsWith("/select,"))
|
|
1464
|
+
return null;
|
|
1465
|
+
return {
|
|
1466
|
+
verb: "reveal.open",
|
|
1467
|
+
args: { ...baseVerbArgs(options), mode: "windowsSelect", targetPath: args[0].slice("/select,".length) }
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
// packages/runtime/dist/repo-service/process.js
|
|
1471
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1472
|
+
import { Socket as Socket2 } from "node:net";
|
|
1473
|
+
var REPO_SERVICE_MAX_BUFFER = 2 * 1024 * 1024;
|
|
1474
|
+
var CAPABILITIES_SCHEMA2 = "openscout.probe.capabilities/v1";
|
|
1475
|
+
var REPO_SCAN_SCHEMA = "openscout.repo.scan/v1";
|
|
1476
|
+
var REPO_DIFF_SCHEMA = "openscout.repo.diff/v1";
|
|
1477
|
+
var REPO_RESPONSE_SCHEMA = "openscout.repo.response/v1";
|
|
1478
|
+
var REPO_SCAN_CAPABILITY_ID = "repo.scan";
|
|
1479
|
+
var REPO_DIFF_CAPABILITY_ID = "repo.diff";
|
|
1480
|
+
var CAPABILITY_RECHECK_MS2 = 1e4;
|
|
1481
|
+
var REPO_SERVICE_TRANSPORT_METADATA = Symbol.for("openscout.repoService.transport");
|
|
1482
|
+
class RepoServiceSocketClient {
|
|
1483
|
+
env;
|
|
1484
|
+
capabilities = null;
|
|
1485
|
+
lastCapabilityCheckAt = null;
|
|
1486
|
+
lastError = null;
|
|
1487
|
+
daemonObserved = false;
|
|
1488
|
+
fallbackByOperation = new Map;
|
|
1489
|
+
constructor(env = process.env) {
|
|
1490
|
+
this.env = env;
|
|
1491
|
+
}
|
|
1492
|
+
async requestJob(subcommand, input, timeoutMs) {
|
|
1493
|
+
const operation = operationForSubcommand(subcommand);
|
|
1494
|
+
if (!operation) {
|
|
1495
|
+
return { state: "spawn" };
|
|
1496
|
+
}
|
|
1497
|
+
const socketPath = resolveScoutdProbesSocketPath(this.env);
|
|
1498
|
+
const socketExists = existsSync2(socketPath);
|
|
1499
|
+
if (!socketExists) {
|
|
1500
|
+
this.capabilities = null;
|
|
1501
|
+
this.lastError = null;
|
|
1502
|
+
return this.daemonObserved ? this.fallback(operation.capabilityId, `probe socket is missing: ${socketPath}`) : { state: "spawn" };
|
|
1503
|
+
}
|
|
1504
|
+
this.daemonObserved = true;
|
|
1505
|
+
const socketTimeoutMs = repoSocketTimeoutMs(timeoutMs);
|
|
1506
|
+
const capabilities = await this.ensureCapabilities(socketPath, socketTimeoutMs);
|
|
1507
|
+
if (!capabilities) {
|
|
1508
|
+
return this.fallback(operation.capabilityId, this.lastError ?? "probe capabilities unavailable");
|
|
1509
|
+
}
|
|
1510
|
+
const capability = capabilities.families.get(operation.capabilityId);
|
|
1511
|
+
if (!capability) {
|
|
1512
|
+
return this.fallback(operation.capabilityId, `scoutd does not serve ${operation.capabilityId}`);
|
|
1513
|
+
}
|
|
1514
|
+
const expectedSchemaVersion = expectedScoutHostProbeSchemaVersion(operation.capabilityId);
|
|
1515
|
+
if (expectedSchemaVersion === null) {
|
|
1516
|
+
return this.fallback(operation.capabilityId, `client has no compiled schema version for ${operation.capabilityId}`);
|
|
1517
|
+
}
|
|
1518
|
+
if (capability.schemaVersion !== expectedSchemaVersion) {
|
|
1519
|
+
return this.fallback(operation.capabilityId, `scoutd serves ${operation.capabilityId} schema v${capability.schemaVersion}, expected v${expectedSchemaVersion}`);
|
|
1520
|
+
}
|
|
1521
|
+
let response;
|
|
1522
|
+
try {
|
|
1523
|
+
response = await requestJson2(socketPath, repoRequestPayload(operation.schema, input, expectedSchemaVersion), socketTimeoutMs);
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
this.capabilities = null;
|
|
1526
|
+
this.lastCapabilityCheckAt = null;
|
|
1527
|
+
this.lastError = fallbackMessage2(error);
|
|
1528
|
+
return this.fallback(operation.capabilityId, this.lastError);
|
|
1529
|
+
}
|
|
1530
|
+
if (!isRecord2(response) || response.schema !== REPO_RESPONSE_SCHEMA) {
|
|
1531
|
+
this.capabilities = null;
|
|
1532
|
+
this.lastCapabilityCheckAt = null;
|
|
1533
|
+
this.lastError = "scoutd returned an invalid repo-service envelope";
|
|
1534
|
+
return this.fallback(operation.capabilityId, this.lastError);
|
|
1535
|
+
}
|
|
1536
|
+
if (response.error) {
|
|
1537
|
+
const error = isRecord2(response.error) ? response.error : {};
|
|
1538
|
+
const message = readString2(error.message) ?? readString2(error.code) ?? "scoutd repo service failed";
|
|
1539
|
+
throw new Error(message);
|
|
1540
|
+
}
|
|
1541
|
+
this.fallbackByOperation.delete(operation.capabilityId);
|
|
1542
|
+
this.lastError = null;
|
|
1543
|
+
return {
|
|
1544
|
+
state: "scoutd",
|
|
1545
|
+
value: response.value,
|
|
1546
|
+
daemonVersion: readString2(response.daemonVersion) ?? capabilities.daemonVersion
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
resetForTests() {
|
|
1550
|
+
this.capabilities = null;
|
|
1551
|
+
this.lastCapabilityCheckAt = null;
|
|
1552
|
+
this.lastError = null;
|
|
1553
|
+
this.daemonObserved = false;
|
|
1554
|
+
this.fallbackByOperation.clear();
|
|
1555
|
+
}
|
|
1556
|
+
async ensureCapabilities(socketPath, timeoutMs) {
|
|
1557
|
+
const now = Date.now();
|
|
1558
|
+
if (this.capabilities && this.lastCapabilityCheckAt !== null && now - this.lastCapabilityCheckAt < CAPABILITY_RECHECK_MS2) {
|
|
1559
|
+
return this.capabilities;
|
|
1560
|
+
}
|
|
1561
|
+
this.lastCapabilityCheckAt = now;
|
|
1562
|
+
try {
|
|
1563
|
+
const response = await requestJson2(socketPath, { schema: CAPABILITIES_SCHEMA2 }, timeoutMs);
|
|
1564
|
+
if (!isRecord2(response) || response.schema !== CAPABILITIES_SCHEMA2) {
|
|
1565
|
+
throw new Error("scoutd returned an invalid capabilities envelope");
|
|
1566
|
+
}
|
|
1567
|
+
const daemonVersion = readString2(response.daemonVersion) ?? "unknown";
|
|
1568
|
+
const families = new Map;
|
|
1569
|
+
if (Array.isArray(response.families)) {
|
|
1570
|
+
for (const entry of response.families) {
|
|
1571
|
+
if (!isRecord2(entry))
|
|
1572
|
+
continue;
|
|
1573
|
+
const probeId = readString2(entry.probeId);
|
|
1574
|
+
const schemaVersion = readNumber2(entry.schemaVersion);
|
|
1575
|
+
const ttlMs = readNumber2(entry.ttlMs);
|
|
1576
|
+
if (probeId && schemaVersion !== null && ttlMs !== null) {
|
|
1577
|
+
families.set(probeId, { probeId, schemaVersion, ttlMs });
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
this.capabilities = { daemonVersion, families };
|
|
1582
|
+
this.daemonObserved = true;
|
|
1583
|
+
this.lastError = null;
|
|
1584
|
+
return this.capabilities;
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
this.capabilities = null;
|
|
1587
|
+
this.lastError = fallbackMessage2(error);
|
|
1588
|
+
return null;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
fallback(operation, reason) {
|
|
1592
|
+
let state = this.fallbackByOperation.get(operation);
|
|
1593
|
+
if (!state || state.reason !== reason) {
|
|
1594
|
+
state = { since: Date.now(), reason };
|
|
1595
|
+
this.fallbackByOperation.set(operation, state);
|
|
1596
|
+
}
|
|
1597
|
+
return {
|
|
1598
|
+
state: "spawn",
|
|
1599
|
+
fallbackSince: state.since,
|
|
1600
|
+
fallbackReason: state.reason
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
function operationForSubcommand(subcommand) {
|
|
1605
|
+
switch (subcommand) {
|
|
1606
|
+
case "scan":
|
|
1607
|
+
return { capabilityId: REPO_SCAN_CAPABILITY_ID, schema: REPO_SCAN_SCHEMA };
|
|
1608
|
+
case "diff":
|
|
1609
|
+
return { capabilityId: REPO_DIFF_CAPABILITY_ID, schema: REPO_DIFF_SCHEMA };
|
|
1610
|
+
default:
|
|
1611
|
+
return null;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
function repoRequestPayload(schema, input, schemaVersion) {
|
|
1615
|
+
if (isRecord2(input)) {
|
|
1616
|
+
return { ...input, schema, schemaVersion };
|
|
1617
|
+
}
|
|
1618
|
+
return { schema, schemaVersion, value: input };
|
|
1619
|
+
}
|
|
1620
|
+
function readString2(value) {
|
|
1621
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
1622
|
+
}
|
|
1623
|
+
function readNumber2(value) {
|
|
1624
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1625
|
+
}
|
|
1626
|
+
function isRecord2(value) {
|
|
1627
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1628
|
+
}
|
|
1629
|
+
function fallbackMessage2(error) {
|
|
1630
|
+
if (error instanceof Error && error.message.trim()) {
|
|
1631
|
+
return error.message;
|
|
1632
|
+
}
|
|
1633
|
+
return String(error);
|
|
1634
|
+
}
|
|
1635
|
+
function repoSocketTimeoutMs(timeoutMs) {
|
|
1636
|
+
return Math.max(900, Math.min(Math.max(0, timeoutMs) + 1000, 31000));
|
|
1637
|
+
}
|
|
1638
|
+
async function requestJson2(socketPath, payload, timeoutMs) {
|
|
1639
|
+
const bun = globalThis.Bun;
|
|
1640
|
+
if (bun?.connect) {
|
|
1641
|
+
return await requestJsonWithBun2(bun.connect, socketPath, payload, timeoutMs);
|
|
1642
|
+
}
|
|
1643
|
+
return await new Promise((resolvePromise, reject) => {
|
|
1644
|
+
const socket = new Socket2;
|
|
1645
|
+
let response = "";
|
|
1646
|
+
let settled = false;
|
|
1647
|
+
const finish = (error, value) => {
|
|
1648
|
+
if (settled)
|
|
1649
|
+
return;
|
|
1650
|
+
settled = true;
|
|
1651
|
+
clearTimeout(timer);
|
|
1652
|
+
socket.removeAllListeners();
|
|
1653
|
+
socket.destroy();
|
|
1654
|
+
if (error) {
|
|
1655
|
+
reject(error);
|
|
1656
|
+
} else {
|
|
1657
|
+
resolvePromise(value);
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
const timer = setTimeout(() => {
|
|
1661
|
+
finish(new Error(`scoutd repo-service socket timed out after ${timeoutMs}ms`));
|
|
1662
|
+
}, timeoutMs);
|
|
1663
|
+
timer.unref?.();
|
|
1664
|
+
const finishFromResponse = () => {
|
|
1665
|
+
if (settled)
|
|
1666
|
+
return;
|
|
1667
|
+
try {
|
|
1668
|
+
finish(null, JSON.parse(response));
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
finish(new Error(`scoutd repo-service response was not JSON: ${fallbackMessage2(error)}`));
|
|
1671
|
+
}
|
|
1672
|
+
};
|
|
1673
|
+
socket.setEncoding("utf8");
|
|
1674
|
+
socket.on("connect", () => {
|
|
1675
|
+
socket.write(`${JSON.stringify(payload)}
|
|
1676
|
+
`);
|
|
1677
|
+
});
|
|
1678
|
+
socket.on("data", (chunk) => {
|
|
1679
|
+
response += chunk;
|
|
1680
|
+
if (Buffer.byteLength(response, "utf8") > REPO_SERVICE_MAX_BUFFER) {
|
|
1681
|
+
finish(new Error("scoutd repo-service response exceeded output limit"));
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
socket.on("error", (error) => finish(error));
|
|
1685
|
+
socket.on("end", finishFromResponse);
|
|
1686
|
+
socket.on("close", () => {
|
|
1687
|
+
if (!settled) {
|
|
1688
|
+
if (response.length === 0) {
|
|
1689
|
+
finish(new Error("scoutd repo-service socket closed without a response"));
|
|
1690
|
+
} else {
|
|
1691
|
+
finishFromResponse();
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
socket.connect(socketPath);
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
async function requestJsonWithBun2(connect, socketPath, payload, timeoutMs) {
|
|
1699
|
+
return await new Promise((resolvePromise, reject) => {
|
|
1700
|
+
let response = "";
|
|
1701
|
+
let settled = false;
|
|
1702
|
+
let socketRef = null;
|
|
1703
|
+
const decoder = new TextDecoder;
|
|
1704
|
+
const finish = (error, value) => {
|
|
1705
|
+
if (settled)
|
|
1706
|
+
return;
|
|
1707
|
+
settled = true;
|
|
1708
|
+
clearTimeout(timer);
|
|
1709
|
+
try {
|
|
1710
|
+
socketRef?.end?.();
|
|
1711
|
+
} catch {}
|
|
1712
|
+
if (error) {
|
|
1713
|
+
reject(error);
|
|
1714
|
+
} else {
|
|
1715
|
+
resolvePromise(value);
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
const finishFromResponse = () => {
|
|
1719
|
+
if (settled)
|
|
1720
|
+
return;
|
|
1721
|
+
try {
|
|
1722
|
+
finish(null, JSON.parse(response));
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
finish(new Error(`scoutd repo-service response was not JSON: ${fallbackMessage2(error)}`));
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
const timer = setTimeout(() => {
|
|
1728
|
+
finish(new Error(`scoutd repo-service socket timed out after ${timeoutMs}ms`));
|
|
1729
|
+
}, timeoutMs);
|
|
1730
|
+
timer.unref?.();
|
|
1731
|
+
connect({
|
|
1732
|
+
unix: socketPath,
|
|
1733
|
+
socket: {
|
|
1734
|
+
open(socket) {
|
|
1735
|
+
socketRef = socket;
|
|
1736
|
+
socket.write(`${JSON.stringify(payload)}
|
|
1737
|
+
`);
|
|
1738
|
+
},
|
|
1739
|
+
data(_socket, data) {
|
|
1740
|
+
response += decoder.decode(data, { stream: true });
|
|
1741
|
+
if (Buffer.byteLength(response, "utf8") > REPO_SERVICE_MAX_BUFFER) {
|
|
1742
|
+
finish(new Error("scoutd repo-service response exceeded output limit"));
|
|
1743
|
+
}
|
|
1744
|
+
},
|
|
1745
|
+
close() {
|
|
1746
|
+
if (response.length === 0) {
|
|
1747
|
+
finish(new Error("scoutd repo-service socket closed without a response"));
|
|
1748
|
+
} else {
|
|
1749
|
+
response += decoder.decode();
|
|
1750
|
+
finishFromResponse();
|
|
1751
|
+
}
|
|
1752
|
+
},
|
|
1753
|
+
error(_socket, error) {
|
|
1754
|
+
finish(error);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
}).then((socket) => {
|
|
1758
|
+
socketRef = socket;
|
|
1759
|
+
}, (error) => {
|
|
1760
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
1761
|
+
});
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// packages/runtime/dist/system-probes/git-build-info.js
|
|
1766
|
+
import { existsSync as existsSync3, realpathSync, statSync } from "node:fs";
|
|
1767
|
+
import { dirname, resolve } from "node:path";
|
|
1768
|
+
var staticMetadataByRepo = new Map;
|
|
1769
|
+
function realpathOrResolved(path) {
|
|
1770
|
+
try {
|
|
1771
|
+
return realpathSync(path);
|
|
1772
|
+
} catch {
|
|
1773
|
+
return resolve(path);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
function directoryOrParent(path) {
|
|
1777
|
+
const resolved = resolve(path);
|
|
1778
|
+
try {
|
|
1779
|
+
const stat = statSync(resolved);
|
|
1780
|
+
return stat.isDirectory() ? resolved : dirname(resolved);
|
|
1781
|
+
} catch {
|
|
1782
|
+
return resolved;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
function canonicalRepoRoot(rawPath) {
|
|
1786
|
+
let current = realpathOrResolved(directoryOrParent(rawPath));
|
|
1787
|
+
while (true) {
|
|
1788
|
+
if (existsSync3(resolve(current, ".git"))) {
|
|
1789
|
+
return realpathOrResolved(current);
|
|
1790
|
+
}
|
|
1791
|
+
const parent = dirname(current);
|
|
1792
|
+
if (parent === current) {
|
|
1793
|
+
return realpathOrResolved(directoryOrParent(rawPath));
|
|
1794
|
+
}
|
|
1795
|
+
current = parent;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
async function gitOutput(repoRoot, args, ctx) {
|
|
1799
|
+
try {
|
|
1800
|
+
const gitBin = process.env.OPENSCOUT_GIT_BIN?.trim() || "git";
|
|
1801
|
+
const { stdout } = await execProbeFile(ctx, gitBin, ["-C", repoRoot, ...args], {
|
|
1802
|
+
maxStdoutBytes: 256 * 1024,
|
|
1803
|
+
maxStderrBytes: 64 * 1024
|
|
1804
|
+
});
|
|
1805
|
+
return stdout;
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
if (error instanceof ProbeCommandError && (error.code === "ENOENT" || error.code === "spawn" || error.code === "exit")) {
|
|
1808
|
+
return null;
|
|
1809
|
+
}
|
|
1810
|
+
throw error;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
async function gitValue(repoRoot, args, ctx) {
|
|
1814
|
+
const output = await gitOutput(repoRoot, args, ctx);
|
|
1815
|
+
const value = output?.trim() ?? "";
|
|
1816
|
+
return value.length > 0 ? value : null;
|
|
1817
|
+
}
|
|
1818
|
+
async function loadStaticMetadata(repoRoot, ctx) {
|
|
1819
|
+
const cached = staticMetadataByRepo.get(repoRoot);
|
|
1820
|
+
if (cached) {
|
|
1821
|
+
return cached;
|
|
1822
|
+
}
|
|
1823
|
+
const metadata = {
|
|
1824
|
+
commit: await gitValue(repoRoot, ["rev-parse", "--short", "HEAD"], ctx),
|
|
1825
|
+
bootBranch: await gitValue(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"], ctx),
|
|
1826
|
+
metadataAt: Date.now()
|
|
1827
|
+
};
|
|
1828
|
+
staticMetadataByRepo.set(repoRoot, metadata);
|
|
1829
|
+
return metadata;
|
|
1830
|
+
}
|
|
1831
|
+
var gitBuildInfoProbe = defineProbeFamily({
|
|
1832
|
+
id: "git.buildInfo",
|
|
1833
|
+
ttlMs: 60000,
|
|
1834
|
+
timeoutMs: 1500,
|
|
1835
|
+
maxKeys: 64,
|
|
1836
|
+
idleKeyTtlMs: 10 * 60000,
|
|
1837
|
+
maxConcurrentKeys: 2,
|
|
1838
|
+
normalizeKey: canonicalRepoRoot,
|
|
1839
|
+
run: async (repoRoot, ctx) => runWithScoutdFallback({
|
|
1840
|
+
probeId: "git.buildInfo",
|
|
1841
|
+
key: repoRoot,
|
|
1842
|
+
ctx,
|
|
1843
|
+
local: async () => {
|
|
1844
|
+
const metadata = await loadStaticMetadata(repoRoot, ctx);
|
|
1845
|
+
const [branch, dirtyStatus] = await Promise.all([
|
|
1846
|
+
gitValue(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"], ctx),
|
|
1847
|
+
gitOutput(repoRoot, ["status", "--porcelain"], ctx)
|
|
1848
|
+
]);
|
|
1849
|
+
return {
|
|
1850
|
+
repoRoot,
|
|
1851
|
+
commit: metadata.commit,
|
|
1852
|
+
bootBranch: metadata.bootBranch,
|
|
1853
|
+
branch: branch ?? metadata.bootBranch,
|
|
1854
|
+
dirty: dirtyStatus === null ? null : dirtyStatus.trim().length > 0,
|
|
1855
|
+
metadataAt: metadata.metadataAt,
|
|
1856
|
+
statusAt: Date.now()
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
})
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
// packages/runtime/dist/system-probes/git.js
|
|
1863
|
+
var GIT_PROBE_TTL_MS = 60000;
|
|
1864
|
+
var DEFAULT_GIT_TIMEOUT_MS = 5000;
|
|
1865
|
+
var DEFAULT_GIT_STDOUT_BYTES = 1024 * 1024;
|
|
1866
|
+
var DEFAULT_GIT_STDERR_BYTES = 256 * 1024;
|
|
1867
|
+
|
|
1868
|
+
class GitCatalogValidationError extends Error {
|
|
1869
|
+
constructor(message) {
|
|
1870
|
+
super(message);
|
|
1871
|
+
this.name = "GitCatalogValidationError";
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
function gitBin() {
|
|
1875
|
+
return process.env.OPENSCOUT_GIT_BIN?.trim() || "git";
|
|
1876
|
+
}
|
|
1877
|
+
function isUnavailable(error) {
|
|
1878
|
+
return error instanceof ProbeCommandError && (error.code === "ENOENT" || error.code === "spawn" || error.code === "exit");
|
|
1879
|
+
}
|
|
1880
|
+
function validateNoNul(value, label) {
|
|
1881
|
+
if (value.includes("\x00")) {
|
|
1882
|
+
throw new GitCatalogValidationError(`${label} contains a NUL byte`);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
function validateGitRefValue(value, label = "git ref") {
|
|
1886
|
+
const trimmed = value?.trim() ?? "";
|
|
1887
|
+
if (!trimmed) {
|
|
1888
|
+
throw new GitCatalogValidationError(`${label} is required`);
|
|
1889
|
+
}
|
|
1890
|
+
validateNoNul(trimmed, label);
|
|
1891
|
+
if (trimmed.startsWith("-")) {
|
|
1892
|
+
throw new GitCatalogValidationError(`${label} must not start with '-'`);
|
|
1893
|
+
}
|
|
1894
|
+
return trimmed;
|
|
1895
|
+
}
|
|
1896
|
+
function validateGitPathspecValue(value, label = "git pathspec") {
|
|
1897
|
+
const trimmed = value?.trim() ?? "";
|
|
1898
|
+
if (!trimmed) {
|
|
1899
|
+
throw new GitCatalogValidationError(`${label} is required`);
|
|
1900
|
+
}
|
|
1901
|
+
validateNoNul(trimmed, label);
|
|
1902
|
+
if (trimmed.startsWith("-")) {
|
|
1903
|
+
throw new GitCatalogValidationError(`${label} must not start with '-'`);
|
|
1904
|
+
}
|
|
1905
|
+
return trimmed;
|
|
1906
|
+
}
|
|
1907
|
+
function validateGitPathspecs(paths) {
|
|
1908
|
+
return (paths ?? []).map((path, index) => validateGitPathspecValue(path, `git pathspec ${index + 1}`));
|
|
1909
|
+
}
|
|
1910
|
+
function normalizeRepoRoot(repoRoot) {
|
|
1911
|
+
return canonicalRepoRoot(repoRoot);
|
|
1912
|
+
}
|
|
1913
|
+
function normalizedRevParseInput(input) {
|
|
1914
|
+
const repoRoot = normalizeRepoRoot(input.repoRoot);
|
|
1915
|
+
if (input.kind === "verifyCommit") {
|
|
1916
|
+
return {
|
|
1917
|
+
repoRoot,
|
|
1918
|
+
kind: input.kind,
|
|
1919
|
+
ref: validateGitRefValue(input.ref, "git verify ref"),
|
|
1920
|
+
quiet: input.quiet === true
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
return { repoRoot, kind: input.kind };
|
|
1924
|
+
}
|
|
1925
|
+
function normalizeRevParseKey(input) {
|
|
1926
|
+
return JSON.stringify(normalizedRevParseInput(input));
|
|
1927
|
+
}
|
|
1928
|
+
function parseRevParseKey(key) {
|
|
1929
|
+
return JSON.parse(key);
|
|
1930
|
+
}
|
|
1931
|
+
function normalizedDiffSelector(selector) {
|
|
1932
|
+
switch (selector.kind) {
|
|
1933
|
+
case "unstaged":
|
|
1934
|
+
case "staged":
|
|
1935
|
+
return selector;
|
|
1936
|
+
case "fromRef":
|
|
1937
|
+
return { kind: selector.kind, ref: validateGitRefValue(selector.ref, "git diff ref") };
|
|
1938
|
+
case "twoRefs":
|
|
1939
|
+
return {
|
|
1940
|
+
kind: selector.kind,
|
|
1941
|
+
baseRef: validateGitRefValue(selector.baseRef, "git diff base ref"),
|
|
1942
|
+
compareRef: validateGitRefValue(selector.compareRef, "git diff compare ref")
|
|
1943
|
+
};
|
|
1944
|
+
case "range":
|
|
1945
|
+
return {
|
|
1946
|
+
kind: selector.kind,
|
|
1947
|
+
notation: selector.notation,
|
|
1948
|
+
baseRef: validateGitRefValue(selector.baseRef, "git diff base ref"),
|
|
1949
|
+
compareRef: validateGitRefValue(selector.compareRef, "git diff compare ref")
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
function normalizedDiffInput(input) {
|
|
1954
|
+
return {
|
|
1955
|
+
repoRoot: normalizeRepoRoot(input.repoRoot),
|
|
1956
|
+
selector: normalizedDiffSelector(input.selector),
|
|
1957
|
+
paths: validateGitPathspecs(input.paths)
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
function normalizeDiffShortstatKey(input) {
|
|
1961
|
+
return JSON.stringify(normalizedDiffInput(input));
|
|
1962
|
+
}
|
|
1963
|
+
function parseDiffKey(key) {
|
|
1964
|
+
return JSON.parse(key);
|
|
1965
|
+
}
|
|
1966
|
+
function normalizedMergeBaseInput(input) {
|
|
1967
|
+
return {
|
|
1968
|
+
repoRoot: normalizeRepoRoot(input.repoRoot),
|
|
1969
|
+
baseRef: validateGitRefValue(input.baseRef, "git merge-base base ref"),
|
|
1970
|
+
compareRef: validateGitRefValue(input.compareRef, "git merge-base compare ref")
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
function normalizeMergeBaseKey(input) {
|
|
1974
|
+
return JSON.stringify(normalizedMergeBaseInput(input));
|
|
1975
|
+
}
|
|
1976
|
+
function parseMergeBaseKey(key) {
|
|
1977
|
+
return JSON.parse(key);
|
|
1978
|
+
}
|
|
1979
|
+
function validateGitStatusVersion(value) {
|
|
1980
|
+
if (value === "v1" || value === "v2")
|
|
1981
|
+
return value;
|
|
1982
|
+
throw new GitCatalogValidationError("git status porcelain version must be v1 or v2");
|
|
1983
|
+
}
|
|
1984
|
+
function normalizedStatusPorcelainInput(input) {
|
|
1985
|
+
const untrackedMode = input.untrackedMode;
|
|
1986
|
+
if (untrackedMode !== undefined && untrackedMode !== "normal") {
|
|
1987
|
+
throw new GitCatalogValidationError("git status untrackedMode must be normal when provided");
|
|
1988
|
+
}
|
|
1989
|
+
return {
|
|
1990
|
+
repoRoot: normalizeRepoRoot(input.repoRoot),
|
|
1991
|
+
version: validateGitStatusVersion(input.version),
|
|
1992
|
+
branch: input.branch === true ? true : undefined,
|
|
1993
|
+
z: input.z === true ? true : undefined,
|
|
1994
|
+
untrackedMode,
|
|
1995
|
+
paths: validateGitPathspecs(input.paths)
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
function normalizeStatusPorcelainKey(input) {
|
|
1999
|
+
return JSON.stringify(normalizedStatusPorcelainInput(input));
|
|
2000
|
+
}
|
|
2001
|
+
function parseStatusPorcelainKey(key) {
|
|
2002
|
+
return JSON.parse(key);
|
|
2003
|
+
}
|
|
2004
|
+
function revParseArgs(input) {
|
|
2005
|
+
switch (input.kind) {
|
|
2006
|
+
case "showToplevel":
|
|
2007
|
+
return ["rev-parse", "--show-toplevel"];
|
|
2008
|
+
case "gitDir":
|
|
2009
|
+
return ["rev-parse", "--git-dir"];
|
|
2010
|
+
case "gitCommonDir":
|
|
2011
|
+
return ["rev-parse", "--git-common-dir"];
|
|
2012
|
+
case "isInsideWorkTree":
|
|
2013
|
+
return ["rev-parse", "--is-inside-work-tree"];
|
|
2014
|
+
case "shortHead":
|
|
2015
|
+
return ["rev-parse", "--short", "HEAD"];
|
|
2016
|
+
case "abbrevRefHead":
|
|
2017
|
+
return ["rev-parse", "--abbrev-ref", "HEAD"];
|
|
2018
|
+
case "upstreamSymbolicFullName":
|
|
2019
|
+
return ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"];
|
|
2020
|
+
case "verifyCommit":
|
|
2021
|
+
return [
|
|
2022
|
+
"rev-parse",
|
|
2023
|
+
"--verify",
|
|
2024
|
+
...input.quiet === true ? ["--quiet"] : [],
|
|
2025
|
+
"--end-of-options",
|
|
2026
|
+
`${validateGitRefValue(input.ref, "git verify ref")}^{commit}`
|
|
2027
|
+
];
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
function diffSelectorArgs(selector) {
|
|
2031
|
+
const normalized = normalizedDiffSelector(selector);
|
|
2032
|
+
switch (normalized.kind) {
|
|
2033
|
+
case "unstaged":
|
|
2034
|
+
return [];
|
|
2035
|
+
case "staged":
|
|
2036
|
+
return ["--cached"];
|
|
2037
|
+
case "fromRef":
|
|
2038
|
+
return ["--end-of-options", normalized.ref];
|
|
2039
|
+
case "twoRefs":
|
|
2040
|
+
return ["--end-of-options", normalized.baseRef, normalized.compareRef];
|
|
2041
|
+
case "range": {
|
|
2042
|
+
const operator = normalized.notation === "ellipsis" ? "..." : "..";
|
|
2043
|
+
return ["--end-of-options", `${normalized.baseRef}${operator}${normalized.compareRef}`];
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
function pathspecArgs(paths) {
|
|
2048
|
+
const normalized = validateGitPathspecs(paths);
|
|
2049
|
+
return normalized.length > 0 ? ["--", ...normalized] : [];
|
|
2050
|
+
}
|
|
2051
|
+
function gitDiffCommandArgs(input) {
|
|
2052
|
+
const normalized = normalizedDiffInput(input);
|
|
2053
|
+
const outputArgs = (() => {
|
|
2054
|
+
switch (input.output) {
|
|
2055
|
+
case "rawZ":
|
|
2056
|
+
return ["--raw", "-z"];
|
|
2057
|
+
case "numstat":
|
|
2058
|
+
return ["--numstat"];
|
|
2059
|
+
case "numstatZ":
|
|
2060
|
+
return ["--numstat", "-z"];
|
|
2061
|
+
case "shortstat":
|
|
2062
|
+
return ["--shortstat"];
|
|
2063
|
+
case "patch":
|
|
2064
|
+
return ["--no-color", "--no-ext-diff", "--default-prefix", "--full-index", "-U3"];
|
|
2065
|
+
}
|
|
2066
|
+
})();
|
|
2067
|
+
return ["diff", ...outputArgs, ...diffSelectorArgs(normalized.selector), ...pathspecArgs(normalized.paths)];
|
|
2068
|
+
}
|
|
2069
|
+
function mergeBaseArgs(input) {
|
|
2070
|
+
const normalized = normalizedMergeBaseInput(input);
|
|
2071
|
+
return [
|
|
2072
|
+
"merge-base",
|
|
2073
|
+
"--end-of-options",
|
|
2074
|
+
normalized.baseRef,
|
|
2075
|
+
normalized.compareRef
|
|
2076
|
+
];
|
|
2077
|
+
}
|
|
2078
|
+
function statusPorcelainArgs(input) {
|
|
2079
|
+
const normalized = normalizedStatusPorcelainInput(input);
|
|
2080
|
+
const paths = normalized.paths ?? [];
|
|
2081
|
+
const args = ["status", `--porcelain=${normalized.version}`];
|
|
2082
|
+
if (normalized.branch)
|
|
2083
|
+
args.push("--branch");
|
|
2084
|
+
if (normalized.z)
|
|
2085
|
+
args.push("-z");
|
|
2086
|
+
if (normalized.untrackedMode === "normal")
|
|
2087
|
+
args.push("-unormal");
|
|
2088
|
+
if (paths.length > 0)
|
|
2089
|
+
args.push("--", ...paths);
|
|
2090
|
+
return args;
|
|
2091
|
+
}
|
|
2092
|
+
async function runGitProbeCommandLocal(ctx, repoRoot, args, maxStdoutBytes) {
|
|
2093
|
+
try {
|
|
2094
|
+
const result = await execProbeFile(ctx, gitBin(), ["-C", normalizeRepoRoot(repoRoot), ...args], {
|
|
2095
|
+
maxStdoutBytes,
|
|
2096
|
+
maxStderrBytes: DEFAULT_GIT_STDERR_BYTES
|
|
2097
|
+
});
|
|
2098
|
+
return result.stdout;
|
|
2099
|
+
} catch (error) {
|
|
2100
|
+
if (isUnavailable(error))
|
|
2101
|
+
return null;
|
|
2102
|
+
throw error;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
function trimOutput(value) {
|
|
2106
|
+
const trimmed = value?.trim() ?? "";
|
|
2107
|
+
return trimmed ? trimmed : null;
|
|
2108
|
+
}
|
|
2109
|
+
var gitRevParseProbe = defineProbeFamily({
|
|
2110
|
+
id: "git.revParse",
|
|
2111
|
+
ttlMs: GIT_PROBE_TTL_MS,
|
|
2112
|
+
timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
|
|
2113
|
+
maxKeys: 256,
|
|
2114
|
+
idleKeyTtlMs: 10 * 60000,
|
|
2115
|
+
maxConcurrentKeys: 4,
|
|
2116
|
+
normalizeKey: normalizeRevParseKey,
|
|
2117
|
+
run: (key, ctx) => runWithScoutdFallback({
|
|
2118
|
+
probeId: "git.revParse",
|
|
2119
|
+
key,
|
|
2120
|
+
ctx,
|
|
2121
|
+
local: async () => {
|
|
2122
|
+
const input = parseRevParseKey(key);
|
|
2123
|
+
return trimOutput(await runGitProbeCommandLocal(ctx, input.repoRoot, revParseArgs(input), 256 * 1024));
|
|
2124
|
+
}
|
|
2125
|
+
})
|
|
2126
|
+
});
|
|
2127
|
+
var gitDiffShortstatProbe = defineProbeFamily({
|
|
2128
|
+
id: "git.diffShortstat",
|
|
2129
|
+
ttlMs: GIT_PROBE_TTL_MS,
|
|
2130
|
+
timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
|
|
2131
|
+
maxKeys: 256,
|
|
2132
|
+
idleKeyTtlMs: 10 * 60000,
|
|
2133
|
+
maxConcurrentKeys: 4,
|
|
2134
|
+
normalizeKey: normalizeDiffShortstatKey,
|
|
2135
|
+
run: (key, ctx) => runWithScoutdFallback({
|
|
2136
|
+
probeId: "git.diffShortstat",
|
|
2137
|
+
key,
|
|
2138
|
+
ctx,
|
|
2139
|
+
local: async () => {
|
|
2140
|
+
const input = parseDiffKey(key);
|
|
2141
|
+
return trimOutput(await runGitProbeCommandLocal(ctx, input.repoRoot, gitDiffCommandArgs({ ...input, output: "shortstat" }), 256 * 1024));
|
|
2142
|
+
}
|
|
2143
|
+
})
|
|
2144
|
+
});
|
|
2145
|
+
var gitMergeBaseProbe = defineProbeFamily({
|
|
2146
|
+
id: "git.mergeBase",
|
|
2147
|
+
ttlMs: GIT_PROBE_TTL_MS,
|
|
2148
|
+
timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
|
|
2149
|
+
maxKeys: 256,
|
|
2150
|
+
idleKeyTtlMs: 10 * 60000,
|
|
2151
|
+
maxConcurrentKeys: 4,
|
|
2152
|
+
normalizeKey: normalizeMergeBaseKey,
|
|
2153
|
+
run: (key, ctx) => runWithScoutdFallback({
|
|
2154
|
+
probeId: "git.mergeBase",
|
|
2155
|
+
key,
|
|
2156
|
+
ctx,
|
|
2157
|
+
local: async () => {
|
|
2158
|
+
const input = parseMergeBaseKey(key);
|
|
2159
|
+
return trimOutput(await runGitProbeCommandLocal(ctx, input.repoRoot, mergeBaseArgs(input), 256 * 1024));
|
|
2160
|
+
}
|
|
2161
|
+
})
|
|
2162
|
+
});
|
|
2163
|
+
var gitStatusPorcelainProbe = defineProbeFamily({
|
|
2164
|
+
id: "git.statusPorcelain",
|
|
2165
|
+
ttlMs: GIT_PROBE_TTL_MS,
|
|
2166
|
+
timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
|
|
2167
|
+
maxKeys: 256,
|
|
2168
|
+
idleKeyTtlMs: 10 * 60000,
|
|
2169
|
+
maxConcurrentKeys: 4,
|
|
2170
|
+
normalizeKey: normalizeStatusPorcelainKey,
|
|
2171
|
+
run: (key, ctx) => runWithScoutdFallback({
|
|
2172
|
+
probeId: "git.statusPorcelain",
|
|
2173
|
+
key,
|
|
2174
|
+
ctx,
|
|
2175
|
+
local: async () => {
|
|
2176
|
+
const input = parseStatusPorcelainKey(key);
|
|
2177
|
+
return await runGitProbeCommandLocal(ctx, input.repoRoot, statusPorcelainArgs(input), DEFAULT_GIT_STDOUT_BYTES);
|
|
2178
|
+
}
|
|
2179
|
+
})
|
|
2180
|
+
});
|
|
2181
|
+
var gitLogLastCommitUnixProbe = defineProbeFamily({
|
|
2182
|
+
id: "git.logLastCommitUnix",
|
|
2183
|
+
ttlMs: GIT_PROBE_TTL_MS,
|
|
2184
|
+
timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
|
|
2185
|
+
maxKeys: 256,
|
|
2186
|
+
idleKeyTtlMs: 10 * 60000,
|
|
2187
|
+
maxConcurrentKeys: 4,
|
|
2188
|
+
normalizeKey: (repoRoot) => normalizeRepoRoot(repoRoot),
|
|
2189
|
+
run: (repoRoot, ctx) => runWithScoutdFallback({
|
|
2190
|
+
probeId: "git.logLastCommitUnix",
|
|
2191
|
+
key: repoRoot,
|
|
2192
|
+
ctx,
|
|
2193
|
+
local: async () => trimOutput(await runGitProbeCommandLocal(ctx, repoRoot, ["log", "-1", "--format=%ct"], 64 * 1024))
|
|
2194
|
+
})
|
|
2195
|
+
});
|
|
2196
|
+
var gitWorktreeListPorcelainProbe = defineProbeFamily({
|
|
2197
|
+
id: "git.worktreeListPorcelain",
|
|
2198
|
+
ttlMs: GIT_PROBE_TTL_MS,
|
|
2199
|
+
timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
|
|
2200
|
+
maxKeys: 256,
|
|
2201
|
+
idleKeyTtlMs: 10 * 60000,
|
|
2202
|
+
maxConcurrentKeys: 4,
|
|
2203
|
+
normalizeKey: (repoRoot) => normalizeRepoRoot(repoRoot),
|
|
2204
|
+
run: (repoRoot, ctx) => runWithScoutdFallback({
|
|
2205
|
+
probeId: "git.worktreeListPorcelain",
|
|
2206
|
+
key: repoRoot,
|
|
2207
|
+
ctx,
|
|
2208
|
+
local: async () => await runGitProbeCommandLocal(ctx, repoRoot, ["worktree", "list", "--porcelain"], DEFAULT_GIT_STDOUT_BYTES)
|
|
2209
|
+
})
|
|
2210
|
+
});
|
|
2211
|
+
|
|
2212
|
+
// packages/runtime/dist/system-probes/net-listeners.js
|
|
2213
|
+
function isUnavailable2(error) {
|
|
2214
|
+
return error instanceof ProbeCommandError && (error.code === "ENOENT" || error.code === "spawn" || error.code === "exit");
|
|
2215
|
+
}
|
|
2216
|
+
function lsofBin() {
|
|
2217
|
+
return process.env.OPENSCOUT_LSOF_BIN?.trim() || "lsof";
|
|
2218
|
+
}
|
|
2219
|
+
var netListenersProbe = defineProbeFamily({
|
|
2220
|
+
id: "net.listeners",
|
|
2221
|
+
ttlMs: 5000,
|
|
2222
|
+
timeoutMs: 1500,
|
|
2223
|
+
maxKeys: 128,
|
|
2224
|
+
idleKeyTtlMs: 2 * 60000,
|
|
2225
|
+
maxConcurrentKeys: 4,
|
|
2226
|
+
normalizeKey: (port) => String(port).trim(),
|
|
2227
|
+
run: (key, ctx) => runWithScoutdFallback({
|
|
2228
|
+
probeId: "net.listeners",
|
|
2229
|
+
key,
|
|
2230
|
+
ctx,
|
|
2231
|
+
local: () => readNetListenerLocal(key, ctx)
|
|
2232
|
+
})
|
|
2233
|
+
});
|
|
2234
|
+
async function readNetListenerLocal(key, ctx) {
|
|
2235
|
+
const port = Number.parseInt(key, 10);
|
|
2236
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
2237
|
+
return { port: 0, pid: null };
|
|
2238
|
+
}
|
|
2239
|
+
try {
|
|
2240
|
+
const { stdout } = await execProbeFile(ctx, lsofBin(), [
|
|
2241
|
+
"-nP",
|
|
2242
|
+
`-iTCP:${port}`,
|
|
2243
|
+
"-sTCP:LISTEN",
|
|
2244
|
+
"-Fp"
|
|
2245
|
+
], {
|
|
2246
|
+
maxStdoutBytes: 64 * 1024,
|
|
2247
|
+
maxStderrBytes: 64 * 1024
|
|
2248
|
+
});
|
|
2249
|
+
const match = stdout.match(/^p(\d+)/m);
|
|
2250
|
+
const pid = match ? Number.parseInt(match[1], 10) : null;
|
|
2251
|
+
return { port, pid: Number.isFinite(pid) ? pid : null };
|
|
2252
|
+
} catch (error) {
|
|
2253
|
+
if (isUnavailable2(error))
|
|
2254
|
+
return { port, pid: null };
|
|
2255
|
+
throw error;
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// packages/runtime/dist/system-probes/ps.js
|
|
2260
|
+
var PS_TTL_MS = 5000;
|
|
2261
|
+
var PS_TIMEOUT_MS = 1500;
|
|
2262
|
+
var DEFAULT_PS_DISCOVERY_MAX_ROWS = 4096;
|
|
2263
|
+
var PS_DISCOVERY_MAX_COMMAND_CHARS = 1024;
|
|
2264
|
+
function parseProcessNumber(value) {
|
|
2265
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
2266
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
2267
|
+
}
|
|
2268
|
+
function normalizeTty(value) {
|
|
2269
|
+
const normalized = value?.replace(/^\/dev\//u, "").trim();
|
|
2270
|
+
if (!normalized || normalized === "??" || normalized === "?")
|
|
2271
|
+
return null;
|
|
2272
|
+
return normalized;
|
|
2273
|
+
}
|
|
2274
|
+
function isUnavailable3(error) {
|
|
2275
|
+
return error instanceof ProbeCommandError && (error.code === "ENOENT" || error.code === "spawn" || error.code === "exit");
|
|
2276
|
+
}
|
|
2277
|
+
function psBin() {
|
|
2278
|
+
return process.env.OPENSCOUT_PS_BIN?.trim() || "ps";
|
|
2279
|
+
}
|
|
2280
|
+
function lsofBin2() {
|
|
2281
|
+
return process.env.OPENSCOUT_LSOF_BIN?.trim() || "lsof";
|
|
2282
|
+
}
|
|
2283
|
+
function psDiscoveryMaxRows() {
|
|
2284
|
+
const parsed = Number.parseInt(process.env.OPENSCOUT_PS_DISCOVERY_MAX_ROWS ?? "", 10);
|
|
2285
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PS_DISCOVERY_MAX_ROWS;
|
|
2286
|
+
}
|
|
2287
|
+
function truncateCommand(value) {
|
|
2288
|
+
if (value.length <= PS_DISCOVERY_MAX_COMMAND_CHARS) {
|
|
2289
|
+
return { command: value, truncated: false };
|
|
2290
|
+
}
|
|
2291
|
+
return {
|
|
2292
|
+
command: value.slice(0, PS_DISCOVERY_MAX_COMMAND_CHARS),
|
|
2293
|
+
truncated: true
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
function parseRows(output) {
|
|
2297
|
+
return output.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
2298
|
+
const parts = line.split(/\s+/u);
|
|
2299
|
+
const pid = parseProcessNumber(parts[0]);
|
|
2300
|
+
const ppid = parseProcessNumber(parts[1]);
|
|
2301
|
+
const pgid = parseProcessNumber(parts[2]);
|
|
2302
|
+
const tty = normalizeTty(parts[3]);
|
|
2303
|
+
const comm = parts.slice(4).join(" ");
|
|
2304
|
+
return pid && ppid && pgid && comm ? { pid, ppid, pgid, tty, comm } : null;
|
|
2305
|
+
}).filter((row) => Boolean(row));
|
|
2306
|
+
}
|
|
2307
|
+
function parseCommandRows(output) {
|
|
2308
|
+
return output.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
2309
|
+
const parts = line.split(/\s+/u);
|
|
2310
|
+
const pid = parseProcessNumber(parts[0]);
|
|
2311
|
+
const ppid = parseProcessNumber(parts[1]);
|
|
2312
|
+
const pgid = parseProcessNumber(parts[2]);
|
|
2313
|
+
const tty = normalizeTty(parts[3]);
|
|
2314
|
+
const command = truncateCommand(parts.slice(4).join(" ")).command;
|
|
2315
|
+
const comm = command.split(/\s+/u)[0] ?? "";
|
|
2316
|
+
return pid && ppid && pgid && comm && command ? { pid, ppid, pgid, tty, comm, command } : null;
|
|
2317
|
+
}).filter((row) => Boolean(row));
|
|
2318
|
+
}
|
|
2319
|
+
function parseDiscoveryRows(output) {
|
|
2320
|
+
return output.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
2321
|
+
const match = line.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/u);
|
|
2322
|
+
if (!match)
|
|
2323
|
+
return null;
|
|
2324
|
+
const pid = parseProcessNumber(match[1]);
|
|
2325
|
+
const ppid = parseProcessNumber(match[2]);
|
|
2326
|
+
const etime = match[3]?.trim() ?? "";
|
|
2327
|
+
const command = match[4]?.trim() ?? "";
|
|
2328
|
+
return pid && ppid && etime && command ? { pid, ppid, etime, command } : null;
|
|
2329
|
+
}).filter((row) => Boolean(row));
|
|
2330
|
+
}
|
|
2331
|
+
function summarizeProcessDiscoveryRows(rows, maxRows = psDiscoveryMaxRows()) {
|
|
2332
|
+
const totalCount = rows.length;
|
|
2333
|
+
let truncatedCommand = false;
|
|
2334
|
+
const cappedRows = rows.slice(0, maxRows).map((row) => {
|
|
2335
|
+
const command = truncateCommand(row.command);
|
|
2336
|
+
if (command.truncated)
|
|
2337
|
+
truncatedCommand = true;
|
|
2338
|
+
return { ...row, command: command.command };
|
|
2339
|
+
});
|
|
2340
|
+
return {
|
|
2341
|
+
rows: cappedRows,
|
|
2342
|
+
truncated: totalCount > cappedRows.length || truncatedCommand,
|
|
2343
|
+
totalCount,
|
|
2344
|
+
returnedCount: cappedRows.length
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
async function readPsRuntimeLocal(ctx) {
|
|
2348
|
+
try {
|
|
2349
|
+
const [rows, commandRows] = await Promise.all([
|
|
2350
|
+
execProbeFile(ctx, psBin(), ["-axo", "pid=,ppid=,pgid=,tty=,comm="], {
|
|
2351
|
+
maxStdoutBytes: 4 * 1024 * 1024,
|
|
2352
|
+
maxStderrBytes: 128 * 1024
|
|
2353
|
+
}),
|
|
2354
|
+
execProbeFile(ctx, psBin(), ["-axo", "pid=,ppid=,pgid=,tty=,command="], {
|
|
2355
|
+
maxStdoutBytes: 8 * 1024 * 1024,
|
|
2356
|
+
maxStderrBytes: 128 * 1024
|
|
2357
|
+
})
|
|
2358
|
+
]);
|
|
2359
|
+
return {
|
|
2360
|
+
rows: parseRows(rows.stdout),
|
|
2361
|
+
commandRows: parseCommandRows(commandRows.stdout)
|
|
2362
|
+
};
|
|
2363
|
+
} catch (error) {
|
|
2364
|
+
if (isUnavailable3(error)) {
|
|
2365
|
+
return { rows: [], commandRows: [] };
|
|
2366
|
+
}
|
|
2367
|
+
throw error;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
var psRuntimeProbe = defineProbe({
|
|
2371
|
+
id: "ps.runtime",
|
|
2372
|
+
ttlMs: PS_TTL_MS,
|
|
2373
|
+
timeoutMs: PS_TIMEOUT_MS,
|
|
2374
|
+
run: (ctx) => runWithScoutdFallback({
|
|
2375
|
+
probeId: "ps.runtime",
|
|
2376
|
+
ctx,
|
|
2377
|
+
local: () => readPsRuntimeLocal(ctx)
|
|
2378
|
+
})
|
|
2379
|
+
});
|
|
2380
|
+
async function readPsDiscoveryLocal(ctx) {
|
|
2381
|
+
try {
|
|
2382
|
+
const { stdout } = await execProbeFile(ctx, psBin(), ["-axww", "-o", "pid=,ppid=,etime=,command="], {
|
|
2383
|
+
maxStdoutBytes: 32 * 1024 * 1024,
|
|
2384
|
+
maxStderrBytes: 128 * 1024
|
|
2385
|
+
});
|
|
2386
|
+
return summarizeProcessDiscoveryRows(parseDiscoveryRows(stdout));
|
|
2387
|
+
} catch (error) {
|
|
2388
|
+
if (isUnavailable3(error))
|
|
2389
|
+
return summarizeProcessDiscoveryRows([]);
|
|
2390
|
+
throw error;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
var psDiscoveryProbe = defineProbe({
|
|
2394
|
+
id: "ps.discovery",
|
|
2395
|
+
ttlMs: 1000,
|
|
2396
|
+
timeoutMs: PS_TIMEOUT_MS,
|
|
2397
|
+
run: (ctx) => runWithScoutdFallback({
|
|
2398
|
+
probeId: "ps.discovery",
|
|
2399
|
+
ctx,
|
|
2400
|
+
local: () => readPsDiscoveryLocal(ctx)
|
|
2401
|
+
})
|
|
2402
|
+
});
|
|
2403
|
+
var processCwdProbe = defineProbeFamily({
|
|
2404
|
+
id: "ps.cwd",
|
|
2405
|
+
ttlMs: PS_TTL_MS,
|
|
2406
|
+
timeoutMs: 1500,
|
|
2407
|
+
maxKeys: 256,
|
|
2408
|
+
idleKeyTtlMs: 2 * 60000,
|
|
2409
|
+
maxConcurrentKeys: 4,
|
|
2410
|
+
normalizeKey: (pid) => String(pid).trim(),
|
|
2411
|
+
run: async (key, ctx) => runWithScoutdFallback({
|
|
2412
|
+
probeId: "ps.cwd",
|
|
2413
|
+
key,
|
|
2414
|
+
ctx,
|
|
2415
|
+
local: () => readProcessCwdLocal(key, ctx)
|
|
2416
|
+
})
|
|
2417
|
+
});
|
|
2418
|
+
async function readProcessCwdLocal(key, ctx) {
|
|
2419
|
+
const pid = Number.parseInt(key, 10);
|
|
2420
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
2421
|
+
return null;
|
|
2422
|
+
try {
|
|
2423
|
+
const { stdout } = await execProbeFile(ctx, lsofBin2(), [
|
|
2424
|
+
"-a",
|
|
2425
|
+
"-p",
|
|
2426
|
+
String(pid),
|
|
2427
|
+
"-d",
|
|
2428
|
+
"cwd",
|
|
2429
|
+
"-Fn"
|
|
2430
|
+
], {
|
|
2431
|
+
maxStdoutBytes: 64 * 1024,
|
|
2432
|
+
maxStderrBytes: 64 * 1024
|
|
2433
|
+
});
|
|
2434
|
+
for (const line of stdout.split(/\r?\n/u)) {
|
|
2435
|
+
if (!line.startsWith("n"))
|
|
2436
|
+
continue;
|
|
2437
|
+
const value = line.slice(1).trim();
|
|
2438
|
+
if (value)
|
|
2439
|
+
return value;
|
|
2440
|
+
}
|
|
2441
|
+
return null;
|
|
2442
|
+
} catch (error) {
|
|
2443
|
+
if (isUnavailable3(error))
|
|
2444
|
+
return null;
|
|
2445
|
+
throw error;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// packages/runtime/dist/system-probes/tailscale-status.js
|
|
2450
|
+
import { readFile } from "node:fs/promises";
|
|
2451
|
+
var DEFAULT_TAILSCALE_STATUS_TIMEOUT_MS = 1500;
|
|
2452
|
+
function parseTailscaleStatusJson(raw) {
|
|
2453
|
+
return JSON.parse(raw);
|
|
2454
|
+
}
|
|
2455
|
+
function parsePeers(status) {
|
|
2456
|
+
const peers = Object.entries(status.Peer ?? {});
|
|
2457
|
+
return peers.map(([fallbackId, peer]) => ({
|
|
2458
|
+
id: peer.ID ?? fallbackId,
|
|
2459
|
+
name: peer.HostName ?? peer.DNSName ?? fallbackId,
|
|
2460
|
+
dnsName: peer.DNSName,
|
|
2461
|
+
addresses: peer.TailscaleIPs ?? [],
|
|
2462
|
+
online: peer.Online ?? false,
|
|
2463
|
+
hostName: peer.HostName,
|
|
2464
|
+
os: peer.OS,
|
|
2465
|
+
tags: peer.Tags ?? []
|
|
2466
|
+
}));
|
|
2467
|
+
}
|
|
2468
|
+
function parseSelf(status) {
|
|
2469
|
+
const self = status.Self;
|
|
2470
|
+
if (!self) {
|
|
2471
|
+
return null;
|
|
2472
|
+
}
|
|
2473
|
+
return {
|
|
2474
|
+
id: self.ID ?? self.DNSName ?? self.HostName ?? "self",
|
|
2475
|
+
name: self.HostName ?? self.DNSName ?? "self",
|
|
2476
|
+
dnsName: self.DNSName,
|
|
2477
|
+
addresses: self.TailscaleIPs ?? [],
|
|
2478
|
+
online: self.Online ?? true,
|
|
2479
|
+
hostName: self.HostName,
|
|
2480
|
+
os: self.OS,
|
|
2481
|
+
tailnetName: status.CurrentTailnet?.Name,
|
|
2482
|
+
magicDnsSuffix: status.CurrentTailnet?.MagicDNSSuffix
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
function isTailscaleBackendRunning(status) {
|
|
2486
|
+
return (status.BackendState ?? "").trim().toLowerCase() === "running";
|
|
2487
|
+
}
|
|
2488
|
+
function summarizeTailscaleStatus(status) {
|
|
2489
|
+
return {
|
|
2490
|
+
backendState: status.BackendState ?? null,
|
|
2491
|
+
running: isTailscaleBackendRunning(status),
|
|
2492
|
+
health: status.Health ?? [],
|
|
2493
|
+
peers: parsePeers(status),
|
|
2494
|
+
self: parseSelf(status)
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
function statusTimeoutMs(env) {
|
|
2498
|
+
const parsed = Number.parseInt(env.OPENSCOUT_TAILSCALE_STATUS_TIMEOUT_MS ?? "", 10);
|
|
2499
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TAILSCALE_STATUS_TIMEOUT_MS;
|
|
2500
|
+
}
|
|
2501
|
+
async function readStatusJsonFromFile(filePath) {
|
|
2502
|
+
try {
|
|
2503
|
+
const raw = await readFile(filePath, "utf8");
|
|
2504
|
+
return parseTailscaleStatusJson(raw);
|
|
2505
|
+
} catch {
|
|
2506
|
+
return null;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
function isDomainUnavailableError(error) {
|
|
2510
|
+
if (!(error instanceof ProbeCommandError)) {
|
|
2511
|
+
return false;
|
|
2512
|
+
}
|
|
2513
|
+
return error.code === "ENOENT" || error.code === "spawn" || error.code === "exit";
|
|
2514
|
+
}
|
|
2515
|
+
async function readTailscaleStatusSummaryLocal(ctx) {
|
|
2516
|
+
const fixturePath = process.env.OPENSCOUT_TAILSCALE_STATUS_JSON;
|
|
2517
|
+
if (fixturePath) {
|
|
2518
|
+
const status = await readStatusJsonFromFile(fixturePath);
|
|
2519
|
+
return status ? summarizeTailscaleStatus(status) : null;
|
|
2520
|
+
}
|
|
2521
|
+
const tailscaleBin = process.env.OPENSCOUT_TAILSCALE_BIN ?? "tailscale";
|
|
2522
|
+
try {
|
|
2523
|
+
const { stdout } = await execProbeFile(ctx, tailscaleBin, ["status", "--json"], {
|
|
2524
|
+
maxStdoutBytes: 4 * 1024 * 1024,
|
|
2525
|
+
maxStderrBytes: 256 * 1024
|
|
2526
|
+
});
|
|
2527
|
+
return summarizeTailscaleStatus(parseTailscaleStatusJson(stdout));
|
|
2528
|
+
} catch (error) {
|
|
2529
|
+
if (isDomainUnavailableError(error)) {
|
|
2530
|
+
return null;
|
|
2531
|
+
}
|
|
2532
|
+
throw error;
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
var tailscaleStatusProbe = defineProbe({
|
|
2536
|
+
id: "tailscale.status",
|
|
2537
|
+
ttlMs: 30000,
|
|
2538
|
+
timeoutMs: statusTimeoutMs(process.env),
|
|
2539
|
+
run: (ctx) => runWithScoutdFallback({
|
|
2540
|
+
probeId: "tailscale.status",
|
|
2541
|
+
ctx,
|
|
2542
|
+
local: () => readTailscaleStatusSummaryLocal(ctx)
|
|
2543
|
+
})
|
|
2544
|
+
});
|
|
2545
|
+
|
|
2546
|
+
// packages/runtime/dist/system-probes/tmux.js
|
|
2547
|
+
import { homedir as homedir2 } from "node:os";
|
|
2548
|
+
import { join as join3 } from "node:path";
|
|
2549
|
+
var TMUX_TTL_MS = 5000;
|
|
2550
|
+
var TMUX_TIMEOUT_MS = 1500;
|
|
2551
|
+
var ZELLIJ_TIMEOUT_MS = 1500;
|
|
2552
|
+
function parsePositiveInteger(value, fallback) {
|
|
2553
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
2554
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
2555
|
+
}
|
|
2556
|
+
function cleanOptionalString(value) {
|
|
2557
|
+
const trimmed = value?.trim();
|
|
2558
|
+
return trimmed ? trimmed : null;
|
|
2559
|
+
}
|
|
2560
|
+
function splitDelimitedLine(line, delimiter, fieldCount) {
|
|
2561
|
+
const parts = line.split(delimiter);
|
|
2562
|
+
if (parts.length <= fieldCount)
|
|
2563
|
+
return parts;
|
|
2564
|
+
return [...parts.slice(0, fieldCount - 1), parts.slice(fieldCount - 1).join(delimiter)];
|
|
2565
|
+
}
|
|
2566
|
+
function parseProcessNumber2(value) {
|
|
2567
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
2568
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
2569
|
+
}
|
|
2570
|
+
function isUnavailable4(error) {
|
|
2571
|
+
return error instanceof ProbeCommandError && (error.code === "ENOENT" || error.code === "spawn" || error.code === "exit");
|
|
2572
|
+
}
|
|
2573
|
+
function tmuxBin() {
|
|
2574
|
+
return process.env.OPENSCOUT_TMUX_BIN?.trim() || "tmux";
|
|
2575
|
+
}
|
|
2576
|
+
function zellijBin() {
|
|
2577
|
+
return process.env.OPENSCOUT_ZELLIJ_BIN?.trim() || "zellij";
|
|
2578
|
+
}
|
|
2579
|
+
function tmuxSocketFromEnv(env) {
|
|
2580
|
+
const explicit = env.OPENSCOUT_TMUX_SOCKET?.trim();
|
|
2581
|
+
if (explicit)
|
|
2582
|
+
return explicit;
|
|
2583
|
+
const tmux = env.TMUX?.trim();
|
|
2584
|
+
if (!tmux)
|
|
2585
|
+
return null;
|
|
2586
|
+
const [socketPath] = tmux.split(",");
|
|
2587
|
+
return socketPath?.trim() || null;
|
|
2588
|
+
}
|
|
2589
|
+
function tmuxSocketKey(input) {
|
|
2590
|
+
if (typeof input === "string")
|
|
2591
|
+
return input.trim() || "default";
|
|
2592
|
+
const socketPath = input?.socketPath?.trim() || tmuxSocketFromEnv(input?.env ?? process.env);
|
|
2593
|
+
return socketPath || "default";
|
|
2594
|
+
}
|
|
2595
|
+
function tmuxSocketArgs(key) {
|
|
2596
|
+
return key === "default" ? [] : ["-S", key];
|
|
2597
|
+
}
|
|
2598
|
+
function zellijSocketDirFromEnv(env) {
|
|
2599
|
+
return env.ZELLIJ_SOCKET_DIR?.trim() || env.OPENSCOUT_ZELLIJ_SOCKET_DIR?.trim() || join3(env.HOME?.trim() || homedir2(), ".openscout", "zellij-sockets");
|
|
2600
|
+
}
|
|
2601
|
+
function zellijSocketKey(input) {
|
|
2602
|
+
if (typeof input === "string")
|
|
2603
|
+
return input.trim() || zellijSocketDirFromEnv(process.env);
|
|
2604
|
+
return input?.socketDir?.trim() || zellijSocketDirFromEnv(input?.env ?? process.env);
|
|
2605
|
+
}
|
|
2606
|
+
function parseTmuxSessionList(output) {
|
|
2607
|
+
return output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
2608
|
+
const [name, windows, attached, createdAt, currentCommand, currentPath] = line.includes("|") ? splitDelimitedLine(line, "|", 6) : splitDelimitedLine(line, "\t", 6);
|
|
2609
|
+
if (!name)
|
|
2610
|
+
return null;
|
|
2611
|
+
return {
|
|
2612
|
+
name,
|
|
2613
|
+
windows: parsePositiveInteger(windows, 1),
|
|
2614
|
+
attached: parsePositiveInteger(attached, 0),
|
|
2615
|
+
createdAt: createdAt ? Number.parseInt(createdAt, 10) || null : null,
|
|
2616
|
+
currentCommand: cleanOptionalString(currentCommand),
|
|
2617
|
+
currentPath: cleanOptionalString(currentPath)
|
|
2618
|
+
};
|
|
2619
|
+
}).filter((session) => Boolean(session));
|
|
2620
|
+
}
|
|
2621
|
+
function stripAnsi(value) {
|
|
2622
|
+
return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/gu, "");
|
|
2623
|
+
}
|
|
2624
|
+
function parseZellijSessionList(output) {
|
|
2625
|
+
return stripAnsi(output).split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
2626
|
+
const [name] = line.split(/\s+/u);
|
|
2627
|
+
if (!name)
|
|
2628
|
+
return null;
|
|
2629
|
+
return {
|
|
2630
|
+
name,
|
|
2631
|
+
state: /\bEXITED\b/iu.test(line) ? "exited" : "live",
|
|
2632
|
+
raw: line
|
|
2633
|
+
};
|
|
2634
|
+
}).filter((session) => Boolean(session));
|
|
2635
|
+
}
|
|
2636
|
+
async function readTmuxSessionsLocal(key, ctx) {
|
|
2637
|
+
try {
|
|
2638
|
+
const { stdout } = await execProbeFile(ctx, tmuxBin(), [
|
|
2639
|
+
...tmuxSocketArgs(key),
|
|
2640
|
+
"list-sessions",
|
|
2641
|
+
"-F",
|
|
2642
|
+
"#{session_name}|#{session_windows}|#{session_attached}|#{session_created}|#{pane_current_command}|#{pane_current_path}"
|
|
2643
|
+
], {
|
|
2644
|
+
maxStdoutBytes: 512 * 1024,
|
|
2645
|
+
maxStderrBytes: 64 * 1024
|
|
2646
|
+
});
|
|
2647
|
+
return parseTmuxSessionList(stdout);
|
|
2648
|
+
} catch (error) {
|
|
2649
|
+
if (isUnavailable4(error))
|
|
2650
|
+
return [];
|
|
2651
|
+
throw error;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
var tmuxSessionsProbe = defineProbeFamily({
|
|
2655
|
+
id: "tmux.sessions",
|
|
2656
|
+
ttlMs: TMUX_TTL_MS,
|
|
2657
|
+
timeoutMs: TMUX_TIMEOUT_MS,
|
|
2658
|
+
maxKeys: 16,
|
|
2659
|
+
idleKeyTtlMs: 5 * 60000,
|
|
2660
|
+
maxConcurrentKeys: 2,
|
|
2661
|
+
normalizeKey: tmuxSocketKey,
|
|
2662
|
+
run: (key, ctx) => runWithScoutdFallback({
|
|
2663
|
+
probeId: "tmux.sessions",
|
|
2664
|
+
key,
|
|
2665
|
+
ctx,
|
|
2666
|
+
local: () => readTmuxSessionsLocal(key, ctx)
|
|
2667
|
+
})
|
|
2668
|
+
});
|
|
2669
|
+
async function readZellijSessionsLocal(socketDir, ctx) {
|
|
2670
|
+
try {
|
|
2671
|
+
const { stdout } = await execProbeFile(ctx, zellijBin(), ["list-sessions"], {
|
|
2672
|
+
env: { ...process.env, ZELLIJ_SOCKET_DIR: socketDir },
|
|
2673
|
+
maxStdoutBytes: 512 * 1024,
|
|
2674
|
+
maxStderrBytes: 64 * 1024
|
|
2675
|
+
});
|
|
2676
|
+
return parseZellijSessionList(stdout);
|
|
2677
|
+
} catch (error) {
|
|
2678
|
+
if (isUnavailable4(error))
|
|
2679
|
+
return [];
|
|
2680
|
+
throw error;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
var zellijSessionsProbe = defineProbeFamily({
|
|
2684
|
+
id: "zellij.sessions",
|
|
2685
|
+
ttlMs: TMUX_TTL_MS,
|
|
2686
|
+
timeoutMs: ZELLIJ_TIMEOUT_MS,
|
|
2687
|
+
maxKeys: 16,
|
|
2688
|
+
idleKeyTtlMs: 5 * 60000,
|
|
2689
|
+
maxConcurrentKeys: 2,
|
|
2690
|
+
normalizeKey: zellijSocketKey,
|
|
2691
|
+
run: (socketDir, ctx) => runWithScoutdFallback({
|
|
2692
|
+
probeId: "zellij.sessions",
|
|
2693
|
+
key: socketDir,
|
|
2694
|
+
ctx,
|
|
2695
|
+
local: () => readZellijSessionsLocal(socketDir, ctx)
|
|
2696
|
+
})
|
|
2697
|
+
});
|
|
2698
|
+
function normalizeTmuxPaneKey(input) {
|
|
2699
|
+
const socketPath = tmuxSocketKey(input.socketPath ?? null);
|
|
2700
|
+
if (input.kind === "detail") {
|
|
2701
|
+
return JSON.stringify({ kind: "detail", socketPath, target: input.target.trim() });
|
|
2702
|
+
}
|
|
2703
|
+
return JSON.stringify({
|
|
2704
|
+
kind: "capture",
|
|
2705
|
+
socketPath,
|
|
2706
|
+
target: input.target.trim(),
|
|
2707
|
+
start: input.start,
|
|
2708
|
+
end: input.end,
|
|
2709
|
+
joinWrapped: input.joinWrapped !== false,
|
|
2710
|
+
maxBytes: input.maxBytes ?? null
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
function parseTmuxPaneKey(key) {
|
|
2714
|
+
const parsed = JSON.parse(key);
|
|
2715
|
+
return parsed;
|
|
2716
|
+
}
|
|
2717
|
+
var tmuxPanesProbe = defineProbeFamily({
|
|
2718
|
+
id: "tmux.panes",
|
|
2719
|
+
ttlMs: TMUX_TTL_MS,
|
|
2720
|
+
timeoutMs: 1500,
|
|
2721
|
+
maxKeys: 128,
|
|
2722
|
+
idleKeyTtlMs: 2 * 60000,
|
|
2723
|
+
maxConcurrentKeys: 4,
|
|
2724
|
+
normalizeKey: normalizeTmuxPaneKey,
|
|
2725
|
+
run: (key, ctx) => runWithScoutdFallback({
|
|
2726
|
+
probeId: "tmux.panes",
|
|
2727
|
+
key,
|
|
2728
|
+
ctx,
|
|
2729
|
+
local: () => readTmuxPaneLocal(key, ctx)
|
|
2730
|
+
})
|
|
2731
|
+
});
|
|
2732
|
+
async function readTmuxPaneLocal(key, ctx) {
|
|
2733
|
+
const parsed = parseTmuxPaneKey(key);
|
|
2734
|
+
try {
|
|
2735
|
+
if (parsed.kind === "detail") {
|
|
2736
|
+
const { stdout: stdout2 } = await execProbeFile(ctx, tmuxBin(), [
|
|
2737
|
+
...tmuxSocketArgs(parsed.socketPath),
|
|
2738
|
+
"display-message",
|
|
2739
|
+
"-p",
|
|
2740
|
+
"-t",
|
|
2741
|
+
parsed.target,
|
|
2742
|
+
"#{pane_pid}\t#{pane_tty}\t#{pane_current_path}"
|
|
2743
|
+
], {
|
|
2744
|
+
maxStdoutBytes: 64 * 1024,
|
|
2745
|
+
maxStderrBytes: 64 * 1024
|
|
2746
|
+
});
|
|
2747
|
+
const [pidRaw, ttyRaw, pathRaw] = stdout2.trim().split("\t");
|
|
2748
|
+
const panePid = parseProcessNumber2(pidRaw);
|
|
2749
|
+
const paneTty = ttyRaw?.replace(/^\/dev\//u, "").trim();
|
|
2750
|
+
const paneCurrentPath = pathRaw?.trim() || null;
|
|
2751
|
+
return panePid && paneTty ? { panePid, paneTty, paneCurrentPath } : null;
|
|
2752
|
+
}
|
|
2753
|
+
const { stdout } = await execProbeFile(ctx, tmuxBin(), [
|
|
2754
|
+
...tmuxSocketArgs(parsed.socketPath),
|
|
2755
|
+
"capture-pane",
|
|
2756
|
+
"-p",
|
|
2757
|
+
...parsed.joinWrapped === false ? [] : ["-J"],
|
|
2758
|
+
"-t",
|
|
2759
|
+
parsed.target,
|
|
2760
|
+
"-S",
|
|
2761
|
+
parsed.start,
|
|
2762
|
+
"-E",
|
|
2763
|
+
parsed.end
|
|
2764
|
+
], {
|
|
2765
|
+
maxStdoutBytes: parsed.maxBytes ?? 1024 * 1024,
|
|
2766
|
+
maxStderrBytes: 64 * 1024
|
|
2767
|
+
});
|
|
2768
|
+
return { body: stdout };
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
if (isUnavailable4(error))
|
|
2771
|
+
return null;
|
|
2772
|
+
throw error;
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
async function readTmuxSessionExists(sessionName, options = {}) {
|
|
2776
|
+
const name = sessionName.trim();
|
|
2777
|
+
if (!name)
|
|
2778
|
+
return false;
|
|
2779
|
+
const snapshot = await tmuxSessionsProbe.for({ env: options.env, socketPath: options.socketPath }).fresh({ maxAgeMs: options.maxAgeMs ?? TMUX_TTL_MS });
|
|
2780
|
+
return Boolean(snapshot.value?.some((session) => session.name === name));
|
|
2781
|
+
}
|
|
2782
|
+
function invalidateTmuxSessions(options = {}) {
|
|
2783
|
+
tmuxSessionsProbe.invalidate({ env: options.env, socketPath: options.socketPath }, options.reason);
|
|
2784
|
+
}
|
|
2785
|
+
async function readZellijSessionExists(sessionName, options = {}) {
|
|
2786
|
+
const name = sessionName.trim();
|
|
2787
|
+
if (!name)
|
|
2788
|
+
return false;
|
|
2789
|
+
const snapshot = await zellijSessionsProbe.for({ env: options.env, socketDir: options.socketDir }).fresh({ maxAgeMs: options.maxAgeMs ?? TMUX_TTL_MS });
|
|
2790
|
+
return Boolean(snapshot.value?.some((session) => session.name === name));
|
|
2791
|
+
}
|
|
2792
|
+
// packages/runtime/dist/system-probes/sessions.js
|
|
2793
|
+
import { createReadStream } from "node:fs";
|
|
2794
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2795
|
+
import { homedir as homedir3 } from "node:os";
|
|
2796
|
+
import { basename as basename2, dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
|
|
2797
|
+
import { createInterface } from "node:readline";
|
|
2798
|
+
function positiveInteger2(value, fallback, max) {
|
|
2799
|
+
return Number.isFinite(value) && value > 0 ? Math.min(max, Math.floor(value)) : fallback;
|
|
2800
|
+
}
|
|
2801
|
+
function normalizeScanKey(input) {
|
|
2802
|
+
return {
|
|
2803
|
+
home: resolve2(input.home?.trim() || homedir3()),
|
|
2804
|
+
workspaceRoot: input.workspaceRoot?.trim() ? resolve2(input.workspaceRoot) : null,
|
|
2805
|
+
maxAgeDays: positiveInteger2(input.maxAgeDays, 14, 3650),
|
|
2806
|
+
limit: positiveInteger2(input.limit, 250, 1e4)
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
function normalizeSessionFileScanKey(input) {
|
|
2810
|
+
return JSON.stringify(normalizeScanKey(input));
|
|
2811
|
+
}
|
|
2812
|
+
function normalizeSessionSearchKey(input) {
|
|
2813
|
+
const scan = normalizeScanKey(input);
|
|
2814
|
+
return JSON.stringify({
|
|
2815
|
+
...scan,
|
|
2816
|
+
query: input.query,
|
|
2817
|
+
candidateLimit: positiveInteger2(input.candidateLimit, Math.max(scan.limit * 10, 1000), 20000)
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
function parseKey(key) {
|
|
2821
|
+
return JSON.parse(key);
|
|
2822
|
+
}
|
|
2823
|
+
function extractProjectName(filePath) {
|
|
2824
|
+
const claudeMatch = filePath.match(/\.claude\/projects\/[^/]*-dev-([^/]+)/);
|
|
2825
|
+
if (claudeMatch?.[1])
|
|
2826
|
+
return claudeMatch[1];
|
|
2827
|
+
return basename2(dirname2(filePath)) || "unknown";
|
|
2828
|
+
}
|
|
2829
|
+
function detectAgent(filePath, fallback = "unknown") {
|
|
2830
|
+
if (filePath.includes(".claude"))
|
|
2831
|
+
return "claude-code";
|
|
2832
|
+
if (filePath.includes(".codex") || filePath.includes("codex"))
|
|
2833
|
+
return "codex";
|
|
2834
|
+
if (filePath.includes(".aider") || filePath.includes("aider"))
|
|
2835
|
+
return "aider";
|
|
2836
|
+
return fallback;
|
|
2837
|
+
}
|
|
2838
|
+
async function directoryExists(path) {
|
|
2839
|
+
try {
|
|
2840
|
+
return (await stat(path)).isDirectory();
|
|
2841
|
+
} catch {
|
|
2842
|
+
return false;
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
async function scanJsonlFiles(input) {
|
|
2846
|
+
const out = [];
|
|
2847
|
+
async function visit(directory, depth) {
|
|
2848
|
+
if (input.signal.aborted || out.length >= input.cap || depth < 0)
|
|
2849
|
+
return;
|
|
2850
|
+
let entries;
|
|
2851
|
+
try {
|
|
2852
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
2853
|
+
} catch {
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
for (const entry of entries) {
|
|
2857
|
+
if (input.signal.aborted || out.length >= input.cap)
|
|
2858
|
+
return;
|
|
2859
|
+
const filePath = join4(directory, entry.name);
|
|
2860
|
+
if (entry.isDirectory()) {
|
|
2861
|
+
if (input.skipSubagents && entry.name === "subagents")
|
|
2862
|
+
continue;
|
|
2863
|
+
await visit(filePath, depth - 1);
|
|
2864
|
+
continue;
|
|
2865
|
+
}
|
|
2866
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
|
|
2867
|
+
continue;
|
|
2868
|
+
try {
|
|
2869
|
+
const info = await stat(filePath);
|
|
2870
|
+
if (!info.isFile() || info.mtimeMs < input.cutoffMs)
|
|
2871
|
+
continue;
|
|
2872
|
+
out.push({
|
|
2873
|
+
path: filePath,
|
|
2874
|
+
project: extractProjectName(filePath),
|
|
2875
|
+
agent: detectAgent(filePath, input.agent),
|
|
2876
|
+
modifiedAt: info.mtimeMs,
|
|
2877
|
+
sizeBytes: info.size,
|
|
2878
|
+
lineCount: 0
|
|
2879
|
+
});
|
|
2880
|
+
} catch {}
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
await visit(input.root, input.maxDepth);
|
|
2884
|
+
return out;
|
|
2885
|
+
}
|
|
2886
|
+
async function scanSessionFilesLocal(input, ctx) {
|
|
2887
|
+
const normalized = normalizeScanKey(input);
|
|
2888
|
+
const cutoffMs = Date.now() - normalized.maxAgeDays * 24 * 60 * 60000;
|
|
2889
|
+
const roots = [
|
|
2890
|
+
{ root: join4(normalized.home, ".claude", "projects"), agent: "claude-code", maxDepth: 8, skipSubagents: true },
|
|
2891
|
+
{ root: join4(normalized.home, ".codex"), agent: "codex", maxDepth: 8, skipSubagents: true },
|
|
2892
|
+
{ root: join4(normalized.home, ".openai-codex"), agent: "codex", maxDepth: 8, skipSubagents: true }
|
|
2893
|
+
];
|
|
2894
|
+
const results = [];
|
|
2895
|
+
const cap = Math.max(normalized.limit * 4, normalized.limit + 100);
|
|
2896
|
+
for (const root of roots) {
|
|
2897
|
+
if (!await directoryExists(root.root))
|
|
2898
|
+
continue;
|
|
2899
|
+
results.push(...await scanJsonlFiles({
|
|
2900
|
+
...root,
|
|
2901
|
+
cutoffMs,
|
|
2902
|
+
signal: ctx.signal,
|
|
2903
|
+
cap
|
|
2904
|
+
}));
|
|
2905
|
+
}
|
|
2906
|
+
if (normalized.workspaceRoot && await directoryExists(normalized.workspaceRoot)) {
|
|
2907
|
+
const existing = new Set(results.map((result) => result.path));
|
|
2908
|
+
const workspaceResults = await scanJsonlFiles({
|
|
2909
|
+
root: normalized.workspaceRoot,
|
|
2910
|
+
agent: "unknown",
|
|
2911
|
+
cutoffMs,
|
|
2912
|
+
maxDepth: 4,
|
|
2913
|
+
skipSubagents: false,
|
|
2914
|
+
signal: ctx.signal,
|
|
2915
|
+
cap
|
|
2916
|
+
});
|
|
2917
|
+
for (const result of workspaceResults) {
|
|
2918
|
+
if (existing.has(result.path))
|
|
2919
|
+
continue;
|
|
2920
|
+
existing.add(result.path);
|
|
2921
|
+
results.push(result);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
results.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
|
2925
|
+
return results.slice(0, normalized.limit);
|
|
2926
|
+
}
|
|
2927
|
+
async function searchFile(path, query, signal) {
|
|
2928
|
+
const needle = query.toLowerCase();
|
|
2929
|
+
let count = 0;
|
|
2930
|
+
const preview = [];
|
|
2931
|
+
const stream = createReadStream(path, { encoding: "utf8" });
|
|
2932
|
+
signal.addEventListener("abort", () => stream.destroy(signal.reason), { once: true });
|
|
2933
|
+
const reader = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
|
|
2934
|
+
try {
|
|
2935
|
+
for await (const line of reader) {
|
|
2936
|
+
if (signal.aborted)
|
|
2937
|
+
break;
|
|
2938
|
+
if (!String(line).toLowerCase().includes(needle))
|
|
2939
|
+
continue;
|
|
2940
|
+
count += 1;
|
|
2941
|
+
if (preview.length < 3) {
|
|
2942
|
+
preview.push(String(line));
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
} catch {
|
|
2946
|
+
return { count: 0, preview: [] };
|
|
2947
|
+
}
|
|
2948
|
+
return { count, preview };
|
|
2949
|
+
}
|
|
2950
|
+
async function searchSessionFilesLocal(input, ctx) {
|
|
2951
|
+
const query = input.query.trim();
|
|
2952
|
+
if (!query)
|
|
2953
|
+
return [];
|
|
2954
|
+
const candidateLimit = positiveInteger2(input.candidateLimit, Math.max(input.limit * 10, 1000), 20000);
|
|
2955
|
+
const sessions = await scanSessionFilesLocal({ ...input, limit: candidateLimit }, ctx);
|
|
2956
|
+
const matches = [];
|
|
2957
|
+
for (const session of sessions) {
|
|
2958
|
+
if (ctx.signal.aborted)
|
|
2959
|
+
break;
|
|
2960
|
+
const result = await searchFile(session.path, query, ctx.signal);
|
|
2961
|
+
if (result.count <= 0)
|
|
2962
|
+
continue;
|
|
2963
|
+
matches.push({
|
|
2964
|
+
path: session.path,
|
|
2965
|
+
project: session.project,
|
|
2966
|
+
agent: session.agent,
|
|
2967
|
+
matchCount: result.count,
|
|
2968
|
+
preview: result.preview
|
|
2969
|
+
});
|
|
2970
|
+
}
|
|
2971
|
+
matches.sort((a, b) => b.matchCount - a.matchCount);
|
|
2972
|
+
return matches.slice(0, positiveInteger2(input.limit, 50, 1e4));
|
|
2973
|
+
}
|
|
2974
|
+
var sessionsScanProbe = defineProbeFamily({
|
|
2975
|
+
id: "sessions.scan",
|
|
2976
|
+
ttlMs: 1e4,
|
|
2977
|
+
timeoutMs: 1e4,
|
|
2978
|
+
maxKeys: 64,
|
|
2979
|
+
idleKeyTtlMs: 5 * 60000,
|
|
2980
|
+
maxConcurrentKeys: 2,
|
|
2981
|
+
normalizeKey: normalizeSessionFileScanKey,
|
|
2982
|
+
run: (key, ctx) => scanSessionFilesLocal(parseKey(key), ctx)
|
|
2983
|
+
});
|
|
2984
|
+
var sessionsSearchProbe = defineProbeFamily({
|
|
2985
|
+
id: "sessions.search",
|
|
2986
|
+
ttlMs: 1e4,
|
|
2987
|
+
timeoutMs: 1e4,
|
|
2988
|
+
maxKeys: 128,
|
|
2989
|
+
idleKeyTtlMs: 5 * 60000,
|
|
2990
|
+
maxConcurrentKeys: 2,
|
|
2991
|
+
normalizeKey: normalizeSessionSearchKey,
|
|
2992
|
+
run: (key, ctx) => searchSessionFilesLocal(parseKey(key), ctx)
|
|
2993
|
+
});
|
|
2994
|
+
// packages/runtime/dist/system-probes/cert-status.js
|
|
2995
|
+
import { existsSync as existsSync4, realpathSync as realpathSync2 } from "node:fs";
|
|
2996
|
+
import { resolve as resolve3 } from "node:path";
|
|
2997
|
+
function canonicalCertPath(path) {
|
|
2998
|
+
try {
|
|
2999
|
+
return realpathSync2(path);
|
|
3000
|
+
} catch {
|
|
3001
|
+
return resolve3(path);
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
function isUnavailable5(error) {
|
|
3005
|
+
return error instanceof ProbeCommandError && (error.code === "ENOENT" || error.code === "spawn" || error.code === "exit");
|
|
3006
|
+
}
|
|
3007
|
+
var certStatusProbe = defineProbeFamily({
|
|
3008
|
+
id: "cert.status",
|
|
3009
|
+
ttlMs: 5 * 60000,
|
|
3010
|
+
timeoutMs: 5000,
|
|
3011
|
+
maxKeys: 64,
|
|
3012
|
+
idleKeyTtlMs: 30 * 60000,
|
|
3013
|
+
maxConcurrentKeys: 2,
|
|
3014
|
+
normalizeKey: canonicalCertPath,
|
|
3015
|
+
run: async (certPath, ctx) => {
|
|
3016
|
+
if (!existsSync4(certPath)) {
|
|
3017
|
+
return {
|
|
3018
|
+
certPath,
|
|
3019
|
+
exists: false,
|
|
3020
|
+
validAtLeast24h: false,
|
|
3021
|
+
issuer: null,
|
|
3022
|
+
subject: null,
|
|
3023
|
+
publiclyTrusted: false
|
|
3024
|
+
};
|
|
3025
|
+
}
|
|
3026
|
+
let validAtLeast24h = false;
|
|
3027
|
+
try {
|
|
3028
|
+
await execProbeFile(ctx, "openssl", ["x509", "-in", certPath, "-noout", "-checkend", "86400"], {
|
|
3029
|
+
maxStdoutBytes: 64 * 1024,
|
|
3030
|
+
maxStderrBytes: 64 * 1024
|
|
3031
|
+
});
|
|
3032
|
+
validAtLeast24h = true;
|
|
3033
|
+
} catch (error) {
|
|
3034
|
+
if (!isUnavailable5(error)) {
|
|
3035
|
+
throw error;
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
let issuer = null;
|
|
3039
|
+
let subject = null;
|
|
3040
|
+
try {
|
|
3041
|
+
const { stdout } = await execProbeFile(ctx, "openssl", ["x509", "-in", certPath, "-noout", "-issuer", "-subject"], {
|
|
3042
|
+
maxStdoutBytes: 128 * 1024,
|
|
3043
|
+
maxStderrBytes: 64 * 1024
|
|
3044
|
+
});
|
|
3045
|
+
issuer = stdout.match(/^issuer=(.*)$/m)?.[1]?.trim() || null;
|
|
3046
|
+
subject = stdout.match(/^subject=(.*)$/m)?.[1]?.trim() || null;
|
|
3047
|
+
} catch (error) {
|
|
3048
|
+
if (!isUnavailable5(error)) {
|
|
3049
|
+
throw error;
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
return {
|
|
3053
|
+
certPath,
|
|
3054
|
+
exists: true,
|
|
3055
|
+
validAtLeast24h,
|
|
3056
|
+
issuer,
|
|
3057
|
+
subject,
|
|
3058
|
+
publiclyTrusted: Boolean(validAtLeast24h && issuer && subject && issuer !== subject)
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
});
|
|
3062
|
+
// packages/web/server/terminal-relay-session.ts
|
|
3063
|
+
import { randomBytes, timingSafeEqual } from "crypto";
|
|
3064
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, rmSync } from "fs";
|
|
3065
|
+
import { dirname as pathDirname, resolve as pathResolve, sep as pathSep } from "path";
|
|
3066
|
+
|
|
3067
|
+
// packages/web/server/terminal-relay-flow.ts
|
|
3068
|
+
var TERMINAL_ACK_CAPABILITY = "terminal:ack";
|
|
3069
|
+
var DEFAULT_HIGH_WATER_BYTES = 512 * 1024;
|
|
3070
|
+
var DEFAULT_LOW_WATER_BYTES = 256 * 1024;
|
|
3071
|
+
var DEFAULT_MAX_QUEUED_BYTES = 2 * 1024 * 1024;
|
|
3072
|
+
function byteLength(data) {
|
|
3073
|
+
return Buffer.byteLength(data, "utf8");
|
|
3074
|
+
}
|
|
3075
|
+
function socketOpen(ws) {
|
|
3076
|
+
return Boolean(ws && ws.readyState === 1);
|
|
3077
|
+
}
|
|
3078
|
+
function createTerminalFlowControlState(options = {}) {
|
|
3079
|
+
const highWaterBytes = Math.max(1, options.highWaterBytes ?? DEFAULT_HIGH_WATER_BYTES);
|
|
3080
|
+
const lowWaterBytes = Math.max(0, Math.min(options.lowWaterBytes ?? DEFAULT_LOW_WATER_BYTES, highWaterBytes));
|
|
3081
|
+
const maxQueuedBytes = Math.max(0, options.maxQueuedBytes ?? DEFAULT_MAX_QUEUED_BYTES);
|
|
3082
|
+
return {
|
|
3083
|
+
nextSeq: 1,
|
|
3084
|
+
inFlightBytes: 0,
|
|
3085
|
+
queuedBytes: 0,
|
|
3086
|
+
droppedBytes: 0,
|
|
3087
|
+
paused: false,
|
|
3088
|
+
highWaterBytes,
|
|
3089
|
+
lowWaterBytes,
|
|
3090
|
+
maxQueuedBytes,
|
|
3091
|
+
pendingAcks: new Map,
|
|
3092
|
+
queue: []
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
function resetTerminalFlowControl(state, hooks = {}) {
|
|
3096
|
+
state.inFlightBytes = 0;
|
|
3097
|
+
state.queuedBytes = 0;
|
|
3098
|
+
state.pendingAcks.clear();
|
|
3099
|
+
state.queue = [];
|
|
3100
|
+
resumeFlow(state, hooks, true);
|
|
3101
|
+
}
|
|
3102
|
+
function pauseFlow(state, hooks) {
|
|
3103
|
+
if (state.paused)
|
|
3104
|
+
return;
|
|
3105
|
+
state.paused = true;
|
|
3106
|
+
hooks.pause?.();
|
|
3107
|
+
}
|
|
3108
|
+
function resumeFlow(state, hooks, force = false) {
|
|
3109
|
+
if (!state.paused)
|
|
3110
|
+
return;
|
|
3111
|
+
if (!force && state.inFlightBytes + state.queuedBytes > state.lowWaterBytes)
|
|
3112
|
+
return;
|
|
3113
|
+
state.paused = false;
|
|
3114
|
+
hooks.resume?.();
|
|
3115
|
+
}
|
|
3116
|
+
function sendChunk(state, ws, chunk, hooks) {
|
|
3117
|
+
if (!socketOpen(ws))
|
|
3118
|
+
return false;
|
|
3119
|
+
ws.send(JSON.stringify({ type: "terminal:data", data: chunk.data, seq: chunk.seq }));
|
|
3120
|
+
state.pendingAcks.set(chunk.seq, chunk);
|
|
3121
|
+
state.inFlightBytes += chunk.bytes;
|
|
3122
|
+
if (state.inFlightBytes >= state.highWaterBytes) {
|
|
3123
|
+
pauseFlow(state, hooks);
|
|
3124
|
+
}
|
|
3125
|
+
return true;
|
|
3126
|
+
}
|
|
3127
|
+
function dropOverflow(state, hooks) {
|
|
3128
|
+
if (state.maxQueuedBytes <= 0)
|
|
3129
|
+
return;
|
|
3130
|
+
let droppedBytes = 0;
|
|
3131
|
+
let droppedChunks = 0;
|
|
3132
|
+
while (state.queuedBytes > state.maxQueuedBytes && state.queue.length > 0) {
|
|
3133
|
+
const dropped = state.queue.shift();
|
|
3134
|
+
if (!dropped)
|
|
3135
|
+
break;
|
|
3136
|
+
state.queuedBytes -= dropped.bytes;
|
|
3137
|
+
state.droppedBytes += dropped.bytes;
|
|
3138
|
+
droppedBytes += dropped.bytes;
|
|
3139
|
+
droppedChunks += 1;
|
|
3140
|
+
}
|
|
3141
|
+
if (droppedChunks > 0) {
|
|
3142
|
+
hooks.onDrop?.({ bytes: droppedBytes, chunks: droppedChunks });
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
function queueChunk(state, chunk, hooks) {
|
|
3146
|
+
state.queue.push(chunk);
|
|
3147
|
+
state.queuedBytes += chunk.bytes;
|
|
3148
|
+
pauseFlow(state, hooks);
|
|
3149
|
+
dropOverflow(state, hooks);
|
|
3150
|
+
}
|
|
3151
|
+
function enqueueTerminalData(state, ws, data, hooks = {}) {
|
|
3152
|
+
if (!data || !socketOpen(ws))
|
|
3153
|
+
return null;
|
|
3154
|
+
const chunk = {
|
|
3155
|
+
seq: state.nextSeq++,
|
|
3156
|
+
data,
|
|
3157
|
+
bytes: byteLength(data)
|
|
3158
|
+
};
|
|
3159
|
+
if (state.inFlightBytes >= state.highWaterBytes || state.queue.length > 0) {
|
|
3160
|
+
queueChunk(state, chunk, hooks);
|
|
3161
|
+
return chunk.seq;
|
|
3162
|
+
}
|
|
3163
|
+
sendChunk(state, ws, chunk, hooks);
|
|
3164
|
+
return chunk.seq;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
// packages/web/server/terminal-relay-zellij.ts
|
|
3168
|
+
import { mkdtempSync, writeFileSync, mkdirSync } from "fs";
|
|
3169
|
+
import { tmpdir } from "os";
|
|
3170
|
+
import { join as join5 } from "path";
|
|
3171
|
+
function kdlString(value) {
|
|
3172
|
+
return JSON.stringify(value);
|
|
3173
|
+
}
|
|
3174
|
+
function createZellijLayoutFile({ cwd, commandBin, commandArgs }) {
|
|
3175
|
+
const dir = mkdtempSync(join5(tmpdir(), "hudson-zellij-"));
|
|
3176
|
+
const layoutPath = join5(dir, "layout.kdl");
|
|
3177
|
+
const argsBlock = commandArgs.length > 0 ? `
|
|
3178
|
+
args ${commandArgs.map(kdlString).join(" ")}` : "";
|
|
3179
|
+
const layout = `layout {
|
|
3180
|
+
pane command=${kdlString(commandBin)} {
|
|
3181
|
+
cwd ${kdlString(cwd)}${argsBlock}
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
`;
|
|
3185
|
+
writeFileSync(layoutPath, layout, "utf8");
|
|
3186
|
+
return layoutPath;
|
|
3187
|
+
}
|
|
3188
|
+
function prepareZellijSocketDir(socketDir) {
|
|
3189
|
+
if (!socketDir)
|
|
3190
|
+
return {};
|
|
3191
|
+
mkdirSync(socketDir, { recursive: true });
|
|
3192
|
+
return { ZELLIJ_SOCKET_DIR: socketDir };
|
|
3193
|
+
}
|
|
3194
|
+
function buildZellijAttachArgs({
|
|
3195
|
+
sessionName,
|
|
3196
|
+
controlMode = "owner",
|
|
3197
|
+
layoutPath,
|
|
3198
|
+
cwd
|
|
3199
|
+
}) {
|
|
3200
|
+
if (controlMode === "observe") {
|
|
3201
|
+
return ["watch", sessionName];
|
|
3202
|
+
}
|
|
3203
|
+
const args = ["attach", "--create", sessionName];
|
|
3204
|
+
const optionArgs = [];
|
|
3205
|
+
if (layoutPath)
|
|
3206
|
+
optionArgs.push("--default-layout", layoutPath);
|
|
3207
|
+
if (cwd)
|
|
3208
|
+
optionArgs.push("--default-cwd", cwd);
|
|
3209
|
+
if (optionArgs.length > 0)
|
|
3210
|
+
args.push("options", ...optionArgs);
|
|
3211
|
+
return args;
|
|
3212
|
+
}
|
|
3213
|
+
|
|
8
3214
|
// packages/web/server/terminal-relay-session.ts
|
|
9
|
-
import { createRequire } from "module";
|
|
10
|
-
import { execFileSync, execSync } from "child_process";
|
|
11
|
-
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
12
|
-
import { join, dirname as pathDirname } from "path";
|
|
13
3215
|
var require2 = createRequire(import.meta.url);
|
|
14
|
-
var pty =
|
|
3216
|
+
var pty = (() => {
|
|
3217
|
+
try {
|
|
3218
|
+
return require2("@lydell/node-pty");
|
|
3219
|
+
} catch {
|
|
3220
|
+
return require2("node-pty");
|
|
3221
|
+
}
|
|
3222
|
+
})();
|
|
15
3223
|
var DEFAULT_ORPHAN_TTL_MS = 30 * 60 * 1000;
|
|
16
3224
|
var MAX_BUFFER_SIZE = 512 * 1024;
|
|
17
|
-
var
|
|
3225
|
+
var SAFE_TRUNCATION_WINDOW = 4096;
|
|
3226
|
+
var sessions2 = new Map;
|
|
18
3227
|
function generateId() {
|
|
19
|
-
return
|
|
3228
|
+
return randomBytes(8).toString("hex");
|
|
3229
|
+
}
|
|
3230
|
+
function generateReconnectToken() {
|
|
3231
|
+
return randomBytes(16).toString("hex");
|
|
3232
|
+
}
|
|
3233
|
+
function verifyReconnectToken(session, token) {
|
|
3234
|
+
if (typeof token !== "string" || token.length === 0)
|
|
3235
|
+
return false;
|
|
3236
|
+
const expected = Buffer.from(session.reconnectToken, "utf8");
|
|
3237
|
+
const supplied = Buffer.from(token, "utf8");
|
|
3238
|
+
if (expected.length !== supplied.length)
|
|
3239
|
+
return false;
|
|
3240
|
+
return timingSafeEqual(expected, supplied);
|
|
3241
|
+
}
|
|
3242
|
+
var MULTIPLEXER_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$/;
|
|
3243
|
+
function isValidMultiplexerName(name) {
|
|
3244
|
+
return MULTIPLEXER_NAME_RE.test(name);
|
|
20
3245
|
}
|
|
21
3246
|
function send(ws, data) {
|
|
22
3247
|
if (ws.readyState === 1) {
|
|
@@ -34,12 +3259,47 @@ function markSessionPtyClosed(session, err, op) {
|
|
|
34
3259
|
console.warn(`[relay] Session ${session.id}: PTY ${op} failed after fd closed (${err instanceof Error ? err.message : String(err)})`);
|
|
35
3260
|
scheduleReap(session, 1e4);
|
|
36
3261
|
}
|
|
3262
|
+
function pauseSessionOutput(session) {
|
|
3263
|
+
try {
|
|
3264
|
+
session.pty.pause?.();
|
|
3265
|
+
} catch {}
|
|
3266
|
+
}
|
|
3267
|
+
function resumeSessionOutput(session) {
|
|
3268
|
+
try {
|
|
3269
|
+
session.pty.resume?.();
|
|
3270
|
+
} catch {}
|
|
3271
|
+
}
|
|
3272
|
+
function resetSessionFlowControl(session) {
|
|
3273
|
+
resetTerminalFlowControl(session.flowControl, {
|
|
3274
|
+
resume: () => resumeSessionOutput(session)
|
|
3275
|
+
});
|
|
3276
|
+
}
|
|
3277
|
+
function sendTerminalOutput(session, data) {
|
|
3278
|
+
if (!session.flowControlEnabled) {
|
|
3279
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
3280
|
+
send(session.ws, { type: "terminal:data", data });
|
|
3281
|
+
}
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
enqueueTerminalData(session.flowControl, session.ws, data, {
|
|
3285
|
+
pause: () => pauseSessionOutput(session),
|
|
3286
|
+
resume: () => resumeSessionOutput(session),
|
|
3287
|
+
onDrop: ({ bytes, chunks }) => {
|
|
3288
|
+
console.warn(`[relay] Session ${session.id}: dropped ${bytes} bytes across ${chunks} queued output chunks after flow-control overflow`);
|
|
3289
|
+
}
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
function clientSupportsAck(capabilities) {
|
|
3293
|
+
return Array.isArray(capabilities) && capabilities.includes(TERMINAL_ACK_CAPABILITY);
|
|
3294
|
+
}
|
|
37
3295
|
function sessionOwnsSocket(session, ws) {
|
|
38
3296
|
return session.ws === ws;
|
|
39
3297
|
}
|
|
40
3298
|
function writeSession(session, data) {
|
|
41
3299
|
if (session.exited)
|
|
42
3300
|
return false;
|
|
3301
|
+
if (session.controlMode === "observe")
|
|
3302
|
+
return false;
|
|
43
3303
|
try {
|
|
44
3304
|
session.pty.write(data);
|
|
45
3305
|
return true;
|
|
@@ -54,6 +3314,8 @@ function writeSession(session, data) {
|
|
|
54
3314
|
function resizeSession(session, cols, rows) {
|
|
55
3315
|
if (session.exited)
|
|
56
3316
|
return false;
|
|
3317
|
+
if (session.controlMode === "observe" && session.backend !== "zellij")
|
|
3318
|
+
return false;
|
|
57
3319
|
try {
|
|
58
3320
|
session.pty.resize(cols, rows);
|
|
59
3321
|
if (session.backend === "tmux" && session.tmuxSession) {
|
|
@@ -70,13 +3332,57 @@ function resizeSession(session, cols, rows) {
|
|
|
70
3332
|
throw err;
|
|
71
3333
|
}
|
|
72
3334
|
}
|
|
3335
|
+
function isLowSurrogate(code) {
|
|
3336
|
+
return code >= 56320 && code <= 57343;
|
|
3337
|
+
}
|
|
3338
|
+
function truncateOutputBuffer(buffer, maxSize = MAX_BUFFER_SIZE) {
|
|
3339
|
+
if (buffer.length <= maxSize)
|
|
3340
|
+
return buffer;
|
|
3341
|
+
let start = buffer.length - maxSize;
|
|
3342
|
+
if (isLowSurrogate(buffer.charCodeAt(start)))
|
|
3343
|
+
start += 1;
|
|
3344
|
+
const scanEnd = Math.min(buffer.length, start + SAFE_TRUNCATION_WINDOW);
|
|
3345
|
+
for (let i = start;i < scanEnd; i++) {
|
|
3346
|
+
const code = buffer.charCodeAt(i);
|
|
3347
|
+
if (code === 10)
|
|
3348
|
+
return buffer.slice(i + 1);
|
|
3349
|
+
if (code === 27)
|
|
3350
|
+
return buffer.slice(i);
|
|
3351
|
+
}
|
|
3352
|
+
return buffer.slice(start);
|
|
3353
|
+
}
|
|
3354
|
+
function chunkReplayData(data, maxBytes) {
|
|
3355
|
+
if (!data)
|
|
3356
|
+
return [];
|
|
3357
|
+
const limit = Math.max(1, maxBytes);
|
|
3358
|
+
if (Buffer.byteLength(data, "utf8") <= limit)
|
|
3359
|
+
return [data];
|
|
3360
|
+
const chunks = [];
|
|
3361
|
+
let start = 0;
|
|
3362
|
+
while (start < data.length) {
|
|
3363
|
+
let end = Math.min(start + limit, data.length);
|
|
3364
|
+
for (;; ) {
|
|
3365
|
+
if (end > start + 1 && isLowSurrogate(data.charCodeAt(end))) {
|
|
3366
|
+
end -= 1;
|
|
3367
|
+
continue;
|
|
3368
|
+
}
|
|
3369
|
+
const bytes = Buffer.byteLength(data.slice(start, end), "utf8");
|
|
3370
|
+
if (bytes <= limit || end <= start + 1)
|
|
3371
|
+
break;
|
|
3372
|
+
end = start + Math.max(1, Math.floor((end - start) * limit / bytes));
|
|
3373
|
+
}
|
|
3374
|
+
chunks.push(data.slice(start, end));
|
|
3375
|
+
start = end;
|
|
3376
|
+
}
|
|
3377
|
+
return chunks;
|
|
3378
|
+
}
|
|
73
3379
|
function resolveCwd(raw) {
|
|
74
3380
|
const home = process.env.HOME || "/tmp";
|
|
75
3381
|
const expanded = (raw || home).replace(/^~/, home);
|
|
76
|
-
if (
|
|
3382
|
+
if (existsSync5(expanded))
|
|
77
3383
|
return expanded;
|
|
78
3384
|
try {
|
|
79
|
-
|
|
3385
|
+
mkdirSync2(expanded, { recursive: true });
|
|
80
3386
|
console.log(`[relay] Created missing cwd: ${expanded}`);
|
|
81
3387
|
return expanded;
|
|
82
3388
|
} catch {
|
|
@@ -84,26 +3390,40 @@ function resolveCwd(raw) {
|
|
|
84
3390
|
return home;
|
|
85
3391
|
}
|
|
86
3392
|
}
|
|
87
|
-
function findBin(name, envOverride) {
|
|
3393
|
+
async function findBin(name, envOverride) {
|
|
88
3394
|
if (envOverride && process.env[envOverride])
|
|
89
|
-
return process.env[envOverride];
|
|
3395
|
+
return process.env[envOverride] ?? null;
|
|
90
3396
|
try {
|
|
91
|
-
|
|
3397
|
+
const result = await execSystemFile("which", [name], {
|
|
3398
|
+
timeoutMs: 1500,
|
|
3399
|
+
maxStdoutBytes: 64 * 1024,
|
|
3400
|
+
maxStderrBytes: 64 * 1024
|
|
3401
|
+
});
|
|
3402
|
+
return result.stdout.trim() || null;
|
|
92
3403
|
} catch {
|
|
93
3404
|
return null;
|
|
94
3405
|
}
|
|
95
3406
|
}
|
|
96
|
-
function findClaudeBin() {
|
|
3407
|
+
async function findClaudeBin() {
|
|
97
3408
|
return findBin("claude", "CLAUDE_BIN");
|
|
98
3409
|
}
|
|
99
|
-
function findPiBin() {
|
|
3410
|
+
async function findPiBin() {
|
|
100
3411
|
return findBin("pi", "PI_BIN");
|
|
101
3412
|
}
|
|
102
|
-
function findShellBin() {
|
|
3413
|
+
async function findShellBin() {
|
|
103
3414
|
const configured = process.env.SHELL;
|
|
104
|
-
if (configured &&
|
|
3415
|
+
if (configured && existsSync5(configured))
|
|
105
3416
|
return configured;
|
|
106
|
-
return findBin("zsh") ?? findBin("bash") ?? findBin("sh") ?? (
|
|
3417
|
+
return await findBin("zsh") ?? await findBin("bash") ?? await findBin("sh") ?? (existsSync5("/bin/sh") ? "/bin/sh" : null);
|
|
3418
|
+
}
|
|
3419
|
+
async function findZellijBin() {
|
|
3420
|
+
return findBin("zellij", "ZELLIJ_BIN");
|
|
3421
|
+
}
|
|
3422
|
+
async function zellijSessionExists(_zellijBin, name, env) {
|
|
3423
|
+
return await readZellijSessionExists(name, { env, maxAgeMs: 5000 });
|
|
3424
|
+
}
|
|
3425
|
+
async function tmuxSessionExists(name) {
|
|
3426
|
+
return await readTmuxSessionExists(name, { maxAgeMs: 5000 });
|
|
107
3427
|
}
|
|
108
3428
|
function normalizePiProviderForCli(provider) {
|
|
109
3429
|
if (!provider)
|
|
@@ -112,17 +3432,12 @@ function normalizePiProviderForCli(provider) {
|
|
|
112
3432
|
return "github-copilot";
|
|
113
3433
|
return provider;
|
|
114
3434
|
}
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
execSync(`tmux has-session -t ${name} 2>/dev/null`, { encoding: "utf8" });
|
|
118
|
-
return true;
|
|
119
|
-
} catch {
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
3435
|
+
function shellQuote(value) {
|
|
3436
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
122
3437
|
}
|
|
123
|
-
function resizeTmuxWindow(name, cols, rows) {
|
|
3438
|
+
async function resizeTmuxWindow(name, cols, rows) {
|
|
124
3439
|
try {
|
|
125
|
-
|
|
3440
|
+
await execSystemFile("tmux", [
|
|
126
3441
|
"resize-window",
|
|
127
3442
|
"-t",
|
|
128
3443
|
name,
|
|
@@ -130,21 +3445,32 @@ function resizeTmuxWindow(name, cols, rows) {
|
|
|
130
3445
|
String(cols),
|
|
131
3446
|
"-y",
|
|
132
3447
|
String(rows)
|
|
133
|
-
], {
|
|
3448
|
+
], { timeoutMs: 2000 });
|
|
134
3449
|
return true;
|
|
135
3450
|
} catch {
|
|
136
3451
|
return false;
|
|
137
3452
|
}
|
|
138
3453
|
}
|
|
3454
|
+
function resolveBootstrapPath(cwd, relPath) {
|
|
3455
|
+
const base = pathResolve(cwd);
|
|
3456
|
+
const absPath = pathResolve(base, relPath);
|
|
3457
|
+
if (absPath === base || !absPath.startsWith(base + pathSep))
|
|
3458
|
+
return null;
|
|
3459
|
+
return absPath;
|
|
3460
|
+
}
|
|
139
3461
|
function bootstrapFiles(cwd, files, sessionId) {
|
|
140
3462
|
for (const [relPath, content] of Object.entries(files)) {
|
|
141
|
-
const absPath =
|
|
142
|
-
if (!
|
|
3463
|
+
const absPath = resolveBootstrapPath(cwd, relPath);
|
|
3464
|
+
if (!absPath) {
|
|
3465
|
+
console.warn(`[relay] Session ${sessionId}: refused to bootstrap ${relPath} — path escapes the session cwd`);
|
|
3466
|
+
continue;
|
|
3467
|
+
}
|
|
3468
|
+
if (!existsSync5(absPath)) {
|
|
143
3469
|
try {
|
|
144
3470
|
const dir = pathDirname(absPath);
|
|
145
|
-
if (!
|
|
146
|
-
|
|
147
|
-
|
|
3471
|
+
if (!existsSync5(dir))
|
|
3472
|
+
mkdirSync2(dir, { recursive: true });
|
|
3473
|
+
writeFileSync2(absPath, content, "utf-8");
|
|
148
3474
|
console.log(`[relay] Session ${sessionId}: bootstrapped ${relPath}`);
|
|
149
3475
|
} catch (err) {
|
|
150
3476
|
console.warn(`[relay] Session ${sessionId}: failed to bootstrap ${relPath}:`, err);
|
|
@@ -152,15 +3478,36 @@ function bootstrapFiles(cwd, files, sessionId) {
|
|
|
152
3478
|
}
|
|
153
3479
|
}
|
|
154
3480
|
}
|
|
155
|
-
function spawnTmuxSession(tmuxName, cols, rows, cwd, commandBin, commandArgs, env) {
|
|
156
|
-
const exists = tmuxSessionExists(tmuxName);
|
|
3481
|
+
async function spawnTmuxSession(tmuxName, cols, rows, cwd, commandBin, commandArgs, env, controlMode) {
|
|
3482
|
+
const exists = await tmuxSessionExists(tmuxName);
|
|
157
3483
|
if (!exists) {
|
|
158
|
-
|
|
159
|
-
|
|
3484
|
+
if (controlMode === "observe") {
|
|
3485
|
+
throw new Error(`tmux session '${tmuxName}' does not exist`);
|
|
3486
|
+
}
|
|
3487
|
+
const shellCmd = [commandBin, ...commandArgs].map(shellQuote).join(" ");
|
|
3488
|
+
await execSystemFile("tmux", [
|
|
3489
|
+
"new-session",
|
|
3490
|
+
"-d",
|
|
3491
|
+
"-s",
|
|
3492
|
+
tmuxName,
|
|
3493
|
+
"-x",
|
|
3494
|
+
String(cols),
|
|
3495
|
+
"-y",
|
|
3496
|
+
String(rows),
|
|
3497
|
+
"-c",
|
|
3498
|
+
cwd,
|
|
3499
|
+
shellCmd
|
|
3500
|
+
], { env, timeoutMs: 5000 });
|
|
3501
|
+
invalidateTmuxSessions({ env, reason: "terminal-relay.new-session" });
|
|
160
3502
|
console.log(`[relay] Created tmux session: ${tmuxName}`);
|
|
3503
|
+
if (muxTtlMs() > 0)
|
|
3504
|
+
trackCreatedMuxSession("tmux", tmuxName);
|
|
161
3505
|
} else {
|
|
162
|
-
|
|
163
|
-
|
|
3506
|
+
if (controlMode !== "observe") {
|
|
3507
|
+
await resizeTmuxWindow(tmuxName, cols, rows);
|
|
3508
|
+
}
|
|
3509
|
+
console.log(`[relay] Attaching to existing tmux session: ${tmuxName}${controlMode === "observe" ? " (observe)" : ""}`);
|
|
3510
|
+
markMuxSessionInUse("tmux", tmuxName);
|
|
164
3511
|
}
|
|
165
3512
|
return pty.spawn("tmux", ["attach", "-t", tmuxName], {
|
|
166
3513
|
name: "xterm-256color",
|
|
@@ -170,16 +3517,44 @@ function spawnTmuxSession(tmuxName, cols, rows, cwd, commandBin, commandArgs, en
|
|
|
170
3517
|
env
|
|
171
3518
|
});
|
|
172
3519
|
}
|
|
173
|
-
function
|
|
3520
|
+
function spawnZellijSession(zellijBin2, zellijName, cols, rows, cwd, commandBin, commandArgs, env, controlMode) {
|
|
3521
|
+
const layoutPath = controlMode === "observe" ? undefined : createZellijLayoutFile({ cwd, commandBin, commandArgs });
|
|
3522
|
+
const args = buildZellijAttachArgs({
|
|
3523
|
+
sessionName: zellijName,
|
|
3524
|
+
controlMode,
|
|
3525
|
+
layoutPath,
|
|
3526
|
+
cwd
|
|
3527
|
+
});
|
|
3528
|
+
return {
|
|
3529
|
+
ptyProcess: pty.spawn(zellijBin2, args, {
|
|
3530
|
+
name: "xterm-256color",
|
|
3531
|
+
cols,
|
|
3532
|
+
rows,
|
|
3533
|
+
cwd,
|
|
3534
|
+
env
|
|
3535
|
+
}),
|
|
3536
|
+
layoutPath
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
async function createSession(ws, msg) {
|
|
174
3540
|
const id = generateId();
|
|
175
3541
|
const cols = Math.max(msg.cols || 80, 20);
|
|
176
3542
|
const rows = Math.max(msg.rows || 24, 4);
|
|
177
3543
|
const backend = msg.backend || "pty";
|
|
3544
|
+
const controlMode = msg.controlMode || "owner";
|
|
178
3545
|
const tmuxName = msg.tmuxSession || `hudson-${id}`;
|
|
3546
|
+
const zellijName = msg.zellijSession || `hudson-${id}`;
|
|
179
3547
|
const agent = msg.agent || "claude";
|
|
3548
|
+
const multiplexerName = backend === "tmux" ? tmuxName : backend === "zellij" ? zellijName : null;
|
|
3549
|
+
if (multiplexerName && !isValidMultiplexerName(multiplexerName)) {
|
|
3550
|
+
const reason = `Invalid ${backend} session name. Use letters, digits, dashes, and underscores (max 64 chars).`;
|
|
3551
|
+
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
3552
|
+
send(ws, { type: "session:error", error: reason });
|
|
3553
|
+
return null;
|
|
3554
|
+
}
|
|
180
3555
|
let agentBin;
|
|
181
3556
|
if (agent === "shell") {
|
|
182
|
-
agentBin = findShellBin();
|
|
3557
|
+
agentBin = await findShellBin();
|
|
183
3558
|
if (!agentBin) {
|
|
184
3559
|
const reason = "No login shell found. Set SHELL or install zsh, bash, or sh.";
|
|
185
3560
|
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
@@ -187,7 +3562,7 @@ function createSession(ws, msg) {
|
|
|
187
3562
|
return null;
|
|
188
3563
|
}
|
|
189
3564
|
} else if (agent === "pi") {
|
|
190
|
-
agentBin = findPiBin();
|
|
3565
|
+
agentBin = await findPiBin();
|
|
191
3566
|
if (!agentBin) {
|
|
192
3567
|
const reason = "pi CLI not found. Install it with: npm install -g @mariozechner/pi-coding-agent";
|
|
193
3568
|
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
@@ -195,7 +3570,7 @@ function createSession(ws, msg) {
|
|
|
195
3570
|
return null;
|
|
196
3571
|
}
|
|
197
3572
|
} else {
|
|
198
|
-
agentBin = findClaudeBin();
|
|
3573
|
+
agentBin = await findClaudeBin();
|
|
199
3574
|
if (!agentBin) {
|
|
200
3575
|
const reason = "Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code";
|
|
201
3576
|
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
@@ -203,18 +3578,25 @@ function createSession(ws, msg) {
|
|
|
203
3578
|
return null;
|
|
204
3579
|
}
|
|
205
3580
|
}
|
|
206
|
-
if (!
|
|
3581
|
+
if (!existsSync5(agentBin)) {
|
|
207
3582
|
const reason = `${agent} binary not found at ${agentBin}`;
|
|
208
3583
|
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
209
3584
|
send(ws, { type: "session:error", error: reason });
|
|
210
3585
|
return null;
|
|
211
3586
|
}
|
|
212
|
-
if (backend === "tmux" && !findBin("tmux")) {
|
|
3587
|
+
if (backend === "tmux" && !await findBin("tmux")) {
|
|
213
3588
|
const reason = "tmux not found. Install it with: brew install tmux";
|
|
214
3589
|
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
215
3590
|
send(ws, { type: "session:error", error: reason });
|
|
216
3591
|
return null;
|
|
217
3592
|
}
|
|
3593
|
+
const zellijBin2 = backend === "zellij" ? await findZellijBin() : null;
|
|
3594
|
+
if (backend === "zellij" && !zellijBin2) {
|
|
3595
|
+
const reason = "zellij not found. Install it with: brew install zellij";
|
|
3596
|
+
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
3597
|
+
send(ws, { type: "session:error", error: reason });
|
|
3598
|
+
return null;
|
|
3599
|
+
}
|
|
218
3600
|
const cwd = resolveCwd(msg.cwd);
|
|
219
3601
|
if (msg.workspaceFiles) {
|
|
220
3602
|
bootstrapFiles(cwd, msg.workspaceFiles, id);
|
|
@@ -237,13 +3619,29 @@ function createSession(ws, msg) {
|
|
|
237
3619
|
if (msg.systemPrompt)
|
|
238
3620
|
agentArgs.push("--system-prompt", msg.systemPrompt);
|
|
239
3621
|
}
|
|
240
|
-
const env = {
|
|
3622
|
+
const env = {
|
|
3623
|
+
...process.env,
|
|
3624
|
+
...prepareZellijSocketDir(backend === "zellij" ? msg.zellijSocketDir : undefined),
|
|
3625
|
+
TERM: "xterm-256color",
|
|
3626
|
+
FORCE_COLOR: "1"
|
|
3627
|
+
};
|
|
241
3628
|
delete env.CLAUDECODE;
|
|
242
3629
|
let ptyProcess;
|
|
3630
|
+
let zellijLayoutPath;
|
|
243
3631
|
try {
|
|
244
3632
|
if (backend === "tmux") {
|
|
245
3633
|
console.log(`[relay] Session ${id}: tmux backend (session: ${tmuxName}) in ${cwd} [agent: ${agent}]`);
|
|
246
|
-
ptyProcess = spawnTmuxSession(tmuxName, cols, rows, cwd, agentBin, agentArgs, env);
|
|
3634
|
+
ptyProcess = await spawnTmuxSession(tmuxName, cols, rows, cwd, agentBin, agentArgs, env, controlMode);
|
|
3635
|
+
} else if (backend === "zellij") {
|
|
3636
|
+
console.log(`[relay] Session ${id}: zellij backend (session: ${zellijName}, mode: ${controlMode}) in ${cwd} [agent: ${agent}]`);
|
|
3637
|
+
const trackZellij = muxTtlMs() > 0 && controlMode !== "observe" && !await zellijSessionExists(zellijBin2, zellijName, env);
|
|
3638
|
+
const spawned = spawnZellijSession(zellijBin2, zellijName, cols, rows, cwd, agentBin, agentArgs, env, controlMode);
|
|
3639
|
+
ptyProcess = spawned.ptyProcess;
|
|
3640
|
+
zellijLayoutPath = spawned.layoutPath;
|
|
3641
|
+
if (trackZellij)
|
|
3642
|
+
trackCreatedMuxSession("zellij", zellijName);
|
|
3643
|
+
else
|
|
3644
|
+
markMuxSessionInUse("zellij", zellijName);
|
|
247
3645
|
} else {
|
|
248
3646
|
console.log(`[relay] Session ${id}: pty backend, spawning ${agentBin} in ${cwd} [agent: ${agent}]`);
|
|
249
3647
|
ptyProcess = pty.spawn(agentBin, agentArgs, {
|
|
@@ -265,25 +3663,29 @@ function createSession(ws, msg) {
|
|
|
265
3663
|
id,
|
|
266
3664
|
pty: ptyProcess,
|
|
267
3665
|
ws,
|
|
3666
|
+
reconnectToken: generateReconnectToken(),
|
|
268
3667
|
outputBuffer: "",
|
|
269
3668
|
cols,
|
|
270
3669
|
rows,
|
|
271
3670
|
reapTimer: null,
|
|
272
3671
|
orphanTTL,
|
|
273
3672
|
backend,
|
|
3673
|
+
controlMode,
|
|
274
3674
|
...backend === "tmux" ? { tmuxSession: tmuxName } : {},
|
|
3675
|
+
...backend === "zellij" ? {
|
|
3676
|
+
zellijSession: zellijName,
|
|
3677
|
+
...msg.zellijSocketDir ? { zellijSocketDir: msg.zellijSocketDir } : {},
|
|
3678
|
+
...zellijLayoutPath ? { zellijLayoutPath } : {}
|
|
3679
|
+
} : {},
|
|
3680
|
+
flowControl: createTerminalFlowControlState(),
|
|
3681
|
+
flowControlEnabled: clientSupportsAck(msg.clientCapabilities),
|
|
275
3682
|
exited: false,
|
|
276
3683
|
exitCode: null
|
|
277
3684
|
};
|
|
278
3685
|
const startTime = Date.now();
|
|
279
3686
|
ptyProcess.onData((data) => {
|
|
280
|
-
session.outputBuffer
|
|
281
|
-
|
|
282
|
-
session.outputBuffer = session.outputBuffer.slice(-MAX_BUFFER_SIZE);
|
|
283
|
-
}
|
|
284
|
-
if (session.ws && session.ws.readyState === 1) {
|
|
285
|
-
send(session.ws, { type: "terminal:data", data });
|
|
286
|
-
}
|
|
3687
|
+
session.outputBuffer = truncateOutputBuffer(session.outputBuffer + data, MAX_BUFFER_SIZE);
|
|
3688
|
+
sendTerminalOutput(session, data);
|
|
287
3689
|
});
|
|
288
3690
|
ptyProcess.onExit(({ exitCode }) => {
|
|
289
3691
|
session.exited = true;
|
|
@@ -304,15 +3706,17 @@ function createSession(ws, msg) {
|
|
|
304
3706
|
}
|
|
305
3707
|
scheduleReap(session, 1e4);
|
|
306
3708
|
});
|
|
307
|
-
|
|
3709
|
+
sessions2.set(id, session);
|
|
308
3710
|
console.log(`[relay] Session ${id} created (${cols}x${rows})`);
|
|
309
3711
|
return session;
|
|
310
3712
|
}
|
|
311
|
-
function attachSession(session, ws, cols, rows) {
|
|
3713
|
+
function attachSession(session, ws, cols, rows, clientCapabilities) {
|
|
312
3714
|
if (session.reapTimer) {
|
|
313
3715
|
clearTimeout(session.reapTimer);
|
|
314
3716
|
session.reapTimer = null;
|
|
315
3717
|
}
|
|
3718
|
+
resetSessionFlowControl(session);
|
|
3719
|
+
session.flowControlEnabled = clientSupportsAck(clientCapabilities);
|
|
316
3720
|
session.ws = ws;
|
|
317
3721
|
if (cols && rows) {
|
|
318
3722
|
const c = Math.max(cols, 20);
|
|
@@ -324,11 +3728,15 @@ function attachSession(session, ws, cols, rows) {
|
|
|
324
3728
|
if (session.exited) {
|
|
325
3729
|
send(ws, { type: "session:exit", exitCode: session.exitCode });
|
|
326
3730
|
} else if (session.outputBuffer.length > 0) {
|
|
327
|
-
|
|
3731
|
+
const replayChunks = session.flowControlEnabled ? chunkReplayData(session.outputBuffer, session.flowControl.highWaterBytes) : [session.outputBuffer];
|
|
3732
|
+
for (const chunk of replayChunks) {
|
|
3733
|
+
sendTerminalOutput(session, chunk);
|
|
3734
|
+
}
|
|
328
3735
|
}
|
|
329
3736
|
console.log(`[relay] Session ${session.id} reconnected`);
|
|
330
3737
|
}
|
|
331
3738
|
function detachSession(session) {
|
|
3739
|
+
resetSessionFlowControl(session);
|
|
332
3740
|
session.ws = null;
|
|
333
3741
|
if (session.exited) {
|
|
334
3742
|
scheduleReap(session, 5000);
|
|
@@ -347,33 +3755,226 @@ function scheduleReap(session, delay) {
|
|
|
347
3755
|
}, delay);
|
|
348
3756
|
}
|
|
349
3757
|
function destroy(sessionId) {
|
|
350
|
-
const session =
|
|
3758
|
+
const session = sessions2.get(sessionId);
|
|
351
3759
|
if (!session)
|
|
352
3760
|
return;
|
|
353
3761
|
if (session.reapTimer)
|
|
354
3762
|
clearTimeout(session.reapTimer);
|
|
3763
|
+
resetSessionFlowControl(session);
|
|
355
3764
|
try {
|
|
356
3765
|
session.pty.kill();
|
|
357
3766
|
} catch {}
|
|
358
|
-
|
|
3767
|
+
if (session.zellijLayoutPath) {
|
|
3768
|
+
try {
|
|
3769
|
+
rmSync(pathDirname(session.zellijLayoutPath), { recursive: true, force: true });
|
|
3770
|
+
} catch {}
|
|
3771
|
+
}
|
|
3772
|
+
sessions2.delete(sessionId);
|
|
359
3773
|
if (session.backend === "tmux") {
|
|
360
3774
|
console.log(`[relay] Session ${sessionId} bridge destroyed (tmux session '${session.tmuxSession}' still alive)`);
|
|
3775
|
+
if (session.tmuxSession)
|
|
3776
|
+
markMuxSessionDetached("tmux", session.tmuxSession);
|
|
3777
|
+
} else if (session.backend === "zellij") {
|
|
3778
|
+
console.log(`[relay] Session ${sessionId} bridge destroyed (zellij session '${session.zellijSession}' still alive)`);
|
|
3779
|
+
if (session.zellijSession)
|
|
3780
|
+
markMuxSessionDetached("zellij", session.zellijSession);
|
|
361
3781
|
} else {
|
|
362
3782
|
console.log(`[relay] Session ${sessionId} destroyed`);
|
|
363
3783
|
}
|
|
364
3784
|
}
|
|
3785
|
+
var trackedMuxSessions = new Map;
|
|
3786
|
+
function muxTtlMs() {
|
|
3787
|
+
const raw = Number(process.env.HUDSON_RELAY_MUX_TTL_MS || 0);
|
|
3788
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
|
3789
|
+
}
|
|
3790
|
+
function muxKey(backend, name) {
|
|
3791
|
+
return `${backend}:${name}`;
|
|
3792
|
+
}
|
|
3793
|
+
function trackCreatedMuxSession(backend, name) {
|
|
3794
|
+
trackedMuxSessions.set(muxKey(backend, name), { backend, name, detachedAt: null });
|
|
3795
|
+
}
|
|
3796
|
+
function markMuxSessionInUse(backend, name) {
|
|
3797
|
+
const record = trackedMuxSessions.get(muxKey(backend, name));
|
|
3798
|
+
if (record)
|
|
3799
|
+
record.detachedAt = null;
|
|
3800
|
+
}
|
|
3801
|
+
function markMuxSessionDetached(backend, name, now = Date.now()) {
|
|
3802
|
+
const record = trackedMuxSessions.get(muxKey(backend, name));
|
|
3803
|
+
if (record)
|
|
3804
|
+
record.detachedAt = now;
|
|
3805
|
+
}
|
|
365
3806
|
|
|
366
3807
|
// packages/web/server/terminal-relay-node.ts
|
|
367
3808
|
process.title = "scout-relay";
|
|
368
3809
|
var require3 = createRequire2(import.meta.url);
|
|
369
3810
|
var { WebSocketServer } = require3("ws");
|
|
370
|
-
var hostname = process.env.OPENSCOUT_WEB_TERMINAL_RELAY_HOST?.trim() ||
|
|
3811
|
+
var hostname = process.env.OPENSCOUT_WEB_TERMINAL_RELAY_HOST?.trim() || "127.0.0.1";
|
|
371
3812
|
var port = Number.parseInt(process.env.OPENSCOUT_WEB_TERMINAL_RELAY_PORT?.trim() || "3201", 10);
|
|
372
3813
|
var UPLOAD_DIR = "/tmp/scout-uploads";
|
|
3814
|
+
var OUTPUT_BATCH_INTERVAL_MS = 8;
|
|
3815
|
+
var OUTPUT_BATCH_DRAIN_CONTINUE_MS = 1;
|
|
3816
|
+
var OUTPUT_BATCH_CHUNK_CHARS = 16 * 1024;
|
|
3817
|
+
var OUTPUT_BATCH_MAX_WRITES = 2;
|
|
3818
|
+
var OUTPUT_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024;
|
|
3819
|
+
var OUTPUT_IN_FLIGHT_LOW_WATER_CHARS = 256 * 1024;
|
|
3820
|
+
var OUTPUT_PENDING_MAX_CHARS = 2 * 1024 * 1024;
|
|
3821
|
+
var OUTPUT_BACKLOG_WARNING = `\x18\x1B[0m\r
|
|
3822
|
+
[OpenScout skipped terminal output because the relay backlog exceeded 2 MB.]\r
|
|
3823
|
+
`;
|
|
3824
|
+
function disableGeneratedSessionFlowControl(msg) {
|
|
3825
|
+
return { ...msg, clientCapabilities: [] };
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
class FlowControlledRelaySocket {
|
|
3829
|
+
ws;
|
|
3830
|
+
session = null;
|
|
3831
|
+
pendingOutput = "";
|
|
3832
|
+
pendingFlush = null;
|
|
3833
|
+
nextSeq = 1;
|
|
3834
|
+
unacked = [];
|
|
3835
|
+
unackedChars = 0;
|
|
3836
|
+
ptyPaused = false;
|
|
3837
|
+
droppedBacklog = false;
|
|
3838
|
+
constructor(ws) {
|
|
3839
|
+
this.ws = ws;
|
|
3840
|
+
}
|
|
3841
|
+
get readyState() {
|
|
3842
|
+
return this.ws.readyState;
|
|
3843
|
+
}
|
|
3844
|
+
bindSession(session) {
|
|
3845
|
+
this.session = session;
|
|
3846
|
+
}
|
|
3847
|
+
send(data) {
|
|
3848
|
+
if (Buffer.isBuffer(data)) {
|
|
3849
|
+
this.sendRaw(data);
|
|
3850
|
+
return;
|
|
3851
|
+
}
|
|
3852
|
+
const message = parseServerMessage(data);
|
|
3853
|
+
if (message?.type === "terminal:data" && typeof message.data === "string") {
|
|
3854
|
+
this.enqueueOutput(message.data);
|
|
3855
|
+
return;
|
|
3856
|
+
}
|
|
3857
|
+
if (message?.type === "session:exit") {
|
|
3858
|
+
this.flushOutput({ ignoreBackpressure: true });
|
|
3859
|
+
}
|
|
3860
|
+
this.sendRaw(data);
|
|
3861
|
+
}
|
|
3862
|
+
handleAck(seq) {
|
|
3863
|
+
if (typeof seq !== "number" || !Number.isFinite(seq)) {
|
|
3864
|
+
return;
|
|
3865
|
+
}
|
|
3866
|
+
while (this.unacked.length > 0 && this.unacked[0].seq <= seq) {
|
|
3867
|
+
const acked = this.unacked.shift();
|
|
3868
|
+
this.unackedChars = Math.max(0, this.unackedChars - acked.chars);
|
|
3869
|
+
}
|
|
3870
|
+
if (this.ptyPaused && this.unackedChars < OUTPUT_IN_FLIGHT_LOW_WATER_CHARS) {
|
|
3871
|
+
this.resumePty();
|
|
3872
|
+
}
|
|
3873
|
+
if (this.pendingOutput.length > 0) {
|
|
3874
|
+
this.scheduleFlush(OUTPUT_BATCH_DRAIN_CONTINUE_MS);
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
dispose() {
|
|
3878
|
+
if (this.pendingFlush) {
|
|
3879
|
+
clearTimeout(this.pendingFlush);
|
|
3880
|
+
this.pendingFlush = null;
|
|
3881
|
+
}
|
|
3882
|
+
this.pendingOutput = "";
|
|
3883
|
+
this.unacked = [];
|
|
3884
|
+
this.unackedChars = 0;
|
|
3885
|
+
this.resumePty();
|
|
3886
|
+
this.session = null;
|
|
3887
|
+
}
|
|
3888
|
+
enqueueOutput(data) {
|
|
3889
|
+
if (!data) {
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
this.pendingOutput += data;
|
|
3893
|
+
if (this.pendingOutput.length > OUTPUT_PENDING_MAX_CHARS) {
|
|
3894
|
+
this.pendingOutput = OUTPUT_BACKLOG_WARNING + this.pendingOutput.slice(-OUTPUT_PENDING_MAX_CHARS);
|
|
3895
|
+
if (!this.droppedBacklog) {
|
|
3896
|
+
console.warn("[relay] terminal output backlog exceeded 2 MB; keeping only the newest output");
|
|
3897
|
+
this.droppedBacklog = true;
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
this.scheduleFlush(OUTPUT_BATCH_INTERVAL_MS);
|
|
3901
|
+
}
|
|
3902
|
+
scheduleFlush(delayMs) {
|
|
3903
|
+
if (this.pendingFlush || this.pendingOutput.length === 0) {
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3906
|
+
this.pendingFlush = setTimeout(() => {
|
|
3907
|
+
this.pendingFlush = null;
|
|
3908
|
+
this.flushOutput();
|
|
3909
|
+
}, delayMs);
|
|
3910
|
+
}
|
|
3911
|
+
flushOutput(options = {}) {
|
|
3912
|
+
if (this.pendingOutput.length === 0 || this.readyState !== 1) {
|
|
3913
|
+
return;
|
|
3914
|
+
}
|
|
3915
|
+
let writes = 0;
|
|
3916
|
+
while (this.pendingOutput.length > 0 && writes < OUTPUT_BATCH_MAX_WRITES) {
|
|
3917
|
+
if (!options.ignoreBackpressure && this.unackedChars >= OUTPUT_IN_FLIGHT_HIGH_WATER_CHARS) {
|
|
3918
|
+
this.pausePty();
|
|
3919
|
+
return;
|
|
3920
|
+
}
|
|
3921
|
+
const chunk = this.pendingOutput.slice(0, OUTPUT_BATCH_CHUNK_CHARS);
|
|
3922
|
+
this.pendingOutput = this.pendingOutput.slice(chunk.length);
|
|
3923
|
+
const seq = this.nextSeq++;
|
|
3924
|
+
this.unacked.push({ seq, chars: chunk.length });
|
|
3925
|
+
this.unackedChars += chunk.length;
|
|
3926
|
+
this.sendRaw(JSON.stringify({ type: "terminal:data", data: chunk, seq, chars: chunk.length }));
|
|
3927
|
+
writes++;
|
|
3928
|
+
}
|
|
3929
|
+
if (!options.ignoreBackpressure && this.unackedChars >= OUTPUT_IN_FLIGHT_HIGH_WATER_CHARS) {
|
|
3930
|
+
this.pausePty();
|
|
3931
|
+
return;
|
|
3932
|
+
}
|
|
3933
|
+
if (this.pendingOutput.length > 0) {
|
|
3934
|
+
this.scheduleFlush(OUTPUT_BATCH_DRAIN_CONTINUE_MS);
|
|
3935
|
+
} else {
|
|
3936
|
+
this.droppedBacklog = false;
|
|
3937
|
+
}
|
|
3938
|
+
}
|
|
3939
|
+
pausePty() {
|
|
3940
|
+
if (this.ptyPaused || !this.session || this.session.exited) {
|
|
3941
|
+
return;
|
|
3942
|
+
}
|
|
3943
|
+
try {
|
|
3944
|
+
this.session.pty.pause();
|
|
3945
|
+
this.ptyPaused = true;
|
|
3946
|
+
} catch (error) {
|
|
3947
|
+
console.warn("[relay] Failed to pause PTY for terminal flow control:", error);
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
resumePty() {
|
|
3951
|
+
if (!this.ptyPaused || !this.session || this.session.exited) {
|
|
3952
|
+
this.ptyPaused = false;
|
|
3953
|
+
return;
|
|
3954
|
+
}
|
|
3955
|
+
try {
|
|
3956
|
+
this.session.pty.resume();
|
|
3957
|
+
} catch (error) {
|
|
3958
|
+
console.warn("[relay] Failed to resume PTY for terminal flow control:", error);
|
|
3959
|
+
} finally {
|
|
3960
|
+
this.ptyPaused = false;
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
sendRaw(data) {
|
|
3964
|
+
if (this.ws.readyState === 1) {
|
|
3965
|
+
this.ws.send(data);
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
function sessionMatchesSurface(session, backend, sessionName) {
|
|
3970
|
+
if (!backend || !sessionName || session.backend !== backend)
|
|
3971
|
+
return false;
|
|
3972
|
+
return session.terminalSession === sessionName || session.tmuxSession === sessionName || session.zellijSession === sessionName;
|
|
3973
|
+
}
|
|
373
3974
|
var pendingCommand = null;
|
|
374
3975
|
function queueTerminalCommand(input) {
|
|
375
3976
|
if (!input.cwd) {
|
|
376
|
-
for (const [, session] of
|
|
3977
|
+
for (const [, session] of sessions2) {
|
|
377
3978
|
if (writeSession(session, input.command + `
|
|
378
3979
|
`)) {
|
|
379
3980
|
return;
|
|
@@ -387,15 +3988,17 @@ function drainPendingCommand() {
|
|
|
387
3988
|
pendingCommand = null;
|
|
388
3989
|
return command;
|
|
389
3990
|
}
|
|
390
|
-
function
|
|
3991
|
+
function parseServerMessage(raw) {
|
|
391
3992
|
try {
|
|
392
3993
|
const message = JSON.parse(raw);
|
|
393
|
-
|
|
394
|
-
return message;
|
|
395
|
-
}
|
|
3994
|
+
return typeof message?.type === "string" ? message : null;
|
|
396
3995
|
} catch {}
|
|
397
3996
|
return null;
|
|
398
3997
|
}
|
|
3998
|
+
function parseMessage(raw) {
|
|
3999
|
+
const message = parseServerMessage(raw);
|
|
4000
|
+
return message ? message : null;
|
|
4001
|
+
}
|
|
399
4002
|
function setCors(res) {
|
|
400
4003
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
401
4004
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
@@ -419,9 +4022,6 @@ function writeJson(res, status, body) {
|
|
|
419
4022
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
420
4023
|
res.end(JSON.stringify(body));
|
|
421
4024
|
}
|
|
422
|
-
function relayUploadPath(name) {
|
|
423
|
-
return join2(UPLOAD_DIR, `${randomUUID()}-${name}`);
|
|
424
|
-
}
|
|
425
4025
|
var server = createServer(async (req, res) => {
|
|
426
4026
|
setCors(res);
|
|
427
4027
|
if (req.method === "OPTIONS") {
|
|
@@ -432,7 +4032,7 @@ var server = createServer(async (req, res) => {
|
|
|
432
4032
|
const url = new URL(req.url || "/", `http://${hostname}:${port}`);
|
|
433
4033
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
434
4034
|
let attachedSessions = 0;
|
|
435
|
-
for (const session of
|
|
4035
|
+
for (const session of sessions2.values()) {
|
|
436
4036
|
if (session.ws) {
|
|
437
4037
|
attachedSessions += 1;
|
|
438
4038
|
}
|
|
@@ -441,7 +4041,7 @@ var server = createServer(async (req, res) => {
|
|
|
441
4041
|
ok: true,
|
|
442
4042
|
surface: "openscout-terminal-relay",
|
|
443
4043
|
pid: process.pid,
|
|
444
|
-
sessions:
|
|
4044
|
+
sessions: sessions2.size,
|
|
445
4045
|
attachedSessions
|
|
446
4046
|
});
|
|
447
4047
|
return;
|
|
@@ -452,9 +4052,14 @@ var server = createServer(async (req, res) => {
|
|
|
452
4052
|
writeJson(res, 400, { error: "Missing name or data" });
|
|
453
4053
|
return;
|
|
454
4054
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
4055
|
+
const safeName = sanitizeUploadName(body.name);
|
|
4056
|
+
if (!safeName) {
|
|
4057
|
+
writeJson(res, 400, { error: "Invalid name" });
|
|
4058
|
+
return;
|
|
4059
|
+
}
|
|
4060
|
+
await mkdir(UPLOAD_DIR, { recursive: true, mode: 448 });
|
|
4061
|
+
const filepath = join6(UPLOAD_DIR, `${randomUUID()}-${safeName}`);
|
|
4062
|
+
await writeFile(filepath, Buffer.from(body.data, "base64"), { mode: 384 });
|
|
458
4063
|
writeJson(res, 200, { path: filepath });
|
|
459
4064
|
return;
|
|
460
4065
|
}
|
|
@@ -473,121 +4078,176 @@ var server = createServer(async (req, res) => {
|
|
|
473
4078
|
writeJson(res, 200, { ok: true });
|
|
474
4079
|
return;
|
|
475
4080
|
}
|
|
4081
|
+
if (req.method === "POST" && url.pathname === "/api/terminal/session/destroy") {
|
|
4082
|
+
const body = await readJson(req);
|
|
4083
|
+
const sessionId = body?.sessionId?.trim();
|
|
4084
|
+
if (!sessionId) {
|
|
4085
|
+
writeJson(res, 400, { error: "missing sessionId" });
|
|
4086
|
+
return;
|
|
4087
|
+
}
|
|
4088
|
+
const existed = sessions2.has(sessionId);
|
|
4089
|
+
if (existed) {
|
|
4090
|
+
destroy(sessionId);
|
|
4091
|
+
}
|
|
4092
|
+
writeJson(res, 200, { ok: true, destroyed: existed });
|
|
4093
|
+
return;
|
|
4094
|
+
}
|
|
4095
|
+
if (req.method === "POST" && url.pathname === "/api/terminal/session/destroy-surface") {
|
|
4096
|
+
const body = await readJson(req);
|
|
4097
|
+
const backend = body?.backend?.trim();
|
|
4098
|
+
const sessionName = body?.sessionName?.trim();
|
|
4099
|
+
if (!backend || !sessionName) {
|
|
4100
|
+
writeJson(res, 400, { error: "missing backend or sessionName" });
|
|
4101
|
+
return;
|
|
4102
|
+
}
|
|
4103
|
+
let destroyed = 0;
|
|
4104
|
+
for (const [id, session] of [...sessions2]) {
|
|
4105
|
+
if (!sessionMatchesSurface(session, backend, sessionName))
|
|
4106
|
+
continue;
|
|
4107
|
+
destroy(id);
|
|
4108
|
+
destroyed += 1;
|
|
4109
|
+
}
|
|
4110
|
+
writeJson(res, 200, { ok: true, destroyed });
|
|
4111
|
+
return;
|
|
4112
|
+
}
|
|
476
4113
|
writeJson(res, 404, { error: "Not found" });
|
|
477
4114
|
});
|
|
478
|
-
var wss = new WebSocketServer({
|
|
4115
|
+
var wss = new WebSocketServer({
|
|
4116
|
+
server,
|
|
4117
|
+
verifyClient: (info) => {
|
|
4118
|
+
const origin = info.origin ?? info.req.headers.origin;
|
|
4119
|
+
return !origin;
|
|
4120
|
+
}
|
|
4121
|
+
});
|
|
479
4122
|
wss.on("connection", (ws) => {
|
|
480
4123
|
let sessionId = null;
|
|
4124
|
+
const relaySocket = new FlowControlledRelaySocket(ws);
|
|
481
4125
|
ws.on("message", (raw) => {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
switch (msg.type) {
|
|
487
|
-
case "session:init": {
|
|
488
|
-
const pending = drainPendingCommand();
|
|
489
|
-
if (sessionId) {
|
|
490
|
-
const previous = sessions.get(sessionId);
|
|
491
|
-
if (previous && sessionOwnsSocket(previous, ws)) {
|
|
492
|
-
detachSession(previous);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
const session = createSession(ws, pending?.cwd ? { ...msg, cwd: pending.cwd, agent: "shell" } : msg);
|
|
496
|
-
if (!session) {
|
|
497
|
-
break;
|
|
498
|
-
}
|
|
499
|
-
sessionId = session.id;
|
|
500
|
-
send(ws, { type: "session:ready", sessionId: session.id });
|
|
501
|
-
if (pending) {
|
|
502
|
-
setTimeout(() => writeSession(session, pending.command + `
|
|
503
|
-
`), 400);
|
|
504
|
-
}
|
|
505
|
-
break;
|
|
4126
|
+
(async () => {
|
|
4127
|
+
const msg = parseMessage(raw.toString());
|
|
4128
|
+
if (!msg) {
|
|
4129
|
+
return;
|
|
506
4130
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
4131
|
+
switch (msg.type) {
|
|
4132
|
+
case "session:init": {
|
|
4133
|
+
const pending = drainPendingCommand();
|
|
510
4134
|
if (sessionId) {
|
|
511
|
-
const previous =
|
|
512
|
-
if (previous && sessionOwnsSocket(previous,
|
|
4135
|
+
const previous = sessions2.get(sessionId);
|
|
4136
|
+
if (previous && sessionOwnsSocket(previous, relaySocket)) {
|
|
513
4137
|
detachSession(previous);
|
|
514
4138
|
}
|
|
515
4139
|
}
|
|
516
|
-
const session = createSession(
|
|
517
|
-
type: "session:init",
|
|
518
|
-
cols: msg.cols ?? 80,
|
|
519
|
-
rows: msg.rows ?? 24,
|
|
520
|
-
cwd: pending.cwd,
|
|
521
|
-
agent: "shell"
|
|
522
|
-
});
|
|
4140
|
+
const session = await createSession(relaySocket, disableGeneratedSessionFlowControl(pending?.cwd ? { ...msg, cwd: pending.cwd, agent: "shell" } : msg));
|
|
523
4141
|
if (!session) {
|
|
524
4142
|
break;
|
|
525
4143
|
}
|
|
4144
|
+
relaySocket.bindSession(session);
|
|
526
4145
|
sessionId = session.id;
|
|
527
|
-
send(ws, { type: "session:ready", sessionId: session.id });
|
|
528
|
-
|
|
4146
|
+
send(ws, { type: "session:ready", sessionId: session.id, reconnectToken: session.reconnectToken });
|
|
4147
|
+
if (pending) {
|
|
4148
|
+
setTimeout(() => writeSession(session, pending.command + `
|
|
529
4149
|
`), 400);
|
|
4150
|
+
}
|
|
530
4151
|
break;
|
|
531
4152
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
if (
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
4153
|
+
case "session:reconnect": {
|
|
4154
|
+
const pending = drainPendingCommand();
|
|
4155
|
+
if (pending?.cwd) {
|
|
4156
|
+
if (sessionId) {
|
|
4157
|
+
const previous = sessions2.get(sessionId);
|
|
4158
|
+
if (previous && sessionOwnsSocket(previous, relaySocket)) {
|
|
4159
|
+
detachSession(previous);
|
|
4160
|
+
}
|
|
538
4161
|
}
|
|
4162
|
+
const session = await createSession(relaySocket, {
|
|
4163
|
+
type: "session:init",
|
|
4164
|
+
cols: msg.cols ?? 80,
|
|
4165
|
+
rows: msg.rows ?? 24,
|
|
4166
|
+
cwd: pending.cwd,
|
|
4167
|
+
agent: "shell",
|
|
4168
|
+
clientCapabilities: []
|
|
4169
|
+
});
|
|
4170
|
+
if (!session) {
|
|
4171
|
+
break;
|
|
4172
|
+
}
|
|
4173
|
+
relaySocket.bindSession(session);
|
|
4174
|
+
sessionId = session.id;
|
|
4175
|
+
send(ws, { type: "session:ready", sessionId: session.id, reconnectToken: session.reconnectToken });
|
|
4176
|
+
setTimeout(() => writeSession(session, pending.command + `
|
|
4177
|
+
`), 400);
|
|
4178
|
+
break;
|
|
539
4179
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
4180
|
+
const existing = sessions2.get(msg.sessionId);
|
|
4181
|
+
const requestedControlMode = msg.controlMode || "owner";
|
|
4182
|
+
const canReconnect = existing && !existing.exited && existing.controlMode === requestedControlMode && verifyReconnectToken(existing, msg.reconnectToken);
|
|
4183
|
+
if (canReconnect) {
|
|
4184
|
+
if (sessionId && sessionId !== msg.sessionId) {
|
|
4185
|
+
const previous = sessions2.get(sessionId);
|
|
4186
|
+
if (previous && sessionOwnsSocket(previous, relaySocket)) {
|
|
4187
|
+
detachSession(previous);
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
if (existing.ws && existing.ws !== relaySocket) {
|
|
4191
|
+
send(existing.ws, { type: "session:detached" });
|
|
4192
|
+
}
|
|
4193
|
+
sessionId = existing.id;
|
|
4194
|
+
relaySocket.bindSession(existing);
|
|
4195
|
+
attachSession(existing, relaySocket, msg.cols, msg.rows, []);
|
|
4196
|
+
send(ws, {
|
|
4197
|
+
type: "session:ready",
|
|
4198
|
+
sessionId: existing.id,
|
|
4199
|
+
reconnectToken: existing.reconnectToken,
|
|
4200
|
+
reconnected: true
|
|
4201
|
+
});
|
|
4202
|
+
if (pending) {
|
|
4203
|
+
setTimeout(() => writeSession(existing, pending.command + `
|
|
552
4204
|
`), 400);
|
|
4205
|
+
}
|
|
4206
|
+
} else {
|
|
4207
|
+
send(ws, { type: "session:expired", sessionId: msg.sessionId });
|
|
553
4208
|
}
|
|
554
|
-
|
|
555
|
-
send(ws, { type: "session:expired", sessionId: msg.sessionId });
|
|
556
|
-
}
|
|
557
|
-
break;
|
|
558
|
-
}
|
|
559
|
-
case "terminal:input": {
|
|
560
|
-
if (!sessionId) {
|
|
561
|
-
return;
|
|
4209
|
+
break;
|
|
562
4210
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
4211
|
+
case "terminal:ack": {
|
|
4212
|
+
relaySocket.handleAck(msg.seq);
|
|
4213
|
+
break;
|
|
566
4214
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
4215
|
+
case "terminal:input": {
|
|
4216
|
+
if (!sessionId) {
|
|
4217
|
+
return;
|
|
4218
|
+
}
|
|
4219
|
+
const session = sessions2.get(sessionId);
|
|
4220
|
+
if (session && sessionOwnsSocket(session, relaySocket)) {
|
|
4221
|
+
writeSession(session, msg.data);
|
|
4222
|
+
}
|
|
4223
|
+
break;
|
|
572
4224
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
4225
|
+
case "terminal:resize": {
|
|
4226
|
+
if (!sessionId) {
|
|
4227
|
+
return;
|
|
4228
|
+
}
|
|
4229
|
+
const session = sessions2.get(sessionId);
|
|
4230
|
+
if (session && sessionOwnsSocket(session, relaySocket)) {
|
|
4231
|
+
const cols = Math.max(msg.cols || 80, 20);
|
|
4232
|
+
const rows = Math.max(msg.rows || 24, 4);
|
|
4233
|
+
resizeSession(session, cols, rows);
|
|
4234
|
+
}
|
|
4235
|
+
break;
|
|
578
4236
|
}
|
|
579
|
-
break;
|
|
580
4237
|
}
|
|
581
|
-
}
|
|
4238
|
+
})().catch((error) => {
|
|
4239
|
+
console.error("[relay] message handler failed:", error);
|
|
4240
|
+
});
|
|
582
4241
|
});
|
|
583
4242
|
ws.on("close", () => {
|
|
584
4243
|
if (!sessionId) {
|
|
585
4244
|
return;
|
|
586
4245
|
}
|
|
587
|
-
const session =
|
|
588
|
-
if (session && sessionOwnsSocket(session,
|
|
4246
|
+
const session = sessions2.get(sessionId);
|
|
4247
|
+
if (session && sessionOwnsSocket(session, relaySocket)) {
|
|
589
4248
|
detachSession(session);
|
|
590
4249
|
}
|
|
4250
|
+
relaySocket.dispose();
|
|
591
4251
|
sessionId = null;
|
|
592
4252
|
});
|
|
593
4253
|
ws.on("error", (err) => {
|
|
@@ -595,10 +4255,11 @@ wss.on("connection", (ws) => {
|
|
|
595
4255
|
if (!sessionId) {
|
|
596
4256
|
return;
|
|
597
4257
|
}
|
|
598
|
-
const session =
|
|
599
|
-
if (session && sessionOwnsSocket(session,
|
|
4258
|
+
const session = sessions2.get(sessionId);
|
|
4259
|
+
if (session && sessionOwnsSocket(session, relaySocket)) {
|
|
600
4260
|
detachSession(session);
|
|
601
4261
|
}
|
|
4262
|
+
relaySocket.dispose();
|
|
602
4263
|
sessionId = null;
|
|
603
4264
|
});
|
|
604
4265
|
});
|
|
@@ -608,7 +4269,7 @@ server.listen(port, hostname, () => {
|
|
|
608
4269
|
var shutdown = () => {
|
|
609
4270
|
console.log(`
|
|
610
4271
|
[relay] Shutting down...`);
|
|
611
|
-
for (const [id] of
|
|
4272
|
+
for (const [id] of sessions2) {
|
|
612
4273
|
destroy(id);
|
|
613
4274
|
}
|
|
614
4275
|
wss.close();
|