@threadbase-sh/streamer 1.58.1 → 1.58.3

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) {
@@ -11398,7 +11480,7 @@ var PairTokenStore = class {
11398
11480
  };
11399
11481
  }
11400
11482
  /**
11401
- * Whether `consume` would succeed right now, WITHOUT spending the token.
11483
+ * What `consume` would answer right now, WITHOUT spending the token.
11402
11484
  *
11403
11485
  * Exists so a caller can reject a bad token before doing any work, and still
11404
11486
  * spend the token only once the work has succeeded. A pair token is
@@ -11407,10 +11489,14 @@ var PairTokenStore = class {
11407
11489
  * indistinguishable from an attacker replaying a photographed code, which is
11408
11490
  * the one signal `design.md` §2.6 designates as replay detection.
11409
11491
  *
11410
- * Advisory, not a reservation: it takes no lock and holds nothing. The
11411
- * authoritative answer is still `consume`'s.
11492
+ * **Named for its relationship to `consume`, deliberately.** It reserves
11493
+ * nothing and takes no lock, so two callers can both be told `{ ok: true }`
11494
+ * for the same token. A name like `verify` would read as a gate that had
11495
+ * decided something, and inviting a caller to act on this alone is precisely
11496
+ * the misuse the ordering it enables exists to prevent. A reader who sees
11497
+ * `wouldConsume` asks where the `consume` is, which is the right question.
11412
11498
  */
11413
- verify(token) {
11499
+ wouldConsume(token) {
11414
11500
  const result = this.check(token);
11415
11501
  return result.ok ? { ok: true } : result;
11416
11502
  }
@@ -12334,7 +12420,15 @@ function createLiveSessionOptions(deps) {
12334
12420
  // grace-timer/idle-reaper hold (statusSource "shutdown") apart from a
12335
12421
  // genuine process exit ("process-exit"), and reports both as
12336
12422
  // `lifecycle: "completed"`. See managedToResponse in session-store.ts.
12337
- ...session.statusSource != null && { statusSource: session.statusSource }
12423
+ ...session.statusSource != null && { statusSource: session.statusSource },
12424
+ // Why a session died, not just that it did. Without this the store's
12425
+ // copy has no failureReason, so managedToResponse falls through to
12426
+ // `lifecycle: "completed"` (see session-store.ts) and a session that
12427
+ // never started — missing CLI, missing project dir — is reported to
12428
+ // every client as one that finished normally. Guarded like its
12429
+ // neighbours: a later transition must not blank a recorded failure.
12430
+ ...session.failureReason != null && { failureReason: session.failureReason },
12431
+ ...session.failureCode != null && { failureCode: session.failureCode }
12338
12432
  });
12339
12433
  deps.managedSessionsRepo()?.recordStatus(
12340
12434
  session.id,
@@ -15789,6 +15883,41 @@ var StreamerServer = class {
15789
15883
  json(res, 503, body);
15790
15884
  return true;
15791
15885
  }
15886
+ /**
15887
+ * Say which provider CLIs this machine can actually launch.
15888
+ *
15889
+ * The operator cannot discover this case unaided: under launchd/Task
15890
+ * Scheduler the service inherits a stripped PATH, so a CLI that works
15891
+ * perfectly in their terminal is invisible to the service, and every session
15892
+ * start dies milliseconds in. `/api/diagnostics` answers it too, but only for
15893
+ * someone who already suspects it.
15894
+ *
15895
+ * Availability only, never a version — `--version` costs a process spawn per
15896
+ * provider (85ms for claude here) and belongs on the first request that wants
15897
+ * it, not on boot.
15898
+ *
15899
+ * Called AFTER the port is bound, which is not cosmetic. This is the first
15900
+ * caller of the exe resolvers in the process, so the memo is cold by
15901
+ * definition and each provider pays one synchronous `which` / `where.exe`
15902
+ * (platform.ts) with a 3s timeout. On POSIX that is 3ms found, 7ms missing.
15903
+ * Windows is the risk — `where.exe` is slower, `execFileSync` blocks the
15904
+ * event loop, and Task Scheduler's stripped PATH is exactly where a miss
15905
+ * pays the full timeout — so the worst case is ~6s of two blocking lookups.
15906
+ * After `listen()` that delays the first requests on a box that cannot start
15907
+ * a session anyway; before it, it would have delayed binding the port.
15908
+ */
15909
+ logProviderAvailability() {
15910
+ for (const provider of [CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER]) {
15911
+ if (locateProviderExe(provider)) {
15912
+ this.log.info(`Provider ${provider}: found`, { event: "config.provider", provider });
15913
+ } else {
15914
+ this.log.warn(`Provider ${provider}: not found on PATH \u2014 sessions cannot start`, {
15915
+ event: "config.provider_missing",
15916
+ provider
15917
+ });
15918
+ }
15919
+ }
15920
+ }
15792
15921
  async listen(port, opts) {
15793
15922
  if (this.featureFlags.ptyHost) {
15794
15923
  try {
@@ -15849,6 +15978,7 @@ var StreamerServer = class {
15849
15978
  event: "server.listening",
15850
15979
  ...this.host !== void 0 && { host: this.host }
15851
15980
  });
15981
+ this.logProviderAvailability();
15852
15982
  try {
15853
15983
  this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
15854
15984
  this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
@@ -16225,8 +16355,14 @@ var StreamerServer = class {
16225
16355
  json(res, 400, { error: "Missing token or clientPublicKey" });
16226
16356
  return;
16227
16357
  }
16228
- const precheck = this.pairTokens.verify(token);
16358
+ const precheck = this.pairTokens.wouldConsume(token);
16229
16359
  if (!precheck.ok) {
16360
+ if (precheck.reason === "used") {
16361
+ this.log.warn(
16362
+ "[pair] a pair token was replayed. If you did not just pair a device, check the paired-devices list and revoke anything you do not recognise.",
16363
+ { event: "pair.token_replayed", ip }
16364
+ );
16365
+ }
16230
16366
  json(res, 401, { error: `Pair token ${precheck.reason}` });
16231
16367
  return;
16232
16368
  }