@sideboard-ai/core 0.1.68 → 0.1.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/{agents-PE3RHQS5.js → agents-GB7XL2LI.js} +6 -5
  2. package/dist/{agents-JBD7IAGK.js → agents-TCAK3MXN.js} +6 -5
  3. package/dist/{app-settings-ONKMFSEJ.js → app-settings-3TLA2EJ7.js} +16 -1
  4. package/dist/{app-settings-GJVZGU3M.js → app-settings-P42Z3VOB.js} +16 -1
  5. package/dist/caffeinate-hold-BEJIYPJ7.js +107 -0
  6. package/dist/caffeinate-hold-KLC6SABD.js +14 -0
  7. package/dist/{chunk-DPDGMMGI.js → chunk-3CY6WJ7O.js} +2 -2
  8. package/dist/{chunk-ROXEENBT.js → chunk-57ROTAFD.js} +62 -3
  9. package/dist/{chunk-N5LMEP65.js → chunk-7NCIHRAU.js} +22 -4
  10. package/dist/{chunk-OICHV4DH.js → chunk-DGPUAZA4.js} +62 -3
  11. package/dist/{chunk-VX6ACNXS.js → chunk-H2IXNFQ2.js} +22 -4
  12. package/dist/chunk-JAWEDVVA.js +106 -0
  13. package/dist/chunk-JT7R45JB.js +20 -0
  14. package/dist/{chunk-EEBDVLDK.js → chunk-MF2YMEGD.js} +61 -9
  15. package/dist/{chunk-47RBLBRK.js → chunk-NG7S3OPX.js} +10 -4
  16. package/dist/chunk-NSTQ6QKD.js +23 -0
  17. package/dist/{chunk-REKGFVUX.js → chunk-OE7YBB5X.js} +61 -9
  18. package/dist/{chunk-BPOTVI5J.js → chunk-PTJJIQK7.js} +472 -18
  19. package/dist/{chunk-6Q7H6SMX.js → chunk-S25IHL5H.js} +10 -4
  20. package/dist/{chunk-QQC2D4MY.js → chunk-UH57WVFP.js} +2 -2
  21. package/dist/{chunk-MHR4OGZ4.js → chunk-W7YOTDVO.js} +493 -18
  22. package/dist/{coordinator-prompt-YZYZ5P6P.js → coordinator-prompt-2J4PIMUF.js} +6 -3
  23. package/dist/{coordinator-prompt-HD6IMCKS.js → coordinator-prompt-QYYG2IBB.js} +6 -3
  24. package/dist/{global-workspace-Z6GF7D2K.js → global-workspace-KHIFQUUM.js} +11 -4
  25. package/dist/{global-workspace-7P5NG3JZ.js → global-workspace-SVRYNJZA.js} +11 -4
  26. package/dist/index.cjs +4184 -850
  27. package/dist/index.d.cts +543 -21
  28. package/dist/index.d.ts +543 -21
  29. package/dist/index.js +3285 -748
  30. package/dist/mcp/run-stdio.cjs +1995 -482
  31. package/dist/mcp/run-stdio.js +943 -151
  32. package/dist/{workspaces-BWJNGEE6.js → workspaces-AHFTHVBT.js} +6 -5
  33. package/dist/{workspaces-O6EZGKQK.js → workspaces-SVQIEVIZ.js} +6 -5
  34. package/dist/{worktree-VDXLUOWR.js → worktree-KWXHMKRN.js} +1 -1
  35. package/dist/{worktree-ZPSKEZFE.js → worktree-VOCH3WS3.js} +1 -1
  36. package/package.json +3 -1
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  listWorkspaces,
5
5
  removeWorkspace,
6
6
  syncWorkspacesFromThreads
