flyspace-cli 0.1.31 → 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.31" : 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.31";
2474
+ const current = "0.1.33";
2472
2475
  console.log(`Current version: ${current}`);
2473
2476
  console.log("Checking for updates...\n");
2474
2477
  try {
@@ -3108,10 +3111,14 @@ async function runRestart() {
3108
3111
  import { spawn as spawn3 } from "child_process";
3109
3112
  import { existsSync as existsSync12 } from "fs";
3110
3113
  import { join as join12 } from "path";
3114
+ function serviceLogFile(errors) {
3115
+ const suffix = errors ? "-error.log" : ".log";
3116
+ const candidates = ["host", "custie"].map((name) => join12(paths.LOG_DIR, `${name}${suffix}`));
3117
+ return candidates.find((file) => existsSync12(file)) ?? candidates[0];
3118
+ }
3111
3119
  async function runLogs(args) {
3112
3120
  const showErrors = args.includes("--error") || args.includes("-e");
3113
- const filename = showErrors ? "custie-error.log" : "custie.log";
3114
- const logFile = join12(paths.LOG_DIR, filename);
3121
+ const logFile = serviceLogFile(showErrors);
3115
3122
  if (!existsSync12(logFile)) {
3116
3123
  console.error(`Log file not found: ${logFile}`);
3117
3124
  console.error("Is the service installed? Run `flyspace install` first.");
@@ -4140,7 +4147,7 @@ async function startWorkspaceTrial(workspace, plan, ownerEmail) {
4140
4147
  const subscription = await stripe.subscriptions.create({
4141
4148
  customer: customer.id,
4142
4149
  items: [{ price: priceId }],
4143
- trial_period_days: 14,
4150
+ trial_period_days: TRIAL_DAYS,
4144
4151
  trial_settings: { end_behavior: { missing_payment_method: "pause" } },
4145
4152
  metadata: { workspace_id: workspace.id, plan }
4146
4153
  });
@@ -6406,7 +6413,7 @@ async function runMcpCmd(args) {
6406
6413
  import { spawn as spawn8, spawnSync } from "child_process";
6407
6414
  import { existsSync as existsSync33 } from "fs";
6408
6415
  import { platform as platform7 } from "os";
6409
- import { join as join29 } from "path";
6416
+ import { join as join28 } from "path";
6410
6417
 
6411
6418
  // src/reconcile.ts
6412
6419
  import { existsSync as existsSync15, mkdirSync as mkdirSync8, writeFileSync as writeFileSync8 } from "fs";
@@ -6579,7 +6586,7 @@ var requireDocsAccess = async (c, next) => {
6579
6586
  };
6580
6587
 
6581
6588
  // ../api/src/dashboard/api.ts
6582
- import { Hono as Hono29 } from "hono";
6589
+ import { Hono as Hono30 } from "hono";
6583
6590
 
6584
6591
  // ../api/src/dashboard/public-share-routes.ts
6585
6592
  import { existsSync as existsSync17, readFileSync as readFileSync16, statSync as statSync3 } from "fs";
@@ -8731,7 +8738,17 @@ function pruneExpired(now) {
8731
8738
  }
8732
8739
  var SUPPORT_NAME = "Flyspace Support";
8733
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
+ }
8734
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) {
8735
8752
  const cookie = c.req?.header("Cookie") ?? "";
8736
8753
  if (!cookie) return resolveAuthContextUncached(c);
8737
8754
  const key = `${getProfile()}\0${cookie}`;
@@ -8796,7 +8813,8 @@ async function resolveAuthContextUncached(c) {
8796
8813
  passwordSet: Boolean(metadata.passwordSetAt),
8797
8814
  agentConnectedAt: workspace.agentConnectedAt ?? null,
8798
8815
  projectsSetupAt: workspace.projectsSetupAt ?? null,
8799
- plan: workspace.plan ?? null
8816
+ plan: workspace.plan ?? null,
8817
+ ...workspace.archivedAt ? { archived: true } : {}
8800
8818
  };
8801
8819
  }
8802
8820
 
@@ -9112,11 +9130,19 @@ function loadHostPrivateKey(path = hostPrivateKeyPath()) {
9112
9130
  // ../api/src/dashboard/uploads-routes.ts
9113
9131
  import { randomUUID as randomUUID2 } from "crypto";
9114
9132
  import { Hono as Hono7 } from "hono";
9133
+ import sharp4 from "sharp";
9115
9134
  import { PutObjectCommand as PutObjectCommand2, GetObjectCommand as GetObjectCommand3 } from "@aws-sdk/client-s3";
9116
9135
  import { getSignedUrl as getSignedUrl3 } from "@aws-sdk/s3-request-presigner";
9117
9136
  var MAX_FILE_BYTES = 10 * 1024 * 1024;
9118
9137
  var PRESIGN_TTL_SECONDS2 = 300;
9119
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
+ }
9120
9146
  var READABLE_PREFIXES = [KEY_PREFIX, SCRATCH_PREFIX];
9121
9147
  function sanitizeFilename(name) {
9122
9148
  const cleaned = name.replace(/[/\\]/g, "_").replace(/\s+/g, "-").replace(/[^\w.-]/g, "").trim();
@@ -9140,22 +9166,33 @@ function createUploadsRouter() {
9140
9166
  if (file.size > MAX_FILE_BYTES) {
9141
9167
  return c.json({ error: `file must be ${MAX_FILE_BYTES / (1024 * 1024)}MB or smaller` }, 400);
9142
9168
  }
9143
- const key = `${KEY_PREFIX}/${getProfile()}/${randomUUID2()}-${sanitizeFilename(file.name)}`;
9144
- const buffer = Buffer.from(await file.arrayBuffer());
9145
- await getR2Client().send(
9146
- new PutObjectCommand2({
9147
- Bucket: getR2Bucket(),
9148
- Key: key,
9149
- Body: buffer,
9150
- ContentType: file.type || "application/octet-stream"
9151
- })
9152
- );
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
+ }
9153
9190
  return c.json(
9154
9191
  {
9155
9192
  url: `/api/attachments/${encodeKeyForUrl(key)}`,
9156
9193
  name: file.name,
9157
- type: file.type,
9158
- size: file.size
9194
+ type,
9195
+ size
9159
9196
  },
9160
9197
  201
9161
9198
  );
@@ -9540,12 +9577,94 @@ function createAuthRouter() {
9540
9577
  return auth;
9541
9578
  }
9542
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
+
9543
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
+ }
9544
9653
  var requireWorkspaceMembership = async (c, next) => {
9545
9654
  const ctx = await resolveAuthContext(c);
9546
9655
  if (!ctx) return c.json({ error: "not signed in" }, 401);
9547
9656
  c.set("auth", ctx);
9548
- 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
+ }
9549
9668
  };
9550
9669
 
9551
9670
  // ../api/src/dashboard/billing-routes.ts
@@ -9650,7 +9769,14 @@ function createAdminRouter() {
9650
9769
  const admin = new Hono11();
9651
9770
  admin.use("*", requirePlatformOperator);
9652
9771
  admin.get("/workspaces", async (c) => {
9653
- 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]));
9654
9780
  const workspaces = await Promise.all(
9655
9781
  summaries.map(async (w) => {
9656
9782
  const agentModel = cachedProfileConfig(w.slug)?.agentModel;
@@ -9660,11 +9786,23 @@ function createAdminRouter() {
9660
9786
  console.error(`[admin] R2 usage for ${w.slug}:`, err.message);
9661
9787
  return null;
9662
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
+ };
9663
9799
  return {
9664
9800
  ...w,
9801
+ owner,
9665
9802
  agent: agentModel ? { runner: runnerForModel(agentModel), model: agentModel } : null,
9666
9803
  r2Bytes,
9667
- flyVolumeBytes: null
9804
+ flyVolumeBytes: null,
9805
+ supportAccess: supportIds.has(w.id)
9668
9806
  };
9669
9807
  })
9670
9808
  );
@@ -9752,6 +9890,63 @@ function createAdminRouter() {
9752
9890
  }
9753
9891
  return c.json({ sent: true, inviteLink: inviteUrl.toString() });
9754
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
+ });
9755
9950
  return admin;
9756
9951
  }
