flyspace-cli 0.1.27 → 0.1.30

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.
Files changed (2) hide show
  1. package/dist/cli.js +2066 -1935
  2. package/package.json +7 -2
package/dist/cli.js CHANGED
@@ -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";
@@ -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.27" : null;
1838
+ var CLI_VERSION = true ? "0.1.30" : 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)];
@@ -2058,11 +2108,11 @@ async function serverReachable() {
2058
2108
  return err.cause ? String(err.cause) : err.message;
2059
2109
  }
2060
2110
  }
2061
- function checkService(name, installed, file, running, fix) {
2111
+ function checkService(name, installed, file, running2, fix) {
2062
2112
  if (!installed) bad2(`${name} not installed`, `run: ${fix}`);
2063
2113
  else if (serviceBinaryMissing(file))
2064
2114
  bad2(`${name} points at a CLI that no longer exists`, `run: ${fix}`);
2065
- else if (!running) bad2(`${name} not running`, `logs: ${tilde3(paths.LOG_DIR)}`);
2115
+ else if (!running2) bad2(`${name} not running`, `logs: ${tilde3(paths.LOG_DIR)}`);
2066
2116
  else ok2(`${name} running`);
2067
2117
  }
2068
2118
  async function runDoctor(args) {
@@ -2331,7 +2381,7 @@ async function runProfiles(args = []) {
2331
2381
 
2332
2382
  // src/commands/upgrade.ts
2333
2383
  import { execSync as execSync6 } from "child_process";
2334
- import { realpathSync as realpathSync2 } from "fs";
2384
+ import { realpathSync as realpathSync3 } from "fs";
2335
2385
  import { join as join11 } from "path";
2336
2386
  function restartInstalledProfiles() {
2337
2387
  if (isHostServiceInstalled()) {
@@ -2355,7 +2405,7 @@ Restarting ${installed.length} instance(s)...`);
2355
2405
  function npmInstallCommand() {
2356
2406
  let prefix;
2357
2407
  try {
2358
- const entry = realpathSync2(process.argv[1] ?? "");
2408
+ const entry = realpathSync3(process.argv[1] ?? "");
2359
2409
  const at = entry.indexOf(`/lib/node_modules/${"flyspace-cli"}/`);
2360
2410
  if (at > 0) prefix = entry.slice(0, at);
2361
2411
  } catch {
@@ -2368,7 +2418,7 @@ function npmEnv() {
2368
2418
  return home2 ? { ...process.env, PATH: `${join11(home2, "node", "bin")}:${process.env.PATH ?? ""}` } : process.env;
2369
2419
  }
2370
2420
  async function runUpgrade() {
2371
- const current = "0.1.27";
2421
+ const current = "0.1.30";
2372
2422
  console.log(`Current version: ${current}`);
2373
2423
  console.log("Checking for updates...\n");
2374
2424
  try {
@@ -6298,10 +6348,10 @@ async function runMcpCmd(args) {
6298
6348
  }
6299
6349
 
6300
6350
  // src/commands/dashboard.ts
6301
- import { spawn as spawn7, spawnSync } from "child_process";
6351
+ import { spawn as spawn8, spawnSync } from "child_process";
6302
6352
  import { existsSync as existsSync32 } from "fs";
6303
6353
  import { platform as platform7 } from "os";
6304
- import { join as join28 } from "path";
6354
+ import { join as join29 } from "path";
6305
6355
 
6306
6356
  // src/reconcile.ts
6307
6357
  import { existsSync as existsSync14, mkdirSync as mkdirSync8, writeFileSync as writeFileSync8 } from "fs";
@@ -7348,8 +7398,8 @@ var inFlight = /* @__PURE__ */ new Map();
7348
7398
  async function readOrCreateTranslation(input2) {
7349
7399
  const contentHash = translationHash(input2.title, input2.content);
7350
7400
  const key = `${input2.shareId}:${contentHash}:${input2.lang}`;
7351
- const running = inFlight.get(key);
7352
- if (running) return running;
7401
+ const running2 = inFlight.get(key);
7402
+ if (running2) return running2;
7353
7403
  const job = (async () => {
7354
7404
  const cached = await getDocShareTranslation(input2.shareId, input2.lang, contentHash);
7355
7405
  if (cached) return cached;
@@ -8514,7 +8564,7 @@ function createGuestRouter() {
8514
8564
  }
8515
8565
 
8516
8566
  // ../api/src/dashboard/auth-routes.ts
8517
- import { Hono as Hono7 } from "hono";
8567
+ import { Hono as Hono8 } from "hono";
8518
8568
 
8519
8569
  // ../api/src/dashboard/supabase-server-client.ts
8520
8570
  import { createServerClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr";
@@ -8624,6 +8674,8 @@ function pruneExpired(now) {
8624
8674
  if (entry.expires <= now) resolved.delete(key);
8625
8675
  }
8626
8676
  }
8677
+ var SUPPORT_NAME = "Flyspace Support";
8678
+ var SUPPORT_AVATAR_URL = "/logo/flyspace-mark.svg";
8627
8679
  async function resolveAuthContext(c, opts = {}) {
8628
8680
  const cookie = c.req?.header("Cookie") ?? "";
8629
8681
  if (!cookie) return resolveAuthContextUncached(c);
@@ -8682,8 +8734,9 @@ async function resolveAuthContextUncached(c) {
8682
8734
  handle: membership.handle,
8683
8735
  role: membership.role,
8684
8736
  workspaceId: workspace.id,
8685
- name: metadata.name,
8686
- 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 },
8687
8740
  onboarded: Boolean(metadata.onboardedAt),
8688
8741
  passwordSet: Boolean(metadata.passwordSetAt),
8689
8742
  agentConnectedAt: workspace.agentConnectedAt ?? null,
@@ -8752,1781 +8805,2036 @@ async function verifyTurnstileToken(token) {
8752
8805
  return data.action === void 0 || data.action === "login";
8753
8806
  }
8754
8807
 
8755
- // ../api/src/dashboard/auth-routes.ts
8756
- function createAuthRouter() {
8757
- const auth = new Hono7();
8758
- const emailedLink = (deliver) => async (c) => {
8759
- const body = await c.req.json().catch(() => ({}));
8760
- const email = body.email?.trim().toLowerCase();
8761
- if (!email) return c.json({ error: "email is required" }, 400);
8762
- if (!isDevHost(c) && !await verifyTurnstileToken(body.captchaToken)) {
8763
- return c.json({ error: "captcha check failed \u2014 please retry" }, 400);
8764
- }
8765
- const workspace = await getWorkspaceBySlug(getProfile());
8766
- if (!workspace) return c.json({ error: "unknown workspace" }, 404);
8767
- const membership = await getMembershipByEmail(workspace.id, email);
8768
- if (!membership) {
8769
- return c.json({ error: "no access \u2014 ask an admin to invite you" }, 403);
8770
- }
8771
- try {
8772
- await deliver(email, callbackUrl(c));
8773
- } catch (err) {
8774
- return c.json({ error: err instanceof Error ? err.message : "could not send" }, 500);
8775
- }
8776
- return c.json({ sent: true });
8808
+ // ../api/src/dashboard/onboarding-routes.ts
8809
+ import { Hono as Hono7 } from "hono";
8810
+
8811
+ // ../api/src/store/device-store.ts
8812
+ import { createHash as createHash7, createPublicKey as createPublicKey2, randomBytes as randomBytes4, randomInt } from "crypto";
8813
+ var DEVICE_CODE_TTL_MS = 10 * 6e4;
8814
+ var ACCESS_TOKEN_TTL_MS = 15 * 6e4;
8815
+ var CHALLENGE_TTL_MS = 2 * 6e4;
8816
+ var POLL_INTERVAL_S = 5;
8817
+ var sha256 = (s) => createHash7("sha256").update(s).digest("hex");
8818
+ function parseEd25519PublicKey(b64) {
8819
+ if (typeof b64 !== "string" || b64.length > 200) return null;
8820
+ try {
8821
+ const key = createPublicKey2({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
8822
+ return key.asymmetricKeyType === "ed25519" ? key : null;
8823
+ } catch {
8824
+ return null;
8825
+ }
8826
+ }
8827
+ var USER_CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ";
8828
+ function generateUserCode() {
8829
+ const pick = () => Array.from({ length: 4 }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
8830
+ return `${pick()}-${pick()}`;
8831
+ }
8832
+ function normalizeUserCode(input2) {
8833
+ const raw = input2.toUpperCase().replace(/[\s-]/g, "");
8834
+ if (raw.length !== 8 || [...raw].some((ch) => !USER_CODE_ALPHABET.includes(ch))) return null;
8835
+ return `${raw.slice(0, 4)}-${raw.slice(4)}`;
8836
+ }
8837
+ function toDevice(r) {
8838
+ return {
8839
+ id: String(r["id"]),
8840
+ userId: String(r["user_id"]),
8841
+ name: String(r["name"]),
8842
+ platform: r["platform"] ?? null,
8843
+ publicKey: String(r["public_key"]),
8844
+ createdAt: String(r["created_at"]),
8845
+ lastSeenAt: r["last_seen_at"] ?? null,
8846
+ revokedAt: r["revoked_at"] ?? null
8777
8847
  };
8778
- auth.post("/login", emailedLink(deliverSignInLink));
8779
- auth.post("/forgot-password", emailedLink(deliverPasswordResetLink));
8780
- auth.get("/session", async (c) => {
8781
- const ctx = await resolveAuthContext(c, { fresh: c.req.query("fresh") === "1" });
8782
- if (!ctx) return c.json({ error: "not signed in" }, 401);
8783
- return c.json({
8784
- email: ctx.email,
8785
- handle: ctx.handle,
8786
- role: ctx.role,
8787
- name: ctx.name,
8788
- avatarUrl: ctx.avatarKey ? await signAvatarUrl(ctx.avatarKey) : null,
8789
- onboarded: ctx.onboarded,
8790
- passwordSet: ctx.passwordSet,
8791
- agentConnectedAt: ctx.agentConnectedAt,
8792
- plan: ctx.plan ?? null,
8793
- onboarding: { pending: onboardingPending(ctx) }
8794
- });
8795
- });
8796
- auth.patch("/handle", async (c) => {
8797
- const ctx = await resolveAuthContext(c);
8798
- if (!ctx) return c.json({ error: "not signed in" }, 401);
8799
- if (ctx.onboarded) return c.json({ error: "handle can no longer be changed" }, 403);
8800
- const body = await c.req.json().catch(() => ({}));
8801
- const handle = body.handle?.trim().replace(/^@/, "").toLowerCase() ?? "";
8802
- if (!HANDLE_PATTERN.test(handle)) {
8803
- return c.json(
8804
- { error: "handle must be 1-32 characters: a-z, 0-9, _ or -, starting with a letter or digit" },
8805
- 400
8806
- );
8807
- }
8808
- try {
8809
- await setMembershipHandle(ctx.workspaceId, ctx.userId, handle);
8810
- } catch (err) {
8811
- if (err instanceof HandleTakenError) return c.json({ error: err.message }, 409);
8812
- throw err;
8813
- }
8814
- return c.json({ handle });
8815
- });
8816
- auth.post("/logout", async (c) => {
8817
- const supabase = createRequestSupabaseClient(c);
8818
- await supabase.auth.signOut();
8819
- return c.json({ ok: true });
8820
- });
8821
- return auth;
8822
8848
  }
8823
-
8824
- // ../api/src/dashboard/auth-middleware.ts
8825
- var requireWorkspaceMembership = async (c, next) => {
8826
- const ctx = await resolveAuthContext(c);
8827
- if (!ctx) return c.json({ error: "not signed in" }, 401);
8828
- c.set("auth", ctx);
8829
- return next();
8830
- };
8831
-
8832
- // ../api/src/dashboard/billing-routes.ts
8833
- import { Hono as Hono8 } from "hono";
8834
- function isPlan(value) {
8835
- return value === "local" || value === "cloud";
8849
+ function toPending(r) {
8850
+ return {
8851
+ id: String(r["id"]),
8852
+ userCode: String(r["user_code"]),
8853
+ publicKey: String(r["public_key"]),
8854
+ name: String(r["name"]),
8855
+ platform: r["platform"] ?? null,
8856
+ requesterIp: r["requester_ip"] ?? null,
8857
+ approvedBy: r["approved_by"] ?? null,
8858
+ workspaceIds: r["workspace_ids"] ?? [],
8859
+ deniedAt: r["denied_at"] ?? null,
8860
+ lastPolledAt: r["last_polled_at"] ?? null,
8861
+ expiresAt: String(r["expires_at"]),
8862
+ createdAt: String(r["created_at"])
8863
+ };
8836
8864
  }
8837
- var LOCK_EXEMPT = [/\/billing(\/|$)/, /\/profiles?$/, /\/push\//];
8838
- async function requireActivePlan(c, next) {
8839
- if (c.req.method === "OPTIONS") return next();
8840
- if (LOCK_EXEMPT.some((re) => re.test(c.req.path))) return next();
8841
- const auth = c.get("auth");
8842
- if (!auth) return next();
8843
- if (isLockedPlan(await getWorkspacePlanStatus(auth.workspaceId))) {
8844
- return c.json({ error: "trial ended: add a card to reactivate this workspace", code: "plan_locked" }, 402);
8845
- }
8846
- return next();
8865
+ function fail(what, error2) {
8866
+ throw new Error(`${what}: ${error2.message}`);
8847
8867
  }
8848
- function createBillingRouter() {
8849
- const billing = new Hono8();
8850
- billing.get("/", async (c) => {
8851
- const auth = c.get("auth");
8852
- const { planStatus, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
8853
- let trialEnd = null;
8854
- let plan = null;
8855
- if (stripeSubscriptionId) {
8856
- try {
8857
- const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
8858
- if (planStatus === "trialing" && sub.trial_end) {
8859
- trialEnd = new Date(sub.trial_end * 1e3).toISOString();
8860
- }
8861
- if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
8862
- } catch (err) {
8863
- console.warn("[billing] subscription lookup failed:", err);
8864
- }
8865
- }
8866
- return c.json({ planStatus, trialEnd, plan, locked: isLockedPlan(planStatus) });
8867
- });
8868
- billing.post("/checkout", async (c) => {
8869
- const auth = c.get("auth");
8870
- if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
8871
- const { stripeCustomerId, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
8872
- if (!stripeCustomerId) return c.json({ error: "no subscription for this workspace" }, 409);
8873
- let plan = null;
8874
- if (stripeSubscriptionId) {
8875
- try {
8876
- const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
8877
- if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
8878
- } catch (err) {
8879
- console.warn("[billing] subscription lookup failed:", err);
8880
- }
8881
- }
8882
- const origin = c.req.header("origin") ?? `https://${trustedHost(c)}`;
8883
- const session = await getStripeClient().checkout.sessions.create({
8884
- mode: "setup",
8885
- currency: "usd",
8886
- customer: stripeCustomerId,
8887
- success_url: `${origin}/?billing=done`,
8888
- cancel_url: `${origin}/`,
8889
- metadata: { workspace_id: auth.workspaceId },
8890
- ...plan && {
8891
- custom_text: {
8892
- submit: {
8893
- message: `${plan.name} plan, US$${plan.priceUsd}/month. The first month is charged as soon as this card is saved, then monthly until cancelled.`
8894
- }
8895
- }
8896
- }
8868
+ async function createDeviceCode(input2) {
8869
+ const db = getSupabaseClient();
8870
+ await db.from("device_codes").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
8871
+ const deviceCode = randomBytes4(32).toString("hex");
8872
+ for (let attempt = 0; attempt < 3; attempt++) {
8873
+ const userCode = generateUserCode();
8874
+ const { error: error2 } = await db.from("device_codes").insert({
8875
+ device_code_hash: sha256(deviceCode),
8876
+ user_code: userCode,
8877
+ public_key: input2.publicKey,
8878
+ name: input2.name,
8879
+ platform: input2.platform,
8880
+ requester_ip: input2.requesterIp,
8881
+ expires_at: new Date(Date.now() + DEVICE_CODE_TTL_MS).toISOString()
8897
8882
  });
8898
- if (!session.url) return c.json({ error: "Stripe returned no checkout URL" }, 502);
8899
- return c.json({ url: session.url });
8900
- });
8901
- return billing;
8883
+ if (!error2) return { deviceCode, userCode };
8884
+ if (error2.code !== "23505") fail("createDeviceCode", error2);
8885
+ }
8886
+ throw new Error("createDeviceCode: could not allocate a unique user code");
8902
8887
  }
8903
-
8904
- // ../api/src/dashboard/admin-routes.ts
8905
- import { Hono as Hono9 } from "hono";
8906
-
8907
- // ../api/src/dashboard/admin-middleware.ts
8908
- function platformAdminEmails() {
8909
- const raw = process.env["PLATFORM_ADMIN_EMAILS"] ?? "";
8910
- return new Set(
8911
- raw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean)
8912
- );
8888
+ async function getDeviceCodeBySecret(deviceCode) {
8889
+ const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("device_code_hash", sha256(deviceCode)).maybeSingle();
8890
+ if (error2) fail("getDeviceCodeBySecret", error2);
8891
+ return data ? toPending(data) : null;
8913
8892
  }
8914
- var requirePlatformOperator = async (c, next) => {
8915
- const supabase = createRequestSupabaseClient(c);
8916
- const {
8917
- data: { user }
8918
- } = await supabase.auth.getUser();
8919
- if (!user?.email) return c.json({ error: "not signed in" }, 401);
8920
- const email = user.email.trim().toLowerCase();
8921
- if (!platformAdminEmails().has(email)) {
8922
- return c.json({ error: "platform operator access required" }, 403);
8923
- }
8924
- c.set("platformOperator", { userId: user.id, email });
8925
- return next();
8926
- };
8927
-
8928
- // ../api/src/dashboard/admin-routes.ts
8929
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
8930
- function createAdminRouter() {
8931
- const admin = new Hono9();
8932
- admin.use("*", requirePlatformOperator);
8933
- admin.get("/workspaces", async (c) => {
8934
- const summaries = await listWorkspaceSummaries();
8935
- const workspaces = await Promise.all(
8936
- summaries.map(async (w) => {
8937
- const agentModel = cachedProfileConfig(w.slug)?.agentModel;
8938
- const r2Bytes = await Promise.all(
8939
- ["chat", SCRATCH_PREFIX].map((prefix) => sumR2PrefixBytes(`${prefix}/${w.slug}/`))
8940
- ).then(([a, b]) => a + b).catch((err) => {
8941
- console.error(`[admin] R2 usage for ${w.slug}:`, err.message);
8942
- return null;
8943
- });
8944
- return {
8945
- ...w,
8946
- agent: agentModel ? { runner: runnerForModel(agentModel), model: agentModel } : null,
8947
- r2Bytes,
8948
- flyVolumeBytes: null
8949
- };
8950
- })
8893
+ async function getDeviceCodeByUserCode(userCode) {
8894
+ const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("user_code", userCode).maybeSingle();
8895
+ if (error2) fail("getDeviceCodeByUserCode", error2);
8896
+ return data ? toPending(data) : null;
8897
+ }
8898
+ async function markPolled(id) {
8899
+ const { error: error2 } = await getSupabaseClient().from("device_codes").update({ last_polled_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id);
8900
+ if (error2) fail("markPolled", error2);
8901
+ }
8902
+ async function decideDeviceCode(id, userId, decision) {
8903
+ const { data, error: error2 } = await getSupabaseClient().from("device_codes").update(
8904
+ decision.approve ? { approved_by: userId, workspace_ids: decision.workspaceIds } : { denied_at: (/* @__PURE__ */ new Date()).toISOString() }
8905
+ ).eq("id", id).is("approved_by", null).is("denied_at", null).select("id");
8906
+ if (error2) fail("decideDeviceCode", error2);
8907
+ return (data ?? []).length > 0;
8908
+ }
8909
+ async function redeemDeviceCode(pending2) {
8910
+ const db = getSupabaseClient();
8911
+ const { data: claimed, error: claimError } = await db.from("device_codes").delete().eq("id", pending2.id).select("id");
8912
+ if (claimError) fail("redeemDeviceCode", claimError);
8913
+ if (!claimed?.length) return null;
8914
+ const existing = pending2.approvedBy ? await findDeviceByKey(pending2.publicKey, pending2.approvedBy) : null;
8915
+ 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({
8916
+ user_id: pending2.approvedBy,
8917
+ name: pending2.name,
8918
+ platform: pending2.platform,
8919
+ public_key: pending2.publicKey
8920
+ }).select("*").single();
8921
+ if (error2) fail("redeemDeviceCode", error2);
8922
+ const device = toDevice(data);
8923
+ if (pending2.workspaceIds.length) {
8924
+ const { error: bindError } = await db.from("device_workspaces").upsert(
8925
+ pending2.workspaceIds.map((workspaceId) => ({
8926
+ device_id: device.id,
8927
+ workspace_id: workspaceId,
8928
+ bound_by: pending2.approvedBy
8929
+ })),
8930
+ { onConflict: "device_id,workspace_id", ignoreDuplicates: true }
8951
8931
  );
8952
- return c.json({ workspaces });
8953
- });
8954
- admin.get("/invites", async (c) => {
8955
- const invites = await listSetupInvites();
8956
- return c.json({ invites });
8957
- });
8958
- admin.post("/setup-invites", async (c) => {
8959
- const body = await c.req.json().catch(() => ({}));
8960
- const ownerEmail = body.ownerEmail?.trim().toLowerCase();
8961
- if (!ownerEmail || !EMAIL_RE.test(ownerEmail)) {
8962
- return c.json({ error: "ownerEmail must be a valid email address" }, 400);
8963
- }
8964
- const plan = body.plan === "cloud" ? "cloud" : body.plan === "local" ? "local" : void 0;
8965
- if (!plan) return c.json({ error: 'plan must be "local" or "cloud"' }, 400);
8966
- const ownerName = body.ownerName?.trim().slice(0, 80) || null;
8967
- const message = body.message?.trim() || null;
8968
- if (message && message.length > INVITE_MESSAGE_MAX) {
8969
- return c.json({ error: `message must be ${INVITE_MESSAGE_MAX} characters or fewer` }, 400);
8970
- }
8971
- const operator = c.get("platformOperator");
8972
- const token = await issueSetupInvite({
8973
- email: ownerEmail,
8974
- ownerName,
8975
- plan,
8976
- message,
8977
- invitedBy: operator.userId
8978
- });
8979
- const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
8980
- inviteUrl.searchParams.set("token", token);
8981
- if (body.sendEmail === false) {
8982
- return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
8983
- }
8984
- try {
8985
- await deliverWorkspaceSetupInvite({
8986
- email: ownerEmail,
8987
- inviteLink: inviteUrl.toString(),
8988
- message
8989
- });
8990
- } catch (err) {
8991
- await deleteSetupInvite(token).catch(() => {
8992
- });
8993
- return c.json(
8994
- {
8995
- error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
8996
- },
8997
- 500
8998
- );
8999
- }
9000
- return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
9001
- });
9002
- admin.delete("/setup-invites/:id", async (c) => {
9003
- await revokeSetupInvite(c.req.param("id"));
9004
- return c.json({ revoked: true });
9005
- });
9006
- admin.post("/setup-invites/:id/resend", async (c) => {
9007
- const operator = c.get("platformOperator");
9008
- let token;
9009
- let invite;
9010
- try {
9011
- ({ token, invite } = await reissueSetupInvite(c.req.param("id")));
9012
- } catch (err) {
9013
- return c.json({ error: err instanceof Error ? err.message : "resend failed" }, 404);
9014
- }
9015
- const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
9016
- inviteUrl.searchParams.set("token", token);
9017
- if (c.req.query("email") === "false") {
9018
- return c.json({ sent: false, inviteLink: inviteUrl.toString() });
9019
- }
9020
- try {
9021
- await deliverWorkspaceSetupInvite({
9022
- email: invite.email,
9023
- inviteLink: inviteUrl.toString(),
9024
- message: invite.message
9025
- });
9026
- } catch (err) {
9027
- return c.json(
9028
- {
9029
- error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
9030
- },
9031
- 500
9032
- );
9033
- }
9034
- return c.json({ sent: true, inviteLink: inviteUrl.toString() });
9035
- });
9036
- return admin;
8932
+ if (bindError) fail("redeemDeviceCode", bindError);
8933
+ }
8934
+ return device;
9037
8935
  }
9038
-
9039
- // ../api/src/dashboard/agent-identities.ts
9040
- var BOT_LABEL = "Workspace Agent";
9041
- var BOT_DM_KEY = "claude";
9042
-
9043
- // ../api/src/dashboard/data-api.ts
9044
- import { existsSync as existsSync19 } from "fs";
9045
- import sharp5 from "sharp";
9046
-
9047
- // ../api/src/dashboard/automations-routes.ts
9048
- import { Hono as Hono10 } from "hono";
9049
- var TYPES2 = ["schedule", "trigger", "mention-trigger"];
9050
- function isType(t) {
9051
- return TYPES2.includes(t);
8936
+ async function getDevice(id) {
8937
+ const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("id", id).maybeSingle();
8938
+ if (error2) fail("getDevice", error2);
8939
+ return data ? toDevice(data) : null;
9052
8940
  }
9053
- function sanitize(body) {
9054
- const { created_by: _b, created_at: _a, ...rest } = body;
9055
- void _b;
9056
- void _a;
9057
- return rest;
8941
+ async function findDeviceByKey(publicKey, userId) {
8942
+ 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();
8943
+ if (error2) fail("findDeviceByKey", error2);
8944
+ return data ? toDevice(data) : null;
9058
8945
  }
9059
- function statusFor(message) {
9060
- if (/already exists/.test(message)) return 409;
9061
- if (/not found/.test(message)) return 404;
9062
- return 400;
8946
+ async function issueAccessToken(deviceId) {
8947
+ const token = `fsd_${randomBytes4(32).toString("hex")}`;
8948
+ const { error: error2 } = await getSupabaseClient().from("devices").update({
8949
+ access_token_hash: sha256(token),
8950
+ access_token_expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_MS).toISOString(),
8951
+ last_seen_at: (/* @__PURE__ */ new Date()).toISOString()
8952
+ }).eq("id", deviceId);
8953
+ if (error2) fail("issueAccessToken", error2);
8954
+ return { token, expiresInS: ACCESS_TOKEN_TTL_MS / 1e3 };
9063
8955
  }
9064
- function createAutomationWriteRouter() {
9065
- const app = new Hono10();
9066
- const manager = () => new AutomationManager();
9067
- app.get("/:type/:name", (c) => {
9068
- const item = manager().get(c.req.param("name"));
9069
- if (!item) return c.json({ error: "not found" }, 404);
9070
- return c.json(item);
9071
- });
9072
- app.post("/:type", async (c) => {
9073
- const type = c.req.param("type");
9074
- if (!isType(type)) return c.json({ error: "unknown type" }, 400);
9075
- const body = sanitize(await c.req.json());
9076
- const next = { ...body, created_at: (/* @__PURE__ */ new Date()).toISOString() };
9077
- try {
9078
- const m = manager();
9079
- if (type === "schedule") await m.addSchedule(next);
9080
- else if (type === "trigger") await m.addTrigger(next);
9081
- else await m.addMentionTrigger(next);
9082
- return c.json(next, 201);
9083
- } catch (e) {
9084
- const msg = e.message;
9085
- const status = statusFor(msg);
9086
- return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
9087
- }
9088
- });
9089
- app.put("/:type/:name", async (c) => {
9090
- const type = c.req.param("type");
9091
- if (!isType(type)) return c.json({ error: "unknown type" }, 400);
9092
- const name = c.req.param("name");
9093
- const body = sanitize(await c.req.json());
9094
- try {
9095
- const m = manager();
9096
- const existing = m.get(name);
9097
- const next = {
9098
- ...body,
9099
- created_at: existing?.created_at
9100
- };
9101
- await m.update(type, name, next);
9102
- return c.json(next, 200);
9103
- } catch (e) {
9104
- const msg = e.message;
9105
- const status = statusFor(msg);
9106
- return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
9107
- }
9108
- });
9109
- app.delete("/:type/:name", async (c) => {
9110
- try {
9111
- await manager().remove(c.req.param("name"));
9112
- return c.json({ ok: true }, 200);
9113
- } catch (e) {
9114
- return c.json({ error: e.message }, 404);
9115
- }
9116
- });
9117
- return app;
8956
+ async function deviceForAccessToken(token) {
8957
+ if (!token.startsWith("fsd_")) return null;
8958
+ 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();
8959
+ if (error2) fail("deviceForAccessToken", error2);
8960
+ return data ? toDevice(data) : null;
9118
8961
  }
9119
-
9120
- // ../api/src/dashboard/categories-routes.ts
9121
- import { Hono as Hono11 } from "hono";
9122
- function createCategoryWriteRouter() {
9123
- const app = new Hono11();
9124
- app.get("/", async (c) => {
9125
- const { data, error: error2 } = await getSupabaseClient().from("categories").select("*").eq("profile", getProfile()).order("position", { ascending: true });
9126
- if (error2) return c.json({ error: error2.message }, 500);
9127
- return c.json({ categories: data ?? [] });
9128
- });
9129
- app.post("/", async (c) => {
9130
- const body = await c.req.json().catch(() => ({}));
9131
- const name = typeof body["name"] === "string" ? body["name"].trim() : "";
9132
- if (!name) return c.json({ errors: ["name is required"] }, 400);
9133
- const supabase = getSupabaseClient();
9134
- const { data: last } = await supabase.from("categories").select("position").eq("profile", getProfile()).order("position", { ascending: false }).limit(1);
9135
- const nextPosition = (last?.[0]?.position ?? -1) + 1;
9136
- const { data, error: error2 } = await supabase.from("categories").insert({ name, position: nextPosition, profile: getProfile() }).select().single();
9137
- if (error2) return c.json({ error: error2.message }, 500);
9138
- return c.json(data, 201);
9139
- });
9140
- app.patch("/reorder", async (c) => {
9141
- const body = await c.req.json().catch(() => ({}));
9142
- const ids = Array.isArray(body["ids"]) ? body["ids"].filter((x) => typeof x === "string") : [];
9143
- if (ids.length === 0) return c.json({ errors: ["ids is required"] }, 400);
9144
- const supabase = getSupabaseClient();
9145
- const results = await Promise.all(
9146
- ids.map(
9147
- (id, position) => supabase.from("categories").update({ position, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile())
9148
- )
9149
- );
9150
- const failed2 = results.find((r) => r.error);
9151
- if (failed2?.error) return c.json({ error: failed2.error.message }, 500);
9152
- return c.json({ ok: true });
9153
- });
9154
- app.put("/:id", async (c) => {
9155
- const id = c.req.param("id");
9156
- const body = await c.req.json().catch(() => ({}));
9157
- const name = typeof body["name"] === "string" ? body["name"].trim() : "";
9158
- if (!name) return c.json({ errors: ["name is required"] }, 400);
9159
- const { data, error: error2 } = await getSupabaseClient().from("categories").update({ name, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile()).select().single();
9160
- if (error2) return c.json({ error: error2.message }, 404);
9161
- return c.json(data);
9162
- });
9163
- app.delete("/:id", async (c) => {
9164
- const id = c.req.param("id");
9165
- const supabase = getSupabaseClient();
9166
- const { error: unsetError } = await supabase.from("channels").update({ category_id: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("category_id", id);
9167
- if (unsetError) return c.json({ error: unsetError.message }, 500);
9168
- await refreshChannelRegistry();
9169
- const { error: error2 } = await supabase.from("categories").delete().eq("id", id).eq("profile", getProfile());
9170
- if (error2) return c.json({ error: error2.message }, 404);
9171
- return c.json({ ok: true });
8962
+ async function revokeDevice2(id) {
8963
+ const { error: error2 } = await getSupabaseClient().from("devices").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString(), access_token_hash: null }).eq("id", id);
8964
+ if (error2) fail("revokeDevice", error2);
8965
+ }
8966
+ async function createChallenge(deviceId) {
8967
+ const db = getSupabaseClient();
8968
+ await db.from("device_challenges").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
8969
+ const nonce = randomBytes4(32).toString("base64url");
8970
+ const { error: error2 } = await db.from("device_challenges").insert({
8971
+ nonce,
8972
+ device_id: deviceId,
8973
+ expires_at: new Date(Date.now() + CHALLENGE_TTL_MS).toISOString()
9172
8974
  });
9173
- return app;
8975
+ if (error2) fail("createChallenge", error2);
8976
+ return nonce;
9174
8977
  }
9175
-
9176
- // ../api/src/dashboard/chat-routes.ts
9177
- import { Hono as Hono12 } from "hono";
9178
-
9179
- // ../api/src/dashboard/fork-summary.ts
9180
- var GROQ_API_URL3 = "https://api.groq.com/openai/v1/chat/completions";
9181
- var SUMMARY_MODEL = "openai/gpt-oss-120b";
9182
- var MAX_INPUT_CHARS2 = 24e3;
9183
- var TITLE_MAX_CHARS = 80;
9184
- 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.
9185
-
9186
- Rules:
9187
- - 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.
9188
- - 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.
9189
- - 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.
9190
- - 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.
9191
- - Preserve essential code identifiers, file names, and URLs, but do not reproduce whole code blocks \u2014 describe what they do in one sentence instead.
9192
- - No preamble like "Summary:" \u2014 start directly with the title line.`;
9193
- 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.
9194
-
9195
- Output ONE line only: the title. Nothing else \u2014 no summary, no explanation, no blank line.
9196
-
9197
- Shape:
9198
- - A short noun phrase naming the subject, like a filed ticket's title. Max ${TITLE_MAX_CHARS} characters, and shorter is better.
9199
- - Not a sentence copied from the message, not a quote, not Markdown. No bold, no backticks, no quotes, no trailing punctuation.
9200
-
9201
- Content:
9202
- - 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.
9203
- - 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.
9204
- - 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.
9205
- - 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.
9206
-
9207
- Example \u2014 a message that works out why an admin could not sign in, and closes by suggesting turning email confirmation off:
9208
- admin \u5E33\u865F\u88AB email \u9A57\u8B49\u4FE1\u5361\u4F4F`;
9209
- function clampTitle(title) {
9210
- return title.length > TITLE_MAX_CHARS ? `${title.slice(0, TITLE_MAX_CHARS - 1)}\u2026` : title;
8978
+ async function consumeChallenge(deviceId, nonce) {
8979
+ 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");
8980
+ if (error2) fail("consumeChallenge", error2);
8981
+ return (data ?? []).length > 0;
9211
8982
  }
9212
- function deriveTitle(body) {
9213
- const line = body.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "Forked message";
9214
- return clampTitle(line);
8983
+ async function listAdminWorkspaces(userId) {
8984
+ const { data, error: error2 } = await getSupabaseClient().from("memberships").select("workspace_id, workspaces!inner(slug)").eq("user_id", userId).eq("role", "admin").eq("status", "active");
8985
+ if (error2) fail("listAdminWorkspaces", error2);
8986
+ return (data ?? []).map((r) => ({ id: r.workspace_id, slug: r.workspaces.slug })).sort((a, b) => a.slug.localeCompare(b.slug));
9215
8987
  }
9216
- function cleanTitleLine(raw) {
9217
- const line = raw.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
9218
- return clampTitle(
9219
- line.replace(/^#{1,6}\s*/, "").replace(/^[*_`"'\u201c\u2018\u300c\u300e]+|[*_`"'\u201d\u2019\u300d\u300f]+$/g, "").trim()
9220
- );
8988
+ async function listDeviceWorkspaces(device) {
8989
+ const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id").eq("device_id", device.id);
8990
+ if (error2) fail("listDeviceWorkspaces", error2);
8991
+ const bound = new Set((data ?? []).map((r) => r.workspace_id));
8992
+ return (await listAdminWorkspaces(device.userId)).filter((w) => bound.has(w.id));
9221
8993
  }
9222
- function fallbackTopic(body) {
9223
- return { title: deriveTitle(body), body };
8994
+ async function bindDeviceWorkspace(deviceId, workspaceId, boundBy) {
8995
+ const { error: error2 } = await getSupabaseClient().from("device_workspaces").upsert(
8996
+ { device_id: deviceId, workspace_id: workspaceId, bound_by: boundBy },
8997
+ { onConflict: "device_id,workspace_id", ignoreDuplicates: true }
8998
+ );
8999
+ if (error2) fail("bindDeviceWorkspace", error2);
9224
9000
  }
9225
- function parseTopic(raw, original) {
9226
- const text = raw.trim();
9227
- const breakAt = text.indexOf("\n");
9228
- if (breakAt === -1) return { title: deriveTitle(text), body: text };
9229
- const title = text.slice(0, breakAt).trim();
9230
- const body = text.slice(breakAt + 1).trim();
9231
- if (!title || !body) return fallbackTopic(original);
9232
- return {
9233
- title: clampTitle(title),
9234
- body
9235
- };
9001
+ async function listWorkspaceDevices(workspaceId) {
9002
+ const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("devices!inner(*)").eq("workspace_id", workspaceId).is("devices.revoked_at", null);
9003
+ if (error2) fail("listWorkspaceDevices", error2);
9004
+ return (data ?? []).map((r) => toDevice(r.devices)).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
9236
9005
  }
9237
- async function buildForkTopic(transcript, fallbackText, mode = "condense") {
9238
- const input2 = transcript.length > MAX_INPUT_CHARS2 ? transcript.slice(-MAX_INPUT_CHARS2) : transcript;
9239
- const fallback = fallbackText ?? transcript;
9240
- const focus = mode === "focus";
9241
- const apiKey = process.env["GROQ_API_KEY"];
9242
- if (!apiKey) return fallbackTopic(fallback);
9243
- try {
9244
- const res = await fetch(GROQ_API_URL3, {
9245
- method: "POST",
9246
- headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
9247
- body: JSON.stringify({
9248
- model: SUMMARY_MODEL,
9249
- temperature: 0.2,
9250
- // A title has to sort the message's central claim from the next-step
9251
- // paragraph it often ends with; 'low' reliably grabs the latter.
9252
- reasoning_effort: focus ? "medium" : "low",
9253
- // A title is one short line, but focus mode reasons before answering
9254
- // and the budget covers reasoning tokens too — too small a cap here
9255
- // truncates the title mid-word rather than failing.
9256
- max_tokens: focus ? 512 : 1024,
9257
- messages: [
9258
- {
9259
- role: "system",
9260
- content: focus ? FOCUS_TITLE_SYSTEM_PROMPT : CONDENSE_SYSTEM_PROMPT
9261
- },
9262
- { role: "user", content: input2 }
9263
- ]
9264
- })
9265
- });
9266
- if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
9267
- const data = await res.json();
9268
- const raw = data.choices?.[0]?.message?.content?.trim();
9269
- if (!raw) throw new Error("Groq returned an empty summary");
9270
- if (focus) return { title: cleanTitleLine(raw) || deriveTitle(fallback), body: fallback };
9271
- return parseTopic(raw, fallback);
9272
- } catch (err) {
9273
- console.error("Fork summary failed, using original text:", err);
9274
- return fallbackTopic(fallback);
9006
+ var STALE_HOST_MS = 14 * 24 * 60 * 6e4;
9007
+ var isStale = (lastSeenAt) => !!lastSeenAt && Date.now() - Date.parse(lastSeenAt) > STALE_HOST_MS;
9008
+ async function listWorkspaceHosts(workspaceIds, exceptPublicKey) {
9009
+ const hosts2 = /* @__PURE__ */ new Map();
9010
+ if (!workspaceIds.length) return hosts2;
9011
+ 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);
9012
+ if (error2) fail("listWorkspaceHosts", error2);
9013
+ for (const r of data ?? []) {
9014
+ if (r.devices.public_key !== exceptPublicKey && !isStale(r.devices.last_seen_at)) {
9015
+ hosts2.set(r.workspace_id, r.devices.name);
9016
+ }
9275
9017
  }
9018
+ return hosts2;
9276
9019
  }
9277
-
9278
- // ../api/src/dashboard/slack-format.ts
9279
- import { nameToEmoji } from "gemoji";
9280
- var SLACK_EMOJI_ALIASES = {
9281
- double_vertical_bar: "\u23F8\uFE0F"
9282
- };
9283
- function emojifyShortcode(code) {
9284
- const name = code.slice(1, -1);
9285
- return nameToEmoji[name] ?? SLACK_EMOJI_ALIASES[name] ?? code;
9286
- }
9287
- function resolveSlackTokens(text) {
9288
- 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);
9020
+ async function releaseStaleHosts(workspaceIds, exceptPublicKey) {
9021
+ if (!workspaceIds.length) return;
9022
+ const db = getSupabaseClient();
9023
+ 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);
9024
+ if (error2) fail("releaseStaleHosts", error2);
9025
+ for (const r of data ?? []) {
9026
+ if (r.devices.public_key === exceptPublicKey || !isStale(r.devices.last_seen_at)) continue;
9027
+ const { error: delError } = await db.from("device_workspaces").delete().eq("device_id", r.device_id).eq("workspace_id", r.workspace_id);
9028
+ if (delError) fail("releaseStaleHosts", delError);
9029
+ }
9289
9030
  }
9290
- function slackMrkdwnToMarkdown(text) {
9291
- 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, "&");
9031
+ async function userEmail(userId) {
9032
+ const { data, error: error2 } = await getSupabaseClient().auth.admin.getUserById(userId);
9033
+ if (error2) return null;
9034
+ return data.user?.email ?? null;
9292
9035
  }
9293
9036
 
9294
- // ../api/src/dashboard/thread-relevance.ts
9295
- async function relevantThreadsPage(supabase, handle, channelIds, opts) {
9296
- const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9297
- const { data, error: error2 } = await supabase.rpc("relevant_threads", {
9298
- handle,
9299
- mention_pattern: `@${escaped}\\M`,
9300
- channel_ids: channelIds,
9301
- before: opts.before ?? null,
9302
- page_limit: opts.limit
9303
- });
9304
- if (error2) throw new Error(`relevant_threads: ${error2.message}`);
9305
- return data ?? [];
9306
- }
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";
9307
9040
 
9308
- // ../api/src/dashboard/thread-translate.ts
9309
- import { createHash as createHash7 } from "crypto";
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");
9047
+ }
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"));
9055
+ }
9310
9056
 
9311
- // ../api/src/store/thread-translation-store.ts
9312
- async function getThreadTranslations(lang, keys) {
9313
- const found = /* @__PURE__ */ new Map();
9314
- if (keys.length === 0) return found;
9315
- const clauses = keys.map((k) => `and(reply_id.eq.${k.replyId},body_hash.eq.${k.bodyHash})`).join(",");
9316
- const { data, error: error2 } = await getSupabaseClient().from("thread_reply_translations").select("reply_id, body, passthrough").eq("lang", lang).or(clauses);
9317
- if (error2) throw new Error(`getThreadTranslations(${lang}): ${error2.message}`);
9318
- for (const row of data ?? []) {
9319
- const replyId = row["reply_id"];
9320
- found.set(replyId, {
9321
- replyId,
9322
- body: row["body"],
9323
- passthrough: row["passthrough"]
9324
- });
9325
- }
9326
- return found;
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);
9327
9074
  }
9328
- async function putThreadTranslations(lang, rows) {
9329
- if (rows.length === 0) return;
9330
- const { error: error2 } = await getSupabaseClient().from("thread_reply_translations").upsert(
9331
- rows.map((r) => ({
9332
- reply_id: r.replyId,
9333
- lang,
9334
- body_hash: r.bodyHash,
9335
- body: r.body,
9336
- passthrough: r.passthrough
9337
- })),
9338
- { onConflict: "reply_id,lang,body_hash" }
9339
- );
9340
- if (error2) throw new Error(`putThreadTranslations(${lang}): ${error2.message}`);
9075
+ function parseWireMessage(raw) {
9076
+ try {
9077
+ return JSON.parse(String(raw));
9078
+ } catch {
9079
+ return null;
9080
+ }
9341
9081
  }
9342
9082
 
9343
- // ../api/src/dashboard/thread-translate.ts
9344
- var GROQ_API_URL4 = "https://api.groq.com/openai/v1/chat/completions";
9345
- var TRANSLATION_MODEL2 = "openai/gpt-oss-120b";
9346
- var RECIPE_VERSION3 = 1;
9347
- var BATCH_CHAR_BUDGET = 16e3;
9348
- var CONTEXT_MESSAGES = 4;
9349
- var MAX_MESSAGE_CHARS = 12e3;
9350
- var MAX_OUTPUT_TOKENS2 = 32768;
9351
- var BY_CODE2 = new Map(TRANSLATION_LANGUAGES.map((l) => [l.code, l]));
9352
- function isTranslatableLanguage2(lang) {
9353
- return BY_CODE2.has(lang);
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);
9354
9089
  }
9355
- function translationHash2(author, body) {
9356
- return createHash7("sha256").update(`${RECIPE_VERSION3}
9357
- ${author}
9358
- ${body}`).digest("hex");
9090
+ function removeHost(ws) {
9091
+ if (hosts.delete(ws)) notifyHostStatus();
9092
+ }
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");
9099
+ }
9100
+ }
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;
9359
9105
  }
9360
- function systemPrompt2(languageName2) {
9361
- return `You translate messages from a group chat into ${languageName2}. You are a translator, not an editor, a summarizer, or a participant.
9362
9106
 
9363
- You receive a JSON object with two keys:
9364
- - "context": earlier messages, for reference only. NEVER translate these and never mention them in your output.
9365
- - "translate": the messages to translate, each with an "n" and a "body".
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
+ }
9366
9115
 
9367
- Reply with ONLY a JSON object of this exact shape, one entry per message in "translate", in the same order:
9368
- {"messages":[{"n":1,"body":"...","already_target":false}]}
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.
9369
9131
 
9370
- Rules:
9371
- - "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.
9372
- - 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.
9373
- - 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.
9374
- - 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.
9375
- - 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.
9376
- - Keep established technical terms in English where a fluent speaker of ${languageName2} would keep them in English rather than reaching for a translated coinage.
9377
- - 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.
9378
- - Never add commentary, notes about your choices, apologies, or an explanation of anything you could not translate.`;
9379
- }
9380
- function normalizeHyphens2(text) {
9381
- return text.replace(/‐|‑/g, "-");
9382
- }
9383
- function clip(body) {
9384
- return body.length > MAX_MESSAGE_CHARS ? body.slice(0, MAX_MESSAGE_CHARS) : body;
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, ""));
9385
9147
  }
9386
- function batchMessages(messages) {
9387
- const batches = [];
9388
- let current = [];
9389
- let size = 0;
9390
- for (const m of messages) {
9391
- const len = Math.min(m.body.length, MAX_MESSAGE_CHARS);
9392
- if (current.length > 0 && size + len > BATCH_CHAR_BUDGET) {
9393
- batches.push(current);
9394
- current = [];
9395
- size = 0;
9396
- }
9397
- current.push(m);
9398
- size += len;
9399
- }
9400
- if (current.length > 0) batches.push(current);
9401
- return batches;
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");
9402
9157
  }
9403
- async function translateBatch(batch, context, lang) {
9404
- const language = BY_CODE2.get(lang);
9405
- if (!language) throw new Error(`unsupported translation language: ${lang}`);
9406
- const apiKey = process.env["GROQ_API_KEY"];
9407
- if (!apiKey) throw new Error("GROQ_API_KEY is not configured");
9408
- const payload = {
9409
- context: context.map((m) => ({ author: m.author, body: clip(m.body) })),
9410
- translate: batch.map((m, i) => ({ n: i + 1, author: m.author, body: clip(m.body) }))
9411
- };
9412
- const res = await fetch(GROQ_API_URL4, {
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, {
9413
9169
  method: "POST",
9170
+ signal: AbortSignal.timeout(TIMEOUT_MS),
9414
9171
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
9415
9172
  body: JSON.stringify({
9416
- model: TRANSLATION_MODEL2,
9417
- temperature: 0,
9418
- max_tokens: MAX_OUTPUT_TOKENS2,
9419
- 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,
9420
9178
  messages: [
9421
- { role: "system", content: systemPrompt2(language.name) },
9422
- { role: "user", content: JSON.stringify(payload) }
9179
+ { role: "system", content: SYSTEM_PROMPT },
9180
+ { role: "user", content: facts }
9423
9181
  ]
9424
9182
  })
9425
9183
  });
9426
- if (!res.ok) throw new Error(`translation model returned ${res.status}`);
9184
+ if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
9427
9185
  const data = await res.json();
9428
- const choice = data.choices?.[0];
9429
- if (choice?.finish_reason === "length") {
9430
- throw new Error("the translation ran past the model output limit");
9431
- }
9432
- const raw = choice?.message?.content?.trim() ?? "";
9433
- if (!raw) throw new Error("the model returned an empty translation");
9434
- let parsed;
9435
- try {
9436
- parsed = JSON.parse(raw);
9437
- } catch {
9438
- throw new Error("the model returned unparseable JSON");
9439
- }
9440
- const byNumber = /* @__PURE__ */ new Map();
9441
- for (const entry of parsed.messages ?? []) {
9442
- 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
+ }
9443
9198
  }
9444
- return batch.map((m, i) => {
9445
- const entry = byNumber.get(i + 1);
9446
- const body = typeof entry?.body === "string" ? entry.body.trim() : "";
9447
- if (!body) return { id: m.id, body: m.body, passthrough: true };
9448
- return {
9449
- id: m.id,
9450
- body: normalizeHyphens2(body),
9451
- passthrough: entry?.already_target === true
9452
- };
9453
- });
9454
- }
9455
- var inFlight4 = /* @__PURE__ */ new Map();
9456
- async function translateThread(threadId, lang, messages) {
9457
- if (!BY_CODE2.has(lang)) throw new Error(`unsupported translation language: ${lang}`);
9458
- if (messages.length === 0) return [];
9459
- const shape = createHash7("sha256").update(messages.map((m) => translationHash2(m.author, m.body)).join("\n")).digest("hex");
9460
- const key = `${threadId}:${lang}:${shape}`;
9461
- const running = inFlight4.get(key);
9462
- if (running) return running;
9463
- const job = (async () => {
9464
- const hashes = messages.map((m) => ({
9465
- replyId: m.id,
9466
- bodyHash: translationHash2(m.author, m.body)
9467
- }));
9468
- const cached = await getThreadTranslations(lang, hashes);
9469
- const missing = messages.filter((m) => !cached.has(m.id));
9470
- const fresh = /* @__PURE__ */ new Map();
9471
- if (missing.length > 0) {
9472
- const index = new Map(messages.map((m, i) => [m.id, i]));
9473
- const batches = batchMessages(missing);
9474
- const results = await Promise.all(
9475
- batches.map((batch) => {
9476
- const first = index.get(batch[0].id) ?? 0;
9477
- const context = messages.slice(Math.max(0, first - CONTEXT_MESSAGES), first);
9478
- return translateBatch(batch, context, lang);
9479
- })
9480
- );
9481
- for (const row of results.flat()) fresh.set(row.id, row);
9482
- const hashById = new Map(hashes.map((h) => [h.replyId, h.bodyHash]));
9483
- await putThreadTranslations(
9484
- lang,
9485
- [...fresh.values()].map((r) => ({
9486
- replyId: r.id,
9487
- bodyHash: hashById.get(r.id),
9488
- body: r.body,
9489
- passthrough: r.passthrough
9490
- }))
9491
- );
9492
- }
9493
- return messages.map((m) => {
9494
- const hit = cached.get(m.id);
9495
- if (hit) return { id: m.id, body: hit.body, passthrough: hit.passthrough };
9496
- return fresh.get(m.id) ?? { id: m.id, body: m.body, passthrough: true };
9497
- });
9498
- })().finally(() => inFlight4.delete(key));
9499
- inFlight4.set(key, job);
9500
- return job;
9199
+ return `${text ?? fallbackGreeting(input2)}
9200
+
9201
+ [BUTTONS: ${greetingButtons(input2).join(" | ")}]`;
9501
9202
  }
9502
9203
 
9503
- // ../api/src/dashboard/chat-routes.ts
9504
- function isNonEmptyString(v) {
9505
- return typeof v === "string" && v.trim().length > 0;
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;
9506
9209
  }
9507
- function sanitizeModel(v) {
9508
- if (typeof v !== "string") return null;
9509
- const model = v.trim().toLowerCase();
9510
- return isAllowedModel(model) ? model : null;
9210
+ async function isHostOwner(ctx) {
9211
+ if (ctx.role !== "admin") return false;
9212
+ if (ctx.isSupport) return true;
9213
+ return (await listWorkspaceDevices(ctx.workspaceId)).some((d) => d.userId === ctx.userId);
9511
9214
  }
9512
- async function threadModel(threadId) {
9513
- 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();
9514
- return sanitizeModel(data?.model);
9215
+ function hostUnavailable(c, agent, method) {
9216
+ return c.json({ error: "host offline", reason: agent.getHostCallFailure?.(method) ?? "offline" }, 503);
9515
9217
  }
9516
- function requestedModel(c, model) {
9517
- return c.get("auth")?.role === "admin" ? sanitizeModel(model) : null;
9218
+ function createOnboardingRouter(agent) {
9219
+ const app = new Hono7();
9220
+ app.get("/scan", async (c) => {
9221
+ const ctx = await resolveAuthContext(c);
9222
+ if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
9223
+ const scan = await agent.scanOnboarding?.(getProfile());
9224
+ if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
9225
+ return c.json(scan);
9226
+ });
9227
+ app.post("/channels", async (c) => {
9228
+ const ctx = await resolveAuthContext(c);
9229
+ if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
9230
+ const body = await c.req.json().catch(() => ({}));
9231
+ const runner = body["agent"];
9232
+ const folders = Array.isArray(body["folders"]) ? body["folders"].filter((f) => typeof f === "string" && f.length > 0) : [];
9233
+ if (folders.length > MAX_FOLDERS) return c.json({ error: `at most ${MAX_FOLDERS} folders` }, 400);
9234
+ const scan = await agent.scanOnboarding?.();
9235
+ if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
9236
+ const offered = new Map(scan.projects.map((p) => [p.path, p]));
9237
+ const unknown = folders.find((f) => !offered.has(f));
9238
+ if (unknown) return c.json({ error: "folder was not offered by the scan" }, 400);
9239
+ const taken = new Set(getProfileChannelRegistry().map((ch) => ch.name));
9240
+ taken.add("general");
9241
+ const items = [{ name: "general" }];
9242
+ for (const path of folders) items.push({ name: uniqueChannelName(offered.get(path).name, taken), path });
9243
+ const linked = await agent.linkProjectFolders?.(getProfile(), items);
9244
+ if (!linked) return hostUnavailable(c, agent, "linkProjectFolders");
9245
+ if (runner === "claude" || runner === "codex") {
9246
+ await agent.saveLocalDashboardSettings?.({ providers: [{ runner }] });
9247
+ }
9248
+ const created = [];
9249
+ for (const l of linked) {
9250
+ created.push(
9251
+ l.name === "general" ? await ensureGeneralChannel() : await createChannel({ name: l.name, cwd: l.cwd })
9252
+ );
9253
+ }
9254
+ await markWorkspaceProjectsSetup(ctx.workspaceId);
9255
+ return c.json({ channels: created.map((ch) => ({ id: ch.id, name: ch.name })) }, 201);
9256
+ });
9257
+ app.post("/link-folder", async (c) => {
9258
+ const ctx = await resolveAuthContext(c);
9259
+ if (!isWorkspaceOwner(ctx)) return c.json({ error: "workspace owner access required" }, 403);
9260
+ const body = await c.req.json().catch(() => ({}));
9261
+ const path = typeof body["path"] === "string" ? body["path"].trim() : "";
9262
+ const name = typeof body["name"] === "string" ? body["name"].trim() : "";
9263
+ if (!path || !CHANNEL_NAME_RE.test(name)) return c.json({ error: "path and a valid name are required" }, 400);
9264
+ if (getProfileChannelRegistry().some((ch) => ch.name === name)) {
9265
+ return c.json({ error: `#${name} already exists` }, 409);
9266
+ }
9267
+ if (body["manual"] === true) {
9268
+ const devices = await listWorkspaceDevices(ctx.workspaceId);
9269
+ if (!devices.some((d) => d.userId === ctx.userId)) {
9270
+ return c.json({ error: "a path can only be typed on your own machine" }, 403);
9271
+ }
9272
+ } else {
9273
+ const scan = await agent.scanOnboarding?.(getProfile());
9274
+ if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
9275
+ if (!scan.projects.some((p) => p.path === path)) {
9276
+ return c.json({ error: "folder was not offered by the scan" }, 400);
9277
+ }
9278
+ }
9279
+ const linked = await agent.linkProjectFolders?.(getProfile(), [{ name, path }]);
9280
+ if (!linked) {
9281
+ const hostError = getHostCallError("linkProjectFolders");
9282
+ if (hostError) return c.json({ error: hostError }, 409);
9283
+ return hostUnavailable(c, agent, "linkProjectFolders");
9284
+ }
9285
+ return c.json({ cwd: linked[0].cwd });
9286
+ });
9287
+ app.post("/reset", async (c) => {
9288
+ const ctx = await resolveAuthContext(c);
9289
+ if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
9290
+ const { error: error2 } = await getSupabaseClient().from("threads").delete().eq("client_key", ctx.workspaceId);
9291
+ if (error2) return c.json({ error: error2.message }, 500);
9292
+ await resetWorkspaceOnboarding(ctx.workspaceId);
9293
+ return c.json({ ok: true });
9294
+ });
9295
+ app.get("/greeting", async (c) => {
9296
+ const ctx = await resolveAuthContext(c);
9297
+ if (ctx?.role !== "admin") return c.json({ pending: false });
9298
+ const greetedAt = await getWorkspaceGreetedAt(ctx.workspaceId);
9299
+ const pending2 = !!ctx.projectsSetupAt && !greetedAt;
9300
+ if (!pending2) return c.json({ pending: pending2, channelId: null, threadId: null });
9301
+ const general = await ensureGeneralChannel();
9302
+ const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ctx.name?.trim() || ctx.handle);
9303
+ return c.json({ pending: pending2, channelId: general.id, threadId });
9304
+ });
9305
+ app.post("/greeting", async (c) => {
9306
+ const ctx = await resolveAuthContext(c);
9307
+ if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
9308
+ const supabase = getSupabaseClient();
9309
+ const general = await ensureGeneralChannel();
9310
+ const ownerName = ctx.name?.trim() || ctx.handle;
9311
+ const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ownerName);
9312
+ if (!threadId) return c.json({ error: "could not create the welcome thread" }, 500);
9313
+ const done = { channelId: general.id, threadId };
9314
+ const replied = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
9315
+ if (replied.data) return c.json(done);
9316
+ if (await getWorkspaceGreetedAt(ctx.workspaceId))
9317
+ return c.json({ error: "already greeted" }, 409);
9318
+ const [branding, billing, members] = await Promise.all([
9319
+ getWorkspaceBranding(getProfile()).catch(() => null),
9320
+ getWorkspaceBilling(ctx.workspaceId).catch(() => null),
9321
+ listMemberships(ctx.workspaceId).catch(() => [])
9322
+ ]);
9323
+ const body = await buildGreeting({
9324
+ ownerName,
9325
+ workspaceName: branding?.displayName || getProfile(),
9326
+ trialing: billing?.planStatus === "trialing",
9327
+ hostConnected: !!ctx.agentConnectedAt,
9328
+ channels: getProfileChannelRegistry().filter((ch) => !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt).map((ch) => ch.name),
9329
+ memberCount: members.filter((m) => !m.isSupport).length
9330
+ });
9331
+ const again = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
9332
+ if (again.data) return c.json(done);
9333
+ const reply = await supabase.from("thread_replies").insert({ thread_id: threadId, author: AGENT_AUTHOR, body });
9334
+ if (reply.error) return c.json({ error: reply.error.message }, 500);
9335
+ await markWorkspaceGreeted(ctx.workspaceId);
9336
+ return c.json(done, 201);
9337
+ });
9338
+ return app;
9518
9339
  }
9519
- function sanitizeEffort(v) {
9520
- if (typeof v !== "string") return null;
9521
- const effort = v.trim().toLowerCase();
9522
- return isEffort(effort) ? effort : null;
9340
+ async function ensureGreetingThread(workspaceId, channelId, ownerName) {
9341
+ const supabase = getSupabaseClient();
9342
+ const existing = () => supabase.from("threads").select("id").eq("client_key", workspaceId).maybeSingle();
9343
+ const found = await existing();
9344
+ if (found.data) return found.data.id;
9345
+ const { data, error: error2 } = await supabase.from("threads").insert({
9346
+ channel_id: channelId,
9347
+ title: `Welcome to your Flyspace, ${ownerName}!`,
9348
+ author: AGENT_AUTHOR,
9349
+ client_key: workspaceId
9350
+ }).select("id").single();
9351
+ if (data) return data.id;
9352
+ return error2?.code === "23505" ? (await existing()).data?.id ?? null : null;
9523
9353
  }
9524
- function requestedEffort(c, effort) {
9525
- return c.get("auth")?.role === "admin" ? sanitizeEffort(effort) : null;
9354
+
9355
+ // ../api/src/dashboard/auth-routes.ts
9356
+ function createAuthRouter() {
9357
+ const auth = new Hono8();
9358
+ const emailedLink = (deliver) => async (c) => {
9359
+ const body = await c.req.json().catch(() => ({}));
9360
+ const email = body.email?.trim().toLowerCase();
9361
+ if (!email) return c.json({ error: "email is required" }, 400);
9362
+ if (!isDevHost(c) && !await verifyTurnstileToken(body.captchaToken)) {
9363
+ return c.json({ error: "captcha check failed \u2014 please retry" }, 400);
9364
+ }
9365
+ const workspace = await getWorkspaceBySlug(getProfile());
9366
+ if (!workspace) return c.json({ error: "unknown workspace" }, 404);
9367
+ const membership = await getMembershipByEmail(workspace.id, email);
9368
+ if (!membership) {
9369
+ return c.json({ error: "no access \u2014 ask an admin to invite you" }, 403);
9370
+ }
9371
+ try {
9372
+ await deliver(email, callbackUrl(c));
9373
+ } catch (err) {
9374
+ return c.json({ error: err instanceof Error ? err.message : "could not send" }, 500);
9375
+ }
9376
+ return c.json({ sent: true });
9377
+ };
9378
+ auth.post("/login", emailedLink(deliverSignInLink));
9379
+ auth.post("/forgot-password", emailedLink(deliverPasswordResetLink));
9380
+ auth.get("/session", async (c) => {
9381
+ const ctx = await resolveAuthContext(c, { fresh: c.req.query("fresh") === "1" });
9382
+ if (!ctx) return c.json({ error: "not signed in" }, 401);
9383
+ return c.json({
9384
+ email: ctx.email,
9385
+ handle: ctx.handle,
9386
+ role: ctx.role,
9387
+ name: ctx.name,
9388
+ avatarUrl: ctx.isSupport ? SUPPORT_AVATAR_URL : ctx.avatarKey ? await signAvatarUrl(ctx.avatarKey) : null,
9389
+ onboarded: ctx.onboarded,
9390
+ passwordSet: ctx.passwordSet,
9391
+ agentConnectedAt: ctx.agentConnectedAt,
9392
+ plan: ctx.plan ?? null,
9393
+ onboarding: { pending: onboardingPending(ctx) },
9394
+ owner: isWorkspaceOwner(ctx),
9395
+ // Only the Host's owner can run `flyspace upgrade` on it, so other
9396
+ // admins never see the upgrade banner. Support staff can tell them to.
9397
+ hostOwner: await isHostOwner(ctx)
9398
+ });
9399
+ });
9400
+ auth.patch("/handle", async (c) => {
9401
+ const ctx = await resolveAuthContext(c);
9402
+ if (!ctx) return c.json({ error: "not signed in" }, 401);
9403
+ if (ctx.onboarded) return c.json({ error: "handle can no longer be changed" }, 403);
9404
+ const body = await c.req.json().catch(() => ({}));
9405
+ const handle = body.handle?.trim().replace(/^@/, "").toLowerCase() ?? "";
9406
+ if (!HANDLE_PATTERN.test(handle)) {
9407
+ return c.json(
9408
+ { error: "handle must be 1-32 characters: a-z, 0-9, _ or -, starting with a letter or digit" },
9409
+ 400
9410
+ );
9411
+ }
9412
+ try {
9413
+ await setMembershipHandle(ctx.workspaceId, ctx.userId, handle);
9414
+ } catch (err) {
9415
+ if (err instanceof HandleTakenError) return c.json({ error: err.message }, 409);
9416
+ throw err;
9417
+ }
9418
+ return c.json({ handle });
9419
+ });
9420
+ auth.post("/logout", async (c) => {
9421
+ const supabase = createRequestSupabaseClient(c);
9422
+ await supabase.auth.signOut();
9423
+ return c.json({ ok: true });
9424
+ });
9425
+ return auth;
9526
9426
  }
9527
- function effortFromRow(v) {
9528
- return typeof v === "string" && isEffort(v) ? v : void 0;
9427
+
9428
+ // ../api/src/dashboard/auth-middleware.ts
9429
+ var requireWorkspaceMembership = async (c, next) => {
9430
+ const ctx = await resolveAuthContext(c);
9431
+ if (!ctx) return c.json({ error: "not signed in" }, 401);
9432
+ c.set("auth", ctx);
9433
+ return next();
9434
+ };
9435
+
9436
+ // ../api/src/dashboard/billing-routes.ts
9437
+ import { Hono as Hono9 } from "hono";
9438
+ function isPlan(value) {
9439
+ return value === "local" || value === "cloud";
9529
9440
  }
9530
- var BOARD_STATUSES = [
9531
- "backlog",
9532
- "todo",
9533
- "in_progress",
9534
- "in_review",
9535
- "done",
9536
- "cancelled"
9537
- ];
9538
- var NO_STATUS_COLUMN = "none";
9539
- function boardColumnParam(status) {
9540
- if (status === NO_STATUS_COLUMN) return NO_STATUS_COLUMN;
9541
- return BOARD_STATUSES.includes(status) ? status : null;
9542
- }
9543
- var THREAD_CHUNK_SIZE = 25;
9544
- var THREADS_PAGE_SIZE = 30;
9545
- var THREADS_PAGE_SIZE_MAX = 100;
9546
- var REPLIES_PAGE_SIZE = 50;
9547
- var REPLIES_PAGE_SIZE_MAX = 200;
9548
- var TRANSLATABLE_REPLIES_MAX = 400;
9549
- var BOARD_PAGE_SIZE = 50;
9550
- var BOARD_PAGE_SIZE_MAX = 100;
9551
- function ownChannelIds(c) {
9552
- const viewer = c.get("auth");
9553
- return new Set(
9554
- getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, viewer)).flatMap((ch) => ch.externalChannelId ? [ch.id, ch.externalChannelId] : [ch.id])
9555
- );
9556
- }
9557
- async function globalChannelMeta(c) {
9558
- const viewer = c.get("auth");
9559
- const map = /* @__PURE__ */ new Map();
9560
- const channels = getChannelRegistry();
9561
- let workspaceIdBySlug = /* @__PURE__ */ new Map();
9562
- let roleByWorkspaceId = /* @__PURE__ */ new Map();
9563
- if (viewer?.email && channels.length > 0) {
9564
- const [workspaces, memberships] = await Promise.all([
9565
- listWorkspaces(),
9566
- listMembershipsByEmail(viewer.email)
9567
- ]);
9568
- workspaceIdBySlug = new Map(workspaces.map((w) => [w.slug, w.id]));
9569
- roleByWorkspaceId = new Map(memberships.map((m) => [m.workspaceId, m.role]));
9570
- }
9571
- for (const ch of channels) {
9572
- const workspaceId = workspaceIdBySlug.get(ch.profile);
9573
- const role = workspaceId ? roleByWorkspaceId.get(workspaceId) : void 0;
9574
- if (!canSeeChannel(ch, { email: viewer?.email, role })) continue;
9575
- const meta = { workspace: ch.profile, channelName: ch.name, agentName: ch.agentName };
9576
- map.set(ch.id, meta);
9577
- if (ch.externalChannelId) map.set(ch.externalChannelId, meta);
9441
+ var LOCK_EXEMPT = [/\/billing(\/|$)/, /\/profiles?$/, /\/push\//];
9442
+ async function requireActivePlan(c, next) {
9443
+ if (c.req.method === "OPTIONS") return next();
9444
+ if (LOCK_EXEMPT.some((re) => re.test(c.req.path))) return next();
9445
+ const auth = c.get("auth");
9446
+ if (!auth) return next();
9447
+ if (isLockedPlan(await getWorkspacePlanStatus(auth.workspaceId))) {
9448
+ return c.json({ error: "trial ended: add a card to reactivate this workspace", code: "plan_locked" }, 402);
9578
9449
  }
9579
- return map;
9580
- }
9581
- async function threadIsKnown(threadId, c) {
9582
- const { data } = await getSupabaseClient().from("threads").select("channel_id").eq("id", threadId).single();
9583
- const channelId = data?.channel_id;
9584
- return !!channelId && (await globalChannelMeta(c)).has(channelId);
9585
- }
9586
- function chunk(items, size) {
9587
- const out = [];
9588
- for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
9589
- return out;
9450
+ return next();
9590
9451
  }
9591
- async function withPreviews(supabase, threads) {
9592
- const chunkResults = await Promise.all(
9593
- chunk(
9594
- threads.map((t) => t.id),
9595
- THREAD_CHUNK_SIZE
9596
- ).map(
9597
- (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 })
9598
- )
9599
- );
9600
- const previewByThread = /* @__PURE__ */ new Map();
9601
- for (const { data } of chunkResults) {
9602
- for (const r of data ?? []) {
9603
- const entry = previewByThread.get(r.thread_id);
9604
- if (entry) {
9605
- entry.count += 1;
9606
- entry.lastAuthor = r.author;
9607
- entry.participants.delete(r.author);
9608
- entry.participants.set(r.author, r.external_user_id ?? null);
9609
- } else
9610
- previewByThread.set(r.thread_id, {
9611
- count: 1,
9612
- firstBody: r.body,
9613
- lastAuthor: r.author,
9614
- participants: /* @__PURE__ */ new Map()
9615
- });
9452
+ function createBillingRouter() {
9453
+ const billing = new Hono9();
9454
+ billing.get("/", async (c) => {
9455
+ const auth = c.get("auth");
9456
+ const { planStatus, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
9457
+ let trialEnd = null;
9458
+ let plan = null;
9459
+ if (stripeSubscriptionId) {
9460
+ try {
9461
+ const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
9462
+ if (planStatus === "trialing" && sub.trial_end) {
9463
+ trialEnd = new Date(sub.trial_end * 1e3).toISOString();
9464
+ }
9465
+ if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
9466
+ } catch (err) {
9467
+ console.warn("[billing] subscription lookup failed:", err);
9468
+ }
9616
9469
  }
9617
- }
9618
- return threads.map((t) => {
9619
- const entry = previewByThread.get(t.id);
9620
- return {
9621
- ...t,
9622
- reply_count: Math.max(0, (entry?.count ?? 0) - 1),
9623
- preview: entry?.firstBody ? slackMrkdwnToMarkdown(entry.firstBody) : null,
9624
- // Who spoke last, counting only replies AFTER the opening message —
9625
- // the reader's own handle here means there is nothing new for them to
9626
- // read, on this browser or any other (see web/src/lib/thread-unread).
9627
- last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
9628
- // Everyone who replied, most recent first, for the row's avatar stack.
9629
- participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id }))
9630
- };
9470
+ return c.json({ planStatus, trialEnd, plan, locked: isLockedPlan(planStatus) });
9631
9471
  });
9632
- }
9633
- async function labelThread(channelId, body, scope) {
9634
- const channel = getProfileChannelRegistry().find(
9635
- (ch) => ch.id === channelId || ch.externalChannelId === channelId
9636
- );
9637
- if (!channel) return null;
9638
- const supabase = getSupabaseClient();
9639
- const { data: recent } = await supabase.from("threads").select("auto_title, title").eq("channel_id", channelId).order("created_at", { ascending: false }).limit(50);
9640
- const existingCategories = extractCategories(
9641
- (recent ?? []).flatMap((t) => [
9642
- t.auto_title,
9643
- t.title
9644
- ])
9645
- );
9646
- return buildAutoTitle({
9647
- channelName: channel.name,
9648
- channelPrompt: channel.prompt,
9649
- workspacePrompt: loadWorkspacePrompt().prompt,
9650
- titlePrompt: (await loadWorkspaceBranding()).autoTitlePrompt,
9651
- existingCategories,
9652
- body,
9653
- scope
9472
+ billing.post("/checkout", async (c) => {
9473
+ const auth = c.get("auth");
9474
+ if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
9475
+ const { stripeCustomerId, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
9476
+ if (!stripeCustomerId) return c.json({ error: "no subscription for this workspace" }, 409);
9477
+ let plan = null;
9478
+ if (stripeSubscriptionId) {
9479
+ try {
9480
+ const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
9481
+ if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
9482
+ } catch (err) {
9483
+ console.warn("[billing] subscription lookup failed:", err);
9484
+ }
9485
+ }
9486
+ const origin = c.req.header("origin") ?? `https://${trustedHost(c)}`;
9487
+ const session = await getStripeClient().checkout.sessions.create({
9488
+ mode: "setup",
9489
+ currency: "usd",
9490
+ customer: stripeCustomerId,
9491
+ success_url: `${origin}/?billing=done`,
9492
+ cancel_url: `${origin}/`,
9493
+ metadata: { workspace_id: auth.workspaceId },
9494
+ ...plan && {
9495
+ custom_text: {
9496
+ submit: {
9497
+ message: `${plan.name} plan, US$${plan.priceUsd}/month. The first month is charged as soon as this card is saved, then monthly until cancelled.`
9498
+ }
9499
+ }
9500
+ }
9501
+ });
9502
+ if (!session.url) return c.json({ error: "Stripe returned no checkout URL" }, 502);
9503
+ return c.json({ url: session.url });
9654
9504
  });
9505
+ return billing;
9655
9506
  }
9656
- async function generateAutoTitle(threadId, channelId, body) {
9657
- if ((await loadWorkspaceBranding()).autoTitleEnabled === false) return;
9658
- const autoTitle = await labelThread(channelId, body);
9659
- if (!autoTitle) return;
9660
- const supabase = getSupabaseClient();
9661
- await supabase.from("threads").update({ auto_title: autoTitle }).eq("id", threadId).is("title", null);
9507
+
9508
+ // ../api/src/dashboard/admin-routes.ts
9509
+ import { Hono as Hono10 } from "hono";
9510
+
9511
+ // ../api/src/dashboard/admin-middleware.ts
9512
+ function platformAdminEmails() {
9513
+ const raw = process.env["PLATFORM_ADMIN_EMAILS"] ?? "";
9514
+ return new Set(
9515
+ raw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean)
9516
+ );
9662
9517
  }
9663
- function createChatRouter(agent = {}) {
9664
- const app = new Hono12();
9518
+ var requirePlatformOperator = async (c, next) => {
9519
+ const supabase = createRequestSupabaseClient(c);
9665
9520
  const {
9666
- onChatMessage: onMessage,
9667
- isAgentThinking,
9668
- getAgentActivities,
9669
- cancelAgentRun,
9670
- regenerateAgentRun,
9671
- listAgentRuns
9672
- } = agent;
9673
- app.post(
9674
- "/threads/:id/agent-cancel",
9675
- async (c) => c.json({ cancelled: await cancelAgentRun?.(c.req.param("id")) ?? false })
9676
- );
9677
- app.post(
9678
- "/threads/:id/agent-regenerate",
9679
- async (c) => c.json({ started: await regenerateAgentRun?.(c.req.param("id")) ?? false })
9680
- );
9681
- app.get(
9682
- "/threads/:id/agent-status",
9683
- async (c) => c.json({
9684
- thinking: await isAgentThinking?.(c.req.param("id")) ?? false,
9685
- activities: await getAgentActivities?.(c.req.param("id")) ?? []
9686
- })
9687
- );
9688
- app.get("/agent-runs", async (c) => c.json({ threadIds: await listAgentRuns?.() ?? [] }));
9689
- app.get("/channels/:channelId/threads", async (c) => {
9690
- const channelId = c.req.param("channelId");
9691
- if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
9692
- const requestedLimit = Number(c.req.query("limit"));
9693
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
9694
- const before = c.req.query("before");
9695
- const sortColumn = c.req.query("sort") === "activity" ? "updated_at" : "created_at";
9696
- const statusParam = c.req.query("status");
9697
- const statusFilter = statusParam ? [
9698
- ...new Set(
9699
- statusParam.split(",").map(boardColumnParam).filter((s) => s !== null)
9700
- )
9701
- ] : [];
9702
- const supabase = getSupabaseClient();
9703
- let query = supabase.from("threads").select("*").eq("channel_id", channelId).is("archived_at", null).order(sortColumn, { ascending: false }).limit(limit);
9704
- if (before) query = query.lt(sortColumn, before);
9705
- if (statusFilter.length) {
9706
- const withoutNone = statusFilter.filter((s) => s !== NO_STATUS_COLUMN);
9707
- const includeNone = statusFilter.includes(NO_STATUS_COLUMN);
9708
- if (includeNone && withoutNone.length) {
9709
- query = query.or(`board_status.in.(${withoutNone.join(",")}),board_status.is.null`);
9710
- } else if (includeNone) {
9711
- query = query.is("board_status", null);
9712
- } else {
9713
- query = query.in("board_status", withoutNone);
9714
- }
9715
- }
9716
- const { data: threads, error: error2 } = await query;
9717
- if (error2) return c.json({ error: error2.message }, 500);
9718
- if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
9719
- const withPreview = await withPreviews(supabase, threads);
9720
- const nextCursor = threads.length === limit ? threads[threads.length - 1][sortColumn] : null;
9721
- return c.json({ threads: withPreview, nextCursor });
9722
- });
9723
- app.get("/channels/:channelId/board/:status", async (c) => {
9724
- const channelId = c.req.param("channelId");
9725
- if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
9726
- const column = boardColumnParam(c.req.param("status"));
9727
- if (!column) {
9728
- return c.json(
9729
- { error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
9730
- 400
9731
- );
9732
- }
9733
- const requestedLimit = Number(c.req.query("limit"));
9734
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
9735
- const before = c.req.query("before");
9736
- const supabase = getSupabaseClient();
9737
- let query = supabase.from("threads").select("*").eq("channel_id", channelId);
9738
- query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
9739
- query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
9740
- if (before) query = query.lt("updated_at", before);
9741
- const { data: threads, error: error2 } = await query;
9742
- if (error2) return c.json({ error: error2.message }, 500);
9743
- if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
9744
- const withPreview = await withPreviews(supabase, threads);
9745
- const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
9746
- return c.json({ threads: withPreview, nextCursor });
9747
- });
9748
- app.get("/board/:status", async (c) => {
9749
- const column = boardColumnParam(c.req.param("status"));
9750
- if (!column) {
9751
- return c.json(
9752
- { error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
9753
- 400
9754
- );
9755
- }
9756
- const requestedLimit = Number(c.req.query("limit"));
9757
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
9758
- const before = c.req.query("before");
9759
- const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
9760
- const supabase = getSupabaseClient();
9761
- let query = supabase.from("threads").select("*").in("channel_id", meta ? [...meta.keys()] : [...ownChannelIds(c)]);
9762
- query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
9763
- query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
9764
- if (before) query = query.lt("updated_at", before);
9765
- const { data: threads, error: error2 } = await query;
9766
- if (error2) return c.json({ error: error2.message }, 500);
9767
- if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
9768
- const withPreview = await withPreviews(supabase, threads);
9769
- const labelled = meta ? withPreview.map((t) => {
9770
- const channelMeta = meta.get(t.channel_id ?? "");
9771
- return {
9772
- ...t,
9773
- workspace: channelMeta?.workspace ?? null,
9774
- channel_name: channelMeta?.channelName ?? null,
9775
- agent_name: channelMeta?.agentName ?? null
9776
- };
9777
- }) : withPreview;
9778
- const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
9779
- return c.json({ threads: labelled, nextCursor });
9780
- });
9781
- app.get("/threads", async (c) => {
9782
- const requestedLimit = Number(c.req.query("limit"));
9783
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
9784
- const before = c.req.query("before");
9785
- const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
9786
- const relevantTo = c.req.query("relevantTo");
9787
- const supabase = getSupabaseClient();
9788
- const channelIds = meta ? [...meta.keys()] : [...ownChannelIds(c)];
9789
- let threads;
9790
- if (isNonEmptyString(relevantTo)) {
9791
- try {
9792
- threads = await relevantThreadsPage(supabase, relevantTo, channelIds, { before, limit });
9793
- } catch (err) {
9794
- return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
9795
- }
9796
- } else {
9797
- let query = supabase.from("threads").select("*").in("channel_id", channelIds).is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
9798
- if (before) query = query.lt("updated_at", before);
9799
- const { data, error: error2 } = await query;
9800
- if (error2) return c.json({ error: error2.message }, 500);
9801
- threads = data;
9802
- }
9803
- if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
9804
- const threadIds = threads.map((t) => t.id);
9805
- const chunkResults = await Promise.all(
9806
- chunk(threadIds, THREAD_CHUNK_SIZE).map(
9807
- (ids) => supabase.from("thread_replies").select("*").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
9808
- )
9521
+ data: { user }
9522
+ } = await supabase.auth.getUser();
9523
+ if (!user?.email) return c.json({ error: "not signed in" }, 401);
9524
+ const email = user.email.trim().toLowerCase();
9525
+ if (!platformAdminEmails().has(email)) {
9526
+ return c.json({ error: "platform operator access required" }, 403);
9527
+ }
9528
+ c.set("platformOperator", { userId: user.id, email });
9529
+ return next();
9530
+ };
9531
+
9532
+ // ../api/src/dashboard/admin-routes.ts
9533
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
9534
+ function createAdminRouter() {
9535
+ const admin = new Hono10();
9536
+ admin.use("*", requirePlatformOperator);
9537
+ admin.get("/workspaces", async (c) => {
9538
+ const summaries = await listWorkspaceSummaries();
9539
+ const workspaces = await Promise.all(
9540
+ summaries.map(async (w) => {
9541
+ const agentModel = cachedProfileConfig(w.slug)?.agentModel;
9542
+ const r2Bytes = await Promise.all(
9543
+ ["chat", SCRATCH_PREFIX].map((prefix) => sumR2PrefixBytes(`${prefix}/${w.slug}/`))
9544
+ ).then(([a, b]) => a + b).catch((err) => {
9545
+ console.error(`[admin] R2 usage for ${w.slug}:`, err.message);
9546
+ return null;
9547
+ });
9548
+ return {
9549
+ ...w,
9550
+ agent: agentModel ? { runner: runnerForModel(agentModel), model: agentModel } : null,
9551
+ r2Bytes,
9552
+ flyVolumeBytes: null
9553
+ };
9554
+ })
9809
9555
  );
9810
- const repliesByThread = /* @__PURE__ */ new Map();
9811
- for (const { data } of chunkResults) {
9812
- for (const r of data ?? []) {
9813
- const arr = repliesByThread.get(r.thread_id);
9814
- if (arr) arr.push(r);
9815
- else repliesByThread.set(r.thread_id, [r]);
9816
- }
9817
- }
9818
- const withReplies = threads.map((t) => {
9819
- const channelMeta = meta?.get(t.channel_id);
9820
- return {
9821
- ...t,
9822
- ...meta ? {
9823
- workspace: channelMeta?.workspace ?? null,
9824
- channel_name: channelMeta?.channelName ?? null,
9825
- agent_name: channelMeta?.agentName ?? null
9826
- } : {},
9827
- replies: (repliesByThread.get(t.id) ?? []).map((r) => ({
9828
- ...r,
9829
- body: slackMrkdwnToMarkdown(r.body)
9830
- }))
9831
- };
9832
- });
9833
- const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
9834
- return c.json({ threads: withReplies, nextCursor });
9556
+ return c.json({ workspaces });
9835
9557
  });
9836
- app.get("/threads/archived", async (c) => {
9837
- if (c.get("auth")?.role !== "admin") {
9838
- return c.json({ error: "only an admin can view archived topics" }, 403);
9839
- }
9840
- const requestedLimit = Number(c.req.query("limit"));
9841
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
9842
- const before = c.req.query("before");
9843
- const supabase = getSupabaseClient();
9844
- let query = supabase.from("threads").select("*").in("channel_id", [...ownChannelIds(c)]).not("archived_at", "is", null).order("archived_at", { ascending: false }).limit(limit);
9845
- if (before) query = query.lt("archived_at", before);
9846
- const { data: threads, error: error2 } = await query;
9847
- if (error2) return c.json({ error: error2.message }, 500);
9848
- if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
9849
- const withPreview = await withPreviews(supabase, threads);
9850
- const nextCursor = threads.length === limit ? threads[threads.length - 1].archived_at : null;
9851
- return c.json({ threads: withPreview, nextCursor });
9558
+ admin.get("/invites", async (c) => {
9559
+ const invites = await listSetupInvites();
9560
+ return c.json({ invites });
9852
9561
  });
9853
- app.get("/threads/:id", async (c) => {
9854
- const id = c.req.param("id");
9855
- const requestedLimit = Number(c.req.query("limit"));
9856
- const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, REPLIES_PAGE_SIZE_MAX) : REPLIES_PAGE_SIZE;
9857
- const before = c.req.query("before");
9858
- 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);
9859
- if (before) repliesQuery = repliesQuery.lt("created_at", before);
9860
- const [thread, replies, opening] = await Promise.all([
9861
- getSupabaseClient().from("threads").select("*").eq("id", id).single(),
9862
- repliesQuery,
9863
- // The opening message, fetched separately because the replies page
9864
- // above is newest-first: on a long thread the opener isn't in it. It
9865
- // is what the header falls back to on a thread with no title of its
9866
- // own (see threadCrumbLabel) — a title is never written on the
9867
- // thread's behalf, so an untitled row must still be able to show and
9868
- // rename a topic.
9869
- getSupabaseClient().from("thread_replies").select("body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(1)
9870
- ]);
9871
- if (thread.error) return c.json({ error: thread.error.message }, 404);
9872
- if (replies.error) return c.json({ error: replies.error.message }, 500);
9873
- const channelId = thread.data?.channel_id;
9874
- const meta = await globalChannelMeta(c);
9875
- const channelMeta = channelId ? meta.get(channelId) : void 0;
9876
- if (!channelId || !channelMeta) {
9877
- return c.json({ error: "not found" }, 404);
9562
+ admin.post("/setup-invites", async (c) => {
9563
+ const body = await c.req.json().catch(() => ({}));
9564
+ const ownerEmail = body.ownerEmail?.trim().toLowerCase();
9565
+ if (!ownerEmail || !EMAIL_RE.test(ownerEmail)) {
9566
+ return c.json({ error: "ownerEmail must be a valid email address" }, 400);
9878
9567
  }
9879
- const page2 = replies.data ?? [];
9880
- const nextCursor = page2.length === limit ? page2[page2.length - 1].created_at : null;
9881
- const formattedReplies = page2.reverse().map((r) => ({
9882
- ...r,
9883
- body: slackMrkdwnToMarkdown(r.body)
9884
- }));
9885
- const openingBody = (opening.data ?? [])[0]?.body;
9886
- return c.json({
9887
- ...thread.data,
9888
- // Always stamped (not just on `?scope=all` feeds, see the `/threads`
9889
- // route above) — the reply pane can be opened directly by id (global
9890
- // board, permalink) without the active workspace's channel map ever
9891
- // having loaded this thread's channel, so the header needs its own
9892
- // workspace/channel label rather than depending on that cache.
9893
- workspace: channelMeta.workspace,
9894
- channel_name: channelMeta.channelName,
9895
- agent_name: channelMeta.agentName,
9896
- preview: openingBody ? slackMrkdwnToMarkdown(openingBody) : null,
9897
- replies: formattedReplies,
9898
- nextCursor
9568
+ const plan = body.plan === "cloud" ? "cloud" : body.plan === "local" ? "local" : void 0;
9569
+ if (!plan) return c.json({ error: 'plan must be "local" or "cloud"' }, 400);
9570
+ const ownerName = body.ownerName?.trim().slice(0, 80) || null;
9571
+ const message = body.message?.trim() || null;
9572
+ if (message && message.length > INVITE_MESSAGE_MAX) {
9573
+ return c.json({ error: `message must be ${INVITE_MESSAGE_MAX} characters or fewer` }, 400);
9574
+ }
9575
+ const operator = c.get("platformOperator");
9576
+ const token = await issueSetupInvite({
9577
+ email: ownerEmail,
9578
+ ownerName,
9579
+ plan,
9580
+ message,
9581
+ invitedBy: operator.userId
9899
9582
  });
9900
- });
9901
- app.post("/threads/:id/translation/:lang", async (c) => {
9902
- const id = c.req.param("id");
9903
- const lang = canonicalLanguage(c.req.param("lang"));
9904
- if (!isTranslatableLanguage2(lang)) return c.json({ error: "unsupported language" }, 400);
9905
- const [thread, replies] = await Promise.all([
9906
- getSupabaseClient().from("threads").select("channel_id").eq("id", id).single(),
9907
- 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)
9908
- ]);
9909
- if (thread.error) return c.json({ error: "not found" }, 404);
9910
- if (replies.error) return c.json({ error: replies.error.message }, 500);
9911
- const channelId = thread.data?.channel_id;
9912
- if (!channelId || !(await globalChannelMeta(c)).has(channelId)) {
9913
- return c.json({ error: "not found" }, 404);
9583
+ const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
9584
+ inviteUrl.searchParams.set("token", token);
9585
+ if (body.sendEmail === false) {
9586
+ return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
9914
9587
  }
9915
- const messages = (replies.data ?? []).map(
9916
- (r) => ({ id: r.id, author: r.author, body: slackMrkdwnToMarkdown(r.body) })
9917
- );
9918
9588
  try {
9919
- return c.json({ lang, messages: await translateThread(id, lang, messages) });
9589
+ await deliverWorkspaceSetupInvite({
9590
+ email: ownerEmail,
9591
+ inviteLink: inviteUrl.toString(),
9592
+ message
9593
+ });
9920
9594
  } catch (err) {
9921
- const message = err instanceof Error ? err.message : "translation failed";
9922
- console.error(`Thread translation failed (${id} \u2192 ${lang}):`, err);
9923
- return c.json({ error: message }, 502);
9595
+ await deleteSetupInvite(token).catch(() => {
9596
+ });
9597
+ return c.json(
9598
+ {
9599
+ error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
9600
+ },
9601
+ 500
9602
+ );
9924
9603
  }
9604
+ return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
9925
9605
  });
9926
- app.patch("/threads/:id/board-status", async (c) => {
9927
- const id = c.req.param("id");
9928
- const input2 = await c.req.json().catch(() => null);
9929
- const value = input2?.board_status ?? null;
9930
- if (value !== null && !BOARD_STATUSES.includes(value)) {
9606
+ admin.delete("/setup-invites/:id", async (c) => {
9607
+ await revokeSetupInvite(c.req.param("id"));
9608
+ return c.json({ revoked: true });
9609
+ });
9610
+ admin.post("/setup-invites/:id/resend", async (c) => {
9611
+ const operator = c.get("platformOperator");
9612
+ let token;
9613
+ let invite;
9614
+ try {
9615
+ ({ token, invite } = await reissueSetupInvite(c.req.param("id")));
9616
+ } catch (err) {
9617
+ return c.json({ error: err instanceof Error ? err.message : "resend failed" }, 404);
9618
+ }
9619
+ const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
9620
+ inviteUrl.searchParams.set("token", token);
9621
+ if (c.req.query("email") === "false") {
9622
+ return c.json({ sent: false, inviteLink: inviteUrl.toString() });
9623
+ }
9624
+ try {
9625
+ await deliverWorkspaceSetupInvite({
9626
+ email: invite.email,
9627
+ inviteLink: inviteUrl.toString(),
9628
+ message: invite.message
9629
+ });
9630
+ } catch (err) {
9931
9631
  return c.json(
9932
- { error: `board_status must be null or one of: ${BOARD_STATUSES.join(", ")}` },
9933
- 400
9632
+ {
9633
+ error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
9634
+ },
9635
+ 500
9934
9636
  );
9935
9637
  }
9936
- if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
9937
- const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ board_status: value }).eq("id", id).select().single();
9938
- if (error2) return c.json({ error: error2.message }, 500);
9939
- return c.json(thread);
9638
+ return c.json({ sent: true, inviteLink: inviteUrl.toString() });
9940
9639
  });
9941
- app.patch("/threads/:id", async (c) => {
9942
- const id = c.req.param("id");
9943
- const input2 = await c.req.json().catch(() => null);
9944
- const raw = input2?.title;
9945
- if (raw !== null && !isNonEmptyString(raw)) {
9946
- return c.json({ errors: ["title must be a non-empty string, or null to clear it"] }, 400);
9640
+ return admin;
9641
+ }
9642
+
9643
+ // ../api/src/dashboard/agent-identities.ts
9644
+ var BOT_LABEL = "Workspace Agent";
9645
+ var BOT_DM_KEY = "claude";
9646
+
9647
+ // ../api/src/dashboard/data-api.ts
9648
+ import { existsSync as existsSync19 } from "fs";
9649
+ import sharp5 from "sharp";
9650
+
9651
+ // ../api/src/dashboard/automations-routes.ts
9652
+ import { Hono as Hono11 } from "hono";
9653
+ var TYPES2 = ["schedule", "trigger", "mention-trigger"];
9654
+ function isType(t) {
9655
+ return TYPES2.includes(t);
9656
+ }
9657
+ function sanitize(body) {
9658
+ const { created_by: _b, created_at: _a, ...rest } = body;
9659
+ void _b;
9660
+ void _a;
9661
+ return rest;
9662
+ }
9663
+ function statusFor(message) {
9664
+ if (/already exists/.test(message)) return 409;
9665
+ if (/not found/.test(message)) return 404;
9666
+ return 400;
9667
+ }
9668
+ function createAutomationWriteRouter() {
9669
+ const app = new Hono11();
9670
+ const manager = () => new AutomationManager();
9671
+ app.get("/:type/:name", (c) => {
9672
+ const item = manager().get(c.req.param("name"));
9673
+ if (!item) return c.json({ error: "not found" }, 404);
9674
+ return c.json(item);
9675
+ });
9676
+ app.post("/:type", async (c) => {
9677
+ const type = c.req.param("type");
9678
+ if (!isType(type)) return c.json({ error: "unknown type" }, 400);
9679
+ const body = sanitize(await c.req.json());
9680
+ const next = { ...body, created_at: (/* @__PURE__ */ new Date()).toISOString() };
9681
+ try {
9682
+ const m = manager();
9683
+ if (type === "schedule") await m.addSchedule(next);
9684
+ else if (type === "trigger") await m.addTrigger(next);
9685
+ else await m.addMentionTrigger(next);
9686
+ return c.json(next, 201);
9687
+ } catch (e) {
9688
+ const msg = e.message;
9689
+ const status = statusFor(msg);
9690
+ return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
9947
9691
  }
9948
- const title = raw === null ? null : raw.trim();
9949
- if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
9950
- const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ title }).eq("id", id).select().single();
9951
- if (error2) return c.json({ error: error2.message }, 500);
9952
- return c.json(thread);
9953
9692
  });
9954
- app.post("/threads/:id/suggest-title", async (c) => {
9955
- const id = c.req.param("id");
9956
- if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
9957
- const { data: thread } = await getSupabaseClient().from("threads").select("channel_id").eq("id", id).single();
9958
- if (!thread) return c.json({ error: "not found" }, 404);
9959
- 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 });
9960
- if (error2) return c.json({ error: error2.message }, 500);
9961
- const transcript = (rows ?? []).map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
9962
- const title = await labelThread(thread.channel_id, transcript, "thread");
9963
- if (!title) return c.json({ error: "could not generate a topic" }, 502);
9964
- return c.json({ title });
9693
+ app.put("/:type/:name", async (c) => {
9694
+ const type = c.req.param("type");
9695
+ if (!isType(type)) return c.json({ error: "unknown type" }, 400);
9696
+ const name = c.req.param("name");
9697
+ const body = sanitize(await c.req.json());
9698
+ try {
9699
+ const m = manager();
9700
+ const existing = m.get(name);
9701
+ const next = {
9702
+ ...body,
9703
+ created_at: existing?.created_at
9704
+ };
9705
+ await m.update(type, name, next);
9706
+ return c.json(next, 200);
9707
+ } catch (e) {
9708
+ const msg = e.message;
9709
+ const status = statusFor(msg);
9710
+ return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
9711
+ }
9965
9712
  });
9966
- app.patch("/replies/:replyId", async (c) => {
9967
- const replyId = c.req.param("replyId");
9968
- const input2 = await c.req.json().catch(() => null);
9969
- const author = input2?.author;
9970
- const body = input2?.body;
9971
- if (!isNonEmptyString(author) || !isNonEmptyString(body)) {
9972
- return c.json({ errors: ["author and body are required"] }, 400);
9713
+ app.delete("/:type/:name", async (c) => {
9714
+ try {
9715
+ await manager().remove(c.req.param("name"));
9716
+ return c.json({ ok: true }, 200);
9717
+ } catch (e) {
9718
+ return c.json({ error: e.message }, 404);
9973
9719
  }
9720
+ });
9721
+ return app;
9722
+ }
9723
+
9724
+ // ../api/src/dashboard/categories-routes.ts
9725
+ import { Hono as Hono12 } from "hono";
9726
+ function createCategoryWriteRouter() {
9727
+ const app = new Hono12();
9728
+ app.get("/", async (c) => {
9729
+ const { data, error: error2 } = await getSupabaseClient().from("categories").select("*").eq("profile", getProfile()).order("position", { ascending: true });
9730
+ if (error2) return c.json({ error: error2.message }, 500);
9731
+ return c.json({ categories: data ?? [] });
9732
+ });
9733
+ app.post("/", async (c) => {
9734
+ const body = await c.req.json().catch(() => ({}));
9735
+ const name = typeof body["name"] === "string" ? body["name"].trim() : "";
9736
+ if (!name) return c.json({ errors: ["name is required"] }, 400);
9974
9737
  const supabase = getSupabaseClient();
9975
- const { data: existing, error: fetchError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
9976
- if (fetchError) return c.json({ error: fetchError.message }, 404);
9977
- if (!await threadIsKnown(existing.thread_id, c)) return c.json({ error: "not found" }, 404);
9978
- if (existing.author !== author) {
9979
- return c.json({ error: "only the author can edit a message" }, 403);
9980
- }
9981
- const { data: reply, error: error2 } = await supabase.from("thread_replies").update({ body, edited_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", replyId).select().single();
9738
+ const { data: last } = await supabase.from("categories").select("position").eq("profile", getProfile()).order("position", { ascending: false }).limit(1);
9739
+ const nextPosition = (last?.[0]?.position ?? -1) + 1;
9740
+ const { data, error: error2 } = await supabase.from("categories").insert({ name, position: nextPosition, profile: getProfile() }).select().single();
9982
9741
  if (error2) return c.json({ error: error2.message }, 500);
9983
- if (isHumanAuthor(author)) {
9984
- 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();
9985
- if (latest2?.id === replyId) {
9986
- await Promise.resolve(regenerateAgentRun?.(existing.thread_id, replyId)).catch(() => {
9987
- });
9988
- }
9989
- }
9990
- return c.json(reply);
9742
+ return c.json(data, 201);
9991
9743
  });
9992
- app.post("/replies/:replyId/reactions", async (c) => {
9993
- const replyId = c.req.param("replyId");
9994
- const input2 = await c.req.json().catch(() => null);
9995
- const author = input2?.author;
9996
- const emoji = input2?.emoji;
9997
- if (!isNonEmptyString(author) || !isNonEmptyString(emoji)) {
9998
- return c.json({ errors: ["author and emoji are required"] }, 400);
9999
- }
9744
+ app.patch("/reorder", async (c) => {
9745
+ const body = await c.req.json().catch(() => ({}));
9746
+ const ids = Array.isArray(body["ids"]) ? body["ids"].filter((x) => typeof x === "string") : [];
9747
+ if (ids.length === 0) return c.json({ errors: ["ids is required"] }, 400);
10000
9748
  const supabase = getSupabaseClient();
10001
- const { data: reply, error: replyError } = await supabase.from("thread_replies").select("thread_id").eq("id", replyId).single();
10002
- if (replyError) return c.json({ error: "not found" }, 404);
10003
- if (!await threadIsKnown(reply.thread_id, c)) return c.json({ error: "not found" }, 404);
10004
- const { data: existing, error: existingError } = await supabase.from("reply_reactions").select("id").eq("reply_id", replyId).eq("emoji", emoji).eq("author", author).maybeSingle();
10005
- if (existingError) return c.json({ error: existingError.message }, 500);
10006
- if (existing) {
10007
- const { error: error2 } = await supabase.from("reply_reactions").delete().eq("id", existing.id);
10008
- if (error2) return c.json({ error: error2.message }, 500);
10009
- } else {
10010
- const { error: error2 } = await supabase.from("reply_reactions").insert({ reply_id: replyId, emoji, author });
10011
- if (error2 && error2.code !== "23505") return c.json({ error: error2.message }, 500);
10012
- }
10013
- const { data: reactions, error: reactionsError } = await supabase.from("reply_reactions").select("emoji, author").eq("reply_id", replyId);
10014
- if (reactionsError) return c.json({ error: reactionsError.message }, 500);
10015
- return c.json({ reactions: reactions ?? [] });
9749
+ const results = await Promise.all(
9750
+ ids.map(
9751
+ (id, position) => supabase.from("categories").update({ position, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile())
9752
+ )
9753
+ );
9754
+ const failed2 = results.find((r) => r.error);
9755
+ if (failed2?.error) return c.json({ error: failed2.error.message }, 500);
9756
+ return c.json({ ok: true });
10016
9757
  });
10017
- app.patch("/threads/:id/archive", async (c) => {
10018
- if (c.get("auth")?.role !== "admin") {
10019
- return c.json({ error: "only an admin can archive a topic" }, 403);
10020
- }
9758
+ app.put("/:id", async (c) => {
10021
9759
  const id = c.req.param("id");
10022
- const input2 = await c.req.json().catch(() => null);
10023
- const archived = input2?.archived;
10024
- if (typeof archived !== "boolean") {
10025
- return c.json({ errors: ["archived (boolean) is required"] }, 400);
10026
- }
10027
- if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
10028
- const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ archived_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", id).select().single();
10029
- if (error2) return c.json({ error: error2.message }, 500);
10030
- return c.json(thread);
9760
+ const body = await c.req.json().catch(() => ({}));
9761
+ const name = typeof body["name"] === "string" ? body["name"].trim() : "";
9762
+ if (!name) return c.json({ errors: ["name is required"] }, 400);
9763
+ const { data, error: error2 } = await getSupabaseClient().from("categories").update({ name, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile()).select().single();
9764
+ if (error2) return c.json({ error: error2.message }, 404);
9765
+ return c.json(data);
10031
9766
  });
10032
- app.post("/channels/:channelId/threads", async (c) => {
10033
- const channelId = c.req.param("channelId");
10034
- if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
10035
- const input2 = await c.req.json();
10036
- if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
10037
- return c.json({ errors: ["author and body are required"] }, 400);
10038
- }
10039
- const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10040
- const model = requestedModel(c, input2.model);
10041
- const effort = requestedEffort(c, input2.effort);
9767
+ app.delete("/:id", async (c) => {
9768
+ const id = c.req.param("id");
10042
9769
  const supabase = getSupabaseClient();
10043
- let thread;
10044
- let replayed = false;
10045
- const { data: inserted, error: threadError } = await supabase.from("threads").insert({
10046
- channel_id: channelId,
10047
- // A stored title means someone named the topic. A blank one is not a
10048
- // name, so it lands as null and the topic is derived from the
10049
- // opening message at display time (see threadCrumbLabel).
10050
- title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
10051
- author: input2.author,
10052
- client_key: clientKey2
10053
- }).select().single();
10054
- if (threadError) {
10055
- if (!clientKey2 || threadError.code !== "23505") {
10056
- return c.json({ error: threadError.message }, 500);
10057
- }
10058
- const { data: existing } = await supabase.from("threads").select("*").eq("client_key", clientKey2).single();
10059
- if (!existing) return c.json({ error: threadError.message }, 500);
10060
- thread = existing;
10061
- replayed = true;
10062
- } else {
10063
- thread = inserted;
10064
- }
10065
- let { data: reply } = replayed && clientKey2 ? await supabase.from("thread_replies").select("*").eq("client_key", clientKey2).single() : { data: null };
10066
- let replyCompleted = false;
10067
- if (!reply) {
10068
- const { data: insertedReply, error: replyError } = await supabase.from("thread_replies").insert({
10069
- thread_id: thread.id,
10070
- author: input2.author,
10071
- body: input2.body,
10072
- client_key: clientKey2,
10073
- model,
10074
- effort
10075
- }).select().single();
10076
- if (replyError) return c.json({ error: replyError.message }, 500);
10077
- reply = insertedReply;
10078
- replyCompleted = true;
10079
- }
10080
- if (!replayed || replyCompleted) {
10081
- onMessage?.({
10082
- threadId: thread.id,
10083
- replyId: reply.id,
10084
- author: input2.author,
10085
- body: input2.body,
10086
- // From the row, not `model`/`effort`: on the replay path the reply
10087
- // predates this request, so its own stored override is the source of
10088
- // truth.
10089
- model: reply["model"] ?? void 0,
10090
- effort: effortFromRow(reply["effort"])
10091
- });
10092
- }
10093
- if (!replayed && thread["title"] == null) {
10094
- void generateAutoTitle(thread.id, channelId, input2.body).catch(
10095
- (err) => console.error("Auto title failed:", err)
10096
- );
10097
- }
10098
- return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
10099
- });
10100
- app.post("/replies/:replyId/fork", async (c) => {
10101
- const replyId = c.req.param("replyId");
10102
- const input2 = await c.req.json().catch(() => null);
10103
- const author = input2?.author;
10104
- if (!isNonEmptyString(author)) {
10105
- return c.json({ errors: ["author is required"] }, 400);
10106
- }
10107
- const mode = input2?.mode === "focus" ? "focus" : "condense";
10108
- const supabase = getSupabaseClient();
10109
- const { data: source, error: sourceError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
10110
- if (sourceError) return c.json({ error: sourceError.message }, 404);
10111
- const { data: sourceThread, error: sourceThreadError } = await supabase.from("threads").select("*").eq("id", source.thread_id).single();
10112
- if (sourceThreadError) return c.json({ error: sourceThreadError.message }, 404);
10113
- if (!(await globalChannelMeta(c)).has(sourceThread.channel_id)) {
10114
- return c.json({ error: "not found" }, 404);
10115
- }
10116
- 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 });
10117
- const priorRows = priorReplies ?? [];
10118
- const sourceText = slackMrkdwnToMarkdown(source.body);
10119
- const FOCUS_CONTEXT_BELOW_CHARS = 280;
10120
- const inputRows = mode === "focus" ? priorRows.slice(sourceText.length < FOCUS_CONTEXT_BELOW_CHARS ? -2 : -1) : priorRows;
10121
- const transcript = inputRows.map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
10122
- const { title, body } = await buildForkTopic(transcript || sourceText, sourceText, mode);
10123
- const backlink = `/chat/${encodeURIComponent(sourceThread.channel_id)}/${sourceThread.id}#m-${replyId.slice(0, 8)}`;
10124
- const openingBody = `${body}
10125
-
10126
- > \u2442 Forked from [a message by ${source.author}](${backlink})`;
10127
- const { data: thread, error: threadError } = await supabase.from("threads").insert({ channel_id: sourceThread.channel_id, title, author }).select().single();
10128
- if (threadError) return c.json({ error: threadError.message }, 500);
10129
- const { data: reply, error: replyError } = await supabase.from("thread_replies").insert({ thread_id: thread.id, author, body: openingBody }).select().single();
10130
- if (replyError) return c.json({ error: replyError.message }, 500);
10131
- onMessage?.({
10132
- threadId: thread.id,
10133
- replyId: reply.id,
10134
- author,
10135
- body: openingBody
10136
- });
10137
- return c.json({ ...thread, replies: [reply] }, 201);
10138
- });
10139
- app.post("/threads/:id/replies", async (c) => {
10140
- const threadId = c.req.param("id");
10141
- const input2 = await c.req.json();
10142
- if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
10143
- return c.json({ errors: ["author and body are required"] }, 400);
10144
- }
10145
- const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10146
- const effort = requestedEffort(c, input2.effort);
10147
- if (!await threadIsKnown(threadId, c)) return c.json({ error: "not found" }, 404);
10148
- const model = requestedModel(c, input2.model) ?? await threadModel(threadId);
10149
- const { data, error: error2 } = await getSupabaseClient().from("thread_replies").insert({
10150
- thread_id: threadId,
10151
- author: input2.author,
10152
- body: input2.body,
10153
- client_key: clientKey2,
10154
- model,
10155
- effort
10156
- }).select().single();
10157
- if (error2) {
10158
- if (clientKey2 && error2.code === "23505") {
10159
- const { data: existing } = await getSupabaseClient().from("thread_replies").select("*").eq("client_key", clientKey2).single();
10160
- if (existing) return c.json(existing, 200);
10161
- }
10162
- return c.json({ error: error2.message }, 500);
10163
- }
10164
- onMessage?.({
10165
- threadId,
10166
- replyId: data.id,
10167
- author: input2.author,
10168
- body: input2.body,
10169
- model: data.model ?? void 0,
10170
- effort: effortFromRow(data.effort)
10171
- });
10172
- return c.json(data, 201);
9770
+ const { error: unsetError } = await supabase.from("channels").update({ category_id: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("category_id", id);
9771
+ if (unsetError) return c.json({ error: unsetError.message }, 500);
9772
+ await refreshChannelRegistry();
9773
+ const { error: error2 } = await supabase.from("categories").delete().eq("id", id).eq("profile", getProfile());
9774
+ if (error2) return c.json({ error: error2.message }, 404);
9775
+ return c.json({ ok: true });
10173
9776
  });
10174
9777
  return app;
10175
9778
  }
10176
9779
 
10177
- // ../api/src/dashboard/doc-shares-routes.ts
9780
+ // ../api/src/dashboard/chat-routes.ts
10178
9781
  import { Hono as Hono13 } from "hono";
10179
9782
 
10180
- // ../api/src/dashboard/doc-share-features.ts
10181
- import { DeleteObjectCommand as DeleteObjectCommand2 } from "@aws-sdk/client-s3";
10182
- var DEFAULT_FEATURES = {
10183
- audioEnabled: false,
10184
- askEnabled: true,
10185
- socialEnabled: false,
10186
- brandingEnabled: true,
10187
- translationEnabled: false,
10188
- translationLangs: []
10189
- };
10190
- var HTML_FEATURES = {
10191
- audioEnabled: false,
10192
- askEnabled: false,
10193
- socialEnabled: false,
10194
- brandingEnabled: false,
10195
- translationEnabled: false,
10196
- translationLangs: []
10197
- };
10198
- function resolveFeatures(requested, existing) {
10199
- const base = existing ?? DEFAULT_FEATURES;
10200
- return {
10201
- audioEnabled: requested?.audio ?? base.audioEnabled,
10202
- askEnabled: requested?.ask ?? base.askEnabled,
10203
- socialEnabled: requested?.social ?? base.socialEnabled,
10204
- brandingEnabled: requested?.branding ?? base.brandingEnabled,
10205
- translationEnabled: requested?.translation ?? base.translationEnabled,
10206
- // Replaced wholesale, not merged: the allowlist IS the answer to "which
10207
- // languages does this link offer", and a merge would make removing one
10208
- // impossible.
10209
- translationLangs: requested?.translationLangs ?? base.translationLangs
10210
- };
10211
- }
10212
- async function deleteShareAssets(keys) {
10213
- const client2 = getR2Client();
10214
- const bucket = getR2Bucket();
10215
- for (const key of keys) {
10216
- try {
10217
- await client2.send(new DeleteObjectCommand2({ Bucket: bucket, Key: key }));
10218
- } catch {
10219
- }
10220
- }
10221
- }
10222
- async function updateShareFeatures(share, requested) {
10223
- if (share.kind === "html") return share;
10224
- if (share.kind === "excalidraw") {
10225
- if (requested.branding === void 0) return share;
10226
- return updateDocShareFeatures(share.id, { ...share, brandingEnabled: requested.branding });
10227
- }
10228
- const features = resolveFeatures(requested, share);
10229
- const updated = await updateDocShareFeatures(share.id, features);
10230
- if (!updated) return null;
10231
- if (features.audioEnabled) void queueShareAudio(updated);
10232
- else if (share.audioEnabled) await dropShareAudio(share);
10233
- return updated;
10234
- }
9783
+ // ../api/src/dashboard/fork-summary.ts
9784
+ var GROQ_API_URL4 = "https://api.groq.com/openai/v1/chat/completions";
9785
+ var SUMMARY_MODEL = "openai/gpt-oss-120b";
9786
+ var MAX_INPUT_CHARS2 = 24e3;
9787
+ var TITLE_MAX_CHARS = 80;
9788
+ 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.
10235
9789
 
10236
- // ../api/src/dashboard/agent-bridge-data.ts
10237
- import { randomBytes as randomBytes5, randomUUID as randomUUID2, verify as verifyRaw } from "crypto";
10238
- import { WebSocket, WebSocketServer } from "ws";
9790
+ Rules:
9791
+ - 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.
9792
+ - 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.
9793
+ - 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.
9794
+ - 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.
9795
+ - Preserve essential code identifiers, file names, and URLs, but do not reproduce whole code blocks \u2014 describe what they do in one sentence instead.
9796
+ - No preamble like "Summary:" \u2014 start directly with the title line.`;
9797
+ 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.
10239
9798
 
10240
- // ../api/src/dashboard/host-identity.ts
10241
- import { createPrivateKey as createPrivateKey2, createPublicKey as createPublicKey2, generateKeyPairSync as generateKeyPairSync2 } from "crypto";
10242
- import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync16, writeFileSync as writeFileSync9 } from "fs";
10243
- import { dirname as dirname5, join as join17 } from "path";
10244
- function hostPrivateKeyPath() {
10245
- return process.env["FLYSPACE_HOST_KEY_PATH"] || join17(paths.BASE_CONFIG_DIR, "host.key");
10246
- }
10247
- function loadHostPrivateKey(path = hostPrivateKeyPath()) {
10248
- if (!existsSync17(path)) {
10249
- throw new Error(
10250
- `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.`
10251
- );
10252
- }
10253
- return createPrivateKey2(readFileSync16(path, "utf8"));
10254
- }
9799
+ Output ONE line only: the title. Nothing else \u2014 no summary, no explanation, no blank line.
10255
9800
 
10256
- // ../api/src/store/device-store.ts
10257
- import { createHash as createHash8, createPublicKey as createPublicKey3, randomBytes as randomBytes4, randomInt } from "crypto";
10258
- var DEVICE_CODE_TTL_MS = 10 * 6e4;
10259
- var ACCESS_TOKEN_TTL_MS = 15 * 6e4;
10260
- var CHALLENGE_TTL_MS = 2 * 6e4;
10261
- var POLL_INTERVAL_S = 5;
10262
- var sha256 = (s) => createHash8("sha256").update(s).digest("hex");
10263
- function parseEd25519PublicKey(b64) {
10264
- if (typeof b64 !== "string" || b64.length > 200) return null;
10265
- try {
10266
- const key = createPublicKey3({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
10267
- return key.asymmetricKeyType === "ed25519" ? key : null;
10268
- } catch {
10269
- return null;
10270
- }
9801
+ Shape:
9802
+ - A short noun phrase naming the subject, like a filed ticket's title. Max ${TITLE_MAX_CHARS} characters, and shorter is better.
9803
+ - Not a sentence copied from the message, not a quote, not Markdown. No bold, no backticks, no quotes, no trailing punctuation.
9804
+
9805
+ Content:
9806
+ - 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.
9807
+ - 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.
9808
+ - 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.
9809
+ - 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.
9810
+
9811
+ Example \u2014 a message that works out why an admin could not sign in, and closes by suggesting turning email confirmation off:
9812
+ admin \u5E33\u865F\u88AB email \u9A57\u8B49\u4FE1\u5361\u4F4F`;
9813
+ function clampTitle(title) {
9814
+ return title.length > TITLE_MAX_CHARS ? `${title.slice(0, TITLE_MAX_CHARS - 1)}\u2026` : title;
10271
9815
  }
10272
- var USER_CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ";
10273
- function generateUserCode() {
10274
- const pick = () => Array.from({ length: 4 }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
10275
- return `${pick()}-${pick()}`;
9816
+ function deriveTitle(body) {
9817
+ const line = body.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "Forked message";
9818
+ return clampTitle(line);
10276
9819
  }
10277
- function normalizeUserCode(input2) {
10278
- const raw = input2.toUpperCase().replace(/[\s-]/g, "");
10279
- if (raw.length !== 8 || [...raw].some((ch) => !USER_CODE_ALPHABET.includes(ch))) return null;
10280
- return `${raw.slice(0, 4)}-${raw.slice(4)}`;
9820
+ function cleanTitleLine(raw) {
9821
+ const line = raw.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
9822
+ return clampTitle(
9823
+ line.replace(/^#{1,6}\s*/, "").replace(/^[*_`"'\u201c\u2018\u300c\u300e]+|[*_`"'\u201d\u2019\u300d\u300f]+$/g, "").trim()
9824
+ );
10281
9825
  }
10282
- function toDevice(r) {
10283
- return {
10284
- id: String(r["id"]),
10285
- userId: String(r["user_id"]),
10286
- name: String(r["name"]),
10287
- platform: r["platform"] ?? null,
10288
- publicKey: String(r["public_key"]),
10289
- createdAt: String(r["created_at"]),
10290
- lastSeenAt: r["last_seen_at"] ?? null,
10291
- revokedAt: r["revoked_at"] ?? null
10292
- };
9826
+ function fallbackTopic(body) {
9827
+ return { title: deriveTitle(body), body };
10293
9828
  }
10294
- function toPending(r) {
9829
+ function parseTopic(raw, original) {
9830
+ const text = raw.trim();
9831
+ const breakAt = text.indexOf("\n");
9832
+ if (breakAt === -1) return { title: deriveTitle(text), body: text };
9833
+ const title = text.slice(0, breakAt).trim();
9834
+ const body = text.slice(breakAt + 1).trim();
9835
+ if (!title || !body) return fallbackTopic(original);
10295
9836
  return {
10296
- id: String(r["id"]),
10297
- userCode: String(r["user_code"]),
10298
- publicKey: String(r["public_key"]),
10299
- name: String(r["name"]),
10300
- platform: r["platform"] ?? null,
10301
- requesterIp: r["requester_ip"] ?? null,
10302
- approvedBy: r["approved_by"] ?? null,
10303
- workspaceIds: r["workspace_ids"] ?? [],
10304
- deniedAt: r["denied_at"] ?? null,
10305
- lastPolledAt: r["last_polled_at"] ?? null,
10306
- expiresAt: String(r["expires_at"]),
10307
- createdAt: String(r["created_at"])
9837
+ title: clampTitle(title),
9838
+ body
10308
9839
  };
10309
9840
  }
10310
- function fail(what, error2) {
10311
- throw new Error(`${what}: ${error2.message}`);
10312
- }
10313
- async function createDeviceCode(input2) {
10314
- const db = getSupabaseClient();
10315
- await db.from("device_codes").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
10316
- const deviceCode = randomBytes4(32).toString("hex");
10317
- for (let attempt = 0; attempt < 3; attempt++) {
10318
- const userCode = generateUserCode();
10319
- const { error: error2 } = await db.from("device_codes").insert({
10320
- device_code_hash: sha256(deviceCode),
10321
- user_code: userCode,
10322
- public_key: input2.publicKey,
10323
- name: input2.name,
10324
- platform: input2.platform,
10325
- requester_ip: input2.requesterIp,
10326
- expires_at: new Date(Date.now() + DEVICE_CODE_TTL_MS).toISOString()
9841
+ async function buildForkTopic(transcript, fallbackText, mode = "condense") {
9842
+ const input2 = transcript.length > MAX_INPUT_CHARS2 ? transcript.slice(-MAX_INPUT_CHARS2) : transcript;
9843
+ const fallback = fallbackText ?? transcript;
9844
+ const focus = mode === "focus";
9845
+ const apiKey = process.env["GROQ_API_KEY"];
9846
+ if (!apiKey) return fallbackTopic(fallback);
9847
+ try {
9848
+ const res = await fetch(GROQ_API_URL4, {
9849
+ method: "POST",
9850
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
9851
+ body: JSON.stringify({
9852
+ model: SUMMARY_MODEL,
9853
+ temperature: 0.2,
9854
+ // A title has to sort the message's central claim from the next-step
9855
+ // paragraph it often ends with; 'low' reliably grabs the latter.
9856
+ reasoning_effort: focus ? "medium" : "low",
9857
+ // A title is one short line, but focus mode reasons before answering
9858
+ // and the budget covers reasoning tokens too — too small a cap here
9859
+ // truncates the title mid-word rather than failing.
9860
+ max_tokens: focus ? 512 : 1024,
9861
+ messages: [
9862
+ {
9863
+ role: "system",
9864
+ content: focus ? FOCUS_TITLE_SYSTEM_PROMPT : CONDENSE_SYSTEM_PROMPT
9865
+ },
9866
+ { role: "user", content: input2 }
9867
+ ]
9868
+ })
10327
9869
  });
10328
- if (!error2) return { deviceCode, userCode };
10329
- if (error2.code !== "23505") fail("createDeviceCode", error2);
9870
+ if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
9871
+ const data = await res.json();
9872
+ const raw = data.choices?.[0]?.message?.content?.trim();
9873
+ if (!raw) throw new Error("Groq returned an empty summary");
9874
+ if (focus) return { title: cleanTitleLine(raw) || deriveTitle(fallback), body: fallback };
9875
+ return parseTopic(raw, fallback);
9876
+ } catch (err) {
9877
+ console.error("Fork summary failed, using original text:", err);
9878
+ return fallbackTopic(fallback);
10330
9879
  }
10331
- throw new Error("createDeviceCode: could not allocate a unique user code");
10332
9880
  }
10333
- async function getDeviceCodeBySecret(deviceCode) {
10334
- const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("device_code_hash", sha256(deviceCode)).maybeSingle();
10335
- if (error2) fail("getDeviceCodeBySecret", error2);
10336
- return data ? toPending(data) : null;
9881
+
9882
+ // ../api/src/dashboard/slack-format.ts
9883
+ import { nameToEmoji } from "gemoji";
9884
+ var SLACK_EMOJI_ALIASES = {
9885
+ double_vertical_bar: "\u23F8\uFE0F"
9886
+ };
9887
+ function emojifyShortcode(code) {
9888
+ const name = code.slice(1, -1);
9889
+ return nameToEmoji[name] ?? SLACK_EMOJI_ALIASES[name] ?? code;
10337
9890
  }
10338
- async function getDeviceCodeByUserCode(userCode) {
10339
- const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("user_code", userCode).maybeSingle();
10340
- if (error2) fail("getDeviceCodeByUserCode", error2);
10341
- return data ? toPending(data) : null;
9891
+ function resolveSlackTokens(text) {
9892
+ 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);
10342
9893
  }
10343
- async function markPolled(id) {
10344
- const { error: error2 } = await getSupabaseClient().from("device_codes").update({ last_polled_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id);
10345
- if (error2) fail("markPolled", error2);
9894
+ function slackMrkdwnToMarkdown(text) {
9895
+ 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, "&");
10346
9896
  }
10347
- async function decideDeviceCode(id, userId, decision) {
10348
- const { data, error: error2 } = await getSupabaseClient().from("device_codes").update(
10349
- decision.approve ? { approved_by: userId, workspace_ids: decision.workspaceIds } : { denied_at: (/* @__PURE__ */ new Date()).toISOString() }
10350
- ).eq("id", id).is("approved_by", null).is("denied_at", null).select("id");
10351
- if (error2) fail("decideDeviceCode", error2);
10352
- return (data ?? []).length > 0;
9897
+
9898
+ // ../api/src/dashboard/thread-relevance.ts
9899
+ async function relevantThreadsPage(supabase, handle, channelIds, opts) {
9900
+ const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9901
+ const { data, error: error2 } = await supabase.rpc("relevant_threads", {
9902
+ handle,
9903
+ mention_pattern: `@${escaped}\\M`,
9904
+ channel_ids: channelIds,
9905
+ before: opts.before ?? null,
9906
+ page_limit: opts.limit
9907
+ });
9908
+ if (error2) throw new Error(`relevant_threads: ${error2.message}`);
9909
+ return data ?? [];
10353
9910
  }
10354
- async function redeemDeviceCode(pending2) {
10355
- const db = getSupabaseClient();
10356
- const { data: claimed, error: claimError } = await db.from("device_codes").delete().eq("id", pending2.id).select("id");
10357
- if (claimError) fail("redeemDeviceCode", claimError);
10358
- if (!claimed?.length) return null;
10359
- const existing = pending2.approvedBy ? await findDeviceByKey(pending2.publicKey, pending2.approvedBy) : null;
10360
- 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({
10361
- user_id: pending2.approvedBy,
10362
- name: pending2.name,
10363
- platform: pending2.platform,
10364
- public_key: pending2.publicKey
10365
- }).select("*").single();
10366
- if (error2) fail("redeemDeviceCode", error2);
10367
- const device = toDevice(data);
10368
- if (pending2.workspaceIds.length) {
10369
- const { error: bindError } = await db.from("device_workspaces").upsert(
10370
- pending2.workspaceIds.map((workspaceId) => ({
10371
- device_id: device.id,
10372
- workspace_id: workspaceId,
10373
- bound_by: pending2.approvedBy
10374
- })),
10375
- { onConflict: "device_id,workspace_id", ignoreDuplicates: true }
10376
- );
10377
- if (bindError) fail("redeemDeviceCode", bindError);
9911
+
9912
+ // ../api/src/dashboard/thread-translate.ts
9913
+ import { createHash as createHash8 } from "crypto";
9914
+
9915
+ // ../api/src/store/thread-translation-store.ts
9916
+ async function getThreadTranslations(lang, keys) {
9917
+ const found = /* @__PURE__ */ new Map();
9918
+ if (keys.length === 0) return found;
9919
+ const clauses = keys.map((k) => `and(reply_id.eq.${k.replyId},body_hash.eq.${k.bodyHash})`).join(",");
9920
+ const { data, error: error2 } = await getSupabaseClient().from("thread_reply_translations").select("reply_id, body, passthrough").eq("lang", lang).or(clauses);
9921
+ if (error2) throw new Error(`getThreadTranslations(${lang}): ${error2.message}`);
9922
+ for (const row of data ?? []) {
9923
+ const replyId = row["reply_id"];
9924
+ found.set(replyId, {
9925
+ replyId,
9926
+ body: row["body"],
9927
+ passthrough: row["passthrough"]
9928
+ });
10378
9929
  }
10379
- return device;
9930
+ return found;
10380
9931
  }
10381
- async function getDevice(id) {
10382
- const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("id", id).maybeSingle();
10383
- if (error2) fail("getDevice", error2);
10384
- return data ? toDevice(data) : null;
9932
+ async function putThreadTranslations(lang, rows) {
9933
+ if (rows.length === 0) return;
9934
+ const { error: error2 } = await getSupabaseClient().from("thread_reply_translations").upsert(
9935
+ rows.map((r) => ({
9936
+ reply_id: r.replyId,
9937
+ lang,
9938
+ body_hash: r.bodyHash,
9939
+ body: r.body,
9940
+ passthrough: r.passthrough
9941
+ })),
9942
+ { onConflict: "reply_id,lang,body_hash" }
9943
+ );
9944
+ if (error2) throw new Error(`putThreadTranslations(${lang}): ${error2.message}`);
10385
9945
  }
10386
- async function findDeviceByKey(publicKey, userId) {
10387
- 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();
10388
- if (error2) fail("findDeviceByKey", error2);
10389
- return data ? toDevice(data) : null;
9946
+
9947
+ // ../api/src/dashboard/thread-translate.ts
9948
+ var GROQ_API_URL5 = "https://api.groq.com/openai/v1/chat/completions";
9949
+ var TRANSLATION_MODEL2 = "openai/gpt-oss-120b";
9950
+ var RECIPE_VERSION3 = 1;
9951
+ var BATCH_CHAR_BUDGET = 16e3;
9952
+ var CONTEXT_MESSAGES = 4;
9953
+ var MAX_MESSAGE_CHARS = 12e3;
9954
+ var MAX_OUTPUT_TOKENS2 = 32768;
9955
+ var BY_CODE2 = new Map(TRANSLATION_LANGUAGES.map((l) => [l.code, l]));
9956
+ function isTranslatableLanguage2(lang) {
9957
+ return BY_CODE2.has(lang);
10390
9958
  }
10391
- async function issueAccessToken(deviceId) {
10392
- const token = `fsd_${randomBytes4(32).toString("hex")}`;
10393
- const { error: error2 } = await getSupabaseClient().from("devices").update({
10394
- access_token_hash: sha256(token),
10395
- access_token_expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_MS).toISOString(),
10396
- last_seen_at: (/* @__PURE__ */ new Date()).toISOString()
10397
- }).eq("id", deviceId);
10398
- if (error2) fail("issueAccessToken", error2);
10399
- return { token, expiresInS: ACCESS_TOKEN_TTL_MS / 1e3 };
9959
+ function translationHash2(author, body) {
9960
+ return createHash8("sha256").update(`${RECIPE_VERSION3}
9961
+ ${author}
9962
+ ${body}`).digest("hex");
10400
9963
  }
10401
- async function deviceForAccessToken(token) {
10402
- if (!token.startsWith("fsd_")) return null;
10403
- 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();
10404
- if (error2) fail("deviceForAccessToken", error2);
10405
- return data ? toDevice(data) : null;
9964
+ function systemPrompt2(languageName2) {
9965
+ return `You translate messages from a group chat into ${languageName2}. You are a translator, not an editor, a summarizer, or a participant.
9966
+
9967
+ You receive a JSON object with two keys:
9968
+ - "context": earlier messages, for reference only. NEVER translate these and never mention them in your output.
9969
+ - "translate": the messages to translate, each with an "n" and a "body".
9970
+
9971
+ Reply with ONLY a JSON object of this exact shape, one entry per message in "translate", in the same order:
9972
+ {"messages":[{"n":1,"body":"...","already_target":false}]}
9973
+
9974
+ Rules:
9975
+ - "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.
9976
+ - 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.
9977
+ - 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.
9978
+ - 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.
9979
+ - 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.
9980
+ - Keep established technical terms in English where a fluent speaker of ${languageName2} would keep them in English rather than reaching for a translated coinage.
9981
+ - 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.
9982
+ - Never add commentary, notes about your choices, apologies, or an explanation of anything you could not translate.`;
10406
9983
  }
10407
- async function revokeDevice2(id) {
10408
- const { error: error2 } = await getSupabaseClient().from("devices").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString(), access_token_hash: null }).eq("id", id);
10409
- if (error2) fail("revokeDevice", error2);
9984
+ function normalizeHyphens2(text) {
9985
+ return text.replace(/‐|‑/g, "-");
10410
9986
  }
10411
- async function createChallenge(deviceId) {
10412
- const db = getSupabaseClient();
10413
- await db.from("device_challenges").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
10414
- const nonce = randomBytes4(32).toString("base64url");
10415
- const { error: error2 } = await db.from("device_challenges").insert({
10416
- nonce,
10417
- device_id: deviceId,
10418
- expires_at: new Date(Date.now() + CHALLENGE_TTL_MS).toISOString()
9987
+ function clip(body) {
9988
+ return body.length > MAX_MESSAGE_CHARS ? body.slice(0, MAX_MESSAGE_CHARS) : body;
9989
+ }
9990
+ function batchMessages(messages) {
9991
+ const batches = [];
9992
+ let current = [];
9993
+ let size = 0;
9994
+ for (const m of messages) {
9995
+ const len = Math.min(m.body.length, MAX_MESSAGE_CHARS);
9996
+ if (current.length > 0 && size + len > BATCH_CHAR_BUDGET) {
9997
+ batches.push(current);
9998
+ current = [];
9999
+ size = 0;
10000
+ }
10001
+ current.push(m);
10002
+ size += len;
10003
+ }
10004
+ if (current.length > 0) batches.push(current);
10005
+ return batches;
10006
+ }
10007
+ async function translateBatch(batch, context, lang) {
10008
+ const language = BY_CODE2.get(lang);
10009
+ if (!language) throw new Error(`unsupported translation language: ${lang}`);
10010
+ const apiKey = process.env["GROQ_API_KEY"];
10011
+ if (!apiKey) throw new Error("GROQ_API_KEY is not configured");
10012
+ const payload = {
10013
+ context: context.map((m) => ({ author: m.author, body: clip(m.body) })),
10014
+ translate: batch.map((m, i) => ({ n: i + 1, author: m.author, body: clip(m.body) }))
10015
+ };
10016
+ const res = await fetch(GROQ_API_URL5, {
10017
+ method: "POST",
10018
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
10019
+ body: JSON.stringify({
10020
+ model: TRANSLATION_MODEL2,
10021
+ temperature: 0,
10022
+ max_tokens: MAX_OUTPUT_TOKENS2,
10023
+ response_format: { type: "json_object" },
10024
+ messages: [
10025
+ { role: "system", content: systemPrompt2(language.name) },
10026
+ { role: "user", content: JSON.stringify(payload) }
10027
+ ]
10028
+ })
10029
+ });
10030
+ if (!res.ok) throw new Error(`translation model returned ${res.status}`);
10031
+ const data = await res.json();
10032
+ const choice = data.choices?.[0];
10033
+ if (choice?.finish_reason === "length") {
10034
+ throw new Error("the translation ran past the model output limit");
10035
+ }
10036
+ const raw = choice?.message?.content?.trim() ?? "";
10037
+ if (!raw) throw new Error("the model returned an empty translation");
10038
+ let parsed;
10039
+ try {
10040
+ parsed = JSON.parse(raw);
10041
+ } catch {
10042
+ throw new Error("the model returned unparseable JSON");
10043
+ }
10044
+ const byNumber = /* @__PURE__ */ new Map();
10045
+ for (const entry of parsed.messages ?? []) {
10046
+ if (typeof entry?.n === "number") byNumber.set(entry.n, entry);
10047
+ }
10048
+ return batch.map((m, i) => {
10049
+ const entry = byNumber.get(i + 1);
10050
+ const body = typeof entry?.body === "string" ? entry.body.trim() : "";
10051
+ if (!body) return { id: m.id, body: m.body, passthrough: true };
10052
+ return {
10053
+ id: m.id,
10054
+ body: normalizeHyphens2(body),
10055
+ passthrough: entry?.already_target === true
10056
+ };
10057
+ });
10058
+ }
10059
+ var inFlight4 = /* @__PURE__ */ new Map();
10060
+ async function translateThread(threadId, lang, messages) {
10061
+ if (!BY_CODE2.has(lang)) throw new Error(`unsupported translation language: ${lang}`);
10062
+ if (messages.length === 0) return [];
10063
+ const shape = createHash8("sha256").update(messages.map((m) => translationHash2(m.author, m.body)).join("\n")).digest("hex");
10064
+ const key = `${threadId}:${lang}:${shape}`;
10065
+ const running2 = inFlight4.get(key);
10066
+ if (running2) return running2;
10067
+ const job = (async () => {
10068
+ const hashes = messages.map((m) => ({
10069
+ replyId: m.id,
10070
+ bodyHash: translationHash2(m.author, m.body)
10071
+ }));
10072
+ const cached = await getThreadTranslations(lang, hashes);
10073
+ const missing = messages.filter((m) => !cached.has(m.id));
10074
+ const fresh = /* @__PURE__ */ new Map();
10075
+ if (missing.length > 0) {
10076
+ const index = new Map(messages.map((m, i) => [m.id, i]));
10077
+ const batches = batchMessages(missing);
10078
+ const results = await Promise.all(
10079
+ batches.map((batch) => {
10080
+ const first = index.get(batch[0].id) ?? 0;
10081
+ const context = messages.slice(Math.max(0, first - CONTEXT_MESSAGES), first);
10082
+ return translateBatch(batch, context, lang);
10083
+ })
10084
+ );
10085
+ for (const row of results.flat()) fresh.set(row.id, row);
10086
+ const hashById = new Map(hashes.map((h) => [h.replyId, h.bodyHash]));
10087
+ await putThreadTranslations(
10088
+ lang,
10089
+ [...fresh.values()].map((r) => ({
10090
+ replyId: r.id,
10091
+ bodyHash: hashById.get(r.id),
10092
+ body: r.body,
10093
+ passthrough: r.passthrough
10094
+ }))
10095
+ );
10096
+ }
10097
+ return messages.map((m) => {
10098
+ const hit = cached.get(m.id);
10099
+ if (hit) return { id: m.id, body: hit.body, passthrough: hit.passthrough };
10100
+ return fresh.get(m.id) ?? { id: m.id, body: m.body, passthrough: true };
10101
+ });
10102
+ })().finally(() => inFlight4.delete(key));
10103
+ inFlight4.set(key, job);
10104
+ return job;
10105
+ }
10106
+
10107
+ // ../api/src/dashboard/chat-routes.ts
10108
+ function isNonEmptyString(v) {
10109
+ return typeof v === "string" && v.trim().length > 0;
10110
+ }
10111
+ function sanitizeModel(v) {
10112
+ if (typeof v !== "string") return null;
10113
+ const model = v.trim().toLowerCase();
10114
+ return isAllowedModel(model) ? model : null;
10115
+ }
10116
+ async function threadModel(threadId) {
10117
+ 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();
10118
+ return sanitizeModel(data?.model);
10119
+ }
10120
+ function requestedModel(c, model) {
10121
+ return c.get("auth")?.role === "admin" ? sanitizeModel(model) : null;
10122
+ }
10123
+ function sanitizeEffort(v) {
10124
+ if (typeof v !== "string") return null;
10125
+ const effort = v.trim().toLowerCase();
10126
+ return isEffort(effort) ? effort : null;
10127
+ }
10128
+ function requestedEffort(c, effort) {
10129
+ return c.get("auth")?.role === "admin" ? sanitizeEffort(effort) : null;
10130
+ }
10131
+ function effortFromRow(v) {
10132
+ return typeof v === "string" && isEffort(v) ? v : void 0;
10133
+ }
10134
+ var BOARD_STATUSES = [
10135
+ "backlog",
10136
+ "todo",
10137
+ "in_progress",
10138
+ "in_review",
10139
+ "done",
10140
+ "cancelled"
10141
+ ];
10142
+ var NO_STATUS_COLUMN = "none";
10143
+ function boardColumnParam(status) {
10144
+ if (status === NO_STATUS_COLUMN) return NO_STATUS_COLUMN;
10145
+ return BOARD_STATUSES.includes(status) ? status : null;
10146
+ }
10147
+ var THREAD_CHUNK_SIZE = 25;
10148
+ var THREADS_PAGE_SIZE = 30;
10149
+ var THREADS_PAGE_SIZE_MAX = 100;
10150
+ var REPLIES_PAGE_SIZE = 50;
10151
+ var REPLIES_PAGE_SIZE_MAX = 200;
10152
+ var TRANSLATABLE_REPLIES_MAX = 400;
10153
+ var BOARD_PAGE_SIZE = 50;
10154
+ var BOARD_PAGE_SIZE_MAX = 100;
10155
+ function ownChannelIds(c) {
10156
+ const viewer = c.get("auth");
10157
+ return new Set(
10158
+ getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, viewer)).flatMap((ch) => ch.externalChannelId ? [ch.id, ch.externalChannelId] : [ch.id])
10159
+ );
10160
+ }
10161
+ async function globalChannelMeta(c) {
10162
+ const viewer = c.get("auth");
10163
+ const map = /* @__PURE__ */ new Map();
10164
+ const channels = getChannelRegistry();
10165
+ let workspaceIdBySlug = /* @__PURE__ */ new Map();
10166
+ let roleByWorkspaceId = /* @__PURE__ */ new Map();
10167
+ if (viewer?.email && channels.length > 0) {
10168
+ const [workspaces, memberships] = await Promise.all([
10169
+ listWorkspaces(),
10170
+ listMembershipsByEmail(viewer.email)
10171
+ ]);
10172
+ workspaceIdBySlug = new Map(workspaces.map((w) => [w.slug, w.id]));
10173
+ roleByWorkspaceId = new Map(memberships.map((m) => [m.workspaceId, m.role]));
10174
+ }
10175
+ for (const ch of channels) {
10176
+ const workspaceId = workspaceIdBySlug.get(ch.profile);
10177
+ const role = workspaceId ? roleByWorkspaceId.get(workspaceId) : void 0;
10178
+ if (!canSeeChannel(ch, { email: viewer?.email, role })) continue;
10179
+ const meta = { workspace: ch.profile, channelName: ch.name, agentName: ch.agentName };
10180
+ map.set(ch.id, meta);
10181
+ if (ch.externalChannelId) map.set(ch.externalChannelId, meta);
10182
+ }
10183
+ return map;
10184
+ }
10185
+ async function threadIsKnown(threadId, c) {
10186
+ const { data } = await getSupabaseClient().from("threads").select("channel_id").eq("id", threadId).single();
10187
+ const channelId = data?.channel_id;
10188
+ return !!channelId && (await globalChannelMeta(c)).has(channelId);
10189
+ }
10190
+ function chunk(items, size) {
10191
+ const out = [];
10192
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
10193
+ return out;
10194
+ }
10195
+ async function withPreviews(supabase, threads) {
10196
+ const chunkResults = await Promise.all(
10197
+ chunk(
10198
+ threads.map((t) => t.id),
10199
+ THREAD_CHUNK_SIZE
10200
+ ).map(
10201
+ (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 })
10202
+ )
10203
+ );
10204
+ const previewByThread = /* @__PURE__ */ new Map();
10205
+ for (const { data } of chunkResults) {
10206
+ for (const r of data ?? []) {
10207
+ const entry = previewByThread.get(r.thread_id);
10208
+ if (entry) {
10209
+ entry.count += 1;
10210
+ entry.lastAuthor = r.author;
10211
+ entry.participants.delete(r.author);
10212
+ entry.participants.set(r.author, r.external_user_id ?? null);
10213
+ } else
10214
+ previewByThread.set(r.thread_id, {
10215
+ count: 1,
10216
+ firstBody: r.body,
10217
+ lastAuthor: r.author,
10218
+ participants: /* @__PURE__ */ new Map()
10219
+ });
10220
+ }
10221
+ }
10222
+ return threads.map((t) => {
10223
+ const entry = previewByThread.get(t.id);
10224
+ return {
10225
+ ...t,
10226
+ reply_count: Math.max(0, (entry?.count ?? 0) - 1),
10227
+ preview: entry?.firstBody ? slackMrkdwnToMarkdown(entry.firstBody) : null,
10228
+ // Who spoke last, counting only replies AFTER the opening message —
10229
+ // the reader's own handle here means there is nothing new for them to
10230
+ // read, on this browser or any other (see web/src/lib/thread-unread).
10231
+ last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
10232
+ // Everyone who replied, most recent first, for the row's avatar stack.
10233
+ participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id }))
10234
+ };
10235
+ });
10236
+ }
10237
+ async function labelThread(channelId, body, scope) {
10238
+ const channel = getProfileChannelRegistry().find(
10239
+ (ch) => ch.id === channelId || ch.externalChannelId === channelId
10240
+ );
10241
+ if (!channel) return null;
10242
+ const supabase = getSupabaseClient();
10243
+ const { data: recent } = await supabase.from("threads").select("auto_title, title").eq("channel_id", channelId).order("created_at", { ascending: false }).limit(50);
10244
+ const existingCategories = extractCategories(
10245
+ (recent ?? []).flatMap((t) => [
10246
+ t.auto_title,
10247
+ t.title
10248
+ ])
10249
+ );
10250
+ return buildAutoTitle({
10251
+ channelName: channel.name,
10252
+ channelPrompt: channel.prompt,
10253
+ workspacePrompt: loadWorkspacePrompt().prompt,
10254
+ titlePrompt: (await loadWorkspaceBranding()).autoTitlePrompt,
10255
+ existingCategories,
10256
+ body,
10257
+ scope
10258
+ });
10259
+ }
10260
+ async function generateAutoTitle(threadId, channelId, body) {
10261
+ if ((await loadWorkspaceBranding()).autoTitleEnabled === false) return;
10262
+ const autoTitle = await labelThread(channelId, body);
10263
+ if (!autoTitle) return;
10264
+ const supabase = getSupabaseClient();
10265
+ await supabase.from("threads").update({ auto_title: autoTitle }).eq("id", threadId).is("title", null);
10266
+ }
10267
+ function createChatRouter(agent = {}) {
10268
+ const app = new Hono13();
10269
+ const {
10270
+ onChatMessage: onMessage,
10271
+ isAgentThinking,
10272
+ getAgentActivities,
10273
+ cancelAgentRun,
10274
+ regenerateAgentRun,
10275
+ listAgentRuns
10276
+ } = agent;
10277
+ app.post(
10278
+ "/threads/:id/agent-cancel",
10279
+ async (c) => c.json({ cancelled: await cancelAgentRun?.(c.req.param("id")) ?? false })
10280
+ );
10281
+ app.post(
10282
+ "/threads/:id/agent-regenerate",
10283
+ async (c) => c.json({ started: await regenerateAgentRun?.(c.req.param("id")) ?? false })
10284
+ );
10285
+ app.get(
10286
+ "/threads/:id/agent-status",
10287
+ async (c) => c.json({
10288
+ thinking: await isAgentThinking?.(c.req.param("id")) ?? false,
10289
+ activities: await getAgentActivities?.(c.req.param("id")) ?? []
10290
+ })
10291
+ );
10292
+ app.get("/agent-runs", async (c) => c.json({ threadIds: await listAgentRuns?.() ?? [] }));
10293
+ app.get("/channels/:channelId/threads", async (c) => {
10294
+ const channelId = c.req.param("channelId");
10295
+ if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
10296
+ const requestedLimit = Number(c.req.query("limit"));
10297
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
10298
+ const before = c.req.query("before");
10299
+ const sortColumn = c.req.query("sort") === "activity" ? "updated_at" : "created_at";
10300
+ const statusParam = c.req.query("status");
10301
+ const statusFilter = statusParam ? [
10302
+ ...new Set(
10303
+ statusParam.split(",").map(boardColumnParam).filter((s) => s !== null)
10304
+ )
10305
+ ] : [];
10306
+ const supabase = getSupabaseClient();
10307
+ let query = supabase.from("threads").select("*").eq("channel_id", channelId).is("archived_at", null).order(sortColumn, { ascending: false }).limit(limit);
10308
+ if (before) query = query.lt(sortColumn, before);
10309
+ if (statusFilter.length) {
10310
+ const withoutNone = statusFilter.filter((s) => s !== NO_STATUS_COLUMN);
10311
+ const includeNone = statusFilter.includes(NO_STATUS_COLUMN);
10312
+ if (includeNone && withoutNone.length) {
10313
+ query = query.or(`board_status.in.(${withoutNone.join(",")}),board_status.is.null`);
10314
+ } else if (includeNone) {
10315
+ query = query.is("board_status", null);
10316
+ } else {
10317
+ query = query.in("board_status", withoutNone);
10318
+ }
10319
+ }
10320
+ const { data: threads, error: error2 } = await query;
10321
+ if (error2) return c.json({ error: error2.message }, 500);
10322
+ if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
10323
+ const withPreview = await withPreviews(supabase, threads);
10324
+ const nextCursor = threads.length === limit ? threads[threads.length - 1][sortColumn] : null;
10325
+ return c.json({ threads: withPreview, nextCursor });
10326
+ });
10327
+ app.get("/channels/:channelId/board/:status", async (c) => {
10328
+ const channelId = c.req.param("channelId");
10329
+ if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
10330
+ const column = boardColumnParam(c.req.param("status"));
10331
+ if (!column) {
10332
+ return c.json(
10333
+ { error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
10334
+ 400
10335
+ );
10336
+ }
10337
+ const requestedLimit = Number(c.req.query("limit"));
10338
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
10339
+ const before = c.req.query("before");
10340
+ const supabase = getSupabaseClient();
10341
+ let query = supabase.from("threads").select("*").eq("channel_id", channelId);
10342
+ query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
10343
+ query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
10344
+ if (before) query = query.lt("updated_at", before);
10345
+ const { data: threads, error: error2 } = await query;
10346
+ if (error2) return c.json({ error: error2.message }, 500);
10347
+ if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
10348
+ const withPreview = await withPreviews(supabase, threads);
10349
+ const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
10350
+ return c.json({ threads: withPreview, nextCursor });
10351
+ });
10352
+ app.get("/board/:status", async (c) => {
10353
+ const column = boardColumnParam(c.req.param("status"));
10354
+ if (!column) {
10355
+ return c.json(
10356
+ { error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
10357
+ 400
10358
+ );
10359
+ }
10360
+ const requestedLimit = Number(c.req.query("limit"));
10361
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
10362
+ const before = c.req.query("before");
10363
+ const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
10364
+ const supabase = getSupabaseClient();
10365
+ let query = supabase.from("threads").select("*").in("channel_id", meta ? [...meta.keys()] : [...ownChannelIds(c)]);
10366
+ query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
10367
+ query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
10368
+ if (before) query = query.lt("updated_at", before);
10369
+ const { data: threads, error: error2 } = await query;
10370
+ if (error2) return c.json({ error: error2.message }, 500);
10371
+ if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
10372
+ const withPreview = await withPreviews(supabase, threads);
10373
+ const labelled = meta ? withPreview.map((t) => {
10374
+ const channelMeta = meta.get(t.channel_id ?? "");
10375
+ return {
10376
+ ...t,
10377
+ workspace: channelMeta?.workspace ?? null,
10378
+ channel_name: channelMeta?.channelName ?? null,
10379
+ agent_name: channelMeta?.agentName ?? null
10380
+ };
10381
+ }) : withPreview;
10382
+ const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
10383
+ return c.json({ threads: labelled, nextCursor });
10384
+ });
10385
+ app.get("/threads", async (c) => {
10386
+ const requestedLimit = Number(c.req.query("limit"));
10387
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
10388
+ const before = c.req.query("before");
10389
+ const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
10390
+ const relevantTo = c.req.query("relevantTo");
10391
+ const supabase = getSupabaseClient();
10392
+ const channelIds = meta ? [...meta.keys()] : [...ownChannelIds(c)];
10393
+ let threads;
10394
+ if (isNonEmptyString(relevantTo)) {
10395
+ try {
10396
+ threads = await relevantThreadsPage(supabase, relevantTo, channelIds, { before, limit });
10397
+ } catch (err) {
10398
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
10399
+ }
10400
+ } else {
10401
+ let query = supabase.from("threads").select("*").in("channel_id", channelIds).is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
10402
+ if (before) query = query.lt("updated_at", before);
10403
+ const { data, error: error2 } = await query;
10404
+ if (error2) return c.json({ error: error2.message }, 500);
10405
+ threads = data;
10406
+ }
10407
+ if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
10408
+ const threadIds = threads.map((t) => t.id);
10409
+ const chunkResults = await Promise.all(
10410
+ chunk(threadIds, THREAD_CHUNK_SIZE).map(
10411
+ (ids) => supabase.from("thread_replies").select("*").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
10412
+ )
10413
+ );
10414
+ const repliesByThread = /* @__PURE__ */ new Map();
10415
+ for (const { data } of chunkResults) {
10416
+ for (const r of data ?? []) {
10417
+ const arr = repliesByThread.get(r.thread_id);
10418
+ if (arr) arr.push(r);
10419
+ else repliesByThread.set(r.thread_id, [r]);
10420
+ }
10421
+ }
10422
+ const withReplies = threads.map((t) => {
10423
+ const channelMeta = meta?.get(t.channel_id);
10424
+ return {
10425
+ ...t,
10426
+ ...meta ? {
10427
+ workspace: channelMeta?.workspace ?? null,
10428
+ channel_name: channelMeta?.channelName ?? null,
10429
+ agent_name: channelMeta?.agentName ?? null
10430
+ } : {},
10431
+ replies: (repliesByThread.get(t.id) ?? []).map((r) => ({
10432
+ ...r,
10433
+ body: slackMrkdwnToMarkdown(r.body)
10434
+ }))
10435
+ };
10436
+ });
10437
+ const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
10438
+ return c.json({ threads: withReplies, nextCursor });
10439
+ });
10440
+ app.get("/threads/archived", async (c) => {
10441
+ if (c.get("auth")?.role !== "admin") {
10442
+ return c.json({ error: "only an admin can view archived topics" }, 403);
10443
+ }
10444
+ const requestedLimit = Number(c.req.query("limit"));
10445
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
10446
+ const before = c.req.query("before");
10447
+ const supabase = getSupabaseClient();
10448
+ let query = supabase.from("threads").select("*").in("channel_id", [...ownChannelIds(c)]).not("archived_at", "is", null).order("archived_at", { ascending: false }).limit(limit);
10449
+ if (before) query = query.lt("archived_at", before);
10450
+ const { data: threads, error: error2 } = await query;
10451
+ if (error2) return c.json({ error: error2.message }, 500);
10452
+ if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
10453
+ const withPreview = await withPreviews(supabase, threads);
10454
+ const nextCursor = threads.length === limit ? threads[threads.length - 1].archived_at : null;
10455
+ return c.json({ threads: withPreview, nextCursor });
10456
+ });
10457
+ app.get("/threads/:id", async (c) => {
10458
+ const id = c.req.param("id");
10459
+ const requestedLimit = Number(c.req.query("limit"));
10460
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, REPLIES_PAGE_SIZE_MAX) : REPLIES_PAGE_SIZE;
10461
+ const before = c.req.query("before");
10462
+ 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);
10463
+ if (before) repliesQuery = repliesQuery.lt("created_at", before);
10464
+ const [thread, replies, opening] = await Promise.all([
10465
+ getSupabaseClient().from("threads").select("*").eq("id", id).single(),
10466
+ repliesQuery,
10467
+ // The opening message, fetched separately because the replies page
10468
+ // above is newest-first: on a long thread the opener isn't in it. It
10469
+ // is what the header falls back to on a thread with no title of its
10470
+ // own (see threadCrumbLabel) — a title is never written on the
10471
+ // thread's behalf, so an untitled row must still be able to show and
10472
+ // rename a topic.
10473
+ getSupabaseClient().from("thread_replies").select("body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(1)
10474
+ ]);
10475
+ if (thread.error) return c.json({ error: thread.error.message }, 404);
10476
+ if (replies.error) return c.json({ error: replies.error.message }, 500);
10477
+ const channelId = thread.data?.channel_id;
10478
+ const meta = await globalChannelMeta(c);
10479
+ const channelMeta = channelId ? meta.get(channelId) : void 0;
10480
+ if (!channelId || !channelMeta) {
10481
+ return c.json({ error: "not found" }, 404);
10482
+ }
10483
+ const page2 = replies.data ?? [];
10484
+ const nextCursor = page2.length === limit ? page2[page2.length - 1].created_at : null;
10485
+ const formattedReplies = page2.reverse().map((r) => ({
10486
+ ...r,
10487
+ body: slackMrkdwnToMarkdown(r.body)
10488
+ }));
10489
+ const openingBody = (opening.data ?? [])[0]?.body;
10490
+ return c.json({
10491
+ ...thread.data,
10492
+ // Always stamped (not just on `?scope=all` feeds, see the `/threads`
10493
+ // route above) — the reply pane can be opened directly by id (global
10494
+ // board, permalink) without the active workspace's channel map ever
10495
+ // having loaded this thread's channel, so the header needs its own
10496
+ // workspace/channel label rather than depending on that cache.
10497
+ workspace: channelMeta.workspace,
10498
+ channel_name: channelMeta.channelName,
10499
+ agent_name: channelMeta.agentName,
10500
+ preview: openingBody ? slackMrkdwnToMarkdown(openingBody) : null,
10501
+ replies: formattedReplies,
10502
+ nextCursor
10503
+ });
10504
+ });
10505
+ app.post("/threads/:id/translation/:lang", async (c) => {
10506
+ const id = c.req.param("id");
10507
+ const lang = canonicalLanguage(c.req.param("lang"));
10508
+ if (!isTranslatableLanguage2(lang)) return c.json({ error: "unsupported language" }, 400);
10509
+ const [thread, replies] = await Promise.all([
10510
+ getSupabaseClient().from("threads").select("channel_id").eq("id", id).single(),
10511
+ 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)
10512
+ ]);
10513
+ if (thread.error) return c.json({ error: "not found" }, 404);
10514
+ if (replies.error) return c.json({ error: replies.error.message }, 500);
10515
+ const channelId = thread.data?.channel_id;
10516
+ if (!channelId || !(await globalChannelMeta(c)).has(channelId)) {
10517
+ return c.json({ error: "not found" }, 404);
10518
+ }
10519
+ const messages = (replies.data ?? []).map(
10520
+ (r) => ({ id: r.id, author: r.author, body: slackMrkdwnToMarkdown(r.body) })
10521
+ );
10522
+ try {
10523
+ return c.json({ lang, messages: await translateThread(id, lang, messages) });
10524
+ } catch (err) {
10525
+ const message = err instanceof Error ? err.message : "translation failed";
10526
+ console.error(`Thread translation failed (${id} \u2192 ${lang}):`, err);
10527
+ return c.json({ error: message }, 502);
10528
+ }
10529
+ });
10530
+ app.patch("/threads/:id/board-status", async (c) => {
10531
+ const id = c.req.param("id");
10532
+ const input2 = await c.req.json().catch(() => null);
10533
+ const value = input2?.board_status ?? null;
10534
+ if (value !== null && !BOARD_STATUSES.includes(value)) {
10535
+ return c.json(
10536
+ { error: `board_status must be null or one of: ${BOARD_STATUSES.join(", ")}` },
10537
+ 400
10538
+ );
10539
+ }
10540
+ if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
10541
+ const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ board_status: value }).eq("id", id).select().single();
10542
+ if (error2) return c.json({ error: error2.message }, 500);
10543
+ return c.json(thread);
10544
+ });
10545
+ app.patch("/threads/:id", async (c) => {
10546
+ const id = c.req.param("id");
10547
+ const input2 = await c.req.json().catch(() => null);
10548
+ const raw = input2?.title;
10549
+ if (raw !== null && !isNonEmptyString(raw)) {
10550
+ return c.json({ errors: ["title must be a non-empty string, or null to clear it"] }, 400);
10551
+ }
10552
+ const title = raw === null ? null : raw.trim();
10553
+ if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
10554
+ const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ title }).eq("id", id).select().single();
10555
+ if (error2) return c.json({ error: error2.message }, 500);
10556
+ return c.json(thread);
10557
+ });
10558
+ app.post("/threads/:id/suggest-title", async (c) => {
10559
+ const id = c.req.param("id");
10560
+ if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
10561
+ const { data: thread } = await getSupabaseClient().from("threads").select("channel_id").eq("id", id).single();
10562
+ if (!thread) return c.json({ error: "not found" }, 404);
10563
+ 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 });
10564
+ if (error2) return c.json({ error: error2.message }, 500);
10565
+ const transcript = (rows ?? []).map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
10566
+ const title = await labelThread(thread.channel_id, transcript, "thread");
10567
+ if (!title) return c.json({ error: "could not generate a topic" }, 502);
10568
+ return c.json({ title });
10569
+ });
10570
+ app.patch("/replies/:replyId", async (c) => {
10571
+ const replyId = c.req.param("replyId");
10572
+ const input2 = await c.req.json().catch(() => null);
10573
+ const author = input2?.author;
10574
+ const body = input2?.body;
10575
+ if (!isNonEmptyString(author) || !isNonEmptyString(body)) {
10576
+ return c.json({ errors: ["author and body are required"] }, 400);
10577
+ }
10578
+ const supabase = getSupabaseClient();
10579
+ const { data: existing, error: fetchError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
10580
+ if (fetchError) return c.json({ error: fetchError.message }, 404);
10581
+ if (!await threadIsKnown(existing.thread_id, c)) return c.json({ error: "not found" }, 404);
10582
+ if (existing.author !== author) {
10583
+ return c.json({ error: "only the author can edit a message" }, 403);
10584
+ }
10585
+ const { data: reply, error: error2 } = await supabase.from("thread_replies").update({ body, edited_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", replyId).select().single();
10586
+ if (error2) return c.json({ error: error2.message }, 500);
10587
+ if (isHumanAuthor(author)) {
10588
+ 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();
10589
+ if (latest2?.id === replyId) {
10590
+ await Promise.resolve(regenerateAgentRun?.(existing.thread_id, replyId)).catch(() => {
10591
+ });
10592
+ }
10593
+ }
10594
+ return c.json(reply);
10595
+ });
10596
+ app.post("/replies/:replyId/reactions", async (c) => {
10597
+ const replyId = c.req.param("replyId");
10598
+ const input2 = await c.req.json().catch(() => null);
10599
+ const author = input2?.author;
10600
+ const emoji = input2?.emoji;
10601
+ if (!isNonEmptyString(author) || !isNonEmptyString(emoji)) {
10602
+ return c.json({ errors: ["author and emoji are required"] }, 400);
10603
+ }
10604
+ const supabase = getSupabaseClient();
10605
+ const { data: reply, error: replyError } = await supabase.from("thread_replies").select("thread_id").eq("id", replyId).single();
10606
+ if (replyError) return c.json({ error: "not found" }, 404);
10607
+ if (!await threadIsKnown(reply.thread_id, c)) return c.json({ error: "not found" }, 404);
10608
+ const { data: existing, error: existingError } = await supabase.from("reply_reactions").select("id").eq("reply_id", replyId).eq("emoji", emoji).eq("author", author).maybeSingle();
10609
+ if (existingError) return c.json({ error: existingError.message }, 500);
10610
+ if (existing) {
10611
+ const { error: error2 } = await supabase.from("reply_reactions").delete().eq("id", existing.id);
10612
+ if (error2) return c.json({ error: error2.message }, 500);
10613
+ } else {
10614
+ const { error: error2 } = await supabase.from("reply_reactions").insert({ reply_id: replyId, emoji, author });
10615
+ if (error2 && error2.code !== "23505") return c.json({ error: error2.message }, 500);
10616
+ }
10617
+ const { data: reactions, error: reactionsError } = await supabase.from("reply_reactions").select("emoji, author").eq("reply_id", replyId);
10618
+ if (reactionsError) return c.json({ error: reactionsError.message }, 500);
10619
+ return c.json({ reactions: reactions ?? [] });
10620
+ });
10621
+ app.patch("/threads/:id/archive", async (c) => {
10622
+ if (c.get("auth")?.role !== "admin") {
10623
+ return c.json({ error: "only an admin can archive a topic" }, 403);
10624
+ }
10625
+ const id = c.req.param("id");
10626
+ const input2 = await c.req.json().catch(() => null);
10627
+ const archived = input2?.archived;
10628
+ if (typeof archived !== "boolean") {
10629
+ return c.json({ errors: ["archived (boolean) is required"] }, 400);
10630
+ }
10631
+ if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
10632
+ const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ archived_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", id).select().single();
10633
+ if (error2) return c.json({ error: error2.message }, 500);
10634
+ return c.json(thread);
10635
+ });
10636
+ app.post("/channels/:channelId/threads", async (c) => {
10637
+ const channelId = c.req.param("channelId");
10638
+ if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
10639
+ const input2 = await c.req.json();
10640
+ if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
10641
+ return c.json({ errors: ["author and body are required"] }, 400);
10642
+ }
10643
+ const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10644
+ const model = requestedModel(c, input2.model);
10645
+ const effort = requestedEffort(c, input2.effort);
10646
+ const supabase = getSupabaseClient();
10647
+ let thread;
10648
+ let replayed = false;
10649
+ const { data: inserted, error: threadError } = await supabase.from("threads").insert({
10650
+ channel_id: channelId,
10651
+ // A stored title means someone named the topic. A blank one is not a
10652
+ // name, so it lands as null and the topic is derived from the
10653
+ // opening message at display time (see threadCrumbLabel).
10654
+ title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
10655
+ author: input2.author,
10656
+ client_key: clientKey2
10657
+ }).select().single();
10658
+ if (threadError) {
10659
+ if (!clientKey2 || threadError.code !== "23505") {
10660
+ return c.json({ error: threadError.message }, 500);
10661
+ }
10662
+ const { data: existing } = await supabase.from("threads").select("*").eq("client_key", clientKey2).single();
10663
+ if (!existing) return c.json({ error: threadError.message }, 500);
10664
+ thread = existing;
10665
+ replayed = true;
10666
+ } else {
10667
+ thread = inserted;
10668
+ }
10669
+ let { data: reply } = replayed && clientKey2 ? await supabase.from("thread_replies").select("*").eq("client_key", clientKey2).single() : { data: null };
10670
+ let replyCompleted = false;
10671
+ if (!reply) {
10672
+ const { data: insertedReply, error: replyError } = await supabase.from("thread_replies").insert({
10673
+ thread_id: thread.id,
10674
+ author: input2.author,
10675
+ body: input2.body,
10676
+ client_key: clientKey2,
10677
+ model,
10678
+ effort
10679
+ }).select().single();
10680
+ if (replyError) return c.json({ error: replyError.message }, 500);
10681
+ reply = insertedReply;
10682
+ replyCompleted = true;
10683
+ }
10684
+ if (!replayed || replyCompleted) {
10685
+ onMessage?.({
10686
+ threadId: thread.id,
10687
+ replyId: reply.id,
10688
+ author: input2.author,
10689
+ body: input2.body,
10690
+ // From the row, not `model`/`effort`: on the replay path the reply
10691
+ // predates this request, so its own stored override is the source of
10692
+ // truth.
10693
+ model: reply["model"] ?? void 0,
10694
+ effort: effortFromRow(reply["effort"])
10695
+ });
10696
+ }
10697
+ if (!replayed && thread["title"] == null) {
10698
+ void generateAutoTitle(thread.id, channelId, input2.body).catch(
10699
+ (err) => console.error("Auto title failed:", err)
10700
+ );
10701
+ }
10702
+ return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
10419
10703
  });
10420
- if (error2) fail("createChallenge", error2);
10421
- return nonce;
10422
- }
10423
- async function consumeChallenge(deviceId, nonce) {
10424
- 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");
10425
- if (error2) fail("consumeChallenge", error2);
10426
- return (data ?? []).length > 0;
10427
- }
10428
- async function listAdminWorkspaces(userId) {
10429
- const { data, error: error2 } = await getSupabaseClient().from("memberships").select("workspace_id, workspaces!inner(slug)").eq("user_id", userId).eq("role", "admin").eq("status", "active");
10430
- if (error2) fail("listAdminWorkspaces", error2);
10431
- return (data ?? []).map((r) => ({ id: r.workspace_id, slug: r.workspaces.slug })).sort((a, b) => a.slug.localeCompare(b.slug));
10432
- }
10433
- async function listDeviceWorkspaces(device) {
10434
- const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id").eq("device_id", device.id);
10435
- if (error2) fail("listDeviceWorkspaces", error2);
10436
- const bound = new Set((data ?? []).map((r) => r.workspace_id));
10437
- return (await listAdminWorkspaces(device.userId)).filter((w) => bound.has(w.id));
10438
- }
10439
- async function bindDeviceWorkspace(deviceId, workspaceId, boundBy) {
10440
- const { error: error2 } = await getSupabaseClient().from("device_workspaces").upsert(
10441
- { device_id: deviceId, workspace_id: workspaceId, bound_by: boundBy },
10442
- { onConflict: "device_id,workspace_id", ignoreDuplicates: true }
10443
- );
10444
- if (error2) fail("bindDeviceWorkspace", error2);
10445
- }
10446
- async function listWorkspaceDevices(workspaceId) {
10447
- const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("devices!inner(*)").eq("workspace_id", workspaceId).is("devices.revoked_at", null);
10448
- if (error2) fail("listWorkspaceDevices", error2);
10449
- return (data ?? []).map((r) => toDevice(r.devices)).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
10450
- }
10451
- var STALE_HOST_MS = 14 * 24 * 60 * 6e4;
10452
- var isStale = (lastSeenAt) => !!lastSeenAt && Date.now() - Date.parse(lastSeenAt) > STALE_HOST_MS;
10453
- async function listWorkspaceHosts(workspaceIds, exceptPublicKey) {
10454
- const hosts2 = /* @__PURE__ */ new Map();
10455
- if (!workspaceIds.length) return hosts2;
10456
- 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);
10457
- if (error2) fail("listWorkspaceHosts", error2);
10458
- for (const r of data ?? []) {
10459
- if (r.devices.public_key !== exceptPublicKey && !isStale(r.devices.last_seen_at)) {
10460
- hosts2.set(r.workspace_id, r.devices.name);
10704
+ app.post("/replies/:replyId/fork", async (c) => {
10705
+ const replyId = c.req.param("replyId");
10706
+ const input2 = await c.req.json().catch(() => null);
10707
+ const author = input2?.author;
10708
+ if (!isNonEmptyString(author)) {
10709
+ return c.json({ errors: ["author is required"] }, 400);
10461
10710
  }
10462
- }
10463
- return hosts2;
10464
- }
10465
- async function releaseStaleHosts(workspaceIds, exceptPublicKey) {
10466
- if (!workspaceIds.length) return;
10467
- const db = getSupabaseClient();
10468
- 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);
10469
- if (error2) fail("releaseStaleHosts", error2);
10470
- for (const r of data ?? []) {
10471
- if (r.devices.public_key === exceptPublicKey || !isStale(r.devices.last_seen_at)) continue;
10472
- const { error: delError } = await db.from("device_workspaces").delete().eq("device_id", r.device_id).eq("workspace_id", r.workspace_id);
10473
- if (delError) fail("releaseStaleHosts", delError);
10474
- }
10475
- }
10476
- async function userEmail(userId) {
10477
- const { data, error: error2 } = await getSupabaseClient().auth.admin.getUserById(userId);
10478
- if (error2) return null;
10479
- return data.user?.email ?? null;
10480
- }
10711
+ const mode = input2?.mode === "focus" ? "focus" : "condense";
10712
+ const supabase = getSupabaseClient();
10713
+ const { data: source, error: sourceError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
10714
+ if (sourceError) return c.json({ error: sourceError.message }, 404);
10715
+ const { data: sourceThread, error: sourceThreadError } = await supabase.from("threads").select("*").eq("id", source.thread_id).single();
10716
+ if (sourceThreadError) return c.json({ error: sourceThreadError.message }, 404);
10717
+ if (!(await globalChannelMeta(c)).has(sourceThread.channel_id)) {
10718
+ return c.json({ error: "not found" }, 404);
10719
+ }
10720
+ 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 });
10721
+ const priorRows = priorReplies ?? [];
10722
+ const sourceText = slackMrkdwnToMarkdown(source.body);
10723
+ const FOCUS_CONTEXT_BELOW_CHARS = 280;
10724
+ const inputRows = mode === "focus" ? priorRows.slice(sourceText.length < FOCUS_CONTEXT_BELOW_CHARS ? -2 : -1) : priorRows;
10725
+ const transcript = inputRows.map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
10726
+ const { title, body } = await buildForkTopic(transcript || sourceText, sourceText, mode);
10727
+ const backlink = `/chat/${encodeURIComponent(sourceThread.channel_id)}/${sourceThread.id}#m-${replyId.slice(0, 8)}`;
10728
+ const openingBody = `${body}
10481
10729
 
10482
- // ../core/src/bridge-protocol.ts
10483
- var HOST_SOCKET_PATH = "/internal/host-socket";
10484
- var DEFAULT_DATA_API_PORT = 4757;
10485
- var CALL_TIMEOUT_MS = 15e3;
10486
- var HEARTBEAT_INTERVAL_MS = 15e3;
10487
- var HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2.5;
10488
- var LOCAL_RELAY_PATH = "/internal/agent-call";
10489
- var RELAYED_METHODS = [
10490
- "onChatMessage",
10491
- "isAgentThinking",
10492
- "getAgentActivities",
10493
- "cancelAgentRun",
10494
- "regenerateAgentRun",
10495
- "listAgentRuns"
10496
- ];
10497
- function encodeWireMessage(msg) {
10498
- return JSON.stringify(msg);
10499
- }
10500
- function parseWireMessage(raw) {
10501
- try {
10502
- return JSON.parse(String(raw));
10503
- } catch {
10504
- return null;
10505
- }
10730
+ > \u2442 Forked from [a message by ${source.author}](${backlink})`;
10731
+ const { data: thread, error: threadError } = await supabase.from("threads").insert({ channel_id: sourceThread.channel_id, title, author }).select().single();
10732
+ if (threadError) return c.json({ error: threadError.message }, 500);
10733
+ const { data: reply, error: replyError } = await supabase.from("thread_replies").insert({ thread_id: thread.id, author, body: openingBody }).select().single();
10734
+ if (replyError) return c.json({ error: replyError.message }, 500);
10735
+ onMessage?.({
10736
+ threadId: thread.id,
10737
+ replyId: reply.id,
10738
+ author,
10739
+ body: openingBody
10740
+ });
10741
+ return c.json({ ...thread, replies: [reply] }, 201);
10742
+ });
10743
+ app.post("/threads/:id/replies", async (c) => {
10744
+ const threadId = c.req.param("id");
10745
+ const input2 = await c.req.json();
10746
+ if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
10747
+ return c.json({ errors: ["author and body are required"] }, 400);
10748
+ }
10749
+ const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10750
+ const effort = requestedEffort(c, input2.effort);
10751
+ if (!await threadIsKnown(threadId, c)) return c.json({ error: "not found" }, 404);
10752
+ const model = requestedModel(c, input2.model) ?? await threadModel(threadId);
10753
+ const { data, error: error2 } = await getSupabaseClient().from("thread_replies").insert({
10754
+ thread_id: threadId,
10755
+ author: input2.author,
10756
+ body: input2.body,
10757
+ client_key: clientKey2,
10758
+ model,
10759
+ effort
10760
+ }).select().single();
10761
+ if (error2) {
10762
+ if (clientKey2 && error2.code === "23505") {
10763
+ const { data: existing } = await getSupabaseClient().from("thread_replies").select("*").eq("client_key", clientKey2).single();
10764
+ if (existing) return c.json(existing, 200);
10765
+ }
10766
+ return c.json({ error: error2.message }, 500);
10767
+ }
10768
+ onMessage?.({
10769
+ threadId,
10770
+ replyId: data.id,
10771
+ author: input2.author,
10772
+ body: input2.body,
10773
+ model: data.model ?? void 0,
10774
+ effort: effortFromRow(data.effort)
10775
+ });
10776
+ return c.json(data, 201);
10777
+ });
10778
+ return app;
10506
10779
  }
10507
10780
 
10508
- // ../api/src/dashboard/agent-bridge-data.ts
10509
- var hosts = /* @__PURE__ */ new Map();
10510
- var hostStatusListeners = /* @__PURE__ */ new Set();
10511
- function notifyHostStatus() {
10512
- const online = hosts.size > 0;
10513
- for (const l of hostStatusListeners) l(online);
10514
- }
10515
- function removeHost(ws) {
10516
- if (hosts.delete(ws)) notifyHostStatus();
10781
+ // ../api/src/dashboard/doc-shares-routes.ts
10782
+ import { Hono as Hono14 } from "hono";
10783
+
10784
+ // ../api/src/dashboard/doc-share-features.ts
10785
+ import { DeleteObjectCommand as DeleteObjectCommand2 } from "@aws-sdk/client-s3";
10786
+ var DEFAULT_FEATURES = {
10787
+ audioEnabled: false,
10788
+ askEnabled: true,
10789
+ socialEnabled: false,
10790
+ brandingEnabled: true,
10791
+ translationEnabled: false,
10792
+ translationLangs: []
10793
+ };
10794
+ var HTML_FEATURES = {
10795
+ audioEnabled: false,
10796
+ askEnabled: false,
10797
+ socialEnabled: false,
10798
+ brandingEnabled: false,
10799
+ translationEnabled: false,
10800
+ translationLangs: []
10801
+ };
10802
+ function resolveFeatures(requested, existing) {
10803
+ const base = existing ?? DEFAULT_FEATURES;
10804
+ return {
10805
+ audioEnabled: requested?.audio ?? base.audioEnabled,
10806
+ askEnabled: requested?.ask ?? base.askEnabled,
10807
+ socialEnabled: requested?.social ?? base.socialEnabled,
10808
+ brandingEnabled: requested?.branding ?? base.brandingEnabled,
10809
+ translationEnabled: requested?.translation ?? base.translationEnabled,
10810
+ // Replaced wholesale, not merged: the allowlist IS the answer to "which
10811
+ // languages does this link offer", and a merge would make removing one
10812
+ // impossible.
10813
+ translationLangs: requested?.translationLangs ?? base.translationLangs
10814
+ };
10517
10815
  }
10518
- function disconnectHostDevice(deviceId) {
10519
- for (const [ws, scope] of hosts) {
10520
- if (scope.deviceId !== deviceId) continue;
10521
- console.log(`[host-socket] closing connection for revoked device ${deviceId}`);
10522
- removeHost(ws);
10523
- ws.close(1008, "device revoked");
10816
+ async function deleteShareAssets(keys) {
10817
+ const client2 = getR2Client();
10818
+ const bucket = getR2Bucket();
10819
+ for (const key of keys) {
10820
+ try {
10821
+ await client2.send(new DeleteObjectCommand2({ Bucket: bucket, Key: key }));
10822
+ } catch {
10823
+ }
10524
10824
  }
10525
10825
  }
10526
- var LAST_SEEN_RESOLUTION_MS = 60 * 6e4;
10527
- var lastCallError = /* @__PURE__ */ new Map();
10528
- function getHostCallError(method, profile = getProfile()) {
10529
- return lastCallError.get(`${profile}:${method}`) ?? null;
10826
+ async function updateShareFeatures(share, requested) {
10827
+ if (share.kind === "html") return share;
10828
+ if (share.kind === "excalidraw") {
10829
+ if (requested.branding === void 0) return share;
10830
+ return updateDocShareFeatures(share.id, { ...share, brandingEnabled: requested.branding });
10831
+ }
10832
+ const features = resolveFeatures(requested, share);
10833
+ const updated = await updateDocShareFeatures(share.id, features);
10834
+ if (!updated) return null;
10835
+ if (features.audioEnabled) void queueShareAudio(updated);
10836
+ else if (share.audioEnabled) await dropShareAudio(share);
10837
+ return updated;
10530
10838
  }
10531
10839
 
10532
10840
  // ../api/src/dashboard/doc-share-publish.ts
@@ -10537,7 +10845,7 @@ import { CopyObjectCommand, PutObjectCommand as PutObjectCommand2 } from "@aws-s
10537
10845
  var SHARE_ASSET_PLACEHOLDER = "/share/{{flyspace-share-id}}/assets/";
10538
10846
 
10539
10847
  // ../api/src/dashboard/share-language.ts
10540
- var GROQ_API_URL5 = "https://api.groq.com/openai/v1/chat/completions";
10848
+ var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
10541
10849
  var CLASSIFIER_MODEL = "openai/gpt-oss-20b";
10542
10850
  var SAMPLE_CHARS = 4e3;
10543
10851
  var NOISE = [
@@ -10576,7 +10884,7 @@ async function detectSourceLanguage(title, content) {
10576
10884
  const sample = languageSample(title, content);
10577
10885
  if (sample.length < MIN_SAMPLE_CHARS) return null;
10578
10886
  try {
10579
- const res = await fetch(GROQ_API_URL5, {
10887
+ const res = await fetch(GROQ_API_URL6, {
10580
10888
  method: "POST",
10581
10889
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
10582
10890
  body: JSON.stringify({
@@ -10806,7 +11114,7 @@ function resolveChannel2(c) {
10806
11114
  }
10807
11115
  var LIST_LIMIT = 500;
10808
11116
  function createDocShareListRouter() {
10809
- const app = new Hono13();
11117
+ const app = new Hono14();
10810
11118
  app.get("/shares", async (c) => {
10811
11119
  const viewer = c.get("auth");
10812
11120
  const readable = await filterReadableDocsChannels(
@@ -10850,7 +11158,7 @@ function createDocShareListRouter() {
10850
11158
  return app;
10851
11159
  }
10852
11160
  function createDocShareWriteRouter(agent = {}) {
10853
- const app = new Hono13();
11161
+ const app = new Hono14();
10854
11162
  app.use("/:id/docs/share", requireDocsAccess);
10855
11163
  app.use("/:id/docs/share/*", requireDocsAccess);
10856
11164
  app.use("/:id/docs/shares", requireDocsAccess);
@@ -11023,7 +11331,7 @@ function createDocShareWriteRouter(agent = {}) {
11023
11331
  }
11024
11332
 
11025
11333
  // ../api/src/dashboard/search-routes.ts
11026
- import { Hono as Hono14 } from "hono";
11334
+ import { Hono as Hono15 } from "hono";
11027
11335
  function matchScore(q, fields) {
11028
11336
  const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
11029
11337
  if (terms.length === 0) return 0;
@@ -11171,7 +11479,7 @@ function searchAutomations(q) {
11171
11479
  }
11172
11480
  var RESULT_LIMIT = 30;
11173
11481
  function createSearchRouter() {
11174
- const app = new Hono14();
11482
+ const app = new Hono15();
11175
11483
  app.get("/", async (c) => {
11176
11484
  const q = c.req.query("q")?.trim();
11177
11485
  if (!q) return c.json({ results: [] });
@@ -11187,14 +11495,14 @@ function createSearchRouter() {
11187
11495
  }
11188
11496
 
11189
11497
  // ../api/src/dashboard/feature-flags-routes.ts
11190
- import { Hono as Hono15 } from "hono";
11498
+ import { Hono as Hono16 } from "hono";
11191
11499
  async function isFeatureEnabled(c, flagId) {
11192
11500
  const auth = c.get("auth");
11193
11501
  const enabled = await listEnabledFeatureFlags(auth.workspaceId);
11194
11502
  return resolveFeatureFlags(enabled, isDevHost(c))[flagId];
11195
11503
  }
11196
11504
  function createFeatureFlagsRouter() {
11197
- const app = new Hono15();
11505
+ const app = new Hono16();
11198
11506
  app.get("/", async (c) => {
11199
11507
  const auth = c.get("auth");
11200
11508
  const devHost = isDevHost(c);
@@ -11221,7 +11529,7 @@ function createFeatureFlagsRouter() {
11221
11529
 
11222
11530
  // ../api/src/dashboard/uploads-routes.ts
11223
11531
  import { randomUUID as randomUUID3 } from "crypto";
11224
- import { Hono as Hono16 } from "hono";
11532
+ import { Hono as Hono17 } from "hono";
11225
11533
  import { PutObjectCommand as PutObjectCommand3, GetObjectCommand as GetObjectCommand3 } from "@aws-sdk/client-s3";
11226
11534
  import { getSignedUrl as getSignedUrl3 } from "@aws-sdk/s3-request-presigner";
11227
11535
  var MAX_FILE_BYTES = 10 * 1024 * 1024;
@@ -11236,7 +11544,7 @@ function encodeKeyForUrl(key) {
11236
11544
  return key.split("/").map(encodeURIComponent).join("/");
11237
11545
  }
11238
11546
  function createUploadsRouter() {
11239
- const app = new Hono16();
11547
+ const app = new Hono17();
11240
11548
  app.post("/uploads", async (c) => {
11241
11549
  const body = await c.req.parseBody().catch(() => null);
11242
11550
  const file = body?.["file"];
@@ -11281,14 +11589,14 @@ function createUploadsRouter() {
11281
11589
  }
11282
11590
 
11283
11591
  // ../api/src/dashboard/transcribe-routes.ts
11284
- import { Hono as Hono17 } from "hono";
11592
+ import { Hono as Hono18 } from "hono";
11285
11593
  var GROQ_TRANSCRIBE_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
11286
11594
  var MODEL = "whisper-large-v3-turbo";
11287
11595
  var MAX_AUDIO_BYTES = 5 * 1024 * 1024;
11288
11596
  var MAX_PROMPT_CHARS = 400;
11289
11597
  var NO_SPEECH_THRESHOLD = 0.6;
11290
11598
  function createTranscribeRouter() {
11291
- const app = new Hono17();
11599
+ const app = new Hono18();
11292
11600
  app.post("/transcribe", async (c) => {
11293
11601
  if (!await isFeatureEnabled(c, "dictation"))
11294
11602
  return c.json({ error: "feature not enabled" }, 403);
@@ -11328,7 +11636,7 @@ function createTranscribeRouter() {
11328
11636
 
11329
11637
  // ../api/src/dashboard/avatars-routes.ts
11330
11638
  import { createHash as createHash9, randomUUID as randomUUID4 } from "crypto";
11331
- import { Hono as Hono18 } from "hono";
11639
+ import { Hono as Hono19 } from "hono";
11332
11640
  import sharp4 from "sharp";
11333
11641
  import { PutObjectCommand as PutObjectCommand4, DeleteObjectCommand as DeleteObjectCommand3 } from "@aws-sdk/client-s3";
11334
11642
  var AVATAR_SIZE = 192;
@@ -11381,7 +11689,7 @@ function avatarKeyPrefix() {
11381
11689
  return `${KEY_PREFIX2}/${getProfile()}/`;
11382
11690
  }
11383
11691
  function createAvatarsRouter() {
11384
- const app = new Hono18();
11692
+ const app = new Hono19();
11385
11693
  app.get("/avatars/slack", async (c) => {
11386
11694
  const ctx = await resolveAuthContext(c);
11387
11695
  if (!ctx) return c.json({ error: "not signed in" }, 401);
@@ -11430,7 +11738,7 @@ function createAvatarsRouter() {
11430
11738
  }
11431
11739
 
11432
11740
  // ../api/src/dashboard/members-routes.ts
11433
- import { Hono as Hono19 } from "hono";
11741
+ import { Hono as Hono20 } from "hono";
11434
11742
  function inviteLinkFor(c, token) {
11435
11743
  const url = new URL("/auth/invite", callbackUrl(c));
11436
11744
  url.searchParams.set("token", token);
@@ -11438,7 +11746,7 @@ function inviteLinkFor(c, token) {
11438
11746
  }
11439
11747
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11440
11748
  function createMembersRouter() {
11441
- const members = new Hono19();
11749
+ const members = new Hono20();
11442
11750
  members.get("/", async (c) => {
11443
11751
  const ctx = await resolveAuthContext(c);
11444
11752
  if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
@@ -11665,7 +11973,7 @@ function createMembersRouter() {
11665
11973
  }
11666
11974
 
11667
11975
  // ../api/src/dashboard/notifications-routes.ts
11668
- import { Hono as Hono20 } from "hono";
11976
+ import { Hono as Hono21 } from "hono";
11669
11977
 
11670
11978
  // ../api/src/store/notification-store.ts
11671
11979
  function rowToNotification(row) {
@@ -11733,7 +12041,7 @@ async function markAllRead(channelIds, userId) {
11733
12041
 
11734
12042
  // ../api/src/dashboard/notifications-routes.ts
11735
12043
  function createNotificationsRouter() {
11736
- const notifications = new Hono20();
12044
+ const notifications = new Hono21();
11737
12045
  notifications.get("/", async (c) => {
11738
12046
  const auth = c.get("auth");
11739
12047
  const channelIds = getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, auth)).map((ch) => ch.id);
@@ -11760,7 +12068,7 @@ function createNotificationsRouter() {
11760
12068
  return notifications;
11761
12069
  }
11762
12070
  function createThreadReadRouter() {
11763
- const threads = new Hono20();
12071
+ const threads = new Hono21();
11764
12072
  threads.post("/threads/:threadId/read", async (c) => {
11765
12073
  const { userId } = c.get("auth");
11766
12074
  const threadId = c.req.param("threadId");
@@ -11772,7 +12080,7 @@ function createThreadReadRouter() {
11772
12080
  }
11773
12081
 
11774
12082
  // ../api/src/dashboard/push-routes.ts
11775
- import { Hono as Hono21 } from "hono";
12083
+ import { Hono as Hono22 } from "hono";
11776
12084
 
11777
12085
  // ../api/src/push/send.ts
11778
12086
  import webpush from "web-push";
@@ -11875,7 +12183,7 @@ async function pushToUser(userId, payload) {
11875
12183
 
11876
12184
  // ../api/src/dashboard/push-routes.ts
11877
12185
  function createPushRouter() {
11878
- const push = new Hono21();
12186
+ const push = new Hono22();
11879
12187
  push.get("/key", (c) => c.json({ publicKey: getVapidPublicKey() }));
11880
12188
  push.post("/subscribe", async (c) => {
11881
12189
  const { userId } = c.get("auth");
@@ -11906,7 +12214,7 @@ function createPushRouter() {
11906
12214
  }
11907
12215
 
11908
12216
  // ../api/src/dashboard/mcp-oauth-routes.ts
11909
- import { Hono as Hono22 } from "hono";
12217
+ import { Hono as Hono23 } from "hono";
11910
12218
 
11911
12219
  // ../api/src/mcp/crypto.ts
11912
12220
  import { createCipheriv, createDecipheriv, randomBytes as randomBytes7, timingSafeEqual } from "crypto";
@@ -12421,7 +12729,7 @@ function backToDashboard(c, to, params) {
12421
12729
  return c.redirect(`${target.pathname}${target.search}`);
12422
12730
  }
12423
12731
  function createMcpOAuthRouter() {
12424
- const app = new Hono22();
12732
+ const app = new Hono23();
12425
12733
  app.get("/oauth/:provider/start", async (c) => {
12426
12734
  const auth = c.get("auth");
12427
12735
  if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
@@ -12548,7 +12856,7 @@ function createMcpOAuthRouter() {
12548
12856
  }
12549
12857
 
12550
12858
  // ../api/src/dashboard/mcp-routes.ts
12551
- import { Hono as Hono23 } from "hono";
12859
+ import { Hono as Hono24 } from "hono";
12552
12860
 
12553
12861
  // ../api/src/mcp/transport.ts
12554
12862
  var KINDS = ["inherit", "npx", "local", "http", "sse"];
@@ -12678,7 +12986,7 @@ function bindingsByConnection() {
12678
12986
  return map;
12679
12987
  }
12680
12988
  function createMcpRouter() {
12681
- const app = new Hono23();
12989
+ const app = new Hono24();
12682
12990
  app.get("/providers", async (c) => {
12683
12991
  const denied = requireAdmin(c.get("auth"));
12684
12992
  if (denied) return c.json({ error: denied }, 403);
@@ -13060,7 +13368,7 @@ async function buildThreadUsage(threadTs) {
13060
13368
  }
13061
13369
 
13062
13370
  // ../api/src/dashboard/channel-members-routes.ts
13063
- import { Hono as Hono24 } from "hono";
13371
+ import { Hono as Hono25 } from "hono";
13064
13372
  async function isChannelAdmin(ctx, channelId) {
13065
13373
  if (ctx.role === "admin") return true;
13066
13374
  const membership = await getMembershipByUserId(ctx.workspaceId, ctx.userId);
@@ -13069,7 +13377,7 @@ async function isChannelAdmin(ctx, channelId) {
13069
13377
  return members.some((m) => m.membershipId === membership.id && m.role === "channel_admin");
13070
13378
  }
13071
13379
  function createChannelMembersRouter() {
13072
- const router = new Hono24();
13380
+ const router = new Hono25();
13073
13381
  router.get("/:id/members", async (c) => {
13074
13382
  const ctx = await resolveAuthContext(c);
13075
13383
  if (!ctx) return c.json({ error: "workspace access required" }, 403);
@@ -13139,7 +13447,7 @@ function createChannelMembersRouter() {
13139
13447
  }
13140
13448
 
13141
13449
  // ../api/src/dashboard/memories-routes.ts
13142
- import { Hono as Hono25 } from "hono";
13450
+ import { Hono as Hono26 } from "hono";
13143
13451
  async function readPatch(c) {
13144
13452
  const body = await c.req.json().catch(() => ({}));
13145
13453
  const patch = {};
@@ -13167,7 +13475,7 @@ var requireAgentMemory = async (c, next) => {
13167
13475
  await next();
13168
13476
  };
13169
13477
  function createMemoriesRouter() {
13170
- const router = new Hono25();
13478
+ const router = new Hono26();
13171
13479
  for (const path of [
13172
13480
  "/:id/memories",
13173
13481
  "/:id/memories/*",
@@ -13240,7 +13548,7 @@ function createMemoriesRouter() {
13240
13548
  return router;
13241
13549
  }
13242
13550
  function createScopedMemoriesRouter() {
13243
- const router = new Hono25();
13551
+ const router = new Hono26();
13244
13552
  router.use("*", requireAgentMemory);
13245
13553
  const workspace = (c) => ({
13246
13554
  scope: "workspace",
@@ -13299,218 +13607,6 @@ function createScopedMemoriesRouter() {
13299
13607
  return router;
13300
13608
  }
13301
13609
 
13302
- // ../api/src/dashboard/onboarding-routes.ts
13303
- import { Hono as Hono26 } from "hono";
13304
-
13305
- // ../api/src/dashboard/unique-channel-name.ts
13306
- function uniqueChannelName(raw, taken) {
13307
- const base = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
13308
- let name = base;
13309
- for (let i = 2; taken.has(name); i += 1) name = `${base}-${i}`;
13310
- taken.add(name);
13311
- return name;
13312
- }
13313
-
13314
- // ../api/src/dashboard/workspace-greeting.ts
13315
- var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
13316
- var GREETING_MODEL = "openai/gpt-oss-20b";
13317
- var TIMEOUT_MS = 6e3;
13318
- var MAX_LISTED_CHANNELS = 8;
13319
- var GUIDES = [
13320
- "Welcome to Flyspace",
13321
- "Working with the agent",
13322
- "Channels and folders",
13323
- "Docs",
13324
- "Sharing docs",
13325
- "Inviting your team",
13326
- "Automations"
13327
- ];
13328
- 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.
13329
-
13330
- - Plain Markdown, 60 to 110 words, in English. No headings, no emoji, no em or en dashes, no sign-off.
13331
- - Greet the owner by name and name the workspace.
13332
- - Say that #general is public: everyone invited to the workspace can see it.
13333
- - If project channels are listed, mention them as #name and say each one is linked to that project folder. Never invent a channel.
13334
- - Mention the trial or the missing Host only if the facts say so.
13335
- - If guides are listed, say they are in the Docs pane of #general and suggest starting with the first one.
13336
- - Use only the facts given. Do not add buttons or a list of next steps: those are appended for you.`;
13337
- function greetingButtons(input2) {
13338
- const buttons = [];
13339
- if (!input2.hostConnected) buttons.push("Connect a machine");
13340
- if (input2.channels[0]) buttons.push(`Summarise #${input2.channels[0]} for me`);
13341
- if (input2.hostConnected) buttons.push("Give me a quick tour of the guides");
13342
- if (input2.memberCount <= 1) buttons.push("Invite my team");
13343
- buttons.push("Set up a daily digest");
13344
- return buttons.slice(0, 3).map((b) => b.replace(/[|\]]/g, ""));
13345
- }
13346
- function fallbackGreeting(input2) {
13347
- const channels = input2.channels.slice(0, MAX_LISTED_CHANNELS).map((c) => `#${c}`);
13348
- return [
13349
- `Welcome to ${input2.workspaceName}, ${input2.ownerName}.`,
13350
- "This is #general. It is public, so everyone you invite to the workspace can see it.",
13351
- 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.` : "",
13352
- input2.hostConnected ? "A few short guides are waiting in the Docs pane of #general. Start with Welcome to Flyspace." : "",
13353
- "Mention the agent in any channel to get started."
13354
- ].filter(Boolean).join("\n\n");
13355
- }
13356
- async function groqGreeting(input2, apiKey) {
13357
- const facts = [
13358
- `Owner: ${input2.ownerName}`,
13359
- `Workspace: ${input2.workspaceName}`,
13360
- `Plan: ${input2.trialing ? "14-day free trial, just started" : "not on a trial"}`,
13361
- `Host: ${input2.hostConnected ? "a machine is connected, agents can run" : "no machine connected yet, agents cannot run until one is"}`,
13362
- `Project channels: ${input2.channels.slice(0, MAX_LISTED_CHANNELS).join(", ") || "(none yet)"}`,
13363
- `Members: ${input2.memberCount}`,
13364
- `Guides in #general: ${input2.hostConnected ? GUIDES.join(", ") : "(none, no machine connected)"}`
13365
- ].join("\n");
13366
- const res = await fetch(GROQ_API_URL6, {
13367
- method: "POST",
13368
- signal: AbortSignal.timeout(TIMEOUT_MS),
13369
- headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
13370
- body: JSON.stringify({
13371
- model: GREETING_MODEL,
13372
- temperature: 0.6,
13373
- // Reasoning model: the cap covers reasoning tokens too, so keep it roomy.
13374
- reasoning_effort: "low",
13375
- max_tokens: 900,
13376
- messages: [
13377
- { role: "system", content: SYSTEM_PROMPT },
13378
- { role: "user", content: facts }
13379
- ]
13380
- })
13381
- });
13382
- if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
13383
- const data = await res.json();
13384
- const text = data.choices?.[0]?.message?.content?.replace(/\[BUTTONS:[^\]]*\]/g, "").replace(/\s*[–—]\s*/g, ", ").trim();
13385
- return text || null;
13386
- }
13387
- async function buildGreeting(input2) {
13388
- let text = null;
13389
- const apiKey = process.env["GROQ_API_KEY"];
13390
- if (apiKey) {
13391
- try {
13392
- text = await groqGreeting(input2, apiKey);
13393
- } catch (err) {
13394
- console.error("Workspace greeting failed, using the fixed template:", err);
13395
- }
13396
- }
13397
- return `${text ?? fallbackGreeting(input2)}
13398
-
13399
- [BUTTONS: ${greetingButtons(input2).join(" | ")}]`;
13400
- }
13401
-
13402
- // ../api/src/dashboard/onboarding-routes.ts
13403
- var MAX_FOLDERS = 20;
13404
- function hostUnavailable(c, agent, method) {
13405
- return c.json({ error: "host offline", reason: agent.getHostCallFailure?.(method) ?? "offline" }, 503);
13406
- }
13407
- function createOnboardingRouter(agent) {
13408
- const app = new Hono26();
13409
- app.get("/scan", async (c) => {
13410
- const ctx = await resolveAuthContext(c);
13411
- if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13412
- const scan = await agent.scanOnboarding?.();
13413
- if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
13414
- return c.json(scan);
13415
- });
13416
- app.post("/channels", async (c) => {
13417
- const ctx = await resolveAuthContext(c);
13418
- if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13419
- const body = await c.req.json().catch(() => ({}));
13420
- const runner = body["agent"];
13421
- const folders = Array.isArray(body["folders"]) ? body["folders"].filter((f) => typeof f === "string" && f.length > 0) : [];
13422
- if (folders.length > MAX_FOLDERS) return c.json({ error: `at most ${MAX_FOLDERS} folders` }, 400);
13423
- const scan = await agent.scanOnboarding?.();
13424
- if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
13425
- const offered = new Map(scan.projects.map((p) => [p.path, p]));
13426
- const unknown = folders.find((f) => !offered.has(f));
13427
- if (unknown) return c.json({ error: "folder was not offered by the scan" }, 400);
13428
- const taken = new Set(getProfileChannelRegistry().map((ch) => ch.name));
13429
- taken.add("general");
13430
- const items = [{ name: "general" }];
13431
- for (const path of folders) items.push({ name: uniqueChannelName(offered.get(path).name, taken), path });
13432
- const linked = await agent.linkProjectFolders?.(getProfile(), items);
13433
- if (!linked) return hostUnavailable(c, agent, "linkProjectFolders");
13434
- if (runner === "claude" || runner === "codex") {
13435
- await agent.saveLocalDashboardSettings?.({ providers: [{ runner }] });
13436
- }
13437
- const created = [];
13438
- for (const l of linked) {
13439
- created.push(
13440
- l.name === "general" ? await ensureGeneralChannel() : await createChannel({ name: l.name, cwd: l.cwd })
13441
- );
13442
- }
13443
- await markWorkspaceProjectsSetup(ctx.workspaceId);
13444
- return c.json({ channels: created.map((ch) => ({ id: ch.id, name: ch.name })) }, 201);
13445
- });
13446
- app.post("/reset", async (c) => {
13447
- const ctx = await resolveAuthContext(c);
13448
- if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13449
- const { error: error2 } = await getSupabaseClient().from("threads").delete().eq("client_key", ctx.workspaceId);
13450
- if (error2) return c.json({ error: error2.message }, 500);
13451
- await resetWorkspaceOnboarding(ctx.workspaceId);
13452
- return c.json({ ok: true });
13453
- });
13454
- app.get("/greeting", async (c) => {
13455
- const ctx = await resolveAuthContext(c);
13456
- if (ctx?.role !== "admin") return c.json({ pending: false });
13457
- const greetedAt = await getWorkspaceGreetedAt(ctx.workspaceId);
13458
- const pending2 = !!ctx.projectsSetupAt && !greetedAt;
13459
- if (!pending2) return c.json({ pending: pending2, channelId: null, threadId: null });
13460
- const general = await ensureGeneralChannel();
13461
- const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ctx.name?.trim() || ctx.handle);
13462
- return c.json({ pending: pending2, channelId: general.id, threadId });
13463
- });
13464
- app.post("/greeting", async (c) => {
13465
- const ctx = await resolveAuthContext(c);
13466
- if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13467
- const supabase = getSupabaseClient();
13468
- const general = await ensureGeneralChannel();
13469
- const ownerName = ctx.name?.trim() || ctx.handle;
13470
- const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ownerName);
13471
- if (!threadId) return c.json({ error: "could not create the welcome thread" }, 500);
13472
- const done = { channelId: general.id, threadId };
13473
- const replied = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
13474
- if (replied.data) return c.json(done);
13475
- if (await getWorkspaceGreetedAt(ctx.workspaceId))
13476
- return c.json({ error: "already greeted" }, 409);
13477
- const [branding, billing, members] = await Promise.all([
13478
- getWorkspaceBranding(getProfile()).catch(() => null),
13479
- getWorkspaceBilling(ctx.workspaceId).catch(() => null),
13480
- listMemberships(ctx.workspaceId).catch(() => [])
13481
- ]);
13482
- const body = await buildGreeting({
13483
- ownerName,
13484
- workspaceName: branding?.displayName || getProfile(),
13485
- trialing: billing?.planStatus === "trialing",
13486
- hostConnected: !!ctx.agentConnectedAt,
13487
- channels: getProfileChannelRegistry().filter((ch) => !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt).map((ch) => ch.name),
13488
- memberCount: members.filter((m) => !m.isSupport).length
13489
- });
13490
- const again = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
13491
- if (again.data) return c.json(done);
13492
- const reply = await supabase.from("thread_replies").insert({ thread_id: threadId, author: AGENT_AUTHOR, body });
13493
- if (reply.error) return c.json({ error: reply.error.message }, 500);
13494
- await markWorkspaceGreeted(ctx.workspaceId);
13495
- return c.json(done, 201);
13496
- });
13497
- return app;
13498
- }
13499
- async function ensureGreetingThread(workspaceId, channelId, ownerName) {
13500
- const supabase = getSupabaseClient();
13501
- const existing = () => supabase.from("threads").select("id").eq("client_key", workspaceId).maybeSingle();
13502
- const found = await existing();
13503
- if (found.data) return found.data.id;
13504
- const { data, error: error2 } = await supabase.from("threads").insert({
13505
- channel_id: channelId,
13506
- title: `Welcome to your Flyspace, ${ownerName}!`,
13507
- author: AGENT_AUTHOR,
13508
- client_key: workspaceId
13509
- }).select("id").single();
13510
- if (data) return data.id;
13511
- return error2?.code === "23505" ? (await existing()).data?.id ?? null : null;
13512
- }
13513
-
13514
13610
  // ../api/src/dashboard/host-status-signal.ts
13515
13611
  var HOST_STATUS_CHANNEL = "host-status";
13516
13612
  var channelReady = null;
@@ -13610,6 +13706,7 @@ async function resolveThreadLinks(sessions2) {
13610
13706
  }
13611
13707
 
13612
13708
  // ../api/src/dashboard/data-api.ts
13709
+ var SELF_UPGRADE_MIN_VERSION = "0.1.30";
13613
13710
  function registerDataRoutes(api, agent = {}) {
13614
13711
  api.get("/profile", (c) => c.json({ profile: getProfile() }));
13615
13712
  api.get("/host/status", async (c) => {
@@ -13623,7 +13720,20 @@ function registerDataRoutes(api, agent = {}) {
13623
13720
  const cliVersion = agent.getHostCliVersion?.(getProfile()) ?? null;
13624
13721
  const latest2 = cliVersion ? await latestCliVersion() : null;
13625
13722
  const outdated = !!(cliVersion && latest2 && isOlderVersion(cliVersion, latest2));
13626
- return c.json({ online: !paused, cliVersion, ...outdated ? { latestCliVersion: latest2 } : {} });
13723
+ const selfUpgrade = !!cliVersion && !isOlderVersion(cliVersion, SELF_UPGRADE_MIN_VERSION);
13724
+ return c.json({
13725
+ online: !paused,
13726
+ cliVersion,
13727
+ selfUpgrade,
13728
+ ...outdated ? { latestCliVersion: latest2 } : {}
13729
+ });
13730
+ });
13731
+ api.post("/host/upgrade", async (c) => {
13732
+ const auth = c.get("auth");
13733
+ if (!await isHostOwner(auth)) return c.json({ error: "only the Host owner can upgrade it" }, 403);
13734
+ const res = await agent.upgradeHost?.();
13735
+ if (!res) return c.json({ error: "Host is offline" }, 503);
13736
+ return c.json(res, 202);
13627
13737
  });
13628
13738
  api.get("/channels", async (c) => {
13629
13739
  const auth = c.get("auth");
@@ -13811,9 +13921,9 @@ function registerDataRoutes(api, agent = {}) {
13811
13921
  void broadcastHostStatusChange();
13812
13922
  let cancelled = 0;
13813
13923
  if (body.paused) {
13814
- const running = await agent.listAgentRuns?.() ?? [];
13815
- if (running.length > 0) {
13816
- const { data } = await getSupabaseClient().from("threads").select("id, channel_id").in("id", running);
13924
+ const running2 = await agent.listAgentRuns?.() ?? [];
13925
+ if (running2.length > 0) {
13926
+ const { data } = await getSupabaseClient().from("threads").select("id, channel_id").in("id", running2);
13817
13927
  const profile = getProfile();
13818
13928
  for (const t of data ?? []) {
13819
13929
  if (findOwningProfile(t.channel_id) !== profile) continue;
@@ -13831,7 +13941,7 @@ function registerDataRoutes(api, agent = {}) {
13831
13941
  const host = trustedHost(c);
13832
13942
  const known = await listKnownWorkspaces();
13833
13943
  const hostProfile = profileFromHost(host, known);
13834
- const running = new Set(await agent.listRunningProfiles?.() ?? []);
13944
+ const running2 = new Set(await agent.listRunningProfiles?.() ?? []);
13835
13945
  const auth = c.get("auth");
13836
13946
  const [{ data: memberships }, workspaces] = await Promise.all([
13837
13947
  getSupabaseClient().from("memberships").select("workspace_id").eq("user_id", auth.userId),
@@ -13848,7 +13958,7 @@ function registerDataRoutes(api, agent = {}) {
13848
13958
  return {
13849
13959
  name,
13850
13960
  active: name === active,
13851
- running: running.has(name),
13961
+ running: running2.has(name),
13852
13962
  displayName: branding.displayName,
13853
13963
  logo: branding.logo,
13854
13964
  hostname: (hostProfile ? workspaceHostname(host, name) : null) ?? void 0
@@ -14222,16 +14332,16 @@ function installSkillSync(profiles, options = {}) {
14222
14332
  if (!databaseReachable()) return () => {
14223
14333
  };
14224
14334
  const { intervalMs = SYNC_INTERVAL_MS, onError = (e) => console.error("[skill-sync]", e) } = options;
14225
- let running = false;
14335
+ let running2 = false;
14226
14336
  const tick = async () => {
14227
- if (running) return;
14228
- running = true;
14337
+ if (running2) return;
14338
+ running2 = true;
14229
14339
  try {
14230
14340
  for (const profile of profiles) {
14231
14341
  await runWithProfile(profile, () => pushSkillScopes()).catch(onError);
14232
14342
  }
14233
14343
  } finally {
14234
- running = false;
14344
+ running2 = false;
14235
14345
  }
14236
14346
  };
14237
14347
  void tick();
@@ -14671,7 +14781,7 @@ import {
14671
14781
  readSync as readSync2,
14672
14782
  readdirSync as readdirSync5,
14673
14783
  readFileSync as readFileSync20,
14674
- realpathSync as realpathSync3,
14784
+ realpathSync as realpathSync4,
14675
14785
  renameSync,
14676
14786
  rmSync as rmSync8,
14677
14787
  statSync as statSync6,
@@ -14783,8 +14893,8 @@ var inFlight5 = /* @__PURE__ */ new Map();
14783
14893
  async function readOrCreateDocTranslation(input2) {
14784
14894
  const contentHash = translationHash(input2.title, input2.content);
14785
14895
  const key = `${input2.channelId}:${input2.sourcePath}:${contentHash}:${input2.lang}`;
14786
- const running = inFlight5.get(key);
14787
- if (running) return running;
14896
+ const running2 = inFlight5.get(key);
14897
+ if (running2) return running2;
14788
14898
  const job = (async () => {
14789
14899
  const cached = await getDocTranslation(input2.channelId, input2.sourcePath, input2.lang, contentHash);
14790
14900
  if (cached) return cached;
@@ -17514,7 +17624,7 @@ function moveDocEntry(root, from, to) {
17514
17624
  return { status: "into-self" };
17515
17625
  }
17516
17626
  try {
17517
- if (realpathSync3(finalAbs) !== realpathSync3(absFrom)) return { status: "exists" };
17627
+ if (realpathSync4(finalAbs) !== realpathSync4(absFrom)) return { status: "exists" };
17518
17628
  } catch {
17519
17629
  }
17520
17630
  renameSync(absFrom, finalAbs);
@@ -18741,6 +18851,26 @@ function readDocShareBundle({ cwd, sourcePath }) {
18741
18851
  return { status: "ok", kind, title, content: content + renderAttachments(attachments), assets };
18742
18852
  }
18743
18853
 
18854
+ // src/dashboard/host-upgrade.ts
18855
+ import { spawn as spawn7 } from "child_process";
18856
+ import { mkdirSync as mkdirSync13, openSync as openSync3 } from "fs";
18857
+ import { join as join27 } from "path";
18858
+ var running = false;
18859
+ function startHostUpgrade() {
18860
+ if (running) return { started: false };
18861
+ mkdirSync13(paths.LOG_DIR, { recursive: true });
18862
+ const log5 = openSync3(join27(paths.LOG_DIR, "host-upgrade.log"), "a");
18863
+ const child = spawn7(process.execPath, [process.argv[1] ?? "", "upgrade"], {
18864
+ detached: true,
18865
+ stdio: ["ignore", log5, log5]
18866
+ });
18867
+ running = true;
18868
+ child.on("exit", () => running = false);
18869
+ child.on("error", () => running = false);
18870
+ child.unref();
18871
+ return { started: true };
18872
+ }
18873
+
18744
18874
  // src/dashboard/agent-bridge-host.ts
18745
18875
  var HOSTED_DATA_SOCKET_ORIGIN = "wss://flyspace-api.fly.dev";
18746
18876
  function dataSocketUrl() {
@@ -18781,7 +18911,8 @@ function localAgentCallbacks(chatAgent) {
18781
18911
  docsRequest: (req) => handleDocsRequest(req),
18782
18912
  hostRequest: (req) => handleHostRequest(req),
18783
18913
  listSessions: () => listLocalSessions(),
18784
- scanOnboarding: () => scanOnboarding(),
18914
+ upgradeHost: () => startHostUpgrade(),
18915
+ scanOnboarding: (slug) => scanOnboarding(slug),
18785
18916
  linkProjectFolders: (slug, items) => linkProjectFolders(slug, items),
18786
18917
  isHostOnline: () => true
18787
18918
  };
@@ -19038,7 +19169,7 @@ function startPushWatcher() {
19038
19169
 
19039
19170
  // src/dashboard/machine-server.ts
19040
19171
  import { existsSync as existsSync31 } from "fs";
19041
- import { join as join27 } from "path";
19172
+ import { join as join28 } from "path";
19042
19173
  import { serveStatic } from "@hono/node-server/serve-static";
19043
19174
 
19044
19175
  // src/dashboard/app-icon-routes.ts
@@ -19120,7 +19251,7 @@ function pngResponse(c, png) {
19120
19251
  }
19121
19252
 
19122
19253
  // src/dashboard/machine-server.ts
19123
- var WEB_DIST = join27(paths.WEB_ROOT, "dist");
19254
+ var WEB_DIST = join28(paths.WEB_ROOT, "dist");
19124
19255
  async function bootMachine() {
19125
19256
  await reconcileAll().catch(
19126
19257
  (err) => console.error("[dashboard] reconcileAll failed:", err.message)
@@ -19140,14 +19271,14 @@ async function bootMachine() {
19140
19271
  function mountSpa(app) {
19141
19272
  app.route("/share", createPublicShareAssetRouter(WEB_DIST));
19142
19273
  app.route("/", createAppIconRouter());
19143
- if (existsSync31(WEB_DIST) || existsSync31(join27(paths.WEB_ROOT, "package.json"))) {
19274
+ if (existsSync31(WEB_DIST) || existsSync31(join28(paths.WEB_ROOT, "package.json"))) {
19144
19275
  app.use("/*", serveStatic({ root: WEB_DIST }));
19145
19276
  }
19146
19277
  app.get("/assets/*", (c) => c.text("stale asset \u2014 reload for the current build", 404));
19147
- const spaFallback = serveStatic({ path: join27(WEB_DIST, "index.html") });
19278
+ const spaFallback = serveStatic({ path: join28(WEB_DIST, "index.html") });
19148
19279
  app.get("*", async (c, next) => {
19149
- if (!existsSync31(join27(WEB_DIST, "index.html"))) {
19150
- const isCheckout = existsSync31(join27(paths.WEB_ROOT, "package.json"));
19280
+ if (!existsSync31(join28(WEB_DIST, "index.html"))) {
19281
+ const isCheckout = existsSync31(join28(paths.WEB_ROOT, "package.json"));
19151
19282
  return c.text(
19152
19283
  isCheckout ? `Workspace UI build missing (${WEB_DIST}).
19153
19284
  Run \`pnpm build:web\` from the repo root.
@@ -19276,13 +19407,13 @@ function flagValue(args, name) {
19276
19407
  function openBrowser2(url) {
19277
19408
  const cmd = platform7() === "darwin" ? "open" : platform7() === "win32" ? "start" : "xdg-open";
19278
19409
  try {
19279
- spawn7(cmd, [url], { stdio: "ignore", detached: true }).unref();
19410
+ spawn8(cmd, [url], { stdio: "ignore", detached: true }).unref();
19280
19411
  } catch {
19281
19412
  }
19282
19413
  }
19283
19414
  function ensureWebBuild() {
19284
- if (existsSync32(join28(paths.WEB_ROOT, "dist", "index.html"))) return;
19285
- if (!existsSync32(join28(paths.WEB_ROOT, "package.json"))) return;
19415
+ if (existsSync32(join29(paths.WEB_ROOT, "dist", "index.html"))) return;
19416
+ if (!existsSync32(join29(paths.WEB_ROOT, "package.json"))) return;
19286
19417
  console.log(" packages/ui/dist missing, building the dashboard UI...");
19287
19418
  const result = spawnSync("pnpm", ["build"], {
19288
19419
  cwd: paths.WEB_ROOT,
@@ -19332,7 +19463,7 @@ async function runDashboard(args) {
19332
19463
  }
19333
19464
 
19334
19465
  // src/commands/workspace.ts
19335
- import { existsSync as existsSync33, mkdirSync as mkdirSync13, writeFileSync as writeFileSync13 } from "fs";
19466
+ import { existsSync as existsSync33, mkdirSync as mkdirSync14, writeFileSync as writeFileSync13 } from "fs";
19336
19467
  var USAGE7 = `
19337
19468
  Usage: flyspace workspace <subcommand> [options]
19338
19469
 
@@ -19436,7 +19567,7 @@ async function provisionChannels(specs) {
19436
19567
  }
19437
19568
  const cwd = expandPath(spec.cwd);
19438
19569
  if (!existsSync33(cwd)) {
19439
- mkdirSync13(cwd, { recursive: true });
19570
+ mkdirSync14(cwd, { recursive: true });
19440
19571
  console.log(` created cwd ${cwd}`);
19441
19572
  }
19442
19573
  await createChannel({ name: spec.name, cwd, position: index });
@@ -19513,10 +19644,10 @@ async function runReconcileCmd(args) {
19513
19644
  }
19514
19645
 
19515
19646
  // src/cli.ts
19516
- import { join as join29 } from "path";
19647
+ import { join as join30 } from "path";
19517
19648
  var home = flyspaceHome();
19518
19649
  if (home) {
19519
- const bin = join29(home, "bin");
19650
+ const bin = join30(home, "bin");
19520
19651
  const path = process.env.PATH ?? "";
19521
19652
  if (!path.split(":").includes(bin)) process.env.PATH = path ? `${bin}:${path}` : bin;
19522
19653
  }
@@ -19654,7 +19785,7 @@ async function main() {
19654
19785
  break;
19655
19786
  case "-v":
19656
19787
  case "--version":
19657
- printRaw("0.1.27");
19788
+ printRaw("0.1.30");
19658
19789
  break;
19659
19790
  case "-h":
19660
19791
  case "--help":