flyspace-cli 0.1.25 → 0.1.29

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/cli.js CHANGED
@@ -89,6 +89,7 @@ import {
89
89
  isLockedPlan,
90
90
  isLogicalCwd,
91
91
  isMemoryType,
92
+ isUnderLogicalRoot,
92
93
  isUuid,
93
94
  isValidSkillName,
94
95
  lastRunsForWorkspace,
@@ -140,7 +141,6 @@ import {
140
141
  resolveChannelPrompt,
141
142
  resolveCwd,
142
143
  resolveFeatureFlags,
143
- resolveLogicalCwd,
144
144
  resolveSelectedOutputStyle,
145
145
  resolveUserId,
146
146
  runAutomation,
@@ -184,7 +184,7 @@ import {
184
184
  warnOnBrokenChannelCwds,
185
185
  workspaceSkillsDir,
186
186
  writeSkillFile
187
- } from "./chunk-B5NWSWAM.js";
187
+ } from "./chunk-6YFDWUNF.js";
188
188
  import {
189
189
  firstReplyBody,
190
190
  getThread,
@@ -831,8 +831,8 @@ function flyspaceHome(entry = process.argv[1]) {
831
831
  if (at <= 0) return void 0;
832
832
  const npmPrefix = cliPath.slice(0, at);
833
833
  if (basename(npmPrefix) !== "cli") return void 0;
834
- const home = dirname3(npmPrefix);
835
- return existsSync3(join4(home, "bin", "flyspace")) && existsSync3(join4(home, "node", "bin", "node")) ? home : void 0;
834
+ const home2 = dirname3(npmPrefix);
835
+ return existsSync3(join4(home2, "bin", "flyspace")) && existsSync3(join4(home2, "node", "bin", "node")) ? home2 : void 0;
836
836
  }
837
837
  function serviceLabel(profile) {
838
838
  return profile === DEFAULT_PROFILE ? "ai.flyspace" : `ai.flyspace.${profile}`;
@@ -976,8 +976,8 @@ function detectClaudeBinDir() {
976
976
  }
977
977
  }
978
978
  function detectNodeBinDir() {
979
- const home = flyspaceHome();
980
- return home ? join5(home, "node", "bin") : dirname4(process.execPath);
979
+ const home2 = flyspaceHome();
980
+ return home2 ? join5(home2, "node", "bin") : dirname4(process.execPath);
981
981
  }
982
982
  function nodeShimDirs() {
983
983
  return [join5(homedir2(), ".local", "share", "mise", "shims"), join5(homedir2(), ".asdf", "shims")].filter(
@@ -985,8 +985,8 @@ function nodeShimDirs() {
985
985
  );
986
986
  }
987
987
  function detectCustieBin() {
988
- const home = flyspaceHome();
989
- if (home) return join5(home, "bin", "flyspace");
988
+ const home2 = flyspaceHome();
989
+ if (home2) return join5(home2, "bin", "flyspace");
990
990
  const argv1 = process.argv[1];
991
991
  if (argv1) {
992
992
  return resolve2(argv1);
@@ -1219,6 +1219,7 @@ import {
1219
1219
  readdirSync as readdirSync2,
1220
1220
  readFileSync as readFileSync7,
1221
1221
  readSync,
1222
+ realpathSync as realpathSync2,
1222
1223
  statSync as statSync2,
1223
1224
  symlinkSync
1224
1225
  } from "fs";
@@ -1317,8 +1318,8 @@ function codexTrustedPaths() {
1317
1318
  }
1318
1319
  }
1319
1320
  function isExcluded(path) {
1320
- const home = homedir3();
1321
- if (path === home || path === "/") return true;
1321
+ const home2 = homedir3();
1322
+ if (path === home2 || path === "/") return true;
1322
1323
  if (path === "/tmp" || path.startsWith("/tmp/")) return true;
1323
1324
  if (path === "/private/tmp" || path.startsWith("/private/tmp/")) return true;
1324
1325
  const root = flyspaceRoot();
@@ -1330,10 +1331,10 @@ function isExcluded(path) {
1330
1331
  }
1331
1332
  }
1332
1333
  function gitRepos() {
1333
- const home = homedir3();
1334
+ const home2 = homedir3();
1334
1335
  const found = [];
1335
1336
  for (const rootName of GIT_SCAN_ROOTS) {
1336
- const root = join7(home, rootName);
1337
+ const root = join7(home2, rootName);
1337
1338
  let entries2;
1338
1339
  try {
1339
1340
  entries2 = readdirSync2(root, { withFileTypes: true });
@@ -1362,7 +1363,23 @@ function detectRunners(history = { claude: false, codex: false }) {
1362
1363
  }
1363
1364
  ];
1364
1365
  }
1365
- function scanOnboarding() {
1366
+ function linkedTargets(slug) {
1367
+ const out = /* @__PURE__ */ new Map();
1368
+ if (!SLUG_RE.test(slug)) return out;
1369
+ const dir = join7(flyspaceRoot(), slug);
1370
+ try {
1371
+ for (const e of readdirSync2(dir, { withFileTypes: true })) {
1372
+ if (!e.isSymbolicLink()) continue;
1373
+ try {
1374
+ out.set(realpathSync2(join7(dir, e.name)), e.name);
1375
+ } catch {
1376
+ }
1377
+ }
1378
+ } catch {
1379
+ }
1380
+ return out;
1381
+ }
1382
+ function scanOnboarding(slug) {
1366
1383
  const byPath = /* @__PURE__ */ new Map();
1367
1384
  const history = { claude: false, codex: false };
1368
1385
  const add = (rawPath, source, mtime, sessions2, trusted = false) => {
@@ -1432,6 +1449,18 @@ function scanOnboarding() {
1432
1449
  projects.sort(
1433
1450
  (a, b) => Number(b.sources.length > 1) - Number(a.sources.length > 1) || Number(b.trusted) - Number(a.trusted) || b.lastUsed - a.lastUsed || b.sessions - a.sessions
1434
1451
  );
1452
+ if (slug) {
1453
+ const linked = linkedTargets(slug);
1454
+ for (const p of projects) {
1455
+ let real = p.path;
1456
+ try {
1457
+ real = realpathSync2(p.path);
1458
+ } catch {
1459
+ }
1460
+ const name = linked.get(real);
1461
+ if (name) p.linkedAs = name;
1462
+ }
1463
+ }
1435
1464
  return {
1436
1465
  root: flyspaceRoot(),
1437
1466
  runners: detectRunners(history),
@@ -1443,13 +1472,23 @@ var GENERAL_TEMPLATE_DIR = join7(paths.PACKAGE_ROOT, "templates", "general");
1443
1472
  function makeLink(target, linkPath) {
1444
1473
  symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
1445
1474
  }
1475
+ var SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
1476
+ function assertLinkable(target) {
1477
+ const home2 = homedir3();
1478
+ const root = existsSync6(flyspaceRoot()) ? realpathSync2(flyspaceRoot()) : flyspaceRoot();
1479
+ const denied = [join7(home2, ".ssh"), join7(home2, "Library"), join7(home2, ".config"), join7(home2, ".gnupg")];
1480
+ if (target === home2 || target === "/" || target === root || target.startsWith(`${root}/`)) {
1481
+ throw new Error(`cannot link ${target}`);
1482
+ }
1483
+ if (denied.some((d) => target === d || target.startsWith(`${d}/`))) throw new Error(`cannot link ${target}`);
1484
+ }
1446
1485
  function linkProjectFolders(slug, items) {
1447
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error(`invalid workspace slug: ${slug}`);
1486
+ if (!SLUG_RE.test(slug)) throw new Error(`invalid workspace slug: ${slug}`);
1448
1487
  const dir = join7(flyspaceRoot(), slug);
1449
1488
  mkdirSync5(dir, { recursive: true });
1450
1489
  const results = [];
1451
1490
  for (const item of items) {
1452
- if (!/^[a-z0-9][a-z0-9-]*$/.test(item.name)) throw new Error(`invalid channel name: ${item.name}`);
1491
+ if (!SLUG_RE.test(item.name)) throw new Error(`invalid channel name: ${item.name}`);
1453
1492
  const dest = join7(dir, item.name);
1454
1493
  let exists = true;
1455
1494
  try {
@@ -1460,10 +1499,21 @@ function linkProjectFolders(slug, items) {
1460
1499
  if (exists && item.name === "general" && !item.path) {
1461
1500
  cpSync(GENERAL_TEMPLATE_DIR, dest, { recursive: true, force: false });
1462
1501
  }
1502
+ if (exists && item.path) {
1503
+ let current = null;
1504
+ try {
1505
+ current = realpathSync2(dest);
1506
+ } catch {
1507
+ }
1508
+ if (current !== realpathSync2(resolve3(item.path))) {
1509
+ throw new Error(`#${item.name} already points at another folder`);
1510
+ }
1511
+ }
1463
1512
  if (!exists) {
1464
1513
  if (item.path) {
1465
1514
  const target = resolve3(item.path);
1466
1515
  if (!statSync2(target).isDirectory()) throw new Error(`not a directory: ${target}`);
1516
+ assertLinkable(realpathSync2(target));
1467
1517
  makeLink(target, dest);
1468
1518
  } else {
1469
1519
  mkdirSync5(dest, { recursive: true });
@@ -1785,7 +1835,7 @@ async function runAuthCmd(args) {
1785
1835
  }
1786
1836
 
1787
1837
  // ../api/src/version.ts
1788
- var CLI_VERSION = true ? "0.1.25" : null;
1838
+ var CLI_VERSION = true ? "0.1.29" : null;
1789
1839
  function isOlderVersion(a, b) {
1790
1840
  const parse = (v) => v.replace(/^v/, "").split("-")[0].split(".").map(Number);
1791
1841
  const [pa, pb] = [parse(a), parse(b)];
@@ -1793,6 +1843,19 @@ function isOlderVersion(a, b) {
1793
1843
  for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i];
1794
1844
  return false;
1795
1845
  }
1846
+ var LATEST_TTL_MS = 60 * 6e4;
1847
+ var latest = null;
1848
+ async function latestCliVersion() {
1849
+ if (latest && Date.now() - latest.at < LATEST_TTL_MS) return latest.version;
1850
+ let version = null;
1851
+ try {
1852
+ const res = await fetch("https://registry.npmjs.org/flyspace-cli/latest", { signal: AbortSignal.timeout(5e3) });
1853
+ if (res.ok) version = (await res.json()).version ?? null;
1854
+ } catch {
1855
+ }
1856
+ latest = { version, at: Date.now() };
1857
+ return version;
1858
+ }
1796
1859
 
1797
1860
  // src/commands/setup.ts
1798
1861
  var BOLD2 = "\x1B[1m";
@@ -1831,14 +1894,14 @@ async function servedWorkspaces() {
1831
1894
  }
1832
1895
  function checkLatestVersion() {
1833
1896
  if (!CLI_VERSION) return true;
1834
- let latest = "";
1897
+ let latest2 = "";
1835
1898
  try {
1836
- latest = execSync4(`npm view ${"flyspace-cli"} version`, { encoding: "utf-8", stdio: "pipe", timeout: 1e4 }).trim();
1899
+ latest2 = execSync4(`npm view ${"flyspace-cli"} version`, { encoding: "utf-8", stdio: "pipe", timeout: 1e4 }).trim();
1837
1900
  } catch {
1838
1901
  return true;
1839
1902
  }
1840
- if (!latest || !isOlderVersion(CLI_VERSION, latest)) return true;
1841
- bad(`flyspace ${CLI_VERSION} is out of date (latest ${latest}) ${DIM2}run: flyspace upgrade${RESET2}`);
1903
+ if (!latest2 || !isOlderVersion(CLI_VERSION, latest2)) return true;
1904
+ bad(`flyspace ${CLI_VERSION} is out of date (latest ${latest2}) ${DIM2}run: flyspace upgrade${RESET2}`);
1842
1905
  return false;
1843
1906
  }
1844
1907
  function loginArgs(args) {
@@ -2318,7 +2381,7 @@ async function runProfiles(args = []) {
2318
2381
 
2319
2382
  // src/commands/upgrade.ts
2320
2383
  import { execSync as execSync6 } from "child_process";
2321
- import { realpathSync as realpathSync2 } from "fs";
2384
+ import { realpathSync as realpathSync3 } from "fs";
2322
2385
  import { join as join11 } from "path";
2323
2386
  function restartInstalledProfiles() {
2324
2387
  if (isHostServiceInstalled()) {
@@ -2342,7 +2405,7 @@ Restarting ${installed.length} instance(s)...`);
2342
2405
  function npmInstallCommand() {
2343
2406
  let prefix;
2344
2407
  try {
2345
- const entry = realpathSync2(process.argv[1] ?? "");
2408
+ const entry = realpathSync3(process.argv[1] ?? "");
2346
2409
  const at = entry.indexOf(`/lib/node_modules/${"flyspace-cli"}/`);
2347
2410
  if (at > 0) prefix = entry.slice(0, at);
2348
2411
  } catch {
@@ -2351,24 +2414,24 @@ function npmInstallCommand() {
2351
2414
  return `npm install ${global} --ignore-scripts ${"flyspace-cli"}@latest && npm rebuild ${global} better-sqlite3`;
2352
2415
  }
2353
2416
  function npmEnv() {
2354
- const home = flyspaceHome();
2355
- return home ? { ...process.env, PATH: `${join11(home, "node", "bin")}:${process.env.PATH ?? ""}` } : process.env;
2417
+ const home2 = flyspaceHome();
2418
+ return home2 ? { ...process.env, PATH: `${join11(home2, "node", "bin")}:${process.env.PATH ?? ""}` } : process.env;
2356
2419
  }
2357
2420
  async function runUpgrade() {
2358
- const current = "0.1.25";
2421
+ const current = "0.1.29";
2359
2422
  console.log(`Current version: ${current}`);
2360
2423
  console.log("Checking for updates...\n");
2361
2424
  try {
2362
- const latest = execSync6(`npm view ${"flyspace-cli"} version`, { encoding: "utf-8", env: npmEnv() }).trim();
2363
- if (latest === current) {
2425
+ const latest2 = execSync6(`npm view ${"flyspace-cli"} version`, { encoding: "utf-8", env: npmEnv() }).trim();
2426
+ if (latest2 === current) {
2364
2427
  console.log(`Already on the latest version (${current}).`);
2365
2428
  return;
2366
2429
  }
2367
- console.log(`New version available: ${latest}`);
2430
+ console.log(`New version available: ${latest2}`);
2368
2431
  console.log("Upgrading...\n");
2369
2432
  execSync6(npmInstallCommand(), { stdio: "inherit", env: npmEnv() });
2370
2433
  console.log(`
2371
- Upgraded from ${current} to ${latest}.`);
2434
+ Upgraded from ${current} to ${latest2}.`);
2372
2435
  restartInstalledProfiles();
2373
2436
  console.log("\nReload your shell to use the new version:");
2374
2437
  console.log(" source ~/.zshrc # or source ~/.bashrc");
@@ -5038,7 +5101,7 @@ async function channelHistory(client2, nameOrId, args) {
5038
5101
  const limit = parseInt(getArg2(args, "--limit") ?? "100", 10);
5039
5102
  const isToday = args.includes("--today");
5040
5103
  let oldest = getArg2(args, "--oldest");
5041
- let latest = getArg2(args, "--latest");
5104
+ let latest2 = getArg2(args, "--latest");
5042
5105
  if (isToday) {
5043
5106
  const now = /* @__PURE__ */ new Date();
5044
5107
  const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -5048,7 +5111,7 @@ async function channelHistory(client2, nameOrId, args) {
5048
5111
  channel: channelId,
5049
5112
  limit,
5050
5113
  ...oldest ? { oldest } : {},
5051
- ...latest ? { latest } : {}
5114
+ ...latest2 ? { latest: latest2 } : {}
5052
5115
  });
5053
5116
  const messages = (result.messages ?? []).reverse();
5054
5117
  if (!messages.length) {
@@ -8501,7 +8564,7 @@ function createGuestRouter() {
8501
8564
  }
8502
8565
 
8503
8566
  // ../api/src/dashboard/auth-routes.ts
8504
- import { Hono as Hono7 } from "hono";
8567
+ import { Hono as Hono8 } from "hono";
8505
8568
 
8506
8569
  // ../api/src/dashboard/supabase-server-client.ts
8507
8570
  import { createServerClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr";
@@ -8611,6 +8674,8 @@ function pruneExpired(now) {
8611
8674
  if (entry.expires <= now) resolved.delete(key);
8612
8675
  }
8613
8676
  }
8677
+ var SUPPORT_NAME = "Flyspace Support";
8678
+ var SUPPORT_AVATAR_URL = "/logo/flyspace-mark.svg";
8614
8679
  async function resolveAuthContext(c, opts = {}) {
8615
8680
  const cookie = c.req?.header("Cookie") ?? "";
8616
8681
  if (!cookie) return resolveAuthContextUncached(c);
@@ -8669,8 +8734,9 @@ async function resolveAuthContextUncached(c) {
8669
8734
  handle: membership.handle,
8670
8735
  role: membership.role,
8671
8736
  workspaceId: workspace.id,
8672
- name: metadata.name,
8673
- avatarKey: metadata.avatarKey,
8737
+ // A support admin posts into a customer's workspace as Flyspace, not as
8738
+ // the staff member's personal profile.
8739
+ ...membership.isSupport ? { name: SUPPORT_NAME, isSupport: true } : { name: metadata.name, avatarKey: metadata.avatarKey },
8674
8740
  onboarded: Boolean(metadata.onboardedAt),
8675
8741
  passwordSet: Boolean(metadata.passwordSetAt),
8676
8742
  agentConnectedAt: workspace.agentConnectedAt ?? null,
@@ -8739,1781 +8805,2031 @@ async function verifyTurnstileToken(token) {
8739
8805
  return data.action === void 0 || data.action === "login";
8740
8806
  }
8741
8807
 
8742
- // ../api/src/dashboard/auth-routes.ts
8743
- function createAuthRouter() {
8744
- const auth = new Hono7();
8745
- const emailedLink = (deliver) => async (c) => {
8746
- const body = await c.req.json().catch(() => ({}));
8747
- const email = body.email?.trim().toLowerCase();
8748
- if (!email) return c.json({ error: "email is required" }, 400);
8749
- if (!isDevHost(c) && !await verifyTurnstileToken(body.captchaToken)) {
8750
- return c.json({ error: "captcha check failed \u2014 please retry" }, 400);
8751
- }
8752
- const workspace = await getWorkspaceBySlug(getProfile());
8753
- if (!workspace) return c.json({ error: "unknown workspace" }, 404);
8754
- const membership = await getMembershipByEmail(workspace.id, email);
8755
- if (!membership) {
8756
- return c.json({ error: "no access \u2014 ask an admin to invite you" }, 403);
8757
- }
8758
- try {
8759
- await deliver(email, callbackUrl(c));
8760
- } catch (err) {
8761
- return c.json({ error: err instanceof Error ? err.message : "could not send" }, 500);
8762
- }
8763
- return c.json({ sent: true });
8808
+ // ../api/src/store/device-store.ts
8809
+ import { createHash as createHash7, createPublicKey as createPublicKey2, randomBytes as randomBytes4, randomInt } from "crypto";
8810
+ var DEVICE_CODE_TTL_MS = 10 * 6e4;
8811
+ var ACCESS_TOKEN_TTL_MS = 15 * 6e4;
8812
+ var CHALLENGE_TTL_MS = 2 * 6e4;
8813
+ var POLL_INTERVAL_S = 5;
8814
+ var sha256 = (s) => createHash7("sha256").update(s).digest("hex");
8815
+ function parseEd25519PublicKey(b64) {
8816
+ if (typeof b64 !== "string" || b64.length > 200) return null;
8817
+ try {
8818
+ const key = createPublicKey2({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
8819
+ return key.asymmetricKeyType === "ed25519" ? key : null;
8820
+ } catch {
8821
+ return null;
8822
+ }
8823
+ }
8824
+ var USER_CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ";
8825
+ function generateUserCode() {
8826
+ const pick = () => Array.from({ length: 4 }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
8827
+ return `${pick()}-${pick()}`;
8828
+ }
8829
+ function normalizeUserCode(input2) {
8830
+ const raw = input2.toUpperCase().replace(/[\s-]/g, "");
8831
+ if (raw.length !== 8 || [...raw].some((ch) => !USER_CODE_ALPHABET.includes(ch))) return null;
8832
+ return `${raw.slice(0, 4)}-${raw.slice(4)}`;
8833
+ }
8834
+ function toDevice(r) {
8835
+ return {
8836
+ id: String(r["id"]),
8837
+ userId: String(r["user_id"]),
8838
+ name: String(r["name"]),
8839
+ platform: r["platform"] ?? null,
8840
+ publicKey: String(r["public_key"]),
8841
+ createdAt: String(r["created_at"]),
8842
+ lastSeenAt: r["last_seen_at"] ?? null,
8843
+ revokedAt: r["revoked_at"] ?? null
8764
8844
  };
8765
- auth.post("/login", emailedLink(deliverSignInLink));
8766
- auth.post("/forgot-password", emailedLink(deliverPasswordResetLink));
8767
- auth.get("/session", async (c) => {
8768
- const ctx = await resolveAuthContext(c, { fresh: c.req.query("fresh") === "1" });
8769
- if (!ctx) return c.json({ error: "not signed in" }, 401);
8770
- return c.json({
8771
- email: ctx.email,
8772
- handle: ctx.handle,
8773
- role: ctx.role,
8774
- name: ctx.name,
8775
- avatarUrl: ctx.avatarKey ? await signAvatarUrl(ctx.avatarKey) : null,
8776
- onboarded: ctx.onboarded,
8777
- passwordSet: ctx.passwordSet,
8778
- agentConnectedAt: ctx.agentConnectedAt,
8779
- plan: ctx.plan ?? null,
8780
- onboarding: { pending: onboardingPending(ctx) }
8781
- });
8782
- });
8783
- auth.patch("/handle", async (c) => {
8784
- const ctx = await resolveAuthContext(c);
8785
- if (!ctx) return c.json({ error: "not signed in" }, 401);
8786
- if (ctx.onboarded) return c.json({ error: "handle can no longer be changed" }, 403);
8787
- const body = await c.req.json().catch(() => ({}));
8788
- const handle = body.handle?.trim().replace(/^@/, "").toLowerCase() ?? "";
8789
- if (!HANDLE_PATTERN.test(handle)) {
8790
- return c.json(
8791
- { error: "handle must be 1-32 characters: a-z, 0-9, _ or -, starting with a letter or digit" },
8792
- 400
8793
- );
8794
- }
8795
- try {
8796
- await setMembershipHandle(ctx.workspaceId, ctx.userId, handle);
8797
- } catch (err) {
8798
- if (err instanceof HandleTakenError) return c.json({ error: err.message }, 409);
8799
- throw err;
8800
- }
8801
- return c.json({ handle });
8802
- });
8803
- auth.post("/logout", async (c) => {
8804
- const supabase = createRequestSupabaseClient(c);
8805
- await supabase.auth.signOut();
8806
- return c.json({ ok: true });
8807
- });
8808
- return auth;
8809
8845
  }
8810
-
8811
- // ../api/src/dashboard/auth-middleware.ts
8812
- var requireWorkspaceMembership = async (c, next) => {
8813
- const ctx = await resolveAuthContext(c);
8814
- if (!ctx) return c.json({ error: "not signed in" }, 401);
8815
- c.set("auth", ctx);
8816
- return next();
8817
- };
8818
-
8819
- // ../api/src/dashboard/billing-routes.ts
8820
- import { Hono as Hono8 } from "hono";
8821
- function isPlan(value) {
8822
- return value === "local" || value === "cloud";
8846
+ function toPending(r) {
8847
+ return {
8848
+ id: String(r["id"]),
8849
+ userCode: String(r["user_code"]),
8850
+ publicKey: String(r["public_key"]),
8851
+ name: String(r["name"]),
8852
+ platform: r["platform"] ?? null,
8853
+ requesterIp: r["requester_ip"] ?? null,
8854
+ approvedBy: r["approved_by"] ?? null,
8855
+ workspaceIds: r["workspace_ids"] ?? [],
8856
+ deniedAt: r["denied_at"] ?? null,
8857
+ lastPolledAt: r["last_polled_at"] ?? null,
8858
+ expiresAt: String(r["expires_at"]),
8859
+ createdAt: String(r["created_at"])
8860
+ };
8823
8861
  }
8824
- var LOCK_EXEMPT = [/\/billing(\/|$)/, /\/profiles?$/, /\/push\//];
8825
- async function requireActivePlan(c, next) {
8826
- if (c.req.method === "OPTIONS") return next();
8827
- if (LOCK_EXEMPT.some((re) => re.test(c.req.path))) return next();
8828
- const auth = c.get("auth");
8829
- if (!auth) return next();
8830
- if (isLockedPlan(await getWorkspacePlanStatus(auth.workspaceId))) {
8831
- return c.json({ error: "trial ended: add a card to reactivate this workspace", code: "plan_locked" }, 402);
8832
- }
8833
- return next();
8862
+ function fail(what, error2) {
8863
+ throw new Error(`${what}: ${error2.message}`);
8834
8864
  }
8835
- function createBillingRouter() {
8836
- const billing = new Hono8();
8837
- billing.get("/", async (c) => {
8838
- const auth = c.get("auth");
8839
- const { planStatus, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
8840
- let trialEnd = null;
8841
- let plan = null;
8842
- if (stripeSubscriptionId) {
8843
- try {
8844
- const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
8845
- if (planStatus === "trialing" && sub.trial_end) {
8846
- trialEnd = new Date(sub.trial_end * 1e3).toISOString();
8847
- }
8848
- if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
8849
- } catch (err) {
8850
- console.warn("[billing] subscription lookup failed:", err);
8851
- }
8852
- }
8853
- return c.json({ planStatus, trialEnd, plan, locked: isLockedPlan(planStatus) });
8854
- });
8855
- billing.post("/checkout", async (c) => {
8856
- const auth = c.get("auth");
8857
- if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
8858
- const { stripeCustomerId, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
8859
- if (!stripeCustomerId) return c.json({ error: "no subscription for this workspace" }, 409);
8860
- let plan = null;
8861
- if (stripeSubscriptionId) {
8862
- try {
8863
- const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
8864
- if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
8865
- } catch (err) {
8866
- console.warn("[billing] subscription lookup failed:", err);
8867
- }
8868
- }
8869
- const origin = c.req.header("origin") ?? `https://${trustedHost(c)}`;
8870
- const session = await getStripeClient().checkout.sessions.create({
8871
- mode: "setup",
8872
- currency: "usd",
8873
- customer: stripeCustomerId,
8874
- success_url: `${origin}/?billing=done`,
8875
- cancel_url: `${origin}/`,
8876
- metadata: { workspace_id: auth.workspaceId },
8877
- ...plan && {
8878
- custom_text: {
8879
- submit: {
8880
- message: `${plan.name} plan, US$${plan.priceUsd}/month. The first month is charged as soon as this card is saved, then monthly until cancelled.`
8881
- }
8882
- }
8883
- }
8865
+ async function createDeviceCode(input2) {
8866
+ const db = getSupabaseClient();
8867
+ await db.from("device_codes").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
8868
+ const deviceCode = randomBytes4(32).toString("hex");
8869
+ for (let attempt = 0; attempt < 3; attempt++) {
8870
+ const userCode = generateUserCode();
8871
+ const { error: error2 } = await db.from("device_codes").insert({
8872
+ device_code_hash: sha256(deviceCode),
8873
+ user_code: userCode,
8874
+ public_key: input2.publicKey,
8875
+ name: input2.name,
8876
+ platform: input2.platform,
8877
+ requester_ip: input2.requesterIp,
8878
+ expires_at: new Date(Date.now() + DEVICE_CODE_TTL_MS).toISOString()
8884
8879
  });
8885
- if (!session.url) return c.json({ error: "Stripe returned no checkout URL" }, 502);
8886
- return c.json({ url: session.url });
8887
- });
8888
- return billing;
8880
+ if (!error2) return { deviceCode, userCode };
8881
+ if (error2.code !== "23505") fail("createDeviceCode", error2);
8882
+ }
8883
+ throw new Error("createDeviceCode: could not allocate a unique user code");
8889
8884
  }
8890
-
8891
- // ../api/src/dashboard/admin-routes.ts
8892
- import { Hono as Hono9 } from "hono";
8893
-
8894
- // ../api/src/dashboard/admin-middleware.ts
8895
- function platformAdminEmails() {
8896
- const raw = process.env["PLATFORM_ADMIN_EMAILS"] ?? "";
8897
- return new Set(
8898
- raw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean)
8899
- );
8885
+ async function getDeviceCodeBySecret(deviceCode) {
8886
+ const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("device_code_hash", sha256(deviceCode)).maybeSingle();
8887
+ if (error2) fail("getDeviceCodeBySecret", error2);
8888
+ return data ? toPending(data) : null;
8900
8889
  }
8901
- var requirePlatformOperator = async (c, next) => {
8902
- const supabase = createRequestSupabaseClient(c);
8903
- const {
8904
- data: { user }
8905
- } = await supabase.auth.getUser();
8906
- if (!user?.email) return c.json({ error: "not signed in" }, 401);
8907
- const email = user.email.trim().toLowerCase();
8908
- if (!platformAdminEmails().has(email)) {
8909
- return c.json({ error: "platform operator access required" }, 403);
8910
- }
8911
- c.set("platformOperator", { userId: user.id, email });
8912
- return next();
8913
- };
8914
-
8915
- // ../api/src/dashboard/admin-routes.ts
8916
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
8917
- function createAdminRouter() {
8918
- const admin = new Hono9();
8919
- admin.use("*", requirePlatformOperator);
8920
- admin.get("/workspaces", async (c) => {
8921
- const summaries = await listWorkspaceSummaries();
8922
- const workspaces = await Promise.all(
8923
- summaries.map(async (w) => {
8924
- const agentModel = cachedProfileConfig(w.slug)?.agentModel;
8925
- const r2Bytes = await Promise.all(
8926
- ["chat", SCRATCH_PREFIX].map((prefix) => sumR2PrefixBytes(`${prefix}/${w.slug}/`))
8927
- ).then(([a, b]) => a + b).catch((err) => {
8928
- console.error(`[admin] R2 usage for ${w.slug}:`, err.message);
8929
- return null;
8930
- });
8931
- return {
8932
- ...w,
8933
- agent: agentModel ? { runner: runnerForModel(agentModel), model: agentModel } : null,
8934
- r2Bytes,
8935
- flyVolumeBytes: null
8936
- };
8937
- })
8890
+ async function getDeviceCodeByUserCode(userCode) {
8891
+ const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("user_code", userCode).maybeSingle();
8892
+ if (error2) fail("getDeviceCodeByUserCode", error2);
8893
+ return data ? toPending(data) : null;
8894
+ }
8895
+ async function markPolled(id) {
8896
+ const { error: error2 } = await getSupabaseClient().from("device_codes").update({ last_polled_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id);
8897
+ if (error2) fail("markPolled", error2);
8898
+ }
8899
+ async function decideDeviceCode(id, userId, decision) {
8900
+ const { data, error: error2 } = await getSupabaseClient().from("device_codes").update(
8901
+ decision.approve ? { approved_by: userId, workspace_ids: decision.workspaceIds } : { denied_at: (/* @__PURE__ */ new Date()).toISOString() }
8902
+ ).eq("id", id).is("approved_by", null).is("denied_at", null).select("id");
8903
+ if (error2) fail("decideDeviceCode", error2);
8904
+ return (data ?? []).length > 0;
8905
+ }
8906
+ async function redeemDeviceCode(pending2) {
8907
+ const db = getSupabaseClient();
8908
+ const { data: claimed, error: claimError } = await db.from("device_codes").delete().eq("id", pending2.id).select("id");
8909
+ if (claimError) fail("redeemDeviceCode", claimError);
8910
+ if (!claimed?.length) return null;
8911
+ const existing = pending2.approvedBy ? await findDeviceByKey(pending2.publicKey, pending2.approvedBy) : null;
8912
+ const { data, error: error2 } = existing ? await db.from("devices").update({ name: pending2.name, platform: pending2.platform }).eq("id", existing.id).select("*").single() : await db.from("devices").insert({
8913
+ user_id: pending2.approvedBy,
8914
+ name: pending2.name,
8915
+ platform: pending2.platform,
8916
+ public_key: pending2.publicKey
8917
+ }).select("*").single();
8918
+ if (error2) fail("redeemDeviceCode", error2);
8919
+ const device = toDevice(data);
8920
+ if (pending2.workspaceIds.length) {
8921
+ const { error: bindError } = await db.from("device_workspaces").upsert(
8922
+ pending2.workspaceIds.map((workspaceId) => ({
8923
+ device_id: device.id,
8924
+ workspace_id: workspaceId,
8925
+ bound_by: pending2.approvedBy
8926
+ })),
8927
+ { onConflict: "device_id,workspace_id", ignoreDuplicates: true }
8938
8928
  );
8939
- return c.json({ workspaces });
8940
- });
8941
- admin.get("/invites", async (c) => {
8942
- const invites = await listSetupInvites();
8943
- return c.json({ invites });
8944
- });
8945
- admin.post("/setup-invites", async (c) => {
8946
- const body = await c.req.json().catch(() => ({}));
8947
- const ownerEmail = body.ownerEmail?.trim().toLowerCase();
8948
- if (!ownerEmail || !EMAIL_RE.test(ownerEmail)) {
8949
- return c.json({ error: "ownerEmail must be a valid email address" }, 400);
8950
- }
8951
- const plan = body.plan === "cloud" ? "cloud" : body.plan === "local" ? "local" : void 0;
8952
- if (!plan) return c.json({ error: 'plan must be "local" or "cloud"' }, 400);
8953
- const ownerName = body.ownerName?.trim().slice(0, 80) || null;
8954
- const message = body.message?.trim() || null;
8955
- if (message && message.length > INVITE_MESSAGE_MAX) {
8956
- return c.json({ error: `message must be ${INVITE_MESSAGE_MAX} characters or fewer` }, 400);
8957
- }
8958
- const operator = c.get("platformOperator");
8959
- const token = await issueSetupInvite({
8960
- email: ownerEmail,
8961
- ownerName,
8962
- plan,
8963
- message,
8964
- invitedBy: operator.userId
8965
- });
8966
- const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
8967
- inviteUrl.searchParams.set("token", token);
8968
- if (body.sendEmail === false) {
8969
- return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
8970
- }
8971
- try {
8972
- await deliverWorkspaceSetupInvite({
8973
- email: ownerEmail,
8974
- inviteLink: inviteUrl.toString(),
8975
- message
8976
- });
8977
- } catch (err) {
8978
- await deleteSetupInvite(token).catch(() => {
8979
- });
8980
- return c.json(
8981
- {
8982
- error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
8983
- },
8984
- 500
8985
- );
8986
- }
8987
- return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
8988
- });
8989
- admin.delete("/setup-invites/:id", async (c) => {
8990
- await revokeSetupInvite(c.req.param("id"));
8991
- return c.json({ revoked: true });
8992
- });
8993
- admin.post("/setup-invites/:id/resend", async (c) => {
8994
- const operator = c.get("platformOperator");
8995
- let token;
8996
- let invite;
8997
- try {
8998
- ({ token, invite } = await reissueSetupInvite(c.req.param("id")));
8999
- } catch (err) {
9000
- return c.json({ error: err instanceof Error ? err.message : "resend failed" }, 404);
9001
- }
9002
- const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
9003
- inviteUrl.searchParams.set("token", token);
9004
- if (c.req.query("email") === "false") {
9005
- return c.json({ sent: false, inviteLink: inviteUrl.toString() });
9006
- }
9007
- try {
9008
- await deliverWorkspaceSetupInvite({
9009
- email: invite.email,
9010
- inviteLink: inviteUrl.toString(),
9011
- message: invite.message
9012
- });
9013
- } catch (err) {
9014
- return c.json(
9015
- {
9016
- error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
9017
- },
9018
- 500
9019
- );
9020
- }
9021
- return c.json({ sent: true, inviteLink: inviteUrl.toString() });
9022
- });
9023
- return admin;
8929
+ if (bindError) fail("redeemDeviceCode", bindError);
8930
+ }
8931
+ return device;
9024
8932
  }
9025
-
9026
- // ../api/src/dashboard/agent-identities.ts
9027
- var BOT_LABEL = "Workspace Agent";
9028
- var BOT_DM_KEY = "claude";
9029
-
9030
- // ../api/src/dashboard/data-api.ts
9031
- import { existsSync as existsSync19 } from "fs";
9032
- import sharp5 from "sharp";
9033
-
9034
- // ../api/src/dashboard/automations-routes.ts
9035
- import { Hono as Hono10 } from "hono";
9036
- var TYPES2 = ["schedule", "trigger", "mention-trigger"];
9037
- function isType(t) {
9038
- return TYPES2.includes(t);
8933
+ async function getDevice(id) {
8934
+ const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("id", id).maybeSingle();
8935
+ if (error2) fail("getDevice", error2);
8936
+ return data ? toDevice(data) : null;
9039
8937
  }
9040
- function sanitize(body) {
9041
- const { created_by: _b, created_at: _a, ...rest } = body;
9042
- void _b;
9043
- void _a;
9044
- return rest;
8938
+ async function findDeviceByKey(publicKey, userId) {
8939
+ const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("public_key", publicKey).eq("user_id", userId).is("revoked_at", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
8940
+ if (error2) fail("findDeviceByKey", error2);
8941
+ return data ? toDevice(data) : null;
9045
8942
  }
9046
- function statusFor(message) {
9047
- if (/already exists/.test(message)) return 409;
9048
- if (/not found/.test(message)) return 404;
9049
- return 400;
8943
+ async function issueAccessToken(deviceId) {
8944
+ const token = `fsd_${randomBytes4(32).toString("hex")}`;
8945
+ const { error: error2 } = await getSupabaseClient().from("devices").update({
8946
+ access_token_hash: sha256(token),
8947
+ access_token_expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_MS).toISOString(),
8948
+ last_seen_at: (/* @__PURE__ */ new Date()).toISOString()
8949
+ }).eq("id", deviceId);
8950
+ if (error2) fail("issueAccessToken", error2);
8951
+ return { token, expiresInS: ACCESS_TOKEN_TTL_MS / 1e3 };
9050
8952
  }
9051
- function createAutomationWriteRouter() {
9052
- const app = new Hono10();
9053
- const manager = () => new AutomationManager();
9054
- app.get("/:type/:name", (c) => {
9055
- const item = manager().get(c.req.param("name"));
9056
- if (!item) return c.json({ error: "not found" }, 404);
9057
- return c.json(item);
9058
- });
9059
- app.post("/:type", async (c) => {
9060
- const type = c.req.param("type");
9061
- if (!isType(type)) return c.json({ error: "unknown type" }, 400);
9062
- const body = sanitize(await c.req.json());
9063
- const next = { ...body, created_at: (/* @__PURE__ */ new Date()).toISOString() };
9064
- try {
9065
- const m = manager();
9066
- if (type === "schedule") await m.addSchedule(next);
9067
- else if (type === "trigger") await m.addTrigger(next);
9068
- else await m.addMentionTrigger(next);
9069
- return c.json(next, 201);
9070
- } catch (e) {
9071
- const msg = e.message;
9072
- const status = statusFor(msg);
9073
- return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
9074
- }
9075
- });
9076
- app.put("/:type/:name", async (c) => {
9077
- const type = c.req.param("type");
9078
- if (!isType(type)) return c.json({ error: "unknown type" }, 400);
9079
- const name = c.req.param("name");
9080
- const body = sanitize(await c.req.json());
9081
- try {
9082
- const m = manager();
9083
- const existing = m.get(name);
9084
- const next = {
9085
- ...body,
9086
- created_at: existing?.created_at
9087
- };
9088
- await m.update(type, name, next);
9089
- return c.json(next, 200);
9090
- } catch (e) {
9091
- const msg = e.message;
9092
- const status = statusFor(msg);
9093
- return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
9094
- }
9095
- });
9096
- app.delete("/:type/:name", async (c) => {
9097
- try {
9098
- await manager().remove(c.req.param("name"));
9099
- return c.json({ ok: true }, 200);
9100
- } catch (e) {
9101
- return c.json({ error: e.message }, 404);
9102
- }
9103
- });
9104
- return app;
8953
+ async function deviceForAccessToken(token) {
8954
+ if (!token.startsWith("fsd_")) return null;
8955
+ const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("access_token_hash", sha256(token)).is("revoked_at", null).gt("access_token_expires_at", (/* @__PURE__ */ new Date()).toISOString()).maybeSingle();
8956
+ if (error2) fail("deviceForAccessToken", error2);
8957
+ return data ? toDevice(data) : null;
9105
8958
  }
9106
-
9107
- // ../api/src/dashboard/categories-routes.ts
9108
- import { Hono as Hono11 } from "hono";
9109
- function createCategoryWriteRouter() {
9110
- const app = new Hono11();
9111
- app.get("/", async (c) => {
9112
- const { data, error: error2 } = await getSupabaseClient().from("categories").select("*").eq("profile", getProfile()).order("position", { ascending: true });
9113
- if (error2) return c.json({ error: error2.message }, 500);
9114
- return c.json({ categories: data ?? [] });
9115
- });
9116
- app.post("/", async (c) => {
9117
- const body = await c.req.json().catch(() => ({}));
9118
- const name = typeof body["name"] === "string" ? body["name"].trim() : "";
9119
- if (!name) return c.json({ errors: ["name is required"] }, 400);
9120
- const supabase = getSupabaseClient();
9121
- const { data: last } = await supabase.from("categories").select("position").eq("profile", getProfile()).order("position", { ascending: false }).limit(1);
9122
- const nextPosition = (last?.[0]?.position ?? -1) + 1;
9123
- const { data, error: error2 } = await supabase.from("categories").insert({ name, position: nextPosition, profile: getProfile() }).select().single();
9124
- if (error2) return c.json({ error: error2.message }, 500);
9125
- return c.json(data, 201);
9126
- });
9127
- app.patch("/reorder", async (c) => {
9128
- const body = await c.req.json().catch(() => ({}));
9129
- const ids = Array.isArray(body["ids"]) ? body["ids"].filter((x) => typeof x === "string") : [];
9130
- if (ids.length === 0) return c.json({ errors: ["ids is required"] }, 400);
9131
- const supabase = getSupabaseClient();
9132
- const results = await Promise.all(
9133
- ids.map(
9134
- (id, position) => supabase.from("categories").update({ position, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile())
9135
- )
9136
- );
9137
- const failed2 = results.find((r) => r.error);
9138
- if (failed2?.error) return c.json({ error: failed2.error.message }, 500);
9139
- return c.json({ ok: true });
9140
- });
9141
- app.put("/:id", async (c) => {
9142
- const id = c.req.param("id");
9143
- const body = await c.req.json().catch(() => ({}));
9144
- const name = typeof body["name"] === "string" ? body["name"].trim() : "";
9145
- if (!name) return c.json({ errors: ["name is required"] }, 400);
9146
- const { data, error: error2 } = await getSupabaseClient().from("categories").update({ name, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile()).select().single();
9147
- if (error2) return c.json({ error: error2.message }, 404);
9148
- return c.json(data);
9149
- });
9150
- app.delete("/:id", async (c) => {
9151
- const id = c.req.param("id");
9152
- const supabase = getSupabaseClient();
9153
- const { error: unsetError } = await supabase.from("channels").update({ category_id: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("category_id", id);
9154
- if (unsetError) return c.json({ error: unsetError.message }, 500);
9155
- await refreshChannelRegistry();
9156
- const { error: error2 } = await supabase.from("categories").delete().eq("id", id).eq("profile", getProfile());
9157
- if (error2) return c.json({ error: error2.message }, 404);
9158
- return c.json({ ok: true });
8959
+ async function revokeDevice2(id) {
8960
+ const { error: error2 } = await getSupabaseClient().from("devices").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString(), access_token_hash: null }).eq("id", id);
8961
+ if (error2) fail("revokeDevice", error2);
8962
+ }
8963
+ async function createChallenge(deviceId) {
8964
+ const db = getSupabaseClient();
8965
+ await db.from("device_challenges").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
8966
+ const nonce = randomBytes4(32).toString("base64url");
8967
+ const { error: error2 } = await db.from("device_challenges").insert({
8968
+ nonce,
8969
+ device_id: deviceId,
8970
+ expires_at: new Date(Date.now() + CHALLENGE_TTL_MS).toISOString()
9159
8971
  });
9160
- return app;
8972
+ if (error2) fail("createChallenge", error2);
8973
+ return nonce;
9161
8974
  }
9162
-
9163
- // ../api/src/dashboard/chat-routes.ts
9164
- import { Hono as Hono12 } from "hono";
9165
-
9166
- // ../api/src/dashboard/fork-summary.ts
9167
- var GROQ_API_URL3 = "https://api.groq.com/openai/v1/chat/completions";
9168
- var SUMMARY_MODEL = "openai/gpt-oss-120b";
9169
- var MAX_INPUT_CHARS2 = 24e3;
9170
- var TITLE_MAX_CHARS = 80;
9171
- var CONDENSE_SYSTEM_PROMPT = `You turn a chat conversation transcript into a new discussion topic where the work continues. Output EXACTLY two parts separated by one blank line: the first line is a concise topic title (max ${TITLE_MAX_CHARS} characters, no quotes, no Markdown), then a handoff summary of the conversation so far.
9172
-
9173
- Rules:
9174
- - Keep the conversation's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
9175
- - The summary opens a new thread where the work picks up: state the goal, what has been done or decided so far, and what remains open or should happen next.
9176
- - Weight the END of the transcript more heavily: later messages reflect the current state. If an earlier plan or decision was later changed, superseded, or abandoned, summarize it in one clause at most and lead with what it became. Never present a discarded early approach with the same weight as the latest one.
9177
- - Summarize the WORK, not the run mechanics: ignore status noise such as stopped/cancelled/terminated-run markers (e.g. "\u23F9 Stopped by user."), error or retry notices, and progress placeholders. A run being cut short is never part of the topic \u2014 capture the progress that was made instead.
9178
- - Preserve essential code identifiers, file names, and URLs, but do not reproduce whole code blocks \u2014 describe what they do in one sentence instead.
9179
- - No preamble like "Summary:" \u2014 start directly with the title line.`;
9180
- var FOCUS_TITLE_SYSTEM_PROMPT = `You name a new discussion topic after one chat message that someone is branching off into a focused side-discussion.
9181
-
9182
- Output ONE line only: the title. Nothing else \u2014 no summary, no explanation, no blank line.
9183
-
9184
- Shape:
9185
- - A short noun phrase naming the subject, like a filed ticket's title. Max ${TITLE_MAX_CHARS} characters, and shorter is better.
9186
- - Not a sentence copied from the message, not a quote, not Markdown. No bold, no backticks, no quotes, no trailing punctuation.
9187
-
9188
- Content:
9189
- - Keep the message's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
9190
- - Name the topic after the FINAL message in the input. Any earlier message is only context for what that message replies to, never the subject of the title.
9191
- - Name that message's central point. A message that diagnoses something is named after the finding, even when its last paragraph proposes a next step \u2014 do not title it after the proposed action.
9192
- - Never reframe it into a different question, never assert a conclusion the message does not reach, and never turn an open investigation into a settled recommendation or a list of steps.
9193
-
9194
- Example \u2014 a message that works out why an admin could not sign in, and closes by suggesting turning email confirmation off:
9195
- admin \u5E33\u865F\u88AB email \u9A57\u8B49\u4FE1\u5361\u4F4F`;
9196
- function clampTitle(title) {
9197
- return title.length > TITLE_MAX_CHARS ? `${title.slice(0, TITLE_MAX_CHARS - 1)}\u2026` : title;
8975
+ async function consumeChallenge(deviceId, nonce) {
8976
+ const { data, error: error2 } = await getSupabaseClient().from("device_challenges").delete().eq("nonce", nonce).eq("device_id", deviceId).gt("expires_at", (/* @__PURE__ */ new Date()).toISOString()).select("nonce");
8977
+ if (error2) fail("consumeChallenge", error2);
8978
+ return (data ?? []).length > 0;
9198
8979
  }
9199
- function deriveTitle(body) {
9200
- const line = body.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "Forked message";
9201
- return clampTitle(line);
8980
+ async function listAdminWorkspaces(userId) {
8981
+ const { data, error: error2 } = await getSupabaseClient().from("memberships").select("workspace_id, workspaces!inner(slug)").eq("user_id", userId).eq("role", "admin").eq("status", "active");
8982
+ if (error2) fail("listAdminWorkspaces", error2);
8983
+ return (data ?? []).map((r) => ({ id: r.workspace_id, slug: r.workspaces.slug })).sort((a, b) => a.slug.localeCompare(b.slug));
9202
8984
  }
9203
- function cleanTitleLine(raw) {
9204
- const line = raw.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
9205
- return clampTitle(
9206
- line.replace(/^#{1,6}\s*/, "").replace(/^[*_`"'\u201c\u2018\u300c\u300e]+|[*_`"'\u201d\u2019\u300d\u300f]+$/g, "").trim()
9207
- );
8985
+ async function listDeviceWorkspaces(device) {
8986
+ const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id").eq("device_id", device.id);
8987
+ if (error2) fail("listDeviceWorkspaces", error2);
8988
+ const bound = new Set((data ?? []).map((r) => r.workspace_id));
8989
+ return (await listAdminWorkspaces(device.userId)).filter((w) => bound.has(w.id));
9208
8990
  }
9209
- function fallbackTopic(body) {
9210
- return { title: deriveTitle(body), body };
8991
+ async function bindDeviceWorkspace(deviceId, workspaceId, boundBy) {
8992
+ const { error: error2 } = await getSupabaseClient().from("device_workspaces").upsert(
8993
+ { device_id: deviceId, workspace_id: workspaceId, bound_by: boundBy },
8994
+ { onConflict: "device_id,workspace_id", ignoreDuplicates: true }
8995
+ );
8996
+ if (error2) fail("bindDeviceWorkspace", error2);
9211
8997
  }
9212
- function parseTopic(raw, original) {
9213
- const text = raw.trim();
9214
- const breakAt = text.indexOf("\n");
9215
- if (breakAt === -1) return { title: deriveTitle(text), body: text };
9216
- const title = text.slice(0, breakAt).trim();
9217
- const body = text.slice(breakAt + 1).trim();
9218
- if (!title || !body) return fallbackTopic(original);
9219
- return {
9220
- title: clampTitle(title),
9221
- body
9222
- };
8998
+ async function listWorkspaceDevices(workspaceId) {
8999
+ const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("devices!inner(*)").eq("workspace_id", workspaceId).is("devices.revoked_at", null);
9000
+ if (error2) fail("listWorkspaceDevices", error2);
9001
+ return (data ?? []).map((r) => toDevice(r.devices)).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
9223
9002
  }
9224
- async function buildForkTopic(transcript, fallbackText, mode = "condense") {
9225
- const input2 = transcript.length > MAX_INPUT_CHARS2 ? transcript.slice(-MAX_INPUT_CHARS2) : transcript;
9226
- const fallback = fallbackText ?? transcript;
9227
- const focus = mode === "focus";
9228
- const apiKey = process.env["GROQ_API_KEY"];
9229
- if (!apiKey) return fallbackTopic(fallback);
9230
- try {
9231
- const res = await fetch(GROQ_API_URL3, {
9232
- method: "POST",
9233
- headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
9234
- body: JSON.stringify({
9235
- model: SUMMARY_MODEL,
9236
- temperature: 0.2,
9237
- // A title has to sort the message's central claim from the next-step
9238
- // paragraph it often ends with; 'low' reliably grabs the latter.
9239
- reasoning_effort: focus ? "medium" : "low",
9240
- // A title is one short line, but focus mode reasons before answering
9241
- // and the budget covers reasoning tokens too — too small a cap here
9242
- // truncates the title mid-word rather than failing.
9243
- max_tokens: focus ? 512 : 1024,
9244
- messages: [
9245
- {
9246
- role: "system",
9247
- content: focus ? FOCUS_TITLE_SYSTEM_PROMPT : CONDENSE_SYSTEM_PROMPT
9248
- },
9249
- { role: "user", content: input2 }
9250
- ]
9251
- })
9252
- });
9253
- if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
9254
- const data = await res.json();
9255
- const raw = data.choices?.[0]?.message?.content?.trim();
9256
- if (!raw) throw new Error("Groq returned an empty summary");
9257
- if (focus) return { title: cleanTitleLine(raw) || deriveTitle(fallback), body: fallback };
9258
- return parseTopic(raw, fallback);
9259
- } catch (err) {
9260
- console.error("Fork summary failed, using original text:", err);
9261
- return fallbackTopic(fallback);
9003
+ var STALE_HOST_MS = 14 * 24 * 60 * 6e4;
9004
+ var isStale = (lastSeenAt) => !!lastSeenAt && Date.now() - Date.parse(lastSeenAt) > STALE_HOST_MS;
9005
+ async function listWorkspaceHosts(workspaceIds, exceptPublicKey) {
9006
+ const hosts2 = /* @__PURE__ */ new Map();
9007
+ if (!workspaceIds.length) return hosts2;
9008
+ const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id, devices!inner(name, public_key, revoked_at, last_seen_at)").in("workspace_id", workspaceIds).is("devices.revoked_at", null);
9009
+ if (error2) fail("listWorkspaceHosts", error2);
9010
+ for (const r of data ?? []) {
9011
+ if (r.devices.public_key !== exceptPublicKey && !isStale(r.devices.last_seen_at)) {
9012
+ hosts2.set(r.workspace_id, r.devices.name);
9013
+ }
9262
9014
  }
9015
+ return hosts2;
9263
9016
  }
9264
-
9265
- // ../api/src/dashboard/slack-format.ts
9266
- import { nameToEmoji } from "gemoji";
9267
- var SLACK_EMOJI_ALIASES = {
9268
- double_vertical_bar: "\u23F8\uFE0F"
9269
- };
9270
- function emojifyShortcode(code) {
9271
- const name = code.slice(1, -1);
9272
- return nameToEmoji[name] ?? SLACK_EMOJI_ALIASES[name] ?? code;
9273
- }
9274
- function resolveSlackTokens(text) {
9275
- return text.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_m, id) => `@${getLocalCache().displayNameFor(id)}`).replace(/<#([A-Z0-9]+)\|([^>]+)>/g, (_m, _id, name) => `#${name}`).replace(/<#([A-Z0-9]+)>/g, (_m, id) => `#${getLocalCache().lookupChannelById(id)?.name ?? id}`).replace(/<!channel>/g, "@channel").replace(/<!here>/g, "@here").replace(/<!everyone>/g, "@everyone").replace(/<!subteam\^[A-Z0-9]+\|@?([^>]+)>/g, "@$1").replace(/<mailto:([^|>]+)\|([^>]+)>/g, (_m, addr, label2) => `[${label2}](mailto:${addr})`).replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, (_m, url, label2) => `[${label2}](${url})`).replace(/:[a-z0-9_+-]+:/gi, emojifyShortcode);
9017
+ async function releaseStaleHosts(workspaceIds, exceptPublicKey) {
9018
+ if (!workspaceIds.length) return;
9019
+ const db = getSupabaseClient();
9020
+ const { data, error: error2 } = await db.from("device_workspaces").select("device_id, workspace_id, devices!inner(public_key, last_seen_at)").in("workspace_id", workspaceIds);
9021
+ if (error2) fail("releaseStaleHosts", error2);
9022
+ for (const r of data ?? []) {
9023
+ if (r.devices.public_key === exceptPublicKey || !isStale(r.devices.last_seen_at)) continue;
9024
+ const { error: delError } = await db.from("device_workspaces").delete().eq("device_id", r.device_id).eq("workspace_id", r.workspace_id);
9025
+ if (delError) fail("releaseStaleHosts", delError);
9026
+ }
9276
9027
  }
9277
- function slackMrkdwnToMarkdown(text) {
9278
- return resolveSlackTokens(text).replace(/(?<![\w*])\*(?!\*)(\S(?:[^*\n]*?\S)?)\*(?!\*)(?![\w*])/g, "**$1**").replace(/(?<![\w~])~(?!~)(\S(?:[^~\n]*?\S)?)~(?!~)(?![\w~])/g, "~~$1~~").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
9028
+ async function userEmail(userId) {
9029
+ const { data, error: error2 } = await getSupabaseClient().auth.admin.getUserById(userId);
9030
+ if (error2) return null;
9031
+ return data.user?.email ?? null;
9279
9032
  }
9280
9033
 
9281
- // ../api/src/dashboard/thread-relevance.ts
9282
- async function relevantThreadsPage(supabase, handle, channelIds, opts) {
9283
- const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9284
- const { data, error: error2 } = await supabase.rpc("relevant_threads", {
9285
- handle,
9286
- mention_pattern: `@${escaped}\\M`,
9287
- channel_ids: channelIds,
9288
- before: opts.before ?? null,
9289
- page_limit: opts.limit
9290
- });
9291
- if (error2) throw new Error(`relevant_threads: ${error2.message}`);
9292
- return data ?? [];
9293
- }
9034
+ // ../api/src/dashboard/onboarding-routes.ts
9035
+ import { Hono as Hono7 } from "hono";
9294
9036
 
9295
- // ../api/src/dashboard/thread-translate.ts
9296
- import { createHash as createHash7 } from "crypto";
9037
+ // ../api/src/dashboard/agent-bridge-data.ts
9038
+ import { randomBytes as randomBytes5, randomUUID as randomUUID2, verify as verifyRaw } from "crypto";
9039
+ import { WebSocket, WebSocketServer } from "ws";
9297
9040
 
9298
- // ../api/src/store/thread-translation-store.ts
9299
- async function getThreadTranslations(lang, keys) {
9300
- const found = /* @__PURE__ */ new Map();
9301
- if (keys.length === 0) return found;
9302
- const clauses = keys.map((k) => `and(reply_id.eq.${k.replyId},body_hash.eq.${k.bodyHash})`).join(",");
9303
- const { data, error: error2 } = await getSupabaseClient().from("thread_reply_translations").select("reply_id, body, passthrough").eq("lang", lang).or(clauses);
9304
- if (error2) throw new Error(`getThreadTranslations(${lang}): ${error2.message}`);
9305
- for (const row of data ?? []) {
9306
- const replyId = row["reply_id"];
9307
- found.set(replyId, {
9308
- replyId,
9309
- body: row["body"],
9310
- passthrough: row["passthrough"]
9311
- });
9312
- }
9313
- return found;
9041
+ // ../api/src/dashboard/host-identity.ts
9042
+ import { createPrivateKey as createPrivateKey2, createPublicKey as createPublicKey3, generateKeyPairSync as generateKeyPairSync2 } from "crypto";
9043
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync16, writeFileSync as writeFileSync9 } from "fs";
9044
+ import { dirname as dirname5, join as join17 } from "path";
9045
+ function hostPrivateKeyPath() {
9046
+ return process.env["FLYSPACE_HOST_KEY_PATH"] || join17(paths.BASE_CONFIG_DIR, "host.key");
9314
9047
  }
9315
- async function putThreadTranslations(lang, rows) {
9316
- if (rows.length === 0) return;
9317
- const { error: error2 } = await getSupabaseClient().from("thread_reply_translations").upsert(
9318
- rows.map((r) => ({
9319
- reply_id: r.replyId,
9320
- lang,
9321
- body_hash: r.bodyHash,
9322
- body: r.body,
9323
- passthrough: r.passthrough
9324
- })),
9325
- { onConflict: "reply_id,lang,body_hash" }
9326
- );
9327
- if (error2) throw new Error(`putThreadTranslations(${lang}): ${error2.message}`);
9048
+ function loadHostPrivateKey(path = hostPrivateKeyPath()) {
9049
+ if (!existsSync17(path)) {
9050
+ throw new Error(
9051
+ `No Host identity at ${path}. Run \`pnpm run generate-host-key\` on this machine, then set FLYSPACE_HOST_PUBLIC_KEY on the Data process to the printed public key.`
9052
+ );
9053
+ }
9054
+ return createPrivateKey2(readFileSync16(path, "utf8"));
9328
9055
  }
9329
9056
 
9330
- // ../api/src/dashboard/thread-translate.ts
9331
- var GROQ_API_URL4 = "https://api.groq.com/openai/v1/chat/completions";
9332
- var TRANSLATION_MODEL2 = "openai/gpt-oss-120b";
9333
- var RECIPE_VERSION3 = 1;
9334
- var BATCH_CHAR_BUDGET = 16e3;
9335
- var CONTEXT_MESSAGES = 4;
9336
- var MAX_MESSAGE_CHARS = 12e3;
9337
- var MAX_OUTPUT_TOKENS2 = 32768;
9338
- var BY_CODE2 = new Map(TRANSLATION_LANGUAGES.map((l) => [l.code, l]));
9339
- function isTranslatableLanguage2(lang) {
9340
- return BY_CODE2.has(lang);
9057
+ // ../core/src/bridge-protocol.ts
9058
+ var HOST_SOCKET_PATH = "/internal/host-socket";
9059
+ var DEFAULT_DATA_API_PORT = 4757;
9060
+ var CALL_TIMEOUT_MS = 15e3;
9061
+ var HEARTBEAT_INTERVAL_MS = 15e3;
9062
+ var HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2.5;
9063
+ var LOCAL_RELAY_PATH = "/internal/agent-call";
9064
+ var RELAYED_METHODS = [
9065
+ "onChatMessage",
9066
+ "isAgentThinking",
9067
+ "getAgentActivities",
9068
+ "cancelAgentRun",
9069
+ "regenerateAgentRun",
9070
+ "listAgentRuns"
9071
+ ];
9072
+ function encodeWireMessage(msg) {
9073
+ return JSON.stringify(msg);
9341
9074
  }
9342
- function translationHash2(author, body) {
9343
- return createHash7("sha256").update(`${RECIPE_VERSION3}
9344
- ${author}
9345
- ${body}`).digest("hex");
9075
+ function parseWireMessage(raw) {
9076
+ try {
9077
+ return JSON.parse(String(raw));
9078
+ } catch {
9079
+ return null;
9080
+ }
9346
9081
  }
9347
- function systemPrompt2(languageName2) {
9348
- return `You translate messages from a group chat into ${languageName2}. You are a translator, not an editor, a summarizer, or a participant.
9349
9082
 
9350
- You receive a JSON object with two keys:
9351
- - "context": earlier messages, for reference only. NEVER translate these and never mention them in your output.
9352
- - "translate": the messages to translate, each with an "n" and a "body".
9353
-
9354
- Reply with ONLY a JSON object of this exact shape, one entry per message in "translate", in the same order:
9355
- {"messages":[{"n":1,"body":"...","already_target":false}]}
9356
-
9357
- Rules:
9358
- - "already_target" is true when the message is ALREADY written in ${languageName2}. In that case copy "body" through byte for byte, changing nothing at all. A message whose prose is in another language is NOT already_target just because it contains English technical terms, code, or product names.
9359
- - Translate meaning, not words. These are people talking, not prose to be polished. Keep the speaker's register: a terse message stays terse, a casual one stays casual, a rude one stays rude. Do not make anyone sound more formal, more complete, or more articulate than they wrote.
9360
- - A short reply ("\u597D\u554A", "same", "nope") is translated as the natural equivalent in ${languageName2}, using the context to see what it is answering. Never expand it into a full sentence the speaker did not write.
9361
- - Keep the Markdown exactly as it is: same heading levels, list nesting, table shape, link and image syntax, blockquotes, and order. Do not add, merge, split, or reorder anything.
9362
- - Do NOT translate: text inside fenced or inline code blocks, URLs, file paths, command names, or @mentions and /skill tokens. Leave them byte-for-byte identical.
9363
- - Keep established technical terms in English where a fluent speaker of ${languageName2} would keep them in English rather than reaching for a translated coinage.
9364
- - Use the punctuation conventions of ${languageName2}, including full-width punctuation for Chinese and Japanese. Use ordinary ASCII hyphens, never typographic or non-breaking hyphen variants.
9365
- - Never add commentary, notes about your choices, apologies, or an explanation of anything you could not translate.`;
9366
- }
9367
- function normalizeHyphens2(text) {
9368
- return text.replace(/‐|‑/g, "-");
9083
+ // ../api/src/dashboard/agent-bridge-data.ts
9084
+ var hosts = /* @__PURE__ */ new Map();
9085
+ var hostStatusListeners = /* @__PURE__ */ new Set();
9086
+ function notifyHostStatus() {
9087
+ const online = hosts.size > 0;
9088
+ for (const l of hostStatusListeners) l(online);
9369
9089
  }
9370
- function clip(body) {
9371
- return body.length > MAX_MESSAGE_CHARS ? body.slice(0, MAX_MESSAGE_CHARS) : body;
9090
+ function removeHost(ws) {
9091
+ if (hosts.delete(ws)) notifyHostStatus();
9372
9092
  }
9373
- function batchMessages(messages) {
9374
- const batches = [];
9375
- let current = [];
9376
- let size = 0;
9377
- for (const m of messages) {
9378
- const len = Math.min(m.body.length, MAX_MESSAGE_CHARS);
9379
- if (current.length > 0 && size + len > BATCH_CHAR_BUDGET) {
9380
- batches.push(current);
9381
- current = [];
9382
- size = 0;
9383
- }
9384
- current.push(m);
9385
- size += len;
9093
+ function disconnectHostDevice(deviceId) {
9094
+ for (const [ws, scope] of hosts) {
9095
+ if (scope.deviceId !== deviceId) continue;
9096
+ console.log(`[host-socket] closing connection for revoked device ${deviceId}`);
9097
+ removeHost(ws);
9098
+ ws.close(1008, "device revoked");
9386
9099
  }
9387
- if (current.length > 0) batches.push(current);
9388
- return batches;
9389
9100
  }
9390
- async function translateBatch(batch, context, lang) {
9391
- const language = BY_CODE2.get(lang);
9392
- if (!language) throw new Error(`unsupported translation language: ${lang}`);
9393
- const apiKey = process.env["GROQ_API_KEY"];
9394
- if (!apiKey) throw new Error("GROQ_API_KEY is not configured");
9395
- const payload = {
9396
- context: context.map((m) => ({ author: m.author, body: clip(m.body) })),
9397
- translate: batch.map((m, i) => ({ n: i + 1, author: m.author, body: clip(m.body) }))
9398
- };
9399
- const res = await fetch(GROQ_API_URL4, {
9101
+ var LAST_SEEN_RESOLUTION_MS = 60 * 6e4;
9102
+ var lastCallError = /* @__PURE__ */ new Map();
9103
+ function getHostCallError(method, profile = getProfile()) {
9104
+ return lastCallError.get(`${profile}:${method}`) ?? null;
9105
+ }
9106
+
9107
+ // ../api/src/dashboard/unique-channel-name.ts
9108
+ function uniqueChannelName(raw, taken) {
9109
+ const base = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
9110
+ let name = base;
9111
+ for (let i = 2; taken.has(name); i += 1) name = `${base}-${i}`;
9112
+ taken.add(name);
9113
+ return name;
9114
+ }
9115
+
9116
+ // ../api/src/dashboard/workspace-greeting.ts
9117
+ var GROQ_API_URL3 = "https://api.groq.com/openai/v1/chat/completions";
9118
+ var GREETING_MODEL = "openai/gpt-oss-20b";
9119
+ var TIMEOUT_MS = 6e3;
9120
+ var MAX_LISTED_CHANNELS = 8;
9121
+ var GUIDES = [
9122
+ "Welcome to Flyspace",
9123
+ "Working with the agent",
9124
+ "Channels and folders",
9125
+ "Docs",
9126
+ "Sharing docs",
9127
+ "Inviting your team",
9128
+ "Automations"
9129
+ ];
9130
+ var SYSTEM_PROMPT = `You write the first message a new Flyspace workspace owner sees, posted in their #general channel. Flyspace is a team chat where an AI agent works inside every channel.
9131
+
9132
+ - Plain Markdown, 60 to 110 words, in English. No headings, no emoji, no em or en dashes, no sign-off.
9133
+ - Greet the owner by name and name the workspace.
9134
+ - Say that #general is public: everyone invited to the workspace can see it.
9135
+ - If project channels are listed, mention them as #name and say each one is linked to that project folder. Never invent a channel.
9136
+ - Mention the trial or the missing Host only if the facts say so.
9137
+ - If guides are listed, say they are in the Docs pane of #general and suggest starting with the first one.
9138
+ - Use only the facts given. Do not add buttons or a list of next steps: those are appended for you.`;
9139
+ function greetingButtons(input2) {
9140
+ const buttons = [];
9141
+ if (!input2.hostConnected) buttons.push("Connect a machine");
9142
+ if (input2.channels[0]) buttons.push(`Summarise #${input2.channels[0]} for me`);
9143
+ if (input2.hostConnected) buttons.push("Give me a quick tour of the guides");
9144
+ if (input2.memberCount <= 1) buttons.push("Invite my team");
9145
+ buttons.push("Set up a daily digest");
9146
+ return buttons.slice(0, 3).map((b) => b.replace(/[|\]]/g, ""));
9147
+ }
9148
+ function fallbackGreeting(input2) {
9149
+ const channels = input2.channels.slice(0, MAX_LISTED_CHANNELS).map((c) => `#${c}`);
9150
+ return [
9151
+ `Welcome to ${input2.workspaceName}, ${input2.ownerName}.`,
9152
+ "This is #general. It is public, so everyone you invite to the workspace can see it.",
9153
+ channels.length ? `Your project channels are ready: ${channels.join(", ")}. Each one is linked to its project folder, so the agent there works on the real files.` : "",
9154
+ input2.hostConnected ? "A few short guides are waiting in the Docs pane of #general. Start with Welcome to Flyspace." : "",
9155
+ "Mention the agent in any channel to get started."
9156
+ ].filter(Boolean).join("\n\n");
9157
+ }
9158
+ async function groqGreeting(input2, apiKey) {
9159
+ const facts = [
9160
+ `Owner: ${input2.ownerName}`,
9161
+ `Workspace: ${input2.workspaceName}`,
9162
+ `Plan: ${input2.trialing ? "14-day free trial, just started" : "not on a trial"}`,
9163
+ `Host: ${input2.hostConnected ? "a machine is connected, agents can run" : "no machine connected yet, agents cannot run until one is"}`,
9164
+ `Project channels: ${input2.channels.slice(0, MAX_LISTED_CHANNELS).join(", ") || "(none yet)"}`,
9165
+ `Members: ${input2.memberCount}`,
9166
+ `Guides in #general: ${input2.hostConnected ? GUIDES.join(", ") : "(none, no machine connected)"}`
9167
+ ].join("\n");
9168
+ const res = await fetch(GROQ_API_URL3, {
9400
9169
  method: "POST",
9170
+ signal: AbortSignal.timeout(TIMEOUT_MS),
9401
9171
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
9402
9172
  body: JSON.stringify({
9403
- model: TRANSLATION_MODEL2,
9404
- temperature: 0,
9405
- max_tokens: MAX_OUTPUT_TOKENS2,
9406
- response_format: { type: "json_object" },
9173
+ model: GREETING_MODEL,
9174
+ temperature: 0.6,
9175
+ // Reasoning model: the cap covers reasoning tokens too, so keep it roomy.
9176
+ reasoning_effort: "low",
9177
+ max_tokens: 900,
9407
9178
  messages: [
9408
- { role: "system", content: systemPrompt2(language.name) },
9409
- { role: "user", content: JSON.stringify(payload) }
9179
+ { role: "system", content: SYSTEM_PROMPT },
9180
+ { role: "user", content: facts }
9410
9181
  ]
9411
9182
  })
9412
9183
  });
9413
- if (!res.ok) throw new Error(`translation model returned ${res.status}`);
9184
+ if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
9414
9185
  const data = await res.json();
9415
- const choice = data.choices?.[0];
9416
- if (choice?.finish_reason === "length") {
9417
- throw new Error("the translation ran past the model output limit");
9418
- }
9419
- const raw = choice?.message?.content?.trim() ?? "";
9420
- if (!raw) throw new Error("the model returned an empty translation");
9421
- let parsed;
9422
- try {
9423
- parsed = JSON.parse(raw);
9424
- } catch {
9425
- throw new Error("the model returned unparseable JSON");
9426
- }
9427
- const byNumber = /* @__PURE__ */ new Map();
9428
- for (const entry of parsed.messages ?? []) {
9429
- if (typeof entry?.n === "number") byNumber.set(entry.n, entry);
9186
+ const text = data.choices?.[0]?.message?.content?.replace(/\[BUTTONS:[^\]]*\]/g, "").replace(/\s*[–—]\s*/g, ", ").trim();
9187
+ return text || null;
9188
+ }
9189
+ async function buildGreeting(input2) {
9190
+ let text = null;
9191
+ const apiKey = process.env["GROQ_API_KEY"];
9192
+ if (apiKey) {
9193
+ try {
9194
+ text = await groqGreeting(input2, apiKey);
9195
+ } catch (err) {
9196
+ console.error("Workspace greeting failed, using the fixed template:", err);
9197
+ }
9430
9198
  }
9431
- return batch.map((m, i) => {
9432
- const entry = byNumber.get(i + 1);
9433
- const body = typeof entry?.body === "string" ? entry.body.trim() : "";
9434
- if (!body) return { id: m.id, body: m.body, passthrough: true };
9435
- return {
9436
- id: m.id,
9437
- body: normalizeHyphens2(body),
9438
- passthrough: entry?.already_target === true
9439
- };
9440
- });
9199
+ return `${text ?? fallbackGreeting(input2)}
9200
+
9201
+ [BUTTONS: ${greetingButtons(input2).join(" | ")}]`;
9441
9202
  }
9442
- var inFlight4 = /* @__PURE__ */ new Map();
9443
- async function translateThread(threadId, lang, messages) {
9444
- if (!BY_CODE2.has(lang)) throw new Error(`unsupported translation language: ${lang}`);
9445
- if (messages.length === 0) return [];
9446
- const shape = createHash7("sha256").update(messages.map((m) => translationHash2(m.author, m.body)).join("\n")).digest("hex");
9447
- const key = `${threadId}:${lang}:${shape}`;
9448
- const running = inFlight4.get(key);
9449
- if (running) return running;
9450
- const job = (async () => {
9451
- const hashes = messages.map((m) => ({
9452
- replyId: m.id,
9453
- bodyHash: translationHash2(m.author, m.body)
9454
- }));
9455
- const cached = await getThreadTranslations(lang, hashes);
9456
- const missing = messages.filter((m) => !cached.has(m.id));
9457
- const fresh = /* @__PURE__ */ new Map();
9458
- if (missing.length > 0) {
9459
- const index = new Map(messages.map((m, i) => [m.id, i]));
9460
- const batches = batchMessages(missing);
9461
- const results = await Promise.all(
9462
- batches.map((batch) => {
9463
- const first = index.get(batch[0].id) ?? 0;
9464
- const context = messages.slice(Math.max(0, first - CONTEXT_MESSAGES), first);
9465
- return translateBatch(batch, context, lang);
9466
- })
9467
- );
9468
- for (const row of results.flat()) fresh.set(row.id, row);
9469
- const hashById = new Map(hashes.map((h) => [h.replyId, h.bodyHash]));
9470
- await putThreadTranslations(
9471
- lang,
9472
- [...fresh.values()].map((r) => ({
9473
- replyId: r.id,
9474
- bodyHash: hashById.get(r.id),
9475
- body: r.body,
9476
- passthrough: r.passthrough
9477
- }))
9203
+
9204
+ // ../api/src/dashboard/onboarding-routes.ts
9205
+ var MAX_FOLDERS = 20;
9206
+ var CHANNEL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
9207
+ function isWorkspaceOwner(ctx) {
9208
+ return ctx?.role === "admin" && !ctx.isSupport;
9209
+ }
9210
+ function hostUnavailable(c, agent, method) {
9211
+ return c.json({ error: "host offline", reason: agent.getHostCallFailure?.(method) ?? "offline" }, 503);
9212
+ }
9213
+ function createOnboardingRouter(agent) {
9214
+ const app = new Hono7();
9215
+ app.get("/scan", async (c) => {
9216
+ const ctx = await resolveAuthContext(c);
9217
+ if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
9218
+ const scan = await agent.scanOnboarding?.(getProfile());
9219
+ if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
9220
+ return c.json(scan);
9221
+ });
9222
+ app.post("/channels", async (c) => {
9223
+ const ctx = await resolveAuthContext(c);
9224
+ if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
9225
+ const body = await c.req.json().catch(() => ({}));
9226
+ const runner = body["agent"];
9227
+ const folders = Array.isArray(body["folders"]) ? body["folders"].filter((f) => typeof f === "string" && f.length > 0) : [];
9228
+ if (folders.length > MAX_FOLDERS) return c.json({ error: `at most ${MAX_FOLDERS} folders` }, 400);
9229
+ const scan = await agent.scanOnboarding?.();
9230
+ if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
9231
+ const offered = new Map(scan.projects.map((p) => [p.path, p]));
9232
+ const unknown = folders.find((f) => !offered.has(f));
9233
+ if (unknown) return c.json({ error: "folder was not offered by the scan" }, 400);
9234
+ const taken = new Set(getProfileChannelRegistry().map((ch) => ch.name));
9235
+ taken.add("general");
9236
+ const items = [{ name: "general" }];
9237
+ for (const path of folders) items.push({ name: uniqueChannelName(offered.get(path).name, taken), path });
9238
+ const linked = await agent.linkProjectFolders?.(getProfile(), items);
9239
+ if (!linked) return hostUnavailable(c, agent, "linkProjectFolders");
9240
+ if (runner === "claude" || runner === "codex") {
9241
+ await agent.saveLocalDashboardSettings?.({ providers: [{ runner }] });
9242
+ }
9243
+ const created = [];
9244
+ for (const l of linked) {
9245
+ created.push(
9246
+ l.name === "general" ? await ensureGeneralChannel() : await createChannel({ name: l.name, cwd: l.cwd })
9478
9247
  );
9479
9248
  }
9480
- return messages.map((m) => {
9481
- const hit = cached.get(m.id);
9482
- if (hit) return { id: m.id, body: hit.body, passthrough: hit.passthrough };
9483
- return fresh.get(m.id) ?? { id: m.id, body: m.body, passthrough: true };
9249
+ await markWorkspaceProjectsSetup(ctx.workspaceId);
9250
+ return c.json({ channels: created.map((ch) => ({ id: ch.id, name: ch.name })) }, 201);
9251
+ });
9252
+ app.post("/link-folder", async (c) => {
9253
+ const ctx = await resolveAuthContext(c);
9254
+ if (!isWorkspaceOwner(ctx)) return c.json({ error: "workspace owner access required" }, 403);
9255
+ const body = await c.req.json().catch(() => ({}));
9256
+ const path = typeof body["path"] === "string" ? body["path"].trim() : "";
9257
+ const name = typeof body["name"] === "string" ? body["name"].trim() : "";
9258
+ if (!path || !CHANNEL_NAME_RE.test(name)) return c.json({ error: "path and a valid name are required" }, 400);
9259
+ if (getProfileChannelRegistry().some((ch) => ch.name === name)) {
9260
+ return c.json({ error: `#${name} already exists` }, 409);
9261
+ }
9262
+ if (body["manual"] === true) {
9263
+ const devices = await listWorkspaceDevices(ctx.workspaceId);
9264
+ if (!devices.some((d) => d.userId === ctx.userId)) {
9265
+ return c.json({ error: "a path can only be typed on your own machine" }, 403);
9266
+ }
9267
+ } else {
9268
+ const scan = await agent.scanOnboarding?.(getProfile());
9269
+ if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
9270
+ if (!scan.projects.some((p) => p.path === path)) {
9271
+ return c.json({ error: "folder was not offered by the scan" }, 400);
9272
+ }
9273
+ }
9274
+ const linked = await agent.linkProjectFolders?.(getProfile(), [{ name, path }]);
9275
+ if (!linked) {
9276
+ const hostError = getHostCallError("linkProjectFolders");
9277
+ if (hostError) return c.json({ error: hostError }, 409);
9278
+ return hostUnavailable(c, agent, "linkProjectFolders");
9279
+ }
9280
+ return c.json({ cwd: linked[0].cwd });
9281
+ });
9282
+ app.post("/reset", async (c) => {
9283
+ const ctx = await resolveAuthContext(c);
9284
+ if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
9285
+ const { error: error2 } = await getSupabaseClient().from("threads").delete().eq("client_key", ctx.workspaceId);
9286
+ if (error2) return c.json({ error: error2.message }, 500);
9287
+ await resetWorkspaceOnboarding(ctx.workspaceId);
9288
+ return c.json({ ok: true });
9289
+ });
9290
+ app.get("/greeting", async (c) => {
9291
+ const ctx = await resolveAuthContext(c);
9292
+ if (ctx?.role !== "admin") return c.json({ pending: false });
9293
+ const greetedAt = await getWorkspaceGreetedAt(ctx.workspaceId);
9294
+ const pending2 = !!ctx.projectsSetupAt && !greetedAt;
9295
+ if (!pending2) return c.json({ pending: pending2, channelId: null, threadId: null });
9296
+ const general = await ensureGeneralChannel();
9297
+ const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ctx.name?.trim() || ctx.handle);
9298
+ return c.json({ pending: pending2, channelId: general.id, threadId });
9299
+ });
9300
+ app.post("/greeting", async (c) => {
9301
+ const ctx = await resolveAuthContext(c);
9302
+ if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
9303
+ const supabase = getSupabaseClient();
9304
+ const general = await ensureGeneralChannel();
9305
+ const ownerName = ctx.name?.trim() || ctx.handle;
9306
+ const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ownerName);
9307
+ if (!threadId) return c.json({ error: "could not create the welcome thread" }, 500);
9308
+ const done = { channelId: general.id, threadId };
9309
+ const replied = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
9310
+ if (replied.data) return c.json(done);
9311
+ if (await getWorkspaceGreetedAt(ctx.workspaceId))
9312
+ return c.json({ error: "already greeted" }, 409);
9313
+ const [branding, billing, members] = await Promise.all([
9314
+ getWorkspaceBranding(getProfile()).catch(() => null),
9315
+ getWorkspaceBilling(ctx.workspaceId).catch(() => null),
9316
+ listMemberships(ctx.workspaceId).catch(() => [])
9317
+ ]);
9318
+ const body = await buildGreeting({
9319
+ ownerName,
9320
+ workspaceName: branding?.displayName || getProfile(),
9321
+ trialing: billing?.planStatus === "trialing",
9322
+ hostConnected: !!ctx.agentConnectedAt,
9323
+ channels: getProfileChannelRegistry().filter((ch) => !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt).map((ch) => ch.name),
9324
+ memberCount: members.filter((m) => !m.isSupport).length
9484
9325
  });
9485
- })().finally(() => inFlight4.delete(key));
9486
- inFlight4.set(key, job);
9487
- return job;
9488
- }
9489
-
9490
- // ../api/src/dashboard/chat-routes.ts
9491
- function isNonEmptyString(v) {
9492
- return typeof v === "string" && v.trim().length > 0;
9493
- }
9494
- function sanitizeModel(v) {
9495
- if (typeof v !== "string") return null;
9496
- const model = v.trim().toLowerCase();
9497
- return isAllowedModel(model) ? model : null;
9326
+ const again = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
9327
+ if (again.data) return c.json(done);
9328
+ const reply = await supabase.from("thread_replies").insert({ thread_id: threadId, author: AGENT_AUTHOR, body });
9329
+ if (reply.error) return c.json({ error: reply.error.message }, 500);
9330
+ await markWorkspaceGreeted(ctx.workspaceId);
9331
+ return c.json(done, 201);
9332
+ });
9333
+ return app;
9498
9334
  }
9499
- async function threadModel(threadId) {
9500
- const { data } = await getSupabaseClient().from("thread_replies").select("model").eq("thread_id", threadId).not("model", "is", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
9501
- return sanitizeModel(data?.model);
9335
+ async function ensureGreetingThread(workspaceId, channelId, ownerName) {
9336
+ const supabase = getSupabaseClient();
9337
+ const existing = () => supabase.from("threads").select("id").eq("client_key", workspaceId).maybeSingle();
9338
+ const found = await existing();
9339
+ if (found.data) return found.data.id;
9340
+ const { data, error: error2 } = await supabase.from("threads").insert({
9341
+ channel_id: channelId,
9342
+ title: `Welcome to your Flyspace, ${ownerName}!`,
9343
+ author: AGENT_AUTHOR,
9344
+ client_key: workspaceId
9345
+ }).select("id").single();
9346
+ if (data) return data.id;
9347
+ return error2?.code === "23505" ? (await existing()).data?.id ?? null : null;
9502
9348
  }
9503
- function requestedModel(c, model) {
9504
- return c.get("auth")?.role === "admin" ? sanitizeModel(model) : null;
9349
+
9350
+ // ../api/src/dashboard/auth-routes.ts
9351
+ function createAuthRouter() {
9352
+ const auth = new Hono8();
9353
+ const emailedLink = (deliver) => async (c) => {
9354
+ const body = await c.req.json().catch(() => ({}));
9355
+ const email = body.email?.trim().toLowerCase();
9356
+ if (!email) return c.json({ error: "email is required" }, 400);
9357
+ if (!isDevHost(c) && !await verifyTurnstileToken(body.captchaToken)) {
9358
+ return c.json({ error: "captcha check failed \u2014 please retry" }, 400);
9359
+ }
9360
+ const workspace = await getWorkspaceBySlug(getProfile());
9361
+ if (!workspace) return c.json({ error: "unknown workspace" }, 404);
9362
+ const membership = await getMembershipByEmail(workspace.id, email);
9363
+ if (!membership) {
9364
+ return c.json({ error: "no access \u2014 ask an admin to invite you" }, 403);
9365
+ }
9366
+ try {
9367
+ await deliver(email, callbackUrl(c));
9368
+ } catch (err) {
9369
+ return c.json({ error: err instanceof Error ? err.message : "could not send" }, 500);
9370
+ }
9371
+ return c.json({ sent: true });
9372
+ };
9373
+ auth.post("/login", emailedLink(deliverSignInLink));
9374
+ auth.post("/forgot-password", emailedLink(deliverPasswordResetLink));
9375
+ auth.get("/session", async (c) => {
9376
+ const ctx = await resolveAuthContext(c, { fresh: c.req.query("fresh") === "1" });
9377
+ if (!ctx) return c.json({ error: "not signed in" }, 401);
9378
+ return c.json({
9379
+ email: ctx.email,
9380
+ handle: ctx.handle,
9381
+ role: ctx.role,
9382
+ name: ctx.name,
9383
+ avatarUrl: ctx.isSupport ? SUPPORT_AVATAR_URL : ctx.avatarKey ? await signAvatarUrl(ctx.avatarKey) : null,
9384
+ onboarded: ctx.onboarded,
9385
+ passwordSet: ctx.passwordSet,
9386
+ agentConnectedAt: ctx.agentConnectedAt,
9387
+ plan: ctx.plan ?? null,
9388
+ onboarding: { pending: onboardingPending(ctx) },
9389
+ owner: isWorkspaceOwner(ctx),
9390
+ // Only the Host's owner can run `flyspace upgrade` on it, so other
9391
+ // admins never see the upgrade banner. Support staff can tell them to.
9392
+ hostOwner: ctx.role === "admin" && (ctx.isSupport === true || (await listWorkspaceDevices(ctx.workspaceId)).some((d) => d.userId === ctx.userId))
9393
+ });
9394
+ });
9395
+ auth.patch("/handle", async (c) => {
9396
+ const ctx = await resolveAuthContext(c);
9397
+ if (!ctx) return c.json({ error: "not signed in" }, 401);
9398
+ if (ctx.onboarded) return c.json({ error: "handle can no longer be changed" }, 403);
9399
+ const body = await c.req.json().catch(() => ({}));
9400
+ const handle = body.handle?.trim().replace(/^@/, "").toLowerCase() ?? "";
9401
+ if (!HANDLE_PATTERN.test(handle)) {
9402
+ return c.json(
9403
+ { error: "handle must be 1-32 characters: a-z, 0-9, _ or -, starting with a letter or digit" },
9404
+ 400
9405
+ );
9406
+ }
9407
+ try {
9408
+ await setMembershipHandle(ctx.workspaceId, ctx.userId, handle);
9409
+ } catch (err) {
9410
+ if (err instanceof HandleTakenError) return c.json({ error: err.message }, 409);
9411
+ throw err;
9412
+ }
9413
+ return c.json({ handle });
9414
+ });
9415
+ auth.post("/logout", async (c) => {
9416
+ const supabase = createRequestSupabaseClient(c);
9417
+ await supabase.auth.signOut();
9418
+ return c.json({ ok: true });
9419
+ });
9420
+ return auth;
9505
9421
  }
9506
- function sanitizeEffort(v) {
9507
- if (typeof v !== "string") return null;
9508
- const effort = v.trim().toLowerCase();
9509
- return isEffort(effort) ? effort : null;
9422
+
9423
+ // ../api/src/dashboard/auth-middleware.ts
9424
+ var requireWorkspaceMembership = async (c, next) => {
9425
+ const ctx = await resolveAuthContext(c);
9426
+ if (!ctx) return c.json({ error: "not signed in" }, 401);
9427
+ c.set("auth", ctx);
9428
+ return next();
9429
+ };
9430
+
9431
+ // ../api/src/dashboard/billing-routes.ts
9432
+ import { Hono as Hono9 } from "hono";
9433
+ function isPlan(value) {
9434
+ return value === "local" || value === "cloud";
9510
9435
  }
9511
- function requestedEffort(c, effort) {
9512
- return c.get("auth")?.role === "admin" ? sanitizeEffort(effort) : null;
9436
+ var LOCK_EXEMPT = [/\/billing(\/|$)/, /\/profiles?$/, /\/push\//];
9437
+ async function requireActivePlan(c, next) {
9438
+ if (c.req.method === "OPTIONS") return next();
9439
+ if (LOCK_EXEMPT.some((re) => re.test(c.req.path))) return next();
9440
+ const auth = c.get("auth");
9441
+ if (!auth) return next();
9442
+ if (isLockedPlan(await getWorkspacePlanStatus(auth.workspaceId))) {
9443
+ return c.json({ error: "trial ended: add a card to reactivate this workspace", code: "plan_locked" }, 402);
9444
+ }
9445
+ return next();
9513
9446
  }
9514
- function effortFromRow(v) {
9515
- return typeof v === "string" && isEffort(v) ? v : void 0;
9516
- }
9517
- var BOARD_STATUSES = [
9518
- "backlog",
9519
- "todo",
9520
- "in_progress",
9521
- "in_review",
9522
- "done",
9523
- "cancelled"
9524
- ];
9525
- var NO_STATUS_COLUMN = "none";
9526
- function boardColumnParam(status) {
9527
- if (status === NO_STATUS_COLUMN) return NO_STATUS_COLUMN;
9528
- return BOARD_STATUSES.includes(status) ? status : null;
9447
+ function createBillingRouter() {
9448
+ const billing = new Hono9();
9449
+ billing.get("/", async (c) => {
9450
+ const auth = c.get("auth");
9451
+ const { planStatus, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
9452
+ let trialEnd = null;
9453
+ let plan = null;
9454
+ if (stripeSubscriptionId) {
9455
+ try {
9456
+ const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
9457
+ if (planStatus === "trialing" && sub.trial_end) {
9458
+ trialEnd = new Date(sub.trial_end * 1e3).toISOString();
9459
+ }
9460
+ if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
9461
+ } catch (err) {
9462
+ console.warn("[billing] subscription lookup failed:", err);
9463
+ }
9464
+ }
9465
+ return c.json({ planStatus, trialEnd, plan, locked: isLockedPlan(planStatus) });
9466
+ });
9467
+ billing.post("/checkout", async (c) => {
9468
+ const auth = c.get("auth");
9469
+ if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
9470
+ const { stripeCustomerId, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
9471
+ if (!stripeCustomerId) return c.json({ error: "no subscription for this workspace" }, 409);
9472
+ let plan = null;
9473
+ if (stripeSubscriptionId) {
9474
+ try {
9475
+ const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
9476
+ if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
9477
+ } catch (err) {
9478
+ console.warn("[billing] subscription lookup failed:", err);
9479
+ }
9480
+ }
9481
+ const origin = c.req.header("origin") ?? `https://${trustedHost(c)}`;
9482
+ const session = await getStripeClient().checkout.sessions.create({
9483
+ mode: "setup",
9484
+ currency: "usd",
9485
+ customer: stripeCustomerId,
9486
+ success_url: `${origin}/?billing=done`,
9487
+ cancel_url: `${origin}/`,
9488
+ metadata: { workspace_id: auth.workspaceId },
9489
+ ...plan && {
9490
+ custom_text: {
9491
+ submit: {
9492
+ message: `${plan.name} plan, US$${plan.priceUsd}/month. The first month is charged as soon as this card is saved, then monthly until cancelled.`
9493
+ }
9494
+ }
9495
+ }
9496
+ });
9497
+ if (!session.url) return c.json({ error: "Stripe returned no checkout URL" }, 502);
9498
+ return c.json({ url: session.url });
9499
+ });
9500
+ return billing;
9529
9501
  }
9530
- var THREAD_CHUNK_SIZE = 25;
9531
- var THREADS_PAGE_SIZE = 30;
9532
- var THREADS_PAGE_SIZE_MAX = 100;
9533
- var REPLIES_PAGE_SIZE = 50;
9534
- var REPLIES_PAGE_SIZE_MAX = 200;
9535
- var TRANSLATABLE_REPLIES_MAX = 400;
9536
- var BOARD_PAGE_SIZE = 50;
9537
- var BOARD_PAGE_SIZE_MAX = 100;
9538
- function ownChannelIds(c) {
9539
- const viewer = c.get("auth");
9502
+
9503
+ // ../api/src/dashboard/admin-routes.ts
9504
+ import { Hono as Hono10 } from "hono";
9505
+
9506
+ // ../api/src/dashboard/admin-middleware.ts
9507
+ function platformAdminEmails() {
9508
+ const raw = process.env["PLATFORM_ADMIN_EMAILS"] ?? "";
9540
9509
  return new Set(
9541
- getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, viewer)).flatMap((ch) => ch.externalChannelId ? [ch.id, ch.externalChannelId] : [ch.id])
9510
+ raw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean)
9542
9511
  );
9543
9512
  }
9544
- async function globalChannelMeta(c) {
9545
- const viewer = c.get("auth");
9546
- const map = /* @__PURE__ */ new Map();
9547
- const channels = getChannelRegistry();
9548
- let workspaceIdBySlug = /* @__PURE__ */ new Map();
9549
- let roleByWorkspaceId = /* @__PURE__ */ new Map();
9550
- if (viewer?.email && channels.length > 0) {
9551
- const [workspaces, memberships] = await Promise.all([
9552
- listWorkspaces(),
9553
- listMembershipsByEmail(viewer.email)
9554
- ]);
9555
- workspaceIdBySlug = new Map(workspaces.map((w) => [w.slug, w.id]));
9556
- roleByWorkspaceId = new Map(memberships.map((m) => [m.workspaceId, m.role]));
9557
- }
9558
- for (const ch of channels) {
9559
- const workspaceId = workspaceIdBySlug.get(ch.profile);
9560
- const role = workspaceId ? roleByWorkspaceId.get(workspaceId) : void 0;
9561
- if (!canSeeChannel(ch, { email: viewer?.email, role })) continue;
9562
- const meta = { workspace: ch.profile, channelName: ch.name, agentName: ch.agentName };
9563
- map.set(ch.id, meta);
9564
- if (ch.externalChannelId) map.set(ch.externalChannelId, meta);
9513
+ var requirePlatformOperator = async (c, next) => {
9514
+ const supabase = createRequestSupabaseClient(c);
9515
+ const {
9516
+ data: { user }
9517
+ } = await supabase.auth.getUser();
9518
+ if (!user?.email) return c.json({ error: "not signed in" }, 401);
9519
+ const email = user.email.trim().toLowerCase();
9520
+ if (!platformAdminEmails().has(email)) {
9521
+ return c.json({ error: "platform operator access required" }, 403);
9565
9522
  }
9566
- return map;
9567
- }
9568
- async function threadIsKnown(threadId, c) {
9569
- const { data } = await getSupabaseClient().from("threads").select("channel_id").eq("id", threadId).single();
9570
- const channelId = data?.channel_id;
9571
- return !!channelId && (await globalChannelMeta(c)).has(channelId);
9572
- }
9573
- function chunk(items, size) {
9574
- const out = [];
9575
- for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
9576
- return out;
9577
- }
9578
- async function withPreviews(supabase, threads) {
9579
- const chunkResults = await Promise.all(
9580
- chunk(
9581
- threads.map((t) => t.id),
9582
- THREAD_CHUNK_SIZE
9583
- ).map(
9584
- (ids) => supabase.from("thread_replies").select("thread_id, body, author, external_user_id").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
9585
- )
9586
- );
9587
- const previewByThread = /* @__PURE__ */ new Map();
9588
- for (const { data } of chunkResults) {
9589
- for (const r of data ?? []) {
9590
- const entry = previewByThread.get(r.thread_id);
9591
- if (entry) {
9592
- entry.count += 1;
9593
- entry.lastAuthor = r.author;
9594
- entry.participants.delete(r.author);
9595
- entry.participants.set(r.author, r.external_user_id ?? null);
9596
- } else
9597
- previewByThread.set(r.thread_id, {
9598
- count: 1,
9599
- firstBody: r.body,
9600
- lastAuthor: r.author,
9601
- participants: /* @__PURE__ */ new Map()
9523
+ c.set("platformOperator", { userId: user.id, email });
9524
+ return next();
9525
+ };
9526
+
9527
+ // ../api/src/dashboard/admin-routes.ts
9528
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
9529
+ function createAdminRouter() {
9530
+ const admin = new Hono10();
9531
+ admin.use("*", requirePlatformOperator);
9532
+ admin.get("/workspaces", async (c) => {
9533
+ const summaries = await listWorkspaceSummaries();
9534
+ const workspaces = await Promise.all(
9535
+ summaries.map(async (w) => {
9536
+ const agentModel = cachedProfileConfig(w.slug)?.agentModel;
9537
+ const r2Bytes = await Promise.all(
9538
+ ["chat", SCRATCH_PREFIX].map((prefix) => sumR2PrefixBytes(`${prefix}/${w.slug}/`))
9539
+ ).then(([a, b]) => a + b).catch((err) => {
9540
+ console.error(`[admin] R2 usage for ${w.slug}:`, err.message);
9541
+ return null;
9602
9542
  });
9603
- }
9604
- }
9605
- return threads.map((t) => {
9606
- const entry = previewByThread.get(t.id);
9607
- return {
9608
- ...t,
9609
- reply_count: Math.max(0, (entry?.count ?? 0) - 1),
9610
- preview: entry?.firstBody ? slackMrkdwnToMarkdown(entry.firstBody) : null,
9611
- // Who spoke last, counting only replies AFTER the opening message —
9612
- // the reader's own handle here means there is nothing new for them to
9613
- // read, on this browser or any other (see web/src/lib/thread-unread).
9614
- last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
9615
- // Everyone who replied, most recent first, for the row's avatar stack.
9616
- participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id }))
9617
- };
9543
+ return {
9544
+ ...w,
9545
+ agent: agentModel ? { runner: runnerForModel(agentModel), model: agentModel } : null,
9546
+ r2Bytes,
9547
+ flyVolumeBytes: null
9548
+ };
9549
+ })
9550
+ );
9551
+ return c.json({ workspaces });
9618
9552
  });
9619
- }
9620
- async function labelThread(channelId, body, scope) {
9621
- const channel = getProfileChannelRegistry().find(
9622
- (ch) => ch.id === channelId || ch.externalChannelId === channelId
9623
- );
9624
- if (!channel) return null;
9625
- const supabase = getSupabaseClient();
9626
- const { data: recent } = await supabase.from("threads").select("auto_title, title").eq("channel_id", channelId).order("created_at", { ascending: false }).limit(50);
9627
- const existingCategories = extractCategories(
9628
- (recent ?? []).flatMap((t) => [
9629
- t.auto_title,
9630
- t.title
9631
- ])
9632
- );
9633
- return buildAutoTitle({
9634
- channelName: channel.name,
9635
- channelPrompt: channel.prompt,
9636
- workspacePrompt: loadWorkspacePrompt().prompt,
9637
- titlePrompt: (await loadWorkspaceBranding()).autoTitlePrompt,
9638
- existingCategories,
9639
- body,
9640
- scope
9553
+ admin.get("/invites", async (c) => {
9554
+ const invites = await listSetupInvites();
9555
+ return c.json({ invites });
9641
9556
  });
9642
- }
9643
- async function generateAutoTitle(threadId, channelId, body) {
9644
- if ((await loadWorkspaceBranding()).autoTitleEnabled === false) return;
9645
- const autoTitle = await labelThread(channelId, body);
9646
- if (!autoTitle) return;
9647
- const supabase = getSupabaseClient();
9648
- await supabase.from("threads").update({ auto_title: autoTitle }).eq("id", threadId).is("title", null);
9649
- }
9650
- function createChatRouter(agent = {}) {
9651
- const app = new Hono12();
9652
- const {
9653
- onChatMessage: onMessage,
9654
- isAgentThinking,
9655
- getAgentActivities,
9656
- cancelAgentRun,
9657
- regenerateAgentRun,
9658
- listAgentRuns
9659
- } = agent;
9660
- app.post(
9661
- "/threads/:id/agent-cancel",
9662
- async (c) => c.json({ cancelled: await cancelAgentRun?.(c.req.param("id")) ?? false })
9663
- );
9664
- app.post(
9665
- "/threads/:id/agent-regenerate",
9666
- async (c) => c.json({ started: await regenerateAgentRun?.(c.req.param("id")) ?? false })
9667
- );
9668
- app.get(
9669
- "/threads/:id/agent-status",
9670
- async (c) => c.json({
9671
- thinking: await isAgentThinking?.(c.req.param("id")) ?? false,
9672
- activities: await getAgentActivities?.(c.req.param("id")) ?? []
9673
- })
9674
- );
9675
- app.get("/agent-runs", async (c) => c.json({ threadIds: await listAgentRuns?.() ?? [] }));
9676
- app.get("/channels/:channelId/threads", async (c) => {
9677
- const channelId = c.req.param("channelId");
9678
- if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
9679
- const requestedLimit = Number(c.req.query("limit"));
9680
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
9681
- const before = c.req.query("before");
9682
- const sortColumn = c.req.query("sort") === "activity" ? "updated_at" : "created_at";
9683
- const statusParam = c.req.query("status");
9684
- const statusFilter = statusParam ? [
9685
- ...new Set(
9686
- statusParam.split(",").map(boardColumnParam).filter((s) => s !== null)
9687
- )
9688
- ] : [];
9689
- const supabase = getSupabaseClient();
9690
- let query = supabase.from("threads").select("*").eq("channel_id", channelId).is("archived_at", null).order(sortColumn, { ascending: false }).limit(limit);
9691
- if (before) query = query.lt(sortColumn, before);
9692
- if (statusFilter.length) {
9693
- const withoutNone = statusFilter.filter((s) => s !== NO_STATUS_COLUMN);
9694
- const includeNone = statusFilter.includes(NO_STATUS_COLUMN);
9695
- if (includeNone && withoutNone.length) {
9696
- query = query.or(`board_status.in.(${withoutNone.join(",")}),board_status.is.null`);
9697
- } else if (includeNone) {
9698
- query = query.is("board_status", null);
9699
- } else {
9700
- query = query.in("board_status", withoutNone);
9701
- }
9557
+ admin.post("/setup-invites", async (c) => {
9558
+ const body = await c.req.json().catch(() => ({}));
9559
+ const ownerEmail = body.ownerEmail?.trim().toLowerCase();
9560
+ if (!ownerEmail || !EMAIL_RE.test(ownerEmail)) {
9561
+ return c.json({ error: "ownerEmail must be a valid email address" }, 400);
9702
9562
  }
9703
- const { data: threads, error: error2 } = await query;
9704
- if (error2) return c.json({ error: error2.message }, 500);
9705
- if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
9706
- const withPreview = await withPreviews(supabase, threads);
9707
- const nextCursor = threads.length === limit ? threads[threads.length - 1][sortColumn] : null;
9708
- return c.json({ threads: withPreview, nextCursor });
9709
- });
9710
- app.get("/channels/:channelId/board/:status", async (c) => {
9711
- const channelId = c.req.param("channelId");
9712
- if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
9713
- const column = boardColumnParam(c.req.param("status"));
9714
- if (!column) {
9563
+ const plan = body.plan === "cloud" ? "cloud" : body.plan === "local" ? "local" : void 0;
9564
+ if (!plan) return c.json({ error: 'plan must be "local" or "cloud"' }, 400);
9565
+ const ownerName = body.ownerName?.trim().slice(0, 80) || null;
9566
+ const message = body.message?.trim() || null;
9567
+ if (message && message.length > INVITE_MESSAGE_MAX) {
9568
+ return c.json({ error: `message must be ${INVITE_MESSAGE_MAX} characters or fewer` }, 400);
9569
+ }
9570
+ const operator = c.get("platformOperator");
9571
+ const token = await issueSetupInvite({
9572
+ email: ownerEmail,
9573
+ ownerName,
9574
+ plan,
9575
+ message,
9576
+ invitedBy: operator.userId
9577
+ });
9578
+ const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
9579
+ inviteUrl.searchParams.set("token", token);
9580
+ if (body.sendEmail === false) {
9581
+ return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
9582
+ }
9583
+ try {
9584
+ await deliverWorkspaceSetupInvite({
9585
+ email: ownerEmail,
9586
+ inviteLink: inviteUrl.toString(),
9587
+ message
9588
+ });
9589
+ } catch (err) {
9590
+ await deleteSetupInvite(token).catch(() => {
9591
+ });
9715
9592
  return c.json(
9716
- { error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
9717
- 400
9593
+ {
9594
+ error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
9595
+ },
9596
+ 500
9718
9597
  );
9719
9598
  }
9720
- const requestedLimit = Number(c.req.query("limit"));
9721
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
9722
- const before = c.req.query("before");
9723
- const supabase = getSupabaseClient();
9724
- let query = supabase.from("threads").select("*").eq("channel_id", channelId);
9725
- query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
9726
- query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
9727
- if (before) query = query.lt("updated_at", before);
9728
- const { data: threads, error: error2 } = await query;
9729
- if (error2) return c.json({ error: error2.message }, 500);
9730
- if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
9731
- const withPreview = await withPreviews(supabase, threads);
9732
- const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
9733
- return c.json({ threads: withPreview, nextCursor });
9599
+ return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
9734
9600
  });
9735
- app.get("/board/:status", async (c) => {
9736
- const column = boardColumnParam(c.req.param("status"));
9737
- if (!column) {
9601
+ admin.delete("/setup-invites/:id", async (c) => {
9602
+ await revokeSetupInvite(c.req.param("id"));
9603
+ return c.json({ revoked: true });
9604
+ });
9605
+ admin.post("/setup-invites/:id/resend", async (c) => {
9606
+ const operator = c.get("platformOperator");
9607
+ let token;
9608
+ let invite;
9609
+ try {
9610
+ ({ token, invite } = await reissueSetupInvite(c.req.param("id")));
9611
+ } catch (err) {
9612
+ return c.json({ error: err instanceof Error ? err.message : "resend failed" }, 404);
9613
+ }
9614
+ const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
9615
+ inviteUrl.searchParams.set("token", token);
9616
+ if (c.req.query("email") === "false") {
9617
+ return c.json({ sent: false, inviteLink: inviteUrl.toString() });
9618
+ }
9619
+ try {
9620
+ await deliverWorkspaceSetupInvite({
9621
+ email: invite.email,
9622
+ inviteLink: inviteUrl.toString(),
9623
+ message: invite.message
9624
+ });
9625
+ } catch (err) {
9738
9626
  return c.json(
9739
- { error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
9740
- 400
9627
+ {
9628
+ error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
9629
+ },
9630
+ 500
9741
9631
  );
9742
9632
  }
9743
- const requestedLimit = Number(c.req.query("limit"));
9744
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
9745
- const before = c.req.query("before");
9746
- const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
9747
- const supabase = getSupabaseClient();
9748
- let query = supabase.from("threads").select("*").in("channel_id", meta ? [...meta.keys()] : [...ownChannelIds(c)]);
9749
- query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
9750
- query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
9751
- if (before) query = query.lt("updated_at", before);
9752
- const { data: threads, error: error2 } = await query;
9753
- if (error2) return c.json({ error: error2.message }, 500);
9754
- if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
9755
- const withPreview = await withPreviews(supabase, threads);
9756
- const labelled = meta ? withPreview.map((t) => {
9757
- const channelMeta = meta.get(t.channel_id ?? "");
9758
- return {
9759
- ...t,
9760
- workspace: channelMeta?.workspace ?? null,
9761
- channel_name: channelMeta?.channelName ?? null,
9762
- agent_name: channelMeta?.agentName ?? null
9763
- };
9764
- }) : withPreview;
9765
- const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
9766
- return c.json({ threads: labelled, nextCursor });
9633
+ return c.json({ sent: true, inviteLink: inviteUrl.toString() });
9767
9634
  });
9768
- app.get("/threads", async (c) => {
9769
- const requestedLimit = Number(c.req.query("limit"));
9770
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
9771
- const before = c.req.query("before");
9772
- const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
9773
- const relevantTo = c.req.query("relevantTo");
9774
- const supabase = getSupabaseClient();
9775
- const channelIds = meta ? [...meta.keys()] : [...ownChannelIds(c)];
9776
- let threads;
9777
- if (isNonEmptyString(relevantTo)) {
9778
- try {
9779
- threads = await relevantThreadsPage(supabase, relevantTo, channelIds, { before, limit });
9780
- } catch (err) {
9781
- return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
9782
- }
9783
- } else {
9784
- let query = supabase.from("threads").select("*").in("channel_id", channelIds).is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
9785
- if (before) query = query.lt("updated_at", before);
9786
- const { data, error: error2 } = await query;
9787
- if (error2) return c.json({ error: error2.message }, 500);
9788
- threads = data;
9789
- }
9790
- if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
9791
- const threadIds = threads.map((t) => t.id);
9792
- const chunkResults = await Promise.all(
9793
- chunk(threadIds, THREAD_CHUNK_SIZE).map(
9794
- (ids) => supabase.from("thread_replies").select("*").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
9795
- )
9796
- );
9797
- const repliesByThread = /* @__PURE__ */ new Map();
9798
- for (const { data } of chunkResults) {
9799
- for (const r of data ?? []) {
9800
- const arr = repliesByThread.get(r.thread_id);
9801
- if (arr) arr.push(r);
9802
- else repliesByThread.set(r.thread_id, [r]);
9803
- }
9804
- }
9805
- const withReplies = threads.map((t) => {
9806
- const channelMeta = meta?.get(t.channel_id);
9807
- return {
9808
- ...t,
9809
- ...meta ? {
9810
- workspace: channelMeta?.workspace ?? null,
9811
- channel_name: channelMeta?.channelName ?? null,
9812
- agent_name: channelMeta?.agentName ?? null
9813
- } : {},
9814
- replies: (repliesByThread.get(t.id) ?? []).map((r) => ({
9815
- ...r,
9816
- body: slackMrkdwnToMarkdown(r.body)
9817
- }))
9818
- };
9819
- });
9820
- const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
9821
- return c.json({ threads: withReplies, nextCursor });
9635
+ return admin;
9636
+ }
9637
+
9638
+ // ../api/src/dashboard/agent-identities.ts
9639
+ var BOT_LABEL = "Workspace Agent";
9640
+ var BOT_DM_KEY = "claude";
9641
+
9642
+ // ../api/src/dashboard/data-api.ts
9643
+ import { existsSync as existsSync19 } from "fs";
9644
+ import sharp5 from "sharp";
9645
+
9646
+ // ../api/src/dashboard/automations-routes.ts
9647
+ import { Hono as Hono11 } from "hono";
9648
+ var TYPES2 = ["schedule", "trigger", "mention-trigger"];
9649
+ function isType(t) {
9650
+ return TYPES2.includes(t);
9651
+ }
9652
+ function sanitize(body) {
9653
+ const { created_by: _b, created_at: _a, ...rest } = body;
9654
+ void _b;
9655
+ void _a;
9656
+ return rest;
9657
+ }
9658
+ function statusFor(message) {
9659
+ if (/already exists/.test(message)) return 409;
9660
+ if (/not found/.test(message)) return 404;
9661
+ return 400;
9662
+ }
9663
+ function createAutomationWriteRouter() {
9664
+ const app = new Hono11();
9665
+ const manager = () => new AutomationManager();
9666
+ app.get("/:type/:name", (c) => {
9667
+ const item = manager().get(c.req.param("name"));
9668
+ if (!item) return c.json({ error: "not found" }, 404);
9669
+ return c.json(item);
9822
9670
  });
9823
- app.get("/threads/archived", async (c) => {
9824
- if (c.get("auth")?.role !== "admin") {
9825
- return c.json({ error: "only an admin can view archived topics" }, 403);
9671
+ app.post("/:type", async (c) => {
9672
+ const type = c.req.param("type");
9673
+ if (!isType(type)) return c.json({ error: "unknown type" }, 400);
9674
+ const body = sanitize(await c.req.json());
9675
+ const next = { ...body, created_at: (/* @__PURE__ */ new Date()).toISOString() };
9676
+ try {
9677
+ const m = manager();
9678
+ if (type === "schedule") await m.addSchedule(next);
9679
+ else if (type === "trigger") await m.addTrigger(next);
9680
+ else await m.addMentionTrigger(next);
9681
+ return c.json(next, 201);
9682
+ } catch (e) {
9683
+ const msg = e.message;
9684
+ const status = statusFor(msg);
9685
+ return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
9826
9686
  }
9827
- const requestedLimit = Number(c.req.query("limit"));
9828
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
9829
- const before = c.req.query("before");
9830
- const supabase = getSupabaseClient();
9831
- let query = supabase.from("threads").select("*").in("channel_id", [...ownChannelIds(c)]).not("archived_at", "is", null).order("archived_at", { ascending: false }).limit(limit);
9832
- if (before) query = query.lt("archived_at", before);
9833
- const { data: threads, error: error2 } = await query;
9834
- if (error2) return c.json({ error: error2.message }, 500);
9835
- if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
9836
- const withPreview = await withPreviews(supabase, threads);
9837
- const nextCursor = threads.length === limit ? threads[threads.length - 1].archived_at : null;
9838
- return c.json({ threads: withPreview, nextCursor });
9839
- });
9840
- app.get("/threads/:id", async (c) => {
9841
- const id = c.req.param("id");
9842
- const requestedLimit = Number(c.req.query("limit"));
9843
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, REPLIES_PAGE_SIZE_MAX) : REPLIES_PAGE_SIZE;
9844
- const before = c.req.query("before");
9845
- let repliesQuery = getSupabaseClient().from("thread_replies").select("*, reactions:reply_reactions(emoji, author)").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: false }).limit(limit);
9846
- if (before) repliesQuery = repliesQuery.lt("created_at", before);
9847
- const [thread, replies, opening] = await Promise.all([
9848
- getSupabaseClient().from("threads").select("*").eq("id", id).single(),
9849
- repliesQuery,
9850
- // The opening message, fetched separately because the replies page
9851
- // above is newest-first: on a long thread the opener isn't in it. It
9852
- // is what the header falls back to on a thread with no title of its
9853
- // own (see threadCrumbLabel) — a title is never written on the
9854
- // thread's behalf, so an untitled row must still be able to show and
9855
- // rename a topic.
9856
- getSupabaseClient().from("thread_replies").select("body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(1)
9857
- ]);
9858
- if (thread.error) return c.json({ error: thread.error.message }, 404);
9859
- if (replies.error) return c.json({ error: replies.error.message }, 500);
9860
- const channelId = thread.data?.channel_id;
9861
- const meta = await globalChannelMeta(c);
9862
- const channelMeta = channelId ? meta.get(channelId) : void 0;
9863
- if (!channelId || !channelMeta) {
9864
- return c.json({ error: "not found" }, 404);
9865
- }
9866
- const page2 = replies.data ?? [];
9867
- const nextCursor = page2.length === limit ? page2[page2.length - 1].created_at : null;
9868
- const formattedReplies = page2.reverse().map((r) => ({
9869
- ...r,
9870
- body: slackMrkdwnToMarkdown(r.body)
9871
- }));
9872
- const openingBody = (opening.data ?? [])[0]?.body;
9873
- return c.json({
9874
- ...thread.data,
9875
- // Always stamped (not just on `?scope=all` feeds, see the `/threads`
9876
- // route above) — the reply pane can be opened directly by id (global
9877
- // board, permalink) without the active workspace's channel map ever
9878
- // having loaded this thread's channel, so the header needs its own
9879
- // workspace/channel label rather than depending on that cache.
9880
- workspace: channelMeta.workspace,
9881
- channel_name: channelMeta.channelName,
9882
- agent_name: channelMeta.agentName,
9883
- preview: openingBody ? slackMrkdwnToMarkdown(openingBody) : null,
9884
- replies: formattedReplies,
9885
- nextCursor
9886
- });
9887
9687
  });
9888
- app.post("/threads/:id/translation/:lang", async (c) => {
9889
- const id = c.req.param("id");
9890
- const lang = canonicalLanguage(c.req.param("lang"));
9891
- if (!isTranslatableLanguage2(lang)) return c.json({ error: "unsupported language" }, 400);
9892
- const [thread, replies] = await Promise.all([
9893
- getSupabaseClient().from("threads").select("channel_id").eq("id", id).single(),
9894
- getSupabaseClient().from("thread_replies").select("id, author, body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(TRANSLATABLE_REPLIES_MAX)
9895
- ]);
9896
- if (thread.error) return c.json({ error: "not found" }, 404);
9897
- if (replies.error) return c.json({ error: replies.error.message }, 500);
9898
- const channelId = thread.data?.channel_id;
9899
- if (!channelId || !(await globalChannelMeta(c)).has(channelId)) {
9900
- return c.json({ error: "not found" }, 404);
9901
- }
9902
- const messages = (replies.data ?? []).map(
9903
- (r) => ({ id: r.id, author: r.author, body: slackMrkdwnToMarkdown(r.body) })
9904
- );
9688
+ app.put("/:type/:name", async (c) => {
9689
+ const type = c.req.param("type");
9690
+ if (!isType(type)) return c.json({ error: "unknown type" }, 400);
9691
+ const name = c.req.param("name");
9692
+ const body = sanitize(await c.req.json());
9905
9693
  try {
9906
- return c.json({ lang, messages: await translateThread(id, lang, messages) });
9907
- } catch (err) {
9908
- const message = err instanceof Error ? err.message : "translation failed";
9909
- console.error(`Thread translation failed (${id} \u2192 ${lang}):`, err);
9910
- return c.json({ error: message }, 502);
9911
- }
9912
- });
9913
- app.patch("/threads/:id/board-status", async (c) => {
9914
- const id = c.req.param("id");
9915
- const input2 = await c.req.json().catch(() => null);
9916
- const value = input2?.board_status ?? null;
9917
- if (value !== null && !BOARD_STATUSES.includes(value)) {
9918
- return c.json(
9919
- { error: `board_status must be null or one of: ${BOARD_STATUSES.join(", ")}` },
9920
- 400
9921
- );
9694
+ const m = manager();
9695
+ const existing = m.get(name);
9696
+ const next = {
9697
+ ...body,
9698
+ created_at: existing?.created_at
9699
+ };
9700
+ await m.update(type, name, next);
9701
+ return c.json(next, 200);
9702
+ } catch (e) {
9703
+ const msg = e.message;
9704
+ const status = statusFor(msg);
9705
+ return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
9922
9706
  }
9923
- if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
9924
- const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ board_status: value }).eq("id", id).select().single();
9925
- if (error2) return c.json({ error: error2.message }, 500);
9926
- return c.json(thread);
9927
9707
  });
9928
- app.patch("/threads/:id", async (c) => {
9929
- const id = c.req.param("id");
9930
- const input2 = await c.req.json().catch(() => null);
9931
- const raw = input2?.title;
9932
- if (raw !== null && !isNonEmptyString(raw)) {
9933
- return c.json({ errors: ["title must be a non-empty string, or null to clear it"] }, 400);
9708
+ app.delete("/:type/:name", async (c) => {
9709
+ try {
9710
+ await manager().remove(c.req.param("name"));
9711
+ return c.json({ ok: true }, 200);
9712
+ } catch (e) {
9713
+ return c.json({ error: e.message }, 404);
9934
9714
  }
9935
- const title = raw === null ? null : raw.trim();
9936
- if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
9937
- const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ title }).eq("id", id).select().single();
9938
- if (error2) return c.json({ error: error2.message }, 500);
9939
- return c.json(thread);
9940
9715
  });
9941
- app.post("/threads/:id/suggest-title", async (c) => {
9942
- const id = c.req.param("id");
9943
- if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
9944
- const { data: thread } = await getSupabaseClient().from("threads").select("channel_id").eq("id", id).single();
9945
- if (!thread) return c.json({ error: "not found" }, 404);
9946
- const { data: rows, error: error2 } = await getSupabaseClient().from("thread_replies").select("author, body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true });
9716
+ return app;
9717
+ }
9718
+
9719
+ // ../api/src/dashboard/categories-routes.ts
9720
+ import { Hono as Hono12 } from "hono";
9721
+ function createCategoryWriteRouter() {
9722
+ const app = new Hono12();
9723
+ app.get("/", async (c) => {
9724
+ const { data, error: error2 } = await getSupabaseClient().from("categories").select("*").eq("profile", getProfile()).order("position", { ascending: true });
9947
9725
  if (error2) return c.json({ error: error2.message }, 500);
9948
- const transcript = (rows ?? []).map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
9949
- const title = await labelThread(thread.channel_id, transcript, "thread");
9950
- if (!title) return c.json({ error: "could not generate a topic" }, 502);
9951
- return c.json({ title });
9726
+ return c.json({ categories: data ?? [] });
9952
9727
  });
9953
- app.patch("/replies/:replyId", async (c) => {
9954
- const replyId = c.req.param("replyId");
9955
- const input2 = await c.req.json().catch(() => null);
9956
- const author = input2?.author;
9957
- const body = input2?.body;
9958
- if (!isNonEmptyString(author) || !isNonEmptyString(body)) {
9959
- return c.json({ errors: ["author and body are required"] }, 400);
9960
- }
9728
+ app.post("/", async (c) => {
9729
+ const body = await c.req.json().catch(() => ({}));
9730
+ const name = typeof body["name"] === "string" ? body["name"].trim() : "";
9731
+ if (!name) return c.json({ errors: ["name is required"] }, 400);
9961
9732
  const supabase = getSupabaseClient();
9962
- const { data: existing, error: fetchError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
9963
- if (fetchError) return c.json({ error: fetchError.message }, 404);
9964
- if (!await threadIsKnown(existing.thread_id, c)) return c.json({ error: "not found" }, 404);
9965
- if (existing.author !== author) {
9966
- return c.json({ error: "only the author can edit a message" }, 403);
9967
- }
9968
- const { data: reply, error: error2 } = await supabase.from("thread_replies").update({ body, edited_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", replyId).select().single();
9733
+ const { data: last } = await supabase.from("categories").select("position").eq("profile", getProfile()).order("position", { ascending: false }).limit(1);
9734
+ const nextPosition = (last?.[0]?.position ?? -1) + 1;
9735
+ const { data, error: error2 } = await supabase.from("categories").insert({ name, position: nextPosition, profile: getProfile() }).select().single();
9969
9736
  if (error2) return c.json({ error: error2.message }, 500);
9970
- if (isHumanAuthor(author)) {
9971
- const { data: latest } = await supabase.from("thread_replies").select("id").eq("thread_id", existing.thread_id).is("archived_at", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
9972
- if (latest?.id === replyId) {
9973
- await Promise.resolve(regenerateAgentRun?.(existing.thread_id, replyId)).catch(() => {
9974
- });
9975
- }
9976
- }
9977
- return c.json(reply);
9737
+ return c.json(data, 201);
9978
9738
  });
9979
- app.post("/replies/:replyId/reactions", async (c) => {
9980
- const replyId = c.req.param("replyId");
9981
- const input2 = await c.req.json().catch(() => null);
9982
- const author = input2?.author;
9983
- const emoji = input2?.emoji;
9984
- if (!isNonEmptyString(author) || !isNonEmptyString(emoji)) {
9985
- return c.json({ errors: ["author and emoji are required"] }, 400);
9986
- }
9739
+ app.patch("/reorder", async (c) => {
9740
+ const body = await c.req.json().catch(() => ({}));
9741
+ const ids = Array.isArray(body["ids"]) ? body["ids"].filter((x) => typeof x === "string") : [];
9742
+ if (ids.length === 0) return c.json({ errors: ["ids is required"] }, 400);
9987
9743
  const supabase = getSupabaseClient();
9988
- const { data: reply, error: replyError } = await supabase.from("thread_replies").select("thread_id").eq("id", replyId).single();
9989
- if (replyError) return c.json({ error: "not found" }, 404);
9990
- if (!await threadIsKnown(reply.thread_id, c)) return c.json({ error: "not found" }, 404);
9991
- const { data: existing, error: existingError } = await supabase.from("reply_reactions").select("id").eq("reply_id", replyId).eq("emoji", emoji).eq("author", author).maybeSingle();
9992
- if (existingError) return c.json({ error: existingError.message }, 500);
9993
- if (existing) {
9994
- const { error: error2 } = await supabase.from("reply_reactions").delete().eq("id", existing.id);
9995
- if (error2) return c.json({ error: error2.message }, 500);
9996
- } else {
9997
- const { error: error2 } = await supabase.from("reply_reactions").insert({ reply_id: replyId, emoji, author });
9998
- if (error2 && error2.code !== "23505") return c.json({ error: error2.message }, 500);
9999
- }
10000
- const { data: reactions, error: reactionsError } = await supabase.from("reply_reactions").select("emoji, author").eq("reply_id", replyId);
10001
- if (reactionsError) return c.json({ error: reactionsError.message }, 500);
10002
- return c.json({ reactions: reactions ?? [] });
9744
+ const results = await Promise.all(
9745
+ ids.map(
9746
+ (id, position) => supabase.from("categories").update({ position, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile())
9747
+ )
9748
+ );
9749
+ const failed2 = results.find((r) => r.error);
9750
+ if (failed2?.error) return c.json({ error: failed2.error.message }, 500);
9751
+ return c.json({ ok: true });
10003
9752
  });
10004
- app.patch("/threads/:id/archive", async (c) => {
10005
- if (c.get("auth")?.role !== "admin") {
10006
- return c.json({ error: "only an admin can archive a topic" }, 403);
10007
- }
9753
+ app.put("/:id", async (c) => {
10008
9754
  const id = c.req.param("id");
10009
- const input2 = await c.req.json().catch(() => null);
10010
- const archived = input2?.archived;
10011
- if (typeof archived !== "boolean") {
10012
- return c.json({ errors: ["archived (boolean) is required"] }, 400);
10013
- }
10014
- if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
10015
- const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ archived_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", id).select().single();
10016
- if (error2) return c.json({ error: error2.message }, 500);
10017
- return c.json(thread);
9755
+ const body = await c.req.json().catch(() => ({}));
9756
+ const name = typeof body["name"] === "string" ? body["name"].trim() : "";
9757
+ if (!name) return c.json({ errors: ["name is required"] }, 400);
9758
+ const { data, error: error2 } = await getSupabaseClient().from("categories").update({ name, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile()).select().single();
9759
+ if (error2) return c.json({ error: error2.message }, 404);
9760
+ return c.json(data);
10018
9761
  });
10019
- app.post("/channels/:channelId/threads", async (c) => {
10020
- const channelId = c.req.param("channelId");
10021
- if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
10022
- const input2 = await c.req.json();
10023
- if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
10024
- return c.json({ errors: ["author and body are required"] }, 400);
10025
- }
10026
- const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10027
- const model = requestedModel(c, input2.model);
10028
- const effort = requestedEffort(c, input2.effort);
9762
+ app.delete("/:id", async (c) => {
9763
+ const id = c.req.param("id");
10029
9764
  const supabase = getSupabaseClient();
10030
- let thread;
10031
- let replayed = false;
10032
- const { data: inserted, error: threadError } = await supabase.from("threads").insert({
10033
- channel_id: channelId,
10034
- // A stored title means someone named the topic. A blank one is not a
10035
- // name, so it lands as null and the topic is derived from the
10036
- // opening message at display time (see threadCrumbLabel).
10037
- title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
10038
- author: input2.author,
10039
- client_key: clientKey2
10040
- }).select().single();
10041
- if (threadError) {
10042
- if (!clientKey2 || threadError.code !== "23505") {
10043
- return c.json({ error: threadError.message }, 500);
10044
- }
10045
- const { data: existing } = await supabase.from("threads").select("*").eq("client_key", clientKey2).single();
10046
- if (!existing) return c.json({ error: threadError.message }, 500);
10047
- thread = existing;
10048
- replayed = true;
10049
- } else {
10050
- thread = inserted;
10051
- }
10052
- let { data: reply } = replayed && clientKey2 ? await supabase.from("thread_replies").select("*").eq("client_key", clientKey2).single() : { data: null };
10053
- let replyCompleted = false;
10054
- if (!reply) {
10055
- const { data: insertedReply, error: replyError } = await supabase.from("thread_replies").insert({
10056
- thread_id: thread.id,
10057
- author: input2.author,
10058
- body: input2.body,
10059
- client_key: clientKey2,
10060
- model,
10061
- effort
10062
- }).select().single();
10063
- if (replyError) return c.json({ error: replyError.message }, 500);
10064
- reply = insertedReply;
10065
- replyCompleted = true;
10066
- }
10067
- if (!replayed || replyCompleted) {
10068
- onMessage?.({
10069
- threadId: thread.id,
10070
- replyId: reply.id,
10071
- author: input2.author,
10072
- body: input2.body,
10073
- // From the row, not `model`/`effort`: on the replay path the reply
10074
- // predates this request, so its own stored override is the source of
10075
- // truth.
10076
- model: reply["model"] ?? void 0,
10077
- effort: effortFromRow(reply["effort"])
10078
- });
10079
- }
10080
- if (!replayed && thread["title"] == null) {
10081
- void generateAutoTitle(thread.id, channelId, input2.body).catch(
10082
- (err) => console.error("Auto title failed:", err)
10083
- );
10084
- }
10085
- return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
10086
- });
10087
- app.post("/replies/:replyId/fork", async (c) => {
10088
- const replyId = c.req.param("replyId");
10089
- const input2 = await c.req.json().catch(() => null);
10090
- const author = input2?.author;
10091
- if (!isNonEmptyString(author)) {
10092
- return c.json({ errors: ["author is required"] }, 400);
10093
- }
10094
- const mode = input2?.mode === "focus" ? "focus" : "condense";
10095
- const supabase = getSupabaseClient();
10096
- const { data: source, error: sourceError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
10097
- if (sourceError) return c.json({ error: sourceError.message }, 404);
10098
- const { data: sourceThread, error: sourceThreadError } = await supabase.from("threads").select("*").eq("id", source.thread_id).single();
10099
- if (sourceThreadError) return c.json({ error: sourceThreadError.message }, 404);
10100
- if (!(await globalChannelMeta(c)).has(sourceThread.channel_id)) {
10101
- return c.json({ error: "not found" }, 404);
10102
- }
10103
- const { data: priorReplies } = await supabase.from("thread_replies").select("author, body, created_at").eq("thread_id", source.thread_id).lte("created_at", source.created_at).is("archived_at", null).order("created_at", { ascending: true });
10104
- const priorRows = priorReplies ?? [];
10105
- const sourceText = slackMrkdwnToMarkdown(source.body);
10106
- const FOCUS_CONTEXT_BELOW_CHARS = 280;
10107
- const inputRows = mode === "focus" ? priorRows.slice(sourceText.length < FOCUS_CONTEXT_BELOW_CHARS ? -2 : -1) : priorRows;
10108
- const transcript = inputRows.map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
10109
- const { title, body } = await buildForkTopic(transcript || sourceText, sourceText, mode);
10110
- const backlink = `/chat/${encodeURIComponent(sourceThread.channel_id)}/${sourceThread.id}#m-${replyId.slice(0, 8)}`;
10111
- const openingBody = `${body}
10112
-
10113
- > \u2442 Forked from [a message by ${source.author}](${backlink})`;
10114
- const { data: thread, error: threadError } = await supabase.from("threads").insert({ channel_id: sourceThread.channel_id, title, author }).select().single();
10115
- if (threadError) return c.json({ error: threadError.message }, 500);
10116
- const { data: reply, error: replyError } = await supabase.from("thread_replies").insert({ thread_id: thread.id, author, body: openingBody }).select().single();
10117
- if (replyError) return c.json({ error: replyError.message }, 500);
10118
- onMessage?.({
10119
- threadId: thread.id,
10120
- replyId: reply.id,
10121
- author,
10122
- body: openingBody
10123
- });
10124
- return c.json({ ...thread, replies: [reply] }, 201);
10125
- });
10126
- app.post("/threads/:id/replies", async (c) => {
10127
- const threadId = c.req.param("id");
10128
- const input2 = await c.req.json();
10129
- if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
10130
- return c.json({ errors: ["author and body are required"] }, 400);
10131
- }
10132
- const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10133
- const effort = requestedEffort(c, input2.effort);
10134
- if (!await threadIsKnown(threadId, c)) return c.json({ error: "not found" }, 404);
10135
- const model = requestedModel(c, input2.model) ?? await threadModel(threadId);
10136
- const { data, error: error2 } = await getSupabaseClient().from("thread_replies").insert({
10137
- thread_id: threadId,
10138
- author: input2.author,
10139
- body: input2.body,
10140
- client_key: clientKey2,
10141
- model,
10142
- effort
10143
- }).select().single();
10144
- if (error2) {
10145
- if (clientKey2 && error2.code === "23505") {
10146
- const { data: existing } = await getSupabaseClient().from("thread_replies").select("*").eq("client_key", clientKey2).single();
10147
- if (existing) return c.json(existing, 200);
10148
- }
10149
- return c.json({ error: error2.message }, 500);
10150
- }
10151
- onMessage?.({
10152
- threadId,
10153
- replyId: data.id,
10154
- author: input2.author,
10155
- body: input2.body,
10156
- model: data.model ?? void 0,
10157
- effort: effortFromRow(data.effort)
10158
- });
10159
- return c.json(data, 201);
9765
+ const { error: unsetError } = await supabase.from("channels").update({ category_id: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("category_id", id);
9766
+ if (unsetError) return c.json({ error: unsetError.message }, 500);
9767
+ await refreshChannelRegistry();
9768
+ const { error: error2 } = await supabase.from("categories").delete().eq("id", id).eq("profile", getProfile());
9769
+ if (error2) return c.json({ error: error2.message }, 404);
9770
+ return c.json({ ok: true });
10160
9771
  });
10161
9772
  return app;
10162
9773
  }
10163
9774
 
10164
- // ../api/src/dashboard/doc-shares-routes.ts
9775
+ // ../api/src/dashboard/chat-routes.ts
10165
9776
  import { Hono as Hono13 } from "hono";
10166
9777
 
10167
- // ../api/src/dashboard/doc-share-features.ts
10168
- import { DeleteObjectCommand as DeleteObjectCommand2 } from "@aws-sdk/client-s3";
10169
- var DEFAULT_FEATURES = {
10170
- audioEnabled: false,
10171
- askEnabled: true,
10172
- socialEnabled: false,
10173
- brandingEnabled: true,
10174
- translationEnabled: false,
10175
- translationLangs: []
10176
- };
10177
- var HTML_FEATURES = {
10178
- audioEnabled: false,
10179
- askEnabled: false,
10180
- socialEnabled: false,
10181
- brandingEnabled: false,
10182
- translationEnabled: false,
10183
- translationLangs: []
10184
- };
10185
- function resolveFeatures(requested, existing) {
10186
- const base = existing ?? DEFAULT_FEATURES;
10187
- return {
10188
- audioEnabled: requested?.audio ?? base.audioEnabled,
10189
- askEnabled: requested?.ask ?? base.askEnabled,
10190
- socialEnabled: requested?.social ?? base.socialEnabled,
10191
- brandingEnabled: requested?.branding ?? base.brandingEnabled,
10192
- translationEnabled: requested?.translation ?? base.translationEnabled,
10193
- // Replaced wholesale, not merged: the allowlist IS the answer to "which
10194
- // languages does this link offer", and a merge would make removing one
10195
- // impossible.
10196
- translationLangs: requested?.translationLangs ?? base.translationLangs
10197
- };
10198
- }
10199
- async function deleteShareAssets(keys) {
10200
- const client2 = getR2Client();
10201
- const bucket = getR2Bucket();
10202
- for (const key of keys) {
10203
- try {
10204
- await client2.send(new DeleteObjectCommand2({ Bucket: bucket, Key: key }));
10205
- } catch {
10206
- }
10207
- }
10208
- }
10209
- async function updateShareFeatures(share, requested) {
10210
- if (share.kind === "html") return share;
10211
- if (share.kind === "excalidraw") {
10212
- if (requested.branding === void 0) return share;
10213
- return updateDocShareFeatures(share.id, { ...share, brandingEnabled: requested.branding });
10214
- }
10215
- const features = resolveFeatures(requested, share);
10216
- const updated = await updateDocShareFeatures(share.id, features);
10217
- if (!updated) return null;
10218
- if (features.audioEnabled) void queueShareAudio(updated);
10219
- else if (share.audioEnabled) await dropShareAudio(share);
10220
- return updated;
10221
- }
9778
+ // ../api/src/dashboard/fork-summary.ts
9779
+ var GROQ_API_URL4 = "https://api.groq.com/openai/v1/chat/completions";
9780
+ var SUMMARY_MODEL = "openai/gpt-oss-120b";
9781
+ var MAX_INPUT_CHARS2 = 24e3;
9782
+ var TITLE_MAX_CHARS = 80;
9783
+ var CONDENSE_SYSTEM_PROMPT = `You turn a chat conversation transcript into a new discussion topic where the work continues. Output EXACTLY two parts separated by one blank line: the first line is a concise topic title (max ${TITLE_MAX_CHARS} characters, no quotes, no Markdown), then a handoff summary of the conversation so far.
10222
9784
 
10223
- // ../api/src/dashboard/agent-bridge-data.ts
10224
- import { randomBytes as randomBytes5, randomUUID as randomUUID2, verify as verifyRaw } from "crypto";
10225
- import { WebSocket, WebSocketServer } from "ws";
9785
+ Rules:
9786
+ - Keep the conversation's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
9787
+ - The summary opens a new thread where the work picks up: state the goal, what has been done or decided so far, and what remains open or should happen next.
9788
+ - Weight the END of the transcript more heavily: later messages reflect the current state. If an earlier plan or decision was later changed, superseded, or abandoned, summarize it in one clause at most and lead with what it became. Never present a discarded early approach with the same weight as the latest one.
9789
+ - Summarize the WORK, not the run mechanics: ignore status noise such as stopped/cancelled/terminated-run markers (e.g. "\u23F9 Stopped by user."), error or retry notices, and progress placeholders. A run being cut short is never part of the topic \u2014 capture the progress that was made instead.
9790
+ - Preserve essential code identifiers, file names, and URLs, but do not reproduce whole code blocks \u2014 describe what they do in one sentence instead.
9791
+ - No preamble like "Summary:" \u2014 start directly with the title line.`;
9792
+ var FOCUS_TITLE_SYSTEM_PROMPT = `You name a new discussion topic after one chat message that someone is branching off into a focused side-discussion.
10226
9793
 
10227
- // ../api/src/dashboard/host-identity.ts
10228
- import { createPrivateKey as createPrivateKey2, createPublicKey as createPublicKey2, generateKeyPairSync as generateKeyPairSync2 } from "crypto";
10229
- import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync16, writeFileSync as writeFileSync9 } from "fs";
10230
- import { dirname as dirname5, join as join17 } from "path";
10231
- function hostPrivateKeyPath() {
10232
- return process.env["FLYSPACE_HOST_KEY_PATH"] || join17(paths.BASE_CONFIG_DIR, "host.key");
9794
+ Output ONE line only: the title. Nothing else \u2014 no summary, no explanation, no blank line.
9795
+
9796
+ Shape:
9797
+ - A short noun phrase naming the subject, like a filed ticket's title. Max ${TITLE_MAX_CHARS} characters, and shorter is better.
9798
+ - Not a sentence copied from the message, not a quote, not Markdown. No bold, no backticks, no quotes, no trailing punctuation.
9799
+
9800
+ Content:
9801
+ - Keep the message's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
9802
+ - Name the topic after the FINAL message in the input. Any earlier message is only context for what that message replies to, never the subject of the title.
9803
+ - Name that message's central point. A message that diagnoses something is named after the finding, even when its last paragraph proposes a next step \u2014 do not title it after the proposed action.
9804
+ - Never reframe it into a different question, never assert a conclusion the message does not reach, and never turn an open investigation into a settled recommendation or a list of steps.
9805
+
9806
+ Example \u2014 a message that works out why an admin could not sign in, and closes by suggesting turning email confirmation off:
9807
+ admin \u5E33\u865F\u88AB email \u9A57\u8B49\u4FE1\u5361\u4F4F`;
9808
+ function clampTitle(title) {
9809
+ return title.length > TITLE_MAX_CHARS ? `${title.slice(0, TITLE_MAX_CHARS - 1)}\u2026` : title;
10233
9810
  }
10234
- function loadHostPrivateKey(path = hostPrivateKeyPath()) {
10235
- if (!existsSync17(path)) {
10236
- throw new Error(
10237
- `No Host identity at ${path}. Run \`pnpm run generate-host-key\` on this machine, then set FLYSPACE_HOST_PUBLIC_KEY on the Data process to the printed public key.`
10238
- );
10239
- }
10240
- return createPrivateKey2(readFileSync16(path, "utf8"));
9811
+ function deriveTitle(body) {
9812
+ const line = body.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "Forked message";
9813
+ return clampTitle(line);
10241
9814
  }
10242
-
10243
- // ../api/src/store/device-store.ts
10244
- import { createHash as createHash8, createPublicKey as createPublicKey3, randomBytes as randomBytes4, randomInt } from "crypto";
10245
- var DEVICE_CODE_TTL_MS = 10 * 6e4;
10246
- var ACCESS_TOKEN_TTL_MS = 15 * 6e4;
10247
- var CHALLENGE_TTL_MS = 2 * 6e4;
10248
- var POLL_INTERVAL_S = 5;
10249
- var sha256 = (s) => createHash8("sha256").update(s).digest("hex");
10250
- function parseEd25519PublicKey(b64) {
10251
- if (typeof b64 !== "string" || b64.length > 200) return null;
10252
- try {
10253
- const key = createPublicKey3({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
10254
- return key.asymmetricKeyType === "ed25519" ? key : null;
10255
- } catch {
10256
- return null;
10257
- }
9815
+ function cleanTitleLine(raw) {
9816
+ const line = raw.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
9817
+ return clampTitle(
9818
+ line.replace(/^#{1,6}\s*/, "").replace(/^[*_`"'\u201c\u2018\u300c\u300e]+|[*_`"'\u201d\u2019\u300d\u300f]+$/g, "").trim()
9819
+ );
10258
9820
  }
10259
- var USER_CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ";
10260
- function generateUserCode() {
10261
- const pick = () => Array.from({ length: 4 }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
10262
- return `${pick()}-${pick()}`;
9821
+ function fallbackTopic(body) {
9822
+ return { title: deriveTitle(body), body };
10263
9823
  }
10264
- function normalizeUserCode(input2) {
10265
- const raw = input2.toUpperCase().replace(/[\s-]/g, "");
10266
- if (raw.length !== 8 || [...raw].some((ch) => !USER_CODE_ALPHABET.includes(ch))) return null;
10267
- return `${raw.slice(0, 4)}-${raw.slice(4)}`;
10268
- }
10269
- function toDevice(r) {
10270
- return {
10271
- id: String(r["id"]),
10272
- userId: String(r["user_id"]),
10273
- name: String(r["name"]),
10274
- platform: r["platform"] ?? null,
10275
- publicKey: String(r["public_key"]),
10276
- createdAt: String(r["created_at"]),
10277
- lastSeenAt: r["last_seen_at"] ?? null,
10278
- revokedAt: r["revoked_at"] ?? null
10279
- };
10280
- }
10281
- function toPending(r) {
9824
+ function parseTopic(raw, original) {
9825
+ const text = raw.trim();
9826
+ const breakAt = text.indexOf("\n");
9827
+ if (breakAt === -1) return { title: deriveTitle(text), body: text };
9828
+ const title = text.slice(0, breakAt).trim();
9829
+ const body = text.slice(breakAt + 1).trim();
9830
+ if (!title || !body) return fallbackTopic(original);
10282
9831
  return {
10283
- id: String(r["id"]),
10284
- userCode: String(r["user_code"]),
10285
- publicKey: String(r["public_key"]),
10286
- name: String(r["name"]),
10287
- platform: r["platform"] ?? null,
10288
- requesterIp: r["requester_ip"] ?? null,
10289
- approvedBy: r["approved_by"] ?? null,
10290
- workspaceIds: r["workspace_ids"] ?? [],
10291
- deniedAt: r["denied_at"] ?? null,
10292
- lastPolledAt: r["last_polled_at"] ?? null,
10293
- expiresAt: String(r["expires_at"]),
10294
- createdAt: String(r["created_at"])
9832
+ title: clampTitle(title),
9833
+ body
10295
9834
  };
10296
9835
  }
10297
- function fail(what, error2) {
10298
- throw new Error(`${what}: ${error2.message}`);
10299
- }
10300
- async function createDeviceCode(input2) {
10301
- const db = getSupabaseClient();
10302
- await db.from("device_codes").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
10303
- const deviceCode = randomBytes4(32).toString("hex");
10304
- for (let attempt = 0; attempt < 3; attempt++) {
10305
- const userCode = generateUserCode();
10306
- const { error: error2 } = await db.from("device_codes").insert({
10307
- device_code_hash: sha256(deviceCode),
10308
- user_code: userCode,
10309
- public_key: input2.publicKey,
10310
- name: input2.name,
10311
- platform: input2.platform,
10312
- requester_ip: input2.requesterIp,
10313
- expires_at: new Date(Date.now() + DEVICE_CODE_TTL_MS).toISOString()
9836
+ async function buildForkTopic(transcript, fallbackText, mode = "condense") {
9837
+ const input2 = transcript.length > MAX_INPUT_CHARS2 ? transcript.slice(-MAX_INPUT_CHARS2) : transcript;
9838
+ const fallback = fallbackText ?? transcript;
9839
+ const focus = mode === "focus";
9840
+ const apiKey = process.env["GROQ_API_KEY"];
9841
+ if (!apiKey) return fallbackTopic(fallback);
9842
+ try {
9843
+ const res = await fetch(GROQ_API_URL4, {
9844
+ method: "POST",
9845
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
9846
+ body: JSON.stringify({
9847
+ model: SUMMARY_MODEL,
9848
+ temperature: 0.2,
9849
+ // A title has to sort the message's central claim from the next-step
9850
+ // paragraph it often ends with; 'low' reliably grabs the latter.
9851
+ reasoning_effort: focus ? "medium" : "low",
9852
+ // A title is one short line, but focus mode reasons before answering
9853
+ // and the budget covers reasoning tokens too — too small a cap here
9854
+ // truncates the title mid-word rather than failing.
9855
+ max_tokens: focus ? 512 : 1024,
9856
+ messages: [
9857
+ {
9858
+ role: "system",
9859
+ content: focus ? FOCUS_TITLE_SYSTEM_PROMPT : CONDENSE_SYSTEM_PROMPT
9860
+ },
9861
+ { role: "user", content: input2 }
9862
+ ]
9863
+ })
10314
9864
  });
10315
- if (!error2) return { deviceCode, userCode };
10316
- if (error2.code !== "23505") fail("createDeviceCode", error2);
9865
+ if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
9866
+ const data = await res.json();
9867
+ const raw = data.choices?.[0]?.message?.content?.trim();
9868
+ if (!raw) throw new Error("Groq returned an empty summary");
9869
+ if (focus) return { title: cleanTitleLine(raw) || deriveTitle(fallback), body: fallback };
9870
+ return parseTopic(raw, fallback);
9871
+ } catch (err) {
9872
+ console.error("Fork summary failed, using original text:", err);
9873
+ return fallbackTopic(fallback);
10317
9874
  }
10318
- throw new Error("createDeviceCode: could not allocate a unique user code");
10319
9875
  }
10320
- async function getDeviceCodeBySecret(deviceCode) {
10321
- const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("device_code_hash", sha256(deviceCode)).maybeSingle();
10322
- if (error2) fail("getDeviceCodeBySecret", error2);
10323
- return data ? toPending(data) : null;
9876
+
9877
+ // ../api/src/dashboard/slack-format.ts
9878
+ import { nameToEmoji } from "gemoji";
9879
+ var SLACK_EMOJI_ALIASES = {
9880
+ double_vertical_bar: "\u23F8\uFE0F"
9881
+ };
9882
+ function emojifyShortcode(code) {
9883
+ const name = code.slice(1, -1);
9884
+ return nameToEmoji[name] ?? SLACK_EMOJI_ALIASES[name] ?? code;
10324
9885
  }
10325
- async function getDeviceCodeByUserCode(userCode) {
10326
- const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("user_code", userCode).maybeSingle();
10327
- if (error2) fail("getDeviceCodeByUserCode", error2);
10328
- return data ? toPending(data) : null;
9886
+ function resolveSlackTokens(text) {
9887
+ return text.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_m, id) => `@${getLocalCache().displayNameFor(id)}`).replace(/<#([A-Z0-9]+)\|([^>]+)>/g, (_m, _id, name) => `#${name}`).replace(/<#([A-Z0-9]+)>/g, (_m, id) => `#${getLocalCache().lookupChannelById(id)?.name ?? id}`).replace(/<!channel>/g, "@channel").replace(/<!here>/g, "@here").replace(/<!everyone>/g, "@everyone").replace(/<!subteam\^[A-Z0-9]+\|@?([^>]+)>/g, "@$1").replace(/<mailto:([^|>]+)\|([^>]+)>/g, (_m, addr, label2) => `[${label2}](mailto:${addr})`).replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, (_m, url, label2) => `[${label2}](${url})`).replace(/:[a-z0-9_+-]+:/gi, emojifyShortcode);
10329
9888
  }
10330
- async function markPolled(id) {
10331
- const { error: error2 } = await getSupabaseClient().from("device_codes").update({ last_polled_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id);
10332
- if (error2) fail("markPolled", error2);
9889
+ function slackMrkdwnToMarkdown(text) {
9890
+ return resolveSlackTokens(text).replace(/(?<![\w*])\*(?!\*)(\S(?:[^*\n]*?\S)?)\*(?!\*)(?![\w*])/g, "**$1**").replace(/(?<![\w~])~(?!~)(\S(?:[^~\n]*?\S)?)~(?!~)(?![\w~])/g, "~~$1~~").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
10333
9891
  }
10334
- async function decideDeviceCode(id, userId, decision) {
10335
- const { data, error: error2 } = await getSupabaseClient().from("device_codes").update(
10336
- decision.approve ? { approved_by: userId, workspace_ids: decision.workspaceIds } : { denied_at: (/* @__PURE__ */ new Date()).toISOString() }
10337
- ).eq("id", id).is("approved_by", null).is("denied_at", null).select("id");
10338
- if (error2) fail("decideDeviceCode", error2);
10339
- return (data ?? []).length > 0;
9892
+
9893
+ // ../api/src/dashboard/thread-relevance.ts
9894
+ async function relevantThreadsPage(supabase, handle, channelIds, opts) {
9895
+ const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9896
+ const { data, error: error2 } = await supabase.rpc("relevant_threads", {
9897
+ handle,
9898
+ mention_pattern: `@${escaped}\\M`,
9899
+ channel_ids: channelIds,
9900
+ before: opts.before ?? null,
9901
+ page_limit: opts.limit
9902
+ });
9903
+ if (error2) throw new Error(`relevant_threads: ${error2.message}`);
9904
+ return data ?? [];
10340
9905
  }
10341
- async function redeemDeviceCode(pending2) {
10342
- const db = getSupabaseClient();
10343
- const { data: claimed, error: claimError } = await db.from("device_codes").delete().eq("id", pending2.id).select("id");
10344
- if (claimError) fail("redeemDeviceCode", claimError);
10345
- if (!claimed?.length) return null;
10346
- const existing = pending2.approvedBy ? await findDeviceByKey(pending2.publicKey, pending2.approvedBy) : null;
10347
- const { data, error: error2 } = existing ? await db.from("devices").update({ name: pending2.name, platform: pending2.platform }).eq("id", existing.id).select("*").single() : await db.from("devices").insert({
10348
- user_id: pending2.approvedBy,
10349
- name: pending2.name,
10350
- platform: pending2.platform,
10351
- public_key: pending2.publicKey
10352
- }).select("*").single();
10353
- if (error2) fail("redeemDeviceCode", error2);
10354
- const device = toDevice(data);
10355
- if (pending2.workspaceIds.length) {
10356
- const { error: bindError } = await db.from("device_workspaces").upsert(
10357
- pending2.workspaceIds.map((workspaceId) => ({
10358
- device_id: device.id,
10359
- workspace_id: workspaceId,
10360
- bound_by: pending2.approvedBy
10361
- })),
10362
- { onConflict: "device_id,workspace_id", ignoreDuplicates: true }
10363
- );
10364
- if (bindError) fail("redeemDeviceCode", bindError);
9906
+
9907
+ // ../api/src/dashboard/thread-translate.ts
9908
+ import { createHash as createHash8 } from "crypto";
9909
+
9910
+ // ../api/src/store/thread-translation-store.ts
9911
+ async function getThreadTranslations(lang, keys) {
9912
+ const found = /* @__PURE__ */ new Map();
9913
+ if (keys.length === 0) return found;
9914
+ const clauses = keys.map((k) => `and(reply_id.eq.${k.replyId},body_hash.eq.${k.bodyHash})`).join(",");
9915
+ const { data, error: error2 } = await getSupabaseClient().from("thread_reply_translations").select("reply_id, body, passthrough").eq("lang", lang).or(clauses);
9916
+ if (error2) throw new Error(`getThreadTranslations(${lang}): ${error2.message}`);
9917
+ for (const row of data ?? []) {
9918
+ const replyId = row["reply_id"];
9919
+ found.set(replyId, {
9920
+ replyId,
9921
+ body: row["body"],
9922
+ passthrough: row["passthrough"]
9923
+ });
10365
9924
  }
10366
- return device;
9925
+ return found;
10367
9926
  }
10368
- async function getDevice(id) {
10369
- const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("id", id).maybeSingle();
10370
- if (error2) fail("getDevice", error2);
10371
- return data ? toDevice(data) : null;
9927
+ async function putThreadTranslations(lang, rows) {
9928
+ if (rows.length === 0) return;
9929
+ const { error: error2 } = await getSupabaseClient().from("thread_reply_translations").upsert(
9930
+ rows.map((r) => ({
9931
+ reply_id: r.replyId,
9932
+ lang,
9933
+ body_hash: r.bodyHash,
9934
+ body: r.body,
9935
+ passthrough: r.passthrough
9936
+ })),
9937
+ { onConflict: "reply_id,lang,body_hash" }
9938
+ );
9939
+ if (error2) throw new Error(`putThreadTranslations(${lang}): ${error2.message}`);
10372
9940
  }
10373
- async function findDeviceByKey(publicKey, userId) {
10374
- const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("public_key", publicKey).eq("user_id", userId).is("revoked_at", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
10375
- if (error2) fail("findDeviceByKey", error2);
10376
- return data ? toDevice(data) : null;
9941
+
9942
+ // ../api/src/dashboard/thread-translate.ts
9943
+ var GROQ_API_URL5 = "https://api.groq.com/openai/v1/chat/completions";
9944
+ var TRANSLATION_MODEL2 = "openai/gpt-oss-120b";
9945
+ var RECIPE_VERSION3 = 1;
9946
+ var BATCH_CHAR_BUDGET = 16e3;
9947
+ var CONTEXT_MESSAGES = 4;
9948
+ var MAX_MESSAGE_CHARS = 12e3;
9949
+ var MAX_OUTPUT_TOKENS2 = 32768;
9950
+ var BY_CODE2 = new Map(TRANSLATION_LANGUAGES.map((l) => [l.code, l]));
9951
+ function isTranslatableLanguage2(lang) {
9952
+ return BY_CODE2.has(lang);
10377
9953
  }
10378
- async function issueAccessToken(deviceId) {
10379
- const token = `fsd_${randomBytes4(32).toString("hex")}`;
10380
- const { error: error2 } = await getSupabaseClient().from("devices").update({
10381
- access_token_hash: sha256(token),
10382
- access_token_expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_MS).toISOString(),
10383
- last_seen_at: (/* @__PURE__ */ new Date()).toISOString()
10384
- }).eq("id", deviceId);
10385
- if (error2) fail("issueAccessToken", error2);
10386
- return { token, expiresInS: ACCESS_TOKEN_TTL_MS / 1e3 };
9954
+ function translationHash2(author, body) {
9955
+ return createHash8("sha256").update(`${RECIPE_VERSION3}
9956
+ ${author}
9957
+ ${body}`).digest("hex");
10387
9958
  }
10388
- async function deviceForAccessToken(token) {
10389
- if (!token.startsWith("fsd_")) return null;
10390
- const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("access_token_hash", sha256(token)).is("revoked_at", null).gt("access_token_expires_at", (/* @__PURE__ */ new Date()).toISOString()).maybeSingle();
10391
- if (error2) fail("deviceForAccessToken", error2);
10392
- return data ? toDevice(data) : null;
9959
+ function systemPrompt2(languageName2) {
9960
+ return `You translate messages from a group chat into ${languageName2}. You are a translator, not an editor, a summarizer, or a participant.
9961
+
9962
+ You receive a JSON object with two keys:
9963
+ - "context": earlier messages, for reference only. NEVER translate these and never mention them in your output.
9964
+ - "translate": the messages to translate, each with an "n" and a "body".
9965
+
9966
+ Reply with ONLY a JSON object of this exact shape, one entry per message in "translate", in the same order:
9967
+ {"messages":[{"n":1,"body":"...","already_target":false}]}
9968
+
9969
+ Rules:
9970
+ - "already_target" is true when the message is ALREADY written in ${languageName2}. In that case copy "body" through byte for byte, changing nothing at all. A message whose prose is in another language is NOT already_target just because it contains English technical terms, code, or product names.
9971
+ - Translate meaning, not words. These are people talking, not prose to be polished. Keep the speaker's register: a terse message stays terse, a casual one stays casual, a rude one stays rude. Do not make anyone sound more formal, more complete, or more articulate than they wrote.
9972
+ - A short reply ("\u597D\u554A", "same", "nope") is translated as the natural equivalent in ${languageName2}, using the context to see what it is answering. Never expand it into a full sentence the speaker did not write.
9973
+ - Keep the Markdown exactly as it is: same heading levels, list nesting, table shape, link and image syntax, blockquotes, and order. Do not add, merge, split, or reorder anything.
9974
+ - Do NOT translate: text inside fenced or inline code blocks, URLs, file paths, command names, or @mentions and /skill tokens. Leave them byte-for-byte identical.
9975
+ - Keep established technical terms in English where a fluent speaker of ${languageName2} would keep them in English rather than reaching for a translated coinage.
9976
+ - Use the punctuation conventions of ${languageName2}, including full-width punctuation for Chinese and Japanese. Use ordinary ASCII hyphens, never typographic or non-breaking hyphen variants.
9977
+ - Never add commentary, notes about your choices, apologies, or an explanation of anything you could not translate.`;
10393
9978
  }
10394
- async function revokeDevice2(id) {
10395
- const { error: error2 } = await getSupabaseClient().from("devices").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString(), access_token_hash: null }).eq("id", id);
10396
- if (error2) fail("revokeDevice", error2);
9979
+ function normalizeHyphens2(text) {
9980
+ return text.replace(/‐|‑/g, "-");
10397
9981
  }
10398
- async function createChallenge(deviceId) {
10399
- const db = getSupabaseClient();
10400
- await db.from("device_challenges").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
10401
- const nonce = randomBytes4(32).toString("base64url");
10402
- const { error: error2 } = await db.from("device_challenges").insert({
10403
- nonce,
10404
- device_id: deviceId,
10405
- expires_at: new Date(Date.now() + CHALLENGE_TTL_MS).toISOString()
9982
+ function clip(body) {
9983
+ return body.length > MAX_MESSAGE_CHARS ? body.slice(0, MAX_MESSAGE_CHARS) : body;
9984
+ }
9985
+ function batchMessages(messages) {
9986
+ const batches = [];
9987
+ let current = [];
9988
+ let size = 0;
9989
+ for (const m of messages) {
9990
+ const len = Math.min(m.body.length, MAX_MESSAGE_CHARS);
9991
+ if (current.length > 0 && size + len > BATCH_CHAR_BUDGET) {
9992
+ batches.push(current);
9993
+ current = [];
9994
+ size = 0;
9995
+ }
9996
+ current.push(m);
9997
+ size += len;
9998
+ }
9999
+ if (current.length > 0) batches.push(current);
10000
+ return batches;
10001
+ }
10002
+ async function translateBatch(batch, context, lang) {
10003
+ const language = BY_CODE2.get(lang);
10004
+ if (!language) throw new Error(`unsupported translation language: ${lang}`);
10005
+ const apiKey = process.env["GROQ_API_KEY"];
10006
+ if (!apiKey) throw new Error("GROQ_API_KEY is not configured");
10007
+ const payload = {
10008
+ context: context.map((m) => ({ author: m.author, body: clip(m.body) })),
10009
+ translate: batch.map((m, i) => ({ n: i + 1, author: m.author, body: clip(m.body) }))
10010
+ };
10011
+ const res = await fetch(GROQ_API_URL5, {
10012
+ method: "POST",
10013
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
10014
+ body: JSON.stringify({
10015
+ model: TRANSLATION_MODEL2,
10016
+ temperature: 0,
10017
+ max_tokens: MAX_OUTPUT_TOKENS2,
10018
+ response_format: { type: "json_object" },
10019
+ messages: [
10020
+ { role: "system", content: systemPrompt2(language.name) },
10021
+ { role: "user", content: JSON.stringify(payload) }
10022
+ ]
10023
+ })
10024
+ });
10025
+ if (!res.ok) throw new Error(`translation model returned ${res.status}`);
10026
+ const data = await res.json();
10027
+ const choice = data.choices?.[0];
10028
+ if (choice?.finish_reason === "length") {
10029
+ throw new Error("the translation ran past the model output limit");
10030
+ }
10031
+ const raw = choice?.message?.content?.trim() ?? "";
10032
+ if (!raw) throw new Error("the model returned an empty translation");
10033
+ let parsed;
10034
+ try {
10035
+ parsed = JSON.parse(raw);
10036
+ } catch {
10037
+ throw new Error("the model returned unparseable JSON");
10038
+ }
10039
+ const byNumber = /* @__PURE__ */ new Map();
10040
+ for (const entry of parsed.messages ?? []) {
10041
+ if (typeof entry?.n === "number") byNumber.set(entry.n, entry);
10042
+ }
10043
+ return batch.map((m, i) => {
10044
+ const entry = byNumber.get(i + 1);
10045
+ const body = typeof entry?.body === "string" ? entry.body.trim() : "";
10046
+ if (!body) return { id: m.id, body: m.body, passthrough: true };
10047
+ return {
10048
+ id: m.id,
10049
+ body: normalizeHyphens2(body),
10050
+ passthrough: entry?.already_target === true
10051
+ };
10052
+ });
10053
+ }
10054
+ var inFlight4 = /* @__PURE__ */ new Map();
10055
+ async function translateThread(threadId, lang, messages) {
10056
+ if (!BY_CODE2.has(lang)) throw new Error(`unsupported translation language: ${lang}`);
10057
+ if (messages.length === 0) return [];
10058
+ const shape = createHash8("sha256").update(messages.map((m) => translationHash2(m.author, m.body)).join("\n")).digest("hex");
10059
+ const key = `${threadId}:${lang}:${shape}`;
10060
+ const running = inFlight4.get(key);
10061
+ if (running) return running;
10062
+ const job = (async () => {
10063
+ const hashes = messages.map((m) => ({
10064
+ replyId: m.id,
10065
+ bodyHash: translationHash2(m.author, m.body)
10066
+ }));
10067
+ const cached = await getThreadTranslations(lang, hashes);
10068
+ const missing = messages.filter((m) => !cached.has(m.id));
10069
+ const fresh = /* @__PURE__ */ new Map();
10070
+ if (missing.length > 0) {
10071
+ const index = new Map(messages.map((m, i) => [m.id, i]));
10072
+ const batches = batchMessages(missing);
10073
+ const results = await Promise.all(
10074
+ batches.map((batch) => {
10075
+ const first = index.get(batch[0].id) ?? 0;
10076
+ const context = messages.slice(Math.max(0, first - CONTEXT_MESSAGES), first);
10077
+ return translateBatch(batch, context, lang);
10078
+ })
10079
+ );
10080
+ for (const row of results.flat()) fresh.set(row.id, row);
10081
+ const hashById = new Map(hashes.map((h) => [h.replyId, h.bodyHash]));
10082
+ await putThreadTranslations(
10083
+ lang,
10084
+ [...fresh.values()].map((r) => ({
10085
+ replyId: r.id,
10086
+ bodyHash: hashById.get(r.id),
10087
+ body: r.body,
10088
+ passthrough: r.passthrough
10089
+ }))
10090
+ );
10091
+ }
10092
+ return messages.map((m) => {
10093
+ const hit = cached.get(m.id);
10094
+ if (hit) return { id: m.id, body: hit.body, passthrough: hit.passthrough };
10095
+ return fresh.get(m.id) ?? { id: m.id, body: m.body, passthrough: true };
10096
+ });
10097
+ })().finally(() => inFlight4.delete(key));
10098
+ inFlight4.set(key, job);
10099
+ return job;
10100
+ }
10101
+
10102
+ // ../api/src/dashboard/chat-routes.ts
10103
+ function isNonEmptyString(v) {
10104
+ return typeof v === "string" && v.trim().length > 0;
10105
+ }
10106
+ function sanitizeModel(v) {
10107
+ if (typeof v !== "string") return null;
10108
+ const model = v.trim().toLowerCase();
10109
+ return isAllowedModel(model) ? model : null;
10110
+ }
10111
+ async function threadModel(threadId) {
10112
+ const { data } = await getSupabaseClient().from("thread_replies").select("model").eq("thread_id", threadId).not("model", "is", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
10113
+ return sanitizeModel(data?.model);
10114
+ }
10115
+ function requestedModel(c, model) {
10116
+ return c.get("auth")?.role === "admin" ? sanitizeModel(model) : null;
10117
+ }
10118
+ function sanitizeEffort(v) {
10119
+ if (typeof v !== "string") return null;
10120
+ const effort = v.trim().toLowerCase();
10121
+ return isEffort(effort) ? effort : null;
10122
+ }
10123
+ function requestedEffort(c, effort) {
10124
+ return c.get("auth")?.role === "admin" ? sanitizeEffort(effort) : null;
10125
+ }
10126
+ function effortFromRow(v) {
10127
+ return typeof v === "string" && isEffort(v) ? v : void 0;
10128
+ }
10129
+ var BOARD_STATUSES = [
10130
+ "backlog",
10131
+ "todo",
10132
+ "in_progress",
10133
+ "in_review",
10134
+ "done",
10135
+ "cancelled"
10136
+ ];
10137
+ var NO_STATUS_COLUMN = "none";
10138
+ function boardColumnParam(status) {
10139
+ if (status === NO_STATUS_COLUMN) return NO_STATUS_COLUMN;
10140
+ return BOARD_STATUSES.includes(status) ? status : null;
10141
+ }
10142
+ var THREAD_CHUNK_SIZE = 25;
10143
+ var THREADS_PAGE_SIZE = 30;
10144
+ var THREADS_PAGE_SIZE_MAX = 100;
10145
+ var REPLIES_PAGE_SIZE = 50;
10146
+ var REPLIES_PAGE_SIZE_MAX = 200;
10147
+ var TRANSLATABLE_REPLIES_MAX = 400;
10148
+ var BOARD_PAGE_SIZE = 50;
10149
+ var BOARD_PAGE_SIZE_MAX = 100;
10150
+ function ownChannelIds(c) {
10151
+ const viewer = c.get("auth");
10152
+ return new Set(
10153
+ getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, viewer)).flatMap((ch) => ch.externalChannelId ? [ch.id, ch.externalChannelId] : [ch.id])
10154
+ );
10155
+ }
10156
+ async function globalChannelMeta(c) {
10157
+ const viewer = c.get("auth");
10158
+ const map = /* @__PURE__ */ new Map();
10159
+ const channels = getChannelRegistry();
10160
+ let workspaceIdBySlug = /* @__PURE__ */ new Map();
10161
+ let roleByWorkspaceId = /* @__PURE__ */ new Map();
10162
+ if (viewer?.email && channels.length > 0) {
10163
+ const [workspaces, memberships] = await Promise.all([
10164
+ listWorkspaces(),
10165
+ listMembershipsByEmail(viewer.email)
10166
+ ]);
10167
+ workspaceIdBySlug = new Map(workspaces.map((w) => [w.slug, w.id]));
10168
+ roleByWorkspaceId = new Map(memberships.map((m) => [m.workspaceId, m.role]));
10169
+ }
10170
+ for (const ch of channels) {
10171
+ const workspaceId = workspaceIdBySlug.get(ch.profile);
10172
+ const role = workspaceId ? roleByWorkspaceId.get(workspaceId) : void 0;
10173
+ if (!canSeeChannel(ch, { email: viewer?.email, role })) continue;
10174
+ const meta = { workspace: ch.profile, channelName: ch.name, agentName: ch.agentName };
10175
+ map.set(ch.id, meta);
10176
+ if (ch.externalChannelId) map.set(ch.externalChannelId, meta);
10177
+ }
10178
+ return map;
10179
+ }
10180
+ async function threadIsKnown(threadId, c) {
10181
+ const { data } = await getSupabaseClient().from("threads").select("channel_id").eq("id", threadId).single();
10182
+ const channelId = data?.channel_id;
10183
+ return !!channelId && (await globalChannelMeta(c)).has(channelId);
10184
+ }
10185
+ function chunk(items, size) {
10186
+ const out = [];
10187
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
10188
+ return out;
10189
+ }
10190
+ async function withPreviews(supabase, threads) {
10191
+ const chunkResults = await Promise.all(
10192
+ chunk(
10193
+ threads.map((t) => t.id),
10194
+ THREAD_CHUNK_SIZE
10195
+ ).map(
10196
+ (ids) => supabase.from("thread_replies").select("thread_id, body, author, external_user_id").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
10197
+ )
10198
+ );
10199
+ const previewByThread = /* @__PURE__ */ new Map();
10200
+ for (const { data } of chunkResults) {
10201
+ for (const r of data ?? []) {
10202
+ const entry = previewByThread.get(r.thread_id);
10203
+ if (entry) {
10204
+ entry.count += 1;
10205
+ entry.lastAuthor = r.author;
10206
+ entry.participants.delete(r.author);
10207
+ entry.participants.set(r.author, r.external_user_id ?? null);
10208
+ } else
10209
+ previewByThread.set(r.thread_id, {
10210
+ count: 1,
10211
+ firstBody: r.body,
10212
+ lastAuthor: r.author,
10213
+ participants: /* @__PURE__ */ new Map()
10214
+ });
10215
+ }
10216
+ }
10217
+ return threads.map((t) => {
10218
+ const entry = previewByThread.get(t.id);
10219
+ return {
10220
+ ...t,
10221
+ reply_count: Math.max(0, (entry?.count ?? 0) - 1),
10222
+ preview: entry?.firstBody ? slackMrkdwnToMarkdown(entry.firstBody) : null,
10223
+ // Who spoke last, counting only replies AFTER the opening message —
10224
+ // the reader's own handle here means there is nothing new for them to
10225
+ // read, on this browser or any other (see web/src/lib/thread-unread).
10226
+ last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
10227
+ // Everyone who replied, most recent first, for the row's avatar stack.
10228
+ participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id }))
10229
+ };
10230
+ });
10231
+ }
10232
+ async function labelThread(channelId, body, scope) {
10233
+ const channel = getProfileChannelRegistry().find(
10234
+ (ch) => ch.id === channelId || ch.externalChannelId === channelId
10235
+ );
10236
+ if (!channel) return null;
10237
+ const supabase = getSupabaseClient();
10238
+ const { data: recent } = await supabase.from("threads").select("auto_title, title").eq("channel_id", channelId).order("created_at", { ascending: false }).limit(50);
10239
+ const existingCategories = extractCategories(
10240
+ (recent ?? []).flatMap((t) => [
10241
+ t.auto_title,
10242
+ t.title
10243
+ ])
10244
+ );
10245
+ return buildAutoTitle({
10246
+ channelName: channel.name,
10247
+ channelPrompt: channel.prompt,
10248
+ workspacePrompt: loadWorkspacePrompt().prompt,
10249
+ titlePrompt: (await loadWorkspaceBranding()).autoTitlePrompt,
10250
+ existingCategories,
10251
+ body,
10252
+ scope
10253
+ });
10254
+ }
10255
+ async function generateAutoTitle(threadId, channelId, body) {
10256
+ if ((await loadWorkspaceBranding()).autoTitleEnabled === false) return;
10257
+ const autoTitle = await labelThread(channelId, body);
10258
+ if (!autoTitle) return;
10259
+ const supabase = getSupabaseClient();
10260
+ await supabase.from("threads").update({ auto_title: autoTitle }).eq("id", threadId).is("title", null);
10261
+ }
10262
+ function createChatRouter(agent = {}) {
10263
+ const app = new Hono13();
10264
+ const {
10265
+ onChatMessage: onMessage,
10266
+ isAgentThinking,
10267
+ getAgentActivities,
10268
+ cancelAgentRun,
10269
+ regenerateAgentRun,
10270
+ listAgentRuns
10271
+ } = agent;
10272
+ app.post(
10273
+ "/threads/:id/agent-cancel",
10274
+ async (c) => c.json({ cancelled: await cancelAgentRun?.(c.req.param("id")) ?? false })
10275
+ );
10276
+ app.post(
10277
+ "/threads/:id/agent-regenerate",
10278
+ async (c) => c.json({ started: await regenerateAgentRun?.(c.req.param("id")) ?? false })
10279
+ );
10280
+ app.get(
10281
+ "/threads/:id/agent-status",
10282
+ async (c) => c.json({
10283
+ thinking: await isAgentThinking?.(c.req.param("id")) ?? false,
10284
+ activities: await getAgentActivities?.(c.req.param("id")) ?? []
10285
+ })
10286
+ );
10287
+ app.get("/agent-runs", async (c) => c.json({ threadIds: await listAgentRuns?.() ?? [] }));
10288
+ app.get("/channels/:channelId/threads", async (c) => {
10289
+ const channelId = c.req.param("channelId");
10290
+ if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
10291
+ const requestedLimit = Number(c.req.query("limit"));
10292
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
10293
+ const before = c.req.query("before");
10294
+ const sortColumn = c.req.query("sort") === "activity" ? "updated_at" : "created_at";
10295
+ const statusParam = c.req.query("status");
10296
+ const statusFilter = statusParam ? [
10297
+ ...new Set(
10298
+ statusParam.split(",").map(boardColumnParam).filter((s) => s !== null)
10299
+ )
10300
+ ] : [];
10301
+ const supabase = getSupabaseClient();
10302
+ let query = supabase.from("threads").select("*").eq("channel_id", channelId).is("archived_at", null).order(sortColumn, { ascending: false }).limit(limit);
10303
+ if (before) query = query.lt(sortColumn, before);
10304
+ if (statusFilter.length) {
10305
+ const withoutNone = statusFilter.filter((s) => s !== NO_STATUS_COLUMN);
10306
+ const includeNone = statusFilter.includes(NO_STATUS_COLUMN);
10307
+ if (includeNone && withoutNone.length) {
10308
+ query = query.or(`board_status.in.(${withoutNone.join(",")}),board_status.is.null`);
10309
+ } else if (includeNone) {
10310
+ query = query.is("board_status", null);
10311
+ } else {
10312
+ query = query.in("board_status", withoutNone);
10313
+ }
10314
+ }
10315
+ const { data: threads, error: error2 } = await query;
10316
+ if (error2) return c.json({ error: error2.message }, 500);
10317
+ if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
10318
+ const withPreview = await withPreviews(supabase, threads);
10319
+ const nextCursor = threads.length === limit ? threads[threads.length - 1][sortColumn] : null;
10320
+ return c.json({ threads: withPreview, nextCursor });
10321
+ });
10322
+ app.get("/channels/:channelId/board/:status", async (c) => {
10323
+ const channelId = c.req.param("channelId");
10324
+ if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
10325
+ const column = boardColumnParam(c.req.param("status"));
10326
+ if (!column) {
10327
+ return c.json(
10328
+ { error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
10329
+ 400
10330
+ );
10331
+ }
10332
+ const requestedLimit = Number(c.req.query("limit"));
10333
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
10334
+ const before = c.req.query("before");
10335
+ const supabase = getSupabaseClient();
10336
+ let query = supabase.from("threads").select("*").eq("channel_id", channelId);
10337
+ query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
10338
+ query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
10339
+ if (before) query = query.lt("updated_at", before);
10340
+ const { data: threads, error: error2 } = await query;
10341
+ if (error2) return c.json({ error: error2.message }, 500);
10342
+ if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
10343
+ const withPreview = await withPreviews(supabase, threads);
10344
+ const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
10345
+ return c.json({ threads: withPreview, nextCursor });
10346
+ });
10347
+ app.get("/board/:status", async (c) => {
10348
+ const column = boardColumnParam(c.req.param("status"));
10349
+ if (!column) {
10350
+ return c.json(
10351
+ { error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
10352
+ 400
10353
+ );
10354
+ }
10355
+ const requestedLimit = Number(c.req.query("limit"));
10356
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
10357
+ const before = c.req.query("before");
10358
+ const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
10359
+ const supabase = getSupabaseClient();
10360
+ let query = supabase.from("threads").select("*").in("channel_id", meta ? [...meta.keys()] : [...ownChannelIds(c)]);
10361
+ query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
10362
+ query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
10363
+ if (before) query = query.lt("updated_at", before);
10364
+ const { data: threads, error: error2 } = await query;
10365
+ if (error2) return c.json({ error: error2.message }, 500);
10366
+ if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
10367
+ const withPreview = await withPreviews(supabase, threads);
10368
+ const labelled = meta ? withPreview.map((t) => {
10369
+ const channelMeta = meta.get(t.channel_id ?? "");
10370
+ return {
10371
+ ...t,
10372
+ workspace: channelMeta?.workspace ?? null,
10373
+ channel_name: channelMeta?.channelName ?? null,
10374
+ agent_name: channelMeta?.agentName ?? null
10375
+ };
10376
+ }) : withPreview;
10377
+ const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
10378
+ return c.json({ threads: labelled, nextCursor });
10379
+ });
10380
+ app.get("/threads", async (c) => {
10381
+ const requestedLimit = Number(c.req.query("limit"));
10382
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
10383
+ const before = c.req.query("before");
10384
+ const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
10385
+ const relevantTo = c.req.query("relevantTo");
10386
+ const supabase = getSupabaseClient();
10387
+ const channelIds = meta ? [...meta.keys()] : [...ownChannelIds(c)];
10388
+ let threads;
10389
+ if (isNonEmptyString(relevantTo)) {
10390
+ try {
10391
+ threads = await relevantThreadsPage(supabase, relevantTo, channelIds, { before, limit });
10392
+ } catch (err) {
10393
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
10394
+ }
10395
+ } else {
10396
+ let query = supabase.from("threads").select("*").in("channel_id", channelIds).is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
10397
+ if (before) query = query.lt("updated_at", before);
10398
+ const { data, error: error2 } = await query;
10399
+ if (error2) return c.json({ error: error2.message }, 500);
10400
+ threads = data;
10401
+ }
10402
+ if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
10403
+ const threadIds = threads.map((t) => t.id);
10404
+ const chunkResults = await Promise.all(
10405
+ chunk(threadIds, THREAD_CHUNK_SIZE).map(
10406
+ (ids) => supabase.from("thread_replies").select("*").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
10407
+ )
10408
+ );
10409
+ const repliesByThread = /* @__PURE__ */ new Map();
10410
+ for (const { data } of chunkResults) {
10411
+ for (const r of data ?? []) {
10412
+ const arr = repliesByThread.get(r.thread_id);
10413
+ if (arr) arr.push(r);
10414
+ else repliesByThread.set(r.thread_id, [r]);
10415
+ }
10416
+ }
10417
+ const withReplies = threads.map((t) => {
10418
+ const channelMeta = meta?.get(t.channel_id);
10419
+ return {
10420
+ ...t,
10421
+ ...meta ? {
10422
+ workspace: channelMeta?.workspace ?? null,
10423
+ channel_name: channelMeta?.channelName ?? null,
10424
+ agent_name: channelMeta?.agentName ?? null
10425
+ } : {},
10426
+ replies: (repliesByThread.get(t.id) ?? []).map((r) => ({
10427
+ ...r,
10428
+ body: slackMrkdwnToMarkdown(r.body)
10429
+ }))
10430
+ };
10431
+ });
10432
+ const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
10433
+ return c.json({ threads: withReplies, nextCursor });
10434
+ });
10435
+ app.get("/threads/archived", async (c) => {
10436
+ if (c.get("auth")?.role !== "admin") {
10437
+ return c.json({ error: "only an admin can view archived topics" }, 403);
10438
+ }
10439
+ const requestedLimit = Number(c.req.query("limit"));
10440
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
10441
+ const before = c.req.query("before");
10442
+ const supabase = getSupabaseClient();
10443
+ let query = supabase.from("threads").select("*").in("channel_id", [...ownChannelIds(c)]).not("archived_at", "is", null).order("archived_at", { ascending: false }).limit(limit);
10444
+ if (before) query = query.lt("archived_at", before);
10445
+ const { data: threads, error: error2 } = await query;
10446
+ if (error2) return c.json({ error: error2.message }, 500);
10447
+ if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
10448
+ const withPreview = await withPreviews(supabase, threads);
10449
+ const nextCursor = threads.length === limit ? threads[threads.length - 1].archived_at : null;
10450
+ return c.json({ threads: withPreview, nextCursor });
10451
+ });
10452
+ app.get("/threads/:id", async (c) => {
10453
+ const id = c.req.param("id");
10454
+ const requestedLimit = Number(c.req.query("limit"));
10455
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, REPLIES_PAGE_SIZE_MAX) : REPLIES_PAGE_SIZE;
10456
+ const before = c.req.query("before");
10457
+ let repliesQuery = getSupabaseClient().from("thread_replies").select("*, reactions:reply_reactions(emoji, author)").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: false }).limit(limit);
10458
+ if (before) repliesQuery = repliesQuery.lt("created_at", before);
10459
+ const [thread, replies, opening] = await Promise.all([
10460
+ getSupabaseClient().from("threads").select("*").eq("id", id).single(),
10461
+ repliesQuery,
10462
+ // The opening message, fetched separately because the replies page
10463
+ // above is newest-first: on a long thread the opener isn't in it. It
10464
+ // is what the header falls back to on a thread with no title of its
10465
+ // own (see threadCrumbLabel) — a title is never written on the
10466
+ // thread's behalf, so an untitled row must still be able to show and
10467
+ // rename a topic.
10468
+ getSupabaseClient().from("thread_replies").select("body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(1)
10469
+ ]);
10470
+ if (thread.error) return c.json({ error: thread.error.message }, 404);
10471
+ if (replies.error) return c.json({ error: replies.error.message }, 500);
10472
+ const channelId = thread.data?.channel_id;
10473
+ const meta = await globalChannelMeta(c);
10474
+ const channelMeta = channelId ? meta.get(channelId) : void 0;
10475
+ if (!channelId || !channelMeta) {
10476
+ return c.json({ error: "not found" }, 404);
10477
+ }
10478
+ const page2 = replies.data ?? [];
10479
+ const nextCursor = page2.length === limit ? page2[page2.length - 1].created_at : null;
10480
+ const formattedReplies = page2.reverse().map((r) => ({
10481
+ ...r,
10482
+ body: slackMrkdwnToMarkdown(r.body)
10483
+ }));
10484
+ const openingBody = (opening.data ?? [])[0]?.body;
10485
+ return c.json({
10486
+ ...thread.data,
10487
+ // Always stamped (not just on `?scope=all` feeds, see the `/threads`
10488
+ // route above) — the reply pane can be opened directly by id (global
10489
+ // board, permalink) without the active workspace's channel map ever
10490
+ // having loaded this thread's channel, so the header needs its own
10491
+ // workspace/channel label rather than depending on that cache.
10492
+ workspace: channelMeta.workspace,
10493
+ channel_name: channelMeta.channelName,
10494
+ agent_name: channelMeta.agentName,
10495
+ preview: openingBody ? slackMrkdwnToMarkdown(openingBody) : null,
10496
+ replies: formattedReplies,
10497
+ nextCursor
10498
+ });
10499
+ });
10500
+ app.post("/threads/:id/translation/:lang", async (c) => {
10501
+ const id = c.req.param("id");
10502
+ const lang = canonicalLanguage(c.req.param("lang"));
10503
+ if (!isTranslatableLanguage2(lang)) return c.json({ error: "unsupported language" }, 400);
10504
+ const [thread, replies] = await Promise.all([
10505
+ getSupabaseClient().from("threads").select("channel_id").eq("id", id).single(),
10506
+ getSupabaseClient().from("thread_replies").select("id, author, body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(TRANSLATABLE_REPLIES_MAX)
10507
+ ]);
10508
+ if (thread.error) return c.json({ error: "not found" }, 404);
10509
+ if (replies.error) return c.json({ error: replies.error.message }, 500);
10510
+ const channelId = thread.data?.channel_id;
10511
+ if (!channelId || !(await globalChannelMeta(c)).has(channelId)) {
10512
+ return c.json({ error: "not found" }, 404);
10513
+ }
10514
+ const messages = (replies.data ?? []).map(
10515
+ (r) => ({ id: r.id, author: r.author, body: slackMrkdwnToMarkdown(r.body) })
10516
+ );
10517
+ try {
10518
+ return c.json({ lang, messages: await translateThread(id, lang, messages) });
10519
+ } catch (err) {
10520
+ const message = err instanceof Error ? err.message : "translation failed";
10521
+ console.error(`Thread translation failed (${id} \u2192 ${lang}):`, err);
10522
+ return c.json({ error: message }, 502);
10523
+ }
10524
+ });
10525
+ app.patch("/threads/:id/board-status", async (c) => {
10526
+ const id = c.req.param("id");
10527
+ const input2 = await c.req.json().catch(() => null);
10528
+ const value = input2?.board_status ?? null;
10529
+ if (value !== null && !BOARD_STATUSES.includes(value)) {
10530
+ return c.json(
10531
+ { error: `board_status must be null or one of: ${BOARD_STATUSES.join(", ")}` },
10532
+ 400
10533
+ );
10534
+ }
10535
+ if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
10536
+ const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ board_status: value }).eq("id", id).select().single();
10537
+ if (error2) return c.json({ error: error2.message }, 500);
10538
+ return c.json(thread);
10539
+ });
10540
+ app.patch("/threads/:id", async (c) => {
10541
+ const id = c.req.param("id");
10542
+ const input2 = await c.req.json().catch(() => null);
10543
+ const raw = input2?.title;
10544
+ if (raw !== null && !isNonEmptyString(raw)) {
10545
+ return c.json({ errors: ["title must be a non-empty string, or null to clear it"] }, 400);
10546
+ }
10547
+ const title = raw === null ? null : raw.trim();
10548
+ if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
10549
+ const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ title }).eq("id", id).select().single();
10550
+ if (error2) return c.json({ error: error2.message }, 500);
10551
+ return c.json(thread);
10552
+ });
10553
+ app.post("/threads/:id/suggest-title", async (c) => {
10554
+ const id = c.req.param("id");
10555
+ if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
10556
+ const { data: thread } = await getSupabaseClient().from("threads").select("channel_id").eq("id", id).single();
10557
+ if (!thread) return c.json({ error: "not found" }, 404);
10558
+ const { data: rows, error: error2 } = await getSupabaseClient().from("thread_replies").select("author, body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true });
10559
+ if (error2) return c.json({ error: error2.message }, 500);
10560
+ const transcript = (rows ?? []).map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
10561
+ const title = await labelThread(thread.channel_id, transcript, "thread");
10562
+ if (!title) return c.json({ error: "could not generate a topic" }, 502);
10563
+ return c.json({ title });
10564
+ });
10565
+ app.patch("/replies/:replyId", async (c) => {
10566
+ const replyId = c.req.param("replyId");
10567
+ const input2 = await c.req.json().catch(() => null);
10568
+ const author = input2?.author;
10569
+ const body = input2?.body;
10570
+ if (!isNonEmptyString(author) || !isNonEmptyString(body)) {
10571
+ return c.json({ errors: ["author and body are required"] }, 400);
10572
+ }
10573
+ const supabase = getSupabaseClient();
10574
+ const { data: existing, error: fetchError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
10575
+ if (fetchError) return c.json({ error: fetchError.message }, 404);
10576
+ if (!await threadIsKnown(existing.thread_id, c)) return c.json({ error: "not found" }, 404);
10577
+ if (existing.author !== author) {
10578
+ return c.json({ error: "only the author can edit a message" }, 403);
10579
+ }
10580
+ const { data: reply, error: error2 } = await supabase.from("thread_replies").update({ body, edited_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", replyId).select().single();
10581
+ if (error2) return c.json({ error: error2.message }, 500);
10582
+ if (isHumanAuthor(author)) {
10583
+ const { data: latest2 } = await supabase.from("thread_replies").select("id").eq("thread_id", existing.thread_id).is("archived_at", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
10584
+ if (latest2?.id === replyId) {
10585
+ await Promise.resolve(regenerateAgentRun?.(existing.thread_id, replyId)).catch(() => {
10586
+ });
10587
+ }
10588
+ }
10589
+ return c.json(reply);
10590
+ });
10591
+ app.post("/replies/:replyId/reactions", async (c) => {
10592
+ const replyId = c.req.param("replyId");
10593
+ const input2 = await c.req.json().catch(() => null);
10594
+ const author = input2?.author;
10595
+ const emoji = input2?.emoji;
10596
+ if (!isNonEmptyString(author) || !isNonEmptyString(emoji)) {
10597
+ return c.json({ errors: ["author and emoji are required"] }, 400);
10598
+ }
10599
+ const supabase = getSupabaseClient();
10600
+ const { data: reply, error: replyError } = await supabase.from("thread_replies").select("thread_id").eq("id", replyId).single();
10601
+ if (replyError) return c.json({ error: "not found" }, 404);
10602
+ if (!await threadIsKnown(reply.thread_id, c)) return c.json({ error: "not found" }, 404);
10603
+ const { data: existing, error: existingError } = await supabase.from("reply_reactions").select("id").eq("reply_id", replyId).eq("emoji", emoji).eq("author", author).maybeSingle();
10604
+ if (existingError) return c.json({ error: existingError.message }, 500);
10605
+ if (existing) {
10606
+ const { error: error2 } = await supabase.from("reply_reactions").delete().eq("id", existing.id);
10607
+ if (error2) return c.json({ error: error2.message }, 500);
10608
+ } else {
10609
+ const { error: error2 } = await supabase.from("reply_reactions").insert({ reply_id: replyId, emoji, author });
10610
+ if (error2 && error2.code !== "23505") return c.json({ error: error2.message }, 500);
10611
+ }
10612
+ const { data: reactions, error: reactionsError } = await supabase.from("reply_reactions").select("emoji, author").eq("reply_id", replyId);
10613
+ if (reactionsError) return c.json({ error: reactionsError.message }, 500);
10614
+ return c.json({ reactions: reactions ?? [] });
10615
+ });
10616
+ app.patch("/threads/:id/archive", async (c) => {
10617
+ if (c.get("auth")?.role !== "admin") {
10618
+ return c.json({ error: "only an admin can archive a topic" }, 403);
10619
+ }
10620
+ const id = c.req.param("id");
10621
+ const input2 = await c.req.json().catch(() => null);
10622
+ const archived = input2?.archived;
10623
+ if (typeof archived !== "boolean") {
10624
+ return c.json({ errors: ["archived (boolean) is required"] }, 400);
10625
+ }
10626
+ if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
10627
+ const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ archived_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", id).select().single();
10628
+ if (error2) return c.json({ error: error2.message }, 500);
10629
+ return c.json(thread);
10630
+ });
10631
+ app.post("/channels/:channelId/threads", async (c) => {
10632
+ const channelId = c.req.param("channelId");
10633
+ if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
10634
+ const input2 = await c.req.json();
10635
+ if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
10636
+ return c.json({ errors: ["author and body are required"] }, 400);
10637
+ }
10638
+ const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10639
+ const model = requestedModel(c, input2.model);
10640
+ const effort = requestedEffort(c, input2.effort);
10641
+ const supabase = getSupabaseClient();
10642
+ let thread;
10643
+ let replayed = false;
10644
+ const { data: inserted, error: threadError } = await supabase.from("threads").insert({
10645
+ channel_id: channelId,
10646
+ // A stored title means someone named the topic. A blank one is not a
10647
+ // name, so it lands as null and the topic is derived from the
10648
+ // opening message at display time (see threadCrumbLabel).
10649
+ title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
10650
+ author: input2.author,
10651
+ client_key: clientKey2
10652
+ }).select().single();
10653
+ if (threadError) {
10654
+ if (!clientKey2 || threadError.code !== "23505") {
10655
+ return c.json({ error: threadError.message }, 500);
10656
+ }
10657
+ const { data: existing } = await supabase.from("threads").select("*").eq("client_key", clientKey2).single();
10658
+ if (!existing) return c.json({ error: threadError.message }, 500);
10659
+ thread = existing;
10660
+ replayed = true;
10661
+ } else {
10662
+ thread = inserted;
10663
+ }
10664
+ let { data: reply } = replayed && clientKey2 ? await supabase.from("thread_replies").select("*").eq("client_key", clientKey2).single() : { data: null };
10665
+ let replyCompleted = false;
10666
+ if (!reply) {
10667
+ const { data: insertedReply, error: replyError } = await supabase.from("thread_replies").insert({
10668
+ thread_id: thread.id,
10669
+ author: input2.author,
10670
+ body: input2.body,
10671
+ client_key: clientKey2,
10672
+ model,
10673
+ effort
10674
+ }).select().single();
10675
+ if (replyError) return c.json({ error: replyError.message }, 500);
10676
+ reply = insertedReply;
10677
+ replyCompleted = true;
10678
+ }
10679
+ if (!replayed || replyCompleted) {
10680
+ onMessage?.({
10681
+ threadId: thread.id,
10682
+ replyId: reply.id,
10683
+ author: input2.author,
10684
+ body: input2.body,
10685
+ // From the row, not `model`/`effort`: on the replay path the reply
10686
+ // predates this request, so its own stored override is the source of
10687
+ // truth.
10688
+ model: reply["model"] ?? void 0,
10689
+ effort: effortFromRow(reply["effort"])
10690
+ });
10691
+ }
10692
+ if (!replayed && thread["title"] == null) {
10693
+ void generateAutoTitle(thread.id, channelId, input2.body).catch(
10694
+ (err) => console.error("Auto title failed:", err)
10695
+ );
10696
+ }
10697
+ return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
10406
10698
  });
10407
- if (error2) fail("createChallenge", error2);
10408
- return nonce;
10409
- }
10410
- async function consumeChallenge(deviceId, nonce) {
10411
- const { data, error: error2 } = await getSupabaseClient().from("device_challenges").delete().eq("nonce", nonce).eq("device_id", deviceId).gt("expires_at", (/* @__PURE__ */ new Date()).toISOString()).select("nonce");
10412
- if (error2) fail("consumeChallenge", error2);
10413
- return (data ?? []).length > 0;
10414
- }
10415
- async function listAdminWorkspaces(userId) {
10416
- const { data, error: error2 } = await getSupabaseClient().from("memberships").select("workspace_id, workspaces!inner(slug)").eq("user_id", userId).eq("role", "admin").eq("status", "active");
10417
- if (error2) fail("listAdminWorkspaces", error2);
10418
- return (data ?? []).map((r) => ({ id: r.workspace_id, slug: r.workspaces.slug })).sort((a, b) => a.slug.localeCompare(b.slug));
10419
- }
10420
- async function listDeviceWorkspaces(device) {
10421
- const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id").eq("device_id", device.id);
10422
- if (error2) fail("listDeviceWorkspaces", error2);
10423
- const bound = new Set((data ?? []).map((r) => r.workspace_id));
10424
- return (await listAdminWorkspaces(device.userId)).filter((w) => bound.has(w.id));
10425
- }
10426
- async function bindDeviceWorkspace(deviceId, workspaceId, boundBy) {
10427
- const { error: error2 } = await getSupabaseClient().from("device_workspaces").upsert(
10428
- { device_id: deviceId, workspace_id: workspaceId, bound_by: boundBy },
10429
- { onConflict: "device_id,workspace_id", ignoreDuplicates: true }
10430
- );
10431
- if (error2) fail("bindDeviceWorkspace", error2);
10432
- }
10433
- async function listWorkspaceDevices(workspaceId) {
10434
- const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("devices!inner(*)").eq("workspace_id", workspaceId).is("devices.revoked_at", null);
10435
- if (error2) fail("listWorkspaceDevices", error2);
10436
- return (data ?? []).map((r) => toDevice(r.devices)).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
10437
- }
10438
- var STALE_HOST_MS = 14 * 24 * 60 * 6e4;
10439
- var isStale = (lastSeenAt) => !!lastSeenAt && Date.now() - Date.parse(lastSeenAt) > STALE_HOST_MS;
10440
- async function listWorkspaceHosts(workspaceIds, exceptPublicKey) {
10441
- const hosts2 = /* @__PURE__ */ new Map();
10442
- if (!workspaceIds.length) return hosts2;
10443
- const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id, devices!inner(name, public_key, revoked_at, last_seen_at)").in("workspace_id", workspaceIds).is("devices.revoked_at", null);
10444
- if (error2) fail("listWorkspaceHosts", error2);
10445
- for (const r of data ?? []) {
10446
- if (r.devices.public_key !== exceptPublicKey && !isStale(r.devices.last_seen_at)) {
10447
- hosts2.set(r.workspace_id, r.devices.name);
10699
+ app.post("/replies/:replyId/fork", async (c) => {
10700
+ const replyId = c.req.param("replyId");
10701
+ const input2 = await c.req.json().catch(() => null);
10702
+ const author = input2?.author;
10703
+ if (!isNonEmptyString(author)) {
10704
+ return c.json({ errors: ["author is required"] }, 400);
10448
10705
  }
10449
- }
10450
- return hosts2;
10451
- }
10452
- async function releaseStaleHosts(workspaceIds, exceptPublicKey) {
10453
- if (!workspaceIds.length) return;
10454
- const db = getSupabaseClient();
10455
- const { data, error: error2 } = await db.from("device_workspaces").select("device_id, workspace_id, devices!inner(public_key, last_seen_at)").in("workspace_id", workspaceIds);
10456
- if (error2) fail("releaseStaleHosts", error2);
10457
- for (const r of data ?? []) {
10458
- if (r.devices.public_key === exceptPublicKey || !isStale(r.devices.last_seen_at)) continue;
10459
- const { error: delError } = await db.from("device_workspaces").delete().eq("device_id", r.device_id).eq("workspace_id", r.workspace_id);
10460
- if (delError) fail("releaseStaleHosts", delError);
10461
- }
10462
- }
10463
- async function userEmail(userId) {
10464
- const { data, error: error2 } = await getSupabaseClient().auth.admin.getUserById(userId);
10465
- if (error2) return null;
10466
- return data.user?.email ?? null;
10467
- }
10706
+ const mode = input2?.mode === "focus" ? "focus" : "condense";
10707
+ const supabase = getSupabaseClient();
10708
+ const { data: source, error: sourceError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
10709
+ if (sourceError) return c.json({ error: sourceError.message }, 404);
10710
+ const { data: sourceThread, error: sourceThreadError } = await supabase.from("threads").select("*").eq("id", source.thread_id).single();
10711
+ if (sourceThreadError) return c.json({ error: sourceThreadError.message }, 404);
10712
+ if (!(await globalChannelMeta(c)).has(sourceThread.channel_id)) {
10713
+ return c.json({ error: "not found" }, 404);
10714
+ }
10715
+ const { data: priorReplies } = await supabase.from("thread_replies").select("author, body, created_at").eq("thread_id", source.thread_id).lte("created_at", source.created_at).is("archived_at", null).order("created_at", { ascending: true });
10716
+ const priorRows = priorReplies ?? [];
10717
+ const sourceText = slackMrkdwnToMarkdown(source.body);
10718
+ const FOCUS_CONTEXT_BELOW_CHARS = 280;
10719
+ const inputRows = mode === "focus" ? priorRows.slice(sourceText.length < FOCUS_CONTEXT_BELOW_CHARS ? -2 : -1) : priorRows;
10720
+ const transcript = inputRows.map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
10721
+ const { title, body } = await buildForkTopic(transcript || sourceText, sourceText, mode);
10722
+ const backlink = `/chat/${encodeURIComponent(sourceThread.channel_id)}/${sourceThread.id}#m-${replyId.slice(0, 8)}`;
10723
+ const openingBody = `${body}
10468
10724
 
10469
- // ../core/src/bridge-protocol.ts
10470
- var HOST_SOCKET_PATH = "/internal/host-socket";
10471
- var DEFAULT_DATA_API_PORT = 4757;
10472
- var CALL_TIMEOUT_MS = 15e3;
10473
- var HEARTBEAT_INTERVAL_MS = 15e3;
10474
- var HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2.5;
10475
- var LOCAL_RELAY_PATH = "/internal/agent-call";
10476
- var RELAYED_METHODS = [
10477
- "onChatMessage",
10478
- "isAgentThinking",
10479
- "getAgentActivities",
10480
- "cancelAgentRun",
10481
- "regenerateAgentRun",
10482
- "listAgentRuns"
10483
- ];
10484
- function encodeWireMessage(msg) {
10485
- return JSON.stringify(msg);
10486
- }
10487
- function parseWireMessage(raw) {
10488
- try {
10489
- return JSON.parse(String(raw));
10490
- } catch {
10491
- return null;
10492
- }
10725
+ > \u2442 Forked from [a message by ${source.author}](${backlink})`;
10726
+ const { data: thread, error: threadError } = await supabase.from("threads").insert({ channel_id: sourceThread.channel_id, title, author }).select().single();
10727
+ if (threadError) return c.json({ error: threadError.message }, 500);
10728
+ const { data: reply, error: replyError } = await supabase.from("thread_replies").insert({ thread_id: thread.id, author, body: openingBody }).select().single();
10729
+ if (replyError) return c.json({ error: replyError.message }, 500);
10730
+ onMessage?.({
10731
+ threadId: thread.id,
10732
+ replyId: reply.id,
10733
+ author,
10734
+ body: openingBody
10735
+ });
10736
+ return c.json({ ...thread, replies: [reply] }, 201);
10737
+ });
10738
+ app.post("/threads/:id/replies", async (c) => {
10739
+ const threadId = c.req.param("id");
10740
+ const input2 = await c.req.json();
10741
+ if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
10742
+ return c.json({ errors: ["author and body are required"] }, 400);
10743
+ }
10744
+ const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10745
+ const effort = requestedEffort(c, input2.effort);
10746
+ if (!await threadIsKnown(threadId, c)) return c.json({ error: "not found" }, 404);
10747
+ const model = requestedModel(c, input2.model) ?? await threadModel(threadId);
10748
+ const { data, error: error2 } = await getSupabaseClient().from("thread_replies").insert({
10749
+ thread_id: threadId,
10750
+ author: input2.author,
10751
+ body: input2.body,
10752
+ client_key: clientKey2,
10753
+ model,
10754
+ effort
10755
+ }).select().single();
10756
+ if (error2) {
10757
+ if (clientKey2 && error2.code === "23505") {
10758
+ const { data: existing } = await getSupabaseClient().from("thread_replies").select("*").eq("client_key", clientKey2).single();
10759
+ if (existing) return c.json(existing, 200);
10760
+ }
10761
+ return c.json({ error: error2.message }, 500);
10762
+ }
10763
+ onMessage?.({
10764
+ threadId,
10765
+ replyId: data.id,
10766
+ author: input2.author,
10767
+ body: input2.body,
10768
+ model: data.model ?? void 0,
10769
+ effort: effortFromRow(data.effort)
10770
+ });
10771
+ return c.json(data, 201);
10772
+ });
10773
+ return app;
10493
10774
  }
10494
10775
 
10495
- // ../api/src/dashboard/agent-bridge-data.ts
10496
- var hosts = /* @__PURE__ */ new Map();
10497
- var hostStatusListeners = /* @__PURE__ */ new Set();
10498
- function notifyHostStatus() {
10499
- const online = hosts.size > 0;
10500
- for (const l of hostStatusListeners) l(online);
10501
- }
10502
- function removeHost(ws) {
10503
- if (hosts.delete(ws)) notifyHostStatus();
10776
+ // ../api/src/dashboard/doc-shares-routes.ts
10777
+ import { Hono as Hono14 } from "hono";
10778
+
10779
+ // ../api/src/dashboard/doc-share-features.ts
10780
+ import { DeleteObjectCommand as DeleteObjectCommand2 } from "@aws-sdk/client-s3";
10781
+ var DEFAULT_FEATURES = {
10782
+ audioEnabled: false,
10783
+ askEnabled: true,
10784
+ socialEnabled: false,
10785
+ brandingEnabled: true,
10786
+ translationEnabled: false,
10787
+ translationLangs: []
10788
+ };
10789
+ var HTML_FEATURES = {
10790
+ audioEnabled: false,
10791
+ askEnabled: false,
10792
+ socialEnabled: false,
10793
+ brandingEnabled: false,
10794
+ translationEnabled: false,
10795
+ translationLangs: []
10796
+ };
10797
+ function resolveFeatures(requested, existing) {
10798
+ const base = existing ?? DEFAULT_FEATURES;
10799
+ return {
10800
+ audioEnabled: requested?.audio ?? base.audioEnabled,
10801
+ askEnabled: requested?.ask ?? base.askEnabled,
10802
+ socialEnabled: requested?.social ?? base.socialEnabled,
10803
+ brandingEnabled: requested?.branding ?? base.brandingEnabled,
10804
+ translationEnabled: requested?.translation ?? base.translationEnabled,
10805
+ // Replaced wholesale, not merged: the allowlist IS the answer to "which
10806
+ // languages does this link offer", and a merge would make removing one
10807
+ // impossible.
10808
+ translationLangs: requested?.translationLangs ?? base.translationLangs
10809
+ };
10504
10810
  }
10505
- function disconnectHostDevice(deviceId) {
10506
- for (const [ws, scope] of hosts) {
10507
- if (scope.deviceId !== deviceId) continue;
10508
- console.log(`[host-socket] closing connection for revoked device ${deviceId}`);
10509
- removeHost(ws);
10510
- ws.close(1008, "device revoked");
10811
+ async function deleteShareAssets(keys) {
10812
+ const client2 = getR2Client();
10813
+ const bucket = getR2Bucket();
10814
+ for (const key of keys) {
10815
+ try {
10816
+ await client2.send(new DeleteObjectCommand2({ Bucket: bucket, Key: key }));
10817
+ } catch {
10818
+ }
10511
10819
  }
10512
10820
  }
10513
- var LAST_SEEN_RESOLUTION_MS = 60 * 6e4;
10514
- var lastCallError = /* @__PURE__ */ new Map();
10515
- function getHostCallError(method, profile = getProfile()) {
10516
- return lastCallError.get(`${profile}:${method}`) ?? null;
10821
+ async function updateShareFeatures(share, requested) {
10822
+ if (share.kind === "html") return share;
10823
+ if (share.kind === "excalidraw") {
10824
+ if (requested.branding === void 0) return share;
10825
+ return updateDocShareFeatures(share.id, { ...share, brandingEnabled: requested.branding });
10826
+ }
10827
+ const features = resolveFeatures(requested, share);
10828
+ const updated = await updateDocShareFeatures(share.id, features);
10829
+ if (!updated) return null;
10830
+ if (features.audioEnabled) void queueShareAudio(updated);
10831
+ else if (share.audioEnabled) await dropShareAudio(share);
10832
+ return updated;
10517
10833
  }
10518
10834
 
10519
10835
  // ../api/src/dashboard/doc-share-publish.ts
@@ -10524,7 +10840,7 @@ import { CopyObjectCommand, PutObjectCommand as PutObjectCommand2 } from "@aws-s
10524
10840
  var SHARE_ASSET_PLACEHOLDER = "/share/{{flyspace-share-id}}/assets/";
10525
10841
 
10526
10842
  // ../api/src/dashboard/share-language.ts
10527
- var GROQ_API_URL5 = "https://api.groq.com/openai/v1/chat/completions";
10843
+ var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
10528
10844
  var CLASSIFIER_MODEL = "openai/gpt-oss-20b";
10529
10845
  var SAMPLE_CHARS = 4e3;
10530
10846
  var NOISE = [
@@ -10563,7 +10879,7 @@ async function detectSourceLanguage(title, content) {
10563
10879
  const sample = languageSample(title, content);
10564
10880
  if (sample.length < MIN_SAMPLE_CHARS) return null;
10565
10881
  try {
10566
- const res = await fetch(GROQ_API_URL5, {
10882
+ const res = await fetch(GROQ_API_URL6, {
10567
10883
  method: "POST",
10568
10884
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
10569
10885
  body: JSON.stringify({
@@ -10793,7 +11109,7 @@ function resolveChannel2(c) {
10793
11109
  }
10794
11110
  var LIST_LIMIT = 500;
10795
11111
  function createDocShareListRouter() {
10796
- const app = new Hono13();
11112
+ const app = new Hono14();
10797
11113
  app.get("/shares", async (c) => {
10798
11114
  const viewer = c.get("auth");
10799
11115
  const readable = await filterReadableDocsChannels(
@@ -10837,7 +11153,7 @@ function createDocShareListRouter() {
10837
11153
  return app;
10838
11154
  }
10839
11155
  function createDocShareWriteRouter(agent = {}) {
10840
- const app = new Hono13();
11156
+ const app = new Hono14();
10841
11157
  app.use("/:id/docs/share", requireDocsAccess);
10842
11158
  app.use("/:id/docs/share/*", requireDocsAccess);
10843
11159
  app.use("/:id/docs/shares", requireDocsAccess);
@@ -10931,7 +11247,7 @@ function createDocShareWriteRouter(agent = {}) {
10931
11247
  case "host-outdated":
10932
11248
  return c.json(
10933
11249
  {
10934
- error: "The Host machine runs an older Flyspace version that cannot publish docs. Update it with `npm i -g flyspace-cli` and try again."
11250
+ error: "The Host machine runs an older Flyspace version that cannot publish docs. Update it by running `flyspace upgrade` on that machine, then try again."
10935
11251
  },
10936
11252
  503
10937
11253
  );
@@ -11010,7 +11326,7 @@ function createDocShareWriteRouter(agent = {}) {
11010
11326
  }
11011
11327
 
11012
11328
  // ../api/src/dashboard/search-routes.ts
11013
- import { Hono as Hono14 } from "hono";
11329
+ import { Hono as Hono15 } from "hono";
11014
11330
  function matchScore(q, fields) {
11015
11331
  const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
11016
11332
  if (terms.length === 0) return 0;
@@ -11158,7 +11474,7 @@ function searchAutomations(q) {
11158
11474
  }
11159
11475
  var RESULT_LIMIT = 30;
11160
11476
  function createSearchRouter() {
11161
- const app = new Hono14();
11477
+ const app = new Hono15();
11162
11478
  app.get("/", async (c) => {
11163
11479
  const q = c.req.query("q")?.trim();
11164
11480
  if (!q) return c.json({ results: [] });
@@ -11174,14 +11490,14 @@ function createSearchRouter() {
11174
11490
  }
11175
11491
 
11176
11492
  // ../api/src/dashboard/feature-flags-routes.ts
11177
- import { Hono as Hono15 } from "hono";
11493
+ import { Hono as Hono16 } from "hono";
11178
11494
  async function isFeatureEnabled(c, flagId) {
11179
11495
  const auth = c.get("auth");
11180
11496
  const enabled = await listEnabledFeatureFlags(auth.workspaceId);
11181
11497
  return resolveFeatureFlags(enabled, isDevHost(c))[flagId];
11182
11498
  }
11183
11499
  function createFeatureFlagsRouter() {
11184
- const app = new Hono15();
11500
+ const app = new Hono16();
11185
11501
  app.get("/", async (c) => {
11186
11502
  const auth = c.get("auth");
11187
11503
  const devHost = isDevHost(c);
@@ -11208,7 +11524,7 @@ function createFeatureFlagsRouter() {
11208
11524
 
11209
11525
  // ../api/src/dashboard/uploads-routes.ts
11210
11526
  import { randomUUID as randomUUID3 } from "crypto";
11211
- import { Hono as Hono16 } from "hono";
11527
+ import { Hono as Hono17 } from "hono";
11212
11528
  import { PutObjectCommand as PutObjectCommand3, GetObjectCommand as GetObjectCommand3 } from "@aws-sdk/client-s3";
11213
11529
  import { getSignedUrl as getSignedUrl3 } from "@aws-sdk/s3-request-presigner";
11214
11530
  var MAX_FILE_BYTES = 10 * 1024 * 1024;
@@ -11223,7 +11539,7 @@ function encodeKeyForUrl(key) {
11223
11539
  return key.split("/").map(encodeURIComponent).join("/");
11224
11540
  }
11225
11541
  function createUploadsRouter() {
11226
- const app = new Hono16();
11542
+ const app = new Hono17();
11227
11543
  app.post("/uploads", async (c) => {
11228
11544
  const body = await c.req.parseBody().catch(() => null);
11229
11545
  const file = body?.["file"];
@@ -11268,14 +11584,14 @@ function createUploadsRouter() {
11268
11584
  }
11269
11585
 
11270
11586
  // ../api/src/dashboard/transcribe-routes.ts
11271
- import { Hono as Hono17 } from "hono";
11587
+ import { Hono as Hono18 } from "hono";
11272
11588
  var GROQ_TRANSCRIBE_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
11273
11589
  var MODEL = "whisper-large-v3-turbo";
11274
11590
  var MAX_AUDIO_BYTES = 5 * 1024 * 1024;
11275
11591
  var MAX_PROMPT_CHARS = 400;
11276
11592
  var NO_SPEECH_THRESHOLD = 0.6;
11277
11593
  function createTranscribeRouter() {
11278
- const app = new Hono17();
11594
+ const app = new Hono18();
11279
11595
  app.post("/transcribe", async (c) => {
11280
11596
  if (!await isFeatureEnabled(c, "dictation"))
11281
11597
  return c.json({ error: "feature not enabled" }, 403);
@@ -11315,7 +11631,7 @@ function createTranscribeRouter() {
11315
11631
 
11316
11632
  // ../api/src/dashboard/avatars-routes.ts
11317
11633
  import { createHash as createHash9, randomUUID as randomUUID4 } from "crypto";
11318
- import { Hono as Hono18 } from "hono";
11634
+ import { Hono as Hono19 } from "hono";
11319
11635
  import sharp4 from "sharp";
11320
11636
  import { PutObjectCommand as PutObjectCommand4, DeleteObjectCommand as DeleteObjectCommand3 } from "@aws-sdk/client-s3";
11321
11637
  var AVATAR_SIZE = 192;
@@ -11368,7 +11684,7 @@ function avatarKeyPrefix() {
11368
11684
  return `${KEY_PREFIX2}/${getProfile()}/`;
11369
11685
  }
11370
11686
  function createAvatarsRouter() {
11371
- const app = new Hono18();
11687
+ const app = new Hono19();
11372
11688
  app.get("/avatars/slack", async (c) => {
11373
11689
  const ctx = await resolveAuthContext(c);
11374
11690
  if (!ctx) return c.json({ error: "not signed in" }, 401);
@@ -11417,7 +11733,7 @@ function createAvatarsRouter() {
11417
11733
  }
11418
11734
 
11419
11735
  // ../api/src/dashboard/members-routes.ts
11420
- import { Hono as Hono19 } from "hono";
11736
+ import { Hono as Hono20 } from "hono";
11421
11737
  function inviteLinkFor(c, token) {
11422
11738
  const url = new URL("/auth/invite", callbackUrl(c));
11423
11739
  url.searchParams.set("token", token);
@@ -11425,11 +11741,11 @@ function inviteLinkFor(c, token) {
11425
11741
  }
11426
11742
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11427
11743
  function createMembersRouter() {
11428
- const members = new Hono19();
11744
+ const members = new Hono20();
11429
11745
  members.get("/", async (c) => {
11430
11746
  const ctx = await resolveAuthContext(c);
11431
11747
  if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
11432
- const list = await listMemberships(ctx.workspaceId);
11748
+ const list = (await listMemberships(ctx.workspaceId)).filter((m) => !m.isSupport);
11433
11749
  const members2 = await Promise.all(
11434
11750
  list.map(async (m) => {
11435
11751
  const { data } = await getSupabaseClient().auth.admin.getUserById(m.userId);
@@ -11457,7 +11773,9 @@ function createMembersRouter() {
11457
11773
  members.get("/directory", async (c) => {
11458
11774
  const ctx = await resolveAuthContext(c);
11459
11775
  if (!ctx) return c.json({ error: "unauthorized" }, 401);
11460
- const list = (await listMemberships(ctx.workspaceId)).filter((m) => m.status === "active");
11776
+ const list = (await listMemberships(ctx.workspaceId)).filter(
11777
+ (m) => m.status === "active" && !m.isSupport
11778
+ );
11461
11779
  const directory = await Promise.all(
11462
11780
  list.map(async (m) => {
11463
11781
  const { data } = await getSupabaseClient().auth.admin.getUserById(m.userId);
@@ -11650,7 +11968,7 @@ function createMembersRouter() {
11650
11968
  }
11651
11969
 
11652
11970
  // ../api/src/dashboard/notifications-routes.ts
11653
- import { Hono as Hono20 } from "hono";
11971
+ import { Hono as Hono21 } from "hono";
11654
11972
 
11655
11973
  // ../api/src/store/notification-store.ts
11656
11974
  function rowToNotification(row) {
@@ -11718,7 +12036,7 @@ async function markAllRead(channelIds, userId) {
11718
12036
 
11719
12037
  // ../api/src/dashboard/notifications-routes.ts
11720
12038
  function createNotificationsRouter() {
11721
- const notifications = new Hono20();
12039
+ const notifications = new Hono21();
11722
12040
  notifications.get("/", async (c) => {
11723
12041
  const auth = c.get("auth");
11724
12042
  const channelIds = getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, auth)).map((ch) => ch.id);
@@ -11745,7 +12063,7 @@ function createNotificationsRouter() {
11745
12063
  return notifications;
11746
12064
  }
11747
12065
  function createThreadReadRouter() {
11748
- const threads = new Hono20();
12066
+ const threads = new Hono21();
11749
12067
  threads.post("/threads/:threadId/read", async (c) => {
11750
12068
  const { userId } = c.get("auth");
11751
12069
  const threadId = c.req.param("threadId");
@@ -11757,7 +12075,7 @@ function createThreadReadRouter() {
11757
12075
  }
11758
12076
 
11759
12077
  // ../api/src/dashboard/push-routes.ts
11760
- import { Hono as Hono21 } from "hono";
12078
+ import { Hono as Hono22 } from "hono";
11761
12079
 
11762
12080
  // ../api/src/push/send.ts
11763
12081
  import webpush from "web-push";
@@ -11860,7 +12178,7 @@ async function pushToUser(userId, payload) {
11860
12178
 
11861
12179
  // ../api/src/dashboard/push-routes.ts
11862
12180
  function createPushRouter() {
11863
- const push = new Hono21();
12181
+ const push = new Hono22();
11864
12182
  push.get("/key", (c) => c.json({ publicKey: getVapidPublicKey() }));
11865
12183
  push.post("/subscribe", async (c) => {
11866
12184
  const { userId } = c.get("auth");
@@ -11891,7 +12209,7 @@ function createPushRouter() {
11891
12209
  }
11892
12210
 
11893
12211
  // ../api/src/dashboard/mcp-oauth-routes.ts
11894
- import { Hono as Hono22 } from "hono";
12212
+ import { Hono as Hono23 } from "hono";
11895
12213
 
11896
12214
  // ../api/src/mcp/crypto.ts
11897
12215
  import { createCipheriv, createDecipheriv, randomBytes as randomBytes7, timingSafeEqual } from "crypto";
@@ -12406,7 +12724,7 @@ function backToDashboard(c, to, params) {
12406
12724
  return c.redirect(`${target.pathname}${target.search}`);
12407
12725
  }
12408
12726
  function createMcpOAuthRouter() {
12409
- const app = new Hono22();
12727
+ const app = new Hono23();
12410
12728
  app.get("/oauth/:provider/start", async (c) => {
12411
12729
  const auth = c.get("auth");
12412
12730
  if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
@@ -12533,7 +12851,7 @@ function createMcpOAuthRouter() {
12533
12851
  }
12534
12852
 
12535
12853
  // ../api/src/dashboard/mcp-routes.ts
12536
- import { Hono as Hono23 } from "hono";
12854
+ import { Hono as Hono24 } from "hono";
12537
12855
 
12538
12856
  // ../api/src/mcp/transport.ts
12539
12857
  var KINDS = ["inherit", "npx", "local", "http", "sse"];
@@ -12663,7 +12981,7 @@ function bindingsByConnection() {
12663
12981
  return map;
12664
12982
  }
12665
12983
  function createMcpRouter() {
12666
- const app = new Hono23();
12984
+ const app = new Hono24();
12667
12985
  app.get("/providers", async (c) => {
12668
12986
  const denied = requireAdmin(c.get("auth"));
12669
12987
  if (denied) return c.json({ error: denied }, 403);
@@ -12898,9 +13216,9 @@ async function threadUsageForWorkspace(workspace, threadTs) {
12898
13216
  return { totals: totalsOf(rows), byModel: groupBy(rows, (r) => r.model ?? "unknown") };
12899
13217
  }
12900
13218
  async function sessionUsageForThreads(workspace, threadTsList) {
12901
- const latest = /* @__PURE__ */ new Map();
13219
+ const latest2 = /* @__PURE__ */ new Map();
12902
13220
  const totals = /* @__PURE__ */ new Map();
12903
- if (threadTsList.length === 0) return { latest, totals };
13221
+ if (threadTsList.length === 0) return { latest: latest2, totals };
12904
13222
  const rows = await fetchUsageRows(workspace, { threadTsIn: threadTsList });
12905
13223
  const latestAt = /* @__PURE__ */ new Map();
12906
13224
  for (const row of rows) {
@@ -12909,7 +13227,7 @@ async function sessionUsageForThreads(workspace, threadTsList) {
12909
13227
  const seenAt = latestAt.get(row.thread_ts);
12910
13228
  if (seenAt && seenAt >= row.ran_at) continue;
12911
13229
  latestAt.set(row.thread_ts, row.ran_at);
12912
- latest.set(row.thread_ts, {
13230
+ latest2.set(row.thread_ts, {
12913
13231
  model: row.model ?? void 0,
12914
13232
  numTurns: row.num_turns ?? void 0,
12915
13233
  inputTokens: row.input_tokens,
@@ -12918,7 +13236,7 @@ async function sessionUsageForThreads(workspace, threadTsList) {
12918
13236
  label: row.label ?? void 0
12919
13237
  });
12920
13238
  }
12921
- return { latest, totals };
13239
+ return { latest: latest2, totals };
12922
13240
  }
12923
13241
 
12924
13242
  // ../api/src/dashboard/usage-report.ts
@@ -13045,7 +13363,7 @@ async function buildThreadUsage(threadTs) {
13045
13363
  }
13046
13364
 
13047
13365
  // ../api/src/dashboard/channel-members-routes.ts
13048
- import { Hono as Hono24 } from "hono";
13366
+ import { Hono as Hono25 } from "hono";
13049
13367
  async function isChannelAdmin(ctx, channelId) {
13050
13368
  if (ctx.role === "admin") return true;
13051
13369
  const membership = await getMembershipByUserId(ctx.workspaceId, ctx.userId);
@@ -13054,7 +13372,7 @@ async function isChannelAdmin(ctx, channelId) {
13054
13372
  return members.some((m) => m.membershipId === membership.id && m.role === "channel_admin");
13055
13373
  }
13056
13374
  function createChannelMembersRouter() {
13057
- const router = new Hono24();
13375
+ const router = new Hono25();
13058
13376
  router.get("/:id/members", async (c) => {
13059
13377
  const ctx = await resolveAuthContext(c);
13060
13378
  if (!ctx) return c.json({ error: "workspace access required" }, 403);
@@ -13124,7 +13442,7 @@ function createChannelMembersRouter() {
13124
13442
  }
13125
13443
 
13126
13444
  // ../api/src/dashboard/memories-routes.ts
13127
- import { Hono as Hono25 } from "hono";
13445
+ import { Hono as Hono26 } from "hono";
13128
13446
  async function readPatch(c) {
13129
13447
  const body = await c.req.json().catch(() => ({}));
13130
13448
  const patch = {};
@@ -13152,7 +13470,7 @@ var requireAgentMemory = async (c, next) => {
13152
13470
  await next();
13153
13471
  };
13154
13472
  function createMemoriesRouter() {
13155
- const router = new Hono25();
13473
+ const router = new Hono26();
13156
13474
  for (const path of [
13157
13475
  "/:id/memories",
13158
13476
  "/:id/memories/*",
@@ -13225,7 +13543,7 @@ function createMemoriesRouter() {
13225
13543
  return router;
13226
13544
  }
13227
13545
  function createScopedMemoriesRouter() {
13228
- const router = new Hono25();
13546
+ const router = new Hono26();
13229
13547
  router.use("*", requireAgentMemory);
13230
13548
  const workspace = (c) => ({
13231
13549
  scope: "workspace",
@@ -13284,218 +13602,6 @@ function createScopedMemoriesRouter() {
13284
13602
  return router;
13285
13603
  }
13286
13604
 
13287
- // ../api/src/dashboard/onboarding-routes.ts
13288
- import { Hono as Hono26 } from "hono";
13289
-
13290
- // ../api/src/dashboard/unique-channel-name.ts
13291
- function uniqueChannelName(raw, taken) {
13292
- const base = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
13293
- let name = base;
13294
- for (let i = 2; taken.has(name); i += 1) name = `${base}-${i}`;
13295
- taken.add(name);
13296
- return name;
13297
- }
13298
-
13299
- // ../api/src/dashboard/workspace-greeting.ts
13300
- var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
13301
- var GREETING_MODEL = "openai/gpt-oss-20b";
13302
- var TIMEOUT_MS = 6e3;
13303
- var MAX_LISTED_CHANNELS = 8;
13304
- var GUIDES = [
13305
- "Welcome to Flyspace",
13306
- "Working with the agent",
13307
- "Channels and folders",
13308
- "Docs",
13309
- "Sharing docs",
13310
- "Inviting your team",
13311
- "Automations"
13312
- ];
13313
- var SYSTEM_PROMPT = `You write the first message a new Flyspace workspace owner sees, posted in their #general channel. Flyspace is a team chat where an AI agent works inside every channel.
13314
-
13315
- - Plain Markdown, 60 to 110 words, in English. No headings, no emoji, no em or en dashes, no sign-off.
13316
- - Greet the owner by name and name the workspace.
13317
- - Say that #general is public: everyone invited to the workspace can see it.
13318
- - If project channels are listed, mention them as #name and say each one is linked to that project folder. Never invent a channel.
13319
- - Mention the trial or the missing Host only if the facts say so.
13320
- - If guides are listed, say they are in the Docs pane of #general and suggest starting with the first one.
13321
- - Use only the facts given. Do not add buttons or a list of next steps: those are appended for you.`;
13322
- function greetingButtons(input2) {
13323
- const buttons = [];
13324
- if (!input2.hostConnected) buttons.push("Connect a machine");
13325
- if (input2.channels[0]) buttons.push(`Summarise #${input2.channels[0]} for me`);
13326
- if (input2.hostConnected) buttons.push("Give me a quick tour of the guides");
13327
- if (input2.memberCount <= 1) buttons.push("Invite my team");
13328
- buttons.push("Set up a daily digest");
13329
- return buttons.slice(0, 3).map((b) => b.replace(/[|\]]/g, ""));
13330
- }
13331
- function fallbackGreeting(input2) {
13332
- const channels = input2.channels.slice(0, MAX_LISTED_CHANNELS).map((c) => `#${c}`);
13333
- return [
13334
- `Welcome to ${input2.workspaceName}, ${input2.ownerName}.`,
13335
- "This is #general. It is public, so everyone you invite to the workspace can see it.",
13336
- channels.length ? `Your project channels are ready: ${channels.join(", ")}. Each one is linked to its project folder, so the agent there works on the real files.` : "",
13337
- input2.hostConnected ? "A few short guides are waiting in the Docs pane of #general. Start with Welcome to Flyspace." : "",
13338
- "Mention the agent in any channel to get started."
13339
- ].filter(Boolean).join("\n\n");
13340
- }
13341
- async function groqGreeting(input2, apiKey) {
13342
- const facts = [
13343
- `Owner: ${input2.ownerName}`,
13344
- `Workspace: ${input2.workspaceName}`,
13345
- `Plan: ${input2.trialing ? "14-day free trial, just started" : "not on a trial"}`,
13346
- `Host: ${input2.hostConnected ? "a machine is connected, agents can run" : "no machine connected yet, agents cannot run until one is"}`,
13347
- `Project channels: ${input2.channels.slice(0, MAX_LISTED_CHANNELS).join(", ") || "(none yet)"}`,
13348
- `Members: ${input2.memberCount}`,
13349
- `Guides in #general: ${input2.hostConnected ? GUIDES.join(", ") : "(none, no machine connected)"}`
13350
- ].join("\n");
13351
- const res = await fetch(GROQ_API_URL6, {
13352
- method: "POST",
13353
- signal: AbortSignal.timeout(TIMEOUT_MS),
13354
- headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
13355
- body: JSON.stringify({
13356
- model: GREETING_MODEL,
13357
- temperature: 0.6,
13358
- // Reasoning model: the cap covers reasoning tokens too, so keep it roomy.
13359
- reasoning_effort: "low",
13360
- max_tokens: 900,
13361
- messages: [
13362
- { role: "system", content: SYSTEM_PROMPT },
13363
- { role: "user", content: facts }
13364
- ]
13365
- })
13366
- });
13367
- if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
13368
- const data = await res.json();
13369
- const text = data.choices?.[0]?.message?.content?.replace(/\[BUTTONS:[^\]]*\]/g, "").replace(/\s*[–—]\s*/g, ", ").trim();
13370
- return text || null;
13371
- }
13372
- async function buildGreeting(input2) {
13373
- let text = null;
13374
- const apiKey = process.env["GROQ_API_KEY"];
13375
- if (apiKey) {
13376
- try {
13377
- text = await groqGreeting(input2, apiKey);
13378
- } catch (err) {
13379
- console.error("Workspace greeting failed, using the fixed template:", err);
13380
- }
13381
- }
13382
- return `${text ?? fallbackGreeting(input2)}
13383
-
13384
- [BUTTONS: ${greetingButtons(input2).join(" | ")}]`;
13385
- }
13386
-
13387
- // ../api/src/dashboard/onboarding-routes.ts
13388
- var MAX_FOLDERS = 20;
13389
- function hostUnavailable(c, agent, method) {
13390
- return c.json({ error: "host offline", reason: agent.getHostCallFailure?.(method) ?? "offline" }, 503);
13391
- }
13392
- function createOnboardingRouter(agent) {
13393
- const app = new Hono26();
13394
- app.get("/scan", async (c) => {
13395
- const ctx = await resolveAuthContext(c);
13396
- if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13397
- const scan = await agent.scanOnboarding?.();
13398
- if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
13399
- return c.json(scan);
13400
- });
13401
- app.post("/channels", async (c) => {
13402
- const ctx = await resolveAuthContext(c);
13403
- if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13404
- const body = await c.req.json().catch(() => ({}));
13405
- const runner = body["agent"];
13406
- const folders = Array.isArray(body["folders"]) ? body["folders"].filter((f) => typeof f === "string" && f.length > 0) : [];
13407
- if (folders.length > MAX_FOLDERS) return c.json({ error: `at most ${MAX_FOLDERS} folders` }, 400);
13408
- const scan = await agent.scanOnboarding?.();
13409
- if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
13410
- const offered = new Map(scan.projects.map((p) => [p.path, p]));
13411
- const unknown = folders.find((f) => !offered.has(f));
13412
- if (unknown) return c.json({ error: "folder was not offered by the scan" }, 400);
13413
- const taken = new Set(getProfileChannelRegistry().map((ch) => ch.name));
13414
- taken.add("general");
13415
- const items = [{ name: "general" }];
13416
- for (const path of folders) items.push({ name: uniqueChannelName(offered.get(path).name, taken), path });
13417
- const linked = await agent.linkProjectFolders?.(getProfile(), items);
13418
- if (!linked) return hostUnavailable(c, agent, "linkProjectFolders");
13419
- if (runner === "claude" || runner === "codex") {
13420
- await agent.saveLocalDashboardSettings?.({ providers: [{ runner }] });
13421
- }
13422
- const created = [];
13423
- for (const l of linked) {
13424
- created.push(
13425
- l.name === "general" ? await ensureGeneralChannel() : await createChannel({ name: l.name, cwd: l.cwd })
13426
- );
13427
- }
13428
- await markWorkspaceProjectsSetup(ctx.workspaceId);
13429
- return c.json({ channels: created.map((ch) => ({ id: ch.id, name: ch.name })) }, 201);
13430
- });
13431
- app.post("/reset", async (c) => {
13432
- const ctx = await resolveAuthContext(c);
13433
- if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13434
- const { error: error2 } = await getSupabaseClient().from("threads").delete().eq("client_key", ctx.workspaceId);
13435
- if (error2) return c.json({ error: error2.message }, 500);
13436
- await resetWorkspaceOnboarding(ctx.workspaceId);
13437
- return c.json({ ok: true });
13438
- });
13439
- app.get("/greeting", async (c) => {
13440
- const ctx = await resolveAuthContext(c);
13441
- if (ctx?.role !== "admin") return c.json({ pending: false });
13442
- const greetedAt = await getWorkspaceGreetedAt(ctx.workspaceId);
13443
- const pending2 = !!ctx.projectsSetupAt && !greetedAt;
13444
- if (!pending2) return c.json({ pending: pending2, channelId: null, threadId: null });
13445
- const general = await ensureGeneralChannel();
13446
- const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ctx.name?.trim() || ctx.handle);
13447
- return c.json({ pending: pending2, channelId: general.id, threadId });
13448
- });
13449
- app.post("/greeting", async (c) => {
13450
- const ctx = await resolveAuthContext(c);
13451
- if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13452
- const supabase = getSupabaseClient();
13453
- const general = await ensureGeneralChannel();
13454
- const ownerName = ctx.name?.trim() || ctx.handle;
13455
- const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ownerName);
13456
- if (!threadId) return c.json({ error: "could not create the welcome thread" }, 500);
13457
- const done = { channelId: general.id, threadId };
13458
- const replied = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
13459
- if (replied.data) return c.json(done);
13460
- if (await getWorkspaceGreetedAt(ctx.workspaceId))
13461
- return c.json({ error: "already greeted" }, 409);
13462
- const [branding, billing, members] = await Promise.all([
13463
- getWorkspaceBranding(getProfile()).catch(() => null),
13464
- getWorkspaceBilling(ctx.workspaceId).catch(() => null),
13465
- listMemberships(ctx.workspaceId).catch(() => [])
13466
- ]);
13467
- const body = await buildGreeting({
13468
- ownerName,
13469
- workspaceName: branding?.displayName || getProfile(),
13470
- trialing: billing?.planStatus === "trialing",
13471
- hostConnected: !!ctx.agentConnectedAt,
13472
- channels: getProfileChannelRegistry().filter((ch) => !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt).map((ch) => ch.name),
13473
- memberCount: members.length
13474
- });
13475
- const again = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
13476
- if (again.data) return c.json(done);
13477
- const reply = await supabase.from("thread_replies").insert({ thread_id: threadId, author: AGENT_AUTHOR, body });
13478
- if (reply.error) return c.json({ error: reply.error.message }, 500);
13479
- await markWorkspaceGreeted(ctx.workspaceId);
13480
- return c.json(done, 201);
13481
- });
13482
- return app;
13483
- }
13484
- async function ensureGreetingThread(workspaceId, channelId, ownerName) {
13485
- const supabase = getSupabaseClient();
13486
- const existing = () => supabase.from("threads").select("id").eq("client_key", workspaceId).maybeSingle();
13487
- const found = await existing();
13488
- if (found.data) return found.data.id;
13489
- const { data, error: error2 } = await supabase.from("threads").insert({
13490
- channel_id: channelId,
13491
- title: `Welcome to your Flyspace, ${ownerName}!`,
13492
- author: AGENT_AUTHOR,
13493
- client_key: workspaceId
13494
- }).select("id").single();
13495
- if (data) return data.id;
13496
- return error2?.code === "23505" ? (await existing()).data?.id ?? null : null;
13497
- }
13498
-
13499
13605
  // ../api/src/dashboard/host-status-signal.ts
13500
13606
  var HOST_STATUS_CHANNEL = "host-status";
13501
13607
  var channelReady = null;
@@ -13605,7 +13711,10 @@ function registerDataRoutes(api, agent = {}) {
13605
13711
  }
13606
13712
  const auth = c.get("auth");
13607
13713
  const paused = await getAgentPaused(auth.workspaceId);
13608
- return c.json({ online: !paused });
13714
+ const cliVersion = agent.getHostCliVersion?.(getProfile()) ?? null;
13715
+ const latest2 = cliVersion ? await latestCliVersion() : null;
13716
+ const outdated = !!(cliVersion && latest2 && isOlderVersion(cliVersion, latest2));
13717
+ return c.json({ online: !paused, cliVersion, ...outdated ? { latestCliVersion: latest2 } : {} });
13609
13718
  });
13610
13719
  api.get("/channels", async (c) => {
13611
13720
  const auth = c.get("auth");
@@ -13842,17 +13951,17 @@ function registerDataRoutes(api, agent = {}) {
13842
13951
  api.get("/sessions", async (c) => {
13843
13952
  const raw = await agent.listSessions?.() ?? [];
13844
13953
  const threadTsList = raw.map((s) => s.threadTs);
13845
- let latest;
13954
+ let latest2;
13846
13955
  let totals;
13847
13956
  try {
13848
- ({ latest, totals } = await sessionUsageForThreads(getProfile(), threadTsList));
13957
+ ({ latest: latest2, totals } = await sessionUsageForThreads(getProfile(), threadTsList));
13849
13958
  } catch {
13850
- latest = /* @__PURE__ */ new Map();
13959
+ latest2 = /* @__PURE__ */ new Map();
13851
13960
  totals = /* @__PURE__ */ new Map();
13852
13961
  const usageStore = existsSync19(paths.DB_FILE) ? getLocalCache().openUsageStore(paths.DB_FILE) : null;
13853
13962
  if (usageStore) {
13854
13963
  try {
13855
- latest = usageStore.latestByThread(threadTsList);
13964
+ latest2 = usageStore.latestByThread(threadTsList);
13856
13965
  totals = usageStore.totalsByThread(threadTsList);
13857
13966
  } finally {
13858
13967
  usageStore.close();
@@ -13861,7 +13970,7 @@ function registerDataRoutes(api, agent = {}) {
13861
13970
  }
13862
13971
  const threadLinks = await resolveThreadLinks(raw);
13863
13972
  const sessions2 = raw.map((s) => {
13864
- const run4 = latest.get(s.threadTs);
13973
+ const run4 = latest2.get(s.threadTs);
13865
13974
  const turns = run4?.numTurns ?? 1;
13866
13975
  const total = run4 ? run4.inputTokens + run4.cacheReadTokens + run4.cacheCreationTokens : void 0;
13867
13976
  const link = s.channelId.startsWith("chat:") ? threadLinks.chat.get(s.threadTs) : threadLinks.external.get(`${s.channelId}:${s.threadTs}`);
@@ -14494,7 +14603,8 @@ function coerceMemberEmails(raw) {
14494
14603
  return raw.filter((v) => typeof v === "string").map((v) => v.trim().toLowerCase()).filter(Boolean);
14495
14604
  }
14496
14605
  function ensureCwdDir(cwd) {
14497
- const expanded = isLogicalCwd(cwd) ? resolveLogicalCwd(cwd) : expandPath(cwd);
14606
+ const expanded = expandPath(cwd);
14607
+ if (isLogicalCwd(cwd) || isUnderLogicalRoot(expanded)) return;
14498
14608
  if (!existsSync21(expanded)) mkdirSync10(expanded, { recursive: true });
14499
14609
  }
14500
14610
  function toResponse3(channel) {
@@ -14652,7 +14762,7 @@ import {
14652
14762
  readSync as readSync2,
14653
14763
  readdirSync as readdirSync5,
14654
14764
  readFileSync as readFileSync20,
14655
- realpathSync as realpathSync3,
14765
+ realpathSync as realpathSync4,
14656
14766
  renameSync,
14657
14767
  rmSync as rmSync8,
14658
14768
  statSync as statSync6,
@@ -15755,12 +15865,12 @@ var RETRY_DELAY_MS = 3e3;
15755
15865
  var MISSED_MESSAGE_WINDOW_MS = 24 * 60 * 60 * 1e3;
15756
15866
  var MISSED_MESSAGE_THREAD_LIMIT = 100;
15757
15867
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
15758
- function stripPathPrefixes(label2, roots, home = homedir9()) {
15868
+ function stripPathPrefixes(label2, roots, home2 = homedir9()) {
15759
15869
  let out = label2;
15760
15870
  for (const root of roots) {
15761
15871
  if (root) out = out.replaceAll(`${root.replace(/\/+$/, "")}/`, "");
15762
15872
  }
15763
- if (home) out = out.replaceAll(`${home.replace(/\/+$/, "")}/`, "~/");
15873
+ if (home2) out = out.replaceAll(`${home2.replace(/\/+$/, "")}/`, "~/");
15764
15874
  return out;
15765
15875
  }
15766
15876
  var ChatAgent = class {
@@ -17495,7 +17605,7 @@ function moveDocEntry(root, from, to) {
17495
17605
  return { status: "into-self" };
17496
17606
  }
17497
17607
  try {
17498
- if (realpathSync3(finalAbs) !== realpathSync3(absFrom)) return { status: "exists" };
17608
+ if (realpathSync4(finalAbs) !== realpathSync4(absFrom)) return { status: "exists" };
17499
17609
  } catch {
17500
17610
  }
17501
17611
  renameSync(absFrom, finalAbs);
@@ -18069,9 +18179,9 @@ function claudeAccount() {
18069
18179
  }
18070
18180
  }
18071
18181
  function codexAccount() {
18072
- const home = process.env["CODEX_HOME"]?.trim() || join24(homedir10(), ".codex");
18182
+ const home2 = process.env["CODEX_HOME"]?.trim() || join24(homedir10(), ".codex");
18073
18183
  try {
18074
- const auth = JSON.parse(readFileSync21(join24(home, "auth.json"), "utf8"));
18184
+ const auth = JSON.parse(readFileSync21(join24(home2, "auth.json"), "utf8"));
18075
18185
  const payload = auth.tokens?.id_token?.split(".")[1];
18076
18186
  if (!payload) return void 0;
18077
18187
  const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
@@ -18762,7 +18872,7 @@ function localAgentCallbacks(chatAgent) {
18762
18872
  docsRequest: (req) => handleDocsRequest(req),
18763
18873
  hostRequest: (req) => handleHostRequest(req),
18764
18874
  listSessions: () => listLocalSessions(),
18765
- scanOnboarding: () => scanOnboarding(),
18875
+ scanOnboarding: (slug) => scanOnboarding(slug),
18766
18876
  linkProjectFolders: (slug, items) => linkProjectFolders(slug, items),
18767
18877
  isHostOnline: () => true
18768
18878
  };
@@ -19494,6 +19604,13 @@ async function runReconcileCmd(args) {
19494
19604
  }
19495
19605
 
19496
19606
  // src/cli.ts
19607
+ import { join as join29 } from "path";
19608
+ var home = flyspaceHome();
19609
+ if (home) {
19610
+ const bin = join29(home, "bin");
19611
+ const path = process.env.PATH ?? "";
19612
+ if (!path.split(":").includes(bin)) process.env.PATH = path ? `${bin}:${path}` : bin;
19613
+ }
19497
19614
  var XERO_USAGE = false ? " xero Connect to Xero and expose it as an MCP server\n" : "";
19498
19615
  var USAGE9 = `
19499
19616
  Usage: flyspace <command> [options]
@@ -19628,7 +19745,7 @@ async function main() {
19628
19745
  break;
19629
19746
  case "-v":
19630
19747
  case "--version":
19631
- printRaw("0.1.25");
19748
+ printRaw("0.1.29");
19632
19749
  break;
19633
19750
  case "-h":
19634
19751
  case "--help":