9757
9952
 
@@ -9761,7 +9956,7 @@ var BOT_DM_KEY = "claude";
9761
9956
 
9762
9957
  // ../api/src/dashboard/data-api.ts
9763
9958
  import { existsSync as existsSync20 } from "fs";
9764
- import sharp5 from "sharp";
9959
+ import sharp6 from "sharp";
9765
9960
 
9766
9961
  // ../api/src/dashboard/automations-routes.ts
9767
9962
  import { Hono as Hono12 } from "hono";
@@ -10255,6 +10450,25 @@ var BOARD_STATUSES = [
10255
10450
  "cancelled"
10256
10451
  ];
10257
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
+ }
10258
10472
  function boardColumnParam(status) {
10259
10473
  if (status === NO_STATUS_COLUMN) return NO_STATUS_COLUMN;
10260
10474
  return BOARD_STATUSES.includes(status) ? status : null;
@@ -10320,7 +10534,9 @@ async function withPreviews(supabase, threads) {
10320
10534
  for (const { data } of chunkResults) {
10321
10535
  for (const r of data ?? []) {
10322
10536
  const entry = previewByThread.get(r.thread_id);
10537
+ const images = r.body.includes("![") ? markdownImageUrls(slackMrkdwnToMarkdown(r.body)) : [];
10323
10538
  if (entry) {
10539
+ entry.images.push(...images);
10324
10540
  entry.count += 1;
10325
10541
  entry.lastAuthor = r.author;
10326
10542
  entry.participants.delete(r.author);
@@ -10330,7 +10546,8 @@ async function withPreviews(supabase, threads) {
10330
10546
  count: 1,
10331
10547
  firstBody: r.body,
10332
10548
  lastAuthor: r.author,
10333
- participants: /* @__PURE__ */ new Map()
10549
+ participants: /* @__PURE__ */ new Map(),
10550
+ images
10334
10551
  });
10335
10552
  }
10336
10553
  }
@@ -10345,10 +10562,19 @@ async function withPreviews(supabase, threads) {
10345
10562
  // read, on this browser or any other (see web/src/lib/thread-unread).
10346
10563
  last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
10347
10564
  // Everyone who replied, most recent first, for the row's avatar stack.
10348
- 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
10349
10570
  };
10350
10571
  });
