flyspace-cli 0.1.32 → 0.1.33

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.
@@ -102,6 +102,9 @@ function loadConfig() {
102
102
  const autoRespondChannelIds = new Set(
103
103
  (process.env["AUTO_RESPOND_CHANNEL_IDS"] ?? "").split(",").map((s) => s.trim()).filter(Boolean)
104
104
  );
105
+ const slackEnglishOnlyExemptChannelIds = new Set(
106
+ (process.env["SLACK_ENGLISH_ONLY_EXEMPT_CHANNEL_IDS"] ?? "").split(",").map((s) => s.trim()).filter(Boolean)
107
+ );
105
108
  const slack = slackConfigured();
106
109
  return {
107
110
  slackBotToken: slack ? requireEnv("SLACK_BOT_TOKEN") : void 0,
@@ -118,7 +121,9 @@ function loadConfig() {
118
121
  maxBudgetUsd: parseBudget(process.env["MAX_BUDGET_USD"]),
119
122
  ownerUserId,
120
123
  autoRespondChannelIds,
121
- codexModel: process.env["CUSTIE_CODEX_MODEL"]?.trim() || DEFAULT_CODEX_MODEL
124
+ codexModel: process.env["CUSTIE_CODEX_MODEL"]?.trim() || DEFAULT_CODEX_MODEL,
125
+ slackEnglishOnly: process.env["SLACK_ENGLISH_ONLY"] === "true",
126
+ slackEnglishOnlyExemptChannelIds
122
127
  };
123
128
  }
124
129
 
@@ -356,6 +361,7 @@ function rowToChannel(row) {
356
361
  isDefault: row.is_default === true,
357
362
  profile: row.profile ?? "default",
358
363
  archivedAt: row.archived_at,
364
+ greetedAt: row.greeted_at,
359
365
  createdAt: row.created_at,
360
366
  updatedAt: row.updated_at
361
367
  };
@@ -616,6 +622,12 @@ async function updateChannelById(id, patch) {
616
622
  await refreshChannelRegistry();
617
623
  return rowToChannel(data);
618
624
  }
625
+ async function markChannelGreeted(id) {
626
+ const { data, error } = await getSupabaseClient().from("channels").update({ greeted_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).is("greeted_at", null).select("id");
627
+ if (error) throw new Error(error.message);
628
+ await refreshChannelRegistry();
629
+ return (data ?? []).length > 0;
630
+ }
619
631
  async function updateChannelsMcpConnections(updates) {
620
632
  if (!updates.length) return;
621
633
  const supabase = getSupabaseClient();
@@ -2575,14 +2587,15 @@ var workspaceCache = /* @__PURE__ */ new Map();
2575
2587
  async function getWorkspaceBySlug(slug) {
2576
2588
  const cached = workspaceCache.get(slug);
2577
2589
  if (cached && cached.expiresAt > Date.now()) return cached.workspace;
2578
- const { data, error } = await getSupabaseClient().from("workspaces").select("id, slug, agent_connected_at, projects_setup_at, plan").eq("slug", slug).maybeSingle();
2590
+ const { data, error } = await getSupabaseClient().from("workspaces").select("id, slug, agent_connected_at, projects_setup_at, plan, archived_at").eq("slug", slug).maybeSingle();
2579
2591
  if (error) throw new Error(`getWorkspaceBySlug(${slug}): ${error.message}`);
2580
2592
  const workspace = data ? {
2581
2593
  id: data.id,
2582
2594
  slug: data.slug,
2583
2595
  agentConnectedAt: data.agent_connected_at,
2584
2596
  projectsSetupAt: data.projects_setup_at,
2585
- plan: data.plan ?? null
2597
+ plan: data.plan ?? null,
2598
+ archivedAt: data.archived_at ?? null
2586
2599
  } : null;
2587
2600
  workspaceCache.set(slug, { workspace, expiresAt: Date.now() + WORKSPACE_CACHE_TTL_MS });
2588
2601
  return workspace;
@@ -2597,29 +2610,70 @@ async function listWorkspaces() {
2597
2610
  if (error) throw new Error(`listWorkspaces: ${error.message}`);
2598
2611
  return data ?? [];
2599
2612
  }
2613
+ var TRIAL_DAYS = 14;
2600
2614
  async function listWorkspaceSummaries() {
2601
2615
  const db = getSupabaseClient();
2602
- const [workspaces, settings, channels, memberships] = await Promise.all([
2603
- db.from("workspaces").select("id, slug"),
2604
- db.from("workspace_settings").select("profile, display_name"),
2616
+ const [workspaces, settings, channels, memberships, activity] = await Promise.all([
2617
+ db.from("workspaces").select("id, slug, created_at, agent_connected_at, plan, plan_status, archived_at"),
2618
+ db.from("workspace_settings").select("profile, display_name, logo"),
2605
2619
  db.from("channels").select("profile").is("archived_at", null),
2606
- db.from("memberships").select("workspace_id").eq("status", "active")
2620
+ db.from("memberships").select("workspace_id, user_id, email, handle, role, accepted_at").eq("status", "active").eq("is_support", false).order("invited_at", { ascending: true }),
2621
+ db.rpc("workspace_last_activity")
2607
2622
  ]);
2608
- for (const r of [workspaces, settings, channels, memberships]) {
2623
+ for (const r of [workspaces, settings, channels, memberships, activity]) {
2609
2624
  if (r.error) throw new Error(`listWorkspaceSummaries: ${r.error.message}`);
2610
2625
  }
2611
2626
  const displayNames = new Map(
2612
2627
  (settings.data ?? []).map((s) => [s.profile, s.display_name])
2613
2628
  );
2629
+ const logos = new Map(
2630
+ (settings.data ?? []).map((s) => [s.profile, s.logo])
2631
+ );
2632
+ const lastActivity = new Map(
2633
+ (activity.data ?? []).map((a) => [
2634
+ a.profile,
2635
+ a.last_activity_at
2636
+ ])
2637
+ );
2614
2638
  const tally = (keys) => keys.reduce((m, k) => m.set(k, (m.get(k) ?? 0) + 1), /* @__PURE__ */ new Map());
2615
2639
  const channelCounts = tally((channels.data ?? []).map((c) => c.profile));
2616
2640
  const memberCounts = tally((memberships.data ?? []).map((m) => m.workspace_id));
2617
- return (workspaces.data ?? []).map((w) => ({
2618
- ...w,
2619
- displayName: displayNames.get(w.slug) || null,
2620
- channelCount: channelCounts.get(w.slug) ?? 0,
2621
- memberCount: memberCounts.get(w.id) ?? 0
2622
- }));
2641
+ const owners = /* @__PURE__ */ new Map();
2642
+ for (const m of memberships.data ?? []) {
2643
+ if (m.role !== "admin" || owners.has(m.workspace_id)) continue;
2644
+ owners.set(m.workspace_id, {
2645
+ userId: m.user_id,
2646
+ email: m.email,
2647
+ handle: m.handle,
2648
+ joinedAt: m.accepted_at ?? null
2649
+ });
2650
+ }
2651
+ return (workspaces.data ?? []).map((w) => {
2652
+ const createdAt = w.created_at;
2653
+ const planStatus = w.plan_status ?? null;
2654
+ return {
2655
+ id: w.id,
2656
+ slug: w.slug,
2657
+ createdAt,
2658
+ agentConnectedAt: w.agent_connected_at,
2659
+ plan: w.plan ?? null,
2660
+ planStatus,
2661
+ trialEndsAt: planStatus === "trialing" ? new Date(new Date(createdAt).getTime() + TRIAL_DAYS * 864e5).toISOString() : null,
2662
+ archivedAt: w.archived_at ?? null,
2663
+ displayName: displayNames.get(w.slug) || null,
2664
+ logo: logos.get(w.slug) || null,
2665
+ channelCount: channelCounts.get(w.slug) ?? 0,
2666
+ memberCount: memberCounts.get(w.id) ?? 0,
2667
+ lastActivityAt: lastActivity.get(w.slug) ?? null,
2668
+ owner: owners.get(w.id) ?? null
2669
+ };
2670
+ });
2671
+ }
2672
+ async function setWorkspaceArchived(workspaceId, archived) {
2673
+ const { data, error } = await getSupabaseClient().from("workspaces").update({ archived_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", workspaceId).select("slug");
2674
+ if (error) throw new Error(`setWorkspaceArchived(${workspaceId}): ${error.message}`);
2675
+ if (!data?.length) throw new Error("workspace not found");
2676
+ for (const row of data) workspaceCache.delete(row.slug);
2623
2677
  }
2624
2678
  async function createWorkspace(slug) {
2625
2679
  const { data, error } = await getSupabaseClient().from("workspaces").insert({ slug }).select("id, slug").single();
@@ -5383,6 +5437,29 @@ async function forkThreadWithSummary(ctx, deps) {
5383
5437
  return { forked: true, newThreadTs };
5384
5438
  }
5385
5439
 
5440
+ // src/slack/reply-language.ts
5441
+ var CJK = /[぀-ヿ㐀-䶿一-鿿가-힯豈-﫿]/g;
5442
+ var CJK_THRESHOLD = 10;
5443
+ var REWRITE_PROMPT = "[system: this Slack channel is English-only. Your previous reply was NOT posted because it was not in English. Rewrite that same reply in English (en-AU spelling): same content, same structure, and keep any [BUTTONS: ...] marker with its labels translated. Do not run any tools and do not mention this rewrite. Output only the rewritten reply.]";
5444
+ function isEnglishOnlyChannel(config, channelId) {
5445
+ return config.slackEnglishOnly && !config.slackEnglishOnlyExemptChannelIds.has(channelId);
5446
+ }
5447
+ function isNonEnglishReply(text) {
5448
+ const prose = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
5449
+ return (prose.match(CJK)?.length ?? 0) >= CJK_THRESHOLD;
5450
+ }
5451
+ async function enforceEnglishReply(text, rewrite) {
5452
+ if (!isNonEnglishReply(text)) return text;
5453
+ try {
5454
+ const rewritten = await rewrite(REWRITE_PROMPT);
5455
+ if (rewritten?.trim()) return rewritten;
5456
+ console.error("[reply-language] rewrite returned nothing \u2014 posting the original");
5457
+ } catch (err) {
5458
+ console.error("[reply-language] rewrite failed \u2014 posting the original:", err);
5459
+ }
5460
+ return text;
5461
+ }
5462
+
5386
5463
  // src/store/identity-map.ts
5387
5464
  function identityHandleFor(slackUserId) {
5388
5465
  const raw = process.env.SLACK_IDENTITY_MAP;
@@ -6549,7 +6626,12 @@ ${trimmedDetail.slice(0, 1500)}
6549
6626
  if (codexResponse.sessionId) {
6550
6627
  store.saveSession(channelId, sessionKey, codexResponse.sessionId, "codex");
6551
6628
  }
6552
- const { cleanedText: cleanedText2, buttons: buttons2 } = extractButtons(codexResponse.text);
6629
+ const codexSessionId = codexResponse.sessionId;
6630
+ const codexText = codexSessionId && isEnglishOnlyChannel(config, channelId) ? await enforceEnglishReply(codexResponse.text, async (p) => {
6631
+ const r = await askCodex(p, cwd, codexOptions, codexSessionId);
6632
+ return r.isError ? null : r.text;
6633
+ }) : codexResponse.text;
6634
+ const { cleanedText: cleanedText2, buttons: buttons2 } = extractButtons(codexText);
6553
6635
  const actionsBlock2 = buttons2 ? buildActionsBlock(buttons2) : null;
6554
6636
  const rtBlocks2 = markdownToBlocks(cleanedText2);
6555
6637
  const messages2 = rtBlocks2.length > 0 ? rtBlocks2.map((b) => ({ blocks: [b], text: blockToFallbackText(b) })) : splitMessage(toSlackMarkdown(cleanedText2)).map((text) => ({ text }));
@@ -6740,7 +6822,21 @@ retry: ${retryDetail}`);
6740
6822
  } else {
6741
6823
  store.saveSession(channelId, sessionKey, resolved.sessionId);
6742
6824
  }
6743
- const { cleanedText, buttons } = extractButtons(resolved.text);
6825
+ const replyText = !resolved.isError && resolved.sessionId && isEnglishOnlyChannel(config, channelId) ? await enforceEnglishReply(resolved.text, async (p) => {
6826
+ const r = await askClaude(
6827
+ p,
6828
+ cwd,
6829
+ botName,
6830
+ askOptions,
6831
+ claudeConfigDir,
6832
+ resolved.sessionId,
6833
+ claudeCodeOauthToken
6834
+ );
6835
+ if (r.isError) return null;
6836
+ store.saveSession(channelId, sessionKey, r.sessionId);
6837
+ return r.text;
6838
+ }) : resolved.text;
6839
+ const { cleanedText, buttons } = extractButtons(replyText);
6744
6840
  const actionsBlock = buttons ? buildActionsBlock(buttons) : null;
6745
6841
  const rtBlocks = markdownToBlocks(cleanedText);
6746
6842
  const messages = rtBlocks.length > 0 ? rtBlocks.map((b) => ({ blocks: [b], text: blockToFallbackText(b) })) : splitMessage(toSlackMarkdown(cleanedText)).map((text) => ({ text }));
@@ -6757,7 +6853,7 @@ retry: ${retryDetail}`);
6757
6853
  }
6758
6854
  if (debug4) {
6759
6855
  console.log(
6760
- `[response] length=${resolved.text.length} messages=${messages.length} buttons=${buttons?.length ?? 0}`
6856
+ `[response] length=${replyText.length} messages=${messages.length} buttons=${buttons?.length ?? 0}`
6761
6857
  );
6762
6858
  }
6763
6859
  await clearReaction();
@@ -8975,6 +9071,7 @@ export {
8975
9071
  createChannel,
8976
9072
  DefaultChannelError,
8977
9073
  updateChannelById,
9074
+ markChannelGreeted,
8978
9075
  updateChannelsMcpConnections,
8979
9076
  ensureAgentDmChannel,
8980
9077
  ensureGeneralChannel,
@@ -9035,7 +9132,9 @@ export {
9035
9132
  getWorkspaceBySlug,
9036
9133
  getWorkspaceById,
9037
9134
  listWorkspaces,
9135
+ TRIAL_DAYS,
9038
9136
  listWorkspaceSummaries,
9137
+ setWorkspaceArchived,
9039
9138
  createWorkspace,
9040
9139
  deleteWorkspace,
9041
9140
  setWorkspaceStripeSubscription,
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  SKIP_SUBTYPES,
19
19
  SLACK_ENV,
20
20
  SessionStore,
21
+ TRIAL_DAYS,
21
22
  UsageStore,
22
23
  activateMembership,
23
24
  agentLabel,
@@ -118,6 +119,7 @@ import {
118
119
  loadFullDashboardSettings,
119
120
  loadWorkspaceBranding,
120
121
  loadWorkspacePrompt,
122
+ markChannelGreeted,
121
123
  markInviteResent,
122
124
  markMemoryUsed,
123
125
  markWorkspaceGreeted,
@@ -159,6 +161,7 @@ import {
159
161
  setMembershipHandle,
160
162
  setMemoryAutoPaused,
161
163
  setOutputStyleDefault,
164
+ setWorkspaceArchived,
162
165
  setWorkspaceStripeSubscription,
163
166
  slackConfigured,
164
167
  startAutomationRegistryPolling,
@@ -184,7 +187,7 @@ import {
184
187
  warnOnBrokenChannelCwds,
185
188
  workspaceSkillsDir,
186
189
  writeSkillFile
187
- } from "./chunk-6YFDWUNF.js";
190
+ } from "./chunk-KGX44L3V.js";
188
191
  import {
189
192
  firstReplyBody,
190
193
  getThread,
@@ -1858,7 +1861,7 @@ async function runAuthCmd(args) {
1858
1861
  }
1859
1862
 
1860
1863
  // ../api/src/version.ts
1861
- var CLI_VERSION = true ? "0.1.32" : null;
1864
+ var CLI_VERSION = true ? "0.1.33" : null;
1862
1865
  function isOlderVersion(a, b) {
1863
1866
  const parse = (v) => v.replace(/^v/, "").split("-")[0].split(".").map(Number);
1864
1867
  const [pa, pb] = [parse(a), parse(b)];
@@ -2468,7 +2471,7 @@ function npmEnv(target) {
2468
2471
  return nodeBin ? { ...process.env, PATH: `${nodeBin}:${process.env.PATH ?? ""}` } : process.env;
2469
2472
  }
2470
2473
  async function runUpgrade() {
2471
- const current = "0.1.32";
2474
+ const current = "0.1.33";
2472
2475
  console.log(`Current version: ${current}`);
2473
2476
  console.log("Checking for updates...\n");
2474
2477
  try {
@@ -4144,7 +4147,7 @@ async function startWorkspaceTrial(workspace, plan, ownerEmail) {
4144
4147
  const subscription = await stripe.subscriptions.create({
4145
4148
  customer: customer.id,
4146
4149
  items: [{ price: priceId }],
4147
- trial_period_days: 14,
4150
+ trial_period_days: TRIAL_DAYS,
4148
4151
  trial_settings: { end_behavior: { missing_payment_method: "pause" } },
4149
4152
  metadata: { workspace_id: workspace.id, plan }
4150
4153
  });
@@ -6583,7 +6586,7 @@ var requireDocsAccess = async (c, next) => {
6583
6586
  };
6584
6587
 
6585
6588
  // ../api/src/dashboard/api.ts
6586
- import { Hono as Hono29 } from "hono";
6589
+ import { Hono as Hono30 } from "hono";
6587
6590
 
6588
6591
  // ../api/src/dashboard/public-share-routes.ts
6589
6592
  import { existsSync as existsSync17, readFileSync as readFileSync16, statSync as statSync3 } from "fs";
@@ -8735,7 +8738,17 @@ function pruneExpired(now) {
8735
8738
  }
8736
8739
  var SUPPORT_NAME = "Flyspace Support";
8737
8740
  var SUPPORT_AVATAR_URL = "/logo/flyspace-support.svg";
8741
+ function workspaceArchivedError() {
8742
+ return new HTTPException2(403, {
8743
+ res: Response.json({ error: "workspace archived", archived: true }, { status: 403 })
8744
+ });
8745
+ }
8738
8746
  async function resolveAuthContext(c, opts = {}) {
8747
+ const ctx = await resolveAuthContextCached(c, opts);
8748
+ if (ctx?.archived && !ctx.isSupport) throw workspaceArchivedError();
8749
+ return ctx;
8750
+ }
8751
+ async function resolveAuthContextCached(c, opts) {
8739
8752
  const cookie = c.req?.header("Cookie") ?? "";
8740
8753
  if (!cookie) return resolveAuthContextUncached(c);
8741
8754
  const key = `${getProfile()}\0${cookie}`;
@@ -8800,7 +8813,8 @@ async function resolveAuthContextUncached(c) {
8800
8813
  passwordSet: Boolean(metadata.passwordSetAt),
8801
8814
  agentConnectedAt: workspace.agentConnectedAt ?? null,
8802
8815
  projectsSetupAt: workspace.projectsSetupAt ?? null,
8803
- plan: workspace.plan ?? null
8816
+ plan: workspace.plan ?? null,
8817
+ ...workspace.archivedAt ? { archived: true } : {}
8804
8818
  };
8805
8819
  }
8806
8820
 
@@ -9116,11 +9130,19 @@ function loadHostPrivateKey(path = hostPrivateKeyPath()) {
9116
9130
  // ../api/src/dashboard/uploads-routes.ts
9117
9131
  import { randomUUID as randomUUID2 } from "crypto";
9118
9132
  import { Hono as Hono7 } from "hono";
9133
+ import sharp4 from "sharp";
9119
9134
  import { PutObjectCommand as PutObjectCommand2, GetObjectCommand as GetObjectCommand3 } from "@aws-sdk/client-s3";
9120
9135
  import { getSignedUrl as getSignedUrl3 } from "@aws-sdk/s3-request-presigner";
9121
9136
  var MAX_FILE_BYTES = 10 * 1024 * 1024;
9122
9137
  var PRESIGN_TTL_SECONDS2 = 300;
9123
9138
  var KEY_PREFIX = "chat";
9139
+ var RESIZABLE = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/avif"]);
9140
+ var FULL_EDGE = 2560;
9141
+ var THUMB_EDGE = 480;
9142
+ var WEBP_QUALITY = 80;
9143
+ function toWebp(input2, edge) {
9144
+ return sharp4(input2).rotate().resize({ width: edge, height: edge, fit: "inside", withoutEnlargement: true }).webp({ quality: WEBP_QUALITY }).toBuffer();
9145
+ }
9124
9146
  var READABLE_PREFIXES = [KEY_PREFIX, SCRATCH_PREFIX];
9125
9147
  function sanitizeFilename(name) {
9126
9148
  const cleaned = name.replace(/[/\\]/g, "_").replace(/\s+/g, "-").replace(/[^\w.-]/g, "").trim();
@@ -9144,22 +9166,33 @@ function createUploadsRouter() {
9144
9166
  if (file.size > MAX_FILE_BYTES) {
9145
9167
  return c.json({ error: `file must be ${MAX_FILE_BYTES / (1024 * 1024)}MB or smaller` }, 400);
9146
9168
  }
9147
- const key = `${KEY_PREFIX}/${getProfile()}/${randomUUID2()}-${sanitizeFilename(file.name)}`;
9148
- const buffer = Buffer.from(await file.arrayBuffer());
9149
- await getR2Client().send(
9150
- new PutObjectCommand2({
9151
- Bucket: getR2Bucket(),
9152
- Key: key,
9153
- Body: buffer,
9154
- ContentType: file.type || "application/octet-stream"
9155
- })
9156
- );
9169
+ const id = `${KEY_PREFIX}/${getProfile()}/${randomUUID2()}-${sanitizeFilename(file.name)}`;
9170
+ const original = Buffer.from(await file.arrayBuffer());
9171
+ const put = (Key, Body, ContentType) => getR2Client().send(new PutObjectCommand2({ Bucket: getR2Bucket(), Key, Body, ContentType }));
9172
+ let key = id;
9173
+ let size = file.size;
9174
+ let type = file.type;
9175
+ const renditions = RESIZABLE.has(file.type) ? await Promise.all([toWebp(original, FULL_EDGE), toWebp(original, THUMB_EDGE)]).catch(
9176
+ () => null
9177
+ ) : null;
9178
+ if (renditions) {
9179
+ const [full, thumb] = renditions;
9180
+ key = `${id.replace(/\.[^./]*$/, "")}.webp`;
9181
+ size = full.length;
9182
+ type = "image/webp";
9183
+ await Promise.all([
9184
+ put(key, full, type),
9185
+ put(key.replace(/\.webp$/, ".thumb.webp"), thumb, type)
9186
+ ]);
9187
+ } else {
9188
+ await put(key, original, file.type || "application/octet-stream");
9189
+ }
9157
9190
  return c.json(
9158
9191
  {
9159
9192
  url: `/api/attachments/${encodeKeyForUrl(key)}`,
9160
9193
  name: file.name,
9161
- type: file.type,
9162
- size: file.size
9194
+ type,
9195
+ size
9163
9196
  },
9164
9197
  201
9165
9198
  );
@@ -9544,12 +9577,94 @@ function createAuthRouter() {
9544
9577
  return auth;
9545
9578
  }
9546
9579
 
9580
+ // ../api/src/store/support-store.ts
9581
+ var SUPPORT_HANDLE = "flyspace-support";
9582
+ async function logSupportAction(entry) {
9583
+ const { error: error2 } = await getSupabaseClient().from("support_access_log").insert({
9584
+ workspace_id: entry.workspaceId,
9585
+ staff_user_id: entry.staffUserId,
9586
+ staff_email: entry.staffEmail,
9587
+ action: entry.action,
9588
+ detail: entry.detail ?? null
9589
+ });
9590
+ if (error2) throw new Error(`logSupportAction(${entry.action}): ${error2.message}`);
9591
+ }
9592
+ async function listSupportWorkspaceIds(userId) {
9593
+ const { data, error: error2 } = await getSupabaseClient().from("memberships").select("workspace_id").eq("user_id", userId).eq("is_support", true);
9594
+ if (error2) throw new Error(`listSupportWorkspaceIds: ${error2.message}`);
9595
+ return new Set((data ?? []).map((r) => r.workspace_id));
9596
+ }
9597
+ async function grantSupportMembership(params) {
9598
+ const db = getSupabaseClient();
9599
+ const { data: existing, error: readError } = await db.from("memberships").select("is_support").eq("workspace_id", params.workspaceId).eq("user_id", params.userId).maybeSingle();
9600
+ if (readError) throw new Error(`grantSupportMembership: ${readError.message}`);
9601
+ if (existing) {
9602
+ if (existing.is_support === true) return "existing";
9603
+ throw new Error("already a regular member of this workspace");
9604
+ }
9605
+ const { data: taken } = await db.from("memberships").select("id").eq("workspace_id", params.workspaceId).eq("handle", SUPPORT_HANDLE).maybeSingle();
9606
+ const handle = taken ? `${SUPPORT_HANDLE}-${params.userId.slice(0, 8)}` : SUPPORT_HANDLE;
9607
+ const { error: error2 } = await db.from("memberships").insert({
9608
+ workspace_id: params.workspaceId,
9609
+ user_id: params.userId,
9610
+ email: params.email,
9611
+ handle,
9612
+ role: "admin",
9613
+ status: "active",
9614
+ is_support: true,
9615
+ accepted_at: (/* @__PURE__ */ new Date()).toISOString()
9616
+ });
9617
+ if (error2) throw new Error(`grantSupportMembership: ${error2.message}`);
9618
+ return "granted";
9619
+ }
9620
+ async function revokeSupportMembership(workspaceId, userId) {
9621
+ const { data, error: error2 } = await getSupabaseClient().from("memberships").delete().eq("workspace_id", workspaceId).eq("user_id", userId).eq("is_support", true).select("id");
9622
+ if (error2) throw new Error(`revokeSupportMembership: ${error2.message}`);
9623
+ return (data ?? []).length > 0;
9624
+ }
9625
+ async function listSupportLog(filter) {
9626
+ let query = getSupabaseClient().from("support_access_log").select("*").order("created_at", { ascending: false }).limit(Math.min(filter.limit ?? 200, 1e3));
9627
+ if (filter.workspaceId) query = query.eq("workspace_id", filter.workspaceId);
9628
+ const { data, error: error2 } = await query;
9629
+ if (error2) throw new Error(`listSupportLog: ${error2.message}`);
9630
+ return (data ?? []).map((r) => ({
9631
+ id: r.id,
9632
+ workspaceId: r.workspace_id,
9633
+ staffUserId: r.staff_user_id,
9634
+ staffEmail: r.staff_email,
9635
+ action: r.action,
9636
+ detail: r.detail ?? null,
9637
+ createdAt: r.created_at
9638
+ }));
9639
+ }
9640
+
9547
9641
  // ../api/src/dashboard/auth-middleware.ts
9642
+ var ENTER_WINDOW_MS = 8 * 60 * 60 * 1e3;
9643
+ var lastEnter = /* @__PURE__ */ new Map();
9644
+ function auditSupport(ctx, action, detail) {
9645
+ logSupportAction({
9646
+ workspaceId: ctx.workspaceId,
9647
+ staffUserId: ctx.userId,
9648
+ staffEmail: ctx.email,
9649
+ action,
9650
+ detail
9651
+ }).catch((err) => console.error("[support-audit]", err.message));
9652
+ }
9548
9653
  var requireWorkspaceMembership = async (c, next) => {
9549
9654
  const ctx = await resolveAuthContext(c);
9550
9655
  if (!ctx) return c.json({ error: "not signed in" }, 401);
9551
9656
  c.set("auth", ctx);
9552
- return next();
9657
+ if (!ctx.isSupport) return next();
9658
+ const key = `${ctx.userId}:${ctx.workspaceId}`;
9659
+ const now = Date.now();
9660
+ if ((lastEnter.get(key) ?? 0) + ENTER_WINDOW_MS < now) {
9661
+ lastEnter.set(key, now);
9662
+ auditSupport(ctx, "enter");
9663
+ }
9664
+ await next();
9665
+ if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method)) {
9666
+ auditSupport(ctx, "write", { method: c.req.method, path: c.req.path, status: c.res.status });
9667
+ }
9553
9668
  };
9554
9669
 
9555
9670
  // ../api/src/dashboard/billing-routes.ts
@@ -9654,7 +9769,14 @@ function createAdminRouter() {
9654
9769
  const admin = new Hono11();
9655
9770
  admin.use("*", requirePlatformOperator);
9656
9771
  admin.get("/workspaces", async (c) => {
9657
- const summaries = await listWorkspaceSummaries();
9772
+ const operator = c.get("platformOperator");
9773
+ const [summaries, supportIds, users] = await Promise.all([
9774
+ listWorkspaceSummaries(),
9775
+ listSupportWorkspaceIds(operator.userId),
9776
+ // One call for every owner's profile metadata instead of one per row.
9777
+ getSupabaseClient().auth.admin.listUsers({ perPage: 1e3 })
9778
+ ]);
9779
+ const usersById = new Map((users.data?.users ?? []).map((u) => [u.id, u]));
9658
9780
  const workspaces = await Promise.all(
9659
9781
  summaries.map(async (w) => {
9660
9782
  const agentModel = cachedProfileConfig(w.slug)?.agentModel;
@@ -9664,11 +9786,23 @@ function createAdminRouter() {
9664
9786
  console.error(`[admin] R2 usage for ${w.slug}:`, err.message);
9665
9787
  return null;
9666
9788
  });
9789
+ const metadata = w.owner ? usersById.get(w.owner.userId)?.user_metadata : void 0;
9790
+ const owner = w.owner && {
9791
+ ...w.owner,
9792
+ role: "admin",
9793
+ avatarUrl: metadata?.avatarKey ? await signAvatarUrl(metadata.avatarKey) : null,
9794
+ name: metadata?.name ?? null,
9795
+ title: metadata?.title ?? null,
9796
+ bio: metadata?.bio ?? null,
9797
+ timeZone: metadata?.timeZone ?? null
9798
+ };
9667
9799
  return {
9668
9800
  ...w,
9801
+ owner,
9669
9802
  agent: agentModel ? { runner: runnerForModel(agentModel), model: agentModel } : null,
9670
9803
  r2Bytes,
9671
- flyVolumeBytes: null
9804
+ flyVolumeBytes: null,
9805
+ supportAccess: supportIds.has(w.id)
9672
9806
  };
9673
9807
  })
9674
9808
  );
@@ -9756,6 +9890,63 @@ function createAdminRouter() {
9756
9890
  }
9757
9891
  return c.json({ sent: true, inviteLink: inviteUrl.toString() });
9758
9892
  });
9893
+ admin.post("/workspaces/:id/support-access", async (c) => {
9894
+ const operator = c.get("platformOperator");
9895
+ const workspaceId = c.req.param("id");
9896
+ let result;
9897
+ try {
9898
+ result = await grantSupportMembership({
9899
+ workspaceId,
9900
+ userId: operator.userId,
9901
+ email: operator.email
9902
+ });
9903
+ } catch (err) {
9904
+ return c.json({ error: err instanceof Error ? err.message : "grant failed" }, 409);
9905
+ }
9906
+ if (result === "granted") {
9907
+ await logSupportAction({
9908
+ workspaceId,
9909
+ staffUserId: operator.userId,
9910
+ staffEmail: operator.email,
9911
+ action: "grant"
9912
+ });
9913
+ }
9914
+ invalidateAuthContextCache();
9915
+ return c.json({ supportAccess: true });
9916
+ });
9917
+ admin.delete("/workspaces/:id/support-access", async (c) => {
9918
+ const operator = c.get("platformOperator");
9919
+ const workspaceId = c.req.param("id");
9920
+ if (await revokeSupportMembership(workspaceId, operator.userId)) {
9921
+ await logSupportAction({
9922
+ workspaceId,
9923
+ staffUserId: operator.userId,
9924
+ staffEmail: operator.email,
9925
+ action: "leave"
9926
+ });
9927
+ }
9928
+ invalidateAuthContextCache();
9929
+ return c.json({ supportAccess: false });
9930
+ });
9931
+ const archiveRoute = (archived) => async (c) => {
9932
+ try {
9933
+ await setWorkspaceArchived(c.req.param("id"), archived);
9934
+ } catch (err) {
9935
+ const message = err instanceof Error ? err.message : "archive failed";
9936
+ return c.json({ error: message }, message === "workspace not found" ? 404 : 500);
9937
+ }
9938
+ invalidateAuthContextCache();
9939
+ return c.json({ archived });
9940
+ };
9941
+ admin.post("/workspaces/:id/archive", archiveRoute(true));
9942
+ admin.delete("/workspaces/:id/archive", archiveRoute(false));
9943
+ admin.get("/support-log", async (c) => {
9944
+ const entries2 = await listSupportLog({
9945
+ workspaceId: c.req.query("workspace") || void 0,
9946
+ limit: Number(c.req.query("limit")) || void 0
9947
+ });
9948
+ return c.json({ entries: entries2 });
9949
+ });
9759
9950
  return admin;
9760
9951
  }
9761
9952
 
@@ -9765,7 +9956,7 @@ var BOT_DM_KEY = "claude";
9765
9956
 
9766
9957
  // ../api/src/dashboard/data-api.ts
9767
9958
  import { existsSync as existsSync20 } from "fs";
9768
- import sharp5 from "sharp";
9959
+ import sharp6 from "sharp";
9769
9960
 
9770
9961
  // ../api/src/dashboard/automations-routes.ts
9771
9962
  import { Hono as Hono12 } from "hono";
@@ -10259,6 +10450,25 @@ var BOARD_STATUSES = [
10259
10450
  "cancelled"
10260
10451
  ];
10261
10452
  var NO_STATUS_COLUMN = "none";
10453
+ function isBoardStatus(v) {
10454
+ return typeof v === "string" && BOARD_STATUSES.includes(v);
10455
+ }
10456
+ async function callerMembershipId(c) {
10457
+ const viewer = c.get("auth");
10458
+ if (!viewer) return null;
10459
+ return (await getMembershipByUserId(viewer.workspaceId, viewer.userId))?.id ?? null;
10460
+ }
10461
+ async function rememberComposerStatus(c, channelId, status) {
10462
+ const membershipId = await callerMembershipId(c);
10463
+ if (!membershipId) return;
10464
+ const { error: error2 } = await getSupabaseClient().from("channel_member_prefs").upsert({
10465
+ channel_id: channelId,
10466
+ membership_id: membershipId,
10467
+ last_board_status: status,
10468
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
10469
+ });
10470
+ if (error2) throw new Error(error2.message);
10471
+ }
10262
10472
  function boardColumnParam(status) {
10263
10473
  if (status === NO_STATUS_COLUMN) return NO_STATUS_COLUMN;
10264
10474
  return BOARD_STATUSES.includes(status) ? status : null;
@@ -10324,7 +10534,9 @@ async function withPreviews(supabase, threads) {
10324
10534
  for (const { data } of chunkResults) {
10325
10535
  for (const r of data ?? []) {
10326
10536
  const entry = previewByThread.get(r.thread_id);
10537
+ const images = r.body.includes("![") ? markdownImageUrls(slackMrkdwnToMarkdown(r.body)) : [];
10327
10538
  if (entry) {
10539
+ entry.images.push(...images);
10328
10540
  entry.count += 1;
10329
10541
  entry.lastAuthor = r.author;
10330
10542
  entry.participants.delete(r.author);
@@ -10334,7 +10546,8 @@ async function withPreviews(supabase, threads) {
10334
10546
  count: 1,
10335
10547
  firstBody: r.body,
10336
10548
  lastAuthor: r.author,
10337
- participants: /* @__PURE__ */ new Map()
10549
+ participants: /* @__PURE__ */ new Map(),
10550
+ images
10338
10551
  });
10339
10552
  }
10340
10553
  }
@@ -10349,10 +10562,19 @@ async function withPreviews(supabase, threads) {
10349
10562
  // read, on this browser or any other (see web/src/lib/thread-unread).
10350
10563
  last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
10351
10564
  // Everyone who replied, most recent first, for the row's avatar stack.
10352
- participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id }))
10565
+ participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id })),
10566
+ // Every image in the thread, in order, so the row can show a thumbnail
10567
+ // strip; only the first few URLs are sent, the rest are just counted.
10568
+ images: (entry?.images ?? []).slice(0, ROW_IMAGE_LIMIT),
10569
+ image_count: entry?.images.length ?? 0
10353
10570
  };
10354
10571
  });
10355
10572
  }
10573
+ var ROW_IMAGE_LIMIT = 3;
10574
+ var MARKDOWN_IMAGE_RE = /!\[[^\]]*\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/g;
10575
+ function markdownImageUrls(markdown) {
10576
+ return [...markdown.matchAll(MARKDOWN_IMAGE_RE)].map((m) => m[1]);
10577
+ }
10356
10578
  async function labelThread(channelId, body, scope) {
10357
10579
  const channel = getProfileChannelRegistry().find(
10358
10580
  (ch) => ch.id === channelId || ch.externalChannelId === channelId
@@ -10762,6 +10984,13 @@ function createChatRouter(agent = {}) {
10762
10984
  const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10763
10985
  const model = requestedModel(c, input2.model);
10764
10986
  const effort = requestedEffort(c, input2.effort);
10987
+ const pickedStatus = input2.board_status;
10988
+ if (pickedStatus != null && !isBoardStatus(pickedStatus)) {
10989
+ return c.json(
10990
+ { error: `board_status must be null or one of: ${BOARD_STATUSES.join(", ")}` },
10991
+ 400
10992
+ );
10993
+ }
10765
10994
  const supabase = getSupabaseClient();
10766
10995
  let thread;
10767
10996
  let replayed = false;
@@ -10772,7 +11001,8 @@ function createChatRouter(agent = {}) {
10772
11001
  // opening message at display time (see threadCrumbLabel).
10773
11002
  title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
10774
11003
  author: input2.author,
10775
- client_key: clientKey2
11004
+ client_key: clientKey2,
11005
+ board_status: pickedStatus ?? null
10776
11006
  }).select().single();
10777
11007
  if (threadError) {
10778
11008
  if (!clientKey2 || threadError.code !== "23505") {
@@ -10813,6 +11043,11 @@ function createChatRouter(agent = {}) {
10813
11043
  effort: effortFromRow(reply["effort"])
10814
11044
  });
10815
11045
  }
11046
+ if (!replayed && pickedStatus !== void 0) {
11047
+ void rememberComposerStatus(c, channelId, pickedStatus).catch(
11048
+ (err) => console.error("Remember composer status failed:", err)
11049
+ );
11050
+ }
10816
11051
  if (!replayed && thread["title"] == null) {
10817
11052
  void generateAutoTitle(thread.id, channelId, input2.body).catch(
10818
11053
  (err) => console.error("Auto title failed:", err)
@@ -10820,6 +11055,15 @@ function createChatRouter(agent = {}) {
10820
11055
  }
10821
11056
  return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
10822
11057
  });
11058
+ app.get("/channels/:channelId/composer-status", async (c) => {
11059
+ const channelId = c.req.param("channelId");
11060
+ if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
11061
+ const membershipId = await callerMembershipId(c);
11062
+ if (!membershipId) return c.json({ lastStatus: null });
11063
+ const { data: pref, error: error2 } = await getSupabaseClient().from("channel_member_prefs").select("last_board_status").eq("channel_id", channelId).eq("membership_id", membershipId).maybeSingle();
11064
+ if (error2) return c.json({ error: error2.message }, 500);
11065
+ return c.json({ lastStatus: pref?.last_board_status ?? null });
11066
+ });
10823
11067
  app.post("/replies/:replyId/fork", async (c) => {
10824
11068
  const replyId = c.req.param("replyId");
10825
11069
  const input2 = await c.req.json().catch(() => null);
@@ -11035,7 +11279,7 @@ function generateShareId() {
11035
11279
  return randomBytes6(9).toString("base64url");
11036
11280
  }
11037
11281
  async function publishDocShare(input2, readBundle) {
11038
- const bundle = await readBundle({ cwd: input2.cwd, sourcePath: input2.sourcePath });
11282
+ const bundle = await readBundle({ channelId: input2.channelId, cwd: input2.cwd, sourcePath: input2.sourcePath });
11039
11283
  if (bundle.status !== "ok") return bundle;
11040
11284
  const existing = await findDocShareBySource(input2.workspaceId, input2.channelId, input2.sourcePath);
11041
11285
  const shareId = existing?.id ?? generateShareId();
@@ -11695,7 +11939,7 @@ function createTranscribeRouter() {
11695
11939
  // ../api/src/dashboard/avatars-routes.ts
11696
11940
  import { createHash as createHash9, randomUUID as randomUUID4 } from "crypto";
11697
11941
  import { Hono as Hono19 } from "hono";
11698
- import sharp4 from "sharp";
11942
+ import sharp5 from "sharp";
11699
11943
  import { PutObjectCommand as PutObjectCommand4, DeleteObjectCommand as DeleteObjectCommand3 } from "@aws-sdk/client-s3";
11700
11944
  var AVATAR_SIZE = 192;
11701
11945
  var MAX_AVATAR_BYTES = 2 * 1024 * 1024;
@@ -11731,7 +11975,7 @@ async function storeAvatar(file, owner) {
11731
11975
  return { error: "avatar must be a PNG or JPEG image" };
11732
11976
  if (file.size > MAX_AVATAR_BYTES) return { error: "avatar must be 2MB or smaller" };
11733
11977
  const original = Buffer.from(await file.arrayBuffer());
11734
- const resized = await sharp4(original).resize({ width: AVATAR_SIZE, height: AVATAR_SIZE, fit: "cover" }).png().toBuffer();
11978
+ const resized = await sharp5(original).resize({ width: AVATAR_SIZE, height: AVATAR_SIZE, fit: "cover" }).png().toBuffer();
11735
11979
  const key = `${avatarKeyPrefix()}${owner}-${randomUUID4()}.png`;
11736
11980
  await getR2Client().send(
11737
11981
  new PutObjectCommand4({
@@ -11909,10 +12153,6 @@ function createMembersRouter() {
11909
12153
  if (avatarKey && !avatarKey.startsWith(`${avatarKeyPrefix()}invite-${ctx.userId}-`)) {
11910
12154
  return c.json({ error: "unknown avatar" }, 400);
11911
12155
  }
11912
- const { plan } = await getWorkspaceBilling(ctx.workspaceId);
11913
- if (plan === "local") {
11914
- return c.json({ error: "The Solo plan is for one person. Upgrade to invite members." }, 403);
11915
- }
11916
12156
  const existing = await getMembershipByEmail(ctx.workspaceId, email);
11917
12157
  if (existing) return c.json({ error: "already a member of this workspace" }, 409);
11918
12158
  let membership;
@@ -13425,8 +13665,95 @@ async function buildThreadUsage(threadTs) {
13425
13665
  }
13426
13666
  }
13427
13667
 
13428
- // ../api/src/dashboard/channel-members-routes.ts
13668
+ // ../api/src/dashboard/channel-greeting.ts
13429
13669
  import { Hono as Hono25 } from "hono";
13670
+ function eligible(ch) {
13671
+ return !!ch && !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt;
13672
+ }
13673
+ function buildIntroPrompt(ch) {
13674
+ const automations = [
13675
+ ...(ch.automations?.schedules ?? []).map((s) => s.name),
13676
+ ...(ch.automations?.triggers ?? []).map((t) => t.name)
13677
+ ].filter(Boolean);
13678
+ return [
13679
+ `[channel intro request] Write the one-time intro message for the #${ch.name} channel, into this thread. It is the first thing a visitor sees here, so it must be grounded in what this folder actually contains.`,
13680
+ "",
13681
+ "Read-only: inspect the folder (file listing, README.md, AGENTS.md / CLAUDE.md, docs/, git status, .claude/skills and any SKILL.md files), but do not create, modify or delete anything, and do not run anything with side effects.",
13682
+ "",
13683
+ "Language: match how this project is usually worked on, in this order of evidence: (1) the user messages in this folder's recent agent sessions (Claude Code: ~/.claude/projects/<encoded cwd>/*.jsonl; Codex: ~/.codex/sessions/**/*.jsonl with a matching cwd): read only enough to judge the language, never quote them; (2) the main language of README.md / AGENTS.md / docs/; (3) English. For Chinese use Traditional Chinese (Taiwan), full-width punctuation.",
13684
+ "",
13685
+ "Shape (pyramid, at most ~80 Chinese characters / ~60 English words in total):",
13686
+ "1. One sentence: what this channel/project is, and what you can best help with here.",
13687
+ `2. Three one-line bullets: what already exists in the folder (repo, docs); 1-2 skills that are really available and how to invoke them; one concrete automation suggestion with frequency and content (e.g. "every Monday 9am, summarise last week's commits here").`,
13688
+ "3. End with exactly one line `[BUTTONS: a | b | c]` whose three buttons correspond to the three bullets and read as directly executable requests.",
13689
+ "",
13690
+ "Rules: never invent skills or automations that are not actually there: skip that bullet instead. " + (automations.length ? `This channel already has automations (${automations.join(", ")}): introduce the existing one(s) instead of suggesting a new one. ` : "") + "Do not explain Flyspace itself. If the folder is empty, instead ask what this channel will be used for and offer `[BUTTONS: Help me set up this channel]`. Output only the message itself, nothing else."
13691
+ ].join("\n");
13692
+ }
13693
+ var FALLBACK_BODY = "I could not write this channel's intro automatically. Ask me below and I'll take a look at the folder.\n\n[BUTTONS: Introduce this channel]";
13694
+ function introOpening(channelName) {
13695
+ return `Welcome to #${channelName}. I'm reading this project's folder, docs, skills and automations to work out how I can help here. Below you'll find a short intro and a few first steps you can click to try.`;
13696
+ }
13697
+ function createChannelGreetingRouter(agent) {
13698
+ const app = new Hono25();
13699
+ app.get("/:id/greeting", async (c) => {
13700
+ const ctx = await resolveAuthContext(c);
13701
+ const ch = findChannelById(c.req.param("id"));
13702
+ const pending2 = isWorkspaceOwner(ctx) && eligible(ch) && !ch.greetedAt && (agent.isHostOnline?.(ch.profile) ?? false);
13703
+ if (!pending2) return c.json({ pending: false, threadId: null });
13704
+ const threadId = await ensureIntroThread(ch);
13705
+ return c.json({ pending: !!threadId, threadId });
13706
+ });
13707
+ app.post("/:id/greeting", async (c) => {
13708
+ const ctx = await resolveAuthContext(c);
13709
+ if (!isWorkspaceOwner(ctx)) return c.json({ error: "workspace owner access required" }, 403);
13710
+ const ch = findChannelById(c.req.param("id"));
13711
+ if (!eligible(ch)) return c.json({ error: "channel has no intro" }, 404);
13712
+ const threadId = await ensureIntroThread(ch);
13713
+ if (!threadId) return c.json({ error: "could not create the intro thread" }, 500);
13714
+ if (!await markChannelGreeted(ch.id)) return c.json({ threadId, error: "already greeted" }, 409);
13715
+ try {
13716
+ if (!(agent.isHostOnline?.(ch.profile) ?? false)) throw new Error("host offline");
13717
+ await agent.onChatMessage?.({
13718
+ threadId,
13719
+ // Synthetic: no thread_replies row has this id, so the prompt is
13720
+ // never shown and never counted as thread context.
13721
+ replyId: crypto.randomUUID(),
13722
+ author: ctx.handle,
13723
+ body: buildIntroPrompt(ch)
13724
+ });
13725
+ } catch (err) {
13726
+ console.error(`[channel-greeting] dispatch failed for #${ch.name}:`, err);
13727
+ await getSupabaseClient().from("thread_replies").insert({ thread_id: threadId, author: AGENT_AUTHOR, body: FALLBACK_BODY });
13728
+ }
13729
+ return c.json({ threadId }, 201);
13730
+ });
13731
+ return app;
13732
+ }
13733
+ async function ensureIntroThread(ch) {
13734
+ const supabase = getSupabaseClient();
13735
+ const existing = () => supabase.from("threads").select("id").eq("client_key", ch.id).maybeSingle();
13736
+ const found = await existing();
13737
+ if (found.data) return found.data.id;
13738
+ const { data, error: error2 } = await supabase.from("threads").insert({
13739
+ channel_id: ch.id,
13740
+ title: `Getting started in #${ch.name}`,
13741
+ author: AGENT_AUTHOR,
13742
+ client_key: ch.id
13743
+ }).select("id").single();
13744
+ if (data) {
13745
+ await supabase.from("thread_replies").insert({
13746
+ thread_id: data.id,
13747
+ author: AGENT_AUTHOR,
13748
+ body: introOpening(ch.name)
13749
+ });
13750
+ return data.id;
13751
+ }
13752
+ return error2?.code === "23505" ? (await existing()).data?.id ?? null : null;
13753
+ }
13754
+
13755
+ // ../api/src/dashboard/channel-members-routes.ts
13756
+ import { Hono as Hono26 } from "hono";
13430
13757
  async function isChannelAdmin(ctx, channelId) {
13431
13758
  if (ctx.role === "admin") return true;
13432
13759
  const membership = await getMembershipByUserId(ctx.workspaceId, ctx.userId);
@@ -13435,7 +13762,7 @@ async function isChannelAdmin(ctx, channelId) {
13435
13762
  return members.some((m) => m.membershipId === membership.id && m.role === "channel_admin");
13436
13763
  }
13437
13764
  function createChannelMembersRouter() {
13438
- const router = new Hono25();
13765
+ const router = new Hono26();
13439
13766
  router.get("/:id/members", async (c) => {
13440
13767
  const ctx = await resolveAuthContext(c);
13441
13768
  if (!ctx) return c.json({ error: "workspace access required" }, 403);
@@ -13505,7 +13832,7 @@ function createChannelMembersRouter() {
13505
13832
  }
13506
13833
 
13507
13834
  // ../api/src/dashboard/memories-routes.ts
13508
- import { Hono as Hono26 } from "hono";
13835
+ import { Hono as Hono27 } from "hono";
13509
13836
  async function readPatch(c) {
13510
13837
  const body = await c.req.json().catch(() => ({}));
13511
13838
  const patch = {};
@@ -13533,7 +13860,7 @@ var requireAgentMemory = async (c, next) => {
13533
13860
  await next();
13534
13861
  };
13535
13862
  function createMemoriesRouter() {
13536
- const router = new Hono26();
13863
+ const router = new Hono27();
13537
13864
  for (const path of [
13538
13865
  "/:id/memories",
13539
13866
  "/:id/memories/*",
@@ -13606,7 +13933,7 @@ function createMemoriesRouter() {
13606
13933
  return router;
13607
13934
  }
13608
13935
  function createScopedMemoriesRouter() {
13609
- const router = new Hono26();
13936
+ const router = new Hono27();
13610
13937
  router.use("*", requireAgentMemory);
13611
13938
  const workspace = (c) => ({
13612
13939
  scope: "workspace",
@@ -13837,6 +14164,7 @@ function registerDataRoutes(api, agent = {}) {
13837
14164
  });
13838
14165
  api.route("/channels", createDocShareWriteRouter(agent));
13839
14166
  api.route("/onboarding", createOnboardingRouter(agent));
14167
+ api.route("/channels", createChannelGreetingRouter(agent));
13840
14168
  api.route("/channels", createChannelMembersRouter());
13841
14169
  api.route("/channels", createMemoriesRouter());
13842
14170
  api.route("/memories", createScopedMemoriesRouter());
@@ -14080,7 +14408,7 @@ function registerDataRoutes(api, agent = {}) {
14080
14408
  return c.json({ error: "logo must be 2MB or smaller" }, 400);
14081
14409
  }
14082
14410
  const original = Buffer.from(await file.arrayBuffer());
14083
- const resized = await sharp5(original).resize({ width: LOGO_SIZE, height: LOGO_SIZE, fit: "cover" }).png().toBuffer();
14411
+ const resized = await sharp6(original).resize({ width: LOGO_SIZE, height: LOGO_SIZE, fit: "cover" }).png().toBuffer();
14084
14412
  const logo = `data:image/png;base64,${resized.toString("base64")}`;
14085
14413
  return c.json(await saveWorkspaceBranding({ logo }));
14086
14414
  });
@@ -14090,7 +14418,7 @@ function registerDataRoutes(api, agent = {}) {
14090
14418
  // ../api/src/dashboard/device-auth-routes.ts
14091
14419
  import { verify } from "crypto";
14092
14420
  import { getConnInfo } from "@hono/node-server/conninfo";
14093
- import { Hono as Hono27 } from "hono";
14421
+ import { Hono as Hono28 } from "hono";
14094
14422
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14095
14423
  function cleanText(value, max) {
14096
14424
  if (typeof value !== "string") return null;
@@ -14138,7 +14466,7 @@ async function boundWorkspaceIds(publicKey, userId) {
14138
14466
  return new Set(existing ? (await listDeviceWorkspaces(existing)).map((w) => w.id) : []);
14139
14467
  }
14140
14468
  function createDeviceAuthRouter() {
14141
- const app = new Hono27();
14469
+ const app = new Hono28();
14142
14470
  app.post("/code", async (c) => {
14143
14471
  const body = await readBody(c);
14144
14472
  const publicKey = typeof body["public_key"] === "string" ? body["public_key"] : "";
@@ -14457,7 +14785,7 @@ var workspaceRef = () => ({ kind: "workspace" });
14457
14785
  var channelRef = (c) => ({ kind: "channel", channelId: c.req.param("id") });
14458
14786
 
14459
14787
  // ../api/src/dashboard/output-styles-routes.ts
14460
- import { Hono as Hono28 } from "hono";
14788
+ import { Hono as Hono29 } from "hono";
14461
14789
  function requireAdmin3(auth) {
14462
14790
  return auth?.role === "admin" ? null : "admin access required";
14463
14791
  }
@@ -14477,7 +14805,7 @@ function failure(c, err) {
14477
14805
  return c.json({ error: message }, err instanceof OutputStyleConflictError ? 409 : 500);
14478
14806
  }
14479
14807
  function createOutputStylesRouter() {
14480
- const app = new Hono28();
14808
+ const app = new Hono29();
14481
14809
  app.get("/", (c) => {
14482
14810
  const auth = c.get("auth");
14483
14811
  const denied = requireAdmin3(auth);
@@ -14569,7 +14897,7 @@ function createOutputStylesRouter() {
14569
14897
  return app;
14570
14898
  }
14571
14899
  async function handleOutputStylesRequest(req) {
14572
- const app = new Hono28();
14900
+ const app = new Hono29();
14573
14901
  app.use("*", async (c, next) => {
14574
14902
  c.set("auth", { role: "admin" });
14575
14903
  await next();
@@ -14590,7 +14918,7 @@ import { streamSSE } from "hono/streaming";
14590
14918
 
14591
14919
  // ../api/src/dashboard/api.ts
14592
14920
  function createGatedRouter(agent, register) {
14593
- const api = new Hono29();
14921
+ const api = new Hono30();
14594
14922
  api.use("*", async (c, next) => {
14595
14923
  const known = await listKnownWorkspaces();
14596
14924
  const fromHost = profileFromHost(trustedHost(c), known);
@@ -14639,11 +14967,11 @@ function createGatedRouter(agent, register) {
14639
14967
 
14640
14968
  // src/dashboard/host-api.ts
14641
14969
  import { existsSync as existsSync30, readFileSync as readFileSync24, statSync as statSync8 } from "fs";
14642
- import sharp6 from "sharp";
14970
+ import sharp7 from "sharp";
14643
14971
 
14644
14972
  // src/dashboard/channels-routes.ts
14645
14973
  import { existsSync as existsSync22, mkdirSync as mkdirSync10 } from "fs";
14646
- import { Hono as Hono30 } from "hono";
14974
+ import { Hono as Hono31 } from "hono";
14647
14975
  function coerceAccess(raw) {
14648
14976
  if (raw === "open" || raw === "*" || raw === "all") return raw;
14649
14977
  if (Array.isArray(raw) && raw.every((v) => typeof v === "string")) return raw;
@@ -14706,7 +15034,7 @@ function toResponse3(channel) {
14706
15034
  };
14707
15035
  }
14708
15036
  function createChannelWriteRouter() {
14709
- const app = new Hono30();
15037
+ const app = new Hono31();
14710
15038
  app.post("/", async (c) => {
14711
15039
  const body = await c.req.json().catch(() => ({}));
14712
15040
  const name = typeof body["name"] === "string" ? body["name"].trim() : "";
@@ -14846,7 +15174,7 @@ import {
14846
15174
  } from "fs";
14847
15175
  import { Readable } from "stream";
14848
15176
  import { basename as basename8, dirname as dirname8, extname, join as join23, relative, resolve as resolve5, sep } from "path";
14849
- import { Hono as Hono31 } from "hono";
15177
+ import { Hono as Hono32 } from "hono";
14850
15178
  import { streamSSE as streamSSE2 } from "hono/streaming";
14851
15179
 
14852
15180
  // src/dashboard/docs-watcher.ts
@@ -17727,7 +18055,7 @@ function deleteDocEntry(root, path) {
17727
18055
  };
17728
18056
  }
17729
18057
  function createDocsRouter({ gate = requireDocsAccess } = {}) {
17730
- const app = new Hono31();
18058
+ const app = new Hono32();
17731
18059
  app.use("/:id/docs", gate);
17732
18060
  app.use("/:id/docs/*", gate);
17733
18061
  app.get("/:id/docs", (c) => {
@@ -18065,7 +18393,7 @@ function withDocLinkBridge(html) {
18065
18393
  // src/dashboard/skills-routes-host.ts
18066
18394
  import { existsSync as existsSync27, statSync as statSync7 } from "fs";
18067
18395
  import { dirname as dirname9 } from "path";
18068
- import { Hono as Hono32 } from "hono";
18396
+ import { Hono as Hono33 } from "hono";
18069
18397
  function workspaceScope() {
18070
18398
  const claudeConfigDir = profileClaudeConfigDir();
18071
18399
  const cwd = defaultClaudeCwd();
@@ -18171,12 +18499,12 @@ var diskBackend = {
18171
18499
  write: writeSkillAndSync
18172
18500
  };
18173
18501
  function createWorkspaceSkillsRouter() {
18174
- const app = new Hono32();
18502
+ const app = new Hono33();
18175
18503
  mountSkillRoutes(app, "/skills", workspaceRef, diskBackend);
18176
18504
  return app;
18177
18505
  }
18178
18506
  function createChannelSkillsRouter() {
18179
- const app = new Hono32();
18507
+ const app = new Hono33();
18180
18508
  mountSkillRoutes(app, "/:id/skill-files", channelRef, diskBackend);
18181
18509
  return app;
18182
18510
  }
@@ -18311,7 +18639,7 @@ import { randomUUID as randomUUID5 } from "crypto";
18311
18639
  import { chmodSync, existsSync as existsSync29, mkdirSync as mkdirSync12, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "fs";
18312
18640
  import { createRequire } from "module";
18313
18641
  import { dirname as dirname10, join as join25 } from "path";
18314
- import { Hono as Hono33 } from "hono";
18642
+ import { Hono as Hono34 } from "hono";
18315
18643
  var SESSION_TTL_MS = 10 * 6e4;
18316
18644
  var URL_WAIT_MS = 3e4;
18317
18645
  var TOKEN_WAIT_MS = 6e4;
@@ -18468,7 +18796,7 @@ function failure2(c, err) {
18468
18796
  return c.json({ error: err.message }, 500);
18469
18797
  }
18470
18798
  function createProviderLoginRouter() {
18471
- const app = new Hono33();
18799
+ const app = new Hono34();
18472
18800
  app.use("*", async (c, next) => {
18473
18801
  const denied = requireAdmin4(c.get("auth"));
18474
18802
  if (denied) return c.json({ error: denied }, 403);
@@ -18512,7 +18840,7 @@ function createProviderLoginRouter() {
18512
18840
  return app;
18513
18841
  }
18514
18842
  async function handleProviderLoginRequest(req) {
18515
- const app = new Hono33();
18843
+ const app = new Hono34();
18516
18844
  app.use("*", async (c, next) => {
18517
18845
  c.set("auth", { role: "admin" });
18518
18846
  await next();
@@ -18620,7 +18948,7 @@ function registerHostRoutes(api) {
18620
18948
  return c.json({ error: "avatar must be 2MB or smaller" }, 400);
18621
18949
  }
18622
18950
  const original = Buffer.from(await file.arrayBuffer());
18623
- const resized = await sharp6(original).resize({ width: LOGO_SIZE, height: LOGO_SIZE, fit: "cover" }).png().toBuffer();
18951
+ const resized = await sharp7(original).resize({ width: LOGO_SIZE, height: LOGO_SIZE, fit: "cover" }).png().toBuffer();
18624
18952
  const avatar = `data:image/png;base64,${resized.toString("base64")}`;
18625
18953
  const name = c.req.param("name");
18626
18954
  if (name === BOT_PROFILE_KEY) return c.json(await saveBotIdentity({ avatar }));
@@ -18669,13 +18997,13 @@ import dotenv2 from "dotenv";
18669
18997
  import { WebSocket as WebSocket2 } from "ws";
18670
18998
 
18671
18999
  // src/dashboard/docs-relay-host.ts
18672
- import { Hono as Hono34 } from "hono";
19000
+ import { Hono as Hono35 } from "hono";
18673
19001
  var RETURNED_HEADERS = ["content-type", "content-disposition", "content-security-policy", "cache-control"];
18674
19002
  async function handleDocsRequest(req) {
18675
19003
  return runRelayed(req, (app) => app.route("/channels", createDocsRouter({ gate: async (_c, next) => next() })));
18676
19004
  }
18677
19005
  async function runRelayed(req, register) {
18678
- const app = new Hono34();
19006
+ const app = new Hono35();
18679
19007
  app.use("*", async (c, next) => {
18680
19008
  c.set("auth", req.auth);
18681
19009
  await next();
@@ -18976,9 +19304,12 @@ function localAgentCallbacks(chatAgent) {
18976
19304
  listMissingCwds: () => auditChannelCwds().map((ch) => ch.id),
18977
19305
  listRunningProfiles: () => listProfiles().filter(isServiceRunning2),
18978
19306
  getAgentIdentities: () => resolveAgentIdentities(),
18979
- readDocShareBundle: (req) => readDocShareBundle(req),
18980
- listChannelSkills: (args) => listSkills(args.claudeConfigDir, args.cwd),
18981
- listFlatDocs: (args) => flatDocs(args.cwd, { query: args.query, sort: args.sort, showHidden: args.showHidden }),
19307
+ readDocShareBundle: (req) => readDocShareBundle(req.channelId ? { ...req, cwd: resolveChannelConfig(req.channelId).cwd } : req),
19308
+ listChannelSkills: (args) => {
19309
+ const { cwd, claudeConfigDir } = args.channelId ? resolveChannelConfig(args.channelId) : args;
19310
+ return listSkills(claudeConfigDir, cwd);
19311
+ },
19312
+ listFlatDocs: (args) => flatDocs(args.channelId ? resolveChannelConfig(args.channelId).cwd : args.cwd, { query: args.query, sort: args.sort, showHidden: args.showHidden }),
18982
19313
  getLocalDashboardSettings: () => loadDashboardSettings(),
18983
19314
  saveLocalDashboardSettings: (patch) => saveDashboardSettings(patch),
18984
19315
  writeSkill: (ref, write) => writeSkillAndSync(ref, write),
@@ -19253,7 +19584,7 @@ import { join as join27 } from "path";
19253
19584
  import { serveStatic } from "@hono/node-server/serve-static";
19254
19585
 
19255
19586
  // src/dashboard/app-icon-routes.ts
19256
- import { Hono as Hono35 } from "hono";
19587
+ import { Hono as Hono36 } from "hono";
19257
19588
  var NEUTRAL_MANIFEST = {
19258
19589
  id: "/",
19259
19590
  name: "Flyspace",
@@ -19291,7 +19622,7 @@ async function withResolvedProfile2(c, next) {
19291
19622
  return profile ? runWithProfile(profile, () => next()) : next();
19292
19623
  }
19293
19624
  function createAppIconRouter() {
19294
- const app = new Hono35();
19625
+ const app = new Hono36();
19295
19626
  app.get("/manifest.json", withResolvedProfile2, async (c) => {
19296
19627
  if (!await resolvedProfileHost(c)) return c.json(NEUTRAL_MANIFEST);
19297
19628
  const profile = getProfile();
@@ -19865,7 +20196,7 @@ async function main() {
19865
20196
  break;
19866
20197
  case "-v":
19867
20198
  case "--version":
19868
- printRaw("0.1.32");
20199
+ printRaw("0.1.33");
19869
20200
  break;
19870
20201
  case "-h":
19871
20202
  case "--help":
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startServer
4
- } from "./chunk-6YFDWUNF.js";
4
+ } from "./chunk-KGX44L3V.js";
5
5
  import "./chunk-GYHCSGMJ.js";
6
6
  export {
7
7
  startServer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flyspace-cli",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "description": "AI-native headless community OS",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,