@threadbase-sh/streamer 1.58.1 → 1.58.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -907,6 +907,21 @@ var import_child_process = require("child_process");
907
907
  var import_fs3 = require("fs");
908
908
  var import_os2 = require("os");
909
909
  var import_path3 = require("path");
910
+
911
+ // src/providers.ts
912
+ var CLAUDE_CODE_PROVIDER = "claude-code";
913
+ var CODEX_CLI_PROVIDER = "codex-cli";
914
+ function isProviderName(value) {
915
+ return value === CLAUDE_CODE_PROVIDER || value === CODEX_CLI_PROVIDER;
916
+ }
917
+ function coerceProviderForRunner(value) {
918
+ return isProviderName(value) ? value : CLAUDE_CODE_PROVIDER;
919
+ }
920
+ function isProviderResumable(_provider, availabilityResumable) {
921
+ return availabilityResumable;
922
+ }
923
+
924
+ // src/platform.ts
910
925
  var isWindows = (0, import_os2.platform)() === "win32";
911
926
  var WINDOWS_EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([".exe", ".cmd", ".bat"]);
912
927
  function isWindowsExecutablePath(path) {
@@ -1036,18 +1051,38 @@ function resolveCodexExe() {
1036
1051
  _codexExe = "codex";
1037
1052
  return _codexExe;
1038
1053
  }
1039
-
1040
- // src/providers.ts
1041
- var CLAUDE_CODE_PROVIDER = "claude-code";
1042
- var CODEX_CLI_PROVIDER = "codex-cli";
1043
- function isProviderName(value) {
1044
- return value === CLAUDE_CODE_PROVIDER || value === CODEX_CLI_PROVIDER;
1054
+ function isExecutableFile(path) {
1055
+ try {
1056
+ if (!(0, import_fs3.statSync)(path).isFile()) return false;
1057
+ (0, import_fs3.accessSync)(path, import_fs3.constants.X_OK);
1058
+ return true;
1059
+ } catch {
1060
+ return false;
1061
+ }
1045
1062
  }
1046
- function coerceProviderForRunner(value) {
1047
- return isProviderName(value) ? value : CLAUDE_CODE_PROVIDER;
1063
+ function locateExecutable(exe) {
1064
+ if (/[\\/]/.test(exe)) return isExecutableFile(exe) ? exe : null;
1065
+ const names = isWindows ? [
1066
+ ...isWindowsExecutablePath(exe) ? [exe] : [],
1067
+ ...[...WINDOWS_EXECUTABLE_EXTENSIONS].map((ext) => `${exe}${ext}`)
1068
+ ] : [exe];
1069
+ for (const dir of (process.env.PATH ?? "").split(import_path3.delimiter)) {
1070
+ if (!dir) continue;
1071
+ for (const name of names) {
1072
+ const candidate = (0, import_path3.join)(dir, name);
1073
+ if (isExecutableFile(candidate)) return candidate;
1074
+ }
1075
+ }
1076
+ return null;
1048
1077
  }
1049
- function isProviderResumable(_provider, availabilityResumable) {
1050
- return availabilityResumable;
1078
+ function locateProviderExe(provider) {
1079
+ const isCodex = provider === CODEX_CLI_PROVIDER;
1080
+ const found = locateExecutable(isCodex ? resolveCodexExe() : resolveClaudeExe());
1081
+ if (found === null) {
1082
+ if (isCodex) clearCodexExeCache();
1083
+ else clearClaudeExeCache();
1084
+ }
1085
+ return found;
1051
1086
  }
1052
1087
 
1053
1088
  // src/pty-shared.ts
@@ -3674,11 +3709,13 @@ var LiveSessionManager = class {
3674
3709
  async start(sessionId, options) {
3675
3710
  const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
3676
3711
  const runner = this.assertSupportedProvider(provider, options.projectPath);
3712
+ this.assertProviderInstalled(provider);
3677
3713
  return runner.start(sessionId, options);
3678
3714
  }
3679
3715
  async startFresh(options) {
3680
3716
  const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
3681
3717
  const runner = this.assertSupportedProvider(provider, options.projectPath);
3718
+ this.assertProviderInstalled(provider);
3682
3719
  return runner.startFresh(options);
3683
3720
  }
3684
3721
  /**
@@ -3697,6 +3734,7 @@ var LiveSessionManager = class {
3697
3734
  err.statusCode = 501;
3698
3735
  throw err;
3699
3736
  }
3737
+ this.assertProviderInstalled(provider);
3700
3738
  return runner.startFork(options);
3701
3739
  }
3702
3740
  sendInput(sessionId, input) {
@@ -3778,6 +3816,30 @@ var LiveSessionManager = class {
3778
3816
  }
3779
3817
  throw new Error(`Session not found: ${sessionId}`);
3780
3818
  }
3819
+ /**
3820
+ * Refuse before spawning when the provider's CLI is not on this machine.
3821
+ *
3822
+ * Without this the spawn "succeeds": on POSIX execvp fails inside the forked
3823
+ * child, so a session appears, exits ~12ms later with code 1 and no output,
3824
+ * and the caller is told only that it "exited before becoming ready" — or,
3825
+ * on the Claude resume path, is told nothing at all, since that path answers
3826
+ * 200 before the process has had a chance to die. Every start route funnels
3827
+ * through here, so one check covers start, resume, adopt and fork.
3828
+ *
3829
+ * 503, not 500: the request was well-formed and the fault is this machine's
3830
+ * environment. `code` is what mobile branches on (it reads `errBody.code`),
3831
+ * and `PROVIDER_NOT_INSTALLED` is a remediation string it already knows.
3832
+ */
3833
+ assertProviderInstalled(provider) {
3834
+ if (locateProviderExe(provider) !== null) return;
3835
+ const command = provider === CODEX_CLI_PROVIDER ? "codex" : "claude";
3836
+ const err = new Error(
3837
+ `The ${command} command was not found on this server. Install the ${provider} CLI, or make sure it is on the PATH the streamer runs with.`
3838
+ );
3839
+ err.statusCode = 503;
3840
+ err.code = "PROVIDER_NOT_INSTALLED";
3841
+ throw err;
3842
+ }
3781
3843
  assertSupportedProvider(provider, projectPath) {
3782
3844
  if (this.remoteRunner) return this.remoteRunner;
3783
3845
  const runner = this.runners.get(provider);
@@ -4460,7 +4522,9 @@ var corsMiddleware = (configValue) => {
4460
4522
  // src/api/middleware/error.middleware.ts
4461
4523
  var errorMiddleware = (err, c) => {
4462
4524
  const message = err instanceof Error ? err.message : "Internal server error";
4463
- return c.json({ error: message }, 500);
4525
+ const { statusCode, code } = err;
4526
+ const status = typeof statusCode === "number" && statusCode >= 400 && statusCode <= 599 ? statusCode : 500;
4527
+ return c.json(typeof code === "string" ? { error: message, code } : { error: message }, status);
4464
4528
  };
4465
4529
 
4466
4530
  // src/api/routes/backup.routes.ts
@@ -5015,24 +5079,26 @@ function computeBootToken() {
5015
5079
  }
5016
5080
 
5017
5081
  // src/api/routes/diagnostics.routes.ts
5018
- function providerCheck(name, resolve2) {
5082
+ function providerCheck(name) {
5019
5083
  try {
5020
- const exe = resolve2();
5021
- return {
5022
- id: `provider:${name}`,
5023
- status: "ok",
5024
- summary: `${name} CLI is installed.`,
5025
- remediation: "NONE",
5026
- detail: { location: redactPath(exe) }
5027
- };
5084
+ const exe = locateProviderExe(name);
5085
+ if (exe !== null) {
5086
+ return {
5087
+ id: `provider:${name}`,
5088
+ status: "ok",
5089
+ summary: `${name} CLI is installed.`,
5090
+ remediation: "NONE",
5091
+ detail: { location: redactPath(exe) }
5092
+ };
5093
+ }
5028
5094
  } catch {
5029
- return {
5030
- id: `provider:${name}`,
5031
- status: "failed",
5032
- summary: `${name} CLI could not be located. Sessions for this provider cannot start.`,
5033
- remediation: "PROVIDER_NOT_INSTALLED"
5034
- };
5035
5095
  }
5096
+ return {
5097
+ id: `provider:${name}`,
5098
+ status: "failed",
5099
+ summary: `${name} CLI could not be located. Sessions for this provider cannot start.`,
5100
+ remediation: "PROVIDER_NOT_INSTALLED"
5101
+ };
5036
5102
  }
5037
5103
  var createDiagnosticsRoutes = (deps) => {
5038
5104
  const app = new import_hono8.Hono();
@@ -5045,8 +5111,8 @@ var createDiagnosticsRoutes = (deps) => {
5045
5111
  remediation: "NONE",
5046
5112
  detail: { version: getVersion(), uptimeSeconds: Math.floor(process.uptime()) }
5047
5113
  });
5048
- checks.push(providerCheck("claude-code", resolveClaudeExe));
5049
- checks.push(providerCheck("codex-cli", resolveCodexExe));
5114
+ checks.push(providerCheck(CLAUDE_CODE_PROVIDER));
5115
+ checks.push(providerCheck(CODEX_CLI_PROVIDER));
5050
5116
  const cacheAlert = deps.cacheMonitor()?.healthzField();
5051
5117
  checks.push(
5052
5118
  cacheAlert ? {
@@ -6118,7 +6184,10 @@ function parseVersionOutput(output) {
6118
6184
  const match = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
6119
6185
  return match ? match[0] : null;
6120
6186
  }
6187
+ var versionByExe = /* @__PURE__ */ new Map();
6121
6188
  function runVersion(exe) {
6189
+ const cached3 = versionByExe.get(exe);
6190
+ if (cached3 !== void 0) return Promise.resolve(cached3);
6122
6191
  const viaShell = isWindows && /\.(?:cmd|bat)$/i.test(exe);
6123
6192
  const file = viaShell ? `"${exe}"` : exe;
6124
6193
  return new Promise((resolve2) => {
@@ -6128,7 +6197,9 @@ function runVersion(exe) {
6128
6197
  { timeout: VERSION_TIMEOUT_MS, shell: viaShell, windowsHide: true },
6129
6198
  (err, stdout, stderr) => {
6130
6199
  if (err && !stdout && !stderr) return resolve2(null);
6131
- resolve2(parseVersionOutput(`${stdout}${stderr}`));
6200
+ const version = parseVersionOutput(`${stdout}${stderr}`);
6201
+ if (version !== null) versionByExe.set(exe, version);
6202
+ resolve2(version);
6132
6203
  }
6133
6204
  );
6134
6205
  });
@@ -6172,13 +6243,16 @@ function compareSemver(a, b) {
6172
6243
  if (pb.pre === null) return -1;
6173
6244
  return pa.pre < pb.pre ? -1 : 1;
6174
6245
  }
6175
- async function providerHealth(name, resolveExe, detect = runVersion) {
6246
+ async function providerHealth(name, locateExe = () => locateProviderExe(name), detect = runVersion) {
6176
6247
  const verifiedAgainst = VERIFIED_AGAINST[name];
6177
6248
  const capabilities = capabilitiesFor(name);
6178
- let exe;
6249
+ let exe = null;
6179
6250
  try {
6180
- exe = resolveExe();
6251
+ exe = locateExe();
6181
6252
  } catch {
6253
+ exe = null;
6254
+ }
6255
+ if (exe === null) {
6182
6256
  return {
6183
6257
  name,
6184
6258
  available: false,
@@ -6215,8 +6289,8 @@ var createProviderRoutes = () => {
6215
6289
  const app = new import_hono14.Hono();
6216
6290
  app.get("/", async (c) => {
6217
6291
  const providers = await Promise.all([
6218
- providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
6219
- providerHealth(CODEX_CLI_PROVIDER, resolveCodexExe)
6292
+ providerHealth(CLAUDE_CODE_PROVIDER),
6293
+ providerHealth(CODEX_CLI_PROVIDER)
6220
6294
  ]);
6221
6295
  return c.json({ providers });
6222
6296
  });
@@ -10687,7 +10761,10 @@ var SessionHandlers = class {
10687
10761
  });
10688
10762
  this.sessionStore.addManaged(session);
10689
10763
  this.registryBoot.recordSessionSpawn(session);
10690
- const { outcome } = await this.deps.waitForStartupOutcome(session.id, START_READY_TIMEOUT_MS);
10764
+ const { outcome, session: settled } = await this.deps.waitForStartupOutcome(
10765
+ session.id,
10766
+ START_READY_TIMEOUT_MS
10767
+ );
10691
10768
  const current = this.sessionStore.get(session.id, this.deps.ptyAttachedIds());
10692
10769
  if (outcome === "ready" && current) {
10693
10770
  json(res, 200, { session: current });
@@ -10695,7 +10772,7 @@ var SessionHandlers = class {
10695
10772
  json(res, 502, {
10696
10773
  id: session.id,
10697
10774
  status: "idle",
10698
- error: current.failureReason ?? "Session exited before becoming ready"
10775
+ error: settled?.failureReason ?? "Session exited before becoming ready"
10699
10776
  });
10700
10777
  } else {
10701
10778
  json(res, 202, { id: session.id, status: "pending" });
@@ -10709,11 +10786,16 @@ var SessionHandlers = class {
10709
10786
  } catch (err) {
10710
10787
  const message = err instanceof Error ? err.message : "Failed to start session";
10711
10788
  const statusCode = typeof err.statusCode === "number" ? err.statusCode : 500;
10789
+ const code = err.code;
10712
10790
  this.log.error(`[start] failed to start session: ${message}`, {
10713
10791
  event: "session.start_failed",
10714
10792
  error: message
10715
10793
  });
10716
- json(res, statusCode, { error: message });
10794
+ json(
10795
+ res,
10796
+ statusCode,
10797
+ typeof code === "string" ? { error: message, code } : { error: message }
10798
+ );
10717
10799
  }
10718
10800
  }
10719
10801
  async handleSetSessionName(sessionId, req, res) {
@@ -12334,7 +12416,15 @@ function createLiveSessionOptions(deps) {
12334
12416
  // grace-timer/idle-reaper hold (statusSource "shutdown") apart from a
12335
12417
  // genuine process exit ("process-exit"), and reports both as
12336
12418
  // `lifecycle: "completed"`. See managedToResponse in session-store.ts.
12337
- ...session.statusSource != null && { statusSource: session.statusSource }
12419
+ ...session.statusSource != null && { statusSource: session.statusSource },
12420
+ // Why a session died, not just that it did. Without this the store's
12421
+ // copy has no failureReason, so managedToResponse falls through to
12422
+ // `lifecycle: "completed"` (see session-store.ts) and a session that
12423
+ // never started — missing CLI, missing project dir — is reported to
12424
+ // every client as one that finished normally. Guarded like its
12425
+ // neighbours: a later transition must not blank a recorded failure.
12426
+ ...session.failureReason != null && { failureReason: session.failureReason },
12427
+ ...session.failureCode != null && { failureCode: session.failureCode }
12338
12428
  });
12339
12429
  deps.managedSessionsRepo()?.recordStatus(
12340
12430
  session.id,
@@ -15789,6 +15879,41 @@ var StreamerServer = class {
15789
15879
  json(res, 503, body);
15790
15880
  return true;
15791
15881
  }
15882
+ /**
15883
+ * Say which provider CLIs this machine can actually launch.
15884
+ *
15885
+ * The operator cannot discover this case unaided: under launchd/Task
15886
+ * Scheduler the service inherits a stripped PATH, so a CLI that works
15887
+ * perfectly in their terminal is invisible to the service, and every session
15888
+ * start dies milliseconds in. `/api/diagnostics` answers it too, but only for
15889
+ * someone who already suspects it.
15890
+ *
15891
+ * Availability only, never a version — `--version` costs a process spawn per
15892
+ * provider (85ms for claude here) and belongs on the first request that wants
15893
+ * it, not on boot.
15894
+ *
15895
+ * Called AFTER the port is bound, which is not cosmetic. This is the first
15896
+ * caller of the exe resolvers in the process, so the memo is cold by
15897
+ * definition and each provider pays one synchronous `which` / `where.exe`
15898
+ * (platform.ts) with a 3s timeout. On POSIX that is 3ms found, 7ms missing.
15899
+ * Windows is the risk — `where.exe` is slower, `execFileSync` blocks the
15900
+ * event loop, and Task Scheduler's stripped PATH is exactly where a miss
15901
+ * pays the full timeout — so the worst case is ~6s of two blocking lookups.
15902
+ * After `listen()` that delays the first requests on a box that cannot start
15903
+ * a session anyway; before it, it would have delayed binding the port.
15904
+ */
15905
+ logProviderAvailability() {
15906
+ for (const provider of [CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER]) {
15907
+ if (locateProviderExe(provider)) {
15908
+ this.log.info(`Provider ${provider}: found`, { event: "config.provider", provider });
15909
+ } else {
15910
+ this.log.warn(`Provider ${provider}: not found on PATH \u2014 sessions cannot start`, {
15911
+ event: "config.provider_missing",
15912
+ provider
15913
+ });
15914
+ }
15915
+ }
15916
+ }
15792
15917
  async listen(port, opts) {
15793
15918
  if (this.featureFlags.ptyHost) {
15794
15919
  try {
@@ -15849,6 +15974,7 @@ var StreamerServer = class {
15849
15974
  event: "server.listening",
15850
15975
  ...this.host !== void 0 && { host: this.host }
15851
15976
  });
15977
+ this.logProviderAvailability();
15852
15978
  try {
15853
15979
  this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
15854
15980
  this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());