10351
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
+ }
10352
10578
  async function labelThread(channelId, body, scope) {
10353
10579
  const channel = getProfileChannelRegistry().find(
10354
10580
  (ch) => ch.id === channelId || ch.externalChannelId === channelId
@@ -10758,6 +10984,13 @@ function createChatRouter(agent = {}) {
10758
10984
  const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
10759
10985
  const model = requestedModel(c, input2.model);
10760
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
+ }
10761
10994
  const supabase = getSupabaseClient();
10762
10995
  let thread;
10763
10996
  let replayed = false;
@@ -10768,7 +11001,8 @@ function createChatRouter(agent = {}) {
10768
11001
  // opening message at display time (see threadCrumbLabel).
10769
11002
  title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
10770
11003
  author: input2.author,
10771
- client_key: clientKey2
11004
+ client_key: clientKey2,
11005
+ board_status: pickedStatus ?? null
10772
11006
  }).select().single();
10773
11007
  if (threadError) {
10774
11008
  if (!clientKey2 || threadError.code !== "23505") {
@@ -10809,6 +11043,11 @@ function createChatRouter(agent = {}) {
10809
11043
  effort: effortFromRow(reply["effort"])
10810
11044
  });
10811
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
+ }
10812
11051
  if (!replayed && thread["title"] == null) {
10813
11052
  void generateAutoTitle(thread.id, channelId, input2.body).catch(
10814
11053
  (err) => console.error("Auto title failed:", err)
@@ -10816,6 +11055,15 @@ function createChatRouter(agent = {}) {
10816
11055
  }
10817
11056
  return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
10818
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
+ });
10819
11067
  app.post("/replies/:replyId/fork", async (c) => {
10820
11068
  const replyId = c.req.param("replyId");
10821
11069
  const input2 = await c.req.json().catch(() => null);
@@ -11031,7 +11279,7 @@ function generateShareId() {
11031
11279
  return randomBytes6(9).toString("base64url");
11032
11280
  }
11033
11281
  async function publishDocShare(input2, readBundle) {
11034
- const bundle = await readBundle({ cwd: input2.cwd, sourcePath: input2.sourcePath });
11282
+ const bundle = await readBundle({ channelId: input2.channelId, cwd: input2.cwd, sourcePath: input2.sourcePath });
11035
11283
  if (bundle.status !== "ok") return bundle;
11036
11284
  const existing = await findDocShareBySource(input2.workspaceId, input2.channelId, input2.sourcePath);
11037
11285
  const shareId = existing?.id ?? generateShareId();
@@ -11691,7 +11939,7 @@ function createTranscribeRouter() {
11691
11939
  // ../api/src/dashboard/avatars-routes.ts
11692
11940
  import { createHash as createHash9, randomUUID as randomUUID4 } from "crypto";
11693
11941
  import { Hono as Hono19 } from "hono";
11694
- import sharp4 from "sharp";
11942
+ import sharp5 from "sharp";
11695
11943
  import { PutObjectCommand as PutObjectCommand4, DeleteObjectCommand as DeleteObjectCommand3 } from "@aws-sdk/client-s3";
11696
11944
  var AVATAR_SIZE = 192;
11697
11945
  var MAX_AVATAR_BYTES = 2 * 1024 * 1024;
@@ -11727,7 +11975,7 @@ async function storeAvatar(file, owner) {
11727
11975
  return { error: "avatar must be a PNG or JPEG image" };
11728
11976
  if (file.size > MAX_AVATAR_BYTES) return { error: "avatar must be 2MB or smaller" };
11729
11977
  const original = Buffer.from(await file.arrayBuffer());
11730
- 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();
11731
11979
  const key = `${avatarKeyPrefix()}${owner}-${randomUUID4()}.png`;
11732
11980
  await getR2Client().send(
11733
11981
  new PutObjectCommand4({
@@ -11905,10 +12153,6 @@ function createMembersRouter() {
11905
12153
  if (avatarKey && !avatarKey.startsWith(`${avatarKeyPrefix()}invite-${ctx.userId}-`)) {
11906
12154
  return c.json({ error: "unknown avatar" }, 400);
11907
12155
  }
11908
- const { plan } = await getWorkspaceBilling(ctx.workspaceId);
11909
- if (plan === "local") {
11910
- return c.json({ error: "The Solo plan is for one person. Upgrade to invite members." }, 403);
11911
- }
11912
12156
  const existing = await getMembershipByEmail(ctx.workspaceId, email);
11913
12157
  if (existing) return c.json({ error: "already a member of this workspace" }, 409);
11914
12158
  let membership;
@@ -13421,8 +13665,95 @@ async function buildThreadUsage(threadTs) {
13421
13665
  }
13422
13666
  }
13423
13667
 
13424
- // ../api/src/dashboard/channel-members-routes.ts
13668
+ // ../api/src/dashboard/channel-greeting.ts
13425
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";
13426
13757
  async function isChannelAdmin(ctx, channelId) {
13427
13758
  if (ctx.role === "admin") return true;
13428
13759
  const membership = await getMembershipByUserId(ctx.workspaceId, ctx.userId);
@@ -13431,7 +13762,7 @@ async function isChannelAdmin(ctx, channelId) {
13431
13762
  return members.some((m) => m.membershipId === membership.id && m.role === "channel_admin");
13432
13763
  }
13433
13764
  function createChannelMembersRouter() {
13434
- const router = new Hono25();
13765
+ const router = new Hono26();
13435
13766
  router.get("/:id/members", async (c) => {
13436
13767
  const ctx = await resolveAuthContext(c);
13437
13768
  if (!ctx) return c.json({ error: "workspace access required" }, 403);
@@ -13501,7 +13832,7 @@ function createChannelMembersRouter() {
13501
13832
  }
13502
13833
 
13503
13834
  // ../api/src/dashboard/memories-routes.ts
13504
- import { Hono as Hono26 } from "hono";
13835
+ import { Hono as Hono27 } from "hono";
13505
13836
  async function readPatch(c) {
13506
13837
  const body = await c.req.json().catch(() => ({}));
13507
13838
  const patch = {};
@@ -13529,7 +13860,7 @@ var requireAgentMemory = async (c, next) => {
13529
13860
  await next();
13530
13861
  };
13531
13862
  function createMemoriesRouter() {
13532
- const router = new Hono26();
13863
+ const router = new Hono27();
13533
13864
  for (const path of [
13534
13865
  "/:id/memories",
13535
13866
  "/:id/memories/*",
@@ -13602,7 +13933,7 @@ function createMemoriesRouter() {
13602
13933
  return router;
13603
13934
  }
13604
13935
  function createScopedMemoriesRouter() {
13605
- const router = new Hono26();
13936
+ const router = new Hono27();
13606
13937
  router.use("*", requireAgentMemory);
13607
13938
  const workspace = (c) => ({
13608
13939
  scope: "workspace",
@@ -13833,6 +14164,7 @@ function registerDataRoutes(api, agent = {}) {
13833
14164
  });
13834
14165
  api.route("/channels", createDocShareWriteRouter(agent));
13835
14166
  api.route("/onboarding", createOnboardingRouter(agent));
14167
+ api.route("/channels", createChannelGreetingRouter(agent));
13836
14168
  api.route("/channels", createChannelMembersRouter());
13837
14169
  api.route("/channels", createMemoriesRouter());
13838
14170
  api.route("/memories", createScopedMemoriesRouter());
@@ -14076,7 +14408,7 @@ function registerDataRoutes(api, agent = {}) {
14076
14408
  return c.json({ error: "logo must be 2MB or smaller" }, 400);
14077
14409
  }
14078
14410
  const original = Buffer.from(await file.arrayBuffer());
14079
- 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();
14080
14412
  const logo = `data:image/png;base64,${resized.toString("base64")}`;
14081
14413
  return c.json(await saveWorkspaceBranding({ logo }));
14082
14414
  });
@@ -14086,7 +14418,7 @@ function registerDataRoutes(api, agent = {}) {
14086
14418
  // ../api/src/dashboard/device-auth-routes.ts
14087
14419
  import { verify } from "crypto";
14088
14420
  import { getConnInfo } from "@hono/node-server/conninfo";
14089
- import { Hono as Hono27 } from "hono";
14421
+ import { Hono as Hono28 } from "hono";
14090
14422
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14091
14423
  function cleanText(value, max) {
14092
14424
  if (typeof value !== "string") return null;
@@ -14134,7 +14466,7 @@ async function boundWorkspaceIds(publicKey, userId) {
14134
14466
  return new Set(existing ? (await listDeviceWorkspaces(existing)).map((w) => w.id) : []);
14135
14467
  }
14136
14468
  function createDeviceAuthRouter() {
14137
- const app = new Hono27();
14469
+ const app = new Hono28();
14138
14470
  app.post("/code", async (c) => {
14139
14471
  const body = await readBody(c);
14140
14472
  const publicKey = typeof body["public_key"] === "string" ? body["public_key"] : "";
@@ -14453,7 +14785,7 @@ var workspaceRef = () => ({ kind: "workspace" });
14453
14785
  var channelRef = (c) => ({ kind: "channel", channelId: c.req.param("id") });
14454
14786
 
14455
14787
  // ../api/src/dashboard/output-styles-routes.ts
14456
- import { Hono as Hono28 } from "hono";
14788
+ import { Hono as Hono29 } from "hono";
14457
14789
  function requireAdmin3(auth) {
14458
14790
  return auth?.role === "admin" ? null : "admin access required";
14459
14791
  }
@@ -14473,7 +14805,7 @@ function failure(c, err) {
14473
14805
  return c.json({ error: message }, err instanceof OutputStyleConflictError ? 409 : 500);
14474
14806
  }
14475
14807
  function createOutputStylesRouter() {
14476
- const app = new Hono28();
14808
+ const app = new Hono29();
14477
14809
  app.get("/", (c) => {
14478
14810
  const auth = c.get("auth");
14479
14811
  const denied = requireAdmin3(auth);
@@ -14565,7 +14897,7 @@ function createOutputStylesRouter() {
14565
14897
  return app;
14566
14898
  }
14567
14899
  async function handleOutputStylesRequest(req) {
14568
- const app = new Hono28();
14900
+ const app = new Hono29();
14569
14901
  app.use("*", async (c, next) => {
14570
14902
  c.set("auth", { role: "admin" });
14571
14903
  await next();
@@ -14586,7 +14918,7 @@ import { streamSSE } from "hono/streaming";
14586
14918
 
14587
14919
  // ../api/src/dashboard/api.ts
14588
14920
  function createGatedRouter(agent, register) {
14589
- const api = new Hono29();
14921
+ const api = new Hono30();
14590
14922
  api.use("*", async (c, next) => {
14591
14923
  const known = await listKnownWorkspaces();
14592
14924
  const fromHost = profileFromHost(trustedHost(c), known);
@@ -14635,12 +14967,11 @@ function createGatedRouter(agent, register) {
14635
14967
 
14636
14968
  // src/dashboard/host-api.ts
14637
14969
  import { existsSync as existsSync30, readFileSync as readFileSync24, statSync as statSync8 } from "fs";
14638
- import { join as join26 } from "path";
14639
- import sharp6 from "sharp";
14970
+ import sharp7 from "sharp";
14640
14971
 
14641
14972
  // src/dashboard/channels-routes.ts
14642
14973
  import { existsSync as existsSync22, mkdirSync as mkdirSync10 } from "fs";
14643
- import { Hono as Hono30 } from "hono";
14974
+ import { Hono as Hono31 } from "hono";
14644
14975
  function coerceAccess(raw) {
14645
14976
  if (raw === "open" || raw === "*" || raw === "all") return raw;
14646
14977
  if (Array.isArray(raw) && raw.every((v) => typeof v === "string")) return raw;
@@ -14703,7 +15034,7 @@ function toResponse3(channel) {
14703
15034
  };
14704
15035
  }
14705
15036
  function createChannelWriteRouter() {
14706
- const app = new Hono30();
15037
+ const app = new Hono31();
14707
15038
  app.post("/", async (c) => {
14708
15039
  const body = await c.req.json().catch(() => ({}));
14709
15040
  const name = typeof body["name"] === "string" ? body["name"].trim() : "";
@@ -14843,7 +15174,7 @@ import {
14843
15174
  } from "fs";
14844
15175
  import { Readable } from "stream";
14845
15176
  import { basename as basename8, dirname as dirname8, extname, join as join23, relative, resolve as resolve5, sep } from "path";
14846
- import { Hono as Hono31 } from "hono";
15177
+ import { Hono as Hono32 } from "hono";
14847
15178
  import { streamSSE as streamSSE2 } from "hono/streaming";
14848
15179
 
14849
15180
  // src/dashboard/docs-watcher.ts
@@ -17724,7 +18055,7 @@ function deleteDocEntry(root, path) {
17724
18055
  };
17725
18056
  }
17726
18057
  function createDocsRouter({ gate = requireDocsAccess } = {}) {
17727
- const app = new Hono31();
18058
+ const app = new Hono32();
17728
18059
  app.use("/:id/docs", gate);
17729
18060
  app.use("/:id/docs/*", gate);
17730
18061
  app.get("/:id/docs", (c) => {
@@ -18062,7 +18393,7 @@ function withDocLinkBridge(html) {
18062
18393
  // src/dashboard/skills-routes-host.ts
18063
18394
  import { existsSync as existsSync27, statSync as statSync7 } from "fs";
18064
18395
  import { dirname as dirname9 } from "path";
18065
- import { Hono as Hono32 } from "hono";
18396
+ import { Hono as Hono33 } from "hono";
18066
18397
  function workspaceScope() {
18067
18398
  const claudeConfigDir = profileClaudeConfigDir();
18068
18399
  const cwd = defaultClaudeCwd();
@@ -18168,12 +18499,12 @@ var diskBackend = {
18168
18499
  write: writeSkillAndSync
18169
18500
  };
18170
18501
  function createWorkspaceSkillsRouter() {
18171
- const app = new Hono32();
18502
+ const app = new Hono33();
18172
18503
  mountSkillRoutes(app, "/skills", workspaceRef, diskBackend);
18173
18504
  return app;
18174
18505
  }
18175
18506
  function createChannelSkillsRouter() {
18176
- const app = new Hono32();
18507
+ const app = new Hono33();
18177
18508
  mountSkillRoutes(app, "/:id/skill-files", channelRef, diskBackend);
18178
18509
  return app;
18179
18510
  }
@@ -18308,7 +18639,7 @@ import { randomUUID as randomUUID5 } from "crypto";
18308
18639
  import { chmodSync, existsSync as existsSync29, mkdirSync as mkdirSync12, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "fs";
18309
18640
  import { createRequire } from "module";
18310
18641
  import { dirname as dirname10, join as join25 } from "path";
18311
- import { Hono as Hono33 } from "hono";
18642
+ import { Hono as Hono34 } from "hono";
18312
18643
  var SESSION_TTL_MS = 10 * 6e4;
18313
18644
  var URL_WAIT_MS = 3e4;
18314
18645
  var TOKEN_WAIT_MS = 6e4;
@@ -18465,7 +18796,7 @@ function failure2(c, err) {
18465
18796
  return c.json({ error: err.message }, 500);
18466
18797
  }
18467
18798
  function createProviderLoginRouter() {
18468
- const app = new Hono33();
18799
+ const app = new Hono34();
18469
18800
  app.use("*", async (c, next) => {
18470
18801
  const denied = requireAdmin4(c.get("auth"));
18471
18802
  if (denied) return c.json({ error: denied }, 403);
@@ -18509,7 +18840,7 @@ function createProviderLoginRouter() {
18509
18840
  return app;
18510
18841
  }
18511
18842
  async function handleProviderLoginRequest(req) {
18512
- const app = new Hono33();
18843
+ const app = new Hono34();
18513
18844
  app.use("*", async (c, next) => {
18514
18845
  c.set("auth", { role: "admin" });
18515
18846
  await next();
@@ -18617,7 +18948,7 @@ function registerHostRoutes(api) {
18617
18948
  return c.json({ error: "avatar must be 2MB or smaller" }, 400);
18618
18949
  }
18619
18950
  const original = Buffer.from(await file.arrayBuffer());
18620
- 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();
18621
18952
  const avatar = `data:image/png;base64,${resized.toString("base64")}`;
18622
18953
  const name = c.req.param("name");
18623
18954
  if (name === BOT_PROFILE_KEY) return c.json(await saveBotIdentity({ avatar }));
@@ -18631,8 +18962,7 @@ function registerHostRoutes(api) {
18631
18962
  api.get("/logs", (c) => {
18632
18963
  const errors = c.req.query("error") === "true" || c.req.query("error") === "1";
18633
18964
  const limit = Math.min(Number(c.req.query("limit")) || 200, 2e3);
18634
- const filename = errors ? "custie-error.log" : "custie.log";
18635
- const file = join26(paths.LOG_DIR, filename);
18965
+ const file = serviceLogFile(errors);
18636
18966
  const exists = existsSync30(file);
18637
18967
  return c.json({
18638
18968
  file,
@@ -18667,13 +18997,13 @@ import dotenv2 from "dotenv";
18667
18997
  import { WebSocket as WebSocket2 } from "ws";
18668
18998
 
18669
18999
  // src/dashboard/docs-relay-host.ts
18670
- import { Hono as Hono34 } from "hono";
19000
+ import { Hono as Hono35 } from "hono";
18671
19001
  var RETURNED_HEADERS = ["content-type", "content-disposition", "content-security-policy", "cache-control"];
18672
19002
  async function handleDocsRequest(req) {
18673
19003
  return runRelayed(req, (app) => app.route("/channels", createDocsRouter({ gate: async (_c, next) => next() })));
18674
19004
  }
18675
19005
  async function runRelayed(req, register) {
18676
- const app = new Hono34();
19006
+ const app = new Hono35();
18677
19007
  app.use("*", async (c, next) => {
18678
19008
  c.set("auth", req.auth);
18679
19009
  await next();
@@ -18928,12 +19258,12 @@ function readDocShareBundle({ cwd, sourcePath }) {
18928
19258
  // src/dashboard/host-upgrade.ts
18929
19259
  import { spawn as spawn7 } from "child_process";
18930
19260
  import { mkdirSync as mkdirSync13, openSync as openSync3 } from "fs";
18931
- import { join as join27 } from "path";
19261
+ import { join as join26 } from "path";
18932
19262
  var running = false;
18933
19263
  function startHostUpgrade() {
18934
19264
  if (running) return { started: false };
18935
19265
  mkdirSync13(paths.LOG_DIR, { recursive: true });
18936
- const log5 = openSync3(join27(paths.LOG_DIR, "host-upgrade.log"), "a");
19266
+ const log5 = openSync3(join26(paths.LOG_DIR, "host-upgrade.log"), "a");
18937
19267
  const child = spawn7(process.execPath, [process.argv[1] ?? "", "upgrade"], {
18938
19268
  detached: true,
18939
19269
  stdio: ["ignore", log5, log5]
@@ -18974,9 +19304,12 @@ function localAgentCallbacks(chatAgent) {
18974
19304
  listMissingCwds: () => auditChannelCwds().map((ch) => ch.id),
18975
19305
  listRunningProfiles: () => listProfiles().filter(isServiceRunning2),
18976
19306
  getAgentIdentities: () => resolveAgentIdentities(),
18977
- readDocShareBundle: (req) => readDocShareBundle(req),
18978
- listChannelSkills: (args) => listSkills(args.claudeConfigDir, args.cwd),
18979
- 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 }),
18980
19313
  getLocalDashboardSettings: () => loadDashboardSettings(),
18981
19314
  saveLocalDashboardSettings: (patch) => saveDashboardSettings(patch),
18982
19315
  writeSkill: (ref, write) => writeSkillAndSync(ref, write),
@@ -19247,11 +19580,11 @@ function startPushWatcher() {
19247
19580
 
19248
19581
  // src/dashboard/machine-server.ts
19249
19582
  import { existsSync as existsSync32 } from "fs";
19250
- import { join as join28 } from "path";
19583
+ import { join as join27 } from "path";
19251
19584
  import { serveStatic } from "@hono/node-server/serve-static";
19252
19585
 
19253
19586
  // src/dashboard/app-icon-routes.ts
19254
- import { Hono as Hono35 } from "hono";
19587
+ import { Hono as Hono36 } from "hono";
19255
19588
  var NEUTRAL_MANIFEST = {
19256
19589
  id: "/",
19257
19590
  name: "Flyspace",
@@ -19289,7 +19622,7 @@ async function withResolvedProfile2(c, next) {
19289
19622
  return profile ? runWithProfile(profile, () => next()) : next();
19290
19623
  }
19291
19624
  function createAppIconRouter() {
19292
- const app = new Hono35();
19625
+ const app = new Hono36();
19293
19626
  app.get("/manifest.json", withResolvedProfile2, async (c) => {
19294
19627
  if (!await resolvedProfileHost(c)) return c.json(NEUTRAL_MANIFEST);
19295
19628
  const profile = getProfile();
@@ -19329,7 +19662,7 @@ function pngResponse(c, png) {
19329
19662
  }
19330
19663
 
19331
19664
  // src/dashboard/machine-server.ts
19332
- var WEB_DIST = join28(paths.WEB_ROOT, "dist");
19665
+ var WEB_DIST = join27(paths.WEB_ROOT, "dist");
19333
19666
  async function bootMachine() {
19334
19667
  await reconcileAll().catch(
19335
19668
  (err) => console.error("[dashboard] reconcileAll failed:", err.message)
@@ -19349,14 +19682,14 @@ async function bootMachine() {
19349
19682
  function mountSpa(app) {
19350
19683
  app.route("/share", createPublicShareAssetRouter(WEB_DIST));
19351
19684
  app.route("/", createAppIconRouter());
19352
- if (existsSync32(WEB_DIST) || existsSync32(join28(paths.WEB_ROOT, "package.json"))) {
19685
+ if (existsSync32(WEB_DIST) || existsSync32(join27(paths.WEB_ROOT, "package.json"))) {
19353
19686
  app.use("/*", serveStatic({ root: WEB_DIST }));
19354
19687
  }
19355
19688
  app.get("/assets/*", (c) => c.text("stale asset \u2014 reload for the current build", 404));
19356
- const spaFallback = serveStatic({ path: join28(WEB_DIST, "index.html") });
19689
+ const spaFallback = serveStatic({ path: join27(WEB_DIST, "index.html") });
19357
19690
  app.get("*", async (c, next) => {
19358
- if (!existsSync32(join28(WEB_DIST, "index.html"))) {
19359
- const isCheckout = existsSync32(join28(paths.WEB_ROOT, "package.json"));
19691
+ if (!existsSync32(join27(WEB_DIST, "index.html"))) {
19692
+ const isCheckout = existsSync32(join27(paths.WEB_ROOT, "package.json"));
19360
19693
  return c.text(
19361
19694
  isCheckout ? `Workspace UI build missing (${WEB_DIST}).
19362
19695
  Run \`pnpm build:web\` from the repo root.
@@ -19490,8 +19823,8 @@ function openBrowser2(url) {
19490
19823
  }
19491
19824
  }
19492
19825
  function ensureWebBuild() {
19493
- if (existsSync33(join29(paths.WEB_ROOT, "dist", "index.html"))) return;
19494
- if (!existsSync33(join29(paths.WEB_ROOT, "package.json"))) return;
19826
+ if (existsSync33(join28(paths.WEB_ROOT, "dist", "index.html"))) return;
19827
+ if (!existsSync33(join28(paths.WEB_ROOT, "package.json"))) return;
19495
19828
  console.log(" packages/ui/dist missing, building the dashboard UI...");
19496
19829
  const result = spawnSync("pnpm", ["build"], {
19497
19830
  cwd: paths.WEB_ROOT,
@@ -19722,10 +20055,10 @@ async function runReconcileCmd(args) {
19722
20055
  }
19723
20056
 
19724
20057
  // src/cli.ts
19725
- import { join as join30 } from "path";
20058
+ import { join as join29 } from "path";
19726
20059
  var home = flyspaceHome();
19727
20060
  if (home) {
19728
- const bin = join30(home, "bin");
20061
+ const bin = join29(home, "bin");
19729
20062
  const path = process.env.PATH ?? "";
19730
20063
  if (!path.split(":").includes(bin)) process.env.PATH = path ? `${bin}:${path}` : bin;
19731
20064
  }
@@ -19863,7 +20196,7 @@ async function main() {
19863
20196
  break;
19864
20197
  case "-v":
19865
20198
  case "--version":
19866
- printRaw("0.1.31");
20199
+ printRaw("0.1.33");
19867
20200
  break;
19868
20201
  case "-h":
19869
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.31",
3
+ "version": "0.1.33",
4
4
  "description": "AI-native headless community OS",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,