@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.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +59 -3
  3. package/bin/openscout-runtime.mjs +23 -2
  4. package/bin/scout +34 -0
  5. package/bin/scout.mjs +195 -18
  6. package/bin/scoutd +0 -0
  7. package/dist/client/apple-touch-icon.png +0 -0
  8. package/dist/client/assets/RepoDiffViewer-D8gg36W3.js +56 -0
  9. package/dist/client/assets/{addon-fit-BNV7JPhj.js → addon-fit-D4KdY-pj.js} +1 -1
  10. package/dist/client/assets/{addon-webgl-B-_Y5L3F.js → addon-webgl-Bo_tmKBp.js} +1 -1
  11. package/dist/client/assets/arc.es-D0gujIKy.js +188 -0
  12. package/dist/client/assets/embed-entry-CUVNwyB_.js +1 -0
  13. package/dist/client/assets/index-CRkjiso5.js +257 -0
  14. package/dist/client/assets/index-D_RX2cRb.css +1 -0
  15. package/dist/client/assets/{xterm-Cx1oABPt.js → xterm-RfflDFod.js} +1 -1
  16. package/dist/client/favicon-16.png +0 -0
  17. package/dist/client/favicon-32.png +0 -0
  18. package/dist/client/favicon.ico +0 -0
  19. package/dist/client/favicon.svg +48 -0
  20. package/dist/client/index.html +42 -2
  21. package/dist/client/openscout-icon.png +0 -0
  22. package/dist/client/site.webmanifest +19 -0
  23. package/dist/client/web-app-icon-192.png +0 -0
  24. package/dist/client/web-app-icon-512.png +0 -0
  25. package/dist/drizzle/0000_curly_iron_monger.sql +592 -0
  26. package/dist/drizzle/0001_invocation-status-columns.sql +7 -0
  27. package/dist/drizzle/0002_invocation-flight-metadata.sql +21 -0
  28. package/dist/drizzle/README.md +81 -0
  29. package/dist/drizzle/meta/0000_snapshot.json +4575 -0
  30. package/dist/drizzle/meta/0001_snapshot.json +4624 -0
  31. package/dist/drizzle/meta/0002_snapshot.json +4631 -0
  32. package/dist/drizzle/meta/_journal.json +27 -0
  33. package/dist/main.mjs +77080 -60152
  34. package/dist/node/main.mjs +7607 -0
  35. package/dist/openscout-terminal-relay.mjs +3830 -152
  36. package/dist/{pair-supervisor.mjs → pairing-runtime-controller.mjs} +50837 -41212
  37. package/dist/runtime/base-daemon.mjs +2219 -488
  38. package/dist/runtime/broker-daemon.mjs +39082 -25118
  39. package/dist/runtime/broker-process-manager.mjs +2048 -392
  40. package/dist/runtime/mesh-discover.mjs +2012 -391
  41. package/dist/scout-control-plane-web.mjs +58128 -34736
  42. package/dist/scout-web-server.mjs +58128 -34736
  43. package/dist/statusline.mjs +287 -0
  44. package/package.json +8 -4
  45. package/dist/client/assets/index-BJri_z5a.css +0 -1
  46. 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 join2 } from "node:path";
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 = require2("node-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 sessions = new Map;
3225
+ var SAFE_TRUNCATION_WINDOW = 4096;
3226
+ var sessions2 = new Map;
18
3227
  function generateId() {
19
- return Math.random().toString(36).slice(2, 10);
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 (existsSync(expanded))
3382
+ if (existsSync5(expanded))
74
3383
  return expanded;
75
3384
  try {
76
- mkdirSync(expanded, { recursive: true });
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
- return execSync(`which ${name}`, { encoding: "utf8" }).trim() || null;
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 && existsSync(configured))
3415
+ if (configured && existsSync5(configured))
102
3416
  return configured;
103
- return findBin("zsh") ?? findBin("bash") ?? findBin("sh") ?? (existsSync("/bin/sh") ? "/bin/sh" : null);
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 tmuxSessionExists(name) {
3435
+ function shellQuote(value) {
3436
+ return `'${value.replace(/'/g, `'\\''`)}'`;
3437
+ }
3438
+ async function resizeTmuxWindow(name, cols, rows) {
113
3439
  try {
114
- execSync(`tmux has-session -t ${name} 2>/dev/null`, { encoding: "utf8" });
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 = join(cwd, relPath);
123
- if (!existsSync(absPath)) {
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 (!existsSync(dir))
127
- mkdirSync(dir, { recursive: true });
128
- writeFileSync(absPath, content, "utf-8");
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
- const shellCmd = [commandBin, ...commandArgs].map((a) => a.includes(" ") ? `'${a}'` : a).join(" ");
140
- execSync(`tmux new-session -d -s ${tmuxName} -x ${cols} -y ${rows} -c '${cwd}' '${shellCmd}'`, { env });
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
- try {
144
- execSync(`tmux resize-window -t ${tmuxName} -x ${cols} -y ${rows} 2>/dev/null`);
145
- } catch {}
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 createSession(ws, msg) {
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 (!existsSync(agentBin)) {
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 = { ...process.env, TERM: "xterm-256color", FORCE_COLOR: "1" };
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 += data;
264
- if (session.outputBuffer.length > MAX_BUFFER_SIZE) {
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
- sessions.set(id, session);
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
- send(ws, { type: "terminal:data", data: session.outputBuffer });
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 = sessions.get(sessionId);
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
- sessions.delete(sessionId);
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() || process.env.OPENSCOUT_WEB_HOST?.trim() || process.env.SCOUT_WEB_HOST?.trim() || "127.0.0.1";
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 sessions) {
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 parseMessage(raw) {
3991
+ function parseServerMessage(raw) {
374
3992
  try {
375
3993
  const message = JSON.parse(raw);
376
- if (typeof message.type === "string") {
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 sessions.values()) {
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: sessions.size,
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
- await mkdir(UPLOAD_DIR, { recursive: true });
439
- const filepath = relayUploadPath(body.name);
440
- await writeFile(filepath, Buffer.from(body.data, "base64"));
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({ server });
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
- const msg = parseMessage(raw.toString());
466
- if (!msg) {
467
- return;
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
- case "session:reconnect": {
491
- const pending = drainPendingCommand();
492
- if (pending?.cwd) {
4131
+ switch (msg.type) {
4132
+ case "session:init": {
4133
+ const pending = drainPendingCommand();
493
4134
  if (sessionId) {
494
- const previous = sessions.get(sessionId);
495
- if (previous && sessionOwnsSocket(previous, ws)) {
4135
+ const previous = sessions2.get(sessionId);
4136
+ if (previous && sessionOwnsSocket(previous, relaySocket)) {
496
4137
  detachSession(previous);
497
4138
  }
498
4139
  }
499
- const session = createSession(ws, {
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
- setTimeout(() => writeSession(session, pending.command + `
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
- const existing = sessions.get(msg.sessionId);
516
- if (existing && !existing.exited) {
517
- if (sessionId && sessionId !== msg.sessionId) {
518
- const previous = sessions.get(sessionId);
519
- if (previous && sessionOwnsSocket(previous, ws)) {
520
- detachSession(previous);
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
- if (existing.ws && existing.ws !== ws) {
524
- send(existing.ws, { type: "session:detached" });
525
- }
526
- sessionId = existing.id;
527
- attachSession(existing, ws, msg.cols, msg.rows);
528
- send(ws, {
529
- type: "session:ready",
530
- sessionId: existing.id,
531
- reconnected: true
532
- });
533
- if (pending) {
534
- setTimeout(() => writeSession(existing, pending.command + `
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
- } else {
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
- const session = sessions.get(sessionId);
547
- if (session && sessionOwnsSocket(session, ws)) {
548
- writeSession(session, msg.data);
4211
+ case "terminal:ack": {
4212
+ relaySocket.handleAck(msg.seq);
4213
+ break;
549
4214
  }
550
- break;
551
- }
552
- case "terminal:resize": {
553
- if (!sessionId) {
554
- return;
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
- const session = sessions.get(sessionId);
557
- if (session && sessionOwnsSocket(session, ws)) {
558
- const cols = Math.max(msg.cols || 80, 20);
559
- const rows = Math.max(msg.rows || 24, 4);
560
- resizeSession(session, cols, rows);
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 = sessions.get(sessionId);
571
- if (session && sessionOwnsSocket(session, ws)) {
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 = sessions.get(sessionId);
582
- if (session && sessionOwnsSocket(session, ws)) {
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 sessions) {
4272
+ for (const [id] of sessions2) {
595
4273
  destroy(id);
596
4274
  }
597
4275
  wss.close();