flyspace-cli 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7,6 +7,10 @@ import {
7
7
  BUILT_IN_OUTPUT_STYLES,
8
8
  DEFAULT_CODEX_MODEL,
9
9
  DEFAULT_TIMEZONE,
10
+ DEFAULT_TITLE_RULES,
11
+ DefaultChannelError,
12
+ HANDLE_PATTERN,
13
+ HandleTakenError,
10
14
  InvalidTransferError,
11
15
  LastAdminError,
12
16
  MessageQueue,
@@ -23,6 +27,7 @@ import {
23
27
  authorFor,
24
28
  blockToFallbackText,
25
29
  bodyTextFor,
30
+ buildAutoTitle,
26
31
  buildFilesPromptSection,
27
32
  buildSystemPrompt,
28
33
  canSeeChannel,
@@ -38,9 +43,14 @@ import {
38
43
  deleteSkillFile,
39
44
  deleteWorkspace,
40
45
  displayNameFor,
46
+ docsAccessPromptNote,
41
47
  ensureAgentDmChannel,
48
+ ensureGeneralChannel,
49
+ ensureLogicalCwdDir,
50
+ ensureMemberDmChannel,
42
51
  ensureUsersCached,
43
52
  expandPath,
53
+ extractCategories,
44
54
  findChannel,
45
55
  findChannelById,
46
56
  findSkillDetail,
@@ -63,6 +73,7 @@ import {
63
73
  isCwdUsable,
64
74
  isEffort,
65
75
  isLockedPlan,
76
+ isLogicalCwd,
66
77
  isValidSkillName,
67
78
  lastRunsForWorkspace,
68
79
  listActiveUsers,
@@ -90,6 +101,7 @@ import {
90
101
  lookupById,
91
102
  markInviteResent,
92
103
  markWorkspaceAgentConnected,
104
+ markWorkspaceProjectsSetup,
93
105
  markdownToBlocks,
94
106
  mcpStrictConfigEnabled,
95
107
  mimetypeSizeLimit,
@@ -105,6 +117,7 @@ import {
105
117
  resolveChannelId,
106
118
  resolveChannelPrompt,
107
119
  resolveCwd,
120
+ resolveLogicalCwd,
108
121
  resolveSelectedOutputStyle,
109
122
  resolveUserId,
110
123
  runAutomation,
@@ -117,6 +130,7 @@ import {
117
130
  saveWorkspacePrompt,
118
131
  setAgentPaused,
119
132
  setAllowGuestAccess,
133
+ setMembershipHandle,
120
134
  setOutputStyleDefault,
121
135
  setWorkspaceStripeSubscription,
122
136
  slackConfigured,
@@ -136,7 +150,7 @@ import {
136
150
  warnOnBrokenChannelCwds,
137
151
  workspaceSkillsDir,
138
152
  writeSkillFile
139
- } from "./chunk-6D2PIFEA.js";
153
+ } from "./chunk-BZYTXXF7.js";
140
154
  import {
141
155
  firstReplyBody,
142
156
  getThread,
@@ -144,7 +158,7 @@ import {
144
158
  listThreads,
145
159
  moveThread,
146
160
  resolveChannel
147
- } from "./chunk-X7SJ653V.js";
161
+ } from "./chunk-6PJ73XYW.js";
148
162
  import {
149
163
  AGENT_AUTHOR,
150
164
  DEFAULT_PROFILE,
@@ -154,15 +168,17 @@ import {
154
168
  getR2Bucket,
155
169
  getR2Client,
156
170
  getSupabaseClient,
171
+ hasSupabaseCredentials,
157
172
  isHumanAuthor,
158
173
  paths,
159
174
  postToDashboardChannel,
160
175
  resolveDashboardChannelId,
161
176
  runWithProfile,
177
+ setSupabaseClient,
162
178
  sumR2PrefixBytes,
163
179
  syncPostToDashboard,
164
180
  uploadLocalImages
165
- } from "./chunk-QZIN7MWV.js";
181
+ } from "./chunk-ZBMYF5LX.js";
166
182
 
167
183
  // src/logger.ts
168
184
  var ALREADY_STAMPED = /^\[\d{4}-\d{2}-\d{2}T/;
@@ -852,7 +868,7 @@ Restarting ${installed.length} instance(s)...`);
852
868
  }
853
869
  }
854
870
  async function runUpgrade() {
855
- const current = "0.1.1";
871
+ const current = "0.1.4";
856
872
  console.log(`Current version: ${current}`);
857
873
  console.log("Checking for updates...\n");
858
874
  try {
@@ -1491,10 +1507,10 @@ async function runLogs(args) {
1491
1507
  }
1492
1508
 
1493
1509
  // src/commands/slack.ts
1494
- import { execFileSync as execFileSync3 } from "child_process";
1495
- import { existsSync as existsSync28, readFileSync as readFileSync16, writeFileSync as writeFileSync9 } from "fs";
1510
+ import { execFileSync as execFileSync4 } from "child_process";
1511
+ import { existsSync as existsSync28, readFileSync as readFileSync17, writeFileSync as writeFileSync10 } from "fs";
1496
1512
  import { resolve as resolve7 } from "path";
1497
- import { tmpdir as tmpdir3, homedir as homedir8 } from "os";
1513
+ import { tmpdir as tmpdir3, homedir as homedir9 } from "os";
1498
1514
  import { input, confirm, select, checkbox } from "@inquirer/prompts";
1499
1515
  import { WebClient as WebClient3 } from "@slack/web-api";
1500
1516
 
@@ -1547,6 +1563,10 @@ async function emailForToken(token, type) {
1547
1563
  }
1548
1564
  }
1549
1565
 
1566
+ // src/dashboard/profile-config.ts
1567
+ import { homedir as homedir4 } from "os";
1568
+ import { join as join7 } from "path";
1569
+
1550
1570
  // src/dashboard/profile-config-cache.ts
1551
1571
  var cache = /* @__PURE__ */ new Map();
1552
1572
  function setProfileConfigCache(configs) {
@@ -1561,6 +1581,11 @@ function cachedProfileConfig(profile = getProfile()) {
1561
1581
  function profileEnv(key) {
1562
1582
  return cachedProfileConfig()?.env[key] || process.env[key] || void 0;
1563
1583
  }
1584
+ function profileClaudeConfigDir() {
1585
+ const config = cachedProfileConfig();
1586
+ if (!config) return process.env["CLAUDE_CONFIG_DIR"] || void 0;
1587
+ return config.env["CLAUDE_CONFIG_DIR"]?.trim() || join7(homedir4(), ".claude");
1588
+ }
1564
1589
  function profileOauthToken() {
1565
1590
  const env = cachedProfileConfig()?.env ?? {};
1566
1591
  if (env["CLAUDE_CODE_OAUTH_TOKEN"]) return env["CLAUDE_CODE_OAUTH_TOKEN"];
@@ -1587,7 +1612,7 @@ function resolveChannelConfig(channelId) {
1587
1612
  mounts: channel?.mounts ?? [],
1588
1613
  prompt: channel?.prompt?.trim() || void 0,
1589
1614
  codexModel: profileEnv("CUSTIE_CODEX_MODEL")?.trim() || DEFAULT_CODEX_MODEL,
1590
- claudeConfigDir: profileEnv("CLAUDE_CONFIG_DIR"),
1615
+ claudeConfigDir: profileClaudeConfigDir(),
1591
1616
  claudeCodeOauthToken: profileOauthToken(),
1592
1617
  agentName: channel?.agentName ?? null,
1593
1618
  profile: channel?.profile ?? getProfile(),
@@ -1758,7 +1783,10 @@ var THEME_ACCENTS = {
1758
1783
  sanctuary: "#a13c4a",
1759
1784
  light: "#3a66a3",
1760
1785
  "sanctuary-light": "#a13c4a",
1761
- folio: "#9c4a34"
1786
+ folio: "#9c4a34",
1787
+ // Cheer's yellow can't carry the white button text mail uses, so mail
1788
+ // takes the theme's dark ink instead.
1789
+ cheer: "#241c15"
1762
1790
  };
1763
1791
  function accentOf(id) {
1764
1792
  return THEME_ACCENTS[id ?? "default"] ?? THEME_ACCENTS["default"];
@@ -1786,6 +1814,10 @@ var FONTS = {
1786
1814
  stack: "system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang TC','Noto Sans TC',sans-serif"
1787
1815
  },
1788
1816
  serif: { stack: "Georgia,'Noto Serif TC','Times New Roman',serif" },
1817
+ inter: {
1818
+ stack: "Inter,'PingFang TC','Noto Sans TC',-apple-system,'Segoe UI',sans-serif",
1819
+ google: "Inter:wght@400;600;700"
1820
+ },
1789
1821
  exo2: {
1790
1822
  stack: "'Exo 2','PingFang TC','Noto Sans TC',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
1791
1823
  google: "Exo+2:wght@400;500;600;700"
@@ -2032,6 +2064,13 @@ function trustedHost(c) {
2032
2064
  const trusted = expected && forwarded && c.req.header("x-share-proxy-key") === expected ? forwarded : void 0;
2033
2065
  return trusted ?? c.req.header("host");
2034
2066
  }
2067
+ function isDevHost(c) {
2068
+ const expected = process.env["SHARE_PROXY_KEY"];
2069
+ const forwarded = c.req.header("x-forwarded-host");
2070
+ return Boolean(
2071
+ expected && forwarded?.startsWith("dev-") && c.req.header("x-share-proxy-key") === expected
2072
+ );
2073
+ }
2035
2074
 
2036
2075
  // src/dashboard/cors.ts
2037
2076
  function allowedOrigins() {
@@ -2174,8 +2213,8 @@ async function listSetupInvites() {
2174
2213
 
2175
2214
  // src/workspace-slug.ts
2176
2215
  var SLUG_PATTERN = /^[a-z](?:[a-z0-9]|-(?!-))*[a-z0-9]$/;
2177
- var SLUG_MIN = 3;
2178
- var SLUG_MAX = 30;
2216
+ var SLUG_MIN = 5;
2217
+ var SLUG_MAX = 15;
2179
2218
  var RESERVED = /* @__PURE__ */ new Set([
2180
2219
  // Platform hosts and routes (`app`, `www`, `admin` are also host-profile.ts's RESERVED_LABELS).
2181
2220
  "app",
@@ -2493,6 +2532,7 @@ async function provisionWorkspace(params) {
2493
2532
  );
2494
2533
  throw err;
2495
2534
  }
2535
+ await runWithProfile(params.slug, ensureGeneralChannel).catch((err) => console.error(`[workspace-setup] general channel for ${params.slug} failed:`, err));
2496
2536
  clearKnownWorkspacesCache();
2497
2537
  return workspace;
2498
2538
  }
@@ -2559,7 +2599,6 @@ body { margin:0; }
2559
2599
  @media (min-width:800px) {
2560
2600
  .fs-split { grid-template-columns:minmax(0,5fr) minmax(0,6fr); gap:56px; }
2561
2601
  .fs-split > div + div { border-left:1px solid ${base.line}; padding-left:56px; }
2562
- .fs-pair { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
2563
2602
  }
2564
2603
  </style>
2565
2604
  </head>
@@ -2603,9 +2642,9 @@ function hostDiagram(display, accent) {
2603
2642
  const agent = (x, title, path, fill) => {
2604
2643
  const unit = 16 + 8 + title.length * 7;
2605
2644
  const start = x + 67 - unit / 2;
2606
- return `<rect x="${x}" y="182" width="134" height="38" rx="8" fill="${DARK.bg}" stroke="${DARK.line}"/><svg x="${start}" y="193" width="16" height="16" viewBox="0 0 24 24"><path d="${path}" fill="${fill}" fill-rule="evenodd"/></svg><text x="${start + 24}" y="201" dominant-baseline="central" fill="${DARK.ink}" font-size="13" font-weight="600">${title}</text>`;
2645
+ return `<rect x="${x}" y="116" width="134" height="38" rx="8" fill="${DARK.bg}" stroke="${DARK.line}"/><svg x="${start}" y="127" width="16" height="16" viewBox="0 0 24 24"><path d="${path}" fill="${fill}" fill-rule="evenodd"/></svg><text x="${start + 24}" y="135" dominant-baseline="central" fill="${DARK.ink}" font-size="13" font-weight="600">${title}</text>`;
2607
2646
  };
2608
- return `<svg viewBox="0 0 320 232" width="100%" role="img" aria-labelledby="fs-host-title" style="display:block;max-width:360px;margin:20px 0 0;font-family:${display};"><title id="fs-host-title">The Flyspace Host on your machine connects to your workspace over a WebSocket and runs Claude Code or Codex CLI.</title><defs><marker id="fs-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="${accent}"/></marker></defs>` + box(10, "Your Workspace") + arrow(88, 51) + `<text x="172" y="73" fill="${DARK.dim}" font-size="11">WebSocket</text><rect x="8" y="90" width="304" height="136" rx="10" fill="none" stroke="${DARK.dim}" stroke-dasharray="4 4"/><text x="20" y="108" fill="${DARK.dim}" font-size="10" font-weight="600" letter-spacing="0.06em">YOUR MACHINE</text>` + box(116, "Flyspace Host") + arrow(156, 180, 87) + arrow(156, 180, 233) + agent(20, "Claude Code", CLAUDE_PATH, "#D97757") + agent(166, "Codex CLI", CODEX_PATH, DARK.ink) + `</svg>`;
2647
+ return `<svg viewBox="0 0 320 170" width="100%" role="img" aria-labelledby="fs-host-title" style="display:block;max-width:360px;margin:20px 0 0;font-family:${display};"><title id="fs-host-title">The Flyspace Host on your machine connects to your workspace over the Flyspace Socket and runs Claude Code or Codex CLI.</title><defs><marker id="fs-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="${accent}"/></marker></defs>` + box(10, "Your Workspace") + arrow(88, 51) + `<text x="172" y="73" fill="${DARK.dim}" font-size="11">Flyspace Socket</text><rect x="8" y="90" width="304" height="74" rx="10" fill="none" stroke="${DARK.dim}" stroke-dasharray="4 4"/><text x="20" y="108" fill="${DARK.dim}" font-size="10" font-weight="600" letter-spacing="0.06em">YOUR MACHINE</text>` + agent(20, "Claude Code", CLAUDE_PATH, "#D97757") + agent(166, "Codex CLI", CODEX_PATH, DARK.ink) + `</svg>`;
2609
2648
  }
2610
2649
  function setupPage(c, token, invite, form = {}) {
2611
2650
  const display = fontOf(void 0).stack;
@@ -2613,7 +2652,7 @@ function setupPage(c, token, invite, form = {}) {
2613
2652
  const host = trustedHost(c) ?? "";
2614
2653
  const suffix = `.${host.replace(/:\d+$/, "").split(".").slice(1).join(".")}`;
2615
2654
  return page(
2616
- `<div class="fs-split"><div>` + masthead2("Flyspace", FLYSPACE_MARK, display, true) + `<div class="fs-ink" style="font:600 20px/1.3 ${display};color:${DARK.ink};margin-bottom:10px;">Create your workspace</div><p class="fs-ink" style="margin:0 0 8px;font:500 15px/1.5 ${display};color:${DARK.ink};">Welcome to the early access cohort.</p><p class="fs-muted" style="margin:0;font:400 14px/1.6 ${display};color:${DARK.muted};">Your agents run on your own machine. The Flyspace Host connects out to this workspace, so Claude Code or Codex works in each channel's real folder, with your own tools and keys.</p>` + hostDiagram(display, accent) + `</div><div><form id="setup" method="post" action="/auth/new-workspace"><input type="hidden" name="token" value="${escapeHtml(token)}"><div class="fs-dim" style="margin:0 0 14px;font:600 11px/1.4 ${display};letter-spacing:0.06em;text-transform:uppercase;color:${DARK.dim};">Your account</div><label for="username" class="fs-muted" style="display:block;font:400 13px/1.4 ${display};color:${DARK.muted};margin-bottom:4px;">You'll be its Owner, signed in as</label><input id="username" name="username" type="email" autocomplete="username" readonly tabindex="-1" value="${escapeHtml(invite.email)}" style="display:block;width:100%;box-sizing:border-box;border:0;outline:0;background:transparent;padding:0;margin-bottom:20px;font:600 15px/1.4 ${display};color:${DARK.ink};text-overflow:ellipsis;"><div class="fs-pair"><div>` + passwordField("password", "Password", "new-password", display, !!form.passwordError) + `</div><div>` + passwordField("confirm", "Confirm password", "new-password", display, !!form.passwordError) + `</div></div>` + (form.passwordError ? `<p style="margin:-8px 0 16px;font:500 13px/1.5 ${display};color:#d33;">${escapeHtml(form.passwordError)}</p>` : `<p class="fs-dim" style="margin:-8px 0 20px;font:400 12px/1.6 ${display};color:${DARK.dim};">At least ${PASSWORD_MIN} characters. You'll use it to sign in to your workspace.</p>`) + `<div style="border-top:1px solid ${DARK.line};margin:4px 0 24px;"></div><div class="fs-dim" style="margin:0 0 14px;font:600 11px/1.4 ${display};letter-spacing:0.06em;text-transform:uppercase;color:${DARK.dim};">Your workspace</div><label for="name" class="fs-ink" style="display:block;font:600 13px/1.4 ${display};color:${DARK.ink};margin-bottom:6px;">Workspace name</label><input id="name" name="name" required autocomplete="organization" maxlength="${DISPLAY_NAME_MAX}" value="${escapeHtml(form.name ?? "")}" placeholder="My Team" class="fs-card" style="display:block;width:100%;box-sizing:border-box;border:1px solid ${DARK.line};border-radius:8px;padding:12px;outline:0;background:transparent;font:500 15px/1.2 ${display};color:inherit;"><p class="fs-dim" style="margin:8px 0 20px;font:400 12px/1.6 ${display};color:${DARK.dim};">Shown across your workspace. You can change it later.</p><label for="slug" class="fs-ink" style="display:block;font:600 13px/1.4 ${display};color:${DARK.ink};margin-bottom:6px;">Workspace URL</label><div class="fs-card" style="display:flex;align-items:center;border:1px solid ${form.error ? "#d33" : DARK.line};border-radius:8px;padding:0 12px;"><input id="slug" name="slug" required autocomplete="off" autocapitalize="none" spellcheck="false" minlength="${SLUG_MIN}" maxlength="${SLUG_MAX}" pattern="[a-z](?:[a-z0-9]|-(?!-))*[a-z0-9]" value="${escapeHtml(form.slug ?? "")}" placeholder="my-team" style="flex:1;min-width:0;border:0;outline:0;background:transparent;padding:12px 0;font:500 15px/1.2 ${display};color:inherit;"><span class="fs-dim" style="font:400 15px/1 ${display};color:${DARK.dim};white-space:nowrap;">${escapeHtml(suffix)}</span></div>` + (form.error ? `<p style="margin:8px 0 0;font:500 13px/1.5 ${display};color:#d33;">${escapeHtml(form.error)}</p>` : "") + `<p class="fs-dim" style="margin:8px 0 20px;font:400 12px/1.6 ${display};color:${DARK.dim};">Lowercase letters, digits and hyphens, ${SLUG_MIN} to ${SLUG_MAX} characters. <strong style="font-weight:600;color:${DARK.ink};">It can't be changed later.</strong></p><button id="setup-submit" class="fs-btn" type="submit" style="background:${accent};color:${DARK.onAccent};border-radius:8px;padding:13px 24px;width:100%;font:600 15px/1 ${display};">Create workspace</button></form><div style="margin-top:20px;border-left:2px solid ${accent};padding:2px 0 2px 12px;"><p class="fs-muted" style="margin:0;font:400 13px/1.6 ${display};color:${DARK.muted};"><strong class="fs-ink" style="font-weight:600;color:${DARK.ink};">Next:</strong> we'll help you connect Claude Code or Codex CLI.</p></div><p class="fs-dim" style="margin:16px 0 0;font:400 12px/1.6 ${display};color:${DARK.dim};">By creating a workspace, you agree to the <a href="https://flyspace.ai/terms/" target="_blank" rel="noreferrer" style="color:inherit;text-decoration:underline;text-underline-offset:2px;">Terms</a> and <a href="https://flyspace.ai/privacy/" target="_blank" rel="noreferrer" style="color:inherit;text-decoration:underline;text-underline-offset:2px;">Privacy Policy</a>.</p></div></div><script>(()=>{const n=document.getElementById('name'),s=document.getElementById('slug');let touched=!!s.value;s.addEventListener('input',()=>{touched=true});n.addEventListener('input',()=>{if(touched)return;s.value=n.value.normalize('NFKD').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').replace(/^[^a-z]+/,'').slice(0,${SLUG_MAX}).replace(/-+$/,'')})})()</script><script>document.getElementById('setup').addEventListener('submit',()=>{const b=document.getElementById('setup-submit');b.disabled=true;b.style.opacity='0.7';b.style.cursor='progress';b.textContent='Creating workspace\u2026'})</script><script>document.querySelectorAll('[data-toggle-password]').forEach(b=>b.addEventListener('click',()=>{const i=document.getElementById(b.dataset.togglePassword),show=i.type==='password';i.type=show?'text':'password';b.setAttribute('aria-pressed',String(show));b.setAttribute('aria-label',show?'Hide password':'Show password');b.querySelector('[data-eye]').style.display=show?'none':'';b.querySelector('[data-eye-off]').style.display=show?'':'none'}))</script>`,
2655
+ `<div class="fs-split"><div>` + masthead2("Flyspace", FLYSPACE_MARK, display, true) + `<div class="fs-ink" style="font:600 20px/1.3 ${display};color:${DARK.ink};margin-bottom:10px;">Create your workspace</div><p class="fs-ink" style="margin:0 0 8px;font:500 15px/1.5 ${display};color:${DARK.ink};">Welcome to the early access cohort.</p><p class="fs-muted" style="margin:0;font:400 14px/1.6 ${display};color:${DARK.muted};">Your agents run on your own machine. The Flyspace Host connects out to this workspace, so Claude Code or Codex works in each channel's real folder, with your own tools and keys.</p>` + hostDiagram(display, accent) + `</div><div><form id="setup" method="post" action="/auth/new-workspace"><input type="hidden" name="token" value="${escapeHtml(token)}"><div class="fs-dim" style="margin:0 0 14px;font:600 11px/1.4 ${display};letter-spacing:0.06em;text-transform:uppercase;color:${DARK.dim};">Your account</div><label for="username" class="fs-muted" style="display:block;font:400 13px/1.4 ${display};color:${DARK.muted};margin-bottom:4px;">You'll be its Owner, signed in as</label><input id="username" name="username" type="email" autocomplete="username" readonly tabindex="-1" value="${escapeHtml(invite.email)}" style="display:block;width:100%;box-sizing:border-box;border:0;outline:0;background:transparent;padding:0;margin-bottom:20px;font:600 15px/1.4 ${display};color:${DARK.ink};text-overflow:ellipsis;"><div style="margin-bottom:14px;">` + passwordField("password", "Password", "new-password", display, !!form.passwordError) + `</div><div>` + passwordField("confirm", "Confirm password", "new-password", display, !!form.passwordError) + `</div>` + (form.passwordError ? `<p style="margin:-8px 0 16px;font:500 13px/1.5 ${display};color:#d33;">${escapeHtml(form.passwordError)}</p>` : `<p class="fs-dim" style="margin:-8px 0 20px;font:400 12px/1.6 ${display};color:${DARK.dim};">At least ${PASSWORD_MIN} characters. You'll use it to sign in to your workspace.</p>`) + `<div style="border-top:1px solid ${DARK.line};margin:4px 0 24px;"></div><div class="fs-dim" style="margin:0 0 14px;font:600 11px/1.4 ${display};letter-spacing:0.06em;text-transform:uppercase;color:${DARK.dim};">Your workspace</div><label for="name" class="fs-ink" style="display:block;font:600 13px/1.4 ${display};color:${DARK.ink};margin-bottom:6px;">Workspace name</label><input id="name" name="name" required autocomplete="organization" maxlength="${DISPLAY_NAME_MAX}" value="${escapeHtml(form.name ?? "")}" placeholder="My Team" class="fs-card" style="display:block;width:100%;box-sizing:border-box;border:1px solid ${DARK.line};border-radius:8px;padding:12px;outline:0;background:transparent;font:500 15px/1.2 ${display};color:inherit;"><p class="fs-dim" style="margin:8px 0 20px;font:400 12px/1.6 ${display};color:${DARK.dim};">Shown across your workspace. You can change it later.</p><label for="slug" class="fs-ink" style="display:block;font:600 13px/1.4 ${display};color:${DARK.ink};margin-bottom:6px;">Workspace URL</label><div class="fs-card" style="display:flex;align-items:center;border:1px solid ${form.error ? "#d33" : DARK.line};border-radius:8px;padding:0 12px;"><input id="slug" name="slug" required autocomplete="off" autocapitalize="none" spellcheck="false" minlength="${SLUG_MIN}" maxlength="${SLUG_MAX}" pattern="[a-z](?:[a-z0-9]|-(?!-))*[a-z0-9]" value="${escapeHtml(form.slug ?? "")}" placeholder="my-team" style="flex:1;min-width:0;border:0;outline:0;background:transparent;padding:12px 0;font:500 15px/1.2 ${display};color:inherit;"><span class="fs-dim" style="font:400 15px/1 ${display};color:${DARK.dim};white-space:nowrap;">${escapeHtml(suffix)}</span></div>` + (form.error ? `<p style="margin:8px 0 0;font:500 13px/1.5 ${display};color:#d33;">${escapeHtml(form.error)}</p>` : "") + `<p class="fs-dim" style="margin:8px 0 20px;font:400 12px/1.6 ${display};color:${DARK.dim};">${SLUG_MIN} to ${SLUG_MAX} lowercase letters, digits or hyphens. <strong style="font-weight:600;color:${DARK.ink};">Can't be changed later.</strong></p><div style="border-top:1px solid ${DARK.line};margin:4px 0 20px;"></div><label class="fs-dim" style="display:flex;align-items:flex-start;gap:10px;margin:0 0 16px;font:400 13px/1.5 ${display};color:${DARK.dim};cursor:pointer;"><input type="checkbox" name="terms" required style="margin:3px 0 0;flex:none;accent-color:${accent};"><span>I agree to the <a href="https://flyspace.ai/terms/" target="_blank" rel="noreferrer" style="color:inherit;text-decoration:underline;text-underline-offset:2px;">Terms</a> and <a href="https://flyspace.ai/privacy/" target="_blank" rel="noreferrer" style="color:inherit;text-decoration:underline;text-underline-offset:2px;">Privacy Policy</a>.</span></label><button id="setup-submit" class="fs-btn" type="submit" style="background:${accent};color:${DARK.onAccent};border-radius:8px;padding:13px 24px;width:100%;font:600 15px/1 ${display};">Create workspace</button></form><div style="margin-top:20px;border-left:2px solid ${accent};padding:2px 0 2px 12px;"><p class="fs-muted" style="margin:0;font:400 13px/1.6 ${display};color:${DARK.muted};"><strong class="fs-ink" style="font-weight:600;color:${DARK.ink};">Next:</strong> we'll help you connect Claude Code or Codex CLI.</p></div></div></div><script>(()=>{const n=document.getElementById('name'),s=document.getElementById('slug');const K='fs-setup-draft';try{const d=JSON.parse(sessionStorage.getItem(K)||'{}');if(!n.value&&d.name)n.value=d.name;if(!s.value&&d.slug)s.value=d.slug}catch(e){}const save=()=>{try{sessionStorage.setItem(K,JSON.stringify({name:n.value,slug:s.value}))}catch(e){}};let touched=!!s.value;s.addEventListener('input',()=>{touched=true});n.addEventListener('input',()=>{if(touched)return;s.value=n.value.normalize('NFKD').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').replace(/^[^a-z]+/,'').slice(0,${SLUG_MAX}).replace(/-+$/,'')});n.addEventListener('input',save);s.addEventListener('input',save)})()</script><script>document.getElementById('setup').addEventListener('submit',()=>{const b=document.getElementById('setup-submit');b.disabled=true;b.style.opacity='0.7';b.style.cursor='progress';b.textContent='Creating workspace\u2026'})</script><script>document.querySelectorAll('[data-toggle-password]').forEach(b=>b.addEventListener('click',()=>{const i=document.getElementById(b.dataset.togglePassword),show=i.type==='password';i.type=show?'text':'password';b.setAttribute('aria-pressed',String(show));b.setAttribute('aria-label',show?'Hide password':'Show password');b.querySelector('[data-eye]').style.display=show?'none':'';b.querySelector('[data-eye-off]').style.display=show?'':'none'}))</script>`,
2617
2656
  void 0,
2618
2657
  void 0,
2619
2658
  true,
@@ -2789,7 +2828,11 @@ function createAuthConfirmRouter() {
2789
2828
  invite.email,
2790
2829
  invite.ownerName ? { name: invite.ownerName } : {}
2791
2830
  );
2792
- const { error: error2 } = await getSupabaseClient().auth.admin.updateUserById(userId, { password });
2831
+ const { error: error2 } = await getSupabaseClient().auth.admin.updateUserById(userId, {
2832
+ password,
2833
+ // The owner just chose one: don't ask again during onboarding.
2834
+ user_metadata: { passwordSetAt: (/* @__PURE__ */ new Date()).toISOString() }
2835
+ });
2793
2836
  if (error2) {
2794
2837
  const weak = error2.code === "weak_password";
2795
2838
  if (!weak) console.error(`[new-workspace] setting the password for ${invite.email} failed:`, error2);
@@ -2849,9 +2892,7 @@ function createAuthConfirmRouter() {
2849
2892
  const fragment = new URLSearchParams({
2850
2893
  access_token: session.accessToken,
2851
2894
  refresh_token: session.refreshToken,
2852
- type: "magiclink",
2853
- // AuthCallback lands a fresh owner on the connect-your-agent page.
2854
- next: "connect"
2895
+ type: "magiclink"
2855
2896
  });
2856
2897
  return c.redirect(`${origin}/auth/callback#${fragment}`, 303);
2857
2898
  } catch (err) {
@@ -2957,8 +2998,8 @@ async function deliverSignInLink(email, redirectTo) {
2957
2998
  // src/dashboard/server.ts
2958
2999
  import { existsSync as existsSync27 } from "fs";
2959
3000
  import { createServer } from "net";
2960
- import { join as join19 } from "path";
2961
- import { Hono as Hono30 } from "hono";
3001
+ import { join as join21 } from "path";
3002
+ import { Hono as Hono32 } from "hono";
2962
3003
  import { serve } from "@hono/node-server";
2963
3004
  import { serveStatic } from "@hono/node-server/serve-static";
2964
3005
 
@@ -3003,24 +3044,157 @@ async function reconcileAll() {
3003
3044
  return results;
3004
3045
  }
3005
3046
 
3047
+ // src/store/channel-members-store.ts
3048
+ function rowToChannelMember(row) {
3049
+ return {
3050
+ id: row["id"],
3051
+ channelId: row["channel_id"],
3052
+ membershipId: row["membership_id"],
3053
+ role: row["role"],
3054
+ addedAt: row["added_at"]
3055
+ };
3056
+ }
3057
+ async function listChannelMembers(channelId) {
3058
+ const { data, error: error2 } = await getSupabaseClient().from("channel_members").select("*").eq("channel_id", channelId).order("added_at", { ascending: true });
3059
+ if (error2) throw new Error(`listChannelMembers(${channelId}): ${error2.message}`);
3060
+ return (data ?? []).map(rowToChannelMember);
3061
+ }
3062
+ async function upsertChannelMember(params) {
3063
+ const { data, error: error2 } = await getSupabaseClient().from("channel_members").upsert(
3064
+ { channel_id: params.channelId, membership_id: params.membershipId, role: params.role },
3065
+ { onConflict: "channel_id,membership_id" }
3066
+ ).select("*").single();
3067
+ if (error2) {
3068
+ throw new Error(
3069
+ `upsertChannelMember(${params.channelId}, ${params.membershipId}): ${error2.message}`
3070
+ );
3071
+ }
3072
+ return rowToChannelMember(data);
3073
+ }
3074
+ async function removeChannelMember(channelId, membershipId) {
3075
+ const { data, error: error2 } = await getSupabaseClient().from("channel_members").delete().eq("channel_id", channelId).eq("membership_id", membershipId).select("id");
3076
+ if (error2) throw new Error(`removeChannelMember(${channelId}, ${membershipId}): ${error2.message}`);
3077
+ return data?.length ? true : false;
3078
+ }
3079
+
3080
+ // src/store/channel-docs-access-store.ts
3081
+ var RANK = { none: 0, read: 1, write: 2 };
3082
+ function docsAccessAtLeast(have, need) {
3083
+ return RANK[have] >= RANK[need];
3084
+ }
3085
+ function isDocsGrant(value) {
3086
+ return value === "read" || value === "write";
3087
+ }
3088
+ function rowToGrant(row) {
3089
+ return {
3090
+ id: row["id"],
3091
+ channelId: row["channel_id"],
3092
+ membershipId: row["membership_id"],
3093
+ access: row["access"],
3094
+ grantedAt: row["granted_at"]
3095
+ };
3096
+ }
3097
+ async function listChannelDocsAccess(channelId) {
3098
+ const { data, error: error2 } = await getSupabaseClient().from("channel_docs_access").select("*").eq("channel_id", channelId).order("granted_at", { ascending: true });
3099
+ if (error2) throw new Error(`listChannelDocsAccess(${channelId}): ${error2.message}`);
3100
+ return (data ?? []).map(rowToGrant);
3101
+ }
3102
+ async function getChannelDocsGrant(channelId, membershipId) {
3103
+ const { data, error: error2 } = await getSupabaseClient().from("channel_docs_access").select("*").eq("channel_id", channelId).eq("membership_id", membershipId).maybeSingle();
3104
+ if (error2) throw new Error(`getChannelDocsGrant(${channelId}, ${membershipId}): ${error2.message}`);
3105
+ return data ? rowToGrant(data) : null;
3106
+ }
3107
+ async function upsertChannelDocsAccess(params) {
3108
+ const { data, error: error2 } = await getSupabaseClient().from("channel_docs_access").upsert(
3109
+ { channel_id: params.channelId, membership_id: params.membershipId, access: params.access },
3110
+ { onConflict: "channel_id,membership_id" }
3111
+ ).select("*").single();
3112
+ if (error2) {
3113
+ throw new Error(
3114
+ `upsertChannelDocsAccess(${params.channelId}, ${params.membershipId}): ${error2.message}`
3115
+ );
3116
+ }
3117
+ return rowToGrant(data);
3118
+ }
3119
+ async function removeChannelDocsAccess(channelId, membershipId) {
3120
+ const { data, error: error2 } = await getSupabaseClient().from("channel_docs_access").delete().eq("channel_id", channelId).eq("membership_id", membershipId).select("id");
3121
+ if (error2) {
3122
+ throw new Error(`removeChannelDocsAccess(${channelId}, ${membershipId}): ${error2.message}`);
3123
+ }
3124
+ return data?.length ? true : false;
3125
+ }
3126
+ async function resolveForMembership(membership, channelId) {
3127
+ if (membership.role === "admin") return "write";
3128
+ const members = await listChannelMembers(channelId);
3129
+ if (members.some((m) => m.membershipId === membership.id && m.role === "channel_admin")) {
3130
+ return "write";
3131
+ }
3132
+ const grant = await getChannelDocsGrant(channelId, membership.id);
3133
+ return grant?.access ?? "none";
3134
+ }
3135
+ async function resolveDocsAccess(ctx, channelId) {
3136
+ if (ctx.role === "admin") return "write";
3137
+ const membership = await getMembershipByUserId(ctx.workspaceId, ctx.userId);
3138
+ if (!membership) return "none";
3139
+ return resolveForMembership(membership, channelId);
3140
+ }
3141
+ async function resolveDocsAccessByHandle(workspaceSlug, handle, channelId) {
3142
+ const workspace = await getWorkspaceBySlug(workspaceSlug);
3143
+ if (!workspace) return null;
3144
+ const wanted = handle.toLowerCase();
3145
+ const membership = (await listMemberships(workspace.id)).find(
3146
+ (m) => m.handle.toLowerCase() === wanted
3147
+ );
3148
+ if (!membership) return "none";
3149
+ return resolveForMembership(membership, channelId);
3150
+ }
3151
+ async function filterReadableDocsChannels(ctx, channelIds) {
3152
+ if (ctx.role === "admin") return new Set(channelIds);
3153
+ const membership = await getMembershipByUserId(ctx.workspaceId, ctx.userId);
3154
+ if (!membership) return /* @__PURE__ */ new Set();
3155
+ const db = getSupabaseClient();
3156
+ const [grants, roles] = await Promise.all([
3157
+ db.from("channel_docs_access").select("channel_id").eq("membership_id", membership.id),
3158
+ db.from("channel_members").select("channel_id, role").eq("membership_id", membership.id)
3159
+ ]);
3160
+ if (grants.error) throw new Error(`filterReadableDocsChannels: ${grants.error.message}`);
3161
+ if (roles.error) throw new Error(`filterReadableDocsChannels: ${roles.error.message}`);
3162
+ const allowed = /* @__PURE__ */ new Set();
3163
+ for (const r of grants.data ?? []) allowed.add(r.channel_id);
3164
+ for (const r of roles.data ?? []) {
3165
+ if (r.role === "channel_admin") allowed.add(r.channel_id);
3166
+ }
3167
+ return new Set(channelIds.filter((id) => allowed.has(id)));
3168
+ }
3169
+
3170
+ // src/dashboard/docs-access-gate.ts
3171
+ var requireDocsAccess = async (c, next) => {
3172
+ const auth = c.get("auth");
3173
+ const need = c.req.method === "GET" ? "read" : "write";
3174
+ const have = auth ? await resolveDocsAccess(auth, c.req.param("id") ?? "") : "none";
3175
+ if (!docsAccessAtLeast(have, need)) return c.json({ error: "docs access required" }, 403);
3176
+ await next();
3177
+ };
3178
+
3006
3179
  // src/dashboard/api.ts
3007
- import { Hono as Hono27 } from "hono";
3180
+ import { Hono as Hono29 } from "hono";
3008
3181
 
3009
3182
  // src/dashboard/public-share-routes.ts
3010
3183
  import { existsSync as existsSync11, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
3011
- import { join as join8 } from "path";
3184
+ import { join as join9 } from "path";
3012
3185
  import { Hono as Hono3 } from "hono";
3013
3186
  import { GetObjectCommand } from "@aws-sdk/client-s3";
3014
3187
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
3015
3188
 
3016
3189
  // src/store/doc-share-store.ts
3017
3190
  function rowToKind(value) {
3018
- return value === "html" ? "html" : "markdown";
3191
+ return value === "html" || value === "excalidraw" ? value : "markdown";
3019
3192
  }
3020
3193
  function rowToShare(row) {
3021
3194
  return {
3022
3195
  id: row["id"],
3023
3196
  slug: row["slug"] ?? null,
3197
+ tags: Array.isArray(row["tags"]) ? row["tags"] : [],
3024
3198
  workspaceId: row["workspace_id"],
3025
3199
  profile: row["profile"],
3026
3200
  channelId: row["channel_id"],
@@ -3087,11 +3261,12 @@ async function setDocShareSlug(id, slug) {
3087
3261
  if (error2) throw new Error(`setDocShareSlug(${id}): ${error2.message}`);
3088
3262
  return data ? rowToShare(data) : null;
3089
3263
  }
3090
- var SUMMARY_COLUMNS = "id, slug, profile, channel_id, source_path, title, kind, created_by, created_at, updated_at, audio_enabled, ask_enabled, social_enabled, branding_enabled, translation_enabled, translation_langs, audio_status";
3264
+ var SUMMARY_COLUMNS = "id, slug, tags, profile, channel_id, source_path, title, kind, created_by, created_at, updated_at, audio_enabled, ask_enabled, social_enabled, branding_enabled, translation_enabled, translation_langs, audio_status";
3091
3265
  function rowToSummary(row) {
3092
3266
  return {
3093
3267
  id: row["id"],
3094
3268
  slug: row["slug"] ?? null,
3269
+ tags: Array.isArray(row["tags"]) ? row["tags"] : [],
3095
3270
  profile: row["profile"],
3096
3271
  channelId: row["channel_id"],
3097
3272
  sourcePath: row["source_path"],
@@ -3166,6 +3341,11 @@ async function updateDocShareSnapshot(id, snapshot2, features) {
3166
3341
  if (error2) throw new Error(`updateDocShareSnapshot(${id}): ${error2.message}`);
3167
3342
  return rowToShare(data);
3168
3343
  }
3344
+ async function setDocShareTags(id, tags) {
3345
+ const { data, error: error2 } = await getSupabaseClient().from("doc_shares").update({ tags }).eq("id", id).is("revoked_at", null).select("*").maybeSingle();
3346
+ if (error2) throw new Error(`setDocShareTags(${id}): ${error2.message}`);
3347
+ return data ? rowToShare(data) : null;
3348
+ }
3169
3349
  async function updateDocShareFeatures(id, features) {
3170
3350
  const { data, error: error2 } = await getSupabaseClient().from("doc_shares").update({
3171
3351
  ...features.audioEnabled !== void 0 ? { audio_enabled: features.audioEnabled } : {},
@@ -3467,11 +3647,11 @@ function injectShareHead(html, head) {
3467
3647
  // src/dashboard/app-icon.ts
3468
3648
  import { createHash as createHash2 } from "crypto";
3469
3649
  import { readFileSync as readFileSync5 } from "fs";
3470
- import { join as join7 } from "path";
3650
+ import { join as join8 } from "path";
3471
3651
  import sharp from "sharp";
3472
3652
  var PLATE = "#0A0A0F";
3473
- var MARK_FILE = join7(paths.PACKAGE_ROOT, "web", "public", "icons", "icon-512.png");
3474
- var MARK_SVG_FILE = join7(paths.PACKAGE_ROOT, "web", "public", "favicon.svg");
3653
+ var MARK_FILE = join8(paths.PACKAGE_ROOT, "web", "public", "icons", "icon-512.png");
3654
+ var MARK_SVG_FILE = join8(paths.PACKAGE_ROOT, "web", "public", "favicon.svg");
3475
3655
  var LAYOUT = {
3476
3656
  standard: { logo: 0.7, logoCx: 0.5, logoCy: 0.48, badge: 0.27, badgeCx: 0.22, badgeCy: 0.76 },
3477
3657
  maskable: { logo: 0.56, logoCx: 0.5, logoCy: 0.48, badge: 0.23, badgeCx: 0.33, badgeCy: 0.66 }
@@ -4636,6 +4816,9 @@ function shareLangAudio(share, lang, audio) {
4636
4816
  truncated: isArticleTruncated(share.content)
4637
4817
  };
4638
4818
  }
4819
+ function shareDescription(share, shown) {
4820
+ return share.kind === "excalidraw" ? "" : excerptFromMarkdown(shown.content, shown.title);
4821
+ }
4639
4822
  function createPublicShareApiRouter() {
4640
4823
  const app = new Hono3();
4641
4824
  app.get("/:id", async (c) => {
@@ -4784,7 +4967,7 @@ function serveBuildFile(c, webDist, dir, name) {
4784
4967
  if (!type || !/^[A-Za-z0-9._-]+$/.test(name) || name.includes("..")) {
4785
4968
  return c.text("not found", 404);
4786
4969
  }
4787
- const path = join8(webDist, dir, name);
4970
+ const path = join9(webDist, dir, name);
4788
4971
  if (!existsSync11(path)) return c.text("not found", 404);
4789
4972
  c.header("Content-Type", type);
4790
4973
  c.header("Cache-Control", "public, max-age=31536000, immutable");
@@ -4812,7 +4995,7 @@ function createPublicShareAssetRouter(webDist) {
4812
4995
  const workspace = await workspaceOf(share);
4813
4996
  const png = await renderShareOgImage({
4814
4997
  title: shown.title,
4815
- description: excerptFromMarkdown(shown.content, shown.title),
4998
+ description: shareDescription(share, shown),
4816
4999
  // Branding off drops the card's header row too. An unfurl in Slack or on
4817
5000
  // LinkedIn is the first thing a reader sees, so a page that doesn't sign
4818
5001
  // itself and a card that does would be the same contradiction the flag
@@ -4876,7 +5059,7 @@ function createPublicShareAssetRouter(webDist) {
4876
5059
  const id = c.req.param("file").replace(/\.md$/, "");
4877
5060
  const share = await getDocShare(id);
4878
5061
  if (!share) return c.json({ error: "not found" }, 404);
4879
- if (share.kind === "html") return c.json({ error: "not found" }, 404);
5062
+ if (share.kind !== "markdown") return c.json({ error: "not found" }, 404);
4880
5063
  c.header("Content-Type", "text/markdown; charset=utf-8");
4881
5064
  c.header("Content-Disposition", "inline");
4882
5065
  c.header("Cache-Control", "no-store");
@@ -4890,7 +5073,7 @@ function createPublicShareAssetRouter(webDist) {
4890
5073
  const writtenIn = opts.noindex ? share.sourceLang : opts.lang ?? share.sourceLang;
4891
5074
  const head = buildShareHead({
4892
5075
  title: shown.title,
4893
- description: excerptFromMarkdown(shown.content, shown.title),
5076
+ description: shareDescription(share, shown),
4894
5077
  workspaceName: publisher,
4895
5078
  pageUrl: opts.lang ? `${base}/${opts.lang}` : base,
4896
5079
  // The card follows the page's language where there is one to follow (see
@@ -4912,11 +5095,9 @@ function createPublicShareAssetRouter(webDist) {
4912
5095
  workspaceName: publisher,
4913
5096
  published: formatPublished(share.updatedAt) || void 0
4914
5097
  });
4915
- let html = injectShareBody(
4916
- injectShareHead(readShell(shell), analytics ? `${head}
4917
- ${analytics}` : head),
4918
- body
4919
- );
5098
+ const headed = injectShareHead(readShell(shell), analytics ? `${head}
5099
+ ${analytics}` : head);
5100
+ let html = share.kind === "excalidraw" ? headed : injectShareBody(headed, body);
4920
5101
  if (writtenIn) html = setHtmlLang(html, writtenIn);
4921
5102
  c.header("Content-Type", "text/html; charset=utf-8");
4922
5103
  c.header("Cache-Control", "no-store");
@@ -4926,7 +5107,7 @@ ${analytics}` : head),
4926
5107
  const requested = c.req.param("lang");
4927
5108
  const lang = canonicalLanguage(requested);
4928
5109
  if (!isTranslatableLanguage(lang)) return next();
4929
- const shell = join8(webDist, "index.html");
5110
+ const shell = join9(webDist, "index.html");
4930
5111
  if (!existsSync11(shell)) return next();
4931
5112
  const share = await getDocShare(c.req.param("id"));
4932
5113
  if (!share) return next();
@@ -4962,7 +5143,7 @@ ${analytics}` : head),
4962
5143
  const share = await getDocShare(c.req.param("id"));
4963
5144
  if (!share) return next();
4964
5145
  if (share.kind === "html") return servePublishedHtml(c, share);
4965
- const shell = join8(webDist, "index.html");
5146
+ const shell = join9(webDist, "index.html");
4966
5147
  if (!existsSync11(shell)) return next();
4967
5148
  return readerPage(
4968
5149
  c,
@@ -5033,6 +5214,48 @@ import { Hono as Hono5 } from "hono";
5033
5214
 
5034
5215
  // src/dashboard/supabase-server-client.ts
5035
5216
  import { createServerClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr";
5217
+
5218
+ // src/dashboard/scoped-auth-cookies.ts
5219
+ import { stringFromBase64URL } from "@supabase/ssr";
5220
+ var SESSION_NAME = /^(sb-.+-auth-token)(?:\.\d+)?$/;
5221
+ var SCOPE_MARK = "fsw_";
5222
+ var SCOPE_END = "__";
5223
+ function hostLabel(host) {
5224
+ return host.toLowerCase().replace(/:\d+$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5225
+ }
5226
+ var scopePrefix = (label2) => `${SCOPE_MARK}${label2}${SCOPE_END}`;
5227
+ function sessionUserId(cookies) {
5228
+ const base = cookies.map((c) => SESSION_NAME.exec(c.name)?.[1]).find(Boolean);
5229
+ if (!base) return null;
5230
+ const whole = cookies.find((c) => c.name === base)?.value;
5231
+ const raw = whole ?? cookies.filter((c) => c.name.startsWith(`${base}.`)).sort((a, b) => Number(a.name.split(".").pop()) - Number(b.name.split(".").pop())).map((c) => c.value).join("");
5232
+ if (!raw) return null;
5233
+ try {
5234
+ const json = raw.startsWith("base64-") ? stringFromBase64URL(raw.slice(7)) : raw;
5235
+ const id = JSON.parse(json).user?.id;
5236
+ return typeof id === "string" ? id : null;
5237
+ } catch {
5238
+ return null;
5239
+ }
5240
+ }
5241
+ var isScoped = (name) => name.startsWith(SCOPE_MARK);
5242
+ function readScopedCookies(all, label2) {
5243
+ const prefix = scopePrefix(label2);
5244
+ const own = all.filter((c) => c.name.startsWith(prefix)).map((c) => ({ name: c.name.slice(prefix.length), value: c.value }));
5245
+ return own.length ? own : all.filter((c) => !isScoped(c.name));
5246
+ }
5247
+ function routeCookieWrites(all, label2, writes) {
5248
+ const prefix = scopePrefix(label2);
5249
+ const hasOwn = all.some((c) => c.name.startsWith(prefix));
5250
+ const incoming = sessionUserId(writes.filter((c) => c.value !== ""));
5251
+ const shared = sessionUserId(all.filter((c) => !isScoped(c.name)));
5252
+ const toScoped = hasOwn || incoming !== null && shared !== null && incoming !== shared;
5253
+ return writes.map(
5254
+ (c) => toScoped ? { name: prefix + c.name, value: c.value, scoped: true } : { ...c, scoped: false }
5255
+ );
5256
+ }
5257
+
5258
+ // src/dashboard/supabase-server-client.ts
5036
5259
  var REQUEST_TIMEOUT_MS = 5e3;
5037
5260
  function fetchWithTimeout(input2, init) {
5038
5261
  return fetch(input2, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
@@ -5043,26 +5266,34 @@ function createRequestSupabaseClient(c) {
5043
5266
  if (!url || !key) {
5044
5267
  throw new Error("SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_KEY not set \u2014 check .env");
5045
5268
  }
5269
+ const origin = c.req.header("Origin");
5270
+ const label2 = hostLabel(
5271
+ (origin ? URL.parse(origin)?.host : void 0) ?? c.req.header("X-Forwarded-Host") ?? c.req.header("Host") ?? ""
5272
+ );
5273
+ const rawCookies = () => parseCookieHeader(c.req.header("Cookie") ?? "").map(({ name, value }) => ({
5274
+ name,
5275
+ value: value ?? ""
5276
+ }));
5046
5277
  return createServerClient(url, key, {
5047
5278
  global: { fetch: fetchWithTimeout },
5048
5279
  cookies: {
5049
5280
  // `parseCookieHeader` types `value` as optional; the client requires a
5050
5281
  // string, and a valueless cookie is the same as an empty one here.
5051
- getAll: () => parseCookieHeader(c.req.header("Cookie") ?? "").map(({ name, value }) => ({
5052
- name,
5053
- value: value ?? ""
5054
- })),
5282
+ getAll: () => readScopedCookies(rawCookies(), label2),
5055
5283
  setAll: (cookies) => {
5056
5284
  const domain = process.env["AUTH_COOKIE_DOMAIN"]?.trim();
5057
5285
  const overrides = {
5058
5286
  ...domain ? { domain } : {},
5059
5287
  ...crossOriginShell(c) ? { sameSite: "none", secure: true } : {}
5060
5288
  };
5061
- for (const { name, value, options } of cookies) {
5062
- c.header("Set-Cookie", serializeCookieHeader(name, value, { ...options, ...overrides }), {
5289
+ const routed = routeCookieWrites(rawCookies(), label2, cookies);
5290
+ cookies.forEach(({ options }, i) => {
5291
+ const { name, value, scoped } = routed[i];
5292
+ const { domain: _domain, ...rest } = overrides;
5293
+ c.header("Set-Cookie", serializeCookieHeader(name, value, { ...options, ...scoped ? rest : overrides }), {
5063
5294
  append: true
5064
5295
  });
5065
- }
5296
+ });
5066
5297
  }
5067
5298
  }
5068
5299
  });
@@ -5071,6 +5302,14 @@ function createRequestSupabaseClient(c) {
5071
5302
  // src/dashboard/auth-context.ts
5072
5303
  import { HTTPException } from "hono/http-exception";
5073
5304
  import { isAuthApiError, isAuthRetryableFetchError } from "@supabase/supabase-js";
5305
+ function onboardingPending(ctx) {
5306
+ const pending3 = [];
5307
+ if (!ctx.onboarded && !ctx.passwordSet) pending3.push("account");
5308
+ if (!ctx.onboarded) pending3.push("profile");
5309
+ if (ctx.role === "admin" && !ctx.agentConnectedAt) pending3.push("agent");
5310
+ if (ctx.role === "admin" && !ctx.projectsSetupAt) pending3.push("projects");
5311
+ return pending3;
5312
+ }
5074
5313
  var inFlight3 = /* @__PURE__ */ new Map();
5075
5314
  var CACHE_TTL_MS = 3e4;
5076
5315
  var resolved = /* @__PURE__ */ new Map();
@@ -5082,12 +5321,13 @@ function pruneExpired(now) {
5082
5321
  if (entry.expires <= now) resolved.delete(key);
5083
5322
  }
5084
5323
  }
5085
- async function resolveAuthContext(c) {
5324
+ async function resolveAuthContext(c, opts = {}) {
5086
5325
  const cookie = c.req?.header("Cookie") ?? "";
5087
5326
  if (!cookie) return resolveAuthContextUncached(c);
5088
5327
  const key = `${getProfile()}\0${cookie}`;
5089
5328
  const now = Date.now();
5090
5329
  pruneExpired(now);
5330
+ if (opts.fresh) resolved.delete(key);
5091
5331
  const cached = resolved.get(key);
5092
5332
  if (cached) return cached.value;
5093
5333
  const inflight = inFlight3.get(key);
@@ -5142,7 +5382,9 @@ async function resolveAuthContextUncached(c) {
5142
5382
  name: metadata.name,
5143
5383
  avatarKey: metadata.avatarKey,
5144
5384
  onboarded: Boolean(metadata.onboardedAt),
5145
- agentConnectedAt: workspace.agentConnectedAt ?? null
5385
+ passwordSet: Boolean(metadata.passwordSetAt),
5386
+ agentConnectedAt: workspace.agentConnectedAt ?? null,
5387
+ projectsSetupAt: workspace.projectsSetupAt ?? null
5146
5388
  };
5147
5389
  }
5148
5390
 
@@ -5213,7 +5455,7 @@ function createAuthRouter() {
5213
5455
  const body = await c.req.json().catch(() => ({}));
5214
5456
  const email = body.email?.trim().toLowerCase();
5215
5457
  if (!email) return c.json({ error: "email is required" }, 400);
5216
- if (!await verifyTurnstileToken(body.captchaToken)) {
5458
+ if (!isDevHost(c) && !await verifyTurnstileToken(body.captchaToken)) {
5217
5459
  return c.json({ error: "captcha check failed \u2014 please retry" }, 400);
5218
5460
  }
5219
5461
  const workspace = await getWorkspaceBySlug(getProfile());
@@ -5230,7 +5472,7 @@ function createAuthRouter() {
5230
5472
  return c.json({ sent: true });
5231
5473
  });
5232
5474
  auth.get("/session", async (c) => {
5233
- const ctx = await resolveAuthContext(c);
5475
+ const ctx = await resolveAuthContext(c, { fresh: c.req.query("fresh") === "1" });
5234
5476
  if (!ctx) return c.json({ error: "not signed in" }, 401);
5235
5477
  return c.json({
5236
5478
  email: ctx.email,
@@ -5239,9 +5481,31 @@ function createAuthRouter() {
5239
5481
  name: ctx.name,
5240
5482
  avatarUrl: ctx.avatarKey ? await signAvatarUrl(ctx.avatarKey) : null,
5241
5483
  onboarded: ctx.onboarded,
5242
- agentConnectedAt: ctx.agentConnectedAt
5484
+ passwordSet: ctx.passwordSet,
5485
+ agentConnectedAt: ctx.agentConnectedAt,
5486
+ onboarding: { pending: onboardingPending(ctx) }
5243
5487
  });
5244
5488
  });
5489
+ auth.patch("/handle", async (c) => {
5490
+ const ctx = await resolveAuthContext(c);
5491
+ if (!ctx) return c.json({ error: "not signed in" }, 401);
5492
+ if (ctx.onboarded) return c.json({ error: "handle can no longer be changed" }, 403);
5493
+ const body = await c.req.json().catch(() => ({}));
5494
+ const handle = body.handle?.trim().replace(/^@/, "").toLowerCase() ?? "";
5495
+ if (!HANDLE_PATTERN.test(handle)) {
5496
+ return c.json(
5497
+ { error: "handle must be 1-32 characters: a-z, 0-9, _ or -, starting with a letter or digit" },
5498
+ 400
5499
+ );
5500
+ }
5501
+ try {
5502
+ await setMembershipHandle(ctx.workspaceId, ctx.userId, handle);
5503
+ } catch (err) {
5504
+ if (err instanceof HandleTakenError) return c.json({ error: err.message }, 409);
5505
+ throw err;
5506
+ }
5507
+ return c.json({ handle });
5508
+ });
5245
5509
  auth.post("/logout", async (c) => {
5246
5510
  const supabase = createRequestSupabaseClient(c);
5247
5511
  await supabase.auth.signOut();
@@ -5408,6 +5672,9 @@ function createAdminRouter() {
5408
5672
  const proto = c.req.header("x-forwarded-proto") ?? "http";
5409
5673
  const inviteUrl = new URL(`${proto}://${trustedHost(c)}/auth/new-workspace`);
5410
5674
  inviteUrl.searchParams.set("token", token);
5675
+ if (body.sendEmail === false) {
5676
+ return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
5677
+ }
5411
5678
  try {
5412
5679
  await deliverWorkspaceSetupInvite({
5413
5680
  email: ownerEmail,
@@ -5425,7 +5692,7 @@ function createAdminRouter() {
5425
5692
  500
5426
5693
  );
5427
5694
  }
5428
- return c.json({ email: ownerEmail }, 201);
5695
+ return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
5429
5696
  });
5430
5697
  admin.delete("/setup-invites/:id", async (c) => {
5431
5698
  await revokeSetupInvite(c.req.param("id"));
@@ -5443,6 +5710,9 @@ function createAdminRouter() {
5443
5710
  const proto = c.req.header("x-forwarded-proto") ?? "http";
5444
5711
  const inviteUrl = new URL(`${proto}://${trustedHost(c)}/auth/new-workspace`);
5445
5712
  inviteUrl.searchParams.set("token", token);
5713
+ if (c.req.query("email") === "false") {
5714
+ return c.json({ sent: false, inviteLink: inviteUrl.toString() });
5715
+ }
5446
5716
  try {
5447
5717
  await deliverWorkspaceSetupInvite({
5448
5718
  email: invite.email,
@@ -5458,19 +5728,119 @@ function createAdminRouter() {
5458
5728
  500
5459
5729
  );
5460
5730
  }
5461
- return c.json({ sent: true });
5731
+ return c.json({ sent: true, inviteLink: inviteUrl.toString() });
5462
5732
  });
5463
5733
  return admin;
5464
5734
  }
5465
5735
 
5466
5736
  // src/dashboard/host-api.ts
5467
5737
  import { existsSync as existsSync20, readFileSync as readFileSync10, statSync as statSync7 } from "fs";
5468
- import { join as join15 } from "path";
5738
+ import { join as join16 } from "path";
5469
5739
  import sharp3 from "sharp";
5470
5740
 
5471
5741
  // src/dashboard/channels-routes.ts
5472
5742
  import { existsSync as existsSync12, mkdirSync as mkdirSync4 } from "fs";
5743
+ import { Hono as Hono9 } from "hono";
5744
+
5745
+ // src/dashboard/feature-flags-routes.ts
5473
5746
  import { Hono as Hono8 } from "hono";
5747
+
5748
+ // src/feature-flags.ts
5749
+ var featureFlags = {
5750
+ "ai-cost": {
5751
+ title: "AI cost",
5752
+ description: "Shows admins what the agent has spent: a Cost section in a channel's AI overview and a per-thread cost in the thread menu. Estimated at API list rates, so on a Claude subscription it will not match your bill.",
5753
+ stage: "beta"
5754
+ },
5755
+ "read-aloud": {
5756
+ title: "Read aloud",
5757
+ description: "Listen to messages, threads and docs as audio. Still rough in places, and each playback uses text-to-speech.",
5758
+ stage: "beta"
5759
+ },
5760
+ excalidraw: {
5761
+ title: "Excalidraw",
5762
+ description: "Create and edit whiteboard drawings (.excalidraw files) in the Docs pane. Existing drawings stay viewable with this off.",
5763
+ stage: "beta"
5764
+ },
5765
+ sandbox: {
5766
+ title: "Sandbox runtime",
5767
+ description: "Run a channel agent in an isolated sandbox without Docker. Channels already on Sandbox keep working with this off.",
5768
+ stage: "beta"
5769
+ },
5770
+ dictation: {
5771
+ title: "Dictation",
5772
+ description: "A mic button in the message composer that turns speech into text as you talk. Each phrase uses speech-to-text.",
5773
+ stage: "dev"
5774
+ }
5775
+ };
5776
+ var featureFlagIds = Object.keys(featureFlags);
5777
+ function isFeatureFlagId(id) {
5778
+ return Object.hasOwn(featureFlags, id);
5779
+ }
5780
+ function isFeatureVisible(stage, devHost) {
5781
+ return stage === "beta" || stage === "dev" && devHost;
5782
+ }
5783
+ function resolveFeatureFlags(enabledIds, devHost) {
5784
+ const enabled = new Set(enabledIds);
5785
+ const out = {};
5786
+ for (const id of featureFlagIds) {
5787
+ const stage = featureFlags[id].stage;
5788
+ out[id] = stage === "ga" || isFeatureVisible(stage, devHost) && enabled.has(id);
5789
+ }
5790
+ return out;
5791
+ }
5792
+
5793
+ // src/store/feature-flag-store.ts
5794
+ async function listEnabledFeatureFlags(workspaceId) {
5795
+ const { data, error: error2 } = await getSupabaseClient().from("feature_flags").select("flag_id").eq("workspace_id", workspaceId);
5796
+ if (error2) throw new Error(`listEnabledFeatureFlags(${workspaceId}): ${error2.message}`);
5797
+ return (data ?? []).map((r) => r.flag_id);
5798
+ }
5799
+ async function enableFeatureFlag(workspaceId, flagId, userId) {
5800
+ const { error: error2 } = await getSupabaseClient().from("feature_flags").upsert(
5801
+ { workspace_id: workspaceId, flag_id: flagId, enabled_by: userId },
5802
+ { onConflict: "workspace_id,flag_id" }
5803
+ );
5804
+ if (error2) throw new Error(`enableFeatureFlag(${workspaceId}, ${flagId}): ${error2.message}`);
5805
+ }
5806
+ async function disableFeatureFlag(workspaceId, flagId) {
5807
+ const { error: error2 } = await getSupabaseClient().from("feature_flags").delete().eq("workspace_id", workspaceId).eq("flag_id", flagId);
5808
+ if (error2) throw new Error(`disableFeatureFlag(${workspaceId}, ${flagId}): ${error2.message}`);
5809
+ }
5810
+
5811
+ // src/dashboard/feature-flags-routes.ts
5812
+ async function isFeatureEnabled(c, flagId) {
5813
+ const auth = c.get("auth");
5814
+ const enabled = await listEnabledFeatureFlags(auth.workspaceId);
5815
+ return resolveFeatureFlags(enabled, isDevHost(c))[flagId];
5816
+ }
5817
+ function createFeatureFlagsRouter() {
5818
+ const app = new Hono8();
5819
+ app.get("/", async (c) => {
5820
+ const auth = c.get("auth");
5821
+ const devHost = isDevHost(c);
5822
+ const flags = resolveFeatureFlags(await listEnabledFeatureFlags(auth.workspaceId), devHost);
5823
+ const labs = featureFlagIds.filter((id) => isFeatureVisible(featureFlags[id].stage, devHost)).map((id) => ({ id, ...featureFlags[id], enabled: flags[id] }));
5824
+ return c.json({ flags, labs });
5825
+ });
5826
+ const toggle = async (c, enabled) => {
5827
+ const auth = c.get("auth");
5828
+ if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
5829
+ const flagId = c.req.param("flagId") ?? "";
5830
+ if (!isFeatureFlagId(flagId)) return c.json({ error: "unknown feature flag" }, 404);
5831
+ if (!isFeatureVisible(featureFlags[flagId].stage, isDevHost(c))) {
5832
+ return c.json({ error: "this feature flag cannot be changed here" }, 403);
5833
+ }
5834
+ if (enabled) await enableFeatureFlag(auth.workspaceId, flagId, auth.userId);
5835
+ else await disableFeatureFlag(auth.workspaceId, flagId);
5836
+ return c.json({ id: flagId, enabled });
5837
+ };
5838
+ app.put("/:flagId", (c) => toggle(c, true));
5839
+ app.delete("/:flagId", (c) => toggle(c, false));
5840
+ return app;
5841
+ }
5842
+
5843
+ // src/dashboard/channels-routes.ts
5474
5844
  function coerceAccess(raw) {
5475
5845
  if (raw === "open" || raw === "*" || raw === "all") return raw;
5476
5846
  if (Array.isArray(raw) && raw.every((v) => typeof v === "string")) return raw;
@@ -5506,7 +5876,7 @@ function coerceMemberEmails(raw) {
5506
5876
  return raw.filter((v) => typeof v === "string").map((v) => v.trim().toLowerCase()).filter(Boolean);
5507
5877
  }
5508
5878
  function ensureCwdDir(cwd) {
5509
- const expanded = expandPath(cwd);
5879
+ const expanded = isLogicalCwd(cwd) ? resolveLogicalCwd(cwd) : expandPath(cwd);
5510
5880
  if (!existsSync12(expanded)) mkdirSync4(expanded, { recursive: true });
5511
5881
  }
5512
5882
  function toResponse(channel) {
@@ -5526,11 +5896,13 @@ function toResponse(channel) {
5526
5896
  runtime: channel.runtime,
5527
5897
  mounts: channel.mounts,
5528
5898
  agentName: channel.agentName,
5899
+ dmKey: channel.dmKey,
5900
+ isDefault: channel.isDefault,
5529
5901
  archived: !!channel.archivedAt
5530
5902
  };
5531
5903
  }
5532
5904
  function createChannelWriteRouter() {
5533
- const app = new Hono8();
5905
+ const app = new Hono9();
5534
5906
  app.post("/", async (c) => {
5535
5907
  const body = await c.req.json().catch(() => ({}));
5536
5908
  const name = typeof body["name"] === "string" ? body["name"].trim() : "";
@@ -5558,7 +5930,7 @@ function createChannelWriteRouter() {
5558
5930
  });
5559
5931
  return c.json(toResponse(channel), 201);
5560
5932
  } catch (err) {
5561
- return c.json({ error: err.message }, 500);
5933
+ return c.json({ error: err.message }, err instanceof DefaultChannelError ? 400 : 500);
5562
5934
  }
5563
5935
  });
5564
5936
  app.put("/:id", async (c) => {
@@ -5569,6 +5941,8 @@ function createChannelWriteRouter() {
5569
5941
  const cwd = typeof body["cwd"] === "string" ? body["cwd"].trim() : "";
5570
5942
  if (!cwd) return c.json({ errors: ["cwd is required"] }, 400);
5571
5943
  const model = typeof body["model"] === "string" ? body["model"].trim() : "";
5944
+ if (coerceRuntime(body["runtime"]) === "sandbox" && existing.runtime !== "sandbox" && !await isFeatureEnabled(c, "sandbox"))
5945
+ return c.json({ error: "feature not enabled" }, 403);
5572
5946
  try {
5573
5947
  ensureCwdDir(cwd);
5574
5948
  const channel = await updateChannelById(id, {
@@ -5586,7 +5960,7 @@ function createChannelWriteRouter() {
5586
5960
  dropSessionsIfRuntimeChanged(id, existing.runtime, channel.runtime);
5587
5961
  return c.json(toResponse(channel), 200);
5588
5962
  } catch (err) {
5589
- return c.json({ error: err.message }, 500);
5963
+ return c.json({ error: err.message }, err instanceof DefaultChannelError ? 400 : 500);
5590
5964
  }
5591
5965
  });
5592
5966
  app.patch("/reorder", async (c) => {
@@ -5629,6 +6003,8 @@ function createChannelWriteRouter() {
5629
6003
  if ("memberEmails" in body) patch.memberEmails = coerceMemberEmails(body["memberEmails"]);
5630
6004
  if ("runtime" in body) {
5631
6005
  const r = coerceRuntime(body["runtime"]);
6006
+ if (r === "sandbox" && existing.runtime !== "sandbox" && !await isFeatureEnabled(c, "sandbox"))
6007
+ return c.json({ error: "feature not enabled" }, 403);
5632
6008
  if (r) patch.runtime = r;
5633
6009
  }
5634
6010
  if ("mounts" in body) patch.mounts = coerceMounts(body["mounts"]);
@@ -5641,7 +6017,7 @@ function createChannelWriteRouter() {
5641
6017
  dropSessionsIfRuntimeChanged(id, existing.runtime, channel.runtime);
5642
6018
  return c.json(toResponse(channel), 200);
5643
6019
  } catch (err) {
5644
- return c.json({ error: err.message }, 500);
6020
+ return c.json({ error: err.message }, err instanceof DefaultChannelError ? 400 : 500);
5645
6021
  }
5646
6022
  });
5647
6023
  return app;
@@ -5663,13 +6039,13 @@ import {
5663
6039
  statSync as statSync4,
5664
6040
  writeFileSync as writeFileSync6
5665
6041
  } from "fs";
5666
- import { basename as basename4, dirname as dirname3, extname, join as join13, relative, resolve as resolve4, sep } from "path";
5667
- import { Hono as Hono9 } from "hono";
6042
+ import { basename as basename4, dirname as dirname3, extname, join as join14, relative, resolve as resolve4, sep } from "path";
6043
+ import { Hono as Hono10 } from "hono";
5668
6044
  import { streamSSE } from "hono/streaming";
5669
6045
 
5670
6046
  // src/dashboard/docs-watcher.ts
5671
6047
  import { existsSync as existsSync13, watch } from "fs";
5672
- import { basename as basename2, join as join9 } from "path";
6048
+ import { basename as basename2, join as join10 } from "path";
5673
6049
  var DEBOUNCE_MS = 200;
5674
6050
  var MAX_PENDING = 200;
5675
6051
  var IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
@@ -5710,7 +6086,7 @@ function watchDocs(cwd, listener) {
5710
6086
  const current = entries.get(cwd);
5711
6087
  if (!current || filename === null) return;
5712
6088
  const rel = filename.split("\\").join("/");
5713
- if (rel === root && !existsSync13(join9(cwd, rel))) return;
6089
+ if (rel === root && !existsSync13(join10(cwd, rel))) return;
5714
6090
  if (isIgnoredDocPath(rel)) return;
5715
6091
  if (current.pending.size < MAX_PENDING) current.pending.add(rel);
5716
6092
  if (current.timer === null) current.timer = setTimeout(() => flush(cwd), DEBOUNCE_MS);
@@ -5950,12 +6326,12 @@ async function readOrQueueDocAudio(input2) {
5950
6326
  import { readFileSync as readFileSync8 } from "fs";
5951
6327
 
5952
6328
  // src/dashboard/chat-agent.ts
5953
- import { homedir as homedir5 } from "os";
6329
+ import { homedir as homedir6 } from "os";
5954
6330
 
5955
6331
  // src/mcp/config.ts
5956
6332
  import { mkdtempSync, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
5957
6333
  import { tmpdir } from "os";
5958
- import { join as join10 } from "path";
6334
+ import { join as join11 } from "path";
5959
6335
 
5960
6336
  // src/mcp/crypto.ts
5961
6337
  import { createCipheriv, createDecipheriv, randomBytes as randomBytes3, timingSafeEqual } from "crypto";
@@ -6641,8 +7017,8 @@ async function buildMcpConfig(profile, connectionIds) {
6641
7017
  return { config, skipped };
6642
7018
  }
6643
7019
  function writeMcpConfigFile(config) {
6644
- const dir = mkdtempSync(join10(tmpdir(), "flyspace-mcp-"));
6645
- const path = join10(dir, "mcp-config.json");
7020
+ const dir = mkdtempSync(join11(tmpdir(), "flyspace-mcp-"));
7021
+ const path = join11(dir, "mcp-config.json");
6646
7022
  writeFileSync5(path, JSON.stringify(config), { mode: 384 });
6647
7023
  return {
6648
7024
  path,
@@ -6664,15 +7040,15 @@ async function withMcpConfig(profile, connectionIds, fn) {
6664
7040
 
6665
7041
  // src/claude/agents.ts
6666
7042
  import { existsSync as existsSync14, readdirSync as readdirSync2, readFileSync as readFileSync7 } from "fs";
6667
- import { homedir as homedir4 } from "os";
6668
- import { basename as basename3, join as join11 } from "path";
7043
+ import { homedir as homedir5 } from "os";
7044
+ import { basename as basename3, join as join12 } from "path";
6669
7045
  import yaml from "js-yaml";
6670
7046
  function defaultConfigDir() {
6671
- return join11(homedir4(), ".claude");
7047
+ return join12(homedir5(), ".claude");
6672
7048
  }
6673
7049
  function expandHome(p) {
6674
- if (p === "~") return homedir4();
6675
- if (p.startsWith("~/")) return join11(homedir4(), p.slice(2));
7050
+ if (p === "~") return homedir5();
7051
+ if (p.startsWith("~/")) return join12(homedir5(), p.slice(2));
6676
7052
  return p;
6677
7053
  }
6678
7054
  function parseFrontmatter(file) {
@@ -6702,7 +7078,7 @@ function collectFrom(root, source) {
6702
7078
  return [];
6703
7079
  }
6704
7080
  return entries2.map((file) => {
6705
- const path = join11(root, file);
7081
+ const path = join12(root, file);
6706
7082
  const fm = parseFrontmatter(path);
6707
7083
  const name = fm.name?.trim() || basename3(file, ".md");
6708
7084
  return { name, description: (fm.description ?? "").trim(), source, path };
@@ -6711,8 +7087,8 @@ function collectFrom(root, source) {
6711
7087
  function listAgents(configDir2, cwd) {
6712
7088
  const base = configDir2 ? expandHome(configDir2) : defaultConfigDir();
6713
7089
  const collected = [
6714
- ...collectFrom(join11(cwd, ".claude", "agents"), "project"),
6715
- ...collectFrom(join11(base, "agents"), "user")
7090
+ ...collectFrom(join12(cwd, ".claude", "agents"), "project"),
7091
+ ...collectFrom(join12(base, "agents"), "user")
6716
7092
  ];
6717
7093
  const byName = /* @__PURE__ */ new Map();
6718
7094
  for (const agent of collected) {
@@ -6952,9 +7328,54 @@ ${incoming}`;
6952
7328
  }
6953
7329
  }
6954
7330
 
7331
+ // src/dashboard/should-resume.ts
7332
+ var GROQ_API_URL5 = "https://api.groq.com/openai/v1/chat/completions";
7333
+ var CLASSIFIER_MODEL3 = "openai/gpt-oss-20b";
7334
+ var SYSTEM_PROMPT3 = `An AI assistant was working in a chat thread when its process was restarted. Anything it was still doing, or had promised to do later, was lost. You are given the thread topic and its most recent messages; the last one is the assistant's own. Decide whether the assistant has to pick the work back up.
7335
+
7336
+ Reply YES only if the assistant's last message clearly leaves work it owes unfinished and nothing after it delivers that work, e.g. it says something is running or in progress and that it will report back, it announces a next step it has not done yet, or it stops partway through a list of things it was asked to do.
7337
+
7338
+ Reply NO for anything else: the assistant answered or finished the request, it is waiting on the human (asked a question, offered choices, needs a decision or approval), it reported a result or an error, or you are unsure. Staying quiet is the safe default: answering YES makes the assistant act without being asked.
7339
+
7340
+ Answer with only the single word YES or NO, nothing else.`;
7341
+ async function shouldResume(topic, recent) {
7342
+ const apiKey = process.env["GROQ_API_KEY"];
7343
+ if (!apiKey) return false;
7344
+ if (recent.length === 0) return false;
7345
+ const user = `[thread topic]
7346
+ ${topic}
7347
+
7348
+ [most recent messages]
7349
+ ${recent.map((m) => `${m.author}: ${m.body}`).join("\n")}`;
7350
+ try {
7351
+ const res = await fetch(GROQ_API_URL5, {
7352
+ method: "POST",
7353
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
7354
+ body: JSON.stringify({
7355
+ model: CLASSIFIER_MODEL3,
7356
+ temperature: 0,
7357
+ reasoning_effort: "low",
7358
+ // gpt-oss reasons before it answers, and those tokens count against
7359
+ // this cap (see should-interrupt.ts).
7360
+ max_tokens: 150,
7361
+ messages: [
7362
+ { role: "system", content: SYSTEM_PROMPT3 },
7363
+ { role: "user", content: user }
7364
+ ]
7365
+ })
7366
+ });
7367
+ if (!res.ok) return false;
7368
+ const data = await res.json();
7369
+ const answer = data.choices?.[0]?.message?.content?.trim().toUpperCase() ?? "";
7370
+ return answer.startsWith("YES");
7371
+ } catch {
7372
+ return false;
7373
+ }
7374
+ }
7375
+
6955
7376
  // src/dashboard/attachment-downloader.ts
6956
7377
  import { writeFile } from "fs/promises";
6957
- import { join as join12 } from "path";
7378
+ import { join as join13 } from "path";
6958
7379
  import { GetObjectCommand as GetObjectCommand4 } from "@aws-sdk/client-s3";
6959
7380
  var debug = process.env["DEBUG"] === "true";
6960
7381
  var ATTACHMENT_LINK = /\]\(\/api\/attachments\/([^)?\s]+)(?:\?[^)\s]*)?\)/g;
@@ -7001,7 +7422,7 @@ async function downloadOne(key) {
7001
7422
  if (bytes.length > sizeLimit) {
7002
7423
  return { kind: "skip", skipped: { name, mimetype, reason: "too_large" } };
7003
7424
  }
7004
- const localPath = join12(paths.UPLOADS_DIR, lastSegment);
7425
+ const localPath = join13(paths.UPLOADS_DIR, lastSegment);
7005
7426
  await writeFile(localPath, bytes);
7006
7427
  if (debug)
7007
7428
  console.log(`[attachment-downloader] saved ${key} -> ${localPath} (${bytes.length} bytes)`);
@@ -7090,8 +7511,10 @@ function loadChatAgentConfig() {
7090
7511
  };
7091
7512
  }
7092
7513
  var RETRY_DELAY_MS = 3e3;
7514
+ var MISSED_MESSAGE_WINDOW_MS = 24 * 60 * 60 * 1e3;
7515
+ var MISSED_MESSAGE_THREAD_LIMIT = 100;
7093
7516
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
7094
- function stripPathPrefixes(label2, roots, home = homedir5()) {
7517
+ function stripPathPrefixes(label2, roots, home = homedir6()) {
7095
7518
  let out = label2;
7096
7519
  for (const root of roots) {
7097
7520
  if (root) out = out.replaceAll(`${root.replace(/\/+$/, "")}/`, "");
@@ -7109,6 +7532,7 @@ var ChatAgent = class {
7109
7532
  this.supabase = deps.supabase ?? getSupabaseClient;
7110
7533
  this.shouldAgentRespond = deps.shouldAgentRespond ?? shouldAgentRespond;
7111
7534
  this.shouldInterrupt = deps.shouldInterrupt ?? shouldInterrupt;
7535
+ this.shouldResume = deps.shouldResume ?? shouldResume;
7112
7536
  }
7113
7537
  queue = new MessageQueue();
7114
7538
  ask;
@@ -7118,6 +7542,7 @@ var ChatAgent = class {
7118
7542
  supabase;
7119
7543
  shouldAgentRespond;
7120
7544
  shouldInterrupt;
7545
+ shouldResume;
7121
7546
  /** Threads with a `run()` currently in flight, mapped to the tool-use
7122
7547
  * activities observed so far plus the run's abort controller. The realtime
7123
7548
  * "thinking"/"activity" broadcasts are ephemeral (no history for late
@@ -7139,6 +7564,10 @@ var ChatAgent = class {
7139
7564
  * it's accepted until `doRegenerate` settles — see `regenerate()`'s doc
7140
7565
  * comment for why this can't just be `activeRuns`. */
7141
7566
  pendingRegenerate = /* @__PURE__ */ new Set();
7567
+ /** `replyId`s this process has already been handed, so a recovery pass
7568
+ * never re-runs a message that is queued, in flight, or was deliberately
7569
+ * left unanswered by the ambient classifier. */
7570
+ seenReplies = /* @__PURE__ */ new Set();
7142
7571
  /** Workspace roots (workspaces.root_path) for path-prefix stripping in
7143
7572
  * activity labels. Fetched once per process — roots change ~never, and a
7144
7573
  * restart picks up edits. */
@@ -7146,6 +7575,7 @@ var ChatAgent = class {
7146
7575
  /** Fire-and-forget entry point, called by chat-routes after every write. */
7147
7576
  handleMessage(msg) {
7148
7577
  if (msg.author === AGENT_AUTHOR) return;
7578
+ this.seenReplies.add(msg.replyId);
7149
7579
  this.queue.enqueue(msg.threadId, () => this.maybeRun(msg));
7150
7580
  void this.maybeSupersede(msg);
7151
7581
  }
@@ -7180,11 +7610,81 @@ var ChatAgent = class {
7180
7610
  this.supersedingReplies.add(msg.replyId);
7181
7611
  run3.abort.abort();
7182
7612
  }
7613
+ /**
7614
+ * Picks up messages nobody answered. `handleMessage` is fire-and-forget and
7615
+ * every bit of run state lives in this process, so a message is silently
7616
+ * lost when it is written while the Host is offline (the Data side's call
7617
+ * has nowhere to land) or when the process dies mid-run. The stored thread
7618
+ * is the only durable record of either, so this reads it back: any recently
7619
+ * active thread whose latest message is a human's has nothing after it, and
7620
+ * goes through the normal `handleMessage` path, which still decides
7621
+ * whether it warrants a reply at all (mention, lone human, or the ambient
7622
+ * classifier), and still honours the pause switch.
7623
+ *
7624
+ * A thread the agent had the last word in can be unfinished too: it said
7625
+ * "running, I'll report back" and the restart took the promise with it.
7626
+ * Nothing but the wording records that, so `shouldResume` reads the topic
7627
+ * and the recent replies and, on a yes, the agent is handed its own last
7628
+ * message to carry on from. At most once per human message: a thread whose
7629
+ * last two messages are both the agent's is left alone, so a resume that
7630
+ * itself ends on a promise can't re-trigger on every restart.
7631
+ *
7632
+ * Call after anything that could have opened such a gap: Host start, and
7633
+ * every Host socket reconnect. Idempotent within a process (`seenReplies`).
7634
+ * Returns how many messages were handed over.
7635
+ */
7636
+ async recoverMissed() {
7637
+ const supabase = this.supabase();
7638
+ const since2 = new Date(Date.now() - MISSED_MESSAGE_WINDOW_MS).toISOString();
7639
+ const { data: threads, error: error2 } = await supabase.from("threads").select("id, channel_id, title").gte("updated_at", since2).is("archived_at", null).order("updated_at", { ascending: false }).limit(MISSED_MESSAGE_THREAD_LIMIT);
7640
+ if (error2) {
7641
+ console.error("[chat-agent] recoverMissed: failed to list threads:", error2.message);
7642
+ return 0;
7643
+ }
7644
+ let recovered = 0;
7645
+ for (const thread of threads ?? []) {
7646
+ if (!findChannel(thread.channel_id)) continue;
7647
+ const { data } = await supabase.from("thread_replies").select("id, author, body, model, effort, external_thread_replies_id").eq("thread_id", thread.id).is("archived_at", null).order("created_at", { ascending: false }).limit(CLASSIFIER_CONTEXT_SIZE);
7648
+ const tail = data ?? [];
7649
+ const last = tail[0];
7650
+ if (!last || last.external_thread_replies_id) continue;
7651
+ if (this.seenReplies.has(last.id)) continue;
7652
+ if (!isHumanAuthor(last.author)) {
7653
+ this.seenReplies.add(last.id);
7654
+ const asker = tail[1];
7655
+ if (!asker || !isHumanAuthor(asker.author)) continue;
7656
+ const transcript = [...tail].reverse().map((r) => ({ author: r.author, body: r.body }));
7657
+ if (!await this.shouldResume(thread.title ?? "", transcript)) continue;
7658
+ console.log(`[chat-agent] resuming unfinished work. thread=${thread.id}`);
7659
+ this.handleMessage({
7660
+ threadId: thread.id,
7661
+ replyId: last.id,
7662
+ author: asker.author,
7663
+ body: last.body,
7664
+ resumed: true
7665
+ });
7666
+ recovered++;
7667
+ continue;
7668
+ }
7669
+ console.log(`[chat-agent] recovering unanswered message. thread=${thread.id}`);
7670
+ this.handleMessage({
7671
+ threadId: thread.id,
7672
+ replyId: last.id,
7673
+ author: last.author,
7674
+ body: last.body,
7675
+ model: last.model ?? void 0,
7676
+ effort: last.effort && isEffort(last.effort) ? last.effort : void 0,
7677
+ recovered: true
7678
+ });
7679
+ recovered++;
7680
+ }
7681
+ return recovered;
7682
+ }
7183
7683
  /** Decides whether this message should trigger a reply, then runs it. */
7184
7684
  async maybeRun(msg) {
7185
7685
  const superseding = this.supersedingReplies.delete(msg.replyId);
7186
7686
  if (await this.isPausedForThread(msg.threadId)) return;
7187
- if (superseding) {
7687
+ if (superseding || msg.resumed) {
7188
7688
  await this.run(msg);
7189
7689
  return;
7190
7690
  }
@@ -7440,6 +7940,7 @@ var ChatAgent = class {
7440
7940
  activeRunner: runnerForModel(loadDashboardSettings().agent)
7441
7941
  })
7442
7942
  );
7943
+ ensureLogicalCwdDir(baseConfig.cwd);
7443
7944
  const pickedModel = msg.model?.trim();
7444
7945
  let activeRunner = pickedModel ? runnerForModel(pickedModel) : defaultRunner;
7445
7946
  let channelConfig = pickedModel ? {
@@ -7461,18 +7962,34 @@ var ChatAgent = class {
7461
7962
  };
7462
7963
  }
7463
7964
  }
7965
+ const docsAccess = await this.resolveSenderDocsAccess(
7966
+ owningProfile,
7967
+ msg.author,
7968
+ thread.channel_id
7969
+ );
7970
+ if (docsAccess && docsAccess !== "write") channelConfig = { ...channelConfig, docsAccess };
7464
7971
  const store = this.openSessionStore();
7465
7972
  try {
7466
7973
  const channelKey = `chat:${thread.channel_id}`;
7467
7974
  const session = store.getSession(channelKey, msg.threadId);
7468
- const header = `[context: chat channel=${thread.channel_id}` + (thread.title ? `, thread="${thread.title}"` : "") + `, sender=${msg.author}. This is the Flyspace dashboard, not Slack \u2014 it renders full Markdown, so use normal paragraphs, line breaks, and real Markdown lists instead of Slack-style single-line replies. When you name a file that lives in this channel's folder, write it as @ + its cwd-relative path (@docs/spec.md, @web/src/App.tsx) as plain text, never inside backticks or a Markdown link \u2014 the dashboard turns that into a chip the reader clicks to open the file. A trailing line number (@src/app.ts:120) is fine. Paths outside the cwd stay in backticks.]
7975
+ const docsNote = docsAccessPromptNote(channelConfig.docsAccess);
7976
+ const header = (docsNote ? `[access: ${docsNote}]
7977
+
7978
+ ` : "") + `[context: chat channel=${thread.channel_id}` + (thread.title ? `, thread="${thread.title}"` : "") + `, sender=${msg.author}. This is the Flyspace dashboard, not Slack \u2014 it renders full Markdown, so use normal paragraphs, line breaks, and real Markdown lists instead of Slack-style single-line replies. When you name a file that lives in this channel's folder, write it as @ + its cwd-relative path (@docs/spec.md, @web/src/App.tsx) as plain text, never inside backticks or a Markdown link \u2014 the dashboard turns that into a chip the reader clicks to open the file. A trailing line number (@src/app.ts:120) is fine. Paths outside the cwd stay in backticks.]
7469
7979
 
7470
7980
  `;
7471
7981
  const attachments = await downloadChatAttachments(msg.body);
7472
7982
  const interruptionNote = carried ? `[note: you were still working on "${carried.interrupted.slice(0, 500)}" when ${msg.author} sent the message below. It was sent to correct or replace that request, so it wins. Whatever you already did is still in your context \u2014 keep what is still useful and adjust, rather than starting over or finishing the superseded request. Do not apologise for the interruption; just answer.]
7473
7983
 
7474
7984
  ` : "";
7475
- const barePrompt = header + interruptionNote + msg.body + buildFilesPromptSection(attachments);
7985
+ const recoveryNote = msg.recovered ? `[note: the message below went unanswered: the agent was offline when it was sent, or a restart cut short the run answering it. If that run got as far as changing files or starting work, check what is already done before redoing it. Do not apologise for the delay; just answer.]
7986
+
7987
+ ` : "";
7988
+ const body = msg.resumed ? `[note: nobody sent a new message. The agent process restarted after your last reply in this thread, quoted below, which left work unfinished. Anything you had running or meant to do next may have died with it. Check what actually got done (a background job may also still be running, or have finished), then complete what is left and report the result. If there turns out to be nothing left to do, say so in one line. Do not promise another follow-up, and do not apologise for the gap.]
7989
+
7990
+ [your last reply]
7991
+ ${msg.body}` : msg.body;
7992
+ const barePrompt = header + interruptionNote + recoveryNote + body + buildFilesPromptSection(attachments);
7476
7993
  let resumableSessionId = session?.runner === activeRunner ? session.sessionId : void 0;
7477
7994
  let prompt = barePrompt;
7478
7995
  let rolledOver = false;
@@ -7748,9 +8265,9 @@ var ChatAgent = class {
7748
8265
  }, activity: () => {
7749
8266
  } };
7750
8267
  }
7751
- let pending2 = ready;
8268
+ let pending3 = ready;
7752
8269
  const send2 = (event, payload, last = false) => {
7753
- pending2 = pending2.then(async (subscribed) => {
8270
+ pending3 = pending3.then(async (subscribed) => {
7754
8271
  if (subscribed) {
7755
8272
  const res = await channel.send({ type: "broadcast", event, payload }).catch((err) => err.message);
7756
8273
  if (res !== "ok") {
@@ -7760,7 +8277,7 @@ var ChatAgent = class {
7760
8277
  return subscribed;
7761
8278
  }).catch(() => false);
7762
8279
  if (last) {
7763
- void pending2.finally(() => void this.supabase().removeChannel(channel));
8280
+ void pending3.finally(() => void this.supabase().removeChannel(channel));
7764
8281
  }
7765
8282
  };
7766
8283
  return {
@@ -7807,9 +8324,9 @@ var ChatAgent = class {
7807
8324
  };
7808
8325
  return this.channelActivitySend;
7809
8326
  }
7810
- let pending2 = ready;
8327
+ let pending3 = ready;
7811
8328
  this.channelActivitySend = (event, payload) => {
7812
- pending2 = pending2.then(async (subscribed) => {
8329
+ pending3 = pending3.then(async (subscribed) => {
7813
8330
  if (subscribed) {
7814
8331
  const res = await channel.send({ type: "broadcast", event, payload }).catch((err) => err.message);
7815
8332
  if (res !== "ok") {
@@ -7821,6 +8338,20 @@ var ChatAgent = class {
7821
8338
  };
7822
8339
  return this.channelActivitySend;
7823
8340
  }
8341
+ /**
8342
+ * The sender's Docs level on `channelId`, or undefined when no restriction
8343
+ * applies (non-human author, or a profile with no Supabase workspace). A
8344
+ * lookup failure resolves to 'none': better to withhold files than leak them.
8345
+ */
8346
+ async resolveSenderDocsAccess(profile, author, channelId) {
8347
+ if (!isHumanAuthor(author)) return void 0;
8348
+ try {
8349
+ return await resolveDocsAccessByHandle(profile, author, channelId) ?? void 0;
8350
+ } catch (err) {
8351
+ console.error("[chat-agent] docs access lookup failed:", err.message);
8352
+ return "none";
8353
+ }
8354
+ }
7824
8355
  async tryAsk(channelConfig, prompt, sessionId, onActivity, signal, usageContext) {
7825
8356
  const name = subAgentName(channelConfig.agentName);
7826
8357
  const agentPersona = name ? readAgentPersona(channelConfig.claudeConfigDir, channelConfig.cwd, name) : void 0;
@@ -7843,7 +8374,8 @@ var ChatAgent = class {
7843
8374
  onActivity,
7844
8375
  signal,
7845
8376
  mcpConfigPath,
7846
- ...usageContext ? { usageContext } : {}
8377
+ ...usageContext ? { usageContext } : {},
8378
+ ...channelConfig.docsAccess ? { docsAccess: channelConfig.docsAccess } : {}
7847
8379
  },
7848
8380
  channelConfig.claudeConfigDir,
7849
8381
  sessionId,
@@ -7873,12 +8405,19 @@ var ChatAgent = class {
7873
8405
  // the channel's own instructions ride in with the prompt. Without
7874
8406
  // this a Codex turn answers as a generic coding agent: no house
7875
8407
  // language rules, no Slack/dashboard conventions, no channel prompt.
7876
- systemPrompt: buildSystemPrompt(
7877
- this.config.botName,
7878
- agentPersona,
7879
- channelConfig.prompt,
7880
- resolveSelectedOutputStyle(channelConfig.claudeConfigDir)?.body
7881
- ),
8408
+ systemPrompt: [
8409
+ buildSystemPrompt(
8410
+ this.config.botName,
8411
+ agentPersona,
8412
+ channelConfig.prompt,
8413
+ resolveSelectedOutputStyle(channelConfig.claudeConfigDir)?.body
8414
+ ),
8415
+ docsAccessPromptNote(channelConfig.docsAccess)
8416
+ ].filter(Boolean).join("\n\n---\n\n"),
8417
+ // Codex has no per-tool deny list, so any restricted requester gets
8418
+ // its read-only sandbox (no file writes). It can still read: see the
8419
+ // residual-gap note in docs/features/user-roles.md.
8420
+ ...channelConfig.docsAccess === "read" || channelConfig.docsAccess === "none" ? { sandbox: "read-only" } : {},
7882
8421
  ...onActivity ? { onActivity } : {},
7883
8422
  ...signal ? { signal } : {},
7884
8423
  ...usageContext ? { usageContext } : {}
@@ -8198,7 +8737,9 @@ var DOC_EXTENSIONS = {
8198
8737
  ".htm": "html",
8199
8738
  // A file whose whole content is a URL — the Docs pane renders it as a
8200
8739
  // full-size iframe of that page instead of showing the link as text.
8201
- ".url": "url"
8740
+ ".url": "url",
8741
+ // An Excalidraw drawing: JSON on disk, drawn and edited in the Docs pane.
8742
+ ".excalidraw": "excalidraw"
8202
8743
  };
8203
8744
  var MEDIA_EXTENSIONS = {
8204
8745
  ".png": "image/png",
@@ -8261,7 +8802,7 @@ var CODE_EXTENSIONS = {
8261
8802
  function highlightLanguageOf(path) {
8262
8803
  const ext = extname(path).toLowerCase();
8263
8804
  const doc = DOC_EXTENSIONS[ext];
8264
- if (doc) return doc === "md" ? "markdown" : "xml";
8805
+ if (doc) return doc === "md" ? "markdown" : doc === "excalidraw" ? "json" : "xml";
8265
8806
  return CODE_EXTENSIONS[ext] ?? null;
8266
8807
  }
8267
8808
  var SKIP_FILES = /* @__PURE__ */ new Set([
@@ -8363,7 +8904,7 @@ function readHead(absPath) {
8363
8904
  }
8364
8905
  }
8365
8906
  function extractTitle(absPath, kind) {
8366
- if (kind === "url") return basename4(absPath);
8907
+ if (kind === "url" || kind === "excalidraw") return basename4(absPath);
8367
8908
  const head = readHead(absPath);
8368
8909
  if (!head) return basename4(absPath);
8369
8910
  if (kind === "md") {
@@ -8405,7 +8946,7 @@ function probeDir(absDir, showHidden) {
8405
8946
  return false;
8406
8947
  }
8407
8948
  if (!showHidden && entry.name.startsWith(".")) continue;
8408
- const abs = join13(dir, entry.name);
8949
+ const abs = join14(dir, entry.name);
8409
8950
  const kind = entryKind(entry, abs);
8410
8951
  if (kind === "dir") {
8411
8952
  if (SKIP_DIRS.has(entry.name)) hasOther = true;
@@ -8425,14 +8966,14 @@ function probeDir(absDir, showHidden) {
8425
8966
  }
8426
8967
  function readGitBranch(dir) {
8427
8968
  try {
8428
- let gitDir = join13(dir, ".git");
8969
+ let gitDir = join14(dir, ".git");
8429
8970
  if (statSync4(gitDir).isFile()) {
8430
8971
  const pointer = readFileSync9(gitDir, "utf8").trim();
8431
8972
  const match = pointer.match(/^gitdir:\s*(.+)$/);
8432
8973
  if (!match) return void 0;
8433
8974
  gitDir = resolve4(dir, match[1]);
8434
8975
  }
8435
- const head = readFileSync9(join13(gitDir, "HEAD"), "utf8").trim();
8976
+ const head = readFileSync9(join14(gitDir, "HEAD"), "utf8").trim();
8436
8977
  const branchMatch = head.match(/^ref:\s*refs\/heads\/(.+)$/);
8437
8978
  if (branchMatch) return branchMatch[1];
8438
8979
  return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 7) : void 0;
@@ -8454,14 +8995,14 @@ function listDocLevel(root, relDir = "", sort = "newest", showHidden = false) {
8454
8995
  for (const entry of entries2) {
8455
8996
  if (dirs.length + docs.length >= MAX_ENTRIES) break;
8456
8997
  if (!showHidden && entry.name.startsWith(".")) continue;
8457
- const abs = join13(absDir, entry.name);
8998
+ const abs = join14(absDir, entry.name);
8458
8999
  const path = relative(root, abs).split(sep).join("/");
8459
9000
  const kind = entryKind(entry, abs);
8460
9001
  if (kind === "dir") {
8461
9002
  if (SKIP_DIRS.has(entry.name)) continue;
8462
9003
  const probe2 = probeDir(abs, showHidden);
8463
9004
  if (!probe2.hasDocs && probe2.hasOther) continue;
8464
- const isRepo = existsSync16(join13(abs, ".git"));
9005
+ const isRepo = existsSync16(join14(abs, ".git"));
8465
9006
  dirs.push({
8466
9007
  path,
8467
9008
  name: entry.name,
@@ -8497,7 +9038,7 @@ function flatDocs(root, {
8497
9038
  for (const entry of entries2) {
8498
9039
  if (hits.length >= MAX_FLAT_DOCS) return;
8499
9040
  if (!showHidden && entry.name.startsWith(".")) continue;
8500
- const abs = join13(dir, entry.name);
9041
+ const abs = join14(dir, entry.name);
8501
9042
  const kind = entryKind(entry, abs);
8502
9043
  if (kind === "dir") {
8503
9044
  if (!SKIP_DIRS.has(entry.name)) walk(abs, depth + 1);
@@ -8577,7 +9118,7 @@ function uniqueName(absDir, name) {
8577
9118
  let candidate = name;
8578
9119
  for (let n = 1; n < 1e3; n++) {
8579
9120
  try {
8580
- statSync4(join13(absDir, candidate));
9121
+ statSync4(join14(absDir, candidate));
8581
9122
  } catch {
8582
9123
  return candidate;
8583
9124
  }
@@ -8599,7 +9140,7 @@ function saveUploadedFile(root, relDir, name, data) {
8599
9140
  if (!(ext in DOC_EXTENSIONS) && !ASSET_LIST_EXTENSIONS.has(ext)) return { status: "bad-type" };
8600
9141
  if (data.byteLength > MAX_UPLOAD_BYTES) return { status: "too-large" };
8601
9142
  const final = uniqueName(absDir, safe);
8602
- const abs = join13(absDir, final);
9143
+ const abs = join14(absDir, final);
8603
9144
  writeFileSync6(abs, data);
8604
9145
  return { status: "ok", path: relative(root, abs).split(sep).join("/"), name: final };
8605
9146
  }
@@ -8613,7 +9154,7 @@ function createDocFolder(root, relDir, name) {
8613
9154
  }
8614
9155
  const safe = sanitizeUploadName(name);
8615
9156
  if (!safe) return { status: "bad-name" };
8616
- const abs = join13(absDir, safe);
9157
+ const abs = join14(absDir, safe);
8617
9158
  try {
8618
9159
  statSync4(abs);
8619
9160
  return { status: "exists" };
@@ -8636,7 +9177,7 @@ function createDocFile(root, relDir, name, content) {
8636
9177
  if (!(ext in DOC_EXTENSIONS) && !(ext in CODE_EXTENSIONS)) return { status: "bad-name" };
8637
9178
  if (SKIP_FILES.has(safe)) return { status: "bad-name" };
8638
9179
  if (Buffer.byteLength(content, "utf8") > MAX_CONTENT_BYTES) return { status: "too-large" };
8639
- const abs = join13(absDir, safe);
9180
+ const abs = join14(absDir, safe);
8640
9181
  try {
8641
9182
  writeFileSync6(abs, content, { encoding: "utf8", flag: "wx" });
8642
9183
  } catch (err) {
@@ -8674,7 +9215,7 @@ function moveDocEntry(root, from, to) {
8674
9215
  } catch {
8675
9216
  return { status: "bad-target" };
8676
9217
  }
8677
- const finalAbs = join13(absToDir, name);
9218
+ const finalAbs = join14(absToDir, name);
8678
9219
  if (isDir2 && (finalAbs === absFrom || finalAbs.startsWith(absFrom + sep))) {
8679
9220
  return { status: "into-self" };
8680
9221
  }
@@ -8705,7 +9246,9 @@ function deleteDocEntry(root, path) {
8705
9246
  };
8706
9247
  }
8707
9248
  function createDocsRouter() {
8708
- const app = new Hono9();
9249
+ const app = new Hono10();
9250
+ app.use("/:id/docs", requireDocsAccess);
9251
+ app.use("/:id/docs/*", requireDocsAccess);
8709
9252
  app.get("/:id/docs", (c) => {
8710
9253
  const { cwd } = resolveChannelConfig(c.req.param("id"));
8711
9254
  const dir = c.req.query("path") ?? "";
@@ -8821,6 +9364,8 @@ function createDocsRouter() {
8821
9364
  if (!body || typeof body.name !== "string") return c.json({ error: "name is required" }, 400);
8822
9365
  const dir = typeof body.path === "string" ? body.path : "";
8823
9366
  const content = typeof body.content === "string" ? body.content : "";
9367
+ if (body.name.toLowerCase().endsWith(".excalidraw") && !await isFeatureEnabled(c, "excalidraw"))
9368
+ return c.json({ error: "feature not enabled" }, 403);
8824
9369
  const result = createDocFile(cwd, dir, body.name, content);
8825
9370
  switch (result.status) {
8826
9371
  case "no-dir":
@@ -9010,6 +9555,8 @@ function createDocsRouter() {
9010
9555
  if ("error" in resolved2) return resolved2.error;
9011
9556
  const lang = canonicalLanguage(c.req.param("lang"));
9012
9557
  if (!isTranslatableLanguage(lang)) return c.json({ error: "not found" }, 404);
9558
+ if (!await isFeatureEnabled(c, "read-aloud"))
9559
+ return c.json({ error: "feature not enabled" }, 403);
9013
9560
  try {
9014
9561
  const audio = await readOrQueueDocAudio({
9015
9562
  channelId: resolved2.channel.id,
@@ -9028,7 +9575,7 @@ function createDocsRouter() {
9028
9575
  }
9029
9576
 
9030
9577
  // src/dashboard/output-styles-routes.ts
9031
- import { Hono as Hono10 } from "hono";
9578
+ import { Hono as Hono11 } from "hono";
9032
9579
  function requireAdmin(auth) {
9033
9580
  return auth?.role === "admin" ? null : "admin access required";
9034
9581
  }
@@ -9042,13 +9589,13 @@ function toResponse3(style) {
9042
9589
  updatedAt: style.updatedAt
9043
9590
  };
9044
9591
  }
9045
- var configDir = () => profileEnv("CLAUDE_CONFIG_DIR");
9592
+ var configDir = () => profileClaudeConfigDir();
9046
9593
  function failure(c, err) {
9047
9594
  const message = err.message;
9048
9595
  return c.json({ error: message }, err instanceof OutputStyleConflictError ? 409 : 500);
9049
9596
  }
9050
9597
  function createOutputStylesRouter() {
9051
- const app = new Hono10();
9598
+ const app = new Hono11();
9052
9599
  app.get("/", (c) => {
9053
9600
  const auth = c.get("auth");
9054
9601
  const denied = requireAdmin(auth);
@@ -9139,11 +9686,44 @@ function createOutputStylesRouter() {
9139
9686
  });
9140
9687
  return app;
9141
9688
  }
9689
+ async function handleOutputStylesRequest(req) {
9690
+ const app = new Hono11();
9691
+ app.use("*", async (c, next) => {
9692
+ c.set("auth", { role: "admin" });
9693
+ await next();
9694
+ });
9695
+ app.route("/", createOutputStylesRouter());
9696
+ const res = await app.request(req.path, {
9697
+ method: req.method,
9698
+ ...req.body !== void 0 && {
9699
+ body: JSON.stringify(req.body),
9700
+ headers: { "content-type": "application/json" }
9701
+ }
9702
+ });
9703
+ return { status: res.status, body: await res.json().catch(() => null) };
9704
+ }
9705
+ function registerRelayedOutputStylesRoutes(api, agent) {
9706
+ api.all("/output-styles/*", relay(agent, (p) => p.slice(p.indexOf("/output-styles") + "/output-styles".length) || "/"));
9707
+ api.all("/output-styles", relay(agent, () => "/"));
9708
+ }
9709
+ function relay(agent, pathOf) {
9710
+ return async (c) => {
9711
+ const denied = requireAdmin(c.get("auth"));
9712
+ if (denied) return c.json({ error: denied }, 403);
9713
+ const method = c.req.method;
9714
+ const body = method === "GET" || method === "DELETE" ? void 0 : await c.req.json().catch(() => null);
9715
+ const result = await agent.outputStylesRequest?.({ method, path: pathOf(c.req.path), body });
9716
+ if (!result) {
9717
+ return c.json({ error: "Output styles need the Host machine, which is not connected right now." }, 503);
9718
+ }
9719
+ return c.json(result.body, result.status);
9720
+ };
9721
+ }
9142
9722
 
9143
9723
  // src/dashboard/skills-routes.ts
9144
9724
  import { existsSync as existsSync18, statSync as statSync6 } from "fs";
9145
9725
  import { dirname as dirname4 } from "path";
9146
- import { Hono as Hono11 } from "hono";
9726
+ import { Hono as Hono12 } from "hono";
9147
9727
 
9148
9728
  // src/dashboard/skill-mirror.ts
9149
9729
  import { createHash as createHash8 } from "crypto";
@@ -9170,7 +9750,7 @@ function ownSkills(scope, dir) {
9170
9750
  }
9171
9751
  function snapshot(scopes) {
9172
9752
  const wants = (scope) => !scopes || scopes.has(scope);
9173
- const configDir2 = profileEnv("CLAUDE_CONFIG_DIR");
9753
+ const configDir2 = profileClaudeConfigDir();
9174
9754
  const rows = [];
9175
9755
  if (wants("workspace")) rows.push(...ownSkills("workspace", workspaceSkillsDir(configDir2)));
9176
9756
  if (wants("plugin")) {
@@ -9262,7 +9842,7 @@ async function readMirroredSkills(scopes, name) {
9262
9842
 
9263
9843
  // src/dashboard/skills-routes.ts
9264
9844
  function workspaceScope() {
9265
- const claudeConfigDir = profileEnv("CLAUDE_CONFIG_DIR");
9845
+ const claudeConfigDir = profileClaudeConfigDir();
9266
9846
  const cwd = defaultClaudeCwd();
9267
9847
  return {
9268
9848
  dir: workspaceSkillsDir(claudeConfigDir),
@@ -9468,12 +10048,12 @@ function mountSkillRoutes(app, base, refOf, backend) {
9468
10048
  var workspaceRef = () => ({ kind: "workspace" });
9469
10049
  var channelRef = (c) => ({ kind: "channel", channelId: c.req.param("id") });
9470
10050
  function createWorkspaceSkillsRouter() {
9471
- const app = new Hono11();
10051
+ const app = new Hono12();
9472
10052
  mountSkillRoutes(app, "/skills", workspaceRef, diskBackend);
9473
10053
  return app;
9474
10054
  }
9475
10055
  function createChannelSkillsRouter() {
9476
- const app = new Hono11();
10056
+ const app = new Hono12();
9477
10057
  mountSkillRoutes(app, "/:id/skill-files", channelRef, diskBackend);
9478
10058
  return app;
9479
10059
  }
@@ -9491,7 +10071,7 @@ async function resolveAgentIdentities() {
9491
10071
  const main2 = [
9492
10072
  {
9493
10073
  name: BOT_DM_KEY,
9494
- label: "Workspace Bot",
10074
+ label: "Workspace Agent",
9495
10075
  kind: "main",
9496
10076
  cwd: defaultCwd,
9497
10077
  model: settings.agentProfiles?.[BOT_DM_KEY]?.model,
@@ -9512,8 +10092,8 @@ async function resolveAgentIdentities() {
9512
10092
  // src/dashboard/providers.ts
9513
10093
  import { execFileSync } from "child_process";
9514
10094
  import { existsSync as existsSync19 } from "fs";
9515
- import { homedir as homedir6 } from "os";
9516
- import { join as join14, resolve as resolve5 } from "path";
10095
+ import { homedir as homedir7 } from "os";
10096
+ import { join as join15, resolve as resolve5 } from "path";
9517
10097
  var PROVIDER_LABELS = {
9518
10098
  claude: "Claude Code",
9519
10099
  codex: "Codex"
@@ -9530,8 +10110,8 @@ function binaryOnPath(bin) {
9530
10110
  }
9531
10111
  function claudeAuthAvailable() {
9532
10112
  if (profileEnv("CLAUDE_CODE_OAUTH_TOKEN")?.trim()) return true;
9533
- const configDir2 = profileEnv("CLAUDE_CONFIG_DIR")?.trim() || resolve5(homedir6(), ".claude");
9534
- if (existsSync19(join14(configDir2, ".credentials.json"))) return true;
10113
+ const configDir2 = profileClaudeConfigDir() || resolve5(homedir7(), ".claude");
10114
+ if (existsSync19(join15(configDir2, ".credentials.json"))) return true;
9535
10115
  if (process.platform !== "darwin") return false;
9536
10116
  try {
9537
10117
  execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials"], {
@@ -9594,6 +10174,8 @@ function registerHostRoutes(api) {
9594
10174
  agent,
9595
10175
  displayName: body["displayName"] === null || typeof body["displayName"] === "string" ? body["displayName"] : void 0,
9596
10176
  gaMeasurementId: body["gaMeasurementId"] === null || typeof body["gaMeasurementId"] === "string" ? body["gaMeasurementId"] : void 0,
10177
+ autoTitleEnabled: typeof body["autoTitleEnabled"] === "boolean" ? body["autoTitleEnabled"] : void 0,
10178
+ autoTitlePrompt: body["autoTitlePrompt"] === null || typeof body["autoTitlePrompt"] === "string" ? body["autoTitlePrompt"] : void 0,
9597
10179
  fallbackModel: body["fallbackModel"] === null || typeof body["fallbackModel"] === "string" ? body["fallbackModel"] : void 0,
9598
10180
  // Shape-checked only — which runners exist, whether a model belongs to
9599
10181
  // the runner it's listed under, and what a missing/duplicate entry
@@ -9632,7 +10214,7 @@ function registerHostRoutes(api) {
9632
10214
  api.delete("/settings/prompt", (c) => c.json(resetWorkspacePrompt()));
9633
10215
  api.get(
9634
10216
  "/settings/bot",
9635
- async (c) => c.json({ name: BOT_PROFILE_KEY, label: "Workspace Bot", ...await loadBotIdentity() })
10217
+ async (c) => c.json({ name: BOT_PROFILE_KEY, label: "Workspace Agent", ...await loadBotIdentity() })
9636
10218
  );
9637
10219
  api.get("/settings/agents", async (c) => c.json({ agents: await resolveAgentIdentities() }));
9638
10220
  api.put("/settings/agents/:name", async (c) => {
@@ -9672,7 +10254,7 @@ function registerHostRoutes(api) {
9672
10254
  const errors = c.req.query("error") === "true" || c.req.query("error") === "1";
9673
10255
  const limit = Math.min(Number(c.req.query("limit")) || 200, 2e3);
9674
10256
  const filename = errors ? "custie-error.log" : "custie.log";
9675
- const file = join15(paths.LOG_DIR, filename);
10257
+ const file = join16(paths.LOG_DIR, filename);
9676
10258
  const exists = existsSync20(file);
9677
10259
  return c.json({
9678
10260
  file,
@@ -9690,11 +10272,11 @@ function tailFile(file, limit) {
9690
10272
  }
9691
10273
 
9692
10274
  // src/dashboard/data-api.ts
9693
- import { existsSync as existsSync23 } from "fs";
10275
+ import { existsSync as existsSync22 } from "fs";
9694
10276
  import sharp5 from "sharp";
9695
10277
 
9696
10278
  // src/dashboard/automations-routes.ts
9697
- import { Hono as Hono12 } from "hono";
10279
+ import { Hono as Hono13 } from "hono";
9698
10280
  var TYPES2 = ["schedule", "trigger", "mention-trigger"];
9699
10281
  function isType(t) {
9700
10282
  return TYPES2.includes(t);
@@ -9711,7 +10293,7 @@ function statusFor(message) {
9711
10293
  return 400;
9712
10294
  }
9713
10295
  function createAutomationWriteRouter() {
9714
- const app = new Hono12();
10296
+ const app = new Hono13();
9715
10297
  const manager = () => new AutomationManager();
9716
10298
  app.get("/:type/:name", (c) => {
9717
10299
  const item = manager().get(c.req.param("name"));
@@ -9767,9 +10349,9 @@ function createAutomationWriteRouter() {
9767
10349
  }
9768
10350
 
9769
10351
  // src/dashboard/categories-routes.ts
9770
- import { Hono as Hono13 } from "hono";
10352
+ import { Hono as Hono14 } from "hono";
9771
10353
  function createCategoryWriteRouter() {
9772
- const app = new Hono13();
10354
+ const app = new Hono14();
9773
10355
  app.get("/", async (c) => {
9774
10356
  const { data, error: error2 } = await getSupabaseClient().from("categories").select("*").eq("profile", getProfile()).order("position", { ascending: true });
9775
10357
  if (error2) return c.json({ error: error2.message }, 500);
@@ -9823,10 +10405,10 @@ function createCategoryWriteRouter() {
9823
10405
  }
9824
10406
 
9825
10407
  // src/dashboard/chat-routes.ts
9826
- import { Hono as Hono14 } from "hono";
10408
+ import { Hono as Hono15 } from "hono";
9827
10409
 
9828
10410
  // src/dashboard/fork-summary.ts
9829
- var GROQ_API_URL5 = "https://api.groq.com/openai/v1/chat/completions";
10411
+ var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
9830
10412
  var SUMMARY_MODEL = "openai/gpt-oss-120b";
9831
10413
  var MAX_INPUT_CHARS2 = 24e3;
9832
10414
  var TITLE_MAX_CHARS = 80;
@@ -9890,7 +10472,7 @@ async function buildForkTopic(transcript, fallbackText, mode = "condense") {
9890
10472
  const apiKey = process.env["GROQ_API_KEY"];
9891
10473
  if (!apiKey) return fallbackTopic(fallback);
9892
10474
  try {
9893
- const res = await fetch(GROQ_API_URL5, {
10475
+ const res = await fetch(GROQ_API_URL6, {
9894
10476
  method: "POST",
9895
10477
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
9896
10478
  body: JSON.stringify({
@@ -9990,7 +10572,7 @@ async function putThreadTranslations(lang, rows) {
9990
10572
  }
9991
10573
 
9992
10574
  // src/dashboard/thread-translate.ts
9993
- var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
10575
+ var GROQ_API_URL7 = "https://api.groq.com/openai/v1/chat/completions";
9994
10576
  var TRANSLATION_MODEL2 = "openai/gpt-oss-120b";
9995
10577
  var RECIPE_VERSION3 = 1;
9996
10578
  var BATCH_CHAR_BUDGET = 16e3;
@@ -10058,7 +10640,7 @@ async function translateBatch(batch, context, lang) {
10058
10640
  context: context.map((m) => ({ author: m.author, body: clip(m.body) })),
10059
10641
  translate: batch.map((m, i) => ({ n: i + 1, author: m.author, body: clip(m.body) }))
10060
10642
  };
10061
- const res = await fetch(GROQ_API_URL6, {
10643
+ const res = await fetch(GROQ_API_URL7, {
10062
10644
  method: "POST",
10063
10645
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
10064
10646
  body: JSON.stringify({
@@ -10158,6 +10740,10 @@ function sanitizeModel(v) {
10158
10740
  const model = v.trim().toLowerCase();
10159
10741
  return isAllowedModel(model) ? model : null;
10160
10742
  }
10743
+ async function threadModel(threadId) {
10744
+ 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();
10745
+ return sanitizeModel(data?.model);
10746
+ }
10161
10747
  function requestedModel(c, model) {
10162
10748
  return c.get("auth")?.role === "admin" ? sanitizeModel(model) : null;
10163
10749
  }
@@ -10266,8 +10852,38 @@ async function withPreviews(supabase, threads) {
10266
10852
  };
10267
10853
  });
10268
10854
  }
10855
+ async function labelThread(channelId, body, scope) {
10856
+ const channel = getProfileChannelRegistry().find(
10857
+ (ch) => ch.id === channelId || ch.externalChannelId === channelId
10858
+ );
10859
+ if (!channel) return null;
10860
+ const supabase = getSupabaseClient();
10861
+ const { data: recent } = await supabase.from("threads").select("auto_title, title").eq("channel_id", channelId).order("created_at", { ascending: false }).limit(50);
10862
+ const existingCategories = extractCategories(
10863
+ (recent ?? []).flatMap((t) => [
10864
+ t.auto_title,
10865
+ t.title
10866
+ ])
10867
+ );
10868
+ return buildAutoTitle({
10869
+ channelName: channel.name,
10870
+ channelPrompt: channel.prompt,
10871
+ workspacePrompt: loadWorkspacePrompt().prompt,
10872
+ titlePrompt: (await loadWorkspaceBranding()).autoTitlePrompt,
10873
+ existingCategories,
10874
+ body,
10875
+ scope
10876
+ });
10877
+ }
10878
+ async function generateAutoTitle(threadId, channelId, body) {
10879
+ if ((await loadWorkspaceBranding()).autoTitleEnabled === false) return;
10880
+ const autoTitle = await labelThread(channelId, body);
10881
+ if (!autoTitle) return;
10882
+ const supabase = getSupabaseClient();
10883
+ await supabase.from("threads").update({ auto_title: autoTitle }).eq("id", threadId).is("title", null);
10884
+ }
10269
10885
  function createChatRouter(agent = {}) {
10270
- const app = new Hono14();
10886
+ const app = new Hono15();
10271
10887
  const {
10272
10888
  onChatMessage: onMessage,
10273
10889
  isAgentThinking,
@@ -10557,6 +11173,18 @@ function createChatRouter(agent = {}) {
10557
11173
  if (error2) return c.json({ error: error2.message }, 500);
10558
11174
  return c.json(thread);
10559
11175
  });
11176
+ app.post("/threads/:id/suggest-title", async (c) => {
11177
+ const id = c.req.param("id");
11178
+ if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
11179
+ const { data: thread } = await getSupabaseClient().from("threads").select("channel_id").eq("id", id).single();
11180
+ if (!thread) return c.json({ error: "not found" }, 404);
11181
+ 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 });
11182
+ if (error2) return c.json({ error: error2.message }, 500);
11183
+ const transcript = (rows ?? []).map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
11184
+ const title = await labelThread(thread.channel_id, transcript, "thread");
11185
+ if (!title) return c.json({ error: "could not generate a topic" }, 502);
11186
+ return c.json({ title });
11187
+ });
10560
11188
  app.patch("/replies/:replyId", async (c) => {
10561
11189
  const replyId = c.req.param("replyId");
10562
11190
  const input2 = await c.req.json().catch(() => null);
@@ -10677,6 +11305,11 @@ function createChatRouter(agent = {}) {
10677
11305
  effort: effortFromRow(reply["effort"])
10678
11306
  });
10679
11307
  }
11308
+ if (!replayed && thread["title"] == null) {
11309
+ void generateAutoTitle(thread.id, channelId, input2.body).catch(
11310
+ (err) => console.error("Auto title failed:", err)
11311
+ );
11312
+ }
10680
11313
  return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
10681
11314
  });
10682
11315
  app.post("/replies/:replyId/fork", async (c) => {
@@ -10725,9 +11358,9 @@ function createChatRouter(agent = {}) {
10725
11358
  return c.json({ errors: ["author and body are required"] }, 400);
10726
11359
  }
10727
11360
  const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10728
- const model = requestedModel(c, input2.model);
10729
11361
  const effort = requestedEffort(c, input2.effort);
10730
11362
  if (!await threadIsKnown(threadId, c)) return c.json({ error: "not found" }, 404);
11363
+ const model = requestedModel(c, input2.model) ?? await threadModel(threadId);
10731
11364
  const { data, error: error2 } = await getSupabaseClient().from("thread_replies").insert({
10732
11365
  thread_id: threadId,
10733
11366
  author: input2.author,
@@ -10757,7 +11390,7 @@ function createChatRouter(agent = {}) {
10757
11390
  }
10758
11391
 
10759
11392
  // src/dashboard/doc-shares-routes.ts
10760
- import { Hono as Hono15 } from "hono";
11393
+ import { Hono as Hono16 } from "hono";
10761
11394
 
10762
11395
  // src/dashboard/doc-share-features.ts
10763
11396
  import { DeleteObjectCommand as DeleteObjectCommand3 } from "@aws-sdk/client-s3";
@@ -10802,7 +11435,7 @@ async function deleteShareAssets(keys) {
10802
11435
  }
10803
11436
  }
10804
11437
  async function updateShareFeatures(share, requested) {
10805
- if (share.kind === "html") return share;
11438
+ if (share.kind === "html" || share.kind === "excalidraw") return share;
10806
11439
  const features = resolveFeatures(requested, share);
10807
11440
  const updated = await updateDocShareFeatures(share.id, features);
10808
11441
  if (!updated) return null;
@@ -10811,6 +11444,25 @@ async function updateShareFeatures(share, requested) {
10811
11444
  return updated;
10812
11445
  }
10813
11446
 
11447
+ // src/dashboard/share-tags.ts
11448
+ var MAX_TAGS = 10;
11449
+ var MAX_TAG_LENGTH = 30;
11450
+ function normalizeTag(raw) {
11451
+ const tag = raw.normalize("NFKC").trim().toLowerCase().replace(/^#+/, "").replace(/[\s_/\\,?#%]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "").slice(0, MAX_TAG_LENGTH).replace(/-+$/, "");
11452
+ return tag || null;
11453
+ }
11454
+ function normalizeTags(raw) {
11455
+ if (!Array.isArray(raw)) return null;
11456
+ const tags = /* @__PURE__ */ new Set();
11457
+ for (const item of raw) {
11458
+ if (typeof item !== "string") continue;
11459
+ const tag = normalizeTag(item);
11460
+ if (tag) tags.add(tag);
11461
+ if (tags.size >= MAX_TAGS) break;
11462
+ }
11463
+ return [...tags];
11464
+ }
11465
+
10814
11466
  // src/dashboard/share-slug.ts
10815
11467
  var MIN_SLUG = 2;
10816
11468
  var MAX_SLUG = 64;
@@ -10861,6 +11513,7 @@ function shareJson(c, share) {
10861
11513
  return {
10862
11514
  id: share.id,
10863
11515
  slug: share.slug,
11516
+ tags: share.tags,
10864
11517
  url: shareUrl(c, share.slug ?? share.id),
10865
11518
  path: share.sourcePath,
10866
11519
  title: share.title,
@@ -10917,18 +11570,26 @@ function resolveChannel2(c) {
10917
11570
  }
10918
11571
  var LIST_LIMIT = 500;
10919
11572
  function createDocShareListRouter() {
10920
- const app = new Hono15();
11573
+ const app = new Hono16();
10921
11574
  app.get("/shares", async (c) => {
10922
11575
  const viewer = c.get("auth");
11576
+ const readable = await filterReadableDocsChannels(
11577
+ viewer,
11578
+ getProfileChannelRegistry().map((ch) => ch.id)
11579
+ );
10923
11580
  const channels = new Map(
10924
11581
  getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, viewer)).map((ch) => [ch.id, ch.name])
10925
11582
  );
11583
+ for (const id of [...channels.keys()]) {
11584
+ if (!readable.has(id)) channels.delete(id);
11585
+ }
10926
11586
  const shares = await listLiveDocShares([...channels.keys()], LIST_LIMIT + 1);
10927
11587
  return c.json({
10928
11588
  truncated: shares.length > LIST_LIMIT,
10929
11589
  shares: shares.slice(0, LIST_LIMIT).map((s) => ({
10930
11590
  id: s.id,
10931
11591
  slug: s.slug,
11592
+ tags: s.tags,
10932
11593
  url: shareUrl(c, s.slug ?? s.id),
10933
11594
  path: s.sourcePath,
10934
11595
  title: s.title,
@@ -10953,7 +11614,10 @@ function createDocShareListRouter() {
10953
11614
  return app;
10954
11615
  }
10955
11616
  function createDocShareWriteRouter(agent = {}) {
10956
- const app = new Hono15();
11617
+ const app = new Hono16();
11618
+ app.use("/:id/docs/share", requireDocsAccess);
11619
+ app.use("/:id/docs/share/*", requireDocsAccess);
11620
+ app.use("/:id/docs/shares", requireDocsAccess);
10957
11621
  app.get("/:id/docs/share", async (c) => {
10958
11622
  const resolved2 = resolveChannel2(c);
10959
11623
  if ("error" in resolved2) return resolved2.error;
@@ -11047,10 +11711,11 @@ function createDocShareWriteRouter(agent = {}) {
11047
11711
  if ("invalid" in slug)
11048
11712
  return c.json({ error: slugErrorMessage(slug.invalid), field: "slug" }, 400);
11049
11713
  const features = readFeatures(body);
11050
- if (!("slug" in slug) && features.audio === void 0 && features.ask === void 0 && features.social === void 0 && features.branding === void 0 && features.translation === void 0 && features.translationLangs === void 0) {
11714
+ const tags = normalizeTags(body["tags"]);
11715
+ if (!("slug" in slug) && tags === null && features.audio === void 0 && features.ask === void 0 && features.social === void 0 && features.branding === void 0 && features.translation === void 0 && features.translationLangs === void 0) {
11051
11716
  return c.json(
11052
11717
  {
11053
- error: "slug, audio, ask, social, branding, translation or translationLangs is required"
11718
+ error: "slug, tags, audio, ask, social, branding, translation or translationLangs is required"
11054
11719
  },
11055
11720
  400
11056
11721
  );
@@ -11072,7 +11737,13 @@ function createDocShareWriteRouter(agent = {}) {
11072
11737
  throw err;
11073
11738
  }
11074
11739
  }
11075
- const updated = await updateShareFeatures(renamed, features);
11740
+ let tagged = renamed;
11741
+ if (tags !== null) {
11742
+ const next = await setDocShareTags(renamed.id, tags);
11743
+ if (!next) return c.json({ error: "not shared" }, 404);
11744
+ tagged = next;
11745
+ }
11746
+ const updated = await updateShareFeatures(tagged, features);
11076
11747
  if (!updated) return c.json({ error: "not shared" }, 404);
11077
11748
  return c.json(shareJson(c, updated));
11078
11749
  });
@@ -11100,7 +11771,7 @@ function createDocShareWriteRouter(agent = {}) {
11100
11771
  }
11101
11772
 
11102
11773
  // src/dashboard/search-routes.ts
11103
- import { Hono as Hono16 } from "hono";
11774
+ import { Hono as Hono17 } from "hono";
11104
11775
  function matchScore(q, fields) {
11105
11776
  const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
11106
11777
  if (terms.length === 0) return 0;
@@ -11113,6 +11784,20 @@ function matchScore(q, fields) {
11113
11784
  }
11114
11785
  return score;
11115
11786
  }
11787
+ var SNIPPET_LENGTH = 180;
11788
+ function repeatsTitle(title, snippet) {
11789
+ const norm = (s) => s.replace(/[*_`]/g, "").replace(/…$/, "").trim().toLowerCase();
11790
+ const t = norm(title);
11791
+ return t.length > 0 && norm(snippet).slice(0, t.length + 8).includes(t);
11792
+ }
11793
+ function snippetAround(text, q) {
11794
+ const lower = text.toLowerCase();
11795
+ const positions = q.toLowerCase().split(/\s+/).filter(Boolean).map((term) => lower.indexOf(term)).filter((i) => i >= 0);
11796
+ const hit = positions.length ? Math.min(...positions) : 0;
11797
+ const start = hit > 40 ? hit - 40 : 0;
11798
+ const end = Math.min(text.length, start + SNIPPET_LENGTH);
11799
+ return `${start > 0 ? "\u2026" : ""}${text.slice(start, end).trim()}${end < text.length ? "\u2026" : ""}`;
11800
+ }
11116
11801
  async function searchThreads(q) {
11117
11802
  const supabase = getSupabaseClient();
11118
11803
  const { data, error: error2 } = await supabase.rpc("search_threads", { q, match_limit: 20 });
@@ -11120,19 +11805,34 @@ async function searchThreads(q) {
11120
11805
  console.error("search_threads RPC failed:", error2.message);
11121
11806
  return [];
11122
11807
  }
11123
- const ownChannelIds2 = new Set(
11808
+ const channelById = new Map(
11124
11809
  getProfileChannelRegistry().flatMap(
11125
- (ch) => ch.externalChannelId ? [ch.id, ch.externalChannelId] : [ch.id]
11810
+ (ch) => ch.externalChannelId ? [
11811
+ [ch.id, ch],
11812
+ [ch.externalChannelId, ch]
11813
+ ] : [[ch.id, ch]]
11126
11814
  )
11127
11815
  );
11128
- return (data ?? []).filter((t) => ownChannelIds2.has(t.channel_id)).map((t) => ({
11129
- type: "thread",
11130
- id: t.id,
11131
- title: t.title ?? t.snippet ?? "(untitled thread)",
11132
- snippet: t.title ? t.snippet ?? void 0 : void 0,
11133
- url: `/chat/${encodeURIComponent(t.channel_id)}/${encodeURIComponent(t.id)}`,
11134
- score: t.score
11135
- }));
11816
+ return (data ?? []).flatMap((t) => {
11817
+ const channel = channelById.get(t.channel_id);
11818
+ if (!channel) return [];
11819
+ return [
11820
+ {
11821
+ type: "thread",
11822
+ id: t.id,
11823
+ title: t.title ?? t.auto_title ?? "",
11824
+ snippet: t.snippet && !repeatsTitle(t.title ?? t.auto_title ?? "", t.snippet) ? snippetAround(t.snippet, q) : void 0,
11825
+ url: `/chat/${encodeURIComponent(t.channel_id)}/${encodeURIComponent(t.id)}`,
11826
+ score: t.score,
11827
+ author: t.author,
11828
+ createdAt: t.created_at,
11829
+ channelId: channel.id,
11830
+ channelName: channel.name,
11831
+ boardStatus: t.board_status,
11832
+ replyCount: t.reply_count
11833
+ }
11834
+ ];
11835
+ });
11136
11836
  }
11137
11837
  function searchChannels(q) {
11138
11838
  const channels = getProfileChannelRegistry();
@@ -11219,7 +11919,7 @@ function searchAutomations(q) {
11219
11919
  }
11220
11920
  var RESULT_LIMIT = 30;
11221
11921
  function createSearchRouter() {
11222
- const app = new Hono16();
11922
+ const app = new Hono17();
11223
11923
  app.get("/", async (c) => {
11224
11924
  const q = c.req.query("q")?.trim();
11225
11925
  if (!q) return c.json({ results: [] });
@@ -11236,7 +11936,7 @@ function createSearchRouter() {
11236
11936
 
11237
11937
  // src/dashboard/uploads-routes.ts
11238
11938
  import { randomUUID } from "crypto";
11239
- import { Hono as Hono17 } from "hono";
11939
+ import { Hono as Hono18 } from "hono";
11240
11940
  import { PutObjectCommand as PutObjectCommand3, GetObjectCommand as GetObjectCommand5 } from "@aws-sdk/client-s3";
11241
11941
  import { getSignedUrl as getSignedUrl4 } from "@aws-sdk/s3-request-presigner";
11242
11942
  var MAX_FILE_BYTES = 10 * 1024 * 1024;
@@ -11251,7 +11951,7 @@ function encodeKeyForUrl(key) {
11251
11951
  return key.split("/").map(encodeURIComponent).join("/");
11252
11952
  }
11253
11953
  function createUploadsRouter() {
11254
- const app = new Hono17();
11954
+ const app = new Hono18();
11255
11955
  app.post("/uploads", async (c) => {
11256
11956
  const body = await c.req.parseBody().catch(() => null);
11257
11957
  const file = body?.["file"];
@@ -11295,9 +11995,55 @@ function createUploadsRouter() {
11295
11995
  return app;
11296
11996
  }
11297
11997
 
11998
+ // src/dashboard/transcribe-routes.ts
11999
+ import { Hono as Hono19 } from "hono";
12000
+ var GROQ_TRANSCRIBE_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
12001
+ var MODEL = "whisper-large-v3-turbo";
12002
+ var MAX_AUDIO_BYTES = 5 * 1024 * 1024;
12003
+ var MAX_PROMPT_CHARS = 400;
12004
+ var NO_SPEECH_THRESHOLD = 0.6;
12005
+ function createTranscribeRouter() {
12006
+ const app = new Hono19();
12007
+ app.post("/transcribe", async (c) => {
12008
+ if (!await isFeatureEnabled(c, "dictation"))
12009
+ return c.json({ error: "feature not enabled" }, 403);
12010
+ const apiKey = process.env["GROQ_API_KEY"];
12011
+ if (!apiKey) return c.json({ error: "transcription is not configured" }, 503);
12012
+ const body = await c.req.parseBody().catch(() => null);
12013
+ const file = body?.["file"];
12014
+ if (!(file instanceof File)) return c.json({ error: "missing file" }, 400);
12015
+ if (file.size > MAX_AUDIO_BYTES) return c.json({ error: "audio segment too large" }, 400);
12016
+ const form = new FormData();
12017
+ form.append("file", file, file.name || "segment.wav");
12018
+ form.append("model", MODEL);
12019
+ form.append("temperature", "0");
12020
+ form.append("response_format", "verbose_json");
12021
+ const prompt = body?.["prompt"];
12022
+ if (typeof prompt === "string" && prompt)
12023
+ form.append("prompt", prompt.slice(-MAX_PROMPT_CHARS));
12024
+ const language = body?.["language"];
12025
+ if (typeof language === "string" && /^[a-z]{2}$/.test(language)) {
12026
+ form.append("language", language);
12027
+ }
12028
+ const res = await fetch(GROQ_TRANSCRIBE_URL, {
12029
+ method: "POST",
12030
+ headers: { authorization: `Bearer ${apiKey}` },
12031
+ body: form
12032
+ });
12033
+ if (!res.ok) return c.json({ error: `transcription failed (${res.status})` }, 502);
12034
+ const data = await res.json();
12035
+ const segments = data.segments ?? [];
12036
+ if (segments.length > 0 && segments.every((s) => (s.no_speech_prob ?? 0) > NO_SPEECH_THRESHOLD)) {
12037
+ return c.json({ text: "" });
12038
+ }
12039
+ return c.json({ text: (data.text ?? "").trim() });
12040
+ });
12041
+ return app;
12042
+ }
12043
+
11298
12044
  // src/dashboard/avatars-routes.ts
11299
12045
  import { randomUUID as randomUUID2 } from "crypto";
11300
- import { Hono as Hono18 } from "hono";
12046
+ import { Hono as Hono20 } from "hono";
11301
12047
  import sharp4 from "sharp";
11302
12048
  import { PutObjectCommand as PutObjectCommand4, DeleteObjectCommand as DeleteObjectCommand4 } from "@aws-sdk/client-s3";
11303
12049
  var AVATAR_SIZE = 192;
@@ -11340,7 +12086,7 @@ function avatarKeyPrefix() {
11340
12086
  return `${KEY_PREFIX3}/${getProfile()}/`;
11341
12087
  }
11342
12088
  function createAvatarsRouter() {
11343
- const app = new Hono18();
12089
+ const app = new Hono20();
11344
12090
  app.get("/avatars/slack", async (c) => {
11345
12091
  const ctx = await resolveAuthContext(c);
11346
12092
  if (!ctx) return c.json({ error: "not signed in" }, 401);
@@ -11378,7 +12124,7 @@ function createAvatarsRouter() {
11378
12124
  }
11379
12125
 
11380
12126
  // src/dashboard/members-routes.ts
11381
- import { Hono as Hono19 } from "hono";
12127
+ import { Hono as Hono21 } from "hono";
11382
12128
  function inviteLinkFor(c, token) {
11383
12129
  const url = new URL("/auth/invite", callbackUrl(c));
11384
12130
  url.searchParams.set("token", token);
@@ -11386,7 +12132,7 @@ function inviteLinkFor(c, token) {
11386
12132
  }
11387
12133
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11388
12134
  function createMembersRouter() {
11389
- const members = new Hono19();
12135
+ const members = new Hono21();
11390
12136
  members.get("/", async (c) => {
11391
12137
  const ctx = await resolveAuthContext(c);
11392
12138
  if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
@@ -11415,6 +12161,33 @@ function createMembersRouter() {
11415
12161
  );
11416
12162
  return c.json({ members: members2 });
11417
12163
  });
12164
+ members.get("/directory", async (c) => {
12165
+ const ctx = await resolveAuthContext(c);
12166
+ if (!ctx) return c.json({ error: "unauthorized" }, 401);
12167
+ const list = (await listMemberships(ctx.workspaceId)).filter((m) => m.status === "active");
12168
+ const directory = await Promise.all(
12169
+ list.map(async (m) => {
12170
+ const { data } = await getSupabaseClient().auth.admin.getUserById(m.userId);
12171
+ const metadata = data?.user?.user_metadata;
12172
+ const avatarUrl = metadata?.avatarKey ? await signAvatarUrl(metadata.avatarKey) : null;
12173
+ return { userId: m.userId, handle: m.handle, role: m.role, avatarUrl };
12174
+ })
12175
+ );
12176
+ return c.json({ members: directory });
12177
+ });
12178
+ members.post("/:userId/dm", async (c) => {
12179
+ const ctx = await resolveAuthContext(c);
12180
+ if (!ctx) return c.json({ error: "unauthorized" }, 401);
12181
+ const peerId = c.req.param("userId");
12182
+ if (peerId === ctx.userId) return c.json({ error: "cannot message yourself" }, 400);
12183
+ const peer = await getMembershipByUserId(ctx.workspaceId, peerId);
12184
+ if (!peer || peer.status !== "active") return c.json({ error: "member not found" }, 404);
12185
+ const channel = await ensureMemberDmChannel(
12186
+ { userId: ctx.userId, email: ctx.email, handle: ctx.handle },
12187
+ { userId: peer.userId, email: peer.email, handle: peer.handle }
12188
+ );
12189
+ return c.json({ channelId: channel.id });
12190
+ });
11418
12191
  members.get("/check", async (c) => {
11419
12192
  const ctx = await resolveAuthContext(c);
11420
12193
  if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
@@ -11570,7 +12343,7 @@ function createMembersRouter() {
11570
12343
  }
11571
12344
 
11572
12345
  // src/dashboard/notifications-routes.ts
11573
- import { Hono as Hono20 } from "hono";
12346
+ import { Hono as Hono22 } from "hono";
11574
12347
 
11575
12348
  // src/store/notification-store.ts
11576
12349
  function rowToNotification(row) {
@@ -11638,7 +12411,7 @@ async function markAllRead(channelIds, userId) {
11638
12411
 
11639
12412
  // src/dashboard/notifications-routes.ts
11640
12413
  function createNotificationsRouter() {
11641
- const notifications = new Hono20();
12414
+ const notifications = new Hono22();
11642
12415
  notifications.get("/", async (c) => {
11643
12416
  const auth = c.get("auth");
11644
12417
  const channelIds = getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, auth)).map((ch) => ch.id);
@@ -11665,7 +12438,7 @@ function createNotificationsRouter() {
11665
12438
  return notifications;
11666
12439
  }
11667
12440
  function createThreadReadRouter() {
11668
- const threads = new Hono20();
12441
+ const threads = new Hono22();
11669
12442
  threads.post("/threads/:threadId/read", async (c) => {
11670
12443
  const { userId } = c.get("auth");
11671
12444
  const threadId = c.req.param("threadId");
@@ -11677,7 +12450,7 @@ function createThreadReadRouter() {
11677
12450
  }
11678
12451
 
11679
12452
  // src/dashboard/push-routes.ts
11680
- import { Hono as Hono21 } from "hono";
12453
+ import { Hono as Hono23 } from "hono";
11681
12454
 
11682
12455
  // src/push/send.ts
11683
12456
  import webpush from "web-push";
@@ -11780,7 +12553,7 @@ async function pushToUser(userId, payload) {
11780
12553
 
11781
12554
  // src/dashboard/push-routes.ts
11782
12555
  function createPushRouter() {
11783
- const push = new Hono21();
12556
+ const push = new Hono23();
11784
12557
  push.get("/key", (c) => c.json({ publicKey: getVapidPublicKey() }));
11785
12558
  push.post("/subscribe", async (c) => {
11786
12559
  const { userId } = c.get("auth");
@@ -11811,7 +12584,7 @@ function createPushRouter() {
11811
12584
  }
11812
12585
 
11813
12586
  // src/dashboard/mcp-oauth-routes.ts
11814
- import { Hono as Hono22 } from "hono";
12587
+ import { Hono as Hono24 } from "hono";
11815
12588
  var CONNECTIONS_PAGE = "/settings/connections";
11816
12589
  function callbackUri(c) {
11817
12590
  const proto = c.req.header("x-forwarded-proto") ?? "http";
@@ -11826,7 +12599,7 @@ function backToDashboard(c, to, params) {
11826
12599
  return c.redirect(`${target.pathname}${target.search}`);
11827
12600
  }
11828
12601
  function createMcpOAuthRouter() {
11829
- const app = new Hono22();
12602
+ const app = new Hono24();
11830
12603
  app.get("/oauth/:provider/start", async (c) => {
11831
12604
  const auth = c.get("auth");
11832
12605
  if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
@@ -11953,7 +12726,7 @@ function createMcpOAuthRouter() {
11953
12726
  }
11954
12727
 
11955
12728
  // src/dashboard/mcp-routes.ts
11956
- import { Hono as Hono23 } from "hono";
12729
+ import { Hono as Hono25 } from "hono";
11957
12730
 
11958
12731
  // src/mcp/transport.ts
11959
12732
  var KINDS = ["inherit", "npx", "local", "http", "sse"];
@@ -12083,7 +12856,7 @@ function bindingsByConnection() {
12083
12856
  return map;
12084
12857
  }
12085
12858
  function createMcpRouter() {
12086
- const app = new Hono23();
12859
+ const app = new Hono25();
12087
12860
  app.get("/providers", async (c) => {
12088
12861
  const denied = requireAdmin3(c.get("auth"));
12089
12862
  if (denied) return c.json({ error: denied }, 403);
@@ -12465,42 +13238,7 @@ async function buildThreadUsage(threadTs) {
12465
13238
  }
12466
13239
 
12467
13240
  // src/dashboard/channel-members-routes.ts
12468
- import { Hono as Hono24 } from "hono";
12469
-
12470
- // src/store/channel-members-store.ts
12471
- function rowToChannelMember(row) {
12472
- return {
12473
- id: row["id"],
12474
- channelId: row["channel_id"],
12475
- membershipId: row["membership_id"],
12476
- role: row["role"],
12477
- addedAt: row["added_at"]
12478
- };
12479
- }
12480
- async function listChannelMembers(channelId) {
12481
- const { data, error: error2 } = await getSupabaseClient().from("channel_members").select("*").eq("channel_id", channelId).order("added_at", { ascending: true });
12482
- if (error2) throw new Error(`listChannelMembers(${channelId}): ${error2.message}`);
12483
- return (data ?? []).map(rowToChannelMember);
12484
- }
12485
- async function upsertChannelMember(params) {
12486
- const { data, error: error2 } = await getSupabaseClient().from("channel_members").upsert(
12487
- { channel_id: params.channelId, membership_id: params.membershipId, role: params.role },
12488
- { onConflict: "channel_id,membership_id" }
12489
- ).select("*").single();
12490
- if (error2) {
12491
- throw new Error(
12492
- `upsertChannelMember(${params.channelId}, ${params.membershipId}): ${error2.message}`
12493
- );
12494
- }
12495
- return rowToChannelMember(data);
12496
- }
12497
- async function removeChannelMember(channelId, membershipId) {
12498
- const { data, error: error2 } = await getSupabaseClient().from("channel_members").delete().eq("channel_id", channelId).eq("membership_id", membershipId).select("id");
12499
- if (error2) throw new Error(`removeChannelMember(${channelId}, ${membershipId}): ${error2.message}`);
12500
- return data?.length ? true : false;
12501
- }
12502
-
12503
- // src/dashboard/channel-members-routes.ts
13241
+ import { Hono as Hono26 } from "hono";
12504
13242
  async function isChannelAdmin(ctx, channelId) {
12505
13243
  if (ctx.role === "admin") return true;
12506
13244
  const membership = await getMembershipByUserId(ctx.workspaceId, ctx.userId);
@@ -12509,7 +13247,7 @@ async function isChannelAdmin(ctx, channelId) {
12509
13247
  return members.some((m) => m.membershipId === membership.id && m.role === "channel_admin");
12510
13248
  }
12511
13249
  function createChannelMembersRouter() {
12512
- const router = new Hono24();
13250
+ const router = new Hono26();
12513
13251
  router.get("/:id/members", async (c) => {
12514
13252
  const ctx = await resolveAuthContext(c);
12515
13253
  if (!ctx) return c.json({ error: "workspace access required" }, 403);
@@ -12545,277 +13283,143 @@ function createChannelMembersRouter() {
12545
13283
  if (!removed) return c.json({ error: "no such channel member" }, 404);
12546
13284
  return c.body(null, 204);
12547
13285
  });
13286
+ router.get("/:id/docs-access/me", async (c) => {
13287
+ const ctx = await resolveAuthContext(c);
13288
+ if (!ctx) return c.json({ error: "workspace access required" }, 403);
13289
+ return c.json({ access: await resolveDocsAccess(ctx, c.req.param("id")) });
13290
+ });
13291
+ router.get("/:id/docs-access", async (c) => {
13292
+ const ctx = await resolveAuthContext(c);
13293
+ if (!ctx) return c.json({ error: "workspace access required" }, 403);
13294
+ const channelId = c.req.param("id");
13295
+ if (!await isChannelAdmin(ctx, channelId)) {
13296
+ return c.json({ error: "channel admin access required" }, 403);
13297
+ }
13298
+ return c.json({ grants: await listChannelDocsAccess(channelId) });
13299
+ });
13300
+ router.put("/:id/docs-access/:membershipId", async (c) => {
13301
+ const ctx = await resolveAuthContext(c);
13302
+ if (!ctx) return c.json({ error: "workspace access required" }, 403);
13303
+ const channelId = c.req.param("id");
13304
+ if (!await isChannelAdmin(ctx, channelId)) {
13305
+ return c.json({ error: "channel admin access required" }, 403);
13306
+ }
13307
+ const body = await c.req.json().catch(() => ({}));
13308
+ if (!isDocsGrant(body.access)) {
13309
+ return c.json({ error: "access must be 'read' or 'write'" }, 400);
13310
+ }
13311
+ const grant = await upsertChannelDocsAccess({
13312
+ channelId,
13313
+ membershipId: c.req.param("membershipId"),
13314
+ access: body.access
13315
+ });
13316
+ return c.json({ grant });
13317
+ });
13318
+ router.delete("/:id/docs-access/:membershipId", async (c) => {
13319
+ const ctx = await resolveAuthContext(c);
13320
+ if (!ctx) return c.json({ error: "workspace access required" }, 403);
13321
+ const channelId = c.req.param("id");
13322
+ if (!await isChannelAdmin(ctx, channelId)) {
13323
+ return c.json({ error: "channel admin access required" }, 403);
13324
+ }
13325
+ const removed = await removeChannelDocsAccess(channelId, c.req.param("membershipId"));
13326
+ if (!removed) return c.json({ error: "no such docs grant" }, 404);
13327
+ return c.body(null, 204);
13328
+ });
12548
13329
  return router;
12549
13330
  }
12550
13331
 
12551
13332
  // src/dashboard/onboarding-routes.ts
12552
- import { Hono as Hono25 } from "hono";
13333
+ import { Hono as Hono27 } from "hono";
12553
13334
 
12554
- // src/dashboard/onboarding-scan.ts
12555
- import { execFileSync as execFileSync2 } from "child_process";
12556
- import {
12557
- existsSync as existsSync22,
12558
- lstatSync as lstatSync2,
12559
- mkdirSync as mkdirSync6,
12560
- readdirSync as readdirSync4,
12561
- readFileSync as readFileSync11,
12562
- statSync as statSync8,
12563
- symlinkSync
12564
- } from "fs";
12565
- import { homedir as homedir7 } from "os";
12566
- import { basename as basename5, join as join16, resolve as resolve6 } from "path";
12567
- var MAX_JSONL_HEAD_BYTES = 64 * 1024;
12568
- var GIT_SCAN_ROOTS = ["Workspaces", "Projects", "code"];
12569
- function flyspaceRoot() {
12570
- return process.env["FLYSPACE_ROOT"] || join16(homedir7(), "Flyspace");
12571
- }
12572
- function onPath(cmd) {
12573
- try {
12574
- execFileSync2(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" });
12575
- return true;
12576
- } catch {
12577
- return false;
12578
- }
12579
- }
12580
- function claudeDir() {
12581
- return process.env["CLAUDE_CONFIG_DIR"] || join16(homedir7(), ".claude");
12582
- }
12583
- function codexDir() {
12584
- return process.env["CODEX_HOME"] || join16(homedir7(), ".codex");
12585
- }
12586
- function claudeLoggedIn() {
12587
- if (process.env["ANTHROPIC_API_KEY"]) return true;
12588
- if (existsSync22(join16(claudeDir(), ".credentials.json"))) return true;
12589
- try {
12590
- const raw = JSON.parse(readFileSync11(join16(homedir7(), ".claude.json"), "utf-8"));
12591
- if (raw.oauthAccount) return true;
12592
- } catch {
12593
- }
12594
- if (process.platform !== "darwin") return false;
12595
- try {
12596
- execFileSync2("security", ["find-generic-password", "-s", "Claude Code-credentials"], {
12597
- stdio: "ignore"
12598
- });
12599
- return true;
12600
- } catch {
12601
- return false;
12602
- }
13335
+ // src/dashboard/unique-channel-name.ts
13336
+ function uniqueChannelName(raw, taken) {
13337
+ const base = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
13338
+ let name = base;
13339
+ for (let i = 2; taken.has(name); i += 1) name = `${base}-${i}`;
13340
+ taken.add(name);
13341
+ return name;
12603
13342
  }
12604
- function codexLoggedIn() {
12605
- return !!process.env["OPENAI_API_KEY"] || existsSync22(join16(codexDir(), "auth.json"));
13343
+
13344
+ // src/dashboard/workspace-greeting.ts
13345
+ var GROQ_API_URL8 = "https://api.groq.com/openai/v1/chat/completions";
13346
+ var GREETING_MODEL = "openai/gpt-oss-20b";
13347
+ var TIMEOUT_MS = 6e3;
13348
+ var MAX_LISTED_CHANNELS = 8;
13349
+ var SYSTEM_PROMPT4 = `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.
13350
+
13351
+ - Plain Markdown, 60 to 110 words, in English. No headings, no emoji, no em or en dashes, no sign-off.
13352
+ - Greet the owner by name and name the workspace.
13353
+ - Say that #general is public: everyone invited to the workspace can see it.
13354
+ - If project channels are listed, mention them as #name and say each one is linked to that project folder. Never invent a channel.
13355
+ - Mention the trial or the missing Host only if the facts say so.
13356
+ - Use only the facts given. Do not add buttons or a list of next steps: those are appended for you.`;
13357
+ function greetingButtons(input2) {
13358
+ const buttons = [];
13359
+ if (!input2.hostConnected) buttons.push("Connect a machine");
13360
+ if (input2.channels[0]) buttons.push(`Summarise #${input2.channels[0]} for me`);
13361
+ if (input2.memberCount <= 1) buttons.push("Invite my team");
13362
+ buttons.push("Set up a daily digest");
13363
+ return buttons.slice(0, 3).map((b) => b.replace(/[|\]]/g, ""));
13364
+ }
13365
+ function fallbackGreeting(input2) {
13366
+ const channels = input2.channels.slice(0, MAX_LISTED_CHANNELS).map((c) => `#${c}`);
13367
+ return [
13368
+ `Welcome to ${input2.workspaceName}, ${input2.ownerName}.`,
13369
+ "This is #general. It is public, so everyone you invite to the workspace can see it.",
13370
+ 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.` : "",
13371
+ "Mention the agent in any channel to get started."
13372
+ ].filter(Boolean).join("\n\n");
13373
+ }
13374
+ async function groqGreeting(input2, apiKey) {
13375
+ const facts = [
13376
+ `Owner: ${input2.ownerName}`,
13377
+ `Workspace: ${input2.workspaceName}`,
13378
+ `Plan: ${input2.trialing ? "14-day free trial, just started" : "not on a trial"}`,
13379
+ `Host: ${input2.hostConnected ? "a machine is connected, agents can run" : "no machine connected yet, agents cannot run until one is"}`,
13380
+ `Project channels: ${input2.channels.slice(0, MAX_LISTED_CHANNELS).join(", ") || "(none yet)"}`,
13381
+ `Members: ${input2.memberCount}`
13382
+ ].join("\n");
13383
+ const res = await fetch(GROQ_API_URL8, {
13384
+ method: "POST",
13385
+ signal: AbortSignal.timeout(TIMEOUT_MS),
13386
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
13387
+ body: JSON.stringify({
13388
+ model: GREETING_MODEL,
13389
+ temperature: 0.6,
13390
+ // Reasoning model: the cap covers reasoning tokens too, so keep it roomy.
13391
+ reasoning_effort: "low",
13392
+ max_tokens: 900,
13393
+ messages: [
13394
+ { role: "system", content: SYSTEM_PROMPT4 },
13395
+ { role: "user", content: facts }
13396
+ ]
13397
+ })
13398
+ });
13399
+ if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
13400
+ const data = await res.json();
13401
+ const text = data.choices?.[0]?.message?.content?.replace(/\[BUTTONS:[^\]]*\]/g, "").replace(/\s*[–—]\s*/g, ", ").trim();
13402
+ return text || null;
12606
13403
  }
12607
- function jsonlFiles(dir, depth) {
12608
- const out = [];
12609
- let entries2;
12610
- try {
12611
- entries2 = readdirSync4(dir, { withFileTypes: true });
12612
- } catch {
12613
- return out;
12614
- }
12615
- for (const e of entries2) {
12616
- const full = join16(dir, e.name);
12617
- if (e.isDirectory()) {
12618
- if (depth > 0) out.push(...jsonlFiles(full, depth - 1));
12619
- } else if (e.name.endsWith(".jsonl")) {
12620
- try {
12621
- out.push({ file: full, mtime: statSync8(full).mtimeMs });
12622
- } catch {
12623
- }
13404
+ async function buildGreeting(input2) {
13405
+ let text = null;
13406
+ const apiKey = process.env["GROQ_API_KEY"];
13407
+ if (apiKey) {
13408
+ try {
13409
+ text = await groqGreeting(input2, apiKey);
13410
+ } catch (err) {
13411
+ console.error("Workspace greeting failed, using the fixed template:", err);
12624
13412
  }
12625
13413
  }
12626
- return out;
12627
- }
12628
- function cwdFromTranscript(file) {
12629
- let head;
12630
- try {
12631
- head = readFileSync11(file, { encoding: "utf-8", flag: "r" }).slice(0, MAX_JSONL_HEAD_BYTES);
12632
- } catch {
12633
- return null;
12634
- }
12635
- for (const line of head.split("\n")) {
12636
- if (!line.includes('"cwd"')) continue;
12637
- try {
12638
- const obj = JSON.parse(line);
12639
- const cwd = obj.cwd ?? obj.payload?.cwd;
12640
- if (typeof cwd === "string" && cwd) return cwd;
12641
- } catch {
12642
- }
12643
- }
12644
- return null;
12645
- }
12646
- function codexTrustedPaths() {
12647
- try {
12648
- const toml = readFileSync11(join16(codexDir(), "config.toml"), "utf-8");
12649
- return [...toml.matchAll(/^\[projects\.(?:"([^"]+)"|'([^']+)')\]/gm)].map((m) => m[1] ?? m[2]);
12650
- } catch {
12651
- return [];
12652
- }
12653
- }
12654
- function isExcluded(path) {
12655
- const home = homedir7();
12656
- if (path === home || path === "/") return true;
12657
- if (path === "/tmp" || path.startsWith("/tmp/")) return true;
12658
- if (path === "/private/tmp" || path.startsWith("/private/tmp/")) return true;
12659
- const root = flyspaceRoot();
12660
- if (path === root || path.startsWith(`${root}/`)) return true;
12661
- try {
12662
- return !statSync8(path).isDirectory();
12663
- } catch {
12664
- return true;
12665
- }
12666
- }
12667
- function gitRepos() {
12668
- const home = homedir7();
12669
- const found = [];
12670
- for (const rootName of GIT_SCAN_ROOTS) {
12671
- const root = join16(home, rootName);
12672
- let entries2;
12673
- try {
12674
- entries2 = readdirSync4(root, { withFileTypes: true });
12675
- } catch {
12676
- continue;
12677
- }
12678
- for (const e of entries2) {
12679
- if (e.isDirectory() && existsSync22(join16(root, e.name, ".git"))) found.push(join16(root, e.name));
12680
- }
12681
- }
12682
- return found;
12683
- }
12684
- function detectRunners(history = { claude: false, codex: false }) {
12685
- return [
12686
- {
12687
- id: "claude",
12688
- installed: onPath("claude"),
12689
- loggedIn: claudeLoggedIn(),
12690
- hasHistory: history.claude
12691
- },
12692
- {
12693
- id: "codex",
12694
- installed: onPath("codex"),
12695
- loggedIn: codexLoggedIn(),
12696
- hasHistory: history.codex
12697
- }
12698
- ];
12699
- }
12700
- function scanOnboarding() {
12701
- const byPath = /* @__PURE__ */ new Map();
12702
- const history = { claude: false, codex: false };
12703
- const add = (rawPath, source, mtime, sessions, trusted = false) => {
12704
- const path = resolve6(rawPath);
12705
- if (isExcluded(path)) return;
12706
- const existing = byPath.get(path);
12707
- if (existing) {
12708
- if (!existing.sources.includes(source)) existing.sources.push(source);
12709
- existing.lastUsed = Math.max(existing.lastUsed, mtime);
12710
- existing.sessions += sessions;
12711
- existing.trusted ||= trusted;
12712
- } else {
12713
- byPath.set(path, {
12714
- path,
12715
- name: basename5(path),
12716
- sources: [source],
12717
- lastUsed: mtime,
12718
- sessions,
12719
- trusted
12720
- });
12721
- }
12722
- };
12723
- const claudeProjects = join16(claudeDir(), "projects");
12724
- try {
12725
- for (const e of readdirSync4(claudeProjects, { withFileTypes: true })) {
12726
- if (!e.isDirectory()) continue;
12727
- const files = jsonlFiles(join16(claudeProjects, e.name), 0);
12728
- if (!files.length) continue;
12729
- history.claude = true;
12730
- files.sort((a, b) => b.mtime - a.mtime);
12731
- let cwd = null;
12732
- for (const f of files.slice(0, 3)) {
12733
- cwd = cwdFromTranscript(f.file);
12734
- if (cwd) break;
12735
- }
12736
- if (cwd) add(cwd, "claude", files[0].mtime, files.length);
12737
- }
12738
- } catch {
12739
- }
12740
- const codexSessions = jsonlFiles(join16(codexDir(), "sessions"), 4);
12741
- if (codexSessions.length) history.codex = true;
12742
- const perCwd = /* @__PURE__ */ new Map();
12743
- for (const f of codexSessions) {
12744
- const cwd = cwdFromTranscript(f.file);
12745
- if (!cwd) continue;
12746
- const cur = perCwd.get(cwd) ?? { mtime: 0, count: 0 };
12747
- cur.mtime = Math.max(cur.mtime, f.mtime);
12748
- cur.count += 1;
12749
- perCwd.set(cwd, cur);
12750
- }
12751
- for (const [cwd, v] of perCwd) add(cwd, "codex", v.mtime, v.count);
12752
- for (const p of codexTrustedPaths()) add(p, "codex", 0, 0, true);
12753
- let projects = [...byPath.values()];
12754
- let fromGitScan = false;
12755
- if (!projects.length) {
12756
- fromGitScan = true;
12757
- for (const repo of gitRepos()) {
12758
- let mtime = 0;
12759
- try {
12760
- mtime = statSync8(repo).mtimeMs;
12761
- } catch {
12762
- }
12763
- add(repo, "git", mtime, 0);
12764
- }
12765
- projects = [...byPath.values()];
12766
- }
12767
- projects.sort(
12768
- (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
12769
- );
12770
- return {
12771
- root: flyspaceRoot(),
12772
- runners: detectRunners(history),
12773
- projects,
12774
- fromGitScan
12775
- };
12776
- }
12777
- function uniqueChannelName(raw, taken) {
12778
- const base = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
12779
- let name = base;
12780
- for (let i = 2; taken.has(name); i += 1) name = `${base}-${i}`;
12781
- taken.add(name);
12782
- return name;
12783
- }
12784
- function makeLink(target, linkPath) {
12785
- symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
12786
- }
12787
- function linkProjectFolders(slug, items) {
12788
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error(`invalid workspace slug: ${slug}`);
12789
- const dir = join16(flyspaceRoot(), slug);
12790
- mkdirSync6(dir, { recursive: true });
12791
- const results = [];
12792
- for (const item of items) {
12793
- if (!/^[a-z0-9][a-z0-9-]*$/.test(item.name)) throw new Error(`invalid channel name: ${item.name}`);
12794
- const dest = join16(dir, item.name);
12795
- let exists = true;
12796
- try {
12797
- lstatSync2(dest);
12798
- } catch {
12799
- exists = false;
12800
- }
12801
- if (!exists) {
12802
- if (item.path) {
12803
- const target = resolve6(item.path);
12804
- if (!statSync8(target).isDirectory()) throw new Error(`not a directory: ${target}`);
12805
- makeLink(target, dest);
12806
- } else {
12807
- mkdirSync6(dest, { recursive: true });
12808
- }
12809
- }
12810
- results.push({ name: item.name, cwd: `${slug}/${item.name}` });
12811
- }
12812
- return results;
13414
+ return `${text ?? fallbackGreeting(input2)}
13415
+
13416
+ [BUTTONS: ${greetingButtons(input2).join(" | ")}]`;
12813
13417
  }
12814
13418
 
12815
13419
  // src/dashboard/onboarding-routes.ts
12816
13420
  var MAX_FOLDERS = 20;
12817
13421
  function createOnboardingRouter(agent) {
12818
- const app = new Hono25();
13422
+ const app = new Hono27();
12819
13423
  app.get("/scan", async (c) => {
12820
13424
  const ctx = await resolveAuthContext(c);
12821
13425
  if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
@@ -12848,9 +13452,54 @@ function createOnboardingRouter(agent) {
12848
13452
  await agent.saveLocalDashboardSettings?.({ providers: [{ runner }] });
12849
13453
  }
12850
13454
  const created = [];
12851
- for (const l of linked) created.push(await createChannel({ name: l.name, cwd: l.cwd }));
13455
+ for (const l of linked) {
13456
+ created.push(
13457
+ l.name === "general" ? await ensureGeneralChannel() : await createChannel({ name: l.name, cwd: l.cwd })
13458
+ );
13459
+ }
13460
+ await markWorkspaceProjectsSetup(ctx.workspaceId);
12852
13461
  return c.json({ channels: created.map((ch) => ({ id: ch.id, name: ch.name })) }, 201);
12853
13462
  });
13463
+ app.post("/greeting", async (c) => {
13464
+ const ctx = await resolveAuthContext(c);
13465
+ if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13466
+ const supabase = getSupabaseClient();
13467
+ const general = await ensureGeneralChannel();
13468
+ const existing = () => supabase.from("threads").select("id").eq("client_key", ctx.workspaceId).maybeSingle();
13469
+ const found = await existing();
13470
+ if (found.data) return c.json({ channelId: general.id, threadId: found.data.id });
13471
+ const [branding, billing, members] = await Promise.all([
13472
+ getWorkspaceBranding(getProfile()).catch(() => null),
13473
+ getWorkspaceBilling(ctx.workspaceId).catch(() => null),
13474
+ listMemberships(ctx.workspaceId).catch(() => [])
13475
+ ]);
13476
+ const workspaceName2 = branding?.displayName || getProfile();
13477
+ const body = await buildGreeting({
13478
+ ownerName: ctx.name?.trim() || ctx.handle,
13479
+ workspaceName: workspaceName2,
13480
+ trialing: billing?.planStatus === "trialing",
13481
+ hostConnected: !!ctx.agentConnectedAt,
13482
+ channels: getProfileChannelRegistry().filter((ch) => !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt).map((ch) => ch.name),
13483
+ memberCount: members.length
13484
+ });
13485
+ const { data: thread, error: error2 } = await supabase.from("threads").insert({
13486
+ channel_id: general.id,
13487
+ title: `Welcome to ${workspaceName2}`,
13488
+ author: AGENT_AUTHOR,
13489
+ client_key: ctx.workspaceId
13490
+ }).select("id").single();
13491
+ if (error2) {
13492
+ const raced = error2.code === "23505" ? (await existing()).data : null;
13493
+ if (!raced) return c.json({ error: error2.message }, 500);
13494
+ return c.json({ channelId: general.id, threadId: raced.id });
13495
+ }
13496
+ const reply = await supabase.from("thread_replies").insert({ thread_id: thread.id, author: AGENT_AUTHOR, body });
13497
+ if (reply.error) {
13498
+ await supabase.from("threads").delete().eq("id", thread.id);
13499
+ return c.json({ error: reply.error.message }, 500);
13500
+ }
13501
+ return c.json({ channelId: general.id, threadId: thread.id }, 201);
13502
+ });
12854
13503
  return app;
12855
13504
  }
12856
13505
 
@@ -12980,7 +13629,8 @@ function registerDataRoutes(api, agent = {}) {
12980
13629
  externalChannelId: ch.externalChannelId,
12981
13630
  categoryId: ch.categoryId,
12982
13631
  position: ch.position,
12983
- name: ch.name,
13632
+ // A DM is stored as "<a>, <b>"; each side sees the other's handle.
13633
+ name: ch.dmKey ? ch.name.split(", ").filter((h) => h !== auth.handle).join(", ") || ch.name : ch.name,
12984
13634
  configured: true,
12985
13635
  cwd: ch.cwd,
12986
13636
  // Surfaces a rotted working directory in the sidebar instead of
@@ -12993,18 +13643,20 @@ function registerDataRoutes(api, agent = {}) {
12993
13643
  visibility: ch.visibility,
12994
13644
  memberEmails: ch.memberEmails,
12995
13645
  agentName: ch.agentName,
13646
+ dmKey: ch.dmKey,
12996
13647
  archived: !!ch.archivedAt
12997
13648
  }));
12998
13649
  return c.json({
12999
13650
  channels: list,
13000
13651
  defaultModel: defaultModel(),
13001
13652
  defaultEffort: defaultEffort() ?? null,
13002
- defaultCwd: defaultClaudeCwd()
13653
+ defaultCwd: getProfile()
13003
13654
  });
13004
13655
  });
13005
13656
  api.get("/channels/:id/usage", async (c) => {
13006
13657
  const ctx = await resolveAuthContext(c);
13007
13658
  if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13659
+ if (!await isFeatureEnabled(c, "ai-cost")) return c.json({ error: "feature not enabled" }, 403);
13008
13660
  const raw = Number.parseInt(c.req.query("days") ?? "7", 10);
13009
13661
  const days = Number.isFinite(raw) && raw >= 0 ? raw : 7;
13010
13662
  return c.json(await buildChannelUsageReport(c.req.param("id"), days, channelLabel));
@@ -13023,7 +13675,7 @@ function registerDataRoutes(api, agent = {}) {
13023
13675
  runs.set(name, { lastRun: run3.lastRunAt.toISOString(), lastStatus: run3.lastStatus });
13024
13676
  }
13025
13677
  } catch {
13026
- if (existsSync23(paths.DB_FILE)) {
13678
+ if (existsSync22(paths.DB_FILE)) {
13027
13679
  const runStore = new AutomationRunStore(paths.DB_FILE);
13028
13680
  try {
13029
13681
  for (const run3 of runStore.listLastRuns()) {
@@ -13085,7 +13737,7 @@ function registerDataRoutes(api, agent = {}) {
13085
13737
  const runs = (await recentRunsForWorkspace(getProfile(), 200)).map(toResponse5);
13086
13738
  return c.json({ runs });
13087
13739
  } catch {
13088
- if (!existsSync23(paths.DB_FILE)) return c.json({ runs: [] });
13740
+ if (!existsSync22(paths.DB_FILE)) return c.json({ runs: [] });
13089
13741
  const runStore = new AutomationRunStore(paths.DB_FILE);
13090
13742
  try {
13091
13743
  const runs = runStore.listRuns(200).map(toResponse5);
@@ -13105,13 +13757,19 @@ function registerDataRoutes(api, agent = {}) {
13105
13757
  api.get("/usage/threads/:threadTs", async (c) => {
13106
13758
  const ctx = await resolveAuthContext(c);
13107
13759
  if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
13760
+ if (!await isFeatureEnabled(c, "ai-cost")) return c.json({ error: "feature not enabled" }, 403);
13108
13761
  return c.json(await buildThreadUsage(c.req.param("threadTs")));
13109
13762
  });
13110
13763
  api.route("/automations", createAutomationWriteRouter());
13111
13764
  api.route("/billing", createBillingRouter());
13112
13765
  api.route("/chat", createChatRouter(agent));
13113
13766
  api.route("/search", createSearchRouter());
13767
+ api.use("/tts/*", async (c, next) => {
13768
+ if (!await isFeatureEnabled(c, "read-aloud")) return c.json({ error: "feature not enabled" }, 403);
13769
+ await next();
13770
+ });
13114
13771
  api.route("/tts", createTtsRouter());
13772
+ api.route("/feature-flags", createFeatureFlagsRouter());
13115
13773
  api.route("/members", createMembersRouter());
13116
13774
  api.route("/notifications", createNotificationsRouter());
13117
13775
  api.route("/push", createPushRouter());
@@ -13158,6 +13816,7 @@ function registerDataRoutes(api, agent = {}) {
13158
13816
  return c.json({ paused: body.paused, cancelled });
13159
13817
  });
13160
13818
  api.route("/", createUploadsRouter());
13819
+ api.route("/", createTranscribeRouter());
13161
13820
  api.route("/", createAvatarsRouter());
13162
13821
  api.get("/profiles", async (c) => {
13163
13822
  const active = getProfile();
@@ -13200,7 +13859,7 @@ function registerDataRoutes(api, agent = {}) {
13200
13859
  } catch {
13201
13860
  latest = /* @__PURE__ */ new Map();
13202
13861
  totals = /* @__PURE__ */ new Map();
13203
- if (existsSync23(paths.DB_FILE)) {
13862
+ if (existsSync22(paths.DB_FILE)) {
13204
13863
  const usageStore = new UsageStore(paths.DB_FILE);
13205
13864
  try {
13206
13865
  latest = usageStore.latestByThread(threadTsList);
@@ -13210,65 +13869,321 @@ function registerDataRoutes(api, agent = {}) {
13210
13869
  }
13211
13870
  }
13212
13871
  }
13213
- const threadLinks = await resolveThreadLinks(raw);
13214
- const sessions = raw.map((s) => {
13215
- const run3 = latest.get(s.threadTs);
13216
- const turns = run3?.numTurns ?? 1;
13217
- const total = run3 ? run3.inputTokens + run3.cacheReadTokens + run3.cacheCreationTokens : void 0;
13218
- const link = s.channelId.startsWith("chat:") ? threadLinks.chat.get(s.threadTs) : threadLinks.external.get(`${s.channelId}:${s.threadTs}`);
13219
- return {
13220
- ...s,
13221
- channelLabel: channelLabel(s.channelId),
13222
- model: run3?.model,
13223
- numTurns: run3?.numTurns,
13224
- contextTokens: total !== void 0 ? Math.round(total / (turns > 0 ? turns : 1)) : void 0,
13225
- rolledOver: run3?.label === "rollover",
13226
- threadTitle: link?.title ?? void 0,
13227
- chatChannelId: link?.chatChannelId,
13228
- chatThreadId: link?.chatThreadId,
13229
- costUsd: totals.get(s.threadTs)?.costUsd
13230
- };
13231
- });
13232
- return c.json({ sessions });
13233
- });
13234
- const LOGO_SIZE = 256;
13235
- const MAX_LOGO_BYTES = 2 * 1024 * 1024;
13236
- const LOGO_MIME_TYPES = ["image/png", "image/jpeg"];
13237
- api.post("/settings/logo", async (c) => {
13238
- const body = await c.req.parseBody().catch(() => null);
13239
- const file = body?.["file"];
13240
- if (!(file instanceof File)) return c.json({ error: "missing file" }, 400);
13241
- if (!LOGO_MIME_TYPES.includes(file.type)) {
13242
- return c.json({ error: "logo must be a PNG or JPEG image" }, 400);
13872
+ const threadLinks = await resolveThreadLinks(raw);
13873
+ const sessions = raw.map((s) => {
13874
+ const run3 = latest.get(s.threadTs);
13875
+ const turns = run3?.numTurns ?? 1;
13876
+ const total = run3 ? run3.inputTokens + run3.cacheReadTokens + run3.cacheCreationTokens : void 0;
13877
+ const link = s.channelId.startsWith("chat:") ? threadLinks.chat.get(s.threadTs) : threadLinks.external.get(`${s.channelId}:${s.threadTs}`);
13878
+ return {
13879
+ ...s,
13880
+ channelLabel: channelLabel(s.channelId),
13881
+ model: run3?.model,
13882
+ numTurns: run3?.numTurns,
13883
+ contextTokens: total !== void 0 ? Math.round(total / (turns > 0 ? turns : 1)) : void 0,
13884
+ rolledOver: run3?.label === "rollover",
13885
+ threadTitle: link?.title ?? void 0,
13886
+ chatChannelId: link?.chatChannelId,
13887
+ chatThreadId: link?.chatThreadId,
13888
+ costUsd: totals.get(s.threadTs)?.costUsd
13889
+ };
13890
+ });
13891
+ return c.json({ sessions });
13892
+ });
13893
+ const LOGO_SIZE = 256;
13894
+ const MAX_LOGO_BYTES = 2 * 1024 * 1024;
13895
+ const LOGO_MIME_TYPES = ["image/png", "image/jpeg"];
13896
+ api.post("/settings/logo", async (c) => {
13897
+ const body = await c.req.parseBody().catch(() => null);
13898
+ const file = body?.["file"];
13899
+ if (!(file instanceof File)) return c.json({ error: "missing file" }, 400);
13900
+ if (!LOGO_MIME_TYPES.includes(file.type)) {
13901
+ return c.json({ error: "logo must be a PNG or JPEG image" }, 400);
13902
+ }
13903
+ if (file.size > MAX_LOGO_BYTES) {
13904
+ return c.json({ error: "logo must be 2MB or smaller" }, 400);
13905
+ }
13906
+ const original = Buffer.from(await file.arrayBuffer());
13907
+ const resized = await sharp5(original).resize({ width: LOGO_SIZE, height: LOGO_SIZE, fit: "cover" }).png().toBuffer();
13908
+ const logo = `data:image/png;base64,${resized.toString("base64")}`;
13909
+ return c.json(await saveWorkspaceBranding({ logo }));
13910
+ });
13911
+ api.delete("/settings/logo", async (c) => c.json(await saveWorkspaceBranding({ logo: null })));
13912
+ }
13913
+
13914
+ // src/dashboard/device-auth-routes.ts
13915
+ import { verify } from "crypto";
13916
+ import { getConnInfo } from "@hono/node-server/conninfo";
13917
+ import { Hono as Hono28 } from "hono";
13918
+
13919
+ // src/dashboard/agent-bridge.ts
13920
+ import { randomBytes as randomBytes8, randomUUID as randomUUID4, sign as signRaw, timingSafeEqual as timingSafeEqual2, verify as verifyRaw } from "crypto";
13921
+
13922
+ // src/dashboard/onboarding-scan.ts
13923
+ import { execFileSync as execFileSync2 } from "child_process";
13924
+ import {
13925
+ existsSync as existsSync23,
13926
+ lstatSync as lstatSync2,
13927
+ mkdirSync as mkdirSync6,
13928
+ readdirSync as readdirSync4,
13929
+ readFileSync as readFileSync11,
13930
+ statSync as statSync8,
13931
+ symlinkSync
13932
+ } from "fs";
13933
+ import { homedir as homedir8 } from "os";
13934
+ import { basename as basename5, join as join17, resolve as resolve6 } from "path";
13935
+ var MAX_JSONL_HEAD_BYTES = 64 * 1024;
13936
+ var GIT_SCAN_ROOTS = ["Workspaces", "Projects", "code"];
13937
+ function flyspaceRoot() {
13938
+ return process.env["FLYSPACE_ROOT"] || join17(homedir8(), "Flyspace");
13939
+ }
13940
+ function onPath(cmd) {
13941
+ try {
13942
+ execFileSync2(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" });
13943
+ return true;
13944
+ } catch {
13945
+ return false;
13946
+ }
13947
+ }
13948
+ function claudeDir() {
13949
+ return process.env["CLAUDE_CONFIG_DIR"] || join17(homedir8(), ".claude");
13950
+ }
13951
+ function codexDir() {
13952
+ return process.env["CODEX_HOME"] || join17(homedir8(), ".codex");
13953
+ }
13954
+ function claudeLoggedIn() {
13955
+ if (process.env["ANTHROPIC_API_KEY"]) return true;
13956
+ if (existsSync23(join17(claudeDir(), ".credentials.json"))) return true;
13957
+ try {
13958
+ const raw = JSON.parse(readFileSync11(join17(homedir8(), ".claude.json"), "utf-8"));
13959
+ if (raw.oauthAccount) return true;
13960
+ } catch {
13961
+ }
13962
+ if (process.platform !== "darwin") return false;
13963
+ try {
13964
+ execFileSync2("security", ["find-generic-password", "-s", "Claude Code-credentials"], {
13965
+ stdio: "ignore"
13966
+ });
13967
+ return true;
13968
+ } catch {
13969
+ return false;
13970
+ }
13971
+ }
13972
+ function codexLoggedIn() {
13973
+ return !!process.env["OPENAI_API_KEY"] || existsSync23(join17(codexDir(), "auth.json"));
13974
+ }
13975
+ function jsonlFiles(dir, depth) {
13976
+ const out = [];
13977
+ let entries2;
13978
+ try {
13979
+ entries2 = readdirSync4(dir, { withFileTypes: true });
13980
+ } catch {
13981
+ return out;
13982
+ }
13983
+ for (const e of entries2) {
13984
+ const full = join17(dir, e.name);
13985
+ if (e.isDirectory()) {
13986
+ if (depth > 0) out.push(...jsonlFiles(full, depth - 1));
13987
+ } else if (e.name.endsWith(".jsonl")) {
13988
+ try {
13989
+ out.push({ file: full, mtime: statSync8(full).mtimeMs });
13990
+ } catch {
13991
+ }
13992
+ }
13993
+ }
13994
+ return out;
13995
+ }
13996
+ function cwdFromTranscript(file) {
13997
+ let head;
13998
+ try {
13999
+ head = readFileSync11(file, { encoding: "utf-8", flag: "r" }).slice(0, MAX_JSONL_HEAD_BYTES);
14000
+ } catch {
14001
+ return null;
14002
+ }
14003
+ for (const line of head.split("\n")) {
14004
+ if (!line.includes('"cwd"')) continue;
14005
+ try {
14006
+ const obj = JSON.parse(line);
14007
+ const cwd = obj.cwd ?? obj.payload?.cwd;
14008
+ if (typeof cwd === "string" && cwd) return cwd;
14009
+ } catch {
14010
+ }
14011
+ }
14012
+ return null;
14013
+ }
14014
+ function codexTrustedPaths() {
14015
+ try {
14016
+ const toml = readFileSync11(join17(codexDir(), "config.toml"), "utf-8");
14017
+ return [...toml.matchAll(/^\[projects\.(?:"([^"]+)"|'([^']+)')\]/gm)].map((m) => m[1] ?? m[2]);
14018
+ } catch {
14019
+ return [];
14020
+ }
14021
+ }
14022
+ function isExcluded(path) {
14023
+ const home = homedir8();
14024
+ if (path === home || path === "/") return true;
14025
+ if (path === "/tmp" || path.startsWith("/tmp/")) return true;
14026
+ if (path === "/private/tmp" || path.startsWith("/private/tmp/")) return true;
14027
+ const root = flyspaceRoot();
14028
+ if (path === root || path.startsWith(`${root}/`)) return true;
14029
+ try {
14030
+ return !statSync8(path).isDirectory();
14031
+ } catch {
14032
+ return true;
14033
+ }
14034
+ }
14035
+ function gitRepos() {
14036
+ const home = homedir8();
14037
+ const found = [];
14038
+ for (const rootName of GIT_SCAN_ROOTS) {
14039
+ const root = join17(home, rootName);
14040
+ let entries2;
14041
+ try {
14042
+ entries2 = readdirSync4(root, { withFileTypes: true });
14043
+ } catch {
14044
+ continue;
14045
+ }
14046
+ for (const e of entries2) {
14047
+ if (e.isDirectory() && existsSync23(join17(root, e.name, ".git"))) found.push(join17(root, e.name));
14048
+ }
14049
+ }
14050
+ return found;
14051
+ }
14052
+ function detectRunners(history = { claude: false, codex: false }) {
14053
+ return [
14054
+ {
14055
+ id: "claude",
14056
+ installed: onPath("claude"),
14057
+ loggedIn: claudeLoggedIn(),
14058
+ hasHistory: history.claude
14059
+ },
14060
+ {
14061
+ id: "codex",
14062
+ installed: onPath("codex"),
14063
+ loggedIn: codexLoggedIn(),
14064
+ hasHistory: history.codex
14065
+ }
14066
+ ];
14067
+ }
14068
+ function scanOnboarding() {
14069
+ const byPath = /* @__PURE__ */ new Map();
14070
+ const history = { claude: false, codex: false };
14071
+ const add = (rawPath, source, mtime, sessions, trusted = false) => {
14072
+ const path = resolve6(rawPath);
14073
+ if (isExcluded(path)) return;
14074
+ const existing = byPath.get(path);
14075
+ if (existing) {
14076
+ if (!existing.sources.includes(source)) existing.sources.push(source);
14077
+ existing.lastUsed = Math.max(existing.lastUsed, mtime);
14078
+ existing.sessions += sessions;
14079
+ existing.trusted ||= trusted;
14080
+ } else {
14081
+ byPath.set(path, {
14082
+ path,
14083
+ name: basename5(path),
14084
+ sources: [source],
14085
+ lastUsed: mtime,
14086
+ sessions,
14087
+ trusted
14088
+ });
14089
+ }
14090
+ };
14091
+ const claudeProjects = join17(claudeDir(), "projects");
14092
+ try {
14093
+ for (const e of readdirSync4(claudeProjects, { withFileTypes: true })) {
14094
+ if (!e.isDirectory()) continue;
14095
+ const files = jsonlFiles(join17(claudeProjects, e.name), 0);
14096
+ if (!files.length) continue;
14097
+ history.claude = true;
14098
+ files.sort((a, b) => b.mtime - a.mtime);
14099
+ let cwd = null;
14100
+ for (const f of files.slice(0, 3)) {
14101
+ cwd = cwdFromTranscript(f.file);
14102
+ if (cwd) break;
14103
+ }
14104
+ if (cwd) add(cwd, "claude", files[0].mtime, files.length);
14105
+ }
14106
+ } catch {
14107
+ }
14108
+ const codexSessions = jsonlFiles(join17(codexDir(), "sessions"), 4);
14109
+ if (codexSessions.length) history.codex = true;
14110
+ const perCwd = /* @__PURE__ */ new Map();
14111
+ for (const f of codexSessions) {
14112
+ const cwd = cwdFromTranscript(f.file);
14113
+ if (!cwd) continue;
14114
+ const cur = perCwd.get(cwd) ?? { mtime: 0, count: 0 };
14115
+ cur.mtime = Math.max(cur.mtime, f.mtime);
14116
+ cur.count += 1;
14117
+ perCwd.set(cwd, cur);
14118
+ }
14119
+ for (const [cwd, v] of perCwd) add(cwd, "codex", v.mtime, v.count);
14120
+ for (const p of codexTrustedPaths()) add(p, "codex", 0, 0, true);
14121
+ let projects = [...byPath.values()];
14122
+ let fromGitScan = false;
14123
+ if (!projects.length) {
14124
+ fromGitScan = true;
14125
+ for (const repo of gitRepos()) {
14126
+ let mtime = 0;
14127
+ try {
14128
+ mtime = statSync8(repo).mtimeMs;
14129
+ } catch {
14130
+ }
14131
+ add(repo, "git", mtime, 0);
14132
+ }
14133
+ projects = [...byPath.values()];
14134
+ }
14135
+ projects.sort(
14136
+ (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
14137
+ );
14138
+ return {
14139
+ root: flyspaceRoot(),
14140
+ runners: detectRunners(history),
14141
+ projects,
14142
+ fromGitScan
14143
+ };
14144
+ }
14145
+ function makeLink(target, linkPath) {
14146
+ symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
14147
+ }
14148
+ function linkProjectFolders(slug, items) {
14149
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error(`invalid workspace slug: ${slug}`);
14150
+ const dir = join17(flyspaceRoot(), slug);
14151
+ mkdirSync6(dir, { recursive: true });
14152
+ const results = [];
14153
+ for (const item of items) {
14154
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(item.name)) throw new Error(`invalid channel name: ${item.name}`);
14155
+ const dest = join17(dir, item.name);
14156
+ let exists = true;
14157
+ try {
14158
+ lstatSync2(dest);
14159
+ } catch {
14160
+ exists = false;
13243
14161
  }
13244
- if (file.size > MAX_LOGO_BYTES) {
13245
- return c.json({ error: "logo must be 2MB or smaller" }, 400);
14162
+ if (!exists) {
14163
+ if (item.path) {
14164
+ const target = resolve6(item.path);
14165
+ if (!statSync8(target).isDirectory()) throw new Error(`not a directory: ${target}`);
14166
+ makeLink(target, dest);
14167
+ } else {
14168
+ mkdirSync6(dest, { recursive: true });
14169
+ }
13246
14170
  }
13247
- const original = Buffer.from(await file.arrayBuffer());
13248
- const resized = await sharp5(original).resize({ width: LOGO_SIZE, height: LOGO_SIZE, fit: "cover" }).png().toBuffer();
13249
- const logo = `data:image/png;base64,${resized.toString("base64")}`;
13250
- return c.json(await saveWorkspaceBranding({ logo }));
13251
- });
13252
- api.delete("/settings/logo", async (c) => c.json(await saveWorkspaceBranding({ logo: null })));
14171
+ results.push({ name: item.name, cwd: `${slug}/${item.name}` });
14172
+ }
14173
+ return results;
13253
14174
  }
13254
14175
 
13255
- // src/dashboard/device-auth-routes.ts
13256
- import { verify } from "crypto";
13257
- import { getConnInfo } from "@hono/node-server/conninfo";
13258
- import { Hono as Hono26 } from "hono";
13259
-
13260
14176
  // src/dashboard/agent-bridge.ts
13261
- import { randomBytes as randomBytes7, randomUUID as randomUUID3, sign as signRaw, verify as verifyRaw } from "crypto";
13262
- import { existsSync as existsSync26, readFileSync as readFileSync15 } from "fs";
14177
+ import { existsSync as existsSync26, readFileSync as readFileSync16 } from "fs";
13263
14178
  import dotenv from "dotenv";
13264
14179
  import { WebSocket, WebSocketServer } from "ws";
13265
14180
 
13266
14181
  // src/dashboard/host-identity.ts
13267
14182
  import { createPrivateKey, createPublicKey, generateKeyPairSync } from "crypto";
13268
14183
  import { existsSync as existsSync24, mkdirSync as mkdirSync7, readFileSync as readFileSync12, writeFileSync as writeFileSync7 } from "fs";
13269
- import { dirname as dirname5, join as join17 } from "path";
14184
+ import { dirname as dirname5, join as join18 } from "path";
13270
14185
  function hostPrivateKeyPath() {
13271
- return process.env["FLYSPACE_HOST_KEY_PATH"] || join17(paths.BASE_CONFIG_DIR, "host.key");
14186
+ return process.env["FLYSPACE_HOST_KEY_PATH"] || join18(paths.BASE_CONFIG_DIR, "host.key");
13272
14187
  }
13273
14188
  function loadHostPrivateKey(path = hostPrivateKeyPath()) {
13274
14189
  if (!existsSync24(path)) {
@@ -13289,8 +14204,84 @@ function loadHostPublicKey() {
13289
14204
  }
13290
14205
  }
13291
14206
 
14207
+ // src/dashboard/host-lock.ts
14208
+ import { execFileSync as execFileSync3 } from "child_process";
14209
+ import { randomBytes as randomBytes5 } from "crypto";
14210
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync13, rmSync as rmSync4, writeFileSync as writeFileSync8 } from "fs";
14211
+ import { join as join19 } from "path";
14212
+ function hostLockPath() {
14213
+ return join19(paths.BASE_DATA_DIR, "host.lock");
14214
+ }
14215
+ function isLiveFlyspace(pid) {
14216
+ try {
14217
+ process.kill(pid, 0);
14218
+ const command = execFileSync3("ps", ["-p", String(pid), "-o", "command="], {
14219
+ encoding: "utf8"
14220
+ });
14221
+ return command.includes("flyspace") || command.includes("host-server");
14222
+ } catch {
14223
+ return false;
14224
+ }
14225
+ }
14226
+ function readLockPid(path) {
14227
+ try {
14228
+ const pid = Number(JSON.parse(readFileSync13(path, "utf8")).pid);
14229
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
14230
+ } catch {
14231
+ return null;
14232
+ }
14233
+ }
14234
+ function acquireHostLock() {
14235
+ const path = hostLockPath();
14236
+ mkdirSync8(paths.BASE_DATA_DIR, { recursive: true });
14237
+ const body = JSON.stringify({
14238
+ pid: process.pid,
14239
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
14240
+ execPath: process.argv[1] ?? ""
14241
+ });
14242
+ for (let attempt = 0; attempt < 2; attempt++) {
14243
+ try {
14244
+ writeFileSync8(path, body, { flag: "wx", mode: 384 });
14245
+ const release2 = () => {
14246
+ if (readLockPid(path) === process.pid) rmSync4(path, { force: true });
14247
+ };
14248
+ process.once("exit", release2);
14249
+ return release2;
14250
+ } catch (err) {
14251
+ if (err.code !== "EEXIST") throw err;
14252
+ const holder = readLockPid(path);
14253
+ if (holder === process.pid) return () => {
14254
+ };
14255
+ if (holder && isLiveFlyspace(holder)) {
14256
+ throw new Error(
14257
+ `Another Flyspace Host is already running on this machine (pid ${holder}). Stop it first, or check for a second CLI install (\`which -a flyspace\`). Two Hosts overwrite each other's skill mirror.`
14258
+ );
14259
+ }
14260
+ rmSync4(path, { force: true });
14261
+ }
14262
+ }
14263
+ throw new Error(`Could not take the Host lock at ${path}`);
14264
+ }
14265
+ function publishHostEndpoint(port) {
14266
+ const path = hostLockPath();
14267
+ if (readLockPid(path) !== process.pid) return null;
14268
+ const token = randomBytes5(32).toString("hex");
14269
+ const lock = JSON.parse(readFileSync13(path, "utf8"));
14270
+ writeFileSync8(path, JSON.stringify({ ...lock, port, token }), { mode: 384 });
14271
+ return token;
14272
+ }
14273
+ function readHostEndpoint() {
14274
+ try {
14275
+ const { pid, port, token } = JSON.parse(readFileSync13(hostLockPath(), "utf8"));
14276
+ if (pid === process.pid || !Number.isInteger(port) || typeof token !== "string") return null;
14277
+ return { port, token };
14278
+ } catch {
14279
+ return null;
14280
+ }
14281
+ }
14282
+
13292
14283
  // src/store/device-store.ts
13293
- import { createHash as createHash10, createPublicKey as createPublicKey2, randomBytes as randomBytes5, randomInt } from "crypto";
14284
+ import { createHash as createHash10, createPublicKey as createPublicKey2, randomBytes as randomBytes6, randomInt } from "crypto";
13294
14285
  var DEVICE_CODE_TTL_MS = 10 * 6e4;
13295
14286
  var ACCESS_TOKEN_TTL_MS = 15 * 6e4;
13296
14287
  var CHALLENGE_TTL_MS = 2 * 6e4;
@@ -13349,7 +14340,7 @@ function fail(what, error2) {
13349
14340
  async function createDeviceCode(input2) {
13350
14341
  const db = getSupabaseClient();
13351
14342
  await db.from("device_codes").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
13352
- const deviceCode = randomBytes5(32).toString("hex");
14343
+ const deviceCode = randomBytes6(32).toString("hex");
13353
14344
  for (let attempt = 0; attempt < 3; attempt++) {
13354
14345
  const userCode = generateUserCode();
13355
14346
  const { error: error2 } = await db.from("device_codes").insert({
@@ -13387,26 +14378,28 @@ async function decideDeviceCode(id, userId, decision) {
13387
14378
  if (error2) fail("decideDeviceCode", error2);
13388
14379
  return (data ?? []).length > 0;
13389
14380
  }
13390
- async function redeemDeviceCode(pending2) {
14381
+ async function redeemDeviceCode(pending3) {
13391
14382
  const db = getSupabaseClient();
13392
- const { data: claimed, error: claimError } = await db.from("device_codes").delete().eq("id", pending2.id).select("id");
14383
+ const { data: claimed, error: claimError } = await db.from("device_codes").delete().eq("id", pending3.id).select("id");
13393
14384
  if (claimError) fail("redeemDeviceCode", claimError);
13394
14385
  if (!claimed?.length) return null;
13395
- const { data, error: error2 } = await db.from("devices").insert({
13396
- user_id: pending2.approvedBy,
13397
- name: pending2.name,
13398
- platform: pending2.platform,
13399
- public_key: pending2.publicKey
14386
+ const existing = pending3.approvedBy ? await findDeviceByKey(pending3.publicKey, pending3.approvedBy) : null;
14387
+ const { data, error: error2 } = existing ? await db.from("devices").update({ name: pending3.name, platform: pending3.platform }).eq("id", existing.id).select("*").single() : await db.from("devices").insert({
14388
+ user_id: pending3.approvedBy,
14389
+ name: pending3.name,
14390
+ platform: pending3.platform,
14391
+ public_key: pending3.publicKey
13400
14392
  }).select("*").single();
13401
14393
  if (error2) fail("redeemDeviceCode", error2);
13402
14394
  const device = toDevice(data);
13403
- if (pending2.workspaceIds.length) {
13404
- const { error: bindError } = await db.from("device_workspaces").insert(
13405
- pending2.workspaceIds.map((workspaceId) => ({
14395
+ if (pending3.workspaceIds.length) {
14396
+ const { error: bindError } = await db.from("device_workspaces").upsert(
14397
+ pending3.workspaceIds.map((workspaceId) => ({
13406
14398
  device_id: device.id,
13407
14399
  workspace_id: workspaceId,
13408
- bound_by: pending2.approvedBy
13409
- }))
14400
+ bound_by: pending3.approvedBy
14401
+ })),
14402
+ { onConflict: "device_id,workspace_id", ignoreDuplicates: true }
13410
14403
  );
13411
14404
  if (bindError) fail("redeemDeviceCode", bindError);
13412
14405
  }
@@ -13417,8 +14410,13 @@ async function getDevice(id) {
13417
14410
  if (error2) fail("getDevice", error2);
13418
14411
  return data ? toDevice(data) : null;
13419
14412
  }
14413
+ async function findDeviceByKey(publicKey, userId) {
14414
+ 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();
14415
+ if (error2) fail("findDeviceByKey", error2);
14416
+ return data ? toDevice(data) : null;
14417
+ }
13420
14418
  async function issueAccessToken(deviceId) {
13421
- const token = `fsd_${randomBytes5(32).toString("hex")}`;
14419
+ const token = `fsd_${randomBytes6(32).toString("hex")}`;
13422
14420
  const { error: error2 } = await getSupabaseClient().from("devices").update({
13423
14421
  access_token_hash: sha256(token),
13424
14422
  access_token_expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_MS).toISOString(),
@@ -13440,7 +14438,7 @@ async function revokeDevice(id) {
13440
14438
  async function createChallenge(deviceId) {
13441
14439
  const db = getSupabaseClient();
13442
14440
  await db.from("device_challenges").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
13443
- const nonce = randomBytes5(32).toString("base64url");
14441
+ const nonce = randomBytes6(32).toString("base64url");
13444
14442
  const { error: error2 } = await db.from("device_challenges").insert({
13445
14443
  nonce,
13446
14444
  device_id: deviceId,
@@ -13465,11 +14463,28 @@ async function listDeviceWorkspaces(device) {
13465
14463
  const bound = new Set((data ?? []).map((r) => r.workspace_id));
13466
14464
  return (await listAdminWorkspaces(device.userId)).filter((w) => bound.has(w.id));
13467
14465
  }
14466
+ async function bindDeviceWorkspace(deviceId, workspaceId, boundBy) {
14467
+ const { error: error2 } = await getSupabaseClient().from("device_workspaces").upsert(
14468
+ { device_id: deviceId, workspace_id: workspaceId, bound_by: boundBy },
14469
+ { onConflict: "device_id,workspace_id", ignoreDuplicates: true }
14470
+ );
14471
+ if (error2) fail("bindDeviceWorkspace", error2);
14472
+ }
13468
14473
  async function listWorkspaceDevices(workspaceId) {
13469
14474
  const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("devices!inner(*)").eq("workspace_id", workspaceId).is("devices.revoked_at", null);
13470
14475
  if (error2) fail("listWorkspaceDevices", error2);
13471
14476
  return (data ?? []).map((r) => toDevice(r.devices)).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
13472
14477
  }
14478
+ async function listWorkspaceHosts(workspaceIds, exceptPublicKey) {
14479
+ const hosts2 = /* @__PURE__ */ new Map();
14480
+ if (!workspaceIds.length) return hosts2;
14481
+ const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id, devices!inner(name, public_key, revoked_at)").in("workspace_id", workspaceIds).is("devices.revoked_at", null);
14482
+ if (error2) fail("listWorkspaceHosts", error2);
14483
+ for (const r of data ?? []) {
14484
+ if (r.devices.public_key !== exceptPublicKey) hosts2.set(r.workspace_id, r.devices.name);
14485
+ }
14486
+ return hosts2;
14487
+ }
13473
14488
  async function userEmail(userId) {
13474
14489
  const { data, error: error2 } = await getSupabaseClient().auth.admin.getUserById(userId);
13475
14490
  if (error2) return null;
@@ -13478,8 +14493,8 @@ async function userEmail(userId) {
13478
14493
 
13479
14494
  // src/auth/device-auth.ts
13480
14495
  import { createPrivateKey as createPrivateKey2, createPublicKey as createPublicKey3, generateKeyPairSync as generateKeyPairSync2, sign } from "crypto";
13481
- import { existsSync as existsSync25, mkdirSync as mkdirSync8, readFileSync as readFileSync13, rmSync as rmSync4, writeFileSync as writeFileSync8 } from "fs";
13482
- import { dirname as dirname6, join as join18 } from "path";
14496
+ import { existsSync as existsSync25, mkdirSync as mkdirSync9, readFileSync as readFileSync14, rmSync as rmSync5, writeFileSync as writeFileSync9 } from "fs";
14497
+ import { dirname as dirname6, join as join20 } from "path";
13483
14498
  var DEFAULT_API_URL = "https://app.flyspace.ai";
13484
14499
  var EXPIRY_SKEW_MS = 6e4;
13485
14500
  var AuthApiError = class extends Error {
@@ -13493,20 +14508,20 @@ function apiUrl() {
13493
14508
  return (process.env["FLYSPACE_API_URL"] || DEFAULT_API_URL).replace(/\/+$/, "");
13494
14509
  }
13495
14510
  function deviceKeyPath() {
13496
- return join18(paths.BASE_CONFIG_DIR, "device.key");
14511
+ return join20(paths.BASE_CONFIG_DIR, "device.key");
13497
14512
  }
13498
14513
  function credentialsPath() {
13499
- return join18(paths.BASE_CONFIG_DIR, "credentials.json");
14514
+ return join20(paths.BASE_CONFIG_DIR, "credentials.json");
13500
14515
  }
13501
14516
  function ensureDeviceKey(path = deviceKeyPath()) {
13502
14517
  let created = false;
13503
14518
  if (!existsSync25(path)) {
13504
14519
  const { privateKey: privateKey2 } = generateKeyPairSync2("ed25519");
13505
- mkdirSync8(dirname6(path), { recursive: true });
13506
- writeFileSync8(path, privateKey2.export({ type: "pkcs8", format: "pem" }), { mode: 384 });
14520
+ mkdirSync9(dirname6(path), { recursive: true });
14521
+ writeFileSync9(path, privateKey2.export({ type: "pkcs8", format: "pem" }), { mode: 384 });
13507
14522
  created = true;
13508
14523
  }
13509
- const privateKey = createPrivateKey2(readFileSync13(path, "utf8"));
14524
+ const privateKey = createPrivateKey2(readFileSync14(path, "utf8"));
13510
14525
  const publicKeyBase64 = createPublicKey3(privateKey).export({ type: "spki", format: "der" }).toString(
13511
14526
  "base64"
13512
14527
  );
@@ -13515,19 +14530,19 @@ function ensureDeviceKey(path = deviceKeyPath()) {
13515
14530
  function loadCredentials(path = credentialsPath()) {
13516
14531
  if (!existsSync25(path)) return null;
13517
14532
  try {
13518
- return JSON.parse(readFileSync13(path, "utf8"));
14533
+ return JSON.parse(readFileSync14(path, "utf8"));
13519
14534
  } catch {
13520
14535
  return null;
13521
14536
  }
13522
14537
  }
13523
14538
  function saveCredentials(creds, path = credentialsPath()) {
13524
- mkdirSync8(dirname6(path), { recursive: true });
13525
- writeFileSync8(path, `${JSON.stringify(creds, null, 2)}
14539
+ mkdirSync9(dirname6(path), { recursive: true });
14540
+ writeFileSync9(path, `${JSON.stringify(creds, null, 2)}
13526
14541
  `, { mode: 384 });
13527
14542
  }
13528
14543
  function clearLocalAuth() {
13529
- rmSync4(credentialsPath(), { force: true });
13530
- rmSync4(deviceKeyPath(), { force: true });
14544
+ rmSync5(credentialsPath(), { force: true });
14545
+ rmSync5(deviceKeyPath(), { force: true });
13531
14546
  }
13532
14547
  async function post(base, path, body, token) {
13533
14548
  return fetch(`${base}${path}`, {
@@ -13644,14 +14659,14 @@ async function getAccessToken() {
13644
14659
  }
13645
14660
 
13646
14661
  // src/dashboard/doc-share-publisher.ts
13647
- import { randomBytes as randomBytes6 } from "crypto";
13648
- import { readFileSync as readFileSync14, statSync as statSync9 } from "fs";
14662
+ import { randomBytes as randomBytes7 } from "crypto";
14663
+ import { readFileSync as readFileSync15, statSync as statSync9 } from "fs";
13649
14664
  import { extname as extname2, posix } from "path";
13650
14665
  import { CopyObjectCommand, PutObjectCommand as PutObjectCommand5 } from "@aws-sdk/client-s3";
13651
14666
 
13652
14667
  // src/dashboard/share-language.ts
13653
- var GROQ_API_URL7 = "https://api.groq.com/openai/v1/chat/completions";
13654
- var CLASSIFIER_MODEL3 = "openai/gpt-oss-20b";
14668
+ var GROQ_API_URL9 = "https://api.groq.com/openai/v1/chat/completions";
14669
+ var CLASSIFIER_MODEL4 = "openai/gpt-oss-20b";
13655
14670
  var SAMPLE_CHARS = 4e3;
13656
14671
  var NOISE = [
13657
14672
  /```[\s\S]*?```/g,
@@ -13689,11 +14704,11 @@ async function detectSourceLanguage(title, content) {
13689
14704
  const sample = languageSample(title, content);
13690
14705
  if (sample.length < MIN_SAMPLE_CHARS) return null;
13691
14706
  try {
13692
- const res = await fetch(GROQ_API_URL7, {
14707
+ const res = await fetch(GROQ_API_URL9, {
13693
14708
  method: "POST",
13694
14709
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
13695
14710
  body: JSON.stringify({
13696
- model: CLASSIFIER_MODEL3,
14711
+ model: CLASSIFIER_MODEL4,
13697
14712
  temperature: 0,
13698
14713
  reasoning_effort: "low",
13699
14714
  // gpt-oss reasons before it answers, and those tokens count against
@@ -13765,10 +14780,11 @@ function shareKindOf(absPath) {
13765
14780
  const ext = extname2(absPath).toLowerCase();
13766
14781
  if (ext === ".md" || ext === ".markdown") return "markdown";
13767
14782
  if (ext === ".html" || ext === ".htm") return "html";
14783
+ if (ext === ".excalidraw") return "excalidraw";
13768
14784
  return null;
13769
14785
  }
13770
14786
  function generateShareId() {
13771
- return randomBytes6(9).toString("base64url");
14787
+ return randomBytes7(9).toString("base64url");
13772
14788
  }
13773
14789
  function isExternalHref(href) {
13774
14790
  return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("#");
@@ -13831,8 +14847,17 @@ async function buildSnapshot(cwd, sourcePath, shareId) {
13831
14847
  }
13832
14848
  if (!stat.isFile()) return { status: "not-found" };
13833
14849
  if (stat.size > MAX_CONTENT_BYTES2) return { status: "too-large" };
13834
- const source = readFileSync14(abs, "utf8");
13835
- const title = extractTitle(abs, kind === "html" ? "html" : "md");
14850
+ const source = readFileSync15(abs, "utf8");
14851
+ const title = extractTitle(abs, kind === "markdown" ? "md" : kind);
14852
+ if (kind === "excalidraw") {
14853
+ try {
14854
+ const parsed = JSON.parse(source);
14855
+ if (!parsed || typeof parsed !== "object") return { status: "unsupported" };
14856
+ } catch {
14857
+ return { status: "unsupported" };
14858
+ }
14859
+ return { kind, title, content: source, assetKeys: [] };
14860
+ }
13836
14861
  const docDir = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/")) : "";
13837
14862
  const client2 = getR2Client();
13838
14863
  const bucket = getR2Bucket();
@@ -13902,7 +14927,7 @@ async function buildSnapshot(cwd, sourcePath, shareId) {
13902
14927
  let text = null;
13903
14928
  try {
13904
14929
  const size = statSync9(docAbs).size;
13905
- if (size <= budget) text = readFileSync14(docAbs, "utf8");
14930
+ if (size <= budget) text = readFileSync15(docAbs, "utf8");
13906
14931
  } catch {
13907
14932
  continue;
13908
14933
  }
@@ -13919,7 +14944,7 @@ async function buildSnapshot(cwd, sourcePath, shareId) {
13919
14944
  let body;
13920
14945
  try {
13921
14946
  if (statSync9(resolved2.abs).size > MAX_ASSET_BYTES) continue;
13922
- body = readFileSync14(resolved2.abs);
14947
+ body = readFileSync15(resolved2.abs);
13923
14948
  } catch {
13924
14949
  continue;
13925
14950
  }
@@ -13956,7 +14981,7 @@ async function publishDocShare(input2) {
13956
14981
  const shareId = existing?.id ?? generateShareId();
13957
14982
  const built = await buildSnapshot(input2.cwd, input2.sourcePath, shareId);
13958
14983
  if ("status" in built) return built;
13959
- const html = built.kind === "html";
14984
+ const html = built.kind !== "markdown";
13960
14985
  const features = html ? HTML_FEATURES : resolveFeatures(input2.features, existing);
13961
14986
  const snapshot2 = {
13962
14987
  ...built,
@@ -13990,6 +15015,281 @@ async function publishDocShare(input2) {
13990
15015
  return { status: "ok", share };
13991
15016
  }
13992
15017
 
15018
+ // src/dashboard/host-db-proxy.ts
15019
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15020
+ var SLUG_COLUMN = {
15021
+ workspaces: "slug",
15022
+ channels: "profile",
15023
+ automations: "profile",
15024
+ agent_usage: "workspace",
15025
+ skill_files: "workspace"
15026
+ };
15027
+ var ALL = ["GET", "HEAD", "POST", "PATCH", "DELETE"];
15028
+ var METHODS = {
15029
+ workspaces: ["GET", "HEAD"],
15030
+ channels: ALL,
15031
+ automations: ALL,
15032
+ agent_usage: ["GET", "HEAD", "POST"],
15033
+ skill_files: ALL,
15034
+ threads: ["GET", "HEAD", "POST", "PATCH"],
15035
+ thread_replies: ["GET", "HEAD", "POST", "PATCH"]
15036
+ };
15037
+ var FORWARDED_HEADERS = ["accept", "content-type", "prefer", "range"];
15038
+ var deny = (message, status = 403) => ({
15039
+ status,
15040
+ headers: { "content-type": "application/json" },
15041
+ body: JSON.stringify({ code: "FLYSPACE_DENIED", message, details: null, hint: null })
15042
+ });
15043
+ var inList = (values) => `in.(${values.map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`).join(",")})`;
15044
+ function filterValues(raw) {
15045
+ if (raw.startsWith("eq.")) return [raw.slice(3)];
15046
+ if (raw.startsWith("in.(") && raw.endsWith(")")) {
15047
+ const inner = raw.slice(4, -1);
15048
+ return inner === "" ? [] : inner.split(",").map((v) => v.replace(/^"|"$/g, ""));
15049
+ }
15050
+ return null;
15051
+ }
15052
+ function bodyRows(body) {
15053
+ if (!body) return [];
15054
+ try {
15055
+ const parsed = JSON.parse(body);
15056
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
15057
+ return rows.every((r) => r && typeof r === "object") ? rows : null;
15058
+ } catch {
15059
+ return null;
15060
+ }
15061
+ }
15062
+ async function getJson(forward, path) {
15063
+ const res = await forward({ method: "GET", path, headers: { accept: "application/json" } });
15064
+ if (res.status !== 200) return null;
15065
+ try {
15066
+ return JSON.parse(res.body);
15067
+ } catch {
15068
+ return null;
15069
+ }
15070
+ }
15071
+ async function ownedChannelIds(forward, slugs) {
15072
+ const rows = await getJson(
15073
+ forward,
15074
+ `/rest/v1/channels?select=id,external_channel_id&profile=${encodeURIComponent(inList(slugs))}`
15075
+ );
15076
+ if (!rows) return null;
15077
+ return rows.flatMap((r) => [r.id, r.external_channel_id].filter((v) => !!v));
15078
+ }
15079
+ async function threadsOwned(forward, threadIds, channelIds) {
15080
+ const wanted = [...new Set(threadIds)];
15081
+ if (wanted.length === 0 || channelIds.length === 0) return false;
15082
+ const rows = await getJson(
15083
+ forward,
15084
+ `/rest/v1/threads?select=id&id=${encodeURIComponent(inList(wanted))}&channel_id=${encodeURIComponent(inList(channelIds))}`
15085
+ );
15086
+ return !!rows && new Set(rows.map((r) => r.id)).size === wanted.length;
15087
+ }
15088
+ async function replyThreadIds(forward, method, params, rows) {
15089
+ if (method === "POST") {
15090
+ const ids = rows.map((r) => r["thread_id"]);
15091
+ return ids.length > 0 && ids.every((v) => typeof v === "string" && UUID.test(v)) ? ids : null;
15092
+ }
15093
+ const threadFilters = params.getAll("thread_id").map(filterValues);
15094
+ if (threadFilters.length > 0) {
15095
+ const ids = threadFilters.flatMap((v) => v ?? [""]);
15096
+ return ids.length > 0 && ids.every((v) => UUID.test(v)) ? ids : null;
15097
+ }
15098
+ const idFilters = params.getAll("id").map(filterValues);
15099
+ const replyIds = idFilters.flatMap((v) => v ?? [""]);
15100
+ if (replyIds.length === 0 || !replyIds.every((v) => UUID.test(v))) return null;
15101
+ const found = await getJson(
15102
+ forward,
15103
+ `/rest/v1/thread_replies?select=id,thread_id&id=${encodeURIComponent(inList(replyIds))}`
15104
+ );
15105
+ if (!found || found.length !== new Set(replyIds).size) return null;
15106
+ return found.map((r) => r.thread_id);
15107
+ }
15108
+ async function proxyHostDb(workspaces, req, forward, assigned = workspaces ?? []) {
15109
+ if (!workspaces) return deny("This Host is not bound to a workspace.");
15110
+ if (workspaces.length === 0) return deny("This device has no workspace.");
15111
+ const url = new URL(req.path, "http://proxy.invalid");
15112
+ const match = /^\/rest\/v1\/([a-z_]+)$/.exec(url.pathname);
15113
+ const table = match?.[1];
15114
+ if (!table || !(table in METHODS)) return deny(`Table is not available to a Host: ${url.pathname}`);
15115
+ const method = req.method.toUpperCase();
15116
+ if (!METHODS[table].includes(method)) return deny(`${method} ${table} is not available to a Host.`);
15117
+ const params = url.searchParams;
15118
+ const isWrite = method === "POST" || method === "PATCH" || method === "DELETE";
15119
+ const rows = method === "POST" ? bodyRows(req.body) : [];
15120
+ if (rows === null) return deny("Body must be a JSON object or array.", 400);
15121
+ const slugColumn = SLUG_COLUMN[table];
15122
+ if (slugColumn) {
15123
+ const scope = table === "skill_files" && isWrite ? assigned : workspaces;
15124
+ if (scope.length === 0) return deny(`This Host is not the assigned Host for any workspace it may write ${table} to.`);
15125
+ if (method === "POST") {
15126
+ const ok = rows.length > 0 && rows.every((r) => typeof r[slugColumn] === "string" && scope.includes(r[slugColumn]));
15127
+ if (!ok) return deny(`Every ${table} row must belong to a workspace this Host is assigned.`);
15128
+ }
15129
+ if (method === "PATCH") {
15130
+ const patch = bodyRows(req.body);
15131
+ if (!patch || patch.some((r) => slugColumn in r && !scope.includes(r[slugColumn]))) {
15132
+ return deny(`A ${table} row cannot be moved to another workspace.`);
15133
+ }
15134
+ }
15135
+ params.append(slugColumn, inList(scope));
15136
+ } else {
15137
+ const channelIds = await ownedChannelIds(forward, workspaces);
15138
+ if (!channelIds) return deny("Could not resolve the device workspaces.", 502);
15139
+ if (table === "threads") {
15140
+ if (method === "POST") {
15141
+ const ok = rows.length > 0 && rows.every((r) => typeof r["channel_id"] === "string" && channelIds.includes(r["channel_id"]));
15142
+ if (!ok) return deny("A thread must belong to a channel of the device workspaces.");
15143
+ }
15144
+ if (method === "PATCH") {
15145
+ const patch = bodyRows(req.body);
15146
+ if (!patch || patch.some((r) => "channel_id" in r)) return deny("A thread cannot move channels.");
15147
+ }
15148
+ params.append("channel_id", inList(channelIds));
15149
+ } else {
15150
+ const threadIds = await replyThreadIds(forward, method, params, rows);
15151
+ if (!threadIds) return deny("thread_replies requests must name the thread or replies they touch.");
15152
+ if (!await threadsOwned(forward, threadIds, channelIds)) return deny("Thread is outside the device workspaces.");
15153
+ if (method === "PATCH") {
15154
+ const patch = bodyRows(req.body);
15155
+ if (!patch || patch.some((r) => "thread_id" in r)) return deny("A reply cannot move threads.");
15156
+ }
15157
+ }
15158
+ }
15159
+ const headers = {};
15160
+ for (const name of FORWARDED_HEADERS) {
15161
+ const value = req.headers[name];
15162
+ if (value !== void 0) headers[name] = value;
15163
+ }
15164
+ return forward({
15165
+ method,
15166
+ path: `${url.pathname}?${params.toString()}`,
15167
+ headers,
15168
+ body: isWrite ? req.body : void 0
15169
+ });
15170
+ }
15171
+ var THREAD_TOPIC = /^agent-thinking:([0-9a-f-]{36})$/i;
15172
+ async function hostMayBroadcast(workspaces, topic, forward) {
15173
+ if (!workspaces || workspaces.length === 0) return false;
15174
+ if (topic === "channel-activity" || topic === "host-status") return true;
15175
+ const thread = THREAD_TOPIC.exec(topic)?.[1];
15176
+ if (!thread || !UUID.test(thread)) return false;
15177
+ const channelIds = await ownedChannelIds(forward, workspaces);
15178
+ return !!channelIds && await threadsOwned(forward, [thread], channelIds);
15179
+ }
15180
+ var forwardToSupabase = async (req) => {
15181
+ const base = process.env["SUPABASE_URL"];
15182
+ const key = process.env["SUPABASE_SECRET_KEY"];
15183
+ if (!base || !key) return deny("Data API has no Supabase credentials.", 500);
15184
+ const res = await fetch(`${base}${req.path}`, {
15185
+ method: req.method,
15186
+ headers: { ...req.headers, apikey: key, authorization: `Bearer ${key}` },
15187
+ body: req.body
15188
+ });
15189
+ return {
15190
+ status: res.status,
15191
+ headers: Object.fromEntries(
15192
+ ["content-type", "content-range", "content-profile"].flatMap((h) => {
15193
+ const v = res.headers.get(h);
15194
+ return v ? [[h, v]] : [];
15195
+ })
15196
+ ),
15197
+ body: await res.text()
15198
+ };
15199
+ };
15200
+
15201
+ // src/dashboard/host-db-client.ts
15202
+ import { createClient as createClient2 } from "@supabase/supabase-js";
15203
+ import { randomUUID as randomUUID3 } from "crypto";
15204
+ var REQUEST_TIMEOUT_MS3 = 3e4;
15205
+ var pending = /* @__PURE__ */ new Map();
15206
+ var sender = null;
15207
+ var onSender = [];
15208
+ function setHostDbSender(fn) {
15209
+ sender = fn;
15210
+ if (!fn) return;
15211
+ const waiting = onSender;
15212
+ onSender = [];
15213
+ for (const wake of waiting) wake();
15214
+ }
15215
+ function handleHostDbResult(id, res) {
15216
+ const p = pending.get(id);
15217
+ if (!p) return;
15218
+ pending.delete(id);
15219
+ clearTimeout(p.timer);
15220
+ p.resolve(res);
15221
+ }
15222
+ function whenConnected() {
15223
+ if (sender) return Promise.resolve(sender);
15224
+ return new Promise((resolve8, reject) => {
15225
+ const timer2 = setTimeout(() => {
15226
+ onSender = onSender.filter((w) => w !== wake);
15227
+ reject(new Error("Host is not connected to the Data API"));
15228
+ }, REQUEST_TIMEOUT_MS3);
15229
+ const wake = () => {
15230
+ clearTimeout(timer2);
15231
+ resolve8(sender);
15232
+ };
15233
+ onSender.push(wake);
15234
+ });
15235
+ }
15236
+ async function request(req) {
15237
+ const send2 = await whenConnected();
15238
+ const id = randomUUID3();
15239
+ return new Promise((resolve8, reject) => {
15240
+ const timer2 = setTimeout(() => {
15241
+ pending.delete(id);
15242
+ reject(new Error("Data API did not answer in time"));
15243
+ }, REQUEST_TIMEOUT_MS3);
15244
+ pending.set(id, { resolve: resolve8, reject, timer: timer2 });
15245
+ send2({ type: "db", id, req });
15246
+ });
15247
+ }
15248
+ var BODYLESS_STATUS = /* @__PURE__ */ new Set([101, 204, 205, 304]);
15249
+ var hostDbFetch = async (input2, init) => {
15250
+ const req = new Request(input2, init);
15251
+ const url = new URL(req.url);
15252
+ const method = req.method.toUpperCase();
15253
+ const res = await request({
15254
+ method,
15255
+ path: `${url.pathname}${url.search}`,
15256
+ headers: Object.fromEntries(req.headers.entries()),
15257
+ body: method === "GET" || method === "HEAD" ? void 0 : await req.text()
15258
+ });
15259
+ const noBody = method === "HEAD" || BODYLESS_STATUS.has(res.status);
15260
+ return new Response(noBody ? null : res.body, { status: res.status, headers: res.headers });
15261
+ };
15262
+ function relayChannel(topic) {
15263
+ const channel = {
15264
+ on: () => channel,
15265
+ subscribe: (callback) => {
15266
+ queueMicrotask(() => callback?.("SUBSCRIBED"));
15267
+ return channel;
15268
+ },
15269
+ send: async (msg) => {
15270
+ if (msg.type !== "broadcast" || !sender) return "error";
15271
+ sender({ type: "db-broadcast", topic, event: msg.event, payload: msg.payload ?? {} });
15272
+ return "ok";
15273
+ },
15274
+ unsubscribe: async () => "ok"
15275
+ };
15276
+ return channel;
15277
+ }
15278
+ function createHostDbClient() {
15279
+ const client2 = createClient2("https://host-db.invalid", "host-device", {
15280
+ auth: { persistSession: false, autoRefreshToken: false },
15281
+ global: { fetch: hostDbFetch }
15282
+ });
15283
+ return new Proxy(client2, {
15284
+ get(target, prop) {
15285
+ if (prop === "channel") return relayChannel;
15286
+ if (prop === "removeChannel") return async () => "ok";
15287
+ const value = Reflect.get(target, prop, target);
15288
+ return typeof value === "function" ? value.bind(target) : value;
15289
+ }
15290
+ });
15291
+ }
15292
+
13993
15293
  // src/dashboard/agent-bridge.ts
13994
15294
  var HOST_SOCKET_PATH = "/internal/host-socket";
13995
15295
  var DEFAULT_DATA_API_PORT = 4757;
@@ -14026,6 +15326,7 @@ function localAgentCallbacks(chatAgent) {
14026
15326
  getLocalDashboardSettings: () => loadDashboardSettings(),
14027
15327
  saveLocalDashboardSettings: (patch) => saveDashboardSettings(patch),
14028
15328
  writeSkill: (ref, write) => writeSkillAndSync(ref, write),
15329
+ outputStylesRequest: (req) => handleOutputStylesRequest(req),
14029
15330
  listSessions: () => listLocalSessions(),
14030
15331
  scanOnboarding: () => scanOnboarding(),
14031
15332
  linkProjectFolders: (slug, items) => linkProjectFolders(slug, items),
@@ -14037,7 +15338,7 @@ function buildHostConfigSnapshot() {
14037
15338
  const snapshot2 = {};
14038
15339
  for (const profile of listProfiles()) {
14039
15340
  snapshot2[profile] = runWithProfile(profile, () => ({
14040
- env: existsSync26(paths.CONFIG_FILE) ? dotenv.parse(readFileSync15(paths.CONFIG_FILE, "utf-8")) : {},
15341
+ env: existsSync26(paths.CONFIG_FILE) ? dotenv.parse(readFileSync16(paths.CONFIG_FILE, "utf-8")) : {},
14041
15342
  agentModel: loadDashboardSettings().agent
14042
15343
  }));
14043
15344
  }
@@ -14052,9 +15353,9 @@ var CALL_TIMEOUT_MS = 15e3;
14052
15353
  var AUTH_TIMEOUT_MS = 5e3;
14053
15354
  var HEARTBEAT_INTERVAL_MS = 15e3;
14054
15355
  var HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2.5;
14055
- var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15356
+ var UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14056
15357
  var hosts = /* @__PURE__ */ new Map();
14057
- var pending = /* @__PURE__ */ new Map();
15358
+ var pending2 = /* @__PURE__ */ new Map();
14058
15359
  var hostStatusListeners = /* @__PURE__ */ new Set();
14059
15360
  var lastAuthFailure = null;
14060
15361
  function hostFor(profile) {
@@ -14153,7 +15454,7 @@ function attachHostSocket(server) {
14153
15454
  wss.on("connection", (ws) => {
14154
15455
  alive.add(ws);
14155
15456
  ws.on("pong", () => alive.add(ws));
14156
- const nonce = randomBytes7(32).toString("base64");
15457
+ const nonce = randomBytes8(32).toString("base64");
14157
15458
  const authTimer = setTimeout(() => {
14158
15459
  console.error("[host-socket] auth timed out");
14159
15460
  ws.close(1008, "auth timeout");
@@ -14165,32 +15466,48 @@ function attachHostSocket(server) {
14165
15466
  if (msg.type === "auth") {
14166
15467
  clearTimeout(authTimer);
14167
15468
  void (async () => {
14168
- const scope = await verifyHostAuth(nonce, msg, legacyPublicKey);
14169
- if (!scope) {
15469
+ const scope2 = await verifyHostAuth(nonce, msg, legacyPublicKey);
15470
+ if (!scope2) {
14170
15471
  console.error("[host-socket] rejecting connection: bad signature or unknown/revoked device");
14171
15472
  lastAuthFailure = { reason: "bad signature", since: Date.now() };
14172
15473
  send(ws, { type: "auth-failed", reason: "bad signature" });
14173
15474
  ws.close(1008, "bad signature");
14174
15475
  return;
14175
15476
  }
14176
- supersedeSameIdentity(scope, ws);
15477
+ supersedeSameIdentity(scope2, ws);
14177
15478
  lastAuthFailure = null;
14178
- addHost(ws, scope);
15479
+ addHost(ws, scope2);
14179
15480
  send(ws, { type: "auth-ok" });
14180
- console.log(`[host-socket] Host authenticated (authPath: ${scope.authPath})`);
14181
- if (scope.workspaces) void markAgentConnected(scope.workspaces);
15481
+ console.log(`[host-socket] Host authenticated (authPath: ${scope2.authPath})`);
15482
+ if (scope2.workspaces) void markAgentConnected(scope2.workspaces);
14182
15483
  })();
14183
15484
  return;
14184
15485
  }
14185
15486
  if (msg.type === "result" || msg.type === "call-error") {
14186
- const waiter = pending.get(msg.id);
15487
+ const waiter = pending2.get(msg.id);
14187
15488
  if (!waiter) return;
14188
- pending.delete(msg.id);
15489
+ pending2.delete(msg.id);
14189
15490
  waiter.resolve(msg.type === "result" ? msg.value : null);
14190
15491
  return;
14191
15492
  }
14192
15493
  if (msg.type === "host-config") {
14193
15494
  setProfileConfigCache(msg.configs);
15495
+ return;
15496
+ }
15497
+ const scope = hosts.get(ws);
15498
+ if (!scope) return;
15499
+ if (msg.type === "db") {
15500
+ const assigned = (scope.workspaces ?? []).filter((p) => hostFor(p) === ws);
15501
+ void proxyHostDb(scope.workspaces, msg.req, forwardToSupabase, assigned).then(
15502
+ (res) => send(ws, { type: "db-result", id: msg.id, res })
15503
+ );
15504
+ return;
15505
+ }
15506
+ if (msg.type === "db-broadcast") {
15507
+ void (async () => {
15508
+ if (!await hostMayBroadcast(scope.workspaces, msg.topic, forwardToSupabase)) return;
15509
+ await getSupabaseClient().channel(msg.topic).httpSend(msg.event, msg.payload).catch((err) => console.error(`[host-socket] broadcast ${msg.topic} failed:`, err.message));
15510
+ })();
14194
15511
  }
14195
15512
  });
14196
15513
  ws.on("close", () => {
@@ -14210,14 +15527,14 @@ function callHost(method, args) {
14210
15527
  );
14211
15528
  return Promise.resolve(null);
14212
15529
  }
14213
- const id = randomUUID3();
15530
+ const id = randomUUID4();
14214
15531
  return new Promise((resolve8) => {
14215
15532
  const timer2 = setTimeout(() => {
14216
- pending.delete(id);
15533
+ pending2.delete(id);
14217
15534
  console.error(`[host-socket] ${method} -> timed out`);
14218
15535
  resolve8(null);
14219
15536
  }, CALL_TIMEOUT_MS);
14220
- pending.set(id, {
15537
+ pending2.set(id, {
14221
15538
  resolve: (value) => {
14222
15539
  clearTimeout(timer2);
14223
15540
  resolve8(value);
@@ -14268,6 +15585,8 @@ function remoteAgentCallbacks() {
14268
15585
  saveLocalDashboardSettings: (patch) => callHost("saveLocalDashboardSettings", [patch]),
14269
15586
  // Same posture as saveLocalDashboardSettings: `null` becomes a 503.
14270
15587
  writeSkill: (ref, write) => callHost("writeSkill", [ref, write]),
15588
+ // Same posture as writeSkill: `null` becomes a 503.
15589
+ outputStylesRequest: (req) => callHost("outputStylesRequest", [req]),
14271
15590
  // `null` (no Host connected) is passed through rather than defaulting to
14272
15591
  // `[]` — see listSessions's doc comment on AgentCallbacks.
14273
15592
  listSessions: () => callHost("listSessions", []),
@@ -14281,7 +15600,7 @@ function remoteAgentCallbacks() {
14281
15600
  }
14282
15601
  var RECONNECT_BASE_MS = 1e3;
14283
15602
  var RECONNECT_MAX_MS = 3e4;
14284
- function connectHostSocket(url, callbacks) {
15603
+ function connectHostSocket(url, callbacks, onAuthenticated) {
14285
15604
  const credentials = loadCredentials();
14286
15605
  const deviceId = credentials?.deviceId;
14287
15606
  const privateKey = deviceId ? ensureDeviceKey().privateKey : loadHostPrivateKey();
@@ -14322,11 +15641,17 @@ function connectHostSocket(url, callbacks) {
14322
15641
  }
14323
15642
  if (msg.type === "auth-ok") {
14324
15643
  console.log("[host-socket] authenticated");
15644
+ setHostDbSender((out) => send(ws, out));
14325
15645
  send(ws, { type: "host-config", configs: buildHostConfigSnapshot() });
14326
15646
  if (configPushTimer) clearInterval(configPushTimer);
14327
15647
  configPushTimer = setInterval(() => {
14328
15648
  send(ws, { type: "host-config", configs: buildHostConfigSnapshot() });
14329
15649
  }, HOST_CONFIG_PUSH_INTERVAL_MS);
15650
+ onAuthenticated?.();
15651
+ return;
15652
+ }
15653
+ if (msg.type === "db-result") {
15654
+ handleHostDbResult(msg.id, msg.res);
14330
15655
  return;
14331
15656
  }
14332
15657
  if (msg.type === "call") {
@@ -14334,6 +15659,7 @@ function connectHostSocket(url, callbacks) {
14334
15659
  }
14335
15660
  });
14336
15661
  ws.on("close", () => {
15662
+ setHostDbSender(null);
14337
15663
  if (configPushTimer) {
14338
15664
  clearInterval(configPushTimer);
14339
15665
  configPushTimer = null;
@@ -14375,6 +15701,56 @@ async function handleCall(msg, callbacks, ws) {
14375
15701
  }
14376
15702
  });
14377
15703
  }
15704
+ var LOCAL_RELAY_PATH = "/internal/agent-call";
15705
+ var RELAYED_METHODS = [
15706
+ "onChatMessage",
15707
+ "isAgentThinking",
15708
+ "getAgentActivities",
15709
+ "cancelAgentRun",
15710
+ "regenerateAgentRun",
15711
+ "listAgentRuns"
15712
+ ];
15713
+ function relayedAgentCallbacks(local) {
15714
+ const relay2 = (method) => async (...args) => {
15715
+ const endpoint = readHostEndpoint();
15716
+ if (endpoint) {
15717
+ try {
15718
+ const res = await fetch(`http://127.0.0.1:${endpoint.port}${LOCAL_RELAY_PATH}`, {
15719
+ method: "POST",
15720
+ headers: { authorization: `Bearer ${endpoint.token}`, "content-type": "application/json" },
15721
+ body: JSON.stringify({ method, profile: getProfile(), args }),
15722
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
15723
+ });
15724
+ if (res.ok) return (await res.json()).value;
15725
+ console.error(`[agent-relay] ${method} -> Host answered ${res.status}, running locally`);
15726
+ } catch (err) {
15727
+ console.error(`[agent-relay] ${method} -> ${err.message}, running locally`);
15728
+ }
15729
+ }
15730
+ return local[method]?.(...args);
15731
+ };
15732
+ return {
15733
+ ...local,
15734
+ ...Object.fromEntries(RELAYED_METHODS.map((method) => [method, relay2(method)]))
15735
+ };
15736
+ }
15737
+ async function handleLocalRelay(req, callbacks, token) {
15738
+ const given = Buffer.from(req.headers.get("authorization") ?? "");
15739
+ const expected = Buffer.from(`Bearer ${token}`);
15740
+ if (!token || given.length !== expected.length || !timingSafeEqual2(given, expected)) {
15741
+ return Response.json({ error: "unauthorized" }, { status: 401 });
15742
+ }
15743
+ const body = await req.json().catch(() => null);
15744
+ const method = RELAYED_METHODS.find((m) => m === body?.method);
15745
+ if (!body || !method || typeof body.profile !== "string" || !Array.isArray(body.args)) {
15746
+ return Response.json({ error: "bad request" }, { status: 400 });
15747
+ }
15748
+ const value = await runWithProfile(
15749
+ body.profile,
15750
+ async () => callbacks[method]?.(...body.args)
15751
+ );
15752
+ return Response.json({ value: value ?? null });
15753
+ }
14378
15754
  function send(ws, msg) {
14379
15755
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
14380
15756
  }
@@ -14387,7 +15763,7 @@ function parse(raw) {
14387
15763
  }
14388
15764
  async function verifyHostAuth(nonce, msg, legacyPublicKey) {
14389
15765
  if (msg.deviceId) {
14390
- if (!UUID.test(msg.deviceId)) return null;
15766
+ if (!UUID2.test(msg.deviceId)) return null;
14391
15767
  const device = await getDevice(msg.deviceId);
14392
15768
  if (!device || device.revokedAt) return null;
14393
15769
  const key = parseEd25519PublicKey(device.publicKey);
@@ -14411,7 +15787,7 @@ function verifySignature(nonce, signatureBase64, publicKey) {
14411
15787
  }
14412
15788
 
14413
15789
  // src/dashboard/device-auth-routes.ts
14414
- var UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15790
+ var UUID3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14415
15791
  function cleanText(value, max) {
14416
15792
  if (typeof value !== "string") return null;
14417
15793
  const trimmed = value.trim();
@@ -14440,7 +15816,7 @@ async function readBody(c) {
14440
15816
  return {};
14441
15817
  }
14442
15818
  }
14443
- async function sessionUserId(c) {
15819
+ async function sessionUserId2(c) {
14444
15820
  try {
14445
15821
  const { data } = await createRequestSupabaseClient(c).auth.getUser();
14446
15822
  return data.user?.id ?? null;
@@ -14453,8 +15829,12 @@ async function bearerDevice(c) {
14453
15829
  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : "";
14454
15830
  return token ? deviceForAccessToken(token) : null;
14455
15831
  }
15832
+ async function boundWorkspaceIds(publicKey, userId) {
15833
+ const existing = await findDeviceByKey(publicKey, userId);
15834
+ return new Set(existing ? (await listDeviceWorkspaces(existing)).map((w) => w.id) : []);
15835
+ }
14456
15836
  function createDeviceAuthRouter() {
14457
- const app = new Hono26();
15837
+ const app = new Hono28();
14458
15838
  app.post("/code", async (c) => {
14459
15839
  const body = await readBody(c);
14460
15840
  const publicKey = typeof body["public_key"] === "string" ? body["public_key"] : "";
@@ -14478,18 +15858,18 @@ function createDeviceAuthRouter() {
14478
15858
  app.post("/token", async (c) => {
14479
15859
  const body = await readBody(c);
14480
15860
  const code = typeof body["device_code"] === "string" ? body["device_code"] : "";
14481
- const pending2 = code ? await getDeviceCodeBySecret(code) : null;
14482
- if (!pending2 || Date.parse(pending2.expiresAt) <= Date.now()) {
15861
+ const pending3 = code ? await getDeviceCodeBySecret(code) : null;
15862
+ if (!pending3 || Date.parse(pending3.expiresAt) <= Date.now()) {
14483
15863
  return c.json({ error: "expired_token" }, 400);
14484
15864
  }
14485
- if (pending2.deniedAt) return c.json({ error: "access_denied" }, 400);
14486
- if (!pending2.approvedBy) {
14487
- const last = pending2.lastPolledAt ? Date.parse(pending2.lastPolledAt) : 0;
14488
- await markPolled(pending2.id);
15865
+ if (pending3.deniedAt) return c.json({ error: "access_denied" }, 400);
15866
+ if (!pending3.approvedBy) {
15867
+ const last = pending3.lastPolledAt ? Date.parse(pending3.lastPolledAt) : 0;
15868
+ await markPolled(pending3.id);
14489
15869
  const tooSoon = Date.now() - last < (POLL_INTERVAL_S - 1) * 1e3;
14490
15870
  return c.json({ error: tooSoon ? "slow_down" : "authorization_pending" }, 400);
14491
15871
  }
14492
- const device = await redeemDeviceCode(pending2);
15872
+ const device = await redeemDeviceCode(pending3);
14493
15873
  if (!device) return c.json({ error: "expired_token" }, 400);
14494
15874
  const { token, expiresInS } = await issueAccessToken(device.id);
14495
15875
  return c.json({
@@ -14503,14 +15883,14 @@ function createDeviceAuthRouter() {
14503
15883
  });
14504
15884
  app.post("/challenge", async (c) => {
14505
15885
  const body = await readBody(c);
14506
- const deviceId = typeof body["device_id"] === "string" && UUID2.test(body["device_id"]) ? body["device_id"] : "";
15886
+ const deviceId = typeof body["device_id"] === "string" && UUID3.test(body["device_id"]) ? body["device_id"] : "";
14507
15887
  const device = deviceId ? await getDevice(deviceId) : null;
14508
15888
  if (!device || device.revokedAt) return c.json({ error: "unknown_device" }, 401);
14509
15889
  return c.json({ nonce: await createChallenge(device.id) });
14510
15890
  });
14511
15891
  app.post("/refresh", async (c) => {
14512
15892
  const body = await readBody(c);
14513
- const deviceId = typeof body["device_id"] === "string" && UUID2.test(body["device_id"]) ? body["device_id"] : "";
15893
+ const deviceId = typeof body["device_id"] === "string" && UUID3.test(body["device_id"]) ? body["device_id"] : "";
14514
15894
  const nonce = typeof body["nonce"] === "string" ? body["nonce"] : "";
14515
15895
  const signature = typeof body["signature"] === "string" ? body["signature"] : "";
14516
15896
  const device = deviceId ? await getDevice(deviceId) : null;
@@ -14539,24 +15919,32 @@ function createDeviceAuthRouter() {
14539
15919
  return c.json({ ok: true });
14540
15920
  });
14541
15921
  app.get("/lookup", async (c) => {
14542
- const userId = await sessionUserId(c);
15922
+ const userId = await sessionUserId2(c);
14543
15923
  if (!userId) return c.json({ error: "not signed in" }, 401);
14544
15924
  const userCode = normalizeUserCode(c.req.query("user_code") ?? "");
14545
- const pending2 = userCode ? await getDeviceCodeByUserCode(userCode) : null;
14546
- if (!pending2 || Date.parse(pending2.expiresAt) <= Date.now() || pending2.approvedBy || pending2.deniedAt) {
15925
+ const pending3 = userCode ? await getDeviceCodeByUserCode(userCode) : null;
15926
+ if (!pending3 || Date.parse(pending3.expiresAt) <= Date.now() || pending3.approvedBy || pending3.deniedAt) {
14547
15927
  return c.json({ error: "That code is invalid, expired, or already used." }, 404);
14548
15928
  }
15929
+ const workspaces = await listAdminWorkspaces(userId);
15930
+ const hosts2 = await listWorkspaceHosts(
15931
+ workspaces.map((w) => w.id),
15932
+ pending3.publicKey
15933
+ );
15934
+ const bound = await boundWorkspaceIds(pending3.publicKey, userId);
14549
15935
  return c.json({
14550
- user_code: pending2.userCode,
14551
- name: pending2.name,
14552
- platform: pending2.platform,
14553
- ip: pending2.requesterIp,
14554
- created_at: pending2.createdAt,
14555
- workspaces: await listAdminWorkspaces(userId)
15936
+ user_code: pending3.userCode,
15937
+ name: pending3.name,
15938
+ platform: pending3.platform,
15939
+ ip: pending3.requesterIp,
15940
+ created_at: pending3.createdAt,
15941
+ // `host`: another machine this workspace already runs on, so it can't be
15942
+ // picked. `bound`: this machine already serves it, so there's nothing to pick.
15943
+ workspaces: workspaces.map((w) => ({ ...w, host: hosts2.get(w.id) ?? null, bound: bound.has(w.id) }))
14556
15944
  });
14557
15945
  });
14558
15946
  app.get("/workspace", async (c) => {
14559
- const userId = await sessionUserId(c);
15947
+ const userId = await sessionUserId2(c);
14560
15948
  if (!userId) return c.json({ error: "not signed in" }, 401);
14561
15949
  const slug = getProfile();
14562
15950
  const workspace = (await listAdminWorkspaces(userId)).find((w) => w.slug === slug);
@@ -14569,28 +15957,33 @@ function createDeviceAuthRouter() {
14569
15957
  });
14570
15958
  });
14571
15959
  app.post("/approve", async (c) => {
14572
- const userId = await sessionUserId(c);
15960
+ const userId = await sessionUserId2(c);
14573
15961
  if (!userId) return c.json({ error: "not signed in" }, 401);
14574
15962
  const body = await readBody(c);
14575
15963
  const userCode = normalizeUserCode(typeof body["user_code"] === "string" ? body["user_code"] : "");
14576
15964
  const decision = body["decision"];
14577
15965
  if (!userCode || decision !== "approve" && decision !== "deny") return c.json({ error: "invalid request" }, 400);
15966
+ const pending3 = await getDeviceCodeByUserCode(userCode);
15967
+ if (!pending3 || Date.parse(pending3.expiresAt) <= Date.now()) {
15968
+ return c.json({ error: "That code is invalid or expired." }, 404);
15969
+ }
14578
15970
  let workspaceIds = [];
14579
15971
  if (decision === "approve") {
14580
15972
  const requested = Array.isArray(body["workspace_ids"]) ? body["workspace_ids"] : [];
14581
15973
  const allowed = new Set((await listAdminWorkspaces(userId)).map((w) => w.id));
14582
15974
  workspaceIds = [...new Set(requested.filter((id) => typeof id === "string"))];
14583
- if (!workspaceIds.length) return c.json({ error: "Pick at least one workspace." }, 400);
14584
15975
  if (workspaceIds.some((id) => !allowed.has(id))) {
14585
15976
  return c.json({ error: "You can only add a device to workspaces you are an admin of." }, 403);
14586
15977
  }
15978
+ if (!workspaceIds.length && !(await boundWorkspaceIds(pending3.publicKey, userId)).size) {
15979
+ return c.json({ error: "Pick at least one workspace." }, 400);
15980
+ }
14587
15981
  }
14588
- const pending2 = await getDeviceCodeByUserCode(userCode);
14589
- if (!pending2 || Date.parse(pending2.expiresAt) <= Date.now()) {
14590
- return c.json({ error: "That code is invalid or expired." }, 404);
15982
+ if ((await listWorkspaceHosts(workspaceIds, pending3.publicKey)).size) {
15983
+ return c.json({ error: "That workspace already has a Host. Its Host cannot be replaced." }, 409);
14591
15984
  }
14592
15985
  const decided = await decideDeviceCode(
14593
- pending2.id,
15986
+ pending3.id,
14594
15987
  userId,
14595
15988
  decision === "approve" ? { approve: true, workspaceIds } : { approve: false }
14596
15989
  );
@@ -14604,7 +15997,7 @@ function createDeviceAuthRouter() {
14604
15997
 
14605
15998
  // src/dashboard/api.ts
14606
15999
  function createApiRouter(agent = {}, mode = "all") {
14607
- const api = new Hono27();
16000
+ const api = new Hono29();
14608
16001
  api.use("*", async (c, next) => {
14609
16002
  const known = await listKnownWorkspaces();
14610
16003
  const fromHost = profileFromHost(trustedHost(c), known);
@@ -14649,13 +16042,17 @@ function createApiRouter(agent = {}, mode = "all") {
14649
16042
  if (mode !== "host") registerDataRoutes(api, agent);
14650
16043
  if (mode !== "data") registerHostRoutes(api);
14651
16044
  if (mode === "data") {
14652
- api.get("/settings/agents", async (c) => c.json({ agents: await agent.getAgentIdentities?.() ?? [] }));
16045
+ api.get(
16046
+ "/settings/agents",
16047
+ async (c) => c.json({ agents: await agent.getAgentIdentities?.() ?? [] })
16048
+ );
14653
16049
  api.get("/channels/:id/skills", async (c) => {
14654
16050
  const { cwd, claudeConfigDir } = resolveChannelConfig(c.req.param("id"));
14655
16051
  return c.json({ skills: await agent.listChannelSkills?.({ cwd, claudeConfigDir }) ?? [] });
14656
16052
  });
14657
16053
  registerMirroredSkillRoutes(api, agent);
14658
- api.get("/channels/:id/docs/flat", async (c) => {
16054
+ registerRelayedOutputStylesRoutes(api, agent);
16055
+ api.get("/channels/:id/docs/flat", requireDocsAccess, async (c) => {
14659
16056
  const { cwd } = resolveChannelConfig(c.req.param("id"));
14660
16057
  const query = c.req.query("q") ?? "";
14661
16058
  const sort = c.req.query("sort") === "oldest" ? "oldest" : "newest";
@@ -14668,7 +16065,7 @@ function createApiRouter(agent = {}, mode = "all") {
14668
16065
  loadWorkspaceBranding(),
14669
16066
  Promise.resolve(agent.getLocalDashboardSettings?.()).then((v) => v ?? localDefaults())
14670
16067
  ]);
14671
- return c.json({ ...branding, ...local });
16068
+ return c.json({ ...branding, ...local, autoTitleDefaultPrompt: DEFAULT_TITLE_RULES });
14672
16069
  });
14673
16070
  api.put("/settings", async (c) => {
14674
16071
  const body = await c.req.json().catch(() => ({}));
@@ -14711,7 +16108,7 @@ function createApiRouter(agent = {}, mode = "all") {
14711
16108
  }
14712
16109
 
14713
16110
  // src/dashboard/app-icon-routes.ts
14714
- import { Hono as Hono28 } from "hono";
16111
+ import { Hono as Hono30 } from "hono";
14715
16112
  var NEUTRAL_MANIFEST = {
14716
16113
  id: "/",
14717
16114
  name: "Flyspace",
@@ -14749,7 +16146,7 @@ async function withResolvedProfile2(c, next) {
14749
16146
  return profile ? runWithProfile(profile, () => next()) : next();
14750
16147
  }
14751
16148
  function createAppIconRouter() {
14752
- const app = new Hono28();
16149
+ const app = new Hono30();
14753
16150
  app.get("/manifest.json", withResolvedProfile2, async (c) => {
14754
16151
  if (!await resolvedProfileHost(c)) return c.json(NEUTRAL_MANIFEST);
14755
16152
  const profile = getProfile();
@@ -14789,7 +16186,7 @@ function pngResponse(c, png) {
14789
16186
  }
14790
16187
 
14791
16188
  // src/dashboard/stripe-routes.ts
14792
- import { Hono as Hono29 } from "hono";
16189
+ import { Hono as Hono31 } from "hono";
14793
16190
  function planStatusFor(status) {
14794
16191
  const known = ["trialing", "active", "paused", "canceled"];
14795
16192
  return known.includes(status) ? status : null;
@@ -14832,7 +16229,7 @@ async function handleCardAdded(session) {
14832
16229
  }
14833
16230
  }
14834
16231
  function createStripeWebhookRouter() {
14835
- const stripeRouter = new Hono29();
16232
+ const stripeRouter = new Hono31();
14836
16233
  stripeRouter.post("/webhook", async (c) => {
14837
16234
  const secret = process.env["STRIPE_WEBHOOK_SECRET"];
14838
16235
  const signature = c.req.header("stripe-signature");
@@ -14943,7 +16340,7 @@ function startHostStatusBroadcast() {
14943
16340
 
14944
16341
  // src/dashboard/server.ts
14945
16342
  var DEFAULT_DASHBOARD_PORT = 4747;
14946
- var WEB_DIST = join19(paths.PACKAGE_ROOT, "web", "dist");
16343
+ var WEB_DIST = join21(paths.PACKAGE_ROOT, "web", "dist");
14947
16344
  var DEFAULT_DASHBOARD_HOST = "127.0.0.1";
14948
16345
  async function findFreePort(start, host = DEFAULT_DASHBOARD_HOST, attempts = 50) {
14949
16346
  for (let port = start; port < start + attempts; port++) {
@@ -14960,6 +16357,12 @@ async function findFreePort(start, host = DEFAULT_DASHBOARD_HOST, attempts = 50)
14960
16357
  async function startDashboardServer(preferredPort = DEFAULT_DASHBOARD_PORT, host = DEFAULT_DASHBOARD_HOST, mode = "all") {
14961
16358
  const servesHost = mode !== "data";
14962
16359
  loadEnvFiles();
16360
+ let lateCallbacks;
16361
+ let onHostConnected;
16362
+ const bootCallbacks = new Proxy({}, {
16363
+ get: (_t, prop) => lateCallbacks?.[prop]
16364
+ });
16365
+ const earlyHostSocket = mode === "host" && !hasSupabaseCredentials() && loadCredentials() ? (setSupabaseClient(createHostDbClient()), connectHostSocket(dataSocketUrl(), bootCallbacks, () => onHostConnected?.())) : void 0;
14963
16366
  if (servesHost) {
14964
16367
  await reconcileAll().catch(
14965
16368
  (err) => console.error("[dashboard] reconcileAll failed:", err.message)
@@ -14973,11 +16376,18 @@ async function startDashboardServer(preferredPort = DEFAULT_DASHBOARD_PORT, host
14973
16376
  await refreshAutomationRegistry();
14974
16377
  const stopAutomationPolling = startAutomationRegistryPolling();
14975
16378
  const chatAgent = servesHost ? new ChatAgent(loadChatAgentConfig()) : void 0;
14976
- const agent = chatAgent ? localAgentCallbacks(chatAgent) : remoteAgentCallbacks();
16379
+ const local = chatAgent ? localAgentCallbacks(chatAgent) : void 0;
16380
+ const agent = !local ? remoteAgentCallbacks() : mode === "all" ? relayedAgentCallbacks(local) : local;
16381
+ if (chatAgent) lateCallbacks = agent;
16382
+ if (chatAgent && mode === "host") {
16383
+ onHostConnected = () => void chatAgent.recoverMissed().catch((err) => console.error("[chat-agent] recoverMissed failed:", err.message));
16384
+ if (earlyHostSocket) onHostConnected();
16385
+ }
14977
16386
  if (servesHost) installUsageRecording();
14978
- const stopSkillSync = servesHost ? installSkillSync(listProfiles()) : () => {
16387
+ const mirrorsSkills = mode === "host" || process.env["FLYSPACE_SKILL_SYNC"] === "1";
16388
+ const stopSkillSync = mirrorsSkills ? installSkillSync(listProfiles()) : () => {
14979
16389
  };
14980
- const app = new Hono30();
16390
+ const app = new Hono32();
14981
16391
  app.use("*", async (c, next) => {
14982
16392
  await next();
14983
16393
  if (c.req.path.startsWith("/assets/")) {
@@ -14989,6 +16399,10 @@ async function startDashboardServer(preferredPort = DEFAULT_DASHBOARD_PORT, host
14989
16399
  app.use("*", corsForShell);
14990
16400
  app.route("/api", createApiRouter(agent, mode));
14991
16401
  app.route("/auth", createAuthConfirmRouter());
16402
+ let relayToken = null;
16403
+ if (mode === "host") {
16404
+ app.post(LOCAL_RELAY_PATH, (c) => handleLocalRelay(c.req.raw, agent, relayToken));
16405
+ }
14992
16406
  app.route("/stripe", createStripeWebhookRouter());
14993
16407
  if (!servesHost) {
14994
16408
  app.all(
@@ -15000,13 +16414,14 @@ async function startDashboardServer(preferredPort = DEFAULT_DASHBOARD_PORT, host
15000
16414
  app.route("/", createAppIconRouter());
15001
16415
  app.use("/*", serveStatic({ root: WEB_DIST }));
15002
16416
  app.get("/assets/*", (c) => c.text("stale asset \u2014 reload for the current build", 404));
15003
- const spaFallback = serveStatic({ path: join19(WEB_DIST, "index.html") });
16417
+ const spaFallback = serveStatic({ path: join21(WEB_DIST, "index.html") });
15004
16418
  app.get("*", (c, next) => {
15005
- if (!existsSync27(join19(WEB_DIST, "index.html"))) {
16419
+ if (!existsSync27(join21(WEB_DIST, "index.html"))) {
16420
+ const isCheckout = existsSync27(join21(paths.PACKAGE_ROOT, "web", "package.json"));
15006
16421
  return c.text(
15007
- `Dashboard UI build missing (${WEB_DIST}).
16422
+ isCheckout ? `Workspace UI build missing (${WEB_DIST}).
15008
16423
  Run \`pnpm --dir web build\` (or \`pnpm --dir web dev\`).
15009
- The JSON API is live under /api.`,
16424
+ The JSON API is live under /api.` : "This install has no bundled Workspace UI.\nOpen the hosted Workspace at https://flyspace.ai instead.\nThe JSON API is live under /api.",
15010
16425
  503
15011
16426
  );
15012
16427
  }
@@ -15015,10 +16430,12 @@ The JSON API is live under /api.`,
15015
16430
  }
15016
16431
  const port = await findFreePort(preferredPort, host);
15017
16432
  const server = serve({ fetch: app.fetch, port, hostname: host });
16433
+ if (mode === "host") relayToken = publishHostEndpoint(port);
15018
16434
  if (mode === "data") attachHostSocket(server);
15019
16435
  const stopHostStatusBroadcast = mode === "data" ? startHostStatusBroadcast() : void 0;
15020
- const hostSocketClient = mode === "host" && chatAgent ? connectHostSocket(dataSocketUrl(), localAgentCallbacks(chatAgent)) : void 0;
15021
- const stopPushWatcher = startPushWatcher();
16436
+ const hostSocketClient = earlyHostSocket ?? (mode === "host" && chatAgent ? connectHostSocket(dataSocketUrl(), localAgentCallbacks(chatAgent), () => onHostConnected?.()) : void 0);
16437
+ const stopPushWatcher = earlyHostSocket ? () => {
16438
+ } : startPushWatcher();
15022
16439
  const displayHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
15023
16440
  return {
15024
16441
  port,
@@ -15058,7 +16475,7 @@ async function uploadImage(client2, { channel, filePath, threadTs, text }) {
15058
16475
 
15059
16476
  // src/commands/slack-sync.ts
15060
16477
  import { WebClient as WebClient2 } from "@slack/web-api";
15061
- function createClient2() {
16478
+ function createClient3() {
15062
16479
  loadEnvFiles();
15063
16480
  const config = loadConfig();
15064
16481
  return new WebClient2(requireSlackBotToken(config));
@@ -15188,7 +16605,7 @@ function parseSince(args) {
15188
16605
  }
15189
16606
  async function runSlackSync(args) {
15190
16607
  const oldest = parseSince(args);
15191
- const client2 = createClient2();
16608
+ const client2 = createClient3();
15192
16609
  const auth = await client2.auth.test();
15193
16610
  const own = { userId: auth.user_id, botId: auth.bot_id };
15194
16611
  try {
@@ -15264,7 +16681,7 @@ function getArg2(args, flag) {
15264
16681
  const idx = args.indexOf(flag);
15265
16682
  return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : void 0;
15266
16683
  }
15267
- function createClient3() {
16684
+ function createClient4() {
15268
16685
  loadEnvFiles();
15269
16686
  const config = loadConfig();
15270
16687
  return new WebClient3(requireSlackBotToken(config));
@@ -15420,7 +16837,7 @@ async function threadReplies(client2, nameOrId, ts2) {
15420
16837
  async function postMessage(client2, args) {
15421
16838
  const channelArg = getArg2(args, "--channel");
15422
16839
  const textFilePath = getArg2(args, "--text-file");
15423
- const text = textFilePath ? readFileSync16(textFilePath, "utf8") : getArg2(args, "--text");
16840
+ const text = textFilePath ? readFileSync17(textFilePath, "utf8") : getArg2(args, "--text");
15424
16841
  const threadTs = getArg2(args, "--thread");
15425
16842
  const markdown = args.includes("--markdown");
15426
16843
  const broadcast = args.includes("--broadcast") && Boolean(threadTs);
@@ -15510,14 +16927,14 @@ function mask(token) {
15510
16927
  }
15511
16928
  function hasClaudeBinary() {
15512
16929
  try {
15513
- execFileSync3("claude", ["--version"], { stdio: "ignore" });
16930
+ execFileSync4("claude", ["--version"], { stdio: "ignore" });
15514
16931
  return true;
15515
16932
  } catch {
15516
16933
  return false;
15517
16934
  }
15518
16935
  }
15519
16936
  function defaultClaudeConfigDir() {
15520
- return process.env.CLAUDE_CONFIG_DIR || resolve7(homedir8(), ".claude");
16937
+ return process.env.CLAUDE_CONFIG_DIR || resolve7(homedir9(), ".claude");
15521
16938
  }
15522
16939
  function hasClaudeCredentials(configDir2) {
15523
16940
  if (existsSync28(resolve7(configDir2, ".credentials.json"))) {
@@ -15525,7 +16942,7 @@ function hasClaudeCredentials(configDir2) {
15525
16942
  }
15526
16943
  if (process.platform === "darwin") {
15527
16944
  try {
15528
- execFileSync3(
16945
+ execFileSync4(
15529
16946
  "security",
15530
16947
  ["find-generic-password", "-s", "Claude Code-credentials"],
15531
16948
  { stdio: "ignore" }
@@ -15557,7 +16974,7 @@ async function ensureClaudeCodeReady() {
15557
16974
  console.log(" Flyspace spawns Claude Code as a subprocess. Without credentials, the bot");
15558
16975
  console.log(" will prompt /login inside Slack at runtime and you cannot complete the");
15559
16976
  console.log(" browser sign-in from there.\n");
15560
- if (claudeConfigDir !== resolve7(homedir8(), ".claude")) {
16977
+ if (claudeConfigDir !== resolve7(homedir9(), ".claude")) {
15561
16978
  console.log(" Tip: run with the same CLAUDE_CONFIG_DIR you use for this Claude Code install:");
15562
16979
  console.log(` \x1B[1mCLAUDE_CONFIG_DIR=${claudeConfigDir} claude\x1B[0m
15563
16980
  `);
@@ -15577,7 +16994,7 @@ async function ensureClaudeCodeReady() {
15577
16994
  }
15578
16995
  function getManifestYaml(botName) {
15579
16996
  const manifestPath = resolve7(paths.PACKAGE_ROOT, "slack-app-manifest.yml");
15580
- const raw = readFileSync16(manifestPath, "utf-8");
16997
+ const raw = readFileSync17(manifestPath, "utf-8");
15581
16998
  return raw.replace(/name: Custie/g, `name: ${botName}`);
15582
16999
  }
15583
17000
  async function stepPersonalise() {
@@ -15994,7 +17411,7 @@ function writeConfigFile(config) {
15994
17411
  `OWNER_USER_ID=${config.ownerUserId}`,
15995
17412
  ``
15996
17413
  ].join("\n");
15997
- writeFileSync9(paths.CONFIG_FILE, envContent);
17414
+ writeFileSync10(paths.CONFIG_FILE, envContent);
15998
17415
  success4(`Config written to ${paths.CONFIG_FILE}`);
15999
17416
  }
16000
17417
  function printNextSteps() {
@@ -16011,8 +17428,8 @@ function copyDefaultPrompt(botName) {
16011
17428
  if (!existsSync28(paths.PROMPT_FILE)) {
16012
17429
  const defaultPrompt = resolve7(paths.PACKAGE_ROOT, "system.default.md");
16013
17430
  if (existsSync28(defaultPrompt)) {
16014
- const content = readFileSync16(defaultPrompt, "utf-8").replace(/\{\{botName\}\}/g, botName);
16015
- writeFileSync9(paths.PROMPT_FILE, content);
17431
+ const content = readFileSync17(defaultPrompt, "utf-8").replace(/\{\{botName\}\}/g, botName);
17432
+ writeFileSync10(paths.PROMPT_FILE, content);
16016
17433
  success4(`Default prompt copied to ${paths.PROMPT_FILE}`);
16017
17434
  }
16018
17435
  }
@@ -16099,12 +17516,12 @@ async function runSlackCmd(args) {
16099
17516
  break;
16100
17517
  }
16101
17518
  case "channels": {
16102
- const client2 = createClient3();
17519
+ const client2 = createClient4();
16103
17520
  await listChannels2(client2, args.includes("--refresh"));
16104
17521
  break;
16105
17522
  }
16106
17523
  case "users": {
16107
- const client2 = createClient3();
17524
+ const client2 = createClient4();
16108
17525
  await listUsers(client2, args.includes("--refresh"));
16109
17526
  break;
16110
17527
  }
@@ -16113,7 +17530,7 @@ async function runSlackCmd(args) {
16113
17530
  console.error("Usage: flyspace slack channel-info <name-or-id>");
16114
17531
  process.exit(1);
16115
17532
  }
16116
- const client2 = createClient3();
17533
+ const client2 = createClient4();
16117
17534
  await channelInfo(client2, args[1]);
16118
17535
  break;
16119
17536
  }
@@ -16122,7 +17539,7 @@ async function runSlackCmd(args) {
16122
17539
  console.error("Usage: flyspace slack user-info <name-or-id>");
16123
17540
  process.exit(1);
16124
17541
  }
16125
- const client2 = createClient3();
17542
+ const client2 = createClient4();
16126
17543
  await userInfo(client2, args[1]);
16127
17544
  break;
16128
17545
  }
@@ -16131,7 +17548,7 @@ async function runSlackCmd(args) {
16131
17548
  console.error("Usage: flyspace slack history <name-or-id> [--today] [--limit n]");
16132
17549
  process.exit(1);
16133
17550
  }
16134
- const client2 = createClient3();
17551
+ const client2 = createClient4();
16135
17552
  await channelHistory(client2, args[1], args.slice(2));
16136
17553
  break;
16137
17554
  }
@@ -16140,12 +17557,12 @@ async function runSlackCmd(args) {
16140
17557
  console.error("Usage: flyspace slack thread <name-or-id> <ts>");
16141
17558
  process.exit(1);
16142
17559
  }
16143
- const client2 = createClient3();
17560
+ const client2 = createClient4();
16144
17561
  await threadReplies(client2, args[1], args[2]);
16145
17562
  break;
16146
17563
  }
16147
17564
  case "post": {
16148
- const client2 = createClient3();
17565
+ const client2 = createClient4();
16149
17566
  await postMessage(client2, args.slice(1));
16150
17567
  break;
16151
17568
  }
@@ -16156,14 +17573,14 @@ async function runSlackCmd(args) {
16156
17573
  console.error("Usage: flyspace slack delete --channel <channel-id> --ts <timestamp>");
16157
17574
  process.exit(1);
16158
17575
  }
16159
- const client2 = createClient3();
17576
+ const client2 = createClient4();
16160
17577
  const channelId = await resolveChannelId2(client2, channel);
16161
17578
  await client2.chat.delete({ channel: channelId, ts: ts2 });
16162
17579
  console.log(`Message deleted (channel: ${channelId}, ts: ${ts2}).`);
16163
17580
  break;
16164
17581
  }
16165
17582
  case "upload": {
16166
- const client2 = createClient3();
17583
+ const client2 = createClient4();
16167
17584
  await uploadImageCmd(client2, args.slice(1));
16168
17585
  break;
16169
17586
  }
@@ -16358,7 +17775,7 @@ async function runMcpCmd(args) {
16358
17775
  loadEnvFiles();
16359
17776
  switch (server) {
16360
17777
  case "threads": {
16361
- const { startThreadsServer } = await import("./threads-EQGJZF44.js");
17778
+ const { startThreadsServer } = await import("./threads-PAG4PKHR.js");
16362
17779
  await startThreadsServer();
16363
17780
  return;
16364
17781
  }
@@ -16373,7 +17790,7 @@ async function runMcpCmd(args) {
16373
17790
  import { spawn as spawn5, spawnSync } from "child_process";
16374
17791
  import { existsSync as existsSync29 } from "fs";
16375
17792
  import { platform as platform6 } from "os";
16376
- import { join as join20 } from "path";
17793
+ import { join as join22 } from "path";
16377
17794
  function flagValue(args, name) {
16378
17795
  const eq = args.find((a) => a.startsWith(`--${name}=`));
16379
17796
  if (eq) return eq.slice(`--${name}=`.length);
@@ -16388,7 +17805,8 @@ function openBrowser(url) {
16388
17805
  }
16389
17806
  }
16390
17807
  function ensureWebBuild() {
16391
- if (existsSync29(join20(paths.PACKAGE_ROOT, "web", "dist", "index.html"))) return;
17808
+ if (existsSync29(join22(paths.PACKAGE_ROOT, "web", "dist", "index.html"))) return;
17809
+ if (!existsSync29(join22(paths.PACKAGE_ROOT, "web", "package.json"))) return;
16392
17810
  console.log(" web/dist missing \u2014 building the dashboard UI (pnpm --dir web build)...");
16393
17811
  const result = spawnSync("pnpm", ["--dir", "web", "build"], {
16394
17812
  cwd: paths.PACKAGE_ROOT,
@@ -16411,6 +17829,7 @@ async function runDashboard(args) {
16411
17829
  if (mode !== "all" && mode !== "host") {
16412
17830
  throw new Error(`--mode must be "all" or "host" (got "${mode}")`);
16413
17831
  }
17832
+ if (mode === "host") acquireHostLock();
16414
17833
  ensureWebBuild();
16415
17834
  const server = await startDashboardServer(preferredPort, host, mode);
16416
17835
  const profile = getProfile();
@@ -16436,7 +17855,7 @@ async function runDashboard(args) {
16436
17855
  }
16437
17856
 
16438
17857
  // src/commands/workspace.ts
16439
- import { existsSync as existsSync30, mkdirSync as mkdirSync9, writeFileSync as writeFileSync10 } from "fs";
17858
+ import { existsSync as existsSync30, mkdirSync as mkdirSync10, writeFileSync as writeFileSync11 } from "fs";
16440
17859
  var USAGE5 = `
16441
17860
  Usage: flyspace workspace <subcommand> [options]
16442
17861
 
@@ -16483,6 +17902,7 @@ async function provisionSupabase(slug, adminEmail, baseDomain, allowInvite) {
16483
17902
  console.log(
16484
17903
  existing ? ` workspace row exists (${workspace.id})` : ` workspace row created (${workspace.id})`
16485
17904
  );
17905
+ await bindThisHost(workspace.id);
16486
17906
  if (await getMembershipByEmail(workspace.id, adminEmail)) {
16487
17907
  console.log(` membership exists for ${adminEmail}`);
16488
17908
  return;
@@ -16507,6 +17927,16 @@ async function provisionSupabase(slug, adminEmail, baseDomain, allowInvite) {
16507
17927
  console.log(` sign-in link sent to ${adminEmail}`);
16508
17928
  }
16509
17929
  }
17930
+ async function bindThisHost(workspaceId) {
17931
+ const deviceId = loadCredentials()?.deviceId;
17932
+ const device = deviceId ? await getDevice(deviceId) : null;
17933
+ if (!device || device.revokedAt) {
17934
+ console.log(" no paired Host on this machine, skipping device binding");
17935
+ return;
17936
+ }
17937
+ await bindDeviceWorkspace(device.id, workspaceId, device.userId);
17938
+ console.log(` bound to Host "${device.name}" (restart it to pick up the new workspace)`);
17939
+ }
16510
17940
  function provisionLocal(botName) {
16511
17941
  ensureDirs();
16512
17942
  console.log(` config dir ready (${paths.CONFIG_DIR})`);
@@ -16514,7 +17944,7 @@ function provisionLocal(botName) {
16514
17944
  if (existsSync30(paths.CONFIG_FILE)) {
16515
17945
  console.log(" config.env exists");
16516
17946
  } else {
16517
- writeFileSync10(paths.CONFIG_FILE, `BOT_NAME=${botName}
17947
+ writeFileSync11(paths.CONFIG_FILE, `BOT_NAME=${botName}
16518
17948
  `);
16519
17949
  console.log(` config.env written (${paths.CONFIG_FILE})`);
16520
17950
  }
@@ -16523,13 +17953,13 @@ async function provisionChannels(specs) {
16523
17953
  if (specs.length === 0) return;
16524
17954
  await refreshChannelRegistry();
16525
17955
  for (const [index, spec] of specs.entries()) {
16526
- if (findChannel(spec.name)) {
17956
+ if (getProfileChannelRegistry().some((c) => c.name === spec.name)) {
16527
17957
  console.log(` channel "${spec.name}" exists`);
16528
17958
  continue;
16529
17959
  }
16530
17960
  const cwd = expandPath(spec.cwd);
16531
17961
  if (!existsSync30(cwd)) {
16532
- mkdirSync9(cwd, { recursive: true });
17962
+ mkdirSync10(cwd, { recursive: true });
16533
17963
  console.log(` created cwd ${cwd}`);
16534
17964
  }
16535
17965
  await createChannel({ name: spec.name, cwd, position: index });
@@ -17034,7 +18464,7 @@ async function main() {
17034
18464
  break;
17035
18465
  case "-v":
17036
18466
  case "--version":
17037
- printRaw("0.1.1");
18467
+ printRaw("0.1.4");
17038
18468
  break;
17039
18469
  case "-h":
17040
18470
  case "--help":