@threadbase-sh/streamer 1.58.0 → 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
@@ -1305,11 +1340,19 @@ var CodexPtyRunner = class {
1305
1340
  onStatusChange;
1306
1341
  onPhaseChange;
1307
1342
  onReady;
1308
- // Broadcasts Codex's blocking startup gates (directory trust, hooks review)
1309
- // as question cards; null dismisses the card once the gate leaves the screen.
1343
+ // Every Codex prompt the client can answer — startup gates (directory trust,
1344
+ // hooks review), command approvals, and the rate-limit model picker is
1345
+ // broadcast through this one channel; null dismisses the card once the prompt
1346
+ // leaves the screen.
1347
+ //
1348
+ // Deliberately NOT onLiveQuestion/onLiveQuestionGone, which is Claude's
1349
+ // AskUserQuestion transport. Both channels land on the same mobile
1350
+ // QuestionCard, and the permission one is the correct fit for Codex: its menus
1351
+ // are answered by the option's real on-screen number (parseCodexNumberedOptions
1352
+ // emits `answerKeys: "2\r"`), which is exactly what `permissionIndices` carries
1353
+ // and what AskUserQuestion's down-arrow-count model cannot express. Wiring the
1354
+ // question channel as well would be a second path to the same card.
1310
1355
  onPermissionChange;
1311
- onLiveQuestion;
1312
- onLiveQuestionGone;
1313
1356
  onUserMessage;
1314
1357
  log;
1315
1358
  // Tracks sessions whose PTY has spawned but Codex hasn't yet reached its
@@ -1359,8 +1402,6 @@ var CodexPtyRunner = class {
1359
1402
  this.onPhaseChange = options.onPhaseChange;
1360
1403
  this.onReady = options.onReady;
1361
1404
  this.onPermissionChange = options.onPermissionChange;
1362
- this.onLiveQuestion = options.onLiveQuestion;
1363
- this.onLiveQuestionGone = options.onLiveQuestionGone;
1364
1405
  this.onUserMessage = options.onUserMessage;
1365
1406
  this.log = options.logger ?? getLogger("codex-pty");
1366
1407
  }
@@ -3668,11 +3709,13 @@ var LiveSessionManager = class {
3668
3709
  async start(sessionId, options) {
3669
3710
  const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
3670
3711
  const runner = this.assertSupportedProvider(provider, options.projectPath);
3712
+ this.assertProviderInstalled(provider);
3671
3713
  return runner.start(sessionId, options);
3672
3714
  }
3673
3715
  async startFresh(options) {
3674
3716
  const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
3675
3717
  const runner = this.assertSupportedProvider(provider, options.projectPath);
3718
+ this.assertProviderInstalled(provider);
3676
3719
  return runner.startFresh(options);
3677
3720
  }
3678
3721
  /**
@@ -3691,6 +3734,7 @@ var LiveSessionManager = class {
3691
3734
  err.statusCode = 501;
3692
3735
  throw err;
3693
3736
  }
3737
+ this.assertProviderInstalled(provider);
3694
3738
  return runner.startFork(options);
3695
3739
  }
3696
3740
  sendInput(sessionId, input) {
@@ -3772,6 +3816,30 @@ var LiveSessionManager = class {
3772
3816
  }
3773
3817
  throw new Error(`Session not found: ${sessionId}`);
3774
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
+ }
3775
3843
  assertSupportedProvider(provider, projectPath) {
3776
3844
  if (this.remoteRunner) return this.remoteRunner;
3777
3845
  const runner = this.runners.get(provider);
@@ -4454,7 +4522,9 @@ var corsMiddleware = (configValue) => {
4454
4522
  // src/api/middleware/error.middleware.ts
4455
4523
  var errorMiddleware = (err, c) => {
4456
4524
  const message = err instanceof Error ? err.message : "Internal server error";
4457
- 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);
4458
4528
  };
4459
4529
 
4460
4530
  // src/api/routes/backup.routes.ts
@@ -5009,24 +5079,26 @@ function computeBootToken() {
5009
5079
  }
5010
5080
 
5011
5081
  // src/api/routes/diagnostics.routes.ts
5012
- function providerCheck(name, resolve2) {
5082
+ function providerCheck(name) {
5013
5083
  try {
5014
- const exe = resolve2();
5015
- return {
5016
- id: `provider:${name}`,
5017
- status: "ok",
5018
- summary: `${name} CLI is installed.`,
5019
- remediation: "NONE",
5020
- detail: { location: redactPath(exe) }
5021
- };
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
+ }
5022
5094
  } catch {
5023
- return {
5024
- id: `provider:${name}`,
5025
- status: "failed",
5026
- summary: `${name} CLI could not be located. Sessions for this provider cannot start.`,
5027
- remediation: "PROVIDER_NOT_INSTALLED"
5028
- };
5029
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
+ };
5030
5102
  }
5031
5103
  var createDiagnosticsRoutes = (deps) => {
5032
5104
  const app = new import_hono8.Hono();
@@ -5039,8 +5111,8 @@ var createDiagnosticsRoutes = (deps) => {
5039
5111
  remediation: "NONE",
5040
5112
  detail: { version: getVersion(), uptimeSeconds: Math.floor(process.uptime()) }
5041
5113
  });
5042
- checks.push(providerCheck("claude-code", resolveClaudeExe));
5043
- checks.push(providerCheck("codex-cli", resolveCodexExe));
5114
+ checks.push(providerCheck(CLAUDE_CODE_PROVIDER));
5115
+ checks.push(providerCheck(CODEX_CLI_PROVIDER));
5044
5116
  const cacheAlert = deps.cacheMonitor()?.healthzField();
5045
5117
  checks.push(
5046
5118
  cacheAlert ? {
@@ -6112,7 +6184,10 @@ function parseVersionOutput(output) {
6112
6184
  const match = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
6113
6185
  return match ? match[0] : null;
6114
6186
  }
6187
+ var versionByExe = /* @__PURE__ */ new Map();
6115
6188
  function runVersion(exe) {
6189
+ const cached3 = versionByExe.get(exe);
6190
+ if (cached3 !== void 0) return Promise.resolve(cached3);
6116
6191
  const viaShell = isWindows && /\.(?:cmd|bat)$/i.test(exe);
6117
6192
  const file = viaShell ? `"${exe}"` : exe;
6118
6193
  return new Promise((resolve2) => {
@@ -6122,7 +6197,9 @@ function runVersion(exe) {
6122
6197
  { timeout: VERSION_TIMEOUT_MS, shell: viaShell, windowsHide: true },
6123
6198
  (err, stdout, stderr) => {
6124
6199
  if (err && !stdout && !stderr) return resolve2(null);
6125
- resolve2(parseVersionOutput(`${stdout}${stderr}`));
6200
+ const version = parseVersionOutput(`${stdout}${stderr}`);
6201
+ if (version !== null) versionByExe.set(exe, version);
6202
+ resolve2(version);
6126
6203
  }
6127
6204
  );
6128
6205
  });
@@ -6166,13 +6243,16 @@ function compareSemver(a, b) {
6166
6243
  if (pb.pre === null) return -1;
6167
6244
  return pa.pre < pb.pre ? -1 : 1;
6168
6245
  }
6169
- async function providerHealth(name, resolveExe, detect = runVersion) {
6246
+ async function providerHealth(name, locateExe = () => locateProviderExe(name), detect = runVersion) {
6170
6247
  const verifiedAgainst = VERIFIED_AGAINST[name];
6171
6248
  const capabilities = capabilitiesFor(name);
6172
- let exe;
6249
+ let exe = null;
6173
6250
  try {
6174
- exe = resolveExe();
6251
+ exe = locateExe();
6175
6252
  } catch {
6253
+ exe = null;
6254
+ }
6255
+ if (exe === null) {
6176
6256
  return {
6177
6257
  name,
6178
6258
  available: false,
@@ -6209,8 +6289,8 @@ var createProviderRoutes = () => {
6209
6289
  const app = new import_hono14.Hono();
6210
6290
  app.get("/", async (c) => {
6211
6291
  const providers = await Promise.all([
6212
- providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
6213
- providerHealth(CODEX_CLI_PROVIDER, resolveCodexExe)
6292
+ providerHealth(CLAUDE_CODE_PROVIDER),
6293
+ providerHealth(CODEX_CLI_PROVIDER)
6214
6294
  ]);
6215
6295
  return c.json({ providers });
6216
6296
  });
@@ -10681,7 +10761,10 @@ var SessionHandlers = class {
10681
10761
  });
10682
10762
  this.sessionStore.addManaged(session);
10683
10763
  this.registryBoot.recordSessionSpawn(session);
10684
- 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
+ );
10685
10768
  const current = this.sessionStore.get(session.id, this.deps.ptyAttachedIds());
10686
10769
  if (outcome === "ready" && current) {
10687
10770
  json(res, 200, { session: current });
@@ -10689,7 +10772,7 @@ var SessionHandlers = class {
10689
10772
  json(res, 502, {
10690
10773
  id: session.id,
10691
10774
  status: "idle",
10692
- error: current.failureReason ?? "Session exited before becoming ready"
10775
+ error: settled?.failureReason ?? "Session exited before becoming ready"
10693
10776
  });
10694
10777
  } else {
10695
10778
  json(res, 202, { id: session.id, status: "pending" });
@@ -10703,11 +10786,16 @@ var SessionHandlers = class {
10703
10786
  } catch (err) {
10704
10787
  const message = err instanceof Error ? err.message : "Failed to start session";
10705
10788
  const statusCode = typeof err.statusCode === "number" ? err.statusCode : 500;
10789
+ const code = err.code;
10706
10790
  this.log.error(`[start] failed to start session: ${message}`, {
10707
10791
  event: "session.start_failed",
10708
10792
  error: message
10709
10793
  });
10710
- json(res, statusCode, { error: message });
10794
+ json(
10795
+ res,
10796
+ statusCode,
10797
+ typeof code === "string" ? { error: message, code } : { error: message }
10798
+ );
10711
10799
  }
10712
10800
  }
10713
10801
  async handleSetSessionName(sessionId, req, res) {
@@ -11391,7 +11479,37 @@ var PairTokenStore = class {
11391
11479
  expiresInSeconds: Math.floor(this.ttlMs / 1e3)
11392
11480
  };
11393
11481
  }
11482
+ /**
11483
+ * Whether `consume` would succeed right now, WITHOUT spending the token.
11484
+ *
11485
+ * Exists so a caller can reject a bad token before doing any work, and still
11486
+ * spend the token only once the work has succeeded. A pair token is
11487
+ * single-use and lives 180 seconds, so spending it on a request that then
11488
+ * fails costs the user a whole new QR — and, worse, makes their retry
11489
+ * indistinguishable from an attacker replaying a photographed code, which is
11490
+ * the one signal `design.md` §2.6 designates as replay detection.
11491
+ *
11492
+ * Advisory, not a reservation: it takes no lock and holds nothing. The
11493
+ * authoritative answer is still `consume`'s.
11494
+ */
11495
+ verify(token) {
11496
+ const result = this.check(token);
11497
+ return result.ok ? { ok: true } : result;
11498
+ }
11394
11499
  consume(token) {
11500
+ const result = this.check(token);
11501
+ if (!result.ok) return result;
11502
+ result.record.used = true;
11503
+ return { ok: true };
11504
+ }
11505
+ /**
11506
+ * The shared predicate behind `verify` and `consume`.
11507
+ *
11508
+ * One implementation on purpose: two copies of "is this token usable" is two
11509
+ * places for the expiry or single-use rule to drift, and a drift in this
11510
+ * direction fails open.
11511
+ */
11512
+ check(token) {
11395
11513
  const record2 = this.current;
11396
11514
  if (!record2 || record2.token !== token) return { ok: false, reason: "unknown" };
11397
11515
  if (Date.now() > record2.expiresAt) {
@@ -11399,8 +11517,7 @@ var PairTokenStore = class {
11399
11517
  return { ok: false, reason: "expired" };
11400
11518
  }
11401
11519
  if (record2.used) return { ok: false, reason: "used" };
11402
- record2.used = true;
11403
- return { ok: true };
11520
+ return { ok: true, record: record2 };
11404
11521
  }
11405
11522
  peek() {
11406
11523
  return this.current;
@@ -12299,7 +12416,15 @@ function createLiveSessionOptions(deps) {
12299
12416
  // grace-timer/idle-reaper hold (statusSource "shutdown") apart from a
12300
12417
  // genuine process exit ("process-exit"), and reports both as
12301
12418
  // `lifecycle: "completed"`. See managedToResponse in session-store.ts.
12302
- ...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 }
12303
12428
  });
12304
12429
  deps.managedSessionsRepo()?.recordStatus(
12305
12430
  session.id,
@@ -15754,6 +15879,41 @@ var StreamerServer = class {
15754
15879
  json(res, 503, body);
15755
15880
  return true;
15756
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
+ }
15757
15917
  async listen(port, opts) {
15758
15918
  if (this.featureFlags.ptyHost) {
15759
15919
  try {
@@ -15814,6 +15974,7 @@ var StreamerServer = class {
15814
15974
  event: "server.listening",
15815
15975
  ...this.host !== void 0 && { host: this.host }
15816
15976
  });
15977
+ this.logProviderAvailability();
15817
15978
  try {
15818
15979
  this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
15819
15980
  this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
@@ -16190,9 +16351,9 @@ var StreamerServer = class {
16190
16351
  json(res, 400, { error: "Missing token or clientPublicKey" });
16191
16352
  return;
16192
16353
  }
16193
- const result = this.pairTokens.consume(token);
16194
- if (!result.ok) {
16195
- json(res, 401, { error: `Pair token ${result.reason}` });
16354
+ const precheck = this.pairTokens.verify(token);
16355
+ if (!precheck.ok) {
16356
+ json(res, 401, { error: `Pair token ${precheck.reason}` });
16196
16357
  return;
16197
16358
  }
16198
16359
  let sealed;
@@ -16203,6 +16364,11 @@ var StreamerServer = class {
16203
16364
  json(res, 400, { error: message });
16204
16365
  return;
16205
16366
  }
16367
+ const result = this.pairTokens.consume(token);
16368
+ if (!result.ok) {
16369
+ json(res, 401, { error: `Pair token ${result.reason}` });
16370
+ return;
16371
+ }
16206
16372
  const ts = (/* @__PURE__ */ new Date()).toISOString();
16207
16373
  this.log.info(`[pair] token exchanged from ${ip} at ${ts}`, {
16208
16374
  event: "pair.token_exchanged",