@openscout/scout 0.2.72 → 0.2.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +41 -7
  3. package/bin/scout +34 -0
  4. package/bin/scout.mjs +195 -18
  5. package/bin/scoutd +0 -0
  6. package/dist/client/apple-touch-icon.png +0 -0
  7. package/dist/client/assets/RepoDiffViewer-D8gg36W3.js +56 -0
  8. package/dist/client/assets/{addon-fit-C5ueiijZ.js → addon-fit-D4KdY-pj.js} +1 -1
  9. package/dist/client/assets/{addon-webgl-Cm7sl8RO.js → addon-webgl-Bo_tmKBp.js} +1 -1
  10. package/dist/client/assets/arc.es-D0gujIKy.js +188 -0
  11. package/dist/client/assets/embed-entry-CUVNwyB_.js +1 -0
  12. package/dist/client/assets/index-CRkjiso5.js +257 -0
  13. package/dist/client/assets/index-D_RX2cRb.css +1 -0
  14. package/dist/client/assets/{xterm-FFux-PdF.js → xterm-RfflDFod.js} +1 -1
  15. package/dist/client/favicon-16.png +0 -0
  16. package/dist/client/favicon-32.png +0 -0
  17. package/dist/client/favicon.ico +0 -0
  18. package/dist/client/favicon.svg +48 -0
  19. package/dist/client/index.html +8 -2
  20. package/dist/client/openscout-icon.png +0 -0
  21. package/dist/client/site.webmanifest +19 -0
  22. package/dist/client/web-app-icon-192.png +0 -0
  23. package/dist/client/web-app-icon-512.png +0 -0
  24. package/dist/drizzle/0000_curly_iron_monger.sql +592 -0
  25. package/dist/drizzle/0001_invocation-status-columns.sql +7 -0
  26. package/dist/drizzle/0002_invocation-flight-metadata.sql +21 -0
  27. package/dist/drizzle/README.md +81 -0
  28. package/dist/drizzle/meta/0000_snapshot.json +4575 -0
  29. package/dist/drizzle/meta/0001_snapshot.json +4624 -0
  30. package/dist/drizzle/meta/0002_snapshot.json +4631 -0
  31. package/dist/drizzle/meta/_journal.json +27 -0
  32. package/dist/main.mjs +72583 -60970
  33. package/dist/node/main.mjs +7607 -0
  34. package/dist/openscout-terminal-relay.mjs +3819 -158
  35. package/dist/{pair-supervisor.mjs → pairing-runtime-controller.mjs} +51525 -46801
  36. package/dist/runtime/base-daemon.mjs +2153 -502
  37. package/dist/runtime/broker-daemon.mjs +34730 -27036
  38. package/dist/runtime/broker-process-manager.mjs +2038 -421
  39. package/dist/runtime/mesh-discover.mjs +2002 -420
  40. package/dist/scout-control-plane-web.mjs +54522 -39921
  41. package/dist/scout-web-server.mjs +54522 -39921
  42. package/dist/statusline.mjs +287 -0
  43. package/package.json +7 -3
  44. package/dist/client/assets/RepoDiffViewer-DRzXs-TP.js +0 -1
  45. package/dist/client/assets/index-CYs6TkOv.js +0 -191
  46. package/dist/client/assets/index-n_fpvwq8.css +0 -1
@@ -2,21 +2,3246 @@
2
2
  import { createServer } from "node:http";
3
3
  import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { randomUUID } from "node:crypto";
