@openscout/scout 0.2.70 → 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 +59 -3
- package/bin/openscout-runtime.mjs +23 -2
- 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-BNV7JPhj.js → addon-fit-D4KdY-pj.js} +1 -1
- package/dist/client/assets/{addon-webgl-B-_Y5L3F.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-Cx1oABPt.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 +42 -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 +77080 -60152
- package/dist/node/main.mjs +7607 -0
- package/dist/openscout-terminal-relay.mjs +3830 -152
- package/dist/{pair-supervisor.mjs → pairing-runtime-controller.mjs} +50837 -41212
- package/dist/runtime/base-daemon.mjs +2219 -488
- package/dist/runtime/broker-daemon.mjs +39082 -25118
- package/dist/runtime/broker-process-manager.mjs +2048 -392
- package/dist/runtime/mesh-discover.mjs +2012 -391
- package/dist/scout-control-plane-web.mjs +58128 -34736
- package/dist/scout-web-server.mjs +58128 -34736
- package/dist/statusline.mjs +287 -0
- package/package.json +8 -4
- package/dist/client/assets/index-BJri_z5a.css +0 -1
- package/dist/client/assets/index-Ccjo5BZz.js +0 -199
|
@@ -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 { 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,8 +3314,13 @@ 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);
|
|
3321
|
+
if (session.backend === "tmux" && session.tmuxSession) {
|
|
3322
|
+
resizeTmuxWindow(session.tmuxSession, cols, rows);
|
|
3323
|
+
}
|
|
59
3324
|
session.cols = cols;
|
|
60
3325
|
session.rows = rows;
|
|
61
3326
|
return true;
|
|
@@ -67,13 +3332,57 @@ function resizeSession(session, cols, rows) {
|
|
|
67
3332
|
throw err;
|
|
68
3333
|
}
|
|
69
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
|
+
}
|
|
70
3379
|
function resolveCwd(raw) {
|
|
71
3380
|
const home = process.env.HOME || "/tmp";
|
|
72
3381
|
const expanded = (raw || home).replace(/^~/, home);
|
|
73
|
-
if (
|
|
3382
|
+
if (existsSync5(expanded))
|
|
74
3383
|
return expanded;
|
|
75
3384
|
try {
|
|
76
|
-
|
|
3385
|
+
mkdirSync2(expanded, { recursive: true });
|
|
77
3386
|
console.log(`[relay] Created missing cwd: ${expanded}`);
|
|
78
3387
|
return expanded;
|
|
79
3388
|
} catch {
|
|
@@ -81,26 +3390,40 @@ function resolveCwd(raw) {
|
|
|
81
3390
|
return home;
|
|
82
3391
|
}
|
|
83
3392
|
}
|
|
84
|
-
function findBin(name, envOverride) {
|
|
3393
|
+
async function findBin(name, envOverride) {
|
|
85
3394
|
if (envOverride && process.env[envOverride])
|
|
86
|
-
return process.env[envOverride];
|
|
3395
|
+
return process.env[envOverride] ?? null;
|
|
87
3396
|
try {
|
|
88
|
-
|
|
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;
|
|
89
3403
|
} catch {
|
|
90
3404
|
return null;
|
|
91
3405
|
}
|
|
92
3406
|
}
|
|
93
|
-
function findClaudeBin() {
|
|
3407
|
+
async function findClaudeBin() {
|
|
94
3408
|
return findBin("claude", "CLAUDE_BIN");
|
|
95
3409
|
}
|
|
96
|
-
function findPiBin() {
|
|
3410
|
+
async function findPiBin() {
|
|
97
3411
|
return findBin("pi", "PI_BIN");
|
|
98
3412
|
}
|
|
99
|
-
function findShellBin() {
|
|
3413
|
+
async function findShellBin() {
|
|
100
3414
|
const configured = process.env.SHELL;
|
|
101
|
-
if (configured &&
|
|
3415
|
+
if (configured && existsSync5(configured))
|
|
102
3416
|
return configured;
|
|
103
|
-
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 });
|
|
104
3427
|
}
|
|
105
3428
|
function normalizePiProviderForCli(provider) {
|
|
106
3429
|
if (!provider)
|
|
@@ -109,23 +3432,45 @@ function normalizePiProviderForCli(provider) {
|
|
|
109
3432
|
return "github-copilot";
|
|
110
3433
|
return provider;
|
|
111
3434
|
}
|
|
112
|
-
function
|
|
3435
|
+
function shellQuote(value) {
|
|
3436
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
3437
|
+
}
|
|
3438
|
+
async function resizeTmuxWindow(name, cols, rows) {
|
|
113
3439
|
try {
|
|
114
|
-
|
|
3440
|
+
await execSystemFile("tmux", [
|
|
3441
|
+
"resize-window",
|
|
3442
|
+
"-t",
|
|
3443
|
+
name,
|
|
3444
|
+
"-x",
|
|
3445
|
+
String(cols),
|
|
3446
|
+
"-y",
|
|
3447
|
+
String(rows)
|
|
3448
|
+
], { timeoutMs: 2000 });
|
|
115
3449
|
return true;
|
|
116
3450
|
} catch {
|
|
117
3451
|
return false;
|
|
118
3452
|
}
|
|
119
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
|
+
}
|
|
120
3461
|
function bootstrapFiles(cwd, files, sessionId) {
|
|
121
3462
|
for (const [relPath, content] of Object.entries(files)) {
|
|
122
|
-
const absPath =
|
|
123
|
-
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)) {
|
|
124
3469
|
try {
|
|
125
3470
|
const dir = pathDirname(absPath);
|
|
126
|
-
if (!
|
|
127
|
-
|
|
128
|
-
|
|
3471
|
+
if (!existsSync5(dir))
|
|
3472
|
+
mkdirSync2(dir, { recursive: true });
|
|
3473
|
+
writeFileSync2(absPath, content, "utf-8");
|
|
129
3474
|
console.log(`[relay] Session ${sessionId}: bootstrapped ${relPath}`);
|
|
130
3475
|
} catch (err) {
|
|
131
3476
|
console.warn(`[relay] Session ${sessionId}: failed to bootstrap ${relPath}:`, err);
|
|
@@ -133,17 +3478,36 @@ function bootstrapFiles(cwd, files, sessionId) {
|
|
|
133
3478
|
}
|
|
134
3479
|
}
|
|
135
3480
|
}
|
|
136
|
-
function spawnTmuxSession(tmuxName, cols, rows, cwd, commandBin, commandArgs, env) {
|
|
137
|
-
const exists = tmuxSessionExists(tmuxName);
|
|
3481
|
+
async function spawnTmuxSession(tmuxName, cols, rows, cwd, commandBin, commandArgs, env, controlMode) {
|
|
3482
|
+
const exists = await tmuxSessionExists(tmuxName);
|
|
138
3483
|
if (!exists) {
|
|
139
|
-
|
|
140
|
-
|
|
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" });
|
|
141
3502
|
console.log(`[relay] Created tmux session: ${tmuxName}`);
|
|
3503
|
+
if (muxTtlMs() > 0)
|
|
3504
|
+
trackCreatedMuxSession("tmux", tmuxName);
|
|
142
3505
|
} else {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
console.log(`[relay] Attaching to existing tmux session: ${tmuxName}`);
|
|
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);
|
|
147
3511
|
}
|
|
148
3512
|
return pty.spawn("tmux", ["attach", "-t", tmuxName], {
|
|
149
3513
|
name: "xterm-256color",
|
|
@@ -153,16 +3517,44 @@ function spawnTmuxSession(tmuxName, cols, rows, cwd, commandBin, commandArgs, en
|
|
|
153
3517
|
env
|
|
154
3518
|
});
|
|
155
3519
|
}
|
|
156
|
-
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) {
|
|
157
3540
|
const id = generateId();
|
|
158
3541
|
const cols = Math.max(msg.cols || 80, 20);
|
|
159
3542
|
const rows = Math.max(msg.rows || 24, 4);
|
|
160
3543
|
const backend = msg.backend || "pty";
|
|
3544
|
+
const controlMode = msg.controlMode || "owner";
|
|
161
3545
|
const tmuxName = msg.tmuxSession || `hudson-${id}`;
|
|
3546
|
+
const zellijName = msg.zellijSession || `hudson-${id}`;
|
|
162
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
|
+
}
|
|
163
3555
|
let agentBin;
|
|
164
3556
|
if (agent === "shell") {
|
|
165
|
-
agentBin = findShellBin();
|
|
3557
|
+
agentBin = await findShellBin();
|
|
166
3558
|
if (!agentBin) {
|
|
167
3559
|
const reason = "No login shell found. Set SHELL or install zsh, bash, or sh.";
|
|
168
3560
|
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
@@ -170,7 +3562,7 @@ function createSession(ws, msg) {
|
|
|
170
3562
|
return null;
|
|
171
3563
|
}
|
|
172
3564
|
} else if (agent === "pi") {
|
|
173
|
-
agentBin = findPiBin();
|
|
3565
|
+
agentBin = await findPiBin();
|
|
174
3566
|
if (!agentBin) {
|
|
175
3567
|
const reason = "pi CLI not found. Install it with: npm install -g @mariozechner/pi-coding-agent";
|
|
176
3568
|
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
@@ -178,7 +3570,7 @@ function createSession(ws, msg) {
|
|
|
178
3570
|
return null;
|
|
179
3571
|
}
|
|
180
3572
|
} else {
|
|
181
|
-
agentBin = findClaudeBin();
|
|
3573
|
+
agentBin = await findClaudeBin();
|
|
182
3574
|
if (!agentBin) {
|
|
183
3575
|
const reason = "Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code";
|
|
184
3576
|
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
@@ -186,18 +3578,25 @@ function createSession(ws, msg) {
|
|
|
186
3578
|
return null;
|
|
187
3579
|
}
|
|
188
3580
|
}
|
|
189
|
-
if (!
|
|
3581
|
+
if (!existsSync5(agentBin)) {
|
|
190
3582
|
const reason = `${agent} binary not found at ${agentBin}`;
|
|
191
3583
|
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
192
3584
|
send(ws, { type: "session:error", error: reason });
|
|
193
3585
|
return null;
|
|
194
3586
|
}
|
|
195
|
-
if (backend === "tmux" && !findBin("tmux")) {
|
|
3587
|
+
if (backend === "tmux" && !await findBin("tmux")) {
|
|
196
3588
|
const reason = "tmux not found. Install it with: brew install tmux";
|
|
197
3589
|
console.error(`[relay] Session ${id} failed: ${reason}`);
|
|
198
3590
|
send(ws, { type: "session:error", error: reason });
|
|
199
3591
|
return null;
|
|
200
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
|
+
}
|
|
201
3600
|
const cwd = resolveCwd(msg.cwd);
|
|
202
3601
|
if (msg.workspaceFiles) {
|
|
203
3602
|
bootstrapFiles(cwd, msg.workspaceFiles, id);
|
|
@@ -220,13 +3619,29 @@ function createSession(ws, msg) {
|
|
|
220
3619
|
if (msg.systemPrompt)
|
|
221
3620
|
agentArgs.push("--system-prompt", msg.systemPrompt);
|
|
222
3621
|
}
|
|
223
|
-
const env = {
|
|
3622
|
+
const env = {
|
|
3623
|
+
...process.env,
|
|
3624
|
+
...prepareZellijSocketDir(backend === "zellij" ? msg.zellijSocketDir : undefined),
|
|
3625
|
+
TERM: "xterm-256color",
|
|
3626
|
+
FORCE_COLOR: "1"
|
|
3627
|
+
};
|
|
224
3628
|
delete env.CLAUDECODE;
|
|
225
3629
|
let ptyProcess;
|
|
3630
|
+
let zellijLayoutPath;
|
|
226
3631
|
try {
|
|
227
3632
|
if (backend === "tmux") {
|
|
228
3633
|
console.log(`[relay] Session ${id}: tmux backend (session: ${tmuxName}) in ${cwd} [agent: ${agent}]`);
|
|
229
|
-
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);
|
|
230
3645
|
} else {
|
|
231
3646
|
console.log(`[relay] Session ${id}: pty backend, spawning ${agentBin} in ${cwd} [agent: ${agent}]`);
|
|
232
3647
|
ptyProcess = pty.spawn(agentBin, agentArgs, {
|
|
@@ -248,25 +3663,29 @@ function createSession(ws, msg) {
|
|
|
248
3663
|
id,
|
|
249
3664
|
pty: ptyProcess,
|
|
250
3665
|
ws,
|
|
3666
|
+
reconnectToken: generateReconnectToken(),
|
|
251
3667
|
outputBuffer: "",
|
|
252
3668
|
cols,
|
|
253
3669
|
rows,
|
|
254
3670
|
reapTimer: null,
|
|
255
3671
|
orphanTTL,
|
|
256
3672
|
backend,
|
|
3673
|
+
controlMode,
|
|
257
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),
|
|
258
3682
|
exited: false,
|
|
259
3683
|
exitCode: null
|
|
260
3684
|
};
|
|
261
3685
|
const startTime = Date.now();
|
|
262
3686
|
ptyProcess.onData((data) => {
|
|
263
|
-
session.outputBuffer
|
|
264
|
-
|
|
265
|
-
session.outputBuffer = session.outputBuffer.slice(-MAX_BUFFER_SIZE);
|
|
266
|
-
}
|
|
267
|
-
if (session.ws && session.ws.readyState === 1) {
|
|
268
|
-
send(session.ws, { type: "terminal:data", data });
|
|
269
|
-
}
|
|
3687
|
+
session.outputBuffer = truncateOutputBuffer(session.outputBuffer + data, MAX_BUFFER_SIZE);
|
|
3688
|
+
sendTerminalOutput(session, data);
|
|
270
3689
|
});
|
|
271
3690
|
ptyProcess.onExit(({ exitCode }) => {
|
|
272
3691
|
session.exited = true;
|
|
@@ -287,15 +3706,17 @@ function createSession(ws, msg) {
|
|
|
287
3706
|
}
|
|
288
3707
|
scheduleReap(session, 1e4);
|
|
289
3708
|
});
|
|
290
|
-
|
|
3709
|
+
sessions2.set(id, session);
|
|
291
3710
|
console.log(`[relay] Session ${id} created (${cols}x${rows})`);
|
|
292
3711
|
return session;
|
|
293
3712
|
}
|
|
294
|
-
function attachSession(session, ws, cols, rows) {
|
|
3713
|
+
function attachSession(session, ws, cols, rows, clientCapabilities) {
|
|
295
3714
|
if (session.reapTimer) {
|
|
296
3715
|
clearTimeout(session.reapTimer);
|
|
297
3716
|
session.reapTimer = null;
|
|
298
3717
|
}
|
|
3718
|
+
resetSessionFlowControl(session);
|
|
3719
|
+
session.flowControlEnabled = clientSupportsAck(clientCapabilities);
|
|
299
3720
|
session.ws = ws;
|
|
300
3721
|
if (cols && rows) {
|
|
301
3722
|
const c = Math.max(cols, 20);
|
|
@@ -307,11 +3728,15 @@ function attachSession(session, ws, cols, rows) {
|
|
|
307
3728
|
if (session.exited) {
|
|
308
3729
|
send(ws, { type: "session:exit", exitCode: session.exitCode });
|
|
309
3730
|
} else if (session.outputBuffer.length > 0) {
|
|
310
|
-
|
|
3731
|
+
const replayChunks = session.flowControlEnabled ? chunkReplayData(session.outputBuffer, session.flowControl.highWaterBytes) : [session.outputBuffer];
|
|
3732
|
+
for (const chunk of replayChunks) {
|
|
3733
|
+
sendTerminalOutput(session, chunk);
|
|
3734
|
+
}
|
|
311
3735
|
}
|
|
312
3736
|
console.log(`[relay] Session ${session.id} reconnected`);
|
|
313
3737
|
}
|
|
314
3738
|
function detachSession(session) {
|
|
3739
|
+
resetSessionFlowControl(session);
|
|
315
3740
|
session.ws = null;
|
|
316
3741
|
if (session.exited) {
|
|
317
3742
|
scheduleReap(session, 5000);
|
|
@@ -330,33 +3755,226 @@ function scheduleReap(session, delay) {
|
|
|
330
3755
|
}, delay);
|
|
331
3756
|
}
|
|
332
3757
|
function destroy(sessionId) {
|
|
333
|
-
const session =
|
|
3758
|
+
const session = sessions2.get(sessionId);
|
|
334
3759
|
if (!session)
|
|
335
3760
|
return;
|
|
336
3761
|
if (session.reapTimer)
|
|
337
3762
|
clearTimeout(session.reapTimer);
|
|
3763
|
+
resetSessionFlowControl(session);
|
|
338
3764
|
try {
|
|
339
3765
|
session.pty.kill();
|
|
340
3766
|
} catch {}
|
|
341
|
-
|
|
3767
|
+
if (session.zellijLayoutPath) {
|
|
3768
|
+
try {
|
|
3769
|
+
rmSync(pathDirname(session.zellijLayoutPath), { recursive: true, force: true });
|
|
3770
|
+
} catch {}
|
|
3771
|
+
}
|
|
3772
|
+
sessions2.delete(sessionId);
|
|
342
3773
|
if (session.backend === "tmux") {
|
|
343
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);
|
|
344
3781
|
} else {
|
|
345
3782
|
console.log(`[relay] Session ${sessionId} destroyed`);
|
|
346
3783
|
}
|
|
347
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
|
+
}
|
|
348
3806
|
|
|
349
3807
|
// packages/web/server/terminal-relay-node.ts
|
|
350
3808
|
process.title = "scout-relay";
|
|
351
3809
|
var require3 = createRequire2(import.meta.url);
|
|
352
3810
|
var { WebSocketServer } = require3("ws");
|
|
353
|
-
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";
|
|
354
3812
|
var port = Number.parseInt(process.env.OPENSCOUT_WEB_TERMINAL_RELAY_PORT?.trim() || "3201", 10);
|
|
355
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
|
+
}
|
|
356
3974
|
var pendingCommand = null;
|
|
357
3975
|
function queueTerminalCommand(input) {
|
|
358
3976
|
if (!input.cwd) {
|
|
359
|
-
for (const [, session] of
|
|
3977
|
+
for (const [, session] of sessions2) {
|
|
360
3978
|
if (writeSession(session, input.command + `
|
|
361
3979
|
`)) {
|
|
362
3980
|
return;
|
|
@@ -370,15 +3988,17 @@ function drainPendingCommand() {
|
|
|
370
3988
|
pendingCommand = null;
|
|
371
3989
|
return command;
|
|
372
3990
|
}
|
|
373
|
-
function
|
|
3991
|
+
function parseServerMessage(raw) {
|
|
374
3992
|
try {
|
|
375
3993
|
const message = JSON.parse(raw);
|
|
376
|
-
|
|
377
|
-
return message;
|
|
378
|
-
}
|
|
3994
|
+
return typeof message?.type === "string" ? message : null;
|
|
379
3995
|
} catch {}
|
|
380
3996
|
return null;
|
|
381
3997
|
}
|
|
3998
|
+
function parseMessage(raw) {
|
|
3999
|
+
const message = parseServerMessage(raw);
|
|
4000
|
+
return message ? message : null;
|
|
4001
|
+
}
|
|
382
4002
|
function setCors(res) {
|
|
383
4003
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
384
4004
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
@@ -402,9 +4022,6 @@ function writeJson(res, status, body) {
|
|
|
402
4022
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
403
4023
|
res.end(JSON.stringify(body));
|
|
404
4024
|
}
|
|
405
|
-
function relayUploadPath(name) {
|
|
406
|
-
return join2(UPLOAD_DIR, `${randomUUID()}-${name}`);
|
|
407
|
-
}
|
|
408
4025
|
var server = createServer(async (req, res) => {
|
|
409
4026
|
setCors(res);
|
|
410
4027
|
if (req.method === "OPTIONS") {
|
|
@@ -415,7 +4032,7 @@ var server = createServer(async (req, res) => {
|
|
|
415
4032
|
const url = new URL(req.url || "/", `http://${hostname}:${port}`);
|
|
416
4033
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
417
4034
|
let attachedSessions = 0;
|
|
418
|
-
for (const session of
|
|
4035
|
+
for (const session of sessions2.values()) {
|
|
419
4036
|
if (session.ws) {
|
|
420
4037
|
attachedSessions += 1;
|
|
421
4038
|
}
|
|
@@ -424,7 +4041,7 @@ var server = createServer(async (req, res) => {
|
|
|
424
4041
|
ok: true,
|
|
425
4042
|
surface: "openscout-terminal-relay",
|
|
426
4043
|
pid: process.pid,
|
|
427
|
-
sessions:
|
|
4044
|
+
sessions: sessions2.size,
|
|
428
4045
|
attachedSessions
|
|
429
4046
|
});
|
|
430
4047
|
return;
|
|
@@ -435,9 +4052,14 @@ var server = createServer(async (req, res) => {
|
|
|
435
4052
|
writeJson(res, 400, { error: "Missing name or data" });
|
|
436
4053
|
return;
|
|
437
4054
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 });
|
|
441
4063
|
writeJson(res, 200, { path: filepath });
|
|
442
4064
|
return;
|
|
443
4065
|
}
|
|
@@ -456,121 +4078,176 @@ var server = createServer(async (req, res) => {
|
|
|
456
4078
|
writeJson(res, 200, { ok: true });
|
|
457
4079
|
return;
|
|
458
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
|
+
}
|
|
459
4113
|
writeJson(res, 404, { error: "Not found" });
|
|
460
4114
|
});
|
|
461
|
-
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
|
+
});
|
|
462
4122
|
wss.on("connection", (ws) => {
|
|
463
4123
|
let sessionId = null;
|
|
4124
|
+
const relaySocket = new FlowControlledRelaySocket(ws);
|
|
464
4125
|
ws.on("message", (raw) => {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
switch (msg.type) {
|
|
470
|
-
case "session:init": {
|
|
471
|
-
const pending = drainPendingCommand();
|
|
472
|
-
if (sessionId) {
|
|
473
|
-
const previous = sessions.get(sessionId);
|
|
474
|
-
if (previous && sessionOwnsSocket(previous, ws)) {
|
|
475
|
-
detachSession(previous);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
const session = createSession(ws, pending?.cwd ? { ...msg, cwd: pending.cwd, agent: "shell" } : msg);
|
|
479
|
-
if (!session) {
|
|
480
|
-
break;
|
|
481
|
-
}
|
|
482
|
-
sessionId = session.id;
|
|
483
|
-
send(ws, { type: "session:ready", sessionId: session.id });
|
|
484
|
-
if (pending) {
|
|
485
|
-
setTimeout(() => writeSession(session, pending.command + `
|
|
486
|
-
`), 400);
|
|
487
|
-
}
|
|
488
|
-
break;
|
|
4126
|
+
(async () => {
|
|
4127
|
+
const msg = parseMessage(raw.toString());
|
|
4128
|
+
if (!msg) {
|
|
4129
|
+
return;
|
|
489
4130
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
4131
|
+
switch (msg.type) {
|
|
4132
|
+
case "session:init": {
|
|
4133
|
+
const pending = drainPendingCommand();
|
|
493
4134
|
if (sessionId) {
|
|
494
|
-
const previous =
|
|
495
|
-
if (previous && sessionOwnsSocket(previous,
|
|
4135
|
+
const previous = sessions2.get(sessionId);
|
|
4136
|
+
if (previous && sessionOwnsSocket(previous, relaySocket)) {
|
|
496
4137
|
detachSession(previous);
|
|
497
4138
|
}
|
|
498
4139
|
}
|
|
499
|
-
const session = createSession(
|
|
500
|
-
type: "session:init",
|
|
501
|
-
cols: msg.cols ?? 80,
|
|
502
|
-
rows: msg.rows ?? 24,
|
|
503
|
-
cwd: pending.cwd,
|
|
504
|
-
agent: "shell"
|
|
505
|
-
});
|
|
4140
|
+
const session = await createSession(relaySocket, disableGeneratedSessionFlowControl(pending?.cwd ? { ...msg, cwd: pending.cwd, agent: "shell" } : msg));
|
|
506
4141
|
if (!session) {
|
|
507
4142
|
break;
|
|
508
4143
|
}
|
|
4144
|
+
relaySocket.bindSession(session);
|
|
509
4145
|
sessionId = session.id;
|
|
510
|
-
send(ws, { type: "session:ready", sessionId: session.id });
|
|
511
|
-
|
|
4146
|
+
send(ws, { type: "session:ready", sessionId: session.id, reconnectToken: session.reconnectToken });
|
|
4147
|
+
if (pending) {
|
|
4148
|
+
setTimeout(() => writeSession(session, pending.command + `
|
|
512
4149
|
`), 400);
|
|
4150
|
+
}
|
|
513
4151
|
break;
|
|
514
4152
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
+
}
|
|
521
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;
|
|
522
4179
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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 + `
|
|
535
4204
|
`), 400);
|
|
4205
|
+
}
|
|
4206
|
+
} else {
|
|
4207
|
+
send(ws, { type: "session:expired", sessionId: msg.sessionId });
|
|
536
4208
|
}
|
|
537
|
-
|
|
538
|
-
send(ws, { type: "session:expired", sessionId: msg.sessionId });
|
|
539
|
-
}
|
|
540
|
-
break;
|
|
541
|
-
}
|
|
542
|
-
case "terminal:input": {
|
|
543
|
-
if (!sessionId) {
|
|
544
|
-
return;
|
|
4209
|
+
break;
|
|
545
4210
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
4211
|
+
case "terminal:ack": {
|
|
4212
|
+
relaySocket.handleAck(msg.seq);
|
|
4213
|
+
break;
|
|
549
4214
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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;
|
|
555
4224
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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;
|
|
561
4236
|
}
|
|
562
|
-
break;
|
|
563
4237
|
}
|
|
564
|
-
}
|
|
4238
|
+
})().catch((error) => {
|
|
4239
|
+
console.error("[relay] message handler failed:", error);
|
|
4240
|
+
});
|
|
565
4241
|
});
|
|
566
4242
|
ws.on("close", () => {
|
|
567
4243
|
if (!sessionId) {
|
|
568
4244
|
return;
|
|
569
4245
|
}
|
|
570
|
-
const session =
|
|
571
|
-
if (session && sessionOwnsSocket(session,
|
|
4246
|
+
const session = sessions2.get(sessionId);
|
|
4247
|
+
if (session && sessionOwnsSocket(session, relaySocket)) {
|
|
572
4248
|
detachSession(session);
|
|
573
4249
|
}
|
|
4250
|
+
relaySocket.dispose();
|
|
574
4251
|
sessionId = null;
|
|
575
4252
|
});
|
|
576
4253
|
ws.on("error", (err) => {
|
|
@@ -578,10 +4255,11 @@ wss.on("connection", (ws) => {
|
|
|
578
4255
|
if (!sessionId) {
|
|
579
4256
|
return;
|
|
580
4257
|
}
|
|
581
|
-
const session =
|
|
582
|
-
if (session && sessionOwnsSocket(session,
|
|
4258
|
+
const session = sessions2.get(sessionId);
|
|
4259
|
+
if (session && sessionOwnsSocket(session, relaySocket)) {
|
|
583
4260
|
detachSession(session);
|
|
584
4261
|
}
|
|
4262
|
+
relaySocket.dispose();
|
|
585
4263
|
sessionId = null;
|
|
586
4264
|
});
|
|
587
4265
|
});
|
|
@@ -591,7 +4269,7 @@ server.listen(port, hostname, () => {
|
|
|
591
4269
|
var shutdown = () => {
|
|
592
4270
|
console.log(`
|
|
593
4271
|
[relay] Shutting down...`);
|
|
594
|
-
for (const [id] of
|
|
4272
|
+
for (const [id] of sessions2) {
|
|
595
4273
|
destroy(id);
|
|
596
4274
|
}
|
|
597
4275
|
wss.close();
|