7
- } from "./chunk-DPDGMMGI.js";
7
+ } from "./chunk-3CY6WJ7O.js";
8
8
  import {
9
9
  BRIGHTSY_MCP_ALLOWED_TOOLS,
10
10
  CLAUDE_MODEL_CATALOG,
@@ -58,7 +58,7 @@ import {
58
58
  sideboardMcpProfile,
59
59
  threadRequestsBrightsyMcp,
60
60
  writeInjectedMcpConfig
61
- } from "./chunk-6Q7H6SMX.js";
61
+ } from "./chunk-S25IHL5H.js";
62
62
  import {
63
63
  CLOUD_COORDINATOR_BUSY_REPLY,
64
64
  CLOUD_COORDINATOR_STOPPED_REPLY,
@@ -71,18 +71,30 @@ import {
71
71
  coerceOrchestratorAgent,
72
72
  createGlobalChat,
73
73
  ensureCloudCoordinator,
74
+ ensureSlackCoordinator,
74
75
  healOrchestrationSoccerTitles,
75
76
  isCloudCoordinatorThread,
76
77
  isGlobalRepoPath,
77
78
  isGlobalThread,
78
79
  isOrchestratorCapableAgent,
79
80
  isOrchestratorThread,
81
+ isSlackCoordinatorThread,
80
82
  listGlobalThreads,
81
83
  orchestrationTitleNeedsSoccerNickname,
82
84
  orchestratorSessionPoisonedByBuiltins,
83
85
  parseForceStopMessage,
86
+ slackCoordinatorSourceRef,
84
87
  takenTeamSlugsForOrchestration
85
- } from "./chunk-ROXEENBT.js";
88
+ } from "./chunk-57ROTAFD.js";
89
+ import {
90
+ COORDINATOR_TOOL_PLAYBOOK,
91
+ SLACK_REPLY_FORMATTING,
92
+ coordinatorSystemPrompt,
93
+ coordinatorTurnReminder,
94
+ enrichWorkspacesWithGithub,
95
+ ensureGlobalCoordinatorCwd,
96
+ formatWorkspaceInventory
97
+ } from "./chunk-H2IXNFQ2.js";
86
98
  import {
87
99
  brightsyConfigPath,
88
100
  brightsyMcpServerName,
@@ -117,14 +129,6 @@ import {
117
129
  pushTurnStderr,
118
130
  summarizeTurnStderr
119
131
  } from "./chunk-3WJAUKIL.js";
120
- import {
121
- COORDINATOR_TOOL_PLAYBOOK,
122
- coordinatorSystemPrompt,
123
- coordinatorTurnReminder,
124
- enrichWorkspacesWithGithub,
125
- ensureGlobalCoordinatorCwd,
126
- formatWorkspaceInventory
127
- } from "./chunk-VX6ACNXS.js";
128
132
  import {
129
133
  HARNESS_ENV_KEYS,
130
134
  appSettingsPath,
@@ -138,10 +142,13 @@ import {
138
142
  brightsyInjectWorktreeMcpEnabled,
139
143
  caffeinateWhileCloudConnectEnabled,
140
144
  caffeinateWhileRunningEnabled,
145
+ caffeinateWhileSlackListenEnabled,
141
146
  childEnvWithAppSettings,
142
147
  claudeChromeEnabled,
143
148
  claudeUserSettingsPath,
144
149
  deleteBranchOnPurgeEnabled,
150
+ disconnectLinearConnection,
151
+ ensureSlackDeviceIdentity,
145
152
  getDefaultAgent,
146
153
  getDefaultEffort,
147
154
  getDefaultFast,
@@ -150,16 +157,27 @@ import {
150
157
  getLinearApiKey,
151
158
  harnessEnvKey,
152
159
  isLinearConnected,
160
+ isSecureFileEncrypted,
153
161
  loadAppSettings,
154
162
  maxConcurrentAgents,
155
163
  orchestrationQuotaFallbackAgent,
156
164
  orchestrationQuotaOnLimit,
165
+ persistVaultKeyInKeychain,
166
+ readKeychainVaultKey,
167
+ readSecureJson,
157
168
  resolveAgentExecutable,
158
169
  resolveClaudeExecutable,
159
170
  resolveEffectiveIssueSource,
160
171
  resolveNewThreadOptions,
161
172
  resolveThreadDefaults,
173
+ resolveVaultKey,
162
174
  saveAppSettings,
175
+ saveLinearOAuth,
176
+ secureFileUnlocksWith,
177
+ setVaultMasterKey,
178
+ slackAppLevelToken,
179
+ slackListenEnabled,
180
+ toPublicAppSettings,
163
181
  updateAdvancedSettings,
164
182
  updateAgentExecutable,
165
183
  updateAppEnvironment,
@@ -168,8 +186,17 @@ import {
168
186
  updateCodexSettings,
169
187
  updateDefaultsSettings,
170
188
  updateIntegrationsSettings,
171
- updateOpencodeSettings
172
- } from "./chunk-MHR4OGZ4.js";
189
+ updateOpencodeSettings,
190
+ writeSecureJson
191
+ } from "./chunk-W7YOTDVO.js";
192
+ import {
193
+ caffeinateHoldPath,
194
+ getCaffeinateHold,
195
+ setCaffeinateHold
196
+ } from "./chunk-JAWEDVVA.js";
197
+ import {
198
+ writePrivateFile
199
+ } from "./chunk-JT7R45JB.js";
173
200
  import {
174
201
  FAMOUS_SOCCER_TEAMS,
175
202
  addPrStackLayer,
@@ -232,7 +259,7 @@ import {
232
259
  worktreeDisplayLabel,
233
260
  worktreeDisplayLabelForGroup,
234
261
  worktreeNameFromPath
235
- } from "./chunk-EEBDVLDK.js";
262
+ } from "./chunk-MF2YMEGD.js";
236
263
  import {
237
264
  ATTACHMENTS_DIR,
238
265
  LEGACY_ATTACHMENTS_DIR,
@@ -322,9 +349,9 @@ async function getGitHubStatus() {
322
349
  };
323
350
  }
324
351
  const status = await run("gh", ["auth", "status"], { reject: false });
325
- const text = `${status.stdout}
352
+ const text2 = `${status.stdout}
326
353
  ${status.stderr}`;
327
- const loginMatch = text.match(/Logged in to ([^\s]+) account (\S+)/i) ?? text.match(/Logged in to ([^\s]+) as (\S+)/i);
354
+ const loginMatch = text2.match(/Logged in to ([^\s]+) account (\S+)/i) ?? text2.match(/Logged in to ([^\s]+) as (\S+)/i);
328
355
  if (loginMatch) {
329
356
  return {
330
357
  connected: true,
@@ -344,8 +371,255 @@ async function refreshGitHubAuth() {
344
371
  return getGitHubStatus();
345
372
  }
346
373
 
347
- // src/integrations/linear.ts
374
+ // src/integrations/linear-oauth.ts
375
+ import { createHash, randomBytes } from "crypto";
376
+ import { createServer } from "http";
377
+
378
+ // src/integrations/linear-app.ts
379
+ var BAKED_LINEAR_CLIENT_ID = "39a15abc7ea5bea17c47f47f85ff5fd0";
380
+ var BAKED_LINEAR_CLIENT_SECRET = "";
381
+ function hasBakedLinearOAuth() {
382
+ return Boolean(BAKED_LINEAR_CLIENT_ID.trim());
383
+ }
384
+
385
+ // src/integrations/linear-oauth.ts
386
+ var LINEAR_OAUTH_PORT = 19848;
387
+ var LINEAR_OAUTH_REDIRECT = `http://127.0.0.1:${LINEAR_OAUTH_PORT}/callback`;
388
+ var LINEAR_AUTHORIZE = "https://linear.app/oauth/authorize";
389
+ var LINEAR_TOKEN = "https://api.linear.app/oauth/token";
390
+ var LINEAR_REVOKE = "https://api.linear.app/oauth/revoke";
348
391
  var LINEAR_GRAPHQL = "https://api.linear.app/graphql";
392
+ var LINEAR_OAUTH_SCOPES = "read";
393
+ var REFRESH_SKEW_MS = 5 * 6e4;
394
+ function linearOAuthCredentials() {
395
+ const settings = loadAppSettings();
396
+ const clientId = process.env.SIDEBOARD_LINEAR_CLIENT_ID?.trim() || settings.integrations.linearClientId?.trim() || BAKED_LINEAR_CLIENT_ID.trim();
397
+ const clientSecret = process.env.SIDEBOARD_LINEAR_CLIENT_SECRET?.trim() || settings.integrations.linearClientSecret?.trim() || BAKED_LINEAR_CLIENT_SECRET.trim();
398
+ if (!clientId) {
399
+ throw new Error(
400
+ `Linear browser sign-in needs a Linear OAuth app Client ID. Create one at linear.app/settings/api/applications/new with callback ${LINEAR_OAUTH_REDIRECT}, then set SIDEBOARD_LINEAR_CLIENT_ID (PKCE does not require a secret). You can still paste a personal API key.`
401
+ );
402
+ }
403
+ return { clientId, clientSecret };
404
+ }
405
+ function createLinearPkce() {
406
+ const verifier = randomBytes(32).toString("base64url");
407
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
408
+ return { verifier, challenge };
409
+ }
410
+ function linearOAuthAuthorizeUrl(clientId, state, codeChallenge) {
411
+ const params = new URLSearchParams({
412
+ client_id: clientId,
413
+ redirect_uri: LINEAR_OAUTH_REDIRECT,
414
+ response_type: "code",
415
+ scope: LINEAR_OAUTH_SCOPES,
416
+ state,
417
+ prompt: "consent",
418
+ actor: "user",
419
+ code_challenge: codeChallenge,
420
+ code_challenge_method: "S256"
421
+ });
422
+ return `${LINEAR_AUTHORIZE}?${params.toString()}`;
423
+ }
424
+ function linearAuthorizationHeader(token) {
425
+ const t = token.trim();
426
+ if (!t) return t;
427
+ if (/^bearer\s+/i.test(t)) return t;
428
+ if (t.startsWith("lin_api_")) return t;
429
+ return `Bearer ${t}`;
430
+ }
431
+ function htmlPage(title, body) {
432
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
433
+ <style>body{font-family:ui-sans-serif,system-ui,sans-serif;padding:48px 24px;max-width:36rem;margin:0 auto;color:#1a1a1a}
434
+ h1{font-size:1.25rem}p{line-height:1.5;color:#444}</style></head><body>${body}</body></html>`;
435
+ }
436
+ async function exchangeLinearToken(body) {
437
+ const res = await fetch(LINEAR_TOKEN, {
438
+ method: "POST",
439
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
440
+ body
441
+ });
442
+ const data = await res.json().catch(() => ({}));
443
+ if (!res.ok || data.error || !data.access_token?.trim()) {
444
+ const detail = data.error_description || data.error || `HTTP ${res.status}`;
445
+ throw new Error(`Linear OAuth token exchange failed: ${detail}`);
446
+ }
447
+ return data;
448
+ }
449
+ async function persistLinearTokens(data, viewer) {
450
+ return saveLinearOAuth({
451
+ accessToken: data.access_token.trim(),
452
+ refreshToken: data.refresh_token?.trim(),
453
+ expiresIn: data.expires_in,
454
+ viewerName: viewer?.name,
455
+ organizationName: viewer?.organizationName
456
+ });
457
+ }
458
+ async function fetchLinearViewer(accessToken) {
459
+ const res = await fetch(LINEAR_GRAPHQL, {
460
+ method: "POST",
461
+ headers: {
462
+ "Content-Type": "application/json",
463
+ Authorization: linearAuthorizationHeader(accessToken)
464
+ },
465
+ body: JSON.stringify({
466
+ query: "{ viewer { name organization { name } } }"
467
+ })
468
+ });
469
+ if (!res.ok) return {};
470
+ const json = await res.json().catch(() => ({}));
471
+ const viewer = json.data?.viewer;
472
+ return {
473
+ name: viewer?.name?.trim() || void 0,
474
+ organizationName: viewer?.organization?.name?.trim() || void 0
475
+ };
476
+ }
477
+ var refreshInFlight = null;
478
+ async function refreshLinearAccessToken(refreshToken) {
479
+ const { clientId, clientSecret } = linearOAuthCredentials();
480
+ const body = new URLSearchParams({
481
+ grant_type: "refresh_token",
482
+ refresh_token: refreshToken,
483
+ client_id: clientId
484
+ });
485
+ if (clientSecret) body.set("client_secret", clientSecret);
486
+ try {
487
+ const data = await exchangeLinearToken(body);
488
+ await persistLinearTokens(data);
489
+ return data.access_token.trim();
490
+ } catch (err) {
491
+ const message = err instanceof Error ? err.message : String(err);
492
+ if (/invalid_grant|invalid_token|unauthorized/i.test(message)) {
493
+ disconnectLinearConnection();
494
+ throw new Error("Linear sign-in expired \u2014 connect again in Account settings");
495
+ }
496
+ throw err;
497
+ }
498
+ }
499
+ async function getLinearAuthToken(settings = loadAppSettings()) {
500
+ const access = settings.integrations.linearAccessToken?.trim() || "";
501
+ const refresh = settings.integrations.linearRefreshToken?.trim() || "";
502
+ const expiresAt = settings.integrations.linearTokenExpiresAt ?? 0;
503
+ const apiKey = settings.integrations.linearApiKey?.trim() || "";
504
+ if (access && (!refresh || Date.now() < expiresAt - REFRESH_SKEW_MS)) {
505
+ return access;
506
+ }
507
+ if (refresh) {
508
+ if (!refreshInFlight) {
509
+ refreshInFlight = refreshLinearAccessToken(refresh).finally(() => {
510
+ refreshInFlight = null;
511
+ });
512
+ }
513
+ return refreshInFlight.catch((err) => {
514
+ if (apiKey) return apiKey;
515
+ throw err;
516
+ });
517
+ }
518
+ return apiKey || null;
519
+ }
520
+ async function startLinearOAuth(opts) {
521
+ const { clientId, clientSecret } = linearOAuthCredentials();
522
+ const state = randomBytes(16).toString("hex");
523
+ const pkce = createLinearPkce();
524
+ const authorizeUrl = linearOAuthAuthorizeUrl(clientId, state, pkce.challenge);
525
+ const timeoutMs = opts?.timeoutMs ?? 5 * 6e4;
526
+ const code = await new Promise((resolve, reject) => {
527
+ const server = createServer((req, res) => {
528
+ try {
529
+ const url = new URL(req.url || "/", `http://127.0.0.1:${LINEAR_OAUTH_PORT}`);
530
+ if (url.pathname !== "/callback") {
531
+ res.writeHead(404);
532
+ res.end("Not found");
533
+ return;
534
+ }
535
+ const err = url.searchParams.get("error");
536
+ const gotState = url.searchParams.get("state");
537
+ const gotCode = url.searchParams.get("code");
538
+ if (err) {
539
+ res.writeHead(400, { "Content-Type": "text/html" });
540
+ res.end(htmlPage("Linear", `<h1>Authorization cancelled</h1><p>${err}</p>`));
541
+ cleanup();
542
+ reject(new Error(`Linear OAuth: ${err}`));
543
+ return;
544
+ }
545
+ if (gotState !== state || !gotCode) {
546
+ res.writeHead(400, { "Content-Type": "text/html" });
547
+ res.end(htmlPage("Linear", "<h1>Invalid callback</h1><p>State or code missing.</p>"));
548
+ cleanup();
549
+ reject(new Error("Linear OAuth callback was invalid"));
550
+ return;
551
+ }
552
+ res.writeHead(200, { "Content-Type": "text/html" });
553
+ res.end(
554
+ htmlPage(
555
+ "Linear connected",
556
+ "<h1>Linear connected</h1><p>You can close this tab and return to Sideboard.</p>"
557
+ )
558
+ );
559
+ cleanup();
560
+ resolve(gotCode);
561
+ } catch (e) {
562
+ cleanup();
563
+ reject(e instanceof Error ? e : new Error(String(e)));
564
+ }
565
+ });
566
+ const timer = setTimeout(() => {
567
+ cleanup();
568
+ reject(new Error("Linear sign-in timed out \u2014 try again"));
569
+ }, timeoutMs);
570
+ const cleanup = () => {
571
+ clearTimeout(timer);
572
+ server.close();
573
+ };
574
+ server.on("error", (e) => {
575
+ cleanup();
576
+ reject(
577
+ e instanceof Error && e.code === "EADDRINUSE" ? new Error(
578
+ `Port ${LINEAR_OAUTH_PORT} is in use. Close whatever is bound there, or paste a Linear API key instead.`
579
+ ) : e
580
+ );
581
+ });
582
+ server.listen(LINEAR_OAUTH_PORT, "127.0.0.1", () => {
583
+ void Promise.resolve(opts?.openUrl?.(authorizeUrl)).catch((e) => {
584
+ cleanup();
585
+ reject(e instanceof Error ? e : new Error(String(e)));
586
+ });
587
+ });
588
+ });
589
+ const body = new URLSearchParams({
590
+ grant_type: "authorization_code",
591
+ code,
592
+ redirect_uri: LINEAR_OAUTH_REDIRECT,
593
+ client_id: clientId,
594
+ code_verifier: pkce.verifier
595
+ });
596
+ if (clientSecret) body.set("client_secret", clientSecret);
597
+ const data = await exchangeLinearToken(body);
598
+ const viewer = await fetchLinearViewer(data.access_token.trim()).catch(() => ({}));
599
+ return persistLinearTokens(data, viewer);
600
+ }
601
+ async function revokeLinearToken(token, tokenTypeHint) {
602
+ const body = new URLSearchParams({
603
+ token,
604
+ token_type_hint: tokenTypeHint
605
+ });
606
+ await fetch(LINEAR_REVOKE, {
607
+ method: "POST",
608
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
609
+ body
610
+ }).catch(() => void 0);
611
+ }
612
+ async function disconnectLinear() {
613
+ const settings = loadAppSettings();
614
+ const access = settings.integrations.linearAccessToken?.trim();
615
+ const refresh = settings.integrations.linearRefreshToken?.trim();
616
+ if (access) await revokeLinearToken(access, "access_token");
617
+ if (refresh) await revokeLinearToken(refresh, "refresh_token");
618
+ return disconnectLinearConnection();
619
+ }
620
+
621
+ // src/integrations/linear.ts
622
+ var LINEAR_GRAPHQL2 = "https://api.linear.app/graphql";
349
623
  var ASSIGNED_ISSUES_QUERY = `
350
624
  query SideboardAssignedIssues($first: Int!) {
351
625
  viewer {
@@ -366,16 +640,16 @@ query SideboardAssignedIssues($first: Int!) {
366
640
  }
367
641
  `;
368
642
  async function listLinearIssuesDirect(opts) {
369
- const apiKey = (opts?.apiKey ?? getLinearApiKey())?.trim();
643
+ const apiKey = (opts?.apiKey ?? await getLinearAuthToken())?.trim();
370
644
  if (!apiKey) {
371
- throw new Error("Linear is not connected \u2014 add an API key in Account settings");
645
+ throw new Error("Linear is not connected \u2014 sign in from Account settings");
372
646
  }
373
647
  const first = Math.max(1, Math.min(100, opts?.limit ?? 50));
374
- const res = await fetch(LINEAR_GRAPHQL, {
648
+ const res = await fetch(LINEAR_GRAPHQL2, {
375
649
  method: "POST",
376
650
  headers: {
377
651
  "Content-Type": "application/json",
378
- Authorization: apiKey
652
+ Authorization: linearAuthorizationHeader(apiKey)
379
653
  },
380
654
  body: JSON.stringify({
381
655
  query: ASSIGNED_ISSUES_QUERY,
@@ -545,9 +819,9 @@ function toolFilePath(input) {
545
819
  if (!input) return void 0;
546
820
  return str(input.file_path) ?? str(input.path) ?? str(input.filePath) ?? str(input.filename);
547
821
  }
548
- function countLines(text) {
549
- if (!text) return 0;
550
- return text.split("\n").length;
822
+ function countLines(text2) {
823
+ if (!text2) return 0;
824
+ return text2.split("\n").length;
551
825
  }
552
826
  function diffFromInput(input) {
553
827
  if (!input) return {};
@@ -674,9 +948,9 @@ function applyAgentEvent(parts, event) {
674
948
  function partsToAssistantText(parts) {
675
949
  return parts.filter((p) => p.type === "text").map((p) => p.text).join("").trim();
676
950
  }
677
- function stripBrightsyNdjsonNoise(text) {
678
- if (!text || !text.includes('"type"')) return text;
679
- let out = text;
951
+ function stripBrightsyNdjsonNoise(text2) {
952
+ if (!text2 || !text2.includes('"type"')) return text2;
953
+ let out = text2;
680
954
  out = out.replace(
681
955
  /\{"type":"(?:tool_use|tool_result|tool|thinking|usage|done|error)"[\s\S]*?\}\s*(?=\{"type":"|$|(?=[A-Za-z*#]))/g,
682
956
  ""
@@ -724,9 +998,9 @@ async function spawnAgentTurn(thread, input, onEvent) {
724
998
  `Cannot spawn ${thread.agent}: thread ${thread.id} has no worktreePath`
725
999
  );
726
1000
  }
727
- const { isGlobalThread: isGlobalThread2 } = await import("./global-workspace-7P5NG3JZ.js");
1001
+ const { isGlobalThread: isGlobalThread2 } = await import("./global-workspace-SVRYNJZA.js");
728
1002
  if (isGlobalThread2(thread)) {
729
- const { ensureGlobalCoordinatorCwd: ensureGlobalCoordinatorCwd2 } = await import("./coordinator-prompt-HD6IMCKS.js");
1003
+ const { ensureGlobalCoordinatorCwd: ensureGlobalCoordinatorCwd2 } = await import("./coordinator-prompt-QYYG2IBB.js");
730
1004
  ensureGlobalCoordinatorCwd2(
731
1005
  isOrchestratorThread(thread) ? { orchestratorThreadId: thread.id } : void 0
732
1006
  );
@@ -813,8 +1087,8 @@ async function spawnAgentTurn(thread, input, onEvent) {
813
1087
  onEvent({ type: "exit", data: exitCode });
814
1088
  const finalized = finalizeParts(parts);
815
1089
  const rawText = assistantText.trim() || partsToAssistantText(finalized);
816
- const text = thread.agent === "brightsy" ? stripBrightsyNdjsonNoise(rawText) : rawText;
817
- return { exitCode, sessionId, assistantText: text, parts: finalized, usage };
1090
+ const text2 = thread.agent === "brightsy" ? stripBrightsyNdjsonNoise(rawText) : rawText;
1091
+ return { exitCode, sessionId, assistantText: text2, parts: finalized, usage };
818
1092
  });
819
1093
  return {
820
1094
  pid: child.pid,
@@ -1054,7 +1328,7 @@ import {
1054
1328
  readdirSync,
1055
1329
  readFileSync as readFileSync2
1056
1330
  } from "fs";
1057
- import { createServer } from "net";
1331
+ import { createServer as createServer2 } from "net";
1058
1332
  import { basename, dirname, join as join2 } from "path";
1059
1333
  import { execa as execa2 } from "execa";
1060
1334
  import { createInterface as createInterface2 } from "readline";
@@ -1306,7 +1580,7 @@ function getRunMode(worktreePath, repoPath) {
1306
1580
  }
1307
1581
  async function allocatePort() {
1308
1582
  return new Promise((resolve, reject) => {
1309
- const server = createServer();
1583
+ const server = createServer2();
1310
1584
  server.listen(0, "127.0.0.1", () => {
1311
1585
  const addr = server.address();
1312
1586
  if (!addr || typeof addr === "string") {
@@ -1326,7 +1600,7 @@ async function allocatePortRange(size = PORT_RANGE_SIZE) {
1326
1600
  for (let i = 1; i < size; i++) {
1327
1601
  const candidate = base + i;
1328
1602
  const free = await new Promise((resolve) => {
1329
- const server = createServer();
1603
+ const server = createServer2();
1330
1604
  server.once("error", () => resolve(false));
1331
1605
  server.listen(candidate, "127.0.0.1", () => {
1332
1606
  server.close(() => resolve(true));
@@ -1679,13 +1953,13 @@ function formatStat(files) {
1679
1953
  return `${n} file${n === 1 ? "" : "s"} changed, ${additions} insertions(+), ${deletions} deletions(-)`;
1680
1954
  }
1681
1955
  function emptyScopeStats() {
1682
- const z2 = emptyStat();
1956
+ const z3 = emptyStat();
1683
1957
  return {
1684
- commits: z2,
1685
- uncommitted: z2,
1686
- staged: z2,
1687
- unstaged: z2,
1688
- last_turn: z2
1958
+ commits: z3,
1959
+ uncommitted: z3,
1960
+ staged: z3,
1961
+ unstaged: z3,
1962
+ last_turn: z3
1689
1963
  };
1690
1964
  }
1691
1965
  function filesFromDiff(nameStatus, numstat, combinedDiff, maxHunk) {
@@ -2674,16 +2948,16 @@ var PASTE_ATTACH_MIN_CHARS = 1200;
2674
2948
  var PASTE_ATTACH_MIN_LINES = 15;
2675
2949
  var PASTED_NAME_RE = /^Pasted text #(\d+)\.txt$/i;
2676
2950
  var PASTED_NAME_ALT_RE = /^pasted-(\d+)\.txt$/i;
2677
- function pastedTextStats(text) {
2678
- const chars = text.length;
2951
+ function pastedTextStats(text2) {
2952
+ const chars = text2.length;
2679
2953
  if (chars === 0) return { chars: 0, lines: 0 };
2680
- const lines = text.split(/\r\n|\r|\n/).length;
2954
+ const lines = text2.split(/\r\n|\r|\n/).length;
2681
2955
  return { chars, lines };
2682
2956
  }
2683
- function shouldAttachPastedText(text) {
2684
- const trimmed = text.trim();
2957
+ function shouldAttachPastedText(text2) {
2958
+ const trimmed = text2.trim();
2685
2959
  if (!trimmed) return false;
2686
- const { chars, lines } = pastedTextStats(text);
2960
+ const { chars, lines } = pastedTextStats(text2);
2687
2961
  return chars >= PASTE_ATTACH_MIN_CHARS || lines >= PASTE_ATTACH_MIN_LINES;
2688
2962
  }
2689
2963
  function nextPastedTextName(existing) {
@@ -2694,13 +2968,13 @@ function nextPastedTextName(existing) {
2694
2968
  }
2695
2969
  return `Pasted text #${max + 1}.txt`;
2696
2970
  }
2697
- function buildPastedTextAttachment(text, opts) {
2971
+ function buildPastedTextAttachment(text2, opts) {
2698
2972
  return {
2699
2973
  id: opts?.id ?? randomUUID2(),
2700
2974
  name: opts?.name ?? "Pasted text #1.txt",
2701
2975
  kind: "file",
2702
2976
  path: opts?.path,
2703
- content: text
2977
+ content: text2
2704
2978
  };
2705
2979
  }
2706
2980
 
@@ -2748,9 +3022,9 @@ async function tryClaudeSummary(transcript, opts) {
2748
3022
  { cwd: opts?.cwd, reject: false }
2749
3023
  );
2750
3024
  if (exitCode !== 0) return null;
2751
- const text = stdout.trim();
2752
- if (text.length < 40) return null;
2753
- return text;
3025
+ const text2 = stdout.trim();
3026
+ if (text2.length < 40) return null;
3027
+ return text2;
2754
3028
  } catch {
2755
3029
  return null;
2756
3030
  }
@@ -2799,9 +3073,9 @@ function extractiveSummary(transcript) {
2799
3073
  );
2800
3074
  return parts.join("\n");
2801
3075
  }
2802
- function clipSummary(text) {
2803
- if (text.length <= MAX_SUMMARY_CHARS) return text;
2804
- return `${text.slice(0, MAX_SUMMARY_CHARS)}
3076
+ function clipSummary(text2) {
3077
+ if (text2.length <= MAX_SUMMARY_CHARS) return text2;
3078
+ return `${text2.slice(0, MAX_SUMMARY_CHARS)}
2805
3079
 
2806
3080
  [\u2026summary truncated\u2026]`;
2807
3081
  }
@@ -3203,7 +3477,7 @@ async function createThread(input, _onSetupLine) {
3203
3477
  return readThread(thread.id) ?? thread;
3204
3478
  }
3205
3479
  async function listLinearIssues(agent, repoPath) {
3206
- const { getAdapter: getAdapter2 } = await import("./agents-JBD7IAGK.js");
3480
+ const { getAdapter: getAdapter2 } = await import("./agents-TCAK3MXN.js");
3207
3481
  await requireAgent(agent, { requireLinear: true });
3208
3482
  const adapter = getAdapter2(agent);
3209
3483
  if (!adapter.listLinearIssues) {
@@ -3701,13 +3975,13 @@ function resolveConductorCursorAgentId(workspacePath) {
3701
3975
  for (const hash of hashes) {
3702
3976
  const agentsFile = join7(CURSOR_SDK_STORE, hash, "agents.ndjson");
3703
3977
  if (!existsSync9(agentsFile)) continue;
3704
- let text;
3978
+ let text2;
3705
3979
  try {
3706
- text = readFileSync7(agentsFile, "utf8");
3980
+ text2 = readFileSync7(agentsFile, "utf8");
3707
3981
  } catch {
3708
3982
  continue;
3709
3983
  }
3710
- for (const line of text.split("\n")) {
3984
+ for (const line of text2.split("\n")) {
3711
3985
  const trimmed = line.trim();
3712
3986
  if (!trimmed) continue;
3713
3987
  try {
@@ -3746,7 +4020,7 @@ async function adoptThread(input) {
3746
4020
  messages: input.messages ?? []
3747
4021
  });
3748
4022
  writeThread(thread);
3749
- const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-BWJNGEE6.js");
4023
+ const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-AHFTHVBT.js");
3750
4024
  await ensureWorkspace2(repoPath);
3751
4025
  return thread;
3752
4026
  }
@@ -4435,7 +4709,7 @@ function buildReviewRequestAttachment(content, opts) {
4435
4709
  function readExistingReviewRequestFile(worktreePath) {
4436
4710
  return readTextIfPresent(join10(worktreePath, REPO_REVIEW_PATH)) ?? readTextIfPresent(join10(worktreePath, REVIEW_REQUEST_PATH)) ?? readTextIfPresent(join10(worktreePath, LEGACY_REVIEW_REQUEST_PATH));
4437
4711
  }
4438
- async function requestReview(threadRef, send) {
4712
+ async function requestReview(threadRef, send2) {
4439
4713
  const from = findThreadByRef(threadRef);
4440
4714
  if (!from) throw new Error(`Thread not found: ${threadRef}`);
4441
4715
  if (isOrchestratorThread(from)) {
@@ -4457,7 +4731,7 @@ async function requestReview(threadRef, send) {
4457
4731
  })
4458
4732
  ]
4459
4733
  });
4460
- const started = await send(tab.id, REVIEW_REQUEST_PREFILL);
4734
+ const started = await send2(tab.id, REVIEW_REQUEST_PREFILL);
4461
4735
  return { tab: started, from };
4462
4736
  }
4463
4737
 
@@ -4513,8 +4787,8 @@ function buildQuotaHandoffAttachment(from, limitText, fallbackAgent) {
4513
4787
  );
4514
4788
  const recent = from.messages.slice(-8).map((m) => {
4515
4789
  const role = m.role === "user" ? "User" : m.role === "agent" ? "Agent" : "Summary";
4516
- const text = m.text.trim().replace(/\s+/g, " ").slice(0, 280);
4517
- return text ? `- ${role}: ${text}` : null;
4790
+ const text2 = m.text.trim().replace(/\s+/g, " ").slice(0, 280);
4791
+ return text2 ? `- ${role}: ${text2}` : null;
4518
4792
  }).filter(Boolean);
4519
4793
  const body = [
4520
4794
  `# Orchestration handoff`,
@@ -4697,7 +4971,7 @@ var Orchestrator = class {
4697
4971
  orphans: orphans.map((o) => ({ path: o.path, repoPath: o.repoPath }))
4698
4972
  });
4699
4973
  }
4700
- const { autoCleanupOrphansEnabled: autoCleanupOrphansEnabled2 } = await import("./app-settings-ONKMFSEJ.js");
4974
+ const { autoCleanupOrphansEnabled: autoCleanupOrphansEnabled2 } = await import("./app-settings-3TLA2EJ7.js");
4701
4975
  if (autoCleanupOrphansEnabled2() && shouldRunWorktreeCleanup() && orphans.length > 0) {
4702
4976
  await cleanupOrphanWorktrees({ repoPaths });
4703
4977
  }
@@ -4854,7 +5128,7 @@ var Orchestrator = class {
4854
5128
  }
4855
5129
  async finishCreateThread(threadId, prompt) {
4856
5130
  await this.runSetupAfterCreate(threadId);
4857
- const { autoRunAfterSetupEnabled: autoRunAfterSetupEnabled2 } = await import("./app-settings-ONKMFSEJ.js");
5131
+ const { autoRunAfterSetupEnabled: autoRunAfterSetupEnabled2 } = await import("./app-settings-3TLA2EJ7.js");
4858
5132
  if (autoRunAfterSetupEnabled2()) {
4859
5133
  try {
4860
5134
  await this.startDev(threadId);
@@ -4934,11 +5208,11 @@ var Orchestrator = class {
4934
5208
  return results;
4935
5209
  }
4936
5210
  /** Edit the text of a not-yet-started queued message. */
4937
- async editQueuedMessage(threadRef, index, text) {
5211
+ async editQueuedMessage(threadRef, index, text2) {
4938
5212
  const thread = this.requireThread(threadRef);
4939
5213
  return withThreadLock(thread.id, async () => {
4940
5214
  const current = this.requireThread(thread.id);
4941
- const trimmed = text.trim();
5215
+ const trimmed = text2.trim();
4942
5216
  if (!trimmed || index < 0 || index >= current.queue.length) {
4943
5217
  return current;
4944
5218
  }
@@ -5083,10 +5357,19 @@ var Orchestrator = class {
5083
5357
  attachments: sentAttachments ?? thread.attachments
5084
5358
  }
5085
5359
  );
5086
- const orchestrationReminder = isOrchestratorThread(thread) ? coordinatorTurnReminder({
5087
- parentId: threadId,
5088
- goal: thread.sourceRef || thread.title
5089
- }) : null;
5360
+ const slackInbound = /^(Slack DM|Slack @mention)(?:\n|$)/.test(
5361
+ expandedPrompt.trim()
5362
+ );
5363
+ const orchestrationReminder = isOrchestratorThread(thread) ? [
5364
+ coordinatorTurnReminder({
5365
+ parentId: threadId,
5366
+ goal: thread.sourceRef || thread.title
5367
+ }),
5368
+ slackInbound ? [
5369
+ "This turn is a Slack DM or @mention. Your reply is posted back in Slack \u2014 keep it concise. Sideboard signs it with this Mac's destination name; do not prefix the name yourself.",
5370
+ SLACK_REPLY_FORMATTING
5371
+ ].join("\n") : null
5372
+ ].filter(Boolean).join("\n") : null;
5090
5373
  const artifactReminder = thread.agent !== "brightsy" ? [
5091
5374
  "Sideboard side column (important):",
5092
5375
  "There is no claude.ai Artifact tool here \u2014 that is normal.",
@@ -5126,7 +5409,7 @@ var Orchestrator = class {
5126
5409
  });
5127
5410
  const artifactDirective = isBrightsy ? null : formatArtifactDirective();
5128
5411
  const settings = loadWorkspaceSettings(fresh.worktreePath, fresh.repoPath);
5129
- const { autoRenameBranchEnabled: autoRenameBranchEnabled2 } = await import("./app-settings-ONKMFSEJ.js");
5412
+ const { autoRenameBranchEnabled: autoRenameBranchEnabled2 } = await import("./app-settings-3TLA2EJ7.js");
5130
5413
  const renameBranchDirective = !isBrightsy && !isOrchestration && autoRenameBranchEnabled2() ? formatRenameBranchDirective(fresh, {
5131
5414
  customPrompt: settings?.prompts?.renameBranch
5132
5415
  }) : null;
@@ -5146,7 +5429,9 @@ var Orchestrator = class {
5146
5429
  goal: fresh.sourceRef || fresh.title || "Orchestration",
5147
5430
  parentId: fresh.id,
5148
5431
  workspaces: inventory,
5149
- audience: "desktop"
5432
+ audience: /^(Slack DM|Slack @mention)(?:\n|$)/.test(
5433
+ expandedPrompt.trim()
5434
+ ) ? "slack" : "desktop"
5150
5435
  });
5151
5436
  }
5152
5437
  }
@@ -5771,7 +6056,8 @@ var Orchestrator = class {
5771
6056
  isDraft: false,
5772
6057
  reviewDecision: null,
5773
6058
  baseRefName: "",
5774
- headRefName: ""
6059
+ headRefName: "",
6060
+ isInMergeQueue: false
5775
6061
  };
5776
6062
  await this.persistPrMetaAndMaybeArchive(thread, metaLike);
5777
6063
  return { url: metaLike.url, state };
@@ -5821,7 +6107,7 @@ var Orchestrator = class {
5821
6107
  updateThread(thread.id, { title: meta.title });
5822
6108
  }
5823
6109
  }
5824
- const { autoArchiveOnMergeEnabled: autoArchiveOnMergeEnabled2 } = await import("./app-settings-ONKMFSEJ.js");
6110
+ const { autoArchiveOnMergeEnabled: autoArchiveOnMergeEnabled2 } = await import("./app-settings-3TLA2EJ7.js");
5825
6111
  const latest = this.requireThread(thread.id);
5826
6112
  if (!shouldAutoArchiveOnPrMerge({
5827
6113
  previousPrState: prevState || null,
@@ -6045,7 +6331,7 @@ var Orchestrator = class {
6045
6331
  this.emit({ type: "status_changed", threadId: archived.id, status: "archived" });
6046
6332
  if (thread.repoPath && !isGlobalRepoPath(thread.repoPath)) {
6047
6333
  try {
6048
- const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-BWJNGEE6.js");
6334
+ const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-AHFTHVBT.js");
6049
6335
  await ensureWorkspace2(thread.repoPath);
6050
6336
  } catch {
6051
6337
  }
@@ -6066,7 +6352,7 @@ var Orchestrator = class {
6066
6352
  await runArchiveScript(thread.repoPath, thread.worktreePath);
6067
6353
  } catch {
6068
6354
  }
6069
- const { deleteBranchOnPurgeEnabled: deleteBranchOnPurgeEnabled2 } = await import("./app-settings-ONKMFSEJ.js");
6355
+ const { deleteBranchOnPurgeEnabled: deleteBranchOnPurgeEnabled2 } = await import("./app-settings-3TLA2EJ7.js");
6070
6356
  const deleteBranch = opts?.deleteBranch ?? deleteBranchOnPurgeEnabled2();
6071
6357
  await removeWorktree(thread.repoPath, thread.worktreePath, {
6072
6358
  deleteBranch: deleteBranch ? thread.branchName : void 0
@@ -6087,7 +6373,7 @@ var Orchestrator = class {
6087
6373
  return restored2;
6088
6374
  }
6089
6375
  if (!existsSync13(thread.worktreePath)) {
6090
- const { createThreadWorktree: createThreadWorktree2 } = await import("./worktree-ZPSKEZFE.js");
6376
+ const { createThreadWorktree: createThreadWorktree2 } = await import("./worktree-VOCH3WS3.js");
6091
6377
  const { execa: execa6 } = await import("execa");
6092
6378
  const slug = thread.worktreePath.split("/").pop();
6093
6379
  const dest = thread.worktreePath;
@@ -6196,7 +6482,7 @@ async function startOrchestration(opts) {
6196
6482
  sourceRef: "default",
6197
6483
  ...createOpts
6198
6484
  }).catch(async () => {
6199
- const { resolveDefaultBranch: resolveDefaultBranch2, resolveRepoRoot: resolveRepoRoot2 } = await import("./worktree-ZPSKEZFE.js");
6485
+ const { resolveDefaultBranch: resolveDefaultBranch2, resolveRepoRoot: resolveRepoRoot2 } = await import("./worktree-VOCH3WS3.js");
6200
6486
  const repo = await resolveRepoRoot2(repoPath);
6201
6487
  const def = await resolveDefaultBranch2(repo);
6202
6488
  return createThread({
@@ -6318,7 +6604,7 @@ function formatPlanQuestionsForChat(questions) {
6318
6604
  // src/mcp/server.ts
6319
6605
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6320
6606
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6321
- import { z } from "zod";
6607
+ import { z as z2 } from "zod";
6322
6608
  import { basename as basename4 } from "path";
6323
6609
 
6324
6610
  // src/mcp/archive-guard.ts
@@ -6329,379 +6615,1144 @@ function mcpArchiveBlockedReason(thread) {
6329
6615
  return null;
6330
6616
  }
6331
6617
 
6332
- // src/mcp/server.ts
6333
- var MAX_ORCH_THREADS = 5;
6334
- var CREATE_THREAD_TIMEOUT_MS = 9e4;
6335
- function withTimeout(promise, ms, label) {
6336
- return new Promise((resolve, reject) => {
6337
- const timer = setTimeout(() => {
6338
- reject(new Error(`${label} timed out after ${Math.round(ms / 1e3)}s`));
6339
- }, ms);
6340
- promise.then(
6341
- (value) => {
6342
- clearTimeout(timer);
6343
- resolve(value);
6344
- },
6345
- (err) => {
6346
- clearTimeout(timer);
6347
- reject(err);
6348
- }
6349
- );
6618
+ // src/mcp/slack-tools.ts
6619
+ import { z } from "zod";
6620
+
6621
+ // src/slack/api.ts
6622
+ var SLACK_API = "https://slack.com/api";
6623
+ var SlackApiError = class extends Error {
6624
+ constructor(method, slackError) {
6625
+ super(`Slack ${method}: ${slackError}`);
6626
+ this.method = method;
6627
+ this.slackError = slackError;
6628
+ this.name = "SlackApiError";
6629
+ }
6630
+ method;
6631
+ slackError;
6632
+ };
6633
+ async function slackApi(token, method, params, fetchImpl) {
6634
+ const doFetch = fetchImpl ?? fetch;
6635
+ const body = new URLSearchParams();
6636
+ if (params) {
6637
+ for (const [key, value] of Object.entries(params)) {
6638
+ if (value === void 0) continue;
6639
+ body.set(key, String(value));
6640
+ }
6641
+ }
6642
+ const res = await doFetch(`${SLACK_API}/${method}`, {
6643
+ method: "POST",
6644
+ headers: {
6645
+ Authorization: `Bearer ${token}`,
6646
+ "Content-Type": "application/x-www-form-urlencoded"
6647
+ },
6648
+ body
6350
6649
  });
6351
- }
6352
- async function startMcpServer() {
6353
- const orch = getOrchestrator();
6354
- try {
6355
- const { maxConcurrentAgents: maxConcurrentAgents2 } = await import("./app-settings-ONKMFSEJ.js");
6356
- orch.setMaxConcurrent(maxConcurrentAgents2());
6357
- } catch {
6650
+ const json = await res.json();
6651
+ if (!json.ok) {
6652
+ throw new SlackApiError(method, json.error || `HTTP ${res.status}`);
6358
6653
  }
6359
- try {
6360
- await orch.reconcile(void 0, { reclaimStaleTurns: false, drainQueues: false });
6361
- } catch (err) {
6362
- console.error(
6363
- "[sideboard-mcp] reconcile on boot failed (continuing):",
6364
- err instanceof Error ? err.message : err
6654
+ return json;
6655
+ }
6656
+ async function slackAuthTest(token) {
6657
+ return slackApi(token, "auth.test");
6658
+ }
6659
+
6660
+ // src/slack/destination.ts
6661
+ function isChannelId(raw) {
6662
+ return /^[CGD][A-Z0-9]+$/i.test(raw);
6663
+ }
6664
+ function isUserId(raw) {
6665
+ return /^U[A-Z0-9]+$/i.test(raw);
6666
+ }
6667
+ function isEmail(raw) {
6668
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw);
6669
+ }
6670
+ function normalizeSlackHandle(raw) {
6671
+ let s = raw.trim();
6672
+ const mention = s.match(/^<@([UW][A-Z0-9]+)(?:\|[^>]+)?>$/i);
6673
+ if (mention?.[1]) return mention[1];
6674
+ const channelMention = s.match(/^<#([CGD][A-Z0-9]+)(?:\|[^>]+)?>$/i);
6675
+ if (channelMention?.[1]) return channelMention[1];
6676
+ if (s.startsWith("#") || s.startsWith("@")) s = s.slice(1);
6677
+ return s.trim();
6678
+ }
6679
+ async function listSlackUsers(token, opts) {
6680
+ const query = opts?.query?.trim().toLowerCase() ?? "";
6681
+ const limit = Math.min(200, Math.max(1, opts?.limit ?? 50));
6682
+ const members = [];
6683
+ let cursor;
6684
+ do {
6685
+ const data = await slackApi(
6686
+ token,
6687
+ "users.list",
6688
+ {
6689
+ limit: 200,
6690
+ cursor: cursor || void 0
6691
+ },
6692
+ opts?.fetchImpl
6365
6693
  );
6694
+ for (const m of data.members ?? []) {
6695
+ if (!m.id || m.deleted || m.is_bot) continue;
6696
+ members.push(m);
6697
+ }
6698
+ cursor = data.response_metadata?.next_cursor?.trim() || void 0;
6699
+ } while (cursor && members.length < 2e3);
6700
+ const mapped = members.map((m) => ({
6701
+ id: m.id,
6702
+ name: m.name ?? m.id,
6703
+ real_name: m.profile?.real_name,
6704
+ display_name: m.profile?.display_name,
6705
+ email: m.profile?.email
6706
+ }));
6707
+ const filtered = query ? mapped.filter((u) => {
6708
+ const hay = [u.name, u.real_name, u.display_name, u.email, u.id].filter(Boolean).join(" ").toLowerCase();
6709
+ return hay.includes(query);
6710
+ }) : mapped;
6711
+ return filtered.slice(0, limit);
6712
+ }
6713
+ async function findUserId(token, handle, fetchImpl) {
6714
+ if (isUserId(handle)) {
6715
+ return { id: handle, name: handle };
6366
6716
  }
6367
- const server = new McpServer({
6368
- name: "sideboard",
6369
- version: "0.1.0"
6370
- });
6371
- server.tool(
6372
- "list_workspaces",
6373
- "List registered Sideboard workspaces (repos). Each line is name, path, and github:owner/repo when resolvable \u2014 use path as repoPath for list_branches/list_prs/list_issues/create_thread.",
6374
- {},
6375
- async () => {
6376
- const workspaces = orch.listWorkspaces();
6377
- const lines = await Promise.all(
6378
- workspaces.map(async (w) => {
6379
- const slug = await resolveGithubRepoSlug(w.path).catch(() => null);
6380
- return slug ? `${w.name} ${w.path} github:${slug}` : `${w.name} ${w.path}`;
6381
- })
6717
+ if (isEmail(handle)) {
6718
+ try {
6719
+ const data = await slackApi(
6720
+ token,
6721
+ "users.lookupByEmail",
6722
+ { email: handle },
6723
+ fetchImpl
6382
6724
  );
6383
- return {
6384
- content: [
6385
- { type: "text", text: lines.join("\n") || "(no workspaces)" }
6386
- ]
6387
- };
6725
+ if (data.user?.id) {
6726
+ return {
6727
+ id: data.user.id,
6728
+ name: data.user.name ?? data.user.id,
6729
+ real_name: data.user.profile?.real_name,
6730
+ display_name: data.user.profile?.display_name,
6731
+ email: data.user.profile?.email
6732
+ };
6733
+ }
6734
+ } catch {
6388
6735
  }
6736
+ }
6737
+ const users = await listSlackUsers(token, { query: handle, limit: 20, fetchImpl });
6738
+ const exact = users.find(
6739
+ (u) => u.name.toLowerCase() === handle.toLowerCase() || u.display_name?.toLowerCase() === handle.toLowerCase() || u.real_name?.toLowerCase() === handle.toLowerCase() || u.email?.toLowerCase() === handle.toLowerCase()
6389
6740
  );
6390
- server.tool(
6391
- "list_threads",
6392
- "List Sideboard threads across all workspaces (one summary line each \u2014 token-frugal). Each line ends with sideboard://thread/<id> \u2014 use that URL in markdown links so the UI can open the chat.",
6393
- {},
6394
- async () => {
6395
- const threads = orch.getThreads(true);
6396
- const lines = threads.map((t) => {
6397
- const repo = t.repoPath === GLOBAL_WORKSPACE_ID ? "Orchestration" : basename4(t.repoPath) || t.repoPath;
6398
- return `${t.id.slice(0, 8)} ${t.status.padEnd(9)} ${t.agent.padEnd(8)} ${repo} ${t.sourceType}:${t.sourceRef} ${t.title} sideboard://thread/${t.id}${t.devPort ? ` http://localhost:${t.devPort}` : ""}`;
6399
- });
6400
- return {
6401
- content: [{ type: "text", text: lines.join("\n") || "(no threads)" }]
6402
- };
6403
- }
6741
+ if (exact) return exact;
6742
+ if (users.length === 1) return users[0];
6743
+ if (users.length === 0) {
6744
+ throw new Error(`No Slack user matching "${handle}"`);
6745
+ }
6746
+ throw new Error(
6747
+ `Ambiguous Slack user "${handle}". Matches: ${users.slice(0, 5).map((u) => `${u.name} (${u.id})`).join(", ")}. Pass a user id (U\u2026) or use slack_list_users.`
6404
6748
  );
6405
- server.tool(
6406
- "get_thread",
6407
- "Get a compact thread summary by id/ref",
6408
- { ref: z.string() },
6409
- async ({ ref }) => {
6410
- const t = orch.getThread(ref);
6411
- if (!t) {
6412
- return { content: [{ type: "text", text: `Thread not found: ${ref}` }], isError: true };
6413
- }
6414
- const summary = {
6415
- id: t.id,
6416
- title: t.title,
6417
- status: t.status,
6418
- agent: t.agent,
6419
- sourceType: t.sourceType,
6420
- sourceRef: t.sourceRef,
6421
- branchName: t.branchName,
6422
- worktreePath: t.worktreePath,
6423
- sessionId: t.sessionId,
6424
- queueLength: t.queue.length,
6425
- messageCount: t.messages.length,
6426
- devPort: t.devPort,
6427
- prUrl: t.prUrl,
6428
- lastError: t.lastError ?? null
6429
- };
6430
- return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
6431
- }
6749
+ }
6750
+ async function openDmChannel(token, userId, fetchImpl) {
6751
+ const data = await slackApi(
6752
+ token,
6753
+ "conversations.open",
6754
+ { users: userId },
6755
+ fetchImpl
6432
6756
  );
6433
- server.tool(
6434
- "present_artifact",
6435
- "Show an HTML, SVG, markdown, or React document in Sideboard\u2019s Claude-style side column (desktop). Use instead of claude.ai\u2019s artifact tool \u2014 pass the full document content. Prefer type=html for interactive pages. For type=react, pass a single component module that `export default`s a component (JSX/TSX ok) \u2014 Sideboard bootstraps React/ReactDOM/Babel and renders it; only `react`/`react-dom` imports are available, no other npm packages.",
6757
+ const id = data.channel?.id?.trim();
6758
+ if (!id) throw new Error(`Could not open Slack DM with user ${userId}`);
6759
+ return id;
6760
+ }
6761
+ async function resolveChannelByName(token, name, fetchImpl) {
6762
+ const data = await slackApi(
6763
+ token,
6764
+ "conversations.list",
6436
6765
  {
6437
- title: z.string().describe("Short title shown in the artifact pane header"),
6438
- type: z.enum(["html", "svg", "markdown", "react"]).describe(
6439
- "Artifact kind \u2014 html opens an iframe preview; react transpiles and renders a default-exported component in a sandboxed iframe"
6440
- ),
6441
- content: z.string().describe(
6442
- "Full document body (complete HTML page, SVG markup, markdown, or a React component module with a default export)"
6443
- ),
6444
- artifact_id: z.string().optional().describe("Stable id when updating the same artifact across turns")
6766
+ types: "public_channel,private_channel",
6767
+ exclude_archived: true,
6768
+ limit: 200
6445
6769
  },
6446
- async ({ title, type, artifact_id }) => {
6447
- const id = artifact_id?.trim() || `artifact_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
6448
- const payload = {
6449
- ok: true,
6450
- artifact_id: id,
6451
- title,
6452
- type,
6453
- message: "Artifact accepted. Sideboard desktop opens it in the side column beside chat."
6770
+ fetchImpl
6771
+ );
6772
+ const match = (data.channels ?? []).find((c) => c.name?.toLowerCase() === name.toLowerCase());
6773
+ if (!match?.id) throw new Error(`No Slack channel named #${name}`);
6774
+ return match.id;
6775
+ }
6776
+ async function resolveSlackDestination(token, destination, opts) {
6777
+ const raw = destination.trim();
6778
+ if (!raw) throw new Error("destination is required");
6779
+ const hadAt = raw.startsWith("@") || /^<@/i.test(raw);
6780
+ const hadHash = raw.startsWith("#") || /^<#/i.test(raw);
6781
+ const handle = normalizeSlackHandle(raw);
6782
+ if (isChannelId(handle)) {
6783
+ return {
6784
+ channelId: handle,
6785
+ kind: handle.toUpperCase().startsWith("D") ? "dm" : "channel",
6786
+ label: handle
6787
+ };
6788
+ }
6789
+ if (isUserId(handle) || hadAt || isEmail(handle)) {
6790
+ const user = await findUserId(token, handle, opts?.fetchImpl);
6791
+ if (opts?.forRead) {
6792
+ const channelId2 = await openDmChannel(token, user.id, opts?.fetchImpl);
6793
+ return {
6794
+ channelId: channelId2,
6795
+ kind: "user",
6796
+ userId: user.id,
6797
+ label: user.display_name || user.real_name || user.name
6454
6798
  };
6455
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
6456
6799
  }
6457
- );
6458
- server.tool(
6459
- "ask_user",
6460
- "Ask the user clarifying multiple-choice questions in Sideboard\u2019s composer (plan mode). Before calling, write a short chat message explaining the decision and what each option means. Include a description on every option. Use for approach forks and requirements \u2014 not for \u201Cis the plan ready?\u201D. After calling, stop and wait for their next message with answers.",
6461
- {
6462
- questions: z.array(
6463
- z.object({
6464
- question: z.string().describe("Full question text ending with ?"),
6465
- header: z.string().max(24).optional().describe("Short label shown above the question"),
6466
- multiSelect: z.boolean().optional().describe("Allow selecting multiple options"),
6467
- options: z.array(
6468
- z.object({
6469
- label: z.string(),
6470
- description: z.string().optional().describe("What this option means / when to choose it (strongly preferred)")
6471
- })
6472
- ).min(2).max(6).describe("2\u20136 choices (Sideboard also offers Other)")
6473
- })
6474
- ).min(1).max(4).describe("1\u20134 questions")
6475
- },
6476
- async ({ questions }) => {
6477
- const payload = {
6478
- ok: true,
6479
- questions,
6480
- message: "Questions shown in Sideboard\u2019s composer. Wait for the user\u2019s next message with their answers before continuing."
6800
+ const channelId = await openDmChannel(token, user.id, opts?.fetchImpl);
6801
+ return {
6802
+ channelId,
6803
+ kind: "user",
6804
+ userId: user.id,
6805
+ label: user.display_name || user.real_name || user.name
6806
+ };
6807
+ }
6808
+ if (hadHash) {
6809
+ const channelId = await resolveChannelByName(token, handle, opts?.fetchImpl);
6810
+ return { channelId, kind: "channel", label: `#${handle}` };
6811
+ }
6812
+ try {
6813
+ const channelId = await resolveChannelByName(token, handle, opts?.fetchImpl);
6814
+ return { channelId, kind: "channel", label: `#${handle}` };
6815
+ } catch (channelErr) {
6816
+ try {
6817
+ const user = await findUserId(token, handle, opts?.fetchImpl);
6818
+ const channelId = await openDmChannel(token, user.id, opts?.fetchImpl);
6819
+ return {
6820
+ channelId,
6821
+ kind: "user",
6822
+ userId: user.id,
6823
+ label: user.display_name || user.real_name || user.name
6481
6824
  };
6482
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
6825
+ } catch {
6826
+ throw channelErr;
6483
6827
  }
6484
- );
6485
- server.tool(
6486
- "present_plan",
6487
- "Save the implementation plan as markdown to .context/attachments/plan.md and show it in Sideboard chat for user approval (Copy / Hand off / Approve). Call this when the plan is ready \u2014 required in plan mode. Pass the full plan body in content. Then Claude should call ExitPlanMode.",
6488
- {
6489
- title: z.string().optional().describe("Short plan title (defaults to Plan)"),
6490
- content: z.string().min(1).describe("Full plan markdown (headings, steps, risks, open questions)"),
6491
- thread_id: z.string().optional().describe("Sideboard thread id when cwd is not the worktree")
6492
- },
6493
- async ({ title, content, thread_id }) => {
6494
- const { writePlanFile: writePlanFile2 } = await import("./plan-file-6O7G4VPQ.js");
6495
- let root = process.cwd();
6496
- if (thread_id?.trim()) {
6497
- const t = orch.getThread(thread_id.trim());
6498
- if (t?.worktreePath?.trim()) root = t.worktreePath;
6499
- }
6500
- const path = writePlanFile2(root, content);
6501
- const payload = {
6502
- ok: true,
6503
- path,
6504
- title: title?.trim() || "Plan",
6505
- message: "Plan saved to .context/attachments/plan.md and shown in Sideboard chat for approval."
6506
- };
6507
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
6828
+ }
6829
+ }
6830
+
6831
+ // src/slack/github-link.ts
6832
+ function labelGithubUrl(url) {
6833
+ const raw = url.trim();
6834
+ if (!raw) return null;
6835
+ let parsed;
6836
+ try {
6837
+ parsed = new URL(raw);
6838
+ } catch {
6839
+ return null;
6840
+ }
6841
+ if (!/(^|\.)github\.com$/i.test(parsed.hostname)) return null;
6842
+ const path = parsed.pathname;
6843
+ const hash = parsed.hash || "";
6844
+ const pr = path.match(/\/([^/]+)\/([^/]+)\/pull\/(\d+)/i);
6845
+ if (pr) {
6846
+ const n = pr[3];
6847
+ if (/#discussion_r\d+/i.test(hash) || /#pullrequestreview-\d+/i.test(hash)) {
6848
+ return { kind: "comment", label: `PR #${n} comment`, url: raw };
6849
+ }
6850
+ if (/#issuecomment-\d+/i.test(hash)) {
6851
+ return { kind: "comment", label: `PR #${n} comment`, url: raw };
6852
+ }
6853
+ return { kind: "pr", label: `PR #${n}`, url: raw };
6854
+ }
6855
+ const issueComment = path.match(/\/([^/]+)\/([^/]+)\/issues\/(\d+)/i);
6856
+ if (issueComment && /#issuecomment-\d+/i.test(hash)) {
6857
+ return { kind: "comment", label: `Issue #${issueComment[3]} comment`, url: raw };
6858
+ }
6859
+ const blob = path.match(/\/([^/]+)\/([^/]+)\/blob\/[^/]+\/(.+)$/i);
6860
+ if (blob) {
6861
+ const filePath = decodeURIComponent(blob[3]);
6862
+ const fileName = filePath.split("/").pop() || filePath;
6863
+ const line = hash.match(/^#L(\d+)(?:-L(\d+))?$/i);
6864
+ if (line) {
6865
+ const start = line[1];
6866
+ const end = line[2];
6867
+ const loc = end && end !== start ? `${start}-${end}` : start;
6868
+ return { kind: "code", label: `${fileName}:${loc}`, url: raw };
6869
+ }
6870
+ return { kind: "code", label: fileName, url: raw };
6871
+ }
6872
+ return { kind: "other", label: "GitHub", url: raw };
6873
+ }
6874
+ function appendGithubLink(text2, githubUrl) {
6875
+ const body = text2.trimEnd();
6876
+ if (!githubUrl?.trim()) return body;
6877
+ const labeled = labelGithubUrl(githubUrl);
6878
+ if (!labeled) {
6879
+ return body ? `${body}
6880
+ ${githubUrl.trim()}` : githubUrl.trim();
6881
+ }
6882
+ const link = `<${labeled.url}|${labeled.label}>`;
6883
+ return body ? `${body}
6884
+ ${link}` : link;
6885
+ }
6886
+
6887
+ // src/slack/outbound-watch.ts
6888
+ import { existsSync as existsSync14, readFileSync as readFileSync9 } from "fs";
6889
+ import { join as join12 } from "path";
6890
+
6891
+ // src/slack/workspaces.ts
6892
+ import { join as join11 } from "path";
6893
+ function storePath() {
6894
+ return join11(appDataDir(), "slack-workspaces.json");
6895
+ }
6896
+ function readStore() {
6897
+ try {
6898
+ const path = storePath();
6899
+ const wasEncrypted = isSecureFileEncrypted(path);
6900
+ const parsed = readSecureJson(path);
6901
+ const workspaces = Array.isArray(parsed?.workspaces) ? parsed.workspaces : [];
6902
+ if (workspaces.length > 0 && !wasEncrypted && resolveVaultKey()) {
6903
+ writeSecureJson(path, { workspaces });
6904
+ }
6905
+ return workspaces;
6906
+ } catch {
6907
+ return [];
6908
+ }
6909
+ }
6910
+ function writeStore(workspaces) {
6911
+ writeSecureJson(storePath(), { workspaces });
6912
+ return workspaces;
6913
+ }
6914
+ function toInfo(ws) {
6915
+ return {
6916
+ team_id: ws.team_id,
6917
+ team_name: ws.team_name,
6918
+ user_id: ws.user_id,
6919
+ has_bot_token: Boolean(ws.bot_token),
6920
+ has_user_token: Boolean(ws.user_token),
6921
+ connected_at: ws.connected_at
6922
+ };
6923
+ }
6924
+ function listSlackWorkspaces() {
6925
+ return readStore().map(toInfo).sort((a, b) => a.team_name.localeCompare(b.team_name));
6926
+ }
6927
+ function listSlackWorkspacesRaw() {
6928
+ return readStore();
6929
+ }
6930
+ function getSlackWorkspace(teamId) {
6931
+ const id = teamId.trim();
6932
+ if (!id) return null;
6933
+ return readStore().find(
6934
+ (ws) => ws.team_id === id || ws.team_name.toLowerCase() === id.toLowerCase()
6935
+ ) ?? null;
6936
+ }
6937
+ function upsertSlackWorkspace(entry) {
6938
+ const workspaces = readStore().filter((ws) => ws.team_id !== entry.team_id);
6939
+ workspaces.push(entry);
6940
+ writeStore(workspaces);
6941
+ return toInfo(entry);
6942
+ }
6943
+ function disconnectSlackWorkspace(teamId) {
6944
+ const id = teamId.trim();
6945
+ writeStore(readStore().filter((ws) => ws.team_id !== id));
6946
+ return listSlackWorkspaces();
6947
+ }
6948
+ function tokenKind(token) {
6949
+ if (token.startsWith("xoxb-")) return "bot";
6950
+ if (token.startsWith("xoxp-") || token.startsWith("xoxe.xoxp-")) return "user";
6951
+ return "unknown";
6952
+ }
6953
+ async function connectSlackToken(rawToken) {
6954
+ const token = rawToken.trim();
6955
+ if (!token) throw new Error("Paste a Slack bot (xoxb-) or user (xoxp-) token");
6956
+ const auth = await slackAuthTest(token);
6957
+ const teamId = auth.team_id?.trim();
6958
+ if (!teamId) throw new Error("Slack auth.test did not return a team_id");
6959
+ const existing = getSlackWorkspace(teamId);
6960
+ const kind = tokenKind(token);
6961
+ const next = {
6962
+ team_id: teamId,
6963
+ team_name: auth.team?.trim() || existing?.team_name || teamId,
6964
+ user_id: auth.user_id?.trim() || existing?.user_id,
6965
+ bot_token: kind === "bot" ? token : existing?.bot_token,
6966
+ user_token: kind === "user" || kind === "unknown" ? token : existing?.user_token,
6967
+ scopes: existing?.scopes,
6968
+ connected_at: existing?.connected_at || (/* @__PURE__ */ new Date()).toISOString()
6969
+ };
6970
+ if (kind === "unknown" && auth.bot_id) {
6971
+ next.bot_token = token;
6972
+ next.user_token = existing?.user_token;
6973
+ }
6974
+ return upsertSlackWorkspace(next);
6975
+ }
6976
+ function slackTokenFor(ws, kind = "read") {
6977
+ if (kind === "search") {
6978
+ const token2 = ws.user_token?.trim();
6979
+ if (!token2) {
6980
+ throw new Error(
6981
+ `Slack search needs a user token for ${ws.team_name}. Reconnect via Account \u2192 Slack (browser) or paste an xoxp- token.`
6982
+ );
6983
+ }
6984
+ return token2;
6985
+ }
6986
+ const token = (kind === "write" ? ws.bot_token || ws.user_token : ws.user_token || ws.bot_token)?.trim();
6987
+ if (!token) {
6988
+ throw new Error(`Slack workspace ${ws.team_name} has no token`);
6989
+ }
6990
+ return token;
6991
+ }
6992
+ function requireSlackWorkspace(teamId) {
6993
+ const ws = getSlackWorkspace(teamId);
6994
+ if (!ws) {
6995
+ const connected = listSlackWorkspaces();
6996
+ const hint = connected.length === 0 ? "Connect a workspace in Account \u2192 Slack workspaces." : `Connected: ${connected.map((t) => `${t.team_name} (${t.team_id})`).join(", ")}`;
6997
+ throw new Error(`Unknown Slack team_id "${teamId}". ${hint}`);
6998
+ }
6999
+ return ws;
7000
+ }
7001
+
7002
+ // src/slack/outbound-watch.ts
7003
+ var MAX_WATCHES = 40;
7004
+ var WATCH_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
7005
+ var POLL_INTERVAL_MS = 12e3;
7006
+ var lastPollMs = 0;
7007
+ var nameCache = /* @__PURE__ */ new Map();
7008
+ function storePath2() {
7009
+ return join12(appDataDir(), "slack-outbound-watch.json");
7010
+ }
7011
+ function watchId(teamId, channelId, ts) {
7012
+ return `${teamId}:${channelId}:${ts}`;
7013
+ }
7014
+ function badgeId(teamId, userId) {
7015
+ return `${teamId}:${userId}`;
7016
+ }
7017
+ function slackArchiveUrl(channelId, ts) {
7018
+ return `https://slack.com/archives/${channelId}/p${ts.replace(".", "")}`;
7019
+ }
7020
+ function initialsFromName(name) {
7021
+ const parts = name.trim().split(/\s+/).filter(Boolean);
7022
+ if (parts.length === 0) return "?";
7023
+ if (parts.length === 1) {
7024
+ const w = parts[0];
7025
+ return (w.slice(0, 2) || "?").toUpperCase();
7026
+ }
7027
+ return `${parts[0][0] ?? ""}${parts[parts.length - 1][0] ?? ""}`.toUpperCase();
7028
+ }
7029
+ function hueFromId(id) {
7030
+ let h = 0;
7031
+ for (const c of id) h = h * 31 + c.charCodeAt(0) >>> 0;
7032
+ return h % 360;
7033
+ }
7034
+ function tsNewer(a, b) {
7035
+ return Number(a) > Number(b);
7036
+ }
7037
+ function readStore2() {
7038
+ const path = storePath2();
7039
+ if (!existsSync14(path)) return [];
7040
+ try {
7041
+ const parsed = isSecureFileEncrypted(path) ? readSecureJson(path) : JSON.parse(readFileSync9(path, "utf8"));
7042
+ return Array.isArray(parsed?.watches) ? parsed.watches : [];
7043
+ } catch {
7044
+ return [];
7045
+ }
7046
+ }
7047
+ function writeStore2(watches) {
7048
+ writePrivateFile(storePath2(), `${JSON.stringify({ watches }, null, 2)}
7049
+ `);
7050
+ return watches;
7051
+ }
7052
+ function pruneWatches(watches, nowMs = Date.now()) {
7053
+ const cutoff = nowMs - WATCH_TTL_MS;
7054
+ const kept = watches.filter((w) => {
7055
+ const posted = Date.parse(w.postedAt);
7056
+ return Number.isFinite(posted) ? posted >= cutoff : true;
7057
+ });
7058
+ if (kept.length <= MAX_WATCHES) return kept;
7059
+ return kept.slice().sort((a, b) => b.postedAt.localeCompare(a.postedAt)).slice(0, MAX_WATCHES);
7060
+ }
7061
+ function recordSlackOutboundWatch(input) {
7062
+ const ts = input.ts.trim();
7063
+ const channelId = input.channelId.trim();
7064
+ const teamId = input.teamId.trim();
7065
+ if (!ts || !channelId || !teamId) return null;
7066
+ const owner = input.ownerUserId?.trim();
7067
+ const toUser = input.toUserId?.trim();
7068
+ if (toUser && owner && toUser === owner) return null;
7069
+ const id = watchId(teamId, channelId, ts);
7070
+ const next = {
7071
+ id,
7072
+ teamId,
7073
+ channelId,
7074
+ ts,
7075
+ threadTs: input.threadTs?.trim() || ts,
7076
+ kind: input.kind === "dm" ? "dm" : "channel",
7077
+ toUserId: toUser,
7078
+ toLabel: input.toLabel.trim() || toUser || channelId,
7079
+ ownerUserId: owner,
7080
+ postedAt: (/* @__PURE__ */ new Date()).toISOString(),
7081
+ lastSeenTs: ts,
7082
+ unread: false,
7083
+ permalink: slackArchiveUrl(channelId, ts)
7084
+ };
7085
+ const watches = pruneWatches(readStore2().filter((w) => w.id !== id));
7086
+ watches.unshift(next);
7087
+ writeStore2(pruneWatches(watches));
7088
+ return next;
7089
+ }
7090
+ function isHumanReply(msg, watch) {
7091
+ const ts = msg.ts?.trim();
7092
+ if (!ts || ts === watch.ts) return false;
7093
+ if (!tsNewer(ts, watch.lastSeenTs)) return false;
7094
+ if (msg.bot_id) return false;
7095
+ if (msg.subtype) return false;
7096
+ const user = msg.user?.trim();
7097
+ if (!user) return false;
7098
+ if (watch.ownerUserId && user === watch.ownerUserId) return false;
7099
+ return true;
7100
+ }
7101
+ function displayName(user) {
7102
+ const fromProfile = user.profile?.display_name?.trim() || user.profile?.real_name?.trim();
7103
+ return fromProfile || user.real_name?.trim() || user.name?.trim() || "";
7104
+ }
7105
+ async function resolveUserName(token, userId, fallback, fetchImpl) {
7106
+ const cached = nameCache.get(userId);
7107
+ if (cached) return cached;
7108
+ try {
7109
+ const data = await slackApi(token, "users.info", { user: userId }, fetchImpl);
7110
+ const name = displayName(data.user ?? {}) || fallback;
7111
+ nameCache.set(userId, name);
7112
+ return name;
7113
+ } catch {
7114
+ return fallback;
7115
+ }
7116
+ }
7117
+ async function resolvePermalink(token, channelId, ts, fetchImpl) {
7118
+ try {
7119
+ const data = await slackApi(
7120
+ token,
7121
+ "chat.getPermalink",
7122
+ { channel: channelId, message_ts: ts },
7123
+ fetchImpl
7124
+ );
7125
+ if (data.permalink?.startsWith("http")) return data.permalink;
7126
+ } catch {
7127
+ }
7128
+ return slackArchiveUrl(channelId, ts);
7129
+ }
7130
+ async function fetchMessages(token, watch, fetchImpl) {
7131
+ const out = [];
7132
+ try {
7133
+ const data = await slackApi(
7134
+ token,
7135
+ "conversations.replies",
7136
+ {
7137
+ channel: watch.channelId,
7138
+ ts: watch.threadTs,
7139
+ oldest: watch.lastSeenTs,
7140
+ inclusive: false,
7141
+ limit: 50
7142
+ },
7143
+ fetchImpl
7144
+ );
7145
+ out.push(...data.messages ?? []);
7146
+ } catch {
7147
+ }
7148
+ if (watch.kind === "dm" || watch.channelId.startsWith("D")) {
7149
+ try {
7150
+ const data = await slackApi(
7151
+ token,
7152
+ "conversations.history",
7153
+ {
7154
+ channel: watch.channelId,
7155
+ oldest: watch.lastSeenTs,
7156
+ inclusive: false,
7157
+ limit: 50
7158
+ },
7159
+ fetchImpl
7160
+ );
7161
+ out.push(...data.messages ?? []);
7162
+ } catch {
7163
+ }
7164
+ }
7165
+ return out;
7166
+ }
7167
+ function listSlackReplyBadges() {
7168
+ const unread = readStore2().filter((w) => w.unread && w.replyUserId && w.permalink);
7169
+ const byUser = /* @__PURE__ */ new Map();
7170
+ for (const w of unread) {
7171
+ const id = badgeId(w.teamId, w.replyUserId);
7172
+ const prev = byUser.get(id);
7173
+ if (!prev || tsNewer(w.replyTs || "", prev.replyTs || "")) {
7174
+ byUser.set(id, w);
7175
+ }
7176
+ }
7177
+ return [...byUser.entries()].map(([id, w]) => {
7178
+ const userName = w.replyUserName || w.toLabel || "Slack";
7179
+ return {
7180
+ id,
7181
+ userId: w.replyUserId,
7182
+ userName,
7183
+ initials: initialsFromName(userName),
7184
+ hue: hueFromId(w.replyUserId),
7185
+ permalink: w.permalink || slackArchiveUrl(w.channelId, w.replyTs || w.ts),
7186
+ label: w.toLabel,
7187
+ preview: w.replyPreview,
7188
+ repliedAt: w.replyTs || w.postedAt
7189
+ };
7190
+ }).sort((a, b) => b.repliedAt.localeCompare(a.repliedAt));
7191
+ }
7192
+ function dismissSlackReplyBadge(badgeKey) {
7193
+ const key = badgeKey.trim();
7194
+ const watches = readStore2().map((w) => {
7195
+ if (!w.unread || !w.replyUserId) return w;
7196
+ if (badgeId(w.teamId, w.replyUserId) !== key) return w;
7197
+ return { ...w, unread: false };
7198
+ });
7199
+ writeStore2(watches);
7200
+ return listSlackReplyBadges();
7201
+ }
7202
+ function permalinkForSlackReplyBadge(badgeKey) {
7203
+ return listSlackReplyBadges().find((b) => b.id === badgeKey)?.permalink ?? null;
7204
+ }
7205
+ async function refreshSlackReplyBadges(opts) {
7206
+ const now = opts?.now ?? Date.now();
7207
+ if (!opts?.force && now - lastPollMs < POLL_INTERVAL_MS) {
7208
+ return listSlackReplyBadges();
7209
+ }
7210
+ lastPollMs = now;
7211
+ const existing = readStore2();
7212
+ let watches = pruneWatches(existing, now);
7213
+ let changed = watches.length !== existing.length;
7214
+ for (let i = 0; i < watches.length; i++) {
7215
+ const watch = watches[i];
7216
+ const ws = getSlackWorkspace(watch.teamId);
7217
+ if (!ws) continue;
7218
+ let token;
7219
+ try {
7220
+ token = slackTokenFor(ws, "read");
7221
+ } catch {
7222
+ continue;
7223
+ }
7224
+ const messages = await fetchMessages(token, watch, opts?.fetchImpl);
7225
+ const replies = messages.filter((m) => isHumanReply(m, watch)).sort((a, b) => Number(a.ts) - Number(b.ts));
7226
+ const latest = replies[replies.length - 1];
7227
+ if (!latest?.ts || !latest.user) continue;
7228
+ const fallback = watch.toUserId === latest.user ? watch.toLabel : latest.user;
7229
+ const replyUserName = await resolveUserName(
7230
+ token,
7231
+ latest.user,
7232
+ fallback,
7233
+ opts?.fetchImpl
7234
+ );
7235
+ const permalink = await resolvePermalink(
7236
+ token,
7237
+ watch.channelId,
7238
+ latest.ts,
7239
+ opts?.fetchImpl
7240
+ );
7241
+ watches[i] = {
7242
+ ...watch,
7243
+ lastSeenTs: latest.ts,
7244
+ unread: true,
7245
+ replyUserId: latest.user,
7246
+ replyUserName,
7247
+ replyTs: latest.ts,
7248
+ replyPreview: (latest.text ?? "").slice(0, 140),
7249
+ permalink
7250
+ };
7251
+ changed = true;
7252
+ }
7253
+ if (changed) writeStore2(watches);
7254
+ return listSlackReplyBadges();
7255
+ }
7256
+
7257
+ // src/mcp/slack-tools.ts
7258
+ function text(payload, isError = false) {
7259
+ return {
7260
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
7261
+ ...isError ? { isError: true } : {}
7262
+ };
7263
+ }
7264
+ function fail(err) {
7265
+ return text(
7266
+ { error: err instanceof Error ? err.message : String(err) },
7267
+ true
7268
+ );
7269
+ }
7270
+ function registerSlackTools(server) {
7271
+ server.tool(
7272
+ "list_teams",
7273
+ "List Slack workspaces connected in Sideboard Account settings. Each row is team_id + name. Pass team_id to slack_list_channels, slack_list_users, slack_search, slack_read, and slack_post.",
7274
+ {},
7275
+ async () => {
7276
+ const teams = listSlackWorkspaces();
7277
+ if (teams.length === 0) {
7278
+ return text({
7279
+ teams: [],
7280
+ hint: "No Slack workspaces connected. Add one in Account \u2192 Slack workspaces (paste xoxb-/xoxp- or browser sign-in)."
7281
+ });
7282
+ }
7283
+ return text({
7284
+ teams: teams.map((t) => ({
7285
+ team_id: t.team_id,
7286
+ name: t.team_name,
7287
+ search: t.has_user_token
7288
+ }))
7289
+ });
6508
7290
  }
6509
7291
  );
6510
7292
  server.tool(
6511
- "present_schema",
6512
- "Open Sideboard\u2019s schema-driven CMS side column (filterable table and/or form). Pass JSON Schema + schemaUi (Brightsy extensions supported). Use datasource=brightsy with resource_id (record type UUID) when logged into Brightsy; use datasource=inline with embedded resource/records for any other source.",
7293
+ "slack_list_channels",
7294
+ "List channels in a connected Slack workspace. Pass team_id from list_teams.",
6513
7295
  {
6514
- title: z.string().describe("Pane title"),
6515
- mode: z.enum(["table", "form"]).optional().describe("table = list/filter records; form = edit one record"),
6516
- datasource: z.enum(["brightsy", "inline"]).optional().describe("brightsy resolves via login; inline uses embedded resource/records"),
6517
- resource_id: z.string().optional().describe("Brightsy record type UUID (or generic resource id)"),
6518
- record_id: z.string().optional().describe("Record id when opening form mode"),
6519
- resource: z.record(z.unknown()).optional().describe("Inline { id, title, schema, schemaUi?, slug? }"),
6520
- record: z.record(z.unknown()).optional().describe("Inline record { id, data, published_at? }"),
6521
- records: z.array(z.record(z.unknown())).optional().describe("Inline records for table mode"),
6522
- pane_id: z.string().optional().describe("Stable pane id across updates")
7296
+ team_id: z.string(),
7297
+ limit: z.number().optional()
6523
7298
  },
6524
- async (args) => {
6525
- const id = args.pane_id?.trim() || `schema_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
6526
- const payload = {
6527
- ok: true,
6528
- pane_id: id,
6529
- title: args.title,
6530
- mode: args.mode ?? (args.record_id || args.record ? "form" : "table"),
6531
- datasource: args.datasource ?? (args.resource ? "inline" : "brightsy"),
6532
- resource_id: args.resource_id,
6533
- record_id: args.record_id,
6534
- message: "Schema pane accepted. Sideboard desktop opens the CMS column beside chat."
6535
- };
6536
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
7299
+ async ({ team_id, limit }) => {
7300
+ try {
7301
+ const ws = requireSlackWorkspace(team_id);
7302
+ const token = slackTokenFor(ws, "read");
7303
+ const data = await slackApi(token, "conversations.list", {
7304
+ types: "public_channel,private_channel",
7305
+ exclude_archived: true,
7306
+ limit: Math.min(200, Math.max(1, limit ?? 100))
7307
+ });
7308
+ return text({
7309
+ team_id: ws.team_id,
7310
+ team_name: ws.team_name,
7311
+ channels: (data.channels ?? []).map((c) => ({
7312
+ id: c.id,
7313
+ name: c.name,
7314
+ private: Boolean(c.is_private),
7315
+ member: Boolean(c.is_member)
7316
+ }))
7317
+ });
7318
+ } catch (err) {
7319
+ return fail(err);
7320
+ }
6537
7321
  }
6538
7322
  );
6539
7323
  server.tool(
6540
- "present_files",
6541
- "Open Sideboard\u2019s Files column (CMS-style file manager: browse, upload, pick). Use datasource=brightsy when the user is logged into Brightsy account storage; datasource=memory for a session-local demo store. Prefer this over claiming a file manager UI is unavailable. Pair with present_schema when editing records that need media.",
7324
+ "slack_list_users",
7325
+ "List or search people in a connected Slack workspace (for DMs / @mentions). Pass team_id from list_teams. Optional query matches name, display name, real name, or email.",
6542
7326
  {
6543
- title: z.string().optional().describe("Pane title (default: Files)"),
6544
- datasource: z.enum(["brightsy", "memory"]).optional().describe("brightsy = account storage via login; memory = session demo store"),
6545
- path: z.string().optional().describe("Initial folder path (e.g. public)"),
6546
- pane_id: z.string().optional().describe("Stable pane id across updates")
7327
+ team_id: z.string(),
7328
+ query: z.string().optional(),
7329
+ limit: z.number().optional()
6547
7330
  },
6548
- async (args) => {
6549
- const id = args.pane_id?.trim() || `files_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
6550
- const payload = {
6551
- ok: true,
6552
- pane_id: id,
6553
- title: args.title?.trim() || "Files",
6554
- datasource: args.datasource ?? "brightsy",
6555
- path: args.path,
6556
- message: "Files pane accepted. Sideboard desktop opens the Files column beside chat."
6557
- };
6558
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
7331
+ async ({ team_id, query, limit }) => {
7332
+ try {
7333
+ const ws = requireSlackWorkspace(team_id);
7334
+ const token = slackTokenFor(ws, "read");
7335
+ const users = await listSlackUsers(token, { query, limit });
7336
+ return text({
7337
+ team_id: ws.team_id,
7338
+ team_name: ws.team_name,
7339
+ query: query?.trim() || void 0,
7340
+ users
7341
+ });
7342
+ } catch (err) {
7343
+ return fail(err);
7344
+ }
6559
7345
  }
6560
7346
  );
6561
- if (sideboardMcpProfile() !== "worktree") {
6562
- const { resolveNewThreadOptions: resolveNewThreadOptions2, resolveThreadDefaults: resolveThreadDefaults2 } = await import("./app-settings-ONKMFSEJ.js");
6563
- const accountDefaults = resolveThreadDefaults2();
6564
- const accountDefaultsHint = `Account defaults: agent=${accountDefaults.agent}, model=${accountDefaults.model?.trim() || "Auto"}, effort=${accountDefaults.effort}`;
6565
- server.tool(
6566
- "create_thread",
6567
- `Create a new worktree thread (chat) from branch, pr, or ticket. Pass repoPath from list_workspaces. From an orchestration chat, omit parentThreadId (Sideboard binds the child to this chat) or pass the exact id from the turn reminder \u2014 never invent a uuid. Prefer omitting agent/model so Sideboard applies ${accountDefaultsHint}. Then use send_to_thread to chat.`,
6568
- {
6569
- sourceType: z.enum(["branch", "pr", "ticket"]),
6570
- sourceRef: z.string(),
6571
- agent: z.enum(["claude", "codex", "opencode", "brightsy", "cursor"]).optional().describe(`Omit to use Account default agent (${accountDefaults.agent})`),
6572
- model: z.string().nullable().optional().describe(
6573
- `Usually omit to use Account default model (${accountDefaults.model?.trim() || "Auto"}). Pass null only to force Auto / agent-default.`
6574
- ),
6575
- repoPath: z.string(),
6576
- title: z.string().optional(),
6577
- parentThreadId: z.string().optional().describe(
6578
- "Orchestration: omit (preferred) or pass YOUR chat id from the turn reminder / AGENTS.md. Do not invent uuids."
6579
- )
6580
- },
6581
- async (args) => {
6582
- const envParentId = process.env.SIDEBOARD_ORCHESTRATOR_THREAD_ID?.trim() || "";
6583
- let parentId = args.parentThreadId?.trim() || "";
6584
- let parent = parentId ? orch.getThread(parentId) : null;
6585
- let parentCorrectedFrom;
6586
- if (envParentId) {
6587
- const envParent = orch.getThread(envParentId);
6588
- if (envParent) {
6589
- if (parentId && parentId !== envParentId) parentCorrectedFrom = parentId;
6590
- else if (!parentId) parentCorrectedFrom = void 0;
6591
- parentId = envParentId;
6592
- parent = envParent;
6593
- }
7347
+ server.tool(
7348
+ "slack_search",
7349
+ "Search messages in a connected Slack workspace (needs a user token). Pass team_id from list_teams.",
7350
+ {
7351
+ team_id: z.string(),
7352
+ query: z.string(),
7353
+ count: z.number().optional()
7354
+ },
7355
+ async ({ team_id, query, count }) => {
7356
+ try {
7357
+ const ws = requireSlackWorkspace(team_id);
7358
+ const token = slackTokenFor(ws, "search");
7359
+ const data = await slackApi(token, "search.messages", {
7360
+ query,
7361
+ count: Math.min(50, Math.max(1, count ?? 10)),
7362
+ sort: "timestamp"
7363
+ });
7364
+ return text({
7365
+ team_id: ws.team_id,
7366
+ team_name: ws.team_name,
7367
+ query,
7368
+ matches: (data.messages?.matches ?? []).map((m) => ({
7369
+ channel: m.channel?.name || m.channel?.id,
7370
+ user: m.username || m.user,
7371
+ ts: m.ts,
7372
+ text: m.text,
7373
+ permalink: m.permalink
7374
+ }))
7375
+ });
7376
+ } catch (err) {
7377
+ return fail(err);
7378
+ }
7379
+ }
7380
+ );
7381
+ server.tool(
7382
+ "slack_read",
7383
+ "Read recent messages in a Slack channel or DM, or a thread when ts is set. Pass team_id from list_teams and #channel, @user, or a C\u2026/D\u2026/U\u2026 id.",
7384
+ {
7385
+ team_id: z.string(),
7386
+ channel: z.string(),
7387
+ ts: z.string().optional(),
7388
+ limit: z.number().optional()
7389
+ },
7390
+ async ({ team_id, channel, ts, limit }) => {
7391
+ try {
7392
+ const ws = requireSlackWorkspace(team_id);
7393
+ const token = slackTokenFor(ws, "read");
7394
+ const dest = await resolveSlackDestination(token, channel, { forRead: true });
7395
+ const n = Math.min(100, Math.max(1, limit ?? 20));
7396
+ if (ts?.trim()) {
7397
+ const data2 = await slackApi(token, "conversations.replies", {
7398
+ channel: dest.channelId,
7399
+ ts: ts.trim(),
7400
+ limit: n
7401
+ });
7402
+ return text({
7403
+ team_id: ws.team_id,
7404
+ channel: dest.channelId,
7405
+ label: dest.label,
7406
+ thread_ts: ts.trim(),
7407
+ messages: data2.messages ?? []
7408
+ });
6594
7409
  }
6595
- if (parentId && !parent) {
6596
- parentCorrectedFrom = parentId;
6597
- parentId = "";
6598
- parent = null;
7410
+ const data = await slackApi(token, "conversations.history", {
7411
+ channel: dest.channelId,
7412
+ limit: n
7413
+ });
7414
+ return text({
7415
+ team_id: ws.team_id,
7416
+ channel: dest.channelId,
7417
+ label: dest.label,
7418
+ messages: data.messages ?? []
7419
+ });
7420
+ } catch (err) {
7421
+ return fail(err);
7422
+ }
7423
+ }
7424
+ );
7425
+ server.tool(
7426
+ "slack_post",
7427
+ "Post a message to a Slack channel or DM (as the Sideboard bot). Pass team_id from list_teams. Use to or channel for #name, @user, or C\u2026/D\u2026/U\u2026 ids. Optional github_url appends a PR / code / comment link. Only notify when the user asks. Thread with thread_ts when set.",
7428
+ {
7429
+ team_id: z.string(),
7430
+ channel: z.string().optional(),
7431
+ to: z.string().optional(),
7432
+ text: z.string(),
7433
+ github_url: z.string().optional(),
7434
+ thread_ts: z.string().optional()
7435
+ },
7436
+ async ({ team_id, channel, to, text: message, github_url, thread_ts }) => {
7437
+ try {
7438
+ const destRaw = (to ?? channel)?.trim();
7439
+ if (!destRaw) {
7440
+ return fail(new Error("slack_post needs `to` or `channel` (#name, @user, or Slack id)"));
6599
7441
  }
6600
- if (parentId) {
6601
- const children = orch.getThreads(false).filter((t) => t.parentThreadId === parentId);
6602
- if (children.length >= MAX_ORCH_THREADS) {
6603
- return {
6604
- content: [
6605
- {
6606
- type: "text",
6607
- text: `Thread-creation cap (${MAX_ORCH_THREADS}) reached for this orchestration session`
6608
- }
6609
- ],
6610
- isError: true
6611
- };
7442
+ const ws = requireSlackWorkspace(team_id);
7443
+ const token = slackTokenFor(ws, "write");
7444
+ const dest = await resolveSlackDestination(token, destRaw);
7445
+ const body = appendGithubLink(message, github_url);
7446
+ const data = await slackApi(
7447
+ token,
7448
+ "chat.postMessage",
7449
+ {
7450
+ channel: dest.channelId,
7451
+ text: body,
7452
+ thread_ts: thread_ts?.trim() || void 0,
7453
+ unfurl_links: true,
7454
+ unfurl_media: true
7455
+ }
7456
+ );
7457
+ const postedTs = data.ts?.trim();
7458
+ const postedChannel = (data.channel || dest.channelId).trim();
7459
+ if (postedTs && postedChannel) {
7460
+ try {
7461
+ recordSlackOutboundWatch({
7462
+ teamId: ws.team_id,
7463
+ channelId: postedChannel,
7464
+ ts: postedTs,
7465
+ threadTs: thread_ts?.trim() || postedTs,
7466
+ kind: dest.kind === "channel" ? "channel" : "dm",
7467
+ toUserId: dest.userId,
7468
+ toLabel: dest.label,
7469
+ ownerUserId: ws.user_id
7470
+ });
7471
+ } catch {
6612
7472
  }
6613
7473
  }
6614
- let agentArg = args.agent;
6615
- let agentCoercedFrom;
6616
- const resolvedProbe = resolveNewThreadOptions2({ agent: agentArg }).agent;
6617
- if (resolvedProbe === "codex") {
6618
- agentCoercedFrom = agentArg ?? "codex";
6619
- const accountAgent = resolveNewThreadOptions2({}).agent;
6620
- agentArg = accountAgent !== "codex" ? accountAgent : "cursor";
6621
- }
6622
- const opts = resolveNewThreadOptions2({
6623
- agent: agentArg,
6624
- model: args.model
7474
+ return text({
7475
+ ok: true,
7476
+ team_id: ws.team_id,
7477
+ channel: postedChannel,
7478
+ label: dest.label,
7479
+ kind: dest.kind,
7480
+ user_id: dest.userId,
7481
+ ts: postedTs
6625
7482
  });
6626
- try {
6627
- const thread = await withTimeout(
6628
- orch.createThread({
6629
- sourceType: args.sourceType,
6630
- sourceRef: args.sourceRef,
6631
- agent: opts.agent,
6632
- model: opts.model,
6633
- effort: opts.effort,
6634
- fast: opts.fast,
6635
- repoPath: args.repoPath,
6636
- title: args.title,
6637
- parentThreadId: parentId || null
6638
- }),
6639
- CREATE_THREAD_TIMEOUT_MS,
6640
- "create_thread"
6641
- );
6642
- const parentNote = parentCorrectedFrom ? parentId ? `Ignored unknown/stale parentThreadId ${parentCorrectedFrom}; nested under ${parentId}` : `Ignored unknown/stale parentThreadId ${parentCorrectedFrom}; created without parent` : void 0;
6643
- return {
6644
- content: [
6645
- {
6646
- type: "text",
6647
- text: JSON.stringify({
6648
- id: thread.id,
6649
- title: thread.title,
6650
- branchName: thread.branchName,
6651
- worktreePath: thread.worktreePath,
6652
- agent: thread.agent,
6653
- model: thread.model,
6654
- status: thread.status,
6655
- link: `sideboard://thread/${thread.id}`,
6656
- parentThreadId: thread.parentThreadId,
6657
- ...agentCoercedFrom ? {
6658
- agentCoercedFrom,
6659
- note: `Avoid nested Codex under a Codex orchestrator \u2014 used Account default agent=${thread.agent}`
6660
- } : {},
6661
- ...parentCorrectedFrom ? { parentCorrectedFrom, parentNote } : {}
6662
- })
6663
- }
6664
- ]
6665
- };
6666
- } catch (err) {
6667
- const message = err instanceof Error ? err.message : String(err);
7483
+ } catch (err) {
7484
+ return fail(err);
7485
+ }
7486
+ }
7487
+ );
7488
+ }
7489
+
7490
+ // src/mcp/server.ts
7491
+ var MAX_ORCH_THREADS = 5;
7492
+ var CREATE_THREAD_TIMEOUT_MS = 9e4;
7493
+ function withTimeout(promise, ms, label) {
7494
+ return new Promise((resolve, reject) => {
7495
+ const timer = setTimeout(() => {
7496
+ reject(new Error(`${label} timed out after ${Math.round(ms / 1e3)}s`));
7497
+ }, ms);
7498
+ promise.then(
7499
+ (value) => {
7500
+ clearTimeout(timer);
7501
+ resolve(value);
7502
+ },
7503
+ (err) => {
7504
+ clearTimeout(timer);
7505
+ reject(err);
7506
+ }
7507
+ );
7508
+ });
7509
+ }
7510
+ async function startMcpServer() {
7511
+ const orch = getOrchestrator();
7512
+ try {
7513
+ const { maxConcurrentAgents: maxConcurrentAgents2 } = await import("./app-settings-3TLA2EJ7.js");
7514
+ orch.setMaxConcurrent(maxConcurrentAgents2());
7515
+ } catch {
7516
+ }
7517
+ try {
7518
+ await orch.reconcile(void 0, { reclaimStaleTurns: false, drainQueues: false });
7519
+ } catch (err) {
7520
+ console.error(
7521
+ "[sideboard-mcp] reconcile on boot failed (continuing):",
7522
+ err instanceof Error ? err.message : err
7523
+ );
7524
+ }
7525
+ const server = new McpServer({
7526
+ name: "sideboard",
7527
+ version: "0.1.0"
7528
+ });
7529
+ server.tool(
7530
+ "list_workspaces",
7531
+ "List registered Sideboard workspaces (repos). Each line is name, path, and github:owner/repo when resolvable \u2014 use path as repoPath for list_branches/list_prs/list_issues/create_thread.",
7532
+ {},
7533
+ async () => {
7534
+ const workspaces = orch.listWorkspaces();
7535
+ const lines = await Promise.all(
7536
+ workspaces.map(async (w) => {
7537
+ const slug = await resolveGithubRepoSlug(w.path).catch(() => null);
7538
+ return slug ? `${w.name} ${w.path} github:${slug}` : `${w.name} ${w.path}`;
7539
+ })
7540
+ );
7541
+ return {
7542
+ content: [
7543
+ { type: "text", text: lines.join("\n") || "(no workspaces)" }
7544
+ ]
7545
+ };
7546
+ }
7547
+ );
7548
+ server.tool(
7549
+ "list_threads",
7550
+ "List Sideboard threads across all workspaces (one summary line each \u2014 token-frugal). Each line ends with sideboard://thread/<id> \u2014 use that URL in markdown links so the UI can open the chat.",
7551
+ {},
7552
+ async () => {
7553
+ const threads = orch.getThreads(true);
7554
+ const lines = threads.map((t) => {
7555
+ const repo = t.repoPath === GLOBAL_WORKSPACE_ID ? "Orchestration" : basename4(t.repoPath) || t.repoPath;
7556
+ return `${t.id.slice(0, 8)} ${t.status.padEnd(9)} ${t.agent.padEnd(8)} ${repo} ${t.sourceType}:${t.sourceRef} ${t.title} sideboard://thread/${t.id}${t.devPort ? ` http://localhost:${t.devPort}` : ""}`;
7557
+ });
7558
+ return {
7559
+ content: [{ type: "text", text: lines.join("\n") || "(no threads)" }]
7560
+ };
7561
+ }
7562
+ );
7563
+ server.tool(
7564
+ "get_thread",
7565
+ "Get a compact thread summary by id/ref",
7566
+ { ref: z2.string() },
7567
+ async ({ ref }) => {
7568
+ const t = orch.getThread(ref);
7569
+ if (!t) {
7570
+ return { content: [{ type: "text", text: `Thread not found: ${ref}` }], isError: true };
7571
+ }
7572
+ const summary = {
7573
+ id: t.id,
7574
+ title: t.title,
7575
+ status: t.status,
7576
+ agent: t.agent,
7577
+ sourceType: t.sourceType,
7578
+ sourceRef: t.sourceRef,
7579
+ branchName: t.branchName,
7580
+ worktreePath: t.worktreePath,
7581
+ sessionId: t.sessionId,
7582
+ queueLength: t.queue.length,
7583
+ messageCount: t.messages.length,
7584
+ devPort: t.devPort,
7585
+ prUrl: t.prUrl,
7586
+ lastError: t.lastError ?? null
7587
+ };
7588
+ return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
7589
+ }
7590
+ );
7591
+ server.tool(
7592
+ "present_artifact",
7593
+ "Show an HTML, SVG, markdown, or React document in Sideboard\u2019s Claude-style side column (desktop). Use instead of claude.ai\u2019s artifact tool \u2014 pass the full document content. Prefer type=html for interactive pages. For type=react, pass a single component module that `export default`s a component (JSX/TSX ok) \u2014 Sideboard bootstraps React/ReactDOM/Babel and renders it; only `react`/`react-dom` imports are available, no other npm packages.",
7594
+ {
7595
+ title: z2.string().describe("Short title shown in the artifact pane header"),
7596
+ type: z2.enum(["html", "svg", "markdown", "react"]).describe(
7597
+ "Artifact kind \u2014 html opens an iframe preview; react transpiles and renders a default-exported component in a sandboxed iframe"
7598
+ ),
7599
+ content: z2.string().describe(
7600
+ "Full document body (complete HTML page, SVG markup, markdown, or a React component module with a default export)"
7601
+ ),
7602
+ artifact_id: z2.string().optional().describe("Stable id when updating the same artifact across turns")
7603
+ },
7604
+ async ({ title, type, artifact_id }) => {
7605
+ const id = artifact_id?.trim() || `artifact_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
7606
+ const payload = {
7607
+ ok: true,
7608
+ artifact_id: id,
7609
+ title,
7610
+ type,
7611
+ message: "Artifact accepted. Sideboard desktop opens it in the side column beside chat."
7612
+ };
7613
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
7614
+ }
7615
+ );
7616
+ server.tool(
7617
+ "ask_user",
7618
+ "Ask the user clarifying multiple-choice questions in Sideboard\u2019s composer (plan mode). Before calling, write a short chat message explaining the decision and what each option means. Include a description on every option. Use for approach forks and requirements \u2014 not for \u201Cis the plan ready?\u201D. After calling, stop and wait for their next message with answers.",
7619
+ {
7620
+ questions: z2.array(
7621
+ z2.object({
7622
+ question: z2.string().describe("Full question text ending with ?"),
7623
+ header: z2.string().max(24).optional().describe("Short label shown above the question"),
7624
+ multiSelect: z2.boolean().optional().describe("Allow selecting multiple options"),
7625
+ options: z2.array(
7626
+ z2.object({
7627
+ label: z2.string(),
7628
+ description: z2.string().optional().describe("What this option means / when to choose it (strongly preferred)")
7629
+ })
7630
+ ).min(2).max(6).describe("2\u20136 choices (Sideboard also offers Other)")
7631
+ })
7632
+ ).min(1).max(4).describe("1\u20134 questions")
7633
+ },
7634
+ async ({ questions }) => {
7635
+ const payload = {
7636
+ ok: true,
7637
+ questions,
7638
+ message: "Questions shown in Sideboard\u2019s composer. Wait for the user\u2019s next message with their answers before continuing."
7639
+ };
7640
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
7641
+ }
7642
+ );
7643
+ server.tool(
7644
+ "present_plan",
7645
+ "Save the implementation plan as markdown to .context/attachments/plan.md and show it in Sideboard chat for user approval (Copy / Hand off / Approve). Call this when the plan is ready \u2014 required in plan mode. Pass the full plan body in content. Then Claude should call ExitPlanMode.",
7646
+ {
7647
+ title: z2.string().optional().describe("Short plan title (defaults to Plan)"),
7648
+ content: z2.string().min(1).describe("Full plan markdown (headings, steps, risks, open questions)"),
7649
+ thread_id: z2.string().optional().describe("Sideboard thread id when cwd is not the worktree")
7650
+ },
7651
+ async ({ title, content, thread_id }) => {
7652
+ const { writePlanFile: writePlanFile2 } = await import("./plan-file-6O7G4VPQ.js");
7653
+ let root = process.cwd();
7654
+ if (thread_id?.trim()) {
7655
+ const t = orch.getThread(thread_id.trim());
7656
+ if (t?.worktreePath?.trim()) root = t.worktreePath;
7657
+ }
7658
+ const path = writePlanFile2(root, content);
7659
+ const payload = {
7660
+ ok: true,
7661
+ path,
7662
+ title: title?.trim() || "Plan",
7663
+ message: "Plan saved to .context/attachments/plan.md and shown in Sideboard chat for approval."
7664
+ };
7665
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
7666
+ }
7667
+ );
7668
+ server.tool(
7669
+ "present_schema",
7670
+ "Open Sideboard\u2019s schema-driven side column (filterable table and/or form). Pass JSON Schema + optional schemaUi. Prefer datasource=inline with embedded resource/records. Use datasource=brightsy with resource_id only when the user is logged into Brightsy.",
7671
+ {
7672
+ title: z2.string().describe("Pane title"),
7673
+ mode: z2.enum(["table", "form"]).optional().describe("table = list/filter records; form = edit one record"),
7674
+ datasource: z2.enum(["brightsy", "inline"]).optional().describe("brightsy resolves via login; inline uses embedded resource/records"),
7675
+ resource_id: z2.string().optional().describe("Brightsy record type UUID (or generic resource id)"),
7676
+ record_id: z2.string().optional().describe("Record id when opening form mode"),
7677
+ resource: z2.record(z2.unknown()).optional().describe("Inline { id, title, schema, schemaUi?, slug? }"),
7678
+ record: z2.record(z2.unknown()).optional().describe("Inline record { id, data, published_at? }"),
7679
+ records: z2.array(z2.record(z2.unknown())).optional().describe("Inline records for table mode"),
7680
+ pane_id: z2.string().optional().describe("Stable pane id across updates")
7681
+ },
7682
+ async (args) => {
7683
+ const id = args.pane_id?.trim() || `schema_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
7684
+ const payload = {
7685
+ ok: true,
7686
+ pane_id: id,
7687
+ title: args.title,
7688
+ mode: args.mode ?? (args.record_id || args.record ? "form" : "table"),
7689
+ datasource: args.datasource ?? (args.resource ? "inline" : "brightsy"),
7690
+ resource_id: args.resource_id,
7691
+ record_id: args.record_id,
7692
+ message: "Schema pane accepted. Sideboard desktop opens the CMS column beside chat."
7693
+ };
7694
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
7695
+ }
7696
+ );
7697
+ server.tool(
7698
+ "present_files",
7699
+ "Open Sideboard\u2019s Files column (CMS-style file manager: browse, upload, pick). Use datasource=brightsy when the user is logged into Brightsy account storage; datasource=memory for a session-local demo store. Prefer this over claiming a file manager UI is unavailable. Pair with present_schema when editing records that need media.",
7700
+ {
7701
+ title: z2.string().optional().describe("Pane title (default: Files)"),
7702
+ datasource: z2.enum(["brightsy", "memory"]).optional().describe("brightsy = account storage via login; memory = session demo store"),
7703
+ path: z2.string().optional().describe("Initial folder path (e.g. public)"),
7704
+ pane_id: z2.string().optional().describe("Stable pane id across updates")
7705
+ },
7706
+ async (args) => {
7707
+ const id = args.pane_id?.trim() || `files_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
7708
+ const payload = {
7709
+ ok: true,
7710
+ pane_id: id,
7711
+ title: args.title?.trim() || "Files",
7712
+ datasource: args.datasource ?? "brightsy",
7713
+ path: args.path,
7714
+ message: "Files pane accepted. Sideboard desktop opens the Files column beside chat."
7715
+ };
7716
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
7717
+ }
7718
+ );
7719
+ registerSlackTools(server);
7720
+ if (sideboardMcpProfile() !== "worktree") {
7721
+ const { getCaffeinateHold: getCaffeinateHold2, setCaffeinateHold: setCaffeinateHold2 } = await import("./caffeinate-hold-KLC6SABD.js");
7722
+ const { resolveNewThreadOptions: resolveNewThreadOptions2, resolveThreadDefaults: resolveThreadDefaults2 } = await import("./app-settings-3TLA2EJ7.js");
7723
+ const accountDefaults = resolveThreadDefaults2();
7724
+ const accountDefaultsHint = `Account defaults: agent=${accountDefaults.agent}, model=${accountDefaults.model?.trim() || "Auto"}, effort=${accountDefaults.effort}`;
7725
+ server.tool(
7726
+ "set_caffeinate",
7727
+ "Keep this Mac awake with caffeinate (like Claude Code) across turns \u2014 independent of Settings toggles. Turn ON when the user will be away from the keyboard, is driving work from Slack, or asks you to keep the machine awake. Turn OFF when they say they are done, wrapping up, going to sleep, or no longer need the Mac awake. macOS only.",
7728
+ {
7729
+ enabled: z2.boolean().describe("true = hold caffeinate on; false = release and let the Mac sleep")
7730
+ },
7731
+ async ({ enabled }) => {
7732
+ const state = setCaffeinateHold2(enabled);
7733
+ if (enabled && !state.held) {
6668
7734
  return {
6669
7735
  content: [
6670
7736
  {
6671
7737
  type: "text",
6672
- text: `create_thread failed: ${message}`
7738
+ text: JSON.stringify({
7739
+ ...state,
7740
+ ok: false,
7741
+ message: state.platform === "darwin" ? "Could not start caffeinate." : "Caffeinate is macOS only."
7742
+ })
6673
7743
  }
6674
7744
  ],
6675
7745
  isError: true
6676
7746
  };
6677
7747
  }
6678
- }
6679
- );
6680
- server.tool(
6681
- "send_to_thread",
6682
- "Queue a prompt on a worktree thread chat (runs under concurrency cap). Use after create_thread to start or continue a conversation. Set force_stop=true to interrupt an in-flight/queued turn (kill + clear queue) before queueing this prompt \u2014 use when the thread is mid-turn or has stale queued prompts you need to replace.",
6683
- {
6684
- ref: z.string(),
6685
- prompt: z.string(),
6686
- force_stop: z.boolean().optional()
6687
- },
6688
- async ({ ref, prompt, force_stop }) => {
6689
- if (force_stop) {
6690
- const existing = orch.getThread(ref);
6691
- if (existing) {
6692
- orch.stop(ref, { clearQueue: true });
6693
- }
6694
- }
6695
- const thread = await orch.send(ref, prompt);
6696
7748
  return {
6697
7749
  content: [
6698
7750
  {
6699
7751
  type: "text",
6700
7752
  text: JSON.stringify({
6701
- id: thread.id,
6702
- status: thread.status,
6703
- queueLength: thread.queue.length,
6704
- forceStopped: Boolean(force_stop)
7753
+ ...getCaffeinateHold2(),
7754
+ ok: true,
7755
+ message: state.held ? "Mac will stay awake until you call set_caffeinate with enabled=false (or the user says they are done)." : "Caffeinate hold released. The Mac can sleep (unless Settings caffeinate toggles are on)."
6705
7756
  })
6706
7757
  }
6707
7758
  ]
@@ -6709,16 +7760,162 @@ async function startMcpServer() {
6709
7760
  }
6710
7761
  );
6711
7762
  server.tool(
6712
- "wait_for_turn",
6713
- "Block until the thread finishes its current/queued turn (avoids polling). Use after send_to_thread to read the agent reply.",
7763
+ "create_thread",
7764
+ `Create a new worktree thread (chat) from branch, pr, or ticket. Pass repoPath from list_workspaces. From an orchestration chat, omit parentThreadId (Sideboard binds the child to this chat) or pass the exact id from the turn reminder \u2014 never invent a uuid. Prefer omitting agent/model so Sideboard applies ${accountDefaultsHint}. Then use send_to_thread to chat.`,
6714
7765
  {
6715
- ref: z.string(),
6716
- timeoutMs: z.number().optional()
7766
+ sourceType: z2.enum(["branch", "pr", "ticket"]),
7767
+ sourceRef: z2.string(),
7768
+ agent: z2.enum(["claude", "codex", "opencode", "brightsy", "cursor"]).optional().describe(`Omit to use Account default agent (${accountDefaults.agent})`),
7769
+ model: z2.string().nullable().optional().describe(
7770
+ `Usually omit to use Account default model (${accountDefaults.model?.trim() || "Auto"}). Pass null only to force Auto / agent-default.`
7771
+ ),
7772
+ repoPath: z2.string(),
7773
+ title: z2.string().optional(),
7774
+ parentThreadId: z2.string().optional().describe(
7775
+ "Orchestration: omit (preferred) or pass YOUR chat id from the turn reminder / AGENTS.md. Do not invent uuids."
7776
+ )
6717
7777
  },
6718
- async ({ ref, timeoutMs }) => {
6719
- const thread = await orch.waitForTurn(ref, timeoutMs ?? 6e5);
6720
- const result = orch.getTurnResult(thread.id);
6721
- return {
7778
+ async (args) => {
7779
+ const envParentId = process.env.SIDEBOARD_ORCHESTRATOR_THREAD_ID?.trim() || "";
7780
+ let parentId = args.parentThreadId?.trim() || "";
7781
+ let parent = parentId ? orch.getThread(parentId) : null;
7782
+ let parentCorrectedFrom;
7783
+ if (envParentId) {
7784
+ const envParent = orch.getThread(envParentId);
7785
+ if (envParent) {
7786
+ if (parentId && parentId !== envParentId) parentCorrectedFrom = parentId;
7787
+ else if (!parentId) parentCorrectedFrom = void 0;
7788
+ parentId = envParentId;
7789
+ parent = envParent;
7790
+ }
7791
+ }
7792
+ if (parentId && !parent) {
7793
+ parentCorrectedFrom = parentId;
7794
+ parentId = "";
7795
+ parent = null;
7796
+ }
7797
+ if (parentId) {
7798
+ const children = orch.getThreads(false).filter((t) => t.parentThreadId === parentId);
7799
+ if (children.length >= MAX_ORCH_THREADS) {
7800
+ return {
7801
+ content: [
7802
+ {
7803
+ type: "text",
7804
+ text: `Thread-creation cap (${MAX_ORCH_THREADS}) reached for this orchestration session`
7805
+ }
7806
+ ],
7807
+ isError: true
7808
+ };
7809
+ }
7810
+ }
7811
+ let agentArg = args.agent;
7812
+ let agentCoercedFrom;
7813
+ const resolvedProbe = resolveNewThreadOptions2({ agent: agentArg }).agent;
7814
+ if (resolvedProbe === "codex") {
7815
+ agentCoercedFrom = agentArg ?? "codex";
7816
+ const accountAgent = resolveNewThreadOptions2({}).agent;
7817
+ agentArg = accountAgent !== "codex" ? accountAgent : "cursor";
7818
+ }
7819
+ const opts = resolveNewThreadOptions2({
7820
+ agent: agentArg,
7821
+ model: args.model
7822
+ });
7823
+ try {
7824
+ const thread = await withTimeout(
7825
+ orch.createThread({
7826
+ sourceType: args.sourceType,
7827
+ sourceRef: args.sourceRef,
7828
+ agent: opts.agent,
7829
+ model: opts.model,
7830
+ effort: opts.effort,
7831
+ fast: opts.fast,
7832
+ repoPath: args.repoPath,
7833
+ title: args.title,
7834
+ parentThreadId: parentId || null
7835
+ }),
7836
+ CREATE_THREAD_TIMEOUT_MS,
7837
+ "create_thread"
7838
+ );
7839
+ const parentNote = parentCorrectedFrom ? parentId ? `Ignored unknown/stale parentThreadId ${parentCorrectedFrom}; nested under ${parentId}` : `Ignored unknown/stale parentThreadId ${parentCorrectedFrom}; created without parent` : void 0;
7840
+ return {
7841
+ content: [
7842
+ {
7843
+ type: "text",
7844
+ text: JSON.stringify({
7845
+ id: thread.id,
7846
+ title: thread.title,
7847
+ branchName: thread.branchName,
7848
+ worktreePath: thread.worktreePath,
7849
+ agent: thread.agent,
7850
+ model: thread.model,
7851
+ status: thread.status,
7852
+ link: `sideboard://thread/${thread.id}`,
7853
+ parentThreadId: thread.parentThreadId,
7854
+ ...agentCoercedFrom ? {
7855
+ agentCoercedFrom,
7856
+ note: `Avoid nested Codex under a Codex orchestrator \u2014 used Account default agent=${thread.agent}`
7857
+ } : {},
7858
+ ...parentCorrectedFrom ? { parentCorrectedFrom, parentNote } : {}
7859
+ })
7860
+ }
7861
+ ]
7862
+ };
7863
+ } catch (err) {
7864
+ const message = err instanceof Error ? err.message : String(err);
7865
+ return {
7866
+ content: [
7867
+ {
7868
+ type: "text",
7869
+ text: `create_thread failed: ${message}`
7870
+ }
7871
+ ],
7872
+ isError: true
7873
+ };
7874
+ }
7875
+ }
7876
+ );
7877
+ server.tool(
7878
+ "send_to_thread",
7879
+ "Queue a prompt on a worktree thread chat (runs under concurrency cap). Use after create_thread to start or continue a conversation. Set force_stop=true to interrupt an in-flight/queued turn (kill + clear queue) before queueing this prompt \u2014 use when the thread is mid-turn or has stale queued prompts you need to replace.",
7880
+ {
7881
+ ref: z2.string(),
7882
+ prompt: z2.string(),
7883
+ force_stop: z2.boolean().optional()
7884
+ },
7885
+ async ({ ref, prompt, force_stop }) => {
7886
+ if (force_stop) {
7887
+ const existing = orch.getThread(ref);
7888
+ if (existing) {
7889
+ orch.stop(ref, { clearQueue: true });
7890
+ }
7891
+ }
7892
+ const thread = await orch.send(ref, prompt);
7893
+ return {
7894
+ content: [
7895
+ {
7896
+ type: "text",
7897
+ text: JSON.stringify({
7898
+ id: thread.id,
7899
+ status: thread.status,
7900
+ queueLength: thread.queue.length,
7901
+ forceStopped: Boolean(force_stop)
7902
+ })
7903
+ }
7904
+ ]
7905
+ };
7906
+ }
7907
+ );
7908
+ server.tool(
7909
+ "wait_for_turn",
7910
+ "Block until the thread finishes its current/queued turn (avoids polling). Use after send_to_thread to read the agent reply.",
7911
+ {
7912
+ ref: z2.string(),
7913
+ timeoutMs: z2.number().optional()
7914
+ },
7915
+ async ({ ref, timeoutMs }) => {
7916
+ const thread = await orch.waitForTurn(ref, timeoutMs ?? 6e5);
7917
+ const result = orch.getTurnResult(thread.id);
7918
+ return {
6722
7919
  content: [
6723
7920
  {
6724
7921
  type: "text",
@@ -6735,7 +7932,7 @@ async function startMcpServer() {
6735
7932
  server.tool(
6736
7933
  "get_turn_result",
6737
7934
  "Final assistant message only (not full transcript)",
6738
- { ref: z.string() },
7935
+ { ref: z2.string() },
6739
7936
  async ({ ref }) => {
6740
7937
  const result = orch.getTurnResult(ref);
6741
7938
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
@@ -6745,8 +7942,8 @@ async function startMcpServer() {
6745
7942
  "stop_thread",
6746
7943
  "Force-stop a thread: kill any in-flight agent turn AND clear queued prompts so drainQueue cannot continue. Does not archive the worktree. Optional force defaults to true.",
6747
7944
  {
6748
- ref: z.string(),
6749
- force: z.boolean().optional()
7945
+ ref: z2.string(),
7946
+ force: z2.boolean().optional()
6750
7947
  },
6751
7948
  async ({ ref, force }) => {
6752
7949
  const t = orch.getThread(ref);
@@ -6775,8 +7972,8 @@ async function startMcpServer() {
6775
7972
  );
6776
7973
  server.tool(
6777
7974
  "archive_thread",
6778
- "Archive a thread (stops agent/dev, runs archive script, removes worktree when last chat tab). Cannot archive the cloud coordinator \u2014 use the Sideboard UI for that.",
6779
- { ref: z.string() },
7975
+ "Archive a thread (stops agent/dev, runs archive script, removes worktree when last chat tab). Coordinators open PRs only by asking the worktree agent.",
7976
+ { ref: z2.string() },
6780
7977
  async ({ ref }) => {
6781
7978
  const t = orch.getThread(ref);
6782
7979
  if (!t) {
@@ -6809,7 +8006,7 @@ async function startMcpServer() {
6809
8006
  server.tool(
6810
8007
  "restore_thread",
6811
8008
  "Restore an archived thread (recreates worktree from branch when needed)",
6812
- { ref: z.string() },
8009
+ { ref: z2.string() },
6813
8010
  async ({ ref }) => {
6814
8011
  try {
6815
8012
  const restored = await orch.restore(ref);
@@ -6835,8 +8032,8 @@ async function startMcpServer() {
6835
8032
  "get_diff",
6836
8033
  "Compact diff summary (capped hunks, paginated)",
6837
8034
  {
6838
- ref: z.string(),
6839
- maxFiles: z.number().optional()
8035
+ ref: z2.string(),
8036
+ maxFiles: z2.number().optional()
6840
8037
  },
6841
8038
  async ({ ref, maxFiles }) => {
6842
8039
  const summary = await orch.diffSummary(ref);
@@ -6856,7 +8053,7 @@ async function startMcpServer() {
6856
8053
  server.tool(
6857
8054
  "request_review",
6858
8055
  'Start a merge-readiness Review on a worktree agent thread (same as the desktop Review button). Opens a new Review chat tab, attaches .sideboard/review.md when present (else local Review request.md / stock template), and sends "Review changes in this workspace." Expect Approve / Approve with nits / Request changes / Needs more information. Pass a worktree thread ref \u2014 not the orchestrator. Then wait_for_turn / get_turn_result on the returned review tab id.',
6859
- { ref: z.string().describe("Worktree thread id/ref to review") },
8056
+ { ref: z2.string().describe("Worktree thread id/ref to review") },
6860
8057
  async ({ ref }) => {
6861
8058
  try {
6862
8059
  const tab = await orch.requestReview(ref);
@@ -6881,7 +8078,7 @@ async function startMcpServer() {
6881
8078
  }
6882
8079
  }
6883
8080
  );
6884
- const agentEnum = z.enum(["claude", "codex", "opencode", "brightsy", "cursor"]);
8081
+ const agentEnum = z2.enum(["claude", "codex", "opencode", "brightsy", "cursor"]);
6885
8082
  server.tool(
6886
8083
  "list_models",
6887
8084
  "List models for an agent. Prefer Auto: do not call this unless you have a reason to pin a specific model (user request, cost/latency, capability). Omit agent to list all.",
@@ -6904,11 +8101,11 @@ async function startMcpServer() {
6904
8101
  "fork_worktree",
6905
8102
  "Fork a worktree agent chat into a NEW git worktree + chat (desktop \u201CFork to new workspace\u201D). Seeds a transcript (through through_index, default all). Optional agent override. Leave model unset for Auto (default) \u2014 only pass model when you have a reason. Not for the orchestrator. Then send_to_thread / wait_for_turn on the returned id.",
6906
8103
  {
6907
- ref: z.string().describe("Worktree thread id/ref to fork"),
6908
- through_index: z.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
8104
+ ref: z2.string().describe("Worktree thread id/ref to fork"),
8105
+ through_index: z2.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
6909
8106
  agent: agentEnum.optional().describe("Agent for the forked chat (default: same as source)"),
6910
- model: z.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
6911
- title: z.string().optional()
8107
+ model: z2.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
8108
+ title: z2.string().optional()
6912
8109
  },
6913
8110
  async ({ ref, through_index, agent, model, title }) => {
6914
8111
  try {
@@ -6947,13 +8144,13 @@ async function startMcpServer() {
6947
8144
  );
6948
8145
  server.tool(
6949
8146
  "fork_chat",
6950
- "Fork a chat into a NEW tab on the SAME workspace: worktree agent \u2192 same worktree tab; Global orchestration chat \u2192 new orchestration chat (same synthetic home). Seeds a transcript; optional agent override. Leave model unset for Auto unless you have a reason. Orchestration forks require an MCP-capable agent (claude, cursor, codex, opencode \u2014 not brightsy). Remote coordinators use this to continue an orchestration chat on another agent after session limits. Then send_to_thread / wait_for_turn on the returned id. Use fork_worktree only for worktree agents that need a new git worktree.",
8147
+ "Fork a chat into a NEW tab on the SAME workspace: worktree agent \u2192 same worktree tab; Global orchestration chat \u2192 new orchestration chat (same synthetic home). Seeds a transcript; optional agent override. Leave model unset for Auto unless you have a reason. Orchestration forks require an MCP-capable agent (claude, cursor, codex, opencode \u2014 not brightsy). Slack / Global orchestrators use this to continue an orchestration chat on another agent after session limits. Then send_to_thread / wait_for_turn on the returned id. Use fork_worktree only for worktree agents that need a new git worktree.",
6951
8148
  {
6952
- ref: z.string().describe("Thread id/ref to fork (worktree agent or orchestration chat)"),
6953
- through_index: z.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
8149
+ ref: z2.string().describe("Thread id/ref to fork (worktree agent or orchestration chat)"),
8150
+ through_index: z2.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
6954
8151
  agent: agentEnum.optional().describe("Agent for the forked chat (default: same as source)"),
6955
- model: z.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
6956
- title: z.string().optional()
8152
+ model: z2.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
8153
+ title: z2.string().optional()
6957
8154
  },
6958
8155
  async ({ ref, through_index, agent, model, title }) => {
6959
8156
  try {
@@ -6999,8 +8196,8 @@ async function startMcpServer() {
6999
8196
  "run_dev_script",
7000
8197
  "Start a .sideboard/.conductor run script for a thread (default script if name omitted); returns port",
7001
8198
  {
7002
- ref: z.string(),
7003
- name: z.string().optional()
8199
+ ref: z2.string(),
8200
+ name: z2.string().optional()
7004
8201
  },
7005
8202
  async ({ ref, name }) => {
7006
8203
  const result = await orch.startDev(ref, name);
@@ -7022,7 +8219,7 @@ async function startMcpServer() {
7022
8219
  server.tool(
7023
8220
  "list_run_scripts",
7024
8221
  "List named run scripts available for a thread",
7025
- { ref: z.string() },
8222
+ { ref: z2.string() },
7026
8223
  async ({ ref }) => {
7027
8224
  const scripts = orch.listThreadRunScripts(ref);
7028
8225
  const active = orch.getActiveRuns(ref);
@@ -7040,8 +8237,8 @@ async function startMcpServer() {
7040
8237
  "stop_dev_script",
7041
8238
  "Stop a running script for a thread (all scripts if name omitted)",
7042
8239
  {
7043
- ref: z.string(),
7044
- name: z.string().optional()
8240
+ ref: z2.string(),
8241
+ name: z2.string().optional()
7045
8242
  },
7046
8243
  async ({ ref, name }) => {
7047
8244
  orch.stopDev(ref, name);
@@ -7051,7 +8248,7 @@ async function startMcpServer() {
7051
8248
  server.tool(
7052
8249
  "run_setup",
7053
8250
  "Re-run workspace setup (Sideboard/Conductor settings or .cursor/worktrees.json)",
7054
- { ref: z.string() },
8251
+ { ref: z2.string() },
7055
8252
  async ({ ref }) => {
7056
8253
  const result = await orch.runSetup(ref);
7057
8254
  return {
@@ -7062,7 +8259,7 @@ async function startMcpServer() {
7062
8259
  server.tool(
7063
8260
  "add_workspace",
7064
8261
  "Register a git repo as a Sideboard workspace",
7065
- { repoPath: z.string() },
8262
+ { repoPath: z2.string() },
7066
8263
  async ({ repoPath }) => {
7067
8264
  const ws = await orch.addWorkspace(repoPath);
7068
8265
  return { content: [{ type: "text", text: JSON.stringify(ws) }] };
@@ -7071,7 +8268,7 @@ async function startMcpServer() {
7071
8268
  server.tool(
7072
8269
  "remove_workspace",
7073
8270
  "Unregister a Sideboard workspace (does not archive threads)",
7074
- { repoPath: z.string() },
8271
+ { repoPath: z2.string() },
7075
8272
  async ({ repoPath }) => {
7076
8273
  orch.removeWorkspace(repoPath);
7077
8274
  return { content: [{ type: "text", text: "ok" }] };
@@ -7081,12 +8278,12 @@ async function startMcpServer() {
7081
8278
  "fanout",
7082
8279
  "Best-of-n: create one thread per agent with the same prompt (parallel attempts)",
7083
8280
  {
7084
- prompt: z.string(),
7085
- agents: z.array(z.enum(["claude", "codex", "opencode", "brightsy", "cursor"])),
7086
- repoPath: z.string(),
7087
- sourceType: z.enum(["branch", "pr", "ticket"]).optional(),
7088
- sourceRef: z.string().optional(),
7089
- title: z.string().optional()
8281
+ prompt: z2.string(),
8282
+ agents: z2.array(z2.enum(["claude", "codex", "opencode", "brightsy", "cursor"])),
8283
+ repoPath: z2.string(),
8284
+ sourceType: z2.enum(["branch", "pr", "ticket"]).optional(),
8285
+ sourceRef: z2.string().optional(),
8286
+ title: z2.string().optional()
7090
8287
  },
7091
8288
  async (args) => {
7092
8289
  const threads = await orch.bestOfN(args);
@@ -7113,8 +8310,8 @@ async function startMcpServer() {
7113
8310
  "list_branches",
7114
8311
  "List git branches in a registered workspace. Pass repoPath from list_workspaces (unmerged into the default branch by default \u2014 for create_thread sourceType=branch).",
7115
8312
  {
7116
- repoPath: z.string(),
7117
- unmergedOnly: z.boolean().optional()
8313
+ repoPath: z2.string(),
8314
+ unmergedOnly: z2.boolean().optional()
7118
8315
  },
7119
8316
  async ({ repoPath, unmergedOnly }) => {
7120
8317
  const root = await resolveRepoRoot(repoPath);
@@ -7134,7 +8331,7 @@ async function startMcpServer() {
7134
8331
  server.tool(
7135
8332
  "list_prs",
7136
8333
  "List open GitHub PRs for a registered workspace. Pass repoPath from list_workspaces (uses gh against that repo remote). Then create_thread with sourceType=pr.",
7137
- { repoPath: z.string() },
8334
+ { repoPath: z2.string() },
7138
8335
  async ({ repoPath }) => {
7139
8336
  const root = await resolveRepoRoot(repoPath);
7140
8337
  const prs = await listPrs(root);
@@ -7153,7 +8350,7 @@ async function startMcpServer() {
7153
8350
  server.tool(
7154
8351
  "get_pr_stack",
7155
8352
  "Load the GitHub PR stack for a thread worktree (`gh stack view --json`). Returns null JSON when the branch is not stacked. Prefer this before mergePr on stacked PRs.",
7156
- { ref: z.string() },
8353
+ { ref: z2.string() },
7157
8354
  async ({ ref }) => {
7158
8355
  const stack = await orch.getPrStack(ref);
7159
8356
  return {
@@ -7165,8 +8362,8 @@ async function startMcpServer() {
7165
8362
  "open_pr_stack_layers",
7166
8363
  "Materialize one worktree+thread per stack layer (or a single 1-based layer). Pass a thread ref already on the stack.",
7167
8364
  {
7168
- ref: z.string(),
7169
- layer: z.number().int().positive().optional()
8365
+ ref: z2.string(),
8366
+ layer: z2.number().int().positive().optional()
7170
8367
  },
7171
8368
  async ({ ref, layer }) => {
7172
8369
  const result = await orch.openPrStackLayers(ref, { layer });
@@ -7200,9 +8397,9 @@ async function startMcpServer() {
7200
8397
  "add_stack_layer",
7201
8398
  "Add a branch on top of the current stack (`gh stack add`) and open a worktree+thread for it.",
7202
8399
  {
7203
- ref: z.string(),
7204
- branchName: z.string(),
7205
- title: z.string().optional()
8400
+ ref: z2.string(),
8401
+ branchName: z2.string(),
8402
+ title: z2.string().optional()
7206
8403
  },
7207
8404
  async ({ ref, branchName, title }) => {
7208
8405
  const result = await orch.addStackLayer(ref, branchName, { title });
@@ -7231,11 +8428,11 @@ async function startMcpServer() {
7231
8428
  "create_pr_stack",
7232
8429
  "Create a new GitHub PR stack with one Sideboard worktree per layer (bottom\u2192top branch names). Requires `gh extension install github/gh-stack`.",
7233
8430
  {
7234
- repoPath: z.string(),
7235
- branches: z.array(z.string()).min(1),
7236
- agent: z.enum(["claude", "codex", "opencode", "brightsy", "cursor"]),
7237
- base: z.string().optional(),
7238
- title: z.string().optional()
8431
+ repoPath: z2.string(),
8432
+ branches: z2.array(z2.string()).min(1),
8433
+ agent: z2.enum(["claude", "codex", "opencode", "brightsy", "cursor"]),
8434
+ base: z2.string().optional(),
8435
+ title: z2.string().optional()
7239
8436
  },
7240
8437
  async (args) => {
7241
8438
  const result = await orch.createPrStack({
@@ -7269,362 +8466,1640 @@ async function startMcpServer() {
7269
8466
  ]
7270
8467
  };
7271
8468
  }
7272
- );
7273
- server.tool(
7274
- "list_issues",
7275
- "List issues from Sideboard Account connections (Linear API or GitHub Issues; Linear\u2192GitHub fallback when Linear is not connected). Pass repoPath from list_workspaces \u2014 GitHub Issues are scoped to that repo. Then create_thread with sourceType=ticket.",
7276
- { repoPath: z.string() },
7277
- async ({ repoPath }) => {
7278
- const root = await resolveRepoRoot(repoPath);
7279
- const result = await listIssues(root);
7280
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8469
+ );
8470
+ server.tool(
8471
+ "list_issues",
8472
+ "List issues from Sideboard Account connections (Linear API or GitHub Issues; Linear\u2192GitHub fallback when Linear is not connected). Pass repoPath from list_workspaces \u2014 GitHub Issues are scoped to that repo. Then create_thread with sourceType=ticket.",
8473
+ { repoPath: z2.string() },
8474
+ async ({ repoPath }) => {
8475
+ const root = await resolveRepoRoot(repoPath);
8476
+ const result = await listIssues(root);
8477
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8478
+ }
8479
+ );
8480
+ server.tool(
8481
+ "list_linear_issues",
8482
+ "Deprecated: prefer list_issues. Lists assigned Linear issues via the chosen agent MCP connector",
8483
+ {
8484
+ agent: z2.enum(["claude", "codex", "opencode"]),
8485
+ repoPath: z2.string()
8486
+ },
8487
+ async ({ agent, repoPath }) => {
8488
+ const issues = await listLinearIssues(agent, repoPath);
8489
+ return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
8490
+ }
8491
+ );
8492
+ }
8493
+ const transport = new StdioServerTransport();
8494
+ await server.connect(transport);
8495
+ }
8496
+
8497
+ // src/brightsy/api.ts
8498
+ function formatBrightsyFetchError(err, url) {
8499
+ if (!(err instanceof Error)) return `${String(err)} (${url})`;
8500
+ const cause = err.cause;
8501
+ let detail = "";
8502
+ if (cause instanceof Error) {
8503
+ const code = typeof cause.code === "string" ? cause.code : void 0;
8504
+ detail = code ? ` [${code}: ${cause.message}]` : ` [${cause.message}]`;
8505
+ } else if (cause != null) {
8506
+ detail = ` [${String(cause)}]`;
8507
+ }
8508
+ return `${err.message}${detail} (${url})`;
8509
+ }
8510
+ var BrightsySideboardApi = class {
8511
+ cfg;
8512
+ fetchImpl;
8513
+ constructor(cfgOrOpts) {
8514
+ const isOpts = !!cfgOrOpts && typeof cfgOrOpts === "object" && ("fetchImpl" in cfgOrOpts || "cfg" in cfgOrOpts) && !("access_token" in cfgOrOpts);
8515
+ if (isOpts) {
8516
+ const opts = cfgOrOpts;
8517
+ this.cfg = opts.cfg ?? loadBrightsyConfig();
8518
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
8519
+ } else {
8520
+ this.cfg = cfgOrOpts ?? loadBrightsyConfig();
8521
+ this.fetchImpl = globalThis.fetch.bind(globalThis);
8522
+ }
8523
+ }
8524
+ get accountId() {
8525
+ return this.cfg.account_id;
8526
+ }
8527
+ get endpoint() {
8528
+ return (this.cfg.endpoint || "https://brightsy.ai").replace(/\/$/, "");
8529
+ }
8530
+ async request(path, init = {}) {
8531
+ await this.refreshIfNeeded();
8532
+ const url = `${this.endpoint}${path}`;
8533
+ const headers = {
8534
+ Authorization: `Bearer ${this.cfg.access_token}`,
8535
+ Accept: "application/json"
8536
+ };
8537
+ if (init.body != null) headers["Content-Type"] = "application/json";
8538
+ let res;
8539
+ try {
8540
+ res = await this.fetchImpl(url, {
8541
+ method: init.method ?? "GET",
8542
+ headers,
8543
+ body: init.body != null ? JSON.stringify(init.body) : void 0
8544
+ });
8545
+ } catch (err) {
8546
+ throw new Error(formatBrightsyFetchError(err, url));
8547
+ }
8548
+ if (!res.ok) {
8549
+ const err = await res.json().catch(() => ({}));
8550
+ throw new Error(err.error?.message ?? `${res.status} ${res.statusText} (${url})`);
8551
+ }
8552
+ return await res.json();
8553
+ }
8554
+ async refreshIfNeeded() {
8555
+ const expiresAt = this.cfg.expires_at ?? 0;
8556
+ if (!this.cfg.refresh_token || Date.now() < expiresAt - 6e4) return;
8557
+ const clientId = this.cfg.oauth_client_id || "brightsy-cli";
8558
+ const url = `${this.endpoint}/oauth/token`;
8559
+ let res;
8560
+ try {
8561
+ res = await this.fetchImpl(url, {
8562
+ method: "POST",
8563
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
8564
+ body: new URLSearchParams({
8565
+ grant_type: "refresh_token",
8566
+ refresh_token: this.cfg.refresh_token,
8567
+ client_id: clientId
8568
+ })
8569
+ });
8570
+ } catch (err) {
8571
+ throw new Error(formatBrightsyFetchError(err, url));
8572
+ }
8573
+ if (!res.ok) return;
8574
+ const data = await res.json();
8575
+ if (!data.access_token) return;
8576
+ this.cfg.access_token = data.access_token;
8577
+ if (data.refresh_token) this.cfg.refresh_token = data.refresh_token;
8578
+ if (data.expires_in) {
8579
+ this.cfg.expires_at = Date.now() + data.expires_in * 1e3;
8580
+ }
8581
+ saveBrightsyConfig(this.cfg);
8582
+ }
8583
+ async getAccess() {
8584
+ return this.request(
8585
+ `/api/v1beta/desktop/access?accountId=${encodeURIComponent(this.accountId)}`
8586
+ );
8587
+ }
8588
+ async setAccess(enabled, allow_always = false) {
8589
+ return this.request("/api/v1beta/desktop/access", {
8590
+ method: "PUT",
8591
+ body: {
8592
+ accountId: this.accountId,
8593
+ enabled,
8594
+ allow_always
8595
+ }
8596
+ });
8597
+ }
8598
+ async getTasks(status) {
8599
+ const params = new URLSearchParams();
8600
+ if (status) params.set("status", status);
8601
+ params.set("accountId", this.accountId);
8602
+ const q = `?${params.toString()}`;
8603
+ const data = await this.request(
8604
+ `/api/v1beta/desktop/tasks${q}`
8605
+ );
8606
+ return data.tasks ?? [];
8607
+ }
8608
+ async approveTask(taskId) {
8609
+ await this.request(`/api/v1beta/desktop/tasks/${taskId}`, {
8610
+ method: "PATCH",
8611
+ body: { action: "approve" }
8612
+ });
8613
+ }
8614
+ async markRunning(taskId) {
8615
+ await this.request(`/api/v1beta/desktop/tasks/${taskId}`, {
8616
+ method: "PATCH",
8617
+ body: { task_status: "running" }
8618
+ });
8619
+ }
8620
+ async submitResponse(taskId, response) {
8621
+ await this.request(`/api/v1beta/desktop/tasks/${taskId}/response`, {
8622
+ method: "POST",
8623
+ body: { response }
8624
+ });
8625
+ }
8626
+ };
8627
+ function taskMessageText(task) {
8628
+ const parts = task.message?.parts ?? [];
8629
+ const text2 = parts.find((p) => p.kind === "text")?.text ?? "";
8630
+ return text2.trim();
8631
+ }
8632
+
8633
+ // src/brightsy/cloud-connect.ts
8634
+ var POLL_MS = 5e3;
8635
+ var handleTaskChain = Promise.resolve();
8636
+ function enqueueHandleTask(fn) {
8637
+ const run2 = handleTaskChain.then(fn, fn);
8638
+ handleTaskChain = run2.then(
8639
+ () => void 0,
8640
+ () => void 0
8641
+ );
8642
+ return run2;
8643
+ }
8644
+ function refreshWorkspaces() {
8645
+ const orch = getOrchestrator();
8646
+ return orch.listWorkspaces();
8647
+ }
8648
+ async function handleTask(api, task, opts) {
8649
+ const log = opts.onLog ?? (() => void 0);
8650
+ let message = taskMessageText(task);
8651
+ if (!message) {
8652
+ log(`skip ${task.id.slice(0, 8)}: empty message`);
8653
+ return;
8654
+ }
8655
+ if (task.task_status !== "pending" && task.task_status !== "running" && task.task_status !== "awaiting_confirmation") {
8656
+ return;
8657
+ }
8658
+ const workspaces = refreshWorkspaces();
8659
+ for (const ws of workspaces) {
8660
+ await getOrchestrator().reconcile(ws.path).catch(() => void 0);
8661
+ }
8662
+ const inventory = await enrichWorkspacesWithGithub(workspaces);
8663
+ const coordinator = ensureCloudCoordinator(opts.agent);
8664
+ let fresh = readThread(coordinator.id) ?? coordinator;
8665
+ const parsed = parseForceStopMessage(message);
8666
+ if (parsed.forceStop) {
8667
+ try {
8668
+ getOrchestrator().stop(fresh.id);
8669
+ log(`force-stop ${task.id.slice(0, 8)} \u2192 coordinator ${fresh.id.slice(0, 8)}`);
8670
+ } catch (err) {
8671
+ const msg = err instanceof Error ? err.message : String(err);
8672
+ log(`force-stop ${task.id.slice(0, 8)}: ${msg}`);
8673
+ }
8674
+ if (!parsed.remainder) {
8675
+ if (task.task_status === "awaiting_confirmation") {
8676
+ await api.approveTask(task.id).catch(() => void 0);
8677
+ }
8678
+ await api.markRunning(task.id).catch(() => void 0);
8679
+ await api.submitResponse(task.id, CLOUD_COORDINATOR_STOPPED_REPLY);
8680
+ log(`replied stopped ${task.id.slice(0, 8)}`);
8681
+ return;
8682
+ }
8683
+ message = parsed.remainder;
8684
+ fresh = readThread(coordinator.id) ?? ensureCloudCoordinator(opts.agent);
8685
+ }
8686
+ if (fresh.status === "running" || fresh.status === "queued") {
8687
+ log(
8688
+ `busy ${task.id.slice(0, 8)} \u2192 coordinator ${fresh.id.slice(0, 8)} (${fresh.status})`
8689
+ );
8690
+ if (task.task_status === "awaiting_confirmation") {
8691
+ await api.approveTask(task.id).catch(() => void 0);
8692
+ }
8693
+ await api.markRunning(task.id).catch(() => void 0);
8694
+ await api.submitResponse(task.id, CLOUD_COORDINATOR_BUSY_REPLY);
8695
+ log(`replied busy ${task.id.slice(0, 8)}`);
8696
+ return;
8697
+ }
8698
+ if (task.task_status === "awaiting_confirmation") {
8699
+ log(`approve ${task.id.slice(0, 8)}`);
8700
+ await api.approveTask(task.id);
8701
+ task = { ...task, task_status: "pending" };
8702
+ }
8703
+ log(
8704
+ `run ${task.id.slice(0, 8)} \u2192 coordinator ${fresh.id.slice(0, 8)} (${inventory.length} workspace${inventory.length === 1 ? "" : "s"})`
8705
+ );
8706
+ await api.markRunning(task.id).catch(() => void 0);
8707
+ const orch = getOrchestrator();
8708
+ const prompt = [
8709
+ coordinatorSystemPrompt({
8710
+ goal: fresh.sourceRef || CLOUD_ORCHESTRATOR_GOAL,
8711
+ parentId: fresh.id,
8712
+ workspaces: inventory,
8713
+ audience: "cloud"
8714
+ }),
8715
+ "",
8716
+ "--- Cloud agent request ---",
8717
+ message
8718
+ ].join("\n");
8719
+ try {
8720
+ await orch.send(fresh.id, prompt);
8721
+ await orch.waitForTurn(fresh.id, 14 * 60 * 1e3);
8722
+ const result = orch.getTurnResult(fresh.id);
8723
+ const reply = result.text.trim() || `(coordinator finished with status ${result.status}, no text)`;
8724
+ await api.submitResponse(task.id, reply);
8725
+ log(`replied ${task.id.slice(0, 8)} (${reply.length} chars)`);
8726
+ } catch (err) {
8727
+ const msg = err instanceof Error ? err.message : String(err);
8728
+ const timedOut = /timed out|timeout/i.test(msg);
8729
+ const reply = timedOut ? CLOUD_COORDINATOR_TIMEOUT_REPLY : `Sideboard coordinator error: ${msg}`;
8730
+ await api.submitResponse(task.id, reply).catch(() => void 0);
8731
+ log(`replied error ${task.id.slice(0, 8)}: ${msg}`);
8732
+ if (!timedOut) throw err;
8733
+ }
8734
+ }
8735
+ async function runCloudConnect(opts) {
8736
+ const log = opts.onLog ?? console.log;
8737
+ const api = opts.api ?? new BrightsySideboardApi(
8738
+ opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : void 0
8739
+ );
8740
+ const enableAccess = opts.enableAccess !== false;
8741
+ const allowAlways = opts.allowAlways !== false;
8742
+ const pollMs = opts.pollIntervalMs ?? POLL_MS;
8743
+ const workspaces = refreshWorkspaces();
8744
+ const coordinator = ensureCloudCoordinator(opts.agent);
8745
+ const access = await api.getAccess();
8746
+ if (!access.enabled) {
8747
+ if (!enableAccess) {
8748
+ throw new Error(
8749
+ "Brightsy desktop access is disabled. Enable Cloud messages in Sideboard Settings \u2192 Brightsy, or Brightsy User Settings."
8750
+ );
8751
+ }
8752
+ log("Enabling Brightsy desktop access\u2026");
8753
+ await api.setAccess(true, allowAlways);
8754
+ } else if (allowAlways && !access.allow_always) {
8755
+ await api.setAccess(true, true);
8756
+ }
8757
+ log(
8758
+ `Connected to Brightsy (${api.endpoint}). Polling desktop tasks every ${pollMs / 1e3}s\u2026`
8759
+ );
8760
+ log(
8761
+ `Global coordinator: ${coordinator.id.slice(0, 8)} agent: ${opts.agent} global chats: ${listGlobalThreads().length}`
8762
+ );
8763
+ log(`Workspaces (${workspaces.length}):`);
8764
+ for (const line of formatWorkspaceInventory(workspaces).split("\n")) {
8765
+ log(` ${line}`);
8766
+ }
8767
+ const inFlight = /* @__PURE__ */ new Set();
8768
+ let consecutivePollFailures = 0;
8769
+ while (!opts.signal?.aborted) {
8770
+ try {
8771
+ const hadFailures = consecutivePollFailures > 0;
8772
+ const [pending, awaiting, running] = await Promise.all([
8773
+ api.getTasks("pending"),
8774
+ api.getTasks("awaiting_confirmation"),
8775
+ api.getTasks("running")
8776
+ ]);
8777
+ consecutivePollFailures = 0;
8778
+ if (hadFailures) log("poll recovered");
8779
+ const tasks = [...awaiting, ...pending, ...running];
8780
+ for (const task of tasks) {
8781
+ if (inFlight.has(task.id)) continue;
8782
+ if (task.task_status === "running") continue;
8783
+ inFlight.add(task.id);
8784
+ const message = taskMessageText(task);
8785
+ if (message && parseForceStopMessage(message).forceStop) {
8786
+ try {
8787
+ getOrchestrator().stop(ensureCloudCoordinator(opts.agent).id);
8788
+ log(
8789
+ `force-stop interrupt ${task.id.slice(0, 8)} \u2192 coordinator`
8790
+ );
8791
+ } catch (err) {
8792
+ const msg = err instanceof Error ? err.message : String(err);
8793
+ log(`force-stop interrupt ${task.id.slice(0, 8)}: ${msg}`);
8794
+ }
8795
+ }
8796
+ void enqueueHandleTask(() => handleTask(api, task, opts)).catch((err) => {
8797
+ const msg = err instanceof Error ? err.message : String(err);
8798
+ log(`error ${task.id.slice(0, 8)}: ${msg}`);
8799
+ }).finally(() => inFlight.delete(task.id));
8800
+ }
8801
+ } catch (err) {
8802
+ consecutivePollFailures += 1;
8803
+ const msg = err instanceof Error ? err.message : String(err);
8804
+ if (consecutivePollFailures === 1) {
8805
+ log(`poll retry: ${msg}`);
8806
+ } else {
8807
+ log(`poll error: ${msg}`);
8808
+ }
8809
+ }
8810
+ await new Promise((resolve) => {
8811
+ const t = setTimeout(resolve, pollMs);
8812
+ opts.signal?.addEventListener(
8813
+ "abort",
8814
+ () => {
8815
+ clearTimeout(t);
8816
+ resolve();
8817
+ },
8818
+ { once: true }
8819
+ );
8820
+ });
8821
+ }
8822
+ }
8823
+
8824
+ // src/slack/oauth.ts
8825
+ import { randomBytes as randomBytes2 } from "crypto";
8826
+ import { createServer as createServer3 } from "http";
8827
+
8828
+ // src/slack/baked-app.ts
8829
+ var BAKED_SLACK_CLIENT_ID = "7592788819232.11813536272562";
8830
+ var BAKED_SLACK_CLIENT_SECRET = "10290abf6ad0dc11fe6fc321a6215299";
8831
+ var BAKED_SLACK_RELAY_URL = "wss://slack-relay.sideboard.cloud/desktop";
8832
+ function hasBakedSlackOAuth() {
8833
+ return Boolean(BAKED_SLACK_CLIENT_ID.trim() && BAKED_SLACK_CLIENT_SECRET.trim());
8834
+ }
8835
+ function slackRelayUrl() {
8836
+ return process.env.SIDEBOARD_SLACK_RELAY_URL?.trim() || BAKED_SLACK_RELAY_URL.trim();
8837
+ }
8838
+
8839
+ // src/slack/oauth.ts
8840
+ var SLACK_OAUTH_PORT = 19847;
8841
+ var SLACK_OAUTH_REDIRECT = `http://127.0.0.1:${SLACK_OAUTH_PORT}/callback`;
8842
+ var SLACK_BOT_SCOPES = [
8843
+ "app_mentions:read",
8844
+ "channels:history",
8845
+ "channels:read",
8846
+ "groups:history",
8847
+ "groups:read",
8848
+ "im:history",
8849
+ "im:read",
8850
+ "im:write",
8851
+ "mpim:history",
8852
+ "mpim:read",
8853
+ "chat:write",
8854
+ "chat:write.public",
8855
+ "reactions:write",
8856
+ "users:read",
8857
+ "users:read.email",
8858
+ "team:read"
8859
+ ].join(",");
8860
+ var SLACK_USER_SCOPES = [
8861
+ "search:read",
8862
+ "channels:history",
8863
+ "channels:read",
8864
+ "groups:history",
8865
+ "im:history",
8866
+ "mpim:history",
8867
+ "chat:write",
8868
+ "users:read"
8869
+ ].join(",");
8870
+ function slackOAuthCredentials() {
8871
+ const settings = loadAppSettings();
8872
+ const clientId = process.env.SIDEBOARD_SLACK_CLIENT_ID?.trim() || settings.integrations.slackClientId?.trim() || BAKED_SLACK_CLIENT_ID.trim();
8873
+ const clientSecret = process.env.SIDEBOARD_SLACK_CLIENT_SECRET?.trim() || settings.integrations.slackClientSecret?.trim() || BAKED_SLACK_CLIENT_SECRET.trim();
8874
+ if (!clientId || !clientSecret) {
8875
+ throw new Error(
8876
+ "Slack browser sign-in needs a Slack app Client ID and Secret (Account \u2192 Slack, or SIDEBOARD_SLACK_CLIENT_ID / SIDEBOARD_SLACK_CLIENT_SECRET). You can still paste an xoxb- or xoxp- token."
8877
+ );
8878
+ }
8879
+ return { clientId, clientSecret };
8880
+ }
8881
+ function slackOAuthAuthorizeUrl(clientId, state) {
8882
+ const params = new URLSearchParams({
8883
+ client_id: clientId,
8884
+ scope: SLACK_BOT_SCOPES,
8885
+ user_scope: SLACK_USER_SCOPES,
8886
+ redirect_uri: SLACK_OAUTH_REDIRECT,
8887
+ state
8888
+ });
8889
+ return `https://slack.com/oauth/v2/authorize?${params.toString()}`;
8890
+ }
8891
+ function htmlPage2(title, body) {
8892
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
8893
+ <style>body{font-family:ui-sans-serif,system-ui,sans-serif;padding:48px 24px;max-width:36rem;margin:0 auto;color:#1a1a1a}
8894
+ h1{font-size:1.25rem}p{line-height:1.5;color:#444}</style></head><body>${body}</body></html>`;
8895
+ }
8896
+ async function startSlackOAuth(opts) {
8897
+ const { clientId, clientSecret } = slackOAuthCredentials();
8898
+ const state = randomBytes2(16).toString("hex");
8899
+ const authorizeUrl = slackOAuthAuthorizeUrl(clientId, state);
8900
+ const timeoutMs = opts?.timeoutMs ?? 5 * 6e4;
8901
+ const code = await new Promise((resolve, reject) => {
8902
+ const server = createServer3((req, res2) => {
8903
+ try {
8904
+ const url = new URL(req.url || "/", `http://127.0.0.1:${SLACK_OAUTH_PORT}`);
8905
+ if (url.pathname !== "/callback") {
8906
+ res2.writeHead(404);
8907
+ res2.end("Not found");
8908
+ return;
8909
+ }
8910
+ const err = url.searchParams.get("error");
8911
+ const gotState = url.searchParams.get("state");
8912
+ const gotCode = url.searchParams.get("code");
8913
+ if (err) {
8914
+ res2.writeHead(400, { "Content-Type": "text/html" });
8915
+ res2.end(htmlPage2("Slack", `<h1>Authorization cancelled</h1><p>${err}</p>`));
8916
+ cleanup();
8917
+ reject(new Error(`Slack OAuth: ${err}`));
8918
+ return;
8919
+ }
8920
+ if (gotState !== state || !gotCode) {
8921
+ res2.writeHead(400, { "Content-Type": "text/html" });
8922
+ res2.end(htmlPage2("Slack", "<h1>Invalid callback</h1><p>State or code missing.</p>"));
8923
+ cleanup();
8924
+ reject(new Error("Slack OAuth callback was invalid"));
8925
+ return;
8926
+ }
8927
+ res2.writeHead(200, { "Content-Type": "text/html" });
8928
+ res2.end(
8929
+ htmlPage2(
8930
+ "Slack connected",
8931
+ "<h1>Slack workspace connected</h1><p>You can close this tab and return to Sideboard.</p>"
8932
+ )
8933
+ );
8934
+ cleanup();
8935
+ resolve(gotCode);
8936
+ } catch (e) {
8937
+ cleanup();
8938
+ reject(e instanceof Error ? e : new Error(String(e)));
8939
+ }
8940
+ });
8941
+ const timer = setTimeout(() => {
8942
+ cleanup();
8943
+ reject(new Error("Slack sign-in timed out \u2014 try again"));
8944
+ }, timeoutMs);
8945
+ const cleanup = () => {
8946
+ clearTimeout(timer);
8947
+ server.close();
8948
+ };
8949
+ server.on("error", (e) => {
8950
+ cleanup();
8951
+ reject(
8952
+ e instanceof Error && e.code === "EADDRINUSE" ? new Error(
8953
+ `Port ${SLACK_OAUTH_PORT} is in use. Close whatever is bound there, or paste a Slack token instead.`
8954
+ ) : e
8955
+ );
8956
+ });
8957
+ server.listen(SLACK_OAUTH_PORT, "127.0.0.1", () => {
8958
+ void Promise.resolve(opts?.openUrl?.(authorizeUrl)).catch((e) => {
8959
+ cleanup();
8960
+ reject(e instanceof Error ? e : new Error(String(e)));
8961
+ });
8962
+ });
8963
+ });
8964
+ const body = new URLSearchParams({
8965
+ client_id: clientId,
8966
+ client_secret: clientSecret,
8967
+ code,
8968
+ redirect_uri: SLACK_OAUTH_REDIRECT
8969
+ });
8970
+ const res = await fetch("https://slack.com/api/oauth.v2.access", {
8971
+ method: "POST",
8972
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
8973
+ body
8974
+ });
8975
+ const data = await res.json();
8976
+ if (!data.ok) {
8977
+ throw new Error(`Slack OAuth exchange failed: ${data.error || res.status}`);
8978
+ }
8979
+ const teamId = data.team?.id?.trim();
8980
+ if (!teamId) throw new Error("Slack OAuth did not return a team id");
8981
+ return upsertSlackWorkspace({
8982
+ team_id: teamId,
8983
+ team_name: data.team?.name?.trim() || teamId,
8984
+ user_id: data.authed_user?.id,
8985
+ bot_token: data.access_token,
8986
+ user_token: data.authed_user?.access_token,
8987
+ scopes: [data.scope, data.authed_user?.scope].filter(Boolean).join(","),
8988
+ connected_at: (/* @__PURE__ */ new Date()).toISOString()
8989
+ });
8990
+ }
8991
+
8992
+ // src/slack/socket-mode.ts
8993
+ import { WebSocket as WsWebSocket } from "ws";
8994
+ var BACKOFF_START_MS = 1e3;
8995
+ var BACKOFF_MAX_MS = 3e4;
8996
+ function stripSlackMentions(text2) {
8997
+ return text2.replace(/<@[^>]+>/g, "").replace(/\s+/g, " ").trim();
8998
+ }
8999
+ function isSlackStopCommand(text2) {
9000
+ const t = stripSlackMentions(text2).toLowerCase();
9001
+ return t === "stop" || t === "sideboard_force_stop";
9002
+ }
9003
+ function parseSlackSocketFrame(raw) {
9004
+ try {
9005
+ const parsed = JSON.parse(raw);
9006
+ if (!parsed || typeof parsed !== "object") return null;
9007
+ return parsed;
9008
+ } catch {
9009
+ return null;
9010
+ }
9011
+ }
9012
+ function inboundFromSocketFrame(frame) {
9013
+ if (frame.type !== "events_api") return null;
9014
+ const event = frame.payload?.event;
9015
+ if (!event) return null;
9016
+ if (event.bot_id) return null;
9017
+ if (event.subtype) return null;
9018
+ const channelId = event.channel?.trim();
9019
+ const ts = event.ts?.trim();
9020
+ if (!channelId || !ts) return null;
9021
+ const rawText = event.text?.trim() ?? "";
9022
+ const text2 = stripSlackMentions(rawText);
9023
+ if (!text2) return null;
9024
+ const teamId = (frame.payload?.team_id || event.team || "").trim();
9025
+ if (!teamId) return null;
9026
+ const isDm = event.type === "message" && (event.channel_type === "im" || event.channel_type === "mpim" || !event.channel_type && channelId.startsWith("D"));
9027
+ const isMention = event.type === "app_mention";
9028
+ if (!isDm && !isMention) return null;
9029
+ return {
9030
+ teamId,
9031
+ channelId,
9032
+ ts,
9033
+ threadTs: event.thread_ts?.trim() || void 0,
9034
+ userId: event.user?.trim(),
9035
+ text: text2,
9036
+ kind: isMention ? "mention" : "dm"
9037
+ };
9038
+ }
9039
+ async function openSlackSocketUrl(appToken, fetchImpl) {
9040
+ const token = appToken.trim();
9041
+ if (!token.startsWith("xapp-")) {
9042
+ throw new Error(
9043
+ "Socket Mode needs an app-level token (xapp-\u2026) with connections:write. Create one on the Slack app \u2192 Basic Information \u2192 App-Level Tokens."
9044
+ );
9045
+ }
9046
+ const doFetch = fetchImpl ?? fetch;
9047
+ const res = await doFetch("https://slack.com/api/apps.connections.open", {
9048
+ method: "POST",
9049
+ headers: {
9050
+ Authorization: `Bearer ${token}`,
9051
+ "Content-Type": "application/x-www-form-urlencoded"
9052
+ },
9053
+ body: new URLSearchParams()
9054
+ });
9055
+ const json = await res.json();
9056
+ if (!json.ok) {
9057
+ throw new SlackApiError("apps.connections.open", json.error || `HTTP ${res.status}`);
9058
+ }
9059
+ const url = json.url?.trim();
9060
+ if (!url) throw new Error("Slack apps.connections.open did not return a WebSocket URL");
9061
+ return url;
9062
+ }
9063
+ function messageToString(data) {
9064
+ if (typeof data === "string") return data;
9065
+ if (Buffer.isBuffer(data)) return data.toString("utf8");
9066
+ if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
9067
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
9068
+ return String(data ?? "");
9069
+ }
9070
+ function nodeWebSocketCtor() {
9071
+ return class NodeSlackWebSocket {
9072
+ socket;
9073
+ constructor(url) {
9074
+ this.socket = new WsWebSocket(url);
9075
+ }
9076
+ send(data) {
9077
+ this.socket.send(data);
9078
+ }
9079
+ close(code, reason) {
9080
+ this.socket.close(code, reason);
9081
+ }
9082
+ addEventListener(type, listener) {
9083
+ if (type === "message") {
9084
+ this.socket.on("message", (data) => listener({ data: messageToString(data) }));
9085
+ return;
9086
+ }
9087
+ this.socket.on(type, () => listener({}));
9088
+ }
9089
+ };
9090
+ }
9091
+ function resolveWebSocket(ctor) {
9092
+ if (ctor) return ctor;
9093
+ const globalWs = globalThis.WebSocket;
9094
+ if (globalWs) return globalWs;
9095
+ return nodeWebSocketCtor();
9096
+ }
9097
+ function wait(ms, signal) {
9098
+ return new Promise((resolve, reject) => {
9099
+ if (signal?.aborted) {
9100
+ reject(new Error("aborted"));
9101
+ return;
9102
+ }
9103
+ const t = setTimeout(resolve, ms);
9104
+ signal?.addEventListener(
9105
+ "abort",
9106
+ () => {
9107
+ clearTimeout(t);
9108
+ reject(new Error("aborted"));
9109
+ },
9110
+ { once: true }
9111
+ );
9112
+ });
9113
+ }
9114
+ async function runSlackSocketMode(opts) {
9115
+ const log = opts.onLog ?? (() => void 0);
9116
+ const Ws = resolveWebSocket(opts.WebSocketImpl);
9117
+ let backoff = BACKOFF_START_MS;
9118
+ while (!opts.signal?.aborted) {
9119
+ let ws = null;
9120
+ try {
9121
+ const url = await openSlackSocketUrl(opts.appToken, opts.fetchImpl);
9122
+ ws = new Ws(url);
9123
+ const session = connectSession(ws, opts, log);
9124
+ backoff = BACKOFF_START_MS;
9125
+ await session;
9126
+ } catch (err) {
9127
+ if (opts.signal?.aborted) return;
9128
+ const msg = err instanceof Error ? err.message : String(err);
9129
+ if (msg === "aborted") return;
9130
+ log(`socket error: ${msg}`);
9131
+ } finally {
9132
+ try {
9133
+ ws?.close();
9134
+ } catch {
9135
+ }
9136
+ }
9137
+ if (opts.signal?.aborted) return;
9138
+ log(`reconnect in ${Math.round(backoff / 1e3)}s`);
9139
+ try {
9140
+ await wait(backoff, opts.signal);
9141
+ } catch {
9142
+ return;
9143
+ }
9144
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
9145
+ }
9146
+ }
9147
+ function connectSession(ws, opts, log) {
9148
+ return new Promise((resolve, reject) => {
9149
+ let settled = false;
9150
+ const finish = (err) => {
9151
+ if (settled) return;
9152
+ settled = true;
9153
+ opts.signal?.removeEventListener("abort", onAbort);
9154
+ if (err) reject(err);
9155
+ else resolve();
9156
+ };
9157
+ const onAbort = () => {
9158
+ try {
9159
+ ws.close();
9160
+ } catch {
9161
+ }
9162
+ finish();
9163
+ };
9164
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
9165
+ ws.addEventListener("open", () => {
9166
+ log("Socket Mode connected");
9167
+ });
9168
+ ws.addEventListener("error", () => {
9169
+ finish(new Error("WebSocket error"));
9170
+ });
9171
+ ws.addEventListener("close", () => {
9172
+ finish();
9173
+ });
9174
+ ws.addEventListener("message", (ev) => {
9175
+ const raw = typeof ev.data === "string" ? ev.data : String(ev.data ?? "");
9176
+ const frame = parseSlackSocketFrame(raw);
9177
+ if (!frame) return;
9178
+ if (frame.envelope_id) {
9179
+ try {
9180
+ ws.send(JSON.stringify({ envelope_id: frame.envelope_id }));
9181
+ } catch {
9182
+ }
9183
+ }
9184
+ if (frame.type === "hello") {
9185
+ log("Socket Mode hello");
9186
+ return;
7281
9187
  }
7282
- );
7283
- server.tool(
7284
- "list_linear_issues",
7285
- "Deprecated: prefer list_issues. Lists assigned Linear issues via the chosen agent MCP connector",
7286
- {
7287
- agent: z.enum(["claude", "codex", "opencode"]),
7288
- repoPath: z.string()
7289
- },
7290
- async ({ agent, repoPath }) => {
7291
- const issues = await listLinearIssues(agent, repoPath);
7292
- return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] };
9188
+ if (frame.type === "disconnect") {
9189
+ log(`socket disconnect: ${frame.reason || "refresh"}`);
9190
+ try {
9191
+ ws.close();
9192
+ } catch {
9193
+ }
9194
+ return;
7293
9195
  }
7294
- );
7295
- }
7296
- const transport = new StdioServerTransport();
7297
- await server.connect(transport);
9196
+ const inbound = inboundFromSocketFrame(frame);
9197
+ if (!inbound) return;
9198
+ void Promise.resolve(opts.onEvent(inbound)).catch((err) => {
9199
+ const msg = err instanceof Error ? err.message : String(err);
9200
+ log(`event error: ${msg}`);
9201
+ });
9202
+ });
9203
+ });
7298
9204
  }
7299
9205
 
7300
- // src/brightsy/api.ts
7301
- function formatBrightsyFetchError(err, url) {
7302
- if (!(err instanceof Error)) return `${String(err)} (${url})`;
7303
- const cause = err.cause;
7304
- let detail = "";
7305
- if (cause instanceof Error) {
7306
- const code = typeof cause.code === "string" ? cause.code : void 0;
7307
- detail = code ? ` [${code}: ${cause.message}]` : ` [${cause.message}]`;
7308
- } else if (cause != null) {
7309
- detail = ` [${String(cause)}]`;
9206
+ // src/slack/reply-target.ts
9207
+ import { existsSync as existsSync15, readFileSync as readFileSync10 } from "fs";
9208
+ import { join as join13 } from "path";
9209
+ function storePath3() {
9210
+ return join13(appDataDir(), "slack-reply-to.json");
9211
+ }
9212
+ function readStore3() {
9213
+ const path = storePath3();
9214
+ if (!existsSync15(path)) return {};
9215
+ try {
9216
+ const parsed = isSecureFileEncrypted(path) ? readSecureJson(path) : JSON.parse(readFileSync10(path, "utf8"));
9217
+ return parsed?.targets && typeof parsed.targets === "object" ? parsed.targets : {};
9218
+ } catch {
9219
+ return {};
7310
9220
  }
7311
- return `${err.message}${detail} (${url})`;
7312
9221
  }
7313
- var BrightsySideboardApi = class {
7314
- cfg;
7315
- fetchImpl;
7316
- constructor(cfgOrOpts) {
7317
- const isOpts = !!cfgOrOpts && typeof cfgOrOpts === "object" && ("fetchImpl" in cfgOrOpts || "cfg" in cfgOrOpts) && !("access_token" in cfgOrOpts);
7318
- if (isOpts) {
7319
- const opts = cfgOrOpts;
7320
- this.cfg = opts.cfg ?? loadBrightsyConfig();
7321
- this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
7322
- } else {
7323
- this.cfg = cfgOrOpts ?? loadBrightsyConfig();
7324
- this.fetchImpl = globalThis.fetch.bind(globalThis);
9222
+ function setSlackReplyTarget(target) {
9223
+ const targets = readStore3();
9224
+ targets[target.threadId] = target;
9225
+ writePrivateFile(storePath3(), `${JSON.stringify({ targets }, null, 2)}
9226
+ `);
9227
+ }
9228
+ function getSlackReplyTarget(threadId) {
9229
+ return readStore3()[threadId] ?? null;
9230
+ }
9231
+
9232
+ // src/slack/relay-protocol.ts
9233
+ import { randomBytes as randomBytes3 } from "crypto";
9234
+ function slackRelaySessionKey(teamId, userId, deviceId) {
9235
+ return `${teamId.trim()}:${userId.trim()}:${deviceId.trim()}`;
9236
+ }
9237
+ function slackRelayUserKey(teamId, userId) {
9238
+ return `${teamId.trim()}:${userId.trim()}`;
9239
+ }
9240
+ function newSlackRelayEventId() {
9241
+ return randomBytes3(12).toString("hex");
9242
+ }
9243
+ function parseSlackRelayClientMessage(raw) {
9244
+ try {
9245
+ const parsed = JSON.parse(raw);
9246
+ if (!parsed || typeof parsed !== "object" || !("type" in parsed)) return null;
9247
+ if (parsed.type === "ping") return { type: "ping" };
9248
+ if (parsed.type === "claim") {
9249
+ const eventId = typeof parsed.eventId === "string" ? parsed.eventId.trim() : "";
9250
+ if (!eventId) return null;
9251
+ return { type: "claim", eventId };
9252
+ }
9253
+ if (parsed.type === "register") {
9254
+ const teamId = typeof parsed.teamId === "string" ? parsed.teamId.trim() : "";
9255
+ const userId = typeof parsed.userId === "string" ? parsed.userId.trim() : "";
9256
+ const deviceId = typeof parsed.deviceId === "string" ? parsed.deviceId.trim() : "";
9257
+ const deviceLabel = typeof parsed.deviceLabel === "string" ? parsed.deviceLabel.trim() : void 0;
9258
+ const botToken = typeof parsed.botToken === "string" ? parsed.botToken.trim() : "";
9259
+ const userToken = typeof parsed.userToken === "string" ? parsed.userToken.trim() : "";
9260
+ if (!teamId || !userId || !deviceId || !botToken || !userToken) return null;
9261
+ return {
9262
+ type: "register",
9263
+ teamId,
9264
+ userId,
9265
+ deviceId,
9266
+ deviceLabel: deviceLabel || void 0,
9267
+ botToken,
9268
+ userToken
9269
+ };
7325
9270
  }
9271
+ return null;
9272
+ } catch {
9273
+ return null;
7326
9274
  }
7327
- get accountId() {
7328
- return this.cfg.account_id;
7329
- }
7330
- get endpoint() {
7331
- return (this.cfg.endpoint || "https://brightsy.ai").replace(/\/$/, "");
7332
- }
7333
- async request(path, init = {}) {
7334
- await this.refreshIfNeeded();
7335
- const url = `${this.endpoint}${path}`;
7336
- const headers = {
7337
- Authorization: `Bearer ${this.cfg.access_token}`,
7338
- Accept: "application/json"
7339
- };
7340
- if (init.body != null) headers["Content-Type"] = "application/json";
7341
- let res;
7342
- try {
7343
- res = await this.fetchImpl(url, {
7344
- method: init.method ?? "GET",
7345
- headers,
7346
- body: init.body != null ? JSON.stringify(init.body) : void 0
7347
- });
7348
- } catch (err) {
7349
- throw new Error(formatBrightsyFetchError(err, url));
9275
+ }
9276
+ function parseSlackRelayServerMessage(raw) {
9277
+ try {
9278
+ const parsed = JSON.parse(raw);
9279
+ if (!parsed || typeof parsed !== "object" || !("type" in parsed)) return null;
9280
+ if (parsed.type === "pong") return { type: "pong" };
9281
+ if (parsed.type === "registered") {
9282
+ const teamId = typeof parsed.teamId === "string" ? parsed.teamId.trim() : "";
9283
+ const userId = typeof parsed.userId === "string" ? parsed.userId.trim() : "";
9284
+ const deviceId = typeof parsed.deviceId === "string" ? parsed.deviceId.trim() : "";
9285
+ if (!teamId || !userId) return null;
9286
+ return { type: "registered", teamId, userId, deviceId: deviceId || "unknown" };
9287
+ }
9288
+ if (parsed.type === "claim_ok" || parsed.type === "claim_denied") {
9289
+ const eventId = typeof parsed.eventId === "string" ? parsed.eventId.trim() : "";
9290
+ if (!eventId) return null;
9291
+ return { type: parsed.type, eventId };
9292
+ }
9293
+ if (parsed.type === "error") {
9294
+ const message = typeof parsed.message === "string" ? parsed.message : "error";
9295
+ return { type: "error", message };
9296
+ }
9297
+ if (parsed.type === "event") {
9298
+ const eventId = typeof parsed.eventId === "string" && parsed.eventId.trim() ? parsed.eventId.trim() : `legacy-${Date.now()}`;
9299
+ const msg = parsed.message;
9300
+ if (!msg || typeof msg !== "object") return null;
9301
+ if (typeof msg.teamId !== "string" || typeof msg.channelId !== "string" || typeof msg.ts !== "string" || typeof msg.text !== "string" || msg.kind !== "dm" && msg.kind !== "mention") {
9302
+ return null;
9303
+ }
9304
+ return { type: "event", eventId, message: msg };
7350
9305
  }
7351
- if (!res.ok) {
7352
- const err = await res.json().catch(() => ({}));
7353
- throw new Error(err.error?.message ?? `${res.status} ${res.statusText} (${url})`);
9306
+ return null;
9307
+ } catch {
9308
+ return null;
9309
+ }
9310
+ }
9311
+
9312
+ // src/slack/relay-client.ts
9313
+ var BACKOFF_START_MS2 = 1e3;
9314
+ var BACKOFF_MAX_MS2 = 3e4;
9315
+ function wait2(ms, signal) {
9316
+ return new Promise((resolve, reject) => {
9317
+ if (signal?.aborted) {
9318
+ reject(new Error("aborted"));
9319
+ return;
7354
9320
  }
7355
- return await res.json();
9321
+ const t = setTimeout(resolve, ms);
9322
+ signal?.addEventListener(
9323
+ "abort",
9324
+ () => {
9325
+ clearTimeout(t);
9326
+ reject(new Error("aborted"));
9327
+ },
9328
+ { once: true }
9329
+ );
9330
+ });
9331
+ }
9332
+ function send(ws, msg) {
9333
+ ws.send(JSON.stringify(msg));
9334
+ }
9335
+ async function runSlackRelayClient(opts) {
9336
+ const log = opts.onLog ?? (() => void 0);
9337
+ const Ws = resolveWebSocket(opts.WebSocketImpl);
9338
+ const url = opts.url.trim();
9339
+ const deviceId = opts.deviceId.trim();
9340
+ if (!url) throw new Error("Slack relay URL is empty");
9341
+ if (!deviceId) throw new Error("Slack relay needs a deviceId for this Mac");
9342
+ if (opts.workspaces.length === 0) {
9343
+ throw new Error(
9344
+ "Slack relay needs a connected workspace with bot + user tokens (Add via browser)."
9345
+ );
7356
9346
  }
7357
- async refreshIfNeeded() {
7358
- const expiresAt = this.cfg.expires_at ?? 0;
7359
- if (!this.cfg.refresh_token || Date.now() < expiresAt - 6e4) return;
7360
- const clientId = this.cfg.oauth_client_id || "brightsy-cli";
7361
- const url = `${this.endpoint}/oauth/token`;
7362
- let res;
9347
+ let backoff = BACKOFF_START_MS2;
9348
+ while (!opts.signal?.aborted) {
9349
+ let ws = null;
7363
9350
  try {
7364
- res = await this.fetchImpl(url, {
7365
- method: "POST",
7366
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
7367
- body: new URLSearchParams({
7368
- grant_type: "refresh_token",
7369
- refresh_token: this.cfg.refresh_token,
7370
- client_id: clientId
7371
- })
7372
- });
9351
+ ws = new Ws(url);
9352
+ await connectSession2(ws, opts, log);
9353
+ backoff = BACKOFF_START_MS2;
7373
9354
  } catch (err) {
7374
- throw new Error(formatBrightsyFetchError(err, url));
9355
+ if (opts.signal?.aborted) return;
9356
+ const msg = err instanceof Error ? err.message : String(err);
9357
+ if (msg === "aborted") return;
9358
+ log(`relay error: ${msg}`);
9359
+ } finally {
9360
+ try {
9361
+ ws?.close();
9362
+ } catch {
9363
+ }
7375
9364
  }
7376
- if (!res.ok) return;
7377
- const data = await res.json();
7378
- if (!data.access_token) return;
7379
- this.cfg.access_token = data.access_token;
7380
- if (data.refresh_token) this.cfg.refresh_token = data.refresh_token;
7381
- if (data.expires_in) {
7382
- this.cfg.expires_at = Date.now() + data.expires_in * 1e3;
9365
+ if (opts.signal?.aborted) return;
9366
+ log(`relay reconnect in ${Math.round(backoff / 1e3)}s`);
9367
+ try {
9368
+ await wait2(backoff, opts.signal);
9369
+ } catch {
9370
+ return;
7383
9371
  }
7384
- saveBrightsyConfig(this.cfg);
9372
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS2);
7385
9373
  }
7386
- async getAccess() {
7387
- return this.request(
7388
- `/api/v1beta/desktop/access?accountId=${encodeURIComponent(this.accountId)}`
9374
+ }
9375
+ function connectSession2(ws, opts, log) {
9376
+ return new Promise((resolve, reject) => {
9377
+ let settled = false;
9378
+ const pending = /* @__PURE__ */ new Map();
9379
+ const allowedUsers = new Set(
9380
+ opts.workspaces.map((w) => `${w.teamId.trim()}:${w.userId.trim()}`)
7389
9381
  );
7390
- }
7391
- async setAccess(enabled, allow_always = false) {
7392
- return this.request("/api/v1beta/desktop/access", {
7393
- method: "PUT",
7394
- body: {
7395
- accountId: this.accountId,
7396
- enabled,
7397
- allow_always
9382
+ const finish = (err) => {
9383
+ if (settled) return;
9384
+ settled = true;
9385
+ pending.clear();
9386
+ opts.signal?.removeEventListener("abort", onAbort);
9387
+ if (err) reject(err);
9388
+ else resolve();
9389
+ };
9390
+ const onAbort = () => {
9391
+ try {
9392
+ ws.close();
9393
+ } catch {
9394
+ }
9395
+ finish();
9396
+ };
9397
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
9398
+ ws.addEventListener("open", () => {
9399
+ const label = opts.deviceLabel?.trim() || opts.deviceId.trim();
9400
+ log(`Relay connected \xB7 ${label}`);
9401
+ for (const wsInfo of opts.workspaces) {
9402
+ send(ws, {
9403
+ type: "register",
9404
+ teamId: wsInfo.teamId,
9405
+ userId: wsInfo.userId,
9406
+ deviceId: opts.deviceId.trim(),
9407
+ deviceLabel: opts.deviceLabel?.trim() || void 0,
9408
+ botToken: wsInfo.botToken,
9409
+ userToken: wsInfo.userToken
9410
+ });
7398
9411
  }
7399
9412
  });
7400
- }
7401
- async getTasks(status) {
7402
- const params = new URLSearchParams();
7403
- if (status) params.set("status", status);
7404
- params.set("accountId", this.accountId);
7405
- const q = `?${params.toString()}`;
7406
- const data = await this.request(
7407
- `/api/v1beta/desktop/tasks${q}`
7408
- );
7409
- return data.tasks ?? [];
7410
- }
7411
- async approveTask(taskId) {
7412
- await this.request(`/api/v1beta/desktop/tasks/${taskId}`, {
7413
- method: "PATCH",
7414
- body: { action: "approve" }
9413
+ ws.addEventListener("error", () => {
9414
+ finish(new Error("WebSocket error"));
7415
9415
  });
7416
- }
7417
- async markRunning(taskId) {
7418
- await this.request(`/api/v1beta/desktop/tasks/${taskId}`, {
7419
- method: "PATCH",
7420
- body: { task_status: "running" }
9416
+ ws.addEventListener("close", () => {
9417
+ finish();
7421
9418
  });
7422
- }
7423
- async submitResponse(taskId, response) {
7424
- await this.request(`/api/v1beta/desktop/tasks/${taskId}/response`, {
7425
- method: "POST",
7426
- body: { response }
9419
+ ws.addEventListener("message", (ev) => {
9420
+ const raw = typeof ev.data === "string" ? ev.data : String(ev.data ?? "");
9421
+ const msg = parseSlackRelayServerMessage(raw);
9422
+ if (!msg) return;
9423
+ if (msg.type === "pong") return;
9424
+ if (msg.type === "error") {
9425
+ log(`relay error: ${msg.message}`);
9426
+ return;
9427
+ }
9428
+ if (msg.type === "registered") {
9429
+ log(
9430
+ msg.deviceId && msg.deviceId !== "unknown" ? `Relay registered ${msg.teamId}/${msg.userId} \xB7 device ${msg.deviceId.slice(0, 8)}\u2026` : `Relay registered ${msg.teamId}/${msg.userId}`
9431
+ );
9432
+ return;
9433
+ }
9434
+ if (msg.type === "event") {
9435
+ const key = `${msg.message.teamId.trim()}:${(msg.message.userId ?? "").trim()}`;
9436
+ if (!allowedUsers.has(key)) {
9437
+ log(`relay skip: event for ${key} not owned by this desktop`);
9438
+ return;
9439
+ }
9440
+ pending.set(msg.eventId, msg.message);
9441
+ send(ws, { type: "claim", eventId: msg.eventId });
9442
+ const eventId = msg.eventId;
9443
+ setTimeout(() => {
9444
+ const inbound = pending.get(eventId);
9445
+ if (!inbound) return;
9446
+ pending.delete(eventId);
9447
+ void Promise.resolve(opts.onEvent(inbound)).catch((err) => {
9448
+ const errMsg = err instanceof Error ? err.message : String(err);
9449
+ log(`event error: ${errMsg}`);
9450
+ });
9451
+ }, 400);
9452
+ return;
9453
+ }
9454
+ if (msg.type === "claim_denied") {
9455
+ pending.delete(msg.eventId);
9456
+ return;
9457
+ }
9458
+ if (msg.type === "claim_ok") {
9459
+ const inbound = pending.get(msg.eventId);
9460
+ pending.delete(msg.eventId);
9461
+ if (!inbound) return;
9462
+ void Promise.resolve(opts.onEvent(inbound)).catch((err) => {
9463
+ const errMsg = err instanceof Error ? err.message : String(err);
9464
+ log(`event error: ${errMsg}`);
9465
+ });
9466
+ }
7427
9467
  });
7428
- }
7429
- };
7430
- function taskMessageText(task) {
7431
- const parts = task.message?.parts ?? [];
7432
- const text = parts.find((p) => p.kind === "text")?.text ?? "";
7433
- return text.trim();
9468
+ });
7434
9469
  }
7435
9470
 
7436
- // src/brightsy/cloud-connect.ts
7437
- var POLL_MS = 5e3;
7438
- var handleTaskChain = Promise.resolve();
7439
- function enqueueHandleTask(fn) {
7440
- const run2 = handleTaskChain.then(fn, fn);
7441
- handleTaskChain = run2.then(
9471
+ // src/slack/listen.ts
9472
+ var SLACK_LISTEN_BUSY_REPLY = [
9473
+ "Sideboard is busy with an in-progress turn.",
9474
+ "I did not start a new request.",
9475
+ 'Send "stop" to interrupt, or try again when idle.'
9476
+ ].join(" ");
9477
+ var SLACK_LISTEN_STOPPED_REPLY = "Sideboard stopped the in-progress turn. Send another message when you want to continue.";
9478
+ var SLACK_LISTEN_TIMEOUT_REPLY = [
9479
+ "Sideboard timed out waiting for the local agent turn to finish.",
9480
+ 'Send "stop" to interrupt, or try again.'
9481
+ ].join(" ");
9482
+ var handleChain = Promise.resolve();
9483
+ function enqueueHandle(fn) {
9484
+ const run2 = handleChain.then(fn, fn);
9485
+ handleChain = run2.then(
7442
9486
  () => void 0,
7443
9487
  () => void 0
7444
9488
  );
7445
9489
  return run2;
7446
9490
  }
7447
- function refreshWorkspaces() {
7448
- const orch = getOrchestrator();
7449
- return orch.listWorkspaces();
9491
+ function refreshWorkspaces2() {
9492
+ return getOrchestrator().listWorkspaces();
9493
+ }
9494
+ function formatSlackInboundPrompt(msg) {
9495
+ const kind = msg.kind === "mention" ? "Slack @mention" : "Slack DM";
9496
+ return `${kind}
9497
+
9498
+ ${msg.text}`;
9499
+ }
9500
+ function isSlackInboundUserPrompt(text2) {
9501
+ return text2.startsWith("Slack DM\n") || text2.startsWith("Slack @mention\n");
9502
+ }
9503
+ function formatSlackSignedReply(deviceLabel, text2) {
9504
+ const label = deviceLabel.trim();
9505
+ const body = text2.trim();
9506
+ if (!body) return body;
9507
+ if (!label) return body;
9508
+ const head = `${label}:`;
9509
+ if (body.slice(0, head.length).toLowerCase() === head.toLowerCase()) {
9510
+ return body;
9511
+ }
9512
+ return `${label}: ${body}`;
9513
+ }
9514
+ function signForThisMac(text2) {
9515
+ return formatSlackSignedReply(ensureSlackDeviceIdentity().deviceLabel, text2);
9516
+ }
9517
+ function slackReplyThreadTs(msg) {
9518
+ if (msg.threadTs && msg.threadTs !== msg.ts) return msg.threadTs;
9519
+ if (msg.kind === "mention") return msg.ts;
9520
+ return void 0;
9521
+ }
9522
+ var SLACK_SEEN_REACTION = "+1";
9523
+ function writeTokenForTeam(teamId) {
9524
+ const workspaces = listSlackWorkspacesRaw();
9525
+ const ws = workspaces.find((w) => w.team_id === teamId);
9526
+ if (!ws) {
9527
+ const connected = workspaces.map((w) => `${w.team_name} (${w.team_id})`).join(", ");
9528
+ throw new Error(
9529
+ connected ? `No connected Slack workspace for team ${teamId}. Connected: ${connected}` : `No connected Slack workspace for team ${teamId}`
9530
+ );
9531
+ }
9532
+ return slackTokenFor(ws, "write");
9533
+ }
9534
+ async function ackSlackInboundSeen(msg, opts) {
9535
+ const log = opts.onLog ?? (() => void 0);
9536
+ try {
9537
+ if (opts.addReaction) {
9538
+ await opts.addReaction(msg, SLACK_SEEN_REACTION);
9539
+ return;
9540
+ }
9541
+ await slackApi(
9542
+ writeTokenForTeam(msg.teamId),
9543
+ "reactions.add",
9544
+ {
9545
+ channel: msg.channelId,
9546
+ timestamp: msg.ts,
9547
+ name: SLACK_SEEN_REACTION
9548
+ },
9549
+ opts.fetchImpl
9550
+ );
9551
+ } catch (err) {
9552
+ if (err instanceof SlackApiError && err.slackError === "already_reacted") return;
9553
+ const errMsg = err instanceof Error ? err.message : String(err);
9554
+ const hint = err instanceof SlackApiError && err.slackError === "missing_scope" ? " \u2014 reconnect Slack in Account to grant reactions:write" : "";
9555
+ log(`react error: ${errMsg}${hint}`);
9556
+ }
9557
+ }
9558
+ async function postSlackText(target, text2, opts) {
9559
+ const stub = {
9560
+ teamId: target.teamId,
9561
+ channelId: target.channelId,
9562
+ ts: target.threadTs ?? "",
9563
+ threadTs: target.threadTs,
9564
+ text: "",
9565
+ kind: "dm"
9566
+ };
9567
+ if (opts.postReply) {
9568
+ await opts.postReply(stub, text2);
9569
+ return;
9570
+ }
9571
+ const token = writeTokenForTeam(target.teamId);
9572
+ await slackApi(
9573
+ token,
9574
+ "chat.postMessage",
9575
+ {
9576
+ channel: target.channelId,
9577
+ text: text2,
9578
+ thread_ts: target.threadTs
9579
+ },
9580
+ opts.fetchImpl
9581
+ );
9582
+ }
9583
+ async function postSlackReply(msg, text2, opts) {
9584
+ await postSlackText(
9585
+ {
9586
+ teamId: msg.teamId,
9587
+ channelId: msg.channelId,
9588
+ threadTs: slackReplyThreadTs(msg)
9589
+ },
9590
+ signForThisMac(text2),
9591
+ opts
9592
+ );
9593
+ }
9594
+ var lastRelayed = /* @__PURE__ */ new Map();
9595
+ function markRelayed(threadId, text2) {
9596
+ lastRelayed.set(threadId, `${threadId}:${text2}`);
7450
9597
  }
7451
- async function handleTask(api, task, opts) {
9598
+ function alreadyRelayed(threadId, text2) {
9599
+ return lastRelayed.get(threadId) === `${threadId}:${text2}`;
9600
+ }
9601
+ async function relayCoordinatorReplyToSlack(threadId, opts) {
9602
+ const target = getSlackReplyTarget(threadId);
9603
+ if (!target) return;
9604
+ const thread = readThread(threadId);
9605
+ const lastUser = thread ? [...thread.messages].reverse().find((m) => m.role === "user") : void 0;
9606
+ if (lastUser && isSlackInboundUserPrompt(lastUser.text)) return;
9607
+ const result = getOrchestrator().getTurnResult(threadId);
9608
+ const text2 = result.text.trim();
9609
+ if (!text2) return;
9610
+ if (alreadyRelayed(threadId, text2)) return;
9611
+ markRelayed(threadId, text2);
7452
9612
  const log = opts.onLog ?? (() => void 0);
7453
- let message = taskMessageText(task);
7454
- if (!message) {
7455
- log(`skip ${task.id.slice(0, 8)}: empty message`);
7456
- return;
7457
- }
7458
- if (task.task_status !== "pending" && task.task_status !== "running" && task.task_status !== "awaiting_confirmation") {
7459
- return;
9613
+ try {
9614
+ await postSlackText(target, signForThisMac(text2), opts);
9615
+ log(`replied ${target.threadTs ?? target.channelId} (${text2.length} chars)`);
9616
+ } catch (err) {
9617
+ lastRelayed.delete(threadId);
9618
+ const errMsg = err instanceof Error ? err.message : String(err);
9619
+ log(`relay error: ${errMsg}`);
7460
9620
  }
7461
- const workspaces = refreshWorkspaces();
9621
+ }
9622
+ async function handleSlackInbound(msg, opts) {
9623
+ const log = opts.onLog ?? (() => void 0);
9624
+ await ackSlackInboundSeen(msg, opts);
9625
+ const agent = coerceOrchestratorAgent(opts.agent ?? getDefaultAgent());
9626
+ const workspaces = refreshWorkspaces2();
7462
9627
  for (const ws of workspaces) {
7463
9628
  await getOrchestrator().reconcile(ws.path).catch(() => void 0);
7464
9629
  }
7465
9630
  const inventory = await enrichWorkspacesWithGithub(workspaces);
7466
- const coordinator = ensureCloudCoordinator(opts.agent);
7467
- let fresh = readThread(coordinator.id) ?? coordinator;
7468
- const parsed = parseForceStopMessage(message);
7469
- if (parsed.forceStop) {
9631
+ const userId = msg.userId?.trim();
9632
+ if (!userId) {
9633
+ log(`skip ${msg.kind} ${msg.ts}: missing Slack user id`);
9634
+ return;
9635
+ }
9636
+ const coordinator = ensureSlackCoordinator(msg.teamId, userId, agent);
9637
+ const fresh = readThread(coordinator.id) ?? coordinator;
9638
+ if (isSlackStopCommand(msg.text)) {
7470
9639
  try {
7471
9640
  getOrchestrator().stop(fresh.id);
7472
- log(`force-stop ${task.id.slice(0, 8)} \u2192 coordinator ${fresh.id.slice(0, 8)}`);
9641
+ log(`stop ${msg.kind} ${msg.ts} \u2192 coordinator ${fresh.id.slice(0, 8)}`);
7473
9642
  } catch (err) {
7474
- const msg = err instanceof Error ? err.message : String(err);
7475
- log(`force-stop ${task.id.slice(0, 8)}: ${msg}`);
7476
- }
7477
- if (!parsed.remainder) {
7478
- if (task.task_status === "awaiting_confirmation") {
7479
- await api.approveTask(task.id).catch(() => void 0);
7480
- }
7481
- await api.markRunning(task.id).catch(() => void 0);
7482
- await api.submitResponse(task.id, CLOUD_COORDINATOR_STOPPED_REPLY);
7483
- log(`replied stopped ${task.id.slice(0, 8)}`);
7484
- return;
9643
+ const errMsg = err instanceof Error ? err.message : String(err);
9644
+ log(`stop ${msg.ts}: ${errMsg}`);
7485
9645
  }
7486
- message = parsed.remainder;
7487
- fresh = readThread(coordinator.id) ?? ensureCloudCoordinator(opts.agent);
9646
+ await postSlackReply(msg, SLACK_LISTEN_STOPPED_REPLY, opts);
9647
+ log(`replied stopped ${msg.ts}`);
9648
+ return;
7488
9649
  }
7489
9650
  if (fresh.status === "running" || fresh.status === "queued") {
7490
- log(
7491
- `busy ${task.id.slice(0, 8)} \u2192 coordinator ${fresh.id.slice(0, 8)} (${fresh.status})`
7492
- );
7493
- if (task.task_status === "awaiting_confirmation") {
7494
- await api.approveTask(task.id).catch(() => void 0);
7495
- }
7496
- await api.markRunning(task.id).catch(() => void 0);
7497
- await api.submitResponse(task.id, CLOUD_COORDINATOR_BUSY_REPLY);
7498
- log(`replied busy ${task.id.slice(0, 8)}`);
9651
+ log(`busy ${msg.kind} ${msg.ts} \u2192 coordinator ${fresh.id.slice(0, 8)} (${fresh.status})`);
9652
+ await postSlackReply(msg, SLACK_LISTEN_BUSY_REPLY, opts);
9653
+ log(`replied busy ${msg.ts}`);
7499
9654
  return;
7500
9655
  }
7501
- if (task.task_status === "awaiting_confirmation") {
7502
- log(`approve ${task.id.slice(0, 8)}`);
7503
- await api.approveTask(task.id);
7504
- task = { ...task, task_status: "pending" };
7505
- }
7506
9656
  log(
7507
- `run ${task.id.slice(0, 8)} \u2192 coordinator ${fresh.id.slice(0, 8)} (${inventory.length} workspace${inventory.length === 1 ? "" : "s"})`
9657
+ `run ${msg.kind} ${msg.ts} user ${userId} \u2192 coordinator ${fresh.id.slice(0, 8)} (${inventory.length} workspace${inventory.length === 1 ? "" : "s"})`
7508
9658
  );
7509
- await api.markRunning(task.id).catch(() => void 0);
9659
+ const prompt = formatSlackInboundPrompt(msg);
9660
+ setSlackReplyTarget({
9661
+ threadId: fresh.id,
9662
+ teamId: msg.teamId,
9663
+ channelId: msg.channelId,
9664
+ threadTs: slackReplyThreadTs(msg)
9665
+ });
7510
9666
  const orch = getOrchestrator();
7511
- const prompt = [
7512
- coordinatorSystemPrompt({
7513
- goal: fresh.sourceRef || CLOUD_ORCHESTRATOR_GOAL,
7514
- parentId: fresh.id,
7515
- workspaces: inventory,
7516
- audience: "cloud"
7517
- }),
7518
- "",
7519
- "--- Cloud agent request ---",
7520
- message
7521
- ].join("\n");
9667
+ let reply;
7522
9668
  try {
7523
9669
  await orch.send(fresh.id, prompt);
7524
9670
  await orch.waitForTurn(fresh.id, 14 * 60 * 1e3);
7525
- const result = orch.getTurnResult(fresh.id);
7526
- const reply = result.text.trim() || `(coordinator finished with status ${result.status}, no text)`;
7527
- await api.submitResponse(task.id, reply);
7528
- log(`replied ${task.id.slice(0, 8)} (${reply.length} chars)`);
9671
+ reply = orch.getTurnResult(fresh.id).text.trim();
7529
9672
  } catch (err) {
7530
- const msg = err instanceof Error ? err.message : String(err);
7531
- const timedOut = /timed out|timeout/i.test(msg);
7532
- const reply = timedOut ? CLOUD_COORDINATOR_TIMEOUT_REPLY : `Sideboard coordinator error: ${msg}`;
7533
- await api.submitResponse(task.id, reply).catch(() => void 0);
7534
- log(`replied error ${task.id.slice(0, 8)}: ${msg}`);
9673
+ const errMsg = err instanceof Error ? err.message : String(err);
9674
+ const timedOut = /timed out|timeout/i.test(errMsg);
9675
+ const errorReply = timedOut ? SLACK_LISTEN_TIMEOUT_REPLY : `Sideboard coordinator error: ${errMsg}`;
9676
+ await postSlackReply(msg, errorReply, opts).catch((postErr) => {
9677
+ const postMsg = postErr instanceof Error ? postErr.message : String(postErr);
9678
+ log(`post error: ${postMsg}`);
9679
+ });
9680
+ log(`replied error ${msg.ts}: ${errMsg}`);
7535
9681
  if (!timedOut) throw err;
9682
+ return;
9683
+ }
9684
+ if (!reply) {
9685
+ log(`post error: no agent text to send for ${msg.ts}`);
9686
+ return;
9687
+ }
9688
+ if (alreadyRelayed(fresh.id, reply)) {
9689
+ log(`turn finished ${msg.ts} (already posted)`);
9690
+ return;
9691
+ }
9692
+ try {
9693
+ await postSlackReply(msg, reply, opts);
9694
+ markRelayed(fresh.id, reply);
9695
+ log(`replied ${msg.ts} (${reply.length} chars)`);
9696
+ } catch (err) {
9697
+ const errMsg = err instanceof Error ? err.message : String(err);
9698
+ log(`post error: ${errMsg}`);
7536
9699
  }
7537
9700
  }
7538
- async function runCloudConnect(opts) {
9701
+ function ownedSlackUserKeys() {
9702
+ const keys = /* @__PURE__ */ new Set();
9703
+ for (const w of listSlackWorkspacesRaw()) {
9704
+ const team = w.team_id?.trim();
9705
+ const user = w.user_id?.trim();
9706
+ if (team && user) keys.add(`${team}:${user}`);
9707
+ }
9708
+ return keys;
9709
+ }
9710
+ function isInboundForThisDesktop(msg) {
9711
+ const userId = msg.userId?.trim();
9712
+ if (!userId) return false;
9713
+ const owned = ownedSlackUserKeys();
9714
+ return owned.has(`${msg.teamId.trim()}:${userId}`);
9715
+ }
9716
+ async function runSlackListen(opts = {}) {
7539
9717
  const log = opts.onLog ?? console.log;
7540
- const api = opts.api ?? new BrightsySideboardApi(
7541
- opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : void 0
7542
- );
7543
- const enableAccess = opts.enableAccess !== false;
7544
- const allowAlways = opts.allowAlways !== false;
7545
- const pollMs = opts.pollIntervalMs ?? POLL_MS;
7546
- const workspaces = refreshWorkspaces();
7547
- const coordinator = ensureCloudCoordinator(opts.agent);
7548
- const access = await api.getAccess();
7549
- if (!access.enabled) {
7550
- if (!enableAccess) {
9718
+ const connected = listSlackWorkspacesRaw();
9719
+ const orch = getOrchestrator();
9720
+ const off = orch.on((event) => {
9721
+ if (event.type !== "turn_finished") return;
9722
+ void relayCoordinatorReplyToSlack(event.threadId, opts);
9723
+ });
9724
+ const onInbound = (msg) => enqueueHandle(async () => {
9725
+ if (!isInboundForThisDesktop(msg)) {
9726
+ log(
9727
+ `skip ${msg.kind} ${msg.ts}: Slack user ${msg.userId ?? "?"} is not connected on this Mac`
9728
+ );
9729
+ return;
9730
+ }
9731
+ await handleSlackInbound(msg, opts);
9732
+ });
9733
+ try {
9734
+ const relay = (opts.relayUrl ?? slackRelayUrl()).trim();
9735
+ if (!relay) {
9736
+ throw new Error("Slack Listen needs the hosted relay URL.");
9737
+ }
9738
+ const withIdentity = connected.filter(
9739
+ (w) => w.bot_token?.trim() && w.user_token?.trim() && w.user_id?.trim()
9740
+ );
9741
+ if (withIdentity.length === 0) {
7551
9742
  throw new Error(
7552
- "Brightsy desktop access is disabled. Enable Cloud messages in Sideboard Settings \u2192 Brightsy, or Brightsy User Settings."
9743
+ "Slack Listen needs Add via browser (bot token + your Slack user id). Paste-only bot tokens cannot prove which Slack user owns this Mac."
7553
9744
  );
7554
9745
  }
7555
- log("Enabling Brightsy desktop access\u2026");
7556
- await api.setAccess(true, allowAlways);
7557
- } else if (allowAlways && !access.allow_always) {
7558
- await api.setAccess(true, true);
9746
+ const device = ensureSlackDeviceIdentity();
9747
+ log(
9748
+ `Relay listen \xB7 ${device.deviceLabel} \xB7 ${withIdentity.length} workspace${withIdentity.length === 1 ? "" : "s"} as Slack user: ${withIdentity.map((w) => `${w.team_name}/${w.user_id}`).join(", ")}`
9749
+ );
9750
+ await runSlackRelayClient({
9751
+ url: relay,
9752
+ signal: opts.signal,
9753
+ onLog: log,
9754
+ WebSocketImpl: opts.WebSocketImpl,
9755
+ deviceId: device.deviceId,
9756
+ deviceLabel: device.deviceLabel,
9757
+ workspaces: withIdentity.map((w) => ({
9758
+ teamId: w.team_id,
9759
+ userId: w.user_id.trim(),
9760
+ botToken: w.bot_token.trim(),
9761
+ userToken: w.user_token.trim()
9762
+ })),
9763
+ onEvent: onInbound
9764
+ });
9765
+ } finally {
9766
+ off();
7559
9767
  }
7560
- log(
7561
- `Connected to Brightsy (${api.endpoint}). Polling desktop tasks every ${pollMs / 1e3}s\u2026`
7562
- );
7563
- log(
7564
- `Global coordinator: ${coordinator.id.slice(0, 8)} agent: ${opts.agent} global chats: ${listGlobalThreads().length}`
7565
- );
7566
- log(`Workspaces (${workspaces.length}):`);
7567
- for (const line of formatWorkspaceInventory(workspaces).split("\n")) {
7568
- log(` ${line}`);
9768
+ }
9769
+ function resolveSlackListenMode(opts) {
9770
+ const relay = (opts?.relayUrl ?? slackRelayUrl()).trim();
9771
+ const count = opts?.workspaceCount ?? listSlackWorkspacesRaw().filter(
9772
+ (w) => w.bot_token?.trim() && w.user_token?.trim() && w.user_id?.trim()
9773
+ ).length;
9774
+ if (relay && count > 0) return "relay";
9775
+ return null;
9776
+ }
9777
+
9778
+ // src/slack/relay-hub.ts
9779
+ function parseSlackDeviceDestination(text2) {
9780
+ const m = text2.match(/^\s*(?:to\s+)?(?:@|#)?([A-Za-z][\w-]{0,63})\s*[::]\s*/);
9781
+ if (!m) return { label: null, rest: text2 };
9782
+ return { label: m[1], rest: text2.slice(m[0].length) };
9783
+ }
9784
+ var SlackRelayHub = class {
9785
+ sessions = /* @__PURE__ */ new Map();
9786
+ bySocket = /* @__PURE__ */ new Map();
9787
+ pendingClaims = /* @__PURE__ */ new Map();
9788
+ seq = 0;
9789
+ fetchImpl;
9790
+ authTest;
9791
+ log;
9792
+ constructor(opts = {}) {
9793
+ this.fetchImpl = opts.fetchImpl;
9794
+ this.log = opts.onLog ?? (() => void 0);
9795
+ this.authTest = opts.authTest ?? (async (token) => slackApi(token, "auth.test", void 0, this.fetchImpl));
9796
+ }
9797
+ sessionFor(teamId, userId, deviceId) {
9798
+ return this.sessions.get(slackRelaySessionKey(teamId, userId, deviceId)) ?? null;
9799
+ }
9800
+ sessionsForUser(teamId, userId) {
9801
+ const t = teamId.trim();
9802
+ const u = userId.trim();
9803
+ return [...this.sessions.values()].filter((s) => s.teamId === t && s.userId === u);
9804
+ }
9805
+ listSessions() {
9806
+ return [...this.sessions.values()];
9807
+ }
9808
+ detachSocket(socket) {
9809
+ const key = this.bySocket.get(socket);
9810
+ this.bySocket.delete(socket);
9811
+ if (!key) return;
9812
+ const current = this.sessions.get(key);
9813
+ if (current?.socket === socket) {
9814
+ this.sessions.delete(key);
9815
+ this.log(`desktop disconnected ${key}`);
9816
+ }
9817
+ }
9818
+ async handleClientMessage(socket, msg) {
9819
+ if (msg.type === "ping") {
9820
+ this.send(socket, { type: "pong" });
9821
+ return;
9822
+ }
9823
+ if (msg.type === "claim") {
9824
+ this.handleClaim(socket, msg.eventId);
9825
+ return;
9826
+ }
9827
+ if (msg.type === "register") {
9828
+ await this.register(
9829
+ socket,
9830
+ msg.teamId,
9831
+ msg.userId,
9832
+ msg.deviceId,
9833
+ msg.deviceLabel,
9834
+ msg.botToken,
9835
+ msg.userToken
9836
+ );
9837
+ }
7569
9838
  }
7570
- const inFlight = /* @__PURE__ */ new Set();
7571
- let consecutivePollFailures = 0;
7572
- while (!opts.signal?.aborted) {
9839
+ async register(socket, teamId, userId, deviceId, deviceLabel, botToken, userToken) {
9840
+ const claimedTeam = teamId.trim();
9841
+ const claimedUser = userId.trim();
9842
+ const claimedDevice = deviceId.trim();
9843
+ const label = (deviceLabel?.trim() || claimedDevice).slice(0, 64);
9844
+ const bot = botToken.trim();
9845
+ const user = userToken.trim();
9846
+ if (!claimedTeam || !claimedUser || !claimedDevice || !bot || !user) {
9847
+ this.send(socket, {
9848
+ type: "error",
9849
+ message: "register needs teamId, userId, deviceId, botToken, and userToken"
9850
+ });
9851
+ return;
9852
+ }
7573
9853
  try {
7574
- const hadFailures = consecutivePollFailures > 0;
7575
- const [pending, awaiting, running] = await Promise.all([
7576
- api.getTasks("pending"),
7577
- api.getTasks("awaiting_confirmation"),
7578
- api.getTasks("running")
7579
- ]);
7580
- consecutivePollFailures = 0;
7581
- if (hadFailures) log("poll recovered");
7582
- const tasks = [...awaiting, ...pending, ...running];
7583
- for (const task of tasks) {
7584
- if (inFlight.has(task.id)) continue;
7585
- if (task.task_status === "running") continue;
7586
- inFlight.add(task.id);
7587
- const message = taskMessageText(task);
7588
- if (message && parseForceStopMessage(message).forceStop) {
7589
- try {
7590
- getOrchestrator().stop(ensureCloudCoordinator(opts.agent).id);
7591
- log(
7592
- `force-stop interrupt ${task.id.slice(0, 8)} \u2192 coordinator`
7593
- );
7594
- } catch (err) {
7595
- const msg = err instanceof Error ? err.message : String(err);
7596
- log(`force-stop interrupt ${task.id.slice(0, 8)}: ${msg}`);
7597
- }
7598
- }
7599
- void enqueueHandleTask(() => handleTask(api, task, opts)).catch((err) => {
7600
- const msg = err instanceof Error ? err.message : String(err);
7601
- log(`error ${task.id.slice(0, 8)}: ${msg}`);
7602
- }).finally(() => inFlight.delete(task.id));
9854
+ const userAuth = await this.authTest(user);
9855
+ const userTeam = userAuth.team_id?.trim();
9856
+ const userAuthId = userAuth.user_id?.trim();
9857
+ if (!userTeam || !userAuthId) {
9858
+ this.send(socket, {
9859
+ type: "error",
9860
+ message: "user token auth.test did not return team_id/user_id"
9861
+ });
9862
+ return;
9863
+ }
9864
+ if (userTeam !== claimedTeam || userAuthId !== claimedUser) {
9865
+ this.send(socket, {
9866
+ type: "error",
9867
+ message: `User token identity ${userTeam}/${userAuthId} does not match claimed ${claimedTeam}/${claimedUser}`
9868
+ });
9869
+ this.log(
9870
+ `register rejected: claimed ${claimedTeam}/${claimedUser} user-token ${userTeam}/${userAuthId}`
9871
+ );
9872
+ return;
9873
+ }
9874
+ const botAuth = await this.authTest(bot);
9875
+ const botTeam = botAuth.team_id?.trim();
9876
+ if (!botTeam) {
9877
+ this.send(socket, {
9878
+ type: "error",
9879
+ message: "bot token auth.test did not return team_id"
9880
+ });
9881
+ return;
9882
+ }
9883
+ if (botTeam !== claimedTeam) {
9884
+ this.send(socket, {
9885
+ type: "error",
9886
+ message: `Bot token team_id ${botTeam} does not match claimed ${claimedTeam}`
9887
+ });
9888
+ this.log(`register rejected: claimed team ${claimedTeam} bot ${botTeam}`);
9889
+ return;
7603
9890
  }
7604
9891
  } catch (err) {
7605
- consecutivePollFailures += 1;
7606
- const msg = err instanceof Error ? err.message : String(err);
7607
- if (consecutivePollFailures === 1) {
7608
- log(`poll retry: ${msg}`);
7609
- } else {
7610
- log(`poll error: ${msg}`);
9892
+ const errMsg = err instanceof Error ? err.message : String(err);
9893
+ this.send(socket, { type: "error", message: `auth.test failed: ${errMsg}` });
9894
+ return;
9895
+ }
9896
+ const key = slackRelaySessionKey(claimedTeam, claimedUser, claimedDevice);
9897
+ const prev = this.sessions.get(key);
9898
+ if (prev && prev.socket !== socket) {
9899
+ try {
9900
+ prev.socket.close(4e3, "replaced");
9901
+ } catch {
9902
+ }
9903
+ this.bySocket.delete(prev.socket);
9904
+ }
9905
+ const session = {
9906
+ id: `s${++this.seq}`,
9907
+ teamId: claimedTeam,
9908
+ userId: claimedUser,
9909
+ deviceId: claimedDevice,
9910
+ deviceLabel: label,
9911
+ socket,
9912
+ connectedAt: Date.now()
9913
+ };
9914
+ this.sessions.set(key, session);
9915
+ this.bySocket.set(socket, key);
9916
+ this.send(socket, {
9917
+ type: "registered",
9918
+ teamId: claimedTeam,
9919
+ userId: claimedUser,
9920
+ deviceId: claimedDevice
9921
+ });
9922
+ this.log(`desktop registered ${key} (${label})`);
9923
+ }
9924
+ /**
9925
+ * Fan-out an inbound event to matching device(s) for that Slack user.
9926
+ * Optional leading `Personal:` / `Work:` selects by device label.
9927
+ * Desktops must claim; first claim wins.
9928
+ */
9929
+ routeEvent(message) {
9930
+ const userId = message.userId?.trim();
9931
+ if (!userId) {
9932
+ this.log(`skip event ${message.ts}: missing user id`);
9933
+ return false;
9934
+ }
9935
+ const online = this.sessionsForUser(message.teamId, userId);
9936
+ if (online.length === 0) {
9937
+ this.log(`no desktop for ${slackRelayUserKey(message.teamId, userId)} \u2014 skip`);
9938
+ return false;
9939
+ }
9940
+ const { label, rest } = parseSlackDeviceDestination(message.text);
9941
+ let targets = online;
9942
+ let outbound = message;
9943
+ if (label) {
9944
+ const needle = label.toLowerCase();
9945
+ targets = online.filter((s) => s.deviceLabel.toLowerCase() === needle);
9946
+ if (targets.length === 0) {
9947
+ this.log(
9948
+ `no device labeled "${label}" for ${slackRelayUserKey(message.teamId, userId)} \u2014 skip (online: ${online.map((s) => s.deviceLabel).join(", ")})`
9949
+ );
9950
+ return false;
7611
9951
  }
9952
+ outbound = { ...message, text: rest };
7612
9953
  }
7613
- await new Promise((resolve) => {
7614
- const t = setTimeout(resolve, pollMs);
7615
- opts.signal?.addEventListener(
7616
- "abort",
7617
- () => {
7618
- clearTimeout(t);
7619
- resolve();
7620
- },
7621
- { once: true }
7622
- );
9954
+ const eventId = newSlackRelayEventId();
9955
+ this.pendingClaims.set(eventId, {
9956
+ winnerKey: null,
9957
+ offered: new Set(
9958
+ targets.map((s) => slackRelaySessionKey(s.teamId, s.userId, s.deviceId))
9959
+ )
7623
9960
  });
9961
+ setTimeout(() => this.pendingClaims.delete(eventId), 6e4).unref?.();
9962
+ for (const session of targets) {
9963
+ this.send(session.socket, { type: "event", eventId, message: outbound });
9964
+ }
9965
+ this.log(
9966
+ `fan-out ${eventId} \u2192 ${targets.map((s) => s.deviceLabel || s.deviceId).join(", ")}${label ? ` (dest ${label})` : ""}`
9967
+ );
9968
+ return true;
9969
+ }
9970
+ handleClaim(socket, eventId) {
9971
+ const key = this.bySocket.get(socket);
9972
+ if (!key) {
9973
+ this.send(socket, { type: "error", message: "claim before register" });
9974
+ return;
9975
+ }
9976
+ const pending = this.pendingClaims.get(eventId);
9977
+ if (!pending || !pending.offered.has(key)) {
9978
+ this.send(socket, { type: "claim_denied", eventId });
9979
+ return;
9980
+ }
9981
+ if (!pending.winnerKey) {
9982
+ pending.winnerKey = key;
9983
+ this.send(socket, { type: "claim_ok", eventId });
9984
+ this.log(`claim_ok ${eventId} \u2192 ${key}`);
9985
+ return;
9986
+ }
9987
+ this.send(socket, { type: "claim_denied", eventId });
9988
+ }
9989
+ send(socket, msg) {
9990
+ try {
9991
+ socket.send(JSON.stringify(msg));
9992
+ } catch (err) {
9993
+ const errMsg = err instanceof Error ? err.message : String(err);
9994
+ this.log(`send error: ${errMsg}`);
9995
+ }
9996
+ }
9997
+ };
9998
+
9999
+ // src/slack/relay-server.ts
10000
+ import { createServer as createServer4 } from "http";
10001
+ import { WebSocketServer } from "ws";
10002
+ async function startSlackRelayServer(opts) {
10003
+ const log = opts.onLog ?? console.log;
10004
+ const appToken = opts.appToken.trim();
10005
+ if (!appToken.startsWith("xapp-")) {
10006
+ throw new Error("SIDEBOARD_SLACK_APP_TOKEN must be an xapp-\u2026 app-level token");
7624
10007
  }
10008
+ const hub = opts.hub ?? new SlackRelayHub({
10009
+ fetchImpl: opts.fetchImpl,
10010
+ onLog: log
10011
+ });
10012
+ const httpServer = createServer4((req, res) => {
10013
+ if (req.url === "/health" || req.url === "/") {
10014
+ res.writeHead(200, { "Content-Type": "application/json" });
10015
+ res.end(
10016
+ JSON.stringify({
10017
+ ok: true,
10018
+ service: "sideboard-slack-relay",
10019
+ sessions: hub.listSessions().length
10020
+ })
10021
+ );
10022
+ return;
10023
+ }
10024
+ res.writeHead(404);
10025
+ res.end("Not found");
10026
+ });
10027
+ const wss = new WebSocketServer({ server: httpServer, path: "/desktop" });
10028
+ wss.on("connection", (ws, _req) => {
10029
+ const socket = {
10030
+ send: (data) => {
10031
+ if (ws.readyState === ws.OPEN) ws.send(data);
10032
+ },
10033
+ close: (code, reason) => {
10034
+ try {
10035
+ ws.close(code, reason);
10036
+ } catch {
10037
+ }
10038
+ }
10039
+ };
10040
+ log("desktop connected");
10041
+ ws.on("message", (data) => {
10042
+ const raw = typeof data === "string" ? data : data.toString("utf8");
10043
+ const msg = parseSlackRelayClientMessage(raw);
10044
+ if (!msg) {
10045
+ socket.send(JSON.stringify({ type: "error", message: "invalid message" }));
10046
+ return;
10047
+ }
10048
+ void hub.handleClientMessage(socket, msg);
10049
+ });
10050
+ ws.on("close", () => {
10051
+ hub.detachSocket(socket);
10052
+ });
10053
+ ws.on("error", () => {
10054
+ hub.detachSocket(socket);
10055
+ });
10056
+ });
10057
+ const port = opts.port ?? 0;
10058
+ const host = opts.host ?? "0.0.0.0";
10059
+ await new Promise((resolve, reject) => {
10060
+ httpServer.once("error", reject);
10061
+ httpServer.listen(port, host, () => resolve());
10062
+ });
10063
+ const address = httpServer.address();
10064
+ const boundPort = typeof address === "object" && address ? address.port : typeof port === "number" ? port : 0;
10065
+ const url = `ws://${host === "0.0.0.0" ? "127.0.0.1" : host}:${boundPort}/desktop`;
10066
+ log(`relay listening on ${url}`);
10067
+ const ac = new AbortController();
10068
+ const onAbort = () => ac.abort();
10069
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
10070
+ const socketModeDone = runSlackSocketMode({
10071
+ appToken,
10072
+ signal: ac.signal,
10073
+ fetchImpl: opts.fetchImpl,
10074
+ onLog: (line) => log(line),
10075
+ onEvent: (msg) => {
10076
+ hub.routeEvent(msg);
10077
+ }
10078
+ }).catch((err) => {
10079
+ if (ac.signal.aborted) return;
10080
+ const errMsg = err instanceof Error ? err.message : String(err);
10081
+ log(`socket mode ended: ${errMsg}`);
10082
+ });
10083
+ return {
10084
+ port: boundPort,
10085
+ url,
10086
+ hub,
10087
+ close: async () => {
10088
+ opts.signal?.removeEventListener("abort", onAbort);
10089
+ ac.abort();
10090
+ await new Promise((resolve) => {
10091
+ wss.close(() => resolve());
10092
+ });
10093
+ await new Promise((resolve, reject) => {
10094
+ httpServer.close((err) => err ? reject(err) : resolve());
10095
+ });
10096
+ await socketModeDone.catch(() => void 0);
10097
+ }
10098
+ };
7625
10099
  }
7626
10100
  export {
7627
10101
  ATTACHMENTS_DIR,
10102
+ BAKED_SLACK_RELAY_URL,
7628
10103
  BRIGHTSY_MCP_ALLOWED_TOOLS,
7629
10104
  BrightsySideboardApi,
7630
10105
  CLAUDE_MODEL_CATALOG,
@@ -7643,6 +10118,9 @@ export {
7643
10118
  LEGACY_ATTACHMENTS_DIR,
7644
10119
  LEGACY_PLAN_FILE_REL,
7645
10120
  LEGACY_REVIEW_REQUEST_PATH,
10121
+ LINEAR_OAUTH_PORT,
10122
+ LINEAR_OAUTH_REDIRECT,
10123
+ LINEAR_OAUTH_SCOPES,
7646
10124
  MAX_ANTHROPIC_CACHE_CONTROL_BLOCKS,
7647
10125
  ORCHESTRATOR_AGENT_KINDS,
7648
10126
  Orchestrator,
@@ -7659,7 +10137,16 @@ export {
7659
10137
  SIDEBOARD_FORCE_STOP,
7660
10138
  SIDEBOARD_MCP_ALLOWED_TOOLS,
7661
10139
  SIDEBOARD_MCP_PROFILE_ENV,
10140
+ SLACK_LISTEN_BUSY_REPLY,
10141
+ SLACK_LISTEN_STOPPED_REPLY,
10142
+ SLACK_LISTEN_TIMEOUT_REPLY,
10143
+ SLACK_OAUTH_PORT,
10144
+ SLACK_OAUTH_REDIRECT,
10145
+ SLACK_REPLY_FORMATTING,
10146
+ SLACK_SEEN_REACTION,
10147
+ SlackRelayHub,
7662
10148
  THINKING_EFFORTS,
10149
+ ackSlackInboundSeen,
7663
10150
  addPrStackLayer,
7664
10151
  addStackLayerFromThread,
7665
10152
  addWorkspace,
@@ -7701,8 +10188,10 @@ export {
7701
10188
  buildReviewRequestAttachment,
7702
10189
  buildSessionSeed,
7703
10190
  buildWorkspaceScriptEnv,
10191
+ caffeinateHoldPath,
7704
10192
  caffeinateWhileCloudConnectEnabled,
7705
10193
  caffeinateWhileRunningEnabled,
10194
+ caffeinateWhileSlackListenEnabled,
7706
10195
  captureLoginEnv,
7707
10196
  captureTurnBaseline,
7708
10197
  checkoutPrStackLayer,
@@ -7720,6 +10209,7 @@ export {
7720
10209
  conductorDbPath,
7721
10210
  confirmLand,
7722
10211
  connectBrightsyTeam,
10212
+ connectSlackToken,
7723
10213
  coordinatorSystemPrompt,
7724
10214
  coordinatorTurnReminder,
7725
10215
  copyConfiguredFiles,
@@ -7728,6 +10218,7 @@ export {
7728
10218
  createEmptyThread,
7729
10219
  createExistingBranchWorktree,
7730
10220
  createGlobalChat,
10221
+ createLinearPkce,
7731
10222
  createOrUpdatePr,
7732
10223
  createPrStack,
7733
10224
  createThread,
@@ -7742,7 +10233,11 @@ export {
7742
10233
  detectGhStack,
7743
10234
  detectLocalMergeConflicts,
7744
10235
  disconnectBrightsyTeam,
10236
+ disconnectLinear,
10237
+ disconnectLinearConnection,
10238
+ disconnectSlackWorkspace,
7745
10239
  discoverSkills,
10240
+ dismissSlackReplyBadge,
7746
10241
  dropCachedPrefixOnResume,
7747
10242
  encodeBrightsyTarget,
7748
10243
  enrichPathWithNpmGlobalBin,
@@ -7752,6 +10247,8 @@ export {
7752
10247
  ensureGhPreferOrigin,
7753
10248
  ensureGlobalCoordinatorCwd,
7754
10249
  ensureReviewRequestFile,
10250
+ ensureSlackCoordinator,
10251
+ ensureSlackDeviceIdentity,
7755
10252
  ensureWorkspace,
7756
10253
  estimateMessageChars,
7757
10254
  estimateThreadChars,
@@ -7780,6 +10277,8 @@ export {
7780
10277
  formatPlanQuestionsForChat,
7781
10278
  formatRateLimitResetHint,
7782
10279
  formatRenameBranchDirective,
10280
+ formatSlackInboundPrompt,
10281
+ formatSlackSignedReply,
7783
10282
  formatTranscriptMarkdown,
7784
10283
  formatWorkspaceInventory,
7785
10284
  formatWorktreeDirective,
@@ -7787,6 +10286,7 @@ export {
7787
10286
  getAdapter,
7788
10287
  getAgentSetupInfo,
7789
10288
  getBrightsySession,
10289
+ getCaffeinateHold,
7790
10290
  getDefaultAgent,
7791
10291
  getDefaultEffort,
7792
10292
  getDefaultFast,
@@ -7797,6 +10297,7 @@ export {
7797
10297
  getGitHubStatus,
7798
10298
  getIssueSource,
7799
10299
  getLinearApiKey,
10300
+ getLinearAuthToken,
7800
10301
  getOrchestrator,
7801
10302
  getPr,
7802
10303
  getPrChecks,
@@ -7806,12 +10307,16 @@ export {
7806
10307
  getRepoSetupInfo,
7807
10308
  getRunMode,
7808
10309
  getRunScript,
10310
+ getSlackWorkspace,
7809
10311
  gh,
7810
10312
  ghHeadRef,
7811
10313
  ghRepoSelectArgs,
7812
10314
  git,
7813
10315
  globalAgentCwd,
10316
+ handleSlackInbound,
7814
10317
  harnessEnvKey,
10318
+ hasBakedLinearOAuth,
10319
+ hasBakedSlackOAuth,
7815
10320
  hasConductorHook,
7816
10321
  hasCursorWorktreeSetup,
7817
10322
  hasRepoHook,
@@ -7836,6 +10341,7 @@ export {
7836
10341
  isGlobalThread,
7837
10342
  isImageFilePath,
7838
10343
  isInPrStack,
10344
+ isInboundForThisDesktop,
7839
10345
  isLinearConnected,
7840
10346
  isOrchestratorCapableAgent,
7841
10347
  isOrchestratorThread,
@@ -7844,8 +10350,12 @@ export {
7844
10350
  isPresentPlanToolName,
7845
10351
  isSessionQuotaLimit,
7846
10352
  isSideboardScratchPath,
10353
+ isSlackCoordinatorThread,
7847
10354
  isThinkingEffort,
7848
10355
  isWorkspaceScratchPath,
10356
+ linearAuthorizationHeader,
10357
+ linearOAuthAuthorizeUrl,
10358
+ linearOAuthCredentials,
7849
10359
  listAgentSetupInfo,
7850
10360
  listBranchCommits,
7851
10361
  listBranches,
@@ -7864,6 +10374,8 @@ export {
7864
10374
  listOpencodeModels,
7865
10375
  listPrs,
7866
10376
  listRunScripts,
10377
+ listSlackReplyBadges,
10378
+ listSlackWorkspaces,
7867
10379
  listThreads,
7868
10380
  listWorkspaces,
7869
10381
  listWorktreeFiles,
@@ -7907,9 +10419,13 @@ export {
7907
10419
  parseMcpList,
7908
10420
  parsePlanQuestionsInput,
7909
10421
  parseSessionQuotaResetAt,
10422
+ parseSlackRelayClientMessage,
10423
+ parseSlackRelayServerMessage,
7910
10424
  partsToAssistantText,
7911
10425
  pastedTextStats,
10426
+ permalinkForSlackReplyBadge,
7912
10427
  permissionMode,
10428
+ persistVaultKeyInKeychain,
7913
10429
  planFileAbs,
7914
10430
  posixShellSingleQuote,
7915
10431
  prepareTerminalCommand,
@@ -7917,13 +10433,16 @@ export {
7917
10433
  promptMentionsBrightsy,
7918
10434
  pushBranch,
7919
10435
  readExistingReviewRequestFile,
10436
+ readKeychainVaultKey,
7920
10437
  readPlanFile,
7921
10438
  readSkillBody,
7922
10439
  readThread,
7923
10440
  readWorktreeFile,
7924
10441
  readWorktreeFileForUpload,
7925
10442
  readWorktreeInclude,
10443
+ recordSlackOutboundWatch,
7926
10444
  refreshGitHubAuth,
10445
+ refreshSlackReplyBadges,
7927
10446
  removeWorkspace,
7928
10447
  removeWorktree,
7929
10448
  repoSlug,
@@ -7948,18 +10467,26 @@ export {
7948
10467
  resolveQuotaFallbackAgent,
7949
10468
  resolveRepoRoot,
7950
10469
  resolveReviewGuidelines,
10470
+ resolveSlackListenMode,
7951
10471
  resolveThreadDefaults,
7952
10472
  resolveThreadEffort,
10473
+ resolveVaultKey,
7953
10474
  resolveWorktreeStartPoint,
7954
10475
  run,
7955
10476
  runArchiveScript,
7956
10477
  runCloudConnect,
7957
10478
  runCursorWorktreeSetup,
7958
10479
  runSetupScript,
10480
+ runSlackListen,
10481
+ runSlackRelayClient,
7959
10482
  sameWorktreePath,
7960
10483
  sanitizeMcpServerName,
7961
10484
  saveAppSettings,
10485
+ saveLinearOAuth,
10486
+ secureFileUnlocksWith,
10487
+ setCaffeinateHold,
7962
10488
  setStatus,
10489
+ setVaultMasterKey,
7963
10490
  settingsSourceLabel,
7964
10491
  shouldAttachPastedText,
7965
10492
  shouldCompactContext,
@@ -7970,6 +10497,12 @@ export {
7970
10497
  sideboardMcpProfile,
7971
10498
  sideboardReposDir,
7972
10499
  sideboardWorkspacesDir,
10500
+ slackAppLevelToken,
10501
+ slackArchiveUrl,
10502
+ slackCoordinatorSourceRef,
10503
+ slackListenEnabled,
10504
+ slackOAuthCredentials,
10505
+ slackRelayUrl,
7973
10506
  slugify,
7974
10507
  spawnAgentTurn,
7975
10508
  splitForCompaction,
@@ -7979,8 +10512,11 @@ export {
7979
10512
  stageAbsolutePathsAsAttachments,
7980
10513
  stageBuffersAsAttachments,
7981
10514
  startDevServer,
10515
+ startLinearOAuth,
7982
10516
  startMcpServer,
7983
10517
  startOrchestration,
10518
+ startSlackOAuth,
10519
+ startSlackRelayServer,
7984
10520
  stripBrightsyNdjsonNoise,
7985
10521
  submitPrStack,
7986
10522
  suggestSlug,
@@ -7998,6 +10534,7 @@ export {
7998
10534
  threadRequestsBrightsyMcp,
7999
10535
  threadsDir,
8000
10536
  threadsSharingWorktree,
10537
+ toPublicAppSettings,
8001
10538
  toolDescription,
8002
10539
  toolDetail,
8003
10540
  toolFilePath,