5
- import { join as 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 { execFileSync, execSync } from "child_process";
11
- import { existsSync, mkdirSync, writeFileSync } from "fs";
12
- import { join, dirname as pathDirname } from "path";
13
3215
  var require2 = createRequire(import.meta.url);
14
- var pty = require2("@lydell/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,6 +3314,8 @@ function writeSession(session, data) {
54
3314
  function resizeSession(session, cols, rows) {
55
3315
  if (session.exited)
56
3316
  return false;
3317
+ if (session.controlMode === "observe" && session.backend !== "zellij")
3318
+ return false;
57
3319
  try {
58
3320
  session.pty.resize(cols, rows);
59
3321
  if (session.backend === "tmux" && session.tmuxSession) {
@@ -70,13 +3332,57 @@ function resizeSession(session, cols, rows) {
70
3332
  throw err;
71
3333
  }
72
3334
  }
3335
+ function isLowSurrogate(code) {
3336
+ return code >= 56320 && code <= 57343;
3337
+ }
3338
+ function truncateOutputBuffer(buffer, maxSize = MAX_BUFFER_SIZE) {
3339
+ if (buffer.length <= maxSize)
3340
+ return buffer;
3341
+ let start = buffer.length - maxSize;
3342
+ if (isLowSurrogate(buffer.charCodeAt(start)))
3343
+ start += 1;
3344
+ const scanEnd = Math.min(buffer.length, start + SAFE_TRUNCATION_WINDOW);
3345
+ for (let i = start;i < scanEnd; i++) {
3346
+ const code = buffer.charCodeAt(i);
3347
+ if (code === 10)
3348
+ return buffer.slice(i + 1);
3349
+ if (code === 27)
3350
+ return buffer.slice(i);
3351
+ }
3352
+ return buffer.slice(start);
3353
+ }
3354
+ function chunkReplayData(data, maxBytes) {
3355
+ if (!data)
3356
+ return [];
3357
+ const limit = Math.max(1, maxBytes);
3358
+ if (Buffer.byteLength(data, "utf8") <= limit)
3359
+ return [data];
3360
+ const chunks = [];
3361
+ let start = 0;
3362
+ while (start < data.length) {
3363
+ let end = Math.min(start + limit, data.length);
3364
+ for (;; ) {
3365
+ if (end > start + 1 && isLowSurrogate(data.charCodeAt(end))) {
3366
+ end -= 1;
3367
+ continue;
3368
+ }
3369
+ const bytes = Buffer.byteLength(data.slice(start, end), "utf8");
3370
+ if (bytes <= limit || end <= start + 1)
3371
+ break;
3372
+ end = start + Math.max(1, Math.floor((end - start) * limit / bytes));
3373
+ }
3374
+ chunks.push(data.slice(start, end));
3375
+ start = end;
3376
+ }
3377
+ return chunks;
3378
+ }
73
3379
  function resolveCwd(raw) {
74
3380
  const home = process.env.HOME || "/tmp";
75
3381
  const expanded = (raw || home).replace(/^~/, home);
76
- if (existsSync(expanded))
3382
+ if (existsSync5(expanded))
77
3383
  return expanded;
78
3384
  try {
79
- mkdirSync(expanded, { recursive: true });
3385
+ mkdirSync2(expanded, { recursive: true });
80
3386
  console.log(`[relay] Created missing cwd: ${expanded}`);
81
3387
  return expanded;
82
3388
  } catch {
@@ -84,26 +3390,40 @@ function resolveCwd(raw) {
84
3390
  return home;
85
3391
  }
86
3392
  }
87
- function findBin(name, envOverride) {
3393
+ async function findBin(name, envOverride) {
88
3394
  if (envOverride && process.env[envOverride])
89
- return process.env[envOverride];
3395
+ return process.env[envOverride] ?? null;
90
3396
  try {
91
- 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;
92
3403
  } catch {
93
3404
  return null;
94
3405
  }
95
3406
  }
96
- function findClaudeBin() {
3407
+ async function findClaudeBin() {
97
3408
  return findBin("claude", "CLAUDE_BIN");
98
3409
  }
99
- function findPiBin() {
3410
+ async function findPiBin() {
100
3411
  return findBin("pi", "PI_BIN");
101
3412
  }
102
- function findShellBin() {
3413
+ async function findShellBin() {
103
3414
  const configured = process.env.SHELL;
104
- if (configured && existsSync(configured))
3415
+ if (configured && existsSync5(configured))
105
3416
  return configured;
106
- 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 });
107
3427
  }
108
3428
  function normalizePiProviderForCli(provider) {
109
3429
  if (!provider)
@@ -112,17 +3432,12 @@ function normalizePiProviderForCli(provider) {
112
3432
  return "github-copilot";
113
3433
  return provider;
114
3434
  }
115
- function tmuxSessionExists(name) {
116
- try {
117
- execSync(`tmux has-session -t ${name} 2>/dev/null`, { encoding: "utf8" });
118
- return true;
119
- } catch {
120
- return false;
121
- }
3435
+ function shellQuote(value) {
3436
+ return `'${value.replace(/'/g, `'\\''`)}'`;
122
3437
  }
123
- function resizeTmuxWindow(name, cols, rows) {
3438
+ async function resizeTmuxWindow(name, cols, rows) {
124
3439
  try {
125
- execFileSync("tmux", [
3440
+ await execSystemFile("tmux", [
126
3441
  "resize-window",
127
3442
  "-t",
128
3443
  name,
@@ -130,21 +3445,32 @@ function resizeTmuxWindow(name, cols, rows) {
130
3445
  String(cols),
131
3446
  "-y",
132
3447
  String(rows)
133
- ], { stdio: "ignore" });
3448
+ ], { timeoutMs: 2000 });
134
3449
  return true;
135
3450
  } catch {
136
3451
  return false;
137
3452
  }
138
3453
  }
3454
+ function resolveBootstrapPath(cwd, relPath) {
3455
+ const base = pathResolve(cwd);
3456
+ const absPath = pathResolve(base, relPath);
3457
+ if (absPath === base || !absPath.startsWith(base + pathSep))
3458
+ return null;
3459
+ return absPath;
3460
+ }
139
3461
  function bootstrapFiles(cwd, files, sessionId) {
140
3462
  for (const [relPath, content] of Object.entries(files)) {
141
- const absPath = join(cwd, relPath);
142
- 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)) {
143
3469
  try {
144
3470
  const dir = pathDirname(absPath);
145
- if (!existsSync(dir))
146
- mkdirSync(dir, { recursive: true });
147
- writeFileSync(absPath, content, "utf-8");
3471
+ if (!existsSync5(dir))
3472
+ mkdirSync2(dir, { recursive: true });
3473
+ writeFileSync2(absPath, content, "utf-8");
148
3474
  console.log(`[relay] Session ${sessionId}: bootstrapped ${relPath}`);
149
3475
  } catch (err) {
150
3476
  console.warn(`[relay] Session ${sessionId}: failed to bootstrap ${relPath}:`, err);
@@ -152,15 +3478,36 @@ function bootstrapFiles(cwd, files, sessionId) {
152
3478
  }
153
3479
  }
154
3480
  }
155
- function spawnTmuxSession(tmuxName, cols, rows, cwd, commandBin, commandArgs, env) {
156
- const exists = tmuxSessionExists(tmuxName);
3481
+ async function spawnTmuxSession(tmuxName, cols, rows, cwd, commandBin, commandArgs, env, controlMode) {
3482
+ const exists = await tmuxSessionExists(tmuxName);
157
3483
  if (!exists) {
158
- const shellCmd = [commandBin, ...commandArgs].map((a) => a.includes(" ") ? `'${a}'` : a).join(" ");
159
- 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" });
160
3502
  console.log(`[relay] Created tmux session: ${tmuxName}`);
3503
+ if (muxTtlMs() > 0)
3504
+ trackCreatedMuxSession("tmux", tmuxName);
161
3505
  } else {
162
- resizeTmuxWindow(tmuxName, cols, rows);
163
- 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);
164
3511
  }
165
3512
  return pty.spawn("tmux", ["attach", "-t", tmuxName], {
166
3513
  name: "xterm-256color",
@@ -170,16 +3517,44 @@ function spawnTmuxSession(tmuxName, cols, rows, cwd, commandBin, commandArgs, en
170
3517
  env
171
3518
  });
172
3519
  }
173
- function 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) {
174
3540
  const id = generateId();
175
3541
  const cols = Math.max(msg.cols || 80, 20);
176
3542
  const rows = Math.max(msg.rows || 24, 4);
177
3543
  const backend = msg.backend || "pty";
3544
+ const controlMode = msg.controlMode || "owner";
178
3545
  const tmuxName = msg.tmuxSession || `hudson-${id}`;
3546
+ const zellijName = msg.zellijSession || `hudson-${id}`;
179
3547
  const agent = msg.agent || "claude";
3548
+ const multiplexerName = backend === "tmux" ? tmuxName : backend === "zellij" ? zellijName : null;
3549
+ if (multiplexerName && !isValidMultiplexerName(multiplexerName)) {
3550
+ const reason = `Invalid ${backend} session name. Use letters, digits, dashes, and underscores (max 64 chars).`;
3551
+ console.error(`[relay] Session ${id} failed: ${reason}`);
3552
+ send(ws, { type: "session:error", error: reason });
3553
+ return null;
3554
+ }
180
3555
  let agentBin;
181
3556
  if (agent === "shell") {
182
- agentBin = findShellBin();
3557
+ agentBin = await findShellBin();
183
3558
  if (!agentBin) {
184
3559
  const reason = "No login shell found. Set SHELL or install zsh, bash, or sh.";
185
3560
  console.error(`[relay] Session ${id} failed: ${reason}`);
@@ -187,7 +3562,7 @@ function createSession(ws, msg) {
187
3562
  return null;
188
3563
  }
189
3564
  } else if (agent === "pi") {
190
- agentBin = findPiBin();
3565
+ agentBin = await findPiBin();
191
3566
  if (!agentBin) {
192
3567
  const reason = "pi CLI not found. Install it with: npm install -g @mariozechner/pi-coding-agent";
193
3568
  console.error(`[relay] Session ${id} failed: ${reason}`);
@@ -195,7 +3570,7 @@ function createSession(ws, msg) {
195
3570
  return null;
196
3571
  }
197
3572
  } else {
198
- agentBin = findClaudeBin();
3573
+ agentBin = await findClaudeBin();
199
3574
  if (!agentBin) {
200
3575
  const reason = "Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code";
201
3576
  console.error(`[relay] Session ${id} failed: ${reason}`);
@@ -203,18 +3578,25 @@ function createSession(ws, msg) {
203
3578
  return null;
204
3579
  }
205
3580
  }
206
- if (!existsSync(agentBin)) {
3581
+ if (!existsSync5(agentBin)) {
207
3582
  const reason = `${agent} binary not found at ${agentBin}`;
208
3583
  console.error(`[relay] Session ${id} failed: ${reason}`);
209
3584
  send(ws, { type: "session:error", error: reason });
210
3585
  return null;
211
3586
  }
212
- if (backend === "tmux" && !findBin("tmux")) {
3587
+ if (backend === "tmux" && !await findBin("tmux")) {
213
3588
  const reason = "tmux not found. Install it with: brew install tmux";
214
3589
  console.error(`[relay] Session ${id} failed: ${reason}`);
215
3590
  send(ws, { type: "session:error", error: reason });
216
3591
  return null;
217
3592
  }
3593
+ const zellijBin2 = backend === "zellij" ? await findZellijBin() : null;
3594
+ if (backend === "zellij" && !zellijBin2) {
3595
+ const reason = "zellij not found. Install it with: brew install zellij";
3596
+ console.error(`[relay] Session ${id} failed: ${reason}`);
3597
+ send(ws, { type: "session:error", error: reason });
3598
+ return null;
3599
+ }
218
3600
  const cwd = resolveCwd(msg.cwd);
219
3601
  if (msg.workspaceFiles) {
220
3602
  bootstrapFiles(cwd, msg.workspaceFiles, id);
@@ -237,13 +3619,29 @@ function createSession(ws, msg) {
237
3619
  if (msg.systemPrompt)
238
3620
  agentArgs.push("--system-prompt", msg.systemPrompt);
239
3621
  }
240
- const env = { ...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
+ };
241
3628
  delete env.CLAUDECODE;
242
3629
  let ptyProcess;
3630
+ let zellijLayoutPath;
243
3631
  try {
244
3632
  if (backend === "tmux") {
245
3633
  console.log(`[relay] Session ${id}: tmux backend (session: ${tmuxName}) in ${cwd} [agent: ${agent}]`);
246
- ptyProcess = spawnTmuxSession(tmuxName, cols, rows, cwd, agentBin, agentArgs, env);
3634
+ ptyProcess = await spawnTmuxSession(tmuxName, cols, rows, cwd, agentBin, agentArgs, env, controlMode);
3635
+ } else if (backend === "zellij") {
3636
+ console.log(`[relay] Session ${id}: zellij backend (session: ${zellijName}, mode: ${controlMode}) in ${cwd} [agent: ${agent}]`);
3637
+ const trackZellij = muxTtlMs() > 0 && controlMode !== "observe" && !await zellijSessionExists(zellijBin2, zellijName, env);
3638
+ const spawned = spawnZellijSession(zellijBin2, zellijName, cols, rows, cwd, agentBin, agentArgs, env, controlMode);
3639
+ ptyProcess = spawned.ptyProcess;
3640
+ zellijLayoutPath = spawned.layoutPath;
3641
+ if (trackZellij)
3642
+ trackCreatedMuxSession("zellij", zellijName);
3643
+ else
3644
+ markMuxSessionInUse("zellij", zellijName);
247
3645
  } else {
248
3646
  console.log(`[relay] Session ${id}: pty backend, spawning ${agentBin} in ${cwd} [agent: ${agent}]`);
249
3647
  ptyProcess = pty.spawn(agentBin, agentArgs, {
@@ -265,25 +3663,29 @@ function createSession(ws, msg) {
265
3663
  id,
266
3664
  pty: ptyProcess,
267
3665
  ws,
3666
+ reconnectToken: generateReconnectToken(),
268
3667
  outputBuffer: "",
269
3668
  cols,
270
3669
  rows,
271
3670
  reapTimer: null,
272
3671
  orphanTTL,
273
3672
  backend,
3673
+ controlMode,
274
3674
  ...backend === "tmux" ? { tmuxSession: tmuxName } : {},
3675
+ ...backend === "zellij" ? {
3676
+ zellijSession: zellijName,
3677
+ ...msg.zellijSocketDir ? { zellijSocketDir: msg.zellijSocketDir } : {},
3678
+ ...zellijLayoutPath ? { zellijLayoutPath } : {}
3679
+ } : {},
3680
+ flowControl: createTerminalFlowControlState(),
3681
+ flowControlEnabled: clientSupportsAck(msg.clientCapabilities),
275
3682
  exited: false,
276
3683
  exitCode: null
277
3684
  };
278
3685
  const startTime = Date.now();
279
3686
  ptyProcess.onData((data) => {
280
- session.outputBuffer += data;
281
- if (session.outputBuffer.length > MAX_BUFFER_SIZE) {
282
- session.outputBuffer = session.outputBuffer.slice(-MAX_BUFFER_SIZE);
283
- }
284
- if (session.ws && session.ws.readyState === 1) {
285
- send(session.ws, { type: "terminal:data", data });
286
- }
3687
+ session.outputBuffer = truncateOutputBuffer(session.outputBuffer + data, MAX_BUFFER_SIZE);
3688
+ sendTerminalOutput(session, data);
287
3689
  });
288
3690
  ptyProcess.onExit(({ exitCode }) => {
289
3691
  session.exited = true;
@@ -304,15 +3706,17 @@ function createSession(ws, msg) {
304
3706
  }
305
3707
  scheduleReap(session, 1e4);
306
3708
  });
307
- sessions.set(id, session);
3709
+ sessions2.set(id, session);
308
3710
  console.log(`[relay] Session ${id} created (${cols}x${rows})`);
309
3711
  return session;
310
3712
  }
311
- function attachSession(session, ws, cols, rows) {
3713
+ function attachSession(session, ws, cols, rows, clientCapabilities) {
312
3714
  if (session.reapTimer) {
313
3715
  clearTimeout(session.reapTimer);
314
3716
  session.reapTimer = null;
315
3717
  }
3718
+ resetSessionFlowControl(session);
3719
+ session.flowControlEnabled = clientSupportsAck(clientCapabilities);
316
3720
  session.ws = ws;
317
3721
  if (cols && rows) {
318
3722
  const c = Math.max(cols, 20);
@@ -324,11 +3728,15 @@ function attachSession(session, ws, cols, rows) {
324
3728
  if (session.exited) {
325
3729
  send(ws, { type: "session:exit", exitCode: session.exitCode });
326
3730
  } else if (session.outputBuffer.length > 0) {
327
- 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
+ }
328
3735
  }
329
3736
  console.log(`[relay] Session ${session.id} reconnected`);
330
3737
  }
331
3738
  function detachSession(session) {
3739
+ resetSessionFlowControl(session);
332
3740
  session.ws = null;
333
3741
  if (session.exited) {
334
3742
  scheduleReap(session, 5000);
@@ -347,33 +3755,226 @@ function scheduleReap(session, delay) {
347
3755
  }, delay);
348
3756
  }
349
3757
  function destroy(sessionId) {
350
- const session = sessions.get(sessionId);
3758
+ const session = sessions2.get(sessionId);
351
3759
  if (!session)
352
3760
  return;
353
3761
  if (session.reapTimer)
354
3762
  clearTimeout(session.reapTimer);
3763
+ resetSessionFlowControl(session);
355
3764
  try {
356
3765
  session.pty.kill();
357
3766
  } catch {}
358
- 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);
359
3773
  if (session.backend === "tmux") {
360
3774
  console.log(`[relay] Session ${sessionId} bridge destroyed (tmux session '${session.tmuxSession}' still alive)`);
3775
+ if (session.tmuxSession)
3776
+ markMuxSessionDetached("tmux", session.tmuxSession);
3777
+ } else if (session.backend === "zellij") {
3778
+ console.log(`[relay] Session ${sessionId} bridge destroyed (zellij session '${session.zellijSession}' still alive)`);
3779
+ if (session.zellijSession)
3780
+ markMuxSessionDetached("zellij", session.zellijSession);
361
3781
  } else {
362
3782
  console.log(`[relay] Session ${sessionId} destroyed`);
363
3783
  }
364
3784
  }
3785
+ var trackedMuxSessions = new Map;
3786
+ function muxTtlMs() {
3787
+ const raw = Number(process.env.HUDSON_RELAY_MUX_TTL_MS || 0);
3788
+ return Number.isFinite(raw) && raw > 0 ? raw : 0;
3789
+ }
3790
+ function muxKey(backend, name) {
3791
+ return `${backend}:${name}`;
3792
+ }
3793
+ function trackCreatedMuxSession(backend, name) {
3794
+ trackedMuxSessions.set(muxKey(backend, name), { backend, name, detachedAt: null });
3795
+ }
3796
+ function markMuxSessionInUse(backend, name) {
3797
+ const record = trackedMuxSessions.get(muxKey(backend, name));
3798
+ if (record)
3799
+ record.detachedAt = null;
3800
+ }
3801
+ function markMuxSessionDetached(backend, name, now = Date.now()) {
3802
+ const record = trackedMuxSessions.get(muxKey(backend, name));
3803
+ if (record)
3804
+ record.detachedAt = now;
3805
+ }
365
3806
 
366
3807
  // packages/web/server/terminal-relay-node.ts
367
3808
  process.title = "scout-relay";
368
3809
  var require3 = createRequire2(import.meta.url);
369
3810
  var { WebSocketServer } = require3("ws");
370
- var hostname = process.env.OPENSCOUT_WEB_TERMINAL_RELAY_HOST?.trim() || 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";
371
3812
  var port = Number.parseInt(process.env.OPENSCOUT_WEB_TERMINAL_RELAY_PORT?.trim() || "3201", 10);
372
3813
  var UPLOAD_DIR = "/tmp/scout-uploads";
3814
+ var OUTPUT_BATCH_INTERVAL_MS = 8;
3815
+ var OUTPUT_BATCH_DRAIN_CONTINUE_MS = 1;
3816
+ var OUTPUT_BATCH_CHUNK_CHARS = 16 * 1024;
3817
+ var OUTPUT_BATCH_MAX_WRITES = 2;
3818
+ var OUTPUT_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024;
3819
+ var OUTPUT_IN_FLIGHT_LOW_WATER_CHARS = 256 * 1024;
3820
+ var OUTPUT_PENDING_MAX_CHARS = 2 * 1024 * 1024;
3821
+ var OUTPUT_BACKLOG_WARNING = `\x18\x1B[0m\r
3822
+ [OpenScout skipped terminal output because the relay backlog exceeded 2 MB.]\r
3823
+ `;
3824
+ function disableGeneratedSessionFlowControl(msg) {
3825
+ return { ...msg, clientCapabilities: [] };
3826
+ }
3827
+
3828
+ class FlowControlledRelaySocket {
3829
+ ws;
3830
+ session = null;
3831
+ pendingOutput = "";
3832
+ pendingFlush = null;
3833
+ nextSeq = 1;
3834
+ unacked = [];
3835
+ unackedChars = 0;
3836
+ ptyPaused = false;
3837
+ droppedBacklog = false;
3838
+ constructor(ws) {
3839
+ this.ws = ws;
3840
+ }
3841
+ get readyState() {
3842
+ return this.ws.readyState;
3843
+ }
3844
+ bindSession(session) {
3845
+ this.session = session;
3846
+ }
3847
+ send(data) {
3848
+ if (Buffer.isBuffer(data)) {
3849
+ this.sendRaw(data);
3850
+ return;
3851
+ }
3852
+ const message = parseServerMessage(data);
3853
+ if (message?.type === "terminal:data" && typeof message.data === "string") {
3854
+ this.enqueueOutput(message.data);
3855
+ return;
3856
+ }
3857
+ if (message?.type === "session:exit") {
3858
+ this.flushOutput({ ignoreBackpressure: true });
3859
+ }
3860
+ this.sendRaw(data);
3861
+ }
3862
+ handleAck(seq) {
3863
+ if (typeof seq !== "number" || !Number.isFinite(seq)) {
3864
+ return;
3865
+ }
3866
+ while (this.unacked.length > 0 && this.unacked[0].seq <= seq) {
3867
+ const acked = this.unacked.shift();
3868
+ this.unackedChars = Math.max(0, this.unackedChars - acked.chars);
3869
+ }
3870
+ if (this.ptyPaused && this.unackedChars < OUTPUT_IN_FLIGHT_LOW_WATER_CHARS) {
3871
+ this.resumePty();
3872
+ }
3873
+ if (this.pendingOutput.length > 0) {
3874
+ this.scheduleFlush(OUTPUT_BATCH_DRAIN_CONTINUE_MS);
3875
+ }
3876
+ }
3877
+ dispose() {
3878
+ if (this.pendingFlush) {
3879
+ clearTimeout(this.pendingFlush);
3880
+ this.pendingFlush = null;
3881
+ }
3882
+ this.pendingOutput = "";
3883
+ this.unacked = [];
3884
+ this.unackedChars = 0;
3885
+ this.resumePty();
3886
+ this.session = null;
3887
+ }
3888
+ enqueueOutput(data) {
3889
+ if (!data) {
3890
+ return;
3891
+ }
3892
+ this.pendingOutput += data;
3893
+ if (this.pendingOutput.length > OUTPUT_PENDING_MAX_CHARS) {
3894
+ this.pendingOutput = OUTPUT_BACKLOG_WARNING + this.pendingOutput.slice(-OUTPUT_PENDING_MAX_CHARS);
3895
+ if (!this.droppedBacklog) {
3896
+ console.warn("[relay] terminal output backlog exceeded 2 MB; keeping only the newest output");
3897
+ this.droppedBacklog = true;
3898
+ }
3899
+ }
3900
+ this.scheduleFlush(OUTPUT_BATCH_INTERVAL_MS);
3901
+ }
3902
+ scheduleFlush(delayMs) {
3903
+ if (this.pendingFlush || this.pendingOutput.length === 0) {
3904
+ return;
3905
+ }
3906
+ this.pendingFlush = setTimeout(() => {
3907
+ this.pendingFlush = null;
3908
+ this.flushOutput();
3909
+ }, delayMs);
3910
+ }
3911
+ flushOutput(options = {}) {
3912
+ if (this.pendingOutput.length === 0 || this.readyState !== 1) {
3913
+ return;
3914
+ }
3915
+ let writes = 0;
3916
+ while (this.pendingOutput.length > 0 && writes < OUTPUT_BATCH_MAX_WRITES) {
3917
+ if (!options.ignoreBackpressure && this.unackedChars >= OUTPUT_IN_FLIGHT_HIGH_WATER_CHARS) {
3918
+ this.pausePty();
3919
+ return;
3920
+ }
3921
+ const chunk = this.pendingOutput.slice(0, OUTPUT_BATCH_CHUNK_CHARS);
3922
+ this.pendingOutput = this.pendingOutput.slice(chunk.length);
3923
+ const seq = this.nextSeq++;
3924
+ this.unacked.push({ seq, chars: chunk.length });
3925
+ this.unackedChars += chunk.length;
3926
+ this.sendRaw(JSON.stringify({ type: "terminal:data", data: chunk, seq, chars: chunk.length }));
3927
+ writes++;
3928
+ }
3929
+ if (!options.ignoreBackpressure && this.unackedChars >= OUTPUT_IN_FLIGHT_HIGH_WATER_CHARS) {
3930
+ this.pausePty();
3931
+ return;
3932
+ }
3933
+ if (this.pendingOutput.length > 0) {
3934
+ this.scheduleFlush(OUTPUT_BATCH_DRAIN_CONTINUE_MS);
3935
+ } else {
3936
+ this.droppedBacklog = false;
3937
+ }
3938
+ }
3939
+ pausePty() {
3940
+ if (this.ptyPaused || !this.session || this.session.exited) {
3941
+ return;
3942
+ }
3943
+ try {
3944
+ this.session.pty.pause();
3945
+ this.ptyPaused = true;
3946
+ } catch (error) {
3947
+ console.warn("[relay] Failed to pause PTY for terminal flow control:", error);
3948
+ }
3949
+ }
3950
+ resumePty() {
3951
+ if (!this.ptyPaused || !this.session || this.session.exited) {
3952
+ this.ptyPaused = false;
3953
+ return;
3954
+ }
3955
+ try {
3956
+ this.session.pty.resume();
3957
+ } catch (error) {
3958
+ console.warn("[relay] Failed to resume PTY for terminal flow control:", error);
3959
+ } finally {
3960
+ this.ptyPaused = false;
3961
+ }
3962
+ }
3963
+ sendRaw(data) {
3964
+ if (this.ws.readyState === 1) {
3965
+ this.ws.send(data);
3966
+ }
3967
+ }
3968
+ }
3969
+ function sessionMatchesSurface(session, backend, sessionName) {
3970
+ if (!backend || !sessionName || session.backend !== backend)
3971
+ return false;
3972
+ return session.terminalSession === sessionName || session.tmuxSession === sessionName || session.zellijSession === sessionName;
3973
+ }
373
3974
  var pendingCommand = null;
374
3975
  function queueTerminalCommand(input) {
375
3976
  if (!input.cwd) {
376
- for (const [, session] of sessions) {
3977
+ for (const [, session] of sessions2) {
377
3978
  if (writeSession(session, input.command + `
378
3979
  `)) {
379
3980
  return;
@@ -387,15 +3988,17 @@ function drainPendingCommand() {
387
3988
  pendingCommand = null;
388
3989
  return command;
389
3990
  }
390
- function parseMessage(raw) {
3991
+ function parseServerMessage(raw) {
391
3992
  try {
392
3993
  const message = JSON.parse(raw);
393
- if (typeof message.type === "string") {
394
- return message;
395
- }
3994
+ return typeof message?.type === "string" ? message : null;
396
3995
  } catch {}
397
3996
  return null;
398
3997
  }
3998
+ function parseMessage(raw) {
3999
+ const message = parseServerMessage(raw);
4000
+ return message ? message : null;
4001
+ }
399
4002
  function setCors(res) {
400
4003
  res.setHeader("Access-Control-Allow-Origin", "*");
401
4004
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
@@ -419,9 +4022,6 @@ function writeJson(res, status, body) {
419
4022
  res.writeHead(status, { "Content-Type": "application/json" });
420
4023
  res.end(JSON.stringify(body));
421
4024
  }
422
- function relayUploadPath(name) {
423
- return join2(UPLOAD_DIR, `${randomUUID()}-${name}`);
424
- }
425
4025
  var server = createServer(async (req, res) => {
426
4026
  setCors(res);
427
4027
  if (req.method === "OPTIONS") {
@@ -432,7 +4032,7 @@ var server = createServer(async (req, res) => {
432
4032
  const url = new URL(req.url || "/", `http://${hostname}:${port}`);
433
4033
  if (req.method === "GET" && url.pathname === "/health") {
434
4034
  let attachedSessions = 0;
435
- for (const session of sessions.values()) {
4035
+ for (const session of sessions2.values()) {
436
4036
  if (session.ws) {
437
4037
  attachedSessions += 1;
438
4038
  }
@@ -441,7 +4041,7 @@ var server = createServer(async (req, res) => {
441
4041
  ok: true,
442
4042
  surface: "openscout-terminal-relay",
443
4043
  pid: process.pid,
444
- sessions: sessions.size,
4044
+ sessions: sessions2.size,
445
4045
  attachedSessions
446
4046
  });
447
4047
  return;
@@ -452,9 +4052,14 @@ var server = createServer(async (req, res) => {
452
4052
  writeJson(res, 400, { error: "Missing name or data" });
453
4053
  return;
454
4054
  }
455
- await mkdir(UPLOAD_DIR, { recursive: true });
456
- const filepath = relayUploadPath(body.name);
457
- 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 });
458
4063
  writeJson(res, 200, { path: filepath });
459
4064
  return;
460
4065
  }
@@ -473,121 +4078,176 @@ var server = createServer(async (req, res) => {
473
4078
  writeJson(res, 200, { ok: true });
474
4079
  return;
475
4080
  }
4081
+ if (req.method === "POST" && url.pathname === "/api/terminal/session/destroy") {
4082
+ const body = await readJson(req);
4083
+ const sessionId = body?.sessionId?.trim();
4084
+ if (!sessionId) {
4085
+ writeJson(res, 400, { error: "missing sessionId" });
4086
+ return;
4087
+ }
4088
+ const existed = sessions2.has(sessionId);
4089
+ if (existed) {
4090
+ destroy(sessionId);
4091
+ }
4092
+ writeJson(res, 200, { ok: true, destroyed: existed });
4093
+ return;
4094
+ }
4095
+ if (req.method === "POST" && url.pathname === "/api/terminal/session/destroy-surface") {
4096
+ const body = await readJson(req);
4097
+ const backend = body?.backend?.trim();
4098
+ const sessionName = body?.sessionName?.trim();
4099
+ if (!backend || !sessionName) {
4100
+ writeJson(res, 400, { error: "missing backend or sessionName" });
4101
+ return;
4102
+ }
4103
+ let destroyed = 0;
4104
+ for (const [id, session] of [...sessions2]) {
4105
+ if (!sessionMatchesSurface(session, backend, sessionName))
4106
+ continue;
4107
+ destroy(id);
4108
+ destroyed += 1;
4109
+ }
4110
+ writeJson(res, 200, { ok: true, destroyed });
4111
+ return;
4112
+ }
476
4113
  writeJson(res, 404, { error: "Not found" });
477
4114
  });
478
- var wss = new WebSocketServer({ 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
+ });
479
4122
  wss.on("connection", (ws) => {
480
4123
  let sessionId = null;
4124
+ const relaySocket = new FlowControlledRelaySocket(ws);
481
4125
  ws.on("message", (raw) => {
482
- const msg = parseMessage(raw.toString());
483
- if (!msg) {
484
- return;
485
- }
486
- switch (msg.type) {
487
- case "session:init": {
488
- const pending = drainPendingCommand();
489
- if (sessionId) {
490
- const previous = sessions.get(sessionId);
491
- if (previous && sessionOwnsSocket(previous, ws)) {
492
- detachSession(previous);
493
- }
494
- }
495
- const session = createSession(ws, pending?.cwd ? { ...msg, cwd: pending.cwd, agent: "shell" } : msg);
496
- if (!session) {
497
- break;
498
- }
499
- sessionId = session.id;
500
- send(ws, { type: "session:ready", sessionId: session.id });
501
- if (pending) {
502
- setTimeout(() => writeSession(session, pending.command + `
503
- `), 400);
504
- }
505
- break;
4126
+ (async () => {
4127
+ const msg = parseMessage(raw.toString());
4128
+ if (!msg) {
4129
+ return;
506
4130
  }
507
- case "session:reconnect": {
508
- const pending = drainPendingCommand();
509
- if (pending?.cwd) {
4131
+ switch (msg.type) {
4132
+ case "session:init": {
4133
+ const pending = drainPendingCommand();
510
4134
  if (sessionId) {
511
- const previous = sessions.get(sessionId);
512
- if (previous && sessionOwnsSocket(previous, ws)) {
4135
+ const previous = sessions2.get(sessionId);
4136
+ if (previous && sessionOwnsSocket(previous, relaySocket)) {
513
4137
  detachSession(previous);
514
4138
  }
515
4139
  }
516
- const session = createSession(ws, {
517
- type: "session:init",
518
- cols: msg.cols ?? 80,
519
- rows: msg.rows ?? 24,
520
- cwd: pending.cwd,
521
- agent: "shell"
522
- });
4140
+ const session = await createSession(relaySocket, disableGeneratedSessionFlowControl(pending?.cwd ? { ...msg, cwd: pending.cwd, agent: "shell" } : msg));
523
4141
  if (!session) {
524
4142
  break;
525
4143
  }
4144
+ relaySocket.bindSession(session);
526
4145
  sessionId = session.id;
527
- send(ws, { type: "session:ready", sessionId: session.id });
528
- 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 + `
529
4149
  `), 400);
4150
+ }
530
4151
  break;
531
4152
  }
532
- const existing = sessions.get(msg.sessionId);
533
- if (existing && !existing.exited) {
534
- if (sessionId && sessionId !== msg.sessionId) {
535
- const previous = sessions.get(sessionId);
536
- if (previous && sessionOwnsSocket(previous, ws)) {
537
- 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
+ }
538
4161
  }
4162
+ const session = await createSession(relaySocket, {
4163
+ type: "session:init",
4164
+ cols: msg.cols ?? 80,
4165
+ rows: msg.rows ?? 24,
4166
+ cwd: pending.cwd,
4167
+ agent: "shell",
4168
+ clientCapabilities: []
4169
+ });
4170
+ if (!session) {
4171
+ break;
4172
+ }
4173
+ relaySocket.bindSession(session);
4174
+ sessionId = session.id;
4175
+ send(ws, { type: "session:ready", sessionId: session.id, reconnectToken: session.reconnectToken });
4176
+ setTimeout(() => writeSession(session, pending.command + `
4177
+ `), 400);
4178
+ break;
539
4179
  }
540
- if (existing.ws && existing.ws !== ws) {
541
- send(existing.ws, { type: "session:detached" });
542
- }
543
- sessionId = existing.id;
544
- attachSession(existing, ws, msg.cols, msg.rows);
545
- send(ws, {
546
- type: "session:ready",
547
- sessionId: existing.id,
548
- reconnected: true
549
- });
550
- if (pending) {
551
- 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 + `
552
4204
  `), 400);
4205
+ }
4206
+ } else {
4207
+ send(ws, { type: "session:expired", sessionId: msg.sessionId });
553
4208
  }
554
- } else {
555
- send(ws, { type: "session:expired", sessionId: msg.sessionId });
556
- }
557
- break;
558
- }
559
- case "terminal:input": {
560
- if (!sessionId) {
561
- return;
4209
+ break;
562
4210
  }
563
- const session = sessions.get(sessionId);
564
- if (session && sessionOwnsSocket(session, ws)) {
565
- writeSession(session, msg.data);
4211
+ case "terminal:ack": {
4212
+ relaySocket.handleAck(msg.seq);
4213
+ break;
566
4214
  }
567
- break;
568
- }
569
- case "terminal:resize": {
570
- if (!sessionId) {
571
- 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;
572
4224
  }
573
- const session = sessions.get(sessionId);
574
- if (session && sessionOwnsSocket(session, ws)) {
575
- const cols = Math.max(msg.cols || 80, 20);
576
- const rows = Math.max(msg.rows || 24, 4);
577
- 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;
578
4236
  }
579
- break;
580
4237
  }
581
- }
4238
+ })().catch((error) => {
4239
+ console.error("[relay] message handler failed:", error);
4240
+ });
582
4241
  });
583
4242
  ws.on("close", () => {
584
4243
  if (!sessionId) {
585
4244
  return;
586
4245
  }
587
- const session = sessions.get(sessionId);
588
- if (session && sessionOwnsSocket(session, ws)) {
4246
+ const session = sessions2.get(sessionId);
4247
+ if (session && sessionOwnsSocket(session, relaySocket)) {
589
4248
  detachSession(session);
590
4249
  }
4250
+ relaySocket.dispose();
591
4251
  sessionId = null;
592
4252
  });
593
4253
  ws.on("error", (err) => {
@@ -595,10 +4255,11 @@ wss.on("connection", (ws) => {
595
4255
  if (!sessionId) {
596
4256
  return;
597
4257
  }
598
- const session = sessions.get(sessionId);
599
- if (session && sessionOwnsSocket(session, ws)) {
4258
+ const session = sessions2.get(sessionId);
4259
+ if (session && sessionOwnsSocket(session, relaySocket)) {
600
4260
  detachSession(session);
601
4261
  }
4262
+ relaySocket.dispose();
602
4263
  sessionId = null;
603
4264
  });
604
4265
  });
@@ -608,7 +4269,7 @@ server.listen(port, hostname, () => {
608
4269
  var shutdown = () => {
609
4270
  console.log(`
610
4271
  [relay] Shutting down...`);
611
- for (const [id] of sessions) {
4272
+ for (const [id] of sessions2) {
612
4273
  destroy(id);
613
4274
  }
614
4275
  wss.close();