@openscout/scout 0.2.57 → 0.2.58

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.
@@ -5703,6 +5703,20 @@ var init_support_paths = () => {};
5703
5703
  import { existsSync as existsSync4, statSync as statSync2 } from "fs";
5704
5704
  import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
5705
5705
  import { dirname as dirname3, join as join5 } from "path";
5706
+ function buildHarnessResumeCommand(entry, sessionId, cwd) {
5707
+ if (!entry.resume)
5708
+ return null;
5709
+ const parts = [entry.resume.command, entry.resume.sessionFlag, sessionId];
5710
+ if (cwd && entry.resume.cwdFlag) {
5711
+ parts.push(entry.resume.cwdFlag, cwd);
5712
+ }
5713
+ return parts.join(" ");
5714
+ }
5715
+ function findHarnessEntry(harness) {
5716
+ if (!harness)
5717
+ return null;
5718
+ return BUILT_IN_HARNESS_CATALOG.find((e) => e.harness === harness || e.name === harness) ?? null;
5719
+ }
5706
5720
  async function writeHarnessCatalogOverrides(overrides, overridePath = resolveOpenScoutSupportPaths().harnessCatalogPath) {
5707
5721
  await mkdir2(dirname3(overridePath), { recursive: true });
5708
5722
  const payload = {
@@ -5761,6 +5775,11 @@ var init_harness_catalog = __esm(() => {
5761
5775
  loginCommand: "claude login",
5762
5776
  notReadyMessage: "Claude is installed but not authenticated yet."
5763
5777
  },
5778
+ resume: {
5779
+ command: "claude",
5780
+ sessionFlag: "--resume",
5781
+ cwdFlag: "--cwd"
5782
+ },
5764
5783
  capabilities: ["chat", "invoke", "deliver", "summarize", "review"]
5765
5784
  },
5766
5785
  {
@@ -5792,6 +5811,11 @@ var init_harness_catalog = __esm(() => {
5792
5811
  loginCommand: "codex login",
5793
5812
  notReadyMessage: "Codex is installed but not authenticated yet."
5794
5813
  },
5814
+ resume: {
5815
+ command: "codex",
5816
+ sessionFlag: "--thread",
5817
+ cwdFlag: "--cwd"
5818
+ },
5795
5819
  capabilities: ["chat", "invoke", "deliver", "review", "execute"]
5796
5820
  }
5797
5821
  ];
@@ -8056,7 +8080,8 @@ function buildManagedAgentShellExports(options) {
8056
8080
  // packages/runtime/src/claude-stream-json.ts
8057
8081
  import { randomUUID } from "crypto";
8058
8082
  import { spawn as spawn3 } from "child_process";
8059
- import { appendFile as appendFile2, mkdir as mkdir4, readFile as readFile5, rm as rm3, writeFile as writeFile4 } from "fs/promises";
8083
+ import { appendFile as appendFile2, mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
8084
+ import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
8060
8085
  import { join as join13 } from "path";
8061
8086
  function resolveClaudeStreamJsonOutput(result, fallbackParts) {
8062
8087
  const trimmedResult = result?.trim();
@@ -8092,6 +8117,53 @@ function parseJsonObject(value) {
8092
8117
  return {};
8093
8118
  }
8094
8119
  }
8120
+ function readSessionCatalogSync(runtimeDirectory) {
8121
+ const catalogPath = join13(runtimeDirectory, SESSION_CATALOG_FILENAME);
8122
+ try {
8123
+ if (!existsSync9(catalogPath)) {
8124
+ const legacyPath = join13(runtimeDirectory, "claude-session-id.txt");
8125
+ if (existsSync9(legacyPath)) {
8126
+ const legacyId = readFileSync5(legacyPath, "utf8").trim();
8127
+ if (legacyId) {
8128
+ return {
8129
+ activeSessionId: legacyId,
8130
+ sessions: [{ id: legacyId, startedAt: Date.now(), cwd: "" }]
8131
+ };
8132
+ }
8133
+ }
8134
+ return { activeSessionId: null, sessions: [] };
8135
+ }
8136
+ const raw2 = readFileSync5(catalogPath, "utf8").trim();
8137
+ if (!raw2)
8138
+ return { activeSessionId: null, sessions: [] };
8139
+ const parsed = JSON.parse(raw2);
8140
+ return {
8141
+ activeSessionId: parsed.activeSessionId ?? null,
8142
+ sessions: Array.isArray(parsed.sessions) ? parsed.sessions : []
8143
+ };
8144
+ } catch {
8145
+ return { activeSessionId: null, sessions: [] };
8146
+ }
8147
+ }
8148
+ async function readSessionCatalog(runtimeDirectory) {
8149
+ return readSessionCatalogSync(runtimeDirectory);
8150
+ }
8151
+ async function writeSessionCatalog(runtimeDirectory, catalog) {
8152
+ const catalogPath = join13(runtimeDirectory, SESSION_CATALOG_FILENAME);
8153
+ await writeFile4(catalogPath, JSON.stringify(catalog, null, 2) + `
8154
+ `);
8155
+ }
8156
+ function catalogRecordSession(catalog, sessionId, cwd) {
8157
+ const now = Date.now();
8158
+ const sessions = catalog.sessions.map((s) => s.id === catalog.activeSessionId && !s.endedAt ? { ...s, endedAt: now } : s);
8159
+ if (!sessions.some((s) => s.id === sessionId)) {
8160
+ sessions.push({ id: sessionId, startedAt: now, cwd });
8161
+ }
8162
+ while (sessions.length > SESSION_CATALOG_MAX_ENTRIES) {
8163
+ sessions.shift();
8164
+ }
8165
+ return { activeSessionId: sessionId, sessions };
8166
+ }
8095
8167
  function appendActionOutput(blockState, output) {
8096
8168
  if (!blockState || blockState.block.type !== "action" || !output) {
8097
8169
  return;
@@ -8495,7 +8567,7 @@ function buildClaudeStreamJsonSessionSnapshot(raw2, options, targetClaudeSession
8495
8567
 
8496
8568
  class ClaudeStreamJsonSession {
8497
8569
  options;
8498
- sessionStatePath;
8570
+ catalogDirectory;
8499
8571
  stdoutLogPath;
8500
8572
  stderrLogPath;
8501
8573
  process = null;
@@ -8506,7 +8578,7 @@ class ClaudeStreamJsonSession {
8506
8578
  lastConfigSignature;
8507
8579
  constructor(options) {
8508
8580
  this.options = options;
8509
- this.sessionStatePath = join13(options.runtimeDirectory, "claude-session-id.txt");
8581
+ this.catalogDirectory = options.runtimeDirectory;
8510
8582
  this.stdoutLogPath = join13(options.logsDirectory, "stdout.log");
8511
8583
  this.stderrLogPath = join13(options.logsDirectory, "stderr.log");
8512
8584
  this.lastConfigSignature = this.configSignature(options);
@@ -8612,7 +8684,9 @@ class ClaudeStreamJsonSession {
8612
8684
  }
8613
8685
  if (options.resetSession) {
8614
8686
  this.claudeSessionId = null;
8615
- await rm3(this.sessionStatePath, { force: true });
8687
+ const catalog = await readSessionCatalog(this.catalogDirectory);
8688
+ catalog.activeSessionId = null;
8689
+ await writeSessionCatalog(this.catalogDirectory, catalog);
8616
8690
  }
8617
8691
  }
8618
8692
  configSignature(options) {
@@ -8641,7 +8715,8 @@ class ClaudeStreamJsonSession {
8641
8715
  await mkdir4(this.options.runtimeDirectory, { recursive: true });
8642
8716
  await mkdir4(this.options.logsDirectory, { recursive: true });
8643
8717
  await writeFile4(join13(this.options.runtimeDirectory, "prompt.txt"), this.options.systemPrompt);
8644
- this.claudeSessionId = await readOptionalFile2(this.sessionStatePath);
8718
+ const catalog = await readSessionCatalog(this.catalogDirectory);
8719
+ this.claudeSessionId = catalog.activeSessionId;
8645
8720
  const args = [
8646
8721
  "--verbose",
8647
8722
  "--print",
@@ -8720,8 +8795,11 @@ class ClaudeStreamJsonSession {
8720
8795
  const nextSessionId = event.session_id ?? event.sessionId ?? null;
8721
8796
  if (nextSessionId && nextSessionId !== this.claudeSessionId) {
8722
8797
  this.claudeSessionId = nextSessionId;
8723
- writeFile4(this.sessionStatePath, `${nextSessionId}
8724
- `);
8798
+ (async () => {
8799
+ const catalog = await readSessionCatalog(this.catalogDirectory);
8800
+ const updated = catalogRecordSession(catalog, nextSessionId, this.options.cwd);
8801
+ await writeSessionCatalog(this.catalogDirectory, updated);
8802
+ })();
8725
8803
  }
8726
8804
  return;
8727
8805
  }
@@ -8794,29 +8872,30 @@ async function answerClaudeStreamJsonQuestion(options, input) {
8794
8872
  }
8795
8873
  async function getClaudeStreamJsonAgentSnapshot(options) {
8796
8874
  const stdoutLogPath = join13(options.logsDirectory, "stdout.log");
8797
- const sessionStatePath = join13(options.runtimeDirectory, "claude-session-id.txt");
8798
- const [rawLog, persistedSessionId] = await Promise.all([
8875
+ const [rawLog, catalog] = await Promise.all([
8799
8876
  readOptionalFile2(stdoutLogPath),
8800
- readOptionalFile2(sessionStatePath)
8877
+ readSessionCatalog(options.runtimeDirectory)
8801
8878
  ]);
8802
8879
  if (!rawLog) {
8803
8880
  return null;
8804
8881
  }
8805
- return buildClaudeStreamJsonSessionSnapshot(rawLog, options, persistedSessionId);
8882
+ return buildClaudeStreamJsonSessionSnapshot(rawLog, options, catalog.activeSessionId);
8806
8883
  }
8807
8884
  async function shutdownClaudeStreamJsonAgent(options, shutdownOptions = {}) {
8808
8885
  const key = sessionKey(options);
8809
8886
  const session = sessions.get(key);
8810
8887
  if (!session) {
8811
8888
  if (shutdownOptions.resetSession) {
8812
- await rm3(join13(options.runtimeDirectory, "claude-session-id.txt"), { force: true });
8889
+ const catalog = await readSessionCatalog(options.runtimeDirectory);
8890
+ catalog.activeSessionId = null;
8891
+ await writeSessionCatalog(options.runtimeDirectory, catalog);
8813
8892
  }
8814
8893
  return;
8815
8894
  }
8816
8895
  sessions.delete(key);
8817
8896
  await session.shutdown(shutdownOptions);
8818
8897
  }
8819
- var sessions;
8898
+ var SESSION_CATALOG_FILENAME = "session-catalog.json", SESSION_CATALOG_MAX_ENTRIES = 64, sessions;
8820
8899
  var init_claude_stream_json = __esm(() => {
8821
8900
  sessions = new Map;
8822
8901
  });
@@ -10435,7 +10514,7 @@ function buildInvocationCollaborationContextPrompt(invocation) {
10435
10514
 
10436
10515
  // packages/runtime/src/broker-service.ts
10437
10516
  import { spawnSync } from "child_process";
10438
- import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
10517
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync6, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
10439
10518
  import { homedir as homedir11 } from "os";
10440
10519
  import { basename as basename4, dirname as dirname10, join as join15, resolve as resolve5 } from "path";
10441
10520
  import { fileURLToPath as fileURLToPath5 } from "url";
@@ -10474,7 +10553,7 @@ function runtimePackageDir() {
10474
10553
  return resolve5(moduleDir, "..");
10475
10554
  }
10476
10555
  function isInstalledRuntimePackageDir(candidate) {
10477
- return existsSync9(join15(candidate, "package.json")) && existsSync9(join15(candidate, "bin", "openscout-runtime.mjs"));
10556
+ return existsSync10(join15(candidate, "package.json")) && existsSync10(join15(candidate, "bin", "openscout-runtime.mjs"));
10478
10557
  }
10479
10558
  function findGlobalRuntimeDir() {
10480
10559
  const candidates = [
@@ -10505,7 +10584,7 @@ function findWorkspaceRuntimeDir(startDir) {
10505
10584
  let current = resolve5(startDir);
10506
10585
  while (true) {
10507
10586
  const candidate = join15(current, "packages", "runtime");
10508
- if (existsSync9(join15(candidate, "package.json")) && existsSync9(join15(candidate, "src"))) {
10587
+ if (existsSync10(join15(candidate, "package.json")) && existsSync10(join15(candidate, "src"))) {
10509
10588
  return candidate;
10510
10589
  }
10511
10590
  const parent = dirname10(current);
@@ -10519,18 +10598,18 @@ function resolveBunExecutable2() {
10519
10598
  if (explicit && explicit.trim().length > 0) {
10520
10599
  return explicit;
10521
10600
  }
10522
- if (basename4(process.execPath).startsWith("bun") && existsSync9(process.execPath)) {
10601
+ if (basename4(process.execPath).startsWith("bun") && existsSync10(process.execPath)) {
10523
10602
  return process.execPath;
10524
10603
  }
10525
10604
  const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
10526
10605
  for (const entry of pathEntries) {
10527
10606
  const candidate = join15(entry, "bun");
10528
- if (existsSync9(candidate)) {
10607
+ if (existsSync10(candidate)) {
10529
10608
  return candidate;
10530
10609
  }
10531
10610
  }
10532
10611
  const homeBun = join15(homedir11(), ".bun", "bin", "bun");
10533
- if (existsSync9(homeBun)) {
10612
+ if (existsSync10(homeBun)) {
10534
10613
  return homeBun;
10535
10614
  }
10536
10615
  return "bun";
@@ -10720,10 +10799,10 @@ function launchctlPath() {
10720
10799
  return "/bin/launchctl";
10721
10800
  }
10722
10801
  function readLogLines(path) {
10723
- if (!existsSync9(path)) {
10802
+ if (!existsSync10(path)) {
10724
10803
  return [];
10725
10804
  }
10726
- return readFileSync5(path, "utf8").split(`
10805
+ return readFileSync6(path, "utf8").split(`
10727
10806
  `).map((line) => line.trim()).filter(Boolean);
10728
10807
  }
10729
10808
  function isPackageScriptBanner(line) {
@@ -10820,7 +10899,7 @@ async function brokerServiceStatus(config = resolveBrokerServiceConfig()) {
10820
10899
  ensureServiceDirectories(config);
10821
10900
  const launchctl = inspectLaunchctl(config);
10822
10901
  const health = await fetchHealthSnapshot(config);
10823
- const installed = existsSync9(config.launchAgentPath);
10902
+ const installed = existsSync10(config.launchAgentPath);
10824
10903
  const lastLogLine = health.reachable ? readLastLogLine([config.stdoutLogPath, config.stderrLogPath]) : readLastLogLine([config.stderrLogPath, config.stdoutLogPath]);
10825
10904
  return {
10826
10905
  label: config.label,
@@ -10882,7 +10961,7 @@ async function restartBrokerService(config = resolveBrokerServiceConfig()) {
10882
10961
  }
10883
10962
  async function uninstallBrokerService(config = resolveBrokerServiceConfig()) {
10884
10963
  await stopBrokerService(config);
10885
- if (existsSync9(config.launchAgentPath)) {
10964
+ if (existsSync10(config.launchAgentPath)) {
10886
10965
  rmSync2(config.launchAgentPath, { force: true });
10887
10966
  }
10888
10967
  return brokerServiceStatus(config);
@@ -10975,6 +11054,7 @@ __export(exports_local_agents, {
10975
11054
  resolveLocalAgentIdentity: () => resolveLocalAgentIdentity,
10976
11055
  resolveLocalAgentByName: () => resolveLocalAgentByName,
10977
11056
  renderLocalAgentSystemPromptTemplate: () => renderLocalAgentSystemPromptTemplate,
11057
+ normalizeLocalAgentSystemPrompt: () => normalizeLocalAgentSystemPrompt,
10978
11058
  loadRegisteredLocalAgentBindings: () => loadRegisteredLocalAgentBindings,
10979
11059
  listLocalAgents: () => listLocalAgents,
10980
11060
  isLocalAgentSessionAlive: () => isLocalAgentSessionAlive,
@@ -10998,7 +11078,7 @@ __export(exports_local_agents, {
10998
11078
  });
10999
11079
  import { randomUUID as randomUUID2 } from "crypto";
11000
11080
  import { execFileSync as execFileSync2, execSync } from "child_process";
11001
- import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
11081
+ import { existsSync as existsSync11, readFileSync as readFileSync7 } from "fs";
11002
11082
  import { mkdir as mkdir6, rm as rm5, stat as stat4, writeFile as writeFile6 } from "fs/promises";
11003
11083
  import { basename as basename5, dirname as dirname11, join as join16, resolve as resolve6 } from "path";
11004
11084
  import { fileURLToPath as fileURLToPath6 } from "url";
@@ -11011,10 +11091,10 @@ function resolveProjectsRoot(projectPath) {
11011
11091
  }
11012
11092
  try {
11013
11093
  const supportPaths = resolveOpenScoutSupportPaths();
11014
- if (!existsSync10(supportPaths.settingsPath)) {
11094
+ if (!existsSync11(supportPaths.settingsPath)) {
11015
11095
  return dirname11(projectPath);
11016
11096
  }
11017
- const raw2 = JSON.parse(readFileSync6(supportPaths.settingsPath, "utf8"));
11097
+ const raw2 = JSON.parse(readFileSync7(supportPaths.settingsPath, "utf8"));
11018
11098
  const workspaceRoot = raw2.discovery?.workspaceRoots?.find((entry) => typeof entry === "string" && entry.trim().length > 0);
11019
11099
  return workspaceRoot ? resolve6(workspaceRoot) : dirname11(projectPath);
11020
11100
  } catch {
@@ -11030,8 +11110,14 @@ function nowSeconds2() {
11030
11110
  function normalizeBrokerTimestamp(value) {
11031
11111
  return value > 10000000000 ? Math.floor(value / 1000) : value;
11032
11112
  }
11113
+ function scoutCliPath() {
11114
+ return join16(OPENSCOUT_REPO_ROOT, "packages", "cli", "bin", "scout.mjs");
11115
+ }
11116
+ function legacyNodeBrokerRelayCommand() {
11117
+ return `node ${JSON.stringify(scoutCliPath())}`;
11118
+ }
11033
11119
  function brokerRelayCommand() {
11034
- return `node ${JSON.stringify(join16(OPENSCOUT_REPO_ROOT, "packages", "cli", "bin", "scout.mjs"))}`;
11120
+ return `bun ${JSON.stringify(scoutCliPath())}`;
11035
11121
  }
11036
11122
  function sleep3(ms) {
11037
11123
  return new Promise((resolve7) => setTimeout(resolve7, ms));
@@ -11053,7 +11139,7 @@ function resolveScoutSkillPath() {
11053
11139
  join16(process.env.HOME ?? "", ".agents", "skills", "relay-agent-comms", "SKILL.md")
11054
11140
  ];
11055
11141
  for (const path of candidatePaths) {
11056
- if (existsSync10(path)) {
11142
+ if (existsSync11(path)) {
11057
11143
  return path;
11058
11144
  }
11059
11145
  }
@@ -11249,7 +11335,7 @@ function buildLegacyBrokerBackedRelayPrompt(agentId, projectName, projectPath, r
11249
11335
  function legacyLocalAgentSystemPromptCandidates(agentId, projectName, projectPath) {
11250
11336
  const relayHub = resolveRelayHub();
11251
11337
  const brokerUrl = resolveBrokerUrl();
11252
- const relayCommandBases = ["openscout relay", brokerRelayCommand()];
11338
+ const relayCommandBases = ["openscout relay", brokerRelayCommand(), legacyNodeBrokerRelayCommand()];
11253
11339
  const projectPathCandidates = projectPath.endsWith("/") ? [projectPath, projectPath.slice(0, -1)] : [projectPath, `${projectPath}/`];
11254
11340
  const candidates = new Set;
11255
11341
  for (const pathCandidate of projectPathCandidates) {
@@ -11260,12 +11346,28 @@ function legacyLocalAgentSystemPromptCandidates(agentId, projectName, projectPat
11260
11346
  }
11261
11347
  return Array.from(candidates);
11262
11348
  }
11349
+ function generatedLocalAgentSystemPromptCandidates(agentId, projectName, projectPath) {
11350
+ const baseContext = buildLocalAgentTemplateContext(agentId, projectName, projectPath);
11351
+ const relayCommands = [brokerRelayCommand(), legacyNodeBrokerRelayCommand()];
11352
+ const transportModes = [undefined, "codex_app_server", "claude_stream_json"];
11353
+ const candidates = new Set;
11354
+ for (const relayCommand of relayCommands) {
11355
+ const context = {
11356
+ ...baseContext,
11357
+ relayCommand
11358
+ };
11359
+ for (const transport of transportModes) {
11360
+ candidates.add(renderLocalAgentSystemPromptTemplate(buildLocalAgentSystemPromptTemplate(), context, transport ? { transport } : {}));
11361
+ }
11362
+ }
11363
+ return Array.from(candidates);
11364
+ }
11263
11365
  function normalizeLocalAgentSystemPrompt(agentId, projectName, projectPath, systemPrompt) {
11264
11366
  const trimmed = systemPrompt?.trim();
11265
11367
  if (!trimmed) {
11266
11368
  return;
11267
11369
  }
11268
- if (legacyLocalAgentSystemPromptCandidates(agentId, projectName, projectPath).includes(trimmed)) {
11370
+ if (legacyLocalAgentSystemPromptCandidates(agentId, projectName, projectPath).includes(trimmed) || generatedLocalAgentSystemPromptCandidates(agentId, projectName, projectPath).includes(trimmed)) {
11269
11371
  return;
11270
11372
  }
11271
11373
  return trimmed;
@@ -11617,11 +11719,13 @@ async function getLocalAgentEndpointSessionSnapshot(endpoint) {
11617
11719
  async function ensureLocalSessionEndpointOnline(endpoint) {
11618
11720
  if (endpoint.transport === "codex_app_server") {
11619
11721
  await ensureCodexAppServerAgentOnline(buildCodexEndpointSessionOptions(endpoint));
11620
- return;
11722
+ return {};
11621
11723
  }
11622
11724
  if (endpoint.transport === "claude_stream_json") {
11623
- await ensureClaudeStreamJsonAgentOnline(buildClaudeEndpointSessionOptions(endpoint));
11725
+ const result = await ensureClaudeStreamJsonAgentOnline(buildClaudeEndpointSessionOptions(endpoint));
11726
+ return { externalSessionId: result.sessionId };
11624
11727
  }
11728
+ return {};
11625
11729
  }
11626
11730
  async function shutdownLocalSessionEndpoint(endpoint) {
11627
11731
  if (endpoint.transport === "codex_app_server") {
@@ -12059,7 +12163,7 @@ async function ensureLocalAgentOnline(agentName, record) {
12059
12163
  await new Promise((resolve7) => setTimeout(resolve7, 100));
12060
12164
  }
12061
12165
  if (!isLocalAgentSessionAlive(normalizedRecord.tmuxSession)) {
12062
- const stderrTail = existsSync10(stderrLogFile) ? readFileSync6(stderrLogFile, "utf8").trim().split(/\r?\n/).slice(-10).join(`
12166
+ const stderrTail = existsSync11(stderrLogFile) ? readFileSync7(stderrLogFile, "utf8").trim().split(/\r?\n/).slice(-10).join(`
12063
12167
  `).trim() : "";
12064
12168
  throw new Error(stderrTail ? `Relay agent ${agentName} failed to stay online:
12065
12169
  ${stderrTail}` : `Relay agent ${agentName} failed to stay online.`);
@@ -12427,6 +12531,29 @@ async function restartAllLocalAgents(options = {}) {
12427
12531
  }));
12428
12532
  return restarted.filter((agent) => Boolean(agent));
12429
12533
  }
12534
+ function readPersistedClaudeSessionId(agentName) {
12535
+ try {
12536
+ const runtimeDir = relayAgentRuntimeDirectory(agentName);
12537
+ const catalogPath = join16(runtimeDir, "session-catalog.json");
12538
+ if (existsSync11(catalogPath)) {
12539
+ const raw2 = readFileSync7(catalogPath, "utf8").trim();
12540
+ if (raw2) {
12541
+ const catalog = JSON.parse(raw2);
12542
+ if (typeof catalog.activeSessionId === "string" && catalog.activeSessionId.trim()) {
12543
+ return catalog.activeSessionId.trim();
12544
+ }
12545
+ }
12546
+ }
12547
+ const legacyPath = join16(runtimeDir, "claude-session-id.txt");
12548
+ if (existsSync11(legacyPath)) {
12549
+ const value = readFileSync7(legacyPath, "utf8").trim();
12550
+ return value || null;
12551
+ }
12552
+ return null;
12553
+ } catch {
12554
+ return null;
12555
+ }
12556
+ }
12430
12557
  function buildLocalAgentBinding(agentId, record, alive, nodeId, source) {
12431
12558
  const normalizedRecord = normalizeLocalAgentRecord(agentId, record);
12432
12559
  const definitionId = normalizedRecord.definitionId ?? agentId;
@@ -12434,6 +12561,7 @@ function buildLocalAgentBinding(agentId, record, alive, nodeId, source) {
12434
12561
  const projectRoot = normalizedRecord.projectRoot ?? normalizedRecord.cwd;
12435
12562
  const instance = buildRelayAgentInstance(definitionId, projectRoot);
12436
12563
  const actorId = instance.id;
12564
+ const externalSessionId = normalizedRecord.transport === "claude_stream_json" ? readPersistedClaudeSessionId(definitionId) : null;
12437
12565
  return {
12438
12566
  actor: {
12439
12567
  id: actorId,
@@ -12511,7 +12639,8 @@ function buildLocalAgentBinding(agentId, record, alive, nodeId, source) {
12511
12639
  selector: instance.selector,
12512
12640
  nodeQualifier: instance.nodeQualifier,
12513
12641
  workspaceQualifier: instance.workspaceQualifier,
12514
- branch: instance.branch
12642
+ branch: instance.branch,
12643
+ ...externalSessionId ? { externalSessionId } : {}
12515
12644
  }
12516
12645
  }
12517
12646
  };
@@ -12711,12 +12840,12 @@ var init_local_agents = __esm(async () => {
12711
12840
  });
12712
12841
 
12713
12842
  // packages/web/server/index.ts
12714
- import { existsSync as existsSync14 } from "fs";
12843
+ import { existsSync as existsSync15 } from "fs";
12715
12844
  import { dirname as dirname13, join as join21, resolve as resolve10 } from "path";
12716
12845
  import { fileURLToPath as fileURLToPath8 } from "url";
12717
12846
 
12718
12847
  // packages/web/server/create-openscout-web-server.ts
12719
- import { existsSync as existsSync12, rmSync as rmSync3 } from "fs";
12848
+ import { existsSync as existsSync14, rmSync as rmSync3 } from "fs";
12720
12849
  import { dirname as dirname12, resolve as resolve9 } from "path";
12721
12850
  import { fileURLToPath as fileURLToPath7 } from "url";
12722
12851
 
@@ -15487,19 +15616,15 @@ function resolveDbPath() {
15487
15616
  return join12(controlHome, "control-plane.sqlite");
15488
15617
  }
15489
15618
  var _db = null;
15490
- var _dbOpenedAt = 0;
15491
- var DB_REOPEN_MS = 2000;
15619
+ var DB_BUSY_TIMEOUT_MS = 250;
15620
+ function configureReadonlyDb(db) {
15621
+ db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
15622
+ db.exec("PRAGMA query_only = ON");
15623
+ }
15492
15624
  function db() {
15493
- const now = Date.now();
15494
- if (_db && now - _dbOpenedAt > DB_REOPEN_MS) {
15495
- _db.close();
15496
- _db = null;
15497
- }
15498
15625
  if (!_db) {
15499
15626
  _db = new Database(resolveDbPath(), { readonly: true });
15500
- _db.exec("PRAGMA busy_timeout = 5000");
15501
- _db.exec("PRAGMA journal_mode = WAL");
15502
- _dbOpenedAt = now;
15627
+ configureReadonlyDb(_db);
15503
15628
  }
15504
15629
  return _db;
15505
15630
  }
@@ -15536,7 +15661,7 @@ function resolveHarnessSessionId(transport, endpointSessionId, metadata) {
15536
15661
  return metadataString(metadata, "threadId") ?? endpointSessionId;
15537
15662
  }
15538
15663
  if (transport === "claude_stream_json") {
15539
- return endpointSessionId ?? metadataString(metadata, "externalSessionId");
15664
+ return metadataString(metadata, "externalSessionId") ?? endpointSessionId;
15540
15665
  }
15541
15666
  return null;
15542
15667
  }
@@ -16714,125 +16839,67 @@ function queryFleet(opts) {
16714
16839
  activity
16715
16840
  };
16716
16841
  }
16717
- var WINDOW_TIERS = [
16718
- { ms: 30 * 60000, label: "30m" },
16719
- { ms: 60 * 60000, label: "1h" },
16720
- { ms: 6 * 60 * 60000, label: "6h" },
16721
- { ms: 24 * 60 * 60000, label: "24h" },
16722
- { ms: 7 * 24 * 60 * 60000, label: "7d" }
16723
- ];
16724
- var MIN_FILLED_BUCKETS = 3;
16725
- var _sealedCounts = new Map;
16726
- var _cachedTier = null;
16727
- function queryHeartrate(numBuckets = 48) {
16728
- const nowMs = Date.now();
16729
- let chosenTier = WINDOW_TIERS[WINDOW_TIERS.length - 1];
16730
- for (const tier of WINDOW_TIERS) {
16731
- const bucketMs2 = tier.ms / numBuckets;
16732
- const currentBucketStart2 = Math.floor(nowMs / bucketMs2) * bucketMs2;
16733
- const alignedStart2 = currentBucketStart2 - (numBuckets - 1) * bucketMs2;
16734
- if (_cachedTier === tier.label) {
16735
- let sealedHits = 0;
16736
- let filledSealed = 0;
16737
- for (let i = 0;i < numBuckets - 1; i++) {
16738
- const key = String(alignedStart2 + i * bucketMs2);
16739
- if (_sealedCounts.has(key)) {
16740
- sealedHits++;
16741
- if (_sealedCounts.get(key) > 0)
16742
- filledSealed++;
16743
- }
16744
- }
16745
- if (sealedHits === numBuckets - 1 && filledSealed >= MIN_FILLED_BUCKETS) {
16746
- chosenTier = tier;
16747
- break;
16748
- }
16749
- }
16750
- const rows2 = db().prepare(`SELECT ts FROM activity_items WHERE ts >= ? ORDER BY ts ASC`).all(alignedStart2);
16751
- const counts2 = new Array(numBuckets).fill(0);
16752
- for (const row of rows2) {
16753
- const ms = row.ts < 1000000000000 ? row.ts * 1000 : row.ts;
16754
- if (ms < alignedStart2)
16842
+ var HEARTRATE_WINDOW_MS = 7 * 24 * 60 * 60000;
16843
+ var DEFAULT_HEARTRATE_BUCKETS = 56;
16844
+ var SQLITE_MILLISECONDS_THRESHOLD = 1000000000000;
16845
+ function normalizeActivityTimestampMs(ts) {
16846
+ return ts < SQLITE_MILLISECONDS_THRESHOLD ? ts * 1000 : ts;
16847
+ }
16848
+ function smoothHeartrateCounts(counts) {
16849
+ const energy = counts.map((count) => Math.sqrt(count));
16850
+ const weights = [0.56, 0.28, 0.11, 0.05];
16851
+ return energy.map((_, index) => {
16852
+ let total = 0;
16853
+ let weightTotal = 0;
16854
+ for (let offset = -3;offset <= 3; offset++) {
16855
+ const nextIndex = index + offset;
16856
+ if (nextIndex < 0 || nextIndex >= energy.length)
16755
16857
  continue;
16756
- const idx = Math.min(numBuckets - 1, Math.floor((ms - alignedStart2) / bucketMs2));
16757
- if (idx >= 0)
16758
- counts2[idx]++;
16759
- }
16760
- const filled = counts2.filter((c) => c > 0).length;
16761
- if (filled >= MIN_FILLED_BUCKETS) {
16762
- if (_cachedTier !== tier.label) {
16763
- _sealedCounts.clear();
16764
- _cachedTier = tier.label;
16765
- }
16766
- for (let i = 0;i < numBuckets - 1; i++) {
16767
- _sealedCounts.set(String(alignedStart2 + i * bucketMs2), counts2[i]);
16768
- }
16769
- const peak2 = Math.max(1, ...counts2);
16770
- return {
16771
- windowLabel: tier.label,
16772
- buckets: counts2.map((count, i) => ({
16773
- ts: Math.round(alignedStart2 + i * bucketMs2),
16774
- count,
16775
- value: count / peak2
16776
- }))
16777
- };
16858
+ const weight = weights[Math.abs(offset)] ?? 0;
16859
+ total += energy[nextIndex] * weight;
16860
+ weightTotal += weight;
16778
16861
  }
16862
+ return weightTotal > 0 ? total / weightTotal : 0;
16863
+ });
16864
+ }
16865
+ function formatHeartrateBucketLabel(bucketMs) {
16866
+ const minutes = Math.round(bucketMs / 60000);
16867
+ if (minutes % (24 * 60) === 0) {
16868
+ return `${minutes / (24 * 60)}d buckets`;
16779
16869
  }
16780
- const bucketMs = chosenTier.ms / numBuckets;
16870
+ if (minutes % 60 === 0) {
16871
+ return `${minutes / 60}h buckets`;
16872
+ }
16873
+ return `${minutes}m buckets`;
16874
+ }
16875
+ function queryHeartrate(numBuckets = DEFAULT_HEARTRATE_BUCKETS, nowMs = Date.now()) {
16876
+ const bucketMs = HEARTRATE_WINDOW_MS / numBuckets;
16781
16877
  const currentBucketStart = Math.floor(nowMs / bucketMs) * bucketMs;
16782
16878
  const alignedStart = currentBucketStart - (numBuckets - 1) * bucketMs;
16783
- if (_cachedTier === chosenTier.label) {
16784
- const counts2 = new Array(numBuckets).fill(0);
16785
- let allSealed = true;
16786
- for (let i = 0;i < numBuckets - 1; i++) {
16787
- const key = String(alignedStart + i * bucketMs);
16788
- if (_sealedCounts.has(key)) {
16789
- counts2[i] = _sealedCounts.get(key);
16790
- } else {
16791
- allSealed = false;
16792
- break;
16793
- }
16794
- }
16795
- if (allSealed) {
16796
- const liveStart = alignedStart + (numBuckets - 1) * bucketMs;
16797
- const row = db().prepare(`SELECT COUNT(*) AS c FROM activity_items WHERE ts >= ?`).get(liveStart);
16798
- counts2[numBuckets - 1] = row.c;
16799
- const peak2 = Math.max(1, ...counts2);
16800
- return {
16801
- windowLabel: chosenTier.label,
16802
- buckets: counts2.map((count, i) => ({
16803
- ts: Math.round(alignedStart + i * bucketMs),
16804
- count,
16805
- value: count / peak2
16806
- }))
16807
- };
16808
- }
16809
- }
16810
- _sealedCounts.clear();
16811
- _cachedTier = chosenTier.label;
16812
- const rows = db().prepare(`SELECT ts FROM activity_items WHERE ts >= ? ORDER BY ts ASC`).all(alignedStart);
16879
+ const alignedStartSeconds = Math.floor(alignedStart / 1000);
16880
+ const rows = db().prepare(`SELECT ts
16881
+ FROM activity_items
16882
+ WHERE ts >= ?1
16883
+ OR (ts < ?2 AND ts >= ?3)
16884
+ ORDER BY ts ASC`).all(alignedStart, SQLITE_MILLISECONDS_THRESHOLD, alignedStartSeconds);
16813
16885
  const counts = new Array(numBuckets).fill(0);
16814
16886
  for (const row of rows) {
16815
- const ms = row.ts < 1000000000000 ? row.ts * 1000 : row.ts;
16816
- if (ms < alignedStart)
16887
+ const ms = normalizeActivityTimestampMs(row.ts);
16888
+ if (ms < alignedStart || ms > nowMs)
16817
16889
  continue;
16818
16890
  const idx = Math.min(numBuckets - 1, Math.floor((ms - alignedStart) / bucketMs));
16819
16891
  if (idx >= 0)
16820
16892
  counts[idx]++;
16821
16893
  }
16822
- for (let i = 0;i < numBuckets - 1; i++) {
16823
- _sealedCounts.set(String(alignedStart + i * bucketMs), counts[i]);
16824
- }
16825
- for (const key of _sealedCounts.keys()) {
16826
- if (Number(key) < alignedStart)
16827
- _sealedCounts.delete(key);
16828
- }
16829
- const peak = Math.max(1, ...counts);
16894
+ const smoothed = smoothHeartrateCounts(counts);
16895
+ const peak = Math.max(1, ...smoothed);
16830
16896
  return {
16831
- windowLabel: chosenTier.label,
16897
+ windowLabel: "trailing 7d",
16898
+ bucketLabel: formatHeartrateBucketLabel(bucketMs),
16832
16899
  buckets: counts.map((count, i) => ({
16833
16900
  ts: Math.round(alignedStart + i * bucketMs),
16834
16901
  count,
16835
- value: count / peak
16902
+ value: smoothed[i] / peak
16836
16903
  }))
16837
16904
  };
16838
16905
  }
@@ -17761,7 +17828,7 @@ function whoEntryState(endpoints, registrationKind) {
17761
17828
 
17762
17829
  // packages/web/server/core/observe/service.ts
17763
17830
  init_src();
17764
- import { existsSync as existsSync11, readFileSync as readFileSync7, statSync as statSync4 } from "fs";
17831
+ import { existsSync as existsSync12, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
17765
17832
  import { homedir as homedir12 } from "os";
17766
17833
  import { join as join18, resolve as resolve8 } from "path";
17767
17834
 
@@ -19749,6 +19816,9 @@ function parseSelf(status) {
19749
19816
  magicDnsSuffix: status.CurrentTailnet?.MagicDNSSuffix
19750
19817
  };
19751
19818
  }
19819
+ function isBackendRunning(status) {
19820
+ return (status.BackendState ?? "").trim().toLowerCase() === "running";
19821
+ }
19752
19822
  async function readStatusJsonFromFile(filePath) {
19753
19823
  const raw2 = await readFile7(filePath, "utf8");
19754
19824
  return parseStatusJson(raw2);
@@ -19766,19 +19836,25 @@ async function readStatusJson() {
19766
19836
  return null;
19767
19837
  }
19768
19838
  }
19769
- async function readTailscalePeers() {
19770
- const status = await readStatusJson();
19771
- if (!status) {
19772
- return [];
19839
+ async function readTailscaleSelf() {
19840
+ const summary = await readTailscaleStatusSummary();
19841
+ if (!summary) {
19842
+ return null;
19773
19843
  }
19774
- return parsePeers(status);
19844
+ return summary.self;
19775
19845
  }
19776
- async function readTailscaleSelf() {
19846
+ async function readTailscaleStatusSummary() {
19777
19847
  const status = await readStatusJson();
19778
19848
  if (!status) {
19779
19849
  return null;
19780
19850
  }
19781
- return parseSelf(status);
19851
+ return {
19852
+ backendState: status.BackendState ?? null,
19853
+ running: isBackendRunning(status),
19854
+ health: status.Health ?? [],
19855
+ peers: parsePeers(status),
19856
+ self: parseSelf(status)
19857
+ };
19782
19858
  }
19783
19859
 
19784
19860
  // packages/runtime/src/index.ts
@@ -20075,11 +20151,37 @@ function encodeClaudeProjectsSlug2(absolutePath) {
20075
20151
  }
20076
20152
  function resolveClaudeHistoryPath(cwd, sessionId) {
20077
20153
  const normalizedCwd = expandHome(cwd)?.trim();
20154
+ if (!normalizedCwd) {
20155
+ return null;
20156
+ }
20157
+ const projectDir = join18(homedir12(), ".claude", "projects", encodeClaudeProjectsSlug2(normalizedCwd));
20078
20158
  const normalizedSessionId = sessionId?.trim().replace(/\.jsonl$/u, "") || "";
20079
- if (!normalizedCwd || !normalizedSessionId) {
20159
+ if (normalizedSessionId) {
20160
+ const exactPath = join18(projectDir, `${normalizedSessionId}.jsonl`);
20161
+ if (existsSync12(exactPath)) {
20162
+ return exactPath;
20163
+ }
20164
+ }
20165
+ return findMostRecentJsonl(projectDir);
20166
+ }
20167
+ function findMostRecentJsonl(dir) {
20168
+ if (!existsSync12(dir))
20169
+ return null;
20170
+ try {
20171
+ let best = null;
20172
+ for (const entry of readdirSync2(dir)) {
20173
+ if (!entry.endsWith(".jsonl"))
20174
+ continue;
20175
+ const full = join18(dir, entry);
20176
+ const st = statSync4(full);
20177
+ if (!best || st.mtimeMs > best.mtime) {
20178
+ best = { path: full, mtime: st.mtimeMs };
20179
+ }
20180
+ }
20181
+ return best?.path ?? null;
20182
+ } catch {
20080
20183
  return null;
20081
20184
  }
20082
- return join18(homedir12(), ".claude", "projects", encodeClaudeProjectsSlug2(normalizedCwd), `${normalizedSessionId}.jsonl`);
20083
20185
  }
20084
20186
  function historyAdapterAlias(value) {
20085
20187
  const normalized = value?.trim().toLowerCase();
@@ -20143,7 +20245,7 @@ function readHistorySnapshot(candidate) {
20143
20245
  if (!supportsHistorySessionSnapshotForPath(candidate.path, candidate.adapterType)) {
20144
20246
  return null;
20145
20247
  }
20146
- if (!existsSync11(candidate.path)) {
20248
+ if (!existsSync12(candidate.path)) {
20147
20249
  return null;
20148
20250
  }
20149
20251
  const stat5 = statSync4(candidate.path);
@@ -20157,7 +20259,7 @@ function readHistorySnapshot(candidate) {
20157
20259
  }
20158
20260
  const replay = createHistorySessionSnapshot({
20159
20261
  path: candidate.path,
20160
- content: readFileSync7(candidate.path, "utf8"),
20262
+ content: readFileSync8(candidate.path, "utf8"),
20161
20263
  adapterType: candidate.adapterType,
20162
20264
  baseTimestampMs: stat5.mtimeMs
20163
20265
  });
@@ -20659,6 +20761,9 @@ async function loadAgentObservePayload(agentId) {
20659
20761
 
20660
20762
  // packages/web/server/core/mesh/service.ts
20661
20763
  await init_broker_service();
20764
+ import { execFile as execFile2 } from "child_process";
20765
+ import { promisify as promisify2 } from "util";
20766
+ var execFileAsync2 = promisify2(execFile2);
20662
20767
  function normalizeHost(host) {
20663
20768
  return host.trim().replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
20664
20769
  }
@@ -20689,9 +20794,13 @@ function stripTrailingDot(value) {
20689
20794
  return trimmed.replace(/\.$/, "");
20690
20795
  }
20691
20796
  async function readTailscaleStatus() {
20692
- const peers = await readTailscalePeers();
20797
+ const summary = await readTailscaleStatusSummary();
20798
+ const peers = summary?.peers ?? [];
20693
20799
  return {
20694
- available: peers.length > 0,
20800
+ available: summary !== null,
20801
+ running: summary?.running ?? false,
20802
+ backendState: summary?.backendState ?? null,
20803
+ health: summary?.health ?? [],
20695
20804
  peers,
20696
20805
  onlineCount: peers.filter((p) => p.online).length
20697
20806
  };
@@ -20712,6 +20821,16 @@ function computeIssues(health, localNode, nodes, tailscale2) {
20712
20821
  });
20713
20822
  return issues;
20714
20823
  }
20824
+ if (tailscale2.available && !tailscale2.running) {
20825
+ issues.push({
20826
+ code: "tailscale_stopped",
20827
+ severity: "warning",
20828
+ title: "Tailscale is stopped",
20829
+ summary: "This machine can still show cached Tailnet peers, but the local Tailscale backend is not running, so the broker cannot reach them.",
20830
+ action: "Start Tailscale on this machine, then refresh mesh discovery.",
20831
+ actionCommand: null
20832
+ });
20833
+ }
20715
20834
  if (localNode?.advertiseScope === "local") {
20716
20835
  issues.push({
20717
20836
  code: "local_only",
@@ -20879,6 +20998,24 @@ async function announceMeshVisibility() {
20879
20998
  } catch {}
20880
20999
  return loadMeshStatus();
20881
21000
  }
21001
+ async function openTailscaleApp() {
21002
+ if (process.platform !== "darwin") {
21003
+ throw new Error("Opening Tailscale from Scout is only supported on macOS right now.");
21004
+ }
21005
+ try {
21006
+ await execFileAsync2("open", ["-a", "Tailscale"]);
21007
+ } catch (error) {
21008
+ const detail = error instanceof Error ? error.message : String(error);
21009
+ throw new Error(`Scout could not open Tailscale.app on this machine. ${detail}`);
21010
+ }
21011
+ }
21012
+ async function controlTailscale(action) {
21013
+ if (action === "open_app") {
21014
+ await openTailscaleApp();
21015
+ return loadMeshStatus();
21016
+ }
21017
+ throw new Error(`Unsupported Tailscale action: ${action}`);
21018
+ }
20882
21019
 
20883
21020
  // packages/web/server/runtime-summary.ts
20884
21021
  async function loadOpenScoutWebShellState() {
@@ -20915,780 +21052,839 @@ async function loadOpenScoutWebShellState() {
20915
21052
 
20916
21053
  // packages/web/server/create-openscout-web-server.ts
20917
21054
  init_setup();
20918
- function parseOptionalPositiveInt(value, fallback) {
20919
- if (typeof value !== "string" || value.trim().length === 0) {
20920
- return fallback;
20921
- }
20922
- const parsed = Number.parseInt(value, 10);
20923
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
21055
+ init_support_paths();
21056
+ init_claude_stream_json();
21057
+ init_harness_catalog();
21058
+
21059
+ // ../hudson/packages/hudson-relay/src/relay/session.ts
21060
+ import { createRequire } from "module";
21061
+ import { execSync as execSync2 } from "child_process";
21062
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "fs";
21063
+ import { join as join19, dirname as pathDirname } from "path";
21064
+ var require2 = createRequire(import.meta.url);
21065
+ var pty = require2("node-pty");
21066
+ var DEFAULT_ORPHAN_TTL_MS = 30 * 60 * 1000;
21067
+ var MAX_BUFFER_SIZE = 512 * 1024;
21068
+ var sessions3 = new Map;
21069
+ function generateId() {
21070
+ return Math.random().toString(36).slice(2, 10);
20924
21071
  }
20925
- function inferDirectTargetAgentId(conversationId, session, senderId) {
20926
- if (session?.kind === "direct") {
20927
- if (session.agentId) {
20928
- return session.agentId;
20929
- }
20930
- const participants = session.participantIds.filter((participantId) => participantId.trim().length > 0);
20931
- if (participants.length === 2) {
20932
- const operatorCandidates = new Set([senderId.trim(), "operator"]);
20933
- const nonOperatorParticipants = participants.filter((participantId) => !operatorCandidates.has(participantId));
20934
- if (nonOperatorParticipants.length === 1) {
20935
- return nonOperatorParticipants[0] ?? null;
20936
- }
20937
- const localSessionParticipant = nonOperatorParticipants.find((participantId) => participantId.startsWith("local-session-agent-")) ?? participants.find((participantId) => participantId.startsWith("local-session-agent-"));
20938
- if (localSessionParticipant) {
20939
- return localSessionParticipant;
20940
- }
20941
- return participants[0] ?? null;
20942
- }
20943
- }
20944
- if (conversationId?.startsWith("dm.operator.")) {
20945
- const legacyAgentId = conversationId.slice("dm.operator.".length);
20946
- return legacyAgentId || null;
21072
+ function send(ws, data) {
21073
+ if (ws.readyState === 1) {
21074
+ ws.send(JSON.stringify(data));
20947
21075
  }
20948
- return null;
20949
21076
  }
20950
- function inferDirectSenderId(_session, _fallbackSenderId, _directTargetAgentId) {
20951
- return "operator";
21077
+ function resolveCwd(raw2) {
21078
+ const home = process.env.HOME || "/tmp";
21079
+ const expanded = (raw2 || home).replace(/^~/, home);
21080
+ if (existsSync13(expanded))
21081
+ return expanded;
21082
+ try {
21083
+ mkdirSync6(expanded, { recursive: true });
21084
+ console.log(`[relay] Created missing cwd: ${expanded}`);
21085
+ return expanded;
21086
+ } catch {
21087
+ console.warn(`[relay] Could not create cwd ${expanded}, falling back to ${home}`);
21088
+ return home;
21089
+ }
20952
21090
  }
20953
- function resolveConversationRouting(conversationId) {
20954
- const fallbackSenderId = "operator";
20955
- const session = conversationId ? querySessionById(conversationId) : null;
20956
- const directAgentId = inferDirectTargetAgentId(conversationId, session, fallbackSenderId);
20957
- const senderId = inferDirectSenderId(session, fallbackSenderId, directAgentId);
20958
- return { directAgentId, senderId };
21091
+ function findBin(name, envOverride) {
21092
+ if (envOverride && process.env[envOverride])
21093
+ return process.env[envOverride];
21094
+ try {
21095
+ return execSync2(`which ${name}`, { encoding: "utf8" }).trim() || null;
21096
+ } catch {
21097
+ return null;
21098
+ }
20959
21099
  }
20960
- function resolveBundledStaticClientRoot(moduleUrl = import.meta.url) {
20961
- return resolve9(dirname12(fileURLToPath7(moduleUrl)), "client");
21100
+ function findClaudeBin() {
21101
+ return findBin("claude", "CLAUDE_BIN");
20962
21102
  }
20963
- function resolveSourceStaticClientRoot(moduleUrl = import.meta.url) {
20964
- return resolve9(dirname12(fileURLToPath7(moduleUrl)), "../dist/client");
21103
+ function findPiBin() {
21104
+ return findBin("pi", "PI_BIN");
20965
21105
  }
20966
- function resolveStaticRoot(staticRoot) {
20967
- const configured = staticRoot?.trim();
20968
- if (configured) {
20969
- return configured;
21106
+ function tmuxSessionExists2(name) {
21107
+ try {
21108
+ execSync2(`tmux has-session -t ${name} 2>/dev/null`, { encoding: "utf8" });
21109
+ return true;
21110
+ } catch {
21111
+ return false;
20970
21112
  }
20971
- const bundled = resolveBundledStaticClientRoot(import.meta.url);
20972
- if (existsSync12(resolve9(bundled, "index.html"))) {
20973
- return bundled;
21113
+ }
21114
+ function bootstrapFiles(cwd, files, sessionId) {
21115
+ for (const [relPath, content] of Object.entries(files)) {
21116
+ const absPath = join19(cwd, relPath);
21117
+ if (!existsSync13(absPath)) {
21118
+ try {
21119
+ const dir = pathDirname(absPath);
21120
+ if (!existsSync13(dir))
21121
+ mkdirSync6(dir, { recursive: true });
21122
+ writeFileSync6(absPath, content, "utf-8");
21123
+ console.log(`[relay] Session ${sessionId}: bootstrapped ${relPath}`);
21124
+ } catch (err) {
21125
+ console.warn(`[relay] Session ${sessionId}: failed to bootstrap ${relPath}:`, err);
21126
+ }
21127
+ }
20974
21128
  }
20975
- return resolveSourceStaticClientRoot(import.meta.url);
20976
21129
  }
20977
- async function loadPairingState(currentDirectory, refresh) {
20978
- return refresh ? refreshScoutWebPairingState(currentDirectory) : getScoutWebPairingState(currentDirectory);
21130
+ function spawnTmuxSession(tmuxName, cols, rows, cwd, claudeBin, claudeArgs, env) {
21131
+ const exists = tmuxSessionExists2(tmuxName);
21132
+ if (!exists) {
21133
+ const shellCmd = [claudeBin, ...claudeArgs].map((a) => a.includes(" ") ? `'${a}'` : a).join(" ");
21134
+ execSync2(`tmux new-session -d -s ${tmuxName} -x ${cols} -y ${rows} -c '${cwd}' '${shellCmd}'`, { env });
21135
+ console.log(`[relay] Created tmux session: ${tmuxName}`);
21136
+ } else {
21137
+ try {
21138
+ execSync2(`tmux resize-window -t ${tmuxName} -x ${cols} -y ${rows} 2>/dev/null`);
21139
+ } catch {}
21140
+ console.log(`[relay] Attaching to existing tmux session: ${tmuxName}`);
21141
+ }
21142
+ return pty.spawn("tmux", ["attach", "-t", tmuxName], {
21143
+ name: "xterm-256color",
21144
+ cols,
21145
+ rows,
21146
+ cwd,
21147
+ env
21148
+ });
20979
21149
  }
20980
- async function createOpenScoutWebServer(options) {
20981
- const shellTtl = options.shellStateCacheTtlMs ?? 15000;
20982
- const currentDirectory = options.currentDirectory;
20983
- const app = new Hono2;
20984
- const shellStateCache = createCachedSnapshot(loadOpenScoutWebShellState, shellTtl);
20985
- installScoutApiMiddleware(app, "openscout-web api");
20986
- app.get("/api/health", (c) => c.json({
20987
- ok: true,
20988
- surface: "openscout-web",
20989
- currentDirectory
20990
- }));
20991
- app.get("/api/pairing-state", async (c) => c.json(await loadPairingState(currentDirectory, false)));
20992
- app.get("/api/pairing-state/refresh", async (c) => c.json(await loadPairingState(currentDirectory, true)));
20993
- app.post("/api/pairing/control", async (c) => {
20994
- const { action } = await c.req.json();
20995
- const result = await controlScoutWebPairingService(action, currentDirectory);
20996
- shellStateCache.invalidate();
20997
- return c.json(result);
20998
- });
20999
- app.delete("/api/pairing/peers/:fingerprint", async (c) => {
21000
- const fingerprint = c.req.param("fingerprint");
21001
- const removed = removeScoutPairingTrustedPeer(fingerprint);
21002
- if (!removed) {
21003
- return c.json({ error: "Peer not found" }, 404);
21150
+ function createSession(ws, msg) {
21151
+ const id = generateId();
21152
+ const cols = Math.max(msg.cols || 80, 20);
21153
+ const rows = Math.max(msg.rows || 24, 4);
21154
+ const backend = msg.backend || "pty";
21155
+ const tmuxName = msg.tmuxSession || `hudson-${id}`;
21156
+ const agent = msg.agent || "claude";
21157
+ let agentBin;
21158
+ if (agent === "pi") {
21159
+ agentBin = findPiBin();
21160
+ if (!agentBin) {
21161
+ const reason = "pi CLI not found. Install it with: npm install -g @mariozechner/pi-coding-agent";
21162
+ console.error(`[relay] Session ${id} failed: ${reason}`);
21163
+ send(ws, { type: "session:error", error: reason });
21164
+ return null;
21004
21165
  }
21005
- return c.json({ ok: true });
21006
- });
21007
- app.get("/api/shell-state", async (c) => c.json(await shellStateCache.get()));
21008
- app.get("/api/shell-state/refresh", async (c) => c.json(await shellStateCache.refresh()));
21009
- app.get("/api/agents", (c) => c.json(queryAgents()));
21010
- app.get("/api/observe/agents", async (c) => {
21011
- const ids = c.req.query("ids")?.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
21012
- return c.json(await loadAgentObserveSummaries(ids));
21013
- });
21014
- app.get("/api/agents/:id/observe", async (c) => {
21015
- const payload = await loadAgentObservePayload(c.req.param("id"));
21016
- return payload ? c.json(payload) : c.json({ error: "not found" }, 404);
21017
- });
21018
- app.get("/api/activity", (c) => c.json(queryActivity()));
21019
- app.get("/api/heartrate", (c) => c.json(queryHeartrate()));
21020
- app.get("/api/fleet", (c) => c.json(queryFleet({
21021
- limit: parseOptionalPositiveInt(c.req.query("limit")),
21022
- activityLimit: parseOptionalPositiveInt(c.req.query("activityLimit"))
21023
- })));
21024
- app.get("/api/messages", (c) => c.json(queryRecentMessages(parseOptionalPositiveInt(c.req.query("limit"), 80) ?? 80, { conversationId: c.req.query("conversationId") || undefined })));
21025
- const handleListWork = (c) => {
21026
- const agentId = c.req.query("agentId");
21027
- const activeOnly = c.req.query("active") !== "false";
21028
- return c.json(queryWorkItems({
21029
- agentId: agentId || undefined,
21030
- activeOnly
21031
- }));
21032
- };
21033
- const handleWorkDetail = (c) => {
21034
- const workId = c.req.param("id");
21035
- if (!workId) {
21036
- return c.json({ error: "id is required" }, 400);
21166
+ } else {
21167
+ agentBin = findClaudeBin();
21168
+ if (!agentBin) {
21169
+ const reason = "Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code";
21170
+ console.error(`[relay] Session ${id} failed: ${reason}`);
21171
+ send(ws, { type: "session:error", error: reason });
21172
+ return null;
21037
21173
  }
21038
- const detail = queryWorkItemById(workId);
21039
- return detail ? c.json(detail) : c.json({ error: "not found" }, 404);
21174
+ }
21175
+ if (!existsSync13(agentBin)) {
21176
+ const reason = `${agent} binary not found at ${agentBin}`;
21177
+ console.error(`[relay] Session ${id} failed: ${reason}`);
21178
+ send(ws, { type: "session:error", error: reason });
21179
+ return null;
21180
+ }
21181
+ if (backend === "tmux" && !findBin("tmux")) {
21182
+ const reason = "tmux not found. Install it with: brew install tmux";
21183
+ console.error(`[relay] Session ${id} failed: ${reason}`);
21184
+ send(ws, { type: "session:error", error: reason });
21185
+ return null;
21186
+ }
21187
+ const cwd = resolveCwd(msg.cwd);
21188
+ if (msg.workspaceFiles) {
21189
+ bootstrapFiles(cwd, msg.workspaceFiles, id);
21190
+ }
21191
+ let agentArgs;
21192
+ if (agent === "pi") {
21193
+ agentArgs = ["--verbose"];
21194
+ if (msg.provider)
21195
+ agentArgs.push("--provider", msg.provider);
21196
+ if (msg.model)
21197
+ agentArgs.push("--model", msg.model);
21198
+ if (msg.systemPrompt)
21199
+ agentArgs.push("--system-prompt", msg.systemPrompt);
21200
+ } else {
21201
+ agentArgs = ["--verbose"];
21202
+ if (msg.systemPrompt)
21203
+ agentArgs.push("--system-prompt", msg.systemPrompt);
21204
+ }
21205
+ const env = { ...process.env, TERM: "xterm-256color", FORCE_COLOR: "1" };
21206
+ delete env.CLAUDECODE;
21207
+ let ptyProcess;
21208
+ try {
21209
+ if (backend === "tmux") {
21210
+ console.log(`[relay] Session ${id}: tmux backend (session: ${tmuxName}) in ${cwd} [agent: ${agent}]`);
21211
+ ptyProcess = spawnTmuxSession(tmuxName, cols, rows, cwd, agentBin, agentArgs, env);
21212
+ } else {
21213
+ console.log(`[relay] Session ${id}: pty backend, spawning ${agentBin} in ${cwd} [agent: ${agent}]`);
21214
+ ptyProcess = pty.spawn(agentBin, agentArgs, {
21215
+ name: "xterm-256color",
21216
+ cols,
21217
+ rows,
21218
+ cwd,
21219
+ env
21220
+ });
21221
+ }
21222
+ } catch (err) {
21223
+ const message = err instanceof Error ? err.message : String(err);
21224
+ console.error(`[relay] Session ${id}: failed to spawn PTY \u2014 ${message}`);
21225
+ send(ws, { type: "session:error", error: `Failed to spawn terminal: ${message}` });
21226
+ return null;
21227
+ }
21228
+ const orphanTTL = msg.orphanTTL && msg.orphanTTL > 0 ? msg.orphanTTL : DEFAULT_ORPHAN_TTL_MS;
21229
+ const session = {
21230
+ id,
21231
+ pty: ptyProcess,
21232
+ ws,
21233
+ outputBuffer: "",
21234
+ cols,
21235
+ rows,
21236
+ reapTimer: null,
21237
+ orphanTTL,
21238
+ backend,
21239
+ ...backend === "tmux" ? { tmuxSession: tmuxName } : {},
21240
+ exited: false,
21241
+ exitCode: null
21040
21242
  };
21041
- app.get("/api/work", handleListWork);
21042
- app.get("/api/tasks", handleListWork);
21043
- app.get("/api/work/:id", handleWorkDetail);
21044
- app.get("/api/tasks/:id", handleWorkDetail);
21045
- app.get("/api/flights", (c) => {
21046
- const agentId = c.req.query("agentId");
21047
- const conversationId = c.req.query("conversationId");
21048
- const collaborationRecordId = c.req.query("collaborationRecordId");
21049
- const activeOnly = c.req.query("active") !== "false";
21050
- return c.json(queryFlights({
21051
- agentId: agentId || undefined,
21052
- conversationId: conversationId || undefined,
21053
- collaborationRecordId: collaborationRecordId || undefined,
21054
- activeOnly
21055
- }));
21056
- });
21057
- app.get("/api/sessions", (c) => c.json(querySessions()));
21058
- app.get("/api/session/:id", (c) => {
21059
- const session = querySessionById(c.req.param("id"));
21060
- return session ? c.json(session) : c.json({ error: "not found" }, 404);
21061
- });
21062
- app.get("/api/mesh", async (c) => {
21063
- try {
21064
- return c.json(await loadMeshStatus());
21065
- } catch (err) {
21066
- const message = err instanceof Error ? err.message : String(err);
21067
- return c.json({ error: message }, 500);
21243
+ const startTime = Date.now();
21244
+ ptyProcess.onData((data) => {
21245
+ session.outputBuffer += data;
21246
+ if (session.outputBuffer.length > MAX_BUFFER_SIZE) {
21247
+ session.outputBuffer = session.outputBuffer.slice(-MAX_BUFFER_SIZE);
21068
21248
  }
21069
- });
21070
- app.post("/api/mesh/announce", async (c) => {
21071
- try {
21072
- return c.json(await announceMeshVisibility());
21073
- } catch (err) {
21074
- const message = err instanceof Error ? err.message : String(err);
21075
- return c.json({ error: message }, 500);
21249
+ if (session.ws && session.ws.readyState === 1) {
21250
+ send(session.ws, { type: "terminal:data", data });
21076
21251
  }
21077
21252
  });
21078
- app.get("/api/user", (c) => {
21079
- const config = loadUserConfig();
21080
- return c.json({
21081
- name: resolveOperatorName(),
21082
- handle: config.handle ?? "",
21083
- pronouns: config.pronouns ?? "",
21084
- hue: config.hue ?? 195,
21085
- bio: config.bio ?? "",
21086
- timezone: config.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
21087
- workingHours: config.workingHours ?? "08:00 \u2013 18:00",
21088
- interruptThreshold: config.interruptThreshold ?? "blocking-only",
21089
- batchWindow: config.batchWindow ?? 15,
21090
- channel: config.channel ?? "here+mobile",
21091
- verbosity: config.verbosity ?? "terse",
21092
- tone: config.tone ?? "direct",
21093
- quietHours: config.quietHours ?? "22:00 \u2013 07:00"
21094
- });
21095
- });
21096
- app.get("/api/onboarding/state", async (c) => {
21097
- const hasLocalConfig = localConfigExists();
21098
- const settings = await readOpenScoutSettings({ currentDirectory }).catch(() => null);
21099
- const configuredContextRoot = settings?.discovery.contextRoot ?? null;
21100
- const discoveryStart = configuredContextRoot ?? currentDirectory;
21101
- const projectRoot = await findNearestProjectRoot(discoveryStart).catch(() => null);
21102
- const hasProjectConfig = projectRoot !== null;
21103
- const userName = loadUserConfig().name?.trim() ?? "";
21104
- return c.json({
21105
- hasLocalConfig,
21106
- hasProjectConfig,
21107
- hasOperatorName: userName.length > 0,
21108
- localConfigPath: localConfigPath(),
21109
- localConfig: hasLocalConfig ? loadLocalConfig() : null,
21110
- projectRoot,
21111
- currentDirectory,
21112
- contextRoot: configuredContextRoot,
21113
- operatorName: userName || null,
21114
- operatorNameSuggestion: resolveOperatorName()
21115
- });
21116
- });
21117
- app.delete("/api/onboarding/state", (c) => {
21118
- try {
21119
- rmSync3(localConfigPath(), { force: true });
21120
- } catch {}
21121
- return c.json({ ok: true, localConfigPath: localConfigPath() });
21122
- });
21123
- app.post("/api/onboarding/project", async (c) => {
21124
- const body = await c.req.json().catch(() => ({}));
21125
- const contextRoot = body.contextRoot?.trim();
21126
- if (!contextRoot) {
21127
- return c.json({ error: "contextRoot is required" }, 400);
21253
+ ptyProcess.onExit(({ exitCode }) => {
21254
+ session.exited = true;
21255
+ session.exitCode = exitCode;
21256
+ const uptime = Date.now() - startTime;
21257
+ const crashed = exitCode !== 0 && uptime < 5000;
21258
+ let reason;
21259
+ if (crashed) {
21260
+ const clean = session.outputBuffer.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").trim();
21261
+ const lines = clean.split(`
21262
+ `).filter((l) => l.trim()).slice(-5);
21263
+ reason = lines.join(`
21264
+ `) || `Process exited with code ${exitCode}`;
21265
+ console.error(`[relay] Session ${id} crashed after ${uptime}ms (code ${exitCode}): ${reason}`);
21128
21266
  }
21129
- const sourceRoots = (body.sourceRoots ?? []).map((entry) => entry?.trim()).filter((entry) => Boolean(entry && entry.length > 0));
21130
- const harness = body.defaultHarness === "codex" ? "codex" : "claude";
21131
- try {
21132
- await writeOpenScoutSettings({
21133
- discovery: {
21134
- contextRoot,
21135
- workspaceRoots: sourceRoots
21136
- },
21137
- agents: { defaultHarness: harness }
21138
- });
21139
- const result = await initializeOpenScoutSetup({
21140
- currentDirectory: contextRoot
21141
- });
21142
- return c.json({
21143
- ok: true,
21144
- projectConfigPath: result.currentProjectConfigPath
21145
- });
21146
- } catch (err) {
21147
- const message = err instanceof Error ? err.message : String(err);
21148
- console.error("[onboarding/project]", message);
21149
- return c.json({ error: message }, 500);
21150
- }
21151
- });
21152
- app.post("/api/onboarding/init", async (c) => {
21153
- const body = await c.req.json().catch(() => ({}));
21154
- writeLocalConfig({
21155
- version: 1,
21156
- host: body.host ?? DEFAULT_LOCAL_CONFIG.host,
21157
- ports: {
21158
- broker: body.ports?.broker ?? DEFAULT_LOCAL_CONFIG.ports.broker,
21159
- web: body.ports?.web ?? DEFAULT_LOCAL_CONFIG.ports.web,
21160
- pairing: body.ports?.pairing ?? DEFAULT_LOCAL_CONFIG.ports.pairing
21161
- }
21162
- });
21163
- return c.json({
21164
- ok: true,
21165
- localConfig: loadLocalConfig(),
21166
- localConfigPath: localConfigPath()
21167
- });
21168
- });
21169
- app.post("/api/user", async (c) => {
21170
- const body = await c.req.json();
21171
- const config = loadUserConfig();
21172
- const stringFields = [
21173
- "name",
21174
- "handle",
21175
- "pronouns",
21176
- "bio",
21177
- "timezone",
21178
- "workingHours",
21179
- "interruptThreshold",
21180
- "channel",
21181
- "verbosity",
21182
- "tone",
21183
- "quietHours"
21184
- ];
21185
- for (const key of stringFields) {
21186
- if (key in body) {
21187
- const val = body[key];
21188
- if (typeof val === "string" && val.trim()) {
21189
- config[key] = val.trim();
21190
- } else {
21191
- delete config[key];
21192
- }
21193
- }
21194
- }
21195
- if ("hue" in body && typeof body.hue === "number") {
21196
- config.hue = body.hue;
21197
- }
21198
- if ("batchWindow" in body && typeof body.batchWindow === "number") {
21199
- config.batchWindow = body.batchWindow;
21200
- }
21201
- saveUserConfig(config);
21202
- return c.json({
21203
- name: resolveOperatorName(),
21204
- handle: config.handle ?? "",
21205
- pronouns: config.pronouns ?? "",
21206
- hue: config.hue ?? 195,
21207
- bio: config.bio ?? "",
21208
- timezone: config.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
21209
- workingHours: config.workingHours ?? "08:00 \u2013 18:00",
21210
- interruptThreshold: config.interruptThreshold ?? "blocking-only",
21211
- batchWindow: config.batchWindow ?? 15,
21212
- channel: config.channel ?? "here+mobile",
21213
- verbosity: config.verbosity ?? "terse",
21214
- tone: config.tone ?? "direct",
21215
- quietHours: config.quietHours ?? "22:00 \u2013 07:00"
21216
- });
21217
- });
21218
- app.post("/api/agents/:agentId/interrupt", async (c) => {
21219
- const agentId = c.req.param("agentId");
21220
- const { interruptLocalAgent: interruptLocalAgent2 } = await init_local_agents().then(() => exports_local_agents);
21221
- const result = await interruptLocalAgent2(agentId);
21222
- if (!result.ok)
21223
- return c.json({ error: "Agent not found or not interruptible" }, 404);
21224
- return c.json({ ok: true });
21225
- });
21226
- app.post("/api/send", async (c) => {
21227
- const { body, conversationId } = await c.req.json();
21228
- if (!body?.trim()) {
21229
- return c.json({ error: "body is required" }, 400);
21230
- }
21231
- const { directAgentId, senderId } = resolveConversationRouting(conversationId);
21232
- if (directAgentId) {
21233
- const result2 = await sendScoutDirectMessage({
21234
- agentId: directAgentId,
21235
- body: body.trim(),
21236
- currentDirectory,
21237
- source: "scout-web"
21238
- });
21239
- return c.json(result2);
21240
- }
21241
- const result = await sendScoutMessage({
21242
- senderId,
21243
- body: body.trim(),
21244
- currentDirectory
21245
- });
21246
- if (!result.usedBroker) {
21247
- return c.json({ error: "broker unreachable" }, 502);
21248
- }
21249
- return c.json(result);
21250
- });
21251
- app.post("/api/ask", async (c) => {
21252
- const { body, conversationId } = await c.req.json();
21253
- if (!body?.trim()) {
21254
- return c.json({ error: "body is required" }, 400);
21255
- }
21256
- const { directAgentId, senderId } = resolveConversationRouting(conversationId);
21257
- if (!directAgentId) {
21258
- return c.json({
21259
- error: "ask is only available in a direct conversation with one agent"
21260
- }, 400);
21261
- }
21262
- const result = await askScoutQuestion({
21263
- senderId,
21264
- targetLabel: directAgentId,
21265
- body: body.trim(),
21266
- currentDirectory
21267
- });
21268
- if (!result.usedBroker) {
21269
- return c.json({ error: "broker unreachable" }, 502);
21270
- }
21271
- if (result.unresolvedTarget) {
21272
- return c.json({
21273
- error: `could not route ask to ${result.unresolvedTarget}`,
21274
- targetDiagnostic: result.targetDiagnostic ?? null
21275
- }, 409);
21276
- }
21277
- return c.json(result);
21278
- });
21279
- app.get("/api/events", async (c) => {
21280
- const brokerHost = process.env.OPENSCOUT_BROKER_HOST ?? "127.0.0.1";
21281
- const brokerPort = process.env.OPENSCOUT_BROKER_PORT ?? "65535";
21282
- const brokerUrl = process.env.OPENSCOUT_BROKER_URL ?? `http://${brokerHost}:${brokerPort}`;
21283
- try {
21284
- return await relayEventStream(`${brokerUrl}/v1/events/stream`, {
21285
- signal: c.req.raw.signal
21286
- });
21287
- } catch {
21288
- return c.text("Broker unreachable", 502);
21289
- }
21290
- });
21291
- await registerScoutWebAssets(app, {
21292
- assetMode: options.assetMode,
21293
- staticRoot: resolveStaticRoot(options.staticRoot),
21294
- viteDevUrl: options.viteDevUrl,
21295
- defaultViteUrl: "http://127.0.0.1:5180"
21296
- });
21297
- const warmupCaches = () => Promise.allSettled([
21298
- shellStateCache.refresh(),
21299
- loadPairingState(currentDirectory, true)
21300
- ]).then((results) => {
21301
- for (const result of results) {
21302
- if (result.status === "rejected") {
21303
- const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
21304
- console.error("[openscout-web api] initial cache warmup failed:", message);
21305
- }
21267
+ if (session.ws) {
21268
+ send(session.ws, { type: "session:exit", exitCode, ...reason ? { reason } : {} });
21306
21269
  }
21270
+ scheduleReap(session, 1e4);
21307
21271
  });
21308
- return { app, warmupCaches };
21309
- }
21310
-
21311
- // ../hudson/packages/hudson-relay/src/relay/session.ts
21312
- import { createRequire } from "module";
21313
- import { execSync as execSync2 } from "child_process";
21314
- import { existsSync as existsSync13, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "fs";
21315
- import { join as join19, dirname as pathDirname } from "path";
21316
- var require2 = createRequire(import.meta.url);
21317
- var pty = require2("node-pty");
21318
- var DEFAULT_ORPHAN_TTL_MS = 30 * 60 * 1000;
21319
- var MAX_BUFFER_SIZE = 512 * 1024;
21320
- var sessions3 = new Map;
21321
- function generateId() {
21322
- return Math.random().toString(36).slice(2, 10);
21272
+ sessions3.set(id, session);
21273
+ console.log(`[relay] Session ${id} created (${cols}x${rows})`);
21274
+ return session;
21323
21275
  }
21324
- function send(ws, data) {
21325
- if (ws.readyState === 1) {
21326
- ws.send(JSON.stringify(data));
21276
+ function attachSession(session, ws, cols, rows) {
21277
+ if (session.reapTimer) {
21278
+ clearTimeout(session.reapTimer);
21279
+ session.reapTimer = null;
21327
21280
  }
21328
- }
21329
- function resolveCwd(raw2) {
21330
- const home = process.env.HOME || "/tmp";
21331
- const expanded = (raw2 || home).replace(/^~/, home);
21332
- if (existsSync13(expanded))
21333
- return expanded;
21334
- try {
21335
- mkdirSync6(expanded, { recursive: true });
21336
- console.log(`[relay] Created missing cwd: ${expanded}`);
21337
- return expanded;
21338
- } catch {
21339
- console.warn(`[relay] Could not create cwd ${expanded}, falling back to ${home}`);
21340
- return home;
21281
+ session.ws = ws;
21282
+ if (cols && rows) {
21283
+ const c = Math.max(cols, 20);
21284
+ const r = Math.max(rows, 4);
21285
+ if (c !== session.cols || r !== session.rows) {
21286
+ session.pty.resize(c, r);
21287
+ session.cols = c;
21288
+ session.rows = r;
21289
+ }
21341
21290
  }
21342
- }
21343
- function findBin(name, envOverride) {
21344
- if (envOverride && process.env[envOverride])
21345
- return process.env[envOverride];
21346
- try {
21347
- return execSync2(`which ${name}`, { encoding: "utf8" }).trim() || null;
21348
- } catch {
21349
- return null;
21291
+ if (session.exited) {
21292
+ send(ws, { type: "session:exit", exitCode: session.exitCode });
21293
+ } else if (session.outputBuffer.length > 0) {
21294
+ send(ws, { type: "terminal:data", data: session.outputBuffer });
21350
21295
  }
21296
+ console.log(`[relay] Session ${session.id} reconnected`);
21351
21297
  }
21352
- function findClaudeBin() {
21353
- return findBin("claude", "CLAUDE_BIN");
21298
+ function detachSession(session) {
21299
+ session.ws = null;
21300
+ if (session.exited) {
21301
+ scheduleReap(session, 5000);
21302
+ } else {
21303
+ scheduleReap(session, session.orphanTTL);
21304
+ console.log(`[relay] Session ${session.id} detached (orphaned for ${session.orphanTTL / 1000}s)`);
21305
+ }
21354
21306
  }
21355
- function findPiBin() {
21356
- return findBin("pi", "PI_BIN");
21307
+ function scheduleReap(session, delay) {
21308
+ if (session.reapTimer)
21309
+ clearTimeout(session.reapTimer);
21310
+ session.reapTimer = setTimeout(() => {
21311
+ if (!session.ws) {
21312
+ destroy(session.id);
21313
+ }
21314
+ }, delay);
21357
21315
  }
21358
- function tmuxSessionExists2(name) {
21316
+ function destroy(sessionId) {
21317
+ const session = sessions3.get(sessionId);
21318
+ if (!session)
21319
+ return;
21320
+ if (session.reapTimer)
21321
+ clearTimeout(session.reapTimer);
21359
21322
  try {
21360
- execSync2(`tmux has-session -t ${name} 2>/dev/null`, { encoding: "utf8" });
21361
- return true;
21362
- } catch {
21363
- return false;
21364
- }
21365
- }
21366
- function bootstrapFiles(cwd, files, sessionId) {
21367
- for (const [relPath, content] of Object.entries(files)) {
21368
- const absPath = join19(cwd, relPath);
21369
- if (!existsSync13(absPath)) {
21370
- try {
21371
- const dir = pathDirname(absPath);
21372
- if (!existsSync13(dir))
21373
- mkdirSync6(dir, { recursive: true });
21374
- writeFileSync6(absPath, content, "utf-8");
21375
- console.log(`[relay] Session ${sessionId}: bootstrapped ${relPath}`);
21376
- } catch (err) {
21377
- console.warn(`[relay] Session ${sessionId}: failed to bootstrap ${relPath}:`, err);
21323
+ session.pty.kill();
21324
+ } catch {}
21325
+ sessions3.delete(sessionId);
21326
+ if (session.backend === "tmux") {
21327
+ console.log(`[relay] Session ${sessionId} bridge destroyed (tmux session '${session.tmuxSession}' still alive)`);
21328
+ } else {
21329
+ console.log(`[relay] Session ${sessionId} destroyed`);
21330
+ }
21331
+ }
21332
+
21333
+ // packages/web/server/relay.ts
21334
+ import { mkdir as mkdir7 } from "fs/promises";
21335
+ import { join as join20 } from "path";
21336
+ import { randomUUID as randomUUID3 } from "crypto";
21337
+ var UPLOAD_DIR = "/tmp/scout-uploads";
21338
+ async function handleRelayUpload(req) {
21339
+ const { name, data } = await req.json();
21340
+ if (!name || !data) {
21341
+ return Response.json({ error: "Missing name or data" }, { status: 400 });
21342
+ }
21343
+ await mkdir7(UPLOAD_DIR, { recursive: true });
21344
+ const filename = `${randomUUID3()}-${name}`;
21345
+ const filepath = join20(UPLOAD_DIR, filename);
21346
+ await Bun.write(filepath, Buffer.from(data, "base64"));
21347
+ return Response.json({ path: filepath });
21348
+ }
21349
+ var _pendingCommand = null;
21350
+ function queueTerminalCommand(cmd) {
21351
+ for (const [, session] of sessions3) {
21352
+ if (!session.exited) {
21353
+ session.pty.write(cmd + `
21354
+ `);
21355
+ return;
21356
+ }
21357
+ }
21358
+ _pendingCommand = cmd;
21359
+ }
21360
+ function drainPendingCommand() {
21361
+ const cmd = _pendingCommand;
21362
+ _pendingCommand = null;
21363
+ return cmd;
21364
+ }
21365
+ function asRelay(ws) {
21366
+ return ws;
21367
+ }
21368
+ function parseMessage(raw2) {
21369
+ try {
21370
+ const msg = JSON.parse(raw2);
21371
+ if (typeof msg.type === "string")
21372
+ return msg;
21373
+ } catch {}
21374
+ return null;
21375
+ }
21376
+ var relayWebSocket = {
21377
+ open(_ws) {},
21378
+ message(ws, raw2) {
21379
+ const msg = parseMessage(typeof raw2 === "string" ? raw2 : raw2.toString());
21380
+ if (!msg)
21381
+ return;
21382
+ const rws = asRelay(ws);
21383
+ switch (msg.type) {
21384
+ case "session:init": {
21385
+ if (ws.data.sessionId) {
21386
+ const prev = sessions3.get(ws.data.sessionId);
21387
+ if (prev)
21388
+ detachSession(prev);
21389
+ }
21390
+ const session = createSession(rws, msg);
21391
+ if (!session)
21392
+ break;
21393
+ ws.data.sessionId = session.id;
21394
+ send(rws, { type: "session:ready", sessionId: session.id });
21395
+ const pending = drainPendingCommand();
21396
+ if (pending)
21397
+ setTimeout(() => session.pty.write(pending + `
21398
+ `), 400);
21399
+ break;
21400
+ }
21401
+ case "session:reconnect": {
21402
+ const existing = sessions3.get(msg.sessionId);
21403
+ if (existing && !existing.exited) {
21404
+ if (ws.data.sessionId && ws.data.sessionId !== msg.sessionId) {
21405
+ const prev = sessions3.get(ws.data.sessionId);
21406
+ if (prev)
21407
+ detachSession(prev);
21408
+ }
21409
+ if (existing.ws && existing.ws !== rws) {
21410
+ send(existing.ws, { type: "session:detached" });
21411
+ }
21412
+ ws.data.sessionId = existing.id;
21413
+ attachSession(existing, rws, msg.cols, msg.rows);
21414
+ send(rws, {
21415
+ type: "session:ready",
21416
+ sessionId: existing.id,
21417
+ reconnected: true
21418
+ });
21419
+ const pending = drainPendingCommand();
21420
+ if (pending)
21421
+ setTimeout(() => existing.pty.write(pending + `
21422
+ `), 400);
21423
+ } else {
21424
+ send(rws, { type: "session:expired", sessionId: msg.sessionId });
21425
+ }
21426
+ break;
21427
+ }
21428
+ case "terminal:input": {
21429
+ if (!ws.data.sessionId)
21430
+ return;
21431
+ const session = sessions3.get(ws.data.sessionId);
21432
+ if (session && !session.exited) {
21433
+ session.pty.write(msg.data);
21434
+ }
21435
+ break;
21436
+ }
21437
+ case "terminal:resize": {
21438
+ if (!ws.data.sessionId)
21439
+ return;
21440
+ const session = sessions3.get(ws.data.sessionId);
21441
+ if (session && !session.exited) {
21442
+ const cols = Math.max(msg.cols || 80, 20);
21443
+ const rows = Math.max(msg.rows || 24, 4);
21444
+ session.pty.resize(cols, rows);
21445
+ session.cols = cols;
21446
+ session.rows = rows;
21447
+ }
21448
+ break;
21378
21449
  }
21379
21450
  }
21451
+ },
21452
+ close(ws) {
21453
+ if (ws.data.sessionId) {
21454
+ const session = sessions3.get(ws.data.sessionId);
21455
+ if (session)
21456
+ detachSession(session);
21457
+ ws.data.sessionId = null;
21458
+ }
21380
21459
  }
21460
+ };
21461
+ function destroyAllRelaySessions() {
21462
+ for (const [id] of sessions3)
21463
+ destroy(id);
21381
21464
  }
21382
- function spawnTmuxSession(tmuxName, cols, rows, cwd, claudeBin, claudeArgs, env) {
21383
- const exists = tmuxSessionExists2(tmuxName);
21384
- if (!exists) {
21385
- const shellCmd = [claudeBin, ...claudeArgs].map((a) => a.includes(" ") ? `'${a}'` : a).join(" ");
21386
- execSync2(`tmux new-session -d -s ${tmuxName} -x ${cols} -y ${rows} -c '${cwd}' '${shellCmd}'`, { env });
21387
- console.log(`[relay] Created tmux session: ${tmuxName}`);
21388
- } else {
21389
- try {
21390
- execSync2(`tmux resize-window -t ${tmuxName} -x ${cols} -y ${rows} 2>/dev/null`);
21391
- } catch {}
21392
- console.log(`[relay] Attaching to existing tmux session: ${tmuxName}`);
21465
+
21466
+ // packages/web/server/create-openscout-web-server.ts
21467
+ function parseOptionalPositiveInt(value, fallback) {
21468
+ if (typeof value !== "string" || value.trim().length === 0) {
21469
+ return fallback;
21393
21470
  }
21394
- return pty.spawn("tmux", ["attach", "-t", tmuxName], {
21395
- name: "xterm-256color",
21396
- cols,
21397
- rows,
21398
- cwd,
21399
- env
21400
- });
21471
+ const parsed = Number.parseInt(value, 10);
21472
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
21401
21473
  }
21402
- function createSession(ws, msg) {
21403
- const id = generateId();
21404
- const cols = Math.max(msg.cols || 80, 20);
21405
- const rows = Math.max(msg.rows || 24, 4);
21406
- const backend = msg.backend || "pty";
21407
- const tmuxName = msg.tmuxSession || `hudson-${id}`;
21408
- const agent = msg.agent || "claude";
21409
- let agentBin;
21410
- if (agent === "pi") {
21411
- agentBin = findPiBin();
21412
- if (!agentBin) {
21413
- const reason = "pi CLI not found. Install it with: npm install -g @mariozechner/pi-coding-agent";
21414
- console.error(`[relay] Session ${id} failed: ${reason}`);
21415
- send(ws, { type: "session:error", error: reason });
21416
- return null;
21474
+ function inferDirectTargetAgentId(conversationId, session, senderId) {
21475
+ if (session?.kind === "direct") {
21476
+ if (session.agentId) {
21477
+ return session.agentId;
21417
21478
  }
21418
- } else {
21419
- agentBin = findClaudeBin();
21420
- if (!agentBin) {
21421
- const reason = "Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code";
21422
- console.error(`[relay] Session ${id} failed: ${reason}`);
21423
- send(ws, { type: "session:error", error: reason });
21424
- return null;
21479
+ const participants = session.participantIds.filter((participantId) => participantId.trim().length > 0);
21480
+ if (participants.length === 2) {
21481
+ const operatorCandidates = new Set([senderId.trim(), "operator"]);
21482
+ const nonOperatorParticipants = participants.filter((participantId) => !operatorCandidates.has(participantId));
21483
+ if (nonOperatorParticipants.length === 1) {
21484
+ return nonOperatorParticipants[0] ?? null;
21485
+ }
21486
+ const localSessionParticipant = nonOperatorParticipants.find((participantId) => participantId.startsWith("local-session-agent-")) ?? participants.find((participantId) => participantId.startsWith("local-session-agent-"));
21487
+ if (localSessionParticipant) {
21488
+ return localSessionParticipant;
21489
+ }
21490
+ return participants[0] ?? null;
21425
21491
  }
21426
21492
  }
21427
- if (!existsSync13(agentBin)) {
21428
- const reason = `${agent} binary not found at ${agentBin}`;
21429
- console.error(`[relay] Session ${id} failed: ${reason}`);
21430
- send(ws, { type: "session:error", error: reason });
21431
- return null;
21432
- }
21433
- if (backend === "tmux" && !findBin("tmux")) {
21434
- const reason = "tmux not found. Install it with: brew install tmux";
21435
- console.error(`[relay] Session ${id} failed: ${reason}`);
21436
- send(ws, { type: "session:error", error: reason });
21437
- return null;
21493
+ if (conversationId?.startsWith("dm.operator.")) {
21494
+ const legacyAgentId = conversationId.slice("dm.operator.".length);
21495
+ return legacyAgentId || null;
21438
21496
  }
21439
- const cwd = resolveCwd(msg.cwd);
21440
- if (msg.workspaceFiles) {
21441
- bootstrapFiles(cwd, msg.workspaceFiles, id);
21497
+ return null;
21498
+ }
21499
+ function inferDirectSenderId(_session, _fallbackSenderId, _directTargetAgentId) {
21500
+ return "operator";
21501
+ }
21502
+ function resolveConversationRouting(conversationId) {
21503
+ const fallbackSenderId = "operator";
21504
+ const session = conversationId ? querySessionById(conversationId) : null;
21505
+ const directAgentId = inferDirectTargetAgentId(conversationId, session, fallbackSenderId);
21506
+ const senderId = inferDirectSenderId(session, fallbackSenderId, directAgentId);
21507
+ return { directAgentId, senderId };
21508
+ }
21509
+ function resolveBundledStaticClientRoot(moduleUrl = import.meta.url) {
21510
+ return resolve9(dirname12(fileURLToPath7(moduleUrl.toString())), "client");
21511
+ }
21512
+ function resolveSourceStaticClientRoot(moduleUrl = import.meta.url) {
21513
+ return resolve9(dirname12(fileURLToPath7(moduleUrl.toString())), "../dist/client");
21514
+ }
21515
+ function resolveStaticRoot(staticRoot) {
21516
+ const configured = staticRoot?.trim();
21517
+ if (configured) {
21518
+ return configured;
21442
21519
  }
21443
- let agentArgs;
21444
- if (agent === "pi") {
21445
- agentArgs = ["--verbose"];
21446
- if (msg.provider)
21447
- agentArgs.push("--provider", msg.provider);
21448
- if (msg.model)
21449
- agentArgs.push("--model", msg.model);
21450
- if (msg.systemPrompt)
21451
- agentArgs.push("--system-prompt", msg.systemPrompt);
21452
- } else {
21453
- agentArgs = ["--verbose"];
21454
- if (msg.systemPrompt)
21455
- agentArgs.push("--system-prompt", msg.systemPrompt);
21520
+ const bundled = resolveBundledStaticClientRoot(import.meta.url);
21521
+ if (existsSync14(resolve9(bundled, "index.html"))) {
21522
+ return bundled;
21456
21523
  }
21457
- const env = { ...process.env, TERM: "xterm-256color", FORCE_COLOR: "1" };
21458
- delete env.CLAUDECODE;
21459
- let ptyProcess;
21460
- try {
21461
- if (backend === "tmux") {
21462
- console.log(`[relay] Session ${id}: tmux backend (session: ${tmuxName}) in ${cwd} [agent: ${agent}]`);
21463
- ptyProcess = spawnTmuxSession(tmuxName, cols, rows, cwd, agentBin, agentArgs, env);
21464
- } else {
21465
- console.log(`[relay] Session ${id}: pty backend, spawning ${agentBin} in ${cwd} [agent: ${agent}]`);
21466
- ptyProcess = pty.spawn(agentBin, agentArgs, {
21467
- name: "xterm-256color",
21468
- cols,
21469
- rows,
21470
- cwd,
21471
- env
21472
- });
21524
+ return resolveSourceStaticClientRoot(import.meta.url);
21525
+ }
21526
+ async function loadPairingState(currentDirectory, refresh) {
21527
+ return refresh ? refreshScoutWebPairingState(currentDirectory) : getScoutWebPairingState(currentDirectory);
21528
+ }
21529
+ async function createOpenScoutWebServer(options) {
21530
+ const shellTtl = options.shellStateCacheTtlMs ?? 15000;
21531
+ const currentDirectory = options.currentDirectory;
21532
+ const app = new Hono2;
21533
+ const shellStateCache = createCachedSnapshot(loadOpenScoutWebShellState, shellTtl);
21534
+ installScoutApiMiddleware(app, "openscout-web api");
21535
+ app.get("/api/health", (c) => c.json({
21536
+ ok: true,
21537
+ surface: "openscout-web",
21538
+ currentDirectory
21539
+ }));
21540
+ app.get("/api/pairing-state", async (c) => c.json(await loadPairingState(currentDirectory, false)));
21541
+ app.get("/api/pairing-state/refresh", async (c) => c.json(await loadPairingState(currentDirectory, true)));
21542
+ app.post("/api/pairing/control", async (c) => {
21543
+ const { action } = await c.req.json();
21544
+ const result = await controlScoutWebPairingService(action, currentDirectory);
21545
+ shellStateCache.invalidate();
21546
+ return c.json(result);
21547
+ });
21548
+ app.delete("/api/pairing/peers/:fingerprint", async (c) => {
21549
+ const fingerprint = c.req.param("fingerprint");
21550
+ const removed = removeScoutPairingTrustedPeer(fingerprint);
21551
+ if (!removed) {
21552
+ return c.json({ error: "Peer not found" }, 404);
21473
21553
  }
21474
- } catch (err) {
21475
- const message = err instanceof Error ? err.message : String(err);
21476
- console.error(`[relay] Session ${id}: failed to spawn PTY \u2014 ${message}`);
21477
- send(ws, { type: "session:error", error: `Failed to spawn terminal: ${message}` });
21478
- return null;
21479
- }
21480
- const orphanTTL = msg.orphanTTL && msg.orphanTTL > 0 ? msg.orphanTTL : DEFAULT_ORPHAN_TTL_MS;
21481
- const session = {
21482
- id,
21483
- pty: ptyProcess,
21484
- ws,
21485
- outputBuffer: "",
21486
- cols,
21487
- rows,
21488
- reapTimer: null,
21489
- orphanTTL,
21490
- backend,
21491
- ...backend === "tmux" ? { tmuxSession: tmuxName } : {},
21492
- exited: false,
21493
- exitCode: null
21554
+ return c.json({ ok: true });
21555
+ });
21556
+ app.get("/api/shell-state", async (c) => c.json(await shellStateCache.get()));
21557
+ app.get("/api/shell-state/refresh", async (c) => c.json(await shellStateCache.refresh()));
21558
+ app.get("/api/agents", (c) => c.json(queryAgents()));
21559
+ app.get("/api/observe/agents", async (c) => {
21560
+ const ids = c.req.query("ids")?.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
21561
+ return c.json(await loadAgentObserveSummaries(ids));
21562
+ });
21563
+ app.get("/api/agents/:id/observe", async (c) => {
21564
+ const payload = await loadAgentObservePayload(c.req.param("id"));
21565
+ return payload ? c.json(payload) : c.json({ error: "not found" }, 404);
21566
+ });
21567
+ app.get("/api/agents/:id/session-catalog", (c) => {
21568
+ const agentId = c.req.param("id");
21569
+ const agents = queryAgents();
21570
+ const agent = agents.find((a) => a.id === agentId);
21571
+ if (!agent)
21572
+ return c.json({ error: "agent not found" }, 404);
21573
+ const runtimeDir = relayAgentRuntimeDirectory(agent.id);
21574
+ const catalog = readSessionCatalogSync(runtimeDir);
21575
+ const cwd = agent.cwd ?? agent.projectRoot ?? ".";
21576
+ const sessionId = catalog.activeSessionId;
21577
+ const harnessEntry = findHarnessEntry(agent.harness);
21578
+ const resumeCommand = sessionId && harnessEntry ? buildHarnessResumeCommand(harnessEntry, sessionId, cwd) : null;
21579
+ return c.json({ ...catalog, agentId, harness: agent.harness, resumeCommand });
21580
+ });
21581
+ app.get("/api/activity", (c) => c.json(queryActivity()));
21582
+ app.get("/api/heartrate", (c) => c.json(queryHeartrate()));
21583
+ app.get("/api/fleet", (c) => c.json(queryFleet({
21584
+ limit: parseOptionalPositiveInt(c.req.query("limit")),
21585
+ activityLimit: parseOptionalPositiveInt(c.req.query("activityLimit"))
21586
+ })));
21587
+ app.get("/api/messages", (c) => c.json(queryRecentMessages(parseOptionalPositiveInt(c.req.query("limit"), 80) ?? 80, { conversationId: c.req.query("conversationId") || undefined })));
21588
+ const handleListWork = (c) => {
21589
+ const agentId = c.req.query("agentId");
21590
+ const activeOnly = c.req.query("active") !== "false";
21591
+ return c.json(queryWorkItems({
21592
+ agentId: agentId || undefined,
21593
+ activeOnly
21594
+ }));
21494
21595
  };
21495
- const startTime = Date.now();
21496
- ptyProcess.onData((data) => {
21497
- session.outputBuffer += data;
21498
- if (session.outputBuffer.length > MAX_BUFFER_SIZE) {
21499
- session.outputBuffer = session.outputBuffer.slice(-MAX_BUFFER_SIZE);
21596
+ const handleWorkDetail = (c) => {
21597
+ const workId = c.req.param("id");
21598
+ if (!workId) {
21599
+ return c.json({ error: "id is required" }, 400);
21600
+ }
21601
+ const detail = queryWorkItemById(workId);
21602
+ return detail ? c.json(detail) : c.json({ error: "not found" }, 404);
21603
+ };
21604
+ app.get("/api/work", handleListWork);
21605
+ app.get("/api/tasks", handleListWork);
21606
+ app.get("/api/work/:id", handleWorkDetail);
21607
+ app.get("/api/tasks/:id", handleWorkDetail);
21608
+ app.get("/api/flights", (c) => {
21609
+ const agentId = c.req.query("agentId");
21610
+ const conversationId = c.req.query("conversationId");
21611
+ const collaborationRecordId = c.req.query("collaborationRecordId");
21612
+ const activeOnly = c.req.query("active") !== "false";
21613
+ return c.json(queryFlights({
21614
+ agentId: agentId || undefined,
21615
+ conversationId: conversationId || undefined,
21616
+ collaborationRecordId: collaborationRecordId || undefined,
21617
+ activeOnly
21618
+ }));
21619
+ });
21620
+ app.get("/api/sessions", (c) => c.json(querySessions()));
21621
+ app.get("/api/session/:id", (c) => {
21622
+ const session = querySessionById(c.req.param("id"));
21623
+ return session ? c.json(session) : c.json({ error: "not found" }, 404);
21624
+ });
21625
+ app.get("/api/mesh", async (c) => {
21626
+ try {
21627
+ return c.json(await loadMeshStatus());
21628
+ } catch (err) {
21629
+ const message = err instanceof Error ? err.message : String(err);
21630
+ return c.json({ error: message }, 500);
21631
+ }
21632
+ });
21633
+ app.post("/api/mesh/announce", async (c) => {
21634
+ try {
21635
+ return c.json(await announceMeshVisibility());
21636
+ } catch (err) {
21637
+ const message = err instanceof Error ? err.message : String(err);
21638
+ return c.json({ error: message }, 500);
21639
+ }
21640
+ });
21641
+ app.post("/api/mesh/tailscale", async (c) => {
21642
+ try {
21643
+ const { action } = await c.req.json();
21644
+ return c.json(await controlTailscale(action));
21645
+ } catch (err) {
21646
+ const message = err instanceof Error ? err.message : String(err);
21647
+ return c.json({ error: message }, 500);
21648
+ }
21649
+ });
21650
+ app.get("/api/user", (c) => {
21651
+ const config = loadUserConfig();
21652
+ return c.json({
21653
+ name: resolveOperatorName(),
21654
+ handle: config.handle ?? "",
21655
+ pronouns: config.pronouns ?? "",
21656
+ hue: config.hue ?? 195,
21657
+ bio: config.bio ?? "",
21658
+ timezone: config.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
21659
+ workingHours: config.workingHours ?? "08:00 \u2013 18:00",
21660
+ interruptThreshold: config.interruptThreshold ?? "blocking-only",
21661
+ batchWindow: config.batchWindow ?? 15,
21662
+ channel: config.channel ?? "here+mobile",
21663
+ verbosity: config.verbosity ?? "terse",
21664
+ tone: config.tone ?? "direct",
21665
+ quietHours: config.quietHours ?? "22:00 \u2013 07:00"
21666
+ });
21667
+ });
21668
+ app.get("/api/onboarding/state", async (c) => {
21669
+ const hasLocalConfig = localConfigExists();
21670
+ const settings = await readOpenScoutSettings({ currentDirectory }).catch(() => null);
21671
+ const configuredContextRoot = settings?.discovery.contextRoot ?? null;
21672
+ const discoveryStart = configuredContextRoot ?? currentDirectory;
21673
+ const projectRoot = await findNearestProjectRoot(discoveryStart).catch(() => null);
21674
+ const hasProjectConfig = projectRoot !== null;
21675
+ const userName = loadUserConfig().name?.trim() ?? "";
21676
+ return c.json({
21677
+ hasLocalConfig,
21678
+ hasProjectConfig,
21679
+ hasOperatorName: userName.length > 0,
21680
+ localConfigPath: localConfigPath(),
21681
+ localConfig: hasLocalConfig ? loadLocalConfig() : null,
21682
+ projectRoot,
21683
+ currentDirectory,
21684
+ contextRoot: configuredContextRoot,
21685
+ operatorName: userName || null,
21686
+ operatorNameSuggestion: resolveOperatorName()
21687
+ });
21688
+ });
21689
+ app.delete("/api/onboarding/state", (c) => {
21690
+ try {
21691
+ rmSync3(localConfigPath(), { force: true });
21692
+ } catch {}
21693
+ return c.json({ ok: true, localConfigPath: localConfigPath() });
21694
+ });
21695
+ app.post("/api/onboarding/project", async (c) => {
21696
+ const body = await c.req.json().catch(() => ({}));
21697
+ const contextRoot = body.contextRoot?.trim();
21698
+ if (!contextRoot) {
21699
+ return c.json({ error: "contextRoot is required" }, 400);
21700
+ }
21701
+ const sourceRoots = (body.sourceRoots ?? []).map((entry) => entry?.trim()).filter((entry) => Boolean(entry && entry.length > 0));
21702
+ const harness = body.defaultHarness === "codex" ? "codex" : "claude";
21703
+ try {
21704
+ await writeOpenScoutSettings({
21705
+ discovery: {
21706
+ contextRoot,
21707
+ workspaceRoots: sourceRoots
21708
+ },
21709
+ agents: { defaultHarness: harness }
21710
+ });
21711
+ const result = await initializeOpenScoutSetup({
21712
+ currentDirectory: contextRoot
21713
+ });
21714
+ return c.json({
21715
+ ok: true,
21716
+ projectConfigPath: result.currentProjectConfigPath
21717
+ });
21718
+ } catch (err) {
21719
+ const message = err instanceof Error ? err.message : String(err);
21720
+ console.error("[onboarding/project]", message);
21721
+ return c.json({ error: message }, 500);
21722
+ }
21723
+ });
21724
+ app.post("/api/onboarding/init", async (c) => {
21725
+ const body = await c.req.json().catch(() => ({}));
21726
+ writeLocalConfig({
21727
+ version: 1,
21728
+ host: body.host ?? DEFAULT_LOCAL_CONFIG.host,
21729
+ ports: {
21730
+ broker: body.ports?.broker ?? DEFAULT_LOCAL_CONFIG.ports.broker,
21731
+ web: body.ports?.web ?? DEFAULT_LOCAL_CONFIG.ports.web,
21732
+ pairing: body.ports?.pairing ?? DEFAULT_LOCAL_CONFIG.ports.pairing
21733
+ }
21734
+ });
21735
+ return c.json({
21736
+ ok: true,
21737
+ localConfig: loadLocalConfig(),
21738
+ localConfigPath: localConfigPath()
21739
+ });
21740
+ });
21741
+ app.post("/api/user", async (c) => {
21742
+ const body = await c.req.json();
21743
+ const config = loadUserConfig();
21744
+ const stringFields = [
21745
+ "name",
21746
+ "handle",
21747
+ "pronouns",
21748
+ "bio",
21749
+ "timezone",
21750
+ "workingHours",
21751
+ "interruptThreshold",
21752
+ "channel",
21753
+ "verbosity",
21754
+ "tone",
21755
+ "quietHours"
21756
+ ];
21757
+ for (const key of stringFields) {
21758
+ if (key in body) {
21759
+ const val = body[key];
21760
+ if (typeof val === "string" && val.trim()) {
21761
+ config[key] = val.trim();
21762
+ } else {
21763
+ delete config[key];
21764
+ }
21765
+ }
21766
+ }
21767
+ if ("hue" in body && typeof body.hue === "number") {
21768
+ config.hue = body.hue;
21769
+ }
21770
+ if ("batchWindow" in body && typeof body.batchWindow === "number") {
21771
+ config.batchWindow = body.batchWindow;
21772
+ }
21773
+ saveUserConfig(config);
21774
+ return c.json({
21775
+ name: resolveOperatorName(),
21776
+ handle: config.handle ?? "",
21777
+ pronouns: config.pronouns ?? "",
21778
+ hue: config.hue ?? 195,
21779
+ bio: config.bio ?? "",
21780
+ timezone: config.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
21781
+ workingHours: config.workingHours ?? "08:00 \u2013 18:00",
21782
+ interruptThreshold: config.interruptThreshold ?? "blocking-only",
21783
+ batchWindow: config.batchWindow ?? 15,
21784
+ channel: config.channel ?? "here+mobile",
21785
+ verbosity: config.verbosity ?? "terse",
21786
+ tone: config.tone ?? "direct",
21787
+ quietHours: config.quietHours ?? "22:00 \u2013 07:00"
21788
+ });
21789
+ });
21790
+ app.post("/api/terminal/run", async (c) => {
21791
+ const body = await c.req.json();
21792
+ if (!body.command)
21793
+ return c.json({ error: "missing command" }, 400);
21794
+ queueTerminalCommand(body.command);
21795
+ return c.json({ ok: true });
21796
+ });
21797
+ app.post("/api/agents/:agentId/interrupt", async (c) => {
21798
+ const agentId = c.req.param("agentId");
21799
+ const { interruptLocalAgent: interruptLocalAgent2 } = await init_local_agents().then(() => exports_local_agents);
21800
+ const result = await interruptLocalAgent2(agentId);
21801
+ if (!result.ok)
21802
+ return c.json({ error: "Agent not found or not interruptible" }, 404);
21803
+ return c.json({ ok: true });
21804
+ });
21805
+ app.post("/api/send", async (c) => {
21806
+ const { body, conversationId } = await c.req.json();
21807
+ if (!body?.trim()) {
21808
+ return c.json({ error: "body is required" }, 400);
21500
21809
  }
21501
- if (session.ws && session.ws.readyState === 1) {
21502
- send(session.ws, { type: "terminal:data", data });
21810
+ const { directAgentId, senderId } = resolveConversationRouting(conversationId);
21811
+ if (directAgentId) {
21812
+ const result2 = await sendScoutDirectMessage({
21813
+ agentId: directAgentId,
21814
+ body: body.trim(),
21815
+ currentDirectory,
21816
+ source: "scout-web"
21817
+ });
21818
+ return c.json(result2);
21819
+ }
21820
+ const result = await sendScoutMessage({
21821
+ senderId,
21822
+ body: body.trim(),
21823
+ currentDirectory
21824
+ });
21825
+ if (!result.usedBroker) {
21826
+ return c.json({ error: "broker unreachable" }, 502);
21503
21827
  }
21828
+ return c.json(result);
21504
21829
  });
21505
- ptyProcess.onExit(({ exitCode }) => {
21506
- session.exited = true;
21507
- session.exitCode = exitCode;
21508
- const uptime = Date.now() - startTime;
21509
- const crashed = exitCode !== 0 && uptime < 5000;
21510
- let reason;
21511
- if (crashed) {
21512
- const clean = session.outputBuffer.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").trim();
21513
- const lines = clean.split(`
21514
- `).filter((l) => l.trim()).slice(-5);
21515
- reason = lines.join(`
21516
- `) || `Process exited with code ${exitCode}`;
21517
- console.error(`[relay] Session ${id} crashed after ${uptime}ms (code ${exitCode}): ${reason}`);
21830
+ app.post("/api/ask", async (c) => {
21831
+ const { body, conversationId } = await c.req.json();
21832
+ if (!body?.trim()) {
21833
+ return c.json({ error: "body is required" }, 400);
21518
21834
  }
21519
- if (session.ws) {
21520
- send(session.ws, { type: "session:exit", exitCode, ...reason ? { reason } : {} });
21835
+ const { directAgentId, senderId } = resolveConversationRouting(conversationId);
21836
+ if (!directAgentId) {
21837
+ return c.json({
21838
+ error: "ask is only available in a direct conversation with one agent"
21839
+ }, 400);
21521
21840
  }
21522
- scheduleReap(session, 1e4);
21523
- });
21524
- sessions3.set(id, session);
21525
- console.log(`[relay] Session ${id} created (${cols}x${rows})`);
21526
- return session;
21527
- }
21528
- function attachSession(session, ws, cols, rows) {
21529
- if (session.reapTimer) {
21530
- clearTimeout(session.reapTimer);
21531
- session.reapTimer = null;
21532
- }
21533
- session.ws = ws;
21534
- if (cols && rows) {
21535
- const c = Math.max(cols, 20);
21536
- const r = Math.max(rows, 4);
21537
- if (c !== session.cols || r !== session.rows) {
21538
- session.pty.resize(c, r);
21539
- session.cols = c;
21540
- session.rows = r;
21841
+ const result = await askScoutQuestion({
21842
+ senderId,
21843
+ targetLabel: directAgentId,
21844
+ body: body.trim(),
21845
+ currentDirectory
21846
+ });
21847
+ if (!result.usedBroker) {
21848
+ return c.json({ error: "broker unreachable" }, 502);
21541
21849
  }
21542
- }
21543
- if (session.exited) {
21544
- send(ws, { type: "session:exit", exitCode: session.exitCode });
21545
- } else if (session.outputBuffer.length > 0) {
21546
- send(ws, { type: "terminal:data", data: session.outputBuffer });
21547
- }
21548
- console.log(`[relay] Session ${session.id} reconnected`);
21549
- }
21550
- function detachSession(session) {
21551
- session.ws = null;
21552
- if (session.exited) {
21553
- scheduleReap(session, 5000);
21554
- } else {
21555
- scheduleReap(session, session.orphanTTL);
21556
- console.log(`[relay] Session ${session.id} detached (orphaned for ${session.orphanTTL / 1000}s)`);
21557
- }
21558
- }
21559
- function scheduleReap(session, delay) {
21560
- if (session.reapTimer)
21561
- clearTimeout(session.reapTimer);
21562
- session.reapTimer = setTimeout(() => {
21563
- if (!session.ws) {
21564
- destroy(session.id);
21850
+ if (result.unresolvedTarget) {
21851
+ return c.json({
21852
+ error: `could not route ask to ${result.unresolvedTarget}`,
21853
+ targetDiagnostic: result.targetDiagnostic ?? null
21854
+ }, 409);
21565
21855
  }
21566
- }, delay);
21567
- }
21568
- function destroy(sessionId) {
21569
- const session = sessions3.get(sessionId);
21570
- if (!session)
21571
- return;
21572
- if (session.reapTimer)
21573
- clearTimeout(session.reapTimer);
21574
- try {
21575
- session.pty.kill();
21576
- } catch {}
21577
- sessions3.delete(sessionId);
21578
- if (session.backend === "tmux") {
21579
- console.log(`[relay] Session ${sessionId} bridge destroyed (tmux session '${session.tmuxSession}' still alive)`);
21580
- } else {
21581
- console.log(`[relay] Session ${sessionId} destroyed`);
21582
- }
21583
- }
21584
-
21585
- // packages/web/server/relay.ts
21586
- import { mkdir as mkdir7 } from "fs/promises";
21587
- import { join as join20 } from "path";
21588
- import { randomUUID as randomUUID3 } from "crypto";
21589
- var UPLOAD_DIR = "/tmp/scout-uploads";
21590
- async function handleRelayUpload(req) {
21591
- const { name, data } = await req.json();
21592
- if (!name || !data) {
21593
- return Response.json({ error: "Missing name or data" }, { status: 400 });
21594
- }
21595
- await mkdir7(UPLOAD_DIR, { recursive: true });
21596
- const filename = `${randomUUID3()}-${name}`;
21597
- const filepath = join20(UPLOAD_DIR, filename);
21598
- await Bun.write(filepath, Buffer.from(data, "base64"));
21599
- return Response.json({ path: filepath });
21600
- }
21601
- function asRelay(ws) {
21602
- return ws;
21603
- }
21604
- function parseMessage(raw2) {
21605
- try {
21606
- const msg = JSON.parse(raw2);
21607
- if (typeof msg.type === "string")
21608
- return msg;
21609
- } catch {}
21610
- return null;
21611
- }
21612
- var relayWebSocket = {
21613
- open(_ws) {},
21614
- message(ws, raw2) {
21615
- const msg = parseMessage(typeof raw2 === "string" ? raw2 : raw2.toString());
21616
- if (!msg)
21617
- return;
21618
- const rws = asRelay(ws);
21619
- switch (msg.type) {
21620
- case "session:init": {
21621
- if (ws.data.sessionId) {
21622
- const prev = sessions3.get(ws.data.sessionId);
21623
- if (prev)
21624
- detachSession(prev);
21625
- }
21626
- const session = createSession(rws, msg);
21627
- if (!session)
21628
- break;
21629
- ws.data.sessionId = session.id;
21630
- send(rws, { type: "session:ready", sessionId: session.id });
21631
- break;
21632
- }
21633
- case "session:reconnect": {
21634
- const existing = sessions3.get(msg.sessionId);
21635
- if (existing && !existing.exited) {
21636
- if (ws.data.sessionId && ws.data.sessionId !== msg.sessionId) {
21637
- const prev = sessions3.get(ws.data.sessionId);
21638
- if (prev)
21639
- detachSession(prev);
21640
- }
21641
- if (existing.ws && existing.ws !== rws) {
21642
- send(existing.ws, { type: "session:detached" });
21643
- }
21644
- ws.data.sessionId = existing.id;
21645
- attachSession(existing, rws, msg.cols, msg.rows);
21646
- send(rws, {
21647
- type: "session:ready",
21648
- sessionId: existing.id,
21649
- reconnected: true
21650
- });
21651
- } else {
21652
- send(rws, { type: "session:expired", sessionId: msg.sessionId });
21653
- }
21654
- break;
21655
- }
21656
- case "terminal:input": {
21657
- if (!ws.data.sessionId)
21658
- return;
21659
- const session = sessions3.get(ws.data.sessionId);
21660
- if (session && !session.exited) {
21661
- session.pty.write(msg.data);
21662
- }
21663
- break;
21664
- }
21665
- case "terminal:resize": {
21666
- if (!ws.data.sessionId)
21667
- return;
21668
- const session = sessions3.get(ws.data.sessionId);
21669
- if (session && !session.exited) {
21670
- const cols = Math.max(msg.cols || 80, 20);
21671
- const rows = Math.max(msg.rows || 24, 4);
21672
- session.pty.resize(cols, rows);
21673
- session.cols = cols;
21674
- session.rows = rows;
21675
- }
21676
- break;
21677
- }
21856
+ return c.json(result);
21857
+ });
21858
+ app.get("/api/events", async (c) => {
21859
+ const brokerHost = process.env.OPENSCOUT_BROKER_HOST ?? "127.0.0.1";
21860
+ const brokerPort = process.env.OPENSCOUT_BROKER_PORT ?? "65535";
21861
+ const brokerUrl = process.env.OPENSCOUT_BROKER_URL ?? `http://${brokerHost}:${brokerPort}`;
21862
+ try {
21863
+ return await relayEventStream(`${brokerUrl}/v1/events/stream`, {
21864
+ signal: c.req.raw.signal
21865
+ });
21866
+ } catch {
21867
+ return c.text("Broker unreachable", 502);
21678
21868
  }
21679
- },
21680
- close(ws) {
21681
- if (ws.data.sessionId) {
21682
- const session = sessions3.get(ws.data.sessionId);
21683
- if (session)
21684
- detachSession(session);
21685
- ws.data.sessionId = null;
21869
+ });
21870
+ await registerScoutWebAssets(app, {
21871
+ assetMode: options.assetMode,
21872
+ staticRoot: resolveStaticRoot(options.staticRoot),
21873
+ viteDevUrl: options.viteDevUrl,
21874
+ defaultViteUrl: "http://127.0.0.1:5180"
21875
+ });
21876
+ const warmupCaches = () => Promise.allSettled([
21877
+ shellStateCache.refresh(),
21878
+ loadPairingState(currentDirectory, true)
21879
+ ]).then((results) => {
21880
+ for (const result of results) {
21881
+ if (result.status === "rejected") {
21882
+ const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
21883
+ console.error("[openscout-web api] initial cache warmup failed:", message);
21884
+ }
21686
21885
  }
21687
- }
21688
- };
21689
- function destroyAllRelaySessions() {
21690
- for (const [id] of sessions3)
21691
- destroy(id);
21886
+ });
21887
+ return { app, warmupCaches };
21692
21888
  }
21693
21889
 
21694
21890
  // packages/web/server/index.ts
@@ -21702,11 +21898,11 @@ function resolveStaticRoot2() {
21702
21898
  }
21703
21899
  const selfDir = dirname13(fileURLToPath8(import.meta.url));
21704
21900
  const siblingClientRoot = join21(selfDir, "client");
21705
- if (existsSync14(join21(siblingClientRoot, "index.html"))) {
21901
+ if (existsSync15(join21(siblingClientRoot, "index.html"))) {
21706
21902
  return siblingClientRoot;
21707
21903
  }
21708
21904
  const sourceDistClientRoot = resolve10(selfDir, "../dist/client");
21709
- if (existsSync14(join21(sourceDistClientRoot, "index.html"))) {
21905
+ if (existsSync15(join21(sourceDistClientRoot, "index.html"))) {
21710
21906
  return sourceDistClientRoot;
21711
21907
  }
21712
21908
  return;